Compare commits

..

6 Commits

15 changed files with 277 additions and 56 deletions

View File

@@ -10,7 +10,7 @@ The API is deliberately well-defined with an OpenAPI specification and auto-gene
Spothole itself is also open source, Public Domain licenced code that anyone can take and modify. Spothole itself is also open source, Public Domain licenced code that anyone can take and modify.
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. Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, the UK Packet Repeater Network, and NG3K.
![Screenshot](/images/screenshot2.png) ![Screenshot](/images/screenshot2.png)

View File

@@ -77,6 +77,10 @@ spot-providers:
name: "RBN FT8" name: "RBN FT8"
enabled: false enabled: false
port: 7001 port: 7001
-
class: "UKPacketNet"
name: "UK Packet Radio Net"
enabled: false
# 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:

View File

@@ -141,7 +141,7 @@ class LookupHelper:
try: try:
# Start with the basic country-files.com-based decoder. # Start with the basic country-files.com-based decoder.
country = self.CALL_INFO_BASIC.get_country_name(call) country = self.CALL_INFO_BASIC.get_country_name(call)
except KeyError as e: except (KeyError, ValueError) as e:
country = None country = None
# Couldn't get anything from basic call info database, try QRZ.com # Couldn't get anything from basic call info database, try QRZ.com
if not country: if not country:
@@ -170,7 +170,7 @@ class LookupHelper:
try: try:
# Start with the basic country-files.com-based decoder. # Start with the basic country-files.com-based decoder.
dxcc = self.CALL_INFO_BASIC.get_adif_id(call) dxcc = self.CALL_INFO_BASIC.get_adif_id(call)
except KeyError as e: except (KeyError, ValueError) as e:
dxcc = None dxcc = None
# Couldn't get anything from basic call info database, try QRZ.com # Couldn't get anything from basic call info database, try QRZ.com
if not dxcc: if not dxcc:
@@ -198,7 +198,7 @@ class LookupHelper:
try: try:
# Start with the basic country-files.com-based decoder. # Start with the basic country-files.com-based decoder.
continent = self.CALL_INFO_BASIC.get_continent(call) continent = self.CALL_INFO_BASIC.get_continent(call)
except KeyError as e: except (KeyError, ValueError) as e:
continent = None continent = None
# Couldn't get anything from basic call info database, try Clublog data # Couldn't get anything from basic call info database, try Clublog data
if not continent: if not continent:
@@ -221,7 +221,7 @@ class LookupHelper:
try: try:
# Start with the basic country-files.com-based decoder. # Start with the basic country-files.com-based decoder.
cqz = self.CALL_INFO_BASIC.get_cqz(call) cqz = self.CALL_INFO_BASIC.get_cqz(call)
except KeyError as e: except (KeyError, ValueError) as e:
cqz = None cqz = None
# Couldn't get anything from basic call info database, try QRZ.com # Couldn't get anything from basic call info database, try QRZ.com
if not cqz: if not cqz:
@@ -249,7 +249,7 @@ class LookupHelper:
try: try:
# Start with the basic country-files.com-based decoder. # Start with the basic country-files.com-based decoder.
ituz = self.CALL_INFO_BASIC.get_ituz(call) ituz = self.CALL_INFO_BASIC.get_ituz(call)
except KeyError as e: except (KeyError, ValueError) as e:
ituz = None ituz = None
# Couldn't get anything from basic call info database, try QRZ.com # Couldn't get anything from basic call info database, try QRZ.com
if not ituz: if not ituz:
@@ -273,13 +273,13 @@ class LookupHelper:
data = self.LOOKUP_LIB_QRZ.lookup_callsign(callsign=call) data = self.LOOKUP_LIB_QRZ.lookup_callsign(callsign=call)
self.QRZ_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds self.QRZ_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
return data return data
except KeyError: except (KeyError, ValueError):
# QRZ had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call. # QRZ had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call.
try: try:
data = self.LOOKUP_LIB_QRZ.lookup_callsign(callsign=callinfo.Callinfo.get_homecall(call)) data = self.LOOKUP_LIB_QRZ.lookup_callsign(callsign=callinfo.Callinfo.get_homecall(call))
self.QRZ_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds self.QRZ_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
return data return data
except KeyError: except (KeyError, ValueError):
# QRZ had no info for the call, that's OK. Cache a None so we don't try to look this up again # QRZ had no info for the call, that's OK. Cache a None so we don't try to look this up again
self.QRZ_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds self.QRZ_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
return None return None
@@ -296,13 +296,13 @@ class LookupHelper:
data = self.LOOKUP_LIB_CLUBLOG_API.lookup_callsign(callsign=call) data = self.LOOKUP_LIB_CLUBLOG_API.lookup_callsign(callsign=call)
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
return data return data
except KeyError: except (KeyError, ValueError):
# Clublog had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call. # Clublog had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call.
try: try:
data = self.LOOKUP_LIB_CLUBLOG_API.lookup_callsign(callsign=callinfo.Callinfo.get_homecall(call)) data = self.LOOKUP_LIB_CLUBLOG_API.lookup_callsign(callsign=callinfo.Callinfo.get_homecall(call))
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
return data return data
except KeyError: except (KeyError, ValueError):
# Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again # Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
return None return None
@@ -319,7 +319,7 @@ class LookupHelper:
try: try:
data = self.LOOKUP_LIB_CLUBLOG_XML.lookup_callsign(callsign=call) data = self.LOOKUP_LIB_CLUBLOG_XML.lookup_callsign(callsign=call)
return data return data
except KeyError: except (KeyError, ValueError):
# Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again # Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
return None return None

View File

@@ -0,0 +1,40 @@
from bottle import response
from prometheus_client import CollectorRegistry, generate_latest, CONTENT_TYPE_LATEST, Counter, disable_created_metrics, \
Gauge
disable_created_metrics()
# Prometheus metrics registry
registry = CollectorRegistry()
page_requests_counter = Counter(
"spothole_page_requests",
"Total number of page requests received",
registry=registry,
)
api_requests_counter = Counter(
"spothole_api_requests",
"Total number of API requests received",
registry=registry
)
spots_gauge = Gauge(
"spothole_spots",
"Number of spots currently in the software",
registry=registry
)
alerts_gauge = Gauge(
"spothole_alerts",
"Number of alerts currently in the software",
registry=registry
)
memory_use_gauge = Gauge(
"spothole_memory_usage_bytes",
"Current memory usage of the software in bytes",
registry=registry
)
# Get a Prometheus metrics response for Bottle
def get_metrics():
response.content_type = CONTENT_TYPE_LATEST
response.status = 200
return generate_latest(registry)

View File

@@ -7,6 +7,7 @@ import pytz
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from core.constants import SOFTWARE_VERSION from core.constants import SOFTWARE_VERSION
from core.prometheus_metrics_handler import memory_use_gauge, spots_gauge, alerts_gauge
# Provides a timed update of the application's status data. # Provides a timed update of the application's status data.
@@ -60,8 +61,15 @@ class StatusReporter:
self.status_data["webserver"] = {"status": self.web_server.status, self.status_data["webserver"] = {"status": self.web_server.status,
"last_api_access": self.web_server.last_api_access_time.replace( "last_api_access": self.web_server.last_api_access_time.replace(
tzinfo=pytz.UTC).timestamp() if self.web_server.last_api_access_time else 0, tzinfo=pytz.UTC).timestamp() if self.web_server.last_api_access_time else 0,
"api_access_count": self.web_server.api_access_counter,
"last_page_access": self.web_server.last_page_access_time.replace( "last_page_access": self.web_server.last_page_access_time.replace(
tzinfo=pytz.UTC).timestamp() if self.web_server.last_page_access_time else 0} tzinfo=pytz.UTC).timestamp() if self.web_server.last_page_access_time else 0,
"page_access_count": self.web_server.page_access_counter}
# Update Prometheus metrics
memory_use_gauge.set(psutil.Process(os.getpid()).memory_info().rss * 1024)
spots_gauge.set(len(self.spots))
alerts_gauge.set(len(self.alerts))
self.run_timer = Timer(self.run_interval, self.run) self.run_timer = Timer(self.run_interval, self.run)
self.run_timer.start() self.run_timer.start()

View File

@@ -41,9 +41,8 @@ class Spot:
dx_cq_zone: int = None dx_cq_zone: int = None
# ITU zone of the DX operator # ITU zone of the DX operator
dx_itu_zone: int = None dx_itu_zone: int = None
# If this is an APRS spot, what SSID was the DX operator using? # If this is an APRS/Packet/etc spot, what SSID was the DX operator using?
# This is a string not an int for now, as I often see non-numeric ones somehow dx_ssid: str = None
dx_aprs_ssid: str = None
# Maidenhead grid locator for the DX. This could be from a geographical reference e.g. POTA, or just from the # Maidenhead grid locator for the DX. This could be from a geographical reference e.g. POTA, or just from the
# country # country
dx_grid: str = None dx_grid: str = None
@@ -68,6 +67,8 @@ class Spot:
de_flag: str = None de_flag: str = None
# Continent of the spotter # Continent of the spotter
de_continent: str = None de_continent: str = None
# If this is an APRS/Packet/etc spot, what SSID was the spotter/receiver using?
de_ssid: str = None
# Maidenhead grid locator for the spotter. This is not going to be from a xOTA reference so it will likely just be # Maidenhead grid locator for the spotter. This is not going to be from a xOTA reference so it will likely just be
# a QRZ or DXCC lookup. If the spotter is also portable, this is probably wrong, but it's good enough for some # a QRZ or DXCC lookup. If the spotter is also portable, this is probably wrong, but it's good enough for some
# simple mapping. # simple mapping.
@@ -157,7 +158,10 @@ class Spot:
# Clean up DX call if it has an SSID or -# from RBN # Clean up DX call if it has an SSID or -# from RBN
if self.dx_call and "-" in self.dx_call: if self.dx_call and "-" in self.dx_call:
self.dx_call = self.dx_call.split("-")[0] split = self.dx_call.split("-")
self.dx_call = split[0]
if len(split) > 1 and split[1] != "#":
self.dx_ssid = split[1]
# DX country, continent, zones etc. from callsign # DX country, continent, zones etc. from callsign
if self.dx_call and not self.dx_country: if self.dx_call and not self.dx_country:
@@ -175,7 +179,10 @@ class Spot:
# Clean up spotter call if it has an SSID or -# from RBN # Clean up spotter call if it has an SSID or -# from RBN
if self.de_call and "-" in self.de_call: if self.de_call and "-" in self.de_call:
self.de_call = self.de_call.split("-")[0] split = self.de_call.split("-")
self.de_call = split[0]
if len(split) > 1 and split[1] != "#":
self.de_ssid = split[1]
# If we have a spotter of "RBNHOLE", we should have the actual spotter callsign in the comment, so extract it. # If we have a spotter of "RBNHOLE", we should have the actual spotter callsign in the comment, so extract it.
# RBNHole posts come from a number of providers, so it's dealt with here in the generic spot handling code. # RBNHole posts come from a number of providers, so it's dealt with here in the generic spot handling code.
@@ -192,8 +199,8 @@ class Spot:
self.de_call = sotamat_call_match.group(1).upper() self.de_call = sotamat_call_match.group(1).upper()
# Spotter country, continent, zones etc. from callsign. # Spotter country, continent, zones etc. from callsign.
# DE of "RBNHOLE" and "SOTAMAT" are not things we can look up location for # DE call with no digits, or APRS servers starting "T2" are not things we can look up location for
if self.de_call != "RBNHOLE" and self.de_call != "SOTAMAT": if any(char.isdigit() for char in self.de_call) and not (self.de_call.startswith("T2") and self.source == "APRS-IS"):
if self.de_call and not self.de_country: if self.de_call and not self.de_country:
self.de_country = lookup_helper.infer_country_from_callsign(self.de_call) self.de_country = lookup_helper.infer_country_from_callsign(self.de_call)
if self.de_call and not self.de_continent: if self.de_call and not self.de_continent:
@@ -290,8 +297,8 @@ class Spot:
self.dx_location_good = self.dx_location_source == "SPOT" or self.dx_location_source == "WAB/WAI GRID" or ( self.dx_location_good = self.dx_location_source == "SPOT" or self.dx_location_source == "WAB/WAI GRID" or (
self.dx_location_source == "QRZ" and not "/" in self.dx_call) self.dx_location_source == "QRZ" and not "/" in self.dx_call)
# DE of "RBNHOLE" and "SOTAMAT" are not things we can look up location for # DE with no digits and APRS servers starting "T2" are not things we can look up location for
if self.de_call != "RBNHOLE" and self.de_call != "SOTAMAT": if any(char.isdigit() for char in self.de_call) and not (self.de_call.startswith("T2") and self.source == "APRS-IS"):
# DE operator position lookup, using QRZ.com. # DE operator position lookup, using QRZ.com.
if self.de_call and not self.de_latitude: if self.de_call and not self.de_latitude:
latlon = lookup_helper.infer_latlon_from_callsign_qrz(self.de_call) latlon = lookup_helper.infer_latlon_from_callsign_qrz(self.de_call)

View File

@@ -11,3 +11,4 @@ psutil~=7.1.0
requests-sse~=0.5.2 requests-sse~=0.5.2
rss-parser~=2.1.1 rss-parser~=2.1.1
pyproj~=3.7.2 pyproj~=3.7.2
prometheus_client~=0.23.1

View File

@@ -6,9 +6,11 @@ from threading import Thread
import bottle import bottle
import pytz import pytz
from bottle import run, request, response, template from bottle import run, request, response, template
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS, SOFTWARE_VERSION from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS, SOFTWARE_VERSION
from core.prometheus_metrics_handler import page_requests_counter, registry, get_metrics, api_requests_counter
from data.spot import Spot from data.spot import Spot
@@ -19,6 +21,8 @@ class WebServer:
def __init__(self, spots, alerts, status_data, port): def __init__(self, spots, alerts, status_data, port):
self.last_page_access_time = None self.last_page_access_time = None
self.last_api_access_time = None self.last_api_access_time = None
self.page_access_counter = 0
self.api_access_counter = 0
self.spots = spots self.spots = spots
self.alerts = alerts self.alerts = alerts
self.status_data = status_data self.status_data = status_data
@@ -44,6 +48,8 @@ class WebServer:
bottle.get("/status")(lambda: self.serve_template('webpage_status')) bottle.get("/status")(lambda: self.serve_template('webpage_status'))
bottle.get("/about")(lambda: self.serve_template('webpage_about')) bottle.get("/about")(lambda: self.serve_template('webpage_about'))
bottle.get("/apidocs")(lambda: self.serve_template('webpage_apidocs')) bottle.get("/apidocs")(lambda: self.serve_template('webpage_apidocs'))
# Route for Prometheus metrics
bottle.get("/metrics")(lambda: self.serve_prometheus_metrics())
# Default route to serve from "webassets" # Default route to serve from "webassets"
bottle.get("/<filepath:path>")(self.serve_static_file) bottle.get("/<filepath:path>")(self.serve_static_file)
@@ -92,6 +98,8 @@ class WebServer:
# Serve a JSON API endpoint # Serve a JSON API endpoint
def serve_api(self, data): def serve_api(self, data):
self.last_api_access_time = datetime.now(pytz.UTC) self.last_api_access_time = datetime.now(pytz.UTC)
self.api_access_counter += 1
api_requests_counter.inc()
self.status = "OK" self.status = "OK"
response.content_type = 'application/json' response.content_type = 'application/json'
response.set_header('Cache-Control', 'no-store') response.set_header('Cache-Control', 'no-store')
@@ -100,6 +108,8 @@ class WebServer:
# Accept a spot # Accept a spot
def accept_spot(self): def accept_spot(self):
self.last_api_access_time = datetime.now(pytz.UTC) self.last_api_access_time = datetime.now(pytz.UTC)
self.api_access_counter += 1
api_requests_counter.inc()
self.status = "OK" self.status = "OK"
try: try:
@@ -153,6 +163,8 @@ class WebServer:
# Serve a templated page # Serve a templated page
def serve_template(self, template_name): def serve_template(self, template_name):
self.last_page_access_time = datetime.now(pytz.UTC) self.last_page_access_time = datetime.now(pytz.UTC)
self.page_access_counter += 1
page_requests_counter.inc()
self.status = "OK" self.status = "OK"
return template(template_name) return template(template_name)
@@ -160,6 +172,10 @@ class WebServer:
def serve_static_file(self, filepath): def serve_static_file(self, filepath):
return bottle.static_file(filepath, root="webassets") return bottle.static_file(filepath, root="webassets")
# Serve Prometheus metrics
def serve_prometheus_metrics(self):
return get_metrics()
# Utility method to apply filters to the overall spot list and return only a subset. Enables query parameters in # Utility method to apply filters to the overall spot list and return only a subset. Enables query parameters in
# the main "spots" GET call. # the main "spots" GET call.
def get_spot_list_with_filters(self): def get_spot_list_with_filters(self):
@@ -241,17 +257,19 @@ class WebServer:
if needs_good_location: if needs_good_location:
spots = [s for s in spots if s.dx_location_good] spots = [s for s in spots if s.dx_location_good]
case "dedupe": case "dedupe":
# Ensure only the latest spot of each callsign is present in the list. This relies on the list being # Ensure only the latest spot of each callsign-SSID combo is present in the list. This relies on the
# in reverse time order, so if any future change allows re-ordering the list, that should be done # list being in reverse time order, so if any future change allows re-ordering the list, that should
# *after* this. # be done *after* this. SSIDs are deliberately included here (see issue #68) because e.g. M0TRT-7
# and M0TRT-9 APRS transponders could well be in different locations, on different frequencies etc.
dedupe = query.get(k).upper() == "TRUE" dedupe = query.get(k).upper() == "TRUE"
if dedupe: if dedupe:
spots_temp = [] spots_temp = []
already_seen = [] already_seen = []
for s in spots: for s in spots:
if s.dx_call not in already_seen: call_plus_ssid = s.dx_call + (s.dx_ssid if s.dx_ssid else "")
if call_plus_ssid not in already_seen:
spots_temp.append(s) spots_temp.append(s)
already_seen.append(s.dx_call) already_seen.append(call_plus_ssid)
spots = spots_temp spots = spots_temp
# If we have a "limit" parameter, we apply that last, regardless of where it appeared in the list of keys. # If we have a "limit" parameter, we apply that last, regardless of where it appeared in the list of keys.
if "limit" in query.keys(): if "limit" in query.keys():

View File

@@ -37,13 +37,17 @@ class APRSIS(SpotProvider):
def handle(self, data): def handle(self, data):
# Split SSID in "from" call and store separately # Split SSID in "from" call and store separately
from_parts = data["from"].split("-") from_parts = data["from"].split("-").upper()
dx_call = from_parts[0] dx_call = from_parts[0]
dx_aprs_ssid = from_parts[1] if len(from_parts) > 1 else None dx_ssid = from_parts[1] if len(from_parts) > 1 else None
via_parts = data["via"].split("-").upper()
de_call = via_parts[0]
de_ssid = via_parts[1] if len(via_parts) > 1 else None
spot = Spot(source="APRS-IS", spot = Spot(source="APRS-IS",
dx_call=dx_call, dx_call=dx_call,
dx_aprs_ssid=dx_aprs_ssid, dx_ssid=dx_ssid,
de_call=data["via"], de_call=de_call,
de_ssid=de_ssid,
comment=data["comment"] if "comment" in data else None, comment=data["comment"] if "comment" in data else None,
dx_latitude=data["latitude"] if "latitude" in data else None, dx_latitude=data["latitude"] if "latitude" in data else None,
dx_longitude=data["longitude"] if "longitude" in data else None, dx_longitude=data["longitude"] if "longitude" in data else None,

View File

@@ -0,0 +1,88 @@
import re
from datetime import datetime, timedelta
import pytz
from requests_cache import CachedSession
from core.constants import HTTP_HEADERS
from core.sig_utils import get_icon_for_sig, get_ref_regex_for_sig
from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider
# Spot provider for UK Packet Radio network API
class UKPacketNet(HTTPSpotProvider):
POLL_INTERVAL_SEC = 600
SPOTS_URL = "https://nodes.ukpacketradio.network/api/nodedata"
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
nodes = http_response.json()["nodes"]
for callsign, node in nodes.items():
# The node corresponse to the spotter here. It has an "mheard" section which indicates which nodes it has
# recently heard, which will be our "DX". But "mheard" stations are not necessarily over RF, they could be
# via the internet, so we also need to look up the "port" on which the station was heard, and check that it
# is RF.
if "mheard" in node:
for heard in node["mheard"]:
heard_port_id = heard["port"]
if "ports" in node:
for listed_port in node["ports"]:
if listed_port["id"] == heard_port_id and listed_port["linkType"] == "RF":
# This is another packet station heard over RF, so we are good to create a Spot object.
# First build a "full" comment combining some of the extra info
comment = listed_port["comment"] if "comment" in listed_port else ""
comment = (comment + " " + listed_port["mode"]) if "mode" in listed_port else comment
comment = (comment + " " + listed_port["modulation"]) if "modulation" in listed_port else comment
comment = (comment + " " + str(listed_port["baud"]) + " baud") if "baud" in listed_port and listed_port["baud"] > 0 else comment
# Get frequency from the comment if it's not set properly in the data structure. This is
# very hacky but a lot of node comments contain their frequency as the first or second
# word of their comment, but not in the proper data structure field.
freq = listed_port["freq"] if "freq" in listed_port and listed_port["freq"] > 0 else None
if not freq and comment:
possible_freq = comment.split(" ")[0].upper().replace("MHZ", "")
if re.match(r"^[0-9.]+$", possible_freq) and possible_freq != "1200" and possible_freq != "9600":
freq = float(possible_freq) * 1000000
if not freq and len(comment.split(" ")) > 1:
possible_freq = comment.split(" ")[1].upper().replace("MHZ", "")
if re.match(r"^[0-9.]+$", possible_freq) and possible_freq != "1200" and possible_freq != "9600":
freq = float(possible_freq) * 1000000
# Check for a found frequency likely having been in kHz, sorry to all GHz packet folks
if freq and freq > 1000000000:
freq = freq / 1000
# Now build the spot object
spot = Spot(source=self.name,
dx_call=heard["callsign"].upper(),
de_call=node["callsign"].upper(),
freq=freq,
mode="PKT",
comment=comment,
icon="tower-cell",
time=datetime.strptime(heard["lastHeard"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp(),
de_grid=node["location"]["locator"] if "locator" in node["location"] else None,
de_latitude=node["location"]["coords"]["lat"],
de_longitude=node["location"]["coords"]["lon"])
# 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)
break
# Now we have a list of every node that heard every other node via RF, as Spothole spots. What each spot doesn't
# yet have is a DX lat/lon/grid, because the data doesn't provide the location of the "mheard" stations within
# the structure. However, each "heard" station should also be represented in the list somewhere with its own
# data, and we can use that to look these up.
for spot in new_spots:
if spot.dx_call in nodes:
spot.dx_grid = nodes[spot.dx_call]["location"]["locator"] if "locator" in nodes[spot.dx_call]["location"] else None
spot.dx_latitude = nodes[spot.dx_call]["location"]["coords"]["lat"]
spot.dx_longitude = nodes[spot.dx_call]["location"]["coords"]["lon"]
return new_spots

View File

@@ -6,7 +6,6 @@
<p>While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outdoor 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> <p>While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outdoor 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>
<p>The API is deliberately well-defined with an <a href="/apidocs/openapi.yml">OpenAPI specification</a> and <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>The API is deliberately well-defined with an <a href="/apidocs/openapi.yml">OpenAPI specification</a> and <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>. If you want to run your own copy of Spothole, or start modifying it for your own purposes, the <a href="https://git.ianrenton.com/ian/spothole/src/branch/main/README.md">README file</a> contains a description of how the software works and how it's laid out, as well as instructions for configuring systemd, nginx and anything else you might need to run your own server.</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>. If you want to run your own copy of Spothole, or start modifying it for your own purposes, the <a href="https://git.ianrenton.com/ian/spothole/src/branch/main/README.md">README file</a> contains a description of how the software works and how it's laid out, as well as instructions for configuring systemd, nginx and anything else you might need to run your own server.</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> and other contributors. Full details are available in the README.</p> <p>The software was written by <a href="https://ianrenton.com">Ian Renton, MØTRT</a> and other contributors. Full details are available in the README.</p>
<p>This server is running Spothole version {{software_version}}.</p> <p>This server is running Spothole version {{software_version}}.</p>
<h2 id="faq" class="mt-4">FAQ</h2> <h2 id="faq" class="mt-4">FAQ</h2>
@@ -16,6 +15,10 @@
<p>As well as spots, it also provides a similar feed of "alerts". This is where amateur radio users who are going to interesting places soon will announce their intentions.</p> <p>As well as spots, it also provides a similar feed of "alerts". This is where amateur radio users who are going to interesting places soon will announce their intentions.</p>
<h4 class="mt-4">What are "DX", "DE" and modes?</h4> <h4 class="mt-4">What are "DX", "DE" and modes?</h4>
<p>In amateur radio terminology, the "DX" contact is the "interesting" one that is using the frequency shown. They might be on a remote island or just in a local park, but either way it's interesting enough that someone has "spotted" them. The callsign listed under "DE" is the person who spotted the "DX" operator. "Modes" are the type of communication they are using. You might see "CW" which is Morse Code, or voice "modes" like SSB or FM, or more exotic "data" modes which are used for computer-to-computer communication.</p> <p>In amateur radio terminology, the "DX" contact is the "interesting" one that is using the frequency shown. They might be on a remote island or just in a local park, but either way it's interesting enough that someone has "spotted" them. The callsign listed under "DE" is the person who spotted the "DX" operator. "Modes" are the type of communication they are using. You might see "CW" which is Morse Code, or voice "modes" like SSB or FM, or more exotic "data" modes which are used for computer-to-computer communication.</p>
<h4 class="mt-4">What data sources are supported?</h4>
<p>Spothole can retrieve spots from: Telnet-based DX clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, and the UK Packet Repeater Network.</p>
<p>Spothole can retrieve alerts from: NG3K, POTA, SOTA, WWFF, Parks 'n' Peaks, and WOTA.</p>
<p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, IOTA, MOTS, ARLHS, ILLW, SIOTA, WCA, ZLOTA, KRMNPA, WOTA, WAB & WAI.</p>
<h4 class="mt-4">How is this better than DXheat, DXsummit, POTA's own website, etc?</h4> <h4 class="mt-4">How is this better than DXheat, DXsummit, POTA's own website, etc?</h4>
<p>It's probably not? But it's nice to have choice.</p> <p>It's probably not? But it's nice to have choice.</p>
<p>I think it's got two key advantages over those sites:</p> <p>I think it's got two key advantages over those sites:</p>

View File

@@ -78,11 +78,14 @@ paths:
- HEMA - HEMA
- WCA - WCA
- MOTA - MOTA
- SiOTA - SIOTA
- ARLHS - ARLHS
- ILLW - ILLW
- ZLOTA - ZLOTA
- IOTA - IOTA
- WOTA
- WAB
- WAI
- name: needs_sig - name: needs_sig
in: query in: query
description: "Limit the spots to only ones with a Special Interest Group such as POTA. Because supplying all known SIGs as a `sigs` parameter is unwieldy, and leaving `sigs` blank will also return spots with *no* SIG, this parameter can be set true to return only spots with a SIG, regardless of what it is, so long as it's not blank. This is what Field Spotter uses to exclude generic cluster spots and only retrieve xOTA things." description: "Limit the spots to only ones with a Special Interest Group such as POTA. Because supplying all known SIGs as a `sigs` parameter is unwieldy, and leaving `sigs` blank will also return spots with *no* SIG, this parameter can be set true to return only spots with a SIG, regardless of what it is, so long as it's not blank. This is what Field Spotter uses to exclude generic cluster spots and only retrieve xOTA things."
@@ -151,6 +154,9 @@ paths:
- PSK - PSK
- BPSK31 - BPSK31
- OLIVIA - OLIVIA
- MFSK
- MFSK32
- PKT
- name: mode_type - name: mode_type
in: query in: query
description: "Limit the spots to only ones from one or more mode families. To select more than one mode family, supply a comma-separated list." description: "Limit the spots to only ones from one or more mode families. To select more than one mode family, supply a comma-separated list."
@@ -291,11 +297,14 @@ paths:
- HEMA - HEMA
- WCA - WCA
- MOTA - MOTA
- SiOTA - SIOTA
- ARLHS - ARLHS
- ILLW - ILLW
- ZLOTA - ZLOTA
- IOTA - IOTA
- WOTA
- WAB
- WAI
- name: dx_continent - name: dx_continent
in: query in: query
description: "Limit the alerts to only ones where the DX (the operator being spotted) is on the given continent(s). To select more than one continent, supply a comma-separated list." description: "Limit the alerts to only ones where the DX (the operator being spotted) is on the given continent(s). To select more than one continent, supply a comma-separated list."
@@ -551,10 +560,10 @@ components:
type: integer type: integer
description: ITU zone of the DX operator description: ITU zone of the DX operator
example: 14 example: 14
dx_aprs_ssid: dx_ssid:
type: string type: string
description: If this is an APRS spot, what SSID was the DX operator using? description: If this is an APRS/Packet/etc. spot, what SSID was the DX operator/sender using?
example: "" example: "7"
dx_grid: dx_grid:
type: string type: string
description: Maidenhead grid locator for the DX spot. This could be from a geographical reference e.g. POTA, or just from the country description: Maidenhead grid locator for the DX spot. This could be from a geographical reference e.g. POTA, or just from the country
@@ -609,6 +618,10 @@ components:
type: integer type: integer
description: DXCC ID of the spotter description: DXCC ID of the spotter
example: 235 example: 235
de_ssid:
type: string
description: If this is an APRS/Packet/etc. spot, what SSID was the receiver using?
example: "9"
de_grid: de_grid:
type: string type: string
description: Maidenhead grid locator for the spotter. This is not going to be from a xOTA reference so it will likely just be a QRZ or DXCC lookup. If the spotter is also portable, this is probably wrong, but it's good enough for some simple mapping. description: Maidenhead grid locator for the spotter. This is not going to be from a xOTA reference so it will likely just be a QRZ or DXCC lookup. If the spotter is also portable, this is probably wrong, but it's good enough for some simple mapping.
@@ -649,6 +662,9 @@ components:
- PSK - PSK
- BPSK31 - BPSK31
- OLIVIA - OLIVIA
- MFSK
- MFSK32
- PKT
example: SSB example: SSB
mode_type: mode_type:
type: string type: string
@@ -724,11 +740,14 @@ components:
- HEMA - HEMA
- WCA - WCA
- MOTA - MOTA
- SiOTA - SIOTA
- ARLHS - ARLHS
- ILLW - ILLW
- ZLOTA - ZLOTA
- IOTA - IOTA
- WOTA
- WAB
- WAI
example: POTA example: POTA
sig_refs: sig_refs:
type: array type: array
@@ -884,11 +903,14 @@ components:
- HEMA - HEMA
- WCA - WCA
- MOTA - MOTA
- SiOTA - SIOTA
- ARLHS - ARLHS
- ILLW - ILLW
- ZLOTA - ZLOTA
- IOTA - IOTA
- WOTA
- WAB
- WAI
example: POTA example: POTA
sig_refs: sig_refs:
type: array type: array

View File

@@ -142,7 +142,7 @@ function updateBands() {
// Now each spot is tagged with how far down the div it should go, add them to the DOM. // Now each spot is tagged with how far down the div it should go, add them to the DOM.
spotList.forEach(s => { spotList.forEach(s => {
bandSpotsDiv.append(`<div class="band-spot" style="top: ${s['pxDownBandLabel']}px; border-top: 1px solid ${s.band_color}; border-left: 5px solid ${s.band_color}; border-bottom: 1px solid ${s.band_color}; border-right: 1px solid ${s.band_color};"><span class="band-spot-call">${s.dx_call}</span><span class="band-spot-info">${s.dx_call} ${(s.freq/1000000).toFixed(3)} ${s.mode}</span></div>`); bandSpotsDiv.append(`<div class="band-spot" style="top: ${s['pxDownBandLabel']}px; border-top: 1px solid ${s.band_color}; border-left: 5px solid ${s.band_color}; border-bottom: 1px solid ${s.band_color}; border-right: 1px solid ${s.band_color};"><span class="band-spot-call">${s.dx_call}${s.dx_ssid != null ? "-" + s.dx_ssid : ""}</span><span class="band-spot-info">${s.dx_call}${s.dx_ssid != null ? "-" + s.dx_ssid : ""} ${(s.freq/1000000).toFixed(3)} ${s.mode}</span></div>`);
}); });
// Work out how tall the canvas should be. Normally this is matching the normal band column height, but if some // Work out how tall the canvas should be. Normally this is matching the normal band column height, but if some

View File

@@ -66,6 +66,16 @@ function getIcon(s) {
// Tooltip text for the markers // Tooltip text for the markers
function getTooltipText(s) { function getTooltipText(s) {
// Format DX call
var dx_call = s["dx_call"];
if (dx_call == null) {
dx_call = "";
dx_flag = "";
}
if (s["dx_ssid"] != null) {
dx_call = dx_call + "-" + s["dx_ssid"];
}
// Format DX flag // Format DX flag
var dx_flag = "<i class='fa-solid fa-globe-africa'></i>"; var dx_flag = "<i class='fa-solid fa-globe-africa'></i>";
if (s["dx_flag"] && s["dx_flag"] != null && s["dx_flag"] != "") { if (s["dx_flag"] && s["dx_flag"] != null && s["dx_flag"] != "") {
@@ -73,11 +83,14 @@ function getTooltipText(s) {
} }
// Format the frequency // Format the frequency
var freq_string = "Unknown"
if (s["freq"] != null) {
var mhz = Math.floor(s["freq"] / 1000000.0); var mhz = Math.floor(s["freq"] / 1000000.0);
var khz = Math.floor((s["freq"] - (mhz * 1000000.0)) / 1000.0); var khz = Math.floor((s["freq"] - (mhz * 1000000.0)) / 1000.0);
var hz = Math.floor(s["freq"] - (mhz * 1000000.0) - (khz * 1000.0)); var hz = Math.floor(s["freq"] - (mhz * 1000000.0) - (khz * 1000.0));
var hz_string = (hz > 0) ? hz.toFixed(0)[0] : ""; var hz_string = (hz > 0) ? hz.toFixed(0)[0] : "";
var freq_string = `<span class='freq-mhz'>${mhz.toFixed(0)}</span><span class='freq-khz'>${khz.toFixed(0).padStart(3, '0')}</span><span class='freq-hz hideonmobile'>${hz_string}</span>` freq_string = `<span class='freq-mhz freq-mhz-pad'>${mhz.toFixed(0)}</span><span class='freq-khz'>${khz.toFixed(0).padStart(3, '0')}</span><span class='freq-hz hideonmobile'>${hz_string}</span>`
}
// Format comment // Format comment
var commentText = ""; var commentText = "";
@@ -104,10 +117,7 @@ function getTooltipText(s) {
} }
// DX // DX
const shortCall = s["dx_call"].split("/").sort(function (a, b) { ttt = `<span class='nowrap'><span class='icon-wrapper'>${dx_flag}</span> <a href='https://www.qrz.com/db/${dx_call}' target='_blank' class="dx-link">${dx_call}</a></span><br/>`;
return b.length - a.length;
})[0];
ttt = `<span class='nowrap'><span class='icon-wrapper'>${dx_flag}</span> <a href='https://www.qrz.com/db/${shortCall}' target='_blank' class="dx-link">${s["dx_call"]}</a></span><br/>`;
// Frequency & band // Frequency & band
ttt += `<span class='icon-wrapper'><i class='fa-solid fa-radio markerPopupIcon'></i></span>&nbsp;${freq_string}`; ttt += `<span class='icon-wrapper'><i class='fa-solid fa-radio markerPopupIcon'></i></span>&nbsp;${freq_string}`;

View File

@@ -92,6 +92,16 @@ function updateTable() {
} }
var time_formatted = time.format("HH:mm"); var time_formatted = time.format("HH:mm");
// Format DX call
var dx_call = s["dx_call"];
if (dx_call == null) {
dx_call = "";
dx_flag = "";
}
if (s["dx_ssid"] != null) {
dx_call = dx_call + "-" + s["dx_ssid"];
}
// Format DX flag // Format DX flag
var dx_flag = "<i class='fa-solid fa-globe-africa'></i>"; var dx_flag = "<i class='fa-solid fa-globe-africa'></i>";
if (s["dx_flag"] && s["dx_flag"] != null && s["dx_flag"] != "") { if (s["dx_flag"] && s["dx_flag"] != null && s["dx_flag"] != "") {
@@ -105,11 +115,14 @@ function updateTable() {
} }
// Format the frequency // Format the frequency
var freq_string = "Unknown"
if (s["freq"] != null) {
var mhz = Math.floor(s["freq"] / 1000000.0); var mhz = Math.floor(s["freq"] / 1000000.0);
var khz = Math.floor((s["freq"] - (mhz * 1000000.0)) / 1000.0); var khz = Math.floor((s["freq"] - (mhz * 1000000.0)) / 1000.0);
var hz = Math.floor(s["freq"] - (mhz * 1000000.0) - (khz * 1000.0)); var hz = Math.floor(s["freq"] - (mhz * 1000000.0) - (khz * 1000.0));
var hz_string = (hz > 0) ? hz.toFixed(0)[0] : ""; var hz_string = (hz > 0) ? hz.toFixed(0)[0] : "";
var freq_string = `<span class='freq-mhz freq-mhz-pad'>${mhz.toFixed(0)}</span><span class='freq-khz'>${khz.toFixed(0).padStart(3, '0')}</span><span class='freq-hz hideonmobile'>${hz_string}</span>` freq_string = `<span class='freq-mhz freq-mhz-pad'>${mhz.toFixed(0)}</span><span class='freq-khz'>${khz.toFixed(0).padStart(3, '0')}</span><span class='freq-hz hideonmobile'>${hz_string}</span>`
}
// Format the mode // Format the mode
mode_string = s["mode"]; mode_string = s["mode"];
@@ -176,6 +189,9 @@ function updateTable() {
de_call = ""; de_call = "";
de_flag = ""; de_flag = "";
} }
if (s["de_ssid"] != null) {
de_call = de_call + "-" + s["de_ssid"];
}
// Format band name // Format band name
var bandFullName = s['band'] ? s['band'] + " band": "Unknown band"; var bandFullName = s['band'] ? s['band'] + " band": "Unknown band";
@@ -185,10 +201,10 @@ function updateTable() {
$tr.append(`<td class='nowrap'>${time_formatted}</td>`); $tr.append(`<td class='nowrap'>${time_formatted}</td>`);
} }
if (showDX) { if (showDX) {
$tr.append(`<td class='nowrap'><span class='flag-wrapper hideonmobile' title='${dx_country}'>${dx_flag}</span><a class='dx-link' href='https://qrz.com/db/${s["dx_call"]}' target='_new' title='${s["dx_name"] != null ? s["dx_name"] : ""}'>${s["dx_call"]}</a></td>`); $tr.append(`<td class='nowrap'><span class='flag-wrapper hideonmobile' title='${dx_country}'>${dx_flag}</span><a class='dx-link' href='https://qrz.com/db/${s["dx_call"]}' target='_new' title='${s["dx_name"] != null ? s["dx_name"] : ""}'>${dx_call}</a></td>`);
} }
if (showFreq) { if (showFreq) {
$tr.append(`<td class='nowrap'><span class='band-bullet' title='${bandFullName}' style='color: ${s["band_color"]}'>&#9632;</span>${freq_string}</td>`); $tr.append(`<td class='nowrap'><span class='band-bullet' title='${bandFullName}' style='${(s["freq"] != null) ? "color: " + s["band_color"] : "display: none;"}'>&#9632;</span>${freq_string}</td>`);
} }
if (showMode) { if (showMode) {
$tr.append(`<td class='nowrap'>${mode_string}</td>`); $tr.append(`<td class='nowrap'>${mode_string}</td>`);