10 Commits

29 changed files with 165 additions and 78 deletions

View File

@@ -20,13 +20,18 @@ class SOTA(HTTPAlertProvider):
# Iterate through source data # Iterate through source data
for source_alert in http_response.json(): for source_alert in http_response.json():
# Convert to our alert format # Convert to our alert format
details = source_alert["summitDetails"].split(", ")
summit_name = details[0]
summit_points = None
if len(details) > 2:
summit_points = int(details[-1].split(" ")[0])
alert = Alert(source=self.name, alert = Alert(source=self.name,
source_id=source_alert["id"], source_id=source_alert["id"],
dx_calls=[source_alert["activatingCallsign"].upper()], dx_calls=[source_alert["activatingCallsign"].upper()],
dx_names=[source_alert["activatorName"].upper()], dx_names=[source_alert["activatorName"].upper()],
freqs_modes=source_alert["frequency"], freqs_modes=source_alert["frequency"],
comment=source_alert["comments"], comment=source_alert["comments"],
sig_refs=[SIGRef(id=source_alert["associationCode"] + "/" + source_alert["summitCode"], sig="SOTA", name=source_alert["summitDetails"])], sig_refs=[SIGRef(id=source_alert["associationCode"] + "/" + source_alert["summitCode"], sig="SOTA", name=summit_name, activation_score=summit_points)],
start_time=datetime.strptime(source_alert["dateActivated"], start_time=datetime.strptime(source_alert["dateActivated"],
"%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=pytz.UTC).timestamp(), "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=pytz.UTC).timestamp(),
is_dxpedition=False) is_dxpedition=False)

View File

@@ -59,28 +59,53 @@ spot-providers:
enabled: true enabled: true
host: "hrd.wa9pie.net" host: "hrd.wa9pie.net"
port: 8000 port: 8000
login_prompt: "login: " # Prompt the cluster node gives when asking for a callsign to log in. Varies between cluster node software.
login_prompt: "login:"
# Callsign Spothole will use to log into this cluster. Ensure the SSID (e.g. -99) is different to any personal
# connection you might make to this cluster node.
login_callsign: "N0CALL-99"
# Whether to allow RBN spots that come via this cluster. If you don't want RBN spots or you are making a separate
# connection to RBN directly, leave this as False. If you want RBN spots from this cluster, set this to True. (Make
# sure you aren't also separately connecting to RBN directly, otherwise you may get duplicate spots.) Note that not
# all clusters sent RBN spots anyway.
allow_rbn_spots: false
- -
class: "DXCluster" class: "DXCluster"
name: "W3LPL Cluster" name: "W3LPL Cluster"
enabled: false enabled: false
host: "w3lpl.net" host: "w3lpl.net"
port: 7373 port: 7373
login_prompt: "Please enter your call: " # Prompt the cluster node gives when asking for a callsign to log in. Varies between cluster node software.
login_prompt: "Please enter your call:"
# Callsign Spothole will use to log into this cluster. Ensure the SSID (e.g. -99) is different to any personal
# connection you might make to this cluster node.
login_callsign: "N0CALL-99"
# Whether to allow RBN spots that come via this cluster. If you don't want RBN spots or you are making a separate
# connection to RBN directly, leave this as False. If you want RBN spots from this cluster, set this to True. (Make
# sure you aren't also separately connecting to RBN directly, otherwise you may get duplicate spots.) Note that not
# all clusters sent RBN spots anyway.
allow_rbn_spots: false
- -
class: "RBN" class: "RBN"
name: "RBN CW/RTTY" name: "RBN CW/RTTY"
enabled: false enabled: false
port: 7000 port: 7000
# This setting doesn't affect the spot provider itself, or anything in the back-end of Spothole, just the web UI.
# By default spots from all enabled providers will be shown in the web UI. However, you might want RBN data to be
# received by Spothole but not shown on the web UI unless the user explicitly turns it on. For that behaviour,
# set enabled to true, but enabled-by-default-in-web-ui to false.
enabled-by-default-in-web-ui: false
- -
class: "RBN" class: "RBN"
name: "RBN FT8" name: "RBN FT8"
enabled: false enabled: false
port: 7001 port: 7001
enabled-by-default-in-web-ui: false
- -
class: "UKPacketNet" class: "UKPacketNet"
name: "UK Packet Radio Net" name: "UK Packet Radio Net"
enabled: false enabled: false
enabled-by-default-in-web-ui: false
- -
class: "XOTA" class: "XOTA"
name: "39C3 TOTA" name: "39C3 TOTA"

View File

@@ -5,7 +5,8 @@ import yaml
# Check you have a config file # Check you have a config file
if not os.path.isfile("config.yml"): if not os.path.isfile("config.yml"):
logging.error("Your config file is missing. Ensure you have copied config-example.yml to config.yml and updated it according to your needs.") logging.error(
"Your config file is missing. Ensure you have copied config-example.yml to config.yml and updated it according to your needs.")
exit() exit()
# Load config # Load config
@@ -17,4 +18,9 @@ MAX_ALERT_AGE = config["max-alert-age-sec"]
SERVER_OWNER_CALLSIGN = config["server-owner-callsign"] SERVER_OWNER_CALLSIGN = config["server-owner-callsign"]
WEB_SERVER_PORT = config["web-server-port"] WEB_SERVER_PORT = config["web-server-port"]
ALLOW_SPOTTING = config["allow-spotting"] ALLOW_SPOTTING = config["allow-spotting"]
WEB_UI_OPTIONS = config["web-ui-options"] WEB_UI_OPTIONS = config["web-ui-options"]
# For ease of config, each spot provider owns its own config about whether it should be enabled by default in the web UI
# but for consistency we provide this to the front-end in web-ui-options because it has no impact outside of the web UI.
WEB_UI_OPTIONS["spot-providers-enabled-by-default"] = [p["name"] for p in config["spot-providers"] if p["enabled"] and (
"enabled-by-default-in-web-ui" not in p or p["enabled-by-default-in-web-ui"] == True)]

View File

@@ -4,7 +4,7 @@ from data.sig import SIG
# General software # General software
SOFTWARE_NAME = "Spothole by M0TRT" SOFTWARE_NAME = "Spothole by M0TRT"
SOFTWARE_VERSION = "1.1-pre" SOFTWARE_VERSION = "1.1.1"
# HTTP headers used for spot providers that use HTTP # HTTP headers used for spot providers that use HTTP
HTTP_HEADERS = {"User-Agent": SOFTWARE_NAME + ", v" + SOFTWARE_VERSION + " (operated by " + SERVER_OWNER_CALLSIGN + ")"} HTTP_HEADERS = {"User-Agent": SOFTWARE_NAME + ", v" + SOFTWARE_VERSION + " (operated by " + SERVER_OWNER_CALLSIGN + ")"}
@@ -36,10 +36,25 @@ SIGS = [
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA". # Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
CW_MODES = ["CW"] CW_MODES = ["CW"]
PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"] PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"]
DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "BPSK", "PSK", "PSK31", "BPSK31", "OLIVIA", "MFSK", "MFSK32", "PKT", "MSK144"] DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"]
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
MODE_TYPES = ["CW", "PHONE", "DATA"] MODE_TYPES = ["CW", "PHONE", "DATA"]
# Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots
# that match a key in this table will be converted to the corresponding value, so only the modes above will actually be
# present in the spots.
MODE_ALIASES = {
"RTT": "RTTY",
"BPSK": "PSK",
"PSK31": "PSK",
"BPSK31": "PSK",
"MFSK": "FSK",
"MFSK32": "FSK",
"DIGI": "DATA",
"DIGITAL": "DATA"
}
# Band definitions # Band definitions
BANDS = [ BANDS = [
Band(name="2200m", start_freq=135700, end_freq=137800), Band(name="2200m", start_freq=135700, end_freq=137800),

View File

@@ -16,7 +16,7 @@ from requests_cache import CachedSession
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
from core.config import config from core.config import config
from core.constants import BANDS, UNKNOWN_BAND, CW_MODES, PHONE_MODES, DATA_MODES, ALL_MODES, \ from core.constants import BANDS, UNKNOWN_BAND, CW_MODES, PHONE_MODES, DATA_MODES, ALL_MODES, \
HTTP_HEADERS, HAMQTH_PRG HTTP_HEADERS, HAMQTH_PRG, MODE_ALIASES
# Singleton class that provides lookup functionality. # Singleton class that provides lookup functionality.
@@ -160,6 +160,9 @@ class LookupHelper:
for mode in ALL_MODES: for mode in ALL_MODES:
if mode in comment.upper(): if mode in comment.upper():
return mode return mode
for mode in MODE_ALIASES.keys():
if mode in comment.upper():
return MODE_ALIASES[mode]
return None return None
# Infer a "mode family" from a mode. # Infer a "mode family" from a mode.

View File

@@ -46,6 +46,7 @@ def populate_sig_ref_info(sig_ref):
sig_ref.grid = data["locator"] if "locator" in data else None sig_ref.grid = data["locator"] if "locator" in data else None
sig_ref.latitude = data["latitude"] if "latitude" in data else None sig_ref.latitude = data["latitude"] if "latitude" in data else None
sig_ref.longitude = data["longitude"] if "longitude" in data else None sig_ref.longitude = data["longitude"] if "longitude" in data else None
sig_ref.activation_score = data["points"] if "points" in data else None
elif sig.upper() == "WWBOTA": elif sig.upper() == "WWBOTA":
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + ref_id, data = SEMI_STATIC_URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + ref_id,
headers=HTTP_HEADERS).json() headers=HTTP_HEADERS).json()

View File

@@ -53,8 +53,6 @@ class Alert:
sig: str = None sig: str = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO # SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
sig_refs: list = None sig_refs: list = None
# Activation score. SOTA only
activation_score: int = None
# Whether this alert is for a DXpedition, as opposed to e.g. an xOTA programme. # Whether this alert is for a DXpedition, as opposed to e.g. an xOTA programme.
is_dxpedition: bool = False is_dxpedition: bool = False
# Where we got the alert from, e.g. "POTA", "SOTA"... # Where we got the alert from, e.g. "POTA", "SOTA"...

View File

@@ -17,4 +17,6 @@ class SIGRef:
# Longitude of the reference, if known. # Longitude of the reference, if known.
longitude: float = None longitude: float = None
# Maidenhead grid reference of the reference, if known. # Maidenhead grid reference of the reference, if known.
grid: str = None grid: str = None
# Activation score. SOTA only
activation_score: int = None

View File

@@ -10,6 +10,7 @@ import pytz
from pyhamtools.locator import locator_to_latlong, latlong_to_locator from pyhamtools.locator import locator_to_latlong, latlong_to_locator
from core.config import MAX_SPOT_AGE from core.config import MAX_SPOT_AGE
from core.constants import MODE_ALIASES
from core.lookup_helper import lookup_helper from core.lookup_helper import lookup_helper
from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
@@ -106,8 +107,6 @@ class Spot:
sig: str = None sig: str = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO # SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
sig_refs: list = None sig_refs: list = None
# Activation score. SOTA only
activation_score: int = None
# Timing info # Timing info
@@ -215,17 +214,16 @@ class Spot:
self.mode = lookup_helper.infer_mode_from_frequency(self.freq) self.mode = lookup_helper.infer_mode_from_frequency(self.freq)
self.mode_source = "BANDPLAN" self.mode_source = "BANDPLAN"
# Normalise "generic digital" modes. "DIGITAL", "DIGI" and "DATA" are just the same thing with no extra # Normalise mode if necessary.
# information, so standardise on "DATA" if self.mode in MODE_ALIASES:
if self.mode == "DIGI" or self.mode == "DIGITAL": self.mode = MODE_ALIASES[self.mode]
self.mode = "DATA"
# Mode type from mode # Mode type from mode
if self.mode and not self.mode_type: if self.mode and not self.mode_type:
self.mode_type = lookup_helper.infer_mode_type_from_mode(self.mode) self.mode_type = lookup_helper.infer_mode_type_from_mode(self.mode)
# If we have a latitude at this point, it can only have been provided by the spot itself # If we have a latitude or grid at this point, it can only have been provided by the spot itself
if self.dx_latitude: if self.dx_latitude or self.dx_grid:
self.dx_location_source = "SPOT" self.dx_location_source = "SPOT"
# Set the top-level "SIG" if it is missing but we have at least one SIG ref. # Set the top-level "SIG" if it is missing but we have at least one SIG ref.

View File

@@ -40,6 +40,7 @@ class APIOptionsHandler(tornado.web.RequestHandler):
# one of our proviers. # one of our proviers.
if ALLOW_SPOTTING: if ALLOW_SPOTTING:
options["spot_sources"].append("API") options["spot_sources"].append("API")
options["web-ui-options"]["spot-providers-enabled-by-default"].append("API")
self.write(json.dumps(options, default=serialize_everything)) self.write(json.dumps(options, default=serialize_everything))
self.set_status(200) self.set_status(200)

View File

@@ -12,22 +12,27 @@ from data.spot import Spot
from spotproviders.spot_provider import SpotProvider from spotproviders.spot_provider import SpotProvider
# Spot provider for a DX Cluster. Hostname port and login_prompt provided as parameters. # Spot provider for a DX Cluster. Hostname, port, login_prompt, login_callsign and allow_rbn_spots are provided in config.
# See config-example.yml for examples.
class DXCluster(SpotProvider): class DXCluster(SpotProvider):
# Note the callsign pattern deliberately excludes calls ending in "-#", which are from RBN and can be enabled by
# default on some clusters. If you want RBN spots, there is a separate provider for that.
CALLSIGN_PATTERN = "([a-z|0-9|/]+)" CALLSIGN_PATTERN = "([a-z|0-9|/]+)"
FREQUENCY_PATTERN = "([0-9|.]+)" FREQUENCY_PATTERN = "([0-9|.]+)"
LINE_PATTERN = re.compile( LINE_PATTERN_EXCLUDE_RBN = re.compile(
"^DX de " + CALLSIGN_PATTERN + ":\\s+" + FREQUENCY_PATTERN + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)", "^DX de " + CALLSIGN_PATTERN + ":\\s+" + FREQUENCY_PATTERN + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)",
re.IGNORECASE) re.IGNORECASE)
LINE_PATTERN_ALLOW_RBN = re.compile(
"^DX de " + CALLSIGN_PATTERN + "-?#?:\\s+" + FREQUENCY_PATTERN + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)",
re.IGNORECASE)
# Constructor requires hostname and port # Constructor requires hostname and port
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config) super().__init__(provider_config)
self.hostname = provider_config["host"] self.hostname = provider_config["host"]
self.port = provider_config["port"] self.port = provider_config["port"]
self.login_prompt = provider_config["login_prompt"] self.login_prompt = provider_config["login_prompt"] if "login_prompt" in provider_config else "login:"
self.login_callsign = provider_config["login_callsign"] if "login_callsign" in provider_config else SERVER_OWNER_CALLSIGN
self.allow_rbn_spots = provider_config["allow_rbn_spots"] if "allow_rbn_spots" in provider_config else False
self.spot_line_pattern = self.LINE_PATTERN_ALLOW_RBN if self.allow_rbn_spots else self.LINE_PATTERN_EXCLUDE_RBN
self.telnet = None self.telnet = None
self.thread = Thread(target=self.handle) self.thread = Thread(target=self.handle)
self.thread.daemon = True self.thread.daemon = True
@@ -50,7 +55,7 @@ class DXCluster(SpotProvider):
logging.info("DX Cluster " + self.hostname + " connecting...") logging.info("DX Cluster " + self.hostname + " connecting...")
self.telnet = telnetlib3.Telnet(self.hostname, self.port) self.telnet = telnetlib3.Telnet(self.hostname, self.port)
self.telnet.read_until(self.login_prompt.encode("latin-1")) self.telnet.read_until(self.login_prompt.encode("latin-1"))
self.telnet.write((SERVER_OWNER_CALLSIGN + "\n").encode("latin-1")) self.telnet.write((self.login_callsign + "\n").encode("latin-1"))
connected = True connected = True
logging.info("DX Cluster " + self.hostname + " connected.") logging.info("DX Cluster " + self.hostname + " connected.")
except Exception as e: except Exception as e:
@@ -63,7 +68,7 @@ class DXCluster(SpotProvider):
try: try:
# Check new telnet info against regular expression # Check new telnet info against regular expression
telnet_output = self.telnet.read_until("\n".encode("latin-1")) telnet_output = self.telnet.read_until("\n".encode("latin-1"))
match = self.LINE_PATTERN.match(telnet_output.decode("latin-1")) match = self.spot_line_pattern.match(telnet_output.decode("latin-1"))
if match: if match:
spot_time = datetime.strptime(match.group(5), "%H%MZ") spot_time = datetime.strptime(match.group(5), "%H%MZ")
spot_datetime = datetime.combine(datetime.today(), spot_time.time()).replace(tzinfo=pytz.UTC) spot_datetime = datetime.combine(datetime.today(), spot_time.time()).replace(tzinfo=pytz.UTC)

View File

@@ -45,9 +45,8 @@ class SOTA(HTTPSpotProvider):
mode=source_spot["mode"].upper(), mode=source_spot["mode"].upper(),
comment=source_spot["comments"], comment=source_spot["comments"],
sig="SOTA", sig="SOTA",
sig_refs=[SIGRef(id=source_spot["summitCode"], sig="SOTA", name=source_spot["summitName"])], sig_refs=[SIGRef(id=source_spot["summitCode"], sig="SOTA", name=source_spot["summitName"], activation_score=source_spot["points"])],
time=datetime.fromisoformat(source_spot["timeStamp"]).timestamp(), time=datetime.fromisoformat(source_spot["timeStamp"].replace("Z", "+00:00")).timestamp())
activation_score=source_spot["points"])
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do # Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
# that for us. # that for us.

View File

@@ -30,7 +30,7 @@ class WWBOTA(SSESpotProvider):
comment=source_spot["comment"], comment=source_spot["comment"],
sig="WWBOTA", sig="WWBOTA",
sig_refs=refs, sig_refs=refs,
time=datetime.fromisoformat(source_spot["time"]).timestamp(), time=datetime.fromisoformat(source_spot["time"].replace("Z", "+00:00")).timestamp(),
# WWBOTA spots can contain multiple references for bunkers being activated simultaneously. For # WWBOTA spots can contain multiple references for bunkers being activated simultaneously. For
# now, we will just pick the first one to use as our grid, latitude and longitude. # now, we will just pick the first one to use as our grid, latitude and longitude.
dx_grid=source_spot["references"][0]["locator"], dx_grid=source_spot["references"][0]["locator"],

View File

@@ -35,7 +35,7 @@ class ZLOTA(HTTPSpotProvider):
comment=source_spot["comments"], comment=source_spot["comments"],
sig="ZLOTA", sig="ZLOTA",
sig_refs=[SIGRef(id=source_spot["reference"], sig="ZLOTA", name=source_spot["name"])], sig_refs=[SIGRef(id=source_spot["reference"], sig="ZLOTA", name=source_spot["name"])],
time=datetime.fromisoformat(source_spot["referenced_time"]).astimezone(pytz.UTC).timestamp()) time=datetime.fromisoformat(source_spot["referenced_time"].replace("Z", "+00:00")).astimezone(pytz.UTC).timestamp())
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots

View File

@@ -63,7 +63,7 @@
<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=5"></script> <script src="/js/common.js?v=6"></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=5"></script> <script src="/js/common.js?v=6"></script>
<script src="/js/add-spot.js?v=5"></script> <script src="/js/add-spot.js?v=6"></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

@@ -168,8 +168,8 @@
</div> </div>
<script src="/js/common.js?v=5"></script> <script src="/js/common.js?v=6"></script>
<script src="/js/alerts.js?v=5"></script> <script src="/js/alerts.js?v=6"></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

@@ -134,9 +134,9 @@
</div> </div>
<script src="/js/common.js?v=5"></script> <script src="/js/common.js?v=6"></script>
<script src="/js/spotsbandsandmap.js?v=5"></script> <script src="/js/spotsbandsandmap.js?v=6"></script>
<script src="/js/bands.js?v=5"></script> <script src="/js/bands.js?v=6"></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=5"></script> <script src="https://misc.ianrenton.com/jsutils/utils.js?v=6"></script>
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=5"></script> <script src="https://misc.ianrenton.com/jsutils/storage.js?v=6"></script>
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=5"></script> <script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=6"></script>
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=5"></script> <script src="https://misc.ianrenton.com/jsutils/geo.js?v=6"></script>
</head> </head>
<body> <body>

View File

@@ -152,9 +152,9 @@
<script src="https://cdn.jsdelivr.net/npm/leaflet.geodesic"></script> <script src="https://cdn.jsdelivr.net/npm/leaflet.geodesic"></script>
<script src="https://cdn.jsdelivr.net/npm/@joergdietrich/leaflet.terminator@1.1.0/L.Terminator.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@joergdietrich/leaflet.terminator@1.1.0/L.Terminator.min.js"></script>
<script src="/js/common.js?v=5"></script> <script src="/js/common.js?v=6"></script>
<script src="/js/spotsbandsandmap.js?v=5"></script> <script src="/js/spotsbandsandmap.js?v=6"></script>
<script src="/js/map.js?v=5"></script> <script src="/js/map.js?v=6"></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

@@ -223,9 +223,9 @@
</div> </div>
<script src="/js/common.js?v=5"></script> <script src="/js/common.js?v=6"></script>
<script src="/js/spotsbandsandmap.js?v=5"></script> <script src="/js/spotsbandsandmap.js?v=6"></script>
<script src="/js/spots.js?v=5"></script> <script src="/js/spots.js?v=6"></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=5"></script> <script src="/js/common.js?v=6"></script>
<script src="/js/status.js?v=5"></script> <script src="/js/status.js?v=6"></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 %}

10
webassets/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

6
webassets/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

View File

@@ -1,4 +1,4 @@
openapi: 3.0.4 openapi: 3.1.0
info: info:
title: Spothole API title: Spothole API
description: |- description: |-
@@ -14,7 +14,9 @@ info:
### 1.1 ### 1.1
Added Server-Sent Event API endpoint. Removed band colour and icon information from spots. * Added Server-Sent Event API endpoints for spots and alerts.
* Removed band colour and icon information from spots.
* Moved activation_score from top-level in Spot and Alert to be part of the SIGRef
contact: contact:
email: ian@ianrenton.com email: ian@ianrenton.com
license: license:
@@ -555,6 +557,12 @@ paths:
type: integer type: integer
example: 30 example: 30
description: The suggested default "maximum spot age" that the web UI should retrieve from the API description: The suggested default "maximum spot age" that the web UI should retrieve from the API
spot-providers-enabled-by-default:
type: array
description: A list of the spot providers that should be enabled in the web UI on first load, if the user hasn't already got a localStorage setting that sets their preference. This is to allow some high-volume providers like RBN to be enabled in Spothole's back-end and displayable in the web UI if the user wants, but by default the experience will not include them.
items:
type: string
example: "POTA"
alert-count: alert-count:
type: array type: array
description: An array of suggested "alert counts" that the web UI can retrieve from the API description: An array of suggested "alert counts" that the web UI can retrieve from the API
@@ -864,12 +872,9 @@ components:
- SSTV - SSTV
- JS8 - JS8
- HELL - HELL
- BPSK
- PSK
- BPSK31
- OLIVIA - OLIVIA
- MFSK - PSK
- MFSK32 - FSK
- PKT - PKT
- MSK144 - MSK144
example: SSB example: SSB
@@ -940,6 +945,10 @@ components:
type: number type: number
description: Longitude of the reference, in degrees, if known. description: Longitude of the reference, in degrees, if known.
example: -1.2345 example: -1.2345
activation_score:
type: integer
description: Activation score. SOTA only
example: 0
Spot: Spot:
type: object type: object
@@ -1086,10 +1095,6 @@ components:
items: items:
$ref: '#/components/schemas/SIGRef' $ref: '#/components/schemas/SIGRef'
description: SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO description: SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
activation_score:
type: integer
description: Activation score. SOTA only
example: 0
qrt: qrt:
type: boolean type: boolean
description: QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments. description: QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
@@ -1194,10 +1199,6 @@ components:
items: items:
$ref: '#/components/schemas/SIGRef' $ref: '#/components/schemas/SIGRef'
description: SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO description: SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
activation_score:
type: integer
description: Activation score. SOTA only
example: 0
source: source:
type: string type: string
description: Where we got the alert from. description: Where we got the alert from.

View File

@@ -252,7 +252,7 @@ function loadOptions() {
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]); generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]); generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]); generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
generateMultiToggleFilterCard("#source-options", "source", options["spot_sources"]); generateSourcesMultiToggleFilterCard(options["spot_sources"], options["web-ui-options"]["spot-providers-enabled-by-default"]);
// Load URL params. These may select things from the various filter & display options, so the function needs // Load URL params. These may select things from the various filter & display options, so the function needs
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress // to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress

View File

@@ -184,7 +184,7 @@ function loadOptions() {
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]); generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]); generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]); generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
generateMultiToggleFilterCard("#source-options", "source", options["spot_sources"]); generateSourcesMultiToggleFilterCard(options["spot_sources"], options["web-ui-options"]["spot-providers-enabled-by-default"]);
// Load URL params. These may select things from the various filter & display options, so the function needs // Load URL params. These may select things from the various filter & display options, so the function needs
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress // to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress

View File

@@ -422,7 +422,7 @@ function loadOptions() {
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]); generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]); generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]); generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
generateMultiToggleFilterCard("#source-options", "source", options["spot_sources"]); generateSourcesMultiToggleFilterCard(options["spot_sources"], options["web-ui-options"]["spot-providers-enabled-by-default"]);
// Load URL params. These may select things from the various filter & display options, so the function needs // Load URL params. These may select things from the various filter & display options, so the function needs
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress // to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress

View File

@@ -6,10 +6,9 @@ var spots = []
function addBandToggleColourCSS(band_options) { function addBandToggleColourCSS(band_options) {
var $style = $('<style>'); var $style = $('<style>');
band_options.forEach(o => { band_options.forEach(o => {
// CSS doesn't like IDs with decimal points in, so we need to replace that var domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
var cssFormattedBandName = o['name'] ? o['name'].replace('.', 'p') : "unknown"; $style.append(`#filter-button-label-band-${domSafeName} { border-color: ${bandToColor(o['name'])}; color: var(--bs-primary);}`);
$style.append(`#filter-button-label-band-${cssFormattedBandName} { border-color: ${bandToColor(o['name'])}; color: var(--bs-primary);}`); $style.append(`.btn-check:checked + #filter-button-label-band-${domSafeName} { background-color: ${bandToColor(o['name'])}; color: ${bandToContrastColor(o['name'])};}`);
$style.append(`.btn-check:checked + #filter-button-label-band-${cssFormattedBandName} { background-color: ${bandToColor(o['name'])}; color: ${bandToContrastColor(o['name'])};}`);
}); });
$('html > head').append($style); $('html > head').append($style);
} }
@@ -18,10 +17,8 @@ function addBandToggleColourCSS(band_options) {
function generateBandsMultiToggleFilterCard(band_options) { function generateBandsMultiToggleFilterCard(band_options) {
// Create a button for each option // Create a button for each option
band_options.forEach(o => { band_options.forEach(o => {
// CSS doesn't like IDs with decimal points in, so we need to replace that in the same way as when we originally var domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
// queried the options endpoint and set our CSS. $("#band-options").append(`<input type="checkbox" class="btn-check filter-button-band storeable-checkbox" name="options" id="filter-button-band-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline" id="filter-button-label-band-${domSafeName}" for="filter-button-band-${domSafeName}">${o['name']}</label> `);
var cssFormattedBandName = o['name'] ? o['name'].replace('.', 'p') : "unknown";
$("#band-options").append(`<input type="checkbox" class="btn-check filter-button-band storeable-checkbox" name="options" id="filter-button-band-${cssFormattedBandName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline" id="filter-button-label-band-${cssFormattedBandName}" for="filter-button-band-${cssFormattedBandName}">${o['name']}</label> `);
}); });
// Create All/None/Ham HF buttons // Create All/None/Ham HF buttons
$("#band-options").append(` <span style="display: inline-block"><button id="filter-button-band-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', true);">All</button> <button id="filter-button-band-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', false);">None</button> <button id="filter-button-band-none" type="button" class="btn btn-outline-secondary" onclick="setHamHFBandToggles();">Ham HF</button></span>`); $("#band-options").append(` <span style="display: inline-block"><button id="filter-button-band-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', true);">All</button> <button id="filter-button-band-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', false);">None</button> <button id="filter-button-band-none" type="button" class="btn btn-outline-secondary" onclick="setHamHFBandToggles();">Ham HF</button></span>`);
@@ -41,7 +38,8 @@ function setHamHFBandToggles() {
function generateSIGsMultiToggleFilterCard(sig_options) { function generateSIGsMultiToggleFilterCard(sig_options) {
// Create a button for each option // Create a button for each option
sig_options.forEach(o => { sig_options.forEach(o => {
$("#sig-options").append(`<input type="checkbox" class="btn-check filter-button-sig storeable-checkbox" name="options" id="filter-button-sig-${o['name']}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-primary" id="filter-button-label-sig-${o['name']}" for="filter-button-sig-${o['name']}" title="${o['description']}"><i class="fa-solid ${sigToIcon(o['name'], 'fa-tower-cell')}"></i> ${o['name']}</label> `); var domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$("#sig-options").append(`<input type="checkbox" class="btn-check filter-button-sig storeable-checkbox" name="options" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-primary" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${sigToIcon(o['name'], 'fa-tower-cell')}"></i> ${o['name']}</label> `);
}); });
// Create a bonus "NO_SIG" / "General DX" option // Create a bonus "NO_SIG" / "General DX" option
$("#sig-options").append(`<input type="checkbox" class="btn-check filter-button-sig storeable-checkbox" name="options" id="filter-button-sig-NO_SIG" value="NO_SIG" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-primary" id="filter-button-label-sig-NO_SIG" for="filter-button-sig-NO_SIG"><i class="fa-solid fa-tower-cell"></i> General DX</label> `); $("#sig-options").append(`<input type="checkbox" class="btn-check filter-button-sig storeable-checkbox" name="options" id="filter-button-sig-NO_SIG" value="NO_SIG" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-primary" id="filter-button-label-sig-NO_SIG" for="filter-button-sig-NO_SIG"><i class="fa-solid fa-tower-cell"></i> General DX</label> `);
@@ -49,6 +47,20 @@ function generateSIGsMultiToggleFilterCard(sig_options) {
$("#sig-options").append(` <span style="display: inline-block"><button id="filter-button-sig-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('sig', true);">All</button>&nbsp;<button id="filter-button-sig-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('sig', false);">None</button></span>`); $("#sig-options").append(` <span style="display: inline-block"><button id="filter-button-sig-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('sig', true);">All</button>&nbsp;<button id="filter-button-sig-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('sig', false);">None</button></span>`);
} }
// Generate Sources filter card. This one is a minor special case as we create the buttons in the normal way, but then
// set which ones are enabled by default based on config rather than having them all enabled by default. We also sanitise
// names here for HTML elements.
function generateSourcesMultiToggleFilterCard(source_options, sources_enabled_by_default) {
// Create a button for each option
source_options.forEach(o => {
var enable = sources_enabled_by_default.includes(o);
var domSafeName = o.replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$("#source-options").append(`<input type="checkbox" class="btn-check filter-button-source storeable-checkbox" name="options" id="filter-button-source-${domSafeName}" value="${o}" autocomplete="off" onClick="filtersUpdated()" ${enable ? "checked" : ""}><label class="btn btn-outline-primary" for="filter-button-source-${domSafeName}">${o}</label> `);
});
// Create All/None buttons
$("#source-options").append(` <span style="display: inline-block"><button id="filter-button-source-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('source', true);">All</button>&nbsp;<button id="filter-button-source-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('source', false);">None</button></span>`);
}
// Method called when any filter is changed to reload the spots and persist the filter settings. // Method called when any filter is changed to reload the spots and persist the filter settings.
function filtersUpdated() { function filtersUpdated() {
loadSpots(); loadSpots();