mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-09-20 22:37:44 +00:00
Compare commits
34
Commits
9d117a6069
...
2.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab81c136cc | ||
|
|
f0df4f38ca | ||
|
|
4b51dd9ba5 | ||
|
|
556ea56378 | ||
|
|
a367888e14 | ||
|
|
29d8654234 | ||
|
|
d79c8f72c8 | ||
|
|
a03e1336c8 | ||
|
|
0fa8cd763d | ||
|
|
4261c60d74 | ||
|
|
29eea1edc0 | ||
|
|
7c458a8c5b | ||
|
|
4a09e46fe0 | ||
|
|
e3df512b9e | ||
|
|
ee45a15b4e | ||
|
|
5e56cd3b19 | ||
|
|
04f5df5260 | ||
|
|
8f8ff46426 | ||
|
|
2e51d5fdb2 | ||
|
|
3bab3cb784 | ||
|
|
a4245ed5ce | ||
|
|
64bb8ef3eb | ||
|
|
3b575de36a | ||
|
|
94dc9044c4 | ||
|
|
be625c40a4 | ||
|
|
0fcc459cc4 | ||
|
|
e52790f07d | ||
|
|
237cdaa091 | ||
|
|
df724409a5 | ||
|
|
3e4327ec0c | ||
|
|
d6e53c14e9 | ||
|
|
d3d1d20821 | ||
|
|
ab86bdc4d6 | ||
|
|
d7202f208c |
+7
@@ -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" />
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# 
|
# 
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -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.
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
+39
-6
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -249,6 +270,18 @@ sig_ref_data_providers:
|
|||||||
- class: "FEA"
|
- class: "FEA"
|
||||||
enabled: true
|
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
|
||||||
|
|
||||||
@@ -287,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.
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ class CleanupTimer:
|
|||||||
"""Stop any threads and prepare for application shutdown"""
|
"""Stop any threads and prepare for application shutdown"""
|
||||||
|
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=15)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning("Cleanup worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while not self._stop_event.wait(timeout=self._cleanup_interval):
|
while not self._stop_event.wait(timeout=self._cleanup_interval):
|
||||||
|
|||||||
@@ -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", {})
|
||||||
|
|||||||
+106
-5
@@ -1,10 +1,10 @@
|
|||||||
from core.config import SERVER_OWNER_CALLSIGN
|
from core.config import SERVER_OWNER_CALLSIGN
|
||||||
from core.enums import SIGType
|
from core.enums import SIGRefType, SIGType
|
||||||
from data.band import Band
|
from data.band import Band
|
||||||
from data.sig import SIG
|
from data.sig import SIG
|
||||||
|
|
||||||
# General software
|
# General software
|
||||||
SOFTWARE_VERSION = "2.1-pre"
|
SOFTWARE_VERSION = "2.1.1"
|
||||||
|
|
||||||
# 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})"}
|
||||||
@@ -12,11 +12,28 @@ 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,
|
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",
|
icon="fa-tree",
|
||||||
refs_globally_unique=False,
|
refs_globally_unique=False,
|
||||||
@@ -26,6 +43,7 @@ SIGS = [
|
|||||||
comment_names=["SOTA"],
|
comment_names=["SOTA"],
|
||||||
description="Summits on the Air",
|
description="Summits on the Air",
|
||||||
sig_type=SIGType.WORLDWIDE,
|
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",
|
icon="fa-mountain-sun",
|
||||||
refs_globally_unique=False,
|
refs_globally_unique=False,
|
||||||
@@ -35,6 +53,7 @@ SIGS = [
|
|||||||
comment_names=["WWFF"],
|
comment_names=["WWFF"],
|
||||||
description="World Wide Flora & Fauna",
|
description="World Wide Flora & Fauna",
|
||||||
sig_type=SIGType.WORLDWIDE,
|
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",
|
icon="fa-seedling",
|
||||||
refs_globally_unique=True,
|
refs_globally_unique=True,
|
||||||
@@ -44,6 +63,7 @@ SIGS = [
|
|||||||
comment_names=["GMA"],
|
comment_names=["GMA"],
|
||||||
description="Global Mountain Activity",
|
description="Global Mountain Activity",
|
||||||
sig_type=SIGType.WORLDWIDE,
|
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",
|
icon="fa-person-hiking",
|
||||||
refs_globally_unique=False,
|
refs_globally_unique=False,
|
||||||
@@ -53,6 +73,7 @@ SIGS = [
|
|||||||
comment_names=["WWBOTA", "BOTA"],
|
comment_names=["WWBOTA", "BOTA"],
|
||||||
description="Worldwide Bunkers on the Air",
|
description="Worldwide Bunkers on the Air",
|
||||||
sig_type=SIGType.WORLDWIDE,
|
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",
|
icon="fa-radiation",
|
||||||
refs_globally_unique=True,
|
refs_globally_unique=True,
|
||||||
@@ -62,6 +83,7 @@ SIGS = [
|
|||||||
comment_names=["HEMA"],
|
comment_names=["HEMA"],
|
||||||
description="HuMPs Excluding Marilyns Award",
|
description="HuMPs Excluding Marilyns Award",
|
||||||
sig_type=SIGType.WORLDWIDE,
|
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",
|
icon="fa-mound",
|
||||||
refs_globally_unique=False,
|
refs_globally_unique=False,
|
||||||
@@ -71,6 +93,7 @@ SIGS = [
|
|||||||
comment_names=["IOTA"],
|
comment_names=["IOTA"],
|
||||||
description="Islands on the Air",
|
description="Islands on the Air",
|
||||||
sig_type=SIGType.WORLDWIDE,
|
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",
|
icon="fa-book-atlas",
|
||||||
refs_globally_unique=False,
|
refs_globally_unique=False,
|
||||||
@@ -80,6 +103,7 @@ SIGS = [
|
|||||||
comment_names=[],
|
comment_names=[],
|
||||||
description="Global Mountain Activity - Islands",
|
description="Global Mountain Activity - Islands",
|
||||||
sig_type=SIGType.WORLDWIDE,
|
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",
|
icon="fa-person-hiking",
|
||||||
refs_globally_unique=False,
|
refs_globally_unique=False,
|
||||||
@@ -89,6 +113,7 @@ SIGS = [
|
|||||||
comment_names=["ARLHS"],
|
comment_names=["ARLHS"],
|
||||||
description="Amateur Radio Lighthouse Society",
|
description="Amateur Radio Lighthouse Society",
|
||||||
sig_type=SIGType.WORLDWIDE,
|
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",
|
icon="fa-house-flood-water",
|
||||||
refs_globally_unique=False,
|
refs_globally_unique=False,
|
||||||
@@ -98,6 +123,7 @@ SIGS = [
|
|||||||
comment_names=["ILLW"],
|
comment_names=["ILLW"],
|
||||||
description="International Lighthouse & Lightship Weekend",
|
description="International Lighthouse & Lightship Weekend",
|
||||||
sig_type=SIGType.EVENT,
|
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",
|
icon="fa-house-flood-water",
|
||||||
refs_globally_unique=False,
|
refs_globally_unique=False,
|
||||||
@@ -107,6 +133,7 @@ SIGS = [
|
|||||||
comment_names=["MOTA"],
|
comment_names=["MOTA"],
|
||||||
description="Mills on the Air",
|
description="Mills on the Air",
|
||||||
sig_type=SIGType.EVENT,
|
sig_type=SIGType.EVENT,
|
||||||
|
ref_type=SIGRefType.MILL,
|
||||||
ref_regex=r"X\d{4,6}",
|
ref_regex=r"X\d{4,6}",
|
||||||
icon="fa-fan",
|
icon="fa-fan",
|
||||||
refs_globally_unique=True,
|
refs_globally_unique=True,
|
||||||
@@ -116,6 +143,7 @@ SIGS = [
|
|||||||
comment_names=["SIOTA"],
|
comment_names=["SIOTA"],
|
||||||
description="Silos on the Air",
|
description="Silos on the Air",
|
||||||
sig_type=SIGType.WORLDWIDE,
|
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",
|
icon="fa-wheat-awn",
|
||||||
refs_globally_unique=False,
|
refs_globally_unique=False,
|
||||||
@@ -125,6 +153,7 @@ SIGS = [
|
|||||||
comment_names=["WCA"],
|
comment_names=["WCA"],
|
||||||
description="World Castles Award",
|
description="World Castles Award",
|
||||||
sig_type=SIGType.WORLDWIDE,
|
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",
|
icon="fa-chess-rook",
|
||||||
refs_globally_unique=False,
|
refs_globally_unique=False,
|
||||||
@@ -134,6 +163,7 @@ SIGS = [
|
|||||||
comment_names=["ZLOTA"],
|
comment_names=["ZLOTA"],
|
||||||
description="New Zealand on the Air",
|
description="New Zealand on the Air",
|
||||||
sig_type=SIGType.REGIONAL,
|
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",
|
icon="fa-kiwi-bird",
|
||||||
region_flag="🇳🇿",
|
region_flag="🇳🇿",
|
||||||
@@ -144,6 +174,7 @@ SIGS = [
|
|||||||
comment_names=["WOTA"],
|
comment_names=["WOTA"],
|
||||||
description="Wainwrights on the Air",
|
description="Wainwrights on the Air",
|
||||||
sig_type=SIGType.REGIONAL,
|
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",
|
icon="fa-w",
|
||||||
region_flag="🇬🇧",
|
region_flag="🇬🇧",
|
||||||
@@ -154,6 +185,7 @@ SIGS = [
|
|||||||
comment_names=[],
|
comment_names=[],
|
||||||
description="Beaches on the Air",
|
description="Beaches on the Air",
|
||||||
sig_type=SIGType.WORLDWIDE,
|
sig_type=SIGType.WORLDWIDE,
|
||||||
|
ref_type=SIGRefType.BEACH,
|
||||||
icon="fa-umbrella-beach",
|
icon="fa-umbrella-beach",
|
||||||
refs_globally_unique=False,
|
refs_globally_unique=False,
|
||||||
),
|
),
|
||||||
@@ -162,6 +194,7 @@ SIGS = [
|
|||||||
comment_names=["KRMNPA"],
|
comment_names=["KRMNPA"],
|
||||||
description="Keith Roget Memorial National Parks Award",
|
description="Keith Roget Memorial National Parks Award",
|
||||||
sig_type=SIGType.REGIONAL,
|
sig_type=SIGType.REGIONAL,
|
||||||
|
ref_type=SIGRefType.PARK,
|
||||||
ref_regex=r"VKFF\-\d{4}",
|
ref_regex=r"VKFF\-\d{4}",
|
||||||
icon="fa-earth-oceania",
|
icon="fa-earth-oceania",
|
||||||
region_flag="🇦🇺",
|
region_flag="🇦🇺",
|
||||||
@@ -172,6 +205,7 @@ SIGS = [
|
|||||||
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,
|
sig_type=SIGType.REGIONAL,
|
||||||
|
ref_type=SIGRefType.PARK,
|
||||||
ref_regex=r"VKFF\-\d{4}",
|
ref_regex=r"VKFF\-\d{4}",
|
||||||
icon="fa-earth-oceania",
|
icon="fa-earth-oceania",
|
||||||
region_flag="🇦🇺",
|
region_flag="🇦🇺",
|
||||||
@@ -182,6 +216,7 @@ SIGS = [
|
|||||||
comment_names=["LLOTA"],
|
comment_names=["LLOTA"],
|
||||||
description="Lagos y Lagunas on the Air",
|
description="Lagos y Lagunas on the Air",
|
||||||
sig_type=SIGType.WORLDWIDE,
|
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",
|
icon="fa-water",
|
||||||
refs_globally_unique=True,
|
refs_globally_unique=True,
|
||||||
@@ -191,6 +226,7 @@ SIGS = [
|
|||||||
comment_names=["TOTA"],
|
comment_names=["TOTA"],
|
||||||
description="Towers on the Air",
|
description="Towers on the Air",
|
||||||
sig_type=SIGType.WORLDWIDE,
|
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",
|
icon="fa-tower-observation",
|
||||||
refs_globally_unique=False,
|
refs_globally_unique=False,
|
||||||
@@ -200,6 +236,7 @@ SIGS = [
|
|||||||
comment_names=[],
|
comment_names=[],
|
||||||
description="Tiles on the Air",
|
description="Tiles on the Air",
|
||||||
sig_type=SIGType.WORLDWIDE,
|
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",
|
icon="fa-square",
|
||||||
refs_globally_unique=False,
|
refs_globally_unique=False,
|
||||||
@@ -209,6 +246,7 @@ SIGS = [
|
|||||||
comment_names=["WAB"],
|
comment_names=["WAB"],
|
||||||
description="Worked All Britain",
|
description="Worked All Britain",
|
||||||
sig_type=SIGType.REGIONAL,
|
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",
|
icon="fa-table-cells-large",
|
||||||
region_flag="🇬🇧",
|
region_flag="🇬🇧",
|
||||||
@@ -219,26 +257,39 @@ SIGS = [
|
|||||||
comment_names=["WAI"],
|
comment_names=["WAI"],
|
||||||
description="Worked All Ireland",
|
description="Worked All Ireland",
|
||||||
sig_type=SIGType.REGIONAL,
|
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",
|
icon="fa-table-cells-large",
|
||||||
region_flag="🇮🇪",
|
region_flag="🇮🇪",
|
||||||
refs_globally_unique=False,
|
refs_globally_unique=False,
|
||||||
),
|
),
|
||||||
|
SIG(
|
||||||
|
name="DMF",
|
||||||
|
comment_names=["DMF"],
|
||||||
|
description="Diplôme des Moulins de France",
|
||||||
|
sig_type=SIGType.REGIONAL,
|
||||||
|
ref_type=SIGRefType.MILL,
|
||||||
|
icon="fa-fan",
|
||||||
|
region_flag="🇫🇷",
|
||||||
|
refs_globally_unique=False,
|
||||||
|
),
|
||||||
SIG(
|
SIG(
|
||||||
name="DME",
|
name="DME",
|
||||||
comment_names=["DME"],
|
comment_names=["DME"],
|
||||||
description="Diploma Municipios de España",
|
description="Diploma Municipios de España",
|
||||||
sig_type=SIGType.REGIONAL,
|
sig_type=SIGType.REGIONAL,
|
||||||
ref_regex=r"\d{4,5}",
|
ref_type=SIGRefType.TOWN,
|
||||||
|
ref_regex=r"DME[\- ]\d{3,5}",
|
||||||
icon="fa-building",
|
icon="fa-building",
|
||||||
region_flag="🇪🇸",
|
region_flag="🇪🇸",
|
||||||
refs_globally_unique=False,
|
refs_globally_unique=True,
|
||||||
),
|
),
|
||||||
SIG(
|
SIG(
|
||||||
name="FEA",
|
name="FEA",
|
||||||
comment_names=["FEA"],
|
comment_names=["FEA"],
|
||||||
description="Diploma Faros de España",
|
description="Diploma Faros de España",
|
||||||
sig_type=SIGType.REGIONAL,
|
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-
|
# 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
|
# prefix and just use FEA-1234 or FEA 1234, so allow for that. The FEA sigref data provider adds both
|
||||||
# forms to the database.
|
# forms to the database.
|
||||||
@@ -247,13 +298,58 @@ SIGS = [
|
|||||||
region_flag="🇪🇸",
|
region_flag="🇪🇸",
|
||||||
refs_globally_unique=True,
|
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(
|
SIG(
|
||||||
name="DTMBA",
|
name="DTMBA",
|
||||||
comment_names=["DTMBA"],
|
comment_names=["DTMBA"],
|
||||||
description="Diploma Teatri Musei e Belle Arti",
|
description="Diploma Teatri Musei e Belle Arti",
|
||||||
sig_type=SIGType.REGIONAL,
|
sig_type=SIGType.REGIONAL,
|
||||||
|
ref_type=SIGRefType.BUILDING,
|
||||||
ref_regex=r"I-?[0-9]{3,4}\s?[A-Z]{2}",
|
ref_regex=r"I-?[0-9]{3,4}\s?[A-Z]{2}",
|
||||||
icon="fa-masks-theater",
|
icon="fa-landmark",
|
||||||
region_flag="🇮🇹",
|
region_flag="🇮🇹",
|
||||||
refs_globally_unique=True,
|
refs_globally_unique=True,
|
||||||
),
|
),
|
||||||
@@ -262,6 +358,7 @@ SIGS = [
|
|||||||
comment_names=["BIWOTA"],
|
comment_names=["BIWOTA"],
|
||||||
description="British Inland Waterways on the Air",
|
description="British Inland Waterways on the Air",
|
||||||
sig_type=SIGType.EVENT,
|
sig_type=SIGType.EVENT,
|
||||||
|
ref_type=SIGRefType.WATERWAY,
|
||||||
icon="fa-ship",
|
icon="fa-ship",
|
||||||
region_flag="🇬🇧",
|
region_flag="🇬🇧",
|
||||||
refs_globally_unique=False,
|
refs_globally_unique=False,
|
||||||
@@ -271,6 +368,7 @@ SIGS = [
|
|||||||
comment_names=["COTA"],
|
comment_names=["COTA"],
|
||||||
description="Castles on the Air",
|
description="Castles on the Air",
|
||||||
sig_type=SIGType.REGIONAL,
|
sig_type=SIGType.REGIONAL,
|
||||||
|
ref_type=SIGRefType.CASTLE,
|
||||||
ref_regex=r"[A-Z]{3}\-[0-9]{3,5}",
|
ref_regex=r"[A-Z]{3}\-[0-9]{3,5}",
|
||||||
icon="fa-chess-rook",
|
icon="fa-chess-rook",
|
||||||
region_flag="🇩🇪",
|
region_flag="🇩🇪",
|
||||||
@@ -281,6 +379,7 @@ SIGS = [
|
|||||||
comment_names=["PGA"],
|
comment_names=["PGA"],
|
||||||
description="Polish Gmina Award",
|
description="Polish Gmina Award",
|
||||||
sig_type=SIGType.REGIONAL,
|
sig_type=SIGType.REGIONAL,
|
||||||
|
ref_type=SIGRefType.REGION,
|
||||||
ref_regex=r"[A-Z]{2}[0-9]{2}",
|
ref_regex=r"[A-Z]{2}[0-9]{2}",
|
||||||
icon="fa-g",
|
icon="fa-g",
|
||||||
region_flag="🇵🇱",
|
region_flag="🇵🇱",
|
||||||
@@ -291,6 +390,7 @@ SIGS = [
|
|||||||
comment_names=[],
|
comment_names=[],
|
||||||
description="Toilets on the Air",
|
description="Toilets on the Air",
|
||||||
sig_type=SIGType.EVENT,
|
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",
|
icon="fa-toilet",
|
||||||
region_flag="🏴☠️",
|
region_flag="🏴☠️",
|
||||||
@@ -341,5 +441,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",
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-33
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
from core.config import config, create_provider_from_config
|
from core.config import config, create_provider_from_config
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ class DataProviders:
|
|||||||
self.static_data_providers = []
|
self.static_data_providers = []
|
||||||
self.sig_ref_data_providers = []
|
self.sig_ref_data_providers = []
|
||||||
self.callsign_data_providers = []
|
self.callsign_data_providers = []
|
||||||
|
self._startup_timers = []
|
||||||
|
|
||||||
def setup(self):
|
def setup(self):
|
||||||
for entry in config["spot_providers"]:
|
for entry in config["spot_providers"]:
|
||||||
@@ -43,41 +45,58 @@ class DataProviders:
|
|||||||
def start(self):
|
def start(self):
|
||||||
# Start data providers before spot/alert providers so the lookup data is there already for incoming spots.
|
# Start data providers before spot/alert providers so the lookup data is there already for incoming spots.
|
||||||
# Each category is fired off after a small delay to give the rest of Spothole chance to start up.
|
# Each category is fired off after a small delay to give the rest of Spothole chance to start up.
|
||||||
threading.Timer(5.0, lambda: self.start_providers(self.static_data_providers, "static data")).start()
|
self._startup_timers = [
|
||||||
threading.Timer(
|
threading.Timer(5.0, lambda: self.start_providers(self.static_data_providers, "static data")),
|
||||||
10.0,
|
threading.Timer(10.0, lambda: self.start_providers(self.callsign_data_providers, "callsign data")),
|
||||||
lambda: self.start_providers(self.callsign_data_providers, "callsign data"),
|
threading.Timer(15.0, lambda: self.start_providers(self.spot_providers, "spot")),
|
||||||
).start()
|
threading.Timer(20.0, lambda: self.start_providers(self.alert_providers, "alert")),
|
||||||
threading.Timer(15.0, lambda: self.start_providers(self.spot_providers, "spot")).start()
|
threading.Timer(
|
||||||
threading.Timer(20.0, lambda: self.start_providers(self.alert_providers, "alert")).start()
|
25.0,
|
||||||
threading.Timer(
|
lambda: self.start_providers(self.solar_condition_providers, "solar condition"),
|
||||||
25.0,
|
),
|
||||||
lambda: self.start_providers(self.solar_condition_providers, "solar condition"),
|
threading.Timer(30.0, lambda: self.start_providers(self.sig_ref_data_providers, "SIG ref data")),
|
||||||
).start()
|
]
|
||||||
threading.Timer(
|
for t in self._startup_timers:
|
||||||
30.0,
|
t.daemon = True
|
||||||
lambda: self.start_providers(self.sig_ref_data_providers, "SIG ref data"),
|
t.start()
|
||||||
).start()
|
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
for sp in self.spot_providers:
|
# Cancel any startup timers that haven't fired yet
|
||||||
if sp.enabled:
|
for t in self._startup_timers:
|
||||||
sp.stop()
|
t.cancel()
|
||||||
for ap in self.alert_providers:
|
|
||||||
if ap.enabled:
|
# Stop all providers
|
||||||
ap.stop()
|
all_providers = [
|
||||||
for scp in self.solar_condition_providers:
|
p
|
||||||
if scp.enabled:
|
for p in (
|
||||||
scp.stop()
|
self.spot_providers
|
||||||
for srdp in self.sig_ref_data_providers:
|
+ self.alert_providers
|
||||||
if srdp.enabled:
|
+ self.solar_condition_providers
|
||||||
srdp.stop()
|
+ self.sig_ref_data_providers
|
||||||
for sdp in self.static_data_providers:
|
+ self.static_data_providers
|
||||||
if sdp.enabled:
|
+ self.callsign_data_providers
|
||||||
sdp.stop()
|
)
|
||||||
for cdp in self.callsign_data_providers:
|
if p.enabled
|
||||||
if cdp.enabled:
|
]
|
||||||
cdp.stop()
|
if not all_providers:
|
||||||
|
return
|
||||||
|
|
||||||
|
def stop_provider(p):
|
||||||
|
try:
|
||||||
|
p.stop()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Exception stopping provider")
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=stop_provider, args=(p,), daemon=True) for p in all_providers]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
deadline = time.monotonic() + 40
|
||||||
|
for t in threads:
|
||||||
|
t.join(timeout=max(0.0, deadline - time.monotonic()))
|
||||||
|
still_running = [t for t in threads if t.is_alive()]
|
||||||
|
if still_running:
|
||||||
|
logger.warning("Some threads did not stop in time!")
|
||||||
|
|
||||||
|
|
||||||
# Global object
|
# Global object
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ class DataStore:
|
|||||||
and testing the callsign every time is expensive. So instead we build a separate in-memory lookup of compiled
|
and testing the callsign every time is expensive. So instead we build a separate in-memory lookup of compiled
|
||||||
regex against DXCC entity code, as a list of tuples we can iterate through."""
|
regex against DXCC entity code, as a list of tuples we can iterate through."""
|
||||||
|
|
||||||
|
self.dxcc_lookup_by_call_regex = []
|
||||||
for entry in [DATA_STORE.dxcc_data[key] for key in DATA_STORE.dxcc_data]:
|
for entry in [DATA_STORE.dxcc_data[key] for key in DATA_STORE.dxcc_data]:
|
||||||
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"]))
|
||||||
|
|
||||||
|
|||||||
+16
-10
@@ -30,7 +30,6 @@ class Mode(str, Enum):
|
|||||||
OLIVIA = "OLIVIA"
|
OLIVIA = "OLIVIA"
|
||||||
PKT = "PKT"
|
PKT = "PKT"
|
||||||
MSK144 = "MSK144"
|
MSK144 = "MSK144"
|
||||||
UNKNOWN = "UNKNOWN"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_cw(self) -> bool:
|
def is_cw(self) -> bool:
|
||||||
@@ -42,26 +41,28 @@ class Mode(str, Enum):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_data(self) -> bool:
|
def is_data(self) -> bool:
|
||||||
return not (self.is_cw or self.is_phone or self == Mode.UNKNOWN)
|
return not (self.is_cw or self.is_phone)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_name(name):
|
def from_name(name):
|
||||||
"""Convert a string to an enum mode using the alias table."""
|
"""Convert a string to an enum mode using the alias table."""
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return Mode(name.upper())
|
return Mode(name.upper())
|
||||||
except ValueError:
|
except (KeyError, ValueError):
|
||||||
try:
|
try:
|
||||||
return Mode(MODE_ALIASES[name.upper()])
|
return Mode(MODE_ALIASES[name.upper()])
|
||||||
except ValueError:
|
except (KeyError, ValueError):
|
||||||
return Mode.UNKNOWN
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ModeType(str, Enum):
|
class ModeType(str, Enum):
|
||||||
PHONE = "PHONE"
|
PHONE = "PHONE"
|
||||||
CW = "CW"
|
CW = "CW"
|
||||||
DATA = "DATA"
|
DATA = "DATA"
|
||||||
UNKNOWN = "UNKNOWN"
|
|
||||||
|
|
||||||
|
|
||||||
class ModeSource(str, Enum):
|
class ModeSource(str, Enum):
|
||||||
@@ -70,7 +71,6 @@ class ModeSource(str, Enum):
|
|||||||
SPOT = "SPOT"
|
SPOT = "SPOT"
|
||||||
COMMENT = "COMMENT"
|
COMMENT = "COMMENT"
|
||||||
BANDPLAN = "BANDPLAN"
|
BANDPLAN = "BANDPLAN"
|
||||||
NONE = "NONE"
|
|
||||||
|
|
||||||
|
|
||||||
class LocationSourceForSpot(str, Enum):
|
class LocationSourceForSpot(str, Enum):
|
||||||
@@ -81,7 +81,6 @@ class LocationSourceForSpot(str, Enum):
|
|||||||
GRID = "GRID"
|
GRID = "GRID"
|
||||||
HOME_QTH = "HOME QTH"
|
HOME_QTH = "HOME QTH"
|
||||||
DXCC = "DXCC"
|
DXCC = "DXCC"
|
||||||
NONE = "NONE"
|
|
||||||
|
|
||||||
|
|
||||||
class LocationSourceForCallsign(str, Enum):
|
class LocationSourceForCallsign(str, Enum):
|
||||||
@@ -89,7 +88,6 @@ class LocationSourceForCallsign(str, Enum):
|
|||||||
|
|
||||||
HOME_QTH = "HOME QTH"
|
HOME_QTH = "HOME QTH"
|
||||||
DXCC = "DXCC"
|
DXCC = "DXCC"
|
||||||
NONE = "NONE"
|
|
||||||
|
|
||||||
|
|
||||||
class SIGRefType(str, Enum):
|
class SIGRefType(str, Enum):
|
||||||
@@ -112,7 +110,15 @@ class SIGRefType(str, Enum):
|
|||||||
REGION = "REGION"
|
REGION = "REGION"
|
||||||
GRID = "GRID"
|
GRID = "GRID"
|
||||||
TOILET = "TOILET"
|
TOILET = "TOILET"
|
||||||
UNKNOWN = "UNKNOWN"
|
|
||||||
|
|
||||||
|
class AlertType(str, Enum):
|
||||||
|
"""Type of an alert."""
|
||||||
|
|
||||||
|
XOTA = "XOTA"
|
||||||
|
SATELLITE = "SATELLITE"
|
||||||
|
DXPEDITION = "DXPEDITION"
|
||||||
|
CONTEST = "CONTEST"
|
||||||
|
|
||||||
|
|
||||||
class SIGType(str, Enum):
|
class SIGType(str, Enum):
|
||||||
|
|||||||
+12
-4
@@ -22,6 +22,8 @@ class LiveDataCache:
|
|||||||
self._listeners_lock = threading.Lock()
|
self._listeners_lock = threading.Lock()
|
||||||
self._snapshot_dir = snapshot_dir
|
self._snapshot_dir = snapshot_dir
|
||||||
self._disk_cache = diskcache.Cache(str(snapshot_dir))
|
self._disk_cache = diskcache.Cache(str(snapshot_dir))
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._snapshot_thread = None
|
||||||
self._load_snapshot()
|
self._load_snapshot()
|
||||||
self._start_periodic_snapshot(snapshot_interval_sec)
|
self._start_periodic_snapshot(snapshot_interval_sec)
|
||||||
|
|
||||||
@@ -89,13 +91,19 @@ class LiveDataCache:
|
|||||||
|
|
||||||
def _start_periodic_snapshot(self, interval):
|
def _start_periodic_snapshot(self, interval):
|
||||||
def loop():
|
def loop():
|
||||||
while True:
|
while not self._stop_event.wait(timeout=interval):
|
||||||
time.sleep(interval)
|
|
||||||
self.save_snapshot()
|
self.save_snapshot()
|
||||||
|
|
||||||
t = threading.Thread(target=loop, name=f"LiveDataCache-Snapshot-{self._snapshot_dir}")
|
self._snapshot_thread = threading.Thread(
|
||||||
t.start()
|
target=loop, name=f"LiveDataCache-Snapshot-{self._snapshot_dir}", daemon=True
|
||||||
|
)
|
||||||
|
self._snapshot_thread.start()
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._snapshot_thread:
|
||||||
|
self._snapshot_thread.join(timeout=15)
|
||||||
|
if self._snapshot_thread.is_alive():
|
||||||
|
logger.warning(f"LiveDataCache snapshot thread for {self._snapshot_dir} did not exit on time.")
|
||||||
self.save_snapshot()
|
self.save_snapshot()
|
||||||
self._disk_cache.close()
|
self._disk_cache.close()
|
||||||
|
|||||||
+51
-34
@@ -1,56 +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
|
||||||
|
|
||||||
# Sometimes we allow spaces instead of dashes in references due to common usage that way, but official reference
|
# 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.
|
# lists never do, so convert them here.
|
||||||
ref_id.replace(" ", "-")
|
ref_id = ref_id.replace(" ", "-")
|
||||||
|
|
||||||
# Prepare the object to be returned
|
# Prepare the object to be returned
|
||||||
sig_ref = SIGRef(sig=sig, id=ref_id)
|
sig_ref = SIGRef(sig=sig_name, id=ref_id)
|
||||||
|
|
||||||
|
# We can always get the reference type and the icon from the SIG itself
|
||||||
|
for sig in SIGS:
|
||||||
|
if sig.name.upper() == sig_name.upper():
|
||||||
|
sig_ref.ref_type = sig.ref_type
|
||||||
|
sig_ref.icon = sig.icon
|
||||||
|
|
||||||
try:
|
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)}"
|
||||||
|
|
||||||
# DTMBA spotters sometimes include spaces and dashes, our regex allows them but they must be removed here so we
|
# DTMBA spotters sometimes include spaces and dashes, our regex allows them but they must be removed here so we
|
||||||
# can look up against the official list which doesn't have them
|
# can look up against the official list which doesn't have them
|
||||||
if sig.upper() == "DTMBA":
|
if sig_name.upper() == "DTMBA":
|
||||||
ref_id = ref_id.replace("-", "").replace(" ", "")
|
ref_id = ref_id.replace("-", "").replace(" ", "")
|
||||||
|
|
||||||
# If the SIG is HEMA, we have no current lookup for this so just skip the lookup here.
|
### NO DATA SIGS ###
|
||||||
if sig.upper() == "HEMA":
|
#
|
||||||
sig_ref.ref_type = "Summit"
|
# 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:
|
||||||
@@ -61,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
|
||||||
@@ -74,20 +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() == "BIWOTA":
|
elif sig_name.upper() == "GMA Islands":
|
||||||
# For BIWOTA there are no references, all we can set is a type
|
|
||||||
sig_ref.ref_type = "Waterway"
|
|
||||||
|
|
||||||
elif sig.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)
|
||||||
@@ -98,25 +106,34 @@ 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
|
try:
|
||||||
if lookup_data:
|
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
|
||||||
return lookup_data
|
if lookup_data:
|
||||||
|
for attr, value in lookup_data.__dict__.items():
|
||||||
|
if value is not None and sig_ref.__dict__.get(attr) is None:
|
||||||
|
sig_ref.__dict__[attr] = 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 (ValueError, KeyError):
|
||||||
|
# Catch exceptions due to e.g. old versions of objects in the cache that are no longer compatible,
|
||||||
|
# and remove them from the cache.
|
||||||
|
del DATA_STORE.sigrefs[key]
|
||||||
|
return None
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+17
-15
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from threading import Event, Thread
|
from threading import Event, Thread
|
||||||
@@ -11,7 +12,10 @@ 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
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class StatusReporter:
|
class StatusReporter:
|
||||||
@@ -32,13 +36,17 @@ class StatusReporter:
|
|||||||
def start(self):
|
def start(self):
|
||||||
"""Start the reporter thread"""
|
"""Start the reporter thread"""
|
||||||
|
|
||||||
self._thread = Thread(target=self._run, name="StatusReporter")
|
self._thread = Thread(target=self._run, name="StatusReporter", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Stop any threads and prepare for application shutdown"""
|
"""Stop any threads and prepare for application shutdown"""
|
||||||
|
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=15)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning("Status reporter worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
"""Thread entry point: report immediately on startup, then on each interval until stopped"""
|
"""Thread entry point: report immediately on startup, then on each interval until stopped"""
|
||||||
@@ -134,19 +142,13 @@ class StatusReporter:
|
|||||||
else 0,
|
else 0,
|
||||||
}
|
}
|
||||||
DATA_STORE.status.get()["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,
|
DATA_STORE.status.get()["telnet"] = {
|
||||||
"api_access_count": WEB_SERVER.web_server_metrics["api_access_counter"],
|
"client_count": TELNET_SERVER.client_count,
|
||||||
"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.store()
|
DATA_STORE.status.store()
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,13 @@ class URLDataCache(CachedSession):
|
|||||||
used across multiple threads, though note that URL lookups will block each other this way, so it is still better to
|
used across multiple threads, though note that URL lookups will block each other this way, so it is still better to
|
||||||
create one of these objects per thread if possible."""
|
create one of these objects per thread if possible."""
|
||||||
|
|
||||||
_lock = threading.Lock()
|
|
||||||
|
|
||||||
def __init__(self, name):
|
def __init__(self, name):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
f"{CACHE_DIR}urls/{name}",
|
f"{CACHE_DIR}urls/{name}",
|
||||||
expire_after=timedelta(days=1),
|
expire_after=timedelta(days=1),
|
||||||
allowable_codes=(200, 400, 401, 403, 404),
|
allowable_codes=(200, 400, 401, 403, 404),
|
||||||
)
|
)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
def get(self, *args, **kwargs):
|
def get(self, *args, **kwargs):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|||||||
+20
-8
@@ -20,11 +20,11 @@ def safe_json_dumps(obj):
|
|||||||
return simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, default=lambda o: o.__dict__)
|
return simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, default=lambda o: o.__dict__)
|
||||||
|
|
||||||
|
|
||||||
def infer_mode_from_comment(comment: str) -> Mode:
|
def infer_mode_from_comment(comment: str) -> Mode | None:
|
||||||
"""Infer a mode from the comment"""
|
"""Infer a mode from the comment"""
|
||||||
|
|
||||||
if not comment:
|
if not comment:
|
||||||
return Mode.UNKNOWN
|
return None
|
||||||
|
|
||||||
for mode in Mode:
|
for mode in Mode:
|
||||||
if re.search(r"(^|\W)" + mode + r"($|\W)", comment, re.IGNORECASE):
|
if re.search(r"(^|\W)" + mode + r"($|\W)", comment, re.IGNORECASE):
|
||||||
@@ -33,14 +33,17 @@ def infer_mode_from_comment(comment: str) -> Mode:
|
|||||||
if re.search(r"(^|\W)" + alias + r"($|\W)", comment, re.IGNORECASE):
|
if re.search(r"(^|\W)" + alias + r"($|\W)", comment, re.IGNORECASE):
|
||||||
return Mode(MODE_ALIASES[alias])
|
return Mode(MODE_ALIASES[alias])
|
||||||
|
|
||||||
return Mode.UNKNOWN
|
return None
|
||||||
|
|
||||||
|
|
||||||
def infer_mode_type_from_mode(mode: str) -> ModeType:
|
def infer_mode_type_from_mode(mode: str) -> ModeType | None:
|
||||||
"""Infer a "mode family" from a mode ."""
|
"""Infer a "mode family" from a mode ."""
|
||||||
|
|
||||||
if not mode:
|
if not mode:
|
||||||
return ModeType.UNKNOWN
|
return None
|
||||||
|
|
||||||
|
if mode in MODE_ALIASES:
|
||||||
|
mode = MODE_ALIASES[mode]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mode = Mode(mode.upper())
|
mode = Mode(mode.upper())
|
||||||
@@ -53,7 +56,7 @@ def infer_mode_type_from_mode(mode: str) -> ModeType:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
if mode.upper() != "OTHER" and mode != "?":
|
if mode.upper() != "OTHER" and mode != "?":
|
||||||
logger.warning(f"Found an unrecognised mode: {mode}. Developer should categorise this.")
|
logger.warning(f"Found an unrecognised mode: {mode}. Developer should categorise this.")
|
||||||
return ModeType.UNKNOWN
|
return None
|
||||||
|
|
||||||
|
|
||||||
def infer_band_from_freq(freq):
|
def infer_band_from_freq(freq):
|
||||||
@@ -96,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
|
||||||
|
|
||||||
@@ -118,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 = 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
|
||||||
|
|||||||
+47
-14
@@ -1,13 +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 Continent
|
from core.enums import AlertType, Continent
|
||||||
from core.sig_lookup_helper import populate_missing_sig_ref_info
|
from core.sig_lookup_helper import populate_missing_sig_ref_info
|
||||||
from core.utils import get_flag_for_dxcc
|
from core.utils import get_flag_for_dxcc
|
||||||
|
|
||||||
@@ -20,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
|
||||||
@@ -36,6 +39,9 @@ class Alert:
|
|||||||
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
|
||||||
@@ -46,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"""
|
||||||
|
|
||||||
@@ -92,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 = Continent(call_info.continent) if call_info.continent else None
|
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:
|
||||||
@@ -106,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
|
||||||
@@ -130,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.SATELLITE:
|
||||||
|
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")
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ class Callsign:
|
|||||||
# 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
|
# Location source
|
||||||
location_source: LocationSourceForCallsign = LocationSourceForCallsign.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
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
from core.enums import SIGType
|
from core.enums import SIGRefType, SIGType
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -27,6 +27,9 @@ class SIG:
|
|||||||
refs_globally_unique: bool
|
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 to use in the UI when referencing this SIG. Chosen from the Font Awesome set.
|
||||||
|
|||||||
+5
-3
@@ -8,16 +8,18 @@ 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: SIGRefType = SIGRefType.UNKNOWN
|
ref_type: SIGRefType | None = None
|
||||||
# URL to look up more information about the reference, if known.
|
# URL to look up more information about the reference, if known.
|
||||||
url: str | None = None
|
url: str | None = None
|
||||||
|
# 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.
|
||||||
|
|||||||
+51
-25
@@ -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
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ 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 PROPAGATION_MODES, SIGS
|
from core.constants import PROPAGATION_MODES, SIGS
|
||||||
|
from core.data_store import DATA_STORE
|
||||||
from core.enums import Continent, LocationSourceForSpot, Mode, ModeSource, ModeType
|
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
|
||||||
@@ -70,7 +71,7 @@ class Spot:
|
|||||||
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.
|
# DX Location source. Indicates how accurate the location might be.
|
||||||
dx_location_source: LocationSourceForSpot = LocationSourceForSpot.NONE
|
dx_location_source: LocationSourceForSpot | None = 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.
|
||||||
@@ -103,11 +104,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: Mode = Mode.UNKNOWN
|
mode: Mode | None = None
|
||||||
# Inferred mode "family".
|
# Inferred mode "family".
|
||||||
mode_type: ModeType = ModeType.UNKNOWN
|
mode_type: ModeType | None = None
|
||||||
# Source of the mode information.
|
# Source of the mode information.
|
||||||
mode_source: ModeSource = ModeSource.NONE
|
mode_source: ModeSource | 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 +125,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 +147,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,7 +190,7 @@ 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 = 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
|
||||||
@@ -222,7 +228,7 @@ 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 = 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
|
||||||
@@ -239,21 +245,17 @@ class Spot:
|
|||||||
self.band = band.name
|
self.band = band.name
|
||||||
|
|
||||||
# Mode from comments or bandplan
|
# Mode from comments or bandplan
|
||||||
if not self.mode:
|
if self.mode:
|
||||||
self.mode = Mode.UNKNOWN
|
|
||||||
if self.mode != Mode.UNKNOWN:
|
|
||||||
self.mode_source = ModeSource.SPOT
|
self.mode_source = ModeSource.SPOT
|
||||||
if self.comment and self.mode == Mode.UNKNOWN:
|
if self.comment and not self.mode:
|
||||||
self.mode = infer_mode_from_comment(self.comment)
|
self.mode = infer_mode_from_comment(self.comment)
|
||||||
self.mode_source = ModeSource.COMMENT
|
self.mode_source = ModeSource.COMMENT
|
||||||
if self.freq and self.mode == Mode.UNKNOWN:
|
if self.freq and not self.mode:
|
||||||
self.mode = infer_mode_from_frequency(self.freq)
|
self.mode = infer_mode_from_frequency(self.freq)
|
||||||
self.mode_source = ModeSource.BANDPLAN
|
self.mode_source = ModeSource.BANDPLAN
|
||||||
|
|
||||||
# Mode type from mode
|
# Mode type from mode
|
||||||
if not self.mode_type:
|
if self.mode and not self.mode_type:
|
||||||
self.mode_type = ModeType.UNKNOWN
|
|
||||||
if self.mode != Mode.UNKNOWN and self.mode_type == ModeType.UNKNOWN:
|
|
||||||
self.mode_type = infer_mode_type_from_mode(self.mode)
|
self.mode_type = infer_mode_type_from_mode(self.mode)
|
||||||
|
|
||||||
# If we have a latitude or grid at this point, it can only have been provided by the spot itself
|
# If we have a latitude or grid at this point, it can only have been provided by the spot itself
|
||||||
@@ -261,12 +263,12 @@ class Spot:
|
|||||||
self.dx_location_source = LocationSourceForSpot.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:
|
||||||
@@ -314,7 +316,7 @@ class Spot:
|
|||||||
# 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
|
||||||
@@ -330,7 +332,7 @@ class Spot:
|
|||||||
|
|
||||||
# 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".
|
||||||
@@ -358,6 +360,18 @@ 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"
|
||||||
|
if not any(sig_ref.sig == "AMSAT" for sig_ref in self.sig_refs):
|
||||||
|
self.sig_refs.append(SIGRef(sig="AMSAT"))
|
||||||
|
if self.propagation_mode == "Earth-Moon-Earth":
|
||||||
|
if not self.sig:
|
||||||
|
self.sig = "EME"
|
||||||
|
if not any(sig_ref.sig == "EME" for sig_ref in self.sig_refs):
|
||||||
|
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(
|
||||||
@@ -414,7 +428,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}"
|
||||||
@@ -434,6 +448,15 @@ class Spot:
|
|||||||
elif self.dx_call:
|
elif self.dx_call:
|
||||||
self.dx_itu_zone = dx_call_info.itu_zone
|
self.dx_itu_zone = dx_call_info.itu_zone
|
||||||
|
|
||||||
|
# DXCC lookup from callsign if nothing else has provided it
|
||||||
|
if self.dx_call and not self.dx_dxcc_id:
|
||||||
|
for regex, entity_code in DATA_STORE.dxcc_lookup_by_call_regex:
|
||||||
|
if regex.pattern and regex.match(self.dx_call):
|
||||||
|
self.dx_dxcc_id = entity_code
|
||||||
|
break
|
||||||
|
if self.dx_dxcc_id and not self.dx_flag:
|
||||||
|
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
|
||||||
|
|
||||||
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
|
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
|
||||||
# is likely at home.
|
# is likely at home.
|
||||||
self.dx_location_good = bool(
|
self.dx_location_good = bool(
|
||||||
@@ -459,6 +482,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")
|
||||||
|
|
||||||
@@ -470,16 +498,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+4
-1
@@ -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*
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -27,11 +27,15 @@ class HTTPAlertProvider(AlertProvider):
|
|||||||
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
||||||
# subsequent polls, so start() returns immediately and the application can continue starting.
|
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||||
logger.info(f"Set up query of {self.name} alert API every {self._poll_interval!s} seconds.")
|
logger.info(f"Set up query of {self.name} alert API every {self._poll_interval!s} seconds.")
|
||||||
self._thread = Thread(target=self._run, name=f"HTTPAlertProvider-{self.name}")
|
self._thread = Thread(target=self._run, name=f"HTTPAlertProvider-{self.name}", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=35)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning(f"{self.name} alert worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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,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,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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from providers.alert.rsgb_ical_alert_provider import RSGBICALAlertProvider
|
||||||
|
|
||||||
|
|
||||||
|
class RSGBHFContests(RSGBICALAlertProvider):
|
||||||
|
"""Alert provider for RSGB HF Contest calendar"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 30
|
||||||
|
ALERTS_URL = "https://calendar.google.com/calendar/ical/a5ff31ebb1b4834dc7fff4c5415ae8251c6a9aa11f98c6af6e472b6c552b1915%40group.calendar.google.com/public/basic.ics"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__("RSGB HF Contests", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from providers.alert.rsgb_ical_alert_provider import RSGBICALAlertProvider
|
||||||
|
|
||||||
|
|
||||||
|
class RSGBVHFContests(RSGBICALAlertProvider):
|
||||||
|
"""Alert provider for RSGB VHF Contest calendar"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 30
|
||||||
|
ALERTS_URL = "https://calendar.google.com/calendar/ical/40f3552bff39a016f1cdca205864177070dcad68d55be17eb061cb021f39f96c%40group.calendar.google.com/public/basic.ics"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__("RSGB VHF Contests", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
|
||||||
@@ -2,6 +2,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
import pytz
|
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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -42,7 +42,13 @@ class CallsignDataProvider:
|
|||||||
|
|
||||||
if self.enabled:
|
if self.enabled:
|
||||||
if callsign in self._storage:
|
if callsign in self._storage:
|
||||||
return self._storage[callsign]
|
try:
|
||||||
|
return self._storage[callsign]
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
# Catch exceptions due to e.g. old versions of objects in the cache that are no longer compatible,
|
||||||
|
# and remove them from the cache.
|
||||||
|
del self._storage[callsign]
|
||||||
|
return None
|
||||||
else:
|
else:
|
||||||
c = self._perform_new_lookup(callsign, lookup_credentials)
|
c = self._perform_new_lookup(callsign, lookup_credentials)
|
||||||
if c:
|
if c:
|
||||||
|
|||||||
@@ -32,11 +32,15 @@ class FileDownloadCallsignDataProvider(CallsignDataProvider):
|
|||||||
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
||||||
# subsequent polls, so start() returns immediately and the application can continue starting.
|
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||||
logger.info(f"Set up query of {self.name} callsign reference data every {self._poll_interval!s} days.")
|
logger.info(f"Set up query of {self.name} callsign reference data every {self._poll_interval!s} days.")
|
||||||
self._thread = Thread(target=self._run, name=f"FileDownloadCallsignDataProvider-{self.name}")
|
self._thread = Thread(target=self._run, name=f"FileDownloadCallsignDataProvider-{self.name}", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=35)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning(f"{self.name} callsign data worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -124,8 +124,8 @@ class HamQTH(APIQueryCallsignDataProvider):
|
|||||||
lat = None
|
lat = None
|
||||||
lon = None
|
lon = None
|
||||||
if (
|
if (
|
||||||
"latitude" in data
|
data.get("latitude") is not None
|
||||||
and "longitude" in data
|
and data.get("longitude") is not None
|
||||||
and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0)
|
and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0)
|
||||||
and -89.9 < float(data["latitude"]) < 89.9
|
and -89.9 < float(data["latitude"]) < 89.9
|
||||||
):
|
):
|
||||||
@@ -134,7 +134,7 @@ class HamQTH(APIQueryCallsignDataProvider):
|
|||||||
|
|
||||||
# Check for sensible grids
|
# Check for sensible grids
|
||||||
grid = None
|
grid = None
|
||||||
if "grid" in data and not data["grid"].startswith("AA00"):
|
if data.get("grid") and not data["grid"].startswith("AA00"):
|
||||||
grid = data["grid"]
|
grid = data["grid"]
|
||||||
|
|
||||||
return Callsign(
|
return Callsign(
|
||||||
@@ -143,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=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 data.get("adif") is not None else None,
|
||||||
cq_zone=int(data["cq"]) if "cq" in data else None,
|
cq_zone=int(data["cq"]) if data.get("cq") is not None else None,
|
||||||
itu_zone=int(data["itu"]) if "itu" in data else None,
|
itu_zone=int(data["itu"]) if data.get("itu") is not None else None,
|
||||||
location_source=LocationSourceForCallsign.HOME_QTH,
|
location_source=LocationSourceForCallsign.HOME_QTH,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -150,8 +150,8 @@ class QRZ(APIQueryCallsignDataProvider):
|
|||||||
lat = None
|
lat = None
|
||||||
lon = None
|
lon = None
|
||||||
if (
|
if (
|
||||||
"latitude" in data
|
data.get("latitude") is not None
|
||||||
and "longitude" in data
|
and data.get("longitude") is not None
|
||||||
and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0)
|
and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0)
|
||||||
and -89.9 < float(data["latitude"]) < 89.9
|
and -89.9 < float(data["latitude"]) < 89.9
|
||||||
):
|
):
|
||||||
@@ -160,7 +160,7 @@ class QRZ(APIQueryCallsignDataProvider):
|
|||||||
|
|
||||||
# Check for sensible grids
|
# Check for sensible grids
|
||||||
grid = None
|
grid = None
|
||||||
if "grid" in data and not data["grid"].startswith("AA00"):
|
if data.get("grid") and not data["grid"].startswith("AA00"):
|
||||||
grid = data["grid"]
|
grid = data["grid"]
|
||||||
|
|
||||||
return Callsign(
|
return Callsign(
|
||||||
@@ -169,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=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 data.get("adif") is not None else None,
|
||||||
cq_zone=int(data["cqzone"]) if "cqzone" in data else None,
|
cq_zone=int(data["cqzone"]) if data.get("cqzone") is not None else None,
|
||||||
itu_zone=int(data["ituzone"]) if "ituzone" in data else None,
|
itu_zone=int(data["ituzone"]) if data.get("ituzone") is not None else None,
|
||||||
location_source=LocationSourceForCallsign.HOME_QTH,
|
location_source=LocationSourceForCallsign.HOME_QTH,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import io
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from core.enums import SIGRefType
|
||||||
|
from data.sig_ref import SIGRef
|
||||||
|
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class DCE(FileDownloadSIGRefDataProvider):
|
||||||
|
"""SIG ref data provider for Diploma Castillos de España"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 365
|
||||||
|
SIG = "DCE"
|
||||||
|
DATA_URL = "https://www.acracb.org/dce/descargas/General/directorio_referencias_dce.xls"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
|
def _http_response_to_data(self, http_response):
|
||||||
|
new_data = []
|
||||||
|
|
||||||
|
file_stream = io.BytesIO(http_response.content)
|
||||||
|
df = pd.read_excel(file_stream, engine="xlrd", header=None)
|
||||||
|
|
||||||
|
for index, row in df.iterrows():
|
||||||
|
if row.iloc[0] and row.iloc[2]:
|
||||||
|
new_data.append(
|
||||||
|
SIGRef(sig=self.SIG, id=row.iloc[0].strip(), name=row.iloc[2].strip(), ref_type=SIGRefType.CASTLE)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||||
|
# the data in this case
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||||
|
# is available for other threads e.g. the web server.
|
||||||
|
sleep(0.001)
|
||||||
|
|
||||||
|
return new_data
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import io
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from core.enums import SIGRefType
|
||||||
|
from data.sig_ref import SIGRef
|
||||||
|
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class DEFE(FileDownloadSIGRefDataProvider):
|
||||||
|
"""SIG ref data provider for Diploma Estationes de Ferrocarril de España"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 365
|
||||||
|
SIG = "DEFE"
|
||||||
|
DATA_URL = "https://www.acracb.org/defe/descargas/General/directorio_referencias_defe.xls"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
|
def _http_response_to_data(self, http_response):
|
||||||
|
new_data = []
|
||||||
|
|
||||||
|
file_stream = io.BytesIO(http_response.content)
|
||||||
|
df = pd.read_excel(file_stream, engine="xlrd", header=None)
|
||||||
|
|
||||||
|
for index, row in df.iterrows():
|
||||||
|
# Skip the header row
|
||||||
|
if str(row.iloc[0]) == "NºDEFE":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if row.iloc[0] and row.iloc[1]:
|
||||||
|
new_data.append(
|
||||||
|
SIGRef(sig=self.SIG, id=row.iloc[0].strip(), name=row.iloc[1].strip(), ref_type=SIGRefType.BUILDING)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||||
|
# the data in this case
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||||
|
# is available for other threads e.g. the web server.
|
||||||
|
sleep(0.001)
|
||||||
|
|
||||||
|
return new_data
|
||||||
@@ -23,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")
|
||||||
@@ -49,7 +53,7 @@ class DME(LocalFileSIGRefDataProvider):
|
|||||||
|
|
||||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest
|
# 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
|
# of the data in this case
|
||||||
if self._stop:
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import csv
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
from core.enums import SIGRefType
|
||||||
|
from data.sig_ref import SIGRef
|
||||||
|
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class DMUE(FileDownloadSIGRefDataProvider):
|
||||||
|
"""SIG ref data provider for Diploma Museos de España"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 365
|
||||||
|
SIG = "DMUE"
|
||||||
|
DATA_URL = "https://dmue.radiogalena.es/nom_dmue.csv"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
|
def _http_response_to_data(self, http_response):
|
||||||
|
new_data = []
|
||||||
|
|
||||||
|
for row in csv.reader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"):
|
||||||
|
if len(row) > 1 and row[0] and row[1]:
|
||||||
|
new_data.append(
|
||||||
|
SIGRef(sig=self.SIG, id=row[0].strip(), name=row[1].strip(), ref_type=SIGRefType.BUILDING)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||||
|
# the data in this case
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||||
|
# is available for other threads e.g. the web server.
|
||||||
|
sleep(0.001)
|
||||||
|
|
||||||
|
return new_data
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import io
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from core.enums import SIGRefType
|
||||||
|
from data.sig_ref import SIGRef
|
||||||
|
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class DMVE(FileDownloadSIGRefDataProvider):
|
||||||
|
"""SIG ref data provider for Diploma Monumentos y Vestigios de España"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 365
|
||||||
|
SIG = "DMVE"
|
||||||
|
DATA_URL = "https://www.acracb.org/dmve/descargas/General/directorio_referencias_dmve.xls"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
|
def _http_response_to_data(self, http_response):
|
||||||
|
new_data = []
|
||||||
|
|
||||||
|
file_stream = io.BytesIO(http_response.content)
|
||||||
|
# Despide the .xls extension this is actually an xlsx file, so we need openpyxl not xlrd
|
||||||
|
df = pd.read_excel(file_stream, engine="openpyxl", header=None)
|
||||||
|
|
||||||
|
for index, row in df.iterrows():
|
||||||
|
ref = row.iloc[0]
|
||||||
|
name = row.iloc[1]
|
||||||
|
|
||||||
|
# Skip the header row and blank rows
|
||||||
|
if str(ref) == "REF.":
|
||||||
|
continue
|
||||||
|
if pd.isna(ref) or pd.isna(name):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ref and name:
|
||||||
|
new_data.append(SIGRef(sig=self.SIG, id=ref.strip(), name=name.strip(), ref_type=SIGRefType.BUILDING))
|
||||||
|
|
||||||
|
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||||
|
# the data in this case
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||||
|
# is available for other threads e.g. the web server.
|
||||||
|
sleep(0.001)
|
||||||
|
|
||||||
|
return new_data
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from threading import Event, Thread
|
from threading import Thread
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
||||||
@@ -21,19 +21,21 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
|
|||||||
self._url = url
|
self._url = url
|
||||||
self._poll_interval = poll_interval
|
self._poll_interval = poll_interval
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._stop_event = Event()
|
|
||||||
self._url_data_cache = URLDataCache(f"sigrefdata_{sig_name}")
|
self._url_data_cache = URLDataCache(f"sigrefdata_{sig_name}")
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
||||||
# subsequent polls, so start() returns immediately and the application can continue starting.
|
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||||
logger.info(f"Set up query of {self.sig_name} SIG ref data every {self._poll_interval!s} days.")
|
logger.info(f"Set up query of {self.sig_name} SIG ref data every {self._poll_interval!s} days.")
|
||||||
self._thread = Thread(target=self._run, name=f"FileDownloadSIGRefDataProvider-{self.sig_name}")
|
self._thread = Thread(target=self._run, name=f"FileDownloadSIGRefDataProvider-{self.sig_name}", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
super().stop()
|
super().stop()
|
||||||
self._stop_event.set()
|
if self._thread:
|
||||||
|
self._thread.join(timeout=35)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning(f"{self.sig_name} SIG ref data worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from threading import Event
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ class SIGRefDataProvider:
|
|||||||
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.reference_count = 0
|
self.reference_count = 0
|
||||||
self._stop = False
|
self._stop_event = Event()
|
||||||
|
|
||||||
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"""
|
||||||
@@ -30,20 +31,22 @@ class SIGRefDataProvider:
|
|||||||
"""Stop any threads and prepare for application shutdown. Subclasses should implement this method and call
|
"""Stop any threads and prepare for application shutdown. Subclasses should implement this method and call
|
||||||
super()."""
|
super()."""
|
||||||
|
|
||||||
self._stop = True
|
self._stop_event.set()
|
||||||
|
|
||||||
def _add_data(self, new_data):
|
def _add_data(self, new_data):
|
||||||
"""Add all the provided reference data objects to the data store."""
|
"""Add all the provided reference data objects to the data store."""
|
||||||
|
|
||||||
# with transact() batches all writes together to save making thousands of individual sqlite writes
|
# with transact() batches all writes together to save making thousands of individual sqlite writes. However,
|
||||||
with DATA_STORE.sigrefs.transact():
|
# that means that each provider holds the lock while it writes, and the default behaviour for other attempted
|
||||||
|
# transact()s is to fail if they can't get the lock (?!). This behaviour is fixed by retry=True.
|
||||||
|
with DATA_STORE.sigrefs.transact(retry=True):
|
||||||
for d in new_data:
|
for d in new_data:
|
||||||
DATA_STORE.sigrefs.set(f"{self.sig_name}:{d.id}", d)
|
DATA_STORE.sigrefs.set(f"{self.sig_name}:{d.id}", d)
|
||||||
|
|
||||||
# For the big data sources, loading will take a few minutes. If we want to shut down the software neatly
|
# For the big data sources, loading will take a few minutes. If we want to shut down the software neatly
|
||||||
# within the first few minutes of startup, we need a way to abort this expensive process of filling up the
|
# within the first few minutes of startup, we need a way to abort this expensive process of filling up the
|
||||||
# disk cache.
|
# disk cache.
|
||||||
if self._stop:
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
self.reference_count = len(new_data)
|
self.reference_count = len(new_data)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class Toilets(LocalFileSIGRefDataProvider):
|
|||||||
|
|
||||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest
|
# 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
|
# of the data in this case
|
||||||
if self._stop:
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
return new_data
|
return new_data
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class ZLOTA(FileDownloadSIGRefDataProvider):
|
|||||||
try:
|
try:
|
||||||
ref_type = SIGRefType(ref["asset_type"].title().upper())
|
ref_type = SIGRefType(ref["asset_type"].title().upper())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
ref_type = SIGRefType.UNKNOWN
|
ref_type = None
|
||||||
|
|
||||||
new_ref = SIGRef(
|
new_ref = SIGRef(
|
||||||
sig=self.SIG,
|
sig=self.SIG,
|
||||||
|
|||||||
@@ -67,11 +67,15 @@ class GIROIonosonde(SolarConditionsProvider):
|
|||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
logger.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
|
logger.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
|
||||||
self._thread = Thread(target=self._run, name="GIROIonosondeDataProvider")
|
self._thread = Thread(target=self._run, name="GIROIonosondeDataProvider", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=35)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning("GIRO ionosonde worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
# Real interval at which we poll is the "once per hour" divided by the number of stations, so each one gets
|
# Real interval at which we poll is the "once per hour" divided by the number of stations, so each one gets
|
||||||
|
|||||||
@@ -25,11 +25,15 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
|
|||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
logger.info(f"Set up query of {self.name} solar conditions API every {self._poll_interval!s} seconds.")
|
logger.info(f"Set up query of {self.name} solar conditions API every {self._poll_interval!s} seconds.")
|
||||||
self._thread = Thread(target=self._run, name=f"HTTPSolarConditionsProvider-{self.name}")
|
self._thread = Thread(target=self._run, name=f"HTTPSolarConditionsProvider-{self.name}", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=35)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning(f"{self.name} solar conditions worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -32,11 +32,15 @@ class KC2GProp(SolarConditionsProvider):
|
|||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
logger.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.")
|
logger.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.")
|
||||||
self._thread = Thread(target=self._run, name="KC2GPropProvider")
|
self._thread = Thread(target=self._run, name="KC2GPropProvider", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=35)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning("KC2G ionosonde worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
+55
-35
@@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from threading import Thread
|
from threading import Event, Thread
|
||||||
|
|
||||||
import aprslib
|
import aprslib
|
||||||
import pytz
|
import pytz
|
||||||
@@ -17,49 +17,69 @@ class APRSIS(SpotProvider):
|
|||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__("APRS-IS", provider_config)
|
super().__init__("APRS-IS", provider_config)
|
||||||
self._thread = Thread(target=self._connect, name="APRSISSpotProvider")
|
self._thread = None
|
||||||
self._thread.daemon = True
|
|
||||||
self._aprsis = None
|
self._aprsis = None
|
||||||
|
self._stop_event = Event()
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
self._thread = Thread(target=self._run, name="APRSISSpotProvider", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def _connect(self):
|
def _run(self):
|
||||||
self._aprsis = aprslib.IS(SERVER_OWNER_CALLSIGN)
|
while not self._stop_event.is_set():
|
||||||
self.status = "Connecting"
|
try:
|
||||||
logger.info("APRS-IS connecting...")
|
self._aprsis = aprslib.IS(SERVER_OWNER_CALLSIGN)
|
||||||
self._aprsis.connect()
|
self.status = "Connecting"
|
||||||
self._aprsis.consumer(self._handle)
|
logger.info("APRS-IS connecting...")
|
||||||
logger.info("APRS-IS connected.")
|
self._aprsis.connect()
|
||||||
|
logger.info("APRS-IS connected.")
|
||||||
|
self._aprsis.consumer(self._handle, immortal=True)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
if not self._stop_event.is_set():
|
||||||
|
self.status = "Error"
|
||||||
|
logger.exception("Exception in APRS-IS provider")
|
||||||
|
|
||||||
|
if not self._stop_event.is_set():
|
||||||
|
self._stop_event.wait(timeout=5)
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self.status = "Shutting down"
|
self.status = "Shutting down"
|
||||||
self._aprsis.close()
|
self._stop_event.set()
|
||||||
self._thread.join()
|
if self._aprsis:
|
||||||
|
self._aprsis.close()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=15)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning("APRS-IS worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _handle(self, data):
|
def _handle(self, data):
|
||||||
# Split SSID in "from" call and store separately
|
try:
|
||||||
from_parts = str(data["from"]).split("-")
|
# Split SSID in "from" call and store separately
|
||||||
dx_call = from_parts[0].upper()
|
from_parts = str(data["from"]).split("-")
|
||||||
dx_ssid = from_parts[1].upper() if len(from_parts) > 1 else None
|
dx_call = from_parts[0].upper()
|
||||||
via_parts = str(data["via"]).split("-")
|
dx_ssid = from_parts[1].upper() if len(from_parts) > 1 else None
|
||||||
de_call = via_parts[0].upper()
|
via_parts = str(data["via"]).split("-")
|
||||||
de_ssid = via_parts[1].upper() if len(via_parts) > 1 else None
|
de_call = via_parts[0].upper()
|
||||||
spot = Spot(
|
de_ssid = via_parts[1].upper() if len(via_parts) > 1 else None
|
||||||
source="APRS-IS",
|
spot = Spot(
|
||||||
dx_call=dx_call,
|
source="APRS-IS",
|
||||||
dx_ssid=dx_ssid,
|
dx_call=dx_call,
|
||||||
de_call=de_call,
|
dx_ssid=dx_ssid,
|
||||||
de_ssid=de_ssid,
|
de_call=de_call,
|
||||||
comment=str(data["comment"]) if "comment" in data else None,
|
de_ssid=de_ssid,
|
||||||
dx_latitude=float(data["latitude"]) if "latitude" in data else None,
|
comment=str(data["comment"]) if "comment" in data else None,
|
||||||
dx_longitude=float(data["longitude"]) if "longitude" in data else None,
|
dx_latitude=float(data["latitude"]) if data.get("latitude") is not None else None,
|
||||||
time=datetime.now(pytz.UTC).timestamp(),
|
dx_longitude=float(data["longitude"]) if data.get("longitude") is not None else None,
|
||||||
) # APRS-IS spots are live so we can assume spot time is "now"
|
time=datetime.now(pytz.UTC).timestamp(),
|
||||||
|
) # APRS-IS spots are live so we can assume spot time is "now"
|
||||||
|
|
||||||
# Add to our list
|
# Add to our list
|
||||||
self._submit(spot)
|
self._submit(spot)
|
||||||
|
|
||||||
self.status = "OK"
|
self.status = "OK"
|
||||||
self.last_update_time = datetime.now(pytz.UTC)
|
self.last_update_time = datetime.now(pytz.UTC)
|
||||||
logger.debug("Data received from APRS-IS.")
|
logger.debug("Data received from APRS-IS.")
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Exception handling APRS-IS packet")
|
||||||
|
|||||||
+18
-16
@@ -1,8 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from threading import Thread
|
from threading import Event, Thread
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import telnetlib3
|
import telnetlib3
|
||||||
@@ -41,23 +40,26 @@ class DXCluster(SpotProvider):
|
|||||||
self._LINE_PATTERN_ALLOW_RBN if self._allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN
|
self._LINE_PATTERN_ALLOW_RBN if self._allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN
|
||||||
)
|
)
|
||||||
self._telnet = None
|
self._telnet = None
|
||||||
self._thread = Thread(target=self._handle, name=f"DXClusterSpotProvider-{self.name}")
|
self._thread = None
|
||||||
self._thread.daemon = True
|
self._stop_event = Event()
|
||||||
self._running = True
|
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
self._thread = Thread(target=self._handle, name=f"DXClusterSpotProvider-{self.name}", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._running = False
|
self._stop_event.set()
|
||||||
if self._telnet:
|
if self._telnet:
|
||||||
self._telnet.close()
|
self._telnet.close()
|
||||||
self._thread.join()
|
if self._thread:
|
||||||
|
self._thread.join(timeout=15)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning(f"DX Cluster {self._hostname} worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _handle(self):
|
def _handle(self):
|
||||||
while self._running:
|
while not self._stop_event.is_set():
|
||||||
connected = False
|
connected = False
|
||||||
while not connected and self._running:
|
while not connected and not self._stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
self.status = "Connecting"
|
self.status = "Connecting"
|
||||||
logger.info(f"DX Cluster {self._hostname} connecting...")
|
logger.info(f"DX Cluster {self._hostname} connecting...")
|
||||||
@@ -69,14 +71,14 @@ class DXCluster(SpotProvider):
|
|||||||
except ConnectionRefusedError:
|
except ConnectionRefusedError:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logger.warning(f"Connection refused to DX cluster {self._hostname}")
|
logger.warning(f"Connection refused to DX cluster {self._hostname}")
|
||||||
sleep(300)
|
self._stop_event.wait(timeout=300)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logger.exception(f"Exception while connecting to DX Cluster Provider ({self._hostname}).")
|
logger.exception(f"Exception while connecting to DX Cluster Provider ({self._hostname}).")
|
||||||
sleep(5)
|
self._stop_event.wait(timeout=5)
|
||||||
|
|
||||||
self.status = "Waiting for Data"
|
self.status = "Waiting for Data"
|
||||||
while connected and self._running:
|
while connected and not self._stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
# Check new telnet info against regular expression
|
# Check new telnet info against regular expression
|
||||||
telnet_output = self._telnet.read_until("\n".encode("latin-1"))
|
telnet_output = self._telnet.read_until("\n".encode("latin-1"))
|
||||||
@@ -106,19 +108,19 @@ class DXCluster(SpotProvider):
|
|||||||
|
|
||||||
except EOFError:
|
except EOFError:
|
||||||
connected = False
|
connected = False
|
||||||
if self._running:
|
if not self._stop_event.is_set():
|
||||||
self.status = "Restarting"
|
self.status = "Restarting"
|
||||||
logger.warning(f"Disconnected from DX Cluster {self._hostname}. Reconnecting...")
|
logger.warning(f"Disconnected from DX Cluster {self._hostname}. Reconnecting...")
|
||||||
sleep(5)
|
self._stop_event.wait(timeout=5)
|
||||||
else:
|
else:
|
||||||
logger.info(f"DX Cluster {self._hostname} shutting down...")
|
logger.info(f"DX Cluster {self._hostname} shutting down...")
|
||||||
self.status = "Shutting down"
|
self.status = "Shutting down"
|
||||||
except Exception:
|
except Exception:
|
||||||
connected = False
|
connected = False
|
||||||
if self._running:
|
if not self._stop_event.is_set():
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logger.exception(f"Exception in DX Cluster Provider ({self._hostname})")
|
logger.exception(f"Exception in DX Cluster Provider ({self._hostname})")
|
||||||
sleep(5)
|
self._stop_event.wait(timeout=5)
|
||||||
else:
|
else:
|
||||||
logger.info(f"DX Cluster {self._hostname} shutting down...")
|
logger.info(f"DX Cluster {self._hostname} shutting down...")
|
||||||
self.status = "Shutting down"
|
self.status = "Shutting down"
|
||||||
|
|||||||
@@ -66,9 +66,7 @@ class GMA(HTTPSpotProvider):
|
|||||||
if (source_spot["QRG"] != "" and source_spot["QRG"] != "QRT")
|
if (source_spot["QRG"] != "" and source_spot["QRG"] != "QRT")
|
||||||
else None,
|
else None,
|
||||||
# Filter out some weird mode strings
|
# Filter out some weird mode strings
|
||||||
mode=Mode.from_name(source_spot["MODE"].upper())
|
mode=Mode.from_name(source_spot["MODE"].upper()) if "<>" not in source_spot["MODE"] else None,
|
||||||
if "<>" not in source_spot["MODE"]
|
|
||||||
else Mode.UNKNOWN,
|
|
||||||
comment=source_spot["TEXT"],
|
comment=source_spot["TEXT"],
|
||||||
sig_refs=[
|
sig_refs=[
|
||||||
SIGRef(
|
SIGRef(
|
||||||
|
|||||||
@@ -28,12 +28,16 @@ class HTTPSpotProvider(SpotProvider):
|
|||||||
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
||||||
# subsequent polls, so start() returns immediately and the application can continue starting.
|
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||||
logger.info(f"Set up query of {self.name} spot API every {self._poll_interval!s} seconds.")
|
logger.info(f"Set up query of {self.name} spot API every {self._poll_interval!s} seconds.")
|
||||||
self._thread = Thread(target=self._run, name=f"HTTPSpotProvider-{self.name}")
|
self._thread = Thread(target=self._run, name=f"HTTPSpotProvider-{self.name}", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
self._wakeup_event.set()
|
self._wakeup_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=35)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning(f"{self.name} spot worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def force_poll(self):
|
def force_poll(self):
|
||||||
"""Trigger an immediate poll without waiting for the normal interval."""
|
"""Trigger an immediate poll without waiting for the normal interval."""
|
||||||
|
|||||||
+17
-15
@@ -1,8 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from threading import Thread
|
from threading import Event, Thread
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import telnetlib3
|
import telnetlib3
|
||||||
@@ -30,23 +29,26 @@ class RBN(SpotProvider):
|
|||||||
super().__init__(name, provider_config)
|
super().__init__(name, provider_config)
|
||||||
self._port = provider_config["port"]
|
self._port = provider_config["port"]
|
||||||
self._telnet = None
|
self._telnet = None
|
||||||
self._thread = Thread(target=self._handle, name=f"RBNSpotProvider-{self.name}")
|
self._thread = None
|
||||||
self._thread.daemon = True
|
self._stop_event = Event()
|
||||||
self._running = True
|
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
self._thread = Thread(target=self._handle, name=f"RBNSpotProvider-{self.name}", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._running = False
|
self._stop_event.set()
|
||||||
if self._telnet:
|
if self._telnet:
|
||||||
self._telnet.close()
|
self._telnet.close()
|
||||||
self._thread.join()
|
if self._thread:
|
||||||
|
self._thread.join(timeout=15)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning(f"RBN (port {self._port!s}) worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _handle(self):
|
def _handle(self):
|
||||||
while self._running:
|
while not self._stop_event.is_set():
|
||||||
connected = False
|
connected = False
|
||||||
while not connected and self._running:
|
while not connected and not self._stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
self.status = "Connecting"
|
self.status = "Connecting"
|
||||||
logger.info(f"RBN port {self._port!s} connecting...")
|
logger.info(f"RBN port {self._port!s} connecting...")
|
||||||
@@ -58,10 +60,10 @@ class RBN(SpotProvider):
|
|||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logger.exception(f"Exception while connecting to RBN (port {self._port!s}).")
|
logger.exception(f"Exception while connecting to RBN (port {self._port!s}).")
|
||||||
sleep(5)
|
self._stop_event.wait(timeout=5)
|
||||||
|
|
||||||
self.status = "Waiting for Data"
|
self.status = "Waiting for Data"
|
||||||
while connected and self._running:
|
while connected and not self._stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
# Check new telnet info against regular expression
|
# Check new telnet info against regular expression
|
||||||
telnet_output = self._telnet.read_until("\n".encode("latin-1"))
|
telnet_output = self._telnet.read_until("\n".encode("latin-1"))
|
||||||
@@ -91,19 +93,19 @@ class RBN(SpotProvider):
|
|||||||
|
|
||||||
except EOFError:
|
except EOFError:
|
||||||
connected = False
|
connected = False
|
||||||
if self._running:
|
if not self._stop_event.is_set():
|
||||||
self.status = "Restarting"
|
self.status = "Restarting"
|
||||||
logger.warning(f"Disconnected from RBN provider (port {self._port!s}). Reconnecting...")
|
logger.warning(f"Disconnected from RBN provider (port {self._port!s}). Reconnecting...")
|
||||||
sleep(5)
|
self._stop_event.wait(timeout=5)
|
||||||
else:
|
else:
|
||||||
logger.info(f"RBN provider (port {self._port!s}) shutting down...")
|
logger.info(f"RBN provider (port {self._port!s}) shutting down...")
|
||||||
self.status = "Shutting down"
|
self.status = "Shutting down"
|
||||||
except Exception:
|
except Exception:
|
||||||
connected = False
|
connected = False
|
||||||
if self._running:
|
if not self._stop_event.is_set():
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logger.exception(f"Exception in RBN provider (port {self._port!s})")
|
logger.exception(f"Exception in RBN provider (port {self._port!s})")
|
||||||
sleep(5)
|
self._stop_event.wait(timeout=5)
|
||||||
else:
|
else:
|
||||||
logger.info(f"RBN provider (port {self._port!s}) shutting down...")
|
logger.info(f"RBN provider (port {self._port!s}) shutting down...")
|
||||||
self.status = "Shutting down"
|
self.status = "Shutting down"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class UKPacketNet(HTTPSpotProvider):
|
|||||||
)
|
)
|
||||||
comment = (
|
comment = (
|
||||||
f"{comment} {listed_port['baud']!s} baud"
|
f"{comment} {listed_port['baud']!s} baud"
|
||||||
if "baud" in listed_port and listed_port["baud"] > 0
|
if listed_port.get("baud") and listed_port["baud"] > 0
|
||||||
else comment
|
else comment
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ class UKPacketNet(HTTPSpotProvider):
|
|||||||
# very hacky but a lot of node comments contain their frequency as the first or second
|
# very hacky but a lot of node comments contain their frequency as the first or second
|
||||||
# word of their comment, but not in the proper data structure field.
|
# word of their comment, but not in the proper data structure field.
|
||||||
freq = (
|
freq = (
|
||||||
listed_port["freq"] if "freq" in listed_port and listed_port["freq"] > 0 else None
|
listed_port["freq"] if listed_port.get("freq") and listed_port["freq"] > 0 else None
|
||||||
)
|
)
|
||||||
if not freq and comment:
|
if not freq and comment:
|
||||||
possible_freq = comment.split(" ")[0].upper().replace("MHZ", "")
|
possible_freq = comment.split(" ")[0].upper().replace("MHZ", "")
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from threading import Thread
|
from threading import Event, Thread
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from websocket import create_connection
|
from websocket import create_connection
|
||||||
@@ -20,22 +19,24 @@ class WebsocketSpotProvider(SpotProvider):
|
|||||||
self._url = url
|
self._url = url
|
||||||
self._ws = None
|
self._ws = None
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._stopped = False
|
self._stop_event = Event()
|
||||||
self._last_event_id = None
|
self._last_event_id = None
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
logger.info(f"Set up websocket connection to {self.name} spot API.")
|
logger.info(f"Set up websocket connection to {self.name} spot API.")
|
||||||
self._stopped = False
|
self._stop_event.clear()
|
||||||
self._thread = Thread(target=self._run, name=f"WebsocketSpotProvider-{self.name}")
|
self._thread = Thread(target=self._run, name=f"WebsocketSpotProvider-{self.name}")
|
||||||
self._thread.daemon = True
|
self._thread.daemon = True
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._stopped = True
|
self._stop_event.set()
|
||||||
if self._ws:
|
if self._ws:
|
||||||
self._ws.close()
|
self._ws.close()
|
||||||
if self._thread:
|
if self._thread:
|
||||||
self._thread.join()
|
self._thread.join(timeout=15)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning(f"{self.name} websocket worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _on_open(self):
|
def _on_open(self):
|
||||||
self.status = "Waiting for Data"
|
self.status = "Waiting for Data"
|
||||||
@@ -44,14 +45,19 @@ class WebsocketSpotProvider(SpotProvider):
|
|||||||
self.status = "Connecting"
|
self.status = "Connecting"
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while not self._stopped:
|
while not self._stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Connecting to {self.name} spot API...")
|
logger.debug(f"Connecting to {self.name} spot API...")
|
||||||
self.status = "Connecting"
|
self.status = "Connecting"
|
||||||
self._ws = create_connection(self._url, header=HTTP_HEADERS)
|
self._ws = create_connection(self._url, header=HTTP_HEADERS)
|
||||||
self.status = "Connected"
|
self.status = "Connected"
|
||||||
data = self._ws.recv()
|
|
||||||
if data:
|
# Keep reading from this same connection until it drops or we're asked to stop, rather than
|
||||||
|
# reconnecting for every message.
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
data = self._ws.recv()
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
try:
|
try:
|
||||||
new_spot = self._ws_message_to_spot(data)
|
new_spot = self._ws_message_to_spot(data)
|
||||||
if new_spot:
|
if new_spot:
|
||||||
@@ -69,7 +75,16 @@ class WebsocketSpotProvider(SpotProvider):
|
|||||||
logger.exception(f"Exception in Websocket Spot Provider ({self.name})")
|
logger.exception(f"Exception in Websocket Spot Provider ({self.name})")
|
||||||
else:
|
else:
|
||||||
self.status = "Disconnected"
|
self.status = "Disconnected"
|
||||||
sleep(5) # Wait before trying to reconnect
|
finally:
|
||||||
|
if self._ws:
|
||||||
|
try:
|
||||||
|
self._ws.close()
|
||||||
|
except Exception:
|
||||||
|
# No problem, we were getting rid of this object anyway.
|
||||||
|
pass
|
||||||
|
self._ws = None
|
||||||
|
if not self._stop_event.is_set():
|
||||||
|
self._stop_event.wait(timeout=5) # Wait before trying to reconnect
|
||||||
|
|
||||||
def _ws_message_to_spot(self, b):
|
def _ws_message_to_spot(self, b):
|
||||||
"""Convert a WS message received from the API into a spot. The exact message data (in bytes) is provided here so the
|
"""Convert a WS message received from the API into a spot. The exact message data (in bytes) is provided here so the
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class WWBOTA(SSESpotProvider):
|
|||||||
name=ref["name"],
|
name=ref["name"],
|
||||||
latitude=ref["lat"],
|
latitude=ref["lat"],
|
||||||
longitude=ref["long"],
|
longitude=ref["long"],
|
||||||
ref_type=SIGRefType.BUNKER
|
ref_type=SIGRefType.BUNKER,
|
||||||
)
|
)
|
||||||
refs.append(sigref)
|
refs.append(sigref)
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ class WWBOTA(SSESpotProvider):
|
|||||||
dx_call=source_spot["call"].upper(),
|
dx_call=source_spot["call"].upper(),
|
||||||
de_call=source_spot["spotter"].upper(),
|
de_call=source_spot["spotter"].upper(),
|
||||||
freq=float(source_spot["freq"]) * 1000000,
|
freq=float(source_spot["freq"]) * 1000000,
|
||||||
mode=Mode.from_name(source_spot["mode"].upper()) if "mode" in source_spot else Mode.UNKNOWN,
|
mode=Mode.from_name(source_spot["mode"].upper()) if source_spot.get("mode") else None,
|
||||||
comment=source_spot["comment"],
|
comment=source_spot["comment"],
|
||||||
sig="WWBOTA",
|
sig="WWBOTA",
|
||||||
sig_refs=refs,
|
sig_refs=refs,
|
||||||
|
|||||||
@@ -29,11 +29,15 @@ class FileDownloadStaticDataProvider(StaticDataProvider):
|
|||||||
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
||||||
# subsequent polls, so start() returns immediately and the application can continue starting.
|
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||||
logger.info(f"Set up query of {self.name} static reference data every {self._poll_interval!s} days.")
|
logger.info(f"Set up query of {self.name} static reference data every {self._poll_interval!s} days.")
|
||||||
self._thread = Thread(target=self._run, name=f"FileDownloadStaticDataProvider-{self.name}")
|
self._thread = Thread(target=self._run, name=f"FileDownloadStaticDataProvider-{self.name}", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=35)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning(f"{self.name} static data worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "spothole"
|
name = "spothole"
|
||||||
version = "2.1-pre"
|
version = "2.1.1"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Ian Renton", email = "ian@ianrenton.com" },
|
{ name = "Ian Renton", email = "ian@ianrenton.com" },
|
||||||
]
|
]
|
||||||
|
|||||||
+5
-1
@@ -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,9 +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
|
pdfplumber~=0.11.10
|
||||||
|
xlrd~=2.0.2
|
||||||
|
openpyxl~=3.1.5
|
||||||
|
icalendar~=7.3.0
|
||||||
+24
-7
@@ -5,25 +5,35 @@ 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__)
|
||||||
|
|
||||||
|
_shutdown_in_progress = False
|
||||||
|
|
||||||
|
|
||||||
def shutdown(_signum=None, _frame=None):
|
def shutdown(_signum=None, _frame=None):
|
||||||
"""Shutdown function"""
|
"""Shutdown function"""
|
||||||
|
|
||||||
|
# Check if this is the second time a shutdown was asked for, if so immediately kill the program.
|
||||||
|
global _shutdown_in_progress
|
||||||
|
if _shutdown_in_progress:
|
||||||
|
os._exit(1)
|
||||||
|
_shutdown_in_progress = True
|
||||||
|
|
||||||
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()
|
||||||
os._exit(0)
|
logger.info("Stopped.")
|
||||||
|
|
||||||
|
|
||||||
# Main function
|
# Main function
|
||||||
@@ -41,8 +51,9 @@ if __name__ == "__main__":
|
|||||||
logger.info("Starting...")
|
logger.info("Starting...")
|
||||||
logger.info(f"This is Spothole version {SOFTWARE_VERSION}. This instance is run by {SERVER_OWNER_CALLSIGN}.")
|
logger.info(f"This is Spothole version {SOFTWARE_VERSION}. This instance is run by {SERVER_OWNER_CALLSIGN}.")
|
||||||
|
|
||||||
# Shut down gracefully on SIGINT
|
# Shut down gracefully on SIGINT or SIGTERM
|
||||||
signal.signal(signal.SIGINT, shutdown)
|
signal.signal(signal.SIGINT, shutdown)
|
||||||
|
signal.signal(signal.SIGTERM, shutdown)
|
||||||
|
|
||||||
# Set up data store
|
# Set up data store
|
||||||
DATA_STORE.setup()
|
DATA_STORE.setup()
|
||||||
@@ -57,10 +68,16 @@ if __name__ == "__main__":
|
|||||||
status_reporter = StatusReporter(run_interval=5)
|
status_reporter = StatusReporter(run_interval=5)
|
||||||
status_reporter.start()
|
status_reporter.start()
|
||||||
|
|
||||||
|
# Run the telnet server
|
||||||
|
if TELNET_SERVER_ENABLED:
|
||||||
|
TELNET_SERVER.start(port=TELNET_SERVER_PORT)
|
||||||
|
|
||||||
# Set up the web server
|
# Set up the web server
|
||||||
WEB_SERVER.setup()
|
WEB_SERVER.setup()
|
||||||
|
|
||||||
# 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
|
||||||
# be the last thing we do. web_server.stop() triggers an await condition in the web server which finishes the main
|
|
||||||
# thread.
|
|
||||||
WEB_SERVER.start()
|
WEB_SERVER.start()
|
||||||
|
|
||||||
|
# Block the main thread until a termination signal arrives and shutdown() is running.
|
||||||
|
while not _shutdown_in_progress:
|
||||||
|
signal.pause()
|
||||||
|
|||||||
+93
-16
@@ -17,11 +17,16 @@ info:
|
|||||||
|
|
||||||
### 2.1
|
### 2.1
|
||||||
|
|
||||||
* Added DTMBA, FEA, BIWOTA, COTA & PGA SIGs
|
* 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.
|
* Removed the distinction between LSB & USB (both will now show as SSB) and between the various digital voice modes, which will now show as DV.
|
||||||
* Added `sig_type`, `icon`, `region_flag` and `refs_globally_unique` to SIG information
|
* Added `sig_type`, `icon`, `region_flag` and `refs_globally_unique` to SIG data
|
||||||
* Unknown modes and mode types now return "UNKNOWN" not null
|
* Added `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
|
* 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
|
||||||
|
|
||||||
@@ -138,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'
|
||||||
@@ -178,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'
|
||||||
@@ -209,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'
|
||||||
@@ -243,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'
|
||||||
@@ -653,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
|
||||||
@@ -661,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:
|
||||||
@@ -675,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
|
||||||
@@ -781,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
|
||||||
@@ -860,7 +901,12 @@ components:
|
|||||||
- WAB
|
- WAB
|
||||||
- WAI
|
- WAI
|
||||||
- DME
|
- DME
|
||||||
|
- DMF
|
||||||
- FEA
|
- FEA
|
||||||
|
- DMUE
|
||||||
|
- DMVE
|
||||||
|
- DCE
|
||||||
|
- DEFE
|
||||||
- DTMBA
|
- DTMBA
|
||||||
- BIWOTA
|
- BIWOTA
|
||||||
- COTA
|
- COTA
|
||||||
@@ -903,9 +949,16 @@ components:
|
|||||||
- REGION
|
- REGION
|
||||||
- GRID
|
- GRID
|
||||||
- TOILET
|
- TOILET
|
||||||
- UNKNOWN
|
|
||||||
example: PARK
|
example: PARK
|
||||||
|
|
||||||
|
AlertType:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- XOTA
|
||||||
|
- DXPEDITION
|
||||||
|
- CONTEST
|
||||||
|
example: XOTA
|
||||||
|
|
||||||
Continent:
|
Continent:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
@@ -970,7 +1023,6 @@ components:
|
|||||||
- FSK
|
- FSK
|
||||||
- PKT
|
- PKT
|
||||||
- MSK144
|
- MSK144
|
||||||
- UNKNOWN
|
|
||||||
example: SSB
|
example: SSB
|
||||||
|
|
||||||
ModeType:
|
ModeType:
|
||||||
@@ -979,7 +1031,6 @@ components:
|
|||||||
- CW
|
- CW
|
||||||
- PHONE
|
- PHONE
|
||||||
- DATA
|
- DATA
|
||||||
- UNKNOWN
|
|
||||||
example: CW
|
example: CW
|
||||||
|
|
||||||
ModeSource:
|
ModeSource:
|
||||||
@@ -1280,6 +1331,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:
|
||||||
@@ -1433,6 +1488,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.
|
||||||
@@ -1441,6 +1503,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:
|
||||||
@@ -2041,14 +2107,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.
|
||||||
|
|||||||
+47
-37
@@ -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"], options["sigs"], "fa-globe-africa")}'></i></span> ${sigSourceText}</td>`);
|
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid ${a["icon"]}'></i></span> ${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"], options["sigs"], "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
|
||||||
|
|||||||
@@ -285,20 +285,6 @@ function callWanted(call) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the Font Awesome icon for a given SIG. If the SIG is unknown, the provided default symbol will be returned
|
|
||||||
// The sig is provided by name as a string, while all_sigs contains all the SIG info from the options call. This
|
|
||||||
// is because sometimes the sig name arrives via a spot and therefore we need to look up the rest of the SIG data
|
|
||||||
// separately.
|
|
||||||
function sigToIcon(sig, all_sigs, defaultIcon) {
|
|
||||||
if (sig) {
|
|
||||||
const match = all_sigs.find((check_sig) => check_sig["name"].toUpperCase() === sig.toUpperCase());
|
|
||||||
if (match) {
|
|
||||||
return match["icon"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defaultIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Startup
|
// Startup
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
usePreferredTheme();
|
usePreferredTheme();
|
||||||
|
|||||||
+10
-4
@@ -223,7 +223,7 @@ function updateMap() {
|
|||||||
// Get an icon for a spot, based on its band, using PSK Reporter colours, its program etc.
|
// 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"], options["sigs"], "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"], options["sigs"], "fa-tower-cell")}'></i></span> ${sigSourceText} ${sig_refs}</span><br/>`;
|
ttt += `<span class='nowrap'><span class='icon-wrapper'><i class='fa-solid ${s["icon"]}'></i></span> ${sigSourceText} ${sig_refs}</span><br/>`;
|
||||||
|
|
||||||
// Time
|
// Time
|
||||||
ttt += `<span class='icon-wrapper'><i class='fa-solid fa-clock markerPopupIcon'></i></span> ${moment.unix(s["time"]).fromNow()}`;
|
ttt += `<span class='icon-wrapper'><i class='fa-solid fa-clock markerPopupIcon'></i></span> ${moment.unix(s["time"]).fromNow()}`;
|
||||||
|
|||||||
+11
-2
@@ -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"], options["sigs"], "fa-tower-cell")}'></i></span> ${typeText}</td>`);
|
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid ${s["icon"]}'></i></span> ${typeText}</td>`);
|
||||||
}
|
}
|
||||||
if (showRef) {
|
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"], options["sigs"], "fa-tower-cell")}'></i></span> ${typeText} `);
|
$td2floatleft.append(`<span class='icon-wrapper'><i class='fa-solid ${s["icon"]}'></i></span> ${typeText} `);
|
||||||
}
|
}
|
||||||
if (showRef) {
|
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.
|
||||||
|
|||||||
+4
-2
@@ -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");
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
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._thread = 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.
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=asyncio.run, args=(self._start_internal(),), name="TelnetServer", daemon=True
|
||||||
|
)
|
||||||
|
self._thread.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)
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=15)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning("Telnet server background thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
|
@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. Iterate over a copy
|
||||||
|
# since a new client can connect (mutating self._clients) while we're awaiting a write below.
|
||||||
|
disconnected_clients = set()
|
||||||
|
for writer in list(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()
|
||||||
+18
-4
@@ -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>
|
||||||
@@ -102,9 +110,11 @@
|
|||||||
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, 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), el Diploma
|
the Air, Worked All Britain (WAB), Worked All Ireland (WAI), Diploma Municipios de España (DME), Diploma
|
||||||
Faros de España (FEA), il Diploma Teatri Musei e Belle Arti (DTMBA), British Inland Waterways on the Air
|
Faros de España (FEA), Diploma Muesos de España (DMUE), Diploma Castillos de España (DCE), Diploma Monumentos y
|
||||||
(BIWOTA), Castles on the Air (COTA), Polish Gmina Award (PGA), and Toilets on the Air.</p>
|
Vestigios de España (DMVE), Diploma Estaciones de Ferrocarril de España (DEFE), Diploma Teatri Musei e Belle
|
||||||
|
Arti (DTMBA), British Inland Waterways on the Air (BIWOTA), Castles on the Air (COTA), Polish Gmina Award (PGA),
|
||||||
|
Diplôme des Moulins de France (DMF), 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
|
<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
|
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>
|
of one I've missed, please let me know!</p>
|
||||||
@@ -160,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
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/add-spot.js?v=1788338285"></script>
|
<script src="/static/js/add-spot.js?v=1789763826"></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>
|
||||||
|
|||||||
@@ -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=1788338286"></script>
|
<script src="/static/js/alerts.js?v=1789763826"></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>
|
||||||
|
|||||||
@@ -76,8 +76,8 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/spotsbandsandmap.js?v=1788338285"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1789763826"></script>
|
||||||
<script src="/static/js/bands.js?v=1788338285"></script>
|
<script src="/static/js/bands.js?v=1789763826"></script>
|
||||||
<script>$(document).ready(function () {
|
<script>$(document).ready(function () {
|
||||||
$("#nav-link-bands").addClass("active");
|
$("#nav-link-bands").addClass("active");
|
||||||
}); <!-- highlight active page in nav --></script>
|
}); <!-- highlight active page in nav --></script>
|
||||||
|
|||||||
+5
-5
@@ -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=1788338285" type="text/css">
|
<link rel="stylesheet" href="/static/css/style.css?v=1789763825" type="text/css">
|
||||||
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
|
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
|
||||||
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
|
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
|
||||||
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
|
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
window.fetchEventSource = fetchEventSource;
|
window.fetchEventSource = fetchEventSource;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="/static/js/utils.js?v=1788338285"></script>
|
<script src="/static/js/utils.js?v=1789763825"></script>
|
||||||
<script src="/static/js/ui-ham.js?v=1788338285"></script>
|
<script src="/static/js/ui-ham.js?v=1789763825"></script>
|
||||||
<script src="/static/js/geo.js?v=1788338285"></script>
|
<script src="/static/js/geo.js?v=1789763825"></script>
|
||||||
<script src="/static/js/common.js?v=1788338285"></script>
|
<script src="/static/js/common.js?v=1789763825"></script>
|
||||||
{% end %}
|
{% end %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -39,9 +39,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowSource"
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowType"
|
||||||
value="tableShowSource" oninput="columnsUpdated();" checked>
|
value="tableShowType" oninput="columnsUpdated();" checked>
|
||||||
<label class="form-check-label" for="tableShowSource">Source</label>
|
<label class="form-check-label" for="tableShowType">Source</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
|||||||
@@ -284,7 +284,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
|
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
|
||||||
<script src="/static/js/conditions.js?v=1788338285"></script>
|
<script src="/static/js/conditions.js?v=1789763825"></script>
|
||||||
<script>$(document).ready(function () {
|
<script>$(document).ready(function () {
|
||||||
$("#nav-link-conditions").addClass("active");
|
$("#nav-link-conditions").addClass("active");
|
||||||
}); <!-- highlight active page in nav --></script>
|
}); <!-- highlight active page in nav --></script>
|
||||||
|
|||||||
@@ -16,13 +16,13 @@
|
|||||||
"src": "/static/img/icon-192-pwa.png",
|
"src": "/static/img/icon-192-pwa.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/static/img/icon-512-pwa.png",
|
"src": "/static/img/icon-512-pwa.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable any"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"url": "{{ baseurl }}"
|
"url": "{{ baseurl }}"
|
||||||
|
|||||||
+2
-2
@@ -113,8 +113,8 @@
|
|||||||
const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}";
|
const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="/static/js/spotsbandsandmap.js?v=1788338286"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1789763825"></script>
|
||||||
<script src="/static/js/map.js?v=1788338286"></script>
|
<script src="/static/js/map.js?v=1789763825"></script>
|
||||||
<script>$(document).ready(function () {
|
<script>$(document).ready(function () {
|
||||||
$("#nav-link-map").addClass("active");
|
$("#nav-link-map").addClass("active");
|
||||||
}); <!-- highlight active page in nav --></script>
|
}); <!-- highlight active page in nav --></script>
|
||||||
|
|||||||
+14
-2
@@ -14,6 +14,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if telnet_server_enabled %}
|
||||||
|
<div id="intro-box-telnet" class="permanently-dismissible-box mt-3">
|
||||||
|
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||||
|
<i class="fa-solid fa-circle-info"></i> <strong>Spothole now has a telnet server!</strong><br/>If you'd like to
|
||||||
|
add Spothole as a source of data to your desktop logging application, now you can. Use server address
|
||||||
|
<code>{{ telnet_server_address }}</code> and port <code>{{ telnet_server_port }}</code>.
|
||||||
|
<button type="button" id="intro-box-telnet-dismiss" class="btn-close" data-bs-dismiss="alert"
|
||||||
|
aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% end %}
|
||||||
|
|
||||||
<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-md-4 mb-3 mb-md-0">
|
<div class="col-md-4 mb-3 mb-md-0">
|
||||||
@@ -113,8 +125,8 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/spotsbandsandmap.js?v=1788338285"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1789763825"></script>
|
||||||
<script src="/static/js/spots.js?v=1788338285"></script>
|
<script src="/static/js/spots.js?v=1789763825"></script>
|
||||||
<script>$(document).ready(function () {
|
<script>$(document).ready(function () {
|
||||||
$("#nav-link-spots").addClass("active");
|
$("#nav-link-spots").addClass("active");
|
||||||
}); <!-- highlight active page in nav --></script>
|
}); <!-- highlight active page in nav --></script>
|
||||||
|
|||||||
+13
-3
@@ -21,9 +21,19 @@
|
|||||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
<div class="col"><strong>Web Server</strong></div>
|
<div class="col"><strong>Web Server</strong></div>
|
||||||
<div class="col">Status: <span id="web-server-status"></span></div>
|
<div class="col">Status: <span id="web-server-status"></span></div>
|
||||||
<div class="col">Last API call: <span id="web-server-last-api"></span></div>
|
<div class="col">SSE clients connected: <span id="web-server-sse-clients"></span></div>
|
||||||
<div class="col">Last page req: <span id="web-server-last-page"></span></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
|
<div class="col"></div>
|
||||||
|
<div class="col">API request rate: <span id="web-server-api-rate"></span></div>
|
||||||
|
<div class="col">Page request rate: <span id="web-server-page-rate"></span></div>
|
||||||
|
</div>
|
||||||
|
{% if telnet_server_enabled %}
|
||||||
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
|
<div class="col"><strong>Telnet Server</strong></div>
|
||||||
|
<div class="col">Clients connected: <span id="telnet-server-clients"></span></div>
|
||||||
|
</div>
|
||||||
|
{% end %}
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
||||||
<div class="col"><strong>Cleanup Service</strong></div>
|
<div class="col"><strong>Cleanup Service</strong></div>
|
||||||
<div class="col">Status: <span id="cleanup-status"></span></div>
|
<div class="col">Status: <span id="cleanup-status"></span></div>
|
||||||
@@ -86,7 +96,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/status.js?v=1788338285"></script>
|
<script src="/static/js/status.js?v=1789763826"></script>
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$("#nav-link-status").addClass("active");
|
$("#nav-link-status").addClass("active");
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<div id="timing-container">Loading...</div>
|
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytz
|
|
||||||
import requests
|
import requests
|
||||||
import tornado
|
import tornado
|
||||||
from tornado import httputil
|
from tornado import httputil
|
||||||
@@ -12,7 +10,6 @@ from tornado.web import Application
|
|||||||
|
|
||||||
from core.config import ALLOW_SPOTTING, ALLOW_UPSTREAM_SPOTTING, RECAPTCHA_SECRET_KEY
|
from core.config import ALLOW_SPOTTING, ALLOW_UPSTREAM_SPOTTING, RECAPTCHA_SECRET_KEY
|
||||||
from core.constants import UNKNOWN_BAND
|
from core.constants import UNKNOWN_BAND
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
|
||||||
from core.sig_utils import get_ref_regex_for_sig
|
from core.sig_utils import get_ref_regex_for_sig
|
||||||
from core.utils import infer_band_from_freq, safe_json_dumps
|
from core.utils import infer_band_from_freq, safe_json_dumps
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
@@ -33,23 +30,15 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
):
|
):
|
||||||
self._spots = None
|
self._spots = None
|
||||||
self._web_server_metrics = None
|
|
||||||
self._spot_providers = None
|
self._spot_providers = None
|
||||||
super().__init__(application, request, **kwargs)
|
super().__init__(application, request, **kwargs)
|
||||||
|
|
||||||
def initialize(self, spots, web_server_metrics, spot_providers=None):
|
def initialize(self, spots, spot_providers=None):
|
||||||
self._spots = spots
|
self._spots = spots
|
||||||
self._web_server_metrics = web_server_metrics
|
|
||||||
self._spot_providers = spot_providers or []
|
self._spot_providers = spot_providers or []
|
||||||
|
|
||||||
def post(self):
|
def post(self):
|
||||||
try:
|
try:
|
||||||
# Metrics
|
|
||||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
|
||||||
self._web_server_metrics["api_access_counter"] += 1
|
|
||||||
self._web_server_metrics["status"] = "OK"
|
|
||||||
api_requests_counter.inc()
|
|
||||||
|
|
||||||
# Reject if not allowed
|
# Reject if not allowed
|
||||||
if not ALLOW_SPOTTING:
|
if not ALLOW_SPOTTING:
|
||||||
self.set_status(401)
|
self.set_status(401)
|
||||||
@@ -9,7 +9,7 @@ import tornado_eventsource.handler
|
|||||||
from tornado import httputil
|
from tornado import httputil
|
||||||
from tornado.web import Application
|
from tornado.web import Application
|
||||||
|
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
from core.enums import AlertType
|
||||||
from core.utils import safe_json_dumps
|
from core.utils import safe_json_dumps
|
||||||
from data.lookup_credentials import extract_credentials
|
from data.lookup_credentials import extract_credentials
|
||||||
|
|
||||||
@@ -26,12 +26,10 @@ class APIAlertsHandler(tornado.web.RequestHandler):
|
|||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
):
|
):
|
||||||
self._alerts = None
|
self._alerts = None
|
||||||
self._web_server_metrics = None
|
|
||||||
super().__init__(application, request, **kwargs)
|
super().__init__(application, request, **kwargs)
|
||||||
|
|
||||||
def initialize(self, alerts, web_server_metrics):
|
def initialize(self, alerts):
|
||||||
self._alerts = alerts
|
self._alerts = alerts
|
||||||
self._web_server_metrics = web_server_metrics
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _enrich(alerts, credentials):
|
def _enrich(alerts, credentials):
|
||||||
@@ -44,12 +42,6 @@ class APIAlertsHandler(tornado.web.RequestHandler):
|
|||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
try:
|
try:
|
||||||
# Metrics
|
|
||||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
|
||||||
self._web_server_metrics["api_access_counter"] += 1
|
|
||||||
self._web_server_metrics["status"] = "OK"
|
|
||||||
api_requests_counter.inc()
|
|
||||||
|
|
||||||
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
||||||
# reduce that to just the first entry, and convert bytes to string
|
# reduce that to just the first entry, and convert bytes to string
|
||||||
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
||||||
@@ -57,8 +49,12 @@ class APIAlertsHandler(tornado.web.RequestHandler):
|
|||||||
# Fetch all alerts matching the query, then optionally enrich with online data
|
# Fetch all alerts matching the query, then optionally enrich with online data
|
||||||
credentials = extract_credentials(self.request.headers)
|
credentials = extract_credentials(self.request.headers)
|
||||||
data = get_alert_list_with_filters(self._alerts, query_params)
|
data = get_alert_list_with_filters(self._alerts, query_params)
|
||||||
|
fields = [f.strip() for f in query_params["fields"].split(",")] if "fields" in query_params else []
|
||||||
if credentials:
|
if credentials:
|
||||||
data = self._enrich(data, credentials)
|
data = self._enrich(data, credentials)
|
||||||
|
# Filter for only the required fields, if necessary
|
||||||
|
if fields:
|
||||||
|
data = filter_fields(data, fields)
|
||||||
self.write(safe_json_dumps(data))
|
self.write(safe_json_dumps(data))
|
||||||
self.set_status(200)
|
self.set_status(200)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -77,14 +73,13 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
|||||||
|
|
||||||
def __init__(self, application, request, **kwargs: Any):
|
def __init__(self, application, request, **kwargs: Any):
|
||||||
self._sse_alert_broadcaster = None
|
self._sse_alert_broadcaster = None
|
||||||
self._web_server_metrics = None
|
|
||||||
self._query_params = None
|
self._query_params = None
|
||||||
self._credentials = None
|
self._credentials = None
|
||||||
|
self._fields = None
|
||||||
super().__init__(application, request, **kwargs)
|
super().__init__(application, request, **kwargs)
|
||||||
|
|
||||||
def initialize(self, sse_alert_broadcaster, web_server_metrics):
|
def initialize(self, sse_alert_broadcaster):
|
||||||
self._sse_alert_broadcaster = sse_alert_broadcaster
|
self._sse_alert_broadcaster = sse_alert_broadcaster
|
||||||
self._web_server_metrics = web_server_metrics
|
|
||||||
|
|
||||||
def custom_headers(self):
|
def custom_headers(self):
|
||||||
"""Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data"""
|
"""Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data"""
|
||||||
@@ -93,16 +88,13 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
|||||||
|
|
||||||
def open(self):
|
def open(self):
|
||||||
try:
|
try:
|
||||||
# Metrics
|
|
||||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
|
||||||
self._web_server_metrics["api_access_counter"] += 1
|
|
||||||
self._web_server_metrics["status"] = "OK"
|
|
||||||
api_requests_counter.inc()
|
|
||||||
|
|
||||||
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
||||||
# reduce that to just the first entry, and convert bytes to string
|
# reduce that to just the first entry, and convert bytes to string
|
||||||
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
||||||
self._credentials = extract_credentials(self.request.headers)
|
self._credentials = extract_credentials(self.request.headers)
|
||||||
|
self._fields = (
|
||||||
|
[f.strip() for f in self._query_params["fields"].split(",")] if "fields" in self._query_params else []
|
||||||
|
)
|
||||||
|
|
||||||
# Flush headers immediately so nginx doesn't time out waiting for a response
|
# Flush headers immediately so nginx doesn't time out waiting for a response
|
||||||
self.write_message("keepalive", "")
|
self.write_message("keepalive", "")
|
||||||
@@ -125,10 +117,15 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
|||||||
"""Callback when a new alert arrives"""
|
"""Callback when a new alert arrives"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# If the new alert matches our param filters, send it to the client. If not, ignore it.
|
||||||
if alert_allowed_by_query(alert, self._query_params):
|
if alert_allowed_by_query(alert, self._query_params):
|
||||||
|
# Add lookup data if we have credentials
|
||||||
if self._credentials:
|
if self._credentials:
|
||||||
alert = copy.deepcopy(alert)
|
alert = copy.deepcopy(alert)
|
||||||
alert.infer_missing(self._credentials)
|
alert.infer_missing(self._credentials)
|
||||||
|
# Filter fields returned if necessary
|
||||||
|
if self._fields:
|
||||||
|
alert = filter_fields([alert], self._fields)[0]
|
||||||
self.write_message(msg=safe_json_dumps(alert))
|
self.write_message(msg=safe_json_dumps(alert))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Exception in SSE callback, connection will be closed")
|
logger.exception("Exception in SSE callback, connection will be closed")
|
||||||
@@ -169,11 +166,18 @@ def alert_allowed_by_query(alert, query):
|
|||||||
max_duration = int(query.get(k))
|
max_duration = int(query.get(k))
|
||||||
# Check the duration if end_time is provided. If end_time is not provided, assume the activation is
|
# Check the duration if end_time is provided. If end_time is not provided, assume the activation is
|
||||||
# "short", i.e. it always passes this check. If dxpeditions_skip_max_duration_check is true and
|
# "short", i.e. it always passes this check. If dxpeditions_skip_max_duration_check is true and
|
||||||
# the alert is a dxpedition, it also always passes the check.
|
# the alert is a dxpedition, or contests_skip_max_duration_check and the alert is a contest, it also
|
||||||
if alert.is_dxpedition and (
|
# always passes the check.
|
||||||
query.get("dxpeditions_skip_max_duration_check").upper() == "TRUE"
|
if (
|
||||||
if "dxpeditions_skip_max_duration_check" in query
|
alert.alert_type == AlertType.DXPEDITION
|
||||||
else False
|
and "dxpeditions_skip_max_duration_check" in query
|
||||||
|
and query.get("dxpeditions_skip_max_duration_check").upper() == "TRUE"
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
alert.alert_type == AlertType.CONTEST
|
||||||
|
and "contests_skip_max_duration_check" in query
|
||||||
|
and query.get("contests_skip_max_duration_check").upper() == "TRUE"
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
if alert.end_time and alert.start_time and alert.end_time - alert.start_time > max_duration:
|
if alert.end_time and alert.start_time and alert.end_time - alert.start_time > max_duration:
|
||||||
@@ -208,3 +212,9 @@ def alert_allowed_by_query(alert, query):
|
|||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def filter_fields(alerts, fields):
|
||||||
|
"""Given a list of alert objects, return copies containing only the named fields."""
|
||||||
|
|
||||||
|
return [{k: v for k, v in alert.__dict__.items() if k in fields} for alert in alerts]
|
||||||
@@ -11,7 +11,6 @@ from tornado.web import Application
|
|||||||
|
|
||||||
from core.constants import BANDS
|
from core.constants import BANDS
|
||||||
from core.enums import Continent
|
from core.enums import Continent
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
|
||||||
from core.utils import safe_json_dumps
|
from core.utils import safe_json_dumps
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -30,20 +29,13 @@ class APIDxStatsHandler(tornado.web.RequestHandler):
|
|||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
):
|
):
|
||||||
self._spots = None
|
self._spots = None
|
||||||
self._web_server_metrics = None
|
|
||||||
super().__init__(application, request, **kwargs)
|
super().__init__(application, request, **kwargs)
|
||||||
|
|
||||||
def initialize(self, spots, web_server_metrics):
|
def initialize(self, spots):
|
||||||
self._spots = spots
|
self._spots = spots
|
||||||
self._web_server_metrics = web_server_metrics
|
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
try:
|
try:
|
||||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
|
||||||
self._web_server_metrics["api_access_counter"] += 1
|
|
||||||
self._web_server_metrics["status"] = "OK"
|
|
||||||
api_requests_counter.inc()
|
|
||||||
|
|
||||||
one_hour_ago = (datetime.now(pytz.UTC) - timedelta(hours=1)).timestamp()
|
one_hour_ago = (datetime.now(pytz.UTC) - timedelta(hours=1)).timestamp()
|
||||||
counts = Counter()
|
counts = Counter()
|
||||||
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytz
|
|
||||||
import tornado
|
import tornado
|
||||||
from tornado import httputil
|
from tornado import httputil
|
||||||
from tornado.web import Application
|
from tornado.web import Application
|
||||||
@@ -15,7 +13,6 @@ from core.geo_utils import (
|
|||||||
lat_lon_to_cq_zone,
|
lat_lon_to_cq_zone,
|
||||||
lat_lon_to_itu_zone,
|
lat_lon_to_itu_zone,
|
||||||
)
|
)
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
|
||||||
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 get_ref_regex_for_sig
|
from core.sig_utils import get_ref_regex_for_sig
|
||||||
from core.utils import safe_json_dumps
|
from core.utils import safe_json_dumps
|
||||||
@@ -34,20 +31,10 @@ class APILookupCallHandler(tornado.web.RequestHandler):
|
|||||||
request: httputil.HTTPServerRequest,
|
request: httputil.HTTPServerRequest,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
):
|
):
|
||||||
self._web_server_metrics = None
|
|
||||||
super().__init__(application, request, **kwargs)
|
super().__init__(application, request, **kwargs)
|
||||||
|
|
||||||
def initialize(self, web_server_metrics):
|
|
||||||
self._web_server_metrics = web_server_metrics
|
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
try:
|
try:
|
||||||
# Metrics
|
|
||||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
|
||||||
self._web_server_metrics["api_access_counter"] += 1
|
|
||||||
self._web_server_metrics["status"] = "OK"
|
|
||||||
api_requests_counter.inc()
|
|
||||||
|
|
||||||
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
||||||
# reduce that to just the first entry, and convert bytes to string
|
# reduce that to just the first entry, and convert bytes to string
|
||||||
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
||||||
@@ -85,20 +72,10 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
|
|||||||
request: httputil.HTTPServerRequest,
|
request: httputil.HTTPServerRequest,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
):
|
):
|
||||||
self._web_server_metrics = None
|
|
||||||
super().__init__(application, request, **kwargs)
|
super().__init__(application, request, **kwargs)
|
||||||
|
|
||||||
def initialize(self, web_server_metrics):
|
|
||||||
self._web_server_metrics = web_server_metrics
|
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
try:
|
try:
|
||||||
# Metrics
|
|
||||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
|
||||||
self._web_server_metrics["api_access_counter"] += 1
|
|
||||||
self._web_server_metrics["status"] = "OK"
|
|
||||||
api_requests_counter.inc()
|
|
||||||
|
|
||||||
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
||||||
# reduce that to just the first entry, and convert bytes to string
|
# reduce that to just the first entry, and convert bytes to string
|
||||||
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
||||||
@@ -143,20 +120,10 @@ class APILookupGridHandler(tornado.web.RequestHandler):
|
|||||||
request: httputil.HTTPServerRequest,
|
request: httputil.HTTPServerRequest,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
):
|
):
|
||||||
self._web_server_metrics = None
|
|
||||||
super().__init__(application, request, **kwargs)
|
super().__init__(application, request, **kwargs)
|
||||||
|
|
||||||
def initialize(self, web_server_metrics):
|
|
||||||
self._web_server_metrics = web_server_metrics
|
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
try:
|
try:
|
||||||
# Metrics
|
|
||||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
|
||||||
self._web_server_metrics["api_access_counter"] += 1
|
|
||||||
self._web_server_metrics["status"] = "OK"
|
|
||||||
api_requests_counter.inc()
|
|
||||||
|
|
||||||
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
||||||
# reduce that to just the first entry, and convert bytes to string
|
# reduce that to just the first entry, and convert bytes to string
|
||||||
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytz
|
|
||||||
import tornado
|
import tornado
|
||||||
from tornado import httputil
|
from tornado import httputil
|
||||||
from tornado.web import Application
|
from tornado.web import Application
|
||||||
@@ -10,7 +8,6 @@ from tornado.web import Application
|
|||||||
from core.config import ALLOW_SPOTTING, MAX_SPOT_AGE
|
from core.config import ALLOW_SPOTTING, MAX_SPOT_AGE
|
||||||
from core.constants import BANDS, PROPAGATION_MODES, SIGS
|
from core.constants import BANDS, PROPAGATION_MODES, SIGS
|
||||||
from core.enums import Continent, Mode, ModeType
|
from core.enums import Continent, Mode, ModeType
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
|
||||||
from core.utils import safe_json_dumps
|
from core.utils import safe_json_dumps
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -26,23 +23,15 @@ class APIOptionsHandler(tornado.web.RequestHandler):
|
|||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
):
|
):
|
||||||
self._status_data = None
|
self._status_data = None
|
||||||
self._web_server_metrics = None
|
|
||||||
self._spot_providers = None
|
self._spot_providers = None
|
||||||
super().__init__(application, request, **kwargs)
|
super().__init__(application, request, **kwargs)
|
||||||
|
|
||||||
def initialize(self, status_data, web_server_metrics, spot_providers=None):
|
def initialize(self, status_data, spot_providers=None):
|
||||||
self._status_data = status_data
|
self._status_data = status_data
|
||||||
self._web_server_metrics = web_server_metrics
|
|
||||||
self._spot_providers = spot_providers or []
|
self._spot_providers = spot_providers or []
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
try:
|
try:
|
||||||
# Metrics
|
|
||||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
|
||||||
self._web_server_metrics["api_access_counter"] += 1
|
|
||||||
self._web_server_metrics["status"] = "OK"
|
|
||||||
api_requests_counter.inc()
|
|
||||||
|
|
||||||
# Build a map of SIG name -> list of provider names that can submit spots for that SIG
|
# Build a map of SIG name -> list of provider names that can submit spots for that SIG
|
||||||
spot_submit_providers = {}
|
spot_submit_providers = {}
|
||||||
|
|
||||||
+1
-12
@@ -1,13 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytz
|
|
||||||
import tornado
|
import tornado
|
||||||
from tornado import httputil
|
from tornado import httputil
|
||||||
from tornado.web import Application
|
from tornado.web import Application
|
||||||
|
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
|
||||||
from core.utils import safe_json_dumps
|
from core.utils import safe_json_dumps
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -23,21 +20,13 @@ class APISolarConditionsHandler(tornado.web.RequestHandler):
|
|||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
):
|
):
|
||||||
self._solar_conditions = None
|
self._solar_conditions = None
|
||||||
self._web_server_metrics = None
|
|
||||||
super().__init__(application, request, **kwargs)
|
super().__init__(application, request, **kwargs)
|
||||||
|
|
||||||
def initialize(self, solar_conditions, web_server_metrics):
|
def initialize(self, solar_conditions):
|
||||||
self._solar_conditions = solar_conditions
|
self._solar_conditions = solar_conditions
|
||||||
self._web_server_metrics = web_server_metrics
|
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
try:
|
try:
|
||||||
# Metrics
|
|
||||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
|
||||||
self._web_server_metrics["api_access_counter"] += 1
|
|
||||||
self._web_server_metrics["status"] = "OK"
|
|
||||||
api_requests_counter.inc()
|
|
||||||
|
|
||||||
self.write(self._solar_conditions.to_json())
|
self.write(self._solar_conditions.to_json())
|
||||||
self.set_status(200)
|
self.set_status(200)
|
||||||
self.set_header("Cache-Control", "no-store")
|
self.set_header("Cache-Control", "no-store")
|
||||||
@@ -9,7 +9,6 @@ import tornado_eventsource.handler
|
|||||||
from tornado import httputil
|
from tornado import httputil
|
||||||
from tornado.web import Application
|
from tornado.web import Application
|
||||||
|
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
|
||||||
from core.utils import safe_json_dumps
|
from core.utils import safe_json_dumps
|
||||||
from data.lookup_credentials import extract_credentials
|
from data.lookup_credentials import extract_credentials
|
||||||
|
|
||||||
@@ -26,12 +25,10 @@ class APISpotsHandler(tornado.web.RequestHandler):
|
|||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
):
|
):
|
||||||
self._spots = None
|
self._spots = None
|
||||||
self._web_server_metrics = None
|
|
||||||
super().__init__(application, request, **kwargs)
|
super().__init__(application, request, **kwargs)
|
||||||
|
|
||||||
def initialize(self, spots, web_server_metrics):
|
def initialize(self, spots):
|
||||||
self._spots = spots
|
self._spots = spots
|
||||||
self._web_server_metrics = web_server_metrics
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _enrich(spots, credentials):
|
def _enrich(spots, credentials):
|
||||||
@@ -44,21 +41,19 @@ class APISpotsHandler(tornado.web.RequestHandler):
|
|||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
try:
|
try:
|
||||||
# Metrics
|
|
||||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
|
||||||
self._web_server_metrics["api_access_counter"] += 1
|
|
||||||
self._web_server_metrics["status"] = "OK"
|
|
||||||
api_requests_counter.inc()
|
|
||||||
|
|
||||||
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
||||||
# reduce that to just the first entry, and convert bytes to string
|
# reduce that to just the first entry, and convert bytes to string
|
||||||
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
||||||
|
|
||||||
# Fetch all spots matching the query, then optionally enrich with online data
|
# Fetch all spots matching the query, then optionally enrich with online data
|
||||||
credentials = extract_credentials(self.request.headers)
|
credentials = extract_credentials(self.request.headers)
|
||||||
|
fields = [f.strip() for f in query_params["fields"].split(",")] if "fields" in query_params else []
|
||||||
data = get_spot_list_with_filters(self._spots, query_params)
|
data = get_spot_list_with_filters(self._spots, query_params)
|
||||||
if credentials:
|
if credentials:
|
||||||
data = self._enrich(data, credentials)
|
data = self._enrich(data, credentials)
|
||||||
|
# Filter for only the required fields, if necessary
|
||||||
|
if fields:
|
||||||
|
data = filter_fields(data, fields)
|
||||||
self.write(safe_json_dumps(data))
|
self.write(safe_json_dumps(data))
|
||||||
self.set_status(200)
|
self.set_status(200)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -77,14 +72,13 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
|||||||
|
|
||||||
def __init__(self, application, request, **kwargs: Any):
|
def __init__(self, application, request, **kwargs: Any):
|
||||||
self._sse_spot_broadcaster = None
|
self._sse_spot_broadcaster = None
|
||||||
self._web_server_metrics = None
|
|
||||||
self._query_params = None
|
self._query_params = None
|
||||||
self._credentials = None
|
self._credentials = None
|
||||||
|
self._fields = None
|
||||||
super().__init__(application, request, **kwargs)
|
super().__init__(application, request, **kwargs)
|
||||||
|
|
||||||
def initialize(self, sse_spot_broadcaster, web_server_metrics):
|
def initialize(self, sse_spot_broadcaster):
|
||||||
self._sse_spot_broadcaster = sse_spot_broadcaster
|
self._sse_spot_broadcaster = sse_spot_broadcaster
|
||||||
self._web_server_metrics = web_server_metrics
|
|
||||||
|
|
||||||
def custom_headers(self):
|
def custom_headers(self):
|
||||||
"""Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data"""
|
"""Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data"""
|
||||||
@@ -95,16 +89,13 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
|||||||
"""Called once on the client opening a connection, set things up"""
|
"""Called once on the client opening a connection, set things up"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Metrics
|
|
||||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
|
||||||
self._web_server_metrics["api_access_counter"] += 1
|
|
||||||
self._web_server_metrics["status"] = "OK"
|
|
||||||
api_requests_counter.inc()
|
|
||||||
|
|
||||||
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
||||||
# reduce that to just the first entry, and convert bytes to string
|
# reduce that to just the first entry, and convert bytes to string
|
||||||
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
||||||
self._credentials = extract_credentials(self.request.headers)
|
self._credentials = extract_credentials(self.request.headers)
|
||||||
|
self._fields = (
|
||||||
|
[f.strip() for f in self._query_params["fields"].split(",")] if "fields" in self._query_params else []
|
||||||
|
)
|
||||||
|
|
||||||
# Flush headers immediately so nginx doesn't time out waiting for a response
|
# Flush headers immediately so nginx doesn't time out waiting for a response
|
||||||
self.write_message("keepalive", "")
|
self.write_message("keepalive", "")
|
||||||
@@ -129,9 +120,13 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
|||||||
try:
|
try:
|
||||||
# If the new spot matches our param filters, send it to the client. If not, ignore it.
|
# If the new spot matches our param filters, send it to the client. If not, ignore it.
|
||||||
if spot_allowed_by_query(spot, self._query_params):
|
if spot_allowed_by_query(spot, self._query_params):
|
||||||
|
# Add lookup data if we have credentials
|
||||||
if self._credentials:
|
if self._credentials:
|
||||||
spot = copy.deepcopy(spot)
|
spot = copy.deepcopy(spot)
|
||||||
spot.infer_missing(self._credentials)
|
spot.infer_missing(self._credentials)
|
||||||
|
# Filter fields returned if necessary
|
||||||
|
if self._fields:
|
||||||
|
spot = filter_fields([spot], self._fields)[0]
|
||||||
self.write_message(msg=safe_json_dumps(spot))
|
self.write_message(msg=safe_json_dumps(spot))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Exception in SSE callback, connection will be closed")
|
logger.exception("Exception in SSE callback, connection will be closed")
|
||||||
@@ -266,3 +261,9 @@ def spot_allowed_by_query(spot, query):
|
|||||||
if needs_good_location and not spot.dx_location_good:
|
if needs_good_location and not spot.dx_location_good:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def filter_fields(spots, fields):
|
||||||
|
"""Given a list of spot objects, return copies containing only the named fields."""
|
||||||
|
|
||||||
|
return [{k: v for k, v in spot.__dict__.items() if k in fields} for spot in spots]
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytz
|
|
||||||
import tornado
|
import tornado
|
||||||
from tornado import httputil
|
from tornado import httputil
|
||||||
from tornado.web import Application
|
from tornado.web import Application
|
||||||
|
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
|
||||||
from core.utils import safe_json_dumps
|
from core.utils import safe_json_dumps
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -23,21 +20,13 @@ class APIStatusHandler(tornado.web.RequestHandler):
|
|||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
):
|
):
|
||||||
self._status_data = None
|
self._status_data = None
|
||||||
self._web_server_metrics = None
|
|
||||||
super().__init__(application, request, **kwargs)
|
super().__init__(application, request, **kwargs)
|
||||||
|
|
||||||
def initialize(self, status_data, web_server_metrics):
|
def initialize(self, status_data):
|
||||||
self._status_data = status_data
|
self._status_data = status_data
|
||||||
self._web_server_metrics = web_server_metrics
|
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
try:
|
try:
|
||||||
# Metrics
|
|
||||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
|
||||||
self._web_server_metrics["api_access_counter"] += 1
|
|
||||||
self._web_server_metrics["status"] = "OK"
|
|
||||||
api_requests_counter.inc()
|
|
||||||
|
|
||||||
self.write(safe_json_dumps(self._status_data))
|
self.write(safe_json_dumps(self._status_data))
|
||||||
self.set_status(200)
|
self.set_status(200)
|
||||||
self.set_header("Cache-Control", "no-store")
|
self.set_header("Cache-Control", "no-store")
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytz
|
|
||||||
import tornado
|
import tornado
|
||||||
from tornado import httputil
|
from tornado import httputil
|
||||||
from tornado.web import Application
|
from tornado.web import Application
|
||||||
|
|
||||||
from core.config import ALLOW_SPOTTING
|
from core.config import ALLOW_SPOTTING
|
||||||
from core.constants import UNKNOWN_BAND
|
from core.constants import UNKNOWN_BAND
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
|
||||||
from core.sig_utils import get_ref_regex_for_sig
|
from core.sig_utils import get_ref_regex_for_sig
|
||||||
from core.utils import infer_band_from_freq, safe_json_dumps
|
from core.utils import infer_band_from_freq, safe_json_dumps
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
@@ -28,21 +25,13 @@ class V1APISpotHandler(tornado.web.RequestHandler):
|
|||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
):
|
):
|
||||||
self._spots = None
|
self._spots = None
|
||||||
self._web_server_metrics = None
|
|
||||||
super().__init__(application, request, **kwargs)
|
super().__init__(application, request, **kwargs)
|
||||||
|
|
||||||
def initialize(self, spots, web_server_metrics):
|
def initialize(self, spots):
|
||||||
self._spots = spots
|
self._spots = spots
|
||||||
self._web_server_metrics = web_server_metrics
|
|
||||||
|
|
||||||
def post(self):
|
def post(self):
|
||||||
try:
|
try:
|
||||||
# Metrics
|
|
||||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
|
||||||
self._web_server_metrics["api_access_counter"] += 1
|
|
||||||
self._web_server_metrics["status"] = "OK"
|
|
||||||
api_requests_counter.inc()
|
|
||||||
|
|
||||||
# Reject if not allowed
|
# Reject if not allowed
|
||||||
if not ALLOW_SPOTTING:
|
if not ALLOW_SPOTTING:
|
||||||
self.set_status(401)
|
self.set_status(401)
|
||||||
+1
-1
@@ -34,7 +34,7 @@ class V1RedirectHandler(tornado.web.RequestHandler):
|
|||||||
response = await client.fetch(
|
response = await client.fetch(
|
||||||
new_url,
|
new_url,
|
||||||
method=self.request.method,
|
method=self.request.method,
|
||||||
headers=self.request.headers,
|
headers=headers,
|
||||||
body=None if self.request.method == "GET" else (self.request.body or b""),
|
body=None if self.request.method == "GET" else (self.request.body or b""),
|
||||||
raise_error=False,
|
raise_error=False,
|
||||||
follow_redirects=False,
|
follow_redirects=False,
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from server.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
|
from webserver.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
|
||||||
|
|
||||||
_GRID_SOURCE_RE = re.compile(r'"dx_location_source":\s*"GRID"')
|
_GRID_SOURCE_RE = re.compile(r'"dx_location_source":\s*"GRID"')
|
||||||
_LEGACY_PARAM_TO_HEADER_MAP = {
|
_LEGACY_PARAM_TO_HEADER_MAP = {
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytz
|
|
||||||
import tornado
|
import tornado
|
||||||
from tornado import httputil
|
from tornado import httputil
|
||||||
from tornado.web import Application
|
from tornado.web import Application
|
||||||
|
|
||||||
from core.config import ALLOW_SPOTTING, BASE_URL, SERVER_OWNER_CALLSIGN, WEB_UI_OPTIONS
|
from core.config import (
|
||||||
|
ALLOW_SPOTTING,
|
||||||
|
BASE_URL,
|
||||||
|
SERVER_OWNER_CALLSIGN,
|
||||||
|
TELNET_SERVER_ADDRESS,
|
||||||
|
TELNET_SERVER_ENABLED,
|
||||||
|
TELNET_SERVER_PORT,
|
||||||
|
WEB_UI_OPTIONS,
|
||||||
|
)
|
||||||
from core.constants import SOFTWARE_VERSION
|
from core.constants import SOFTWARE_VERSION
|
||||||
from core.prometheus_metrics_handler import page_requests_counter
|
|
||||||
|
|
||||||
|
|
||||||
class PageTemplateHandler(tornado.web.RequestHandler):
|
class PageTemplateHandler(tornado.web.RequestHandler):
|
||||||
@@ -21,20 +26,12 @@ class PageTemplateHandler(tornado.web.RequestHandler):
|
|||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
):
|
):
|
||||||
self._template_name = None
|
self._template_name = None
|
||||||
self._web_server_metrics = None
|
|
||||||
super().__init__(application, request, **kwargs)
|
super().__init__(application, request, **kwargs)
|
||||||
|
|
||||||
def initialize(self, template_name, web_server_metrics):
|
def initialize(self, template_name):
|
||||||
self._template_name = template_name
|
self._template_name = template_name
|
||||||
self._web_server_metrics = web_server_metrics
|
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
# Metrics
|
|
||||||
self._web_server_metrics["last_page_access_time"] = datetime.now(pytz.UTC)
|
|
||||||
self._web_server_metrics["page_access_counter"] += 1
|
|
||||||
self._web_server_metrics["status"] = "OK"
|
|
||||||
page_requests_counter.inc()
|
|
||||||
|
|
||||||
# Load named template, and provide variables used in templates
|
# Load named template, and provide variables used in templates
|
||||||
self.render(
|
self.render(
|
||||||
f"{self._template_name}.html",
|
f"{self._template_name}.html",
|
||||||
@@ -43,5 +40,8 @@ class PageTemplateHandler(tornado.web.RequestHandler):
|
|||||||
allow_spotting=ALLOW_SPOTTING,
|
allow_spotting=ALLOW_SPOTTING,
|
||||||
web_ui_options=WEB_UI_OPTIONS,
|
web_ui_options=WEB_UI_OPTIONS,
|
||||||
baseurl=BASE_URL,
|
baseurl=BASE_URL,
|
||||||
|
telnet_server_enabled=TELNET_SERVER_ENABLED,
|
||||||
|
telnet_server_address=TELNET_SERVER_ADDRESS,
|
||||||
|
telnet_server_port=TELNET_SERVER_PORT,
|
||||||
current_path=self.request.path,
|
current_path=self.request.path,
|
||||||
)
|
)
|
||||||
@@ -26,6 +26,11 @@ class SSEBroadcaster:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
self._handlers.discard(handler)
|
self._handlers.discard(handler)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client_count(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return len(self._handlers)
|
||||||
|
|
||||||
def publish(self, value):
|
def publish(self, value):
|
||||||
self._loop.add_callback(self._broadcast, value)
|
self._loop.add_callback(self._broadcast, value)
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
import tornado
|
import tornado
|
||||||
from tornado.web import StaticFileHandler
|
from tornado.web import StaticFileHandler
|
||||||
@@ -14,25 +15,26 @@ from core.config import (
|
|||||||
)
|
)
|
||||||
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 server.handlers.api.addspot import APISpotHandler
|
from webserver.handlers.api.addspot import APISpotHandler
|
||||||
from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
|
from webserver.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
|
||||||
from server.handlers.api.dxstats import APIDxStatsHandler
|
from webserver.handlers.api.dxstats import APIDxStatsHandler
|
||||||
from server.handlers.api.lookups import (
|
from webserver.handlers.api.lookups import (
|
||||||
APILookupCallHandler,
|
APILookupCallHandler,
|
||||||
APILookupGridHandler,
|
APILookupGridHandler,
|
||||||
APILookupSIGRefHandler,
|
APILookupSIGRefHandler,
|
||||||
)
|
)
|
||||||
from server.handlers.api.options import APIOptionsHandler
|
from webserver.handlers.api.options import APIOptionsHandler
|
||||||
from server.handlers.api.solar_conditions import APISolarConditionsHandler
|
from webserver.handlers.api.solar_conditions import APISolarConditionsHandler
|
||||||
from server.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
|
from webserver.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
|
||||||
from server.handlers.api.status import APIStatusHandler
|
from webserver.handlers.api.status import APIStatusHandler
|
||||||
from server.handlers.api.v1_addspot import V1APISpotHandler
|
from webserver.handlers.api.v1_addspot import V1APISpotHandler
|
||||||
from server.handlers.api.v1_compatability import V1RedirectHandler
|
from webserver.handlers.api.v1_compatability import V1RedirectHandler
|
||||||
from server.handlers.api.v1_spots import V1APISpotsHandler, V1APISpotsStreamHandler
|
from webserver.handlers.api.v1_spots import V1APISpotsHandler, V1APISpotsStreamHandler
|
||||||
from server.handlers.manifesthandler import ManifestHandler
|
from webserver.handlers.manifesthandler import ManifestHandler
|
||||||
from server.handlers.metrics import PrometheusMetricsHandler
|
from webserver.handlers.metrics import PrometheusMetricsHandler
|
||||||
from server.handlers.pagetemplate import PageTemplateHandler
|
from webserver.handlers.pagetemplate import PageTemplateHandler
|
||||||
from server.sse_broadcaster import SSEBroadcaster
|
from webserver.sse_broadcaster import SSEBroadcaster
|
||||||
|
from webserver.webserver_metrics import WebServerMetrics
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -52,92 +54,107 @@ class WebServer:
|
|||||||
self._port = WEB_SERVER_PORT
|
self._port = WEB_SERVER_PORT
|
||||||
self._api_only_mode = API_ONLY_MODE
|
self._api_only_mode = API_ONLY_MODE
|
||||||
self._shutdown_event = asyncio.Event()
|
self._shutdown_event = asyncio.Event()
|
||||||
self.web_server_metrics = {
|
self._loop = None
|
||||||
"last_page_access_time": None,
|
self._thread = None
|
||||||
"last_api_access_time": None,
|
self.web_server_metrics = WebServerMetrics()
|
||||||
"page_access_counter": 0,
|
|
||||||
"api_access_counter": 0,
|
|
||||||
"status": "Starting",
|
|
||||||
}
|
|
||||||
|
|
||||||
def setup(self):
|
def setup(self):
|
||||||
# Listen for new spots and alerts being added to the cache, so we can notify SSE clients immediately
|
# Listen for new spots and alerts being added to the cache, so we can notify SSE clients immediately
|
||||||
DATA_STORE.spots.add_listener(self._spot_broadcaster.publish)
|
DATA_STORE.spots.add_listener(self._spot_broadcaster.publish)
|
||||||
DATA_STORE.alerts.add_listener(self._alert_broadcaster.publish)
|
DATA_STORE.alerts.add_listener(self._alert_broadcaster.publish)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sse_client_count(self) -> int:
|
||||||
|
"""Number of connected SSE clients, across both the spots and alerts streams."""
|
||||||
|
|
||||||
|
return self._spot_broadcaster.client_count + self._alert_broadcaster.client_count
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Start the web server"""
|
"""Start the web server"""
|
||||||
|
|
||||||
asyncio.run(self._start_inner())
|
self._thread = threading.Thread(target=asyncio.run, args=(self._start_inner(),), name="WebServer", daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Stop the web server"""
|
"""Stop the web server"""
|
||||||
|
|
||||||
self._shutdown_event.set()
|
if self._loop and self._loop.is_running():
|
||||||
|
self._loop.call_soon_threadsafe(self._shutdown_event.set)
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=15)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning("Web server background thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
|
def _handle_loop_exception(self, loop, context):
|
||||||
|
"""Ignore "cancelled" exceptions from the asyncio loop to avoid printing exceptions to the log on shutdown when
|
||||||
|
we cancel the SSE handler threads"""
|
||||||
|
|
||||||
|
exception = context.get("exception")
|
||||||
|
if isinstance(exception, asyncio.CancelledError):
|
||||||
|
return
|
||||||
|
loop.default_exception_handler(context)
|
||||||
|
|
||||||
async def _start_inner(self):
|
async def _start_inner(self):
|
||||||
"""Start method (async). Sets up the Tornado application."""
|
"""Start method (async). Sets up the Tornado application."""
|
||||||
|
|
||||||
|
self._loop = asyncio.get_running_loop()
|
||||||
|
self._loop.set_exception_handler(self._handle_loop_exception)
|
||||||
|
|
||||||
# Bind the SSE broadcasters to the web server's loop, so they fire correctly
|
# Bind the SSE broadcasters to the web server's loop, so they fire correctly
|
||||||
self._spot_broadcaster.bind_to_web_server_loop()
|
self._spot_broadcaster.bind_to_web_server_loop()
|
||||||
self._alert_broadcaster.bind_to_web_server_loop()
|
self._alert_broadcaster.bind_to_web_server_loop()
|
||||||
|
|
||||||
# Prepare a list of common arguments that are passed in to every API & page handler. This is just a basic thing
|
|
||||||
# to avoid copy-pasting the same thing to every route declaration below.
|
|
||||||
handler_opts = {"web_server_metrics": self.web_server_metrics}
|
|
||||||
|
|
||||||
# API endpoints are always enabled
|
# API endpoints are always enabled
|
||||||
api_routes = [
|
api_routes = [
|
||||||
(
|
(
|
||||||
r"/api/v2/spots",
|
r"/api/v2/spots",
|
||||||
APISpotsHandler,
|
APISpotsHandler,
|
||||||
{"spots": self._data_store.spots, **handler_opts},
|
{"spots": self._data_store.spots},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"/api/v2/alerts",
|
r"/api/v2/alerts",
|
||||||
APIAlertsHandler,
|
APIAlertsHandler,
|
||||||
{"alerts": self._data_store.alerts, **handler_opts},
|
{"alerts": self._data_store.alerts},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"/api/v2/spots/stream",
|
r"/api/v2/spots/stream",
|
||||||
APISpotsStreamHandler,
|
APISpotsStreamHandler,
|
||||||
{"sse_spot_broadcaster": self._spot_broadcaster, **handler_opts},
|
{"sse_spot_broadcaster": self._spot_broadcaster},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"/api/v2/alerts/stream",
|
r"/api/v2/alerts/stream",
|
||||||
APIAlertsStreamHandler,
|
APIAlertsStreamHandler,
|
||||||
{"sse_alert_broadcaster": self._alert_broadcaster, **handler_opts},
|
{"sse_alert_broadcaster": self._alert_broadcaster},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"/api/v2/solar",
|
r"/api/v2/solar",
|
||||||
APISolarConditionsHandler,
|
APISolarConditionsHandler,
|
||||||
{"solar_conditions": self._data_store.solar_conditions.get(), **handler_opts},
|
{"solar_conditions": self._data_store.solar_conditions.get()},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"/api/v2/dxstats",
|
r"/api/v2/dxstats",
|
||||||
APIDxStatsHandler,
|
APIDxStatsHandler,
|
||||||
{"spots": self._data_store.spots, **handler_opts},
|
{"spots": self._data_store.spots},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"/api/v2/options",
|
r"/api/v2/options",
|
||||||
APIOptionsHandler,
|
APIOptionsHandler,
|
||||||
{"status_data": self._data_store.status.get(), **handler_opts},
|
{"status_data": self._data_store.status.get()},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"/api/v2/status",
|
r"/api/v2/status",
|
||||||
APIStatusHandler,
|
APIStatusHandler,
|
||||||
{"status_data": self._data_store.status.get(), **handler_opts},
|
{"status_data": self._data_store.status.get()},
|
||||||
),
|
),
|
||||||
(r"/api/v2/lookup/call", APILookupCallHandler, {**handler_opts}),
|
(r"/api/v2/lookup/call", APILookupCallHandler),
|
||||||
(r"/api/v2/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}),
|
(r"/api/v2/lookup/sigref", APILookupSIGRefHandler),
|
||||||
(r"/api/v2/lookup/grid", APILookupGridHandler, {**handler_opts}),
|
(r"/api/v2/lookup/grid", APILookupGridHandler),
|
||||||
(
|
(
|
||||||
r"/api/v2/spot",
|
r"/api/v2/spot",
|
||||||
APISpotHandler,
|
APISpotHandler,
|
||||||
{
|
{
|
||||||
"spots": self._data_store.spots,
|
"spots": self._data_store.spots,
|
||||||
"spot_providers": self._data_providers,
|
"spot_providers": self._data_providers,
|
||||||
**handler_opts,
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -148,19 +165,18 @@ class WebServer:
|
|||||||
(
|
(
|
||||||
r"/api/v1/spots",
|
r"/api/v1/spots",
|
||||||
V1APISpotsHandler,
|
V1APISpotsHandler,
|
||||||
{"spots": self._data_store.spots, **handler_opts},
|
{"spots": self._data_store.spots},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"/api/v1/spots/stream",
|
r"/api/v1/spots/stream",
|
||||||
V1APISpotsStreamHandler,
|
V1APISpotsStreamHandler,
|
||||||
{"sse_spot_broadcaster": self._spot_broadcaster, **handler_opts},
|
{"sse_spot_broadcaster": self._spot_broadcaster},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"/api/v1/spot",
|
r"/api/v1/spot",
|
||||||
V1APISpotHandler,
|
V1APISpotHandler,
|
||||||
{
|
{
|
||||||
"spots": self._data_store.spots,
|
"spots": self._data_store.spots,
|
||||||
**handler_opts,
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(r"/api/v1/(.*)", V1RedirectHandler),
|
(r"/api/v1/(.*)", V1RedirectHandler),
|
||||||
@@ -173,41 +189,41 @@ class WebServer:
|
|||||||
(
|
(
|
||||||
r"/",
|
r"/",
|
||||||
PageTemplateHandler,
|
PageTemplateHandler,
|
||||||
{"template_name": "api_only_home", **handler_opts},
|
{"template_name": "api_only_home"},
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
ui_routes = [
|
ui_routes = [
|
||||||
(r"/", PageTemplateHandler, {"template_name": "spots", **handler_opts}),
|
(r"/", PageTemplateHandler, {"template_name": "spots"}),
|
||||||
(
|
(
|
||||||
r"/map",
|
r"/map",
|
||||||
PageTemplateHandler,
|
PageTemplateHandler,
|
||||||
{"template_name": "map", **handler_opts},
|
{"template_name": "map"},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"/bands",
|
r"/bands",
|
||||||
PageTemplateHandler,
|
PageTemplateHandler,
|
||||||
{"template_name": "bands", **handler_opts},
|
{"template_name": "bands"},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"/alerts",
|
r"/alerts",
|
||||||
PageTemplateHandler,
|
PageTemplateHandler,
|
||||||
{"template_name": "alerts", **handler_opts},
|
{"template_name": "alerts"},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"/conditions",
|
r"/conditions",
|
||||||
PageTemplateHandler,
|
PageTemplateHandler,
|
||||||
{"template_name": "conditions", **handler_opts},
|
{"template_name": "conditions"},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"/status",
|
r"/status",
|
||||||
PageTemplateHandler,
|
PageTemplateHandler,
|
||||||
{"template_name": "status", **handler_opts},
|
{"template_name": "status"},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"/about",
|
r"/about",
|
||||||
PageTemplateHandler,
|
PageTemplateHandler,
|
||||||
{"template_name": "about", **handler_opts},
|
{"template_name": "about"},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
# Only allow the Add Spot page if spotting is allowed
|
# Only allow the Add Spot page if spotting is allowed
|
||||||
@@ -216,7 +232,7 @@ class WebServer:
|
|||||||
(
|
(
|
||||||
r"/add-spot",
|
r"/add-spot",
|
||||||
PageTemplateHandler,
|
PageTemplateHandler,
|
||||||
{"template_name": "add_spot", **handler_opts},
|
{"template_name": "add_spot"},
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -226,7 +242,7 @@ class WebServer:
|
|||||||
(
|
(
|
||||||
r"/apidocs",
|
r"/apidocs",
|
||||||
PageTemplateHandler,
|
PageTemplateHandler,
|
||||||
{"template_name": "apidocs", **handler_opts},
|
{"template_name": "apidocs"},
|
||||||
),
|
),
|
||||||
(r"/metrics", PrometheusMetricsHandler),
|
(r"/metrics", PrometheusMetricsHandler),
|
||||||
(r"/manifest.webmanifest", ManifestHandler),
|
(r"/manifest.webmanifest", ManifestHandler),
|
||||||
@@ -254,7 +270,11 @@ class WebServer:
|
|||||||
|
|
||||||
def request_log(handler):
|
def request_log(handler):
|
||||||
"""Custom log function to provide more data about requests when enabled, and to provide the ability to turn off
|
"""Custom log function to provide more data about requests when enabled, and to provide the ability to turn off
|
||||||
web request logging altogetether."""
|
web request logging altogetether. Also records the time of the request and status in the webserver metrics. Probably
|
||||||
|
not what this method is supposed to be used for but it's a convenient thing that gets called on every request, so
|
||||||
|
saves having to pass the metrics around each handler individually."""
|
||||||
|
|
||||||
|
WEB_SERVER.web_server_metrics.record(handler.request.path, handler.get_status())
|
||||||
|
|
||||||
if LOG_WEB_REQUESTS:
|
if LOG_WEB_REQUESTS:
|
||||||
if handler.get_status() < 500:
|
if handler.get_status() < 500:
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user