mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-09-20 14:27:42 +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" },
|
||||||
]
|
]
|
||||||
|
|||||||
+6
-2
@@ -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()
|
||||||
|
|||||||
+94
-17
@@ -17,12 +17,17 @@ 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
|
||||||
|
|
||||||
* **Breaking change:** The "add spot" API has changed to enable future support for upstream submission to the spotting services associated with various SIGs. Instead of just posting the spot object itself as the JSON content of the POST, this has moved into a `spot` object within the structure. A new `handling` object alongside it contains the `submit_upstream`, `upstream_provider`, `upstream_credentials`, and `captcha_token` fields which control the server handling of the spot.
|
* **Breaking change:** The "add spot" API has changed to enable future support for upstream submission to the spotting services associated with various SIGs. Instead of just posting the spot object itself as the JSON content of the POST, this has moved into a `spot` object within the structure. A new `handling` object alongside it contains the `submit_upstream`, `upstream_provider`, `upstream_credentials`, and `captcha_token` fields which control the server handling of the spot.
|
||||||
@@ -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