Fetch solar conditions from HamQSL #92

This commit is contained in:
Ian Renton
2026-03-28 10:04:29 +00:00
parent ce99bbc6cf
commit 1173af6a9d
20 changed files with 526 additions and 29 deletions

View File

@@ -301,6 +301,7 @@ To navigate your way around the source code, this list may help.
* `/data` - Data storage classes * `/data` - Data storage classes
* `/spotproviders` - Classes providing spots by accessing the APIs of other services * `/spotproviders` - Classes providing spots by accessing the APIs of other services
* `/alertproviders` - Classes providing alerts by accessing the APIs of other services * `/alertproviders` - Classes providing alerts by accessing the APIs of other services
* `/solarconditionsproviders` - Classes providing solar and propagation by accessing the APIs of other services
* `/server` - Classes for running Spothole's own web server * `/server` - Classes for running Spothole's own web server
*Templates* *Templates*
@@ -355,4 +356,6 @@ The software uses a number of Python libraries as listed in `requirements.txt`,
Particular thanks go to country-files.com for providing country lookup data for amateur radio, to K0SWE for [this JSON-formatted DXCC data](https://github.com/k0swe/dxcc-json/), and to the developers of `pyhamtools` for making it easy to use country-files.com data as well as QRZ.com and Clublog lookup. Particular thanks go to country-files.com for providing country lookup data for amateur radio, to K0SWE for [this JSON-formatted DXCC data](https://github.com/k0swe/dxcc-json/), and to the developers of `pyhamtools` for making it easy to use country-files.com data as well as QRZ.com and Clublog lookup.
Amateur radio clusters, outdoor programmes, propagation data providers etc. are almost all volunteer-run services that make no or little profit, and are done for the love of amateur radio. Services like Spothole, which build on top of them, are truly standing on the shoulders of giants. None of this would have been possible without the hard work and dedication of many other people within the amaetur radio community.
The project's name was suggested by Harm, DK4HAA. Thanks! The project's name was suggested by Harm, DK4HAA. Thanks!

View File

@@ -128,7 +128,6 @@ spot-providers:
sig: "TOTA" sig: "TOTA"
locations-csv: "datafiles/39c3-tota.csv" locations-csv: "datafiles/39c3-tota.csv"
# Alert providers to use. Same setup as the spot providers list above. # Alert providers to use. Same setup as the spot providers list above.
alert-providers: alert-providers:
- -
@@ -160,6 +159,15 @@ alert-providers:
name: "NG3K" name: "NG3K"
enabled: true enabled: true
# Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band
# conditions, etc.) and make it available via the /api/v1/solar endpoint.
solar-condition-providers:
-
class: "HamQSL"
name: "HamQSL"
enabled: true
# Port to open the local web server on # Port to open the local web server on
web-server-port: 8080 web-server-port: 8080

View File

@@ -14,7 +14,7 @@ class StatusReporter:
"""Provides a timed update of the application's status data.""" """Provides a timed update of the application's status data."""
def __init__(self, status_data, run_interval, web_server, cleanup_timer, spots, spot_providers, alerts, def __init__(self, status_data, run_interval, web_server, cleanup_timer, spots, spot_providers, alerts,
alert_providers): alert_providers, solar_condition_providers):
"""Constructor""" """Constructor"""
self._status_data = status_data self._status_data = status_data
@@ -25,6 +25,7 @@ class StatusReporter:
self._spot_providers = spot_providers self._spot_providers = spot_providers
self._alerts = alerts self._alerts = alerts
self._alert_providers = alert_providers self._alert_providers = alert_providers
self._solar_condition_providers = solar_condition_providers
self._thread = None self._thread = None
self._stop_event = Event() self._stop_event = Event()
self._startup_time = datetime.now(pytz.UTC) self._startup_time = datetime.now(pytz.UTC)
@@ -70,6 +71,11 @@ class StatusReporter:
"last_updated": p.last_update_time.replace( "last_updated": p.last_update_time.replace(
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0}, tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
self._alert_providers)) self._alert_providers))
self._status_data["solar_condition_providers"] = list(
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
"last_updated": p.last_update_time.replace(
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
self._solar_condition_providers))
self._status_data["cleanup"] = {"status": self._cleanup_timer.status, self._status_data["cleanup"] = {"status": self._cleanup_timer.status,
"last_ran": self._cleanup_timer.last_cleanup_time.replace( "last_ran": self._cleanup_timer.last_cleanup_time.replace(
tzinfo=pytz.UTC).timestamp() if self._cleanup_timer.last_cleanup_time else 0} tzinfo=pytz.UTC).timestamp() if self._cleanup_timer.last_cleanup_time else 0}

69
data/solar_conditions.py Normal file
View File

@@ -0,0 +1,69 @@
import json
from dataclasses import dataclass
@dataclass
class HFBandCondition:
"""Data class representing HF propagation conditions for certain bands and time of day."""
# Band name, e.g. "80m-40m", "20m-17m", "10m-6m"
band: str = None
# Time of day: "day" or "night"
time: str = None
# Propagation condition: "Good", "Fair", or "Poor"
condition: str = None
@dataclass
class VHFCondition:
"""Data class representing a VHF propagation condition."""
# Phenomenon name, e.g. "E-Skip", "Sporadic E"
phenomenon: str = None
# Geographic location this applies to, e.g. "Europe", "N America"
location: str = None
# Condition description, e.g. "Band Closed", "Enhanced", "Good"
condition: str = None
@dataclass
class SolarConditions:
"""Data class representing current solar and propagation conditions."""
# Time the data was last updated at the source, UTC seconds since UNIX epoch
updated: float = None
# Solar Flux Index (SFI)
sfi: int = None
# A-index (daily geomagnetic activity)
a_index: int = None
# K-index (3-hour geomagnetic activity)
k_index: int = None
# X-ray flux class, e.g. "B2.3", "C1.0"
x_ray: str = None
# Proton flux
proton_flux: int = None
# Electron flux
electron_flux: int = None
# Aurora activity level
aurora: int = None
# Latitude in degrees of the aurora boundary
aurora_latitude: float = None
# Sunspot count
sunspots: int = None
# Solar wind speed in km/s
solar_wind: float = None
# Interplanetary magnetic field strength in nT
magnetic_field: float = None
# Geomagnetic field condition, e.g. "Quiet", "Unsettled", "Active", "Storm"
geomag_field: str = None
# Geomagnetic background noise level, e.g. "S0", "S1", "S2"
geomag_noise: str = None
# HF band propagation conditions
hf_conditions: list = None # list[HFBandCondition]
# VHF propagation phenomena
vhf_conditions: list = None # list[VHFCondition]
def to_json(self):
"""JSON serialise"""
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 194 KiB

View File

@@ -0,0 +1,28 @@
import json
from datetime import datetime
import pytz
import tornado
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything
class APISolarConditionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/solar"""
def initialize(self, solar_conditions, web_server_metrics):
self._solar_conditions = solar_conditions
self._web_server_metrics = web_server_metrics
def get(self):
# 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")

View File

@@ -10,6 +10,7 @@ from server.handlers.api.addspot import APISpotHandler
from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
from server.handlers.api.lookups import APILookupCallHandler, APILookupSIGRefHandler, APILookupGridHandler from server.handlers.api.lookups import APILookupCallHandler, APILookupSIGRefHandler, APILookupGridHandler
from server.handlers.api.options import APIOptionsHandler 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.spots import APISpotsHandler, APISpotsStreamHandler
from server.handlers.api.status import APIStatusHandler from server.handlers.api.status import APIStatusHandler
from server.handlers.metrics import PrometheusMetricsHandler from server.handlers.metrics import PrometheusMetricsHandler
@@ -19,11 +20,12 @@ from server.handlers.pagetemplate import PageTemplateHandler
class WebServer: class WebServer:
"""Provides the public-facing web server.""" """Provides the public-facing web server."""
def __init__(self, spots, alerts, status_data, port): def __init__(self, spots, alerts, solar_conditions, status_data, port):
"""Constructor""" """Constructor"""
self._spots = spots self._spots = spots
self._alerts = alerts self._alerts = alerts
self._solar_conditions = solar_conditions
self._sse_spot_queues = [] self._sse_spot_queues = []
self._sse_alert_queues = [] self._sse_alert_queues = []
self._status_data = status_data self._status_data = status_data
@@ -59,6 +61,8 @@ class WebServer:
{"sse_spot_queues": self._sse_spot_queues, "web_server_metrics": self.web_server_metrics}), {"sse_spot_queues": self._sse_spot_queues, "web_server_metrics": self.web_server_metrics}),
(r"/api/v1/alerts/stream", APIAlertsStreamHandler, (r"/api/v1/alerts/stream", APIAlertsStreamHandler,
{"sse_alert_queues": self._sse_alert_queues, "web_server_metrics": self.web_server_metrics}), {"sse_alert_queues": self._sse_alert_queues, "web_server_metrics": self.web_server_metrics}),
(r"/api/v1/solar", APISolarConditionsHandler,
{"solar_conditions": self._solar_conditions, "web_server_metrics": self.web_server_metrics}),
(r"/api/v1/options", APIOptionsHandler, (r"/api/v1/options", APIOptionsHandler,
{"status_data": self._status_data, "web_server_metrics": self.web_server_metrics}), {"status_data": self._status_data, "web_server_metrics": self.web_server_metrics}),
(r"/api/v1/status", APIStatusHandler, (r"/api/v1/status", APIStatusHandler,

View File

@@ -0,0 +1,104 @@
import logging
from xml.etree import ElementTree
import pytz
from dateutil import parser as dateutil_parser, tz as dateutil_tz
from data.solar_conditions import HFBandCondition, VHFCondition
from solarconditionsproviders.http_solar_conditions_provider import HTTPSolarConditionsProvider
POLL_INTERVAL = 3600 # 1 hour
URL = "https://www.hamqsl.com/solarxml.php"
class HamQSL(HTTPSolarConditionsProvider):
"""Solar conditions provider using the HamQSL.com XML API (https://www.hamqsl.com/solarxml.php).
Provides solar flux index, geomagnetic indices, and HF/VHF propagation condition summaries."""
def __init__(self, provider_config):
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:
logging.warning("HamQSL solar conditions API returned unexpected XML structure")
return None
# Some error checking functions in case the data is janky.
def text(tag, default=None):
el = sd.find(tag)
return el.text.strip() if el is not None and el.text else default
def float_val(tag, default=None):
try:
return float(text(tag))
except (ValueError, TypeError):
return default
def int_val(tag, default=None):
try:
return int(text(tag))
except (ValueError, TypeError):
return default
# Process HF band conditions
hf_conditions = []
calc = sd.find("calculatedconditions")
if calc is not None:
for band_el in calc.findall("band"):
name = band_el.get("name")
time = band_el.get("time")
condition = band_el.text.strip() if band_el.text else None
if name and time and condition:
hf_conditions.append(HFBandCondition(band=name, time=time, condition=condition))
# Process VHF propagation conditions
vhf_conditions = []
vhf = sd.find("calculatedvhfconditions")
if vhf is not None:
for ph_el in vhf.findall("phenomenon"):
vhf_conditions.append(VHFCondition(
phenomenon=ph_el.get("name"),
location=ph_el.get("location"),
condition=ph_el.text.strip() if ph_el.text else None
))
# Parse the "updated" timestamp string (format: "28 Mar 2026 0949 GMT") to UTC epoch seconds.
updated = None
updated_str = text("updated")
if updated_str:
try:
tz_abbr = updated_str.split()[-1]
timezone = dateutil_tz.gettz(tz_abbr)
if timezone is None:
raise ValueError("Unknown timezone abbreviation: " + tz_abbr)
dt = dateutil_parser.parse(updated_str, tzinfos={tz_abbr: timezone})
updated = dt.astimezone(pytz.UTC).timestamp()
except (ValueError, IndexError):
logging.warning("HamQSL solar conditions API returned unrecognised timestamp format: " + updated_str)
# Return the data ready to be put into the solar conditions object.
return {
"updated": updated,
"sfi": int_val("solarflux"),
"a_index": int_val("aindex"),
"k_index": int_val("kindex"),
"x_ray": text("xray"),
"sunspots": int_val("sunspots"),
"proton_flux": int_val("protonflux"),
"electron_flux": int_val("electonflux"),
"aurora": int_val("aurora"),
"aurora_latitude": float_val("latdegree"),
"solar_wind": float_val("solarwind"),
"magnetic_field": float_val("magneticfield"),
"geomag_field": text("geomagfield"),
"geomag_noise": text("signalnoise"),
"hf_conditions": hf_conditions,
"vhf_conditions": vhf_conditions
}

View File

@@ -0,0 +1,61 @@
import logging
from datetime import datetime
from threading import Thread, Event
import pytz
import requests
from core.constants import HTTP_HEADERS
from solarconditionsproviders.solar_conditions_provider import SolarConditionsProvider
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, provider_config, url, poll_interval):
super().__init__(provider_config)
self._url = url
self._poll_interval = poll_interval
self._thread = None
self._stop_event = Event()
def start(self):
logging.info("Set up query of " + self.name + " solar conditions API every " + str(self._poll_interval) + " seconds.")
self._thread = Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self):
self._stop_event.set()
def _run(self):
while True:
self._poll()
if self._stop_event.wait(timeout=self._poll_interval):
break
def _poll(self):
try:
logging.debug("Polling " + self.name + " solar conditions API...")
http_response = requests.get(self._url, headers=HTTP_HEADERS)
new_data = self._http_response_to_solar_conditions(http_response)
if new_data:
for key, value in new_data.items():
if hasattr(self._solar_conditions, key):
setattr(self._solar_conditions, key, value)
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Received data from " + self.name + " solar conditions API.")
except Exception:
self.status = "Error"
logging.exception("Exception in HTTP Solar Conditions Provider (" + self.name + ")")
self._stop_event.wait(timeout=1)
def _http_response_to_solar_conditions(self, http_response):
"""Convert an HTTP response into solar conditions data. Returns a dict mapping SolarConditions field
names to their new values, or None if the response could not be parsed. Only the fields returned will
be updated on the shared SolarConditions object; any fields not included will be left unchanged."""
raise NotImplementedError("Subclasses must implement this method")

View File

@@ -0,0 +1,32 @@
from datetime import datetime
import pytz
class SolarConditionsProvider:
"""Generic solar conditions provider class. Subclasses of this query individual APIs for space weather and
propagation data."""
def __init__(self, provider_config):
"""Constructor"""
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"
self._solar_conditions = None
def setup(self, solar_conditions):
"""Set up the provider, giving it the solar conditions dict to update"""
self._solar_conditions = solar_conditions
def start(self):
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
raise NotImplementedError("Subclasses must implement this method")
def stop(self):
"""Stop any threads and prepare for application shutdown"""
raise NotImplementedError("Subclasses must implement this method")

View File

@@ -8,6 +8,7 @@ import sys
from diskcache import Cache from diskcache import Cache
from core.cleanup import CleanupTimer from core.cleanup import CleanupTimer
from data.solar_conditions import SolarConditions
from core.config import config, WEB_SERVER_PORT, SERVER_OWNER_CALLSIGN from core.config import config, WEB_SERVER_PORT, SERVER_OWNER_CALLSIGN
from core.constants import SOFTWARE_NAME, SOFTWARE_VERSION from core.constants import SOFTWARE_NAME, SOFTWARE_VERSION
from core.lookup_helper import lookup_helper from core.lookup_helper import lookup_helper
@@ -17,10 +18,12 @@ from server.webserver import WebServer
# Globals # Globals
spots = Cache('cache/spots_cache') spots = Cache('cache/spots_cache')
alerts = Cache('cache/alerts_cache') alerts = Cache('cache/alerts_cache')
solar_conditions = SolarConditions()
web_server = None web_server = None
status_data = {} status_data = {}
spot_providers = [] spot_providers = []
alert_providers = [] alert_providers = []
solar_condition_providers = []
cleanup_timer = None cleanup_timer = None
run = True run = True
@@ -38,6 +41,9 @@ def shutdown(sig, frame):
for ap in alert_providers: for ap in alert_providers:
if ap.enabled: if ap.enabled:
ap.stop() ap.stop()
for scp in solar_condition_providers:
if scp.enabled:
scp.stop()
cleanup_timer.stop() cleanup_timer.stop()
lookup_helper.stop() lookup_helper.stop()
spots.close() spots.close()
@@ -61,6 +67,14 @@ def get_alert_provider_from_config(config_providers_entry):
return provider_class(config_providers_entry) return provider_class(config_providers_entry)
def get_solar_conditions_provider_from_config(config_providers_entry):
"""Utility method to get a solar conditions provider based on the class specified in its config entry."""
module = importlib.import_module('solarconditionsproviders.' + config_providers_entry["class"].lower())
provider_class = getattr(module, config_providers_entry["class"])
return provider_class(config_providers_entry)
# Main function # Main function
if __name__ == '__main__': if __name__ == '__main__':
# Set up logging # Set up logging
@@ -83,7 +97,7 @@ if __name__ == '__main__':
lookup_helper.start() lookup_helper.start()
# Set up web server # Set up web server
web_server = WebServer(spots=spots, alerts=alerts, status_data=status_data, port=WEB_SERVER_PORT) web_server = WebServer(spots=spots, alerts=alerts, solar_conditions=solar_conditions, status_data=status_data, port=WEB_SERVER_PORT)
# Fetch, set up and start spot providers # Fetch, set up and start spot providers
for entry in config["spot-providers"]: for entry in config["spot-providers"]:
@@ -101,6 +115,14 @@ if __name__ == '__main__':
if p.enabled: if p.enabled:
p.start() p.start()
# Fetch, 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:
p.setup(solar_conditions=solar_conditions)
if p.enabled:
p.start()
# Set up timer to clear spot list of old data # Set up timer to clear spot list of old data
cleanup_timer = CleanupTimer(spots=spots, alerts=alerts, web_server=web_server, cleanup_interval=60) cleanup_timer = CleanupTimer(spots=spots, alerts=alerts, web_server=web_server, cleanup_interval=60)
cleanup_timer.start() cleanup_timer.start()
@@ -108,7 +130,8 @@ if __name__ == '__main__':
# Set up status reporter # Set up status reporter
status_reporter = StatusReporter(status_data=status_data, spots=spots, alerts=alerts, web_server=web_server, status_reporter = StatusReporter(status_data=status_data, spots=spots, alerts=alerts, web_server=web_server,
cleanup_timer=cleanup_timer, spot_providers=spot_providers, cleanup_timer=cleanup_timer, spot_providers=spot_providers,
alert_providers=alert_providers, run_interval=5) alert_providers=alert_providers,
solar_condition_providers=solar_condition_providers, run_interval=5)
status_reporter.start() status_reporter.start()
logging.info("Startup complete.") logging.info("Startup complete.")

View File

@@ -27,6 +27,7 @@
<h4 class="mt-4">What data sources are supported?</h4> <h4 class="mt-4">What data sources are supported?</h4>
<p>Spothole can retrieve spots from: <a href="https://www.dxcluster.info/telnet/">Telnet-based DX clusters</a>, the <a href="https://www.reversebeacon.net/">Reverse Beacon Network (RBN)</a>, the <a href="https://www.aprs-is.net/">APRS Internet Service (APRS-IS)</a>, <a href="https://pota.app">POTA</a>, <a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a href="https://www.cqgma.org/">GMA</a>, <a href="https://wwbota.net/">WWBOTA</a>, <a href="http://www.hema.org.uk/">HEMA</a>, <a href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://ontheair.nz">ZLOTA</a>, <a href="https://www.wota.org.uk/">WOTA</a>, <a href="https://llota.app">LLOTA</a>, <a href="https://wwtota.com">WWTOTA</a>, the <a href="https://ukpacketradio.network/">UK Packet Repeater Network</a>, and any site based on the <a href="https://github.com/nischu/xOTA">xOTA software by nischu</a>.</p> <p>Spothole can retrieve spots from: <a href="https://www.dxcluster.info/telnet/">Telnet-based DX clusters</a>, the <a href="https://www.reversebeacon.net/">Reverse Beacon Network (RBN)</a>, the <a href="https://www.aprs-is.net/">APRS Internet Service (APRS-IS)</a>, <a href="https://pota.app">POTA</a>, <a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a href="https://www.cqgma.org/">GMA</a>, <a href="https://wwbota.net/">WWBOTA</a>, <a href="http://www.hema.org.uk/">HEMA</a>, <a href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://ontheair.nz">ZLOTA</a>, <a href="https://www.wota.org.uk/">WOTA</a>, <a href="https://llota.app">LLOTA</a>, <a href="https://wwtota.com">WWTOTA</a>, the <a href="https://ukpacketradio.network/">UK Packet Repeater Network</a>, and any site based on the <a href="https://github.com/nischu/xOTA">xOTA software by nischu</a>.</p>
<p>Spothole can retrieve alerts from: <a href="https://www.ng3k.com/">NG3K</a>, <a href="https://pota.app">POTA</a>, <a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://www.wota.org.uk/">WOTA</a> and <a href="https://www.beachesontheair.com/">BOTA</a>.</p> <p>Spothole can retrieve alerts from: <a href="https://www.ng3k.com/">NG3K</a>, <a href="https://pota.app">POTA</a>, <a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://www.wota.org.uk/">WOTA</a> and <a href="https://www.beachesontheair.com/">BOTA</a>.</p>
<p>Spothole can retrieve solar and propagation condition data from <a href="https://www.hamqsl.com">HamQSL</a>.</p>
<p>Note that the server owner has not necessarily enabled all these data sources. In particular it is common to disable RBN, to avoid the server being swamped with FT8 traffic, and to disable APRS-IS and UK Packet Net so that the server only displays stations where there is likely to be an operator physically present for a QSO.</p> <p>Note that the server owner has not necessarily enabled all these data sources. In particular it is common to disable RBN, to avoid the server being swamped with FT8 traffic, and to disable APRS-IS and UK Packet Net so that the server only displays stations where there is likely to be an operator physically present for a QSO.</p>
<p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: Parks on the Air (POTA), Summits on the Air (SOTA), Worldwide Flora & Fauna (WWFF), Global Mountain Activity (GMA), Worldwide Bunkers on the Air (WWBOTA), HuMPs Excluding Marilyns Award (HEMA), Islands on the Air (IOTA), Mills on the Air (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), Worked All Britain (WAB), Worked All Ireland (WAI), and Toilets on the Air (TOTA).</p> <p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: Parks on the Air (POTA), Summits on the Air (SOTA), Worldwide Flora & Fauna (WWFF), Global Mountain Activity (GMA), Worldwide Bunkers on the Air (WWBOTA), HuMPs Excluding Marilyns Award (HEMA), Islands on the Air (IOTA), Mills on the Air (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), 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> <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>
@@ -61,12 +62,12 @@
{% end %} {% end %}
<p>Spothole is open source, so you can audit <a href="https://git.ianrenton.com/ian/spothole">the code</a> if you like.</p> <p>Spothole is open source, so you can audit <a href="https://git.ianrenton.com/ian/spothole">the code</a> if you like.</p>
<h2 class="mt-4">Thanks</h2> <h2 class="mt-4">Thanks</h2>
<p>This project would not have been possible without those volunteers who have taken it upon themselves to run DX clusters, xOTA programmes, DXpedition lists, callsign lookup databases, and other online tools on which Spothole's data is based.</p> <p>This project would not have been possible without those volunteers who have taken it upon themselves to run DX clusters, xOTA programmes, DXpedition lists, callsign lookup databases, solar conditions and propagation modelling software, and other online tools on which Spothole's data is based. The vast majority of these are not profit-seeking and are made purely for the love of the hobby and to help others in the community. Spothole is standing on the shoulders of giants, who deserve a huge amount of thanks for all the work they put in.</p>
<p>Spothole is also dependent on a number of Python libraries, in particular pyhamtools, and many JavaScript libraries, as well as the Font Awesome icon set and flag icons from the Noto Color Emoji set, and MIT-licenced GeoJSON files for CQ and ITU zones from HA8TKS.</p> <p>Spothole is also dependent on a number of Python libraries, in particular pyhamtools, and many JavaScript libraries, as well as the Font Awesome icon set and flag icons from the Noto Color Emoji set, and MIT-licenced GeoJSON files for CQ and ITU zones from HA8TKS.</p>
<p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p> <p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p>
</div> </div>
<script src="/js/common.js?v=1774598773"></script> <script src="/js/common.js?v=1774692270"></script>
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

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

View File

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

View File

@@ -62,9 +62,9 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/js/common.js?v=1774598773"></script> <script src="/js/common.js?v=1774692270"></script>
<script src="/js/spotsbandsandmap.js?v=1774598773"></script> <script src="/js/spotsbandsandmap.js?v=1774692270"></script>
<script src="/js/bands.js?v=1774598773"></script> <script src="/js/bands.js?v=1774692270"></script>
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -46,10 +46,10 @@
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/tinycolor2@1.6.0/cjs/tinycolor.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/tinycolor2@1.6.0/cjs/tinycolor.min.js"></script>
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1774598773"></script> <script src="https://misc.ianrenton.com/jsutils/utils.js?v=1774692269"></script>
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=1774598773"></script> <script src="https://misc.ianrenton.com/jsutils/storage.js?v=1774692269"></script>
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1774598773"></script> <script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1774692269"></script>
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1774598773"></script> <script src="https://misc.ianrenton.com/jsutils/geo.js?v=1774692269"></script>
</head> </head>
<body> <body>

View File

@@ -70,9 +70,9 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/js/common.js?v=1774598773"></script> <script src="/js/common.js?v=1774692270"></script>
<script src="/js/spotsbandsandmap.js?v=1774598773"></script> <script src="/js/spotsbandsandmap.js?v=1774692270"></script>
<script src="/js/map.js?v=1774598773"></script> <script src="/js/map.js?v=1774692270"></script>
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -87,9 +87,9 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/js/common.js?v=1774598773"></script> <script src="/js/common.js?v=1774692269"></script>
<script src="/js/spotsbandsandmap.js?v=1774598773"></script> <script src="/js/spotsbandsandmap.js?v=1774692269"></script>
<script src="/js/spots.js?v=1774598773"></script> <script src="/js/spots.js?v=1774692269"></script>
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -3,8 +3,8 @@
<div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div> <div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div>
<script src="/js/common.js?v=1774598773"></script> <script src="/js/common.js?v=1774692270"></script>
<script src="/js/status.js?v=1774598773"></script> <script src="/js/status.js?v=1774692270"></script>
<script>$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}

View File

@@ -13,6 +13,11 @@ info:
## Changelog ## Changelog
### 1.2
* Added `/solar` endpoint for solar and propagation conditions.
* Added `solar_condition_providers` array to the `/status` response.
### 1.1 ### 1.1
* Added Server-Sent Event API endpoints for spots and alerts. * Added Server-Sent Event API endpoints for spots and alerts.
@@ -23,7 +28,7 @@ info:
license: license:
name: The Unlicense name: The Unlicense
url: https://unlicense.org/#the-unlicense url: https://unlicense.org/#the-unlicense
version: v1.1 version: v1.2
servers: servers:
- url: https://spothole.app/api/v1 - url: https://spothole.app/api/v1
paths: paths:
@@ -476,6 +481,11 @@ paths:
description: An array of all the alert providers. description: An array of all the alert providers.
items: items:
$ref: '#/components/schemas/AlertProviderStatus' $ref: '#/components/schemas/AlertProviderStatus'
solar_condition_providers:
type: array
description: An array of all the solar conditions providers.
items:
$ref: '#/components/schemas/SolarConditionsProviderStatus'
/options: /options:
@@ -772,6 +782,22 @@ paths:
example: "Failed" example: "Failed"
/solar:
get:
tags:
- General
summary: Get solar and propagation conditions
description: Returns the current solar conditions and HF/VHF propagation condition summaries. This data is sourced from external providers (e.g. HamQSL) and updated periodically. All fields may be null if no provider has successfully fetched data yet.
operationId: solar
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/SolarConditions'
components: components:
schemas: schemas:
Source: Source:
@@ -1329,3 +1355,135 @@ components:
type: string type: string
description: Regex that matches this SIG's reference IDs. Generally for Spothole's own internal use, clients probably won't need this. description: Regex that matches this SIG's reference IDs. Generally for Spothole's own internal use, clients probably won't need this.
example: "[A-Z]{2}\\-\\d+" example: "[A-Z]{2}\\-\\d+"
HFBandCondition:
type: object
description: HF propagation conditions for a group of bands at a particular time of day.
properties:
band:
type: string
description: Band group name as used by the data source, e.g. "80m-40m", "30m-20m", "17m-15m", "10m-6m".
example: "80m-40m"
time:
type: string
description: Time of day these conditions apply to.
enum:
- day
- night
example: day
condition:
type: string
description: Propagation condition assessment.
enum:
- Good
- Fair
- Poor
example: Good
VHFCondition:
type: object
description: A VHF propagation phenomenon and its current condition.
properties:
phenomenon:
type: string
description: The name of the propagation phenomenon, e.g. "E-Skip", "Sporadic E".
example: "E-Skip"
location:
type: string
description: The geographic region this condition applies to, e.g. "Europe", "N America".
example: "Europe"
condition:
type: string
description: The current condition for this phenomenon and location.
example: "Band Closed"
SolarConditions:
type: object
description: Current solar and propagation conditions. All fields may be null if no provider has successfully fetched data yet.
properties:
updated:
type: number
description: Time that the data was last updated, UTC seconds since UNIX epoch
example: 1759579508
sfi:
type: integer
description: Solar Flux Index (SFI). Higher values generally indicate better HF propagation.
example: 170
a_index:
type: integer
description: A-index — daily geomagnetic activity index. Higher values indicate more disturbed conditions.
example: 7
k_index:
type: integer
description: K-index — 3-hour geomagnetic activity index, 09. Values of 5 or above indicate a geomagnetic storm.
example: 2
x_ray:
type: string
description: Current X-ray flux class, e.g. "B2.3", "C1.0", "M5.0".
example: "B2.3"
proton_flux:
type: integer
description: Proton flux level.
example: 1
electron_flux:
type: integer
description: Electron flux level.
example: 631
aurora:
type: integer
description: Aurora activity level.
example: 5
aurora_latitude:
type: number
description: Latitude in degrees of the equatorward boundary of the aurora.
example: 66.3
sunspots:
type: integer
description: Current sunspot count.
example: 87
solar_wind:
type: number
description: Solar wind speed in km/s.
example: 356.6
magnetic_field:
type: number
description: Interplanetary magnetic field (IMF) strength in nT.
example: 2.5
geomag_field:
type: string
description: Geomagnetic field condition summary.
example: "Active"
geomag_noise:
type: string
description: Geomagnetic background noise level on HF, using S-units.
example: "S0"
hf_conditions:
type: array
description: HF propagation condition assessments by band group and time of day.
items:
$ref: '#/components/schemas/HFBandCondition'
vhf_conditions:
type: array
description: VHF propagation condition assessments by phenomenon and location.
items:
$ref: '#/components/schemas/VHFCondition'
SolarConditionsProviderStatus:
type: object
properties:
name:
type: string
description: The name of the provider.
example: HamQSL
enabled:
type: boolean
description: Whether the provider is enabled or not.
example: true
status:
type: string
description: The status of the provider.
example: OK
last_updated:
type: number
description: The last time at which this provider received data, UTC seconds since UNIX epoch. If this is zero, the provider has never updated.
example: 1759579508