21 Commits

Author SHA1 Message Date
Ian Renton
59fa6500eb Merge branch 'main' into 95-send-spots-to-xota 2026-06-21 22:08:25 +01:00
Ian Renton
7a34526a91 Merge branch 'main' into 95-send-spots-to-xota 2026-06-21 21:28:20 +01:00
Ian Renton
d1c4dd4e4c Merge branch 'main' into 95-send-spots-to-xota
# Conflicts:
#	webassets/apidocs/openapi.yml
2026-06-21 11:12:48 +01:00
Ian Renton
692fa83323 Merge branch 'main' into 95-send-spots-to-xota 2026-06-21 09:36:30 +01:00
Ian Renton
6062211bc7 Merge branch 'main' into 95-send-spots-to-xota
# Conflicts:
#	README.md
#	webassets/apidocs/openapi.yml
2026-06-21 09:03:31 +01:00
Ian Renton
ec5984ec35 Updated README.md 2026-06-20 13:39:52 +01:00
Ian Renton
2affe460a5 Merge branch 'main' into 95-send-spots-to-xota 2026-06-20 13:29:00 +01:00
Ian Renton
277e374994 Merge branch 'main' into 95-send-spots-to-xota
# Conflicts:
#	README.md
2026-06-20 12:23:06 +01:00
Ian Renton
8d09484425 Move some of the "add spot" checks from client-side to server-side to avoid duplication and enforce them in the proper place. #95 2026-06-20 10:30:24 +01:00
Ian Renton
e08a183d1b Move user credentials into HTTP request headers to prevent them being logged in the server logs 2026-06-20 10:15:35 +01:00
Ian Renton
ae17839096 Stop fudging the server-side handling instructions for "add spot" into the spot data structure itself, instead break them out into a new area. This is a breaking change to the API so all API endpoints have been bumped to v2. 2026-06-20 09:57:09 +01:00
Ian Renton
1e42c69b78 Clear up one TODO and update comments on some more 2026-06-20 08:33:00 +01:00
Ian Renton
20966cc7cf IDE inspection fixes and global autoformat 2026-06-20 08:28:11 +01:00
Ian Renton
172a31bb18 Merge branch 'main' into 95-send-spots-to-xota
# Conflicts:
#	README.md
#	server/handlers/api/addspot.py
#	server/handlers/api/options.py
#	spotproviders/tiles.py
#	templates/about.html
#	templates/add_spot.html
#	templates/alerts.html
#	templates/api_only_home.html
#	templates/bands.html
#	templates/base.html
#	templates/conditions.html
#	templates/map.html
#	templates/spots.html
#	templates/status.html
#	webassets/css/style.css
#	webassets/js/add-spot.js
#	webassets/js/geo.js
#	webassets/js/ui-ham.js
#	webassets/js/utils.js
2026-06-19 21:48:10 +01:00
Ian Renton
88f055384d Internalise third-party dependencies
(cherry picked from commit 725eb619b4)
2026-06-18 20:36:46 +01:00
Ian Renton
4408203d55 Fix some IDE warnings
(cherry picked from commit 8fc3cfa56d)
2026-06-18 20:36:21 +01:00
Ian Renton
af9f542740 Skip JS integrity checks since jsdelivr docs say they may re-minify sources as necessary, potentially resulting in different checksums
(cherry picked from commit e5b2afd765)
2026-06-18 20:36:03 +01:00
ian
b81f5eeb5a Update webassets/js/add-spot.js 2026-06-13 08:24:30 +00:00
Ian Renton
fd21e01c9d Implement spotting to Tiles on the Air. #95 2026-06-13 08:17:38 +01:00
Ian Renton
1afb407ca5 First stab at submitting spots upstream. POTA is working, all other providers still to do. #95 2026-06-12 09:14:21 +01:00
Ian Renton
930d5357fe Prevent "TOTA" in cluster comments being flagged as Toilets on the Air, as this is ambiguous 2026-06-12 07:18:46 +01:00
40 changed files with 967 additions and 225 deletions

View File

@@ -90,7 +90,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 (`/webassets/apidocs/openapi.yml`), which you can
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
`https://spothole.app/api/v1/spots` once every few minutes. Apply filters if necessary.
`https://spothole.app/api/v2/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
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 "map" and "bands"
@@ -103,12 +103,12 @@ once every two minutes, so if your client is interested in POTA data there's no
than that.
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/v1/spots/stream`.
e.g. `https://spothole.app/api/v2/spots/stream`.
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
`https://spothole.app/api/v1/spots?sig=POTA,SOTA` rather than making two separate calls to
`https://spothole.app/api/v1/spots?sig=POTA` and `https://spothole.app/api/v1/spots?sig=SOTA`.
`https://spothole.app/api/v2/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`.
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.
@@ -328,7 +328,7 @@ server {
}
# SSE endpoints
location ~ ^/api/v1/(spots|alerts)/stream {
location ~ ^/api/v2/(spots|alerts)/stream {
proxy_pass http://127.0.0.1:8080;
# Allow keep-alive
@@ -528,6 +528,9 @@ This project would not have been possible without these libraries, so many thank
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.
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
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.

View File

@@ -181,7 +181,7 @@ alert-providers:
# Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band
# conditions, etc.) and make it available via the /api/v1/solar endpoint.
# conditions, etc.) and make it available via the /api/v2/solar endpoint.
solar-condition-providers:
- class: "HamQSL"
name: "HamQSL"
@@ -211,6 +211,20 @@ clublog-api-key: ""
# Allow submitting spots to the Spothole API?
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: ""
# Options for the web UI.
web-ui-options:
spot-count: [ 10, 25, 50, 100 ]

View File

@@ -14,20 +14,26 @@ with open("config.yml") as f:
config = yaml.safe_load(f)
logging.info("Loaded config.")
BASE_URL = config["base-url"]
MAX_SPOT_AGE = config["max-spot-age-sec"]
MAX_ALERT_AGE = config["max-alert-age-sec"]
SERVER_OWNER_CALLSIGN = config["server-owner-callsign"]
WEB_SERVER_PORT = config["web-server-port"]
ALLOW_SPOTTING = config["allow-spotting"]
WEB_UI_OPTIONS = config["web-ui-options"]
BASE_URL = config.get("base-url", "http://localhost:8080")
MAX_SPOT_AGE = config.get("max-spot-age-sec", 3600)
MAX_ALERT_AGE = config.get("max-alert-age-sec", 604800)
SERVER_OWNER_CALLSIGN = config.get("server-owner-callsign", "N0CALL")
WEB_SERVER_PORT = config.get("web-server-port", 8080)
ALLOW_SPOTTING = config.get("allow-spotting", True)
ALLOW_UPSTREAM_SPOTTING = config.get("allow-upstream-spotting", True)
WEB_UI_OPTIONS = config.get("web-ui-options", {})
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", "")
# For ease of config, each spot provider owns its own config about whether it should be enabled by default in the web UI
# but for consistency we provide this to the front-end in web-ui-options because it has no impact outside of the web UI.
WEB_UI_OPTIONS["spot-providers-enabled-by-default"] = [p["name"] for p in config["spot-providers"] if p["enabled"] and (
"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
# one of our proviers. We set that to also be enabled by default.
# one of our proviers. We set that to also be enabled by default. We can also include the reCaptcha site key so the UI
# can access it.
if ALLOW_SPOTTING:
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

View File

@@ -11,7 +11,7 @@ HAMQTH_PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_C
# Special Interest Groups
SIGS = [
SIG(name="POTA", description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}"),
SIG(name="POTA", description="Parks on the Air", ref_regex=r"([A-Z]{2}\-\d{4,5}|K\-TEST)"),
SIG(name="SOTA", description="Summits on the Air", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
SIG(name="WWFF", description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"),
SIG(name="GMA", description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
@@ -37,10 +37,12 @@ SIGS = [
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
CW_MODES = ["CW"]
PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"]
PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "FUSION", "M17"]
DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"]
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
MODE_TYPES = ["CW", "PHONE", "DATA"]
SSB_SUB_MODES = ["USB", "LSB"]
DV_SUB_MODES = ["DMR", "DSTAR", "C4FM", "FUSION", "M17"]
# Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots

View File

@@ -12,15 +12,15 @@ class LookupCredentials:
hamqth_session_id: str = "" # alternative to username/password
def extract_credentials(query_params):
"""Build a LookupCredentials from HTTP query params; returns None if no usable credentials are present."""
def extract_credentials(headers):
"""Build a LookupCredentials from HTTP request headers; returns None if no usable credentials are present."""
creds = LookupCredentials(
qrz_username=query_params.get("qrz_username", ""),
qrz_password=query_params.get("qrz_password", ""),
qrz_session_key=query_params.get("qrz_session_key", ""),
hamqth_username=query_params.get("hamqth_username", ""),
hamqth_password=query_params.get("hamqth_password", ""),
hamqth_session_id=query_params.get("hamqth_session_id", ""),
qrz_username=headers.get("X-QRZ-Username", ""),
qrz_password=headers.get("X-QRZ-Password", ""),
qrz_session_key=headers.get("X-QRZ-Session-Key", ""),
hamqth_username=headers.get("X-HamQTH-Username", ""),
hamqth_password=headers.get("X-HamQTH-Password", ""),
hamqth_session_id=headers.get("X-HamQTH-Session-ID", ""),
)
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)

View File

@@ -253,9 +253,16 @@ class Spot:
if self.comment:
sig_matches = re.finditer(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
for sig_match in sig_matches:
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster
# spots where the comment is just "POTA".
# See what SIG we think this is
found_sig = sig_match.group(2).upper()
# "TOTA" is now ambiguous, with Toilets and Towers both using it. If we have found "TOTA" in a comment,
# ignore it as we can't tell what it is.
if found_sig != "TOTA":
continue
# Now, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster
# spots where the comment is just "POTA".
if not self.sig:
self.sig = found_sig

View File

@@ -1,15 +1,17 @@
import json
import logging
import re
import threading
from datetime import datetime
from typing import Any
import pytz
import requests
import tornado
from tornado import httputil
from tornado.web import Application
from core.config import ALLOW_SPOTTING, MAX_SPOT_AGE
from core.config import ALLOW_SPOTTING, ALLOW_UPSTREAM_SPOTTING, MAX_SPOT_AGE, RECAPTCHA_SECRET_KEY
from core.constants import UNKNOWN_BAND
from core.lookup_helper import infer_band_from_freq
from core.prometheus_metrics_handler import api_requests_counter
@@ -17,19 +19,24 @@ from core.sig_utils import get_ref_regex_for_sig
from core.utils import serialize_everything
from data.sig_ref import SIGRef
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):
"""API request handler for /api/v1/spot (POST)"""
"""API request handler for /api/v2/spot (POST)"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._spots = None
self._web_server_metrics = None
self._spot_providers = None
super().__init__(application, request, **kwargs)
def initialize(self, spots, web_server_metrics):
def initialize(self, spots, web_server_metrics, spot_providers=None):
self._spots = spots
self._web_server_metrics = web_server_metrics
self._spot_providers = spot_providers or []
def post(self):
try:
@@ -66,15 +73,45 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Content-Type", "application/json")
return
# Read in the request body as JSON then convert to a Spot object
json_spot = tornado.escape.json_decode(post_data)
spot = Spot(**json_spot)
# Read in the request body as JSON
json_body = tornado.escape.json_decode(post_data)
# 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)
# Converting to a spot object this way won't have coped with sig_ref objects, so fix that. (Would be nice to
# redo this in a functional style)
if spot.sig_refs:
if spot.sig and spot.sig_refs:
real_sig_refs = []
for dict_obj in spot.sig_refs:
dict_obj = {**dict_obj, "sig": spot.sig}
real_sig_refs.append(json.loads(json.dumps(dict_obj), object_hook=lambda d: SIGRef(**d)))
spot.sig_refs = real_sig_refs
@@ -134,11 +171,76 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Content-Type", "application/json")
return
# infer missing data, and add it to our database.
spot.source = "API"
# Reject upstream submission if not permitted
if submit_upstream and not ALLOW_UPSTREAM_SPOTTING:
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()
self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
if upstream_warning:
self.write(json.dumps("Warning - " + upstream_warning, default=serialize_everything))
self.set_status(201)
else:
self.write(json.dumps("OK", default=serialize_everything))
self.set_status(201)
self.set_header("Cache-Control", "no-store")
@@ -150,3 +252,24 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_status(500)
self.set_header("Cache-Control", "no-store")
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

View File

@@ -20,7 +20,7 @@ SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
class APIAlertsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/alerts"""
"""API request handler for /api/v2/alerts"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._alerts = None
@@ -53,7 +53,7 @@ class APIAlertsHandler(tornado.web.RequestHandler):
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
credentials = extract_credentials(query_params)
credentials = extract_credentials(self.request.headers)
data = get_alert_list_with_filters(self._alerts, query_params)
if credentials:
data = self._enrich(data, credentials)
@@ -72,7 +72,7 @@ class APIAlertsHandler(tornado.web.RequestHandler):
class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
"""API request handler for /api/v1/alerts/stream"""
"""API request handler for /api/v2/alerts/stream"""
def __init__(self, application, request, **kwargs: Any):
self._sse_alert_queues = None
@@ -104,7 +104,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
# 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
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
self._credentials = extract_credentials(self._query_params)
self._credentials = extract_credentials(self.request.headers)
# 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)

View File

@@ -17,7 +17,7 @@ BANDS_SET = frozenset(BANDS)
class APIDxStatsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/dxstats"""
"""API request handler for /api/v2/dxstats"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._spots = None

View File

@@ -20,7 +20,7 @@ from data.spot import Spot
class APILookupCallHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/lookup/call"""
"""API request handler for /api/v2/lookup/call"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._web_server_metrics = None
@@ -47,7 +47,7 @@ class APILookupCallHandler(tornado.web.RequestHandler):
if re.match(r"^[A-Z0-9/\-]*$", call):
# Take the callsign, make a "fake spot" so we can run infer_missing() on it, then repack the
# resulting data in the correct way for the API response.
credentials = extract_credentials(query_params)
credentials = extract_credentials(self.request.headers)
fake_spot = Spot(dx_call=call)
fake_spot.infer_missing(credentials)
data = {
@@ -85,7 +85,7 @@ class APILookupCallHandler(tornado.web.RequestHandler):
class APILookupSIGRefHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/lookup/sigref"""
"""API request handler for /api/v2/lookup/sigref"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._web_server_metrics = None
@@ -139,7 +139,7 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
class APILookupGridHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/lookup/grid"""
"""API request handler for /api/v2/lookup/grid"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._web_server_metrics = None

View File

@@ -14,16 +14,18 @@ from core.utils import serialize_everything
class APIOptionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/options"""
"""API request handler for /api/v2/options"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._status_data = None
self._web_server_metrics = None
self._spot_providers = None
super().__init__(application, request, **kwargs)
def initialize(self, status_data, web_server_metrics):
def initialize(self, status_data, web_server_metrics, spot_providers=None):
self._status_data = status_data
self._web_server_metrics = web_server_metrics
self._spot_providers = spot_providers or []
def get(self):
# Metrics
@@ -32,23 +34,37 @@ class APIOptionsHandler(tornado.web.RequestHandler):
self._web_server_metrics["status"] = "OK"
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,
"modes": ALL_MODES,
"mode_types": MODE_TYPES,
"sigs": SIGS,
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle things that aren't even available.
"spot_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"]))),
"spot_sources": spot_sources,
"alert_sources": alert_sources,
"continents": CONTINENTS,
"propagation_modes": list(PROPAGATION_MODES.values()),
"max_spot_age": MAX_SPOT_AGE,
"spot_allowed": ALLOW_SPOTTING}
# 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")
"spot_allowed": ALLOW_SPOTTING,
"spot_submit_providers": spot_submit_providers}
self.write(json.dumps(options, default=serialize_everything))
self.set_status(200)

View File

@@ -10,7 +10,7 @@ from core.prometheus_metrics_handler import api_requests_counter
class APISolarConditionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/solar"""
"""API request handler for /api/v2/solar"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._solar_conditions = None

View File

@@ -20,7 +20,7 @@ SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
class APISpotsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/spots"""
"""API request handler for /api/v2/spots"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._spots = None
@@ -53,7 +53,7 @@ class APISpotsHandler(tornado.web.RequestHandler):
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
credentials = extract_credentials(query_params)
credentials = extract_credentials(self.request.headers)
data = get_spot_list_with_filters(self._spots, query_params)
if credentials:
data = self._enrich(data, credentials)
@@ -72,7 +72,7 @@ class APISpotsHandler(tornado.web.RequestHandler):
class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
"""API request handler for /api/v1/spots/stream"""
"""API request handler for /api/v2/spots/stream"""
def __init__(self, application, request, **kwargs: Any):
self._sse_spot_queues = None
@@ -106,7 +106,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
# 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
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
self._credentials = extract_credentials(self._query_params)
self._credentials = extract_credentials(self.request.headers)
# 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)

View File

@@ -12,7 +12,7 @@ from core.utils import serialize_everything
class APIStatusHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/status"""
"""API request handler for /api/v2/status"""
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
self._status_data = None

View File

@@ -0,0 +1,31 @@
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()

View File

@@ -8,6 +8,7 @@ from tornado.web import StaticFileHandler
from core.config import ALLOW_SPOTTING, WEB_SERVER_PORT, API_ONLY_MODE
from core.utils import empty_queue
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.dxstats import APIDxStatsHandler
from server.handlers.api.lookups import APILookupCallHandler, APILookupSIGRefHandler, APILookupGridHandler
@@ -24,7 +25,7 @@ _HERE = os.path.dirname(__file__ or "")
class WebServer:
"""Provides the public-facing web server."""
def __init__(self, spots, alerts, solar_conditions, status_data):
def __init__(self, spots, alerts, solar_conditions, status_data, spot_providers=None):
"""Constructor"""
self._spots = spots
@@ -33,6 +34,7 @@ class WebServer:
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._api_only_mode = API_ONLY_MODE
self._shutdown_event = asyncio.Event()
@@ -63,20 +65,29 @@ class WebServer:
# API endpoints are always enabled
api_routes = [
(r"/api/v1/spots", APISpotsHandler, {"spots": self._spots, **handler_opts}),
(r"/api/v1/alerts", APIAlertsHandler, {"alerts": self._alerts, **handler_opts}),
(r"/api/v1/spots/stream", APISpotsStreamHandler,
(r"/api/v2/spots", APISpotsHandler, {"spots": self._spots, **handler_opts}),
(r"/api/v2/alerts", APIAlertsHandler, {"alerts": self._alerts, **handler_opts}),
(r"/api/v2/spots/stream", APISpotsStreamHandler,
{"sse_spot_queues": self._sse_spot_queues, **handler_opts}),
(r"/api/v1/alerts/stream", APIAlertsStreamHandler,
(r"/api/v2/alerts/stream", APIAlertsStreamHandler,
{"sse_alert_queues": self._sse_alert_queues, **handler_opts}),
(r"/api/v1/solar", APISolarConditionsHandler, {"solar_conditions": self._solar_conditions, **handler_opts}),
(r"/api/v1/dxstats", APIDxStatsHandler, {"spots": self._spots, **handler_opts}),
(r"/api/v1/options", APIOptionsHandler, {"status_data": self._status_data, **handler_opts}),
(r"/api/v1/status", APIStatusHandler, {"status_data": self._status_data, **handler_opts}),
(r"/api/v1/lookup/call", APILookupCallHandler, {**handler_opts}),
(r"/api/v1/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}),
(r"/api/v1/lookup/grid", APILookupGridHandler, {**handler_opts}),
(r"/api/v1/spot", APISpotHandler, {"spots": self._spots, **handler_opts}),
(r"/api/v2/solar", APISolarConditionsHandler, {"solar_conditions": self._solar_conditions, **handler_opts}),
(r"/api/v2/dxstats", APIDxStatsHandler, {"spots": self._spots, **handler_opts}),
(r"/api/v2/options", APIOptionsHandler,
{"status_data": self._status_data, "spot_providers": self._spot_providers, **handler_opts}),
(r"/api/v2/status", APIStatusHandler, {"status_data": self._status_data, **handler_opts}),
(r"/api/v2/lookup/call", APILookupCallHandler, {**handler_opts}),
(r"/api/v2/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}),
(r"/api/v2/lookup/grid", APILookupGridHandler, {**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
@@ -106,7 +117,7 @@ class WebServer:
(r"/(.*)", StaticFileHandler, {"path": os.path.join(_HERE, "../webassets")})
]
app = tornado.web.Application(api_routes + ui_routes + misc_routes,
app = tornado.web.Application(api_routes + v1_compat_routes + ui_routes + misc_routes,
template_path=os.path.join(_HERE, "../templates"),
log_function=request_log,
debug=False)

View File

@@ -102,18 +102,21 @@ if __name__ == '__main__':
# Set up lookup helper
lookup_helper.start()
# Set up web server
web_server = WebServer(spots=spots, alerts=alerts, solar_conditions=solar_conditions, status_data=status_data)
# Fetch, set up and start spot providers
# Create spot providers
for entry in config["spot-providers"]:
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:
p.setup(spots=spots, web_server=web_server)
if p.enabled:
p.start()
# Fetch, set up and start alert providers
# Create, set up and start alert providers
for entry in config["alert-providers"]:
alert_providers.append(get_alert_provider_from_config(entry))
for p in alert_providers:
@@ -121,7 +124,7 @@ if __name__ == '__main__':
if p.enabled:
p.start()
# Fetch, set up and start solar conditions providers
# Create, set up and start solar conditions providers
for entry in config.get("solar-condition-providers", []):
solar_condition_providers.append(get_solar_conditions_provider_from_config(entry))
for p in solar_condition_providers:

View File

@@ -100,3 +100,11 @@ class GMA(HTTPSpotProvider):
logging.warning(f"The GMA API returned an unexpected response (HTTP {http_response.status_code}).")
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")

View File

@@ -66,3 +66,11 @@ class HEMA(HTTPSpotProvider):
# that for us.
new_spots.append(spot)
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")

View File

@@ -19,6 +19,7 @@ class HTTPSpotProvider(SpotProvider):
self._poll_interval = poll_interval
self._thread = None
self._stop_event = Event()
self._wakeup_event = Event()
def start(self):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
@@ -29,11 +30,19 @@ class HTTPSpotProvider(SpotProvider):
def stop(self):
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):
while True:
self._wakeup_event.clear()
self._poll()
if self._stop_event.wait(timeout=self._poll_interval):
self._wakeup_event.wait(timeout=self._poll_interval)
if self._stop_event.is_set():
break
def _poll(self):

View File

@@ -3,7 +3,9 @@ import re
from datetime import datetime
import pytz
import requests
from core.constants import HTTP_HEADERS
from data.sig_ref import SIGRef
from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider
@@ -14,7 +16,9 @@ class ParksNPeaks(HTTPSpotProvider):
POLL_INTERVAL_SEC = 120
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"
SUBMITTABLE_SIGS = ["POTA", "SOTA", "WWFF", "HEMA", "WOTA", "ZLOTA", "SIOTA", "KRMNPA"]
def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
@@ -63,3 +67,28 @@ class ParksNPeaks(HTTPSpotProvider):
# Add new spot to the list
new_spots.append(spot)
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)

View File

@@ -1,7 +1,9 @@
from datetime import datetime
import pytz
import requests
from core.constants import HTTP_HEADERS
from data.sig_ref import SIGRef
from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider
@@ -12,6 +14,7 @@ class POTA(HTTPSpotProvider):
POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://api.pota.app/spot/activator"
SUBMIT_URL = "https://api.pota.app/spot"
def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
@@ -40,3 +43,25 @@ class POTA(HTTPSpotProvider):
# that for us.
new_spots.append(spot)
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.")

View File

@@ -2,7 +2,7 @@ from datetime import datetime
import requests
from core.constants import HTTP_HEADERS
from core.constants import HTTP_HEADERS, SSB_SUB_MODES, DV_SUB_MODES
from data.sig_ref import SIGRef
from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider
@@ -20,6 +20,9 @@ class SOTA(HTTPSpotProvider):
# 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/"
SUBMIT_URL = "https://api-db2.sota.org.uk/api/spots"
VALID_MODES = ["AM", "CW", "Data", "DV", "FM", "SSB"]
def __init__(self, provider_config):
super().__init__(provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
self._api_epoch = ""
@@ -56,3 +59,46 @@ class SOTA(HTTPSpotProvider):
# that for us.
new_spots.append(spot)
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.")

View File

@@ -68,3 +68,20 @@ class SpotProvider:
"""Stop any threads and prepare for application shutdown"""
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

View File

@@ -1,5 +1,8 @@
from datetime import datetime
import requests
from core.constants import HTTP_HEADERS, SSB_SUB_MODES
from data.sig_ref import SIGRef
from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider
@@ -10,6 +13,9 @@ class Tiles(HTTPSpotProvider):
POLL_INTERVAL_SEC = 120
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):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
@@ -42,6 +48,49 @@ class Tiles(HTTPSpotProvider):
new_spots.append(spot)
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'
# strange frequency format where we can sometimes have e.g. "14.123.5".

View File

@@ -79,3 +79,10 @@ class WOTA(HTTPSpotProvider):
except Exception as e:
logging.error("Exception parsing WOTA spot", e)
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")

View File

@@ -41,3 +41,10 @@ class WWBOTA(SSESpotProvider):
# WWBOTA does support a special "Test" spot type, we need to avoid adding that.
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")

View File

@@ -38,3 +38,11 @@ class WWFF(HTTPSpotProvider):
# that for us.
new_spots.append(spot)
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")

View File

@@ -41,3 +41,10 @@ class ZLOTA(HTTPSpotProvider):
new_spots.append(spot)
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")

View File

@@ -24,14 +24,14 @@
</div>
</div>
<div class="card-body">
<form class="row g-3">
<form class="row g-3" onsubmit="return addSpot();">
<div class="col-auto">
<label for="dx-call" class="form-label">DX Call *</label>
<input type="text" class="form-control input-narrow" id="dx-call" placeholder="N0CALL">
<input type="text" class="form-control input-narrow" id="dx-call" placeholder="N0CALL" required>
</div>
<div class="col-auto">
<label for="freq" class="form-label">Frequency (kHz) *</label>
<input type="text" class="form-control input-narrow" id="freq" placeholder="e.g. 14100">
<input type="text" class="form-control input-narrow" id="freq" placeholder="e.g. 14100" required>
</div>
<div class="col-auto">
<label for="mode" class="form-label">Mode</label>
@@ -60,10 +60,10 @@
<div class="col-auto">
<label for="de-call" class="form-label">Your Call *</label>
<input type="text" class="form-control storeable-text input-narrow" id="de-call"
placeholder="N0CALL">
placeholder="N0CALL" required>
</div>
<div class="col-auto">
<button type="button" class="btn btn-primary mt-2em" onclick="addSpot();">Spot</button>
<button type="submit" class="btn btn-primary mt-2em">Spot</button>
</div>
</form>

View File

@@ -15,6 +15,14 @@ info:
## 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
* Spots can now include a "propagation_mode" field, and the `/options` call enumerates the options that can have.
@@ -40,10 +48,10 @@ info:
license:
name: The Unlicense
url: https://unlicense.org/#the-unlicense
version: v1.4
version: 2.0
servers:
- url: https://spothole.app/api/v1
- url: https://spothole.app/api/v2
tags:
- name: Spots
@@ -324,7 +332,8 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
/lookup/sigref:
@@ -352,7 +361,8 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
@@ -378,7 +388,8 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
/spot:
@@ -387,50 +398,53 @@ paths:
- Spots
summary: Add a spot
description: >
Supply a new spot object, which will be added to the system. Currently, this will not be
reported up the chain to a cluster, POTA, SOTA etc. This may be introduced in a future version.
cURL example: `curl --request POST --header "Content-Type: application/json" --data
'{"dx_call":"M0TRT","time":1760019539, "freq":14200000, "comment":"Test spot please ignore",
"de_call":"M0TRT"}' https://spothole.app/api/v1/spot`
Supply a JSON object containing a `spot` sub-object (the spot data) and an optional `handling` sub-object
containing server-side instructions such as upstream submission). Check `spot_submit_providers` in the
`/options` response to see which SIGs and providers support upstream submission. cURL example:
`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`"
operationId: spot
requestBody:
description: The JSON spot object
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.
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Spot'
$ref: '#/components/schemas/SpotSubmission'
responses:
'200':
'201':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/OkResponse'
type: string
example: "OK"
'415':
description: Incorrect Content-Type
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
'422':
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
components:
parameters:
QrzUsername:
name: qrz_username
in: query
name: X-QRZ-Username
in: header
description: >
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
@@ -438,14 +452,14 @@ components:
schema:
type: string
QrzPassword:
name: qrz_password
in: query
name: X-QRZ-Password
in: header
description: QRZ.com password. Supply together with `qrz_username`.
schema:
type: string
QrzSessionKey:
name: qrz_session_key
in: query
name: X-QRZ-Session-Key
in: header
description: >
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
@@ -453,22 +467,22 @@ components:
schema:
type: string
HamqthUsername:
name: hamqth_username
in: query
name: X-HamQTH-Username
in: header
description: >
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.
schema:
type: string
HamqthPassword:
name: hamqth_password
in: query
name: X-HamQTH-Password
in: header
description: HamQTH password. Supply together with `hamqth_username`.
schema:
type: string
HamqthSessionId:
name: hamqth_session_id
in: query
name: X-HamQTH-Session-ID
in: header
description: >
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
@@ -1178,6 +1192,54 @@ components:
$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:
type: object
description: A server-sent event containing a spot
@@ -1714,14 +1776,6 @@ components:
items:
$ref: '#/components/schemas/Alert'
OkResponse:
type: string
example: "OK"
ErrorResponse:
type: string
example: "Failed"
DxStats:
type: object
description: Spot counts keyed by DE continent
@@ -1859,7 +1913,7 @@ components:
type: integer
description: >
The maximum age, in seconds, of any spot before it will be deleted by the system. When
querying the /api/v1/spots endpoint and providing a "max_age" or "since" parameter, there
querying the /api/v2/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
than this.
example: 3600
@@ -1869,6 +1923,20 @@ components:
Whether the POST /spot call, to add spots to the server directly via its API, is permitted
on this server.
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:
type: object

View File

@@ -1,7 +1,35 @@
// 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
// any saved values from local storage.
function loadOptions() {
$.getJSON('/api/v1/options', function (jsonData) {
$.getJSON('/api/v2/options', function (jsonData) {
// Store options
options = jsonData;
@@ -21,11 +49,144 @@ 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
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
function addSpot() {
try {
@@ -42,57 +203,65 @@ function addSpot() {
const comment = $("#comment").val();
const de = $("#de-call").val().toUpperCase();
// Prepare the spot object for the server
const spot = {};
if (dx !== "") {
spot["dx_call"] = dx;
} else {
showAddSpotError("A DX callsign is required in order to spot.");
return;
}
if (freqStr !== "") {
spot["freq"] = parseFloat(freqStr) * 1000;
} else {
showAddSpotError("A frequency is required in order to spot.");
return;
}
if (mode !== "") {
spot["mode"] = mode;
}
if (sig !== "") {
spot["sig"] = sig;
}
if (sigRef !== "") {
spot["sig_refs"] = [{id: sigRef}];
}
if (dxGrid !== "") {
spot["dx_grid"] = dxGrid;
}
if (comment !== "") {
spot["comment"] = comment;
}
if (de !== "") {
if (mode !== "") spot["mode"] = mode;
if (sig !== "") spot["sig"] = sig;
if (sigRef !== "") spot["sig_refs"] = [{id: sigRef}];
if (dxGrid !== "") spot["dx_grid"] = dxGrid;
if (comment !== "") spot["comment"] = comment;
spot["de_call"] = de;
} else {
showAddSpotError("A spotter callsign is required in order to spot.");
return;
}
spot["time"] = moment.utc().valueOf() / 1000.0;
$.ajax("/api/v1/spot", {
data: JSON.stringify(spot),
// Prepare "handling" structure to tell the server what to do with this spot
const handling = {};
// 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',
type: 'POST',
timeout: 10000,
success: async function () {
success: async function (result) {
// 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-bad").html("");
setTimeout(() => {
$("#result-good").hide();
window.location.replace("/");
}, 1000);
}, 2000);
},
error: function (result) {
if (window._recaptchaWidgetId !== undefined) {
grecaptcha.reset(window._recaptchaWidgetId);
}
if (result.responseText) {
showAddSpotError(result.responseText.slice(1, -1));
} else {
showAddSpotError("The server did not return a response.");
}
}
});
} catch (error) {
@@ -121,20 +290,18 @@ $("#mode").change(function () {
$(this).val($(this).val().trim().toUpperCase());
});
// Display the intro box, unless the user has already dismissed it once.
function displayIntroBox() {
if (localStorage.getItem("add-spot-intro-box-dismissed") == null) {
$("#add-spot-intro-box").show();
}
$("#add-spot-intro-box-dismiss").click(function () {
localStorage.setItem("add-spot-intro-box-dismissed", true);
// Update upstream area and credentials button when SIG changes
$("#sig").change(function () {
updateUpstreamArea();
});
// Update credentials button when provider selector changes
$("#upstream-provider-select").change(function () {
updateCredentialsButton();
});
}
// Startup
$(document).ready(function () {
// Load options
loadOptions();
// Display intro box
displayIntroBox();
});

View File

@@ -6,7 +6,7 @@ let alerts = [];
// Load alerts and populate the table.
function loadAlerts() {
$.getJSON('/api/v1/alerts' + buildQueryString(false), function (jsonData) {
$.ajax({url: '/api/v2/alerts' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
// Store last updated time
lastUpdateTime = moment.utc();
updateRefreshDisplay();
@@ -14,11 +14,11 @@ function loadAlerts() {
alerts = jsonData;
// Update table
updateTable();
});
}});
}
// Build a query string for the API, based on the filters that the user has selected.
function buildQueryString(includeCredentials) {
function buildQueryString() {
let str = "?";
["dx_continent", "source"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) {
@@ -33,9 +33,6 @@ function buildQueryString(includeCredentials) {
if ($("#dxpeditions_skip_max_duration_check")[0].checked) {
str = str + "&dxpeditions_skip_max_duration_check=true";
}
if (includeCredentials) {
str = str + getCredentialQueryString();
}
return str;
}
@@ -280,7 +277,7 @@ function addAlertRowsToTable(tbody, alerts) {
// Load server options. Once a successful callback is made from this, we then query alerts.
function loadOptions() {
$.getJSON('/api/v1/options', function (jsonData) {
$.getJSON('/api/v2/options', function (jsonData) {
// Store options
options = jsonData;

View File

@@ -12,7 +12,7 @@ BAND_COLUMN_SPOT_DIV_HEIGHT_PX = BAND_COLUMN_FONT_SIZE * 1.6;
// Load spots and populate the bands display.
function loadSpots() {
$.getJSON('/api/v1/spots' + buildQueryString(false), function (jsonData) {
$.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
// Store last updated time
lastUpdateTime = moment.utc();
updateRefreshDisplay();
@@ -20,11 +20,11 @@ function loadSpots() {
spots = jsonData;
// Update bands display
updateBands();
});
}});
}
// Build a query string for the API, based on the filters that the user has selected.
function buildQueryString(includeCredentials) {
function buildQueryString() {
let str = "?";
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) {
@@ -34,9 +34,6 @@ function buildQueryString(includeCredentials) {
str = str + "max_age=" + $("#max-spot-age option:selected").val();
// Additional filters for the bands view: No dupes, no QRT
str = str + "&dedupe=true&allow_qrt=false";
if (includeCredentials) {
str = str + getCredentialQueryString();
}
return str;
}
@@ -229,7 +226,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
// spots repeatedly.
function loadOptions() {
$.getJSON('/api/v1/options', function (jsonData) {
$.getJSON('/api/v2/options', function (jsonData) {
// Store options
options = jsonData;

View File

@@ -273,23 +273,23 @@ function closeDataPanel() {
closePanel("#data-area");
}
// Build a query string fragment containing any QRZ.com / HamQTH credentials the user has supplied,
// Build a headers object containing any QRZ.com / HamQTH credentials the user has supplied,
// provided the corresponding "enabled" checkbox is ticked.
function getCredentialQueryString() {
let str = "";
function getCredentialHeaders() {
const headers = {};
if ($("#qrz-enabled")[0] && $("#qrz-enabled")[0].checked) {
const qrzUsername = $("#qrz-username").val();
const qrzPassword = $("#qrz-password").val();
if (qrzUsername) str += "&qrz_username=" + encodeURIComponent(qrzUsername);
if (qrzPassword) str += "&qrz_password=" + encodeURIComponent(qrzPassword);
if (qrzUsername) headers["X-QRZ-Username"] = qrzUsername;
if (qrzPassword) headers["X-QRZ-Password"] = qrzPassword;
}
if ($("#hamqth-enabled")[0] && $("#hamqth-enabled")[0].checked) {
const hamqthUsername = $("#hamqth-username").val();
const hamqthPassword = $("#hamqth-password").val();
if (hamqthUsername) str += "&hamqth_username=" + encodeURIComponent(hamqthUsername);
if (hamqthPassword) str += "&hamqth_password=" + encodeURIComponent(hamqthPassword);
if (hamqthUsername) headers["X-HamQTH-Username"] = hamqthUsername;
if (hamqthPassword) headers["X-HamQTH-Password"] = hamqthPassword;
}
return str;
return headers;
}

View File

@@ -10,7 +10,7 @@ let ionosondeChart = null;
// Load solar conditions
function loadSolarConditions() {
$.getJSON('/api/v1/solar', function (jsonData) {
$.getJSON('/api/v2/solar', function (jsonData) {
// HF
@@ -539,7 +539,7 @@ function renderIonosondeData() {
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
// Add an extra vertical line for 30MHz, which should correspond to the top of the chart and avoid having
// Add an extra horizontal line for 30MHz, which should correspond to the top of the chart and avoid having
// no top "border" gridline
const y30 = scales.y.getPixelForValue(30);
if (y30 >= chartArea.top && y30 <= chartArea.bottom) {
@@ -660,7 +660,7 @@ function dxStatsContientChanged() {
// Fetch DX stats from the API and render
function loadDxStats() {
$.getJSON('/api/v1/dxstats', function (jsonData) {
$.getJSON('/api/v2/dxstats', function (jsonData) {
dxStatsData = jsonData;
renderDxStats();
});

View File

@@ -28,7 +28,7 @@ let firstLoad = true;
// Load spots and populate the map.
function loadSpots() {
$.getJSON('/api/v1/spots' + buildQueryString(true), function (jsonData) {
$.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
// Store data
spots = jsonData;
// Update map
@@ -36,11 +36,11 @@ function loadSpots() {
if ($("#showTerminator")[0].checked) {
terminator.setTime();
}
});
}});
}
// Build a query string for the API, based on the filters that the user has selected.
function buildQueryString(includeCredentials) {
function buildQueryString() {
let str = "?";
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) {
@@ -50,9 +50,6 @@ function buildQueryString(includeCredentials) {
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
str = str + "&dedupe=true&allow_qrt=false";
if (includeCredentials) {
str = str + getCredentialQueryString();
}
return str;
}
@@ -194,7 +191,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
// spots repeatedly.
function loadOptions() {
$.getJSON('/api/v1/options', function (jsonData) {
$.getJSON('/api/v2/options', function (jsonData) {
// Store options
options = jsonData;

View File

@@ -20,7 +20,7 @@ function loadSpots() {
}
// Make the new query
$.getJSON('/api/v1/spots' + buildQueryString(false), function (jsonData) {
$.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
// Store data
spots = jsonData;
// Update table
@@ -30,7 +30,7 @@ function loadSpots() {
if (run) {
startSSEConnection();
}
});
}});
}
// 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) {
evtSource.close();
}
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString(true));
evtSource = new EventSource('/api/v2/spots/stream' + buildQueryString());
evtSource.onmessage = function (event) {
// 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.
function buildQueryString(includeCredentials) {
function buildQueryString() {
let str = "?";
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) {
@@ -97,9 +97,6 @@ function buildQueryString(includeCredentials) {
if ($("#search").val() !== "") {
str = str + "&text_includes=" + encodeURIComponent($("#search").val());
}
if (includeCredentials) {
str = str + getCredentialQueryString();
}
return str;
}
@@ -418,7 +415,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
// spots repeatedly.
function loadOptions() {
$.getJSON('/api/v1/options', function (jsonData) {
$.getJSON('/api/v2/options', function (jsonData) {
// Store options
options = jsonData;

View File

@@ -1,6 +1,6 @@
// Load server status
function loadStatus() {
$.getJSON('/api/v1/status', function (jsonData) {
$.getJSON('/api/v2/status', function (jsonData) {
$("#software-version").text(jsonData["software-version"]);
$("#server-owner-callsign").text(jsonData["server-owner-callsign"]);
$("#up-since").text(moment().subtract(jsonData["uptime"], 'seconds').fromNow());

View File

@@ -296,6 +296,16 @@ function setBandColorScheme(scheme) {
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.
function bandToColor(band) {
let col = (band != null) ? BAND_COLOR_SCHEMES[bandColorScheme][band] : null;
@@ -314,6 +324,22 @@ function bandToContrastColor(band) {
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 = {
"POTA": "fa-tree",
"SOTA": "fa-mountain-sun",
@@ -339,6 +365,31 @@ const SIG_ICONS = {
"TOTA": "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
function sigToIcon(sig, defaultIcon) {
let col = (sig != null) ? SIG_ICONS[sig] : null;
@@ -353,3 +404,35 @@ 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;
}