19 Commits
Author SHA1 Message Date
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
540 changed files with 1784 additions and 9728 deletions
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/datafiles/MUNICIPIOS.csv" charset="ISO-8859-1" />
</component>
</project>
+1 -2
View File
@@ -2,9 +2,8 @@
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
<excludeFolder url="file://$MODULE_DIR$/static/vendor" />
<excludeFolder url="file://$MODULE_DIR$/webassets/vendor" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 virtualenv at ~/code/spothole/.venv" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
+67 -55
View File
@@ -1,4 +1,4 @@
# ![Spothole](/static/img/logo.png)
# ![Spothole](/webassets/img/logo.png)
Spothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open
JSON API as well as a website to browse the data.
@@ -19,8 +19,6 @@ Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), th
SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, LLOTA, WWTOTA, Tiles on the Air, the UK Packet
Repeater Network, NG3K, and any site based on the xOTA software by nischu.
Additional Special Interest Groups (SIGs) without their own specific data source include WAB, WAI and DME.
![Screenshot](/images/screenshot2.png)
![Screenshot](/images/screenshot3.png)
@@ -89,15 +87,15 @@ callsign and park data.
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 (`/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 "alerts"/"upcoming"
page simply query the main spot API on a timer, whereas the spots, map and bands pages combine this approach with
using the Server-Sent Events (SSE) endpoint to update live.
* Refer to the provided HTML/JS interface for a reference on different approaches. For example, the "map" and "bands"
pages simply query the main spot API on a timer, whereas the main/spots page combines this approach with using the
Server-Sent Events (SSE) endpoint to update live.
* Let me know if you get stuck, I'm happy to help.
Please don't hammer the API with an unnecessarily high request rate. For example, Spothole only queries the POTA API
@@ -105,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.
@@ -318,49 +316,33 @@ To set up nginx as a reverse proxy that sits in front of Spothole, first ensure
Create a file at `/etc/nginx/sites-available/` called `spothole`. Give it the following contents, replacing
`spothole.app` with the domain name on which you want to run Spothole. If you changed the port on which Spothole runs,
update that on the "proxy_pass" line, and if you installed Spothole somewhere other than `/home/spothole/spothole`,
adjust the alias location for serving static files.
(The latter section, configuring the nginx server to serve static files directly, improves efficiency because it saves
Spothole itself from serving JS, CSS etc. files. If you can't do this for some reason, e.g. your nginx and spothole are
on different computers, you can omit the `location /static/ {}` block.)
update that on the "proxy_pass" line too.
```nginx
server {
server_name spothole.app;
# Global proxy settings
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_connect_timeout 10s;
proxy_buffering on;
# Pass on IP address and host information to Spothole, in case logging this information is required
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
# Wellknown area for Lets Encrypt
location /.well-known/ {
alias /var/www/html/.well-known/;
}
# Load static assets directly from the Spothole static directory
location /static/ {
alias /home/spothole/spothole/static/;
expires 1h;
add_header Cache-Control "public, max-age=3600, must-revalidate";
}
# SSE endpoints
location ~ ^/api/v1/(spots|alerts)/stream/? {
location ~ ^/api/v2/(spots|alerts)/stream {
proxy_pass http://127.0.0.1:8080;
# Remove buffering, remove caching, add suitable timeouts for SSE API calls
# Allow keep-alive
proxy_http_version 1.1;
proxy_set_header Connection "";
# Set correct content type for SSE API calls
add_header Content-Type text/event-stream always;
# Set remove buffering, remove caching, add suitable timeouts for SSE API calls
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 24h;
proxy_connect_timeout 10s;
proxy_send_timeout 24h;
proxy_set_header X-Accel-Buffering no;
add_header Cache-Control no-store always;
@@ -368,28 +350,59 @@ server {
# Allow cross-origin requests to API
proxy_hide_header Access-Control-Allow-Origin;
add_header Access-Control-Allow-Origin * always;
# Pass on IP address and host information to Spothole, in case logging this information is required
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Other API endpoints
location /api/ {
proxy_pass http://127.0.0.1:8080;
# Remove buffering, remove caching, add suitable timeouts for API calls
proxy_buffering off;
# Allow keep-alive
proxy_http_version 1.1;
proxy_set_header Connection "";
# Set up buffering, remove caching, add suitable timeouts for API calls
proxy_buffering on;
proxy_cache off;
proxy_read_timeout 30s;
proxy_connect_timeout 10s;
add_header Cache-Control no-store always;
# Allow cross-origin requests to API
proxy_hide_header Access-Control-Allow-Origin;
add_header Access-Control-Allow-Origin * always;
# Pass on IP address and host information to Spothole, in case logging this information is required
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Templated pages
# Static assets
location / {
proxy_pass http://127.0.0.1:8080;
# Allow keep-alive
proxy_http_version 1.1;
proxy_set_header Connection "";
# Set up buffering and caching, add suitable timeouts for static asset requests
proxy_buffering on;
proxy_read_timeout 30s;
add_header Cache-Control "no-cache, must-revalidate" always;
proxy_connect_timeout 10s;
add_header Cache-Control "public, max-age=3600, must-revalidate" always;
# Pass on IP address and host information to Spothole, in case logging this information is required
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
@@ -452,12 +465,12 @@ To navigate your way around the source code, this list may help.
*HTML/JS/CSS front-end code*
* `/static` - Root for static files served by the web server. These are all served from a path starting `/static/`.
* `/static/apidocs` - Contains the OpenAPI spec (`openapi.yml`)
* `/static/css` - CSS files used by the web front-end
* `/static/img` - image files used by the web front-end
* `/static/js` - JavaScript used by the web front-end
* `/static/vendor` - Third-party libraries (CSS, JS, fonts and images)
* `/webassets` - Root for static files served by the web server
* `/webassets/apidocs` - Contains the OpenAPI spec (`openapi.yml`)
* `/webassets/css` - CSS files used by the web front-end
* `/webassets/img` - image files used by the web front-end
* `/webassets/js` - JavaScript used by the web front-end
* `/webassets/vendor` - Third-party libraries (CSS, JS, fonts and images)
*Miscellaneous*
@@ -502,21 +515,20 @@ The same approach as above is also used for alert providers.
As well as being my work, I have also gratefully received feature patches from Steven, M1SDH.
The project contains GeoJSON files for CQ and ITU zones, in the `/datafiles/` directory. These are MIT-licenced and, to
my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet. `/datafiles` also contains a
`MUNICIPIOS.csv` file, from the "Nomenclátor Geográfico de Municipios y Entidades de Población" data set sourced from
[el Centro Nacional de Información Geográfica](https://centrodedescargas.cnig.es/CentroDescargas/home).
`didbase-stations.csv` and the TOTA CSV files were created by me based on publicly available data from GIRO and from
maps of conference centres.
my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet.
The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the
`/static/img/flags/` directory.
`/webassets/img/flags/` directory.
The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries.
This project would not have been possible without these libraries, so many thanks to their developers.
### Third Party Libraries
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 `/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
+2 -2
View File
@@ -8,10 +8,10 @@ from core.config import MAX_ALERT_AGE
class AlertProvider:
"""Generic alert provider class. Subclasses of this query the individual APIs for alerts."""
def __init__(self, name, provider_config):
def __init__(self, provider_config):
"""Constructor"""
self.name = name
self.name = provider_config["name"]
self.enabled = provider_config["enabled"]
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled"
+1 -1
View File
@@ -15,7 +15,7 @@ class BOTA(HTTPAlertProvider):
ALERTS_URL = "https://www.beachesontheair.com/"
def __init__(self, provider_config):
super().__init__("BOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response):
new_alerts = []
+10 -20
View File
@@ -4,7 +4,6 @@ from threading import Thread, Event
import pytz
import requests
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
from alertproviders.alert_provider import AlertProvider
from core.constants import HTTP_HEADERS
@@ -14,8 +13,8 @@ class HTTPAlertProvider(AlertProvider):
"""Generic alert provider class for providers that request data via HTTP(S). Just for convenience to avoid code
duplication. Subclasses of this query the individual APIs for data."""
def __init__(self, name, provider_config, url, poll_interval):
super().__init__(name, provider_config)
def __init__(self, provider_config, url, poll_interval):
super().__init__(provider_config)
self._url = url
self._poll_interval = poll_interval
self._thread = None
@@ -42,25 +41,16 @@ class HTTPAlertProvider(AlertProvider):
# Request data from API
logging.debug("Polling " + self.name + " alert API...")
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
# Check response code was good
if http_response.ok:
# Pass off to the subclass for processing
new_alerts = self._http_response_to_alerts(http_response)
# Submit the new alerts for processing. There might not be any alerts for the less popular programs.
if new_alerts:
self._submit_batch(new_alerts)
# Pass off to the subclass for processing
new_alerts = self._http_response_to_alerts(http_response)
# Submit the new alerts for processing. There might not be any alerts for the less popular programs.
if new_alerts:
self._submit_batch(new_alerts)
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Received data from " + self.name + " alert API.")
else:
self.status = "Error"
logging.warning(f"HTTP {http_response.status_code} when calling {self.name} alerts API.")
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Received data from " + self.name + " alert API.")
except ConnectionError:
logging.warning(f"Connection error when accessing {self.name} alerts API.")
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when accessing {self.name} alerts API.")
except Exception:
self.status = "Error"
logging.exception("Exception in HTTP JSON Alert Provider (" + self.name + ")")
+1 -1
View File
@@ -18,7 +18,7 @@ class NG3K(HTTPAlertProvider):
AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE)
def __init__(self, provider_config):
super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response):
new_alerts = []
+3 -9
View File
@@ -15,7 +15,7 @@ class ParksNPeaks(HTTPAlertProvider):
ALERTS_URL = "https://parksnpeaks.org/api/ALERTS/"
def __init__(self, provider_config):
super().__init__("ParksNPeaks", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response):
new_alerts = []
@@ -33,24 +33,18 @@ class ParksNPeaks(HTTPAlertProvider):
start_time = datetime.strptime(source_alert["alTime"], "%Y-%m-%d %H:%M:%S").replace(
tzinfo=pytz.UTC).timestamp()
sigrefs = []
# PnP can give us an alert of class "QRP" which is the only one that's not a real SIG in Spothole's list,
# so mask this out if we got it.
if sig != "QRP":
sigrefs = [SIGRef(id=sig_ref, sig=sig, name=sig_ref_name)]
# Convert to our alert format
alert = Alert(source=self.name,
source_id=source_alert["alID"],
dx_calls=[source_alert["CallSign"].upper()],
freqs_modes=source_alert["Freq"] + " " + source_alert["MODE"],
comment=source_alert["Comments"],
sig_refs=sigrefs,
sig_refs=[SIGRef(id=sig_ref, sig=sig, name=sig_ref_name)],
start_time=start_time,
is_dxpedition=False)
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before
if sig and sig not in ["POTA", "SOTA", "WWFF", "SiOTA", "ZLOTA", "KRMNPA", "LLOTA", "QRP"]:
if sig and sig not in ["POTA", "SOTA", "WWFF", "SiOTA", "ZLOTA", "KRMNPA"]:
logging.warning("PNP alert found with sig " + sig + ", developer needs to add support for this!")
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. Otherwise, add to
+1 -1
View File
@@ -14,7 +14,7 @@ class POTA(HTTPAlertProvider):
ALERTS_URL = "https://api.pota.app/activation"
def __init__(self, provider_config):
super().__init__("POTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response):
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"
def __init__(self, provider_config):
super().__init__("SOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response):
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"
def __init__(self, provider_config):
super().__init__("WOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response):
new_alerts = []
+1 -1
View File
@@ -14,7 +14,7 @@ class WWFF(HTTPAlertProvider):
ALERTS_URL = "https://spots.wwff.co/static/agendas.json"
def __init__(self, provider_config):
super().__init__("WWFF", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response):
new_alerts = []
+47 -14
View File
@@ -19,55 +19,66 @@ base-url: "http://localhost:8080"
# 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.
# Each provider needs a class and an enabled/disabled state. Some require more config such as hostnames/IP
# Each provider needs a class, a name, 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
# 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.
spot-providers:
- class: "POTA"
name: "POTA"
enabled: true
- class: "SOTA"
name: "SOTA"
enabled: true
- class: "WWFF"
name: "WWFF"
enabled: true
- class: "WWBOTA"
name: "WWBOTA"
enabled: true
- class: "GMA"
name: "GMA"
enabled: true
# GMA requires an API key to fetch spots. After creating an account on cqgma.org, email support and request one.
api-key: ""
- class: "HEMA"
name: "HEMA"
enabled: true
- class: "ParksNPeaks"
name: "ParksNPeaks"
enabled: true
- class: "ZLOTA"
name: "ZLOTA"
enabled: true
- class: "WOTA"
name: "WOTA"
enabled: true
- class: "LLOTA"
name: "LLOTA"
enabled: true
- class: "Towers"
- class: "WWTOTA"
name: "WWTOTA"
enabled: true
- class: "Tiles"
name: "Tiles"
enabled: true
- class: "APRSIS"
name: "APRS-IS"
enabled: false
- 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"
enabled: true
host: "hrd.wa9pie.net"
@@ -88,12 +99,18 @@ spot-providers:
enabled: false
host: "w3lpl.net"
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:"
# 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"
# 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
- 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"
enabled: false
port: 7000
@@ -110,12 +127,11 @@ spot-providers:
enabled-by-default-in-web-ui: false
- class: "UKPacketNet"
name: "UK Packet Radio Net"
enabled: false
enabled-by-default-in-web-ui: false
- 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"
enabled: false
url: "wss://39c3.totawatch.de/api/spot/live"
@@ -136,40 +152,51 @@ spot-providers:
# Alert providers to use. Same setup as the spot providers list above.
alert-providers:
- class: "POTA"
name: "POTA"
enabled: true
- class: "SOTA"
name: "SOTA"
enabled: true
- class: "WWFF"
name: "WWFF"
enabled: true
- class: "ParksNPeaks"
name: "ParksNPeaks"
enabled: true
- class: "WOTA"
name: "WOTA"
enabled: true
- class: "BOTA"
name: "BOTA"
enabled: true
- class: "NG3K"
name: "NG3K"
enabled: true
# Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band
# conditions, etc.) and make it available via the /api/v1/solar endpoint.
# conditions, etc.) and make it available via the /api/v2/solar endpoint.
solar-condition-providers:
- class: "HamQSL"
name: "HamQSL"
enabled: true
- class: "NOAA3dayForecast"
name: "NOAA 3-day Forecast"
enabled: true
- class: "GIROIonosonde"
name: "GIRO Ionosonde Data"
enabled: true
- class: "KC2GProp"
name: "KC2G Propagation Data"
enabled: true
# Maximum time to keep spots and alerts in the system before deleting them. By default, one hour for spots and one week
@@ -184,13 +211,19 @@ clublog-api-key: ""
# Allow submitting spots to the Spothole API?
allow-spotting: true
# 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.
log-web-requests: false
# 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
# Minimum severity of log statements to print to the system log. Defaults to INFO, change to DEBUG if you need more
# information.
log-level: INFO
# 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:
+3 -20
View File
@@ -1,4 +1,3 @@
import threading
from datetime import timedelta
from requests_cache import CachedSession
@@ -6,22 +5,6 @@ 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()
# rapidly.
SEMI_STATIC_URL_DATA_CACHE = CachedSession("cache/semi_static_url_data_cache",
expire_after=timedelta(days=30))
+14 -10
View File
@@ -14,22 +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)
LOG_LEVEL = config.get("log-level", "INFO")
LOG_WEB_REQUESTS = config.get("log-web-requests", 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
+27 -27
View File
@@ -3,7 +3,7 @@ from data.band import Band
from data.sig import SIG
# General software
SOFTWARE_VERSION = "1.4"
SOFTWARE_VERSION = "1.4-pre"
# HTTP headers used for spot providers that use HTTP
HTTP_HEADERS = {"User-Agent": "Spothole v" + SOFTWARE_VERSION + " (operated by " + SERVER_OWNER_CALLSIGN + ")"}
@@ -11,37 +11,38 @@ HAMQTH_PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_C
# Special Interest Groups
SIGS = [
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="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="WWBOTA", comment_names=["WWBOTA", "BOTA"], description="Worldwide Bunkers on the Air", ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}"),
SIG(name="HEMA", comment_names=["HEMA"], description="HuMPs Excluding Marilyns Award", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}"),
SIG(name="IOTA", comment_names=["IOTA"], description="Islands on the Air", ref_regex=r"[A-Z]{2}\-\d{3}"),
SIG(name="MOTA", comment_names=["MOTA"], description="Mills on the Air", ref_regex=r"X\d{4,6}"),
SIG(name="ARLHS", comment_names=["ARLHS"], description="Amateur Radio Lighthouse Society", ref_regex=r"[A-Z]{3}\-\d{3,4}"),
SIG(name="ILLW", comment_names=["ILLW"], description="International Lighthouse & Lightship Weekend", ref_regex=r"[A-Z]{2}\d{4}"),
SIG(name="SiOTA", comment_names=["SIOTA"], description="Silos on the Air", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"),
SIG(name="WCA", comment_names=["WCA"], description="World Castles Award", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"),
SIG(name="ZLOTA", comment_names=["ZLOTA"], description="New Zealand on the Air", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}"),
SIG(name="WOTA", comment_names=["WOTA"], description="Wainwrights on the Air", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
SIG(name="BOTA", comment_names=[], description="Beaches on the Air"),
SIG(name="KRMNPA", comment_names=["KRMNPA"], description="Keith Roget Memorial National Parks Award"),
SIG(name="LLOTA", comment_names=["LLOTA"], description="Lagos y Lagunas on the Air", ref_regex=r"LL[A-Z]{2}\-\d{4}"),
SIG(name="Towers", comment_names=["TOTA"], description="Towers on the Air", ref_regex=r"[A-Z]{2,3}R\-\d{4}"),
SIG(name="Tiles", comment_names=[], description="Tiles on the Air", ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}"),
SIG(name="WAB", comment_names=["WAB"], description="Worked All Britain", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
SIG(name="WAI", comment_names=["WAI"], description="Worked All Ireland", ref_regex=r"[A-Z][0-9]{2}"),
SIG(name="DME", comment_names=["DME"], description="Diplomas de Municipios Españoles", ref_regex=r"\d{4,5}"),
SIG(name="Toilets", comment_names=[], description="Toilets on the Air", ref_regex=r"T\-[0-9]{2}")
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}"),
SIG(name="WWBOTA", description="Worldwide Bunkers on the Air", ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}"),
SIG(name="HEMA", description="HuMPs Excluding Marilyns Award", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}"),
SIG(name="IOTA", description="Islands on the Air", ref_regex=r"[A-Z]{2}\-\d{3}"),
SIG(name="MOTA", description="Mills on the Air", ref_regex=r"X\d{4,6}"),
SIG(name="ARLHS", description="Amateur Radio Lighthouse Society", ref_regex=r"[A-Z]{3}\-\d{3,4}"),
SIG(name="ILLW", description="International Lighthouse & Lightship Weekend", ref_regex=r"[A-Z]{2}\d{4}"),
SIG(name="SIOTA", description="Silos on the Air", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"),
SIG(name="WCA", description="World Castles Award", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"),
SIG(name="ZLOTA", description="New Zealand on the Air", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}"),
SIG(name="WOTA", description="Wainwrights on the Air", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
SIG(name="BOTA", description="Beaches on the Air"),
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award"),
SIG(name="LLOTA", description="Lagos y Lagunas on the Air", ref_regex=r"[A-Z]{2}\-\d{4}"),
SIG(name="WWTOTA", description="Towers on the Air", ref_regex=r"[A-Z]{2}R\-\d{4}"),
SIG(name="Tiles", description="Tiles on the Air", ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}"),
SIG(name="WAB", description="Worked All Britain", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
SIG(name="WAI", description="Worked All Ireland", ref_regex=r"[A-Z][0-9]{2}"),
SIG(name="TOTA", description="Toilets on the Air", ref_regex=r"T\-[0-9]{2}")
]
# 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
@@ -102,6 +103,5 @@ PROPAGATION_MODES = {
"AU": "Aurora",
"MS": "Meteor scatter",
"RS": "Rain scatter",
"AS": "Aircraft scatter",
"SAT": "Satellite"
"AS": "Aircraft scatter"
}
+33 -84
View File
@@ -5,13 +5,13 @@ import re
import urllib.parse
from datetime import timedelta
import requests
import xmltodict
from diskcache import Cache
from pyhamtools import LookupLib, Callinfo, callinfo
from pyhamtools.exceptions import APIKeyMissingError
from pyhamtools.frequency import freq_to_band
from pyhamtools.locator import latlong_to_locator
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
from requests_cache import CachedSession
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
@@ -143,21 +143,13 @@ class LookupHelper:
try:
logging.info("Downloading Country-files.com cty.plist...")
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.country-files.com/cty/cty.plist",
headers=HTTP_HEADERS)
headers=HTTP_HEADERS).text
if response.ok:
with open(self._country_files_cty_plist_download_location, "w") as f:
f.write(response.text)
f.flush()
return True
else:
logging.warning(f"HTTP {response.status_code} when downloading Country-files.com cty.plist.")
return False
with open(self._country_files_cty_plist_download_location, "w") as f:
f.write(response)
f.flush()
return True
except ConnectionError:
logging.warning(f"Connection error when downloading Clublog cty.xml.")
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when downloading Clublog cty.xml.")
except Exception as e:
logging.error("Exception when downloading Clublog cty.xml", e)
return False
@@ -169,21 +161,13 @@ class LookupHelper:
logging.info("Downloading dxcc.json...")
response = SEMI_STATIC_URL_DATA_CACHE.get(
"https://raw.githubusercontent.com/k0swe/dxcc-json/refs/heads/main/dxcc.json",
headers=HTTP_HEADERS)
headers=HTTP_HEADERS).text
if response.ok:
with open(self._dxcc_json_download_location, "w") as f:
f.write(response.text)
f.flush()
return True
else:
logging.warning(f"HTTP {response.status_code} when downloading dxcc.json.")
return False
with open(self._dxcc_json_download_location, "w") as f:
f.write(response)
f.flush()
return True
except ConnectionError:
logging.warning(f"Connection error when downloading dxcc.json.")
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when downloading dxcc.json.")
except Exception as e:
logging.error("Exception when downloading dxcc.json", e)
return False
@@ -506,47 +490,25 @@ class LookupHelper:
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
calls_to_try = [call]
try:
home_call = callinfo.Callinfo.get_homecall(call)
if home_call != call:
calls_to_try.append(home_call)
except ValueError:
logging.debug("Could not look up home call for callsign %s", call)
home_call = callinfo.Callinfo.get_homecall(call)
if home_call != call:
calls_to_try.append(home_call)
for lookup_call in calls_to_try:
try:
response = SEMI_STATIC_URL_DATA_CACHE.get(
lookup_response = requests.get(
self._qrz_base_url + "?s=" + session_key + "&callsign=" + urllib.parse.quote_plus(lookup_call),
headers=HTTP_HEADERS, timeout=10)
if response.ok:
qrz_response = xmltodict.parse(response.content).get("QRZDatabase", {})
if qrz_response:
if "Callsign" in qrz_response:
data = _normalize_qrz_data(qrz_response.get("Callsign"))
self._qrz_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
return data
elif "Session" in qrz_response and "Error" in qrz_response.get("Session"):
# Errors here are normally just "callsign not in database", no need to log that ourselves
# above debug level.
logging.debug("QRZ returned an error looking up callsign %s: %s", lookup_call,
qrz_response.get("Session").get("Error"))
elif not response.from_cache:
logging.warning("QRZ returned a malformed response looking up callsign %s", lookup_call)
elif not response.from_cache:
logging.warning("HTTP %d looking up callsign %s using QRZ", lookup_call)
headers=HTTP_HEADERS, timeout=10).content
raw = xmltodict.parse(lookup_response).get("QRZDatabase", {}).get("Callsign")
if raw:
data = _normalize_qrz_data(raw)
self._qrz_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
continue
except ConnectionError:
logging.warning(f"Connection error when looking up callsign %s using QRZ", lookup_call)
continue
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when looking up callsign %s using QRZ.", lookup_call)
continue
except Exception:
logging.error("Exception when looking up callsign %s using QRZ", lookup_call, exc_info=True)
continue
logging.error("Exception when looking up QRZ data")
return None
# Not found in QRZ; cache None so we don't keep retrying
self._qrz_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
@@ -584,36 +546,23 @@ class LookupHelper:
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
calls_to_try = [call]
try:
home_call = callinfo.Callinfo.get_homecall(call)
if home_call != call:
calls_to_try.append(home_call)
except ValueError:
logging.debug("Could not look up home call for callsign %s", call)
home_call = callinfo.Callinfo.get_homecall(call)
if home_call != call:
calls_to_try.append(home_call)
for lookup_call in calls_to_try:
try:
response = SEMI_STATIC_URL_DATA_CACHE.get(
lookup_data = SEMI_STATIC_URL_DATA_CACHE.get(
self._hamqth_base_url + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
lookup_call) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS)
if response.ok:
data = xmltodict.parse(response.content)["HamQTH"]["search"]
self._hamqth_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
return data
elif not response.from_cache:
logging.warning("HTTP %d looking up callsign %s using HamQTH", response.status_code, lookup_call)
lookup_call) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS).content
data = xmltodict.parse(lookup_data)["HamQTH"]["search"]
self._hamqth_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
continue
except ConnectionError:
logging.warning(f"Connection error when looking up callsign %s using HamQTH", lookup_call)
continue
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when looking up callsign %s using HamQTH", lookup_call)
continue
except Exception:
logging.error("Exception when looking up callsign %s using HamQTH", lookup_call, exc_info=True)
continue
logging.error("Exception when looking up HamQTH data")
return None
# Not found in HamQTH; cache None so we don't keep retrying
self._hamqth_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
+81 -216
View File
@@ -2,25 +2,11 @@ import csv
import logging
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
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.geo_utils import wab_wai_square_to_lat_lon
# 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.
with open("datafiles/MUNICIPIOS.csv", encoding="latin-1") as _f:
_DME_INDEX = {row["COD_INE"][:5]: row 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
# 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):
"""Utility function to get the regex string for a SIG reference for a named SIG. If no match is found, None will be returned."""
@@ -31,16 +17,6 @@ def get_ref_regex_for_sig(sig):
return None
def get_sig_name_from_comment_name(sig):
"""Utility function to get the name of a SIG from its "comment name". Generally these will be the same but there are
some cases (e.g. is "TOTA" Towers, Tiles or Toilets?) where we need to transform one to the other."""
for s in SIGS:
if any(n.upper() == sig.upper() for n in s.comment_names):
return s.name
return None
def populate_sig_ref_info(sig_ref):
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object which
must at minimum have a "sig" and an "id". The rest of the object will be populated and returned.
@@ -53,137 +29,72 @@ def populate_sig_ref_info(sig_ref):
ref_id = sig_ref.id
try:
if sig.upper() == "POTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://api.pota.app/park/" + ref_id, headers=HTTP_HEADERS)
if response.ok:
data = response.json()
if data:
fullname = str(data["name"]) if "name" in data else None
if fullname and "parktypeDesc" in data and data["parktypeDesc"] != "":
fullname = fullname + " " + data["parktypeDesc"]
sig_ref.name = fullname
sig_ref.url = "https://pota.app/#/park/" + ref_id
sig_ref.grid = data["grid6"] if "grid6" in data else None
sig_ref.latitude = data["latitude"] if "latitude" in data else None
sig_ref.longitude = data["longitude"] if "longitude" in data else None
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api.pota.app/park/" + ref_id, headers=HTTP_HEADERS).json()
if data:
fullname = str(data["name"]) if "name" in data else None
if fullname and "parktypeDesc" in data and data["parktypeDesc"] != "":
fullname = fullname + " " + data["parktypeDesc"]
sig_ref.name = fullname
sig_ref.url = "https://pota.app/#/park/" + ref_id
sig_ref.grid = data["grid6"] if "grid6" in data else None
sig_ref.latitude = data["latitude"] if "latitude" in data else None
sig_ref.longitude = data["longitude"] if "longitude" in data else None
elif sig.upper() == "SOTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://api-db2.sota.org.uk/api/summits/" + ref_id,
headers=HTTP_HEADERS)
if response.ok:
data = response.json()
if data:
sig_ref.name = data["name"] if "name" in data else None
sig_ref.url = "https://www.sotadata.org.uk/en/summit/" + ref_id
sig_ref.grid = data["locator"] if "locator" in data else None
sig_ref.latitude = data["latitude"] if "latitude" in data else None
sig_ref.longitude = data["longitude"] if "longitude" in data else None
sig_ref.activation_score = data["points"] if "points" in data else None
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api-db2.sota.org.uk/api/summits/" + ref_id,
headers=HTTP_HEADERS).json()
if data:
sig_ref.name = data["name"] if "name" in data else None
sig_ref.url = "https://www.sotadata.org.uk/en/summit/" + ref_id
sig_ref.grid = data["locator"] if "locator" in data else None
sig_ref.latitude = data["latitude"] if "latitude" in data else None
sig_ref.longitude = data["longitude"] if "longitude" in data else None
sig_ref.activation_score = data["points"] if "points" in data else None
elif sig.upper() == "WWBOTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + ref_id,
headers=HTTP_HEADERS)
if response.ok:
data = response.json()
if data:
sig_ref.name = data["name"] if "name" in data else None
sig_ref.url = "https://bunkerwiki.org/?s=" + ref_id if ref_id.startswith("B/G") else None
sig_ref.grid = data["locator"] if "locator" in data else None
sig_ref.latitude = data["lat"] if "lat" in data else None
sig_ref.longitude = data["long"] if "long" in data else None
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + ref_id,
headers=HTTP_HEADERS).json()
if data:
sig_ref.name = data["name"] if "name" in data else None
sig_ref.url = "https://bunkerwiki.org/?s=" + ref_id if ref_id.startswith("B/G") else None
sig_ref.grid = data["locator"] if "locator" in data else None
sig_ref.latitude = data["lat"] if "lat" in data else None
sig_ref.longitude = data["long"] if "long" in data else None
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,
headers=HTTP_HEADERS)
if response.ok:
data = response.json()
if data:
sig_ref.name = data["name"] if "name" in data else None
sig_ref.url = "https://www.cqgma.org/zinfo.php?ref=" + ref_id
sig_ref.grid = data["locator"] if "locator" in data else None
# For some things (just IOTA?) the GMA actually returns a box where "latitude" and "longitude" are
# the zeroest corner of the box, then "lat2" and "lng2" provide the other corner. We detect this
# and provide a single lat/lon for the centre. Otherwise if we don't have these extra parameters,
# just use the single point we have.
if data.get("latitude") is not None and data.get("longitude") is not None and data.get(
"lat2") is not None and data.get("lng2") is not None:
sig_ref.latitude = (float(data["latitude"]) + float(data["lat2"])) / 2.0
sig_ref.longitude = (float(data["longitude"]) + float(data["lng2"])) / 2.0
else:
sig_ref.latitude = float(data["latitude"]) if data.get("latitude") is not None else None
sig_ref.longitude = float(data["longitude"]) if data.get("longitude") is not None else None
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s via GMA", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
data = SEMI_STATIC_URL_DATA_CACHE.get("https://www.cqgma.org/api/ref/?" + ref_id,
headers=HTTP_HEADERS).json()
if data:
sig_ref.name = data["name"] if "name" in data else None
sig_ref.url = "https://www.cqgma.org/zinfo.php?ref=" + ref_id
sig_ref.grid = data["locator"] if "locator" in data else None
sig_ref.latitude = data["latitude"] if "latitude" in data else None
sig_ref.longitude = data["longitude"] if "longitude" in data else None
elif sig.upper() == "WWFF":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://wwff.co/wwff-data/wwff_directory.csv",
headers=HTTP_HEADERS)
if response.ok:
global _WWFF_INDEX_CACHE
if not bool(_WWFF_INDEX_CACHE) or not response.from_cache:
# New data from WWFF, update our internal map
_WWFF_INDEX_CACHE = {row["reference"]: row for row in
csv.DictReader(response.content.decode().splitlines())}
row = _WWFF_INDEX_CACHE.get(ref_id)
if row:
sig_ref.name = row["name"] if "name" in row else None
sig_ref.url = "https://wwff.co/directory/?showRef=" + ref_id
sig_ref.grid = row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None
sig_ref.latitude = float(row["latitude"]) if "latitude" in row and row["latitude"] != "-" else None
sig_ref.longitude = float(row["longitude"]) if "longitude" in row and row[
"longitude"] != "-" else None
elif not response.from_cache:
logging.warning("WWFF database did not contain data for ref %s", ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
wwff_csv_data = SEMI_STATIC_URL_DATA_CACHE.get("https://wwff.co/wwff-data/wwff_directory.csv",
headers=HTTP_HEADERS)
wwff_index = {row["reference"]: row for row in csv.DictReader(wwff_csv_data.content.decode().splitlines())}
row = wwff_index.get(ref_id)
if row:
sig_ref.name = row["name"] if "name" in row else None
sig_ref.url = "https://wwff.co/directory/?showRef=" + ref_id
sig_ref.grid = row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None
sig_ref.latitude = float(row["latitude"]) if "latitude" in row and row["latitude"] != "-" else None
sig_ref.longitude = float(row["longitude"]) if "longitude" in row and row["longitude"] != "-" else None
elif sig.upper() == "SIOTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.silosontheair.com/data/silos.csv",
headers=HTTP_HEADERS)
if response.ok:
global _SIOTA_INDEX_CACHE
if not bool(_SIOTA_INDEX_CACHE) or not response.from_cache:
# New data from SIOTA, update our internal map
_SIOTA_INDEX_CACHE = {row["SILO_CODE"]: row for row in
csv.DictReader(response.content.decode().splitlines())}
row = _SIOTA_INDEX_CACHE.get(ref_id)
if row:
sig_ref.name = row["NAME"] if "NAME" in row else None
sig_ref.grid = row["LOCATOR"] if "LOCATOR" in row else None
sig_ref.latitude = float(row["LAT"]) if "LAT" in row else None
sig_ref.longitude = float(row["LNG"]) if "LNG" in row else None
elif not response.from_cache:
logging.warning("SIOTA database did not contain data for ref %s", ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
siota_csv_data = SEMI_STATIC_URL_DATA_CACHE.get("https://www.silosontheair.com/data/silos.csv",
headers=HTTP_HEADERS)
siota_index = {row["SILO_CODE"]: row for row in
csv.DictReader(siota_csv_data.content.decode().splitlines())}
row = siota_index.get(ref_id)
if row:
sig_ref.name = row["NAME"] if "NAME" in row else None
sig_ref.grid = row["LOCATOR"] if "LOCATOR" in row else None
sig_ref.latitude = float(row["LAT"]) if "LAT" in row else None
sig_ref.longitude = float(row["LNG"]) if "LNG" in row else None
elif sig.upper() == "WOTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.wota.org.uk/mapping/data/summits.json",
headers=HTTP_HEADERS)
if response.ok:
data = response.json()
if data:
global _WOTA_INDEX_CACHE
if not bool(_WOTA_INDEX_CACHE) or not response.from_cache:
# New data from WOTA, update our internal map
_WOTA_INDEX_CACHE = {feature["properties"]["wotaId"]: feature for feature in
data.get("features", [])}
feature = _WOTA_INDEX_CACHE.get(ref_id)
if feature:
data = SEMI_STATIC_URL_DATA_CACHE.get("https://www.wota.org.uk/mapping/data/summits.json",
headers=HTTP_HEADERS).json()
if data:
for feature in data["features"]:
if feature["properties"]["wotaId"] == ref_id:
sig_ref.name = feature["properties"]["title"]
# Fudge WOTA URLs. Outlying fell (LDO) URLs don't match their ID numbers but require 214 to be
# added to them
@@ -194,68 +105,42 @@ def populate_sig_ref_info(sig_ref):
sig_ref.grid = feature["properties"]["qthLocator"]
sig_ref.latitude = feature["geometry"]["coordinates"][1]
sig_ref.longitude = feature["geometry"]["coordinates"][0]
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
break
elif sig.upper() == "ZLOTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://ontheair.nz/assets/assets.json", headers=HTTP_HEADERS)
if response.ok:
data = response.json()
if isinstance(data, list):
global _ZLOTA_INDEX_CACHE
if not bool(_ZLOTA_INDEX_CACHE) or not response.from_cache:
# New data from ZLOTA, update our internal map
_ZLOTA_INDEX_CACHE = {asset["code"]: asset for asset in data}
asset = _ZLOTA_INDEX_CACHE.get(ref_id)
if asset:
data = SEMI_STATIC_URL_DATA_CACHE.get("https://ontheair.nz/assets/assets.json", headers=HTTP_HEADERS).json()
if data:
for asset in data:
if asset["code"] == ref_id:
sig_ref.name = asset["name"]
sig_ref.url = "https://ontheair.nz/assets/" + ref_id.replace("/", "_")
sig_ref.url = "https://ontheair.nz/assets/ZLI_OT-030" + ref_id.replace("/", "_")
try:
sig_ref.grid = latlong_to_locator(asset["y"], asset["x"], 6)
except:
logging.debug("Invalid lat/lon received for reference")
sig_ref.latitude = asset["y"]
sig_ref.longitude = asset["x"]
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
break
elif sig.upper() == "BOTA":
if not sig_ref.name:
sig_ref.name = sig_ref.id
sig_ref.url = "https://www.beachesontheair.com/beaches/" + sig_ref.name.lower().replace(" ", "-")
elif sig.upper() == "LLOTA":
response = SEMI_STATIC_URL_DATA_CACHE.get("https://llota.app/api/public/references",
headers=HTTP_HEADERS)
if response.ok:
data = response.json()
if isinstance(data, list):
global _LLOTA_INDEX_CACHE
if not bool(_LLOTA_INDEX_CACHE) or not response.from_cache:
# New data from LLOTA, update our internal map
_LLOTA_INDEX_CACHE = {ref["reference_code"]: ref for ref in data}
ref = _LLOTA_INDEX_CACHE.get(ref_id)
if ref:
data = SEMI_STATIC_URL_DATA_CACHE.get("https://llota.app/api/public/references",
headers=HTTP_HEADERS).json()
if data:
for ref in data:
if ref["reference_code"] == ref_id:
sig_ref.name = str(ref["name"])
sig_ref.url = "https://llota.app/list/ref/" + ref_id
sig_ref.grid = str(ref["grid_locator"])
ll = locator_to_latlong(sig_ref.grid)
sig_ref.latitude = ll[0]
sig_ref.longitude = ll[1]
elif not response.from_cache:
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
elif not response.from_cache:
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
break
elif sig.upper() == "WWTOTA":
if not sig_ref.name:
sig_ref.name = sig_ref.id
sig_ref.url = "https://wwtota.com/seznam/karta_rozhledny.php?ref=" + str(sig_ref.name)
elif sig.upper() == "TILES":
# Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same
if not sig_ref.name:
@@ -266,7 +151,6 @@ def populate_sig_ref_info(sig_ref):
ll = locator_to_latlong(str(sig_ref.grid))
sig_ref.latitude = ll[0]
sig_ref.longitude = ll[1]
elif sig.upper() == "WAB" or sig.upper() == "WAI":
ll = wab_wai_square_to_lat_lon(ref_id)
if ll:
@@ -276,33 +160,14 @@ def populate_sig_ref_info(sig_ref):
sig_ref.latitude = ll[0]
sig_ref.longitude = ll[1]
except:
logging.warning("Invalid lat/lon received for WAB/WAI reference")
elif sig.upper() == "DME":
# Zero-pad to 5 digits to match our source data
row = _DME_INDEX.get(ref_id.zfill(5))
if row:
sig_ref.name = row["NOMBRE_ACTUAL"] + ", " + row["PROVINCIA"]
sig_ref.latitude = float(row["LATITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get(
"LATITUD_ETRS89_REGCAN95") else None
sig_ref.longitude = float(row["LONGITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get(
"LONGITUD_ETRS89_REGCAN95") else None
if sig_ref.latitude and sig_ref.longitude:
try:
sig_ref.grid = latlong_to_locator(sig_ref.latitude, sig_ref.longitude, 6)
except Exception:
logging.warning("Invalid lat/lon received for DME reference")
else:
logging.warning("DME database did not contain data for ref %s", ref_id)
except ConnectionError:
logging.warning("Connection error when looking up sig_ref info for " + sig + " ref " + ref_id)
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when looking up sig_ref info for " + sig + " ref " + ref_id)
except Exception:
logging.error("Exception when looking up sig_ref info for " + sig + " ref " + ref_id, exc_info=True)
logging.debug("Invalid lat/lon received for reference")
except:
logging.warning("Failed to look up sig_ref info for " + sig + " ref " + ref_id + ".")
return sig_ref
# Regex matching any SIG's "comment name", i.e. how it may be referred to in spot comments
ANY_SIG_REGEX = r"(" + r"|".join(n for s in SIGS for n in s.comment_names) + r")"
# Regex matching any SIG
ANY_SIG_REGEX = r"(" + r"|".join(list(map(lambda p: p.name, SIGS))) + r")"
# Regex matching any SIG reference
ANY_XOTA_SIG_REF_REGEX = r"[\w\/]+\-\d+"
+5 -8
View File
@@ -1,11 +1,8 @@
import simplejson
def safe_json_dumps(obj):
"""Safe version of json.dumps that also converts objects to dicts so they can be output, and ignores NaN floats
which are invalid in JSON."""
return simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, default=lambda o: o.__dict__)
def serialize_everything(obj):
"""Convert objects to serialisable things. Used by JSON serialiser as a default when it encounters unserializable things.
Just converts objects to dict. Try to avoid doing anything clever here when serialising spots, because we also need
to receive spots without complex handling."""
return obj.__dict__
def empty_queue(q):
+8 -8
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)
+5 -13
View File
@@ -1,21 +1,13 @@
from dataclasses import dataclass, field
from dataclasses import dataclass
@dataclass
class SIG:
"""Data class that defines a Special Interest Group. Each contains a name and a longer form description.
They also contain comment_names which attempts to separate out the way people might refer to it in
cluster comments from how it is referred to in the UI & API. (For example, "TOTA" in cluster spot comments
almost always means Towers on the Air, but no single programme is referred to in the UI as "TOTA" as
it's ambiguous between Towers, Toilets and Tiles. And while Beaches got the name "BOTA" first, "BOTA" spots
are much more likely to be bunkers.) Finally, there is a ref_regex which provides a regular expression to
match what references (such as parks and summits) look like for that programme."""
"""Data class that defines a Special Interest Group."""
# SIG name as used in the UI and API, e.g. "Towers"
# SIG name, e.g. "POTA"
name: str
# Description, e.g. "Towers on the Air"
# Description, e.g. "Parks on the Air"
description: str
# SIG names as they might appear in cluster spot comments, e.g. ["TOTA"]
comment_names: list[str] = field(default_factory=list)
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
ref_regex: str | None = None
ref_regex: str = None
+18 -22
View File
@@ -14,7 +14,7 @@ from core.constants import MODE_ALIASES, PROPAGATION_MODES
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
from core.lookup_helper import lookup_helper, infer_band_from_freq, infer_mode_from_comment, \
infer_mode_from_frequency, infer_mode_type_from_mode
from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig, get_sig_name_from_comment_name
from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig
from data.sig_ref import SIGRef
@@ -133,17 +133,6 @@ class Spot:
# The ID the source gave it, if any.
source_id: str | None = None
def __post_init__(self):
"""Normalise fields that don't survive a plain dict to Spot conversion. This is used in the "add spot" API
endpoint where the client is submitting JSON, and we want to recreate a full Spot object, including nested
objects such as the sig_refs list.."""
if self.sig_refs:
self.sig_refs = [
sig_ref if isinstance(sig_ref, SIGRef) else SIGRef(**sig_ref)
for sig_ref in self.sig_refs
]
def infer_missing(self, credentials=None):
"""Infer missing parameters where possible"""
@@ -264,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
# 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".
found_sig = get_sig_name_from_comment_name(sig_match.group(2))
if not self.sig:
self.sig = found_sig
@@ -274,7 +270,7 @@ class Spot:
# If so, add that to the sig_refs list for this spot.
ref_regex = get_ref_regex_for_sig(found_sig)
if ref_regex:
ref_matches = re.finditer(r"(^|\W)" + found_sig + r"([ -])(" + ref_regex + r")($|\W)", self.comment,
ref_matches = re.finditer(r"(^|\W)" + found_sig + r"($|\W)(" + ref_regex + r")($|\W)", self.comment,
re.IGNORECASE)
for ref_match in ref_matches:
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(3).upper(), sig=found_sig))
@@ -301,29 +297,29 @@ class Spot:
if self.sig_refs and len(self.sig_refs) > 0 and not self.sig:
self.sig = self.sig_refs[0].sig
# Parse "de_grid<prop_mode>dx_grid" structures from the comment, e.g. "JN61ES(ES)JM56XT" or "JO02GQ<>KN17LG".
# Parse "de_grid<prop_mode>dx_grid" structures from the comment, e.g. "JN61ES<ES>JM56XT" or "JO02GQ<>KN17LG".
# These are common on cluster spots and can provide grid references in preference to e.g. QRZ lookup, as well as
# being the only source we have for propagation mode. Brace for nightmare regex from hell.
# being the only source we have for propagation mode.
if self.comment:
grid_mode_grid_match = re.search(
r'\b([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)(?:<([^>]*)>|\(([^)]*)\))([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\b',
r'\b([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)<([^>]*)>([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\b',
self.comment)
if grid_mode_grid_match:
# regex matches, so extract grids:
if not self.de_grid:
self.de_grid = grid_mode_grid_match.group(1).upper()
if not self.dx_grid:
self.dx_grid = grid_mode_grid_match.group(4).upper()
self.dx_grid = grid_mode_grid_match.group(3).upper()
self.dx_location_source = "SPOT"
# And extract propagation mode (group 2 for <...>, group 3 for (...)):
mode_tag = (grid_mode_grid_match.group(2) or grid_mode_grid_match.group(3) or "").upper()
# And extract propagation mode:
mode_tag = grid_mode_grid_match.group(2).upper()
if mode_tag and not self.propagation_mode:
if mode_tag in PROPAGATION_MODES:
self.propagation_mode = PROPAGATION_MODES[mode_tag]
else:
self.propagation_mode = mode_tag
logging.info("Seen a new propagation mode tag not yet in the system: %s", mode_tag)
logging.info("Seen a new propagation mode tag not yet in the system: {}", mode_tag)
# DX Grid to lat/lon and vice versa in case one is missing
if self.dx_grid and not self.dx_latitude:
File diff suppressed because it is too large Load Diff
-1
View File
@@ -17,4 +17,3 @@ websocket-client~=1.8.0
tornado~=6.4.2
tornado_eventsource~=3.0.0
geopandas~=0.13.2
simplejson~=4.1.1
+166 -25
View File
@@ -1,33 +1,42 @@
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
from core.sig_utils import get_ref_regex_for_sig
from core.utils import safe_json_dumps
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:
@@ -40,7 +49,8 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject if not allowed
if not ALLOW_SPOTTING:
self.set_status(401)
self.write(safe_json_dumps("Error - this server does not allow new spots to be added via the API."))
self.write(json.dumps("Error - this server does not allow new spots to be added via the API.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
@@ -48,7 +58,8 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject if format not json
if not self.request.headers.get('Content-Type', '').startswith("application/json"):
self.set_status(415)
self.write(safe_json_dumps("Error - request Content-Type must be application/json"))
self.write(
json.dumps("Error - request Content-Type must be application/json", default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
@@ -57,19 +68,58 @@ class APISpotHandler(tornado.web.RequestHandler):
post_data = self.request.body
if not post_data:
self.set_status(422)
self.write(safe_json_dumps("Error - request body is empty"))
self.write(json.dumps("Error - request body is empty", default=serialize_everything))
self.set_header("Cache-Control", "no-store")
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 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
# 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:
self.set_status(422)
self.write(safe_json_dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum."))
self.write(json.dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
@@ -77,13 +127,15 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject invalid-looking callsigns
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call):
self.set_status(422)
self.write(safe_json_dumps("Error - '" + spot.dx_call + "' does not look like a valid callsign."))
self.write(json.dumps("Error - '" + spot.dx_call + "' does not look like a valid callsign.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call):
self.set_status(422)
self.write(safe_json_dumps("Error - '" + spot.de_call + "' does not look like a valid callsign."))
self.write(json.dumps("Error - '" + spot.de_call + "' does not look like a valid callsign.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
@@ -91,7 +143,8 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject if frequency not in a known band
if infer_band_from_freq(spot.freq) == UNKNOWN_BAND:
self.set_status(422)
self.write(safe_json_dumps("Error - Frequency of " + str(spot.freq / 1000.0) + "kHz is not in a known band."))
self.write(json.dumps("Error - Frequency of " + str(spot.freq / 1000.0) + "kHz is not in a known band.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
@@ -101,7 +154,8 @@ class APISpotHandler(tornado.web.RequestHandler):
r"^([A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}|[A-R]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2})$",
spot.dx_grid.upper()):
self.set_status(422)
self.write(safe_json_dumps("Error - '" + spot.dx_grid + "' does not look like a valid Maidenhead grid."))
self.write(json.dumps("Error - '" + spot.dx_grid + "' does not look like a valid Maidenhead grid.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
@@ -110,25 +164,112 @@ class APISpotHandler(tornado.web.RequestHandler):
if spot.sig and spot.sig_refs and len(spot.sig_refs) > 0 and spot.sig_refs[0].id and get_ref_regex_for_sig(
spot.sig) and not re.match(get_ref_regex_for_sig(spot.sig), spot.sig_refs[0].id):
self.set_status(422)
self.write(safe_json_dumps(
"Error - '" + spot.sig_refs[0].id + "' does not look like a valid reference for " + spot.sig + "."))
self.write(json.dumps(
"Error - '" + spot.sig_refs[0].id + "' does not look like a valid reference for " + spot.sig + ".",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# infer missing data, and add it to our database.
spot.source = "API"
spot.infer_missing()
self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
# 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
self.write(safe_json_dumps("OK"))
self.set_status(201)
# 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")
self.set_header("Content-Type", "application/json")
except Exception as e:
logging.error("Exception when handling client request to add spot API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
logging.error(e)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
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
+12 -10
View File
@@ -1,4 +1,5 @@
import copy
import json
import logging
from datetime import datetime
from queue import Queue
@@ -11,7 +12,7 @@ from tornado import httputil
from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps, empty_queue
from core.utils import serialize_everything, empty_queue
from data.lookup_credentials import extract_credentials
SSE_HANDLER_MAX_QUEUE_SIZE = 100
@@ -19,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
@@ -52,25 +53,26 @@ 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)
self.write(safe_json_dumps(data))
self.write(json.dumps(data, default=serialize_everything))
self.set_status(200)
except ValueError as e:
self.write(safe_json_dumps("Bad request - " + str(e)))
logging.error(e)
self.write(json.dumps("Bad request - " + str(e), default=serialize_everything))
self.set_status(400)
except Exception as e:
logging.error("Exception when handling client request to alerts API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
logging.error(e)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
self.set_status(500)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
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
@@ -102,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)
@@ -148,7 +150,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
if self._credentials:
alert = copy.deepcopy(alert)
alert.infer_missing(self._credentials)
self.write_message(msg=safe_json_dumps(alert))
self.write_message(msg=json.dumps(alert, default=serialize_everything))
else:
# Send a keepalive comment if the queue was empty
+23 -31
View File
@@ -1,5 +1,4 @@
import json
import logging
from collections import Counter
from datetime import datetime, timedelta
from typing import Any
@@ -10,7 +9,6 @@ from tornado import httputil
from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps
CONTINENTS = ["EU", "NA", "SA", "AS", "AF", "OC", "AN"]
BANDS = ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"]
@@ -19,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
@@ -31,35 +29,29 @@ class APIDxStatsHandler(tornado.web.RequestHandler):
self._web_server_metrics = web_server_metrics
def get(self):
try:
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
one_hour_ago = (datetime.now(pytz.UTC) - timedelta(hours=1)).timestamp()
counts = Counter()
one_hour_ago = (datetime.now(pytz.UTC) - timedelta(hours=1)).timestamp()
counts = Counter()
for key in self._spots.iterkeys():
spot = self._spots.get(key)
if spot is None:
continue
if not spot.time or spot.time < one_hour_ago:
continue
if spot.de_continent in CONTINENTS_SET and spot.dx_continent in CONTINENTS_SET and spot.band in BANDS_SET:
counts[spot.de_continent, spot.dx_continent, spot.band] += 1
for key in self._spots.iterkeys():
spot = self._spots.get(key)
if spot is None:
continue
if not spot.time or spot.time < one_hour_ago:
continue
if spot.de_continent in CONTINENTS_SET and spot.dx_continent in CONTINENTS_SET and spot.band in BANDS_SET:
counts[spot.de_continent, spot.dx_continent, spot.band] += 1
result = {
de: {dx: {band: counts[de, dx, band] for band in BANDS} for dx in CONTINENTS}
for de in CONTINENTS
}
result = {
de: {dx: {band: counts[de, dx, band] for band in BANDS} for dx in CONTINENTS}
for de in CONTINENTS
}
self.write(json.dumps(result))
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
except Exception as e:
logging.error("Exception when handling client request to dx stats API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
self.write(json.dumps(result))
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
+26 -22
View File
@@ -1,3 +1,4 @@
import json
import logging
import re
from datetime import datetime
@@ -12,14 +13,14 @@ from core.constants import SIGS
from core.geo_utils import lat_lon_for_grid_sw_corner_plus_size, lat_lon_to_cq_zone, lat_lon_to_itu_zone
from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig, populate_sig_ref_info
from core.utils import safe_json_dumps
from core.utils import serialize_everything
from data.lookup_credentials import extract_credentials
from data.sig_ref import SIGRef
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
@@ -46,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 = {
@@ -64,18 +65,19 @@ class APILookupCallHandler(tornado.web.RequestHandler):
"longitude": fake_spot.dx_longitude,
"location_source": fake_spot.dx_location_source
}
self.write(safe_json_dumps(data))
self.write(json.dumps(data, default=serialize_everything))
else:
self.write(safe_json_dumps("Error - '" + call + "' does not look like a valid callsign."))
self.write(json.dumps("Error - '" + call + "' does not look like a valid callsign.",
default=serialize_everything))
self.set_status(422)
else:
self.write(safe_json_dumps("Error - call must be provided"))
self.write(json.dumps("Error - call must be provided", default=serialize_everything))
self.set_status(422)
except Exception as e:
logging.error("Exception when handling client request to call lookup API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
logging.error(e)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
self.set_status(500)
self.set_header("Cache-Control", "no-store")
@@ -83,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
@@ -109,25 +111,27 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
if "sig" in query_params.keys() and "id" in query_params.keys():
sig = str(query_params.get("sig")).upper()
ref_id = str(query_params.get("id")).upper()
if sig in list(map(lambda p: p.name.upper(), SIGS)):
if sig in list(map(lambda p: p.name, SIGS)):
if not get_ref_regex_for_sig(sig) or re.match(get_ref_regex_for_sig(sig), ref_id):
data = populate_sig_ref_info(SIGRef(id=ref_id, sig=sig))
self.write(safe_json_dumps(data))
self.write(json.dumps(data, default=serialize_everything))
else:
self.write(safe_json_dumps(
"Error - '" + ref_id + "' does not look like a valid reference ID for " + sig + "."))
self.write(
json.dumps(
"Error - '" + ref_id + "' does not look like a valid reference ID for " + sig + ".",
default=serialize_everything))
self.set_status(422)
else:
self.write(safe_json_dumps("Error - sig '" + sig + "' is not known."))
self.write(json.dumps("Error - sig '" + sig + "' is not known.", default=serialize_everything))
self.set_status(422)
else:
self.write(safe_json_dumps("Error - sig and id must be provided"))
self.write(json.dumps("Error - sig and id must be provided", default=serialize_everything))
self.set_status(422)
except Exception as e:
logging.error("Exception when handling client request to sig ref lookup API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
logging.error(e)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
self.set_status(500)
self.set_header("Cache-Control", "no-store")
@@ -135,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
@@ -181,15 +185,15 @@ class APILookupGridHandler(tornado.web.RequestHandler):
"latitude": lat + lat_cell_size,
"longitude": lon + lon_cell_size,
}}
self.write(safe_json_dumps(response))
self.write(json.dumps(response, default=serialize_everything))
else:
self.write(safe_json_dumps("Error - grid must be provided"))
self.write(json.dumps("Error - grid must be provided", default=serialize_everything))
self.set_status(422)
except Exception as e:
logging.error("Exception when handling client request to grid ref lookup API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
logging.error(e)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
self.set_status(500)
self.set_header("Cache-Control", "no-store")
+45 -35
View File
@@ -1,4 +1,4 @@
import logging
import json
from datetime import datetime
from typing import Any
@@ -10,53 +10,63 @@ from tornado.web import Application
from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS, PROPAGATION_MODES
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps
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):
try:
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
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"]))),
"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")
# 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)
self.write(safe_json_dumps(options))
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
# 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")
except Exception as e:
logging.error("Exception when handling client request to options API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
options = {"bands": BANDS,
"modes": ALL_MODES,
"mode_types": MODE_TYPES,
"sigs": SIGS,
"spot_sources": spot_sources,
"alert_sources": alert_sources,
"continents": CONTINENTS,
"propagation_modes": PROPAGATION_MODES.values(),
"max_spot_age": MAX_SPOT_AGE,
"spot_allowed": ALLOW_SPOTTING,
"spot_submit_providers": spot_submit_providers}
self.write(json.dumps(options, default=serialize_everything))
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
+10 -18
View File
@@ -1,4 +1,3 @@
import logging
from datetime import datetime
from typing import Any
@@ -8,11 +7,10 @@ from tornado import httputil
from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps
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
@@ -24,19 +22,13 @@ class APISolarConditionsHandler(tornado.web.RequestHandler):
self._web_server_metrics = web_server_metrics
def get(self):
try:
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
self.write(self._solar_conditions.to_json())
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
except Exception as e:
logging.error("Exception when handling client request to solar conditions API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
self.write(self._solar_conditions.to_json())
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
+12 -10
View File
@@ -1,4 +1,5 @@
import copy
import json
import logging
from datetime import datetime, timedelta
from queue import Queue
@@ -11,7 +12,7 @@ from tornado import httputil
from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps, empty_queue
from core.utils import serialize_everything, empty_queue
from data.lookup_credentials import extract_credentials
SSE_HANDLER_MAX_QUEUE_SIZE = 1000
@@ -19,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
@@ -52,25 +53,26 @@ 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)
self.write(safe_json_dumps(data))
self.write(json.dumps(data, default=serialize_everything))
self.set_status(200)
except ValueError as e:
self.write(safe_json_dumps("Bad request - " + str(e)))
logging.error(e)
self.write(json.dumps("Bad request - " + str(e), default=serialize_everything))
self.set_status(400)
except Exception as e:
logging.error("Excedption when handling client request to spots API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
logging.error(e)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
self.set_status(500)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
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
@@ -104,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)
@@ -150,7 +152,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
if self._credentials:
spot = copy.deepcopy(spot)
spot.infer_missing(self._credentials)
self.write_message(msg=safe_json_dumps(spot))
self.write_message(msg=json.dumps(spot, default=serialize_everything))
else:
# Send a keepalive comment if the queue was empty
+12 -18
View File
@@ -1,4 +1,4 @@
import logging
import json
from datetime import datetime
from typing import Any
@@ -8,11 +8,11 @@ from tornado import httputil
from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import safe_json_dumps
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
@@ -24,19 +24,13 @@ class APIStatusHandler(tornado.web.RequestHandler):
self._web_server_metrics = web_server_metrics
def get(self):
try:
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# Metrics
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self._web_server_metrics["api_access_counter"] += 1
self._web_server_metrics["status"] = "OK"
api_requests_counter.inc()
self.write(safe_json_dumps(self._status_data))
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
except Exception as e:
logging.error("Exception when handling client request to status API: %s", e, exc_info=True)
self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500)
self.write(json.dumps(self._status_data, default=serialize_everything))
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
+31
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()
-11
View File
@@ -1,11 +0,0 @@
import tornado.web
from core.config import BASE_URL
class ManifestHandler(tornado.web.RequestHandler):
"""Handler for manifest.webmanifest, which needs BASE_URL inserted and a custom content-type"""
def get(self):
self.set_header("Content-Type", "application/manifest+json; charset=UTF-8")
self.render("manifest.webmanifest", baseurl=BASE_URL)
+42 -40
View File
@@ -5,9 +5,10 @@ import os
import tornado
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
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
@@ -15,7 +16,6 @@ from server.handlers.api.options import APIOptionsHandler
from server.handlers.api.solar_conditions import APISolarConditionsHandler
from server.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
from server.handlers.api.status import APIStatusHandler
from server.handlers.manifesthandler import ManifestHandler
from server.handlers.metrics import PrometheusMetricsHandler
from server.handlers.pagetemplate import PageTemplateHandler
@@ -25,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
@@ -34,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()
@@ -64,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
@@ -100,25 +110,19 @@ class WebServer:
if ALLOW_SPOTTING:
ui_routes += [(r"/add-spot", PageTemplateHandler, {"template_name": "add_spot", **handler_opts})]
# API docs, Prometheus metrics, webapp manifest and static assets are always available regardless of API-only
# mode.
# API docs, Prometheus metrics, and finally static assets are always available regardless of API-only mode.
misc_routes = [
(r"/apidocs", PageTemplateHandler, {"template_name": "apidocs", **handler_opts}),
(r"/metrics", PrometheusMetricsHandler),
(r"/manifest.webmanifest", ManifestHandler),
# If e.g. nginx is configured as a reverse proxy with a hard-coded path to static files, as per the README,
# this will never have to handle anything, but having it here allows Spothole to work without nginx for
# testing.
(r"/static/(.*)", StaticFileHandler, {"path": os.path.join(_HERE, "../static")})
(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)
app.listen(self._port, xheaders=True)
logging.info("Web server running on port " + str(WEB_SERVER_PORT))
logging.info("You can access your copy of Spothole at " + BASE_URL)
await self._shutdown_event.wait()
def notify_new_spot(self, spot):
@@ -172,22 +176,20 @@ class WebServer:
pass
def request_log(handler):
"""Custom log function to provide more data about requests when enabled, and to provide the ability to turn off
web request logging altogetether."""
"""Custom log function to provide more data about requests."""
if LOG_WEB_REQUESTS:
if handler.get_status() < 500:
log_method = logging.info
else:
log_method = logging.warning
if handler.get_status() < 500:
log_method = logging.info
else:
log_method = logging.warning
request = handler.request
client_ip = request.remote_ip
referrer = request.headers.get("Referer", "-")
user_agent = request.headers.get("User-Agent", "-")
request = handler.request
client_ip = request.remote_ip
referrer = request.headers.get("Referer", "-")
user_agent = request.headers.get("User-Agent", "-")
log_method(
f'{client_ip} - "{request.method} {request.uri}" '
f'{handler.get_status()} {request.request_time():.2f}ms | '
f'Ref: {referrer} | UA: {user_agent}'
)
log_method(
f'{client_ip} - "{request.method} {request.uri}" '
f'{handler.get_status()} {request.request_time():.2f}ms | '
f'Ref: {referrer} | UA: {user_agent}'
)
+4 -13
View File
@@ -5,7 +5,6 @@ from threading import Thread, Event
import pytz
import requests
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
from core.constants import HTTP_HEADERS
from solarconditionsproviders.ionosonde_utils import compute_band_states
@@ -30,7 +29,7 @@ class GIROIonosonde(SolarConditionsProvider):
data, but is less reliable and often offline."""
def __init__(self, provider_config):
super().__init__("GIRO Ionosonde Data", provider_config)
super().__init__(provider_config)
self._stations = self._load_stations()
self._thread = None
self._stop_event = Event()
@@ -128,18 +127,10 @@ class GIROIonosonde(SolarConditionsProvider):
from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S")
to_str = to_time.strftime("%Y.%m.%d+%H:%M:%S")
url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUFD,fmin&DMUF=3000&fromDate={from_str}&toDate={to_str}"
try:
http_response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15))
if not http_response.ok:
logging.warning(f"HTTP {http_response.status_code} when calling Giro ionosonde API.")
return None, None, None
return self._parse_all(http_response.text)
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when accessing Giro ionosonde API.")
return None, None, None
except ConnectionError:
logging.warning("Connection error when accessing Giro ionosonde API.")
response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15))
if response.status_code != 200:
return None, None, None
return self._parse_all(response.text)
@staticmethod
def _parse_all(text):
+5 -1
View File
@@ -15,9 +15,13 @@ class HamQSL(HTTPSolarConditionsProvider):
Provides solar flux index, geomagnetic indices, and HF/VHF propagation condition summaries."""
def __init__(self, provider_config):
super().__init__("HamQSL", provider_config, URL, POLL_INTERVAL)
super().__init__(provider_config, URL, POLL_INTERVAL)
def _http_response_to_solar_conditions(self, http_response):
if http_response.status_code != 200:
logging.warning("HamQSL solar conditions API returned HTTP " + str(http_response.status_code))
return None
root = ElementTree.fromstring(http_response.text)
sd = root.find("solardata")
if sd is None:
@@ -4,7 +4,6 @@ from threading import Thread, Event
import pytz
import requests
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
from core.constants import HTTP_HEADERS
from solarconditionsproviders.solar_conditions_provider import SolarConditionsProvider
@@ -14,8 +13,8 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
"""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."""
def __init__(self, name, provider_config, url, poll_interval):
super().__init__(name, provider_config)
def __init__(self, provider_config, url, poll_interval):
super().__init__(provider_config)
self._url = url
self._poll_interval = poll_interval
self._thread = None
@@ -40,22 +39,13 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
try:
logging.debug("Polling " + self.name + " solar conditions API...")
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
# Check response code was good
if http_response.ok:
new_data = self._http_response_to_solar_conditions(http_response)
self.update_data(new_data)
new_data = self._http_response_to_solar_conditions(http_response)
self.update_data(new_data)
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Received data from " + self.name + " solar conditions API.")
else:
self.status = "Error"
logging.warning(f"HTTP {http_response.status_code} when calling {self.name} solar conditions API.")
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Received data from " + self.name + " solar conditions API.")
except ConnectionError:
logging.warning(f"Connection error when accessing {self.name} solar conditions API.")
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when accessing {self.name} solar conditions API.")
except Exception:
self.status = "Error"
logging.exception("Exception in HTTP Solar Conditions Provider (" + self.name + ")")
+5 -10
View File
@@ -4,7 +4,6 @@ from threading import Thread, Event
import pytz
import requests
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
from core.constants import HTTP_HEADERS
from solarconditionsproviders.ionosonde_utils import compute_band_states
@@ -24,7 +23,7 @@ class KC2GProp(SolarConditionsProvider):
online, but has fewer stations and does not provide LUF data."""
def __init__(self, provider_config):
super().__init__("KC2G Propagation Data", provider_config)
super().__init__(provider_config)
self._thread = None
self._stop_event = Event()
@@ -45,9 +44,9 @@ class KC2GProp(SolarConditionsProvider):
def _poll(self):
try:
logging.debug("Polling KC2G ionosonde data...")
http_response = requests.get(KC2G_URL, headers=HTTP_HEADERS, timeout=(5, 30))
if not http_response.ok:
logging.warning(f"HTTP {http_response.status_code} when calling KG2G ionosonde API.")
response = requests.get(KC2G_URL, headers=HTTP_HEADERS, timeout=(5, 30))
if response.status_code != 200:
logging.warning(f"KC2G ionosonde API returned HTTP {response.status_code}")
return
now = datetime.now(timezone.utc)
@@ -58,7 +57,7 @@ class KC2GProp(SolarConditionsProvider):
ionosonde_data = dict(self._solar_conditions.ionosonde_data or {})
updated_count = 0
for reading in http_response.json():
for reading in response.json():
station = reading.get("station", {})
ursi = station.get("code")
name = station.get("name")
@@ -116,10 +115,6 @@ class KC2GProp(SolarConditionsProvider):
self.last_update_time = datetime.now(pytz.UTC)
logging.debug(f"Updated KC2G ionosonde data for {updated_count} stations.")
except ConnectionError:
logging.warning("Connection error when accessing KC2G ionosonde API.")
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when accessing KC2G ionosonde API.")
except Exception:
self.status = "Error"
logging.exception("Exception in KC2G ionosonde data provider")
+5 -1
View File
@@ -13,7 +13,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
corresponding fields in the solar conditions object.."""
def __init__(self, provider_config):
super().__init__("NOAA 3-day Forecast", provider_config, URL, POLL_INTERVAL)
super().__init__(provider_config, URL, POLL_INTERVAL)
@staticmethod
def _parse_percentage_table(lines, section_header, year):
@@ -80,6 +80,10 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
return result if result else None
def _http_response_to_solar_conditions(self, http_response):
if http_response.status_code != 200:
logging.warning("NOAA K-index forecast API returned HTTP " + str(http_response.status_code))
return None
lines = http_response.text.splitlines()
# Find the "NOAA Kp index breakdown" section header
@@ -7,11 +7,11 @@ class SolarConditionsProvider:
"""Generic solar conditions provider class. Subclasses of this query individual APIs for space weather and
propagation data."""
def __init__(self, name, provider_config):
def __init__(self, provider_config):
"""Constructor"""
self._solar_conditions_cache = None
self.name = name
self.name = provider_config["name"]
self.enabled = provider_config["enabled"]
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled"
+12 -9
View File
@@ -8,7 +8,7 @@ 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
from core.constants import SOFTWARE_VERSION
from core.lookup_helper import lookup_helper
from core.status_reporter import StatusReporter
@@ -84,9 +84,9 @@ def get_solar_conditions_provider_from_config(config_providers_entry):
if __name__ == '__main__':
# Set up logging
root = logging.getLogger()
root.setLevel(LOG_LEVEL)
root.setLevel(logging.INFO)
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(LOG_LEVEL)
handler.setLevel(logging.INFO)
formatter = logging.Formatter("%(levelname)s : %(message)s")
handler.setFormatter(formatter)
root.handlers.clear()
@@ -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:
+1 -1
View File
@@ -14,7 +14,7 @@ class APRSIS(SpotProvider):
"""Spot provider for the APRS-IS."""
def __init__(self, provider_config):
super().__init__("APRS-IS", provider_config)
super().__init__(provider_config)
self._thread = Thread(target=self._connect)
self._thread.daemon = True
self._aprsis = None
+1 -6
View File
@@ -26,8 +26,7 @@ class DXCluster(SpotProvider):
def __init__(self, provider_config):
"""Constructor requires hostname and port"""
name = provider_config["name"] if "name" in provider_config else "Cluster"
super().__init__(name, provider_config)
super().__init__(provider_config)
self._hostname = provider_config["host"]
self._port = provider_config["port"]
self._login_prompt = provider_config["login_prompt"] if "login_prompt" in provider_config else "login:"
@@ -60,10 +59,6 @@ class DXCluster(SpotProvider):
self._telnet.write((self._login_callsign + "\n").encode("latin-1"))
connected = True
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:
self.status = "Error"
logging.exception("Exception while connecting to DX Cluster Provider (" + self._hostname + ").")
+22 -26
View File
@@ -14,9 +14,9 @@ class GMA(HTTPSpotProvider):
"""Spot provider for General Mountain Activity"""
POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://www.gma.rocks/api/spots/25/"
SPOTS_URL = "https://www.cqgma.org/api/spots/25/"
# GMA spots don't contain the details of the programme they are for, we need a separate lookup for that
REF_INFO_URL_ROOT = "https://www.gma.rocks/api/ref/?"
REF_INFO_URL_ROOT = "https://www.cqgma.org/api/ref/?"
def __init__(self, provider_config):
# Ensure there is an API key in our config, and set up the query URL using it. If no key is provided,
@@ -26,7 +26,7 @@ class GMA(HTTPSpotProvider):
provider_config["enabled"] = False
logging.warning("GMA spot provider configured but no api key was provided, this API will not be queried.")
super().__init__("GMA", provider_config, self.SPOTS_URL + "?key=" + self.api_key, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.SPOTS_URL + "?key=" + self.api_key, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response):
new_spots = []
@@ -37,38 +37,34 @@ class GMA(HTTPSpotProvider):
spot = Spot(source=self.name,
dx_call=source_spot["ACTIVATOR"].upper(),
de_call=source_spot["SPOTTER"].upper(),
# Seen GMA spots with no frequency or with "QRT" in this field
freq=float(source_spot["QRG"]) * 1000 if (
source_spot["QRG"] != "" and source_spot["QRG"] != "QRT") else None,
# Filter out some weird mode strings
freq=float(source_spot["QRG"]) * 1000 if (source_spot["QRG"] != "") else None,
# Seen GMA spots with no frequency
mode=source_spot["MODE"].upper() if "<>" not in source_spot["MODE"] else None,
# Filter out some weird mode strings
comment=source_spot["TEXT"],
sig_refs=[SIGRef(id=source_spot["REF"], sig="", name=source_spot["NAME"])],
time=datetime.strptime(source_spot["DATE"] + source_spot["TIME"], "%Y%m%d%H%M").replace(
tzinfo=pytz.UTC).timestamp(),
# Seen GMA spots with no (or empty) lat/lon
dx_latitude=float(source_spot["LAT"]) if (
source_spot["LAT"] and source_spot["LAT"] != "") else None,
# Seen GMA spots with no (or empty) lat/lon
dx_longitude=float(source_spot["LON"]) if (
source_spot["LON"] and source_spot["LON"] != "") else None,
qrt=source_spot["QRG"] == "QRT")
source_spot["LON"] and source_spot["LON"] != "") else None)
# GMA doesn't give what programme (SIG) the reference is for until we separately look it up.
if "REF" in source_spot:
try:
ref_response = SEMI_STATIC_URL_DATA_CACHE.get(self.REF_INFO_URL_ROOT + source_spot["REF"],
headers=HTTP_HEADERS)
# 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 != "":
# Sometimes this is blank, so handle that
if ref_response.text is not None and ref_response.text != "":
ref_info = ref_response.json()
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. POTA and WWFF
# spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA
# and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry
# to determine if it's a SOTA summit.
if spot.sig_refs and "reftype" in ref_info and ref_info["reftype"] not in ["POTA",
"WWFF"] and (
ref_info["reftype"] != "Summit" or "sota" not in ref_info or ref_info[
"sota"] == ""):
if spot.sig_refs and "reftype" in ref_info and ref_info["reftype"] not in ["POTA", "WWFF"] and (
ref_info["reftype"] != "Summit" or "sota" not in ref_info or ref_info["sota"] == ""):
match ref_info["reftype"]:
case "Summit":
spot.sig_refs[0].sig = "GMA"
@@ -94,17 +90,9 @@ class GMA(HTTPSpotProvider):
spot.sig_refs[0].sig = ref_info["reftype"]
spot.sig = ref_info["reftype"]
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point;
# other code will do that for us.
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
# that for us.
new_spots.append(spot)
elif not ref_response.from_cache:
if not ref_response.ok:
logging.warning(
f"HTTP {ref_response.status_code} when looking up GMA ref {source_spot["REF"]}")
else:
logging.warning(
f"GMA API returned a malformed response when looking up ref {source_spot["REF"]}")
except:
logging.warning("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot[
"REF"] + ", ignoring this spot for now")
@@ -112,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")
+37 -36
View File
@@ -1,10 +1,8 @@
import logging
import re
from datetime import datetime
import pytz
import requests
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
from core.constants import HTTP_HEADERS
from data.sig_ref import SIGRef
@@ -25,7 +23,7 @@ class HEMA(HTTPSpotProvider):
SPOTTER_COMMENT_PATTERN = re.compile("^\\((.*)\\) (.*)$")
def __init__(self, provider_config):
super().__init__("HEMA", provider_config, self.SPOT_SEED_URL, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.SPOT_SEED_URL, self.POLL_INTERVAL_SEC)
self._spot_seed = ""
def _http_response_to_spots(self, http_response):
@@ -37,39 +35,42 @@ class HEMA(HTTPSpotProvider):
new_spots = []
# OK, if the spot seed actually changed, now we make the real request for data.
if spot_seed_changed:
try:
source_data = requests.get(self.SPOTS_URL, headers=HTTP_HEADERS, timeout=(5, 30))
source_data_items = source_data.text.split("=")
# Iterate through source data items.
for source_spot in source_data_items:
spot_items = source_spot.split(";")
# Any line with less than 9 items is not a proper spot line
if len(spot_items) >= 9:
# Fiddle with some data to extract bits we need. Freq/mode and spotter/comment come in combined fields.
freq_mode_match = re.search(self.FREQ_MODE_PATTERN, spot_items[5])
spotter_comment_match = re.search(self.SPOTTER_COMMENT_PATTERN, spot_items[6])
if not freq_mode_match or not spotter_comment_match:
continue
source_data = requests.get(self.SPOTS_URL, headers=HTTP_HEADERS, timeout=(5, 30))
source_data_items = source_data.text.split("=")
# Iterate through source data items.
for source_spot in source_data_items:
spot_items = source_spot.split(";")
# Any line with less than 9 items is not a proper spot line
if len(spot_items) >= 9:
# Fiddle with some data to extract bits we need. Freq/mode and spotter/comment come in combined fields.
freq_mode_match = re.search(self.FREQ_MODE_PATTERN, spot_items[5])
spotter_comment_match = re.search(self.SPOTTER_COMMENT_PATTERN, spot_items[6])
if not freq_mode_match or not spotter_comment_match:
continue
# Convert to our spot format
spot = Spot(source=self.name,
dx_call=spot_items[2].upper(),
de_call=spotter_comment_match.group(1).upper(),
freq=float(freq_mode_match.group(1)) * 1000000,
mode=freq_mode_match.group(2).upper(),
comment=spotter_comment_match.group(2),
sig="HEMA",
sig_refs=[SIGRef(id=spot_items[3].upper(), sig="HEMA", name=spot_items[4])],
time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M").replace(
tzinfo=pytz.UTC).timestamp(),
dx_latitude=float(spot_items[7]),
dx_longitude=float(spot_items[8]))
# Convert to our spot format
spot = Spot(source=self.name,
dx_call=spot_items[2].upper(),
de_call=spotter_comment_match.group(1).upper(),
freq=float(freq_mode_match.group(1)) * 1000000,
mode=freq_mode_match.group(2).upper(),
comment=spotter_comment_match.group(2),
sig="HEMA",
sig_refs=[SIGRef(id=spot_items[3].upper(), sig="HEMA", name=spot_items[4])],
time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M").replace(
tzinfo=pytz.UTC).timestamp(),
dx_latitude=float(spot_items[7]),
dx_longitude=float(spot_items[8]))
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other
# code will do that for us.
new_spots.append(spot)
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when accessing HEMA spots API.")
except ConnectionError:
logging.warning("Connection error when accessing HEMA spots API.")
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
# 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")
+20 -22
View File
@@ -4,8 +4,6 @@ from threading import Thread, Event
import pytz
import requests
from requests import ReadTimeout
from requests.exceptions import ConnectionError, ConnectTimeout
from core.constants import HTTP_HEADERS
from spotproviders.spot_provider import SpotProvider
@@ -15,12 +13,13 @@ class HTTPSpotProvider(SpotProvider):
"""Generic spot provider class for providers that request data via HTTP(S). Just for convenience to avoid code
duplication. Subclasses of this query the individual APIs for data."""
def __init__(self, name, provider_config, url, poll_interval):
super().__init__(name, provider_config)
def __init__(self, provider_config, url, poll_interval):
super().__init__(provider_config)
self._url = url
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
@@ -31,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):
@@ -43,25 +50,16 @@ class HTTPSpotProvider(SpotProvider):
# Request data from API
logging.debug("Polling " + self.name + " spot API...")
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
# Check response code was good
if http_response.ok:
# Pass off to the subclass for processing
new_spots = self._http_response_to_spots(http_response)
# Submit the new spots for processing. There might not be any spots for the less popular programs.
if new_spots:
self._submit_batch(new_spots)
# Pass off to the subclass for processing
new_spots = self._http_response_to_spots(http_response)
# Submit the new spots for processing. There might not be any spots for the less popular programs.
if new_spots:
self._submit_batch(new_spots)
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Received data from " + self.name + " spot API.")
else:
self.status = "Error"
logging.warning(f"HTTP {http_response.status_code} when calling {self.name} spot API.")
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Received data from " + self.name + " spot API.")
except ConnectionError:
logging.warning(f"Connection error when accessing {self.name} spots API.")
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when accessing {self.name} spots API.")
except Exception:
self.status = "Error"
logging.exception("Exception in HTTP JSON Spot Provider (" + self.name + ")")
+1 -1
View File
@@ -12,7 +12,7 @@ class LLOTA(HTTPSpotProvider):
SPOTS_URL = "https://llota.app/api/public/spots"
def __init__(self, provider_config):
super().__init__("LLOTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response):
new_spots = []
+33 -4
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,10 +16,12 @@ 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__("ParksNPeaks", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response):
new_spots = []
@@ -30,8 +34,8 @@ class ParksNPeaks(HTTPSpotProvider):
dx_call=source_spot["actCallsign"].upper(),
de_call=source_spot["actSpoter"].upper() if source_spot["actSpoter"] != "" else None,
# typo exists in API
freq=float(source_spot["actFreq"].replace(",", "").replace("+-", "")
.replace("+/-", "").strip()) * 1000000 if (source_spot["actFreq"] != "") else None,
freq=float(source_spot["actFreq"].replace(",", "")) * 1000000 if (
source_spot["actFreq"] != "") else None,
# Seen PNP spots with empty frequency, and with comma-separated thousands digits
mode=source_spot["actMode"].upper(),
comment=source_spot["actComments"],
@@ -57,9 +61,34 @@ class ParksNPeaks(HTTPSpotProvider):
sig_refs[0].name = source_spot["actLocation"]
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before
if sig not in ["POTA", "SOTA", "WWFF", "SIOTA", "ZLOTA", "KRMNPA", "LLOTA"]:
if sig not in ["POTA", "SOTA", "WWFF", "SIOTA", "ZLOTA", "KRMNPA"]:
logging.warning("PNP spot found with sig " + sig + ", developer needs to add support for this!")
# 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)
+26 -1
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,9 +14,10 @@ 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__("POTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response):
new_spots = []
@@ -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.")
+1 -2
View File
@@ -23,8 +23,7 @@ class RBN(SpotProvider):
def __init__(self, provider_config):
"""Constructor requires port number."""
name = provider_config["name"] if "name" in provider_config else "RBN"
super().__init__(name, provider_config)
super().__init__(provider_config)
self._port = provider_config["port"]
self._telnet = None
self._thread = Thread(target=self._handle)
+69 -30
View File
@@ -1,10 +1,8 @@
import logging
from datetime import datetime
import requests
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
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
@@ -22,8 +20,11 @@ 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__("SOTA", provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
self._api_epoch = ""
def _http_response_to_spots(self, http_response):
@@ -35,31 +36,69 @@ class SOTA(HTTPSpotProvider):
new_spots = []
# OK, if the epoch actually changed, now we make the real request for data.
if epoch_changed:
try:
source_data = requests.get(self.SPOTS_URL, headers=HTTP_HEADERS, timeout=(5, 30)).json()
# Iterate through source data
for source_spot in source_data:
# Convert to our spot format
spot = Spot(source=self.name,
source_id=source_spot["id"],
dx_call=source_spot["activatorCallsign"].upper(),
dx_name=source_spot["activatorName"],
de_call=source_spot["callsign"].upper(),
freq=(float(source_spot["frequency"]) * 1000000) if (
source_spot["frequency"] is not None) else None,
# Seen SOTA spots with no frequency!
mode=source_spot["mode"].upper(),
comment=source_spot["comments"],
sig="SOTA",
sig_refs=[SIGRef(id=source_spot["summitCode"], sig="SOTA", name=source_spot["summitName"],
activation_score=source_spot["points"])],
time=datetime.fromisoformat(source_spot["timeStamp"].replace("Z", "+00:00")).timestamp())
source_data = requests.get(self.SPOTS_URL, headers=HTTP_HEADERS, timeout=(5, 30)).json()
# Iterate through source data
for source_spot in source_data:
# Convert to our spot format
spot = Spot(source=self.name,
source_id=source_spot["id"],
dx_call=source_spot["activatorCallsign"].upper(),
dx_name=source_spot["activatorName"],
de_call=source_spot["callsign"].upper(),
freq=(float(source_spot["frequency"]) * 1000000) if (
source_spot["frequency"] is not None) else None,
# Seen SOTA spots with no frequency!
mode=source_spot["mode"].upper(),
comment=source_spot["comments"],
sig="SOTA",
sig_refs=[SIGRef(id=source_spot["summitCode"], sig="SOTA", name=source_spot["summitName"],
activation_score=source_spot["points"])],
time=datetime.fromisoformat(source_spot["timeStamp"].replace("Z", "+00:00")).timestamp())
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
# that for us.
new_spots.append(spot)
except ConnectionError:
logging.warning("Connection error when accessing SOTA spots API")
except (ConnectTimeout, ReadTimeout):
logging.warning(f"Timeout when accessing SOTA spots API.")
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
# 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.")
+19 -2
View File
@@ -8,10 +8,10 @@ from core.config import MAX_SPOT_AGE
class SpotProvider:
"""Generic spot provider class. Subclasses of this query the individual APIs for data."""
def __init__(self, name, provider_config):
def __init__(self, provider_config):
"""Constructor"""
self.name = name
self.name = provider_config["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)
@@ -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
+2 -2
View File
@@ -13,8 +13,8 @@ from spotproviders.spot_provider import SpotProvider
class SSESpotProvider(SpotProvider):
"""Spot provider using Server-Sent Events."""
def __init__(self, name, provider_config, url):
super().__init__(name, provider_config)
def __init__(self, provider_config, url):
super().__init__(provider_config)
self._url = url
self._event_source = None
self._thread = None
+50 -1
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,9 +13,12 @@ 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__("Tiles", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response):
new_spots = []
@@ -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".
+1 -1
View File
@@ -14,7 +14,7 @@ class UKPacketNet(HTTPSpotProvider):
SPOTS_URL = "https://nodes.ukpacketradio.network/api/nodedata"
def __init__(self, provider_config):
super().__init__("UK Packet Net", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response):
new_spots = []
+2 -2
View File
@@ -13,8 +13,8 @@ from spotproviders.spot_provider import SpotProvider
class WebsocketSpotProvider(SpotProvider):
"""Spot provider using websockets."""
def __init__(self, name, provider_config, url):
super().__init__(name, provider_config)
def __init__(self, provider_config, url):
super().__init__(provider_config)
self._url = url
self._ws = None
self._thread = None
+8 -1
View File
@@ -21,7 +21,7 @@ class WOTA(HTTPSpotProvider):
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
def __init__(self, provider_config):
super().__init__("WOTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response):
new_spots = []
@@ -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")
+8 -1
View File
@@ -12,7 +12,7 @@ class WWBOTA(SSESpotProvider):
SPOTS_URL = "https://api.wwbota.net/spots/"
def __init__(self, provider_config):
super().__init__("WWBOTA", provider_config, self.SPOTS_URL)
super().__init__(provider_config, self.SPOTS_URL)
def _sse_message_to_spot(self, message):
source_spot = json.loads(message)
@@ -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")
+9 -1
View File
@@ -14,7 +14,7 @@ class WWFF(HTTPSpotProvider):
SPOTS_URL = "https://spots.wwff.co/static/spots.json"
def __init__(self, provider_config):
super().__init__("WWFF", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response):
new_spots = []
@@ -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")
@@ -6,14 +6,14 @@ from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider
class Towers(HTTPSpotProvider):
class WWTOTA(HTTPSpotProvider):
"""Spot provider for Towers on the Air"""
POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://wwtota.com/api/cluster_live.php"
def __init__(self, provider_config):
super().__init__("Towers", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response):
new_spots = []
+1 -2
View File
@@ -21,8 +21,7 @@ class XOTA(WebsocketSpotProvider):
SIG = None
def __init__(self, provider_config):
name = provider_config["name"] if "name" in provider_config else "xOTA"
super().__init__(name, provider_config, provider_config["url"])
super().__init__(provider_config, provider_config["url"])
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
+8 -1
View File
@@ -15,7 +15,7 @@ class ZLOTA(HTTPSpotProvider):
LIST_URL = "https://ontheair.nz/assets/assets.json"
def __init__(self, provider_config):
super().__init__("ZLOTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_spots(self, http_response):
new_spots = []
@@ -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")
-140
View File
@@ -1,140 +0,0 @@
// 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) {
// Store options
options = jsonData;
// Populate modes drop-down
$.each(options["modes"], function (i, m) {
$('#mode').append($('<option>', {
value: m,
text: m
}));
});
// Populate SIG drop-down
$.each(options["sigs"], function (i, sig) {
$('#sig').append($('<option>', {
value: sig.name,
text: sig.name
}));
});
// Load settings from settings storage now all the controls are available
loadSettings();
});
}
// Method called to add a spot to the server
function addSpot() {
try {
// Save settings (this will save "your call" for future use)
saveSettings();
// Unpack the user's entered values
const dx = $("#dx-call").val().toUpperCase();
const freqStr = $("#freq").val();
const mode = $("#mode")[0].value;
const sig = $("#sig")[0].value;
const sigRef = $("#sig-ref").val();
const dxGrid = $("#dx-grid").val();
const comment = $("#comment").val();
const de = $("#de-call").val().toUpperCase();
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"] = [{sig: sig, id: sigRef}];
}
if (dxGrid !== "") {
spot["dx_grid"] = dxGrid;
}
if (comment !== "") {
spot["comment"] = comment;
}
if (de !== "") {
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),
contentType: 'application/json',
type: 'POST',
timeout: 10000,
success: async function () {
$("#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);
},
error: function (result) {
showAddSpotError(result.responseText.slice(1, -1));
}
});
} catch (error) {
showAddSpotError(error);
}
return false;
}
// Show an "add spot" error.
function showAddSpotError(text) {
const div = $("<div class='alert alert-danger alert-dismissible fade show mb-0 mt-4' role='alert'></div>");
div.append("<i class='fa-solid fa-triangle-exclamation'></i> ");
div.append(document.createTextNode(text));
div.append("<button type='button' class='btn-close' data-bs-dismiss='alert' aria-label='Close'></button>");
$("#result-bad").empty().append(div);
}
// Force callsign and mode capitalisation
$("#dx-call").change(function () {
$(this).val($(this).val().trim().toUpperCase());
});
$("#de-call").change(function () {
$(this).val($(this).val().trim().toUpperCase());
});
$("#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);
});
}
// Startup
$(document).ready(function () {
// Load options
loadOptions();
// Display intro box
displayIntroBox();
});
-78
View File
@@ -1,78 +0,0 @@
const CACHE_NAME = 'Spothole';
const CACHE_URLS = [
'static/apidocs/openapi.yml',
'static/audio/ping.mp3',
'static/css/style.css',
'static/js/add-spot.js',
'static/js/alerts.js',
'static/js/bands.js',
'static/js/common.js',
'static/js/geo.js',
'static/js/map.js',
'static/js/spots.js',
'static/js/spotsbandsandmap.js',
'static/js/status.js',
'static/js/ui-ham.js',
'static/js/utils.js',
'static/img/logo.png',
'static/img/favicon.ico',
'static/img/icon-32.png',
'static/img/icon-192.png',
'static/img/icon-512.png',
'static/vendor/img/markers_default@2x.png',
'static/vendor/img/markers_shadow.png',
'static/vendor/img/markers_default.png',
'static/vendor/img/markers_shadow@2x.png',
'static/vendor/css/bootstrap-5.3.8.min.css',
'static/vendor/css/images/marker-icon.png',
'static/vendor/css/images/layers-2x.png',
'static/vendor/css/images/marker-shadow.png',
'static/vendor/css/images/layers.png',
'static/vendor/css/images/marker-icon-2x.png',
'static/vendor/css/leaflet-extra-markers-1.2.2.min.css',
'static/vendor/css/leaflet-1.9.4.min.css',
'static/vendor/css/fontawesome-6.7.2.min.css',
'static/vendor/css/solid-6.7.2.min.css',
'static/vendor/js/jquery-3.7.1.min.js',
'static/vendor/js/chart-4.4.9.umd.min.js',
'static/vendor/js/oms-leaflet-0.2.7.min.js',
'static/vendor/js/leaflet-workedallbritainireland.js',
'static/vendor/js/leaflet-providers-2.0.0.js',
'static/vendor/js/text-image-0.7.0.js',
'static/vendor/js/leaflet-vectorgrid-1.3.0.js',
'static/vendor/js/leaflet-ituzones.js',
'static/vendor/js/tinycolor2-1.6.0.min.js',
'static/vendor/js/bootstrap-5.3.8.bundle.min.js',
'static/vendor/js/moment-2.29.4.min.js',
'static/vendor/js/leaflet-cqzones.js',
'static/vendor/js/leaflet-extra-markers-1.2.2.min.js',
'static/vendor/js/leaflet-terminator-1.1.0.min.js',
'static/vendor/js/leaflet-1.9.4.min.js',
'static/vendor/js/leaflet-geodesic-2.7.2.umd.min.js',
'static/vendor/js/leaflet-maidenhead.js',
'static/vendor/webfonts/fa-solid-900.ttf',
'static/vendor/webfonts/fa-solid-900.woff2'
];
self.addEventListener('fetch', (event) => {
// Is this an asset we can cache?
const url = new URL(event.request.url);
const isCacheableRequest = CACHE_URLS.includes(url.pathname);
if (isCacheableRequest) {
// Open the cache
event.respondWith(caches.open(CACHE_NAME).then((cache) => {
// Go to the network first, cacheing the response
return fetch(event.request.url).then((fetchedResponse) => {
cache.put(event.request, fetchedResponse.clone());
return fetchedResponse;
}).catch(() => {
// If the network is unavailable, get from cache.
return cache.match(event.request.url);
});
}));
} else {
// Not a cacheable request, must be a call to the API, so no cache involved just go to the network
}
});
+5 -13
View File
@@ -101,9 +101,9 @@
(MOTA), the Amateur Radio Lighthouse Socirty (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos
on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National
Parks Award (KRMNPA), Wainwrights on the Air (WOTA), Beaches on the Air (BOTA), Lagos y Lagunas On the Air
(LLOTA), Towers on the Air (WWTOTA), Tiles on the Air, Worked All Britain (WAB), Worked All Ireland (WAI), el
Diploma Municipios de España (DME) and Toilets on the Air (TOTA).</p>
<p>As of the time of writing in June 2026, I think Spothole captures essentially all outdoor radio programmes
(LLOTA), Towers on the Air (WWTOTA), Tiles on the Air, Worked All Britain (WAB), Worked All Ireland (WAI), and
Toilets on the Air (TOTA).</p>
<p>As of the time of writing in November 2025, I think Spothole captures essentially all outdoor radio programmes
that have a defined reference list, and almost certainly those that have a spotting/alerting API. If you know of
one I've missed, please let me know!</p>
<h4 class="mt-4">Why can I filter spots by both SIG and Source? Isn't that basically the same thing?</h4>
@@ -117,6 +117,7 @@
<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.
</li>
<li>SIGs have well-defined names, whereas the server owner may name the sources as they see fit.</li>
</ol>
<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>
@@ -158,22 +159,13 @@
modify it however you like, you can claim you wrote it and charge people £1000 for a copy, I don't really mind.
(Please don't do the last one. But if you're using my code for something cool, it would be nice to hear from
you!)</p>
<h2 id="accuracy" class="mt-4">Data Accuracy</h2>
<h2 class="mt-4">Data Accuracy</h2>
<p>Please note that the data coming out of Spothole is only as good as the data going in. People mis-hear and make
typos when spotting callsigns all the time. There are also plenty of cases where Spothole's data, particularly
location data, may be inaccurate. For example, there are POTA parks that span multiple US states, countries that
span multiple CQ zones, portable operators with no requirement to sign /P, etc. If you are doing something where
accuracy is important, such as contesting, you should not rely on Spothole's data to fill in any gaps in your
log.</p>
<p>In the Spothole user interface, under "Your Data", you can enter your credentials for QRZ.com and/or HamQTH if
you have them. This allows Spothole to augment its data with lookups from these services. See the following
section, Privacy, for details of how these are handled. If you are looking at the map and see lots of spots in
the geographic centre of countries, allowing these lookups will help. For QRZ.com a paid account is required to
look up operator locations.</p>
<p>IOTA in particular causes a mapping problem, firstly that there is no official set of geodata beyond the names of
islands. We also have no way of telling whether priority should be given to IOTA reference or e.g. QRZ lookup,
for example for a G callsign in EU-005 Great Britain the QRZ home address is probably more accurate, but in
EU-002 Aaland islands, it's more likely a DXpedition and the IOTA would be more accurate.</p>
<h2 id="privacy" class="mt-4">Privacy</h2>
<p>Spothole collects no data about you on a permanent basis. All spots and alerts are "timed out" and deleted from
the system after a set interval, which by default is one hour for spots and one week for alerts.</p>
+6 -6
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>
@@ -76,7 +76,7 @@
</div>
<script src="/static/js/add-spot.js?v=1785667229"></script>
<script src="/js/add-spot.js?v=1782036437"></script>
<script>$(document).ready(function () {
$("#nav-link-add-spot").addClass("active");
}); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -75,7 +75,7 @@
</div>
<script src="/static/js/alerts.js?v=1785667229"></script>
<script src="/js/alerts.js?v=1782036437"></script>
<script>$(document).ready(function () {
$("#nav-link-alerts").addClass("active");
}); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -1,13 +1,13 @@
{% extends "skeleton.html" %}
{% block head_extra %}
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
<link href="/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
{% end %}
{% block body %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="text-center mb-4">
<img src="/static/img/logo.png" width="192" height="60" alt="Spothole">
<img src="/img/logo.png" width="192" height="60" alt="Spothole">
</div>
<div class="card">
<div class="card-body">
+1 -1
View File
@@ -1,5 +1,5 @@
{% extends "skeleton.html" %}
{% block body %}
<redoc spec-url="/static/apidocs/openapi.yml"></redoc>
<redoc spec-url="/apidocs/openapi.yml"></redoc>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
{% end %}
+5 -3
View File
@@ -3,7 +3,9 @@
<div class="mt-3">
<div id="settingsButtonRow" class="row mb-3">
<div class="col-auto me-auto pt-3"></div>
<div class="col-auto me-auto pt-3">
{% module Template("widgets/refresh-timer.html", web_ui_options=web_ui_options) %}
</div>
<div class="col-auto">
<div class="d-inline-flex gap-1">
{% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options) %}
@@ -75,8 +77,8 @@
<script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script>
<script src="/static/js/spotsbandsandmap.js?v=1785667229"></script>
<script src="/static/js/bands.js?v=1785667229"></script>
<script src="/js/spotsbandsandmap.js?v=1782036437"></script>
<script src="/js/bands.js?v=1782036437"></script>
<script>$(document).ready(function () {
$("#nav-link-bands").addClass("active");
}); <!-- highlight active page in nav --></script>
+14 -15
View File
@@ -1,26 +1,26 @@
{% extends "skeleton.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/css/style.css?v=1785667228" type="text/css">
<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/solid-6.7.2.min.css" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css?v=1782036437" type="text/css">
<link href="/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
<link href="/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
<link href="/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
<script src="/static/vendor/js/jquery-3.7.1.min.js"></script>
<script src="/static/vendor/js/moment-2.29.4.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="/vendor/js/jquery-3.7.1.min.js"></script>
<script src="/vendor/js/moment-2.29.4.min.js"></script>
<script src="/vendor/js/bootstrap-5.3.8.bundle.min.js"></script>
<script src="/vendor/js/tinycolor2-1.6.0.min.js"></script>
<script src="/static/js/utils.js?v=1785667228"></script>
<script src="/static/js/ui-ham.js?v=1785667228"></script>
<script src="/static/js/geo.js?v=1785667228"></script>
<script src="/static/js/common.js?v=1785667228"></script>
<script src="/js/utils.js?v=1782036437"></script>
<script src="/js/ui-ham.js?v=1782036437"></script>
<script src="/js/geo.js?v=1782036437"></script>
<script src="/js/common.js?v=1782036437"></script>
{% end %}
{% block body %}
<div class="container">
<nav id="header" class="navbar navbar-expand-lg bg-body p-0 border-bottom">
<div class="container-fluid p-0">
<a class="navbar-brand" href="/">
<img src="/static/img/logo.png" class="logo" width="192" height="60" alt="Spothole">
<img src="/img/logo.png" class="logo" width="192" height="60" alt="Spothole">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#navbar-toggler-content" aria-controls="navbar-toggler-content"
@@ -87,8 +87,7 @@
</div>
</div>
<div id="embeddedModeFooter" class="text-body-secondary pt-2 px-3 pb-1">Powered by <img src="/static/img/logo.png"
class="logo"
<div id="embeddedModeFooter" class="text-body-secondary pt-2 px-3 pb-1">Powered by <img src="/img/logo.png" class="logo"
width="96" height="30"
alt="Spothole"></div>
+2 -2
View File
@@ -283,8 +283,8 @@
</div>
</div>
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
<script src="/static/js/conditions.js?v=1785667229"></script>
<script src="/vendor/js/chart-4.4.9.umd.min.js"></script>
<script src="/js/conditions.js?v=1782036437"></script>
<script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active");
}); <!-- highlight active page in nav --></script>
+16 -29
View File
@@ -1,19 +1,6 @@
{% extends "base.html" %}
{% block content %}
<div id="map-intro-box" class="permanently-dismissible-box mt-3">
<div class="alert alert-primary alert-dismissible fade show" role="alert">
<i class="fa-solid fa-circle-info"></i> <strong>Spothole's map</strong><br/>Spothole pulls in location data from
a number of sources to try and provide the best map possible. However, please don't trust them to be anything
other than a rough guide. In particular, if you are seeing lots of spots in the centre of a country, please
consider opening the "Your Data" menu and providing QRZ.com and/or HamQTH credentials so that Spothole can find
more accurate locations for these spots. More information can be found in the
<a href="/about#accuracy" class="alert-link">Data Accuracy section of the About page</a>.
<button type="button" id="map-intro-box-dismiss" class="btn-close" data-bs-dismiss="alert"
aria-label="Close"></button>
</div>
</div>
<div id="map">
<div id="settingsButtonRowMap" class="mt-3 px-3">
<div class="row mb-3">
@@ -90,26 +77,26 @@
</div>
</div>
<link rel="stylesheet" href="/static/vendor/css/leaflet-1.9.4.min.css">
<link rel="stylesheet" href="/static/vendor/css/leaflet-extra-markers-1.2.2.min.css">
<script src="/static/vendor/js/leaflet-1.9.4.min.js"></script>
<script src="/static/vendor/js/oms-leaflet-0.2.7.min.js"></script>
<script src="/static/vendor/js/leaflet-providers-2.0.0.js"></script>
<script src="/static/vendor/js/leaflet-extra-markers-1.2.2.min.js"></script>
<script src="/static/vendor/js/leaflet-geodesic-2.7.2.umd.min.js"></script>
<script src="/static/vendor/js/leaflet-vectorgrid-1.3.0.js"></script>
<script src="/static/vendor/js/text-image-0.7.0.js"></script>
<script src="/static/vendor/js/leaflet-terminator-1.1.0.min.js"></script>
<script src="/static/vendor/js/leaflet-maidenhead.js"></script>
<script src="/static/vendor/js/leaflet-ituzones.js"></script>
<script src="/static/vendor/js/leaflet-cqzones.js"></script>
<script src="/static/vendor/js/leaflet-workedallbritainireland.js"></script>
<link rel="stylesheet" href="/vendor/css/leaflet-1.9.4.min.css">
<link rel="stylesheet" href="/vendor/css/leaflet-extra-markers-1.2.2.min.css">
<script src="/vendor/js/leaflet-1.9.4.min.js"></script>
<script src="/vendor/js/oms-leaflet-0.2.7.min.js"></script>
<script src="/vendor/js/leaflet-providers-2.0.0.js"></script>
<script src="/vendor/js/leaflet-extra-markers-1.2.2.min.js"></script>
<script src="/vendor/js/leaflet-geodesic-2.7.2.umd.min.js"></script>
<script src="/vendor/js/leaflet-vectorgrid-1.3.0.js"></script>
<script src="/vendor/js/text-image-0.7.0.js"></script>
<script src="/vendor/js/leaflet-terminator-1.1.0.min.js"></script>
<script src="/vendor/js/leaflet-maidenhead.js"></script>
<script src="/vendor/js/leaflet-ituzones.js"></script>
<script src="/vendor/js/leaflet-cqzones.js"></script>
<script src="/vendor/js/leaflet-workedallbritainireland.js"></script>
<script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script>
<script src="/static/js/spotsbandsandmap.js?v=1785667228"></script>
<script src="/static/js/map.js?v=1785667228"></script>
<script src="/js/spotsbandsandmap.js?v=1782036437"></script>
<script src="/js/map.js?v=1782036437"></script>
<script>$(document).ready(function () {
$("#nav-link-map").addClass("active");
}); <!-- highlight active page in nav --></script>
+11 -11
View File
@@ -15,10 +15,10 @@
content="An Amateur Radio spotting tool bringing together DX clusters and outdoor programmes, providing a universal JSON API and web interface."/>
<meta property="og:description"
content="An Amateur Radio spotting tool bringing together DX clusters and outdoor programmes, providing a universal JSON API and web interface."/>
<link rel="canonical" href="{{ baseurl }}"/>
<meta property="og:url" content="{{ baseurl }}"/>
<meta property="og:image" content="{{ baseurl }}/static/img/banner.png"/>
<meta property="twitter:image" content="{{ baseurl }}/static/img/banner.png"/>
<link rel="canonical" href="https://spothole.app/"/>
<meta property="og:url" content="https://spothole.app/"/>
<meta property="og:image" content="https://spothole.app/img/banner.png"/>
<meta property="twitter:image" content="https://spothole.app/img/banner.png"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="author" content="Ian Renton"/>
<meta property="og:locale" content="en_GB"/>
@@ -26,14 +26,14 @@
<title>Spothole</title>
<link rel="icon" type="image/png" href="/static/img/icon-512.png">
<link rel="apple-touch-icon" href="/static/img/icon-512-pwa.png">
<link rel="alternate icon" type="image/png" href="/static/img/icon-192.png">
<link rel="alternate icon" type="image/png" href="/static/img/icon-32.png">
<link rel="alternate icon" type="image/png" href="/static/img/icon-16.png">
<link rel="alternate icon" type="image/x-icon" href="/static/favicon.ico">
<link rel="icon" type="image/png" href="/img/icon-512.png">
<link rel="apple-touch-icon" href="img/icon-512-pwa.png">
<link rel="alternate icon" type="image/png" href="/img/icon-192.png">
<link rel="alternate icon" type="image/png" href="/img/icon-32.png">
<link rel="alternate icon" type="image/png" href="/img/icon-16.png">
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="manifest" href="manifest.webmanifest">
{% block head_extra %}{% end %}
</head>
+2 -2
View File
@@ -116,8 +116,8 @@
<script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script>
<script src="/static/js/spotsbandsandmap.js?v=1785667228"></script>
<script src="/static/js/spots.js?v=1785667228"></script>
<script src="/js/spotsbandsandmap.js?v=1782036437"></script>
<script src="/js/spots.js?v=1782036437"></script>
<script>$(document).ready(function () {
$("#nav-link-spots").addClass("active");
}); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -59,7 +59,7 @@
</div>
</div>
<script src="/static/js/status.js?v=1785667229"></script>
<script src="/js/status.js?v=1782036437"></script>
<script>
$(document).ready(function () {
$("#nav-link-status").addClass("active");
@@ -11,18 +11,21 @@ info:
Please note that the data coming out of Spothole is only as good as the data going in. People mis-hear and make typos when spotting callsigns all the time, and there are plenty of areas where Spothole's location data may be inaccurate. If you are doing something where accuracy is important, such as contesting, you should not rely on Spothole's data to fill in any gaps in your log.
Spothole's source code is located at https://git.ianrenton.com/ian/spothole and the README there provides setup instructions if you would like to run your own copy. A demonstration server of Spothole is located at https://spothole.app. The README also contains some examples of how you could query the API of the demonstration server to integrate the data into your own apps.
Spothole's source code is located at https://git.ianrenton.com/ian/spothole and the README there provides setup instructions if you would like to run your own copy. A demonstration server of Spothole is located at https://spothole.app.
## 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.
* Added support for the Diploma de Municipios Españoles (DME) SIG
* Renamed some SIGs to avoid confusion between Towers, Tiles and Toilets
* Added `comment_names` to SIGs in the `/options`, to reflect how they might be referred to in spot comments where
it differs from their `name`.
* Added `propagation_mode` field to spots
### 1.3
@@ -45,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
@@ -329,7 +332,8 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
/lookup/sigref:
@@ -357,7 +361,8 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
@@ -383,7 +388,8 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
/spot:
@@ -392,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
@@ -443,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
@@ -458,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
@@ -776,7 +785,7 @@ components:
- ZLOTA
- WOTA
- LLOTA
- Towers
- WWTOTA
- Tiles
- Cluster
- RBN
@@ -795,7 +804,7 @@ components:
- HEMA
- WCA
- MOTA
- SiOTA
- SIOTA
- ARLHS
- ILLW
- ZLOTA
@@ -804,12 +813,11 @@ components:
- WOTA
- BOTA
- LLOTA
- Towers
- WWTOTA
- Tiles
- WAB
- WAI
- DME
- Toilets
- TOTA
example: POTA
SIGNameIncludingNoSIG:
@@ -1184,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
@@ -1379,16 +1435,6 @@ components:
type: string
description: The full name of the SIG
example: Parks on the Air
comment_names:
type: array
description: >
Names by which this SIG may be referred to in cluster spot comments. Most SIGs have a
single entry matching their programme name (e.g. ["POTA"]), but some have none (where
the name is ambiguous with other SIGs, such as Tiles and Toilets on the Air) or multiple
entries (e.g. WWBOTA accepts both "WWBOTA" and "BOTA" since the latter is often used).
items:
type: string
example: [ "TOTA" ]
ref_regex:
type: string
description: >
@@ -1730,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
@@ -1875,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
@@ -1885,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

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Before

Width:  |  Height:  |  Size: 348 B

After

Width:  |  Height:  |  Size: 348 B

Before

Width:  |  Height:  |  Size: 348 B

After

Width:  |  Height:  |  Size: 348 B

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Before

Width:  |  Height:  |  Size: 348 B

After

Width:  |  Height:  |  Size: 348 B

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Some files were not shown because too many files have changed in this diff Show More