14 Commits

Author SHA1 Message Date
Ian Renton
ca31d23b4a Defensive coding 2025-11-29 16:15:49 +00:00
Ian Renton
8a4f23ac72 Improve expired spot handling and efficiency of handling expired spots during web requests. 2025-11-29 16:12:44 +00:00
Ian Renton
3da8c80ad6 Defensive coding 2025-11-29 15:50:55 +00:00
Ian Renton
0fa8b44c9c Defensive coding 2025-11-29 15:04:19 +00:00
Ian Renton
4aa7b91092 Fix a bug where a spot with no DX lat/lon could still be marked as having "good location" 2025-11-29 15:01:05 +00:00
Ian Renton
e7469db99e README updates 2025-11-29 11:58:41 +00:00
Ian Renton
9d9f4609f0 Doc tweaks 2025-11-26 22:12:20 +00:00
Ian Renton
368e69bf00 Use tower-cell icon for cluster/unknown spots rather than the desktop icon 2025-11-26 21:49:11 +00:00
Ian Renton
9bdd0ab1de Add filtering based on SIG to the web UI. #84 2025-11-26 21:43:10 +00:00
Ian Renton
255719f3b5 Add a special 'NO_SIG' option to 'sig' query params, which will allow us to filter out all xOTA spots/alerts, leaving just the generic ones. #84 2025-11-26 21:13:14 +00:00
Ian Renton
f21ea0ae5d Remove duplicated enums in spec #83 2025-11-26 20:29:35 +00:00
Ian Renton
2be2af176c Merge branch '82-tota'
# Conflicts:
#	webassets/apidocs/openapi.yml
2025-11-26 20:29:05 +00:00
Ian Renton
0c8973bbc6 Remove duplicated enums in spec #83 2025-11-25 22:03:09 +00:00
Ian Renton
296cdb3795 Wider ranges to detect FT8/FT4 in "Guess mode based on frequency" function #85 2025-11-25 21:32:48 +00:00
18 changed files with 438 additions and 521 deletions

View File

@@ -34,7 +34,7 @@ deactivate
cp config-example.yml config.yml cp config-example.yml config.yml
``` ```
Then edit `config.yml` in your text editor of choice to set up the software as you like it. Then edit `config.yml` in your text editor of choice to set up the software as you like it. Mostly, this will involve enabling or disabling the various providers of spot and alert data.
`config.yml` has some entries for QRZ.com username & password, and Clublog API keys. If provided, these allow Spothole to retrieve more information about DX spots, such as the country their callsign corresponds to. The software will work just fine without them, but you may find a few country flags etc. are less accurate or missing. `config.yml` has some entries for QRZ.com username & password, and Clublog API keys. If provided, these allow Spothole to retrieve more information about DX spots, such as the country their callsign corresponds to. The software will work just fine without them, but you may find a few country flags etc. are less accurate or missing.
@@ -57,6 +57,8 @@ If you see some errors on startup, check your configuration, e.g. in case you ha
### systemd configuration ### systemd configuration
If you want Spothole to run automatically on startup on a Linux distribution that uses `systemd`, follow the instructions here. For distros that don't use `systemd`, or Windows/OSX/etc., you can find generic instructions for your OS online.
Create a file at `/etc/systemd/system/spothole.service`. Give it the following content, adjusting for the user you want to run it as and the directory in which you have installed it: Create a file at `/etc/systemd/system/spothole.service`. Give it the following content, adjusting for the user you want to run it as and the directory in which you have installed it:
``` ```
@@ -87,7 +89,9 @@ Check the service has started up correctly with `sudo journalctl -u spothole -f`
### nginx Reverse Proxy configuration ### nginx Reverse Proxy configuration
It's best not to serve Spothole directly on port 80, as that requires root privileges and prevents us using HTTPS, amongst other reasons. To set up nginx as a reverse proxy that sits in front of Spothole, first ensure it's installed e.g. `sudo apt install nginx`, and enabled e.g. `sudo systemd enable nginx`. Web servers generally serve their pages from port 80. However, it's best not to serve Spothole's web interface directly on port 80, as that requires root privileges on a Linux system. It also and prevents us using HTTPS to serve a secure site, since Spothole itself doesn't directly support acting as an HTTPS server. The normal solution to this is to use a "reverse proxy" setup, where a general web server handles HTTP and HTTP requests (to port 80 & 443 respectively), then passes on the request to the back-end application (in this case Spothole). nginx is a common choice for this general web server.
To set up nginx as a reverse proxy that sits in front of Spothole, first ensure it's installed e.g. `sudo apt install nginx`, and enabled e.g. `sudo systemd enable nginx`.
Create a file at `/etc/nginx/sites-available/` called `spothole`. Give it the following contents, replacing `spothole.app` with the domain name on which you want to run Spothole. If you changed the port on which Spothole runs, update that on the "proxy_pass" line too. Create a file at `/etc/nginx/sites-available/` called `spothole`. Give it the following contents, replacing `spothole.app` with the domain name on which you want to run Spothole. If you changed the port on which Spothole runs, update that on the "proxy_pass" line too.
@@ -140,8 +144,8 @@ Once that's working, [install certbot](https://certbot.eff.org/instructions?ws=n
Various approaches exist to writing your own client, but in general: Various approaches exist to writing your own client, but in general:
* Refer to the API docs. These are built on an OpenAPI definition file (`/webassets/apidocs/openapi.yml`), which you can automatically use to generate a client skeleton using various software. * Refer to the API docs. These are built on an OpenAPI definition file (`/webassets/apidocs/openapi.yml`), which you can automatically use to generate a client skeleton using various software.
* Call the main "spots" API to get the data you want. Apply filters if necessary. * Call the main "spots" or "alerts" API endpoints to get the data you want. Apply filters if necessary.
* Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do that first before calling the spots API. * Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do that first before calling the spots/alerts APIs, to allow you to populate your filters correctly.
* Refer to the provided HTML/JS interface for a reference * Refer to the provided HTML/JS interface for a reference
* Let me know if you get stuck, I'm happy to help! * Let me know if you get stuck, I'm happy to help!
@@ -178,19 +182,21 @@ To navigate your way around the source code, this list may help.
### Extending the server ### Extending the server
Spothole is designed to be easily extensible. If you want to write your own provider, simply add a module to the `providers` package containing your class. (Currently, in order to be loaded correctly, the module (file) name should be the same as the class name, but lower case.) Spothole is designed to be easily extensible. If you want to write your own spot provider, for example, simply add a module to the `spotproviders` package containing your class. (Currently, in order to be loaded correctly, the module (file) name should be the same as the class name, but lower case.)
Your class should extend "Provider"; if it operates by polling an HTTP Server on a timer, it can instead extend "HTTPProvider" where some of the work is done for you. Your class should extend "SpotProvider"; if it operates by polling an HTTP Server on a timer, it can instead extend "HTTPSpotProvider" where some of the work is done for you.
The class will need to implement a constructor that takes in the `provider_config` and provides it to the superclass constructor, while also taking any other config parameters it needs. The class will need to implement a constructor that takes in the `provider_config` and provides it to the superclass constructor, while also taking any other config parameters it needs.
If you're extending the base `Provider` class, you will need to implement `start()` and `stop()` methods that start and stop a separate thread which handles the provider's processing needs. The thread should call `submit()` or `submit_batch()` when it has one or more spots to report. If you're extending the base `SpotProvider` class, you will need to implement `start()` and `stop()` methods that start and stop a separate thread which handles the provider's processing needs. The thread should call `submit()` or `submit_batch()` when it has one or more spots to report.
If you're extending the `HTTPProvider` class, you will need to provide a URI to query and an interval to the superclass constructor. You'll then need to implement the `http_response_to_spots()` method which is called when new data is retrieved. Your implementation should then call `submit()` or `submit_batch()` when it has one or more spots to report. If you're extending the `HTTPSpotProvider` class, you will need to provide a URI to query and an interval to the superclass constructor. You'll then need to implement the `http_response_to_spots()` method which is called when new data is retrieved. Your implementation should then call `submit()` or `submit_batch()` when it has one or more spots to report.
When constructing spots, use the comments in the Spot class and the existing implementations as an example. All parameters are optional, but you will at least want to provide a `time` (which must be timezone-aware) and a `dx_call`. When constructing spots, use the comments in the Spot class and the existing implementations as an example. All parameters are optional, but you will at least want to provide a `time` (which must be timezone-aware) and a `dx_call`.
Finally, simply add the appropriate config to the `providers` section of `config.yml`, and your provider should be instantiated on startup. Finally, simply add the appropriate config to the `spot_providers` section of `config.yml`, and your provider should be instantiated on startup.
The same approach as above is also used for alert providers.
### Thanks ### Thanks

View File

@@ -29,16 +29,27 @@ class CleanupTimer:
# Perform cleanup and reschedule next timer # Perform cleanup and reschedule next timer
def cleanup(self): def cleanup(self):
try: try:
# Perform cleanup # Perform cleanup via letting the data expire
self.spots.expire() self.spots.expire()
self.alerts.expire() self.alerts.expire()
# Alerts can persist in the system for a while, so we want to explicitly clean up any alerts that have # Explicitly clean up any spots and alerts that have expired
# expired for id in list(self.spots.iterkeys()):
try:
spot = self.spots[id]
if spot.expired():
self.spots.delete(id)
except KeyError:
# Must have already been deleted, OK with that
pass
for id in list(self.alerts.iterkeys()): for id in list(self.alerts.iterkeys()):
alert = self.alerts[id] try:
if alert.expired(): alert = self.alerts[id]
self.alerts.delete(id) if alert.expired():
self.alerts.delete(id)
except KeyError:
# Must have already been deleted, OK with that
pass
self.status = "OK" self.status = "OK"
self.last_cleanup_time = datetime.now(pytz.UTC) self.last_cleanup_time = datetime.now(pytz.UTC)

View File

@@ -418,7 +418,20 @@ class LookupHelper:
# 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):
try: try:
return freq_to_band(freq / 1000.0)["mode"] khz = freq / 1000.0
mode = freq_to_band(khz)["mode"]
# Some additional common digimode ranges in addition to what the 3rd-party freq_to_band function returns.
# This is mostly here just because freq_to_band is very specific about things like FT8 frequencies, and e.g.
# a spot at 7074.5 kHz will be indicated as LSB, even though it's clearly in the FT8 range. Future updates
# might include other common digimode centres of activity here, but this achieves the main goal of keeping
# large numbers of clearly-FT* spots off the list of people filtering out digimodes.
if (7074 <= khz < 7077) or (10136 <= khz < 10139) or (14074 <= khz < 14077) or (18100 <= khz < 18103) or (
21074 <= khz < 21077) or (24915 <= khz < 24918) or (28074 <= khz < 28077):
mode = "FT8"
if (7047.5 <= khz < 7050.5) or (10140 <= khz < 10143) or (14080 <= khz < 14083) or (
18104 <= khz < 18107) or (21140 <= khz < 21143) or (24919 <= khz < 24922) or (28180 <= khz < 28183):
mode = "FT4"
return mode
except KeyError: except KeyError:
return None return None
@@ -442,6 +455,11 @@ class LookupHelper:
# QRZ had no info for the call, that's OK. Cache a None so we don't try to look this up again # QRZ had no info for the call, that's OK. Cache a None so we don't try to look this up again
self.QRZ_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds self.QRZ_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
return None return None
except (Exception):
# General exception like a timeout when communicating with QRZ. Return None this time, but don't cache
# that, so we can try again next time.
logging.error("Exception when looking up QRZ data")
return None
else: else:
return None return None

View File

@@ -137,7 +137,7 @@ class Alert:
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True) return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
# Decide if this alert has expired (in which case it should not be added to the system in the first place, and not # Decide if this alert has expired (in which case it should not be added to the system in the first place, and not
# returned by the web server if later requested, and removed by the cleanup functions. "Expired" is defined as # returned by the web server if later requested, and removed by the cleanup functions). "Expired" is defined as
# either having an end_time in the past, or if it only has a start_time, then that start time was more than 3 hours # either having an end_time in the past, or if it only has a start_time, then that start time was more than 3 hours
# ago. If it somehow doesn't have a start_time either, it is considered to be expired. # ago. If it somehow doesn't have a start_time either, it is considered to be expired.
def expired(self): def expired(self):

View File

@@ -4,11 +4,12 @@ import json
import logging import logging
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime, timedelta
import pytz 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.lookup_helper import lookup_helper from core.lookup_helper import lookup_helper
from core.sig_utils import get_icon_for_sig, get_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig from core.sig_utils import get_icon_for_sig, get_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
@@ -243,13 +244,15 @@ class Spot:
if not self.sig and self.sig_refs and len(self.sig_refs) > 0: if not self.sig and self.sig_refs and len(self.sig_refs) > 0:
self.sig = self.sig_refs[0].sig.upper() self.sig = self.sig_refs[0].sig.upper()
# See if we already have a SIG reference, but the comment looks like it contains more for the same SIG. This # See if we already have a SIG reference, but the comment looks like it contains more for the same SIG. This
# should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002". # should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002".
if self.comment and self.sig_refs and len(self.sig_refs) > 0: if self.comment and self.sig_refs and len(self.sig_refs) > 0 and self.sig_refs[0].sig:
sig = self.sig_refs[0].sig.upper() sig = self.sig_refs[0].sig.upper()
all_comment_ref_matches = re.finditer(r"(^|\W)(" + get_ref_regex_for_sig(sig) + r")(^|\W)", self.comment, re.IGNORECASE) regex = get_ref_regex_for_sig(sig)
for ref_match in all_comment_ref_matches: if regex:
self.append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig)) all_comment_ref_matches = re.finditer(r"(^|\W)(" + regex + r")(^|\W)", self.comment, re.IGNORECASE)
for ref_match in all_comment_ref_matches:
self.append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig))
# See if the comment looks like it contains any SIGs (and optionally SIG references) that we can # See if the comment looks like it contains any SIGs (and optionally SIG references) that we can
# add to the spot. This should catch cluster spot comments like "POTA GB-0001 WWFF GFF-0001" and e.g. POTA # add to the spot. This should catch cluster spot comments like "POTA GB-0001 WWFF GFF-0001" and e.g. POTA
@@ -300,6 +303,10 @@ class Spot:
if self.sig: if self.sig:
self.icon = get_icon_for_sig(self.sig) self.icon = get_icon_for_sig(self.sig)
# Default "radio" icon if nothing else has set it
if not self.icon:
self.icon = "tower-cell"
# 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) ll = locator_to_latlong(self.dx_grid)
@@ -348,9 +355,10 @@ class Spot:
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator # DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
# is likely at home. # is likely at home.
self.dx_location_good = (self.dx_location_source == "SPOT" or self.dx_location_source == "SIG REF LOOKUP" self.dx_location_good = self.dx_latitude and self.dx_longitude and (
or self.dx_location_source == "WAB/WAI GRID" self.dx_location_source == "SPOT" or self.dx_location_source == "SIG REF LOOKUP"
or (self.dx_location_source == "HOME QTH" and not "/" in self.dx_call)) or self.dx_location_source == "WAB/WAI GRID"
or (self.dx_location_source == "HOME QTH" and not "/" in self.dx_call))
# DE with no digits and APRS servers starting "T2" are not things we can look up location for # DE with no digits and APRS servers starting "T2" are not things we can look up location for
if self.de_call and 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 any(char.isdigit() for char in self.de_call) and not (self.de_call.startswith("T2") and self.source == "APRS-IS"):
@@ -380,7 +388,7 @@ class Spot:
self_copy.received_time_iso = "" self_copy.received_time_iso = ""
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest() self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
# JSON serialise # JSON sspoterialise
def to_json(self): def to_json(self):
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True) return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
@@ -396,3 +404,10 @@ class Spot:
if sig_ref.id == new_sig_ref.id and sig_ref.sig == new_sig_ref.sig: if sig_ref.id == new_sig_ref.id and sig_ref.sig == new_sig_ref.sig:
return return
self.sig_refs.append(new_sig_ref) self.sig_refs.append(new_sig_ref)
# Decide if this spot has expired (in which case it should not be added to the system in the first place, and not
# returned by the web server if later requested, and removed by the cleanup functions). "Expired" is defined as
# either having a time further ago than the server's MAX_SPOT_AGE. If it somehow doesn't have a time either, it is
# considered to be expired.
def expired(self):
return not self.time or self.time < (datetime.now(pytz.UTC) - timedelta(seconds=MAX_SPOT_AGE)).timestamp()

View File

@@ -268,8 +268,6 @@ class WebServer:
# infer missing data, and add it to our database. # infer missing data, and add it to our database.
spot.source = "API" spot.source = "API"
if not spot.sig:
spot.icon = "desktop"
spot.infer_missing() spot.infer_missing()
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE) self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
@@ -338,11 +336,14 @@ class WebServer:
sources = query.get(k).split(",") sources = query.get(k).split(",")
spots = [s for s in spots if s.source and s.source in sources] spots = [s for s in spots if s.source and s.source in sources]
case "sig": case "sig":
# If a list of sigs is provided, the spot must have a sig and it must match one of them # If a list of sigs is provided, the spot must have a sig and it must match one of them.
# The special "sig" "NO_SIG", when supplied in the list, mathches spots with no sig.
sigs = query.get(k).split(",") sigs = query.get(k).split(",")
spots = [s for s in spots if s.sig and s.sig in sigs] include_no_sig = "NO_SIG" in sigs
spots = [s for s in spots if (s.sig and s.sig in sigs) or (include_no_sig and not s.sig)]
case "needs_sig": case "needs_sig":
# If true, a sig is required, regardless of what it is, it just can't be missing. # If true, a sig is required, regardless of what it is, it just can't be missing. Mutually
# exclusive with supplying the special "NO_SIG" parameter to the "sig" query param.
needs_sig = query.get(k).upper() == "TRUE" needs_sig = query.get(k).upper() == "TRUE"
if needs_sig: if needs_sig:
spots = [s for s in spots if s.sig] spots = [s for s in spots if s.sig]
@@ -421,7 +422,6 @@ class WebServer:
if a is not None: if a is not None:
alerts.append(a) alerts.append(a)
# We never want alerts that seem to be in the past # We never want alerts that seem to be in the past
alerts = list(filter(lambda alert: not alert.expired(), alerts))
alerts = sorted(alerts, key=lambda alert: (alert.start_time if alert and alert.start_time else 0)) alerts = sorted(alerts, key=lambda alert: (alert.start_time if alert and alert.start_time else 0))
for k in query.keys(): for k in query.keys():
match k: match k:
@@ -441,8 +441,11 @@ class WebServer:
sources = query.get(k).split(",") sources = query.get(k).split(",")
alerts = [a for a in alerts if a.source and a.source in sources] alerts = [a for a in alerts if a.source and a.source in sources]
case "sig": case "sig":
# If a list of sigs is provided, the alert must have a sig and it must match one of them.
# The special "sig" "NO_SIG", when supplied in the list, mathches alerts with no sig.
sigs = query.get(k).split(",") sigs = query.get(k).split(",")
alerts = [a for a in alerts if a.sig and a.sig in sigs] include_no_sig = "NO_SIG" in sigs
spots = [a for a in alerts if (a.sig and a.sig in sigs) or (include_no_sig and not a.sig)]
case "dx_continent": case "dx_continent":
dxconts = query.get(k).split(",") dxconts = query.get(k).split(",")
alerts = [a for a in alerts if a.dx_continent and a.dx_continent in dxconts] alerts = [a for a in alerts if a.dx_continent and a.dx_continent in dxconts]

View File

@@ -72,7 +72,7 @@ class DXCluster(SpotProvider):
de_call=match.group(1), de_call=match.group(1),
freq=float(match.group(2)) * 1000, freq=float(match.group(2)) * 1000,
comment=match.group(4).strip(), comment=match.group(4).strip(),
icon="desktop", icon="tower-cell",
time=spot_datetime.timestamp()) time=spot_datetime.timestamp())
# Add to our list # Add to our list

View File

@@ -41,42 +41,47 @@ class GMA(HTTPSpotProvider):
dx_longitude=float(source_spot["LON"]) if (source_spot["LON"] and source_spot["LON"] != "") else None) dx_longitude=float(source_spot["LON"]) if (source_spot["LON"] and source_spot["LON"] != "") else None)
# GMA doesn't give what programme (SIG) the reference is for until we separately look it up. # GMA doesn't give what programme (SIG) the reference is for until we separately look it up.
ref_response = SEMI_STATIC_URL_DATA_CACHE.get(self.REF_INFO_URL_ROOT + source_spot["REF"], if "REF" in source_spot:
headers=HTTP_HEADERS) try:
# Sometimes this is blank, so handle that ref_response = SEMI_STATIC_URL_DATA_CACHE.get(self.REF_INFO_URL_ROOT + source_spot["REF"],
if ref_response.text is not None and ref_response.text != "": headers=HTTP_HEADERS)
ref_info = ref_response.json() # Sometimes this is blank, so handle that
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. POTA and WWFF if ref_response.text is not None and ref_response.text != "":
# spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA ref_info = ref_response.json()
# and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry # If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. POTA and WWFF
# to determine if it's a SOTA summit. # spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA
if "reftype" in ref_info and ref_info["reftype"] not in ["POTA", "WWFF"] and (ref_info["reftype"] != "Summit" or ref_info["sota"] == ""): # and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry
match ref_info["reftype"]: # to determine if it's a SOTA summit.
case "Summit": if "reftype" in ref_info and ref_info["reftype"] not in ["POTA", "WWFF"] and (
spot.sig_refs[0].sig = "GMA" ref_info["reftype"] != "Summit" or ref_info["sota"] == ""):
spot.sig = "GMA" match ref_info["reftype"]:
case "IOTA Island": case "Summit":
spot.sig_refs[0].sig = "IOTA" spot.sig_refs[0].sig = "GMA"
spot.sig = "IOTA" spot.sig = "GMA"
case "Lighthouse (ILLW)": case "IOTA Island":
spot.sig_refs[0].sig = "ILLW" spot.sig_refs[0].sig = "IOTA"
spot.sig = "ILLW" spot.sig = "IOTA"
case "Lighthouse (ARLHS)": case "Lighthouse (ILLW)":
spot.sig_refs[0].sig = "ARLHS" spot.sig_refs[0].sig = "ILLW"
spot.sig = "ARLHS" spot.sig = "ILLW"
case "Castle": case "Lighthouse (ARLHS)":
spot.sig_refs[0].sig = "WCA" spot.sig_refs[0].sig = "ARLHS"
spot.sig = "WCA" spot.sig = "ARLHS"
case "Mill": case "Castle":
spot.sig_refs[0].sig = "MOTA" spot.sig_refs[0].sig = "WCA"
spot.sig = "MOTA" spot.sig = "WCA"
case _: case "Mill":
logging.warn("GMA spot found with ref type " + ref_info[ spot.sig_refs[0].sig = "MOTA"
"reftype"] + ", developer needs to add support for this!") spot.sig = "MOTA"
spot.sig_refs[0].sig = ref_info["reftype"] case _:
spot.sig = ref_info["reftype"] logging.warn("GMA spot found with ref type " + ref_info[
"reftype"] + ", developer needs to add support for this!")
spot.sig_refs[0].sig = ref_info["reftype"]
spot.sig = ref_info["reftype"]
# 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.
new_spots.append(spot) new_spots.append(spot)
except:
logging.warn("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot["REF"] + ", ignoring this spot for now")
return new_spots return new_spots

View File

@@ -34,8 +34,9 @@ class SpotProvider:
if datetime.fromtimestamp(spot.time, pytz.UTC) > self.last_spot_time: if datetime.fromtimestamp(spot.time, pytz.UTC) > self.last_spot_time:
# Fill in any blanks # Fill in any blanks
spot.infer_missing() spot.infer_missing()
# Add to the list # Add to the list, provided it heas not already expired.
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE) if not spot.expired():
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
self.last_spot_time = datetime.fromtimestamp(max(map(lambda s: s.time, spots)), pytz.UTC) self.last_spot_time = datetime.fromtimestamp(max(map(lambda s: s.time, spots)), pytz.UTC)
# Submit a single spot retrieved from the provider. This will be added to the list regardless of its age. Spots # Submit a single spot retrieved from the provider. This will be added to the list regardless of its age. Spots
@@ -44,9 +45,10 @@ class SpotProvider:
def submit(self, spot): def submit(self, spot):
# Fill in any blanks # Fill in any blanks
spot.infer_missing() spot.infer_missing()
# Add to the list # Add to the list, provided it heas not already expired.
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE) if not spot.expired():
self.last_spot_time = datetime.fromtimestamp(spot.time, pytz.UTC) self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
self.last_spot_time = datetime.fromtimestamp(spot.time, pytz.UTC)
# Stop any threads and prepare for application shutdown # Stop any threads and prepare for application shutdown
def stop(self): def stop(self):

View File

@@ -16,10 +16,18 @@
<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: 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, the UK Packet Repeater Network, and any site based on the xOTA software by nischu.</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>, 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: NG3K, POTA, SOTA, WWFF, Parks 'n' Peaks, WOTA and BOTA.</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), 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>
<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>
<ol><li>Sources like GMA and Parks 'n' Peaks provide spots for multiple different programmes (SIGs).</li>
<li>Cluster spots may name SIGs in their comment, in which case the source remains the Cluster, but a SIG is assigned.</li>
<li>Some SIGs, such as Worked all Britain (WAB), don't have their own spotting site and can <em>only</em> be identified through comments on spots retrieved from other sources.</li>
<li>SIGs have well-defined names, whereas the server owner may name the sources as they see fit.</li></ol>
<p>Spothole's web interface exists not just for the end user, but also as a reference implementation for the API, so I have chosen to demonstrate both methods of filtering.</p>
<h4 class="mt-4">How is this better than DXheat, DXsummit, POTA's own website, etc?</h4> <h4 class="mt-4">How is this better than DXheat, DXsummit, POTA's own website, etc?</h4>
<p>It's probably not? But it's nice to have choice.</p> <p>It's probably not? But it's nice to have choice.</p>
<p>I think it's got three key advantages over those sites:</p> <p>I think it's got three key advantages over those sites:</p>

View File

@@ -26,7 +26,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row row-cols-1 g-4 mb-4"> <div class="row row-cols-1 g-4 mb-4 row-cols-md-3">
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@@ -35,8 +35,24 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">SIGs</h5>
<p id="sig-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Sources</h5>
<p id="source-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
</div> </div>
<div class="row row-cols-1 row-cols-md-4 g-4"> <div class="row row-cols-1 row-cols-md-3 g-4">
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@@ -61,14 +77,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Sources</h5>
<p id="source-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -108,6 +116,6 @@
</div> </div>
<script src="/js/common.js"></script> <script src="/js/common.js"></script>
<script src="/js/spotandmap.js"></script> <script src="/js/spotsbandsandmap.js"></script>
<script src="/js/bands.js"></script> <script src="/js/bands.js"></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>

View File

@@ -25,7 +25,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row row-cols-1 g-4 mb-4"> <div class="row row-cols-1 g-4 mb-4 row-cols-md-3">
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@@ -34,8 +34,24 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">SIGs</h5>
<p id="sig-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Sources</h5>
<p id="source-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
</div> </div>
<div class="row row-cols-1 row-cols-md-4 g-4"> <div class="row row-cols-1 row-cols-md-3 g-4">
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@@ -60,14 +76,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Sources</h5>
<p id="source-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -126,6 +134,6 @@
<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"></script> <script src="/js/common.js"></script>
<script src="/js/spotandmap.js"></script> <script src="/js/spotsbandsandmap.js"></script>
<script src="/js/map.js"></script> <script src="/js/map.js"></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>

View File

@@ -37,7 +37,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row row-cols-1 g-4 mb-4"> <div class="row row-cols-1 g-4 mb-4 row-cols-md-3">
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@@ -46,8 +46,24 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">SIGs</h5>
<p id="sig-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Sources</h5>
<p id="source-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
</div> </div>
<div class="row row-cols-1 row-cols-md-4 g-4"> <div class="row row-cols-1 row-cols-md-3 g-4">
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@@ -72,14 +88,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Sources</h5>
<p id="source-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -188,6 +196,6 @@
</div> </div>
<script src="/js/common.js"></script> <script src="/js/common.js"></script>
<script src="/js/spotandmap.js"></script> <script src="/js/spotsbandsandmap.js"></script>
<script src="/js/spots.js"></script> <script src="/js/spots.js"></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>

View File

@@ -52,55 +52,19 @@ paths:
type: number type: number
- name: source - name: source
in: query in: query
description: "Limit the spots to only ones from one or more sources. To select more than one source, supply a comma-separated list. The allowed options will vary based on how the sources are named within the server's config. See the /options call for how to retrieve a list of these." description: "Limit the spots to only ones from one or more sources. To select more than one source, supply a comma-separated list."
required: false required: false
schema: schema:
type: string $ref: "#/components/schemas/Source"
enum:
- POTA
- SOTA
- WWFF
- WWBOTA
- GMA
- HEMA
- ParksNPeaks
- ZLOTA
- WOTA
- BOTA
- Cluster
- RBN
- APRS-IS
- UKPacketNet
- TOTA
- name: sig - name: sig
in: query in: query
description: "Limit the spots to only ones from one or more Special Interest Groups provided as an argument. To select more than one SIG, supply a comma-separated list." description: "Limit the spots to only ones from one or more Special Interest Groups provided as an argument. To select more than one SIG, supply a comma-separated list. The special `sig` name `NO_SIG` matches spots with no sig set. You can use `sig=NO_SIG` to specifically only return generic spots with no associated SIG. You can also use combinations to request for example POTA + no SIG, but reject other SIGs. If you want to request 'every SIG and not No SIG', see the `needs_sig` query parameter for a shortcut."
required: false required: false
schema: schema:
type: string $ref: "#/components/schemas/SIGNameIncludingNoSIG"
enum:
- POTA
- SOTA
- WWFF
- WWBOTA
- GMA
- HEMA
- WCA
- MOTA
- SIOTA
- ARLHS
- ILLW
- ZLOTA
- KRMNPA
- IOTA
- WOTA
- BOTA
- WAB
- WAI
- TOTA
- name: needs_sig - name: needs_sig
in: query in: query
description: "Limit the spots to only ones with a Special Interest Group such as POTA. Because supplying all known SIGs as a `sigs` parameter is unwieldy, and leaving `sigs` blank will also return spots with *no* SIG, this parameter can be set true to return only spots with a SIG, regardless of what it is, so long as it's not blank. This is what Field Spotter uses to exclude generic cluster spots and only retrieve xOTA things." description: "Limit the spots to only ones with a Special Interest Group such as POTA. Because supplying all known SIGs as a `sigs` parameter is unwieldy, and leaving `sigs` blank will also return spots with *no* SIG, this parameter can be set true to return only spots with a SIG, regardless of what it is, so long as it's not blank. This is the equivalent of supplying the `sig` query param with a list of every known SIG apart from the special `NO_SIG` value. This is what Field Spotter uses to exclude generic cluster spots and only retrieve xOTA things."
required: false required: false
schema: schema:
type: boolean type: boolean
@@ -117,96 +81,31 @@ paths:
description: "Limit the spots to only ones from one or more bands. To select more than one band, supply a comma-separated list." description: "Limit the spots to only ones from one or more bands. To select more than one band, supply a comma-separated list."
required: false required: false
schema: schema:
type: string $ref: "#/components/schemas/BandName"
enum:
- 160m
- 80m
- 60m
- 40m
- 30m
- 20m
- 17m
- 15m
- 12m
- 10m
- 6m
- 4m
- 2m
- 70cm
- 23cm
- 13cm
- name: mode - name: mode
in: query in: query
description: "Limit the spots to only ones from one or more modes. To select more than one mode, supply a comma-separated list." description: "Limit the spots to only ones from one or more modes. To select more than one mode, supply a comma-separated list."
required: false required: false
schema: schema:
type: string $ref: "#/components/schemas/Mode"
enum:
- CW
- PHONE
- SSB
- USB
- LSB
- AM
- FM
- DV
- DMR
- DSTAR
- C4FM
- M17
- DIGI
- DATA
- FT8
- FT4
- RTTY
- SSTV
- JS8
- HELL
- BPSK
- PSK
- BPSK31
- OLIVIA
- MFSK
- MFSK32
- PKT
- name: mode_type - name: mode_type
in: query in: query
description: "Limit the spots to only ones from one or more mode families. To select more than one mode family, supply a comma-separated list." description: "Limit the spots to only ones from one or more mode families. To select more than one mode family, supply a comma-separated list."
required: false required: false
schema: schema:
type: string $ref: "#/components/schemas/Mode"
enum:
- CW
- PHONE
- DATA
- name: dx_continent - name: dx_continent
in: query in: query
description: "Limit the spots to only ones where the DX (the operator being spotted) is on the given continent(s). To select more than one continent, supply a comma-separated list." description: "Limit the spots 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."
required: false required: false
schema: schema:
type: string $ref: "#/components/schemas/Continent"
enum:
- EU
- NA
- SA
- AS
- AF
- OC
- AN
- name: de_continent - name: de_continent
in: query in: query
description: "Limit the spots to only ones where the spotteris on the given continent(s). To select more than one continent, supply a comma-separated list." description: "Limit the spots to only ones where the spotteris on the given continent(s). To select more than one continent, supply a comma-separated list."
required: false required: false
schema: schema:
type: string $ref: "#/components/schemas/Continent"
enum:
- EU
- NA
- SA
- AS
- AF
- OC
- AN
- name: dedupe - name: dedupe
in: query in: query
description: "\"De-duplicate\" the spots, returning only the latest spot for any given callsign." description: "\"De-duplicate\" the spots, returning only the latest spot for any given callsign."
@@ -285,66 +184,22 @@ paths:
type: boolean type: boolean
- name: source - name: source
in: query in: query
description: "Limit the alerts to only ones from one or more sources. To select more than one source, supply a comma-separated list. The options will vary based on how the sources are named within the server's config. See the /options call for how to retrieve a list of these." description: "Limit the alerts to only ones from one or more sources. To select more than one source, supply a comma-separated list."
required: false required: false
schema: schema:
type: string $ref: "#/components/schemas/Source"
enum:
- POTA
- SOTA
- WWFF
- WWBOTA
- GMA
- HEMA
- ParksNPeaks
- ZLOTA
- WOTA
- BOTA
- Cluster
- RBN
- APRS-IS
- UKPacketNet
- TOTA
- name: sig - name: sig
in: query in: query
description: "Limit the alerts to only ones from one or more Special Interest Groups. To select more than one SIG, supply a comma-separated list." description: "Limit the alerts to only ones from one or more Special Interest Groups. To select more than one SIG, supply a comma-separated list. The special value 'NO_SIG' can be included to return alerts specifically without an associated SIG (i.e. general DXpeditions)."
required: false required: false
schema: schema:
type: string $ref: "#/components/schemas/SIGNameIncludingNoSIG"
enum:
- POTA
- SOTA
- WWFF
- WWBOTA
- GMA
- HEMA
- WCA
- MOTA
- SIOTA
- ARLHS
- ILLW
- ZLOTA
- KRMNPA
- IOTA
- WOTA
- BOTA
- WAB
- WAI
- TOTA
- name: dx_continent - name: dx_continent
in: query in: query
description: "Limit the alerts to only ones where the DX operator is on the given continent(s). To select more than one continent, supply a comma-separated list." description: "Limit the alerts to only ones where the DX operator is on the given continent(s). To select more than one continent, supply a comma-separated list."
required: false required: false
schema: schema:
type: string $ref: "#/components/schemas/Continent"
enum:
- EU
- NA
- SA
- AS
- AF
- OC
- AN
- name: dx_call_includes - name: dx_call_includes
in: query in: query
description: "Limit the alerts to only ones where the DX callsign includes the supplied string (case-insensitive). Generally a complete callsign, but you can supply a shorter string for partial matches." description: "Limit the alerts to only ones where the DX callsign includes the supplied string (case-insensitive). Generally a complete callsign, but you can supply a shorter string for partial matches."
@@ -574,17 +429,8 @@ paths:
description: Country flag of the operator. This is limited to the range of emoji flags. For some DXCCs there may not be an official emoji flag, e.g. Northern Ireland, so the appearance may vary depending on your browser and operating system. Some small islands may also have no flag. Many DXCCs may also share a flag, e.g. mainland Spain, Balearic Islands, etc. description: Country flag of the operator. This is limited to the range of emoji flags. For some DXCCs there may not be an official emoji flag, e.g. Northern Ireland, so the appearance may vary depending on your browser and operating system. Some small islands may also have no flag. Many DXCCs may also share a flag, e.g. mainland Spain, Balearic Islands, etc.
example: "" example: ""
continent: continent:
type: string
description: Continent of the operator description: Continent of the operator
enum: $ref: "#/components/schemas/Continent"
- EU
- NA
- SA
- AS
- AF
- OC
- AN
example: EU
dxcc_id: dxcc_id:
type: integer type: integer
description: DXCC ID of the operator description: DXCC ID of the operator
@@ -610,13 +456,8 @@ paths:
description: Longitude of the opertor's QTH, in degrees. This could be from an online lookup service, or just based on the DXCC. description: Longitude of the opertor's QTH, in degrees. This could be from an online lookup service, or just based on the DXCC.
example: -1.2345 example: -1.2345
location_source: location_source:
type: string
description: Where we got the location (grid/latitude/longitude) from. Unlike a spot where we might have a summit position or WAB square, here the only options are an online QTH lookup, or a location based purely on DXCC, or nothing. description: Where we got the location (grid/latitude/longitude) from. Unlike a spot where we might have a summit position or WAB square, here the only options are an online QTH lookup, or a location based purely on DXCC, or nothing.
enum: $ref: "#/components/schemas/LocationSourceForAlert"
- "HOME QTH"
- DXCC
- NONE
example: "HOME QTH"
'422': '422':
description: Validation error e.g. callsign missing or format incorrect description: Validation error e.g. callsign missing or format incorrect
content: content:
@@ -638,28 +479,7 @@ paths:
in: query in: query
description: Special Interest Group (SIG), e.g. outdoor activity programme such as POTA description: Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
required: true required: true
type: string $ref: "#/components/schemas/SIGName"
enum:
- POTA
- SOTA
- WWFF
- WWBOTA
- GMA
- HEMA
- WCA
- MOTA
- SIOTA
- ARLHS
- ILLW
- ZLOTA
- KRMNPA
- IOTA
- WOTA
- BOTA
- WAB
- WAI
- TOTA
example: POTA
- name: id - name: id
in: query in: query
description: ID of a reference in that SIG description: ID of a reference in that SIG
@@ -729,6 +549,184 @@ paths:
components: components:
schemas: schemas:
Source:
type: string
enum:
- POTA
- SOTA
- WWFF
- WWBOTA
- GMA
- HEMA
- ParksNPeaks
- ZLOTA
- WOTA
- Cluster
- RBN
- APRS-IS
- UKPacketNet
example: POTA
SIGName:
type: string
enum:
- POTA
- SOTA
- WWFF
- WWBOTA
- GMA
- HEMA
- WCA
- MOTA
- SIOTA
- ARLHS
- ILLW
- ZLOTA
- KRMNPA
- IOTA
- WOTA
- BOTA
- WAB
- WAI
- TOTA
example: POTA
SIGNameIncludingNoSIG:
type: string
enum:
- POTA
- SOTA
- WWFF
- WWBOTA
- GMA
- HEMA
- WCA
- MOTA
- SIOTA
- ARLHS
- ILLW
- ZLOTA
- KRMNPA
- IOTA
- WOTA
- BOTA
- WAB
- WAI
- TOTA
- NO_SIG
example: POTA
Continent:
type: string
enum:
- EU
- NA
- SA
- AS
- AF
- OC
- AN
example: EU
BandName:
type: string
enum:
- 2200m
- 600m
- 160m
- 80m
- 60m
- 40m
- 30m
- 20m
- 17m
- 15m
- 12m
- 11m
- 10m
- 6m
- 5m
- 4m
- 2m
- 1.25m
- 70cm
- 23cm
- 2.4GHz
- 5.8GHz
- 10GHz
- 24GHz
- 47GHz
- 76GHz
example: 40m
Mode:
type: string
enum:
- CW
- PHONE
- SSB
- USB
- LSB
- AM
- FM
- DV
- DMR
- DSTAR
- C4FM
- M17
- DIGI
- DATA
- FT8
- FT4
- RTTY
- SSTV
- JS8
- HELL
- BPSK
- PSK
- BPSK31
- OLIVIA
- MFSK
- MFSK32
- PKT
example: SSB
ModeType:
type: string
enum:
- CW
- PHONE
- DATA
example: CW
ModeSource:
type: string
enum:
- SPOT
- COMMENT
- BANDPLAN
- NONE
example: SPOT
LocationSourceForSpot:
type: string
enum:
- SPOT
- "SIG REF LOOKUP"
- "WAB/WAI GRID"
- "HOME QTH"
- DXCC
- NONE
example: SPOT
LocationSourceForAlert:
type: string
enum:
- "HOME QTH"
- DXCC
- NONE
example: "HOME QTH"
SIGRef: SIGRef:
type: object type: object
properties: properties:
@@ -737,29 +735,8 @@ components:
description: SIG reference ID. description: SIG reference ID.
example: GB-0001 example: GB-0001
sig: sig:
type: string
description: SIG that this reference is in. description: SIG that this reference is in.
enum: $ref: "#/components/schemas/SIGName"
- POTA
- SOTA
- WWFF
- WWBOTA
- GMA
- HEMA
- WCA
- MOTA
- SIOTA
- ARLHS
- ILLW
- ZLOTA
- KRMNPA
- IOTA
- WOTA
- BOTA
- WAB
- WAI
- TOTA
example: POTA
name: name:
type: string type: string
description: SIG reference name description: SIG reference name
@@ -809,17 +786,8 @@ components:
description: Country flag of the DX operator. This is limited to the range of emoji flags. For some DXCCs there may not be an official emoji flag, e.g. Northern Ireland, so the appearance may vary depending on your browser and operating system. Some small islands may also have no flag. Many DXCCs may also share a flag, e.g. mainland Spain, Balearic Islands, etc. description: Country flag of the DX operator. This is limited to the range of emoji flags. For some DXCCs there may not be an official emoji flag, e.g. Northern Ireland, so the appearance may vary depending on your browser and operating system. Some small islands may also have no flag. Many DXCCs may also share a flag, e.g. mainland Spain, Balearic Islands, etc.
example: "" example: ""
dx_continent: dx_continent:
type: string
description: Continent of the DX operator description: Continent of the DX operator
enum: $ref: "#/components/schemas/Continent"
- EU
- NA
- SA
- AS
- AF
- OC
- AN
example: EU
dx_dxcc_id: dx_dxcc_id:
type: integer type: integer
description: DXCC ID of the DX operator description: DXCC ID of the DX operator
@@ -849,16 +817,8 @@ components:
description: Longitude of the DX spot, in degrees. This could be from a geographical reference e.g. POTA, or from a QRZ lookup description: Longitude of the DX spot, in degrees. This could be from a geographical reference e.g. POTA, or from a QRZ lookup
example: -1.2345 example: -1.2345
dx_location_source: dx_location_source:
type: string
description: Where we got the DX location (grid/latitude/longitude) from. If this was from the spot itself, or from a lookup of the SIG ref (e.g. park) it's likely quite accurate, but if we had to fall back to QRZ lookup, or even a location based on the DXCC itself, it will be a lot less accurate. description: Where we got the DX location (grid/latitude/longitude) from. If this was from the spot itself, or from a lookup of the SIG ref (e.g. park) it's likely quite accurate, but if we had to fall back to QRZ lookup, or even a location based on the DXCC itself, it will be a lot less accurate.
enum: $ref: "#/components/schemas/LocationSourceForSpot"
- SPOT
- "SIG REF LOOKUP"
- "WAB/WAI GRID"
- "HOME QTH"
- DXCC
- NONE
example: SPOT
dx_location_good: dx_location_good:
type: boolean type: boolean
description: Does the software think the location is good enough to put a marker on a map? This is true if the source is "SPOT", "SIG REF LOOKUP" or "WAB/WAI GRID", or alternatively if the source is "HOME QTH" and the callsign doesn't have a slash in it (i.e. operator likely at home). description: Does the software think the location is good enough to put a marker on a map? This is true if the source is "SPOT", "SIG REF LOOKUP" or "WAB/WAI GRID", or alternatively if the source is "HOME QTH" and the callsign doesn't have a slash in it (i.e. operator likely at home).
@@ -876,17 +836,8 @@ components:
description: Country flag of the spotter. This is limited to the range of emoji flags. For some DXCCs there may not be an official emoji flag, e.g. Northern Ireland, so the appearance may vary depending on your browser and operating system. Some small islands may also have no flag. Many DXCCs may also share a flag, e.g. mainland Spain, Balearic Islands, etc. description: Country flag of the spotter. This is limited to the range of emoji flags. For some DXCCs there may not be an official emoji flag, e.g. Northern Ireland, so the appearance may vary depending on your browser and operating system. Some small islands may also have no flag. Many DXCCs may also share a flag, e.g. mainland Spain, Balearic Islands, etc.
example: "" example: ""
de_continent: de_continent:
type: string
enum:
- EU
- NA
- SA
- AS
- AF
- OC
- AN
description: Continent of the spotter description: Continent of the spotter
example: EU $ref: "#/components/schemas/Continent"
de_dxcc_id: de_dxcc_id:
type: integer type: integer
description: DXCC ID of the spotter description: DXCC ID of the spotter
@@ -905,82 +856,25 @@ components:
example: 51.2345 example: 51.2345
de_longitude: de_longitude:
type: number type: number
description: Longitude of the spotter, in degrees. This is not going to be from a xOTA reference so it will likely just be a QRZ or DXCC lookup. If the spotter is also portable, this is probably wrong, but it's good enough for some simple mapping. description: Longitude of the DX spotspotter, in degrees. 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.
example: -1.2345 example: -1.2345
mode: mode:
type: string
description: Reported mode. description: Reported mode.
enum: $ref: "#/components/schemas/Mode"
- CW
- PHONE
- SSB
- USB
- LSB
- AM
- FM
- DV
- DMR
- DSTAR
- C4FM
- M17
- DIGI
- DATA
- FT8
- FT4
- RTTY
- SSTV
- JS8
- HELL
- BPSK
- PSK
- BPSK31
- OLIVIA
- MFSK
- MFSK32
- PKT
example: SSB example: SSB
mode_type: mode_type:
type: string
description: Inferred mode "family". description: Inferred mode "family".
enum: $ref: "#/components/schemas/ModeType"
- CW
- PHONE
- DATA
example: PHONE
mode_source: mode_source:
type: string
description: Where we got the mode from. If this was from the spot itself, it's likely quite accurate, but if we had to fall back to the bandplan, it might not be correct. description: Where we got the mode from. If this was from the spot itself, it's likely quite accurate, but if we had to fall back to the bandplan, it might not be correct.
enum: $ref: "#/components/schemas/ModeSource"
- SPOT
- COMMENT
- BANDPLAN
- NONE
freq: freq:
type: number type: number
description: Frequency, in Hz description: Frequency, in Hz
example: 7150500 example: 7150500
band: band:
type: string
description: Band, defined by the frequency. description: Band, defined by the frequency.
enum: $ref: "#/components/schemas/BandName"
- 160m
- 80m
- 60m
- 40m
- 30m
- 20m
- 17m
- 15m
- 12m
- 10m
- 6m
- 4m
- 2m
- 70cm
- 23cm
- 13cm
- Unknown
example: 40m
time: time:
type: number type: number
description: Time of the spot, UTC seconds since UNIX epoch description: Time of the spot, UTC seconds since UNIX epoch
@@ -1002,29 +896,8 @@ components:
description: Comment left by the spotter, if any description: Comment left by the spotter, if any
example: "59 in NY 73" example: "59 in NY 73"
sig: sig:
type: string
description: Special Interest Group (SIG), e.g. outdoor activity programme such as POTA description: Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
enum: $ref: "#/components/schemas/SIGName"
- POTA
- SOTA
- WWFF
- WWBOTA
- GMA
- HEMA
- WCA
- MOTA
- SIOTA
- ARLHS
- ILLW
- ZLOTA
- KRMNPA
- IOTA
- WOTA
- BOTA
- WAB
- WAI
- TOTA
example: POTA
sig_refs: sig_refs:
type: array type: array
items: items:
@@ -1041,7 +914,7 @@ components:
band_color: band_color:
type: string type: string
descripton: Colour to represent this spot, if a client chooses to colour spots based on their frequency band, using PSK Reporter's default colours. HTML colour e.g. hex. descripton: Colour to represent this spot, if a client chooses to colour spots based on their frequency band, using PSK Reporter's default colours. HTML colour e.g. hex.
example: #ff0000" example: "#ff0000"
band_contrast_color: band_contrast_color:
type: string type: string
descripton: Black or white, whichever best contrasts with "band_color". descripton: Black or white, whichever best contrasts with "band_color".
@@ -1051,24 +924,8 @@ components:
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.
example: false example: false
source: source:
type: string description: Where we got the spot from.
description: Where we got the spot from. The options will vary based on how the sources are named within the server's config. See the /options call for how to retrieve a list of these. $ref: "#/components/schemas/Source"
enum:
- POTA
- SOTA
- WWFF
- WWBOTA
- GMA
- HEMA
- ParksNPeaks
- ZLOTA
- WOTA
- Cluster
- RBN
- APRS-IS
- UKPacketNet
- TOTA
example: POTA
source_id: source_id:
type: string type: string
description: The ID the source gave it, if any. description: The ID the source gave it, if any.
@@ -1103,17 +960,8 @@ components:
description: Country flag of the DX operator. This is limited to the range of emoji flags. For some DXCCs there may not be an official emoji flag, e.g. Northern Ireland, so the appearance may vary depending on your browser and operating system. Some small islands may also have no flag. Many DXCCs may also share a flag, e.g. mainland Spain, Balearic Islands, etc. description: Country flag of the DX operator. This is limited to the range of emoji flags. For some DXCCs there may not be an official emoji flag, e.g. Northern Ireland, so the appearance may vary depending on your browser and operating system. Some small islands may also have no flag. Many DXCCs may also share a flag, e.g. mainland Spain, Balearic Islands, etc.
example: "" example: ""
dx_continent: dx_continent:
type: string
description: Continent of the DX operator description: Continent of the DX operator
enum: $ref: "#/components/schemas/Continent"
- EU
- NA
- SA
- AS
- AF
- OC
- AN
example: EU
dx_dxcc_id: dx_dxcc_id:
type: integer type: integer
description: DXCC ID of the DX operator description: DXCC ID of the DX operator
@@ -1159,27 +1007,8 @@ components:
description: Comment made by the activator, if any description: Comment made by the activator, if any
example: "2025 DXpedition to null island" example: "2025 DXpedition to null island"
sig: sig:
type: string
description: Special Interest Group (SIG), e.g. outdoor activity programme such as POTA description: Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
enum: $ref: "#/components/schemas/SIGName"
- POTA
- SOTA
- WWFF
- WWBOTA
- GMA
- HEMA
- WCA
- MOTA
- SIOTA
- ARLHS
- ILLW
- ZLOTA
- IOTA
- WOTA
- BOTA
- WAB
- WAI
example: POTA
sig_refs: sig_refs:
type: array type: array
items: items:
@@ -1196,22 +1025,7 @@ components:
source: source:
type: string type: string
description: Where we got the alert from. description: Where we got the alert from.
enum: $ref: "#/components/schemas/Source"
- POTA
- SOTA
- WWFF
- WWBOTA
- GMA
- HEMA
- ParksNPeaks
- ZLOTA
- WOTA
- BOTA
- Cluster
- RBN
- APRS-IS
- UKPacketNet
example: POTA
source_id: source_id:
type: string type: string
description: The ID the source gave it, if any. description: The ID the source gave it, if any.
@@ -1221,9 +1035,8 @@ components:
type: object type: object
properties: properties:
name: name:
type: string
description: The name of the provider. description: The name of the provider.
example: POTA $ref: "#/components/schemas/Source"
enabled: enabled:
type: boolean type: boolean
description: Whether the provider is enabled or not. description: Whether the provider is enabled or not.
@@ -1245,9 +1058,8 @@ components:
type: object type: object
properties: properties:
name: name:
type: string
description: The name of the provider. description: The name of the provider.
example: POTA $ref: "#/components/schemas/Source"
enabled: enabled:
type: boolean type: boolean
description: Whether the provider is enabled or not. description: Whether the provider is enabled or not.
@@ -1265,9 +1077,8 @@ components:
type: object type: object
properties: properties:
name: name:
type: string
description: The name of the band description: The name of the band
example: 40m $ref: "#/components/schemas/BandName"
start_freq: start_freq:
type: int type: int
description: The start frequency of this band, in Hz. description: The start frequency of this band, in Hz.
@@ -1289,9 +1100,8 @@ components:
type: object type: object
properties: properties:
name: name:
type: string
description: The abbreviated name of the SIG description: The abbreviated name of the SIG
example: POTA $ref: "#/components/schemas/SIGName"
description: description:
type: string type: string
description: The full name of the SIG description: The full name of the SIG

View File

@@ -23,7 +23,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"].forEach(fn => { ["dx_continent", "de_continent", "mode_type", "source", "band", "sig"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) { if (!allFilterOptionsSelected(fn)) {
str = str + getQueryStringFor(fn) + "&"; str = str + getQueryStringFor(fn) + "&";
} }
@@ -230,6 +230,7 @@ function loadOptions() {
// Populate the filters panel // Populate the filters panel
generateBandsMultiToggleFilterCard(options["bands"]); generateBandsMultiToggleFilterCard(options["bands"]);
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"]); generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);

View File

@@ -17,7 +17,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"].forEach(fn => { ["dx_continent", "de_continent", "mode_type", "source", "band", "sig"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) { if (!allFilterOptionsSelected(fn)) {
str = str + getQueryStringFor(fn) + "&"; str = str + getQueryStringFor(fn) + "&";
} }
@@ -158,6 +158,7 @@ function loadOptions() {
// Populate the filters panel // Populate the filters panel
generateBandsMultiToggleFilterCard(options["bands"]); generateBandsMultiToggleFilterCard(options["bands"]);
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"]); generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);

View File

@@ -14,7 +14,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"].forEach(fn => { ["dx_continent", "de_continent", "mode_type", "source", "band", "sig"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) { if (!allFilterOptionsSelected(fn)) {
str = str + getQueryStringFor(fn) + "&"; str = str + getQueryStringFor(fn) + "&";
} }
@@ -282,6 +282,7 @@ function loadOptions() {
// Populate the filters panel // Populate the filters panel
generateBandsMultiToggleFilterCard(options["bands"]); generateBandsMultiToggleFilterCard(options["bands"]);
generateSIGsMultiToggleFilterCard(options["sigs"]);console.log(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"]); generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);

View File

@@ -30,6 +30,18 @@ function generateBandsMultiToggleFilterCard(band_options) {
$("#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>&nbsp;<button id="filter-button-band-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', false);">None</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>&nbsp;<button id="filter-button-band-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', false);">None</button></span>`);
} }
// Generate SIGs filter card. This one is also a special case.
function generateSIGsMultiToggleFilterCard(sig_options) {
// Create a button for each option
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 fa-${o['icon']}"></i> ${o['name']}</label> `);
});
// 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> `);
// Create All/None buttons
$("#sig-options").append(` <span style="display: inline-block"><button id="filter-button-sig-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('sig', true);">All</button>&nbsp;<button id="filter-button-sig-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('sig', false);">None</button></span>`);
}
// 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();