Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0eb4553406 | ||
|
|
682e2c267c | ||
|
|
f39215ecdd | ||
|
|
c9c8ffc1f7 | ||
|
|
59d5f61d90 | ||
|
|
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 | ||
|
|
9d117a6069 | ||
|
|
606cf1be68 | ||
|
|
5a6fc19ab5 | ||
|
|
9258d680fc | ||
|
|
8189be699e | ||
|
|
fa2e4b929a | ||
|
|
55eaa871e9 | ||
|
|
1b62d4742b | ||
|
|
b0caf697ae | ||
|
|
a33f02491d | ||
|
|
892827f1f8 | ||
|
|
e4738b2c7d | ||
|
|
f42fd237b1 | ||
|
|
d83a979e46 | ||
|
|
12e9b6a4ca | ||
|
|
8345238d76 | ||
|
|
bc326388ac | ||
|
|
df684a601d | ||
|
|
9083470482 | ||
|
|
ba56acd7ac | ||
|
|
8451ca2727 | ||
|
|
7ae77e9fb2 | ||
|
|
c3a360e691 | ||
|
|
2a4190ec4a | ||
|
|
c71c0e9d75 | ||
|
|
f2ef6e93fa | ||
|
|
d63897f8ad | ||
|
|
02e2730e47 | ||
|
|
b2719d2cd6 | ||
|
|
b9268dd4ca | ||
|
|
0cce38b0ab | ||
|
|
5a77248dbc | ||
|
|
14e4886c1b | ||
|
|
f346444ec3 | ||
|
|
6f21ac83b7 | ||
|
|
a20c29cdd1 | ||
|
|
1fd6611ac2 | ||
|
|
a27a04b65d | ||
|
|
c5d6a65786 | ||
|
|
85f63a1258 | ||
|
|
7f1a5ae3be | ||
|
|
38bc03a603 | ||
|
|
03164d747f | ||
|
|
7623ae667e | ||
|
|
1db0c2787a | ||
|
|
e7cdfb0080 | ||
|
|
842e91b34a | ||
|
|
7220a0f58d | ||
|
|
8e48287edf | ||
|
|
36ce350079 | ||
|
|
f8032973ca | ||
|
|
0291be3338 | ||
|
|
1bda204863 | ||
|
|
00c1bc7ba3 | ||
|
|
325d445f30 | ||
|
|
ae1f752516 | ||
|
|
fe4826b729 | ||
|
|
3006ee38cb | ||
|
|
2ffa4780e8 | ||
|
|
d0d6c777da | ||
|
|
335d15bfa0 | ||
|
|
a6e54f524d | ||
|
|
dd89ff4af7 | ||
|
|
74bcd9cded | ||
|
|
af3f82c14d | ||
|
|
7391c28cd0 | ||
|
|
bff5b79f8f | ||
|
|
9cd6d9c177 | ||
|
|
0dee8d827a | ||
|
|
ef2c23c8b6 | ||
|
|
efec8e220e | ||
|
|
12c2e66c64 | ||
|
|
e94d4bd927 | ||
|
|
605b629d43 | ||
|
|
186f03a233 | ||
|
|
d1a9af9932 | ||
|
|
c913d0bac8 | ||
|
|
bab14bd7ac | ||
|
|
6a4b0bed95 | ||
|
|
c285b86131 |
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
||||
@@ -3,6 +3,7 @@
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Annotator" enabled="false" level="ERROR" enabled_by_default="false" />
|
||||
<inspection_tool class="BadExpressionStatementJS" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="CheckImageSize" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="CssOverwrittenProperties" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="CssUnresolvedCustomProperty" enabled="false" level="ERROR" enabled_by_default="false" />
|
||||
<inspection_tool class="CssUnusedSymbol" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
@@ -43,6 +44,34 @@
|
||||
<inspection_tool class="LanguageDetectionInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="OutdatedRequirementInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="PyBroadExceptionInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="PyCompatibilityInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ourVersions">
|
||||
<value>
|
||||
<list size="6">
|
||||
<item index="0" class="java.lang.String" itemvalue="3.15" />
|
||||
<item index="1" class="java.lang.String" itemvalue="3.14" />
|
||||
<item index="2" class="java.lang.String" itemvalue="3.13" />
|
||||
<item index="3" class="java.lang.String" itemvalue="3.12" />
|
||||
<item index="4" class="java.lang.String" itemvalue="3.11" />
|
||||
<item index="5" class="java.lang.String" itemvalue="3.10" />
|
||||
</list>
|
||||
</value>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PyStringConversionWithoutDunderMethodInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="ignoredTypes">
|
||||
<list>
|
||||
<option value="types.NoneType" />
|
||||
</list>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PyStubPackagesAdvertiser" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ignoredPackages">
|
||||
<list>
|
||||
<option value="pandas" />
|
||||
</list>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
|
||||
<option name="processCode" value="true" />
|
||||
<option name="processLiterals" value="true" />
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="PyToolsState">
|
||||
<option name="tools">
|
||||
<map>
|
||||
<entry key="ruff">
|
||||
<value>
|
||||
<ToolEntry>
|
||||
<option name="enabled" value="true" />
|
||||
</ToolEntry>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -12,6 +12,8 @@
|
||||
<option name="IS_MODULE_SDK" value="true" />
|
||||
<option name="ADD_CONTENT_ROOTS" value="true" />
|
||||
<option name="ADD_SOURCE_ROOTS" value="true" />
|
||||
<option name="DEBUG_JUST_MY_CODE" value="true" />
|
||||
<option name="RUN_TOOL" value="" />
|
||||
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/spothole.py" />
|
||||
<option name="PARAMETERS" value="" />
|
||||
<option name="SHOW_COMMAND_LINE" value="false" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||
|
||||
@@ -4,5 +4,6 @@ WORKDIR /app
|
||||
COPY . .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
EXPOSE 8080
|
||||
EXPOSE 7373
|
||||
|
||||
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
|
||||
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,
|
||||
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.
|
||||
|
||||
Additional Special Interest Groups (SIGs) without their own specific data source include KRMNPA, SANPCPA, WAB, WAI and
|
||||
DME.
|
||||
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.
|
||||
|
||||

|
||||
|
||||
@@ -34,18 +32,20 @@ see [https://spothole.app/apidocs](https://spothole.app/apidocs) for the API det
|
||||
This is a Progressive Web App, so you can also "install" it to your Android or iOS device by accessing it in Chrome or
|
||||
Safari respectively, and following the menu-driven process for installing PWAs.
|
||||
|
||||
You are more than welcome to use the data and the API that Spothole provides to power your own software, to run your
|
||||
own Spothole server, or to modify it in any way. More details can be found in the following sections:
|
||||
You are more than welcome to use the data and the API that Spothole provides to power your own software, to run your own
|
||||
Spothole server, or to modify it in any way. More details can be found in the following sections:
|
||||
|
||||
* [Embedding Spothole in another website](docs/embedding.md)
|
||||
* [Writing your own client](docs/clients.md)
|
||||
* [Running your own copy](docs/running.md)
|
||||
* [nginx reverse proxy configuration](docs/nginx.md)
|
||||
* [systemd service file](docs/systemd.md)
|
||||
* [Running in Docker](docs/docker.md)
|
||||
* [Use of multiple cluster logins](docs/multicluster.md)
|
||||
* [nginx reverse proxy configuration](docs/nginx.md)
|
||||
* [systemd service file](docs/systemd.md)
|
||||
* [Running in Docker](docs/docker.md)
|
||||
* [Use of multiple cluster logins](docs/multicluster.md)
|
||||
* [Modifying the source code](docs/modifying.md)
|
||||
|
||||

|
||||
|
||||
## Thanks
|
||||
|
||||
As well as being my work, I have also gratefully received feature patches from Steven, M1SDH.
|
||||
@@ -54,8 +54,8 @@ The project contains GeoJSON files for CQ and ITU zones, in the `/datafiles/` di
|
||||
my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet. `/datafiles` also contains a
|
||||
`MUNICIPIOS.csv` file, from the "Nomenclátor Geográfico de Municipios y Entidades de Población" data set sourced from
|
||||
[el Centro Nacional de Información Geográfica](https://centrodedescargas.cnig.es/CentroDescargas/home).
|
||||
`didbase-stations.csv` and the TOTA file were created by me based on publicly available data from GIRO and from
|
||||
maps of conference centres.
|
||||
`didbase-stations.csv` and the TOTA file were created by me based on publicly available data from GIRO and from maps of
|
||||
conference centres.
|
||||
|
||||
The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the
|
||||
`/static/img/flags/` directory.
|
||||
@@ -65,12 +65,14 @@ This project would not have been possible without these libraries, so many thank
|
||||
|
||||
### Third Party Libraries
|
||||
|
||||
A number of third-party libraries are self-hosted in the `/static/vendor/` directory. These files are subject to
|
||||
their own licences and are not covered by the overall licence declared in the `LICENSE` file.
|
||||
A number of third-party libraries are self-hosted in the `/static/vendor/` directory. These files are subject to their
|
||||
own licences and are not covered by the overall licence declared in the `LICENSE` file.
|
||||
|
||||
A number of third-party libraries are self-hosted in the `/webassets/vendor/` directory. These files are subject to
|
||||
their own licences and are not covered by the overall licence declared in the `LICENSE` file.
|
||||
|
||||
Sound effects are CC0-licenced and sourced from [Pixabay](https://pixabay.com).
|
||||
|
||||
Particular thanks go to country-files.com for providing country lookup data for amateur radio, to K0SWE
|
||||
for [this JSON-formatted DXCC data](https://github.com/k0swe/dxcc-json/), and to the developers of `pyhamtools` for
|
||||
making it easy to use country-files.com data as well as QRZ.com and Clublog lookup.
|
||||
|
||||
@@ -17,6 +17,15 @@ api_only_mode: false
|
||||
# The base URL at which the software runs.
|
||||
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.
|
||||
# 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
|
||||
@@ -67,8 +76,8 @@ spot_providers:
|
||||
|
||||
- class: "DXCluster"
|
||||
# Clusters have a "name" property in case you want to say which cluster node the data is from, or connect to
|
||||
# several clusters and name them separately.
|
||||
name: "HRD Cluster"
|
||||
# several clusters and name them separately. The name will be used as the "source" field for the spots received.
|
||||
name: "Cluster"
|
||||
enabled: true
|
||||
host: "hrd.wa9pie.net"
|
||||
port: 8000
|
||||
@@ -84,11 +93,11 @@ spot_providers:
|
||||
allow_rbn_spots: false
|
||||
|
||||
- class: "DXCluster"
|
||||
name: "W3LPL Cluster"
|
||||
name: "Cluster"
|
||||
enabled: false
|
||||
host: "w3lpl.net"
|
||||
port: 7373
|
||||
login_prompt: "Please enter your call:"
|
||||
login_prompt: "login:"
|
||||
login_callsign: "N0CALL-99"
|
||||
allow_rbn_spots: false
|
||||
|
||||
@@ -162,9 +171,21 @@ alert_providers:
|
||||
- class: "BOTA"
|
||||
enabled: true
|
||||
|
||||
- class: "Hamsat"
|
||||
enabled: true
|
||||
|
||||
- class: "NG3K"
|
||||
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
|
||||
# conditions, etc.) and make it available via the /api/v2/solar endpoint.
|
||||
@@ -246,12 +267,36 @@ sig_ref_data_providers:
|
||||
- class: "DME"
|
||||
enabled: true
|
||||
|
||||
- class: "FEA"
|
||||
enabled: true
|
||||
|
||||
- class: "DMVE"
|
||||
enabled: true
|
||||
|
||||
- class: "DMUE"
|
||||
enabled: true
|
||||
|
||||
- class: "DCE"
|
||||
enabled: true
|
||||
|
||||
- class: "DEFE"
|
||||
enabled: true
|
||||
|
||||
- class: "KRMNPA"
|
||||
enabled: true
|
||||
|
||||
- class: "SANPCPA"
|
||||
enabled: true
|
||||
|
||||
- class: "DTMBA"
|
||||
enabled: true
|
||||
|
||||
- class: "COTA"
|
||||
enabled: true
|
||||
|
||||
- class: "PGA"
|
||||
enabled: true
|
||||
|
||||
- class: "Toilets"
|
||||
enabled: true
|
||||
|
||||
@@ -275,27 +320,27 @@ callsign_data_providers:
|
||||
priority: 2
|
||||
# No server-side credentials for HamQTH. Users must provide their own.
|
||||
|
||||
- class: "CountryFiles"
|
||||
priority: 3
|
||||
enabled: true
|
||||
|
||||
- class: "ClublogAPI"
|
||||
# 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
|
||||
# every callsign via an API call. Normally left disabled but it exists as an option.
|
||||
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,
|
||||
# Clublog uses an API key issued to Spothole, not to the end user.
|
||||
api_key: ""
|
||||
|
||||
- class: "ClublogXML"
|
||||
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
|
||||
# one via their helpdesk portal if you want to use callsign lookups from Clublog.
|
||||
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
|
||||
# for alerts.
|
||||
@@ -345,4 +390,6 @@ web_ui_options:
|
||||
# (home) page, although being arbitrary HTML you can also use a div with absolute, relative, float placement etc. This
|
||||
# is designed for a "donate/support the server" type button, though you are free to do whatever you want with it.
|
||||
# As the server owner you are responsible for the safe usage of this option!
|
||||
support_button_html: ""
|
||||
support_button_html: ""
|
||||
# CartoDB API key. Required to use the CartoDB layers without watermark. Get one from https://carto.com/basemaps/apikey/.
|
||||
cartodb_api_key: ""
|
||||
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
from core.data_providers import DATA_PROVIDERS
|
||||
from data.callsign import Callsign
|
||||
|
||||
@@ -9,10 +11,11 @@ def get_call_info(callsign, lookup_credentials):
|
||||
|
||||
callsign_data = Callsign(call=callsign)
|
||||
|
||||
if callsign:
|
||||
# First check our input looks like a real callsign
|
||||
if callsign and re.match(r"^[A-Za-z0-9/\-]*$", callsign):
|
||||
# Sort callsign providers by priority order, so we query the highest priority (lowest numbers) first, and only
|
||||
# query other providers for data we are missing as we go along.
|
||||
for p in sorted(DATA_PROVIDERS.callsign_data_providers, key=lambda p: p.priority):
|
||||
for p in sorted(DATA_PROVIDERS.callsign_data_providers, key=lambda p2: p2.priority):
|
||||
if p.enabled:
|
||||
# Get new lookup data
|
||||
data = p.lookup(callsign, lookup_credentials)
|
||||
@@ -27,4 +30,4 @@ def get_call_info(callsign, lookup_credentials):
|
||||
if callsign_data.fully_populated():
|
||||
break
|
||||
|
||||
return callsign_data
|
||||
return callsign_data
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from threading import Event, Thread
|
||||
|
||||
import pytz
|
||||
|
||||
from core.data_store import DATA_STORE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CleanupTimer:
|
||||
"""Provides a timed cleanup of the spot list."""
|
||||
|
||||
def __init__(self):
|
||||
"""Constructor"""
|
||||
|
||||
self._cleanup_interval = None
|
||||
self.last_cleanup_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||
self.status = "Starting"
|
||||
self._thread = None
|
||||
self._stop_event = Event()
|
||||
|
||||
def setup(self, cleanup_interval):
|
||||
self._cleanup_interval = cleanup_interval
|
||||
|
||||
def start(self):
|
||||
"""Start the cleanup timer"""
|
||||
|
||||
self._thread = Thread(target=self._run, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""Stop any threads and prepare for application shutdown"""
|
||||
|
||||
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):
|
||||
while not self._stop_event.wait(timeout=self._cleanup_interval):
|
||||
self._cleanup()
|
||||
|
||||
def _cleanup(self):
|
||||
"""Perform cleanup and reschedule next timer"""
|
||||
|
||||
try:
|
||||
for i in list(DATA_STORE.spots.keys()):
|
||||
try:
|
||||
spot = DATA_STORE.spots.get(i)
|
||||
if spot and spot.expired():
|
||||
DATA_STORE.spots.delete(i)
|
||||
except KeyError:
|
||||
# Must have already been deleted, OK with that
|
||||
pass
|
||||
for i in list(DATA_STORE.alerts.keys()):
|
||||
try:
|
||||
alert = DATA_STORE.alerts.get(i)
|
||||
if alert and alert.expired():
|
||||
DATA_STORE.alerts.delete(i)
|
||||
except KeyError:
|
||||
# Must have already been deleted, OK with that
|
||||
pass
|
||||
|
||||
self.status = "OK"
|
||||
self.last_cleanup_time = datetime.now(pytz.UTC)
|
||||
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logger.exception("Exception in Cleanup thread")
|
||||
self._stop_event.wait(timeout=1)
|
||||
|
||||
|
||||
# Global object
|
||||
CLEANUP_TIMER = CleanupTimer()
|
||||
@@ -1,25 +1,32 @@
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check you have a config file
|
||||
if not os.path.isfile("config.yml"):
|
||||
logging.error(
|
||||
"Your config file is missing. Ensure you have copied config-example.yml to config.yml and updated it according to your needs.")
|
||||
exit()
|
||||
logger.error(
|
||||
"Your config file is missing. Ensure you have copied config-example.yml to config.yml and updated it according to your needs."
|
||||
)
|
||||
sys.exit()
|
||||
|
||||
# Load config
|
||||
with open("config.yml") as f:
|
||||
config = yaml.safe_load(f)
|
||||
logging.info("Loaded config.")
|
||||
logger.info("Loaded config.")
|
||||
|
||||
BASE_URL = config.get("base_url", "http://localhost:8080")
|
||||
MAX_SPOT_AGE = config.get("max_spot_age_sec", 3600)
|
||||
MAX_ALERT_AGE = config.get("max_alert_age_sec", 604800)
|
||||
SERVER_OWNER_CALLSIGN = config.get("server_owner_callsign", "N0CALL")
|
||||
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_UPSTREAM_SPOTTING = config.get("allow_upstream_spotting", True)
|
||||
WEB_UI_OPTIONS = config.get("web_ui_options", {})
|
||||
@@ -30,7 +37,9 @@ LOG_LEVEL = config.get("log_level", "INFO")
|
||||
LOG_WEB_REQUESTS = config.get("log_web_requests", False)
|
||||
|
||||
WEB_UI_OPTIONS["qrz_enabled"] = any(p["class"] == "QRZ" and p["enabled"] for p in config["callsign_data_providers"])
|
||||
WEB_UI_OPTIONS["hamqth_enabled"] = any(p["class"] == "HamQTH" and p["enabled"] for p in config["callsign_data_providers"])
|
||||
WEB_UI_OPTIONS["hamqth_enabled"] = any(
|
||||
p["class"] == "HamQTH" and p["enabled"] for p in config["callsign_data_providers"]
|
||||
)
|
||||
WEB_UI_OPTIONS["recaptcha_site_key"] = RECAPTCHA_SITE_KEY
|
||||
WEB_UI_OPTIONS["allow_upstream_spotting"] = ALLOW_SPOTTING and ALLOW_UPSTREAM_SPOTTING
|
||||
|
||||
@@ -40,6 +49,6 @@ def create_provider_from_config(package, config_providers_entry):
|
||||
package to look for it in, as there are several types of provider. e.g. package "providers.spot", where the config
|
||||
entry is for a POTA spot provider."""
|
||||
|
||||
module = importlib.import_module(package + "." + config_providers_entry["class"].lower())
|
||||
module = importlib.import_module(f"{package}.{config_providers_entry['class'].lower()}")
|
||||
provider_class = getattr(module, config_providers_entry["class"])
|
||||
return provider_class(config_providers_entry)
|
||||
|
||||
@@ -1,66 +1,403 @@
|
||||
from core.config import SERVER_OWNER_CALLSIGN
|
||||
from core.enums import SIGRefType, SIGType
|
||||
from data.band import Band
|
||||
from data.sig import SIG
|
||||
|
||||
# General software
|
||||
SOFTWARE_VERSION = "2.0-pre"
|
||||
SOFTWARE_VERSION = "2.1.2"
|
||||
|
||||
# HTTP headers used for spot providers that use HTTP
|
||||
HTTP_HEADERS = {"User-Agent": "Spothole v" + SOFTWARE_VERSION + " (operated by " + SERVER_OWNER_CALLSIGN + ")"}
|
||||
HAMQTH_PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_CALLSIGN).replace(" ", "_")
|
||||
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
|
||||
HAMQTH_PRG = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}".replace(" ", "_")
|
||||
|
||||
# Special Interest Groups
|
||||
SIGS = [
|
||||
SIG(name="POTA", comment_names=["POTA"], description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}|K\-TEST"),
|
||||
SIG(name="SOTA", comment_names=["SOTA"], description="Summits on the Air", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
|
||||
SIG(name="WWFF", comment_names=["WWFF"], description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"),
|
||||
SIG(name="GMA", comment_names=["GMA"], description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
|
||||
SIG(name="WWBOTA", comment_names=["WWBOTA", "BOTA"], description="Worldwide Bunkers on the Air", ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}"),
|
||||
SIG(name="HEMA", comment_names=["HEMA"], description="HuMPs Excluding Marilyns Award", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}"),
|
||||
SIG(name="IOTA", comment_names=["IOTA"], description="Islands on the Air", ref_regex=r"[A-Z]{2}\-\d{3}"),
|
||||
SIG(name="MOTA", comment_names=["MOTA"], description="Mills on the Air", ref_regex=r"X\d{4,6}"),
|
||||
SIG(name="ARLHS", comment_names=["ARLHS"], description="Amateur Radio Lighthouse Society", ref_regex=r"[A-Z]{3}[\- ]\d{3,4}"),
|
||||
SIG(name="ILLW", comment_names=["ILLW"], description="International Lighthouse & Lightship Weekend", ref_regex=r"[A-Z]{2}\d{4}"),
|
||||
SIG(name="SIOTA", comment_names=["SIOTA"], description="Silos on the Air", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"),
|
||||
SIG(name="WCA", comment_names=["WCA"], description="World Castles Award", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"),
|
||||
SIG(name="ZLOTA", comment_names=["ZLOTA"], description="New Zealand on the Air", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}"),
|
||||
SIG(name="WOTA", comment_names=["WOTA"], description="Wainwrights on the Air", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
|
||||
SIG(name="BOTA", comment_names=[], description="Beaches on the Air"),
|
||||
SIG(name="KRMNPA", comment_names=["KRMNPA"], description="Keith Roget Memorial National Parks Award", ref_regex=r"VKFF\-\d{4}"),
|
||||
SIG(name="SANPCPA", comment_names=["SANPCPA"], description="South Australian National Parks and Conservation Parks Award", ref_regex=r"VKFF\-\d{4}"),
|
||||
SIG(name="LLOTA", comment_names=["LLOTA"], description="Lagos y Lagunas on the Air", ref_regex=r"LL[A-Z]{2}\-\d{4}"),
|
||||
SIG(name="Towers", comment_names=["TOTA"], description="Towers on the Air", ref_regex=r"[A-Z]{2,3}R\-\d{4}"),
|
||||
SIG(name="Tiles", comment_names=[], description="Tiles on the Air", ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}"),
|
||||
SIG(name="WAB", comment_names=["WAB"], description="Worked All Britain", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
|
||||
SIG(name="WAI", comment_names=["WAI"], description="Worked All Ireland", ref_regex=r"[A-Z][0-9]{2}"),
|
||||
SIG(name="DME", comment_names=["DME"], description="Diplomas de Municipios Españoles", ref_regex=r"\d{4,5}"),
|
||||
SIG(name="Toilets", comment_names=[], description="Toilets on the Air", ref_regex=r"T\-[0-9]{2}")
|
||||
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(
|
||||
name="POTA",
|
||||
comment_names=["POTA"],
|
||||
description="Parks on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.PARK,
|
||||
ref_regex=r"[A-Z]{2}\-\d{4,5}|K\-TEST",
|
||||
icon="fa-tree",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="SOTA",
|
||||
comment_names=["SOTA"],
|
||||
description="Summits on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
|
||||
icon="fa-mountain-sun",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="WWFF",
|
||||
comment_names=["WWFF"],
|
||||
description="World Wide Flora & Fauna",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.PARK,
|
||||
ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}",
|
||||
icon="fa-seedling",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="GMA",
|
||||
comment_names=["GMA"],
|
||||
description="Global Mountain Activity",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
|
||||
icon="fa-person-hiking",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="WWBOTA",
|
||||
comment_names=["WWBOTA", "BOTA"],
|
||||
description="Worldwide Bunkers on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.BUNKER,
|
||||
ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}",
|
||||
icon="fa-radiation",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="HEMA",
|
||||
comment_names=["HEMA"],
|
||||
description="HuMPs Excluding Marilyns Award",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}",
|
||||
icon="fa-mound",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="IOTA",
|
||||
comment_names=["IOTA"],
|
||||
description="Islands on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.ISLAND,
|
||||
ref_regex=r"[A-Z]{2}\-\d{3}",
|
||||
icon="fa-book-atlas",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="GMA Islands",
|
||||
comment_names=[],
|
||||
description="Global Mountain Activity - Islands",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.ISLAND,
|
||||
ref_regex=r"(([A-Z]{2}\-\d{3})|([A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}))",
|
||||
icon="fa-person-hiking",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="ARLHS",
|
||||
comment_names=["ARLHS"],
|
||||
description="Amateur Radio Lighthouse Society",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.LIGHTHOUSE,
|
||||
ref_regex=r"[A-Z]{3}[\- ]\d{3,4}",
|
||||
icon="fa-house-flood-water",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="ILLW",
|
||||
comment_names=["ILLW"],
|
||||
description="International Lighthouse & Lightship Weekend",
|
||||
sig_type=SIGType.EVENT,
|
||||
ref_type=SIGRefType.LIGHTHOUSE,
|
||||
ref_regex=r"[A-Z]{2}\d{4}",
|
||||
icon="fa-house-flood-water",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="MOTA",
|
||||
comment_names=["MOTA"],
|
||||
description="Mills on the Air",
|
||||
sig_type=SIGType.EVENT,
|
||||
ref_type=SIGRefType.MILL,
|
||||
ref_regex=r"X\d{4,6}",
|
||||
icon="fa-fan",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="SIOTA",
|
||||
comment_names=["SIOTA"],
|
||||
description="Silos on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.SILO,
|
||||
ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d",
|
||||
icon="fa-wheat-awn",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="WCA",
|
||||
comment_names=["WCA"],
|
||||
description="World Castles Award",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.CASTLE,
|
||||
ref_regex=r"[A-Z0-9]{1,3}\-\d{5}",
|
||||
icon="fa-chess-rook",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="ZLOTA",
|
||||
comment_names=["ZLOTA"],
|
||||
description="New Zealand on the Air",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=None,
|
||||
ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}",
|
||||
icon="fa-kiwi-bird",
|
||||
region_flag="🇳🇿",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="WOTA",
|
||||
comment_names=["WOTA"],
|
||||
description="Wainwrights on the Air",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
ref_regex=r"[A-Z]{3}-[0-9]{2}",
|
||||
icon="fa-w",
|
||||
region_flag="🇬🇧",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="BOTA",
|
||||
comment_names=[],
|
||||
description="Beaches on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.BEACH,
|
||||
icon="fa-umbrella-beach",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="KRMNPA",
|
||||
comment_names=["KRMNPA"],
|
||||
description="Keith Roget Memorial National Parks Award",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.PARK,
|
||||
ref_regex=r"VKFF\-\d{4}",
|
||||
icon="fa-earth-oceania",
|
||||
region_flag="🇦🇺",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="SANPCPA",
|
||||
comment_names=["SANPCPA"],
|
||||
description="South Australian National Parks and Conservation Parks Award",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.PARK,
|
||||
ref_regex=r"VKFF\-\d{4}",
|
||||
icon="fa-earth-oceania",
|
||||
region_flag="🇦🇺",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="LLOTA",
|
||||
comment_names=["LLOTA"],
|
||||
description="Lagos y Lagunas on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.LAKE,
|
||||
ref_regex=r"LL[A-Z]{2}\-\d{4}",
|
||||
icon="fa-water",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="Towers",
|
||||
comment_names=["TOTA"],
|
||||
description="Towers on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.TOWER,
|
||||
ref_regex=r"[A-Z]{2,3}R\-\d{4}",
|
||||
icon="fa-tower-observation",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="Tiles",
|
||||
comment_names=[],
|
||||
description="Tiles on the Air",
|
||||
sig_type=SIGType.WORLDWIDE,
|
||||
ref_type=SIGRefType.GRID,
|
||||
ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}",
|
||||
icon="fa-square",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="WAB",
|
||||
comment_names=["WAB"],
|
||||
description="Worked All Britain",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.GRID,
|
||||
ref_regex=r"[A-Z]{1,2}[0-9]{2}",
|
||||
icon="fa-table-cells-large",
|
||||
region_flag="🇬🇧",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="WAI",
|
||||
comment_names=["WAI"],
|
||||
description="Worked All Ireland",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.GRID,
|
||||
ref_regex=r"[A-Z][0-9]{2}",
|
||||
icon="fa-table-cells-large",
|
||||
region_flag="🇮🇪",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="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(
|
||||
name="DME",
|
||||
comment_names=["DME"],
|
||||
description="Diploma Municipios de España",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.TOWN,
|
||||
ref_regex=r"DME[\- ]\d{3,5}",
|
||||
icon="fa-building",
|
||||
region_flag="🇪🇸",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="FEA",
|
||||
comment_names=["FEA"],
|
||||
description="Diploma Faros de España",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.LIGHTHOUSE,
|
||||
# FEA references are technically [DE]\-\d{4}(\.\d)? but spotters always seem to miss out the D- or E-
|
||||
# prefix and just use FEA-1234 or FEA 1234, so allow for that. The FEA sigref data provider adds both
|
||||
# forms to the database.
|
||||
ref_regex=r"([DE]|FEA)[\- ]\d{4}(\.\d)?",
|
||||
icon="fa-house-flood-water",
|
||||
region_flag="🇪🇸",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="DMUE",
|
||||
comment_names=["DMUE"],
|
||||
description="Diploma Museos de España",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.BUILDING,
|
||||
ref_regex=r"MUE[A-Z]{2}-\d{3}",
|
||||
icon="fa-landmark",
|
||||
region_flag="🇪🇸",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="DMVE",
|
||||
comment_names=["DMVE"],
|
||||
description="Diploma Monumentos y Vestigios de España",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.BUILDING,
|
||||
ref_regex=r"MV[A-Z]{1,2}-\d{4}",
|
||||
icon="fa-monument",
|
||||
region_flag="🇪🇸",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="DCE",
|
||||
comment_names=["DCE"],
|
||||
description="Diploma Castillos de España",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.CASTLE,
|
||||
ref_regex=r"C[A-Z]{1,2}-\d{3}",
|
||||
icon="fa-chess-rook",
|
||||
region_flag="🇪🇸",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="DEFE",
|
||||
comment_names=["DEFE"],
|
||||
description="Diploma Estaciones de Ferrocarril de España",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.BUILDING,
|
||||
ref_regex=r"EF[A-Z]{1,2}-\d{3}",
|
||||
icon="fa-train",
|
||||
region_flag="🇪🇸",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="DTMBA",
|
||||
comment_names=["DTMBA"],
|
||||
description="Diploma Teatri Musei e Belle Arti",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.BUILDING,
|
||||
ref_regex=r"I-?[0-9]{3,4}\s?[A-Z]{2}",
|
||||
icon="fa-landmark",
|
||||
region_flag="🇮🇹",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
SIG(
|
||||
name="BIWOTA",
|
||||
comment_names=["BIWOTA"],
|
||||
description="British Inland Waterways on the Air",
|
||||
sig_type=SIGType.EVENT,
|
||||
ref_type=SIGRefType.WATERWAY,
|
||||
icon="fa-ship",
|
||||
region_flag="🇬🇧",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="COTA",
|
||||
comment_names=["COTA"],
|
||||
description="Castles on the Air",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.CASTLE,
|
||||
ref_regex=r"[A-Z]{3}\-[0-9]{3,5}",
|
||||
icon="fa-chess-rook",
|
||||
region_flag="🇩🇪",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="PGA",
|
||||
comment_names=["PGA"],
|
||||
description="Polish Gmina Award",
|
||||
sig_type=SIGType.REGIONAL,
|
||||
ref_type=SIGRefType.REGION,
|
||||
ref_regex=r"[A-Z]{2}[0-9]{2}",
|
||||
icon="fa-g",
|
||||
region_flag="🇵🇱",
|
||||
refs_globally_unique=False,
|
||||
),
|
||||
SIG(
|
||||
name="Toilets",
|
||||
comment_names=[],
|
||||
description="Toilets on the Air",
|
||||
sig_type=SIGType.EVENT,
|
||||
ref_type=SIGRefType.TOILET,
|
||||
ref_regex=r"T\-[0-9]{2}",
|
||||
icon="fa-toilet",
|
||||
region_flag="🏴☠️",
|
||||
refs_globally_unique=True,
|
||||
),
|
||||
]
|
||||
|
||||
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
|
||||
CW_MODES = ["CW"]
|
||||
PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "FUSION", "M17"]
|
||||
DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"]
|
||||
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
|
||||
MODE_TYPES = ["CW", "PHONE", "DATA"]
|
||||
SSB_SUB_MODES = ["USB", "LSB"]
|
||||
DV_SUB_MODES = ["DMR", "DSTAR", "C4FM", "FUSION", "M17"]
|
||||
|
||||
# Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode
|
||||
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots
|
||||
# that match a key in this table will be converted to the corresponding value, so only the modes above will actually be
|
||||
# present in the spots.
|
||||
MODE_ALIASES = {
|
||||
"RTT": "RTTY",
|
||||
"BPSK": "PSK",
|
||||
"PSK31": "PSK",
|
||||
"BPSK31": "PSK",
|
||||
"MFSK": "FSK",
|
||||
"MFSK32": "FSK",
|
||||
"DIGI": "DATA",
|
||||
"DIGITAL": "DATA"
|
||||
}
|
||||
|
||||
# Band definitions
|
||||
BANDS = [
|
||||
Band(name="2200m", start_freq=135700, end_freq=137800),
|
||||
@@ -76,7 +413,7 @@ BANDS = [
|
||||
Band(name="12m", start_freq=24890000, end_freq=24990000, is_ham_hf=True),
|
||||
Band(name="11m", start_freq=26965000, end_freq=27405000),
|
||||
Band(name="10m", start_freq=28000000, end_freq=29700000, is_ham_hf=True),
|
||||
Band(name="6m", start_freq=50000000, end_freq=54000000),
|
||||
Band(name="6m", start_freq=50000000, end_freq=54000000, is_ham_hf=True),
|
||||
Band(name="5m", start_freq=56000000, end_freq=60500000),
|
||||
Band(name="4m", start_freq=70000000, end_freq=70500000),
|
||||
Band(name="2m", start_freq=144000000, end_freq=148000000),
|
||||
@@ -88,12 +425,10 @@ BANDS = [
|
||||
Band(name="10GHz", start_freq=10000000000, end_freq=10500000000),
|
||||
Band(name="24GHz", start_freq=24000000000, end_freq=24050000000),
|
||||
Band(name="47GHz", start_freq=47000000000, end_freq=47200000000),
|
||||
Band(name="76GHz", start_freq=75500000000, end_freq=81500000000)]
|
||||
Band(name="76GHz", start_freq=75500000000, end_freq=81500000000),
|
||||
]
|
||||
UNKNOWN_BAND = Band(name="Unknown", start_freq=0, end_freq=0)
|
||||
|
||||
# Continents
|
||||
CONTINENTS = ["EU", "NA", "SA", "AS", "AF", "OC", "AN"]
|
||||
|
||||
# Propagation modes used in VHF/UHF DX cluster comments, e.g. "JN61ES<ES>JM56XT". I don't think there's an official list
|
||||
# of these anywhere, but here are some I've seen or seen reference to
|
||||
PROPAGATION_MODES = {
|
||||
@@ -106,5 +441,6 @@ PROPAGATION_MODES = {
|
||||
"MS": "Meteor scatter",
|
||||
"RS": "Rain scatter",
|
||||
"AS": "Aircraft scatter",
|
||||
"SAT": "Satellite"
|
||||
"ACS": "Aircraft scatter",
|
||||
"SAT": "Satellite",
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from core.config import config, create_provider_from_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DataProviders:
|
||||
"""Global object for storing data providers."""
|
||||
@@ -14,7 +17,7 @@ class DataProviders:
|
||||
self.static_data_providers = []
|
||||
self.sig_ref_data_providers = []
|
||||
self.callsign_data_providers = []
|
||||
|
||||
self._startup_timers = []
|
||||
|
||||
def setup(self):
|
||||
for entry in config["spot_providers"]:
|
||||
@@ -31,10 +34,10 @@ class DataProviders:
|
||||
self.callsign_data_providers.append(create_provider_from_config("providers.callsigndata", entry))
|
||||
|
||||
@staticmethod
|
||||
def start_providers(providers, type):
|
||||
def start_providers(providers, provider_type):
|
||||
"""Helper method to activate enabled providers in the list."""
|
||||
|
||||
logging.info(f"Starting %s providers...", type)
|
||||
logger.info(f"Starting {provider_type} providers...")
|
||||
for p in providers:
|
||||
if p.enabled:
|
||||
p.start()
|
||||
@@ -42,32 +45,59 @@ class DataProviders:
|
||||
def start(self):
|
||||
# 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.
|
||||
threading.Timer(5.0, lambda: self.start_providers(self.static_data_providers, "static data")).start()
|
||||
threading.Timer(10.0, lambda: self.start_providers(self.callsign_data_providers, "callsign data")).start()
|
||||
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")).start()
|
||||
threading.Timer(25.0, lambda: self.start_providers(self.solar_condition_providers, "solar condition")).start()
|
||||
threading.Timer(30.0, lambda: self.start_providers(self.sig_ref_data_providers, "SIG ref data")).start()
|
||||
self._startup_timers = [
|
||||
threading.Timer(5.0, lambda: self.start_providers(self.static_data_providers, "static data")),
|
||||
threading.Timer(10.0, lambda: self.start_providers(self.callsign_data_providers, "callsign data")),
|
||||
threading.Timer(15.0, lambda: self.start_providers(self.spot_providers, "spot")),
|
||||
threading.Timer(20.0, lambda: self.start_providers(self.alert_providers, "alert")),
|
||||
threading.Timer(
|
||||
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")),
|
||||
]
|
||||
for t in self._startup_timers:
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
def stop(self):
|
||||
for sp in self.spot_providers:
|
||||
if sp.enabled:
|
||||
sp.stop()
|
||||
for ap in self.alert_providers:
|
||||
if ap.enabled:
|
||||
ap.stop()
|
||||
for scp in self.solar_condition_providers:
|
||||
if scp.enabled:
|
||||
scp.stop()
|
||||
for srdp in self.sig_ref_data_providers:
|
||||
if srdp.enabled:
|
||||
srdp.stop()
|
||||
for sdp in self.static_data_providers:
|
||||
if sdp.enabled:
|
||||
sdp.stop()
|
||||
for cdp in self.callsign_data_providers:
|
||||
if cdp.enabled:
|
||||
cdp.stop()
|
||||
# Cancel any startup timers that haven't fired yet
|
||||
for t in self._startup_timers:
|
||||
t.cancel()
|
||||
|
||||
# Stop all providers
|
||||
all_providers = [
|
||||
p
|
||||
for p in (
|
||||
self.spot_providers
|
||||
+ self.alert_providers
|
||||
+ self.solar_condition_providers
|
||||
+ self.sig_ref_data_providers
|
||||
+ self.static_data_providers
|
||||
+ self.callsign_data_providers
|
||||
)
|
||||
if p.enabled
|
||||
]
|
||||
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
|
||||
DATA_PROVIDERS = DataProviders()
|
||||
DATA_PROVIDERS = DataProviders()
|
||||
|
||||
@@ -4,10 +4,13 @@ from pathlib import Path
|
||||
|
||||
import diskcache
|
||||
|
||||
from core.config import MAX_SPOT_AGE, MAX_ALERT_AGE
|
||||
from core.config import MAX_ALERT_AGE, MAX_SPOT_AGE
|
||||
from core.live_data_cache import LiveDataCache
|
||||
from core.single_object_data_cache import SingleObjectDataCache
|
||||
from data.solar_conditions import SolarConditions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CACHE_DIR = "./cache/"
|
||||
|
||||
|
||||
@@ -32,10 +35,8 @@ class DataStore:
|
||||
self.dxcc_data = None
|
||||
self.dxcc_lookup_by_call_regex = []
|
||||
self.sigrefs = None
|
||||
self.status_data = {}
|
||||
self._status = None
|
||||
self.solar_conditions = {}
|
||||
self._solar = None
|
||||
self.status = None
|
||||
self.solar_conditions = None
|
||||
# ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be
|
||||
# caches, they can just be straight objects
|
||||
self.cq_zone_data = None
|
||||
@@ -44,54 +45,60 @@ class DataStore:
|
||||
def setup(self):
|
||||
Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Standard disk cache for solar data and status data, but each cache contains only a single object which we
|
||||
# expose to the wider application
|
||||
self._solar = diskcache.Cache(CACHE_DIR + "solar")
|
||||
if "solar_conditions" not in self._solar:
|
||||
self._solar.add("solar_conditions", SolarConditions())
|
||||
self.solar_conditions = self._solar.get("solar_conditions")
|
||||
self._status = diskcache.Cache(CACHE_DIR + "status")
|
||||
if "status_data" not in self._status:
|
||||
self._status.add("status_data", {})
|
||||
self.status_data = self._status.get("status_data")
|
||||
# For solar data and status data, we use a wrapper around disk cache where each cache contains only a single
|
||||
# object exposed to the wider application, and provides a store() method for callers to notify diskcache that
|
||||
# the object has changed and needs to be re-cached.
|
||||
self.solar_conditions = SingleObjectDataCache(f"{CACHE_DIR}solar", SolarConditions())
|
||||
self.status = SingleObjectDataCache(f"{CACHE_DIR}status", {})
|
||||
|
||||
# Standard disk cache for static reference and SIG ref data. Separate provider threads will repopulate these on
|
||||
# a regular basis but there's no need for a TTL since old data is better than no data.
|
||||
self.dxcc_data = diskcache.Cache(CACHE_DIR + "dxcc_data")
|
||||
self.dxcc_data = diskcache.Cache(f"{CACHE_DIR}dxcc_data")
|
||||
self.regenerate_call_regex_to_dxcc_entity_map()
|
||||
|
||||
# For SIG reference data specifically, we need to key on both SIG *and* reference, and trying to do two layers
|
||||
# of dict in diskcache absolutely destroys performance with unpickling huge dicts, so we have an ugly "SIG:ref"
|
||||
# syntax for keys to keep it a single level.
|
||||
self.sigrefs = diskcache.Cache(CACHE_DIR + "sigrefs")
|
||||
logging.info(f"Loaded data for %d SIG references.", len(self.sigrefs))
|
||||
self.sigrefs = diskcache.Cache(f"{CACHE_DIR}sigrefs")
|
||||
logger.info(f"Loaded data for {len(self.sigrefs)} SIG references.")
|
||||
|
||||
# Standard disk cache for callsign data. This data does have a TTL to trigger an occasional re-lookup.
|
||||
# Old data *is* better than no data, but we can't have a background thread re-looking-up every callsign
|
||||
# we've seen, so we rely on them timing out and this triggering another lookup.
|
||||
self.callsign_data_countryfiles = diskcache.Cache(CACHE_DIR + "callsign_data_countryfiles")
|
||||
self.callsign_data_clublogxml = diskcache.Cache(CACHE_DIR + "callsign_data_clublogxml")
|
||||
self.callsign_data_clublogapi = diskcache.Cache(CACHE_DIR + "callsign_data_clublogapi")
|
||||
self.callsign_data_qrz = diskcache.Cache(CACHE_DIR + "callsign_data_qrz")
|
||||
self.callsign_data_hamqth = diskcache.Cache(CACHE_DIR + "callsign_data_hamqth")
|
||||
self.callsign_data_countryfiles = diskcache.Cache(f"{CACHE_DIR}callsign_data_countryfiles")
|
||||
self.callsign_data_clublogxml = diskcache.Cache(f"{CACHE_DIR}callsign_data_clublogxml")
|
||||
self.callsign_data_clublogapi = diskcache.Cache(f"{CACHE_DIR}callsign_data_clublogapi")
|
||||
self.callsign_data_qrz = diskcache.Cache(f"{CACHE_DIR}callsign_data_qrz")
|
||||
self.callsign_data_hamqth = diskcache.Cache(f"{CACHE_DIR}callsign_data_hamqth")
|
||||
unique_keys = set()
|
||||
for c in [self.callsign_data_countryfiles, self.callsign_data_clublogxml, self.callsign_data_clublogapi,
|
||||
self.callsign_data_qrz, self.callsign_data_hamqth]:
|
||||
for c in [
|
||||
self.callsign_data_countryfiles,
|
||||
self.callsign_data_clublogxml,
|
||||
self.callsign_data_clublogapi,
|
||||
self.callsign_data_qrz,
|
||||
self.callsign_data_hamqth,
|
||||
]:
|
||||
unique_keys.update(c)
|
||||
logging.info(f"Loaded data for %d callsigns.", len(unique_keys))
|
||||
logger.info(f"Loaded data for {len(unique_keys)} callsigns.")
|
||||
|
||||
# Special caches for spots and alerts, which have TTL and write snapshots to disk at an interval. We
|
||||
# specifically load these caches *last* so that any sigref and callsign data is already loaded from disk cache
|
||||
# before the spots and alerts are live in the system.
|
||||
self.spots = LiveDataCache(maxsize=self._MAX_SPOT_COUNT, ttl=MAX_SPOT_AGE,
|
||||
snapshot_dir=CACHE_DIR + "spots",
|
||||
snapshot_interval_sec=self._SPOT_ALERT_SNAPSHOT_INTERVAL_SEC)
|
||||
logging.info(f"Loaded %d spots from a previous run.", len(self.spots.keys()))
|
||||
self.spots = LiveDataCache(
|
||||
maxsize=self._MAX_SPOT_COUNT,
|
||||
ttl=MAX_SPOT_AGE,
|
||||
snapshot_dir=f"{CACHE_DIR}spots",
|
||||
snapshot_interval_sec=self._SPOT_ALERT_SNAPSHOT_INTERVAL_SEC,
|
||||
)
|
||||
logger.info(f"Loaded {len(self.spots.keys())} spots from a previous run.")
|
||||
|
||||
self.alerts = LiveDataCache(maxsize=self._MAX_ALERT_COUNT, ttl=MAX_ALERT_AGE,
|
||||
snapshot_dir=CACHE_DIR + "alerts",
|
||||
snapshot_interval_sec=self._SPOT_ALERT_SNAPSHOT_INTERVAL_SEC)
|
||||
logging.info(f"Loaded %d alerts from a previous run.", len(self.alerts.keys()))
|
||||
self.alerts = LiveDataCache(
|
||||
maxsize=self._MAX_ALERT_COUNT,
|
||||
ttl=MAX_ALERT_AGE,
|
||||
snapshot_dir=f"{CACHE_DIR}alerts",
|
||||
snapshot_interval_sec=self._SPOT_ALERT_SNAPSHOT_INTERVAL_SEC,
|
||||
)
|
||||
logger.info(f"Loaded {len(self.alerts.keys())} alerts from a previous run.")
|
||||
|
||||
def regenerate_call_regex_to_dxcc_entity_map(self):
|
||||
"""DXCC entity data from K0SWE includes a regex which we can use to match a callsign, and determine which DXCC
|
||||
@@ -99,14 +106,15 @@ class DataStore:
|
||||
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."""
|
||||
|
||||
self.dxcc_lookup_by_call_regex = []
|
||||
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"]))
|
||||
|
||||
def close(self):
|
||||
self.spots.close()
|
||||
self.alerts.close()
|
||||
self._solar.close()
|
||||
self._status.close()
|
||||
self.solar_conditions.close()
|
||||
self.status.close()
|
||||
self.dxcc_data.close()
|
||||
self.sigrefs.close()
|
||||
self.callsign_data_countryfiles.close()
|
||||
@@ -115,5 +123,6 @@ class DataStore:
|
||||
self.callsign_data_qrz.close()
|
||||
self.callsign_data_hamqth.close()
|
||||
|
||||
|
||||
# Global object
|
||||
DATA_STORE = DataStore()
|
||||
DATA_STORE = DataStore()
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Continent(str, Enum):
|
||||
EU = "EU"
|
||||
NA = "NA"
|
||||
SA = "SA"
|
||||
AS = "AS"
|
||||
AF = "AF"
|
||||
OC = "OC"
|
||||
AN = "AN"
|
||||
|
||||
|
||||
class Mode(str, Enum):
|
||||
CW = "CW"
|
||||
PHONE = "PHONE"
|
||||
SSB = "SSB"
|
||||
AM = "AM"
|
||||
FM = "FM"
|
||||
DV = "DV"
|
||||
DATA = "DATA"
|
||||
FT8 = "FT8"
|
||||
FT4 = "FT4"
|
||||
RTTY = "RTTY"
|
||||
SSTV = "SSTV"
|
||||
JS8 = "JS8"
|
||||
HELL = "HELL"
|
||||
PSK = "PSK"
|
||||
FSK = "FSK"
|
||||
OLIVIA = "OLIVIA"
|
||||
PKT = "PKT"
|
||||
MSK144 = "MSK144"
|
||||
|
||||
@property
|
||||
def is_cw(self) -> bool:
|
||||
return self == Mode.CW
|
||||
|
||||
@property
|
||||
def is_phone(self) -> bool:
|
||||
return self in {Mode.PHONE, Mode.SSB, Mode.AM, Mode.FM, Mode.DV}
|
||||
|
||||
@property
|
||||
def is_data(self) -> bool:
|
||||
return not (self.is_cw or self.is_phone)
|
||||
|
||||
@staticmethod
|
||||
def from_name(name):
|
||||
"""Convert a string to an enum mode using the alias table."""
|
||||
|
||||
if not name:
|
||||
return None
|
||||
|
||||
try:
|
||||
return Mode(name.upper())
|
||||
except (KeyError, ValueError):
|
||||
try:
|
||||
return Mode(MODE_ALIASES[name.upper()])
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class ModeType(str, Enum):
|
||||
PHONE = "PHONE"
|
||||
CW = "CW"
|
||||
DATA = "DATA"
|
||||
|
||||
|
||||
class ModeSource(str, Enum):
|
||||
"""Where the mode data came from in a spot."""
|
||||
|
||||
SPOT = "SPOT"
|
||||
COMMENT = "COMMENT"
|
||||
BANDPLAN = "BANDPLAN"
|
||||
|
||||
|
||||
class LocationSourceForSpot(str, Enum):
|
||||
"""Where the location data came from in a spot."""
|
||||
|
||||
SPOT = "SPOT"
|
||||
SIG_REF_LOOKUP = "SIG REF LOOKUP"
|
||||
GRID = "GRID"
|
||||
HOME_QTH = "HOME QTH"
|
||||
DXCC = "DXCC"
|
||||
|
||||
|
||||
class LocationSourceForCallsign(str, Enum):
|
||||
"""Where the location data came from in a callsign data object."""
|
||||
|
||||
HOME_QTH = "HOME QTH"
|
||||
DXCC = "DXCC"
|
||||
|
||||
|
||||
class SIGRefType(str, Enum):
|
||||
"""Type of a Special Interest Group reference."""
|
||||
|
||||
PARK = "PARK"
|
||||
SUMMIT = "SUMMIT"
|
||||
CASTLE = "CASTLE"
|
||||
LIGHTHOUSE = "LIGHTHOUSE"
|
||||
ISLAND = "ISLAND"
|
||||
BUNKER = "BUNKER"
|
||||
MILL = "MILL"
|
||||
SILO = "SILO"
|
||||
BEACH = "BEACH"
|
||||
LAKE = "LAKE"
|
||||
TOWER = "TOWER"
|
||||
WATERWAY = "WATERWAY"
|
||||
TOWN = "TOWN"
|
||||
BUILDING = "BUILDING"
|
||||
REGION = "REGION"
|
||||
GRID = "GRID"
|
||||
TOILET = "TOILET"
|
||||
|
||||
|
||||
class AlertType(str, Enum):
|
||||
"""Type of an alert."""
|
||||
|
||||
XOTA = "XOTA"
|
||||
SATELLITE = "SATELLITE"
|
||||
DXPEDITION = "DXPEDITION"
|
||||
CONTEST = "CONTEST"
|
||||
|
||||
|
||||
class SIGType(str, Enum):
|
||||
"""Type of a Special Interest Group. Used to group them in the web UI."""
|
||||
|
||||
WORLDWIDE = "WORLDWIDE"
|
||||
REGIONAL = "REGIONAL"
|
||||
EVENT = "EVENT"
|
||||
|
||||
|
||||
# Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode
|
||||
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots
|
||||
# that match a key in this table will be converted to the corresponding value, so only the modes above will actually be
|
||||
# present in the spots.
|
||||
MODE_ALIASES = {
|
||||
"USB": "SSB",
|
||||
"LSB": "SSB",
|
||||
"DIGITALVOICE": "DV",
|
||||
"DIGITALVOI": "DV",
|
||||
"DMR": "DV",
|
||||
"DSTAR": "DV",
|
||||
"C4FM": "DV",
|
||||
"FUSION": "DV",
|
||||
"M17": "DV",
|
||||
"RTT": "RTTY",
|
||||
"BPSK": "PSK",
|
||||
"PSK31": "PSK",
|
||||
"BPSK31": "PSK",
|
||||
"MFSK": "FSK",
|
||||
"MFSK32": "FSK",
|
||||
"DIGI": "DATA",
|
||||
"DIGITAL": "DATA",
|
||||
}
|
||||
@@ -7,6 +7,8 @@ from shapely.geometry import Point, Polygon
|
||||
|
||||
from core.data_store import DATA_STORE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRANSFORMER_OS_GRID_TO_WGS84 = Transformer.from_crs("EPSG:27700", "EPSG:4326")
|
||||
TRANSFORMER_IRISH_GRID_TO_WGS84 = Transformer.from_crs("EPSG:29903", "EPSG:4326")
|
||||
TRANSFORMER_CI_UTM_GRID_TO_WGS84 = Transformer.from_crs("+proj=utm +zone=30 +ellps=WGS84", "EPSG:4326")
|
||||
@@ -71,7 +73,7 @@ def lat_lon_for_grid_sw_corner(grid):
|
||||
"""Convert a Maidenhead grid reference of arbitrary precision to the lat/long of the southwest corner of the square.
|
||||
Returns None if the grid format is invalid."""
|
||||
|
||||
lat, lon, lat_cell_size, lon_cell_size = lat_lon_for_grid_sw_corner_plus_size(grid)
|
||||
lat, lon, _lat_cell_size, _lon_cell_size = lat_lon_for_grid_sw_corner_plus_size(grid)
|
||||
if lat is not None and lon is not None:
|
||||
return [lat, lon]
|
||||
else:
|
||||
@@ -113,8 +115,8 @@ def lat_lon_for_grid_sw_corner_plus_size(grid):
|
||||
while block * 2 < length:
|
||||
if block % 2 == 0:
|
||||
# Letters in this block
|
||||
lon_cell_no = ord(grid[block * 2]) - ord('A')
|
||||
lat_cell_no = ord(grid[block * 2 + 1]) - ord('A')
|
||||
lon_cell_no = ord(grid[block * 2]) - ord("A")
|
||||
lat_cell_no = ord(grid[block * 2 + 1]) - ord("A")
|
||||
# Bail if the values aren't in range. Allowed values are A-R (0-17) for the first letter block, or
|
||||
# A-X (0-23) thereafter.
|
||||
max_cell_no = 17 if block == 0 else 23
|
||||
@@ -154,7 +156,7 @@ def lat_lon_for_grid_sw_corner_plus_size(grid):
|
||||
lat -= 90.0
|
||||
|
||||
# Return None values on maths errors
|
||||
if any(x != x for x in [lat, lon, lat_cell_size, lon_cell_size]): # NaN check
|
||||
if any(x != x for x in [lat, lon, lat_cell_size, lon_cell_size]):
|
||||
return None, None, None, None
|
||||
|
||||
return lat, lon, lat_cell_size, lon_cell_size
|
||||
@@ -172,7 +174,7 @@ def wab_wai_square_to_lat_lon(ref):
|
||||
elif re.match(r"^W[AV][0-9]{2}$", ref):
|
||||
return utm_grid_square_to_lat_lon(ref)
|
||||
else:
|
||||
logging.warning("Invalid WAB/WAI square: " + ref)
|
||||
logger.warning(f"Invalid WAB/WAI square: {ref}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import time
|
||||
import diskcache
|
||||
from cachetools import TTLCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LiveDataCache:
|
||||
"""Cache for spots and alerts. Uses the fast in-memory TTLCache for normal data I/O, including the TTL to enforce
|
||||
@@ -20,6 +22,8 @@ class LiveDataCache:
|
||||
self._listeners_lock = threading.Lock()
|
||||
self._snapshot_dir = snapshot_dir
|
||||
self._disk_cache = diskcache.Cache(str(snapshot_dir))
|
||||
self._stop_event = threading.Event()
|
||||
self._snapshot_thread = None
|
||||
self._load_snapshot()
|
||||
self._start_periodic_snapshot(snapshot_interval_sec)
|
||||
|
||||
@@ -34,8 +38,7 @@ class LiveDataCache:
|
||||
try:
|
||||
callback(value)
|
||||
except Exception:
|
||||
logging.error("Listener raised an exception for key %s", key, exc_info=True)
|
||||
|
||||
logger.exception(f"Listener raised an exception for key {key}")
|
||||
|
||||
def get(self, key, default=None):
|
||||
with self._lock:
|
||||
@@ -70,11 +73,16 @@ class LiveDataCache:
|
||||
data = [(k, v, time.time()) for k, v in self._cache.items()]
|
||||
try:
|
||||
self._disk_cache.set("snapshot", data)
|
||||
except Exception as e:
|
||||
logging.error("Failed to write snapshot to %s", self._snapshot_dir, e, exc_info=True)
|
||||
except Exception:
|
||||
logger.exception(f"Failed to write snapshot to {self._snapshot_dir}")
|
||||
|
||||
def _load_snapshot(self):
|
||||
data = self._disk_cache.get("snapshot")
|
||||
try:
|
||||
data = self._disk_cache.get("snapshot")
|
||||
except Exception:
|
||||
logger.warning(f"Failed to load snapshot from {self._snapshot_dir}, clearing it.")
|
||||
self._disk_cache.clear()
|
||||
return
|
||||
if not data:
|
||||
return
|
||||
|
||||
@@ -84,17 +92,23 @@ class LiveDataCache:
|
||||
# Only restore entries that would still be within TTL
|
||||
if now - saved_at < self._ttl:
|
||||
self._cache[key] = value
|
||||
logging.info("Loaded snapshot from %s", self._snapshot_dir)
|
||||
logger.info(f"Loaded snapshot from {self._snapshot_dir}")
|
||||
|
||||
def _start_periodic_snapshot(self, interval):
|
||||
def loop():
|
||||
while True:
|
||||
time.sleep(interval)
|
||||
while not self._stop_event.wait(timeout=interval):
|
||||
self.save_snapshot()
|
||||
|
||||
t = threading.Thread(target=loop, name=f"LiveDataCache-Snapshot-{self._snapshot_dir}")
|
||||
t.start()
|
||||
self._snapshot_thread = threading.Thread(
|
||||
target=loop, name=f"LiveDataCache-Snapshot-{self._snapshot_dir}", daemon=True
|
||||
)
|
||||
self._snapshot_thread.start()
|
||||
|
||||
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._disk_cache.close()
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
from prometheus_client import CollectorRegistry, generate_latest, Counter, disable_created_metrics, Gauge
|
||||
from prometheus_client import (
|
||||
CollectorRegistry,
|
||||
Counter,
|
||||
Gauge,
|
||||
disable_created_metrics,
|
||||
generate_latest,
|
||||
)
|
||||
|
||||
disable_created_metrics()
|
||||
# Prometheus metrics registry
|
||||
@@ -9,25 +15,13 @@ page_requests_counter = Counter(
|
||||
"Total number of page requests received",
|
||||
registry=registry,
|
||||
)
|
||||
api_requests_counter = Counter(
|
||||
"spothole_api_requests",
|
||||
"Total number of API requests received",
|
||||
registry=registry
|
||||
)
|
||||
spots_gauge = Gauge(
|
||||
"spothole_spots",
|
||||
"Number of spots currently in the software",
|
||||
registry=registry
|
||||
)
|
||||
alerts_gauge = Gauge(
|
||||
"spothole_alerts",
|
||||
"Number of alerts currently in the software",
|
||||
registry=registry
|
||||
)
|
||||
api_requests_counter = Counter("spothole_api_requests", "Total number of API requests received", registry=registry)
|
||||
spots_gauge = Gauge("spothole_spots", "Number of spots currently in the software", registry=registry)
|
||||
alerts_gauge = Gauge("spothole_alerts", "Number of alerts currently in the software", registry=registry)
|
||||
memory_use_gauge = Gauge(
|
||||
"spothole_memory_usage_bytes",
|
||||
"Current memory usage of the software in bytes",
|
||||
registry=registry
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,53 +1,81 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from pyhamtools.locator import locator_to_latlong, latlong_to_locator
|
||||
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
|
||||
|
||||
from core.constants import SIGS
|
||||
from core.data_store import DATA_STORE
|
||||
from core.enums import SIGRefType
|
||||
from core.geo_utils import wab_wai_square_to_lat_lon
|
||||
from data.sig_ref import SIGRef
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_sig_ref_info(sig, ref_id):
|
||||
|
||||
def get_sig_ref_info(sig_name, ref_id):
|
||||
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig name and
|
||||
a reference ID (both strings) and returns a SigRef object populated with as much data as we can find. This makes
|
||||
use of SIG ref data in the data store, live lookups from the web, or just automatic calculation depending on which
|
||||
SIG we are getting data for."""
|
||||
|
||||
if sig is None or sig == "" or ref_id is None or ref_id == "":
|
||||
logging.debug("Failed to look up sig_ref info, sig or ref were not set.")
|
||||
if sig_name is None or sig_name == "" or ref_id is None or ref_id == "":
|
||||
logger.debug("Failed to look up sig_ref info, sig or ref were not set.")
|
||||
return None
|
||||
|
||||
sig_ref = SIGRef(sig=sig, id=ref_id)
|
||||
# Sometimes we allow spaces instead of dashes in references due to common usage that way, but official reference
|
||||
# lists never do, so convert them here.
|
||||
ref_id = ref_id.replace(" ", "-")
|
||||
|
||||
# Prepare the object to be returned
|
||||
sig_ref = SIGRef(sig=sig_name, id=ref_id)
|
||||
|
||||
# We can always get the reference type and the icon from the SIG itself
|
||||
for sig in SIGS:
|
||||
if sig.name.upper() == sig_name.upper():
|
||||
sig_ref.ref_type = sig.ref_type
|
||||
sig_ref.icon = sig.icon
|
||||
|
||||
try:
|
||||
### FUDGES ###
|
||||
#
|
||||
# DME fudge. Our database has leading zeros padding to 5 digits which is the expected format, but not all
|
||||
# activators add leading zeros.
|
||||
if sig.upper() == "DME":
|
||||
ref_id = ref_id.zfill(5)
|
||||
# activators add leading zeros. We also need to normalise "DME 01234" to "DME-01234" to match what's in our
|
||||
# database.
|
||||
if sig_name.upper() == "DME":
|
||||
match = re.match(r"DME[\- ](\d{3,5})", ref_id, re.IGNORECASE)
|
||||
if match:
|
||||
number = match.group(1)
|
||||
ref_id = f"DME-{number.zfill(5)}"
|
||||
|
||||
# If the SIG is HEMA, we have no current lookup for this so just skip the lookup here.
|
||||
if sig.upper() == "HEMA":
|
||||
# DTMBA spotters sometimes include spaces and dashes, our regex allows them but they must be removed here so we
|
||||
# can look up against the official list which doesn't have them
|
||||
if sig_name.upper() == "DTMBA":
|
||||
ref_id = ref_id.replace("-", "").replace(" ", "")
|
||||
|
||||
### NO DATA SIGS ###
|
||||
#
|
||||
# If the SIG is HEMA or BIWOTA, we have no way to either generate useful data or look it up on a reference list,
|
||||
# so just skip the lookup here.
|
||||
if sig_name.upper() == "HEMA" or sig_name.upper() == "BIWOTA":
|
||||
return sig_ref
|
||||
|
||||
### PROGRAMMATIC DATA GENERATION INSTEAD OF LOOKUPS ###
|
||||
#
|
||||
# If the SIG is Tiles, WAB, WAI or BOTA (Beaches), we don't have anything to look up from the data store, we can
|
||||
# calculate all the information we are going to get directly.
|
||||
if sig.upper() == "TILES":
|
||||
if sig_name.upper() == "TILES":
|
||||
# Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same
|
||||
if not sig_ref.name:
|
||||
sig_ref.name = sig_ref.id
|
||||
if not sig_ref.grid:
|
||||
sig_ref.grid = sig_ref.id
|
||||
if sig_ref.grid and not sig_ref.latitude:
|
||||
if sig_ref.grid and (not sig_ref.latitude or not sig_ref.longitude):
|
||||
ll = locator_to_latlong(str(sig_ref.grid))
|
||||
sig_ref.latitude = ll[0]
|
||||
sig_ref.longitude = ll[1]
|
||||
return sig_ref
|
||||
|
||||
elif sig.upper() == "WAB" or sig.upper() == "WAI":
|
||||
elif sig_name.upper() == "WAB" or sig_name.upper() == "WAI":
|
||||
ll = wab_wai_square_to_lat_lon(ref_id)
|
||||
if ll:
|
||||
sig_ref.name = ref_id
|
||||
@@ -55,33 +83,57 @@ def get_sig_ref_info(sig, ref_id):
|
||||
sig_ref.grid = latlong_to_locator(ll[0], ll[1], 6)
|
||||
sig_ref.latitude = ll[0]
|
||||
sig_ref.longitude = ll[1]
|
||||
except:
|
||||
logging.warning("Invalid lat/lon received for WAB/WAI reference")
|
||||
except Exception:
|
||||
logger.warning("Invalid lat/lon received for WAB/WAI reference")
|
||||
return sig_ref
|
||||
|
||||
elif sig.upper() == "BOTA":
|
||||
elif sig_name.upper() == "BOTA":
|
||||
# For BOTA all we can ever generate is the URL, there is no data file or lookup for lat/longs
|
||||
if not sig_ref.name:
|
||||
sig_ref.name = sig_ref.id
|
||||
sig_ref.url = "https://www.beachesontheair.com/beaches/" + sig_ref.name.lower().replace(" ", "-")
|
||||
if sig_ref.name:
|
||||
sig_ref.url = f"https://www.beachesontheair.com/beaches/{sig_ref.name.lower().replace(' ', '-')}"
|
||||
return sig_ref
|
||||
|
||||
elif sig_name.upper() == "GMA Islands":
|
||||
# GMA Islands is a bit of a mess of GMA and IOTA references. Try looking them both up and see what returns
|
||||
# the best result.
|
||||
iota_lookup = get_sig_ref_info("IOTA", ref_id)
|
||||
gma_lookup = get_sig_ref_info("GMA", ref_id)
|
||||
for key, value in iota_lookup.__dict__.items():
|
||||
if value is not None and sig_ref.__dict__.get(key) is None:
|
||||
sig_ref.__dict__[key] = value
|
||||
for key, value in gma_lookup.__dict__.items():
|
||||
if value is not None and sig_ref.__dict__.get(key) is None:
|
||||
sig_ref.__dict__[key] = value
|
||||
sig_ref.ref_type = SIGRefType.ISLAND
|
||||
return sig_ref
|
||||
|
||||
### ACTUAL LOOKUP ###
|
||||
#
|
||||
# OK, this is something we have to look up. Now check to see if our data store contains reference data and use
|
||||
# that.
|
||||
key = sig + ":" + ref_id
|
||||
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
|
||||
if lookup_data:
|
||||
return lookup_data
|
||||
# OK, this is something we have to look up. Now check to see if our data store contains reference data and if
|
||||
# so, copy the data into the sig_ref object
|
||||
key = f"{sig_name}:{ref_id}"
|
||||
try:
|
||||
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
|
||||
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:
|
||||
# Maybe a super new reference we don't know about yet, but more likely a typo or a test reference,
|
||||
# just silently ignore it.
|
||||
logging.debug("%s database did not contain data for ref %s", sig, ref_id)
|
||||
else:
|
||||
# Maybe a super new reference we don't know about yet, but more likely a typo or a test reference,
|
||||
# just silently ignore it.
|
||||
logger.debug(f"{sig_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:
|
||||
logging.error("Exception when looking up sig_ref info for " + sig + " ref " + ref_id, exc_info=True)
|
||||
logger.exception(f"Exception when looking up sig_ref info for {sig_name} ref {ref_id}")
|
||||
return sig_ref
|
||||
|
||||
|
||||
|
||||
@@ -21,4 +21,4 @@ def get_sig_name_from_comment_name(sig):
|
||||
|
||||
|
||||
# Regex matching any SIG's "comment name", i.e. how it may be referred to in spot comments
|
||||
ANY_SIG_REGEX = r"(" + r"|".join(n for s in SIGS for n in s.comment_names) + r")"
|
||||
ANY_SIG_REGEX = rf"({'|'.join(n for s in SIGS for n in s.comment_names)})"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import diskcache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SingleObjectDataCache:
|
||||
"""Cache for status and solar conditions. This uses DiskCache, but unlike the standard DiskCache users like SIG and
|
||||
callsign lookup handlers, status and solar conditions are persisted as a single object. If we just load the object
|
||||
from DiskCache and modify it, DiskCache doesn't know that it's been updated and needs re-caching, so we provide a
|
||||
store() method that any functions updating the object can call afterwards."""
|
||||
|
||||
def __init__(self, cache_dir, object_if_empty):
|
||||
"""Initialize a SingleObjectDataCache. Provide the directory to load the cache from and save it to. If the cache
|
||||
is empty, the provided object_if_empty parameter will be used to initialise it."""
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._cache = diskcache.Cache(cache_dir)
|
||||
# This cache stores a single object, doesn't matter what it's called so "object" will do
|
||||
if "object" not in self._cache:
|
||||
self._cache.add("object", object_if_empty)
|
||||
try:
|
||||
self._obj = self._cache.get("object")
|
||||
except Exception:
|
||||
logger.warning(f"Failed to load cache from {cache_dir}, clearing it.")
|
||||
self._cache.clear()
|
||||
self._cache.add("object", object_if_empty)
|
||||
self._obj = object_if_empty
|
||||
|
||||
def get(self):
|
||||
"""Get the data object. This can then be manipulated as necessary across multiple threads. Any function
|
||||
modifying the object must remember to call store() afterwards."""
|
||||
return self._obj
|
||||
|
||||
def store(self):
|
||||
"""Store the updated object in the cache. Any function modifying the object must remember to call this
|
||||
afterwards."""
|
||||
with self._lock:
|
||||
self._cache.set("object", self._obj)
|
||||
|
||||
def close(self):
|
||||
self.store()
|
||||
self._cache.close()
|
||||
@@ -1,16 +1,21 @@
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from threading import Thread, Event
|
||||
from threading import Event, Thread
|
||||
|
||||
import psutil
|
||||
import pytz
|
||||
|
||||
from core.cleanup import CLEANUP_TIMER
|
||||
from core.config import SERVER_OWNER_CALLSIGN
|
||||
from core.constants import SOFTWARE_VERSION
|
||||
from core.data_providers import DATA_PROVIDERS
|
||||
from core.data_store import DATA_STORE
|
||||
from core.prometheus_metrics_handler import memory_use_gauge, spots_gauge, alerts_gauge
|
||||
from server.webserver import WEB_SERVER
|
||||
from core.prometheus_metrics_handler import alerts_gauge, memory_use_gauge, spots_gauge
|
||||
from telnetserver.telnetserver import TELNET_SERVER
|
||||
from webserver.webserver import WEB_SERVER
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StatusReporter:
|
||||
@@ -24,19 +29,24 @@ class StatusReporter:
|
||||
self._stop_event = Event()
|
||||
self._startup_time = datetime.now(pytz.UTC)
|
||||
|
||||
DATA_STORE.status_data["software_version"] = SOFTWARE_VERSION
|
||||
DATA_STORE.status_data["server_owner_callsign"] = SERVER_OWNER_CALLSIGN
|
||||
DATA_STORE.status.get()["software_version"] = SOFTWARE_VERSION
|
||||
DATA_STORE.status.get()["server_owner_callsign"] = SERVER_OWNER_CALLSIGN
|
||||
DATA_STORE.status.store()
|
||||
|
||||
def start(self):
|
||||
"""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()
|
||||
|
||||
def stop(self):
|
||||
"""Stop any threads and prepare for application shutdown"""
|
||||
|
||||
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):
|
||||
"""Thread entry point: report immediately on startup, then on each interval until stopped"""
|
||||
@@ -49,57 +59,98 @@ class StatusReporter:
|
||||
def _report(self):
|
||||
"""Write status information"""
|
||||
|
||||
DATA_STORE.status_data["uptime"] = (datetime.now(pytz.UTC) - self._startup_time).total_seconds()
|
||||
DATA_STORE.status_data["mem_use_mb"] = round(psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024), 3)
|
||||
DATA_STORE.status_data["num_spots"] = len(DATA_STORE.spots.values())
|
||||
DATA_STORE.status_data["num_alerts"] = len(DATA_STORE.alerts.values())
|
||||
DATA_STORE.status_data["spot_providers"] = list(
|
||||
map(lambda p: {"name": p.name, "enabled": p.enabled,
|
||||
"enabled_by_default_in_web_ui": p.enabled_by_default_in_web_ui, "status": p.status,
|
||||
"last_updated": p.last_update_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
|
||||
"last_spot": p.last_spot_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if p.last_spot_time.year > 2000 else 0},
|
||||
DATA_PROVIDERS.spot_providers))
|
||||
DATA_STORE.status_data["alert_providers"] = list(
|
||||
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||
"last_updated": p.last_update_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
||||
DATA_PROVIDERS.alert_providers))
|
||||
DATA_STORE.status_data["solar_condition_providers"] = list(
|
||||
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||
"last_updated": p.last_update_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
||||
DATA_PROVIDERS.solar_condition_providers))
|
||||
DATA_STORE.status_data["static_data_providers"] = list(
|
||||
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||
"last_updated": p.last_update_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
||||
DATA_PROVIDERS.static_data_providers))
|
||||
DATA_STORE.status_data["sig_ref_data_providers"] = list(
|
||||
map(lambda p: {"sig_name": p.sig_name, "enabled": p.enabled, "status": p.status,
|
||||
"last_updated": p.last_update_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
|
||||
"reference_count": p.reference_count},
|
||||
DATA_PROVIDERS.sig_ref_data_providers))
|
||||
DATA_STORE.status_data["callsign_data_providers"] = list(
|
||||
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||
"last_updated": p.last_update_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
|
||||
"lookup_count": p.lookup_count},
|
||||
DATA_PROVIDERS.callsign_data_providers))
|
||||
DATA_STORE.status_data["webserver"] = {"status": WEB_SERVER.web_server_metrics["status"],
|
||||
"last_api_access": WEB_SERVER.web_server_metrics[
|
||||
"last_api_access_time"].replace(
|
||||
tzinfo=pytz.UTC).timestamp() if WEB_SERVER.web_server_metrics[
|
||||
"last_api_access_time"] else 0,
|
||||
"api_access_count": WEB_SERVER.web_server_metrics["api_access_counter"],
|
||||
"last_page_access": WEB_SERVER.web_server_metrics[
|
||||
"last_page_access_time"].replace(
|
||||
tzinfo=pytz.UTC).timestamp() if WEB_SERVER.web_server_metrics[
|
||||
"last_page_access_time"] else 0,
|
||||
"page_access_count": WEB_SERVER.web_server_metrics[
|
||||
"page_access_counter"]}
|
||||
DATA_STORE.status.get()["uptime"] = (datetime.now(pytz.UTC) - self._startup_time).total_seconds()
|
||||
DATA_STORE.status.get()["mem_use_mb"] = round(psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024), 3)
|
||||
DATA_STORE.status.get()["num_spots"] = len(DATA_STORE.spots.values())
|
||||
DATA_STORE.status.get()["num_alerts"] = len(DATA_STORE.alerts.values())
|
||||
DATA_STORE.status.get()["spot_providers"] = [
|
||||
{
|
||||
"name": p.name,
|
||||
"enabled": p.enabled,
|
||||
"enabled_by_default_in_web_ui": p.enabled_by_default_in_web_ui,
|
||||
"status": p.status,
|
||||
"last_updated": p.last_update_time.replace(tzinfo=pytz.UTC).timestamp()
|
||||
if p.last_update_time.year > 2000
|
||||
else 0,
|
||||
"last_spot": p.last_spot_time.replace(tzinfo=pytz.UTC).timestamp()
|
||||
if p.last_spot_time.year > 2000
|
||||
else 0,
|
||||
}
|
||||
for p in DATA_PROVIDERS.spot_providers
|
||||
]
|
||||
DATA_STORE.status.get()["alert_providers"] = [
|
||||
{
|
||||
"name": p.name,
|
||||
"enabled": p.enabled,
|
||||
"status": p.status,
|
||||
"last_updated": p.last_update_time.replace(tzinfo=pytz.UTC).timestamp()
|
||||
if p.last_update_time.year > 2000
|
||||
else 0,
|
||||
}
|
||||
for p in DATA_PROVIDERS.alert_providers
|
||||
]
|
||||
DATA_STORE.status.get()["solar_condition_providers"] = [
|
||||
{
|
||||
"name": p.name,
|
||||
"enabled": p.enabled,
|
||||
"status": p.status,
|
||||
"last_updated": p.last_update_time.replace(tzinfo=pytz.UTC).timestamp()
|
||||
if p.last_update_time.year > 2000
|
||||
else 0,
|
||||
}
|
||||
for p in DATA_PROVIDERS.solar_condition_providers
|
||||
]
|
||||
DATA_STORE.status.get()["static_data_providers"] = [
|
||||
{
|
||||
"name": p.name,
|
||||
"enabled": p.enabled,
|
||||
"status": p.status,
|
||||
"last_updated": p.last_update_time.replace(tzinfo=pytz.UTC).timestamp()
|
||||
if p.last_update_time.year > 2000
|
||||
else 0,
|
||||
}
|
||||
for p in DATA_PROVIDERS.static_data_providers
|
||||
]
|
||||
DATA_STORE.status.get()["sig_ref_data_providers"] = [
|
||||
{
|
||||
"sig_name": p.sig_name,
|
||||
"enabled": p.enabled,
|
||||
"status": p.status,
|
||||
"last_updated": p.last_update_time.replace(tzinfo=pytz.UTC).timestamp()
|
||||
if p.last_update_time.year > 2000
|
||||
else 0,
|
||||
"reference_count": p.reference_count,
|
||||
}
|
||||
for p in DATA_PROVIDERS.sig_ref_data_providers
|
||||
]
|
||||
DATA_STORE.status.get()["callsign_data_providers"] = [
|
||||
{
|
||||
"name": p.name,
|
||||
"enabled": p.enabled,
|
||||
"status": p.status,
|
||||
"last_updated": p.last_update_time.replace(tzinfo=pytz.UTC).timestamp()
|
||||
if p.last_update_time.year > 2000
|
||||
else 0,
|
||||
"lookup_count": p.lookup_count,
|
||||
}
|
||||
for p in DATA_PROVIDERS.callsign_data_providers
|
||||
]
|
||||
DATA_STORE.status.get()["cleanup"] = {
|
||||
"status": CLEANUP_TIMER.status,
|
||||
"last_ran": CLEANUP_TIMER.last_cleanup_time.replace(tzinfo=pytz.UTC).timestamp()
|
||||
if CLEANUP_TIMER.last_cleanup_time
|
||||
else 0,
|
||||
}
|
||||
DATA_STORE.status.get()["webserver"] = {
|
||||
"status": WEB_SERVER.web_server_metrics.status,
|
||||
"api_requests_per_hour": WEB_SERVER.web_server_metrics.api_requests_per_hour(),
|
||||
"page_requests_per_hour": WEB_SERVER.web_server_metrics.page_requests_per_hour(),
|
||||
"sse_client_count": WEB_SERVER.sse_client_count,
|
||||
}
|
||||
DATA_STORE.status.get()["telnet"] = {
|
||||
"client_count": TELNET_SERVER.client_count,
|
||||
}
|
||||
DATA_STORE.status.store()
|
||||
|
||||
# Update Prometheus metrics
|
||||
memory_use_gauge.set(psutil.Process(os.getpid()).memory_info().rss)
|
||||
|
||||
@@ -14,12 +14,14 @@ class URLDataCache(CachedSession):
|
||||
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."""
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __init__(self, name):
|
||||
super().__init__(CACHE_DIR + "urls/" + name, expire_after=timedelta(days=1),
|
||||
allowable_codes=(200, 400, 401, 403, 404))
|
||||
super().__init__(
|
||||
f"{CACHE_DIR}urls/{name}",
|
||||
expire_after=timedelta(days=1),
|
||||
allowable_codes=(200, 400, 401, 403, 404),
|
||||
)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
with self._lock:
|
||||
return super().get(*args, **kwargs)
|
||||
return super().get(*args, **kwargs)
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
import simplejson
|
||||
from pyhamtools.frequency import freq_to_band
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.constants import UNKNOWN_BAND, BANDS, CW_MODES, PHONE_MODES, DATA_MODES, MODE_ALIASES, ALL_MODES
|
||||
from core.constants import BANDS, UNKNOWN_BAND
|
||||
from core.data_store import DATA_STORE
|
||||
from data.callsign import Callsign
|
||||
from core.enums import MODE_ALIASES, Continent, Mode, ModeType
|
||||
from data.callsign import Callsign, LocationSourceForCallsign
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def safe_json_dumps(obj):
|
||||
@@ -16,30 +20,42 @@ def safe_json_dumps(obj):
|
||||
return simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, default=lambda o: o.__dict__)
|
||||
|
||||
|
||||
def infer_mode_from_comment(comment):
|
||||
def infer_mode_from_comment(comment: str) -> Mode | None:
|
||||
"""Infer a mode from the comment"""
|
||||
|
||||
for mode in ALL_MODES:
|
||||
if mode in comment.upper():
|
||||
if not comment:
|
||||
return None
|
||||
|
||||
for mode in Mode:
|
||||
if re.search(r"(^|\W)" + mode + r"($|\W)", comment, re.IGNORECASE):
|
||||
return mode
|
||||
for mode in MODE_ALIASES.keys():
|
||||
if mode in comment.upper():
|
||||
return MODE_ALIASES[mode]
|
||||
for alias in MODE_ALIASES:
|
||||
if re.search(r"(^|\W)" + alias + r"($|\W)", comment, re.IGNORECASE):
|
||||
return Mode(MODE_ALIASES[alias])
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def infer_mode_type_from_mode(mode):
|
||||
"""Infer a "mode family" from a mode."""
|
||||
def infer_mode_type_from_mode(mode: str) -> ModeType | None:
|
||||
"""Infer a "mode family" from a mode ."""
|
||||
|
||||
if mode.upper() in CW_MODES:
|
||||
return "CW"
|
||||
elif mode.upper() in PHONE_MODES:
|
||||
return "PHONE"
|
||||
elif mode.upper() in DATA_MODES:
|
||||
return "DATA"
|
||||
else:
|
||||
if mode.upper() != "OTHER":
|
||||
logging.warning("Found an unrecognised mode: " + mode + ". Developer should categorise this.")
|
||||
if not mode:
|
||||
return None
|
||||
|
||||
if mode in MODE_ALIASES:
|
||||
mode = MODE_ALIASES[mode]
|
||||
|
||||
try:
|
||||
mode = Mode(mode.upper())
|
||||
if mode.is_cw:
|
||||
return ModeType.CW
|
||||
if mode.is_phone:
|
||||
return ModeType.PHONE
|
||||
return ModeType.DATA
|
||||
|
||||
except ValueError:
|
||||
if mode.upper() != "OTHER" and mode != "?":
|
||||
logger.warning(f"Found an unrecognised mode: {mode}. Developer should categorise this.")
|
||||
return None
|
||||
|
||||
|
||||
@@ -60,16 +76,39 @@ def infer_mode_from_frequency(freq):
|
||||
mode = freq_to_band(khz)["mode"]
|
||||
# Some additional common digimode ranges in addition to what the 3rd-party freq_to_band function returns.
|
||||
# This is mostly here just because freq_to_band is very specific about things like FT8 frequencies, and e.g.
|
||||
# a spot at 7074.5 kHz will be indicated as LSB, even though it's clearly in the FT8 range. Future updates
|
||||
# a spot at 7074.5 kHz will be indicated as SSB, even though it's clearly in the FT8 range. Future updates
|
||||
# might include other common digimode centres of activity here, but this achieves the main goal of keeping
|
||||
# large numbers of clearly-FT* spots off the list of people filtering out digimodes.
|
||||
if (7074 <= khz < 7077) or (10136 <= khz < 10139) or (14074 <= khz < 14077) or (18100 <= khz < 18103) or (
|
||||
21074 <= khz < 21077) or (24915 <= khz < 24918) or (28074 <= khz < 28077):
|
||||
if (
|
||||
(7074 <= khz < 7077)
|
||||
or (10136 <= khz < 10139)
|
||||
or (14074 <= khz < 14077)
|
||||
or (18100 <= khz < 18103)
|
||||
or (21074 <= khz < 21077)
|
||||
or (24915 <= khz < 24918)
|
||||
or (28074 <= khz < 28077)
|
||||
):
|
||||
mode = "FT8"
|
||||
if (7047.5 <= khz < 7050.5) or (10140 <= khz < 10143) or (14080 <= khz < 14083) or (
|
||||
18104 <= khz < 18107) or (21140 <= khz < 21143) or (24919 <= khz < 24922) or (28180 <= khz < 28183):
|
||||
if (
|
||||
(7047.5 <= khz < 7050.5)
|
||||
or (10140 <= khz < 10143)
|
||||
or (14080 <= khz < 14083)
|
||||
or (18104 <= khz < 18107)
|
||||
or (21140 <= khz < 21143)
|
||||
or (24919 <= khz < 24922)
|
||||
or (28180 <= khz < 28183)
|
||||
):
|
||||
mode = "FT4"
|
||||
return mode
|
||||
|
||||
if not mode:
|
||||
return None
|
||||
|
||||
mode = mode.upper()
|
||||
if mode in MODE_ALIASES:
|
||||
mode = MODE_ALIASES[mode]
|
||||
|
||||
return Mode(mode)
|
||||
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@@ -77,7 +116,7 @@ def infer_mode_from_frequency(freq):
|
||||
def get_flag_for_dxcc(dxcc):
|
||||
"""Get an emoji flag for a given DXCC entity ID"""
|
||||
|
||||
dxcc_data = DATA_STORE.dxcc_data[dxcc] if dxcc in DATA_STORE.dxcc_data else None
|
||||
dxcc_data = DATA_STORE.dxcc_data.get(dxcc, None)
|
||||
return dxcc_data["flag"] if dxcc_data else None
|
||||
|
||||
|
||||
@@ -89,28 +128,30 @@ def get_callsign_object_from_pyhamtools_callinfo(callsign, callinfo):
|
||||
home_call = callinfo.get_homecall(callsign)
|
||||
data = callinfo.get_all(callsign)
|
||||
|
||||
country = data["country"] if "country" in data else None
|
||||
dxcc_id = data["adif"] if "adif" in data else None
|
||||
continent = data["continent"] if "continent" in data else None
|
||||
cq_zone = data["cqz"] if "cqz" in data else None
|
||||
itu_zone = data["ituz"] if "ituz" in data else None
|
||||
country = data.get("country", None)
|
||||
dxcc_id = data.get("adif", None)
|
||||
continent = Continent(data["continent"]) if "continent" in data else None
|
||||
cq_zone = data.get("cqz", None)
|
||||
itu_zone = data.get("ituz", None)
|
||||
lat = float(data["latitude"]) if "latitude" in data else None
|
||||
lon = float(data["longitude"]) if "longitude" in data else None
|
||||
grid = None
|
||||
if lat and lon:
|
||||
grid = latlong_to_locator(lat, lon)
|
||||
|
||||
return Callsign(call=callsign,
|
||||
home_call=home_call,
|
||||
country=country,
|
||||
dxcc_id=dxcc_id,
|
||||
continent=continent,
|
||||
cq_zone=cq_zone,
|
||||
itu_zone=itu_zone,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
grid=grid,
|
||||
location_source="DXCC")
|
||||
return Callsign(
|
||||
call=callsign,
|
||||
home_call=home_call,
|
||||
country=country,
|
||||
dxcc_id=dxcc_id,
|
||||
continent=continent,
|
||||
cq_zone=cq_zone,
|
||||
itu_zone=itu_zone,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
grid=grid,
|
||||
location_source=LocationSourceForCallsign.DXCC,
|
||||
)
|
||||
|
||||
except (KeyError, ValueError):
|
||||
# Unknown callsign, can't look anything up, return a Callsign object with basic data so that gets cached
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytz
|
||||
|
||||
from core.call_lookup_helper import get_call_info
|
||||
from core.enums import AlertType, Continent
|
||||
from core.sig_lookup_helper import populate_missing_sig_ref_info
|
||||
from core.utils import get_flag_for_dxcc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Alert:
|
||||
@@ -18,6 +20,9 @@ class Alert:
|
||||
|
||||
# Unique identifier for the alert
|
||||
id: str | None = None
|
||||
|
||||
# DX (alerting) operator info
|
||||
|
||||
# Callsigns of the operators that has been alerted
|
||||
dx_calls: list | None = None
|
||||
# Names of the operators that has been alerted
|
||||
@@ -27,13 +32,16 @@ class Alert:
|
||||
# Country flag of the DX operator
|
||||
dx_flag: str | None = None
|
||||
# Continent of the DX operator
|
||||
dx_continent: str | None = None
|
||||
dx_continent: Continent | None = None
|
||||
# DXCC ID of the DX operator
|
||||
dx_dxcc_id: int | None = None
|
||||
# CQ zone of the DX operator
|
||||
dx_cq_zone: int | None = None
|
||||
# ITU zone of the DX operator
|
||||
dx_itu_zone: int | None = None
|
||||
|
||||
# General alert info
|
||||
|
||||
# Intended frequencies & modes of operation. Essentially just a different kind of comment field.
|
||||
freqs_modes: str | None = None
|
||||
# Start time of the activation, UTC seconds since UNIX epoch
|
||||
@@ -44,25 +52,41 @@ class Alert:
|
||||
end_time: float | None = None
|
||||
# End time of the activation of the alert, ISO 8601
|
||||
end_time_iso: str | None = None
|
||||
# Comment made by the alerter, if any
|
||||
comment: str | None = None
|
||||
# The type of alert this is: xOTA, DXpedition, or Contest.
|
||||
alert_type: AlertType | None = None
|
||||
# A URL link to more information, if any
|
||||
url: str | None = None
|
||||
|
||||
# Special Interest Group info
|
||||
|
||||
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
|
||||
sig: str | None = None
|
||||
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
||||
sig_refs: list = 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"
|
||||
# call to our API to receive all data that is new to us, even if by a quirk of the API it might be older than the
|
||||
# list time the client polled the API.
|
||||
received_time: float | None = None
|
||||
# Time that this software received the alert, ISO 8601
|
||||
received_time_iso: str | None = None
|
||||
# Comment made by the alerter, if any
|
||||
comment: str | None = None
|
||||
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
|
||||
sig: str | None = None
|
||||
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
||||
sig_refs: list | None = None
|
||||
# Whether this alert is for a DXpedition, as opposed to e.g. an xOTA programme.
|
||||
is_dxpedition: bool = False
|
||||
|
||||
# Source info
|
||||
|
||||
# Where we got the alert from, e.g. "POTA", "SOTA"...
|
||||
source: str | None = None
|
||||
# The ID the source gave it, if any.
|
||||
source_id: str | None = None
|
||||
|
||||
# Display info
|
||||
|
||||
# Icon to use when displaying this alert in the web UI. Chosen from the Font Awesome set.
|
||||
icon: str | None = None
|
||||
|
||||
def infer_missing(self, credentials=None):
|
||||
"""Infer missing parameters where possible"""
|
||||
|
||||
@@ -90,8 +114,8 @@ class Alert:
|
||||
call_info = get_call_info(self.dx_calls[0], credentials)
|
||||
if self.dx_calls and self.dx_calls[0] and not self.dx_country:
|
||||
self.dx_country = call_info.country
|
||||
if self.dx_calls and self.dx_calls[0] and not self.dx_continent:
|
||||
self.dx_continent = call_info.continent
|
||||
if self.dx_calls and self.dx_calls[0] and call_info.continent and not self.dx_continent:
|
||||
self.dx_continent = Continent(call_info.continent)
|
||||
if self.dx_calls and self.dx_calls[0] and not self.dx_cq_zone:
|
||||
self.dx_cq_zone = call_info.cq_zone
|
||||
if self.dx_calls and self.dx_calls[0] and not self.dx_itu_zone:
|
||||
@@ -104,35 +128,43 @@ class Alert:
|
||||
# Fetch SIG data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a reference
|
||||
# in its initial call, we use this code to populate the rest of the data. This includes working out grid refs
|
||||
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
|
||||
if self.sig_refs and len(self.sig_refs) > 0:
|
||||
if self.sig_refs:
|
||||
for sig_ref in self.sig_refs:
|
||||
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
|
||||
# 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
|
||||
|
||||
# Always create an ID based on a hash of every parameter *except* received_time. This is used as the index
|
||||
# to a map, which as a byproduct avoids us having multiple duplicate copies of the object that are identical
|
||||
# apart from that they were retrieved from the API at different times. Note that the simple Python hash()
|
||||
# function includes a seed randomly generated at runtime; this is therefore not consistent between runs. But we
|
||||
# use diskcache to store our data between runs, so we use SHA256 which does not include this random element.
|
||||
# The ID is computed before the online lookups below so that it is stable regardless of whether credentials
|
||||
# are provided, allowing the enriched API response to be matched to the stored alert by ID.
|
||||
# Create an ID based on the source and source ID if possible, as these guaranee uniqueness. If there is no
|
||||
# source ID, use a combination of callsign and start time. Excluding things like the comment here allows for
|
||||
# user updates of their alert comments without duplicating in the system.
|
||||
if not self.id:
|
||||
self_copy = copy.deepcopy(self)
|
||||
self_copy.received_time = 0
|
||||
self_copy.received_time_iso = ""
|
||||
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
|
||||
if self.source and self.source_id:
|
||||
self.id = hashlib.sha256(str({"s": self.source, "sid": self.source_id}).encode("utf-8")).hexdigest()
|
||||
else:
|
||||
self.id = hashlib.sha256(
|
||||
str({"s": self.source, "c": self.dx_calls, "t": self.start_time}).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
# DX operator name lookup, using QRZ.com/HamQTH.
|
||||
if self.dx_calls and not self.dx_names:
|
||||
self.dx_names = list(
|
||||
map(lambda c: get_call_info(c, credentials).name, self.dx_calls))
|
||||
self.dx_names = [get_call_info(c, credentials).name for c in self.dx_calls]
|
||||
|
||||
except Exception as e:
|
||||
logging.error("Exception while inferring missing data from spot", e, exc_info=True)
|
||||
# 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:
|
||||
logger.exception("Exception while inferring missing data from spot")
|
||||
|
||||
def to_json(self):
|
||||
"""JSON serialise"""
|
||||
@@ -145,5 +177,8 @@ class Alert:
|
||||
either having an end_time in the past, or if it only has a start_time, then that start time was more than 3 hours
|
||||
ago. If it somehow doesn't have a start_time either, it is considered to be expired."""
|
||||
|
||||
return not self.start_time or (self.end_time and self.end_time < datetime.now(pytz.UTC).timestamp()) or (
|
||||
not self.end_time and self.start_time < (datetime.now(pytz.UTC) - timedelta(hours=3)).timestamp())
|
||||
return (
|
||||
not self.start_time
|
||||
or (self.end_time and self.end_time < datetime.now(pytz.UTC).timestamp())
|
||||
or (not self.end_time and self.start_time < (datetime.now(pytz.UTC) - timedelta(hours=3)).timestamp())
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from core.enums import Continent, LocationSourceForCallsign
|
||||
|
||||
|
||||
@dataclass
|
||||
class Callsign:
|
||||
@@ -13,35 +15,45 @@ class Callsign:
|
||||
# "Home" call, i.e. with any prefixes and suffixes stripped off
|
||||
home_call: str | None = None
|
||||
# Operator name
|
||||
name : str | None = None
|
||||
name: str | None = None
|
||||
# QTH (location), free text
|
||||
qth : str | None = None
|
||||
qth: str | None = None
|
||||
# Maidenhead grid locator. Depending on the source of lookup this could be a home location from QRZ/HamQTH or just
|
||||
# the centre of the country they're operating in if no other data is available.
|
||||
grid : str | None = None
|
||||
grid: str | None = None
|
||||
# Latitude. Depending on the source of lookup this could be a home location from QRZ/HamQTH or just
|
||||
# the centre of the country they're operating in if no other data is available.
|
||||
latitude : float | None = None
|
||||
latitude: float | None = None
|
||||
# Longitude. Depending on the source of lookup this could be a home location from QRZ/HamQTH or just
|
||||
# the centre of the country they're operating in if no other data is available.
|
||||
longitude : float | None = None
|
||||
longitude: float | None = None
|
||||
# Country in which the callsign indicates they are operating
|
||||
country: str | None = None
|
||||
# Continent in which the callsign indicates they are operating
|
||||
continent: str | None = None
|
||||
continent: Continent | None = None
|
||||
# DXCC ID in which the callsign indicates they are operating
|
||||
dxcc_id: int | None = None
|
||||
# CQ zone in which the callsign indicates they are operating
|
||||
cq_zone: int | None = None
|
||||
# ITU zone in which the callsign indicates they are operating
|
||||
itu_zone: int | None = None
|
||||
# Location source. This can be "HOME QTH" or "DXCC" depending on which provider gave us a location
|
||||
location_source: str | None = None
|
||||
# Location source
|
||||
location_source: LocationSourceForCallsign | None = None
|
||||
|
||||
def fully_populated(self):
|
||||
"""Utility method to indicate that the callsign data is fully populated. Multiple providers can return data for
|
||||
a callsign, and we try them in sequence until we have all the data we can, in which case there's no point
|
||||
querying any other providers."""
|
||||
return self.home_call is not None and self.name is not None and self.qth is not None and self.grid is not None\
|
||||
and self.latitude is not None and self.longitude is not None and self.country is not None and self.continent\
|
||||
is not None and self.dxcc_id is not None and self.cq_zone is not None and self.itu_zone is not None
|
||||
return (
|
||||
self.home_call is not None
|
||||
and self.name is not None
|
||||
and self.qth is not None
|
||||
and self.grid is not None
|
||||
and self.latitude is not None
|
||||
and self.longitude is not None
|
||||
and self.country is not None
|
||||
and self.continent is not None
|
||||
and self.dxcc_id is not None
|
||||
and self.cq_zone is not None
|
||||
and self.itu_zone is not None
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
||||
@dataclass
|
||||
class LookupCredentials:
|
||||
"""Per-request credentials for QRZ.com and HamQTH online callsign lookups."""
|
||||
|
||||
qrz_username: str = ""
|
||||
qrz_password: str = ""
|
||||
qrz_session_key: str = "" # alternative to username/password
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from core.enums import SIGRefType, SIGType
|
||||
|
||||
|
||||
@dataclass
|
||||
class SIG:
|
||||
"""Data class that defines a Special Interest Group. Each contains a name and a longer form description.
|
||||
They also contain comment_names which attempts to separate out the way people might refer to it in
|
||||
cluster comments from how it is referred to in the UI & API. (For example, "TOTA" in cluster spot comments
|
||||
almost always means Towers on the Air, but no single programme is referred to in the UI as "TOTA" as
|
||||
it's ambiguous between Towers, Toilets and Tiles. And while Beaches got the name "BOTA" first, "BOTA" spots
|
||||
are much more likely to be bunkers.) Finally, there is a ref_regex which provides a regular expression to
|
||||
match what references (such as parks and summits) look like for that programme."""
|
||||
They also contain comment_names which attempts to separate out the way people might refer to it in
|
||||
cluster comments from how it is referred to in the UI & API. (For example, "TOTA" in cluster spot comments
|
||||
almost always means Towers on the Air, but no single programme is referred to in the UI as "TOTA" as
|
||||
it's ambiguous between Towers, Toilets and Tiles. And while Beaches got the name "BOTA" first, "BOTA" spots
|
||||
are much more likely to be bunkers.) Finally, there is a ref_regex which provides a regular expression to
|
||||
match what references (such as parks and summits) look like for that programme."""
|
||||
|
||||
# SIG name as used in the UI and API, e.g. "Towers"
|
||||
name: str
|
||||
# Description, e.g. "Towers on the Air"
|
||||
description: str
|
||||
# Type, either Worldwide, Regional or Event. Used for sorting in the web UI.
|
||||
sig_type: SIGType
|
||||
# Identifies that the SIG's reference ID structure defined by its regex is unique across all programmes and
|
||||
# anything else we expect a user to put in a spot comment, and therefore we can pull references out of
|
||||
# spot comments without also needing to see the SIG name first. For example, "OHFF-1234" or "B/G-1234" are
|
||||
# obviously WWFF and WWBOTA, nothing else looks like those. But "SZ09" could be WAB or Tiles, "GB1234" could
|
||||
# conceivably be POTA or ILLW, etc.
|
||||
refs_globally_unique: bool
|
||||
# SIG names as they might appear in cluster spot comments, e.g. ["TOTA"]
|
||||
comment_names: list[str] = field(default_factory=list)
|
||||
# Reference type, what gets activated e.g. Park, Summit. May be None if the SIG is for multiple types of things, in
|
||||
# which case the spot data will have to provide this instead.
|
||||
ref_type: SIGRefType | None = None
|
||||
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
|
||||
ref_regex: str | None = None
|
||||
# Icon to use in the UI when referencing this SIG. Chosen from the Font Awesome set.
|
||||
icon: str | None = None
|
||||
# Emoji flag for the country or region where this SIG is relevant, if any. If None, this implies the SIG is in
|
||||
# worldwide usage.
|
||||
region_flag: str | None = None
|
||||
|
||||
@@ -1,24 +1,32 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from core.enums import SIGRefType
|
||||
|
||||
|
||||
@dataclass
|
||||
class SIGRef:
|
||||
"""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."""
|
||||
|
||||
# Reference ID, e.g. "GB-0001".
|
||||
id: str
|
||||
# SIG that this reference is in, e.g. "POTA".
|
||||
sig: str
|
||||
# Reference ID, e.g. "GB-0001".
|
||||
id: str | None = None
|
||||
# Name of the reference, e.g. "Null Country Park", if known.
|
||||
name: str | None = None
|
||||
# Type of the reference, e.g. "Park", if known.
|
||||
ref_type: SIGRefType | None = None
|
||||
# URL to look up more information about the reference, if known.
|
||||
url: str | None = None
|
||||
# Latitude of the reference, if known.
|
||||
# Icon to use for the reference, derived from the SIG. Chosen from the Font Awesome set.
|
||||
icon: str | None = None
|
||||
# Latitude of the reference, in degrees, if known.
|
||||
latitude: float | None = None
|
||||
# Longitude of the reference, if known.
|
||||
# Longitude of the reference, in degrees, if known.
|
||||
longitude: float | None = None
|
||||
# Maidenhead grid reference of the reference, if known.
|
||||
# Altitude of the reference, in metres, if known.
|
||||
altitude: float | None = None
|
||||
# Maidenhead grid ref of the reference, if known.
|
||||
grid: str | None = None
|
||||
# Activation score. SOTA only
|
||||
activation_score: int | None = None
|
||||
|
||||
@@ -82,9 +82,9 @@ def _xray_blackout_scale(xray):
|
||||
number = float(xray[1:])
|
||||
except ValueError:
|
||||
return 0
|
||||
if letter == 'M':
|
||||
if letter == "M":
|
||||
return 1 if number < 5 else 2
|
||||
if letter == 'X':
|
||||
if letter == "X":
|
||||
if number < 10:
|
||||
return 3
|
||||
if number < 20:
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from math import isnan
|
||||
|
||||
import pytz
|
||||
from pyhamtools.locator import locator_to_latlong, latlong_to_locator
|
||||
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
|
||||
|
||||
from core.config import MAX_SPOT_AGE
|
||||
from core.constants import MODE_ALIASES, PROPAGATION_MODES
|
||||
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
||||
from core.call_lookup_helper import get_call_info
|
||||
from core.sig_utils import ANY_SIG_REGEX, get_ref_regex_for_sig, get_sig_name_from_comment_name
|
||||
from core.config import MAX_SPOT_AGE
|
||||
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.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.utils import infer_band_from_freq, infer_mode_from_comment, \
|
||||
infer_mode_from_frequency, infer_mode_type_from_mode, get_flag_for_dxcc
|
||||
from core.sig_utils import (
|
||||
ANY_SIG_REGEX,
|
||||
get_ref_regex_for_sig,
|
||||
get_sig_name_from_comment_name,
|
||||
)
|
||||
from core.utils import (
|
||||
get_flag_for_dxcc,
|
||||
infer_band_from_freq,
|
||||
infer_mode_from_comment,
|
||||
infer_mode_from_frequency,
|
||||
infer_mode_type_from_mode,
|
||||
)
|
||||
from data.sig_ref import SIGRef
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Spot:
|
||||
@@ -41,7 +54,7 @@ class Spot:
|
||||
# Country flag of the DX operator
|
||||
dx_flag: str | None = None
|
||||
# Continent of the DX operator
|
||||
dx_continent: str | None = None
|
||||
dx_continent: Continent | None = None
|
||||
# DXCC ID of the DX operator
|
||||
dx_dxcc_id: int | None = None
|
||||
# CQ zone of the DX operator
|
||||
@@ -57,9 +70,8 @@ class Spot:
|
||||
# lookup
|
||||
dx_latitude: float | None = None
|
||||
dx_longitude: float | None = None
|
||||
# DX Location source. Indicates how accurate the location might be. Values: "SPOT", "GRID", "SIG REF LOOKUP",
|
||||
# "HOME QTH", "DXCC", "NONE"
|
||||
dx_location_source: str = "NONE"
|
||||
# DX Location source. Indicates how accurate the location might be.
|
||||
dx_location_source: LocationSourceForSpot | None = None
|
||||
# DX Location good. Indicates that the software thinks the location data is good enough to plot on a map. This is
|
||||
# true if the location source is "SPOT", "SIG REF LOOKUP" or "GRID", or if the location source is "HOME QTH" and the
|
||||
# DX callsign doesn't have a suffix like /P.
|
||||
@@ -74,7 +86,7 @@ class Spot:
|
||||
# Country flag of the spotter
|
||||
de_flag: str | None = None
|
||||
# Continent of the spotter
|
||||
de_continent: str | None = None
|
||||
de_continent: Continent | None = None
|
||||
# DXCC ID of the spotter
|
||||
de_dxcc_id: int | None = None
|
||||
# If this is an APRS/Packet/etc spot, what SSID was the spotter/receiver using?
|
||||
@@ -92,11 +104,11 @@ class Spot:
|
||||
# General QSO info
|
||||
|
||||
# Reported mode, such as SSB, PHONE, CW, FT8...
|
||||
mode: str | None = None
|
||||
# Inferred mode "family". One of "CW", "PHONE" or "DIGI".
|
||||
mode_type: str | None = None
|
||||
# Source of the mode information. "SPOT", "COMMENT", "BANDPLAN" or "NONE"
|
||||
mode_source: str = "NONE"
|
||||
mode: Mode | None = None
|
||||
# Inferred mode "family".
|
||||
mode_type: ModeType | None = None
|
||||
# Source of the mode information.
|
||||
mode_source: ModeSource | None = None
|
||||
# Frequency, in Hz
|
||||
freq: float | None = None
|
||||
# Band, defined by the frequency, e.g. "40m" or "70cm"
|
||||
@@ -113,7 +125,7 @@ class Spot:
|
||||
# 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
|
||||
sig_refs: list = field(default_factory=list)
|
||||
|
||||
# Timing info
|
||||
|
||||
@@ -135,16 +147,18 @@ class Spot:
|
||||
# The ID the source gave it, if any.
|
||||
source_id: str | None = None
|
||||
|
||||
# Display info
|
||||
|
||||
# Icon to use when displaying this spot in the web UI. Chosen from the Font Awesome set.
|
||||
icon: str | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Normalise fields that don't survive a plain dict to Spot conversion. This is used in the "add spot" API
|
||||
endpoint where the client is submitting JSON, and we want to recreate a full Spot object, including nested
|
||||
objects such as the sig_refs list.."""
|
||||
|
||||
if self.sig_refs:
|
||||
self.sig_refs = [
|
||||
sig_ref if isinstance(sig_ref, SIGRef) else SIGRef(**sig_ref)
|
||||
for sig_ref in self.sig_refs
|
||||
]
|
||||
self.sig_refs = [sig_ref if isinstance(sig_ref, SIGRef) else SIGRef(**sig_ref) for sig_ref in self.sig_refs]
|
||||
|
||||
def infer_missing(self, credentials=None):
|
||||
"""Infer missing parameters where possible"""
|
||||
@@ -176,8 +190,8 @@ class Spot:
|
||||
dx_call_info = get_call_info(self.dx_call, credentials)
|
||||
if self.dx_call and not self.dx_country:
|
||||
self.dx_country = dx_call_info.country
|
||||
if self.dx_call and not self.dx_continent:
|
||||
self.dx_continent = dx_call_info.continent
|
||||
if self.dx_call and dx_call_info.continent and not self.dx_continent:
|
||||
self.dx_continent = Continent(dx_call_info.continent)
|
||||
if self.dx_call and not self.dx_dxcc_id:
|
||||
self.dx_dxcc_id = dx_call_info.dxcc_id
|
||||
if self.dx_dxcc_id and not self.dx_flag:
|
||||
@@ -207,19 +221,22 @@ class Spot:
|
||||
# Spotter country, continent, zones etc. from callsign.
|
||||
# DE call with no digits, or APRS servers starting "T2" are not things we can look up location for
|
||||
de_call_info = get_call_info(self.de_call, credentials)
|
||||
if self.de_call and any(char.isdigit() for char in self.de_call) and not (
|
||||
self.de_call.startswith("T2") and self.source == "APRS-IS"):
|
||||
if (
|
||||
self.de_call
|
||||
and any(char.isdigit() for char in self.de_call)
|
||||
and not (self.de_call.startswith("T2") and self.source == "APRS-IS")
|
||||
):
|
||||
if not self.de_country:
|
||||
self.de_country = de_call_info.country
|
||||
if not self.de_continent:
|
||||
self.de_continent = de_call_info.continent
|
||||
if de_call_info.continent and not self.de_continent:
|
||||
self.de_continent = Continent(de_call_info.continent)
|
||||
if not self.de_dxcc_id:
|
||||
self.de_dxcc_id = de_call_info.dxcc_id
|
||||
if self.de_dxcc_id and not self.de_flag:
|
||||
self.de_flag = get_flag_for_dxcc(self.de_dxcc_id)
|
||||
|
||||
# Remove NaNs in frequency
|
||||
if self.freq and self.freq == float("nan"):
|
||||
if self.freq and isnan(self.freq):
|
||||
self.freq = None
|
||||
|
||||
# Band from frequency
|
||||
@@ -229,17 +246,13 @@ class Spot:
|
||||
|
||||
# Mode from comments or bandplan
|
||||
if self.mode:
|
||||
self.mode_source = "SPOT"
|
||||
self.mode_source = ModeSource.SPOT
|
||||
if self.comment and not self.mode:
|
||||
self.mode = infer_mode_from_comment(self.comment)
|
||||
self.mode_source = "COMMENT"
|
||||
self.mode_source = ModeSource.COMMENT
|
||||
if self.freq and not self.mode:
|
||||
self.mode = infer_mode_from_frequency(self.freq)
|
||||
self.mode_source = "BANDPLAN"
|
||||
|
||||
# Normalise mode if necessary.
|
||||
if self.mode in MODE_ALIASES:
|
||||
self.mode = MODE_ALIASES[self.mode]
|
||||
self.mode_source = ModeSource.BANDPLAN
|
||||
|
||||
# Mode type from mode
|
||||
if self.mode and not self.mode_type:
|
||||
@@ -247,19 +260,19 @@ class Spot:
|
||||
|
||||
# If we have a latitude or grid at this point, it can only have been provided by the spot itself
|
||||
if self.dx_latitude or self.dx_grid:
|
||||
self.dx_location_source = "SPOT"
|
||||
self.dx_location_source = LocationSourceForSpot.SPOT
|
||||
|
||||
# Set the top-level "SIG" if it is missing but we have at least one SIG ref.
|
||||
if not self.sig and self.sig_refs and len(self.sig_refs) > 0:
|
||||
if not self.sig and self.sig_refs:
|
||||
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
|
||||
# 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()
|
||||
regex = get_ref_regex_for_sig(sig)
|
||||
if regex:
|
||||
all_comment_ref_matches = re.finditer(r"(^|\W)(" + regex + r")(^|\W)", self.comment, re.IGNORECASE)
|
||||
all_comment_ref_matches = re.finditer(r"(^|\W)(" + regex + r")($|\W)", self.comment, re.IGNORECASE)
|
||||
for ref_match in all_comment_ref_matches:
|
||||
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig))
|
||||
|
||||
@@ -279,31 +292,47 @@ class Spot:
|
||||
# If so, add that to the sig_refs list for this spot.
|
||||
ref_regex = get_ref_regex_for_sig(found_sig)
|
||||
if ref_regex:
|
||||
ref_matches = re.finditer(r"(^|\W)" + found_sig + r"([ -])(" + ref_regex + r")($|\W)", self.comment,
|
||||
re.IGNORECASE)
|
||||
ref_matches = re.finditer(
|
||||
r"(^|\W)" + found_sig + r"([ -])(" + ref_regex + r")($|\W)",
|
||||
self.comment,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
for ref_match in ref_matches:
|
||||
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(3).upper(), sig=found_sig))
|
||||
|
||||
# See if the comment looks like it contains any SIG references *without* the corresponding SIG name, but
|
||||
# where the SIG reference is unique-looking enough that we can't confuse it with any other SIG.
|
||||
if self.comment:
|
||||
for sig in SIGS:
|
||||
if sig.refs_globally_unique and sig.ref_regex:
|
||||
ref_matches = re.finditer(r"(^|\W)(" + sig.ref_regex + r")($|\W)", self.comment, re.IGNORECASE)
|
||||
for ref_match in ref_matches:
|
||||
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things
|
||||
# like cluster spots where the comment is just "OHFF-1234", now we know it's WWFF.
|
||||
if not self.sig:
|
||||
self.sig = sig.name
|
||||
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig.name))
|
||||
|
||||
# Fetch SIG data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a reference
|
||||
# in its initial call, we use this code to populate the rest of the data. This includes working out grid refs
|
||||
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
|
||||
if self.sig_refs and len(self.sig_refs) > 0:
|
||||
if self.sig_refs:
|
||||
for sig_ref in self.sig_refs:
|
||||
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 sig_ref.grid and not self.dx_grid:
|
||||
self.dx_grid = sig_ref.grid
|
||||
if sig_ref.latitude and not self.dx_latitude:
|
||||
if sig_ref.latitude and not self.dx_latitude and sig_ref.longitude and not self.dx_longitude:
|
||||
self.dx_latitude = sig_ref.latitude
|
||||
self.dx_longitude = sig_ref.longitude
|
||||
if self.sig == "WAB" or self.sig == "WAI" or self.sig == "Tiles":
|
||||
self.dx_location_source = "GRID"
|
||||
self.dx_location_source = LocationSourceForSpot.GRID
|
||||
else:
|
||||
self.dx_location_source = "SIG REF LOOKUP"
|
||||
self.dx_location_source = LocationSourceForSpot.SIG_REF_LOOKUP
|
||||
|
||||
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
|
||||
# and apply it to the whole spot.
|
||||
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
|
||||
|
||||
# Parse "de_grid<prop_mode>dx_grid" structures from the comment, e.g. "JN61ES(ES)JM56XT" or "JO02GQ<>KN17LG".
|
||||
@@ -311,15 +340,16 @@ class Spot:
|
||||
# being the only source we have for propagation mode. Brace for nightmare regex from hell.
|
||||
if self.comment:
|
||||
grid_mode_grid_match = re.search(
|
||||
r'\b([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)(?:<([^>]*)>|\(([^)]*)\))([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\b',
|
||||
self.comment)
|
||||
r"\b([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)(?:<([^>]*)>|\(([^)]*)\))([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\b",
|
||||
self.comment,
|
||||
)
|
||||
if grid_mode_grid_match:
|
||||
# regex matches, so extract grids:
|
||||
if not self.de_grid:
|
||||
self.de_grid = grid_mode_grid_match.group(1).upper()
|
||||
if not self.dx_grid:
|
||||
self.dx_grid = grid_mode_grid_match.group(4).upper()
|
||||
self.dx_location_source = "GRID"
|
||||
self.dx_location_source = LocationSourceForSpot.GRID
|
||||
|
||||
# And extract propagation mode (group 2 for <...>, group 3 for (...)):
|
||||
mode_tag = (grid_mode_grid_match.group(2) or grid_mode_grid_match.group(3) or "").upper()
|
||||
@@ -328,58 +358,69 @@ class Spot:
|
||||
self.propagation_mode = PROPAGATION_MODES[mode_tag]
|
||||
else:
|
||||
self.propagation_mode = mode_tag
|
||||
logging.info("Seen a new propagation mode tag not yet in the system: %s", 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
|
||||
if self.comment:
|
||||
grid_mode_grid_match = re.search(
|
||||
r'\b([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\s*->\s*([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\b',
|
||||
self.comment)
|
||||
r"\b([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\s*->\s*([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\b",
|
||||
self.comment,
|
||||
)
|
||||
if grid_mode_grid_match:
|
||||
# regex matches, so extract grids:
|
||||
if not self.dx_grid:
|
||||
self.dx_grid = grid_mode_grid_match.group(1).upper()
|
||||
self.dx_location_source = "GRID"
|
||||
self.dx_location_source = LocationSourceForSpot.GRID
|
||||
if not self.de_grid:
|
||||
self.de_grid = grid_mode_grid_match.group(2).upper()
|
||||
|
||||
# DX Grid to lat/lon and vice versa in case one is missing
|
||||
if self.dx_grid and not self.dx_latitude:
|
||||
if self.dx_grid and (not self.dx_latitude or not self.dx_longitude):
|
||||
try:
|
||||
ll = locator_to_latlong(self.dx_grid)
|
||||
self.dx_latitude = ll[0]
|
||||
self.dx_longitude = ll[1]
|
||||
except:
|
||||
logging.debug("Invalid grid received for spot")
|
||||
except Exception:
|
||||
logger.debug("Invalid grid received for spot")
|
||||
if self.dx_latitude and self.dx_longitude and not self.dx_grid:
|
||||
try:
|
||||
self.dx_grid = latlong_to_locator(self.dx_latitude, self.dx_longitude, 8)
|
||||
except:
|
||||
logging.debug("Invalid lat/lon received for spot")
|
||||
except Exception:
|
||||
logger.debug("Invalid lat/lon received for spot")
|
||||
|
||||
# QRT comment detection
|
||||
if self.comment and not self.qrt:
|
||||
self.qrt = "QRT" in self.comment.upper()
|
||||
|
||||
# Always create an ID based on a hash of every parameter *except* received_time. This is used as the index
|
||||
# to a map, which as a byproduct avoids us having multiple duplicate copies of the object that are identical
|
||||
# apart from that they were retrieved from the API at different times. Note that the simple Python hash()
|
||||
# function includes a seed randomly generated at runtime; this is therefore not consistent between runs. But we
|
||||
# use diskcache to store our data between runs, so we use SHA256 which does not include this random element.
|
||||
# The ID is computed before the online lookups below so that it is stable regardless of whether credentials
|
||||
# are provided, allowing the enriched API response to be matched to the stored spot by ID.
|
||||
# Create an ID based on the source and source ID if possible, as these guaranee uniqueness. If there is no
|
||||
# source ID, use a combination of callsign and spot time. Spot time is down to the second or even the
|
||||
# millisecond, so we can be reasonably sure two spots that match are the same spot.
|
||||
if not self.id:
|
||||
self_copy = copy.deepcopy(self)
|
||||
self_copy.received_time = 0
|
||||
self_copy.received_time_iso = ""
|
||||
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
|
||||
if self.source and self.source_id:
|
||||
self.id = hashlib.sha256(str({"s": self.source, "sid": self.source_id}).encode("utf-8")).hexdigest()
|
||||
else:
|
||||
self.id = hashlib.sha256(
|
||||
str({"s": self.source, "c": self.dx_call, "t": self.time}).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
# DX operator details lookup. This should be the last resort compared to taking the data from the actual
|
||||
# spotting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon or DXCC lat/lon
|
||||
# instead of the one from the park reference they're at.
|
||||
if self.dx_call and not self.dx_name:
|
||||
self.dx_name = dx_call_info.name
|
||||
if self.dx_call and not self.dx_latitude:
|
||||
if self.dx_call and (not self.dx_latitude or not self.dx_longitude):
|
||||
self.dx_latitude = dx_call_info.latitude
|
||||
self.dx_longitude = dx_call_info.longitude
|
||||
self.dx_grid = dx_call_info.grid
|
||||
@@ -387,44 +428,67 @@ class Spot:
|
||||
|
||||
# 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.
|
||||
if self.sig_refs and len(self.sig_refs) > 0:
|
||||
if self.sig_refs:
|
||||
qth = self.sig_refs[0].id
|
||||
if self.sig_refs[0].name:
|
||||
qth += " " + self.sig_refs[0].name
|
||||
qth += f" {self.sig_refs[0].name}"
|
||||
self.dx_qth = qth
|
||||
else:
|
||||
self.dx_qth = dx_call_info.qth
|
||||
|
||||
# CQ and ITU zone lookup, preferably from location but failing that, from callsign
|
||||
if not self.dx_cq_zone:
|
||||
if self.dx_latitude:
|
||||
if self.dx_latitude and self.dx_longitude:
|
||||
self.dx_cq_zone = lat_lon_to_cq_zone(self.dx_latitude, self.dx_longitude)
|
||||
elif self.dx_call:
|
||||
self.dx_cq_zone = dx_call_info.cq_zone
|
||||
if not self.dx_itu_zone:
|
||||
if self.dx_latitude:
|
||||
if self.dx_latitude and self.dx_longitude:
|
||||
self.dx_itu_zone = lat_lon_to_itu_zone(self.dx_latitude, self.dx_longitude)
|
||||
elif self.dx_call:
|
||||
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
|
||||
# is likely at home.
|
||||
self.dx_location_good = bool(self.dx_latitude and self.dx_longitude and (
|
||||
self.dx_location_source == "SPOT" or self.dx_location_source == "SIG REF LOOKUP"
|
||||
or self.dx_location_source == "GRID"
|
||||
or (self.dx_location_source == "HOME QTH" and "/" not in (self.dx_call or ""))))
|
||||
self.dx_location_good = bool(
|
||||
self.dx_latitude
|
||||
and self.dx_longitude
|
||||
and (
|
||||
self.dx_location_source == LocationSourceForSpot.SPOT
|
||||
or self.dx_location_source == LocationSourceForSpot.SIG_REF_LOOKUP
|
||||
or self.dx_location_source == LocationSourceForSpot.GRID
|
||||
or (self.dx_location_source == LocationSourceForSpot.HOME_QTH and "/" not in (self.dx_call or ""))
|
||||
)
|
||||
)
|
||||
|
||||
# DE with no digits and APRS servers starting "T2" are not things we can look up location for
|
||||
if self.de_call and any(char.isdigit() for char in self.de_call) and not (
|
||||
self.de_call.startswith("T2") and self.source == "APRS-IS"):
|
||||
if (
|
||||
self.de_call
|
||||
and any(char.isdigit() for char in str(self.de_call))
|
||||
and not (self.de_call.startswith("T2") and self.source == "APRS-IS")
|
||||
and (not self.de_latitude or not self.de_longitude)
|
||||
):
|
||||
# DE operator location lookup
|
||||
if not self.de_latitude:
|
||||
self.de_latitude = de_call_info.latitude
|
||||
self.de_longitude = de_call_info.longitude
|
||||
self.de_grid = de_call_info.grid
|
||||
self.de_latitude = de_call_info.latitude
|
||||
self.de_longitude = de_call_info.longitude
|
||||
self.de_grid = de_call_info.grid
|
||||
|
||||
except Exception as e:
|
||||
logging.error("Exception while inferring missing data from spot", e, exc_info=True)
|
||||
# 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:
|
||||
logger.exception("Exception while inferring missing data from spot")
|
||||
|
||||
def to_json(self):
|
||||
"""JSON serialise"""
|
||||
@@ -434,16 +498,14 @@ class Spot:
|
||||
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."""
|
||||
|
||||
sig_refs = self.sig_refs or []
|
||||
self.sig_refs = sig_refs
|
||||
new_sig_ref.id = new_sig_ref.id.strip().upper()
|
||||
new_sig_ref.sig = new_sig_ref.sig.strip().upper()
|
||||
if new_sig_ref.id == "":
|
||||
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:
|
||||
return
|
||||
sig_refs.append(new_sig_ref)
|
||||
self.sig_refs.append(new_sig_ref)
|
||||
|
||||
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
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
AA343,"Almaty, Kazakhstan"
|
||||
AL945,"Alpena, United States"
|
||||
AT138,"Athens, Greece"
|
||||
AU930,"Austin, United States"
|
||||
BR52P,"Brisbane, Australia"
|
||||
BVJ03,"Boa Vista, Brazil"
|
||||
CAJ2M,"Cachoeira Paulista, Brazil"
|
||||
CB53N,"Canberra, Australia"
|
||||
CGK21,"Campo Grande, Brazil"
|
||||
DB049,"Dourbes, Belgium"
|
||||
DW41K,"Darwin, Australia"
|
||||
EA036,"El Arenosillo, Spain"
|
||||
@@ -18,24 +15,18 @@ GM037,"Gibilmanna, Italy"
|
||||
GR13L,"Grahamstown, South Africa"
|
||||
HE13N,"Hermanus, South Africa"
|
||||
HO54K,"Hobart, Australia"
|
||||
IC437,"I-Cheon, South Korea"
|
||||
IF843,"Idaho National Laboratory, United States"
|
||||
JI91J,"Jicamarca, Peru"
|
||||
JR055,"Juliusruh, Germany"
|
||||
LAA38,"Lajes Terceira Island, Portugal"
|
||||
LL721,"Lualualei, United States"
|
||||
LM42B,"Learmonth, Australia"
|
||||
LV12P,"Louisvale, South Africa"
|
||||
MHJ45,"Millstone Hill, United States"
|
||||
ML10L,"Malindi, Kenya"
|
||||
NI135,"Nicosia, Cyprus"
|
||||
NI63_,"Norfolk, Australia"
|
||||
PA836,"Portt Arguello, United States"
|
||||
PA836,"Port Arguello, United States"
|
||||
PE43K,"Perth, Australia"
|
||||
PF765,"Poker Flat, United States"
|
||||
PQ052,"Pruhonice, Czechia"
|
||||
RO041,"Rome, Italy"
|
||||
SAA0K,"Saoluis, Brazil"
|
||||
SO148,"Sopron, Hungary"
|
||||
TR169,"Tromso, Norway"
|
||||
TV51R,"Townsville, Australia"
|
||||
|
||||
|
@@ -27,4 +27,9 @@ EH T-31,50.3636495,7.5584857
|
||||
EH T-33,50.3636495,7.5584857
|
||||
EH T-34,50.3636495,7.5584857
|
||||
EH T-41,50.3636495,7.5584857
|
||||
EH T-51,50.3636495,7.5584857
|
||||
EH T-51,50.3636495,7.5584857
|
||||
HOPE T-01,40.7527998,-73.9935015
|
||||
HOPE T-02,40.7526614,-73.9935752
|
||||
HOPE T-03,40.7528663,-73.9936566
|
||||
HOPE T-04,40.7527845,-73.9937284
|
||||
HOPE T-05,40.7518292,-73.9932781
|
||||
|
@@ -13,6 +13,7 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "7373:7373" # For telnet if required
|
||||
volumes:
|
||||
- ./config.yml:/app/config.yml
|
||||
- ./cache:/app/cache
|
||||
@@ -23,15 +24,16 @@ You can replace `#main` with any other branch or tag reference, for example `#1.
|
||||
|
||||
Save the file. You will still need to create a copy of `config-example.yml` and name it `config.yml`, though with the
|
||||
Docker setup nothing has actually been downloaded yet, so you will have to copy the example from the repository some
|
||||
other way, e.g. [from the repo in a web browser](https://git.ianrenton.com/ian/spothole/src/branch/main/config-example.yml).
|
||||
other way,
|
||||
e.g. [from the repo in a web browser](https://git.ianrenton.com/ian/spothole/src/branch/main/config-example.yml).
|
||||
|
||||
With that in place, run `docker compose up` and you should be good to go. To detach, press `d` or run the command with
|
||||
the `-d` flag.
|
||||
|
||||
### nginx Reverse Proxy with Docker
|
||||
|
||||
In a containerised setup, it's typical to run an nginx reverse proxy in one container, alongside certbot for renewal
|
||||
of HTTPS certificates, and then applications like Spothole in a separate container. In this case, there are a couple of
|
||||
In a containerised setup, it's typical to run an nginx reverse proxy in one container, alongside certbot for renewal of
|
||||
HTTPS certificates, and then applications like Spothole in a separate container. In this case, there are a couple of
|
||||
variations of the docker compose file above, and the nginx reverse proxy configuration covered [here](./nginx.md), that
|
||||
you will want to make.
|
||||
|
||||
@@ -60,8 +62,8 @@ networks:
|
||||
```
|
||||
|
||||
In your nginx site configuration, you'll want to refer to the Spothole container directly, and drop the block that
|
||||
allows nginx to access static files directly, as these will be inaccessible in another container. So you may end up
|
||||
with something like:
|
||||
allows nginx to access static files directly, as these will be inaccessible in another container. So you may end up with
|
||||
something like:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -146,13 +148,13 @@ server {
|
||||
```
|
||||
|
||||
If desired, you could even change the port on which Spothole runs from 8080 to a plain 80, in which case your
|
||||
`proxy_pass` statements could drop the `:8080` suffix. Since Spothole is in a container, it can serve HTTP on port 80
|
||||
if desired, because it doesn't conflict with the host system.
|
||||
`proxy_pass` statements could drop the `:8080` suffix. Since Spothole is in a container, it can serve HTTP on port 80 if
|
||||
desired, because it doesn't conflict with the host system.
|
||||
|
||||
### Restoring the static files bypass
|
||||
|
||||
If you would still like to bypass Spothole's web server for the static files, and serve them with nginx, you can
|
||||
do. The easiest way is to run another nginx container to serve the files, so your Spothole `compose.yaml` becomes:
|
||||
If you would still like to bypass Spothole's web server for the static files, and serve them with nginx, you can do. The
|
||||
easiest way is to run another nginx container to serve the files, so your Spothole `compose.yaml` becomes:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -183,8 +185,8 @@ networks:
|
||||
external: true
|
||||
```
|
||||
|
||||
Then you can re-add the block that handles the `/static` path in your nginx reverse proxy config, but this time
|
||||
point it at the new container rather than at a filesystem path:
|
||||
Then you can re-add the block that handles the `/static` path in your nginx reverse proxy config, but this time point it
|
||||
at the new container rather than at a filesystem path:
|
||||
|
||||
```nginx
|
||||
# Load static assets from the spothole-static-nginx container
|
||||
|
||||
@@ -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/staticdata` - Classes providing static lookup data by accessing bundled data files or the APIs of other
|
||||
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
|
||||
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
|
||||
|
||||
*Templates*
|
||||
@@ -48,8 +51,8 @@ To navigate your way around the source code, this list may help.
|
||||
### Extending the server
|
||||
|
||||
Spothole is designed to be easily extensible. If you want to write your own spot provider, for example, simply add a
|
||||
module to the `providers.spot` package containing your class. (Currently, in order to be loaded correctly, the module (
|
||||
file) name should be the same as the class name, but lower case.)
|
||||
module to the `providers.spot` package containing your class. (Currently, in order to be loaded correctly, the module
|
||||
(file) name should be the same as the class name, but lower case.)
|
||||
|
||||
Your class should extend "SpotProvider"; if it operates by polling an HTTP Server on a timer, it can instead extend "
|
||||
HTTPSpotProvider" where some of the work is done for you.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Web servers generally serve their pages from port 80. However, it's best not to serve Spothole's web interface directly
|
||||
on port 80, as that requires root privileges on a Linux system. It also and prevents us using HTTPS to serve a secure
|
||||
site, since Spothole itself doesn't directly support acting as an HTTPS server. The normal solution to this is to use
|
||||
a "reverse proxy" setup, where a general web server handles HTTP and HTTP requests (to port 80 & 443 respectively), then
|
||||
site, since Spothole itself doesn't directly support acting as an HTTPS server. The normal solution to this is to use a
|
||||
"reverse proxy" setup, where a general web server handles HTTP and HTTP requests (to port 80 & 443 respectively), then
|
||||
passes on the request to the back-end application (in this case Spothole). nginx is a common choice for this general web
|
||||
server.
|
||||
|
||||
@@ -89,19 +89,14 @@ server {
|
||||
```
|
||||
|
||||
One further change you might want to make to the file above is the `add_header Access-Control-Allow-Origin` statements.
|
||||
These are what's used on
|
||||
my own Spothole server to make sure that other third-party web-based software can get the data from my instance, and
|
||||
applies to any endpoint underneath `/api`. If you want
|
||||
*your* Spothole instance to be set up the same way, so that others can write software in JavaScript that can access it,
|
||||
leave this intact. But if you want your Spothole instance to only be usable by scripts running on the web server you
|
||||
write,
|
||||
you can remove these lines. (Note that this doesn't stop other people writing *non-web-based* software that accesses
|
||||
your
|
||||
Spothole API—the enforcement of cross-origin headers only happens within the user's browser. If you need to lock
|
||||
your
|
||||
instance down so that no-one else can access it with *any* software, that's an aspect of nginx or firewall config that
|
||||
you will need
|
||||
to find help with elsewhere.)
|
||||
These are what's used on my own Spothole server to make sure that other third-party web-based software can get the data
|
||||
from my instance, and applies to any endpoint underneath `/api`. If you want *your* Spothole instance to be set up the
|
||||
same way, so that others can write software in JavaScript that can access it, leave this intact. But if you want your
|
||||
Spothole instance to only be usable by scripts running on the web server you write, you can remove these lines. (Note
|
||||
that this doesn't stop other people writing *non-web-based* software that accesses your Spothole API—the
|
||||
enforcement of cross-origin headers only happens within the user's browser. If you need to lock your instance down so
|
||||
that no-one else can access it with *any* software, that's an aspect of nginx or firewall config that you will need to
|
||||
find help with elsewhere.)
|
||||
|
||||
Now, make a symbolic link to enable the site:
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
If you want to run a copy of Spothole with different configuration settings than the main instance, you can download it
|
||||
and run it on your own local machine or server.
|
||||
|
||||
You will require Python version 3.8 or later. If you encounter an error about `gdal-config` during the following
|
||||
You will require Python version 3.10 or later. If you encounter an error about `gdal-config` during the following
|
||||
process, you will also need `libgdal-dev` installed.
|
||||
|
||||
To download and set up Spothole on a Debian server, run the following commands. Other operating systems will likely be
|
||||
|
||||
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 374 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 119 KiB |
@@ -29,7 +29,7 @@ class AlertProvider:
|
||||
|
||||
# Sort the batch so that earliest ones go in first. This helps keep the ordering correct when alerts are fired
|
||||
# off to SSE listeners.
|
||||
alerts = sorted(alerts, key=lambda a: (a.start_time if a and a.start_time else 0))
|
||||
alerts = sorted(alerts, key=lambda a: a.start_time if a and a.start_time else 0)
|
||||
for alert in alerts:
|
||||
# Fill in any blanks and add to the list
|
||||
alert.infer_missing()
|
||||
|
||||
@@ -3,9 +3,10 @@ from datetime import datetime, timedelta
|
||||
import pytz
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||
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 BOTA(HTTPAlertProvider):
|
||||
@@ -23,17 +24,17 @@ class BOTA(HTTPAlertProvider):
|
||||
bs = BeautifulSoup(http_response.content.decode("utf-8-sig"), features="lxml")
|
||||
if not bs.body:
|
||||
return new_alerts
|
||||
div = bs.body.find('div', attrs={'class': 'view-activations-public'})
|
||||
div = bs.body.find("div", attrs={"class": "view-activations-public"})
|
||||
if div:
|
||||
table = div.find('table', attrs={'class': 'views-table'})
|
||||
table = div.find("table", attrs={"class": "views-table"})
|
||||
if table:
|
||||
tbody = table.find('tbody')
|
||||
tbody = table.find("tbody")
|
||||
if not tbody:
|
||||
return new_alerts
|
||||
for row in tbody.find_all('tr'):
|
||||
cells = row.find_all('td')
|
||||
first_cell_anchor = cells[0].find('a') if len(cells) > 0 else None
|
||||
second_cell_anchor = cells[1].find('a') if len(cells) > 1 else None
|
||||
for row in tbody.find_all("tr"):
|
||||
cells = row.find_all("td")
|
||||
first_cell_anchor = cells[0].find("a") if len(cells) > 0 else None
|
||||
second_cell_anchor = cells[1].find("a") if len(cells) > 1 else None
|
||||
if not first_cell_anchor or not second_cell_anchor:
|
||||
continue
|
||||
first_cell_text = first_cell_anchor.get_text().strip()
|
||||
@@ -41,7 +42,7 @@ class BOTA(HTTPAlertProvider):
|
||||
dx_call = second_cell_anchor.get_text().strip().upper()
|
||||
|
||||
# Get the date, dealing with the fact we get no year so have to figure out if it's last year or next year
|
||||
date_span = cells[2].find('span') if len(cells) > 2 else None
|
||||
date_span = cells[2].find("span") if len(cells) > 2 else None
|
||||
if not date_span:
|
||||
continue
|
||||
date_text = date_span.get_text().strip()
|
||||
@@ -52,11 +53,13 @@ class BOTA(HTTPAlertProvider):
|
||||
date_time = date_time.replace(year=datetime.now(pytz.UTC).year + 1)
|
||||
|
||||
# Convert to our alert format
|
||||
alert = Alert(source=self.name,
|
||||
dx_calls=[dx_call],
|
||||
sig_refs=[SIGRef(id=ref_name, sig="BOTA")],
|
||||
start_time=date_time.timestamp(),
|
||||
is_dxpedition=False)
|
||||
alert = Alert(
|
||||
source=self.name,
|
||||
dx_calls=[dx_call],
|
||||
sig_refs=[SIGRef(id=ref_name, sig="BOTA")],
|
||||
start_time=date_time.timestamp(),
|
||||
alert_type=AlertType.XOTA,
|
||||
)
|
||||
|
||||
new_alerts.append(alert)
|
||||
return new_alerts
|
||||
|
||||
@@ -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
|
||||
@@ -1,13 +1,15 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from threading import Thread, Event
|
||||
from threading import Event, Thread
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||
from requests.exceptions import ConnectionError, ConnectTimeout, JSONDecodeError, ReadTimeout
|
||||
|
||||
from providers.alert.alert_provider import AlertProvider
|
||||
from core.constants import HTTP_HEADERS
|
||||
from providers.alert.alert_provider import AlertProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HTTPAlertProvider(AlertProvider):
|
||||
@@ -24,12 +26,16 @@ class HTTPAlertProvider(AlertProvider):
|
||||
def start(self):
|
||||
# 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.
|
||||
logging.info("Set up query of " + self.name + " alert API every " + str(self._poll_interval) + " seconds.")
|
||||
self._thread = Thread(target=self._run, name=f"HTTPAlertProvider-{self.name}")
|
||||
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}", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
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):
|
||||
while True:
|
||||
@@ -40,7 +46,7 @@ class HTTPAlertProvider(AlertProvider):
|
||||
def _poll(self):
|
||||
try:
|
||||
# Request data from API
|
||||
logging.debug("Polling " + self.name + " alert API...")
|
||||
logger.debug(f"Polling {self.name} alert API...")
|
||||
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
|
||||
# Check response code was good
|
||||
if http_response.ok:
|
||||
@@ -52,18 +58,23 @@ class HTTPAlertProvider(AlertProvider):
|
||||
|
||||
self.status = "OK"
|
||||
self.last_update_time = datetime.now(pytz.UTC)
|
||||
logging.debug("Received data from " + self.name + " alert API.")
|
||||
logger.debug(f"Received data from {self.name} alert API.")
|
||||
else:
|
||||
self.status = "Error"
|
||||
logging.warning(f"HTTP {http_response.status_code} when calling {self.name} alerts API.")
|
||||
logger.warning(f"HTTP {http_response.status_code} when calling {self.name} alerts API.")
|
||||
|
||||
except ConnectionError:
|
||||
logging.warning(f"Connection error when accessing {self.name} alerts API.")
|
||||
self.status = "Error"
|
||||
logger.warning(f"Connection error when accessing {self.name} alerts API.")
|
||||
except (ConnectTimeout, ReadTimeout):
|
||||
logging.warning(f"Timeout when accessing {self.name} alerts API.")
|
||||
self.status = "Error"
|
||||
logger.warning(f"Timeout when accessing {self.name} alerts API.")
|
||||
except JSONDecodeError:
|
||||
self.status = "Error"
|
||||
logger.warning(f"Invalid or empty JSON response from {self.name} alert API.")
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.exception("Exception in HTTP JSON Alert Provider (" + self.name + ")")
|
||||
logger.exception(f"Exception in HTTP JSON Alert Provider ({self.name})")
|
||||
# Brief pause on error before the next poll, but still respond promptly to stop()
|
||||
self._stop_event.wait(timeout=1)
|
||||
|
||||
|
||||
@@ -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.models.rss import RSS
|
||||
|
||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||
from core.enums import AlertType
|
||||
from data.alert import Alert
|
||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||
|
||||
|
||||
class NG3K(HTTPAlertProvider):
|
||||
"""Alert provider NG3K DXpedition list"""
|
||||
"""Alert provider for NG3K DXpedition list"""
|
||||
|
||||
POLL_INTERVAL_SEC = 1800
|
||||
POLL_INTERVAL_DAYS = 1
|
||||
ALERTS_URL = "https://www.ng3k.com/adxo.xml"
|
||||
AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE)
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
|
||||
|
||||
def _http_response_to_alerts(self, http_response):
|
||||
new_alerts = []
|
||||
@@ -49,11 +50,16 @@ class NG3K(HTTPAlertProvider):
|
||||
end_day = end_string.split(", ")[0].strip()
|
||||
end_mon = start_mon
|
||||
|
||||
start_timestamp = datetime.strptime(start_year + " " + start_mon + " " + start_day, "%Y %b %d").replace(
|
||||
tzinfo=pytz.UTC).timestamp()
|
||||
end_timestamp = datetime.strptime(end_year + " " + end_mon + " " + end_day + " 23:59",
|
||||
"%Y %b %d %H:%M").replace(
|
||||
tzinfo=pytz.UTC).timestamp()
|
||||
start_timestamp = (
|
||||
datetime.strptime(f"{start_year} {start_mon} {start_day}", "%Y %b %d")
|
||||
.replace(tzinfo=pytz.UTC)
|
||||
.timestamp()
|
||||
)
|
||||
end_timestamp = (
|
||||
datetime.strptime(f"{end_year} {end_mon} {end_day} 23:59", "%Y %b %d %H:%M")
|
||||
.replace(tzinfo=pytz.UTC)
|
||||
.timestamp()
|
||||
)
|
||||
|
||||
# Sometimes the DX callsign is "real", sometimes you just get a prefix with the real working callsigns being
|
||||
# provided in the "by" field. e.g. call="JW", by="By LA7XK as JW7XK, LA6VM as JW6VM, LA9DL as JW9DL". So
|
||||
@@ -75,14 +81,16 @@ class NG3K(HTTPAlertProvider):
|
||||
comment = extra_parts[3] if len(extra_parts) > 3 else ""
|
||||
|
||||
# Convert to our alert format
|
||||
alert = Alert(source=self.name,
|
||||
dx_calls=dx_calls,
|
||||
dx_country=dx_country,
|
||||
freqs_modes=bands + (("; " + modes) if modes != "" else ""),
|
||||
comment=by + "; " + comment + "; " + qsl_info,
|
||||
start_time=start_timestamp,
|
||||
end_time=end_timestamp,
|
||||
is_dxpedition=True)
|
||||
alert = Alert(
|
||||
source=self.name,
|
||||
dx_calls=dx_calls,
|
||||
dx_country=dx_country,
|
||||
freqs_modes=bands + (f"; {modes}" if modes != "" else ""),
|
||||
comment=f"{by}; {comment}; {qsl_info}",
|
||||
start_time=start_timestamp,
|
||||
end_time=end_timestamp,
|
||||
alert_type=AlertType.DXPEDITION,
|
||||
)
|
||||
|
||||
# Add to our list.
|
||||
new_alerts.append(alert)
|
||||
|
||||
@@ -3,9 +3,12 @@ from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||
from core.enums import AlertType
|
||||
from data.alert import Alert
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ParksNPeaks(HTTPAlertProvider):
|
||||
@@ -30,8 +33,9 @@ class ParksNPeaks(HTTPAlertProvider):
|
||||
else:
|
||||
sig_ref = source_alert["WWFFID"]
|
||||
sig_ref_name = source_alert["Location"]
|
||||
start_time = datetime.strptime(source_alert["alTime"], "%Y-%m-%d %H:%M:%S").replace(
|
||||
tzinfo=pytz.UTC).timestamp()
|
||||
start_time = (
|
||||
datetime.strptime(source_alert["alTime"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp()
|
||||
)
|
||||
|
||||
sigrefs = []
|
||||
# PnP can give us an alert of class "QRP" which is the only one that's not a real SIG in Spothole's list,
|
||||
@@ -40,18 +44,31 @@ class ParksNPeaks(HTTPAlertProvider):
|
||||
sigrefs = [SIGRef(id=sig_ref, sig=sig, name=sig_ref_name)]
|
||||
|
||||
# Convert to our alert format
|
||||
alert = Alert(source=self.name,
|
||||
source_id=source_alert["alID"],
|
||||
dx_calls=[source_alert["CallSign"].upper()],
|
||||
freqs_modes=source_alert["Freq"] + " " + source_alert["MODE"],
|
||||
comment=source_alert["Comments"],
|
||||
sig_refs=sigrefs,
|
||||
start_time=start_time,
|
||||
is_dxpedition=False)
|
||||
alert = Alert(
|
||||
source=self.name,
|
||||
source_id=source_alert["alID"],
|
||||
dx_calls=[source_alert["CallSign"].upper()],
|
||||
freqs_modes=f"{source_alert['Freq']} {source_alert['MODE']}",
|
||||
comment=source_alert["Comments"],
|
||||
sig_refs=sigrefs,
|
||||
start_time=start_time,
|
||||
alert_type=AlertType.XOTA,
|
||||
)
|
||||
|
||||
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before
|
||||
if sig and sig not in ["POTA", "SOTA", "WWFF", "SIOTA", "ZLOTA", "KRMNPA", "SANPCPA", "LLOTA", "QRP"]:
|
||||
logging.warning("PNP alert found with sig " + sig + ", developer needs to add support for this!")
|
||||
if sig and sig not in [
|
||||
"POTA",
|
||||
"SOTA",
|
||||
"WWFF",
|
||||
"HEMA",
|
||||
"SIOTA",
|
||||
"ZLOTA",
|
||||
"KRMNPA",
|
||||
"SANPCPA",
|
||||
"LLOTA",
|
||||
"QRP",
|
||||
]:
|
||||
logger.warning(f"PNP alert found with sig {sig}, developer needs to add support for this!")
|
||||
|
||||
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. Otherwise, add to
|
||||
# the alert list. Note that while ZLOTA has its own spots API, it doesn't have its own alerts API. So that
|
||||
|
||||
@@ -2,9 +2,10 @@ from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||
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 POTA(HTTPAlertProvider):
|
||||
@@ -21,18 +22,31 @@ class POTA(HTTPAlertProvider):
|
||||
# Iterate through source data
|
||||
for source_alert in http_response.json():
|
||||
# Convert to our alert format
|
||||
alert = Alert(source=self.name,
|
||||
source_id=source_alert["scheduledActivitiesId"],
|
||||
dx_calls=[source_alert["activator"].upper()],
|
||||
freqs_modes=source_alert["frequencies"],
|
||||
comment=source_alert["comments"],
|
||||
sig_refs=[SIGRef(id=source_alert["reference"], sig="POTA", name=source_alert["name"],
|
||||
url="https://pota.app/#/park/" + source_alert["reference"])],
|
||||
start_time=datetime.strptime(source_alert["startDate"] + source_alert["startTime"],
|
||||
"%Y-%m-%d%H:%M").replace(tzinfo=pytz.UTC).timestamp(),
|
||||
end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"],
|
||||
"%Y-%m-%d%H:%M").replace(tzinfo=pytz.UTC).timestamp(),
|
||||
is_dxpedition=False)
|
||||
alert = Alert(
|
||||
source=self.name,
|
||||
source_id=source_alert["scheduledActivitiesId"],
|
||||
dx_calls=[source_alert["activator"].upper()],
|
||||
freqs_modes=source_alert["frequencies"],
|
||||
comment=source_alert["comments"],
|
||||
sig_refs=[
|
||||
SIGRef(
|
||||
id=source_alert["reference"],
|
||||
sig="POTA",
|
||||
name=source_alert["name"],
|
||||
url=f"https://pota.app/#/park/{source_alert['reference']}",
|
||||
)
|
||||
],
|
||||
start_time=datetime.strptime(
|
||||
source_alert["startDate"] + source_alert["startTime"],
|
||||
"%Y-%m-%d%H:%M",
|
||||
)
|
||||
.replace(tzinfo=pytz.UTC)
|
||||
.timestamp(),
|
||||
end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"], "%Y-%m-%d%H:%M")
|
||||
.replace(tzinfo=pytz.UTC)
|
||||
.timestamp(),
|
||||
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
|
||||
# in the past. Don't worry about de-duping, removing old alerts etc. at this point; other code will do
|
||||
|
||||
@@ -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,9 +2,10 @@ from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||
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 SOTA(HTTPAlertProvider):
|
||||
@@ -26,18 +27,26 @@ class SOTA(HTTPAlertProvider):
|
||||
summit_points = None
|
||||
if len(details) > 2:
|
||||
summit_points = int(details[-1].split(" ")[0])
|
||||
alert = Alert(source=self.name,
|
||||
source_id=source_alert["id"],
|
||||
dx_calls=[source_alert["activatingCallsign"].upper()],
|
||||
dx_names=[source_alert["activatorName"].upper()],
|
||||
freqs_modes=source_alert["frequency"],
|
||||
comment=source_alert["comments"],
|
||||
sig_refs=[
|
||||
SIGRef(id=source_alert["associationCode"] + "/" + source_alert["summitCode"], sig="SOTA",
|
||||
name=summit_name, activation_score=summit_points)],
|
||||
start_time=datetime.strptime(source_alert["dateActivated"],
|
||||
"%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=pytz.UTC).timestamp(),
|
||||
is_dxpedition=False)
|
||||
alert = Alert(
|
||||
source=self.name,
|
||||
source_id=source_alert["id"],
|
||||
dx_calls=[source_alert["activatingCallsign"].upper()],
|
||||
dx_names=[source_alert["activatorName"].upper()],
|
||||
freqs_modes=source_alert["frequency"],
|
||||
comment=source_alert["comments"],
|
||||
sig_refs=[
|
||||
SIGRef(
|
||||
id=f"{source_alert['associationCode']}/{source_alert['summitCode']}",
|
||||
sig="SOTA",
|
||||
name=summit_name,
|
||||
activation_score=summit_points,
|
||||
)
|
||||
],
|
||||
start_time=datetime.strptime(source_alert["dateActivated"], "%Y-%m-%dT%H:%M:%SZ")
|
||||
.replace(tzinfo=pytz.UTC)
|
||||
.timestamp(),
|
||||
alert_type=AlertType.XOTA,
|
||||
)
|
||||
|
||||
# Add to our list
|
||||
new_alerts.append(alert)
|
||||
|
||||
@@ -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
|
||||
@@ -1,13 +1,17 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
from xml.parsers.expat import ExpatError
|
||||
|
||||
import pytz
|
||||
from rss_parser import Parser as RSSParser
|
||||
from rss_parser.models.rss import RSS
|
||||
|
||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||
from data.alert import Alert
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WOTA(HTTPAlertProvider):
|
||||
@@ -22,43 +26,62 @@ class WOTA(HTTPAlertProvider):
|
||||
|
||||
def _http_response_to_alerts(self, http_response):
|
||||
new_alerts = []
|
||||
rss = cast(RSS, RSSParser.parse(http_response.content.decode("utf-8-sig")))
|
||||
# Iterate through source data
|
||||
for source_alert in rss.channel.items:
|
||||
|
||||
# Reject GUID missing or zero
|
||||
if not source_alert.guid or not source_alert.guid.content or source_alert.guid.content == "http://www.wota.org.uk/alerts/0":
|
||||
continue
|
||||
try:
|
||||
rss = cast(RSS, RSSParser.parse(http_response.content.decode("utf-8-sig")))
|
||||
# Iterate through source data
|
||||
for source_alert in rss.channel.items:
|
||||
# Reject GUID missing or zero
|
||||
if (
|
||||
not source_alert.guid
|
||||
or not source_alert.guid.content
|
||||
or source_alert.guid.content == "http://www.wota.org.uk/alerts/0"
|
||||
):
|
||||
continue
|
||||
|
||||
# Pick apart the title
|
||||
title_split = source_alert.title.split(" on ")
|
||||
dx_call = title_split[0]
|
||||
ref = None
|
||||
ref_name = None
|
||||
if len(title_split) > 1:
|
||||
ref_split = title_split[1].split(" - ")
|
||||
ref = str(ref_split[0])
|
||||
if len(ref_split) > 1:
|
||||
ref_name = str(ref_split[1])
|
||||
# Pick apart the title
|
||||
dx_call = None
|
||||
ref = None
|
||||
ref_name = None
|
||||
try:
|
||||
title_split = source_alert.title.split(" on ")
|
||||
dx_call = title_split[0]
|
||||
if len(title_split) > 1:
|
||||
ref_split = title_split[1].split(" - ")
|
||||
ref = str(ref_split[0])
|
||||
if len(ref_split) > 1:
|
||||
ref_name = str(ref_split[1])
|
||||
except Exception:
|
||||
logger.warning(f"Could not parse WOTA alert title: {source_alert.description}")
|
||||
|
||||
# Pick apart the description
|
||||
desc_split = source_alert.description.split(". ")
|
||||
freqs_modes = desc_split[0].replace("Frequencies/modes:", "").strip()
|
||||
comment = None
|
||||
if len(desc_split) > 1:
|
||||
comment = desc_split[1].strip()
|
||||
# Pick apart the description
|
||||
comment = None
|
||||
freqs_modes = None
|
||||
try:
|
||||
desc_split = source_alert.description.split(". ")
|
||||
freqs_modes = desc_split[0].replace("Frequencies/modes:", "").strip()
|
||||
if len(desc_split) > 1:
|
||||
comment = desc_split[1].strip()
|
||||
except Exception:
|
||||
logger.warning(f"Could not parse WOTA alert description: {source_alert.description}")
|
||||
|
||||
time = datetime.strptime(source_alert.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
|
||||
time = datetime.strptime(source_alert.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
|
||||
|
||||
# Convert to our alert format
|
||||
alert = Alert(source=self.name,
|
||||
source_id=source_alert.guid.content,
|
||||
dx_calls=[dx_call],
|
||||
freqs_modes=freqs_modes,
|
||||
comment=comment,
|
||||
sig_refs=[SIGRef(id=ref, sig="WOTA", name=ref_name)] if ref else [],
|
||||
start_time=time.timestamp())
|
||||
# Convert to our alert format
|
||||
alert = Alert(
|
||||
source=self.name,
|
||||
source_id=source_alert.guid.content,
|
||||
dx_calls=[dx_call],
|
||||
freqs_modes=freqs_modes,
|
||||
comment=comment,
|
||||
sig_refs=[SIGRef(id=ref, sig="WOTA", name=ref_name)] if ref else [],
|
||||
start_time=time.timestamp(),
|
||||
)
|
||||
|
||||
# Add to our list.
|
||||
new_alerts.append(alert)
|
||||
|
||||
except ExpatError:
|
||||
logger.warning("WOTA alert RSS feed was fetched but was invalid")
|
||||
|
||||
# Add to our list.
|
||||
new_alerts.append(alert)
|
||||
return new_alerts
|
||||
|
||||
@@ -2,9 +2,10 @@ from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||
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 WWFF(HTTPAlertProvider):
|
||||
@@ -21,17 +22,21 @@ class WWFF(HTTPAlertProvider):
|
||||
# Iterate through source data
|
||||
for source_alert in http_response.json():
|
||||
# Convert to our alert format
|
||||
alert = Alert(source=self.name,
|
||||
source_id=source_alert["id"],
|
||||
dx_calls=[source_alert["activator_call"].upper()],
|
||||
freqs_modes=source_alert["band"] + " " + source_alert["mode"],
|
||||
comment=source_alert["remarks"],
|
||||
sig_refs=[SIGRef(id=source_alert["reference"], sig="WWFF")],
|
||||
start_time=datetime.strptime(source_alert["utc_start"],
|
||||
"%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp(),
|
||||
end_time=datetime.strptime(source_alert["utc_end"],
|
||||
"%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp(),
|
||||
is_dxpedition=False)
|
||||
alert = Alert(
|
||||
source=self.name,
|
||||
source_id=source_alert["id"],
|
||||
dx_calls=[source_alert["activator_call"].upper()],
|
||||
freqs_modes=f"{source_alert['band']} {source_alert['mode']}",
|
||||
comment=source_alert["remarks"],
|
||||
sig_refs=[SIGRef(id=source_alert["reference"], sig="WWFF")],
|
||||
start_time=datetime.strptime(source_alert["utc_start"], "%Y-%m-%d %H:%M:%S")
|
||||
.replace(tzinfo=pytz.UTC)
|
||||
.timestamp(),
|
||||
end_time=datetime.strptime(source_alert["utc_end"], "%Y-%m-%d %H:%M:%S")
|
||||
.replace(tzinfo=pytz.UTC)
|
||||
.timestamp(),
|
||||
alert_type=AlertType.XOTA,
|
||||
)
|
||||
|
||||
# Add to our list
|
||||
new_alerts.append(alert)
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from threading import Thread, Event
|
||||
|
||||
import pytz
|
||||
from requests import ReadTimeout
|
||||
from requests.exceptions import ConnectionError, ConnectTimeout
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from core.url_data_cache import URLDataCache
|
||||
from providers.callsigndata.callsign_data_provider import CallsignDataProvider
|
||||
|
||||
|
||||
@@ -15,7 +5,7 @@ class APIQueryCallsignDataProvider(CallsignDataProvider):
|
||||
"""Generic callsign data provider class for providers that fetch their data from the web on-demand using an API."""
|
||||
|
||||
def __init__(self, name, provider_config, storage):
|
||||
""" Set up the provider."""
|
||||
"""Set up the provider."""
|
||||
super().__init__(name, provider_config, storage)
|
||||
|
||||
if self.enabled:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
@@ -43,7 +42,13 @@ class CallsignDataProvider:
|
||||
|
||||
if self.enabled:
|
||||
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:
|
||||
c = self._perform_new_lookup(callsign, lookup_credentials)
|
||||
if c:
|
||||
@@ -52,7 +57,6 @@ class CallsignDataProvider:
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def _perform_new_lookup(self, callsign, lookup_credentials):
|
||||
"""Makes a new request to the data source for callsign data."""
|
||||
|
||||
|
||||
@@ -2,12 +2,16 @@ import logging
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
from pyhamtools import LookupLib, Callinfo
|
||||
from pyhamtools import Callinfo, LookupLib
|
||||
|
||||
from core.data_store import DATA_STORE
|
||||
from core.utils import get_callsign_object_from_pyhamtools_callinfo
|
||||
from data.callsign import Callsign
|
||||
from providers.callsigndata.api_query_callsign_data_provider import APIQueryCallsignDataProvider
|
||||
from providers.callsigndata.api_query_callsign_data_provider import (
|
||||
APIQueryCallsignDataProvider,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClublogAPI(APIQueryCallsignDataProvider):
|
||||
@@ -23,12 +27,12 @@ class ClublogAPI(APIQueryCallsignDataProvider):
|
||||
self._callinfo = Callinfo(lookuplib)
|
||||
else:
|
||||
provider_config["enabled"] = False
|
||||
logging.warning(
|
||||
"Clublog XML callsign data provider configured but no api key was provided, this has been disabled.")
|
||||
logger.warning(
|
||||
"Clublog XML callsign data provider configured but no api key was provided, this has been disabled."
|
||||
)
|
||||
|
||||
super().__init__("Clublog API", provider_config, DATA_STORE.callsign_data_clublogapi)
|
||||
|
||||
|
||||
def _perform_new_lookup(self, callsign, lookup_credentials):
|
||||
callsign_data = Callsign(call=callsign)
|
||||
|
||||
@@ -41,8 +45,8 @@ class ClublogAPI(APIQueryCallsignDataProvider):
|
||||
else:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.error("Exception when looking up data from Clublog API", e, exc_info=True)
|
||||
logger.exception("Exception when looking up data from Clublog API")
|
||||
|
||||
return callsign_data
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import gzip
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
from pyhamtools import LookupLib, Callinfo
|
||||
from pyhamtools import Callinfo, LookupLib
|
||||
|
||||
from core.data_store import DATA_STORE
|
||||
from core.utils import get_callsign_object_from_pyhamtools_callinfo
|
||||
from data.callsign import Callsign
|
||||
from providers.callsigndata.file_download_callsign_data_provider import FileDownloadCallsignDataProvider
|
||||
from providers.callsigndata.file_download_callsign_data_provider import (
|
||||
FileDownloadCallsignDataProvider,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClublogXML(FileDownloadCallsignDataProvider):
|
||||
@@ -25,11 +27,18 @@ class ClublogXML(FileDownloadCallsignDataProvider):
|
||||
self._api_key = provider_config.get("api_key", "")
|
||||
if self._api_key == "":
|
||||
provider_config["enabled"] = False
|
||||
logging.warning(
|
||||
"Clublog XML callsign data provider configured but no api key was provided, this has been disabled.")
|
||||
logger.warning(
|
||||
"Clublog XML callsign data provider configured but no api key was provided, this has been disabled."
|
||||
)
|
||||
|
||||
super().__init__("Clublog XML", provider_config, self.DATA_URL + "?api=" + self._api_key,
|
||||
self.CACHE_PATH_ZIPPED, self.POLL_INTERVAL_DAYS, DATA_STORE.callsign_data_clublogxml)
|
||||
super().__init__(
|
||||
"Clublog XML",
|
||||
provider_config,
|
||||
f"{self.DATA_URL}?api={self._api_key}",
|
||||
self.CACHE_PATH_ZIPPED,
|
||||
self.POLL_INTERVAL_DAYS,
|
||||
DATA_STORE.callsign_data_clublogxml,
|
||||
)
|
||||
|
||||
def _handle_file(self, path):
|
||||
try:
|
||||
@@ -47,8 +56,8 @@ class ClublogXML(FileDownloadCallsignDataProvider):
|
||||
self._callinfo = Callinfo(lookuplib)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logging.error("Exception when loading Clublog XML.", e, exc_info=True)
|
||||
except Exception:
|
||||
logger.exception("Exception when loading Clublog XML.")
|
||||
return False
|
||||
|
||||
def _perform_new_lookup(self, callsign, lookup_credentials):
|
||||
@@ -62,8 +71,8 @@ class ClublogXML(FileDownloadCallsignDataProvider):
|
||||
else:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.error("Exception when looking up data from Clublog XML data", e, exc_info=True)
|
||||
logger.exception("Exception when looking up data from Clublog XML data")
|
||||
|
||||
return callsign_data
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import logging
|
||||
|
||||
from pyhamtools import LookupLib, Callinfo
|
||||
from pyhamtools import Callinfo, LookupLib
|
||||
|
||||
from core.data_store import DATA_STORE
|
||||
from core.utils import get_callsign_object_from_pyhamtools_callinfo
|
||||
from data.callsign import Callsign
|
||||
from providers.callsigndata.file_download_callsign_data_provider import FileDownloadCallsignDataProvider
|
||||
from providers.callsigndata.file_download_callsign_data_provider import (
|
||||
FileDownloadCallsignDataProvider,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CountryFiles(FileDownloadCallsignDataProvider):
|
||||
@@ -17,8 +21,14 @@ class CountryFiles(FileDownloadCallsignDataProvider):
|
||||
_callinfo = None
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__("CountryFiles.com", provider_config, self.DATA_URL, self.CACHE_PATH, self.POLL_INTERVAL_DAYS,
|
||||
DATA_STORE.callsign_data_countryfiles)
|
||||
super().__init__(
|
||||
"CountryFiles.com",
|
||||
provider_config,
|
||||
self.DATA_URL,
|
||||
self.CACHE_PATH,
|
||||
self.POLL_INTERVAL_DAYS,
|
||||
DATA_STORE.callsign_data_countryfiles,
|
||||
)
|
||||
|
||||
def _handle_file(self, path):
|
||||
try:
|
||||
@@ -26,8 +36,8 @@ class CountryFiles(FileDownloadCallsignDataProvider):
|
||||
self._callinfo = Callinfo(lookuplib)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logging.error("Exception when loading Country Files cty.plist.", e, exc_info=True)
|
||||
except Exception:
|
||||
logger.exception("Exception when loading Country Files cty.plist.")
|
||||
return False
|
||||
|
||||
def _perform_new_lookup(self, callsign, lookup_credentials):
|
||||
@@ -41,8 +51,8 @@ class CountryFiles(FileDownloadCallsignDataProvider):
|
||||
else:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.error("Exception when looking up data from Country file", e, exc_info=True)
|
||||
logger.exception("Exception when looking up data from Country file")
|
||||
|
||||
return callsign_data
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from threading import Thread, Event
|
||||
from threading import Event, Thread
|
||||
|
||||
import pytz
|
||||
from requests import ReadTimeout
|
||||
from requests.exceptions import ConnectionError, ConnectTimeout
|
||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from core.url_data_cache import URLDataCache
|
||||
from providers.callsigndata.callsign_data_provider import CallsignDataProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileDownloadCallsignDataProvider(CallsignDataProvider):
|
||||
"""Generic callsign data provider class for providers that fetch their data from the web by downloading a file."""
|
||||
|
||||
def __init__(self, name, provider_config, url, cache_file_path, poll_interval, storage):
|
||||
""" Set up the provider, note poll_interval is in *days*."""
|
||||
"""Set up the provider, note poll_interval is in *days*."""
|
||||
super().__init__(name, provider_config, storage)
|
||||
self._url = url
|
||||
self._cache_file_path = cache_file_path
|
||||
self._poll_interval = poll_interval
|
||||
self._thread = None
|
||||
self._stop_event = Event()
|
||||
self._url_data_cache = URLDataCache("callsigndata_" + name)
|
||||
self._url_data_cache = URLDataCache(f"callsigndata_{name}")
|
||||
|
||||
if self.enabled:
|
||||
self.status = "Ready"
|
||||
@@ -30,13 +31,16 @@ class FileDownloadCallsignDataProvider(CallsignDataProvider):
|
||||
def start(self):
|
||||
# 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.
|
||||
logging.info(
|
||||
"Set up query of " + self.name + " callsign reference data every " + str(self._poll_interval) + " days.")
|
||||
self._thread = Thread(target=self._run, name=f"FileDownloadCallsignDataProvider-{self.name}")
|
||||
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}", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
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):
|
||||
while True:
|
||||
@@ -48,7 +52,7 @@ class FileDownloadCallsignDataProvider(CallsignDataProvider):
|
||||
try:
|
||||
# Request the file. Use the data cache (with a TTL of 1 day) here, not as the main mechanism for
|
||||
# caching, but just so continual restarts of the software during testing don't hammer the servers.
|
||||
logging.debug("Downloading " + self.name + " callsign reference data...")
|
||||
logger.debug(f"Downloading {self.name} callsign reference data...")
|
||||
http_response = self._url_data_cache.get(self._url, headers=HTTP_HEADERS)
|
||||
# Check response code was good
|
||||
if http_response.ok:
|
||||
@@ -61,24 +65,26 @@ class FileDownloadCallsignDataProvider(CallsignDataProvider):
|
||||
if ok:
|
||||
self.status = "OK"
|
||||
self.last_update_time = datetime.now(pytz.UTC)
|
||||
logging.info("Updated callsign reference data from " + self.name)
|
||||
logger.info(f"Updated callsign reference data from {self.name}")
|
||||
else:
|
||||
self.status = "Error"
|
||||
logging.warning(f"Error updating callsign reference data from {self.name}.")
|
||||
logger.warning(f"Error updating callsign reference data from {self.name}.")
|
||||
|
||||
else:
|
||||
self.status = "Error"
|
||||
logging.warning(f"HTTP {http_response.status_code} when downloading callsign reference data from {self.name}.")
|
||||
logger.warning(
|
||||
f"HTTP {http_response.status_code} when downloading callsign reference data from {self.name}."
|
||||
)
|
||||
|
||||
except ConnectionError:
|
||||
self.status = "Error"
|
||||
logging.warning(f"Connection error when downloading callsign reference data from {self.name}.")
|
||||
logger.warning(f"Connection error when downloading callsign reference data from {self.name}.")
|
||||
except (ConnectTimeout, ReadTimeout):
|
||||
self.status = "Error"
|
||||
logging.warning(f"Timeout when downloading callsign reference data from {self.name}.")
|
||||
logger.warning(f"Timeout when downloading callsign reference data from {self.name}.")
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.exception("Exception in callsign reference data provider (" + self.name + ")")
|
||||
logger.exception(f"Exception in callsign reference data provider ({self.name})")
|
||||
self._stop_event.wait(timeout=1)
|
||||
|
||||
def _handle_file(self, path):
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import logging
|
||||
import urllib.parse
|
||||
from datetime import timedelta, datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytz
|
||||
import xmltodict
|
||||
from pyhamtools import callinfo
|
||||
from requests import ConnectTimeout, ReadTimeout
|
||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
||||
from requests_cache import CachedSession
|
||||
|
||||
from core.config import SERVER_OWNER_CALLSIGN
|
||||
from core.constants import HTTP_HEADERS, SOFTWARE_VERSION
|
||||
from core.data_store import DATA_STORE, CACHE_DIR
|
||||
from core.data_store import CACHE_DIR, DATA_STORE
|
||||
from core.enums import Continent
|
||||
from core.url_data_cache import URLDataCache
|
||||
from data.callsign import Callsign
|
||||
from providers.callsigndata.api_query_callsign_data_provider import APIQueryCallsignDataProvider
|
||||
from data.callsign import Callsign, LocationSourceForCallsign
|
||||
from providers.callsigndata.api_query_callsign_data_provider import (
|
||||
APIQueryCallsignDataProvider,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HamQTH(APIQueryCallsignDataProvider):
|
||||
@@ -22,18 +27,19 @@ class HamQTH(APIQueryCallsignDataProvider):
|
||||
def __init__(self, provider_config):
|
||||
super().__init__("HamQTH", provider_config, DATA_STORE.callsign_data_hamqth)
|
||||
self._HAMQTH_BASE_URL = "https://www.hamqth.com/xml.php"
|
||||
self._PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_CALLSIGN).replace(" ", "_")
|
||||
self._PRG = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}".replace(" ", "_")
|
||||
self._URL_DATA_CACHE = URLDataCache("hamqth")
|
||||
# Separate URL cache for session key lookups. Once a session key is returned from logging in with a username
|
||||
# and password, this is valid for an hour, so our cache stores this specifically for 55 minutes.
|
||||
self._CREDENTIALS_CACHE = CachedSession(CACHE_DIR + "/urls/hamqth-creds",
|
||||
expire_after=timedelta(minutes=55))
|
||||
self._CREDENTIALS_CACHE = CachedSession(f"{CACHE_DIR}/urls/hamqth-creds", expire_after=timedelta(minutes=55))
|
||||
|
||||
def _perform_new_lookup(self, callsign, lookup_credentials):
|
||||
# If we don't have HamQTH credentials, skip this lookup Return None so we don't *cache* the lack of data, because
|
||||
# # someone might provide credentials next time around.
|
||||
if not lookup_credentials or not ((lookup_credentials.hamqth_username and lookup_credentials.hamqth_password)
|
||||
or lookup_credentials.hamqth_session_id):
|
||||
if not lookup_credentials or not (
|
||||
(lookup_credentials.hamqth_username and lookup_credentials.hamqth_password)
|
||||
or lookup_credentials.hamqth_session_id
|
||||
):
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -44,18 +50,18 @@ class HamQTH(APIQueryCallsignDataProvider):
|
||||
elif lookup_credentials.hamqth_username and lookup_credentials.hamqth_password:
|
||||
try:
|
||||
session_data = self._CREDENTIALS_CACHE.get(
|
||||
self._HAMQTH_BASE_URL + "?u=" + urllib.parse.quote_plus(lookup_credentials.hamqth_username) +
|
||||
"&p=" + urllib.parse.quote_plus(lookup_credentials.hamqth_password),
|
||||
headers=HTTP_HEADERS).content
|
||||
f"{self._HAMQTH_BASE_URL}?u={urllib.parse.quote_plus(lookup_credentials.hamqth_username)}&p={urllib.parse.quote_plus(lookup_credentials.hamqth_password)}",
|
||||
headers=HTTP_HEADERS,
|
||||
).content
|
||||
dict_data = xmltodict.parse(session_data)
|
||||
if "session_id" in dict_data["HamQTH"]["session"]:
|
||||
session_id = str(dict_data["HamQTH"]["session"]["session_id"])
|
||||
else:
|
||||
# Log this failure at debug level only, not our problem if user entered the wrong password.
|
||||
logging.debug("HamQTH login details incorrect, failed to look up with HamQTH.")
|
||||
logger.debug("HamQTH login details incorrect, failed to look up with HamQTH.")
|
||||
return None
|
||||
except Exception:
|
||||
logging.error("Exception when getting HamQTH session key")
|
||||
logger.error("Exception when getting HamQTH session key")
|
||||
return None
|
||||
|
||||
if not session_id:
|
||||
@@ -68,14 +74,16 @@ class HamQTH(APIQueryCallsignDataProvider):
|
||||
if home_call != callsign:
|
||||
calls_to_try.append(home_call)
|
||||
except ValueError:
|
||||
logging.debug("Could not look up home call for callsign %s", callsign)
|
||||
logger.debug(f"Could not look up home call for callsign {callsign}")
|
||||
|
||||
# Try looking up each call using the API
|
||||
for lookup_call in calls_to_try:
|
||||
try:
|
||||
response = self._URL_DATA_CACHE.get(
|
||||
self._HAMQTH_BASE_URL + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
|
||||
lookup_call) + "&prg=" + self._PRG, headers=HTTP_HEADERS, timeout=10)
|
||||
f"{self._HAMQTH_BASE_URL}?id={session_id}&callsign={urllib.parse.quote_plus(lookup_call)}&prg={self._PRG}",
|
||||
headers=HTTP_HEADERS,
|
||||
timeout=10,
|
||||
)
|
||||
if response.ok:
|
||||
# Found data, convert it to our object and return it
|
||||
data = xmltodict.parse(response.content)["HamQTH"]["search"]
|
||||
@@ -85,27 +93,26 @@ class HamQTH(APIQueryCallsignDataProvider):
|
||||
return self.hamqth_response_to_callsign(callsign, data)
|
||||
|
||||
elif not response.from_cache:
|
||||
logging.warning("HTTP %d looking up callsign %s using HamQTH", response.status_code,
|
||||
lookup_call)
|
||||
logger.warning(f"HTTP {response.status_code} looking up callsign {lookup_call} using HamQTH")
|
||||
|
||||
except (KeyError, ValueError):
|
||||
continue
|
||||
except ConnectionError:
|
||||
logging.warning(f"Connection error when looking up callsign %s using HamQTH", lookup_call)
|
||||
logger.warning(f"Connection error when looking up callsign {lookup_call} using HamQTH")
|
||||
continue
|
||||
except (ConnectTimeout, ReadTimeout):
|
||||
logging.warning(f"Timeout when looking up callsign %s using HamQTH", lookup_call)
|
||||
logger.warning(f"Timeout when looking up callsign {lookup_call} using HamQTH")
|
||||
continue
|
||||
except Exception:
|
||||
logging.error("Exception when looking up callsign %s using HamQTH", lookup_call, exc_info=True)
|
||||
logger.exception(f"Exception when looking up callsign {lookup_call} using HamQTH")
|
||||
continue
|
||||
|
||||
# Not found in HamQTH; return a Callsign object with no data so we cache that and don't keep retrying
|
||||
return Callsign(call=callsign)
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.error("Exception when looking up data from HamQTH", e, exc_info=True)
|
||||
logger.exception("Exception when looking up data from HamQTH")
|
||||
# Return None, this won't be cached so we will be asked to query data again for this call next time.
|
||||
return None
|
||||
|
||||
@@ -116,27 +123,32 @@ class HamQTH(APIQueryCallsignDataProvider):
|
||||
# Check for sensible latitudes
|
||||
lat = None
|
||||
lon = None
|
||||
if "latitude" in data and "longitude" in data and (
|
||||
float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float(
|
||||
data["latitude"]) < 89.9:
|
||||
if (
|
||||
data.get("latitude") is not None
|
||||
and data.get("longitude") is not None
|
||||
and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0)
|
||||
and -89.9 < float(data["latitude"]) < 89.9
|
||||
):
|
||||
lat = float(data["latitude"])
|
||||
lon = float(data["longitude"])
|
||||
|
||||
# Check for sensible grids
|
||||
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"]
|
||||
|
||||
return Callsign(call=callsign,
|
||||
home_call=callinfo.Callinfo.get_homecall(callsign),
|
||||
name=data["nick"] if "nick" in data else None,
|
||||
qth=data["qth"] if "qth" in data else None,
|
||||
country=data["country"] if "country" in data else None,
|
||||
continent=data["continent"] if "continent" in data else None,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
grid=grid,
|
||||
dxcc_id=int(data["adif"]) if "adif" in data else None,
|
||||
cq_zone=int(data["cq"]) if "cq" in data else None,
|
||||
itu_zone=int(data["itu"]) if "itu" in data else None,
|
||||
location_source="HOME QTH")
|
||||
return Callsign(
|
||||
call=callsign,
|
||||
home_call=callinfo.Callinfo.get_homecall(callsign),
|
||||
name=data.get("nick", None),
|
||||
qth=data.get("qth", None),
|
||||
country=data.get("country", None),
|
||||
continent=Continent(data["continent"]) if "continent" in data else None,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
grid=grid,
|
||||
dxcc_id=int(data["adif"]) if data.get("adif") is not None else None,
|
||||
cq_zone=int(data["cq"]) if data.get("cq") is not None else None,
|
||||
itu_zone=int(data["itu"]) if data.get("itu") is not None else None,
|
||||
location_source=LocationSourceForCallsign.HOME_QTH,
|
||||
)
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import logging
|
||||
import urllib.parse
|
||||
from datetime import timedelta, datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytz
|
||||
import xmltodict
|
||||
from pyhamtools import callinfo
|
||||
from requests import ConnectTimeout, ReadTimeout
|
||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
||||
from requests_cache import CachedSession
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from core.data_store import DATA_STORE, CACHE_DIR
|
||||
from core.data_store import CACHE_DIR, DATA_STORE
|
||||
from core.enums import Continent, LocationSourceForCallsign
|
||||
from core.url_data_cache import URLDataCache
|
||||
from data.callsign import Callsign
|
||||
from providers.callsigndata.api_query_callsign_data_provider import APIQueryCallsignDataProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QRZ(APIQueryCallsignDataProvider):
|
||||
"""Callsign data provider for QRZ.com."""
|
||||
@@ -24,14 +27,14 @@ class QRZ(APIQueryCallsignDataProvider):
|
||||
self._URL_DATA_CACHE = URLDataCache("qrz")
|
||||
# Separate URL cache for session key lookups. Once a session key is returned from logging in with a username
|
||||
# and password, this is valid for an hour, so our cache stores this specifically for 55 minutes.
|
||||
self._CREDENTIALS_CACHE = CachedSession(CACHE_DIR + "/urls/qrz-creds",
|
||||
expire_after=timedelta(minutes=55))
|
||||
self._CREDENTIALS_CACHE = CachedSession(f"{CACHE_DIR}/urls/qrz-creds", expire_after=timedelta(minutes=55))
|
||||
|
||||
def _perform_new_lookup(self, callsign, lookup_credentials):
|
||||
# If we don't have QRZ credentials, skip this lookup. Return None so we don't *cache* the lack of data, because
|
||||
# someone might provide credentials next time around.
|
||||
if not lookup_credentials or not ((lookup_credentials.qrz_username and lookup_credentials.qrz_password)
|
||||
or lookup_credentials.qrz_session_key):
|
||||
if not lookup_credentials or not (
|
||||
(lookup_credentials.qrz_username and lookup_credentials.qrz_password) or lookup_credentials.qrz_session_key
|
||||
):
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -42,19 +45,19 @@ class QRZ(APIQueryCallsignDataProvider):
|
||||
elif lookup_credentials.qrz_username and lookup_credentials.qrz_password:
|
||||
try:
|
||||
login_response = self._CREDENTIALS_CACHE.get(
|
||||
self._QRZ_BASE_URL + "?username=" + urllib.parse.quote_plus(lookup_credentials.qrz_username) +
|
||||
"&password=" + urllib.parse.quote_plus(lookup_credentials.qrz_password) + "&agent=spothole",
|
||||
headers=HTTP_HEADERS).content
|
||||
f"{self._QRZ_BASE_URL}?username={urllib.parse.quote_plus(lookup_credentials.qrz_username)}&password={urllib.parse.quote_plus(lookup_credentials.qrz_password)}&agent=spothole",
|
||||
headers=HTTP_HEADERS,
|
||||
).content
|
||||
login_data = xmltodict.parse(login_response)
|
||||
session = login_data.get("QRZDatabase", {}).get("Session", {})
|
||||
if "Key" in session:
|
||||
session_key = str(session["Key"])
|
||||
else:
|
||||
# Log this failure at debug level only, not our problem if user entered the wrong password.
|
||||
logging.debug("QRZ.com login details incorrect, failed to look up with QRZ.")
|
||||
logger.debug("QRZ.com login details incorrect, failed to look up with QRZ.")
|
||||
return None
|
||||
except Exception:
|
||||
logging.error("Exception when getting QRZ.com session key")
|
||||
logger.error("Exception when getting QRZ.com session key")
|
||||
return None
|
||||
|
||||
if not session_key:
|
||||
@@ -67,14 +70,16 @@ class QRZ(APIQueryCallsignDataProvider):
|
||||
if home_call != callsign:
|
||||
calls_to_try.append(home_call)
|
||||
except ValueError:
|
||||
logging.debug("Could not look up home call for callsign %s", callsign)
|
||||
logger.debug(f"Could not look up home call for callsign {callsign}")
|
||||
|
||||
# Try looking up each call using the API
|
||||
for lookup_call in calls_to_try:
|
||||
try:
|
||||
response = self._URL_DATA_CACHE.get(
|
||||
self._QRZ_BASE_URL + "?s=" + session_key + "&callsign=" + urllib.parse.quote_plus(lookup_call),
|
||||
headers=HTTP_HEADERS, timeout=10)
|
||||
f"{self._QRZ_BASE_URL}?s={session_key}&callsign={urllib.parse.quote_plus(lookup_call)}",
|
||||
headers=HTTP_HEADERS,
|
||||
timeout=10,
|
||||
)
|
||||
if response.ok:
|
||||
qrz_response = xmltodict.parse(response.content).get("QRZDatabase", {})
|
||||
if qrz_response:
|
||||
@@ -89,32 +94,33 @@ class QRZ(APIQueryCallsignDataProvider):
|
||||
elif "Session" in qrz_response and "Error" in qrz_response.get("Session"):
|
||||
# Errors here are normally just "callsign not in database", no need to log that ourselves
|
||||
# above debug level.
|
||||
logging.debug("QRZ returned an error looking up callsign %s: %s", lookup_call,
|
||||
qrz_response.get("Session").get("Error"))
|
||||
logger.debug(
|
||||
f"QRZ returned an error looking up callsign {lookup_call}: {qrz_response.get('Session').get('Error')}"
|
||||
)
|
||||
|
||||
elif not response.from_cache:
|
||||
logging.warning("QRZ returned a malformed response looking up callsign %s", lookup_call)
|
||||
logger.warning(f"QRZ returned a malformed response looking up callsign {lookup_call}")
|
||||
elif not response.from_cache:
|
||||
logging.warning("HTTP %d looking up callsign %s using QRZ", lookup_call)
|
||||
logger.warning(f"HTTP {response.status_code} looking up callsign {lookup_call} using QRZ")
|
||||
|
||||
except (KeyError, ValueError):
|
||||
continue
|
||||
except ConnectionError:
|
||||
logging.warning(f"Connection error when looking up callsign %s using QRZ", lookup_call)
|
||||
logger.warning(f"Connection error when looking up callsign {lookup_call} using QRZ")
|
||||
continue
|
||||
except (ConnectTimeout, ReadTimeout):
|
||||
logging.warning(f"Timeout when looking up callsign %s using QRZ.", lookup_call)
|
||||
logger.warning(f"Timeout when looking up callsign {lookup_call} using QRZ.")
|
||||
continue
|
||||
except Exception:
|
||||
logging.error("Exception when looking up callsign %s using QRZ", lookup_call, exc_info=True)
|
||||
logger.exception(f"Exception when looking up callsign {lookup_call} using QRZ")
|
||||
continue
|
||||
|
||||
# Not found in QRZ; return a Callsign object with no data so we cache that and don't keep retrying
|
||||
return Callsign(call=callsign)
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.error("Exception when looking up data from QRZ.com", e, exc_info=True)
|
||||
logger.exception("Exception when looking up data from QRZ.com")
|
||||
# Return None, this won't be cached so we will be asked to query data again for this call next time.
|
||||
return None
|
||||
|
||||
@@ -122,6 +128,13 @@ class QRZ(APIQueryCallsignDataProvider):
|
||||
def qrz_response_to_callsign(callsign, data):
|
||||
"""Convert the "Callsign" block in QRZ's API response to our own Callsign object."""
|
||||
|
||||
# I have encountered a user passing multiple callsigns to the QRZ lookup function in a way that QRZ actually
|
||||
# does handle, and returns a list of callsign data. If we encounter that, just take the first one, as our own
|
||||
# functions can't deal with multiple calls this way.
|
||||
if isinstance(data, list):
|
||||
data = data[0]
|
||||
callsign = data["call"]
|
||||
|
||||
# Get a name
|
||||
name = None
|
||||
if "name_fmt" in data:
|
||||
@@ -129,34 +142,39 @@ class QRZ(APIQueryCallsignDataProvider):
|
||||
if "fname" in data:
|
||||
name = data["fname"]
|
||||
if "nick" in data:
|
||||
name = name + " \"" + data["nick"] + "\""
|
||||
name = f'{name} "{data["nick"]}"'
|
||||
if "name" in data:
|
||||
name = name + " " + data["name"]
|
||||
name = f"{name} {data['name']}"
|
||||
|
||||
# Check for sensible latitudes
|
||||
lat = None
|
||||
lon = None
|
||||
if "latitude" in data and "longitude" in data and (
|
||||
float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float(
|
||||
data["latitude"]) < 89.9:
|
||||
if (
|
||||
data.get("latitude") is not None
|
||||
and data.get("longitude") is not None
|
||||
and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0)
|
||||
and -89.9 < float(data["latitude"]) < 89.9
|
||||
):
|
||||
lat = float(data["latitude"])
|
||||
lon = float(data["longitude"])
|
||||
|
||||
# Check for sensible grids
|
||||
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"]
|
||||
|
||||
return Callsign(call=callsign,
|
||||
home_call=callinfo.Callinfo.get_homecall(callsign),
|
||||
name=name,
|
||||
qth=data["addr2"] if "addr2" in data else None,
|
||||
country=data["country"] if "country" in data else None,
|
||||
continent=data["continent"] if "continent" in data else None,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
grid=grid,
|
||||
dxcc_id=int(data["adif"]) if "adif" in data else None,
|
||||
cq_zone=int(data["cqzone"]) if "cqzone" in data else None,
|
||||
itu_zone=int(data["ituzone"]) if "ituzone" in data else None,
|
||||
location_source="HOME QTH")
|
||||
return Callsign(
|
||||
call=callsign,
|
||||
home_call=callinfo.Callinfo.get_homecall(callsign),
|
||||
name=name,
|
||||
qth=data.get("addr2", None),
|
||||
country=data.get("country", None),
|
||||
continent=Continent(data["continent"]) if "continent" in data else None,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
grid=grid,
|
||||
dxcc_id=int(data["adif"]) if data.get("adif") is not None else None,
|
||||
cq_zone=int(data["cqzone"]) if data.get("cqzone") is not None else None,
|
||||
itu_zone=int(data["ituzone"]) if data.get("ituzone") is not None else None,
|
||||
location_source=LocationSourceForCallsign.HOME_QTH,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class ARLHS(FileDownloadSIGRefDataProvider):
|
||||
@@ -20,13 +23,18 @@ class ARLHS(FileDownloadSIGRefDataProvider):
|
||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
|
||||
if "ARLHS" in row and row["ARLHS"] != "":
|
||||
ref_id = row["ARLHS"]
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=row["Name"] if "Name" in row else None,
|
||||
url="https://www.cqgma.org/zinfo.php?ref=" + ref_id,
|
||||
latitude=float(row["Latitude"]) if "Latitude" in row and row[
|
||||
"Latitude"] != "" else None,
|
||||
longitude=float(row["Longitude"]) if "Longitude" in row and row[
|
||||
"Longitude"] != "" else None,
|
||||
grid=row["Maidenhead Locator"]))
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("Name", None),
|
||||
ref_type=SIGRefType.LIGHTHOUSE,
|
||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
||||
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
||||
grid=row["Maidenhead Locator"],
|
||||
)
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
from time import sleep
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
|
||||
|
||||
class COTA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Castles on the Air"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "COTA"
|
||||
DATA_URL = "https://www.cotagroup.org/cotagroup/map/data/castles-all-7d90ee2a5e1175e5dece1bbf9dc87504.json"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
data = http_response.json()
|
||||
if isinstance(data, list):
|
||||
for ref in data[2]:
|
||||
ref_id = ref[3]
|
||||
name = ref[4]
|
||||
lat = float(ref[0])
|
||||
lon = float(ref[1])
|
||||
grid = latlong_to_locator(lat, lon)
|
||||
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=name,
|
||||
ref_type=SIGRefType.CASTLE,
|
||||
grid=grid,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
)
|
||||
)
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest
|
||||
# of the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
return new_data
|
||||
@@ -0,0 +1,42 @@
|
||||
import io
|
||||
from time import sleep
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
|
||||
|
||||
class DCE(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Castillos de España"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 365
|
||||
SIG = "DCE"
|
||||
DATA_URL = "https://www.acracb.org/dce/descargas/General/directorio_referencias_dce.xls"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
|
||||
file_stream = io.BytesIO(http_response.content)
|
||||
df = pd.read_excel(file_stream, engine="xlrd", header=None)
|
||||
|
||||
for index, row in df.iterrows():
|
||||
if row.iloc[0] and row.iloc[2]:
|
||||
new_data.append(
|
||||
SIGRef(sig=self.SIG, id=row.iloc[0].strip(), name=row.iloc[2].strip(), ref_type=SIGRefType.CASTLE)
|
||||
)
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||
# the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
return new_data
|
||||
@@ -0,0 +1,46 @@
|
||||
import io
|
||||
from time import sleep
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
|
||||
|
||||
class DEFE(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Estationes de Ferrocarril de España"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 365
|
||||
SIG = "DEFE"
|
||||
DATA_URL = "https://www.acracb.org/defe/descargas/General/directorio_referencias_defe.xls"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
|
||||
file_stream = io.BytesIO(http_response.content)
|
||||
df = pd.read_excel(file_stream, engine="xlrd", header=None)
|
||||
|
||||
for index, row in df.iterrows():
|
||||
# Skip the header row
|
||||
if str(row.iloc[0]) == "NºDEFE":
|
||||
continue
|
||||
|
||||
if row.iloc[0] and row.iloc[1]:
|
||||
new_data.append(
|
||||
SIGRef(sig=self.SIG, id=row.iloc[0].strip(), name=row.iloc[1].strip(), ref_type=SIGRefType.BUILDING)
|
||||
)
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||
# the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
return new_data
|
||||
@@ -3,8 +3,11 @@ from time import sleep
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.local_file_sig_ref_data_provider import LocalFileSIGRefDataProvider
|
||||
from providers.sigrefdata.local_file_sig_ref_data_provider import (
|
||||
LocalFileSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class DME(LocalFileSIGRefDataProvider):
|
||||
@@ -20,23 +23,37 @@ class DME(LocalFileSIGRefDataProvider):
|
||||
new_data = []
|
||||
with open(path, encoding="latin-1") as _f:
|
||||
for row in csv.DictReader(_f, delimiter=";"):
|
||||
ref_id = row["COD_INE"][:5]
|
||||
latitude = float(row["LATITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get(
|
||||
"LATITUD_ETRS89_REGCAN95") else None
|
||||
longitude = float(row["LONGITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get(
|
||||
"LONGITUD_ETRS89_REGCAN95") else None
|
||||
# Store reference IDs with the "DME-" prefix rather than just the number. This will prevent Spothole
|
||||
# from agressively thinking every number in a spot comment is DME after it's seen "DME" once. The only
|
||||
# numbers that count are straight after "DME " or "DME-". The dash versus space is normalised in
|
||||
# sig_lookup_helper.py.
|
||||
ref_id = "DME-" + row["COD_INE"][:5]
|
||||
latitude = (
|
||||
float(row["LATITUD_ETRS89_REGCAN95"].replace(",", "."))
|
||||
if row.get("LATITUD_ETRS89_REGCAN95")
|
||||
else None
|
||||
)
|
||||
longitude = (
|
||||
float(row["LONGITUD_ETRS89_REGCAN95"].replace(",", "."))
|
||||
if row.get("LONGITUD_ETRS89_REGCAN95")
|
||||
else None
|
||||
)
|
||||
|
||||
ref = SIGRef(sig=self.SIG, id=ref_id,
|
||||
name=row["NOMBRE_ACTUAL"] + ", " + row["PROVINCIA"],
|
||||
latitude=latitude,
|
||||
longitude=longitude)
|
||||
ref = SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
ref_type=SIGRefType.TOWN,
|
||||
name=f"{row['NOMBRE_ACTUAL']}, {row['PROVINCIA']}",
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
)
|
||||
if latitude and longitude:
|
||||
ref.grid = latlong_to_locator(latitude, longitude, 6)
|
||||
new_data.append(ref)
|
||||
|
||||
# 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:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import csv
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
|
||||
|
||||
class DMUE(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Museos de España"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 365
|
||||
SIG = "DMUE"
|
||||
DATA_URL = "https://dmue.radiogalena.es/nom_dmue.csv"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
|
||||
for row in csv.reader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"):
|
||||
if len(row) > 1 and row[0] and row[1]:
|
||||
new_data.append(
|
||||
SIGRef(sig=self.SIG, id=row[0].strip(), name=row[1].strip(), ref_type=SIGRefType.BUILDING)
|
||||
)
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||
# the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
return new_data
|
||||
@@ -0,0 +1,50 @@
|
||||
import io
|
||||
from time import sleep
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
|
||||
|
||||
class DMVE(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Monumentos y Vestigios de España"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 365
|
||||
SIG = "DMVE"
|
||||
DATA_URL = "https://www.acracb.org/dmve/descargas/General/directorio_referencias_dmve.xls"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
|
||||
file_stream = io.BytesIO(http_response.content)
|
||||
# Despide the .xls extension this is actually an xlsx file, so we need openpyxl not xlrd
|
||||
df = pd.read_excel(file_stream, engine="openpyxl", header=None)
|
||||
|
||||
for index, row in df.iterrows():
|
||||
ref = row.iloc[0]
|
||||
name = row.iloc[1]
|
||||
|
||||
# Skip the header row and blank rows
|
||||
if str(ref) == "REF.":
|
||||
continue
|
||||
if pd.isna(ref) or pd.isna(name):
|
||||
continue
|
||||
|
||||
if ref and name:
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref.strip(), name=name.strip(), ref_type=SIGRefType.BUILDING))
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||
# the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
return new_data
|
||||
@@ -0,0 +1,35 @@
|
||||
from time import sleep
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
|
||||
|
||||
class DTMBA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Teatri Musei Belle Arti"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "DTMBA"
|
||||
DATA_URL = "https://www.iu1fig.com/share/iz0eik/dtmba/export.php"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
for row in http_response.content.decode("utf-8-sig").splitlines():
|
||||
split = row.split(";")
|
||||
ref_id = split[0]
|
||||
ref_name = split[1]
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=ref_name, ref_type=SIGRefType.BUILDING))
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||
# the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
return new_data
|
||||
@@ -0,0 +1,56 @@
|
||||
from io import BytesIO
|
||||
from time import sleep
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
|
||||
|
||||
class FEA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Diploma Faros de España"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "FEA"
|
||||
DATA_URL = "http://ea5ol.net/Lista%20Faros.pdf"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
|
||||
# Use PDFPlumber to extract the tables in the PDF
|
||||
with pdfplumber.open(BytesIO(http_response.content)) as pdf:
|
||||
all_rows = []
|
||||
|
||||
for page_number, page in enumerate(pdf.pages, start=1):
|
||||
tables = page.extract_tables()
|
||||
|
||||
for table_number, table in enumerate(tables, start=1):
|
||||
if not table:
|
||||
continue
|
||||
|
||||
rows = [row for row in table if any(cell and cell.strip() for cell in row)]
|
||||
all_rows.extend(rows)
|
||||
|
||||
for row in all_rows:
|
||||
if not "REF" in row[0] and not "\n" in row[0]:
|
||||
# FEA references are technically [DE]\-\d{4}(\.\d)? but spotters always seem to miss out the D- or E-
|
||||
# prefix and just use FEA-1234 or FEA 1234, so we add both copies to the database.
|
||||
ref_id_1 = row[0].strip()
|
||||
ref_id_2 = ref_id_1.replace("D-", "FEA-").replace("E-", "FEA-")
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id_1, name=row[1].strip(), ref_type=SIGRefType.LIGHTHOUSE))
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id_2, name=row[1].strip(), ref_type=SIGRefType.LIGHTHOUSE))
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||
# the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
return new_data
|
||||
@@ -1,39 +1,41 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from threading import Thread, Event
|
||||
from threading import Thread
|
||||
|
||||
import pytz
|
||||
from requests import ReadTimeout
|
||||
from requests.exceptions import ConnectionError, ConnectTimeout
|
||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from core.url_data_cache import URLDataCache
|
||||
from providers.sigrefdata.sig_ref_data_provider import SIGRefDataProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
|
||||
"""Generic SIG ref data provider class for providers that fetch their data from the web by downloading a file."""
|
||||
|
||||
def __init__(self, sig_name, provider_config, url, poll_interval):
|
||||
""" Set up the provider, note poll_interval is in *days*."""
|
||||
"""Set up the provider, note poll_interval is in *days*."""
|
||||
super().__init__(sig_name, provider_config)
|
||||
self._url = url
|
||||
self._poll_interval = poll_interval
|
||||
self._thread = None
|
||||
self._stop_event = Event()
|
||||
self._url_data_cache = URLDataCache("sigrefdata_" + sig_name)
|
||||
self._url_data_cache = URLDataCache(f"sigrefdata_{sig_name}")
|
||||
|
||||
def start(self):
|
||||
# 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.
|
||||
logging.info(
|
||||
"Set up query of " + self.sig_name + " SIG ref data every " + str(self._poll_interval) + " days.")
|
||||
self._thread = Thread(target=self._run, name=f"FileDownloadSIGRefDataProvider-{self.sig_name}")
|
||||
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}", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
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):
|
||||
while True:
|
||||
@@ -45,7 +47,7 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
|
||||
try:
|
||||
# Request data from API. Use the data cache (with a TTL of 1 day) here, not as the main mechanism for
|
||||
# caching, but just so continual restarts of the software during testing don't hammer the servers.
|
||||
logging.debug("Downloading " + self.sig_name + " SIG ref data...")
|
||||
logger.debug(f"Downloading {self.sig_name} SIG ref data...")
|
||||
http_response = self._url_data_cache.get(self._url, headers=HTTP_HEADERS)
|
||||
# Check response code was good
|
||||
if http_response.ok:
|
||||
@@ -57,20 +59,20 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
|
||||
|
||||
self.status = "OK"
|
||||
self.last_update_time = datetime.now(pytz.UTC)
|
||||
logging.debug("Received SIG ref data for " + self.sig_name)
|
||||
logger.debug(f"Received SIG ref data for {self.sig_name}")
|
||||
else:
|
||||
self.status = "Error"
|
||||
logging.warning(f"HTTP {http_response.status_code} when downloading SIG ref data for {self.sig_name}.")
|
||||
logger.warning(f"HTTP {http_response.status_code} when downloading SIG ref data for {self.sig_name}.")
|
||||
|
||||
except ConnectionError:
|
||||
self.status = "Error"
|
||||
logging.warning(f"Connection error when downloading SIG ref data for {self.sig_name}.")
|
||||
logger.warning(f"Connection error when downloading SIG ref data for {self.sig_name}.")
|
||||
except (ConnectTimeout, ReadTimeout):
|
||||
self.status = "Error"
|
||||
logging.warning(f"Timeout when downloading SIG ref data for {self.sig_name}.")
|
||||
logger.warning(f"Timeout when downloading SIG ref data for {self.sig_name}.")
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.exception("Exception in HTTP SIG Ref Data Provider (" + self.sig_name + ")")
|
||||
logger.exception(f"Exception in HTTP SIG Ref Data Provider ({self.sig_name})")
|
||||
self._stop_event.wait(timeout=1)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class GMA(FileDownloadSIGRefDataProvider):
|
||||
@@ -19,13 +22,21 @@ class GMA(FileDownloadSIGRefDataProvider):
|
||||
new_data = []
|
||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
|
||||
ref_id = row["Reference"]
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=row["Name"] if "Name" in row else None,
|
||||
url="https://www.cqgma.org/zinfo.php?ref=" + ref_id,
|
||||
latitude=float(row["Latitude"]) if "Latitude" in row and row[
|
||||
"Latitude"] != "" else None,
|
||||
longitude=float(row["Longitude"]) if "Longitude" in row and row[
|
||||
"Longitude"] != "" else None,
|
||||
grid=row["Maidenhead Locator"]))
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("Name", None),
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
||||
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
||||
altitude=float(row["Height (m)"].replace("m", ""))
|
||||
if "Height (m)" in row and row["Height (m)"] != ""
|
||||
else None,
|
||||
grid=row["Maidenhead Locator"],
|
||||
)
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class ILLW(FileDownloadSIGRefDataProvider):
|
||||
@@ -20,13 +23,18 @@ class ILLW(FileDownloadSIGRefDataProvider):
|
||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
|
||||
if "ILLW" in row and row["ILLW"] != "":
|
||||
ref_id = row["ILLW"]
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=row["Name"] if "Name" in row else None,
|
||||
url="https://www.cqgma.org/zinfo.php?ref=" + ref_id,
|
||||
latitude=float(row["Latitude"]) if "Latitude" in row and row[
|
||||
"Latitude"] != "" else None,
|
||||
longitude=float(row["Longitude"]) if "Longitude" in row and row[
|
||||
"Longitude"] != "" else None,
|
||||
grid=row["Maidenhead Locator"]))
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("Name", None),
|
||||
ref_type=SIGRefType.LIGHTHOUSE,
|
||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
||||
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
||||
grid=row["Maidenhead Locator"],
|
||||
)
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -3,8 +3,13 @@ from time import sleep
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IOTA(FileDownloadSIGRefDataProvider):
|
||||
@@ -23,16 +28,29 @@ class IOTA(FileDownloadSIGRefDataProvider):
|
||||
if isinstance(data, list):
|
||||
for ref in data:
|
||||
ref_id = ref["refno"]
|
||||
latitude = float(ref["latitude_min"]) + float(ref["latitude_max"]) / 2.0
|
||||
longitude = float(ref["longitude_min"]) + float(ref["longitude_max"]) / 2.0
|
||||
latitude = (float(ref["latitude_min"]) + float(ref["latitude_max"])) / 2.0
|
||||
longitude = (float(ref["longitude_min"]) + float(ref["longitude_max"])) / 2.0
|
||||
grid = None
|
||||
try:
|
||||
grid = latlong_to_locator(latitude, longitude, 6)
|
||||
except ValueError:
|
||||
logging.debug(f"Error converting lat/lon to locator for an IOTA reference %f %f", latitude, longitude)
|
||||
logger.debug(
|
||||
"Error converting lat/lon to locator for an IOTA reference %f %f",
|
||||
latitude,
|
||||
longitude,
|
||||
)
|
||||
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=ref["name"],
|
||||
grid=grid, latitude=latitude, longitude=longitude))
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=ref["name"],
|
||||
ref_type=SIGRefType.ISLAND,
|
||||
grid=grid,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
)
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from providers.sigrefdata.pnp_kml_sig_ref_data_provider import ParksNPeaksKMLSIGRefDataProvider
|
||||
from providers.sigrefdata.pnp_kml_sig_ref_data_provider import (
|
||||
ParksNPeaksKMLSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class KRMNPA(ParksNPeaksKMLSIGRefDataProvider):
|
||||
@@ -9,4 +11,4 @@ class KRMNPA(ParksNPeaksKMLSIGRefDataProvider):
|
||||
DATA_URL = "https://parksnpeaks.org/getPOI.php?poiClass=KRMNPA&poiFormat=4"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
@@ -2,8 +2,11 @@ from time import sleep
|
||||
|
||||
from pyhamtools.locator import locator_to_latlong
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class LLOTA(FileDownloadSIGRefDataProvider):
|
||||
@@ -25,11 +28,18 @@ class LLOTA(FileDownloadSIGRefDataProvider):
|
||||
grid = str(ref["grid_locator"])
|
||||
ll = locator_to_latlong(grid)
|
||||
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=str(ref["name"]),
|
||||
url="https://llota.app/list/ref/" + ref_id,
|
||||
grid=grid,
|
||||
latitude=ll[0],
|
||||
longitude=ll[1]))
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=str(ref["name"]),
|
||||
ref_type=SIGRefType.LAKE,
|
||||
url=f"https://llota.app/list/ref/{ref_id}",
|
||||
grid=grid,
|
||||
latitude=ll[0],
|
||||
longitude=ll[1],
|
||||
)
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -5,6 +5,8 @@ import pytz
|
||||
|
||||
from providers.sigrefdata.sig_ref_data_provider import SIGRefDataProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LocalFileSIGRefDataProvider(SIGRefDataProvider):
|
||||
"""Generic SIG ref data provider class for providers that fetch their data from a local file on startup."""
|
||||
@@ -14,7 +16,7 @@ class LocalFileSIGRefDataProvider(SIGRefDataProvider):
|
||||
self._path = path
|
||||
|
||||
def start(self):
|
||||
logging.debug("Loading " + self.sig_name + " SIG ref data from file.")
|
||||
logger.debug(f"Loading {self.sig_name} SIG ref data from file.")
|
||||
try:
|
||||
new_data = self._file_to_data(self._path)
|
||||
if new_data:
|
||||
@@ -23,10 +25,10 @@ class LocalFileSIGRefDataProvider(SIGRefDataProvider):
|
||||
self.last_update_time = datetime.now(pytz.UTC)
|
||||
else:
|
||||
self.status = "Error"
|
||||
logging.info("Failed to load SIG ref data for " + self.sig_name)
|
||||
except Exception as e:
|
||||
logger.info(f"Failed to load SIG ref data for {self.sig_name}")
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.error("Exception in local file SIG Ref Data Provider (" + self.sig_name + ")", e, exc_info=True)
|
||||
logger.exception(f"Exception in local file SIG Ref Data Provider ({self.sig_name})")
|
||||
|
||||
def _file_to_data(self, path):
|
||||
"""Load a file on the given path and turn it into SIG Ref data."""
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class MOTA(FileDownloadSIGRefDataProvider):
|
||||
@@ -19,13 +22,18 @@ class MOTA(FileDownloadSIGRefDataProvider):
|
||||
new_data = []
|
||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
|
||||
ref_id = row["Reference"]
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=row["Name"] if "Name" in row else None,
|
||||
url="https://www.cqgma.org/zinfo.php?ref=" + ref_id,
|
||||
latitude=float(row["Latitude"]) if "Latitude" in row and row[
|
||||
"Latitude"] != "" else None,
|
||||
longitude=float(row["Longitude"]) if "Longitude" in row and row[
|
||||
"Longitude"] != "" else None,
|
||||
grid=row["Maidenhead Locator"]))
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("Name", None),
|
||||
ref_type=SIGRefType.MILL,
|
||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
||||
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
||||
grid=row["Maidenhead Locator"],
|
||||
)
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from time import sleep
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
|
||||
|
||||
class PGA(FileDownloadSIGRefDataProvider):
|
||||
"""SIG ref data provider for Polish Gmina Award"""
|
||||
|
||||
POLL_INTERVAL_DAYS = 30
|
||||
SIG = "PGA"
|
||||
DATA_URL = "http://www.spga.pl/lista_pga2.php"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
new_data = []
|
||||
soup = BeautifulSoup(http_response.text, "html.parser")
|
||||
|
||||
# Iterate through tables in the page
|
||||
for table in soup.find_all("table"):
|
||||
header_cells = table.find_all(["th", "td"], limit=10)
|
||||
header_texts = [c.get_text(strip=True) for c in header_cells]
|
||||
|
||||
# If it has "PGA" and "Nazwa" in the header, it's the main data table
|
||||
if any("PGA" in t for t in header_texts) and any("Nazwa" in t for t in header_texts):
|
||||
# Iterate through all rows except the first
|
||||
rows = table.find_all("tr")
|
||||
for row in rows[1:]:
|
||||
cells = row.find_all("td")
|
||||
ref_id = cells[0].get_text(strip=True)
|
||||
name = cells[1].get_text(strip=True)
|
||||
if not ref_id:
|
||||
continue
|
||||
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=name,
|
||||
ref_type=SIGRefType.REGION,
|
||||
)
|
||||
)
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest
|
||||
# of the data in this case
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||
# is available for other threads e.g. the web server.
|
||||
sleep(0.001)
|
||||
|
||||
return new_data
|
||||
@@ -4,8 +4,11 @@ from time import sleep
|
||||
from fastkml import kml
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class ParksNPeaksKMLSIGRefDataProvider(FileDownloadSIGRefDataProvider):
|
||||
@@ -15,7 +18,7 @@ class ParksNPeaksKMLSIGRefDataProvider(FileDownloadSIGRefDataProvider):
|
||||
REF_PATTERN = re.compile(r"VKFF-\d+")
|
||||
|
||||
def __init__(self, sig_name, provider_config, url, poll_interval):
|
||||
""" Set up the provider, note poll_interval is in *days*."""
|
||||
"""Set up the provider, note poll_interval is in *days*."""
|
||||
super().__init__(sig_name, provider_config, url, poll_interval)
|
||||
|
||||
def _http_response_to_data(self, http_response):
|
||||
@@ -24,7 +27,9 @@ class ParksNPeaksKMLSIGRefDataProvider(FileDownloadSIGRefDataProvider):
|
||||
k = kml.KML.from_string(http_response.content)
|
||||
|
||||
for document in k.features:
|
||||
# noinspection unresolved-references
|
||||
for folder in document.features:
|
||||
# noinspection unresolved-references
|
||||
for placemark in folder.features:
|
||||
description = placemark.description or ""
|
||||
match = self.REF_PATTERN.search(description)
|
||||
@@ -35,10 +40,15 @@ class ParksNPeaksKMLSIGRefDataProvider(FileDownloadSIGRefDataProvider):
|
||||
|
||||
longitude, latitude = placemark.geometry.x, placemark.geometry.y
|
||||
|
||||
ref = SIGRef(sig=self.sig_name, id=ref_id, name=placemark.name,
|
||||
url="https://parksnpeaks.org/getPark.php?actPark=" + ref_id,
|
||||
latitude=latitude,
|
||||
longitude=longitude)
|
||||
ref = SIGRef(
|
||||
sig=self.sig_name,
|
||||
id=ref_id,
|
||||
name=placemark.name,
|
||||
ref_type=SIGRefType.PARK,
|
||||
url=f"https://parksnpeaks.org/getPark.php?actPark={ref_id}",
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
)
|
||||
if latitude and longitude:
|
||||
ref.grid = latlong_to_locator(latitude, longitude, 6)
|
||||
new_data.append(ref)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class POTA(FileDownloadSIGRefDataProvider):
|
||||
@@ -19,13 +22,18 @@ class POTA(FileDownloadSIGRefDataProvider):
|
||||
new_data = []
|
||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
|
||||
ref_id = row["reference"]
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=row["name"] if "name" in row else None,
|
||||
url="https://pota.app/#/park/" + ref_id,
|
||||
grid=row["grid"] if "grid" in row else None,
|
||||
latitude=float(row["latitude"]) if "latitude" in row and row[
|
||||
"latitude"] != "" else None,
|
||||
longitude=float(row["longitude"]) if "longitude" in row and row[
|
||||
"longitude"] != "" else None))
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("name", None),
|
||||
ref_type=SIGRefType.PARK,
|
||||
url=f"https://pota.app/#/park/{ref_id}",
|
||||
grid=row.get("grid", None),
|
||||
latitude=float(row["latitude"]) if "latitude" in row and row["latitude"] != "" else None,
|
||||
longitude=float(row["longitude"]) if "longitude" in row and row["longitude"] != "" else None,
|
||||
)
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from providers.sigrefdata.pnp_kml_sig_ref_data_provider import ParksNPeaksKMLSIGRefDataProvider
|
||||
from providers.sigrefdata.pnp_kml_sig_ref_data_provider import (
|
||||
ParksNPeaksKMLSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class SANPCPA(ParksNPeaksKMLSIGRefDataProvider):
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from threading import Event
|
||||
|
||||
import pytz
|
||||
|
||||
from core.data_store import DATA_STORE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SIGRefDataProvider:
|
||||
"""Generic SIG reference data provider class. Subclasses of this query the individual URLs or files for data."""
|
||||
@@ -17,34 +20,34 @@ class SIGRefDataProvider:
|
||||
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||
self.status = "Not Started" if self.enabled else "Disabled"
|
||||
self.reference_count = 0
|
||||
self._stop = False
|
||||
|
||||
self._stop_event = Event()
|
||||
|
||||
def start(self):
|
||||
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
|
||||
|
||||
raise NotImplementedError("Subclasses must implement this method")
|
||||
|
||||
|
||||
def stop(self):
|
||||
"""Stop any threads and prepare for application shutdown. Subclasses should implement this method and call
|
||||
super()."""
|
||||
|
||||
self._stop = True
|
||||
self._stop_event.set()
|
||||
|
||||
def _add_data(self, new_data):
|
||||
"""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 DATA_STORE.sigrefs.transact():
|
||||
# with transact() batches all writes together to save making thousands of individual sqlite writes. However,
|
||||
# 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:
|
||||
DATA_STORE.sigrefs.set(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
|
||||
# within the first few minutes of startup, we need a way to abort this expensive process of filling up the
|
||||
# disk cache.
|
||||
if self._stop:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
self.reference_count = len(new_data)
|
||||
logging.info(f"Loaded %d references for %s into the data store.", self.reference_count, self.sig_name)
|
||||
logger.info(f"Loaded {self.reference_count} references for {self.sig_name} into the data store.")
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class SIOTA(FileDownloadSIGRefDataProvider):
|
||||
@@ -19,10 +22,17 @@ class SIOTA(FileDownloadSIGRefDataProvider):
|
||||
new_data = []
|
||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
|
||||
ref_id = row["SILO_CODE"]
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=row["NAME"] if "NAME" in row else None,
|
||||
grid=row["LOCATOR"] if "LOCATOR" in row else None,
|
||||
latitude=float(row["LAT"]) if "LAT" in row else None,
|
||||
longitude=float(row["LNG"]) if "LNG" in row else None))
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("NAME", None),
|
||||
ref_type=SIGRefType.SILO,
|
||||
grid=row.get("LOCATOR", None),
|
||||
latitude=float(row["LAT"]) if "LAT" in row else None,
|
||||
longitude=float(row["LNG"]) if "LNG" in row else None,
|
||||
)
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -3,8 +3,11 @@ from time import sleep
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class SOTA(FileDownloadSIGRefDataProvider):
|
||||
@@ -23,11 +26,18 @@ class SOTA(FileDownloadSIGRefDataProvider):
|
||||
ref_id = row["SummitCode"]
|
||||
latitude = float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None
|
||||
longitude = float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None
|
||||
ref = SIGRef(sig=self.SIG, id=ref_id, name=row["SummitName"] if "SummitName" in row else None,
|
||||
url="https://www.sotadata.org.uk/en/summit/" + ref_id,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
activation_score=int(row["Points"]) if "Points" in row else None)
|
||||
altitude = float(row["AltM"]) if "AltM" in row and row["AltM"] != "" else None
|
||||
ref = SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("SummitName", None),
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
url=f"https://www.sotadata.org.uk/en/summit/{ref_id}",
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
altitude=altitude,
|
||||
activation_score=int(row["Points"]) if "Points" in row else None,
|
||||
)
|
||||
if latitude and longitude:
|
||||
ref.grid = latlong_to_locator(latitude, longitude, 6)
|
||||
new_data.append(ref)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import csv
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.local_file_sig_ref_data_provider import LocalFileSIGRefDataProvider
|
||||
from providers.sigrefdata.local_file_sig_ref_data_provider import (
|
||||
LocalFileSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class Toilets(LocalFileSIGRefDataProvider):
|
||||
@@ -19,12 +22,20 @@ class Toilets(LocalFileSIGRefDataProvider):
|
||||
csv_data = _f.read()
|
||||
dr = csv.DictReader(csv_data.splitlines())
|
||||
for row in dr:
|
||||
new_data.append(SIGRef(sig=self.SIG, id=row["ref"], name=row["ref"], latitude=float(row["lat"]),
|
||||
longitude=float(row["lon"])))
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
id=row["ref"],
|
||||
name=row["ref"],
|
||||
ref_type=SIGRefType.TOILET,
|
||||
latitude=float(row["lat"]),
|
||||
longitude=float(row["lon"]),
|
||||
)
|
||||
)
|
||||
|
||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest
|
||||
# of the data in this case
|
||||
if self._stop:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
return new_data
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class Towers(FileDownloadSIGRefDataProvider):
|
||||
@@ -19,11 +22,18 @@ class Towers(FileDownloadSIGRefDataProvider):
|
||||
new_data = []
|
||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"):
|
||||
ref_id = row["Ref"]
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=row["Nazev"] if "Nazev" in row else None,
|
||||
url="https://wwtota.com/seznam/karta_rozhledny.php?ref=" + ref_id,
|
||||
grid=row["Lokator"] if "Lokator" in row and row["Lokator"] != "" else None,
|
||||
latitude=float(row["Lat"]) if "Lat" in row and row["Lat"] != "" else None,
|
||||
longitude=float(row["Lon"]) if "Lon" in row and row["Lon"] != "" else None))
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("Nazev", None),
|
||||
ref_type=SIGRefType.TOWER,
|
||||
url=f"https://wwtota.com/seznam/karta_rozhledny.php?ref={ref_id}",
|
||||
grid=row["Lokator"] if "Lokator" in row and row["Lokator"] != "" else None,
|
||||
latitude=float(row["Lat"]) if "Lat" in row and row["Lat"] != "" else None,
|
||||
longitude=float(row["Lon"]) if "Lon" in row and row["Lon"] != "" else None,
|
||||
)
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -4,8 +4,13 @@ from time import sleep
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WCA(FileDownloadSIGRefDataProvider):
|
||||
@@ -34,13 +39,20 @@ class WCA(FileDownloadSIGRefDataProvider):
|
||||
longitude = float(split[1])
|
||||
grid = latlong_to_locator(latitude, longitude)
|
||||
except ValueError:
|
||||
logging.debug(f"Encountered dodgy formatting in WCA CSV, skipping location data for %s", ref_id)
|
||||
logger.debug(f"Encountered dodgy formatting in WCA CSV, skipping location data for {ref_id}")
|
||||
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=row["CLEAN NAME"] if "CLEAN NAME" in row else None,
|
||||
url="https://www.cqgma.org/zinfo.php?ref=" + ref_id,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
grid=grid))
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("CLEAN NAME", None),
|
||||
ref_type=SIGRefType.CASTLE,
|
||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
grid=grid,
|
||||
)
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
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
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class WOTA(FileDownloadSIGRefDataProvider):
|
||||
@@ -20,15 +23,24 @@ class WOTA(FileDownloadSIGRefDataProvider):
|
||||
ref_id = feature["properties"]["wotaId"]
|
||||
# Fudge WOTA URLs. Outlying fell (LDO) URLs don't match their ID numbers but require 214 to be
|
||||
# added to them
|
||||
url = "https://www.wota.org.uk/MM_" + ref_id
|
||||
url = f"https://www.wota.org.uk/MM_{ref_id}"
|
||||
if ref_id.upper().startswith("LDO-"):
|
||||
number = int(ref_id.upper().replace("LDO-", ""))
|
||||
url = "https://www.wota.org.uk/MM_LDO-" + str(number + 214)
|
||||
url = f"https://www.wota.org.uk/MM_LDO-{number + 214!s}"
|
||||
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=feature["properties"]["title"], url=url,
|
||||
grid=feature["properties"]["qthLocator"],
|
||||
latitude=feature["geometry"]["coordinates"][1],
|
||||
longitude=feature["geometry"]["coordinates"][0]))
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=feature["properties"]["title"],
|
||||
url=url,
|
||||
ref_type=SIGRefType.SUMMIT,
|
||||
grid=feature["properties"]["qthLocator"],
|
||||
latitude=feature["geometry"]["coordinates"][1],
|
||||
longitude=feature["geometry"]["coordinates"][0],
|
||||
altitude=feature["properties"]["height"],
|
||||
)
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class WWBOTA(FileDownloadSIGRefDataProvider):
|
||||
@@ -19,11 +22,18 @@ class WWBOTA(FileDownloadSIGRefDataProvider):
|
||||
new_data = []
|
||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
|
||||
ref_id = row["Reference"]
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=row["Name"] if "Name" in row else None,
|
||||
url="https://bunkerwiki.org/?s=" + ref_id if ref_id.startswith("B/G") else None,
|
||||
grid=row["Locator"] if "Locator" in row and row["Locator"] != "" else None,
|
||||
latitude=float(row["Lat"]) if "Lat" in row and row["Lat"] != "" else None,
|
||||
longitude=float(row["Long"]) if "Long" in row and row["Long"] != "" else None))
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("Name", None),
|
||||
ref_type=SIGRefType.BUNKER,
|
||||
url=f"https://bunkerwiki.org/?s={ref_id}" if ref_id.startswith("B/G") else None,
|
||||
grid=row["Locator"] if "Locator" in row and row["Locator"] != "" else None,
|
||||
latitude=float(row["Lat"]) if "Lat" in row and row["Lat"] != "" else None,
|
||||
longitude=float(row["Long"]) if "Long" in row and row["Long"] != "" else None,
|
||||
)
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class WWFF(FileDownloadSIGRefDataProvider):
|
||||
@@ -19,14 +22,22 @@ class WWFF(FileDownloadSIGRefDataProvider):
|
||||
new_data = []
|
||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
|
||||
ref_id = row["reference"]
|
||||
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=row["name"] if "name" in row else None,
|
||||
url="https://wwff.co/directory/?showRef=" + ref_id,
|
||||
grid=row["iaruLocator"] if "iaruLocator" in row and row[
|
||||
"iaruLocator"] != "-" else None,
|
||||
latitude=float(row["latitude"]) if "latitude" in row and row[
|
||||
"latitude"] != "" and row["latitude"] != "-" else None,
|
||||
longitude=float(row["longitude"]) if "longitude" in row and row[
|
||||
"longitude"] != "" and row["longitude"] != "-" else None))
|
||||
new_data.append(
|
||||
SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=row.get("name", None),
|
||||
ref_type=SIGRefType.PARK,
|
||||
url=f"https://wwff.co/directory/?showRef={ref_id}",
|
||||
grid=row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None,
|
||||
latitude=float(row["latitude"])
|
||||
if "latitude" in row and row["latitude"] != "" and row["latitude"] != "-"
|
||||
else None,
|
||||
longitude=float(row["longitude"])
|
||||
if "longitude" in row and row["longitude"] != "" and row["longitude"] != "-"
|
||||
else None,
|
||||
)
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -2,8 +2,11 @@ from time import sleep
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
|
||||
from core.enums import SIGRefType
|
||||
from data.sig_ref import SIGRef
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
||||
FileDownloadSIGRefDataProvider,
|
||||
)
|
||||
|
||||
|
||||
class ZLOTA(FileDownloadSIGRefDataProvider):
|
||||
@@ -22,16 +25,33 @@ class ZLOTA(FileDownloadSIGRefDataProvider):
|
||||
if isinstance(data, list):
|
||||
for ref in data:
|
||||
ref_id = ref["code"]
|
||||
latitude = ref["y"]
|
||||
longitude = ref["x"]
|
||||
latitude = ref["latitude"]
|
||||
longitude = ref["longitude"]
|
||||
try:
|
||||
ref_type = SIGRefType(ref["asset_type"].title().upper())
|
||||
except ValueError:
|
||||
ref_type = None
|
||||
|
||||
ref = SIGRef(sig=self.SIG, id=ref_id, name=ref["name"],
|
||||
url="https://ontheair.nz/assets/" + ref_id.replace("/", "_"),
|
||||
latitude=latitude,
|
||||
longitude=longitude)
|
||||
new_ref = SIGRef(
|
||||
sig=self.SIG,
|
||||
id=ref_id,
|
||||
name=ref["name"],
|
||||
ref_type=ref_type,
|
||||
url=f"https://ontheair.nz/assets/{ref_id.replace('/', '_')}",
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
)
|
||||
|
||||
# Check lat/lon validity and update grid accordingly
|
||||
if latitude and longitude:
|
||||
ref.grid = latlong_to_locator(latitude, longitude, 6)
|
||||
new_data.append(ref)
|
||||
try:
|
||||
new_ref.grid = latlong_to_locator(latitude, longitude, 6)
|
||||
except ValueError:
|
||||
# Junk lat/lon, remove from the spot
|
||||
new_ref.latitude = None
|
||||
new_ref.longitude = None
|
||||
|
||||
new_data.append(new_ref)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import csv
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from threading import Thread, Event
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from threading import Event, Thread
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from providers.solarconditions.ionosonde_utils import compute_band_states
|
||||
from providers.solarconditions.solar_conditions_provider import SolarConditionsProvider
|
||||
|
||||
# Each station gets polled roughly once every hour (3600 seconds). Note that to avoid a burst of requests to the server
|
||||
# every hour, the requests for data from each station are spaced out throughout the hour, leading to one request being
|
||||
# sent every 1-2 minutes.
|
||||
POLL_INTERVAL = 3600
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Each station gets polled roughly once every two hours (7200 seconds). Note that to avoid a burst of requests to the
|
||||
# server every two hours, the requests for data from each station are spaced out throughout the period, leading to one
|
||||
# request being sent every 3-4 minutes.
|
||||
POLL_INTERVAL = 7200
|
||||
# To avoid looking up all stations in the GIRO system and working out which ones are providing live data, this has been
|
||||
# manually determined and a CSV provided of all the stations that we can query for live data.
|
||||
STATIONS_INDEX = "datafiles/didbase-stations.csv"
|
||||
LGDC_URL = "https://lgdc.uml.edu/common/DIDBGetValues"
|
||||
LGDC_URL = "https://lgdc.uml.edu/fastchar/getbest"
|
||||
HISTORY_HOURS = 24
|
||||
|
||||
|
||||
@@ -40,9 +42,16 @@ class GIROIonosonde(SolarConditionsProvider):
|
||||
# entries so KC2G cache data is preserved.
|
||||
existing = self._solar_conditions.ionosonde_data or {}
|
||||
new_entries = {
|
||||
s["ursi"]: {"ursi": s["ursi"], "name": s["name"], "fof2": None, "muf": None,
|
||||
"luf": None, "band_states": None}
|
||||
for s in self._stations if s["ursi"] not in existing
|
||||
s["ursi"]: {
|
||||
"ursi": s["ursi"],
|
||||
"name": s["name"],
|
||||
"fof2": None,
|
||||
"muf": None,
|
||||
"luf": None,
|
||||
"band_states": None,
|
||||
}
|
||||
for s in self._stations
|
||||
if s["ursi"] not in existing
|
||||
}
|
||||
if new_entries:
|
||||
self.update_data({"ionosonde_data": {**existing, **new_entries}})
|
||||
@@ -50,19 +59,23 @@ class GIROIonosonde(SolarConditionsProvider):
|
||||
@staticmethod
|
||||
def _load_stations():
|
||||
stations = []
|
||||
with open(STATIONS_INDEX, newline='') as f:
|
||||
with open(STATIONS_INDEX, newline="") as f:
|
||||
for row in csv.reader(f):
|
||||
if len(row) >= 2:
|
||||
stations.append({"ursi": row[0].strip(), "name": row[1].strip()})
|
||||
return stations
|
||||
|
||||
def start(self):
|
||||
logging.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
|
||||
self._thread = Thread(target=self._run, name="GIROIonosondeDataProvider")
|
||||
logger.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
|
||||
self._thread = Thread(target=self._run, name="GIROIonosondeDataProvider", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
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):
|
||||
# Real interval at which we poll is the "once per hour" divided by the number of stations, so each one gets
|
||||
@@ -79,7 +92,7 @@ class GIROIonosonde(SolarConditionsProvider):
|
||||
ursi = station["ursi"]
|
||||
name = station["name"]
|
||||
try:
|
||||
logging.debug(f"Polling GIRO ionosonde data for {ursi} ({name})...")
|
||||
logger.debug(f"Polling GIRO ionosonde data for {ursi} ({name})...")
|
||||
now = datetime.now(timezone.utc)
|
||||
from_time = now - timedelta(hours=HISTORY_HOURS)
|
||||
cutoff_ts = from_time.timestamp()
|
||||
@@ -94,8 +107,14 @@ class GIROIonosonde(SolarConditionsProvider):
|
||||
|
||||
# Merge GIRO's readings into any existing data for this station.
|
||||
existing = ionosonde_data.get(ursi, {})
|
||||
merged_fof2 = {**{float(t): v for t, v in (existing.get("fof2") or {}).items()}, **fof2}
|
||||
merged_muf = {**{float(t): v for t, v in (existing.get("muf") or {}).items()}, **muf}
|
||||
merged_fof2 = {
|
||||
**{float(t): v for t, v in (existing.get("fof2") or {}).items()},
|
||||
**fof2,
|
||||
}
|
||||
merged_muf = {
|
||||
**{float(t): v for t, v in (existing.get("muf") or {}).items()},
|
||||
**muf,
|
||||
}
|
||||
merged_luf = dict(luf) if luf else {}
|
||||
|
||||
merged_fof2 = {t: v for t, v in merged_fof2.items() if t >= cutoff_ts}
|
||||
@@ -104,7 +123,8 @@ class GIROIonosonde(SolarConditionsProvider):
|
||||
|
||||
band_states = compute_band_states(merged_fof2, merged_muf, merged_luf)
|
||||
ionosonde_data[ursi] = {
|
||||
"ursi": ursi, "name": name,
|
||||
"ursi": ursi,
|
||||
"name": name,
|
||||
"fof2": merged_fof2 or None,
|
||||
"muf": merged_muf or None,
|
||||
"luf": merged_luf or None,
|
||||
@@ -113,29 +133,29 @@ class GIROIonosonde(SolarConditionsProvider):
|
||||
self.update_data({"ionosonde_data": ionosonde_data})
|
||||
self.status = "OK"
|
||||
self.last_update_time = datetime.now(pytz.UTC)
|
||||
logging.debug(f"Updated ionosonde data for {ursi} ({name}).")
|
||||
logger.debug(f"Updated ionosonde data for {ursi} ({name}).")
|
||||
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.exception(f"Exception fetching GIRO ionosonde data for {ursi} ({name})")
|
||||
logger.exception(f"Exception fetching GIRO ionosonde data for {ursi} ({name})")
|
||||
|
||||
def _fetch_station_data(self, ursi, from_time, to_time):
|
||||
"""Fetch foF2, MUF and LUF readings for a station. Returns (fof2_dict, muf_dict, luf_dict) keyed by UNIX timestamp."""
|
||||
|
||||
from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S")
|
||||
to_str = to_time.strftime("%Y.%m.%d+%H:%M:%S")
|
||||
url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUFD,fmin&DMUF=3000&fromDate={from_str}&toDate={to_str}"
|
||||
url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUF%28D%29,fmin&DMUF=3000&fromDate={from_str}&toDate={to_str}"
|
||||
try:
|
||||
http_response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15))
|
||||
if not http_response.ok:
|
||||
logging.warning(f"HTTP {http_response.status_code} when calling Giro ionosonde API.")
|
||||
logger.warning(f"HTTP {http_response.status_code} when calling Giro ionosonde API.")
|
||||
return None, None, None
|
||||
return self._parse_all(http_response.text)
|
||||
except (ConnectTimeout, ReadTimeout):
|
||||
logging.warning(f"Timeout when accessing Giro ionosonde API.")
|
||||
logger.warning("Timeout when accessing Giro ionosonde API.")
|
||||
return None, None, None
|
||||
except ConnectionError:
|
||||
logging.warning("Connection error when accessing Giro ionosonde API.")
|
||||
logger.warning("Connection error when accessing Giro ionosonde API.")
|
||||
return None, None, None
|
||||
|
||||
@staticmethod
|
||||
@@ -147,14 +167,14 @@ class GIROIonosonde(SolarConditionsProvider):
|
||||
luf_data = {}
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# Data rows have the following format: timestamp CS foF2 QD MUFD QD fmin QD
|
||||
parts = line.split()
|
||||
if len(parts) >= 5:
|
||||
try:
|
||||
# Python 3.8 TZ parsing fudge
|
||||
ts = datetime.fromisoformat(parts[0].replace('Z', '+00:00')).timestamp()
|
||||
ts = datetime.fromisoformat(parts[0].replace("Z", "+00:00")).timestamp()
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
|
||||
@@ -2,9 +2,14 @@ import logging
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import pytz
|
||||
from dateutil import parser as dateutil_parser, tz as dateutil_tz
|
||||
from dateutil import parser as dateutil_parser
|
||||
from dateutil import tz as dateutil_tz
|
||||
|
||||
from providers.solarconditions.http_solar_conditions_provider import HTTPSolarConditionsProvider
|
||||
from providers.solarconditions.http_solar_conditions_provider import (
|
||||
HTTPSolarConditionsProvider,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
POLL_INTERVAL = 3600 # 1 hour
|
||||
URL = "https://www.hamqsl.com/solarxml.php"
|
||||
@@ -21,14 +26,14 @@ class HamQSL(HTTPSolarConditionsProvider):
|
||||
root = ElementTree.fromstring(http_response.text)
|
||||
sd = root.find("solardata")
|
||||
if sd is None:
|
||||
logging.warning("HamQSL solar conditions API returned unexpected XML structure")
|
||||
logger.warning("HamQSL solar conditions API returned unexpected XML structure")
|
||||
return None
|
||||
|
||||
# Some error checking functions in case the data is janky.
|
||||
|
||||
def text(tag, default=None):
|
||||
if sd is None:
|
||||
logging.warning("HamQSL solar conditions API returned unexpected XML structure")
|
||||
logger.warning("HamQSL solar conditions API returned unexpected XML structure")
|
||||
return default
|
||||
el = sd.find(tag)
|
||||
return el.text.strip() if el is not None and el.text else default
|
||||
@@ -72,11 +77,11 @@ class HamQSL(HTTPSolarConditionsProvider):
|
||||
tz_abbr = updated_str.split()[-1]
|
||||
timezone = dateutil_tz.gettz(tz_abbr)
|
||||
if timezone is None:
|
||||
raise ValueError("Unknown timezone abbreviation: " + tz_abbr)
|
||||
raise ValueError(f"Unknown timezone abbreviation: {tz_abbr}")
|
||||
dt = dateutil_parser.parse(updated_str, tzinfos={tz_abbr: timezone})
|
||||
updated = dt.astimezone(pytz.UTC).timestamp()
|
||||
except (ValueError, IndexError):
|
||||
logging.warning("HamQSL solar conditions API returned unrecognised timestamp format: " + updated_str)
|
||||
logger.warning(f"HamQSL solar conditions API returned unrecognised timestamp format: {updated_str}")
|
||||
|
||||
# Return the data ready to be put into the solar conditions object.
|
||||
return {
|
||||
@@ -92,7 +97,8 @@ class HamQSL(HTTPSolarConditionsProvider):
|
||||
"aurora_latitude": float_val("latdegree"),
|
||||
"solar_wind": float_val("solarwind"),
|
||||
"magnetic_field": float_val("magneticfield"),
|
||||
"geomag_field": text("geomagfield").title()
|
||||
"geomag_field": text("geomagfield")
|
||||
.title()
|
||||
.replace("Vr Quiet", "Very Quiet")
|
||||
.replace("Unsettld", "Unsettled")
|
||||
.replace("Min Strm", "Minor Storm")
|
||||
@@ -102,8 +108,10 @@ class HamQSL(HTTPSolarConditionsProvider):
|
||||
"geomag_noise": text("signalnoise"),
|
||||
"hf_conditions": hf_conditions,
|
||||
"vhf_conditions": {
|
||||
"vhf_aurora_northern_hemi": (vhf_map.get(("vhf-aurora", "northern_hemi")) or "").title().replace(
|
||||
"Lat Aur", "Latitude") or None,
|
||||
"vhf_aurora_northern_hemi": (vhf_map.get(("vhf-aurora", "northern_hemi")) or "")
|
||||
.title()
|
||||
.replace("Lat Aur", "Latitude")
|
||||
or None,
|
||||
"es_2m_europe": vhf_map.get(("E-Skip", "europe")),
|
||||
"es_4m_europe": vhf_map.get(("E-Skip", "europe_4m")),
|
||||
"es_6m_europe": vhf_map.get(("E-Skip", "europe_6m")),
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from threading import Thread, Event
|
||||
from threading import Event, Thread
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from providers.solarconditions.solar_conditions_provider import SolarConditionsProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HTTPSolarConditionsProvider(SolarConditionsProvider):
|
||||
"""Generic solar conditions provider for providers that request data via HTTP(S). Subclasses implement
|
||||
@@ -22,13 +24,16 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
|
||||
self._stop_event = Event()
|
||||
|
||||
def start(self):
|
||||
logging.info(
|
||||
"Set up query of " + self.name + " solar conditions API every " + str(self._poll_interval) + " seconds.")
|
||||
self._thread = Thread(target=self._run, name=f"HTTPSolarConditionsProvider-{self.name}")
|
||||
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}", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
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):
|
||||
while True:
|
||||
@@ -38,7 +43,7 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
|
||||
|
||||
def _poll(self):
|
||||
try:
|
||||
logging.debug("Polling " + self.name + " solar conditions API...")
|
||||
logger.debug(f"Polling {self.name} solar conditions API...")
|
||||
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
|
||||
# Check response code was good
|
||||
if http_response.ok:
|
||||
@@ -47,18 +52,18 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
|
||||
|
||||
self.status = "OK"
|
||||
self.last_update_time = datetime.now(pytz.UTC)
|
||||
logging.debug("Received data from " + self.name + " solar conditions API.")
|
||||
logger.debug(f"Received data from {self.name} solar conditions API.")
|
||||
else:
|
||||
self.status = "Error"
|
||||
logging.warning(f"HTTP {http_response.status_code} when calling {self.name} solar conditions API.")
|
||||
logger.warning(f"HTTP {http_response.status_code} when calling {self.name} solar conditions API.")
|
||||
|
||||
except ConnectionError:
|
||||
logging.warning(f"Connection error when accessing {self.name} solar conditions API.")
|
||||
logger.warning(f"Connection error when accessing {self.name} solar conditions API.")
|
||||
except (ConnectTimeout, ReadTimeout):
|
||||
logging.warning(f"Timeout when accessing {self.name} solar conditions API.")
|
||||
logger.warning(f"Timeout when accessing {self.name} solar conditions API.")
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.exception("Exception in HTTP Solar Conditions Provider (" + self.name + ")")
|
||||
logger.exception(f"Exception in HTTP Solar Conditions Provider ({self.name})")
|
||||
self._stop_event.wait(timeout=1)
|
||||
|
||||
def _http_response_to_solar_conditions(self, http_response):
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from threading import Thread, Event
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from threading import Event, Thread
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from providers.solarconditions.ionosonde_utils import compute_band_states
|
||||
from providers.solarconditions.solar_conditions_provider import SolarConditionsProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
POLL_INTERVAL = 900 # 15 minutes
|
||||
KC2G_URL = "https://prop.kc2g.com/api/stations.json"
|
||||
HISTORY_HOURS = 24
|
||||
@@ -29,12 +31,16 @@ class KC2GProp(SolarConditionsProvider):
|
||||
self._stop_event = Event()
|
||||
|
||||
def start(self):
|
||||
logging.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.")
|
||||
self._thread = Thread(target=self._run, name="KC2GPropProvider")
|
||||
logger.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.")
|
||||
self._thread = Thread(target=self._run, name="KC2GPropProvider", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
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):
|
||||
while True:
|
||||
@@ -44,10 +50,10 @@ class KC2GProp(SolarConditionsProvider):
|
||||
|
||||
def _poll(self):
|
||||
try:
|
||||
logging.debug("Polling KC2G ionosonde data...")
|
||||
logger.debug("Polling KC2G ionosonde data...")
|
||||
http_response = requests.get(KC2G_URL, headers=HTTP_HEADERS, timeout=(5, 30))
|
||||
if not http_response.ok:
|
||||
logging.warning(f"HTTP {http_response.status_code} when calling KG2G ionosonde API.")
|
||||
logger.warning(f"HTTP {http_response.status_code} when calling KG2G ionosonde API.")
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -114,13 +120,13 @@ class KC2GProp(SolarConditionsProvider):
|
||||
self.update_data({"ionosonde_data": ionosonde_data})
|
||||
self.status = "OK"
|
||||
self.last_update_time = datetime.now(pytz.UTC)
|
||||
logging.debug(f"Updated KC2G ionosonde data for {updated_count} stations.")
|
||||
logger.debug(f"Updated KC2G ionosonde data for {updated_count} stations.")
|
||||
|
||||
except ConnectionError:
|
||||
logging.warning("Connection error when accessing KC2G ionosonde API.")
|
||||
logger.warning("Connection error when accessing KC2G ionosonde API.")
|
||||
except (ConnectTimeout, ReadTimeout):
|
||||
logging.warning(f"Timeout when accessing KC2G ionosonde API.")
|
||||
logger.warning("Timeout when accessing KC2G ionosonde API.")
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.exception("Exception in KC2G ionosonde data provider")
|
||||
logger.exception("Exception in KC2G ionosonde data provider")
|
||||
self._stop_event.wait(timeout=1)
|
||||
|
||||
@@ -2,7 +2,11 @@ import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from providers.solarconditions.http_solar_conditions_provider import HTTPSolarConditionsProvider
|
||||
from providers.solarconditions.http_solar_conditions_provider import (
|
||||
HTTPSolarConditionsProvider,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
POLL_INTERVAL = 10800 # Every 3 hours
|
||||
URL = "https://services.swpc.noaa.gov/text/3-day-forecast.txt"
|
||||
@@ -26,21 +30,21 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
|
||||
start_idx = i
|
||||
break
|
||||
if start_idx is None:
|
||||
logging.warning(f"NOAA 3-day forecast: could not find '{section_header}' section")
|
||||
logger.warning(f"NOAA 3-day forecast: could not find '{section_header}' section")
|
||||
return None
|
||||
|
||||
# Find the date header line by scanning the next few lines for month & day patterns
|
||||
date_header_idx = None
|
||||
for j in range(start_idx + 1, min(start_idx + 6, len(lines))):
|
||||
if re.search(r'[A-Za-z]{3}\s+\d{2}', lines[j]):
|
||||
if re.search(r"[A-Za-z]{3}\s+\d{2}", lines[j]):
|
||||
date_header_idx = j
|
||||
break
|
||||
if date_header_idx is None:
|
||||
logging.warning(f"NOAA 3-day forecast: could not find date header after '{section_header}'")
|
||||
logger.warning(f"NOAA 3-day forecast: could not find date header after '{section_header}'")
|
||||
return None
|
||||
date_matches = re.findall(r'([A-Za-z]{3})\s+(\d{2})', lines[date_header_idx])
|
||||
date_matches = re.findall(r"([A-Za-z]{3})\s+(\d{2})", lines[date_header_idx])
|
||||
if not date_matches:
|
||||
logging.warning(f"NOAA 3-day forecast: no dates in header: {lines[date_header_idx]}")
|
||||
logger.warning(f"NOAA 3-day forecast: no dates in header: {lines[date_header_idx]}")
|
||||
return None
|
||||
|
||||
# Figure out the date based on the line found
|
||||
@@ -50,25 +54,25 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
|
||||
dt = datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").replace(tzinfo=timezone.utc)
|
||||
column_timestamps.append(dt.timestamp())
|
||||
except ValueError:
|
||||
logging.warning(f"NOAA 3-day forecast: could not parse date: {month_str} {day_str} {year}")
|
||||
logger.warning(f"NOAA 3-day forecast: could not parse date: {month_str} {day_str} {year}")
|
||||
return None
|
||||
|
||||
# Parse data rows. Each non-empty line should have a text label followed by percentage values
|
||||
result = {}
|
||||
for line in lines[date_header_idx + 1:]:
|
||||
for line in lines[date_header_idx + 1 :]:
|
||||
line_stripped = line.strip()
|
||||
if not line_stripped:
|
||||
if result:
|
||||
break
|
||||
continue
|
||||
pct_matches = list(re.finditer(r'\b(\d+)%', line_stripped))
|
||||
pct_matches = list(re.finditer(r"\b(\d+)%", line_stripped))
|
||||
if not pct_matches:
|
||||
if result:
|
||||
break
|
||||
continue
|
||||
|
||||
# Row label is everything before the first percentage value
|
||||
row_label = line_stripped[:line_stripped.index(pct_matches[0].group())].strip()
|
||||
row_label = line_stripped[: line_stripped.index(pct_matches[0].group())].strip()
|
||||
row_data = {}
|
||||
for j, match in enumerate(pct_matches):
|
||||
if j >= len(column_timestamps):
|
||||
@@ -89,26 +93,26 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
|
||||
start_idx = i
|
||||
break
|
||||
if start_idx is None:
|
||||
logging.warning("NOAA K-index forecast: could not find 'NOAA Kp index breakdown' section")
|
||||
logger.warning("NOAA K-index forecast: could not find 'NOAA Kp index breakdown' section")
|
||||
return None
|
||||
|
||||
# Extract the year from the header line, e.g. "NOAA Kp index breakdown Apr 2-Apr 4, 2026"
|
||||
header_line = lines[start_idx]
|
||||
year_match = re.search(r'\b(\d{4})\b', header_line)
|
||||
year_match = re.search(r"\b(\d{4})\b", header_line)
|
||||
if not year_match:
|
||||
logging.warning("NOAA K-index forecast: could not extract year from: " + header_line)
|
||||
logger.warning(f"NOAA K-index forecast: could not extract year from: {header_line}")
|
||||
return None
|
||||
year = int(year_match.group(1))
|
||||
|
||||
# Parse the column date headers on the next line, e.g. " Apr 02 Apr 03 Apr 04"
|
||||
if start_idx + 1 >= len(lines):
|
||||
logging.warning("NOAA K-index forecast: missing date header line")
|
||||
logger.warning("NOAA K-index forecast: missing date header line")
|
||||
return None
|
||||
|
||||
date_header_line = lines[start_idx + 2]
|
||||
date_matches = re.findall(r'([A-Za-z]{3})\s+(\d{2})', date_header_line)
|
||||
date_matches = re.findall(r"([A-Za-z]{3})\s+(\d{2})", date_header_line)
|
||||
if not date_matches:
|
||||
logging.warning("NOAA K-index forecast: could not parse date headers from: " + date_header_line)
|
||||
logger.warning(f"NOAA K-index forecast: could not parse date headers from: {date_header_line}")
|
||||
return None
|
||||
|
||||
column_dates = []
|
||||
@@ -116,13 +120,13 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
|
||||
try:
|
||||
column_dates.append(datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").date())
|
||||
except ValueError:
|
||||
logging.warning(f"NOAA K-index forecast: could not parse date: {month_str} {day_str} {year}")
|
||||
logger.warning(f"NOAA K-index forecast: could not parse date: {month_str} {day_str} {year}")
|
||||
return None
|
||||
|
||||
# Parse each data row, e.g. "00-03UT 2.00 3.00 2.00"
|
||||
k_index_forecast = {}
|
||||
for line in lines[start_idx + 3:]:
|
||||
time_match = re.match(r'^(\d{2})-(\d{2})UT\s+(.*)', line.strip())
|
||||
for line in lines[start_idx + 3 :]:
|
||||
time_match = re.match(r"^(\d{2})-(\d{2})UT\s+(.*)", line.strip())
|
||||
if not time_match:
|
||||
if k_index_forecast:
|
||||
break
|
||||
@@ -130,7 +134,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
|
||||
|
||||
start_hour = int(time_match.group(1))
|
||||
# Split on 2 or more spaces so that e.g. "5.67 (G2)" stays as one token per column
|
||||
raw_values = re.split(r' {2,}', time_match.group(3).strip())
|
||||
raw_values = re.split(r" {2,}", time_match.group(3).strip())
|
||||
|
||||
for i, val in enumerate(raw_values):
|
||||
if i >= len(column_dates):
|
||||
@@ -142,14 +146,22 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
|
||||
continue
|
||||
|
||||
date = column_dates[i]
|
||||
start_dt = datetime(date.year, date.month, date.day, start_hour, 0, 0, tzinfo=timezone.utc)
|
||||
start_dt = datetime(
|
||||
date.year,
|
||||
date.month,
|
||||
date.day,
|
||||
start_hour,
|
||||
0,
|
||||
0,
|
||||
tzinfo=timezone.utc,
|
||||
)
|
||||
|
||||
# Key the data dict by start time
|
||||
key = start_dt.timestamp()
|
||||
k_index_forecast[key] = kp
|
||||
|
||||
if not k_index_forecast:
|
||||
logging.warning("NOAA K-index forecast: no data rows parsed")
|
||||
logger.warning("NOAA K-index forecast: no data rows parsed")
|
||||
return None
|
||||
|
||||
# Parse Solar Radiation Storm Forecast (single row: "S1 or greater")
|
||||
|
||||