Support RSGB contests

This commit is contained in:
Ian Renton
2026-09-05 09:00:32 +01:00
parent d3d1d20821
commit d6e53c14e9
32 changed files with 256 additions and 98 deletions
+6
View File
@@ -165,6 +165,12 @@ alert_providers:
- class: "NG3K" - class: "NG3K"
enabled: true enabled: true
- class: "RSGBHFContests"
enabled: true
- class: "RSGBVHFContests"
enabled: true
# Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band # Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band
# conditions, etc.) and make it available via the /api/v2/solar endpoint. # conditions, etc.) and make it available via the /api/v2/solar endpoint.
+11 -6
View File
@@ -30,7 +30,6 @@ class Mode(str, Enum):
OLIVIA = "OLIVIA" OLIVIA = "OLIVIA"
PKT = "PKT" PKT = "PKT"
MSK144 = "MSK144" MSK144 = "MSK144"
UNKNOWN = "UNKNOWN"
@property @property
def is_cw(self) -> bool: def is_cw(self) -> bool:
@@ -42,14 +41,14 @@ class Mode(str, Enum):
@property @property
def is_data(self) -> bool: def is_data(self) -> bool:
return not (self.is_cw or self.is_phone or self == Mode.UNKNOWN) return not (self.is_cw or self.is_phone)
@staticmethod @staticmethod
def from_name(name): def from_name(name):
"""Convert a string to an enum mode using the alias table.""" """Convert a string to an enum mode using the alias table."""
if not name: if not name:
return Mode.UNKNOWN return None
try: try:
return Mode(name.upper()) return Mode(name.upper())
@@ -57,14 +56,13 @@ class Mode(str, Enum):
try: try:
return Mode(MODE_ALIASES[name.upper()]) return Mode(MODE_ALIASES[name.upper()])
except (KeyError, ValueError): except (KeyError, ValueError):
return Mode.UNKNOWN return None
class ModeType(str, Enum): class ModeType(str, Enum):
PHONE = "PHONE" PHONE = "PHONE"
CW = "CW" CW = "CW"
DATA = "DATA" DATA = "DATA"
UNKNOWN = "UNKNOWN"
class ModeSource(str, Enum): class ModeSource(str, Enum):
@@ -115,7 +113,14 @@ class SIGRefType(str, Enum):
REGION = "REGION" REGION = "REGION"
GRID = "GRID" GRID = "GRID"
TOILET = "TOILET" TOILET = "TOILET"
UNKNOWN = "UNKNOWN"
class AlertType(str, Enum):
"""Type of an alert."""
XOTA = "XOTA"
DXPEDITION = "DXPEDITION"
CONTEST = "CONTEST"
class SIGType(str, Enum): class SIGType(str, Enum):
+6 -6
View File
@@ -20,11 +20,11 @@ def safe_json_dumps(obj):
return simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, default=lambda o: o.__dict__) return simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, default=lambda o: o.__dict__)
def infer_mode_from_comment(comment: str) -> Mode: def infer_mode_from_comment(comment: str) -> Mode | None:
"""Infer a mode from the comment""" """Infer a mode from the comment"""
if not comment: if not comment:
return Mode.UNKNOWN return None
for mode in Mode: for mode in Mode:
if re.search(r"(^|\W)" + mode + r"($|\W)", comment, re.IGNORECASE): if re.search(r"(^|\W)" + mode + r"($|\W)", comment, re.IGNORECASE):
@@ -33,14 +33,14 @@ def infer_mode_from_comment(comment: str) -> Mode:
if re.search(r"(^|\W)" + alias + r"($|\W)", comment, re.IGNORECASE): if re.search(r"(^|\W)" + alias + r"($|\W)", comment, re.IGNORECASE):
return Mode(MODE_ALIASES[alias]) return Mode(MODE_ALIASES[alias])
return Mode.UNKNOWN return None
def infer_mode_type_from_mode(mode: str) -> ModeType: def infer_mode_type_from_mode(mode: str) -> ModeType | None:
"""Infer a "mode family" from a mode .""" """Infer a "mode family" from a mode ."""
if not mode: if not mode:
return ModeType.UNKNOWN return None
if mode in MODE_ALIASES: if mode in MODE_ALIASES:
mode = MODE_ALIASES[mode] mode = MODE_ALIASES[mode]
@@ -56,7 +56,7 @@ def infer_mode_type_from_mode(mode: str) -> ModeType:
except ValueError: except ValueError:
if mode.upper() != "OTHER" and mode != "?": if mode.upper() != "OTHER" and mode != "?":
logger.warning(f"Found an unrecognised mode: {mode}. Developer should categorise this.") logger.warning(f"Found an unrecognised mode: {mode}. Developer should categorise this.")
return ModeType.UNKNOWN return None
def infer_band_from_freq(freq): def infer_band_from_freq(freq):
+5 -3
View File
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
import pytz import pytz
from core.call_lookup_helper import get_call_info from core.call_lookup_helper import get_call_info
from core.enums import Continent from core.enums import AlertType, Continent
from core.sig_lookup_helper import populate_missing_sig_ref_info from core.sig_lookup_helper import populate_missing_sig_ref_info
from core.utils import get_flag_for_dxcc from core.utils import get_flag_for_dxcc
@@ -58,8 +58,10 @@ class Alert:
sig: str | None = None sig: str | None = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO # SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
sig_refs: list | None = None sig_refs: list | None = None
# Whether this alert is for a DXpedition, as opposed to e.g. an xOTA programme. # The type of alert this is: xOTA, DXpedition, or Contest.
is_dxpedition: bool = False alert_type: AlertType | None = None
# A URL link to more information, if any
url: str | None = None
# Where we got the alert from, e.g. "POTA", "SOTA"... # Where we got the alert from, e.g. "POTA", "SOTA"...
source: str | None = None source: str | None = None
# The ID the source gave it, if any. # The ID the source gave it, if any.
+1 -1
View File
@@ -15,7 +15,7 @@ class SIGRef:
# Name of the reference, e.g. "Null Country Park", if known. # Name of the reference, e.g. "Null Country Park", if known.
name: str | None = None name: str | None = None
# Type of the reference, e.g. "Park", if known. # Type of the reference, e.g. "Park", if known.
ref_type: SIGRefType = SIGRefType.UNKNOWN ref_type: SIGRefType | None = None
# URL to look up more information about the reference, if known. # URL to look up more information about the reference, if known.
url: str | None = None url: str | None = None
# Latitude of the reference, in degrees, if known. # Latitude of the reference, in degrees, if known.
+6 -10
View File
@@ -103,9 +103,9 @@ class Spot:
# General QSO info # General QSO info
# Reported mode, such as SSB, PHONE, CW, FT8... # Reported mode, such as SSB, PHONE, CW, FT8...
mode: Mode = Mode.UNKNOWN mode: Mode | None = None
# Inferred mode "family". # Inferred mode "family".
mode_type: ModeType = ModeType.UNKNOWN mode_type: ModeType | None = None
# Source of the mode information. # Source of the mode information.
mode_source: ModeSource = ModeSource.NONE mode_source: ModeSource = ModeSource.NONE
# Frequency, in Hz # Frequency, in Hz
@@ -239,21 +239,17 @@ class Spot:
self.band = band.name self.band = band.name
# Mode from comments or bandplan # Mode from comments or bandplan
if not self.mode: if self.mode:
self.mode = Mode.UNKNOWN
if self.mode != Mode.UNKNOWN:
self.mode_source = ModeSource.SPOT self.mode_source = ModeSource.SPOT
if self.comment and self.mode == Mode.UNKNOWN: if self.comment and not self.mode:
self.mode = infer_mode_from_comment(self.comment) self.mode = infer_mode_from_comment(self.comment)
self.mode_source = ModeSource.COMMENT self.mode_source = ModeSource.COMMENT
if self.freq and self.mode == Mode.UNKNOWN: if self.freq and not self.mode:
self.mode = infer_mode_from_frequency(self.freq) self.mode = infer_mode_from_frequency(self.freq)
self.mode_source = ModeSource.BANDPLAN self.mode_source = ModeSource.BANDPLAN
# Mode type from mode # Mode type from mode
if not self.mode_type: if self.mode and not self.mode_type:
self.mode_type = ModeType.UNKNOWN
if self.mode != Mode.UNKNOWN and self.mode_type == ModeType.UNKNOWN:
self.mode_type = infer_mode_type_from_mode(self.mode) self.mode_type = infer_mode_type_from_mode(self.mode)
# If we have a latitude or grid at this point, it can only have been provided by the spot itself # If we have a latitude or grid at this point, it can only have been provided by the spot itself
+2 -1
View File
@@ -3,6 +3,7 @@ from datetime import datetime, timedelta
import pytz import pytz
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from core.enums import AlertType
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -57,7 +58,7 @@ class BOTA(HTTPAlertProvider):
dx_calls=[dx_call], dx_calls=[dx_call],
sig_refs=[SIGRef(id=ref_name, sig="BOTA")], sig_refs=[SIGRef(id=ref_name, sig="BOTA")],
start_time=date_time.timestamp(), start_time=date_time.timestamp(),
is_dxpedition=False, alert_type=AlertType.XOTA,
) )
new_alerts.append(alert) new_alerts.append(alert)
+5 -4
View File
@@ -6,19 +6,20 @@ import pytz
from rss_parser import Parser from rss_parser import Parser
from rss_parser.models.rss import RSS from rss_parser.models.rss import RSS
from core.enums import AlertType
from data.alert import Alert from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
class NG3K(HTTPAlertProvider): class NG3K(HTTPAlertProvider):
"""Alert provider NG3K DXpedition list""" """Alert provider for NG3K DXpedition list"""
POLL_INTERVAL_SEC = 1800 POLL_INTERVAL_DAYS = 1
ALERTS_URL = "https://www.ng3k.com/adxo.xml" ALERTS_URL = "https://www.ng3k.com/adxo.xml"
AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE) AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE)
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response):
new_alerts = [] new_alerts = []
@@ -88,7 +89,7 @@ class NG3K(HTTPAlertProvider):
comment=f"{by}; {comment}; {qsl_info}", comment=f"{by}; {comment}; {qsl_info}",
start_time=start_timestamp, start_time=start_timestamp,
end_time=end_timestamp, end_time=end_timestamp,
is_dxpedition=True, alert_type=AlertType.DXPEDITION,
) )
# Add to our list. # Add to our list.
+2 -1
View File
@@ -3,6 +3,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import AlertType
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -51,7 +52,7 @@ class ParksNPeaks(HTTPAlertProvider):
comment=source_alert["Comments"], comment=source_alert["Comments"],
sig_refs=sigrefs, sig_refs=sigrefs,
start_time=start_time, start_time=start_time,
is_dxpedition=False, alert_type=AlertType.XOTA,
) )
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before # Log a warning for the developer if PnP gives us an unknown programme we've never seen before
+2 -1
View File
@@ -2,6 +2,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import AlertType
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -44,7 +45,7 @@ class POTA(HTTPAlertProvider):
end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"], "%Y-%m-%d%H:%M") end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"], "%Y-%m-%d%H:%M")
.replace(tzinfo=pytz.UTC) .replace(tzinfo=pytz.UTC)
.timestamp(), .timestamp(),
is_dxpedition=False, alert_type=AlertType.XOTA,
) )
# Add to our list, but exclude any old spots that POTA can sometimes give us where even the end time is # Add to our list, but exclude any old spots that POTA can sometimes give us where even the end time is
+101
View File
@@ -0,0 +1,101 @@
import re
from datetime import datetime, time
from typing import cast
import pytz
from icalendar import Calendar, Event
from core.enums import AlertType, Continent
from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider
class RSGBICALAlertProvider(HTTPAlertProvider):
"""Generic alert provider for RSGB contest ICAL calendars."""
def __init__(self, name, provider_config, url, poll_interval):
super().__init__(name, provider_config, url, poll_interval)
FREQ_PATTERN = re.compile(r"([\d.]+(?:MHz|GHz))|SHF")
def _http_response_to_alerts(self, http_response):
new_alerts = []
cal = Calendar.from_ical(http_response.content)
# Iterate through events
for component in cal.walk():
if component.name != "VEVENT":
continue
event = cast(Event, component)
summary = str(event.get("summary", "")).strip()
# Ensure summaries start with "RSGB" to avoid any confusion
if not summary.startswith("RSGB "):
summary = "RSGB " + summary
# Extract freq from summary
freqs_modes = ""
# HF contests don't give frequencies, all VHF ones do
match = self.FREQ_PATTERN.search(summary)
if match:
freqs_modes = match.group()
else:
freqs_modes = "HF bands"
freqs_modes = freqs_modes + ", "
# Extract mode from summary
if "FMAC" in summary:
freqs_modes = freqs_modes + "FM"
elif "CW" in summary:
freqs_modes = freqs_modes + "CW"
elif "SSB" in summary:
freqs_modes = freqs_modes + "SSB"
elif "FT8" in summary:
freqs_modes = freqs_modes + "FT8"
elif "FT4" in summary:
freqs_modes = freqs_modes + "FT4"
elif "DATA" in summary:
freqs_modes = freqs_modes + "Data modes"
else:
freqs_modes = freqs_modes + "All modes"
dtstart = event.get("dtstart")
dtend = event.get("dtend")
start_timestamp = self._to_utc_timestamp(dtstart.dt)
end_timestamp = self._to_utc_timestamp(dtend.dt) - 1 if dtend is not None else start_timestamp
# Convert to our alert format
alert = Alert(
source=self.name,
dx_calls=[],
dx_country="United Kingdom",
dx_dxcc_id=235,
dx_continent=Continent.EU,
dx_cq_zone=14,
dx_itu_zone=27,
dx_flag="🇬🇧",
freqs_modes=freqs_modes,
comment=summary,
start_time=start_timestamp,
end_time=end_timestamp,
alert_type=AlertType.CONTEST,
)
# Add to our list.
new_alerts.append(alert)
return new_alerts
@staticmethod
def _to_utc_timestamp(value):
"""Convert a date or datetime value from an iCal field into a UTC UNIX timestamp."""
# Datetime object so we can treat it as-is, check if it has a non-UTC tz and convert it if necessary
if isinstance(value, datetime):
if value.tzinfo is None:
value = pytz.UTC.localize(value)
return value.astimezone(pytz.UTC).timestamp()
# Date object so this is an all day event
return pytz.UTC.localize(datetime.combine(value, time.min)).timestamp()
+11
View File
@@ -0,0 +1,11 @@
from providers.alert.rsgb_ical_alert_provider import RSGBICALAlertProvider
class RSGBHFContests(RSGBICALAlertProvider):
"""Alert provider for RSGB HF Contest calendar"""
POLL_INTERVAL_DAYS = 30
ALERTS_URL = "https://calendar.google.com/calendar/ical/a5ff31ebb1b4834dc7fff4c5415ae8251c6a9aa11f98c6af6e472b6c552b1915%40group.calendar.google.com/public/basic.ics"
def __init__(self, provider_config):
super().__init__("RSGB HF Contests", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
+11
View File
@@ -0,0 +1,11 @@
from providers.alert.rsgb_ical_alert_provider import RSGBICALAlertProvider
class RSGBVHFContests(RSGBICALAlertProvider):
"""Alert provider for RSGB VHF Contest calendar"""
POLL_INTERVAL_DAYS = 30
ALERTS_URL = "https://calendar.google.com/calendar/ical/40f3552bff39a016f1cdca205864177070dcad68d55be17eb061cb021f39f96c%40group.calendar.google.com/public/basic.ics"
def __init__(self, provider_config):
super().__init__("RSGB VHF Contests", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
+2 -1
View File
@@ -2,6 +2,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import AlertType
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -44,7 +45,7 @@ class SOTA(HTTPAlertProvider):
start_time=datetime.strptime(source_alert["dateActivated"], "%Y-%m-%dT%H:%M:%SZ") start_time=datetime.strptime(source_alert["dateActivated"], "%Y-%m-%dT%H:%M:%SZ")
.replace(tzinfo=pytz.UTC) .replace(tzinfo=pytz.UTC)
.timestamp(), .timestamp(),
is_dxpedition=False, alert_type=AlertType.XOTA,
) )
# Add to our list # Add to our list
+2 -1
View File
@@ -2,6 +2,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import AlertType
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -34,7 +35,7 @@ class WWFF(HTTPAlertProvider):
end_time=datetime.strptime(source_alert["utc_end"], "%Y-%m-%d %H:%M:%S") end_time=datetime.strptime(source_alert["utc_end"], "%Y-%m-%d %H:%M:%S")
.replace(tzinfo=pytz.UTC) .replace(tzinfo=pytz.UTC)
.timestamp(), .timestamp(),
is_dxpedition=False, alert_type=AlertType.XOTA,
) )
# Add to our list # Add to our list
+1 -1
View File
@@ -30,7 +30,7 @@ class ZLOTA(FileDownloadSIGRefDataProvider):
try: try:
ref_type = SIGRefType(ref["asset_type"].title().upper()) ref_type = SIGRefType(ref["asset_type"].title().upper())
except ValueError: except ValueError:
ref_type = SIGRefType.UNKNOWN ref_type = None
new_ref = SIGRef( new_ref = SIGRef(
sig=self.SIG, sig=self.SIG,
+1 -3
View File
@@ -66,9 +66,7 @@ class GMA(HTTPSpotProvider):
if (source_spot["QRG"] != "" and source_spot["QRG"] != "QRT") if (source_spot["QRG"] != "" and source_spot["QRG"] != "QRT")
else None, else None,
# Filter out some weird mode strings # Filter out some weird mode strings
mode=Mode.from_name(source_spot["MODE"].upper()) mode=Mode.from_name(source_spot["MODE"].upper()) if "<>" not in source_spot["MODE"] else None,
if "<>" not in source_spot["MODE"]
else Mode.UNKNOWN,
comment=source_spot["TEXT"], comment=source_spot["TEXT"],
sig_refs=[ sig_refs=[
SIGRef( SIGRef(
+2 -2
View File
@@ -27,7 +27,7 @@ class WWBOTA(SSESpotProvider):
name=ref["name"], name=ref["name"],
latitude=ref["lat"], latitude=ref["lat"],
longitude=ref["long"], longitude=ref["long"],
ref_type=SIGRefType.BUNKER ref_type=SIGRefType.BUNKER,
) )
refs.append(sigref) refs.append(sigref)
@@ -36,7 +36,7 @@ class WWBOTA(SSESpotProvider):
dx_call=source_spot["call"].upper(), dx_call=source_spot["call"].upper(),
de_call=source_spot["spotter"].upper(), de_call=source_spot["spotter"].upper(),
freq=float(source_spot["freq"]) * 1000000, freq=float(source_spot["freq"]) * 1000000,
mode=Mode.from_name(source_spot["mode"].upper()) if "mode" in source_spot else Mode.UNKNOWN, mode=Mode.from_name(source_spot["mode"].upper()) if "mode" in source_spot else None,
comment=source_spot["comment"], comment=source_spot["comment"],
sig="WWBOTA", sig="WWBOTA",
sig_refs=refs, sig_refs=refs,
+2 -1
View File
@@ -24,4 +24,5 @@ fastkml~=1.4.0
ruff~=0.16.3 ruff~=0.16.3
pdfplumber~=0.11.10 pdfplumber~=0.11.10
xlrd~=2.0.2 xlrd~=2.0.2
openpyxl~=3.1.5 openpyxl~=3.1.5
icalendar~=7.3.0
+13 -5
View File
@@ -9,6 +9,7 @@ import tornado_eventsource.handler
from tornado import httputil from tornado import httputil
from tornado.web import Application from tornado.web import Application
from core.enums import AlertType
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps from core.utils import safe_json_dumps
from data.lookup_credentials import extract_credentials from data.lookup_credentials import extract_credentials
@@ -169,11 +170,18 @@ def alert_allowed_by_query(alert, query):
max_duration = int(query.get(k)) max_duration = int(query.get(k))
# Check the duration if end_time is provided. If end_time is not provided, assume the activation is # Check the duration if end_time is provided. If end_time is not provided, assume the activation is
# "short", i.e. it always passes this check. If dxpeditions_skip_max_duration_check is true and # "short", i.e. it always passes this check. If dxpeditions_skip_max_duration_check is true and
# the alert is a dxpedition, it also always passes the check. # the alert is a dxpedition, or contests_skip_max_duration_check and the alert is a contest, it also
if alert.is_dxpedition and ( # always passes the check.
query.get("dxpeditions_skip_max_duration_check").upper() == "TRUE" if (
if "dxpeditions_skip_max_duration_check" in query alert.alert_type == AlertType.DXPEDITION
else False and "dxpeditions_skip_max_duration_check" in query
and query.get("dxpeditions_skip_max_duration_check").upper() == "TRUE"
):
continue
if (
alert.alert_type == AlertType.CONTEST
and "contests_skip_max_duration_check" in query
and query.get("contests_skip_max_duration_check").upper() == "TRUE"
): ):
continue continue
if alert.end_time and alert.start_time and alert.end_time - alert.start_time > max_duration: if alert.end_time and alert.start_time and alert.end_time - alert.start_time > max_duration:
+32 -7
View File
@@ -19,8 +19,9 @@ info:
* Added DTMBA, FEA, BIWOTA, COTA & PGA SIGs * Added DTMBA, FEA, BIWOTA, COTA & PGA SIGs
* Removed the distinction between LSB & USB (both will now show as SSB) and between the various digital voice modes, which will now show as DV. * Removed the distinction between LSB & USB (both will now show as SSB) and between the various digital voice modes, which will now show as DV.
* Added `sig_type`, `icon`, `region_flag` and `refs_globally_unique` to SIG information * Added `sig_type`, `icon`, `region_flag` and `refs_globally_unique` to SIG data
* Unknown modes and mode types now return "UNKNOWN" not null * Added `alert_type` and `url` to alert data
* Added `contests_skip_max_duration_check` to alert query parameters
* SIG reference types (e.g. "Park") are now capitalised to match other enums * SIG reference types (e.g. "Park") are now capitalised to match other enums
### 2.0 ### 2.0
@@ -209,6 +210,7 @@ paths:
- $ref: '#/components/parameters/AlertReceivedSince' - $ref: '#/components/parameters/AlertReceivedSince'
- $ref: '#/components/parameters/AlertMaxDuration' - $ref: '#/components/parameters/AlertMaxDuration'
- $ref: '#/components/parameters/AlertDxpeditionsSkipMaxDurationCheck' - $ref: '#/components/parameters/AlertDxpeditionsSkipMaxDurationCheck'
- $ref: '#/components/parameters/AlertContestsSkipMaxDurationCheck'
- $ref: '#/components/parameters/AlertSource' - $ref: '#/components/parameters/AlertSource'
- $ref: '#/components/parameters/AlertSig' - $ref: '#/components/parameters/AlertSig'
- $ref: '#/components/parameters/AlertDxContinent' - $ref: '#/components/parameters/AlertDxContinent'
@@ -243,6 +245,7 @@ paths:
parameters: parameters:
- $ref: '#/components/parameters/AlertMaxDuration' - $ref: '#/components/parameters/AlertMaxDuration'
- $ref: '#/components/parameters/AlertDxpeditionsSkipMaxDurationCheck' - $ref: '#/components/parameters/AlertDxpeditionsSkipMaxDurationCheck'
- $ref: '#/components/parameters/AlertContestsSkipMaxDurationCheck'
- $ref: '#/components/parameters/AlertSource' - $ref: '#/components/parameters/AlertSource'
- $ref: '#/components/parameters/AlertSig' - $ref: '#/components/parameters/AlertSig'
- $ref: '#/components/parameters/AlertDxContinent' - $ref: '#/components/parameters/AlertDxContinent'
@@ -661,8 +664,8 @@ components:
time minus start time, if end time is set, otherwise the activation is assumed to be short and time minus start time, if end time is set, otherwise the activation is assumed to be short and
therefore to always pass this check. This is useful to filter out people who alert POTA therefore to always pass this check. This is useful to filter out people who alert POTA
activations lasting months or even years, but note it will also include multi-day or multi-week activations lasting months or even years, but note it will also include multi-day or multi-week
DXpeditions that you might otherwise be interested in. See the DXpeditions or contests that you might otherwise be interested in. See the
dxpeditions_skip_max_duration_check parameter for the workaround. dxpeditions_skip_max_duration_check and contests_skip_max_duration_check parameters for the workaround.
schema: schema:
type: integer type: integer
AlertDxpeditionsSkipMaxDurationCheck: AlertDxpeditionsSkipMaxDurationCheck:
@@ -675,6 +678,16 @@ components:
on the air most of the time. on the air most of the time.
schema: schema:
type: boolean type: boolean
AlertContestsSkipMaxDurationCheck:
name: contests_skip_max_duration_check
in: query
description: >
Return contest alerts even if they last longer than max_duration. This allows the user to
filter out multi-day/multi-week POTA alerts where the operator likely won't be on the air most
of the time, but keep multi-day/multi-week contests where contesters likely *will* be
on the air most of the time.
schema:
type: boolean
AlertSource: AlertSource:
name: source name: source
in: query in: query
@@ -903,9 +916,16 @@ components:
- REGION - REGION
- GRID - GRID
- TOILET - TOILET
- UNKNOWN
example: PARK example: PARK
AlertType:
type: string
enum:
- XOTA
- DXPEDITION
- CONTEST
example: XOTA
Continent: Continent:
type: string type: string
enum: enum:
@@ -970,7 +990,6 @@ components:
- FSK - FSK
- PKT - PKT
- MSK144 - MSK144
- UNKNOWN
example: SSB example: SSB
ModeType: ModeType:
@@ -979,7 +998,6 @@ components:
- CW - CW
- PHONE - PHONE
- DATA - DATA
- UNKNOWN
example: CW example: CW
ModeSource: ModeSource:
@@ -1433,6 +1451,13 @@ components:
items: items:
$ref: '#/components/schemas/SIGRef' $ref: '#/components/schemas/SIGRef'
description: SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO 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"
url:
type: string
description: A URL linking to more information about the alert, e.g. DXpedition or contest info.
example: "https://www.rsgbcc.org/cgi-bin/contest_rules.pl?year=2026&contest=144backpack1"
source: source:
type: string type: string
description: Where we got the alert from. description: Where we got the alert from.
+11 -25
View File
@@ -13,7 +13,6 @@ function loadAlerts() {
url: '/api/v2/alerts' + buildQueryString(), dataType: 'json', success: function (jsonData) { url: '/api/v2/alerts' + buildQueryString(), dataType: 'json', success: function (jsonData) {
// Store last updated time // Store last updated time
lastUpdateTime = moment.utc(); lastUpdateTime = moment.utc();
updateRefreshDisplay();
// Store data // Store data
alerts = jsonData; alerts = jsonData;
// Update table // Update table
@@ -38,6 +37,9 @@ function buildQueryString() {
if ($("#dxpeditions_skip_max_duration_check")[0].checked) { if ($("#dxpeditions_skip_max_duration_check")[0].checked) {
str = str + "&dxpeditions_skip_max_duration_check=true"; str = str + "&dxpeditions_skip_max_duration_check=true";
} }
if ($("#contests_skip_max_duration_check")[0].checked) {
str = str + "&contests_skip_max_duration_check=true";
}
return str; return str;
} }
@@ -208,10 +210,14 @@ function addAlertRowsToTable(tbody, alerts) {
if (a["dx_calls"] != null) { 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(", "); 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") {
// Contest = true and no DX callsigns, so display "Contest"
dx_calls_html = "Contest"
}
// Format DXpedition country // Format DXpedition country
let dx_country_html = ""; let dx_country_html = "";
if (a["is_dxpedition"] === true && a["dx_country"] != null && a["dx_country"] !== "") { if (a["alert_type"] === "DXPEDITION" && a["dx_country"] != null && a["dx_country"] !== "") {
dx_country_html = `<br/>${a["dx_country"]}`; dx_country_html = `<br/>${a["dx_country"]}`;
} }
@@ -226,6 +232,9 @@ function addAlertRowsToTable(tbody, alerts) {
if (a["comment"] != null) { if (a["comment"] != null) {
commentText = escapeHtml(a["comment"]); commentText = escapeHtml(a["comment"]);
} }
if (a["url"] != null) {
commentText = `<a href="${escapeHtml(a['url'])}" target="_new">${commentText}</a>`;
}
// Sig or fallback to source // Sig or fallback to source
let sigSourceText = a["source"]; let sigSourceText = a["source"];
@@ -331,33 +340,10 @@ function filtersUpdated() {
saveSettings(); saveSettings();
} }
// Update the refresh timing display
function updateRefreshDisplay() {
if (lastUpdateTime != null) {
let secSinceUpdate = moment.duration(moment().diff(lastUpdateTime)).asSeconds();
let count = REFRESH_INTERVAL_SEC;
let updatingString = "Updating..."
if (secSinceUpdate < REFRESH_INTERVAL_SEC) {
count = REFRESH_INTERVAL_SEC - secSinceUpdate;
let number;
if (count <= 60) {
number = count.toFixed(0);
updatingString = "<span class='nowrap'>Updating in " + number + " second" + (number !== "1" ? "s" : "") + ".</span>";
} else {
number = Math.round(count / 60.0).toFixed(0);
updatingString = "<span class='nowrap'>Updating in " + number + " minute" + (number !== "1" ? "s" : "") + ".</span>";
}
}
$("#timing-container").html("Last updated at " + lastUpdateTime.format('HH:mm') + " UTC. " + updatingString);
}
}
// Startup // Startup
$(document).ready(function () { $(document).ready(function () {
// Call loadOptions(), this will then trigger loading alerts and setting up timers. // Call loadOptions(), this will then trigger loading alerts and setting up timers.
loadOptions(); loadOptions();
// Update the refresh timing display every second
setInterval(updateRefreshDisplay, 1000);
}); });
// Reload alerts on becoming visible. This forces a refresh when used as a PWA and the user switches back to the PWA // Reload alerts on becoming visible. This forces a refresh when used as a PWA and the user switches back to the PWA
+1 -1
View File
@@ -77,7 +77,7 @@
</div> </div>
<script src="/static/js/add-spot.js?v=1788546948"></script> <script src="/static/js/add-spot.js?v=1788595232"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-add-spot").addClass("active"); $("#nav-link-add-spot").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+1 -4
View File
@@ -3,9 +3,6 @@
<div class="mt-3"> <div class="mt-3">
<div id="settingsButtonRow" class="row mb-3"> <div id="settingsButtonRow" class="row mb-3">
<div class="col-auto me-auto pt-3">
{% module Template("widgets/refresh_timer.html", web_ui_options=web_ui_options) %}
</div>
<div class="col-auto"> <div class="col-auto">
<div class="d-inline-flex gap-1"> <div class="d-inline-flex gap-1">
{% module Template("widgets/filters_display_data_buttons.html", web_ui_options=web_ui_options) %} {% module Template("widgets/filters_display_data_buttons.html", web_ui_options=web_ui_options) %}
@@ -84,7 +81,7 @@
</div> </div>
<script src="/static/js/alerts.js?v=1788546948"></script> <script src="/static/js/alerts.js?v=1788595233"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-alerts").addClass("active"); $("#nav-link-alerts").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -76,8 +76,8 @@
</div> </div>
<script src="/static/js/spotsbandsandmap.js?v=1788546948"></script> <script src="/static/js/spotsbandsandmap.js?v=1788595232"></script>
<script src="/static/js/bands.js?v=1788546948"></script> <script src="/static/js/bands.js?v=1788595232"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-bands").addClass("active"); $("#nav-link-bands").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+5 -5
View File
@@ -1,6 +1,6 @@
{% extends "skeleton.html" %} {% extends "skeleton.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/css/style.css?v=1788546948" type="text/css"> <link rel="stylesheet" href="/static/css/style.css?v=1788595232" type="text/css">
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet"> <link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet"> <link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet"> <link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
@@ -16,10 +16,10 @@
window.fetchEventSource = fetchEventSource; window.fetchEventSource = fetchEventSource;
</script> </script>
<script src="/static/js/utils.js?v=1788546948"></script> <script src="/static/js/utils.js?v=1788595232"></script>
<script src="/static/js/ui-ham.js?v=1788546948"></script> <script src="/static/js/ui-ham.js?v=1788595232"></script>
<script src="/static/js/geo.js?v=1788546948"></script> <script src="/static/js/geo.js?v=1788595232"></script>
<script src="/static/js/common.js?v=1788546948"></script> <script src="/static/js/common.js?v=1788595232"></script>
{% end %} {% end %}
{% block body %} {% block body %}
<div class="container"> <div class="container">
@@ -21,5 +21,11 @@
for="dxpeditions_skip_max_duration_check">Allow for="dxpeditions_skip_max_duration_check">Allow
DXpeditions that are longer</label> DXpeditions that are longer</label>
</p> </p>
<p class='card-text spothole-card-text' style='line-height: 1.5em !important;'>
<input class="form-check-input storeable-checkbox" type="checkbox" value="" onclick="filtersUpdated();"
id="contests_skip_max_duration_check" checked><label class="form-check-label ms-2"
for="contests_skip_max_duration_check">Allow
DXpeditions that are longer</label>
</p>
</div> </div>
</div> </div>
+1 -1
View File
@@ -284,7 +284,7 @@
</div> </div>
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script> <script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
<script src="/static/js/conditions.js?v=1788546948"></script> <script src="/static/js/conditions.js?v=1788595232"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active"); $("#nav-link-conditions").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -113,8 +113,8 @@
const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}"; const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}";
</script> </script>
<script src="/static/js/spotsbandsandmap.js?v=1788546948"></script> <script src="/static/js/spotsbandsandmap.js?v=1788595233"></script>
<script src="/static/js/map.js?v=1788546948"></script> <script src="/static/js/map.js?v=1788595233"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-map").addClass("active"); $("#nav-link-map").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -113,8 +113,8 @@
</div> </div>
<script src="/static/js/spotsbandsandmap.js?v=1788546948"></script> <script src="/static/js/spotsbandsandmap.js?v=1788595232"></script>
<script src="/static/js/spots.js?v=1788546948"></script> <script src="/static/js/spots.js?v=1788595232"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-spots").addClass("active"); $("#nav-link-spots").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -86,7 +86,7 @@
</div> </div>
</div> </div>
<script src="/static/js/status.js?v=1788546948"></script> <script src="/static/js/status.js?v=1788595232"></script>
<script> <script>
$(document).ready(function () { $(document).ready(function () {
$("#nav-link-status").addClass("active"); $("#nav-link-status").addClass("active");
-1
View File
@@ -1 +0,0 @@
<div id="timing-container">Loading...</div>