mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2025-10-27 08:49:27 +00:00
Instantiate but disable providers. Closes #16
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
30
main.py
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()">« Back to the spots table</a></p>
|
||||
|
||||
Reference in New Issue
Block a user