Instantiate but disable providers. Closes #16

This commit is contained in:
Ian Renton
2025-10-02 09:38:05 +01:00
parent 9e495a3fae
commit 9640c0e0c1
18 changed files with 134 additions and 132 deletions

View File

@@ -7,31 +7,60 @@
server-owner-callsign: "N0CALL"
# Data providers to use. This is an example set, tailor it to your liking by commenting and uncommenting.
# RBN is supported but has such a high data rate, you probably don't want it enabled.
# APRS-IS support is not yet implemented.
# RBN and APRS-IS are supported but have such a high data rate, you probably don't want them enabled.
# Each provider needs a type, 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!
providers:
# Some providers don't require any config:
- type: "POTA"
- type: "SOTA"
- type: "WWFF"
- type: "WWBOTA"
- type: "GMA"
- type: "HEMA"
- type: "ParksNPeaks"
# - type: "APRS-IS"
# Some, like DX Clusters, require extra config. You can add multiple DX clusters if you want!
-
type: "POTA"
name: "POTA"
enabled: true
-
type: "SOTA"
name: "SOTA"
enabled: true
-
type: "WWFF"
name: "WWFF"
enabled: true
-
type: "WWBOTA"
name: "WWBOTA"
enabled: true
-
type: "GMA"
name: "GMA"
enabled: true
-
type: "HEMA"
name: "HEMA"
enabled: true
-
type: "ParksNPeaks"
name: "ParksNPeaks"
enabled: true
-
type: "APRS-IS"
name: "APRS-IS"
enabled: false
-
type: "DXCluster"
name: "HRD Dx Cluster"
enabled: true
host: "hrd.wa9pie.net"
port: 8000
# RBN uses two ports, 7000 for CW/RTTY and 7001 for FT8, so if you want both, you need to add two entries:
# -
# type: "RBN"
# port: 7000
# -
# type: "RBN"
# port: 7001
-
type: "RBN"
name: "RBN CW/RTTY"
enabled: false
port: 7000
-
type: "RBN"
name: "RBN FT8"
enabled: false
port: 7001
# Port to open the local web server on
web-server-port: 8080

View File

@@ -1,7 +1,7 @@
from data.band import Band
# General software
SOFTWARE_NAME = "Spothole by M0TRT"
SOFTWARE_NAME = "(S)pothole by M0TRT"
SOFTWARE_VERSION = "0.1"
# Sources

30
main.py
View File

@@ -35,32 +35,34 @@ def shutdown(sig, frame):
logging.info("Stopping program, this may take a few seconds...")
global run
run = False
for p in providers: p.stop()
for p in providers:
if p.enabled:
p.stop()
cleanup_timer.stop()
# Utility method to get a data provider based on its config entry.
def get_provider_from_config(config_providers_entry):
match config_providers_entry["type"]:
case "POTA":
return POTA()
return POTA(config_providers_entry)
case "SOTA":
return SOTA()
return SOTA(config_providers_entry)
case "WWFF":
return WWFF()
return WWFF(config_providers_entry)
case "GMA":
return GMA()
return GMA(config_providers_entry)
case "WWBOTA":
return WWBOTA()
return WWBOTA(config_providers_entry)
case "HEMA":
return HEMA()
return HEMA(config_providers_entry)
case "ParksNPeaks":
return ParksNPeaks()
return ParksNPeaks(config_providers_entry)
case "DXCluster":
return DXCluster(config_providers_entry["host"], config_providers_entry["port"])
return DXCluster(config_providers_entry)
case "RBN":
return RBN(config_providers_entry["port"])
return RBN(config_providers_entry)
case "APRS-IS":
return APRSIS()
return APRSIS(config_providers_entry)
return None
@@ -85,7 +87,9 @@ if __name__ == '__main__':
# Set up data providers
for p in providers: p.setup(spot_list=spot_list)
# Start data providers
for p in providers: p.start()
for p in providers:
if p.enabled:
p.start()
# Set up timer to clear spot list of old data
cleanup_timer = CleanupTimer(spot_list=spot_list, cleanup_interval=60, max_spot_age=config["max-spot-age-sec"])
@@ -103,6 +107,6 @@ if __name__ == '__main__':
status_data["uptime"] = str(datetime.now(pytz.UTC) - startup_time).split(".")[0]
status_data["mem_use_mb"] = round(psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024), 3)
status_data["num_spots"] = len(spot_list)
status_data["providers"] = list(map(lambda p: {"name": p.name(), "status": p.status, "last_updated": p.last_update_time, "last_spot": p.last_spot_time}, providers))
status_data["providers"] = list(map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status, "last_updated": p.last_update_time, "last_spot": p.last_spot_time}, providers))
status_data["cleanup"] = {"status": cleanup_timer.status, "last_ran": cleanup_timer.last_cleanup_time}
status_data["webserver"] = {"status": web_server.status, "last_api_access": web_server.last_api_access_time, "last_page_access": web_server.last_page_access_time}

View File

@@ -13,15 +13,12 @@ from providers.provider import Provider
# Provider for the APRS-IS.
class APRSIS(Provider):
def __init__(self):
super().__init__()
def __init__(self, provider_config):
super().__init__(provider_config)
self.thread = Thread(target=self.connect)
self.thread.daemon = True
self.aprsis = None
def name(self):
return "APRS-IS"
def start(self):
self.thread.start()

View File

@@ -21,18 +21,15 @@ class DXCluster(Provider):
re.IGNORECASE)
# Constructor requires hostname and port
def __init__(self, hostname, port):
super().__init__()
self.hostname = hostname
self.port = port
def __init__(self, provider_config):
super().__init__(provider_config)
self.hostname = provider_config["host"]
self.port = provider_config["port"]
self.telnet = None
self.thread = Thread(target=self.handle)
self.thread.daemon = True
self.run = True
def name(self):
return "DX Cluster " + self.hostname
def start(self):
self.thread.start()
@@ -67,7 +64,7 @@ class DXCluster(Provider):
if match:
spot_time = datetime.strptime(match.group(5), "%H%MZ")
spot_datetime = datetime.combine(datetime.today(), spot_time.time()).replace(tzinfo=pytz.UTC)
spot = Spot(source="Cluster",
spot = Spot(source=self.name,
dx_call=match.group(3),
de_call=match.group(1),
freq=float(match.group(2)),

View File

@@ -16,18 +16,15 @@ class GMA(HTTPProvider):
REF_INFO_CACHE_TIME_DAYS = 30
REF_INFO_CACHE = CachedSession("gma_ref_info_cache", expire_after=timedelta(days=REF_INFO_CACHE_TIME_DAYS))
def __init__(self):
super().__init__(self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def name(self):
return "GMA"
def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def http_response_to_spots(self, http_response):
new_spots = []
# Iterate through source data
for source_spot in http_response.json()["RCD"]:
# Convert to our spot format
spot = Spot(source=self.name(),
spot = Spot(source=self.name,
dx_call=source_spot["ACTIVATOR"].upper(),
de_call=source_spot["SPOTTER"].upper(),
freq=float(source_spot["QRG"]) if (source_spot["QRG"] != "") else None, # Seen GMA spots with no frequency

View File

@@ -20,13 +20,10 @@ class HEMA(HTTPProvider):
FREQ_MODE_PATTERN = re.compile("^([\\d.]*) \\((.*)\\)$")
SPOTTER_COMMENT_PATTERN = re.compile("^\\((.*)\\) (.*)$")
def __init__(self):
super().__init__(self.SPOT_SEED_URL, self.POLL_INTERVAL_SEC)
def __init__(self, provider_config):
super().__init__(provider_config, self.SPOT_SEED_URL, self.POLL_INTERVAL_SEC)
self.spot_seed = ""
def name(self):
return "HEMA"
def http_response_to_spots(self, http_response):
# OK, source data is actually just the spot seed at this point. We'll then go on to fetch real data if we know
# this has changed.
@@ -48,7 +45,7 @@ class HEMA(HTTPProvider):
spotter_comment_match = re.search(self.SPOTTER_COMMENT_PATTERN, spot_items[6])
# Convert to our spot format
spot = Spot(source=self.name(),
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)) * 1000,

View File

@@ -13,20 +13,17 @@ from providers.provider import Provider
# duplication. Subclasses of this query the individual APIs for data.
class HTTPProvider(Provider):
def __init__(self, url, poll_interval):
super().__init__()
def __init__(self, provider_config, url, poll_interval):
super().__init__(provider_config)
self.url = url
self.poll_interval = poll_interval
self.poll_timer = None
def name(self):
raise NotImplementedError("Subclasses must implement this method")
def start(self):
# Fire off a one-shot thread to run poll() for the first time, just to ensure start() returns immediately and
# the application can continue starting. The thread itself will then die, and the timer will kick in on its own
# thread.
logging.info("Set up query of " + self.name() + " API every " + str(self.poll_interval) + " seconds.")
logging.info("Set up query of " + self.name + " API every " + str(self.poll_interval) + " seconds.")
thread = Thread(target=self.poll)
thread.daemon = True
thread.start()
@@ -37,7 +34,7 @@ class HTTPProvider(Provider):
def poll(self):
try:
# Request data from API
logging.debug("Polling " + self.name() + " API...")
logging.debug("Polling " + self.name + " API...")
http_response = requests.get(self.url, headers=self.HTTP_HEADERS)
# Pass off to the subclass for processing
new_spots = self.http_response_to_spots(http_response)
@@ -47,11 +44,11 @@ class HTTPProvider(Provider):
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Received data from " + self.name() + " API.")
logging.debug("Received data from " + self.name + " API.")
except Exception as e:
self.status = "Error"
logging.exception("Exception in HTTP JSON Provider (" + self.name() + ")")
logging.exception("Exception in HTTP JSON Provider (" + self.name + ")")
sleep(1)
self.poll_timer = Timer(self.poll_interval, self.poll)

View File

@@ -12,18 +12,15 @@ class ParksNPeaks(HTTPProvider):
POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://www.parksnpeaks.org/api/ALL"
def __init__(self):
super().__init__(self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def name(self):
return "ParksNPeaks"
def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def http_response_to_spots(self, http_response):
new_spots = []
# Iterate through source data
for source_spot in http_response.json():
# Convert to our spot format
spot = Spot(source=self.name(),
spot = Spot(source=self.name,
source_id=source_spot["actID"],
dx_call=source_spot["actCallsign"].upper(),
de_call=source_spot["actSpoter"].upper(), # typo exists in API

View File

@@ -11,18 +11,15 @@ class POTA(HTTPProvider):
POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://api.pota.app/spot/activator"
def __init__(self):
super().__init__(self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def name(self):
return "POTA"
def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def http_response_to_spots(self, http_response):
new_spots = []
# Iterate through source data
for source_spot in http_response.json():
# Convert to our spot format
spot = Spot(source=self.name(),
spot = Spot(source=self.name,
source_id=source_spot["spotId"],
dx_call=source_spot["activator"].upper(),
de_call=source_spot["spotter"].upper(),

View File

@@ -13,16 +13,14 @@ class Provider:
HTTP_HEADERS = { "User-Agent": SOFTWARE_NAME + " " + SOFTWARE_VERSION + " (operated by " + config["server-owner-callsign"] + ")" }
# Constructor
def __init__(self):
def __init__(self, provider_config):
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)
self.status = "Not Started"
self.status = "Not Started" if self.enabled else "Disabled"
self.spot_list = None
# Return the name of the provider
def name(self):
raise NotImplementedError("Subclasses must implement this method")
# Set up the provider, e.g. giving it the spot list to work from
def setup(self, spot_list):
self.spot_list = spot_list

View File

@@ -22,17 +22,14 @@ class RBN(Provider):
re.IGNORECASE)
# Constructor requires port number.
def __init__(self, port):
super().__init__()
self.port = port
def __init__(self, provider_config):
super().__init__(provider_config)
self.port = provider_config["port"]
self.telnet = None
self.thread = Thread(target=self.handle)
self.thread.daemon = True
self.run = True
def name(self):
return "RBN port " + str(self.port)
def start(self):
self.thread.start()
@@ -68,7 +65,7 @@ class RBN(Provider):
if match:
spot_time = datetime.strptime(match.group(5), "%H%MZ")
spot_datetime = datetime.combine(datetime.today(), spot_time.time()).replace(tzinfo=pytz.UTC)
spot = Spot(source="RBN",
spot = Spot(source=self.name,
dx_call=match.group(3),
de_call=match.group(1),
freq=float(match.group(2)),

View File

@@ -20,13 +20,10 @@ class SOTA(HTTPProvider):
SUMMIT_DATA_CACHE_TIME_DAYS = 30
SUMMIT_DATA_CACHE = CachedSession("sota_summit_data_cache", expire_after=timedelta(days=SUMMIT_DATA_CACHE_TIME_DAYS))
def __init__(self):
super().__init__(self.EPOCH_URL, self.POLL_INTERVAL_SEC)
def __init__(self, provider_config):
super().__init__(provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
self.api_epoch = ""
def name(self):
return "SOTA"
def http_response_to_spots(self, http_response):
# OK, source data is actually just the epoch at this point. We'll then go on to fetch real data if we know this
# has changed.
@@ -40,7 +37,7 @@ class SOTA(HTTPProvider):
# Iterate through source data
for source_spot in source_data:
# Convert to our spot format
spot = Spot(source=self.name(),
spot = Spot(source=self.name,
source_id=source_spot["id"],
dx_call=source_spot["activatorCallsign"].upper(),
dx_name=source_spot["activatorName"],

View File

@@ -9,11 +9,8 @@ class WWBOTA(HTTPProvider):
POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://api.wwbota.org/spots/"
def __init__(self):
super().__init__(self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def name(self):
return "WWBOTA"
def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def http_response_to_spots(self, http_response):
new_spots = []
@@ -26,7 +23,7 @@ class WWBOTA(HTTPProvider):
for ref in source_spot["references"]:
refs.append(ref["reference"])
ref_names.append(ref["name"])
spot = Spot(source=self.name(),
spot = Spot(source=self.name,
dx_call=source_spot["call"].upper(),
de_call=source_spot["spotter"].upper(),
freq=float(source_spot["freq"]) * 1000, # MHz to kHz

View File

@@ -11,18 +11,15 @@ class WWFF(HTTPProvider):
POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://spots.wwff.co/static/spots.json"
def __init__(self):
super().__init__(self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def name(self):
return "WWFF"
def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def http_response_to_spots(self, http_response):
new_spots = []
# Iterate through source data
for source_spot in http_response.json():
# Convert to our spot format
spot = Spot(source=self.name(),
spot = Spot(source=self.name,
source_id=source_spot["id"],
dx_call=source_spot["activator"].upper(),
de_call=source_spot["spotter"].upper(),

View File

@@ -30,7 +30,6 @@ class WebServer:
bottle.get("/api/options")(self.serve_api_options)
bottle.get("/api/status")(self.serve_api_status)
bottle.get("/")(self.serve_index)
bottle.get("/apidocs")(self.serve_apidocs)
bottle.get("/<filepath:path>")(self.serve_static_file)
# Start the web server
@@ -69,19 +68,20 @@ class WebServer:
# Serve the home page. This would be accessible as /index.html but we need this workaround to make it available as /
def serve_index(self):
self.last_page_access_time = datetime.now(pytz.UTC)
self.status = "OK"
return bottle.static_file("index.html", root="webassets")
return self.serve_static_file("")
# Serve the API docs page. This would be accessible as /apidocs/index.html but we need this workaround to make it
# available as /apidocs
def serve_apidocs(self):
self.last_page_access_time = datetime.now(pytz.UTC)
self.status = "OK"
return bottle.static_file("index.html", root="webassets/apidocs")
# Serve general static files from "webassets" directory
# Serve general static files from "webassets" directory, along with some extra workarounds to make URLs such as
# "/", "/about" and "/apidocs" work.
def serve_static_file(self, filepath):
self.last_page_access_time = datetime.now(pytz.UTC)
self.status = "OK"
if filepath == "":
return bottle.static_file("index.html", root="webassets")
elif filepath == "about":
return bottle.static_file("about.html", root="webassets")
elif filepath == "apidocs":
return bottle.static_file("index.html", root="webassets/apidocs")
else:
return bottle.static_file(filepath, root="webassets")
# Utility method to apply filters to the overall spot list and return only a subset. Enables query parameters in

View File

@@ -1,10 +1,10 @@
openapi: 3.0.4
info:
title: Spothole API
title: (S)pothole API
description: |-
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.
(S)pothole 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.
While there are other web-based interfaces to DX clusters, and sites that aggregate spots from various outfoor activity programmes for amateur radio, Spothole differentiates itself by supporting a large number of data sources, and by being "API first" rather than just providing a web front-end. This allows other software to be built on top of it. Spothole itself is also open source, Public Domain licenced code that anyone can take and modify.
While there are other web-based interfaces to DX clusters, and sites that aggregate spots from various outfoor activity programmes for amateur radio, (S)pothole differentiates itself by supporting a large number of data sources, and by being "API first" rather than just providing a web front-end. This allows other software to be built on top of it. Spothole itself is also open source, Public Domain licenced code that anyone can take and modify.
contact:
email: ian@ianrenton.com
license:
@@ -531,6 +531,10 @@ components:
type: string
description: The name of the provider.
example: POTA
enabled:
type: boolean
description: Whether the provider is enabled or not.
example: true
status:
type: string
description: The status of the provider.

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Spothole</title>
<title>(S)pothole</title>
<link rel="stylesheet" href="css/style.css" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
@@ -22,7 +22,7 @@
<div class="container">
<header class="d-flex flex-wrap justify-content-center py-3 mb-4 border-bottom">
<a href="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-dark text-decoration-none">
<span class="fs-4">Spothole</span>
<span class="fs-4">(S)pothole</span>
</a>
<ul class="nav nav-pills">
@@ -36,11 +36,11 @@
<main>
<div id="info-container">
<h3>About Spothole</h3>
<p>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.</p>
<p>While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outfoor activity programmes for amateur radio, Spothole differentiates itself by supporting a large number of data sources, and by being "API first" rather than just providing a web front-end. This allows other software to be built on top of it.</p>
<h3>About (S)pothole</h3>
<p>(S)pothole 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.</p>
<p>While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outfoor activity programmes for amateur radio, (S)pothole differentiates itself by supporting a large number of data sources, and by being "API first" rather than just providing a web front-end. This allows other software to be built on top of it.</p>
<p>The API is deliberately well-defined with an <a href="/apidocs/openapi.yml">OpenAPI specification</a> and auto-generated <a href="/apidocs">API documentation</a>. The API delivers spots in a consistent format regardless of the data source, freeing developers from needing to know how each individual data source presents its data.</p>
<p>Spothole itself is also open source, Public Domain licenced code that anyone can take and modify. <a href="https://git.ianrenton.com/ian/metaspot/">The source code is here</a>.</p>
<p>(S)pothole itself is also open source, Public Domain licenced code that anyone can take and modify. <a href="https://git.ianrenton.com/ian/metaspot/">The source code is here</a>.</p>
<p>Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, and Parks 'n' Peaks.</p>
<p>The software was written by <a href="https://ianrenton.com">Ian Renton, MØTRT</a>.</p>
<p><a href="#" onclick="hideInfo()">&laquo; Back to the spots table</a></p>