Compare commits

..
51 Commits
Author SHA1 Message Date
Ian Renton 29eea1edc0 Telnet fixes and banner 2026-09-12 08:40:50 +01:00
Ian Renton 7c458a8c5b Extract webserver metrics into a separate class to avoid passing it into every API call. Change the display to requests per hour rather than just last request time. Add SSE and telnet client connected count. 2026-09-11 22:32:04 +01:00
Ian Renton 4a09e46fe0 Improve asyncio usage in telnet server 2026-09-11 21:38:22 +01:00
Ian Renton e3df512b9e Add telnet server 2026-09-11 16:23:10 +01:00
Ian Renton ee45a15b4e Add telnet server 2026-09-11 16:18:09 +01:00
Ian Renton 5e56cd3b19 Add telnet server 2026-09-11 15:55:57 +01:00
Ian Renton 04f5df5260 Alerts now displays a "Type" in the table not just the source. Closes #142 2026-09-11 14:43:33 +01:00
Ian Renton 8f8ff46426 country-files.com seems more reliable than Clublog at locating some calls, trying it out as higher priority 2026-09-11 09:47:53 +01:00
Ian Renton 2e51d5fdb2 Doc updates 2026-09-11 08:36:49 +01:00
Ian Renton 3bab3cb784 Add EME SIG and do some refactoring 2026-09-11 08:33:00 +01:00
Ian Renton a4245ed5ce Support Hamsat alerts and use an AMSAT SIG for satellite spots 2026-09-11 08:18:34 +01:00
Ian Renton 64bb8ef3eb Fix a potential NPE and a case where callsign location source could be reported as None instead of DXCC. 2026-09-11 07:32:15 +01:00
Ian Renton 3b575de36a Fix SSE timeout 2026-09-10 17:58:02 +01:00
Ian Renton 94dc9044c4 Add support for WA7BNM Contest Calendar. Closes #106 2026-09-10 16:45:40 +01:00
Ian Renton be625c40a4 Add "fields" query parameter to spots and alerts API calls. Closes #140 2026-09-10 07:54:34 +01:00
Ian Renton 0fcc459cc4 Fix some dodgy handling of DME references 2026-09-05 10:24:18 +01:00
Ian Renton e52790f07d Alerts page layout fixes 2026-09-05 10:19:53 +01:00
Ian Renton 237cdaa091 Fix icon and ref_type disappearing from SIG Ref lookups 2026-09-05 10:14:37 +01:00
Ian Renton df724409a5 Fix some dodgy handling of DME references 2026-09-05 09:57:05 +01:00
Ian Renton 3e4327ec0c Add icons back to spots & alerts 2026-09-05 09:48:56 +01:00
Ian Renton d6e53c14e9 Support RSGB contests 2026-09-05 09:00:32 +01:00
Ian Renton d3d1d20821 Support DMVE, DMUE, DCE, DEFE. Closes #137 2026-09-04 19:35:48 +01:00
Ian Renton ab86bdc4d6 Improve error handling and PWA manifest 2026-09-04 17:50:35 +01:00
Ian Renton d7202f208c Allow ACS as well as AS for aircraft scatter 2026-09-04 17:38:46 +01:00
Ian Renton 9d117a6069 Split out event type SIGs, and do other enum conversions. Closes #138 2026-09-02 09:38:05 +01:00
Ian Renton 606cf1be68 Start converting some enum-like strings to proper enums, plus global reformat 2026-09-02 08:37:13 +01:00
Ian Renton 5a6fc19ab5 dxstats.py now uses is_ham_hf flag on bands list instead of providing its own. Closes #139 2026-09-02 07:46:02 +01:00
Ian Renton 9258d680fc Overnight bug fixes 2026-09-02 07:40:57 +01:00
ian 8189be699e Consider 6m a de facto HF band 2026-09-02 06:33:13 +00:00
Ian Renton fa2e4b929a Start converting some enum-like strings to proper enums 2026-09-02 07:26:02 +01:00
Ian Renton 55eaa871e9 Remove accidentally committed debug code 2026-08-31 21:54:43 +01:00
Ian Renton 1b62d4742b Support PGA. Closes #135 2026-08-31 21:54:13 +01:00
Ian Renton b0caf697ae Support COTA. Closes #131 2026-08-31 21:17:55 +01:00
Ian Renton a33f02491d Avoid polling GIRO stations that don't return any data 2026-08-31 20:54:21 +01:00
Ian Renton 892827f1f8 Allow some SIG refs to be picked up from comments without separate SIG name. Closes #134 2026-08-31 20:49:15 +01:00
Ian Renton e4738b2c7d Split SIGs list into Worldwide, Regional and Other. Closes #133 2026-08-31 18:38:19 +01:00
Ian Renton f42fd237b1 Move icons back into SIG data 2026-08-31 17:57:04 +01:00
Ian Renton d83a979e46 Move icons back into SIG data 2026-08-31 17:31:13 +01:00
Ian Renton 12e9b6a4ca Remove distinction between DV modes and LSB/USB 2026-08-31 11:17:05 +01:00
Ian Renton 8345238d76 Tweak FEA reference handling, add understanding of BIWOTA, remove some unused junk from ui-ham.js 2026-08-31 11:05:53 +01:00
Ian Renton bc326388ac Add support for FEA 2026-08-31 09:39:05 +01:00
Ian Renton df684a601d Fix an exception on shutdown 2026-08-31 09:00:23 +01:00
Ian Renton 9083470482 Add support for DTMBA 2026-08-31 08:59:49 +01:00
Ian Renton ba56acd7ac Fix a bug where solar conditions and status data were not properly persisted to disk between restarts. Release v2.0.5 2026-08-29 09:17:24 +01:00
Ian Renton 8451ca2727 Release v2.0.4 2026-08-29 08:40:11 +01:00
Ian Renton 7ae77e9fb2 Encourage cluster spot sources to simply be named "Cluster" as the specific node shouldn't matter 2026-08-29 08:37:21 +01:00
Ian Renton c3a360e691 Consistency about whether "GMA Islands" is plural or not; widen columns on SIG list so it doesn't wrap 2026-08-29 08:34:04 +01:00
Ian Renton 2a4190ec4a Fix GIRO data source. Release v2.0.3 2026-08-28 21:47:25 +01:00
Ian Renton c71c0e9d75 Release v2.0.2 2026-08-28 16:10:02 +01:00
Ian Renton f2ef6e93fa Merge remote-tracking branch 'origin/main'
# Conflicts:
#	core/constants.py
#	pyproject.toml
#	templates/add_spot.html
#	templates/alerts.html
#	templates/bands.html
#	templates/base.html
#	templates/conditions.html
#	templates/map.html
#	templates/spots.html
#	templates/status.html
2026-08-28 15:46:24 +01:00
Ian Renton d63897f8ad Provide CartoDB API key 2026-08-28 15:43:52 +01:00
124 changed files with 2332 additions and 831 deletions
+7
View File
@@ -65,6 +65,13 @@
</list> </list>
</option> </option>
</inspection_tool> </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"> <inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
<option name="processCode" value="true" /> <option name="processCode" value="true" />
<option name="processLiterals" value="true" /> <option name="processLiterals" value="true" />
+1 -1
View File
@@ -2,7 +2,7 @@
<project version="4"> <project version="4">
<component name="ProjectModuleManager"> <component name="ProjectModuleManager">
<modules> <modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Spothole.iml" filepath="$PROJECT_DIR$/.idea/Spothole.iml" /> <module fileurl="file://$PROJECT_DIR$/.idea/spothole.iml" filepath="$PROJECT_DIR$/.idea/spothole.iml" />
</modules> </modules>
</component> </component>
</project> </project>
+1 -1
View File
@@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run" type="PythonConfigurationType" factoryName="Python"> <configuration default="false" name="Run" type="PythonConfigurationType" factoryName="Python">
<module name="Spothole" /> <module name="spothole" />
<option name="ENV_FILES" value="" /> <option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" /> <option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" /> <option name="PARENT_ENVS" value="true" />
View File
+1
View File
@@ -4,5 +4,6 @@ WORKDIR /app
COPY . . COPY . .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 8080 EXPOSE 8080
EXPOSE 7373
CMD ["python3", "spothole.py"] CMD ["python3", "spothole.py"]
+3 -5
View File
@@ -1,7 +1,7 @@
# ![Spothole](/static/img/logo.png) # ![Spothole](/static/img/logo.png)
Spothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open Spothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open
JSON API as well as a website to browse the data. JSON API as well as a website to browse the data, and its own telnet server for integration with desktop loggers.
![Screenshot](/images/screenshot.png) ![Screenshot](/images/screenshot.png)
@@ -17,10 +17,8 @@ Spothole itself is also open source, Public Domain licenced code that anyone can
Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA,
SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, LLOTA, WWTOTA, Tiles on the Air, the UK Packet SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, LLOTA, WWTOTA, Tiles on the Air, the UK Packet
Repeater Network, NG3K, and any site based on the xOTA software by nischu. Repeater Network, NG3K, and any site based on the xOTA software by nischu. It also integrates with QRZ.com and HamQTH,
retrieves solar data from various sources, provides information about upcoming contests, and more.
Additional Special Interest Groups (SIGs) without their own specific data source include KRMNPA, SANPCPA, WAB, WAI and
DME.
![Screenshot](/images/screenshot2.png) ![Screenshot](/images/screenshot2.png)
+56 -9
View File
@@ -17,6 +17,15 @@ api_only_mode: false
# The base URL at which the software runs. # The base URL at which the software runs.
base_url: "http://localhost:8080" base_url: "http://localhost:8080"
# Whether to run a telnet spot server as well as the web interface
telnet_server_enabled: false
# Telnet server address. This is not really needed by the software itself, just displayed in documentation.
telnet_server_address: "localhost"
# Port to run the telnet server on
telnet_server_port: 7373
# Spot providers to use. This is an example set, tailor it to your liking by commenting and uncommenting. # Spot providers to use. This is an example set, tailor it to your liking by commenting and uncommenting.
# RBN and APRS-IS are supported but have such a high data rate, you probably don't want them enabled. # RBN and APRS-IS are supported but have such a high data rate, you probably don't want them enabled.
# Each provider needs a class and an enabled/disabled state. Some require more config such as hostnames/IP # Each provider needs a class and an enabled/disabled state. Some require more config such as hostnames/IP
@@ -68,7 +77,7 @@ spot_providers:
- class: "DXCluster" - class: "DXCluster"
# Clusters have a "name" property in case you want to say which cluster node the data is from, or connect to # Clusters have a "name" property in case you want to say which cluster node the data is from, or connect to
# several clusters and name them separately. The name will be used as the "source" field for the spots received. # several clusters and name them separately. The name will be used as the "source" field for the spots received.
name: "HRD Cluster" name: "Cluster"
enabled: true enabled: true
host: "hrd.wa9pie.net" host: "hrd.wa9pie.net"
port: 8000 port: 8000
@@ -84,7 +93,7 @@ spot_providers:
allow_rbn_spots: false allow_rbn_spots: false
- class: "DXCluster" - class: "DXCluster"
name: "W3LPL Cluster" name: "Cluster"
enabled: false enabled: false
host: "w3lpl.net" host: "w3lpl.net"
port: 7373 port: 7373
@@ -162,9 +171,21 @@ alert_providers:
- class: "BOTA" - class: "BOTA"
enabled: true enabled: true
- class: "Hamsat"
enabled: true
- class: "NG3K" - class: "NG3K"
enabled: true enabled: true
- class: "WA7BNM"
enabled: true
- class: "RSGBHFContests"
enabled: true
- class: "RSGBVHFContests"
enabled: true
# Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band # Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band
# conditions, etc.) and make it available via the /api/v2/solar endpoint. # conditions, etc.) and make it available via the /api/v2/solar endpoint.
@@ -246,12 +267,36 @@ sig_ref_data_providers:
- class: "DME" - class: "DME"
enabled: true enabled: true
- class: "FEA"
enabled: true
- class: "DMVE"
enabled: true
- class: "DMUE"
enabled: true
- class: "DCE"
enabled: true
- class: "DEFE"
enabled: true
- class: "KRMNPA" - class: "KRMNPA"
enabled: true enabled: true
- class: "SANPCPA" - class: "SANPCPA"
enabled: true enabled: true
- class: "DTMBA"
enabled: true
- class: "COTA"
enabled: true
- class: "PGA"
enabled: true
- class: "Toilets" - class: "Toilets"
enabled: true enabled: true
@@ -275,27 +320,27 @@ callsign_data_providers:
priority: 2 priority: 2
# No server-side credentials for HamQTH. Users must provide their own. # No server-side credentials for HamQTH. Users must provide their own.
- class: "CountryFiles"
priority: 3
enabled: true
- class: "ClublogAPI" - class: "ClublogAPI"
# Querying the Clublog API directly doesn't provide any more data than the XML version, it just provides slightly # Querying the Clublog API directly doesn't provide any more data than the XML version, it just provides slightly
# more up-to-date information in the rare case that the prefix data changes, at a significant cost of looking up # more up-to-date information in the rare case that the prefix data changes, at a significant cost of looking up
# every callsign via an API call. Normally left disabled but it exists as an option. # every callsign via an API call. Normally left disabled but it exists as an option.
enabled: false enabled: false
priority: 3 priority: 4
# API key for Clublog to look up information. Required in order to enable this provider. Unlike QRZ and HamQTH, # API key for Clublog to look up information. Required in order to enable this provider. Unlike QRZ and HamQTH,
# Clublog uses an API key issued to Spothole, not to the end user. # Clublog uses an API key issued to Spothole, not to the end user.
api_key: "" api_key: ""
- class: "ClublogXML" - class: "ClublogXML"
enabled: true enabled: true
priority: 4 priority: 5
# API key for Clublog to look up information. Required in order to enable this provider. You will need to request # API key for Clublog to look up information. Required in order to enable this provider. You will need to request
# one via their helpdesk portal if you want to use callsign lookups from Clublog. # one via their helpdesk portal if you want to use callsign lookups from Clublog.
api_key: "" api_key: ""
- class: "CountryFiles"
priority: 5
enabled: true
# Maximum time to keep spots and alerts in the system before deleting them. By default, one hour for spots and one week # Maximum time to keep spots and alerts in the system before deleting them. By default, one hour for spots and one week
# for alerts. # for alerts.
@@ -345,4 +390,6 @@ web_ui_options:
# (home) page, although being arbitrary HTML you can also use a div with absolute, relative, float placement etc. This # (home) page, although being arbitrary HTML you can also use a div with absolute, relative, float placement etc. This
# is designed for a "donate/support the server" type button, though you are free to do whatever you want with it. # is designed for a "donate/support the server" type button, though you are free to do whatever you want with it.
# As the server owner you are responsible for the safe usage of this option! # As the server owner you are responsible for the safe usage of this option!
support_button_html: "" support_button_html: ""
# CartoDB API key. Required to use the CartoDB layers without watermark. Get one from https://carto.com/basemaps/apikey/.
cartodb_api_key: ""
+3
View File
@@ -24,6 +24,9 @@ MAX_SPOT_AGE = config.get("max_spot_age_sec", 3600)
MAX_ALERT_AGE = config.get("max_alert_age_sec", 604800) MAX_ALERT_AGE = config.get("max_alert_age_sec", 604800)
SERVER_OWNER_CALLSIGN = config.get("server_owner_callsign", "N0CALL") SERVER_OWNER_CALLSIGN = config.get("server_owner_callsign", "N0CALL")
WEB_SERVER_PORT = config.get("web_server_port", 8080) WEB_SERVER_PORT = config.get("web_server_port", 8080)
TELNET_SERVER_ENABLED = config.get("telnet_server_enabled", False)
TELNET_SERVER_ADDRESS = config.get("telnet_server_address", "localhost")
TELNET_SERVER_PORT = config.get("telnet_server_port", 7373)
ALLOW_SPOTTING = config.get("allow_spotting", True) ALLOW_SPOTTING = config.get("allow_spotting", True)
ALLOW_UPSTREAM_SPOTTING = config.get("allow_upstream_spotting", True) ALLOW_UPSTREAM_SPOTTING = config.get("allow_upstream_spotting", True)
WEB_UI_OPTIONS = config.get("web_ui_options", {}) WEB_UI_OPTIONS = config.get("web_ui_options", {})
+243 -65
View File
@@ -1,9 +1,10 @@
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from core.enums import SIGRefType, SIGType
from data.band import Band from data.band import Band
from data.sig import SIG from data.sig import SIG
# General software # General software
SOFTWARE_VERSION = "2.0.1" SOFTWARE_VERSION = "2.1-pre"
# HTTP headers used for spot providers that use HTTP # HTTP headers used for spot providers that use HTTP
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"} HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
@@ -11,203 +12,382 @@ HAMQTH_PRG = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}"
# Special Interest Groups # Special Interest Groups
SIGS = [ SIGS = [
SIG(
name="AMSAT",
comment_names=[],
description="Amateur Radio Satellites",
sig_type=SIGType.WORLDWIDE,
icon="fa-satellite",
refs_globally_unique=False,
),
SIG(
name="EME",
comment_names=[],
description="Moonbounce",
sig_type=SIGType.WORLDWIDE,
icon="fa-moon",
refs_globally_unique=False,
),
SIG( SIG(
name="POTA", name="POTA",
comment_names=["POTA"], comment_names=["POTA"],
description="Parks on the Air", description="Parks on the Air",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.PARK,
ref_regex=r"[A-Z]{2}\-\d{4,5}|K\-TEST", ref_regex=r"[A-Z]{2}\-\d{4,5}|K\-TEST",
icon="fa-tree",
refs_globally_unique=False,
), ),
SIG( SIG(
name="SOTA", name="SOTA",
comment_names=["SOTA"], comment_names=["SOTA"],
description="Summits on the Air", 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}", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
icon="fa-mountain-sun",
refs_globally_unique=False,
), ),
SIG( SIG(
name="WWFF", name="WWFF",
comment_names=["WWFF"], comment_names=["WWFF"],
description="World Wide Flora & Fauna", description="World Wide Flora & Fauna",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.PARK,
ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}",
icon="fa-seedling",
refs_globally_unique=True,
), ),
SIG( SIG(
name="GMA", name="GMA",
comment_names=["GMA"], comment_names=["GMA"],
description="Global Mountain Activity", 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}", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
icon="fa-person-hiking",
refs_globally_unique=False,
), ),
SIG( SIG(
name="WWBOTA", name="WWBOTA",
comment_names=["WWBOTA", "BOTA"], comment_names=["WWBOTA", "BOTA"],
description="Worldwide Bunkers on the Air", 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}", ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}",
icon="fa-radiation",
refs_globally_unique=True,
), ),
SIG( SIG(
name="HEMA", name="HEMA",
comment_names=["HEMA"], comment_names=["HEMA"],
description="HuMPs Excluding Marilyns Award", 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}", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}",
icon="fa-mound",
refs_globally_unique=False,
), ),
SIG( SIG(
name="IOTA", name="IOTA",
comment_names=["IOTA"], comment_names=["IOTA"],
description="Islands on the Air", description="Islands on the Air",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.ISLAND,
ref_regex=r"[A-Z]{2}\-\d{3}", ref_regex=r"[A-Z]{2}\-\d{3}",
icon="fa-book-atlas",
refs_globally_unique=False,
), ),
SIG( SIG(
name="GMA Island", name="GMA Islands",
comment_names=[], comment_names=[],
description="Global Mountain Activity - Islands", 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}))", ref_regex=r"(([A-Z]{2}\-\d{3})|([A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}))",
), icon="fa-person-hiking",
SIG( refs_globally_unique=False,
name="MOTA",
comment_names=["MOTA"],
description="Mills on the Air",
ref_regex=r"X\d{4,6}",
), ),
SIG( SIG(
name="ARLHS", name="ARLHS",
comment_names=["ARLHS"], comment_names=["ARLHS"],
description="Amateur Radio Lighthouse Society", description="Amateur Radio Lighthouse Society",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.LIGHTHOUSE,
ref_regex=r"[A-Z]{3}[\- ]\d{3,4}", ref_regex=r"[A-Z]{3}[\- ]\d{3,4}",
icon="fa-house-flood-water",
refs_globally_unique=False,
), ),
SIG( SIG(
name="ILLW", name="ILLW",
comment_names=["ILLW"], comment_names=["ILLW"],
description="International Lighthouse & Lightship Weekend", description="International Lighthouse & Lightship Weekend",
sig_type=SIGType.EVENT,
ref_type=SIGRefType.LIGHTHOUSE,
ref_regex=r"[A-Z]{2}\d{4}", 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( SIG(
name="SIOTA", name="SIOTA",
comment_names=["SIOTA"], comment_names=["SIOTA"],
description="Silos on the Air", description="Silos on the Air",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.SILO,
ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d",
icon="fa-wheat-awn",
refs_globally_unique=False,
), ),
SIG( SIG(
name="WCA", name="WCA",
comment_names=["WCA"], comment_names=["WCA"],
description="World Castles Award", description="World Castles Award",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.CASTLE,
ref_regex=r"[A-Z0-9]{1,3}\-\d{5}", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}",
icon="fa-chess-rook",
refs_globally_unique=False,
), ),
SIG( SIG(
name="ZLOTA", name="ZLOTA",
comment_names=["ZLOTA"], comment_names=["ZLOTA"],
description="New Zealand on the Air", 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}", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}",
icon="fa-kiwi-bird",
region_flag="🇳🇿",
refs_globally_unique=True,
), ),
SIG( SIG(
name="WOTA", name="WOTA",
comment_names=["WOTA"], comment_names=["WOTA"],
description="Wainwrights on the Air", description="Wainwrights on the Air",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.SUMMIT,
ref_regex=r"[A-Z]{3}-[0-9]{2}", 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( SIG(
name="KRMNPA", name="KRMNPA",
comment_names=["KRMNPA"], comment_names=["KRMNPA"],
description="Keith Roget Memorial National Parks Award", description="Keith Roget Memorial National Parks Award",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.PARK,
ref_regex=r"VKFF\-\d{4}", ref_regex=r"VKFF\-\d{4}",
icon="fa-earth-oceania",
region_flag="🇦🇺",
refs_globally_unique=False,
), ),
SIG( SIG(
name="SANPCPA", name="SANPCPA",
comment_names=["SANPCPA"], comment_names=["SANPCPA"],
description="South Australian National Parks and Conservation Parks Award", description="South Australian National Parks and Conservation Parks Award",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.PARK,
ref_regex=r"VKFF\-\d{4}", ref_regex=r"VKFF\-\d{4}",
icon="fa-earth-oceania",
region_flag="🇦🇺",
refs_globally_unique=False,
), ),
SIG( SIG(
name="LLOTA", name="LLOTA",
comment_names=["LLOTA"], comment_names=["LLOTA"],
description="Lagos y Lagunas on the Air", description="Lagos y Lagunas on the Air",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.LAKE,
ref_regex=r"LL[A-Z]{2}\-\d{4}", ref_regex=r"LL[A-Z]{2}\-\d{4}",
icon="fa-water",
refs_globally_unique=True,
), ),
SIG( SIG(
name="Towers", name="Towers",
comment_names=["TOTA"], comment_names=["TOTA"],
description="Towers on the Air", description="Towers on the Air",
sig_type=SIGType.WORLDWIDE,
ref_type=SIGRefType.TOWER,
ref_regex=r"[A-Z]{2,3}R\-\d{4}", ref_regex=r"[A-Z]{2,3}R\-\d{4}",
icon="fa-tower-observation",
refs_globally_unique=False,
), ),
SIG( SIG(
name="Tiles", name="Tiles",
comment_names=[], comment_names=[],
description="Tiles on the Air", 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}", ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}",
icon="fa-square",
refs_globally_unique=False,
), ),
SIG( SIG(
name="WAB", name="WAB",
comment_names=["WAB"], comment_names=["WAB"],
description="Worked All Britain", description="Worked All Britain",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.GRID,
ref_regex=r"[A-Z]{1,2}[0-9]{2}", ref_regex=r"[A-Z]{1,2}[0-9]{2}",
icon="fa-table-cells-large",
region_flag="🇬🇧",
refs_globally_unique=False,
), ),
SIG( SIG(
name="WAI", name="WAI",
comment_names=["WAI"], comment_names=["WAI"],
description="Worked All Ireland", description="Worked All Ireland",
sig_type=SIGType.REGIONAL,
ref_type=SIGRefType.GRID,
ref_regex=r"[A-Z][0-9]{2}", ref_regex=r"[A-Z][0-9]{2}",
icon="fa-table-cells-large",
region_flag="🇮🇪",
refs_globally_unique=False,
), ),
SIG( SIG(
name="DME", name="DME",
comment_names=["DME"], comment_names=["DME"],
description="Diplomas de Municipios Españoles", description="Diploma Municipios de España",
ref_regex=r"\d{4,5}", 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( SIG(
name="Toilets", name="Toilets",
comment_names=[], comment_names=[],
description="Toilets on the Air", description="Toilets on the Air",
sig_type=SIGType.EVENT,
ref_type=SIGRefType.TOILET,
ref_regex=r"T\-[0-9]{2}", 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 # Band definitions
BANDS = [ BANDS = [
Band(name="2200m", start_freq=135700, end_freq=137800), Band(name="2200m", start_freq=135700, end_freq=137800),
@@ -223,7 +403,7 @@ BANDS = [
Band(name="12m", start_freq=24890000, end_freq=24990000, is_ham_hf=True), Band(name="12m", start_freq=24890000, end_freq=24990000, is_ham_hf=True),
Band(name="11m", start_freq=26965000, end_freq=27405000), Band(name="11m", start_freq=26965000, end_freq=27405000),
Band(name="10m", start_freq=28000000, end_freq=29700000, is_ham_hf=True), 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="5m", start_freq=56000000, end_freq=60500000),
Band(name="4m", start_freq=70000000, end_freq=70500000), Band(name="4m", start_freq=70000000, end_freq=70500000),
Band(name="2m", start_freq=144000000, end_freq=148000000), Band(name="2m", start_freq=144000000, end_freq=148000000),
@@ -239,9 +419,6 @@ BANDS = [
] ]
UNKNOWN_BAND = Band(name="Unknown", start_freq=0, end_freq=0) 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 # 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 # of these anywhere, but here are some I've seen or seen reference to
PROPAGATION_MODES = { PROPAGATION_MODES = {
@@ -254,5 +431,6 @@ PROPAGATION_MODES = {
"MS": "Meteor scatter", "MS": "Meteor scatter",
"RS": "Rain scatter", "RS": "Rain scatter",
"AS": "Aircraft scatter", "AS": "Aircraft scatter",
"ACS": "Aircraft scatter",
"SAT": "Satellite", "SAT": "Satellite",
} }
+10 -19
View File
@@ -6,6 +6,7 @@ import diskcache
from core.config import MAX_ALERT_AGE, MAX_SPOT_AGE from core.config import MAX_ALERT_AGE, MAX_SPOT_AGE
from core.live_data_cache import LiveDataCache from core.live_data_cache import LiveDataCache
from core.single_object_data_cache import SingleObjectDataCache
from data.solar_conditions import SolarConditions from data.solar_conditions import SolarConditions
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -34,10 +35,8 @@ class DataStore:
self.dxcc_data = None self.dxcc_data = None
self.dxcc_lookup_by_call_regex = [] self.dxcc_lookup_by_call_regex = []
self.sigrefs = None self.sigrefs = None
self.status_data = {} self.status = None
self._status = None self.solar_conditions = None
self.solar_conditions = {}
self._solar = None
# ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be # ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be
# caches, they can just be straight objects # caches, they can just be straight objects
self.cq_zone_data = None self.cq_zone_data = None
@@ -46,16 +45,11 @@ class DataStore:
def setup(self): def setup(self):
Path(CACHE_DIR).mkdir(parents=True, exist_ok=True) Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
# Standard disk cache for solar data and status data, but each cache contains only a single object which we # For solar data and status data, we use a wrapper around disk cache where each cache contains only a single
# expose to the wider application # object exposed to the wider application, and provides a store() method for callers to notify diskcache that
self._solar = diskcache.Cache(f"{CACHE_DIR}solar") # the object has changed and needs to be re-cached.
if "solar_conditions" not in self._solar: self.solar_conditions = SingleObjectDataCache(f"{CACHE_DIR}solar", SolarConditions())
self._solar.add("solar_conditions", SolarConditions()) self.status = SingleObjectDataCache(f"{CACHE_DIR}status", {})
self.solar_conditions = self._solar.get("solar_conditions")
self._status = diskcache.Cache(f"{CACHE_DIR}status")
if "status_data" not in self._status:
self._status.add("status_data", {})
self.status_data = self._status.get("status_data")
# Standard disk cache for static reference and SIG ref data. Separate provider threads will repopulate these on # Standard disk cache for static reference and SIG ref data. Separate provider threads will repopulate these on
# a regular basis but there's no need for a TTL since old data is better than no data. # a regular basis but there's no need for a TTL since old data is better than no data.
@@ -116,13 +110,10 @@ class DataStore:
self.dxcc_lookup_by_call_regex.append((re.compile(entry["prefixRegex"]), entry["entityCode"])) self.dxcc_lookup_by_call_regex.append((re.compile(entry["prefixRegex"]), entry["entityCode"]))
def close(self): def close(self):
self.spots.save_snapshot()
self.alerts.save_snapshot()
self.spots.close() self.spots.close()
self.alerts.close() self.alerts.close()
self._solar.close() self.solar_conditions.close()
self._status.close() self.status.close()
self.dxcc_data.close() self.dxcc_data.close()
self.sigrefs.close() self.sigrefs.close()
self.callsign_data_countryfiles.close() self.callsign_data_countryfiles.close()
+154
View File
@@ -0,0 +1,154 @@
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"
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"
class LocationSourceForCallsign(str, Enum):
"""Where the location data came from in a callsign data object."""
HOME_QTH = "HOME QTH"
DXCC = "DXCC"
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"
SATELLITE = "SATELLITE"
DXPEDITION = "DXPEDITION"
CONTEST = "CONTEST"
class SIGType(str, Enum):
"""Type of a Special Interest Group. Used to group them in the web UI."""
WORLDWIDE = "WORLDWIDE"
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",
}
+47 -23
View File
@@ -1,46 +1,70 @@
import logging import logging
import re
from pyhamtools.locator import latlong_to_locator, locator_to_latlong from pyhamtools.locator import latlong_to_locator, locator_to_latlong
from core.constants import SIGS
from core.data_store import DATA_STORE from core.data_store import DATA_STORE
from core.enums import SIGRefType
from core.geo_utils import wab_wai_square_to_lat_lon from core.geo_utils import wab_wai_square_to_lat_lon
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
logger = logging.getLogger(__name__) 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 """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 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 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.""" 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.") logger.debug("Failed to look up sig_ref info, sig or ref were not set.")
return None 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: try:
### FUDGES ### ### FUDGES ###
# #
# DME fudge. Our database has leading zeros padding to 5 digits which is the expected format, but not all # DME fudge. Our database has leading zeros padding to 5 digits which is the expected format, but not all
# activators add leading zeros. # activators add leading zeros. We also need to normalise "DME 01234" to "DME-01234" to match what's in our
if sig.upper() == "DME": # database.
ref_id = ref_id.zfill(5) if sig_name.upper() == "DME":
match = re.match(r"DME[\- ](\d{3,5})", ref_id, re.IGNORECASE)
if match:
number = match.group(1)
ref_id = f"DME-{number.zfill(5)}"
# If the SIG is HEMA, we have no current lookup for this so just skip the lookup here. # DTMBA spotters sometimes include spaces and dashes, our regex allows them but they must be removed here so we
if sig.upper() == "HEMA": # can look up against the official list which doesn't have them
sig_ref.ref_type = "Summit" 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 return sig_ref
### PROGRAMMATIC DATA GENERATION INSTEAD OF LOOKUPS ### ### 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 # 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. # 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 # 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: if not sig_ref.name:
sig_ref.name = sig_ref.id sig_ref.name = sig_ref.id
if not sig_ref.grid: if not sig_ref.grid:
@@ -51,8 +75,7 @@ def get_sig_ref_info(sig, ref_id):
sig_ref.longitude = ll[1] sig_ref.longitude = ll[1]
return sig_ref return sig_ref
elif sig.upper() == "WAB" or sig.upper() == "WAI": elif sig_name.upper() == "WAB" or sig_name.upper() == "WAI":
sig_ref.ref_type = "Grid"
ll = wab_wai_square_to_lat_lon(ref_id) ll = wab_wai_square_to_lat_lon(ref_id)
if ll: if ll:
sig_ref.name = ref_id sig_ref.name = ref_id
@@ -64,16 +87,15 @@ def get_sig_ref_info(sig, ref_id):
logger.warning("Invalid lat/lon received for WAB/WAI reference") logger.warning("Invalid lat/lon received for WAB/WAI reference")
return sig_ref 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 # 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: if not sig_ref.name:
sig_ref.name = sig_ref.id sig_ref.name = sig_ref.id
if sig_ref.name: if sig_ref.name:
sig_ref.url = f"https://www.beachesontheair.com/beaches/{sig_ref.name.lower().replace(' ', '-')}" sig_ref.url = f"https://www.beachesontheair.com/beaches/{sig_ref.name.lower().replace(' ', '-')}"
return sig_ref 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 # 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. # the best result.
iota_lookup = get_sig_ref_info("IOTA", ref_id) iota_lookup = get_sig_ref_info("IOTA", ref_id)
@@ -84,25 +106,27 @@ def get_sig_ref_info(sig, ref_id):
for key, value in gma_lookup.__dict__.items(): for key, value in gma_lookup.__dict__.items():
if value is not None and sig_ref.__dict__.get(key) is None: if value is not None and sig_ref.__dict__.get(key) is None:
sig_ref.__dict__[key] = value sig_ref.__dict__[key] = value
sig_ref.ref_type = "Island" sig_ref.ref_type = SIGRefType.ISLAND
return sig_ref return sig_ref
### ACTUAL LOOKUP ### ### ACTUAL LOOKUP ###
# #
# OK, this is something we have to look up. Now check to see if our data store contains reference data and use # OK, this is something we have to look up. Now check to see if our data store contains reference data and if
# that. # so, copy the data into the sig_ref object
key = f"{sig}:{ref_id}" key = f"{sig_name}:{ref_id}"
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
if lookup_data: 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: else:
# Maybe a super new reference we don't know about yet, but more likely a typo or a test reference, # Maybe a super new reference we don't know about yet, but more likely a typo or a test reference,
# just silently ignore it. # 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: 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 return sig_ref
+39
View File
@@ -0,0 +1,39 @@
import logging
import threading
import diskcache
logger = logging.getLogger(__name__)
class SingleObjectDataCache:
"""Cache for status and solar conditions. This uses DiskCache, but unlike the standard DiskCache users like SIG and
callsign lookup handlers, status and solar conditions are persisted as a single object. If we just load the object
from DiskCache and modify it, DiskCache doesn't know that it's been updated and needs re-caching, so we provide a
store() method that any functions updating the object can call afterwards."""
def __init__(self, cache_dir, object_if_empty):
"""Initialize a SingleObjectDataCache. Provide the directory to load the cache from and save it to. If the cache
is empty, the provided object_if_empty parameter will be used to initialise it."""
self._lock = threading.Lock()
self._cache = diskcache.Cache(cache_dir)
# This cache stores a single object, doesn't matter what it's called so "object" will do
if "object" not in self._cache:
self._cache.add("object", object_if_empty)
self._obj = self._cache.get("object")
def get(self):
"""Get the data object. This can then be manipulated as necessary across multiple threads. Any function
modifying the object must remember to call store() afterwards."""
return self._obj
def store(self):
"""Store the updated object in the cache. Any function modifying the object must remember to call this
afterwards."""
with self._lock:
self._cache.set("object", self._obj)
def close(self):
self.store()
self._cache.close()
+25 -28
View File
@@ -11,7 +11,8 @@ from core.constants import SOFTWARE_VERSION
from core.data_providers import DATA_PROVIDERS from core.data_providers import DATA_PROVIDERS
from core.data_store import DATA_STORE from core.data_store import DATA_STORE
from core.prometheus_metrics_handler import alerts_gauge, memory_use_gauge, spots_gauge from core.prometheus_metrics_handler import alerts_gauge, memory_use_gauge, spots_gauge
from server.webserver import WEB_SERVER from telnetserver.telnetserver import TELNET_SERVER
from webserver.webserver import WEB_SERVER
class StatusReporter: class StatusReporter:
@@ -25,8 +26,9 @@ class StatusReporter:
self._stop_event = Event() self._stop_event = Event()
self._startup_time = datetime.now(pytz.UTC) self._startup_time = datetime.now(pytz.UTC)
DATA_STORE.status_data["software_version"] = SOFTWARE_VERSION DATA_STORE.status.get()["software_version"] = SOFTWARE_VERSION
DATA_STORE.status_data["server_owner_callsign"] = SERVER_OWNER_CALLSIGN DATA_STORE.status.get()["server_owner_callsign"] = SERVER_OWNER_CALLSIGN
DATA_STORE.status.store()
def start(self): def start(self):
"""Start the reporter thread""" """Start the reporter thread"""
@@ -50,11 +52,11 @@ class StatusReporter:
def _report(self): def _report(self):
"""Write status information""" """Write status information"""
DATA_STORE.status_data["uptime"] = (datetime.now(pytz.UTC) - self._startup_time).total_seconds() DATA_STORE.status.get()["uptime"] = (datetime.now(pytz.UTC) - self._startup_time).total_seconds()
DATA_STORE.status_data["mem_use_mb"] = round(psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024), 3) DATA_STORE.status.get()["mem_use_mb"] = round(psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024), 3)
DATA_STORE.status_data["num_spots"] = len(DATA_STORE.spots.values()) DATA_STORE.status.get()["num_spots"] = len(DATA_STORE.spots.values())
DATA_STORE.status_data["num_alerts"] = len(DATA_STORE.alerts.values()) DATA_STORE.status.get()["num_alerts"] = len(DATA_STORE.alerts.values())
DATA_STORE.status_data["spot_providers"] = [ DATA_STORE.status.get()["spot_providers"] = [
{ {
"name": p.name, "name": p.name,
"enabled": p.enabled, "enabled": p.enabled,
@@ -69,7 +71,7 @@ class StatusReporter:
} }
for p in DATA_PROVIDERS.spot_providers for p in DATA_PROVIDERS.spot_providers
] ]
DATA_STORE.status_data["alert_providers"] = [ DATA_STORE.status.get()["alert_providers"] = [
{ {
"name": p.name, "name": p.name,
"enabled": p.enabled, "enabled": p.enabled,
@@ -80,7 +82,7 @@ class StatusReporter:
} }
for p in DATA_PROVIDERS.alert_providers for p in DATA_PROVIDERS.alert_providers
] ]
DATA_STORE.status_data["solar_condition_providers"] = [ DATA_STORE.status.get()["solar_condition_providers"] = [
{ {
"name": p.name, "name": p.name,
"enabled": p.enabled, "enabled": p.enabled,
@@ -91,7 +93,7 @@ class StatusReporter:
} }
for p in DATA_PROVIDERS.solar_condition_providers for p in DATA_PROVIDERS.solar_condition_providers
] ]
DATA_STORE.status_data["static_data_providers"] = [ DATA_STORE.status.get()["static_data_providers"] = [
{ {
"name": p.name, "name": p.name,
"enabled": p.enabled, "enabled": p.enabled,
@@ -102,7 +104,7 @@ class StatusReporter:
} }
for p in DATA_PROVIDERS.static_data_providers for p in DATA_PROVIDERS.static_data_providers
] ]
DATA_STORE.status_data["sig_ref_data_providers"] = [ DATA_STORE.status.get()["sig_ref_data_providers"] = [
{ {
"sig_name": p.sig_name, "sig_name": p.sig_name,
"enabled": p.enabled, "enabled": p.enabled,
@@ -114,7 +116,7 @@ class StatusReporter:
} }
for p in DATA_PROVIDERS.sig_ref_data_providers for p in DATA_PROVIDERS.sig_ref_data_providers
] ]
DATA_STORE.status_data["callsign_data_providers"] = [ DATA_STORE.status.get()["callsign_data_providers"] = [
{ {
"name": p.name, "name": p.name,
"enabled": p.enabled, "enabled": p.enabled,
@@ -126,27 +128,22 @@ class StatusReporter:
} }
for p in DATA_PROVIDERS.callsign_data_providers for p in DATA_PROVIDERS.callsign_data_providers
] ]
DATA_STORE.status_data["cleanup"] = { DATA_STORE.status.get()["cleanup"] = {
"status": CLEANUP_TIMER.status, "status": CLEANUP_TIMER.status,
"last_ran": CLEANUP_TIMER.last_cleanup_time.replace(tzinfo=pytz.UTC).timestamp() "last_ran": CLEANUP_TIMER.last_cleanup_time.replace(tzinfo=pytz.UTC).timestamp()
if CLEANUP_TIMER.last_cleanup_time if CLEANUP_TIMER.last_cleanup_time
else 0, else 0,
} }
DATA_STORE.status_data["webserver"] = { DATA_STORE.status.get()["webserver"] = {
"status": WEB_SERVER.web_server_metrics["status"], "status": WEB_SERVER.web_server_metrics.status,
"last_api_access": WEB_SERVER.web_server_metrics["last_api_access_time"] "api_requests_per_hour": WEB_SERVER.web_server_metrics.api_requests_per_hour(),
.replace(tzinfo=pytz.UTC) "page_requests_per_hour": WEB_SERVER.web_server_metrics.page_requests_per_hour(),
.timestamp() "sse_client_count": WEB_SERVER.sse_client_count,
if WEB_SERVER.web_server_metrics["last_api_access_time"]
else 0,
"api_access_count": WEB_SERVER.web_server_metrics["api_access_counter"],
"last_page_access": WEB_SERVER.web_server_metrics["last_page_access_time"]
.replace(tzinfo=pytz.UTC)
.timestamp()
if WEB_SERVER.web_server_metrics["last_page_access_time"]
else 0,
"page_access_count": WEB_SERVER.web_server_metrics["page_access_counter"],
} }
DATA_STORE.status.get()["telnet"] = {
"client_count": TELNET_SERVER.client_count,
}
DATA_STORE.status.store()
# Update Prometheus metrics # Update Prometheus metrics
memory_use_gauge.set(psutil.Process(os.getpid()).memory_info().rss) memory_use_gauge.set(psutil.Process(os.getpid()).memory_info().rss)
+44 -29
View File
@@ -1,20 +1,14 @@
import logging import logging
import re
import simplejson import simplejson
from pyhamtools.frequency import freq_to_band from pyhamtools.frequency import freq_to_band
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.constants import ( from core.constants import BANDS, UNKNOWN_BAND
ALL_MODES,
BANDS,
CW_MODES,
DATA_MODES,
MODE_ALIASES,
PHONE_MODES,
UNKNOWN_BAND,
)
from core.data_store import DATA_STORE 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__) 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__) 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""" """Infer a mode from the comment"""
for mode in ALL_MODES: if not comment:
if mode in comment.upper(): return None
for mode in Mode:
if re.search(r"(^|\W)" + mode + r"($|\W)", comment, re.IGNORECASE):
return mode return mode
for mode in MODE_ALIASES: for alias in MODE_ALIASES:
if mode in comment.upper(): if re.search(r"(^|\W)" + alias + r"($|\W)", comment, re.IGNORECASE):
return MODE_ALIASES[mode] return Mode(MODE_ALIASES[alias])
return None return None
def infer_mode_type_from_mode(mode): def infer_mode_type_from_mode(mode: str) -> ModeType | None:
"""Infer a "mode family" from a mode.""" """Infer a "mode family" from a mode ."""
if mode.upper() in CW_MODES: if not mode:
return "CW" return None
elif mode.upper() in PHONE_MODES:
return "PHONE" if mode in MODE_ALIASES:
elif mode.upper() in DATA_MODES: mode = MODE_ALIASES[mode]
return "DATA"
else: 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 != "?": if mode.upper() != "OTHER" and mode != "?":
logger.warning(f"Found an unrecognised mode: {mode}. Developer should categorise this.") logger.warning(f"Found an unrecognised mode: {mode}. Developer should categorise this.")
return None return None
@@ -70,7 +76,7 @@ def infer_mode_from_frequency(freq):
mode = freq_to_band(khz)["mode"] mode = freq_to_band(khz)["mode"]
# Some additional common digimode ranges in addition to what the 3rd-party freq_to_band function returns. # 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. # 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 # 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. # large numbers of clearly-FT* spots off the list of people filtering out digimodes.
if ( if (
@@ -93,7 +99,16 @@ def infer_mode_from_frequency(freq):
or (28180 <= khz < 28183) or (28180 <= khz < 28183)
): ):
mode = "FT4" 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: except KeyError:
return None return None
@@ -115,7 +130,7 @@ def get_callsign_object_from_pyhamtools_callinfo(callsign, callinfo):
country = data.get("country", None) country = data.get("country", None)
dxcc_id = data.get("adif", 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) cq_zone = data.get("cqz", None)
itu_zone = data.get("ituz", None) itu_zone = data.get("ituz", None)
lat = float(data["latitude"]) if "latitude" in data else 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, latitude=lat,
longitude=lon, longitude=lon,
grid=grid, grid=grid,
location_source="DXCC", location_source=LocationSourceForCallsign.DXCC,
) )
except (KeyError, ValueError): except (KeyError, ValueError):
+48 -14
View File
@@ -1,12 +1,13 @@
import hashlib import hashlib
import json import json
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import datetime, timedelta from datetime import datetime, timedelta
import pytz import pytz
from core.call_lookup_helper import get_call_info from core.call_lookup_helper import get_call_info
from core.enums import AlertType, Continent
from core.sig_lookup_helper import populate_missing_sig_ref_info from core.sig_lookup_helper import populate_missing_sig_ref_info
from core.utils import get_flag_for_dxcc from core.utils import get_flag_for_dxcc
@@ -19,6 +20,9 @@ class Alert:
# Unique identifier for the alert # Unique identifier for the alert
id: str | None = None id: str | None = None
# DX (alerting) operator info
# Callsigns of the operators that has been alerted # Callsigns of the operators that has been alerted
dx_calls: list | None = None dx_calls: list | None = None
# Names of the operators that has been alerted # Names of the operators that has been alerted
@@ -28,13 +32,16 @@ class Alert:
# Country flag of the DX operator # Country flag of the DX operator
dx_flag: str | None = None dx_flag: str | None = None
# Continent of the DX operator # Continent of the DX operator
dx_continent: str | None = None dx_continent: Continent | None = None
# DXCC ID of the DX operator # DXCC ID of the DX operator
dx_dxcc_id: int | None = None dx_dxcc_id: int | None = None
# CQ zone of the DX operator # CQ zone of the DX operator
dx_cq_zone: int | None = None dx_cq_zone: int | None = None
# ITU zone of the DX operator # ITU zone of the DX operator
dx_itu_zone: int | None = None dx_itu_zone: int | None = None
# General alert info
# Intended frequencies & modes of operation. Essentially just a different kind of comment field. # Intended frequencies & modes of operation. Essentially just a different kind of comment field.
freqs_modes: str | None = None freqs_modes: str | None = None
# Start time of the activation, UTC seconds since UNIX epoch # Start time of the activation, UTC seconds since UNIX epoch
@@ -45,25 +52,41 @@ class Alert:
end_time: float | None = None end_time: float | None = None
# End time of the activation of the alert, ISO 8601 # End time of the activation of the alert, ISO 8601
end_time_iso: str | None = None 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 = field(default_factory=list)
# Timing info
# Time that this software received the alert, UTC seconds since UNIX epoch. This is used with the "since_received" # 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 # 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. # list time the client polled the API.
received_time: float | None = None received_time: float | None = None
# Time that this software received the alert, ISO 8601 # Time that this software received the alert, ISO 8601
received_time_iso: str | None = None received_time_iso: str | None = None
# Comment made by the alerter, if any
comment: str | None = None # Source 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
# Whether this alert is for a DXpedition, as opposed to e.g. an xOTA programme.
is_dxpedition: bool = False
# Where we got the alert from, e.g. "POTA", "SOTA"... # Where we got the alert from, e.g. "POTA", "SOTA"...
source: str | None = None source: str | None = None
# The ID the source gave it, if any. # The ID the source gave it, if any.
source_id: str | None = None 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): def infer_missing(self, credentials=None):
"""Infer missing parameters where possible""" """Infer missing parameters where possible"""
@@ -91,8 +114,8 @@ class Alert:
call_info = get_call_info(self.dx_calls[0], credentials) call_info = get_call_info(self.dx_calls[0], credentials)
if self.dx_calls and self.dx_calls[0] and not self.dx_country: if self.dx_calls and self.dx_calls[0] and not self.dx_country:
self.dx_country = call_info.country self.dx_country = call_info.country
if self.dx_calls and self.dx_calls[0] and not self.dx_continent: if self.dx_calls and self.dx_calls[0] and call_info.continent and not self.dx_continent:
self.dx_continent = call_info.continent self.dx_continent = Continent(call_info.continent)
if self.dx_calls and self.dx_calls[0] and not self.dx_cq_zone: if self.dx_calls and self.dx_calls[0] and not self.dx_cq_zone:
self.dx_cq_zone = call_info.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: if self.dx_calls and self.dx_calls[0] and not self.dx_itu_zone:
@@ -105,13 +128,13 @@ class Alert:
# Fetch SIG data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a reference # 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 # 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 # from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
if self.sig_refs and len(self.sig_refs) > 0: if self.sig_refs:
for sig_ref in self.sig_refs: for sig_ref in self.sig_refs:
populate_missing_sig_ref_info(sig_ref) populate_missing_sig_ref_info(sig_ref)
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG # 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. # and apply it to the whole spot.
if self.sig_refs and len(self.sig_refs) > 0 and self.sig_refs[0] and not self.sig: if self.sig_refs and self.sig_refs[0] and not self.sig:
self.sig = self.sig_refs[0].sig self.sig = self.sig_refs[0].sig
# Create an ID based on the source and source ID if possible, as these guaranee uniqueness. If there is no # Create an ID based on the source and source ID if possible, as these guaranee uniqueness. If there is no
@@ -129,6 +152,17 @@ class Alert:
if self.dx_calls and not self.dx_names: if self.dx_calls and not self.dx_names:
self.dx_names = [get_call_info(c, credentials).name for c in self.dx_calls] 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.alert_type == AlertType.CONTEST:
self.icon = "fa-satellite"
elif self.sig_refs and self.sig_refs[0].icon:
self.icon = self.sig_refs[0].icon
except Exception: except Exception:
logger.exception("Exception while inferring missing data from spot") logger.exception("Exception while inferring missing data from spot")
+5 -3
View File
@@ -1,5 +1,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from core.enums import Continent, LocationSourceForCallsign
@dataclass @dataclass
class Callsign: class Callsign:
@@ -28,15 +30,15 @@ class Callsign:
# Country in which the callsign indicates they are operating # Country in which the callsign indicates they are operating
country: str | None = None country: str | None = None
# Continent in which the callsign indicates they are operating # 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 in which the callsign indicates they are operating
dxcc_id: int | None = None dxcc_id: int | None = None
# CQ zone in which the callsign indicates they are operating # CQ zone in which the callsign indicates they are operating
cq_zone: int | None = None cq_zone: int | None = None
# ITU zone in which the callsign indicates they are operating # ITU zone in which the callsign indicates they are operating
itu_zone: int | None = None itu_zone: int | None = None
# Location source. This can be "HOME QTH" or "DXCC" depending on which provider gave us a location # Location source
location_source: str | None = None location_source: LocationSourceForCallsign | None = None
def fully_populated(self): def fully_populated(self):
"""Utility method to indicate that the callsign data is fully populated. Multiple providers can return data for """Utility method to indicate that the callsign data is fully populated. Multiple providers can return data for
+18
View File
@@ -1,5 +1,7 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from core.enums import SIGRefType, SIGType
@dataclass @dataclass
class SIG: class SIG:
@@ -15,7 +17,23 @@ class SIG:
name: str name: str
# Description, e.g. "Towers on the Air" # Description, e.g. "Towers on the Air"
description: str 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"] # SIG names as they might appear in cluster spot comments, e.g. ["TOTA"]
comment_names: list[str] = field(default_factory=list) 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+". # Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
ref_regex: str | None = None 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
+7 -3
View File
@@ -1,21 +1,25 @@
from dataclasses import dataclass from dataclasses import dataclass
from core.enums import SIGRefType
@dataclass @dataclass
class SIGRef: class SIGRef:
"""Data class that defines a Special Interest Group "info" or reference. As well as the basic reference ID we include a """Data class that defines a Special Interest Group "info" or reference. As well as the basic reference ID we include a
name and a lookup URL.""" name and a lookup URL."""
# Reference ID, e.g. "GB-0001".
id: str
# SIG that this reference is in, e.g. "POTA". # SIG that this reference is in, e.g. "POTA".
sig: str sig: str
# Reference ID, e.g. "GB-0001".
id: str | None = None
# Name of the reference, e.g. "Null Country Park", if known. # Name of the reference, e.g. "Null Country Park", if known.
name: str | None = None name: str | None = None
# Type of the reference, e.g. "Park", if known. # Type of the reference, e.g. "Park", if known.
ref_type: str | None = None ref_type: SIGRefType | None = None
# URL to look up more information about the reference, if known. # URL to look up more information about the reference, if known.
url: str | None = None url: str | None = None
# 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 of the reference, in degrees, if known.
latitude: float | None = None latitude: float | None = None
# Longitude of the reference, in degrees, if known. # Longitude of the reference, in degrees, if known.
+70 -43
View File
@@ -2,7 +2,7 @@ import hashlib
import json import json
import logging import logging
import re import re
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import datetime, timedelta from datetime import datetime, timedelta
from math import isnan from math import isnan
@@ -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.call_lookup_helper import get_call_info
from core.config import MAX_SPOT_AGE 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.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_lookup_helper import populate_missing_sig_ref_info
from core.sig_utils import ( from core.sig_utils import (
@@ -52,7 +53,7 @@ class Spot:
# Country flag of the DX operator # Country flag of the DX operator
dx_flag: str | None = None dx_flag: str | None = None
# Continent of the DX operator # Continent of the DX operator
dx_continent: str | None = None dx_continent: Continent | None = None
# DXCC ID of the DX operator # DXCC ID of the DX operator
dx_dxcc_id: int | None = None dx_dxcc_id: int | None = None
# CQ zone of the DX operator # CQ zone of the DX operator
@@ -68,9 +69,8 @@ class Spot:
# lookup # lookup
dx_latitude: float | None = None dx_latitude: float | None = None
dx_longitude: float | None = None dx_longitude: float | None = None
# DX Location source. Indicates how accurate the location might be. Values: "SPOT", "GRID", "SIG REF LOOKUP", # DX Location source. Indicates how accurate the location might be.
# "HOME QTH", "DXCC", "NONE" dx_location_source: LocationSourceForSpot | None = None
dx_location_source: str = "NONE"
# DX Location good. Indicates that the software thinks the location data is good enough to plot on a map. This is # 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 # 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. # DX callsign doesn't have a suffix like /P.
@@ -85,7 +85,7 @@ class Spot:
# Country flag of the spotter # Country flag of the spotter
de_flag: str | None = None de_flag: str | None = None
# Continent of the spotter # Continent of the spotter
de_continent: str | None = None de_continent: Continent | None = None
# DXCC ID of the spotter # DXCC ID of the spotter
de_dxcc_id: int | None = None de_dxcc_id: int | None = None
# If this is an APRS/Packet/etc spot, what SSID was the spotter/receiver using? # If this is an APRS/Packet/etc spot, what SSID was the spotter/receiver using?
@@ -103,11 +103,11 @@ class Spot:
# General QSO info # General QSO info
# Reported mode, such as SSB, PHONE, CW, FT8... # Reported mode, such as SSB, PHONE, CW, FT8...
mode: str | None = None mode: Mode | None = None
# Inferred mode "family". One of "CW", "PHONE" or "DIGI". # Inferred mode "family".
mode_type: str | None = None mode_type: ModeType | None = None
# Source of the mode information. "SPOT", "COMMENT", "BANDPLAN" or "NONE" # Source of the mode information.
mode_source: str = "NONE" mode_source: ModeSource | None = None
# Frequency, in Hz # Frequency, in Hz
freq: float | None = None freq: float | None = None
# Band, defined by the frequency, e.g. "40m" or "70cm" # Band, defined by the frequency, e.g. "40m" or "70cm"
@@ -124,7 +124,7 @@ class Spot:
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA # Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
sig: str | None = None sig: str | None = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO # SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
sig_refs: list | None = None sig_refs: list = field(default_factory=list)
# Timing info # Timing info
@@ -146,6 +146,11 @@ class Spot:
# The ID the source gave it, if any. # The ID the source gave it, if any.
source_id: str | None = None 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): def __post_init__(self):
"""Normalise fields that don't survive a plain dict to Spot conversion. This is used in the "add spot" API """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 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) dx_call_info = get_call_info(self.dx_call, credentials)
if self.dx_call and not self.dx_country: if self.dx_call and not self.dx_country:
self.dx_country = dx_call_info.country self.dx_country = dx_call_info.country
if self.dx_call and not self.dx_continent: if self.dx_call and dx_call_info.continent and not self.dx_continent:
self.dx_continent = dx_call_info.continent self.dx_continent = Continent(dx_call_info.continent)
if self.dx_call and not self.dx_dxcc_id: if self.dx_call and not self.dx_dxcc_id:
self.dx_dxcc_id = dx_call_info.dxcc_id self.dx_dxcc_id = dx_call_info.dxcc_id
if self.dx_dxcc_id and not self.dx_flag: if self.dx_dxcc_id and not self.dx_flag:
@@ -222,8 +227,8 @@ class Spot:
): ):
if not self.de_country: if not self.de_country:
self.de_country = de_call_info.country self.de_country = de_call_info.country
if not self.de_continent: if de_call_info.continent and not self.de_continent:
self.de_continent = de_call_info.continent self.de_continent = Continent(de_call_info.continent)
if not self.de_dxcc_id: if not self.de_dxcc_id:
self.de_dxcc_id = de_call_info.dxcc_id self.de_dxcc_id = de_call_info.dxcc_id
if self.de_dxcc_id and not self.de_flag: if self.de_dxcc_id and not self.de_flag:
@@ -240,17 +245,13 @@ class Spot:
# Mode from comments or bandplan # Mode from comments or bandplan
if self.mode: if self.mode:
self.mode_source = "SPOT" self.mode_source = ModeSource.SPOT
if self.comment and not self.mode: if self.comment and not self.mode:
self.mode = infer_mode_from_comment(self.comment) self.mode = infer_mode_from_comment(self.comment)
self.mode_source = "COMMENT" self.mode_source = ModeSource.COMMENT
if self.freq and not self.mode: if self.freq and not self.mode:
self.mode = infer_mode_from_frequency(self.freq) self.mode = infer_mode_from_frequency(self.freq)
self.mode_source = "BANDPLAN" self.mode_source = ModeSource.BANDPLAN
# Normalise mode if necessary.
if self.mode in MODE_ALIASES:
self.mode = MODE_ALIASES[self.mode]
# Mode type from mode # Mode type from mode
if self.mode and not self.mode_type: if self.mode and not self.mode_type:
@@ -258,19 +259,19 @@ class Spot:
# If we have a latitude or grid at this point, it can only have been provided by the spot itself # If we have a latitude or grid at this point, it can only have been provided by the spot itself
if self.dx_latitude or self.dx_grid: 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. # 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: if not self.sig and self.sig_refs:
self.sig = self.sig_refs[0].sig.upper() self.sig = self.sig_refs[0].sig.upper()
# See if we already have a SIG reference, but the comment looks like it contains more for the same SIG. This # See if we already have a SIG reference, but the comment looks like it contains more for the same SIG. This
# should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002". # should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002".
if self.comment and self.sig_refs and len(self.sig_refs) > 0 and self.sig_refs[0].sig: if self.comment and self.sig_refs and self.sig_refs[0].sig:
sig = self.sig_refs[0].sig.upper() sig = self.sig_refs[0].sig.upper()
regex = get_ref_regex_for_sig(sig) regex = get_ref_regex_for_sig(sig)
if regex: 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: for ref_match in all_comment_ref_matches:
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig)) self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig))
@@ -298,10 +299,23 @@ class Spot:
for ref_match in ref_matches: for ref_match in ref_matches:
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(3).upper(), sig=found_sig)) 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 # 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 # 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 # from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
if self.sig_refs and len(self.sig_refs) > 0: if self.sig_refs:
for sig_ref in self.sig_refs: for sig_ref in self.sig_refs:
sig_ref = populate_missing_sig_ref_info(sig_ref) sig_ref = populate_missing_sig_ref_info(sig_ref)
# If the spot itself doesn't have location yet, but the SIG ref does, extract it # If the spot itself doesn't have location yet, but the SIG ref does, extract it
@@ -311,13 +325,13 @@ class Spot:
self.dx_latitude = sig_ref.latitude self.dx_latitude = sig_ref.latitude
self.dx_longitude = sig_ref.longitude self.dx_longitude = sig_ref.longitude
if self.sig == "WAB" or self.sig == "WAI" or self.sig == "Tiles": if self.sig == "WAB" or self.sig == "WAI" or self.sig == "Tiles":
self.dx_location_source = "GRID" self.dx_location_source = LocationSourceForSpot.GRID
else: 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 # 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. # and apply it to the whole spot.
if self.sig_refs and len(self.sig_refs) > 0 and not self.sig: if self.sig_refs and not self.sig:
self.sig = self.sig_refs[0].sig self.sig = self.sig_refs[0].sig
# Parse "de_grid<prop_mode>dx_grid" structures from the comment, e.g. "JN61ES(ES)JM56XT" or "JO02GQ<>KN17LG". # Parse "de_grid<prop_mode>dx_grid" structures from the comment, e.g. "JN61ES(ES)JM56XT" or "JO02GQ<>KN17LG".
@@ -334,7 +348,7 @@ class Spot:
self.de_grid = grid_mode_grid_match.group(1).upper() self.de_grid = grid_mode_grid_match.group(1).upper()
if not self.dx_grid: if not self.dx_grid:
self.dx_grid = grid_mode_grid_match.group(4).upper() 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 (...)): # 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() mode_tag = (grid_mode_grid_match.group(2) or grid_mode_grid_match.group(3) or "").upper()
@@ -345,6 +359,16 @@ class Spot:
self.propagation_mode = mode_tag self.propagation_mode = mode_tag
logger.info(f"Seen a new propagation mode tag not yet in the system: {mode_tag}") logger.info(f"Seen a new propagation mode tag not yet in the system: {mode_tag}")
# Set SIGs based on propagation mode
if self.propagation_mode == "Satellite":
if not self.sig:
self.sig = "AMSAT"
self.sig_refs.append(SIGRef(sig="AMSAT"))
if self.propagation_mode == "Earth-Moon-Earth":
if not self.sig:
self.sig = "EME"
self.sig_refs.append(SIGRef(sig="EME"))
# Parse "de_grid -> dx_grid" structures from the comment # Parse "de_grid -> dx_grid" structures from the comment
if self.comment: if self.comment:
grid_mode_grid_match = re.search( grid_mode_grid_match = re.search(
@@ -355,7 +379,7 @@ class Spot:
# regex matches, so extract grids: # regex matches, so extract grids:
if not self.dx_grid: if not self.dx_grid:
self.dx_grid = grid_mode_grid_match.group(1).upper() 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: if not self.de_grid:
self.de_grid = grid_mode_grid_match.group(2).upper() self.de_grid = grid_mode_grid_match.group(2).upper()
@@ -401,7 +425,7 @@ class Spot:
# Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable string, # Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable string,
# otherwise see what they have set on an online lookup service. # otherwise see what they have set on an online lookup service.
if self.sig_refs and len(self.sig_refs) > 0: if self.sig_refs:
qth = self.sig_refs[0].id qth = self.sig_refs[0].id
if self.sig_refs[0].name: if self.sig_refs[0].name:
qth += f" {self.sig_refs[0].name}" qth += f" {self.sig_refs[0].name}"
@@ -427,10 +451,10 @@ class Spot:
self.dx_latitude self.dx_latitude
and self.dx_longitude and self.dx_longitude
and ( and (
self.dx_location_source == "SPOT" self.dx_location_source == LocationSourceForSpot.SPOT
or self.dx_location_source == "SIG REF LOOKUP" or self.dx_location_source == LocationSourceForSpot.SIG_REF_LOOKUP
or self.dx_location_source == "GRID" or self.dx_location_source == LocationSourceForSpot.GRID
or (self.dx_location_source == "HOME QTH" and "/" not in (self.dx_call or "")) or (self.dx_location_source == LocationSourceForSpot.HOME_QTH and "/" not in (self.dx_call or ""))
) )
) )
@@ -446,6 +470,11 @@ class Spot:
self.de_longitude = de_call_info.longitude self.de_longitude = de_call_info.longitude
self.de_grid = de_call_info.grid 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 self.sig_refs[0].icon:
self.icon = self.sig_refs[0].icon
except Exception: except Exception:
logger.exception("Exception while inferring missing data from spot") logger.exception("Exception while inferring missing data from spot")
@@ -457,16 +486,14 @@ class Spot:
def _append_sig_ref_if_missing(self, new_sig_ref): def _append_sig_ref_if_missing(self, new_sig_ref):
"""Append a sig_ref to the list, so long as it's not already there.""" """Append a sig_ref to the list, so long as it's not already there."""
sig_refs = self.sig_refs or []
self.sig_refs = sig_refs
new_sig_ref.id = new_sig_ref.id.strip().upper() new_sig_ref.id = new_sig_ref.id.strip().upper()
new_sig_ref.sig = new_sig_ref.sig.strip().upper() new_sig_ref.sig = new_sig_ref.sig.strip().upper()
if new_sig_ref.id == "": if new_sig_ref.id == "":
return return
for sig_ref in sig_refs: for sig_ref in self.sig_refs:
if sig_ref.id == new_sig_ref.id and sig_ref.sig == new_sig_ref.sig: if sig_ref.id == new_sig_ref.id and sig_ref.sig == new_sig_ref.sig:
return return
sig_refs.append(new_sig_ref) self.sig_refs.append(new_sig_ref)
def expired(self): def expired(self):
"""Decide if this spot has expired (in which case it should not be added to the system in the first place, and not """Decide if this spot has expired (in which case it should not be added to the system in the first place, and not
+1 -10
View File
@@ -1,12 +1,9 @@
AA343,"Almaty, Kazakhstan" AA343,"Almaty, Kazakhstan"
AL945,"Alpena, United States" AL945,"Alpena, United States"
AT138,"Athens, Greece" AT138,"Athens, Greece"
AU930,"Austin, United States"
BR52P,"Brisbane, Australia"
BVJ03,"Boa Vista, Brazil" BVJ03,"Boa Vista, Brazil"
CAJ2M,"Cachoeira Paulista, Brazil" CAJ2M,"Cachoeira Paulista, Brazil"
CB53N,"Canberra, Australia" CB53N,"Canberra, Australia"
CGK21,"Campo Grande, Brazil"
DB049,"Dourbes, Belgium" DB049,"Dourbes, Belgium"
DW41K,"Darwin, Australia" DW41K,"Darwin, Australia"
EA036,"El Arenosillo, Spain" EA036,"El Arenosillo, Spain"
@@ -18,24 +15,18 @@ GM037,"Gibilmanna, Italy"
GR13L,"Grahamstown, South Africa" GR13L,"Grahamstown, South Africa"
HE13N,"Hermanus, South Africa" HE13N,"Hermanus, South Africa"
HO54K,"Hobart, Australia" HO54K,"Hobart, Australia"
IC437,"I-Cheon, South Korea"
IF843,"Idaho National Laboratory, United States" IF843,"Idaho National Laboratory, United States"
JI91J,"Jicamarca, Peru" JI91J,"Jicamarca, Peru"
JR055,"Juliusruh, Germany" JR055,"Juliusruh, Germany"
LAA38,"Lajes Terceira Island, Portugal" LAA38,"Lajes Terceira Island, Portugal"
LL721,"Lualualei, United States" LL721,"Lualualei, United States"
LM42B,"Learmonth, Australia"
LV12P,"Louisvale, South Africa"
MHJ45,"Millstone Hill, United States" MHJ45,"Millstone Hill, United States"
ML10L,"Malindi, Kenya"
NI135,"Nicosia, Cyprus" NI135,"Nicosia, Cyprus"
NI63_,"Norfolk, Australia" NI63_,"Norfolk, Australia"
PA836,"Portt Arguello, United States" PA836,"Port Arguello, United States"
PE43K,"Perth, Australia" PE43K,"Perth, Australia"
PF765,"Poker Flat, United States"
PQ052,"Pruhonice, Czechia" PQ052,"Pruhonice, Czechia"
RO041,"Rome, Italy" RO041,"Rome, Italy"
SAA0K,"Saoluis, Brazil"
SO148,"Sopron, Hungary" SO148,"Sopron, Hungary"
TR169,"Tromso, Norway" TR169,"Tromso, Norway"
TV51R,"Townsville, Australia" TV51R,"Townsville, Australia"
1 AA343 Almaty, Kazakhstan
2 AL945 Alpena, United States
3 AT138 Athens, Greece
AU930 Austin, United States
BR52P Brisbane, Australia
4 BVJ03 Boa Vista, Brazil
5 CAJ2M Cachoeira Paulista, Brazil
6 CB53N Canberra, Australia
CGK21 Campo Grande, Brazil
7 DB049 Dourbes, Belgium
8 DW41K Darwin, Australia
9 EA036 El Arenosillo, Spain
15 GR13L Grahamstown, South Africa
16 HE13N Hermanus, South Africa
17 HO54K Hobart, Australia
IC437 I-Cheon, South Korea
18 IF843 Idaho National Laboratory, United States
19 JI91J Jicamarca, Peru
20 JR055 Juliusruh, Germany
21 LAA38 Lajes Terceira Island, Portugal
22 LL721 Lualualei, United States
LM42B Learmonth, Australia
LV12P Louisvale, South Africa
23 MHJ45 Millstone Hill, United States
ML10L Malindi, Kenya
24 NI135 Nicosia, Cyprus
25 NI63_ Norfolk, Australia
26 PA836 Portt Arguello, United States Port Arguello, United States
27 PE43K Perth, Australia
PF765 Poker Flat, United States
28 PQ052 Pruhonice, Czechia
29 RO041 Rome, Italy
SAA0K Saoluis, Brazil
30 SO148 Sopron, Hungary
31 TR169 Tromso, Norway
32 TV51R Townsville, Australia
+13 -11
View File
@@ -13,6 +13,7 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8080:8080" - "8080:8080"
- "7373:7373" # For telnet if required
volumes: volumes:
- ./config.yml:/app/config.yml - ./config.yml:/app/config.yml
- ./cache:/app/cache - ./cache:/app/cache
@@ -23,15 +24,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 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 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 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. the `-d` flag.
### nginx Reverse Proxy with Docker ### 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 In a containerised setup, it's typical to run an nginx reverse proxy in one container, alongside certbot for renewal of
of HTTPS certificates, and then applications like Spothole in a separate container. In this case, there are a couple 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 variations of the docker compose file above, and the nginx reverse proxy configuration covered [here](./nginx.md), that
you will want to make. you will want to make.
@@ -60,8 +62,8 @@ networks:
``` ```
In your nginx site configuration, you'll want to refer to the Spothole container directly, and drop the block that 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 allows nginx to access static files directly, as these will be inaccessible in another container. So you may end up with
with something like: something like:
```nginx ```nginx
server { server {
@@ -146,13 +148,13 @@ server {
``` ```
If desired, you could even change the port on which Spothole runs from 8080 to a plain 80, in which case your 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 `proxy_pass` statements could drop the `:8080` suffix. Since Spothole is in a container, it can serve HTTP on port 80 if
if desired, because it doesn't conflict with the host system. desired, because it doesn't conflict with the host system.
### Restoring the static files bypass ### 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 If you would still like to bypass Spothole's web server for the static files, and serve them with nginx, you can do. The
do. The easiest way is to run another nginx container to serve the files, so your Spothole `compose.yaml` becomes: easiest way is to run another nginx container to serve the files, so your Spothole `compose.yaml` becomes:
```yaml ```yaml
services: services:
@@ -183,8 +185,8 @@ networks:
external: true external: true
``` ```
Then you can re-add the block that handles the `/static` path in your nginx reverse proxy config, but this time Then you can re-add the block that handles the `/static` path in your nginx reverse proxy config, but this time point it
point it at the new container rather than at a filesystem path: at the new container rather than at a filesystem path:
```nginx ```nginx
# Load static assets from the spothole-static-nginx container # Load static assets from the spothole-static-nginx container
+6 -3
View File
@@ -16,9 +16,12 @@ To navigate your way around the source code, this list may help.
* `/providers/solarconditions` - Classes providing solar and propagation by accessing the APIs of other services * `/providers/solarconditions` - Classes providing solar and propagation by accessing the APIs of other services
* `/providers/staticdata` - Classes providing static lookup data by accessing bundled data files or the APIs of other * `/providers/staticdata` - Classes providing static lookup data by accessing bundled data files or the APIs of other
services services
* `/providers/callsign` - Classes providing callsign lookup data by accessing bundled data files or the APIs of other
services
* `/providers/sigrefdata` - Classes providing SIG reference lookup data by accessing bundled data files or the APIs of * `/providers/sigrefdata` - Classes providing SIG reference lookup data by accessing bundled data files or the APIs of
other services other services
* `/server` - Classes for running Spothole's own web server * `/webserver` - Classes for running Spothole's own web server
* `/telnetserver` - Classes for running Spothole's telnet server
* `spothole.py` - Main application script * `spothole.py` - Main application script
*Templates* *Templates*
@@ -48,8 +51,8 @@ To navigate your way around the source code, this list may help.
### Extending the server ### Extending the server
Spothole is designed to be easily extensible. If you want to write your own spot provider, for example, simply add a 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 ( 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.) (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 " 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. HTTPSpotProvider" where some of the work is done for you.
+10 -15
View File
@@ -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 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 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 site, since Spothole itself doesn't directly support acting as an HTTPS server. The normal solution to this is to use a
a "reverse proxy" setup, where a general web server handles HTTP and HTTP requests (to port 80 & 443 respectively), then "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 passes on the request to the back-end application (in this case Spothole). nginx is a common choice for this general web
server. 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. 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 These are what's used on my own Spothole server to make sure that other third-party web-based software can get the data
my own Spothole server to make sure that other third-party web-based software can get the data from my instance, and from my instance, and applies to any endpoint underneath `/api`. If you want *your* Spothole instance to be set up the
applies to any endpoint underneath `/api`. If you want same way, so that others can write software in JavaScript that can access it, leave this intact. But if you want your
*your* Spothole instance to be set up the same way, so that others can write software in JavaScript that can access it, Spothole instance to only be usable by scripts running on the web server you write, you can remove these lines. (Note
leave this intact. But if you want your Spothole instance to only be usable by scripts running on the web server you that this doesn't stop other people writing *non-web-based* software that accesses your Spothole API&mdash;the
write, enforcement of cross-origin headers only happens within the user's browser. If you need to lock your instance down so
you can remove these lines. (Note that this doesn't stop other people writing *non-web-based* software that accesses that no-one else can access it with *any* software, that's an aspect of nginx or firewall config that you will need to
your find help with elsewhere.)
Spothole API&mdash;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: Now, make a symbolic link to enable the site:
+2 -1
View File
@@ -3,6 +3,7 @@ from datetime import datetime, timedelta
import pytz import pytz
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from core.enums import AlertType
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -57,7 +58,7 @@ class BOTA(HTTPAlertProvider):
dx_calls=[dx_call], dx_calls=[dx_call],
sig_refs=[SIGRef(id=ref_name, sig="BOTA")], sig_refs=[SIGRef(id=ref_name, sig="BOTA")],
start_time=date_time.timestamp(), start_time=date_time.timestamp(),
is_dxpedition=False, alert_type=AlertType.XOTA,
) )
new_alerts.append(alert) new_alerts.append(alert)
+56
View File
@@ -0,0 +1,56 @@
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
class Hamsat(HTTPAlertProvider):
"""Alert provider for Hamsat (hams.at)"""
POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://hams.at/api/alerts"
def __init__(self, provider_config):
super().__init__("Hamsat", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response):
new_alerts = []
# Iterate through source data
for source_alert in http_response.json()["data"]:
# Convert to our alert format
freqs_modes = source_alert.get("mode", "")
if "mhz" in source_alert:
if "mhz_direction" in source_alert:
freqs_modes = f"{source_alert['mhz']!s} {source_alert['mhz_direction']}, {freqs_modes}"
else:
freqs_modes = f"{source_alert['mhz']!s}, {freqs_modes}"
alert = Alert(
source=self.name,
source_id=source_alert["id"],
dx_calls=[source_alert["callsign"].upper()],
freqs_modes=freqs_modes,
comment=source_alert["comment"],
# Fudge a SIG ref to provide the remaining bits of data we need: the satellite and the operator's grid
sig_refs=[
SIGRef(
sig="AMSAT",
id=f"{source_alert['satellite']['name']} from {source_alert['grids'][0]}",
)
],
start_time=datetime.strptime(source_alert["aos_at"], "%Y-%m-%dT%H:%M:%SZ")
.replace(tzinfo=pytz.UTC)
.timestamp(),
end_time=datetime.strptime(source_alert["los_at"], "%Y-%m-%dT%H:%M:%SZ")
.replace(tzinfo=pytz.UTC)
.timestamp(),
alert_type=AlertType.SATELLITE,
)
# Add to our list
new_alerts.append(alert)
return new_alerts
+47
View File
@@ -0,0 +1,47 @@
from datetime import datetime, time
from typing import cast
import pytz
from icalendar import Calendar, Event
from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider
class ICALAlertProvider(HTTPAlertProvider):
"""Generic alert provider for iCal calendars. Defines an abstract method event_to_alert(event) that subclasses must
implement, and use it to convert an iCal event to an Alert object based on whatever format their iCal events use."""
def __init__(self, name, provider_config, url, poll_interval):
super().__init__(name, provider_config, url, poll_interval)
def _http_response_to_alerts(self, http_response):
new_alerts = []
cal = Calendar.from_ical(http_response.content)
# Iterate through events, passing each one in turn to the subclass' event_to_alert method to turn it into
# a Spothole alert object
for component in cal.walk():
if component.name != "VEVENT":
continue
event = cast(Event, component)
alert = self.event_to_alert(event)
new_alerts.append(alert)
return new_alerts
def event_to_alert(self, event: Event) -> Alert:
"""Convert an ICal event to an Alert object. Subclasses must implement this method."""
@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()
+5 -4
View File
@@ -6,19 +6,20 @@ import pytz
from rss_parser import Parser from rss_parser import Parser
from rss_parser.models.rss import RSS from rss_parser.models.rss import RSS
from core.enums import AlertType
from data.alert import Alert from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
class NG3K(HTTPAlertProvider): class NG3K(HTTPAlertProvider):
"""Alert provider NG3K DXpedition list""" """Alert provider for NG3K DXpedition list"""
POLL_INTERVAL_SEC = 1800 POLL_INTERVAL_DAYS = 1
ALERTS_URL = "https://www.ng3k.com/adxo.xml" ALERTS_URL = "https://www.ng3k.com/adxo.xml"
AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE) AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE)
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response):
new_alerts = [] new_alerts = []
@@ -88,7 +89,7 @@ class NG3K(HTTPAlertProvider):
comment=f"{by}; {comment}; {qsl_info}", comment=f"{by}; {comment}; {qsl_info}",
start_time=start_timestamp, start_time=start_timestamp,
end_time=end_timestamp, end_time=end_timestamp,
is_dxpedition=True, alert_type=AlertType.DXPEDITION,
) )
# Add to our list. # Add to our list.
+3 -1
View File
@@ -3,6 +3,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import AlertType
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -51,7 +52,7 @@ class ParksNPeaks(HTTPAlertProvider):
comment=source_alert["Comments"], comment=source_alert["Comments"],
sig_refs=sigrefs, sig_refs=sigrefs,
start_time=start_time, start_time=start_time,
is_dxpedition=False, alert_type=AlertType.XOTA,
) )
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before # Log a warning for the developer if PnP gives us an unknown programme we've never seen before
@@ -59,6 +60,7 @@ class ParksNPeaks(HTTPAlertProvider):
"POTA", "POTA",
"SOTA", "SOTA",
"WWFF", "WWFF",
"HEMA",
"SIOTA", "SIOTA",
"ZLOTA", "ZLOTA",
"KRMNPA", "KRMNPA",
+2 -1
View File
@@ -2,6 +2,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import AlertType
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -44,7 +45,7 @@ class POTA(HTTPAlertProvider):
end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"], "%Y-%m-%d%H:%M") end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"], "%Y-%m-%d%H:%M")
.replace(tzinfo=pytz.UTC) .replace(tzinfo=pytz.UTC)
.timestamp(), .timestamp(),
is_dxpedition=False, alert_type=AlertType.XOTA,
) )
# Add to our list, but exclude any old spots that POTA can sometimes give us where even the end time is # Add to our list, but exclude any old spots that POTA can sometimes give us where even the end time is
@@ -0,0 +1,75 @@
import re
from icalendar import Event
from core.enums import AlertType, Continent
from data.alert import Alert
from providers.alert.ical_alert_provider import ICALAlertProvider
class RSGBICALAlertProvider(ICALAlertProvider):
"""Generic alert provider for RSGB contest iCal calendars. Builds on the generic iCal alert provider by adding
handling specific to how RSGB's iCal events are formatted. This is still effectively an abstract class itself;
RSGB has two contest calendars (HF & VHF) that each subclass this."""
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 event_to_alert(self, event: Event) -> Alert:
"""Convert an iCal event in RSGB's format to an Alert object."""
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. 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,
)
return alert
+11
View File
@@ -0,0 +1,11 @@
from providers.alert.rsgb_ical_alert_provider import RSGBICALAlertProvider
class RSGBHFContests(RSGBICALAlertProvider):
"""Alert provider for RSGB HF Contest calendar"""
POLL_INTERVAL_DAYS = 30
ALERTS_URL = "https://calendar.google.com/calendar/ical/a5ff31ebb1b4834dc7fff4c5415ae8251c6a9aa11f98c6af6e472b6c552b1915%40group.calendar.google.com/public/basic.ics"
def __init__(self, provider_config):
super().__init__("RSGB HF Contests", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
+11
View File
@@ -0,0 +1,11 @@
from providers.alert.rsgb_ical_alert_provider import RSGBICALAlertProvider
class RSGBVHFContests(RSGBICALAlertProvider):
"""Alert provider for RSGB VHF Contest calendar"""
POLL_INTERVAL_DAYS = 30
ALERTS_URL = "https://calendar.google.com/calendar/ical/40f3552bff39a016f1cdca205864177070dcad68d55be17eb061cb021f39f96c%40group.calendar.google.com/public/basic.ics"
def __init__(self, provider_config):
super().__init__("RSGB VHF Contests", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
+2 -1
View File
@@ -2,6 +2,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import AlertType
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -44,7 +45,7 @@ class SOTA(HTTPAlertProvider):
start_time=datetime.strptime(source_alert["dateActivated"], "%Y-%m-%dT%H:%M:%SZ") start_time=datetime.strptime(source_alert["dateActivated"], "%Y-%m-%dT%H:%M:%SZ")
.replace(tzinfo=pytz.UTC) .replace(tzinfo=pytz.UTC)
.timestamp(), .timestamp(),
is_dxpedition=False, alert_type=AlertType.XOTA,
) )
# Add to our list # Add to our list
+41
View File
@@ -0,0 +1,41 @@
from icalendar import Event
from core.enums import AlertType
from data.alert import Alert
from providers.alert.ical_alert_provider import ICALAlertProvider
class WA7BNM(ICALAlertProvider):
"""Alert provider for the WA7BNM contest calendar."""
POLL_INTERVAL_DAYS = 1
ALERTS_URL = "https://contestcalendar.com/weeklycontcustom.php"
def __init__(self, provider_config):
super().__init__(
"WA7BNM Contest Calendar", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60
)
def event_to_alert(self, event: Event) -> Alert:
"""Convert an iCal event in WA7BNM's format to an Alert object."""
summary = str(event.get("summary", ""))
url = str(event.get("url", ""))
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=[],
comment=summary,
url=url,
start_time=start_timestamp,
end_time=end_timestamp,
alert_type=AlertType.CONTEST,
)
return alert
+2 -1
View File
@@ -2,6 +2,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import AlertType
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.http_alert_provider import HTTPAlertProvider
@@ -34,7 +35,7 @@ class WWFF(HTTPAlertProvider):
end_time=datetime.strptime(source_alert["utc_end"], "%Y-%m-%d %H:%M:%S") end_time=datetime.strptime(source_alert["utc_end"], "%Y-%m-%d %H:%M:%S")
.replace(tzinfo=pytz.UTC) .replace(tzinfo=pytz.UTC)
.timestamp(), .timestamp(),
is_dxpedition=False, alert_type=AlertType.XOTA,
) )
# Add to our list # Add to our list
+4 -3
View File
@@ -11,8 +11,9 @@ from requests_cache import CachedSession
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from core.constants import HTTP_HEADERS, SOFTWARE_VERSION from core.constants import HTTP_HEADERS, SOFTWARE_VERSION
from core.data_store import CACHE_DIR, DATA_STORE from core.data_store import CACHE_DIR, DATA_STORE
from core.enums import Continent
from core.url_data_cache import URLDataCache 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 ( from providers.callsigndata.api_query_callsign_data_provider import (
APIQueryCallsignDataProvider, APIQueryCallsignDataProvider,
) )
@@ -142,12 +143,12 @@ class HamQTH(APIQueryCallsignDataProvider):
name=data.get("nick", None), name=data.get("nick", None),
qth=data.get("qth", None), qth=data.get("qth", None),
country=data.get("country", None), country=data.get("country", None),
continent=data.get("continent", None), continent=Continent(data["continent"]) if "continent" in data else None,
latitude=lat, latitude=lat,
longitude=lon, longitude=lon,
grid=grid, grid=grid,
dxcc_id=int(data["adif"]) if "adif" in data else None, dxcc_id=int(data["adif"]) if "adif" in data else None,
cq_zone=int(data["cq"]) if "cq" 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, itu_zone=int(data["itu"]) if "itu" in data else None,
location_source="HOME QTH", location_source=LocationSourceForCallsign.HOME_QTH,
) )
+4 -5
View File
@@ -10,11 +10,10 @@ from requests_cache import CachedSession
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.data_store import CACHE_DIR, DATA_STORE from core.data_store import CACHE_DIR, DATA_STORE
from core.enums import Continent, LocationSourceForCallsign
from core.url_data_cache import URLDataCache from core.url_data_cache import URLDataCache
from data.callsign import Callsign from data.callsign import Callsign
from providers.callsigndata.api_query_callsign_data_provider import ( from providers.callsigndata.api_query_callsign_data_provider import APIQueryCallsignDataProvider
APIQueryCallsignDataProvider,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -170,12 +169,12 @@ class QRZ(APIQueryCallsignDataProvider):
name=name, name=name,
qth=data.get("addr2", None), qth=data.get("addr2", None),
country=data.get("country", None), country=data.get("country", None),
continent=data.get("continent", None), continent=Continent(data["continent"]) if "continent" in data else None,
latitude=lat, latitude=lat,
longitude=lon, longitude=lon,
grid=grid, grid=grid,
dxcc_id=int(data["adif"]) if "adif" in data else None, dxcc_id=int(data["adif"]) if "adif" in data else None,
cq_zone=int(data["cqzone"]) if "cqzone" 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, itu_zone=int(data["ituzone"]) if "ituzone" in data else None,
location_source="HOME QTH", location_source=LocationSourceForCallsign.HOME_QTH,
) )
+2 -1
View File
@@ -1,6 +1,7 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadSIGRefDataProvider,
@@ -27,7 +28,7 @@ class ARLHS(FileDownloadSIGRefDataProvider):
sig=self.SIG, sig=self.SIG,
id=ref_id, id=ref_id,
name=row.get("Name", None), name=row.get("Name", None),
ref_type="Lighthouse", ref_type=SIGRefType.LIGHTHOUSE,
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}", url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None, 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, longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
+52
View File
@@ -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
+42
View File
@@ -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
+46
View File
@@ -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
+7 -2
View File
@@ -3,6 +3,7 @@ from time import sleep
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.sigrefdata.local_file_sig_ref_data_provider import ( from providers.sigrefdata.local_file_sig_ref_data_provider import (
LocalFileSIGRefDataProvider, LocalFileSIGRefDataProvider,
@@ -22,7 +23,11 @@ class DME(LocalFileSIGRefDataProvider):
new_data = [] new_data = []
with open(path, encoding="latin-1") as _f: with open(path, encoding="latin-1") as _f:
for row in csv.DictReader(_f, delimiter=";"): 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 = ( latitude = (
float(row["LATITUD_ETRS89_REGCAN95"].replace(",", ".")) float(row["LATITUD_ETRS89_REGCAN95"].replace(",", "."))
if row.get("LATITUD_ETRS89_REGCAN95") if row.get("LATITUD_ETRS89_REGCAN95")
@@ -37,7 +42,7 @@ class DME(LocalFileSIGRefDataProvider):
ref = SIGRef( ref = SIGRef(
sig=self.SIG, sig=self.SIG,
id=ref_id, id=ref_id,
ref_type="Town", ref_type=SIGRefType.TOWN,
name=f"{row['NOMBRE_ACTUAL']}, {row['PROVINCIA']}", name=f"{row['NOMBRE_ACTUAL']}, {row['PROVINCIA']}",
latitude=latitude, latitude=latitude,
longitude=longitude, longitude=longitude,
+37
View File
@@ -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
+50
View File
@@ -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
+35
View File
@@ -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
+56
View File
@@ -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
+2 -1
View File
@@ -1,6 +1,7 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadSIGRefDataProvider,
@@ -26,7 +27,7 @@ class GMA(FileDownloadSIGRefDataProvider):
sig=self.SIG, sig=self.SIG,
id=ref_id, id=ref_id,
name=row.get("Name", None), name=row.get("Name", None),
ref_type="Summit", ref_type=SIGRefType.SUMMIT,
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}", url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None, 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, longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
+2 -1
View File
@@ -1,6 +1,7 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadSIGRefDataProvider,
@@ -27,7 +28,7 @@ class ILLW(FileDownloadSIGRefDataProvider):
sig=self.SIG, sig=self.SIG,
id=ref_id, id=ref_id,
name=row.get("Name", None), name=row.get("Name", None),
ref_type="Lighthouse", ref_type=SIGRefType.LIGHTHOUSE,
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}", url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None, 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, longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
+2 -1
View File
@@ -3,6 +3,7 @@ from time import sleep
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadSIGRefDataProvider,
@@ -44,7 +45,7 @@ class IOTA(FileDownloadSIGRefDataProvider):
sig=self.SIG, sig=self.SIG,
id=ref_id, id=ref_id,
name=ref["name"], name=ref["name"],
ref_type="Island", ref_type=SIGRefType.ISLAND,
grid=grid, grid=grid,
latitude=latitude, latitude=latitude,
longitude=longitude, longitude=longitude,
+2 -1
View File
@@ -2,6 +2,7 @@ from time import sleep
from pyhamtools.locator import locator_to_latlong from pyhamtools.locator import locator_to_latlong
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadSIGRefDataProvider,
@@ -32,7 +33,7 @@ class LLOTA(FileDownloadSIGRefDataProvider):
sig=self.SIG, sig=self.SIG,
id=ref_id, id=ref_id,
name=str(ref["name"]), name=str(ref["name"]),
ref_type="Lake", ref_type=SIGRefType.LAKE,
url=f"https://llota.app/list/ref/{ref_id}", url=f"https://llota.app/list/ref/{ref_id}",
grid=grid, grid=grid,
latitude=ll[0], latitude=ll[0],
+2 -1
View File
@@ -1,6 +1,7 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadSIGRefDataProvider,
@@ -26,7 +27,7 @@ class MOTA(FileDownloadSIGRefDataProvider):
sig=self.SIG, sig=self.SIG,
id=ref_id, id=ref_id,
name=row.get("Name", None), name=row.get("Name", None),
ref_type="Mill", ref_type=SIGRefType.MILL,
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}", url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None, 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, longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
+58
View File
@@ -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 fastkml import kml
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadSIGRefDataProvider,
@@ -43,7 +44,7 @@ class ParksNPeaksKMLSIGRefDataProvider(FileDownloadSIGRefDataProvider):
sig=self.sig_name, sig=self.sig_name,
id=ref_id, id=ref_id,
name=placemark.name, name=placemark.name,
ref_type="Park", ref_type=SIGRefType.PARK,
url=f"https://parksnpeaks.org/getPark.php?actPark={ref_id}", url=f"https://parksnpeaks.org/getPark.php?actPark={ref_id}",
latitude=latitude, latitude=latitude,
longitude=longitude, longitude=longitude,
+2 -1
View File
@@ -1,6 +1,7 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadSIGRefDataProvider,
@@ -26,7 +27,7 @@ class POTA(FileDownloadSIGRefDataProvider):
sig=self.SIG, sig=self.SIG,
id=ref_id, id=ref_id,
name=row.get("name", None), name=row.get("name", None),
ref_type="Park", ref_type=SIGRefType.PARK,
url=f"https://pota.app/#/park/{ref_id}", url=f"https://pota.app/#/park/{ref_id}",
grid=row.get("grid", None), grid=row.get("grid", None),
latitude=float(row["latitude"]) if "latitude" in row and row["latitude"] != "" else None, latitude=float(row["latitude"]) if "latitude" in row and row["latitude"] != "" else None,
+2 -1
View File
@@ -1,6 +1,7 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadSIGRefDataProvider,
@@ -26,7 +27,7 @@ class SIOTA(FileDownloadSIGRefDataProvider):
sig=self.SIG, sig=self.SIG,
id=ref_id, id=ref_id,
name=row.get("NAME", None), name=row.get("NAME", None),
ref_type="Silo", ref_type=SIGRefType.SILO,
grid=row.get("LOCATOR", None), grid=row.get("LOCATOR", None),
latitude=float(row["LAT"]) if "LAT" in row else None, latitude=float(row["LAT"]) if "LAT" in row else None,
longitude=float(row["LNG"]) if "LNG" in row else None, longitude=float(row["LNG"]) if "LNG" in row else None,
+2 -1
View File
@@ -3,6 +3,7 @@ from time import sleep
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadSIGRefDataProvider,
@@ -30,7 +31,7 @@ class SOTA(FileDownloadSIGRefDataProvider):
sig=self.SIG, sig=self.SIG,
id=ref_id, id=ref_id,
name=row.get("SummitName", None), name=row.get("SummitName", None),
ref_type="Summit", ref_type=SIGRefType.SUMMIT,
url=f"https://www.sotadata.org.uk/en/summit/{ref_id}", url=f"https://www.sotadata.org.uk/en/summit/{ref_id}",
latitude=latitude, latitude=latitude,
longitude=longitude, longitude=longitude,
+2 -1
View File
@@ -1,5 +1,6 @@
import csv import csv
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.sigrefdata.local_file_sig_ref_data_provider import ( from providers.sigrefdata.local_file_sig_ref_data_provider import (
LocalFileSIGRefDataProvider, LocalFileSIGRefDataProvider,
@@ -26,7 +27,7 @@ class Toilets(LocalFileSIGRefDataProvider):
sig=self.SIG, sig=self.SIG,
id=row["ref"], id=row["ref"],
name=row["ref"], name=row["ref"],
ref_type="Toilet", ref_type=SIGRefType.TOILET,
latitude=float(row["lat"]), latitude=float(row["lat"]),
longitude=float(row["lon"]), longitude=float(row["lon"]),
) )
+2 -1
View File
@@ -1,6 +1,7 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadSIGRefDataProvider,
@@ -26,7 +27,7 @@ class Towers(FileDownloadSIGRefDataProvider):
sig=self.SIG, sig=self.SIG,
id=ref_id, id=ref_id,
name=row.get("Nazev", None), name=row.get("Nazev", None),
ref_type="Tower", ref_type=SIGRefType.TOWER,
url=f"https://wwtota.com/seznam/karta_rozhledny.php?ref={ref_id}", url=f"https://wwtota.com/seznam/karta_rozhledny.php?ref={ref_id}",
grid=row["Lokator"] if "Lokator" in row and row["Lokator"] != "" else None, 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, latitude=float(row["Lat"]) if "Lat" in row and row["Lat"] != "" else None,
+2 -1
View File
@@ -4,6 +4,7 @@ from time import sleep
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadSIGRefDataProvider,
@@ -45,7 +46,7 @@ class WCA(FileDownloadSIGRefDataProvider):
sig=self.SIG, sig=self.SIG,
id=ref_id, id=ref_id,
name=row.get("CLEAN NAME", None), name=row.get("CLEAN NAME", None),
ref_type="Castle", ref_type=SIGRefType.CASTLE,
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}", url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
latitude=latitude, latitude=latitude,
longitude=longitude, longitude=longitude,
+2 -1
View File
@@ -1,5 +1,6 @@
from time import sleep from time import sleep
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadSIGRefDataProvider,
@@ -33,7 +34,7 @@ class WOTA(FileDownloadSIGRefDataProvider):
id=ref_id, id=ref_id,
name=feature["properties"]["title"], name=feature["properties"]["title"],
url=url, url=url,
ref_type="Summit", ref_type=SIGRefType.SUMMIT,
grid=feature["properties"]["qthLocator"], grid=feature["properties"]["qthLocator"],
latitude=feature["geometry"]["coordinates"][1], latitude=feature["geometry"]["coordinates"][1],
longitude=feature["geometry"]["coordinates"][0], longitude=feature["geometry"]["coordinates"][0],
+2 -1
View File
@@ -1,6 +1,7 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadSIGRefDataProvider,
@@ -26,7 +27,7 @@ class WWBOTA(FileDownloadSIGRefDataProvider):
sig=self.SIG, sig=self.SIG,
id=ref_id, id=ref_id,
name=row.get("Name", None), 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, 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, 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, latitude=float(row["Lat"]) if "Lat" in row and row["Lat"] != "" else None,
+2 -1
View File
@@ -1,6 +1,7 @@
import csv import csv
from time import sleep from time import sleep
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadSIGRefDataProvider,
@@ -26,7 +27,7 @@ class WWFF(FileDownloadSIGRefDataProvider):
sig=self.SIG, sig=self.SIG,
id=ref_id, id=ref_id,
name=row.get("name", None), name=row.get("name", None),
ref_type="Park", ref_type=SIGRefType.PARK,
url=f"https://wwff.co/directory/?showRef={ref_id}", url=f"https://wwff.co/directory/?showRef={ref_id}",
grid=row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None, grid=row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None,
latitude=float(row["latitude"]) latitude=float(row["latitude"])
+6 -1
View File
@@ -2,6 +2,7 @@ from time import sleep
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from providers.sigrefdata.file_download_sig_ref_data_provider import ( from providers.sigrefdata.file_download_sig_ref_data_provider import (
FileDownloadSIGRefDataProvider, FileDownloadSIGRefDataProvider,
@@ -26,12 +27,16 @@ class ZLOTA(FileDownloadSIGRefDataProvider):
ref_id = ref["code"] ref_id = ref["code"]
latitude = ref["latitude"] latitude = ref["latitude"]
longitude = ref["longitude"] longitude = ref["longitude"]
try:
ref_type = SIGRefType(ref["asset_type"].title().upper())
except ValueError:
ref_type = None
new_ref = SIGRef( new_ref = SIGRef(
sig=self.SIG, sig=self.SIG,
id=ref_id, id=ref_id,
name=ref["name"], name=ref["name"],
ref_type=ref["asset_type"].title(), ref_type=ref_type,
url=f"https://ontheair.nz/assets/{ref_id.replace('/', '_')}", url=f"https://ontheair.nz/assets/{ref_id.replace('/', '_')}",
latitude=latitude, latitude=latitude,
longitude=longitude, longitude=longitude,
+2 -2
View File
@@ -20,7 +20,7 @@ POLL_INTERVAL = 7200
# To avoid looking up all stations in the GIRO system and working out which ones are providing live data, this has been # To avoid looking up all stations in the GIRO system and working out which ones are providing live data, this has been
# manually determined and a CSV provided of all the stations that we can query for live data. # manually determined and a CSV provided of all the stations that we can query for live data.
STATIONS_INDEX = "datafiles/didbase-stations.csv" STATIONS_INDEX = "datafiles/didbase-stations.csv"
LGDC_URL = "https://lgdc.uml.edu/common/DIDBGetValues" LGDC_URL = "https://lgdc.uml.edu/fastchar/getbest"
HISTORY_HOURS = 24 HISTORY_HOURS = 24
@@ -140,7 +140,7 @@ class GIROIonosonde(SolarConditionsProvider):
from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S") from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S")
to_str = to_time.strftime("%Y.%m.%d+%H:%M:%S") to_str = to_time.strftime("%Y.%m.%d+%H:%M:%S")
url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUFD,fmin&DMUF=3000&fromDate={from_str}&toDate={to_str}" url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUF%28D%29,fmin&DMUF=3000&fromDate={from_str}&toDate={to_str}"
try: try:
http_response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15)) http_response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15))
if not http_response.ok: if not http_response.ok:
@@ -16,7 +16,7 @@ class SolarConditionsProvider:
self.enabled = provider_config.get("enabled", True) self.enabled = provider_config.get("enabled", True)
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC) self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled" self.status = "Not Started" if self.enabled else "Disabled"
self._solar_conditions = DATA_STORE.solar_conditions self._solar_conditions = DATA_STORE.solar_conditions.get()
def start(self): def start(self):
"""Start the provider. This should return immediately after spawning threads to access the remote resources""" """Start the provider. This should return immediately after spawning threads to access the remote resources"""
@@ -36,3 +36,4 @@ class SolarConditionsProvider:
if hasattr(self._solar_conditions, key): if hasattr(self._solar_conditions, key):
setattr(self._solar_conditions, key, value) setattr(self._solar_conditions, key, value)
self._solar_conditions.infer_descriptions() self._solar_conditions.infer_descriptions()
DATA_STORE.solar_conditions.store()
+11 -3
View File
@@ -4,6 +4,7 @@ from datetime import datetime
import pytz import pytz
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.enums import Mode, SIGRefType
from core.url_data_cache import URLDataCache from core.url_data_cache import URLDataCache
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
@@ -65,7 +66,7 @@ class GMA(HTTPSpotProvider):
if (source_spot["QRG"] != "" and source_spot["QRG"] != "QRT") if (source_spot["QRG"] != "" and source_spot["QRG"] != "QRT")
else None, else None,
# Filter out some weird mode strings # Filter out some weird mode strings
mode=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"], comment=source_spot["TEXT"],
sig_refs=[ sig_refs=[
SIGRef( SIGRef(
@@ -113,24 +114,31 @@ class GMA(HTTPSpotProvider):
match ref_info["reftype"]: match ref_info["reftype"]:
case "Summit": case "Summit":
spot.sig_refs[0].sig = "GMA" spot.sig_refs[0].sig = "GMA"
spot.sig_refs[0].ref_type = SIGRefType.SUMMIT
spot.sig = "GMA" spot.sig = "GMA"
case "IOTA Island": case "IOTA Island":
spot.sig_refs[0].sig = "IOTA" spot.sig_refs[0].sig = "IOTA"
spot.sig_refs[0].ref_type = SIGRefType.ISLAND
spot.sig = "IOTA" spot.sig = "IOTA"
case "GMA Island": case "GMA Island":
spot.sig_refs[0].sig = "GMA Island" spot.sig_refs[0].sig = "GMA Islands"
spot.sig = "GMA Island" spot.sig_refs[0].ref_type = SIGRefType.ISLAND
spot.sig = "GMA Islands"
case "Lighthouse (ILLW)": case "Lighthouse (ILLW)":
spot.sig_refs[0].sig = "ILLW" spot.sig_refs[0].sig = "ILLW"
spot.sig_refs[0].ref_type = SIGRefType.LIGHTHOUSE
spot.sig = "ILLW" spot.sig = "ILLW"
case "Lighthouse (ARLHS)": case "Lighthouse (ARLHS)":
spot.sig_refs[0].sig = "ARLHS" spot.sig_refs[0].sig = "ARLHS"
spot.sig_refs[0].ref_type = SIGRefType.LIGHTHOUSE
spot.sig = "ARLHS" spot.sig = "ARLHS"
case "Castle": case "Castle":
spot.sig_refs[0].sig = "WCA" spot.sig_refs[0].sig = "WCA"
spot.sig_refs[0].ref_type = SIGRefType.CASTLE
spot.sig = "WCA" spot.sig = "WCA"
case "Mill": case "Mill":
spot.sig_refs[0].sig = "MOTA" spot.sig_refs[0].sig = "MOTA"
spot.sig_refs[0].ref_type = SIGRefType.MILL
spot.sig = "MOTA" spot.sig = "MOTA"
case _: case _:
logger.warning( logger.warning(
+3 -1
View File
@@ -7,6 +7,7 @@ import requests
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.enums import Mode, SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -59,7 +60,7 @@ class HEMA(HTTPSpotProvider):
dx_call=spot_items[2].upper(), dx_call=spot_items[2].upper(),
de_call=spotter_comment_match.group(1).upper(), de_call=spotter_comment_match.group(1).upper(),
freq=float(freq_mode_match.group(1)) * 1000000, 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), comment=spotter_comment_match.group(2),
sig="HEMA", sig="HEMA",
sig_refs=[ sig_refs=[
@@ -69,6 +70,7 @@ class HEMA(HTTPSpotProvider):
name=spot_items[4], name=spot_items[4],
latitude=float(spot_items[7]), latitude=float(spot_items[7]),
longitude=float(spot_items[8]), longitude=float(spot_items[8]),
ref_type=SIGRefType.SUMMIT,
) )
], ],
time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M") time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M")
+3 -1
View File
@@ -1,5 +1,6 @@
from datetime import datetime from datetime import datetime
from core.enums import Mode, SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -31,7 +32,7 @@ class LLOTA(HTTPSpotProvider):
dx_call=source_spot["callsign"].upper(), dx_call=source_spot["callsign"].upper(),
de_call=spotter.upper() if spotter else None, de_call=spotter.upper() if spotter else None,
freq=float(source_spot["frequency"]) * 1000000, freq=float(source_spot["frequency"]) * 1000000,
mode=source_spot["mode"].upper(), mode=Mode.from_name(source_spot["mode"].upper()),
comment=comment, comment=comment,
sig="LLOTA", sig="LLOTA",
sig_refs=[ sig_refs=[
@@ -39,6 +40,7 @@ class LLOTA(HTTPSpotProvider):
id=source_spot["reference"], id=source_spot["reference"],
sig="LLOTA", sig="LLOTA",
name=source_spot["reference_name"], name=source_spot["reference_name"],
ref_type=SIGRefType.LAKE,
) )
], ],
time=datetime.fromisoformat(source_spot["updated_at"].replace("Z", "+00:00")).timestamp(), time=datetime.fromisoformat(source_spot["updated_at"].replace("Z", "+00:00")).timestamp(),
+3 -2
View File
@@ -6,6 +6,7 @@ import pytz
import requests import requests
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.enums import Mode
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -19,7 +20,6 @@ class ParksNPeaks(HTTPSpotProvider):
POLL_INTERVAL_SEC = 120 POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://www.parksnpeaks.org/api/ALL" SPOTS_URL = "https://www.parksnpeaks.org/api/ALL"
SUBMIT_URL = "https://www.parksnpeaks.org/api/SPOT/" SUBMIT_URL = "https://www.parksnpeaks.org/api/SPOT/"
SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv"
SUBMITTABLE_SIGS = [ SUBMITTABLE_SIGS = [
"POTA", "POTA",
"SOTA", "SOTA",
@@ -52,7 +52,7 @@ class ParksNPeaks(HTTPSpotProvider):
if (source_spot["actFreq"] != "") if (source_spot["actFreq"] != "")
else None, else None,
# Seen PNP spots with empty frequency, and with comma-separated thousands digits # 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"], comment=source_spot["actComments"],
time=datetime.strptime(source_spot["actTime"], "%Y-%m-%d %H:%M:%S") time=datetime.strptime(source_spot["actTime"], "%Y-%m-%d %H:%M:%S")
.replace(tzinfo=pytz.UTC) .replace(tzinfo=pytz.UTC)
@@ -87,6 +87,7 @@ class ParksNPeaks(HTTPSpotProvider):
"POTA", "POTA",
"SOTA", "SOTA",
"WWFF", "WWFF",
"HEMA",
"SIOTA", "SIOTA",
"ZLOTA", "ZLOTA",
"KRMNPA", "KRMNPA",
+4 -2
View File
@@ -4,6 +4,7 @@ import pytz
import requests import requests
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.enums import Mode, SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -29,8 +30,8 @@ class POTA(HTTPSpotProvider):
source_id=source_spot["spotId"], source_id=source_spot["spotId"],
dx_call=source_spot["activator"].upper(), dx_call=source_spot["activator"].upper(),
de_call=source_spot["spotter"].upper(), de_call=source_spot["spotter"].upper(),
freq=float(source_spot["frequency"]) * 1000, freq=float(source_spot["frequency"]) * 1000 if source_spot["frequency"] != "INVALID" else None,
mode=source_spot["mode"].upper(), mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["comments"], comment=source_spot["comments"],
sig="POTA", sig="POTA",
sig_refs=[ sig_refs=[
@@ -40,6 +41,7 @@ class POTA(HTTPSpotProvider):
name=source_spot["name"], name=source_spot["name"],
latitude=source_spot["latitude"], latitude=source_spot["latitude"],
longitude=source_spot["longitude"], longitude=source_spot["longitude"],
ref_type=SIGRefType.PARK
) )
], ],
time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S") time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S")
+5 -8
View File
@@ -4,7 +4,8 @@ from datetime import datetime
import requests import requests
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout 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.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -53,7 +54,7 @@ class SOTA(HTTPSpotProvider):
if (source_spot["frequency"] is not None) if (source_spot["frequency"] is not None)
else None, else None,
# Seen SOTA spots with no frequency! # Seen SOTA spots with no frequency!
mode=source_spot["mode"].upper(), mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["comments"], comment=source_spot["comments"],
sig="SOTA", sig="SOTA",
sig_refs=[ sig_refs=[
@@ -64,6 +65,7 @@ class SOTA(HTTPSpotProvider):
latitude=source_spot["latitude"], latitude=source_spot["latitude"],
longitude=source_spot["longitude"], longitude=source_spot["longitude"],
activation_score=source_spot["points"], activation_score=source_spot["points"],
ref_type=SIGRefType.SUMMIT,
) )
], ],
dx_latitude=source_spot["latitude"], 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 # https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js
mode = spot.mode mode = spot.mode
if mode and mode not in self.VALID_MODES: if mode and mode not in self.VALID_MODES:
if mode in SSB_SUB_MODES: mode = "Data"
mode = "SSB"
elif mode in DV_SUB_MODES:
mode = "DV"
else:
mode = "Data"
body = { body = {
"activatorCallsign": spot.dx_call, "activatorCallsign": spot.dx_call,
+1 -1
View File
@@ -65,7 +65,7 @@ class SSESpotProvider(SpotProvider):
self._url, self._url,
headers=HTTP_HEADERS, headers=HTTP_HEADERS,
latest_event_id=self._last_event_id, latest_event_id=self._last_event_id,
timeout=10, timeout=30,
on_open=self._on_open, on_open=self._on_open,
on_error=self._on_error, on_error=self._on_error,
) as event_source: ) as event_source:
+6 -6
View File
@@ -3,7 +3,8 @@ from datetime import datetime
import requests 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.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider 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 # No separate spotter callsign, assume all spots are self-spots
de_call=source_spot["call_sign"].upper(), de_call=source_spot["call_sign"].upper(),
freq=freq, freq=freq,
mode=source_spot["mode"].upper(), mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["notes"], comment=source_spot["notes"],
sig="Tiles", 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. # 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"], name=source_spot["maidenhead_grid"],
latitude=source_spot["latitude"], latitude=source_spot["latitude"],
longitude=source_spot["longitude"], longitude=source_spot["longitude"],
ref_type=SIGRefType.GRID
) )
], ],
time=datetime.fromisoformat(source_spot["created_at"].replace("Z", "+00:00")).timestamp(), time=datetime.fromisoformat(source_spot["created_at"].replace("Z", "+00:00")).timestamp(),
dx_grid=source_spot["maidenhead_grid"], dx_grid=source_spot["maidenhead_grid"],
dx_latitude=source_spot["latitude"], dx_latitude=source_spot["latitude"],
dx_longitude=source_spot["longitude"], 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 # 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: if spot.mode:
mode = spot.mode mode = spot.mode
if mode not in self.VALID_MODES: if mode not in self.VALID_MODES:
if mode in SSB_SUB_MODES: if mode == "OLIVIA":
mode = "SSB"
elif mode == "OLIVIA":
mode = "Olivia" mode = "Olivia"
elif mode == "JS8": elif mode == "JS8":
mode = "JS8Call" mode = "JS8Call"
+7 -4
View File
@@ -1,6 +1,9 @@
import json import json
from datetime import datetime from datetime import datetime
import pytz
from core.enums import SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -32,10 +35,10 @@ class Towers(HTTPSpotProvider):
freq=likely_freq, freq=likely_freq,
comment=source_spot["comment"], comment=source_spot["comment"],
sig="Towers", sig="Towers",
sig_refs=[SIGRef(id=source_spot["ref"], sig="Towers")], sig_refs=[SIGRef(id=source_spot["ref"], sig="Towers", ref_type=SIGRefType.TOWER)],
time=datetime.strptime( time=datetime.strptime(response_json["updated"][:10] + source_spot["time"], "%Y-%m-%d%H:%M")
response_json["updated"][:10] + source_spot["time"], "%Y-%m-%d%H:%M" .replace(tzinfo=pytz.utc)
).timestamp(), .timestamp(),
) )
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do # Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
+2 -1
View File
@@ -3,6 +3,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import Mode
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -77,7 +78,7 @@ class UKPacketNet(HTTPSpotProvider):
dx_call=heard["callsign"].upper(), dx_call=heard["callsign"].upper(),
de_call=node["callsign"].upper(), de_call=node["callsign"].upper(),
freq=freq, freq=freq,
mode="PKT", mode=Mode.PKT,
comment=comment, comment=comment,
time=datetime.strptime(heard["lastHeard"], "%Y-%m-%d %H:%M:%S") time=datetime.strptime(heard["lastHeard"], "%Y-%m-%d %H:%M:%S")
.replace(tzinfo=pytz.UTC) .replace(tzinfo=pytz.UTC)
+6 -3
View File
@@ -8,6 +8,7 @@ import pytz
from rss_parser import Parser from rss_parser import Parser
from rss_parser.models.rss import RSS from rss_parser.models.rss import RSS
from core.enums import Mode, SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -76,7 +77,9 @@ class WOTA(HTTPSpotProvider):
except Exception: except Exception:
logger.warning(f"Could not parse WOTA spot description: {source_spot.description}") logger.warning(f"Could not parse WOTA spot description: {source_spot.description}")
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 # Convert to our spot format
spot = Spot( spot = Spot(
@@ -85,10 +88,10 @@ class WOTA(HTTPSpotProvider):
dx_call=dx_call, dx_call=dx_call,
de_call=spotter, de_call=spotter,
freq=freq_hz, freq=freq_hz,
mode=mode, mode=Mode.from_name(mode),
comment=comment, comment=comment,
sig="WOTA", 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(), time=time.timestamp(),
) )
+3 -1
View File
@@ -1,6 +1,7 @@
import json import json
from datetime import datetime from datetime import datetime
from core.enums import Mode, SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from providers.spot.sse_spot_provider import SSESpotProvider from providers.spot.sse_spot_provider import SSESpotProvider
@@ -26,6 +27,7 @@ class WWBOTA(SSESpotProvider):
name=ref["name"], name=ref["name"],
latitude=ref["lat"], latitude=ref["lat"],
longitude=ref["long"], longitude=ref["long"],
ref_type=SIGRefType.BUNKER,
) )
refs.append(sigref) refs.append(sigref)
@@ -34,7 +36,7 @@ class WWBOTA(SSESpotProvider):
dx_call=source_spot["call"].upper(), dx_call=source_spot["call"].upper(),
de_call=source_spot["spotter"].upper(), de_call=source_spot["spotter"].upper(),
freq=float(source_spot["freq"]) * 1000000, freq=float(source_spot["freq"]) * 1000000,
mode=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"], comment=source_spot["comment"],
sig="WWBOTA", sig="WWBOTA",
sig_refs=refs, sig_refs=refs,
+3 -1
View File
@@ -2,6 +2,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import Mode, SIGRefType
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -27,7 +28,7 @@ class WWFF(HTTPSpotProvider):
dx_call=source_spot["activator"].upper(), dx_call=source_spot["activator"].upper(),
de_call=source_spot["spotter"].upper(), de_call=source_spot["spotter"].upper(),
freq=float(source_spot["frequency_khz"]) * 1000, freq=float(source_spot["frequency_khz"]) * 1000,
mode=source_spot["mode"].upper(), mode=Mode.from_name(source_spot["mode"].upper()),
comment=source_spot["remarks"], comment=source_spot["remarks"],
sig="WWFF", sig="WWFF",
sig_refs=[ sig_refs=[
@@ -37,6 +38,7 @@ class WWFF(HTTPSpotProvider):
name=source_spot["reference_name"], name=source_spot["reference_name"],
latitude=source_spot["latitude"], latitude=source_spot["latitude"],
longitude=source_spot["longitude"], longitude=source_spot["longitude"],
ref_type=SIGRefType.PARK
) )
], ],
time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC).timestamp(), time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC).timestamp(),
+2 -1
View File
@@ -3,6 +3,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import Mode
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from providers.spot.websocket_spot_provider import WebsocketSpotProvider from providers.spot.websocket_spot_provider import WebsocketSpotProvider
@@ -34,7 +35,7 @@ class XOTA(WebsocketSpotProvider):
source_id=source_spot["id"], source_id=source_spot["id"],
dx_call=source_spot["stationCallSign"].upper(), dx_call=source_spot["stationCallSign"].upper(),
freq=float(source_spot["freq"]) * 1000, freq=float(source_spot["freq"]) * 1000,
mode=source_spot["mode"].upper(), mode=Mode.from_name(source_spot["mode"].upper()),
sig=self.SIG, sig=self.SIG,
sig_refs=[ sig_refs=[
SIGRef( SIGRef(
+2 -1
View File
@@ -2,6 +2,7 @@ from datetime import datetime
import pytz import pytz
from core.enums import Mode
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from providers.spot.http_spot_provider import HTTPSpotProvider from providers.spot.http_spot_provider import HTTPSpotProvider
@@ -32,7 +33,7 @@ class ZLOTA(HTTPSpotProvider):
dx_call=source_spot["activator"].upper(), dx_call=source_spot["activator"].upper(),
de_call=source_spot["spotter"].upper(), de_call=source_spot["spotter"].upper(),
freq=freq_hz, freq=freq_hz,
mode=source_spot["mode"].upper().strip(), mode=Mode.from_name(source_spot["mode"].upper().strip()),
comment=source_spot["comments"], comment=source_spot["comments"],
sig="ZLOTA", sig="ZLOTA",
sig_refs=[ sig_refs=[
+3 -3
View File
@@ -1,8 +1,8 @@
[project] [project]
name = "Spothole" name = "spothole"
version = "2.0.1" version = "2.1-pre"
authors = [ authors = [
{ name="Ian Renton", email="ian@ianrenton.com" }, { name = "Ian Renton", email = "ian@ianrenton.com" },
] ]
description = "Spothole is a utility to aggregate \"spots\" from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data." description = "Spothole is a utility to aggregate \"spots\" from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data."
readme = "README.md" readme = "README.md"
+7 -2
View File
@@ -1,6 +1,6 @@
pyyaml~=6.0.3 pyyaml~=6.0.3
requests-cache~=1.2.1 requests-cache~=1.2.1
pyhamtools~=0.12.0 pyhamtools~=0.13.2
telnetlib3~=2.0.8 telnetlib3~=2.0.8
pytz~=2025.2 pytz~=2025.2
requests~=2.32.4 requests~=2.32.4
@@ -16,8 +16,13 @@ beautifulsoup4~=4.14.2
websocket-client~=1.8.0 websocket-client~=1.8.0
tornado~=6.4.2 tornado~=6.4.2
tornado_eventsource~=3.0.0 tornado_eventsource~=3.0.0
pandas~=3.0.0
geopandas~=0.13.2 geopandas~=0.13.2
simplejson~=4.1.1 simplejson~=4.1.1
cachetools~=7.1.6 cachetools~=7.1.6
fastkml~=1.4.0 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
+8 -2
View File
@@ -5,12 +5,13 @@ import signal
import sys import sys
from core.cleanup import CLEANUP_TIMER from core.cleanup import CLEANUP_TIMER
from core.config import LOG_LEVEL, SERVER_OWNER_CALLSIGN from core.config import LOG_LEVEL, SERVER_OWNER_CALLSIGN, TELNET_SERVER_ENABLED, TELNET_SERVER_PORT
from core.constants import SOFTWARE_VERSION from core.constants import SOFTWARE_VERSION
from core.data_providers import DATA_PROVIDERS from core.data_providers import DATA_PROVIDERS
from core.data_store import DATA_STORE from core.data_store import DATA_STORE
from core.status_reporter import StatusReporter from core.status_reporter import StatusReporter
from server.webserver import WEB_SERVER from telnetserver.telnetserver import TELNET_SERVER
from webserver.webserver import WEB_SERVER
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -20,6 +21,7 @@ def shutdown(_signum=None, _frame=None):
logger.info("Stopping program...") logger.info("Stopping program...")
WEB_SERVER.stop() WEB_SERVER.stop()
TELNET_SERVER.stop()
DATA_PROVIDERS.stop() DATA_PROVIDERS.stop()
CLEANUP_TIMER.stop() CLEANUP_TIMER.stop()
DATA_STORE.close() DATA_STORE.close()
@@ -60,6 +62,10 @@ if __name__ == "__main__":
# Set up the web server # Set up the web server
WEB_SERVER.setup() WEB_SERVER.setup()
# Run the telnet server
if TELNET_SERVER_ENABLED:
TELNET_SERVER.start(port=TELNET_SERVER_PORT)
# Run the web server. This is the blocking call that keeps the application running in the main thread, so this must # Run the web server. This is the blocking call that keeps the application running in the main thread, so this must
# be the last thing we do. web_server.stop() triggers an await condition in the web server which finishes the main # be the last thing we do. web_server.stop() triggers an await condition in the web server which finishes the main
# thread. # thread.
+156 -21
View File
@@ -15,6 +15,19 @@ info:
## Changelog ## Changelog
### 2.1
* Added AMSAT, EME, 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
* Added the ability to get only certain fields of spots and alerts from the API by using the `fields` query parameter.
* Replace `last_page_access` with `page_requests_per_hour` and `last_api_access` with `api_requests_per_hour` in the web server stats.
* Added `sse_client_count` to the `webserver` stats, and a new `telnet` object with `client_count`.
### 2.0 ### 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. * **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.
@@ -130,6 +143,7 @@ paths:
- $ref: '#/components/parameters/SpotTextIncludes' - $ref: '#/components/parameters/SpotTextIncludes'
- $ref: '#/components/parameters/SpotNeedsGoodLocation' - $ref: '#/components/parameters/SpotNeedsGoodLocation'
- $ref: '#/components/parameters/SpotAllowQrt' - $ref: '#/components/parameters/SpotAllowQrt'
- $ref: '#/components/parameters/SpotFields'
- $ref: '#/components/parameters/QrzUsername' - $ref: '#/components/parameters/QrzUsername'
- $ref: '#/components/parameters/QrzPassword' - $ref: '#/components/parameters/QrzPassword'
- $ref: '#/components/parameters/QrzSessionKey' - $ref: '#/components/parameters/QrzSessionKey'
@@ -170,6 +184,7 @@ paths:
- $ref: '#/components/parameters/SpotTextIncludes' - $ref: '#/components/parameters/SpotTextIncludes'
- $ref: '#/components/parameters/SpotNeedsGoodLocation' - $ref: '#/components/parameters/SpotNeedsGoodLocation'
- $ref: '#/components/parameters/SpotAllowQrt' - $ref: '#/components/parameters/SpotAllowQrt'
- $ref: '#/components/parameters/SpotFields'
- $ref: '#/components/parameters/QrzUsername' - $ref: '#/components/parameters/QrzUsername'
- $ref: '#/components/parameters/QrzPassword' - $ref: '#/components/parameters/QrzPassword'
- $ref: '#/components/parameters/QrzSessionKey' - $ref: '#/components/parameters/QrzSessionKey'
@@ -201,11 +216,13 @@ paths:
- $ref: '#/components/parameters/AlertReceivedSince' - $ref: '#/components/parameters/AlertReceivedSince'
- $ref: '#/components/parameters/AlertMaxDuration' - $ref: '#/components/parameters/AlertMaxDuration'
- $ref: '#/components/parameters/AlertDxpeditionsSkipMaxDurationCheck' - $ref: '#/components/parameters/AlertDxpeditionsSkipMaxDurationCheck'
- $ref: '#/components/parameters/AlertContestsSkipMaxDurationCheck'
- $ref: '#/components/parameters/AlertSource' - $ref: '#/components/parameters/AlertSource'
- $ref: '#/components/parameters/AlertSig' - $ref: '#/components/parameters/AlertSig'
- $ref: '#/components/parameters/AlertDxContinent' - $ref: '#/components/parameters/AlertDxContinent'
- $ref: '#/components/parameters/AlertDxCallIncludes' - $ref: '#/components/parameters/AlertDxCallIncludes'
- $ref: '#/components/parameters/AlertTextIncludes' - $ref: '#/components/parameters/AlertTextIncludes'
- $ref: '#/components/parameters/AlertFields'
- $ref: '#/components/parameters/QrzUsername' - $ref: '#/components/parameters/QrzUsername'
- $ref: '#/components/parameters/QrzPassword' - $ref: '#/components/parameters/QrzPassword'
- $ref: '#/components/parameters/QrzSessionKey' - $ref: '#/components/parameters/QrzSessionKey'
@@ -235,11 +252,13 @@ paths:
parameters: parameters:
- $ref: '#/components/parameters/AlertMaxDuration' - $ref: '#/components/parameters/AlertMaxDuration'
- $ref: '#/components/parameters/AlertDxpeditionsSkipMaxDurationCheck' - $ref: '#/components/parameters/AlertDxpeditionsSkipMaxDurationCheck'
- $ref: '#/components/parameters/AlertContestsSkipMaxDurationCheck'
- $ref: '#/components/parameters/AlertSource' - $ref: '#/components/parameters/AlertSource'
- $ref: '#/components/parameters/AlertSig' - $ref: '#/components/parameters/AlertSig'
- $ref: '#/components/parameters/AlertDxContinent' - $ref: '#/components/parameters/AlertDxContinent'
- $ref: '#/components/parameters/AlertDxCallIncludes' - $ref: '#/components/parameters/AlertDxCallIncludes'
- $ref: '#/components/parameters/AlertTextIncludes' - $ref: '#/components/parameters/AlertTextIncludes'
- $ref: '#/components/parameters/AlertFields'
- $ref: '#/components/parameters/QrzUsername' - $ref: '#/components/parameters/QrzUsername'
- $ref: '#/components/parameters/QrzPassword' - $ref: '#/components/parameters/QrzPassword'
- $ref: '#/components/parameters/QrzSessionKey' - $ref: '#/components/parameters/QrzSessionKey'
@@ -645,6 +664,16 @@ components:
schema: schema:
type: boolean type: boolean
default: true default: true
SpotFields:
name: fields
in: query
description: >
Filter the fields you receive in each spot, to conserve data bandwidth for constrained applications.
Supply a comma-separated list of the fields you want to receive, using the names of the fields returned by the
`/spots` call, e.g. `id,dx_call,freq,mode,time`. If the "fields" parameter is not supplied, all fields will be
included in the spot data.
schema:
type: string
AlertMaxDuration: AlertMaxDuration:
name: max_duration name: max_duration
in: query in: query
@@ -653,8 +682,8 @@ components:
time minus start time, if end time is set, otherwise the activation is assumed to be short and time minus start time, if end time is set, otherwise the activation is assumed to be short and
therefore to always pass this check. This is useful to filter out people who alert POTA therefore to always pass this check. This is useful to filter out people who alert POTA
activations lasting months or even years, but note it will also include multi-day or multi-week activations lasting months or even years, but note it will also include multi-day or multi-week
DXpeditions that you might otherwise be interested in. See the DXpeditions or contests that you might otherwise be interested in. See the
dxpeditions_skip_max_duration_check parameter for the workaround. dxpeditions_skip_max_duration_check and contests_skip_max_duration_check parameters for the workaround.
schema: schema:
type: integer type: integer
AlertDxpeditionsSkipMaxDurationCheck: AlertDxpeditionsSkipMaxDurationCheck:
@@ -667,6 +696,16 @@ components:
on the air most of the time. on the air most of the time.
schema: schema:
type: boolean type: boolean
AlertContestsSkipMaxDurationCheck:
name: contests_skip_max_duration_check
in: query
description: >
Return contest alerts even if they last longer than max_duration. This allows the user to
filter out multi-day/multi-week POTA alerts where the operator likely won't be on the air most
of the time, but keep multi-day/multi-week contests where contesters likely *will* be
on the air most of the time.
schema:
type: boolean
AlertSource: AlertSource:
name: source name: source
in: query in: query
@@ -773,6 +812,16 @@ components:
you will get all the more recent alerts back, without duplicating the previous latest spot. you will get all the more recent alerts back, without duplicating the previous latest spot.
schema: schema:
type: number type: number
AlertFields:
name: fields
in: query
description: >
Filter the fields you receive in each alert, to conserve data bandwidth for constrained applications.
Supply a comma-separated list of the fields you want to receive, using the names of the fields returned by the
`/alerts` call, e.g. `id,dx_calls,freqs_modes,start_time`. If the "fields" parameter is not supplied, all fields
will be included in the alert data.
schema:
type: string
CallParam: CallParam:
name: call name: call
in: query in: query
@@ -852,9 +901,22 @@ components:
- WAB - WAB
- WAI - WAI
- DME - DME
- FEA
- DTMBA
- BIWOTA
- COTA
- PGA
- Toilets - Toilets
example: POTA example: POTA
SIGType:
type: string
enum:
- WORLDWIDE
- REGIONAL
- EVENT
example: WORLDWIDE
SIGNameIncludingNoSIG: SIGNameIncludingNoSIG:
oneOf: oneOf:
- $ref: "#/components/schemas/SIGName" - $ref: "#/components/schemas/SIGName"
@@ -862,6 +924,36 @@ components:
enum: [ NO_SIG ] enum: [ NO_SIG ]
example: POTA 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: Continent:
type: string type: string
enum: enum:
@@ -911,15 +1003,9 @@ components:
- CW - CW
- PHONE - PHONE
- SSB - SSB
- USB
- LSB
- AM - AM
- FM - FM
- DV - DV
- DMR
- DSTAR
- C4FM
- M17
- DATA - DATA
- FT8 - FT8
- FT4 - FT4
@@ -976,7 +1062,7 @@ components:
- NONE - NONE
example: SPOT example: SPOT
LocationSourceForAlert: LocationSourceForCallsign:
type: string type: string
enum: enum:
- "HOME QTH" - "HOME QTH"
@@ -999,9 +1085,8 @@ components:
description: SIG reference name description: SIG reference name
example: Null Country Park example: Null Country Park
ref_type: ref_type:
type: string
description: SIG reference type description: SIG reference type
example: Park $ref: "#/components/schemas/SIGRefType"
url: url:
type: string type: string
description: SIG reference URL, which the user can look up for more information description: SIG reference URL, which the user can look up for more information
@@ -1241,6 +1326,10 @@ components:
Propagation mode, if known. This is only populated when the upstream spot specifically states it; Spothole 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. does not try to determine it using its own algorithm.
$ref: "#/components/schemas/PropagationMode" $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: SpotSubmission:
@@ -1394,6 +1483,13 @@ components:
items: items:
$ref: '#/components/schemas/SIGRef' $ref: '#/components/schemas/SIGRef'
description: SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO description: SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
alert_type:
description: "The type of alert this is: xOTA, DXpedition, or Contest."
$ref: "#/components/schemas/AlertType"
url:
type: string
description: A URL linking to more information about the alert, e.g. DXpedition or contest info.
example: "https://www.rsgbcc.org/cgi-bin/contest_rules.pl?year=2026&contest=144backpack1"
source: source:
type: string type: string
description: Where we got the alert from. description: Where we got the alert from.
@@ -1402,6 +1498,10 @@ components:
type: string type: string
description: The ID the source gave it, if any. description: The ID the source gave it, if any.
example: "GUID-123456" 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: AlertStream:
@@ -1486,6 +1586,12 @@ components:
type: string type: string
description: The full name of the SIG description: The full name of the SIG
example: Parks on the Air 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: comment_names:
type: array type: array
description: > description: >
@@ -1502,6 +1608,24 @@ components:
Regex that matches this SIG's reference IDs. Generally for Spothole's own internal use, Regex that matches this SIG's reference IDs. Generally for Spothole's own internal use,
clients probably won't need this. clients probably won't need this.
example: "[A-Z]{2}\\-\\d+" 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: SolarConditions:
type: object type: object
@@ -1978,14 +2102,25 @@ components:
type: string type: string
description: The status of the web server description: The status of the web server
example: OK example: OK
last_page_access: page_requests_per_hour:
type: number type: integer
description: The last time a page was accessed on the web server, UTC seconds since UNIX epoch. description: The number of page requests handled by the web server in the last hour
example: 1759579508 example: 123
last_api_access: api_requests_per_hour:
type: number type: integer
description: The last time an API endpoint was accessed on the web server, UTC seconds since UNIX epoch. description: The number of API requests handled by the web server in the last hour
example: 1759579508 example: 123
sse_client_count:
type: integer
description: The number of clients currently connected to SSE streams
example: 5
"telnet":
type: object
properties:
client_count:
type: integer
description: The number of clients currently connected to the telnet server
example: 2
spot_providers: spot_providers:
type: array type: array
description: An array of all the spot providers. description: An array of all the spot providers.
@@ -2030,7 +2165,7 @@ components:
description: An array of all the supported modes. description: An array of all the supported modes.
items: items:
type: string type: string
example: "LSB" example: "SSB"
mode_types: mode_types:
type: array type: array
description: An array of all the supported mode types. description: An array of all the supported mode types.
@@ -2174,7 +2309,7 @@ components:
Where we got the location (grid/latitude/longitude) from. Unlike a spot where we might 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 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. a location based purely on DXCC, or nothing.
$ref: "#/components/schemas/LocationSourceForAlert" $ref: "#/components/schemas/LocationSourceForCallsign"
GridLookup: GridLookup:
type: object type: object
+47 -37
View File
@@ -13,7 +13,6 @@ function loadAlerts() {
url: '/api/v2/alerts' + buildQueryString(), dataType: 'json', success: function (jsonData) { url: '/api/v2/alerts' + buildQueryString(), dataType: 'json', success: function (jsonData) {
// Store last updated time // Store last updated time
lastUpdateTime = moment.utc(); lastUpdateTime = moment.utc();
updateRefreshDisplay();
// Store data // Store data
alerts = jsonData; alerts = jsonData;
// Update table // Update table
@@ -38,6 +37,9 @@ function buildQueryString() {
if ($("#dxpeditions_skip_max_duration_check")[0].checked) { if ($("#dxpeditions_skip_max_duration_check")[0].checked) {
str = str + "&dxpeditions_skip_max_duration_check=true"; str = str + "&dxpeditions_skip_max_duration_check=true";
} }
if ($("#contests_skip_max_duration_check")[0].checked) {
str = str + "&contests_skip_max_duration_check=true";
}
return str; return str;
} }
@@ -52,7 +54,7 @@ function updateTable() {
const showDX = $("#tableShowDX")[0].checked; const showDX = $("#tableShowDX")[0].checked;
const showFreqsModes = $("#tableShowFreqsModes")[0].checked; const showFreqsModes = $("#tableShowFreqsModes")[0].checked;
const showComment = $("#tableShowComment")[0].checked; const showComment = $("#tableShowComment")[0].checked;
const showSource = $("#tableShowSource")[0].checked; const showType = $("#tableShowType")[0].checked;
const showRef = $("#tableShowRef")[0].checked; const showRef = $("#tableShowRef")[0].checked;
// Populate table with headers // Populate table with headers
@@ -73,8 +75,8 @@ function updateTable() {
if (showComment) { if (showComment) {
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Comment</th>`); table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Comment</th>`);
} }
if (showSource) { if (showType) {
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Source</th>`); table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Type</th>`);
} }
if (showRef) { if (showRef) {
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Ref.</th>`); table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Ref.</th>`);
@@ -149,7 +151,7 @@ function addAlertRowsToTable(tbody, alerts) {
const showDX = $("#tableShowDX")[0].checked; const showDX = $("#tableShowDX")[0].checked;
const showFreqsModes = $("#tableShowFreqsModes")[0].checked; const showFreqsModes = $("#tableShowFreqsModes")[0].checked;
const showComment = $("#tableShowComment")[0].checked; const showComment = $("#tableShowComment")[0].checked;
const showSource = $("#tableShowSource")[0].checked; const showType = $("#tableShowType")[0].checked;
const showRef = $("#tableShowRef")[0].checked; const showRef = $("#tableShowRef")[0].checked;
// Get times for the alert, and convert to local time if necessary. // Get times for the alert, and convert to local time if necessary.
@@ -208,10 +210,14 @@ function addAlertRowsToTable(tbody, alerts) {
if (a["dx_calls"] != null) { if (a["dx_calls"] != null) {
dx_calls_html = a["dx_calls"].map(call => `<a class='dx-link' href='https://qrz.com/db/${call}' target='_new'>${call}</a>`).join(", "); dx_calls_html = a["dx_calls"].map(call => `<a class='dx-link' href='https://qrz.com/db/${call}' target='_new'>${call}</a>`).join(", ");
} }
if (dx_calls_html === "" && a["alert_type"] === "CONTEST") {
// Contest = true and no DX callsigns, so display "Contest"
dx_calls_html = "Contest"
}
// Format DXpedition country // Format DXpedition country
let dx_country_html = ""; let dx_country_html = "";
if (a["is_dxpedition"] === true && a["dx_country"] != null && a["dx_country"] !== "") { if (a["alert_type"] === "DXPEDITION" && a["dx_country"] != null && a["dx_country"] !== "") {
dx_country_html = `<br/>${a["dx_country"]}`; dx_country_html = `<br/>${a["dx_country"]}`;
} }
@@ -227,10 +233,37 @@ function addAlertRowsToTable(tbody, alerts) {
commentText = escapeHtml(a["comment"]); commentText = escapeHtml(a["comment"]);
} }
// Sig or fallback to source // Format extra text, like URL and attribution
let sigSourceText = a["source"]; let subComment = a["url"] != null || a["source"] === "NG3K" || a["source"] === "WA7BNM Contest Calendar";
if (a["sig"]) { if (subComment) {
sigSourceText = a["sig"]; let subCommentText = ""
if (a["url"] != null) {
subCommentText += `<a href="${escapeHtml(a['url'])}" target="_new" style="text-decoration: none">More info</a>`;
}
if ((a["source"] === "NG3K" || a["source"] === "WA7BNM Contest Calendar")) {
if (subCommentText !== "") {
subCommentText += " | ";
}
subCommentText += `From ${a["source"]}`;
}
commentText += `<div class="mt-2 small text-secondary">${subCommentText}</div>`;
}
// Type, SIG or fallback to source
let sigTypeText = a["source"];
if (a["alert_type"] === "CONTEST") {
sigTypeText = "Contest";
} else if (a["alert_type"] === "DXPEDITION") {
sigTypeText = "DXpedition";
} else if (a["alert_type"] === "SATELLITE") {
sigTypeText = "Satellite";
} else if (a["alert_type"] === "XOTA") {
if (a["sig"]) {
sigTypeText = a["sig"];
} else {
sigTypeText = "xOTA";
}
} }
// Format sig_refs // Format sig_refs
@@ -263,8 +296,8 @@ function addAlertRowsToTable(tbody, alerts) {
if (showComment) { if (showComment) {
$tr.append(`<td class='hideonmobile'>${commentText}</td>`); $tr.append(`<td class='hideonmobile'>${commentText}</td>`);
} }
if (showSource) { if (showType) {
$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> ${sigTypeText}</td>`);
} }
if (showRef) { if (showRef) {
$tr.append(`<td class='hideonmobile'>${sig_refs}</td>`); $tr.append(`<td class='hideonmobile'>${sig_refs}</td>`);
@@ -281,8 +314,8 @@ function addAlertRowsToTable(tbody, alerts) {
} }
const $td2 = $("<td colspan='100'>"); const $td2 = $("<td colspan='100'>");
if (showSource) { if (showType) {
$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) { if (showRef) {
$td2.append(`${sig_refs} `); $td2.append(`${sig_refs} `);
@@ -331,33 +364,10 @@ function filtersUpdated() {
saveSettings(); saveSettings();
} }
// Update the refresh timing display
function updateRefreshDisplay() {
if (lastUpdateTime != null) {
let secSinceUpdate = moment.duration(moment().diff(lastUpdateTime)).asSeconds();
let count = REFRESH_INTERVAL_SEC;
let updatingString = "Updating..."
if (secSinceUpdate < REFRESH_INTERVAL_SEC) {
count = REFRESH_INTERVAL_SEC - secSinceUpdate;
let number;
if (count <= 60) {
number = count.toFixed(0);
updatingString = "<span class='nowrap'>Updating in " + number + " second" + (number !== "1" ? "s" : "") + ".</span>";
} else {
number = Math.round(count / 60.0).toFixed(0);
updatingString = "<span class='nowrap'>Updating in " + number + " minute" + (number !== "1" ? "s" : "") + ".</span>";
}
}
$("#timing-container").html("Last updated at " + lastUpdateTime.format('HH:mm') + " UTC. " + updatingString);
}
}
// Startup // Startup
$(document).ready(function () { $(document).ready(function () {
// Call loadOptions(), this will then trigger loading alerts and setting up timers. // Call loadOptions(), this will then trigger loading alerts and setting up timers.
loadOptions(); loadOptions();
// Update the refresh timing display every second
setInterval(updateRefreshDisplay, 1000);
}); });
// Reload alerts on becoming visible. This forces a refresh when used as a PWA and the user switches back to the PWA // Reload alerts on becoming visible. This forces a refresh when used as a PWA and the user switches back to the PWA
-1
View File
@@ -285,7 +285,6 @@ function callWanted(call) {
} }
} }
// Startup // Startup
$(document).ready(function () { $(document).ready(function () {
usePreferredTheme(); usePreferredTheme();
+16 -6
View File
@@ -223,7 +223,7 @@ function updateMap() {
// Get an icon for a spot, based on its band, using PSK Reporter colours, its program etc. // Get an icon for a spot, based on its band, using PSK Reporter colours, its program etc.
function getIcon(s) { function getIcon(s) {
return L.ExtraMarkers.icon({ return L.ExtraMarkers.icon({
icon: sigToIcon(s["sig"], "fa-tower-cell"), icon: s["icon"],
iconColor: bandToContrastColor(s["band"]), iconColor: bandToContrastColor(s["band"]),
markerColor: bandToColor(s["band"]), markerColor: bandToColor(s["band"]),
shape: 'circle', shape: 'circle',
@@ -243,13 +243,19 @@ function getTooltipText(s) {
dx_call = dx_call + "-" + s["dx_ssid"]; 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 // Format DX flag
let dx_flag = "<i class='fa-solid fa-globe-africa'></i>"; let dx_flag = "<i class='fa-solid fa-globe-africa'></i>";
if (dx_call == null) { if (dx_call == null) {
dx_flag = ""; dx_flag = "";
} }
if (s["dx_flag"] && s["dx_flag"] != null && s["dx_flag"] !== "") { if (s["dx_dxcc_id"] && s["dx_dxcc_id"] != null && s["dx_dxcc_id"] !== 0) {
dx_flag = s["dx_flag"]; 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 // Format the frequency
@@ -303,7 +309,7 @@ function getTooltipText(s) {
ttt += "<br/>"; ttt += "<br/>";
// Source / SIG / Ref // Source / SIG / Ref
ttt += `<span class='nowrap'><span class='icon-wrapper'><i class='fa-solid ${sigToIcon(s["sig"], "fa-tower-cell")}'></i></span>&nbsp;${sigSourceText} ${sig_refs}</span><br/>`; ttt += `<span class='nowrap'><span class='icon-wrapper'><i class='fa-solid ${s["icon"]}'></i></span>&nbsp;${sigSourceText} ${sig_refs}</span><br/>`;
// Time // Time
ttt += `<span class='icon-wrapper'><i class='fa-solid fa-clock markerPopupIcon'></i></span>&nbsp;${moment.unix(s["time"]).fromNow()}`; ttt += `<span class='icon-wrapper'><i class='fa-solid fa-clock markerPopupIcon'></i></span>&nbsp;${moment.unix(s["time"]).fromNow()}`;
@@ -394,9 +400,11 @@ function setBasemap(basemapname) {
} }
// OpenStreetMap.Mapnik.Dark is a synthetic variant that uses Mapnik tiles with a CSS filter applied // OpenStreetMap.Mapnik.Dark is a synthetic variant that uses Mapnik tiles with a CSS filter applied
const providerName = basemapname === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : basemapname; const providerName = basemapname === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : basemapname;
const isCartoDB = basemapname === "CartoDB.Voyager" || basemapname === "CartoDB.DarkMatter";
backgroundTileLayer = L.tileLayer.provider(providerName, { backgroundTileLayer = L.tileLayer.provider(providerName, {
opacity: parseFloat($("#basemapOpacity").val()), opacity: parseFloat($("#basemapOpacity").val()),
edgeBufferTiles: 1 edgeBufferTiles: 1,
key: isCartoDB ? CARTODB_API_KEY : null
}); });
backgroundTileLayer.addTo(map); backgroundTileLayer.addTo(map);
backgroundTileLayer.bringToBack(); backgroundTileLayer.bringToBack();
@@ -529,9 +537,11 @@ function setUpMap() {
// Add basemap // Add basemap
loadedBasemap = $("#basemap").val(); loadedBasemap = $("#basemap").val();
const initialProviderName = loadedBasemap === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : loadedBasemap; const initialProviderName = loadedBasemap === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : loadedBasemap;
const isCartoDB = loadedBasemap === "CartoDB.Voyager" || loadedBasemap === "CartoDB.DarkMatter";
backgroundTileLayer = L.tileLayer.provider(initialProviderName, { backgroundTileLayer = L.tileLayer.provider(initialProviderName, {
opacity: parseFloat($("#basemapOpacity").val()), opacity: parseFloat($("#basemapOpacity").val()),
edgeBufferTiles: 1 edgeBufferTiles: 1,
key: isCartoDB ? CARTODB_API_KEY : null
}); });
backgroundTileLayer.addTo(map); backgroundTileLayer.addTo(map);
backgroundTileLayer.bringToBack(); backgroundTileLayer.bringToBack();
+11 -2
View File
@@ -398,7 +398,7 @@ function createNewTableRowsForSpot(s, highlightNew) {
$tr.append(`<td class='nowrap hideonmobile'>${distanceText}</td>`); $tr.append(`<td class='nowrap hideonmobile'>${distanceText}</td>`);
} }
if (showType) { 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) { if (showRef) {
$tr.append(`<td class='hideonmobile' style='max-width: 11em;'>${sig_refs}</td>`); $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 $td2 = $("<td colspan='100'>");
const $td2floatleft = $(`<div style="float: left;">`); const $td2floatleft = $(`<div style="float: left;">`);
if (showType) { 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) { if (showRef) {
$td2floatleft.append(`${sig_refs} `); $td2floatleft.append(`${sig_refs} `);
@@ -538,6 +538,15 @@ function displayIntroBox() {
$("#intro-box-dismiss").click(function () { $("#intro-box-dismiss").click(function () {
localStorage.setItem("intro-box-dismissed", true); localStorage.setItem("intro-box-dismissed", true);
}); });
// Do the same with the "telnet" intro box, but only show it if the user has dismissed the normal intro box once,
// to avoid two boxes on first page load.
if (localStorage.getItem("intro-box-telnet-dismissed") == null && localStorage.getItem("intro-box-dismissed") != null) {
$("#intro-box-telnet").show();
}
$("#intro-box-telnet-dismiss").click(function () {
localStorage.setItem("intro-box-telnet-dismissed", true);
});
} }
// Mark a callsign-band-mode combination as worked (or unmark it). Persist this to localStorage. // Mark a callsign-band-mode combination as worked (or unmark it). Persist this to localStorage.
+22 -6
View File
@@ -40,14 +40,30 @@ function setHamHFBandToggles() {
// Generate SIGs filter card. This one is also a special case. // Generate SIGs filter card. This one is also a special case.
function generateSIGsMultiToggleFilterCard(sig_options) { function generateSIGsMultiToggleFilterCard(sig_options) {
const $grid = $('<div class="row row-cols-2 row-cols-xxl-3 g-1 mb-1">'); const $grid1 = $('<div class="row row-cols-2 g-1 mb-1">');
sig_options.forEach(o => { $("#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, ""); 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 // 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(`<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="mt-1"><a href="#" onclick="toggleFilterButtons('sig', true); return false;">All</a> &nbsp; <a href="#" onclick="toggleFilterButtons('sig', false); return false;">None</a></div>`); $("#sig-options").append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('sig', true); return false;">All</a> &nbsp; <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. // Set the mode toggles that relate to Analog Voice.
function setVoiceModeToggles() { 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 () { $(".filter-button-mode").each(function () {
$(this).prop('checked', modes.includes($(this).val().replace("filter-button-mode-", ""))); $(this).prop('checked', modes.includes($(this).val().replace("filter-button-mode-", "")));
}); });
+4 -2
View File
@@ -9,8 +9,10 @@ function loadStatus() {
$("#total-alerts").text(jsonData["num_alerts"]); $("#total-alerts").text(jsonData["num_alerts"]);
$("#web-server-status").text(jsonData["webserver"]["status"]); $("#web-server-status").text(jsonData["webserver"]["status"]);
$("#web-server-last-api").text(moment.unix(jsonData["webserver"]["last_api_access"]).utc().fromNow()); $("#web-server-api-rate").text(jsonData["webserver"]["api_requests_per_hour"] + " / hour");
$("#web-server-last-page").text(moment.unix(jsonData["webserver"]["last_page_access"]).utc().fromNow()); $("#web-server-page-rate").text(jsonData["webserver"]["page_requests_per_hour"] + " / hour");
$("#web-server-sse-clients").text(jsonData["webserver"]["sse_client_count"]);
$("#telnet-server-clients").text(jsonData["telnet"]["client_count"]);
$("#cleanup-status").text(jsonData["cleanup"]["status"]); $("#cleanup-status").text(jsonData["cleanup"]["status"]);
$("#cleanup-last-ran").text((jsonData["cleanup"]["last_ran"] > 0) ? moment.unix(jsonData["cleanup"]["last_ran"]).utc().fromNow() : "N/A"); $("#cleanup-last-ran").text((jsonData["cleanup"]["last_ran"] > 0) ? moment.unix(jsonData["cleanup"]["last_ran"]).utc().fromNow() : "N/A");
-118
View File
@@ -323,121 +323,3 @@ function bandToContrastColor(band) {
const lum = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]; const lum = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
return (lum > 128) ? "#000000" : "#ffffff"; 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 Island": "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 Island": "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;
}
+3 -2
View File
@@ -801,12 +801,13 @@
} }
}, },
CartoDB: { CartoDB: {
url: 'https://{s}.basemaps.cartocdn.com/{variant}/{z}/{x}/{y}{r}.png', url: 'https://{s}.basemaps.cartocdn.com/{variant}/{z}/{x}/{y}{r}.png?key={key}',
options: { options: {
attribution: '{attribution.OpenStreetMap} &copy; <a href="https://carto.com/attributions">CARTO</a>', attribution: '{attribution.OpenStreetMap} &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd', subdomains: 'abcd',
maxZoom: 20, maxZoom: 20,
variant: 'light_all' variant: 'light_all',
key: '<insert your API key here>'
}, },
variants: { variants: {
Positron: 'light_all', Positron: 'light_all',
+219
View File
@@ -0,0 +1,219 @@
import asyncio
import logging
import threading
from datetime import datetime
import pytz
from pyhamtools import callinfo
from core.config import SERVER_OWNER_CALLSIGN
from core.constants import SOFTWARE_VERSION
from core.data_store import DATA_STORE
from data.spot import Spot
logger = logging.getLogger(__name__)
BANNER = (
"""
==
==== ##### ##### ####
== == ..### ..### ..###
== ###==######## ###### ####### .####### ###### .### ######
==###.. ..###..### ###..###...###. .###..### ###..### .### ###..###
==###.### .### .###.### .### .### .### .### .### .### .### .#######
== .###.###.### .###.### .### .### ### .### .### .### .### .### .###...
== ...### .####### ..###### ..##### #### #####..###### #####..######
== .###. .###=.. ...... ..... .... ..... ...... ..... ......
== %% ... %%%%###==
== %%%%%%%%%%%#####== \r\n"""
+ "== ..... =="
+ f"Welcome to Spothole v{SOFTWARE_VERSION}".rjust(56)
+ "\r\n"
+ "========================"
+ f"This server is run by {SERVER_OWNER_CALLSIGN}".rjust(56)
)
MOTD = (
"Spothole's telnet server is a new feature and may not work properly in all\r\n"
+ "loggers. Please give it a try in your logger of choice and let me know if it\r\n"
+ "(or doesn't!) Please note that DXSpider-like commands are not yet supported\r\n"
+ "so you are not yet able to filter spots server-side or log in at all."
)
class TelnetServer:
"""A telnet server designed to provide spots in the same format as DXSpider, for compatibility with desktop loggers."""
def __init__(self):
self._port = None
self._running = False
self._clients = set()
self._loop = None
self._shutdown_event = asyncio.Event()
def start(self, port=7373):
"""Starts the telnet server"""
self._port = port
# Start the telnet server. asyncio.run() needs a coroutine, and threading.Thread needs a plain callable, so
# hand Thread the bridge between the two directly rather than writing a one-line wrapper method for it.
t = threading.Thread(target=asyncio.run, args=(self._start_internal(),), name="TelnetServer", daemon=True)
t.start()
logger.debug("Telnet server background thread spawned")
# Listen for new spots and alerts being added to the cache, so we can notify SSE clients immediately
DATA_STORE.spots.add_listener(self.publish)
self._running = True
async def _start_internal(self):
"""Start method (async). Sets up the telnet server and waits for shutdown."""
self._loop = asyncio.get_running_loop()
server = await asyncio.start_server(self._handle_client, "0.0.0.0", self._port)
logger.info(f"Telnet server listening on port {self._port}")
async with server:
await self._shutdown_event.wait()
await self._stop_internal()
async def _handle_client(self, reader, writer):
"""Handles a new client connection"""
logger.debug("Telnet client connected")
self._clients.add(writer)
# Print banner and MOTD
try:
text = (
BANNER
+ "\r\n\r\n"
+ "================================================================================"
+ "\r\n"
+ MOTD
+ "\r\n"
+ "================================================================================"
+ "\r\n\r\n"
)
writer.write(text.encode("ascii"))
await writer.drain()
except Exception:
logger.exception("Exception printing telnet motd")
# Set up buffer for user input
input_buffer = ""
try:
# Read forever, picking out any commands. Currently we just support "exit"
while True:
data = await reader.read(1024)
if not data:
break
input_buffer, command = self._consume_input(input_buffer, data)
if command == "exit":
writer.write(b"Goodbye!\r\n")
await writer.drain()
# Exit the while read loop, this will disconnect the client.
return
except asyncio.CancelledError:
pass
except Exception:
logger.exception("Exception handling telnet client")
finally:
logger.debug("Telnet client disconnected")
self._clients.remove(writer)
writer.close()
await writer.wait_closed()
def stop(self):
"""Stops the telnet server"""
self._running = False
if self._loop and self._loop.is_running():
logger.debug("Stopping telnet server...")
self._loop.call_soon_threadsafe(self._shutdown_event.set)
@property
def client_count(self) -> int:
return len(self._clients)
async def _stop_internal(self):
"""Stops the telnet server"""
for writer in list(self._clients):
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
self._clients.clear()
def publish(self, spot: Spot):
"""Callback from the data store when a spot is added"""
if self._running and self._clients and self._loop and self._loop.is_running():
asyncio.run_coroutine_threadsafe(self._broadcast_spot_internal(spot), self._loop)
async def _broadcast_spot_internal(self, spot: Spot):
"""Internal version, run on async loop for thread safety?"""
# Ensure ASCII formatting for telnet clients
encoded_line = self._format_dxspider_spot(spot).encode("ascii", errors="ignore")
# Try to write to all clients, and in the process find the ones that are disconnected
disconnected_clients = set()
for writer in self._clients:
try:
writer.write(encoded_line)
await writer.drain()
except Exception:
disconnected_clients.add(writer)
# Clean up any disconnected connections caught during writing
for writer in disconnected_clients:
self._clients.discard(writer)
@staticmethod
def _consume_input(input_buffer: str, data: bytes) -> tuple[str, str | None]:
"""Handle any input the user gives us, keeping a rolling buffer that we keep passing back through and
adding to. Once we get a command, return that as well, so the caller can deal with it."""
command = None
for char in data.decode("ascii", errors="ignore"):
if char in ("\r", "\n"):
stripped = input_buffer.strip().lower()
if stripped:
command = stripped
input_buffer = ""
elif char in ("\b", "\x7f"):
# Handle backspaces
input_buffer = input_buffer[:-1]
else:
input_buffer += char
return input_buffer, command
@staticmethod
def _format_dxspider_spot(spot: Spot) -> str:
"""Formats a spot into the format DXspider uses:
DX de CALLSIGN: FREQUENCY DX_CALLSIGN COMMENTS TIME_UTC.
Always use the base call for the spotter to save space. Everything must align properly to parse in clients,
and everything must be renderable in ASCII."""
de_call = f"{callinfo.Callinfo.get_homecall(spot.de_call)[:6] + ':' if spot.de_call else '???:'!s:<7}"
frequency = f"{(spot.freq / 1000.0):10.1f}"
dx_call = f"{spot.dx_call!s:<12}"
comment = f"{spot.comment.encode('ascii', errors='ignore').decode()[:29]:<30}"
if spot.time:
timestamp = datetime.fromtimestamp(spot.time, tz=pytz.utc).strftime("%H%M") + "Z"
else:
timestamp = datetime.now(tz=pytz.utc).strftime("%H%M") + "Z"
# Combine into classic DXSpider output string followed by network line breaks
return f"DX de {de_call} {frequency} {dx_call} {comment} {timestamp}\r\n"
# Global object
TELNET_SERVER = TelnetServer()
+27 -10
View File
@@ -34,6 +34,13 @@
like. The usage is explained in more detail in the <a like. The usage is explained in more detail in the <a
href="https://git.ianrenton.com/ian/spothole/src/branch/main/README.md">README file</a>. href="https://git.ianrenton.com/ian/spothole/src/branch/main/README.md">README file</a>.
</li> </li>
{% if telnet_server_enabled %}
<li>You can use it as a traditional telnet-based source of spots, similar to DXSpider and other software, <b>in
your desktop logging application</b>. To do this, set up your logger with the server address
<code>{{ telnet_server_address }}</code> and port <code>{{ telnet_server_port }}</code>. You can also access
it from a terminal with <code>telnet {{ telnet_server_address }} {{ telnet_server_port }}</code>.
</li>
{% end %}
<li>You can <b>write your own client using the Spothole API</b>, using the main Spothole instance to provide <li>You can <b>write your own client using the Spothole API</b>, using the main Spothole instance to provide
data, and do whatever you like with it. The README contains guidance on how to do this, and the full API data, and do whatever you like with it. The README contains guidance on how to do this, and the full API
docs are linked above. You can also find reference implementations in the form of Spothole's own web-based docs are linked above. You can also find reference implementations in the form of Spothole's own web-based
@@ -85,7 +92,8 @@
<p>Spothole can retrieve alerts from: <a href="https://www.ng3k.com/">NG3K</a>, <a href="https://pota.app">POTA</a>, <p>Spothole can retrieve alerts from: <a href="https://www.ng3k.com/">NG3K</a>, <a href="https://pota.app">POTA</a>,
<a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a <a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a
href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://www.wota.org.uk/">WOTA</a> and href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://www.wota.org.uk/">WOTA</a> and
<a href="https://www.beachesontheair.com/">BOTA</a>.</p> <a href="https://www.beachesontheair.com/">BOTA</a>. It also fetches contest dates from
<a href="https://contestcalendar.com/">WA7BNM Contest Calendar</a> and RSGB contest calendars.</p>
<p>Spothole can retrieve solar and propagation condition data from <a href="https://www.hamqsl.com">HamQSL</a>, the <p>Spothole can retrieve solar and propagation condition data from <a href="https://www.hamqsl.com">HamQSL</a>, the
<a href="https://www.swpc.noaa.gov/">NOAA Space Weather Prediction Center</a>, the <a <a href="https://www.swpc.noaa.gov/">NOAA Space Weather Prediction Center</a>, the <a
href="https://giro.uml.edu/">Lowell GIRO Data Center</a> and <a href="https://prop.kc2g.com/">prop.kc2g.com</a> href="https://giro.uml.edu/">Lowell GIRO Data Center</a> and <a href="https://prop.kc2g.com/">prop.kc2g.com</a>
@@ -101,12 +109,15 @@
(MOTA), the Amateur Radio Lighthouse Society (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos (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 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 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 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), el Diploma Municipios de España (DME) and Toilets the Air, Worked All Britain (WAB), Worked All Ireland (WAI), Diploma Municipios de España (DME), Diploma
on the Air (TOTA).</p> Faros de España (FEA), Diploma Muesos de España (DMUE), Diploma Castillos de España (DCE), Diploma Monumentos y
<p>As of the time of writing in June 2026, I think Spothole captures essentially all outdoor radio programmes Vestigios de España (DMVE), Diploma Estaciones de Ferrocarril de España (DEFE), Diploma Teatri Musei e Belle
that have a defined reference list, and almost certainly those that have a spotting/alerting API. If you know of Arti (DTMBA), British Inland Waterways on the Air (BIWOTA), Castles on the Air (COTA), Polish Gmina Award (PGA),
one I've missed, please let me know!</p> EME/Moonbounce, Amateur Satellite (AMSAT), and Toilets on the Air.</p>
<p>As of the time of writing in August 2026, I think Spothole captures most outdoor radio programmes that have a
defined, downloadable reference list, and almost certainly those that have a spotting/alerting API. If you know
of one I've missed, please let me know!</p>
<h4 class="mt-4">Why can I filter spots by both SIG and Source? Isn't that basically the same thing?</h4> <h4 class="mt-4">Why can I filter spots by both 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 <p>Mostly, but not quite. While POTA spots generally come from the POTA source and so on, there are a few
exceptions:</p> exceptions:</p>
@@ -159,6 +170,10 @@
modify it however you like, you can claim you wrote it and charge people £1000 for a copy, I don't really mind. modify it however you like, you can claim you wrote it and charge people £1000 for a copy, I don't really mind.
(Please don't do the last one. But if you're using my code for something cool, it would be nice to hear from (Please don't do the last one. But if you're using my code for something cool, it would be nice to hear from
you!)</p> you!)</p>
<h4 class="mt-4">What commands are supported in the telnet server?</h4>
<p>Currently, <code>exit</code>, and nothing else. Support for some DXSpider-like commands may be added to Spothole
in due course, but at the moment if you want to add Spothole as a telnet cluster data source to your desktop
logging application, that application must handle filtering itself.</p>
<h2 id="accuracy" class="mt-4">Data Accuracy</h2> <h2 id="accuracy" class="mt-4">Data Accuracy</h2>
<p>Please note that the data coming out of Spothole is only as good as the data going in. People mis-hear and make <p>Please note that the data coming out of Spothole is only as good as the data going in. People mis-hear and make
typos when spotting callsigns all the time. There are also plenty of cases where Spothole's data, particularly typos when spotting callsigns all the time. There are also plenty of cases where Spothole's data, particularly
@@ -178,13 +193,15 @@
filters. They are also stored in your browser's local storage, so that your preferences are remembered between filters. They are also stored in your browser's local storage, so that your preferences are remembered between
sessions.</p> sessions.</p>
<p>The data you provide can optionally include your login credentials for QRZ.com and HamQTH. You can provide these <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 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 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 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> 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 <p>Any data you send as part of a query, such as your QRZ or HamQTH credentials, is used only for the lifetime of
query and is not saved anywhere apart from your own device.</p> that
query and is not saved anywhere apart from your own device.</p>
<p>Spothole uses no trackers, no ads, and no cookies.</p> <p>Spothole uses no trackers, no ads, and no cookies.</p>
{% if len(web_ui_options["support_button_html"]) > 0 %} {% 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. <p><strong>Caveat: </strong> The owner of this server has chosen to inject their own content into the "spots" page.
+3 -2
View File
@@ -2,7 +2,8 @@
{% block content %} {% block content %}
<div id="add-spot-intro-box" class="permanently-dismissible-box mt-3"> <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 <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 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 visible within Spothole and are not sent "upstream" to DX clusters or xOTA spotting sites. The functionality
@@ -76,7 +77,7 @@
</div> </div>
<script src="/static/js/add-spot.js?v=1787900350"></script> <script src="/static/js/add-spot.js?v=1789198850"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-add-spot").addClass("active"); $("#nav-link-add-spot").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+1 -2
View File
@@ -4,7 +4,6 @@
<div class="mt-3"> <div class="mt-3">
<div id="settingsButtonRow" class="row mb-3"> <div id="settingsButtonRow" class="row mb-3">
<div class="col-auto me-auto pt-3"> <div class="col-auto me-auto pt-3">
{% module Template("widgets/refresh_timer.html", web_ui_options=web_ui_options) %}
</div> </div>
<div class="col-auto"> <div class="col-auto">
<div class="d-inline-flex gap-1"> <div class="d-inline-flex gap-1">
@@ -84,7 +83,7 @@
</div> </div>
<script src="/static/js/alerts.js?v=1787900351"></script> <script src="/static/js/alerts.js?v=1789198850"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-alerts").addClass("active"); $("#nav-link-alerts").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -76,8 +76,8 @@
</div> </div>
<script src="/static/js/spotsbandsandmap.js?v=1787900350"></script> <script src="/static/js/spotsbandsandmap.js?v=1789198850"></script>
<script src="/static/js/bands.js?v=1787900350"></script> <script src="/static/js/bands.js?v=1789198850"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-bands").addClass("active"); $("#nav-link-bands").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+8 -7
View File
@@ -1,6 +1,6 @@
{% extends "skeleton.html" %} {% extends "skeleton.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/css/style.css?v=1787900350" type="text/css"> <link rel="stylesheet" href="/static/css/style.css?v=1789198850" type="text/css">
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet"> <link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet"> <link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet"> <link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
@@ -11,14 +11,15 @@
<script src="/static/vendor/js/tinycolor2-1.6.0.min.js"></script> <script src="/static/vendor/js/tinycolor2-1.6.0.min.js"></script>
<script type="module"> <script type="module">
import { fetchEventSource } from '/static/vendor/js/fetch-event-source-2.0.1/index.js'; import {fetchEventSource} from '/static/vendor/js/fetch-event-source-2.0.1/index.js';
window.fetchEventSource = fetchEventSource;
window.fetchEventSource = fetchEventSource;
</script> </script>
<script src="/static/js/utils.js?v=1787900350"></script> <script src="/static/js/utils.js?v=1789198850"></script>
<script src="/static/js/ui-ham.js?v=1787900350"></script> <script src="/static/js/ui-ham.js?v=1789198850"></script>
<script src="/static/js/geo.js?v=1787900350"></script> <script src="/static/js/geo.js?v=1789198850"></script>
<script src="/static/js/common.js?v=1787900350"></script> <script src="/static/js/common.js?v=1789198850"></script>
{% end %} {% end %}
{% block body %} {% block body %}
<div class="container"> <div class="container">
+5 -1
View File
@@ -19,7 +19,11 @@
<input class="form-check-input storeable-checkbox" type="checkbox" value="" onclick="filtersUpdated();" <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" id="dxpeditions_skip_max_duration_check" checked><label class="form-check-label ms-2"
for="dxpeditions_skip_max_duration_check">Allow 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> </p>
</div> </div>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More