From a21782cb62a77da204be79e72d24941748352a72 Mon Sep 17 00:00:00 2001 From: Ian Renton Date: Mon, 20 Oct 2025 10:33:18 +0100 Subject: [PATCH] Icon lookup for SIGs in preparation for #47. Improved ZLOTA spotter lookup. --- alertproviders/parksnpeaks.py | 18 +++--------------- alertproviders/pota.py | 1 - alertproviders/sota.py | 1 - alertproviders/wwff.py | 1 - core/constants.py | 20 ++++++++++++++++++-- core/utility_functions.py | 8 ++++++++ data/alert.py | 7 ++++++- data/sig.py | 12 ++++++++++++ data/spot.py | 14 ++++++++++---- spotproviders/dxcluster.py | 2 ++ spotproviders/gma.py | 11 ++--------- spotproviders/hema.py | 1 - spotproviders/parksnpeaks.py | 28 ++++++++++------------------ spotproviders/pota.py | 1 - spotproviders/sota.py | 1 - spotproviders/wwbota.py | 1 - spotproviders/wwff.py | 1 - webassets/apidocs/openapi.yml | 21 ++++++++++++++++++--- 18 files changed, 89 insertions(+), 60 deletions(-) create mode 100644 core/utility_functions.py create mode 100644 data/sig.py diff --git a/alertproviders/parksnpeaks.py b/alertproviders/parksnpeaks.py index 2ba9da1..b63e3f9 100644 --- a/alertproviders/parksnpeaks.py +++ b/alertproviders/parksnpeaks.py @@ -42,21 +42,9 @@ class ParksNPeaks(HTTPAlertProvider): start_time=start_time, is_dxpedition=False) - # PNP supports a bunch of programs which should have different icons - if alert.sig == "SiOTA": - alert.icon = "wheat-awn" - elif alert.sig == "ZLOTA": - alert.icon = "kiwi-bird" - elif alert.sig == "KRMNPA": - alert.icon = "earth-oceania" - elif alert.sig in ["POTA", "SOTA", "WWFF"]: - # Don't care about an icon as this will be rejected anyway, we have better data from POTA/SOTA/WWFF direct - alert.icon = "" - else: - # Unknown programme we've never seen before - logging.warn( - "PNP alert found with sig " + alert.sig + ", developer needs to add support for this and set an icon!") - alert.icon = "question" + # Log a warning for the developer if PnP gives us an unknown programme we've never seen before + if alert.sig not in ["POTA", "SOTA", "WWFF", "SiOTA", "ZLOTA", "KRMNPA"]: + logging.warn("PNP alert found with sig " + alert.sig + ", developer needs to add support for this!") # If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. Otherwise, add to # the alert list. diff --git a/alertproviders/pota.py b/alertproviders/pota.py index bc1c2c9..f87c388 100644 --- a/alertproviders/pota.py +++ b/alertproviders/pota.py @@ -27,7 +27,6 @@ class POTA(HTTPAlertProvider): sig="POTA", sig_refs=[source_alert["reference"]], sig_refs_names=[source_alert["name"]], - icon="tree", start_time=datetime.strptime(source_alert["startDate"] + source_alert["startTime"], "%Y-%m-%d%H:%M").replace(tzinfo=pytz.UTC).timestamp(), end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"], diff --git a/alertproviders/sota.py b/alertproviders/sota.py index 072b09c..d7385d6 100644 --- a/alertproviders/sota.py +++ b/alertproviders/sota.py @@ -28,7 +28,6 @@ class SOTA(HTTPAlertProvider): sig="SOTA", sig_refs=[source_alert["associationCode"] + "/" + source_alert["summitCode"]], sig_refs_names=[source_alert["summitDetails"]], - icon="mountain-sun", start_time=datetime.strptime(source_alert["dateActivated"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=pytz.UTC).timestamp(), is_dxpedition=False) diff --git a/alertproviders/wwff.py b/alertproviders/wwff.py index 14dab7f..00ff196 100644 --- a/alertproviders/wwff.py +++ b/alertproviders/wwff.py @@ -26,7 +26,6 @@ class WWFF(HTTPAlertProvider): comment=source_alert["remarks"], sig="WWFF", sig_refs=[source_alert["reference"]], - icon="seedling", 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"], diff --git a/core/constants.py b/core/constants.py index 0acccf9..ab5348f 100644 --- a/core/constants.py +++ b/core/constants.py @@ -1,15 +1,31 @@ from core.config import SERVER_OWNER_CALLSIGN from data.band import Band +from data.sig import SIG # General software SOFTWARE_NAME = "Spothole by M0TRT" SOFTWARE_VERSION = "0.1" # HTTP headers used for spot providers that use HTTP -HTTP_HEADERS = {"User-Agent": SOFTWARE_NAME + " " + SOFTWARE_VERSION + " (operated by " + SERVER_OWNER_CALLSIGN + ")"} +HTTP_HEADERS = {"User-Agent": SOFTWARE_NAME + ", v" + SOFTWARE_VERSION + " (operated by " + SERVER_OWNER_CALLSIGN + ")"} # Special Interest Groups -SIGS = ["POTA", "SOTA", "WWFF", "GMA", "WWBOTA", "HEMA", "MOTA", "ARLHS", "ILLW", "SiOTA", "WCA", "ZLOTA", "IOTA", "KRMNPA"] +SIGS = [ + SIG(name="POTA", description="Parks on the Air", icon="tree"), + SIG(name="SOTA", description="Summits on the Air", icon="mountain-sun"), + SIG(name="WWFF", description="World Wide Flora & Fauna", icon="seedling"), + SIG(name="GMA", description="Global Mountain Activity", icon="person-hiking"), + SIG(name="WWBOTA", description="Worldwide Bunkers on the Air", icon="radiation"), + SIG(name="HEMA", description="HuMPs Excluding Marilyns Award", icon="mound"), + SIG(name="IOTA", description="Islands on the Air", icon="umbrella-beach"), + SIG(name="MOTA", description="Mills on the Air", icon="fan"), + SIG(name="ARLHS", description="Amateur Radio Lighthouse Society", icon="tower-observation"), + SIG(name="ILLW", description="International Lighthouse & Lightship Weekend", icon="tower-observation"), + SIG(name="SiOTA", description="Silos on the Air", icon="wheat-awn"), + SIG(name="WCA", description="World Castles Award", icon="chess-rook"), + SIG(name="ZLOTA", description="New Zealand on the Air", icon="kiwi-bird"), + SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award", icon="earth-oceania") +] # Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA". CW_MODES = ["CW"] diff --git a/core/utility_functions.py b/core/utility_functions.py new file mode 100644 index 0000000..799aa7e --- /dev/null +++ b/core/utility_functions.py @@ -0,0 +1,8 @@ +from core.constants import SIGS + +# Utility function to get the icon for a named SIG. If no match is found, the "circle-question" icon will be returned. +def get_icon_for_sig(sig): + for s in SIGS: + if s.name == sig: + return s.icon + return "circle-question" \ No newline at end of file diff --git a/data/alert.py b/data/alert.py index f18f8ac..a0fcdbf 100644 --- a/data/alert.py +++ b/data/alert.py @@ -9,6 +9,7 @@ import pytz from core.constants import DXCC_FLAGS from core.lookup_helper import lookup_helper +from core.utility_functions import get_icon_for_sig # Data class that defines an alert. @@ -59,7 +60,7 @@ class Alert: # Activation score. SOTA only activation_score: int = None # Icon, from the Font Awesome set. This is fairly opinionated but is here to help the alerthole web UI and Field alertter. Does not include the "fa-" prefix. - icon: str = "question" + icon: str = None # Whether this alert is for a DXpedition, as opposed to e.g. an xOTA programme. is_dxpedition: bool = False # Where we got the alert from, e.g. "POTA", "SOTA"... @@ -100,6 +101,10 @@ class Alert: if self.dx_dxcc_id and self.dx_dxcc_id in DXCC_FLAGS and not self.dx_flag: self.dx_flag = DXCC_FLAGS[self.dx_dxcc_id] + # Icon from SIG + if self.sig and not self.icon: + self.icon = get_icon_for_sig(self.sig) + # DX operator details lookup, using QRZ.com. This should be the last resort compared to taking the data from # the actual alertting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon instead of # the one from the park reference they're at. diff --git a/data/sig.py b/data/sig.py new file mode 100644 index 0000000..082425f --- /dev/null +++ b/data/sig.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + +# Data class that defines a Special Interest Group. +@dataclass +class SIG: + # SIG name, e.g. "POTA" + name: str + # Description, e.g. "Parks on the Air" + description: str + # Icon to use for it, from the Font Awesome set. This is fairly opinionated but is here to help the Spothole web UI + # and Field Spotter. Does not include the "fa-" prefix. + icon: str \ No newline at end of file diff --git a/data/spot.py b/data/spot.py index 7022c24..2bd6142 100644 --- a/data/spot.py +++ b/data/spot.py @@ -11,6 +11,7 @@ from pyhamtools.locator import locator_to_latlong, latlong_to_locator from core.constants import DXCC_FLAGS from core.lookup_helper import lookup_helper +from core.utility_functions import get_icon_for_sig # Data class that defines a spot. @@ -113,7 +114,7 @@ class Spot: # Icon, from the Font Awesome set. This is fairly opinionated but is here to help the Spothole web UI and Field # Spotter. Does not include the "fa-" prefix. - icon: str = "question" + icon: str = None # Colour to represent this spot, if a client chooses to colour spots based on their frequency band, using PSK # Reporter's default colours. HTML colour e.g. hex. A contrast colour is also provided which will be black or white. band_color: str = None @@ -218,6 +219,10 @@ class Spot: if self.mode and not self.mode_type: self.mode_type = lookup_helper.infer_mode_type_from_mode(self.mode) + # Icon from SIG + if self.sig and not self.icon: + self.icon = get_icon_for_sig(self.sig) + # DX Grid to lat/lon and vice versa if self.dx_grid and not self.dx_latitude: ll = locator_to_latlong(self.dx_grid) @@ -237,7 +242,8 @@ class Spot: # Clean up comments if self.comment: - comment = re.sub(r"\[.*]:", "", self.comment) + comment = re.sub(r"\(de [A-Za-z0-9]*\)", "", self.comment) + comment = re.sub(r"\[.*]:", "", comment) comment = re.sub(r"\[.*]", "", comment) comment = re.sub(r"\"\"", "", comment) self.comment = comment.strip() @@ -269,8 +275,8 @@ class Spot: self.dx_location_good = self.dx_location_source == "SPOT" or ( self.dx_location_source == "QRZ" and not "/" in self.dx_call) - # DE of "RBNHOLE" and "SOTAMAT" are not things we can look up location for - if self.de_call != "RBNHOLE" and self.de_call != "SOTAMAT": + # DE of "RBNHOLE", "SOTAMAT" and "ZLOTA" are not things we can look up location for + if self.de_call != "RBNHOLE" and self.de_call != "SOTAMAT" and self.de_call != "ZLOTA": # DE operator position lookup, using QRZ.com. if self.de_call and not self.de_latitude: latlon = lookup_helper.infer_latlon_from_callsign_qrz(self.de_call) diff --git a/spotproviders/dxcluster.py b/spotproviders/dxcluster.py index 3c6fd11..5d30e7a 100644 --- a/spotproviders/dxcluster.py +++ b/spotproviders/dxcluster.py @@ -75,6 +75,8 @@ class DXCluster(SpotProvider): icon="desktop", time=spot_datetime.timestamp()) + # See if the comment looks like it contains a SIG (and optionally SIG reference) + # Add to our list self.submit(spot) diff --git a/spotproviders/gma.py b/spotproviders/gma.py index c744127..8b0984e 100644 --- a/spotproviders/gma.py +++ b/spotproviders/gma.py @@ -57,27 +57,20 @@ class GMA(HTTPSpotProvider): match ref_info["reftype"]: case "Summit": spot.sig = "GMA" - spot.icon = "mountain" case "IOTA Island": spot.sig = "IOTA" - spot.icon = "umbrella-beach" case "Lighthouse (ILLW)": spot.sig = "ILLW" - spot.icon = "tower-observation" case "Lighthouse (ARLHS)": spot.sig = "ARLHS" - spot.icon = "tower-observation" case "Castle": - spot.sig = "WCA/COTA" - spot.icon = "chess-rook" + spot.sig = "WCA" case "Mill": spot.sig = "MOTA" - spot.icon = "fan" case _: logging.warn("GMA spot found with ref type " + ref_info[ - "reftype"] + ", developer needs to figure out an icon for this!") + "reftype"] + ", developer needs to add support for this!") spot.sig = ref_info["reftype"] - spot.icon = "person-hiking" # Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do # that for us. diff --git a/spotproviders/hema.py b/spotproviders/hema.py index 3355a1c..47b72ff 100644 --- a/spotproviders/hema.py +++ b/spotproviders/hema.py @@ -54,7 +54,6 @@ class HEMA(HTTPSpotProvider): sig="HEMA", sig_refs=[spot_items[3].upper()], sig_refs_names=[spot_items[4]], - icon="mound", time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M").replace(tzinfo=pytz.UTC).timestamp(), dx_latitude=float(spot_items[7]), dx_longitude=float(spot_items[8])) diff --git a/spotproviders/parksnpeaks.py b/spotproviders/parksnpeaks.py index e5d50f2..2147c05 100644 --- a/spotproviders/parksnpeaks.py +++ b/spotproviders/parksnpeaks.py @@ -1,5 +1,6 @@ import csv import logging +import re from datetime import datetime, timedelta import pytz @@ -32,7 +33,7 @@ class ParksNPeaks(HTTPSpotProvider): spot = Spot(source=self.name, source_id=source_spot["actID"], dx_call=source_spot["actCallsign"].upper(), - de_call=source_spot["actSpoter"].upper(), # typo exists in API + de_call=source_spot["actSpoter"].upper() if source_spot["actSpoter"] != "" else None, # typo exists in API freq=float(source_spot["actFreq"].replace(",", "")) * 1000000 if ( source_spot["actFreq"] != "") else None, # Seen PNP spots with empty frequency, and with comma-separated thousands digits @@ -47,21 +48,14 @@ class ParksNPeaks(HTTPSpotProvider): if "actLocation" in source_spot and source_spot["actLocation"] != "": spot.sig_refs_names = [source_spot["actLocation"]] - # PNP supports a bunch of programs which should have different icons - if spot.sig == "SiOTA": - spot.icon = "wheat-awn" - elif spot.sig == "ZLOTA": - spot.icon = "kiwi-bird" - elif spot.sig == "KRMNPA": - spot.icon = "earth-oceania" - elif spot.sig in ["POTA", "SOTA", "WWFF"]: - # Don't care about an icon as this will be rejected anyway, we have better data from POTA/SOTA/WWFF direct - spot.icon = "" - else: - # Unknown programme we've never seen before - logging.warn( - "PNP spot found with sig " + spot.sig + ", developer needs to add support for icon and grid/lat/lon lookup!") - spot.icon = "question" + # Extract a de_call if it's in the comment but not in the "actSpoter" field + m = re.search(r"\(de ([A-Za-z0-9]*)\)", spot.comment) + if (not spot.de_call or spot.de_call == "ZLOTA") and m is not None: + spot.de_call = m.group(1) + + # Log a warning for the developer if PnP gives us an unknown programme we've never seen before + if spot.sig not in ["POTA", "SOTA", "WWFF", "SiOTA", "ZLOTA", "KRMNPA"]: + logging.warn("PNP spot found with sig " + spot.sig + ", developer needs to add support for this!") # SiOTA lat/lon/grid lookup if spot.sig == "SiOTA": @@ -82,8 +76,6 @@ class ParksNPeaks(HTTPSpotProvider): spot.sig_refs_names = [asset["name"]] spot.dx_latitude = asset["y"] spot.dx_longitude = asset["x"] - # Junk the "DE call", PNP always returns "ZLOTA" as the spotter for ZLOTA spots - spot.de_call = None break # Note there is currently no support for KRMNPA location lookup, see issue #61. diff --git a/spotproviders/pota.py b/spotproviders/pota.py index 4a07c28..1530b6c 100644 --- a/spotproviders/pota.py +++ b/spotproviders/pota.py @@ -30,7 +30,6 @@ class POTA(HTTPSpotProvider): sig_refs=[source_spot["reference"]], sig_refs_names=[source_spot["name"]], sig_refs_urls=["https://pota.app/#/park/" + source_spot["reference"]], - icon="tree", time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=pytz.UTC).timestamp(), dx_grid=source_spot["grid6"], dx_latitude=source_spot["latitude"], diff --git a/spotproviders/sota.py b/spotproviders/sota.py index ae6573d..5ebfbcb 100644 --- a/spotproviders/sota.py +++ b/spotproviders/sota.py @@ -51,7 +51,6 @@ class SOTA(HTTPSpotProvider): sig_refs=[source_spot["summitCode"]], sig_refs_names=[source_spot["summitName"]], sig_refs_urls=["https://www.sotadata.org.uk/en/summit/" + source_spot["summitCode"]], - icon="mountain-sun", time=datetime.fromisoformat(source_spot["timeStamp"]).timestamp(), activation_score=source_spot["points"]) diff --git a/spotproviders/wwbota.py b/spotproviders/wwbota.py index bf73da0..02b0661 100644 --- a/spotproviders/wwbota.py +++ b/spotproviders/wwbota.py @@ -38,7 +38,6 @@ class WWBOTA(SSESpotProvider): sig="WWBOTA", sig_refs=refs, sig_refs_names=ref_names, - icon="radiation", time=datetime.fromisoformat(source_spot["time"]).timestamp(), # WWBOTA spots can contain multiple references for bunkers being activated simultaneously. For # now, we will just pick the first one to use as our grid, latitude and longitude. diff --git a/spotproviders/wwff.py b/spotproviders/wwff.py index 60b8cb5..edafdf3 100644 --- a/spotproviders/wwff.py +++ b/spotproviders/wwff.py @@ -30,7 +30,6 @@ class WWFF(HTTPSpotProvider): sig_refs=[source_spot["reference"]], sig_refs_names=[source_spot["reference_name"]], sig_refs_urls=["https://wwff.co/directory/?showRef=" + source_spot["reference"]], - icon="seedling", time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC).timestamp(), dx_latitude=source_spot["latitude"], dx_longitude=source_spot["longitude"]) diff --git a/webassets/apidocs/openapi.yml b/webassets/apidocs/openapi.yml index 104bbf2..3f83c43 100644 --- a/webassets/apidocs/openapi.yml +++ b/webassets/apidocs/openapi.yml @@ -413,8 +413,7 @@ paths: type: array description: An array of all the supported Special Interest Groups. items: - type: string - example: "POTA" + $ref: '#/components/schemas/SIG' sources: type: array description: An array of all the supported data sources. @@ -975,4 +974,20 @@ components: contrast_color: type: string description: Black or white, whichever provides the best contrast against the band colour. - example: white \ No newline at end of file + example: white + + SIG: + type: object + properties: + name: + type: string + description: The abbreviated name of the SIG + example: POTA + description: + type: string + description: The full name of the SIG + example: Parks on the Air + icon: + type: string + description: Icon, from the Font Awesome set. This is fairly opinionated but is here to help the Spothole web UI and Field Spotter. Does not include the "fa-" prefix. + example: tree \ No newline at end of file