83 changed files with 715 additions and 1506 deletions
+5 -8
View File
@@ -92,7 +92,7 @@ Various approaches exist to writing your own client, but in general:
* Refer to the API docs. These are built on an OpenAPI definition file (`/static/apidocs/openapi.yml`), which you can * Refer to the API docs. These are built on an OpenAPI definition file (`/static/apidocs/openapi.yml`), which you can
automatically use to generate a client skeleton using various software. automatically use to generate a client skeleton using various software.
* Call the main "spots" or "alerts" API endpoints to get the data you want. For example, your app could call * Call the main "spots" or "alerts" API endpoints to get the data you want. For example, your app could call
`https://spothole.app/api/v2/spots` once every few minutes. Apply filters if necessary. `https://spothole.app/api/v1/spots` once every few minutes. Apply filters if necessary.
* Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do that * Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do that
first before calling the spots/alerts APIs, to allow you to populate your filters correctly. first before calling the spots/alerts APIs, to allow you to populate your filters correctly.
* Refer to the provided HTML/JS interface for a reference on different approaches. For example, the "alerts"/"upcoming" * Refer to the provided HTML/JS interface for a reference on different approaches. For example, the "alerts"/"upcoming"
@@ -105,12 +105,12 @@ once every two minutes, so if your client is interested in POTA data there's no
than that. than that.
If you absolutely must be informed within seconds of a spot arriving in Spothole, please use the SSE endpoints instead, If you absolutely must be informed within seconds of a spot arriving in Spothole, please use the SSE endpoints instead,
e.g. `https://spothole.app/api/v2/spots/stream`. e.g. `https://spothole.app/api/v1/spots/stream`.
If you want to handle different types of spot or alert differently within your client, please consider making a single If you want to handle different types of spot or alert differently within your client, please consider making a single
request to the Spothole API to retrieve all the data, then filtering on your side. For example, call request to the Spothole API to retrieve all the data, then filtering on your side. For example, call
`https://spothole.app/api/v2/spots?sig=POTA,SOTA` rather than making two separate calls to `https://spothole.app/api/v1/spots?sig=POTA,SOTA` rather than making two separate calls to
`https://spothole.app/api/v2/spots?sig=POTA` and `https://spothole.app/api/v2/spots?sig=SOTA`. `https://spothole.app/api/v1/spots?sig=POTA` and `https://spothole.app/api/v1/spots?sig=SOTA`.
Remember, here at Spothole Inc. we offer an industry-standard "five nines" uptime on our server, with our own unique Remember, here at Spothole Inc. we offer an industry-standard "five nines" uptime on our server, with our own unique
twist: we don't tell you which side of the decimal point the nines start! (Translation: This is a hobby project. twist: we don't tell you which side of the decimal point the nines start! (Translation: This is a hobby project.
@@ -354,7 +354,7 @@ server {
} }
# SSE endpoints # SSE endpoints
location ~ ^/api/v2/(spots|alerts)/stream/? { location ~ ^/api/v1/(spots|alerts)/stream/? {
proxy_pass http://127.0.0.1:8080; proxy_pass http://127.0.0.1:8080;
# Remove buffering, remove caching, add suitable timeouts for SSE API calls # Remove buffering, remove caching, add suitable timeouts for SSE API calls
@@ -519,9 +519,6 @@ This project would not have been possible without these libraries, so many thank
A number of third-party libraries are self-hosted in the `/static/vendor/` directory. These files are subject to A number of third-party libraries are self-hosted in the `/static/vendor/` directory. These files are subject to
their own licences and are not covered by the overall licence declared in the `LICENSE` file. their own licences and are not covered by the overall licence declared in the `LICENSE` file.
A number of third-party libraries are self-hosted in the `/webassets/vendor/` directory. These files are subject to
their own licences and are not covered by the overall licence declared in the `LICENSE` file.
Particular thanks go to country-files.com for providing country lookup data for amateur radio, to K0SWE Particular thanks go to country-files.com for providing country lookup data for amateur radio, to K0SWE
for [this JSON-formatted DXCC data](https://github.com/k0swe/dxcc-json/), and to the developers of `pyhamtools` for for [this JSON-formatted DXCC data](https://github.com/k0swe/dxcc-json/), and to the developers of `pyhamtools` for
making it easy to use country-files.com data as well as QRZ.com and Clublog lookup. making it easy to use country-files.com data as well as QRZ.com and Clublog lookup.
+5 -15
View File
@@ -2,27 +2,20 @@ from datetime import datetime
import pytz import pytz
from core.config import MAX_ALERT_AGE from core.data_store import DATA_STORE
class AlertProvider: class AlertProvider:
"""Generic alert provider class. Subclasses of this query the individual APIs for alerts.""" """Generic alert provider class. Subclasses of this query the individual APIs for alerts."""
def __init__(self, provider_config): def __init__(self, name, provider_config):
"""Constructor""" """Constructor"""
self.name = provider_config["name"] self.name = 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)
self.status = "Not Started" if self.enabled else "Disabled" self.status = "Not Started" if self.enabled else "Disabled"
self._alerts = None self._alerts = DATA_STORE.alerts
self._web_server = None
def setup(self, alerts, web_server):
"""Set up the provider, e.g. giving it the alert list to work from"""
self._alerts = alerts
self._web_server = web_server
def start(self): def start(self):
"""Start the provider. This should return immediately after spawning threads to access the remote resources""" """Start the provider. This should return immediately after spawning threads to access the remote resources"""
@@ -44,10 +37,7 @@ class AlertProvider:
def _add_alert(self, alert): def _add_alert(self, alert):
if not alert.expired(): if not alert.expired():
self._alerts.add(alert.id, alert, expire=MAX_ALERT_AGE) self._alerts.set(alert.id, alert)
# Ping the web server in case we have any SSE connections that need to see this immediately
if self._web_server:
self._web_server.notify_new_alert(alert)
def stop(self): def stop(self):
"""Stop any threads and prepare for application shutdown""" """Stop any threads and prepare for application shutdown"""
+1 -1
View File
@@ -15,7 +15,7 @@ class BOTA(HTTPAlertProvider):
ALERTS_URL = "https://www.beachesontheair.com/" ALERTS_URL = "https://www.beachesontheair.com/"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__("BOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response):
new_alerts = [] new_alerts = []
+2 -2
View File
@@ -14,8 +14,8 @@ class HTTPAlertProvider(AlertProvider):
"""Generic alert provider class for providers that request data via HTTP(S). Just for convenience to avoid code """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.""" duplication. Subclasses of this query the individual APIs for data."""
def __init__(self, provider_config, url, poll_interval): def __init__(self, name, provider_config, url, poll_interval):
super().__init__(provider_config) super().__init__(name, provider_config)
self._url = url self._url = url
self._poll_interval = poll_interval self._poll_interval = poll_interval
self._thread = None self._thread = None
+1 -1
View File
@@ -18,7 +18,7 @@ class NG3K(HTTPAlertProvider):
AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE) AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE)
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response):
new_alerts = [] new_alerts = []
+1 -1
View File
@@ -15,7 +15,7 @@ class ParksNPeaks(HTTPAlertProvider):
ALERTS_URL = "https://parksnpeaks.org/api/ALERTS/" ALERTS_URL = "https://parksnpeaks.org/api/ALERTS/"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__("ParksNPeaks", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response):
new_alerts = [] new_alerts = []
+1 -1
View File
@@ -14,7 +14,7 @@ class POTA(HTTPAlertProvider):
ALERTS_URL = "https://api.pota.app/activation" ALERTS_URL = "https://api.pota.app/activation"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__("POTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response):
new_alerts = [] new_alerts = []
+1 -1
View File
@@ -14,7 +14,7 @@ class SOTA(HTTPAlertProvider):
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"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__("SOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response):
new_alerts = [] new_alerts = []
+1 -1
View File
@@ -18,7 +18,7 @@ class WOTA(HTTPAlertProvider):
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z" RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__("WOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response):
new_alerts = [] new_alerts = []
+1 -1
View File
@@ -14,7 +14,7 @@ class WWFF(HTTPAlertProvider):
ALERTS_URL = "https://spots.wwff.co/static/agendas.json" ALERTS_URL = "https://spots.wwff.co/static/agendas.json"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__("WWFF", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response): def _http_response_to_alerts(self, http_response):
new_alerts = [] new_alerts = []
+7 -48
View File
@@ -19,66 +19,55 @@ base-url: "http://localhost:8080"
# Spot providers to use. This is an example set, tailor it to your liking by commenting and uncommenting. # Spot providers to use. This is an example set, tailor it to your liking by commenting and uncommenting.
# RBN and APRS-IS are supported but have such a high data rate, you probably don't want them enabled. # RBN and APRS-IS are supported but have such a high data rate, you probably don't want them enabled.
# Each provider needs a class, a name, and an enabled/disabled state. Some require more config such as hostnames/IP # Each provider needs a class and an enabled/disabled state. Some require more config such as hostnames/IP
# addresses and ports. You can duplicate them if you like, e.g. to support several DX clusters. RBN uses two ports, 7000 # addresses and ports. You can duplicate them if you like, e.g. to support several DX clusters. RBN uses two ports, 7000
# for CW/RTTY and 7001 for FT8, so if you want both, you need two entries, as shown below. # for CW/RTTY and 7001 for FT8, so if you want both, you need two entries, as shown below.
# Feel free to write your own provider classes! There are details in the README. # Feel free to write your own provider classes! There are details in the README.
spot-providers: spot-providers:
- class: "POTA" - class: "POTA"
name: "POTA"
enabled: true enabled: true
- class: "SOTA" - class: "SOTA"
name: "SOTA"
enabled: true enabled: true
- class: "WWFF" - class: "WWFF"
name: "WWFF"
enabled: true enabled: true
- class: "WWBOTA" - class: "WWBOTA"
name: "WWBOTA"
enabled: true enabled: true
- class: "GMA" - class: "GMA"
name: "GMA"
enabled: true enabled: true
# GMA requires an API key to fetch spots. After creating an account on cqgma.org, email support and request one. # GMA requires an API key to fetch spots. After creating an account on cqgma.org, email support and request one.
api-key: "" api-key: ""
- class: "HEMA" - class: "HEMA"
name: "HEMA"
enabled: true enabled: true
- class: "ParksNPeaks" - class: "ParksNPeaks"
name: "ParksNPeaks"
enabled: true enabled: true
- class: "ZLOTA" - class: "ZLOTA"
name: "ZLOTA"
enabled: true enabled: true
- class: "WOTA" - class: "WOTA"
name: "WOTA"
enabled: true enabled: true
- class: "LLOTA" - class: "LLOTA"
name: "LLOTA"
enabled: true enabled: true
- class: "Towers" - class: "Towers"
name: "Towers"
enabled: true enabled: true
- class: "Tiles" - class: "Tiles"
name: "Tiles"
enabled: true enabled: true
- class: "APRSIS" - class: "APRSIS"
name: "APRS-IS"
enabled: false enabled: false
- class: "DXCluster" - class: "DXCluster"
# Clusters have a "name" property in case you want to say which cluster node the data is from, or connect to
# several clusters and name them separately.
name: "HRD Cluster" name: "HRD Cluster"
enabled: true enabled: true
host: "hrd.wa9pie.net" host: "hrd.wa9pie.net"
@@ -99,18 +88,12 @@ spot-providers:
enabled: false enabled: false
host: "w3lpl.net" host: "w3lpl.net"
port: 7373 port: 7373
# Prompt the cluster node gives when asking for a callsign to log in. Varies between cluster node software.
login_prompt: "Please enter your call:" login_prompt: "Please enter your call:"
# Callsign Spothole will use to log into this cluster. Ensure the SSID (e.g. -99) is different to any personal
# connection you might make to this cluster node.
login_callsign: "N0CALL-99" login_callsign: "N0CALL-99"
# Whether to allow RBN spots that come via this cluster. If you don't want RBN spots or you are making a separate
# connection to RBN directly, leave this as False. If you want RBN spots from this cluster, set this to True. (Make
# sure you aren't also separately connecting to RBN directly, otherwise you may get duplicate spots.) Note that not
# all clusters sent RBN spots anyway.
allow_rbn_spots: false allow_rbn_spots: false
- class: "RBN" - class: "RBN"
- # RBN sources have a "name" property in case you want to differentiate between the CW/RTTY and FT8 sources.
name: "RBN CW/RTTY" name: "RBN CW/RTTY"
enabled: false enabled: false
port: 7000 port: 7000
@@ -127,11 +110,12 @@ spot-providers:
enabled-by-default-in-web-ui: false enabled-by-default-in-web-ui: false
- class: "UKPacketNet" - class: "UKPacketNet"
name: "UK Packet Radio Net"
enabled: false enabled: false
enabled-by-default-in-web-ui: false enabled-by-default-in-web-ui: false
- class: "XOTA" - class: "XOTA"
# xOTA sources have a "name" property so you can define what programme it is for, since xOTA is generic software
# for running "something-on-the-air" programmes.
name: "39C3 TOTA" name: "39C3 TOTA"
enabled: false enabled: false
url: "wss://39c3.totawatch.de/api/spot/live" url: "wss://39c3.totawatch.de/api/spot/live"
@@ -152,51 +136,40 @@ spot-providers:
# Alert providers to use. Same setup as the spot providers list above. # Alert providers to use. Same setup as the spot providers list above.
alert-providers: alert-providers:
- class: "POTA" - class: "POTA"
name: "POTA"
enabled: true enabled: true
- class: "SOTA" - class: "SOTA"
name: "SOTA"
enabled: true enabled: true
- class: "WWFF" - class: "WWFF"
name: "WWFF"
enabled: true enabled: true
- class: "ParksNPeaks" - class: "ParksNPeaks"
name: "ParksNPeaks"
enabled: true enabled: true
- class: "WOTA" - class: "WOTA"
name: "WOTA"
enabled: true enabled: true
- class: "BOTA" - class: "BOTA"
name: "BOTA"
enabled: true enabled: true
- class: "NG3K" - class: "NG3K"
name: "NG3K"
enabled: true enabled: true
# Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band # Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band
# conditions, etc.) and make it available via the /api/v2/solar endpoint. # conditions, etc.) and make it available via the /api/v1/solar endpoint.
solar-condition-providers: solar-condition-providers:
- class: "HamQSL" - class: "HamQSL"
name: "HamQSL"
enabled: true enabled: true
- class: "NOAA3dayForecast" - class: "NOAA3dayForecast"
name: "NOAA 3-day Forecast"
enabled: true enabled: true
- class: "GIROIonosonde" - class: "GIROIonosonde"
name: "GIRO Ionosonde Data"
enabled: true enabled: true
- class: "KC2GProp" - class: "KC2GProp"
name: "KC2G Propagation Data"
enabled: true enabled: true
# Maximum time to keep spots and alerts in the system before deleting them. By default, one hour for spots and one week # Maximum time to keep spots and alerts in the system before deleting them. By default, one hour for spots and one week
@@ -211,20 +184,6 @@ clublog-api-key: ""
# Allow submitting spots to the Spothole API? # Allow submitting spots to the Spothole API?
allow-spotting: true allow-spotting: true
# Allow upstream submission of spots to external providers (POTA, SOTA, etc.) via the API?
# Requires allow-spotting to also be true. Set to false to only accept spots into the local
# Spothole database, without forwarding them to any external service.
allow-upstream-spotting: true
# Google reCAPTCHA v2 keys for CAPTCHA protection on upstream spot submission. Both keys must be set to enable CAPTCHA.
# Leave both empty to disable CAPTCHA (e.g. for a private/trusted server) or if allow-spotting is false, in which case
# they will do nothing. Note that with CAPTCHA enabled, this will prevent third-party clients submitting spots through
# Spothole unless the clients are web-based, use the same site key, have their domains enabled in your reCAPTCHA config,
# and of course their user solves the CAPTCHA.
# You can sign up for reCAPTCHA at https://www.google.com/recaptcha/
recaptcha-site-key: ""
recaptcha-secret-key: ""
# Log web requests? Useful to see what your users are requesting in terms of pages and API endpoints, but will fill up # Log web requests? Useful to see what your users are requesting in terms of pages and API endpoints, but will fill up
# your log quickly on a popular server. # your log quickly on a popular server.
log-web-requests: false log-web-requests: false
-27
View File
@@ -1,27 +0,0 @@
import threading
from datetime import timedelta
from requests_cache import CachedSession
# Cache for "semi-static" data such as the locations of parks, CSVs of reference lists, etc.
# This has an expiry time of 30 days, so will re-request from the source after that amount
# of time has passed. This is used throughout Spothole to cache data that does not change
# rapidly. The ThreadSafeSession construct here protects it against some multithreading
# contention weirdness we sometimes used to see on startup where the cache was hammered
# pretty hard. The expanded list of allowable_codes ensures we also cache and return 400-type
# responses, e.g "this SOTA summit ref doesn't actually exist", to avoid hammering remote
# servers for data they've told us they can't provide.
_session = CachedSession("cache/semi_static_url_data_cache", expire_after=timedelta(days=30),
allowable_codes=(200, 400, 401, 403, 404))
_lock = threading.Lock()
class _ThreadSafeSession:
"""Wraps CachedSession with a lock to prevent concurrent SQLite access across threads."""
def get(self, *args, **kwargs):
with _lock:
return _session.get(*args, **kwargs)
SEMI_STATIC_URL_DATA_CACHE = _ThreadSafeSession()
-73
View File
@@ -1,73 +0,0 @@
import logging
from datetime import datetime
from threading import Event, Thread
import pytz
class CleanupTimer:
"""Provides a timed cleanup of the spot list."""
def __init__(self, spots, alerts, web_server, cleanup_interval):
"""Constructor"""
self._spots = spots
self._alerts = alerts
self._web_server = web_server
self._cleanup_interval = cleanup_interval
self.last_cleanup_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Starting"
self._thread = None
self._stop_event = Event()
def start(self):
"""Start the cleanup timer"""
self._thread = Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self):
"""Stop any threads and prepare for application shutdown"""
self._stop_event.set()
def _run(self):
while not self._stop_event.wait(timeout=self._cleanup_interval):
self._cleanup()
def _cleanup(self):
"""Perform cleanup and reschedule next timer"""
try:
# Perform cleanup via letting the data expire
self._spots.expire()
self._alerts.expire()
# Explicitly clean up any spots and alerts that have expired
for i in list(self._spots.iterkeys()):
try:
spot = self._spots[i]
if spot.expired():
self._spots.delete(i)
except KeyError:
# Must have already been deleted, OK with that
pass
for i in list(self._alerts.iterkeys()):
try:
alert = self._alerts[i]
if alert.expired():
self._alerts.delete(i)
except KeyError:
# Must have already been deleted, OK with that
pass
# Clean up web server SSE spot/alert queues
self._web_server.clean_up_sse_queues()
self.status = "OK"
self.last_cleanup_time = datetime.now(pytz.UTC)
except Exception:
self.status = "Error"
logging.exception("Exception in Cleanup thread")
self._stop_event.wait(timeout=1)
+8 -14
View File
@@ -14,17 +14,14 @@ with open("config.yml") as f:
config = yaml.safe_load(f) config = yaml.safe_load(f)
logging.info("Loaded config.") logging.info("Loaded config.")
BASE_URL = config.get("base-url", "http://localhost:8080") BASE_URL = config["base-url"]
MAX_SPOT_AGE = config.get("max-spot-age-sec", 3600) MAX_SPOT_AGE = config["max-spot-age-sec"]
MAX_ALERT_AGE = config.get("max-alert-age-sec", 604800) MAX_ALERT_AGE = config["max-alert-age-sec"]
SERVER_OWNER_CALLSIGN = config.get("server-owner-callsign", "N0CALL") SERVER_OWNER_CALLSIGN = config["server-owner-callsign"]
WEB_SERVER_PORT = config.get("web-server-port", 8080) WEB_SERVER_PORT = config["web-server-port"]
ALLOW_SPOTTING = config.get("allow-spotting", True) ALLOW_SPOTTING = config["allow-spotting"]
ALLOW_UPSTREAM_SPOTTING = config.get("allow-upstream-spotting", True) WEB_UI_OPTIONS = config["web-ui-options"]
WEB_UI_OPTIONS = config.get("web-ui-options", {})
API_ONLY_MODE = config.get("api-only-mode", False) API_ONLY_MODE = config.get("api-only-mode", False)
RECAPTCHA_SECRET_KEY = config.get("recaptcha-secret-key", "")
RECAPTCHA_SITE_KEY = config.get("recaptcha-site-key", "")
LOG_LEVEL = config.get("log-level", "INFO") LOG_LEVEL = config.get("log-level", "INFO")
LOG_WEB_REQUESTS = config.get("log-web-requests", False) LOG_WEB_REQUESTS = config.get("log-web-requests", False)
@@ -33,9 +30,6 @@ LOG_WEB_REQUESTS = config.get("log-web-requests", False)
WEB_UI_OPTIONS["spot-providers-enabled-by-default"] = [p["name"] for p in config["spot-providers"] if p["enabled"] and ( WEB_UI_OPTIONS["spot-providers-enabled-by-default"] = [p["name"] for p in config["spot-providers"] if p["enabled"] and (
"enabled-by-default-in-web-ui" not in p or p["enabled-by-default-in-web-ui"])] "enabled-by-default-in-web-ui" not in p or p["enabled-by-default-in-web-ui"])]
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from # If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
# one of our proviers. We set that to also be enabled by default. We can also include the reCaptcha site key so the UI # one of our proviers. We set that to also be enabled by default.
# can access it.
if ALLOW_SPOTTING: if ALLOW_SPOTTING:
WEB_UI_OPTIONS["spot-providers-enabled-by-default"].append("API") WEB_UI_OPTIONS["spot-providers-enabled-by-default"].append("API")
WEB_UI_OPTIONS["recaptcha-site-key"] = RECAPTCHA_SITE_KEY
WEB_UI_OPTIONS["allow-upstream-spotting"] = ALLOW_SPOTTING and ALLOW_UPSTREAM_SPOTTING
+2 -4
View File
@@ -11,7 +11,7 @@ HAMQTH_PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_C
# Special Interest Groups # Special Interest Groups
SIGS = [ SIGS = [
SIG(name="POTA", comment_names=["POTA"], description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}|K\-TEST"), SIG(name="POTA", comment_names=["POTA"], description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}"),
SIG(name="SOTA", comment_names=["SOTA"], description="Summits on the Air", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"), SIG(name="SOTA", comment_names=["SOTA"], description="Summits on the Air", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
SIG(name="WWFF", comment_names=["WWFF"], description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"), SIG(name="WWFF", comment_names=["WWFF"], description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"),
SIG(name="GMA", comment_names=["GMA"], description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"), SIG(name="GMA", comment_names=["GMA"], description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
@@ -38,12 +38,10 @@ SIGS = [
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA". # Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
CW_MODES = ["CW"] CW_MODES = ["CW"]
PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "FUSION", "M17"] PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"]
DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"] DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"]
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
MODE_TYPES = ["CW", "PHONE", "DATA"] MODE_TYPES = ["CW", "PHONE", "DATA"]
SSB_SUB_MODES = ["USB", "LSB"]
DV_SUB_MODES = ["DMR", "DSTAR", "C4FM", "FUSION", "M17"]
# Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode # Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots # we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots
+69
View File
@@ -0,0 +1,69 @@
from pathlib import Path
import diskcache
from core.config import MAX_SPOT_AGE, MAX_ALERT_AGE
from core.live_data_cache import LiveDataCache
from data.solar_conditions import SolarConditions
class DataStore:
"""Data caching/storage object. Handles storage of spots, alerts, solar conditions, SIG reference data, and callsign
lookup data using different caching strategies for each."""
def __init__(self):
cache_dir = "./cache"
self.MAX_SPOT_COUNT = 10000
self.MAX_ALERT_COUNT = 10000
self.SPOT_ALERT_SNAPSHOT_INTERVAL_SEC = 300
self.CALLSIGN_DATA_TTL_SEC = 30 * 24 * 60 * 60
Path(cache_dir).mkdir(parents=True, exist_ok=True)
# Special caches for spots and alerts, which have TTL and write snapshots to disk at an interval
self.spots = LiveDataCache(maxsize=self.MAX_SPOT_COUNT, ttl=MAX_SPOT_AGE,
snapshot_dir=cache_dir + "/spots",
snapshot_interval_sec=self.SPOT_ALERT_SNAPSHOT_INTERVAL_SEC)
self.alerts = LiveDataCache(maxsize=self.MAX_ALERT_COUNT, ttl=MAX_ALERT_AGE,
snapshot_dir=cache_dir + "/alerts",
snapshot_interval_sec=self.SPOT_ALERT_SNAPSHOT_INTERVAL_SEC)
# Standard disk cache for solar data and status data, but each cache contains only a single object which we
# expose to the wider application
self.solar = diskcache.Cache(cache_dir + "/solar")
if "solar_conditions" not in self.solar:
self.solar.add("solar_conditions", SolarConditions())
self.solar_conditions = self.solar.get("solar_conditions")
self.status = diskcache.Cache(cache_dir + "/status")
if "status_data" not in self.status:
self.status.add("status_data", {})
self.status_data = self.status.get("status_data")
# Standard disk caches for SIG ref data. Separate provider threads will repopulate these on a regular basis
# but there's no need for a TTL since old data is better than no data.
self.sigrefs_wwff = diskcache.Cache(cache_dir + "/sigrefs/wwff")
self.sigrefs_siota = diskcache.Cache(cache_dir + "/sigrefs/siota")
self.sigrefs_wota = diskcache.Cache(cache_dir + "/sigrefs/wota")
self.sigrefs_zlota = diskcache.Cache(cache_dir + "/sigrefs/zlota")
self.sigrefs_llota = diskcache.Cache(cache_dir + "/sigrefs/llota")
self.sigrefs_dme = diskcache.Cache(cache_dir + "/sigrefs/dme")
# Standard disk cache for callsign data. This data does have a TTL to trigger an occasional re-lookup.
# Old data *is* better than no data, but we can't have a background thread re-looking-up every callsign
# we've seen, so we rely on them timing out and this triggering another lookup.
self.callsigns = diskcache.Cache(cache_dir + "/callsigns")
def close(self):
self.spots.close()
self.alerts.close()
self.solar.close()
self.status.close()
self.sigrefs_wwff.close()
self.sigrefs_siota.close()
self.sigrefs_wota.close()
self.sigrefs_zlota.close()
self.sigrefs_llota.close()
self.callsigns.close()
# Global object
DATA_STORE = DataStore()
+100
View File
@@ -0,0 +1,100 @@
import logging
import threading
import time
import diskcache
from cachetools import TTLCache
class LiveDataCache:
"""Cache for spots and alerts. Uses the fast in-memory TTLCache for normal data I/O, including the TTL to enforce
maximum lifetime, and adds a separate diskcache to which we can save and load the TTLCache to provide persistence.
Also adds thread safety so spots and alerts can come from any thread, and a listener mechanism so the web server
can get a callback when new spots/alerts are added, and send them to any SSE clients."""
def __init__(self, maxsize, ttl, snapshot_dir, snapshot_interval_sec):
self._cache = TTLCache(maxsize=maxsize, ttl=ttl)
self._lock = threading.Lock()
self._ttl = ttl
self._listeners = []
self._listeners_lock = threading.Lock()
self._snapshot_dir = snapshot_dir
self._disk_cache = diskcache.Cache(str(snapshot_dir))
self._load_snapshot()
self._start_periodic_snapshot(snapshot_interval_sec)
def set(self, key, value):
with self._lock:
self._cache[key] = value
# Notify listeners
with self._listeners_lock:
listeners = list(self._listeners)
for callback in listeners:
try:
callback(value)
except Exception:
logging.error("Listener raised an exception for key %s", key, exc_info=True)
def get(self, key, default=None):
with self._lock:
return self._cache.get(key, default)
def delete(self, key):
with self._lock:
self._cache.pop(key, None)
def keys(self):
with self._lock:
return list(self._cache.keys())
def values(self):
with self._lock:
return list(self._cache.values())
def add_listener(self, callback):
"""Register callback(value) which will be called whenever a new spot/alert item is added via set(). Used by the
web server (via SSEBroadcaster) to send SSE clients an update on every new spot."""
with self._listeners_lock:
self._listeners.append(callback)
def remove_listener(self, callback):
with self._listeners_lock:
self._listeners.remove(callback)
def save_snapshot(self):
with self._lock:
# Store the time with the data so we can avoid loading anything nxt time that's older than TTL
data = [(k, v, time.time()) for k, v in self._cache.items()]
try:
self._disk_cache.set("snapshot", data)
except Exception as e:
logging.error("Failed to write snapshot to %s", self._snapshot_dir, e, exc_info=True)
def _load_snapshot(self):
data = self._disk_cache.get("snapshot")
if not data:
return
now = time.time()
with self._lock:
for key, value, saved_at in data:
# Only restore entries that would still be within TTL
if now - saved_at < self._ttl:
self._cache[key] = value
logging.info("Loaded snapshot from %s", self._snapshot_dir)
def _start_periodic_snapshot(self, interval):
def loop():
while True:
time.sleep(interval)
self.save_snapshot()
t = threading.Thread(target=loop, daemon=True, name=f"snapshot-{self._snapshot_dir}")
t.start()
def close(self):
self.save_snapshot()
self._disk_cache.close()
+5 -5
View File
@@ -14,10 +14,10 @@ from pyhamtools.locator import latlong_to_locator
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
from requests_cache import CachedSession from requests_cache import CachedSession
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
from core.config import config from core.config import config
from core.constants import BANDS, UNKNOWN_BAND, CW_MODES, PHONE_MODES, DATA_MODES, ALL_MODES, \ from core.constants import BANDS, UNKNOWN_BAND, CW_MODES, PHONE_MODES, DATA_MODES, ALL_MODES, \
HTTP_HEADERS, HAMQTH_PRG, MODE_ALIASES HTTP_HEADERS, HAMQTH_PRG, MODE_ALIASES
from core.url_data_cache import URL_DATA_CACHE
# QRZ XML field names differ from pyhamtools' normalised names; map them here. # QRZ XML field names differ from pyhamtools' normalised names; map them here.
_QRZ_FIELD_MAP = { _QRZ_FIELD_MAP = {
@@ -142,7 +142,7 @@ class LookupHelper:
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 = URL_DATA_CACHE.get("https://www.country-files.com/cty/cty.plist",
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
if response.ok: if response.ok:
@@ -167,7 +167,7 @@ class LookupHelper:
try: try:
logging.info("Downloading dxcc.json...") logging.info("Downloading dxcc.json...")
response = SEMI_STATIC_URL_DATA_CACHE.get( response = URL_DATA_CACHE.get(
"https://raw.githubusercontent.com/k0swe/dxcc-json/refs/heads/main/dxcc.json", "https://raw.githubusercontent.com/k0swe/dxcc-json/refs/heads/main/dxcc.json",
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
@@ -515,7 +515,7 @@ class LookupHelper:
for lookup_call in calls_to_try: for lookup_call in calls_to_try:
try: try:
response = SEMI_STATIC_URL_DATA_CACHE.get( response = URL_DATA_CACHE.get(
self._qrz_base_url + "?s=" + session_key + "&callsign=" + urllib.parse.quote_plus(lookup_call), self._qrz_base_url + "?s=" + session_key + "&callsign=" + urllib.parse.quote_plus(lookup_call),
headers=HTTP_HEADERS, timeout=10) headers=HTTP_HEADERS, timeout=10)
if response.ok: if response.ok:
@@ -593,7 +593,7 @@ class LookupHelper:
for lookup_call in calls_to_try: for lookup_call in calls_to_try:
try: try:
response = SEMI_STATIC_URL_DATA_CACHE.get( response = 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(
lookup_call) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS) lookup_call) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS)
if response.ok: if response.ok:
+39 -49
View File
@@ -4,23 +4,16 @@ import logging
from pyhamtools.locator import latlong_to_locator, locator_to_latlong from pyhamtools.locator import latlong_to_locator, locator_to_latlong
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
from core.constants import SIGS, HTTP_HEADERS from core.constants import SIGS, HTTP_HEADERS
from core.data_store import DATA_STORE
from core.geo_utils import wab_wai_square_to_lat_lon from core.geo_utils import wab_wai_square_to_lat_lon
from core.url_data_cache import URL_DATA_CACHE
# Load Spanish municipality data for the DME programme. There's no convenient lookup API for this, so we embed the data # Load Spanish municipality data for the DME programme. There's no convenient lookup API for this, so we embed the data
# file in Spothole and load it on startup. # file in Spothole and load it on startup.
with open("datafiles/MUNICIPIOS.csv", encoding="latin-1") as _f: with open("datafiles/MUNICIPIOS.csv", encoding="latin-1") as _f:
_DME_INDEX = {row["COD_INE"][:5]: row for row in csv.DictReader(_f, delimiter=";")} for row in csv.DictReader(_f, delimiter=";"):
# Caches for data for the SIGs where we have to download a whole global reference list, rather than looking up a single DATA_STORE.sigrefs_dme.add(row["COD_INE"][:5], row)
# reference. These get populated from the SEMI_STATIC_URL_DATA_CACHE only if the data actually came
# live from the internet, to avoid repopulating them every time we pull the same data from the cache.
_WWFF_INDEX_CACHE = {}
_SIOTA_INDEX_CACHE = {}
_WOTA_INDEX_CACHE = {}
_ZLOTA_INDEX_CACHE = {}
_LLOTA_INDEX_CACHE = {}
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.""" """Utility function to get the regex string for a SIG reference for a named SIG. If no match is found, None will be returned."""
@@ -53,7 +46,7 @@ def populate_sig_ref_info(sig_ref):
ref_id = sig_ref.id ref_id = sig_ref.id
try: try:
if sig.upper() == "POTA": if sig.upper() == "POTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://api.pota.app/park/" + ref_id, headers=HTTP_HEADERS) response = URL_DATA_CACHE.get("https://api.pota.app/park/" + ref_id, headers=HTTP_HEADERS)
if response.ok: if response.ok:
data = response.json() data = response.json()
if data: if data:
@@ -71,7 +64,7 @@ def populate_sig_ref_info(sig_ref):
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id) logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "SOTA": elif sig.upper() == "SOTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://api-db2.sota.org.uk/api/summits/" + ref_id, response = URL_DATA_CACHE.get("https://api-db2.sota.org.uk/api/summits/" + ref_id,
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
if response.ok: if response.ok:
data = response.json() data = response.json()
@@ -88,7 +81,7 @@ def populate_sig_ref_info(sig_ref):
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id) logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "WWBOTA": elif sig.upper() == "WWBOTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + ref_id, response = URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + ref_id,
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
if response.ok: if response.ok:
data = response.json() data = response.json()
@@ -104,7 +97,7 @@ def populate_sig_ref_info(sig_ref):
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id) logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "GMA" or sig.upper() == "ARLHS" or sig.upper() == "ILLW" or sig.upper() == "WCA" or sig.upper() == "MOTA" or sig.upper() == "IOTA": elif sig.upper() == "GMA" or sig.upper() == "ARLHS" or sig.upper() == "ILLW" or sig.upper() == "WCA" or sig.upper() == "MOTA" or sig.upper() == "IOTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.cqgma.org/api/ref/?" + ref_id, response = URL_DATA_CACHE.get("https://www.cqgma.org/api/ref/?" + ref_id,
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
if response.ok: if response.ok:
data = response.json() data = response.json()
@@ -130,15 +123,14 @@ def populate_sig_ref_info(sig_ref):
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id) logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "WWFF": elif sig.upper() == "WWFF":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://wwff.co/wwff-data/wwff_directory.csv", response = URL_DATA_CACHE.get("https://wwff.co/wwff-data/wwff_directory.csv",
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
if response.ok: if response.ok:
global _WWFF_INDEX_CACHE if not bool(DATA_STORE.sigrefs_wwff) or not response.from_cache:
if not bool(_WWFF_INDEX_CACHE) or not response.from_cache:
# New data from WWFF, update our internal map # New data from WWFF, update our internal map
_WWFF_INDEX_CACHE = {row["reference"]: row for row in for row in csv.DictReader(response.content.decode().splitlines()):
csv.DictReader(response.content.decode().splitlines())} DATA_STORE.sigrefs_wwff.add(row["reference"], row)
row = _WWFF_INDEX_CACHE.get(ref_id) row = DATA_STORE.sigrefs_wwff.get(ref_id)
if row: if row:
sig_ref.name = row["name"] if "name" in row else None sig_ref.name = row["name"] if "name" in row else None
sig_ref.url = "https://wwff.co/directory/?showRef=" + ref_id sig_ref.url = "https://wwff.co/directory/?showRef=" + ref_id
@@ -152,15 +144,14 @@ def populate_sig_ref_info(sig_ref):
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id) logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "SIOTA": elif sig.upper() == "SIOTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.silosontheair.com/data/silos.csv", response = URL_DATA_CACHE.get("https://www.silosontheair.com/data/silos.csv",
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
if response.ok: if response.ok:
global _SIOTA_INDEX_CACHE if not bool(DATA_STORE.sigrefs_siota) or not response.from_cache:
if not bool(_SIOTA_INDEX_CACHE) or not response.from_cache:
# New data from SIOTA, update our internal map # New data from SIOTA, update our internal map
_SIOTA_INDEX_CACHE = {row["SILO_CODE"]: row for row in for row in csv.DictReader(response.content.decode().splitlines()):
csv.DictReader(response.content.decode().splitlines())} DATA_STORE.sigrefs_siota.add(row["SILO_CODE"], row)
row = _SIOTA_INDEX_CACHE.get(ref_id) row = DATA_STORE.sigrefs_siota.get(ref_id)
if row: if row:
sig_ref.name = row["NAME"] if "NAME" in row else None sig_ref.name = row["NAME"] if "NAME" in row else None
sig_ref.grid = row["LOCATOR"] if "LOCATOR" in row else None sig_ref.grid = row["LOCATOR"] if "LOCATOR" in row else None
@@ -172,17 +163,16 @@ def populate_sig_ref_info(sig_ref):
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id) logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "WOTA": elif sig.upper() == "WOTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.wota.org.uk/mapping/data/summits.json", response = URL_DATA_CACHE.get("https://www.wota.org.uk/mapping/data/summits.json",
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
if response.ok: if response.ok:
data = response.json() data = response.json()
if data: if data:
global _WOTA_INDEX_CACHE if not bool(DATA_STORE.sigrefs_wota) or not response.from_cache:
if not bool(_WOTA_INDEX_CACHE) or not response.from_cache:
# New data from WOTA, update our internal map # New data from WOTA, update our internal map
_WOTA_INDEX_CACHE = {feature["properties"]["wotaId"]: feature for feature in for feature in data.get("features", []):
data.get("features", [])} DATA_STORE.sigrefs_wota.add(feature["properties"]["wotaId"], feature)
feature = _WOTA_INDEX_CACHE.get(ref_id) feature = DATA_STORE.sigrefs_wota.get(ref_id)
if feature: if feature:
sig_ref.name = feature["properties"]["title"] sig_ref.name = feature["properties"]["title"]
# Fudge WOTA URLs. Outlying fell (LDO) URLs don't match their ID numbers but require 214 to be # Fudge WOTA URLs. Outlying fell (LDO) URLs don't match their ID numbers but require 214 to be
@@ -200,24 +190,24 @@ def populate_sig_ref_info(sig_ref):
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id) logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
elif sig.upper() == "ZLOTA": elif sig.upper() == "ZLOTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://ontheair.nz/assets/assets.json", headers=HTTP_HEADERS) response = URL_DATA_CACHE.get("https://ontheair.nz/assets/assets.json", headers=HTTP_HEADERS)
if response.ok: if response.ok:
data = response.json() data = response.json()
if isinstance(data, list): if isinstance(data, list):
global _ZLOTA_INDEX_CACHE if not bool(DATA_STORE.sigrefs_zlota) or not response.from_cache:
if not bool(_ZLOTA_INDEX_CACHE) or not response.from_cache:
# New data from ZLOTA, update our internal map # New data from ZLOTA, update our internal map
_ZLOTA_INDEX_CACHE = {asset["code"]: asset for asset in data} for ref in data:
asset = _ZLOTA_INDEX_CACHE.get(ref_id) DATA_STORE.sigrefs_zlota.add(ref["code"], ref)
if asset: ref = DATA_STORE.sigrefs_zlota.get(ref_id)
sig_ref.name = asset["name"] if ref:
sig_ref.name = ref["name"]
sig_ref.url = "https://ontheair.nz/assets/" + ref_id.replace("/", "_") sig_ref.url = "https://ontheair.nz/assets/" + ref_id.replace("/", "_")
try: try:
sig_ref.grid = latlong_to_locator(asset["y"], asset["x"], 6) sig_ref.grid = latlong_to_locator(ref["y"], ref["x"], 6)
except: except:
logging.debug("Invalid lat/lon received for reference") logging.debug("Invalid lat/lon received for reference")
sig_ref.latitude = asset["y"] sig_ref.latitude = ref["y"]
sig_ref.longitude = asset["x"] sig_ref.longitude = ref["x"]
elif not response.from_cache: elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id) logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache: elif not response.from_cache:
@@ -229,16 +219,16 @@ def populate_sig_ref_info(sig_ref):
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":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://llota.app/api/public/references", response = URL_DATA_CACHE.get("https://llota.app/api/public/references",
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
if response.ok: if response.ok:
data = response.json() data = response.json()
if isinstance(data, list): if isinstance(data, list):
global _LLOTA_INDEX_CACHE if not bool(DATA_STORE.sigrefs_llota) or not response.from_cache:
if not bool(_LLOTA_INDEX_CACHE) or not response.from_cache:
# New data from LLOTA, update our internal map # New data from LLOTA, update our internal map
_LLOTA_INDEX_CACHE = {ref["reference_code"]: ref for ref in data} for ref in data:
ref = _LLOTA_INDEX_CACHE.get(ref_id) DATA_STORE.sigrefs_llota.add(ref["reference_code"], ref)
ref = DATA_STORE.sigrefs_llota.get(ref_id)
if ref: if ref:
sig_ref.name = str(ref["name"]) sig_ref.name = str(ref["name"])
sig_ref.url = "https://llota.app/list/ref/" + ref_id sig_ref.url = "https://llota.app/list/ref/" + ref_id
@@ -280,7 +270,7 @@ def populate_sig_ref_info(sig_ref):
elif sig.upper() == "DME": elif sig.upper() == "DME":
# Zero-pad to 5 digits to match our source data # Zero-pad to 5 digits to match our source data
row = _DME_INDEX.get(ref_id.zfill(5)) row = DATA_STORE.sigrefs_dme.get(ref_id.zfill(5))
if row: if row:
sig_ref.name = row["NOMBRE_ACTUAL"] + ", " + row["PROVINCIA"] sig_ref.name = row["NOMBRE_ACTUAL"] + ", " + row["PROVINCIA"]
sig_ref.latitude = float(row["LATITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get( sig_ref.latitude = float(row["LATITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get(
+14 -21
View File
@@ -7,31 +7,27 @@ import pytz
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from core.constants import SOFTWARE_VERSION from core.constants import SOFTWARE_VERSION
from core.data_store import DATA_STORE
from core.prometheus_metrics_handler import memory_use_gauge, spots_gauge, alerts_gauge from core.prometheus_metrics_handler import memory_use_gauge, spots_gauge, alerts_gauge
class StatusReporter: class StatusReporter:
"""Provides a timed update of the application's status data.""" """Provides a timed update of the application's status data."""
def __init__(self, status_data, run_interval, web_server, cleanup_timer, spots, spot_providers, alerts, def __init__(self, run_interval, web_server,spot_providers, alert_providers, solar_condition_providers):
alert_providers, solar_condition_providers):
"""Constructor""" """Constructor"""
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
self._cleanup_timer = cleanup_timer
self._spots = spots
self._spot_providers = spot_providers self._spot_providers = spot_providers
self._alerts = alerts
self._alert_providers = alert_providers self._alert_providers = alert_providers
self._solar_condition_providers = solar_condition_providers self._solar_condition_providers = solar_condition_providers
self._thread = None self._thread = None
self._stop_event = Event() self._stop_event = Event()
self._startup_time = datetime.now(pytz.UTC) self._startup_time = datetime.now(pytz.UTC)
self._status_data["software-version"] = SOFTWARE_VERSION DATA_STORE.status_data["software-version"] = SOFTWARE_VERSION
self._status_data["server-owner-callsign"] = SERVER_OWNER_CALLSIGN DATA_STORE.status_data["server-owner-callsign"] = SERVER_OWNER_CALLSIGN
def start(self): def start(self):
"""Start the reporter thread""" """Start the reporter thread"""
@@ -55,31 +51,28 @@ class StatusReporter:
def _report(self): def _report(self):
"""Write status information""" """Write status information"""
self._status_data["uptime"] = (datetime.now(pytz.UTC) - self._startup_time).total_seconds() DATA_STORE.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) DATA_STORE.status_data["mem_use_mb"] = round(psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024), 3)
self._status_data["num_spots"] = len(self._spots) DATA_STORE.status_data["num_spots"] = len(DATA_STORE.spots.values())
self._status_data["num_alerts"] = len(self._alerts) DATA_STORE.status_data["num_alerts"] = len(DATA_STORE.alerts.values())
self._status_data["spot_providers"] = list( DATA_STORE.status_data["spot_providers"] = list(
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status, map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
"last_updated": p.last_update_time.replace( "last_updated": p.last_update_time.replace(
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0, tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
"last_spot": p.last_spot_time.replace( "last_spot": p.last_spot_time.replace(
tzinfo=pytz.UTC).timestamp() if p.last_spot_time.year > 2000 else 0}, tzinfo=pytz.UTC).timestamp() if p.last_spot_time.year > 2000 else 0},
self._spot_providers)) self._spot_providers))
self._status_data["alert_providers"] = list( DATA_STORE.status_data["alert_providers"] = list(
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status, map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
"last_updated": p.last_update_time.replace( "last_updated": p.last_update_time.replace(
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0}, tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
self._alert_providers)) self._alert_providers))
self._status_data["solar_condition_providers"] = list( DATA_STORE.status_data["solar_condition_providers"] = list(
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status, map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
"last_updated": p.last_update_time.replace( "last_updated": p.last_update_time.replace(
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0}, tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
self._solar_condition_providers)) self._solar_condition_providers))
self._status_data["cleanup"] = {"status": self._cleanup_timer.status, DATA_STORE.status_data["webserver"] = {"status": self._web_server.web_server_metrics["status"],
"last_ran": self._cleanup_timer.last_cleanup_time.replace(
tzinfo=pytz.UTC).timestamp() if self._cleanup_timer.last_cleanup_time else 0}
self._status_data["webserver"] = {"status": self._web_server.web_server_metrics["status"],
"last_api_access": self._web_server.web_server_metrics[ "last_api_access": self._web_server.web_server_metrics[
"last_api_access_time"].replace( "last_api_access_time"].replace(
tzinfo=pytz.UTC).timestamp() if self._web_server.web_server_metrics[ tzinfo=pytz.UTC).timestamp() if self._web_server.web_server_metrics[
@@ -94,5 +87,5 @@ class StatusReporter:
# Update Prometheus metrics # Update Prometheus metrics
memory_use_gauge.set(psutil.Process(os.getpid()).memory_info().rss) memory_use_gauge.set(psutil.Process(os.getpid()).memory_info().rss)
spots_gauge.set(len(self._spots)) spots_gauge.set(len(DATA_STORE.spots.values()))
alerts_gauge.set(len(self._alerts)) alerts_gauge.set(len(DATA_STORE.alerts.values()))
+23
View File
@@ -0,0 +1,23 @@
import threading
from datetime import timedelta
from requests_cache import CachedSession
# Cache for "semi-static" data retrieved from a URL. This is a layet of caching in addition to the normal caching of
# spots, alerts and other data in the DataStore class. Its purpose is to avoid hitting remote endpoints frequently when
# e.g. restarting Spothole many times during testing.
_session = CachedSession("cache/semi_static_urls", expire_after=timedelta(days=1),
allowable_codes=(200, 400, 401, 403, 404))
_lock = threading.Lock()
class _ThreadSafeSession:
"""Wraps CachedSession with a lock to prevent concurrent SQLite access across threads. This allows a single object
to be used freely across the application."""
def get(self, *args, **kwargs):
with _lock:
return _session.get(*args, **kwargs)
# Global object
URL_DATA_CACHE = _ThreadSafeSession()
-10
View File
@@ -6,13 +6,3 @@ def safe_json_dumps(obj):
which are invalid in JSON.""" which are invalid in JSON."""
return simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, default=lambda o: o.__dict__) return simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, default=lambda o: o.__dict__)
def empty_queue(q):
"""Empty a queue"""
while not q.empty():
try:
q.get_nowait()
except:
break
+8 -8
View File
@@ -12,15 +12,15 @@ class LookupCredentials:
hamqth_session_id: str = "" # alternative to username/password hamqth_session_id: str = "" # alternative to username/password
def extract_credentials(headers): def extract_credentials(query_params):
"""Build a LookupCredentials from HTTP request headers; returns None if no usable credentials are present.""" """Build a LookupCredentials from HTTP query params; returns None if no usable credentials are present."""
creds = LookupCredentials( creds = LookupCredentials(
qrz_username=headers.get("X-QRZ-Username", ""), qrz_username=query_params.get("qrz_username", ""),
qrz_password=headers.get("X-QRZ-Password", ""), qrz_password=query_params.get("qrz_password", ""),
qrz_session_key=headers.get("X-QRZ-Session-Key", ""), qrz_session_key=query_params.get("qrz_session_key", ""),
hamqth_username=headers.get("X-HamQTH-Username", ""), hamqth_username=query_params.get("hamqth_username", ""),
hamqth_password=headers.get("X-HamQTH-Password", ""), hamqth_password=query_params.get("hamqth_password", ""),
hamqth_session_id=headers.get("X-HamQTH-Session-ID", ""), hamqth_session_id=query_params.get("hamqth_session_id", ""),
) )
has_qrz = creds.qrz_session_key or (creds.qrz_username and creds.qrz_password) has_qrz = creds.qrz_session_key or (creds.qrz_username and creds.qrz_password)
has_hamqth = creds.hamqth_session_id or (creds.hamqth_username and creds.hamqth_password) has_hamqth = creds.hamqth_session_id or (creds.hamqth_username and creds.hamqth_password)
+1
View File
@@ -18,3 +18,4 @@ tornado~=6.4.2
tornado_eventsource~=3.0.0 tornado_eventsource~=3.0.0
geopandas~=0.13.2 geopandas~=0.13.2
simplejson~=4.1.1 simplejson~=4.1.1
cachetools~=7.1.6
+9 -131
View File
@@ -1,40 +1,33 @@
import logging import logging
import re import re
import threading
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
import pytz import pytz
import requests
import tornado import tornado
from tornado import httputil from tornado import httputil
from tornado.web import Application from tornado.web import Application
from core.config import ALLOW_SPOTTING, ALLOW_UPSTREAM_SPOTTING, MAX_SPOT_AGE, RECAPTCHA_SECRET_KEY from core.config import ALLOW_SPOTTING
from core.constants import UNKNOWN_BAND from core.constants import UNKNOWN_BAND
from core.lookup_helper import infer_band_from_freq from core.lookup_helper import infer_band_from_freq
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig from core.sig_utils import get_ref_regex_for_sig
from core.utils import safe_json_dumps from core.utils import safe_json_dumps
from data.spot import Spot from data.spot import Spot
from spotproviders.spot_provider import SpotProvider
RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
class APISpotHandler(tornado.web.RequestHandler): class APISpotHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/spot (POST)""" """API request handler for /api/v1/spot (POST)"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any): def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._spots = None self._spots = None
self._web_server_metrics = None self._web_server_metrics = None
self._spot_providers = None
super().__init__(application, request, **kwargs) super().__init__(application, request, **kwargs)
def initialize(self, spots, web_server_metrics, spot_providers=None): def initialize(self, spots, web_server_metrics):
self._spots = spots self._spots = spots
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics
self._spot_providers = spot_providers or []
def post(self): def post(self):
try: try:
@@ -69,38 +62,9 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
# Read in the request body as JSON # Read in the request body as JSON then convert to a Spot object
json_body = tornado.escape.json_decode(post_data) json_spot = tornado.escape.json_decode(post_data)
spot = Spot(**json_spot)
# Extract the "spot" and "handling" sub-objects from the request body
spot_data = json_body.get("spot", {})
handling = json_body.get("handling", {})
# Extract individual parameters that say how this spot should be handled by the server
submit_upstream = handling.get("submit_upstream", False)
upstream_provider_name = handling.get("upstream_provider", None)
upstream_credentials = handling.get("upstream_credentials", {})
captcha_token = handling.get("captcha_token", None)
# Verify CAPTCHA if required
if RECAPTCHA_SECRET_KEY:
if not captcha_token:
self.set_status(422)
self.write(json.dumps("Error - CAPTCHA token is required for spot submission.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not self._verify_recaptcha(captcha_token):
self.set_status(422)
self.write(json.dumps("Error - CAPTCHA verification failed.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Convert spot field to a Spot object
spot = Spot(**spot_data)
# Reject if no timestamp, frequency, dx_call or de_call # Reject if no timestamp, frequency, dx_call or de_call
if not spot.time or not spot.dx_call or not spot.freq or not spot.de_call: if not spot.time or not spot.dx_call or not spot.freq or not spot.de_call:
@@ -152,76 +116,11 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
# Reject upstream submission if not permitted # infer missing data, and add it to our database.
if submit_upstream and not ALLOW_UPSTREAM_SPOTTING: spot.source = "API"
self.set_status(403)
self.write(json.dumps("Error - this server does not allow upstream spot submission.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Validate upstream submission requirements
if submit_upstream and upstream_provider_name:
if not spot.sig:
self.set_status(422)
self.write(json.dumps("Error - a SIG must be selected to submit upstream.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not spot.sig_refs and upstream_provider_name != "Tiles":
self.set_status(422)
self.write(json.dumps("Error - a SIG reference is required to submit upstream.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not spot.dx_grid and upstream_provider_name == "Tiles":
self.set_status(422)
self.write(json.dumps("Error - a grid reference is required to submit upstream to Tiles on the Air.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not spot.mode and upstream_provider_name == "Tiles":
self.set_status(422)
self.write(json.dumps("Error - a mode is required to submit upstream to Tiles on the Air.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Submit upstream if requested
upstream_warning = None
if submit_upstream and upstream_provider_name:
provider = self._find_provider(upstream_provider_name, spot.sig)
if provider:
try:
# Submit spot to the upstream provider
provider.submit_spot(spot, upstream_credentials)
# Trigger a re-poll after 1 second so the spot appears quickly
threading.Timer(1.0, provider.force_poll).start()
except NotImplementedError as e:
upstream_warning = str(e)
except Exception as e:
logging.warning("Failed to submit spot upstream to " + upstream_provider_name + ": " + str(e))
upstream_warning = "Spot was saved locally but upstream submission to " + upstream_provider_name + " failed: " + str(
e)
else:
upstream_warning = "No enabled provider named '" + upstream_provider_name + "' supports upstream submission for " + spot.sig + " spots."
# If we successfully submitted the spot upstream, don't add it direct to Spothole, otherwise it will be a
# duplicate with what immediately comes back from the API. But if we weren't asked to send it upstream, or
# we were but it failed, we should still add it to our database anyway.
if not submit_upstream or upstream_warning:
spot.infer_missing() spot.infer_missing()
self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE) self._spots.set(spot.id, spot)
if upstream_warning:
self.write(json.dumps("Warning - " + upstream_warning, default=serialize_everything))
self.set_status(201)
else:
self.write(safe_json_dumps("OK")) self.write(safe_json_dumps("OK"))
self.set_status(201) self.set_status(201)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
@@ -233,24 +132,3 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_status(500) self.set_status(500)
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")
def _find_provider(self, provider_name, sig) -> SpotProvider | None:
"""Find an enabled provider by name that can submit spots for the given SIG."""
for p in self._spot_providers:
if p.enabled and p.name == provider_name and p.can_submit_spot(sig):
return p
return None
@staticmethod
def _verify_recaptcha(token):
"""Verify a Google reCAPTCHA v2 token. Returns True if valid."""
try:
response = requests.post(RECAPTCHA_VERIFY_URL,
data={"secret": RECAPTCHA_SECRET_KEY, "response": token},
timeout=(5, 10))
return response.ok and response.json().get("success", False)
except Exception as e:
logging.warning("reCAPTCHA verification request failed: " + str(e))
return False
+17 -50
View File
@@ -1,7 +1,6 @@
import copy import copy
import logging import logging
from datetime import datetime from datetime import datetime
from queue import Queue
from typing import Any from typing import Any
import pytz import pytz
@@ -11,15 +10,12 @@ from tornado import httputil
from tornado.web import Application from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps, empty_queue from core.utils import safe_json_dumps
from data.lookup_credentials import extract_credentials from data.lookup_credentials import extract_credentials
SSE_HANDLER_MAX_QUEUE_SIZE = 100
SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
class APIAlertsHandler(tornado.web.RequestHandler): class APIAlertsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/alerts""" """API request handler for /api/v1/alerts"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any): def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._alerts = None self._alerts = None
@@ -52,7 +48,7 @@ class APIAlertsHandler(tornado.web.RequestHandler):
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()} query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# Fetch all alerts matching the query, then optionally enrich with online data # Fetch all alerts matching the query, then optionally enrich with online data
credentials = extract_credentials(self.request.headers) credentials = extract_credentials(query_params)
data = get_alert_list_with_filters(self._alerts, query_params) data = get_alert_list_with_filters(self._alerts, query_params)
if credentials: if credentials:
data = self._enrich(data, credentials) data = self._enrich(data, credentials)
@@ -70,19 +66,17 @@ class APIAlertsHandler(tornado.web.RequestHandler):
class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler): class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
"""API request handler for /api/v2/alerts/stream""" """API request handler for /api/v1/alerts/stream"""
def __init__(self, application, request, **kwargs: Any): def __init__(self, application, request, **kwargs: Any):
self._sse_alert_queues = None self._sse_alert_broadcaster = None
self._web_server_metrics = None self._web_server_metrics = None
self._query_params = None self._query_params = None
self._credentials = None self._credentials = None
self._alert_queue = None
self._heartbeat = None
super().__init__(application, request, **kwargs) super().__init__(application, request, **kwargs)
def initialize(self, sse_alert_queues, web_server_metrics): def initialize(self, _sse_alert_broadcaster, web_server_metrics):
self._sse_alert_queues = sse_alert_queues self._sse_alert_broadcaster = _sse_alert_broadcaster
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics
def custom_headers(self): def custom_headers(self):
@@ -102,61 +96,34 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
# request.arguments contains lists for each param key because technically the client can supply multiple, # request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string # reduce that to just the first entry, and convert bytes to string
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()} self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
self._credentials = extract_credentials(self.request.headers) self._credentials = extract_credentials(self._query_params)
# Create a alert queue and add it to the web server's list. The web server will fill this when alerts arrive
self._alert_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE)
self._sse_alert_queues.append(self._alert_queue)
# Set up a timed callback to check if anything is in the queue
self._heartbeat = tornado.ioloop.PeriodicCallback(self._callback, SSE_HANDLER_QUEUE_CHECK_INTERVAL)
self._heartbeat.start()
# Flush headers immediately so nginx doesn't time out waiting for a response # Flush headers immediately so nginx doesn't time out waiting for a response
self.write_message("keepalive", "") self.write_message("keepalive", "")
# Register to handle new alerts arriving. The callback() method will get called with the new alert as an
# argument.
self._sse_alert_broadcaster.register(self)
except Exception as e: except Exception as e:
logging.warning("Exception when serving SSE socket: %s", e, exc_info=True) logging.warning("Exception when serving SSE socket: %s", e, exc_info=True)
self.close() self.close()
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""" """When the user closes the socket, deregister ourselves from the alert broadcaster"""
try: self._sse_alert_broadcaster.unregister(self)
if self._alert_queue in self._sse_alert_queues:
self._sse_alert_queues.remove(self._alert_queue)
empty_queue(self._alert_queue)
except:
pass
try:
self._heartbeat.stop()
except:
pass
self._alert_queue = None
super().close() super().close()
def _callback(self): def callback(self, alert):
"""Callback to check if anything has arrived in the queue, and if so send it to the client""" """Callback when a new alert arrives"""
try: try:
if self._alert_queue:
if not self._alert_queue.empty():
while not self._alert_queue.empty():
alert = self._alert_queue.get()
# If the new alert matches our param filters, send it to the client. If not, ignore it.
if alert_allowed_by_query(alert, self._query_params): if alert_allowed_by_query(alert, self._query_params):
if self._credentials: if self._credentials:
alert = copy.deepcopy(alert) alert = copy.deepcopy(alert)
alert.infer_missing(self._credentials) alert.infer_missing(self._credentials)
self.write_message(msg=safe_json_dumps(alert)) self.write_message(msg=safe_json_dumps(alert))
else:
# Send a keepalive comment if the queue was empty
self.write_message("keepalive", "")
if self._alert_queue not in self._sse_alert_queues:
logging.error("Web server cleared up a queue of an active connection!")
self.close()
except Exception as e: except Exception as e:
logging.warning("Exception in SSE callback, connection will be closed: %s", e, exc_info=True) logging.warning("Exception in SSE callback, connection will be closed: %s", e, exc_info=True)
self.close() self.close()
@@ -169,7 +136,7 @@ def get_alert_list_with_filters(all_alerts, query):
# 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.
alert_ids = list(all_alerts.iterkeys()) alert_ids = all_alerts.keys()
alerts = [] alerts = []
for k in alert_ids: for k in alert_ids:
a = all_alerts.get(k) a = all_alerts.get(k)
+2 -2
View File
@@ -19,7 +19,7 @@ BANDS_SET = frozenset(BANDS)
class APIDxStatsHandler(tornado.web.RequestHandler): class APIDxStatsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/dxstats""" """API request handler for /api/v1/dxstats"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any): def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._spots = None self._spots = None
@@ -40,7 +40,7 @@ class APIDxStatsHandler(tornado.web.RequestHandler):
one_hour_ago = (datetime.now(pytz.UTC) - timedelta(hours=1)).timestamp() one_hour_ago = (datetime.now(pytz.UTC) - timedelta(hours=1)).timestamp()
counts = Counter() counts = Counter()
for key in self._spots.iterkeys(): for key in self._spots.keys():
spot = self._spots.get(key) spot = self._spots.get(key)
if spot is None: if spot is None:
continue continue
+4 -4
View File
@@ -19,7 +19,7 @@ from data.spot import Spot
class APILookupCallHandler(tornado.web.RequestHandler): class APILookupCallHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/lookup/call""" """API request handler for /api/v1/lookup/call"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any): def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._web_server_metrics = None self._web_server_metrics = None
@@ -46,7 +46,7 @@ class APILookupCallHandler(tornado.web.RequestHandler):
if re.match(r"^[A-Z0-9/\-]*$", call): if re.match(r"^[A-Z0-9/\-]*$", call):
# Take the callsign, make a "fake spot" so we can run infer_missing() on it, then repack the # Take the callsign, make a "fake spot" so we can run infer_missing() on it, then repack the
# resulting data in the correct way for the API response. # resulting data in the correct way for the API response.
credentials = extract_credentials(self.request.headers) credentials = extract_credentials(query_params)
fake_spot = Spot(dx_call=call) fake_spot = Spot(dx_call=call)
fake_spot.infer_missing(credentials) fake_spot.infer_missing(credentials)
data = { data = {
@@ -83,7 +83,7 @@ class APILookupCallHandler(tornado.web.RequestHandler):
class APILookupSIGRefHandler(tornado.web.RequestHandler): class APILookupSIGRefHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/lookup/sigref""" """API request handler for /api/v1/lookup/sigref"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any): def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._web_server_metrics = None self._web_server_metrics = None
@@ -135,7 +135,7 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
class APILookupGridHandler(tornado.web.RequestHandler): class APILookupGridHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/lookup/grid""" """API request handler for /api/v1/lookup/grid"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any): def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._web_server_metrics = None self._web_server_metrics = None
+12 -28
View File
@@ -14,18 +14,16 @@ from core.utils import safe_json_dumps
class APIOptionsHandler(tornado.web.RequestHandler): class APIOptionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/options""" """API request handler for /api/v1/options"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any): def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._status_data = None self._status_data = None
self._web_server_metrics = None self._web_server_metrics = None
self._spot_providers = None
super().__init__(application, request, **kwargs) super().__init__(application, request, **kwargs)
def initialize(self, status_data, web_server_metrics, spot_providers=None): def initialize(self, status_data, 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
self._spot_providers = spot_providers or []
def get(self): def get(self):
try: try:
@@ -35,37 +33,23 @@ class APIOptionsHandler(tornado.web.RequestHandler):
self._web_server_metrics["status"] = "OK" self._web_server_metrics["status"] = "OK"
api_requests_counter.inc() api_requests_counter.inc()
# Build a map of SIG name -> list of provider names that can submit spots for that SIG
spot_submit_providers = {}
for provider in self._spot_providers:
if not provider.enabled:
continue
for sig in SIGS:
if provider.can_submit_spot(sig.name):
spot_submit_providers.setdefault(sig.name, []).append(provider.name)
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle
# things that aren't even available.
spot_sources: list = list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["spot_providers"])))
alert_sources = list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"])))
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
# one of our providers.
if ALLOW_SPOTTING:
spot_sources.append("API")
options = {"bands": BANDS, options = {"bands": BANDS,
"modes": ALL_MODES, "modes": ALL_MODES,
"mode_types": MODE_TYPES, "mode_types": MODE_TYPES,
"sigs": SIGS, "sigs": SIGS,
"spot_sources": spot_sources, # Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle things that aren't even available.
"alert_sources": alert_sources, "spot_sources": list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["spot_providers"]))),
"alert_sources": list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"]))),
"continents": CONTINENTS, "continents": CONTINENTS,
"propagation_modes": list(PROPAGATION_MODES.values()), "propagation_modes": list(PROPAGATION_MODES.values()),
"max_spot_age": MAX_SPOT_AGE, "max_spot_age": MAX_SPOT_AGE,
"spot_allowed": ALLOW_SPOTTING, "spot_allowed": ALLOW_SPOTTING}
"spot_submit_providers": spot_submit_providers} # If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
# one of our proviers.
if ALLOW_SPOTTING:
options["spot_sources"].append("API")
self.write(safe_json_dumps(options)) self.write(safe_json_dumps(options))
self.set_status(200) self.set_status(200)
+1 -1
View File
@@ -12,7 +12,7 @@ from core.utils import safe_json_dumps
class APISolarConditionsHandler(tornado.web.RequestHandler): class APISolarConditionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/solar""" """API request handler for /api/v1/solar"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any): def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._solar_conditions = None self._solar_conditions = None
+17 -49
View File
@@ -1,7 +1,6 @@
import copy import copy
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from queue import Queue
from typing import Any from typing import Any
import pytz import pytz
@@ -11,15 +10,12 @@ from tornado import httputil
from tornado.web import Application from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps, empty_queue from core.utils import safe_json_dumps
from data.lookup_credentials import extract_credentials from data.lookup_credentials import extract_credentials
SSE_HANDLER_MAX_QUEUE_SIZE = 1000
SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
class APISpotsHandler(tornado.web.RequestHandler): class APISpotsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/spots""" """API request handler for /api/v1/spots"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any): def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._spots = None self._spots = None
@@ -52,7 +48,7 @@ class APISpotsHandler(tornado.web.RequestHandler):
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()} query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# Fetch all spots matching the query, then optionally enrich with online data # Fetch all spots matching the query, then optionally enrich with online data
credentials = extract_credentials(self.request.headers) credentials = extract_credentials(query_params)
data = get_spot_list_with_filters(self._spots, query_params) data = get_spot_list_with_filters(self._spots, query_params)
if credentials: if credentials:
data = self._enrich(data, credentials) data = self._enrich(data, credentials)
@@ -70,19 +66,17 @@ class APISpotsHandler(tornado.web.RequestHandler):
class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler): class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
"""API request handler for /api/v2/spots/stream""" """API request handler for /api/v1/spots/stream"""
def __init__(self, application, request, **kwargs: Any): def __init__(self, application, request, **kwargs: Any):
self._sse_spot_queues = None self._sse_spot_broadcaster = None
self._web_server_metrics = None self._web_server_metrics = None
self._query_params = None self._query_params = None
self._credentials = None self._credentials = None
self._spot_queue = None
self._heartbeat = None
super().__init__(application, request, **kwargs) super().__init__(application, request, **kwargs)
def initialize(self, sse_spot_queues, web_server_metrics): def initialize(self, sse_spot_broadcaster, web_server_metrics):
self._sse_spot_queues = sse_spot_queues self._sse_spot_broadcaster = sse_spot_broadcaster
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics
def custom_headers(self): def custom_headers(self):
@@ -104,61 +98,35 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
# request.arguments contains lists for each param key because technically the client can supply multiple, # request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string # reduce that to just the first entry, and convert bytes to string
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()} self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
self._credentials = extract_credentials(self.request.headers) self._credentials = extract_credentials(self._query_params)
# Create a spot queue and add it to the web server's list. The web server will fill this when spots arrive
self._spot_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE)
self._sse_spot_queues.append(self._spot_queue)
# Set up a timed callback to check if anything is in the queue
self._heartbeat = tornado.ioloop.PeriodicCallback(self._callback, SSE_HANDLER_QUEUE_CHECK_INTERVAL)
self._heartbeat.start()
# Flush headers immediately so nginx doesn't time out waiting for a response # Flush headers immediately so nginx doesn't time out waiting for a response
self.write_message("keepalive", "") self.write_message("keepalive", "")
# Register to handle new spots arriving. The callback() method will get called with the new spot as an
# argument.
self._sse_spot_broadcaster.register(self)
except Exception as e: except Exception as e:
logging.warning("Exception when serving SSE socket: %s", e, exc_info=True) logging.warning("Exception when serving SSE socket: %s", e, exc_info=True)
self.close() self.close()
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""" """When the user closes the socket, deregister ourselves from the spot broadcaster"""
try: self._sse_spot_broadcaster.unregister(self)
if self._spot_queue in self._sse_spot_queues:
self._sse_spot_queues.remove(self._spot_queue)
empty_queue(self._spot_queue)
except:
pass
try:
self._heartbeat.stop()
except:
pass
self._spot_queue = None
super().close() super().close()
def _callback(self): def callback(self, spot):
"""Callback to check if anything has arrived in the queue, and if so send it to the client""" """Callback when a new spot arrives"""
try: try:
if self._spot_queue:
if not self._spot_queue.empty():
while not self._spot_queue.empty():
spot = self._spot_queue.get()
# If the new spot matches our param filters, send it to the client. If not, ignore it. # If the new spot matches our param filters, send it to the client. If not, ignore it.
if spot_allowed_by_query(spot, self._query_params): if spot_allowed_by_query(spot, self._query_params):
if self._credentials: if self._credentials:
spot = copy.deepcopy(spot) spot = copy.deepcopy(spot)
spot.infer_missing(self._credentials) spot.infer_missing(self._credentials)
self.write_message(msg=safe_json_dumps(spot)) self.write_message(msg=safe_json_dumps(spot))
else:
# Send a keepalive comment if the queue was empty
self.write_message("keepalive", "")
if self._spot_queue not in self._sse_spot_queues:
logging.error("Web server cleared up a queue of an active connection!")
self.close()
except Exception as e: except Exception as e:
logging.warning("Exception in SSE callback, connection will be closed: %s", e, exc_info=True) logging.warning("Exception in SSE callback, connection will be closed: %s", e, exc_info=True)
self.close() self.close()
@@ -171,7 +139,7 @@ def get_spot_list_with_filters(all_spots, query):
# 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.
spot_ids = list(all_spots.iterkeys()) spot_ids = all_spots.keys()
spots = [] spots = []
for k in spot_ids: for k in spot_ids:
s = all_spots.get(k) s = all_spots.get(k)
+1 -1
View File
@@ -12,7 +12,7 @@ from core.utils import safe_json_dumps
class APIStatusHandler(tornado.web.RequestHandler): class APIStatusHandler(tornado.web.RequestHandler):
"""API request handler for /api/v2/status""" """API request handler for /api/v1/status"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any): def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._status_data = None self._status_data = None
-31
View File
@@ -1,31 +0,0 @@
import json
import tornado
from core.utils import serialize_everything
class V1GoneHandler(tornado.web.RequestHandler):
"""Returns 410 Gone with a message for any endpoints in the old API that have breaking changes in the new one or
have been retired."""
def post(self):
self.set_status(410)
self.write(json.dumps(
"This API endpoint has a breaking change or has been removed in the current version of the Spothole API. Please see /apidocs for details of the current API version and the endpoints available.",
default=serialize_everything
))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
class V1RedirectHandler(tornado.web.RequestHandler):
"""Returns 308 Permanent Redirect from any path in the old API to the new one, where there were no breaking changes."""
def get(self, path):
new_url = "/api/v2/" + path
if self.request.query:
new_url += "?" + self.request.query
self.set_status(308)
self.set_header("Location", new_url)
self.finish()
+36
View File
@@ -0,0 +1,36 @@
import logging
import threading
from tornado.ioloop import IOLoop
class SSEBroadcaster:
"""Bridge between DataStore listener callbacks (which fire on provider threads) to Tornado's async SSE handlers
(which live on the IOLoop thread) to avoid any interdependency between them."""
def __init__(self):
self._handlers = set()
self._lock = threading.Lock()
self._loop = IOLoop.current()
def register(self, handler):
with self._lock:
self._handlers.add(handler)
def unregister(self, handler):
with self._lock:
self._handlers.discard(handler)
def publish(self, value):
self._loop.add_callback(self._fan_out, value)
def _fan_out(self, value):
with self._lock:
handlers = list(self._handlers)
for handler in handlers:
try:
handler.callback(value)
except Exception:
# Connection probably dropped, ignore and de-register the handler to stop getting future items.
logging.debug("Failed to push to an SSE client; dropping it")
self.unregister(handler)
+25 -83
View File
@@ -6,9 +6,8 @@ import tornado
from tornado.web import StaticFileHandler from tornado.web import StaticFileHandler
from core.config import ALLOW_SPOTTING, WEB_SERVER_PORT, API_ONLY_MODE, LOG_WEB_REQUESTS, BASE_URL from core.config import ALLOW_SPOTTING, WEB_SERVER_PORT, API_ONLY_MODE, LOG_WEB_REQUESTS, BASE_URL
from core.utils import empty_queue from core.data_store import DATA_STORE
from server.handlers.api.addspot import APISpotHandler from server.handlers.api.addspot import APISpotHandler
from server.handlers.api.v1_compatability import V1RedirectHandler, V1GoneHandler
from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
from server.handlers.api.dxstats import APIDxStatsHandler from server.handlers.api.dxstats import APIDxStatsHandler
from server.handlers.api.lookups import APILookupCallHandler, APILookupSIGRefHandler, APILookupGridHandler from server.handlers.api.lookups import APILookupCallHandler, APILookupSIGRefHandler, APILookupGridHandler
@@ -19,6 +18,7 @@ from server.handlers.api.status import APIStatusHandler
from server.handlers.manifesthandler import ManifestHandler from server.handlers.manifesthandler import ManifestHandler
from server.handlers.metrics import PrometheusMetricsHandler from server.handlers.metrics import PrometheusMetricsHandler
from server.handlers.pagetemplate import PageTemplateHandler from server.handlers.pagetemplate import PageTemplateHandler
from server.sse_broadcaster import SSEBroadcaster
_HERE = os.path.dirname(__file__ or "") _HERE = os.path.dirname(__file__ or "")
@@ -26,16 +26,12 @@ _HERE = os.path.dirname(__file__ or "")
class WebServer: class WebServer:
"""Provides the public-facing web server.""" """Provides the public-facing web server."""
def __init__(self, spots, alerts, solar_conditions, status_data, spot_providers=None): def __init__(self):
"""Constructor""" """Constructor"""
self._spots = spots self._data_store = DATA_STORE
self._alerts = alerts self._spot_broadcaster = SSEBroadcaster()
self._solar_conditions = solar_conditions self._alert_broadcaster = SSEBroadcaster()
self._sse_spot_queues = []
self._sse_alert_queues = []
self._status_data = status_data
self._spot_providers = spot_providers or []
self._port = WEB_SERVER_PORT self._port = WEB_SERVER_PORT
self._api_only_mode = API_ONLY_MODE self._api_only_mode = API_ONLY_MODE
self._shutdown_event = asyncio.Event() self._shutdown_event = asyncio.Event()
@@ -47,6 +43,10 @@ class WebServer:
"status": "Starting" "status": "Starting"
} }
# Listen for new spots and alerts being added to the cache, so we can notify SSE clients immediately
DATA_STORE.spots.add_listener(self._spot_broadcaster.publish)
DATA_STORE.alerts.add_listener(self._alert_broadcaster.publish)
def start(self): def start(self):
"""Start the web server""" """Start the web server"""
@@ -66,29 +66,20 @@ class WebServer:
# API endpoints are always enabled # API endpoints are always enabled
api_routes = [ api_routes = [
(r"/api/v2/spots", APISpotsHandler, {"spots": self._spots, **handler_opts}), (r"/api/v1/spots", APISpotsHandler, {"spots": self._data_store.spots, **handler_opts}),
(r"/api/v2/alerts", APIAlertsHandler, {"alerts": self._alerts, **handler_opts}), (r"/api/v1/alerts", APIAlertsHandler, {"alerts": self._data_store.alerts, **handler_opts}),
(r"/api/v2/spots/stream", APISpotsStreamHandler, (r"/api/v1/spots/stream", APISpotsStreamHandler,
{"sse_spot_queues": self._sse_spot_queues, **handler_opts}), {"sse_spot_broadcaster": self._spot_broadcaster, **handler_opts}),
(r"/api/v2/alerts/stream", APIAlertsStreamHandler, (r"/api/v1/alerts/stream", APIAlertsStreamHandler,
{"sse_alert_queues": self._sse_alert_queues, **handler_opts}), {"sse_alert_broadcaster": self._alert_broadcaster, **handler_opts}),
(r"/api/v2/solar", APISolarConditionsHandler, {"solar_conditions": self._solar_conditions, **handler_opts}), (r"/api/v1/solar", APISolarConditionsHandler, {"solar_conditions": self._data_store.solar, **handler_opts}),
(r"/api/v2/dxstats", APIDxStatsHandler, {"spots": self._spots, **handler_opts}), (r"/api/v1/dxstats", APIDxStatsHandler, {"spots": self._data_store.spots, **handler_opts}),
(r"/api/v2/options", APIOptionsHandler, (r"/api/v1/options", APIOptionsHandler, {"status_data": self._data_store.status_data, **handler_opts}),
{"status_data": self._status_data, "spot_providers": self._spot_providers, **handler_opts}), (r"/api/v1/status", APIStatusHandler, {"status_data": self._data_store.status_data, **handler_opts}),
(r"/api/v2/status", APIStatusHandler, {"status_data": self._status_data, **handler_opts}), (r"/api/v1/lookup/call", APILookupCallHandler, {**handler_opts}),
(r"/api/v2/lookup/call", APILookupCallHandler, {**handler_opts}), (r"/api/v1/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}),
(r"/api/v2/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}), (r"/api/v1/lookup/grid", APILookupGridHandler, {**handler_opts}),
(r"/api/v2/lookup/grid", APILookupGridHandler, {**handler_opts}), (r"/api/v1/spot", APISpotHandler, {"spots": self._data_store.spots, **handler_opts}),
(r"/api/v2/spot", APISpotHandler,
{"spots": self._spots, "spot_providers": self._spot_providers, **handler_opts}),
]
# v1 API redirects. Most v1 enpoints are unchanged in v2, and get an HTTP 308 redirect to the v2 API. The ones
# that have the actual breaking changes get a bespoke handler.
v1_compat_routes = [
(r"/api/v1/spot", V1GoneHandler),
(r"/api/v1/(.*)", V1RedirectHandler),
] ]
# If in API-only mode, serve a basic homepage; in normal mode, serve the usual UI routes # If in API-only mode, serve a basic homepage; in normal mode, serve the usual UI routes
@@ -123,7 +114,7 @@ class WebServer:
(r"/static/(.*)", StaticFileHandler, {"path": os.path.join(_HERE, "../static")}) (r"/static/(.*)", StaticFileHandler, {"path": os.path.join(_HERE, "../static")})
] ]
app = tornado.web.Application(api_routes + v1_compat_routes + ui_routes + misc_routes, app = tornado.web.Application(api_routes + ui_routes + misc_routes,
template_path=os.path.join(_HERE, "../templates"), template_path=os.path.join(_HERE, "../templates"),
log_function=request_log, log_function=request_log,
debug=False) debug=False)
@@ -132,55 +123,6 @@ class WebServer:
logging.info("You can access your copy of Spothole at " + BASE_URL) logging.info("You can access your copy of Spothole at " + BASE_URL)
await self._shutdown_event.wait() await self._shutdown_event.wait()
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:
try:
queue.put(spot)
except:
# Cleanup thread was probably deleting the queue, that's fine
pass
pass
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:
try:
queue.put(alert)
except:
# Cleanup thread was probably deleting the queue, that's fine
pass
pass
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:
try:
if q.full():
logging.warning(
"A full SSE spot queue was found, presumably because the client disconnected strangely. It has been removed.")
self._sse_spot_queues.remove(q)
empty_queue(q)
except:
# Probably got deleted already on another thread
pass
for q in self._sse_alert_queues:
try:
if q.full():
logging.warning(
"A full SSE alert queue was found, presumably because the client disconnected strangely. It has been removed.")
self._sse_alert_queues.remove(q)
empty_queue(q)
except:
# Probably got deleted already on another thread
pass
pass
def request_log(handler): def request_log(handler):
"""Custom log function to provide more data about requests when enabled, and to provide the ability to turn off """Custom log function to provide more data about requests when enabled, and to provide the ability to turn off
@@ -0,0 +1,28 @@
from datetime import datetime
import pytz
class SIGRefDataProvider:
"""Generic SIG reference data provider class. Subclasses of this query the individual URLs or files for data."""
def __init__(self, name, provider_config):
"""Constructor"""
self.name = name
self.enabled = provider_config["enabled"]
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.last_spot_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled"
def start(self):
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
raise NotImplementedError("Subclasses must implement this method")
def stop(self):
"""Stop any threads and prepare for application shutdown"""
raise NotImplementedError("Subclasses must implement this method")
+13 -16
View File
@@ -30,11 +30,23 @@ class GIROIonosonde(SolarConditionsProvider):
data, but is less reliable and often offline.""" data, but is less reliable and often offline."""
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config) super().__init__("GIRO Ionosonde Data", provider_config)
self._stations = self._load_stations() self._stations = self._load_stations()
self._thread = None self._thread = None
self._stop_event = Event() self._stop_event = Event()
# Pre-populate ionosonde_data with known station names for stations not already present,
# so the station dropdown is available before the first poll. Does not overwrite existing
# entries so KC2G cache data is preserved.
existing = self._solar_conditions.ionosonde_data or {}
new_entries = {
s["ursi"]: {"ursi": s["ursi"], "name": s["name"], "fof2": None, "muf": None,
"luf": None, "band_states": None}
for s in self._stations if s["ursi"] not in existing
}
if new_entries:
self.update_data({"ionosonde_data": {**existing, **new_entries}})
@staticmethod @staticmethod
def _load_stations(): def _load_stations():
stations = [] stations = []
@@ -44,21 +56,6 @@ class GIROIonosonde(SolarConditionsProvider):
stations.append({"ursi": row[0].strip(), "name": row[1].strip()}) stations.append({"ursi": row[0].strip(), "name": row[1].strip()})
return stations return stations
def setup(self, solar_conditions, solar_conditions_cache):
"""Pre-populate ionosonde_data with known station names for stations not already present,
so the station dropdown is available before the first poll. Does not overwrite existing
entries so KC2G cache data is preserved."""
super().setup(solar_conditions, solar_conditions_cache)
existing = solar_conditions.ionosonde_data or {}
new_entries = {
s["ursi"]: {"ursi": s["ursi"], "name": s["name"], "fof2": None, "muf": None,
"luf": None, "band_states": None}
for s in self._stations if s["ursi"] not in existing
}
if new_entries:
self.update_data({"ionosonde_data": {**existing, **new_entries}})
def start(self): def start(self):
logging.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.") logging.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
self._thread = Thread(target=self._run, daemon=True) self._thread = Thread(target=self._run, daemon=True)
+1 -1
View File
@@ -15,7 +15,7 @@ class HamQSL(HTTPSolarConditionsProvider):
Provides solar flux index, geomagnetic indices, and HF/VHF propagation condition summaries.""" Provides solar flux index, geomagnetic indices, and HF/VHF propagation condition summaries."""
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, URL, POLL_INTERVAL) super().__init__("HamQSL", provider_config, URL, POLL_INTERVAL)
def _http_response_to_solar_conditions(self, http_response): def _http_response_to_solar_conditions(self, http_response):
root = ElementTree.fromstring(http_response.text) root = ElementTree.fromstring(http_response.text)
@@ -14,8 +14,8 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
"""Generic solar conditions provider for providers that request data via HTTP(S). Subclasses implement """Generic solar conditions provider for providers that request data via HTTP(S). Subclasses implement
_http_response_to_solar_conditions() to parse the specific API response format.""" _http_response_to_solar_conditions() to parse the specific API response format."""
def __init__(self, provider_config, url, poll_interval): def __init__(self, name, provider_config, url, poll_interval):
super().__init__(provider_config) super().__init__(name, provider_config)
self._url = url self._url = url
self._poll_interval = poll_interval self._poll_interval = poll_interval
self._thread = None self._thread = None
+1 -1
View File
@@ -24,7 +24,7 @@ class KC2GProp(SolarConditionsProvider):
online, but has fewer stations and does not provide LUF data.""" online, but has fewer stations and does not provide LUF data."""
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config) super().__init__("KC2G Propagation Data", provider_config)
self._thread = None self._thread = None
self._stop_event = Event() self._stop_event = Event()
+1 -1
View File
@@ -13,7 +13,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
corresponding fields in the solar conditions object..""" corresponding fields in the solar conditions object.."""
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, URL, POLL_INTERVAL) super().__init__("NOAA 3-day Forecast", provider_config, URL, POLL_INTERVAL)
@staticmethod @staticmethod
def _parse_percentage_table(lines, section_header, year): def _parse_percentage_table(lines, section_header, year):
@@ -2,26 +2,21 @@ from datetime import datetime
import pytz import pytz
from core.data_store import DATA_STORE
class SolarConditionsProvider: class SolarConditionsProvider:
"""Generic solar conditions provider class. Subclasses of this query individual APIs for space weather and """Generic solar conditions provider class. Subclasses of this query individual APIs for space weather and
propagation data.""" propagation data."""
def __init__(self, provider_config): def __init__(self, name, provider_config):
"""Constructor""" """Constructor"""
self._solar_conditions_cache = None self.name = 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)
self.status = "Not Started" if self.enabled else "Disabled" self.status = "Not Started" if self.enabled else "Disabled"
self._solar_conditions = None self._solar_conditions = DATA_STORE.solar_conditions
def setup(self, solar_conditions, solar_conditions_cache):
"""Set up the provider, giving it the solar conditions object and its backing cache"""
self._solar_conditions = solar_conditions
self._solar_conditions_cache = solar_conditions_cache
def start(self): def start(self):
"""Start the provider. This should return immediately after spawning threads to access the remote resources""" """Start the provider. This should return immediately after spawning threads to access the remote resources"""
@@ -41,4 +36,3 @@ class SolarConditionsProvider:
if hasattr(self._solar_conditions, key): if hasattr(self._solar_conditions, key):
setattr(self._solar_conditions, key, value) setattr(self._solar_conditions, key, value)
self._solar_conditions.infer_descriptions() self._solar_conditions.infer_descriptions()
self._solar_conditions_cache['solar_conditions'] = self._solar_conditions
+9 -34
View File
@@ -5,23 +5,15 @@ import os
import signal import signal
import sys import sys
from diskcache import Cache
from core.cleanup import CleanupTimer
from core.config import config, SERVER_OWNER_CALLSIGN, LOG_LEVEL from core.config import config, SERVER_OWNER_CALLSIGN, LOG_LEVEL
from core.constants import SOFTWARE_VERSION from core.constants import SOFTWARE_VERSION
from core.data_store import DATA_STORE
from core.lookup_helper import lookup_helper from core.lookup_helper import lookup_helper
from core.status_reporter import StatusReporter from core.status_reporter import StatusReporter
from data.solar_conditions import SolarConditions
from server.webserver import WebServer from server.webserver import WebServer
# Globals # Globals
spots = Cache('cache/spots_cache')
alerts = Cache('cache/alerts_cache')
solar_conditions_cache = Cache('cache/solar_conditions_cache')
solar_conditions = solar_conditions_cache.get('solar_conditions', SolarConditions())
web_server = None web_server = None
status_data = {}
spot_providers = [] spot_providers = []
alert_providers = [] alert_providers = []
solar_condition_providers = [] solar_condition_providers = []
@@ -46,13 +38,7 @@ def shutdown(_signum=None, _frame=None):
for scp in solar_condition_providers: for scp in solar_condition_providers:
if scp.enabled: if scp.enabled:
scp.stop() scp.stop()
if cleanup_timer: DATA_STORE.close()
cleanup_timer.stop()
if lookup_helper:
lookup_helper.stop()
spots.close()
alerts.close()
solar_conditions_cache.close()
os._exit(0) os._exit(0)
@@ -102,43 +88,32 @@ if __name__ == '__main__':
# Set up lookup helper # Set up lookup helper
lookup_helper.start() lookup_helper.start()
# Create spot providers # Set up web server
web_server = WebServer()
# Fetch, set up and start spot providers
for entry in config["spot-providers"]: for entry in config["spot-providers"]:
spot_providers.append(get_spot_provider_from_config(entry)) spot_providers.append(get_spot_provider_from_config(entry))
# Set up web server
web_server = WebServer(spots=spots, alerts=alerts, solar_conditions=solar_conditions, status_data=status_data,
spot_providers=spot_providers)
# Set up and start spot providers
for p in spot_providers: for p in spot_providers:
p.setup(spots=spots, web_server=web_server)
if p.enabled: if p.enabled:
p.start() p.start()
# Create, set up and start alert providers # Fetch, set up and start alert providers
for entry in config["alert-providers"]: for entry in config["alert-providers"]:
alert_providers.append(get_alert_provider_from_config(entry)) alert_providers.append(get_alert_provider_from_config(entry))
for p in alert_providers: for p in alert_providers:
p.setup(alerts=alerts, web_server=web_server)
if p.enabled: if p.enabled:
p.start() p.start()
# Create, set up and start solar conditions providers # Fetch, set up and start solar conditions providers
for entry in config.get("solar-condition-providers", []): for entry in config.get("solar-condition-providers", []):
solar_condition_providers.append(get_solar_conditions_provider_from_config(entry)) solar_condition_providers.append(get_solar_conditions_provider_from_config(entry))
for p in solar_condition_providers: for p in solar_condition_providers:
p.setup(solar_conditions=solar_conditions, solar_conditions_cache=solar_conditions_cache)
if p.enabled: if p.enabled:
p.start() p.start()
# Set up timer to clear spot list of old data
cleanup_timer = CleanupTimer(spots=spots, alerts=alerts, web_server=web_server, cleanup_interval=60)
cleanup_timer.start()
# Set up status reporter # Set up status reporter
status_reporter = StatusReporter(status_data=status_data, spots=spots, alerts=alerts, web_server=web_server, status_reporter = StatusReporter(web_server=web_server, spot_providers=spot_providers,
cleanup_timer=cleanup_timer, spot_providers=spot_providers,
alert_providers=alert_providers, alert_providers=alert_providers,
solar_condition_providers=solar_condition_providers, run_interval=5) solar_condition_providers=solar_condition_providers, run_interval=5)
status_reporter.start() status_reporter.start()
+1 -1
View File
@@ -14,7 +14,7 @@ class APRSIS(SpotProvider):
"""Spot provider for the APRS-IS.""" """Spot provider for the APRS-IS."""
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config) super().__init__("APRS-IS", provider_config)
self._thread = Thread(target=self._connect) self._thread = Thread(target=self._connect)
self._thread.daemon = True self._thread.daemon = True
self._aprsis = None self._aprsis = None
+6 -1
View File
@@ -26,7 +26,8 @@ class DXCluster(SpotProvider):
def __init__(self, provider_config): def __init__(self, provider_config):
"""Constructor requires hostname and port""" """Constructor requires hostname and port"""
super().__init__(provider_config) name = provider_config["name"] if "name" in provider_config else "Cluster"
super().__init__(name, 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:"
@@ -59,6 +60,10 @@ class DXCluster(SpotProvider):
self._telnet.write((self._login_callsign + "\n").encode("latin-1")) self._telnet.write((self._login_callsign + "\n").encode("latin-1"))
connected = True connected = True
logging.info("DX Cluster " + self._hostname + " connected.") logging.info("DX Cluster " + self._hostname + " connected.")
except ConnectionRefusedError:
self.status = "Error"
logging.warning("Connection refused to DX cluster " + self._hostname)
sleep(300)
except Exception: except Exception:
self.status = "Error" self.status = "Error"
logging.exception("Exception while connecting to DX Cluster Provider (" + self._hostname + ").") logging.exception("Exception while connecting to DX Cluster Provider (" + self._hostname + ").")
+3 -11
View File
@@ -3,8 +3,8 @@ from datetime import datetime
import pytz import pytz
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.url_data_cache import URL_DATA_CACHE
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
@@ -26,7 +26,7 @@ class GMA(HTTPSpotProvider):
provider_config["enabled"] = False provider_config["enabled"] = False
logging.warning("GMA spot provider configured but no api key was provided, this API will not be queried.") logging.warning("GMA spot provider configured but no api key was provided, this API will not be queried.")
super().__init__(provider_config, self.SPOTS_URL + "?key=" + self.api_key, self.POLL_INTERVAL_SEC) super().__init__("GMA", provider_config, self.SPOTS_URL + "?key=" + self.api_key, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response):
new_spots = [] new_spots = []
@@ -56,7 +56,7 @@ class GMA(HTTPSpotProvider):
# 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:
try: try:
ref_response = SEMI_STATIC_URL_DATA_CACHE.get(self.REF_INFO_URL_ROOT + source_spot["REF"], ref_response = URL_DATA_CACHE.get(self.REF_INFO_URL_ROOT + source_spot["REF"],
headers=HTTP_HEADERS) headers=HTTP_HEADERS)
# Sometimes this is blank even if it's a 200 response, so handle that # Sometimes this is blank even if it's a 200 response, so handle that
if ref_response.ok and ref_response.text is not None and ref_response.text != "": if ref_response.ok and ref_response.text is not None and ref_response.text != "":
@@ -112,11 +112,3 @@ class GMA(HTTPSpotProvider):
logging.warning(f"The GMA API returned an unexpected response (HTTP {http_response.status_code}).") logging.warning(f"The GMA API returned an unexpected response (HTTP {http_response.status_code}).")
return new_spots return new_spots
def can_submit_spot(self, sig):
return sig == "GMA"
def submit_spot(self, spot, credentials):
# TODO: Implement.
# Spotting to GMA is documented: https://www.cqgma.org/api/doc/apigma_spot.pdf We (or the user) need a GMA account, and to send the password in plaintext(!!)
raise NotImplementedError("GMA upstream spot submission is not yet implemented")
+1 -9
View File
@@ -25,7 +25,7 @@ class HEMA(HTTPSpotProvider):
SPOTTER_COMMENT_PATTERN = re.compile("^\\((.*)\\) (.*)$") SPOTTER_COMMENT_PATTERN = re.compile("^\\((.*)\\) (.*)$")
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.SPOT_SEED_URL, self.POLL_INTERVAL_SEC) super().__init__("HEMA", provider_config, self.SPOT_SEED_URL, self.POLL_INTERVAL_SEC)
self._spot_seed = "" self._spot_seed = ""
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response):
@@ -73,11 +73,3 @@ class HEMA(HTTPSpotProvider):
except ConnectionError: except ConnectionError:
logging.warning("Connection error when accessing HEMA spots API.") logging.warning("Connection error when accessing HEMA spots API.")
return new_spots return new_spots
def can_submit_spot(self, sig):
return sig == "HEMA"
def submit_spot(self, spot, credentials):
# TODO: Implement. Currently blocked awaiting their API team to make a change to allow us to spot with a
# reference and not a reference *number*.
raise NotImplementedError("HEMA upstream spot submission is not yet implemented")
+3 -12
View File
@@ -15,13 +15,12 @@ class HTTPSpotProvider(SpotProvider):
"""Generic spot provider class for providers that request data via HTTP(S). Just for convenience to avoid code """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.""" duplication. Subclasses of this query the individual APIs for data."""
def __init__(self, provider_config, url, poll_interval): def __init__(self, name, provider_config, url, poll_interval):
super().__init__(provider_config) super().__init__(name, provider_config)
self._url = url self._url = url
self._poll_interval = poll_interval self._poll_interval = poll_interval
self._thread = None self._thread = None
self._stop_event = Event() self._stop_event = Event()
self._wakeup_event = Event()
def start(self): def start(self):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between # Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
@@ -32,19 +31,11 @@ class HTTPSpotProvider(SpotProvider):
def stop(self): def stop(self):
self._stop_event.set() self._stop_event.set()
self._wakeup_event.set()
def force_poll(self):
"""Trigger an immediate poll without waiting for the normal interval."""
self._wakeup_event.set()
def _run(self): def _run(self):
while True: while True:
self._wakeup_event.clear()
self._poll() self._poll()
self._wakeup_event.wait(timeout=self._poll_interval) if self._stop_event.wait(timeout=self._poll_interval):
if self._stop_event.is_set():
break break
def _poll(self): def _poll(self):
+1 -1
View File
@@ -12,7 +12,7 @@ class LLOTA(HTTPSpotProvider):
SPOTS_URL = "https://llota.app/api/public/spots" SPOTS_URL = "https://llota.app/api/public/spots"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__("LLOTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response):
new_spots = [] new_spots = []
+1 -30
View File
@@ -3,9 +3,7 @@ import re
from datetime import datetime from datetime import datetime
import pytz import pytz
import requests
from core.constants import HTTP_HEADERS
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
@@ -16,12 +14,10 @@ class ParksNPeaks(HTTPSpotProvider):
POLL_INTERVAL_SEC = 120 POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://www.parksnpeaks.org/api/ALL" SPOTS_URL = "https://www.parksnpeaks.org/api/ALL"
SUBMIT_URL = "https://www.parksnpeaks.org/api/SPOT/"
SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv" SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv"
SUBMITTABLE_SIGS = ["POTA", "SOTA", "WWFF", "HEMA", "WOTA", "ZLOTA", "SIOTA", "KRMNPA"]
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__("ParksNPeaks", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response):
new_spots = [] new_spots = []
@@ -67,28 +63,3 @@ class ParksNPeaks(HTTPSpotProvider):
# Add new spot to the list # Add new spot to the list
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, sig):
return sig in self.SUBMITTABLE_SIGS
def submit_spot(self, spot, credentials):
# TODO test this works
user_id = credentials.get("user_id", "")
api_key = credentials.get("api_key", "")
if not user_id or not api_key:
raise ValueError(
"Parks N Peaks user ID and API key are required. Get yours from your Parks N Peaks account.")
sig_ref = spot.sig_refs[0].id if spot.sig_refs else ""
body = {
"actClass": spot.sig or "",
"actCallsign": spot.dx_call,
"actSite": sig_ref,
"mode": spot.mode or "",
"freq": str(spot.freq / 1000000.0),
"comments": spot.comment or "",
"userID": user_id,
"APIKey": api_key,
}
response = requests.post(self.SUBMIT_URL, json=body, headers=HTTP_HEADERS, timeout=(5, 30))
if not response.ok:
raise RuntimeError("Parks N Peaks API returned " + str(response.status_code) + ": " + response.text)
+1 -26
View File
@@ -1,9 +1,7 @@
from datetime import datetime from datetime import datetime
import pytz import pytz
import requests
from core.constants import HTTP_HEADERS
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
@@ -14,10 +12,9 @@ class POTA(HTTPSpotProvider):
POLL_INTERVAL_SEC = 120 POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://api.pota.app/spot/activator" SPOTS_URL = "https://api.pota.app/spot/activator"
SUBMIT_URL = "https://api.pota.app/spot"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__("POTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response):
new_spots = [] new_spots = []
@@ -43,25 +40,3 @@ class POTA(HTTPSpotProvider):
# that for us. # that for us.
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, sig):
return sig == "POTA"
def submit_spot(self, spot, credentials):
sig_ref = spot.sig_refs[0].id if spot.sig_refs else None
if sig_ref:
body = {
"activator": spot.dx_call,
"spotter": spot.de_call,
"frequency": str(spot.freq / 1000.0),
"mode": spot.mode or "",
"reference": sig_ref,
"comments": spot.comment or "",
"source": "Spothole",
}
headers = {**HTTP_HEADERS, "Content-Type": "application/json"}
response = requests.post(self.SUBMIT_URL, json=body, headers=headers, timeout=(5, 30))
if not response.ok:
raise RuntimeError("POTA API returned " + str(response.status_code) + ": " + response.text)
else:
raise RuntimeError("Park reference is required for submitting POTA spots.")
+2 -1
View File
@@ -23,7 +23,8 @@ class RBN(SpotProvider):
def __init__(self, provider_config): def __init__(self, provider_config):
"""Constructor requires port number.""" """Constructor requires port number."""
super().__init__(provider_config) name = provider_config["name"] if "name" in provider_config else "RBN"
super().__init__(name, provider_config)
self._port = provider_config["port"] self._port = provider_config["port"]
self._telnet = None self._telnet = None
self._thread = Thread(target=self._handle) self._thread = Thread(target=self._handle)
+2 -48
View File
@@ -4,7 +4,7 @@ from datetime import datetime
import requests import requests
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
from core.constants import HTTP_HEADERS, SSB_SUB_MODES, DV_SUB_MODES from core.constants import HTTP_HEADERS
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
@@ -22,11 +22,8 @@ class SOTA(HTTPSpotProvider):
# SOTA spots don't contain lat/lon, we need a separate lookup for that # SOTA spots don't contain lat/lon, we need a separate lookup for that
SUMMIT_URL_ROOT = "https://api-db2.sota.org.uk/api/summits/" SUMMIT_URL_ROOT = "https://api-db2.sota.org.uk/api/summits/"
SUBMIT_URL = "https://api-db2.sota.org.uk/api/spots"
VALID_MODES = ["AM", "CW", "Data", "DV", "FM", "SSB"]
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC) super().__init__("SOTA", provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
self._api_epoch = "" self._api_epoch = ""
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response):
@@ -66,46 +63,3 @@ class SOTA(HTTPSpotProvider):
except (ConnectTimeout, ReadTimeout): except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when accessing SOTA spots API.") logging.warning(f"Timeout when accessing SOTA spots API.")
return new_spots return new_spots
def can_submit_spot(self, sig):
return sig == "SOTA"
def submit_spot(self, spot, credentials):
# TODO test this method works
access_token = credentials.get("access_token", "")
id_token = credentials.get("id_token", "")
if not access_token or not id_token:
raise ValueError("SOTA API tokens are required. Please log into SOTA in order to spot to it.")
sig_ref = spot.sig_refs[0].id if spot.sig_refs else ""
if sig_ref:
# Split reference into association and summit codes
ref_split = sig_ref.split("/")
# Figure out a valid mode. Borrowed this from PoLo :)
# https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js
mode = spot.mode
if mode and mode not in self.VALID_MODES:
if mode in SSB_SUB_MODES:
mode = "SSB"
elif mode in DV_SUB_MODES:
mode = "DV"
else:
mode = "Data"
body = {
"activatorCallsign": spot.dx_call,
"associationCode": ref_split[0],
"summitCode": ref_split[1],
"frequency": spot.freq / 1000000.0,
"mode": mode or "",
"callsign": spot.de_call,
"comments": spot.comment or "",
"type": "TEST" # todo replatce with NORMAL/QRT once testing complete
}
headers = {**HTTP_HEADERS, "Authorization": "bearer " + access_token, "id_token": id_token,
"Content-Type": "application/json"}
response = requests.post(self.SUBMIT_URL, json=body, headers=headers, timeout=(5, 30))
if not response.ok:
raise RuntimeError("SOTA API returned " + str(response.status_code) + ": " + response.text)
else:
raise RuntimeError("Summit reference is required for submitting SOTA spots.")
+5 -32
View File
@@ -2,28 +2,21 @@ from datetime import datetime
import pytz import pytz
from core.config import MAX_SPOT_AGE from core.data_store import DATA_STORE
class SpotProvider: class SpotProvider:
"""Generic spot provider class. Subclasses of this query the individual APIs for data.""" """Generic spot provider class. Subclasses of this query the individual APIs for data."""
def __init__(self, provider_config): def __init__(self, name, provider_config):
"""Constructor""" """Constructor"""
self.name = provider_config["name"] self.name = 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)
self.last_spot_time = datetime.min.replace(tzinfo=pytz.UTC) self.last_spot_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled" self.status = "Not Started" if self.enabled else "Disabled"
self._spots = None self._spots = DATA_STORE.spots
self._web_server = None
def setup(self, spots, web_server):
"""Set up the provider, e.g. giving it the spot list to work from"""
self._spots = spots
self._web_server = web_server
def start(self): def start(self):
"""Start the provider. This should return immediately after spawning threads to access the remote resources""" """Start the provider. This should return immediately after spawning threads to access the remote resources"""
@@ -59,29 +52,9 @@ class SpotProvider:
def _add_spot(self, spot): def _add_spot(self, spot):
if not spot.expired(): if not spot.expired():
self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE) self._spots.set(spot.id, spot)
# Ping the web server in case we have any SSE connections that need to see this immediately
if self._web_server:
self._web_server.notify_new_spot(spot)
def stop(self): def stop(self):
"""Stop any threads and prepare for application shutdown""" """Stop any threads and prepare for application shutdown"""
raise NotImplementedError("Subclasses must implement this method") raise NotImplementedError("Subclasses must implement this method")
def can_submit_spot(self, sig):
"""Return True if this provider supports submitting spots upstream for the given SIG."""
return False
def submit_spot(self, spot, credentials):
"""Submit a spot upstream to this provider's API. credentials is a dict with provider-specific keys.
Raises an exception with a descriptive message on failure."""
raise NotImplementedError("This provider does not support spot submission")
def force_poll(self):
"""Trigger an immediate poll without waiting for the normal interval. Default implementation here does nothing
because not all spot providers have a polling mechanism. Providers that do should override this method."""
return
+2 -2
View File
@@ -13,8 +13,8 @@ from spotproviders.spot_provider import SpotProvider
class SSESpotProvider(SpotProvider): class SSESpotProvider(SpotProvider):
"""Spot provider using Server-Sent Events.""" """Spot provider using Server-Sent Events."""
def __init__(self, provider_config, url): def __init__(self, name, provider_config, url):
super().__init__(provider_config) super().__init__(name, provider_config)
self._url = url self._url = url
self._event_source = None self._event_source = None
self._thread = None self._thread = None
+1 -50
View File
@@ -1,8 +1,5 @@
from datetime import datetime from datetime import datetime
import requests
from core.constants import HTTP_HEADERS, SSB_SUB_MODES
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
@@ -13,12 +10,9 @@ class Tiles(HTTPSpotProvider):
POLL_INTERVAL_SEC = 120 POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/spots?active_hours=24" SPOTS_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/spots?active_hours=24"
SUBMIT_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/self-spot"
VALID_MODES = ["SSB", "CW", "FT8", "FT4", "FM", "DMR", "D-STAR", "M17", "AX.25", "JS8Call", "PSK31", "Olivia",
"VarAC", "Other"]
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__("Tiles", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response):
new_spots = [] new_spots = []
@@ -48,49 +42,6 @@ class Tiles(HTTPSpotProvider):
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, sig):
return sig == "Tiles"
def submit_spot(self, spot, credentials):
# Tiles on the air currently only supports *self* spots
if spot.dx_call == spot.de_call:
# Figure out a valid mode. Borrowed this from PoLo :)
# https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js
if spot.mode:
mode = spot.mode
if mode not in self.VALID_MODES:
if mode in SSB_SUB_MODES:
mode = "SSB"
elif mode == "OLIVIA":
mode = "Olivia"
elif mode == "JS8":
mode = "JS8Call"
else:
mode = "Other"
body = {
"call_sign": spot.dx_call,
"frequency": str(spot.freq / 1000000.0),
"mode": mode or "",
"grid": spot.dx_grid or "",
"comment": spot.comment or "",
"lat": spot.dx_latitude or None,
"lon": spot.dx_longitude or None,
"qrt": spot.qrt or False,
"pin": credentials.get("offline_spot_gateway_pin", "")
}
headers = {**HTTP_HEADERS, "Content-Type": "application/json"}
response = requests.post(self.SUBMIT_URL, json=body, headers=headers, timeout=(5, 30))
if not response.ok:
raise RuntimeError(
"Tiles on the Air API returned " + str(response.status_code) + ": " + response.text)
else:
raise RuntimeError("The Tiles on the Air API requires a mode to be set.")
else:
raise RuntimeError(
"The Tiles on the Air API only supports self-spots, the DX call and spotter call must match.")
# Utility function to keep the first decimal point in a given string but remove any others. Used to parse Tiles' # Utility function to keep the first decimal point in a given string but remove any others. Used to parse Tiles'
# strange frequency format where we can sometimes have e.g. "14.123.5". # strange frequency format where we can sometimes have e.g. "14.123.5".
+1 -1
View File
@@ -13,7 +13,7 @@ class Towers(HTTPSpotProvider):
SPOTS_URL = "https://wwtota.com/api/cluster_live.php" SPOTS_URL = "https://wwtota.com/api/cluster_live.php"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__("Towers", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response):
new_spots = [] new_spots = []
+1 -1
View File
@@ -14,7 +14,7 @@ class UKPacketNet(HTTPSpotProvider):
SPOTS_URL = "https://nodes.ukpacketradio.network/api/nodedata" SPOTS_URL = "https://nodes.ukpacketradio.network/api/nodedata"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__("UK Packet Net", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response):
new_spots = [] new_spots = []
+2 -2
View File
@@ -13,8 +13,8 @@ from spotproviders.spot_provider import SpotProvider
class WebsocketSpotProvider(SpotProvider): class WebsocketSpotProvider(SpotProvider):
"""Spot provider using websockets.""" """Spot provider using websockets."""
def __init__(self, provider_config, url): def __init__(self, name, provider_config, url):
super().__init__(provider_config) super().__init__(name, provider_config)
self._url = url self._url = url
self._ws = None self._ws = None
self._thread = None self._thread = None
+1 -8
View File
@@ -21,7 +21,7 @@ class WOTA(HTTPSpotProvider):
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z" RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__("WOTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response):
new_spots = [] new_spots = []
@@ -79,10 +79,3 @@ class WOTA(HTTPSpotProvider):
except Exception as e: except Exception as e:
logging.error("Exception parsing WOTA spot", e) logging.error("Exception parsing WOTA spot", e)
return new_spots return new_spots
def can_submit_spot(self, sig):
return sig == "WOTA"
def submit_spot(self, spot, credentials):
# TODO Ask M5TEA if he's happy to share how this is done from his app
raise NotImplementedError("WOTA upstream spot submission is not yet implemented")
+1 -8
View File
@@ -12,7 +12,7 @@ class WWBOTA(SSESpotProvider):
SPOTS_URL = "https://api.wwbota.net/spots/" SPOTS_URL = "https://api.wwbota.net/spots/"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL) super().__init__("WWBOTA", provider_config, self.SPOTS_URL)
def _sse_message_to_spot(self, message): def _sse_message_to_spot(self, message):
source_spot = json.loads(message) source_spot = json.loads(message)
@@ -41,10 +41,3 @@ class WWBOTA(SSESpotProvider):
# WWBOTA does support a special "Test" spot type, we need to avoid adding that. # WWBOTA does support a special "Test" spot type, we need to avoid adding that.
return spot if source_spot["type"] != "Test" else None return spot if source_spot["type"] != "Test" else None
def can_submit_spot(self, sig):
return sig == "WWBOTA"
def submit_spot(self, spot, credentials):
# TODO: Implement. WWBOTA API docs cover this: https://api.wwbota.org/#tag/Spots/operation/create_spot_spots__post
raise NotImplementedError("WWBOTA upstream spot submission is not yet implemented")
+1 -9
View File
@@ -14,7 +14,7 @@ class WWFF(HTTPSpotProvider):
SPOTS_URL = "https://spots.wwff.co/static/spots.json" SPOTS_URL = "https://spots.wwff.co/static/spots.json"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__("WWFF", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response):
new_spots = [] new_spots = []
@@ -38,11 +38,3 @@ class WWFF(HTTPSpotProvider):
# that for us. # that for us.
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, sig):
return sig == "WWFF"
def submit_spot(self, spot, credentials):
# TODO: Implement. Spotting to WWFF should be possible, need to look up the Spotline docs or copy approach from
# PoLo. Either way I think we need an API key for the app (but maybe not for the user?)
raise NotImplementedError("WWFF upstream spot submission is not yet implemented")
+2 -1
View File
@@ -21,7 +21,8 @@ class XOTA(WebsocketSpotProvider):
SIG = None SIG = None
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, provider_config["url"]) name = provider_config["name"] if "name" in provider_config else "xOTA"
super().__init__(name, provider_config, provider_config["url"])
locations_csv = str(provider_config["locations-csv"]) if "locations-csv" in provider_config else None locations_csv = str(provider_config["locations-csv"]) if "locations-csv" in provider_config else None
self.SIG = str(provider_config["sig"]) if "sig" in provider_config else None self.SIG = str(provider_config["sig"]) if "sig" in provider_config else None
+1 -8
View File
@@ -15,7 +15,7 @@ class ZLOTA(HTTPSpotProvider):
LIST_URL = "https://ontheair.nz/assets/assets.json" LIST_URL = "https://ontheair.nz/assets/assets.json"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__("ZLOTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response): def _http_response_to_spots(self, http_response):
new_spots = [] new_spots = []
@@ -41,10 +41,3 @@ class ZLOTA(HTTPSpotProvider):
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, sig):
return sig == "ZLOTA"
def submit_spot(self, spot, credentials):
# TODO: Implement. Spotting to ZLOTA is supported via POST, see https://ontheair.nz/api
raise NotImplementedError("ZLOTA upstream spot submission is not yet implemented")
+38 -106
View File
@@ -15,14 +15,6 @@ info:
## Changelog ## Changelog
### 2.0
* POST `/spot` now supports upstream submission to the spotting services associated with various SIGs.
* **Breaking change:** The "add spot" API has changed to enable this: instead of just posting the spot object itself as the JSON content of the POST, this has moved into a `spot` object within the structure. A new `handling` object alongside it contains the `submit_upstream`, `upstream_provider`, `upstream_credentials`, and `captcha_token` fields which control the server handling of the spot.
* POST `/spot` now supports Google reCaptcha and (if the site owner has set it up) now requires `captcha_token` in order to successfully submit. (This is used to lock down the submit function and prevent submission via Spothole by bots or third-party clients.)
* GET `/options` now returns `spot_submit_providers`, a map of SIG names to the names of providers that support upstream spot submission for that SIG. (This allows clients to present the user with options of where a new spot can be sent to.)
* **Breaking change:** A user's QRZ.com and HamQTH credentials are now supplied as request headers (`X-QRZ-Username`, `X-QRZ-Password`, `X-QRZ-Session-Key`, `X-HamQTH-Username`, `X-HamQTH-Password`, `X-HamQTH-Session-ID`) rather than query parameters, to keep credentials out of server logs.
### 1.4 ### 1.4
* Spots can now include a "propagation_mode" field, and the `/options` call enumerates the options that can have. * Spots can now include a "propagation_mode" field, and the `/options` call enumerates the options that can have.
@@ -53,10 +45,10 @@ info:
license: license:
name: The Unlicense name: The Unlicense
url: https://unlicense.org/#the-unlicense url: https://unlicense.org/#the-unlicense
version: 2.0 version: v1.4
servers: servers:
- url: https://spothole.app/api/v2 - url: https://spothole.app/api/v1
tags: tags:
- name: Spots - name: Spots
@@ -337,8 +329,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
type: string $ref: '#/components/schemas/ErrorResponse'
example: "Failed"
/lookup/sigref: /lookup/sigref:
@@ -366,8 +357,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
type: string $ref: '#/components/schemas/ErrorResponse'
example: "Failed"
@@ -393,8 +383,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
type: string $ref: '#/components/schemas/ErrorResponse'
example: "Failed"
/spot: /spot:
@@ -403,53 +392,50 @@ paths:
- Spots - Spots
summary: Add a spot summary: Add a spot
description: > description: >
Supply a JSON object containing a `spot` sub-object (the spot data) and an optional `handling` sub-object Supply a new spot object, which will be added to the system. Currently, this will not be
containing server-side instructions such as upstream submission). Check `spot_submit_providers` in the reported up the chain to a cluster, POTA, SOTA etc. This may be introduced in a future version.
`/options` response to see which SIGs and providers support upstream submission. cURL example: cURL example: `curl --request POST --header "Content-Type: application/json" --data
`curl --request POST --header \"Content-Type: application/json\" --data '{\"spot\":{\"dx_call\":\"M0TRT\",\"time\":1760019539,\"freq\":14200000,\"comment\":\"Test spot please ignore\",\"de_call\":\"M0TRT\"}}' https://spothole.app/api/v2/spot`" '{"dx_call":"M0TRT","time":1760019539, "freq":14200000, "comment":"Test spot please ignore",
"de_call":"M0TRT"}' https://spothole.app/api/v1/spot`
operationId: spot operationId: spot
requestBody: requestBody:
description: Object containing a "spot" sub-object with the spot data, and an optional "handling" sub-object with server-side instructions of what to do with it. description: The JSON spot object
required: true required: true
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/SpotSubmission' $ref: '#/components/schemas/Spot'
responses: responses:
'201': '200':
description: Success description: Success
content: content:
application/json: application/json:
schema: schema:
type: string $ref: '#/components/schemas/OkResponse'
example: "OK"
'415': '415':
description: Incorrect Content-Type description: Incorrect Content-Type
content: content:
application/json: application/json:
schema: schema:
type: string $ref: '#/components/schemas/ErrorResponse'
example: "Failed"
'422': '422':
description: Validation error description: Validation error
content: content:
application/json: application/json:
schema: schema:
type: string $ref: '#/components/schemas/ErrorResponse'
example: "Failed"
'500': '500':
description: Internal server error description: Internal server error
content: content:
application/json: application/json:
schema: schema:
type: string $ref: '#/components/schemas/ErrorResponse'
example: "Failed"
components: components:
parameters: parameters:
QrzUsername: QrzUsername:
name: X-QRZ-Username name: qrz_username
in: header in: query
description: > description: >
QRZ.com username for online callsign lookup, which will enrich the returned spots and alerts QRZ.com username for online callsign lookup, which will enrich the returned spots and alerts
with extra data. Requires a QRZ.com XML Subscriber (paid) account. Supply together with with extra data. Requires a QRZ.com XML Subscriber (paid) account. Supply together with
@@ -457,14 +443,14 @@ components:
schema: schema:
type: string type: string
QrzPassword: QrzPassword:
name: X-QRZ-Password name: qrz_password
in: header in: query
description: QRZ.com password. Supply together with `qrz_username`. description: QRZ.com password. Supply together with `qrz_username`.
schema: schema:
type: string type: string
QrzSessionKey: QrzSessionKey:
name: X-QRZ-Session-Key name: qrz_session_key
in: header in: query
description: > description: >
A pre-obtained QRZ.com XML session key, as an alternative to supplying `qrz_username` and A pre-obtained QRZ.com XML session key, as an alternative to supplying `qrz_username` and
`qrz_password`. See https://www.qrz.com/docs/xml/current_spec.html for details on how to `qrz_password`. See https://www.qrz.com/docs/xml/current_spec.html for details on how to
@@ -472,22 +458,22 @@ components:
schema: schema:
type: string type: string
HamqthUsername: HamqthUsername:
name: X-HamQTH-Username name: hamqth_username
in: header in: query
description: > description: >
HamQTH username for online callsign lookup, which will enrich the returned spots and alerts HamQTH username for online callsign lookup, which will enrich the returned spots and alerts
with extra data. Supply together with `hamqth_password`, or supply `hamqth_session_id` instead. with extra data. Supply together with `hamqth_password`, or supply `hamqth_session_id` instead.
schema: schema:
type: string type: string
HamqthPassword: HamqthPassword:
name: X-HamQTH-Password name: hamqth_password
in: header in: query
description: HamQTH password. Supply together with `hamqth_username`. description: HamQTH password. Supply together with `hamqth_username`.
schema: schema:
type: string type: string
HamqthSessionId: HamqthSessionId:
name: X-HamQTH-Session-ID name: hamqth_session_id
in: header in: query
description: > description: >
A pre-obtained HamQTH session ID, as an alternative to supplying `hamqth_username` and A pre-obtained HamQTH session ID, as an alternative to supplying `hamqth_username` and
`hamqth_password`. See https://www.hamqth.com/developers.php for details on how to retrieve `hamqth_password`. See https://www.hamqth.com/developers.php for details on how to retrieve
@@ -1198,54 +1184,6 @@ components:
$ref: "#/components/schemas/PropagationMode" $ref: "#/components/schemas/PropagationMode"
SpotSubmission:
description: >
Request body for POST /spot. Contains a "spot" sub-object with the spot data, and an optional
"handling" sub-object with server-side instructions consumed by Spothole.
type: object
required:
- spot
properties:
spot:
$ref: '#/components/schemas/Spot'
handling:
type: object
description: >
Optional server-side instructions for how to process this spot submission.
properties:
submit_upstream:
type: boolean
description: >
If true, forward the spot to an external upstream provider (e.g. POTA, SOTA) rather
than only adding it to this Spothole server. Requires `sig`, at least one `sig_refs`
entry, and `upstream_provider` to be set. Check `spot_submit_providers` in the
/options response to see which SIGs and providers support this.
default: false
upstream_provider:
type: string
description: >
Name of the upstream provider to submit the spot to, e.g. "POTA" or "SOTA". Must
match one of the provider names returned in `spot_submit_providers` for the chosen SIG.
example: POTA
upstream_credentials:
type: object
description: >
Provider-specific credentials required to authenticate the upstream submission.
The required keys depend on the provider. Credentials are used only for the upstream
call and are never stored by Spothole.
additionalProperties:
type: string
example:
user_id: "12345"
api_key: "abc123"
captcha_token:
type: string
description: >
A Google reCAPTCHA v2 response token. Required when submitting upstream if the
server has reCAPTCHA configured. Obtain the token by completing the reCAPTCHA
widget rendered on the Add Spot page.
example: "03AFY_a8Xq..."
SpotStream: SpotStream:
type: object type: object
description: A server-sent event containing a spot description: A server-sent event containing a spot
@@ -1792,6 +1730,14 @@ components:
items: items:
$ref: '#/components/schemas/Alert' $ref: '#/components/schemas/Alert'
OkResponse:
type: string
example: "OK"
ErrorResponse:
type: string
example: "Failed"
DxStats: DxStats:
type: object type: object
description: Spot counts keyed by DE continent description: Spot counts keyed by DE continent
@@ -1929,7 +1875,7 @@ components:
type: integer type: integer
description: > description: >
The maximum age, in seconds, of any spot before it will be deleted by the system. When The maximum age, in seconds, of any spot before it will be deleted by the system. When
querying the /api/v2/spots endpoint and providing a "max_age" or "since" parameter, there querying the /api/v1/spots endpoint and providing a "max_age" or "since" parameter, there
is no point providing a number larger than this, because the system drops all spots older is no point providing a number larger than this, because the system drops all spots older
than this. than this.
example: 3600 example: 3600
@@ -1939,20 +1885,6 @@ components:
Whether the POST /spot call, to add spots to the server directly via its API, is permitted Whether the POST /spot call, to add spots to the server directly via its API, is permitted
on this server. on this server.
example: true example: true
spot_submit_providers:
type: object
description: >
A map of SIG name to a list of provider names that support upstream spot submission for that SIG.
If a SIG appears as a key here, the POST /spot endpoint accepts `submit_upstream: true` for
spots with that SIG, and will forward the spot to one of the listed providers. Omitted if no
providers support upstream submission.
additionalProperties:
type: array
items:
type: string
example:
POTA: [ POTA ]
SOTA: [ SOTA, GMA, ParksNPeaks ]
CallLookup: CallLookup:
type: object type: object
+46 -213
View File
@@ -1,35 +1,7 @@
// Credentials schema per provider name. Defines the fields to collect and how to label them.
const PROVIDER_CREDENTIAL_SCHEMAS = {
// todo Figure out SOTA authentication
// see e.g. https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAAccount.jsx
// https://github.com/ham2k/app-polo/blob/main/src/store/apis/apiSOTA/apiSOTA.js
// Refresh token? Way to show user that they need to log in again because cached credentials aren't valid?
// todo type: text/password distinction on text boxes so API keys can be obscured
"SOTA": [
{key: "access_token", label: "SOTA Access Token", help: ""},
{key: "id_token", label: "SOTA ID Token", help: "TODO SOTA authentication to provide this..."}
],
"ParksNPeaks": [
{key: "user_id", label: "Parks N Peaks User ID", help: ""},
{key: "api_key", label: "Parks N Peaks API Key", help: "Get your API key from your Parks N Peaks account."}
],
"ZLOTA": [
{key: "user_id", label: "ZLOTA User ID", help: ""},
{key: "api_key", label: "ZLOTA User PIN", help: "Get your PIN from your ZLOTA account."}
],
"Tiles": [
{
key: "offline_spot_gateway_pin",
label: "Offline Spot Gateway PIN",
help: "Get your PIN from your Tiles on the Air account profile."
}
]
};
// Load server options. Once a successful callback is made from this, we can populate the choice boxes in the form and load // Load server options. Once a successful callback is made from this, we can populate the choice boxes in the form and load
// any saved values from local storage. // any saved values from local storage.
function loadOptions() { function loadOptions() {
$.getJSON('/api/v2/options', function (jsonData) { $.getJSON('/api/v1/options', function (jsonData) {
// Store options // Store options
options = jsonData; options = jsonData;
@@ -49,144 +21,11 @@ function loadOptions() {
})); }));
}); });
// Load reCAPTCHA if a site key is configured (key is inlined into page by server)
if (window._recaptchaSiteKey) {
loadRecaptcha(window._recaptchaSiteKey);
}
// Load settings from settings storage now all the controls are available // Load settings from settings storage now all the controls are available
loadSettings(); loadSettings();
// Update the upstream area for any pre-selected SIG
updateUpstreamArea();
}); });
} }
// Load and inject the reCAPTCHA script
function loadRecaptcha(siteKey) {
window._recaptchaSiteKey = siteKey;
if (!document.getElementById('recaptcha-script')) {
const script = document.createElement('script');
script.id = 'recaptcha-script';
script.src = 'https://www.google.com/recaptcha/api.js?render=explicit&onload=renderRecaptcha';
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
$("#recaptcha-area").show();
}
// Called by reCAPTCHA after its script loads
function renderRecaptcha() {
window._recaptchaWidgetId = grecaptcha.render('recaptcha-widget', {
sitekey: window._recaptchaSiteKey,
size: 'normal'
});
}
// Update the "Send spot to..." area based on the currently selected SIG
function updateUpstreamArea() {
if (!window._allowUpstreamSpotting || !options || !options["spot_submit_providers"]) {
$("#upstream-area").hide();
return;
}
const sig = $("#sig").val();
const providers = (sig && options["spot_submit_providers"][sig]) ? options["spot_submit_providers"][sig] : [];
if (providers.length === 0) {
$("#upstream-area").hide();
return;
}
$("#upstream-area").show();
// Update the provider selector
$("#upstream-provider-select").empty();
$.each(providers, function (i, name) {
$("#upstream-provider-select").append($('<option>', {value: name, text: name}));
});
if (providers.length > 1) {
$("#upstream-provider-label").text("upstream spot sources:");
$("#upstream-provider-select-col").show();
} else {
$("#upstream-provider-label").text(providers[0]);
$("#upstream-provider-select-col").hide();
}
// Show the credentials button if this provider has an authentication mechanism and we need input from the user
updateCredentialsButton();
}
// Update the credentials button visibility based on selected provider
function updateCredentialsButton() {
const providerName = getSelectedUpstreamProvider();
if (providerName && PROVIDER_CREDENTIAL_SCHEMAS[providerName]) {
$("#upstream-credentials-btn").show();
} else {
$("#upstream-credentials-btn").hide();
}
}
// Get the currently selected upstream provider name
function getSelectedUpstreamProvider() {
const providers = (options && options["spot_submit_providers"] && $("#sig").val())
? (options["spot_submit_providers"][$("#sig").val()] || [])
: [];
if (providers.length === 0) return null;
if (providers.length === 1) return providers[0];
return $("#upstream-provider-select").val();
}
// Show the credentials modal for the currently selected upstream provider
function showCredentialsModal() {
const providerName = getSelectedUpstreamProvider();
if (!providerName || !PROVIDER_CREDENTIAL_SCHEMAS[providerName]) return;
const schema = PROVIDER_CREDENTIAL_SCHEMAS[providerName];
const stored = loadCredentials(providerName);
$("#credentials-provider-name").text(providerName);
$("#credentials-fields").empty();
$.each(schema, function (i, field) {
const val = stored[field.key] || "";
let html = '<div class="mb-3">';
html += '<label for="cred-' + field.key + '" class="form-label">' + field.label + '</label>';
html += '<input type="text" class="form-control" id="cred-' + field.key + '" value="' + $('<div>').text(val).html() + '">';
if (field.help) {
html += '<div class="form-text">' + field.help + '</div>';
}
html += '</div>';
$("#credentials-fields").append(html);
});
// Store provider name for saveCredentials()
$("#credentials-modal").data("provider", providerName);
new bootstrap.Modal(document.getElementById('credentials-modal')).show();
}
// Save credentials from the modal to local storage
function saveCredentials() {
const providerName = $("#credentials-modal").data("provider");
if (!providerName || !PROVIDER_CREDENTIAL_SCHEMAS[providerName]) return;
const schema = PROVIDER_CREDENTIAL_SCHEMAS[providerName];
const creds = {};
$.each(schema, function (i, field) {
creds[field.key] = $("#cred-" + field.key).val();
});
localStorage.setItem("upstream-credentials-" + providerName, JSON.stringify(creds));
bootstrap.Modal.getInstance(document.getElementById('credentials-modal')).hide();
}
// Load credentials for a provider from local storage
function loadCredentials(providerName) {
const stored = localStorage.getItem("upstream-credentials-" + providerName);
return stored ? JSON.parse(stored) : {};
}
// Method called to add a spot to the server // Method called to add a spot to the server
function addSpot() { function addSpot() {
try { try {
@@ -203,65 +42,57 @@ function addSpot() {
const comment = $("#comment").val(); const comment = $("#comment").val();
const de = $("#de-call").val().toUpperCase(); const de = $("#de-call").val().toUpperCase();
// Prepare the spot object for the server
const spot = {}; const spot = {};
if (dx !== "") {
spot["dx_call"] = dx; spot["dx_call"] = dx;
} else {
showAddSpotError("A DX callsign is required in order to spot.");
return;
}
if (freqStr !== "") {
spot["freq"] = parseFloat(freqStr) * 1000; spot["freq"] = parseFloat(freqStr) * 1000;
if (mode !== "") spot["mode"] = mode; } else {
if (sig !== "") spot["sig"] = sig; showAddSpotError("A frequency is required in order to spot.");
if (sigRef !== "") spot["sig_refs"] = [{sig: sig, id: sigRef}]; return;
if (dxGrid !== "") spot["dx_grid"] = dxGrid; }
if (comment !== "") spot["comment"] = comment; if (mode !== "") {
spot["mode"] = mode;
}
if (sig !== "") {
spot["sig"] = sig;
}
if (sigRef !== "") {
spot["sig_refs"] = [{sig: sig, id: sigRef}];
}
if (dxGrid !== "") {
spot["dx_grid"] = dxGrid;
}
if (comment !== "") {
spot["comment"] = comment;
}
if (de !== "") {
spot["de_call"] = de; spot["de_call"] = de;
} else {
showAddSpotError("A spotter callsign is required in order to spot.");
return;
}
spot["time"] = moment.utc().valueOf() / 1000.0; spot["time"] = moment.utc().valueOf() / 1000.0;
// Prepare "handling" structure to tell the server what to do with this spot $.ajax("/api/v1/spot", {
const handling = {}; data: JSON.stringify(spot),
// Add CAPTCHA token if reCAPTCHA is loaded
if (window._recaptchaWidgetId !== undefined) {
handling["captcha_token"] = grecaptcha.getResponse(window._recaptchaWidgetId);
}
// Upstream submission
const submitUpstream = $("#submit-upstream").is(":checked");
const upstreamProviderName = getSelectedUpstreamProvider();
if (submitUpstream && upstreamProviderName) {
handling["submit_upstream"] = true;
handling["upstream_provider"] = upstreamProviderName;
handling["upstream_credentials"] = loadCredentials(upstreamProviderName);
}
$.ajax("/api/v2/spot", {
data: JSON.stringify({spot, handling}),
contentType: 'application/json', contentType: 'application/json',
type: 'POST', type: 'POST',
timeout: 10000, timeout: 10000,
success: async function (result) { success: async function () {
// Reset CAPTCHA for next use
if (window._recaptchaWidgetId !== undefined) {
grecaptcha.reset(window._recaptchaWidgetId);
}
if (result && result.startsWith && result.startsWith("Warning")) {
$("#result-good").html("<div class='alert alert-warning fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-triangle-exclamation'></i> " + result + " Returning you to the spots list...</div>");
} else {
$("#result-good").html("<div class='alert alert-success fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-check'></i> Spot submitted. Returning you to the spots list...</div>"); $("#result-good").html("<div class='alert alert-success fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-check'></i> Spot submitted. Returning you to the spots list...</div>");
}
$("#result-bad").html(""); $("#result-bad").html("");
setTimeout(() => { setTimeout(() => {
$("#result-good").hide(); $("#result-good").hide();
window.location.replace("/"); window.location.replace("/");
}, 2000); }, 1000);
}, },
error: function (result) { error: function (result) {
if (window._recaptchaWidgetId !== undefined) {
grecaptcha.reset(window._recaptchaWidgetId);
}
if (result.responseText) {
showAddSpotError(result.responseText.slice(1, -1)); showAddSpotError(result.responseText.slice(1, -1));
} else {
showAddSpotError("The server did not return a response.");
}
} }
}); });
} catch (error) { } catch (error) {
@@ -290,18 +121,20 @@ $("#mode").change(function () {
$(this).val($(this).val().trim().toUpperCase()); $(this).val($(this).val().trim().toUpperCase());
}); });
// Update upstream area and credentials button when SIG changes // Display the intro box, unless the user has already dismissed it once.
$("#sig").change(function () { function displayIntroBox() {
updateUpstreamArea(); if (localStorage.getItem("add-spot-intro-box-dismissed") == null) {
}); $("#add-spot-intro-box").show();
}
// Update credentials button when provider selector changes $("#add-spot-intro-box-dismiss").click(function () {
$("#upstream-provider-select").change(function () { localStorage.setItem("add-spot-intro-box-dismissed", true);
updateCredentialsButton(); });
}); }
// Startup // Startup
$(document).ready(function () { $(document).ready(function () {
// Load options // Load options
loadOptions(); loadOptions();
// Display intro box
displayIntroBox();
}); });
+7 -4
View File
@@ -8,7 +8,7 @@ let alerts = [];
// Load alerts and populate the table. // Load alerts and populate the table.
function loadAlerts() { function loadAlerts() {
$.ajax({url: '/api/v2/alerts' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) { $.getJSON('/api/v1/alerts' + buildQueryString(false), function (jsonData) {
// Store last updated time // Store last updated time
lastUpdateTime = moment.utc(); lastUpdateTime = moment.utc();
updateRefreshDisplay(); updateRefreshDisplay();
@@ -16,11 +16,11 @@ function loadAlerts() {
alerts = jsonData; alerts = jsonData;
// Update table // Update table
updateTable(); updateTable();
}}); });
} }
// Build a query string for the API, based on the filters that the user has selected. // Build a query string for the API, based on the filters that the user has selected.
function buildQueryString() { function buildQueryString(includeCredentials) {
let str = "?"; let str = "?";
["dx_continent", "source"].forEach(fn => { ["dx_continent", "source"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) { if (!allFilterOptionsSelected(fn)) {
@@ -35,6 +35,9 @@ function buildQueryString() {
if ($("#dxpeditions_skip_max_duration_check")[0].checked) { if ($("#dxpeditions_skip_max_duration_check")[0].checked) {
str = str + "&dxpeditions_skip_max_duration_check=true"; str = str + "&dxpeditions_skip_max_duration_check=true";
} }
if (includeCredentials) {
str = str + getCredentialQueryString();
}
return str; return str;
} }
@@ -279,7 +282,7 @@ function addAlertRowsToTable(tbody, alerts) {
// Load server options. Once a successful callback is made from this, we then query alerts. // Load server options. Once a successful callback is made from this, we then query alerts.
function loadOptions() { function loadOptions() {
$.getJSON('/api/v2/options', function (jsonData) { $.getJSON('/api/v1/options', function (jsonData) {
// Store options // Store options
options = jsonData; options = jsonData;
+3 -3
View File
@@ -20,7 +20,7 @@ function loadSpots() {
if (evtSource != null) { if (evtSource != null) {
evtSource.close(); evtSource.close();
} }
$.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) { $.getJSON('/api/v1/spots' + buildQueryString(), function (jsonData) {
// Store data // Store data
spots = jsonData; spots = jsonData;
// Update bands display // Update bands display
@@ -35,7 +35,7 @@ function startSSEConnection() {
if (evtSource != null) { if (evtSource != null) {
evtSource.close(); evtSource.close();
} }
evtSource = new EventSource('/api/v2/spots/stream' + buildQueryString()); evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString());
evtSource.onmessage = function (event) { evtSource.onmessage = function (event) {
const newSpot = JSON.parse(event.data); const newSpot = JSON.parse(event.data);
@@ -273,7 +273,7 @@ function removeDuplicatesForBandPanel(spotList) {
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query // Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
// spots repeatedly. // spots repeatedly.
function loadOptions() { function loadOptions() {
$.getJSON('/api/v2/options', function (jsonData) { $.getJSON('/api/v1/options', function (jsonData) {
// Store options // Store options
options = jsonData; options = jsonData;
+8 -8
View File
@@ -250,23 +250,23 @@ function closeDataPanel() {
closePanel("#data-area"); closePanel("#data-area");
} }
// Build a headers object containing any QRZ.com / HamQTH credentials the user has supplied, // Build a query string fragment containing any QRZ.com / HamQTH credentials the user has supplied,
// provided the corresponding "enabled" checkbox is ticked. // provided the corresponding "enabled" checkbox is ticked.
function getCredentialHeaders() { function getCredentialQueryString() {
const headers = {}; let str = "";
if ($("#qrz-enabled")[0] && $("#qrz-enabled")[0].checked) { if ($("#qrz-enabled")[0] && $("#qrz-enabled")[0].checked) {
const qrzUsername = $("#qrz-username").val(); const qrzUsername = $("#qrz-username").val();
const qrzPassword = $("#qrz-password").val(); const qrzPassword = $("#qrz-password").val();
if (qrzUsername) headers["X-QRZ-Username"] = qrzUsername; if (qrzUsername) str += "&qrz_username=" + encodeURIComponent(qrzUsername);
if (qrzPassword) headers["X-QRZ-Password"] = qrzPassword; if (qrzPassword) str += "&qrz_password=" + encodeURIComponent(qrzPassword);
} }
if ($("#hamqth-enabled")[0] && $("#hamqth-enabled")[0].checked) { if ($("#hamqth-enabled")[0] && $("#hamqth-enabled")[0].checked) {
const hamqthUsername = $("#hamqth-username").val(); const hamqthUsername = $("#hamqth-username").val();
const hamqthPassword = $("#hamqth-password").val(); const hamqthPassword = $("#hamqth-password").val();
if (hamqthUsername) headers["X-HamQTH-Username"] = hamqthUsername; if (hamqthUsername) str += "&hamqth_username=" + encodeURIComponent(hamqthUsername);
if (hamqthPassword) headers["X-HamQTH-Password"] = hamqthPassword; if (hamqthPassword) str += "&hamqth_password=" + encodeURIComponent(hamqthPassword);
} }
return headers; return str;
} }
+3 -3
View File
@@ -10,7 +10,7 @@ let ionosondeChart = null;
// Load solar conditions // Load solar conditions
function loadSolarConditions() { function loadSolarConditions() {
$.getJSON('/api/v2/solar', function (jsonData) { $.getJSON('/api/v1/solar', function (jsonData) {
// HF // HF
@@ -539,7 +539,7 @@ function renderIonosondeData() {
ctx.strokeStyle = gridColor; ctx.strokeStyle = gridColor;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.setLineDash([]); ctx.setLineDash([]);
// Add an extra horizontal line for 30MHz, which should correspond to the top of the chart and avoid having // Add an extra vertical line for 30MHz, which should correspond to the top of the chart and avoid having
// no top "border" gridline // no top "border" gridline
const y30 = scales.y.getPixelForValue(30); const y30 = scales.y.getPixelForValue(30);
if (y30 >= chartArea.top && y30 <= chartArea.bottom) { if (y30 >= chartArea.top && y30 <= chartArea.bottom) {
@@ -660,7 +660,7 @@ function dxStatsContientChanged() {
// Fetch DX stats from the API and render // Fetch DX stats from the API and render
function loadDxStats() { function loadDxStats() {
$.getJSON('/api/v2/dxstats', function (jsonData) { $.getJSON('/api/v1/dxstats', function (jsonData) {
dxStatsData = jsonData; dxStatsData = jsonData;
renderDxStats(); renderDxStats();
}); });
+7 -4
View File
@@ -45,7 +45,7 @@ function loadSpots() {
// 2) (If we have credentials) reload with them, replacing what's already there, // 2) (If we have credentials) reload with them, replacing what's already there,
// 3) Subscribe to the SSE endpoint (with credentials if we have them) so that updates come with augmented // 3) Subscribe to the SSE endpoint (with credentials if we have them) so that updates come with augmented
// data if they can. // data if they can.
$.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) { $.getJSON('/api/v1/spots' + buildQueryString(false), function (jsonData) {
// Store data // Store data
spots = jsonData; spots = jsonData;
// Update map // Update map
@@ -68,7 +68,7 @@ function loadSpots() {
// now // now
startSSEConnection(); startSSEConnection();
} }
}}); });
} }
// Start the SSE connection to receive new spots as they arrive // Start the SSE connection to receive new spots as they arrive
@@ -163,7 +163,7 @@ function removeSpotFromMap(key) {
} }
// Build a query string for the API, based on the filters that the user has selected. // Build a query string for the API, based on the filters that the user has selected.
function buildQueryString() { function buildQueryString(includeCredentials) {
let str = "?"; let str = "?";
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => { ["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) { if (!allFilterOptionsSelected(fn)) {
@@ -173,6 +173,9 @@ function buildQueryString() {
str = str + "max_age=" + $("#max-spot-age option:selected").val(); str = str + "max_age=" + $("#max-spot-age option:selected").val();
// Additional filters for the map view: No dupes, no QRT, only spots with good locations // Additional filters for the map view: No dupes, no QRT, only spots with good locations
str = str + "&dedupe=true&allow_qrt=false"; str = str + "&dedupe=true&allow_qrt=false";
if (includeCredentials) {
str = str + getCredentialQueryString();
}
return str; return str;
} }
@@ -301,7 +304,7 @@ function getTooltipText(s) {
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query // Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
// spots repeatedly. // spots repeatedly.
function loadOptions() { function loadOptions() {
$.getJSON('/api/v2/options', function (jsonData) { $.getJSON('/api/v1/options', function (jsonData) {
// Store options // Store options
options = jsonData; options = jsonData;
+8 -5
View File
@@ -20,7 +20,7 @@ function loadSpots() {
} }
// Make the new query // Make the new query
$.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) { $.getJSON('/api/v1/spots' + buildQueryString(false), function (jsonData) {
// Store data // Store data
spots = jsonData; spots = jsonData;
// Update table // Update table
@@ -30,7 +30,7 @@ function loadSpots() {
if (run) { if (run) {
startSSEConnection(); startSSEConnection();
} }
}}); });
} }
// Start an SSE connection (closing an existing one if it exists). This will then be used to add to the table on the // Start an SSE connection (closing an existing one if it exists). This will then be used to add to the table on the
@@ -39,7 +39,7 @@ function startSSEConnection() {
if (evtSource != null) { if (evtSource != null) {
evtSource.close(); evtSource.close();
} }
evtSource = new EventSource('/api/v2/spots/stream' + buildQueryString()); evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString(true));
evtSource.onmessage = function (event) { evtSource.onmessage = function (event) {
// Get the new spot // Get the new spot
@@ -86,7 +86,7 @@ function startSSEConnection() {
} }
// Build a query string for the API, based on the filters that the user has selected. // Build a query string for the API, based on the filters that the user has selected.
function buildQueryString() { function buildQueryString(includeCredentials) {
let str = "?"; let str = "?";
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => { ["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) { if (!allFilterOptionsSelected(fn)) {
@@ -97,6 +97,9 @@ function buildQueryString() {
if ($("#search").val() !== "") { if ($("#search").val() !== "") {
str = str + "&text_includes=" + encodeURIComponent($("#search").val()); str = str + "&text_includes=" + encodeURIComponent($("#search").val());
} }
if (includeCredentials) {
str = str + getCredentialQueryString();
}
return str; return str;
} }
@@ -415,7 +418,7 @@ function createNewTableRowsForSpot(s, highlightNew) {
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query // Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
// spots repeatedly. // spots repeatedly.
function loadOptions() { function loadOptions() {
$.getJSON('/api/v2/options', function (jsonData) { $.getJSON('/api/v1/options', function (jsonData) {
// Store options // Store options
options = jsonData; options = jsonData;
+1 -1
View File
@@ -1,6 +1,6 @@
// Load server status // Load server status
function loadStatus() { function loadStatus() {
$.getJSON('/api/v2/status', function (jsonData) { $.getJSON('/api/v1/status', function (jsonData) {
$("#software-version").text(jsonData["software-version"]); $("#software-version").text(jsonData["software-version"]);
$("#server-owner-callsign").text(jsonData["server-owner-callsign"]); $("#server-owner-callsign").text(jsonData["server-owner-callsign"]);
$("#up-since").text(moment().subtract(jsonData["uptime"], 'seconds').fromNow()); $("#up-since").text(moment().subtract(jsonData["uptime"], 'seconds').fromNow());
-83
View File
@@ -296,16 +296,6 @@ function setBandColorScheme(scheme) {
return ret; return ret;
} }
// Get the list of known bands
function getKnownBands() {
return Array.from(Object.keys(BAND_COLOR_SCHEMES[bandColorScheme]));
}
// Get the list of available band colour schemes
function getAvailableBandColorSchemes() {
return Array.from(Object.keys(BAND_COLOR_SCHEMES));
}
// Band name to colour (in the current colour scheme). If the band is unknown, black will be returned. // Band name to colour (in the current colour scheme). If the band is unknown, black will be returned.
function bandToColor(band) { function bandToColor(band) {
let col = (band != null) ? BAND_COLOR_SCHEMES[bandColorScheme][band] : null; let col = (band != null) ? BAND_COLOR_SCHEMES[bandColorScheme][band] : null;
@@ -324,22 +314,6 @@ function bandToContrastColor(band) {
return (lum > 128) ? "#000000" : "#ffffff"; return (lum > 128) ? "#000000" : "#ffffff";
} }
const MODE_TYPE_COLOR_SCHEMES = {
"CW": "red",
"PHONE": "green",
"DATA": "blue"
}
// Mode type (CW, PHONE, DATA) to colour. If the mode type is unknown, black will be returned.
function modeTypeToColor(modeType) {
let col = (modeType != null) ? MODE_TYPE_COLOR_SCHEMES[modeType.toUpperCase()] : null;
if (col) {
return col;
} else {
return "#000000";
}
}
const SIG_ICONS = { const SIG_ICONS = {
"POTA": "fa-tree", "POTA": "fa-tree",
"SOTA": "fa-mountain-sun", "SOTA": "fa-mountain-sun",
@@ -366,31 +340,6 @@ const SIG_ICONS = {
"Toilets": "fa-toilet" "Toilets": "fa-toilet"
} }
const SIG_NAMES = {
"POTA": "Parks on the Air",
"SOTA": "Summits on the Air",
"WWFF": "Worldwide Flora & Fauna",
"GMA": "Global Mountain Activity",
"WWBOTA": "Bunkers on the Air",
"HEMA": "Humps Excluding Marilyns Award",
"IOTA": "Islands on the Air",
"MOTA": "Mills on the Air",
"ARLHS": "Amateur Radio Lighthouse Society",
"ILLW": "International Lighthouse Lightship Weekend",
"SIOTA": "Silos on the Air",
"WCA": "World Castles Award",
"ZLOTA": "New Zealand on the Air",
"WOTA": "Wainwrights on the Air",
"BOTA": "Beaches on the Air",
"KRMNPA": "Keith Roget Memorial National Parks Award",
"LLOTA": "Lagos y Lagunas on the Air",
"WWTOTA": "Towers on the Air",
"WAB": "Worked All Britain",
"WAI": "Worked All Ireland",
"Tiles": "Tiles on the Air",
"TOTA": "Toilets on the Air"
}
// Get the Font Awesome icon for a given SIG. If the SIG is unknown, the provided default symbol will be returned // Get the Font Awesome icon for a given SIG. If the SIG is unknown, the provided default symbol will be returned
function sigToIcon(sig, defaultIcon) { function sigToIcon(sig, defaultIcon) {
let col = (sig != null) ? SIG_ICONS[sig] : null; let col = (sig != null) ? SIG_ICONS[sig] : null;
@@ -405,35 +354,3 @@ function sigToIcon(sig, defaultIcon) {
} }
} }
} }
// Get the full name for a given SIG abbreviation. If the SIG is unknown, an empty string will be returned.
function sigToName(sig) {
let col = (sig != null) ? SIG_NAMES[sig] : null;
if (col) {
return col;
} else {
let col = (sig != null) ? SIG_NAMES[sig.toUpperCase()] : null;
if (col) {
return col;
} else {
return "";
}
}
}
// Get the list of known SIGs
function getKnownSIGs() {
return Array.from(Object.keys(SIG_ICONS));
}
// Format a Maidenhead grid with alternating alphabetic blocks in lower case
function formatGrid(grid) {
grid = grid.toUpperCase();
if (grid.length >= 6) {
grid = grid.substring(0, 4) + grid.substring(4, 6).toLowerCase() + grid.substring(6);
}
if (grid.length >= 12) {
grid = grid.substring(0, 10) + grid.substring(10, 12).toLowerCase() + grid.substring(14);
}
return grid;
}
-1
View File
@@ -117,7 +117,6 @@
<li>Some SIGs, such as Worked all Britain (WAB), don't have their own spotting site and can <em>only</em> be <li>Some SIGs, such as Worked all Britain (WAB), don't have their own spotting site and can <em>only</em> be
identified through comments on spots retrieved from other sources. identified through comments on spots retrieved from other sources.
</li> </li>
<li>SIGs have well-defined names, whereas the server owner may name the sources as they see fit.</li>
</ol> </ol>
<p>Spothole's web interface exists not just for the end user, but also as a reference implementation for the API, so <p>Spothole's web interface exists not just for the end user, but also as a reference implementation for the API, so
I have chosen to demonstrate both methods of filtering.</p> I have chosen to demonstrate both methods of filtering.</p>
+6 -6
View File
@@ -24,14 +24,14 @@
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
<form class="row g-3" onsubmit="return addSpot();"> <form class="row g-3">
<div class="col-auto"> <div class="col-auto">
<label for="dx-call" class="form-label">DX Call *</label> <label for="dx-call" class="form-label">DX Call *</label>
<input type="text" class="form-control input-narrow" id="dx-call" placeholder="N0CALL" required> <input type="text" class="form-control input-narrow" id="dx-call" placeholder="N0CALL">
</div> </div>
<div class="col-auto"> <div class="col-auto">
<label for="freq" class="form-label">Frequency (kHz) *</label> <label for="freq" class="form-label">Frequency (kHz) *</label>
<input type="text" class="form-control input-narrow" id="freq" placeholder="e.g. 14100" required> <input type="text" class="form-control input-narrow" id="freq" placeholder="e.g. 14100">
</div> </div>
<div class="col-auto"> <div class="col-auto">
<label for="mode" class="form-label">Mode</label> <label for="mode" class="form-label">Mode</label>
@@ -60,10 +60,10 @@
<div class="col-auto"> <div class="col-auto">
<label for="de-call" class="form-label">Your Call *</label> <label for="de-call" class="form-label">Your Call *</label>
<input type="text" class="form-control storeable-text input-narrow" id="de-call" <input type="text" class="form-control storeable-text input-narrow" id="de-call"
placeholder="N0CALL" required> placeholder="N0CALL">
</div> </div>
<div class="col-auto"> <div class="col-auto">
<button type="submit" class="btn btn-primary mt-2em">Spot</button> <button type="button" class="btn btn-primary mt-2em" onclick="addSpot();">Spot</button>
</div> </div>
</form> </form>
@@ -76,7 +76,7 @@
</div> </div>
<script src="/static/js/add-spot.js?v=1785082221"></script> <script src="/static/js/add-spot.js?v=1785434214"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-add-spot").addClass("active"); $("#nav-link-add-spot").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -75,7 +75,7 @@
</div> </div>
<script src="/static/js/alerts.js?v=1785082221"></script> <script src="/static/js/alerts.js?v=1785434213"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-alerts").addClass("active"); $("#nav-link-alerts").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -75,8 +75,8 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/static/js/spotsbandsandmap.js?v=1785082221"></script> <script src="/static/js/spotsbandsandmap.js?v=1785434214"></script>
<script src="/static/js/bands.js?v=1785082221"></script> <script src="/static/js/bands.js?v=1785434214"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-bands").addClass("active"); $("#nav-link-bands").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+5 -5
View File
@@ -1,6 +1,6 @@
{% extends "skeleton.html" %} {% extends "skeleton.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/css/style.css?v=1785082221" type="text/css"> <link rel="stylesheet" href="/static/css/style.css?v=1785434213" type="text/css">
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet"> <link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet"> <link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet"> <link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
@@ -10,10 +10,10 @@
<script src="/static/vendor/js/bootstrap-5.3.8.bundle.min.js"></script> <script src="/static/vendor/js/bootstrap-5.3.8.bundle.min.js"></script>
<script src="/static/vendor/js/tinycolor2-1.6.0.min.js"></script> <script src="/static/vendor/js/tinycolor2-1.6.0.min.js"></script>
<script src="/static/js/utils.js?v=1785082221"></script> <script src="/static/js/utils.js?v=1785434213"></script>
<script src="/static/js/ui-ham.js?v=1785082221"></script> <script src="/static/js/ui-ham.js?v=1785434213"></script>
<script src="/static/js/geo.js?v=1785082221"></script> <script src="/static/js/geo.js?v=1785434213"></script>
<script src="/static/js/common.js?v=1785082221"></script> <script src="/static/js/common.js?v=1785434213"></script>
{% end %} {% end %}
{% block body %} {% block body %}
<div class="container"> <div class="container">
+1 -1
View File
@@ -284,7 +284,7 @@
</div> </div>
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script> <script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
<script src="/static/js/conditions.js?v=1785082221"></script> <script src="/static/js/conditions.js?v=1785434213"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active"); $("#nav-link-conditions").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -108,8 +108,8 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/static/js/spotsbandsandmap.js?v=1785082221"></script> <script src="/static/js/spotsbandsandmap.js?v=1785434213"></script>
<script src="/static/js/map.js?v=1785082221"></script> <script src="/static/js/map.js?v=1785434213"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-map").addClass("active"); $("#nav-link-map").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -116,8 +116,8 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/static/js/spotsbandsandmap.js?v=1785082221"></script> <script src="/static/js/spotsbandsandmap.js?v=1785434213"></script>
<script src="/static/js/spots.js?v=1785082221"></script> <script src="/static/js/spots.js?v=1785434213"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-spots").addClass("active"); $("#nav-link-spots").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -59,7 +59,7 @@
</div> </div>
</div> </div>
<script src="/static/js/status.js?v=1785082221"></script> <script src="/static/js/status.js?v=1785434213"></script>
<script> <script>
$(document).ready(function () { $(document).ready(function () {
$("#nav-link-status").addClass("active"); $("#nav-link-status").addClass("active");