mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-03-15 12:24:29 +00:00
Bulk convert comments above classes/functions/methods into proper docstrings
This commit is contained in:
@@ -5,11 +5,12 @@ import pytz
|
|||||||
from core.config import MAX_ALERT_AGE
|
from core.config import MAX_ALERT_AGE
|
||||||
|
|
||||||
|
|
||||||
# Generic alert provider class. Subclasses of this query the individual APIs for alerts.
|
|
||||||
class AlertProvider:
|
class AlertProvider:
|
||||||
|
"""Generic alert provider class. Subclasses of this query the individual APIs for alerts."""
|
||||||
|
|
||||||
# Constructor
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
|
"""Constructor"""
|
||||||
|
|
||||||
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)
|
||||||
@@ -17,19 +18,22 @@ class AlertProvider:
|
|||||||
self.alerts = None
|
self.alerts = None
|
||||||
self.web_server = None
|
self.web_server = None
|
||||||
|
|
||||||
# Set up the provider, e.g. giving it the alert list to work from
|
|
||||||
def setup(self, alerts, web_server):
|
def setup(self, alerts, web_server):
|
||||||
|
"""Set up the provider, e.g. giving it the alert list to work from"""
|
||||||
|
|
||||||
self.alerts = alerts
|
self.alerts = alerts
|
||||||
self.web_server = web_server
|
self.web_server = web_server
|
||||||
|
|
||||||
# Start the provider. This should return immediately after spawning threads to access the remote resources
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
|
||||||
|
|
||||||
raise NotImplementedError("Subclasses must implement this method")
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
|
|
||||||
# Submit a batch of alerts retrieved from the provider. There is no timestamp checking like there is for spots,
|
|
||||||
# because alerts could be created at any point for any time in the future. Rely on hashcode-based id matching
|
|
||||||
# to deal with duplicates.
|
|
||||||
def submit_batch(self, alerts):
|
def submit_batch(self, alerts):
|
||||||
|
"""Submit a batch of alerts retrieved from the provider. There is no timestamp checking like there is for spots,
|
||||||
|
because alerts could be created at any point for any time in the future. Rely on hashcode-based id matching
|
||||||
|
to deal with duplicates."""
|
||||||
|
|
||||||
# Sort the batch so that earliest ones go in first. This helps keep the ordering correct when alerts are fired
|
# Sort the batch so that earliest ones go in first. This helps keep the ordering correct when alerts are fired
|
||||||
# off to SSE listeners.
|
# off to SSE listeners.
|
||||||
alerts = sorted(alerts, key=lambda alert: (alert.start_time if alert and alert.start_time else 0))
|
alerts = sorted(alerts, key=lambda alert: (alert.start_time if alert and alert.start_time else 0))
|
||||||
@@ -45,6 +49,7 @@ class AlertProvider:
|
|||||||
if self.web_server:
|
if self.web_server:
|
||||||
self.web_server.notify_new_alert(alert)
|
self.web_server.notify_new_alert(alert)
|
||||||
|
|
||||||
# Stop any threads and prepare for application shutdown
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
"""Stop any threads and prepare for application shutdown"""
|
||||||
|
|
||||||
raise NotImplementedError("Subclasses must implement this method")
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
@@ -8,8 +8,9 @@ from data.alert import Alert
|
|||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
|
|
||||||
|
|
||||||
# Alert provider for Beaches on the Air
|
|
||||||
class BOTA(HTTPAlertProvider):
|
class BOTA(HTTPAlertProvider):
|
||||||
|
"""Alert provider for Beaches on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 1800
|
POLL_INTERVAL_SEC = 1800
|
||||||
ALERTS_URL = "https://www.beachesontheair.com/"
|
ALERTS_URL = "https://www.beachesontheair.com/"
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ from alertproviders.alert_provider import AlertProvider
|
|||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
|
|
||||||
|
|
||||||
# Generic alert provider class for providers that request data via HTTP(S). Just for convenience to avoid code
|
|
||||||
# duplication. Subclasses of this query the individual APIs for data.
|
|
||||||
class HTTPAlertProvider(AlertProvider):
|
class HTTPAlertProvider(AlertProvider):
|
||||||
|
"""Generic alert provider class for providers that request data via HTTP(S). Just for convenience to avoid code
|
||||||
|
duplication. Subclasses of this query the individual APIs for data."""
|
||||||
|
|
||||||
def __init__(self, provider_config, url, poll_interval):
|
def __init__(self, provider_config, url, poll_interval):
|
||||||
super().__init__(provider_config)
|
super().__init__(provider_config)
|
||||||
@@ -56,8 +56,9 @@ class HTTPAlertProvider(AlertProvider):
|
|||||||
# Brief pause on error before the next poll, but still respond promptly to stop()
|
# Brief pause on error before the next poll, but still respond promptly to stop()
|
||||||
self._stop_event.wait(timeout=1)
|
self._stop_event.wait(timeout=1)
|
||||||
|
|
||||||
# Convert an HTTP response returned by the API into alert data. The whole response is provided here so the subclass
|
|
||||||
# implementations can check for HTTP status codes if necessary, and handle the response as JSON, XML, text, whatever
|
|
||||||
# the API actually provides.
|
|
||||||
def http_response_to_alerts(self, http_response):
|
def http_response_to_alerts(self, http_response):
|
||||||
|
"""Convert an HTTP response returned by the API into alert data. The whole response is provided here so the subclass
|
||||||
|
implementations can check for HTTP status codes if necessary, and handle the response as JSON, XML, text, whatever
|
||||||
|
the API actually provides."""
|
||||||
|
|
||||||
raise NotImplementedError("Subclasses must implement this method")
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
@@ -8,8 +8,9 @@ from alertproviders.http_alert_provider import HTTPAlertProvider
|
|||||||
from data.alert import Alert
|
from data.alert import Alert
|
||||||
|
|
||||||
|
|
||||||
# Alert provider NG3K DXpedition list
|
|
||||||
class NG3K(HTTPAlertProvider):
|
class NG3K(HTTPAlertProvider):
|
||||||
|
"""Alert provider NG3K DXpedition list"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 1800
|
POLL_INTERVAL_SEC = 1800
|
||||||
ALERTS_URL = "https://www.ng3k.com/adxo.xml"
|
ALERTS_URL = "https://www.ng3k.com/adxo.xml"
|
||||||
AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE)
|
AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE)
|
||||||
@@ -48,7 +49,8 @@ class NG3K(HTTPAlertProvider):
|
|||||||
|
|
||||||
start_timestamp = datetime.strptime(start_year + " " + start_mon + " " + start_day, "%Y %b %d").replace(
|
start_timestamp = datetime.strptime(start_year + " " + start_mon + " " + start_day, "%Y %b %d").replace(
|
||||||
tzinfo=pytz.UTC).timestamp()
|
tzinfo=pytz.UTC).timestamp()
|
||||||
end_timestamp = datetime.strptime(end_year + " " + end_mon + " " + end_day + " 23:59", "%Y %b %d %H:%M").replace(
|
end_timestamp = datetime.strptime(end_year + " " + end_mon + " " + end_day + " 23:59",
|
||||||
|
"%Y %b %d %H:%M").replace(
|
||||||
tzinfo=pytz.UTC).timestamp()
|
tzinfo=pytz.UTC).timestamp()
|
||||||
|
|
||||||
# Sometimes the DX callsign is "real", sometimes you just get a prefix with the real working callsigns being
|
# Sometimes the DX callsign is "real", sometimes you just get a prefix with the real working callsigns being
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ from data.alert import Alert
|
|||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
|
|
||||||
|
|
||||||
# Alert provider for Parks n Peaks
|
|
||||||
class ParksNPeaks(HTTPAlertProvider):
|
class ParksNPeaks(HTTPAlertProvider):
|
||||||
|
"""Alert provider for Parks n Peaks"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 1800
|
POLL_INTERVAL_SEC = 1800
|
||||||
ALERTS_URL = "http://parksnpeaks.org/api/ALERTS/"
|
ALERTS_URL = "http://parksnpeaks.org/api/ALERTS/"
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ from data.alert import Alert
|
|||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
|
|
||||||
|
|
||||||
# Alert provider for Parks on the Air
|
|
||||||
class POTA(HTTPAlertProvider):
|
class POTA(HTTPAlertProvider):
|
||||||
|
"""Alert provider for Parks on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 1800
|
POLL_INTERVAL_SEC = 1800
|
||||||
ALERTS_URL = "https://api.pota.app/activation"
|
ALERTS_URL = "https://api.pota.app/activation"
|
||||||
|
|
||||||
@@ -25,7 +26,8 @@ class POTA(HTTPAlertProvider):
|
|||||||
dx_calls=[source_alert["activator"].upper()],
|
dx_calls=[source_alert["activator"].upper()],
|
||||||
freqs_modes=source_alert["frequencies"],
|
freqs_modes=source_alert["frequencies"],
|
||||||
comment=source_alert["comments"],
|
comment=source_alert["comments"],
|
||||||
sig_refs=[SIGRef(id=source_alert["reference"], sig="POTA", name=source_alert["name"], url="https://pota.app/#/park/" + source_alert["reference"])],
|
sig_refs=[SIGRef(id=source_alert["reference"], sig="POTA", name=source_alert["name"],
|
||||||
|
url="https://pota.app/#/park/" + source_alert["reference"])],
|
||||||
start_time=datetime.strptime(source_alert["startDate"] + source_alert["startTime"],
|
start_time=datetime.strptime(source_alert["startDate"] + source_alert["startTime"],
|
||||||
"%Y-%m-%d%H:%M").replace(tzinfo=pytz.UTC).timestamp(),
|
"%Y-%m-%d%H:%M").replace(tzinfo=pytz.UTC).timestamp(),
|
||||||
end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"],
|
end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"],
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ from data.alert import Alert
|
|||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
|
|
||||||
|
|
||||||
# Alert provider for Summits on the Air
|
|
||||||
class SOTA(HTTPAlertProvider):
|
class SOTA(HTTPAlertProvider):
|
||||||
|
"""Alert provider for Summits on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 1800
|
POLL_INTERVAL_SEC = 1800
|
||||||
ALERTS_URL = "https://api-db2.sota.org.uk/api/alerts/365/all/all"
|
ALERTS_URL = "https://api-db2.sota.org.uk/api/alerts/365/all/all"
|
||||||
|
|
||||||
@@ -31,7 +32,9 @@ class SOTA(HTTPAlertProvider):
|
|||||||
dx_names=[source_alert["activatorName"].upper()],
|
dx_names=[source_alert["activatorName"].upper()],
|
||||||
freqs_modes=source_alert["frequency"],
|
freqs_modes=source_alert["frequency"],
|
||||||
comment=source_alert["comments"],
|
comment=source_alert["comments"],
|
||||||
sig_refs=[SIGRef(id=source_alert["associationCode"] + "/" + source_alert["summitCode"], sig="SOTA", name=summit_name, activation_score=summit_points)],
|
sig_refs=[
|
||||||
|
SIGRef(id=source_alert["associationCode"] + "/" + source_alert["summitCode"], sig="SOTA",
|
||||||
|
name=summit_name, activation_score=summit_points)],
|
||||||
start_time=datetime.strptime(source_alert["dateActivated"],
|
start_time=datetime.strptime(source_alert["dateActivated"],
|
||||||
"%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=pytz.UTC).timestamp(),
|
"%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=pytz.UTC).timestamp(),
|
||||||
is_dxpedition=False)
|
is_dxpedition=False)
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ from data.alert import Alert
|
|||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
|
|
||||||
|
|
||||||
# Alert provider for Wainwrights on the Air
|
|
||||||
class WOTA(HTTPAlertProvider):
|
class WOTA(HTTPAlertProvider):
|
||||||
|
"""Alert provider for Wainwrights on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 1800
|
POLL_INTERVAL_SEC = 1800
|
||||||
ALERTS_URL = "https://www.wota.org.uk/alerts_rss.php"
|
ALERTS_URL = "https://www.wota.org.uk/alerts_rss.php"
|
||||||
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
|
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ from data.alert import Alert
|
|||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
|
|
||||||
|
|
||||||
# Alert provider for Worldwide Flora and Fauna
|
|
||||||
class WWFF(HTTPAlertProvider):
|
class WWFF(HTTPAlertProvider):
|
||||||
|
"""Alert provider for Worldwide Flora and Fauna"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 1800
|
POLL_INTERVAL_SEC = 1800
|
||||||
ALERTS_URL = "https://spots.wwff.co/static/agendas.json"
|
ALERTS_URL = "https://spots.wwff.co/static/agendas.json"
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ from time import sleep
|
|||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
|
|
||||||
# Provides a timed cleanup of the spot list.
|
|
||||||
class CleanupTimer:
|
class CleanupTimer:
|
||||||
|
"""Provides a timed cleanup of the spot list."""
|
||||||
|
|
||||||
# Constructor
|
|
||||||
def __init__(self, spots, alerts, web_server, cleanup_interval):
|
def __init__(self, spots, alerts, web_server, cleanup_interval):
|
||||||
|
"""Constructor"""
|
||||||
|
|
||||||
self.spots = spots
|
self.spots = spots
|
||||||
self.alerts = alerts
|
self.alerts = alerts
|
||||||
self.web_server = web_server
|
self.web_server = web_server
|
||||||
@@ -20,21 +21,24 @@ class CleanupTimer:
|
|||||||
self.status = "Starting"
|
self.status = "Starting"
|
||||||
self._stop_event = Event()
|
self._stop_event = Event()
|
||||||
|
|
||||||
# Start the cleanup timer
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
"""Start the cleanup timer"""
|
||||||
|
|
||||||
self._thread = Thread(target=self._run, daemon=True)
|
self._thread = Thread(target=self._run, daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
# Stop any threads and prepare for application shutdown
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
"""Stop any threads and prepare for application shutdown"""
|
||||||
|
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while not self._stop_event.wait(timeout=self.cleanup_interval):
|
while not self._stop_event.wait(timeout=self.cleanup_interval):
|
||||||
self._cleanup()
|
self._cleanup()
|
||||||
|
|
||||||
# Perform cleanup and reschedule next timer
|
|
||||||
def _cleanup(self):
|
def _cleanup(self):
|
||||||
|
"""Perform cleanup and reschedule next timer"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Perform cleanup via letting the data expire
|
# Perform cleanup via letting the data expire
|
||||||
self.spots.expire()
|
self.spots.expire()
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ for idx in cq_zone_data.index:
|
|||||||
for idx in itu_zone_data.index:
|
for idx in itu_zone_data.index:
|
||||||
prepare(itu_zone_data.at[idx, 'geometry'])
|
prepare(itu_zone_data.at[idx, 'geometry'])
|
||||||
|
|
||||||
# Finds out which CQ zone a lat/lon point is in.
|
|
||||||
def lat_lon_to_cq_zone(lat, lon):
|
def lat_lon_to_cq_zone(lat, lon):
|
||||||
|
"""Finds out which CQ zone a lat/lon point is in."""
|
||||||
|
|
||||||
lon = ((lon + 180) % 360) - 180
|
lon = ((lon + 180) % 360) - 180
|
||||||
for index, row in cq_zone_data.iterrows():
|
for index, row in cq_zone_data.iterrows():
|
||||||
polygon = Polygon(row["geometry"])
|
polygon = Polygon(row["geometry"])
|
||||||
@@ -38,8 +40,9 @@ def lat_lon_to_cq_zone(lat, lon):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Finds out which ITU zone a lat/lon point is in.
|
|
||||||
def lat_lon_to_itu_zone(lat, lon):
|
def lat_lon_to_itu_zone(lat, lon):
|
||||||
|
"""Finds out which ITU zone a lat/lon point is in."""
|
||||||
|
|
||||||
lon = ((lon + 180) % 360) - 180
|
lon = ((lon + 180) % 360) - 180
|
||||||
for index, row in itu_zone_data.iterrows():
|
for index, row in itu_zone_data.iterrows():
|
||||||
polygon = Polygon(row["geometry"])
|
polygon = Polygon(row["geometry"])
|
||||||
@@ -58,9 +61,10 @@ def lat_lon_to_itu_zone(lat, lon):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Convert a Maidenhead grid reference of arbitrary precision to the lat/long of the centre point of the square.
|
|
||||||
# Returns None if the grid format is invalid.
|
|
||||||
def lat_lon_for_grid_centre(grid):
|
def lat_lon_for_grid_centre(grid):
|
||||||
|
"""Convert a Maidenhead grid reference of arbitrary precision to the lat/long of the centre point of the square.
|
||||||
|
Returns None if the grid format is invalid."""
|
||||||
|
|
||||||
lat, lon, lat_cell_size, lon_cell_size = lat_lon_for_grid_sw_corner_plus_size(grid)
|
lat, lon, lat_cell_size, lon_cell_size = lat_lon_for_grid_sw_corner_plus_size(grid)
|
||||||
if lat is not None and lon is not None 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:
|
||||||
return [lat + lat_cell_size / 2.0, lon + lon_cell_size / 2.0]
|
return [lat + lat_cell_size / 2.0, lon + lon_cell_size / 2.0]
|
||||||
@@ -68,18 +72,21 @@ def lat_lon_for_grid_centre(grid):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Convert a Maidenhead grid reference of arbitrary precision to the lat/long of the southwest corner of the square.
|
|
||||||
# Returns None if the grid format is invalid.
|
|
||||||
def lat_lon_for_grid_sw_corner(grid):
|
def lat_lon_for_grid_sw_corner(grid):
|
||||||
|
"""Convert a Maidenhead grid reference of arbitrary precision to the lat/long of the southwest corner of the square.
|
||||||
|
Returns None if the grid format is invalid."""
|
||||||
|
|
||||||
lat, lon, lat_cell_size, lon_cell_size = lat_lon_for_grid_sw_corner_plus_size(grid)
|
lat, lon, lat_cell_size, lon_cell_size = lat_lon_for_grid_sw_corner_plus_size(grid)
|
||||||
if lat is not None and lon is not None:
|
if lat is not None and lon is not None:
|
||||||
return [lat, lon]
|
return [lat, lon]
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Convert a Maidenhead grid reference of arbitrary precision to the lat/long of the northeast corner of the square.
|
|
||||||
# Returns None if the grid format is invalid.
|
|
||||||
def lat_lon_for_grid_ne_corner(grid):
|
def lat_lon_for_grid_ne_corner(grid):
|
||||||
|
"""Convert a Maidenhead grid reference of arbitrary precision to the lat/long of the northeast corner of the square.
|
||||||
|
Returns None if the grid format is invalid."""
|
||||||
|
|
||||||
lat, lon, lat_cell_size, lon_cell_size = lat_lon_for_grid_sw_corner_plus_size(grid)
|
lat, lon, lat_cell_size, lon_cell_size = lat_lon_for_grid_sw_corner_plus_size(grid)
|
||||||
if lat is not None and lon is not None 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:
|
||||||
return [lat + lat_cell_size, lon + lon_cell_size]
|
return [lat + lat_cell_size, lon + lon_cell_size]
|
||||||
@@ -87,11 +94,12 @@ def lat_lon_for_grid_ne_corner(grid):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Convert a Maidenhead grid reference of arbitrary precision to lat/long, including in the result the size of the
|
|
||||||
# lowest grid square. This is a utility method used by the main methods that return the centre, southwest, and
|
|
||||||
# northeast coordinates of a grid square.
|
|
||||||
# The return type is always a tuple of size 4. The elements in it are None if the grid format is invalid.
|
|
||||||
def lat_lon_for_grid_sw_corner_plus_size(grid):
|
def lat_lon_for_grid_sw_corner_plus_size(grid):
|
||||||
|
"""Convert a Maidenhead grid reference of arbitrary precision to lat/long, including in the result the size of the
|
||||||
|
lowest grid square. This is a utility method used by the main methods that return the centre, southwest, and
|
||||||
|
northeast coordinates of a grid square.
|
||||||
|
The return type is always a tuple of size 4. The elements in it are None if the grid format is invalid."""
|
||||||
|
|
||||||
# Make sure we are in upper case so our maths works. Case is arbitrary for Maidenhead references
|
# Make sure we are in upper case so our maths works. Case is arbitrary for Maidenhead references
|
||||||
grid = grid.upper()
|
grid = grid.upper()
|
||||||
|
|
||||||
@@ -157,8 +165,9 @@ def lat_lon_for_grid_sw_corner_plus_size(grid):
|
|||||||
return lat, lon, lat_cell_size, lon_cell_size
|
return lat, lon, lat_cell_size, lon_cell_size
|
||||||
|
|
||||||
|
|
||||||
# Convert a Worked All Britain or Worked All Ireland reference to a lat/lon point.
|
|
||||||
def wab_wai_square_to_lat_lon(ref):
|
def wab_wai_square_to_lat_lon(ref):
|
||||||
|
"""Convert a Worked All Britain or Worked All Ireland reference to a lat/lon point."""
|
||||||
|
|
||||||
# First check we have a valid grid square, and based on what it looks like, use either the Ordnance Survey, Irish,
|
# First check we have a valid grid square, and based on what it looks like, use either the Ordnance Survey, Irish,
|
||||||
# or UTM grid systems to perform the conversion.
|
# or UTM grid systems to perform the conversion.
|
||||||
if re.match(r"^[HNOST][ABCDEFGHJKLMNOPQRSTUVWXYZ][0-9]{2}$", ref):
|
if re.match(r"^[HNOST][ABCDEFGHJKLMNOPQRSTUVWXYZ][0-9]{2}$", ref):
|
||||||
@@ -172,8 +181,9 @@ def wab_wai_square_to_lat_lon(ref):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Get a lat/lon point for the centre of an Ordnance Survey grid square
|
|
||||||
def os_grid_square_to_lat_lon(ref):
|
def os_grid_square_to_lat_lon(ref):
|
||||||
|
"""Get a lat/lon point for the centre of an Ordnance Survey grid square"""
|
||||||
|
|
||||||
# Convert the letters into multipliers for the 500km squares and 100km squares
|
# Convert the letters into multipliers for the 500km squares and 100km squares
|
||||||
offset_500km_multiplier = ord(ref[0]) - 65
|
offset_500km_multiplier = ord(ref[0]) - 65
|
||||||
offset_100km_multiplier = ord(ref[1]) - 65
|
offset_100km_multiplier = ord(ref[1]) - 65
|
||||||
@@ -202,8 +212,9 @@ def os_grid_square_to_lat_lon(ref):
|
|||||||
return lat, lon
|
return lat, lon
|
||||||
|
|
||||||
|
|
||||||
# Get a lat/lon point for the centre of an Irish Grid square.
|
|
||||||
def irish_grid_square_to_lat_lon(ref):
|
def irish_grid_square_to_lat_lon(ref):
|
||||||
|
"""Get a lat/lon point for the centre of an Irish Grid square."""
|
||||||
|
|
||||||
# Convert the letters into multipliers for the 100km squares
|
# Convert the letters into multipliers for the 100km squares
|
||||||
offset_100km_multiplier = ord(ref[0]) - 65
|
offset_100km_multiplier = ord(ref[0]) - 65
|
||||||
|
|
||||||
@@ -229,8 +240,9 @@ def irish_grid_square_to_lat_lon(ref):
|
|||||||
return lat, lon
|
return lat, lon
|
||||||
|
|
||||||
|
|
||||||
# Get a lat/lon point for the centre of a UTM grid square (supports only squares WA & WV for the Channel Islands, nothing else implemented)
|
|
||||||
def utm_grid_square_to_lat_lon(ref):
|
def utm_grid_square_to_lat_lon(ref):
|
||||||
|
"""Get a lat/lon point for the centre of a UTM grid square (supports only squares WA & WV for the Channel Islands, nothing else implemented)"""
|
||||||
|
|
||||||
# Take the numeric parts of the grid square and multiply by 10000 to get metres from the corner of the letter-based grid square
|
# Take the numeric parts of the grid square and multiply by 10000 to get metres from the corner of the letter-based grid square
|
||||||
easting = int(ref[2]) * 10000
|
easting = int(ref[2]) * 10000
|
||||||
northing = int(ref[3]) * 10000
|
northing = int(ref[3]) * 10000
|
||||||
|
|||||||
@@ -19,13 +19,14 @@ from core.constants import BANDS, UNKNOWN_BAND, CW_MODES, PHONE_MODES, DATA_MODE
|
|||||||
HTTP_HEADERS, HAMQTH_PRG, MODE_ALIASES
|
HTTP_HEADERS, HAMQTH_PRG, MODE_ALIASES
|
||||||
|
|
||||||
|
|
||||||
# Singleton class that provides lookup functionality.
|
|
||||||
class LookupHelper:
|
class LookupHelper:
|
||||||
|
"""Singleton class that provides lookup functionality."""
|
||||||
|
|
||||||
# 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.
|
|
||||||
def __init__(self):
|
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.CLUBLOG_CALLSIGN_DATA_CACHE = None
|
||||||
self.LOOKUP_LIB_CLUBLOG_XML = None
|
self.LOOKUP_LIB_CLUBLOG_XML = None
|
||||||
self.CLUBLOG_XML_AVAILABLE = None
|
self.CLUBLOG_XML_AVAILABLE = None
|
||||||
@@ -105,11 +106,12 @@ class LookupHelper:
|
|||||||
for dxcc in self.DXCC_DATA.values():
|
for dxcc in self.DXCC_DATA.values():
|
||||||
dxcc["_prefixRegexCompiled"] = re.compile(dxcc["prefixRegex"])
|
dxcc["_prefixRegexCompiled"] = re.compile(dxcc["prefixRegex"])
|
||||||
|
|
||||||
# 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.
|
|
||||||
def download_country_files_cty_plist(self):
|
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:
|
try:
|
||||||
logging.info("Downloading Country-files.com cty.plist...")
|
logging.info("Downloading Country-files.com cty.plist...")
|
||||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.country-files.com/cty/cty.plist",
|
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.country-files.com/cty/cty.plist",
|
||||||
@@ -124,11 +126,13 @@ class LookupHelper:
|
|||||||
logging.error("Exception when downloading Clublog cty.xml", e)
|
logging.error("Exception when downloading Clublog cty.xml", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Download the dxcc.json file on first startup.
|
|
||||||
def download_dxcc_json(self):
|
def download_dxcc_json(self):
|
||||||
|
"""Download the dxcc.json file on first startup."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logging.info("Downloading dxcc.json...")
|
logging.info("Downloading dxcc.json...")
|
||||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://raw.githubusercontent.com/k0swe/dxcc-json/refs/heads/main/dxcc.json",
|
response = SEMI_STATIC_URL_DATA_CACHE.get(
|
||||||
|
"https://raw.githubusercontent.com/k0swe/dxcc-json/refs/heads/main/dxcc.json",
|
||||||
headers=HTTP_HEADERS).text
|
headers=HTTP_HEADERS).text
|
||||||
|
|
||||||
with open(self.DXCC_JSON_DOWNLOAD_LOCATION, "w") as f:
|
with open(self.DXCC_JSON_DOWNLOAD_LOCATION, "w") as f:
|
||||||
@@ -140,9 +144,10 @@ class LookupHelper:
|
|||||||
logging.error("Exception when downloading dxcc.json", e)
|
logging.error("Exception when downloading dxcc.json", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 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.
|
|
||||||
def download_clublog_ctyxml(self):
|
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:
|
try:
|
||||||
logging.info("Downloading Clublog cty.xml.gz...")
|
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,
|
response = self.CLUBLOG_CTY_XML_CACHE.get("https://cdn.clublog.org/cty.php?api=" + self.CLUBLOG_API_KEY,
|
||||||
@@ -161,8 +166,9 @@ class LookupHelper:
|
|||||||
logging.error("Exception when downloading Clublog cty.xml", e)
|
logging.error("Exception when downloading Clublog cty.xml", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Infer a mode from the comment
|
|
||||||
def infer_mode_from_comment(self, comment):
|
def infer_mode_from_comment(self, comment):
|
||||||
|
"""Infer a mode from the comment"""
|
||||||
|
|
||||||
for mode in ALL_MODES:
|
for mode in ALL_MODES:
|
||||||
if mode in comment.upper():
|
if mode in comment.upper():
|
||||||
return mode
|
return mode
|
||||||
@@ -171,8 +177,9 @@ class LookupHelper:
|
|||||||
return MODE_ALIASES[mode]
|
return MODE_ALIASES[mode]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Infer a "mode family" from a mode.
|
|
||||||
def infer_mode_type_from_mode(self, mode):
|
def infer_mode_type_from_mode(self, mode):
|
||||||
|
"""Infer a "mode family" from a mode."""
|
||||||
|
|
||||||
if mode.upper() in CW_MODES:
|
if mode.upper() in CW_MODES:
|
||||||
return "CW"
|
return "CW"
|
||||||
elif mode.upper() in PHONE_MODES:
|
elif mode.upper() in PHONE_MODES:
|
||||||
@@ -184,15 +191,17 @@ class LookupHelper:
|
|||||||
logging.warn("Found an unrecognised mode: " + mode + ". Developer should categorise this.")
|
logging.warn("Found an unrecognised mode: " + mode + ". Developer should categorise this.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Infer a band from a frequency in Hz
|
|
||||||
def infer_band_from_freq(self, freq):
|
def infer_band_from_freq(self, freq):
|
||||||
|
"""Infer a band from a frequency in Hz"""
|
||||||
|
|
||||||
for b in BANDS:
|
for b in BANDS:
|
||||||
if b.start_freq <= freq <= b.end_freq:
|
if b.start_freq <= freq <= b.end_freq:
|
||||||
return b
|
return b
|
||||||
return UNKNOWN_BAND
|
return UNKNOWN_BAND
|
||||||
|
|
||||||
# Infer a country name from a callsign
|
|
||||||
def infer_country_from_callsign(self, call):
|
def infer_country_from_callsign(self, call):
|
||||||
|
"""Infer a country name from a callsign"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start with the basic country-files.com-based decoder.
|
# Start with the basic country-files.com-based decoder.
|
||||||
country = self.CALL_INFO_BASIC.get_country_name(call)
|
country = self.CALL_INFO_BASIC.get_country_name(call)
|
||||||
@@ -224,8 +233,9 @@ class LookupHelper:
|
|||||||
country = dxcc_data["name"]
|
country = dxcc_data["name"]
|
||||||
return country
|
return country
|
||||||
|
|
||||||
# Infer a DXCC ID from a callsign
|
|
||||||
def infer_dxcc_id_from_callsign(self, call):
|
def infer_dxcc_id_from_callsign(self, call):
|
||||||
|
"""Infer a DXCC ID from a callsign"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start with the basic country-files.com-based decoder.
|
# Start with the basic country-files.com-based decoder.
|
||||||
dxcc = self.CALL_INFO_BASIC.get_adif_id(call)
|
dxcc = self.CALL_INFO_BASIC.get_adif_id(call)
|
||||||
@@ -257,8 +267,9 @@ class LookupHelper:
|
|||||||
dxcc = dxcc_data["entityCode"]
|
dxcc = dxcc_data["entityCode"]
|
||||||
return dxcc
|
return dxcc
|
||||||
|
|
||||||
# Infer a continent shortcode from a callsign
|
|
||||||
def infer_continent_from_callsign(self, call):
|
def infer_continent_from_callsign(self, call):
|
||||||
|
"""Infer a continent shortcode from a callsign"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start with the basic country-files.com-based decoder.
|
# Start with the basic country-files.com-based decoder.
|
||||||
continent = self.CALL_INFO_BASIC.get_continent(call)
|
continent = self.CALL_INFO_BASIC.get_continent(call)
|
||||||
@@ -286,8 +297,9 @@ class LookupHelper:
|
|||||||
continent = dxcc_data["continent"][0]
|
continent = dxcc_data["continent"][0]
|
||||||
return continent
|
return continent
|
||||||
|
|
||||||
# Infer a CQ zone from a callsign
|
|
||||||
def infer_cq_zone_from_callsign(self, call):
|
def infer_cq_zone_from_callsign(self, call):
|
||||||
|
"""Infer a CQ zone from a callsign"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start with the basic country-files.com-based decoder.
|
# Start with the basic country-files.com-based decoder.
|
||||||
cqz = self.CALL_INFO_BASIC.get_cqz(call)
|
cqz = self.CALL_INFO_BASIC.get_cqz(call)
|
||||||
@@ -320,8 +332,9 @@ class LookupHelper:
|
|||||||
cqz = dxcc_data["cq"][0]
|
cqz = dxcc_data["cq"][0]
|
||||||
return cqz
|
return cqz
|
||||||
|
|
||||||
# Infer a ITU zone from a callsign
|
|
||||||
def infer_itu_zone_from_callsign(self, call):
|
def infer_itu_zone_from_callsign(self, call):
|
||||||
|
"""Infer a ITU zone from a callsign"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start with the basic country-files.com-based decoder.
|
# Start with the basic country-files.com-based decoder.
|
||||||
ituz = self.CALL_INFO_BASIC.get_ituz(call)
|
ituz = self.CALL_INFO_BASIC.get_ituz(call)
|
||||||
@@ -345,12 +358,14 @@ class LookupHelper:
|
|||||||
ituz = dxcc_data["itu"]
|
ituz = dxcc_data["itu"]
|
||||||
return ituz
|
return ituz
|
||||||
|
|
||||||
# Get an emoji flag for a given DXCC entity ID
|
|
||||||
def get_flag_for_dxcc(self, dxcc):
|
def get_flag_for_dxcc(self, dxcc):
|
||||||
|
"""Get an emoji flag for a given DXCC entity ID"""
|
||||||
|
|
||||||
return self.DXCC_DATA[dxcc]["flag"] if dxcc in self.DXCC_DATA else None
|
return self.DXCC_DATA[dxcc]["flag"] if dxcc in self.DXCC_DATA else None
|
||||||
|
|
||||||
# Infer an operator name from a callsign (requires QRZ.com/HamQTH)
|
|
||||||
def infer_name_from_callsign_online_lookup(self, call):
|
def infer_name_from_callsign_online_lookup(self, call):
|
||||||
|
"""Infer an operator name from a callsign (requires QRZ.com/HamQTH)"""
|
||||||
|
|
||||||
data = self.get_qrz_data_for_callsign(call)
|
data = self.get_qrz_data_for_callsign(call)
|
||||||
if data and "fname" in data:
|
if data and "fname" in data:
|
||||||
name = data["fname"]
|
name = data["fname"]
|
||||||
@@ -363,32 +378,41 @@ class LookupHelper:
|
|||||||
else:
|
else:
|
||||||
return None
|
return 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)
|
|
||||||
def infer_latlon_from_callsign_online_lookup(self, call):
|
def infer_latlon_from_callsign_online_lookup(self, call):
|
||||||
|
"""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)
|
data = self.get_qrz_data_for_callsign(call)
|
||||||
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:
|
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"])]
|
return [float(data["latitude"]), float(data["longitude"])]
|
||||||
data = self.get_hamqth_data_for_callsign(call)
|
data = self.get_hamqth_data_for_callsign(call)
|
||||||
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:
|
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"])]
|
return [float(data["latitude"]), float(data["longitude"])]
|
||||||
else:
|
else:
|
||||||
return None
|
return 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)
|
|
||||||
def infer_grid_from_callsign_online_lookup(self, call):
|
def infer_grid_from_callsign_online_lookup(self, call):
|
||||||
|
"""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)
|
data = self.get_qrz_data_for_callsign(call)
|
||||||
if data and "locator" in data and data["locator"].upper() != "AA00" and data["locator"].upper() != "AA00AA" and data["locator"].upper() != "AA00AA00":
|
if data and "locator" in data and data["locator"].upper() != "AA00" and data["locator"].upper() != "AA00AA" and \
|
||||||
|
data["locator"].upper() != "AA00AA00":
|
||||||
return data["locator"]
|
return data["locator"]
|
||||||
data = self.get_hamqth_data_for_callsign(call)
|
data = self.get_hamqth_data_for_callsign(call)
|
||||||
if data and "grid" in data and data["grid"].upper() != "AA00" and data["grid"].upper() != "AA00AA" and data["grid"].upper() != "AA00AA00":
|
if data and "grid" in data and data["grid"].upper() != "AA00" and data["grid"].upper() != "AA00AA" and data[
|
||||||
|
"grid"].upper() != "AA00AA00":
|
||||||
return data["grid"]
|
return data["grid"]
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Infer a textual QTH from a callsign (requires QRZ.com/HamQTH)
|
|
||||||
def infer_qth_from_callsign_online_lookup(self, call):
|
def infer_qth_from_callsign_online_lookup(self, call):
|
||||||
|
"""Infer a textual QTH from a callsign (requires QRZ.com/HamQTH)"""
|
||||||
|
|
||||||
data = self.get_qrz_data_for_callsign(call)
|
data = self.get_qrz_data_for_callsign(call)
|
||||||
if data and "addr2" in data:
|
if data and "addr2" in data:
|
||||||
return data["addr2"]
|
return data["addr2"]
|
||||||
@@ -398,8 +422,9 @@ class LookupHelper:
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Infer a latitude and longitude from a callsign (using DXCC, probably very inaccurate)
|
|
||||||
def infer_latlon_from_callsign_dxcc(self, call):
|
def infer_latlon_from_callsign_dxcc(self, call):
|
||||||
|
"""Infer a latitude and longitude from a callsign (using DXCC, probably very inaccurate)"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = self.CALL_INFO_BASIC.get_lat_long(call)
|
data = self.CALL_INFO_BASIC.get_lat_long(call)
|
||||||
if data and "latitude" in data and "longitude" in data:
|
if data and "latitude" in data and "longitude" in data:
|
||||||
@@ -419,8 +444,9 @@ class LookupHelper:
|
|||||||
loc = [float(data["Lat"]), float(data["Lon"])]
|
loc = [float(data["Lat"]), float(data["Lon"])]
|
||||||
return loc
|
return loc
|
||||||
|
|
||||||
# Infer a grid locator from a callsign (using DXCC, probably very inaccurate)
|
|
||||||
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)"""
|
||||||
|
|
||||||
latlon = self.infer_latlon_from_callsign_dxcc(call)
|
latlon = self.infer_latlon_from_callsign_dxcc(call)
|
||||||
grid = None
|
grid = None
|
||||||
try:
|
try:
|
||||||
@@ -429,8 +455,9 @@ class LookupHelper:
|
|||||||
logging.debug("Invalid lat/lon received for DXCC")
|
logging.debug("Invalid lat/lon received for DXCC")
|
||||||
return grid
|
return grid
|
||||||
|
|
||||||
# Infer a mode from the frequency (in Hz) according to the band plan. Just a guess really.
|
|
||||||
def infer_mode_from_frequency(self, freq):
|
def infer_mode_from_frequency(self, freq):
|
||||||
|
"""Infer a mode from the frequency (in Hz) according to the band plan. Just a guess really."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
khz = freq / 1000.0
|
khz = freq / 1000.0
|
||||||
mode = freq_to_band(khz)["mode"]
|
mode = freq_to_band(khz)["mode"]
|
||||||
@@ -449,8 +476,9 @@ class LookupHelper:
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Utility method to get QRZ.com data from cache if possible, if not get it from the API and cache it
|
|
||||||
def get_qrz_data_for_callsign(self, call):
|
def get_qrz_data_for_callsign(self, call):
|
||||||
|
"""Utility method to get QRZ.com 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
|
||||||
if call in self.QRZ_CALLSIGN_DATA_CACHE:
|
if call in self.QRZ_CALLSIGN_DATA_CACHE:
|
||||||
return self.QRZ_CALLSIGN_DATA_CACHE.get(call)
|
return self.QRZ_CALLSIGN_DATA_CACHE.get(call)
|
||||||
@@ -477,8 +505,9 @@ class LookupHelper:
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Utility method to get HamQTH data from cache if possible, if not get it from the API and cache it
|
|
||||||
def get_hamqth_data_for_callsign(self, call):
|
def get_hamqth_data_for_callsign(self, call):
|
||||||
|
"""Utility method to get HamQTH 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
|
||||||
if call in self.HAMQTH_CALLSIGN_DATA_CACHE:
|
if call in self.HAMQTH_CALLSIGN_DATA_CACHE:
|
||||||
return self.HAMQTH_CALLSIGN_DATA_CACHE.get(call)
|
return self.HAMQTH_CALLSIGN_DATA_CACHE.get(call)
|
||||||
@@ -505,7 +534,8 @@ class LookupHelper:
|
|||||||
try:
|
try:
|
||||||
lookup_data = SEMI_STATIC_URL_DATA_CACHE.get(
|
lookup_data = SEMI_STATIC_URL_DATA_CACHE.get(
|
||||||
self.HAMQTH_BASE_URL + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
|
self.HAMQTH_BASE_URL + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
|
||||||
callinfo.Callinfo.get_homecall(call)) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS).content
|
callinfo.Callinfo.get_homecall(call)) + "&prg=" + HAMQTH_PRG,
|
||||||
|
headers=HTTP_HEADERS).content
|
||||||
data = xmltodict.parse(lookup_data)["HamQTH"]["search"]
|
data = xmltodict.parse(lookup_data)["HamQTH"]["search"]
|
||||||
self.HAMQTH_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
|
self.HAMQTH_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
|
||||||
return data
|
return data
|
||||||
@@ -520,8 +550,9 @@ class LookupHelper:
|
|||||||
logging.error("Exception when looking up HamQTH data")
|
logging.error("Exception when looking up HamQTH data")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Utility method to get Clublog API data from cache if possible, if not get it from the API and cache it
|
|
||||||
def get_clublog_api_data_for_callsign(self, call):
|
def get_clublog_api_data_for_callsign(self, call):
|
||||||
|
"""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
|
||||||
if call in self.CLUBLOG_CALLSIGN_DATA_CACHE:
|
if call in self.CLUBLOG_CALLSIGN_DATA_CACHE:
|
||||||
return self.CLUBLOG_CALLSIGN_DATA_CACHE.get(call)
|
return self.CLUBLOG_CALLSIGN_DATA_CACHE.get(call)
|
||||||
@@ -547,8 +578,9 @@ class LookupHelper:
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Utility method to get Clublog XML data from file
|
|
||||||
def get_clublog_xml_data_for_callsign(self, call):
|
def get_clublog_xml_data_for_callsign(self, call):
|
||||||
|
"""Utility method to get Clublog XML data from file"""
|
||||||
|
|
||||||
if self.CLUBLOG_XML_AVAILABLE:
|
if self.CLUBLOG_XML_AVAILABLE:
|
||||||
try:
|
try:
|
||||||
data = self.LOOKUP_LIB_CLUBLOG_XML.lookup_callsign(callsign=call)
|
data = self.LOOKUP_LIB_CLUBLOG_XML.lookup_callsign(callsign=call)
|
||||||
@@ -560,15 +592,17 @@ class LookupHelper:
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Utility method to get generic DXCC data from our lookup table, if we can find it
|
|
||||||
def get_dxcc_data_for_callsign(self, call):
|
def get_dxcc_data_for_callsign(self, call):
|
||||||
|
"""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():
|
||||||
if entry["_prefixRegexCompiled"].match(call):
|
if entry["_prefixRegexCompiled"].match(call):
|
||||||
return entry
|
return entry
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Shutdown method to close down any caches neatly.
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
"""Shutdown method to close down any caches neatly."""
|
||||||
|
|
||||||
self.QRZ_CALLSIGN_DATA_CACHE.close()
|
self.QRZ_CALLSIGN_DATA_CACHE.close()
|
||||||
self.CLUBLOG_CALLSIGN_DATA_CACHE.close()
|
self.CLUBLOG_CALLSIGN_DATA_CACHE.close()
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ memory_use_gauge = Gauge(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Get a Prometheus metrics response for the web server
|
|
||||||
def get_metrics():
|
def get_metrics():
|
||||||
|
"""Get a Prometheus metrics response for the web server"""
|
||||||
|
|
||||||
return generate_latest(registry)
|
return generate_latest(registry)
|
||||||
|
|||||||
@@ -8,18 +8,20 @@ from core.constants import SIGS, HTTP_HEADERS
|
|||||||
from core.geo_utils import wab_wai_square_to_lat_lon
|
from core.geo_utils import wab_wai_square_to_lat_lon
|
||||||
|
|
||||||
|
|
||||||
# Utility function to get the regex string for a SIG reference for a named SIG. If no match is found, None will be returned.
|
|
||||||
def get_ref_regex_for_sig(sig):
|
def get_ref_regex_for_sig(sig):
|
||||||
|
"""Utility function to get the regex string for a SIG reference for a named SIG. If no match is found, None will be returned."""
|
||||||
|
|
||||||
for s in SIGS:
|
for s in SIGS:
|
||||||
if s.name.upper() == sig.upper():
|
if s.name.upper() == sig.upper():
|
||||||
return s.ref_regex
|
return s.ref_regex
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
# Note there is currently no support for KRMNPA location lookup, see issue #61.
|
|
||||||
def populate_sig_ref_info(sig_ref):
|
def populate_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.
|
||||||
|
Note there is currently no support for KRMNPA location lookup, see issue #61."""
|
||||||
|
|
||||||
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.")
|
||||||
|
|
||||||
@@ -75,7 +77,8 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
sig_ref.url = "https://wwff.co/directory/?showRef=" + ref_id
|
sig_ref.url = "https://wwff.co/directory/?showRef=" + ref_id
|
||||||
sig_ref.grid = row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None
|
sig_ref.grid = row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None
|
||||||
sig_ref.latitude = float(row["latitude"]) if "latitude" in row and row["latitude"] != "-" else None
|
sig_ref.latitude = float(row["latitude"]) if "latitude" in row and row["latitude"] != "-" else None
|
||||||
sig_ref.longitude = float(row["longitude"]) if "longitude" in row and row["longitude"] != "-" else None
|
sig_ref.longitude = float(row["longitude"]) if "longitude" in row and row[
|
||||||
|
"longitude"] != "-" else None
|
||||||
break
|
break
|
||||||
elif sig.upper() == "SIOTA":
|
elif sig.upper() == "SIOTA":
|
||||||
siota_csv_data = SEMI_STATIC_URL_DATA_CACHE.get("https://www.silosontheair.com/data/silos.csv",
|
siota_csv_data = SEMI_STATIC_URL_DATA_CACHE.get("https://www.silosontheair.com/data/silos.csv",
|
||||||
@@ -124,7 +127,8 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
sig_ref.name = sig_ref.id
|
sig_ref.name = sig_ref.id
|
||||||
sig_ref.url = "https://www.beachesontheair.com/beaches/" + sig_ref.name.lower().replace(" ", "-")
|
sig_ref.url = "https://www.beachesontheair.com/beaches/" + sig_ref.name.lower().replace(" ", "-")
|
||||||
elif sig.upper() == "LLOTA":
|
elif sig.upper() == "LLOTA":
|
||||||
data = SEMI_STATIC_URL_DATA_CACHE.get("https://llota.app/api/public/references", headers=HTTP_HEADERS).json()
|
data = SEMI_STATIC_URL_DATA_CACHE.get("https://llota.app/api/public/references",
|
||||||
|
headers=HTTP_HEADERS).json()
|
||||||
if data:
|
if data:
|
||||||
for ref in data:
|
for ref in data:
|
||||||
if ref["reference_code"] == ref_id:
|
if ref["reference_code"] == ref_id:
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ from core.constants import SOFTWARE_VERSION
|
|||||||
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
|
||||||
|
|
||||||
|
|
||||||
# Provides a timed update of the application's status data.
|
|
||||||
class StatusReporter:
|
class StatusReporter:
|
||||||
|
"""Provides a timed update of the application's status data."""
|
||||||
|
|
||||||
# Constructor
|
|
||||||
def __init__(self, status_data, run_interval, web_server, cleanup_timer, spots, spot_providers, alerts,
|
def __init__(self, status_data, run_interval, web_server, cleanup_timer, spots, spot_providers, alerts,
|
||||||
alert_providers):
|
alert_providers):
|
||||||
|
"""Constructor"""
|
||||||
|
|
||||||
self.status_data = status_data
|
self.status_data = status_data
|
||||||
self.run_interval = run_interval
|
self.run_interval = run_interval
|
||||||
self.web_server = web_server
|
self.web_server = web_server
|
||||||
@@ -30,24 +31,28 @@ class StatusReporter:
|
|||||||
self.status_data["software-version"] = SOFTWARE_VERSION
|
self.status_data["software-version"] = SOFTWARE_VERSION
|
||||||
self.status_data["server-owner-callsign"] = SERVER_OWNER_CALLSIGN
|
self.status_data["server-owner-callsign"] = SERVER_OWNER_CALLSIGN
|
||||||
|
|
||||||
# Start the reporter thread
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
"""Start the reporter thread"""
|
||||||
|
|
||||||
self._thread = Thread(target=self._run, daemon=True)
|
self._thread = Thread(target=self._run, daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
# Stop any threads and prepare for application shutdown
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
"""Stop any threads and prepare for application shutdown"""
|
||||||
|
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
|
||||||
# Thread entry point: report immediately on startup, then on each interval until stopped
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
|
"""Thread entry point: report immediately on startup, then on each interval until stopped"""
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
self._report()
|
self._report()
|
||||||
if self._stop_event.wait(timeout=self.run_interval):
|
if self._stop_event.wait(timeout=self.run_interval):
|
||||||
break
|
break
|
||||||
|
|
||||||
# Write status information
|
|
||||||
def _report(self):
|
def _report(self):
|
||||||
|
"""Write status information"""
|
||||||
|
|
||||||
self.status_data["uptime"] = (datetime.now(pytz.UTC) - self.startup_time).total_seconds()
|
self.status_data["uptime"] = (datetime.now(pytz.UTC) - self.startup_time).total_seconds()
|
||||||
self.status_data["mem_use_mb"] = round(psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024), 3)
|
self.status_data["mem_use_mb"] = round(psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024), 3)
|
||||||
self.status_data["num_spots"] = len(self.spots)
|
self.status_data["num_spots"] = len(self.spots)
|
||||||
@@ -57,7 +62,8 @@ class StatusReporter:
|
|||||||
"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}, self.spot_providers))
|
tzinfo=pytz.UTC).timestamp() if p.last_spot_time.year > 2000 else 0},
|
||||||
|
self.spot_providers))
|
||||||
self.status_data["alert_providers"] = list(
|
self.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(
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
# Convert objects to serialisable things. Used by JSON serialiser as a default when it encounters unserializable things.
|
|
||||||
# Just converts objects to dict. Try to avoid doing anything clever here when serialising spots, because we also need
|
|
||||||
# to receive spots without complex handling.
|
|
||||||
def serialize_everything(obj):
|
def serialize_everything(obj):
|
||||||
|
"""Convert objects to serialisable things. Used by JSON serialiser as a default when it encounters unserializable things.
|
||||||
|
Just converts objects to dict. Try to avoid doing anything clever here when serialising spots, because we also need
|
||||||
|
to receive spots without complex handling."""
|
||||||
return obj.__dict__
|
return obj.__dict__
|
||||||
|
|
||||||
|
|
||||||
# Empty a queue
|
|
||||||
def empty_queue(q):
|
def empty_queue(q):
|
||||||
|
"""Empty a queue"""
|
||||||
|
|
||||||
while not q.empty():
|
while not q.empty():
|
||||||
try:
|
try:
|
||||||
q.get_nowait()
|
q.get_nowait()
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ from core.lookup_helper import lookup_helper
|
|||||||
from core.sig_utils import populate_sig_ref_info
|
from core.sig_utils import populate_sig_ref_info
|
||||||
|
|
||||||
|
|
||||||
# Data class that defines an alert.
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Alert:
|
class Alert:
|
||||||
|
"""Data class that defines an alert."""
|
||||||
|
|
||||||
# Unique identifier for the alert
|
# Unique identifier for the alert
|
||||||
id: str = None
|
id: str = None
|
||||||
# Callsigns of the operators that has been alerted
|
# Callsigns of the operators that has been alerted
|
||||||
@@ -60,8 +61,9 @@ class Alert:
|
|||||||
# The ID the source gave it, if any.
|
# The ID the source gave it, if any.
|
||||||
source_id: str = None
|
source_id: str = None
|
||||||
|
|
||||||
# Infer missing parameters where possible
|
|
||||||
def infer_missing(self):
|
def infer_missing(self):
|
||||||
|
"""Infer missing parameters where possible"""
|
||||||
|
|
||||||
# If we somehow don't have a start time, set it to zero so it sorts off the bottom of any list but
|
# If we somehow don't have a start time, set it to zero so it sorts off the bottom of any list but
|
||||||
# clients can still reliably parse it as a number.
|
# clients can still reliably parse it as a number.
|
||||||
if not self.start_time:
|
if not self.start_time:
|
||||||
@@ -122,14 +124,16 @@ 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()
|
||||||
|
|
||||||
# JSON serialise
|
|
||||||
def to_json(self):
|
def to_json(self):
|
||||||
|
"""JSON serialise"""
|
||||||
|
|
||||||
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
|
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
|
||||||
|
|
||||||
# Decide if this alert has expired (in which case it should not be added to the system in the first place, and not
|
|
||||||
# returned by the web server if later requested, and removed by the cleanup functions). "Expired" is defined as
|
|
||||||
# either having an end_time in the past, or if it only has a start_time, then that start time was more than 3 hours
|
|
||||||
# ago. If it somehow doesn't have a start_time either, it is considered to be expired.
|
|
||||||
def expired(self):
|
def expired(self):
|
||||||
|
"""Decide if this alert has expired (in which case it should not be added to the system in the first place, and not
|
||||||
|
returned by the web server if later requested, and removed by the cleanup functions). "Expired" is defined as
|
||||||
|
either having an end_time in the past, or if it only has a start_time, then that start time was more than 3 hours
|
||||||
|
ago. If it somehow doesn't have a start_time either, it is considered to be expired."""
|
||||||
|
|
||||||
return not self.start_time or (self.end_time and self.end_time < datetime.now(pytz.UTC).timestamp()) or (
|
return not self.start_time or (self.end_time and self.end_time < datetime.now(pytz.UTC).timestamp()) or (
|
||||||
not self.end_time and self.start_time < (datetime.now(pytz.UTC) - timedelta(hours=3)).timestamp())
|
not self.end_time and self.start_time < (datetime.now(pytz.UTC) - timedelta(hours=3)).timestamp())
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
# Data class that defines a band.
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Band:
|
class Band:
|
||||||
|
"""Data class that defines a band."""
|
||||||
|
|
||||||
# Band name
|
# Band name
|
||||||
name: str
|
name: str
|
||||||
# Start frequency, in Hz
|
# Start frequency, in Hz
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
# Data class that defines a Special Interest Group.
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SIG:
|
class SIG:
|
||||||
|
"""Data class that defines a Special Interest Group."""
|
||||||
|
|
||||||
# SIG name, e.g. "POTA"
|
# SIG name, e.g. "POTA"
|
||||||
name: str
|
name: str
|
||||||
# Description, e.g. "Parks on the Air"
|
# Description, e.g. "Parks on the Air"
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
# Data class that defines a Special Interest Group "info" or reference. As well as the basic reference ID we include a
|
|
||||||
# name and a lookup URL.
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SIGRef:
|
class SIGRef:
|
||||||
|
"""Data class that defines a Special Interest Group "info" or reference. As well as the basic reference ID we include a
|
||||||
|
name and a lookup URL."""
|
||||||
|
|
||||||
# Reference ID, e.g. "GB-0001".
|
# Reference ID, e.g. "GB-0001".
|
||||||
id: str
|
id: str
|
||||||
# SIG that this reference is in, e.g. "POTA".
|
# SIG that this reference is in, e.g. "POTA".
|
||||||
|
|||||||
30
data/spot.py
30
data/spot.py
@@ -17,9 +17,10 @@ from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_f
|
|||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
|
|
||||||
|
|
||||||
# Data class that defines a spot.
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Spot:
|
class Spot:
|
||||||
|
"""Data class that defines a spot."""
|
||||||
|
|
||||||
# Unique identifier for the spot
|
# Unique identifier for the spot
|
||||||
id: str = None
|
id: str = None
|
||||||
|
|
||||||
@@ -129,8 +130,9 @@ class Spot:
|
|||||||
# The ID the source gave it, if any.
|
# The ID the source gave it, if any.
|
||||||
source_id: str = None
|
source_id: str = None
|
||||||
|
|
||||||
# Infer missing parameters where possible
|
|
||||||
def infer_missing(self):
|
def infer_missing(self):
|
||||||
|
"""Infer missing parameters where possible"""
|
||||||
|
|
||||||
# If we somehow don't have a spot time, set it to zero so it sorts off the bottom of any list but
|
# If we somehow don't have a spot time, set it to zero so it sorts off the bottom of any list but
|
||||||
# clients can still reliably parse it as a number.
|
# clients can still reliably parse it as a number.
|
||||||
if not self.time:
|
if not self.time:
|
||||||
@@ -186,7 +188,8 @@ 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
|
||||||
if self.de_call and any(char.isdigit() for char in self.de_call) and not (self.de_call.startswith("T2") and self.source == "APRS-IS"):
|
if self.de_call and any(char.isdigit() for char in self.de_call) and not (
|
||||||
|
self.de_call.startswith("T2") and self.source == "APRS-IS"):
|
||||||
if not self.de_country:
|
if not self.de_country:
|
||||||
self.de_country = lookup_helper.infer_country_from_callsign(self.de_call)
|
self.de_country = lookup_helper.infer_country_from_callsign(self.de_call)
|
||||||
if not self.de_continent:
|
if not self.de_continent:
|
||||||
@@ -253,7 +256,8 @@ class Spot:
|
|||||||
# If so, add that to the sig_refs list for this spot.
|
# If so, add that to the sig_refs list for this spot.
|
||||||
ref_regex = get_ref_regex_for_sig(found_sig)
|
ref_regex = get_ref_regex_for_sig(found_sig)
|
||||||
if ref_regex:
|
if ref_regex:
|
||||||
ref_matches = re.finditer(r"(^|\W)" + found_sig + r"($|\W)(" + ref_regex + r")($|\W)", self.comment, re.IGNORECASE)
|
ref_matches = re.finditer(r"(^|\W)" + found_sig + r"($|\W)(" + ref_regex + r")($|\W)", self.comment,
|
||||||
|
re.IGNORECASE)
|
||||||
for ref_match in ref_matches:
|
for ref_match in ref_matches:
|
||||||
self.append_sig_ref_if_missing(SIGRef(id=ref_match.group(3).upper(), sig=found_sig))
|
self.append_sig_ref_if_missing(SIGRef(id=ref_match.group(3).upper(), sig=found_sig))
|
||||||
|
|
||||||
@@ -348,7 +352,8 @@ class Spot:
|
|||||||
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))
|
||||||
|
|
||||||
# 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 (self.de_call.startswith("T2") and self.source == "APRS-IS"):
|
if self.de_call and any(char.isdigit() for char in self.de_call) and not (
|
||||||
|
self.de_call.startswith("T2") and self.source == "APRS-IS"):
|
||||||
# DE operator position lookup, using QRZ.com.
|
# DE operator position lookup, using QRZ.com.
|
||||||
if not self.de_latitude:
|
if not self.de_latitude:
|
||||||
latlon = lookup_helper.infer_latlon_from_callsign_online_lookup(self.de_call)
|
latlon = lookup_helper.infer_latlon_from_callsign_online_lookup(self.de_call)
|
||||||
@@ -375,12 +380,14 @@ 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()
|
||||||
|
|
||||||
# JSON sspoterialise
|
|
||||||
def to_json(self):
|
def to_json(self):
|
||||||
|
"""JSON serialise"""
|
||||||
|
|
||||||
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
|
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
|
||||||
|
|
||||||
# Append a sig_ref to the list, so long as it's not already there.
|
|
||||||
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."""
|
||||||
|
|
||||||
if not self.sig_refs:
|
if not self.sig_refs:
|
||||||
self.sig_refs = []
|
self.sig_refs = []
|
||||||
new_sig_ref.id = new_sig_ref.id.strip().upper()
|
new_sig_ref.id = new_sig_ref.id.strip().upper()
|
||||||
@@ -392,9 +399,10 @@ class Spot:
|
|||||||
return
|
return
|
||||||
self.sig_refs.append(new_sig_ref)
|
self.sig_refs.append(new_sig_ref)
|
||||||
|
|
||||||
# Decide if this spot has expired (in which case it should not be added to the system in the first place, and not
|
|
||||||
# returned by the web server if later requested, and removed by the cleanup functions). "Expired" is defined as
|
|
||||||
# either having a time further ago than the server's MAX_SPOT_AGE. If it somehow doesn't have a time either, it is
|
|
||||||
# considered to be expired.
|
|
||||||
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
|
||||||
|
returned by the web server if later requested, and removed by the cleanup functions). "Expired" is defined as
|
||||||
|
either having a time further ago than the server's MAX_SPOT_AGE. If it somehow doesn't have a time either, it is
|
||||||
|
considered to be expired."""
|
||||||
|
|
||||||
return not self.time or self.time < (datetime.now(pytz.UTC) - timedelta(seconds=MAX_SPOT_AGE)).timestamp()
|
return not self.time or self.time < (datetime.now(pytz.UTC) - timedelta(seconds=MAX_SPOT_AGE)).timestamp()
|
||||||
@@ -16,8 +16,9 @@ from data.sig_ref import SIGRef
|
|||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
|
|
||||||
|
|
||||||
# API request handler for /api/v1/spot (POST)
|
|
||||||
class APISpotHandler(tornado.web.RequestHandler):
|
class APISpotHandler(tornado.web.RequestHandler):
|
||||||
|
"""API request handler for /api/v1/spot (POST)"""
|
||||||
|
|
||||||
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
|
||||||
@@ -40,9 +41,11 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Reject if format not json
|
# Reject if format not json
|
||||||
if 'Content-Type' not in self.request.headers or self.request.headers.get('Content-Type') != "application/json":
|
if 'Content-Type' not in self.request.headers or self.request.headers.get(
|
||||||
|
'Content-Type') != "application/json":
|
||||||
self.set_status(415)
|
self.set_status(415)
|
||||||
self.write(json.dumps("Error - request Content-Type must be application/json", default=serialize_everything))
|
self.write(
|
||||||
|
json.dumps("Error - request Content-Type must be application/json", default=serialize_everything))
|
||||||
self.set_header("Cache-Control", "no-store")
|
self.set_header("Cache-Control", "no-store")
|
||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ SSE_HANDLER_MAX_QUEUE_SIZE = 100
|
|||||||
SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
|
SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
|
||||||
|
|
||||||
|
|
||||||
# API request handler for /api/v1/alerts
|
|
||||||
class APIAlertsHandler(tornado.web.RequestHandler):
|
class APIAlertsHandler(tornado.web.RequestHandler):
|
||||||
|
"""API request handler for /api/v1/alerts"""
|
||||||
|
|
||||||
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
|
||||||
@@ -47,14 +48,17 @@ class APIAlertsHandler(tornado.web.RequestHandler):
|
|||||||
self.set_header("Cache-Control", "no-store")
|
self.set_header("Cache-Control", "no-store")
|
||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
|
|
||||||
# API request handler for /api/v1/alerts/stream
|
|
||||||
class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
||||||
|
"""API request handler for /api/v1/alerts/stream"""
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
# Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data
|
|
||||||
def custom_headers(self):
|
def custom_headers(self):
|
||||||
|
"""Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data"""
|
||||||
|
|
||||||
return {"Cache-Control": "no-store",
|
return {"Cache-Control": "no-store",
|
||||||
"X-Accel-Buffering": "no"}
|
"X-Accel-Buffering": "no"}
|
||||||
|
|
||||||
@@ -81,8 +85,9 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warn("Exception when serving SSE socket", e)
|
logging.warn("Exception when serving SSE socket", e)
|
||||||
|
|
||||||
# When the user closes the socket, empty our queue and remove it from the list so the server no longer fills it
|
|
||||||
def close(self):
|
def close(self):
|
||||||
|
"""When the user closes the socket, empty our queue and remove it from the list so the server no longer fills it"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.alert_queue in self.sse_alert_queues:
|
if self.alert_queue in self.sse_alert_queues:
|
||||||
self.sse_alert_queues.remove(self.alert_queue)
|
self.sse_alert_queues.remove(self.alert_queue)
|
||||||
@@ -96,8 +101,9 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
|||||||
self.alert_queue = None
|
self.alert_queue = None
|
||||||
super().close()
|
super().close()
|
||||||
|
|
||||||
# Callback to check if anything has arrived in the queue, and if so send it to the client
|
|
||||||
def _callback(self):
|
def _callback(self):
|
||||||
|
"""Callback to check if anything has arrived in the queue, and if so send it to the client"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.alert_queue:
|
if self.alert_queue:
|
||||||
while not self.alert_queue.empty():
|
while not self.alert_queue.empty():
|
||||||
@@ -114,11 +120,10 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
|||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Utility method to apply filters to the overall alert list and return only a subset. Enables query parameters in
|
|
||||||
# the main "alerts" GET call.
|
|
||||||
def get_alert_list_with_filters(all_alerts, query):
|
def get_alert_list_with_filters(all_alerts, query):
|
||||||
|
"""Utility method to apply filters to the overall alert list and return only a subset. Enables query parameters in
|
||||||
|
the main "alerts" GET call."""
|
||||||
|
|
||||||
# Create a shallow copy of the alert list ordered by start time, then filter the list to reduce it only to alerts
|
# Create a shallow copy of the alert list ordered by start time, then filter the list to reduce it only to alerts
|
||||||
# that match the filter parameters in the query string. Finally, apply a limit to the number of alerts returned.
|
# that match the filter parameters in the query string. Finally, apply a limit to the number of alerts returned.
|
||||||
# The list of query string filters is defined in the API docs.
|
# The list of query string filters is defined in the API docs.
|
||||||
@@ -134,9 +139,11 @@ def get_alert_list_with_filters(all_alerts, query):
|
|||||||
alerts = alerts[:int(query.get("limit"))]
|
alerts = alerts[:int(query.get("limit"))]
|
||||||
return alerts
|
return alerts
|
||||||
|
|
||||||
# Given URL query params and an alert, figure out if the alert "passes" the requested filters or is rejected. The list
|
|
||||||
# of query parameters and their function is defined in the API docs.
|
|
||||||
def alert_allowed_by_query(alert, query):
|
def alert_allowed_by_query(alert, query):
|
||||||
|
"""Given URL query params and an alert, figure out if the alert "passes" the requested filters or is rejected. The list
|
||||||
|
of query parameters and their function is defined in the API docs."""
|
||||||
|
|
||||||
for k in query.keys():
|
for k in query.keys():
|
||||||
match k:
|
match k:
|
||||||
case "received_since":
|
case "received_since":
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ from data.sig_ref import SIGRef
|
|||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
|
|
||||||
|
|
||||||
# API request handler for /api/v1/lookup/call
|
|
||||||
class APILookupCallHandler(tornado.web.RequestHandler):
|
class APILookupCallHandler(tornado.web.RequestHandler):
|
||||||
|
"""API request handler for /api/v1/lookup/call"""
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -75,8 +76,9 @@ class APILookupCallHandler(tornado.web.RequestHandler):
|
|||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
|
|
||||||
|
|
||||||
# API request handler for /api/v1/lookup/sigref
|
|
||||||
class APILookupSIGRefHandler(tornado.web.RequestHandler):
|
class APILookupSIGRefHandler(tornado.web.RequestHandler):
|
||||||
|
"""API request handler for /api/v1/lookup/sigref"""
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -123,9 +125,9 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
|
|||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# API request handler for /api/v1/lookup/grid
|
|
||||||
class APILookupGridHandler(tornado.web.RequestHandler):
|
class APILookupGridHandler(tornado.web.RequestHandler):
|
||||||
|
"""API request handler for /api/v1/lookup/grid"""
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ from core.prometheus_metrics_handler import api_requests_counter
|
|||||||
from core.utils import serialize_everything
|
from core.utils import serialize_everything
|
||||||
|
|
||||||
|
|
||||||
# API request handler for /api/v1/options
|
|
||||||
class APIOptionsHandler(tornado.web.RequestHandler):
|
class APIOptionsHandler(tornado.web.RequestHandler):
|
||||||
|
"""API request handler for /api/v1/options"""
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ SSE_HANDLER_MAX_QUEUE_SIZE = 1000
|
|||||||
SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
|
SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
|
||||||
|
|
||||||
|
|
||||||
# API request handler for /api/v1/spots
|
|
||||||
class APISpotsHandler(tornado.web.RequestHandler):
|
class APISpotsHandler(tornado.web.RequestHandler):
|
||||||
|
"""API request handler for /api/v1/spots"""
|
||||||
|
|
||||||
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
|
||||||
@@ -48,19 +49,22 @@ class APISpotsHandler(tornado.web.RequestHandler):
|
|||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
|
|
||||||
|
|
||||||
# API request handler for /api/v1/spots/stream
|
|
||||||
class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
||||||
|
"""API request handler for /api/v1/spots/stream"""
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
# Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data
|
|
||||||
def custom_headers(self):
|
def custom_headers(self):
|
||||||
|
"""Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data"""
|
||||||
|
|
||||||
return {"Cache-Control": "no-store",
|
return {"Cache-Control": "no-store",
|
||||||
"X-Accel-Buffering": "no"}
|
"X-Accel-Buffering": "no"}
|
||||||
|
|
||||||
# Called once on the client opening a connection, set things up
|
|
||||||
def open(self):
|
def open(self):
|
||||||
|
"""Called once on the client opening a connection, set things up"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Metrics
|
# Metrics
|
||||||
self.web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
self.web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
||||||
@@ -83,8 +87,9 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warn("Exception when serving SSE socket", e)
|
logging.warn("Exception when serving SSE socket", e)
|
||||||
|
|
||||||
# When the user closes the socket, empty our queue and remove it from the list so the server no longer fills it
|
|
||||||
def close(self):
|
def close(self):
|
||||||
|
"""When the user closes the socket, empty our queue and remove it from the list so the server no longer fills it"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.spot_queue in self.sse_spot_queues:
|
if self.spot_queue in self.sse_spot_queues:
|
||||||
self.sse_spot_queues.remove(self.spot_queue)
|
self.sse_spot_queues.remove(self.spot_queue)
|
||||||
@@ -98,8 +103,9 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
|||||||
self.spot_queue = None
|
self.spot_queue = None
|
||||||
super().close()
|
super().close()
|
||||||
|
|
||||||
# Callback to check if anything has arrived in the queue, and if so send it to the client
|
|
||||||
def _callback(self):
|
def _callback(self):
|
||||||
|
"""Callback to check if anything has arrived in the queue, and if so send it to the client"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.spot_queue:
|
if self.spot_queue:
|
||||||
while not self.spot_queue.empty():
|
while not self.spot_queue.empty():
|
||||||
@@ -116,10 +122,10 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
|||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Utility method to apply filters to the overall spot list and return only a subset. Enables query parameters in
|
|
||||||
# the main "spots" GET call.
|
|
||||||
def get_spot_list_with_filters(all_spots, query):
|
def get_spot_list_with_filters(all_spots, query):
|
||||||
|
"""Utility method to apply filters to the overall spot list and return only a subset. Enables query parameters in
|
||||||
|
the main "spots" GET call."""
|
||||||
|
|
||||||
# Create a shallow copy of the spot list, ordered by spot time, then filter the list to reduce it only to spots
|
# Create a shallow copy of the spot list, ordered by spot time, then filter the list to reduce it only to spots
|
||||||
# that match the filter parameters in the query string. Finally, apply a limit to the number of spots returned.
|
# that match the filter parameters in the query string. Finally, apply a limit to the number of spots returned.
|
||||||
# The list of query string filters is defined in the API docs.
|
# The list of query string filters is defined in the API docs.
|
||||||
@@ -155,9 +161,11 @@ def get_spot_list_with_filters(all_spots, query):
|
|||||||
|
|
||||||
return spots
|
return spots
|
||||||
|
|
||||||
# Given URL query params and a spot, figure out if the spot "passes" the requested filters or is rejected. The list
|
|
||||||
# of query parameters and their function is defined in the API docs.
|
|
||||||
def spot_allowed_by_query(spot, query):
|
def spot_allowed_by_query(spot, query):
|
||||||
|
"""Given URL query params and a spot, figure out if the spot "passes" the requested filters or is rejected. The list
|
||||||
|
of query parameters and their function is defined in the API docs."""
|
||||||
|
|
||||||
for k in query.keys():
|
for k in query.keys():
|
||||||
match k:
|
match k:
|
||||||
case "since":
|
case "since":
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ from core.prometheus_metrics_handler import api_requests_counter
|
|||||||
from core.utils import serialize_everything
|
from core.utils import serialize_everything
|
||||||
|
|
||||||
|
|
||||||
# API request handler for /api/v1/status
|
|
||||||
class APIStatusHandler(tornado.web.RequestHandler):
|
class APIStatusHandler(tornado.web.RequestHandler):
|
||||||
|
"""API request handler for /api/v1/status"""
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ from prometheus_client import CONTENT_TYPE_LATEST
|
|||||||
from core.prometheus_metrics_handler import get_metrics
|
from core.prometheus_metrics_handler import get_metrics
|
||||||
|
|
||||||
|
|
||||||
# Handler for Prometheus metrics endpoint
|
|
||||||
class PrometheusMetricsHandler(tornado.web.RequestHandler):
|
class PrometheusMetricsHandler(tornado.web.RequestHandler):
|
||||||
|
"""Handler for Prometheus metrics endpoint"""
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
self.write(get_metrics())
|
self.write(get_metrics())
|
||||||
self.set_status(200)
|
self.set_status(200)
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ from core.constants import SOFTWARE_VERSION
|
|||||||
from core.prometheus_metrics_handler import page_requests_counter
|
from core.prometheus_metrics_handler import page_requests_counter
|
||||||
|
|
||||||
|
|
||||||
# Handler for all HTML pages generated from templates
|
|
||||||
class PageTemplateHandler(tornado.web.RequestHandler):
|
class PageTemplateHandler(tornado.web.RequestHandler):
|
||||||
|
"""Handler for all HTML pages generated from templates"""
|
||||||
|
|
||||||
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
|
||||||
@@ -24,4 +25,3 @@ class PageTemplateHandler(tornado.web.RequestHandler):
|
|||||||
# Load named template, and provide variables used in templates
|
# Load named template, and provide variables used in templates
|
||||||
self.render(self.template_name + ".html", software_version=SOFTWARE_VERSION, allow_spotting=ALLOW_SPOTTING,
|
self.render(self.template_name + ".html", software_version=SOFTWARE_VERSION, allow_spotting=ALLOW_SPOTTING,
|
||||||
web_ui_options=WEB_UI_OPTIONS)
|
web_ui_options=WEB_UI_OPTIONS)
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ from server.handlers.metrics import PrometheusMetricsHandler
|
|||||||
from server.handlers.pagetemplate import PageTemplateHandler
|
from server.handlers.pagetemplate import PageTemplateHandler
|
||||||
|
|
||||||
|
|
||||||
# Provides the public-facing web server.
|
|
||||||
class WebServer:
|
class WebServer:
|
||||||
# Constructor
|
"""Provides the public-facing web server."""
|
||||||
|
|
||||||
def __init__(self, spots, alerts, status_data, port):
|
def __init__(self, spots, alerts, status_data, port):
|
||||||
|
"""Constructor"""
|
||||||
|
|
||||||
self.spots = spots
|
self.spots = spots
|
||||||
self.alerts = alerts
|
self.alerts = alerts
|
||||||
self.sse_spot_queues = []
|
self.sse_spot_queues = []
|
||||||
@@ -35,24 +37,32 @@ class WebServer:
|
|||||||
"status": "Starting"
|
"status": "Starting"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Start the web server
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
"""Start the web server"""
|
||||||
|
|
||||||
asyncio.run(self.start_inner())
|
asyncio.run(self.start_inner())
|
||||||
|
|
||||||
# Stop the web server
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
"""Stop the web server"""
|
||||||
|
|
||||||
self.shutdown_event.set()
|
self.shutdown_event.set()
|
||||||
|
|
||||||
# Start method (async). Sets up the Tornado application.
|
|
||||||
async def start_inner(self):
|
async def start_inner(self):
|
||||||
|
"""Start method (async). Sets up the Tornado application."""
|
||||||
|
|
||||||
app = tornado.web.Application([
|
app = tornado.web.Application([
|
||||||
# Routes for API calls
|
# Routes for API calls
|
||||||
(r"/api/v1/spots", APISpotsHandler, {"spots": self.spots, "web_server_metrics": self.web_server_metrics}),
|
(r"/api/v1/spots", APISpotsHandler, {"spots": self.spots, "web_server_metrics": self.web_server_metrics}),
|
||||||
(r"/api/v1/alerts", APIAlertsHandler, {"alerts": self.alerts, "web_server_metrics": self.web_server_metrics}),
|
(r"/api/v1/alerts", APIAlertsHandler,
|
||||||
(r"/api/v1/spots/stream", APISpotsStreamHandler, {"sse_spot_queues": self.sse_spot_queues, "web_server_metrics": self.web_server_metrics}),
|
{"alerts": self.alerts, "web_server_metrics": self.web_server_metrics}),
|
||||||
(r"/api/v1/alerts/stream", APIAlertsStreamHandler, {"sse_alert_queues": self.sse_alert_queues, "web_server_metrics": self.web_server_metrics}),
|
(r"/api/v1/spots/stream", APISpotsStreamHandler,
|
||||||
(r"/api/v1/options", APIOptionsHandler, {"status_data": self.status_data, "web_server_metrics": self.web_server_metrics}),
|
{"sse_spot_queues": self.sse_spot_queues, "web_server_metrics": self.web_server_metrics}),
|
||||||
(r"/api/v1/status", APIStatusHandler, {"status_data": self.status_data, "web_server_metrics": self.web_server_metrics}),
|
(r"/api/v1/alerts/stream", APIAlertsStreamHandler,
|
||||||
|
{"sse_alert_queues": self.sse_alert_queues, "web_server_metrics": self.web_server_metrics}),
|
||||||
|
(r"/api/v1/options", APIOptionsHandler,
|
||||||
|
{"status_data": self.status_data, "web_server_metrics": self.web_server_metrics}),
|
||||||
|
(r"/api/v1/status", APIStatusHandler,
|
||||||
|
{"status_data": self.status_data, "web_server_metrics": self.web_server_metrics}),
|
||||||
(r"/api/v1/lookup/call", APILookupCallHandler, {"web_server_metrics": self.web_server_metrics}),
|
(r"/api/v1/lookup/call", APILookupCallHandler, {"web_server_metrics": self.web_server_metrics}),
|
||||||
(r"/api/v1/lookup/sigref", APILookupSIGRefHandler, {"web_server_metrics": self.web_server_metrics}),
|
(r"/api/v1/lookup/sigref", APILookupSIGRefHandler, {"web_server_metrics": self.web_server_metrics}),
|
||||||
(r"/api/v1/lookup/grid", APILookupGridHandler, {"web_server_metrics": self.web_server_metrics}),
|
(r"/api/v1/lookup/grid", APILookupGridHandler, {"web_server_metrics": self.web_server_metrics}),
|
||||||
@@ -61,11 +71,15 @@ class WebServer:
|
|||||||
(r"/", PageTemplateHandler, {"template_name": "spots", "web_server_metrics": self.web_server_metrics}),
|
(r"/", PageTemplateHandler, {"template_name": "spots", "web_server_metrics": self.web_server_metrics}),
|
||||||
(r"/map", PageTemplateHandler, {"template_name": "map", "web_server_metrics": self.web_server_metrics}),
|
(r"/map", PageTemplateHandler, {"template_name": "map", "web_server_metrics": self.web_server_metrics}),
|
||||||
(r"/bands", PageTemplateHandler, {"template_name": "bands", "web_server_metrics": self.web_server_metrics}),
|
(r"/bands", PageTemplateHandler, {"template_name": "bands", "web_server_metrics": self.web_server_metrics}),
|
||||||
(r"/alerts", PageTemplateHandler, {"template_name": "alerts", "web_server_metrics": self.web_server_metrics}),
|
(r"/alerts", PageTemplateHandler,
|
||||||
(r"/add-spot", PageTemplateHandler, {"template_name": "add_spot", "web_server_metrics": self.web_server_metrics}),
|
{"template_name": "alerts", "web_server_metrics": self.web_server_metrics}),
|
||||||
(r"/status", PageTemplateHandler, {"template_name": "status", "web_server_metrics": self.web_server_metrics}),
|
(r"/add-spot", PageTemplateHandler,
|
||||||
|
{"template_name": "add_spot", "web_server_metrics": self.web_server_metrics}),
|
||||||
|
(r"/status", PageTemplateHandler,
|
||||||
|
{"template_name": "status", "web_server_metrics": self.web_server_metrics}),
|
||||||
(r"/about", PageTemplateHandler, {"template_name": "about", "web_server_metrics": self.web_server_metrics}),
|
(r"/about", PageTemplateHandler, {"template_name": "about", "web_server_metrics": self.web_server_metrics}),
|
||||||
(r"/apidocs", PageTemplateHandler, {"template_name": "apidocs", "web_server_metrics": self.web_server_metrics}),
|
(r"/apidocs", PageTemplateHandler,
|
||||||
|
{"template_name": "apidocs", "web_server_metrics": self.web_server_metrics}),
|
||||||
# Route for Prometheus metrics
|
# Route for Prometheus metrics
|
||||||
(r"/metrics", PrometheusMetricsHandler),
|
(r"/metrics", PrometheusMetricsHandler),
|
||||||
# Default route to serve from "webassets"
|
# Default route to serve from "webassets"
|
||||||
@@ -76,9 +90,10 @@ class WebServer:
|
|||||||
app.listen(self.port)
|
app.listen(self.port)
|
||||||
await self.shutdown_event.wait()
|
await self.shutdown_event.wait()
|
||||||
|
|
||||||
# Internal method called when a new spot is added to the system. This is used to ping any SSE clients that are
|
|
||||||
# awaiting a server-sent message with new spots.
|
|
||||||
def notify_new_spot(self, spot):
|
def notify_new_spot(self, spot):
|
||||||
|
"""Internal method called when a new spot is added to the system. This is used to ping any SSE clients that are
|
||||||
|
awaiting a server-sent message with new spots."""
|
||||||
|
|
||||||
for queue in self.sse_spot_queues:
|
for queue in self.sse_spot_queues:
|
||||||
try:
|
try:
|
||||||
queue.put(spot)
|
queue.put(spot)
|
||||||
@@ -87,9 +102,10 @@ class WebServer:
|
|||||||
pass
|
pass
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Internal method called when a new alert is added to the system. This is used to ping any SSE clients that are
|
|
||||||
# awaiting a server-sent message with new spots.
|
|
||||||
def notify_new_alert(self, alert):
|
def notify_new_alert(self, alert):
|
||||||
|
"""Internal method called when a new alert is added to the system. This is used to ping any SSE clients that are
|
||||||
|
awaiting a server-sent message with new spots."""
|
||||||
|
|
||||||
for queue in self.sse_alert_queues:
|
for queue in self.sse_alert_queues:
|
||||||
try:
|
try:
|
||||||
queue.put(alert)
|
queue.put(alert)
|
||||||
@@ -98,13 +114,15 @@ class WebServer:
|
|||||||
pass
|
pass
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Clean up any SSE queues that are growing too large; probably their client disconnected and we didn't catch it
|
|
||||||
# properly for some reason.
|
|
||||||
def clean_up_sse_queues(self):
|
def clean_up_sse_queues(self):
|
||||||
|
"""Clean up any SSE queues that are growing too large; probably their client disconnected and we didn't catch it
|
||||||
|
properly for some reason."""
|
||||||
|
|
||||||
for q in self.sse_spot_queues:
|
for q in self.sse_spot_queues:
|
||||||
try:
|
try:
|
||||||
if q.full():
|
if q.full():
|
||||||
logging.warn("A full SSE spot queue was found, presumably because the client disconnected strangely. It has been removed.")
|
logging.warn(
|
||||||
|
"A full SSE spot queue was found, presumably because the client disconnected strangely. It has been removed.")
|
||||||
self.sse_spot_queues.remove(q)
|
self.sse_spot_queues.remove(q)
|
||||||
empty_queue(q)
|
empty_queue(q)
|
||||||
except:
|
except:
|
||||||
@@ -113,7 +131,8 @@ class WebServer:
|
|||||||
for q in self.sse_alert_queues:
|
for q in self.sse_alert_queues:
|
||||||
try:
|
try:
|
||||||
if q.full():
|
if q.full():
|
||||||
logging.warn("A full SSE alert queue was found, presumably because the client disconnected strangely. It has been removed.")
|
logging.warn(
|
||||||
|
"A full SSE alert queue was found, presumably because the client disconnected strangely. It has been removed.")
|
||||||
self.sse_alert_queues.remove(q)
|
self.sse_alert_queues.remove(q)
|
||||||
empty_queue(q)
|
empty_queue(q)
|
||||||
except:
|
except:
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ cleanup_timer = None
|
|||||||
run = True
|
run = True
|
||||||
|
|
||||||
|
|
||||||
# Shutdown function
|
|
||||||
def shutdown(sig, frame):
|
def shutdown(sig, frame):
|
||||||
|
"""Shutdown function"""
|
||||||
|
|
||||||
global run
|
global run
|
||||||
|
|
||||||
logging.info("Stopping program...")
|
logging.info("Stopping program...")
|
||||||
@@ -44,15 +45,17 @@ def shutdown(sig, frame):
|
|||||||
os._exit(0)
|
os._exit(0)
|
||||||
|
|
||||||
|
|
||||||
# Utility method to get a spot provider based on the class specified in its config entry.
|
|
||||||
def get_spot_provider_from_config(config_providers_entry):
|
def get_spot_provider_from_config(config_providers_entry):
|
||||||
|
"""Utility method to get a spot provider based on the class specified in its config entry."""
|
||||||
|
|
||||||
module = importlib.import_module('spotproviders.' + config_providers_entry["class"].lower())
|
module = importlib.import_module('spotproviders.' + 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)
|
||||||
|
|
||||||
|
|
||||||
# Utility method to get an alert provider based on the class specified in its config entry.
|
|
||||||
def get_alert_provider_from_config(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('alertproviders.' + config_providers_entry["class"].lower())
|
module = importlib.import_module('alertproviders.' + 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)
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ from data.spot import Spot
|
|||||||
from spotproviders.spot_provider import SpotProvider
|
from spotproviders.spot_provider import SpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for the APRS-IS.
|
|
||||||
class APRSIS(SpotProvider):
|
class APRSIS(SpotProvider):
|
||||||
|
"""Spot provider for the APRS-IS."""
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config)
|
super().__init__(provider_config)
|
||||||
@@ -51,7 +51,8 @@ class APRSIS(SpotProvider):
|
|||||||
comment=data["comment"] if "comment" in data else None,
|
comment=data["comment"] if "comment" in data else None,
|
||||||
dx_latitude=data["latitude"] if "latitude" in data else None,
|
dx_latitude=data["latitude"] if "latitude" in data else None,
|
||||||
dx_longitude=data["longitude"] if "longitude" in data else None,
|
dx_longitude=data["longitude"] if "longitude" in data else None,
|
||||||
time=datetime.now(pytz.UTC).timestamp()) # APRS-IS spots are live so we can assume spot time is "now"
|
time=datetime.now(
|
||||||
|
pytz.UTC).timestamp()) # APRS-IS spots are live so we can assume spot time is "now"
|
||||||
|
|
||||||
# Add to our list
|
# Add to our list
|
||||||
self.submit(spot)
|
self.submit(spot)
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ from data.spot import Spot
|
|||||||
from spotproviders.spot_provider import SpotProvider
|
from spotproviders.spot_provider import SpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for a DX Cluster. Hostname, port, login_prompt, login_callsign and allow_rbn_spots are provided in config.
|
|
||||||
# See config-example.yml for examples.
|
|
||||||
class DXCluster(SpotProvider):
|
class DXCluster(SpotProvider):
|
||||||
|
"""Spot provider for a DX Cluster. Hostname, port, login_prompt, login_callsign and allow_rbn_spots are provided in config.
|
||||||
|
See config-example.yml for examples."""
|
||||||
|
|
||||||
CALLSIGN_PATTERN = "([a-z|0-9|/]+)"
|
CALLSIGN_PATTERN = "([a-z|0-9|/]+)"
|
||||||
FREQUENCY_PATTERN = "([0-9|.]+)"
|
FREQUENCY_PATTERN = "([0-9|.]+)"
|
||||||
LINE_PATTERN_EXCLUDE_RBN = re.compile(
|
LINE_PATTERN_EXCLUDE_RBN = re.compile(
|
||||||
@@ -24,13 +25,15 @@ class DXCluster(SpotProvider):
|
|||||||
"^DX de " + CALLSIGN_PATTERN + "-?#?:\\s+" + FREQUENCY_PATTERN + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)",
|
"^DX de " + CALLSIGN_PATTERN + "-?#?:\\s+" + FREQUENCY_PATTERN + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)",
|
||||||
re.IGNORECASE)
|
re.IGNORECASE)
|
||||||
|
|
||||||
# Constructor requires hostname and port
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
|
"""Constructor requires hostname and port"""
|
||||||
|
|
||||||
super().__init__(provider_config)
|
super().__init__(provider_config)
|
||||||
self.hostname = provider_config["host"]
|
self.hostname = provider_config["host"]
|
||||||
self.port = provider_config["port"]
|
self.port = provider_config["port"]
|
||||||
self.login_prompt = provider_config["login_prompt"] if "login_prompt" in provider_config else "login:"
|
self.login_prompt = provider_config["login_prompt"] if "login_prompt" in provider_config else "login:"
|
||||||
self.login_callsign = provider_config["login_callsign"] if "login_callsign" in provider_config else SERVER_OWNER_CALLSIGN
|
self.login_callsign = provider_config[
|
||||||
|
"login_callsign"] if "login_callsign" in provider_config else SERVER_OWNER_CALLSIGN
|
||||||
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
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ from data.spot import Spot
|
|||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for General Mountain Activity
|
|
||||||
class GMA(HTTPSpotProvider):
|
class GMA(HTTPSpotProvider):
|
||||||
|
"""Spot provider for General Mountain Activity"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
SPOTS_URL = "https://www.cqgma.org/api/spots/25/"
|
SPOTS_URL = "https://www.cqgma.org/api/spots/25/"
|
||||||
# GMA spots don't contain the details of the programme they are for, we need a separate lookup for that
|
# GMA spots don't contain the details of the programme they are for, we need a separate lookup for that
|
||||||
@@ -36,9 +37,11 @@ class GMA(HTTPSpotProvider):
|
|||||||
sig_refs=[SIGRef(id=source_spot["REF"], sig="", name=source_spot["NAME"])],
|
sig_refs=[SIGRef(id=source_spot["REF"], sig="", name=source_spot["NAME"])],
|
||||||
time=datetime.strptime(source_spot["DATE"] + source_spot["TIME"], "%Y%m%d%H%M").replace(
|
time=datetime.strptime(source_spot["DATE"] + source_spot["TIME"], "%Y%m%d%H%M").replace(
|
||||||
tzinfo=pytz.UTC).timestamp(),
|
tzinfo=pytz.UTC).timestamp(),
|
||||||
dx_latitude=float(source_spot["LAT"]) if (source_spot["LAT"] and source_spot["LAT"] != "") else None,
|
dx_latitude=float(source_spot["LAT"]) if (
|
||||||
|
source_spot["LAT"] and source_spot["LAT"] != "") else None,
|
||||||
# Seen GMA spots with no (or empty) lat/lon
|
# Seen GMA spots with no (or empty) lat/lon
|
||||||
dx_longitude=float(source_spot["LON"]) if (source_spot["LON"] and source_spot["LON"] != "") else None)
|
dx_longitude=float(source_spot["LON"]) if (
|
||||||
|
source_spot["LON"] and source_spot["LON"] != "") else None)
|
||||||
|
|
||||||
# GMA doesn't give what programme (SIG) the reference is for until we separately look it up.
|
# GMA doesn't give what programme (SIG) the reference is for until we separately look it up.
|
||||||
if "REF" in source_spot:
|
if "REF" in source_spot:
|
||||||
@@ -83,5 +86,6 @@ class GMA(HTTPSpotProvider):
|
|||||||
# that for us.
|
# that for us.
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
except:
|
except:
|
||||||
logging.warn("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot["REF"] + ", ignoring this spot for now")
|
logging.warn("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot[
|
||||||
|
"REF"] + ", ignoring this spot for now")
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ from data.spot import Spot
|
|||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for HuMPs Excluding Marilyns Award
|
|
||||||
class HEMA(HTTPSpotProvider):
|
class HEMA(HTTPSpotProvider):
|
||||||
|
"""Spot provider for HuMPs Excluding Marilyns Award"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 300
|
POLL_INTERVAL_SEC = 300
|
||||||
# HEMA wants us to check for a "spot seed" from the API and see if it's actually changed before querying the main
|
# HEMA wants us to check for a "spot seed" from the API and see if it's actually changed before querying the main
|
||||||
# data API. So it's actually the SPOT_SEED_URL that we pass into the constructor and get the superclass to call on a
|
# data API. So it's actually the SPOT_SEED_URL that we pass into the constructor and get the superclass to call on a
|
||||||
@@ -54,7 +55,8 @@ class HEMA(HTTPSpotProvider):
|
|||||||
comment=spotter_comment_match.group(2),
|
comment=spotter_comment_match.group(2),
|
||||||
sig="HEMA",
|
sig="HEMA",
|
||||||
sig_refs=[SIGRef(id=spot_items[3].upper(), sig="HEMA", name=spot_items[4])],
|
sig_refs=[SIGRef(id=spot_items[3].upper(), sig="HEMA", name=spot_items[4])],
|
||||||
time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M").replace(tzinfo=pytz.UTC).timestamp(),
|
time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M").replace(
|
||||||
|
tzinfo=pytz.UTC).timestamp(),
|
||||||
dx_latitude=float(spot_items[7]),
|
dx_latitude=float(spot_items[7]),
|
||||||
dx_longitude=float(spot_items[8]))
|
dx_longitude=float(spot_items[8]))
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ from core.constants import HTTP_HEADERS
|
|||||||
from spotproviders.spot_provider import SpotProvider
|
from spotproviders.spot_provider import SpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Generic spot provider class for providers that request data via HTTP(S). Just for convenience to avoid code
|
|
||||||
# duplication. Subclasses of this query the individual APIs for data.
|
|
||||||
class HTTPSpotProvider(SpotProvider):
|
class HTTPSpotProvider(SpotProvider):
|
||||||
|
"""Generic spot provider class for providers that request data via HTTP(S). Just for convenience to avoid code
|
||||||
|
duplication. Subclasses of this query the individual APIs for data."""
|
||||||
|
|
||||||
def __init__(self, provider_config, url, poll_interval):
|
def __init__(self, provider_config, url, poll_interval):
|
||||||
super().__init__(provider_config)
|
super().__init__(provider_config)
|
||||||
@@ -55,8 +55,9 @@ class HTTPSpotProvider(SpotProvider):
|
|||||||
logging.exception("Exception in HTTP JSON Spot Provider (" + self.name + ")")
|
logging.exception("Exception in HTTP JSON Spot Provider (" + self.name + ")")
|
||||||
self._stop_event.wait(timeout=1)
|
self._stop_event.wait(timeout=1)
|
||||||
|
|
||||||
# Convert an HTTP response returned by the API into spot data. The whole response is provided here so the subclass
|
|
||||||
# implementations can check for HTTP status codes if necessary, and handle the response as JSON, XML, text, whatever
|
|
||||||
# the API actually provides.
|
|
||||||
def http_response_to_spots(self, http_response):
|
def http_response_to_spots(self, http_response):
|
||||||
|
"""Convert an HTTP response returned by the API into spot data. The whole response is provided here so the subclass
|
||||||
|
implementations can check for HTTP status codes if necessary, and handle the response as JSON, XML, text, whatever
|
||||||
|
the API actually provides."""
|
||||||
|
|
||||||
raise NotImplementedError("Subclasses must implement this method")
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
@@ -5,8 +5,9 @@ from data.spot import Spot
|
|||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for Lagos y Lagunas On the Air
|
|
||||||
class LLOTA(HTTPSpotProvider):
|
class LLOTA(HTTPSpotProvider):
|
||||||
|
"""Spot provider for Lagos y Lagunas On the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
SPOTS_URL = "https://llota.app/api/public/spots"
|
SPOTS_URL = "https://llota.app/api/public/spots"
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ from data.spot import Spot
|
|||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for Parks n Peaks
|
|
||||||
class ParksNPeaks(HTTPSpotProvider):
|
class ParksNPeaks(HTTPSpotProvider):
|
||||||
|
"""Spot provider for Parks n Peaks"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
SPOTS_URL = "https://www.parksnpeaks.org/api/ALL"
|
SPOTS_URL = "https://www.parksnpeaks.org/api/ALL"
|
||||||
SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv"
|
SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv"
|
||||||
@@ -26,7 +27,8 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
spot = Spot(source=self.name,
|
spot = Spot(source=self.name,
|
||||||
source_id=source_spot["actID"],
|
source_id=source_spot["actID"],
|
||||||
dx_call=source_spot["actCallsign"].upper(),
|
dx_call=source_spot["actCallsign"].upper(),
|
||||||
de_call=source_spot["actSpoter"].upper() if source_spot["actSpoter"] != "" else None, # typo exists in API
|
de_call=source_spot["actSpoter"].upper() if source_spot["actSpoter"] != "" else None,
|
||||||
|
# typo exists in API
|
||||||
freq=float(source_spot["actFreq"].replace(",", "")) * 1000000 if (
|
freq=float(source_spot["actFreq"].replace(",", "")) * 1000000 if (
|
||||||
source_spot["actFreq"] != "") else None,
|
source_spot["actFreq"] != "") else None,
|
||||||
# Seen PNP spots with empty frequency, and with comma-separated thousands digits
|
# Seen PNP spots with empty frequency, and with comma-separated thousands digits
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ from data.spot import Spot
|
|||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for Parks on the Air
|
|
||||||
class POTA(HTTPSpotProvider):
|
class POTA(HTTPSpotProvider):
|
||||||
|
"""Spot provider for Parks on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
SPOTS_URL = "https://api.pota.app/spot/activator"
|
SPOTS_URL = "https://api.pota.app/spot/activator"
|
||||||
|
|
||||||
|
|||||||
@@ -12,17 +12,19 @@ from data.spot import Spot
|
|||||||
from spotproviders.spot_provider import SpotProvider
|
from spotproviders.spot_provider import SpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for the Reverse Beacon Network. Connects to a single port, if you want both CW/RTTY (port 7000) and FT8
|
|
||||||
# (port 7001) you need to instantiate two copies of this. The port is provided as an argument to the constructor.
|
|
||||||
class RBN(SpotProvider):
|
class RBN(SpotProvider):
|
||||||
|
"""Spot provider for the Reverse Beacon Network. Connects to a single port, if you want both CW/RTTY (port 7000) and FT8
|
||||||
|
(port 7001) you need to instantiate two copies of this. The port is provided as an argument to the constructor."""
|
||||||
|
|
||||||
CALLSIGN_PATTERN = "([a-z|0-9|/]+)"
|
CALLSIGN_PATTERN = "([a-z|0-9|/]+)"
|
||||||
FREQUENCY_PATTERM = "([0-9|.]+)"
|
FREQUENCY_PATTERM = "([0-9|.]+)"
|
||||||
LINE_PATTERN = re.compile(
|
LINE_PATTERN = re.compile(
|
||||||
"^DX de " + CALLSIGN_PATTERN + "-.*:\\s+" + FREQUENCY_PATTERM + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)",
|
"^DX de " + CALLSIGN_PATTERN + "-.*:\\s+" + FREQUENCY_PATTERM + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)",
|
||||||
re.IGNORECASE)
|
re.IGNORECASE)
|
||||||
|
|
||||||
# Constructor requires port number.
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
|
"""Constructor requires port number."""
|
||||||
|
|
||||||
super().__init__(provider_config)
|
super().__init__(provider_config)
|
||||||
self.port = provider_config["port"]
|
self.port = provider_config["port"]
|
||||||
self.telnet = None
|
self.telnet = None
|
||||||
@@ -30,7 +32,6 @@ class RBN(SpotProvider):
|
|||||||
self.thread.daemon = True
|
self.thread.daemon = True
|
||||||
self.run = True
|
self.run = True
|
||||||
|
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
self.thread.start()
|
self.thread.start()
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ from data.spot import Spot
|
|||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for Summits on the Air
|
|
||||||
class SOTA(HTTPSpotProvider):
|
class SOTA(HTTPSpotProvider):
|
||||||
|
"""Spot provider for Summits on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
# SOTA wants us to check for an "epoch" from the API and see if it's actually changed before querying the main data
|
# SOTA wants us to check for an "epoch" from the API and see if it's actually changed before querying the main data
|
||||||
# APIs. So it's actually the EPOCH_URL that we pass into the constructor and get the superclass to call on a timer.
|
# APIs. So it's actually the EPOCH_URL that we pass into the constructor and get the superclass to call on a timer.
|
||||||
@@ -41,11 +42,14 @@ class SOTA(HTTPSpotProvider):
|
|||||||
dx_call=source_spot["activatorCallsign"].upper(),
|
dx_call=source_spot["activatorCallsign"].upper(),
|
||||||
dx_name=source_spot["activatorName"],
|
dx_name=source_spot["activatorName"],
|
||||||
de_call=source_spot["callsign"].upper(),
|
de_call=source_spot["callsign"].upper(),
|
||||||
freq=(float(source_spot["frequency"]) * 1000000) if (source_spot["frequency"] is not None) else None, # Seen SOTA spots with no frequency!
|
freq=(float(source_spot["frequency"]) * 1000000) if (
|
||||||
|
source_spot["frequency"] is not None) else None,
|
||||||
|
# Seen SOTA spots with no frequency!
|
||||||
mode=source_spot["mode"].upper(),
|
mode=source_spot["mode"].upper(),
|
||||||
comment=source_spot["comments"],
|
comment=source_spot["comments"],
|
||||||
sig="SOTA",
|
sig="SOTA",
|
||||||
sig_refs=[SIGRef(id=source_spot["summitCode"], sig="SOTA", name=source_spot["summitName"], activation_score=source_spot["points"])],
|
sig_refs=[SIGRef(id=source_spot["summitCode"], sig="SOTA", name=source_spot["summitName"],
|
||||||
|
activation_score=source_spot["points"])],
|
||||||
time=datetime.fromisoformat(source_spot["timeStamp"].replace("Z", "+00:00")).timestamp())
|
time=datetime.fromisoformat(source_spot["timeStamp"].replace("Z", "+00:00")).timestamp())
|
||||||
|
|
||||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import pytz
|
|||||||
from core.config import MAX_SPOT_AGE
|
from core.config import MAX_SPOT_AGE
|
||||||
|
|
||||||
|
|
||||||
# Generic spot provider class. Subclasses of this query the individual APIs for data.
|
|
||||||
class SpotProvider:
|
class SpotProvider:
|
||||||
|
"""Generic spot provider class. Subclasses of this query the individual APIs for data."""
|
||||||
|
|
||||||
# Constructor
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
|
"""Constructor"""
|
||||||
|
|
||||||
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)
|
||||||
@@ -18,20 +19,23 @@ class SpotProvider:
|
|||||||
self.spots = None
|
self.spots = None
|
||||||
self.web_server = None
|
self.web_server = None
|
||||||
|
|
||||||
# Set up the provider, e.g. giving it the spot list to work from
|
|
||||||
def setup(self, spots, web_server):
|
def setup(self, spots, web_server):
|
||||||
|
"""Set up the provider, e.g. giving it the spot list to work from"""
|
||||||
|
|
||||||
self.spots = spots
|
self.spots = spots
|
||||||
self.web_server = web_server
|
self.web_server = web_server
|
||||||
|
|
||||||
# Start the provider. This should return immediately after spawning threads to access the remote resources
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
|
||||||
|
|
||||||
raise NotImplementedError("Subclasses must implement this method")
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
|
|
||||||
# Submit a batch of spots retrieved from the provider. Only spots that are newer than the last spot retrieved
|
|
||||||
# by this provider will be added to the spot list, to prevent duplications. Spots passing the check will also have
|
|
||||||
# their infer_missing() method called to complete their data set. This is called by the API-querying
|
|
||||||
# subclasses on receiving spots.
|
|
||||||
def submit_batch(self, spots):
|
def submit_batch(self, spots):
|
||||||
|
"""Submit a batch of spots retrieved from the provider. Only spots that are newer than the last spot retrieved
|
||||||
|
by this provider will be added to the spot list, to prevent duplications. Spots passing the check will also have
|
||||||
|
their infer_missing() method called to complete their data set. This is called by the API-querying
|
||||||
|
subclasses on receiving spots."""
|
||||||
|
|
||||||
# Sort the batch so that earliest ones go in first. This helps keep the ordering correct when spots are fired
|
# Sort the batch so that earliest ones go in first. This helps keep the ordering correct when spots are fired
|
||||||
# off to SSE listeners.
|
# off to SSE listeners.
|
||||||
spots = sorted(spots, key=lambda spot: (spot.time if spot and spot.time else 0))
|
spots = sorted(spots, key=lambda spot: (spot.time if spot and spot.time else 0))
|
||||||
@@ -42,10 +46,11 @@ class SpotProvider:
|
|||||||
self.add_spot(spot)
|
self.add_spot(spot)
|
||||||
self.last_spot_time = datetime.fromtimestamp(max(map(lambda s: s.time, spots)), pytz.UTC)
|
self.last_spot_time = datetime.fromtimestamp(max(map(lambda s: s.time, spots)), pytz.UTC)
|
||||||
|
|
||||||
# Submit a single spot retrieved from the provider. This will be added to the list regardless of its age. Spots
|
|
||||||
# passing the check will also have their infer_missing() method called to complete their data set. This is called by
|
|
||||||
# the data streaming subclasses, which can be relied upon not to re-provide old spots.
|
|
||||||
def submit(self, spot):
|
def submit(self, spot):
|
||||||
|
"""Submit a single spot retrieved from the provider. This will be added to the list regardless of its age. Spots
|
||||||
|
passing the check will also have their infer_missing() method called to complete their data set. This is called by
|
||||||
|
the data streaming subclasses, which can be relied upon not to re-provide old spots."""
|
||||||
|
|
||||||
# Fill in any blanks and add to the list
|
# Fill in any blanks and add to the list
|
||||||
spot.infer_missing()
|
spot.infer_missing()
|
||||||
self.add_spot(spot)
|
self.add_spot(spot)
|
||||||
@@ -58,6 +63,7 @@ class SpotProvider:
|
|||||||
if self.web_server:
|
if self.web_server:
|
||||||
self.web_server.notify_new_spot(spot)
|
self.web_server.notify_new_spot(spot)
|
||||||
|
|
||||||
# Stop any threads and prepare for application shutdown
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
"""Stop any threads and prepare for application shutdown"""
|
||||||
|
|
||||||
raise NotImplementedError("Subclasses must implement this method")
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
@@ -10,8 +10,8 @@ from core.constants import HTTP_HEADERS
|
|||||||
from spotproviders.spot_provider import SpotProvider
|
from spotproviders.spot_provider import SpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider using Server-Sent Events.
|
|
||||||
class SSESpotProvider(SpotProvider):
|
class SSESpotProvider(SpotProvider):
|
||||||
|
"""Spot provider using Server-Sent Events."""
|
||||||
|
|
||||||
def __init__(self, provider_config, url):
|
def __init__(self, provider_config, url):
|
||||||
super().__init__(provider_config)
|
super().__init__(provider_config)
|
||||||
@@ -62,7 +62,8 @@ class SSESpotProvider(SpotProvider):
|
|||||||
logging.debug("Received data from " + self.name + " spot API.")
|
logging.debug("Received data from " + self.name + " spot API.")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception("Exception processing message from SSE Spot Provider (" + self.name + ")")
|
logging.exception(
|
||||||
|
"Exception processing message from SSE Spot Provider (" + self.name + ")")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
@@ -71,7 +72,8 @@ class SSESpotProvider(SpotProvider):
|
|||||||
self.status = "Disconnected"
|
self.status = "Disconnected"
|
||||||
sleep(5) # Wait before trying to reconnect
|
sleep(5) # Wait before trying to reconnect
|
||||||
|
|
||||||
# Convert an SSE message received from the API into a spot. The whole message data is provided here so the subclass
|
|
||||||
# implementations can handle the message as JSON, XML, text, whatever the API actually provides.
|
|
||||||
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
|
||||||
|
implementations can handle the message as JSON, XML, text, whatever the API actually provides."""
|
||||||
|
|
||||||
raise NotImplementedError("Subclasses must implement this method")
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
@@ -7,8 +7,9 @@ from data.spot import Spot
|
|||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for UK Packet Radio network API
|
|
||||||
class UKPacketNet(HTTPSpotProvider):
|
class UKPacketNet(HTTPSpotProvider):
|
||||||
|
"""Spot provider for UK Packet Radio network API"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 600
|
POLL_INTERVAL_SEC = 600
|
||||||
SPOTS_URL = "https://nodes.ukpacketradio.network/api/nodedata"
|
SPOTS_URL = "https://nodes.ukpacketradio.network/api/nodedata"
|
||||||
|
|
||||||
@@ -35,20 +36,26 @@ class UKPacketNet(HTTPSpotProvider):
|
|||||||
# First build a "full" comment combining some of the extra info
|
# First build a "full" comment combining some of the extra info
|
||||||
comment = listed_port["comment"] if "comment" in listed_port else ""
|
comment = listed_port["comment"] if "comment" in listed_port else ""
|
||||||
comment = (comment + " " + listed_port["mode"]) if "mode" in listed_port else comment
|
comment = (comment + " " + listed_port["mode"]) if "mode" in listed_port else comment
|
||||||
comment = (comment + " " + listed_port["modulation"]) if "modulation" in listed_port else comment
|
comment = (comment + " " + listed_port[
|
||||||
comment = (comment + " " + str(listed_port["baud"]) + " baud") if "baud" in listed_port and listed_port["baud"] > 0 else comment
|
"modulation"]) if "modulation" in listed_port else comment
|
||||||
|
comment = (comment + " " + str(
|
||||||
|
listed_port["baud"]) + " baud") if "baud" in listed_port and listed_port[
|
||||||
|
"baud"] > 0 else comment
|
||||||
|
|
||||||
# Get frequency from the comment if it's not set properly in the data structure. This is
|
# Get frequency from the comment if it's not set properly in the data structure. This is
|
||||||
# very hacky but a lot of node comments contain their frequency as the first or second
|
# very hacky but a lot of node comments contain their frequency as the first or second
|
||||||
# word of their comment, but not in the proper data structure field.
|
# word of their comment, but not in the proper data structure field.
|
||||||
freq = listed_port["freq"] if "freq" in listed_port and listed_port["freq"] > 0 else None
|
freq = listed_port["freq"] if "freq" in listed_port and listed_port[
|
||||||
|
"freq"] > 0 else None
|
||||||
if not freq and comment:
|
if not freq and comment:
|
||||||
possible_freq = comment.split(" ")[0].upper().replace("MHZ", "")
|
possible_freq = comment.split(" ")[0].upper().replace("MHZ", "")
|
||||||
if re.match(r"^[0-9.]+$", possible_freq) and possible_freq != "1200" and possible_freq != "9600":
|
if re.match(r"^[0-9.]+$",
|
||||||
|
possible_freq) and possible_freq != "1200" and possible_freq != "9600":
|
||||||
freq = float(possible_freq) * 1000000
|
freq = float(possible_freq) * 1000000
|
||||||
if not freq and len(comment.split(" ")) > 1:
|
if not freq and len(comment.split(" ")) > 1:
|
||||||
possible_freq = comment.split(" ")[1].upper().replace("MHZ", "")
|
possible_freq = comment.split(" ")[1].upper().replace("MHZ", "")
|
||||||
if re.match(r"^[0-9.]+$", possible_freq) and possible_freq != "1200" and possible_freq != "9600":
|
if re.match(r"^[0-9.]+$",
|
||||||
|
possible_freq) and possible_freq != "1200" and possible_freq != "9600":
|
||||||
freq = float(possible_freq) * 1000000
|
freq = float(possible_freq) * 1000000
|
||||||
# Check for a found frequency likely having been in kHz, sorry to all GHz packet folks
|
# Check for a found frequency likely having been in kHz, sorry to all GHz packet folks
|
||||||
if freq and freq > 1000000000:
|
if freq and freq > 1000000000:
|
||||||
@@ -61,8 +68,10 @@ class UKPacketNet(HTTPSpotProvider):
|
|||||||
freq=freq,
|
freq=freq,
|
||||||
mode="PKT",
|
mode="PKT",
|
||||||
comment=comment,
|
comment=comment,
|
||||||
time=datetime.strptime(heard["lastHeard"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp(),
|
time=datetime.strptime(heard["lastHeard"], "%Y-%m-%d %H:%M:%S").replace(
|
||||||
de_grid=node["location"]["locator"] if "locator" in node["location"] else None,
|
tzinfo=pytz.UTC).timestamp(),
|
||||||
|
de_grid=node["location"]["locator"] if "locator" in node[
|
||||||
|
"location"] else None,
|
||||||
de_latitude=node["location"]["coords"]["lat"],
|
de_latitude=node["location"]["coords"]["lat"],
|
||||||
de_longitude=node["location"]["coords"]["lon"])
|
de_longitude=node["location"]["coords"]["lon"])
|
||||||
|
|
||||||
@@ -77,7 +86,8 @@ class UKPacketNet(HTTPSpotProvider):
|
|||||||
# data, and we can use that to look these up.
|
# data, and we can use that to look these up.
|
||||||
for spot in new_spots:
|
for spot in new_spots:
|
||||||
if spot.dx_call in nodes:
|
if spot.dx_call in nodes:
|
||||||
spot.dx_grid = nodes[spot.dx_call]["location"]["locator"] if "locator" in nodes[spot.dx_call]["location"] else None
|
spot.dx_grid = nodes[spot.dx_call]["location"]["locator"] if "locator" in nodes[spot.dx_call][
|
||||||
|
"location"] else None
|
||||||
spot.dx_latitude = nodes[spot.dx_call]["location"]["coords"]["lat"]
|
spot.dx_latitude = nodes[spot.dx_call]["location"]["coords"]["lat"]
|
||||||
spot.dx_longitude = nodes[spot.dx_call]["location"]["coords"]["lon"]
|
spot.dx_longitude = nodes[spot.dx_call]["location"]["coords"]["lon"]
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ from core.constants import HTTP_HEADERS
|
|||||||
from spotproviders.spot_provider import SpotProvider
|
from spotproviders.spot_provider import SpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider using websockets.
|
|
||||||
class WebsocketSpotProvider(SpotProvider):
|
class WebsocketSpotProvider(SpotProvider):
|
||||||
|
"""Spot provider using websockets."""
|
||||||
|
|
||||||
def __init__(self, provider_config, url):
|
def __init__(self, provider_config, url):
|
||||||
super().__init__(provider_config)
|
super().__init__(provider_config)
|
||||||
@@ -60,7 +60,8 @@ class WebsocketSpotProvider(SpotProvider):
|
|||||||
logging.debug("Received data from " + self.name + " spot API.")
|
logging.debug("Received data from " + self.name + " spot API.")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.exception("Exception processing message from Websocket Spot Provider (" + self.name + ")")
|
logging.exception(
|
||||||
|
"Exception processing message from Websocket Spot Provider (" + self.name + ")")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
@@ -69,7 +70,8 @@ class WebsocketSpotProvider(SpotProvider):
|
|||||||
self.status = "Disconnected"
|
self.status = "Disconnected"
|
||||||
sleep(5) # Wait before trying to reconnect
|
sleep(5) # Wait before trying to reconnect
|
||||||
|
|
||||||
# Convert a WS message received from the API into a spot. The exact message data (in bytes) is provided here so the
|
|
||||||
# subclass implementations can handle the message as string, JSON, XML, whatever the API actually provides.
|
|
||||||
def ws_message_to_spot(self, bytes):
|
def ws_message_to_spot(self, bytes):
|
||||||
|
"""Convert a WS message received from the API into a spot. The exact message data (in bytes) is provided here so the
|
||||||
|
subclass implementations can handle the message as string, JSON, XML, whatever the API actually provides."""
|
||||||
|
|
||||||
raise NotImplementedError("Subclasses must implement this method")
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
@@ -10,8 +10,9 @@ from data.spot import Spot
|
|||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for Wainwrights on the Air
|
|
||||||
class WOTA(HTTPSpotProvider):
|
class WOTA(HTTPSpotProvider):
|
||||||
|
"""Spot provider for Wainwrights on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
SPOTS_URL = "https://www.wota.org.uk/spots_rss.php"
|
SPOTS_URL = "https://www.wota.org.uk/spots_rss.php"
|
||||||
LIST_URL = "https://www.wota.org.uk/mapping/data/summits.json"
|
LIST_URL = "https://www.wota.org.uk/mapping/data/summits.json"
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ from data.spot import Spot
|
|||||||
from spotproviders.sse_spot_provider import SSESpotProvider
|
from spotproviders.sse_spot_provider import SSESpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for Worldwide Bunkers on the Air
|
|
||||||
class WWBOTA(SSESpotProvider):
|
class WWBOTA(SSESpotProvider):
|
||||||
|
"""Spot provider for Worldwide Bunkers on the Air"""
|
||||||
|
|
||||||
SPOTS_URL = "https://api.wwbota.net/spots/"
|
SPOTS_URL = "https://api.wwbota.net/spots/"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ from data.spot import Spot
|
|||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for Worldwide Flora & Fauna
|
|
||||||
class WWFF(HTTPSpotProvider):
|
class WWFF(HTTPSpotProvider):
|
||||||
|
"""Spot provider for Worldwide Flora & Fauna"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
SPOTS_URL = "https://spots.wwff.co/static/spots.json"
|
SPOTS_URL = "https://spots.wwff.co/static/spots.json"
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import pytz
|
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for Towers on the Air
|
|
||||||
class WWTOTA(HTTPSpotProvider):
|
class WWTOTA(HTTPSpotProvider):
|
||||||
|
"""Spot provider for Towers on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
SPOTS_URL = "https://wwtota.com/api/cluster_live.php"
|
SPOTS_URL = "https://wwtota.com/api/cluster_live.php"
|
||||||
|
|
||||||
@@ -33,7 +33,8 @@ class WWTOTA(HTTPSpotProvider):
|
|||||||
comment=source_spot["comment"],
|
comment=source_spot["comment"],
|
||||||
sig="WWTOTA",
|
sig="WWTOTA",
|
||||||
sig_refs=[SIGRef(id=source_spot["ref"], sig="WWTOTA")],
|
sig_refs=[SIGRef(id=source_spot["ref"], sig="WWTOTA")],
|
||||||
time=datetime.strptime(response_json["updated"][:10] + source_spot["time"], "%Y-%m-%d%H:%M").timestamp())
|
time=datetime.strptime(response_json["updated"][:10] + source_spot["time"],
|
||||||
|
"%Y-%m-%d%H:%M").timestamp())
|
||||||
|
|
||||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
||||||
# that for us.
|
# that for us.
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ from data.spot import Spot
|
|||||||
from spotproviders.websocket_spot_provider import WebsocketSpotProvider
|
from spotproviders.websocket_spot_provider import WebsocketSpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for servers based on the "xOTA" software at https://github.com/nischu/xOTA/
|
|
||||||
# The provider typically doesn't give us a lat/lon or SIG explicitly, so our own config provides a SIG and a reference
|
|
||||||
# to a local CSV file with location information. This functionality is implemented for TOTA events, of which there are
|
|
||||||
# several - so a plain lookup of a "TOTA reference" doesn't make sense, it depends on which TOTA and hence which server
|
|
||||||
# supplied the data, which is why the CSV location lookup is here and not in sig_utils.
|
|
||||||
class XOTA(WebsocketSpotProvider):
|
class XOTA(WebsocketSpotProvider):
|
||||||
|
"""Spot provider for servers based on the "xOTA" software at https://github.com/nischu/xOTA/
|
||||||
|
The provider typically doesn't give us a lat/lon or SIG explicitly, so our own config provides a SIG and a reference
|
||||||
|
to a local CSV file with location information. This functionality is implemented for TOTA events, of which there are
|
||||||
|
several - so a plain lookup of a "TOTA reference" doesn't make sense, it depends on which TOTA and hence which server
|
||||||
|
supplied the data, which is why the CSV location lookup is here and not in sig_utils."""
|
||||||
|
|
||||||
LOCATION_DATA = {}
|
LOCATION_DATA = {}
|
||||||
SIG = None
|
SIG = None
|
||||||
|
|
||||||
@@ -47,7 +48,8 @@ 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, longitude=lon)],
|
sig_refs=[SIGRef(id=ref_id, sig=self.SIG, url=source_spot["reference"]["website"], latitude=lat,
|
||||||
|
longitude=lon)],
|
||||||
time=datetime.now(pytz.UTC).timestamp(),
|
time=datetime.now(pytz.UTC).timestamp(),
|
||||||
dx_latitude=lat,
|
dx_latitude=lat,
|
||||||
dx_longitude=lon,
|
dx_longitude=lon,
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ from data.spot import Spot
|
|||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for ZLOTA
|
|
||||||
class ZLOTA(HTTPSpotProvider):
|
class ZLOTA(HTTPSpotProvider):
|
||||||
|
"""Spot provider for ZLOTA"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
SPOTS_URL = "https://ontheair.nz/api/spots?zlota_only=true"
|
SPOTS_URL = "https://ontheair.nz/api/spots?zlota_only=true"
|
||||||
LIST_URL = "https://ontheair.nz/assets/assets.json"
|
LIST_URL = "https://ontheair.nz/assets/assets.json"
|
||||||
@@ -35,7 +36,8 @@ class ZLOTA(HTTPSpotProvider):
|
|||||||
comment=source_spot["comments"],
|
comment=source_spot["comments"],
|
||||||
sig="ZLOTA",
|
sig="ZLOTA",
|
||||||
sig_refs=[SIGRef(id=source_spot["reference"], sig="ZLOTA", name=source_spot["name"])],
|
sig_refs=[SIGRef(id=source_spot["reference"], sig="ZLOTA", name=source_spot["name"])],
|
||||||
time=datetime.fromisoformat(source_spot["referenced_time"].replace("Z", "+00:00")).astimezone(pytz.UTC).timestamp())
|
time=datetime.fromisoformat(source_spot["referenced_time"].replace("Z", "+00:00")).astimezone(
|
||||||
|
pytz.UTC).timestamp())
|
||||||
|
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
<p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p>
|
<p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=1772180923"></script>
|
<script src="/js/common.js?v=1772202095"></script>
|
||||||
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
|
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||||
|
|
||||||
{% end %}
|
{% end %}
|
||||||
@@ -69,8 +69,8 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=1772180923"></script>
|
<script src="/js/common.js?v=1772202095"></script>
|
||||||
<script src="/js/add-spot.js?v=1772180923"></script>
|
<script src="/js/add-spot.js?v=1772202095"></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 %}
|
||||||
@@ -56,8 +56,8 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=1772180923"></script>
|
<script src="/js/common.js?v=1772202096"></script>
|
||||||
<script src="/js/alerts.js?v=1772180923"></script>
|
<script src="/js/alerts.js?v=1772202096"></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 %}
|
||||||
@@ -62,9 +62,9 @@
|
|||||||
<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/common.js?v=1772180923"></script>
|
<script src="/js/common.js?v=1772202095"></script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=1772180923"></script>
|
<script src="/js/spotsbandsandmap.js?v=1772202095"></script>
|
||||||
<script src="/js/bands.js?v=1772180923"></script>
|
<script src="/js/bands.js?v=1772202095"></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 %}
|
||||||
@@ -46,10 +46,10 @@
|
|||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/tinycolor2@1.6.0/cjs/tinycolor.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/tinycolor2@1.6.0/cjs/tinycolor.min.js"></script>
|
||||||
|
|
||||||
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1772180923"></script>
|
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1772202095"></script>
|
||||||
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=1772180923"></script>
|
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=1772202095"></script>
|
||||||
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1772180923"></script>
|
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1772202095"></script>
|
||||||
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1772180923"></script>
|
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1772202095"></script>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -70,9 +70,9 @@
|
|||||||
<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/common.js?v=1772180923"></script>
|
<script src="/js/common.js?v=1772202096"></script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=1772180923"></script>
|
<script src="/js/spotsbandsandmap.js?v=1772202096"></script>
|
||||||
<script src="/js/map.js?v=1772180923"></script>
|
<script src="/js/map.js?v=1772202096"></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 %}
|
||||||
@@ -87,9 +87,9 @@
|
|||||||
<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/common.js?v=1772180923"></script>
|
<script src="/js/common.js?v=1772202095"></script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=1772180923"></script>
|
<script src="/js/spotsbandsandmap.js?v=1772202095"></script>
|
||||||
<script src="/js/spots.js?v=1772180923"></script>
|
<script src="/js/spots.js?v=1772202095"></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 %}
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
|
|
||||||
<div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div>
|
<div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=1772180923"></script>
|
<script src="/js/common.js?v=1772202095"></script>
|
||||||
<script src="/js/status.js?v=1772180923"></script>
|
<script src="/js/status.js?v=1772202095"></script>
|
||||||
<script>$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --></script>
|
<script>$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||||
|
|
||||||
{% end %}
|
{% end %}
|
||||||
@@ -17,7 +17,8 @@ for dxcc in data["dxcc"]:
|
|||||||
flag = dxcc["flag"]
|
flag = dxcc["flag"]
|
||||||
image = Image.new("RGBA", (140, 110), (255, 0, 0, 0))
|
image = Image.new("RGBA", (140, 110), (255, 0, 0, 0))
|
||||||
draw = ImageDraw.Draw(image)
|
draw = ImageDraw.Draw(image)
|
||||||
draw.text((0, -10), flag, font=ImageFont.truetype("/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf", 109), embedded_color=True)
|
draw.text((0, -10), flag, font=ImageFont.truetype("/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf", 109),
|
||||||
|
embedded_color=True)
|
||||||
outfile = str(id) + ".png"
|
outfile = str(id) + ".png"
|
||||||
image.save(outfile, "PNG")
|
image.save(outfile, "PNG")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user