Compare commits

...

6 Commits

15 changed files with 277 additions and 56 deletions

View File

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

View File

@@ -77,6 +77,10 @@ spot-providers:
name: "RBN FT8"
enabled: false
port: 7001
-
class: "UKPacketNet"
name: "UK Packet Radio Net"
enabled: false
# Alert providers to use. Same setup as the spot providers list above.
alert-providers:

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,4 +10,5 @@ diskcache~=5.6.3
psutil~=7.1.0
requests-sse~=0.5.2
rss-parser~=2.1.1
pyproj~=3.7.2
pyproj~=3.7.2
prometheus_client~=0.23.1

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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