Compare commits

...
26 Commits
Author SHA1 Message Date
Ian Renton 203758fa2d Partial fix for mypy issues 2026-09-20 20:42:16 +01:00
Ian Renton 93ea27510f Autogenerated type safety parameterisation of all methods 2026-09-20 20:16:31 +01:00
Ian Renton 324dd1414b Autogenerated type safety parameterisation of all methods 2026-09-20 20:02:19 +01:00
Ian Renton 6037e742cc Fix mojibake in DX cluster and RBN spot comments where the source uses UTF-8 2026-09-20 19:11:53 +01:00
Ian Renton 9272766ffb Fix order in which grids are parsed out of comments 2026-09-20 19:01:48 +01:00
Ian Renton 6f96e0ede1 Improve some activity detection 2026-09-20 10:32:53 +01:00
Ian Renton 190c9f6cef Improve handling of GMA SOTA spots, SOTA references in comments, and grids in comments on VHF 2026-09-20 10:25:08 +01:00
Ian Renton d33259d619 Fix a bug in PNP spot handling where an exception would be thrown if an activity had a location name but not a reference ID. 2026-09-20 10:10:39 +01:00
Ian Renton 2df7ecf4b5 Prevent ruff running on a couple of lines where catching and not logging an exception is a deliberate choice. 2026-09-20 09:55:22 +01:00
Ian Renton 1b41520f7c Merge branch 'main' into 147-sig-activity-changes
# Conflicts:
#	core/constants.py
#	pyproject.toml
2026-09-20 09:51:29 +01:00
Ian Renton c1c9bcd5ae Merge branch 'main' into 147-sig-activity-changes 2026-09-18 21:35:56 +01:00
Ian Renton a24e03028b Restrict the activities that we display on the alerts page's filters to just ones that can actually be in alerts #147 2026-09-18 21:20:42 +01:00
Ian Renton 54d779f036 Allow QRP spots from PnP APIs #147 2026-09-18 20:56:46 +01:00
Ian Renton 7bdc26c926 Activity filter on upcoming page #147 2026-09-18 20:49:24 +01:00
Ian Renton eea045e4b8 Merge branch 'main' into 147-sig-activity-changes
# Conflicts:
#	providers/activityrefdata/activity_ref_data_provider.py
2026-09-18 18:58:22 +01:00
Ian Renton c2c3981cda If a spot has no other activity but its DX call does match a call from an ongoing DXpedition, assign that activity to it. Closes #147 2026-09-18 18:45:06 +01:00
Ian Renton 1a1743d11c ruff fixes 2026-09-18 18:38:35 +01:00
Ian Renton 40033d122e Add a has_refs field to activities #147 2026-09-18 18:28:30 +01:00
Ian Renton e5caf7353d Refactor activity names to use an enum instead of a string to avoid typos #147 2026-09-18 18:09:41 +01:00
Ian Renton ab79e7e01c More About page tweaks. #147 2026-09-18 17:31:31 +01:00
Ian Renton 75905c5ef7 Add /AM and /MM activities, fix up activities list in OpenAPI spec, few more About page tweaks. #147 2026-09-18 17:29:21 +01:00
Ian Renton 0ad0d25c70 Add QRP and RaDAR Rally activities 2026-09-18 17:14:24 +01:00
Ian Renton 51798fbc81 Doc updates 2026-09-18 17:10:30 +01:00
Ian Renton cea90695ba Now that SIGs are rebranded as Activities, we can merge DXpedition and Contest into that without it being too weird, and get rid of AlertType #147 2026-09-18 16:50:00 +01:00
Ian Renton bf9b4ac68c In the JS UI, turn each category of Activity into a collapsible section to save space #147 2026-09-18 15:45:23 +01:00
Ian Renton 81cd686a00 Giant refactor to rebrand "SIG" as "Activity" anywhere that doesn't touch API or config file (which is to be addressed in a future breaking change). #147 2026-09-18 14:54:11 +01:00
170 changed files with 3402 additions and 2675 deletions
+11 -22
View File
@@ -3,6 +3,8 @@
Spothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open
JSON API as well as a website to browse the data, and its own telnet server for integration with desktop loggers.
A "main" or example server runs at [https://spothole.app](https://spothole.app/) in case you'd like to try it out.
![Screenshot](/images/screenshot.png)
While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outdoor
@@ -20,6 +22,8 @@ SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, LLOTA, WWTOTA
Repeater Network, NG3K, and any site based on the xOTA software by nischu. It also integrates with QRZ.com and HamQTH,
retrieves solar data from various sources, provides information about upcoming contests, and more.
You can read more about Spothole on [the "About" page of the main server instance](https://spothole.app/about).
![Screenshot](/images/screenshot2.png)
![Screenshot](/images/screenshot3.png)
@@ -48,7 +52,9 @@ Spothole server, or to modify it in any way. More details can be found in the fo
## Thanks
As well as being my work, I have also gratefully received feature patches from Steven, M1SDH.
[Spothole's "About" page](https://spothole.app/about) contains the full details of the thanks I owe to the various
programme teams, software library providers, bug fixers etc. The extra detail below contains the extra detail of
specific files in this repository which are not all my own work and may be subject to other licences.
The project contains GeoJSON files for CQ and ITU zones, in the `/datafiles/` directory. These are MIT-licenced and, to
my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet. `/datafiles` also contains a
@@ -60,26 +66,9 @@ conference centres.
The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the
`/static/img/flags/` directory.
The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries.
This project would not have been possible without these libraries, so many thanks to their developers.
### Third Party Libraries
A number of third-party libraries are self-hosted in the `/static/vendor/` directory. These files are subject to their
own licences and are not covered by the overall licence declared in the `LICENSE` file.
A number of third-party libraries are self-hosted in the `/webassets/vendor/` directory. These files are subject to
their own licences and are not covered by the overall licence declared in the `LICENSE` file.
The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries
which are self-hosted in the `/static/vendor/` directory. This project would not have been possible without these
libraries, so many thanks to their developers. These libraries are subject to their own licences and are not covered by
the overall licence declared in Spothole's `LICENSE` file.
Sound effects are CC0-licenced and sourced from [Pixabay](https://pixabay.com).
Particular thanks go to country-files.com for providing country lookup data for amateur radio, to K0SWE
for [this JSON-formatted DXCC data](https://github.com/k0swe/dxcc-json/), and to the developers of `pyhamtools` for
making it easy to use country-files.com data as well as QRZ.com and Clublog lookup.
Amateur radio clusters, outdoor programmes, propagation data providers etc. are almost all volunteer-run services that
make no or little profit, and are done for the love of amateur radio. Services like Spothole, which build on top of
them, are truly standing on the shoulders of giants. None of this would have been possible without the hard work and
dedication of many other people within the amaetur radio community.
The project's name was suggested by Harm, DK4HAA. Thanks!
+6 -6
View File
@@ -128,12 +128,12 @@ spot_providers:
name: "C3 TOTA"
enabled: false
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, an activity must be set manually here because xOTA is a generic backend for xOTA
# programmes and so different URLs potentially provide different programmes.
sig: "Toilets"
# 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
# but refers to a toilet in a different building (or continent!)
# For Toilets on the Air, we prefix the activity 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 but refers to a toilet in a different building (or continent!)
sig_ref_prefix: "C3"
- class: "XOTA"
@@ -216,8 +216,8 @@ static_data_providers:
enabled: true
# SIG reference data providers to use. These allow Spothole to download, for example, the WWFF directory that maps WWFF
# park IDs to their name and location.
# Activity reference data providers to use. These allow 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
+158
View File
@@ -0,0 +1,158 @@
import logging
import re
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
from core.activity_utils import get_activity_by_name
from core.data_store import DATA_STORE
from core.enums import ActivityName, ActivityRefType
from core.geo_utils import wab_wai_square_to_lat_lon
from data.activity_ref import ActivityRef
logger = logging.getLogger(__name__)
def get_activity_ref_info(activity_name: str | None, ref_id: str | None) -> ActivityRef | None:
"""Look up details of an activity reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in an
activity name and a reference ID (both strings) and returns an ActivityRef object populated with as much data
as we can find. This makes use of activity ref data in the data store, live lookups from the web, or just
automatic calculation depending on which activity we are getting data for."""
if activity_name is None or activity_name == "" or ref_id is None or ref_id == "":
logger.debug("Failed to look up activity ref info, activity or ref were not set.")
return None
# Sometimes we allow spaces instead of dashes in references due to common usage that way, but official reference
# lists never do, so convert them here.
ref_id = ref_id.replace(" ", "-")
# Prepare the object to be returned
activity_ref = ActivityRef(sig=activity_name, id=ref_id)
# We can always get the reference type and the icon from the activity itself
activity = get_activity_by_name(activity_name)
if activity:
activity_ref.ref_type = activity.ref_type
activity_ref.icon = activity.icon
try:
### FUDGES ###
#
# DME fudge. Our database has leading zeros padding to 5 digits which is the expected format, but not all
# activators add leading zeros. We also need to normalise "DME 01234" to "DME-01234" to match what's in our
# database.
if activity_name.upper() == ActivityName.DME:
match = re.match(r"DME[\- ](\d{3,5})", ref_id, re.IGNORECASE)
if match:
number = match.group(1)
ref_id = f"DME-{number.zfill(5)}"
# DTMBA spotters sometimes include spaces and dashes, our regex allows them but they must be removed here so we
# can look up against the official list which doesn't have them
if activity_name.upper() == ActivityName.DTMBA:
ref_id = ref_id.replace("-", "").replace(" ", "")
### NO DATA ACTIVITIES ###
#
# If the activity is HEMA or BIWOTA, we have no way to either generate useful data or look it up on a
# reference list, so just skip the lookup here.
if activity_name.upper() == ActivityName.HEMA or activity_name.upper() == ActivityName.BIWOTA:
return activity_ref
### PROGRAMMATIC DATA GENERATION INSTEAD OF LOOKUPS ###
#
# If the activity 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.
if activity_name.upper() == ActivityName.TILES.upper():
# Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same
if not activity_ref.name:
activity_ref.name = activity_ref.id
if not activity_ref.grid:
activity_ref.grid = activity_ref.id
if activity_ref.grid and (not activity_ref.latitude or not activity_ref.longitude):
ll = locator_to_latlong(str(activity_ref.grid))
activity_ref.latitude = ll[0]
activity_ref.longitude = ll[1]
return activity_ref
elif activity_name.upper() == ActivityName.WAB or activity_name.upper() == ActivityName.WAI:
ll = wab_wai_square_to_lat_lon(ref_id)
if ll:
activity_ref.name = ref_id
try:
activity_ref.grid = latlong_to_locator(ll[0], ll[1], 6)
activity_ref.latitude = ll[0]
activity_ref.longitude = ll[1]
except Exception:
logger.warning("Invalid lat/lon received for WAB/WAI reference", exc_info=True)
return activity_ref
elif activity_name.upper() == ActivityName.BOTA:
# For BOTA all we can ever generate is the URL, there is no data file or lookup for lat/longs
if not activity_ref.name:
activity_ref.name = activity_ref.id
if activity_ref.name:
activity_ref.url = (
f"https://www.beachesontheair.com/beaches/{activity_ref.name.lower().replace(' ', '-')}"
)
return activity_ref
elif activity_name.upper() == ActivityName.GMA_ISLANDS.upper():
# GMA Islands is a bit of a mess of GMA and IOTA references. Try looking them both up and see what returns
# the best result.
iota_lookup = get_activity_ref_info(ActivityName.IOTA, ref_id)
gma_lookup = get_activity_ref_info(ActivityName.GMA, ref_id)
if iota_lookup:
for key, value in iota_lookup.__dict__.items():
if value is not None and activity_ref.__dict__.get(key) is None:
activity_ref.__dict__[key] = value
if gma_lookup:
for key, value in gma_lookup.__dict__.items():
if value is not None and activity_ref.__dict__.get(key) is None:
activity_ref.__dict__[key] = value
activity_ref.ref_type = ActivityRefType.ISLAND
return activity_ref
### ACTUAL LOOKUP ###
#
# OK, this is something we have to look up. Now check to see if our data store contains reference data and if
# so, copy the data into the activity_ref object
key = f"{activity_name}:{ref_id}"
try:
lookup_data = DATA_STORE.activity_refs.get(key) if key in DATA_STORE.activity_refs else None
if lookup_data:
for attr, value in lookup_data.__dict__.items():
if value is not None and activity_ref.__dict__.get(attr) is None:
activity_ref.__dict__[attr] = value
else:
# Maybe a super new reference we don't know about yet, but more likely a typo or a test reference,
# just silently ignore it.
logger.debug(f"{activity_name} database did not contain data for ref {ref_id}")
except (ValueError, KeyError):
# Catch exceptions due to e.g. old versions of objects in the cache that are no longer compatible,
# and remove them from the cache.
del DATA_STORE.activity_refs[key]
return None
except Exception:
logger.exception(f"Exception when looking up activity ref info for {activity_name} ref {ref_id}")
return activity_ref
def populate_missing_activity_ref_info(activity_ref: ActivityRef) -> ActivityRef:
"""Look up details of an activity reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in an
activity_ref object which must at minimum have a "sig" and an "id". The rest of the object will be populated
and returned. Any data currently in the object will be kept, only missing data in the object will be populated
if it can be determined."""
lookup_data = get_activity_ref_info(activity_ref.sig, activity_ref.id)
if lookup_data:
# Copy new activity ref data into existing object where data was previously missing
for key, value in lookup_data.__dict__.items():
if value is not None and activity_ref.__dict__.get(key) is None:
activity_ref.__dict__[key] = value
return activity_ref
+45
View File
@@ -0,0 +1,45 @@
from core.enums import ActivityName
from data.activities import ACTIVITIES
from data.activity import Activity
def get_activity_by_name(name: str | None) -> Activity | None:
"""Utility function to resolve an arbitrary, case-insensitive activity name string (e.g. from a spot comment, a
provider, or an API request) to the matching known Activity. Returns None if no match is found."""
if not name:
return None
for activity_name, activity in ACTIVITIES.items():
if activity_name.upper() == name.upper():
return activity
return None
def get_ref_regex_for_activity(activity: str) -> str | None:
"""Utility function to get the regex string for an activity reference for a named activity. If no match is
found, None will be returned."""
found = get_activity_by_name(activity)
return found.ref_regex if found else None
def get_icon_for_activity(activity: str) -> str | None:
"""Utility function to get the icon for a named activity. If no match is found, None will be returned."""
found = get_activity_by_name(activity)
return found.icon if found else None
def get_activity_name_from_comment_name(activity: str) -> ActivityName | None:
"""Utility function to get the name of an activity from its "comment name". Generally these will be the same
but there are some cases (e.g. is "TOTA" Towers, Tiles or Toilets?) where we need to transform one to the
other."""
for activity_name, a in ACTIVITIES.items():
if any(n.upper() == activity.upper() for n in a.comment_names):
return activity_name
return None
# Regex matching any activity's "comment name", i.e. how it may be referred to in spot comments
ANY_ACTIVITY_REGEX = rf"({'|'.join(n for a in ACTIVITIES.values() for n in a.comment_names)})"
+3 -2
View File
@@ -2,14 +2,15 @@ import re
from core.data_providers import DATA_PROVIDERS
from data.callsign import Callsign
from data.lookup_credentials import LookupCredentials
def get_call_info(callsign, lookup_credentials):
def get_call_info(callsign: str | None, lookup_credentials: LookupCredentials | None) -> Callsign:
"""Utility method to get the best set of data for a callsign as we can, using all enabled providers.
lookup_credentials is an optional object that carries the user's QRZ.com/HamQTH credentials, if they provided them,
to enable lookup using those providers."""
callsign_data = Callsign(call=callsign)
callsign_data = Callsign(call=callsign or "")
# First check our input looks like a real callsign
if callsign and re.match(r"^[A-Za-z0-9/\-]*$", callsign):
+8 -8
View File
@@ -12,25 +12,25 @@ logger = logging.getLogger(__name__)
class CleanupTimer:
"""Provides a timed cleanup of the spot list."""
def __init__(self):
def __init__(self) -> None:
"""Constructor"""
self._cleanup_interval = None
self._cleanup_interval: float | None = None
self.last_cleanup_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Starting"
self._thread = None
self._thread: Thread | None = None
self._stop_event = Event()
def setup(self, cleanup_interval):
def setup(self, cleanup_interval: float) -> None:
self._cleanup_interval = cleanup_interval
def start(self):
def start(self) -> None:
"""Start the cleanup timer"""
self._thread = Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self):
def stop(self) -> None:
"""Stop any threads and prepare for application shutdown"""
self._stop_event.set()
@@ -39,11 +39,11 @@ class CleanupTimer:
if self._thread.is_alive():
logger.warning("Cleanup worker thread did not exit on time and will be killed.")
def _run(self):
def _run(self) -> None:
while not self._stop_event.wait(timeout=self._cleanup_interval):
self._cleanup()
def _cleanup(self):
def _cleanup(self) -> None:
"""Perform cleanup and reschedule next timer"""
try:
+19 -18
View File
@@ -2,6 +2,7 @@ import importlib
import logging
import os
import sys
from typing import Any
import yaml
@@ -16,25 +17,25 @@ if not os.path.isfile("config.yml"):
# Load config
with open("config.yml") as f:
config = yaml.safe_load(f)
config: dict[str, Any] = yaml.safe_load(f)
logger.info("Loaded config.")
BASE_URL = config.get("base_url", "http://localhost:8080")
MAX_SPOT_AGE = config.get("max_spot_age_sec", 3600)
MAX_ALERT_AGE = config.get("max_alert_age_sec", 604800)
SERVER_OWNER_CALLSIGN = config.get("server_owner_callsign", "N0CALL")
WEB_SERVER_PORT = config.get("web_server_port", 8080)
TELNET_SERVER_ENABLED = config.get("telnet_server_enabled", False)
TELNET_SERVER_ADDRESS = config.get("telnet_server_address", "localhost")
TELNET_SERVER_PORT = config.get("telnet_server_port", 7373)
ALLOW_SPOTTING = config.get("allow_spotting", True)
ALLOW_UPSTREAM_SPOTTING = config.get("allow_upstream_spotting", True)
WEB_UI_OPTIONS = config.get("web_ui_options", {})
API_ONLY_MODE = config.get("api_only_mode", False)
RECAPTCHA_SECRET_KEY = config.get("recaptcha_secret_key", "")
RECAPTCHA_SITE_KEY = config.get("recaptcha_site_key", "")
LOG_LEVEL = config.get("log_level", "INFO")
LOG_WEB_REQUESTS = config.get("log_web_requests", False)
BASE_URL: str = config.get("base_url", "http://localhost:8080")
MAX_SPOT_AGE: int = config.get("max_spot_age_sec", 3600)
MAX_ALERT_AGE: int = config.get("max_alert_age_sec", 604800)
SERVER_OWNER_CALLSIGN: str = config.get("server_owner_callsign", "N0CALL")
WEB_SERVER_PORT: int = config.get("web_server_port", 8080)
TELNET_SERVER_ENABLED: bool = config.get("telnet_server_enabled", False)
TELNET_SERVER_ADDRESS: str = config.get("telnet_server_address", "localhost")
TELNET_SERVER_PORT: int = config.get("telnet_server_port", 7373)
ALLOW_SPOTTING: bool = config.get("allow_spotting", True)
ALLOW_UPSTREAM_SPOTTING: bool = config.get("allow_upstream_spotting", True)
WEB_UI_OPTIONS: dict[str, Any] = config.get("web_ui_options", {})
API_ONLY_MODE: bool = config.get("api_only_mode", False)
RECAPTCHA_SECRET_KEY: str = config.get("recaptcha_secret_key", "")
RECAPTCHA_SITE_KEY: str = config.get("recaptcha_site_key", "")
LOG_LEVEL: str = config.get("log_level", "INFO")
LOG_WEB_REQUESTS: bool = config.get("log_web_requests", False)
WEB_UI_OPTIONS["qrz_enabled"] = any(p["class"] == "QRZ" and p["enabled"] for p in config["callsign_data_providers"])
WEB_UI_OPTIONS["hamqth_enabled"] = any(
@@ -44,7 +45,7 @@ WEB_UI_OPTIONS["recaptcha_site_key"] = RECAPTCHA_SITE_KEY
WEB_UI_OPTIONS["allow_upstream_spotting"] = ALLOW_SPOTTING and ALLOW_UPSTREAM_SPOTTING
def create_provider_from_config(package, config_providers_entry):
def create_provider_from_config(package: str, config_providers_entry: dict[str, Any]) -> Any:
"""Utility method to get a provider based on the class specified in its config entry. You must also provide the
package to look for it in, as there are several types of provider. e.g. package "providers.spot", where the config
entry is for a POTA spot provider."""
+6 -396
View File
@@ -1,405 +1,15 @@
from core.config import SERVER_OWNER_CALLSIGN
from core.enums import SIGRefType, SIGType
from data.band import Band
from data.sig import SIG
# General software
SOFTWARE_VERSION = "2.1.3"
SOFTWARE_VERSION: str = "2.2-pre"
# HTTP headers used for spot providers that use HTTP
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
HAMQTH_PRG = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}".replace(" ", "_")
# Special Interest Groups
SIGS = [
SIG(
name="AMSAT",
comment_names=[],
description="Amateur Radio Satellites",
sig_type=SIGType.WORLDWIDE,
icon="fa-satellite",
refs_globally_unique=False,
),
SIG(
name="EME",
comment_names=[],
description="Moonbounce",
sig_type=SIGType.WORLDWIDE,
icon="fa-moon",
refs_globally_unique=False,
),
SIG(
name="POTA",
comment_names=["POTA"],
description="Parks on the Air",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.PARK,
ref_regex=r"[A-Z]{2}\-\d{4,5}|K\-TEST",
icon="fa-tree",
refs_globally_unique=False,
),
SIG(
name="SOTA",
comment_names=["SOTA"],
description="Summits on the Air",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.SUMMIT,
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
icon="fa-mountain-sun",
refs_globally_unique=False,
),
SIG(
name="WWFF",
comment_names=["WWFF"],
description="World Wide Flora & Fauna",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.PARK,
ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}",
icon="fa-seedling",
refs_globally_unique=True,
),
SIG(
name="GMA",
comment_names=["GMA"],
description="Global Mountain Activity",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.SUMMIT,
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
icon="fa-person-hiking",
refs_globally_unique=False,
),
SIG(
name="WWBOTA",
comment_names=["WWBOTA", "BOTA"],
description="Worldwide Bunkers on the Air",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.BUNKER,
ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}",
icon="fa-radiation",
refs_globally_unique=True,
),
SIG(
name="HEMA",
comment_names=["HEMA"],
description="HuMPs Excluding Marilyns Award",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.SUMMIT,
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}",
icon="fa-mound",
refs_globally_unique=False,
),
SIG(
name="IOTA",
comment_names=["IOTA"],
description="Islands on the Air",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.ISLAND,
ref_regex=r"[A-Z]{2}\-\d{3}",
icon="fa-book-atlas",
refs_globally_unique=False,
),
SIG(
name="GMA Islands",
comment_names=[],
description="Global Mountain Activity - Islands",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.ISLAND,
ref_regex=r"(([A-Z]{2}\-\d{3})|([A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}))",
icon="fa-person-hiking",
refs_globally_unique=False,
),
SIG(
name="ARLHS",
comment_names=["ARLHS"],
description="Amateur Radio Lighthouse Society",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.LIGHTHOUSE,
ref_regex=r"[A-Z]{3}[\- ]\d{3,4}",
icon="fa-house-flood-water",
refs_globally_unique=False,
),
SIG(
name="ILLW",
comment_names=["ILLW"],
description="International Lighthouse & Lightship Weekend",
sig_type=SIGType.EVENT,
ref_type=SIGRefType.LIGHTHOUSE,
ref_regex=r"[A-Z]{2}\d{4}",
icon="fa-house-flood-water",
refs_globally_unique=False,
),
SIG(
name="MOTA",
comment_names=["MOTA"],
description="Mills on the Air",
sig_type=SIGType.EVENT,
ref_type=SIGRefType.MILL,
ref_regex=r"X\d{4,6}",
icon="fa-fan",
refs_globally_unique=True,
),
SIG(
name="SIOTA",
comment_names=["SIOTA"],
description="Silos on the Air",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.SILO,
ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d",
icon="fa-wheat-awn",
refs_globally_unique=False,
),
SIG(
name="WCA",
comment_names=["WCA"],
description="World Castles Award",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.CASTLE,
ref_regex=r"[A-Z0-9]{1,3}\-\d{5}",
icon="fa-chess-rook",
refs_globally_unique=False,
),
SIG(
name="ZLOTA",
comment_names=["ZLOTA"],
description="New Zealand on the Air",
sig_type=SIGType.REGIONAL,
ref_type=None,
ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}",
icon="fa-kiwi-bird",
region_flag="🇳🇿",
refs_globally_unique=True,
),
SIG(
name="WOTA",
comment_names=["WOTA"],
description="Wainwrights on the Air",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.SUMMIT,
ref_regex=r"[A-Z]{3}-[0-9]{2}",
icon="fa-w",
region_flag="🇬🇧",
refs_globally_unique=False,
),
SIG(
name="BOTA",
comment_names=[],
description="Beaches on the Air",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.BEACH,
icon="fa-umbrella-beach",
refs_globally_unique=False,
),
SIG(
name="KRMNPA",
comment_names=["KRMNPA"],
description="Keith Roget Memorial National Parks Award",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.PARK,
ref_regex=r"VKFF\-\d{4}",
icon="fa-earth-oceania",
region_flag="🇦🇺",
refs_globally_unique=False,
),
SIG(
name="SANPCPA",
comment_names=["SANPCPA"],
description="South Australian National Parks and Conservation Parks Award",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.PARK,
ref_regex=r"VKFF\-\d{4}",
icon="fa-earth-oceania",
region_flag="🇦🇺",
refs_globally_unique=False,
),
SIG(
name="LLOTA",
comment_names=["LLOTA"],
description="Lagos y Lagunas on the Air",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.LAKE,
ref_regex=r"LL[A-Z]{2}\-\d{4}",
icon="fa-water",
refs_globally_unique=True,
),
SIG(
name="Towers",
comment_names=["TOTA"],
description="Towers on the Air",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.TOWER,
ref_regex=r"[A-Z]{2,3}R\-\d{4}",
icon="fa-tower-observation",
refs_globally_unique=False,
),
SIG(
name="Tiles",
comment_names=[],
description="Tiles on the Air",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.GRID,
ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}",
icon="fa-square",
refs_globally_unique=False,
),
SIG(
name="WAB",
comment_names=["WAB"],
description="Worked All Britain",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.GRID,
ref_regex=r"[A-Z]{1,2}[0-9]{2}",
icon="fa-table-cells-large",
region_flag="🇬🇧",
refs_globally_unique=False,
),
SIG(
name="WAI",
comment_names=["WAI"],
description="Worked All Ireland",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.GRID,
ref_regex=r"[A-Z][0-9]{2}",
icon="fa-table-cells-large",
region_flag="🇮🇪",
refs_globally_unique=False,
),
SIG(
name="DMF",
comment_names=["DMF"],
description="Diplôme des Moulins de France",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.MILL,
icon="fa-fan",
region_flag="🇫🇷",
refs_globally_unique=False,
),
SIG(
name="DME",
comment_names=["DME"],
description="Diploma Municipios de España",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.TOWN,
ref_regex=r"DME[\- ]\d{3,5}",
icon="fa-building",
region_flag="🇪🇸",
refs_globally_unique=True,
),
SIG(
name="FEA",
comment_names=["FEA"],
description="Diploma Faros de España",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.LIGHTHOUSE,
# FEA references are technically [DE]\-\d{4}(\.\d)? but spotters always seem to miss out the D- or E-
# prefix and just use FEA-1234 or FEA 1234, so allow for that. The FEA sigref data provider adds both
# forms to the database.
ref_regex=r"([DE]|FEA)[\- ]\d{4}(\.\d)?",
icon="fa-house-flood-water",
region_flag="🇪🇸",
refs_globally_unique=True,
),
SIG(
name="DMUE",
comment_names=["DMUE"],
description="Diploma Museos de España",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.BUILDING,
ref_regex=r"MUE[A-Z]{2}-\d{3}",
icon="fa-landmark",
region_flag="🇪🇸",
refs_globally_unique=True,
),
SIG(
name="DMVE",
comment_names=["DMVE"],
description="Diploma Monumentos y Vestigios de España",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.BUILDING,
ref_regex=r"MV[A-Z]{1,2}-\d{4}",
icon="fa-monument",
region_flag="🇪🇸",
refs_globally_unique=True,
),
SIG(
name="DCE",
comment_names=["DCE"],
description="Diploma Castillos de España",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.CASTLE,
ref_regex=r"C[A-Z]{1,2}-\d{3}",
icon="fa-chess-rook",
region_flag="🇪🇸",
refs_globally_unique=False,
),
SIG(
name="DEFE",
comment_names=["DEFE"],
description="Diploma Estaciones de Ferrocarril de España",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.BUILDING,
ref_regex=r"EF[A-Z]{1,2}-\d{3}",
icon="fa-train",
region_flag="🇪🇸",
refs_globally_unique=True,
),
SIG(
name="DTMBA",
comment_names=["DTMBA"],
description="Diploma Teatri Musei e Belle Arti",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.BUILDING,
ref_regex=r"I-?[0-9]{3,4}\s?[A-Z]{2}",
icon="fa-landmark",
region_flag="🇮🇹",
refs_globally_unique=True,
),
SIG(
name="BIWOTA",
comment_names=["BIWOTA"],
description="British Inland Waterways on the Air",
sig_type=SIGType.EVENT,
ref_type=SIGRefType.WATERWAY,
icon="fa-ship",
region_flag="🇬🇧",
refs_globally_unique=False,
),
SIG(
name="COTA",
comment_names=["COTA"],
description="Castles on the Air",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.CASTLE,
ref_regex=r"[A-Z]{3}\-[0-9]{3,5}",
icon="fa-chess-rook",
region_flag="🇩🇪",
refs_globally_unique=False,
),
SIG(
name="PGA",
comment_names=["PGA"],
description="Polish Gmina Award",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.REGION,
ref_regex=r"[A-Z]{2}[0-9]{2}",
icon="fa-g",
region_flag="🇵🇱",
refs_globally_unique=False,
),
SIG(
name="Toilets",
comment_names=[],
description="Toilets on the Air",
sig_type=SIGType.EVENT,
ref_type=SIGRefType.TOILET,
ref_regex=r"T\-[0-9]{2}",
icon="fa-toilet",
region_flag="🏴‍☠️",
refs_globally_unique=True,
),
]
HTTP_HEADERS: dict[str, str] = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
HAMQTH_PRG: str = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}".replace(" ", "_")
# Band definitions
BANDS = [
BANDS: list[Band] = [
Band(name="2200m", start_freq=135700, end_freq=137800),
Band(name="600m", start_freq=472000, end_freq=479000),
Band(name="160m", start_freq=1800000, end_freq=2000000, is_ham_hf=True),
@@ -427,11 +37,11 @@ BANDS = [
Band(name="47GHz", start_freq=47000000000, end_freq=47200000000),
Band(name="76GHz", start_freq=75500000000, end_freq=81500000000),
]
UNKNOWN_BAND = Band(name="Unknown", start_freq=0, end_freq=0)
UNKNOWN_BAND: Band = Band(name="Unknown", start_freq=0, end_freq=0)
# Propagation modes used in VHF/UHF DX cluster comments, e.g. "JN61ES<ES>JM56XT". I don't think there's an official list
# of these anywhere, but here are some I've seen or seen reference to
PROPAGATION_MODES = {
PROPAGATION_MODES: dict[str, str] = {
"F2": "F2 layer ionospheric",
"ES": "Sporadic-E",
"TR": "Tropospheric ducting",
+41 -20
View File
@@ -1,25 +1,39 @@
from __future__ import annotations
import logging
import threading
import time
from collections.abc import Sequence
from typing import TYPE_CHECKING
from core.config import config, create_provider_from_config
if TYPE_CHECKING:
# Can't find a way to resolve the circular dependency on types but apparently this is a way of managing that
# while still having type safety in method definitions.
from providers.activityrefdata.activity_ref_data_provider import ActivityRefDataProvider
from providers.alert.alert_provider import AlertProvider
from providers.callsigndata.callsign_data_provider import CallsignDataProvider
from providers.solarconditions.solar_conditions_provider import SolarConditionsProvider
from providers.spot.spot_provider import SpotProvider
from providers.staticdata.static_data_provider import StaticDataProvider
logger = logging.getLogger(__name__)
class DataProviders:
"""Global object for storing data providers."""
def __init__(self):
self.spot_providers = []
self.alert_providers = []
self.solar_condition_providers = []
self.static_data_providers = []
self.sig_ref_data_providers = []
self.callsign_data_providers = []
self._startup_timers = []
def __init__(self) -> None:
self.spot_providers: list[SpotProvider] = []
self.alert_providers: list[AlertProvider] = []
self.solar_condition_providers: list[SolarConditionsProvider] = []
self.static_data_providers: list[StaticDataProvider] = []
self.sig_ref_data_providers: list[ActivityRefDataProvider] = []
self.callsign_data_providers: list[CallsignDataProvider] = []
self._startup_timers: list[threading.Timer] = []
def setup(self):
def setup(self) -> None:
for entry in config["spot_providers"]:
self.spot_providers.append(create_provider_from_config("providers.spot", entry))
for entry in config["alert_providers"]:
@@ -29,12 +43,17 @@ class DataProviders:
for entry in config.get("static_data_providers", []):
self.static_data_providers.append(create_provider_from_config("providers.staticdata", entry))
for entry in config.get("sig_ref_data_providers", []):
self.sig_ref_data_providers.append(create_provider_from_config("providers.sigrefdata", entry))
self.sig_ref_data_providers.append(create_provider_from_config("providers.activityrefdata", entry))
for entry in config.get("callsign_data_providers", []):
self.callsign_data_providers.append(create_provider_from_config("providers.callsigndata", entry))
@staticmethod
def start_providers(providers, provider_type):
def start_providers(
providers: Sequence[
SpotProvider | AlertProvider | SolarConditionsProvider | StaticDataProvider | ActivityRefDataProvider | CallsignDataProvider
],
provider_type: str,
) -> None:
"""Helper method to activate enabled providers in the list."""
logger.info(f"Starting {provider_type} providers...")
@@ -42,7 +61,7 @@ class DataProviders:
if p.enabled:
p.start()
def start(self):
def start(self) -> None:
# Start data providers before spot/alert providers so the lookup data is there already for incoming spots.
# Each category is fired off after a small delay to give the rest of Spothole chance to start up.
self._startup_timers = [
@@ -54,13 +73,13 @@ class DataProviders:
25.0,
lambda: self.start_providers(self.solar_condition_providers, "solar condition"),
),
threading.Timer(30.0, lambda: self.start_providers(self.sig_ref_data_providers, "SIG ref data")),
threading.Timer(30.0, lambda: self.start_providers(self.sig_ref_data_providers, "activity ref data")),
]
for t in self._startup_timers:
t.daemon = True
t.start()
def stop(self):
def stop(self) -> None:
# Cancel any startup timers that haven't fired yet
for t in self._startup_timers:
t.cancel()
@@ -81,20 +100,22 @@ class DataProviders:
if not all_providers:
return
def stop_provider(p):
def stop_provider(
p: SpotProvider | AlertProvider | SolarConditionsProvider | StaticDataProvider | ActivityRefDataProvider | CallsignDataProvider,
) -> None:
try:
p.stop()
except Exception:
logger.exception("Exception stopping provider")
threads = [threading.Thread(target=stop_provider, args=(p,), daemon=True) for p in all_providers]
for t in threads:
t.start()
for thread in threads:
thread.start()
deadline = time.monotonic() + 15
for t in threads:
t.join(timeout=max(0.0, deadline - time.monotonic()))
still_running = [t for t in threads if t.is_alive()]
for thread in threads:
thread.join(timeout=max(0.0, deadline - time.monotonic()))
still_running = [thread for thread in threads if thread.is_alive()]
if still_running:
logger.warning("Some threads did not stop in time!")
+109 -42
View File
@@ -1,75 +1,142 @@
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import TYPE_CHECKING, Any
import diskcache
import geopandas
from core.config import MAX_ALERT_AGE, MAX_SPOT_AGE
from core.live_data_cache import LiveDataCache
from core.single_object_data_cache import SingleObjectDataCache
from data.solar_conditions import SolarConditions
if TYPE_CHECKING:
# Can't find a way to resolve the circular dependency on types but apparently this is a way of managing that
# while still having type safety in method definitions.
from data.alert import Alert
from data.spot import Spot
logger = logging.getLogger(__name__)
CACHE_DIR = "./cache/"
class DataStore:
"""Data caching/storage object. Handles storage of spots, alerts, solar conditions, SIG reference data, and callsign
lookup data using different caching strategies for each."""
"""Data caching/storage object. Handles storage of spots, alerts, solar conditions, activity reference data, and
callsign lookup data using different caching strategies for each."""
def __init__(self):
def __init__(self) -> None:
# Constants
self._MAX_SPOT_COUNT = 100000
self._MAX_ALERT_COUNT = 100000
self._SPOT_ALERT_SNAPSHOT_INTERVAL_SEC = 300
self.CALLSIGN_DATA_TTL_SEC = 30 * 24 * 60 * 60
# Caches
self.alerts = None
self.spots = None
self.callsign_data_countryfiles = None
self.callsign_data_clublogxml = None
self.callsign_data_clublogapi = None
self.callsign_data_qrz = None
self.callsign_data_hamqth = None
self.dxcc_data = None
self.dxcc_lookup_by_call_regex = []
self.sigrefs = None
self.status = None
self.solar_conditions = None
# Caches. These are all created by setup(), which must be called before use; they are None only in the window
# between construction of this (global, single-instance) object and that call.
self._alerts: LiveDataCache[Alert] | None = None
self._spots: LiveDataCache[Spot] | None = None
self._callsign_data_countryfiles: diskcache.Cache | None = None
self._callsign_data_clublogxml: diskcache.Cache | None = None
self._callsign_data_clublogapi: diskcache.Cache | None = None
self._callsign_data_qrz: diskcache.Cache | None = None
self._callsign_data_hamqth: diskcache.Cache | None = None
self._dxcc_data: diskcache.Cache | None = None
self.dxcc_lookup_by_call_regex: list[tuple[re.Pattern[str], Any]] = []
self._activity_refs: diskcache.Cache | None = None
self._status: SingleObjectDataCache[dict[str, Any]] | None = None
self._solar_conditions: SingleObjectDataCache[SolarConditions] | None = None
# ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be
# caches, they can just be straight objects
self.cq_zone_data = None
self.itu_zone_data = None
# caches, they can just be straight objects. Unlike the caches above, these genuinely may never be populated,
# if the corresponding static data provider isn't configured, so callers must handle None.
self.cq_zone_data: geopandas.GeoDataFrame | None = None
self.itu_zone_data: geopandas.GeoDataFrame | None = None
def setup(self):
@property
def alerts(self) -> LiveDataCache[Alert]:
assert self._alerts is not None, "DataStore.setup() must be called before use"
return self._alerts
@property
def spots(self) -> LiveDataCache[Spot]:
assert self._spots is not None, "DataStore.setup() must be called before use"
return self._spots
@property
def callsign_data_countryfiles(self) -> diskcache.Cache:
assert self._callsign_data_countryfiles is not None, "DataStore.setup() must be called before use"
return self._callsign_data_countryfiles
@property
def callsign_data_clublogxml(self) -> diskcache.Cache:
assert self._callsign_data_clublogxml is not None, "DataStore.setup() must be called before use"
return self._callsign_data_clublogxml
@property
def callsign_data_clublogapi(self) -> diskcache.Cache:
assert self._callsign_data_clublogapi is not None, "DataStore.setup() must be called before use"
return self._callsign_data_clublogapi
@property
def callsign_data_qrz(self) -> diskcache.Cache:
assert self._callsign_data_qrz is not None, "DataStore.setup() must be called before use"
return self._callsign_data_qrz
@property
def callsign_data_hamqth(self) -> diskcache.Cache:
assert self._callsign_data_hamqth is not None, "DataStore.setup() must be called before use"
return self._callsign_data_hamqth
@property
def dxcc_data(self) -> diskcache.Cache:
assert self._dxcc_data is not None, "DataStore.setup() must be called before use"
return self._dxcc_data
@property
def activity_refs(self) -> diskcache.Cache:
assert self._activity_refs is not None, "DataStore.setup() must be called before use"
return self._activity_refs
@property
def status(self) -> SingleObjectDataCache[dict[str, Any]]:
assert self._status is not None, "DataStore.setup() must be called before use"
return self._status
@property
def solar_conditions(self) -> SingleObjectDataCache[SolarConditions]:
assert self._solar_conditions is not None, "DataStore.setup() must be called before use"
return self._solar_conditions
def setup(self) -> None:
Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
# For solar data and status data, we use a wrapper around disk cache where each cache contains only a single
# object exposed to the wider application, and provides a store() method for callers to notify diskcache that
# the object has changed and needs to be re-cached.
self.solar_conditions = SingleObjectDataCache(f"{CACHE_DIR}solar", SolarConditions())
self.status = SingleObjectDataCache(f"{CACHE_DIR}status", {})
self._solar_conditions = SingleObjectDataCache(f"{CACHE_DIR}solar", SolarConditions())
self._status = SingleObjectDataCache(f"{CACHE_DIR}status", {})
# Standard disk cache for static reference and SIG ref data. Separate provider threads will repopulate these on
# a regular basis but there's no need for a TTL since old data is better than no data.
self.dxcc_data = diskcache.Cache(f"{CACHE_DIR}dxcc_data")
# Standard disk cache for static reference and activity ref data. Separate provider threads will repopulate
# these on a regular basis but there's no need for a TTL since old data is better than no data.
self._dxcc_data = diskcache.Cache(f"{CACHE_DIR}dxcc_data")
self.regenerate_call_regex_to_dxcc_entity_map()
# For SIG reference data specifically, we need to key on both SIG *and* reference, and trying to do two layers
# of dict in diskcache absolutely destroys performance with unpickling huge dicts, so we have an ugly "SIG:ref"
# syntax for keys to keep it a single level.
self.sigrefs = diskcache.Cache(f"{CACHE_DIR}sigrefs")
logger.info(f"Loaded data for {len(self.sigrefs)} SIG references.")
# For activity reference data specifically, we need to key on both activity *and* reference, and trying to do
# two layers of dict in diskcache absolutely destroys performance with unpickling huge dicts, so we have an
# ugly "activity:ref" syntax for keys to keep it a single level.
self._activity_refs = diskcache.Cache(f"{CACHE_DIR}activity_refs")
logger.info(f"Loaded data for {len(self.activity_refs)} activity references.")
# Standard disk cache for callsign data. This data does have a TTL to trigger an occasional re-lookup.
# Old data *is* better than no data, but we can't have a background thread re-looking-up every callsign
# we've seen, so we rely on them timing out and this triggering another lookup.
self.callsign_data_countryfiles = diskcache.Cache(f"{CACHE_DIR}callsign_data_countryfiles")
self.callsign_data_clublogxml = diskcache.Cache(f"{CACHE_DIR}callsign_data_clublogxml")
self.callsign_data_clublogapi = diskcache.Cache(f"{CACHE_DIR}callsign_data_clublogapi")
self.callsign_data_qrz = diskcache.Cache(f"{CACHE_DIR}callsign_data_qrz")
self.callsign_data_hamqth = diskcache.Cache(f"{CACHE_DIR}callsign_data_hamqth")
self._callsign_data_countryfiles = diskcache.Cache(f"{CACHE_DIR}callsign_data_countryfiles")
self._callsign_data_clublogxml = diskcache.Cache(f"{CACHE_DIR}callsign_data_clublogxml")
self._callsign_data_clublogapi = diskcache.Cache(f"{CACHE_DIR}callsign_data_clublogapi")
self._callsign_data_qrz = diskcache.Cache(f"{CACHE_DIR}callsign_data_qrz")
self._callsign_data_hamqth = diskcache.Cache(f"{CACHE_DIR}callsign_data_hamqth")
unique_keys = set()
for c in [
self.callsign_data_countryfiles,
@@ -82,9 +149,9 @@ class DataStore:
logger.info(f"Loaded data for {len(unique_keys)} 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(
# specifically load these caches *last* so that any activity ref 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=f"{CACHE_DIR}spots",
@@ -92,7 +159,7 @@ class DataStore:
)
logger.info(f"Loaded {len(self.spots.keys())} spots from a previous run.")
self.alerts = LiveDataCache(
self._alerts = LiveDataCache(
maxsize=self._MAX_ALERT_COUNT,
ttl=MAX_ALERT_AGE,
snapshot_dir=f"{CACHE_DIR}alerts",
@@ -100,7 +167,7 @@ class DataStore:
)
logger.info(f"Loaded {len(self.alerts.keys())} alerts from a previous run.")
def regenerate_call_regex_to_dxcc_entity_map(self):
def regenerate_call_regex_to_dxcc_entity_map(self) -> None:
"""DXCC entity data from K0SWE includes a regex which we can use to match a callsign, and determine which DXCC
entity it belongs to. But getting every DXCC entity data object out of DiskCache, iterating, compiling its regex
and testing the callsign every time is expensive. So instead we build a separate in-memory lookup of compiled
@@ -110,13 +177,13 @@ class DataStore:
for entry in [DATA_STORE.dxcc_data[key] for key in DATA_STORE.dxcc_data]:
self.dxcc_lookup_by_call_regex.append((re.compile(entry["prefixRegex"]), entry["entityCode"]))
def close(self):
def close(self) -> None:
self.spots.close()
self.alerts.close()
self.solar_conditions.close()
self.status.close()
self.dxcc_data.close()
self.sigrefs.close()
self.activity_refs.close()
self.callsign_data_countryfiles.close()
self.callsign_data_clublogxml.close()
self.callsign_data_clublogapi.close()
+62 -16
View File
@@ -44,7 +44,7 @@ class Mode(str, Enum):
return not (self.is_cw or self.is_phone)
@staticmethod
def from_name(name):
def from_name(name: str) -> "Mode | None":
"""Convert a string to an enum mode using the alias table."""
if not name:
@@ -90,8 +90,62 @@ class LocationSourceForCallsign(str, Enum):
DXCC = "DXCC"
class SIGRefType(str, Enum):
"""Type of a Special Interest Group reference."""
# Definitions of every activity Spothole knows about, keyed by the ActivityName enum. An enum is used for the
# names to avoid typos when using literal strings like "POTA" all over the place.
class ActivityName(str, Enum):
"""Canonical name of every activity. Spothole uses these rather than literals like "POTA" around the code
to ensure I don't accidentally introduce typos."""
CONTEST = "Contest"
DXPEDITION = "DXpedition"
SATELLITE = "Satellite"
EME = "EME"
AERONAUTICAL_MOBILE = "/AM"
MARITIME_MOBILE = "/MM"
QRP = "QRP"
POTA = "POTA"
SOTA = "SOTA"
WWFF = "WWFF"
GMA = "GMA"
WWBOTA = "WWBOTA"
HEMA = "HEMA"
IOTA = "IOTA"
GMA_ISLANDS = "GMA Islands"
ARLHS = "ARLHS"
ILLW = "ILLW"
MOTA = "MOTA"
SIOTA = "SIOTA"
WCA = "WCA"
ZLOTA = "ZLOTA"
WOTA = "WOTA"
BOTA = "BOTA"
KRMNPA = "KRMNPA"
SANPCPA = "SANPCPA"
LLOTA = "LLOTA"
TOWERS = "Towers"
TILES = "Tiles"
RADAR_RALLY = "RaDAR Rally"
WAB = "WAB"
WAI = "WAI"
DMF = "DMF"
DME = "DME"
FEA = "FEA"
DMUE = "DMUE"
DMVE = "DMVE"
DCE = "DCE"
DEFE = "DEFE"
DTMBA = "DTMBA"
BIWOTA = "BIWOTA"
COTA = "COTA"
PGA = "PGA"
TOILETS = "Toilets"
def __str__(self) -> str:
return str(self.value)
class ActivityRefType(str, Enum):
"""Type of an activity reference."""
PARK = "PARK"
SUMMIT = "SUMMIT"
@@ -112,19 +166,11 @@ class SIGRefType(str, Enum):
TOILET = "TOILET"
class AlertType(str, Enum):
"""Type of an alert."""
class ActivityType(str, Enum):
"""Type of an activity. Used to group them in the web UI."""
XOTA = "XOTA"
SATELLITE = "SATELLITE"
DXPEDITION = "DXPEDITION"
CONTEST = "CONTEST"
class SIGType(str, Enum):
"""Type of a Special Interest Group. Used to group them in the web UI."""
WORLDWIDE = "WORLDWIDE"
TRADITIONAL = "TRADITIONAL"
ADVENTURE = "ADVENTURE"
REGIONAL = "REGIONAL"
EVENT = "EVENT"
@@ -133,7 +179,7 @@ class SIGType(str, Enum):
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots
# that match a key in this table will be converted to the corresponding value, so only the modes above will actually be
# present in the spots.
MODE_ALIASES = {
MODE_ALIASES: dict[str, str] = {
"USB": "SSB",
"LSB": "SSB",
"DIGITALVOICE": "DV",
+14 -12
View File
@@ -1,6 +1,6 @@
import logging
import re
from math import floor
from math import floor, isnan
from pyproj import Transformer
from shapely.geometry import Point, Polygon
@@ -14,7 +14,7 @@ TRANSFORMER_IRISH_GRID_TO_WGS84 = Transformer.from_crs("EPSG:29903", "EPSG:4326"
TRANSFORMER_CI_UTM_GRID_TO_WGS84 = Transformer.from_crs("+proj=utm +zone=30 +ellps=WGS84", "EPSG:4326")
def lat_lon_to_cq_zone(lat, lon):
def lat_lon_to_cq_zone(lat: float, lon: float) -> int | None:
"""Finds out which CQ zone a lat/lon point is in."""
if DATA_STORE.cq_zone_data is not None:
@@ -36,7 +36,7 @@ def lat_lon_to_cq_zone(lat, lon):
return None
def lat_lon_to_itu_zone(lat, lon):
def lat_lon_to_itu_zone(lat: float, lon: float) -> int | None:
"""Finds out which ITU zone a lat/lon point is in."""
if DATA_STORE.itu_zone_data is not None:
@@ -58,7 +58,7 @@ def lat_lon_to_itu_zone(lat, lon):
return None
def lat_lon_for_grid_centre(grid):
def lat_lon_for_grid_centre(grid: str) -> list[float] | None:
"""Convert a Maidenhead grid reference of arbitrary precision to the lat/long of the centre point of the square.
Returns None if the grid format is invalid."""
@@ -69,7 +69,7 @@ def lat_lon_for_grid_centre(grid):
return None
def lat_lon_for_grid_sw_corner(grid):
def lat_lon_for_grid_sw_corner(grid: str) -> list[float] | None:
"""Convert a Maidenhead grid reference of arbitrary precision to the lat/long of the southwest corner of the square.
Returns None if the grid format is invalid."""
@@ -80,7 +80,7 @@ def lat_lon_for_grid_sw_corner(grid):
return None
def lat_lon_for_grid_ne_corner(grid):
def lat_lon_for_grid_ne_corner(grid: str) -> list[float] | None:
"""Convert a Maidenhead grid reference of arbitrary precision to the lat/long of the northeast corner of the square.
Returns None if the grid format is invalid."""
@@ -91,7 +91,9 @@ def lat_lon_for_grid_ne_corner(grid):
return None
def lat_lon_for_grid_sw_corner_plus_size(grid):
def lat_lon_for_grid_sw_corner_plus_size(
grid: str,
) -> tuple[float, float, float, float] | tuple[None, None, None, None]:
"""Convert a Maidenhead grid reference of arbitrary precision to lat/long, including in the result the size of the
lowest grid square. This is a utility method used by the main methods that return the centre, southwest, and
northeast coordinates of a grid square.
@@ -156,13 +158,13 @@ def lat_lon_for_grid_sw_corner_plus_size(grid):
lat -= 90.0
# Return None values on maths errors
if any(x != x for x in [lat, lon, lat_cell_size, lon_cell_size]):
if any(isnan(x) for x in [lat, lon, lat_cell_size, lon_cell_size]):
return None, None, None, None
return lat, lon, lat_cell_size, lon_cell_size
def wab_wai_square_to_lat_lon(ref):
def wab_wai_square_to_lat_lon(ref: str) -> tuple[float, float] | None:
"""Convert a Worked All Britain or Worked All Ireland reference to a lat/lon point."""
# First check we have a valid grid square, and based on what it looks like, use either the Ordnance Survey, Irish,
@@ -178,7 +180,7 @@ def wab_wai_square_to_lat_lon(ref):
return None
def os_grid_square_to_lat_lon(ref):
def os_grid_square_to_lat_lon(ref: str) -> tuple[float, float]:
"""Get a lat/lon point for the centre of an Ordnance Survey grid square"""
# Convert the letters into multipliers for the 500km squares and 100km squares
@@ -209,7 +211,7 @@ def os_grid_square_to_lat_lon(ref):
return lat, lon
def irish_grid_square_to_lat_lon(ref):
def irish_grid_square_to_lat_lon(ref: str) -> tuple[float, float]:
"""Get a lat/lon point for the centre of an Irish Grid square."""
# Convert the letters into multipliers for the 100km squares
@@ -237,7 +239,7 @@ def irish_grid_square_to_lat_lon(ref):
return lat, lon
def utm_grid_square_to_lat_lon(ref):
def utm_grid_square_to_lat_lon(ref: str) -> tuple[float, float]:
"""Get a lat/lon point for the centre of a UTM grid square (supports only squares WA & WV for the Channel Islands, nothing else implemented)"""
# Take the numeric parts of the grid square and multiply by 10000 to get metres from the corner of the letter-based grid square
+23 -19
View File
@@ -1,33 +1,37 @@
import logging
import threading
import time
from collections.abc import Callable
from typing import Generic, TypeVar
import diskcache
from cachetools import TTLCache
logger = logging.getLogger(__name__)
VT = TypeVar("VT")
class LiveDataCache:
class LiveDataCache(Generic[VT]):
"""Cache for spots and alerts. Uses the fast in-memory TTLCache for normal data I/O, including the TTL to enforce
maximum lifetime, and adds a separate diskcache to which we can save and load the TTLCache to provide persistence.
Also adds thread safety so spots and alerts can come from any thread, and a listener mechanism so the web server
can get a callback when new spots/alerts are added, and send them to any SSE clients."""
def __init__(self, maxsize, ttl, snapshot_dir, snapshot_interval_sec):
self._cache = TTLCache(maxsize=maxsize, ttl=ttl)
def __init__(self, maxsize: int, ttl: int, snapshot_dir: str, snapshot_interval_sec: int) -> None:
self._cache: TTLCache = TTLCache(maxsize=maxsize, ttl=ttl)
self._lock = threading.Lock()
self._ttl = ttl
self._listeners = []
self._listeners: list[Callable[[VT], None]] = []
self._listeners_lock = threading.Lock()
self._snapshot_dir = snapshot_dir
self._disk_cache = diskcache.Cache(str(snapshot_dir))
self._stop_event = threading.Event()
self._snapshot_thread = None
self._snapshot_thread: threading.Thread | None = None
self._load_snapshot()
self._start_periodic_snapshot(snapshot_interval_sec)
def set(self, key, value):
def set(self, key: str, value: VT) -> None:
with self._lock:
self._cache[key] = value
@@ -40,34 +44,34 @@ class LiveDataCache:
except Exception:
logger.exception(f"Listener raised an exception for key {key}")
def get(self, key, default=None):
def get(self, key: str, default: VT | None = None) -> VT | None:
with self._lock:
return self._cache.get(key, default)
def delete(self, key):
def delete(self, key: str) -> None:
with self._lock:
self._cache.pop(key, None)
def keys(self):
def keys(self) -> list[str]:
with self._lock:
return list(self._cache.keys())
def values(self):
def values(self) -> list[VT]:
with self._lock:
return list(self._cache.values())
def add_listener(self, callback):
def add_listener(self, callback: Callable[[VT], None]) -> None:
"""Register callback(value) which will be called whenever a new spot/alert item is added via set(). Used by the
web server (via SSEBroadcaster) to send SSE clients an update on every new spot."""
with self._listeners_lock:
self._listeners.append(callback)
def remove_listener(self, callback):
def remove_listener(self, callback: Callable[[VT], None]) -> None:
with self._listeners_lock:
self._listeners.remove(callback)
def save_snapshot(self):
def save_snapshot(self) -> None:
with self._lock:
# Store the time with the data so we can avoid loading anything nxt time that's older than TTL
data = [(k, v, time.time()) for k, v in self._cache.items()]
@@ -76,10 +80,10 @@ class LiveDataCache:
except Exception:
logger.exception(f"Failed to write snapshot to {self._snapshot_dir}")
def _load_snapshot(self):
def _load_snapshot(self) -> None:
try:
data = self._disk_cache.get("snapshot")
except Exception:
data: list[tuple[str, VT, float]] | None = self._disk_cache.get("snapshot")
except Exception: # noqa: BLE001 (If any exceptions we treat the data as junk and start from scratch, it's probably due to a version upgrade having incompatible data structures, fine to not log the exception in this case)
logger.warning(f"Failed to load snapshot from {self._snapshot_dir}, clearing it.")
self._disk_cache.clear()
return
@@ -94,8 +98,8 @@ class LiveDataCache:
self._cache[key] = value
logger.info(f"Loaded snapshot from {self._snapshot_dir}")
def _start_periodic_snapshot(self, interval):
def loop():
def _start_periodic_snapshot(self, interval: int) -> None:
def loop() -> None:
while not self._stop_event.wait(timeout=interval):
self.save_snapshot()
@@ -104,7 +108,7 @@ class LiveDataCache:
)
self._snapshot_thread.start()
def close(self):
def close(self) -> None:
self._stop_event.set()
if self._snapshot_thread:
self._snapshot_thread.join(timeout=15)
+1 -1
View File
@@ -25,7 +25,7 @@ memory_use_gauge = Gauge(
)
def get_metrics():
def get_metrics() -> bytes:
"""Get a Prometheus metrics response for the web server"""
return generate_latest(registry)
-153
View File
@@ -1,153 +0,0 @@
import logging
import re
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
from core.constants import SIGS
from core.data_store import DATA_STORE
from core.enums import SIGRefType
from core.geo_utils import wab_wai_square_to_lat_lon
from data.sig_ref import SIGRef
logger = logging.getLogger(__name__)
def get_sig_ref_info(sig_name, ref_id):
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig name and
a reference ID (both strings) and returns a SigRef object populated with as much data as we can find. This makes
use of SIG ref data in the data store, live lookups from the web, or just automatic calculation depending on which
SIG we are getting data for."""
if sig_name is None or sig_name == "" or ref_id is None or ref_id == "":
logger.debug("Failed to look up sig_ref info, sig or ref were not set.")
return None
# Sometimes we allow spaces instead of dashes in references due to common usage that way, but official reference
# lists never do, so convert them here.
ref_id = ref_id.replace(" ", "-")
# Prepare the object to be returned
sig_ref = SIGRef(sig=sig_name, id=ref_id)
# We can always get the reference type and the icon from the SIG itself
for sig in SIGS:
if sig.name.upper() == sig_name.upper():
sig_ref.ref_type = sig.ref_type
sig_ref.icon = sig.icon
try:
### FUDGES ###
#
# DME fudge. Our database has leading zeros padding to 5 digits which is the expected format, but not all
# activators add leading zeros. We also need to normalise "DME 01234" to "DME-01234" to match what's in our
# database.
if sig_name.upper() == "DME":
match = re.match(r"DME[\- ](\d{3,5})", ref_id, re.IGNORECASE)
if match:
number = match.group(1)
ref_id = f"DME-{number.zfill(5)}"
# DTMBA spotters sometimes include spaces and dashes, our regex allows them but they must be removed here so we
# can look up against the official list which doesn't have them
if sig_name.upper() == "DTMBA":
ref_id = ref_id.replace("-", "").replace(" ", "")
### NO DATA SIGS ###
#
# If the SIG is HEMA or BIWOTA, we have no way to either generate useful data or look it up on a reference list,
# so just skip the lookup here.
if sig_name.upper() == "HEMA" or sig_name.upper() == "BIWOTA":
return sig_ref
### PROGRAMMATIC DATA GENERATION INSTEAD OF LOOKUPS ###
#
# 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.
if sig_name.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 or not sig_ref.longitude):
ll = locator_to_latlong(str(sig_ref.grid))
sig_ref.latitude = ll[0]
sig_ref.longitude = ll[1]
return sig_ref
elif sig_name.upper() == "WAB" or sig_name.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 Exception:
logger.warning("Invalid lat/lon received for WAB/WAI reference")
return sig_ref
elif sig_name.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
if sig_ref.name:
sig_ref.url = f"https://www.beachesontheair.com/beaches/{sig_ref.name.lower().replace(' ', '-')}"
return sig_ref
elif sig_name.upper() == "GMA Islands":
# GMA Islands is a bit of a mess of GMA and IOTA references. Try looking them both up and see what returns
# the best result.
iota_lookup = get_sig_ref_info("IOTA", ref_id)
gma_lookup = get_sig_ref_info("GMA", ref_id)
for key, value in iota_lookup.__dict__.items():
if value is not None and sig_ref.__dict__.get(key) is None:
sig_ref.__dict__[key] = value
for key, value in gma_lookup.__dict__.items():
if value is not None and sig_ref.__dict__.get(key) is None:
sig_ref.__dict__[key] = value
sig_ref.ref_type = SIGRefType.ISLAND
return sig_ref
### ACTUAL LOOKUP ###
#
# OK, this is something we have to look up. Now check to see if our data store contains reference data and if
# so, copy the data into the sig_ref object
key = f"{sig_name}:{ref_id}"
try:
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
if lookup_data:
for attr, value in lookup_data.__dict__.items():
if value is not None and sig_ref.__dict__.get(attr) is None:
sig_ref.__dict__[attr] = value
else:
# Maybe a super new reference we don't know about yet, but more likely a typo or a test reference,
# just silently ignore it.
logger.debug(f"{sig_name} database did not contain data for ref {ref_id}")
except (ValueError, KeyError):
# Catch exceptions due to e.g. old versions of objects in the cache that are no longer compatible,
# and remove them from the cache.
del DATA_STORE.sigrefs[key]
return None
except Exception:
logger.exception(f"Exception when looking up sig_ref info for {sig_name} ref {ref_id}")
return sig_ref
def populate_missing_sig_ref_info(sig_ref):
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object
which must at minimum have a "sig" and an "id". The rest of the object will be populated and returned. Any data
currently in the object will be kept, only missing data in the object will be populated if it can be determined."""
lookup_data = get_sig_ref_info(sig_ref.sig, sig_ref.id)
if lookup_data:
# Copy new sig ref data into existing object where data was previously missing
for key, value in lookup_data.__dict__.items():
if value is not None and sig_ref.__dict__.get(key) is None:
sig_ref.__dict__[key] = value
return sig_ref
-24
View File
@@ -1,24 +0,0 @@
from core.constants import SIGS
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."""
for s in SIGS:
if s.name.upper() == sig.upper():
return s.ref_regex
return None
def get_sig_name_from_comment_name(sig):
"""Utility function to get the name of a SIG from its "comment name". Generally these will be the same but there are
some cases (e.g. is "TOTA" Towers, Tiles or Toilets?) where we need to transform one to the other."""
for s in SIGS:
if any(n.upper() == sig.upper() for n in s.comment_names):
return s.name
return None
# Regex matching any SIG's "comment name", i.e. how it may be referred to in spot comments
ANY_SIG_REGEX = rf"({'|'.join(n for s in SIGS for n in s.comment_names)})"
+14 -11
View File
@@ -1,18 +1,21 @@
import logging
import threading
from typing import Generic, TypeVar
import diskcache
logger = logging.getLogger(__name__)
T = TypeVar("T")
class SingleObjectDataCache:
"""Cache for status and solar conditions. This uses DiskCache, but unlike the standard DiskCache users like SIG and
callsign lookup handlers, status and solar conditions are persisted as a single object. If we just load the object
from DiskCache and modify it, DiskCache doesn't know that it's been updated and needs re-caching, so we provide a
store() method that any functions updating the object can call afterwards."""
def __init__(self, cache_dir, object_if_empty):
class SingleObjectDataCache(Generic[T]):
"""Cache for status and solar conditions. This uses DiskCache, but unlike the standard DiskCache users like
activity ref and callsign lookup handlers, status and solar conditions are persisted as a single object. If we
just load the object from DiskCache and modify it, DiskCache doesn't know that it's been updated and needs
re-caching, so we provide a store() method that any functions updating the object can call afterwards."""
def __init__(self, cache_dir: str, object_if_empty: T) -> None:
"""Initialize a SingleObjectDataCache. Provide the directory to load the cache from and save it to. If the cache
is empty, the provided object_if_empty parameter will be used to initialise it."""
@@ -22,24 +25,24 @@ class SingleObjectDataCache:
if "object" not in self._cache:
self._cache.add("object", object_if_empty)
try:
self._obj = self._cache.get("object")
except Exception:
self._obj: T = self._cache.get("object")
except Exception: # noqa: BLE001 (If any exceptions we treat the data as junk and start from scratch, it's probably due to a version upgrade having incompatible data structures, fine to not log the exception in this case)
logger.warning(f"Failed to load cache from {cache_dir}, clearing it.")
self._cache.clear()
self._cache.add("object", object_if_empty)
self._obj = object_if_empty
def get(self):
def get(self) -> T:
"""Get the data object. This can then be manipulated as necessary across multiple threads. Any function
modifying the object must remember to call store() afterwards."""
return self._obj
def store(self):
def store(self) -> None:
"""Store the updated object in the cache. Any function modifying the object must remember to call this
afterwards."""
with self._lock:
self._cache.set("object", self._obj)
def close(self):
def close(self) -> None:
self.store()
self._cache.close()
+6 -6
View File
@@ -21,11 +21,11 @@ logger = logging.getLogger(__name__)
class StatusReporter:
"""Provides a timed update of the application's status data."""
def __init__(self, run_interval):
def __init__(self, run_interval: float) -> None:
"""Constructor"""
self._run_interval = run_interval
self._thread = None
self._thread: Thread | None = None
self._stop_event = Event()
self._startup_time = datetime.now(pytz.UTC)
@@ -33,13 +33,13 @@ class StatusReporter:
DATA_STORE.status.get()["server_owner_callsign"] = SERVER_OWNER_CALLSIGN
DATA_STORE.status.store()
def start(self):
def start(self) -> None:
"""Start the reporter thread"""
self._thread = Thread(target=self._run, name="StatusReporter", daemon=True)
self._thread.start()
def stop(self):
def stop(self) -> None:
"""Stop any threads and prepare for application shutdown"""
self._stop_event.set()
@@ -48,7 +48,7 @@ class StatusReporter:
if self._thread.is_alive():
logger.warning("Status reporter worker thread did not exit on time and will be killed.")
def _run(self):
def _run(self) -> None:
"""Thread entry point: report immediately on startup, then on each interval until stopped"""
while True:
@@ -56,7 +56,7 @@ class StatusReporter:
if self._stop_event.wait(timeout=self._run_interval):
break
def _report(self):
def _report(self) -> None:
"""Write status information"""
DATA_STORE.status.get()["uptime"] = (datetime.now(pytz.UTC) - self._startup_time).total_seconds()
+6 -5
View File
@@ -1,7 +1,8 @@
import threading
from datetime import timedelta
from typing import Any
from requests_cache import CachedSession
from requests_cache import AnyResponse, CachedSession
from core.data_store import CACHE_DIR
@@ -14,14 +15,14 @@ class URLDataCache(CachedSession):
used across multiple threads, though note that URL lookups will block each other this way, so it is still better to
create one of these objects per thread if possible."""
def __init__(self, name):
def __init__(self, name: str) -> None:
super().__init__(
f"{CACHE_DIR}urls/{name}",
expire_after=timedelta(days=1),
allowable_codes=(200, 400, 401, 403, 404),
)
self._lock = threading.Lock()
self._get_lock = threading.Lock()
def get(self, *args, **kwargs):
with self._lock:
def get(self, *args: Any, **kwargs: Any) -> AnyResponse:
with self._get_lock:
return super().get(*args, **kwargs)
+18 -5
View File
@@ -1,19 +1,22 @@
import logging
import re
from typing import Any
import simplejson
from pyhamtools import Callinfo
from pyhamtools.frequency import freq_to_band
from pyhamtools.locator import latlong_to_locator
from core.constants import BANDS, UNKNOWN_BAND
from core.data_store import DATA_STORE
from core.enums import MODE_ALIASES, Continent, Mode, ModeType
from data.band import Band
from data.callsign import Callsign, LocationSourceForCallsign
logger = logging.getLogger(__name__)
def safe_json_dumps(obj):
def safe_json_dumps(obj: Any) -> str:
"""Safe version of json.dumps that also converts objects to dicts so they can be output, and ignores NaN floats
which are invalid in JSON."""
@@ -59,7 +62,7 @@ def infer_mode_type_from_mode(mode: str) -> ModeType | None:
return None
def infer_band_from_freq(freq):
def infer_band_from_freq(freq: float) -> Band:
"""Infer a band from a frequency in Hz"""
for b in BANDS:
@@ -68,7 +71,7 @@ def infer_band_from_freq(freq):
return UNKNOWN_BAND
def infer_mode_from_frequency(freq):
def infer_mode_from_frequency(freq: float) -> Mode | None:
"""Infer a mode from the frequency (in Hz) according to the band plan. Just a guess really."""
try:
@@ -113,14 +116,14 @@ def infer_mode_from_frequency(freq):
return None
def get_flag_for_dxcc(dxcc):
def get_flag_for_dxcc(dxcc: int) -> str | None:
"""Get an emoji flag for a given DXCC entity ID"""
dxcc_data = DATA_STORE.dxcc_data.get(dxcc, None)
return dxcc_data["flag"] if dxcc_data else None
def get_callsign_object_from_pyhamtools_callinfo(callsign, callinfo):
def get_callsign_object_from_pyhamtools_callinfo(callsign: str, callinfo: Callinfo) -> Callsign:
"""Utility function to take the data provided by a PyHamTools CallInfo object and populate our own Callsign data
object from it"""
@@ -156,3 +159,13 @@ def get_callsign_object_from_pyhamtools_callinfo(callsign, callinfo):
except (KeyError, ValueError):
# Unknown callsign, can't look anything up, return a Callsign object with basic data so that gets cached
return Callsign(call=callsign)
def decode_telnet_bytes(data: bytes) -> str:
"""Decode a line of text received from a telnet connection, which might be UTF-8 or Latin-1 depending on the
whims of the server owner."""
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return data.decode("latin-1")
+501
View File
@@ -0,0 +1,501 @@
from core.enums import ActivityName, ActivityRefType, ActivityType
from data.activity import Activity
ACTIVITIES: dict[ActivityName, Activity] = {
ActivityName.CONTEST: Activity(
name=ActivityName.CONTEST,
description="Contest",
sig_type=ActivityType.TRADITIONAL,
has_refs=False,
refs_globally_unique=False,
# No sensible way to determine *which* contest, but if we set comment_names=["CONTEST"] then at least
# any spots with "contest" in the comment will get allocated to this activity.
comment_names=["CONTEST", "TEST"],
icon="fa-trophy",
alerts_possible=True,
),
ActivityName.DXPEDITION: Activity(
name=ActivityName.DXPEDITION,
description="Radio expedition to a remote location",
sig_type=ActivityType.TRADITIONAL,
has_refs=False,
refs_globally_unique=False,
# DXpedition stations are never really spotted with "DXpedition" in the comments, but we can assign
# this activity to a spot other ways.
comment_names=[],
icon="fa-book-atlas",
alerts_possible=True,
),
ActivityName.SATELLITE: Activity(
name=ActivityName.SATELLITE,
description="Amateur Radio Satellite",
sig_type=ActivityType.TRADITIONAL,
has_refs=False,
refs_globally_unique=False,
comment_names=[],
icon="fa-satellite",
alerts_possible=True,
),
ActivityName.EME: Activity(
name=ActivityName.EME,
description="Earth-Moon-Earth (Moonbounce)",
sig_type=ActivityType.TRADITIONAL,
has_refs=False,
refs_globally_unique=False,
comment_names=[],
icon="fa-moon",
),
ActivityName.AERONAUTICAL_MOBILE: Activity(
name=ActivityName.AERONAUTICAL_MOBILE,
description="Aeronautical Mobile",
sig_type=ActivityType.TRADITIONAL,
has_refs=False,
refs_globally_unique=False,
# Don't pick /AM out of comments, spot.py will handle picking it out of the callsign
comment_names=[],
icon="fa-plane",
),
ActivityName.MARITIME_MOBILE: Activity(
name=ActivityName.MARITIME_MOBILE,
description="Maritime Mobile",
sig_type=ActivityType.TRADITIONAL,
has_refs=False,
refs_globally_unique=False,
# Don't pick /MM out of comments, spot.py will handle picking it out of the callsign
comment_names=[],
icon="fa-sailboat",
),
ActivityName.QRP: Activity(
name=ActivityName.QRP,
description="Low power",
sig_type=ActivityType.TRADITIONAL,
has_refs=False,
refs_globally_unique=False,
comment_names=["QRP"],
icon="fa-volume-low",
alerts_possible=True,
),
ActivityName.POTA: Activity(
name=ActivityName.POTA,
description="Parks on the Air",
sig_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.PARK,
ref_regex=r"[A-Z]{2}\-\d{4,5}|K\-TEST",
comment_names=["POTA"],
icon="fa-tree",
alerts_possible=True,
),
ActivityName.SOTA: Activity(
name=ActivityName.SOTA,
description="Summits on the Air",
sig_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.SUMMIT,
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
comment_names=["SOTA"],
icon="fa-mountain-sun",
alerts_possible=True,
),
ActivityName.WWFF: Activity(
name=ActivityName.WWFF,
description="World Wide Flora & Fauna",
sig_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.PARK,
ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}",
comment_names=["WWFF"],
icon="fa-seedling",
alerts_possible=True,
),
ActivityName.GMA: Activity(
name=ActivityName.GMA,
description="Global Mountain Activity",
sig_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.SUMMIT,
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
comment_names=["GMA"],
icon="fa-person-hiking",
),
ActivityName.WWBOTA: Activity(
name=ActivityName.WWBOTA,
description="Worldwide Bunkers on the Air",
sig_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.BUNKER,
ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}",
comment_names=["WWBOTA", "BOTA"],
icon="fa-radiation",
),
ActivityName.HEMA: Activity(
name=ActivityName.HEMA,
description="HuMPs Excluding Marilyns Award",
sig_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.SUMMIT,
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}",
comment_names=["HEMA"],
icon="fa-mound",
alerts_possible=True,
),
ActivityName.IOTA: Activity(
name=ActivityName.IOTA,
description="Islands on the Air",
sig_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.ISLAND,
ref_regex=r"(?:AF|AN|AS|EU|NA|OC|SA)\-\d{3}",
comment_names=["IOTA"],
icon="fa-book-atlas",
),
ActivityName.GMA_ISLANDS: Activity(
name=ActivityName.GMA_ISLANDS,
description="Global Mountain Activity - Islands",
sig_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.ISLAND,
ref_regex=r"(?:(?:[A-Z]{2}\-\d{3})|(?:[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}))",
comment_names=[],
icon="fa-person-hiking",
),
ActivityName.ARLHS: Activity(
name=ActivityName.ARLHS,
description="Amateur Radio Lighthouse Society",
sig_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.LIGHTHOUSE,
ref_regex=r"[A-Z]{3}[\- ]\d{3,4}",
comment_names=["ARLHS"],
icon="fa-house-flood-water",
),
ActivityName.ILLW: Activity(
name=ActivityName.ILLW,
description="International Lighthouse & Lightship Weekend",
sig_type=ActivityType.EVENT,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.LIGHTHOUSE,
ref_regex=r"[A-Z]{2}\d{4}",
comment_names=["ILLW"],
icon="fa-house-flood-water",
),
ActivityName.MOTA: Activity(
name=ActivityName.MOTA,
description="Mills on the Air",
sig_type=ActivityType.EVENT,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.MILL,
ref_regex=r"X\d{4,6}",
comment_names=["MOTA"],
icon="fa-fan",
),
ActivityName.SIOTA: Activity(
name=ActivityName.SIOTA,
description="Silos on the Air",
sig_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.SILO,
ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d",
comment_names=["SIOTA"],
icon="fa-wheat-awn",
alerts_possible=True,
),
ActivityName.WCA: Activity(
name=ActivityName.WCA,
description="World Castles Award",
sig_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.CASTLE,
ref_regex=r"[A-Z0-9]{1,3}\-\d{5}",
comment_names=["WCA"],
icon="fa-chess-rook",
),
ActivityName.ZLOTA: Activity(
name=ActivityName.ZLOTA,
description="New Zealand on the Air",
sig_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=True,
ref_type=None,
ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}",
comment_names=["ZLOTA"],
icon="fa-kiwi-bird",
region_flag="🇳🇿",
alerts_possible=True,
),
ActivityName.WOTA: Activity(
name=ActivityName.WOTA,
description="Wainwrights on the Air",
sig_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.SUMMIT,
ref_regex=r"[A-Z]{3}-[0-9]{2}",
comment_names=["WOTA"],
icon="fa-w",
region_flag="🇬🇧",
alerts_possible=True,
),
ActivityName.BOTA: Activity(
name=ActivityName.BOTA,
description="Beaches on the Air",
sig_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.BEACH,
comment_names=[],
icon="fa-umbrella-beach",
alerts_possible=True,
),
ActivityName.KRMNPA: Activity(
name=ActivityName.KRMNPA,
description="Keith Roget Memorial National Parks Award",
sig_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.PARK,
ref_regex=r"VKFF\-\d{4}",
comment_names=["KRMNPA"],
icon="fa-earth-oceania",
region_flag="🇦🇺",
alerts_possible=True,
),
ActivityName.SANPCPA: Activity(
name=ActivityName.SANPCPA,
description="South Australian National Parks and Conservation Parks Award",
sig_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.PARK,
ref_regex=r"VKFF\-\d{4}",
comment_names=["SANPCPA"],
icon="fa-earth-oceania",
region_flag="🇦🇺",
alerts_possible=True,
),
ActivityName.LLOTA: Activity(
name=ActivityName.LLOTA,
description="Lagos y Lagunas on the Air",
sig_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.LAKE,
ref_regex=r"LL[A-Z]{2}\-\d{4}",
comment_names=["LLOTA"],
icon="fa-water",
alerts_possible=True,
),
ActivityName.TOWERS: Activity(
name=ActivityName.TOWERS,
description="Towers on the Air",
sig_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.TOWER,
ref_regex=r"[A-Z]{2,3}R\-\d{4}",
comment_names=["TOTA"],
icon="fa-tower-observation",
),
ActivityName.TILES: Activity(
name=ActivityName.TILES,
description="Tiles on the Air",
sig_type=ActivityType.ADVENTURE,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.GRID,
ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}",
comment_names=[],
icon="fa-square",
),
ActivityName.RADAR_RALLY: Activity(
name=ActivityName.RADAR_RALLY,
description="RaDAR Rally",
sig_type=ActivityType.EVENT,
has_refs=False,
refs_globally_unique=False,
comment_names=["RaDAR"],
icon="fa-headset",
),
ActivityName.WAB: Activity(
name=ActivityName.WAB,
description="Worked All Britain",
sig_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.GRID,
ref_regex=r"[A-Z]{1,2}[0-9]{2}",
comment_names=["WAB"],
icon="fa-table-cells-large",
region_flag="🇬🇧",
),
ActivityName.WAI: Activity(
name=ActivityName.WAI,
description="Worked All Ireland",
sig_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.GRID,
ref_regex=r"[A-Z][0-9]{2}",
comment_names=["WAI"],
icon="fa-table-cells-large",
region_flag="🇮🇪",
),
ActivityName.DMF: Activity(
name=ActivityName.DMF,
description="Diplôme des Moulins de France",
sig_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.MILL,
comment_names=["DMF"],
icon="fa-fan",
region_flag="🇫🇷",
),
ActivityName.DME: Activity(
name=ActivityName.DME,
description="Diploma Municipios de España",
sig_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.TOWN,
ref_regex=r"DME[\- ]\d{3,5}",
comment_names=["DME"],
icon="fa-building",
region_flag="🇪🇸",
),
ActivityName.FEA: Activity(
name=ActivityName.FEA,
description="Diploma Faros de España",
sig_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.LIGHTHOUSE,
# FEA references are technically [DE]\-\d{4}(\.\d)? but spotters always seem to miss out the D- or E-
# prefix and just use FEA-1234 or FEA 1234, so allow for that. The FEA activity ref data provider adds both
# forms to the database.
ref_regex=r"(?:[DE]|FEA)[\- ]\d{4}(?:\.\d)?",
comment_names=["FEA"],
icon="fa-house-flood-water",
region_flag="🇪🇸",
),
ActivityName.DMUE: Activity(
name=ActivityName.DMUE,
description="Diploma Museos de España",
sig_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.BUILDING,
ref_regex=r"MUE[A-Z]{2}-\d{3}",
comment_names=["DMUE"],
icon="fa-landmark",
region_flag="🇪🇸",
),
ActivityName.DMVE: Activity(
name=ActivityName.DMVE,
description="Diploma Monumentos y Vestigios de España",
sig_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.BUILDING,
ref_regex=r"MV[A-Z]{1,2}-\d{4}",
comment_names=["DMVE"],
icon="fa-monument",
region_flag="🇪🇸",
),
ActivityName.DCE: Activity(
name=ActivityName.DCE,
description="Diploma Castillos de España",
sig_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.CASTLE,
ref_regex=r"C[A-Z]{1,2}-\d{3}",
comment_names=["DCE"],
icon="fa-chess-rook",
region_flag="🇪🇸",
),
ActivityName.DEFE: Activity(
name=ActivityName.DEFE,
description="Diploma Estaciones de Ferrocarril de España",
sig_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.BUILDING,
ref_regex=r"EF[A-Z]{1,2}-\d{3}",
comment_names=["DEFE"],
icon="fa-train",
region_flag="🇪🇸",
),
ActivityName.DTMBA: Activity(
name=ActivityName.DTMBA,
description="Diploma Teatri Musei e Belle Arti",
sig_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.BUILDING,
ref_regex=r"I-?[0-9]{3,4}\s?[A-Z]{2}",
comment_names=["DTMBA"],
icon="fa-landmark",
region_flag="🇮🇹",
),
ActivityName.BIWOTA: Activity(
name=ActivityName.BIWOTA,
description="British Inland Waterways on the Air",
sig_type=ActivityType.EVENT,
has_refs=False,
refs_globally_unique=False,
ref_type=ActivityRefType.WATERWAY,
comment_names=["BIWOTA"],
icon="fa-ship",
region_flag="🇬🇧",
),
ActivityName.COTA: Activity(
name=ActivityName.COTA,
description="Castles on the Air",
sig_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.CASTLE,
ref_regex=r"[A-Z]{3}\-[0-9]{3,5}",
comment_names=["COTA"],
icon="fa-chess-rook",
region_flag="🇩🇪",
),
ActivityName.PGA: Activity(
name=ActivityName.PGA,
description="Polish Gmina Award",
sig_type=ActivityType.REGIONAL,
has_refs=True,
refs_globally_unique=False,
ref_type=ActivityRefType.REGION,
ref_regex=r"[A-Z]{2}[0-9]{2}",
comment_names=["PGA"],
icon="fa-g",
region_flag="🇵🇱",
),
ActivityName.TOILETS: Activity(
name=ActivityName.TOILETS,
description="Toilets on the Air",
sig_type=ActivityType.EVENT,
has_refs=True,
refs_globally_unique=True,
ref_type=ActivityRefType.TOILET,
ref_regex=r"T\-[0-9]{2}",
comment_names=[],
icon="fa-toilet",
region_flag="🏴‍☠️",
),
}
+49
View File
@@ -0,0 +1,49 @@
from dataclasses import dataclass, field
from core.enums import ActivityName, ActivityRefType, ActivityType
@dataclass
class Activity:
"""Data class that defines an Activity (formerly referred to as a "Special Interest Group" or "SIG", a term
which is still used for the `sig` field name in the API for backwards compatibility). Each contains a name and
a longer form description. They also contain comment_names which attempts to separate out the way people might
refer to it in cluster comments from how it is referred to in the UI & API. (For example, "TOTA" in cluster
spot comments almost always means Towers on the Air, but no single programme is referred to in the UI as "TOTA"
as it's ambiguous between Towers, Toilets and Tiles. And while Beaches got the name "BOTA" first, "BOTA" spots
are much more likely to be bunkers.) Finally, there is a ref_regex which provides a regular expression to
match what references (such as parks and summits) look like for that programme."""
# Activity name as used in the UI and API, e.g. "Towers"
name: ActivityName
# Description, e.g. "Towers on the Air"
description: str
# Type, either Worldwide, Regional or Event. Used for sorting in the web UI.
# Note: this field is still named "sig_type" in the API for backwards compatibility.
sig_type: ActivityType
# Whether this activity has a fixed set of references (e.g. parks) with some sort of ID to "activate"
has_refs: bool
# Identifies that the activity's reference ID structure defined by its regex is unique across all programmes and
# anything else we expect a user to put in a spot comment, and therefore we can pull references out of
# spot comments without also needing to see the activity name first. For example, "OHFF-1234" or "B/G-1234" are
# obviously WWFF and WWBOTA, nothing else looks like those. But "SZ09" could be WAB or Tiles, "GB1234" could
# conceivably be POTA or ILLW, etc. So in those cases we need to see the name of the activity itself immediately
# before it.
refs_globally_unique: bool
# Reference type, what gets activated e.g. Park, Summit. May be None if the activity is for multiple types of
# things, in which case the spot data will have to provide this instead.
ref_type: ActivityRefType | None = None
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+". Only set when has_references is True and we know
# the fixed format references take for this activity.
ref_regex: str | None = None
# Activity names as they might appear in cluster spot comments, e.g. ["TOTA"]
comment_names: list[str] = field(default_factory=list)
# Icon to use in the UI when referencing this activity. Chosen from the Font Awesome set.
icon: str | None = None
# Emoji flag for the country or region where this activity is relevant, if any. If None, this implies the
# activity is in worldwide usage.
region_flag: str | None = None
# Can alerts ever have this activity? A lot of activities can be figured out from spot comments but can never
# occur in alerts, so to avoid showing all activities on the "upcoming" page, we allow it to filter based on
# this.
alerts_possible: bool = False
+6 -6
View File
@@ -1,24 +1,24 @@
from dataclasses import dataclass
from core.enums import SIGRefType
from core.enums import ActivityRefType
@dataclass
class SIGRef:
"""Data class that defines a Special Interest Group "info" or reference. As well as the basic reference ID we include a
class ActivityRef:
"""Data class that defines an Activity "info" or reference. As well as the basic reference ID we include a
name and a lookup URL."""
# SIG that this reference is in, e.g. "POTA".
# Activity that this reference is in, e.g. "POTA". Still named "sig" for backwards compatibility with the API.
sig: str
# Reference ID, e.g. "GB-0001".
id: str | None = None
# Name of the reference, e.g. "Null Country Park", if known.
name: str | None = None
# Type of the reference, e.g. "Park", if known.
ref_type: SIGRefType | None = None
ref_type: ActivityRefType | None = None
# URL to look up more information about the reference, if known.
url: str | None = None
# Icon to use for the reference, derived from the SIG. Chosen from the Font Awesome set.
# Icon to use for the reference, derived from the activity. Chosen from the Font Awesome set.
icon: str | None = None
# Latitude of the reference, in degrees, if known.
latitude: float | None = None
+25 -29
View File
@@ -6,10 +6,13 @@ from datetime import datetime, timedelta
import pytz
from core.activity_lookup_helper import populate_missing_activity_ref_info
from core.activity_utils import get_icon_for_activity
from core.call_lookup_helper import get_call_info
from core.enums import AlertType, Continent
from core.sig_lookup_helper import populate_missing_sig_ref_info
from core.enums import Continent
from core.utils import get_flag_for_dxcc
from data.activity_ref import ActivityRef
from data.lookup_credentials import LookupCredentials
logger = logging.getLogger(__name__)
@@ -24,9 +27,9 @@ class Alert:
# DX (alerting) operator info
# Callsigns of the operators that has been alerted
dx_calls: list | None = None
dx_calls: list[str] | None = None
# Names of the operators that has been alerted
dx_names: list | None = None
dx_names: list[str | None] | None = None
# Country of the DX operator
dx_country: str | None = None
# Country flag of the DX operator
@@ -54,17 +57,16 @@ class Alert:
end_time_iso: str | None = None
# Comment made by the alerter, if any
comment: str | None = None
# The type of alert this is: xOTA, DXpedition, or Contest.
alert_type: AlertType | None = None
# A URL link to more information, if any
url: str | None = None
# Special Interest Group info
# Activity info
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
# Activity (e.g. outdoor activity programme such as POTA). Still named "sig" for API backwards compatibility.
sig: str | None = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
sig_refs: list = field(default_factory=list)
# Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO. Still named
# "sig_refs" for API backwards compatibility.
sig_refs: list[ActivityRef] = field(default_factory=list)
# Timing info
@@ -87,7 +89,7 @@ class Alert:
# Icon to use when displaying this alert in the web UI. Chosen from the Font Awesome set.
icon: str | None = None
def infer_missing(self, credentials=None):
def infer_missing(self, credentials: LookupCredentials | None = None) -> None:
"""Infer missing parameters where possible"""
try:
@@ -125,15 +127,15 @@ class Alert:
if self.dx_dxcc_id and not self.dx_flag:
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
# Fetch SIG data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a reference
# in its initial call, we use this code to populate the rest of the data. This includes working out grid refs
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
# Fetch activity data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a
# reference in its initial call, we use this code to populate the rest of the data. This includes working
# out grid refs from WAB and WAI, which count as an activity even though there's no real lookup, just maths
if self.sig_refs:
for sig_ref in self.sig_refs:
populate_missing_sig_ref_info(sig_ref)
for activity_ref in self.sig_refs:
populate_missing_activity_ref_info(activity_ref)
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
# and apply it to the whole spot.
# If the spot itself doesn't have an activity yet, but we have at least one activity reference, take that
# reference's activity and apply it to the whole spot.
if self.sig_refs and self.sig_refs[0] and not self.sig:
self.sig = self.sig_refs[0].sig
@@ -152,26 +154,20 @@ class Alert:
if self.dx_calls and not self.dx_names:
self.dx_names = [get_call_info(c, credentials).name for c in self.dx_calls]
# Icon for the spot should be the icon of the first SIG ref if present, otherwise a radio tower
# Icon for the alert should be the icon of its activity if known, otherwise a radio tower
self.icon = "fa-tower-cell"
if self.alert_type == AlertType.DXPEDITION:
self.icon = "fa-globe-africa"
elif self.alert_type == AlertType.CONTEST:
self.icon = "fa-trophy"
elif self.alert_type == AlertType.SATELLITE:
self.icon = "fa-satellite"
elif self.sig_refs and self.sig_refs[0].icon:
self.icon = self.sig_refs[0].icon
if self.sig and (activity_icon := get_icon_for_activity(self.sig)):
self.icon = activity_icon
except Exception:
logger.exception("Exception while inferring missing data from spot")
def to_json(self):
def to_json(self) -> str:
"""JSON serialise"""
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
def expired(self):
def expired(self) -> bool:
"""Decide if this alert has expired (in which case it should not be added to the system in the first place, and not
returned by the web server if later requested, and removed by the cleanup functions). "Expired" is defined as
either having an end_time in the past, or if it only has a start_time, then that start time was more than 3 hours
+3 -3
View File
@@ -7,8 +7,8 @@ from core.enums import Continent, LocationSourceForCallsign
class Callsign:
"""Data class that defines a callsign and the data associated with it. This will have been retrieved by a callsign
lookup provider using data files or online lookup. This can be used to infer missing data for a spot, though if the
spot has a SIG (e.g. POTA) reference this data for their home location (or even just their country) will be less
accurate and should not be used in preference to that."""
spot has an activity (e.g. POTA) reference this data for their home location (or even just their country) will be
less accurate and should not be used in preference to that."""
# Callsign as spotted
call: str
@@ -40,7 +40,7 @@ class Callsign:
# Location source
location_source: LocationSourceForCallsign | None = None
def fully_populated(self):
def fully_populated(self) -> bool:
"""Utility method to indicate that the callsign data is fully populated. Multiple providers can return data for
a callsign, and we try them in sequence until we have all the data we can, in which case there's no point
querying any other providers."""
+3 -1
View File
@@ -1,5 +1,7 @@
from dataclasses import dataclass
from tornado.httputil import HTTPHeaders
@dataclass
class LookupCredentials:
@@ -13,7 +15,7 @@ class LookupCredentials:
hamqth_session_id: str = "" # alternative to username/password
def extract_credentials(headers):
def extract_credentials(headers: HTTPHeaders) -> LookupCredentials | None:
"""Build a LookupCredentials from HTTP request headers; returns None if no usable credentials are present."""
creds = LookupCredentials(
qrz_username=headers.get("X-QRZ-Username", ""),
-39
View File
@@ -1,39 +0,0 @@
from dataclasses import dataclass, field
from core.enums import SIGRefType, SIGType
@dataclass
class SIG:
"""Data class that defines a Special Interest Group. Each contains a name and a longer form description.
They also contain comment_names which attempts to separate out the way people might refer to it in
cluster comments from how it is referred to in the UI & API. (For example, "TOTA" in cluster spot comments
almost always means Towers on the Air, but no single programme is referred to in the UI as "TOTA" as
it's ambiguous between Towers, Toilets and Tiles. And while Beaches got the name "BOTA" first, "BOTA" spots
are much more likely to be bunkers.) Finally, there is a ref_regex which provides a regular expression to
match what references (such as parks and summits) look like for that programme."""
# SIG name as used in the UI and API, e.g. "Towers"
name: str
# Description, e.g. "Towers on the Air"
description: str
# Type, either Worldwide, Regional or Event. Used for sorting in the web UI.
sig_type: SIGType
# Identifies that the SIG's reference ID structure defined by its regex is unique across all programmes and
# anything else we expect a user to put in a spot comment, and therefore we can pull references out of
# spot comments without also needing to see the SIG name first. For example, "OHFF-1234" or "B/G-1234" are
# obviously WWFF and WWBOTA, nothing else looks like those. But "SZ09" could be WAB or Tiles, "GB1234" could
# conceivably be POTA or ILLW, etc.
refs_globally_unique: bool
# SIG names as they might appear in cluster spot comments, e.g. ["TOTA"]
comment_names: list[str] = field(default_factory=list)
# Reference type, what gets activated e.g. Park, Summit. May be None if the SIG is for multiple types of things, in
# which case the spot data will have to provide this instead.
ref_type: SIGRefType | None = None
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
ref_regex: str | None = None
# Icon to use in the UI when referencing this SIG. Chosen from the Font Awesome set.
icon: str | None = None
# Emoji flag for the country or region where this SIG is relevant, if any. If None, this implies the SIG is in
# worldwide usage.
region_flag: str | None = None
+14 -11
View File
@@ -1,5 +1,8 @@
import json
from dataclasses import dataclass
from typing import Any, TypeVar
T = TypeVar("T")
# Lookup tables for derived text descriptions.
# Each threshold-based table is a list of (min_value, description) pairs in descending order;
@@ -71,7 +74,7 @@ ELECTRON_FLUX_DESCRIPTIONS = [
]
def _xray_blackout_scale(xray):
def _xray_blackout_scale(xray: str | None) -> int:
"""Return the NOAA Radio Blackout scale number (R0-R5) for the given X-ray flux class string
(e.g. "M4.5", "X12")."""
@@ -93,7 +96,7 @@ def _xray_blackout_scale(xray):
return 0
def _lookup_by_threshold(value, table, default=None):
def _lookup_by_threshold(value: int | None, table: list[tuple[int, T]], default: T | None = None) -> T | None:
"""Return the description from a threshold table for the given numeric value.
The table is a list of (min_value, description) pairs in descending order."""
@@ -150,20 +153,20 @@ class SolarConditions:
# Geomagnetic background noise level, e.g. "S0", "S1", "S2"
geomag_noise: str | None = None
# HF band propagation conditions, keyed by "{band}-{time}" e.g. "80m-40m-day"
hf_conditions: dict | None = None
hf_conditions: dict[str, str] | None = None
# VHF propagation conditions, keyed by condition name
vhf_conditions: dict | None = None
vhf_conditions: dict[str, str | None] | None = None
# NOAA Kp index 3-day forecast, keyed by UNIX timestamp of the start of each 3-hour UTC period
k_index_forecast: dict | None = None
k_index_forecast: dict[float, float] | None = None
# NOAA Solar Radiation Storm (S1 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC
solar_storm_forecast: dict | None = None
solar_storm_forecast: dict[float, int] | None = None
# NOAA Radio Blackout (R1-R2) probability forecast, keyed by UNIX timestamp of start of day UTC
blackout_forecast_r1r2: dict | None = None
blackout_forecast_r1r2: dict[float, int] | None = None
# NOAA Radio Blackout (R3 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC
blackout_forecast_r3_or_greater: dict | None = None
blackout_forecast_r3_or_greater: dict[float, int] | None = None
# Ionosonde measurements, dict keyed by URSI code, values are dicts with keys: ursi, name, fof2, muf, luf,
# band_states. Populated by GIROIonosonde or KC2GProp providers.
ionosonde_data: dict | None = None
ionosonde_data: dict[str, Any] | None = None
# Derived values (populated by infer_descriptions())
# HF radio blackout risk description, derived from xray
@@ -183,7 +186,7 @@ class SolarConditions:
# Electron flux description, derived from electron_flux
electron_flux_desc: str | None = None
def infer_descriptions(self):
def infer_descriptions(self) -> None:
"""Populate derived text description fields from the current numeric/raw field values."""
if self.xray and len(self.xray) > 0:
@@ -196,7 +199,7 @@ class SolarConditions:
self.band_conditions_desc = _lookup_by_threshold(self.sfi, BAND_CONDITIONS_DESCRIPTIONS)
self.electron_flux_desc = _lookup_by_threshold(self.electron_flux, ELECTRON_FLUX_DESCRIPTIONS)
def to_json(self):
def to_json(self) -> str:
"""JSON serialise. Dict key order is insertion order (Python 3.7+ guarantee), so callers receive
fields in a predictable, logical sequence without relying on sort_keys."""
+159 -108
View File
@@ -9,18 +9,20 @@ from math import isnan
import pytz
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
from core.activity_lookup_helper import populate_missing_activity_ref_info
from core.activity_utils import (
ANY_ACTIVITY_REGEX,
get_activity_by_name,
get_activity_name_from_comment_name,
get_icon_for_activity,
get_ref_regex_for_activity,
)
from core.call_lookup_helper import get_call_info
from core.config import MAX_SPOT_AGE
from core.constants import PROPAGATION_MODES, SIGS
from core.constants import PROPAGATION_MODES
from core.data_store import DATA_STORE
from core.enums import Continent, LocationSourceForSpot, Mode, ModeSource, ModeType
from core.enums import ActivityName, Continent, LocationSourceForSpot, Mode, ModeSource, ModeType
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
from core.sig_lookup_helper import populate_missing_sig_ref_info
from core.sig_utils import (
ANY_SIG_REGEX,
get_ref_regex_for_sig,
get_sig_name_from_comment_name,
)
from core.utils import (
get_flag_for_dxcc,
infer_band_from_freq,
@@ -28,7 +30,9 @@ from core.utils import (
infer_mode_from_frequency,
infer_mode_type_from_mode,
)
from data.sig_ref import SIGRef
from data.activities import ACTIVITIES
from data.activity_ref import ActivityRef
from data.lookup_credentials import LookupCredentials
logger = logging.getLogger(__name__)
@@ -46,8 +50,8 @@ class Spot:
dx_call: str | None = None
# Name of the operator that has been spotted
dx_name: str | None = None
# QTH of the operator that has been spotted. This could be from any SIG refs or could be from online lookup of their
# home QTH.
# QTH of the operator that has been spotted. This could be from any activity refs or could be from online lookup of
# their home QTH.
dx_qth: str | None = None
# Country of the DX operator
dx_country: str | None = None
@@ -73,8 +77,8 @@ class Spot:
# DX Location source. Indicates how accurate the location might be.
dx_location_source: LocationSourceForSpot | None = None
# DX Location good. Indicates that the software thinks the location data is good enough to plot on a map. This is
# true if the location source is "SPOT", "SIG REF LOOKUP" or "GRID", or if the location source is "HOME QTH" and the
# DX callsign doesn't have a suffix like /P.
# true if the location source is "SPOT", "SIG REF LOOKUP" or "GRID", or if the location source is "HOME QTH" and
# the DX callsign doesn't have a suffix like /P. (Location source retains "SIG" wording for API compatibility.)
dx_location_good: bool = False
# DE (Spotter) info
@@ -120,12 +124,13 @@ class Spot:
# QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
qrt: bool = False
# Special Interest Group info
# Activity info
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
# Activity (e.g. outdoor activity programme such as POTA). Still named "sig" for API backwards compatibility.
sig: str | None = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
sig_refs: list = field(default_factory=list)
# Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO. Still named
# "sig_refs" for API backwards compatibility.
sig_refs: list[ActivityRef] = field(default_factory=list)
# Timing info
@@ -152,15 +157,18 @@ class Spot:
# Icon to use when displaying this spot in the web UI. Chosen from the Font Awesome set.
icon: str | None = None
def __post_init__(self):
def __post_init__(self) -> None:
"""Normalise fields that don't survive a plain dict to Spot conversion. This is used in the "add spot" API
endpoint where the client is submitting JSON, and we want to recreate a full Spot object, including nested
objects such as the sig_refs list.."""
if self.sig_refs:
self.sig_refs = [sig_ref if isinstance(sig_ref, SIGRef) else SIGRef(**sig_ref) for sig_ref in self.sig_refs]
self.sig_refs = [
activity_ref if isinstance(activity_ref, ActivityRef) else ActivityRef(**activity_ref)
for activity_ref in self.sig_refs
]
def infer_missing(self, credentials=None):
def infer_missing(self, credentials: LookupCredentials | None = None) -> None:
"""Infer missing parameters where possible"""
try:
@@ -262,76 +270,89 @@ class Spot:
if self.dx_latitude or self.dx_grid:
self.dx_location_source = LocationSourceForSpot.SPOT
# Set the top-level "SIG" if it is missing but we have at least one SIG ref.
# Set the top-level activity if it is missing but we have at least one activity ref.
if not self.sig and self.sig_refs:
self.sig = self.sig_refs[0].sig.upper()
# See if we already have a SIG reference, but the comment looks like it contains more for the same SIG. This
# should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002".
# See if we already have an activity reference, but the comment looks like it contains more for the same
# activity. This should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002".
if self.comment and self.sig_refs and self.sig_refs[0].sig:
sig = self.sig_refs[0].sig.upper()
regex = get_ref_regex_for_sig(sig)
activity = self.sig_refs[0].sig.upper()
regex = get_ref_regex_for_activity(activity)
if regex:
all_comment_ref_matches = re.finditer(r"(^|\W)(" + regex + r")($|\W)", self.comment, re.IGNORECASE)
for ref_match in all_comment_ref_matches:
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig))
self._append_activity_ref_if_missing(ActivityRef(id=ref_match.group(2).upper(), sig=activity))
# See if the comment looks like it contains any SIGs (and optionally SIG references) that we can
# add to the spot. This should catch cluster spot comments like "POTA GB-0001 WWFF GFF-0001" and e.g. POTA
# comments like "also WWFF GFF-0001".
# See if the comment looks like it contains any activities (and optionally activity references) that we
# can add to the spot. This should catch cluster spot comments like "POTA GB-0001 WWFF GFF-0001" and e.g.
# POTA comments like "also WWFF GFF-0001".
if self.comment:
sig_matches = re.finditer(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
for sig_match in sig_matches:
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster
# spots where the comment is just "POTA".
found_sig = get_sig_name_from_comment_name(sig_match.group(2))
activity_matches = re.finditer(r"(^|\W)" + ANY_ACTIVITY_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
for activity_match in activity_matches:
# First of all, if we haven't got an activity for this spot set yet, now we have. This covers
# things like cluster spots where the comment is just "POTA".
found_activity = get_activity_name_from_comment_name(activity_match.group(2))
if not self.sig:
self.sig = found_sig
self.sig = found_activity
# Now look to see if that SIG name was followed by something that looks like a reference ID for that SIG.
# If so, add that to the sig_refs list for this spot.
ref_regex = get_ref_regex_for_sig(found_sig)
if ref_regex:
# Now look to see if that activity name was followed by something that looks like a reference ID
# for that activity. If so, add that to the sig_refs list for this spot.
found_activity_info = get_activity_by_name(found_activity)
if found_activity and found_activity_info and found_activity_info.has_refs and found_activity_info.ref_regex:
ref_matches = re.finditer(
r"(^|\W)" + found_sig + r"([ -])(" + ref_regex + r")($|\W)",
r"(^|\W)" + found_activity + r"([ -])(" + found_activity_info.ref_regex + r")($|\W)",
self.comment,
re.IGNORECASE,
)
for ref_match in ref_matches:
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(3).upper(), sig=found_sig))
self._append_activity_ref_if_missing(
ActivityRef(id=ref_match.group(3).upper(), sig=found_activity)
)
# See if the comment looks like it contains any SIG references *without* the corresponding SIG name, but
# where the SIG reference is unique-looking enough that we can't confuse it with any other SIG.
# See if the comment looks like it contains any activity references *without* the corresponding activity
# name, but where the activity reference is unique-looking enough that we can't confuse it with any other
# activity.
if self.comment:
for sig in SIGS:
if sig.refs_globally_unique and sig.ref_regex:
ref_matches = re.finditer(r"(^|\W)(" + sig.ref_regex + r")($|\W)", self.comment, re.IGNORECASE)
for candidate_activity in ACTIVITIES.values():
if candidate_activity.has_refs and candidate_activity.refs_globally_unique and candidate_activity.ref_regex:
ref_matches = re.finditer(
r"(^|\W)(" + candidate_activity.ref_regex + r")($|\W)", self.comment, re.IGNORECASE
)
for ref_match in ref_matches:
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things
# like cluster spots where the comment is just "OHFF-1234", now we know it's WWFF.
# First of all, if we haven't got an activity for this spot set yet, now we have. This
# covers things like cluster spots where the comment is just "OHFF-1234", now we know
# it's WWFF.
if not self.sig:
self.sig = sig.name
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig.name))
self.sig = candidate_activity.name
self._append_activity_ref_if_missing(
ActivityRef(id=ref_match.group(2).upper(), sig=candidate_activity.name)
)
# Fetch SIG data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a reference
# in its initial call, we use this code to populate the rest of the data. This includes working out grid refs
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
# Fetch activity data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a
# reference in its initial call, we use this code to populate the rest of the data. This includes working
# out grid refs from WAB and WAI, which count as an activity even though there's no real lookup, just maths
if self.sig_refs:
for sig_ref in self.sig_refs:
sig_ref = populate_missing_sig_ref_info(sig_ref)
# If the spot itself doesn't have location yet, but the SIG ref does, extract it
if sig_ref.grid and not self.dx_grid:
self.dx_grid = sig_ref.grid
if sig_ref.latitude and not self.dx_latitude and sig_ref.longitude and not self.dx_longitude:
self.dx_latitude = sig_ref.latitude
self.dx_longitude = sig_ref.longitude
if self.sig == "WAB" or self.sig == "WAI" or self.sig == "Tiles":
for activity_ref in self.sig_refs:
activity_ref = populate_missing_activity_ref_info(activity_ref)
# If the spot itself doesn't have location yet, but the activity ref does, extract it
if activity_ref.grid and not self.dx_grid:
self.dx_grid = activity_ref.grid
if (
activity_ref.latitude
and not self.dx_latitude
and activity_ref.longitude
and not self.dx_longitude
):
self.dx_latitude = activity_ref.latitude
self.dx_longitude = activity_ref.longitude
if self.sig in (ActivityName.WAB, ActivityName.WAI, ActivityName.TILES):
self.dx_location_source = LocationSourceForSpot.GRID
else:
self.dx_location_source = LocationSourceForSpot.SIG_REF_LOOKUP
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
# and apply it to the whole spot.
# If the spot itself doesn't have an activity yet, but we have at least one activity reference, take that
# reference's activity and apply it to the whole spot.
if self.sig_refs and not self.sig:
self.sig = self.sig_refs[0].sig
@@ -339,20 +360,20 @@ class Spot:
# These are common on cluster spots and can provide grid references in preference to e.g. QRZ lookup, as well as
# being the only source we have for propagation mode. Brace for nightmare regex from hell.
if self.comment:
grid_mode_grid_match = re.search(
grid_match = re.search(
r"\b([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)(?:<([^>]*)>|\(([^)]*)\))([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\b",
self.comment,
)
if grid_mode_grid_match:
if grid_match:
# regex matches, so extract grids:
if not self.de_grid:
self.de_grid = grid_mode_grid_match.group(1).upper()
self.de_grid = grid_match.group(1).upper()
if not self.dx_grid:
self.dx_grid = grid_mode_grid_match.group(4).upper()
self.dx_grid = grid_match.group(4).upper()
self.dx_location_source = LocationSourceForSpot.GRID
# And extract propagation mode (group 2 for <...>, group 3 for (...)):
mode_tag = (grid_mode_grid_match.group(2) or grid_mode_grid_match.group(3) or "").upper()
mode_tag = (grid_match.group(2) or grid_match.group(3) or "").upper()
if mode_tag and not self.propagation_mode:
if mode_tag in PROPAGATION_MODES:
self.propagation_mode = PROPAGATION_MODES[mode_tag]
@@ -360,31 +381,59 @@ class Spot:
self.propagation_mode = mode_tag
logger.info(f"Seen a new propagation mode tag not yet in the system: {mode_tag}")
# Set SIGs based on propagation mode
if self.propagation_mode == "Satellite":
if not self.sig:
self.sig = "AMSAT"
if not any(sig_ref.sig == "AMSAT" for sig_ref in self.sig_refs):
self.sig_refs.append(SIGRef(sig="AMSAT"))
if self.propagation_mode == "Earth-Moon-Earth":
if not self.sig:
self.sig = "EME"
if not any(sig_ref.sig == "EME" for sig_ref in self.sig_refs):
self.sig_refs.append(SIGRef(sig="EME"))
# Parse "de_grid -> dx_grid" structures from the comment
if self.comment:
grid_mode_grid_match = re.search(
grid_match = re.search(
r"\b([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\s*->\s*([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\b",
self.comment,
)
if grid_mode_grid_match:
if grid_match:
# regex matches, so extract grids:
if not self.dx_grid:
self.dx_grid = grid_mode_grid_match.group(1).upper()
self.dx_location_source = LocationSourceForSpot.GRID
if not self.de_grid:
self.de_grid = grid_mode_grid_match.group(2).upper()
self.de_grid = grid_match.group(1).upper()
if not self.dx_grid:
self.dx_grid = grid_match.group(2).upper()
self.dx_location_source = LocationSourceForSpot.GRID
# Parse 6-digit grid structures from the comment if the frequency is VHF or above, as it's common to see
# VHF+ operators spotted with their grid
if self.comment and self.freq and self.freq > 30000000:
grid_match = re.search(
r"\b([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\b",
self.comment,
)
if grid_match and not self.dx_grid:
self.dx_grid = grid_match.group(1).upper()
self.dx_location_source = LocationSourceForSpot.GRID
# Set activities based on propagation mode
if self.propagation_mode == "Satellite" and not self.sig:
self.sig = ActivityName.SATELLITE
if self.propagation_mode == "Earth-Moon-Earth" and not self.sig:
self.sig = ActivityName.EME
# Set activities based on the DX callsign suffix
if self.dx_call and not self.sig:
if self.dx_call.upper().endswith(ActivityName.AERONAUTICAL_MOBILE):
self.sig = ActivityName.AERONAUTICAL_MOBILE
elif self.dx_call.upper().endswith(ActivityName.MARITIME_MOBILE):
self.sig = ActivityName.MARITIME_MOBILE
# Alright, now let's get really fancy. Check if the DX callsign matches one taking part in a currently
# running DXpedition which we know from the alerts list.
if self.dx_call and not self.sig:
now = datetime.now(pytz.UTC).timestamp()
for alert in DATA_STORE.alerts.values():
if (
alert.sig == ActivityName.DXPEDITION
and alert.dx_calls
and alert.start_time
and alert.end_time
and alert.start_time < now < alert.end_time
and self.dx_call.upper() in [c.upper() for c in alert.dx_calls if c]
):
self.sig = ActivityName.DXPEDITION
break
# DX Grid to lat/lon and vice versa in case one is missing
if self.dx_grid and (not self.dx_latitude or not self.dx_longitude):
@@ -393,12 +442,12 @@ class Spot:
self.dx_latitude = ll[0]
self.dx_longitude = ll[1]
except Exception:
logger.debug("Invalid grid received for spot")
logger.debug("Invalid grid received for spot", exc_info=True)
if self.dx_latitude and self.dx_longitude and not self.dx_grid:
try:
self.dx_grid = latlong_to_locator(self.dx_latitude, self.dx_longitude, 8)
except Exception:
logger.debug("Invalid lat/lon received for spot")
logger.debug("Invalid lat/lon received for spot", exc_info=True)
# QRT comment detection
if self.comment and not self.qrt:
@@ -424,12 +473,14 @@ class Spot:
self.dx_latitude = dx_call_info.latitude
self.dx_longitude = dx_call_info.longitude
self.dx_grid = dx_call_info.grid
self.dx_location_source = dx_call_info.location_source
self.dx_location_source = (
LocationSourceForSpot(dx_call_info.location_source) if dx_call_info.location_source else None
)
# Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable string,
# otherwise see what they have set on an online lookup service.
# Determine a "QTH" string. If we have an activity ref, pick the first one and turn it into a suitable
# string, otherwise see what they have set on an online lookup service.
if self.sig_refs:
qth = self.sig_refs[0].id
qth = self.sig_refs[0].id or ""
if self.sig_refs[0].name:
qth += f" {self.sig_refs[0].name}"
self.dx_qth = qth
@@ -450,8 +501,8 @@ class Spot:
# DXCC lookup from callsign if nothing else has provided it
if self.dx_call and not self.dx_dxcc_id:
for regex, entity_code in DATA_STORE.dxcc_lookup_by_call_regex:
if regex.pattern and regex.match(self.dx_call):
for dxcc_regex, entity_code in DATA_STORE.dxcc_lookup_by_call_regex:
if dxcc_regex.pattern and dxcc_regex.match(self.dx_call):
self.dx_dxcc_id = entity_code
break
if self.dx_dxcc_id and not self.dx_flag:
@@ -482,32 +533,32 @@ class Spot:
self.de_longitude = de_call_info.longitude
self.de_grid = de_call_info.grid
# Icon for the spot should be the icon of the first SIG ref if present, otherwise a radio tower
# Icon for the spot should be the icon of its activity if known, otherwise a radio tower
self.icon = "fa-tower-cell"
if self.sig_refs and self.sig_refs[0].icon:
self.icon = self.sig_refs[0].icon
if self.sig and (activity_icon := get_icon_for_activity(self.sig)):
self.icon = activity_icon
except Exception:
logger.exception("Exception while inferring missing data from spot")
def to_json(self):
def to_json(self) -> str:
"""JSON serialise"""
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
def _append_sig_ref_if_missing(self, new_sig_ref):
"""Append a sig_ref to the list, so long as it's not already there."""
def _append_activity_ref_if_missing(self, new_activity_ref: ActivityRef) -> None:
"""Append an activity ref to the list, so long as it's not already there."""
new_sig_ref.id = new_sig_ref.id.strip().upper()
new_sig_ref.sig = new_sig_ref.sig.strip().upper()
if new_sig_ref.id == "":
new_activity_ref.id = (new_activity_ref.id or "").strip().upper()
new_activity_ref.sig = new_activity_ref.sig.strip().upper()
if new_activity_ref.id == "":
return
for sig_ref in self.sig_refs:
if sig_ref.id == new_sig_ref.id and sig_ref.sig == new_sig_ref.sig:
for activity_ref in self.sig_refs:
if activity_ref.id == new_activity_ref.id and activity_ref.sig == new_activity_ref.sig:
return
self.sig_refs.append(new_sig_ref)
self.sig_refs.append(new_activity_ref)
def expired(self):
def expired(self) -> bool:
"""Decide if this spot has expired (in which case it should not be added to the system in the first place, and not
returned by the web server if later requested, and removed by the cleanup functions). "Expired" is defined as
either having a time further ago than the server's MAX_SPOT_AGE. If it somehow doesn't have a time either, it is
+2 -2
View File
@@ -5,7 +5,7 @@ You can embed Spothole's web interface in another website, e.g. for use as part
URL parameters can be used to trigger an "embedded" mode which hides the headers, footers and settings. In this mode,
you provide configuration for the various filter and display options via additional URL parameters. Any settings that
the user has set for Spothole are ignored. This is so that the embedding site can select, for example, their choice of
dark mode or SIG filters, which will not impact how Spothole appears when the user accesses it directly. Effectively, it
dark mode or activity filters, which will not impact how Spothole appears when the user accesses it directly. Effectively, it
becomes separate to their normal Spothole settings.
Setting `embedded` to true is important for the rest of the settings to be applied; otherwise, the user's defaults will
@@ -29,7 +29,7 @@ a mapping exists.
| `limit` | 25, 50, 100, 200, 500 | 100 | `?limit=100` | Sets the number of alerts that will be displayed on the alerts page |
| `max_age` | 300, 600, 1800, 3600 | 1800 | `?max_age=1800` | Sets the maximum age of spots displayed on the map and bands pages, in seconds. |
| `band` | Comma-separated list | (all) | `?band=20m,40m` | Sets the list of bands that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
| `sig` | Comma-separated list | (all) | `?sig=POTA,SOTA,NO_SIG` | Sets the list of SIGs that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
| `sig` | Comma-separated list | (all) | `?sig=POTA,SOTA,NO_SIG` | Sets the list of activities that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
| `source` | Comma-separated list | (all) | `?source=Cluster` | Sets the list of sources that will be shown on any spot or alert pages. Available options match the labels of the buttons in the standard web interface. |
| `mode_type` | Comma-separated list | (all) | `?mode_type=PHONE,CW` | Sets the list of mode types that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
| `dx_continent` | Comma-separated list | (all) | `?dx_continent=NA,SA` | Sets the list of DX Continents that will be shown on any spot or alert pages. Available options match the labels of the buttons in the standard web interface. |
+2 -2
View File
@@ -18,8 +18,8 @@ To navigate your way around the source code, this list may help.
services
* `/providers/callsign` - Classes providing callsign lookup data by accessing bundled data files or the APIs of other
services
* `/providers/sigrefdata` - Classes providing SIG reference lookup data by accessing bundled data files or the APIs of
other services
* `/providers/activityrefdata` - Classes providing activity reference lookup data by accessing bundled data files or
the APIs of other services
* `/webserver` - Classes for running Spothole's own web server
* `/telnetserver` - Classes for running Spothole's telnet server
* `spothole.py` - Main application script
@@ -1,47 +1,51 @@
import logging
from datetime import datetime
from threading import Event
from typing import Any
import pytz
from core.data_store import DATA_STORE
from data.activity_ref import ActivityRef
logger = logging.getLogger(__name__)
class SIGRefDataProvider:
"""Generic SIG reference data provider class. Subclasses of this query the individual URLs or files for data."""
class ActivityRefDataProvider:
"""Generic activity reference data provider class. Subclasses of this query the individual URLs or files for
data."""
def __init__(self, sig_name, provider_config):
"""Constructor"""
def __init__(self, sig_name: str, provider_config: dict[str, Any]) -> None:
"""Constructor. Note the parameter and attribute are still named "sig_name" for consistency with the API's
"sig" field name."""
self.sig_name = sig_name
self.enabled = provider_config["enabled"]
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled"
self.reference_count = 0
self.enabled: bool = provider_config["enabled"]
self.last_update_time: datetime = datetime.min.replace(tzinfo=pytz.UTC)
self.status: str = "Not Started" if self.enabled else "Disabled"
self.reference_count: int = 0
self._stop_event = Event()
def start(self):
def start(self) -> None:
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
raise NotImplementedError("Subclasses must implement this method")
def stop(self):
def stop(self) -> None:
"""Stop any threads and prepare for application shutdown. Subclasses should implement this method and call
super()."""
self._stop_event.set()
def _add_data(self, new_data):
def _add_data(self, new_data: list[ActivityRef]) -> None:
"""Add all the provided reference data objects to the data store."""
# with transact() batches all writes together to save making thousands of individual sqlite writes. However,
# that means that each provider holds the lock while it writes, and the default behaviour for other attempted
# transact()s is to fail if they can't get the lock (?!). This behaviour is fixed by retry=True.
with DATA_STORE.sigrefs.transact(retry=True):
with DATA_STORE.activity_refs.transact(retry=True):
for d in new_data:
DATA_STORE.sigrefs.set(f"{self.sig_name}:{d.id}", d)
DATA_STORE.activity_refs.set(f"{self.sig_name}:{d.id}", d)
# For the big data sources, loading will take a few minutes. If we want to shut down the software neatly
# within the first few minutes of startup, we need a way to abort this expensive process of filling up the
@@ -1,34 +1,37 @@
import csv
from time import sleep
from typing import Any
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider,
import requests
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadActivityRefDataProvider,
)
class ARLHS(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Amateur Radio Light House Society"""
class ARLHS(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Amateur Radio Light House Society"""
POLL_INTERVAL_DAYS = 30
SIG = "ARLHS"
ACTIVITY = ActivityName.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 __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
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,
ActivityRef(
sig=self.ACTIVITY,
id=ref_id,
name=row.get("Name", None),
ref_type=SIGRefType.LIGHTHOUSE,
ref_type=ActivityRefType.LIGHTHOUSE,
url=f"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,
@@ -41,7 +44,7 @@ class ARLHS(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
@@ -1,24 +1,26 @@
from time import sleep
from typing import Any
import requests
from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
class COTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Castles on the Air"""
class COTA(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Castles on the Air"""
POLL_INTERVAL_DAYS = 30
SIG = "COTA"
ACTIVITY = ActivityName.COTA
DATA_URL = "https://www.cotagroup.org/cotagroup/map/data/castles-all-7d90ee2a5e1175e5dece1bbf9dc87504.json"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
data = http_response.json()
if isinstance(data, list):
for ref in data[2]:
@@ -29,11 +31,11 @@ class COTA(FileDownloadSIGRefDataProvider):
grid = latlong_to_locator(lat, lon)
new_data.append(
SIGRef(
sig=self.SIG,
ActivityRef(
sig=self.ACTIVITY,
id=ref_id,
name=name,
ref_type=SIGRefType.CASTLE,
ref_type=ActivityRefType.CASTLE,
grid=grid,
latitude=lat,
longitude=lon,
@@ -45,7 +47,7 @@ class COTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
+44
View File
@@ -0,0 +1,44 @@
import io
from time import sleep
from typing import Any
import pandas as pd
import requests
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
class DCE(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Diploma Castillos de España"""
POLL_INTERVAL_DAYS = 365
ACTIVITY = ActivityName.DCE
DATA_URL = "https://www.acracb.org/dce/descargas/General/directorio_referencias_dce.xls"
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
file_stream = io.BytesIO(http_response.content)
df = pd.read_excel(file_stream, engine="xlrd", header=None)
for index, row in df.iterrows():
if row.iloc[0] and row.iloc[2]:
new_data.append(
ActivityRef(sig=self.ACTIVITY, id=row.iloc[0].strip(), name=row.iloc[2].strip(), ref_type=ActivityRefType.CASTLE)
)
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
# the data in this case
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
return new_data
@@ -1,25 +1,27 @@
import io
from time import sleep
from typing import Any
import pandas as pd
import requests
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
class DEFE(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Diploma Estationes de Ferrocarril de España"""
class DEFE(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Diploma Estationes de Ferrocarril de España"""
POLL_INTERVAL_DAYS = 365
SIG = "DEFE"
ACTIVITY = ActivityName.DEFE
DATA_URL = "https://www.acracb.org/defe/descargas/General/directorio_referencias_defe.xls"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
file_stream = io.BytesIO(http_response.content)
df = pd.read_excel(file_stream, engine="xlrd", header=None)
@@ -31,7 +33,7 @@ class DEFE(FileDownloadSIGRefDataProvider):
if row.iloc[0] and row.iloc[1]:
new_data.append(
SIGRef(sig=self.SIG, id=row.iloc[0].strip(), name=row.iloc[1].strip(), ref_type=SIGRefType.BUILDING)
ActivityRef(sig=self.ACTIVITY, id=row.iloc[0].strip(), name=row.iloc[1].strip(), ref_type=ActivityRefType.BUILDING)
)
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
@@ -39,7 +41,7 @@ class DEFE(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
@@ -1,32 +1,33 @@
import csv
from time import sleep
from typing import Any
from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.local_file_sig_ref_data_provider import (
LocalFileSIGRefDataProvider,
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.local_file_activity_ref_data_provider import (
LocalFileActivityRefDataProvider,
)
class DME(LocalFileSIGRefDataProvider):
"""SIG ref data provider for Diploma Municipios de Espana"""
class DME(LocalFileActivityRefDataProvider):
"""Activity ref data provider for Diploma Municipios de Espana"""
SIG = "DME"
ACTIVITY = ActivityName.DME
PATH = "datafiles/MUNICIPIOS.csv"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.PATH)
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.PATH)
def _file_to_data(self, path):
new_data = []
def _file_to_data(self, path: str) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
with open(path, encoding="latin-1") as _f:
for row in csv.DictReader(_f, delimiter=";"):
# Store reference IDs with the "DME-" prefix rather than just the number. This will prevent Spothole
# from agressively thinking every number in a spot comment is DME after it's seen "DME" once. The only
# numbers that count are straight after "DME " or "DME-". The dash versus space is normalised in
# sig_lookup_helper.py.
# activity_lookup_helper.py.
ref_id = "DME-" + row["COD_INE"][:5]
latitude = (
float(row["LATITUD_ETRS89_REGCAN95"].replace(",", "."))
@@ -39,10 +40,10 @@ class DME(LocalFileSIGRefDataProvider):
else None
)
ref = SIGRef(
sig=self.SIG,
ref = ActivityRef(
sig=self.ACTIVITY,
id=ref_id,
ref_type=SIGRefType.TOWN,
ref_type=ActivityRefType.TOWN,
name=f"{row['NOMBRE_ACTUAL']}, {row['PROVINCIA']}",
latitude=latitude,
longitude=longitude,
@@ -56,7 +57,7 @@ class DME(LocalFileSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
+40
View File
@@ -0,0 +1,40 @@
import csv
from time import sleep
from typing import Any
import requests
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
class DMUE(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Diploma Museos de España"""
POLL_INTERVAL_DAYS = 365
ACTIVITY = ActivityName.DMUE
DATA_URL = "https://dmue.radiogalena.es/nom_dmue.csv"
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
for row in csv.reader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"):
if len(row) > 1 and row[0] and row[1]:
new_data.append(
ActivityRef(sig=self.ACTIVITY, id=row[0].strip(), name=row[1].strip(), ref_type=ActivityRefType.BUILDING)
)
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
# the data in this case
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
return new_data
@@ -1,25 +1,27 @@
import io
from time import sleep
from typing import Any
import pandas as pd
import requests
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
class DMVE(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Diploma Monumentos y Vestigios de España"""
class DMVE(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Diploma Monumentos y Vestigios de España"""
POLL_INTERVAL_DAYS = 365
SIG = "DMVE"
ACTIVITY = ActivityName.DMVE
DATA_URL = "https://www.acracb.org/dmve/descargas/General/directorio_referencias_dmve.xls"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
file_stream = io.BytesIO(http_response.content)
# Despide the .xls extension this is actually an xlsx file, so we need openpyxl not xlrd
@@ -36,14 +38,14 @@ class DMVE(FileDownloadSIGRefDataProvider):
continue
if ref and name:
new_data.append(SIGRef(sig=self.SIG, id=ref.strip(), name=name.strip(), ref_type=SIGRefType.BUILDING))
new_data.append(ActivityRef(sig=self.ACTIVITY, id=ref.strip(), name=name.strip(), ref_type=ActivityRefType.BUILDING))
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
# the data in this case
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
+38
View File
@@ -0,0 +1,38 @@
from time import sleep
from typing import Any
import requests
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
class DTMBA(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Diploma Teatri Musei Belle Arti"""
POLL_INTERVAL_DAYS = 30
ACTIVITY = ActivityName.DTMBA
DATA_URL = "https://www.iu1fig.com/share/iz0eik/dtmba/export.php"
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
for row in http_response.content.decode("utf-8-sig").splitlines():
split = row.split(";")
ref_id = split[0]
ref_name = split[1]
new_data.append(ActivityRef(sig=self.ACTIVITY, id=ref_id, name=ref_name, ref_type=ActivityRefType.BUILDING))
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
# the data in this case
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
return new_data
@@ -1,25 +1,27 @@
from io import BytesIO
from time import sleep
from typing import Any
import pdfplumber
import requests
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
class FEA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Diploma Faros de España"""
class FEA(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Diploma Faros de España"""
POLL_INTERVAL_DAYS = 30
SIG = "FEA"
ACTIVITY = ActivityName.FEA
DATA_URL = "http://ea5ol.net/Lista%20Faros.pdf"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
# Use PDFPlumber to extract the tables in the PDF
with pdfplumber.open(BytesIO(http_response.content)) as pdf:
@@ -41,15 +43,15 @@ class FEA(FileDownloadSIGRefDataProvider):
# prefix and just use FEA-1234 or FEA 1234, so we add both copies to the database.
ref_id_1 = row[0].strip()
ref_id_2 = ref_id_1.replace("D-", "FEA-").replace("E-", "FEA-")
new_data.append(SIGRef(sig=self.SIG, id=ref_id_1, name=row[1].strip(), ref_type=SIGRefType.LIGHTHOUSE))
new_data.append(SIGRef(sig=self.SIG, id=ref_id_2, name=row[1].strip(), ref_type=SIGRefType.LIGHTHOUSE))
new_data.append(ActivityRef(sig=self.ACTIVITY, id=ref_id_1, name=row[1].strip(), ref_type=ActivityRefType.LIGHTHOUSE))
new_data.append(ActivityRef(sig=self.ACTIVITY, id=ref_id_2, name=row[1].strip(), ref_type=ActivityRefType.LIGHTHOUSE))
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
# the data in this case
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
@@ -1,83 +1,87 @@
import logging
from datetime import datetime
from threading import Thread
from typing import Any
import pytz
import requests
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
from core.constants import HTTP_HEADERS
from core.url_data_cache import URLDataCache
from providers.sigrefdata.sig_ref_data_provider import SIGRefDataProvider
from data.activity_ref import ActivityRef
from providers.activityrefdata.activity_ref_data_provider import ActivityRefDataProvider
logger = logging.getLogger(__name__)
class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
"""Generic SIG ref data provider class for providers that fetch their data from the web by downloading a file."""
class FileDownloadActivityRefDataProvider(ActivityRefDataProvider):
"""Generic activity 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: str, provider_config: dict[str, Any], url: str, poll_interval: float) -> None:
"""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._url_data_cache = URLDataCache(f"sigrefdata_{sig_name}")
self._thread: Thread | None = None
self._url_data_cache = URLDataCache(f"activity_ref_data_{sig_name}")
def start(self):
def start(self) -> None:
# 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.
logger.info(f"Set up query of {self.sig_name} SIG ref data every {self._poll_interval!s} days.")
self._thread = Thread(target=self._run, name=f"FileDownloadSIGRefDataProvider-{self.sig_name}", daemon=True)
logger.info(f"Set up query of {self.sig_name} activity ref data every {self._poll_interval!s} days.")
self._thread = Thread(target=self._run, name=f"FileDownloadActivityRefDataProvider-{self.sig_name}", daemon=True)
self._thread.start()
def stop(self):
def stop(self) -> None:
super().stop()
if self._thread:
self._thread.join(timeout=12)
if self._thread.is_alive():
logger.warning(f"{self.sig_name} SIG ref data worker thread did not exit on time and will be killed.")
logger.warning(f"{self.sig_name} activity ref data worker thread did not exit on time and will be killed.")
def _run(self):
def _run(self) -> None:
while True:
self._poll()
if self._stop_event.wait(timeout=self._poll_interval * 60 * 60 * 24):
break
def _poll(self):
def _poll(self) -> None:
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.
logger.debug(f"Downloading {self.sig_name} SIG ref data...")
logger.debug(f"Downloading {self.sig_name} activity ref data...")
http_response = self._url_data_cache.get(self._url, headers=HTTP_HEADERS)
# Check response code was good
if http_response.ok:
# Pass off to the subclass for processing
new_data = self._http_response_to_data(http_response)
# Add the new data to the SIG Ref data store
# Add the new data to the activity ref data store
if new_data:
self._add_data(new_data)
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logger.debug(f"Received SIG ref data for {self.sig_name}")
logger.debug(f"Received activity ref data for {self.sig_name}")
else:
self.status = "Error"
logger.warning(f"HTTP {http_response.status_code} when downloading SIG ref data for {self.sig_name}.")
logger.warning(f"HTTP {http_response.status_code} when downloading activity ref data for {self.sig_name}.")
except ConnectionError:
self.status = "Error"
logger.warning(f"Connection error when downloading SIG ref data for {self.sig_name}.")
logger.warning(f"Connection error when downloading activity ref data for {self.sig_name}.")
except (ConnectTimeout, ReadTimeout):
self.status = "Error"
logger.warning(f"Timeout when downloading SIG ref data for {self.sig_name}.")
logger.warning(f"Timeout when downloading activity ref data for {self.sig_name}.")
except Exception:
self.status = "Error"
logger.exception(f"Exception in HTTP SIG Ref Data Provider ({self.sig_name})")
logger.exception(f"Exception in HTTP Activity 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."""
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
"""Convert an HTTP response returned by the server into activity 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")
@@ -1,33 +1,36 @@
import csv
from time import sleep
from typing import Any
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider,
import requests
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadActivityRefDataProvider,
)
class GMA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Global Mountain Activity"""
class GMA(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Global Mountain Activity"""
POLL_INTERVAL_DAYS = 30
SIG = "GMA"
ACTIVITY = ActivityName.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 __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
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,
ActivityRef(
sig=self.ACTIVITY,
id=ref_id,
name=row.get("Name", None),
ref_type=SIGRefType.SUMMIT,
ref_type=ActivityRefType.SUMMIT,
url=f"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,
@@ -43,7 +46,7 @@ class GMA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
@@ -1,34 +1,37 @@
import csv
from time import sleep
from typing import Any
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider,
import requests
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadActivityRefDataProvider,
)
class ILLW(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for International Lighthouse & Lightship Weekend"""
class ILLW(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for International Lighthouse & Lightship Weekend"""
POLL_INTERVAL_DAYS = 30
SIG = "ILLW"
ACTIVITY = ActivityName.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 __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
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,
ActivityRef(
sig=self.ACTIVITY,
id=ref_id,
name=row.get("Name", None),
ref_type=SIGRefType.LIGHTHOUSE,
ref_type=ActivityRefType.LIGHTHOUSE,
url=f"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,
@@ -41,7 +44,7 @@ class ILLW(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
@@ -1,29 +1,31 @@
import logging
from time import sleep
from typing import Any
import requests
from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider,
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadActivityRefDataProvider,
)
logger = logging.getLogger(__name__)
class IOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Islands on the Air"""
class IOTA(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Islands on the Air"""
POLL_INTERVAL_DAYS = 365
SIG = "IOTA"
ACTIVITY = ActivityName.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 __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
data = http_response.json()
if isinstance(data, list):
for ref in data:
@@ -41,11 +43,11 @@ class IOTA(FileDownloadSIGRefDataProvider):
)
new_data.append(
SIGRef(
sig=self.SIG,
ActivityRef(
sig=self.ACTIVITY,
id=ref_id,
name=ref["name"],
ref_type=SIGRefType.ISLAND,
ref_type=ActivityRefType.ISLAND,
grid=grid,
latitude=latitude,
longitude=longitude,
@@ -57,7 +59,7 @@ class IOTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
+17
View File
@@ -0,0 +1,17 @@
from typing import Any
from core.enums import ActivityName
from providers.activityrefdata.pnp_kml_activity_ref_data_provider import (
ParksNPeaksKMLActivityRefDataProvider,
)
class KRMNPA(ParksNPeaksKMLActivityRefDataProvider):
"""Activity ref data provider for the Keith Roget Memorrial National Parks Award (KRMNPA)."""
POLL_INTERVAL_DAYS = 365
ACTIVITY = ActivityName.KRMNPA
DATA_URL = "https://parksnpeaks.org/getPOI.php?poiClass=KRMNPA&poiFormat=4"
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
@@ -1,26 +1,28 @@
from time import sleep
from typing import Any
import requests
from pyhamtools.locator import locator_to_latlong
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider,
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadActivityRefDataProvider,
)
class LLOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Lagos y Lagunas on the Air"""
class LLOTA(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Lagos y Lagunas on the Air"""
POLL_INTERVAL_DAYS = 7
SIG = "LLOTA"
ACTIVITY = ActivityName.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 __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
data = http_response.json()
if isinstance(data, list):
for ref in data:
@@ -29,11 +31,11 @@ class LLOTA(FileDownloadSIGRefDataProvider):
ll = locator_to_latlong(grid)
new_data.append(
SIGRef(
sig=self.SIG,
ActivityRef(
sig=self.ACTIVITY,
id=ref_id,
name=str(ref["name"]),
ref_type=SIGRefType.LAKE,
ref_type=ActivityRefType.LAKE,
url=f"https://llota.app/list/ref/{ref_id}",
grid=grid,
latitude=ll[0],
@@ -46,7 +48,7 @@ class LLOTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
@@ -0,0 +1,38 @@
import logging
from datetime import datetime
from typing import Any
import pytz
from data.activity_ref import ActivityRef
from providers.activityrefdata.activity_ref_data_provider import ActivityRefDataProvider
logger = logging.getLogger(__name__)
class LocalFileActivityRefDataProvider(ActivityRefDataProvider):
"""Generic activity ref data provider class for providers that fetch their data from a local file on startup."""
def __init__(self, sig_name: str, provider_config: dict[str, Any], path: str) -> None:
super().__init__(sig_name, provider_config)
self._path = path
def start(self) -> None:
logger.debug(f"Loading {self.sig_name} activity 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:
self.status = "Error"
logger.info(f"Failed to load activity ref data for {self.sig_name}")
except Exception:
self.status = "Error"
logger.exception(f"Exception in local file Activity Ref Data Provider ({self.sig_name})")
def _file_to_data(self, path: str) -> list[ActivityRef]:
"""Load a file on the given path and turn it into activity ref data."""
raise NotImplementedError("Subclasses must implement this method")
@@ -1,33 +1,36 @@
import csv
from time import sleep
from typing import Any
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider,
import requests
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadActivityRefDataProvider,
)
class MOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Mills on the Air"""
class MOTA(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Mills on the Air"""
POLL_INTERVAL_DAYS = 30
SIG = "MOTA"
ACTIVITY = ActivityName.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 __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
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,
ActivityRef(
sig=self.ACTIVITY,
id=ref_id,
name=row.get("Name", None),
ref_type=SIGRefType.MILL,
ref_type=ActivityRefType.MILL,
url=f"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,
@@ -40,7 +43,7 @@ class MOTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
@@ -1,24 +1,26 @@
from time import sleep
from typing import Any
import requests
from bs4 import BeautifulSoup
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
class PGA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Polish Gmina Award"""
class PGA(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Polish Gmina Award"""
POLL_INTERVAL_DAYS = 30
SIG = "PGA"
ACTIVITY = ActivityName.PGA
DATA_URL = "http://www.spga.pl/lista_pga2.php"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
soup = BeautifulSoup(http_response.text, "html.parser")
# Iterate through tables in the page
@@ -38,11 +40,11 @@ class PGA(FileDownloadSIGRefDataProvider):
continue
new_data.append(
SIGRef(
sig=self.SIG,
ActivityRef(
sig=self.ACTIVITY,
id=ref_id,
name=name,
ref_type=SIGRefType.REGION,
ref_type=ActivityRefType.REGION,
)
)
@@ -51,7 +53,7 @@ class PGA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
@@ -0,0 +1,79 @@
import re
from time import sleep
from typing import Any
import requests
from fastkml import kml
from fastkml.containers import Document, Folder
from fastkml.features import Placemark
from fastkml.geometry import Point
from pyhamtools.locator import latlong_to_locator
from core.enums import ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadActivityRefDataProvider,
)
class ParksNPeaksKMLActivityRefDataProvider(FileDownloadActivityRefDataProvider):
"""Base class for activity ref data providers that use parksnpeaks.org KML POI feeds and have references that
use the VKFF refs rather than their own system (i.e. KRMNPA and SANPCPA)."""
REF_PATTERN = re.compile(r"VKFF-\d+")
def __init__(self, sig_name: str, provider_config: dict[str, Any], url: str, poll_interval: float) -> None:
"""Set up the provider, note poll_interval is in *days*."""
super().__init__(sig_name, provider_config, url, poll_interval)
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
# KML content may carry an XML encoding declaration, which lxml's parser (used internally here) refuses to
# accept as a decoded str, so bytes must be passed even though the type stub only declares str.
k = kml.KML.from_string(http_response.content) # type: ignore[arg-type]
for document in k.features:
if not isinstance(document, Document):
continue
for folder in document.features:
if not isinstance(folder, Folder):
continue
for placemark in folder.features:
if not isinstance(placemark, Placemark):
continue
description = placemark.description or ""
match = self.REF_PATTERN.search(description)
if not match:
# No VKFF reference found in this placemark - skip it (e.g. non-park waypoints)
continue
ref_id = match.group(0)
if not isinstance(placemark.geometry, Point):
# Not a point location (e.g. a boundary polygon) - skip it, we can't get a single lat/lon from it
continue
longitude, latitude = placemark.geometry.x, placemark.geometry.y
ref = ActivityRef(
sig=self.sig_name,
id=ref_id,
name=placemark.name,
ref_type=ActivityRefType.PARK,
url=f"https://parksnpeaks.org/getPark.php?actPark={ref_id}",
latitude=latitude,
longitude=longitude,
)
if latitude and longitude:
ref.grid = latlong_to_locator(latitude, longitude, 6)
new_data.append(ref)
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to
# parse the rest of the data in this case
if self._stop_event.is_set():
return new_data
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
return new_data
@@ -1,33 +1,36 @@
import csv
from time import sleep
from typing import Any
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider,
import requests
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadActivityRefDataProvider,
)
class POTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Parks on the Air"""
class POTA(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Parks on the Air"""
POLL_INTERVAL_DAYS = 7
SIG = "POTA"
ACTIVITY = ActivityName.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 __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
ref_id = row["reference"]
new_data.append(
SIGRef(
sig=self.SIG,
ActivityRef(
sig=self.ACTIVITY,
id=ref_id,
name=row.get("name", None),
ref_type=SIGRefType.PARK,
ref_type=ActivityRefType.PARK,
url=f"https://pota.app/#/park/{ref_id}",
grid=row.get("grid", None),
latitude=float(row["latitude"]) if "latitude" in row and row["latitude"] != "" else None,
@@ -40,7 +43,7 @@ class POTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
+17
View File
@@ -0,0 +1,17 @@
from typing import Any
from core.enums import ActivityName
from providers.activityrefdata.pnp_kml_activity_ref_data_provider import (
ParksNPeaksKMLActivityRefDataProvider,
)
class SANPCPA(ParksNPeaksKMLActivityRefDataProvider):
"""Activity ref data provider for the South Australia National Parks and Conservation Parks Award (SANPCPA)."""
POLL_INTERVAL_DAYS = 365
ACTIVITY = ActivityName.SANPCPA
DATA_URL = "https://parksnpeaks.org/getPOI.php?poiClass=SANPCPA&poiFormat=4"
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
@@ -1,33 +1,36 @@
import csv
from time import sleep
from typing import Any
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider,
import requests
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadActivityRefDataProvider,
)
class SIOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Silos on the Air"""
class SIOTA(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Silos on the Air"""
POLL_INTERVAL_DAYS = 30
SIG = "SIOTA"
ACTIVITY = ActivityName.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 __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
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,
ActivityRef(
sig=self.ACTIVITY,
id=ref_id,
name=row.get("NAME", None),
ref_type=SIGRefType.SILO,
ref_type=ActivityRefType.SILO,
grid=row.get("LOCATOR", None),
latitude=float(row["LAT"]) if "LAT" in row else None,
longitude=float(row["LNG"]) if "LNG" in row else None,
@@ -39,7 +42,7 @@ class SIOTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
@@ -1,37 +1,39 @@
import csv
from time import sleep
from typing import Any
import requests
from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider,
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadActivityRefDataProvider,
)
class SOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Summits on the Air"""
class SOTA(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Summits on the Air"""
POLL_INTERVAL_DAYS = 30
SIG = "SOTA"
ACTIVITY = ActivityName.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 __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
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
altitude = float(row["AltM"]) if "AltM" in row and row["AltM"] != "" else None
ref = SIGRef(
sig=self.SIG,
ref = ActivityRef(
sig=self.ACTIVITY,
id=ref_id,
name=row.get("SummitName", None),
ref_type=SIGRefType.SUMMIT,
ref_type=ActivityRefType.SUMMIT,
url=f"https://www.sotadata.org.uk/en/summit/{ref_id}",
latitude=latitude,
longitude=longitude,
@@ -47,7 +49,7 @@ class SOTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
+42
View File
@@ -0,0 +1,42 @@
import csv
from typing import Any
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.local_file_activity_ref_data_provider import (
LocalFileActivityRefDataProvider,
)
class Toilets(LocalFileActivityRefDataProvider):
"""Activity ref data provider for Toilets on the Air"""
ACTIVITY = ActivityName.TOILETS
PATH = "datafiles/toilets.csv"
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.PATH)
def _file_to_data(self, path: str) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
with open(path) as _f:
csv_data = _f.read()
dr = csv.DictReader(csv_data.splitlines())
for row in dr:
new_data.append(
ActivityRef(
sig=self.ACTIVITY,
id=row["ref"],
name=row["ref"],
ref_type=ActivityRefType.TOILET,
latitude=float(row["lat"]),
longitude=float(row["lon"]),
)
)
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest
# of the data in this case
if self._stop_event.is_set():
break
return new_data
@@ -1,33 +1,36 @@
import csv
from time import sleep
from typing import Any
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider,
import requests
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadActivityRefDataProvider,
)
class Towers(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Towers on the Air"""
class Towers(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Towers on the Air"""
POLL_INTERVAL_DAYS = 30
SIG = "Towers"
ACTIVITY = ActivityName.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 __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
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,
ActivityRef(
sig=self.ACTIVITY,
id=ref_id,
name=row.get("Nazev", None),
ref_type=SIGRefType.TOWER,
ref_type=ActivityRefType.TOWER,
url=f"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,
@@ -40,7 +43,7 @@ class Towers(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
@@ -1,30 +1,32 @@
import csv
import logging
from time import sleep
from typing import Any
import requests
from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider,
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadActivityRefDataProvider,
)
logger = logging.getLogger(__name__)
class WCA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for World Castles Award"""
class WCA(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for World Castles Award"""
POLL_INTERVAL_DAYS = 30
SIG = "WCA"
ACTIVITY = ActivityName.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 __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
ref_id = row["REF"]
@@ -42,11 +44,11 @@ class WCA(FileDownloadSIGRefDataProvider):
logger.debug(f"Encountered dodgy formatting in WCA CSV, skipping location data for {ref_id}")
new_data.append(
SIGRef(
sig=self.SIG,
ActivityRef(
sig=self.ACTIVITY,
id=ref_id,
name=row.get("CLEAN NAME", None),
ref_type=SIGRefType.CASTLE,
ref_type=ActivityRefType.CASTLE,
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
latitude=latitude,
longitude=longitude,
@@ -59,7 +61,7 @@ class WCA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
@@ -1,24 +1,27 @@
from time import sleep
from typing import Any
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider,
import requests
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadActivityRefDataProvider,
)
class WOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Wainwrights on the Air"""
class WOTA(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Wainwrights on the Air"""
POLL_INTERVAL_DAYS = 365
SIG = "WOTA"
ACTIVITY = ActivityName.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 __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
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
@@ -29,12 +32,12 @@ class WOTA(FileDownloadSIGRefDataProvider):
url = f"https://www.wota.org.uk/MM_LDO-{number + 214!s}"
new_data.append(
SIGRef(
sig=self.SIG,
ActivityRef(
sig=self.ACTIVITY,
id=ref_id,
name=feature["properties"]["title"],
url=url,
ref_type=SIGRefType.SUMMIT,
ref_type=ActivityRefType.SUMMIT,
grid=feature["properties"]["qthLocator"],
latitude=feature["geometry"]["coordinates"][1],
longitude=feature["geometry"]["coordinates"][0],
@@ -47,7 +50,7 @@ class WOTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
@@ -1,33 +1,36 @@
import csv
from time import sleep
from typing import Any
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider,
import requests
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadActivityRefDataProvider,
)
class WWBOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Worldwide Bunkers on the Air"""
class WWBOTA(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Worldwide Bunkers on the Air"""
POLL_INTERVAL_DAYS = 30
SIG = "WWBOTA"
ACTIVITY = ActivityName.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 __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
ref_id = row["Reference"]
new_data.append(
SIGRef(
sig=self.SIG,
ActivityRef(
sig=self.ACTIVITY,
id=ref_id,
name=row.get("Name", None),
ref_type=SIGRefType.BUNKER,
ref_type=ActivityRefType.BUNKER,
url=f"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,
@@ -40,7 +43,7 @@ class WWBOTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
@@ -1,33 +1,36 @@
import csv
from time import sleep
from typing import Any
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider,
import requests
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadActivityRefDataProvider,
)
class WWFF(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Worldwide Flora & Fauna"""
class WWFF(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for Worldwide Flora & Fauna"""
POLL_INTERVAL_DAYS = 30
SIG = "WWFF"
ACTIVITY = ActivityName.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 __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
ref_id = row["reference"]
new_data.append(
SIGRef(
sig=self.SIG,
ActivityRef(
sig=self.ACTIVITY,
id=ref_id,
name=row.get("name", None),
ref_type=SIGRefType.PARK,
ref_type=ActivityRefType.PARK,
url=f"https://wwff.co/directory/?showRef={ref_id}",
grid=row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None,
latitude=float(row["latitude"])
@@ -44,7 +47,7 @@ class WWFF(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
@@ -1,26 +1,28 @@
from time import sleep
from typing import Any
import requests
from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider,
from core.enums import ActivityName, ActivityRefType
from data.activity_ref import ActivityRef
from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadActivityRefDataProvider,
)
class ZLOTA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for New Zealand on the Air"""
class ZLOTA(FileDownloadActivityRefDataProvider):
"""Activity ref data provider for New Zealand on the Air"""
POLL_INTERVAL_DAYS = 30
SIG = "ZLOTA"
ACTIVITY = ActivityName.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 __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response):
new_data = []
def _http_response_to_data(self, http_response: requests.Response) -> list[ActivityRef]:
new_data: list[ActivityRef] = []
data = http_response.json()
if isinstance(data, list):
for ref in data:
@@ -28,12 +30,12 @@ class ZLOTA(FileDownloadSIGRefDataProvider):
latitude = ref["latitude"]
longitude = ref["longitude"]
try:
ref_type = SIGRefType(ref["asset_type"].title().upper())
ref_type = ActivityRefType(ref["asset_type"].title().upper())
except ValueError:
ref_type = None
new_ref = SIGRef(
sig=self.SIG,
new_ref = ActivityRef(
sig=self.ACTIVITY,
id=ref_id,
name=ref["name"],
ref_type=ref_type,
@@ -58,7 +60,7 @@ class ZLOTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
+10 -6
View File
@@ -1,28 +1,31 @@
from datetime import datetime
from typing import Any
import pytz
from core.data_store import DATA_STORE
from core.live_data_cache import LiveDataCache
from data.alert import Alert
class AlertProvider:
"""Generic alert provider class. Subclasses of this query the individual APIs for alerts."""
def __init__(self, name, provider_config):
def __init__(self, name: str, provider_config: dict[str, Any]) -> None:
"""Constructor"""
self.name = name
self.enabled = provider_config.get("enabled", True)
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled"
self._alerts = DATA_STORE.alerts
self._alerts: LiveDataCache[Alert] = DATA_STORE.alerts
def start(self):
def start(self) -> None:
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
raise NotImplementedError("Subclasses must implement this method")
def _submit_batch(self, alerts):
def _submit_batch(self, alerts: list[Alert]) -> None:
"""Submit a batch of alerts retrieved from the provider. There is no timestamp checking like there is for spots,
because alerts could be created at any point for any time in the future. Rely on hashcode-based id matching
to deal with duplicates."""
@@ -35,11 +38,12 @@ class AlertProvider:
alert.infer_missing()
self._add_alert(alert)
def _add_alert(self, alert):
def _add_alert(self, alert: Alert) -> None:
assert alert.id is not None, "infer_missing() always assigns an id"
if not alert.expired():
self._alerts.set(alert.id, alert)
def stop(self):
def stop(self) -> None:
"""Stop any threads and prepare for application shutdown"""
raise NotImplementedError("Subclasses must implement this method")
+8 -6
View File
@@ -1,11 +1,13 @@
from datetime import datetime, timedelta
from typing import Any
import pytz
import requests
from bs4 import BeautifulSoup
from core.enums import AlertType
from core.enums import ActivityName
from data.activity_ref import ActivityRef
from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -15,10 +17,10 @@ class BOTA(HTTPAlertProvider):
POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://www.beachesontheair.com/"
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("BOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response):
def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
new_alerts = []
# Find the table of upcoming alerts
bs = BeautifulSoup(http_response.content.decode("utf-8-sig"), features="lxml")
@@ -56,9 +58,9 @@ class BOTA(HTTPAlertProvider):
alert = Alert(
source=self.name,
dx_calls=[dx_call],
sig_refs=[SIGRef(id=ref_name, sig="BOTA")],
sig=ActivityName.BOTA,
sig_refs=[ActivityRef(id=ref_name, sig=ActivityName.BOTA)],
start_time=date_time.timestamp(),
alert_type=AlertType.XOTA,
)
new_alerts.append(alert)
+10 -8
View File
@@ -1,10 +1,12 @@
from datetime import datetime
from typing import Any
import pytz
import requests
from core.enums import AlertType
from core.enums import ActivityName
from data.activity_ref import ActivityRef
from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -14,10 +16,10 @@ class Hamsat(HTTPAlertProvider):
POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://hams.at/api/alerts"
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("Hamsat", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response):
def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
new_alerts = []
# Iterate through source data
for source_alert in http_response.json()["data"]:
@@ -35,10 +37,11 @@ class Hamsat(HTTPAlertProvider):
dx_calls=[source_alert["callsign"].upper()],
freqs_modes=freqs_modes,
comment=source_alert["comment"],
# Fudge a SIG ref to provide the remaining bits of data we need: the satellite and the operator's grid
sig=ActivityName.SATELLITE,
# Fudge an activity ref to provide the remaining bits of data we need: the satellite and the operator's grid
sig_refs=[
SIGRef(
sig="AMSAT",
ActivityRef(
sig=ActivityName.SATELLITE,
id=f"{source_alert['satellite']['name']} from {source_alert['grids'][0]}",
)
],
@@ -48,7 +51,6 @@ class Hamsat(HTTPAlertProvider):
end_time=datetime.strptime(source_alert["los_at"], "%Y-%m-%dT%H:%M:%SZ")
.replace(tzinfo=pytz.UTC)
.timestamp(),
alert_type=AlertType.SATELLITE,
)
# Add to our list
+9 -7
View File
@@ -1,12 +1,14 @@
import logging
from datetime import datetime
from threading import Event, Thread
from typing import Any
import pytz
import requests
from requests.exceptions import ConnectionError, ConnectTimeout, JSONDecodeError, ReadTimeout
from core.constants import HTTP_HEADERS
from data.alert import Alert
from providers.alert.alert_provider import AlertProvider
logger = logging.getLogger(__name__)
@@ -16,34 +18,34 @@ class HTTPAlertProvider(AlertProvider):
"""Generic alert provider class for providers that request data via HTTP(S). Just for convenience to avoid code
duplication. Subclasses of this query the individual APIs for data."""
def __init__(self, name, provider_config, url, poll_interval):
def __init__(self, name: str, provider_config: dict[str, Any], url: str, poll_interval: int) -> None:
super().__init__(name, provider_config)
self._url = url
self._poll_interval = poll_interval
self._thread = None
self._thread: Thread | None = None
self._stop_event = Event()
def start(self):
def start(self) -> None:
# 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.
logger.info(f"Set up query of {self.name} alert API every {self._poll_interval!s} seconds.")
self._thread = Thread(target=self._run, name=f"HTTPAlertProvider-{self.name}", daemon=True)
self._thread.start()
def stop(self):
def stop(self) -> None:
self._stop_event.set()
if self._thread:
self._thread.join(timeout=12)
if self._thread.is_alive():
logger.warning(f"{self.name} alert worker thread did not exit on time and will be killed.")
def _run(self):
def _run(self) -> None:
while True:
self._poll()
if self._stop_event.wait(timeout=self._poll_interval):
break
def _poll(self):
def _poll(self) -> None:
try:
# Request data from API
logger.debug(f"Polling {self.name} alert API...")
@@ -78,7 +80,7 @@ class HTTPAlertProvider(AlertProvider):
# Brief pause on error before the next poll, but still respond promptly to stop()
self._stop_event.wait(timeout=1)
def _http_response_to_alerts(self, http_response):
def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
"""Convert an HTTP response returned by the API into alert 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, XML, text, whatever
the API actually provides."""
+6 -5
View File
@@ -1,7 +1,8 @@
from datetime import datetime, time
from typing import cast
from datetime import date, datetime, time
from typing import Any, cast
import pytz
import requests
from icalendar import Calendar, Event
from data.alert import Alert
@@ -12,10 +13,10 @@ class ICALAlertProvider(HTTPAlertProvider):
"""Generic alert provider for iCal calendars. Defines an abstract method event_to_alert(event) that subclasses must
implement, and use it to convert an iCal event to an Alert object based on whatever format their iCal events use."""
def __init__(self, name, provider_config, url, poll_interval):
def __init__(self, name: str, provider_config: dict[str, Any], url: str, poll_interval: int) -> None:
super().__init__(name, provider_config, url, poll_interval)
def _http_response_to_alerts(self, http_response):
def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
new_alerts = []
cal = Calendar.from_ical(http_response.content)
@@ -34,7 +35,7 @@ class ICALAlertProvider(HTTPAlertProvider):
"""Convert an ICal event to an Alert object. Subclasses must implement this method."""
@staticmethod
def _to_utc_timestamp(value):
def _to_utc_timestamp(value: datetime | date) -> float:
"""Convert a date or datetime value from an iCal field into a UTC UNIX timestamp."""
# Datetime object so we can treat it as-is, check if it has a non-UTC tz and convert it if necessary
+6 -5
View File
@@ -1,12 +1,13 @@
import re
from datetime import datetime
from typing import cast
from typing import Any, cast
import pytz
import requests
from rss_parser import Parser
from rss_parser.models.rss import RSS
from core.enums import AlertType
from core.enums import ActivityName
from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -18,10 +19,10 @@ class NG3K(HTTPAlertProvider):
ALERTS_URL = "https://www.ng3k.com/adxo.xml"
AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE)
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
def _http_response_to_alerts(self, http_response):
def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
new_alerts = []
rss = cast(RSS, Parser.parse(http_response.content.decode("utf-8-sig")))
# Iterate through source data
@@ -89,7 +90,7 @@ class NG3K(HTTPAlertProvider):
comment=f"{by}; {comment}; {qsl_info}",
start_time=start_timestamp,
end_time=end_timestamp,
alert_type=AlertType.DXPEDITION,
sig=ActivityName.DXPEDITION,
)
# Add to our list.
+27 -29
View File
@@ -1,11 +1,13 @@
import logging
from datetime import datetime
from typing import Any
import pytz
import requests
from core.enums import AlertType
from core.enums import ActivityName
from data.activity_ref import ActivityRef
from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider
logger = logging.getLogger(__name__)
@@ -17,31 +19,27 @@ class ParksNPeaks(HTTPAlertProvider):
POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://parksnpeaks.org/api/ALERTS/"
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("ParksNPeaks", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response):
def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
new_alerts = []
# Iterate through source data
for source_alert in http_response.json():
# Calculate some things
sig = source_alert["Class"].upper()
activity = source_alert["Class"].upper()
if " - " in source_alert["Location"]:
split = source_alert["Location"].split(" - ")
sig_ref = split[0]
sig_ref_name = split[1]
ref_id = split[0]
ref_name = split[1]
else:
sig_ref = source_alert["WWFFID"]
sig_ref_name = source_alert["Location"]
ref_id = source_alert["WWFFID"]
ref_name = source_alert["Location"]
start_time = (
datetime.strptime(source_alert["alTime"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp()
)
sigrefs = []
# PnP can give us an alert of class "QRP" which is the only one that's not a real SIG in Spothole's list,
# so mask this out if we got it.
if sig != "QRP":
sigrefs = [SIGRef(id=sig_ref, sig=sig, name=sig_ref_name)]
activity_refs = [ActivityRef(id=ref_id, sig=activity, name=ref_name)]
# Convert to our alert format
alert = Alert(
@@ -50,29 +48,29 @@ class ParksNPeaks(HTTPAlertProvider):
dx_calls=[source_alert["CallSign"].upper()],
freqs_modes=f"{source_alert['Freq']} {source_alert['MODE']}",
comment=source_alert["Comments"],
sig_refs=sigrefs,
sig=activity,
sig_refs=activity_refs,
start_time=start_time,
alert_type=AlertType.XOTA,
)
# 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",
"HEMA",
"SIOTA",
"ZLOTA",
"KRMNPA",
"SANPCPA",
"LLOTA",
"QRP",
if activity and activity not in [
ActivityName.POTA,
ActivityName.SOTA,
ActivityName.WWFF,
ActivityName.HEMA,
ActivityName.SIOTA,
ActivityName.ZLOTA,
ActivityName.KRMNPA,
ActivityName.SANPCPA,
ActivityName.LLOTA,
ActivityName.QRP,
]:
logger.warning(f"PNP alert found with sig {sig}, developer needs to add support for this!")
logger.warning(f"PNP alert found with activity {activity}, 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
# the alert list. Note that while ZLOTA has its own spots API, it doesn't have its own alerts API. So that
# means the PnP *spot* provider rejects ZLOTA spots here, but the PnP *alerts* provider here allows ZLOTA.
if sig not in ["POTA", "SOTA", "WWFF"]:
if activity not in [ActivityName.POTA, ActivityName.SOTA, ActivityName.WWFF]:
new_alerts.append(alert)
return new_alerts
+9 -7
View File
@@ -1,10 +1,12 @@
from datetime import datetime
from typing import Any
import pytz
import requests
from core.enums import AlertType
from core.enums import ActivityName
from data.activity_ref import ActivityRef
from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -14,10 +16,10 @@ class POTA(HTTPAlertProvider):
POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://api.pota.app/activation"
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("POTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response):
def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
new_alerts = []
# Iterate through source data
for source_alert in http_response.json():
@@ -28,10 +30,11 @@ class POTA(HTTPAlertProvider):
dx_calls=[source_alert["activator"].upper()],
freqs_modes=source_alert["frequencies"],
comment=source_alert["comments"],
sig=ActivityName.POTA,
sig_refs=[
SIGRef(
ActivityRef(
id=source_alert["reference"],
sig="POTA",
sig=ActivityName.POTA,
name=source_alert["name"],
url=f"https://pota.app/#/park/{source_alert['reference']}",
)
@@ -45,7 +48,6 @@ class POTA(HTTPAlertProvider):
end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"], "%Y-%m-%d%H:%M")
.replace(tzinfo=pytz.UTC)
.timestamp(),
alert_type=AlertType.XOTA,
)
# Add to our list, but exclude any old spots that POTA can sometimes give us where even the end time is
+4 -3
View File
@@ -1,8 +1,9 @@
import re
from typing import Any
from icalendar import Event
from core.enums import AlertType, Continent
from core.enums import ActivityName, Continent
from data.alert import Alert
from providers.alert.ical_alert_provider import ICALAlertProvider
@@ -12,7 +13,7 @@ class RSGBICALAlertProvider(ICALAlertProvider):
handling specific to how RSGB's iCal events are formatted. This is still effectively an abstract class itself;
RSGB has two contest calendars (HF & VHF) that each subclass this."""
def __init__(self, name, provider_config, url, poll_interval):
def __init__(self, name: str, provider_config: dict[str, Any], url: str, poll_interval: int) -> None:
super().__init__(name, provider_config, url, poll_interval)
FREQ_PATTERN = re.compile(r"([\d.]+(?:MHz|GHz))|SHF")
@@ -69,7 +70,7 @@ class RSGBICALAlertProvider(ICALAlertProvider):
comment=summary,
start_time=start_timestamp,
end_time=end_timestamp,
alert_type=AlertType.CONTEST,
sig=ActivityName.CONTEST,
)
return alert
+3 -1
View File
@@ -1,3 +1,5 @@
from typing import Any
from providers.alert.rsgb_ical_alert_provider import RSGBICALAlertProvider
@@ -7,5 +9,5 @@ class RSGBHFContests(RSGBICALAlertProvider):
POLL_INTERVAL_DAYS = 30
ALERTS_URL = "https://calendar.google.com/calendar/ical/a5ff31ebb1b4834dc7fff4c5415ae8251c6a9aa11f98c6af6e472b6c552b1915%40group.calendar.google.com/public/basic.ics"
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("RSGB HF Contests", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
+3 -1
View File
@@ -1,3 +1,5 @@
from typing import Any
from providers.alert.rsgb_ical_alert_provider import RSGBICALAlertProvider
@@ -7,5 +9,5 @@ class RSGBVHFContests(RSGBICALAlertProvider):
POLL_INTERVAL_DAYS = 30
ALERTS_URL = "https://calendar.google.com/calendar/ical/40f3552bff39a016f1cdca205864177070dcad68d55be17eb061cb021f39f96c%40group.calendar.google.com/public/basic.ics"
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("RSGB VHF Contests", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
+9 -7
View File
@@ -1,10 +1,12 @@
from datetime import datetime
from typing import Any
import pytz
import requests
from core.enums import AlertType
from core.enums import ActivityName
from data.activity_ref import ActivityRef
from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -14,10 +16,10 @@ class SOTA(HTTPAlertProvider):
POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://api-db2.sota.org.uk/api/alerts/365/all/all"
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("SOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response):
def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
new_alerts = []
# Iterate through source data
for source_alert in http_response.json():
@@ -34,10 +36,11 @@ class SOTA(HTTPAlertProvider):
dx_names=[source_alert["activatorName"].upper()],
freqs_modes=source_alert["frequency"],
comment=source_alert["comments"],
sig=ActivityName.SOTA,
sig_refs=[
SIGRef(
ActivityRef(
id=f"{source_alert['associationCode']}/{source_alert['summitCode']}",
sig="SOTA",
sig=ActivityName.SOTA,
name=summit_name,
activation_score=summit_points,
)
@@ -45,7 +48,6 @@ class SOTA(HTTPAlertProvider):
start_time=datetime.strptime(source_alert["dateActivated"], "%Y-%m-%dT%H:%M:%SZ")
.replace(tzinfo=pytz.UTC)
.timestamp(),
alert_type=AlertType.XOTA,
)
# Add to our list
+5 -3
View File
@@ -1,6 +1,8 @@
from typing import Any
from icalendar import Event
from core.enums import AlertType
from core.enums import ActivityName
from data.alert import Alert
from providers.alert.ical_alert_provider import ICALAlertProvider
@@ -11,7 +13,7 @@ class WA7BNM(ICALAlertProvider):
POLL_INTERVAL_DAYS = 1
ALERTS_URL = "https://contestcalendar.com/weeklycontcustom.php"
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(
"WA7BNM Contest Calendar", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60
)
@@ -35,7 +37,7 @@ class WA7BNM(ICALAlertProvider):
url=url,
start_time=start_timestamp,
end_time=end_timestamp,
alert_type=AlertType.CONTEST,
sig=ActivityName.CONTEST,
)
return alert
+9 -7
View File
@@ -1,14 +1,16 @@
import logging
from datetime import datetime
from typing import cast
from typing import Any, cast
from xml.parsers.expat import ExpatError
import pytz
import requests
from rss_parser import Parser as RSSParser
from rss_parser.models.rss import RSS
from core.enums import ActivityName
from data.activity_ref import ActivityRef
from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider
logger = logging.getLogger(__name__)
@@ -21,10 +23,10 @@ class WOTA(HTTPAlertProvider):
ALERTS_URL = "https://www.wota.org.uk/alerts_rss.php"
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("WOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response):
def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
new_alerts = []
try:
@@ -52,7 +54,7 @@ class WOTA(HTTPAlertProvider):
if len(ref_split) > 1:
ref_name = str(ref_split[1])
except Exception:
logger.warning(f"Could not parse WOTA alert title: {source_alert.description}")
logger.warning(f"Could not parse WOTA alert title: {source_alert.description}", exc_info=True)
# Pick apart the description
comment = None
@@ -63,7 +65,7 @@ class WOTA(HTTPAlertProvider):
if len(desc_split) > 1:
comment = desc_split[1].strip()
except Exception:
logger.warning(f"Could not parse WOTA alert description: {source_alert.description}")
logger.warning(f"Could not parse WOTA alert description: {source_alert.description}", exc_info=True)
time = datetime.strptime(source_alert.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
@@ -74,7 +76,7 @@ class WOTA(HTTPAlertProvider):
dx_calls=[dx_call],
freqs_modes=freqs_modes,
comment=comment,
sig_refs=[SIGRef(id=ref, sig="WOTA", name=ref_name)] if ref else [],
sig_refs=[ActivityRef(id=ref, sig=ActivityName.WOTA, name=ref_name)] if ref else [],
start_time=time.timestamp(),
)
+8 -6
View File
@@ -1,10 +1,12 @@
from datetime import datetime
from typing import Any
import pytz
import requests
from core.enums import AlertType
from core.enums import ActivityName
from data.activity_ref import ActivityRef
from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -14,10 +16,10 @@ class WWFF(HTTPAlertProvider):
POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://spots.wwff.co/static/agendas.json"
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("WWFF", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response):
def _http_response_to_alerts(self, http_response: requests.Response) -> list[Alert]:
new_alerts = []
# Iterate through source data
for source_alert in http_response.json():
@@ -28,14 +30,14 @@ class WWFF(HTTPAlertProvider):
dx_calls=[source_alert["activator_call"].upper()],
freqs_modes=f"{source_alert['band']} {source_alert['mode']}",
comment=source_alert["remarks"],
sig_refs=[SIGRef(id=source_alert["reference"], sig="WWFF")],
sig=ActivityName.WWFF,
sig_refs=[ActivityRef(id=source_alert["reference"], sig=ActivityName.WWFF)],
start_time=datetime.strptime(source_alert["utc_start"], "%Y-%m-%d %H:%M:%S")
.replace(tzinfo=pytz.UTC)
.timestamp(),
end_time=datetime.strptime(source_alert["utc_end"], "%Y-%m-%d %H:%M:%S")
.replace(tzinfo=pytz.UTC)
.timestamp(),
alert_type=AlertType.XOTA,
)
# Add to our list
@@ -1,18 +1,22 @@
from typing import Any
import diskcache
from providers.callsigndata.callsign_data_provider import CallsignDataProvider
class APIQueryCallsignDataProvider(CallsignDataProvider):
"""Generic callsign data provider class for providers that fetch their data from the web on-demand using an API."""
def __init__(self, name, provider_config, storage):
def __init__(self, name: str, provider_config: dict[str, Any], storage: diskcache.Cache) -> None:
"""Set up the provider."""
super().__init__(name, provider_config, storage)
if self.enabled:
self.status = "Ready"
def start(self):
def start(self) -> None:
pass
def stop(self):
def stop(self) -> None:
pass
@@ -1,15 +1,19 @@
from datetime import datetime
from typing import Any
import diskcache
import pytz
from core.data_store import DATA_STORE
from data.callsign import Callsign
from data.lookup_credentials import LookupCredentials
class CallsignDataProvider:
"""Generic callsign reference data provider class. Subclasses of this set up the various mechanisms via which
Spothole can look up data for callsigns."""
def __init__(self, name, provider_config, storage):
def __init__(self, name: str, provider_config: dict[str, Any], storage: diskcache.Cache) -> None:
"""Constructor. As well as name and config, provide the storage object from DATA_STORE that will be used to
store the result of lookups to speed up future access."""
@@ -21,18 +25,18 @@ class CallsignDataProvider:
self.lookup_count = 0
self._storage = storage
def start(self):
def start(self) -> None:
"""Start the provider. This should return immediately after spawning threads to access remote resources, if
needed."""
raise NotImplementedError("Subclasses must implement this method")
def stop(self):
def stop(self) -> None:
"""Stop any threads and prepare for application shutdown"""
raise NotImplementedError("Subclasses must implement this method")
def lookup(self, callsign, lookup_credentials):
def lookup(self, callsign: str, lookup_credentials: LookupCredentials | None) -> Callsign | None:
"""Looks up data for the provided callsign. Takes a LookupCredentials object, which provides any credentials
that have been provided by the user for this session (QRZ.com/HamQTH) to allow us to look up using those
services on the user's behalf. (Clublog is looked up using an API key owned by the server and provided in its
@@ -57,7 +61,7 @@ class CallsignDataProvider:
else:
return None
def _perform_new_lookup(self, callsign, lookup_credentials):
def _perform_new_lookup(self, callsign: str, lookup_credentials: LookupCredentials | None) -> Callsign | None:
"""Makes a new request to the data source for callsign data."""
raise NotImplementedError("Subclasses must implement this method")
+5 -3
View File
@@ -1,5 +1,6 @@
import logging
from datetime import datetime
from typing import Any
import pytz
from pyhamtools import Callinfo, LookupLib
@@ -7,6 +8,7 @@ from pyhamtools import Callinfo, LookupLib
from core.data_store import DATA_STORE
from core.utils import get_callsign_object_from_pyhamtools_callinfo
from data.callsign import Callsign
from data.lookup_credentials import LookupCredentials
from providers.callsigndata.api_query_callsign_data_provider import (
APIQueryCallsignDataProvider,
)
@@ -17,9 +19,9 @@ logger = logging.getLogger(__name__)
class ClublogAPI(APIQueryCallsignDataProvider):
"""Callsign data provider for Clublog's API."""
_callinfo = None
_callinfo: Callinfo | None = None
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
# API key required for this provider
self._api_key = provider_config.get("api_key", "")
if self._api_key != "":
@@ -33,7 +35,7 @@ class ClublogAPI(APIQueryCallsignDataProvider):
super().__init__("Clublog API", provider_config, DATA_STORE.callsign_data_clublogapi)
def _perform_new_lookup(self, callsign, lookup_credentials):
def _perform_new_lookup(self, callsign: str, lookup_credentials: LookupCredentials | None) -> Callsign | None:
callsign_data = Callsign(call=callsign)
try:
+6 -4
View File
@@ -1,11 +1,13 @@
import gzip
import logging
from typing import Any
from pyhamtools import Callinfo, LookupLib
from core.data_store import DATA_STORE
from core.utils import get_callsign_object_from_pyhamtools_callinfo
from data.callsign import Callsign
from data.lookup_credentials import LookupCredentials
from providers.callsigndata.file_download_callsign_data_provider import (
FileDownloadCallsignDataProvider,
)
@@ -20,9 +22,9 @@ class ClublogXML(FileDownloadCallsignDataProvider):
DATA_URL = "https://cdn.clublog.org/cty.php"
CACHE_PATH_ZIPPED = "cache/cty.xml.gz"
CACHE_PATH_UNZIPPED = "cache/cty.xml"
_callinfo = None
_callinfo: Callinfo | None = None
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
# API key required for this provider
self._api_key = provider_config.get("api_key", "")
if self._api_key == "":
@@ -40,7 +42,7 @@ class ClublogXML(FileDownloadCallsignDataProvider):
DATA_STORE.callsign_data_clublogxml,
)
def _handle_file(self, path):
def _handle_file(self, path: str) -> bool:
try:
# The download from Clublog is gzipped so we need to uncompress that and re-save as a separate file that
# the LookupLib can actually use.
@@ -60,7 +62,7 @@ class ClublogXML(FileDownloadCallsignDataProvider):
logger.exception("Exception when loading Clublog XML.")
return False
def _perform_new_lookup(self, callsign, lookup_credentials):
def _perform_new_lookup(self, callsign: str, lookup_credentials: LookupCredentials | None) -> Callsign | None:
callsign_data = Callsign(call=callsign)
try:
+6 -4
View File
@@ -1,10 +1,12 @@
import logging
from typing import Any
from pyhamtools import Callinfo, LookupLib
from core.data_store import DATA_STORE
from core.utils import get_callsign_object_from_pyhamtools_callinfo
from data.callsign import Callsign
from data.lookup_credentials import LookupCredentials
from providers.callsigndata.file_download_callsign_data_provider import (
FileDownloadCallsignDataProvider,
)
@@ -18,9 +20,9 @@ class CountryFiles(FileDownloadCallsignDataProvider):
POLL_INTERVAL_DAYS = 30
DATA_URL = "https://www.country-files.com/cty/cty.plist"
CACHE_PATH = "cache/cty.plist"
_callinfo = None
_callinfo: Callinfo | None = None
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__(
"CountryFiles.com",
provider_config,
@@ -30,7 +32,7 @@ class CountryFiles(FileDownloadCallsignDataProvider):
DATA_STORE.callsign_data_countryfiles,
)
def _handle_file(self, path):
def _handle_file(self, path: str) -> bool:
try:
lookuplib = LookupLib(lookuptype="countryfile", filename=path)
self._callinfo = Callinfo(lookuplib)
@@ -40,7 +42,7 @@ class CountryFiles(FileDownloadCallsignDataProvider):
logger.exception("Exception when loading Country Files cty.plist.")
return False
def _perform_new_lookup(self, callsign, lookup_credentials):
def _perform_new_lookup(self, callsign: str, lookup_credentials: LookupCredentials | None) -> Callsign | None:
callsign_data = Callsign(call=callsign)
try:
@@ -1,7 +1,9 @@
import logging
from datetime import datetime
from threading import Event, Thread
from typing import Any
import diskcache
import pytz
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
@@ -15,40 +17,48 @@ logger = logging.getLogger(__name__)
class FileDownloadCallsignDataProvider(CallsignDataProvider):
"""Generic callsign data provider class for providers that fetch their data from the web by downloading a file."""
def __init__(self, name, provider_config, url, cache_file_path, poll_interval, storage):
def __init__(
self,
name: str,
provider_config: dict[str, Any],
url: str,
cache_file_path: str,
poll_interval: int,
storage: diskcache.Cache,
) -> None:
"""Set up the provider, note poll_interval is in *days*."""
super().__init__(name, provider_config, storage)
self._url = url
self._cache_file_path = cache_file_path
self._poll_interval = poll_interval
self._thread = None
self._thread: Thread | None = None
self._stop_event = Event()
self._url_data_cache = URLDataCache(f"callsigndata_{name}")
if self.enabled:
self.status = "Ready"
def start(self):
def start(self) -> None:
# 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.
logger.info(f"Set up query of {self.name} callsign reference data every {self._poll_interval!s} days.")
self._thread = Thread(target=self._run, name=f"FileDownloadCallsignDataProvider-{self.name}", daemon=True)
self._thread.start()
def stop(self):
def stop(self) -> None:
self._stop_event.set()
if self._thread:
self._thread.join(timeout=12)
if self._thread.is_alive():
logger.warning(f"{self.name} callsign data worker thread did not exit on time and will be killed.")
def _run(self):
def _run(self) -> None:
while True:
self._poll()
if self._stop_event.wait(timeout=self._poll_interval * 60 * 60 * 24):
break
def _poll(self):
def _poll(self) -> None:
try:
# Request the file. 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.
@@ -87,7 +97,7 @@ class FileDownloadCallsignDataProvider(CallsignDataProvider):
logger.exception(f"Exception in callsign reference data provider ({self.name})")
self._stop_event.wait(timeout=1)
def _handle_file(self, path):
def _handle_file(self, path: str) -> bool:
"""Handle an updated file downloaded from the server. Return true if successful, false otherwise."""
raise NotImplementedError("Subclasses must implement this method")
+6 -4
View File
@@ -1,6 +1,7 @@
import logging
import urllib.parse
from datetime import datetime, timedelta
from typing import Any
import pytz
import xmltodict
@@ -14,6 +15,7 @@ from core.data_store import CACHE_DIR, DATA_STORE
from core.enums import Continent
from core.url_data_cache import URLDataCache
from data.callsign import Callsign, LocationSourceForCallsign
from data.lookup_credentials import LookupCredentials
from providers.callsigndata.api_query_callsign_data_provider import (
APIQueryCallsignDataProvider,
)
@@ -24,7 +26,7 @@ logger = logging.getLogger(__name__)
class HamQTH(APIQueryCallsignDataProvider):
"""Callsign data provider for HamQTH."""
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("HamQTH", provider_config, DATA_STORE.callsign_data_hamqth)
self._HAMQTH_BASE_URL = "https://www.hamqth.com/xml.php"
self._PRG = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}".replace(" ", "_")
@@ -33,7 +35,7 @@ class HamQTH(APIQueryCallsignDataProvider):
# and password, this is valid for an hour, so our cache stores this specifically for 55 minutes.
self._CREDENTIALS_CACHE = CachedSession(f"{CACHE_DIR}/urls/hamqth-creds", expire_after=timedelta(minutes=55))
def _perform_new_lookup(self, callsign, lookup_credentials):
def _perform_new_lookup(self, callsign: str, lookup_credentials: LookupCredentials | None) -> Callsign | None:
# If we don't have HamQTH credentials, skip this lookup Return None so we don't *cache* the lack of data, because
# # someone might provide credentials next time around.
if not lookup_credentials or not (
@@ -61,7 +63,7 @@ class HamQTH(APIQueryCallsignDataProvider):
logger.debug("HamQTH login details incorrect, failed to look up with HamQTH.")
return None
except Exception:
logger.error("Exception when getting HamQTH session key")
logger.exception("Exception when getting HamQTH session key")
return None
if not session_id:
@@ -117,7 +119,7 @@ class HamQTH(APIQueryCallsignDataProvider):
return None
@staticmethod
def hamqth_response_to_callsign(callsign, data):
def hamqth_response_to_callsign(callsign: str, data: dict[str, Any]) -> Callsign:
"""Convert the "Callsign" block in HamQTH's API response to our own Callsign object."""
# Check for sensible latitudes
+8 -4
View File
@@ -1,6 +1,7 @@
import logging
import urllib.parse
from datetime import datetime, timedelta
from typing import Any
import pytz
import xmltodict
@@ -13,6 +14,7 @@ from core.data_store import CACHE_DIR, DATA_STORE
from core.enums import Continent, LocationSourceForCallsign
from core.url_data_cache import URLDataCache
from data.callsign import Callsign
from data.lookup_credentials import LookupCredentials
from providers.callsigndata.api_query_callsign_data_provider import APIQueryCallsignDataProvider
logger = logging.getLogger(__name__)
@@ -21,7 +23,7 @@ logger = logging.getLogger(__name__)
class QRZ(APIQueryCallsignDataProvider):
"""Callsign data provider for QRZ.com."""
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("QRZ.com", provider_config, DATA_STORE.callsign_data_qrz)
self._QRZ_BASE_URL = "https://xmldata.qrz.com/xml/current/"
self._URL_DATA_CACHE = URLDataCache("qrz")
@@ -29,7 +31,7 @@ class QRZ(APIQueryCallsignDataProvider):
# and password, this is valid for an hour, so our cache stores this specifically for 55 minutes.
self._CREDENTIALS_CACHE = CachedSession(f"{CACHE_DIR}/urls/qrz-creds", expire_after=timedelta(minutes=55))
def _perform_new_lookup(self, callsign, lookup_credentials):
def _perform_new_lookup(self, callsign: str, lookup_credentials: LookupCredentials | None) -> Callsign | None:
# If we don't have QRZ credentials, skip this lookup. Return None so we don't *cache* the lack of data, because
# someone might provide credentials next time around.
if not lookup_credentials or not (
@@ -57,7 +59,7 @@ class QRZ(APIQueryCallsignDataProvider):
logger.debug("QRZ.com login details incorrect, failed to look up with QRZ.")
return None
except Exception:
logger.error("Exception when getting QRZ.com session key")
logger.exception("Exception when getting QRZ.com session key")
return None
if not session_key:
@@ -125,7 +127,7 @@ class QRZ(APIQueryCallsignDataProvider):
return None
@staticmethod
def qrz_response_to_callsign(callsign, data):
def qrz_response_to_callsign(callsign: str, data: dict[str, Any] | list[Any]) -> Callsign:
"""Convert the "Callsign" block in QRZ's API response to our own Callsign object."""
# I have encountered a user passing multiple callsigns to the QRZ lookup function in a way that QRZ actually
@@ -133,7 +135,9 @@ class QRZ(APIQueryCallsignDataProvider):
# functions can't deal with multiple calls this way.
if isinstance(data, list):
data = data[0]
assert isinstance(data, dict)
callsign = data["call"]
assert isinstance(data, dict)
# Get a name
name = None
-42
View File
@@ -1,42 +0,0 @@
import io
from time import sleep
import pandas as pd
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class DCE(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Diploma Castillos de España"""
POLL_INTERVAL_DAYS = 365
SIG = "DCE"
DATA_URL = "https://www.acracb.org/dce/descargas/General/directorio_referencias_dce.xls"
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 = []
file_stream = io.BytesIO(http_response.content)
df = pd.read_excel(file_stream, engine="xlrd", header=None)
for index, row in df.iterrows():
if row.iloc[0] and row.iloc[2]:
new_data.append(
SIGRef(sig=self.SIG, id=row.iloc[0].strip(), name=row.iloc[2].strip(), ref_type=SIGRefType.CASTLE)
)
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
# the data in this case
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
return new_data
-37
View File
@@ -1,37 +0,0 @@
import csv
from time import sleep
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class DMUE(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Diploma Museos de España"""
POLL_INTERVAL_DAYS = 365
SIG = "DMUE"
DATA_URL = "https://dmue.radiogalena.es/nom_dmue.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.reader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"):
if len(row) > 1 and row[0] and row[1]:
new_data.append(
SIGRef(sig=self.SIG, id=row[0].strip(), name=row[1].strip(), ref_type=SIGRefType.BUILDING)
)
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
# the data in this case
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
return new_data
-35
View File
@@ -1,35 +0,0 @@
from time import sleep
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
class DTMBA(FileDownloadSIGRefDataProvider):
"""SIG ref data provider for Diploma Teatri Musei Belle Arti"""
POLL_INTERVAL_DAYS = 30
SIG = "DTMBA"
DATA_URL = "https://www.iu1fig.com/share/iz0eik/dtmba/export.php"
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 http_response.content.decode("utf-8-sig").splitlines():
split = row.split(";")
ref_id = split[0]
ref_name = split[1]
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=ref_name, ref_type=SIGRefType.BUILDING))
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
# the data in this case
if self._stop_event.is_set():
break
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
return new_data
-14
View File
@@ -1,14 +0,0 @@
from providers.sigrefdata.pnp_kml_sig_ref_data_provider import (
ParksNPeaksKMLSIGRefDataProvider,
)
class KRMNPA(ParksNPeaksKMLSIGRefDataProvider):
"""SIG ref data provider for the Keith Roget Memorrial National Parks Award (KRMNPA)."""
POLL_INTERVAL_DAYS = 365
SIG = "KRMNPA"
DATA_URL = "https://parksnpeaks.org/getPOI.php?poiClass=KRMNPA&poiFormat=4"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
@@ -1,36 +0,0 @@
import logging
from datetime import datetime
import pytz
from providers.sigrefdata.sig_ref_data_provider import SIGRefDataProvider
logger = logging.getLogger(__name__)
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):
logger.debug(f"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:
self.status = "Error"
logger.info(f"Failed to load SIG ref data for {self.sig_name}")
except Exception:
self.status = "Error"
logger.exception(f"Exception in local file SIG Ref Data Provider ({self.sig_name})")
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")
@@ -1,65 +0,0 @@
import re
from time import sleep
from fastkml import kml
from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider,
)
class ParksNPeaksKMLSIGRefDataProvider(FileDownloadSIGRefDataProvider):
"""Base class for SIG ref data providers that use parksnpeaks.org KML POI feeds and have references that use the
VKFF refs rather than their own system (i.e. KRMNPA and SANPCPA)."""
REF_PATTERN = re.compile(r"VKFF-\d+")
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, url, poll_interval)
def _http_response_to_data(self, http_response):
new_data = []
k = kml.KML.from_string(http_response.content)
for document in k.features:
# noinspection unresolved-references
for folder in document.features:
# noinspection unresolved-references
for placemark in folder.features:
description = placemark.description or ""
match = self.REF_PATTERN.search(description)
if not match:
# No VKFF reference found in this placemark - skip it (e.g. non-park waypoints)
continue
ref_id = match.group(0)
longitude, latitude = placemark.geometry.x, placemark.geometry.y
ref = SIGRef(
sig=self.sig_name,
id=ref_id,
name=placemark.name,
ref_type=SIGRefType.PARK,
url=f"https://parksnpeaks.org/getPark.php?actPark={ref_id}",
latitude=latitude,
longitude=longitude,
)
if latitude and longitude:
ref.grid = latlong_to_locator(latitude, longitude, 6)
new_data.append(ref)
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to
# parse the rest of the data in this case
if self._stop_event.is_set():
return new_data
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
# is available for other threads e.g. the web server.
sleep(0.001)
return new_data
-14
View File
@@ -1,14 +0,0 @@
from providers.sigrefdata.pnp_kml_sig_ref_data_provider import (
ParksNPeaksKMLSIGRefDataProvider,
)
class SANPCPA(ParksNPeaksKMLSIGRefDataProvider):
"""SIG ref data provider for the South Australia National Parks and Conservation Parks Award (SANPCPA)."""
POLL_INTERVAL_DAYS = 365
SIG = "SANPCPA"
DATA_URL = "https://parksnpeaks.org/getPOI.php?poiClass=SANPCPA&poiFormat=4"
def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
-41
View File
@@ -1,41 +0,0 @@
import csv
from core.enums import SIGRefType
from data.sig_ref import SIGRef
from providers.sigrefdata.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 = []
with open(path) as _f:
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"],
ref_type=SIGRefType.TOILET,
latitude=float(row["lat"]),
longitude=float(row["lon"]),
)
)
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest
# of the data in this case
if self._stop_event.is_set():
break
return new_data
+20 -17
View File
@@ -2,6 +2,7 @@ import csv
import logging
from datetime import datetime, timedelta, timezone
from threading import Event, Thread
from typing import Any
import pytz
import requests
@@ -31,17 +32,17 @@ class GIROIonosonde(SolarConditionsProvider):
Designed to run alongside KC2GProp even though they produce similar data. GIRO has more stations and includes LUF
data, but is less reliable and often offline."""
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("GIRO Ionosonde Data", provider_config)
self._stations = self._load_stations()
self._thread = None
self._stop_event = Event()
self._stations: list[dict[str, str]] = self._load_stations()
self._thread: Thread | None = None
self._stop_event: Event = Event()
# Pre-populate ionosonde_data with known station names for stations not already present,
# so the station dropdown is available before the first poll. Does not overwrite existing
# entries so KC2G cache data is preserved.
existing = self._solar_conditions.ionosonde_data or {}
new_entries = {
existing: dict[str, Any] = self._solar_conditions.ionosonde_data or {}
new_entries: dict[str, Any] = {
s["ursi"]: {
"ursi": s["ursi"],
"name": s["name"],
@@ -57,27 +58,27 @@ class GIROIonosonde(SolarConditionsProvider):
self.update_data({"ionosonde_data": {**existing, **new_entries}})
@staticmethod
def _load_stations():
stations = []
def _load_stations() -> list[dict[str, str]]:
stations: list[dict[str, str]] = []
with open(STATIONS_INDEX, newline="") as f:
for row in csv.reader(f):
if len(row) >= 2:
stations.append({"ursi": row[0].strip(), "name": row[1].strip()})
return stations
def start(self):
def start(self) -> None:
logger.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
self._thread = Thread(target=self._run, name="GIROIonosondeDataProvider", daemon=True)
self._thread.start()
def stop(self):
def stop(self) -> None:
self._stop_event.set()
if self._thread:
self._thread.join(timeout=12)
if self._thread.is_alive():
logger.warning("GIRO ionosonde worker thread did not exit on time and will be killed.")
def _run(self):
def _run(self) -> None:
# Real interval at which we poll is the "once per hour" divided by the number of stations, so each one gets
# polled once per hour, just not all at once
interval = POLL_INTERVAL / len(self._stations)
@@ -88,7 +89,7 @@ class GIROIonosonde(SolarConditionsProvider):
if self._stop_event.wait(timeout=interval):
break
def _poll_station(self, station):
def _poll_station(self, station: dict[str, str]) -> None:
ursi = station["ursi"]
name = station["name"]
try:
@@ -139,7 +140,9 @@ class GIROIonosonde(SolarConditionsProvider):
self.status = "Error"
logger.exception(f"Exception fetching GIRO ionosonde data for {ursi} ({name})")
def _fetch_station_data(self, ursi, from_time, to_time):
def _fetch_station_data(
self, ursi: str, from_time: datetime, to_time: datetime
) -> tuple[dict[float, float] | None, dict[float, float] | None, dict[float, float] | None]:
"""Fetch foF2, MUF and LUF readings for a station. Returns (fof2_dict, muf_dict, luf_dict) keyed by UNIX timestamp."""
from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S")
@@ -159,12 +162,12 @@ class GIROIonosonde(SolarConditionsProvider):
return None, None, None
@staticmethod
def _parse_all(text):
def _parse_all(text: str) -> tuple[dict[float, float], dict[float, float], dict[float, float]]:
"""Parse web server response and return (fof2_dict, muf_dict, luf_dict) keyed by UNIX timestamp."""
fof2_data = {}
muf_data = {}
luf_data = {}
fof2_data: dict[float, float] = {}
muf_data: dict[float, float] = {}
luf_data: dict[float, float] = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
+9 -7
View File
@@ -1,7 +1,9 @@
import logging
from typing import Any
from xml.etree import ElementTree
import pytz
import requests
from dateutil import parser as dateutil_parser
from dateutil import tz as dateutil_tz
@@ -19,10 +21,10 @@ class HamQSL(HTTPSolarConditionsProvider):
"""Solar conditions provider using the HamQSL.com XML API (https://www.hamqsl.com/solarxml.php).
Provides solar flux index, geomagnetic indices, and HF/VHF propagation condition summaries."""
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("HamQSL", provider_config, URL, POLL_INTERVAL)
def _http_response_to_solar_conditions(self, http_response):
def _http_response_to_solar_conditions(self, http_response: requests.Response) -> dict[str, Any] | None:
root = ElementTree.fromstring(http_response.text)
sd = root.find("solardata")
if sd is None:
@@ -31,27 +33,27 @@ class HamQSL(HTTPSolarConditionsProvider):
# Some error checking functions in case the data is janky.
def text(tag, default=None):
def text(tag: str, default: str | None = None) -> str | None:
if sd is None:
logger.warning("HamQSL solar conditions API returned unexpected XML structure")
return default
el = sd.find(tag)
return el.text.strip() if el is not None and el.text else default
def float_val(tag, default=None):
def float_val(tag: str, default: float | None = None) -> float | None:
try:
return float(text(tag))
except (ValueError, TypeError):
return default
def int_val(tag, default=None):
def int_val(tag: str, default: int | None = None) -> int | None:
try:
return int(text(tag))
except (ValueError, TypeError):
return default
# Process HF band conditions
hf_conditions = {}
hf_conditions: dict[str, str] = {}
calc = sd.find("calculatedconditions")
if calc is not None:
for band_el in calc.findall("band"):
@@ -62,7 +64,7 @@ class HamQSL(HTTPSolarConditionsProvider):
hf_conditions[f"{name}-{time}"] = condition
# Process VHF propagation conditions
vhf_map = {}
vhf_map: dict[tuple[str | None, str | None], str | None] = {}
vhf = sd.find("calculatedvhfconditions")
if vhf is not None:
for ph_el in vhf.findall("phenomenon"):
@@ -1,6 +1,7 @@
import logging
from datetime import datetime
from threading import Event, Thread
from typing import Any
import pytz
import requests
@@ -16,32 +17,32 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
"""Generic solar conditions provider for providers that request data via HTTP(S). Subclasses implement
_http_response_to_solar_conditions() to parse the specific API response format."""
def __init__(self, name, provider_config, url, poll_interval):
def __init__(self, name: str, provider_config: dict[str, Any], url: str, poll_interval: float) -> None:
super().__init__(name, provider_config)
self._url = url
self._poll_interval = poll_interval
self._thread = None
self._stop_event = Event()
self._url: str = url
self._poll_interval: float = poll_interval
self._thread: Thread | None = None
self._stop_event: Event = Event()
def start(self):
def start(self) -> None:
logger.info(f"Set up query of {self.name} solar conditions API every {self._poll_interval!s} seconds.")
self._thread = Thread(target=self._run, name=f"HTTPSolarConditionsProvider-{self.name}", daemon=True)
self._thread.start()
def stop(self):
def stop(self) -> None:
self._stop_event.set()
if self._thread:
self._thread.join(timeout=12)
if self._thread.is_alive():
logger.warning(f"{self.name} solar conditions worker thread did not exit on time and will be killed.")
def _run(self):
def _run(self) -> None:
while True:
self._poll()
if self._stop_event.wait(timeout=self._poll_interval):
break
def _poll(self):
def _poll(self) -> None:
try:
logger.debug(f"Polling {self.name} solar conditions API...")
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
@@ -66,7 +67,7 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
logger.exception(f"Exception in HTTP Solar Conditions Provider ({self.name})")
self._stop_event.wait(timeout=1)
def _http_response_to_solar_conditions(self, http_response):
def _http_response_to_solar_conditions(self, http_response: requests.Response) -> dict[str, Any] | None:
"""Convert an HTTP response into solar conditions data. Returns a dict mapping SolarConditions field
names to their new values, or None if the response could not be parsed. Only the fields returned will
be updated on the shared SolarConditions object; any fields not included will be left unchanged."""
+8 -3
View File
@@ -1,9 +1,10 @@
from core.constants import BANDS
from data.band import Band
HF_BANDS = [b for b in BANDS if b.is_ham_hf]
HF_BANDS: list[Band] = [b for b in BANDS if b.is_ham_hf]
def _latest(d) -> float | None:
def _latest(d: dict[float, float | str] | None) -> float | None:
"""Given a map where the key is a timestamp and the value is a number represented as a string, find the latest
timestamp and return the corresponding value as a float."""
@@ -11,7 +12,11 @@ def _latest(d) -> float | None:
return float(val) if (val is not None and val != "None") else None
def compute_band_states(fof2_dict, muf_dict, luf_dict):
def compute_band_states(
fof2_dict: dict[float, float | str] | None,
muf_dict: dict[float, float | str] | None,
luf_dict: dict[float, float | str] | None,
) -> dict[str, str]:
"""Compute HF band states from the latest foF2, MUF and LUF values.
Returns a map where the keys are HF bands and the values are as follows:
+9 -8
View File
@@ -1,6 +1,7 @@
import logging
from datetime import datetime, timedelta, timezone
from threading import Event, Thread
from typing import Any
import pytz
import requests
@@ -25,30 +26,30 @@ class KC2GProp(SolarConditionsProvider):
Designed to run alongside GIROIonosonde even though they produce similar data. KC2G is more reliable and is always
online, but has fewer stations and does not provide LUF data."""
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("KC2G Propagation Data", provider_config)
self._thread = None
self._stop_event = Event()
self._thread: Thread | None = None
self._stop_event: Event = Event()
def start(self):
def start(self) -> None:
logger.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.")
self._thread = Thread(target=self._run, name="KC2GPropProvider", daemon=True)
self._thread.start()
def stop(self):
def stop(self) -> None:
self._stop_event.set()
if self._thread:
self._thread.join(timeout=12)
if self._thread.is_alive():
logger.warning("KC2G ionosonde worker thread did not exit on time and will be killed.")
def _run(self):
def _run(self) -> None:
while True:
self._poll()
if self._stop_event.wait(timeout=POLL_INTERVAL):
break
def _poll(self):
def _poll(self) -> None:
try:
logger.debug("Polling KC2G ionosonde data...")
http_response = requests.get(KC2G_URL, headers=HTTP_HEADERS, timeout=(5, 30))
@@ -61,7 +62,7 @@ class KC2GProp(SolarConditionsProvider):
# Start from existing ionosonde_data so the accumulated time series survives across polls and restarts and
# stations provided only by GIROIonosonde are not discarded
ionosonde_data = dict(self._solar_conditions.ionosonde_data or {})
ionosonde_data: dict[str, Any] = dict(self._solar_conditions.ionosonde_data or {})
updated_count = 0
for reading in http_response.json():
+22 -17
View File
@@ -1,6 +1,9 @@
import logging
import re
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from typing import Any
import requests
from providers.solarconditions.http_solar_conditions_provider import (
HTTPSolarConditionsProvider,
@@ -16,11 +19,11 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
"""Solar conditions provider using the NOAA 3-day forecast text file. Parses the NOAA forecast and populates
corresponding fields in the solar conditions object.."""
def __init__(self, provider_config):
def __init__(self, provider_config: dict[str, Any]) -> None:
super().__init__("NOAA 3-day Forecast", provider_config, URL, POLL_INTERVAL)
@staticmethod
def _parse_percentage_table(lines, section_header, year):
def _parse_percentage_table(lines: list[str], section_header: str, year: int) -> dict[str, dict[float, int]] | None:
"""Find and parse a forecast table using percentages, identified by section_header. This is common to the lookup
of the solar storm and radio blackout forecast parsing."""
@@ -48,7 +51,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
return None
# Figure out the date based on the line found
column_timestamps = []
column_timestamps: list[float] = []
for month_str, day_str in date_matches:
try:
dt = datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").replace(tzinfo=timezone.utc)
@@ -58,7 +61,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
return None
# Parse data rows. Each non-empty line should have a text label followed by percentage values
result = {}
result: dict[str, dict[float, int]] = {}
for line in lines[date_header_idx + 1 :]:
line_stripped = line.strip()
if not line_stripped:
@@ -73,7 +76,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
# Row label is everything before the first percentage value
row_label = line_stripped[: line_stripped.index(pct_matches[0].group())].strip()
row_data = {}
row_data: dict[float, int] = {}
for j, match in enumerate(pct_matches):
if j >= len(column_timestamps):
break
@@ -83,7 +86,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
return result if result else None
def _http_response_to_solar_conditions(self, http_response):
def _http_response_to_solar_conditions(self, http_response: requests.Response) -> dict[str, Any] | None:
lines = http_response.text.splitlines()
# Find the "NOAA Kp index breakdown" section header
@@ -115,16 +118,18 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
logger.warning(f"NOAA K-index forecast: could not parse date headers from: {date_header_line}")
return None
column_dates = []
column_dates: list[date] = []
for month_str, day_str in date_matches:
try:
column_dates.append(datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").date())
column_dates.append(
datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").replace(tzinfo=timezone.utc).date()
)
except ValueError:
logger.warning(f"NOAA K-index forecast: could not parse date: {month_str} {day_str} {year}")
return None
# Parse each data row, e.g. "00-03UT 2.00 3.00 2.00"
k_index_forecast = {}
k_index_forecast: dict[float, float] = {}
for line in lines[start_idx + 3 :]:
time_match = re.match(r"^(\d{2})-(\d{2})UT\s+(.*)", line.strip())
if not time_match:
@@ -145,11 +150,11 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
except (ValueError, IndexError):
continue
date = column_dates[i]
column_date = column_dates[i]
start_dt = datetime(
date.year,
date.month,
date.day,
column_date.year,
column_date.month,
column_date.day,
start_hour,
0,
0,
@@ -165,14 +170,14 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
return None
# Parse Solar Radiation Storm Forecast (single row: "S1 or greater")
solar_storm_forecast = None
solar_storm_forecast: dict[float, int] | None = None
radiation_table = self._parse_percentage_table(lines, "Solar Radiation Storm Forecast", year)
if radiation_table:
solar_storm_forecast = radiation_table.get("S1 or greater")
# Parse Radio Blackout Forecast (two rows: "R1-R2" and "R3 or greater")
blackout_forecast_r1r2 = None
blackout_forecast_r3_or_greater = None
blackout_forecast_r1r2: dict[float, int] | None = None
blackout_forecast_r3_or_greater: dict[float, int] | None = None
blackout_table = self._parse_percentage_table(lines, "Radio Blackout Forecast", year)
if blackout_table:
blackout_forecast_r1r2 = blackout_table.get("R1-R2")
@@ -1,34 +1,36 @@
from datetime import datetime
from typing import Any
import pytz
from core.data_store import DATA_STORE
from data.solar_conditions import SolarConditions
class SolarConditionsProvider:
"""Generic solar conditions provider class. Subclasses of this query individual APIs for space weather and
propagation data."""
def __init__(self, name, provider_config):
def __init__(self, name: str, provider_config: dict[str, Any]) -> None:
"""Constructor"""
self.name = name
self.enabled = provider_config.get("enabled", True)
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled"
self._solar_conditions = DATA_STORE.solar_conditions.get()
self.name: str = name
self.enabled: bool = provider_config.get("enabled", True)
self.last_update_time: datetime = datetime.min.replace(tzinfo=pytz.UTC)
self.status: str = "Not Started" if self.enabled else "Disabled"
self._solar_conditions: SolarConditions = DATA_STORE.solar_conditions.get()
def start(self):
def start(self) -> None:
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
raise NotImplementedError("Subclasses must implement this method")
def stop(self):
def stop(self) -> None:
"""Stop any threads and prepare for application shutdown"""
raise NotImplementedError("Subclasses must implement this method")
def update_data(self, new_data):
def update_data(self, new_data: dict[str, Any] | None) -> None:
"""Update the solar conditions object with new data"""
if new_data:

Some files were not shown because too many files have changed in this diff Show More