Compare commits

...
38 Commits
Author SHA1 Message Date
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 ed172a0063 Release 2.1.3 2026-09-20 09:48:10 +01:00
Ian Renton f45b7d51b0 Give threads less time to shut down gracefully. Attempt shutdown() rather than stop() on the telnet connections to see if that improves things 2026-09-19 15:07:31 +01:00
Ian Renton 0eb4553406 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	templates/add_spot.html
#	templates/alerts.html
#	templates/bands.html
#	templates/base.html
#	templates/conditions.html
#	templates/map.html
#	templates/spots.html
#	templates/status.html
2026-09-19 10:49:36 +01:00
Ian Renton 682e2c267c Improve handling of empty JSON repsonses from ParksNPeaks (and potentially others) 2026-09-19 10:49:09 +01:00
Ian Renton f39215ecdd Attempt at fixing slight hscroll on mobile 2026-09-19 08:33:15 +01:00
Ian Renton c9c8ffc1f7 If cache load fails because objects are from a different version and throw an exception, clear the cache. 2026-09-19 08:04:50 +01:00
Ian Renton 59d5f61d90 Potential fix for an issue where the telnet client was reconnecting right at the same time we try to shut down spothole, causing the stop() method to close one telnet object but then a new one is created and read from anyway. 2026-09-19 07:55:12 +01:00
Ian Renton ab81c136cc Release 2.1.1 2026-09-18 21:37:05 +01:00
Ian Renton c1c9bcd5ae Merge branch 'main' into 147-sig-activity-changes 2026-09-18 21:35:56 +01:00
Ian Renton f0df4f38ca Turns out "if x in list" returns true if list[x] = None, so guard against that by checking list.get(x) instead 2026-09-18 21:33:26 +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 4b51dd9ba5 (Hopefully) fix a bug where several sig ref data providers try to write to the data store simultaneously on startup. 2026-09-18 18:57:18 +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
Ian Renton 556ea56378 v2.1 release 2026-09-18 12:44:09 +01:00
Ian Renton a367888e14 Missed a couple of _stop_events 2026-09-18 09:27:15 +01:00
Ian Renton 29d8654234 Use _stop_event consistently across all threads as the way to signal that it should stop. Add thread joins with timeouts to allow the program to exit cleanly 2026-09-18 09:21:38 +01:00
Ian Renton d79c8f72c8 Add missing SIGs to OpenAPI spec 2026-09-18 07:58:55 +01:00
Ian Renton a03e1336c8 Code review fixes 2026-09-18 07:56:08 +01:00
Ian Renton 0fa8cd763d Add support for Diplôme des Moulins de France 2026-09-18 07:38:30 +01:00
Ian Renton 4261c60d74 Improve protection against old unsupported objects (e.g. LocationSourceForCallsign.NONE) coming back from the cache and throwing exceptions 2026-09-18 07:28:29 +01:00
135 changed files with 2491 additions and 1893 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 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. 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) ![Screenshot](/images/screenshot.png)
While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outdoor 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, 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. 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/screenshot2.png)
![Screenshot](/images/screenshot3.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 ## 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 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 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 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. `/static/img/flags/` directory.
The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries. 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. 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
### Third Party Libraries the overall licence declared in Spothole's `LICENSE` file.
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.
Sound effects are CC0-licenced and sourced from [Pixabay](https://pixabay.com). 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" name: "C3 TOTA"
enabled: false enabled: false
url: "wss://39c3.totawatch.de/api/spot/live" url: "wss://39c3.totawatch.de/api/spot/live"
# For the "XOTA" provider, a SIG must be set menually here because xOTA is a generic backend for xOTA # For the "XOTA" provider, an activity must be set manually here because xOTA is a generic backend for xOTA
# programmes and so different URLs potentially provide different programmes. # programmes and so different URLs potentially provide different programmes.
sig: "Toilets" sig: "Toilets"
# For Toilets on the Air, we prefix the SIG references (T-01 etc) with some characters that define the conference: # For Toilets on the Air, we prefix the activity references (T-01 etc) with some characters that define the
# C3, EH or HOPE - so we can look up the correct locations in our database, because each conference starts from T-01 # conference: C3, EH or HOPE - so we can look up the correct locations in our database, because each conference
# but refers to a toilet in a different building (or continent!) # starts from T-01 but refers to a toilet in a different building (or continent!)
sig_ref_prefix: "C3" sig_ref_prefix: "C3"
- class: "XOTA" - class: "XOTA"
@@ -216,8 +216,8 @@ static_data_providers:
enabled: true enabled: true
# SIG reference data providers to use. These allow Spothole to download, for example, the WWFF directory that maps WWFF # Activity reference data providers to use. These allow Spothole to download, for example, the WWFF directory that
# park IDs to their name and location. # maps WWFF park IDs to their name and location.
sig_ref_data_providers: sig_ref_data_providers:
- class: "POTA" - class: "POTA"
enabled: true enabled: true
+156
View File
@@ -0,0 +1,156 @@
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, ref_id):
"""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)
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
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):
"""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
+43
View File
@@ -0,0 +1,43 @@
from data.activities import ACTIVITIES
def get_activity_by_name(name):
"""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):
"""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):
"""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):
"""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)})"
+4
View File
@@ -34,6 +34,10 @@ class CleanupTimer:
"""Stop any threads and prepare for application shutdown""" """Stop any threads and prepare for application shutdown"""
self._stop_event.set() self._stop_event.set()
if self._thread:
self._thread.join(timeout=5)
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):
while not self._stop_event.wait(timeout=self._cleanup_interval): while not self._stop_event.wait(timeout=self._cleanup_interval):
+1 -381
View File
@@ -1,393 +1,13 @@
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from core.enums import SIGRefType, SIGType
from data.band import Band from data.band import Band
from data.sig import SIG
# General software # General software
SOFTWARE_VERSION = "2.1-pre" SOFTWARE_VERSION = "2.2-pre"
# HTTP headers used for spot providers that use HTTP # HTTP headers used for spot providers that use HTTP
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"} 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(" ", "_") 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="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,
),
]
# Band definitions # Band definitions
BANDS = [ BANDS = [
Band(name="2200m", start_freq=135700, end_freq=137800), Band(name="2200m", start_freq=135700, end_freq=137800),
+50 -31
View File
@@ -1,5 +1,6 @@
import logging import logging
import threading import threading
import time
from core.config import config, create_provider_from_config from core.config import config, create_provider_from_config
@@ -16,6 +17,7 @@ class DataProviders:
self.static_data_providers = [] self.static_data_providers = []
self.sig_ref_data_providers = [] self.sig_ref_data_providers = []
self.callsign_data_providers = [] self.callsign_data_providers = []
self._startup_timers = []
def setup(self): def setup(self):
for entry in config["spot_providers"]: for entry in config["spot_providers"]:
@@ -27,7 +29,7 @@ class DataProviders:
for entry in config.get("static_data_providers", []): for entry in config.get("static_data_providers", []):
self.static_data_providers.append(create_provider_from_config("providers.staticdata", entry)) self.static_data_providers.append(create_provider_from_config("providers.staticdata", entry))
for entry in config.get("sig_ref_data_providers", []): 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", []): for entry in config.get("callsign_data_providers", []):
self.callsign_data_providers.append(create_provider_from_config("providers.callsigndata", entry)) self.callsign_data_providers.append(create_provider_from_config("providers.callsigndata", entry))
@@ -43,41 +45,58 @@ class DataProviders:
def start(self): def start(self):
# Start data providers before spot/alert providers so the lookup data is there already for incoming spots. # 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. # Each category is fired off after a small delay to give the rest of Spothole chance to start up.
threading.Timer(5.0, lambda: self.start_providers(self.static_data_providers, "static data")).start() self._startup_timers = [
threading.Timer( threading.Timer(5.0, lambda: self.start_providers(self.static_data_providers, "static data")),
10.0, threading.Timer(10.0, lambda: self.start_providers(self.callsign_data_providers, "callsign data")),
lambda: self.start_providers(self.callsign_data_providers, "callsign data"), threading.Timer(15.0, lambda: self.start_providers(self.spot_providers, "spot")),
).start() threading.Timer(20.0, lambda: self.start_providers(self.alert_providers, "alert")),
threading.Timer(15.0, lambda: self.start_providers(self.spot_providers, "spot")).start()
threading.Timer(20.0, lambda: self.start_providers(self.alert_providers, "alert")).start()
threading.Timer( threading.Timer(
25.0, 25.0,
lambda: self.start_providers(self.solar_condition_providers, "solar condition"), lambda: self.start_providers(self.solar_condition_providers, "solar condition"),
).start() ),
threading.Timer( threading.Timer(30.0, lambda: self.start_providers(self.sig_ref_data_providers, "activity ref data")),
30.0, ]
lambda: self.start_providers(self.sig_ref_data_providers, "SIG ref data"), for t in self._startup_timers:
).start() t.daemon = True
t.start()
def stop(self): def stop(self):
for sp in self.spot_providers: # Cancel any startup timers that haven't fired yet
if sp.enabled: for t in self._startup_timers:
sp.stop() t.cancel()
for ap in self.alert_providers:
if ap.enabled: # Stop all providers
ap.stop() all_providers = [
for scp in self.solar_condition_providers: p
if scp.enabled: for p in (
scp.stop() self.spot_providers
for srdp in self.sig_ref_data_providers: + self.alert_providers
if srdp.enabled: + self.solar_condition_providers
srdp.stop() + self.sig_ref_data_providers
for sdp in self.static_data_providers: + self.static_data_providers
if sdp.enabled: + self.callsign_data_providers
sdp.stop() )
for cdp in self.callsign_data_providers: if p.enabled
if cdp.enabled: ]
cdp.stop() if not all_providers:
return
def stop_provider(p):
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()
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()]
if still_running:
logger.warning("Some threads did not stop in time!")
# Global object # Global object
+14 -13
View File
@@ -15,8 +15,8 @@ CACHE_DIR = "./cache/"
class DataStore: class DataStore:
"""Data caching/storage object. Handles storage of spots, alerts, solar conditions, SIG reference data, and callsign """Data caching/storage object. Handles storage of spots, alerts, solar conditions, activity reference data, and
lookup data using different caching strategies for each.""" callsign lookup data using different caching strategies for each."""
def __init__(self): def __init__(self):
# Constants # Constants
@@ -34,7 +34,7 @@ class DataStore:
self.callsign_data_hamqth = None self.callsign_data_hamqth = None
self.dxcc_data = None self.dxcc_data = None
self.dxcc_lookup_by_call_regex = [] self.dxcc_lookup_by_call_regex = []
self.sigrefs = None self.activity_refs = None
self.status = None self.status = None
self.solar_conditions = None self.solar_conditions = None
# ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be # ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be
@@ -51,16 +51,16 @@ class DataStore:
self.solar_conditions = SingleObjectDataCache(f"{CACHE_DIR}solar", SolarConditions()) self.solar_conditions = SingleObjectDataCache(f"{CACHE_DIR}solar", SolarConditions())
self.status = SingleObjectDataCache(f"{CACHE_DIR}status", {}) self.status = SingleObjectDataCache(f"{CACHE_DIR}status", {})
# Standard disk cache for static reference and SIG ref data. Separate provider threads will repopulate these on # Standard disk cache for static reference and activity ref data. Separate provider threads will repopulate
# a regular basis but there's no need for a TTL since old data is better than no data. # 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.dxcc_data = diskcache.Cache(f"{CACHE_DIR}dxcc_data")
self.regenerate_call_regex_to_dxcc_entity_map() 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 # For activity reference data specifically, we need to key on both activity *and* reference, and trying to do
# of dict in diskcache absolutely destroys performance with unpickling huge dicts, so we have an ugly "SIG:ref" # two layers of dict in diskcache absolutely destroys performance with unpickling huge dicts, so we have an
# syntax for keys to keep it a single level. # ugly "activity:ref" syntax for keys to keep it a single level.
self.sigrefs = diskcache.Cache(f"{CACHE_DIR}sigrefs") self.activity_refs = diskcache.Cache(f"{CACHE_DIR}activity_refs")
logger.info(f"Loaded data for {len(self.sigrefs)} SIG references.") 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. # Standard disk cache for callsign data. This data does have a TTL to trigger an occasional re-lookup.
# Old data *is* better than no data, but we can't have a background thread re-looking-up every callsign # Old data *is* better than no data, but we can't have a background thread re-looking-up every callsign
@@ -82,8 +82,8 @@ class DataStore:
logger.info(f"Loaded data for {len(unique_keys)} callsigns.") 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 # Special caches for spots and alerts, which have TTL and write snapshots to disk at an interval. We
# specifically load these caches *last* so that any sigref and callsign data is already loaded from disk cache # specifically load these caches *last* so that any activity ref and callsign data is already loaded from disk
# before the spots and alerts are live in the system. # cache before the spots and alerts are live in the system.
self.spots = LiveDataCache( self.spots = LiveDataCache(
maxsize=self._MAX_SPOT_COUNT, maxsize=self._MAX_SPOT_COUNT,
ttl=MAX_SPOT_AGE, ttl=MAX_SPOT_AGE,
@@ -106,6 +106,7 @@ class DataStore:
and testing the callsign every time is expensive. So instead we build a separate in-memory lookup of compiled and testing the callsign every time is expensive. So instead we build a separate in-memory lookup of compiled
regex against DXCC entity code, as a list of tuples we can iterate through.""" regex against DXCC entity code, as a list of tuples we can iterate through."""
self.dxcc_lookup_by_call_regex = []
for entry in [DATA_STORE.dxcc_data[key] for key in DATA_STORE.dxcc_data]: 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"])) self.dxcc_lookup_by_call_regex.append((re.compile(entry["prefixRegex"]), entry["entityCode"]))
@@ -115,7 +116,7 @@ class DataStore:
self.solar_conditions.close() self.solar_conditions.close()
self.status.close() self.status.close()
self.dxcc_data.close() self.dxcc_data.close()
self.sigrefs.close() self.activity_refs.close()
self.callsign_data_countryfiles.close() self.callsign_data_countryfiles.close()
self.callsign_data_clublogxml.close() self.callsign_data_clublogxml.close()
self.callsign_data_clublogapi.close() self.callsign_data_clublogapi.close()
+60 -14
View File
@@ -90,8 +90,62 @@ class LocationSourceForCallsign(str, Enum):
DXCC = "DXCC" DXCC = "DXCC"
class SIGRefType(str, Enum): # Definitions of every activity Spothole knows about, keyed by the ActivityName enum. An enum is used for the
"""Type of a Special Interest Group reference.""" # 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):
return str(self.value)
class ActivityRefType(str, Enum):
"""Type of an activity reference."""
PARK = "PARK" PARK = "PARK"
SUMMIT = "SUMMIT" SUMMIT = "SUMMIT"
@@ -112,19 +166,11 @@ class SIGRefType(str, Enum):
TOILET = "TOILET" TOILET = "TOILET"
class AlertType(str, Enum): class ActivityType(str, Enum):
"""Type of an alert.""" """Type of an activity. Used to group them in the web UI."""
XOTA = "XOTA" TRADITIONAL = "TRADITIONAL"
SATELLITE = "SATELLITE" ADVENTURE = "ADVENTURE"
DXPEDITION = "DXPEDITION"
CONTEST = "CONTEST"
class SIGType(str, Enum):
"""Type of a Special Interest Group. Used to group them in the web UI."""
WORLDWIDE = "WORLDWIDE"
REGIONAL = "REGIONAL" REGIONAL = "REGIONAL"
EVENT = "EVENT" EVENT = "EVENT"
+2 -2
View File
@@ -1,6 +1,6 @@
import logging import logging
import re import re
from math import floor from math import floor, isnan
from pyproj import Transformer from pyproj import Transformer
from shapely.geometry import Point, Polygon from shapely.geometry import Point, Polygon
@@ -156,7 +156,7 @@ def lat_lon_for_grid_sw_corner_plus_size(grid):
lat -= 90.0 lat -= 90.0
# Return None values on maths errors # 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 None, None, None, None
return lat, lon, lat_cell_size, lon_cell_size return lat, lon, lat_cell_size, lon_cell_size
+17 -4
View File
@@ -22,6 +22,8 @@ class LiveDataCache:
self._listeners_lock = threading.Lock() self._listeners_lock = threading.Lock()
self._snapshot_dir = snapshot_dir self._snapshot_dir = snapshot_dir
self._disk_cache = diskcache.Cache(str(snapshot_dir)) self._disk_cache = diskcache.Cache(str(snapshot_dir))
self._stop_event = threading.Event()
self._snapshot_thread = None
self._load_snapshot() self._load_snapshot()
self._start_periodic_snapshot(snapshot_interval_sec) self._start_periodic_snapshot(snapshot_interval_sec)
@@ -75,7 +77,12 @@ class LiveDataCache:
logger.exception(f"Failed to write snapshot to {self._snapshot_dir}") logger.exception(f"Failed to write snapshot to {self._snapshot_dir}")
def _load_snapshot(self): def _load_snapshot(self):
try:
data = self._disk_cache.get("snapshot") data = 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
if not data: if not data:
return return
@@ -89,13 +96,19 @@ class LiveDataCache:
def _start_periodic_snapshot(self, interval): def _start_periodic_snapshot(self, interval):
def loop(): def loop():
while True: while not self._stop_event.wait(timeout=interval):
time.sleep(interval)
self.save_snapshot() self.save_snapshot()
t = threading.Thread(target=loop, name=f"LiveDataCache-Snapshot-{self._snapshot_dir}") self._snapshot_thread = threading.Thread(
t.start() target=loop, name=f"LiveDataCache-Snapshot-{self._snapshot_dir}", daemon=True
)
self._snapshot_thread.start()
def close(self): def close(self):
self._stop_event.set()
if self._snapshot_thread:
self._snapshot_thread.join(timeout=15)
if self._snapshot_thread.is_alive():
logger.warning(f"LiveDataCache snapshot thread for {self._snapshot_dir} did not exit on time.")
self.save_snapshot() self.save_snapshot()
self._disk_cache.close() self._disk_cache.close()
-146
View File
@@ -1,146 +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.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}"
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
if lookup_data:
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
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 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)})"
+10 -4
View File
@@ -7,10 +7,10 @@ logger = logging.getLogger(__name__)
class SingleObjectDataCache: class SingleObjectDataCache:
"""Cache for status and solar conditions. This uses DiskCache, but unlike the standard DiskCache users like SIG and """Cache for status and solar conditions. This uses DiskCache, but unlike the standard DiskCache users like
callsign lookup handlers, status and solar conditions are persisted as a single object. If we just load the object activity ref and callsign lookup handlers, status and solar conditions are persisted as a single object. If we
from DiskCache and modify it, DiskCache doesn't know that it's been updated and needs re-caching, so we provide a just load the object from DiskCache and modify it, DiskCache doesn't know that it's been updated and needs
store() method that any functions updating the object can call afterwards.""" 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): def __init__(self, cache_dir, object_if_empty):
"""Initialize a SingleObjectDataCache. Provide the directory to load the cache from and save it to. If the cache """Initialize a SingleObjectDataCache. Provide the directory to load the cache from and save it to. If the cache
@@ -21,7 +21,13 @@ class SingleObjectDataCache:
# This cache stores a single object, doesn't matter what it's called so "object" will do # This cache stores a single object, doesn't matter what it's called so "object" will do
if "object" not in self._cache: if "object" not in self._cache:
self._cache.add("object", object_if_empty) self._cache.add("object", object_if_empty)
try:
self._obj = self._cache.get("object") self._obj = 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):
"""Get the data object. This can then be manipulated as necessary across multiple threads. Any function """Get the data object. This can then be manipulated as necessary across multiple threads. Any function
+8 -1
View File
@@ -1,3 +1,4 @@
import logging
import os import os
from datetime import datetime from datetime import datetime
from threading import Event, Thread from threading import Event, Thread
@@ -14,6 +15,8 @@ from core.prometheus_metrics_handler import alerts_gauge, memory_use_gauge, spot
from telnetserver.telnetserver import TELNET_SERVER from telnetserver.telnetserver import TELNET_SERVER
from webserver.webserver import WEB_SERVER from webserver.webserver import WEB_SERVER
logger = logging.getLogger(__name__)
class StatusReporter: class StatusReporter:
"""Provides a timed update of the application's status data.""" """Provides a timed update of the application's status data."""
@@ -33,13 +36,17 @@ class StatusReporter:
def start(self): def start(self):
"""Start the reporter thread""" """Start the reporter thread"""
self._thread = Thread(target=self._run, name="StatusReporter") self._thread = Thread(target=self._run, name="StatusReporter", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self):
"""Stop any threads and prepare for application shutdown""" """Stop any threads and prepare for application shutdown"""
self._stop_event.set() self._stop_event.set()
if self._thread:
self._thread.join(timeout=15)
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):
"""Thread entry point: report immediately on startup, then on each interval until stopped""" """Thread entry point: report immediately on startup, then on each interval until stopped"""
+1 -2
View File
@@ -14,14 +14,13 @@ class URLDataCache(CachedSession):
used across multiple threads, though note that URL lookups will block each other this way, so it is still better to 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.""" create one of these objects per thread if possible."""
_lock = threading.Lock()
def __init__(self, name): def __init__(self, name):
super().__init__( super().__init__(
f"{CACHE_DIR}urls/{name}", f"{CACHE_DIR}urls/{name}",
expire_after=timedelta(days=1), expire_after=timedelta(days=1),
allowable_codes=(200, 400, 401, 403, 404), allowable_codes=(200, 400, 401, 403, 404),
) )
self._lock = threading.Lock()
def get(self, *args, **kwargs): def get(self, *args, **kwargs):
with self._lock: with self._lock:
+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 dataclasses import dataclass
from core.enums import SIGRefType from core.enums import ActivityRefType
@dataclass @dataclass
class SIGRef: class ActivityRef:
"""Data class that defines a Special Interest Group "info" or reference. As well as the basic reference ID we include a """Data class that defines an Activity "info" or reference. As well as the basic reference ID we include a
name and a lookup URL.""" 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 sig: str
# Reference ID, e.g. "GB-0001". # Reference ID, e.g. "GB-0001".
id: str | None = None id: str | None = None
# Name of the reference, e.g. "Null Country Park", if known. # Name of the reference, e.g. "Null Country Park", if known.
name: str | None = None name: str | None = None
# Type of the reference, e.g. "Park", if known. # 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 to look up more information about the reference, if known.
url: str | None = None 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 icon: str | None = None
# Latitude of the reference, in degrees, if known. # Latitude of the reference, in degrees, if known.
latitude: float | None = None latitude: float | None = None
+17 -23
View File
@@ -6,9 +6,10 @@ from datetime import datetime, timedelta
import pytz 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.call_lookup_helper import get_call_info
from core.enums import AlertType, Continent from core.enums import Continent
from core.sig_lookup_helper import populate_missing_sig_ref_info
from core.utils import get_flag_for_dxcc from core.utils import get_flag_for_dxcc
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -54,16 +55,15 @@ class Alert:
end_time_iso: str | None = None end_time_iso: str | None = None
# Comment made by the alerter, if any # Comment made by the alerter, if any
comment: str | None = None 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 # A URL link to more information, if any
url: str | None = None 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: str | None = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO # 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 = field(default_factory=list) sig_refs: list = field(default_factory=list)
# Timing info # Timing info
@@ -125,15 +125,15 @@ class Alert:
if self.dx_dxcc_id and not self.dx_flag: if self.dx_dxcc_id and not self.dx_flag:
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id) 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 # Fetch activity data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a
# in its initial call, we use this code to populate the rest of the data. This includes working out grid refs # reference in its initial call, we use this code to populate the rest of the data. This includes working
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths # 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: if self.sig_refs:
for sig_ref in self.sig_refs: for activity_ref in self.sig_refs:
populate_missing_sig_ref_info(sig_ref) 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 # If the spot itself doesn't have an activity yet, but we have at least one activity reference, take that
# and apply it to the whole spot. # reference's activity and apply it to the whole spot.
if self.sig_refs and self.sig_refs[0] and not self.sig: if self.sig_refs and self.sig_refs[0] and not self.sig:
self.sig = self.sig_refs[0].sig self.sig = self.sig_refs[0].sig
@@ -152,16 +152,10 @@ class Alert:
if self.dx_calls and not self.dx_names: if self.dx_calls and not self.dx_names:
self.dx_names = [get_call_info(c, credentials).name for c in self.dx_calls] 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" self.icon = "fa-tower-cell"
if self.alert_type == AlertType.DXPEDITION: if self.sig and (activity_icon := get_icon_for_activity(self.sig)):
self.icon = "fa-globe-africa" self.icon = activity_icon
elif self.alert_type == AlertType.CONTEST:
self.icon = "fa-trophy"
elif self.alert_type == AlertType.CONTEST:
self.icon = "fa-satellite"
elif self.sig_refs and self.sig_refs[0].icon:
self.icon = self.sig_refs[0].icon
except Exception: except Exception:
logger.exception("Exception while inferring missing data from spot") logger.exception("Exception while inferring missing data from spot")
+2 -2
View File
@@ -7,8 +7,8 @@ from core.enums import Continent, LocationSourceForCallsign
class Callsign: class Callsign:
"""Data class that defines a callsign and the data associated with it. This will have been retrieved by a 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 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 spot has an activity (e.g. POTA) reference this data for their home location (or even just their country) will be
accurate and should not be used in preference to that.""" less accurate and should not be used in preference to that."""
# Callsign as spotted # Callsign as spotted
call: str call: str
-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
+154 -94
View File
@@ -9,17 +9,20 @@ from math import isnan
import pytz import pytz
from pyhamtools.locator import latlong_to_locator, locator_to_latlong 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.call_lookup_helper import get_call_info
from core.config import MAX_SPOT_AGE from core.config import MAX_SPOT_AGE
from core.constants import PROPAGATION_MODES, SIGS from core.constants import PROPAGATION_MODES
from core.enums import Continent, LocationSourceForSpot, Mode, ModeSource, ModeType from core.data_store import DATA_STORE
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.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 ( from core.utils import (
get_flag_for_dxcc, get_flag_for_dxcc,
infer_band_from_freq, infer_band_from_freq,
@@ -27,7 +30,8 @@ from core.utils import (
infer_mode_from_frequency, infer_mode_from_frequency,
infer_mode_type_from_mode, infer_mode_type_from_mode,
) )
from data.sig_ref import SIGRef from data.activities import ACTIVITIES
from data.activity_ref import ActivityRef
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -45,8 +49,8 @@ class Spot:
dx_call: str | None = None dx_call: str | None = None
# Name of the operator that has been spotted # Name of the operator that has been spotted
dx_name: str | None = None 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 # QTH of the operator that has been spotted. This could be from any activity refs or could be from online lookup of
# home QTH. # their home QTH.
dx_qth: str | None = None dx_qth: str | None = None
# Country of the DX operator # Country of the DX operator
dx_country: str | None = None dx_country: str | None = None
@@ -72,8 +76,8 @@ class Spot:
# DX Location source. Indicates how accurate the location might be. # DX Location source. Indicates how accurate the location might be.
dx_location_source: LocationSourceForSpot | None = None 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 # 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 # true if the location source is "SPOT", "SIG REF LOOKUP" or "GRID", or if the location source is "HOME QTH" and
# DX callsign doesn't have a suffix like /P. # the DX callsign doesn't have a suffix like /P. (Location source retains "SIG" wording for API compatibility.)
dx_location_good: bool = False dx_location_good: bool = False
# DE (Spotter) info # DE (Spotter) info
@@ -119,11 +123,12 @@ class Spot:
# QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments. # QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
qrt: bool = False 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: str | None = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO # 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 = field(default_factory=list) sig_refs: list = field(default_factory=list)
# Timing info # Timing info
@@ -157,7 +162,10 @@ class Spot:
objects such as the sig_refs list..""" objects such as the sig_refs list.."""
if self.sig_refs: 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=None):
"""Infer missing parameters where possible""" """Infer missing parameters where possible"""
@@ -261,76 +269,89 @@ class Spot:
if self.dx_latitude or self.dx_grid: if self.dx_latitude or self.dx_grid:
self.dx_location_source = LocationSourceForSpot.SPOT 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: if not self.sig and self.sig_refs:
self.sig = self.sig_refs[0].sig.upper() 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 # See if we already have an activity reference, but the comment looks like it contains more for the same
# should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002". # 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: if self.comment and self.sig_refs and self.sig_refs[0].sig:
sig = self.sig_refs[0].sig.upper() activity = self.sig_refs[0].sig.upper()
regex = get_ref_regex_for_sig(sig) regex = get_ref_regex_for_activity(activity)
if regex: if regex:
all_comment_ref_matches = re.finditer(r"(^|\W)(" + regex + r")($|\W)", self.comment, re.IGNORECASE) all_comment_ref_matches = re.finditer(r"(^|\W)(" + regex + r")($|\W)", self.comment, re.IGNORECASE)
for ref_match in all_comment_ref_matches: 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 # See if the comment looks like it contains any activities (and optionally activity references) that we
# add to the spot. This should catch cluster spot comments like "POTA GB-0001 WWFF GFF-0001" and e.g. POTA # can add to the spot. This should catch cluster spot comments like "POTA GB-0001 WWFF GFF-0001" and e.g.
# comments like "also WWFF GFF-0001". # POTA comments like "also WWFF GFF-0001".
if self.comment: if self.comment:
sig_matches = re.finditer(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", self.comment, re.IGNORECASE) activity_matches = re.finditer(r"(^|\W)" + ANY_ACTIVITY_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
for sig_match in sig_matches: for activity_match in activity_matches:
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster # First of all, if we haven't got an activity for this spot set yet, now we have. This covers
# spots where the comment is just "POTA". # things like cluster spots where the comment is just "POTA".
found_sig = get_sig_name_from_comment_name(sig_match.group(2)) found_activity = get_activity_name_from_comment_name(activity_match.group(2))
if not self.sig: 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. # Now look to see if that activity name was followed by something that looks like a reference ID
# If so, add that to the sig_refs list for this spot. # for that activity. If so, add that to the sig_refs list for this spot.
ref_regex = get_ref_regex_for_sig(found_sig) found_activity_info = get_activity_by_name(found_activity)
if ref_regex: if found_activity_info and found_activity_info.has_refs and found_activity_info.ref_regex:
ref_matches = re.finditer( 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, self.comment,
re.IGNORECASE, re.IGNORECASE,
) )
for ref_match in ref_matches: 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 # See if the comment looks like it contains any activity references *without* the corresponding activity
# where the SIG reference is unique-looking enough that we can't confuse it with any other SIG. # name, but where the activity reference is unique-looking enough that we can't confuse it with any other
# activity.
if self.comment: if self.comment:
for sig in SIGS: for activity in ACTIVITIES.values():
if sig.refs_globally_unique and sig.ref_regex: if activity.has_refs and activity.refs_globally_unique and activity.ref_regex:
ref_matches = re.finditer(r"(^|\W)(" + sig.ref_regex + r")($|\W)", self.comment, re.IGNORECASE) ref_matches = re.finditer(
r"(^|\W)(" + activity.ref_regex + r")($|\W)", self.comment, re.IGNORECASE
)
for ref_match in ref_matches: 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 # First of all, if we haven't got an activity for this spot set yet, now we have. This
# like cluster spots where the comment is just "OHFF-1234", now we know it's WWFF. # covers things like cluster spots where the comment is just "OHFF-1234", now we know
# it's WWFF.
if not self.sig: if not self.sig:
self.sig = sig.name self.sig = activity.name
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig.name)) self._append_activity_ref_if_missing(
ActivityRef(id=ref_match.group(2).upper(), sig=activity.name)
)
# Fetch SIG data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a reference # Fetch activity data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a
# in its initial call, we use this code to populate the rest of the data. This includes working out grid refs # reference in its initial call, we use this code to populate the rest of the data. This includes working
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths # 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: if self.sig_refs:
for sig_ref in self.sig_refs: for activity_ref in self.sig_refs:
sig_ref = populate_missing_sig_ref_info(sig_ref) activity_ref = populate_missing_activity_ref_info(activity_ref)
# If the spot itself doesn't have location yet, but the SIG ref does, extract it # If the spot itself doesn't have location yet, but the activity ref does, extract it
if sig_ref.grid and not self.dx_grid: if activity_ref.grid and not self.dx_grid:
self.dx_grid = sig_ref.grid self.dx_grid = activity_ref.grid
if sig_ref.latitude and not self.dx_latitude and sig_ref.longitude and not self.dx_longitude: if (
self.dx_latitude = sig_ref.latitude activity_ref.latitude
self.dx_longitude = sig_ref.longitude and not self.dx_latitude
if self.sig == "WAB" or self.sig == "WAI" or self.sig == "Tiles": 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 self.dx_location_source = LocationSourceForSpot.GRID
else: else:
self.dx_location_source = LocationSourceForSpot.SIG_REF_LOOKUP 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 # If the spot itself doesn't have an activity yet, but we have at least one activity reference, take that
# and apply it to the whole spot. # reference's activity and apply it to the whole spot.
if self.sig_refs and not self.sig: if self.sig_refs and not self.sig:
self.sig = self.sig_refs[0].sig self.sig = self.sig_refs[0].sig
@@ -338,20 +359,20 @@ class Spot:
# These are common on cluster spots and can provide grid references in preference to e.g. QRZ lookup, as well as # 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. # being the only source we have for propagation mode. Brace for nightmare regex from hell.
if self.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})?)?)(?:<([^>]*)>|\(([^)]*)\))([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\b", 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, self.comment,
) )
if grid_mode_grid_match: if grid_match:
# regex matches, so extract grids: # regex matches, so extract grids:
if not self.de_grid: 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: 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 self.dx_location_source = LocationSourceForSpot.GRID
# And extract propagation mode (group 2 for <...>, group 3 for (...)): # 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 and not self.propagation_mode:
if mode_tag in PROPAGATION_MODES: if mode_tag in PROPAGATION_MODES:
self.propagation_mode = PROPAGATION_MODES[mode_tag] self.propagation_mode = PROPAGATION_MODES[mode_tag]
@@ -359,29 +380,59 @@ class Spot:
self.propagation_mode = mode_tag self.propagation_mode = mode_tag
logger.info(f"Seen a new propagation mode tag not yet in the system: {mode_tag}") logger.info(f"Seen a new propagation mode tag not yet in the system: {mode_tag}")
# Set SIGs based on propagation mode # Set activities based on propagation mode
if self.propagation_mode == "Satellite": if self.propagation_mode == "Satellite" and not self.sig:
if not self.sig: self.sig = ActivityName.SATELLITE
self.sig = "AMSAT" if self.propagation_mode == "Earth-Moon-Earth" and not self.sig:
self.sig_refs.append(SIGRef(sig="AMSAT")) self.sig = ActivityName.EME
if self.propagation_mode == "Earth-Moon-Earth":
if not self.sig: # Parse 6-digit grid structures from the comment if the frequency is VHF or above, as it's common to see
self.sig = "EME" # VHF+ operators spotted with their grid
self.sig_refs.append(SIGRef(sig="EME")) 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 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
# Parse "de_grid -> dx_grid" structures from the comment # Parse "de_grid -> dx_grid" structures from the comment
if self.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", 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, self.comment,
) )
if grid_mode_grid_match: if grid_match:
# regex matches, so extract grids: # regex matches, so extract grids:
if not self.dx_grid: if not self.dx_grid:
self.dx_grid = grid_mode_grid_match.group(1).upper() self.dx_grid = grid_match.group(1).upper()
self.dx_location_source = LocationSourceForSpot.GRID self.dx_location_source = LocationSourceForSpot.GRID
if not self.de_grid: if not self.de_grid:
self.de_grid = grid_mode_grid_match.group(2).upper() self.de_grid = grid_match.group(2).upper()
# DX Grid to lat/lon and vice versa in case one is missing # 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): if self.dx_grid and (not self.dx_latitude or not self.dx_longitude):
@@ -390,12 +441,12 @@ class Spot:
self.dx_latitude = ll[0] self.dx_latitude = ll[0]
self.dx_longitude = ll[1] self.dx_longitude = ll[1]
except Exception: 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: if self.dx_latitude and self.dx_longitude and not self.dx_grid:
try: try:
self.dx_grid = latlong_to_locator(self.dx_latitude, self.dx_longitude, 8) self.dx_grid = latlong_to_locator(self.dx_latitude, self.dx_longitude, 8)
except Exception: except Exception:
logger.debug("Invalid lat/lon received for spot") logger.debug("Invalid lat/lon received for spot", exc_info=True)
# QRT comment detection # QRT comment detection
if self.comment and not self.qrt: if self.comment and not self.qrt:
@@ -423,8 +474,8 @@ class Spot:
self.dx_grid = dx_call_info.grid self.dx_grid = dx_call_info.grid
self.dx_location_source = dx_call_info.location_source self.dx_location_source = dx_call_info.location_source
# Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable string, # Determine a "QTH" string. If we have an activity ref, pick the first one and turn it into a suitable
# otherwise see what they have set on an online lookup service. # string, otherwise see what they have set on an online lookup service.
if self.sig_refs: if self.sig_refs:
qth = self.sig_refs[0].id qth = self.sig_refs[0].id
if self.sig_refs[0].name: if self.sig_refs[0].name:
@@ -445,6 +496,15 @@ class Spot:
elif self.dx_call: elif self.dx_call:
self.dx_itu_zone = dx_call_info.itu_zone self.dx_itu_zone = dx_call_info.itu_zone
# 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):
self.dx_dxcc_id = entity_code
break
if self.dx_dxcc_id and not self.dx_flag:
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator # DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
# is likely at home. # is likely at home.
self.dx_location_good = bool( self.dx_location_good = bool(
@@ -470,10 +530,10 @@ class Spot:
self.de_longitude = de_call_info.longitude self.de_longitude = de_call_info.longitude
self.de_grid = de_call_info.grid 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" self.icon = "fa-tower-cell"
if self.sig_refs and self.sig_refs[0].icon: if self.sig and (activity_icon := get_icon_for_activity(self.sig)):
self.icon = self.sig_refs[0].icon self.icon = activity_icon
except Exception: except Exception:
logger.exception("Exception while inferring missing data from spot") logger.exception("Exception while inferring missing data from spot")
@@ -483,17 +543,17 @@ class Spot:
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True) return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
def _append_sig_ref_if_missing(self, new_sig_ref): def _append_activity_ref_if_missing(self, new_activity_ref):
"""Append a sig_ref to the list, so long as it's not already there.""" """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_activity_ref.id = new_activity_ref.id.strip().upper()
new_sig_ref.sig = new_sig_ref.sig.strip().upper() new_activity_ref.sig = new_activity_ref.sig.strip().upper()
if new_sig_ref.id == "": if new_activity_ref.id == "":
return return
for sig_ref in self.sig_refs: for activity_ref in self.sig_refs:
if sig_ref.id == new_sig_ref.id and sig_ref.sig == new_sig_ref.sig: if activity_ref.id == new_activity_ref.id and activity_ref.sig == new_activity_ref.sig:
return return
self.sig_refs.append(new_sig_ref) self.sig_refs.append(new_activity_ref)
def expired(self): def expired(self):
"""Decide if this spot has expired (in which case it should not be added to the system in the first place, and not """Decide if this spot has expired (in which case it should not be added to the system in the first place, and not
+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, 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 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 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. 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 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 | | `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. | | `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. | | `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. | | `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. | | `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. | | `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 services
* `/providers/callsign` - Classes providing callsign lookup data by accessing bundled data files or the APIs of other * `/providers/callsign` - Classes providing callsign lookup data by accessing bundled data files or the APIs of other
services services
* `/providers/sigrefdata` - Classes providing SIG reference lookup data by accessing bundled data files or the APIs of * `/providers/activityrefdata` - Classes providing activity reference lookup data by accessing bundled data files or
other services the APIs of other services
* `/webserver` - Classes for running Spothole's own web server * `/webserver` - Classes for running Spothole's own web server
* `/telnetserver` - Classes for running Spothole's telnet server * `/telnetserver` - Classes for running Spothole's telnet server
* `spothole.py` - Main application script * `spothole.py` - Main application script
@@ -1,5 +1,6 @@
import logging import logging
from datetime import datetime from datetime import datetime
from threading import Event
import pytz import pytz
@@ -8,18 +9,20 @@ from core.data_store import DATA_STORE
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SIGRefDataProvider: class ActivityRefDataProvider:
"""Generic SIG reference data provider class. Subclasses of this query the individual URLs or files for data.""" """Generic activity reference data provider class. Subclasses of this query the individual URLs or files for
data."""
def __init__(self, sig_name, provider_config): def __init__(self, sig_name, provider_config):
"""Constructor""" """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.sig_name = sig_name
self.enabled = provider_config["enabled"] self.enabled = provider_config["enabled"]
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC) self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled" self.status = "Not Started" if self.enabled else "Disabled"
self.reference_count = 0 self.reference_count = 0
self._stop = False self._stop_event = Event()
def start(self): def start(self):
"""Start the provider. This should return immediately after spawning threads to access the remote resources""" """Start the provider. This should return immediately after spawning threads to access the remote resources"""
@@ -30,20 +33,22 @@ class SIGRefDataProvider:
"""Stop any threads and prepare for application shutdown. Subclasses should implement this method and call """Stop any threads and prepare for application shutdown. Subclasses should implement this method and call
super().""" super()."""
self._stop = True self._stop_event.set()
def _add_data(self, new_data): def _add_data(self, new_data):
"""Add all the provided reference data objects to the data store.""" """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 # with transact() batches all writes together to save making thousands of individual sqlite writes. However,
with DATA_STORE.sigrefs.transact(): # 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.activity_refs.transact(retry=True):
for d in new_data: 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 # 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 # within the first few minutes of startup, we need a way to abort this expensive process of filling up the
# disk cache. # disk cache.
if self._stop: if self._stop_event.is_set():
break break
self.reference_count = len(new_data) self.reference_count = len(new_data)
@@ -1,22 +1,22 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadActivityRefDataProvider,
) )
class ARLHS(FileDownloadSIGRefDataProvider): class ARLHS(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Amateur Radio Light House Society""" """Activity ref data provider for Amateur Radio Light House Society"""
POLL_INTERVAL_DAYS = 30 POLL_INTERVAL_DAYS = 30
SIG = "ARLHS" ACTIVITY = ActivityName.ARLHS
DATA_URL = "https://www.gma.rocks/download/lighthouse.csv" DATA_URL = "https://www.gma.rocks/download/lighthouse.csv"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
@@ -24,11 +24,11 @@ class ARLHS(FileDownloadSIGRefDataProvider):
if "ARLHS" in row and row["ARLHS"] != "": if "ARLHS" in row and row["ARLHS"] != "":
ref_id = row["ARLHS"] ref_id = row["ARLHS"]
new_data.append( new_data.append(
SIGRef( ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("Name", None), name=row.get("Name", None),
ref_type=SIGRefType.LIGHTHOUSE, ref_type=ActivityRefType.LIGHTHOUSE,
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}", url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None, latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None, longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
@@ -41,7 +41,7 @@ class ARLHS(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -2,20 +2,20 @@ from time import sleep
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
class COTA(FileDownloadSIGRefDataProvider): class COTA(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Castles on the Air""" """Activity ref data provider for Castles on the Air"""
POLL_INTERVAL_DAYS = 30 POLL_INTERVAL_DAYS = 30
SIG = "COTA" ACTIVITY = ActivityName.COTA
DATA_URL = "https://www.cotagroup.org/cotagroup/map/data/castles-all-7d90ee2a5e1175e5dece1bbf9dc87504.json" DATA_URL = "https://www.cotagroup.org/cotagroup/map/data/castles-all-7d90ee2a5e1175e5dece1bbf9dc87504.json"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
@@ -29,11 +29,11 @@ class COTA(FileDownloadSIGRefDataProvider):
grid = latlong_to_locator(lat, lon) grid = latlong_to_locator(lat, lon)
new_data.append( new_data.append(
SIGRef( ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=ref_id, id=ref_id,
name=name, name=name,
ref_type=SIGRefType.CASTLE, ref_type=ActivityRefType.CASTLE,
grid=grid, grid=grid,
latitude=lat, latitude=lat,
longitude=lon, longitude=lon,
@@ -45,7 +45,7 @@ class COTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -3,20 +3,20 @@ from time import sleep
import pandas as pd import pandas as pd
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
class DCE(FileDownloadSIGRefDataProvider): class DCE(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Diploma Castillos de España""" """Activity ref data provider for Diploma Castillos de España"""
POLL_INTERVAL_DAYS = 365 POLL_INTERVAL_DAYS = 365
SIG = "DCE" ACTIVITY = ActivityName.DCE
DATA_URL = "https://www.acracb.org/dce/descargas/General/directorio_referencias_dce.xls" DATA_URL = "https://www.acracb.org/dce/descargas/General/directorio_referencias_dce.xls"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
@@ -27,7 +27,7 @@ class DCE(FileDownloadSIGRefDataProvider):
for index, row in df.iterrows(): for index, row in df.iterrows():
if row.iloc[0] and row.iloc[2]: if row.iloc[0] and row.iloc[2]:
new_data.append( new_data.append(
SIGRef(sig=self.SIG, id=row.iloc[0].strip(), name=row.iloc[2].strip(), ref_type=SIGRefType.CASTLE) 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 # Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
@@ -35,7 +35,7 @@ class DCE(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -3,20 +3,20 @@ from time import sleep
import pandas as pd import pandas as pd
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
class DEFE(FileDownloadSIGRefDataProvider): class DEFE(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Diploma Estationes de Ferrocarril de España""" """Activity ref data provider for Diploma Estationes de Ferrocarril de España"""
POLL_INTERVAL_DAYS = 365 POLL_INTERVAL_DAYS = 365
SIG = "DEFE" ACTIVITY = ActivityName.DEFE
DATA_URL = "https://www.acracb.org/defe/descargas/General/directorio_referencias_defe.xls" DATA_URL = "https://www.acracb.org/defe/descargas/General/directorio_referencias_defe.xls"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
@@ -31,7 +31,7 @@ class DEFE(FileDownloadSIGRefDataProvider):
if row.iloc[0] and row.iloc[1]: if row.iloc[0] and row.iloc[1]:
new_data.append( 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 # Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
@@ -39,7 +39,7 @@ class DEFE(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -3,21 +3,21 @@ from time import sleep
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.local_file_sig_ref_data_provider import ( from providers.activityrefdata.local_file_activity_ref_data_provider import (
LocalFileSIGRefDataProvider, LocalFileActivityRefDataProvider,
) )
class DME(LocalFileSIGRefDataProvider): class DME(LocalFileActivityRefDataProvider):
"""SIG ref data provider for Diploma Municipios de Espana""" """Activity ref data provider for Diploma Municipios de Espana"""
SIG = "DME" ACTIVITY = ActivityName.DME
PATH = "datafiles/MUNICIPIOS.csv" PATH = "datafiles/MUNICIPIOS.csv"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.PATH) super().__init__(self.ACTIVITY, provider_config, self.PATH)
def _file_to_data(self, path): def _file_to_data(self, path):
new_data = [] new_data = []
@@ -26,7 +26,7 @@ class DME(LocalFileSIGRefDataProvider):
# Store reference IDs with the "DME-" prefix rather than just the number. This will prevent Spothole # 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 # 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 # 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] ref_id = "DME-" + row["COD_INE"][:5]
latitude = ( latitude = (
float(row["LATITUD_ETRS89_REGCAN95"].replace(",", ".")) float(row["LATITUD_ETRS89_REGCAN95"].replace(",", "."))
@@ -39,10 +39,10 @@ class DME(LocalFileSIGRefDataProvider):
else None else None
) )
ref = SIGRef( ref = ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=ref_id, id=ref_id,
ref_type=SIGRefType.TOWN, ref_type=ActivityRefType.TOWN,
name=f"{row['NOMBRE_ACTUAL']}, {row['PROVINCIA']}", name=f"{row['NOMBRE_ACTUAL']}, {row['PROVINCIA']}",
latitude=latitude, latitude=latitude,
longitude=longitude, longitude=longitude,
@@ -53,10 +53,10 @@ class DME(LocalFileSIGRefDataProvider):
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest # 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 # of the data in this case
if self._stop: if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -1,20 +1,20 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
class DMUE(FileDownloadSIGRefDataProvider): class DMUE(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Diploma Museos de España""" """Activity ref data provider for Diploma Museos de España"""
POLL_INTERVAL_DAYS = 365 POLL_INTERVAL_DAYS = 365
SIG = "DMUE" ACTIVITY = ActivityName.DMUE
DATA_URL = "https://dmue.radiogalena.es/nom_dmue.csv" DATA_URL = "https://dmue.radiogalena.es/nom_dmue.csv"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
@@ -22,7 +22,7 @@ class DMUE(FileDownloadSIGRefDataProvider):
for row in csv.reader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"): for row in csv.reader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"):
if len(row) > 1 and row[0] and row[1]: if len(row) > 1 and row[0] and row[1]:
new_data.append( new_data.append(
SIGRef(sig=self.SIG, id=row[0].strip(), name=row[1].strip(), ref_type=SIGRefType.BUILDING) 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 # Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
@@ -30,7 +30,7 @@ class DMUE(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -3,20 +3,20 @@ from time import sleep
import pandas as pd import pandas as pd
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
class DMVE(FileDownloadSIGRefDataProvider): class DMVE(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Diploma Monumentos y Vestigios de España""" """Activity ref data provider for Diploma Monumentos y Vestigios de España"""
POLL_INTERVAL_DAYS = 365 POLL_INTERVAL_DAYS = 365
SIG = "DMVE" ACTIVITY = ActivityName.DMVE
DATA_URL = "https://www.acracb.org/dmve/descargas/General/directorio_referencias_dmve.xls" DATA_URL = "https://www.acracb.org/dmve/descargas/General/directorio_referencias_dmve.xls"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
@@ -36,14 +36,14 @@ class DMVE(FileDownloadSIGRefDataProvider):
continue continue
if ref and name: 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 # 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 # the data in this case
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -1,19 +1,19 @@
from time import sleep from time import sleep
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
class DTMBA(FileDownloadSIGRefDataProvider): class DTMBA(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Diploma Teatri Musei Belle Arti""" """Activity ref data provider for Diploma Teatri Musei Belle Arti"""
POLL_INTERVAL_DAYS = 30 POLL_INTERVAL_DAYS = 30
SIG = "DTMBA" ACTIVITY = ActivityName.DTMBA
DATA_URL = "https://www.iu1fig.com/share/iz0eik/dtmba/export.php" DATA_URL = "https://www.iu1fig.com/share/iz0eik/dtmba/export.php"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
@@ -21,14 +21,14 @@ class DTMBA(FileDownloadSIGRefDataProvider):
split = row.split(";") split = row.split(";")
ref_id = split[0] ref_id = split[0]
ref_name = split[1] ref_name = split[1]
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=ref_name, ref_type=SIGRefType.BUILDING)) 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 # 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 # the data in this case
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -3,20 +3,20 @@ from time import sleep
import pdfplumber import pdfplumber
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
class FEA(FileDownloadSIGRefDataProvider): class FEA(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Diploma Faros de España""" """Activity ref data provider for Diploma Faros de España"""
POLL_INTERVAL_DAYS = 30 POLL_INTERVAL_DAYS = 30
SIG = "FEA" ACTIVITY = ActivityName.FEA
DATA_URL = "http://ea5ol.net/Lista%20Faros.pdf" DATA_URL = "http://ea5ol.net/Lista%20Faros.pdf"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
@@ -41,15 +41,15 @@ class FEA(FileDownloadSIGRefDataProvider):
# prefix and just use FEA-1234 or FEA 1234, so we add both copies to the database. # 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_1 = row[0].strip()
ref_id_2 = ref_id_1.replace("D-", "FEA-").replace("E-", "FEA-") 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(ActivityRef(sig=self.ACTIVITY, id=ref_id_1, name=row[1].strip(), ref_type=ActivityRefType.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_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 # 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 # the data in this case
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -1,19 +1,20 @@
import logging import logging
from datetime import datetime from datetime import datetime
from threading import Event, Thread from threading import Thread
import pytz import pytz
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.url_data_cache import URLDataCache from core.url_data_cache import URLDataCache
from providers.sigrefdata.sig_ref_data_provider import SIGRefDataProvider from providers.activityrefdata.activity_ref_data_provider import ActivityRefDataProvider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class FileDownloadSIGRefDataProvider(SIGRefDataProvider): class FileDownloadActivityRefDataProvider(ActivityRefDataProvider):
"""Generic SIG ref data provider class for providers that fetch their data from the web by downloading a file.""" """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, provider_config, url, poll_interval):
"""Set up the provider, note poll_interval is in *days*.""" """Set up the provider, note poll_interval is in *days*."""
@@ -21,19 +22,21 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
self._url = url self._url = url
self._poll_interval = poll_interval self._poll_interval = poll_interval
self._thread = None self._thread = None
self._stop_event = Event() self._url_data_cache = URLDataCache(f"activity_ref_data_{sig_name}")
self._url_data_cache = URLDataCache(f"sigrefdata_{sig_name}")
def start(self): def start(self):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between # Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting. # 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.") 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"FileDownloadSIGRefDataProvider-{self.sig_name}") self._thread = Thread(target=self._run, name=f"FileDownloadActivityRefDataProvider-{self.sig_name}", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self):
super().stop() super().stop()
self._stop_event.set() if self._thread:
self._thread.join(timeout=12)
if self._thread.is_alive():
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):
while True: while True:
@@ -45,37 +48,37 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
try: try:
# Request data from API. Use the data cache (with a TTL of 1 day) here, not as the main mechanism for # 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. # 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) http_response = self._url_data_cache.get(self._url, headers=HTTP_HEADERS)
# Check response code was good # Check response code was good
if http_response.ok: if http_response.ok:
# Pass off to the subclass for processing # Pass off to the subclass for processing
new_data = self._http_response_to_data(http_response) 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: if new_data:
self._add_data(new_data) self._add_data(new_data)
self.status = "OK" self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC) 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: else:
self.status = "Error" 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: except ConnectionError:
self.status = "Error" 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): except (ConnectTimeout, ReadTimeout):
self.status = "Error" 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: except Exception:
self.status = "Error" 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) self._stop_event.wait(timeout=1)
def _http_response_to_data(self, http_response): 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 """Convert an HTTP response returned by the server into activity ref data. The whole response is provided here
subclass implementations can check for HTTP status codes if necessary, and handle the response as JSON, CSV, so the subclass implementations can check for HTTP status codes if necessary, and handle the response as
whatever the remote file actually is.""" JSON, CSV, whatever the remote file actually is."""
raise NotImplementedError("Subclasses must implement this method") raise NotImplementedError("Subclasses must implement this method")
@@ -1,33 +1,33 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadActivityRefDataProvider,
) )
class GMA(FileDownloadSIGRefDataProvider): class GMA(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Global Mountain Activity""" """Activity ref data provider for Global Mountain Activity"""
POLL_INTERVAL_DAYS = 30 POLL_INTERVAL_DAYS = 30
SIG = "GMA" ACTIVITY = ActivityName.GMA
DATA_URL = "https://www.gma.rocks/download/summits.csv" DATA_URL = "https://www.gma.rocks/download/summits.csv"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
ref_id = row["Reference"] ref_id = row["Reference"]
new_data.append( new_data.append(
SIGRef( ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("Name", None), name=row.get("Name", None),
ref_type=SIGRefType.SUMMIT, ref_type=ActivityRefType.SUMMIT,
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}", url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None, latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None, longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
@@ -43,7 +43,7 @@ class GMA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -1,22 +1,22 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadActivityRefDataProvider,
) )
class ILLW(FileDownloadSIGRefDataProvider): class ILLW(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for International Lighthouse & Lightship Weekend""" """Activity ref data provider for International Lighthouse & Lightship Weekend"""
POLL_INTERVAL_DAYS = 30 POLL_INTERVAL_DAYS = 30
SIG = "ILLW" ACTIVITY = ActivityName.ILLW
DATA_URL = "https://www.gma.rocks/download/lighthouse.csv" DATA_URL = "https://www.gma.rocks/download/lighthouse.csv"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
@@ -24,11 +24,11 @@ class ILLW(FileDownloadSIGRefDataProvider):
if "ILLW" in row and row["ILLW"] != "": if "ILLW" in row and row["ILLW"] != "":
ref_id = row["ILLW"] ref_id = row["ILLW"]
new_data.append( new_data.append(
SIGRef( ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("Name", None), name=row.get("Name", None),
ref_type=SIGRefType.LIGHTHOUSE, ref_type=ActivityRefType.LIGHTHOUSE,
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}", url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None, latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None, longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
@@ -41,7 +41,7 @@ class ILLW(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -3,24 +3,24 @@ from time import sleep
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadActivityRefDataProvider,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class IOTA(FileDownloadSIGRefDataProvider): class IOTA(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Islands on the Air""" """Activity ref data provider for Islands on the Air"""
POLL_INTERVAL_DAYS = 365 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" DATA_URL = "https://www.iota-world.org/islands-on-the-air/downloads/download-file.html?path=groups.json"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
@@ -41,11 +41,11 @@ class IOTA(FileDownloadSIGRefDataProvider):
) )
new_data.append( new_data.append(
SIGRef( ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=ref_id, id=ref_id,
name=ref["name"], name=ref["name"],
ref_type=SIGRefType.ISLAND, ref_type=ActivityRefType.ISLAND,
grid=grid, grid=grid,
latitude=latitude, latitude=latitude,
longitude=longitude, longitude=longitude,
@@ -57,7 +57,7 @@ class IOTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
+15
View File
@@ -0,0 +1,15 @@
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):
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
@@ -2,22 +2,22 @@ from time import sleep
from pyhamtools.locator import locator_to_latlong from pyhamtools.locator import locator_to_latlong
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadActivityRefDataProvider,
) )
class LLOTA(FileDownloadSIGRefDataProvider): class LLOTA(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Lagos y Lagunas on the Air""" """Activity ref data provider for Lagos y Lagunas on the Air"""
POLL_INTERVAL_DAYS = 7 POLL_INTERVAL_DAYS = 7
SIG = "LLOTA" ACTIVITY = ActivityName.LLOTA
DATA_URL = "https://llota.app/api/public/references" DATA_URL = "https://llota.app/api/public/references"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
@@ -29,11 +29,11 @@ class LLOTA(FileDownloadSIGRefDataProvider):
ll = locator_to_latlong(grid) ll = locator_to_latlong(grid)
new_data.append( new_data.append(
SIGRef( ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=ref_id, id=ref_id,
name=str(ref["name"]), name=str(ref["name"]),
ref_type=SIGRefType.LAKE, ref_type=ActivityRefType.LAKE,
url=f"https://llota.app/list/ref/{ref_id}", url=f"https://llota.app/list/ref/{ref_id}",
grid=grid, grid=grid,
latitude=ll[0], latitude=ll[0],
@@ -46,7 +46,7 @@ class LLOTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -0,0 +1,36 @@
import logging
from datetime import datetime
import pytz
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, provider_config, path):
super().__init__(sig_name, provider_config)
self._path = path
def start(self):
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):
"""Load a file on the given path and turn it into activity ref data."""
raise NotImplementedError("Subclasses must implement this method")
@@ -1,33 +1,33 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadActivityRefDataProvider,
) )
class MOTA(FileDownloadSIGRefDataProvider): class MOTA(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Mills on the Air""" """Activity ref data provider for Mills on the Air"""
POLL_INTERVAL_DAYS = 30 POLL_INTERVAL_DAYS = 30
SIG = "MOTA" ACTIVITY = ActivityName.MOTA
DATA_URL = "https://www.gma.rocks/download/mills.csv" DATA_URL = "https://www.gma.rocks/download/mills.csv"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
ref_id = row["Reference"] ref_id = row["Reference"]
new_data.append( new_data.append(
SIGRef( ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("Name", None), name=row.get("Name", None),
ref_type=SIGRefType.MILL, ref_type=ActivityRefType.MILL,
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}", url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None, latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None, longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
@@ -40,7 +40,7 @@ class MOTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -2,20 +2,20 @@ from time import sleep
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
class PGA(FileDownloadSIGRefDataProvider): class PGA(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Polish Gmina Award""" """Activity ref data provider for Polish Gmina Award"""
POLL_INTERVAL_DAYS = 30 POLL_INTERVAL_DAYS = 30
SIG = "PGA" ACTIVITY = ActivityName.PGA
DATA_URL = "http://www.spga.pl/lista_pga2.php" DATA_URL = "http://www.spga.pl/lista_pga2.php"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
@@ -38,11 +38,11 @@ class PGA(FileDownloadSIGRefDataProvider):
continue continue
new_data.append( new_data.append(
SIGRef( ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=ref_id, id=ref_id,
name=name, name=name,
ref_type=SIGRefType.REGION, ref_type=ActivityRefType.REGION,
) )
) )
@@ -51,7 +51,7 @@ class PGA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -4,16 +4,16 @@ from time import sleep
from fastkml import kml from fastkml import kml
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType from core.enums import ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadActivityRefDataProvider,
) )
class ParksNPeaksKMLSIGRefDataProvider(FileDownloadSIGRefDataProvider): class ParksNPeaksKMLActivityRefDataProvider(FileDownloadActivityRefDataProvider):
"""Base class for SIG ref data providers that use parksnpeaks.org KML POI feeds and have references that use the """Base class for activity ref data providers that use parksnpeaks.org KML POI feeds and have references that
VKFF refs rather than their own system (i.e. KRMNPA and SANPCPA).""" use the VKFF refs rather than their own system (i.e. KRMNPA and SANPCPA)."""
REF_PATTERN = re.compile(r"VKFF-\d+") REF_PATTERN = re.compile(r"VKFF-\d+")
@@ -40,11 +40,11 @@ class ParksNPeaksKMLSIGRefDataProvider(FileDownloadSIGRefDataProvider):
longitude, latitude = placemark.geometry.x, placemark.geometry.y longitude, latitude = placemark.geometry.x, placemark.geometry.y
ref = SIGRef( ref = ActivityRef(
sig=self.sig_name, sig=self.sig_name,
id=ref_id, id=ref_id,
name=placemark.name, name=placemark.name,
ref_type=SIGRefType.PARK, ref_type=ActivityRefType.PARK,
url=f"https://parksnpeaks.org/getPark.php?actPark={ref_id}", url=f"https://parksnpeaks.org/getPark.php?actPark={ref_id}",
latitude=latitude, latitude=latitude,
longitude=longitude, longitude=longitude,
@@ -58,7 +58,7 @@ class ParksNPeaksKMLSIGRefDataProvider(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
return new_data return new_data
# 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -1,33 +1,33 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadActivityRefDataProvider,
) )
class POTA(FileDownloadSIGRefDataProvider): class POTA(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Parks on the Air""" """Activity ref data provider for Parks on the Air"""
POLL_INTERVAL_DAYS = 7 POLL_INTERVAL_DAYS = 7
SIG = "POTA" ACTIVITY = ActivityName.POTA
DATA_URL = "https://pota.app/all_parks_ext.csv" DATA_URL = "https://pota.app/all_parks_ext.csv"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
ref_id = row["reference"] ref_id = row["reference"]
new_data.append( new_data.append(
SIGRef( ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("name", None), name=row.get("name", None),
ref_type=SIGRefType.PARK, ref_type=ActivityRefType.PARK,
url=f"https://pota.app/#/park/{ref_id}", url=f"https://pota.app/#/park/{ref_id}",
grid=row.get("grid", None), grid=row.get("grid", None),
latitude=float(row["latitude"]) if "latitude" in row and row["latitude"] != "" else None, latitude=float(row["latitude"]) if "latitude" in row and row["latitude"] != "" else None,
@@ -40,7 +40,7 @@ class POTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
+15
View File
@@ -0,0 +1,15 @@
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):
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
@@ -1,33 +1,33 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadActivityRefDataProvider,
) )
class SIOTA(FileDownloadSIGRefDataProvider): class SIOTA(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Silos on the Air""" """Activity ref data provider for Silos on the Air"""
POLL_INTERVAL_DAYS = 30 POLL_INTERVAL_DAYS = 30
SIG = "SIOTA" ACTIVITY = ActivityName.SIOTA
DATA_URL = "https://www.silosontheair.com/data/silos.csv" DATA_URL = "https://www.silosontheair.com/data/silos.csv"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
ref_id = row["SILO_CODE"] ref_id = row["SILO_CODE"]
new_data.append( new_data.append(
SIGRef( ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("NAME", None), name=row.get("NAME", None),
ref_type=SIGRefType.SILO, ref_type=ActivityRefType.SILO,
grid=row.get("LOCATOR", None), grid=row.get("LOCATOR", None),
latitude=float(row["LAT"]) if "LAT" in row else None, latitude=float(row["LAT"]) if "LAT" in row else None,
longitude=float(row["LNG"]) if "LNG" in row else None, longitude=float(row["LNG"]) if "LNG" in row else None,
@@ -39,7 +39,7 @@ class SIOTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -3,22 +3,22 @@ from time import sleep
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadActivityRefDataProvider,
) )
class SOTA(FileDownloadSIGRefDataProvider): class SOTA(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Summits on the Air""" """Activity ref data provider for Summits on the Air"""
POLL_INTERVAL_DAYS = 30 POLL_INTERVAL_DAYS = 30
SIG = "SOTA" ACTIVITY = ActivityName.SOTA
DATA_URL = "https://storage.sota.org.uk/summitslist.csv" DATA_URL = "https://storage.sota.org.uk/summitslist.csv"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
@@ -27,11 +27,11 @@ class SOTA(FileDownloadSIGRefDataProvider):
latitude = float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None latitude = float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None
longitude = float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None 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 altitude = float(row["AltM"]) if "AltM" in row and row["AltM"] != "" else None
ref = SIGRef( ref = ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("SummitName", None), name=row.get("SummitName", None),
ref_type=SIGRefType.SUMMIT, ref_type=ActivityRefType.SUMMIT,
url=f"https://www.sotadata.org.uk/en/summit/{ref_id}", url=f"https://www.sotadata.org.uk/en/summit/{ref_id}",
latitude=latitude, latitude=latitude,
longitude=longitude, longitude=longitude,
@@ -47,7 +47,7 @@ class SOTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -1,20 +1,20 @@
import csv import csv
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.local_file_sig_ref_data_provider import ( from providers.activityrefdata.local_file_activity_ref_data_provider import (
LocalFileSIGRefDataProvider, LocalFileActivityRefDataProvider,
) )
class Toilets(LocalFileSIGRefDataProvider): class Toilets(LocalFileActivityRefDataProvider):
"""SIG ref data provider for Toilets on the Air""" """Activity ref data provider for Toilets on the Air"""
SIG = "Toilets" ACTIVITY = ActivityName.TOILETS
PATH = "datafiles/toilets.csv" PATH = "datafiles/toilets.csv"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.PATH) super().__init__(self.ACTIVITY, provider_config, self.PATH)
def _file_to_data(self, path): def _file_to_data(self, path):
new_data = [] new_data = []
@@ -23,11 +23,11 @@ class Toilets(LocalFileSIGRefDataProvider):
dr = csv.DictReader(csv_data.splitlines()) dr = csv.DictReader(csv_data.splitlines())
for row in dr: for row in dr:
new_data.append( new_data.append(
SIGRef( ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=row["ref"], id=row["ref"],
name=row["ref"], name=row["ref"],
ref_type=SIGRefType.TOILET, ref_type=ActivityRefType.TOILET,
latitude=float(row["lat"]), latitude=float(row["lat"]),
longitude=float(row["lon"]), longitude=float(row["lon"]),
) )
@@ -35,7 +35,7 @@ class Toilets(LocalFileSIGRefDataProvider):
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest # 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 # of the data in this case
if self._stop: if self._stop_event.is_set():
break break
return new_data return new_data
@@ -1,33 +1,33 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadActivityRefDataProvider,
) )
class Towers(FileDownloadSIGRefDataProvider): class Towers(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Towers on the Air""" """Activity ref data provider for Towers on the Air"""
POLL_INTERVAL_DAYS = 30 POLL_INTERVAL_DAYS = 30
SIG = "Towers" ACTIVITY = ActivityName.TOWERS
DATA_URL = "https://wwtota.com/servis/generate_csv.php?ref=&filter=all" DATA_URL = "https://wwtota.com/servis/generate_csv.php?ref=&filter=all"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"):
ref_id = row["Ref"] ref_id = row["Ref"]
new_data.append( new_data.append(
SIGRef( ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("Nazev", None), 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}", url=f"https://wwtota.com/seznam/karta_rozhledny.php?ref={ref_id}",
grid=row["Lokator"] if "Lokator" in row and row["Lokator"] != "" else None, 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, latitude=float(row["Lat"]) if "Lat" in row and row["Lat"] != "" else None,
@@ -40,7 +40,7 @@ class Towers(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -4,24 +4,24 @@ from time import sleep
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadActivityRefDataProvider,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class WCA(FileDownloadSIGRefDataProvider): class WCA(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for World Castles Award""" """Activity ref data provider for World Castles Award"""
POLL_INTERVAL_DAYS = 30 POLL_INTERVAL_DAYS = 30
SIG = "WCA" ACTIVITY = ActivityName.WCA
DATA_URL = "https://polo.ham2k.com/data/activities/wca/all-castles.csv" DATA_URL = "https://polo.ham2k.com/data/activities/wca/all-castles.csv"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
@@ -42,11 +42,11 @@ class WCA(FileDownloadSIGRefDataProvider):
logger.debug(f"Encountered dodgy formatting in WCA CSV, skipping location data for {ref_id}") logger.debug(f"Encountered dodgy formatting in WCA CSV, skipping location data for {ref_id}")
new_data.append( new_data.append(
SIGRef( ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("CLEAN NAME", None), 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}", url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
latitude=latitude, latitude=latitude,
longitude=longitude, longitude=longitude,
@@ -59,7 +59,7 @@ class WCA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -1,21 +1,21 @@
from time import sleep from time import sleep
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadActivityRefDataProvider,
) )
class WOTA(FileDownloadSIGRefDataProvider): class WOTA(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Wainwrights on the Air""" """Activity ref data provider for Wainwrights on the Air"""
POLL_INTERVAL_DAYS = 365 POLL_INTERVAL_DAYS = 365
SIG = "WOTA" ACTIVITY = ActivityName.WOTA
DATA_URL = "https://www.wota.org.uk/mapping/data/summits.json" DATA_URL = "https://www.wota.org.uk/mapping/data/summits.json"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
@@ -29,12 +29,12 @@ class WOTA(FileDownloadSIGRefDataProvider):
url = f"https://www.wota.org.uk/MM_LDO-{number + 214!s}" url = f"https://www.wota.org.uk/MM_LDO-{number + 214!s}"
new_data.append( new_data.append(
SIGRef( ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=ref_id, id=ref_id,
name=feature["properties"]["title"], name=feature["properties"]["title"],
url=url, url=url,
ref_type=SIGRefType.SUMMIT, ref_type=ActivityRefType.SUMMIT,
grid=feature["properties"]["qthLocator"], grid=feature["properties"]["qthLocator"],
latitude=feature["geometry"]["coordinates"][1], latitude=feature["geometry"]["coordinates"][1],
longitude=feature["geometry"]["coordinates"][0], longitude=feature["geometry"]["coordinates"][0],
@@ -47,7 +47,7 @@ class WOTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -1,33 +1,33 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadActivityRefDataProvider,
) )
class WWBOTA(FileDownloadSIGRefDataProvider): class WWBOTA(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Worldwide Bunkers on the Air""" """Activity ref data provider for Worldwide Bunkers on the Air"""
POLL_INTERVAL_DAYS = 30 POLL_INTERVAL_DAYS = 30
SIG = "WWBOTA" ACTIVITY = ActivityName.WWBOTA
DATA_URL = "https://api.wwbota.org/bunkers/?format=CSV" DATA_URL = "https://api.wwbota.org/bunkers/?format=CSV"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
ref_id = row["Reference"] ref_id = row["Reference"]
new_data.append( new_data.append(
SIGRef( ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("Name", None), 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, 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, 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, latitude=float(row["Lat"]) if "Lat" in row and row["Lat"] != "" else None,
@@ -40,7 +40,7 @@ class WWBOTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -1,33 +1,33 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadActivityRefDataProvider,
) )
class WWFF(FileDownloadSIGRefDataProvider): class WWFF(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for Worldwide Flora & Fauna""" """Activity ref data provider for Worldwide Flora & Fauna"""
POLL_INTERVAL_DAYS = 30 POLL_INTERVAL_DAYS = 30
SIG = "WWFF" ACTIVITY = ActivityName.WWFF
DATA_URL = "https://wwff.co/wwff-data/wwff_directory.csv" DATA_URL = "https://wwff.co/wwff-data/wwff_directory.csv"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()): for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
ref_id = row["reference"] ref_id = row["reference"]
new_data.append( new_data.append(
SIGRef( ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=ref_id, id=ref_id,
name=row.get("name", None), name=row.get("name", None),
ref_type=SIGRefType.PARK, ref_type=ActivityRefType.PARK,
url=f"https://wwff.co/directory/?showRef={ref_id}", url=f"https://wwff.co/directory/?showRef={ref_id}",
grid=row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None, grid=row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None,
latitude=float(row["latitude"]) latitude=float(row["latitude"])
@@ -44,7 +44,7 @@ class WWFF(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
@@ -2,22 +2,22 @@ from time import sleep
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.activityrefdata.file_download_activity_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadActivityRefDataProvider,
) )
class ZLOTA(FileDownloadSIGRefDataProvider): class ZLOTA(FileDownloadActivityRefDataProvider):
"""SIG ref data provider for New Zealand on the Air""" """Activity ref data provider for New Zealand on the Air"""
POLL_INTERVAL_DAYS = 30 POLL_INTERVAL_DAYS = 30
SIG = "ZLOTA" ACTIVITY = ActivityName.ZLOTA
DATA_URL = "https://ontheair.nz/assets/assets.json" DATA_URL = "https://ontheair.nz/assets/assets.json"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS) super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
def _http_response_to_data(self, http_response): def _http_response_to_data(self, http_response):
new_data = [] new_data = []
@@ -28,12 +28,12 @@ class ZLOTA(FileDownloadSIGRefDataProvider):
latitude = ref["latitude"] latitude = ref["latitude"]
longitude = ref["longitude"] longitude = ref["longitude"]
try: try:
ref_type = SIGRefType(ref["asset_type"].title().upper()) ref_type = ActivityRefType(ref["asset_type"].title().upper())
except ValueError: except ValueError:
ref_type = None ref_type = None
new_ref = SIGRef( new_ref = ActivityRef(
sig=self.SIG, sig=self.ACTIVITY,
id=ref_id, id=ref_id,
name=ref["name"], name=ref["name"],
ref_type=ref_type, ref_type=ref_type,
@@ -58,7 +58,7 @@ class ZLOTA(FileDownloadSIGRefDataProvider):
if self._stop_event.is_set(): if self._stop_event.is_set():
break 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. # is available for other threads e.g. the web server.
sleep(0.001) sleep(0.001)
+4 -4
View File
@@ -3,9 +3,9 @@ from datetime import datetime, timedelta
import pytz import pytz
from bs4 import BeautifulSoup 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.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -56,9 +56,9 @@ class BOTA(HTTPAlertProvider):
alert = Alert( alert = Alert(
source=self.name, source=self.name,
dx_calls=[dx_call], 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(), start_time=date_time.timestamp(),
alert_type=AlertType.XOTA,
) )
new_alerts.append(alert) new_alerts.append(alert)
+6 -6
View File
@@ -2,9 +2,9 @@ from datetime import datetime
import pytz import pytz
from core.enums import AlertType from core.enums import ActivityName
from data.activity_ref import ActivityRef
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -35,10 +35,11 @@ class Hamsat(HTTPAlertProvider):
dx_calls=[source_alert["callsign"].upper()], dx_calls=[source_alert["callsign"].upper()],
freqs_modes=freqs_modes, freqs_modes=freqs_modes,
comment=source_alert["comment"], 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=[ sig_refs=[
SIGRef( ActivityRef(
sig="AMSAT", sig=ActivityName.SATELLITE,
id=f"{source_alert['satellite']['name']} from {source_alert['grids'][0]}", id=f"{source_alert['satellite']['name']} from {source_alert['grids'][0]}",
) )
], ],
@@ -48,7 +49,6 @@ class Hamsat(HTTPAlertProvider):
end_time=datetime.strptime(source_alert["los_at"], "%Y-%m-%dT%H:%M:%SZ") end_time=datetime.strptime(source_alert["los_at"], "%Y-%m-%dT%H:%M:%SZ")
.replace(tzinfo=pytz.UTC) .replace(tzinfo=pytz.UTC)
.timestamp(), .timestamp(),
alert_type=AlertType.SATELLITE,
) )
# Add to our list # Add to our list
+11 -2
View File
@@ -4,7 +4,7 @@ from threading import Event, Thread
import pytz import pytz
import requests import requests
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout from requests.exceptions import ConnectionError, ConnectTimeout, JSONDecodeError, ReadTimeout
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from providers.alert.alert_provider import AlertProvider from providers.alert.alert_provider import AlertProvider
@@ -27,11 +27,15 @@ class HTTPAlertProvider(AlertProvider):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between # Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting. # subsequent polls, so start() returns immediately and the application can continue starting.
logger.info(f"Set up query of {self.name} alert API every {self._poll_interval!s} seconds.") 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}") self._thread = Thread(target=self._run, name=f"HTTPAlertProvider-{self.name}", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self):
self._stop_event.set() 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):
while True: while True:
@@ -60,9 +64,14 @@ class HTTPAlertProvider(AlertProvider):
logger.warning(f"HTTP {http_response.status_code} when calling {self.name} alerts API.") logger.warning(f"HTTP {http_response.status_code} when calling {self.name} alerts API.")
except ConnectionError: except ConnectionError:
self.status = "Error"
logger.warning(f"Connection error when accessing {self.name} alerts API.") logger.warning(f"Connection error when accessing {self.name} alerts API.")
except (ConnectTimeout, ReadTimeout): except (ConnectTimeout, ReadTimeout):
self.status = "Error"
logger.warning(f"Timeout when accessing {self.name} alerts API.") logger.warning(f"Timeout when accessing {self.name} alerts API.")
except JSONDecodeError:
self.status = "Error"
logger.warning(f"Invalid or empty JSON response from {self.name} alert API.")
except Exception: except Exception:
self.status = "Error" self.status = "Error"
logger.exception(f"Exception in HTTP JSON Alert Provider ({self.name})") logger.exception(f"Exception in HTTP JSON Alert Provider ({self.name})")
+2 -2
View File
@@ -6,7 +6,7 @@ import pytz
from rss_parser import Parser from rss_parser import Parser
from rss_parser.models.rss import RSS from rss_parser.models.rss import RSS
from core.enums import AlertType from core.enums import ActivityName
from data.alert import Alert from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -89,7 +89,7 @@ class NG3K(HTTPAlertProvider):
comment=f"{by}; {comment}; {qsl_info}", comment=f"{by}; {comment}; {qsl_info}",
start_time=start_timestamp, start_time=start_timestamp,
end_time=end_timestamp, end_time=end_timestamp,
alert_type=AlertType.DXPEDITION, sig=ActivityName.DXPEDITION,
) )
# Add to our list. # Add to our list.
+23 -27
View File
@@ -3,9 +3,9 @@ from datetime import datetime
import pytz import pytz
from core.enums import AlertType from core.enums import ActivityName
from data.activity_ref import ActivityRef
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -25,23 +25,19 @@ class ParksNPeaks(HTTPAlertProvider):
# Iterate through source data # Iterate through source data
for source_alert in http_response.json(): for source_alert in http_response.json():
# Calculate some things # Calculate some things
sig = source_alert["Class"].upper() activity = source_alert["Class"].upper()
if " - " in source_alert["Location"]: if " - " in source_alert["Location"]:
split = source_alert["Location"].split(" - ") split = source_alert["Location"].split(" - ")
sig_ref = split[0] ref_id = split[0]
sig_ref_name = split[1] ref_name = split[1]
else: else:
sig_ref = source_alert["WWFFID"] ref_id = source_alert["WWFFID"]
sig_ref_name = source_alert["Location"] ref_name = source_alert["Location"]
start_time = ( start_time = (
datetime.strptime(source_alert["alTime"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp() datetime.strptime(source_alert["alTime"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp()
) )
sigrefs = [] activity_refs = [ActivityRef(id=ref_id, sig=activity, name=ref_name)]
# 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)]
# Convert to our alert format # Convert to our alert format
alert = Alert( alert = Alert(
@@ -50,29 +46,29 @@ class ParksNPeaks(HTTPAlertProvider):
dx_calls=[source_alert["CallSign"].upper()], dx_calls=[source_alert["CallSign"].upper()],
freqs_modes=f"{source_alert['Freq']} {source_alert['MODE']}", freqs_modes=f"{source_alert['Freq']} {source_alert['MODE']}",
comment=source_alert["Comments"], comment=source_alert["Comments"],
sig_refs=sigrefs, sig=activity,
sig_refs=activity_refs,
start_time=start_time, 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 # Log a warning for the developer if PnP gives us an unknown programme we've never seen before
if sig and sig not in [ if activity and activity not in [
"POTA", ActivityName.POTA,
"SOTA", ActivityName.SOTA,
"WWFF", ActivityName.WWFF,
"HEMA", ActivityName.HEMA,
"SIOTA", ActivityName.SIOTA,
"ZLOTA", ActivityName.ZLOTA,
"KRMNPA", ActivityName.KRMNPA,
"SANPCPA", ActivityName.SANPCPA,
"LLOTA", ActivityName.LLOTA,
"QRP", 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 # 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 # 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. # 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) new_alerts.append(alert)
return new_alerts return new_alerts
+5 -5
View File
@@ -2,9 +2,9 @@ from datetime import datetime
import pytz import pytz
from core.enums import AlertType from core.enums import ActivityName
from data.activity_ref import ActivityRef
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -28,10 +28,11 @@ class POTA(HTTPAlertProvider):
dx_calls=[source_alert["activator"].upper()], dx_calls=[source_alert["activator"].upper()],
freqs_modes=source_alert["frequencies"], freqs_modes=source_alert["frequencies"],
comment=source_alert["comments"], comment=source_alert["comments"],
sig=ActivityName.POTA,
sig_refs=[ sig_refs=[
SIGRef( ActivityRef(
id=source_alert["reference"], id=source_alert["reference"],
sig="POTA", sig=ActivityName.POTA,
name=source_alert["name"], name=source_alert["name"],
url=f"https://pota.app/#/park/{source_alert['reference']}", url=f"https://pota.app/#/park/{source_alert['reference']}",
) )
@@ -45,7 +46,6 @@ class POTA(HTTPAlertProvider):
end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"], "%Y-%m-%d%H:%M") end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"], "%Y-%m-%d%H:%M")
.replace(tzinfo=pytz.UTC) .replace(tzinfo=pytz.UTC)
.timestamp(), .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 # Add to our list, but exclude any old spots that POTA can sometimes give us where even the end time is
+2 -2
View File
@@ -2,7 +2,7 @@ import re
from icalendar import Event from icalendar import Event
from core.enums import AlertType, Continent from core.enums import ActivityName, Continent
from data.alert import Alert from data.alert import Alert
from providers.alert.ical_alert_provider import ICALAlertProvider from providers.alert.ical_alert_provider import ICALAlertProvider
@@ -69,7 +69,7 @@ class RSGBICALAlertProvider(ICALAlertProvider):
comment=summary, comment=summary,
start_time=start_timestamp, start_time=start_timestamp,
end_time=end_timestamp, end_time=end_timestamp,
alert_type=AlertType.CONTEST, sig=ActivityName.CONTEST,
) )
return alert return alert
+5 -5
View File
@@ -2,9 +2,9 @@ from datetime import datetime
import pytz import pytz
from core.enums import AlertType from core.enums import ActivityName
from data.activity_ref import ActivityRef
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -34,10 +34,11 @@ class SOTA(HTTPAlertProvider):
dx_names=[source_alert["activatorName"].upper()], dx_names=[source_alert["activatorName"].upper()],
freqs_modes=source_alert["frequency"], freqs_modes=source_alert["frequency"],
comment=source_alert["comments"], comment=source_alert["comments"],
sig=ActivityName.SOTA,
sig_refs=[ sig_refs=[
SIGRef( ActivityRef(
id=f"{source_alert['associationCode']}/{source_alert['summitCode']}", id=f"{source_alert['associationCode']}/{source_alert['summitCode']}",
sig="SOTA", sig=ActivityName.SOTA,
name=summit_name, name=summit_name,
activation_score=summit_points, activation_score=summit_points,
) )
@@ -45,7 +46,6 @@ class SOTA(HTTPAlertProvider):
start_time=datetime.strptime(source_alert["dateActivated"], "%Y-%m-%dT%H:%M:%SZ") start_time=datetime.strptime(source_alert["dateActivated"], "%Y-%m-%dT%H:%M:%SZ")
.replace(tzinfo=pytz.UTC) .replace(tzinfo=pytz.UTC)
.timestamp(), .timestamp(),
alert_type=AlertType.XOTA,
) )
# Add to our list # Add to our list
+2 -2
View File
@@ -1,6 +1,6 @@
from icalendar import Event from icalendar import Event
from core.enums import AlertType from core.enums import ActivityName
from data.alert import Alert from data.alert import Alert
from providers.alert.ical_alert_provider import ICALAlertProvider from providers.alert.ical_alert_provider import ICALAlertProvider
@@ -35,7 +35,7 @@ class WA7BNM(ICALAlertProvider):
url=url, url=url,
start_time=start_timestamp, start_time=start_timestamp,
end_time=end_timestamp, end_time=end_timestamp,
alert_type=AlertType.CONTEST, sig=ActivityName.CONTEST,
) )
return alert return alert
+5 -4
View File
@@ -7,8 +7,9 @@ import pytz
from rss_parser import Parser as RSSParser from rss_parser import Parser as RSSParser
from rss_parser.models.rss import RSS 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.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -52,7 +53,7 @@ class WOTA(HTTPAlertProvider):
if len(ref_split) > 1: if len(ref_split) > 1:
ref_name = str(ref_split[1]) ref_name = str(ref_split[1])
except Exception: 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 # Pick apart the description
comment = None comment = None
@@ -63,7 +64,7 @@ class WOTA(HTTPAlertProvider):
if len(desc_split) > 1: if len(desc_split) > 1:
comment = desc_split[1].strip() comment = desc_split[1].strip()
except Exception: 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) time = datetime.strptime(source_alert.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
@@ -74,7 +75,7 @@ class WOTA(HTTPAlertProvider):
dx_calls=[dx_call], dx_calls=[dx_call],
freqs_modes=freqs_modes, freqs_modes=freqs_modes,
comment=comment, 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(), start_time=time.timestamp(),
) )
+4 -4
View File
@@ -2,9 +2,9 @@ from datetime import datetime
import pytz import pytz
from core.enums import AlertType from core.enums import ActivityName
from data.activity_ref import ActivityRef
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -28,14 +28,14 @@ class WWFF(HTTPAlertProvider):
dx_calls=[source_alert["activator_call"].upper()], dx_calls=[source_alert["activator_call"].upper()],
freqs_modes=f"{source_alert['band']} {source_alert['mode']}", freqs_modes=f"{source_alert['band']} {source_alert['mode']}",
comment=source_alert["remarks"], 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") start_time=datetime.strptime(source_alert["utc_start"], "%Y-%m-%d %H:%M:%S")
.replace(tzinfo=pytz.UTC) .replace(tzinfo=pytz.UTC)
.timestamp(), .timestamp(),
end_time=datetime.strptime(source_alert["utc_end"], "%Y-%m-%d %H:%M:%S") end_time=datetime.strptime(source_alert["utc_end"], "%Y-%m-%d %H:%M:%S")
.replace(tzinfo=pytz.UTC) .replace(tzinfo=pytz.UTC)
.timestamp(), .timestamp(),
alert_type=AlertType.XOTA,
) )
# Add to our list # Add to our list
@@ -42,7 +42,13 @@ class CallsignDataProvider:
if self.enabled: if self.enabled:
if callsign in self._storage: if callsign in self._storage:
try:
return self._storage[callsign] return self._storage[callsign]
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 self._storage[callsign]
return None
else: else:
c = self._perform_new_lookup(callsign, lookup_credentials) c = self._perform_new_lookup(callsign, lookup_credentials)
if c: if c:
@@ -32,11 +32,15 @@ class FileDownloadCallsignDataProvider(CallsignDataProvider):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between # Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting. # subsequent polls, so start() returns immediately and the application can continue starting.
logger.info(f"Set up query of {self.name} callsign reference data every {self._poll_interval!s} days.") 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}") self._thread = Thread(target=self._run, name=f"FileDownloadCallsignDataProvider-{self.name}", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self):
self._stop_event.set() 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):
while True: while True:
+7 -7
View File
@@ -61,7 +61,7 @@ class HamQTH(APIQueryCallsignDataProvider):
logger.debug("HamQTH login details incorrect, failed to look up with HamQTH.") logger.debug("HamQTH login details incorrect, failed to look up with HamQTH.")
return None return None
except Exception: except Exception:
logger.error("Exception when getting HamQTH session key") logger.exception("Exception when getting HamQTH session key")
return None return None
if not session_id: if not session_id:
@@ -124,8 +124,8 @@ class HamQTH(APIQueryCallsignDataProvider):
lat = None lat = None
lon = None lon = None
if ( if (
"latitude" in data data.get("latitude") is not None
and "longitude" in data and data.get("longitude") is not None
and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0)
and -89.9 < float(data["latitude"]) < 89.9 and -89.9 < float(data["latitude"]) < 89.9
): ):
@@ -134,7 +134,7 @@ class HamQTH(APIQueryCallsignDataProvider):
# Check for sensible grids # Check for sensible grids
grid = None grid = None
if "grid" in data and not data["grid"].startswith("AA00"): if data.get("grid") and not data["grid"].startswith("AA00"):
grid = data["grid"] grid = data["grid"]
return Callsign( return Callsign(
@@ -147,8 +147,8 @@ class HamQTH(APIQueryCallsignDataProvider):
latitude=lat, latitude=lat,
longitude=lon, longitude=lon,
grid=grid, grid=grid,
dxcc_id=int(data["adif"]) if "adif" in data else None, dxcc_id=int(data["adif"]) if data.get("adif") is not None else None,
cq_zone=int(data["cq"]) if "cq" in data else None, cq_zone=int(data["cq"]) if data.get("cq") is not None else None,
itu_zone=int(data["itu"]) if "itu" in data else None, itu_zone=int(data["itu"]) if data.get("itu") is not None else None,
location_source=LocationSourceForCallsign.HOME_QTH, location_source=LocationSourceForCallsign.HOME_QTH,
) )
+7 -7
View File
@@ -57,7 +57,7 @@ class QRZ(APIQueryCallsignDataProvider):
logger.debug("QRZ.com login details incorrect, failed to look up with QRZ.") logger.debug("QRZ.com login details incorrect, failed to look up with QRZ.")
return None return None
except Exception: except Exception:
logger.error("Exception when getting QRZ.com session key") logger.exception("Exception when getting QRZ.com session key")
return None return None
if not session_key: if not session_key:
@@ -150,8 +150,8 @@ class QRZ(APIQueryCallsignDataProvider):
lat = None lat = None
lon = None lon = None
if ( if (
"latitude" in data data.get("latitude") is not None
and "longitude" in data and data.get("longitude") is not None
and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0)
and -89.9 < float(data["latitude"]) < 89.9 and -89.9 < float(data["latitude"]) < 89.9
): ):
@@ -160,7 +160,7 @@ class QRZ(APIQueryCallsignDataProvider):
# Check for sensible grids # Check for sensible grids
grid = None grid = None
if "grid" in data and not data["grid"].startswith("AA00"): if data.get("grid") and not data["grid"].startswith("AA00"):
grid = data["grid"] grid = data["grid"]
return Callsign( return Callsign(
@@ -173,8 +173,8 @@ class QRZ(APIQueryCallsignDataProvider):
latitude=lat, latitude=lat,
longitude=lon, longitude=lon,
grid=grid, grid=grid,
dxcc_id=int(data["adif"]) if "adif" in data else None, dxcc_id=int(data["adif"]) if data.get("adif") is not None else None,
cq_zone=int(data["cqzone"]) if "cqzone" in data else None, cq_zone=int(data["cqzone"]) if data.get("cqzone") is not None else None,
itu_zone=int(data["ituzone"]) if "ituzone" in data else None, itu_zone=int(data["ituzone"]) if data.get("ituzone") is not None else None,
location_source=LocationSourceForCallsign.HOME_QTH, location_source=LocationSourceForCallsign.HOME_QTH,
) )
-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")
-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)
+5 -1
View File
@@ -67,11 +67,15 @@ class GIROIonosonde(SolarConditionsProvider):
def start(self): def start(self):
logger.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.") logger.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
self._thread = Thread(target=self._run, name="GIROIonosondeDataProvider") self._thread = Thread(target=self._run, name="GIROIonosondeDataProvider", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self):
self._stop_event.set() 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):
# Real interval at which we poll is the "once per hour" divided by the number of stations, so each one gets # Real interval at which we poll is the "once per hour" divided by the number of stations, so each one gets
@@ -25,11 +25,15 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
def start(self): def start(self):
logger.info(f"Set up query of {self.name} solar conditions API every {self._poll_interval!s} seconds.") 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}") self._thread = Thread(target=self._run, name=f"HTTPSolarConditionsProvider-{self.name}", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self):
self._stop_event.set() 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):
while True: while True:
+5 -1
View File
@@ -32,11 +32,15 @@ class KC2GProp(SolarConditionsProvider):
def start(self): def start(self):
logger.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.") logger.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.")
self._thread = Thread(target=self._run, name="KC2GPropProvider") self._thread = Thread(target=self._run, name="KC2GPropProvider", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self):
self._stop_event.set() 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):
while True: while True:
@@ -118,7 +118,9 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
column_dates = [] column_dates = []
for month_str, day_str in date_matches: for month_str, day_str in date_matches:
try: 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: except ValueError:
logger.warning(f"NOAA K-index forecast: could not parse date: {month_str} {day_str} {year}") logger.warning(f"NOAA K-index forecast: could not parse date: {month_str} {day_str} {year}")
return None return None
+28 -8
View File
@@ -1,6 +1,6 @@
import logging import logging
from datetime import datetime from datetime import datetime
from threading import Thread from threading import Event, Thread
import aprslib import aprslib
import pytz import pytz
@@ -17,27 +17,44 @@ class APRSIS(SpotProvider):
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__("APRS-IS", provider_config) super().__init__("APRS-IS", provider_config)
self._thread = Thread(target=self._connect, name="APRSISSpotProvider") self._thread = None
self._thread.daemon = True
self._aprsis = None self._aprsis = None
self._stop_event = Event()
def start(self): def start(self):
self._thread = Thread(target=self._run, name="APRSISSpotProvider", daemon=True)
self._thread.start() self._thread.start()
def _connect(self): def _run(self):
while not self._stop_event.is_set():
try:
self._aprsis = aprslib.IS(SERVER_OWNER_CALLSIGN) self._aprsis = aprslib.IS(SERVER_OWNER_CALLSIGN)
self.status = "Connecting" self.status = "Connecting"
logger.info("APRS-IS connecting...") logger.info("APRS-IS connecting...")
self._aprsis.connect() self._aprsis.connect()
self._aprsis.consumer(self._handle)
logger.info("APRS-IS connected.") logger.info("APRS-IS connected.")
self._aprsis.consumer(self._handle, immortal=True)
except Exception:
if not self._stop_event.is_set():
self.status = "Error"
logger.exception("Exception in APRS-IS provider")
if not self._stop_event.is_set():
self._stop_event.wait(timeout=5)
def stop(self): def stop(self):
self.status = "Shutting down" self.status = "Shutting down"
self._stop_event.set()
if self._aprsis:
self._aprsis.close() self._aprsis.close()
self._thread.join() if self._thread:
self._thread.join(timeout=5)
if self._thread.is_alive():
logger.warning("APRS-IS worker thread did not exit on time and will be killed.")
def _handle(self, data): def _handle(self, data):
try:
# Split SSID in "from" call and store separately # Split SSID in "from" call and store separately
from_parts = str(data["from"]).split("-") from_parts = str(data["from"]).split("-")
dx_call = from_parts[0].upper() dx_call = from_parts[0].upper()
@@ -52,8 +69,8 @@ class APRSIS(SpotProvider):
de_call=de_call, de_call=de_call,
de_ssid=de_ssid, de_ssid=de_ssid,
comment=str(data["comment"]) if "comment" in data else None, comment=str(data["comment"]) if "comment" in data else None,
dx_latitude=float(data["latitude"]) if "latitude" in data else None, dx_latitude=float(data["latitude"]) if data.get("latitude") is not None else None,
dx_longitude=float(data["longitude"]) if "longitude" in data else None, dx_longitude=float(data["longitude"]) if data.get("longitude") is not None else None,
time=datetime.now(pytz.UTC).timestamp(), time=datetime.now(pytz.UTC).timestamp(),
) # APRS-IS spots are live so we can assume spot time is "now" ) # APRS-IS spots are live so we can assume spot time is "now"
@@ -63,3 +80,6 @@ class APRSIS(SpotProvider):
self.status = "OK" self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC) self.last_update_time = datetime.now(pytz.UTC)
logger.debug("Data received from APRS-IS.") logger.debug("Data received from APRS-IS.")
except Exception:
logger.exception("Exception handling APRS-IS packet")
+34 -18
View File
@@ -1,8 +1,8 @@
import logging import logging
import re import re
import socket
from datetime import datetime from datetime import datetime
from threading import Thread from threading import Event, Lock, Thread
from time import sleep
import pytz import pytz
import telnetlib3 import telnetlib3
@@ -41,27 +41,43 @@ class DXCluster(SpotProvider):
self._LINE_PATTERN_ALLOW_RBN if self._allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN self._LINE_PATTERN_ALLOW_RBN if self._allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN
) )
self._telnet = None self._telnet = None
self._thread = Thread(target=self._handle, name=f"DXClusterSpotProvider-{self.name}") self._telnet_lock = Lock()
self._thread.daemon = True self._thread = None
self._running = True self._stop_event = Event()
def start(self): def start(self):
self._thread = Thread(target=self._handle, name=f"DXClusterSpotProvider-{self.name}", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self):
self._running = False self._stop_event.set()
with self._telnet_lock:
if self._telnet: if self._telnet:
try:
self._telnet.sock.shutdown(socket.SHUT_RDWR)
except (AttributeError, OSError):
pass
self._telnet.close() self._telnet.close()
self._thread.join() if self._thread:
self._thread.join(timeout=5)
if self._thread.is_alive():
logger.warning(f"DX Cluster {self._hostname} worker thread did not exit on time and will be killed.")
def _handle(self): def _handle(self):
while self._running: while not self._stop_event.is_set():
connected = False connected = False
while not connected and self._running: while not connected and not self._stop_event.is_set():
try: try:
self.status = "Connecting" self.status = "Connecting"
logger.info(f"DX Cluster {self._hostname} connecting...") logger.info(f"DX Cluster {self._hostname} connecting...")
self._telnet = telnetlib3.Telnet(self._hostname, self._port) new_telnet = telnetlib3.Telnet(self._hostname, self._port)
with self._telnet_lock:
self._telnet = new_telnet
if self._stop_event.is_set():
# stop() was called while we were connecting, close the connection rather than trying to
# read when we know it won't work
new_telnet.close()
break
self._telnet.read_until(self._login_prompt.encode("latin-1")) self._telnet.read_until(self._login_prompt.encode("latin-1"))
self._telnet.write(f"{self._login_callsign}\n".encode("latin-1")) self._telnet.write(f"{self._login_callsign}\n".encode("latin-1"))
connected = True connected = True
@@ -69,20 +85,20 @@ class DXCluster(SpotProvider):
except ConnectionRefusedError: except ConnectionRefusedError:
self.status = "Error" self.status = "Error"
logger.warning(f"Connection refused to DX cluster {self._hostname}") logger.warning(f"Connection refused to DX cluster {self._hostname}")
sleep(300) self._stop_event.wait(timeout=300)
except Exception: except Exception:
self.status = "Error" self.status = "Error"
logger.exception(f"Exception while connecting to DX Cluster Provider ({self._hostname}).") logger.exception(f"Exception while connecting to DX Cluster Provider ({self._hostname}).")
sleep(5) self._stop_event.wait(timeout=5)
self.status = "Waiting for Data" self.status = "Waiting for Data"
while connected and self._running: while connected and not self._stop_event.is_set():
try: try:
# Check new telnet info against regular expression # Check new telnet info against regular expression
telnet_output = self._telnet.read_until("\n".encode("latin-1")) telnet_output = self._telnet.read_until("\n".encode("latin-1"))
match = self._spot_line_pattern.match(telnet_output.decode("latin-1")) match = self._spot_line_pattern.match(telnet_output.decode("latin-1"))
if match: if match:
spot_time = datetime.strptime(match.group(5), "%H%MZ") spot_time = datetime.strptime(match.group(5), "%H%MZ").replace(tzinfo=pytz.UTC)
spot_datetime = datetime.combine( spot_datetime = datetime.combine(
datetime.now(pytz.UTC).date(), datetime.now(pytz.UTC).date(),
spot_time.time(), spot_time.time(),
@@ -106,19 +122,19 @@ class DXCluster(SpotProvider):
except EOFError: except EOFError:
connected = False connected = False
if self._running: if not self._stop_event.is_set():
self.status = "Restarting" self.status = "Restarting"
logger.warning(f"Disconnected from DX Cluster {self._hostname}. Reconnecting...") logger.warning(f"Disconnected from DX Cluster {self._hostname}. Reconnecting...")
sleep(5) self._stop_event.wait(timeout=5)
else: else:
logger.info(f"DX Cluster {self._hostname} shutting down...") logger.info(f"DX Cluster {self._hostname} shutting down...")
self.status = "Shutting down" self.status = "Shutting down"
except Exception: except Exception:
connected = False connected = False
if self._running: if not self._stop_event.is_set():
self.status = "Error" self.status = "Error"
logger.exception(f"Exception in DX Cluster Provider ({self._hostname})") logger.exception(f"Exception in DX Cluster Provider ({self._hostname})")
sleep(5) self._stop_event.wait(timeout=5)
else: else:
logger.info(f"DX Cluster {self._hostname} shutting down...") logger.info(f"DX Cluster {self._hostname} shutting down...")
self.status = "Shutting down" self.status = "Shutting down"
+44 -41
View File
@@ -4,9 +4,9 @@ from datetime import datetime
import pytz import pytz
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.enums import Mode, SIGRefType from core.enums import ActivityName, ActivityRefType, Mode
from core.url_data_cache import URLDataCache from core.url_data_cache import URLDataCache
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -69,7 +69,7 @@ class GMA(HTTPSpotProvider):
mode=Mode.from_name(source_spot["MODE"].upper()) if "<>" not in source_spot["MODE"] else None, mode=Mode.from_name(source_spot["MODE"].upper()) if "<>" not in source_spot["MODE"] else None,
comment=source_spot["TEXT"], comment=source_spot["TEXT"],
sig_refs=[ sig_refs=[
SIGRef( ActivityRef(
id=source_spot["REF"], id=source_spot["REF"],
sig="", sig="",
name=source_spot["NAME"], name=source_spot["NAME"],
@@ -83,7 +83,7 @@ class GMA(HTTPSpotProvider):
qrt=source_spot["QRG"] == "QRT", qrt=source_spot["QRG"] == "QRT",
) )
# GMA doesn't give what programme (SIG) the reference is for until we separately look it up. # GMA doesn't give what programme (activity) the reference is for until we separately look it up.
if "REF" in source_spot: if "REF" in source_spot:
try: try:
ref_response = self._url_data_cache.get( ref_response = self._url_data_cache.get(
@@ -98,48 +98,51 @@ class GMA(HTTPSpotProvider):
and ref_response.text != "\n" and ref_response.text != "\n"
): ):
ref_info = ref_response.json() ref_info = ref_response.json()
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. POTA and WWFF if spot.sig_refs and ref_info and "reftype" in ref_info:
# spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA
# and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry
# to determine if it's a SOTA summit.
if (
spot.sig_refs
and ref_info
and "reftype" in ref_info
and ref_info["reftype"] not in ["POTA", "WWFF"]
and (
ref_info["reftype"] != "Summit" or "sota" not in ref_info or ref_info["sota"] == ""
)
):
match ref_info["reftype"]: match ref_info["reftype"]:
case "Summit": case "Summit":
spot.sig_refs[0].sig = "GMA" # Summits are a bit complicated, they can be SOTA or GMA depending on the
spot.sig_refs[0].ref_type = SIGRefType.SUMMIT # separate "sota" field:
spot.sig = "GMA" if "sota" in ref_info and ref_info["sota"] != "":
spot.sig_refs[0].sig = ActivityName.SOTA
spot.sig_refs[0].ref_type = ActivityRefType.SUMMIT
spot.sig = ActivityName.SOTA
else:
spot.sig_refs[0].sig = ActivityName.GMA
spot.sig_refs[0].ref_type = ActivityRefType.SUMMIT
spot.sig = ActivityName.GMA
case "POTA":
spot.sig_refs[0].sig = ActivityName.POTA
spot.sig_refs[0].ref_type = ActivityRefType.PARK
spot.sig = ActivityName.POTA
case "WWFF":
spot.sig_refs[0].sig = ActivityName.WWFF
spot.sig_refs[0].ref_type = ActivityRefType.PARK
spot.sig = ActivityName.WWFF
case "IOTA Island": case "IOTA Island":
spot.sig_refs[0].sig = "IOTA" spot.sig_refs[0].sig = ActivityName.IOTA
spot.sig_refs[0].ref_type = SIGRefType.ISLAND spot.sig_refs[0].ref_type = ActivityRefType.ISLAND
spot.sig = "IOTA" spot.sig = ActivityName.IOTA
case "GMA Island": case "GMA Island":
spot.sig_refs[0].sig = "GMA Islands" spot.sig_refs[0].sig = ActivityName.GMA_ISLANDS
spot.sig_refs[0].ref_type = SIGRefType.ISLAND spot.sig_refs[0].ref_type = ActivityRefType.ISLAND
spot.sig = "GMA Islands" spot.sig = ActivityName.GMA_ISLANDS
case "Lighthouse (ILLW)": case "Lighthouse (ILLW)":
spot.sig_refs[0].sig = "ILLW" spot.sig_refs[0].sig = ActivityName.ILLW
spot.sig_refs[0].ref_type = SIGRefType.LIGHTHOUSE spot.sig_refs[0].ref_type = ActivityRefType.LIGHTHOUSE
spot.sig = "ILLW" spot.sig = ActivityName.ILLW
case "Lighthouse (ARLHS)": case "Lighthouse (ARLHS)":
spot.sig_refs[0].sig = "ARLHS" spot.sig_refs[0].sig = ActivityName.ARLHS
spot.sig_refs[0].ref_type = SIGRefType.LIGHTHOUSE spot.sig_refs[0].ref_type = ActivityRefType.LIGHTHOUSE
spot.sig = "ARLHS" spot.sig = ActivityName.ARLHS
case "Castle": case "Castle":
spot.sig_refs[0].sig = "WCA" spot.sig_refs[0].sig = ActivityName.WCA
spot.sig_refs[0].ref_type = SIGRefType.CASTLE spot.sig_refs[0].ref_type = ActivityRefType.CASTLE
spot.sig = "WCA" spot.sig = ActivityName.WCA
case "Mill": case "Mill":
spot.sig_refs[0].sig = "MOTA" spot.sig_refs[0].sig = ActivityName.MOTA
spot.sig_refs[0].ref_type = SIGRefType.MILL spot.sig_refs[0].ref_type = ActivityRefType.MILL
spot.sig = "MOTA" spot.sig = ActivityName.MOTA
case _: case _:
logger.warning( logger.warning(
f"GMA spot found with ref type {ref_info['reftype']}, developer needs to add support for this!" f"GMA spot found with ref type {ref_info['reftype']}, developer needs to add support for this!"
@@ -158,7 +161,7 @@ class GMA(HTTPSpotProvider):
) )
except Exception: except Exception:
logger.exception( logger.exception(
f"Exception when looking up {self.REF_INFO_URL_ROOT}{source_spot['REF']}, SIG data will not be populated for the spot." f"Exception when looking up {self.REF_INFO_URL_ROOT}{source_spot['REF']}, activity data will not be populated for the spot."
) )
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; # Add to our list. Don't worry about de-duping, removing old spots etc. at this point;
@@ -169,8 +172,8 @@ class GMA(HTTPSpotProvider):
return new_spots return new_spots
def can_submit_spot(self, sig): def can_submit_spot(self, activity):
return sig == "GMA" return activity == ActivityName.GMA
def submit_spot(self, spot, credentials): def submit_spot(self, spot, credentials):
# TODO: Implement. # TODO: Implement.
+8 -8
View File
@@ -7,8 +7,8 @@ import requests
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.enums import Mode, SIGRefType from core.enums import ActivityName, ActivityRefType, Mode
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -62,15 +62,15 @@ class HEMA(HTTPSpotProvider):
freq=float(freq_mode_match.group(1)) * 1000000, freq=float(freq_mode_match.group(1)) * 1000000,
mode=Mode.from_name(freq_mode_match.group(2).upper()), mode=Mode.from_name(freq_mode_match.group(2).upper()),
comment=spotter_comment_match.group(2), comment=spotter_comment_match.group(2),
sig="HEMA", sig=ActivityName.HEMA,
sig_refs=[ sig_refs=[
SIGRef( ActivityRef(
id=spot_items[3].upper(), id=spot_items[3].upper(),
sig="HEMA", sig=ActivityName.HEMA,
name=spot_items[4], name=spot_items[4],
latitude=float(spot_items[7]), latitude=float(spot_items[7]),
longitude=float(spot_items[8]), longitude=float(spot_items[8]),
ref_type=SIGRefType.SUMMIT, ref_type=ActivityRefType.SUMMIT,
) )
], ],
time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M") time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M")
@@ -89,8 +89,8 @@ class HEMA(HTTPSpotProvider):
logger.warning("Connection error when accessing HEMA spots API.") logger.warning("Connection error when accessing HEMA spots API.")
return new_spots return new_spots
def can_submit_spot(self, sig): def can_submit_spot(self, activity):
return sig == "HEMA" return activity == ActivityName.HEMA
def submit_spot(self, spot, credentials): def submit_spot(self, spot, credentials):
# TODO: Implement. Currently blocked awaiting their API team to make a change to allow us to spot with a # TODO: Implement. Currently blocked awaiting their API team to make a change to allow us to spot with a
+11 -2
View File
@@ -4,7 +4,7 @@ from threading import Event, Thread
import pytz import pytz
import requests import requests
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout from requests.exceptions import ConnectionError, ConnectTimeout, JSONDecodeError, ReadTimeout
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from providers.spot.spot_provider import SpotProvider from providers.spot.spot_provider import SpotProvider
@@ -28,12 +28,16 @@ class HTTPSpotProvider(SpotProvider):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between # Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting. # subsequent polls, so start() returns immediately and the application can continue starting.
logger.info(f"Set up query of {self.name} spot API every {self._poll_interval!s} seconds.") logger.info(f"Set up query of {self.name} spot API every {self._poll_interval!s} seconds.")
self._thread = Thread(target=self._run, name=f"HTTPSpotProvider-{self.name}") self._thread = Thread(target=self._run, name=f"HTTPSpotProvider-{self.name}", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self):
self._stop_event.set() self._stop_event.set()
self._wakeup_event.set() self._wakeup_event.set()
if self._thread:
self._thread.join(timeout=12)
if self._thread.is_alive():
logger.warning(f"{self.name} spot worker thread did not exit on time and will be killed.")
def force_poll(self): def force_poll(self):
"""Trigger an immediate poll without waiting for the normal interval.""" """Trigger an immediate poll without waiting for the normal interval."""
@@ -69,9 +73,14 @@ class HTTPSpotProvider(SpotProvider):
logger.warning(f"HTTP {http_response.status_code} when calling {self.name} spot API.") logger.warning(f"HTTP {http_response.status_code} when calling {self.name} spot API.")
except ConnectionError: except ConnectionError:
self.status = "Error"
logger.warning(f"Connection error when accessing {self.name} spots API.") logger.warning(f"Connection error when accessing {self.name} spots API.")
except (ConnectTimeout, ReadTimeout): except (ConnectTimeout, ReadTimeout):
self.status = "Error"
logger.warning(f"Timeout when accessing {self.name} spots API.") logger.warning(f"Timeout when accessing {self.name} spots API.")
except JSONDecodeError:
self.status = "Error"
logger.warning(f"Invalid or empty JSON response from {self.name} spots API.")
except Exception: except Exception:
self.status = "Error" self.status = "Error"
logger.exception(f"Exception in HTTP Spot Provider ({self.name})") logger.exception(f"Exception in HTTP Spot Provider ({self.name})")
+6 -6
View File
@@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from core.enums import Mode, SIGRefType from core.enums import ActivityName, ActivityRefType, Mode
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -34,13 +34,13 @@ class LLOTA(HTTPSpotProvider):
freq=float(source_spot["frequency"]) * 1000000, freq=float(source_spot["frequency"]) * 1000000,
mode=Mode.from_name(source_spot["mode"].upper()), mode=Mode.from_name(source_spot["mode"].upper()),
comment=comment, comment=comment,
sig="LLOTA", sig=ActivityName.LLOTA,
sig_refs=[ sig_refs=[
SIGRef( ActivityRef(
id=source_spot["reference"], id=source_spot["reference"],
sig="LLOTA", sig=ActivityName.LLOTA,
name=source_spot["reference_name"], name=source_spot["reference_name"],
ref_type=SIGRefType.LAKE, ref_type=ActivityRefType.LAKE,
) )
], ],
time=datetime.fromisoformat(source_spot["updated_at"].replace("Z", "+00:00")).timestamp(), time=datetime.fromisoformat(source_spot["updated_at"].replace("Z", "+00:00")).timestamp(),
+58 -41
View File
@@ -1,13 +1,14 @@
import logging import logging
import re import re
from datetime import datetime from datetime import datetime
from typing import ClassVar
import pytz import pytz
import requests import requests
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.enums import Mode from core.enums import ActivityName, Mode
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -20,16 +21,16 @@ class ParksNPeaks(HTTPSpotProvider):
POLL_INTERVAL_SEC = 120 POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://www.parksnpeaks.org/api/ALL" SPOTS_URL = "https://www.parksnpeaks.org/api/ALL"
SUBMIT_URL = "https://www.parksnpeaks.org/api/SPOT/" SUBMIT_URL = "https://www.parksnpeaks.org/api/SPOT/"
SUBMITTABLE_SIGS = [ SUBMITTABLE_ACTIVITIES: ClassVar[list[ActivityName]] = [
"POTA", ActivityName.POTA,
"SOTA", ActivityName.SOTA,
"WWFF", ActivityName.WWFF,
"HEMA", ActivityName.HEMA,
"WOTA", ActivityName.WOTA,
"ZLOTA", ActivityName.ZLOTA,
"SIOTA", ActivityName.SIOTA,
"KRMNPA", ActivityName.KRMNPA,
"SANPCPA", ActivityName.SANPCPA,
] ]
def __init__(self, provider_config): def __init__(self, provider_config):
@@ -64,44 +65,60 @@ class ParksNPeaks(HTTPSpotProvider):
if not spot.de_call and m: if not spot.de_call and m:
spot.de_call = str(m.group(1)) spot.de_call = str(m.group(1))
# Record SIG information. Sometimes we get a "SIG" of "QRP", which we ignore as it's not a programme with a # Record activity information
# defined set of references activity = source_spot["actClass"].upper()
sig = source_spot["actClass"].upper() ref_id = source_spot["actSiteID"]
sig_ref = source_spot["actSiteID"]
if sig and sig != "" and sig != "QRP" and sig_ref and sig_ref != "": if activity:
spot.sig = sig spot.sig = activity
sig_refs = [
SIGRef( if ref_id:
id=source_spot["actSiteID"], activity_refs = [
sig=source_spot["actClass"].upper(), ActivityRef(
id=ref_id,
sig=activity,
# Free text location is not present in all spots, so only add it if it's set
name=source_spot["actLocation"]
if "actLocation" in source_spot and source_spot["actLocation"] != ""
else None,
) )
] ]
spot.sig_refs = sig_refs spot.sig_refs = activity_refs
# Free text location is not present in all spots, so only add it if it's set else:
if "actLocation" in source_spot and source_spot["actLocation"] != "": # If no actSiteID is set, e.g. because actClass is "QRP", sometimes we still have an actLocation
sig_refs[0].name = source_spot["actLocation"] # which is free text like "SOTA G/SC-001". If we have that, and not a normal comment field, use
# that location as the comment field so the information doesn't get lost.
if (
"actLocation" in source_spot
and source_spot["actLocation"] != ""
and ("actComments" not in source_spot or source_spot["actComments"] == "")
):
spot.comment = source_spot["actLocation"]
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before # Log a warning for the developer if PnP gives us an unknown programme we've never seen before
if sig not in [ if activity not in [
"POTA", ActivityName.POTA,
"SOTA", ActivityName.SOTA,
"WWFF", ActivityName.WWFF,
"HEMA", ActivityName.HEMA,
"SIOTA", ActivityName.SIOTA,
"ZLOTA", ActivityName.ZLOTA,
"KRMNPA", ActivityName.KRMNPA,
"SANPCPA", ActivityName.SANPCPA,
"LLOTA", ActivityName.LLOTA,
ActivityName.QRP,
]: ]:
logger.warning(f"PNP spot found with sig {sig}, developer needs to add support for this!") logger.warning(
f"PNP spot found with activity {activity}, developer needs to add support for this!"
)
# Add new spot to the list # Add new spot to the list
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, sig): def can_submit_spot(self, activity):
return sig in self.SUBMITTABLE_SIGS return activity in self.SUBMITTABLE_ACTIVITIES
def submit_spot(self, spot, credentials): def submit_spot(self, spot, credentials):
# TODO test this works # TODO test this works
@@ -111,11 +128,11 @@ class ParksNPeaks(HTTPSpotProvider):
raise ValueError( raise ValueError(
"Parks N Peaks user ID and API key are required. Get yours from your Parks N Peaks account." "Parks N Peaks user ID and API key are required. Get yours from your Parks N Peaks account."
) )
sig_ref = spot.sig_refs[0].id if spot.sig_refs else "" ref_id = spot.sig_refs[0].id if spot.sig_refs else ""
body = { body = {
"actClass": spot.sig or "", "actClass": spot.sig or "",
"actCallsign": spot.dx_call, "actCallsign": spot.dx_call,
"actSite": sig_ref, "actSite": ref_id,
"mode": spot.mode or "", "mode": spot.mode or "",
"freq": str(spot.freq / 1000000.0), "freq": str(spot.freq / 1000000.0),
"comments": spot.comment or "", "comments": spot.comment or "",
+8 -8
View File
@@ -4,8 +4,8 @@ import pytz
import requests import requests
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.enums import Mode, SIGRefType from core.enums import ActivityName, ActivityRefType, Mode
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -33,15 +33,15 @@ class POTA(HTTPSpotProvider):
freq=float(source_spot["frequency"]) * 1000 if source_spot["frequency"] != "INVALID" else None, freq=float(source_spot["frequency"]) * 1000 if source_spot["frequency"] != "INVALID" else None,
mode=Mode.from_name(source_spot["mode"].upper()), mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["comments"], comment=source_spot["comments"],
sig="POTA", sig=ActivityName.POTA,
sig_refs=[ sig_refs=[
SIGRef( ActivityRef(
id=source_spot["reference"], id=source_spot["reference"],
sig="POTA", sig=ActivityName.POTA,
name=source_spot["name"], name=source_spot["name"],
latitude=source_spot["latitude"], latitude=source_spot["latitude"],
longitude=source_spot["longitude"], longitude=source_spot["longitude"],
ref_type=SIGRefType.PARK ref_type=ActivityRefType.PARK
) )
], ],
time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S") time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S")
@@ -57,8 +57,8 @@ class POTA(HTTPSpotProvider):
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, sig): def can_submit_spot(self, activity):
return sig == "POTA" return activity == ActivityName.POTA
def submit_spot(self, spot, credentials): def submit_spot(self, spot, credentials):
sig_ref = spot.sig_refs[0].id if spot.sig_refs else None sig_ref = spot.sig_refs[0].id if spot.sig_refs else None
+33 -17
View File
@@ -1,8 +1,8 @@
import logging import logging
import re import re
import socket
from datetime import datetime from datetime import datetime
from threading import Thread from threading import Event, Lock, Thread
from time import sleep
import pytz import pytz
import telnetlib3 import telnetlib3
@@ -30,27 +30,43 @@ class RBN(SpotProvider):
super().__init__(name, provider_config) super().__init__(name, provider_config)
self._port = provider_config["port"] self._port = provider_config["port"]
self._telnet = None self._telnet = None
self._thread = Thread(target=self._handle, name=f"RBNSpotProvider-{self.name}") self._telnet_lock = Lock()
self._thread.daemon = True self._thread = None
self._running = True self._stop_event = Event()
def start(self): def start(self):
self._thread = Thread(target=self._handle, name=f"RBNSpotProvider-{self.name}", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self):
self._running = False self._stop_event.set()
with self._telnet_lock:
if self._telnet: if self._telnet:
try:
self._telnet.sock.shutdown(socket.SHUT_RDWR)
except (AttributeError, OSError):
pass
self._telnet.close() self._telnet.close()
self._thread.join() if self._thread:
self._thread.join(timeout=5)
if self._thread.is_alive():
logger.warning(f"RBN (port {self._port!s}) worker thread did not exit on time and will be killed.")
def _handle(self): def _handle(self):
while self._running: while not self._stop_event.is_set():
connected = False connected = False
while not connected and self._running: while not connected and not self._stop_event.is_set():
try: try:
self.status = "Connecting" self.status = "Connecting"
logger.info(f"RBN port {self._port!s} connecting...") logger.info(f"RBN port {self._port!s} connecting...")
self._telnet = telnetlib3.Telnet("telnet.reversebeacon.net", self._port) new_telnet = telnetlib3.Telnet("telnet.reversebeacon.net", self._port)
with self._telnet_lock:
self._telnet = new_telnet
if self._stop_event.is_set():
# stop() was called while we were connecting, close the connection rather than trying to
# read when we know it won't work
new_telnet.close()
break
self._telnet.read_until("Please enter your call: ".encode("latin-1")) self._telnet.read_until("Please enter your call: ".encode("latin-1"))
self._telnet.write(f"{SERVER_OWNER_CALLSIGN}\n".encode("latin-1")) self._telnet.write(f"{SERVER_OWNER_CALLSIGN}\n".encode("latin-1"))
connected = True connected = True
@@ -58,16 +74,16 @@ class RBN(SpotProvider):
except Exception: except Exception:
self.status = "Error" self.status = "Error"
logger.exception(f"Exception while connecting to RBN (port {self._port!s}).") logger.exception(f"Exception while connecting to RBN (port {self._port!s}).")
sleep(5) self._stop_event.wait(timeout=5)
self.status = "Waiting for Data" self.status = "Waiting for Data"
while connected and self._running: while connected and not self._stop_event.is_set():
try: try:
# Check new telnet info against regular expression # Check new telnet info against regular expression
telnet_output = self._telnet.read_until("\n".encode("latin-1")) telnet_output = self._telnet.read_until("\n".encode("latin-1"))
match = self._LINE_PATTERN.match(telnet_output.decode("latin-1")) match = self._LINE_PATTERN.match(telnet_output.decode("latin-1"))
if match: if match:
spot_time = datetime.strptime(match.group(5), "%H%MZ") spot_time = datetime.strptime(match.group(5), "%H%MZ").replace(tzinfo=pytz.UTC)
spot_datetime = datetime.combine( spot_datetime = datetime.combine(
datetime.now(pytz.UTC).date(), datetime.now(pytz.UTC).date(),
spot_time.time(), spot_time.time(),
@@ -91,19 +107,19 @@ class RBN(SpotProvider):
except EOFError: except EOFError:
connected = False connected = False
if self._running: if not self._stop_event.is_set():
self.status = "Restarting" self.status = "Restarting"
logger.warning(f"Disconnected from RBN provider (port {self._port!s}). Reconnecting...") logger.warning(f"Disconnected from RBN provider (port {self._port!s}). Reconnecting...")
sleep(5) self._stop_event.wait(timeout=5)
else: else:
logger.info(f"RBN provider (port {self._port!s}) shutting down...") logger.info(f"RBN provider (port {self._port!s}) shutting down...")
self.status = "Shutting down" self.status = "Shutting down"
except Exception: except Exception:
connected = False connected = False
if self._running: if not self._stop_event.is_set():
self.status = "Error" self.status = "Error"
logger.exception(f"Exception in RBN provider (port {self._port!s})") logger.exception(f"Exception in RBN provider (port {self._port!s})")
sleep(5) self._stop_event.wait(timeout=5)
else: else:
logger.info(f"RBN provider (port {self._port!s}) shutting down...") logger.info(f"RBN provider (port {self._port!s}) shutting down...")
self.status = "Shutting down" self.status = "Shutting down"
+10 -9
View File
@@ -1,12 +1,13 @@
import logging import logging
from datetime import datetime from datetime import datetime
from typing import ClassVar
import requests import requests
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.enums import Mode, SIGRefType from core.enums import ActivityName, ActivityRefType, Mode
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -24,7 +25,7 @@ class SOTA(HTTPSpotProvider):
SPOTS_URL = "https://api-db2.sota.org.uk/api/spots/60/all/all" SPOTS_URL = "https://api-db2.sota.org.uk/api/spots/60/all/all"
SUBMIT_URL = "https://api-db2.sota.org.uk/api/spots" SUBMIT_URL = "https://api-db2.sota.org.uk/api/spots"
VALID_MODES = ["AM", "CW", "Data", "DV", "FM", "SSB"] VALID_MODES: ClassVar[list[str]] = ["AM", "CW", "Data", "DV", "FM", "SSB"]
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__("SOTA", provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC) super().__init__("SOTA", provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
@@ -56,16 +57,16 @@ class SOTA(HTTPSpotProvider):
# Seen SOTA spots with no frequency! # Seen SOTA spots with no frequency!
mode=Mode.from_name(source_spot["mode"].upper()), mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["comments"], comment=source_spot["comments"],
sig="SOTA", sig=ActivityName.SOTA,
sig_refs=[ sig_refs=[
SIGRef( ActivityRef(
id=source_spot["summitCode"], id=source_spot["summitCode"],
sig="SOTA", sig=ActivityName.SOTA,
name=source_spot["summitName"], name=source_spot["summitName"],
latitude=source_spot["latitude"], latitude=source_spot["latitude"],
longitude=source_spot["longitude"], longitude=source_spot["longitude"],
activation_score=source_spot["points"], activation_score=source_spot["points"],
ref_type=SIGRefType.SUMMIT, ref_type=ActivityRefType.SUMMIT,
) )
], ],
dx_latitude=source_spot["latitude"], dx_latitude=source_spot["latitude"],
@@ -82,8 +83,8 @@ class SOTA(HTTPSpotProvider):
logger.warning("Timeout when accessing SOTA spots API.") logger.warning("Timeout when accessing SOTA spots API.")
return new_spots return new_spots
def can_submit_spot(self, sig): def can_submit_spot(self, activity):
return sig == "SOTA" return activity == ActivityName.SOTA
def submit_spot(self, spot, credentials): def submit_spot(self, spot, credentials):
# TODO test this method works # TODO test this method works
+2 -2
View File
@@ -60,8 +60,8 @@ class SpotProvider:
raise NotImplementedError("Subclasses must implement this method") raise NotImplementedError("Subclasses must implement this method")
def can_submit_spot(self, sig): def can_submit_spot(self, activity):
"""Return True if this provider supports submitting spots upstream for the given SIG.""" """Return True if this provider supports submitting spots upstream for the given activity."""
return False return False
+1 -1
View File
@@ -42,7 +42,7 @@ class SSESpotProvider(SpotProvider):
logger.exception(f"Exception closing SSE connection for {self.name} during stop()") logger.exception(f"Exception closing SSE connection for {self.name} during stop()")
if self._thread: if self._thread:
self._thread.join(timeout=15) self._thread.join(timeout=5)
if self._thread.is_alive(): if self._thread.is_alive():
logger.warning(f"{self.name} SSE worker thread did not exit on time and will be killed.") logger.warning(f"{self.name} SSE worker thread did not exit on time and will be killed.")
+11 -10
View File
@@ -1,11 +1,12 @@
import logging import logging
from datetime import datetime from datetime import datetime
from typing import ClassVar
import requests import requests
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.enums import LocationSourceForSpot, Mode, SIGRefType from core.enums import ActivityName, ActivityRefType, LocationSourceForSpot, Mode
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -18,7 +19,7 @@ class Tiles(HTTPSpotProvider):
POLL_INTERVAL_SEC = 120 POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/spots?active_hours=24" SPOTS_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/spots?active_hours=24"
SUBMIT_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/self-spot" SUBMIT_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/self-spot"
VALID_MODES = [ VALID_MODES: ClassVar[list[str]] = [
"SSB", "SSB",
"CW", "CW",
"FT8", "FT8",
@@ -58,17 +59,17 @@ class Tiles(HTTPSpotProvider):
freq=freq, freq=freq,
mode=Mode.from_name(source_spot["mode"].upper()), mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["notes"], comment=source_spot["notes"],
sig="Tiles", sig=ActivityName.TILES,
# Tiles spots can include POTA & SOTA references, but ignore those on the basis that we will get them separately from the POTA/SOTA providers anyway. # Tiles spots can include POTA & SOTA references, but ignore those on the basis that we will get them separately from the POTA/SOTA providers anyway.
# Just take the grid reference itself as the single Tiles SIG reference. # Just take the grid reference itself as the single Tiles activity reference.
sig_refs=[ sig_refs=[
SIGRef( ActivityRef(
id=source_spot["maidenhead_grid"], id=source_spot["maidenhead_grid"],
sig="Tiles", sig=ActivityName.TILES,
name=source_spot["maidenhead_grid"], name=source_spot["maidenhead_grid"],
latitude=source_spot["latitude"], latitude=source_spot["latitude"],
longitude=source_spot["longitude"], longitude=source_spot["longitude"],
ref_type=SIGRefType.GRID ref_type=ActivityRefType.GRID
) )
], ],
time=datetime.fromisoformat(source_spot["created_at"].replace("Z", "+00:00")).timestamp(), time=datetime.fromisoformat(source_spot["created_at"].replace("Z", "+00:00")).timestamp(),
@@ -83,8 +84,8 @@ class Tiles(HTTPSpotProvider):
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, sig): def can_submit_spot(self, activity):
return sig == "Tiles" return activity == ActivityName.TILES
def submit_spot(self, spot, credentials): def submit_spot(self, spot, credentials):
# Tiles on the air currently only supports *self* spots # Tiles on the air currently only supports *self* spots
+4 -4
View File
@@ -3,8 +3,8 @@ from datetime import datetime
import pytz import pytz
from core.enums import SIGRefType from core.enums import ActivityName, ActivityRefType
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -34,8 +34,8 @@ class Towers(HTTPSpotProvider):
dx_call=source_spot["call"].upper(), dx_call=source_spot["call"].upper(),
freq=likely_freq, freq=likely_freq,
comment=source_spot["comment"], comment=source_spot["comment"],
sig="Towers", sig=ActivityName.TOWERS,
sig_refs=[SIGRef(id=source_spot["ref"], sig="Towers", ref_type=SIGRefType.TOWER)], sig_refs=[ActivityRef(id=source_spot["ref"], sig=ActivityName.TOWERS, ref_type=ActivityRefType.TOWER)],
time=datetime.strptime(response_json["updated"][:10] + source_spot["time"], "%Y-%m-%d%H:%M") time=datetime.strptime(response_json["updated"][:10] + source_spot["time"], "%Y-%m-%d%H:%M")
.replace(tzinfo=pytz.utc) .replace(tzinfo=pytz.utc)
.timestamp(), .timestamp(),
+2 -2
View File
@@ -42,7 +42,7 @@ class UKPacketNet(HTTPSpotProvider):
) )
comment = ( comment = (
f"{comment} {listed_port['baud']!s} baud" f"{comment} {listed_port['baud']!s} baud"
if "baud" in listed_port and listed_port["baud"] > 0 if listed_port.get("baud") and listed_port["baud"] > 0
else comment else comment
) )
@@ -50,7 +50,7 @@ class UKPacketNet(HTTPSpotProvider):
# very hacky but a lot of node comments contain their frequency as the first or second # very hacky but a lot of node comments contain their frequency as the first or second
# word of their comment, but not in the proper data structure field. # word of their comment, but not in the proper data structure field.
freq = ( freq = (
listed_port["freq"] if "freq" in listed_port and listed_port["freq"] > 0 else None listed_port["freq"] if listed_port.get("freq") and listed_port["freq"] > 0 else None
) )
if not freq and comment: if not freq and comment:
possible_freq = comment.split(" ")[0].upper().replace("MHZ", "") possible_freq = comment.split(" ")[0].upper().replace("MHZ", "")
+24 -9
View File
@@ -1,7 +1,6 @@
import logging import logging
from datetime import datetime from datetime import datetime
from threading import Thread from threading import Event, Thread
from time import sleep
import pytz import pytz
from websocket import create_connection from websocket import create_connection
@@ -20,22 +19,24 @@ class WebsocketSpotProvider(SpotProvider):
self._url = url self._url = url
self._ws = None self._ws = None
self._thread = None self._thread = None
self._stopped = False self._stop_event = Event()
self._last_event_id = None self._last_event_id = None
def start(self): def start(self):
logger.info(f"Set up websocket connection to {self.name} spot API.") logger.info(f"Set up websocket connection to {self.name} spot API.")
self._stopped = False self._stop_event.clear()
self._thread = Thread(target=self._run, name=f"WebsocketSpotProvider-{self.name}") self._thread = Thread(target=self._run, name=f"WebsocketSpotProvider-{self.name}")
self._thread.daemon = True self._thread.daemon = True
self._thread.start() self._thread.start()
def stop(self): def stop(self):
self._stopped = True self._stop_event.set()
if self._ws: if self._ws:
self._ws.close() self._ws.close()
if self._thread: if self._thread:
self._thread.join() self._thread.join(timeout=5)
if self._thread.is_alive():
logger.warning(f"{self.name} websocket worker thread did not exit on time and will be killed.")
def _on_open(self): def _on_open(self):
self.status = "Waiting for Data" self.status = "Waiting for Data"
@@ -44,14 +45,19 @@ class WebsocketSpotProvider(SpotProvider):
self.status = "Connecting" self.status = "Connecting"
def _run(self): def _run(self):
while not self._stopped: while not self._stop_event.is_set():
try: try:
logger.debug(f"Connecting to {self.name} spot API...") logger.debug(f"Connecting to {self.name} spot API...")
self.status = "Connecting" self.status = "Connecting"
self._ws = create_connection(self._url, header=HTTP_HEADERS) self._ws = create_connection(self._url, header=HTTP_HEADERS)
self.status = "Connected" self.status = "Connected"
# Keep reading from this same connection until it drops or we're asked to stop, rather than
# reconnecting for every message.
while not self._stop_event.is_set():
data = self._ws.recv() data = self._ws.recv()
if data: if not data:
break
try: try:
new_spot = self._ws_message_to_spot(data) new_spot = self._ws_message_to_spot(data)
if new_spot: if new_spot:
@@ -69,7 +75,16 @@ class WebsocketSpotProvider(SpotProvider):
logger.exception(f"Exception in Websocket Spot Provider ({self.name})") logger.exception(f"Exception in Websocket Spot Provider ({self.name})")
else: else:
self.status = "Disconnected" self.status = "Disconnected"
sleep(5) # Wait before trying to reconnect finally:
if self._ws:
try:
self._ws.close()
except Exception:
# No problem, we were getting rid of this object anyway.
logger.debug(f"Exception while closing socket in {self.name}", exc_info=True)
self._ws = None
if not self._stop_event.is_set():
self._stop_event.wait(timeout=5) # Wait before trying to reconnect
def _ws_message_to_spot(self, b): def _ws_message_to_spot(self, b):
"""Convert a WS message received from the API into a spot. The exact message data (in bytes) is provided here so the """Convert a WS message received from the API into a spot. The exact message data (in bytes) is provided here so the
+14 -8
View File
@@ -8,8 +8,8 @@ import pytz
from rss_parser import Parser from rss_parser import Parser
from rss_parser.models.rss import RSS from rss_parser.models.rss import RSS
from core.enums import Mode, SIGRefType from core.enums import ActivityName, ActivityRefType, Mode
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -54,7 +54,7 @@ class WOTA(HTTPSpotProvider):
if len(ref_split) > 1: if len(ref_split) > 1:
ref_name = str(ref_split[1]) ref_name = str(ref_split[1])
except Exception: except Exception:
logger.warning(f"Could not parse WOTA spot title: {source_spot.title}") logger.warning(f"Could not parse WOTA spot title: {source_spot.title}", exc_info=True)
# Pick apart the description # Pick apart the description
freq_hz = None freq_hz = None
@@ -75,7 +75,9 @@ class WOTA(HTTPSpotProvider):
if len(desc_split) > 2: if len(desc_split) > 2:
spotter = desc_split[2].replace("Spotted by ", "").replace(".", "").upper().strip() spotter = desc_split[2].replace("Spotted by ", "").replace(".", "").upper().strip()
except Exception: except Exception:
logger.warning(f"Could not parse WOTA spot description: {source_spot.description}") logger.warning(
f"Could not parse WOTA spot description: {source_spot.description}", exc_info=True
)
time = datetime.strptime(source_spot.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone( time = datetime.strptime(source_spot.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(
pytz.UTC pytz.UTC
@@ -90,8 +92,12 @@ class WOTA(HTTPSpotProvider):
freq=freq_hz, freq=freq_hz,
mode=Mode.from_name(mode), mode=Mode.from_name(mode),
comment=comment, comment=comment,
sig="WOTA", sig=ActivityName.WOTA,
sig_refs=[SIGRef(id=ref, sig="WOTA", name=ref_name, ref_type=SIGRefType.SUMMIT)] if ref else [], sig_refs=(
[ActivityRef(id=ref, sig=ActivityName.WOTA, name=ref_name, ref_type=ActivityRefType.SUMMIT)]
if ref
else []
),
time=time.timestamp(), time=time.timestamp(),
) )
@@ -104,8 +110,8 @@ class WOTA(HTTPSpotProvider):
return new_spots return new_spots
def can_submit_spot(self, sig): def can_submit_spot(self, activity):
return sig == "WOTA" return activity == ActivityName.WOTA
def submit_spot(self, spot, credentials): def submit_spot(self, spot, credentials):
# TODO Ask M5TEA if he's happy to share how this is done from his app # TODO Ask M5TEA if he's happy to share how this is done from his app
+10 -10
View File
@@ -1,8 +1,8 @@
import json import json
from datetime import datetime from datetime import datetime
from core.enums import Mode, SIGRefType from core.enums import ActivityName, ActivityRefType, Mode
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from data.spot import Spot from data.spot import Spot
from providers.spot.sse_spot_provider import SSESpotProvider from providers.spot.sse_spot_provider import SSESpotProvider
@@ -21,24 +21,24 @@ class WWBOTA(SSESpotProvider):
# n-fer activations. # n-fer activations.
refs = [] refs = []
for ref in source_spot["references"]: for ref in source_spot["references"]:
sigref = SIGRef( activity_ref = ActivityRef(
id=ref["reference"], id=ref["reference"],
sig="WWBOTA", sig=ActivityName.WWBOTA,
name=ref["name"], name=ref["name"],
latitude=ref["lat"], latitude=ref["lat"],
longitude=ref["long"], longitude=ref["long"],
ref_type=SIGRefType.BUNKER, ref_type=ActivityRefType.BUNKER,
) )
refs.append(sigref) refs.append(activity_ref)
spot = Spot( spot = Spot(
source=self.name, source=self.name,
dx_call=source_spot["call"].upper(), dx_call=source_spot["call"].upper(),
de_call=source_spot["spotter"].upper(), de_call=source_spot["spotter"].upper(),
freq=float(source_spot["freq"]) * 1000000, freq=float(source_spot["freq"]) * 1000000,
mode=Mode.from_name(source_spot["mode"].upper()) if "mode" in source_spot else None, mode=Mode.from_name(source_spot["mode"].upper()) if source_spot.get("mode") else None,
comment=source_spot["comment"], comment=source_spot["comment"],
sig="WWBOTA", sig=ActivityName.WWBOTA,
sig_refs=refs, sig_refs=refs,
time=datetime.fromisoformat(source_spot["time"].replace("Z", "+00:00")).timestamp(), time=datetime.fromisoformat(source_spot["time"].replace("Z", "+00:00")).timestamp(),
# WWBOTA spots can contain multiple references for bunkers being activated simultaneously. For # WWBOTA spots can contain multiple references for bunkers being activated simultaneously. For
@@ -52,8 +52,8 @@ class WWBOTA(SSESpotProvider):
# WWBOTA does support a special "Test" spot type, we need to avoid adding that. # WWBOTA does support a special "Test" spot type, we need to avoid adding that.
return spot if source_spot["type"] != "Test" else None return spot if source_spot["type"] != "Test" else None
def can_submit_spot(self, sig): def can_submit_spot(self, activity):
return sig == "WWBOTA" return activity == ActivityName.WWBOTA
def submit_spot(self, spot, credentials): def submit_spot(self, spot, credentials):
# TODO: Implement. WWBOTA API docs cover this: https://api.wwbota.org/#tag/Spots/operation/create_spot_spots__post # TODO: Implement. WWBOTA API docs cover this: https://api.wwbota.org/#tag/Spots/operation/create_spot_spots__post
+8 -8
View File
@@ -2,8 +2,8 @@ from datetime import datetime
import pytz import pytz
from core.enums import Mode, SIGRefType from core.enums import ActivityName, ActivityRefType, Mode
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -30,15 +30,15 @@ class WWFF(HTTPSpotProvider):
freq=float(source_spot["frequency_khz"]) * 1000, freq=float(source_spot["frequency_khz"]) * 1000,
mode=Mode.from_name(source_spot["mode"].upper()), mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["remarks"], comment=source_spot["remarks"],
sig="WWFF", sig=ActivityName.WWFF,
sig_refs=[ sig_refs=[
SIGRef( ActivityRef(
id=source_spot["reference"], id=source_spot["reference"],
sig="WWFF", sig=ActivityName.WWFF,
name=source_spot["reference_name"], name=source_spot["reference_name"],
latitude=source_spot["latitude"], latitude=source_spot["latitude"],
longitude=source_spot["longitude"], longitude=source_spot["longitude"],
ref_type=SIGRefType.PARK ref_type=ActivityRefType.PARK
) )
], ],
time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC).timestamp(), time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC).timestamp(),
@@ -51,8 +51,8 @@ class WWFF(HTTPSpotProvider):
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, sig): def can_submit_spot(self, activity):
return sig == "WWFF" return activity == ActivityName.WWFF
def submit_spot(self, spot, credentials): def submit_spot(self, spot, credentials):
# TODO: Implement. Spotting to WWFF should be possible, need to look up the Spotline docs or copy approach from # TODO: Implement. Spotting to WWFF should be possible, need to look up the Spotline docs or copy approach from
+13 -14
View File
@@ -4,43 +4,42 @@ from datetime import datetime
import pytz import pytz
from core.enums import Mode from core.enums import Mode
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from data.spot import Spot from data.spot import Spot
from providers.spot.websocket_spot_provider import WebsocketSpotProvider from providers.spot.websocket_spot_provider import WebsocketSpotProvider
class XOTA(WebsocketSpotProvider): class XOTA(WebsocketSpotProvider):
"""Spot provider for servers based on the "xOTA" software at https://github.com/nischu/xOTA/ """Spot provider for servers based on the "xOTA" software at https://github.com/nischu/xOTA/
The provider typically doesn't give us a lat/lon or SIG explicitly, so our own config provides a SIG which we can The provider typically doesn't give us a lat/lon or activity explicitly, so our own config provides an activity
then use for lookups. This functionality is implemented for Toilets on the Air events, of which there are which we can then use for lookups. This functionality is implemented for Toilets on the Air events, of which
several - so a plain lookup of a "TOTA reference" doesn't make sense, it depends on which TOTA, which is why we also there are several - so a plain lookup of a "TOTA reference" doesn't make sense, it depends on which TOTA, which
provide a sig_ref_prefix in our config. This is applied to the reference ID, so e.g. "T-01" at C3 might become is why we also provide a sig_ref_prefix in our config. This is applied to the reference ID, so e.g. "T-01" at C3
"C3 T-01". This allows us to provide location lookups for TOTA at several conferences.""" might become "C3 T-01". This allows us to provide location lookups for TOTA at several conferences."""
LOCATION_DATA = {} ACTIVITY = None
SIG = None
def __init__(self, provider_config): def __init__(self, provider_config):
name = provider_config.get("name", "xOTA") name = provider_config.get("name", "xOTA")
super().__init__(name, provider_config, provider_config["url"]) super().__init__(name, provider_config, provider_config["url"])
self.SIG = str(provider_config["sig"]) if "sig" in provider_config else None self.ACTIVITY = str(provider_config["sig"]) if "sig" in provider_config else None
self._sig_ref_prefix = str(provider_config["sig_ref_prefix"]) if "sig_ref_prefix" in provider_config else "" self._activity_ref_prefix = str(provider_config["sig_ref_prefix"]) if "sig_ref_prefix" in provider_config else ""
def _ws_message_to_spot(self, b): def _ws_message_to_spot(self, b):
string = b.decode("utf-8") string = b.decode("utf-8")
source_spot = json.loads(string) source_spot = json.loads(string)
ref_id = f"{self._sig_ref_prefix} {source_spot['reference']['title']}" ref_id = f"{self._activity_ref_prefix} {source_spot['reference']['title']}"
spot = Spot( spot = Spot(
source=self.name, source=self.name,
source_id=source_spot["id"], source_id=source_spot["id"],
dx_call=source_spot["stationCallSign"].upper(), dx_call=source_spot["stationCallSign"].upper(),
freq=float(source_spot["freq"]) * 1000, freq=float(source_spot["freq"]) * 1000,
mode=Mode.from_name(source_spot["mode"].upper()), mode=Mode.from_name(source_spot["mode"].upper()),
sig=self.SIG, sig=self.ACTIVITY,
sig_refs=[ sig_refs=[
SIGRef( ActivityRef(
id=ref_id, id=ref_id,
sig=self.SIG or "", sig=self.ACTIVITY or "",
url=source_spot["reference"]["website"], url=source_spot["reference"]["website"],
) )
], ],
+7 -7
View File
@@ -2,8 +2,8 @@ from datetime import datetime
import pytz import pytz
from core.enums import Mode from core.enums import ActivityName, Mode
from data.sig_ref import SIGRef from data.activity_ref import ActivityRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -35,11 +35,11 @@ class ZLOTA(HTTPSpotProvider):
freq=freq_hz, freq=freq_hz,
mode=Mode.from_name(source_spot["mode"].upper().strip()), mode=Mode.from_name(source_spot["mode"].upper().strip()),
comment=source_spot["comments"], comment=source_spot["comments"],
sig="ZLOTA", sig=ActivityName.ZLOTA,
sig_refs=[ sig_refs=[
SIGRef( ActivityRef(
id=source_spot["reference"], id=source_spot["reference"],
sig="ZLOTA", sig=ActivityName.ZLOTA,
name=source_spot["name"], name=source_spot["name"],
) )
], ],
@@ -51,8 +51,8 @@ class ZLOTA(HTTPSpotProvider):
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, sig): def can_submit_spot(self, activity):
return sig == "ZLOTA" return activity == ActivityName.ZLOTA
def submit_spot(self, spot, credentials): def submit_spot(self, spot, credentials):
# TODO: Implement. Spotting to ZLOTA is supported via POST, see https://ontheair.nz/api # TODO: Implement. Spotting to ZLOTA is supported via POST, see https://ontheair.nz/api
@@ -29,11 +29,15 @@ class FileDownloadStaticDataProvider(StaticDataProvider):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between # Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
# subsequent polls, so start() returns immediately and the application can continue starting. # subsequent polls, so start() returns immediately and the application can continue starting.
logger.info(f"Set up query of {self.name} static reference data every {self._poll_interval!s} days.") logger.info(f"Set up query of {self.name} static reference data every {self._poll_interval!s} days.")
self._thread = Thread(target=self._run, name=f"FileDownloadStaticDataProvider-{self.name}") self._thread = Thread(target=self._run, name=f"FileDownloadStaticDataProvider-{self.name}", daemon=True)
self._thread.start() self._thread.start()
def stop(self): def stop(self):
self._stop_event.set() self._stop_event.set()
if self._thread:
self._thread.join(timeout=12)
if self._thread.is_alive():
logger.warning(f"{self.name} static data worker thread did not exit on time and will be killed.")
def _run(self): def _run(self):
while True: while True:

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