mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-09-20 22:37:44 +00:00
Compare commits
28
Commits
2.0.5
..
0fcc459cc4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fcc459cc4 | ||
|
|
e52790f07d | ||
|
|
237cdaa091 | ||
|
|
df724409a5 | ||
|
|
3e4327ec0c | ||
|
|
d6e53c14e9 | ||
|
|
d3d1d20821 | ||
|
|
ab86bdc4d6 | ||
|
|
d7202f208c | ||
|
|
9d117a6069 | ||
|
|
606cf1be68 | ||
|
|
5a6fc19ab5 | ||
|
|
9258d680fc | ||
|
|
8189be699e | ||
|
|
fa2e4b929a | ||
|
|
55eaa871e9 | ||
|
|
1b62d4742b | ||
|
|
b0caf697ae | ||
|
|
a33f02491d | ||
|
|
892827f1f8 | ||
|
|
e4738b2c7d | ||
|
|
f42fd237b1 | ||
|
|
d83a979e46 | ||
|
|
12e9b6a4ca | ||
|
|
8345238d76 | ||
|
|
bc326388ac | ||
|
|
df684a601d | ||
|
|
9083470482 |
+7
@@ -65,6 +65,13 @@
|
||||
</list>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PyStubPackagesAdvertiser" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ignoredPackages">
|
||||
<list>
|
||||
<option value="pandas" />
|
||||
</list>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
|
||||
<option name="processCode" value="true" />
|
||||
<option name="processLiterals" value="true" />
|
||||
|
||||
@@ -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.
|
||||
@@ -246,12 +252,36 @@ sig_ref_data_providers:
|
||||
- class: "DME"
|
||||
enabled: true
|
||||
|
||||
- class: "FEA"
|
||||
enabled: true
|
||||
|
||||
- class: "DMVE"
|
||||
enabled: true
|
||||
|
||||
- class: "DMUE"
|
||||
enabled: true
|
||||
|
||||
- class: "DCE"
|
||||
enabled: true
|
||||
|
||||
- class: "DEFE"
|
||||
enabled: true
|
||||
|
||||
- class: "KRMNPA"
|
||||
enabled: true
|
||||
|
||||
- class: "SANPCPA"
|
||||
enabled: true
|
||||
|
||||
- class: "DTMBA"
|
||||
enabled: true
|
||||
|
||||
- class: "COTA"
|
||||
enabled: true
|
||||
|
||||
- class: "PGA"
|
||||
enabled: true
|
||||
|
||||
- class: "Toilets"
|
||||
enabled: true
|
||||
|
||||
|
||||
+226
-64
@@ -1,9 +1,10 @@
|
||||
from core.config import SERVER_OWNER_CALLSIGN
|
||||
from core.enums import SIGRefType, SIGType
|
||||
from data.band import Band
|
||||
from data.sig import SIG
|
||||
|
||||
# General software
|
||||
SOFTWARE_VERSION = "2.0.5"
|
||||
SOFTWARE_VERSION = "2.1-pre"
|
||||
|
||||
# HTTP headers used for spot providers that use HTTP
|
||||
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
|
||||
@@ -15,199 +16,362 @@ SIGS = [
|
||||
name="POTA",
|
||||
comment_names=["POTA"],
|
||||
description="Parks on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.PARK,
|
||||
ref_regex=r"[A-Z]{2}\-\d{4,5}|K\-TEST",
|
||||
icon="fa-tree",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="SOTA",
|
||||
comment_names=["SOTA"],
|
||||
description="Summits on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
|
||||
icon="fa-mountain-sun",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="WWFF",
|
||||
comment_names=["WWFF"],
|
||||
description="World Wide Flora & Fauna",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.PARK,
|
||||
ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}",
|
||||
icon="fa-seedling",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="GMA",
|
||||
comment_names=["GMA"],
|
||||
description="Global Mountain Activity",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
|
||||
icon="fa-person-hiking",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="WWBOTA",
|
||||
comment_names=["WWBOTA", "BOTA"],
|
||||
description="Worldwide Bunkers on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.BUNKER,
|
||||
ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}",
|
||||
icon="fa-radiation",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="HEMA",
|
||||
comment_names=["HEMA"],
|
||||
description="HuMPs Excluding Marilyns Award",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}",
|
||||
icon="fa-mound",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="IOTA",
|
||||
comment_names=["IOTA"],
|
||||
description="Islands on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.ISLAND,
|
||||
ref_regex=r"[A-Z]{2}\-\d{3}",
|
||||
icon="fa-book-atlas",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="GMA Islands",
|
||||
comment_names=[],
|
||||
description="Global Mountain Activity - Islands",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.ISLAND,
|
||||
ref_regex=r"(([A-Z]{2}\-\d{3})|([A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}))",
|
||||
),
|
||||
SIG(
|
||||
name="MOTA",
|
||||
comment_names=["MOTA"],
|
||||
description="Mills on the Air",
|
||||
ref_regex=r"X\d{4,6}",
|
||||
icon="fa-person-hiking",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="ARLHS",
|
||||
comment_names=["ARLHS"],
|
||||
description="Amateur Radio Lighthouse Society",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.LIGHTHOUSE,
|
||||
ref_regex=r"[A-Z]{3}[\- ]\d{3,4}",
|
||||
icon="fa-house-flood-water",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="ILLW",
|
||||
comment_names=["ILLW"],
|
||||
description="International Lighthouse & Lightship Weekend",
|
||||
sig_type=SIGType.EVENT,
|
||||
ref_type=SIGRefType.LIGHTHOUSE,
|
||||
ref_regex=r"[A-Z]{2}\d{4}",
|
||||
icon="fa-house-flood-water",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="MOTA",
|
||||
comment_names=["MOTA"],
|
||||
description="Mills on the Air",
|
||||
sig_type=SIGType.EVENT,
|
||||
ref_type=SIGRefType.MILL,
|
||||
ref_regex=r"X\d{4,6}",
|
||||
icon="fa-fan",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="SIOTA",
|
||||
comment_names=["SIOTA"],
|
||||
description="Silos on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.SILO,
|
||||
ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d",
|
||||
icon="fa-wheat-awn",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="WCA",
|
||||
comment_names=["WCA"],
|
||||
description="World Castles Award",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.CASTLE,
|
||||
ref_regex=r"[A-Z0-9]{1,3}\-\d{5}",
|
||||
icon="fa-chess-rook",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="ZLOTA",
|
||||
comment_names=["ZLOTA"],
|
||||
description="New Zealand on the Air",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=None,
|
||||
ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}",
|
||||
icon="fa-kiwi-bird",
|
||||
region_flag="🇳🇿",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="WOTA",
|
||||
comment_names=["WOTA"],
|
||||
description="Wainwrights on the Air",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
ref_regex=r"[A-Z]{3}-[0-9]{2}",
|
||||
icon="fa-w",
|
||||
region_flag="🇬🇧",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="BOTA",
|
||||
comment_names=[],
|
||||
description="Beaches on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.BEACH,
|
||||
icon="fa-umbrella-beach",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(name="BOTA", comment_names=[], description="Beaches on the Air"),
|
||||
SIG(
|
||||
name="KRMNPA",
|
||||
comment_names=["KRMNPA"],
|
||||
description="Keith Roget Memorial National Parks Award",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.PARK,
|
||||
ref_regex=r"VKFF\-\d{4}",
|
||||
icon="fa-earth-oceania",
|
||||
region_flag="🇦🇺",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="SANPCPA",
|
||||
comment_names=["SANPCPA"],
|
||||
description="South Australian National Parks and Conservation Parks Award",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.PARK,
|
||||
ref_regex=r"VKFF\-\d{4}",
|
||||
icon="fa-earth-oceania",
|
||||
region_flag="🇦🇺",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="LLOTA",
|
||||
comment_names=["LLOTA"],
|
||||
description="Lagos y Lagunas on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.LAKE,
|
||||
ref_regex=r"LL[A-Z]{2}\-\d{4}",
|
||||
icon="fa-water",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="Towers",
|
||||
comment_names=["TOTA"],
|
||||
description="Towers on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.TOWER,
|
||||
ref_regex=r"[A-Z]{2,3}R\-\d{4}",
|
||||
icon="fa-tower-observation",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="Tiles",
|
||||
comment_names=[],
|
||||
description="Tiles on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.GRID,
|
||||
ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}",
|
||||
icon="fa-square",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="WAB",
|
||||
comment_names=["WAB"],
|
||||
description="Worked All Britain",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.GRID,
|
||||
ref_regex=r"[A-Z]{1,2}[0-9]{2}",
|
||||
icon="fa-table-cells-large",
|
||||
region_flag="🇬🇧",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="WAI",
|
||||
comment_names=["WAI"],
|
||||
description="Worked All Ireland",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.GRID,
|
||||
ref_regex=r"[A-Z][0-9]{2}",
|
||||
icon="fa-table-cells-large",
|
||||
region_flag="🇮🇪",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="DME",
|
||||
comment_names=["DME"],
|
||||
description="Diplomas de Municipios Españoles",
|
||||
ref_regex=r"\d{4,5}",
|
||||
description="Diploma Municipios de España",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.TOWN,
|
||||
ref_regex=r"DME[\- ]\d{3,5}",
|
||||
icon="fa-building",
|
||||
region_flag="🇪🇸",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="FEA",
|
||||
comment_names=["FEA"],
|
||||
description="Diploma Faros de España",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.LIGHTHOUSE,
|
||||
# FEA references are technically [DE]\-\d{4}(\.\d)? but spotters always seem to miss out the D- or E-
|
||||
# prefix and just use FEA-1234 or FEA 1234, so allow for that. The FEA sigref data provider adds both
|
||||
# forms to the database.
|
||||
ref_regex=r"([DE]|FEA)[\- ]\d{4}(\.\d)?",
|
||||
icon="fa-house-flood-water",
|
||||
region_flag="🇪🇸",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="DMUE",
|
||||
comment_names=["DMUE"],
|
||||
description="Diploma Museos de España",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.BUILDING,
|
||||
ref_regex=r"MUE[A-Z]{2}-\d{3}",
|
||||
icon="fa-landmark",
|
||||
region_flag="🇪🇸",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="DMVE",
|
||||
comment_names=["DMVE"],
|
||||
description="Diploma Monumentos y Vestigios de España",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.BUILDING,
|
||||
ref_regex=r"MV[A-Z]{1,2}-\d{4}",
|
||||
icon="fa-monument",
|
||||
region_flag="🇪🇸",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="DCE",
|
||||
comment_names=["DCE"],
|
||||
description="Diploma Castillos de España",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.CASTLE,
|
||||
ref_regex=r"C[A-Z]{1,2}-\d{3}",
|
||||
icon="fa-chess-rook",
|
||||
region_flag="🇪🇸",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="DEFE",
|
||||
comment_names=["DEFE"],
|
||||
description="Diploma Estaciones de Ferrocarril de España",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.BUILDING,
|
||||
ref_regex=r"EF[A-Z]{1,2}-\d{3}",
|
||||
icon="fa-train",
|
||||
region_flag="🇪🇸",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="DTMBA",
|
||||
comment_names=["DTMBA"],
|
||||
description="Diploma Teatri Musei e Belle Arti",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.BUILDING,
|
||||
ref_regex=r"I-?[0-9]{3,4}\s?[A-Z]{2}",
|
||||
icon="fa-landmark",
|
||||
region_flag="🇮🇹",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="BIWOTA",
|
||||
comment_names=["BIWOTA"],
|
||||
description="British Inland Waterways on the Air",
|
||||
sig_type=SIGType.EVENT,
|
||||
ref_type=SIGRefType.WATERWAY,
|
||||
icon="fa-ship",
|
||||
region_flag="🇬🇧",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="COTA",
|
||||
comment_names=["COTA"],
|
||||
description="Castles on the Air",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.CASTLE,
|
||||
ref_regex=r"[A-Z]{3}\-[0-9]{3,5}",
|
||||
icon="fa-chess-rook",
|
||||
region_flag="🇩🇪",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="PGA",
|
||||
comment_names=["PGA"],
|
||||
description="Polish Gmina Award",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.REGION,
|
||||
ref_regex=r"[A-Z]{2}[0-9]{2}",
|
||||
icon="fa-g",
|
||||
region_flag="🇵🇱",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="Toilets",
|
||||
comment_names=[],
|
||||
description="Toilets on the Air",
|
||||
sig_type=SIGType.EVENT,
|
||||
ref_type=SIGRefType.TOILET,
|
||||
ref_regex=r"T\-[0-9]{2}",
|
||||
icon="fa-toilet",
|
||||
region_flag="🏴☠️",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
]
|
||||
|
||||
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
|
||||
CW_MODES = ["CW"]
|
||||
PHONE_MODES = [
|
||||
"PHONE",
|
||||
"SSB",
|
||||
"USB",
|
||||
"LSB",
|
||||
"AM",
|
||||
"FM",
|
||||
"DV",
|
||||
"DMR",
|
||||
"DSTAR",
|
||||
"C4FM",
|
||||
"FUSION",
|
||||
"M17",
|
||||
]
|
||||
DATA_MODES = [
|
||||
"DATA",
|
||||
"FT8",
|
||||
"FT4",
|
||||
"RTTY",
|
||||
"SSTV",
|
||||
"JS8",
|
||||
"HELL",
|
||||
"PSK",
|
||||
"FSK",
|
||||
"OLIVIA",
|
||||
"PKT",
|
||||
"MSK144",
|
||||
]
|
||||
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
|
||||
MODE_TYPES = ["CW", "PHONE", "DATA"]
|
||||
SSB_SUB_MODES = ["USB", "LSB"]
|
||||
DV_SUB_MODES = ["DMR", "DSTAR", "C4FM", "FUSION", "M17"]
|
||||
|
||||
# Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode
|
||||
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots
|
||||
# that match a key in this table will be converted to the corresponding value, so only the modes above will actually be
|
||||
# present in the spots.
|
||||
MODE_ALIASES = {
|
||||
"RTT": "RTTY",
|
||||
"BPSK": "PSK",
|
||||
"PSK31": "PSK",
|
||||
"BPSK31": "PSK",
|
||||
"MFSK": "FSK",
|
||||
"MFSK32": "FSK",
|
||||
"DIGI": "DATA",
|
||||
"DIGITAL": "DATA",
|
||||
}
|
||||
|
||||
# Band definitions
|
||||
BANDS = [
|
||||
Band(name="2200m", start_freq=135700, end_freq=137800),
|
||||
@@ -223,7 +387,7 @@ BANDS = [
|
||||
Band(name="12m", start_freq=24890000, end_freq=24990000, is_ham_hf=True),
|
||||
Band(name="11m", start_freq=26965000, end_freq=27405000),
|
||||
Band(name="10m", start_freq=28000000, end_freq=29700000, is_ham_hf=True),
|
||||
Band(name="6m", start_freq=50000000, end_freq=54000000),
|
||||
Band(name="6m", start_freq=50000000, end_freq=54000000, is_ham_hf=True),
|
||||
Band(name="5m", start_freq=56000000, end_freq=60500000),
|
||||
Band(name="4m", start_freq=70000000, end_freq=70500000),
|
||||
Band(name="2m", start_freq=144000000, end_freq=148000000),
|
||||
@@ -239,9 +403,6 @@ BANDS = [
|
||||
]
|
||||
UNKNOWN_BAND = Band(name="Unknown", start_freq=0, end_freq=0)
|
||||
|
||||
# Continents
|
||||
CONTINENTS = ["EU", "NA", "SA", "AS", "AF", "OC", "AN"]
|
||||
|
||||
# Propagation modes used in VHF/UHF DX cluster comments, e.g. "JN61ES<ES>JM56XT". I don't think there's an official list
|
||||
# of these anywhere, but here are some I've seen or seen reference to
|
||||
PROPAGATION_MODES = {
|
||||
@@ -254,5 +415,6 @@ PROPAGATION_MODES = {
|
||||
"MS": "Meteor scatter",
|
||||
"RS": "Rain scatter",
|
||||
"AS": "Aircraft scatter",
|
||||
"ACS": "Aircraft scatter",
|
||||
"SAT": "Satellite",
|
||||
}
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ class DataStore:
|
||||
def close(self):
|
||||
self.spots.close()
|
||||
self.alerts.close()
|
||||
self.solar.close()
|
||||
self.solar_conditions.close()
|
||||
self.status.close()
|
||||
self.dxcc_data.close()
|
||||
self.sigrefs.close()
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Continent(str, Enum):
|
||||
EU = "EU"
|
||||
NA = "NA"
|
||||
SA = "SA"
|
||||
AS = "AS"
|
||||
AF = "AF"
|
||||
OC = "OC"
|
||||
AN = "AN"
|
||||
|
||||
|
||||
class Mode(str, Enum):
|
||||
CW = "CW"
|
||||
PHONE = "PHONE"
|
||||
SSB = "SSB"
|
||||
AM = "AM"
|
||||
FM = "FM"
|
||||
DV = "DV"
|
||||
DATA = "DATA"
|
||||
FT8 = "FT8"
|
||||
FT4 = "FT4"
|
||||
RTTY = "RTTY"
|
||||
SSTV = "SSTV"
|
||||
JS8 = "JS8"
|
||||
HELL = "HELL"
|
||||
PSK = "PSK"
|
||||
FSK = "FSK"
|
||||
OLIVIA = "OLIVIA"
|
||||
PKT = "PKT"
|
||||
MSK144 = "MSK144"
|
||||
|
||||
@property
|
||||
def is_cw(self) -> bool:
|
||||
return self == Mode.CW
|
||||
|
||||
@property
|
||||
def is_phone(self) -> bool:
|
||||
return self in {Mode.PHONE, Mode.SSB, Mode.AM, Mode.FM, Mode.DV}
|
||||
|
||||
@property
|
||||
def is_data(self) -> bool:
|
||||
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 None
|
||||
|
||||
try:
|
||||
return Mode(name.upper())
|
||||
except (KeyError, ValueError):
|
||||
try:
|
||||
return Mode(MODE_ALIASES[name.upper()])
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class ModeType(str, Enum):
|
||||
PHONE = "PHONE"
|
||||
CW = "CW"
|
||||
DATA = "DATA"
|
||||
|
||||
|
||||
class ModeSource(str, Enum):
|
||||
"""Where the mode data came from in a spot."""
|
||||
|
||||
SPOT = "SPOT"
|
||||
COMMENT = "COMMENT"
|
||||
BANDPLAN = "BANDPLAN"
|
||||
NONE = "NONE"
|
||||
|
||||
|
||||
class LocationSourceForSpot(str, Enum):
|
||||
"""Where the location data came from in a spot."""
|
||||
|
||||
SPOT = "SPOT"
|
||||
SIG_REF_LOOKUP = "SIG REF LOOKUP"
|
||||
GRID = "GRID"
|
||||
HOME_QTH = "HOME QTH"
|
||||
DXCC = "DXCC"
|
||||
NONE = "NONE"
|
||||
|
||||
|
||||
class LocationSourceForCallsign(str, Enum):
|
||||
"""Where the location data came from in a callsign data object."""
|
||||
|
||||
HOME_QTH = "HOME QTH"
|
||||
DXCC = "DXCC"
|
||||
NONE = "NONE"
|
||||
|
||||
|
||||
class SIGRefType(str, Enum):
|
||||
"""Type of a Special Interest Group reference."""
|
||||
|
||||
PARK = "PARK"
|
||||
SUMMIT = "SUMMIT"
|
||||
CASTLE = "CASTLE"
|
||||
LIGHTHOUSE = "LIGHTHOUSE"
|
||||
ISLAND = "ISLAND"
|
||||
BUNKER = "BUNKER"
|
||||
MILL = "MILL"
|
||||
SILO = "SILO"
|
||||
BEACH = "BEACH"
|
||||
LAKE = "LAKE"
|
||||
TOWER = "TOWER"
|
||||
WATERWAY = "WATERWAY"
|
||||
TOWN = "TOWN"
|
||||
BUILDING = "BUILDING"
|
||||
REGION = "REGION"
|
||||
GRID = "GRID"
|
||||
TOILET = "TOILET"
|
||||
|
||||
|
||||
class AlertType(str, Enum):
|
||||
"""Type of an alert."""
|
||||
|
||||
XOTA = "XOTA"
|
||||
DXPEDITION = "DXPEDITION"
|
||||
CONTEST = "CONTEST"
|
||||
|
||||
|
||||
class SIGType(str, Enum):
|
||||
"""Type of a Special Interest Group. Used to group them in the web UI."""
|
||||
|
||||
WORLDWIDE = "WORLDWIDE"
|
||||
REGIONAL = "REGIONAL"
|
||||
EVENT = "EVENT"
|
||||
|
||||
|
||||
# Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode
|
||||
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots
|
||||
# that match a key in this table will be converted to the corresponding value, so only the modes above will actually be
|
||||
# present in the spots.
|
||||
MODE_ALIASES = {
|
||||
"USB": "SSB",
|
||||
"LSB": "SSB",
|
||||
"DIGITALVOICE": "DV",
|
||||
"DIGITALVOI": "DV",
|
||||
"DMR": "DV",
|
||||
"DSTAR": "DV",
|
||||
"C4FM": "DV",
|
||||
"FUSION": "DV",
|
||||
"M17": "DV",
|
||||
"RTT": "RTTY",
|
||||
"BPSK": "PSK",
|
||||
"PSK31": "PSK",
|
||||
"BPSK31": "PSK",
|
||||
"MFSK": "FSK",
|
||||
"MFSK32": "FSK",
|
||||
"DIGI": "DATA",
|
||||
"DIGITAL": "DATA",
|
||||
}
|
||||
+46
-23
@@ -1,46 +1,69 @@
|
||||
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
|
||||
|
||||
sig_ref = SIGRef(sig=sig, id=ref_id)
|
||||
# Sometimes we allow spaces instead of dashes in references due to common usage that way, but official reference
|
||||
# lists never do, so convert them here.
|
||||
ref_id.replace(" ", "-")
|
||||
|
||||
# Prepare the object to be returned
|
||||
sig_ref = SIGRef(sig=sig_name, id=ref_id)
|
||||
|
||||
# We can always get the reference type and the icon from the SIG itself
|
||||
for sig in SIGS:
|
||||
if sig.name.upper() == sig_name.upper():
|
||||
sig_ref.ref_type = sig.ref_type
|
||||
sig_ref.icon = sig.icon
|
||||
|
||||
try:
|
||||
### FUDGES ###
|
||||
#
|
||||
# DME fudge. Our database has leading zeros padding to 5 digits which is the expected format, but not all
|
||||
# activators add leading zeros.
|
||||
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)}"
|
||||
|
||||
# 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"
|
||||
# DTMBA spotters sometimes include spaces and dashes, our regex allows them but they must be removed here so we
|
||||
# can look up against the official list which doesn't have them
|
||||
if sig_name.upper() == "DTMBA":
|
||||
ref_id = ref_id.replace("-", "").replace(" ", "")
|
||||
|
||||
### NO DATA SIGS ###
|
||||
#
|
||||
# If the SIG is HEMA or BIWOTA, we have no way to either generate useful data or look it up on a reference list,
|
||||
# so just skip the lookup here.
|
||||
if sig_name.upper() == "HEMA" or sig_name.upper() == "BIWOTA":
|
||||
return sig_ref
|
||||
|
||||
### PROGRAMMATIC DATA GENERATION INSTEAD OF LOOKUPS ###
|
||||
#
|
||||
# If the SIG is Tiles, WAB, WAI or BOTA (Beaches), we don't have anything to look up from the data store, we can
|
||||
# calculate all the information we are going to get directly.
|
||||
if sig.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:
|
||||
@@ -51,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
|
||||
@@ -64,16 +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() == "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)
|
||||
@@ -84,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
|
||||
|
||||
|
||||
|
||||
+44
-29
@@ -1,20 +1,14 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
import simplejson
|
||||
from pyhamtools.frequency import freq_to_band
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.constants import (
|
||||
ALL_MODES,
|
||||
BANDS,
|
||||
CW_MODES,
|
||||
DATA_MODES,
|
||||
MODE_ALIASES,
|
||||
PHONE_MODES,
|
||||
UNKNOWN_BAND,
|
||||
)
|
||||
from core.constants import BANDS, UNKNOWN_BAND
|
||||
from core.data_store import DATA_STORE
|
||||
from data.callsign import Callsign
|
||||
from core.enums import MODE_ALIASES, Continent, Mode, ModeType
|
||||
from data.callsign import Callsign, LocationSourceForCallsign
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,28 +20,40 @@ 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):
|
||||
def infer_mode_from_comment(comment: str) -> Mode | None:
|
||||
"""Infer a mode from the comment"""
|
||||
|
||||
for mode in ALL_MODES:
|
||||
if mode in comment.upper():
|
||||
if not comment:
|
||||
return None
|
||||
|
||||
for mode in Mode:
|
||||
if re.search(r"(^|\W)" + mode + r"($|\W)", comment, re.IGNORECASE):
|
||||
return mode
|
||||
for mode in MODE_ALIASES:
|
||||
if mode in comment.upper():
|
||||
return MODE_ALIASES[mode]
|
||||
for alias in MODE_ALIASES:
|
||||
if re.search(r"(^|\W)" + alias + r"($|\W)", comment, re.IGNORECASE):
|
||||
return Mode(MODE_ALIASES[alias])
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def infer_mode_type_from_mode(mode):
|
||||
"""Infer a "mode family" from a mode."""
|
||||
def infer_mode_type_from_mode(mode: str) -> ModeType | None:
|
||||
"""Infer a "mode family" from a mode ."""
|
||||
|
||||
if mode.upper() in CW_MODES:
|
||||
return "CW"
|
||||
elif mode.upper() in PHONE_MODES:
|
||||
return "PHONE"
|
||||
elif mode.upper() in DATA_MODES:
|
||||
return "DATA"
|
||||
else:
|
||||
if not mode:
|
||||
return None
|
||||
|
||||
if mode in MODE_ALIASES:
|
||||
mode = MODE_ALIASES[mode]
|
||||
|
||||
try:
|
||||
mode = Mode(mode.upper())
|
||||
if mode.is_cw:
|
||||
return ModeType.CW
|
||||
if mode.is_phone:
|
||||
return ModeType.PHONE
|
||||
return ModeType.DATA
|
||||
|
||||
except ValueError:
|
||||
if mode.upper() != "OTHER" and mode != "?":
|
||||
logger.warning(f"Found an unrecognised mode: {mode}. Developer should categorise this.")
|
||||
return None
|
||||
@@ -70,7 +76,7 @@ def infer_mode_from_frequency(freq):
|
||||
mode = freq_to_band(khz)["mode"]
|
||||
# Some additional common digimode ranges in addition to what the 3rd-party freq_to_band function returns.
|
||||
# This is mostly here just because freq_to_band is very specific about things like FT8 frequencies, and e.g.
|
||||
# a spot at 7074.5 kHz will be indicated as LSB, even though it's clearly in the FT8 range. Future updates
|
||||
# a spot at 7074.5 kHz will be indicated as SSB, even though it's clearly in the FT8 range. Future updates
|
||||
# might include other common digimode centres of activity here, but this achieves the main goal of keeping
|
||||
# large numbers of clearly-FT* spots off the list of people filtering out digimodes.
|
||||
if (
|
||||
@@ -93,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
|
||||
|
||||
@@ -115,7 +130,7 @@ def get_callsign_object_from_pyhamtools_callinfo(callsign, callinfo):
|
||||
|
||||
country = data.get("country", None)
|
||||
dxcc_id = data.get("adif", None)
|
||||
continent = data.get("continent", None)
|
||||
continent = Continent(data["continent"]) if "continent" in data else None
|
||||
cq_zone = data.get("cqz", None)
|
||||
itu_zone = data.get("ituz", None)
|
||||
lat = float(data["latitude"]) if "latitude" in data else None
|
||||
@@ -135,7 +150,7 @@ def get_callsign_object_from_pyhamtools_callinfo(callsign, callinfo):
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
grid=grid,
|
||||
location_source="DXCC",
|
||||
location_source=LocationSourceForCallsign.DXCC,
|
||||
)
|
||||
|
||||
except (KeyError, ValueError):
|
||||
|
||||
+43
-11
@@ -7,6 +7,7 @@ from datetime import datetime, timedelta
|
||||
import pytz
|
||||
|
||||
from core.call_lookup_helper import get_call_info
|
||||
from core.enums import AlertType, Continent
|
||||
from core.sig_lookup_helper import populate_missing_sig_ref_info
|
||||
from core.utils import get_flag_for_dxcc
|
||||
|
||||
@@ -19,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
|
||||
@@ -28,13 +32,16 @@ class Alert:
|
||||
# Country flag of the DX operator
|
||||
dx_flag: str | None = None
|
||||
# Continent of the DX operator
|
||||
dx_continent: str | None = None
|
||||
dx_continent: Continent | None = None
|
||||
# DXCC ID of the DX operator
|
||||
dx_dxcc_id: int | None = None
|
||||
# CQ zone of the DX operator
|
||||
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
|
||||
@@ -45,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"""
|
||||
|
||||
@@ -91,8 +114,8 @@ class Alert:
|
||||
call_info = get_call_info(self.dx_calls[0], credentials)
|
||||
if self.dx_calls and self.dx_calls[0] and not self.dx_country:
|
||||
self.dx_country = call_info.country
|
||||
if self.dx_calls and self.dx_calls[0] and not self.dx_continent:
|
||||
self.dx_continent = call_info.continent
|
||||
if self.dx_calls and self.dx_calls[0] and call_info.continent and not self.dx_continent:
|
||||
self.dx_continent = Continent(call_info.continent)
|
||||
if self.dx_calls and self.dx_calls[0] and not self.dx_cq_zone:
|
||||
self.dx_cq_zone = call_info.cq_zone
|
||||
if self.dx_calls and self.dx_calls[0] and not self.dx_itu_zone:
|
||||
@@ -129,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")
|
||||
|
||||
|
||||
+5
-3
@@ -1,5 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from core.enums import Continent, LocationSourceForCallsign
|
||||
|
||||
|
||||
@dataclass
|
||||
class Callsign:
|
||||
@@ -28,15 +30,15 @@ class Callsign:
|
||||
# Country in which the callsign indicates they are operating
|
||||
country: str | None = None
|
||||
# Continent in which the callsign indicates they are operating
|
||||
continent: str | None = None
|
||||
continent: Continent | None = None
|
||||
# DXCC ID in which the callsign indicates they are operating
|
||||
dxcc_id: int | None = None
|
||||
# CQ zone in which the callsign indicates they are operating
|
||||
cq_zone: int | None = None
|
||||
# ITU zone in which the callsign indicates they are operating
|
||||
itu_zone: int | None = None
|
||||
# Location source. This can be "HOME QTH" or "DXCC" depending on which provider gave us a location
|
||||
location_source: str | None = None
|
||||
# Location source
|
||||
location_source: LocationSourceForCallsign = LocationSourceForCallsign.NONE
|
||||
|
||||
def fully_populated(self):
|
||||
"""Utility method to indicate that the callsign data is fully populated. Multiple providers can return data for
|
||||
|
||||
+18
@@ -1,5 +1,7 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from core.enums import SIGRefType, SIGType
|
||||
|
||||
|
||||
@dataclass
|
||||
class SIG:
|
||||
@@ -15,7 +17,23 @@ class SIG:
|
||||
name: str
|
||||
# Description, e.g. "Towers on the Air"
|
||||
description: str
|
||||
# Type, either Worldwide, Regional or Event. Used for sorting in the web UI.
|
||||
sig_type: SIGType
|
||||
# Identifies that the SIG's reference ID structure defined by its regex is unique across all programmes and
|
||||
# anything else we expect a user to put in a spot comment, and therefore we can pull references out of
|
||||
# spot comments without also needing to see the SIG name first. For example, "OHFF-1234" or "B/G-1234" are
|
||||
# obviously WWFF and WWBOTA, nothing else looks like those. But "SZ09" could be WAB or Tiles, "GB1234" could
|
||||
# conceivably be POTA or ILLW, etc.
|
||||
refs_globally_unique: bool
|
||||
# SIG names as they might appear in cluster spot comments, e.g. ["TOTA"]
|
||||
comment_names: list[str] = field(default_factory=list)
|
||||
# Reference type, what gets activated e.g. Park, Summit. May be None if the SIG is for multiple types of things, in
|
||||
# which case the spot data will have to provide this instead.
|
||||
ref_type: SIGRefType | None = None
|
||||
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
|
||||
ref_regex: str | None = None
|
||||
# Icon to use in the UI when referencing this SIG. Chosen from the Font Awesome set.
|
||||
icon: str | None = None
|
||||
# Emoji flag for the country or region where this SIG is relevant, if any. If None, this implies the SIG is in
|
||||
# worldwide usage.
|
||||
region_flag: str | None = None
|
||||
|
||||
+5
-1
@@ -1,5 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from core.enums import SIGRefType
|
||||
|
||||
|
||||
@dataclass
|
||||
class SIGRef:
|
||||
@@ -13,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: str | None = None
|
||||
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.
|
||||
|
||||
+51
-32
@@ -11,7 +11,8 @@ from pyhamtools.locator import latlong_to_locator, locator_to_latlong
|
||||
|
||||
from core.call_lookup_helper import get_call_info
|
||||
from core.config import MAX_SPOT_AGE
|
||||
from core.constants import MODE_ALIASES, PROPAGATION_MODES
|
||||
from core.constants import PROPAGATION_MODES, SIGS
|
||||
from core.enums import Continent, LocationSourceForSpot, Mode, ModeSource, ModeType
|
||||
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
||||
from core.sig_lookup_helper import populate_missing_sig_ref_info
|
||||
from core.sig_utils import (
|
||||
@@ -52,7 +53,7 @@ class Spot:
|
||||
# Country flag of the DX operator
|
||||
dx_flag: str | None = None
|
||||
# Continent of the DX operator
|
||||
dx_continent: str | None = None
|
||||
dx_continent: Continent | None = None
|
||||
# DXCC ID of the DX operator
|
||||
dx_dxcc_id: int | None = None
|
||||
# CQ zone of the DX operator
|
||||
@@ -68,9 +69,8 @@ class Spot:
|
||||
# lookup
|
||||
dx_latitude: float | None = None
|
||||
dx_longitude: float | None = None
|
||||
# DX Location source. Indicates how accurate the location might be. Values: "SPOT", "GRID", "SIG REF LOOKUP",
|
||||
# "HOME QTH", "DXCC", "NONE"
|
||||
dx_location_source: str = "NONE"
|
||||
# DX Location source. Indicates how accurate the location might be.
|
||||
dx_location_source: LocationSourceForSpot = LocationSourceForSpot.NONE
|
||||
# DX Location good. Indicates that the software thinks the location data is good enough to plot on a map. This is
|
||||
# true if the location source is "SPOT", "SIG REF LOOKUP" or "GRID", or if the location source is "HOME QTH" and the
|
||||
# DX callsign doesn't have a suffix like /P.
|
||||
@@ -85,7 +85,7 @@ class Spot:
|
||||
# Country flag of the spotter
|
||||
de_flag: str | None = None
|
||||
# Continent of the spotter
|
||||
de_continent: str | None = None
|
||||
de_continent: Continent | None = None
|
||||
# DXCC ID of the spotter
|
||||
de_dxcc_id: int | None = None
|
||||
# If this is an APRS/Packet/etc spot, what SSID was the spotter/receiver using?
|
||||
@@ -103,11 +103,11 @@ class Spot:
|
||||
# General QSO info
|
||||
|
||||
# Reported mode, such as SSB, PHONE, CW, FT8...
|
||||
mode: str | None = None
|
||||
# Inferred mode "family". One of "CW", "PHONE" or "DIGI".
|
||||
mode_type: str | None = None
|
||||
# Source of the mode information. "SPOT", "COMMENT", "BANDPLAN" or "NONE"
|
||||
mode_source: str = "NONE"
|
||||
mode: Mode | None = None
|
||||
# Inferred mode "family".
|
||||
mode_type: ModeType | None = None
|
||||
# Source of the mode information.
|
||||
mode_source: ModeSource = ModeSource.NONE
|
||||
# Frequency, in Hz
|
||||
freq: float | None = None
|
||||
# Band, defined by the frequency, e.g. "40m" or "70cm"
|
||||
@@ -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
|
||||
@@ -184,8 +189,8 @@ class Spot:
|
||||
dx_call_info = get_call_info(self.dx_call, credentials)
|
||||
if self.dx_call and not self.dx_country:
|
||||
self.dx_country = dx_call_info.country
|
||||
if self.dx_call and not self.dx_continent:
|
||||
self.dx_continent = dx_call_info.continent
|
||||
if self.dx_call and dx_call_info.continent and not self.dx_continent:
|
||||
self.dx_continent = Continent(dx_call_info.continent)
|
||||
if self.dx_call and not self.dx_dxcc_id:
|
||||
self.dx_dxcc_id = dx_call_info.dxcc_id
|
||||
if self.dx_dxcc_id and not self.dx_flag:
|
||||
@@ -222,8 +227,8 @@ class Spot:
|
||||
):
|
||||
if not self.de_country:
|
||||
self.de_country = de_call_info.country
|
||||
if not self.de_continent:
|
||||
self.de_continent = de_call_info.continent
|
||||
if de_call_info.continent and not self.de_continent:
|
||||
self.de_continent = Continent(de_call_info.continent)
|
||||
if not self.de_dxcc_id:
|
||||
self.de_dxcc_id = de_call_info.dxcc_id
|
||||
if self.de_dxcc_id and not self.de_flag:
|
||||
@@ -240,17 +245,13 @@ class Spot:
|
||||
|
||||
# Mode from comments or bandplan
|
||||
if self.mode:
|
||||
self.mode_source = "SPOT"
|
||||
self.mode_source = ModeSource.SPOT
|
||||
if self.comment and not self.mode:
|
||||
self.mode = infer_mode_from_comment(self.comment)
|
||||
self.mode_source = "COMMENT"
|
||||
self.mode_source = ModeSource.COMMENT
|
||||
if self.freq and not self.mode:
|
||||
self.mode = infer_mode_from_frequency(self.freq)
|
||||
self.mode_source = "BANDPLAN"
|
||||
|
||||
# Normalise mode if necessary.
|
||||
if self.mode in MODE_ALIASES:
|
||||
self.mode = MODE_ALIASES[self.mode]
|
||||
self.mode_source = ModeSource.BANDPLAN
|
||||
|
||||
# Mode type from mode
|
||||
if self.mode and not self.mode_type:
|
||||
@@ -258,7 +259,7 @@ class Spot:
|
||||
|
||||
# If we have a latitude or grid at this point, it can only have been provided by the spot itself
|
||||
if self.dx_latitude or self.dx_grid:
|
||||
self.dx_location_source = "SPOT"
|
||||
self.dx_location_source = LocationSourceForSpot.SPOT
|
||||
|
||||
# Set the top-level "SIG" if it is missing but we have at least one SIG ref.
|
||||
if not self.sig and self.sig_refs and len(self.sig_refs) > 0:
|
||||
@@ -270,7 +271,7 @@ class Spot:
|
||||
sig = self.sig_refs[0].sig.upper()
|
||||
regex = get_ref_regex_for_sig(sig)
|
||||
if regex:
|
||||
all_comment_ref_matches = re.finditer(r"(^|\W)(" + regex + r")(^|\W)", self.comment, re.IGNORECASE)
|
||||
all_comment_ref_matches = re.finditer(r"(^|\W)(" + regex + r")($|\W)", self.comment, re.IGNORECASE)
|
||||
for ref_match in all_comment_ref_matches:
|
||||
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig))
|
||||
|
||||
@@ -298,6 +299,19 @@ class Spot:
|
||||
for ref_match in ref_matches:
|
||||
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(3).upper(), sig=found_sig))
|
||||
|
||||
# See if the comment looks like it contains any SIG references *without* the corresponding SIG name, but
|
||||
# where the SIG reference is unique-looking enough that we can't confuse it with any other SIG.
|
||||
if self.comment:
|
||||
for sig in SIGS:
|
||||
if sig.refs_globally_unique and sig.ref_regex:
|
||||
ref_matches = re.finditer(r"(^|\W)(" + sig.ref_regex + r")($|\W)", self.comment, re.IGNORECASE)
|
||||
for ref_match in ref_matches:
|
||||
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things
|
||||
# like cluster spots where the comment is just "OHFF-1234", now we know it's WWFF.
|
||||
if not self.sig:
|
||||
self.sig = sig.name
|
||||
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig.name))
|
||||
|
||||
# Fetch SIG data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a reference
|
||||
# in its initial call, we use this code to populate the rest of the data. This includes working out grid refs
|
||||
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
|
||||
@@ -311,9 +325,9 @@ class Spot:
|
||||
self.dx_latitude = sig_ref.latitude
|
||||
self.dx_longitude = sig_ref.longitude
|
||||
if self.sig == "WAB" or self.sig == "WAI" or self.sig == "Tiles":
|
||||
self.dx_location_source = "GRID"
|
||||
self.dx_location_source = LocationSourceForSpot.GRID
|
||||
else:
|
||||
self.dx_location_source = "SIG REF LOOKUP"
|
||||
self.dx_location_source = LocationSourceForSpot.SIG_REF_LOOKUP
|
||||
|
||||
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
|
||||
# and apply it to the whole spot.
|
||||
@@ -334,7 +348,7 @@ class Spot:
|
||||
self.de_grid = grid_mode_grid_match.group(1).upper()
|
||||
if not self.dx_grid:
|
||||
self.dx_grid = grid_mode_grid_match.group(4).upper()
|
||||
self.dx_location_source = "GRID"
|
||||
self.dx_location_source = LocationSourceForSpot.GRID
|
||||
|
||||
# And extract propagation mode (group 2 for <...>, group 3 for (...)):
|
||||
mode_tag = (grid_mode_grid_match.group(2) or grid_mode_grid_match.group(3) or "").upper()
|
||||
@@ -355,7 +369,7 @@ class Spot:
|
||||
# regex matches, so extract grids:
|
||||
if not self.dx_grid:
|
||||
self.dx_grid = grid_mode_grid_match.group(1).upper()
|
||||
self.dx_location_source = "GRID"
|
||||
self.dx_location_source = LocationSourceForSpot.GRID
|
||||
if not self.de_grid:
|
||||
self.de_grid = grid_mode_grid_match.group(2).upper()
|
||||
|
||||
@@ -427,10 +441,10 @@ class Spot:
|
||||
self.dx_latitude
|
||||
and self.dx_longitude
|
||||
and (
|
||||
self.dx_location_source == "SPOT"
|
||||
or self.dx_location_source == "SIG REF LOOKUP"
|
||||
or self.dx_location_source == "GRID"
|
||||
or (self.dx_location_source == "HOME QTH" and "/" not in (self.dx_call or ""))
|
||||
self.dx_location_source == LocationSourceForSpot.SPOT
|
||||
or self.dx_location_source == LocationSourceForSpot.SIG_REF_LOOKUP
|
||||
or self.dx_location_source == LocationSourceForSpot.GRID
|
||||
or (self.dx_location_source == LocationSourceForSpot.HOME_QTH and "/" not in (self.dx_call or ""))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -446,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")
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
AA343,"Almaty, Kazakhstan"
|
||||
AL945,"Alpena, United States"
|
||||
AT138,"Athens, Greece"
|
||||
AU930,"Austin, United States"
|
||||
BR52P,"Brisbane, Australia"
|
||||
BVJ03,"Boa Vista, Brazil"
|
||||
CAJ2M,"Cachoeira Paulista, Brazil"
|
||||
CB53N,"Canberra, Australia"
|
||||
CGK21,"Campo Grande, Brazil"
|
||||
DB049,"Dourbes, Belgium"
|
||||
DW41K,"Darwin, Australia"
|
||||
EA036,"El Arenosillo, Spain"
|
||||
@@ -18,24 +15,18 @@ GM037,"Gibilmanna, Italy"
|
||||
GR13L,"Grahamstown, South Africa"
|
||||
HE13N,"Hermanus, South Africa"
|
||||
HO54K,"Hobart, Australia"
|
||||
IC437,"I-Cheon, South Korea"
|
||||
IF843,"Idaho National Laboratory, United States"
|
||||
JI91J,"Jicamarca, Peru"
|
||||
JR055,"Juliusruh, Germany"
|
||||
LAA38,"Lajes Terceira Island, Portugal"
|
||||
LL721,"Lualualei, United States"
|
||||
LM42B,"Learmonth, Australia"
|
||||
LV12P,"Louisvale, South Africa"
|
||||
MHJ45,"Millstone Hill, United States"
|
||||
ML10L,"Malindi, Kenya"
|
||||
NI135,"Nicosia, Cyprus"
|
||||
NI63_,"Norfolk, Australia"
|
||||
PA836,"Portt Arguello, United States"
|
||||
PA836,"Port Arguello, United States"
|
||||
PE43K,"Perth, Australia"
|
||||
PF765,"Poker Flat, United States"
|
||||
PQ052,"Pruhonice, Czechia"
|
||||
RO041,"Rome, Italy"
|
||||
SAA0K,"Saoluis, Brazil"
|
||||
SO148,"Sopron, Hungary"
|
||||
TR169,"Tromso, Norway"
|
||||
TV51R,"Townsville, Australia"
|
||||
|
||||
|
+12
-11
@@ -23,15 +23,16 @@ You can replace `#main` with any other branch or tag reference, for example `#1.
|
||||
|
||||
Save the file. You will still need to create a copy of `config-example.yml` and name it `config.yml`, though with the
|
||||
Docker setup nothing has actually been downloaded yet, so you will have to copy the example from the repository some
|
||||
other way, e.g. [from the repo in a web browser](https://git.ianrenton.com/ian/spothole/src/branch/main/config-example.yml).
|
||||
other way,
|
||||
e.g. [from the repo in a web browser](https://git.ianrenton.com/ian/spothole/src/branch/main/config-example.yml).
|
||||
|
||||
With that in place, run `docker compose up` and you should be good to go. To detach, press `d` or run the command with
|
||||
the `-d` flag.
|
||||
|
||||
### nginx Reverse Proxy with Docker
|
||||
|
||||
In a containerised setup, it's typical to run an nginx reverse proxy in one container, alongside certbot for renewal
|
||||
of HTTPS certificates, and then applications like Spothole in a separate container. In this case, there are a couple of
|
||||
In a containerised setup, it's typical to run an nginx reverse proxy in one container, alongside certbot for renewal of
|
||||
HTTPS certificates, and then applications like Spothole in a separate container. In this case, there are a couple of
|
||||
variations of the docker compose file above, and the nginx reverse proxy configuration covered [here](./nginx.md), that
|
||||
you will want to make.
|
||||
|
||||
@@ -60,8 +61,8 @@ networks:
|
||||
```
|
||||
|
||||
In your nginx site configuration, you'll want to refer to the Spothole container directly, and drop the block that
|
||||
allows nginx to access static files directly, as these will be inaccessible in another container. So you may end up
|
||||
with something like:
|
||||
allows nginx to access static files directly, as these will be inaccessible in another container. So you may end up with
|
||||
something like:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -146,13 +147,13 @@ server {
|
||||
```
|
||||
|
||||
If desired, you could even change the port on which Spothole runs from 8080 to a plain 80, in which case your
|
||||
`proxy_pass` statements could drop the `:8080` suffix. Since Spothole is in a container, it can serve HTTP on port 80
|
||||
if desired, because it doesn't conflict with the host system.
|
||||
`proxy_pass` statements could drop the `:8080` suffix. Since Spothole is in a container, it can serve HTTP on port 80 if
|
||||
desired, because it doesn't conflict with the host system.
|
||||
|
||||
### Restoring the static files bypass
|
||||
|
||||
If you would still like to bypass Spothole's web server for the static files, and serve them with nginx, you can
|
||||
do. The easiest way is to run another nginx container to serve the files, so your Spothole `compose.yaml` becomes:
|
||||
If you would still like to bypass Spothole's web server for the static files, and serve them with nginx, you can do. The
|
||||
easiest way is to run another nginx container to serve the files, so your Spothole `compose.yaml` becomes:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -183,8 +184,8 @@ networks:
|
||||
external: true
|
||||
```
|
||||
|
||||
Then you can re-add the block that handles the `/static` path in your nginx reverse proxy config, but this time
|
||||
point it at the new container rather than at a filesystem path:
|
||||
Then you can re-add the block that handles the `/static` path in your nginx reverse proxy config, but this time point it
|
||||
at the new container rather than at a filesystem path:
|
||||
|
||||
```nginx
|
||||
# Load static assets from the spothole-static-nginx container
|
||||
|
||||
+2
-2
@@ -48,8 +48,8 @@ To navigate your way around the source code, this list may help.
|
||||
### Extending the server
|
||||
|
||||
Spothole is designed to be easily extensible. If you want to write your own spot provider, for example, simply add a
|
||||
module to the `providers.spot` package containing your class. (Currently, in order to be loaded correctly, the module (
|
||||
file) name should be the same as the class name, but lower case.)
|
||||
module to the `providers.spot` package containing your class. (Currently, in order to be loaded correctly, the module
|
||||
(file) name should be the same as the class name, but lower case.)
|
||||
|
||||
Your class should extend "SpotProvider"; if it operates by polling an HTTP Server on a timer, it can instead extend "
|
||||
HTTPSpotProvider" where some of the work is done for you.
|
||||
|
||||
+10
-15
@@ -2,8 +2,8 @@
|
||||
|
||||
Web servers generally serve their pages from port 80. However, it's best not to serve Spothole's web interface directly
|
||||
on port 80, as that requires root privileges on a Linux system. It also and prevents us using HTTPS to serve a secure
|
||||
site, since Spothole itself doesn't directly support acting as an HTTPS server. The normal solution to this is to use
|
||||
a "reverse proxy" setup, where a general web server handles HTTP and HTTP requests (to port 80 & 443 respectively), then
|
||||
site, since Spothole itself doesn't directly support acting as an HTTPS server. The normal solution to this is to use a
|
||||
"reverse proxy" setup, where a general web server handles HTTP and HTTP requests (to port 80 & 443 respectively), then
|
||||
passes on the request to the back-end application (in this case Spothole). nginx is a common choice for this general web
|
||||
server.
|
||||
|
||||
@@ -89,19 +89,14 @@ server {
|
||||
```
|
||||
|
||||
One further change you might want to make to the file above is the `add_header Access-Control-Allow-Origin` statements.
|
||||
These are what's used on
|
||||
my own Spothole server to make sure that other third-party web-based software can get the data from my instance, and
|
||||
applies to any endpoint underneath `/api`. If you want
|
||||
*your* Spothole instance to be set up the same way, so that others can write software in JavaScript that can access it,
|
||||
leave this intact. But if you want your Spothole instance to only be usable by scripts running on the web server you
|
||||
write,
|
||||
you can remove these lines. (Note that this doesn't stop other people writing *non-web-based* software that accesses
|
||||
your
|
||||
Spothole API—the enforcement of cross-origin headers only happens within the user's browser. If you need to lock
|
||||
your
|
||||
instance down so that no-one else can access it with *any* software, that's an aspect of nginx or firewall config that
|
||||
you will need
|
||||
to find help with elsewhere.)
|
||||
These are what's used on my own Spothole server to make sure that other third-party web-based software can get the data
|
||||
from my instance, and applies to any endpoint underneath `/api`. If you want *your* Spothole instance to be set up the
|
||||
same way, so that others can write software in JavaScript that can access it, leave this intact. But if you want your
|
||||
Spothole instance to only be usable by scripts running on the web server you write, you can remove these lines. (Note
|
||||
that this doesn't stop other people writing *non-web-based* software that accesses your Spothole API—the
|
||||
enforcement of cross-origin headers only happens within the user's browser. If you need to lock your instance down so
|
||||
that no-one else can access it with *any* software, that's an aspect of nginx or firewall config that you will need to
|
||||
find help with elsewhere.)
|
||||
|
||||
Now, make a symbolic link to enable the site:
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -59,6 +60,7 @@ class ParksNPeaks(HTTPAlertProvider):
|
||||
"POTA",
|
||||
"SOTA",
|
||||
"WWFF",
|
||||
"HEMA",
|
||||
"SIOTA",
|
||||
"ZLOTA",
|
||||
"KRMNPA",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -11,8 +11,9 @@ from requests_cache import CachedSession
|
||||
from core.config import SERVER_OWNER_CALLSIGN
|
||||
from core.constants import HTTP_HEADERS, SOFTWARE_VERSION
|
||||
from core.data_store import CACHE_DIR, DATA_STORE
|
||||
from core.enums import Continent
|
||||
from core.url_data_cache import URLDataCache
|
||||
from data.callsign import Callsign
|
||||
from data.callsign import Callsign, LocationSourceForCallsign
|
||||
from providers.callsigndata.api_query_callsign_data_provider import (
|
||||
APIQueryCallsignDataProvider,
|
||||
)
|
||||
@@ -142,12 +143,12 @@ class HamQTH(APIQueryCallsignDataProvider):
|
||||
name=data.get("nick", None),
|
||||
qth=data.get("qth", None),
|
||||
country=data.get("country", None),
|
||||
continent=data.get("continent", None),
|
||||
continent=Continent(data["continent"]) if "continent" in data else None,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
grid=grid,
|
||||
dxcc_id=int(data["adif"]) if "adif" in data else None,
|
||||
cq_zone=int(data["cq"]) if "cq" in data else None,
|
||||
itu_zone=int(data["itu"]) if "itu" in data else None,
|
||||
location_source="HOME QTH",
|
||||
location_source=LocationSourceForCallsign.HOME_QTH,
|
||||
)
|
||||
|
||||
@@ -10,11 +10,10 @@ from requests_cache import CachedSession
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from core.data_store import CACHE_DIR, DATA_STORE
|
||||
from core.enums import Continent, LocationSourceForCallsign
|
||||
from core.url_data_cache import URLDataCache
|
||||
from data.callsign import Callsign
|
||||
from providers.callsigndata.api_query_callsign_data_provider import (
|
||||
APIQueryCallsignDataProvider,
|
||||
)
|
||||
from providers.callsigndata.api_query_callsign_data_provider import APIQueryCallsignDataProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -170,12 +169,12 @@ class QRZ(APIQueryCallsignDataProvider):
|
||||
name=name,
|
||||
qth=data.get("addr2", None),
|
||||
country=data.get("country", None),
|
||||
continent=data.get("continent", None),
|
||||
continent=Continent(data["continent"]) if "continent" in data else None,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
grid=grid,
|
||||
dxcc_id=int(data["adif"]) if "adif" in data else None,
|
||||
cq_zone=int(data["cqzone"]) if "cqzone" in data else None,
|
||||
itu_zone=int(data["ituzone"]) if "ituzone" in data else None,
|
||||
location_source="HOME QTH",
|
||||
location_source=LocationSourceForCallsign.HOME_QTH,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
@@ -27,7 +28,7 @@ class ARLHS(FileDownloadSIGRefDataProvider):
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("Name", None),
|
||||
ref_type="Lighthouse",
|
||||
ref_type=SIGRefType.LIGHTHOUSE,
|
||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
||||
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
from time import sleep
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
|
||||
|
||||
class COTA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Castles on the Air"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "COTA"
|
||||
DATA_URL = "https://www.cotagroup.org/cotagroup/map/data/castles-all-7d90ee2a5e1175e5dece1bbf9dc87504.json"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
data = http_response.json()
|
||||
if isinstance(data, list):
|
||||
for ref in data[2]:
|
||||
ref_id = ref[3]
|
||||
name = ref[4]
|
||||
lat = float(ref[0])
|
||||
lon = float(ref[1])
|
||||
grid = latlong_to_locator(lat, lon)
|
||||
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=name,
|
||||
ref_type=SIGRefType.CASTLE,
|
||||
grid=grid,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
)
|
||||
)
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest
|
||||
# of the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
return new_data
|
||||
@@ -0,0 +1,42 @@
|
||||
import io
|
||||
from time import sleep
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
|
||||
|
||||
class DCE(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Castillos de España"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 365
|
||||
SIG = "DCE"
|
||||
DATA_URL = "https://www.acracb.org/dce/descargas/General/directorio_referencias_dce.xls"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
|
||||
file_stream = io.BytesIO(http_response.content)
|
||||
df = pd.read_excel(file_stream, engine="xlrd", header=None)
|
||||
|
||||
for index, row in df.iterrows():
|
||||
if row.iloc[0] and row.iloc[2]:
|
||||
new_data.append(
|
||||
SIGRef(sig=self.SIG, id=row.iloc[0].strip(), name=row.iloc[2].strip(), ref_type=SIGRefType.CASTLE)
|
||||
)
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||
# the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
return new_data
|
||||
@@ -0,0 +1,46 @@
|
||||
import io
|
||||
from time import sleep
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
|
||||
|
||||
class DEFE(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Estationes de Ferrocarril de España"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 365
|
||||
SIG = "DEFE"
|
||||
DATA_URL = "https://www.acracb.org/defe/descargas/General/directorio_referencias_defe.xls"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
|
||||
file_stream = io.BytesIO(http_response.content)
|
||||
df = pd.read_excel(file_stream, engine="xlrd", header=None)
|
||||
|
||||
for index, row in df.iterrows():
|
||||
# Skip the header row
|
||||
if str(row.iloc[0]) == "NºDEFE":
|
||||
continue
|
||||
|
||||
if row.iloc[0] and row.iloc[1]:
|
||||
new_data.append(
|
||||
SIGRef(sig=self.SIG, id=row.iloc[0].strip(), name=row.iloc[1].strip(), ref_type=SIGRefType.BUILDING)
|
||||
)
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||
# the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
return new_data
|
||||
@@ -3,6 +3,7 @@ from time import sleep
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.local_file_sig_ref_data_provider import (
|
||||
LocalFileSIGRefDataProvider,
|
||||
@@ -22,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")
|
||||
@@ -37,7 +42,7 @@ class DME(LocalFileSIGRefDataProvider):
|
||||
ref = SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
ref_type="Town",
|
||||
ref_type=SIGRefType.TOWN,
|
||||
name=f"{row['NOMBRE_ACTUAL']}, {row['PROVINCIA']}",
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
|
||||
|
||||
class DMUE(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Museos de España"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 365
|
||||
SIG = "DMUE"
|
||||
DATA_URL = "https://dmue.radiogalena.es/nom_dmue.csv"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
|
||||
for row in csv.reader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"):
|
||||
if len(row) > 1 and row[0] and row[1]:
|
||||
new_data.append(
|
||||
SIGRef(sig=self.SIG, id=row[0].strip(), name=row[1].strip(), ref_type=SIGRefType.BUILDING)
|
||||
)
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||
# the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
return new_data
|
||||
@@ -0,0 +1,50 @@
|
||||
import io
|
||||
from time import sleep
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
|
||||
|
||||
class DMVE(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Monumentos y Vestigios de España"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 365
|
||||
SIG = "DMVE"
|
||||
DATA_URL = "https://www.acracb.org/dmve/descargas/General/directorio_referencias_dmve.xls"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
|
||||
file_stream = io.BytesIO(http_response.content)
|
||||
# Despide the .xls extension this is actually an xlsx file, so we need openpyxl not xlrd
|
||||
df = pd.read_excel(file_stream, engine="openpyxl", header=None)
|
||||
|
||||
for index, row in df.iterrows():
|
||||
ref = row.iloc[0]
|
||||
name = row.iloc[1]
|
||||
|
||||
# Skip the header row and blank rows
|
||||
if str(ref) == "REF.":
|
||||
continue
|
||||
if pd.isna(ref) or pd.isna(name):
|
||||
continue
|
||||
|
||||
if ref and name:
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref.strip(), name=name.strip(), ref_type=SIGRefType.BUILDING))
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||
# the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
return new_data
|
||||
@@ -0,0 +1,35 @@
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
|
||||
|
||||
class DTMBA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Teatri Musei Belle Arti"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "DTMBA"
|
||||
DATA_URL = "https://www.iu1fig.com/share/iz0eik/dtmba/export.php"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
for row in http_response.content.decode("utf-8-sig").splitlines():
|
||||
split = row.split(";")
|
||||
ref_id = split[0]
|
||||
ref_name = split[1]
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=ref_name, ref_type=SIGRefType.BUILDING))
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||
# the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
return new_data
|
||||
@@ -0,0 +1,56 @@
|
||||
from io import BytesIO
|
||||
from time import sleep
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
|
||||
|
||||
class FEA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Faros de España"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "FEA"
|
||||
DATA_URL = "http://ea5ol.net/Lista%20Faros.pdf"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
|
||||
# Use PDFPlumber to extract the tables in the PDF
|
||||
with pdfplumber.open(BytesIO(http_response.content)) as pdf:
|
||||
all_rows = []
|
||||
|
||||
for page_number, page in enumerate(pdf.pages, start=1):
|
||||
tables = page.extract_tables()
|
||||
|
||||
for table_number, table in enumerate(tables, start=1):
|
||||
if not table:
|
||||
continue
|
||||
|
||||
rows = [row for row in table if any(cell and cell.strip() for cell in row)]
|
||||
all_rows.extend(rows)
|
||||
|
||||
for row in all_rows:
|
||||
if not "REF" in row[0] and not "\n" in row[0]:
|
||||
# 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 we add both copies to the database.
|
||||
ref_id_1 = row[0].strip()
|
||||
ref_id_2 = ref_id_1.replace("D-", "FEA-").replace("E-", "FEA-")
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id_1, name=row[1].strip(), ref_type=SIGRefType.LIGHTHOUSE))
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id_2, name=row[1].strip(), ref_type=SIGRefType.LIGHTHOUSE))
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||
# the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
return new_data
|
||||
@@ -1,6 +1,7 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
@@ -26,7 +27,7 @@ class GMA(FileDownloadSIGRefDataProvider):
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("Name", None),
|
||||
ref_type="Summit",
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
||||
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
@@ -27,7 +28,7 @@ class ILLW(FileDownloadSIGRefDataProvider):
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("Name", None),
|
||||
ref_type="Lighthouse",
|
||||
ref_type=SIGRefType.LIGHTHOUSE,
|
||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
||||
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
||||
|
||||
@@ -3,6 +3,7 @@ from time import sleep
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
@@ -44,7 +45,7 @@ class IOTA(FileDownloadSIGRefDataProvider):
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=ref["name"],
|
||||
ref_type="Island",
|
||||
ref_type=SIGRefType.ISLAND,
|
||||
grid=grid,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
|
||||
@@ -2,6 +2,7 @@ from time import sleep
|
||||
|
||||
from pyhamtools.locator import locator_to_latlong
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
@@ -32,7 +33,7 @@ class LLOTA(FileDownloadSIGRefDataProvider):
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=str(ref["name"]),
|
||||
ref_type="Lake",
|
||||
ref_type=SIGRefType.LAKE,
|
||||
url=f"https://llota.app/list/ref/{ref_id}",
|
||||
grid=grid,
|
||||
latitude=ll[0],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
@@ -26,7 +27,7 @@ class MOTA(FileDownloadSIGRefDataProvider):
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("Name", None),
|
||||
ref_type="Mill",
|
||||
ref_type=SIGRefType.MILL,
|
||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
||||
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from time import sleep
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
|
||||
|
||||
class PGA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Polish Gmina Award"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "PGA"
|
||||
DATA_URL = "http://www.spga.pl/lista_pga2.php"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
soup = BeautifulSoup(http_response.text, "html.parser")
|
||||
|
||||
# Iterate through tables in the page
|
||||
for table in soup.find_all("table"):
|
||||
header_cells = table.find_all(["th", "td"], limit=10)
|
||||
header_texts = [c.get_text(strip=True) for c in header_cells]
|
||||
|
||||
# If it has "PGA" and "Nazwa" in the header, it's the main data table
|
||||
if any("PGA" in t for t in header_texts) and any("Nazwa" in t for t in header_texts):
|
||||
# Iterate through all rows except the first
|
||||
rows = table.find_all("tr")
|
||||
for row in rows[1:]:
|
||||
cells = row.find_all("td")
|
||||
ref_id = cells[0].get_text(strip=True)
|
||||
name = cells[1].get_text(strip=True)
|
||||
if not ref_id:
|
||||
continue
|
||||
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=name,
|
||||
ref_type=SIGRefType.REGION,
|
||||
)
|
||||
)
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest
|
||||
# of the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
return new_data
|
||||
@@ -4,6 +4,7 @@ from time import sleep
|
||||
from fastkml import kml
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
@@ -43,7 +44,7 @@ class ParksNPeaksKMLSIGRefDataProvider(FileDownloadSIGRefDataProvider):
|
||||
sig=self.sig_name,
|
||||
id=ref_id,
|
||||
name=placemark.name,
|
||||
ref_type="Park",
|
||||
ref_type=SIGRefType.PARK,
|
||||
url=f"https://parksnpeaks.org/getPark.php?actPark={ref_id}",
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
@@ -26,7 +27,7 @@ class POTA(FileDownloadSIGRefDataProvider):
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("name", None),
|
||||
ref_type="Park",
|
||||
ref_type=SIGRefType.PARK,
|
||||
url=f"https://pota.app/#/park/{ref_id}",
|
||||
grid=row.get("grid", None),
|
||||
latitude=float(row["latitude"]) if "latitude" in row and row["latitude"] != "" else None,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
@@ -26,7 +27,7 @@ class SIOTA(FileDownloadSIGRefDataProvider):
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("NAME", None),
|
||||
ref_type="Silo",
|
||||
ref_type=SIGRefType.SILO,
|
||||
grid=row.get("LOCATOR", None),
|
||||
latitude=float(row["LAT"]) if "LAT" in row else None,
|
||||
longitude=float(row["LNG"]) if "LNG" in row else None,
|
||||
|
||||
@@ -3,6 +3,7 @@ from time import sleep
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
@@ -30,7 +31,7 @@ class SOTA(FileDownloadSIGRefDataProvider):
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("SummitName", None),
|
||||
ref_type="Summit",
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
url=f"https://www.sotadata.org.uk/en/summit/{ref_id}",
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import csv
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.local_file_sig_ref_data_provider import (
|
||||
LocalFileSIGRefDataProvider,
|
||||
@@ -26,7 +27,7 @@ class Toilets(LocalFileSIGRefDataProvider):
|
||||
sig=self.SIG,
|
||||
id=row["ref"],
|
||||
name=row["ref"],
|
||||
ref_type="Toilet",
|
||||
ref_type=SIGRefType.TOILET,
|
||||
latitude=float(row["lat"]),
|
||||
longitude=float(row["lon"]),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
@@ -26,7 +27,7 @@ class Towers(FileDownloadSIGRefDataProvider):
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("Nazev", None),
|
||||
ref_type="Tower",
|
||||
ref_type=SIGRefType.TOWER,
|
||||
url=f"https://wwtota.com/seznam/karta_rozhledny.php?ref={ref_id}",
|
||||
grid=row["Lokator"] if "Lokator" in row and row["Lokator"] != "" else None,
|
||||
latitude=float(row["Lat"]) if "Lat" in row and row["Lat"] != "" else None,
|
||||
|
||||
@@ -4,6 +4,7 @@ from time import sleep
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
@@ -45,7 +46,7 @@ class WCA(FileDownloadSIGRefDataProvider):
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("CLEAN NAME", None),
|
||||
ref_type="Castle",
|
||||
ref_type=SIGRefType.CASTLE,
|
||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
@@ -33,7 +34,7 @@ class WOTA(FileDownloadSIGRefDataProvider):
|
||||
id=ref_id,
|
||||
name=feature["properties"]["title"],
|
||||
url=url,
|
||||
ref_type="Summit",
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
grid=feature["properties"]["qthLocator"],
|
||||
latitude=feature["geometry"]["coordinates"][1],
|
||||
longitude=feature["geometry"]["coordinates"][0],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
@@ -26,7 +27,7 @@ class WWBOTA(FileDownloadSIGRefDataProvider):
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("Name", None),
|
||||
ref_type="Bunker",
|
||||
ref_type=SIGRefType.BUNKER,
|
||||
url=f"https://bunkerwiki.org/?s={ref_id}" if ref_id.startswith("B/G") else None,
|
||||
grid=row["Locator"] if "Locator" in row and row["Locator"] != "" else None,
|
||||
latitude=float(row["Lat"]) if "Lat" in row and row["Lat"] != "" else None,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
@@ -26,7 +27,7 @@ class WWFF(FileDownloadSIGRefDataProvider):
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("name", None),
|
||||
ref_type="Park",
|
||||
ref_type=SIGRefType.PARK,
|
||||
url=f"https://wwff.co/directory/?showRef={ref_id}",
|
||||
grid=row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None,
|
||||
latitude=float(row["latitude"])
|
||||
|
||||
@@ -2,6 +2,7 @@ from time import sleep
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
@@ -26,12 +27,16 @@ class ZLOTA(FileDownloadSIGRefDataProvider):
|
||||
ref_id = ref["code"]
|
||||
latitude = ref["latitude"]
|
||||
longitude = ref["longitude"]
|
||||
try:
|
||||
ref_type = SIGRefType(ref["asset_type"].title().upper())
|
||||
except ValueError:
|
||||
ref_type = None
|
||||
|
||||
new_ref = SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=ref["name"],
|
||||
ref_type=ref["asset_type"].title(),
|
||||
ref_type=ref_type,
|
||||
url=f"https://ontheair.nz/assets/{ref_id.replace('/', '_')}",
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
|
||||
@@ -4,6 +4,7 @@ from datetime import datetime
|
||||
import pytz
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from core.enums import Mode, SIGRefType
|
||||
from core.url_data_cache import URLDataCache
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
@@ -65,7 +66,7 @@ class GMA(HTTPSpotProvider):
|
||||
if (source_spot["QRG"] != "" and source_spot["QRG"] != "QRT")
|
||||
else None,
|
||||
# Filter out some weird mode strings
|
||||
mode=source_spot["MODE"].upper() if "<>" not in source_spot["MODE"] else None,
|
||||
mode=Mode.from_name(source_spot["MODE"].upper()) if "<>" not in source_spot["MODE"] else None,
|
||||
comment=source_spot["TEXT"],
|
||||
sig_refs=[
|
||||
SIGRef(
|
||||
@@ -113,24 +114,31 @@ class GMA(HTTPSpotProvider):
|
||||
match ref_info["reftype"]:
|
||||
case "Summit":
|
||||
spot.sig_refs[0].sig = "GMA"
|
||||
spot.sig_refs[0].ref_type = SIGRefType.SUMMIT
|
||||
spot.sig = "GMA"
|
||||
case "IOTA Island":
|
||||
spot.sig_refs[0].sig = "IOTA"
|
||||
spot.sig_refs[0].ref_type = SIGRefType.ISLAND
|
||||
spot.sig = "IOTA"
|
||||
case "GMA Island":
|
||||
spot.sig_refs[0].sig = "GMA Islands"
|
||||
spot.sig_refs[0].ref_type = SIGRefType.ISLAND
|
||||
spot.sig = "GMA Islands"
|
||||
case "Lighthouse (ILLW)":
|
||||
spot.sig_refs[0].sig = "ILLW"
|
||||
spot.sig_refs[0].ref_type = SIGRefType.LIGHTHOUSE
|
||||
spot.sig = "ILLW"
|
||||
case "Lighthouse (ARLHS)":
|
||||
spot.sig_refs[0].sig = "ARLHS"
|
||||
spot.sig_refs[0].ref_type = SIGRefType.LIGHTHOUSE
|
||||
spot.sig = "ARLHS"
|
||||
case "Castle":
|
||||
spot.sig_refs[0].sig = "WCA"
|
||||
spot.sig_refs[0].ref_type = SIGRefType.CASTLE
|
||||
spot.sig = "WCA"
|
||||
case "Mill":
|
||||
spot.sig_refs[0].sig = "MOTA"
|
||||
spot.sig_refs[0].ref_type = SIGRefType.MILL
|
||||
spot.sig = "MOTA"
|
||||
case _:
|
||||
logger.warning(
|
||||
|
||||
@@ -7,6 +7,7 @@ import requests
|
||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from core.enums import Mode, SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
@@ -59,7 +60,7 @@ class HEMA(HTTPSpotProvider):
|
||||
dx_call=spot_items[2].upper(),
|
||||
de_call=spotter_comment_match.group(1).upper(),
|
||||
freq=float(freq_mode_match.group(1)) * 1000000,
|
||||
mode=freq_mode_match.group(2).upper(),
|
||||
mode=Mode.from_name(freq_mode_match.group(2).upper()),
|
||||
comment=spotter_comment_match.group(2),
|
||||
sig="HEMA",
|
||||
sig_refs=[
|
||||
@@ -69,6 +70,7 @@ class HEMA(HTTPSpotProvider):
|
||||
name=spot_items[4],
|
||||
latitude=float(spot_items[7]),
|
||||
longitude=float(spot_items[8]),
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
)
|
||||
],
|
||||
time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from core.enums import Mode, SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
@@ -31,7 +32,7 @@ class LLOTA(HTTPSpotProvider):
|
||||
dx_call=source_spot["callsign"].upper(),
|
||||
de_call=spotter.upper() if spotter else None,
|
||||
freq=float(source_spot["frequency"]) * 1000000,
|
||||
mode=source_spot["mode"].upper(),
|
||||
mode=Mode.from_name(source_spot["mode"].upper()),
|
||||
comment=comment,
|
||||
sig="LLOTA",
|
||||
sig_refs=[
|
||||
@@ -39,6 +40,7 @@ class LLOTA(HTTPSpotProvider):
|
||||
id=source_spot["reference"],
|
||||
sig="LLOTA",
|
||||
name=source_spot["reference_name"],
|
||||
ref_type=SIGRefType.LAKE,
|
||||
)
|
||||
],
|
||||
time=datetime.fromisoformat(source_spot["updated_at"].replace("Z", "+00:00")).timestamp(),
|
||||
|
||||
@@ -6,6 +6,7 @@ import pytz
|
||||
import requests
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from core.enums import Mode
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
@@ -19,7 +20,6 @@ class ParksNPeaks(HTTPSpotProvider):
|
||||
POLL_INTERVAL_SEC = 120
|
||||
SPOTS_URL = "https://www.parksnpeaks.org/api/ALL"
|
||||
SUBMIT_URL = "https://www.parksnpeaks.org/api/SPOT/"
|
||||
SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv"
|
||||
SUBMITTABLE_SIGS = [
|
||||
"POTA",
|
||||
"SOTA",
|
||||
@@ -52,7 +52,7 @@ class ParksNPeaks(HTTPSpotProvider):
|
||||
if (source_spot["actFreq"] != "")
|
||||
else None,
|
||||
# Seen PNP spots with empty frequency, and with comma-separated thousands digits
|
||||
mode=source_spot["actMode"].upper(),
|
||||
mode=Mode.from_name(source_spot["actMode"].upper()),
|
||||
comment=source_spot["actComments"],
|
||||
time=datetime.strptime(source_spot["actTime"], "%Y-%m-%d %H:%M:%S")
|
||||
.replace(tzinfo=pytz.UTC)
|
||||
@@ -87,6 +87,7 @@ class ParksNPeaks(HTTPSpotProvider):
|
||||
"POTA",
|
||||
"SOTA",
|
||||
"WWFF",
|
||||
"HEMA",
|
||||
"SIOTA",
|
||||
"ZLOTA",
|
||||
"KRMNPA",
|
||||
|
||||
@@ -4,6 +4,7 @@ import pytz
|
||||
import requests
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from core.enums import Mode, SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
@@ -29,8 +30,8 @@ class POTA(HTTPSpotProvider):
|
||||
source_id=source_spot["spotId"],
|
||||
dx_call=source_spot["activator"].upper(),
|
||||
de_call=source_spot["spotter"].upper(),
|
||||
freq=float(source_spot["frequency"]) * 1000,
|
||||
mode=source_spot["mode"].upper(),
|
||||
freq=float(source_spot["frequency"]) * 1000 if source_spot["frequency"] != "INVALID" else None,
|
||||
mode=Mode.from_name(source_spot["mode"].upper()),
|
||||
comment=source_spot["comments"],
|
||||
sig="POTA",
|
||||
sig_refs=[
|
||||
@@ -40,6 +41,7 @@ class POTA(HTTPSpotProvider):
|
||||
name=source_spot["name"],
|
||||
latitude=source_spot["latitude"],
|
||||
longitude=source_spot["longitude"],
|
||||
ref_type=SIGRefType.PARK
|
||||
)
|
||||
],
|
||||
time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
@@ -4,7 +4,8 @@ from datetime import datetime
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
||||
|
||||
from core.constants import DV_SUB_MODES, HTTP_HEADERS, SSB_SUB_MODES
|
||||
from core.constants import HTTP_HEADERS
|
||||
from core.enums import Mode, SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
@@ -53,7 +54,7 @@ class SOTA(HTTPSpotProvider):
|
||||
if (source_spot["frequency"] is not None)
|
||||
else None,
|
||||
# Seen SOTA spots with no frequency!
|
||||
mode=source_spot["mode"].upper(),
|
||||
mode=Mode.from_name(source_spot["mode"].upper()),
|
||||
comment=source_spot["comments"],
|
||||
sig="SOTA",
|
||||
sig_refs=[
|
||||
@@ -64,6 +65,7 @@ class SOTA(HTTPSpotProvider):
|
||||
latitude=source_spot["latitude"],
|
||||
longitude=source_spot["longitude"],
|
||||
activation_score=source_spot["points"],
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
)
|
||||
],
|
||||
dx_latitude=source_spot["latitude"],
|
||||
@@ -98,12 +100,7 @@ class SOTA(HTTPSpotProvider):
|
||||
# https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js
|
||||
mode = spot.mode
|
||||
if mode and mode not in self.VALID_MODES:
|
||||
if mode in SSB_SUB_MODES:
|
||||
mode = "SSB"
|
||||
elif mode in DV_SUB_MODES:
|
||||
mode = "DV"
|
||||
else:
|
||||
mode = "Data"
|
||||
mode = "Data"
|
||||
|
||||
body = {
|
||||
"activatorCallsign": spot.dx_call,
|
||||
|
||||
@@ -3,7 +3,8 @@ from datetime import datetime
|
||||
|
||||
import requests
|
||||
|
||||
from core.constants import HTTP_HEADERS, SSB_SUB_MODES
|
||||
from core.constants import HTTP_HEADERS
|
||||
from core.enums import LocationSourceForSpot, Mode, SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
@@ -55,7 +56,7 @@ class Tiles(HTTPSpotProvider):
|
||||
# No separate spotter callsign, assume all spots are self-spots
|
||||
de_call=source_spot["call_sign"].upper(),
|
||||
freq=freq,
|
||||
mode=source_spot["mode"].upper(),
|
||||
mode=Mode.from_name(source_spot["mode"].upper()),
|
||||
comment=source_spot["notes"],
|
||||
sig="Tiles",
|
||||
# Tiles spots can include POTA & SOTA references, but ignore those on the basis that we will get them separately from the POTA/SOTA providers anyway.
|
||||
@@ -67,13 +68,14 @@ class Tiles(HTTPSpotProvider):
|
||||
name=source_spot["maidenhead_grid"],
|
||||
latitude=source_spot["latitude"],
|
||||
longitude=source_spot["longitude"],
|
||||
ref_type=SIGRefType.GRID
|
||||
)
|
||||
],
|
||||
time=datetime.fromisoformat(source_spot["created_at"].replace("Z", "+00:00")).timestamp(),
|
||||
dx_grid=source_spot["maidenhead_grid"],
|
||||
dx_latitude=source_spot["latitude"],
|
||||
dx_longitude=source_spot["longitude"],
|
||||
dx_location_source="GRID",
|
||||
dx_location_source=LocationSourceForSpot.GRID,
|
||||
)
|
||||
|
||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
||||
@@ -92,9 +94,7 @@ class Tiles(HTTPSpotProvider):
|
||||
if spot.mode:
|
||||
mode = spot.mode
|
||||
if mode not in self.VALID_MODES:
|
||||
if mode in SSB_SUB_MODES:
|
||||
mode = "SSB"
|
||||
elif mode == "OLIVIA":
|
||||
if mode == "OLIVIA":
|
||||
mode = "Olivia"
|
||||
elif mode == "JS8":
|
||||
mode = "JS8Call"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
@@ -32,10 +35,10 @@ class Towers(HTTPSpotProvider):
|
||||
freq=likely_freq,
|
||||
comment=source_spot["comment"],
|
||||
sig="Towers",
|
||||
sig_refs=[SIGRef(id=source_spot["ref"], sig="Towers")],
|
||||
time=datetime.strptime(
|
||||
response_json["updated"][:10] + source_spot["time"], "%Y-%m-%d%H:%M"
|
||||
).timestamp(),
|
||||
sig_refs=[SIGRef(id=source_spot["ref"], sig="Towers", ref_type=SIGRefType.TOWER)],
|
||||
time=datetime.strptime(response_json["updated"][:10] + source_spot["time"], "%Y-%m-%d%H:%M")
|
||||
.replace(tzinfo=pytz.utc)
|
||||
.timestamp(),
|
||||
)
|
||||
|
||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from core.enums import Mode
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
|
||||
@@ -77,7 +78,7 @@ class UKPacketNet(HTTPSpotProvider):
|
||||
dx_call=heard["callsign"].upper(),
|
||||
de_call=node["callsign"].upper(),
|
||||
freq=freq,
|
||||
mode="PKT",
|
||||
mode=Mode.PKT,
|
||||
comment=comment,
|
||||
time=datetime.strptime(heard["lastHeard"], "%Y-%m-%d %H:%M:%S")
|
||||
.replace(tzinfo=pytz.UTC)
|
||||
|
||||
@@ -8,6 +8,7 @@ import pytz
|
||||
from rss_parser import Parser
|
||||
from rss_parser.models.rss import RSS
|
||||
|
||||
from core.enums import Mode, SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
@@ -76,7 +77,9 @@ class WOTA(HTTPSpotProvider):
|
||||
except Exception:
|
||||
logger.warning(f"Could not parse WOTA spot description: {source_spot.description}")
|
||||
|
||||
time = datetime.strptime(source_spot.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
|
||||
time = datetime.strptime(source_spot.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(
|
||||
pytz.UTC
|
||||
)
|
||||
|
||||
# Convert to our spot format
|
||||
spot = Spot(
|
||||
@@ -85,10 +88,10 @@ class WOTA(HTTPSpotProvider):
|
||||
dx_call=dx_call,
|
||||
de_call=spotter,
|
||||
freq=freq_hz,
|
||||
mode=mode,
|
||||
mode=Mode.from_name(mode),
|
||||
comment=comment,
|
||||
sig="WOTA",
|
||||
sig_refs=[SIGRef(id=ref, sig="WOTA", name=ref_name)] if ref else [],
|
||||
sig_refs=[SIGRef(id=ref, sig="WOTA", name=ref_name, ref_type=SIGRefType.SUMMIT)] if ref else [],
|
||||
time=time.timestamp(),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from core.enums import Mode, SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.sse_spot_provider import SSESpotProvider
|
||||
@@ -26,6 +27,7 @@ class WWBOTA(SSESpotProvider):
|
||||
name=ref["name"],
|
||||
latitude=ref["lat"],
|
||||
longitude=ref["long"],
|
||||
ref_type=SIGRefType.BUNKER,
|
||||
)
|
||||
refs.append(sigref)
|
||||
|
||||
@@ -34,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=source_spot["mode"].upper() if "mode" in source_spot else None,
|
||||
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,6 +2,7 @@ from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from core.enums import Mode, SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
@@ -27,7 +28,7 @@ class WWFF(HTTPSpotProvider):
|
||||
dx_call=source_spot["activator"].upper(),
|
||||
de_call=source_spot["spotter"].upper(),
|
||||
freq=float(source_spot["frequency_khz"]) * 1000,
|
||||
mode=source_spot["mode"].upper(),
|
||||
mode=Mode.from_name(source_spot["mode"].upper()),
|
||||
comment=source_spot["remarks"],
|
||||
sig="WWFF",
|
||||
sig_refs=[
|
||||
@@ -37,6 +38,7 @@ class WWFF(HTTPSpotProvider):
|
||||
name=source_spot["reference_name"],
|
||||
latitude=source_spot["latitude"],
|
||||
longitude=source_spot["longitude"],
|
||||
ref_type=SIGRefType.PARK
|
||||
)
|
||||
],
|
||||
time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC).timestamp(),
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from core.enums import Mode
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.websocket_spot_provider import WebsocketSpotProvider
|
||||
@@ -34,7 +35,7 @@ class XOTA(WebsocketSpotProvider):
|
||||
source_id=source_spot["id"],
|
||||
dx_call=source_spot["stationCallSign"].upper(),
|
||||
freq=float(source_spot["freq"]) * 1000,
|
||||
mode=source_spot["mode"].upper(),
|
||||
mode=Mode.from_name(source_spot["mode"].upper()),
|
||||
sig=self.SIG,
|
||||
sig_refs=[
|
||||
SIGRef(
|
||||
|
||||
@@ -2,6 +2,7 @@ from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from core.enums import Mode
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||
@@ -32,7 +33,7 @@ class ZLOTA(HTTPSpotProvider):
|
||||
dx_call=source_spot["activator"].upper(),
|
||||
de_call=source_spot["spotter"].upper(),
|
||||
freq=freq_hz,
|
||||
mode=source_spot["mode"].upper().strip(),
|
||||
mode=Mode.from_name(source_spot["mode"].upper().strip()),
|
||||
comment=source_spot["comments"],
|
||||
sig="ZLOTA",
|
||||
sig_refs=[
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "spothole"
|
||||
version = "2.0.5"
|
||||
version = "2.1-pre"
|
||||
authors = [
|
||||
{ name = "Ian Renton", email = "ian@ianrenton.com" },
|
||||
]
|
||||
|
||||
+6
-1
@@ -16,8 +16,13 @@ beautifulsoup4~=4.14.2
|
||||
websocket-client~=1.8.0
|
||||
tornado~=6.4.2
|
||||
tornado_eventsource~=3.0.0
|
||||
pandas~=3.0.0
|
||||
geopandas~=0.13.2
|
||||
simplejson~=4.1.1
|
||||
cachetools~=7.1.6
|
||||
fastkml~=1.4.0
|
||||
ruff~=0.16.3
|
||||
ruff~=0.16.3
|
||||
pdfplumber~=0.11.10
|
||||
xlrd~=2.0.2
|
||||
openpyxl~=3.1.5
|
||||
icalendar~=7.3.0
|
||||
@@ -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:
|
||||
|
||||
@@ -9,15 +9,15 @@ import tornado
|
||||
from tornado import httputil
|
||||
from tornado.web import Application
|
||||
|
||||
from core.constants import BANDS
|
||||
from core.enums import Continent
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import safe_json_dumps
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONTINENTS = ["EU", "NA", "SA", "AS", "AF", "OC", "AN"]
|
||||
BANDS = ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"]
|
||||
CONTINENTS_SET = frozenset(CONTINENTS)
|
||||
BANDS_SET = frozenset(BANDS)
|
||||
CONTINENTS = [c.value for c in Continent]
|
||||
HF_BANDS = [b.name for b in BANDS if b.is_ham_hf]
|
||||
|
||||
|
||||
class APIDxStatsHandler(tornado.web.RequestHandler):
|
||||
@@ -53,15 +53,11 @@ class APIDxStatsHandler(tornado.web.RequestHandler):
|
||||
continue
|
||||
if not spot.time or spot.time < one_hour_ago:
|
||||
continue
|
||||
if (
|
||||
spot.de_continent in CONTINENTS_SET
|
||||
and spot.dx_continent in CONTINENTS_SET
|
||||
and spot.band in BANDS_SET
|
||||
):
|
||||
if spot.de_continent in CONTINENTS and spot.dx_continent in CONTINENTS and spot.band in HF_BANDS:
|
||||
counts[spot.de_continent, spot.dx_continent, spot.band] += 1
|
||||
|
||||
result = {
|
||||
de: {dx: {band: counts[de, dx, band] for band in BANDS} for dx in CONTINENTS} for de in CONTINENTS
|
||||
de: {dx: {band: counts[de, dx, band] for band in HF_BANDS} for dx in CONTINENTS} for de in CONTINENTS
|
||||
}
|
||||
|
||||
self.write(json.dumps(result))
|
||||
|
||||
@@ -8,14 +8,8 @@ from tornado import httputil
|
||||
from tornado.web import Application
|
||||
|
||||
from core.config import ALLOW_SPOTTING, MAX_SPOT_AGE
|
||||
from core.constants import (
|
||||
ALL_MODES,
|
||||
BANDS,
|
||||
CONTINENTS,
|
||||
MODE_TYPES,
|
||||
PROPAGATION_MODES,
|
||||
SIGS,
|
||||
)
|
||||
from core.constants import BANDS, PROPAGATION_MODES, SIGS
|
||||
from core.enums import Continent, Mode, ModeType
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import safe_json_dumps
|
||||
|
||||
@@ -89,14 +83,14 @@ class APIOptionsHandler(tornado.web.RequestHandler):
|
||||
|
||||
options = {
|
||||
"bands": BANDS,
|
||||
"modes": ALL_MODES,
|
||||
"mode_types": MODE_TYPES,
|
||||
"modes": [m.value for m in Mode],
|
||||
"mode_types": [t.value for t in ModeType],
|
||||
"sigs": SIGS,
|
||||
"spot_providers": spot_providers,
|
||||
"spot_providers_enabled_by_default": spot_providers_enabled_by_default,
|
||||
"alert_providers": alert_providers,
|
||||
"callsign_data_providers": callsign_data_providers,
|
||||
"continents": CONTINENTS,
|
||||
"continents": [c.value for c in Continent],
|
||||
"propagation_modes": list(PROPAGATION_MODES.values()),
|
||||
"max_spot_age": MAX_SPOT_AGE,
|
||||
"spot_allowed": ALLOW_SPOTTING,
|
||||
|
||||
@@ -2,7 +2,6 @@ import tornado
|
||||
from tornado.httpclient import AsyncHTTPClient
|
||||
from tornado.httputil import HTTPHeaders
|
||||
|
||||
|
||||
_LEGACY_PARAM_TO_HEADER_MAP = {
|
||||
"qrz_username": "X-QRZ-Username",
|
||||
"qrz_password": "X-QRZ-Password",
|
||||
@@ -12,6 +11,7 @@ _LEGACY_PARAM_TO_HEADER_MAP = {
|
||||
"hamqth_session_id": "X-HamQTH-Session-ID",
|
||||
}
|
||||
|
||||
|
||||
class V1RedirectHandler(tornado.web.RequestHandler):
|
||||
"""Transparently proxies requests from the old API to the new one,
|
||||
returning whatever the v2 endpoint returns, for endpoints with no breaking changes."""
|
||||
@@ -60,4 +60,4 @@ class V1RedirectHandler(tornado.web.RequestHandler):
|
||||
await self._proxy(path)
|
||||
|
||||
async def post(self, path):
|
||||
await self._proxy(path)
|
||||
await self._proxy(path)
|
||||
|
||||
@@ -23,6 +23,7 @@ def _handle_legacy_params(handler):
|
||||
if value:
|
||||
handler.request.headers[header] = value
|
||||
|
||||
|
||||
class V1APISpotsHandler(APISpotsHandler):
|
||||
"""API request handler for /api/v1/spots (GET). Included in early Spothole v2 for backwards compatibility."""
|
||||
|
||||
|
||||
+110
-13
@@ -15,6 +15,16 @@ info:
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2.1
|
||||
|
||||
* 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 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
|
||||
|
||||
* **Breaking change:** The "add spot" API has changed to enable future support for upstream submission to the spotting services associated with various SIGs. Instead of just posting the spot object itself as the JSON content of the POST, this has moved into a `spot` object within the structure. A new `handling` object alongside it contains the `submit_upstream`, `upstream_provider`, `upstream_credentials`, and `captcha_token` fields which control the server handling of the spot.
|
||||
@@ -201,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'
|
||||
@@ -235,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'
|
||||
@@ -653,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:
|
||||
@@ -667,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
|
||||
@@ -852,9 +874,22 @@ components:
|
||||
- WAB
|
||||
- WAI
|
||||
- DME
|
||||
- FEA
|
||||
- DTMBA
|
||||
- BIWOTA
|
||||
- COTA
|
||||
- PGA
|
||||
- Toilets
|
||||
example: POTA
|
||||
|
||||
SIGType:
|
||||
type: string
|
||||
enum:
|
||||
- WORLDWIDE
|
||||
- REGIONAL
|
||||
- EVENT
|
||||
example: WORLDWIDE
|
||||
|
||||
SIGNameIncludingNoSIG:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SIGName"
|
||||
@@ -862,6 +897,36 @@ components:
|
||||
enum: [ NO_SIG ]
|
||||
example: POTA
|
||||
|
||||
SIGRefType:
|
||||
type: string
|
||||
enum:
|
||||
- PARK
|
||||
- SUMMIT
|
||||
- CASTLE
|
||||
- LIGHTHOUSE
|
||||
- ISLAND
|
||||
- BUNKER
|
||||
- MILL
|
||||
- SILO
|
||||
- BEACH
|
||||
- LAKE
|
||||
- TOWER
|
||||
- WATERWAY
|
||||
- TOWN
|
||||
- BUILDING
|
||||
- REGION
|
||||
- GRID
|
||||
- TOILET
|
||||
example: PARK
|
||||
|
||||
AlertType:
|
||||
type: string
|
||||
enum:
|
||||
- XOTA
|
||||
- DXPEDITION
|
||||
- CONTEST
|
||||
example: XOTA
|
||||
|
||||
Continent:
|
||||
type: string
|
||||
enum:
|
||||
@@ -911,15 +976,9 @@ components:
|
||||
- CW
|
||||
- PHONE
|
||||
- SSB
|
||||
- USB
|
||||
- LSB
|
||||
- AM
|
||||
- FM
|
||||
- DV
|
||||
- DMR
|
||||
- DSTAR
|
||||
- C4FM
|
||||
- M17
|
||||
- DATA
|
||||
- FT8
|
||||
- FT4
|
||||
@@ -976,7 +1035,7 @@ components:
|
||||
- NONE
|
||||
example: SPOT
|
||||
|
||||
LocationSourceForAlert:
|
||||
LocationSourceForCallsign:
|
||||
type: string
|
||||
enum:
|
||||
- "HOME QTH"
|
||||
@@ -999,9 +1058,8 @@ components:
|
||||
description: SIG reference name
|
||||
example: Null Country Park
|
||||
ref_type:
|
||||
type: string
|
||||
description: SIG reference type
|
||||
example: Park
|
||||
$ref: "#/components/schemas/SIGRefType"
|
||||
url:
|
||||
type: string
|
||||
description: SIG reference URL, which the user can look up for more information
|
||||
@@ -1241,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:
|
||||
@@ -1394,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.
|
||||
@@ -1402,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:
|
||||
@@ -1486,6 +1559,12 @@ components:
|
||||
type: string
|
||||
description: The full name of the SIG
|
||||
example: Parks on the Air
|
||||
sig_type:
|
||||
type: boolean
|
||||
description: >
|
||||
Whether the SIG is worldwide, regional, or for a specific event. Generally for Spothole's own internal use,
|
||||
clients probably won't need this. Used to group them in the web UI.
|
||||
$ref: "#/components/schemas/SIGType"
|
||||
comment_names:
|
||||
type: array
|
||||
description: >
|
||||
@@ -1502,6 +1581,24 @@ components:
|
||||
Regex that matches this SIG's reference IDs. Generally for Spothole's own internal use,
|
||||
clients probably won't need this.
|
||||
example: "[A-Z]{2}\\-\\d+"
|
||||
refs_globally_unique:
|
||||
type: boolean
|
||||
description: >
|
||||
Identifies that the SIG's reference ID structure defined by its regex is unique across all programmes and
|
||||
anything else we expect a user to put in a spot comment, and therefore we can pull references out of
|
||||
spot comments without also needing to see the SIG name first. For example, "OHFF-1234" or "B/G-1234" are
|
||||
obviously WWFF and WWBOTA, nothing else looks like those. But "SZ09" could be WAB or Tiles, "GB1234" could
|
||||
conceivably be POTA or ILLW, etc. Generally for Spothole's own internal use, clients probably won't need
|
||||
this.
|
||||
icon:
|
||||
type: string
|
||||
description: Icon from the Font Awesome set that represents this SIG, for use in the front end.
|
||||
example: "fa-tree"
|
||||
region_flag:
|
||||
type: string
|
||||
description: >
|
||||
Flag emoji, if this SIG is specific to a country or region. If null, this SIG is treated as worldwide.
|
||||
example: "🇺🇳"
|
||||
|
||||
SolarConditions:
|
||||
type: object
|
||||
@@ -2030,7 +2127,7 @@ components:
|
||||
description: An array of all the supported modes.
|
||||
items:
|
||||
type: string
|
||||
example: "LSB"
|
||||
example: "SSB"
|
||||
mode_types:
|
||||
type: array
|
||||
description: An array of all the supported mode types.
|
||||
@@ -2174,7 +2271,7 @@ components:
|
||||
Where we got the location (grid/latitude/longitude) from. Unlike a spot where we might
|
||||
have a summit position or WAB square, here the only options are an online QTH lookup, or
|
||||
a location based purely on DXCC, or nothing.
|
||||
$ref: "#/components/schemas/LocationSourceForAlert"
|
||||
$ref: "#/components/schemas/LocationSourceForCallsign"
|
||||
|
||||
GridLookup:
|
||||
type: object
|
||||
|
||||
+13
-27
@@ -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"], "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"], "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
|
||||
|
||||
@@ -285,7 +285,6 @@ function callWanted(call) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Startup
|
||||
$(document).ready(function () {
|
||||
usePreferredTheme();
|
||||
|
||||
+10
-4
@@ -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"], "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"], "fa-tower-cell")}'></i></span> ${sigSourceText} ${sig_refs}</span><br/>`;
|
||||
ttt += `<span class='nowrap'><span class='icon-wrapper'><i class='fa-solid ${s["icon"]}'></i></span> ${sigSourceText} ${sig_refs}</span><br/>`;
|
||||
|
||||
// Time
|
||||
ttt += `<span class='icon-wrapper'><i class='fa-solid fa-clock markerPopupIcon'></i></span> ${moment.unix(s["time"]).fromNow()}`;
|
||||
|
||||
+2
-2
@@ -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"], "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"], "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} `);
|
||||
|
||||
@@ -40,14 +40,30 @@ function setHamHFBandToggles() {
|
||||
|
||||
// Generate SIGs filter card. This one is also a special case.
|
||||
function generateSIGsMultiToggleFilterCard(sig_options) {
|
||||
const $grid = $('<div class="row row-cols-2 g-1 mb-1">');
|
||||
sig_options.forEach(o => {
|
||||
const $grid1 = $('<div class="row row-cols-2 g-1 mb-1">');
|
||||
$("#sig-options").append('<strong>Worldwide</strong>');
|
||||
sig_options.filter(o => o["sig_type"] === "WORLDWIDE").forEach(o => {
|
||||
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${sigToIcon(o['name'], 'fa-tower-cell')}"></i> ${o['name']}</label></div></div>`);
|
||||
$grid1.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
|
||||
});
|
||||
$("#sig-options").append($grid1);
|
||||
const $grid2 = $('<div class="row row-cols-2 g-1 mb-1">');
|
||||
$("#sig-options").append('<strong>Regional</strong>');
|
||||
sig_options.filter(o => o["sig_type"] === "REGIONAL").forEach(o => {
|
||||
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||
$grid2.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
|
||||
});
|
||||
$("#sig-options").append($grid2);
|
||||
const $grid3 = $('<div class="row row-cols-2 g-1 mb-1">');
|
||||
$("#sig-options").append('<strong>Event</strong>');
|
||||
sig_options.filter(o => o["sig_type"] === "EVENT").forEach(o => {
|
||||
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||
$grid3.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${o['icon']}"></i> ${o['name']} ${(o["region_flag"] != null) ? o['region_flag'] : ''}</label></div></div>`);
|
||||
});
|
||||
$("#sig-options").append($grid3);
|
||||
$("#sig-options").append('<strong>Other</strong>');
|
||||
// Bonus "NO_SIG" / "General DX" option
|
||||
$grid.append(`<div class="w-100"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-NO_SIG" value="NO_SIG" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-NO_SIG" for="filter-button-sig-NO_SIG"><i class="fa-solid fa-tower-cell"></i> General DX</label></div></div>`);
|
||||
$("#sig-options").append($grid);
|
||||
$("#sig-options").append(`<div class="w-100"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-NO_SIG" value="NO_SIG" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-NO_SIG" for="filter-button-sig-NO_SIG"><i class="fa-solid fa-tower-cell"></i> General DX</label></div></div>`);
|
||||
$("#sig-options").append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('sig', true); return false;">All</a> <a href="#" onclick="toggleFilterButtons('sig', false); return false;">None</a></div>`);
|
||||
}
|
||||
|
||||
@@ -64,7 +80,7 @@ function generateModesMultiToggleFilterCard(mode_options) {
|
||||
|
||||
// Set the mode toggles that relate to Analog Voice.
|
||||
function setVoiceModeToggles() {
|
||||
const modes = ["PHONE", "SSB", "LSB", "USB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"];
|
||||
const modes = ["PHONE", "SSB", "AM", "FM", "DV"];
|
||||
$(".filter-button-mode").each(function () {
|
||||
$(this).prop('checked', modes.includes($(this).val().replace("filter-button-mode-", "")));
|
||||
});
|
||||
|
||||
@@ -323,121 +323,3 @@ function bandToContrastColor(band) {
|
||||
const lum = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
|
||||
return (lum > 128) ? "#000000" : "#ffffff";
|
||||
}
|
||||
|
||||
const MODE_TYPE_COLOR_SCHEMES = {
|
||||
"CW": "red",
|
||||
"PHONE": "green",
|
||||
"DATA": "blue"
|
||||
}
|
||||
|
||||
// Mode type (CW, PHONE, DATA) to colour. If the mode type is unknown, black will be returned.
|
||||
function modeTypeToColor(modeType) {
|
||||
let col = (modeType != null) ? MODE_TYPE_COLOR_SCHEMES[modeType.toUpperCase()] : null;
|
||||
if (col) {
|
||||
return col;
|
||||
} else {
|
||||
return "#000000";
|
||||
}
|
||||
}
|
||||
|
||||
const SIG_ICONS = {
|
||||
"POTA": "fa-tree",
|
||||
"SOTA": "fa-mountain-sun",
|
||||
"WWFF": "fa-seedling",
|
||||
"GMA": "fa-person-hiking",
|
||||
"GMA Islands": "fa-person-hiking",
|
||||
"WWBOTA": "fa-radiation",
|
||||
"HEMA": "fa-mound",
|
||||
"IOTA": "fa-book-atlas",
|
||||
"MOTA": "fa-fan",
|
||||
"ARLHS": "fa-house-flood-water",
|
||||
"ILLW": "fa-house-flood-water",
|
||||
"SIOTA": "fa-wheat-awn",
|
||||
"WCA": "fa-chess-rook",
|
||||
"ZLOTA": "fa-kiwi-bird",
|
||||
"WOTA": "fa-w",
|
||||
"BOTA": "fa-umbrella-beach",
|
||||
"KRMNPA": "fa-earth-oceania",
|
||||
"SANPCPA": "fa-earth-oceania",
|
||||
"LLOTA": "fa-water",
|
||||
"Towers": "fa-tower-observation",
|
||||
"WAB": "fa-table-cells-large",
|
||||
"WAI": "fa-table-cells-large",
|
||||
"DME": "fa-building",
|
||||
"Tiles": "fa-square",
|
||||
"Toilets": "fa-toilet"
|
||||
}
|
||||
|
||||
const SIG_NAMES = {
|
||||
"POTA": "Parks on the Air",
|
||||
"SOTA": "Summits on the Air",
|
||||
"WWFF": "Worldwide Flora & Fauna",
|
||||
"GMA": "Global Mountain Activity",
|
||||
"GMA Islands": "Global Mountain Activity - Islands",
|
||||
"WWBOTA": "Bunkers on the Air",
|
||||
"HEMA": "Humps Excluding Marilyns Award",
|
||||
"IOTA": "Islands on the Air",
|
||||
"MOTA": "Mills on the Air",
|
||||
"ARLHS": "Amateur Radio Lighthouse Society",
|
||||
"ILLW": "International Lighthouse Lightship Weekend",
|
||||
"SIOTA": "Silos on the Air",
|
||||
"WCA": "World Castles Award",
|
||||
"ZLOTA": "New Zealand on the Air",
|
||||
"WOTA": "Wainwrights on the Air",
|
||||
"BOTA": "Beaches on the Air",
|
||||
"KRMNPA": "Keith Roget Memorial National Parks Award",
|
||||
"SANPCPA": "South Australia National Parks and Conservation Parks Award",
|
||||
"LLOTA": "Lagos y Lagunas on the Air",
|
||||
"WWTOTA": "Towers on the Air",
|
||||
"WAB": "Worked All Britain",
|
||||
"WAI": "Worked All Ireland",
|
||||
"Tiles": "Tiles on the Air",
|
||||
"TOTA": "Toilets on the Air"
|
||||
}
|
||||
|
||||
// Get the Font Awesome icon for a given SIG. If the SIG is unknown, the provided default symbol will be returned
|
||||
function sigToIcon(sig, defaultIcon) {
|
||||
let col = (sig != null) ? SIG_ICONS[sig] : null;
|
||||
if (col) {
|
||||
return col;
|
||||
} else {
|
||||
let col = (sig != null) ? SIG_ICONS[sig.toUpperCase()] : null;
|
||||
if (col) {
|
||||
return col;
|
||||
} else {
|
||||
return defaultIcon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the full name for a given SIG abbreviation. If the SIG is unknown, an empty string will be returned.
|
||||
function sigToName(sig) {
|
||||
let col = (sig != null) ? SIG_NAMES[sig] : null;
|
||||
if (col) {
|
||||
return col;
|
||||
} else {
|
||||
let col = (sig != null) ? SIG_NAMES[sig.toUpperCase()] : null;
|
||||
if (col) {
|
||||
return col;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the list of known SIGs
|
||||
function getKnownSIGs() {
|
||||
return Array.from(Object.keys(SIG_ICONS));
|
||||
}
|
||||
|
||||
// Format a Maidenhead grid with alternating alphabetic blocks in lower case
|
||||
function formatGrid(grid) {
|
||||
grid = grid.toUpperCase();
|
||||
if (grid.length >= 6) {
|
||||
grid = grid.substring(0, 4) + grid.substring(4, 6).toLowerCase() + grid.substring(6);
|
||||
}
|
||||
if (grid.length >= 12) {
|
||||
grid = grid.substring(0, 10) + grid.substring(10, 12).toLowerCase() + grid.substring(14);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
+14
-9
@@ -101,12 +101,15 @@
|
||||
(MOTA), the Amateur Radio Lighthouse Society (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos
|
||||
on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National
|
||||
Parks Award (KRMNPA), South Australia National Parks and Conservation Parks Award (SANPCPA), Wainwrights on the
|
||||
Air (WOTA), Beaches on the Air (BOTA), Lagos y Lagunas On the Air (LLOTA), Towers on the Air (WWTOTA), Tiles on
|
||||
the Air, Worked All Britain (WAB), Worked All Ireland (WAI), el Diploma Municipios de España (DME) and Toilets
|
||||
on the Air (TOTA).</p>
|
||||
<p>As of the time of writing in June 2026, I think Spothole captures essentially all outdoor radio programmes
|
||||
that have a defined reference list, and almost certainly those that have a spotting/alerting API. If you know of
|
||||
one I've missed, please let me know!</p>
|
||||
Air (WOTA), Beaches on the Air (BOTA), Lagos y Lagunas On the Air (LLOTA), Towers on the Air, Tiles on
|
||||
the Air, Worked All Britain (WAB), Worked All Ireland (WAI), Diploma Municipios de España (DME), Diploma
|
||||
Faros de España (FEA), Diploma Muesos de España (DMUE), Diploma Castillos de España (DCE), Diploma Monumentos y
|
||||
Vestigios de España (DMVE), Diploma Estaciones de Ferrocarril de España (DEFE), Diploma Teatri Musei e Belle
|
||||
Arti (DTMBA), British Inland Waterways on the Air (BIWOTA), Castles on the Air (COTA), Polish Gmina Award (PGA),
|
||||
and Toilets on the Air.</p>
|
||||
<p>As of the time of writing in August 2026, I think Spothole captures most outdoor radio programmes that have a
|
||||
defined, downloadable reference list, and almost certainly those that have a spotting/alerting API. If you know
|
||||
of one I've missed, please let me know!</p>
|
||||
<h4 class="mt-4">Why can I filter spots by both SIG and Source? Isn't that basically the same thing?</h4>
|
||||
<p>Mostly, but not quite. While POTA spots generally come from the POTA source and so on, there are a few
|
||||
exceptions:</p>
|
||||
@@ -178,13 +181,15 @@
|
||||
filters. They are also stored in your browser's local storage, so that your preferences are remembered between
|
||||
sessions.</p>
|
||||
<p>The data you provide can optionally include your login credentials for QRZ.com and HamQTH. You can provide these
|
||||
in the "Your Data" menu of most pages. If you do, Spothole will augment the data it produces with lookups from these
|
||||
in the "Your Data" menu of most pages. If you do, Spothole will augment the data it produces with lookups from
|
||||
these
|
||||
services, which can for example provide more accurate markers on the map tab, and operator names when you mouse
|
||||
over a DX callsign. Spothole will still work fine if you don't provide these. The values you enter are sent to
|
||||
Spothole via HTTPS so are protected in transit, though of course you do have to trust Spothole with this
|
||||
sensitive data in order to use this feature.</p>
|
||||
<p>Any data you send as part of a query, such as your QRZ or HamQTH credentials, is used only for the lifetime of that
|
||||
query and is not saved anywhere apart from your own device.</p>
|
||||
<p>Any data you send as part of a query, such as your QRZ or HamQTH credentials, is used only for the lifetime of
|
||||
that
|
||||
query and is not saved anywhere apart from your own device.</p>
|
||||
<p>Spothole uses no trackers, no ads, and no cookies.</p>
|
||||
{% if len(web_ui_options["support_button_html"]) > 0 %}
|
||||
<p><strong>Caveat: </strong> The owner of this server has chosen to inject their own content into the "spots" page.
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
{% block content %}
|
||||
|
||||
<div id="add-spot-intro-box" class="permanently-dismissible-box mt-3">
|
||||
<div class="alert alert-primary alert-dismissible fade show" role="alert"> <!-- TODO Remove when feature available -->
|
||||
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||
<!-- TODO Remove when feature available -->
|
||||
<i class="fa-solid fa-circle-info"></i> <strong>Adding spots to Spothole</strong><br/>This page is implemented
|
||||
as a proof of concept for adding spots to the Spothole system. Currently, spots added in this way are only
|
||||
visible within Spothole and are not sent "upstream" to DX clusters or xOTA spotting sites. The functionality
|
||||
@@ -76,7 +77,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/static/js/add-spot.js?v=1787991444"></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>
|
||||
|
||||
@@ -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=1787991444"></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>
|
||||
|
||||
@@ -76,8 +76,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/static/js/spotsbandsandmap.js?v=1787991444"></script>
|
||||
<script src="/static/js/bands.js?v=1787991444"></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>
|
||||
|
||||
+8
-7
@@ -1,6 +1,6 @@
|
||||
{% extends "skeleton.html" %}
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=1787991444" 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">
|
||||
@@ -11,14 +11,15 @@
|
||||
<script src="/static/vendor/js/tinycolor2-1.6.0.min.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import { fetchEventSource } from '/static/vendor/js/fetch-event-source-2.0.1/index.js';
|
||||
window.fetchEventSource = fetchEventSource;
|
||||
import {fetchEventSource} from '/static/vendor/js/fetch-event-source-2.0.1/index.js';
|
||||
|
||||
window.fetchEventSource = fetchEventSource;
|
||||
</script>
|
||||
|
||||
<script src="/static/js/utils.js?v=1787991444"></script>
|
||||
<script src="/static/js/ui-ham.js?v=1787991444"></script>
|
||||
<script src="/static/js/geo.js?v=1787991444"></script>
|
||||
<script src="/static/js/common.js?v=1787991444"></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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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=1787991444"></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>
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
"src": "/static/img/icon-192-pwa.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "maskable"
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/static/img/icon-512-pwa.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "maskable"
|
||||
"purpose": "maskable any"
|
||||
}
|
||||
],
|
||||
"url": "{{ baseurl }}"
|
||||
|
||||
+2
-2
@@ -113,8 +113,8 @@
|
||||
const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}";
|
||||
</script>
|
||||
|
||||
<script src="/static/js/spotsbandsandmap.js?v=1787991444"></script>
|
||||
<script src="/static/js/map.js?v=1787991444"></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>
|
||||
|
||||
@@ -113,8 +113,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/static/js/spotsbandsandmap.js?v=1787991444"></script>
|
||||
<script src="/static/js/spots.js?v=1787991444"></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>
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/status.js?v=1787991444"></script>
|
||||
<script src="/static/js/status.js?v=1788600259"></script>
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
$("#nav-link-status").addClass("active");
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<div id="timing-container">Loading...</div>
|
||||
Reference in New Issue
Block a user