mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-09-20 06:17:41 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1c9bcd5ae | ||
|
|
a24e03028b | ||
|
|
54d779f036 | ||
|
|
7bdc26c926 | ||
|
|
eea045e4b8 | ||
|
|
c2c3981cda | ||
|
|
1a1743d11c | ||
|
|
40033d122e | ||
|
|
e5caf7353d | ||
|
|
ab79e7e01c | ||
|
|
75905c5ef7 | ||
|
|
0ad0d25c70 | ||
|
|
51798fbc81 | ||
|
|
cea90695ba | ||
|
|
bf9b4ac68c | ||
|
|
81cd686a00 |
@@ -3,6 +3,8 @@
|
||||
Spothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open
|
||||
JSON API as well as a website to browse the data, and its own telnet server for integration with desktop loggers.
|
||||
|
||||
A "main" or example server runs at [https://spothole.app](https://spothole.app/) in case you'd like to try it out.
|
||||
|
||||

|
||||
|
||||
While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outdoor
|
||||
@@ -20,6 +22,8 @@ SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, LLOTA, WWTOTA
|
||||
Repeater Network, NG3K, and any site based on the xOTA software by nischu. It also integrates with QRZ.com and HamQTH,
|
||||
retrieves solar data from various sources, provides information about upcoming contests, and more.
|
||||
|
||||
You can read more about Spothole on [the "About" page of the main server instance](https://spothole.app/about).
|
||||
|
||||

|
||||
|
||||

|
||||
@@ -48,7 +52,9 @@ Spothole server, or to modify it in any way. More details can be found in the fo
|
||||
|
||||
## Thanks
|
||||
|
||||
As well as being my work, I have also gratefully received feature patches from Steven, M1SDH.
|
||||
[Spothole's "About" page](https://spothole.app/about) contains the full details of the thanks I owe to the various
|
||||
programme teams, software library providers, bug fixers etc. The extra detail below contains the extra detail of
|
||||
specific files in this repository which are not all my own work and may be subject to other licences.
|
||||
|
||||
The project contains GeoJSON files for CQ and ITU zones, in the `/datafiles/` directory. These are MIT-licenced and, to
|
||||
my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet. `/datafiles` also contains a
|
||||
@@ -60,26 +66,9 @@ conference centres.
|
||||
The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the
|
||||
`/static/img/flags/` directory.
|
||||
|
||||
The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries.
|
||||
This project would not have been possible without these libraries, so many thanks to their developers.
|
||||
|
||||
### Third Party Libraries
|
||||
|
||||
A number of third-party libraries are self-hosted in the `/static/vendor/` directory. These files are subject to their
|
||||
own licences and are not covered by the overall licence declared in the `LICENSE` file.
|
||||
|
||||
A number of third-party libraries are self-hosted in the `/webassets/vendor/` directory. These files are subject to
|
||||
their own licences and are not covered by the overall licence declared in the `LICENSE` file.
|
||||
The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries
|
||||
which are self-hosted in the `/static/vendor/` directory. This project would not have been possible without these
|
||||
libraries, so many thanks to their developers. These libraries are subject to their own licences and are not covered by
|
||||
the overall licence declared in Spothole's `LICENSE` file.
|
||||
|
||||
Sound effects are CC0-licenced and sourced from [Pixabay](https://pixabay.com).
|
||||
|
||||
Particular thanks go to country-files.com for providing country lookup data for amateur radio, to K0SWE
|
||||
for [this JSON-formatted DXCC data](https://github.com/k0swe/dxcc-json/), and to the developers of `pyhamtools` for
|
||||
making it easy to use country-files.com data as well as QRZ.com and Clublog lookup.
|
||||
|
||||
Amateur radio clusters, outdoor programmes, propagation data providers etc. are almost all volunteer-run services that
|
||||
make no or little profit, and are done for the love of amateur radio. Services like Spothole, which build on top of
|
||||
them, are truly standing on the shoulders of giants. None of this would have been possible without the hard work and
|
||||
dedication of many other people within the amaetur radio community.
|
||||
|
||||
The project's name was suggested by Harm, DK4HAA. Thanks!
|
||||
|
||||
+6
-6
@@ -128,12 +128,12 @@ spot_providers:
|
||||
name: "C3 TOTA"
|
||||
enabled: false
|
||||
url: "wss://39c3.totawatch.de/api/spot/live"
|
||||
# For the "XOTA" provider, a SIG must be set menually here because xOTA is a generic backend for xOTA
|
||||
# For the "XOTA" provider, an activity must be set manually here because xOTA is a generic backend for xOTA
|
||||
# programmes and so different URLs potentially provide different programmes.
|
||||
sig: "Toilets"
|
||||
# For Toilets on the Air, we prefix the SIG references (T-01 etc) with some characters that define the conference:
|
||||
# C3, EH or HOPE - so we can look up the correct locations in our database, because each conference starts from T-01
|
||||
# but refers to a toilet in a different building (or continent!)
|
||||
# For Toilets on the Air, we prefix the activity references (T-01 etc) with some characters that define the
|
||||
# conference: C3, EH or HOPE - so we can look up the correct locations in our database, because each conference
|
||||
# starts from T-01 but refers to a toilet in a different building (or continent!)
|
||||
sig_ref_prefix: "C3"
|
||||
|
||||
- class: "XOTA"
|
||||
@@ -216,8 +216,8 @@ static_data_providers:
|
||||
enabled: true
|
||||
|
||||
|
||||
# SIG reference data providers to use. These allow Spothole to download, for example, the WWFF directory that maps WWFF
|
||||
# park IDs to their name and location.
|
||||
# Activity reference data providers to use. These allow Spothole to download, for example, the WWFF directory that
|
||||
# maps WWFF park IDs to their name and location.
|
||||
sig_ref_data_providers:
|
||||
- class: "POTA"
|
||||
enabled: true
|
||||
|
||||
@@ -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
|
||||
@@ -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)})"
|
||||
+1
-391
@@ -1,403 +1,13 @@
|
||||
from core.config import SERVER_OWNER_CALLSIGN
|
||||
from core.enums import SIGRefType, SIGType
|
||||
from data.band import Band
|
||||
from data.sig import SIG
|
||||
|
||||
# General software
|
||||
SOFTWARE_VERSION = "2.1.1"
|
||||
SOFTWARE_VERSION = "2.2-pre"
|
||||
|
||||
# HTTP headers used for spot providers that use HTTP
|
||||
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
|
||||
HAMQTH_PRG = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}".replace(" ", "_")
|
||||
|
||||
# Special Interest Groups
|
||||
SIGS = [
|
||||
SIG(
|
||||
name="AMSAT",
|
||||
comment_names=[],
|
||||
description="Amateur Radio Satellites",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
icon="fa-satellite",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="EME",
|
||||
comment_names=[],
|
||||
description="Moonbounce",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
icon="fa-moon",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="POTA",
|
||||
comment_names=["POTA"],
|
||||
description="Parks on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.PARK,
|
||||
ref_regex=r"[A-Z]{2}\-\d{4,5}|K\-TEST",
|
||||
icon="fa-tree",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="SOTA",
|
||||
comment_names=["SOTA"],
|
||||
description="Summits on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
|
||||
icon="fa-mountain-sun",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="WWFF",
|
||||
comment_names=["WWFF"],
|
||||
description="World Wide Flora & Fauna",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.PARK,
|
||||
ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}",
|
||||
icon="fa-seedling",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="GMA",
|
||||
comment_names=["GMA"],
|
||||
description="Global Mountain Activity",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
|
||||
icon="fa-person-hiking",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="WWBOTA",
|
||||
comment_names=["WWBOTA", "BOTA"],
|
||||
description="Worldwide Bunkers on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.BUNKER,
|
||||
ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}",
|
||||
icon="fa-radiation",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="HEMA",
|
||||
comment_names=["HEMA"],
|
||||
description="HuMPs Excluding Marilyns Award",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}",
|
||||
icon="fa-mound",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="IOTA",
|
||||
comment_names=["IOTA"],
|
||||
description="Islands on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.ISLAND,
|
||||
ref_regex=r"[A-Z]{2}\-\d{3}",
|
||||
icon="fa-book-atlas",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="GMA Islands",
|
||||
comment_names=[],
|
||||
description="Global Mountain Activity - Islands",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.ISLAND,
|
||||
ref_regex=r"(([A-Z]{2}\-\d{3})|([A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}))",
|
||||
icon="fa-person-hiking",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="ARLHS",
|
||||
comment_names=["ARLHS"],
|
||||
description="Amateur Radio Lighthouse Society",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.LIGHTHOUSE,
|
||||
ref_regex=r"[A-Z]{3}[\- ]\d{3,4}",
|
||||
icon="fa-house-flood-water",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="ILLW",
|
||||
comment_names=["ILLW"],
|
||||
description="International Lighthouse & Lightship Weekend",
|
||||
sig_type=SIGType.EVENT,
|
||||
ref_type=SIGRefType.LIGHTHOUSE,
|
||||
ref_regex=r"[A-Z]{2}\d{4}",
|
||||
icon="fa-house-flood-water",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="MOTA",
|
||||
comment_names=["MOTA"],
|
||||
description="Mills on the Air",
|
||||
sig_type=SIGType.EVENT,
|
||||
ref_type=SIGRefType.MILL,
|
||||
ref_regex=r"X\d{4,6}",
|
||||
icon="fa-fan",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="SIOTA",
|
||||
comment_names=["SIOTA"],
|
||||
description="Silos on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.SILO,
|
||||
ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d",
|
||||
icon="fa-wheat-awn",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="WCA",
|
||||
comment_names=["WCA"],
|
||||
description="World Castles Award",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.CASTLE,
|
||||
ref_regex=r"[A-Z0-9]{1,3}\-\d{5}",
|
||||
icon="fa-chess-rook",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="ZLOTA",
|
||||
comment_names=["ZLOTA"],
|
||||
description="New Zealand on the Air",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=None,
|
||||
ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}",
|
||||
icon="fa-kiwi-bird",
|
||||
region_flag="🇳🇿",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="WOTA",
|
||||
comment_names=["WOTA"],
|
||||
description="Wainwrights on the Air",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
ref_regex=r"[A-Z]{3}-[0-9]{2}",
|
||||
icon="fa-w",
|
||||
region_flag="🇬🇧",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="BOTA",
|
||||
comment_names=[],
|
||||
description="Beaches on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.BEACH,
|
||||
icon="fa-umbrella-beach",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="KRMNPA",
|
||||
comment_names=["KRMNPA"],
|
||||
description="Keith Roget Memorial National Parks Award",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.PARK,
|
||||
ref_regex=r"VKFF\-\d{4}",
|
||||
icon="fa-earth-oceania",
|
||||
region_flag="🇦🇺",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="SANPCPA",
|
||||
comment_names=["SANPCPA"],
|
||||
description="South Australian National Parks and Conservation Parks Award",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.PARK,
|
||||
ref_regex=r"VKFF\-\d{4}",
|
||||
icon="fa-earth-oceania",
|
||||
region_flag="🇦🇺",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="LLOTA",
|
||||
comment_names=["LLOTA"],
|
||||
description="Lagos y Lagunas on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.LAKE,
|
||||
ref_regex=r"LL[A-Z]{2}\-\d{4}",
|
||||
icon="fa-water",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="Towers",
|
||||
comment_names=["TOTA"],
|
||||
description="Towers on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.TOWER,
|
||||
ref_regex=r"[A-Z]{2,3}R\-\d{4}",
|
||||
icon="fa-tower-observation",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="Tiles",
|
||||
comment_names=[],
|
||||
description="Tiles on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.GRID,
|
||||
ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}",
|
||||
icon="fa-square",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="WAB",
|
||||
comment_names=["WAB"],
|
||||
description="Worked All Britain",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.GRID,
|
||||
ref_regex=r"[A-Z]{1,2}[0-9]{2}",
|
||||
icon="fa-table-cells-large",
|
||||
region_flag="🇬🇧",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="WAI",
|
||||
comment_names=["WAI"],
|
||||
description="Worked All Ireland",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.GRID,
|
||||
ref_regex=r"[A-Z][0-9]{2}",
|
||||
icon="fa-table-cells-large",
|
||||
region_flag="🇮🇪",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="DMF",
|
||||
comment_names=["DMF"],
|
||||
description="Diplôme des Moulins de France",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.MILL,
|
||||
icon="fa-fan",
|
||||
region_flag="🇫🇷",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="DME",
|
||||
comment_names=["DME"],
|
||||
description="Diploma Municipios de España",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.TOWN,
|
||||
ref_regex=r"DME[\- ]\d{3,5}",
|
||||
icon="fa-building",
|
||||
region_flag="🇪🇸",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="FEA",
|
||||
comment_names=["FEA"],
|
||||
description="Diploma Faros de España",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.LIGHTHOUSE,
|
||||
# FEA references are technically [DE]\-\d{4}(\.\d)? but spotters always seem to miss out the D- or E-
|
||||
# prefix and just use FEA-1234 or FEA 1234, so allow for that. The FEA sigref data provider adds both
|
||||
# forms to the database.
|
||||
ref_regex=r"([DE]|FEA)[\- ]\d{4}(\.\d)?",
|
||||
icon="fa-house-flood-water",
|
||||
region_flag="🇪🇸",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="DMUE",
|
||||
comment_names=["DMUE"],
|
||||
description="Diploma Museos de España",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.BUILDING,
|
||||
ref_regex=r"MUE[A-Z]{2}-\d{3}",
|
||||
icon="fa-landmark",
|
||||
region_flag="🇪🇸",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="DMVE",
|
||||
comment_names=["DMVE"],
|
||||
description="Diploma Monumentos y Vestigios de España",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.BUILDING,
|
||||
ref_regex=r"MV[A-Z]{1,2}-\d{4}",
|
||||
icon="fa-monument",
|
||||
region_flag="🇪🇸",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="DCE",
|
||||
comment_names=["DCE"],
|
||||
description="Diploma Castillos de España",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.CASTLE,
|
||||
ref_regex=r"C[A-Z]{1,2}-\d{3}",
|
||||
icon="fa-chess-rook",
|
||||
region_flag="🇪🇸",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="DEFE",
|
||||
comment_names=["DEFE"],
|
||||
description="Diploma Estaciones de Ferrocarril de España",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.BUILDING,
|
||||
ref_regex=r"EF[A-Z]{1,2}-\d{3}",
|
||||
icon="fa-train",
|
||||
region_flag="🇪🇸",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="DTMBA",
|
||||
comment_names=["DTMBA"],
|
||||
description="Diploma Teatri Musei e Belle Arti",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.BUILDING,
|
||||
ref_regex=r"I-?[0-9]{3,4}\s?[A-Z]{2}",
|
||||
icon="fa-landmark",
|
||||
region_flag="🇮🇹",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="BIWOTA",
|
||||
comment_names=["BIWOTA"],
|
||||
description="British Inland Waterways on the Air",
|
||||
sig_type=SIGType.EVENT,
|
||||
ref_type=SIGRefType.WATERWAY,
|
||||
icon="fa-ship",
|
||||
region_flag="🇬🇧",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="COTA",
|
||||
comment_names=["COTA"],
|
||||
description="Castles on the Air",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.CASTLE,
|
||||
ref_regex=r"[A-Z]{3}\-[0-9]{3,5}",
|
||||
icon="fa-chess-rook",
|
||||
region_flag="🇩🇪",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="PGA",
|
||||
comment_names=["PGA"],
|
||||
description="Polish Gmina Award",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.REGION,
|
||||
ref_regex=r"[A-Z]{2}[0-9]{2}",
|
||||
icon="fa-g",
|
||||
region_flag="🇵🇱",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="Toilets",
|
||||
comment_names=[],
|
||||
description="Toilets on the Air",
|
||||
sig_type=SIGType.EVENT,
|
||||
ref_type=SIGRefType.TOILET,
|
||||
ref_regex=r"T\-[0-9]{2}",
|
||||
icon="fa-toilet",
|
||||
region_flag="🏴☠️",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
]
|
||||
|
||||
# Band definitions
|
||||
BANDS = [
|
||||
Band(name="2200m", start_freq=135700, end_freq=137800),
|
||||
|
||||
@@ -29,7 +29,7 @@ class DataProviders:
|
||||
for entry in config.get("static_data_providers", []):
|
||||
self.static_data_providers.append(create_provider_from_config("providers.staticdata", entry))
|
||||
for entry in config.get("sig_ref_data_providers", []):
|
||||
self.sig_ref_data_providers.append(create_provider_from_config("providers.sigrefdata", entry))
|
||||
self.sig_ref_data_providers.append(create_provider_from_config("providers.activityrefdata", entry))
|
||||
for entry in config.get("callsign_data_providers", []):
|
||||
self.callsign_data_providers.append(create_provider_from_config("providers.callsigndata", entry))
|
||||
|
||||
@@ -54,7 +54,7 @@ class DataProviders:
|
||||
25.0,
|
||||
lambda: self.start_providers(self.solar_condition_providers, "solar condition"),
|
||||
),
|
||||
threading.Timer(30.0, lambda: self.start_providers(self.sig_ref_data_providers, "SIG ref data")),
|
||||
threading.Timer(30.0, lambda: self.start_providers(self.sig_ref_data_providers, "activity ref data")),
|
||||
]
|
||||
for t in self._startup_timers:
|
||||
t.daemon = True
|
||||
|
||||
+13
-13
@@ -15,8 +15,8 @@ CACHE_DIR = "./cache/"
|
||||
|
||||
|
||||
class DataStore:
|
||||
"""Data caching/storage object. Handles storage of spots, alerts, solar conditions, SIG reference data, and callsign
|
||||
lookup data using different caching strategies for each."""
|
||||
"""Data caching/storage object. Handles storage of spots, alerts, solar conditions, activity reference data, and
|
||||
callsign lookup data using different caching strategies for each."""
|
||||
|
||||
def __init__(self):
|
||||
# Constants
|
||||
@@ -34,7 +34,7 @@ class DataStore:
|
||||
self.callsign_data_hamqth = None
|
||||
self.dxcc_data = None
|
||||
self.dxcc_lookup_by_call_regex = []
|
||||
self.sigrefs = None
|
||||
self.activity_refs = None
|
||||
self.status = 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
|
||||
@@ -51,16 +51,16 @@ class DataStore:
|
||||
self.solar_conditions = SingleObjectDataCache(f"{CACHE_DIR}solar", SolarConditions())
|
||||
self.status = SingleObjectDataCache(f"{CACHE_DIR}status", {})
|
||||
|
||||
# Standard disk cache for static reference and SIG ref data. Separate provider threads will repopulate these on
|
||||
# a regular basis but there's no need for a TTL since old data is better than no data.
|
||||
# Standard disk cache for static reference and activity ref data. Separate provider threads will repopulate
|
||||
# these on a regular basis but there's no need for a TTL since old data is better than no data.
|
||||
self.dxcc_data = diskcache.Cache(f"{CACHE_DIR}dxcc_data")
|
||||
self.regenerate_call_regex_to_dxcc_entity_map()
|
||||
|
||||
# For SIG reference data specifically, we need to key on both SIG *and* reference, and trying to do two layers
|
||||
# of dict in diskcache absolutely destroys performance with unpickling huge dicts, so we have an ugly "SIG:ref"
|
||||
# syntax for keys to keep it a single level.
|
||||
self.sigrefs = diskcache.Cache(f"{CACHE_DIR}sigrefs")
|
||||
logger.info(f"Loaded data for {len(self.sigrefs)} SIG references.")
|
||||
# For activity reference data specifically, we need to key on both activity *and* reference, and trying to do
|
||||
# two layers of dict in diskcache absolutely destroys performance with unpickling huge dicts, so we have an
|
||||
# ugly "activity:ref" syntax for keys to keep it a single level.
|
||||
self.activity_refs = diskcache.Cache(f"{CACHE_DIR}activity_refs")
|
||||
logger.info(f"Loaded data for {len(self.activity_refs)} activity references.")
|
||||
|
||||
# Standard disk cache for callsign data. This data does have a TTL to trigger an occasional re-lookup.
|
||||
# Old data *is* better than no data, but we can't have a background thread re-looking-up every callsign
|
||||
@@ -82,8 +82,8 @@ class DataStore:
|
||||
logger.info(f"Loaded data for {len(unique_keys)} callsigns.")
|
||||
|
||||
# Special caches for spots and alerts, which have TTL and write snapshots to disk at an interval. We
|
||||
# specifically load these caches *last* so that any sigref and callsign data is already loaded from disk cache
|
||||
# before the spots and alerts are live in the system.
|
||||
# specifically load these caches *last* so that any activity ref and callsign data is already loaded from disk
|
||||
# cache before the spots and alerts are live in the system.
|
||||
self.spots = LiveDataCache(
|
||||
maxsize=self._MAX_SPOT_COUNT,
|
||||
ttl=MAX_SPOT_AGE,
|
||||
@@ -116,7 +116,7 @@ class DataStore:
|
||||
self.solar_conditions.close()
|
||||
self.status.close()
|
||||
self.dxcc_data.close()
|
||||
self.sigrefs.close()
|
||||
self.activity_refs.close()
|
||||
self.callsign_data_countryfiles.close()
|
||||
self.callsign_data_clublogxml.close()
|
||||
self.callsign_data_clublogapi.close()
|
||||
|
||||
+60
-14
@@ -90,8 +90,62 @@ class LocationSourceForCallsign(str, Enum):
|
||||
DXCC = "DXCC"
|
||||
|
||||
|
||||
class SIGRefType(str, Enum):
|
||||
"""Type of a Special Interest Group reference."""
|
||||
# Definitions of every activity Spothole knows about, keyed by the ActivityName enum. An enum is used for the
|
||||
# names to avoid typos when using literal strings like "POTA" all over the place.
|
||||
class ActivityName(str, Enum):
|
||||
"""Canonical name of every activity. Spothole uses these rather than literals like "POTA" around the code
|
||||
to ensure I don't accidentally introduce typos."""
|
||||
|
||||
CONTEST = "Contest"
|
||||
DXPEDITION = "DXpedition"
|
||||
SATELLITE = "Satellite"
|
||||
EME = "EME"
|
||||
AERONAUTICAL_MOBILE = "/AM"
|
||||
MARITIME_MOBILE = "/MM"
|
||||
QRP = "QRP"
|
||||
POTA = "POTA"
|
||||
SOTA = "SOTA"
|
||||
WWFF = "WWFF"
|
||||
GMA = "GMA"
|
||||
WWBOTA = "WWBOTA"
|
||||
HEMA = "HEMA"
|
||||
IOTA = "IOTA"
|
||||
GMA_ISLANDS = "GMA Islands"
|
||||
ARLHS = "ARLHS"
|
||||
ILLW = "ILLW"
|
||||
MOTA = "MOTA"
|
||||
SIOTA = "SIOTA"
|
||||
WCA = "WCA"
|
||||
ZLOTA = "ZLOTA"
|
||||
WOTA = "WOTA"
|
||||
BOTA = "BOTA"
|
||||
KRMNPA = "KRMNPA"
|
||||
SANPCPA = "SANPCPA"
|
||||
LLOTA = "LLOTA"
|
||||
TOWERS = "Towers"
|
||||
TILES = "Tiles"
|
||||
RADAR_RALLY = "RaDAR Rally"
|
||||
WAB = "WAB"
|
||||
WAI = "WAI"
|
||||
DMF = "DMF"
|
||||
DME = "DME"
|
||||
FEA = "FEA"
|
||||
DMUE = "DMUE"
|
||||
DMVE = "DMVE"
|
||||
DCE = "DCE"
|
||||
DEFE = "DEFE"
|
||||
DTMBA = "DTMBA"
|
||||
BIWOTA = "BIWOTA"
|
||||
COTA = "COTA"
|
||||
PGA = "PGA"
|
||||
TOILETS = "Toilets"
|
||||
|
||||
def __str__(self):
|
||||
return str(self.value)
|
||||
|
||||
|
||||
class ActivityRefType(str, Enum):
|
||||
"""Type of an activity reference."""
|
||||
|
||||
PARK = "PARK"
|
||||
SUMMIT = "SUMMIT"
|
||||
@@ -112,19 +166,11 @@ class SIGRefType(str, Enum):
|
||||
TOILET = "TOILET"
|
||||
|
||||
|
||||
class AlertType(str, Enum):
|
||||
"""Type of an alert."""
|
||||
class ActivityType(str, Enum):
|
||||
"""Type of an activity. Used to group them in the web UI."""
|
||||
|
||||
XOTA = "XOTA"
|
||||
SATELLITE = "SATELLITE"
|
||||
DXPEDITION = "DXPEDITION"
|
||||
CONTEST = "CONTEST"
|
||||
|
||||
|
||||
class SIGType(str, Enum):
|
||||
"""Type of a Special Interest Group. Used to group them in the web UI."""
|
||||
|
||||
WORLDWIDE = "WORLDWIDE"
|
||||
TRADITIONAL = "TRADITIONAL"
|
||||
ADVENTURE = "ADVENTURE"
|
||||
REGIONAL = "REGIONAL"
|
||||
EVENT = "EVENT"
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
import re
|
||||
from math import floor
|
||||
from math import floor, isnan
|
||||
|
||||
from pyproj import Transformer
|
||||
from shapely.geometry import Point, Polygon
|
||||
@@ -156,7 +156,7 @@ def lat_lon_for_grid_sw_corner_plus_size(grid):
|
||||
lat -= 90.0
|
||||
|
||||
# Return None values on maths errors
|
||||
if any(x != x for x in [lat, lon, lat_cell_size, lon_cell_size]):
|
||||
if any(isnan(x) for x in [lat, lon, lat_cell_size, lon_cell_size]):
|
||||
return None, None, None, None
|
||||
|
||||
return lat, lon, lat_cell_size, lon_cell_size
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
|
||||
|
||||
from core.constants import SIGS
|
||||
from core.data_store import DATA_STORE
|
||||
from core.enums import SIGRefType
|
||||
from core.geo_utils import wab_wai_square_to_lat_lon
|
||||
from data.sig_ref import SIGRef
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_sig_ref_info(sig_name, ref_id):
|
||||
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig name and
|
||||
a reference ID (both strings) and returns a SigRef object populated with as much data as we can find. This makes
|
||||
use of SIG ref data in the data store, live lookups from the web, or just automatic calculation depending on which
|
||||
SIG we are getting data for."""
|
||||
|
||||
if sig_name is None or sig_name == "" or ref_id is None or ref_id == "":
|
||||
logger.debug("Failed to look up sig_ref info, sig or ref were not set.")
|
||||
return None
|
||||
|
||||
# Sometimes we allow spaces instead of dashes in references due to common usage that way, but official reference
|
||||
# lists never do, so convert them here.
|
||||
ref_id = ref_id.replace(" ", "-")
|
||||
|
||||
# Prepare the object to be returned
|
||||
sig_ref = SIGRef(sig=sig_name, id=ref_id)
|
||||
|
||||
# We can always get the reference type and the icon from the SIG itself
|
||||
for sig in SIGS:
|
||||
if sig.name.upper() == sig_name.upper():
|
||||
sig_ref.ref_type = sig.ref_type
|
||||
sig_ref.icon = sig.icon
|
||||
|
||||
try:
|
||||
### FUDGES ###
|
||||
#
|
||||
# DME fudge. Our database has leading zeros padding to 5 digits which is the expected format, but not all
|
||||
# activators add leading zeros. We also need to normalise "DME 01234" to "DME-01234" to match what's in our
|
||||
# database.
|
||||
if sig_name.upper() == "DME":
|
||||
match = re.match(r"DME[\- ](\d{3,5})", ref_id, re.IGNORECASE)
|
||||
if match:
|
||||
number = match.group(1)
|
||||
ref_id = f"DME-{number.zfill(5)}"
|
||||
|
||||
# DTMBA spotters sometimes include spaces and dashes, our regex allows them but they must be removed here so we
|
||||
# can look up against the official list which doesn't have them
|
||||
if sig_name.upper() == "DTMBA":
|
||||
ref_id = ref_id.replace("-", "").replace(" ", "")
|
||||
|
||||
### NO DATA SIGS ###
|
||||
#
|
||||
# If the SIG is HEMA or BIWOTA, we have no way to either generate useful data or look it up on a reference list,
|
||||
# so just skip the lookup here.
|
||||
if sig_name.upper() == "HEMA" or sig_name.upper() == "BIWOTA":
|
||||
return sig_ref
|
||||
|
||||
### PROGRAMMATIC DATA GENERATION INSTEAD OF LOOKUPS ###
|
||||
#
|
||||
# If the SIG is Tiles, WAB, WAI or BOTA (Beaches), we don't have anything to look up from the data store, we can
|
||||
# calculate all the information we are going to get directly.
|
||||
if sig_name.upper() == "TILES":
|
||||
# Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same
|
||||
if not sig_ref.name:
|
||||
sig_ref.name = sig_ref.id
|
||||
if not sig_ref.grid:
|
||||
sig_ref.grid = sig_ref.id
|
||||
if sig_ref.grid and (not sig_ref.latitude or not sig_ref.longitude):
|
||||
ll = locator_to_latlong(str(sig_ref.grid))
|
||||
sig_ref.latitude = ll[0]
|
||||
sig_ref.longitude = ll[1]
|
||||
return sig_ref
|
||||
|
||||
elif sig_name.upper() == "WAB" or sig_name.upper() == "WAI":
|
||||
ll = wab_wai_square_to_lat_lon(ref_id)
|
||||
if ll:
|
||||
sig_ref.name = ref_id
|
||||
try:
|
||||
sig_ref.grid = latlong_to_locator(ll[0], ll[1], 6)
|
||||
sig_ref.latitude = ll[0]
|
||||
sig_ref.longitude = ll[1]
|
||||
except Exception:
|
||||
logger.warning("Invalid lat/lon received for WAB/WAI reference")
|
||||
return sig_ref
|
||||
|
||||
elif sig_name.upper() == "BOTA":
|
||||
# For BOTA all we can ever generate is the URL, there is no data file or lookup for lat/longs
|
||||
if not sig_ref.name:
|
||||
sig_ref.name = sig_ref.id
|
||||
if sig_ref.name:
|
||||
sig_ref.url = f"https://www.beachesontheair.com/beaches/{sig_ref.name.lower().replace(' ', '-')}"
|
||||
return sig_ref
|
||||
|
||||
elif sig_name.upper() == "GMA Islands":
|
||||
# GMA Islands is a bit of a mess of GMA and IOTA references. Try looking them both up and see what returns
|
||||
# the best result.
|
||||
iota_lookup = get_sig_ref_info("IOTA", ref_id)
|
||||
gma_lookup = get_sig_ref_info("GMA", ref_id)
|
||||
for key, value in iota_lookup.__dict__.items():
|
||||
if value is not None and sig_ref.__dict__.get(key) is None:
|
||||
sig_ref.__dict__[key] = value
|
||||
for key, value in gma_lookup.__dict__.items():
|
||||
if value is not None and sig_ref.__dict__.get(key) is None:
|
||||
sig_ref.__dict__[key] = value
|
||||
sig_ref.ref_type = SIGRefType.ISLAND
|
||||
return sig_ref
|
||||
|
||||
### ACTUAL LOOKUP ###
|
||||
#
|
||||
# OK, this is something we have to look up. Now check to see if our data store contains reference data and if
|
||||
# so, copy the data into the sig_ref object
|
||||
key = f"{sig_name}:{ref_id}"
|
||||
try:
|
||||
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
|
||||
if lookup_data:
|
||||
for attr, value in lookup_data.__dict__.items():
|
||||
if value is not None and sig_ref.__dict__.get(attr) is None:
|
||||
sig_ref.__dict__[attr] = value
|
||||
|
||||
else:
|
||||
# Maybe a super new reference we don't know about yet, but more likely a typo or a test reference,
|
||||
# just silently ignore it.
|
||||
logger.debug(f"{sig_name} database did not contain data for ref {ref_id}")
|
||||
|
||||
except (ValueError, KeyError):
|
||||
# Catch exceptions due to e.g. old versions of objects in the cache that are no longer compatible,
|
||||
# and remove them from the cache.
|
||||
del DATA_STORE.sigrefs[key]
|
||||
return None
|
||||
|
||||
except Exception:
|
||||
logger.exception(f"Exception when looking up sig_ref info for {sig_name} ref {ref_id}")
|
||||
return sig_ref
|
||||
|
||||
|
||||
def populate_missing_sig_ref_info(sig_ref):
|
||||
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object
|
||||
which must at minimum have a "sig" and an "id". The rest of the object will be populated and returned. Any data
|
||||
currently in the object will be kept, only missing data in the object will be populated if it can be determined."""
|
||||
|
||||
lookup_data = get_sig_ref_info(sig_ref.sig, sig_ref.id)
|
||||
|
||||
if lookup_data:
|
||||
# Copy new sig ref data into existing object where data was previously missing
|
||||
for key, value in lookup_data.__dict__.items():
|
||||
if value is not None and sig_ref.__dict__.get(key) is None:
|
||||
sig_ref.__dict__[key] = value
|
||||
|
||||
return sig_ref
|
||||
@@ -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)})"
|
||||
@@ -7,10 +7,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SingleObjectDataCache:
|
||||
"""Cache for status and solar conditions. This uses DiskCache, but unlike the standard DiskCache users like SIG and
|
||||
callsign lookup handlers, status and solar conditions are persisted as a single object. If we just load the object
|
||||
from DiskCache and modify it, DiskCache doesn't know that it's been updated and needs re-caching, so we provide a
|
||||
store() method that any functions updating the object can call afterwards."""
|
||||
"""Cache for status and solar conditions. This uses DiskCache, but unlike the standard DiskCache users like
|
||||
activity ref and callsign lookup handlers, status and solar conditions are persisted as a single object. If we
|
||||
just load the object from DiskCache and modify it, DiskCache doesn't know that it's been updated and needs
|
||||
re-caching, so we provide a store() method that any functions updating the object can call afterwards."""
|
||||
|
||||
def __init__(self, cache_dir, object_if_empty):
|
||||
"""Initialize a SingleObjectDataCache. Provide the directory to load the cache from and save it to. If the cache
|
||||
|
||||
@@ -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"],
|
||||
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=False,
|
||||
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=False,
|
||||
ref_type=ActivityRefType.ISLAND,
|
||||
ref_regex=r"[A-Z]{2}\-\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="🏴☠️",
|
||||
),
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,24 +1,24 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from core.enums import ActivityRefType
|
||||
|
||||
|
||||
@dataclass
|
||||
class SIGRef:
|
||||
"""Data class that defines a Special Interest Group "info" or reference. As well as the basic reference ID we include a
|
||||
class ActivityRef:
|
||||
"""Data class that defines an Activity "info" or reference. As well as the basic reference ID we include a
|
||||
name and a lookup URL."""
|
||||
|
||||
# SIG that this reference is in, e.g. "POTA".
|
||||
# Activity that this reference is in, e.g. "POTA". Still named "sig" for backwards compatibility with the API.
|
||||
sig: str
|
||||
# Reference ID, e.g. "GB-0001".
|
||||
id: str | None = None
|
||||
# Name of the reference, e.g. "Null Country Park", if known.
|
||||
name: str | None = None
|
||||
# Type of the reference, e.g. "Park", if known.
|
||||
ref_type: SIGRefType | None = None
|
||||
ref_type: ActivityRefType | None = None
|
||||
# URL to look up more information about the reference, if known.
|
||||
url: str | None = None
|
||||
# Icon to use for the reference, derived from the SIG. Chosen from the Font Awesome set.
|
||||
# Icon to use for the reference, derived from the activity. Chosen from the Font Awesome set.
|
||||
icon: str | None = None
|
||||
# Latitude of the reference, in degrees, if known.
|
||||
latitude: float | None = None
|
||||
+17
-23
@@ -6,9 +6,10 @@ from datetime import datetime, timedelta
|
||||
|
||||
import pytz
|
||||
|
||||
from core.activity_lookup_helper import populate_missing_activity_ref_info
|
||||
from core.activity_utils import get_icon_for_activity
|
||||
from core.call_lookup_helper import get_call_info
|
||||
from core.enums import AlertType, Continent
|
||||
from core.sig_lookup_helper import populate_missing_sig_ref_info
|
||||
from core.enums import Continent
|
||||
from core.utils import get_flag_for_dxcc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -54,16 +55,15 @@ class Alert:
|
||||
end_time_iso: str | None = None
|
||||
# Comment made by the alerter, if any
|
||||
comment: str | None = None
|
||||
# The type of alert this is: xOTA, DXpedition, or Contest.
|
||||
alert_type: AlertType | None = None
|
||||
# A URL link to more information, if any
|
||||
url: str | None = None
|
||||
|
||||
# Special Interest Group info
|
||||
# Activity info
|
||||
|
||||
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
|
||||
# Activity (e.g. outdoor activity programme such as POTA). Still named "sig" for API backwards compatibility.
|
||||
sig: str | None = None
|
||||
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
||||
# 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)
|
||||
|
||||
# Timing info
|
||||
@@ -125,15 +125,15 @@ class Alert:
|
||||
if self.dx_dxcc_id and not self.dx_flag:
|
||||
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
|
||||
|
||||
# Fetch SIG data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a reference
|
||||
# in its initial call, we use this code to populate the rest of the data. This includes working out grid refs
|
||||
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
|
||||
# Fetch activity data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a
|
||||
# reference in its initial call, we use this code to populate the rest of the data. This includes working
|
||||
# out grid refs from WAB and WAI, which count as an activity even though there's no real lookup, just maths
|
||||
if self.sig_refs:
|
||||
for sig_ref in self.sig_refs:
|
||||
populate_missing_sig_ref_info(sig_ref)
|
||||
for activity_ref in self.sig_refs:
|
||||
populate_missing_activity_ref_info(activity_ref)
|
||||
|
||||
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
|
||||
# and apply it to the whole spot.
|
||||
# If the spot itself doesn't have an activity yet, but we have at least one activity reference, take that
|
||||
# reference's activity and apply it to the whole spot.
|
||||
if self.sig_refs and self.sig_refs[0] and not self.sig:
|
||||
self.sig = self.sig_refs[0].sig
|
||||
|
||||
@@ -152,16 +152,10 @@ class Alert:
|
||||
if self.dx_calls and not self.dx_names:
|
||||
self.dx_names = [get_call_info(c, credentials).name for c in self.dx_calls]
|
||||
|
||||
# Icon for the spot should be the icon of the first SIG ref if present, otherwise a radio tower
|
||||
# Icon for the alert should be the icon of its activity if known, otherwise a radio tower
|
||||
self.icon = "fa-tower-cell"
|
||||
if self.alert_type == AlertType.DXPEDITION:
|
||||
self.icon = "fa-globe-africa"
|
||||
elif self.alert_type == AlertType.CONTEST:
|
||||
self.icon = "fa-trophy"
|
||||
elif self.alert_type == AlertType.SATELLITE:
|
||||
self.icon = "fa-satellite"
|
||||
elif self.sig_refs and self.sig_refs[0].icon:
|
||||
self.icon = self.sig_refs[0].icon
|
||||
if self.sig and (activity_icon := get_icon_for_activity(self.sig)):
|
||||
self.icon = activity_icon
|
||||
|
||||
except Exception:
|
||||
logger.exception("Exception while inferring missing data from spot")
|
||||
|
||||
+2
-2
@@ -7,8 +7,8 @@ from core.enums import Continent, LocationSourceForCallsign
|
||||
class Callsign:
|
||||
"""Data class that defines a callsign and the data associated with it. This will have been retrieved by a callsign
|
||||
lookup provider using data files or online lookup. This can be used to infer missing data for a spot, though if the
|
||||
spot has a SIG (e.g. POTA) reference this data for their home location (or even just their country) will be less
|
||||
accurate and should not be used in preference to that."""
|
||||
spot has an activity (e.g. POTA) reference this data for their home location (or even just their country) will be
|
||||
less accurate and should not be used in preference to that."""
|
||||
|
||||
# Callsign as spotted
|
||||
call: str
|
||||
|
||||
-39
@@ -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
|
||||
+124
-87
@@ -9,18 +9,20 @@ from math import isnan
|
||||
import pytz
|
||||
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
|
||||
|
||||
from core.activity_lookup_helper import populate_missing_activity_ref_info
|
||||
from core.activity_utils import (
|
||||
ANY_ACTIVITY_REGEX,
|
||||
get_activity_by_name,
|
||||
get_activity_name_from_comment_name,
|
||||
get_icon_for_activity,
|
||||
get_ref_regex_for_activity,
|
||||
)
|
||||
from core.call_lookup_helper import get_call_info
|
||||
from core.config import MAX_SPOT_AGE
|
||||
from core.constants import PROPAGATION_MODES, SIGS
|
||||
from core.constants import PROPAGATION_MODES
|
||||
from core.data_store import DATA_STORE
|
||||
from core.enums import Continent, LocationSourceForSpot, Mode, ModeSource, ModeType
|
||||
from core.enums import ActivityName, Continent, LocationSourceForSpot, Mode, ModeSource, ModeType
|
||||
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
||||
from core.sig_lookup_helper import populate_missing_sig_ref_info
|
||||
from core.sig_utils import (
|
||||
ANY_SIG_REGEX,
|
||||
get_ref_regex_for_sig,
|
||||
get_sig_name_from_comment_name,
|
||||
)
|
||||
from core.utils import (
|
||||
get_flag_for_dxcc,
|
||||
infer_band_from_freq,
|
||||
@@ -28,7 +30,8 @@ from core.utils import (
|
||||
infer_mode_from_frequency,
|
||||
infer_mode_type_from_mode,
|
||||
)
|
||||
from data.sig_ref import SIGRef
|
||||
from data.activities import ACTIVITIES
|
||||
from data.activity_ref import ActivityRef
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,8 +49,8 @@ class Spot:
|
||||
dx_call: str | None = None
|
||||
# Name of the operator that has been spotted
|
||||
dx_name: str | None = None
|
||||
# QTH of the operator that has been spotted. This could be from any SIG refs or could be from online lookup of their
|
||||
# home QTH.
|
||||
# QTH of the operator that has been spotted. This could be from any activity refs or could be from online lookup of
|
||||
# their home QTH.
|
||||
dx_qth: str | None = None
|
||||
# Country of the DX operator
|
||||
dx_country: str | None = None
|
||||
@@ -73,8 +76,8 @@ class Spot:
|
||||
# DX Location source. Indicates how accurate the location might be.
|
||||
dx_location_source: LocationSourceForSpot | None = None
|
||||
# DX Location good. Indicates that the software thinks the location data is good enough to plot on a map. This is
|
||||
# true if the location source is "SPOT", "SIG REF LOOKUP" or "GRID", or if the location source is "HOME QTH" and the
|
||||
# DX callsign doesn't have a suffix like /P.
|
||||
# true if the location source is "SPOT", "SIG REF LOOKUP" or "GRID", or if the location source is "HOME QTH" and
|
||||
# the DX callsign doesn't have a suffix like /P. (Location source retains "SIG" wording for API compatibility.)
|
||||
dx_location_good: bool = False
|
||||
|
||||
# DE (Spotter) info
|
||||
@@ -120,11 +123,12 @@ class Spot:
|
||||
# QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
|
||||
qrt: bool = False
|
||||
|
||||
# Special Interest Group info
|
||||
# Activity info
|
||||
|
||||
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
|
||||
# Activity (e.g. outdoor activity programme such as POTA). Still named "sig" for API backwards compatibility.
|
||||
sig: str | None = None
|
||||
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
||||
# 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)
|
||||
|
||||
# Timing info
|
||||
@@ -158,7 +162,10 @@ class Spot:
|
||||
objects such as the sig_refs list.."""
|
||||
|
||||
if self.sig_refs:
|
||||
self.sig_refs = [sig_ref if isinstance(sig_ref, SIGRef) else SIGRef(**sig_ref) for sig_ref in self.sig_refs]
|
||||
self.sig_refs = [
|
||||
activity_ref if isinstance(activity_ref, ActivityRef) else ActivityRef(**activity_ref)
|
||||
for activity_ref in self.sig_refs
|
||||
]
|
||||
|
||||
def infer_missing(self, credentials=None):
|
||||
"""Infer missing parameters where possible"""
|
||||
@@ -262,76 +269,89 @@ class Spot:
|
||||
if self.dx_latitude or self.dx_grid:
|
||||
self.dx_location_source = LocationSourceForSpot.SPOT
|
||||
|
||||
# Set the top-level "SIG" if it is missing but we have at least one SIG ref.
|
||||
# Set the top-level activity if it is missing but we have at least one activity ref.
|
||||
if not self.sig and self.sig_refs:
|
||||
self.sig = self.sig_refs[0].sig.upper()
|
||||
|
||||
# See if we already have a SIG reference, but the comment looks like it contains more for the same SIG. This
|
||||
# should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002".
|
||||
# See if we already have an activity reference, but the comment looks like it contains more for the same
|
||||
# activity. This should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002".
|
||||
if self.comment and self.sig_refs and self.sig_refs[0].sig:
|
||||
sig = self.sig_refs[0].sig.upper()
|
||||
regex = get_ref_regex_for_sig(sig)
|
||||
activity = self.sig_refs[0].sig.upper()
|
||||
regex = get_ref_regex_for_activity(activity)
|
||||
if regex:
|
||||
all_comment_ref_matches = re.finditer(r"(^|\W)(" + regex + r")($|\W)", self.comment, re.IGNORECASE)
|
||||
for ref_match in all_comment_ref_matches:
|
||||
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig))
|
||||
self._append_activity_ref_if_missing(ActivityRef(id=ref_match.group(2).upper(), sig=activity))
|
||||
|
||||
# See if the comment looks like it contains any SIGs (and optionally SIG references) that we can
|
||||
# add to the spot. This should catch cluster spot comments like "POTA GB-0001 WWFF GFF-0001" and e.g. POTA
|
||||
# comments like "also WWFF GFF-0001".
|
||||
# See if the comment looks like it contains any activities (and optionally activity references) that we
|
||||
# can add to the spot. This should catch cluster spot comments like "POTA GB-0001 WWFF GFF-0001" and e.g.
|
||||
# POTA comments like "also WWFF GFF-0001".
|
||||
if self.comment:
|
||||
sig_matches = re.finditer(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
|
||||
for sig_match in sig_matches:
|
||||
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster
|
||||
# spots where the comment is just "POTA".
|
||||
found_sig = get_sig_name_from_comment_name(sig_match.group(2))
|
||||
activity_matches = re.finditer(r"(^|\W)" + ANY_ACTIVITY_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
|
||||
for activity_match in activity_matches:
|
||||
# First of all, if we haven't got an activity for this spot set yet, now we have. This covers
|
||||
# things like cluster spots where the comment is just "POTA".
|
||||
found_activity = get_activity_name_from_comment_name(activity_match.group(2))
|
||||
if not self.sig:
|
||||
self.sig = found_sig
|
||||
self.sig = found_activity
|
||||
|
||||
# Now look to see if that SIG name was followed by something that looks like a reference ID for that SIG.
|
||||
# If so, add that to the sig_refs list for this spot.
|
||||
ref_regex = get_ref_regex_for_sig(found_sig)
|
||||
if ref_regex:
|
||||
# Now look to see if that activity name was followed by something that looks like a reference ID
|
||||
# for that activity. If so, add that to the sig_refs list for this spot.
|
||||
found_activity_info = get_activity_by_name(found_activity)
|
||||
if found_activity_info and found_activity_info.has_refs and found_activity_info.ref_regex:
|
||||
ref_matches = re.finditer(
|
||||
r"(^|\W)" + found_sig + r"([ -])(" + ref_regex + r")($|\W)",
|
||||
r"(^|\W)" + found_activity + r"([ -])(" + found_activity_info.ref_regex + r")($|\W)",
|
||||
self.comment,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
for ref_match in ref_matches:
|
||||
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(3).upper(), sig=found_sig))
|
||||
self._append_activity_ref_if_missing(
|
||||
ActivityRef(id=ref_match.group(3).upper(), sig=found_activity)
|
||||
)
|
||||
|
||||
# See if the comment looks like it contains any SIG references *without* the corresponding SIG name, but
|
||||
# where the SIG reference is unique-looking enough that we can't confuse it with any other SIG.
|
||||
# See if the comment looks like it contains any activity references *without* the corresponding activity
|
||||
# name, but where the activity reference is unique-looking enough that we can't confuse it with any other
|
||||
# activity.
|
||||
if self.comment:
|
||||
for sig in SIGS:
|
||||
if sig.refs_globally_unique and sig.ref_regex:
|
||||
ref_matches = re.finditer(r"(^|\W)(" + sig.ref_regex + r")($|\W)", self.comment, re.IGNORECASE)
|
||||
for activity in ACTIVITIES.values():
|
||||
if activity.has_refs and activity.refs_globally_unique and activity.ref_regex:
|
||||
ref_matches = re.finditer(
|
||||
r"(^|\W)(" + activity.ref_regex + r")($|\W)", self.comment, re.IGNORECASE
|
||||
)
|
||||
for ref_match in ref_matches:
|
||||
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things
|
||||
# like cluster spots where the comment is just "OHFF-1234", now we know it's WWFF.
|
||||
# First of all, if we haven't got an activity for this spot set yet, now we have. This
|
||||
# covers things like cluster spots where the comment is just "OHFF-1234", now we know
|
||||
# it's WWFF.
|
||||
if not self.sig:
|
||||
self.sig = sig.name
|
||||
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig.name))
|
||||
self.sig = activity.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
|
||||
# in its initial call, we use this code to populate the rest of the data. This includes working out grid refs
|
||||
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
|
||||
# Fetch activity data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a
|
||||
# reference in its initial call, we use this code to populate the rest of the data. This includes working
|
||||
# out grid refs from WAB and WAI, which count as an activity even though there's no real lookup, just maths
|
||||
if self.sig_refs:
|
||||
for sig_ref in self.sig_refs:
|
||||
sig_ref = populate_missing_sig_ref_info(sig_ref)
|
||||
# If the spot itself doesn't have location yet, but the SIG ref does, extract it
|
||||
if sig_ref.grid and not self.dx_grid:
|
||||
self.dx_grid = sig_ref.grid
|
||||
if sig_ref.latitude and not self.dx_latitude and sig_ref.longitude and not self.dx_longitude:
|
||||
self.dx_latitude = sig_ref.latitude
|
||||
self.dx_longitude = sig_ref.longitude
|
||||
if self.sig == "WAB" or self.sig == "WAI" or self.sig == "Tiles":
|
||||
for activity_ref in self.sig_refs:
|
||||
activity_ref = populate_missing_activity_ref_info(activity_ref)
|
||||
# If the spot itself doesn't have location yet, but the activity ref does, extract it
|
||||
if activity_ref.grid and not self.dx_grid:
|
||||
self.dx_grid = activity_ref.grid
|
||||
if (
|
||||
activity_ref.latitude
|
||||
and not self.dx_latitude
|
||||
and activity_ref.longitude
|
||||
and not self.dx_longitude
|
||||
):
|
||||
self.dx_latitude = activity_ref.latitude
|
||||
self.dx_longitude = activity_ref.longitude
|
||||
if self.sig in (ActivityName.WAB, ActivityName.WAI, ActivityName.TILES):
|
||||
self.dx_location_source = LocationSourceForSpot.GRID
|
||||
else:
|
||||
self.dx_location_source = LocationSourceForSpot.SIG_REF_LOOKUP
|
||||
|
||||
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
|
||||
# and apply it to the whole spot.
|
||||
# If the spot itself doesn't have an activity yet, but we have at least one activity reference, take that
|
||||
# reference's activity and apply it to the whole spot.
|
||||
if self.sig_refs and not self.sig:
|
||||
self.sig = self.sig_refs[0].sig
|
||||
|
||||
@@ -360,17 +380,34 @@ class Spot:
|
||||
self.propagation_mode = mode_tag
|
||||
logger.info(f"Seen a new propagation mode tag not yet in the system: {mode_tag}")
|
||||
|
||||
# Set SIGs based on propagation mode
|
||||
if self.propagation_mode == "Satellite":
|
||||
if not self.sig:
|
||||
self.sig = "AMSAT"
|
||||
if not any(sig_ref.sig == "AMSAT" for sig_ref in self.sig_refs):
|
||||
self.sig_refs.append(SIGRef(sig="AMSAT"))
|
||||
if self.propagation_mode == "Earth-Moon-Earth":
|
||||
if not self.sig:
|
||||
self.sig = "EME"
|
||||
if not any(sig_ref.sig == "EME" for sig_ref in self.sig_refs):
|
||||
self.sig_refs.append(SIGRef(sig="EME"))
|
||||
# Set activities based on propagation mode
|
||||
if self.propagation_mode == "Satellite" and not self.sig:
|
||||
self.sig = ActivityName.SATELLITE
|
||||
if self.propagation_mode == "Earth-Moon-Earth" and not self.sig:
|
||||
self.sig = ActivityName.EME
|
||||
|
||||
# Set activities based on the DX callsign suffix
|
||||
if self.dx_call and not self.sig:
|
||||
if self.dx_call.upper().endswith(ActivityName.AERONAUTICAL_MOBILE):
|
||||
self.sig = ActivityName.AERONAUTICAL_MOBILE
|
||||
elif self.dx_call.upper().endswith(ActivityName.MARITIME_MOBILE):
|
||||
self.sig = ActivityName.MARITIME_MOBILE
|
||||
|
||||
# Alright, now let's get really fancy. Check if the DX callsign matches one taking part in a currently
|
||||
# running DXpedition which we know from the alerts list.
|
||||
if self.dx_call and not self.sig:
|
||||
now = datetime.now(pytz.UTC).timestamp()
|
||||
for alert in DATA_STORE.alerts.values():
|
||||
if (
|
||||
alert.sig == ActivityName.DXPEDITION
|
||||
and alert.dx_calls
|
||||
and alert.start_time
|
||||
and alert.end_time
|
||||
and alert.start_time < now < alert.end_time
|
||||
and self.dx_call.upper() in [c.upper() for c in alert.dx_calls if c]
|
||||
):
|
||||
self.sig = ActivityName.DXPEDITION
|
||||
break
|
||||
|
||||
# Parse "de_grid -> dx_grid" structures from the comment
|
||||
if self.comment:
|
||||
@@ -393,12 +430,12 @@ class Spot:
|
||||
self.dx_latitude = ll[0]
|
||||
self.dx_longitude = ll[1]
|
||||
except Exception:
|
||||
logger.debug("Invalid grid received for spot")
|
||||
logger.debug("Invalid grid received for spot", exc_info=True)
|
||||
if self.dx_latitude and self.dx_longitude and not self.dx_grid:
|
||||
try:
|
||||
self.dx_grid = latlong_to_locator(self.dx_latitude, self.dx_longitude, 8)
|
||||
except Exception:
|
||||
logger.debug("Invalid lat/lon received for spot")
|
||||
logger.debug("Invalid lat/lon received for spot", exc_info=True)
|
||||
|
||||
# QRT comment detection
|
||||
if self.comment and not self.qrt:
|
||||
@@ -426,8 +463,8 @@ class Spot:
|
||||
self.dx_grid = dx_call_info.grid
|
||||
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,
|
||||
# otherwise see what they have set on an online lookup service.
|
||||
# Determine a "QTH" string. If we have an activity ref, pick the first one and turn it into a suitable
|
||||
# string, otherwise see what they have set on an online lookup service.
|
||||
if self.sig_refs:
|
||||
qth = self.sig_refs[0].id
|
||||
if self.sig_refs[0].name:
|
||||
@@ -482,10 +519,10 @@ class Spot:
|
||||
self.de_longitude = de_call_info.longitude
|
||||
self.de_grid = de_call_info.grid
|
||||
|
||||
# Icon for the spot should be the icon of the first SIG ref if present, otherwise a radio tower
|
||||
# Icon for the spot should be the icon of its activity if known, otherwise a radio tower
|
||||
self.icon = "fa-tower-cell"
|
||||
if self.sig_refs and self.sig_refs[0].icon:
|
||||
self.icon = self.sig_refs[0].icon
|
||||
if self.sig and (activity_icon := get_icon_for_activity(self.sig)):
|
||||
self.icon = activity_icon
|
||||
|
||||
except Exception:
|
||||
logger.exception("Exception while inferring missing data from spot")
|
||||
@@ -495,17 +532,17 @@ class Spot:
|
||||
|
||||
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
|
||||
|
||||
def _append_sig_ref_if_missing(self, new_sig_ref):
|
||||
"""Append a sig_ref to the list, so long as it's not already there."""
|
||||
def _append_activity_ref_if_missing(self, new_activity_ref):
|
||||
"""Append an activity ref to the list, so long as it's not already there."""
|
||||
|
||||
new_sig_ref.id = new_sig_ref.id.strip().upper()
|
||||
new_sig_ref.sig = new_sig_ref.sig.strip().upper()
|
||||
if new_sig_ref.id == "":
|
||||
new_activity_ref.id = new_activity_ref.id.strip().upper()
|
||||
new_activity_ref.sig = new_activity_ref.sig.strip().upper()
|
||||
if new_activity_ref.id == "":
|
||||
return
|
||||
for sig_ref in self.sig_refs:
|
||||
if sig_ref.id == new_sig_ref.id and sig_ref.sig == new_sig_ref.sig:
|
||||
for activity_ref in self.sig_refs:
|
||||
if activity_ref.id == new_activity_ref.id and activity_ref.sig == new_activity_ref.sig:
|
||||
return
|
||||
self.sig_refs.append(new_sig_ref)
|
||||
self.sig_refs.append(new_activity_ref)
|
||||
|
||||
def expired(self):
|
||||
"""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
@@ -5,7 +5,7 @@ You can embed Spothole's web interface in another website, e.g. for use as part
|
||||
URL parameters can be used to trigger an "embedded" mode which hides the headers, footers and settings. In this mode,
|
||||
you provide configuration for the various filter and display options via additional URL parameters. Any settings that
|
||||
the user has set for Spothole are ignored. This is so that the embedding site can select, for example, their choice of
|
||||
dark mode or SIG filters, which will not impact how Spothole appears when the user accesses it directly. Effectively, it
|
||||
dark mode or activity filters, which will not impact how Spothole appears when the user accesses it directly. Effectively, it
|
||||
becomes separate to their normal Spothole settings.
|
||||
|
||||
Setting `embedded` to true is important for the rest of the settings to be applied; otherwise, the user's defaults will
|
||||
@@ -29,7 +29,7 @@ a mapping exists.
|
||||
| `limit` | 25, 50, 100, 200, 500 | 100 | `?limit=100` | Sets the number of alerts that will be displayed on the alerts page |
|
||||
| `max_age` | 300, 600, 1800, 3600 | 1800 | `?max_age=1800` | Sets the maximum age of spots displayed on the map and bands pages, in seconds. |
|
||||
| `band` | Comma-separated list | (all) | `?band=20m,40m` | Sets the list of bands that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
|
||||
| `sig` | Comma-separated list | (all) | `?sig=POTA,SOTA,NO_SIG` | Sets the list of SIGs that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
|
||||
| `sig` | Comma-separated list | (all) | `?sig=POTA,SOTA,NO_SIG` | Sets the list of activities that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
|
||||
| `source` | Comma-separated list | (all) | `?source=Cluster` | Sets the list of sources that will be shown on any spot or alert pages. Available options match the labels of the buttons in the standard web interface. |
|
||||
| `mode_type` | Comma-separated list | (all) | `?mode_type=PHONE,CW` | Sets the list of mode types that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
|
||||
| `dx_continent` | Comma-separated list | (all) | `?dx_continent=NA,SA` | Sets the list of DX Continents that will be shown on any spot or alert pages. Available options match the labels of the buttons in the standard web interface. |
|
||||
|
||||
+2
-2
@@ -18,8 +18,8 @@ To navigate your way around the source code, this list may help.
|
||||
services
|
||||
* `/providers/callsign` - Classes providing callsign lookup data by accessing bundled data files or the APIs of other
|
||||
services
|
||||
* `/providers/sigrefdata` - Classes providing SIG reference lookup data by accessing bundled data files or the APIs of
|
||||
other services
|
||||
* `/providers/activityrefdata` - Classes providing activity reference lookup data by accessing bundled data files or
|
||||
the APIs of other services
|
||||
* `/webserver` - Classes for running Spothole's own web server
|
||||
* `/telnetserver` - Classes for running Spothole's telnet server
|
||||
* `spothole.py` - Main application script
|
||||
|
||||
+7
-5
@@ -9,11 +9,13 @@ from core.data_store import DATA_STORE
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SIGRefDataProvider:
|
||||
"""Generic SIG reference data provider class. Subclasses of this query the individual URLs or files for data."""
|
||||
class ActivityRefDataProvider:
|
||||
"""Generic activity reference data provider class. Subclasses of this query the individual URLs or files for
|
||||
data."""
|
||||
|
||||
def __init__(self, sig_name, provider_config):
|
||||
"""Constructor"""
|
||||
"""Constructor. Note the parameter and attribute are still named "sig_name" for consistency with the API's
|
||||
"sig" field name."""
|
||||
|
||||
self.sig_name = sig_name
|
||||
self.enabled = provider_config["enabled"]
|
||||
@@ -39,9 +41,9 @@ class SIGRefDataProvider:
|
||||
# with transact() batches all writes together to save making thousands of individual sqlite writes. However,
|
||||
# that means that each provider holds the lock while it writes, and the default behaviour for other attempted
|
||||
# transact()s is to fail if they can't get the lock (?!). This behaviour is fixed by retry=True.
|
||||
with DATA_STORE.sigrefs.transact(retry=True):
|
||||
with DATA_STORE.activity_refs.transact(retry=True):
|
||||
for d in new_data:
|
||||
DATA_STORE.sigrefs.set(f"{self.sig_name}:{d.id}", d)
|
||||
DATA_STORE.activity_refs.set(f"{self.sig_name}:{d.id}", d)
|
||||
|
||||
# For the big data sources, loading will take a few minutes. If we want to shut down the software neatly
|
||||
# within the first few minutes of startup, we need a way to abort this expensive process of filling up the
|
||||
@@ -1,22 +1,22 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||
FileDownloadActivityRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class ARLHS(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Amateur Radio Light House Society"""
|
||||
class ARLHS(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Amateur Radio Light House Society"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "ARLHS"
|
||||
ACTIVITY = ActivityName.ARLHS
|
||||
DATA_URL = "https://www.gma.rocks/download/lighthouse.csv"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
@@ -24,11 +24,11 @@ class ARLHS(FileDownloadSIGRefDataProvider):
|
||||
if "ARLHS" in row and row["ARLHS"] != "":
|
||||
ref_id = row["ARLHS"]
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=ref_id,
|
||||
name=row.get("Name", None),
|
||||
ref_type=SIGRefType.LIGHTHOUSE,
|
||||
ref_type=ActivityRefType.LIGHTHOUSE,
|
||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
||||
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
||||
@@ -41,7 +41,7 @@ class ARLHS(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -2,20 +2,20 @@ from time import sleep
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
|
||||
|
||||
|
||||
class COTA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Castles on the Air"""
|
||||
class COTA(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Castles on the Air"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "COTA"
|
||||
ACTIVITY = ActivityName.COTA
|
||||
DATA_URL = "https://www.cotagroup.org/cotagroup/map/data/castles-all-7d90ee2a5e1175e5dece1bbf9dc87504.json"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
@@ -29,11 +29,11 @@ class COTA(FileDownloadSIGRefDataProvider):
|
||||
grid = latlong_to_locator(lat, lon)
|
||||
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=ref_id,
|
||||
name=name,
|
||||
ref_type=SIGRefType.CASTLE,
|
||||
ref_type=ActivityRefType.CASTLE,
|
||||
grid=grid,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
@@ -45,7 +45,7 @@ class COTA(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -3,20 +3,20 @@ from time import sleep
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
|
||||
|
||||
|
||||
class DCE(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Castillos de España"""
|
||||
class DCE(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Diploma Castillos de España"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 365
|
||||
SIG = "DCE"
|
||||
ACTIVITY = ActivityName.DCE
|
||||
DATA_URL = "https://www.acracb.org/dce/descargas/General/directorio_referencias_dce.xls"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
@@ -27,7 +27,7 @@ class DCE(FileDownloadSIGRefDataProvider):
|
||||
for index, row in df.iterrows():
|
||||
if row.iloc[0] and row.iloc[2]:
|
||||
new_data.append(
|
||||
SIGRef(sig=self.SIG, id=row.iloc[0].strip(), name=row.iloc[2].strip(), ref_type=SIGRefType.CASTLE)
|
||||
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
|
||||
@@ -35,7 +35,7 @@ class DCE(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -3,20 +3,20 @@ from time import sleep
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
|
||||
|
||||
|
||||
class DEFE(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Estationes de Ferrocarril de España"""
|
||||
class DEFE(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Diploma Estationes de Ferrocarril de España"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 365
|
||||
SIG = "DEFE"
|
||||
ACTIVITY = ActivityName.DEFE
|
||||
DATA_URL = "https://www.acracb.org/defe/descargas/General/directorio_referencias_defe.xls"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
@@ -31,7 +31,7 @@ class DEFE(FileDownloadSIGRefDataProvider):
|
||||
|
||||
if row.iloc[0] and row.iloc[1]:
|
||||
new_data.append(
|
||||
SIGRef(sig=self.SIG, id=row.iloc[0].strip(), name=row.iloc[1].strip(), ref_type=SIGRefType.BUILDING)
|
||||
ActivityRef(sig=self.ACTIVITY, id=row.iloc[0].strip(), name=row.iloc[1].strip(), ref_type=ActivityRefType.BUILDING)
|
||||
)
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||
@@ -39,7 +39,7 @@ class DEFE(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -3,21 +3,21 @@ from time import sleep
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.local_file_sig_ref_data_provider import (
|
||||
LocalFileSIGRefDataProvider,
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.local_file_activity_ref_data_provider import (
|
||||
LocalFileActivityRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class DME(LocalFileSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Municipios de Espana"""
|
||||
class DME(LocalFileActivityRefDataProvider):
|
||||
"""Activity ref data provider for Diploma Municipios de Espana"""
|
||||
|
||||
SIG = "DME"
|
||||
ACTIVITY = ActivityName.DME
|
||||
PATH = "datafiles/MUNICIPIOS.csv"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.PATH)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.PATH)
|
||||
|
||||
def _file_to_data(self, path):
|
||||
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
|
||||
# from agressively thinking every number in a spot comment is DME after it's seen "DME" once. The only
|
||||
# numbers that count are straight after "DME " or "DME-". The dash versus space is normalised in
|
||||
# sig_lookup_helper.py.
|
||||
# activity_lookup_helper.py.
|
||||
ref_id = "DME-" + row["COD_INE"][:5]
|
||||
latitude = (
|
||||
float(row["LATITUD_ETRS89_REGCAN95"].replace(",", "."))
|
||||
@@ -39,10 +39,10 @@ class DME(LocalFileSIGRefDataProvider):
|
||||
else None
|
||||
)
|
||||
|
||||
ref = SIGRef(
|
||||
sig=self.SIG,
|
||||
ref = ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=ref_id,
|
||||
ref_type=SIGRefType.TOWN,
|
||||
ref_type=ActivityRefType.TOWN,
|
||||
name=f"{row['NOMBRE_ACTUAL']}, {row['PROVINCIA']}",
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
@@ -56,7 +56,7 @@ class DME(LocalFileSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
|
||||
|
||||
|
||||
class DMUE(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Museos de España"""
|
||||
class DMUE(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Diploma Museos de España"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 365
|
||||
SIG = "DMUE"
|
||||
ACTIVITY = ActivityName.DMUE
|
||||
DATA_URL = "https://dmue.radiogalena.es/nom_dmue.csv"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
@@ -22,7 +22,7 @@ class DMUE(FileDownloadSIGRefDataProvider):
|
||||
for row in csv.reader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"):
|
||||
if len(row) > 1 and row[0] and row[1]:
|
||||
new_data.append(
|
||||
SIGRef(sig=self.SIG, id=row[0].strip(), name=row[1].strip(), ref_type=SIGRefType.BUILDING)
|
||||
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
|
||||
@@ -30,7 +30,7 @@ class DMUE(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -3,20 +3,20 @@ from time import sleep
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
|
||||
|
||||
|
||||
class DMVE(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Monumentos y Vestigios de España"""
|
||||
class DMVE(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Diploma Monumentos y Vestigios de España"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 365
|
||||
SIG = "DMVE"
|
||||
ACTIVITY = ActivityName.DMVE
|
||||
DATA_URL = "https://www.acracb.org/dmve/descargas/General/directorio_referencias_dmve.xls"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
@@ -36,14 +36,14 @@ class DMVE(FileDownloadSIGRefDataProvider):
|
||||
continue
|
||||
|
||||
if ref and name:
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref.strip(), name=name.strip(), ref_type=SIGRefType.BUILDING))
|
||||
new_data.append(ActivityRef(sig=self.ACTIVITY, id=ref.strip(), name=name.strip(), ref_type=ActivityRefType.BUILDING))
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||
# the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
|
||||
|
||||
|
||||
class DTMBA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Teatri Musei Belle Arti"""
|
||||
class DTMBA(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Diploma Teatri Musei Belle Arti"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "DTMBA"
|
||||
ACTIVITY = ActivityName.DTMBA
|
||||
DATA_URL = "https://www.iu1fig.com/share/iz0eik/dtmba/export.php"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
@@ -21,14 +21,14 @@ class DTMBA(FileDownloadSIGRefDataProvider):
|
||||
split = row.split(";")
|
||||
ref_id = split[0]
|
||||
ref_name = split[1]
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=ref_name, ref_type=SIGRefType.BUILDING))
|
||||
new_data.append(ActivityRef(sig=self.ACTIVITY, id=ref_id, name=ref_name, ref_type=ActivityRefType.BUILDING))
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||
# the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -3,20 +3,20 @@ from time import sleep
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
|
||||
|
||||
|
||||
class FEA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Faros de España"""
|
||||
class FEA(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Diploma Faros de España"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "FEA"
|
||||
ACTIVITY = ActivityName.FEA
|
||||
DATA_URL = "http://ea5ol.net/Lista%20Faros.pdf"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
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.
|
||||
ref_id_1 = row[0].strip()
|
||||
ref_id_2 = ref_id_1.replace("D-", "FEA-").replace("E-", "FEA-")
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id_1, name=row[1].strip(), ref_type=SIGRefType.LIGHTHOUSE))
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id_2, name=row[1].strip(), ref_type=SIGRefType.LIGHTHOUSE))
|
||||
new_data.append(ActivityRef(sig=self.ACTIVITY, id=ref_id_1, name=row[1].strip(), ref_type=ActivityRefType.LIGHTHOUSE))
|
||||
new_data.append(ActivityRef(sig=self.ACTIVITY, id=ref_id_2, name=row[1].strip(), ref_type=ActivityRefType.LIGHTHOUSE))
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||
# the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
+18
-17
@@ -7,13 +7,14 @@ from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from core.url_data_cache import URLDataCache
|
||||
from providers.sigrefdata.sig_ref_data_provider import SIGRefDataProvider
|
||||
from providers.activityrefdata.activity_ref_data_provider import ActivityRefDataProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
|
||||
"""Generic SIG ref data provider class for providers that fetch their data from the web by downloading a file."""
|
||||
class FileDownloadActivityRefDataProvider(ActivityRefDataProvider):
|
||||
"""Generic activity ref data provider class for providers that fetch their data from the web by downloading a
|
||||
file."""
|
||||
|
||||
def __init__(self, sig_name, provider_config, url, poll_interval):
|
||||
"""Set up the provider, note poll_interval is in *days*."""
|
||||
@@ -21,13 +22,13 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
|
||||
self._url = url
|
||||
self._poll_interval = poll_interval
|
||||
self._thread = None
|
||||
self._url_data_cache = URLDataCache(f"sigrefdata_{sig_name}")
|
||||
self._url_data_cache = URLDataCache(f"activity_ref_data_{sig_name}")
|
||||
|
||||
def start(self):
|
||||
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
||||
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||
logger.info(f"Set up query of {self.sig_name} SIG ref data every {self._poll_interval!s} days.")
|
||||
self._thread = Thread(target=self._run, name=f"FileDownloadSIGRefDataProvider-{self.sig_name}", daemon=True)
|
||||
logger.info(f"Set up query of {self.sig_name} activity ref data every {self._poll_interval!s} days.")
|
||||
self._thread = Thread(target=self._run, name=f"FileDownloadActivityRefDataProvider-{self.sig_name}", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
@@ -35,7 +36,7 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
|
||||
if self._thread:
|
||||
self._thread.join(timeout=35)
|
||||
if self._thread.is_alive():
|
||||
logger.warning(f"{self.sig_name} SIG ref data worker thread did not exit on time and will be killed.")
|
||||
logger.warning(f"{self.sig_name} activity ref data worker thread did not exit on time and will be killed.")
|
||||
|
||||
def _run(self):
|
||||
while True:
|
||||
@@ -47,37 +48,37 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
|
||||
try:
|
||||
# Request data from API. Use the data cache (with a TTL of 1 day) here, not as the main mechanism for
|
||||
# caching, but just so continual restarts of the software during testing don't hammer the servers.
|
||||
logger.debug(f"Downloading {self.sig_name} SIG ref data...")
|
||||
logger.debug(f"Downloading {self.sig_name} activity ref data...")
|
||||
http_response = self._url_data_cache.get(self._url, headers=HTTP_HEADERS)
|
||||
# Check response code was good
|
||||
if http_response.ok:
|
||||
# Pass off to the subclass for processing
|
||||
new_data = self._http_response_to_data(http_response)
|
||||
# Add the new data to the SIG Ref data store
|
||||
# Add the new data to the activity ref data store
|
||||
if new_data:
|
||||
self._add_data(new_data)
|
||||
|
||||
self.status = "OK"
|
||||
self.last_update_time = datetime.now(pytz.UTC)
|
||||
logger.debug(f"Received SIG ref data for {self.sig_name}")
|
||||
logger.debug(f"Received activity ref data for {self.sig_name}")
|
||||
else:
|
||||
self.status = "Error"
|
||||
logger.warning(f"HTTP {http_response.status_code} when downloading SIG ref data for {self.sig_name}.")
|
||||
logger.warning(f"HTTP {http_response.status_code} when downloading activity ref data for {self.sig_name}.")
|
||||
|
||||
except ConnectionError:
|
||||
self.status = "Error"
|
||||
logger.warning(f"Connection error when downloading SIG ref data for {self.sig_name}.")
|
||||
logger.warning(f"Connection error when downloading activity ref data for {self.sig_name}.")
|
||||
except (ConnectTimeout, ReadTimeout):
|
||||
self.status = "Error"
|
||||
logger.warning(f"Timeout when downloading SIG ref data for {self.sig_name}.")
|
||||
logger.warning(f"Timeout when downloading activity ref data for {self.sig_name}.")
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logger.exception(f"Exception in HTTP SIG Ref Data Provider ({self.sig_name})")
|
||||
logger.exception(f"Exception in HTTP Activity Ref Data Provider ({self.sig_name})")
|
||||
self._stop_event.wait(timeout=1)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
"""Convert an HTTP response returned by the server into SIG Ref data. The whole response is provided here so the
|
||||
subclass implementations can check for HTTP status codes if necessary, and handle the response as JSON, CSV,
|
||||
whatever the remote file actually is."""
|
||||
"""Convert an HTTP response returned by the server into activity ref data. The whole response is provided here
|
||||
so the subclass implementations can check for HTTP status codes if necessary, and handle the response as
|
||||
JSON, CSV, whatever the remote file actually is."""
|
||||
|
||||
raise NotImplementedError("Subclasses must implement this method")
|
||||
@@ -1,33 +1,33 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||
FileDownloadActivityRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class GMA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Global Mountain Activity"""
|
||||
class GMA(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Global Mountain Activity"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "GMA"
|
||||
ACTIVITY = ActivityName.GMA
|
||||
DATA_URL = "https://www.gma.rocks/download/summits.csv"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
|
||||
ref_id = row["Reference"]
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=ref_id,
|
||||
name=row.get("Name", None),
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
ref_type=ActivityRefType.SUMMIT,
|
||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
||||
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
||||
@@ -43,7 +43,7 @@ class GMA(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||
FileDownloadActivityRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class ILLW(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for International Lighthouse & Lightship Weekend"""
|
||||
class ILLW(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for International Lighthouse & Lightship Weekend"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "ILLW"
|
||||
ACTIVITY = ActivityName.ILLW
|
||||
DATA_URL = "https://www.gma.rocks/download/lighthouse.csv"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
@@ -24,11 +24,11 @@ class ILLW(FileDownloadSIGRefDataProvider):
|
||||
if "ILLW" in row and row["ILLW"] != "":
|
||||
ref_id = row["ILLW"]
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=ref_id,
|
||||
name=row.get("Name", None),
|
||||
ref_type=SIGRefType.LIGHTHOUSE,
|
||||
ref_type=ActivityRefType.LIGHTHOUSE,
|
||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
||||
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
||||
@@ -41,7 +41,7 @@ class ILLW(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -3,24 +3,24 @@ from time import sleep
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||
FileDownloadActivityRefDataProvider,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IOTA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Islands on the Air"""
|
||||
class IOTA(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Islands on the Air"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 365
|
||||
SIG = "IOTA"
|
||||
ACTIVITY = ActivityName.IOTA
|
||||
DATA_URL = "https://www.iota-world.org/islands-on-the-air/downloads/download-file.html?path=groups.json"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
@@ -41,11 +41,11 @@ class IOTA(FileDownloadSIGRefDataProvider):
|
||||
)
|
||||
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=ref_id,
|
||||
name=ref["name"],
|
||||
ref_type=SIGRefType.ISLAND,
|
||||
ref_type=ActivityRefType.ISLAND,
|
||||
grid=grid,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
@@ -57,7 +57,7 @@ class IOTA(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -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 core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||
FileDownloadActivityRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class LLOTA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Lagos y Lagunas on the Air"""
|
||||
class LLOTA(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Lagos y Lagunas on the Air"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 7
|
||||
SIG = "LLOTA"
|
||||
ACTIVITY = ActivityName.LLOTA
|
||||
DATA_URL = "https://llota.app/api/public/references"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
@@ -29,11 +29,11 @@ class LLOTA(FileDownloadSIGRefDataProvider):
|
||||
ll = locator_to_latlong(grid)
|
||||
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=ref_id,
|
||||
name=str(ref["name"]),
|
||||
ref_type=SIGRefType.LAKE,
|
||||
ref_type=ActivityRefType.LAKE,
|
||||
url=f"https://llota.app/list/ref/{ref_id}",
|
||||
grid=grid,
|
||||
latitude=ll[0],
|
||||
@@ -46,7 +46,7 @@ class LLOTA(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -0,0 +1,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
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||
FileDownloadActivityRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class MOTA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Mills on the Air"""
|
||||
class MOTA(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Mills on the Air"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "MOTA"
|
||||
ACTIVITY = ActivityName.MOTA
|
||||
DATA_URL = "https://www.gma.rocks/download/mills.csv"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
|
||||
ref_id = row["Reference"]
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=ref_id,
|
||||
name=row.get("Name", None),
|
||||
ref_type=SIGRefType.MILL,
|
||||
ref_type=ActivityRefType.MILL,
|
||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
||||
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
||||
@@ -40,7 +40,7 @@ class MOTA(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -2,20 +2,20 @@ from time import sleep
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
|
||||
|
||||
|
||||
class PGA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Polish Gmina Award"""
|
||||
class PGA(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Polish Gmina Award"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "PGA"
|
||||
ACTIVITY = ActivityName.PGA
|
||||
DATA_URL = "http://www.spga.pl/lista_pga2.php"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
@@ -38,11 +38,11 @@ class PGA(FileDownloadSIGRefDataProvider):
|
||||
continue
|
||||
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=ref_id,
|
||||
name=name,
|
||||
ref_type=SIGRefType.REGION,
|
||||
ref_type=ActivityRefType.REGION,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -51,7 +51,7 @@ class PGA(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
+10
-10
@@ -4,16 +4,16 @@ from time import sleep
|
||||
from fastkml import kml
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
from core.enums import ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||
FileDownloadActivityRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class ParksNPeaksKMLSIGRefDataProvider(FileDownloadSIGRefDataProvider):
|
||||
"""Base class for SIG ref data providers that use parksnpeaks.org KML POI feeds and have references that use the
|
||||
VKFF refs rather than their own system (i.e. KRMNPA and SANPCPA)."""
|
||||
class ParksNPeaksKMLActivityRefDataProvider(FileDownloadActivityRefDataProvider):
|
||||
"""Base class for activity ref data providers that use parksnpeaks.org KML POI feeds and have references that
|
||||
use the VKFF refs rather than their own system (i.e. KRMNPA and SANPCPA)."""
|
||||
|
||||
REF_PATTERN = re.compile(r"VKFF-\d+")
|
||||
|
||||
@@ -40,11 +40,11 @@ class ParksNPeaksKMLSIGRefDataProvider(FileDownloadSIGRefDataProvider):
|
||||
|
||||
longitude, latitude = placemark.geometry.x, placemark.geometry.y
|
||||
|
||||
ref = SIGRef(
|
||||
ref = ActivityRef(
|
||||
sig=self.sig_name,
|
||||
id=ref_id,
|
||||
name=placemark.name,
|
||||
ref_type=SIGRefType.PARK,
|
||||
ref_type=ActivityRefType.PARK,
|
||||
url=f"https://parksnpeaks.org/getPark.php?actPark={ref_id}",
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
@@ -58,7 +58,7 @@ class ParksNPeaksKMLSIGRefDataProvider(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
return new_data
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||
FileDownloadActivityRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class POTA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Parks on the Air"""
|
||||
class POTA(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Parks on the Air"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 7
|
||||
SIG = "POTA"
|
||||
ACTIVITY = ActivityName.POTA
|
||||
DATA_URL = "https://pota.app/all_parks_ext.csv"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
|
||||
ref_id = row["reference"]
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=ref_id,
|
||||
name=row.get("name", None),
|
||||
ref_type=SIGRefType.PARK,
|
||||
ref_type=ActivityRefType.PARK,
|
||||
url=f"https://pota.app/#/park/{ref_id}",
|
||||
grid=row.get("grid", None),
|
||||
latitude=float(row["latitude"]) if "latitude" in row and row["latitude"] != "" else None,
|
||||
@@ -40,7 +40,7 @@ class POTA(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -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
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||
FileDownloadActivityRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class SIOTA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Silos on the Air"""
|
||||
class SIOTA(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Silos on the Air"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "SIOTA"
|
||||
ACTIVITY = ActivityName.SIOTA
|
||||
DATA_URL = "https://www.silosontheair.com/data/silos.csv"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
|
||||
ref_id = row["SILO_CODE"]
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=ref_id,
|
||||
name=row.get("NAME", None),
|
||||
ref_type=SIGRefType.SILO,
|
||||
ref_type=ActivityRefType.SILO,
|
||||
grid=row.get("LOCATOR", None),
|
||||
latitude=float(row["LAT"]) if "LAT" in row else None,
|
||||
longitude=float(row["LNG"]) if "LNG" in row else None,
|
||||
@@ -39,7 +39,7 @@ class SIOTA(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -3,22 +3,22 @@ from time import sleep
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||
FileDownloadActivityRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class SOTA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Summits on the Air"""
|
||||
class SOTA(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Summits on the Air"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "SOTA"
|
||||
ACTIVITY = ActivityName.SOTA
|
||||
DATA_URL = "https://storage.sota.org.uk/summitslist.csv"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
@@ -27,11 +27,11 @@ class SOTA(FileDownloadSIGRefDataProvider):
|
||||
latitude = float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None
|
||||
longitude = float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None
|
||||
altitude = float(row["AltM"]) if "AltM" in row and row["AltM"] != "" else None
|
||||
ref = SIGRef(
|
||||
sig=self.SIG,
|
||||
ref = ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=ref_id,
|
||||
name=row.get("SummitName", None),
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
ref_type=ActivityRefType.SUMMIT,
|
||||
url=f"https://www.sotadata.org.uk/en/summit/{ref_id}",
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
@@ -47,7 +47,7 @@ class SOTA(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import csv
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.local_file_sig_ref_data_provider import (
|
||||
LocalFileSIGRefDataProvider,
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.local_file_activity_ref_data_provider import (
|
||||
LocalFileActivityRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class Toilets(LocalFileSIGRefDataProvider):
|
||||
"""SIG ref data provider for Toilets on the Air"""
|
||||
class Toilets(LocalFileActivityRefDataProvider):
|
||||
"""Activity ref data provider for Toilets on the Air"""
|
||||
|
||||
SIG = "Toilets"
|
||||
ACTIVITY = ActivityName.TOILETS
|
||||
PATH = "datafiles/toilets.csv"
|
||||
|
||||
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):
|
||||
new_data = []
|
||||
@@ -23,11 +23,11 @@ class Toilets(LocalFileSIGRefDataProvider):
|
||||
dr = csv.DictReader(csv_data.splitlines())
|
||||
for row in dr:
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=row["ref"],
|
||||
name=row["ref"],
|
||||
ref_type=SIGRefType.TOILET,
|
||||
ref_type=ActivityRefType.TOILET,
|
||||
latitude=float(row["lat"]),
|
||||
longitude=float(row["lon"]),
|
||||
)
|
||||
@@ -1,33 +1,33 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||
FileDownloadActivityRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class Towers(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Towers on the Air"""
|
||||
class Towers(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Towers on the Air"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "Towers"
|
||||
ACTIVITY = ActivityName.TOWERS
|
||||
DATA_URL = "https://wwtota.com/servis/generate_csv.php?ref=&filter=all"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"):
|
||||
ref_id = row["Ref"]
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=ref_id,
|
||||
name=row.get("Nazev", None),
|
||||
ref_type=SIGRefType.TOWER,
|
||||
ref_type=ActivityRefType.TOWER,
|
||||
url=f"https://wwtota.com/seznam/karta_rozhledny.php?ref={ref_id}",
|
||||
grid=row["Lokator"] if "Lokator" in row and row["Lokator"] != "" else None,
|
||||
latitude=float(row["Lat"]) if "Lat" in row and row["Lat"] != "" else None,
|
||||
@@ -40,7 +40,7 @@ class Towers(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -4,24 +4,24 @@ from time import sleep
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||
FileDownloadActivityRefDataProvider,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WCA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for World Castles Award"""
|
||||
class WCA(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for World Castles Award"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "WCA"
|
||||
ACTIVITY = ActivityName.WCA
|
||||
DATA_URL = "https://polo.ham2k.com/data/activities/wca/all-castles.csv"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
@@ -42,11 +42,11 @@ class WCA(FileDownloadSIGRefDataProvider):
|
||||
logger.debug(f"Encountered dodgy formatting in WCA CSV, skipping location data for {ref_id}")
|
||||
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=ref_id,
|
||||
name=row.get("CLEAN NAME", None),
|
||||
ref_type=SIGRefType.CASTLE,
|
||||
ref_type=ActivityRefType.CASTLE,
|
||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
@@ -59,7 +59,7 @@ class WCA(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||
FileDownloadActivityRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class WOTA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Wainwrights on the Air"""
|
||||
class WOTA(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Wainwrights on the Air"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 365
|
||||
SIG = "WOTA"
|
||||
ACTIVITY = ActivityName.WOTA
|
||||
DATA_URL = "https://www.wota.org.uk/mapping/data/summits.json"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
@@ -29,12 +29,12 @@ class WOTA(FileDownloadSIGRefDataProvider):
|
||||
url = f"https://www.wota.org.uk/MM_LDO-{number + 214!s}"
|
||||
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=ref_id,
|
||||
name=feature["properties"]["title"],
|
||||
url=url,
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
ref_type=ActivityRefType.SUMMIT,
|
||||
grid=feature["properties"]["qthLocator"],
|
||||
latitude=feature["geometry"]["coordinates"][1],
|
||||
longitude=feature["geometry"]["coordinates"][0],
|
||||
@@ -47,7 +47,7 @@ class WOTA(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||
FileDownloadActivityRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class WWBOTA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Worldwide Bunkers on the Air"""
|
||||
class WWBOTA(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Worldwide Bunkers on the Air"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "WWBOTA"
|
||||
ACTIVITY = ActivityName.WWBOTA
|
||||
DATA_URL = "https://api.wwbota.org/bunkers/?format=CSV"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
|
||||
ref_id = row["Reference"]
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=ref_id,
|
||||
name=row.get("Name", None),
|
||||
ref_type=SIGRefType.BUNKER,
|
||||
ref_type=ActivityRefType.BUNKER,
|
||||
url=f"https://bunkerwiki.org/?s={ref_id}" if ref_id.startswith("B/G") else None,
|
||||
grid=row["Locator"] if "Locator" in row and row["Locator"] != "" else None,
|
||||
latitude=float(row["Lat"]) if "Lat" in row and row["Lat"] != "" else None,
|
||||
@@ -40,7 +40,7 @@ class WWBOTA(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||
FileDownloadActivityRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class WWFF(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Worldwide Flora & Fauna"""
|
||||
class WWFF(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for Worldwide Flora & Fauna"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "WWFF"
|
||||
ACTIVITY = ActivityName.WWFF
|
||||
DATA_URL = "https://wwff.co/wwff-data/wwff_directory.csv"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
|
||||
ref_id = row["reference"]
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=ref_id,
|
||||
name=row.get("name", None),
|
||||
ref_type=SIGRefType.PARK,
|
||||
ref_type=ActivityRefType.PARK,
|
||||
url=f"https://wwff.co/directory/?showRef={ref_id}",
|
||||
grid=row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None,
|
||||
latitude=float(row["latitude"])
|
||||
@@ -44,7 +44,7 @@ class WWFF(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -2,22 +2,22 @@ from time import sleep
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||
FileDownloadActivityRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class ZLOTA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for New Zealand on the Air"""
|
||||
class ZLOTA(FileDownloadActivityRefDataProvider):
|
||||
"""Activity ref data provider for New Zealand on the Air"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "ZLOTA"
|
||||
ACTIVITY = ActivityName.ZLOTA
|
||||
DATA_URL = "https://ontheair.nz/assets/assets.json"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
@@ -28,12 +28,12 @@ class ZLOTA(FileDownloadSIGRefDataProvider):
|
||||
latitude = ref["latitude"]
|
||||
longitude = ref["longitude"]
|
||||
try:
|
||||
ref_type = SIGRefType(ref["asset_type"].title().upper())
|
||||
ref_type = ActivityRefType(ref["asset_type"].title().upper())
|
||||
except ValueError:
|
||||
ref_type = None
|
||||
|
||||
new_ref = SIGRef(
|
||||
sig=self.SIG,
|
||||
new_ref = ActivityRef(
|
||||
sig=self.ACTIVITY,
|
||||
id=ref_id,
|
||||
name=ref["name"],
|
||||
ref_type=ref_type,
|
||||
@@ -58,7 +58,7 @@ class ZLOTA(FileDownloadSIGRefDataProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
@@ -3,9 +3,9 @@ from datetime import datetime, timedelta
|
||||
import pytz
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from core.enums import AlertType
|
||||
from core.enums import ActivityName
|
||||
from data.activity_ref import ActivityRef
|
||||
from data.alert import Alert
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||
|
||||
|
||||
@@ -56,9 +56,9 @@ class BOTA(HTTPAlertProvider):
|
||||
alert = Alert(
|
||||
source=self.name,
|
||||
dx_calls=[dx_call],
|
||||
sig_refs=[SIGRef(id=ref_name, sig="BOTA")],
|
||||
sig=ActivityName.BOTA,
|
||||
sig_refs=[ActivityRef(id=ref_name, sig=ActivityName.BOTA)],
|
||||
start_time=date_time.timestamp(),
|
||||
alert_type=AlertType.XOTA,
|
||||
)
|
||||
|
||||
new_alerts.append(alert)
|
||||
|
||||
@@ -2,9 +2,9 @@ from datetime import datetime
|
||||
|
||||
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.sig_ref import SIGRef
|
||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||
|
||||
|
||||
@@ -35,10 +35,11 @@ class Hamsat(HTTPAlertProvider):
|
||||
dx_calls=[source_alert["callsign"].upper()],
|
||||
freqs_modes=freqs_modes,
|
||||
comment=source_alert["comment"],
|
||||
# Fudge a SIG ref to provide the remaining bits of data we need: the satellite and the operator's grid
|
||||
sig=ActivityName.SATELLITE,
|
||||
# Fudge an activity ref to provide the remaining bits of data we need: the satellite and the operator's grid
|
||||
sig_refs=[
|
||||
SIGRef(
|
||||
sig="AMSAT",
|
||||
ActivityRef(
|
||||
sig=ActivityName.SATELLITE,
|
||||
id=f"{source_alert['satellite']['name']} from {source_alert['grids'][0]}",
|
||||
)
|
||||
],
|
||||
@@ -48,7 +49,6 @@ class Hamsat(HTTPAlertProvider):
|
||||
end_time=datetime.strptime(source_alert["los_at"], "%Y-%m-%dT%H:%M:%SZ")
|
||||
.replace(tzinfo=pytz.UTC)
|
||||
.timestamp(),
|
||||
alert_type=AlertType.SATELLITE,
|
||||
)
|
||||
|
||||
# Add to our list
|
||||
|
||||
@@ -6,7 +6,7 @@ import pytz
|
||||
from rss_parser import Parser
|
||||
from rss_parser.models.rss import RSS
|
||||
|
||||
from core.enums import AlertType
|
||||
from core.enums import ActivityName
|
||||
from data.alert import Alert
|
||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||
|
||||
@@ -89,7 +89,7 @@ class NG3K(HTTPAlertProvider):
|
||||
comment=f"{by}; {comment}; {qsl_info}",
|
||||
start_time=start_timestamp,
|
||||
end_time=end_timestamp,
|
||||
alert_type=AlertType.DXPEDITION,
|
||||
sig=ActivityName.DXPEDITION,
|
||||
)
|
||||
|
||||
# Add to our list.
|
||||
|
||||
@@ -3,9 +3,9 @@ from datetime import datetime
|
||||
|
||||
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.sig_ref import SIGRef
|
||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -25,23 +25,19 @@ class ParksNPeaks(HTTPAlertProvider):
|
||||
# Iterate through source data
|
||||
for source_alert in http_response.json():
|
||||
# Calculate some things
|
||||
sig = source_alert["Class"].upper()
|
||||
activity = source_alert["Class"].upper()
|
||||
if " - " in source_alert["Location"]:
|
||||
split = source_alert["Location"].split(" - ")
|
||||
sig_ref = split[0]
|
||||
sig_ref_name = split[1]
|
||||
ref_id = split[0]
|
||||
ref_name = split[1]
|
||||
else:
|
||||
sig_ref = source_alert["WWFFID"]
|
||||
sig_ref_name = source_alert["Location"]
|
||||
ref_id = source_alert["WWFFID"]
|
||||
ref_name = source_alert["Location"]
|
||||
start_time = (
|
||||
datetime.strptime(source_alert["alTime"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp()
|
||||
)
|
||||
|
||||
sigrefs = []
|
||||
# PnP can give us an alert of class "QRP" which is the only one that's not a real SIG in Spothole's list,
|
||||
# so mask this out if we got it.
|
||||
if sig != "QRP":
|
||||
sigrefs = [SIGRef(id=sig_ref, sig=sig, name=sig_ref_name)]
|
||||
activity_refs = [ActivityRef(id=ref_id, sig=activity, name=ref_name)]
|
||||
|
||||
# Convert to our alert format
|
||||
alert = Alert(
|
||||
@@ -50,29 +46,29 @@ class ParksNPeaks(HTTPAlertProvider):
|
||||
dx_calls=[source_alert["CallSign"].upper()],
|
||||
freqs_modes=f"{source_alert['Freq']} {source_alert['MODE']}",
|
||||
comment=source_alert["Comments"],
|
||||
sig_refs=sigrefs,
|
||||
sig=activity,
|
||||
sig_refs=activity_refs,
|
||||
start_time=start_time,
|
||||
alert_type=AlertType.XOTA,
|
||||
)
|
||||
|
||||
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before
|
||||
if sig and sig not in [
|
||||
"POTA",
|
||||
"SOTA",
|
||||
"WWFF",
|
||||
"HEMA",
|
||||
"SIOTA",
|
||||
"ZLOTA",
|
||||
"KRMNPA",
|
||||
"SANPCPA",
|
||||
"LLOTA",
|
||||
"QRP",
|
||||
if activity and activity not in [
|
||||
ActivityName.POTA,
|
||||
ActivityName.SOTA,
|
||||
ActivityName.WWFF,
|
||||
ActivityName.HEMA,
|
||||
ActivityName.SIOTA,
|
||||
ActivityName.ZLOTA,
|
||||
ActivityName.KRMNPA,
|
||||
ActivityName.SANPCPA,
|
||||
ActivityName.LLOTA,
|
||||
ActivityName.QRP,
|
||||
]:
|
||||
logger.warning(f"PNP alert found with sig {sig}, developer needs to add support for this!")
|
||||
logger.warning(f"PNP alert found with activity {activity}, developer needs to add support for this!")
|
||||
|
||||
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. Otherwise, add to
|
||||
# the alert list. Note that while ZLOTA has its own spots API, it doesn't have its own alerts API. So that
|
||||
# means the PnP *spot* provider rejects ZLOTA spots here, but the PnP *alerts* provider here allows ZLOTA.
|
||||
if sig not in ["POTA", "SOTA", "WWFF"]:
|
||||
if activity not in [ActivityName.POTA, ActivityName.SOTA, ActivityName.WWFF]:
|
||||
new_alerts.append(alert)
|
||||
return new_alerts
|
||||
|
||||
@@ -2,9 +2,9 @@ from datetime import datetime
|
||||
|
||||
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.sig_ref import SIGRef
|
||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||
|
||||
|
||||
@@ -28,10 +28,11 @@ class POTA(HTTPAlertProvider):
|
||||
dx_calls=[source_alert["activator"].upper()],
|
||||
freqs_modes=source_alert["frequencies"],
|
||||
comment=source_alert["comments"],
|
||||
sig=ActivityName.POTA,
|
||||
sig_refs=[
|
||||
SIGRef(
|
||||
ActivityRef(
|
||||
id=source_alert["reference"],
|
||||
sig="POTA",
|
||||
sig=ActivityName.POTA,
|
||||
name=source_alert["name"],
|
||||
url=f"https://pota.app/#/park/{source_alert['reference']}",
|
||||
)
|
||||
@@ -45,7 +46,6 @@ class POTA(HTTPAlertProvider):
|
||||
end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"], "%Y-%m-%d%H:%M")
|
||||
.replace(tzinfo=pytz.UTC)
|
||||
.timestamp(),
|
||||
alert_type=AlertType.XOTA,
|
||||
)
|
||||
|
||||
# Add to our list, but exclude any old spots that POTA can sometimes give us where even the end time is
|
||||
|
||||
@@ -2,7 +2,7 @@ import re
|
||||
|
||||
from icalendar import Event
|
||||
|
||||
from core.enums import AlertType, Continent
|
||||
from core.enums import ActivityName, Continent
|
||||
from data.alert import Alert
|
||||
from providers.alert.ical_alert_provider import ICALAlertProvider
|
||||
|
||||
@@ -69,7 +69,7 @@ class RSGBICALAlertProvider(ICALAlertProvider):
|
||||
comment=summary,
|
||||
start_time=start_timestamp,
|
||||
end_time=end_timestamp,
|
||||
alert_type=AlertType.CONTEST,
|
||||
sig=ActivityName.CONTEST,
|
||||
)
|
||||
|
||||
return alert
|
||||
|
||||
@@ -2,9 +2,9 @@ from datetime import datetime
|
||||
|
||||
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.sig_ref import SIGRef
|
||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||
|
||||
|
||||
@@ -34,10 +34,11 @@ class SOTA(HTTPAlertProvider):
|
||||
dx_names=[source_alert["activatorName"].upper()],
|
||||
freqs_modes=source_alert["frequency"],
|
||||
comment=source_alert["comments"],
|
||||
sig=ActivityName.SOTA,
|
||||
sig_refs=[
|
||||
SIGRef(
|
||||
ActivityRef(
|
||||
id=f"{source_alert['associationCode']}/{source_alert['summitCode']}",
|
||||
sig="SOTA",
|
||||
sig=ActivityName.SOTA,
|
||||
name=summit_name,
|
||||
activation_score=summit_points,
|
||||
)
|
||||
@@ -45,7 +46,6 @@ class SOTA(HTTPAlertProvider):
|
||||
start_time=datetime.strptime(source_alert["dateActivated"], "%Y-%m-%dT%H:%M:%SZ")
|
||||
.replace(tzinfo=pytz.UTC)
|
||||
.timestamp(),
|
||||
alert_type=AlertType.XOTA,
|
||||
)
|
||||
|
||||
# Add to our list
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from icalendar import Event
|
||||
|
||||
from core.enums import AlertType
|
||||
from core.enums import ActivityName
|
||||
from data.alert import Alert
|
||||
from providers.alert.ical_alert_provider import ICALAlertProvider
|
||||
|
||||
@@ -35,7 +35,7 @@ class WA7BNM(ICALAlertProvider):
|
||||
url=url,
|
||||
start_time=start_timestamp,
|
||||
end_time=end_timestamp,
|
||||
alert_type=AlertType.CONTEST,
|
||||
sig=ActivityName.CONTEST,
|
||||
)
|
||||
|
||||
return alert
|
||||
|
||||
@@ -7,8 +7,9 @@ import pytz
|
||||
from rss_parser import Parser as RSSParser
|
||||
from rss_parser.models.rss import RSS
|
||||
|
||||
from core.enums import ActivityName
|
||||
from data.activity_ref import ActivityRef
|
||||
from data.alert import Alert
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -52,7 +53,7 @@ class WOTA(HTTPAlertProvider):
|
||||
if len(ref_split) > 1:
|
||||
ref_name = str(ref_split[1])
|
||||
except Exception:
|
||||
logger.warning(f"Could not parse WOTA alert title: {source_alert.description}")
|
||||
logger.warning(f"Could not parse WOTA alert title: {source_alert.description}", exc_info=True)
|
||||
|
||||
# Pick apart the description
|
||||
comment = None
|
||||
@@ -63,7 +64,7 @@ class WOTA(HTTPAlertProvider):
|
||||
if len(desc_split) > 1:
|
||||
comment = desc_split[1].strip()
|
||||
except Exception:
|
||||
logger.warning(f"Could not parse WOTA alert description: {source_alert.description}")
|
||||
logger.warning(f"Could not parse WOTA alert description: {source_alert.description}", exc_info=True)
|
||||
|
||||
time = datetime.strptime(source_alert.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
|
||||
|
||||
@@ -74,7 +75,7 @@ class WOTA(HTTPAlertProvider):
|
||||
dx_calls=[dx_call],
|
||||
freqs_modes=freqs_modes,
|
||||
comment=comment,
|
||||
sig_refs=[SIGRef(id=ref, sig="WOTA", name=ref_name)] if ref else [],
|
||||
sig_refs=[ActivityRef(id=ref, sig=ActivityName.WOTA, name=ref_name)] if ref else [],
|
||||
start_time=time.timestamp(),
|
||||
)
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ from datetime import datetime
|
||||
|
||||
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.sig_ref import SIGRef
|
||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||
|
||||
|
||||
@@ -28,14 +28,14 @@ class WWFF(HTTPAlertProvider):
|
||||
dx_calls=[source_alert["activator_call"].upper()],
|
||||
freqs_modes=f"{source_alert['band']} {source_alert['mode']}",
|
||||
comment=source_alert["remarks"],
|
||||
sig_refs=[SIGRef(id=source_alert["reference"], sig="WWFF")],
|
||||
sig=ActivityName.WWFF,
|
||||
sig_refs=[ActivityRef(id=source_alert["reference"], sig=ActivityName.WWFF)],
|
||||
start_time=datetime.strptime(source_alert["utc_start"], "%Y-%m-%d %H:%M:%S")
|
||||
.replace(tzinfo=pytz.UTC)
|
||||
.timestamp(),
|
||||
end_time=datetime.strptime(source_alert["utc_end"], "%Y-%m-%d %H:%M:%S")
|
||||
.replace(tzinfo=pytz.UTC)
|
||||
.timestamp(),
|
||||
alert_type=AlertType.XOTA,
|
||||
)
|
||||
|
||||
# Add to our list
|
||||
|
||||
@@ -61,7 +61,7 @@ class HamQTH(APIQueryCallsignDataProvider):
|
||||
logger.debug("HamQTH login details incorrect, failed to look up with HamQTH.")
|
||||
return None
|
||||
except Exception:
|
||||
logger.error("Exception when getting HamQTH session key")
|
||||
logger.exception("Exception when getting HamQTH session key")
|
||||
return None
|
||||
|
||||
if not session_id:
|
||||
|
||||
@@ -57,7 +57,7 @@ class QRZ(APIQueryCallsignDataProvider):
|
||||
logger.debug("QRZ.com login details incorrect, failed to look up with QRZ.")
|
||||
return None
|
||||
except Exception:
|
||||
logger.error("Exception when getting QRZ.com session key")
|
||||
logger.exception("Exception when getting QRZ.com session key")
|
||||
return None
|
||||
|
||||
if not session_key:
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
from providers.sigrefdata.pnp_kml_sig_ref_data_provider import (
|
||||
ParksNPeaksKMLSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class KRMNPA(ParksNPeaksKMLSIGRefDataProvider):
|
||||
"""SIG ref data provider for the Keith Roget Memorrial National Parks Award (KRMNPA)."""
|
||||
|
||||
POLL_INTERVAL_DAYS = 365
|
||||
SIG = "KRMNPA"
|
||||
DATA_URL = "https://parksnpeaks.org/getPOI.php?poiClass=KRMNPA&poiFormat=4"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
@@ -1,36 +0,0 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from providers.sigrefdata.sig_ref_data_provider import SIGRefDataProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocalFileSIGRefDataProvider(SIGRefDataProvider):
|
||||
"""Generic SIG ref data provider class for providers that fetch their data from a local file on startup."""
|
||||
|
||||
def __init__(self, sig, provider_config, path):
|
||||
super().__init__(sig, provider_config)
|
||||
self._path = path
|
||||
|
||||
def start(self):
|
||||
logger.debug(f"Loading {self.sig_name} SIG ref data from file.")
|
||||
try:
|
||||
new_data = self._file_to_data(self._path)
|
||||
if new_data:
|
||||
self._add_data(new_data)
|
||||
self.status = "OK"
|
||||
self.last_update_time = datetime.now(pytz.UTC)
|
||||
else:
|
||||
self.status = "Error"
|
||||
logger.info(f"Failed to load SIG ref data for {self.sig_name}")
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logger.exception(f"Exception in local file SIG Ref Data Provider ({self.sig_name})")
|
||||
|
||||
def _file_to_data(self, path):
|
||||
"""Load a file on the given path and turn it into SIG Ref data."""
|
||||
|
||||
raise NotImplementedError("Subclasses must implement this method")
|
||||
@@ -1,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)
|
||||
@@ -118,7 +118,9 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
|
||||
column_dates = []
|
||||
for month_str, day_str in date_matches:
|
||||
try:
|
||||
column_dates.append(datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").date())
|
||||
column_dates.append(
|
||||
datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").replace(tzinfo=timezone.utc).date()
|
||||
)
|
||||
except ValueError:
|
||||
logger.warning(f"NOAA K-index forecast: could not parse date: {month_str} {day_str} {year}")
|
||||
return None
|
||||
|
||||
@@ -84,7 +84,7 @@ class DXCluster(SpotProvider):
|
||||
telnet_output = self._telnet.read_until("\n".encode("latin-1"))
|
||||
match = self._spot_line_pattern.match(telnet_output.decode("latin-1"))
|
||||
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(
|
||||
datetime.now(pytz.UTC).date(),
|
||||
spot_time.time(),
|
||||
|
||||
+29
-29
@@ -4,9 +4,9 @@ from datetime import datetime
|
||||
import pytz
|
||||
|
||||
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 data.sig_ref import SIGRef
|
||||
from data.activity_ref import ActivityRef
|
||||
from data.spot import Spot
|
||||
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,
|
||||
comment=source_spot["TEXT"],
|
||||
sig_refs=[
|
||||
SIGRef(
|
||||
ActivityRef(
|
||||
id=source_spot["REF"],
|
||||
sig="",
|
||||
name=source_spot["NAME"],
|
||||
@@ -83,7 +83,7 @@ class GMA(HTTPSpotProvider):
|
||||
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:
|
||||
try:
|
||||
ref_response = self._url_data_cache.get(
|
||||
@@ -106,40 +106,40 @@ class GMA(HTTPSpotProvider):
|
||||
spot.sig_refs
|
||||
and ref_info
|
||||
and "reftype" in ref_info
|
||||
and ref_info["reftype"] not in ["POTA", "WWFF"]
|
||||
and ref_info["reftype"] not in [ActivityName.POTA, ActivityName.WWFF]
|
||||
and (
|
||||
ref_info["reftype"] != "Summit" or "sota" not in ref_info or ref_info["sota"] == ""
|
||||
)
|
||||
):
|
||||
match ref_info["reftype"]:
|
||||
case "Summit":
|
||||
spot.sig_refs[0].sig = "GMA"
|
||||
spot.sig_refs[0].ref_type = SIGRefType.SUMMIT
|
||||
spot.sig = "GMA"
|
||||
spot.sig_refs[0].sig = ActivityName.GMA
|
||||
spot.sig_refs[0].ref_type = ActivityRefType.SUMMIT
|
||||
spot.sig = ActivityName.GMA
|
||||
case "IOTA Island":
|
||||
spot.sig_refs[0].sig = "IOTA"
|
||||
spot.sig_refs[0].ref_type = SIGRefType.ISLAND
|
||||
spot.sig = "IOTA"
|
||||
spot.sig_refs[0].sig = ActivityName.IOTA
|
||||
spot.sig_refs[0].ref_type = ActivityRefType.ISLAND
|
||||
spot.sig = ActivityName.IOTA
|
||||
case "GMA Island":
|
||||
spot.sig_refs[0].sig = "GMA Islands"
|
||||
spot.sig_refs[0].ref_type = SIGRefType.ISLAND
|
||||
spot.sig = "GMA Islands"
|
||||
spot.sig_refs[0].sig = ActivityName.GMA_ISLANDS
|
||||
spot.sig_refs[0].ref_type = ActivityRefType.ISLAND
|
||||
spot.sig = ActivityName.GMA_ISLANDS
|
||||
case "Lighthouse (ILLW)":
|
||||
spot.sig_refs[0].sig = "ILLW"
|
||||
spot.sig_refs[0].ref_type = SIGRefType.LIGHTHOUSE
|
||||
spot.sig = "ILLW"
|
||||
spot.sig_refs[0].sig = ActivityName.ILLW
|
||||
spot.sig_refs[0].ref_type = ActivityRefType.LIGHTHOUSE
|
||||
spot.sig = ActivityName.ILLW
|
||||
case "Lighthouse (ARLHS)":
|
||||
spot.sig_refs[0].sig = "ARLHS"
|
||||
spot.sig_refs[0].ref_type = SIGRefType.LIGHTHOUSE
|
||||
spot.sig = "ARLHS"
|
||||
spot.sig_refs[0].sig = ActivityName.ARLHS
|
||||
spot.sig_refs[0].ref_type = ActivityRefType.LIGHTHOUSE
|
||||
spot.sig = ActivityName.ARLHS
|
||||
case "Castle":
|
||||
spot.sig_refs[0].sig = "WCA"
|
||||
spot.sig_refs[0].ref_type = SIGRefType.CASTLE
|
||||
spot.sig = "WCA"
|
||||
spot.sig_refs[0].sig = ActivityName.WCA
|
||||
spot.sig_refs[0].ref_type = ActivityRefType.CASTLE
|
||||
spot.sig = ActivityName.WCA
|
||||
case "Mill":
|
||||
spot.sig_refs[0].sig = "MOTA"
|
||||
spot.sig_refs[0].ref_type = SIGRefType.MILL
|
||||
spot.sig = "MOTA"
|
||||
spot.sig_refs[0].sig = ActivityName.MOTA
|
||||
spot.sig_refs[0].ref_type = ActivityRefType.MILL
|
||||
spot.sig = ActivityName.MOTA
|
||||
case _:
|
||||
logger.warning(
|
||||
f"GMA spot found with ref type {ref_info['reftype']}, developer needs to add support for this!"
|
||||
@@ -158,7 +158,7 @@ class GMA(HTTPSpotProvider):
|
||||
)
|
||||
except 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;
|
||||
@@ -169,8 +169,8 @@ class GMA(HTTPSpotProvider):
|
||||
|
||||
return new_spots
|
||||
|
||||
def can_submit_spot(self, sig):
|
||||
return sig == "GMA"
|
||||
def can_submit_spot(self, activity):
|
||||
return activity == ActivityName.GMA
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
# TODO: Implement.
|
||||
|
||||
@@ -7,8 +7,8 @@ import requests
|
||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from core.enums import Mode, SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from core.enums import ActivityName, ActivityRefType, Mode
|
||||
from data.activity_ref import ActivityRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
|
||||
@@ -62,15 +62,15 @@ class HEMA(HTTPSpotProvider):
|
||||
freq=float(freq_mode_match.group(1)) * 1000000,
|
||||
mode=Mode.from_name(freq_mode_match.group(2).upper()),
|
||||
comment=spotter_comment_match.group(2),
|
||||
sig="HEMA",
|
||||
sig=ActivityName.HEMA,
|
||||
sig_refs=[
|
||||
SIGRef(
|
||||
ActivityRef(
|
||||
id=spot_items[3].upper(),
|
||||
sig="HEMA",
|
||||
sig=ActivityName.HEMA,
|
||||
name=spot_items[4],
|
||||
latitude=float(spot_items[7]),
|
||||
longitude=float(spot_items[8]),
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
ref_type=ActivityRefType.SUMMIT,
|
||||
)
|
||||
],
|
||||
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.")
|
||||
return new_spots
|
||||
|
||||
def can_submit_spot(self, sig):
|
||||
return sig == "HEMA"
|
||||
def can_submit_spot(self, activity):
|
||||
return activity == ActivityName.HEMA
|
||||
|
||||
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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime
|
||||
|
||||
from core.enums import Mode, SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from core.enums import ActivityName, ActivityRefType, Mode
|
||||
from data.activity_ref import ActivityRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
|
||||
@@ -34,13 +34,13 @@ class LLOTA(HTTPSpotProvider):
|
||||
freq=float(source_spot["frequency"]) * 1000000,
|
||||
mode=Mode.from_name(source_spot["mode"].upper()),
|
||||
comment=comment,
|
||||
sig="LLOTA",
|
||||
sig=ActivityName.LLOTA,
|
||||
sig_refs=[
|
||||
SIGRef(
|
||||
ActivityRef(
|
||||
id=source_spot["reference"],
|
||||
sig="LLOTA",
|
||||
sig=ActivityName.LLOTA,
|
||||
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(),
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import ClassVar
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from core.enums import Mode
|
||||
from data.sig_ref import SIGRef
|
||||
from core.enums import ActivityName, Mode
|
||||
from data.activity_ref import ActivityRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
|
||||
@@ -20,16 +21,16 @@ class ParksNPeaks(HTTPSpotProvider):
|
||||
POLL_INTERVAL_SEC = 120
|
||||
SPOTS_URL = "https://www.parksnpeaks.org/api/ALL"
|
||||
SUBMIT_URL = "https://www.parksnpeaks.org/api/SPOT/"
|
||||
SUBMITTABLE_SIGS = [
|
||||
"POTA",
|
||||
"SOTA",
|
||||
"WWFF",
|
||||
"HEMA",
|
||||
"WOTA",
|
||||
"ZLOTA",
|
||||
"SIOTA",
|
||||
"KRMNPA",
|
||||
"SANPCPA",
|
||||
SUBMITTABLE_ACTIVITIES: ClassVar[list[ActivityName]] = [
|
||||
ActivityName.POTA,
|
||||
ActivityName.SOTA,
|
||||
ActivityName.WWFF,
|
||||
ActivityName.HEMA,
|
||||
ActivityName.WOTA,
|
||||
ActivityName.ZLOTA,
|
||||
ActivityName.SIOTA,
|
||||
ActivityName.KRMNPA,
|
||||
ActivityName.SANPCPA,
|
||||
]
|
||||
|
||||
def __init__(self, provider_config):
|
||||
@@ -64,44 +65,45 @@ class ParksNPeaks(HTTPSpotProvider):
|
||||
if not spot.de_call and m:
|
||||
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
|
||||
# defined set of references
|
||||
sig = source_spot["actClass"].upper()
|
||||
sig_ref = source_spot["actSiteID"]
|
||||
if sig and sig != "" and sig != "QRP" and sig_ref and sig_ref != "":
|
||||
spot.sig = sig
|
||||
sig_refs = [
|
||||
SIGRef(
|
||||
id=source_spot["actSiteID"],
|
||||
sig=source_spot["actClass"].upper(),
|
||||
)
|
||||
]
|
||||
spot.sig_refs = sig_refs
|
||||
# Record activity information
|
||||
activity = source_spot["actClass"].upper()
|
||||
ref_id = source_spot["actSiteID"]
|
||||
if activity:
|
||||
spot.sig = activity
|
||||
activity_refs = []
|
||||
if ref_id:
|
||||
activity_refs = [
|
||||
ActivityRef(
|
||||
id=source_spot["actSiteID"],
|
||||
sig=activity,
|
||||
)
|
||||
]
|
||||
spot.sig_refs = activity_refs
|
||||
|
||||
# Free text location is not present in all spots, so only add it if it's set
|
||||
if "actLocation" in source_spot and source_spot["actLocation"] != "":
|
||||
sig_refs[0].name = source_spot["actLocation"]
|
||||
activity_refs[0].name = source_spot["actLocation"]
|
||||
|
||||
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before
|
||||
if sig not in [
|
||||
"POTA",
|
||||
"SOTA",
|
||||
"WWFF",
|
||||
"HEMA",
|
||||
"SIOTA",
|
||||
"ZLOTA",
|
||||
"KRMNPA",
|
||||
"SANPCPA",
|
||||
"LLOTA",
|
||||
if activity not in [
|
||||
ActivityName.POTA,
|
||||
ActivityName.SOTA,
|
||||
ActivityName.WWFF,
|
||||
ActivityName.HEMA,
|
||||
ActivityName.SIOTA,
|
||||
ActivityName.ZLOTA,
|
||||
ActivityName.KRMNPA,
|
||||
ActivityName.SANPCPA,
|
||||
ActivityName.LLOTA,
|
||||
]:
|
||||
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
|
||||
new_spots.append(spot)
|
||||
return new_spots
|
||||
|
||||
def can_submit_spot(self, sig):
|
||||
return sig in self.SUBMITTABLE_SIGS
|
||||
def can_submit_spot(self, activity):
|
||||
return activity in self.SUBMITTABLE_ACTIVITIES
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
# TODO test this works
|
||||
@@ -111,11 +113,11 @@ class ParksNPeaks(HTTPSpotProvider):
|
||||
raise ValueError(
|
||||
"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 = {
|
||||
"actClass": spot.sig or "",
|
||||
"actCallsign": spot.dx_call,
|
||||
"actSite": sig_ref,
|
||||
"actSite": ref_id,
|
||||
"mode": spot.mode or "",
|
||||
"freq": str(spot.freq / 1000000.0),
|
||||
"comments": spot.comment or "",
|
||||
|
||||
@@ -4,8 +4,8 @@ import pytz
|
||||
import requests
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from core.enums import Mode, SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from core.enums import ActivityName, ActivityRefType, Mode
|
||||
from data.activity_ref import ActivityRef
|
||||
from data.spot import Spot
|
||||
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,
|
||||
mode=Mode.from_name(source_spot["mode"].upper()),
|
||||
comment=source_spot["comments"],
|
||||
sig="POTA",
|
||||
sig=ActivityName.POTA,
|
||||
sig_refs=[
|
||||
SIGRef(
|
||||
ActivityRef(
|
||||
id=source_spot["reference"],
|
||||
sig="POTA",
|
||||
sig=ActivityName.POTA,
|
||||
name=source_spot["name"],
|
||||
latitude=source_spot["latitude"],
|
||||
longitude=source_spot["longitude"],
|
||||
ref_type=SIGRefType.PARK
|
||||
ref_type=ActivityRefType.PARK
|
||||
)
|
||||
],
|
||||
time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S")
|
||||
@@ -57,8 +57,8 @@ class POTA(HTTPSpotProvider):
|
||||
new_spots.append(spot)
|
||||
return new_spots
|
||||
|
||||
def can_submit_spot(self, sig):
|
||||
return sig == "POTA"
|
||||
def can_submit_spot(self, activity):
|
||||
return activity == ActivityName.POTA
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
sig_ref = spot.sig_refs[0].id if spot.sig_refs else None
|
||||
|
||||
@@ -69,7 +69,7 @@ class RBN(SpotProvider):
|
||||
telnet_output = self._telnet.read_until("\n".encode("latin-1"))
|
||||
match = self._LINE_PATTERN.match(telnet_output.decode("latin-1"))
|
||||
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(
|
||||
datetime.now(pytz.UTC).date(),
|
||||
spot_time.time(),
|
||||
|
||||
+10
-9
@@ -1,12 +1,13 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import ClassVar
|
||||
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from core.enums import Mode, SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from core.enums import ActivityName, ActivityRefType, Mode
|
||||
from data.activity_ref import ActivityRef
|
||||
from data.spot import Spot
|
||||
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"
|
||||
|
||||
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):
|
||||
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!
|
||||
mode=Mode.from_name(source_spot["mode"].upper()),
|
||||
comment=source_spot["comments"],
|
||||
sig="SOTA",
|
||||
sig=ActivityName.SOTA,
|
||||
sig_refs=[
|
||||
SIGRef(
|
||||
ActivityRef(
|
||||
id=source_spot["summitCode"],
|
||||
sig="SOTA",
|
||||
sig=ActivityName.SOTA,
|
||||
name=source_spot["summitName"],
|
||||
latitude=source_spot["latitude"],
|
||||
longitude=source_spot["longitude"],
|
||||
activation_score=source_spot["points"],
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
ref_type=ActivityRefType.SUMMIT,
|
||||
)
|
||||
],
|
||||
dx_latitude=source_spot["latitude"],
|
||||
@@ -82,8 +83,8 @@ class SOTA(HTTPSpotProvider):
|
||||
logger.warning("Timeout when accessing SOTA spots API.")
|
||||
return new_spots
|
||||
|
||||
def can_submit_spot(self, sig):
|
||||
return sig == "SOTA"
|
||||
def can_submit_spot(self, activity):
|
||||
return activity == ActivityName.SOTA
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
# TODO test this method works
|
||||
|
||||
@@ -60,8 +60,8 @@ class SpotProvider:
|
||||
|
||||
raise NotImplementedError("Subclasses must implement this method")
|
||||
|
||||
def can_submit_spot(self, sig):
|
||||
"""Return True if this provider supports submitting spots upstream for the given SIG."""
|
||||
def can_submit_spot(self, activity):
|
||||
"""Return True if this provider supports submitting spots upstream for the given activity."""
|
||||
|
||||
return False
|
||||
|
||||
|
||||
+11
-10
@@ -1,11 +1,12 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import ClassVar
|
||||
|
||||
import requests
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from core.enums import LocationSourceForSpot, Mode, SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from core.enums import ActivityName, ActivityRefType, LocationSourceForSpot, Mode
|
||||
from data.activity_ref import ActivityRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
|
||||
@@ -18,7 +19,7 @@ class Tiles(HTTPSpotProvider):
|
||||
POLL_INTERVAL_SEC = 120
|
||||
SPOTS_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/spots?active_hours=24"
|
||||
SUBMIT_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/self-spot"
|
||||
VALID_MODES = [
|
||||
VALID_MODES: ClassVar[list[str]] = [
|
||||
"SSB",
|
||||
"CW",
|
||||
"FT8",
|
||||
@@ -58,17 +59,17 @@ class Tiles(HTTPSpotProvider):
|
||||
freq=freq,
|
||||
mode=Mode.from_name(source_spot["mode"].upper()),
|
||||
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.
|
||||
# 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=[
|
||||
SIGRef(
|
||||
ActivityRef(
|
||||
id=source_spot["maidenhead_grid"],
|
||||
sig="Tiles",
|
||||
sig=ActivityName.TILES,
|
||||
name=source_spot["maidenhead_grid"],
|
||||
latitude=source_spot["latitude"],
|
||||
longitude=source_spot["longitude"],
|
||||
ref_type=SIGRefType.GRID
|
||||
ref_type=ActivityRefType.GRID
|
||||
)
|
||||
],
|
||||
time=datetime.fromisoformat(source_spot["created_at"].replace("Z", "+00:00")).timestamp(),
|
||||
@@ -83,8 +84,8 @@ class Tiles(HTTPSpotProvider):
|
||||
new_spots.append(spot)
|
||||
return new_spots
|
||||
|
||||
def can_submit_spot(self, sig):
|
||||
return sig == "Tiles"
|
||||
def can_submit_spot(self, activity):
|
||||
return activity == ActivityName.TILES
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
# Tiles on the air currently only supports *self* spots
|
||||
|
||||
@@ -3,8 +3,8 @@ from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
|
||||
@@ -34,8 +34,8 @@ class Towers(HTTPSpotProvider):
|
||||
dx_call=source_spot["call"].upper(),
|
||||
freq=likely_freq,
|
||||
comment=source_spot["comment"],
|
||||
sig="Towers",
|
||||
sig_refs=[SIGRef(id=source_spot["ref"], sig="Towers", ref_type=SIGRefType.TOWER)],
|
||||
sig=ActivityName.TOWERS,
|
||||
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")
|
||||
.replace(tzinfo=pytz.utc)
|
||||
.timestamp(),
|
||||
|
||||
@@ -81,7 +81,7 @@ class WebsocketSpotProvider(SpotProvider):
|
||||
self._ws.close()
|
||||
except Exception:
|
||||
# No problem, we were getting rid of this object anyway.
|
||||
pass
|
||||
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
|
||||
|
||||
+14
-8
@@ -8,8 +8,8 @@ import pytz
|
||||
from rss_parser import Parser
|
||||
from rss_parser.models.rss import RSS
|
||||
|
||||
from core.enums import Mode, SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from core.enums import ActivityName, ActivityRefType, Mode
|
||||
from data.activity_ref import ActivityRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
|
||||
@@ -54,7 +54,7 @@ class WOTA(HTTPSpotProvider):
|
||||
if len(ref_split) > 1:
|
||||
ref_name = str(ref_split[1])
|
||||
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
|
||||
freq_hz = None
|
||||
@@ -75,7 +75,9 @@ class WOTA(HTTPSpotProvider):
|
||||
if len(desc_split) > 2:
|
||||
spotter = desc_split[2].replace("Spotted by ", "").replace(".", "").upper().strip()
|
||||
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(
|
||||
pytz.UTC
|
||||
@@ -90,8 +92,12 @@ class WOTA(HTTPSpotProvider):
|
||||
freq=freq_hz,
|
||||
mode=Mode.from_name(mode),
|
||||
comment=comment,
|
||||
sig="WOTA",
|
||||
sig_refs=[SIGRef(id=ref, sig="WOTA", name=ref_name, ref_type=SIGRefType.SUMMIT)] if ref else [],
|
||||
sig=ActivityName.WOTA,
|
||||
sig_refs=(
|
||||
[ActivityRef(id=ref, sig=ActivityName.WOTA, name=ref_name, ref_type=ActivityRefType.SUMMIT)]
|
||||
if ref
|
||||
else []
|
||||
),
|
||||
time=time.timestamp(),
|
||||
)
|
||||
|
||||
@@ -104,8 +110,8 @@ class WOTA(HTTPSpotProvider):
|
||||
|
||||
return new_spots
|
||||
|
||||
def can_submit_spot(self, sig):
|
||||
return sig == "WOTA"
|
||||
def can_submit_spot(self, activity):
|
||||
return activity == ActivityName.WOTA
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
# TODO Ask M5TEA if he's happy to share how this is done from his app
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from core.enums import Mode, SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from core.enums import ActivityName, ActivityRefType, Mode
|
||||
from data.activity_ref import ActivityRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.sse_spot_provider import SSESpotProvider
|
||||
|
||||
@@ -21,15 +21,15 @@ class WWBOTA(SSESpotProvider):
|
||||
# n-fer activations.
|
||||
refs = []
|
||||
for ref in source_spot["references"]:
|
||||
sigref = SIGRef(
|
||||
activity_ref = ActivityRef(
|
||||
id=ref["reference"],
|
||||
sig="WWBOTA",
|
||||
sig=ActivityName.WWBOTA,
|
||||
name=ref["name"],
|
||||
latitude=ref["lat"],
|
||||
longitude=ref["long"],
|
||||
ref_type=SIGRefType.BUNKER,
|
||||
ref_type=ActivityRefType.BUNKER,
|
||||
)
|
||||
refs.append(sigref)
|
||||
refs.append(activity_ref)
|
||||
|
||||
spot = Spot(
|
||||
source=self.name,
|
||||
@@ -38,7 +38,7 @@ class WWBOTA(SSESpotProvider):
|
||||
freq=float(source_spot["freq"]) * 1000000,
|
||||
mode=Mode.from_name(source_spot["mode"].upper()) if source_spot.get("mode") else None,
|
||||
comment=source_spot["comment"],
|
||||
sig="WWBOTA",
|
||||
sig=ActivityName.WWBOTA,
|
||||
sig_refs=refs,
|
||||
time=datetime.fromisoformat(source_spot["time"].replace("Z", "+00:00")).timestamp(),
|
||||
# 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.
|
||||
return spot if source_spot["type"] != "Test" else None
|
||||
|
||||
def can_submit_spot(self, sig):
|
||||
return sig == "WWBOTA"
|
||||
def can_submit_spot(self, activity):
|
||||
return activity == ActivityName.WWBOTA
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
# TODO: Implement. WWBOTA API docs cover this: https://api.wwbota.org/#tag/Spots/operation/create_spot_spots__post
|
||||
|
||||
@@ -2,8 +2,8 @@ from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from core.enums import Mode, SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from core.enums import ActivityName, ActivityRefType, Mode
|
||||
from data.activity_ref import ActivityRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
|
||||
@@ -30,15 +30,15 @@ class WWFF(HTTPSpotProvider):
|
||||
freq=float(source_spot["frequency_khz"]) * 1000,
|
||||
mode=Mode.from_name(source_spot["mode"].upper()),
|
||||
comment=source_spot["remarks"],
|
||||
sig="WWFF",
|
||||
sig=ActivityName.WWFF,
|
||||
sig_refs=[
|
||||
SIGRef(
|
||||
ActivityRef(
|
||||
id=source_spot["reference"],
|
||||
sig="WWFF",
|
||||
sig=ActivityName.WWFF,
|
||||
name=source_spot["reference_name"],
|
||||
latitude=source_spot["latitude"],
|
||||
longitude=source_spot["longitude"],
|
||||
ref_type=SIGRefType.PARK
|
||||
ref_type=ActivityRefType.PARK
|
||||
)
|
||||
],
|
||||
time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC).timestamp(),
|
||||
@@ -51,8 +51,8 @@ class WWFF(HTTPSpotProvider):
|
||||
new_spots.append(spot)
|
||||
return new_spots
|
||||
|
||||
def can_submit_spot(self, sig):
|
||||
return sig == "WWFF"
|
||||
def can_submit_spot(self, activity):
|
||||
return activity == ActivityName.WWFF
|
||||
|
||||
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
|
||||
|
||||
+13
-14
@@ -4,43 +4,42 @@ from datetime import datetime
|
||||
import pytz
|
||||
|
||||
from core.enums import Mode
|
||||
from data.sig_ref import SIGRef
|
||||
from data.activity_ref import ActivityRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.websocket_spot_provider import WebsocketSpotProvider
|
||||
|
||||
|
||||
class XOTA(WebsocketSpotProvider):
|
||||
"""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
|
||||
then use for lookups. This functionality is implemented for Toilets on the Air events, of which there are
|
||||
several - so a plain lookup of a "TOTA reference" doesn't make sense, it depends on which TOTA, which is why we also
|
||||
provide a sig_ref_prefix in our config. This is applied to the reference ID, so e.g. "T-01" at C3 might become
|
||||
"C3 T-01". This allows us to provide location lookups for TOTA at several conferences."""
|
||||
The provider typically doesn't give us a lat/lon or activity explicitly, so our own config provides an activity
|
||||
which we can then use for lookups. This functionality is implemented for Toilets on the Air events, of which
|
||||
there are several - so a plain lookup of a "TOTA reference" doesn't make sense, it depends on which TOTA, which
|
||||
is why we also provide a sig_ref_prefix in our config. This is applied to the reference ID, so e.g. "T-01" at C3
|
||||
might become "C3 T-01". This allows us to provide location lookups for TOTA at several conferences."""
|
||||
|
||||
LOCATION_DATA = {}
|
||||
SIG = None
|
||||
ACTIVITY = None
|
||||
|
||||
def __init__(self, provider_config):
|
||||
name = provider_config.get("name", "xOTA")
|
||||
super().__init__(name, provider_config, provider_config["url"])
|
||||
self.SIG = str(provider_config["sig"]) if "sig" in provider_config else None
|
||||
self._sig_ref_prefix = str(provider_config["sig_ref_prefix"]) if "sig_ref_prefix" in provider_config else ""
|
||||
self.ACTIVITY = str(provider_config["sig"]) if "sig" in provider_config else None
|
||||
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):
|
||||
string = b.decode("utf-8")
|
||||
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(
|
||||
source=self.name,
|
||||
source_id=source_spot["id"],
|
||||
dx_call=source_spot["stationCallSign"].upper(),
|
||||
freq=float(source_spot["freq"]) * 1000,
|
||||
mode=Mode.from_name(source_spot["mode"].upper()),
|
||||
sig=self.SIG,
|
||||
sig=self.ACTIVITY,
|
||||
sig_refs=[
|
||||
SIGRef(
|
||||
ActivityRef(
|
||||
id=ref_id,
|
||||
sig=self.SIG or "",
|
||||
sig=self.ACTIVITY or "",
|
||||
url=source_spot["reference"]["website"],
|
||||
)
|
||||
],
|
||||
|
||||
@@ -2,8 +2,8 @@ from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from core.enums import Mode
|
||||
from data.sig_ref import SIGRef
|
||||
from core.enums import ActivityName, Mode
|
||||
from data.activity_ref import ActivityRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
|
||||
@@ -35,11 +35,11 @@ class ZLOTA(HTTPSpotProvider):
|
||||
freq=freq_hz,
|
||||
mode=Mode.from_name(source_spot["mode"].upper().strip()),
|
||||
comment=source_spot["comments"],
|
||||
sig="ZLOTA",
|
||||
sig=ActivityName.ZLOTA,
|
||||
sig_refs=[
|
||||
SIGRef(
|
||||
ActivityRef(
|
||||
id=source_spot["reference"],
|
||||
sig="ZLOTA",
|
||||
sig=ActivityName.ZLOTA,
|
||||
name=source_spot["name"],
|
||||
)
|
||||
],
|
||||
@@ -51,8 +51,8 @@ class ZLOTA(HTTPSpotProvider):
|
||||
new_spots.append(spot)
|
||||
return new_spots
|
||||
|
||||
def can_submit_spot(self, sig):
|
||||
return sig == "ZLOTA"
|
||||
def can_submit_spot(self, activity):
|
||||
return activity == ActivityName.ZLOTA
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
# TODO: Implement. Spotting to ZLOTA is supported via POST, see https://ontheair.nz/api
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "spothole"
|
||||
version = "2.1.1"
|
||||
version = "2.2-pre"
|
||||
authors = [
|
||||
{ name = "Ian Renton", email = "ian@ianrenton.com" },
|
||||
]
|
||||
|
||||
+130
-107
@@ -14,7 +14,14 @@ info:
|
||||
Spothole's source code is located at https://git.ianrenton.com/ian/spothole and the README there provides setup instructions if you would like to run your own copy. A demonstration server of Spothole is located at https://spothole.app. The README also contains some examples of how you could query the API of the demonstration server to integrate the data into your own apps.
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2.2
|
||||
|
||||
* Renamed AMSAT SIG to "Satellite" as AMSAT is a specific organisation not just a general term for satellite QSOs
|
||||
* Removed `alert_type` from alert data. Contest, DXpedition and Satellite alerts now give those values in `sig` instead, alongside the existing outdoor activity programmes. Teeeeechnically a breaking change but AlertType is so new I doubt anyone is using it yet, so slipped this one in anyway. Sorry :)
|
||||
* Added QRP, RaDAR Rally, /AM and /MM activities
|
||||
* Added `has_refs` and `alerts_possible` to Activity data
|
||||
|
||||
### 2.1
|
||||
|
||||
* Added AMSAT, EME, DTMBA, FEA, BIWOTA, COTA & PGA SIGs
|
||||
@@ -109,7 +116,7 @@ tags:
|
||||
- name: General
|
||||
description: Server status and enumeration options.
|
||||
- name: Utilities
|
||||
description: Utility lookups for callsigns, SIG references, and Maidenhead grids.
|
||||
description: Utility lookups for callsigns, activity references, and Maidenhead grids.
|
||||
|
||||
paths:
|
||||
/spots:
|
||||
@@ -129,9 +136,9 @@ paths:
|
||||
- $ref: '#/components/parameters/SpotMaxAge'
|
||||
- $ref: '#/components/parameters/SpotReceivedSince'
|
||||
- $ref: '#/components/parameters/SpotSource'
|
||||
- $ref: '#/components/parameters/SpotSig'
|
||||
- $ref: '#/components/parameters/SpotNeedsSig'
|
||||
- $ref: '#/components/parameters/SpotNeedsSigRef'
|
||||
- $ref: '#/components/parameters/SpotActivity'
|
||||
- $ref: '#/components/parameters/SpotNeedsActivity'
|
||||
- $ref: '#/components/parameters/SpotNeedsActivityRef'
|
||||
- $ref: '#/components/parameters/SpotBand'
|
||||
- $ref: '#/components/parameters/SpotMode'
|
||||
- $ref: '#/components/parameters/SpotModeType'
|
||||
@@ -171,9 +178,9 @@ paths:
|
||||
operationId: spots-stream
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/SpotSource'
|
||||
- $ref: '#/components/parameters/SpotSig'
|
||||
- $ref: '#/components/parameters/SpotNeedsSig'
|
||||
- $ref: '#/components/parameters/SpotNeedsSigRef'
|
||||
- $ref: '#/components/parameters/SpotActivity'
|
||||
- $ref: '#/components/parameters/SpotNeedsActivity'
|
||||
- $ref: '#/components/parameters/SpotNeedsActivityRef'
|
||||
- $ref: '#/components/parameters/SpotBand'
|
||||
- $ref: '#/components/parameters/SpotMode'
|
||||
- $ref: '#/components/parameters/SpotModeType'
|
||||
@@ -218,7 +225,7 @@ paths:
|
||||
- $ref: '#/components/parameters/AlertDxpeditionsSkipMaxDurationCheck'
|
||||
- $ref: '#/components/parameters/AlertContestsSkipMaxDurationCheck'
|
||||
- $ref: '#/components/parameters/AlertSource'
|
||||
- $ref: '#/components/parameters/AlertSig'
|
||||
- $ref: '#/components/parameters/AlertActivity'
|
||||
- $ref: '#/components/parameters/AlertDxContinent'
|
||||
- $ref: '#/components/parameters/AlertDxCallIncludes'
|
||||
- $ref: '#/components/parameters/AlertTextIncludes'
|
||||
@@ -254,7 +261,7 @@ paths:
|
||||
- $ref: '#/components/parameters/AlertDxpeditionsSkipMaxDurationCheck'
|
||||
- $ref: '#/components/parameters/AlertContestsSkipMaxDurationCheck'
|
||||
- $ref: '#/components/parameters/AlertSource'
|
||||
- $ref: '#/components/parameters/AlertSig'
|
||||
- $ref: '#/components/parameters/AlertActivity'
|
||||
- $ref: '#/components/parameters/AlertDxContinent'
|
||||
- $ref: '#/components/parameters/AlertDxCallIncludes'
|
||||
- $ref: '#/components/parameters/AlertTextIncludes'
|
||||
@@ -392,24 +399,24 @@ paths:
|
||||
get:
|
||||
tags:
|
||||
- Utilities
|
||||
summary: Look up SIG ref details
|
||||
summary: Look up activity ref details
|
||||
description: >
|
||||
Perform a lookup of data about a certain reference, providing the SIG and the ID of the
|
||||
reference. A SIGRef structure will be returned containing the SIG and ID, plus any other
|
||||
Perform a lookup of data about a certain reference, providing the activity and the ID of the
|
||||
reference. An ActivityRef structure will be returned containing the activity and ID, plus any other
|
||||
information Spothole could find about it.
|
||||
operationId: sigref
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/SigRefSig'
|
||||
- $ref: '#/components/parameters/SigRefId'
|
||||
- $ref: '#/components/parameters/ActivityRefLookupActivity'
|
||||
- $ref: '#/components/parameters/ActivityRefLookupId'
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SIGRef'
|
||||
$ref: '#/components/schemas/ActivityRef'
|
||||
'422':
|
||||
description: Validation error e.g. SIG not supported or reference format incorrect
|
||||
description: Validation error e.g. activity not supported or reference format incorrect
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -452,7 +459,7 @@ paths:
|
||||
description: >
|
||||
Supply a JSON object containing a `spot` sub-object (the spot data) and an optional `handling` sub-object
|
||||
containing server-side instructions such as upstream submission). Check `spot_submit_providers` in the
|
||||
`/options` response to see which SIGs and providers support upstream submission. cURL example:
|
||||
`/options` response to see which activities and providers support upstream submission. cURL example:
|
||||
`curl --request POST --header \"Content-Type: application/json\" --data '{\"spot\":{\"dx_call\":\"M0TRT\",\"time\":1760019539,\"freq\":14200000,\"comment\":\"Test spot please ignore\",\"de_call\":\"M0TRT\"}}' https://spothole.app/api/v2/spot`"
|
||||
operationId: spot
|
||||
requestBody:
|
||||
@@ -550,37 +557,37 @@ components:
|
||||
named some sources like clusters differently, and provided multiple options.
|
||||
schema:
|
||||
$ref: "#/components/schemas/Source"
|
||||
SpotSig:
|
||||
SpotActivity:
|
||||
name: sig
|
||||
in: query
|
||||
description: >
|
||||
Limit the spots to only ones from one or more Special Interest Groups provided as an argument.
|
||||
To select more than one SIG, supply a comma-separated list. The special `sig` name `NO_SIG`
|
||||
matches spots with no sig set. You can use `sig=NO_SIG` to specifically only return generic
|
||||
spots with no associated SIG. You can also use combinations to request for example POTA + no
|
||||
SIG, but reject other SIGs. If you want to request 'every SIG and not No SIG', see the
|
||||
Limit the spots to only ones from one or more activities provided as an argument.
|
||||
To select more than one activity, supply a comma-separated list. The special `sig` name `NO_SIG`
|
||||
matches spots with no activity set. You can use `sig=NO_SIG` to specifically only return generic
|
||||
spots with no associated activity. You can also use combinations to request for example POTA + no
|
||||
activity, but reject other activities. If you want to request 'every activity and not No Activity', see the
|
||||
`needs_sig` query parameter for a shortcut.
|
||||
schema:
|
||||
$ref: "#/components/schemas/SIGNameIncludingNoSIG"
|
||||
SpotNeedsSig:
|
||||
$ref: "#/components/schemas/ActivityNameIncludingNoSig"
|
||||
SpotNeedsActivity:
|
||||
name: needs_sig
|
||||
in: query
|
||||
description: >
|
||||
Limit the spots to only ones with a Special Interest Group such as POTA. Because supplying all
|
||||
known SIGs as a `sigs` parameter is unwieldy, and leaving `sigs` blank will also return spots
|
||||
with *no* SIG, this parameter can be set true to return only spots with a SIG, regardless of
|
||||
Limit the spots to only ones with an activity such as POTA. Because supplying all
|
||||
known activities as a `sigs` parameter is unwieldy, and leaving `sigs` blank will also return spots
|
||||
with *no* activity, this parameter can be set true to return only spots with an activity, regardless of
|
||||
what it is, so long as it's not blank. This is the equivalent of supplying the `sig` query
|
||||
param with a list of every known SIG apart from the special `NO_SIG` value. This is what Field
|
||||
param with a list of every known activity apart from the special `NO_SIG` value. This is what Field
|
||||
Spotter uses to exclude generic cluster spots and only retrieve xOTA things.
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
SpotNeedsSigRef:
|
||||
SpotNeedsActivityRef:
|
||||
name: needs_sig_ref
|
||||
in: query
|
||||
description: >
|
||||
Limit the spots to only ones which have at least one reference (e.g. a park reference) for
|
||||
Special Interest Groups such as POTA.
|
||||
activities such as POTA.
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
@@ -714,15 +721,15 @@ components:
|
||||
comma-separated list.
|
||||
schema:
|
||||
$ref: "#/components/schemas/Source"
|
||||
AlertSig:
|
||||
AlertActivity:
|
||||
name: sig
|
||||
in: query
|
||||
description: >
|
||||
Limit the alerts to only ones from one or more Special Interest Groups. To select more than one
|
||||
SIG, supply a comma-separated list. The special value 'NO_SIG' can be included to return alerts
|
||||
specifically without an associated SIG (i.e. general DXpeditions).
|
||||
Limit the alerts to only ones from one or more activities. To select more than one
|
||||
activity, supply a comma-separated list. The special value 'NO_SIG' can be included to return alerts
|
||||
specifically without an associated activity (i.e. general DXpeditions).
|
||||
schema:
|
||||
$ref: "#/components/schemas/SIGNameIncludingNoSIG"
|
||||
$ref: "#/components/schemas/ActivityNameIncludingNoSig"
|
||||
AlertDxContinent:
|
||||
name: dx_continent
|
||||
in: query
|
||||
@@ -830,17 +837,17 @@ components:
|
||||
schema:
|
||||
type: string
|
||||
example: M0TRT
|
||||
SigRefSig:
|
||||
ActivityRefLookupActivity:
|
||||
name: sig
|
||||
in: query
|
||||
description: Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
|
||||
description: Activity, e.g. outdoor activity programme such as POTA (still named "sig" in the API for backwards compatibility)
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/components/schemas/SIGName"
|
||||
SigRefId:
|
||||
$ref: "#/components/schemas/ActivityName"
|
||||
ActivityRefLookupId:
|
||||
name: id
|
||||
in: query
|
||||
description: ID of a reference in that SIG
|
||||
description: ID of a reference in that activity
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
@@ -876,9 +883,16 @@ components:
|
||||
- UKPacketNet
|
||||
example: POTA
|
||||
|
||||
SIGName:
|
||||
ActivityName:
|
||||
type: string
|
||||
enum:
|
||||
- Contest
|
||||
- DXpedition
|
||||
- Satellite
|
||||
- EME
|
||||
- /AM
|
||||
- /MM
|
||||
- QRP
|
||||
- POTA
|
||||
- SOTA
|
||||
- WWFF
|
||||
@@ -914,22 +928,23 @@ components:
|
||||
- Toilets
|
||||
example: POTA
|
||||
|
||||
SIGType:
|
||||
ActivityType:
|
||||
type: string
|
||||
enum:
|
||||
- WORLDWIDE
|
||||
- TRADITIONAL
|
||||
- ADVENTURE
|
||||
- REGIONAL
|
||||
- EVENT
|
||||
example: WORLDWIDE
|
||||
example: TRADITIONAL
|
||||
|
||||
SIGNameIncludingNoSIG:
|
||||
ActivityNameIncludingNoSig:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SIGName"
|
||||
- $ref: "#/components/schemas/ActivityName"
|
||||
- type: string
|
||||
enum: [ NO_SIG ]
|
||||
example: POTA
|
||||
|
||||
SIGRefType:
|
||||
ActivityRefType:
|
||||
type: string
|
||||
enum:
|
||||
- PARK
|
||||
@@ -951,14 +966,6 @@ components:
|
||||
- TOILET
|
||||
example: PARK
|
||||
|
||||
AlertType:
|
||||
type: string
|
||||
enum:
|
||||
- XOTA
|
||||
- DXPEDITION
|
||||
- CONTEST
|
||||
example: XOTA
|
||||
|
||||
Continent:
|
||||
type: string
|
||||
enum:
|
||||
@@ -1075,26 +1082,26 @@ components:
|
||||
- NONE
|
||||
example: "HOME QTH"
|
||||
|
||||
SIGRef:
|
||||
ActivityRef:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: SIG reference ID.
|
||||
description: Activity reference ID.
|
||||
example: GB-0001
|
||||
sig:
|
||||
description: SIG that this reference is in.
|
||||
$ref: "#/components/schemas/SIGName"
|
||||
description: Activity that this reference is in. Still named "sig" in the API for backwards compatibility.
|
||||
$ref: "#/components/schemas/ActivityName"
|
||||
name:
|
||||
type: string
|
||||
description: SIG reference name
|
||||
description: Activity reference name
|
||||
example: Null Country Park
|
||||
ref_type:
|
||||
description: SIG reference type
|
||||
$ref: "#/components/schemas/SIGRefType"
|
||||
description: Activity reference type
|
||||
$ref: "#/components/schemas/ActivityRefType"
|
||||
url:
|
||||
type: string
|
||||
description: SIG reference URL, which the user can look up for more information
|
||||
description: Activity reference URL, which the user can look up for more information
|
||||
example: "https://pota.app/#/park/GB-0001"
|
||||
grid:
|
||||
type: string
|
||||
@@ -1136,7 +1143,7 @@ components:
|
||||
dx_qth:
|
||||
type: string
|
||||
description: >
|
||||
QTH of the operator that has been spotted. This could be from any SIG refs or could be
|
||||
QTH of the operator that has been spotted. This could be from any activity refs or could be
|
||||
from online lookup of their home QTH.
|
||||
example: Dorset
|
||||
dx_country:
|
||||
@@ -1194,7 +1201,7 @@ components:
|
||||
dx_location_source:
|
||||
description: >
|
||||
Where we got the DX location (grid/latitude/longitude) from. If this was from the spot
|
||||
itself, or from a lookup of the SIG ref (e.g. park) it's likely quite accurate, but if
|
||||
itself, or from a lookup of the activity ref (e.g. park) it's likely quite accurate, but if
|
||||
we had to fall back to QRZ lookup, or even a location based on the DXCC itself, it will
|
||||
be a lot less accurate. "SPOT" indicates the location source was the spot itself from the
|
||||
spotting service. "SIG REF LOOKUP" indicates that the spot didn't provide a location,
|
||||
@@ -1308,13 +1315,13 @@ components:
|
||||
description: Comment left by the spotter, if any
|
||||
example: "59 in NY 73"
|
||||
sig:
|
||||
description: Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
|
||||
$ref: "#/components/schemas/SIGName"
|
||||
description: Activity, e.g. outdoor activity programme such as POTA (still named "sig" in the API for backwards compatibility)
|
||||
$ref: "#/components/schemas/ActivityName"
|
||||
sig_refs:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SIGRef'
|
||||
description: SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
||||
$ref: '#/components/schemas/ActivityRef'
|
||||
description: Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO. Still named "sig_refs" in the API for backwards compatibility.
|
||||
qrt:
|
||||
type: boolean
|
||||
description: QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
|
||||
@@ -1358,13 +1365,13 @@ components:
|
||||
If true, forward the spot to an external upstream provider (e.g. POTA, SOTA) rather
|
||||
than only adding it to this Spothole server. Requires `sig`, at least one `sig_refs`
|
||||
entry, and `upstream_provider` to be set. Check `spot_submit_providers` in the
|
||||
/options response to see which SIGs and providers support this.
|
||||
/options response to see which activities and providers support this.
|
||||
default: false
|
||||
upstream_provider:
|
||||
type: string
|
||||
description: >
|
||||
Name of the upstream provider to submit the spot to, e.g. "POTA" or "SOTA". Must
|
||||
match one of the provider names returned in `spot_submit_providers` for the chosen SIG.
|
||||
match one of the provider names returned in `spot_submit_providers` for the chosen activity.
|
||||
example: POTA
|
||||
upstream_credentials:
|
||||
type: object
|
||||
@@ -1481,16 +1488,13 @@ components:
|
||||
description: Comment made by the activator, if any
|
||||
example: "2025 DXpedition to null island"
|
||||
sig:
|
||||
description: Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
|
||||
$ref: "#/components/schemas/SIGName"
|
||||
description: Activity, e.g. outdoor activity programme such as POTA (still named "sig" in the API for backwards compatibility)
|
||||
$ref: "#/components/schemas/ActivityName"
|
||||
sig_refs:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SIGRef'
|
||||
description: SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
||||
alert_type:
|
||||
description: "The type of alert this is: xOTA, DXpedition, or Contest."
|
||||
$ref: "#/components/schemas/AlertType"
|
||||
$ref: '#/components/schemas/ActivityRef'
|
||||
description: Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO. Still named "sig_refs" in the API for backwards compatibility.
|
||||
url:
|
||||
type: string
|
||||
description: A URL linking to more information about the alert, e.g. DXpedition or contest info.
|
||||
@@ -1581,28 +1585,41 @@ components:
|
||||
description: The end frequency of this band, in Hz.
|
||||
example: 7200000
|
||||
|
||||
SIG:
|
||||
Activity:
|
||||
type: object
|
||||
description: >
|
||||
Represents an activity (a term which replaces the older "Special Interest Group" or "SIG" terminology,
|
||||
though `sig`-prefixed field names remain for API backwards compatibility).
|
||||
properties:
|
||||
name:
|
||||
description: The abbreviated name of the SIG
|
||||
$ref: "#/components/schemas/SIGName"
|
||||
description: The abbreviated name of the activity
|
||||
$ref: "#/components/schemas/ActivityName"
|
||||
description:
|
||||
type: string
|
||||
description: The full name of the SIG
|
||||
description: The full name of the activity
|
||||
example: Parks on the Air
|
||||
sig_type:
|
||||
type: boolean
|
||||
description: >
|
||||
Whether the SIG is worldwide, regional, or for a specific event. Generally for Spothole's own internal use,
|
||||
clients probably won't need this. Used to group them in the web UI.
|
||||
$ref: "#/components/schemas/SIGType"
|
||||
Whether the activity is traditional (e.g. EME), adventure (e.g. POTA), regional (e.g. WAB), or for a
|
||||
specific event (e.g. MOTA). Generally for Spothole's own internal use, clients probably won't need this.
|
||||
Used to group them in the web UI. Still named "sig_type" in the API for backwards compatibility.
|
||||
$ref: "#/components/schemas/ActivityType"
|
||||
has_refs:
|
||||
type: boolean
|
||||
description: >
|
||||
Whether this activity has a fixed set of references (e.g. parks, summits) with an ID that gets
|
||||
"activated", as opposed to activities like Contest or DXpedition which don't.
|
||||
ref_type:
|
||||
description: >
|
||||
The type of reference that gets activated for this activity, e.g. Park or Summit.
|
||||
$ref: "#/components/schemas/ActivityRefType"
|
||||
comment_names:
|
||||
type: array
|
||||
description: >
|
||||
Names by which this SIG may be referred to in cluster spot comments. Most SIGs have a
|
||||
Names by which this activity may be referred to in cluster spot comments. Most activities have a
|
||||
single entry matching their programme name (e.g. ["POTA"]), but some have none (where
|
||||
the name is ambiguous with other SIGs, such as Tiles and Toilets on the Air) or multiple
|
||||
the name is ambiguous with other activities, such as "TOTA" for Tiles and Toilets on the Air) or multiple
|
||||
entries (e.g. WWBOTA accepts both "WWBOTA" and "BOTA" since the latter is often used).
|
||||
items:
|
||||
type: string
|
||||
@@ -1610,27 +1627,33 @@ components:
|
||||
ref_regex:
|
||||
type: string
|
||||
description: >
|
||||
Regex that matches this SIG's reference IDs. Generally for Spothole's own internal use,
|
||||
Regex that matches this activity's reference IDs. Generally for Spothole's own internal use,
|
||||
clients probably won't need this.
|
||||
example: "[A-Z]{2}\\-\\d+"
|
||||
refs_globally_unique:
|
||||
type: boolean
|
||||
description: >
|
||||
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. Generally for Spothole's own internal use, clients probably won't need
|
||||
this.
|
||||
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. Generally for Spothole's own
|
||||
internal use, clients probably won't need this.
|
||||
icon:
|
||||
type: string
|
||||
description: Icon from the Font Awesome set that represents this SIG, for use in the front end.
|
||||
description: Icon from the Font Awesome set that represents this activity, for use in the front end.
|
||||
example: "fa-tree"
|
||||
region_flag:
|
||||
type: string
|
||||
description: >
|
||||
Flag emoji, if this SIG is specific to a country or region. If null, this SIG is treated as worldwide.
|
||||
Flag emoji, if this activity is specific to a country or region. If null, this activity is treated as
|
||||
worldwide.
|
||||
example: "🇺🇳"
|
||||
alerts_possible:
|
||||
type: boolean
|
||||
description: >
|
||||
Whether alerts can ever have this activity. While Spothole can assign any activity from spot comments, there
|
||||
are only a limited set that can ever crop up in alerts, because we don't parse general comments for alerts.
|
||||
|
||||
SolarConditions:
|
||||
type: object
|
||||
@@ -1978,12 +2001,12 @@ components:
|
||||
is zero, the provider has never updated.
|
||||
example: 1759579508
|
||||
|
||||
SIGRefDataProviderStatus:
|
||||
ActivityRefDataProviderStatus:
|
||||
type: object
|
||||
properties:
|
||||
sig_name:
|
||||
type: string
|
||||
description: The name of the SIG.
|
||||
description: The name of the activity. Still named "sig_name" in the API for backwards compatibility.
|
||||
example: WWFF
|
||||
enabled:
|
||||
type: boolean
|
||||
@@ -2148,9 +2171,9 @@ components:
|
||||
$ref: '#/components/schemas/StaticDataProviderStatus'
|
||||
sig_ref_data_providers:
|
||||
type: array
|
||||
description: An array of all the SIG reference data providers.
|
||||
description: An array of all the activity reference data providers.
|
||||
items:
|
||||
$ref: '#/components/schemas/SIGRefDataProviderStatus'
|
||||
$ref: '#/components/schemas/ActivityRefDataProviderStatus'
|
||||
callsign_data_providers:
|
||||
type: array
|
||||
description: An array of all the callsign data providers.
|
||||
@@ -2179,9 +2202,9 @@ components:
|
||||
example: "PHONE"
|
||||
sigs:
|
||||
type: array
|
||||
description: An array of all the supported Special Interest Groups.
|
||||
description: An array of all the supported activities.
|
||||
items:
|
||||
$ref: '#/components/schemas/SIG'
|
||||
$ref: '#/components/schemas/Activity'
|
||||
spot_providers:
|
||||
type: array
|
||||
description: An array of all the supported spot data sources.
|
||||
@@ -2228,9 +2251,9 @@ components:
|
||||
spot_submit_providers:
|
||||
type: object
|
||||
description: >
|
||||
A map of SIG name to a list of provider names that support upstream spot submission for that SIG.
|
||||
If a SIG appears as a key here, the POST /spot endpoint accepts `submit_upstream: true` for
|
||||
spots with that SIG, and will forward the spot to one of the listed providers. Omitted if no
|
||||
A map of activity name to a list of provider names that support upstream spot submission for that
|
||||
activity. If an activity appears as a key here, the POST /spot endpoint accepts `submit_upstream: true`
|
||||
for spots with that activity, and will forward the spot to one of the listed providers. Omitted if no
|
||||
providers support upstream submission.
|
||||
additionalProperties:
|
||||
type: array
|
||||
@@ -2258,7 +2281,7 @@ components:
|
||||
qth:
|
||||
type: string
|
||||
description: >
|
||||
QTH of the operator. This could be from any SIG refs or could be from online lookup of
|
||||
QTH of the operator. This could be from any activity refs or could be from online lookup of
|
||||
their home QTH.
|
||||
example: Dorset
|
||||
country:
|
||||
|
||||
+46
-1
@@ -165,7 +165,7 @@ a.dx-link {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
a.sig-ref-link {
|
||||
a.activity-ref-link {
|
||||
color: var(--bs-emphasis-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -279,6 +279,51 @@ div#map {
|
||||
}
|
||||
|
||||
|
||||
/* COLLAPSIBLE FILTER SECTIONS (e.g. Activities panel) */
|
||||
|
||||
ul.filter-section-list {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
button.btn-toggle {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: .25rem 0;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
color: var(--bs-emphasis-color);
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
button.btn-toggle:hover,
|
||||
button.btn-toggle:focus {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
button.btn-toggle::before {
|
||||
display: inline-block;
|
||||
width: 1.25em;
|
||||
line-height: 0;
|
||||
content: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%280,0,0,.5%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e");
|
||||
transition: transform .35s ease;
|
||||
transform-origin: .5em 50%;
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] button.btn-toggle::before {
|
||||
content: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%28255,255,255,.5%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
button.btn-toggle[aria-expanded="true"]::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
div.filter-section-body {
|
||||
padding-left: 1.25em;
|
||||
}
|
||||
|
||||
|
||||
/* BANDS PANEL */
|
||||
|
||||
div#bands-container {
|
||||
|
||||
@@ -41,11 +41,11 @@ function loadOptions() {
|
||||
}));
|
||||
});
|
||||
|
||||
// Populate SIG drop-down
|
||||
$.each(options["sigs"], function (i, sig) {
|
||||
// Populate activity drop-down
|
||||
$.each(options["sigs"], function (i, activity) {
|
||||
$('#sig').append($('<option>', {
|
||||
value: sig.name,
|
||||
text: sig.name
|
||||
value: activity.name,
|
||||
text: activity.name
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -57,7 +57,7 @@ function loadOptions() {
|
||||
// Load settings from settings storage now all the controls are available
|
||||
loadSettings();
|
||||
|
||||
// Update the upstream area for any pre-selected SIG
|
||||
// Update the upstream area for any pre-selected activity
|
||||
updateUpstreamArea();
|
||||
});
|
||||
}
|
||||
@@ -84,7 +84,7 @@ function renderRecaptcha() {
|
||||
});
|
||||
}
|
||||
|
||||
// Update the "Send spot to..." area based on the currently selected SIG
|
||||
// Update the "Send spot to..." area based on the currently selected activity
|
||||
function updateUpstreamArea() {
|
||||
if (!window._allowUpstreamSpotting || !options || !options["spot_submit_providers"]) {
|
||||
$("#upstream-area").hide();
|
||||
@@ -290,7 +290,7 @@ $("#mode").change(function () {
|
||||
$(this).val($(this).val().trim().toUpperCase());
|
||||
});
|
||||
|
||||
// Update upstream area and credentials button when SIG changes
|
||||
// Update upstream area and credentials button when activity changes
|
||||
$("#sig").change(function () {
|
||||
updateUpstreamArea();
|
||||
});
|
||||
|
||||
+23
-31
@@ -24,7 +24,7 @@ function loadAlerts() {
|
||||
// Build a query string for the API, based on the filters that the user has selected.
|
||||
function buildQueryString() {
|
||||
let str = "?";
|
||||
["dx_continent", "source"].forEach(fn => {
|
||||
["dx_continent", "source", "sig"].forEach(fn => {
|
||||
if (!allFilterOptionsSelected(fn)) {
|
||||
str = str + getQueryStringFor(fn) + "&";
|
||||
}
|
||||
@@ -54,7 +54,7 @@ function updateTable() {
|
||||
const showDX = $("#tableShowDX")[0].checked;
|
||||
const showFreqsModes = $("#tableShowFreqsModes")[0].checked;
|
||||
const showComment = $("#tableShowComment")[0].checked;
|
||||
const showType = $("#tableShowType")[0].checked;
|
||||
const showActivity = $("#tableShowActivity")[0].checked;
|
||||
const showRef = $("#tableShowRef")[0].checked;
|
||||
|
||||
// Populate table with headers
|
||||
@@ -75,11 +75,11 @@ function updateTable() {
|
||||
if (showComment) {
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Comment</th>`);
|
||||
}
|
||||
if (showType) {
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Type</th>`);
|
||||
if (showActivity) {
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Activity</th>`);
|
||||
}
|
||||
if (showRef) {
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Ref.</th>`);
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Reference</th>`);
|
||||
}
|
||||
|
||||
table.find('tbody').empty();
|
||||
@@ -151,7 +151,7 @@ function addAlertRowsToTable(tbody, alerts) {
|
||||
const showDX = $("#tableShowDX")[0].checked;
|
||||
const showFreqsModes = $("#tableShowFreqsModes")[0].checked;
|
||||
const showComment = $("#tableShowComment")[0].checked;
|
||||
const showType = $("#tableShowType")[0].checked;
|
||||
const showActivity = $("#tableShowActivity")[0].checked;
|
||||
const showRef = $("#tableShowRef")[0].checked;
|
||||
|
||||
// Get times for the alert, and convert to local time if necessary.
|
||||
@@ -210,14 +210,14 @@ function addAlertRowsToTable(tbody, alerts) {
|
||||
if (a["dx_calls"] != null) {
|
||||
dx_calls_html = a["dx_calls"].map(call => `<a class='dx-link' href='https://qrz.com/db/${call}' target='_new'>${call}</a>`).join(", ");
|
||||
}
|
||||
if (dx_calls_html === "" && a["alert_type"] === "CONTEST") {
|
||||
if (dx_calls_html === "" && a["sig"] === "Contest") {
|
||||
// Contest = true and no DX callsigns, so display "Contest"
|
||||
dx_calls_html = "Contest"
|
||||
}
|
||||
|
||||
// Format DXpedition country
|
||||
let dx_country_html = "";
|
||||
if (a["alert_type"] === "DXPEDITION" && a["dx_country"] != null && a["dx_country"] !== "") {
|
||||
if (a["sig"] === "DXpedition" && a["dx_country"] != null && a["dx_country"] !== "") {
|
||||
dx_country_html = `<br/>${a["dx_country"]}`;
|
||||
}
|
||||
|
||||
@@ -250,34 +250,24 @@ function addAlertRowsToTable(tbody, alerts) {
|
||||
}
|
||||
|
||||
|
||||
// Type, SIG or fallback to source
|
||||
let sigTypeText = a["source"];
|
||||
if (a["alert_type"] === "CONTEST") {
|
||||
sigTypeText = "Contest";
|
||||
} else if (a["alert_type"] === "DXPEDITION") {
|
||||
sigTypeText = "DXpedition";
|
||||
} else if (a["alert_type"] === "SATELLITE") {
|
||||
sigTypeText = "Satellite";
|
||||
} else if (a["alert_type"] === "XOTA") {
|
||||
if (a["sig"]) {
|
||||
sigTypeText = a["sig"];
|
||||
} else {
|
||||
sigTypeText = "xOTA";
|
||||
}
|
||||
// Activity or fallback to "General DX"
|
||||
let activityText = "General DX";
|
||||
if (a["sig"]) {
|
||||
activityText = a["sig"];
|
||||
}
|
||||
|
||||
// Format sig_refs
|
||||
let sig_refs = "";
|
||||
// Format activity refs
|
||||
let activityRefs = "";
|
||||
if (a["sig_refs"] != null) {
|
||||
const items = [];
|
||||
for (let i = 0; i < a["sig_refs"].length; i++) {
|
||||
if (a["sig_refs"][i]["url"] != null) {
|
||||
items[i] = `<a href='${encodeURI(a["sig_refs"][i]["url"])}' title='${escapeHtml(a["sig_refs"][i]["name"])}' target='_new' class='sig-ref-link'>${escapeHtml(a["sig_refs"][i]["id"])}</a>`
|
||||
items[i] = `<a href='${encodeURI(a["sig_refs"][i]["url"])}' title='${escapeHtml(a["sig_refs"][i]["name"])}' target='_new' class='activity-ref-link'>${escapeHtml(a["sig_refs"][i]["id"])}</a>`
|
||||
} else {
|
||||
items[i] = `${escapeHtml(a["sig_refs"][i]["id"])}`
|
||||
}
|
||||
}
|
||||
sig_refs = items.join(", ");
|
||||
activityRefs = items.join(", ");
|
||||
}
|
||||
|
||||
// Populate the row
|
||||
@@ -296,11 +286,11 @@ function addAlertRowsToTable(tbody, alerts) {
|
||||
if (showComment) {
|
||||
$tr.append(`<td class='hideonmobile'>${commentText}</td>`);
|
||||
}
|
||||
if (showType) {
|
||||
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid ${a["icon"]}'></i></span> ${sigTypeText}</td>`);
|
||||
if (showActivity) {
|
||||
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid ${a["icon"]}'></i></span> ${activityText}</td>`);
|
||||
}
|
||||
if (showRef) {
|
||||
$tr.append(`<td class='hideonmobile'>${sig_refs}</td>`);
|
||||
$tr.append(`<td class='hideonmobile'>${activityRefs}</td>`);
|
||||
}
|
||||
tbody.append($tr);
|
||||
|
||||
@@ -314,11 +304,11 @@ function addAlertRowsToTable(tbody, alerts) {
|
||||
}
|
||||
|
||||
const $td2 = $("<td colspan='100'>");
|
||||
if (showType) {
|
||||
if (showActivity) {
|
||||
$td2.append(`<span class='icon-wrapper'><i class='fa-solid ${a["icon"]}'></i></span> `);
|
||||
}
|
||||
if (showRef) {
|
||||
$td2.append(`${sig_refs} `);
|
||||
$td2.append(`${activityRefs} `);
|
||||
}
|
||||
if (showFreqsModes) {
|
||||
$td2.append(`${freqsModesText} `);
|
||||
@@ -342,6 +332,8 @@ function loadOptions() {
|
||||
// Populate the filters panel
|
||||
generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#source-options", "source", options["alert_providers"]);
|
||||
// Alerts can only ever be tagged with activities that have an alert source, so only offer those as filters here
|
||||
generateActivitiesMultiToggleFilterCard(options["sigs"].filter(o => o["alerts_possible"]), false);
|
||||
|
||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||
|
||||
+1
-1
@@ -295,7 +295,7 @@ function loadOptions() {
|
||||
|
||||
// Populate the filters panel
|
||||
generateBandsMultiToggleFilterCard(options["bands"]);
|
||||
generateSIGsMultiToggleFilterCard(options["sigs"]);
|
||||
generateActivitiesMultiToggleFilterCard(options["sigs"]);
|
||||
generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#de_continent_options", "de_continent", options["continents"]);
|
||||
generateModesMultiToggleFilterCard(options["modes"]);
|
||||
|
||||
@@ -135,6 +135,64 @@ function generateMultiToggleFilterCard(elementID, filterQuery, options) {
|
||||
$(elementID).append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('${filterQuery}', true); return false;">All</a> <a href="#" onclick="toggleFilterButtons('${filterQuery}', false); return false;">None</a></div>`);
|
||||
}
|
||||
|
||||
// Generate a collapsable section for the activities filter card, and append it to the given list. The section is
|
||||
// expanded by default if isExpanded is true. If hasContent is false (i.e. there are no activities to show in this
|
||||
// section), the section is omitted entirely rather than showing an empty collapsible heading.
|
||||
function appendActivityFilterSection($list, sectionId, heading, isExapanded, hasContent, buildBody) {
|
||||
if (!hasContent) {
|
||||
return;
|
||||
}
|
||||
const collapseId = `activity-options-${sectionId}-collapse`;
|
||||
const $li = $('<li class="mb-1">');
|
||||
$li.append(`<button class="btn-toggle${isExapanded ? '' : ' collapsed'}" data-bs-toggle="collapse" data-bs-target="#${collapseId}" aria-expanded="${isExapanded ? 'true' : 'false'}" aria-controls="${collapseId}">${heading}</button>`);
|
||||
const $body = $(`<div class="collapse filter-section-body${isExapanded ? ' show' : ''}" id="${collapseId}">`);
|
||||
buildBody($body);
|
||||
$li.append($body);
|
||||
$list.append($li);
|
||||
}
|
||||
|
||||
// Build the grid of activity checkboxes for a given (already filtered) list of activities.
|
||||
function buildActivityFilterGrid(activity_options) {
|
||||
const $grid = $('<div class="row row-cols-2 g-1 mb-1">');
|
||||
activity_options.forEach(o => {
|
||||
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
|
||||
});
|
||||
return $grid;
|
||||
}
|
||||
|
||||
// Generate activities filter card. This one is also a special case. includeGeneralDX controls whether the "General
|
||||
// DX" (NO_SIG) option is offered - this doesn't apply on the alerts page, where every alert has an activity.
|
||||
function generateActivitiesMultiToggleFilterCard(activity_options, includeGeneralDX = true) {
|
||||
const $list = $('<ul class="list-unstyled filter-section-list ps-0">');
|
||||
|
||||
const traditional = activity_options.filter(o => o["sig_type"] === "TRADITIONAL");
|
||||
appendActivityFilterSection($list, 'traditional', 'Traditional', true, includeGeneralDX || traditional.length > 0, $body => {
|
||||
if (includeGeneralDX) {
|
||||
$body.append(`<div class="w-100 mb-1"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-NO_SIG" value="NO_SIG" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-NO_SIG" for="filter-button-sig-NO_SIG"><i class="fa-solid fa-tower-cell"></i> General DX</label></div></div>`);
|
||||
}
|
||||
$body.append(buildActivityFilterGrid(traditional));
|
||||
});
|
||||
|
||||
const adventure = activity_options.filter(o => o["sig_type"] === "ADVENTURE");
|
||||
appendActivityFilterSection($list, 'adventure', 'Adventure', true, adventure.length > 0, $body => {
|
||||
$body.append(buildActivityFilterGrid(adventure));
|
||||
});
|
||||
|
||||
const regional = activity_options.filter(o => o["sig_type"] === "REGIONAL");
|
||||
appendActivityFilterSection($list, 'regional', 'Regional', false, regional.length > 0, $body => {
|
||||
$body.append(buildActivityFilterGrid(regional));
|
||||
});
|
||||
|
||||
const event = activity_options.filter(o => o["sig_type"] === "EVENT");
|
||||
appendActivityFilterSection($list, 'event', 'Event', false, event.length > 0, $body => {
|
||||
$body.append(buildActivityFilterGrid(event));
|
||||
});
|
||||
|
||||
$("#activity-options").append($list);
|
||||
$("#activity-options").append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('sig', true); return false;">All</a> <a href="#" onclick="toggleFilterButtons('sig', false); return false;">None</a></div>`);
|
||||
}
|
||||
|
||||
// Method called when "All" or "None" is clicked
|
||||
function toggleFilterButtons(filterQuery, state) {
|
||||
$(".filter-button-" + filterQuery).each(function () {
|
||||
|
||||
+10
-10
@@ -274,24 +274,24 @@ function getTooltipText(s) {
|
||||
commentText = escapeHtml(s["comment"]);
|
||||
}
|
||||
|
||||
// Sig or fallback to source
|
||||
let sigSourceText = s["source"];
|
||||
// Activity or fallback to source
|
||||
let activitySourceText = s["source"];
|
||||
if (s["sig"]) {
|
||||
sigSourceText = s["sig"];
|
||||
activitySourceText = s["sig"];
|
||||
}
|
||||
|
||||
// Format sig_refs
|
||||
let sig_refs = "";
|
||||
// Format activity refs
|
||||
let activityRefs = "";
|
||||
if (s["sig_refs"] != null) {
|
||||
const items = [];
|
||||
for (let i = 0; i < s["sig_refs"].length; i++) {
|
||||
if (s["sig_refs"][i]["url"] != null) {
|
||||
items[i] = `<a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${s["sig_refs"][i]["id"]}</a>`
|
||||
items[i] = `<a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='activity-ref-link'>${s["sig_refs"][i]["id"]}</a>`
|
||||
} else {
|
||||
items[i] = `${s["sig_refs"][i]["id"]}`
|
||||
}
|
||||
}
|
||||
sig_refs = items.join(", ");
|
||||
activityRefs = items.join(", ");
|
||||
}
|
||||
|
||||
// DX
|
||||
@@ -308,8 +308,8 @@ function getTooltipText(s) {
|
||||
}
|
||||
ttt += "<br/>";
|
||||
|
||||
// Source / SIG / Ref
|
||||
ttt += `<span class='nowrap'><span class='icon-wrapper'><i class='fa-solid ${s["icon"]}'></i></span> ${sigSourceText} ${sig_refs}</span><br/>`;
|
||||
// Source / Activity / Ref
|
||||
ttt += `<span class='nowrap'><span class='icon-wrapper'><i class='fa-solid ${s["icon"]}'></i></span> ${activitySourceText} ${activityRefs}</span><br/>`;
|
||||
|
||||
// Time
|
||||
ttt += `<span class='icon-wrapper'><i class='fa-solid fa-clock markerPopupIcon'></i></span> ${moment.unix(s["time"]).fromNow()}`;
|
||||
@@ -339,7 +339,7 @@ function loadOptions() {
|
||||
|
||||
// Populate the filters panel
|
||||
generateBandsMultiToggleFilterCard(options["bands"]);
|
||||
generateSIGsMultiToggleFilterCard(options["sigs"]);
|
||||
generateActivitiesMultiToggleFilterCard(options["sigs"]);
|
||||
generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#de_continent_options", "de_continent", options["continents"]);
|
||||
generateModesMultiToggleFilterCard(options["modes"]);
|
||||
|
||||
+20
-20
@@ -128,7 +128,7 @@ function updateTable() {
|
||||
const showComment = $("#tableShowComment")[0].checked;
|
||||
const showBearing = $("#tableShowBearing")[0].checked && userPos != null;
|
||||
const showDistance = $("#tableShowDistance")[0].checked && userPos != null;
|
||||
const showType = $("#tableShowType")[0].checked;
|
||||
const showActivity = $("#tableShowActivity")[0].checked;
|
||||
const showRef = $("#tableShowRef")[0].checked;
|
||||
const showDE = $("#tableShowDE")[0].checked;
|
||||
const showWorkedCheckbox = $("#tableShowWorkedCheckbox")[0].checked;
|
||||
@@ -157,11 +157,11 @@ function updateTable() {
|
||||
if (showDistance) {
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Distance</th>`);
|
||||
}
|
||||
if (showType) {
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Type</th>`);
|
||||
if (showActivity) {
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Activity</th>`);
|
||||
}
|
||||
if (showRef) {
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Ref.</th>`);
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Reference</th>`);
|
||||
}
|
||||
if (showDE) {
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>DE</th>`);
|
||||
@@ -208,7 +208,7 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
||||
const showComment = $("#tableShowComment")[0].checked;
|
||||
const showBearing = $("#tableShowBearing")[0].checked && userPos != null;
|
||||
const showDistance = $("#tableShowDistance")[0].checked && userPos != null;
|
||||
const showType = $("#tableShowType")[0].checked;
|
||||
const showActivity = $("#tableShowActivity")[0].checked;
|
||||
const showRef = $("#tableShowRef")[0].checked;
|
||||
const showDE = $("#tableShowDE")[0].checked;
|
||||
const showWorkedCheckbox = $("#tableShowWorkedCheckbox")[0].checked;
|
||||
@@ -327,24 +327,24 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
||||
}
|
||||
}
|
||||
|
||||
// Format "type" (Sig or fallback to source)
|
||||
let typeText = s["source"];
|
||||
// Format activity
|
||||
let activityText = "General DX";
|
||||
if (s["sig"]) {
|
||||
typeText = s["sig"];
|
||||
activityText = s["sig"];
|
||||
}
|
||||
|
||||
// Format sig_refs
|
||||
let sig_refs = "";
|
||||
// Format activity refs
|
||||
let activityRefs = "";
|
||||
if (s["sig_refs"] != null) {
|
||||
const items = [];
|
||||
for (let i = 0; i < s["sig_refs"].length; i++) {
|
||||
if (s["sig_refs"][i]["url"] != null) {
|
||||
items[i] = `<span style="white-space: nowrap;"><a href='${encodeURI(s["sig_refs"][i]["url"])}' title='${escapeHtml(s["sig_refs"][i]["name"])}' target='_new' class='sig-ref-link'>${escapeHtml(s["sig_refs"][i]["id"])}</a></span>`
|
||||
items[i] = `<span style="white-space: nowrap;"><a href='${encodeURI(s["sig_refs"][i]["url"])}' title='${escapeHtml(s["sig_refs"][i]["name"])}' target='_new' class='activity-ref-link'>${escapeHtml(s["sig_refs"][i]["id"])}</a></span>`
|
||||
} else {
|
||||
items[i] = `<span style="white-space: nowrap;">${escapeHtml(s["sig_refs"][i]["id"])}</span>`
|
||||
}
|
||||
}
|
||||
sig_refs = items.join(", ");
|
||||
activityRefs = items.join(", ");
|
||||
}
|
||||
|
||||
// Format de country
|
||||
@@ -397,11 +397,11 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
||||
if (showDistance) {
|
||||
$tr.append(`<td class='nowrap hideonmobile'>${distanceText}</td>`);
|
||||
}
|
||||
if (showType) {
|
||||
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid ${s["icon"]}'></i></span> ${typeText}</td>`);
|
||||
if (showActivity) {
|
||||
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid ${s["icon"]}'></i></span> ${activityText}</td>`);
|
||||
}
|
||||
if (showRef) {
|
||||
$tr.append(`<td class='hideonmobile' style='max-width: 11em;'>${sig_refs}</td>`);
|
||||
$tr.append(`<td class='hideonmobile' style='max-width: 11em;'>${activityRefs}</td>`);
|
||||
}
|
||||
if (showDE) {
|
||||
$tr.append(`<td class='nowrap hideonmobile'><span class='flag-wrapper' title='${de_country}'>${de_flag}</span>${de_call}</td>`);
|
||||
@@ -410,7 +410,7 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
||||
$tr.append(`<td class='nowrap hideonmobile'>${workedCheckbox}</td>`);
|
||||
}
|
||||
|
||||
// Second row for mobile view only, containing type, ref & comment
|
||||
// Second row for mobile view only, containing activity, ref & comment
|
||||
const $tr2 = $("<tr class='hidenotonmobile'>");
|
||||
|
||||
// Apply styles as per the first row
|
||||
@@ -429,11 +429,11 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
||||
|
||||
const $td2 = $("<td colspan='100'>");
|
||||
const $td2floatleft = $(`<div style="float: left;">`);
|
||||
if (showType) {
|
||||
$td2floatleft.append(`<span class='icon-wrapper'><i class='fa-solid ${s["icon"]}'></i></span> ${typeText} `);
|
||||
if (showActivity) {
|
||||
$td2floatleft.append(`<span class='icon-wrapper'><i class='fa-solid ${s["icon"]}'></i></span> ${activityText} `);
|
||||
}
|
||||
if (showRef) {
|
||||
$td2floatleft.append(`${sig_refs} `);
|
||||
$td2floatleft.append(`${activityRefs} `);
|
||||
}
|
||||
$td2.append($td2floatleft);
|
||||
const $td2floatright = $(`<div style="float: right;">`);
|
||||
@@ -478,7 +478,7 @@ function loadOptions() {
|
||||
|
||||
// Populate the filters panel
|
||||
generateBandsMultiToggleFilterCard(options["bands"]);
|
||||
generateSIGsMultiToggleFilterCard(options["sigs"]);
|
||||
generateActivitiesMultiToggleFilterCard(options["sigs"]);
|
||||
generateMultiToggleFilterCard("#dx_continent_options", "dx_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#de_continent_options", "de_continent", options["continents"]);
|
||||
generateModesMultiToggleFilterCard(options["modes"]);
|
||||
|
||||
@@ -38,35 +38,6 @@ function setHamHFBandToggles() {
|
||||
filtersUpdated();
|
||||
}
|
||||
|
||||
// Generate SIGs filter card. This one is also a special case.
|
||||
function generateSIGsMultiToggleFilterCard(sig_options) {
|
||||
const $grid1 = $('<div class="row row-cols-2 g-1 mb-1">');
|
||||
$("#sig-options").append('<strong>Worldwide</strong>');
|
||||
sig_options.filter(o => o["sig_type"] === "WORLDWIDE").forEach(o => {
|
||||
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||
$grid1.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
|
||||
});
|
||||
$("#sig-options").append($grid1);
|
||||
const $grid2 = $('<div class="row row-cols-2 g-1 mb-1">');
|
||||
$("#sig-options").append('<strong>Regional</strong>');
|
||||
sig_options.filter(o => o["sig_type"] === "REGIONAL").forEach(o => {
|
||||
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||
$grid2.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
|
||||
});
|
||||
$("#sig-options").append($grid2);
|
||||
const $grid3 = $('<div class="row row-cols-2 g-1 mb-1">');
|
||||
$("#sig-options").append('<strong>Event</strong>');
|
||||
sig_options.filter(o => o["sig_type"] === "EVENT").forEach(o => {
|
||||
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||
$grid3.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
|
||||
});
|
||||
$("#sig-options").append($grid3);
|
||||
$("#sig-options").append('<strong>Other</strong>');
|
||||
// Bonus "NO_SIG" / "General DX" option
|
||||
$("#sig-options").append(`<div class="w-100"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-NO_SIG" value="NO_SIG" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-NO_SIG" for="filter-button-sig-NO_SIG"><i class="fa-solid fa-tower-cell"></i> General DX</label></div></div>`);
|
||||
$("#sig-options").append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('sig', true); return false;">All</a> <a href="#" onclick="toggleFilterButtons('sig', false); return false;">None</a></div>`);
|
||||
}
|
||||
|
||||
// Generate modes filter card. This one is also a special case.
|
||||
function generateModesMultiToggleFilterCard(mode_options) {
|
||||
const $grid = $('<div class="row row-cols-3 row-cols-md-2 row-cols-lg-3 g-1 mb-1">');
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
//
|
||||
// USER INTERFACE FUNCTIONS (AMATEUR RADIO)
|
||||
// Functions providing colour schemes for ham radio bands, SIG icons etc.
|
||||
// Functions providing colour schemes for ham radio bands, activity icons etc.
|
||||
//
|
||||
|
||||
const BAND_COLOR_SCHEMES = {
|
||||
|
||||
@@ -155,7 +155,8 @@ class TelnetServer:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
# No problem, we were disconnecting this client anyway.
|
||||
logger.debug("Exception while closing telnet client connection", exc_info=True)
|
||||
self._clients.clear()
|
||||
|
||||
def publish(self, spot: Spot):
|
||||
@@ -178,6 +179,7 @@ class TelnetServer:
|
||||
writer.write(encoded_line)
|
||||
await writer.drain()
|
||||
except Exception:
|
||||
logger.debug("Exception while writing to telnet client, assuming it disconnected", exc_info=True)
|
||||
disconnected_clients.add(writer)
|
||||
|
||||
# Clean up any disconnected connections caught during writing
|
||||
|
||||
+19
-13
@@ -14,9 +14,8 @@
|
||||
source, freeing developers from needing to know how each individual data source presents its data.</p>
|
||||
<p>Spothole itself is also open source, Public Domain licenced code that anyone can take and modify. <a
|
||||
href="https://git.ianrenton.com/ian/spothole/">The source code is here</a>.</p>
|
||||
<p>The software was written by <a href="https://ianrenton.com">Ian Renton, MØTRT</a> and other contributors. Full
|
||||
details are available in the <a href="https://git.ianrenton.com/ian/spothole/src/branch/main/README.md">README
|
||||
file</a>.</p>
|
||||
<p>The software was written by <a href="https://ianrenton.com">Ian Renton, MØTRT</a> with code contributions by
|
||||
Steven, M1SDH. The full list of thanks due to others can be found at the bottom of this page.</p>
|
||||
<p>This server is running Spothole version {{software_version}}.</p>
|
||||
<h2 class="mt-4 mb-4">Using Spothole</h2>
|
||||
<p>There are a number of different ways to use Spothole, depending on what you want to do with it and your level of
|
||||
@@ -103,7 +102,8 @@
|
||||
<p>Note that the server owner has not necessarily enabled all these data sources. In particular it is common to
|
||||
disable RBN, to avoid the server being swamped with FT8 traffic, and to disable APRS-IS and UK Packet Net so
|
||||
that the server only displays stations where there is likely to be an operator physically present for a QSO.</p>
|
||||
<p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: Parks on the Air
|
||||
<p>Between the various data sources, the following activities / special interest groups are supported: Contests,
|
||||
DXpeditions, Aeronautical Mobile, Maritime Mobile, EME/Moonbounce, Amateur Satellite, QRP, Parks on the Air
|
||||
(POTA), Summits on the Air (SOTA), Worldwide Flora & Fauna (WWFF), Global Mountain Activity (GMA), Worldwide
|
||||
Bunkers on the Air (WWBOTA), HuMPs Excluding Marilyns Award (HEMA), Islands on the Air (IOTA), Mills on the Air
|
||||
(MOTA), the Amateur Radio Lighthouse Society (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos
|
||||
@@ -114,20 +114,20 @@
|
||||
Faros de España (FEA), Diploma Muesos de España (DMUE), Diploma Castillos de España (DCE), Diploma Monumentos y
|
||||
Vestigios de España (DMVE), Diploma Estaciones de Ferrocarril de España (DEFE), Diploma Teatri Musei e Belle
|
||||
Arti (DTMBA), British Inland Waterways on the Air (BIWOTA), Castles on the Air (COTA), Polish Gmina Award (PGA),
|
||||
Diplôme des Moulins de France (DMF), EME/Moonbounce, Amateur Satellite (AMSAT), and Toilets on the Air.</p>
|
||||
<p>As of the time of writing in August 2026, I think Spothole captures most outdoor radio programmes that have a
|
||||
Diplôme des Moulins de France (DMF), RaDAR Rally, and Toilets on the Air.</p>
|
||||
<p>As of the time of writing in August 2026, I think Spothole captures most radio programmes that have a
|
||||
defined, downloadable reference list, and almost certainly those that have a spotting/alerting API. If you know
|
||||
of one I've missed, please let me know!</p>
|
||||
<h4 class="mt-4">Why can I filter spots by both SIG and Source? Isn't that basically the same thing?</h4>
|
||||
<h4 class="mt-4">Why can I filter spots by both Activity and Source? Isn't that basically the same thing?</h4>
|
||||
<p>Mostly, but not quite. While POTA spots generally come from the POTA source and so on, there are a few
|
||||
exceptions:</p>
|
||||
<ol>
|
||||
<li>Sources like GMA and Parks 'n' Peaks provide spots for multiple different programmes (SIGs).</li>
|
||||
<li>Cluster spots may name SIGs in their comment, in which case the source remains the Cluster, but a SIG is
|
||||
assigned.
|
||||
<li>Sources like GMA and Parks 'n' Peaks provide spots for multiple different programmes (activities).</li>
|
||||
<li>Cluster spots may name activities in their comment, in which case the source remains the Cluster, but an
|
||||
activity is assigned.
|
||||
</li>
|
||||
<li>Some SIGs, such as Worked all Britain (WAB), don't have their own spotting site and can <em>only</em> be
|
||||
identified through comments on spots retrieved from other sources.
|
||||
<li>Some activities, such as Worked all Britain (WAB), don't have their own spotting site and can <em>only</em>
|
||||
be identified through comments on spots retrieved from other sources.
|
||||
</li>
|
||||
</ol>
|
||||
<p>Spothole's web interface exists not just for the end user, but also as a reference implementation for the API, so
|
||||
@@ -216,9 +216,15 @@
|
||||
modelling software, and other online tools on which Spothole's data is based. The vast majority of these are not
|
||||
profit-seeking and are made purely for the love of the hobby and to help others in the community. Spothole is
|
||||
standing on the shoulders of giants, who deserve a huge amount of thanks for all the work they put in.</p>
|
||||
<p>Spothole is also dependent on a number of Python libraries, in particular pyhamtools, and many JavaScript
|
||||
<p>The following people have specifically helped develop Spothole either by contributing code, requesting features,
|
||||
or helping squash bugs: Steven M1SDH, Dan S50U, Matt W1CDN, Norby LX1NO, Andrew VK3ARR, Mario DL4MFM, Mark M5TEA,
|
||||
Michael G7VJR, Rob G7LAS, Ed DD5LP, Raph F4LUB, Luc ON7KEC, Richard GD4OFB, Daniel PY2TDB, Alan VK1AO,
|
||||
Andrew WK1AD, Ullrich DF5WC, Wayne N3CDF, Jonathan G4IVV, Erik N2EPE, Sorin YO9TSN, Matt HB9HWI, Priit ES1TEB,
|
||||
Leigh KG7WED, Jouni OH3CUF, Onno VK6FLAB, and Bruce WA7BNM. (If I've forgotten you, let me know!)</p>
|
||||
<p>Spothole is also dependent on a number of Python libraries, such as pyhamtools, and many JavaScript
|
||||
libraries, as well as the Font Awesome icon set and flag icons from the Noto Color Emoji set, and MIT-licenced
|
||||
GeoJSON files for CQ and ITU zones from HA8TKS.</p>
|
||||
<p>The project's name was suggested by Harm DK4HAA.</p>
|
||||
<p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around
|
||||
the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p>
|
||||
</div>
|
||||
|
||||
@@ -41,13 +41,13 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label for="sig" class="form-label">SIG</label>
|
||||
<label for="sig" class="form-label">Activity</label>
|
||||
<select id="sig" class="form-select">
|
||||
<option value="" selected></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label for="sig-ref" class="form-label">SIG Reference</label>
|
||||
<label for="sig-ref" class="form-label">Activity Reference</label>
|
||||
<input type="text" class="form-control input-narrow" id="sig-ref" placeholder="e.g. GB-0001">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/static/js/add-spot.js?v=1789763826"></script>
|
||||
<script src="/static/js/add-spot.js?v=1789763607"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-add-spot").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
@@ -18,13 +18,15 @@
|
||||
<div class="row row-cols-1 row-cols-md-3 g-4">
|
||||
<div class="col">
|
||||
{% module Template("cards/dx_continent.html", web_ui_options=web_ui_options) %}
|
||||
<div class="mt-4"></div>
|
||||
{% module Template("cards/duration_limit_alerts.html", web_ui_options=web_ui_options) %}
|
||||
</div>
|
||||
<div class="col">
|
||||
{% module Template("cards/activities.html", web_ui_options=web_ui_options) %}
|
||||
</div>
|
||||
<div class="col">
|
||||
{% module Template("cards/sources.html", web_ui_options=web_ui_options) %}
|
||||
</div>
|
||||
<div class="col">
|
||||
{% module Template("cards/duration_limit_alerts.html", web_ui_options=web_ui_options) %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,7 +85,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/static/js/alerts.js?v=1789763826"></script>
|
||||
<script src="/static/js/alerts.js?v=1789763607"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-alerts").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user