Fix some IDE warnings, mostly around type safety on the Python side

This commit is contained in:
Ian Renton
2026-06-19 21:33:46 +01:00
parent 05ac652cee
commit edb2641f76
42 changed files with 319 additions and 187 deletions

View File

@@ -8,6 +8,7 @@
<inspection_tool class="CssUnusedSymbol" enabled="false" level="WARNING" enabled_by_default="false" /> <inspection_tool class="CssUnusedSymbol" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="GrazieInspection" enabled="false" level="GRAMMAR_ERROR" enabled_by_default="false" /> <inspection_tool class="GrazieInspection" enabled="false" level="GRAMMAR_ERROR" enabled_by_default="false" />
<inspection_tool class="GrazieStyle" enabled="false" level="STYLE_SUGGESTION" enabled_by_default="false" /> <inspection_tool class="GrazieStyle" enabled="false" level="STYLE_SUGGESTION" enabled_by_default="false" />
<inspection_tool class="HtmlFormInputWithoutLabel" enabled="true" level="WEAK WARNING" enabled_by_default="true" editorAttributes="INFO_ATTRIBUTES" />
<inspection_tool class="HtmlUnknownAttribute" enabled="false" level="WARNING" enabled_by_default="false"> <inspection_tool class="HtmlUnknownAttribute" enabled="false" level="WARNING" enabled_by_default="false">
<option name="myValues"> <option name="myValues">
<value> <value>
@@ -38,8 +39,10 @@
<inspection_tool class="JSJQueryEfficiency" enabled="false" level="WARNING" enabled_by_default="false" /> <inspection_tool class="JSJQueryEfficiency" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="JSUnnecessarySemicolon" enabled="false" level="WARNING" enabled_by_default="false" /> <inspection_tool class="JSUnnecessarySemicolon" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="JSUnresolvedReference" enabled="false" level="WEAK WARNING" enabled_by_default="false" /> <inspection_tool class="JSUnresolvedReference" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="JSUnusedGlobalSymbols" enabled="true" level="WEAK WARNING" enabled_by_default="true" editorAttributes="INFO_ATTRIBUTES" />
<inspection_tool class="LanguageDetectionInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" /> <inspection_tool class="LanguageDetectionInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="OutdatedRequirementInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" /> <inspection_tool class="OutdatedRequirementInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="PyBroadExceptionInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false"> <inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
<option name="processCode" value="true" /> <option name="processCode" value="true" />
<option name="processLiterals" value="true" /> <option name="processLiterals" value="true" />

View File

@@ -21,19 +21,30 @@ class BOTA(HTTPAlertProvider):
new_alerts = [] new_alerts = []
# Find the table of upcoming alerts # Find the table of upcoming alerts
bs = BeautifulSoup(http_response.content.decode(), features="lxml") bs = BeautifulSoup(http_response.content.decode(), features="lxml")
if not bs.body:
return new_alerts
div = bs.body.find('div', attrs={'class': 'view-activations-public'}) div = bs.body.find('div', attrs={'class': 'view-activations-public'})
if div: if div:
table = div.find('table', attrs={'class': 'views-table'}) table = div.find('table', attrs={'class': 'views-table'})
if table: if table:
tbody = table.find('tbody') tbody = table.find('tbody')
if not tbody:
return new_alerts
for row in tbody.find_all('tr'): for row in tbody.find_all('tr'):
cells = row.find_all('td') cells = row.find_all('td')
first_cell_text = str(cells[0].find('a').contents[0]).strip() first_cell_anchor = cells[0].find('a') if len(cells) > 0 else None
second_cell_anchor = cells[1].find('a') if len(cells) > 1 else None
if not first_cell_anchor or not second_cell_anchor:
continue
first_cell_text = first_cell_anchor.get_text().strip()
ref_name = first_cell_text.split(" by ")[0] ref_name = first_cell_text.split(" by ")[0]
dx_call = str(cells[1].find('a').contents[0]).strip().upper() dx_call = second_cell_anchor.get_text().strip().upper()
# Get the date, dealing with the fact we get no year so have to figure out if it's last year or next year # Get the date, dealing with the fact we get no year so have to figure out if it's last year or next year
date_text = str(cells[2].find('span').contents[0]).strip() date_span = cells[2].find('span') if len(cells) > 2 else None
if not date_span:
continue
date_text = date_span.get_text().strip()
date_time = datetime.strptime(date_text, "%d %b - %H:%M UTC").replace(tzinfo=pytz.UTC) date_time = datetime.strptime(date_text, "%d %b - %H:%M UTC").replace(tzinfo=pytz.UTC)
date_time = date_time.replace(year=datetime.now(pytz.UTC).year) date_time = date_time.replace(year=datetime.now(pytz.UTC).year)
# If this was more than a day ago, activation is actually next year # If this was more than a day ago, activation is actually next year

View File

@@ -1,8 +1,10 @@
import re import re
from datetime import datetime from datetime import datetime
from typing import cast
import pytz import pytz
from rss_parser import Parser from rss_parser import Parser
from rss_parser.models.rss import RSS
from alertproviders.http_alert_provider import HTTPAlertProvider from alertproviders.http_alert_provider import HTTPAlertProvider
from data.alert import Alert from data.alert import Alert
@@ -20,7 +22,7 @@ class NG3K(HTTPAlertProvider):
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response):
new_alerts = [] new_alerts = []
rss = Parser.parse(http_response.content.decode()) rss = cast(RSS, Parser.parse(http_response.content.decode()))
# Iterate through source data # Iterate through source data
for source_alert in rss.channel.items: for source_alert in rss.channel.items:
# Deal with "the format"... # Deal with "the format"...

View File

@@ -37,6 +37,6 @@ class POTA(HTTPAlertProvider):
# Add to our list, but exclude any old spots that POTA can sometimes give us where even the end time is # Add to our list, but exclude any old spots that POTA can sometimes give us where even the end time is
# in the past. Don't worry about de-duping, removing old alerts etc. at this point; other code will do # in the past. Don't worry about de-duping, removing old alerts etc. at this point; other code will do
# that for us. # that for us.
if alert.end_time > datetime.now(pytz.UTC).timestamp(): if alert.end_time and alert.end_time > datetime.now(pytz.UTC).timestamp():
new_alerts.append(alert) new_alerts.append(alert)
return new_alerts return new_alerts

View File

@@ -1,7 +1,9 @@
from datetime import datetime from datetime import datetime
from typing import cast
import pytz import pytz
from rss_parser import Parser as RSSParser from rss_parser import Parser as RSSParser
from rss_parser.models.rss import RSS
from alertproviders.http_alert_provider import HTTPAlertProvider from alertproviders.http_alert_provider import HTTPAlertProvider
from data.alert import Alert from data.alert import Alert
@@ -20,7 +22,7 @@ class WOTA(HTTPAlertProvider):
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response):
new_alerts = [] new_alerts = []
rss = RSSParser.parse(http_response.content.decode()) rss = cast(RSS, RSSParser.parse(http_response.content.decode()))
# Iterate through source data # Iterate through source data
for source_alert in rss.channel.items: for source_alert in rss.channel.items:
@@ -35,9 +37,9 @@ class WOTA(HTTPAlertProvider):
ref_name = None ref_name = None
if len(title_split) > 1: if len(title_split) > 1:
ref_split = title_split[1].split(" - ") ref_split = title_split[1].split(" - ")
ref = ref_split[0] ref = str(ref_split[0])
if len(ref_split) > 1: if len(ref_split) > 1:
ref_name = ref_split[1] ref_name = str(ref_split[1])
# Pick apart the description # Pick apart the description
desc_split = source_alert.description.split(". ") desc_split = source_alert.description.split(". ")

View File

@@ -104,7 +104,7 @@ class LookupHelper:
self._hamqth_callsign_data_cache = Cache('cache/hamqth_callsign_lookup_cache') self._hamqth_callsign_data_cache = Cache('cache/hamqth_callsign_lookup_cache')
self._clublog_api_key = config["clublog-api-key"] 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_cty_xml_cache = CachedSession("cache/clublog_cty_xml_cache", expire_after=timedelta(days=10))
self._clublog_api_available = self._clublog_api_key != "" self._clublog_api_available = self._clublog_api_key != ""
self._clublog_xml_download_location = "cache/cty.xml" self._clublog_xml_download_location = "cache/cty.xml"
@@ -184,6 +184,7 @@ class LookupHelper:
open(self._clublog_xml_download_location + ".gz", 'wb').write(response.content) open(self._clublog_xml_download_location + ".gz", 'wb').write(response.content)
with gzip.open(self._clublog_xml_download_location + ".gz", "rb") as uncompressed: with gzip.open(self._clublog_xml_download_location + ".gz", "rb") as uncompressed:
file_content = uncompressed.read() file_content = uncompressed.read()
assert isinstance(file_content, bytes)
logging.info("Caching Clublog cty.xml...") logging.info("Caching Clublog cty.xml...")
with open(self._clublog_xml_download_location, "wb") as f: with open(self._clublog_xml_download_location, "wb") as f:
f.write(file_content) f.write(file_content)
@@ -446,15 +447,16 @@ class LookupHelper:
def infer_grid_from_callsign_dxcc(self, call): def infer_grid_from_callsign_dxcc(self, call):
"""Infer a grid locator from a callsign (using DXCC, probably very inaccurate)""" """Infer a grid locator from a callsign (using DXCC, probably very inaccurate)"""
latlon = self.infer_latlon_from_callsign_dxcc(call) latlon = self.infer_latlon_from_callsign_dxcc(call) or []
grid = None grid = None
if latlon:
try: try:
grid = latlong_to_locator(latlon[0], latlon[1], 8) grid = latlong_to_locator(latlon[0], latlon[1], 8)
except: except:
logging.debug("Invalid lat/lon received for DXCC") logging.debug("Invalid lat/lon received for DXCC")
return grid return grid
def _get_qrz_data_for_callsign(self, call, credentials): 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. """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.""" Returns None immediately if no credentials are provided."""
@@ -475,7 +477,7 @@ class LookupHelper:
login_data = xmltodict.parse(login_response) login_data = xmltodict.parse(login_response)
session = login_data.get("QRZDatabase", {}).get("Session", {}) session = login_data.get("QRZDatabase", {}).get("Session", {})
if "Key" in session: if "Key" in session:
session_key = session["Key"] session_key = str(session["Key"])
else: else:
logging.warning("QRZ.com login details incorrect, failed to look up with QRZ.") logging.warning("QRZ.com login details incorrect, failed to look up with QRZ.")
return None return None
@@ -512,7 +514,7 @@ class LookupHelper:
self._qrz_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds self._qrz_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
return None return None
def _get_hamqth_data_for_callsign(self, call, credentials): 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. """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.""" Returns None immediately if no credentials are provided."""
@@ -531,7 +533,7 @@ class LookupHelper:
"&p=" + urllib.parse.quote_plus(credentials.hamqth_password), headers=HTTP_HEADERS).content "&p=" + urllib.parse.quote_plus(credentials.hamqth_password), headers=HTTP_HEADERS).content
dict_data = xmltodict.parse(session_data) dict_data = xmltodict.parse(session_data)
if "session_id" in dict_data["HamQTH"]["session"]: if "session_id" in dict_data["HamQTH"]["session"]:
session_id = dict_data["HamQTH"]["session"]["session_id"] session_id = str(dict_data["HamQTH"]["session"]["session_id"])
else: else:
logging.warning("HamQTH login details incorrect, failed to look up with HamQTH.") logging.warning("HamQTH login details incorrect, failed to look up with HamQTH.")
return None return None
@@ -566,7 +568,7 @@ class LookupHelper:
self._hamqth_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds self._hamqth_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
return None return None
def _get_clublog_api_data_for_callsign(self, call): 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""" """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 # Fetch from cache if we can, otherwise fetch from the API and cache it
@@ -594,7 +596,7 @@ class LookupHelper:
else: else:
return None return None
def _get_clublog_xml_data_for_callsign(self, call): def _get_clublog_xml_data_for_callsign(self, call) -> dict | None:
"""Utility method to get Clublog XML data from file""" """Utility method to get Clublog XML data from file"""
if self._clublog_xml_available: if self._clublog_xml_available:
@@ -608,7 +610,7 @@ class LookupHelper:
else: else:
return None return None
def _get_dxcc_data_for_callsign(self, call): 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""" """Utility method to get generic DXCC data from our lookup table, if we can find it"""
for entry in self._dxcc_data.values(): for entry in self._dxcc_data.values():

View File

@@ -25,13 +25,13 @@ def populate_sig_ref_info(sig_ref):
if sig_ref.sig is None or sig_ref.id is None: if sig_ref.sig is None or sig_ref.id is None:
logging.warning("Failed to look up sig_ref info, sig or id were not set.") logging.warning("Failed to look up sig_ref info, sig or id were not set.")
sig = sig_ref.sig sig = sig_ref.sig or ""
ref_id = sig_ref.id ref_id = sig_ref.id
try: try:
if sig.upper() == "POTA": if sig.upper() == "POTA":
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api.pota.app/park/" + ref_id, headers=HTTP_HEADERS).json() data = SEMI_STATIC_URL_DATA_CACHE.get("https://api.pota.app/park/" + ref_id, headers=HTTP_HEADERS).json()
if data: if data:
fullname = data["name"] if "name" in data else None fullname = str(data["name"]) if "name" in data else None
if fullname and "parktypeDesc" in data and data["parktypeDesc"] != "": if fullname and "parktypeDesc" in data and data["parktypeDesc"] != "":
fullname = fullname + " " + data["parktypeDesc"] fullname = fullname + " " + data["parktypeDesc"]
sig_ref.name = fullname sig_ref.name = fullname
@@ -129,9 +129,9 @@ def populate_sig_ref_info(sig_ref):
if data: if data:
for ref in data: for ref in data:
if ref["reference_code"] == ref_id: if ref["reference_code"] == ref_id:
sig_ref.name = ref["name"] sig_ref.name = str(ref["name"])
sig_ref.url = "https://llota.app/list/ref/" + ref_id sig_ref.url = "https://llota.app/list/ref/" + ref_id
sig_ref.grid = ref["grid_locator"] sig_ref.grid = str(ref["grid_locator"])
ll = locator_to_latlong(sig_ref.grid) ll = locator_to_latlong(sig_ref.grid)
sig_ref.latitude = ll[0] sig_ref.latitude = ll[0]
sig_ref.longitude = ll[1] sig_ref.longitude = ll[1]
@@ -139,7 +139,7 @@ def populate_sig_ref_info(sig_ref):
elif sig.upper() == "WWTOTA": elif sig.upper() == "WWTOTA":
if not sig_ref.name: if not sig_ref.name:
sig_ref.name = sig_ref.id sig_ref.name = sig_ref.id
sig_ref.url = "https://wwtota.com/seznam/karta_rozhledny.php?ref=" + sig_ref.name sig_ref.url = "https://wwtota.com/seznam/karta_rozhledny.php?ref=" + str(sig_ref.name)
elif sig.upper() == "TILES": elif sig.upper() == "TILES":
# Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same # Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same
if not sig_ref.name: if not sig_ref.name:
@@ -147,7 +147,7 @@ def populate_sig_ref_info(sig_ref):
if not sig_ref.grid: if not sig_ref.grid:
sig_ref.grid = sig_ref.id sig_ref.grid = sig_ref.id
if sig_ref.grid and not sig_ref.latitude: if sig_ref.grid and not sig_ref.latitude:
ll = locator_to_latlong(sig_ref.grid) ll = locator_to_latlong(str(sig_ref.grid))
sig_ref.latitude = ll[0] sig_ref.latitude = ll[0]
sig_ref.longitude = ll[1] sig_ref.longitude = ll[1]
elif sig.upper() == "WAB" or sig.upper() == "WAI": elif sig.upper() == "WAB" or sig.upper() == "WAI":

View File

@@ -15,51 +15,51 @@ class Alert:
"""Data class that defines an alert.""" """Data class that defines an alert."""
# Unique identifier for the alert # Unique identifier for the alert
id: str = None id: str | None = None
# Callsigns of the operators that has been alerted # Callsigns of the operators that has been alerted
dx_calls: list = None dx_calls: list | None = None
# Names of the operators that has been alerted # Names of the operators that has been alerted
dx_names: list = None dx_names: list | None = None
# Country of the DX operator # Country of the DX operator
dx_country: str = None dx_country: str | None = None
# Country flag of the DX operator # Country flag of the DX operator
dx_flag: str = None dx_flag: str | None = None
# Continent of the DX operator # Continent of the DX operator
dx_continent: str = None dx_continent: str | None = None
# DXCC ID of the DX operator # DXCC ID of the DX operator
dx_dxcc_id: int = None dx_dxcc_id: int | None = None
# CQ zone of the DX operator # CQ zone of the DX operator
dx_cq_zone: int = None dx_cq_zone: int | None = None
# ITU zone of the DX operator # ITU zone of the DX operator
dx_itu_zone: int = None dx_itu_zone: int | None = None
# Intended frequencies & modes of operation. Essentially just a different kind of comment field. # Intended frequencies & modes of operation. Essentially just a different kind of comment field.
freqs_modes: str = None freqs_modes: str | None = None
# Start time of the activation, UTC seconds since UNIX epoch # Start time of the activation, UTC seconds since UNIX epoch
start_time: float = None start_time: float | None = None
# Start time of the activation of the alert, ISO 8601 # Start time of the activation of the alert, ISO 8601
start_time_iso: str = None start_time_iso: str | None = None
# End time of the activation, UTC seconds since UNIX epoch. Optional # End time of the activation, UTC seconds since UNIX epoch. Optional
end_time: float = None end_time: float | None = None
# End time of the activation of the alert, ISO 8601 # End time of the activation of the alert, ISO 8601
end_time_iso: str = None end_time_iso: str | None = None
# Time that this software received the alert, UTC seconds since UNIX epoch. This is used with the "since_received" # Time that this software received the alert, UTC seconds since UNIX epoch. This is used with the "since_received"
# call to our API to receive all data that is new to us, even if by a quirk of the API it might be older than the # call to our API to receive all data that is new to us, even if by a quirk of the API it might be older than the
# list time the client polled the API. # list time the client polled the API.
received_time: float = None received_time: float | None = None
# Time that this software received the alert, ISO 8601 # Time that this software received the alert, ISO 8601
received_time_iso: str = None received_time_iso: str | None = None
# Comment made by the alerter, if any # Comment made by the alerter, if any
comment: str = None comment: str | None = None
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA # Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
sig: str = None sig: str | None = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO # SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
sig_refs: list = None sig_refs: list | None = None
# Whether this alert is for a DXpedition, as opposed to e.g. an xOTA programme. # Whether this alert is for a DXpedition, as opposed to e.g. an xOTA programme.
is_dxpedition: bool = False is_dxpedition: bool = False
# Where we got the alert from, e.g. "POTA", "SOTA"... # Where we got the alert from, e.g. "POTA", "SOTA"...
source: str = None source: str | None = None
# The ID the source gave it, if any. # The ID the source gave it, if any.
source_id: str = None source_id: str | None = None
def infer_missing(self, credentials=None): def infer_missing(self, credentials=None):
"""Infer missing parameters where possible""" """Infer missing parameters where possible"""

View File

@@ -11,14 +11,14 @@ class SIGRef:
# SIG that this reference is in, e.g. "POTA". # SIG that this reference is in, e.g. "POTA".
sig: str sig: str
# Name of the reference, e.g. "Null Country Park", if known. # Name of the reference, e.g. "Null Country Park", if known.
name: str = None name: str | None = None
# URL to look up more information about the reference, if known. # URL to look up more information about the reference, if known.
url: str = None url: str | None = None
# Latitude of the reference, if known. # Latitude of the reference, if known.
latitude: float = None latitude: float | None = None
# Longitude of the reference, if known. # Longitude of the reference, if known.
longitude: float = None longitude: float | None = None
# Maidenhead grid reference of the reference, if known. # Maidenhead grid reference of the reference, if known.
grid: str = None grid: str | None = None
# Activation score. SOTA only # Activation score. SOTA only
activation_score: int = None activation_score: int | None = None

View File

@@ -110,11 +110,11 @@ class HFBandCondition:
"""Data class representing HF propagation conditions for certain bands and time of day.""" """Data class representing HF propagation conditions for certain bands and time of day."""
# Band name, e.g. "80m-40m", "20m-17m", "10m-6m" # Band name, e.g. "80m-40m", "20m-17m", "10m-6m"
band: str = None band: str | None = None
# Time of day: "day" or "night" # Time of day: "day" or "night"
time: str = None time: str | None = None
# Propagation condition: "Good", "Fair", or "Poor" # Propagation condition: "Good", "Fair", or "Poor"
condition: str = None condition: str | None = None
@dataclass @dataclass
@@ -122,66 +122,66 @@ class SolarConditions:
"""Data class representing current solar and propagation conditions.""" """Data class representing current solar and propagation conditions."""
# Time the data was last updated at the source, UTC seconds since UNIX epoch # Time the data was last updated at the source, UTC seconds since UNIX epoch
updated: float = None updated: float | None = None
# Solar Flux Index (SFI) # Solar Flux Index (SFI)
sfi: int = None sfi: int | None = None
# A-index (daily geomagnetic activity) # A-index (daily geomagnetic activity)
a_index: int = None a_index: int | None = None
# K-index (3-hour geomagnetic activity) # K-index (3-hour geomagnetic activity)
k_index: int = None k_index: int | None = None
# X-ray flux class, e.g. "B2.3", "C1.0" # X-ray flux class, e.g. "B2.3", "C1.0"
xray: str = None xray: str | None = None
# Proton flux # Proton flux
proton_flux: int = None proton_flux: int | None = None
# Electron flux # Electron flux
electron_flux: int = None electron_flux: int | None = None
# Aurora activity level # Aurora activity level
aurora: int = None aurora: int | None = None
# Latitude in degrees of the aurora boundary # Latitude in degrees of the aurora boundary
aurora_latitude: float = None aurora_latitude: float | None = None
# Sunspot count # Sunspot count
sunspots: int = None sunspots: int | None = None
# Solar wind speed in km/s # Solar wind speed in km/s
solar_wind: float = None solar_wind: float | None = None
# Interplanetary magnetic field strength in nT # Interplanetary magnetic field strength in nT
magnetic_field: float = None magnetic_field: float | None = None
# Geomagnetic field condition, e.g. "Quiet", "Unsettled", "Active", "Storm" # Geomagnetic field condition, e.g. "Quiet", "Unsettled", "Active", "Storm"
geomag_field: str = None geomag_field: str | None = None
# Geomagnetic background noise level, e.g. "S0", "S1", "S2" # Geomagnetic background noise level, e.g. "S0", "S1", "S2"
geomag_noise: str = None geomag_noise: str | None = None
# HF band propagation conditions, keyed by "{band}-{time}" e.g. "80m-40m-day" # HF band propagation conditions, keyed by "{band}-{time}" e.g. "80m-40m-day"
hf_conditions: dict = None hf_conditions: dict | None = None
# VHF propagation conditions, keyed by condition name # VHF propagation conditions, keyed by condition name
vhf_conditions: dict = None vhf_conditions: dict | None = None
# NOAA Kp index 3-day forecast, keyed by UNIX timestamp of the start of each 3-hour UTC period # NOAA Kp index 3-day forecast, keyed by UNIX timestamp of the start of each 3-hour UTC period
k_index_forecast: dict = None k_index_forecast: dict | None = None
# NOAA Solar Radiation Storm (S1 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC # NOAA Solar Radiation Storm (S1 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC
solar_storm_forecast: dict = None solar_storm_forecast: dict | None = None
# NOAA Radio Blackout (R1-R2) probability forecast, keyed by UNIX timestamp of start of day UTC # NOAA Radio Blackout (R1-R2) probability forecast, keyed by UNIX timestamp of start of day UTC
blackout_forecast_r1r2: dict = None blackout_forecast_r1r2: dict | None = None
# NOAA Radio Blackout (R3 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC # NOAA Radio Blackout (R3 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC
blackout_forecast_r3_or_greater: dict = None blackout_forecast_r3_or_greater: dict | None = None
# Ionosonde measurements, dict keyed by URSI code, values are dicts with keys: ursi, name, fof2, muf, luf, # Ionosonde measurements, dict keyed by URSI code, values are dicts with keys: ursi, name, fof2, muf, luf,
# band_states. Populated by GIROIonosonde or KC2GProp providers. # band_states. Populated by GIROIonosonde or KC2GProp providers.
ionosonde_data: dict = None ionosonde_data: dict | None = None
# Derived values (populated by infer_descriptions()) # Derived values (populated by infer_descriptions())
# HF radio blackout risk description, derived from xray # HF radio blackout risk description, derived from xray
xray_desc: str = None xray_desc: str | None = None
# HF radio blackout scale number (R0-R5), derived from xray # HF radio blackout scale number (R0-R5), derived from xray
radio_blackout_scale: int = None radio_blackout_scale: int | None = None
# Solar radiation storm level description, derived from proton_flux # Solar radiation storm level description, derived from proton_flux
proton_flux_desc: str = None proton_flux_desc: str | None = None
# Solar radiation storm scale number (S0-S5), derived from proton_flux # Solar radiation storm scale number (S0-S5), derived from proton_flux
solar_storm_scale: int = None solar_storm_scale: int | None = None
# Geomagnetic storm level description, derived from k_index # Geomagnetic storm level description, derived from k_index
geomag_storm_desc: str = None geomag_storm_desc: str | None = None
# Geomagnetic storm scale number (G0-G5), derived from k_index # Geomagnetic storm scale number (G0-G5), derived from k_index
geomag_storm_scale: int = None geomag_storm_scale: int | None = None
# Overall HF band conditions summary, derived from sfi # Overall HF band conditions summary, derived from sfi
band_conditions_desc: str = None band_conditions_desc: str | None = None
# Electron flux description, derived from electron_flux # Electron flux description, derived from electron_flux
electron_flux_desc: str = None electron_flux_desc: str | None = None
def infer_descriptions(self): def infer_descriptions(self):
"""Populate derived text description fields from the current numeric/raw field values.""" """Populate derived text description fields from the current numeric/raw field values."""

View File

@@ -23,38 +23,38 @@ class Spot:
"""Data class that defines a spot.""" """Data class that defines a spot."""
# Unique identifier for the spot # Unique identifier for the spot
id: str = None id: str | None = None
# DX (spotted) operator info # DX (spotted) operator info
# Callsign of the operator that has been spotted # Callsign of the operator that has been spotted
dx_call: str = None dx_call: str | None = None
# Name of the operator that has been spotted # Name of the operator that has been spotted
dx_name: str = None dx_name: str | None = None
# QTH of the operator that has been spotted. This could be from any SIG refs or could be from online lookup of their # QTH of the operator that has been spotted. This could be from any SIG refs or could be from online lookup of their
# home QTH. # home QTH.
dx_qth: str = None dx_qth: str | None = None
# Country of the DX operator # Country of the DX operator
dx_country: str = None dx_country: str | None = None
# Country flag of the DX operator # Country flag of the DX operator
dx_flag: str = None dx_flag: str | None = None
# Continent of the DX operator # Continent of the DX operator
dx_continent: str = None dx_continent: str | None = None
# DXCC ID of the DX operator # DXCC ID of the DX operator
dx_dxcc_id: int = None dx_dxcc_id: int | None = None
# CQ zone of the DX operator # CQ zone of the DX operator
dx_cq_zone: int = None dx_cq_zone: int | None = None
# ITU zone of the DX operator # ITU zone of the DX operator
dx_itu_zone: int = None dx_itu_zone: int | None = None
# If this is an APRS/Packet/etc spot, what SSID was the DX operator using? # If this is an APRS/Packet/etc spot, what SSID was the DX operator using?
dx_ssid: str = None dx_ssid: str | None = None
# Maidenhead grid locator for the DX. This could be from a geographical reference e.g. POTA, or just from the # Maidenhead grid locator for the DX. This could be from a geographical reference e.g. POTA, or just from the
# country # country
dx_grid: str = None dx_grid: str | None = None
# Latitude & longitude of the DX, in degrees. This could be from a geographical reference e.g. POTA, or from a QRZ # Latitude & longitude of the DX, in degrees. This could be from a geographical reference e.g. POTA, or from a QRZ
# lookup # lookup
dx_latitude: float = None dx_latitude: float | None = None
dx_longitude: float = None dx_longitude: float | None = None
# DX Location source. Indicates how accurate the location might be. Values: "SPOT", "WAB/WAI GRID", "HOME QTH", # DX Location source. Indicates how accurate the location might be. Values: "SPOT", "WAB/WAI GRID", "HOME QTH",
# "DXCC", "NONE" # "DXCC", "NONE"
dx_location_source: str = "NONE" dx_location_source: str = "NONE"
@@ -66,70 +66,70 @@ class Spot:
# DE (Spotter) info # DE (Spotter) info
# Callsign of the spotter # Callsign of the spotter
de_call: str = None de_call: str | None = None
# Country of the spotter # Country of the spotter
de_country: str = None de_country: str | None = None
# Country flag of the spotter # Country flag of the spotter
de_flag: str = None de_flag: str | None = None
# Continent of the spotter # Continent of the spotter
de_continent: str = None de_continent: str | None = None
# DXCC ID of the spotter # DXCC ID of the spotter
de_dxcc_id: int = None de_dxcc_id: int | None = None
# If this is an APRS/Packet/etc spot, what SSID was the spotter/receiver using? # If this is an APRS/Packet/etc spot, what SSID was the spotter/receiver using?
de_ssid: str = None de_ssid: str | None = None
# Maidenhead grid locator for the spotter. This is not going to be from a xOTA reference so it will likely just be # Maidenhead grid locator for the spotter. This is not going to be from a xOTA reference so it will likely just be
# a QRZ or DXCC lookup. If the spotter is also portable, this is probably wrong, but it's good enough for some # a QRZ or DXCC lookup. If the spotter is also portable, this is probably wrong, but it's good enough for some
# simple mapping. # simple mapping.
de_grid: str = None de_grid: str | None = None
# Latitude & longitude of the DX, in degrees. This is not going to be from a xOTA reference so it will likely just # Latitude & longitude of the DX, in degrees. This is not going to be from a xOTA reference so it will likely just
# be a QRZ or DXCC lookup. If the spotter is also portable, this is probably wrong, but it's good enough for some # be a QRZ or DXCC lookup. If the spotter is also portable, this is probably wrong, but it's good enough for some
# simple mapping. # simple mapping.
de_latitude: float = None de_latitude: float | None = None
de_longitude: float = None de_longitude: float | None = None
# General QSO info # General QSO info
# Reported mode, such as SSB, PHONE, CW, FT8... # Reported mode, such as SSB, PHONE, CW, FT8...
mode: str = None mode: str | None = None
# Inferred mode "family". One of "CW", "PHONE" or "DIGI". # Inferred mode "family". One of "CW", "PHONE" or "DIGI".
mode_type: str = None mode_type: str | None = None
# Source of the mode information. "SPOT", "COMMENT", "BANDPLAN" or "NONE" # Source of the mode information. "SPOT", "COMMENT", "BANDPLAN" or "NONE"
mode_source: str = "NONE" mode_source: str = "NONE"
# Frequency, in Hz # Frequency, in Hz
freq: float = None freq: float | None = None
# Band, defined by the frequency, e.g. "40m" or "70cm" # Band, defined by the frequency, e.g. "40m" or "70cm"
band: str = None band: str | None = None
# Comment left by the spotter, if any # Comment left by the spotter, if any
comment: str = None comment: str | None = None
# QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments. # QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
qrt: bool = False qrt: bool = False
# Special Interest Group info # Special Interest Group info
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA # Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
sig: str = None sig: str | None = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO # SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
sig_refs: list = None sig_refs: list | None = None
# Timing info # Timing info
# Time of the spot, UTC seconds since UNIX epoch # Time of the spot, UTC seconds since UNIX epoch
time: float = None time: float | None = None
# Time of the spot, ISO 8601 # Time of the spot, ISO 8601
time_iso: str = None time_iso: str | None = None
# Time that this software received the spot, UTC seconds since UNIX epoch. This is used with the "since_received" # Time that this software received the spot, UTC seconds since UNIX epoch. This is used with the "since_received"
# call to our API to receive all data that is new to us, even if by a quirk of the API it might be older than the # call to our API to receive all data that is new to us, even if by a quirk of the API it might be older than the
# list time the client polled the API. # list time the client polled the API.
received_time: float = None received_time: float | None = None
# Time that this software received the spot, ISO 8601 # Time that this software received the spot, ISO 8601
received_time_iso: str = None received_time_iso: str | None = None
# Source info # Source info
# Where we got the spot from, e.g. "POTA", "Cluster"... # Where we got the spot from, e.g. "POTA", "Cluster"...
source: str = None source: str | None = None
# The ID the source gave it, if any. # The ID the source gave it, if any.
source_id: str = None source_id: str | None = None
def infer_missing(self, credentials=None): def infer_missing(self, credentials=None):
"""Infer missing parameters where possible""" """Infer missing parameters where possible"""
@@ -335,9 +335,10 @@ class Spot:
# Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable string, # Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable string,
# otherwise see what they have set on an online lookup service. # otherwise see what they have set on an online lookup service.
if self.sig_refs and len(self.sig_refs) > 0: if self.sig_refs and len(self.sig_refs) > 0:
self.dx_qth = self.sig_refs[0].id qth = self.sig_refs[0].id
if self.sig_refs[0].name: if self.sig_refs[0].name:
self.dx_qth = self.dx_qth + " " + self.sig_refs[0].name qth += " " + self.sig_refs[0].name
self.dx_qth = qth
else: else:
self.dx_qth = lookup_helper.infer_qth_from_callsign_online_lookup(self.dx_call, credentials) self.dx_qth = lookup_helper.infer_qth_from_callsign_online_lookup(self.dx_call, credentials)
@@ -353,10 +354,10 @@ class Spot:
# It looks like we can sometimes get a string into lat/lon, so try to parse as float, reject if not valid # 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): if isinstance(self.dx_latitude, str) or isinstance(self.dx_longitude, str):
try: try:
self.dx_latitude = float(self.dx_latitude) self.dx_latitude = float(str(self.dx_latitude))
self.dx_longitude = float(self.dx_longitude) self.dx_longitude = float(str(self.dx_longitude))
except (TypeError, ValueError): except (TypeError, ValueError):
logging.warning("Received non-numeric strings in lat/lon (" + str(self.dx_latitude) + ", " + str(self.dx_longitude) + ") for call " + self.dx_call + ", rejecting it") 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_latitude = None
self.dx_longitude = None self.dx_longitude = None
@@ -374,10 +375,10 @@ class Spot:
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator # DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
# is likely at home. # is likely at home.
self.dx_location_good = self.dx_latitude and self.dx_longitude and ( self.dx_location_good = bool(self.dx_latitude and self.dx_longitude and (
self.dx_location_source == "SPOT" or self.dx_location_source == "SIG REF LOOKUP" self.dx_location_source == "SPOT" or self.dx_location_source == "SIG REF LOOKUP"
or self.dx_location_source == "WAB/WAI GRID" or self.dx_location_source == "WAB/WAI GRID"
or (self.dx_location_source == "HOME QTH" and not "/" in self.dx_call)) or (self.dx_location_source == "HOME QTH" and "/" not in (self.dx_call or ""))))
# DE with no digits and APRS servers starting "T2" are not things we can look up location for # 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 (
@@ -406,16 +407,16 @@ class Spot:
def _append_sig_ref_if_missing(self, new_sig_ref): def _append_sig_ref_if_missing(self, new_sig_ref):
"""Append a sig_ref to the list, so long as it's not already there.""" """Append a sig_ref to the list, so long as it's not already there."""
if not self.sig_refs: sig_refs = self.sig_refs or []
self.sig_refs = [] self.sig_refs = sig_refs
new_sig_ref.id = new_sig_ref.id.strip().upper() new_sig_ref.id = new_sig_ref.id.strip().upper()
new_sig_ref.sig = new_sig_ref.sig.strip().upper() new_sig_ref.sig = new_sig_ref.sig.strip().upper()
if new_sig_ref.id == "": if new_sig_ref.id == "":
return return
for sig_ref in self.sig_refs: for sig_ref in sig_refs:
if sig_ref.id == new_sig_ref.id and sig_ref.sig == new_sig_ref.sig: if sig_ref.id == new_sig_ref.id and sig_ref.sig == new_sig_ref.sig:
return return
self.sig_refs.append(new_sig_ref) sig_refs.append(new_sig_ref)
def expired(self): def expired(self):
"""Decide if this spot has expired (in which case it should not be added to the system in the first place, and not """Decide if this spot has expired (in which case it should not be added to the system in the first place, and not

View File

@@ -2,9 +2,12 @@ import json
import logging import logging
import re import re
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
import tornado import tornado
from tornado import httputil
from tornado.web import Application
from core.config import ALLOW_SPOTTING, MAX_SPOT_AGE from core.config import ALLOW_SPOTTING, MAX_SPOT_AGE
from core.constants import UNKNOWN_BAND from core.constants import UNKNOWN_BAND
@@ -19,6 +22,11 @@ from data.spot import Spot
class APISpotHandler(tornado.web.RequestHandler): class APISpotHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/spot (POST)""" """API request handler for /api/v1/spot (POST)"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._spots = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, spots, web_server_metrics): def initialize(self, spots, web_server_metrics):
self._spots = spots self._spots = spots
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics

View File

@@ -3,16 +3,18 @@ import json
import logging import logging
from datetime import datetime from datetime import datetime
from queue import Queue from queue import Queue
from typing import Any
import pytz import pytz
import tornado import tornado
import tornado_eventsource.handler import tornado_eventsource.handler
from tornado import httputil
from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything, empty_queue from core.utils import serialize_everything, empty_queue
from data.lookup_credentials import extract_credentials from data.lookup_credentials import extract_credentials
SSE_HANDLER_MAX_QUEUE_SIZE = 100 SSE_HANDLER_MAX_QUEUE_SIZE = 100
SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000 SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
@@ -20,6 +22,11 @@ SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
class APIAlertsHandler(tornado.web.RequestHandler): class APIAlertsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/alerts""" """API request handler for /api/v1/alerts"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._alerts = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, alerts, web_server_metrics): def initialize(self, alerts, web_server_metrics):
self._alerts = alerts self._alerts = alerts
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics
@@ -67,6 +74,15 @@ class APIAlertsHandler(tornado.web.RequestHandler):
class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler): class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
"""API request handler for /api/v1/alerts/stream""" """API request handler for /api/v1/alerts/stream"""
def __init__(self, application, request, **kwargs: Any):
self._sse_alert_queues = None
self._web_server_metrics = None
self._query_params = None
self._credentials = None
self._alert_queue = None
self._heartbeat = None
super().__init__(application, request, **kwargs)
def initialize(self, sse_alert_queues, web_server_metrics): def initialize(self, sse_alert_queues, web_server_metrics):
self._sse_alert_queues = sse_alert_queues self._sse_alert_queues = sse_alert_queues
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics

View File

@@ -1,9 +1,12 @@
import json import json
from collections import Counter from collections import Counter
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any
import pytz import pytz
import tornado import tornado
from tornado import httputil
from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
@@ -16,6 +19,11 @@ BANDS_SET = frozenset(BANDS)
class APIDxStatsHandler(tornado.web.RequestHandler): class APIDxStatsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/dxstats""" """API request handler for /api/v1/dxstats"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._spots = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, spots, web_server_metrics): def initialize(self, spots, web_server_metrics):
self._spots = spots self._spots = spots
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics

View File

@@ -2,9 +2,12 @@ import json
import logging import logging
import re import re
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
import tornado import tornado
from tornado import httputil
from tornado.web import Application
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
@@ -19,6 +22,10 @@ from data.spot import Spot
class APILookupCallHandler(tornado.web.RequestHandler): class APILookupCallHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/lookup/call""" """API request handler for /api/v1/lookup/call"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, web_server_metrics): def initialize(self, web_server_metrics):
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics
@@ -36,7 +43,7 @@ class APILookupCallHandler(tornado.web.RequestHandler):
# The "call" query param must exist and look like a callsign # The "call" query param must exist and look like a callsign
if "call" in query_params.keys(): if "call" in query_params.keys():
call = 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 # 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. # resulting data in the correct way for the API response.
@@ -80,6 +87,10 @@ class APILookupCallHandler(tornado.web.RequestHandler):
class APILookupSIGRefHandler(tornado.web.RequestHandler): class APILookupSIGRefHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/lookup/sigref""" """API request handler for /api/v1/lookup/sigref"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, web_server_metrics): def initialize(self, web_server_metrics):
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics
@@ -98,8 +109,8 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
# "sig" and "id" query params must exist, SIG must be known, and if we have a reference regex for that SIG, # "sig" and "id" query params must exist, SIG must be known, and if we have a reference regex for that SIG,
# the provided id must match it. # the provided id must match it.
if "sig" in query_params.keys() and "id" in query_params.keys(): if "sig" in query_params.keys() and "id" in query_params.keys():
sig = query_params.get("sig").upper() sig = str(query_params.get("sig")).upper()
ref_id = query_params.get("id").upper() ref_id = str(query_params.get("id")).upper()
if sig in list(map(lambda p: p.name, SIGS)): if sig in list(map(lambda p: p.name, 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_sig_ref_info(SIGRef(id=ref_id, sig=sig))
@@ -129,6 +140,10 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
class APILookupGridHandler(tornado.web.RequestHandler): class APILookupGridHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/lookup/grid""" """API request handler for /api/v1/lookup/grid"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, web_server_metrics): def initialize(self, web_server_metrics):
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics
@@ -146,7 +161,7 @@ class APILookupGridHandler(tornado.web.RequestHandler):
# "grid" query param must exist. # "grid" query param must exist.
if "grid" in query_params.keys(): if "grid" in query_params.keys():
grid = query_params.get("grid").upper() grid = str(query_params.get("grid")).upper()
lat, lon, lat_cell_size, lon_cell_size = lat_lon_for_grid_sw_corner_plus_size(grid) lat, lon, lat_cell_size, lon_cell_size = lat_lon_for_grid_sw_corner_plus_size(grid)
if lat is not None and lon is not None and lat_cell_size is not None and lon_cell_size is not None: if lat is not None and lon is not None and lat_cell_size is not None and lon_cell_size is not None:
center_lat = lat + lat_cell_size / 2.0 center_lat = lat + lat_cell_size / 2.0

View File

@@ -1,8 +1,11 @@
import json import json
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
import tornado import tornado
from tornado import httputil
from tornado.web import Application
from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS
@@ -13,6 +16,11 @@ from core.utils import serialize_everything
class APIOptionsHandler(tornado.web.RequestHandler): class APIOptionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/options""" """API request handler for /api/v1/options"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._status_data = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, status_data, web_server_metrics): def initialize(self, status_data, web_server_metrics):
self._status_data = status_data self._status_data = status_data
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics

View File

@@ -1,7 +1,10 @@
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
import tornado import tornado
from tornado import httputil
from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
@@ -9,6 +12,11 @@ from core.prometheus_metrics_handler import api_requests_counter
class APISolarConditionsHandler(tornado.web.RequestHandler): class APISolarConditionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/solar""" """API request handler for /api/v1/solar"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._solar_conditions = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, solar_conditions, web_server_metrics): def initialize(self, solar_conditions, web_server_metrics):
self._solar_conditions = solar_conditions self._solar_conditions = solar_conditions
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics

View File

@@ -3,16 +3,18 @@ import json
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from queue import Queue from queue import Queue
from typing import Any
import pytz import pytz
import tornado import tornado
import tornado_eventsource.handler import tornado_eventsource.handler
from tornado import httputil
from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything, empty_queue from core.utils import serialize_everything, empty_queue
from data.lookup_credentials import extract_credentials from data.lookup_credentials import extract_credentials
SSE_HANDLER_MAX_QUEUE_SIZE = 1000 SSE_HANDLER_MAX_QUEUE_SIZE = 1000
SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000 SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
@@ -20,6 +22,11 @@ SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
class APISpotsHandler(tornado.web.RequestHandler): class APISpotsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/spots""" """API request handler for /api/v1/spots"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._spots = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, spots, web_server_metrics): def initialize(self, spots, web_server_metrics):
self._spots = spots self._spots = spots
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics
@@ -67,6 +74,15 @@ class APISpotsHandler(tornado.web.RequestHandler):
class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler): class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
"""API request handler for /api/v1/spots/stream""" """API request handler for /api/v1/spots/stream"""
def __init__(self, application, request, **kwargs: Any):
self._sse_spot_queues = None
self._web_server_metrics = None
self._query_params = None
self._credentials = None
self._spot_queue = None
self._heartbeat = None
super().__init__(application, request, **kwargs)
def initialize(self, sse_spot_queues, web_server_metrics): def initialize(self, sse_spot_queues, web_server_metrics):
self._sse_spot_queues = sse_spot_queues self._sse_spot_queues = sse_spot_queues
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics

View File

@@ -1,8 +1,11 @@
import json import json
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
import tornado import tornado
from tornado import httputil
from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything from core.utils import serialize_everything
@@ -11,6 +14,11 @@ from core.utils import serialize_everything
class APIStatusHandler(tornado.web.RequestHandler): class APIStatusHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/status""" """API request handler for /api/v1/status"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._status_data = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, status_data, web_server_metrics): def initialize(self, status_data, web_server_metrics):
self._status_data = status_data self._status_data = status_data
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics

View File

@@ -1,7 +1,10 @@
from datetime import datetime from datetime import datetime
from typing import Any
import pytz import pytz
import tornado import tornado
from tornado import httputil
from tornado.web import Application
from core.config import ALLOW_SPOTTING, WEB_UI_OPTIONS, BASE_URL, SERVER_OWNER_CALLSIGN from core.config import ALLOW_SPOTTING, WEB_UI_OPTIONS, BASE_URL, SERVER_OWNER_CALLSIGN
from core.constants import SOFTWARE_VERSION from core.constants import SOFTWARE_VERSION
@@ -11,6 +14,11 @@ from core.prometheus_metrics_handler import page_requests_counter
class PageTemplateHandler(tornado.web.RequestHandler): class PageTemplateHandler(tornado.web.RequestHandler):
"""Handler for all HTML pages generated from templates""" """Handler for all HTML pages generated from templates"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._template_name = None
self._web_server_metrics = None
super().__init__(application, request, **kwargs)
def initialize(self, template_name, web_server_metrics): def initialize(self, template_name, web_server_metrics):
self._template_name = template_name self._template_name = template_name
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics

View File

@@ -19,6 +19,9 @@ from server.handlers.metrics import PrometheusMetricsHandler
from server.handlers.pagetemplate import PageTemplateHandler from server.handlers.pagetemplate import PageTemplateHandler
_HERE = os.path.dirname(__file__ or "")
class WebServer: class WebServer:
"""Provides the public-facing web server.""" """Provides the public-facing web server."""
@@ -101,11 +104,11 @@ class WebServer:
misc_routes = [ misc_routes = [
(r"/apidocs", PageTemplateHandler, {"template_name": "apidocs", **handler_opts}), (r"/apidocs", PageTemplateHandler, {"template_name": "apidocs", **handler_opts}),
(r"/metrics", PrometheusMetricsHandler), (r"/metrics", PrometheusMetricsHandler),
(r"/(.*)", StaticFileHandler, {"path": os.path.join(os.path.dirname(__file__), "../webassets")}) (r"/(.*)", StaticFileHandler, {"path": os.path.join(_HERE, "../webassets")})
] ]
app = tornado.web.Application(api_routes + ui_routes + misc_routes, app = tornado.web.Application(api_routes + ui_routes + misc_routes,
template_path=os.path.join(os.path.dirname(__file__), "../templates"), template_path=os.path.join(_HERE, "../templates"),
debug=False) debug=False)
app.listen(self._port) app.listen(self._port)
logging.info("Web server running on port " + str(WEB_SERVER_PORT)) logging.info("Web server running on port " + str(WEB_SERVER_PORT))

View File

@@ -28,7 +28,8 @@ class GIROIonosonde(SolarConditionsProvider):
self._thread = None self._thread = None
self._stop_event = Event() self._stop_event = Event()
def _load_stations(self): @staticmethod
def _load_stations():
stations = [] stations = []
with open(STATIONS_INDEX, newline='') as f: with open(STATIONS_INDEX, newline='') as f:
for row in csv.reader(f): for row in csv.reader(f):

View File

@@ -32,6 +32,9 @@ class HamQSL(HTTPSolarConditionsProvider):
# Some error checking functions in case the data is janky. # Some error checking functions in case the data is janky.
def text(tag, default=None): def text(tag, default=None):
if sd is None:
logging.warning("HamQSL solar conditions API returned unexpected XML structure")
return default
el = sd.find(tag) el = sd.find(tag)
return el.text.strip() if el is not None and el.text else default return el.text.strip() if el is not None and el.text else default
@@ -104,7 +107,7 @@ class HamQSL(HTTPSolarConditionsProvider):
"geomag_noise": text("signalnoise"), "geomag_noise": text("signalnoise"),
"hf_conditions": hf_conditions, "hf_conditions": hf_conditions,
"vhf_conditions": { "vhf_conditions": {
"vhf_aurora_northern_hemi": vhf_map.get(("vhf-aurora", "northern_hemi")).title().replace("Lat Aur", "Latitude"), "vhf_aurora_northern_hemi": (vhf_map.get(("vhf-aurora", "northern_hemi")) or "").title().replace("Lat Aur", "Latitude") or None,
"es_2m_europe": vhf_map.get(("E-Skip", "europe")), "es_2m_europe": vhf_map.get(("E-Skip", "europe")),
"es_4m_europe": vhf_map.get(("E-Skip", "europe_4m")), "es_4m_europe": vhf_map.get(("E-Skip", "europe_4m")),
"es_6m_europe": vhf_map.get(("E-Skip", "europe_6m")), "es_6m_europe": vhf_map.get(("E-Skip", "europe_6m")),

View File

@@ -3,8 +3,8 @@ from core.constants import BANDS
HF_BANDS = [b for b in BANDS if b.is_ham_hf] HF_BANDS = [b for b in BANDS if b.is_ham_hf]
def _latest(d): def _latest(d) -> float | None:
return d[max(d.keys())] if d else None return float(d[max(d.keys())]) if d else None
def compute_band_states(fof2_dict, muf_dict, luf_dict): def compute_band_states(fof2_dict, muf_dict, luf_dict):

View File

@@ -10,6 +10,7 @@ class SolarConditionsProvider:
def __init__(self, provider_config): def __init__(self, provider_config):
"""Constructor""" """Constructor"""
self._solar_conditions_cache = None
self.name = provider_config["name"] self.name = provider_config["name"]
self.enabled = provider_config["enabled"] self.enabled = provider_config["enabled"]
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC) self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)

View File

@@ -29,12 +29,13 @@ cleanup_timer = None
run = True run = True
def shutdown(sig, frame): 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()
for sp in spot_providers: for sp in spot_providers:
if sp.enabled: if sp.enabled:
@@ -45,7 +46,9 @@ def shutdown(sig, frame):
for scp in solar_condition_providers: for scp in solar_condition_providers:
if scp.enabled: if scp.enabled:
scp.stop() scp.stop()
if cleanup_timer:
cleanup_timer.stop() cleanup_timer.stop()
if lookup_helper:
lookup_helper.stop() lookup_helper.stop()
spots.close() spots.close()
alerts.close() alerts.close()

View File

@@ -37,20 +37,20 @@ class APRSIS(SpotProvider):
def _handle(self, data): def _handle(self, data):
# Split SSID in "from" call and store separately # Split SSID in "from" call and store separately
from_parts = data["from"].split("-").upper() from_parts = str(data["from"]).split("-")
dx_call = from_parts[0] dx_call = from_parts[0].upper()
dx_ssid = from_parts[1] if len(from_parts) > 1 else None dx_ssid = from_parts[1].upper() if len(from_parts) > 1 else None
via_parts = data["via"].split("-").upper() via_parts = str(data["via"]).split("-")
de_call = via_parts[0] de_call = via_parts[0].upper()
de_ssid = via_parts[1] if len(via_parts) > 1 else None de_ssid = via_parts[1].upper() if len(via_parts) > 1 else None
spot = Spot(source="APRS-IS", spot = Spot(source="APRS-IS",
dx_call=dx_call, dx_call=dx_call,
dx_ssid=dx_ssid, dx_ssid=dx_ssid,
de_call=de_call, de_call=de_call,
de_ssid=de_ssid, de_ssid=de_ssid,
comment=data["comment"] if "comment" in data else None, comment=str(data["comment"]) if "comment" in data else None,
dx_latitude=data["latitude"] if "latitude" in data else None, dx_latitude=float(data["latitude"]) if "latitude" in data else None,
dx_longitude=data["longitude"] if "longitude" in data else None, dx_longitude=float(data["longitude"]) if "longitude" in data else None,
time=datetime.now( time=datetime.now(
pytz.UTC).timestamp()) # APRS-IS spots are live so we can assume spot time is "now" pytz.UTC).timestamp()) # APRS-IS spots are live so we can assume spot time is "now"

View File

@@ -55,7 +55,7 @@ class GMA(HTTPSpotProvider):
# spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA # spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA
# and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry # and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry
# to determine if it's a SOTA summit. # to determine if it's a SOTA summit.
if "reftype" in ref_info and ref_info["reftype"] not in ["POTA", "WWFF"] and ( if spot.sig_refs and "reftype" in ref_info and ref_info["reftype"] not in ["POTA", "WWFF"] and (
ref_info["reftype"] != "Summit" or "sota" not in ref_info or ref_info["sota"] == ""): ref_info["reftype"] != "Summit" or "sota" not in ref_info or ref_info["sota"] == ""):
match ref_info["reftype"]: match ref_info["reftype"]:
case "Summit": case "Summit":

View File

@@ -45,6 +45,8 @@ class HEMA(HTTPSpotProvider):
# Fiddle with some data to extract bits we need. Freq/mode and spotter/comment come in combined fields. # Fiddle with some data to extract bits we need. Freq/mode and spotter/comment come in combined fields.
freq_mode_match = re.search(self.FREQ_MODE_PATTERN, spot_items[5]) freq_mode_match = re.search(self.FREQ_MODE_PATTERN, spot_items[5])
spotter_comment_match = re.search(self.SPOTTER_COMMENT_PATTERN, spot_items[6]) spotter_comment_match = re.search(self.SPOTTER_COMMENT_PATTERN, spot_items[6])
if not freq_mode_match or not spotter_comment_match:
continue
# Convert to our spot format # Convert to our spot format
spot = Spot(source=self.name, spot = Spot(source=self.name,

View File

@@ -22,8 +22,8 @@ class LLOTA(HTTPSpotProvider):
comment = None comment = None
spotter = None spotter = None
if "history" in source_spot and len(source_spot["history"]) > 0: if "history" in source_spot and len(source_spot["history"]) > 0:
comment = source_spot["history"][-1]["comment"] comment = str(source_spot["history"][-1]["comment"])
spotter = source_spot["history"][-1]["spotter_callsign"] spotter = str(source_spot["history"][-1]["spotter_callsign"])
# Convert to our spot format # Convert to our spot format
spot = Spot(source=self.name, spot = Spot(source=self.name,
source_id=source_spot["id"], source_id=source_spot["id"],

View File

@@ -39,9 +39,9 @@ class ParksNPeaks(HTTPSpotProvider):
tzinfo=pytz.UTC).timestamp()) tzinfo=pytz.UTC).timestamp())
# Extract a de_call if it's in the comment but not in the "actSpoter" field # Extract a de_call if it's in the comment but not in the "actSpoter" field
m = re.search(r"\(de ([A-Za-z0-9]*)\)", spot.comment) m = re.search(r"\(de ([A-Za-z0-9]*)\)", spot.comment or "")
if not spot.de_call and m: if not spot.de_call and m:
spot.de_call = m.group(1) spot.de_call = str(m.group(1))
# Record SIG information. Sometimes we get a "SIG" of "QRP", which we ignore as it's not a programme with a # Record SIG information. Sometimes we get a "SIG" of "QRP", which we ignore as it's not a programme with a
# defined set of references # defined set of references
@@ -49,11 +49,12 @@ class ParksNPeaks(HTTPSpotProvider):
sig_ref = source_spot["actSiteID"] sig_ref = source_spot["actSiteID"]
if sig and sig != "" and sig != "QRP" and sig_ref and sig_ref != "": if sig and sig != "" and sig != "QRP" and sig_ref and sig_ref != "":
spot.sig = sig spot.sig = sig
spot.sig_refs = [SIGRef(id=source_spot["actSiteID"], sig=source_spot["actClass"].upper())] sig_refs = [SIGRef(id=source_spot["actSiteID"], sig=source_spot["actClass"].upper())]
spot.sig_refs = sig_refs
# Free text location is not present in all spots, so only add it if it's set # Free text location is not present in all spots, so only add it if it's set
if "actLocation" in source_spot and source_spot["actLocation"] != "": if "actLocation" in source_spot and source_spot["actLocation"] != "":
spot.sig_refs[0].name = source_spot["actLocation"] sig_refs[0].name = source_spot["actLocation"]
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before # Log a warning for the developer if PnP gives us an unknown programme we've never seen before
if sig not in ["POTA", "SOTA", "WWFF", "SIOTA", "ZLOTA", "KRMNPA"]: if sig not in ["POTA", "SOTA", "WWFF", "SIOTA", "ZLOTA", "KRMNPA"]:

View File

@@ -46,7 +46,7 @@ class RBN(SpotProvider):
self.status = "Connecting" self.status = "Connecting"
logging.info("RBN port " + str(self._port) + " connecting...") logging.info("RBN port " + str(self._port) + " connecting...")
self._telnet = telnetlib3.Telnet("telnet.reversebeacon.net", self._port) self._telnet = telnetlib3.Telnet("telnet.reversebeacon.net", self._port)
telnet_output = self._telnet.read_until("Please enter your call: ".encode("latin-1")) self._telnet.read_until("Please enter your call: ".encode("latin-1"))
self._telnet.write((SERVER_OWNER_CALLSIGN + "\n").encode("latin-1")) self._telnet.write((SERVER_OWNER_CALLSIGN + "\n").encode("latin-1"))
connected = True connected = True
logging.info("RBN port " + str(self._port) + " connected.") logging.info("RBN port " + str(self._port) + " connected.")

View File

@@ -1,9 +1,11 @@
import logging import logging
import re import re
from datetime import datetime from datetime import datetime
from typing import cast
import pytz import pytz
from rss_parser import Parser from rss_parser import Parser
from rss_parser.models.rss import RSS
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
@@ -23,7 +25,7 @@ class WOTA(HTTPSpotProvider):
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response):
new_spots = [] new_spots = []
rss = Parser.parse(http_response.content.decode()) rss = cast(RSS, Parser.parse(http_response.content.decode()))
# Iterate through source data # Iterate through source data
for source_spot in rss.channel.items: for source_spot in rss.channel.items:
@@ -39,9 +41,9 @@ class WOTA(HTTPSpotProvider):
ref_name = None ref_name = None
if len(title_split) > 1: if len(title_split) > 1:
ref_split = title_split[1].split(" - ") ref_split = title_split[1].split(" - ")
ref = ref_split[0] ref = str(ref_split[0])
if len(ref_split) > 1: if len(ref_split) > 1:
ref_name = ref_split[1] ref_name = str(ref_split[1])
# Pick apart the description # Pick apart the description
desc_split = source_spot.description.split(". ") desc_split = source_spot.description.split(". ")

View File

@@ -22,8 +22,8 @@ class XOTA(WebsocketSpotProvider):
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, provider_config["url"]) super().__init__(provider_config, provider_config["url"])
locations_csv = provider_config["locations-csv"] if "locations-csv" in provider_config else None locations_csv = str(provider_config["locations-csv"]) if "locations-csv" in provider_config else None
self.SIG = provider_config["sig"] if "sig" in provider_config else None self.SIG = str(provider_config["sig"]) if "sig" in provider_config else None
# Load location data # Load location data
if locations_csv: if locations_csv:
@@ -48,7 +48,7 @@ class XOTA(WebsocketSpotProvider):
freq=float(source_spot["freq"]) * 1000, freq=float(source_spot["freq"]) * 1000,
mode=source_spot["mode"].upper(), mode=source_spot["mode"].upper(),
sig=self.SIG, sig=self.SIG,
sig_refs=[SIGRef(id=ref_id, sig=self.SIG, url=source_spot["reference"]["website"], latitude=lat, sig_refs=[SIGRef(id=ref_id, sig=self.SIG or "", url=source_spot["reference"]["website"], latitude=lat,
longitude=lon)], longitude=lon)],
time=datetime.now(pytz.UTC).timestamp(), time=datetime.now(pytz.UTC).timestamp(),
dx_latitude=lat, dx_latitude=lat,

View File

@@ -69,7 +69,7 @@
</div> </div>
<script src="/js/add-spot.js?v=1781893916"></script> <script src="/js/add-spot.js?v=1781901226"></script>
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -70,7 +70,7 @@
</div> </div>
<script src="/js/alerts.js?v=1781893916"></script> <script src="/js/alerts.js?v=1781901227"></script>
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -76,8 +76,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="/js/spotsbandsandmap.js?v=1781893916"></script> <script src="/js/spotsbandsandmap.js?v=1781901226"></script>
<script src="/js/bands.js?v=1781893916"></script> <script src="/js/bands.js?v=1781901226"></script>
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -1,6 +1,6 @@
{% extends "skeleton.html" %} {% extends "skeleton.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/css/style.css?v=1781893916" type="text/css"> <link rel="stylesheet" href="/css/style.css?v=1781901226" type="text/css">
<link href="/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet"> <link href="/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
<link href="/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet"> <link href="/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
<link href="/vendor/css/solid-6.7.2.min.css" rel="stylesheet"> <link href="/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
@@ -10,10 +10,10 @@
<script src="/vendor/js/bootstrap-5.3.8.bundle.min.js"></script> <script src="/vendor/js/bootstrap-5.3.8.bundle.min.js"></script>
<script src="/vendor/js/tinycolor2-1.6.0.min.js"></script> <script src="/vendor/js/tinycolor2-1.6.0.min.js"></script>
<script src="/js/utils.js?v=1781893916"></script> <script src="/js/utils.js?v=1781901226"></script>
<script src="/js/ui-ham.js?v=1781893916"></script> <script src="/js/ui-ham.js?v=1781901226"></script>
<script src="/js/geo.js?v=1781893916"></script> <script src="/js/geo.js?v=1781901226"></script>
<script src="/js/common.js?v=1781893916"></script> <script src="/js/common.js?v=1781901226"></script>
{% end %} {% end %}
{% block body %} {% block body %}
<div class="container"> <div class="container">

View File

@@ -284,7 +284,7 @@
</div> </div>
<script src="/vendor/js/chart-4.4.9.umd.min.js"></script> <script src="/vendor/js/chart-4.4.9.umd.min.js"></script>
<script src="/js/conditions.js?v=1781893916"></script> <script src="/js/conditions.js?v=1781901226"></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>

View File

@@ -94,8 +94,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="/js/spotsbandsandmap.js?v=1781893916"></script> <script src="/js/spotsbandsandmap.js?v=1781901227"></script>
<script src="/js/map.js?v=1781893916"></script> <script src="/js/map.js?v=1781901227"></script>
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -104,8 +104,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="/js/spotsbandsandmap.js?v=1781893916"></script> <script src="/js/spotsbandsandmap.js?v=1781901226"></script>
<script src="/js/spots.js?v=1781893916"></script> <script src="/js/spots.js?v=1781901226"></script>
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -59,7 +59,7 @@
</div> </div>
</div> </div>
<script src="/js/status.js?v=1781893916"></script> <script src="/js/status.js?v=1781901226"></script>
<script> <script>
$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --> $(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav -->
</script> </script>