mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-08-06 10:31:42 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dfcdb00e8 | ||
|
|
5699627069 | ||
|
|
66081f32f9 | ||
|
|
4f8f748d88 | ||
|
|
62b3414d29 | ||
|
|
459d999f57 | ||
|
|
27d73c27d6 | ||
|
|
f82d611b7e | ||
|
|
50ab27e4a2 | ||
|
|
e31b145b8e | ||
|
|
cee9188538 | ||
|
|
2e6d8e4b4a | ||
|
|
c2ecef1003 | ||
|
|
11a236e668 |
@@ -0,0 +1,7 @@
|
|||||||
|
/.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
/config.yml
|
||||||
|
/cache/
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["python3", "spothole.py"]
|
||||||
@@ -166,12 +166,40 @@ source .venv/bin/activate
|
|||||||
python3 spothole.py
|
python3 spothole.py
|
||||||
```
|
```
|
||||||
|
|
||||||
The software can take a few seconds to start up, mostly because it is downloading an updated file to match callsigns to
|
The software can take a few seconds to start up, mostly because it is downloading updated files containing the various
|
||||||
countries. This is normal, don't panic!
|
bits of reference data it uses. This is normal, don't panic! Once you see `You can access your copy of Spothole at
|
||||||
|
http://localhost:8080` in the log, your server is good to go.
|
||||||
|
|
||||||
If you see some errors on startup, check your configuration, e.g. in case you have specified a port for the web server
|
If you see some errors on startup, check your configuration, e.g. in case you have specified a port for the web server
|
||||||
that is already in use by something else.
|
that is already in use by something else.
|
||||||
|
|
||||||
|
### Running using Docker
|
||||||
|
|
||||||
|
Spothole comes with a Docker configuration to make it easy to run it in a containerised environment. To set it up using
|
||||||
|
Docker, the easiest way is to use a Docker Compose file. Create a new directory such as `/opt/docker/spothole` and
|
||||||
|
create a `compose.yaml` file inside it with the following contents:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
spothole:
|
||||||
|
container_name: spothole
|
||||||
|
build:
|
||||||
|
context: https://git.ianrenton.com/ian/spothole.git#main
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./config.yml:/app/config.yml
|
||||||
|
- ./cache:/app/cache
|
||||||
|
```
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
### Multiple cluster nodes with different settings
|
### Multiple cluster nodes with different settings
|
||||||
|
|
||||||
Dan, S50U has written in with his Spothole cluster settings. He is using a cluster node which provides RBN spots, and
|
Dan, S50U has written in with his Spothole cluster settings. He is using a cluster node which provides RBN spots, and
|
||||||
|
|||||||
+57
-18
@@ -166,7 +166,23 @@ alert-providers:
|
|||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
|
||||||
# Static reference data providers to use. This allows Spothole to download data such as mapping between callsign
|
# 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/v1/solar endpoint.
|
||||||
|
solar-condition-providers:
|
||||||
|
- class: "HamQSL"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- class: "NOAA3dayForecast"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- class: "GIROIonosonde"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- class: "KC2GProp"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
|
||||||
|
# Static reference data providers to use. These allow Spothole to download data such as mapping between callsign
|
||||||
# prefixes and DXCC entities.
|
# prefixes and DXCC entities.
|
||||||
static-data-providers:
|
static-data-providers:
|
||||||
- class: "K0SWE"
|
- class: "K0SWE"
|
||||||
@@ -179,7 +195,7 @@ static-data-providers:
|
|||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
|
||||||
# SIG reference data providers to use. This allows Spothole to download, for example, the WWFF directory that maps WWFF
|
# SIG reference data providers to use. These allow Spothole to download, for example, the WWFF directory that maps WWFF
|
||||||
# park IDs to their name and location.
|
# park IDs to their name and location.
|
||||||
sig-ref-data-providers:
|
sig-ref-data-providers:
|
||||||
- class: "POTA"
|
- class: "POTA"
|
||||||
@@ -233,30 +249,53 @@ sig-ref-data-providers:
|
|||||||
- class: "Toilets"
|
- class: "Toilets"
|
||||||
enabled: true
|
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/v1/solar endpoint.
|
# Callsign data providers to use. These allow Spothole to provide information about callsigns, either from static
|
||||||
solar-condition-providers:
|
# lookups or from web-based services such as QRZ. Note that use of QRZ and HamQTH is *on behalf of the user*, and
|
||||||
- class: "HamQSL"
|
# requires the user to enter their credentials into the website UI or provide them in an API call. Spothole will not
|
||||||
|
# look up all calls via QRZ/HamQTH using the server owner's credentials, as this is against their usage policy.
|
||||||
|
callsign-data-providers:
|
||||||
|
- class: "QRZ"
|
||||||
|
enabled: true
|
||||||
|
# Callsign data providers can often provide conflicting data, e.g. a home location from QRZ vs the centre of the
|
||||||
|
# DXCC entity from a country file lookup. The priority flag sets which source "wins" in the event of conflict.
|
||||||
|
# Lower numbers take priority over higher numbers. Generally then, QRZ/HamQTH should have low numbers as they are
|
||||||
|
# likely to have the most accurate data.
|
||||||
|
priority: 1
|
||||||
|
# No server-side credentials for QRZ. Users must provide their own as per QRZ policy.
|
||||||
|
|
||||||
|
- class: "HamQTH"
|
||||||
|
enabled: true
|
||||||
|
priority: 2
|
||||||
|
# No server-side credentials for HamQTH. Users must provide their own.
|
||||||
|
|
||||||
|
- 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
|
||||||
|
# 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
|
||||||
|
# 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
|
enabled: true
|
||||||
|
|
||||||
- class: "NOAA3dayForecast"
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
- class: "GIROIonosonde"
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
- class: "KC2GProp"
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
# Maximum time to keep spots and alerts in the system before deleting them. By default, one hour for spots and one week
|
# Maximum time to keep spots and alerts in the system before deleting them. By default, one hour for spots and one week
|
||||||
# for alerts.
|
# for alerts.
|
||||||
max-spot-age-sec: 3600
|
max-spot-age-sec: 3600
|
||||||
max-alert-age-sec: 604800
|
max-alert-age-sec: 604800
|
||||||
|
|
||||||
# API key for Clublog to look up information. Optional. You sill need to request one via their helpdesk portal if you
|
|
||||||
# want to use callsign lookups from Clublog.
|
|
||||||
clublog-api-key: ""
|
|
||||||
|
|
||||||
# Allow submitting spots to the Spothole API?
|
# Allow submitting spots to the Spothole API?
|
||||||
allow-spotting: true
|
allow-spotting: true
|
||||||
|
|
||||||
|
|||||||
+30
-632
@@ -1,632 +1,30 @@
|
|||||||
import gzip
|
from core.data_providers import DATA_PROVIDERS
|
||||||
import logging
|
from data.callsign import Callsign
|
||||||
import urllib.parse
|
|
||||||
from datetime import timedelta
|
|
||||||
|
def get_call_info(callsign, lookup_credentials):
|
||||||
import xmltodict
|
"""Utility method to get the best set of data for a callsign as we can, using all enabled providers.
|
||||||
from diskcache import Cache
|
lookup_credentials is an optional object that carries the user's QRZ.com/HamQTH credentials, if they provided them,
|
||||||
from pyhamtools import LookupLib, Callinfo, callinfo
|
to enable lookup using those providers."""
|
||||||
from pyhamtools.exceptions import APIKeyMissingError
|
|
||||||
from pyhamtools.locator import latlong_to_locator
|
callsign_data = Callsign(call=callsign)
|
||||||
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
|
||||||
from requests_cache import CachedSession
|
if callsign:
|
||||||
|
# Sort callsign providers by priority order, so we query the highest priority (lowest numbers) first, and only
|
||||||
from core.config import config
|
# query other providers for data we are missing as we go along.
|
||||||
from core.constants import HTTP_HEADERS, HAMQTH_PRG
|
for p in sorted(DATA_PROVIDERS.callsign_data_providers, key=lambda p: p.priority):
|
||||||
from core.data_store import DATA_STORE
|
if p.enabled:
|
||||||
from core.url_data_cache import URLDataCache
|
# Get new lookup data
|
||||||
|
data = p.lookup(callsign, lookup_credentials)
|
||||||
# QRZ XML field names differ from pyhamtools' normalised names; map them here.
|
if data:
|
||||||
_QRZ_FIELD_MAP = {
|
# If we have new data for fields that were previously unpopulated, add them in
|
||||||
"lat": "latitude",
|
for key, value in data.__dict__.items():
|
||||||
"lon": "longitude",
|
if value is not None and callsign_data.__dict__.get(key) is None:
|
||||||
"grid": "locator",
|
callsign_data.__dict__[key] = value
|
||||||
"ituzone": "ituz",
|
|
||||||
"cqzone": "cqz",
|
# If callsign data is fully populated, avoid looking up using other providers as their data will not
|
||||||
}
|
# be used
|
||||||
_QRZ_INT_FIELDS = {"adif", "cqz", "ituz"}
|
if callsign_data.fully_populated():
|
||||||
_QRZ_FLOAT_FIELDS = {"latitude", "longitude"}
|
break
|
||||||
_URL_DATA_CACHE = URLDataCache("callsign_lookup")
|
|
||||||
|
return callsign_data
|
||||||
|
|
||||||
def _normalize_qrz_data(raw):
|
|
||||||
data = {}
|
|
||||||
for k, v in raw.items():
|
|
||||||
if v is None:
|
|
||||||
continue
|
|
||||||
mapped_key = _QRZ_FIELD_MAP.get(k, k)
|
|
||||||
if mapped_key in _QRZ_INT_FIELDS:
|
|
||||||
try:
|
|
||||||
v = int(v)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
elif mapped_key in _QRZ_FLOAT_FIELDS:
|
|
||||||
try:
|
|
||||||
v = float(v)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
data[mapped_key] = v
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class LookupHelper:
|
|
||||||
"""Singleton class that provides lookup functionality."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Create the lookup helper. Note that nothing actually happens until the start() method is called, and that all
|
|
||||||
lookup methods will fail if start() has not yet been called. This therefore needs starting before any spot or
|
|
||||||
alert handlers are created."""
|
|
||||||
|
|
||||||
self._clublog_callsign_data_cache = None
|
|
||||||
self._lookup_lib_clublog_xml = None
|
|
||||||
self._clublog_xml_available = None
|
|
||||||
self._lookup_lib_clublog_api = None
|
|
||||||
self._clublog_xml_download_location = None
|
|
||||||
self._clublog_api_available = None
|
|
||||||
self._clublog_cty_xml_cache = None
|
|
||||||
self._clublog_api_key = None
|
|
||||||
self._qrz_callsign_data_cache = None
|
|
||||||
self._qrz_base_url = "https://xmldata.qrz.com/xml/current/"
|
|
||||||
# QRZ session keys expire after an hour; cache the login response for 55 minutes.
|
|
||||||
self._qrz_session_cache = CachedSession("cache/qrz_session_cache",
|
|
||||||
expire_after=timedelta(minutes=55))
|
|
||||||
self._hamqth_callsign_data_cache = None
|
|
||||||
self._hamqth_base_url = "https://www.hamqth.com/xml.php"
|
|
||||||
# HamQTH session keys expire after an hour. Rather than working out how much time has passed manually, we cheat
|
|
||||||
# and cache the HTTP response for 55 minutes, so when the login URL is queried within 55 minutes of the previous
|
|
||||||
# time, you just get the cached response.
|
|
||||||
self._hamqth_session_lookup_cache = CachedSession("cache/hamqth_session_cache",
|
|
||||||
expire_after=timedelta(minutes=55))
|
|
||||||
self._call_info_basic = None
|
|
||||||
self._lookup_lib_basic = None
|
|
||||||
self._country_files_cty_plist_download_location = None
|
|
||||||
self._dxcc_json_download_location = None
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
# Lookup helpers from pyhamtools. We use five (!) of these. The simplest is country-files.com, which downloads
|
|
||||||
# the data once on startup, and requires no login/key, but does not have the best coverage.
|
|
||||||
# If the user provides login details/API keys, we also set up helpers for QRZ.com, HamQTH, Clublog (live API
|
|
||||||
# request), and Clublog (XML download). The lookup functions iterate through these in a sensible order, looking
|
|
||||||
# for suitable data.
|
|
||||||
self._country_files_cty_plist_download_location = "cache/cty.plist"
|
|
||||||
success = self._download_country_files_cty_plist()
|
|
||||||
if success:
|
|
||||||
self._lookup_lib_basic = LookupLib(lookuptype="countryfile",
|
|
||||||
filename=self._country_files_cty_plist_download_location)
|
|
||||||
else:
|
|
||||||
self._lookup_lib_basic = LookupLib(lookuptype="countryfile")
|
|
||||||
self._call_info_basic = Callinfo(self._lookup_lib_basic)
|
|
||||||
|
|
||||||
self._qrz_callsign_data_cache = Cache('cache/qrz_callsign_lookup_cache')
|
|
||||||
|
|
||||||
self._hamqth_callsign_data_cache = Cache('cache/hamqth_callsign_lookup_cache')
|
|
||||||
|
|
||||||
self._clublog_api_key = str(config["clublog-api-key"])
|
|
||||||
self._clublog_cty_xml_cache = CachedSession("cache/clublog_cty_xml_cache", expire_after=timedelta(days=10))
|
|
||||||
self._clublog_api_available = self._clublog_api_key != ""
|
|
||||||
self._clublog_xml_download_location = "cache/cty.xml"
|
|
||||||
if self._clublog_api_available:
|
|
||||||
self._lookup_lib_clublog_api = LookupLib(lookuptype="clublogapi", apikey=self._clublog_api_key)
|
|
||||||
success = self._download_clublog_ctyxml()
|
|
||||||
self._clublog_xml_available = success
|
|
||||||
if success:
|
|
||||||
self._lookup_lib_clublog_xml = LookupLib(lookuptype="clublogxml",
|
|
||||||
filename=self._clublog_xml_download_location)
|
|
||||||
self._clublog_callsign_data_cache = Cache('cache/clublog_callsign_lookup_cache')
|
|
||||||
|
|
||||||
|
|
||||||
def _download_country_files_cty_plist(self):
|
|
||||||
"""Download the cty.plist file from country-files.com on first startup. The pyhamtools lib can actually download and use
|
|
||||||
this itself, but it's occasionally offline which causes it to throw an error. By downloading it separately, we can
|
|
||||||
catch errors and handle them, falling back to a previous copy of the file in the cache, and we can use the
|
|
||||||
requests_cache library to prevent re-downloading too quickly if the software keeps restarting."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
logging.info("Downloading Country-files.com cty.plist...")
|
|
||||||
response = _URL_DATA_CACHE.get("https://www.country-files.com/cty/cty.plist",
|
|
||||||
headers=HTTP_HEADERS)
|
|
||||||
|
|
||||||
if response.ok:
|
|
||||||
with open(self._country_files_cty_plist_download_location, "w") as f:
|
|
||||||
f.write(response.text)
|
|
||||||
f.flush()
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logging.warning(f"HTTP {response.status_code} when downloading Country-files.com cty.plist.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except ConnectionError:
|
|
||||||
logging.warning(f"Connection error when downloading Clublog cty.xml.")
|
|
||||||
except (ConnectTimeout, ReadTimeout):
|
|
||||||
logging.warning(f"Timeout when downloading Clublog cty.xml.")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error("Exception when downloading Clublog cty.xml", e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _download_clublog_ctyxml(self):
|
|
||||||
"""Download the cty.xml (gzipped) file from Clublog on first startup, so we can use it in preference to querying the
|
|
||||||
database live if possible."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
logging.info("Downloading Clublog cty.xml.gz...")
|
|
||||||
response = self._clublog_cty_xml_cache.get("https://cdn.clublog.org/cty.php?api=" + self._clublog_api_key,
|
|
||||||
headers=HTTP_HEADERS)
|
|
||||||
logging.info("Caching Clublog cty.xml.gz...")
|
|
||||||
open(self._clublog_xml_download_location + ".gz", 'wb').write(response.content)
|
|
||||||
with gzip.open(self._clublog_xml_download_location + ".gz", "rb") as uncompressed:
|
|
||||||
file_content = uncompressed.read()
|
|
||||||
assert isinstance(file_content, bytes)
|
|
||||||
logging.info("Caching Clublog cty.xml...")
|
|
||||||
with open(self._clublog_xml_download_location, "wb") as f:
|
|
||||||
f.write(file_content)
|
|
||||||
f.flush()
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error("Exception when downloading Clublog cty.xml", e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def infer_country_from_callsign(self, call, credentials=None):
|
|
||||||
"""Infer a country name from a callsign"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Start with the basic country-files.com-based decoder.
|
|
||||||
country = self._call_info_basic.get_country_name(call)
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
country = None
|
|
||||||
# Couldn't get anything from basic call info database, try QRZ.com
|
|
||||||
if not country:
|
|
||||||
qrz_data = self._get_qrz_data_for_callsign(call, credentials)
|
|
||||||
if qrz_data and "country" in qrz_data:
|
|
||||||
country = qrz_data["country"]
|
|
||||||
# Couldn't get anything from QRZ.com database, try HamQTH
|
|
||||||
if not country:
|
|
||||||
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
|
|
||||||
if hamqth_data and "country" in hamqth_data:
|
|
||||||
country = hamqth_data["country"]
|
|
||||||
# Couldn't get anything from HamQTH database, try Clublog data
|
|
||||||
if not country:
|
|
||||||
clublog_data = self._get_clublog_xml_data_for_callsign(call)
|
|
||||||
if clublog_data and "Name" in clublog_data:
|
|
||||||
country = clublog_data["Name"]
|
|
||||||
if not country:
|
|
||||||
clublog_data = self._get_clublog_api_data_for_callsign(call)
|
|
||||||
if clublog_data and "Name" in clublog_data:
|
|
||||||
country = clublog_data["Name"]
|
|
||||||
# Couldn't get anything from Clublog database, try DXCC data
|
|
||||||
if not country:
|
|
||||||
dxcc_data = self._get_dxcc_data_for_callsign(call)
|
|
||||||
if dxcc_data and "name" in dxcc_data:
|
|
||||||
country = dxcc_data["name"]
|
|
||||||
return country
|
|
||||||
|
|
||||||
def infer_dxcc_id_from_callsign(self, call, credentials=None):
|
|
||||||
"""Infer a DXCC ID from a callsign"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Start with the basic country-files.com-based decoder.
|
|
||||||
dxcc = self._call_info_basic.get_adif_id(call)
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
dxcc = None
|
|
||||||
# Couldn't get anything from basic call info database, try QRZ.com
|
|
||||||
if not dxcc:
|
|
||||||
qrz_data = self._get_qrz_data_for_callsign(call, credentials)
|
|
||||||
if qrz_data and "adif" in qrz_data:
|
|
||||||
dxcc = qrz_data["adif"]
|
|
||||||
# Couldn't get anything from QRZ.com database, try HamQTH
|
|
||||||
if not dxcc:
|
|
||||||
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
|
|
||||||
if hamqth_data and "adif" in hamqth_data:
|
|
||||||
dxcc = hamqth_data["adif"]
|
|
||||||
# Couldn't get anything from HamQTH database, try Clublog data
|
|
||||||
if not dxcc:
|
|
||||||
clublog_data = self._get_clublog_xml_data_for_callsign(call)
|
|
||||||
if clublog_data and "DXCC" in clublog_data:
|
|
||||||
dxcc = clublog_data["DXCC"]
|
|
||||||
if not dxcc:
|
|
||||||
clublog_data = self._get_clublog_api_data_for_callsign(call)
|
|
||||||
if clublog_data and "DXCC" in clublog_data:
|
|
||||||
dxcc = clublog_data["DXCC"]
|
|
||||||
# Couldn't get anything from Clublog database, try DXCC data
|
|
||||||
if not dxcc:
|
|
||||||
dxcc_data = self._get_dxcc_data_for_callsign(call)
|
|
||||||
if dxcc_data and "entityCode" in dxcc_data:
|
|
||||||
dxcc = dxcc_data["entityCode"]
|
|
||||||
return dxcc
|
|
||||||
|
|
||||||
def infer_continent_from_callsign(self, call, credentials=None):
|
|
||||||
"""Infer a continent shortcode from a callsign"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Start with the basic country-files.com-based decoder.
|
|
||||||
continent = self._call_info_basic.get_continent(call)
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
continent = None
|
|
||||||
# Couldn't get anything from basic call info database, try HamQTH
|
|
||||||
if not continent:
|
|
||||||
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
|
|
||||||
if hamqth_data and "continent" in hamqth_data:
|
|
||||||
continent = hamqth_data["continent"]
|
|
||||||
# Couldn't get anything from HamQTH database, try Clublog data
|
|
||||||
if not continent:
|
|
||||||
clublog_data = self._get_clublog_xml_data_for_callsign(call)
|
|
||||||
if clublog_data and "Continent" in clublog_data:
|
|
||||||
continent = clublog_data["Continent"]
|
|
||||||
if not continent:
|
|
||||||
clublog_data = self._get_clublog_api_data_for_callsign(call)
|
|
||||||
if clublog_data and "Continent" in clublog_data:
|
|
||||||
continent = clublog_data["Continent"]
|
|
||||||
# Couldn't get anything from Clublog database, try DXCC data
|
|
||||||
if not continent:
|
|
||||||
dxcc_data = self._get_dxcc_data_for_callsign(call)
|
|
||||||
# Some DXCCs are in two continents, if so don't use the continent data as we can't be sure
|
|
||||||
if dxcc_data and "continent" in dxcc_data and len(dxcc_data["continent"]) == 1:
|
|
||||||
continent = dxcc_data["continent"][0]
|
|
||||||
return continent
|
|
||||||
|
|
||||||
def infer_cq_zone_from_callsign(self, call, credentials=None):
|
|
||||||
"""Infer a CQ zone from a callsign"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Start with the basic country-files.com-based decoder.
|
|
||||||
cqz = self._call_info_basic.get_cqz(call)
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
cqz = None
|
|
||||||
# Couldn't get anything from basic call info database, try QRZ.com
|
|
||||||
if not cqz:
|
|
||||||
qrz_data = self._get_qrz_data_for_callsign(call, credentials)
|
|
||||||
if qrz_data and "cqz" in qrz_data:
|
|
||||||
cqz = qrz_data["cqz"]
|
|
||||||
# Couldn't get anything from QRZ.com database, try HamQTH
|
|
||||||
if not cqz:
|
|
||||||
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
|
|
||||||
if hamqth_data and "cq" in hamqth_data:
|
|
||||||
cqz = hamqth_data["cq"]
|
|
||||||
# Couldn't get anything from HamQTH database, try Clublog data
|
|
||||||
if not cqz:
|
|
||||||
clublog_data = self._get_clublog_xml_data_for_callsign(call)
|
|
||||||
if clublog_data and "CQZ" in clublog_data:
|
|
||||||
cqz = clublog_data["CQZ"]
|
|
||||||
if not cqz:
|
|
||||||
clublog_data = self._get_clublog_api_data_for_callsign(call)
|
|
||||||
if clublog_data and "CQZ" in clublog_data:
|
|
||||||
cqz = clublog_data["CQZ"]
|
|
||||||
# Couldn't get anything from Clublog database, try DXCC data
|
|
||||||
if not cqz:
|
|
||||||
dxcc_data = self._get_dxcc_data_for_callsign(call)
|
|
||||||
# Some DXCCs are in multiple zones, if so don't use the zone data as we can't be sure
|
|
||||||
if dxcc_data and "cq" in dxcc_data and len(dxcc_data["cq"]) == 1:
|
|
||||||
cqz = dxcc_data["cq"][0]
|
|
||||||
return cqz
|
|
||||||
|
|
||||||
def infer_itu_zone_from_callsign(self, call, credentials=None):
|
|
||||||
"""Infer a ITU zone from a callsign"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Start with the basic country-files.com-based decoder.
|
|
||||||
ituz = self._call_info_basic.get_ituz(call)
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
ituz = None
|
|
||||||
# Couldn't get anything from basic call info database, try QRZ.com
|
|
||||||
if not ituz:
|
|
||||||
qrz_data = self._get_qrz_data_for_callsign(call, credentials)
|
|
||||||
if qrz_data and "ituz" in qrz_data:
|
|
||||||
ituz = qrz_data["ituz"]
|
|
||||||
# Couldn't get anything from QRZ.com database, try HamQTH
|
|
||||||
if not ituz:
|
|
||||||
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
|
|
||||||
if hamqth_data and "itu" in hamqth_data:
|
|
||||||
ituz = hamqth_data["itu"]
|
|
||||||
# Couldn't get anything from HamQTH database, Clublog doesn't provide this, so try DXCC data
|
|
||||||
if not ituz:
|
|
||||||
dxcc_data = self._get_dxcc_data_for_callsign(call)
|
|
||||||
# Some DXCCs are in multiple zones, if so don't use the zone data as we can't be sure
|
|
||||||
if dxcc_data and "itu" in dxcc_data and len(dxcc_data["itu"]) == 1:
|
|
||||||
ituz = dxcc_data["itu"]
|
|
||||||
return ituz
|
|
||||||
|
|
||||||
def infer_name_from_callsign_online_lookup(self, call, credentials=None):
|
|
||||||
"""Infer an operator name from a callsign (requires QRZ.com/HamQTH)"""
|
|
||||||
|
|
||||||
data = self._get_qrz_data_for_callsign(call, credentials)
|
|
||||||
if data and "name_fmt" in data:
|
|
||||||
return data["name_fmt"]
|
|
||||||
if data and "fname" in data:
|
|
||||||
name = data["fname"]
|
|
||||||
if "nick" in data:
|
|
||||||
name = name + " \"" + data["nick"] + "\""
|
|
||||||
if "name" in data:
|
|
||||||
name = name + " " + data["name"]
|
|
||||||
return name
|
|
||||||
data = self._get_hamqth_data_for_callsign(call, credentials)
|
|
||||||
if data and "nick" in data:
|
|
||||||
return data["nick"]
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def infer_latlon_from_callsign_online_lookup(self, call, credentials=None):
|
|
||||||
"""Infer a latitude and longitude from a callsign (requires QRZ.com/HamQTH)
|
|
||||||
Coordinates that look default are rejected (apologies if your position really is 0,0, enjoy your voyage)"""
|
|
||||||
|
|
||||||
data = self._get_qrz_data_for_callsign(call, credentials)
|
|
||||||
if data and "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:
|
|
||||||
return [float(data["latitude"]), float(data["longitude"])]
|
|
||||||
data = self._get_hamqth_data_for_callsign(call, credentials)
|
|
||||||
if data and "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:
|
|
||||||
return [float(data["latitude"]), float(data["longitude"])]
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def infer_grid_from_callsign_online_lookup(self, call, credentials=None):
|
|
||||||
"""Infer a grid locator from a callsign (requires QRZ.com/HamQTH).
|
|
||||||
Grids that look default are rejected (apologies if your grid really is AA00aa, enjoy your research)"""
|
|
||||||
|
|
||||||
data = self._get_qrz_data_for_callsign(call, credentials)
|
|
||||||
if data and "locator" in data and data["locator"].upper() != "AA00" and data["locator"].upper() != "AA00AA" and \
|
|
||||||
data["locator"].upper() != "AA00AA00":
|
|
||||||
return data["locator"]
|
|
||||||
data = self._get_hamqth_data_for_callsign(call, credentials)
|
|
||||||
if data and "grid" in data and data["grid"].upper() != "AA00" and data["grid"].upper() != "AA00AA" and data[
|
|
||||||
"grid"].upper() != "AA00AA00":
|
|
||||||
return data["grid"]
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def infer_qth_from_callsign_online_lookup(self, call, credentials=None):
|
|
||||||
"""Infer a textual QTH from a callsign (requires QRZ.com/HamQTH)"""
|
|
||||||
|
|
||||||
data = self._get_qrz_data_for_callsign(call, credentials)
|
|
||||||
if data and "addr2" in data:
|
|
||||||
return data["addr2"]
|
|
||||||
data = self._get_hamqth_data_for_callsign(call, credentials)
|
|
||||||
if data and "qth" in data:
|
|
||||||
return data["qth"]
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def infer_latlon_from_callsign_dxcc(self, call):
|
|
||||||
"""Infer a latitude and longitude from a callsign (using DXCC, probably very inaccurate)"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = self._call_info_basic.get_lat_long(call)
|
|
||||||
if data and "latitude" in data and "longitude" in data:
|
|
||||||
loc = [float(data["latitude"]), float(data["longitude"])]
|
|
||||||
else:
|
|
||||||
loc = None
|
|
||||||
except KeyError:
|
|
||||||
loc = None
|
|
||||||
# Couldn't get anything from basic call info database, try Clublog data
|
|
||||||
if not loc:
|
|
||||||
data = self._get_clublog_xml_data_for_callsign(call)
|
|
||||||
if data and "Lat" in data and "Lon" in data:
|
|
||||||
loc = [float(data["Lat"]), float(data["Lon"])]
|
|
||||||
if not loc:
|
|
||||||
data = self._get_clublog_api_data_for_callsign(call)
|
|
||||||
if data and "Lat" in data and "Lon" in data:
|
|
||||||
loc = [float(data["Lat"]), float(data["Lon"])]
|
|
||||||
return loc
|
|
||||||
|
|
||||||
def infer_grid_from_callsign_dxcc(self, call):
|
|
||||||
"""Infer a grid locator from a callsign (using DXCC, probably very inaccurate)"""
|
|
||||||
|
|
||||||
latlon = self.infer_latlon_from_callsign_dxcc(call) or []
|
|
||||||
grid = None
|
|
||||||
if latlon:
|
|
||||||
try:
|
|
||||||
grid = latlong_to_locator(latlon[0], latlon[1], 8)
|
|
||||||
except:
|
|
||||||
logging.debug("Invalid lat/lon received for DXCC")
|
|
||||||
return grid
|
|
||||||
|
|
||||||
def _get_qrz_data_for_callsign(self, call, credentials) -> dict | None:
|
|
||||||
"""Utility method to get QRZ.com data from cache if possible, if not get it from the API and cache it.
|
|
||||||
Returns None immediately if no credentials are provided."""
|
|
||||||
|
|
||||||
# Return from cache if available (a cached None means 'not found in QRZ')
|
|
||||||
if call in self._qrz_callsign_data_cache:
|
|
||||||
return self._qrz_callsign_data_cache.get(call)
|
|
||||||
|
|
||||||
# Obtain session key from credentials
|
|
||||||
session_key = None
|
|
||||||
if credentials and credentials.qrz_session_key:
|
|
||||||
session_key = credentials.qrz_session_key
|
|
||||||
elif credentials and credentials.qrz_username and credentials.qrz_password:
|
|
||||||
try:
|
|
||||||
login_response = self._qrz_session_cache.get(
|
|
||||||
self._qrz_base_url + "?username=" + urllib.parse.quote_plus(credentials.qrz_username) +
|
|
||||||
"&password=" + urllib.parse.quote_plus(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:
|
|
||||||
logging.warning("QRZ.com login details incorrect, failed to look up with QRZ.")
|
|
||||||
return None
|
|
||||||
except Exception:
|
|
||||||
logging.error("Exception when getting QRZ.com session key")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not session_key:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
|
|
||||||
calls_to_try = [call]
|
|
||||||
try:
|
|
||||||
home_call = callinfo.Callinfo.get_homecall(call)
|
|
||||||
if home_call != call:
|
|
||||||
calls_to_try.append(home_call)
|
|
||||||
except ValueError:
|
|
||||||
logging.debug("Could not look up home call for callsign %s", call)
|
|
||||||
|
|
||||||
for lookup_call in calls_to_try:
|
|
||||||
try:
|
|
||||||
response = _URL_DATA_CACHE.get(
|
|
||||||
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:
|
|
||||||
if "Callsign" in qrz_response:
|
|
||||||
data = _normalize_qrz_data(qrz_response.get("Callsign"))
|
|
||||||
self._qrz_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
|
|
||||||
return data
|
|
||||||
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"))
|
|
||||||
|
|
||||||
elif not response.from_cache:
|
|
||||||
logging.warning("QRZ returned a malformed response looking up callsign %s", lookup_call)
|
|
||||||
elif not response.from_cache:
|
|
||||||
logging.warning("HTTP %d looking up callsign %s using QRZ", lookup_call)
|
|
||||||
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
continue
|
|
||||||
except ConnectionError:
|
|
||||||
logging.warning(f"Connection error when looking up callsign %s using QRZ", lookup_call)
|
|
||||||
continue
|
|
||||||
except (ConnectTimeout, ReadTimeout):
|
|
||||||
logging.warning(f"Timeout when looking up callsign %s using QRZ.", lookup_call)
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
logging.error("Exception when looking up callsign %s using QRZ", lookup_call, exc_info=True)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Not found in QRZ; cache None so we don't keep retrying
|
|
||||||
self._qrz_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_hamqth_data_for_callsign(self, call, credentials) -> dict | None:
|
|
||||||
"""Utility method to get HamQTH data from cache if possible, if not get it from the API and cache it.
|
|
||||||
Returns None immediately if no credentials are provided."""
|
|
||||||
|
|
||||||
# Return from cache if available
|
|
||||||
if call in self._hamqth_callsign_data_cache:
|
|
||||||
return self._hamqth_callsign_data_cache.get(call)
|
|
||||||
|
|
||||||
# Obtain session ID from credentials
|
|
||||||
session_id = None
|
|
||||||
if credentials and credentials.hamqth_session_id:
|
|
||||||
session_id = credentials.hamqth_session_id
|
|
||||||
elif credentials and credentials.hamqth_username and credentials.hamqth_password:
|
|
||||||
try:
|
|
||||||
session_data = self._hamqth_session_lookup_cache.get(
|
|
||||||
self._hamqth_base_url + "?u=" + urllib.parse.quote_plus(credentials.hamqth_username) +
|
|
||||||
"&p=" + urllib.parse.quote_plus(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:
|
|
||||||
logging.warning("HamQTH login details incorrect, failed to look up with HamQTH.")
|
|
||||||
return None
|
|
||||||
except Exception:
|
|
||||||
logging.error("Exception when getting HamQTH session ID")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not session_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
|
|
||||||
calls_to_try = [call]
|
|
||||||
try:
|
|
||||||
home_call = callinfo.Callinfo.get_homecall(call)
|
|
||||||
if home_call != call:
|
|
||||||
calls_to_try.append(home_call)
|
|
||||||
except ValueError:
|
|
||||||
logging.debug("Could not look up home call for callsign %s", call)
|
|
||||||
|
|
||||||
for lookup_call in calls_to_try:
|
|
||||||
try:
|
|
||||||
response = _URL_DATA_CACHE.get(
|
|
||||||
self._hamqth_base_url + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
|
|
||||||
lookup_call) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS)
|
|
||||||
if response.ok:
|
|
||||||
data = xmltodict.parse(response.content)["HamQTH"]["search"]
|
|
||||||
self._hamqth_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
|
|
||||||
return data
|
|
||||||
elif not response.from_cache:
|
|
||||||
logging.warning("HTTP %d looking up callsign %s using HamQTH", response.status_code, lookup_call)
|
|
||||||
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
continue
|
|
||||||
except ConnectionError:
|
|
||||||
logging.warning(f"Connection error when looking up callsign %s using HamQTH", lookup_call)
|
|
||||||
continue
|
|
||||||
except (ConnectTimeout, ReadTimeout):
|
|
||||||
logging.warning(f"Timeout when looking up callsign %s using HamQTH", lookup_call)
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
logging.error("Exception when looking up callsign %s using HamQTH", lookup_call, exc_info=True)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Not found in HamQTH; cache None so we don't keep retrying
|
|
||||||
self._hamqth_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_clublog_api_data_for_callsign(self, call) -> dict | None:
|
|
||||||
"""Utility method to get Clublog API data from cache if possible, if not get it from the API and cache it"""
|
|
||||||
|
|
||||||
# Fetch from cache if we can, otherwise fetch from the API and cache it
|
|
||||||
if call in self._clublog_callsign_data_cache:
|
|
||||||
return self._clublog_callsign_data_cache.get(call)
|
|
||||||
elif self._clublog_api_available:
|
|
||||||
try:
|
|
||||||
data = self._lookup_lib_clublog_api.lookup_callsign(callsign=call)
|
|
||||||
self._clublog_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
|
|
||||||
return data
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
# Clublog had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call.
|
|
||||||
try:
|
|
||||||
data = self._lookup_lib_clublog_api.lookup_callsign(callsign=callinfo.Callinfo.get_homecall(call))
|
|
||||||
self._clublog_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
|
|
||||||
return data
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
# Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again
|
|
||||||
self._clublog_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
|
|
||||||
return None
|
|
||||||
except APIKeyMissingError:
|
|
||||||
# User API key was wrong, warn
|
|
||||||
logging.error("Could not look up via Clublog API, key " + self._clublog_api_key + " was rejected.")
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_clublog_xml_data_for_callsign(self, call) -> dict | None:
|
|
||||||
"""Utility method to get Clublog XML data from file"""
|
|
||||||
|
|
||||||
if self._clublog_xml_available:
|
|
||||||
try:
|
|
||||||
data = self._lookup_lib_clublog_xml.lookup_callsign(callsign=call)
|
|
||||||
return data
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
# Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again
|
|
||||||
self._clublog_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_dxcc_data_for_callsign(self, call) -> dict | None:
|
|
||||||
"""Utility method to get generic DXCC data from our lookup table, if we can find it"""
|
|
||||||
|
|
||||||
for pattern, entity_code in DATA_STORE.dxcc_lookup_by_call_regex:
|
|
||||||
if pattern.match(call):
|
|
||||||
return DATA_STORE.dxcc_data[entity_code]
|
|
||||||
return None
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
"""Shutdown method to close down any caches neatly."""
|
|
||||||
|
|
||||||
self._qrz_callsign_data_cache.close()
|
|
||||||
self._hamqth_callsign_data_cache.close()
|
|
||||||
self._clublog_callsign_data_cache.close()
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton object
|
|
||||||
lookup_helper = LookupHelper()
|
|
||||||
+7
-35
@@ -30,47 +30,19 @@ LOG_WEB_REQUESTS = config.get("log-web-requests", False)
|
|||||||
# but for consistency we provide this to the front-end in web-ui-options because it has no impact outside of the web UI.
|
# but for consistency we provide this to the front-end in web-ui-options because it has no impact outside of the web UI.
|
||||||
WEB_UI_OPTIONS["spot-providers-enabled-by-default"] = [p["name"] for p in config["spot-providers"] if p["enabled"] and (
|
WEB_UI_OPTIONS["spot-providers-enabled-by-default"] = [p["name"] for p in config["spot-providers"] if p["enabled"] and (
|
||||||
"enabled-by-default-in-web-ui" not in p or p["enabled-by-default-in-web-ui"])]
|
"enabled-by-default-in-web-ui" not in p or p["enabled-by-default-in-web-ui"])]
|
||||||
|
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"])
|
||||||
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
|
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
|
||||||
# one of our proviers. We set that to also be enabled by default.
|
# one of our proviers. We set that to also be enabled by default.
|
||||||
if ALLOW_SPOTTING:
|
if ALLOW_SPOTTING:
|
||||||
WEB_UI_OPTIONS["spot-providers-enabled-by-default"].append("API")
|
WEB_UI_OPTIONS["spot-providers-enabled-by-default"].append("API")
|
||||||
|
|
||||||
|
|
||||||
def get_spot_provider_from_config(config_providers_entry):
|
def create_provider_from_config(package, config_providers_entry):
|
||||||
"""Utility method to get a spot provider based on the class specified in its config entry."""
|
"""Utility method to get a provider based on the class specified in its config entry. You must also provide the
|
||||||
|
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('providers.spot.' + config_providers_entry["class"].lower())
|
module = importlib.import_module(package + "." + config_providers_entry["class"].lower())
|
||||||
provider_class = getattr(module, config_providers_entry["class"])
|
|
||||||
return provider_class(config_providers_entry)
|
|
||||||
|
|
||||||
|
|
||||||
def get_alert_provider_from_config(config_providers_entry):
|
|
||||||
"""Utility method to get an alert provider based on the class specified in its config entry."""
|
|
||||||
|
|
||||||
module = importlib.import_module('providers.alert.' + config_providers_entry["class"].lower())
|
|
||||||
provider_class = getattr(module, config_providers_entry["class"])
|
|
||||||
return provider_class(config_providers_entry)
|
|
||||||
|
|
||||||
|
|
||||||
def get_solar_conditions_provider_from_config(config_providers_entry):
|
|
||||||
"""Utility method to get a solar conditions provider based on the class specified in its config entry."""
|
|
||||||
|
|
||||||
module = importlib.import_module('providers.solarconditions.' + config_providers_entry["class"].lower())
|
|
||||||
provider_class = getattr(module, config_providers_entry["class"])
|
|
||||||
return provider_class(config_providers_entry)
|
|
||||||
|
|
||||||
|
|
||||||
def get_static_data_provider_from_config(config_providers_entry):
|
|
||||||
"""Utility method to get a static reference data provider based on the class specified in its config entry."""
|
|
||||||
|
|
||||||
module = importlib.import_module('providers.staticdata.' + config_providers_entry["class"].lower())
|
|
||||||
provider_class = getattr(module, config_providers_entry["class"])
|
|
||||||
return provider_class(config_providers_entry)
|
|
||||||
|
|
||||||
|
|
||||||
def get_sig_ref_data_provider_from_config(config_providers_entry):
|
|
||||||
"""Utility method to get a SIG reference data provider based on the class specified in its config entry."""
|
|
||||||
|
|
||||||
module = importlib.import_module('providers.sigrefdata.' + config_providers_entry["class"].lower())
|
|
||||||
provider_class = getattr(module, config_providers_entry["class"])
|
provider_class = getattr(module, config_providers_entry["class"])
|
||||||
return provider_class(config_providers_entry)
|
return provider_class(config_providers_entry)
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ from data.band import Band
|
|||||||
from data.sig import SIG
|
from data.sig import SIG
|
||||||
|
|
||||||
# General software
|
# General software
|
||||||
SOFTWARE_VERSION = "1.4-pre"
|
SOFTWARE_VERSION = "2.0-pre"
|
||||||
|
|
||||||
# HTTP headers used for spot providers that use HTTP
|
# HTTP headers used for spot providers that use HTTP
|
||||||
HTTP_HEADERS = {"User-Agent": "Spothole v" + SOFTWARE_VERSION + " (operated by " + SERVER_OWNER_CALLSIGN + ")"}
|
HTTP_HEADERS = {"User-Agent": "Spothole v" + SOFTWARE_VERSION + " (operated by " + SERVER_OWNER_CALLSIGN + ")"}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from core.config import config, create_provider_from_config
|
||||||
|
|
||||||
|
|
||||||
|
class DataProviders:
|
||||||
|
"""Global object for storing data providers."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.spot_providers = []
|
||||||
|
self.alert_providers = []
|
||||||
|
self.solar_condition_providers = []
|
||||||
|
self.static_data_providers = []
|
||||||
|
self.sig_ref_data_providers = []
|
||||||
|
self.callsign_data_providers = []
|
||||||
|
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
for entry in config["spot-providers"]:
|
||||||
|
self.spot_providers.append(create_provider_from_config("providers.spot", entry))
|
||||||
|
for entry in config["alert-providers"]:
|
||||||
|
self.alert_providers.append(create_provider_from_config("providers.alert", entry))
|
||||||
|
for entry in config.get("solar-condition-providers", []):
|
||||||
|
self.solar_condition_providers.append(create_provider_from_config("providers.solarconditions", entry))
|
||||||
|
for entry in config.get("static-data-providers", []):
|
||||||
|
self.static_data_providers.append(create_provider_from_config("providers.staticdata", entry))
|
||||||
|
for entry in config.get("sig-ref-data-providers", []):
|
||||||
|
self.sig_ref_data_providers.append(create_provider_from_config("providers.sigrefdata", entry))
|
||||||
|
for entry in config.get("callsign-data-providers", []):
|
||||||
|
self.callsign_data_providers.append(create_provider_from_config("providers.callsigndata", entry))
|
||||||
|
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
# Start data providers before spot/alert providers so the lookup data is there already for incoming spots
|
||||||
|
for p in self.static_data_providers:
|
||||||
|
if p.enabled:
|
||||||
|
p.start()
|
||||||
|
for p in self.sig_ref_data_providers:
|
||||||
|
if p.enabled:
|
||||||
|
p.start()
|
||||||
|
for p in self.callsign_data_providers:
|
||||||
|
if p.enabled:
|
||||||
|
p.start()
|
||||||
|
for p in self.spot_providers:
|
||||||
|
if p.enabled:
|
||||||
|
p.start()
|
||||||
|
for p in self.alert_providers:
|
||||||
|
if p.enabled:
|
||||||
|
p.start()
|
||||||
|
for p in self.solar_condition_providers:
|
||||||
|
if p.enabled:
|
||||||
|
p.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()
|
||||||
|
|
||||||
|
# Global object
|
||||||
|
DATA_PROVIDERS = DataProviders()
|
||||||
+23
-7
@@ -20,17 +20,21 @@ class DataStore:
|
|||||||
self._MAX_SPOT_COUNT = 100000
|
self._MAX_SPOT_COUNT = 100000
|
||||||
self._MAX_ALERT_COUNT = 100000
|
self._MAX_ALERT_COUNT = 100000
|
||||||
self._SPOT_ALERT_SNAPSHOT_INTERVAL_SEC = 300
|
self._SPOT_ALERT_SNAPSHOT_INTERVAL_SEC = 300
|
||||||
self._CALLSIGN_DATA_TTL_SEC = 30 * 24 * 60 * 60
|
self.CALLSIGN_DATA_TTL_SEC = 30 * 24 * 60 * 60
|
||||||
# Caches
|
# Caches
|
||||||
self.alerts = None
|
self.alerts = None
|
||||||
self.spots = None
|
self.spots = None
|
||||||
self.callsigns = None
|
self.callsign_data_countryfiles = None
|
||||||
|
self.callsign_data_clublogxml = None
|
||||||
|
self.callsign_data_clublogapi = None
|
||||||
|
self.callsign_data_qrz = None
|
||||||
|
self.callsign_data_hamqth = None
|
||||||
self.dxcc_data = None
|
self.dxcc_data = None
|
||||||
self.dxcc_lookup_by_call_regex = []
|
self.dxcc_lookup_by_call_regex = []
|
||||||
self.sigrefs = None
|
self.sigrefs = None
|
||||||
self.status_data = None
|
self.status_data = {}
|
||||||
self._status = None
|
self._status = None
|
||||||
self.solar_conditions = None
|
self.solar_conditions = {}
|
||||||
self._solar = None
|
self._solar = None
|
||||||
# ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be
|
# ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be
|
||||||
# caches, they can just be straight objects
|
# caches, they can just be straight objects
|
||||||
@@ -65,8 +69,16 @@ class DataStore:
|
|||||||
# Standard disk cache for callsign data. This data does have a TTL to trigger an occasional re-lookup.
|
# 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
|
# 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.
|
# we've seen, so we rely on them timing out and this triggering another lookup.
|
||||||
self.callsigns = diskcache.Cache(CACHE_DIR + "callsigns")
|
self.callsign_data_countryfiles = diskcache.Cache(CACHE_DIR + "callsign_data_countryfiles")
|
||||||
logging.info(f"Loaded data for %d callsigns.", len(self.callsigns))
|
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")
|
||||||
|
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]:
|
||||||
|
unique_keys.update(c)
|
||||||
|
logging.info(f"Loaded data for %d callsigns.", len(unique_keys))
|
||||||
|
|
||||||
# Special caches for spots and alerts, which have TTL and write snapshots to disk at an interval. We
|
# 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
|
# specifically load these caches *last* so that any sigref and callsign data is already loaded from disk cache
|
||||||
@@ -97,7 +109,11 @@ class DataStore:
|
|||||||
self._status.close()
|
self._status.close()
|
||||||
self.dxcc_data.close()
|
self.dxcc_data.close()
|
||||||
self.sigrefs.close()
|
self.sigrefs.close()
|
||||||
self.callsigns.close()
|
self.callsign_data_countryfiles.close()
|
||||||
|
self.callsign_data_clublogxml.close()
|
||||||
|
self.callsign_data_clublogapi.close()
|
||||||
|
self.callsign_data_qrz.close()
|
||||||
|
self.callsign_data_hamqth.close()
|
||||||
|
|
||||||
# Global object
|
# Global object
|
||||||
DATA_STORE = DataStore()
|
DATA_STORE = DataStore()
|
||||||
@@ -92,7 +92,7 @@ class LiveDataCache:
|
|||||||
time.sleep(interval)
|
time.sleep(interval)
|
||||||
self.save_snapshot()
|
self.save_snapshot()
|
||||||
|
|
||||||
t = threading.Thread(target=loop, daemon=True, name=f"snapshot-{self._snapshot_dir}")
|
t = threading.Thread(target=loop, name=f"LiveDataCache-Snapshot-{self._snapshot_dir}")
|
||||||
t.start()
|
t.start()
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
|
|||||||
+30
-15
@@ -4,21 +4,20 @@ from pyhamtools.locator import locator_to_latlong, latlong_to_locator
|
|||||||
|
|
||||||
from core.data_store import DATA_STORE
|
from core.data_store import DATA_STORE
|
||||||
from core.geo_utils import wab_wai_square_to_lat_lon
|
from core.geo_utils import wab_wai_square_to_lat_lon
|
||||||
|
from data.sig_ref import SIGRef
|
||||||
|
|
||||||
|
|
||||||
def populate_sig_ref_info(sig_ref):
|
def get_sig_ref_info(sig, ref_id):
|
||||||
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object
|
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig name and
|
||||||
which must at minimum have a "sig" and an "id". The rest of the object will be populated and returned. This makes
|
a reference ID (both strings) and returns a SigRef object populated with as much data as we can find. This makes
|
||||||
use of SIG ref data in the data store, live lookups from the web, or just automatic calculation depending on which
|
use of SIG ref data in the data store, live lookups from the web, or just automatic calculation depending on which
|
||||||
SIG we are getting data for. Any data currently in the object will be kept, only missing data in the object will
|
SIG we are getting data for."""
|
||||||
be populated if it can be determined."""
|
|
||||||
|
|
||||||
if sig_ref.sig is None or sig_ref.sig == "" or sig_ref.id is None or sig_ref.id == "":
|
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 id were not set.")
|
logging.debug("Failed to look up sig_ref info, sig or ref were not set.")
|
||||||
return sig_ref
|
return None
|
||||||
|
|
||||||
sig = sig_ref.sig
|
sig_ref = SIGRef(sig=sig, id=ref_id)
|
||||||
ref_id = sig_ref.id
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
### FUDGES ###
|
### FUDGES ###
|
||||||
@@ -81,13 +80,29 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
key = sig + ":" + ref_id
|
key = sig + ":" + ref_id
|
||||||
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
|
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
|
||||||
if lookup_data:
|
if lookup_data:
|
||||||
# Copy new sig ref data into existing object where data was previously missing
|
return lookup_data
|
||||||
for key, value in lookup_data.__dict__.items():
|
|
||||||
if value is not None and sig_ref.__dict__.get(key) is None:
|
|
||||||
sig_ref.__dict__[key] = value
|
|
||||||
else:
|
else:
|
||||||
logging.warning("%s database did not contain data for ref %s", sig, ref_id)
|
# 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)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.error("Exception when looking up sig_ref info for " + sig + " ref " + ref_id, exc_info=True)
|
logging.error("Exception when looking up sig_ref info for " + sig + " ref " + ref_id, exc_info=True)
|
||||||
return sig_ref
|
return sig_ref
|
||||||
|
|
||||||
|
|
||||||
|
def populate_missing_sig_ref_info(sig_ref):
|
||||||
|
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object
|
||||||
|
which must at minimum have a "sig" and an "id". The rest of the object will be populated and returned. Any data
|
||||||
|
currently in the object will be kept, only missing data in the object will be populated if it can be determined."""
|
||||||
|
|
||||||
|
lookup_data = get_sig_ref_info(sig_ref.sig, sig_ref.id)
|
||||||
|
|
||||||
|
if lookup_data:
|
||||||
|
# Copy new sig ref data into existing object where data was previously missing
|
||||||
|
for key, value in lookup_data.__dict__.items():
|
||||||
|
if value is not None and sig_ref.__dict__.get(key) is None:
|
||||||
|
sig_ref.__dict__[key] = value
|
||||||
|
|
||||||
|
return sig_ref
|
||||||
|
|||||||
+38
-37
@@ -7,24 +7,19 @@ import pytz
|
|||||||
|
|
||||||
from core.config import SERVER_OWNER_CALLSIGN
|
from core.config import SERVER_OWNER_CALLSIGN
|
||||||
from core.constants import SOFTWARE_VERSION
|
from core.constants import SOFTWARE_VERSION
|
||||||
|
from core.data_providers import DATA_PROVIDERS
|
||||||
from core.data_store import DATA_STORE
|
from core.data_store import DATA_STORE
|
||||||
from core.prometheus_metrics_handler import memory_use_gauge, spots_gauge, alerts_gauge
|
from core.prometheus_metrics_handler import memory_use_gauge, spots_gauge, alerts_gauge
|
||||||
|
from server.webserver import WEB_SERVER
|
||||||
|
|
||||||
|
|
||||||
class StatusReporter:
|
class StatusReporter:
|
||||||
"""Provides a timed update of the application's status data."""
|
"""Provides a timed update of the application's status data."""
|
||||||
|
|
||||||
def __init__(self, run_interval, web_server, spot_providers, alert_providers, solar_condition_providers,
|
def __init__(self, run_interval):
|
||||||
static_data_providers, sig_ref_data_providers):
|
|
||||||
"""Constructor"""
|
"""Constructor"""
|
||||||
|
|
||||||
self._run_interval = run_interval
|
self._run_interval = run_interval
|
||||||
self._web_server = web_server
|
|
||||||
self._spot_providers = spot_providers
|
|
||||||
self._alert_providers = alert_providers
|
|
||||||
self._solar_condition_providers = solar_condition_providers
|
|
||||||
self._static_data_providers = static_data_providers
|
|
||||||
self._sig_ref_data_providers = sig_ref_data_providers
|
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._stop_event = Event()
|
self._stop_event = Event()
|
||||||
self._startup_time = datetime.now(pytz.UTC)
|
self._startup_time = datetime.now(pytz.UTC)
|
||||||
@@ -35,7 +30,7 @@ class StatusReporter:
|
|||||||
def start(self):
|
def start(self):
|
||||||
"""Start the reporter thread"""
|
"""Start the reporter thread"""
|
||||||
|
|
||||||
self._thread = Thread(target=self._run, daemon=True)
|
self._thread = Thread(target=self._run, name="StatusReporter")
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
@@ -59,45 +54,51 @@ class StatusReporter:
|
|||||||
DATA_STORE.status_data["num_spots"] = len(DATA_STORE.spots.values())
|
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["num_alerts"] = len(DATA_STORE.alerts.values())
|
||||||
DATA_STORE.status_data["spot_providers"] = list(
|
DATA_STORE.status_data["spot_providers"] = list(
|
||||||
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||||
"last_updated": p.last_update_time.replace(
|
"last_updated": p.last_update_time.replace(
|
||||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
|
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
|
||||||
"last_spot": p.last_spot_time.replace(
|
"last_spot": p.last_spot_time.replace(
|
||||||
tzinfo=pytz.UTC).timestamp() if p.last_spot_time.year > 2000 else 0},
|
tzinfo=pytz.UTC).timestamp() if p.last_spot_time.year > 2000 else 0},
|
||||||
self._spot_providers))
|
DATA_PROVIDERS.spot_providers))
|
||||||
DATA_STORE.status_data["alert_providers"] = list(
|
DATA_STORE.status_data["alert_providers"] = list(
|
||||||
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||||
"last_updated": p.last_update_time.replace(
|
"last_updated": p.last_update_time.replace(
|
||||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
||||||
self._alert_providers))
|
DATA_PROVIDERS.alert_providers))
|
||||||
DATA_STORE.status_data["solar_condition_providers"] = list(
|
DATA_STORE.status_data["solar_condition_providers"] = list(
|
||||||
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||||
"last_updated": p.last_update_time.replace(
|
"last_updated": p.last_update_time.replace(
|
||||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
||||||
self._solar_condition_providers))
|
DATA_PROVIDERS.solar_condition_providers))
|
||||||
DATA_STORE.status_data["static_data_providers"] = list(
|
DATA_STORE.status_data["static_data_providers"] = list(
|
||||||
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||||
"last_updated": p.last_update_time.replace(
|
"last_updated": p.last_update_time.replace(
|
||||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
||||||
self._static_data_providers))
|
DATA_PROVIDERS.static_data_providers))
|
||||||
DATA_STORE.status_data["sig_ref_data_providers"] = list(
|
DATA_STORE.status_data["sig_ref_data_providers"] = list(
|
||||||
map(lambda p: {"sig_name": p.sig_name, "enabled": p.enabled, "status": p.status,
|
map(lambda p: {"sig_name": p.sig_name, "enabled": p.enabled, "status": p.status,
|
||||||
"last_updated": p.last_update_time.replace(
|
"last_updated": p.last_update_time.replace(
|
||||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
|
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
|
||||||
"reference_count": p.reference_count},
|
"reference_count": p.reference_count},
|
||||||
self._sig_ref_data_providers))
|
DATA_PROVIDERS.sig_ref_data_providers))
|
||||||
DATA_STORE.status_data["webserver"] = {"status": self._web_server.web_server_metrics["status"],
|
DATA_STORE.status_data["callsign_data_providers"] = list(
|
||||||
"last_api_access": self._web_server.web_server_metrics[
|
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||||
"last_api_access_time"].replace(
|
"last_updated": p.last_update_time.replace(
|
||||||
tzinfo=pytz.UTC).timestamp() if self._web_server.web_server_metrics[
|
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
|
||||||
"last_api_access_time"] else 0,
|
"lookup_count": p.lookup_count},
|
||||||
"api_access_count": self._web_server.web_server_metrics["api_access_counter"],
|
DATA_PROVIDERS.callsign_data_providers))
|
||||||
"last_page_access": self._web_server.web_server_metrics[
|
DATA_STORE.status_data["webserver"] = {"status": WEB_SERVER.web_server_metrics["status"],
|
||||||
"last_page_access_time"].replace(
|
"last_api_access": WEB_SERVER.web_server_metrics[
|
||||||
tzinfo=pytz.UTC).timestamp() if self._web_server.web_server_metrics[
|
"last_api_access_time"].replace(
|
||||||
"last_page_access_time"] else 0,
|
tzinfo=pytz.UTC).timestamp() if WEB_SERVER.web_server_metrics[
|
||||||
"page_access_count": self._web_server.web_server_metrics[
|
"last_api_access_time"] else 0,
|
||||||
"page_access_counter"]}
|
"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"]}
|
||||||
|
|
||||||
# Update Prometheus metrics
|
# Update Prometheus metrics
|
||||||
memory_use_gauge.set(psutil.Process(os.getpid()).memory_info().rss)
|
memory_use_gauge.set(psutil.Process(os.getpid()).memory_info().rss)
|
||||||
|
|||||||
+39
-1
@@ -2,9 +2,11 @@ import logging
|
|||||||
|
|
||||||
import simplejson
|
import simplejson
|
||||||
from pyhamtools.frequency import freq_to_band
|
from pyhamtools.frequency import freq_to_band
|
||||||
|
from pyhamtools.locator import latlong_to_locator
|
||||||
|
|
||||||
from core.constants import UNKNOWN_BAND, BANDS, CW_MODES, PHONE_MODES, DATA_MODES, MODE_ALIASES, ALL_MODES
|
from core.constants import UNKNOWN_BAND, BANDS, CW_MODES, PHONE_MODES, DATA_MODES, MODE_ALIASES, ALL_MODES
|
||||||
from core.data_store import DATA_STORE
|
from core.data_store import DATA_STORE
|
||||||
|
from data.callsign import Callsign
|
||||||
|
|
||||||
|
|
||||||
def safe_json_dumps(obj):
|
def safe_json_dumps(obj):
|
||||||
@@ -76,4 +78,40 @@ def get_flag_for_dxcc(dxcc):
|
|||||||
"""Get an emoji flag for a given DXCC entity ID"""
|
"""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[dxcc] if dxcc in DATA_STORE.dxcc_data else None
|
||||||
return dxcc_data["flag"] if dxcc_data else None
|
return dxcc_data["flag"] if dxcc_data else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_callsign_object_from_pyhamtools_callinfo(callsign, callinfo):
|
||||||
|
"""Utility function to take the data provided by a PyHamTools CallInfo object and populate our own Callsign data
|
||||||
|
object from it"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
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
|
||||||
|
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")
|
||||||
|
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
# Unknown callsign, can't look anything up, return a Callsign object with basic data so that gets cached
|
||||||
|
return Callsign(call=callsign)
|
||||||
|
|||||||
+11
-12
@@ -7,8 +7,8 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
from core.call_lookup_helper import lookup_helper
|
from core.call_lookup_helper import get_call_info
|
||||||
from core.sig_lookup_helper import populate_sig_ref_info
|
from core.sig_lookup_helper import populate_missing_sig_ref_info
|
||||||
from core.utils import get_flag_for_dxcc
|
from core.utils import get_flag_for_dxcc
|
||||||
|
|
||||||
|
|
||||||
@@ -86,16 +86,17 @@ class Alert:
|
|||||||
|
|
||||||
# DX country, continent, zones etc. from callsign. CQ/ITU zone are better looked up with a location but we don't
|
# DX country, continent, zones etc. from callsign. CQ/ITU zone are better looked up with a location but we don't
|
||||||
# have a real location for alerts.
|
# have a real location for alerts.
|
||||||
|
call_info = get_call_info(self.dx_calls[0], credentials)
|
||||||
if self.dx_calls and self.dx_calls[0] and not self.dx_country:
|
if self.dx_calls and self.dx_calls[0] and not self.dx_country:
|
||||||
self.dx_country = lookup_helper.infer_country_from_callsign(self.dx_calls[0], credentials)
|
self.dx_country = call_info.country
|
||||||
if self.dx_calls and self.dx_calls[0] and not self.dx_continent:
|
if self.dx_calls and self.dx_calls[0] and not self.dx_continent:
|
||||||
self.dx_continent = lookup_helper.infer_continent_from_callsign(self.dx_calls[0], credentials)
|
self.dx_continent = call_info.continent
|
||||||
if self.dx_calls and self.dx_calls[0] and not self.dx_cq_zone:
|
if self.dx_calls and self.dx_calls[0] and not self.dx_cq_zone:
|
||||||
self.dx_cq_zone = lookup_helper.infer_cq_zone_from_callsign(self.dx_calls[0], credentials)
|
self.dx_cq_zone = call_info.cq_zone
|
||||||
if self.dx_calls and self.dx_calls[0] and not self.dx_itu_zone:
|
if self.dx_calls and self.dx_calls[0] and not self.dx_itu_zone:
|
||||||
self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_calls[0], credentials)
|
self.dx_itu_zone = call_info.itu_zone
|
||||||
if self.dx_calls and self.dx_calls[0] and not self.dx_dxcc_id:
|
if self.dx_calls and self.dx_calls[0] and not self.dx_dxcc_id:
|
||||||
self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_calls[0], credentials)
|
self.dx_dxcc_id = call_info.dxcc_id
|
||||||
if self.dx_dxcc_id and not self.dx_flag:
|
if self.dx_dxcc_id and not self.dx_flag:
|
||||||
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
|
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
|
||||||
|
|
||||||
@@ -104,7 +105,7 @@ class Alert:
|
|||||||
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
|
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
|
||||||
if self.sig_refs and len(self.sig_refs) > 0:
|
if self.sig_refs and len(self.sig_refs) > 0:
|
||||||
for sig_ref in self.sig_refs:
|
for sig_ref in self.sig_refs:
|
||||||
populate_sig_ref_info(sig_ref)
|
populate_missing_sig_ref_info(sig_ref)
|
||||||
|
|
||||||
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
|
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
|
||||||
# and apply it to the whole spot.
|
# and apply it to the whole spot.
|
||||||
@@ -124,12 +125,10 @@ class Alert:
|
|||||||
self_copy.received_time_iso = ""
|
self_copy.received_time_iso = ""
|
||||||
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
|
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
# DX operator details lookup, using QRZ.com/HamQTH. This should be the last resort compared to taking the data
|
# DX operator name lookup, using QRZ.com/HamQTH.
|
||||||
# from the actual alerting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon
|
|
||||||
# instead of the one from the park reference they're at.
|
|
||||||
if self.dx_calls and not self.dx_names:
|
if self.dx_calls and not self.dx_names:
|
||||||
self.dx_names = list(
|
self.dx_names = list(
|
||||||
map(lambda c: lookup_helper.infer_name_from_callsign_online_lookup(c, credentials), self.dx_calls))
|
map(lambda c: get_call_info(c, credentials).name, self.dx_calls))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error("Exception while inferring missing data from spot", e, exc_info=True)
|
logging.error("Exception while inferring missing data from spot", e, exc_info=True)
|
||||||
|
|||||||
+15
-6
@@ -11,7 +11,7 @@ class Callsign:
|
|||||||
# Callsign as spotted
|
# Callsign as spotted
|
||||||
call: str
|
call: str
|
||||||
# "Home" call, i.e. with any prefixes and suffixes stripped off
|
# "Home" call, i.e. with any prefixes and suffixes stripped off
|
||||||
home_call: str
|
home_call: str | None = None
|
||||||
# Operator name
|
# Operator name
|
||||||
name : str | None = None
|
name : str | None = None
|
||||||
# QTH (location), free text
|
# QTH (location), free text
|
||||||
@@ -26,13 +26,22 @@ class Callsign:
|
|||||||
# the centre of the country they're operating in if no other data is available.
|
# 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 in which the callsign indicates they are operating
|
||||||
dx_country: str | None = None
|
country: str | None = None
|
||||||
# Continent in which the callsign indicates they are operating
|
# Continent in which the callsign indicates they are operating
|
||||||
dx_continent: str | None = None
|
continent: str | None = None
|
||||||
# DXCC ID in which the callsign indicates they are operating
|
# DXCC ID in which the callsign indicates they are operating
|
||||||
dx_dxcc_id: int | None = None
|
dxcc_id: int | None = None
|
||||||
# CQ zone in which the callsign indicates they are operating
|
# CQ zone in which the callsign indicates they are operating
|
||||||
dx_cq_zone: int | None = None
|
cq_zone: int | None = None
|
||||||
# ITU zone in which the callsign indicates they are operating
|
# ITU zone in which the callsign indicates they are operating
|
||||||
dx_itu_zone: int | None = None
|
itu_zone: int | None = None
|
||||||
|
# Location source. This can be "HOME QTH" or "DXCC" depending on which provider gave us a location
|
||||||
|
location_source: str | 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
|
||||||
|
|||||||
+25
-55
@@ -12,9 +12,9 @@ from pyhamtools.locator import locator_to_latlong, latlong_to_locator
|
|||||||
from core.config import MAX_SPOT_AGE
|
from core.config import MAX_SPOT_AGE
|
||||||
from core.constants import MODE_ALIASES, PROPAGATION_MODES
|
from core.constants import MODE_ALIASES, PROPAGATION_MODES
|
||||||
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
||||||
from core.call_lookup_helper import lookup_helper
|
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.sig_utils import ANY_SIG_REGEX, get_ref_regex_for_sig, get_sig_name_from_comment_name
|
||||||
from core.sig_lookup_helper import populate_sig_ref_info
|
from core.sig_lookup_helper import populate_missing_sig_ref_info
|
||||||
from core.utils import infer_band_from_freq, infer_mode_from_comment, \
|
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
|
infer_mode_from_frequency, infer_mode_type_from_mode, get_flag_for_dxcc
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
@@ -173,12 +173,13 @@ class Spot:
|
|||||||
self.dx_ssid = split[1]
|
self.dx_ssid = split[1]
|
||||||
|
|
||||||
# DX country, continent etc. from callsign
|
# DX country, continent etc. from callsign
|
||||||
|
dx_call_info = get_call_info(self.dx_call, credentials)
|
||||||
if self.dx_call and not self.dx_country:
|
if self.dx_call and not self.dx_country:
|
||||||
self.dx_country = lookup_helper.infer_country_from_callsign(self.dx_call, credentials)
|
self.dx_country = dx_call_info.country
|
||||||
if self.dx_call and not self.dx_continent:
|
if self.dx_call and not self.dx_continent:
|
||||||
self.dx_continent = lookup_helper.infer_continent_from_callsign(self.dx_call, credentials)
|
self.dx_continent = dx_call_info.continent
|
||||||
if self.dx_call and not self.dx_dxcc_id:
|
if self.dx_call and not self.dx_dxcc_id:
|
||||||
self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_call, credentials)
|
self.dx_dxcc_id = dx_call_info.dxcc_id
|
||||||
if self.dx_dxcc_id and not self.dx_flag:
|
if self.dx_dxcc_id and not self.dx_flag:
|
||||||
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
|
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
|
||||||
|
|
||||||
@@ -205,14 +206,15 @@ class Spot:
|
|||||||
|
|
||||||
# Spotter country, continent, zones etc. from callsign.
|
# 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 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 (
|
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"):
|
self.de_call.startswith("T2") and self.source == "APRS-IS"):
|
||||||
if not self.de_country:
|
if not self.de_country:
|
||||||
self.de_country = lookup_helper.infer_country_from_callsign(self.de_call, credentials)
|
self.de_country = de_call_info.country
|
||||||
if not self.de_continent:
|
if not self.de_continent:
|
||||||
self.de_continent = lookup_helper.infer_continent_from_callsign(self.de_call, credentials)
|
self.de_continent = de_call_info.continent
|
||||||
if not self.de_dxcc_id:
|
if not self.de_dxcc_id:
|
||||||
self.de_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.de_call, credentials)
|
self.de_dxcc_id = de_call_info.dxcc_id
|
||||||
if self.de_dxcc_id and not self.de_flag:
|
if self.de_dxcc_id and not self.de_flag:
|
||||||
self.de_flag = get_flag_for_dxcc(self.de_dxcc_id)
|
self.de_flag = get_flag_for_dxcc(self.de_dxcc_id)
|
||||||
|
|
||||||
@@ -287,7 +289,7 @@ class Spot:
|
|||||||
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
|
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
|
||||||
if self.sig_refs and len(self.sig_refs) > 0:
|
if self.sig_refs and len(self.sig_refs) > 0:
|
||||||
for sig_ref in self.sig_refs:
|
for sig_ref in self.sig_refs:
|
||||||
sig_ref = populate_sig_ref_info(sig_ref)
|
sig_ref = populate_missing_sig_ref_info(sig_ref)
|
||||||
# If the spot itself doesn't have location yet, but the SIG ref does, extract it
|
# If the spot itself doesn't have location yet, but the SIG ref does, extract it
|
||||||
if sig_ref.grid and not self.dx_grid:
|
if sig_ref.grid and not self.dx_grid:
|
||||||
self.dx_grid = sig_ref.grid
|
self.dx_grid = sig_ref.grid
|
||||||
@@ -359,18 +361,16 @@ class Spot:
|
|||||||
self_copy.received_time_iso = ""
|
self_copy.received_time_iso = ""
|
||||||
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
|
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
# DX operator details lookup, using QRZ.com/HamQTH. This should be the last resort compared to taking the data
|
# DX operator details lookup. This should be the last resort compared to taking the data from the actual
|
||||||
# from the actual spotting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon
|
# 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.
|
# instead of the one from the park reference they're at.
|
||||||
if self.dx_call and not self.dx_name:
|
if self.dx_call and not self.dx_name:
|
||||||
self.dx_name = lookup_helper.infer_name_from_callsign_online_lookup(self.dx_call, credentials)
|
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:
|
||||||
latlon = lookup_helper.infer_latlon_from_callsign_online_lookup(self.dx_call, credentials)
|
self.dx_latitude = dx_call_info.latitude
|
||||||
if latlon:
|
self.dx_longitude = dx_call_info.longitude
|
||||||
self.dx_latitude = latlon[0]
|
self.dx_grid = dx_call_info.grid
|
||||||
self.dx_longitude = latlon[1]
|
self.dx_location_source = dx_call_info.location_source
|
||||||
self.dx_grid = lookup_helper.infer_grid_from_callsign_online_lookup(self.dx_call, credentials)
|
|
||||||
self.dx_location_source = "HOME QTH"
|
|
||||||
|
|
||||||
# Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable string,
|
# Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable string,
|
||||||
# otherwise see what they have set on an online lookup service.
|
# otherwise see what they have set on an online lookup service.
|
||||||
@@ -380,39 +380,19 @@ class Spot:
|
|||||||
qth += " " + self.sig_refs[0].name
|
qth += " " + self.sig_refs[0].name
|
||||||
self.dx_qth = qth
|
self.dx_qth = qth
|
||||||
else:
|
else:
|
||||||
self.dx_qth = lookup_helper.infer_qth_from_callsign_online_lookup(self.dx_call, credentials)
|
self.dx_qth = dx_call_info.qth
|
||||||
|
|
||||||
# Last resort for getting a DX position, use the DXCC entity.
|
|
||||||
if self.dx_call and not self.dx_latitude:
|
|
||||||
latlon = lookup_helper.infer_latlon_from_callsign_dxcc(self.dx_call)
|
|
||||||
if latlon:
|
|
||||||
self.dx_latitude = latlon[0]
|
|
||||||
self.dx_longitude = latlon[1]
|
|
||||||
self.dx_grid = lookup_helper.infer_grid_from_callsign_dxcc(self.dx_call)
|
|
||||||
self.dx_location_source = "DXCC"
|
|
||||||
|
|
||||||
# It looks like we can sometimes get a string into lat/lon, so try to parse as float, reject if not valid
|
|
||||||
if isinstance(self.dx_latitude, str) or isinstance(self.dx_longitude, str):
|
|
||||||
try:
|
|
||||||
self.dx_latitude = float(str(self.dx_latitude))
|
|
||||||
self.dx_longitude = float(str(self.dx_longitude))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
logging.warning("Received non-numeric strings in lat/lon (" + str(self.dx_latitude) + ", " + str(
|
|
||||||
self.dx_longitude) + ") for call " + str(self.dx_call) + ", rejecting it")
|
|
||||||
self.dx_latitude = None
|
|
||||||
self.dx_longitude = None
|
|
||||||
|
|
||||||
# CQ and ITU zone lookup, preferably from location but failing that, from callsign
|
# CQ and ITU zone lookup, preferably from location but failing that, from callsign
|
||||||
if not self.dx_cq_zone:
|
if not self.dx_cq_zone:
|
||||||
if self.dx_latitude:
|
if self.dx_latitude:
|
||||||
self.dx_cq_zone = lat_lon_to_cq_zone(self.dx_latitude, self.dx_longitude)
|
self.dx_cq_zone = lat_lon_to_cq_zone(self.dx_latitude, self.dx_longitude)
|
||||||
elif self.dx_call:
|
elif self.dx_call:
|
||||||
self.dx_cq_zone = lookup_helper.infer_cq_zone_from_callsign(self.dx_call, credentials)
|
self.dx_cq_zone = dx_call_info.cq_zone
|
||||||
if not self.dx_itu_zone:
|
if not self.dx_itu_zone:
|
||||||
if self.dx_latitude:
|
if self.dx_latitude:
|
||||||
self.dx_itu_zone = lat_lon_to_itu_zone(self.dx_latitude, self.dx_longitude)
|
self.dx_itu_zone = lat_lon_to_itu_zone(self.dx_latitude, self.dx_longitude)
|
||||||
elif self.dx_call:
|
elif self.dx_call:
|
||||||
self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_call, credentials)
|
self.dx_itu_zone = dx_call_info.itu_zone
|
||||||
|
|
||||||
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
|
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
|
||||||
# is likely at home.
|
# is likely at home.
|
||||||
@@ -424,21 +404,11 @@ class Spot:
|
|||||||
# DE with no digits and APRS servers starting "T2" are not things we can look up location for
|
# 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 (
|
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"):
|
self.de_call.startswith("T2") and self.source == "APRS-IS"):
|
||||||
# DE operator position lookup, using QRZ.com/HamQTH.
|
# DE operator location lookup
|
||||||
if not self.de_latitude:
|
if not self.de_latitude:
|
||||||
latlon = lookup_helper.infer_latlon_from_callsign_online_lookup(self.de_call, credentials)
|
self.de_latitude = de_call_info.latitude
|
||||||
if latlon:
|
self.de_longitude = de_call_info.longitude
|
||||||
self.de_latitude = latlon[0]
|
self.de_grid = de_call_info.grid
|
||||||
self.de_longitude = latlon[1]
|
|
||||||
self.de_grid = lookup_helper.infer_grid_from_callsign_online_lookup(self.de_call, credentials)
|
|
||||||
|
|
||||||
# Last resort for getting a DE position, use the DXCC entity.
|
|
||||||
if not self.de_latitude:
|
|
||||||
latlon = lookup_helper.infer_latlon_from_callsign_dxcc(self.de_call)
|
|
||||||
if latlon:
|
|
||||||
self.de_latitude = latlon[0]
|
|
||||||
self.de_longitude = latlon[1]
|
|
||||||
self.de_grid = lookup_helper.infer_grid_from_callsign_dxcc(self.de_call)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error("Exception while inferring missing data from spot", e, exc_info=True)
|
logging.error("Exception while inferring missing data from spot", e, exc_info=True)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class HTTPAlertProvider(AlertProvider):
|
|||||||
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
||||||
# subsequent polls, so start() returns immediately and the application can continue starting.
|
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||||
logging.info("Set up query of " + self.name + " alert API every " + str(self._poll_interval) + " seconds.")
|
logging.info("Set up query of " + self.name + " alert API every " + str(self._poll_interval) + " seconds.")
|
||||||
self._thread = Thread(target=self._run, daemon=True)
|
self._thread = Thread(target=self._run, name=f"HTTPAlertProvider-{self.name}")
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
super().__init__(name, provider_config, storage)
|
||||||
|
|
||||||
|
if self.enabled:
|
||||||
|
self.status = "Ready"
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
pass
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from core.data_store import DATA_STORE
|
||||||
|
|
||||||
|
|
||||||
|
class CallsignDataProvider:
|
||||||
|
"""Generic callsign reference data provider class. Subclasses of this set up the various mechanisms via which
|
||||||
|
Spothole can look up data for callsigns."""
|
||||||
|
|
||||||
|
def __init__(self, name, provider_config, storage):
|
||||||
|
"""Constructor. As well as name and config, provide the storage object from DATA_STORE that will be used to
|
||||||
|
store the result of lookups to speed up future access."""
|
||||||
|
|
||||||
|
self.name = name
|
||||||
|
self.enabled = provider_config["enabled"]
|
||||||
|
self.priority = int(provider_config["priority"])
|
||||||
|
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||||
|
self.status = "Not Started" if self.enabled else "Disabled"
|
||||||
|
self.lookup_count = 0
|
||||||
|
self._storage = storage
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start the provider. This should return immediately after spawning threads to access remote resources, if
|
||||||
|
needed."""
|
||||||
|
|
||||||
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop any threads and prepare for application shutdown"""
|
||||||
|
|
||||||
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
|
|
||||||
|
def lookup(self, callsign, lookup_credentials):
|
||||||
|
"""Looks up data for the provided callsign. Takes a LookupCredentials object, which provides any credentials
|
||||||
|
that have been provided by the user for this session (QRZ.com/HamQTH) to allow us to look up using those
|
||||||
|
services on the user's behalf. (Clublog is looked up using an API key owned by the server and provided in its
|
||||||
|
config file, so users need not provide their own.) Returns a Callsign object with as much data populated as
|
||||||
|
possible. Data is cached internally for a set period of 30 days to avoid the need to request data from servers
|
||||||
|
each time."""
|
||||||
|
|
||||||
|
if self.enabled:
|
||||||
|
if callsign in self._storage:
|
||||||
|
return self._storage[callsign]
|
||||||
|
else:
|
||||||
|
c = self._perform_new_lookup(callsign, lookup_credentials)
|
||||||
|
if c:
|
||||||
|
self._storage.set(callsign, c, expire=DATA_STORE.CALLSIGN_DATA_TTL_SEC)
|
||||||
|
return c
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _perform_new_lookup(self, callsign, lookup_credentials):
|
||||||
|
"""Makes a new request to the data source for callsign data."""
|
||||||
|
|
||||||
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from pyhamtools import LookupLib, Callinfo
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class ClublogAPI(APIQueryCallsignDataProvider):
|
||||||
|
"""Callsign data provider for Clublog's API."""
|
||||||
|
|
||||||
|
_callinfo = None
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
# API key required for this provider
|
||||||
|
self._api_key = provider_config.get("api-key", "")
|
||||||
|
if self._api_key != "":
|
||||||
|
lookuplib = LookupLib(lookuptype="clublogapi", apikey=self._api_key)
|
||||||
|
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.")
|
||||||
|
|
||||||
|
super().__init__("Clublog API", provider_config, DATA_STORE.callsign_data_clublogapi)
|
||||||
|
|
||||||
|
|
||||||
|
def _perform_new_lookup(self, callsign, lookup_credentials):
|
||||||
|
callsign_data = Callsign(call=callsign)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._callinfo:
|
||||||
|
callsign_data = get_callsign_object_from_pyhamtools_callinfo(callsign, self._callinfo)
|
||||||
|
self.status = "OK"
|
||||||
|
self.last_update_time = datetime.now(pytz.UTC)
|
||||||
|
self.lookup_count += 1
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.error("Exception when looking up data from Clublog API", e, exc_info=True)
|
||||||
|
|
||||||
|
return callsign_data
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import gzip
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from pyhamtools import LookupLib, Callinfo
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class ClublogXML(FileDownloadCallsignDataProvider):
|
||||||
|
"""Callsign data provider for ClubLog's Country File, which provides basic callsign to DXCC entity mapping."""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 30
|
||||||
|
DATA_URL = "https://cdn.clublog.org/cty.php"
|
||||||
|
CACHE_PATH_ZIPPED = "cache/cty.xml.gz"
|
||||||
|
CACHE_PATH_UNZIPPED = "cache/cty.xml"
|
||||||
|
_callinfo = None
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
# API key required for this provider
|
||||||
|
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.")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def _handle_file(self, path):
|
||||||
|
try:
|
||||||
|
# The download from Clublog is gzipped so we need to uncompress that and re-save as a separate file that
|
||||||
|
# the LookupLib can actually use.
|
||||||
|
with gzip.open(path, "rb") as uncompressed:
|
||||||
|
file_content = uncompressed.read()
|
||||||
|
assert isinstance(file_content, bytes)
|
||||||
|
with open(self.CACHE_PATH_UNZIPPED, "wb") as f:
|
||||||
|
f.write(file_content)
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
# Now load the data
|
||||||
|
lookuplib = LookupLib(lookuptype="clublogxml", filename=self.CACHE_PATH_UNZIPPED)
|
||||||
|
self._callinfo = Callinfo(lookuplib)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("Exception when loading Clublog XML.", e, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _perform_new_lookup(self, callsign, lookup_credentials):
|
||||||
|
callsign_data = Callsign(call=callsign)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._callinfo:
|
||||||
|
callsign_data = get_callsign_object_from_pyhamtools_callinfo(callsign, self._callinfo)
|
||||||
|
self.status = "OK"
|
||||||
|
self.lookup_count += 1
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.error("Exception when looking up data from Clublog XML data", e, exc_info=True)
|
||||||
|
|
||||||
|
return callsign_data
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from pyhamtools import LookupLib, Callinfo
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class CountryFiles(FileDownloadCallsignDataProvider):
|
||||||
|
"""Callsign data provider for Country-files.com, which provides basic callsign to DXCC entity mapping."""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 30
|
||||||
|
DATA_URL = "https://www.country-files.com/cty/cty.plist"
|
||||||
|
CACHE_PATH = "cache/cty.plist"
|
||||||
|
_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)
|
||||||
|
|
||||||
|
def _handle_file(self, path):
|
||||||
|
try:
|
||||||
|
lookuplib = LookupLib(lookuptype="countryfile", filename=path)
|
||||||
|
self._callinfo = Callinfo(lookuplib)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("Exception when loading Country Files cty.plist.", e, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _perform_new_lookup(self, callsign, lookup_credentials):
|
||||||
|
callsign_data = Callsign(call=callsign)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._callinfo:
|
||||||
|
callsign_data = get_callsign_object_from_pyhamtools_callinfo(callsign, self._callinfo)
|
||||||
|
self.status = "OK"
|
||||||
|
self.lookup_count += 1
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.error("Exception when looking up data from Country file", e, exc_info=True)
|
||||||
|
|
||||||
|
return callsign_data
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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*."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
if self.enabled:
|
||||||
|
self.status = "Ready"
|
||||||
|
|
||||||
|
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}")
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop_event.set()
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
while True:
|
||||||
|
self._poll()
|
||||||
|
if self._stop_event.wait(timeout=self._poll_interval * 60 * 60 * 24):
|
||||||
|
break
|
||||||
|
|
||||||
|
def _poll(self):
|
||||||
|
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...")
|
||||||
|
http_response = self._url_data_cache.get(self._url, headers=HTTP_HEADERS)
|
||||||
|
# Check response code was good
|
||||||
|
if http_response.ok:
|
||||||
|
# Save the data to a local file
|
||||||
|
with open(self._cache_file_path, "wb") as f:
|
||||||
|
f.write(http_response.content)
|
||||||
|
f.flush()
|
||||||
|
# Pass off to the subclass for processing
|
||||||
|
ok = self._handle_file(self._cache_file_path)
|
||||||
|
if ok:
|
||||||
|
self.status = "OK"
|
||||||
|
self.last_update_time = datetime.now(pytz.UTC)
|
||||||
|
logging.info("Updated callsign reference data from " + self.name)
|
||||||
|
else:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.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}.")
|
||||||
|
|
||||||
|
except ConnectionError:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.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}.")
|
||||||
|
except Exception:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.exception("Exception in callsign reference data provider (" + self.name + ")")
|
||||||
|
self._stop_event.wait(timeout=1)
|
||||||
|
|
||||||
|
def _handle_file(self, path):
|
||||||
|
"""Handle an updated file downloaded from the server. Return true if successful, false otherwise."""
|
||||||
|
|
||||||
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import logging
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
import xmltodict
|
||||||
|
from pyhamtools import callinfo
|
||||||
|
from requests import 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.url_data_cache import URLDataCache
|
||||||
|
from data.callsign import Callsign
|
||||||
|
from providers.callsigndata.api_query_callsign_data_provider import APIQueryCallsignDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class HamQTH(APIQueryCallsignDataProvider):
|
||||||
|
"""Callsign data provider for HamQTH."""
|
||||||
|
|
||||||
|
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._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))
|
||||||
|
|
||||||
|
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):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Obtain session key from credentials, by looking it up from username & password if necessary.
|
||||||
|
session_id = None
|
||||||
|
if lookup_credentials.hamqth_session_id:
|
||||||
|
session_id = lookup_credentials.hamqth_session_id
|
||||||
|
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
|
||||||
|
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.")
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
logging.error("Exception when getting HamQTH session key")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not session_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
|
||||||
|
calls_to_try = [callsign]
|
||||||
|
try:
|
||||||
|
home_call = callinfo.Callinfo.get_homecall(callsign)
|
||||||
|
if home_call != callsign:
|
||||||
|
calls_to_try.append(home_call)
|
||||||
|
except ValueError:
|
||||||
|
logging.debug("Could not look up home call for callsign %s", 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)
|
||||||
|
if response.ok:
|
||||||
|
# Found data, convert it to our object and return it
|
||||||
|
data = xmltodict.parse(response.content)["HamQTH"]["search"]
|
||||||
|
self.status = "OK"
|
||||||
|
self.last_update_time = datetime.now(pytz.UTC)
|
||||||
|
self.lookup_count += 1
|
||||||
|
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)
|
||||||
|
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
continue
|
||||||
|
except ConnectionError:
|
||||||
|
logging.warning(f"Connection error when looking up callsign %s using HamQTH", lookup_call)
|
||||||
|
continue
|
||||||
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
logging.warning(f"Timeout when looking up callsign %s using HamQTH", lookup_call)
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
logging.error("Exception when looking up callsign %s using HamQTH", lookup_call, exc_info=True)
|
||||||
|
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:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.error("Exception when looking up data from HamQTH", e, exc_info=True)
|
||||||
|
# Return None, this won't be cached so we will be asked to query data again for this call next time.
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hamqth_response_to_callsign(callsign, data):
|
||||||
|
"""Convert the "Callsign" block in HamQTH's API response to our own Callsign object."""
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
lat = float(data["latitude"])
|
||||||
|
lon = float(data["longitude"])
|
||||||
|
|
||||||
|
# Check for sensible grids
|
||||||
|
grid = None
|
||||||
|
if "grid" in data 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")
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import logging
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
import xmltodict
|
||||||
|
from pyhamtools import callinfo
|
||||||
|
from requests import ConnectTimeout, ReadTimeout
|
||||||
|
from requests_cache import CachedSession
|
||||||
|
|
||||||
|
from core.constants import HTTP_HEADERS
|
||||||
|
from core.data_store import DATA_STORE, CACHE_DIR
|
||||||
|
from core.url_data_cache import URLDataCache
|
||||||
|
from data.callsign import Callsign
|
||||||
|
from providers.callsigndata.api_query_callsign_data_provider import APIQueryCallsignDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class QRZ(APIQueryCallsignDataProvider):
|
||||||
|
"""Callsign data provider for QRZ.com."""
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__("QRZ.com", provider_config, DATA_STORE.callsign_data_qrz)
|
||||||
|
self._QRZ_BASE_URL = "https://xmldata.qrz.com/xml/current/"
|
||||||
|
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))
|
||||||
|
|
||||||
|
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):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Obtain session key from credentials, by looking it up from username & password if necessary.
|
||||||
|
session_key = None
|
||||||
|
if lookup_credentials.qrz_session_key:
|
||||||
|
session_key = lookup_credentials.qrz_session_key
|
||||||
|
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
|
||||||
|
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.")
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
logging.error("Exception when getting QRZ.com session key")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not session_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
|
||||||
|
calls_to_try = [callsign]
|
||||||
|
try:
|
||||||
|
home_call = callinfo.Callinfo.get_homecall(callsign)
|
||||||
|
if home_call != callsign:
|
||||||
|
calls_to_try.append(home_call)
|
||||||
|
except ValueError:
|
||||||
|
logging.debug("Could not look up home call for callsign %s", 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)
|
||||||
|
if response.ok:
|
||||||
|
qrz_response = xmltodict.parse(response.content).get("QRZDatabase", {})
|
||||||
|
if qrz_response:
|
||||||
|
if "Callsign" in qrz_response:
|
||||||
|
qrz_data = qrz_response.get("Callsign")
|
||||||
|
self.status = "OK"
|
||||||
|
self.last_update_time = datetime.now(pytz.UTC)
|
||||||
|
self.lookup_count += 1
|
||||||
|
# Found data, convert it to our object and return it
|
||||||
|
return self.qrz_response_to_callsign(callsign, qrz_data)
|
||||||
|
|
||||||
|
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"))
|
||||||
|
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("QRZ returned a malformed response looking up callsign %s", lookup_call)
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("HTTP %d looking up callsign %s using QRZ", lookup_call)
|
||||||
|
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
continue
|
||||||
|
except ConnectionError:
|
||||||
|
logging.warning(f"Connection error when looking up callsign %s using QRZ", lookup_call)
|
||||||
|
continue
|
||||||
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
logging.warning(f"Timeout when looking up callsign %s using QRZ.", lookup_call)
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
logging.error("Exception when looking up callsign %s using QRZ", lookup_call, exc_info=True)
|
||||||
|
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:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.error("Exception when looking up data from QRZ.com", e, exc_info=True)
|
||||||
|
# Return None, this won't be cached so we will be asked to query data again for this call next time.
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def qrz_response_to_callsign(callsign, data):
|
||||||
|
"""Convert the "Callsign" block in QRZ's API response to our own Callsign object."""
|
||||||
|
|
||||||
|
# Get a name
|
||||||
|
name = None
|
||||||
|
if "name_fmt" in data:
|
||||||
|
name = data["name_fmt"]
|
||||||
|
if "fname" in data:
|
||||||
|
name = data["fname"]
|
||||||
|
if "nick" in data:
|
||||||
|
name = name + " \"" + data["nick"] + "\""
|
||||||
|
if "name" in data:
|
||||||
|
name = 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:
|
||||||
|
lat = float(data["latitude"])
|
||||||
|
lon = float(data["longitude"])
|
||||||
|
|
||||||
|
# Check for sensible grids
|
||||||
|
grid = None
|
||||||
|
if "grid" in data 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")
|
||||||
@@ -28,7 +28,7 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
|
|||||||
# subsequent polls, so start() returns immediately and the application can continue starting.
|
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||||
logging.info(
|
logging.info(
|
||||||
"Set up query of " + self.sig_name + " SIG ref data every " + str(self._poll_interval) + " days.")
|
"Set up query of " + self.sig_name + " SIG ref data every " + str(self._poll_interval) + " days.")
|
||||||
self._thread = Thread(target=self._run, daemon=True)
|
self._thread = Thread(target=self._run, name=f"FileDownloadSIGRefDataProvider-{self.sig_name}")
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class GIROIonosonde(SolarConditionsProvider):
|
|||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
logging.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
|
logging.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
|
||||||
self._thread = Thread(target=self._run, daemon=True)
|
self._thread = Thread(target=self._run, name="GIROIonosondeDataProvider")
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
|
|||||||
def start(self):
|
def start(self):
|
||||||
logging.info(
|
logging.info(
|
||||||
"Set up query of " + self.name + " solar conditions API every " + str(self._poll_interval) + " seconds.")
|
"Set up query of " + self.name + " solar conditions API every " + str(self._poll_interval) + " seconds.")
|
||||||
self._thread = Thread(target=self._run, daemon=True)
|
self._thread = Thread(target=self._run, name=f"HTTPSolarConditionsProvider-{self.name}")
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class KC2GProp(SolarConditionsProvider):
|
|||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
logging.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.")
|
logging.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.")
|
||||||
self._thread = Thread(target=self._run, daemon=True)
|
self._thread = Thread(target=self._run, name="KC2GPropProvider")
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class APRSIS(SpotProvider):
|
|||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__("APRS-IS", provider_config)
|
super().__init__("APRS-IS", provider_config)
|
||||||
self._thread = Thread(target=self._connect)
|
self._thread = Thread(target=self._connect, name="APRSISSpotProvider")
|
||||||
self._thread.daemon = True
|
self._thread.daemon = True
|
||||||
self._aprsis = None
|
self._aprsis = None
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class DXCluster(SpotProvider):
|
|||||||
self._allow_rbn_spots = provider_config["allow_rbn_spots"] if "allow_rbn_spots" in provider_config else False
|
self._allow_rbn_spots = provider_config["allow_rbn_spots"] if "allow_rbn_spots" in provider_config else False
|
||||||
self._spot_line_pattern = self._LINE_PATTERN_ALLOW_RBN if self._allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN
|
self._spot_line_pattern = self._LINE_PATTERN_ALLOW_RBN if self._allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN
|
||||||
self._telnet = None
|
self._telnet = None
|
||||||
self._thread = Thread(target=self._handle)
|
self._thread = Thread(target=self._handle, name=f"DXClusterSpotProvider-{self.name}")
|
||||||
self._thread.daemon = True
|
self._thread.daemon = True
|
||||||
self._running = True
|
self._running = True
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class HTTPSpotProvider(SpotProvider):
|
|||||||
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
||||||
# subsequent polls, so start() returns immediately and the application can continue starting.
|
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||||
logging.info("Set up query of " + self.name + " spot API every " + str(self._poll_interval) + " seconds.")
|
logging.info("Set up query of " + self.name + " spot API every " + str(self._poll_interval) + " seconds.")
|
||||||
self._thread = Thread(target=self._run, daemon=True)
|
self._thread = Thread(target=self._run, name=f"HTTPSpotProvider-{self.name}")
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class RBN(SpotProvider):
|
|||||||
super().__init__(name, provider_config)
|
super().__init__(name, provider_config)
|
||||||
self._port = provider_config["port"]
|
self._port = provider_config["port"]
|
||||||
self._telnet = None
|
self._telnet = None
|
||||||
self._thread = Thread(target=self._handle)
|
self._thread = Thread(target=self._handle, name=f"RBNSpotProvider-{self.name}")
|
||||||
self._thread.daemon = True
|
self._thread.daemon = True
|
||||||
self._running = True
|
self._running = True
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from threading import Thread
|
from threading import Event, Lock, Thread
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from requests_sse import EventSource
|
from requests_sse import EventSource
|
||||||
@@ -16,24 +15,35 @@ class SSESpotProvider(SpotProvider):
|
|||||||
def __init__(self, name, provider_config, url):
|
def __init__(self, name, provider_config, url):
|
||||||
super().__init__(name, provider_config)
|
super().__init__(name, provider_config)
|
||||||
self._url = url
|
self._url = url
|
||||||
self._event_source = None
|
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._stopped = False
|
|
||||||
self._last_event_id = None
|
self._last_event_id = None
|
||||||
|
self._stop_event = Event()
|
||||||
|
self._event_source_lock = Lock()
|
||||||
|
self._event_source = None
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
logging.info("Set up SSE connection to " + self.name + " spot API.")
|
logging.info("Set up SSE connection to " + self.name + " spot API.")
|
||||||
self._stopped = False
|
self._stop_event.clear()
|
||||||
self._thread = Thread(target=self._run)
|
self._thread = Thread(target=self._run, name=f"SSESpotProvider-{self.name}")
|
||||||
self._thread.daemon = True
|
self._thread.daemon = True
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._stopped = True
|
self._stop_event.set()
|
||||||
if self._event_source:
|
|
||||||
self._event_source.close()
|
with self._event_source_lock:
|
||||||
|
event_source = self._event_source
|
||||||
|
if event_source:
|
||||||
|
try:
|
||||||
|
event_source.close()
|
||||||
|
except Exception:
|
||||||
|
logging.exception(
|
||||||
|
"Exception closing SSE connection for " + self.name + " during stop()")
|
||||||
|
|
||||||
if self._thread:
|
if self._thread:
|
||||||
self._thread.join()
|
self._thread.join(timeout=15)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logging.warning(self.name + " SSE worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _on_open(self):
|
def _on_open(self):
|
||||||
self.status = "Waiting for Data"
|
self.status = "Waiting for Data"
|
||||||
@@ -41,36 +51,45 @@ class SSESpotProvider(SpotProvider):
|
|||||||
def _on_error(self):
|
def _on_error(self):
|
||||||
self.status = "Connecting"
|
self.status = "Connecting"
|
||||||
|
|
||||||
|
def _set_event_source(self, event_source):
|
||||||
|
with self._event_source_lock:
|
||||||
|
self._event_source = event_source
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while not self._stopped:
|
while not self._stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
logging.debug("Connecting to " + self.name + " spot API...")
|
logging.debug("Connecting to " + self.name + " spot API...")
|
||||||
self.status = "Connecting"
|
self.status = "Connecting"
|
||||||
with EventSource(self._url, headers=HTTP_HEADERS, latest_event_id=self._last_event_id, timeout=30,
|
with EventSource(self._url, headers=HTTP_HEADERS, latest_event_id=self._last_event_id, timeout=10,
|
||||||
on_open=self._on_open, on_error=self._on_error) as event_source:
|
on_open=self._on_open, on_error=self._on_error) as event_source:
|
||||||
self._event_source = event_source
|
self._set_event_source(event_source)
|
||||||
for event in self._event_source:
|
try:
|
||||||
if event.type == 'message':
|
for event in event_source:
|
||||||
try:
|
if self._stop_event.is_set():
|
||||||
self._last_event_id = event.last_event_id
|
break
|
||||||
new_spot = self._sse_message_to_spot(event.data)
|
if event.type == 'message':
|
||||||
if new_spot:
|
try:
|
||||||
self._submit(new_spot)
|
self._last_event_id = event.last_event_id
|
||||||
|
new_spot = self._sse_message_to_spot(event.data)
|
||||||
|
if new_spot:
|
||||||
|
self._submit(new_spot)
|
||||||
|
|
||||||
self.status = "OK"
|
self.status = "OK"
|
||||||
self.last_update_time = datetime.now(pytz.UTC)
|
self.last_update_time = datetime.now(pytz.UTC)
|
||||||
logging.debug("Received data from " + self.name + " spot API.")
|
logging.debug("Received data from " + self.name + " spot API.")
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception(
|
logging.exception(
|
||||||
"Exception processing message from SSE Spot Provider (" + self.name + ")")
|
"Exception processing message from SSE Spot Provider (" + self.name + ")")
|
||||||
|
finally:
|
||||||
|
self._set_event_source(None)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logging.exception("Exception in SSE Spot Provider (" + self.name + ")")
|
logging.exception("Exception in SSE Spot Provider (" + self.name + ")")
|
||||||
else:
|
else:
|
||||||
self.status = "Disconnected"
|
self.status = "Disconnected"
|
||||||
sleep(5) # Wait before trying to reconnect
|
self._stop_event.wait(timeout=5) # Wait before trying to reconnect
|
||||||
|
|
||||||
def _sse_message_to_spot(self, message_data):
|
def _sse_message_to_spot(self, message_data):
|
||||||
"""Convert an SSE message received from the API into a spot. The whole message data is provided here so the subclass
|
"""Convert an SSE message received from the API into a spot. The whole message data is provided here so the subclass
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class WebsocketSpotProvider(SpotProvider):
|
|||||||
def start(self):
|
def start(self):
|
||||||
logging.info("Set up websocket connection to " + self.name + " spot API.")
|
logging.info("Set up websocket connection to " + self.name + " spot API.")
|
||||||
self._stopped = False
|
self._stopped = False
|
||||||
self._thread = Thread(target=self._run)
|
self._thread = Thread(target=self._run, name=f"WebsocketSpotProvider-{self.name}")
|
||||||
self._thread.daemon = True
|
self._thread.daemon = True
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class FileDownloadStaticDataProvider(StaticDataProvider):
|
|||||||
# subsequent polls, so start() returns immediately and the application can continue starting.
|
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||||
logging.info(
|
logging.info(
|
||||||
"Set up query of " + self.name + " static reference data every " + str(self._poll_interval) + " days.")
|
"Set up query of " + self.name + " static reference data every " + str(self._poll_interval) + " days.")
|
||||||
self._thread = Thread(target=self._run, daemon=True)
|
self._thread = Thread(target=self._run, name=f"FileDownloadStaticDataProvider-{self.name}")
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ import tornado
|
|||||||
from tornado import httputil
|
from tornado import httputil
|
||||||
from tornado.web import Application
|
from tornado.web import Application
|
||||||
|
|
||||||
|
from core.call_lookup_helper import get_call_info
|
||||||
from core.constants import SIGS
|
from core.constants import SIGS
|
||||||
from core.geo_utils import lat_lon_for_grid_sw_corner_plus_size, lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
from core.geo_utils import lat_lon_for_grid_sw_corner_plus_size, lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
from core.prometheus_metrics_handler import api_requests_counter
|
||||||
|
from core.sig_lookup_helper import populate_missing_sig_ref_info
|
||||||
from core.sig_utils import get_ref_regex_for_sig
|
from core.sig_utils import get_ref_regex_for_sig
|
||||||
from core.sig_lookup_helper import populate_sig_ref_info
|
|
||||||
from core.utils import safe_json_dumps
|
from core.utils import safe_json_dumps
|
||||||
from data.lookup_credentials import extract_credentials
|
from data.lookup_credentials import extract_credentials
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
from data.spot import Spot
|
|
||||||
|
|
||||||
|
|
||||||
class APILookupCallHandler(tornado.web.RequestHandler):
|
class APILookupCallHandler(tornado.web.RequestHandler):
|
||||||
@@ -45,27 +45,9 @@ class APILookupCallHandler(tornado.web.RequestHandler):
|
|||||||
if "call" in query_params.keys():
|
if "call" in query_params.keys():
|
||||||
call = str(query_params.get("call")).upper()
|
call = str(query_params.get("call")).upper()
|
||||||
if re.match(r"^[A-Z0-9/\-]*$", call):
|
if re.match(r"^[A-Z0-9/\-]*$", call):
|
||||||
# Take the callsign, make a "fake spot" so we can run infer_missing() on it, then repack the
|
|
||||||
# resulting data in the correct way for the API response.
|
|
||||||
credentials = extract_credentials(query_params)
|
credentials = extract_credentials(query_params)
|
||||||
fake_spot = Spot(dx_call=call)
|
callsign_data = get_call_info(call, credentials)
|
||||||
fake_spot.infer_missing(credentials)
|
self.write(safe_json_dumps(callsign_data))
|
||||||
data = {
|
|
||||||
"call": call,
|
|
||||||
"name": fake_spot.dx_name,
|
|
||||||
"qth": fake_spot.dx_qth,
|
|
||||||
"country": fake_spot.dx_country,
|
|
||||||
"flag": fake_spot.dx_flag,
|
|
||||||
"continent": fake_spot.dx_continent,
|
|
||||||
"dxcc_id": fake_spot.dx_dxcc_id,
|
|
||||||
"cq_zone": fake_spot.dx_cq_zone,
|
|
||||||
"itu_zone": fake_spot.dx_itu_zone,
|
|
||||||
"grid": fake_spot.dx_grid,
|
|
||||||
"latitude": fake_spot.dx_latitude,
|
|
||||||
"longitude": fake_spot.dx_longitude,
|
|
||||||
"location_source": fake_spot.dx_location_source
|
|
||||||
}
|
|
||||||
self.write(safe_json_dumps(data))
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.write(safe_json_dumps("Error - '" + call + "' does not look like a valid callsign."))
|
self.write(safe_json_dumps("Error - '" + call + "' does not look like a valid callsign."))
|
||||||
@@ -112,7 +94,7 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
|
|||||||
ref_id = str(query_params.get("id")).upper()
|
ref_id = str(query_params.get("id")).upper()
|
||||||
if sig in list(map(lambda p: p.name.upper(), SIGS)):
|
if sig in list(map(lambda p: p.name.upper(), SIGS)):
|
||||||
if not get_ref_regex_for_sig(sig) or re.match(get_ref_regex_for_sig(sig), ref_id):
|
if not get_ref_regex_for_sig(sig) or re.match(get_ref_regex_for_sig(sig), ref_id):
|
||||||
data = populate_sig_ref_info(SIGRef(id=ref_id, sig=sig))
|
data = populate_missing_sig_ref_info(SIGRef(id=ref_id, sig=sig))
|
||||||
self.write(safe_json_dumps(data))
|
self.write(safe_json_dumps(data))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -38,10 +38,12 @@ class APIOptionsHandler(tornado.web.RequestHandler):
|
|||||||
"mode_types": MODE_TYPES,
|
"mode_types": MODE_TYPES,
|
||||||
"sigs": SIGS,
|
"sigs": SIGS,
|
||||||
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle things that aren't even available.
|
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle things that aren't even available.
|
||||||
"spot_sources": list(
|
"spot_providers": list(
|
||||||
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["spot_providers"]))),
|
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["spot_providers"]))),
|
||||||
"alert_sources": list(
|
"alert_providers": list(
|
||||||
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"]))),
|
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"]))),
|
||||||
|
"callsign_data_providers": list(
|
||||||
|
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["callsign_data_providers"]))),
|
||||||
"continents": CONTINENTS,
|
"continents": CONTINENTS,
|
||||||
"propagation_modes": list(PROPAGATION_MODES.values()),
|
"propagation_modes": list(PROPAGATION_MODES.values()),
|
||||||
"max_spot_age": MAX_SPOT_AGE,
|
"max_spot_age": MAX_SPOT_AGE,
|
||||||
@@ -49,7 +51,7 @@ class APIOptionsHandler(tornado.web.RequestHandler):
|
|||||||
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
|
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
|
||||||
# one of our proviers.
|
# one of our proviers.
|
||||||
if ALLOW_SPOTTING:
|
if ALLOW_SPOTTING:
|
||||||
options["spot_sources"].append("API")
|
options["spot_providers"].append("API")
|
||||||
|
|
||||||
self.write(safe_json_dumps(options))
|
self.write(safe_json_dumps(options))
|
||||||
self.set_status(200)
|
self.set_status(200)
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ class SSEBroadcaster:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._handlers = set()
|
self._handlers = set()
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self._loop = None
|
||||||
|
|
||||||
|
def bind_to_web_server_loop(self):
|
||||||
self._loop = IOLoop.current()
|
self._loop = IOLoop.current()
|
||||||
|
|
||||||
def register(self, handler):
|
def register(self, handler):
|
||||||
@@ -22,9 +25,9 @@ class SSEBroadcaster:
|
|||||||
self._handlers.discard(handler)
|
self._handlers.discard(handler)
|
||||||
|
|
||||||
def publish(self, value):
|
def publish(self, value):
|
||||||
self._loop.add_callback(self._fan_out, value)
|
self._loop.add_callback(self._broadcast, value)
|
||||||
|
|
||||||
def _fan_out(self, value):
|
def _broadcast(self, value):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
handlers = list(self._handlers)
|
handlers = list(self._handlers)
|
||||||
for handler in handlers:
|
for handler in handlers:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
import tornado
|
import tornado
|
||||||
from tornado.web import StaticFileHandler
|
from tornado.web import StaticFileHandler
|
||||||
@@ -43,6 +44,7 @@ class WebServer:
|
|||||||
"status": "Starting"
|
"status": "Starting"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
# Listen for new spots and alerts being added to the cache, so we can notify SSE clients immediately
|
# Listen for new spots and alerts being added to the cache, so we can notify SSE clients immediately
|
||||||
DATA_STORE.spots.add_listener(self._spot_broadcaster.publish)
|
DATA_STORE.spots.add_listener(self._spot_broadcaster.publish)
|
||||||
DATA_STORE.alerts.add_listener(self._alert_broadcaster.publish)
|
DATA_STORE.alerts.add_listener(self._alert_broadcaster.publish)
|
||||||
@@ -60,6 +62,10 @@ class WebServer:
|
|||||||
async def _start_inner(self):
|
async def _start_inner(self):
|
||||||
"""Start method (async). Sets up the Tornado application."""
|
"""Start method (async). Sets up the Tornado application."""
|
||||||
|
|
||||||
|
# Bind the SSE broadcasters to the web server's loop, so they fire correctly
|
||||||
|
self._spot_broadcaster.bind_to_web_server_loop()
|
||||||
|
self._alert_broadcaster.bind_to_web_server_loop()
|
||||||
|
|
||||||
# Prepare a list of common arguments that are passed in to every API & page handler. This is just a basic thing
|
# Prepare a list of common arguments that are passed in to every API & page handler. This is just a basic thing
|
||||||
# to avoid copy-pasting the same thing to every route declaration below.
|
# to avoid copy-pasting the same thing to every route declaration below.
|
||||||
handler_opts = {"web_server_metrics": self.web_server_metrics}
|
handler_opts = {"web_server_metrics": self.web_server_metrics}
|
||||||
@@ -145,3 +151,7 @@ def request_log(handler):
|
|||||||
f'{handler.get_status()} {request.request_time():.2f}ms | '
|
f'{handler.get_status()} {request.request_time():.2f}ms | '
|
||||||
f'Ref: {referrer} | UA: {user_agent}'
|
f'Ref: {referrer} | UA: {user_agent}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global object
|
||||||
|
WEB_SERVER = WebServer()
|
||||||
+14
-76
@@ -4,49 +4,24 @@ import os
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from core.config import config, SERVER_OWNER_CALLSIGN, LOG_LEVEL, get_sig_ref_data_provider_from_config, \
|
from core.config import SERVER_OWNER_CALLSIGN, LOG_LEVEL
|
||||||
get_spot_provider_from_config, get_alert_provider_from_config, get_solar_conditions_provider_from_config, \
|
|
||||||
get_static_data_provider_from_config
|
|
||||||
from core.constants import SOFTWARE_VERSION
|
from core.constants import SOFTWARE_VERSION
|
||||||
|
from core.data_providers import DATA_PROVIDERS
|
||||||
from core.data_store import DATA_STORE
|
from core.data_store import DATA_STORE
|
||||||
from core.call_lookup_helper import lookup_helper
|
|
||||||
from core.status_reporter import StatusReporter
|
from core.status_reporter import StatusReporter
|
||||||
from server.webserver import WebServer
|
from server.webserver import WEB_SERVER
|
||||||
|
|
||||||
# Globals
|
# Globals
|
||||||
web_server = None
|
|
||||||
spot_providers = []
|
|
||||||
alert_providers = []
|
|
||||||
solar_condition_providers = []
|
|
||||||
static_data_providers = []
|
|
||||||
sig_ref_data_providers = []
|
|
||||||
cleanup_timer = None
|
|
||||||
run = True
|
run = True
|
||||||
|
|
||||||
|
|
||||||
def shutdown(_signum=None, _frame=None):
|
def shutdown(_signum=None, _frame=None):
|
||||||
"""Shutdown function"""
|
"""Shutdown function"""
|
||||||
|
|
||||||
global run
|
global run
|
||||||
|
|
||||||
logging.info("Stopping program...")
|
logging.info("Stopping program...")
|
||||||
if web_server:
|
WEB_SERVER.stop()
|
||||||
web_server.stop()
|
DATA_PROVIDERS.stop()
|
||||||
for sp in spot_providers:
|
|
||||||
if sp.enabled:
|
|
||||||
sp.stop()
|
|
||||||
for ap in alert_providers:
|
|
||||||
if ap.enabled:
|
|
||||||
ap.stop()
|
|
||||||
for scp in solar_condition_providers:
|
|
||||||
if scp.enabled:
|
|
||||||
scp.stop()
|
|
||||||
for srdp in sig_ref_data_providers:
|
|
||||||
if srdp.enabled:
|
|
||||||
srdp.stop()
|
|
||||||
for srdp in static_data_providers:
|
|
||||||
if srdp.enabled:
|
|
||||||
srdp.stop()
|
|
||||||
DATA_STORE.close()
|
DATA_STORE.close()
|
||||||
os._exit(0)
|
os._exit(0)
|
||||||
|
|
||||||
@@ -73,57 +48,20 @@ if __name__ == '__main__':
|
|||||||
# Set up data store
|
# Set up data store
|
||||||
DATA_STORE.setup()
|
DATA_STORE.setup()
|
||||||
|
|
||||||
# Set up lookup helper
|
# Set up and start data providers
|
||||||
lookup_helper.start()
|
DATA_PROVIDERS.setup()
|
||||||
|
DATA_PROVIDERS.start()
|
||||||
|
|
||||||
# Set up web server
|
# Set up and start status reporter
|
||||||
web_server = WebServer()
|
status_reporter = StatusReporter(run_interval=5)
|
||||||
|
|
||||||
# Fetch, set up and start spot providers
|
|
||||||
for entry in config["spot-providers"]:
|
|
||||||
spot_providers.append(get_spot_provider_from_config(entry))
|
|
||||||
for p in spot_providers:
|
|
||||||
if p.enabled:
|
|
||||||
p.start()
|
|
||||||
|
|
||||||
# Fetch, set up and start alert providers
|
|
||||||
for entry in config["alert-providers"]:
|
|
||||||
alert_providers.append(get_alert_provider_from_config(entry))
|
|
||||||
for p in alert_providers:
|
|
||||||
if p.enabled:
|
|
||||||
p.start()
|
|
||||||
|
|
||||||
# Fetch, set up and start solar conditions providers
|
|
||||||
for entry in config.get("solar-condition-providers", []):
|
|
||||||
solar_condition_providers.append(get_solar_conditions_provider_from_config(entry))
|
|
||||||
for p in solar_condition_providers:
|
|
||||||
if p.enabled:
|
|
||||||
p.start()
|
|
||||||
|
|
||||||
# Fetch, set up and start static reference data providers
|
|
||||||
for entry in config.get("static-data-providers", []):
|
|
||||||
static_data_providers.append(get_static_data_provider_from_config(entry))
|
|
||||||
for p in static_data_providers:
|
|
||||||
if p.enabled:
|
|
||||||
p.start()
|
|
||||||
|
|
||||||
# Fetch, set up and start SIG reference data providers
|
|
||||||
for entry in config.get("sig-ref-data-providers", []):
|
|
||||||
sig_ref_data_providers.append(get_sig_ref_data_provider_from_config(entry))
|
|
||||||
for p in sig_ref_data_providers:
|
|
||||||
if p.enabled:
|
|
||||||
p.start()
|
|
||||||
|
|
||||||
# Set up status reporter
|
|
||||||
status_reporter = StatusReporter(web_server=web_server, spot_providers=spot_providers,
|
|
||||||
alert_providers=alert_providers, static_data_providers=static_data_providers,
|
|
||||||
sig_ref_data_providers=sig_ref_data_providers,
|
|
||||||
solar_condition_providers=solar_condition_providers, run_interval=5)
|
|
||||||
status_reporter.start()
|
status_reporter.start()
|
||||||
|
|
||||||
|
# Set up the web server
|
||||||
|
WEB_SERVER.setup()
|
||||||
|
|
||||||
logging.info("Startup complete.")
|
logging.info("Startup complete.")
|
||||||
|
|
||||||
# Run the web server. This is the blocking call that keeps the application running in the main thread, so this must
|
# Run the web server. This is the blocking call that keeps the application running in the main thread, so this must
|
||||||
# be the last thing we do. web_server.stop() triggers an await condition in the web server which finishes the main
|
# be the last thing we do. web_server.stop() triggers an await condition in the web server which finishes the main
|
||||||
# thread.
|
# thread.
|
||||||
web_server.start()
|
WEB_SERVER.start()
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ info:
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### 2.0
|
||||||
|
|
||||||
|
* Added `sig_ref_data_providers`, `static_data_providers` and `callsign_data_providers` to `/status` response
|
||||||
|
* Added `callsign_data_providers` to `/options` response
|
||||||
|
* BREAKING: Removed `cleanup` from `/status` response
|
||||||
|
* BREAKING: in the `/options` response, renamed `spot_sources` and `alert_sources` to `spot_providers` and
|
||||||
|
`alert_providers`
|
||||||
|
|
||||||
### 1.4
|
### 1.4
|
||||||
|
|
||||||
* Spots can now include a "propagation_mode" field, and the `/options` call enumerates the options that can have.
|
* Spots can now include a "propagation_mode" field, and the `/options` call enumerates the options that can have.
|
||||||
@@ -22,8 +30,6 @@ info:
|
|||||||
* Renamed some SIGs to avoid confusion between Towers, Tiles and Toilets
|
* Renamed some SIGs to avoid confusion between Towers, Tiles and Toilets
|
||||||
* Added `comment_names` to SIGs in the `/options`, to reflect how they might be referred to in spot comments where
|
* Added `comment_names` to SIGs in the `/options`, to reflect how they might be referred to in spot comments where
|
||||||
it differs from their `name`.
|
it differs from their `name`.
|
||||||
* Added `propagation_mode` field to spots
|
|
||||||
* Added `sig_ref_data_providers` and `static_data_providers` to status and removed `cleanup`
|
|
||||||
|
|
||||||
### 1.3
|
### 1.3
|
||||||
|
|
||||||
@@ -46,8 +52,7 @@ info:
|
|||||||
license:
|
license:
|
||||||
name: The Unlicense
|
name: The Unlicense
|
||||||
url: https://unlicense.org/#the-unlicense
|
url: https://unlicense.org/#the-unlicense
|
||||||
version: v1.4
|
version: v2.0
|
||||||
|
|
||||||
servers:
|
servers:
|
||||||
- url: https://spothole.app/api/v1
|
- url: https://spothole.app/api/v1
|
||||||
|
|
||||||
@@ -324,7 +329,7 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/CallLookup'
|
$ref: '#/components/schemas/CallsignData'
|
||||||
'422':
|
'422':
|
||||||
description: Validation error e.g. callsign missing or format incorrect
|
description: Validation error e.g. callsign missing or format incorrect
|
||||||
content:
|
content:
|
||||||
@@ -1769,6 +1774,32 @@ components:
|
|||||||
description: The number of references fetched using this provider.
|
description: The number of references fetched using this provider.
|
||||||
example: 1234
|
example: 1234
|
||||||
|
|
||||||
|
CallsignDataProviderStatus:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
sig_name:
|
||||||
|
type: string
|
||||||
|
description: The name of the provider.
|
||||||
|
example: Country Files
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
description: Whether the provider is enabled or not.
|
||||||
|
example: true
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
description: The status of the provider.
|
||||||
|
example: OK
|
||||||
|
last_updated:
|
||||||
|
type: number
|
||||||
|
description: >
|
||||||
|
The last time at which this provider received data, UTC seconds since UNIX epoch. If this
|
||||||
|
is zero, the provider has never updated.
|
||||||
|
example: 1759579508
|
||||||
|
lookup_count:
|
||||||
|
type: number
|
||||||
|
description: The number of callsign lookups performed using this provider since the server was started.
|
||||||
|
example: 1234
|
||||||
|
|
||||||
SpotList:
|
SpotList:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -1876,6 +1907,11 @@ components:
|
|||||||
description: An array of all the SIG reference data providers.
|
description: An array of all the SIG reference data providers.
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/SIGRefDataProviderStatus'
|
$ref: '#/components/schemas/SIGRefDataProviderStatus'
|
||||||
|
callsign_data_providers:
|
||||||
|
type: array
|
||||||
|
description: An array of all the callsign data providers.
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/CallsignDataProviderStatus'
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
type: object
|
type: object
|
||||||
@@ -1902,12 +1938,24 @@ components:
|
|||||||
description: An array of all the supported Special Interest Groups.
|
description: An array of all the supported Special Interest Groups.
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/SIG'
|
$ref: '#/components/schemas/SIG'
|
||||||
sources:
|
spot_providers:
|
||||||
type: array
|
type: array
|
||||||
description: An array of all the supported data sources.
|
description: An array of all the supported spot data sources.
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
example: "Cluster"
|
example: "Cluster"
|
||||||
|
alert_providers:
|
||||||
|
type: array
|
||||||
|
description: An array of all the supported alert data sources.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: "POTA"
|
||||||
|
callsign_data_providers:
|
||||||
|
type: array
|
||||||
|
description: An array of all the supported callsign lookup providers.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: "QRZ.com"
|
||||||
continents:
|
continents:
|
||||||
type: array
|
type: array
|
||||||
description: An array of all the supported continents.
|
description: An array of all the supported continents.
|
||||||
@@ -1934,12 +1982,16 @@ components:
|
|||||||
on this server.
|
on this server.
|
||||||
example: true
|
example: true
|
||||||
|
|
||||||
CallLookup:
|
CallsignData:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
call:
|
call:
|
||||||
type: string
|
type: string
|
||||||
description: Callsign, as provided to the API
|
description: Callsign, as provided to the API
|
||||||
|
example: DL/M0TRT/P
|
||||||
|
home_call:
|
||||||
|
type: string
|
||||||
|
description: The "home" call, without prefixes or suffixes
|
||||||
example: M0TRT
|
example: M0TRT
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
+1
-1
@@ -288,7 +288,7 @@ function loadOptions() {
|
|||||||
|
|
||||||
// Populate the filters panel
|
// Populate the filters panel
|
||||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||||
generateMultiToggleFilterCard("#source-options", "source", options["alert_sources"]);
|
generateMultiToggleFilterCard("#source-options", "source", options["alert_providers"]);
|
||||||
|
|
||||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||||
|
|||||||
+1
-1
@@ -291,7 +291,7 @@ function loadOptions() {
|
|||||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
||||||
generateModesMultiToggleFilterCard(options["modes"]);
|
generateModesMultiToggleFilterCard(options["modes"]);
|
||||||
generateSourcesMultiToggleFilterCard(options["spot_sources"], spotProvidersEnabledByDefault);
|
generateSourcesMultiToggleFilterCard(options["spot_providers"], spotProvidersEnabledByDefault);
|
||||||
|
|
||||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||||
|
|||||||
+1
-1
@@ -322,7 +322,7 @@ function loadOptions() {
|
|||||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
||||||
generateModesMultiToggleFilterCard(options["modes"]);
|
generateModesMultiToggleFilterCard(options["modes"]);
|
||||||
generateSourcesMultiToggleFilterCard(options["spot_sources"], spotProvidersEnabledByDefault);
|
generateSourcesMultiToggleFilterCard(options["spot_providers"], spotProvidersEnabledByDefault);
|
||||||
|
|
||||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||||
|
|||||||
+1
-1
@@ -436,7 +436,7 @@ function loadOptions() {
|
|||||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
||||||
generateModesMultiToggleFilterCard(options["modes"]);
|
generateModesMultiToggleFilterCard(options["modes"]);
|
||||||
generateSourcesMultiToggleFilterCard(options["spot_sources"], spotProvidersEnabledByDefault);
|
generateSourcesMultiToggleFilterCard(options["spot_providers"], spotProvidersEnabledByDefault);
|
||||||
|
|
||||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||||
|
|||||||
+15
-5
@@ -14,7 +14,7 @@ function loadStatus() {
|
|||||||
|
|
||||||
jsonData["spot_providers"].forEach(p => {
|
jsonData["spot_providers"].forEach(p => {
|
||||||
$("#spot-providers-status-container").append(`
|
$("#spot-providers-status-container").append(`
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
<div class="col"><strong>${p["name"]}</strong></div>
|
<div class="col"><strong>${p["name"]}</strong></div>
|
||||||
<div class="col">Status: ${p["status"]}</div>
|
<div class="col">Status: ${p["status"]}</div>
|
||||||
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
||||||
@@ -24,7 +24,7 @@ function loadStatus() {
|
|||||||
|
|
||||||
jsonData["alert_providers"].forEach(p => {
|
jsonData["alert_providers"].forEach(p => {
|
||||||
$("#alert-providers-status-container").append(`
|
$("#alert-providers-status-container").append(`
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
<div class="col"><strong>${p["name"]}</strong></div>
|
<div class="col"><strong>${p["name"]}</strong></div>
|
||||||
<div class="col">Status: ${p["status"]}</div>
|
<div class="col">Status: ${p["status"]}</div>
|
||||||
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
||||||
@@ -33,7 +33,7 @@ function loadStatus() {
|
|||||||
|
|
||||||
jsonData["solar_condition_providers"].forEach(p => {
|
jsonData["solar_condition_providers"].forEach(p => {
|
||||||
$("#condition-providers-status-container").append(`
|
$("#condition-providers-status-container").append(`
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
<div class="col"><strong>${p["name"]}</strong></div>
|
<div class="col"><strong>${p["name"]}</strong></div>
|
||||||
<div class="col">Status: ${p["status"]}</div>
|
<div class="col">Status: ${p["status"]}</div>
|
||||||
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
||||||
@@ -42,7 +42,7 @@ function loadStatus() {
|
|||||||
|
|
||||||
jsonData["static_data_providers"].forEach(p => {
|
jsonData["static_data_providers"].forEach(p => {
|
||||||
$("#static-data-providers-status-container").append(`
|
$("#static-data-providers-status-container").append(`
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
<div class="col"><strong>${p["name"]}</strong></div>
|
<div class="col"><strong>${p["name"]}</strong></div>
|
||||||
<div class="col">Status: ${p["status"]}</div>
|
<div class="col">Status: ${p["status"]}</div>
|
||||||
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
||||||
@@ -51,13 +51,23 @@ function loadStatus() {
|
|||||||
|
|
||||||
jsonData["sig_ref_data_providers"].forEach(p => {
|
jsonData["sig_ref_data_providers"].forEach(p => {
|
||||||
$("#sig-ref-data-providers-status-container").append(`
|
$("#sig-ref-data-providers-status-container").append(`
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
<div class="col"><strong>${p["sig_name"]}</strong></div>
|
<div class="col"><strong>${p["sig_name"]}</strong></div>
|
||||||
<div class="col">Status: ${p["status"]}</div>
|
<div class="col">Status: ${p["status"]}</div>
|
||||||
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
||||||
<div class="col">References: ${(p["enabled"] && p["reference_count"] > 0) ? p["reference_count"] : "N/A"}</div>
|
<div class="col">References: ${(p["enabled"] && p["reference_count"] > 0) ? p["reference_count"] : "N/A"}</div>
|
||||||
</div>`);
|
</div>`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jsonData["callsign_data_providers"].forEach(p => {
|
||||||
|
$("#callsign-data-providers-status-container").append(`
|
||||||
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
|
<div class="col"><strong>${p["name"]}</strong></div>
|
||||||
|
<div class="col">Status: ${p["status"]}</div>
|
||||||
|
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
||||||
|
<div class="col">Lookups: ${(p["enabled"] && p["lookup_count"] > 0) ? p["lookup_count"] : "N/A"}</div>
|
||||||
|
</div>`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/add-spot.js?v=1785434214"></script>
|
<script src="/static/js/add-spot.js?v=1785958091"></script>
|
||||||
<script>$(document).ready(function () {
|
<script>$(document).ready(function () {
|
||||||
$("#nav-link-add-spot").addClass("active");
|
$("#nav-link-add-spot").addClass("active");
|
||||||
}); <!-- highlight active page in nav --></script>
|
}); <!-- highlight active page in nav --></script>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<div class="d-inline-flex gap-1">
|
<div class="d-inline-flex gap-1">
|
||||||
{% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options) %}
|
{% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options,
|
||||||
|
show_data_button=web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"]) %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,19 +51,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"] %}
|
||||||
<div id="data-area" class="appearing-panel card mb-3">
|
<div id="data-area" class="appearing-panel card mb-3">
|
||||||
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
|
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4">
|
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||||
|
{% if web_ui_options["qrz-enabled"] %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
|
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
|
{% if web_ui_options["hamqth-enabled"] %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
|
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
|
|
||||||
<div id="table-container">
|
<div id="table-container">
|
||||||
<table id="table" class="table">
|
<table id="table" class="table">
|
||||||
@@ -75,7 +82,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/alerts.js?v=1785434213"></script>
|
<script src="/static/js/alerts.js?v=1785958091"></script>
|
||||||
<script>$(document).ready(function () {
|
<script>$(document).ready(function () {
|
||||||
$("#nav-link-alerts").addClass("active");
|
$("#nav-link-alerts").addClass("active");
|
||||||
}); <!-- highlight active page in nav --></script>
|
}); <!-- highlight active page in nav --></script>
|
||||||
|
|||||||
+10
-3
@@ -6,7 +6,8 @@
|
|||||||
<div class="col-auto me-auto pt-3"></div>
|
<div class="col-auto me-auto pt-3"></div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<div class="d-inline-flex gap-1">
|
<div class="d-inline-flex gap-1">
|
||||||
{% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options) %}
|
{% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options,
|
||||||
|
show_data_button=web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"]) %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,19 +55,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"] %}
|
||||||
<div id="data-area" class="appearing-panel card mb-3">
|
<div id="data-area" class="appearing-panel card mb-3">
|
||||||
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
|
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4">
|
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||||
|
{% if web_ui_options["qrz-enabled"] %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
|
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
|
{% if web_ui_options["hamqth-enabled"] %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
|
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
|
|
||||||
<div id="bands-container"></div>
|
<div id="bands-container"></div>
|
||||||
|
|
||||||
@@ -75,8 +82,8 @@
|
|||||||
<script>
|
<script>
|
||||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/js/spotsbandsandmap.js?v=1785434214"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1785958091"></script>
|
||||||
<script src="/static/js/bands.js?v=1785434214"></script>
|
<script src="/static/js/bands.js?v=1785958091"></script>
|
||||||
<script>$(document).ready(function () {
|
<script>$(document).ready(function () {
|
||||||
$("#nav-link-bands").addClass("active");
|
$("#nav-link-bands").addClass("active");
|
||||||
}); <!-- highlight active page in nav --></script>
|
}); <!-- highlight active page in nav --></script>
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{% extends "skeleton.html" %}
|
{% extends "skeleton.html" %}
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v=1785434213" type="text/css">
|
<link rel="stylesheet" href="/static/css/style.css?v=1785958091" type="text/css">
|
||||||
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
|
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
|
||||||
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
|
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
|
||||||
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
|
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
|
||||||
@@ -10,10 +10,10 @@
|
|||||||
<script src="/static/vendor/js/bootstrap-5.3.8.bundle.min.js"></script>
|
<script src="/static/vendor/js/bootstrap-5.3.8.bundle.min.js"></script>
|
||||||
<script src="/static/vendor/js/tinycolor2-1.6.0.min.js"></script>
|
<script src="/static/vendor/js/tinycolor2-1.6.0.min.js"></script>
|
||||||
|
|
||||||
<script src="/static/js/utils.js?v=1785434213"></script>
|
<script src="/static/js/utils.js?v=1785958091"></script>
|
||||||
<script src="/static/js/ui-ham.js?v=1785434213"></script>
|
<script src="/static/js/ui-ham.js?v=1785958091"></script>
|
||||||
<script src="/static/js/geo.js?v=1785434213"></script>
|
<script src="/static/js/geo.js?v=1785958091"></script>
|
||||||
<script src="/static/js/common.js?v=1785434213"></script>
|
<script src="/static/js/common.js?v=1785958091"></script>
|
||||||
{% end %}
|
{% end %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -284,7 +284,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
|
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
|
||||||
<script src="/static/js/conditions.js?v=1785434213"></script>
|
<script src="/static/js/conditions.js?v=1785958091"></script>
|
||||||
<script>$(document).ready(function () {
|
<script>$(document).ready(function () {
|
||||||
$("#nav-link-conditions").addClass("active");
|
$("#nav-link-conditions").addClass("active");
|
||||||
}); <!-- highlight active page in nav --></script>
|
}); <!-- highlight active page in nav --></script>
|
||||||
|
|||||||
+10
-3
@@ -20,7 +20,8 @@
|
|||||||
<div class="col-auto me-auto pt-3"></div>
|
<div class="col-auto me-auto pt-3"></div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<div class="d-inline-flex gap-1">
|
<div class="d-inline-flex gap-1">
|
||||||
{% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options) %}
|
{% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options,
|
||||||
|
show_data_button=web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"]) %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,19 +75,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"] %}
|
||||||
<div id="data-area" class="appearing-panel card mb-3">
|
<div id="data-area" class="appearing-panel card mb-3">
|
||||||
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
|
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4">
|
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||||
|
{% if web_ui_options["qrz-enabled"] %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
|
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
|
{% if web_ui_options["hamqth-enabled"] %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
|
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -108,8 +115,8 @@
|
|||||||
<script>
|
<script>
|
||||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/js/spotsbandsandmap.js?v=1785434213"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1785958091"></script>
|
||||||
<script src="/static/js/map.js?v=1785434213"></script>
|
<script src="/static/js/map.js?v=1785958091"></script>
|
||||||
<script>$(document).ready(function () {
|
<script>$(document).ready(function () {
|
||||||
$("#nav-link-map").addClass("active");
|
$("#nav-link-map").addClass("active");
|
||||||
}); <!-- highlight active page in nav --></script>
|
}); <!-- highlight active page in nav --></script>
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
<div class="col-md-8 text-end">
|
<div class="col-md-8 text-end">
|
||||||
<div class="d-inline-flex gap-3">
|
<div class="d-inline-flex gap-3">
|
||||||
{% module Template("widgets/search.html", web_ui_options=web_ui_options) %}
|
{% module Template("widgets/search.html", web_ui_options=web_ui_options) %}
|
||||||
{% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options) %}
|
{% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options,
|
||||||
|
show_data_button=web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"]) %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,12 +87,16 @@
|
|||||||
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
|
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4">
|
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||||
|
{% if web_ui_options["qrz-enabled"] %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
|
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
|
{% if web_ui_options["hamqth-enabled"] %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
|
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% module Template("cards/location.html", web_ui_options=web_ui_options) %}
|
{% module Template("cards/location.html", web_ui_options=web_ui_options) %}
|
||||||
</div>
|
</div>
|
||||||
@@ -116,8 +121,8 @@
|
|||||||
<script>
|
<script>
|
||||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/js/spotsbandsandmap.js?v=1785434213"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1785958091"></script>
|
||||||
<script src="/static/js/spots.js?v=1785434213"></script>
|
<script src="/static/js/spots.js?v=1785958091"></script>
|
||||||
<script>$(document).ready(function () {
|
<script>$(document).ready(function () {
|
||||||
$("#nav-link-spots").addClass("active");
|
$("#nav-link-spots").addClass("active");
|
||||||
}); <!-- highlight active page in nav --></script>
|
}); <!-- highlight active page in nav --></script>
|
||||||
|
|||||||
+13
-4
@@ -6,19 +6,19 @@
|
|||||||
Spothole
|
Spothole
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
<div class="col"><strong>Metadata</strong></div>
|
<div class="col"><strong>Metadata</strong></div>
|
||||||
<div class="col">Software Version: <span id="software-version"></span></div>
|
<div class="col">Software Version: <span id="software-version"></span></div>
|
||||||
<div class="col">Owner Callsign: <span id="server-owner-callsign"></span></div>
|
<div class="col">Owner Callsign: <span id="server-owner-callsign"></span></div>
|
||||||
<div class="col">Up since: <span id="up-since"></span></div>
|
<div class="col">Up since: <span id="up-since"></span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
<div class="col"><strong>Performance</strong></div>
|
<div class="col"><strong>Performance</strong></div>
|
||||||
<div class="col">Memory Use: <span id="memory-use"></span></div>
|
<div class="col">Memory Use: <span id="memory-use"></span></div>
|
||||||
<div class="col">Total Spots: <span id="total-spots"></span></div>
|
<div class="col">Total Spots: <span id="total-spots"></span></div>
|
||||||
<div class="col">Total Alerts: <span id="total-alerts"></span></div>
|
<div class="col">Total Alerts: <span id="total-alerts"></span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
<div class="col"><strong>Web Server</strong></div>
|
<div class="col"><strong>Web Server</strong></div>
|
||||||
<div class="col">Status: <span id="web-server-status"></span></div>
|
<div class="col">Status: <span id="web-server-status"></span></div>
|
||||||
<div class="col">Last API call: <span id="web-server-last-api"></span></div>
|
<div class="col">Last API call: <span id="web-server-last-api"></span></div>
|
||||||
@@ -72,7 +72,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/status.js?v=1785434213"></script>
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
Callsign Data Providers
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="callsign-data-providers-status-container">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/status.js?v=1785958091"></script>
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$("#nav-link-status").addClass("active");
|
$("#nav-link-status").addClass("active");
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
<button id="display-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button"
|
<button id="display-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button"
|
||||||
onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i><span
|
onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i><span
|
||||||
class="hideonmobile"> Display</span></button>
|
class="hideonmobile"> Display</span></button>
|
||||||
|
{% if show_data_button %}
|
||||||
<button id="data-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button"
|
<button id="data-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button"
|
||||||
onclick="toggleDataPanel();"><i class="fa-solid fa-database"></i><span
|
onclick="toggleDataPanel();"><i class="fa-solid fa-database"></i><span
|
||||||
class="hideonmobile"> Your data</span></button>
|
class="hideonmobile"> Your data</span></button>
|
||||||
|
{% end %}
|
||||||
</div>
|
</div>
|
||||||
Reference in New Issue
Block a user