mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-02-04 09:14:30 +00:00
Compare commits
21 Commits
88-colour-
...
97-wwtota
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b62ef6a9a0 | ||
|
|
7952ad22eb | ||
|
|
33bdcca990 | ||
|
|
261912b6e1 | ||
|
|
bb75b4ec2f | ||
|
|
0babf0a6be | ||
|
|
65957b4c01 | ||
|
|
522f90af97 | ||
|
|
4d344021c7 | ||
|
|
abdf8d3065 | ||
|
|
67b9c3bc50 | ||
|
|
9b3536d740 | ||
|
|
897901e105 | ||
|
|
059d9364eb | ||
|
|
a3ca590ca3 | ||
|
|
cfff8dd832 | ||
|
|
d1a5bfe9c3 | ||
|
|
da2827f559 | ||
|
|
220c9378cf | ||
|
|
e1cdc5b857 | ||
|
|
5482da0e69 |
@@ -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, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, the UK Packet Repeater Network, NG3K, and any site based on the xOTA software by nischu.
|
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, BOTA, LLOTA, WWTOTA, the UK Packet Repeater Network, NG3K, and any site based on the xOTA software by nischu.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -20,27 +20,31 @@ class BOTA(HTTPAlertProvider):
|
|||||||
new_alerts = []
|
new_alerts = []
|
||||||
# Find the table of upcoming alerts
|
# Find the table of upcoming alerts
|
||||||
bs = BeautifulSoup(http_response.content.decode(), features="lxml")
|
bs = BeautifulSoup(http_response.content.decode(), features="lxml")
|
||||||
tbody = bs.body.find('div', attrs={'class': 'view-activations-public'}).find('table', attrs={'class': 'views-table'}).find('tbody')
|
div = bs.body.find('div', attrs={'class': 'view-activations-public'})
|
||||||
for row in tbody.find_all('tr'):
|
if div:
|
||||||
cells = row.find_all('td')
|
table = div.find('table', attrs={'class': 'views-table'})
|
||||||
first_cell_text = str(cells[0].find('a').contents[0]).strip()
|
if table:
|
||||||
ref_name = first_cell_text.split(" by ")[0]
|
tbody = table.find('tbody')
|
||||||
dx_call = str(cells[1].find('a').contents[0]).strip().upper()
|
for row in tbody.find_all('tr'):
|
||||||
|
cells = row.find_all('td')
|
||||||
|
first_cell_text = str(cells[0].find('a').contents[0]).strip()
|
||||||
|
ref_name = first_cell_text.split(" by ")[0]
|
||||||
|
dx_call = str(cells[1].find('a').contents[0]).strip().upper()
|
||||||
|
|
||||||
# Get the date, dealing with the fact we get no year so have to figure out if it's last year or next year
|
# Get the date, dealing with the fact we get no year so have to figure out if it's last year or next year
|
||||||
date_text = str(cells[2].find('span').contents[0]).strip()
|
date_text = str(cells[2].find('span').contents[0]).strip()
|
||||||
date_time = datetime.strptime(date_text,"%d %b - %H:%M UTC").replace(tzinfo=pytz.UTC)
|
date_time = datetime.strptime(date_text,"%d %b - %H:%M UTC").replace(tzinfo=pytz.UTC)
|
||||||
date_time = date_time.replace(year=datetime.now(pytz.UTC).year)
|
date_time = date_time.replace(year=datetime.now(pytz.UTC).year)
|
||||||
# If this was more than a day ago, activation is actually next year
|
# If this was more than a day ago, activation is actually next year
|
||||||
if date_time < datetime.now(pytz.UTC) - timedelta(days=1):
|
if date_time < datetime.now(pytz.UTC) - timedelta(days=1):
|
||||||
date_time = date_time.replace(year=datetime.now(pytz.UTC).year + 1)
|
date_time = date_time.replace(year=datetime.now(pytz.UTC).year + 1)
|
||||||
|
|
||||||
# Convert to our alert format
|
# Convert to our alert format
|
||||||
alert = Alert(source=self.name,
|
alert = Alert(source=self.name,
|
||||||
dx_calls=[dx_call],
|
dx_calls=[dx_call],
|
||||||
sig_refs=[SIGRef(id=ref_name, sig="BOTA")],
|
sig_refs=[SIGRef(id=ref_name, sig="BOTA")],
|
||||||
start_time=date_time.timestamp(),
|
start_time=date_time.timestamp(),
|
||||||
is_dxpedition=False)
|
is_dxpedition=False)
|
||||||
|
|
||||||
new_alerts.append(alert)
|
new_alerts.append(alert)
|
||||||
return new_alerts
|
return new_alerts
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ spot-providers:
|
|||||||
class: "WOTA"
|
class: "WOTA"
|
||||||
name: "WOTA"
|
name: "WOTA"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
-
|
||||||
|
class: "LLOTA"
|
||||||
|
name: "LLOTA"
|
||||||
|
enabled: true
|
||||||
|
-
|
||||||
|
class: "WWTOTA"
|
||||||
|
name: "WWTOTA"
|
||||||
|
enabled: true
|
||||||
-
|
-
|
||||||
class: "APRSIS"
|
class: "APRSIS"
|
||||||
name: "APRS-IS"
|
name: "APRS-IS"
|
||||||
@@ -59,28 +67,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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -18,3 +19,8 @@ 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)]
|
||||||
|
|||||||
@@ -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.3-pre"
|
||||||
|
|
||||||
# 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 + ")"}
|
||||||
@@ -28,6 +28,8 @@ SIGS = [
|
|||||||
SIG(name="WOTA", description="Wainwrights on the Air", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
|
SIG(name="WOTA", description="Wainwrights on the Air", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
|
||||||
SIG(name="BOTA", description="Beaches on the Air"),
|
SIG(name="BOTA", description="Beaches on the Air"),
|
||||||
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award"),
|
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award"),
|
||||||
|
SIG(name="LLOTA", description="Lagos y Lagunas on the Air", ref_regex=r"[A-Z]{2}\-\d{4}"),
|
||||||
|
SIG(name="WWTOTA", description="Towers on the Air", ref_regex=r"[A-Z]{2}R\-\d{4}"),
|
||||||
SIG(name="WAB", description="Worked All Britain", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
|
SIG(name="WAB", description="Worked All Britain", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
|
||||||
SIG(name="WAI", description="Worked All Ireland", ref_regex=r"[A-Z][0-9]{2}"),
|
SIG(name="WAI", description="Worked All Ireland", ref_regex=r"[A-Z][0-9]{2}"),
|
||||||
SIG(name="TOTA", description="Toilets on the Air", ref_regex=r"T\-[0-9]{2}")
|
SIG(name="TOTA", description="Toilets on the Air", ref_regex=r"T\-[0-9]{2}")
|
||||||
@@ -36,10 +38,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),
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -140,12 +140,14 @@ class LookupHelper:
|
|||||||
# database live if possible.
|
# database live if possible.
|
||||||
def download_clublog_ctyxml(self):
|
def download_clublog_ctyxml(self):
|
||||||
try:
|
try:
|
||||||
logging.info("Downloading Clublog cty.xml...")
|
logging.info("Downloading Clublog cty.xml.gz...")
|
||||||
response = self.CLUBLOG_CTY_XML_CACHE.get("https://cdn.clublog.org/cty.php?api=" + self.CLUBLOG_API_KEY,
|
response = self.CLUBLOG_CTY_XML_CACHE.get("https://cdn.clublog.org/cty.php?api=" + self.CLUBLOG_API_KEY,
|
||||||
headers=HTTP_HEADERS)
|
headers=HTTP_HEADERS)
|
||||||
|
logging.info("Caching Clublog cty.xml.gz...")
|
||||||
open(self.CLUBLOG_XML_DOWNLOAD_LOCATION + ".gz", 'wb').write(response.content)
|
open(self.CLUBLOG_XML_DOWNLOAD_LOCATION + ".gz", 'wb').write(response.content)
|
||||||
with gzip.open(self.CLUBLOG_XML_DOWNLOAD_LOCATION + ".gz", "rb") as uncompressed:
|
with gzip.open(self.CLUBLOG_XML_DOWNLOAD_LOCATION + ".gz", "rb") as uncompressed:
|
||||||
file_content = uncompressed.read()
|
file_content = uncompressed.read()
|
||||||
|
logging.info("Caching Clublog cty.xml...")
|
||||||
with open(self.CLUBLOG_XML_DOWNLOAD_LOCATION, "wb") as f:
|
with open(self.CLUBLOG_XML_DOWNLOAD_LOCATION, "wb") as f:
|
||||||
f.write(file_content)
|
f.write(file_content)
|
||||||
f.flush()
|
f.flush()
|
||||||
@@ -160,6 +162,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.
|
||||||
@@ -413,7 +418,12 @@ class LookupHelper:
|
|||||||
# Infer a grid locator from a callsign (using DXCC, probably very inaccurate)
|
# Infer a grid locator from a callsign (using DXCC, probably very inaccurate)
|
||||||
def infer_grid_from_callsign_dxcc(self, call):
|
def infer_grid_from_callsign_dxcc(self, call):
|
||||||
latlon = self.infer_latlon_from_callsign_dxcc(call)
|
latlon = self.infer_latlon_from_callsign_dxcc(call)
|
||||||
return latlong_to_locator(latlon[0], latlon[1], 8)
|
grid = None
|
||||||
|
try:
|
||||||
|
grid = latlong_to_locator(latlon[0], latlon[1], 8)
|
||||||
|
except:
|
||||||
|
logging.debug("Invalid lat/lon received for DXCC")
|
||||||
|
return grid
|
||||||
|
|
||||||
# Infer a mode from the frequency (in Hz) according to the band plan. Just a guess really.
|
# Infer a mode from the frequency (in Hz) according to the band plan. Just a guess really.
|
||||||
def infer_mode_from_frequency(self, freq):
|
def infer_mode_from_frequency(self, freq):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import csv
|
import csv
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from pyhamtools.locator import latlong_to_locator
|
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
|
||||||
|
|
||||||
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
|
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
|
||||||
from core.constants import SIGS, HTTP_HEADERS
|
from core.constants import SIGS, HTTP_HEADERS
|
||||||
@@ -21,7 +21,7 @@ def get_ref_regex_for_sig(sig):
|
|||||||
# Note there is currently no support for KRMNPA location lookup, see issue #61.
|
# Note there is currently no support for KRMNPA location lookup, see issue #61.
|
||||||
def populate_sig_ref_info(sig_ref):
|
def populate_sig_ref_info(sig_ref):
|
||||||
if sig_ref.sig is None or sig_ref.id is None:
|
if sig_ref.sig is None or sig_ref.id is None:
|
||||||
logging.warn("Failed to look up sig_ref info, sig or id were not set.")
|
logging.warning("Failed to look up sig_ref info, sig or id were not set.")
|
||||||
|
|
||||||
sig = sig_ref.sig
|
sig = sig_ref.sig
|
||||||
ref_id = sig_ref.id
|
ref_id = sig_ref.id
|
||||||
@@ -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()
|
||||||
@@ -72,9 +73,9 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
if row["reference"] == ref_id:
|
if row["reference"] == ref_id:
|
||||||
sig_ref.name = row["name"] if "name" in row else None
|
sig_ref.name = row["name"] if "name" in row else None
|
||||||
sig_ref.url = "https://wwff.co/directory/?showRef=" + ref_id
|
sig_ref.url = "https://wwff.co/directory/?showRef=" + ref_id
|
||||||
sig_ref.grid = row["iaruLocator"] if "iaruLocator" in row else None
|
sig_ref.grid = row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None
|
||||||
sig_ref.latitude = float(row["latitude"]) if "latitude" in row else None
|
sig_ref.latitude = float(row["latitude"]) if "latitude" in row and row["latitude"] != "-" else None
|
||||||
sig_ref.longitude = float(row["longitude"]) if "longitude" in row else None
|
sig_ref.longitude = float(row["longitude"]) if "longitude" in row and row["longitude"] != "-" else None
|
||||||
break
|
break
|
||||||
elif sig.upper() == "SIOTA":
|
elif sig.upper() == "SIOTA":
|
||||||
siota_csv_data = SEMI_STATIC_URL_DATA_CACHE.get("https://www.silosontheair.com/data/silos.csv",
|
siota_csv_data = SEMI_STATIC_URL_DATA_CACHE.get("https://www.silosontheair.com/data/silos.csv",
|
||||||
@@ -111,23 +112,45 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
if asset["code"] == ref_id:
|
if asset["code"] == ref_id:
|
||||||
sig_ref.name = asset["name"]
|
sig_ref.name = asset["name"]
|
||||||
sig_ref.url = "https://ontheair.nz/assets/ZLI_OT-030" + ref_id.replace("/", "_")
|
sig_ref.url = "https://ontheair.nz/assets/ZLI_OT-030" + ref_id.replace("/", "_")
|
||||||
sig_ref.grid = latlong_to_locator(asset["y"], asset["x"], 6)
|
try:
|
||||||
sig_ref.latitude = asset["y"]
|
sig_ref.grid = latlong_to_locator(asset["y"], asset["x"], 6)
|
||||||
sig_ref.longitude = asset["x"]
|
except:
|
||||||
|
logging.debug("Invalid lat/lon received for reference")
|
||||||
|
sig_ref.latitude = asset["y"]
|
||||||
|
sig_ref.longitude = asset["x"]
|
||||||
break
|
break
|
||||||
elif sig.upper() == "BOTA":
|
elif sig.upper() == "BOTA":
|
||||||
if not sig_ref.name:
|
if not sig_ref.name:
|
||||||
sig_ref.name = sig_ref.id
|
sig_ref.name = sig_ref.id
|
||||||
sig_ref.url = "https://www.beachesontheair.com/beaches/" + sig_ref.name.lower().replace(" ", "-")
|
sig_ref.url = "https://www.beachesontheair.com/beaches/" + sig_ref.name.lower().replace(" ", "-")
|
||||||
|
elif sig.upper() == "LLOTA":
|
||||||
|
data = SEMI_STATIC_URL_DATA_CACHE.get("https://llota.app/api/public/references", headers=HTTP_HEADERS).json()
|
||||||
|
if data:
|
||||||
|
for ref in data:
|
||||||
|
if ref["reference_code"] == ref_id:
|
||||||
|
sig_ref.name = ref["name"]
|
||||||
|
sig_ref.url = "https://llota.app/list/ref/" + ref_id
|
||||||
|
sig_ref.grid = ref["grid_locator"]
|
||||||
|
ll = locator_to_latlong(sig_ref.grid)
|
||||||
|
sig_ref.latitude = ll[0]
|
||||||
|
sig_ref.longitude = ll[1]
|
||||||
|
break
|
||||||
|
elif sig.upper() == "WWTOTA":
|
||||||
|
if not sig_ref.name:
|
||||||
|
sig_ref.name = sig_ref.id
|
||||||
|
sig_ref.url = "https://wwtota.com/seznam/karta_rozhledny.php?ref=" + sig_ref.name
|
||||||
elif sig.upper() == "WAB" or sig.upper() == "WAI":
|
elif sig.upper() == "WAB" or sig.upper() == "WAI":
|
||||||
ll = wab_wai_square_to_lat_lon(ref_id)
|
ll = wab_wai_square_to_lat_lon(ref_id)
|
||||||
if ll:
|
if ll:
|
||||||
sig_ref.name = ref_id
|
sig_ref.name = ref_id
|
||||||
sig_ref.grid = latlong_to_locator(ll[0], ll[1], 6)
|
try:
|
||||||
sig_ref.latitude = ll[0]
|
sig_ref.grid = latlong_to_locator(ll[0], ll[1], 6)
|
||||||
sig_ref.longitude = ll[1]
|
sig_ref.latitude = ll[0]
|
||||||
|
sig_ref.longitude = ll[1]
|
||||||
|
except:
|
||||||
|
logging.debug("Invalid lat/lon received for reference")
|
||||||
except:
|
except:
|
||||||
logging.warn("Failed to look up sig_ref info for " + sig + " ref " + ref_id + ".")
|
logging.warning("Failed to look up sig_ref info for " + sig + " ref " + ref_id + ".")
|
||||||
return sig_ref
|
return sig_ref
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,13 +47,13 @@ class StatusReporter:
|
|||||||
self.status_data["spot_providers"] = list(
|
self.status_data["spot_providers"] = list(
|
||||||
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||||
"last_updated": p.last_update_time.replace(
|
"last_updated": p.last_update_time.replace(
|
||||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time else 0,
|
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
|
||||||
"last_spot": p.last_spot_time.replace(
|
"last_spot": p.last_spot_time.replace(
|
||||||
tzinfo=pytz.UTC).timestamp() if p.last_spot_time else 0}, self.spot_providers))
|
tzinfo=pytz.UTC).timestamp() if p.last_spot_time.year > 2000 else 0}, self.spot_providers))
|
||||||
self.status_data["alert_providers"] = list(
|
self.status_data["alert_providers"] = list(
|
||||||
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||||
"last_updated": p.last_update_time.replace(
|
"last_updated": p.last_update_time.replace(
|
||||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time else 0},
|
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
||||||
self.alert_providers))
|
self.alert_providers))
|
||||||
self.status_data["cleanup"] = {"status": self.cleanup_timer.status,
|
self.status_data["cleanup"] = {"status": self.cleanup_timer.status,
|
||||||
"last_ran": self.cleanup_timer.last_cleanup_time.replace(
|
"last_ran": self.cleanup_timer.last_cleanup_time.replace(
|
||||||
|
|||||||
@@ -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"...
|
||||||
|
|||||||
@@ -18,3 +18,5 @@ class SIGRef:
|
|||||||
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
|
||||||
24
data/spot.py
24
data/spot.py
@@ -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.
|
||||||
@@ -286,9 +284,13 @@ class Spot:
|
|||||||
|
|
||||||
# DX Grid to lat/lon and vice versa in case one is missing
|
# DX Grid to lat/lon and vice versa in case one is missing
|
||||||
if self.dx_grid and not self.dx_latitude:
|
if self.dx_grid and not self.dx_latitude:
|
||||||
ll = locator_to_latlong(self.dx_grid)
|
try:
|
||||||
self.dx_latitude = ll[0]
|
print(json.dumps(self))
|
||||||
self.dx_longitude = ll[1]
|
ll = locator_to_latlong(self.dx_grid)
|
||||||
|
self.dx_latitude = ll[0]
|
||||||
|
self.dx_longitude = ll[1]
|
||||||
|
except:
|
||||||
|
logging.debug("Invalid grid received for spot")
|
||||||
if self.dx_latitude and self.dx_longitude and not self.dx_grid:
|
if self.dx_latitude and self.dx_longitude and not self.dx_grid:
|
||||||
try:
|
try:
|
||||||
self.dx_grid = latlong_to_locator(self.dx_latitude, self.dx_longitude, 8)
|
self.dx_grid = latlong_to_locator(self.dx_latitude, self.dx_longitude, 8)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
41
spotproviders/llota.py
Normal file
41
spotproviders/llota.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from data.sig_ref import SIGRef
|
||||||
|
from data.spot import Spot
|
||||||
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
|
|
||||||
|
# Spot provider for Lagos y Lagunas On the Air
|
||||||
|
class LLOTA(HTTPSpotProvider):
|
||||||
|
POLL_INTERVAL_SEC = 120
|
||||||
|
SPOTS_URL = "https://llota.app/api/public/spots"
|
||||||
|
|
||||||
|
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():
|
||||||
|
# Find the most recent spotter and comment from the history array
|
||||||
|
comment = None
|
||||||
|
spotter = None
|
||||||
|
if "history" in source_spot and len(source_spot["history"]) > 0:
|
||||||
|
comment = source_spot["history"][-1]["comment"]
|
||||||
|
spotter = source_spot["history"][-1]["spotter_callsign"]
|
||||||
|
# Convert to our spot format
|
||||||
|
spot = Spot(source=self.name,
|
||||||
|
source_id=source_spot["id"],
|
||||||
|
dx_call=source_spot["callsign"].upper(),
|
||||||
|
de_call=spotter.upper() if spotter else None,
|
||||||
|
freq=float(source_spot["frequency"]) * 1000000,
|
||||||
|
mode=source_spot["mode"].upper(),
|
||||||
|
comment=comment,
|
||||||
|
sig="LLOTA",
|
||||||
|
sig_refs=[SIGRef(id=source_spot["reference"], sig="LLOTA", name=source_spot["reference_name"])],
|
||||||
|
time=datetime.fromisoformat(source_spot["updated_at"].replace("Z", "+00:00")).timestamp())
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
return new_spots
|
||||||
@@ -11,8 +11,6 @@ from spotproviders.http_spot_provider import HTTPSpotProvider
|
|||||||
class POTA(HTTPSpotProvider):
|
class POTA(HTTPSpotProvider):
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
SPOTS_URL = "https://api.pota.app/spot/activator"
|
SPOTS_URL = "https://api.pota.app/spot/activator"
|
||||||
# Might need to look up extra park data
|
|
||||||
PARK_URL_ROOT = "https://api.pota.app/park/"
|
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
41
spotproviders/wwtota.py
Normal file
41
spotproviders/wwtota.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from data.sig_ref import SIGRef
|
||||||
|
from data.spot import Spot
|
||||||
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
|
|
||||||
|
# Spot provider for Towers on the Air
|
||||||
|
class WWTOTA(HTTPSpotProvider):
|
||||||
|
POLL_INTERVAL_SEC = 120
|
||||||
|
SPOTS_URL = "https://wwtota.com/api/cluster_live.php"
|
||||||
|
|
||||||
|
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 = []
|
||||||
|
response_fixed = http_response.text.replace("\\/", "/")
|
||||||
|
response_json = json.loads(response_fixed)
|
||||||
|
|
||||||
|
# Iterate through source data
|
||||||
|
for source_spot in response_json["spots"]:
|
||||||
|
# Convert to our spot format
|
||||||
|
likely_freq = float(source_spot["freq"]) * 1000
|
||||||
|
if likely_freq < 1000000:
|
||||||
|
likely_freq = likely_freq * 1000
|
||||||
|
spot = Spot(source=self.name,
|
||||||
|
dx_call=source_spot["call"].upper(),
|
||||||
|
freq=likely_freq,
|
||||||
|
comment=source_spot["comment"],
|
||||||
|
sig="WWTOTA",
|
||||||
|
sig_refs=[SIGRef(id=source_spot["ref"], sig="WWTOTA")],
|
||||||
|
time=datetime.strptime(response_json["updated"][:10] + source_spot["time"], "%Y-%m-%d%H:%M").timestamp())
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
return new_spots
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -25,10 +25,10 @@
|
|||||||
<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 and looking for callers. 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 entered the spot of the "DX" operator. "Modes" are the type of communication they are using. For example 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 and looking for callers. 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 entered the spot of the "DX" operator. "Modes" are the type of communication they are using. For example 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>
|
<h4 class="mt-4">What data sources are supported?</h4>
|
||||||
<p>Spothole can retrieve spots from: <a href="https://www.dxcluster.info/telnet/">Telnet-based DX clusters</a>, the <a href="https://www.reversebeacon.net/">Reverse Beacon Network (RBN)</a>, the <a href="https://www.aprs-is.net/">APRS Internet Service (APRS-IS)</a>, <a href="https://pota.app">POTA</a>, <a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a href="https://www.cqgma.org/">GMA</a>, <a href="https://wwbota.net/">WWBOTA</a>, <a href="http://www.hema.org.uk/">HEMA</a>, <a href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://ontheair.nz">ZLOTA</a>, <a href="https://www.wota.org.uk/">WOTA</a>, the <a href="https://ukpacketradio.network/">UK Packet Repeater Network</a>, and any site based on the <a href="https://github.com/nischu/xOTA">xOTA software by nischu</a>.</p>
|
<p>Spothole can retrieve spots from: <a href="https://www.dxcluster.info/telnet/">Telnet-based DX clusters</a>, the <a href="https://www.reversebeacon.net/">Reverse Beacon Network (RBN)</a>, the <a href="https://www.aprs-is.net/">APRS Internet Service (APRS-IS)</a>, <a href="https://pota.app">POTA</a>, <a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a href="https://www.cqgma.org/">GMA</a>, <a href="https://wwbota.net/">WWBOTA</a>, <a href="http://www.hema.org.uk/">HEMA</a>, <a href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://ontheair.nz">ZLOTA</a>, <a href="https://www.wota.org.uk/">WOTA</a>, <a href="https://llota.app">LLOTA</a>, <a href="https://wwtota.com">WWTOTA</a>, the <a href="https://ukpacketradio.network/">UK Packet Repeater Network</a>, and any site based on the <a href="https://github.com/nischu/xOTA">xOTA software by nischu</a>.</p>
|
||||||
<p>Spothole can retrieve alerts from: <a href="https://www.ng3k.com/">NG3K</a>, <a href="https://pota.app">POTA</a>, <a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://www.wota.org.uk/">WOTA</a> and <a href="https://www.beachesontheair.com/">BOTA</a>.</p>
|
<p>Spothole can retrieve alerts from: <a href="https://www.ng3k.com/">NG3K</a>, <a href="https://pota.app">POTA</a>, <a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://www.wota.org.uk/">WOTA</a> and <a href="https://www.beachesontheair.com/">BOTA</a>.</p>
|
||||||
<p>Note that the server owner has not necessarily enabled all these data sources. In particular it is common to disable RBN, to avoid the server being swamped with FT8 traffic, and to disable APRS-IS and UK Packet Net so that the server only displays stations where there is likely to be an operator physically present for a QSO.</p>
|
<p>Note that the server owner has not necessarily enabled all these data sources. In particular it is common to disable RBN, to avoid the server being swamped with FT8 traffic, and to disable APRS-IS and UK Packet Net so that the server only displays stations where there is likely to be an operator physically present for a QSO.</p>
|
||||||
<p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: Parks on the Air (POTA), Summits on the Air (SOTA), Worldwide Flora & Fauna (WWFF), Global Mountain Activity (GMA), Worldwide Bunkers on the Air (WWBOTA), HuMPs Excluding Marilyns Award (HEMA), Islands on the Air (IOTA), Mills on the Air (MOTA), the Amateur Radio Lighthouse Socirty (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National Parks Award (KRMNPA), Wainwrights on the Air (WOTA), Beaches on the Air (BOTA), Worked All Britain (WAB), Worked All Ireland (WAI), and Toilets on the Air (TOTA).</p>
|
<p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: Parks on the Air (POTA), Summits on the Air (SOTA), Worldwide Flora & Fauna (WWFF), Global Mountain Activity (GMA), Worldwide Bunkers on the Air (WWBOTA), HuMPs Excluding Marilyns Award (HEMA), Islands on the Air (IOTA), Mills on the Air (MOTA), the Amateur Radio Lighthouse Socirty (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National Parks Award (KRMNPA), Wainwrights on the Air (WOTA), Beaches on the Air (BOTA), Lagos y Lagunas On the Air (LLOTA), Towers on the Air (WWTOTA), Worked All Britain (WAB), Worked All Ireland (WAI), and Toilets on the Air (TOTA).</p>
|
||||||
<p>As of the time of writing in November 2025, I think Spothole captures essentially all outdoor radio programmes that have a defined reference list, and almost certainly those that have a spotting/alerting API. If you know of one I've missed, please let me know!</p>
|
<p>As of the time of writing in November 2025, I think Spothole captures essentially all outdoor radio programmes that have a defined reference list, and almost certainly those that have a spotting/alerting API. If you know of one I've missed, please let me know!</p>
|
||||||
<h4 class="mt-4">Why can I filter spots by both SIG and Source? Isn't that basically the same thing?</h4>
|
<h4 class="mt-4">Why can I filter spots by both SIG and Source? Isn't that basically the same thing?</h4>
|
||||||
<p>Mostly, but not quite. While POTA spots generally come from the POTA source and so on, there are a few exceptions:</p>
|
<p>Mostly, but not quite. While POTA spots generally come from the POTA source and so on, there are a few exceptions:</p>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -17,17 +17,17 @@
|
|||||||
<p class="d-inline-flex gap-1">
|
<p class="d-inline-flex gap-1">
|
||||||
<span class="btn-group" role="group">
|
<span class="btn-group" role="group">
|
||||||
<input type="radio" class="btn-check" name="runPause" id="runButton" autocomplete="off" checked>
|
<input type="radio" class="btn-check" name="runPause" id="runButton" autocomplete="off" checked>
|
||||||
<label class="btn btn-outline-primary" for="runButton"><i class="fa-solid fa-play"></i> Run</label>
|
<label class="btn btn-outline-primary" for="runButton"><i class="fa-solid fa-play"></i><span class="hideonmobile"> Run</span></label>
|
||||||
|
|
||||||
<input type="radio" class="btn-check" name="runPause" id="pauseButton" autocomplete="off">
|
<input type="radio" class="btn-check" name="runPause" id="pauseButton" autocomplete="off">
|
||||||
<label class="btn btn-outline-primary" for="pauseButton"><i class="fa-solid fa-pause"></i> Pause</label>
|
<label class="btn btn-outline-primary" for="pauseButton"><i class="fa-solid fa-pause"></i><span class="hideonmobile"> Pause</span></label>
|
||||||
</span>
|
</span>
|
||||||
<span class="hideonmobile" style="position: relative;">
|
<span style="position: relative;">
|
||||||
<i id="searchicon" class="fa-solid fa-magnifying-glass"></i>
|
<i id="searchicon" class="fa-solid fa-magnifying-glass"></i>
|
||||||
<input id="search" type="search" class="form-control" oninput="filtersUpdated();" placeholder="Search">
|
<input id="search" type="search" class="form-control" oninput="filtersUpdated();" placeholder="Search">
|
||||||
</span>
|
</span>
|
||||||
<button id="filters-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i> Filters</button>
|
<button id="filters-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i><span class="hideonmobile"> Filters</span></button>
|
||||||
<button id="display-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i> Display</button>
|
<button id="display-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i><span class="hideonmobile"> Display</span></button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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 %}
|
||||||
@@ -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
10
webassets/.idea/.gitignore
generated
vendored
Normal 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
6
webassets/.idea/vcs.xml
generated
Normal 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>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
openapi: 3.0.4
|
$schema: "https://spec.openapis.org/oas/3.1.0"
|
||||||
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: Spothole API
|
title: Spothole API
|
||||||
description: |-
|
description: |-
|
||||||
@@ -14,7 +15,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 +558,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
|
||||||
@@ -743,6 +752,8 @@ components:
|
|||||||
- ParksNPeaks
|
- ParksNPeaks
|
||||||
- ZLOTA
|
- ZLOTA
|
||||||
- WOTA
|
- WOTA
|
||||||
|
- LLOTA
|
||||||
|
- WWTOTA
|
||||||
- Cluster
|
- Cluster
|
||||||
- RBN
|
- RBN
|
||||||
- APRS-IS
|
- APRS-IS
|
||||||
@@ -768,6 +779,8 @@ components:
|
|||||||
- IOTA
|
- IOTA
|
||||||
- WOTA
|
- WOTA
|
||||||
- BOTA
|
- BOTA
|
||||||
|
- LLOTA
|
||||||
|
- WWTOTA
|
||||||
- WAB
|
- WAB
|
||||||
- WAI
|
- WAI
|
||||||
- TOTA
|
- TOTA
|
||||||
@@ -792,6 +805,8 @@ components:
|
|||||||
- IOTA
|
- IOTA
|
||||||
- WOTA
|
- WOTA
|
||||||
- BOTA
|
- BOTA
|
||||||
|
- LLOTA
|
||||||
|
- WWTOTA
|
||||||
- WAB
|
- WAB
|
||||||
- WAI
|
- WAI
|
||||||
- TOTA
|
- TOTA
|
||||||
@@ -856,7 +871,6 @@ components:
|
|||||||
- DSTAR
|
- DSTAR
|
||||||
- C4FM
|
- C4FM
|
||||||
- M17
|
- M17
|
||||||
- DIGI
|
|
||||||
- DATA
|
- DATA
|
||||||
- FT8
|
- FT8
|
||||||
- FT4
|
- FT4
|
||||||
@@ -864,12 +878,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 +951,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 +1101,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 +1205,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.
|
||||||
@@ -1233,11 +1240,11 @@ components:
|
|||||||
example: OK
|
example: OK
|
||||||
last_updated:
|
last_updated:
|
||||||
type: number
|
type: number
|
||||||
description: The last time at which this provider received data, UTC seconds since UNIX epoch.
|
description: The last time at which this provider received data, UTC seconds since UNIX epoch. If this is zero, the spot provider has never updated.
|
||||||
example: 1759579508
|
example: 1759579508
|
||||||
last_spot:
|
last_spot:
|
||||||
type: number
|
type: number
|
||||||
description: The time of the latest spot received by this provider, UTC seconds since UNIX epoch.
|
description: The time of the latest spot received by this provider, UTC seconds since UNIX epoch. If this is zero, the spot provider has never received a spot that was accepted by the system.
|
||||||
example: 1759579508
|
example: 1759579508
|
||||||
|
|
||||||
AlertProviderStatus:
|
AlertProviderStatus:
|
||||||
@@ -1256,7 +1263,7 @@ components:
|
|||||||
example: OK
|
example: OK
|
||||||
last_updated:
|
last_updated:
|
||||||
type: number
|
type: number
|
||||||
description: The last time at which this provider received data, UTC seconds since UNIX epoch.
|
description: The last time at which this provider received data, UTC seconds since UNIX epoch. If this is zero, the alert provider has never updated.
|
||||||
example: 1759579508
|
example: 1759579508
|
||||||
|
|
||||||
Band:
|
Band:
|
||||||
|
|||||||
@@ -349,6 +349,9 @@ div.band-spot:hover span.band-spot-info {
|
|||||||
max-height: 26em;
|
max-height: 26em;
|
||||||
overflow: scroll;
|
overflow: scroll;
|
||||||
}
|
}
|
||||||
|
input#search {
|
||||||
|
max-width: 7em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 992px) {
|
@media (min-width: 992px) {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function loadSpots() {
|
|||||||
// Build a query string for the API, based on the filters that the user has selected.
|
// Build a query string for the API, based on the filters that the user has selected.
|
||||||
function buildQueryString() {
|
function buildQueryString() {
|
||||||
var str = "?";
|
var str = "?";
|
||||||
["dx_continent", "de_continent", "mode_type", "source", "band", "sig"].forEach(fn => {
|
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
|
||||||
if (!allFilterOptionsSelected(fn)) {
|
if (!allFilterOptionsSelected(fn)) {
|
||||||
str = str + getQueryStringFor(fn) + "&";
|
str = str + getQueryStringFor(fn) + "&";
|
||||||
}
|
}
|
||||||
@@ -251,8 +251,8 @@ function loadOptions() {
|
|||||||
generateSIGsMultiToggleFilterCard(options["sigs"]);
|
generateSIGsMultiToggleFilterCard(options["sigs"]);
|
||||||
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"]);
|
generateModesMultiToggleFilterCard(options["modes"]);
|
||||||
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
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function loadURLParams() {
|
|||||||
updateFilterFromParam(params, "band", "band");
|
updateFilterFromParam(params, "band", "band");
|
||||||
updateFilterFromParam(params, "sig", "sig");
|
updateFilterFromParam(params, "sig", "sig");
|
||||||
updateFilterFromParam(params, "source", "source");
|
updateFilterFromParam(params, "source", "source");
|
||||||
updateFilterFromParam(params, "mode_type", "mode_type");
|
updateFilterFromParam(params, "mode", "mode");
|
||||||
updateFilterFromParam(params, "dx_continent", "dx_continent");
|
updateFilterFromParam(params, "dx_continent", "dx_continent");
|
||||||
updateFilterFromParam(params, "de_continent", "de_continent");
|
updateFilterFromParam(params, "de_continent", "de_continent");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function loadSpots() {
|
|||||||
// Build a query string for the API, based on the filters that the user has selected.
|
// Build a query string for the API, based on the filters that the user has selected.
|
||||||
function buildQueryString() {
|
function buildQueryString() {
|
||||||
var str = "?";
|
var str = "?";
|
||||||
["dx_continent", "de_continent", "mode_type", "source", "band", "sig"].forEach(fn => {
|
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
|
||||||
if (!allFilterOptionsSelected(fn)) {
|
if (!allFilterOptionsSelected(fn)) {
|
||||||
str = str + getQueryStringFor(fn) + "&";
|
str = str + getQueryStringFor(fn) + "&";
|
||||||
}
|
}
|
||||||
@@ -183,8 +183,8 @@ function loadOptions() {
|
|||||||
generateSIGsMultiToggleFilterCard(options["sigs"]);
|
generateSIGsMultiToggleFilterCard(options["sigs"]);
|
||||||
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"]);
|
generateModesMultiToggleFilterCard(options["modes"]);
|
||||||
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
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function updateTimingDisplayRunPause() {
|
|||||||
// Build a query string for the API, based on the filters that the user has selected.
|
// Build a query string for the API, based on the filters that the user has selected.
|
||||||
function buildQueryString() {
|
function buildQueryString() {
|
||||||
var str = "?";
|
var str = "?";
|
||||||
["dx_continent", "de_continent", "mode_type", "source", "band", "sig"].forEach(fn => {
|
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
|
||||||
if (!allFilterOptionsSelected(fn)) {
|
if (!allFilterOptionsSelected(fn)) {
|
||||||
str = str + getQueryStringFor(fn) + "&";
|
str = str + getQueryStringFor(fn) + "&";
|
||||||
}
|
}
|
||||||
@@ -421,8 +421,8 @@ function loadOptions() {
|
|||||||
generateSIGsMultiToggleFilterCard(options["sigs"]);
|
generateSIGsMultiToggleFilterCard(options["sigs"]);
|
||||||
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"]);
|
generateModesMultiToggleFilterCard(options["modes"]);
|
||||||
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
|
||||||
|
|||||||
@@ -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,63 @@ 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> <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> <button id="filter-button-sig-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('sig', false);">None</button></span>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate modes filter card. This one is also a special case.
|
||||||
|
function generateModesMultiToggleFilterCard(mode_options) {
|
||||||
|
// Create a button for each option
|
||||||
|
mode_options.forEach(o => {
|
||||||
|
var domSafeName = o.replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||||
|
$("#mode-options").append(`<input type="checkbox" class="btn-check filter-button-mode storeable-checkbox" name="options" id="filter-button-mode-${domSafeName}" value="${o}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-primary" id="filter-button-label-mode-${domSafeName}" for="filter-button-mode-${domSafeName}">${o}</label> `);
|
||||||
|
});
|
||||||
|
// Create All/None buttons
|
||||||
|
$("#mode-options").append(` <span style="display: inline-block"><button id="filter-button-mode-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('mode', true);">All</button> <button id="filter-button-mode-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('mode', false);">None</button></span>`);
|
||||||
|
// Create category buttons
|
||||||
|
$("#mode-options").append(` <button id="filter-button-mode-av" type="button" class="btn btn-outline-secondary" onclick="toggleAnalogVoiceModeToggles();">Analog Voice</button> <button id="filter-button-mode-dv" type="button" class="btn btn-outline-secondary" onclick="toggleDigitalVoiceModeToggles();">Digital Voice</button> <button id="filter-button-mode-digi" type="button" class="btn btn-outline-secondary" onclick="toggleDigiModeToggles();">Digimodes</button></span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle the mode toggles that relate to Analog Voice.
|
||||||
|
function toggleAnalogVoiceModeToggles() {
|
||||||
|
toggleToggles("mode", ["PHONE", "SSB", "LSB", "USB", "AM", "FM"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle the mode toggles that relate to Digital Voice.
|
||||||
|
function toggleDigitalVoiceModeToggles() {
|
||||||
|
toggleToggles("mode", ["DV", "DMR", "DSTAR", "C4FM", "M17"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle the mode toggles that relate to Digimodes.
|
||||||
|
function toggleDigiModeToggles() {
|
||||||
|
toggleToggles("mode", ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle the a set of toggles of the given type (e.g. "mode") that match the given values (e.g. ["SSB", "AM", "FM"]).
|
||||||
|
function toggleToggles(type, values) {
|
||||||
|
let toggle = null;
|
||||||
|
$(".filter-button-" + type).each(function() {
|
||||||
|
console.log($(this));
|
||||||
|
if (values.includes($(this).val().replace("filter-button-" + type, ""))) {
|
||||||
|
if (toggle == null) {
|
||||||
|
toggle = !$(this).prop('checked');
|
||||||
|
}
|
||||||
|
$(this).prop('checked', toggle);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
filtersUpdated();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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> <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();
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ function loadStatus() {
|
|||||||
jsonData["spot_providers"].forEach(p => {
|
jsonData["spot_providers"].forEach(p => {
|
||||||
$("#status-container").append(generateStatusCard("Spot Provider: " + p["name"], [
|
$("#status-container").append(generateStatusCard("Spot Provider: " + p["name"], [
|
||||||
`Status: ${p["status"]}`,
|
`Status: ${p["status"]}`,
|
||||||
`Last Updated: ${p["enabled"] ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}`,
|
`Last Updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}`,
|
||||||
`Latest Spot: ${p["enabled"] ? moment.unix(p["last_spot"]).utc().fromNow() : "N/A"}`
|
`Latest Spot: ${(p["enabled"] && p["last_spot"] > 0) ? moment.unix(p["last_spot"]).utc().fromNow() : "N/A"}`
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
jsonData["alert_providers"].forEach(p => {
|
jsonData["alert_providers"].forEach(p => {
|
||||||
$("#status-container").append(generateStatusCard("Alert Provider: " + p["name"], [
|
$("#status-container").append(generateStatusCard("Alert Provider: " + p["name"], [
|
||||||
`Status: ${p["status"]}`,
|
`Status: ${p["status"]}`,
|
||||||
`Last Updated: ${p["enabled"] ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}`
|
`Last Updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}`
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user