Compare commits

..
6 Commits
39 changed files with 418 additions and 159 deletions
+6
View File
@@ -165,6 +165,12 @@ alert_providers:
- class: "NG3K"
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
# conditions, etc.) and make it available via the /api/v2/solar endpoint.
+37 -3
View File
@@ -1,5 +1,5 @@
from core.config import SERVER_OWNER_CALLSIGN
from core.enums import SIGType
from core.enums import SIGRefType, SIGType
from data.band import Band
from data.sig import SIG
@@ -17,6 +17,7 @@ SIGS = [
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,
@@ -26,6 +27,7 @@ SIGS = [
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,
@@ -35,6 +37,7 @@ SIGS = [
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,
@@ -44,6 +47,7 @@ SIGS = [
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,
@@ -53,6 +57,7 @@ SIGS = [
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,
@@ -62,6 +67,7 @@ SIGS = [
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,
@@ -71,6 +77,7 @@ SIGS = [
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,
@@ -80,6 +87,7 @@ SIGS = [
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,
@@ -89,6 +97,7 @@ SIGS = [
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,
@@ -98,6 +107,7 @@ SIGS = [
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,
@@ -107,6 +117,7 @@ SIGS = [
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,
@@ -116,6 +127,7 @@ SIGS = [
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,
@@ -125,6 +137,7 @@ SIGS = [
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,
@@ -134,6 +147,7 @@ SIGS = [
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="🇳🇿",
@@ -144,6 +158,7 @@ SIGS = [
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="🇬🇧",
@@ -154,6 +169,7 @@ SIGS = [
comment_names=[],
description="Beaches on the Air",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.BEACH,
icon="fa-umbrella-beach",
refs_globally_unique=False,
),
@@ -162,6 +178,7 @@ SIGS = [
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="🇦🇺",
@@ -172,6 +189,7 @@ SIGS = [
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="🇦🇺",
@@ -182,6 +200,7 @@ SIGS = [
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,
@@ -191,6 +210,7 @@ SIGS = [
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,
@@ -200,6 +220,7 @@ SIGS = [
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,
@@ -209,6 +230,7 @@ SIGS = [
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="🇬🇧",
@@ -219,6 +241,7 @@ SIGS = [
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="🇮🇪",
@@ -229,16 +252,18 @@ SIGS = [
comment_names=["DME"],
description="Diploma Municipios de España",
sig_type=SIGType.REGIONAL,
ref_regex=r"\d{4,5}",
ref_type=SIGRefType.TOWN,
ref_regex=r"DME[\- ]\d{3,5}",
icon="fa-building",
region_flag="🇪🇸",
refs_globally_unique=False,
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.
@@ -252,6 +277,7 @@ SIGS = [
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="🇪🇸",
@@ -262,6 +288,7 @@ SIGS = [
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="🇪🇸",
@@ -272,6 +299,7 @@ SIGS = [
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="🇪🇸",
@@ -282,6 +310,7 @@ SIGS = [
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="🇪🇸",
@@ -292,6 +321,7 @@ SIGS = [
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="🇮🇹",
@@ -302,6 +332,7 @@ SIGS = [
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,
@@ -311,6 +342,7 @@ SIGS = [
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="🇩🇪",
@@ -321,6 +353,7 @@ SIGS = [
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="🇵🇱",
@@ -331,6 +364,7 @@ SIGS = [
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="🏴‍☠️",
+11 -6
View File
@@ -30,7 +30,6 @@ class Mode(str, Enum):
OLIVIA = "OLIVIA"
PKT = "PKT"
MSK144 = "MSK144"
UNKNOWN = "UNKNOWN"
@property
def is_cw(self) -> bool:
@@ -42,14 +41,14 @@ class Mode(str, Enum):
@property
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
def from_name(name):
"""Convert a string to an enum mode using the alias table."""
if not name:
return Mode.UNKNOWN
return None
try:
return Mode(name.upper())
@@ -57,14 +56,13 @@ class Mode(str, Enum):
try:
return Mode(MODE_ALIASES[name.upper()])
except (KeyError, ValueError):
return Mode.UNKNOWN
return None
class ModeType(str, Enum):
PHONE = "PHONE"
CW = "CW"
DATA = "DATA"
UNKNOWN = "UNKNOWN"
class ModeSource(str, Enum):
@@ -115,7 +113,14 @@ class SIGRefType(str, Enum):
REGION = "REGION"
GRID = "GRID"
TOILET = "TOILET"
UNKNOWN = "UNKNOWN"
class AlertType(str, Enum):
"""Type of an alert."""
XOTA = "XOTA"
DXPEDITION = "DXPEDITION"
CONTEST = "CONTEST"
class SIGType(str, Enum):
+37 -28
View File
@@ -1,21 +1,24 @@
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, ref_id):
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 is None or sig == "" or ref_id is None or ref_id == "":
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
@@ -24,33 +27,43 @@ def get_sig_ref_info(sig, ref_id):
ref_id.replace(" ", "-")
# Prepare the object to be returned
sig_ref = SIGRef(sig=sig, id=ref_id)
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.
if sig.upper() == "DME":
ref_id = ref_id.zfill(5)
# 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)
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.upper() == "DTMBA":
if sig_name.upper() == "DTMBA":
ref_id = ref_id.replace("-", "").replace(" ", "")
# If the SIG is HEMA, we have no current lookup for this so just skip the lookup here.
if sig.upper() == "HEMA":
sig_ref.ref_type = "Summit"
### 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.upper() == "TILES":
if sig_name.upper() == "TILES":
# Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same
sig_ref.ref_type = "Grid"
if not sig_ref.name:
sig_ref.name = sig_ref.id
if not sig_ref.grid:
@@ -61,8 +74,7 @@ def get_sig_ref_info(sig, ref_id):
sig_ref.longitude = ll[1]
return sig_ref
elif sig.upper() == "WAB" or sig.upper() == "WAI":
sig_ref.ref_type = "Grid"
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
@@ -74,20 +86,15 @@ def get_sig_ref_info(sig, ref_id):
logger.warning("Invalid lat/lon received for WAB/WAI reference")
return sig_ref
elif sig.upper() == "BOTA":
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
sig_ref.ref_type = "Beach"
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.upper() == "BIWOTA":
# For BIWOTA there are no references, all we can set is a type
sig_ref.ref_type = "Waterway"
elif sig.upper() == "GMA Islands":
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)
@@ -98,25 +105,27 @@ def get_sig_ref_info(sig, ref_id):
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 = "Island"
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 use
# that.
key = f"{sig}:{ref_id}"
# OK, this is something we have to look up. Now check to see if our data store contains reference data and if
# so, copy the data into the sig_ref object
key = f"{sig_name}:{ref_id}"
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
if lookup_data:
return lookup_data
for key, value in lookup_data.__dict__.items():
if value is not None and sig_ref.__dict__.get(key) is None:
sig_ref.__dict__[key] = value
else:
# Maybe a super new reference we don't know about yet, but more likely a typo or a test reference,
# just silently ignore it.
logger.debug(f"{sig} database did not contain data for ref {ref_id}")
logger.debug(f"{sig_name} database did not contain data for ref {ref_id}")
except Exception:
logger.exception(f"Exception when looking up sig_ref info for {sig} ref {ref_id}")
logger.exception(f"Exception when looking up sig_ref info for {sig_name} ref {ref_id}")
return sig_ref
+16 -7
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__)
def infer_mode_from_comment(comment: str) -> Mode:
def infer_mode_from_comment(comment: str) -> Mode | None:
"""Infer a mode from the comment"""
if not comment:
return Mode.UNKNOWN
return None
for mode in Mode:
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):
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 ."""
if not mode:
return ModeType.UNKNOWN
return None
if mode in MODE_ALIASES:
mode = MODE_ALIASES[mode]
@@ -56,7 +56,7 @@ def infer_mode_type_from_mode(mode: str) -> ModeType:
except ValueError:
if mode.upper() != "OTHER" and mode != "?":
logger.warning(f"Found an unrecognised mode: {mode}. Developer should categorise this.")
return ModeType.UNKNOWN
return None
def infer_band_from_freq(freq):
@@ -99,7 +99,16 @@ def infer_mode_from_frequency(freq):
or (28180 <= khz < 28183)
):
mode = "FT4"
return mode
if not mode:
return None
mode = mode.upper()
if mode in MODE_ALIASES:
mode = MODE_ALIASES[mode]
return Mode(mode)
except KeyError:
return None
+40 -9
View File
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
import pytz
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.utils import get_flag_for_dxcc
@@ -20,6 +20,9 @@ class Alert:
# Unique identifier for the alert
id: str | None = None
# DX (alerting) operator info
# Callsigns of the operators that has been alerted
dx_calls: list | None = None
# Names of the operators that has been alerted
@@ -36,6 +39,9 @@ class Alert:
dx_cq_zone: int | None = None
# ITU zone of the DX operator
dx_itu_zone: int | None = None
# General alert info
# Intended frequencies & modes of operation. Essentially just a different kind of comment field.
freqs_modes: str | None = None
# Start time of the activation, UTC seconds since UNIX epoch
@@ -46,25 +52,41 @@ class Alert:
end_time: float | None = None
# End time of the activation of the alert, ISO 8601
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
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
sig: str | None = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
sig_refs: list | None = None
# Timing info
# Time that this software received the alert, UTC seconds since UNIX epoch. This is used with the "since_received"
# call to our API to receive all data that is new to us, even if by a quirk of the API it might be older than the
# list time the client polled the API.
received_time: float | None = None
# Time that this software received the alert, ISO 8601
received_time_iso: str | None = None
# Comment made by the alerter, if any
comment: str | None = None
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
sig: str | None = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
sig_refs: list | None = None
# Whether this alert is for a DXpedition, as opposed to e.g. an xOTA programme.
is_dxpedition: bool = False
# Source info
# Where we got the alert from, e.g. "POTA", "SOTA"...
source: str | None = None
# The ID the source gave it, if any.
source_id: str | None = None
# Display info
# Icon to use when displaying this alert in the web UI. Chosen from the Font Awesome set.
icon: str | None = None
def infer_missing(self, credentials=None):
"""Infer missing parameters where possible"""
@@ -130,6 +152,15 @@ 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
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.sig_refs and len(self.sig_refs) > 0 and self.sig_refs[0].icon:
self.icon = self.sig_refs[0].icon
except Exception:
logger.exception("Exception while inferring missing data from spot")
+4 -1
View File
@@ -1,6 +1,6 @@
from dataclasses import dataclass, field
from core.enums import SIGType
from core.enums import SIGRefType, SIGType
@dataclass
@@ -27,6 +27,9 @@ class SIG:
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.
+3 -1
View File
@@ -15,9 +15,11 @@ class SIGRef:
# 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 = SIGRefType.UNKNOWN
ref_type: SIGRefType | 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: str | None = None
# Latitude of the reference, in degrees, if known.
latitude: float | None = None
# Longitude of the reference, in degrees, if known.
+16 -10
View File
@@ -103,9 +103,9 @@ class Spot:
# General QSO info
# Reported mode, such as SSB, PHONE, CW, FT8...
mode: Mode = Mode.UNKNOWN
mode: Mode | None = None
# Inferred mode "family".
mode_type: ModeType = ModeType.UNKNOWN
mode_type: ModeType | None = None
# Source of the mode information.
mode_source: ModeSource = ModeSource.NONE
# Frequency, in Hz
@@ -146,6 +146,11 @@ class Spot:
# The ID the source gave it, if any.
source_id: str | None = None
# Display info
# Icon to use when displaying this spot in the web UI. Chosen from the Font Awesome set.
icon: str | None = None
def __post_init__(self):
"""Normalise fields that don't survive a plain dict to Spot conversion. This is used in the "add spot" API
endpoint where the client is submitting JSON, and we want to recreate a full Spot object, including nested
@@ -239,21 +244,17 @@ class Spot:
self.band = band.name
# Mode from comments or bandplan
if not self.mode:
self.mode = Mode.UNKNOWN
if self.mode != Mode.UNKNOWN:
if self.mode:
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_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_source = ModeSource.BANDPLAN
# Mode type from mode
if not self.mode_type:
self.mode_type = ModeType.UNKNOWN
if self.mode != Mode.UNKNOWN and self.mode_type == ModeType.UNKNOWN:
if self.mode and not self.mode_type:
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
@@ -459,6 +460,11 @@ 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
self.icon = "fa-tower-cell"
if self.sig_refs and len(self.sig_refs) > 0 and self.sig_refs[0].icon:
self.icon = self.sig_refs[0].icon
except Exception:
logger.exception("Exception while inferring missing data from spot")
+2 -1
View File
@@ -3,6 +3,7 @@ from datetime import datetime, timedelta
import pytz
from bs4 import BeautifulSoup
from core.enums import AlertType
from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -57,7 +58,7 @@ class BOTA(HTTPAlertProvider):
dx_calls=[dx_call],
sig_refs=[SIGRef(id=ref_name, sig="BOTA")],
start_time=date_time.timestamp(),
is_dxpedition=False,
alert_type=AlertType.XOTA,
)
new_alerts.append(alert)
+5 -4
View File
@@ -6,19 +6,20 @@ import pytz
from rss_parser import Parser
from rss_parser.models.rss import RSS
from core.enums import AlertType
from data.alert import Alert
from providers.alert.http_alert_provider import 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"
AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE)
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):
new_alerts = []
@@ -88,7 +89,7 @@ class NG3K(HTTPAlertProvider):
comment=f"{by}; {comment}; {qsl_info}",
start_time=start_timestamp,
end_time=end_timestamp,
is_dxpedition=True,
alert_type=AlertType.DXPEDITION,
)
# Add to our list.
+2 -1
View File
@@ -3,6 +3,7 @@ from datetime import datetime
import pytz
from core.enums import AlertType
from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -51,7 +52,7 @@ class ParksNPeaks(HTTPAlertProvider):
comment=source_alert["Comments"],
sig_refs=sigrefs,
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
+2 -1
View File
@@ -2,6 +2,7 @@ from datetime import datetime
import pytz
from core.enums import AlertType
from data.alert import Alert
from data.sig_ref import SIGRef
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")
.replace(tzinfo=pytz.UTC)
.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
+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
from core.enums import AlertType
from data.alert import Alert
from data.sig_ref import SIGRef
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")
.replace(tzinfo=pytz.UTC)
.timestamp(),
is_dxpedition=False,
alert_type=AlertType.XOTA,
)
# Add to our list
+2 -1
View File
@@ -2,6 +2,7 @@ from datetime import datetime
import pytz
from core.enums import AlertType
from data.alert import Alert
from data.sig_ref import SIGRef
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")
.replace(tzinfo=pytz.UTC)
.timestamp(),
is_dxpedition=False,
alert_type=AlertType.XOTA,
)
# Add to our list
+5 -1
View File
@@ -23,7 +23,11 @@ class DME(LocalFileSIGRefDataProvider):
new_data = []
with open(path, encoding="latin-1") as _f:
for row in csv.DictReader(_f, delimiter=";"):
ref_id = row["COD_INE"][:5]
# 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.
ref_id = "DME-" + row["COD_INE"][:5]
latitude = (
float(row["LATITUD_ETRS89_REGCAN95"].replace(",", "."))
if row.get("LATITUD_ETRS89_REGCAN95")
+1 -1
View File
@@ -30,7 +30,7 @@ class ZLOTA(FileDownloadSIGRefDataProvider):
try:
ref_type = SIGRefType(ref["asset_type"].title().upper())
except ValueError:
ref_type = SIGRefType.UNKNOWN
ref_type = None
new_ref = SIGRef(
sig=self.SIG,
+1 -3
View File
@@ -66,9 +66,7 @@ class GMA(HTTPSpotProvider):
if (source_spot["QRG"] != "" and source_spot["QRG"] != "QRT")
else None,
# Filter out some weird mode strings
mode=Mode.from_name(source_spot["MODE"].upper())
if "<>" not in source_spot["MODE"]
else Mode.UNKNOWN,
mode=Mode.from_name(source_spot["MODE"].upper()) if "<>" not in source_spot["MODE"] else None,
comment=source_spot["TEXT"],
sig_refs=[
SIGRef(
+2 -2
View File
@@ -27,7 +27,7 @@ class WWBOTA(SSESpotProvider):
name=ref["name"],
latitude=ref["lat"],
longitude=ref["long"],
ref_type=SIGRefType.BUNKER
ref_type=SIGRefType.BUNKER,
)
refs.append(sigref)
@@ -36,7 +36,7 @@ class WWBOTA(SSESpotProvider):
dx_call=source_spot["call"].upper(),
de_call=source_spot["spotter"].upper(),
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"],
sig="WWBOTA",
sig_refs=refs,
+2 -1
View File
@@ -24,4 +24,5 @@ fastkml~=1.4.0
ruff~=0.16.3
pdfplumber~=0.11.10
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.web import Application
from core.enums import AlertType
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps
from data.lookup_credentials import extract_credentials
@@ -169,11 +170,18 @@ def alert_allowed_by_query(alert, query):
max_duration = int(query.get(k))
# 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
# the alert is a dxpedition, it also always passes the check.
if alert.is_dxpedition and (
query.get("dxpeditions_skip_max_duration_check").upper() == "TRUE"
if "dxpeditions_skip_max_duration_check" in query
else False
# the alert is a dxpedition, or contests_skip_max_duration_check and the alert is a contest, it also
# always passes the check.
if (
alert.alert_type == AlertType.DXPEDITION
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
if alert.end_time and alert.start_time and alert.end_time - alert.start_time > max_duration:
+41 -7
View File
@@ -19,8 +19,10 @@ info:
* 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.
* Added `sig_type`, `icon`, `region_flag` and `refs_globally_unique` to SIG information
* Unknown modes and mode types now return "UNKNOWN" not null
* Added `sig_type`, `icon`, `region_flag` and `refs_globally_unique` to SIG data
* Added `icon` to spot and alert data
* 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
### 2.0
@@ -209,6 +211,7 @@ paths:
- $ref: '#/components/parameters/AlertReceivedSince'
- $ref: '#/components/parameters/AlertMaxDuration'
- $ref: '#/components/parameters/AlertDxpeditionsSkipMaxDurationCheck'
- $ref: '#/components/parameters/AlertContestsSkipMaxDurationCheck'
- $ref: '#/components/parameters/AlertSource'
- $ref: '#/components/parameters/AlertSig'
- $ref: '#/components/parameters/AlertDxContinent'
@@ -243,6 +246,7 @@ paths:
parameters:
- $ref: '#/components/parameters/AlertMaxDuration'
- $ref: '#/components/parameters/AlertDxpeditionsSkipMaxDurationCheck'
- $ref: '#/components/parameters/AlertContestsSkipMaxDurationCheck'
- $ref: '#/components/parameters/AlertSource'
- $ref: '#/components/parameters/AlertSig'
- $ref: '#/components/parameters/AlertDxContinent'
@@ -661,8 +665,8 @@ components:
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
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_skip_max_duration_check parameter for the workaround.
DXpeditions or contests that you might otherwise be interested in. See the
dxpeditions_skip_max_duration_check and contests_skip_max_duration_check parameters for the workaround.
schema:
type: integer
AlertDxpeditionsSkipMaxDurationCheck:
@@ -675,6 +679,16 @@ components:
on the air most of the time.
schema:
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:
name: source
in: query
@@ -903,9 +917,16 @@ components:
- REGION
- GRID
- TOILET
- UNKNOWN
example: PARK
AlertType:
type: string
enum:
- XOTA
- DXPEDITION
- CONTEST
example: XOTA
Continent:
type: string
enum:
@@ -970,7 +991,6 @@ components:
- FSK
- PKT
- MSK144
- UNKNOWN
example: SSB
ModeType:
@@ -979,7 +999,6 @@ components:
- CW
- PHONE
- DATA
- UNKNOWN
example: CW
ModeSource:
@@ -1280,6 +1299,10 @@ components:
Propagation mode, if known. This is only populated when the upstream spot specifically states it; Spothole
does not try to determine it using its own algorithm.
$ref: "#/components/schemas/PropagationMode"
icon:
type: string
description: Icon to use when displaying this spot in the web UI. Chosen from the Font Awesome set.
example: "fa-tower-cell"
SpotSubmission:
@@ -1433,6 +1456,13 @@ components:
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"
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:
type: string
description: Where we got the alert from.
@@ -1441,6 +1471,10 @@ components:
type: string
description: The ID the source gave it, if any.
example: "GUID-123456"
icon:
type: string
description: Icon to use when displaying this spot in the web UI. Chosen from the Font Awesome set.
example: "fa-tower-cell"
AlertStream:
+13 -27
View File
@@ -13,7 +13,6 @@ function loadAlerts() {
url: '/api/v2/alerts' + buildQueryString(), dataType: 'json', success: function (jsonData) {
// Store last updated time
lastUpdateTime = moment.utc();
updateRefreshDisplay();
// Store data
alerts = jsonData;
// Update table
@@ -38,6 +37,9 @@ function buildQueryString() {
if ($("#dxpeditions_skip_max_duration_check")[0].checked) {
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;
}
@@ -208,10 +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") {
// Contest = true and no DX callsigns, so display "Contest"
dx_calls_html = "Contest"
}
// Format DXpedition country
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"]}`;
}
@@ -226,6 +232,9 @@ function addAlertRowsToTable(tbody, alerts) {
if (a["comment"] != null) {
commentText = escapeHtml(a["comment"]);
}
if (a["url"] != null) {
commentText = `<a href="${escapeHtml(a['url'])}" target="_new">${commentText}</a>`;
}
// Sig or fallback to source
let sigSourceText = a["source"];
@@ -264,7 +273,7 @@ function addAlertRowsToTable(tbody, alerts) {
$tr.append(`<td class='hideonmobile'>${commentText}</td>`);
}
if (showSource) {
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid ${sigToIcon(a["sig"], options["sigs"], "fa-globe-africa")}'></i></span> ${sigSourceText}</td>`);
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid ${a["icon"]}'></i></span> ${sigSourceText}</td>`);
}
if (showRef) {
$tr.append(`<td class='hideonmobile'>${sig_refs}</td>`);
@@ -282,7 +291,7 @@ function addAlertRowsToTable(tbody, alerts) {
const $td2 = $("<td colspan='100'>");
if (showSource) {
$td2.append(`<span class='icon-wrapper'><i class='fa-solid ${sigToIcon(a["sig"], options["sigs"], "fa-globe-africa")}'></i></span> `);
$td2.append(`<span class='icon-wrapper'><i class='fa-solid ${a["icon"]}'></i></span> `);
}
if (showRef) {
$td2.append(`${sig_refs} `);
@@ -331,33 +340,10 @@ function filtersUpdated() {
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
$(document).ready(function () {
// Call loadOptions(), this will then trigger loading alerts and setting up timers.
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
-14
View File
@@ -285,20 +285,6 @@ function callWanted(call) {
}
}
// Get the Font Awesome icon for a given SIG. If the SIG is unknown, the provided default symbol will be returned
// The sig is provided by name as a string, while all_sigs contains all the SIG info from the options call. This
// is because sometimes the sig name arrives via a spot and therefore we need to look up the rest of the SIG data
// separately.
function sigToIcon(sig, all_sigs, defaultIcon) {
if (sig) {
const match = all_sigs.find((check_sig) => check_sig["name"].toUpperCase() === sig.toUpperCase());
if (match) {
return match["icon"];
}
}
return defaultIcon;
}
// Startup
$(document).ready(function () {
usePreferredTheme();
+10 -4
View File
@@ -223,7 +223,7 @@ function updateMap() {
// Get an icon for a spot, based on its band, using PSK Reporter colours, its program etc.
function getIcon(s) {
return L.ExtraMarkers.icon({
icon: sigToIcon(s["sig"], options["sigs"], "fa-tower-cell"),
icon: s["icon"],
iconColor: bandToContrastColor(s["band"]),
markerColor: bandToColor(s["band"]),
shape: 'circle',
@@ -243,13 +243,19 @@ function getTooltipText(s) {
dx_call = dx_call + "-" + s["dx_ssid"];
}
// Format dx country
let dx_country = s["dx_country"];
if (dx_country == null) {
dx_country = "Unknown or not a country";
}
// Format DX flag
let dx_flag = "<i class='fa-solid fa-globe-africa'></i>";
if (dx_call == null) {
dx_flag = "";
}
if (s["dx_flag"] && s["dx_flag"] != null && s["dx_flag"] !== "") {
dx_flag = s["dx_flag"];
if (s["dx_dxcc_id"] && s["dx_dxcc_id"] != null && s["dx_dxcc_id"] !== 0) {
dx_flag = `<img src="static/img/flags/${s['dx_dxcc_id']}.png" class="flag" width="24" alt="${dx_country}" title="${dx_country}"/>`;
}
// Format the frequency
@@ -303,7 +309,7 @@ function getTooltipText(s) {
ttt += "<br/>";
// Source / SIG / Ref
ttt += `<span class='nowrap'><span class='icon-wrapper'><i class='fa-solid ${sigToIcon(s["sig"], options["sigs"], "fa-tower-cell")}'></i></span>&nbsp;${sigSourceText} ${sig_refs}</span><br/>`;
ttt += `<span class='nowrap'><span class='icon-wrapper'><i class='fa-solid ${s["icon"]}'></i></span>&nbsp;${sigSourceText} ${sig_refs}</span><br/>`;
// Time
ttt += `<span class='icon-wrapper'><i class='fa-solid fa-clock markerPopupIcon'></i></span>&nbsp;${moment.unix(s["time"]).fromNow()}`;
+2 -2
View File
@@ -398,7 +398,7 @@ function createNewTableRowsForSpot(s, highlightNew) {
$tr.append(`<td class='nowrap hideonmobile'>${distanceText}</td>`);
}
if (showType) {
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid ${sigToIcon(s["sig"], options["sigs"], "fa-tower-cell")}'></i></span> ${typeText}</td>`);
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid ${s["icon"]}'></i></span> ${typeText}</td>`);
}
if (showRef) {
$tr.append(`<td class='hideonmobile' style='max-width: 11em;'>${sig_refs}</td>`);
@@ -430,7 +430,7 @@ 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 ${sigToIcon(s["sig"], options["sigs"], "fa-tower-cell")}'></i></span> ${typeText} `);
$td2floatleft.append(`<span class='icon-wrapper'><i class='fa-solid ${s["icon"]}'></i></span> ${typeText} `);
}
if (showRef) {
$td2floatleft.append(`${sig_refs} `);
+1 -1
View File
@@ -77,7 +77,7 @@
</div>
<script src="/static/js/add-spot.js?v=1788546948"></script>
<script src="/static/js/add-spot.js?v=1788600259"></script>
<script>$(document).ready(function () {
$("#nav-link-add-spot").addClass("active");
}); <!-- highlight active page in nav --></script>
+1 -2
View File
@@ -4,7 +4,6 @@
<div class="mt-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="d-inline-flex gap-1">
@@ -84,7 +83,7 @@
</div>
<script src="/static/js/alerts.js?v=1788546948"></script>
<script src="/static/js/alerts.js?v=1788600259"></script>
<script>$(document).ready(function () {
$("#nav-link-alerts").addClass("active");
}); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -76,8 +76,8 @@
</div>
<script src="/static/js/spotsbandsandmap.js?v=1788546948"></script>
<script src="/static/js/bands.js?v=1788546948"></script>
<script src="/static/js/spotsbandsandmap.js?v=1788600259"></script>
<script src="/static/js/bands.js?v=1788600259"></script>
<script>$(document).ready(function () {
$("#nav-link-bands").addClass("active");
}); <!-- highlight active page in nav --></script>
+5 -5
View File
@@ -1,6 +1,6 @@
{% extends "skeleton.html" %}
{% 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=1788600258" type="text/css">
<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/solid-6.7.2.min.css" rel="stylesheet">
@@ -16,10 +16,10 @@
window.fetchEventSource = fetchEventSource;
</script>
<script src="/static/js/utils.js?v=1788546948"></script>
<script src="/static/js/ui-ham.js?v=1788546948"></script>
<script src="/static/js/geo.js?v=1788546948"></script>
<script src="/static/js/common.js?v=1788546948"></script>
<script src="/static/js/utils.js?v=1788600258"></script>
<script src="/static/js/ui-ham.js?v=1788600258"></script>
<script src="/static/js/geo.js?v=1788600258"></script>
<script src="/static/js/common.js?v=1788600258"></script>
{% end %}
{% block body %}
<div class="container">
+5 -1
View File
@@ -19,7 +19,11 @@
<input class="form-check-input storeable-checkbox" type="checkbox" value="" onclick="filtersUpdated();"
id="dxpeditions_skip_max_duration_check" checked><label class="form-check-label ms-2"
for="dxpeditions_skip_max_duration_check">Allow
DXpeditions that are longer</label>
DXpeditions that are longer</label><br/>
<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
Contests that are longer</label>
</p>
</div>
</div>
+1 -1
View File
@@ -284,7 +284,7 @@
</div>
<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=1788600259"></script>
<script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active");
}); <!-- 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', '') }}";
</script>
<script src="/static/js/spotsbandsandmap.js?v=1788546948"></script>
<script src="/static/js/map.js?v=1788546948"></script>
<script src="/static/js/spotsbandsandmap.js?v=1788600259"></script>
<script src="/static/js/map.js?v=1788600259"></script>
<script>$(document).ready(function () {
$("#nav-link-map").addClass("active");
}); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -113,8 +113,8 @@
</div>
<script src="/static/js/spotsbandsandmap.js?v=1788546948"></script>
<script src="/static/js/spots.js?v=1788546948"></script>
<script src="/static/js/spotsbandsandmap.js?v=1788600258"></script>
<script src="/static/js/spots.js?v=1788600258"></script>
<script>$(document).ready(function () {
$("#nav-link-spots").addClass("active");
}); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -86,7 +86,7 @@
</div>
</div>
<script src="/static/js/status.js?v=1788546948"></script>
<script src="/static/js/status.js?v=1788600259"></script>
<script>
$(document).ready(function () {
$("#nav-link-status").addClass("active");
-1
View File
@@ -1 +0,0 @@
<div id="timing-container">Loading...</div>