23 Commits

Author SHA1 Message Date
Ian Renton
f31148686d Change 2.4GHz to 13cm to match Field Spotter naming convention #88 2026-01-01 17:33:46 +00:00
Ian Renton
a444be8fe9 Extract common JS into a new project #88 2026-01-01 17:07:59 +00:00
Ian Renton
3f117a47d6 JS faff #88 2025-12-31 10:14:52 +00:00
Ian Renton
06d582ae2d Separate colours and icons out of the Spothole API and re-implement them in the client; provide new colour schemes. #88 2025-12-30 19:08:27 +00:00
Ian Renton
5bf45dba46 Ham HF band toggle preset and prevent some multiple-SSE shenanigans when searching and typing letters quickly 2025-12-30 14:51:49 +00:00
Ian Renton
f4ae6b610e Fix spot table reversing bug and add "de" callsign to mobile view 2025-12-30 09:06:43 +00:00
Ian Renton
6af15e4cfd Reload spots/alerts on visibility change. Closes #89 2025-12-27 15:57:38 +00:00
Ian Renton
6d9bf3d4ec Update docs 2025-12-26 22:14:22 +00:00
Ian Renton
9b737a8176 39C3 TOTA location lookup 2025-12-26 09:14:49 +00:00
Ian Renton
05bc65337f Fix a bug in the mobile view where the second line doesn't get painted green for SSE new spots. Closes #87 2025-12-24 11:16:03 +00:00
Ian Renton
d2c1dbb377 Fix a bug in the mobile view where the second line doesn't get painted green for SSE new spots. Closes #87 2025-12-24 11:14:03 +00:00
Ian Renton
6cf1b38355 Fix metrics content type? 2025-12-24 10:10:46 +00:00
Ian Renton
ac566553d8 nginx config #3 2025-12-24 09:47:26 +00:00
Ian Renton
bcc40d1416 SSE custom headers #3 2025-12-24 09:44:55 +00:00
Ian Renton
2fead92dc5 SSE updates every 5 seconds is probably fine, we don't really need every second. #3 2025-12-24 08:57:38 +00:00
Ian Renton
e8ca488001 Run/Pause button #3 2025-12-24 08:53:44 +00:00
Ian Renton
61fc0b9d0f Starting to implement Run/Pause switch #3 2025-12-23 22:52:21 +00:00
Ian Renton
70dc1b495c Fix SSE connections not respecting filters #3 2025-12-23 22:24:30 +00:00
Ian Renton
7fe478e040 Minor tweak #3 2025-12-23 21:58:32 +00:00
Ian Renton
926cf5caaf Fix handling new spots by SSE when there weren't any others #3 2025-12-23 21:58:25 +00:00
Ian Renton
ae1caaa40f Fix handling new spots by SSE when there weren't the max number already #3 2025-12-23 21:45:17 +00:00
Ian Renton
6116d19580 Fix issue with SSE queues getting lost #3 2025-12-23 21:26:39 +00:00
Ian Renton
86beb27ebf Implement SSE endpoints in Tornado #3 2025-12-23 21:01:41 +00:00
39 changed files with 597 additions and 494 deletions

View File

@@ -30,7 +30,7 @@ URL parameters can be used to trigger an "embedded" mode which hides the headers
Setting `embedded` to true is important for the rest of the settings to be applied; otherwise, the user's defaults will be used in preference to the URL params.
These are supplied with the URL to the page you want to embed, for example for an embedded version of the band map in dark mode, use `https://spothole.com/bands?embedded=true&dark-mode=true`. For an embedded version of the main spots/home page in the system light/dark mode, use `https://spothole.com/?embedded=true`. For dark mode showing 70cm TOTA spots only, use `https://spothole.com/?embedded=true&dark-mode=true&filter-sigs=TOTA&filter-bands=70cm`. Providing no URL params causes the page to be loaded in the normal way it would when accessed directly in the user's browser.
These are supplied with the URL to the page you want to embed, for example for an embedded version of the band map in dark mode, use `https://spothole.app/bands?embedded=true&dark-mode=true`. For an embedded version of the main spots/home page in the system light/dark mode, use `https://spothole.app/?embedded=true`. For dark mode showing 70cm TOTA spots only, use `https://spothole.app/?embedded=true&dark-mode=true&sig=TOTA&band=70cm`. Providing no URL params causes the page to be loaded in the normal way it would when accessed directly in the user's browser.
The supported parameters are as follows. Generally these match the equivalent parameters in the real Spothole API, where a mapping exists.
@@ -157,6 +157,8 @@ server {
location / {
add_header Access-Control-Allow-Origin $xssorigin;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_pass http://127.0.0.1:8080;
}
}
@@ -219,6 +221,7 @@ To navigate your way around the source code, this list may help.
* `/` - Main script (`spothole.py`), pip `requirements.txt`, config, README, etc.
* `/images` - Image sources
* `/datafiles` - Local data sources (differentiated from the majority of data files which are loaded from URLs and cached in `/cache`)
* `/cache` - Directory where static-ish data downloaded from the internet is cached to avoid rapid re-requests, and where spot/alert data is cached so that it survives a software restart. Created on first run.
### Extending the server

View File

@@ -30,6 +30,9 @@ class AlertProvider:
# because alerts could be created at any point for any time in the future. Rely on hashcode-based id matching
# to deal with duplicates.
def submit_batch(self, alerts):
# Sort the batch so that earliest ones go in first. This helps keep the ordering correct when alerts are fired
# off to SSE listeners.
alerts = sorted(alerts, key=lambda alert: (alert.start_time if alert and alert.start_time else 0))
for alert in alerts:
# Fill in any blanks and add to the list
alert.infer_missing()

View File

@@ -76,7 +76,6 @@ class NG3K(HTTPAlertProvider):
dx_country=dx_country,
freqs_modes=bands + (("; " + modes) if modes != "" else ""),
comment=by + "; " + comment + "; " + qsl_info,
icon="globe-africa",
start_time=start_timestamp,
end_time=end_timestamp,
is_dxpedition=True)

View File

@@ -86,12 +86,11 @@ spot-providers:
name: "39C3 TOTA"
enabled: false
url: "wss://dev.39c3.totawatch.de/api/spot/live"
# Fixed SIG/latitude/longitude for all spots from a provider is currently only a feature for the "XOTA" provider,
# Fixed SIG for all spots from a provider & location CSV are currently only a feature for the "XOTA" provider,
# the software found at https://github.com/nischu/xOTA/. This is because this is a generic backend for xOTA
# programmes and so different URLs provide different programmes.
sig: "TOTA"
latitude: 53.5622678
longitude: 9.9855205
locations-csv: "datafiles/39c3-tota.csv"
# Alert providers to use. Same setup as the spot providers list above.

View File

@@ -12,25 +12,25 @@ HAMQTH_PRG = (SOFTWARE_NAME + " v" + SOFTWARE_VERSION + " operated by " + SERVER
# Special Interest Groups
SIGS = [
SIG(name="POTA", description="Parks on the Air", icon="tree", ref_regex=r"[A-Z]{2}\-\d{4,5}"),
SIG(name="SOTA", description="Summits on the Air", icon="mountain-sun", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
SIG(name="WWFF", description="World Wide Flora & Fauna", icon="seedling", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"),
SIG(name="GMA", description="Global Mountain Activity", icon="person-hiking", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
SIG(name="WWBOTA", description="Worldwide Bunkers on the Air", icon="radiation", ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}"),
SIG(name="HEMA", description="HuMPs Excluding Marilyns Award", icon="mound", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}"),
SIG(name="IOTA", description="Islands on the Air", icon="umbrella-beach", ref_regex=r"[A-Z]{2}\-\d{3}"),
SIG(name="MOTA", description="Mills on the Air", icon="fan", ref_regex=r"X\d{4-6}"),
SIG(name="ARLHS", description="Amateur Radio Lighthouse Society", icon="tower-observation", ref_regex=r"[A-Z]{3}\-\d{3,4}"),
SIG(name="ILLW", description="International Lighthouse & Lightship Weekend", icon="tower-observation", ref_regex=r"[A-Z]{2}\d{4}"),
SIG(name="SIOTA", description="Silos on the Air", icon="wheat-awn", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"),
SIG(name="WCA", description="World Castles Award", icon="chess-rook", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"),
SIG(name="ZLOTA", description="New Zealand on the Air", icon="kiwi-bird", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}"),
SIG(name="WOTA", description="Wainwrights on the Air", icon="w", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
SIG(name="BOTA", description="Beaches on the Air", icon="water"),
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award", icon="earth-oceania"),
SIG(name="WAB", description="Worked All Britain", icon="table-cells-large", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
SIG(name="WAI", description="Worked All Ireland", icon="table-cells-large", ref_regex=r"[A-Z][0-9]{2}"),
SIG(name="TOTA", description="Toilets on the Air", icon="toilet", ref_regex=r"T\-[0-9]{2}")
SIG(name="POTA", description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}"),
SIG(name="SOTA", description="Summits on the Air", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
SIG(name="WWFF", description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"),
SIG(name="GMA", description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
SIG(name="WWBOTA", description="Worldwide Bunkers on the Air", ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}"),
SIG(name="HEMA", description="HuMPs Excluding Marilyns Award", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}"),
SIG(name="IOTA", description="Islands on the Air", ref_regex=r"[A-Z]{2}\-\d{3}"),
SIG(name="MOTA", description="Mills on the Air", ref_regex=r"X\d{4-6}"),
SIG(name="ARLHS", description="Amateur Radio Lighthouse Society", ref_regex=r"[A-Z]{3}\-\d{3,4}"),
SIG(name="ILLW", description="International Lighthouse & Lightship Weekend", ref_regex=r"[A-Z]{2}\d{4}"),
SIG(name="SIOTA", description="Silos on the Air", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"),
SIG(name="WCA", description="World Castles Award", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"),
SIG(name="ZLOTA", description="New Zealand on the Air", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}"),
SIG(name="WOTA", description="Wainwrights on the Air", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
SIG(name="BOTA", description="Beaches on the Air"),
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award"),
SIG(name="WAB", description="Worked All Britain", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
SIG(name="WAI", description="Worked All Ireland", ref_regex=r"[A-Z][0-9]{2}"),
SIG(name="TOTA", description="Toilets on the Air", ref_regex=r"T\-[0-9]{2}")
]
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
@@ -42,33 +42,33 @@ MODE_TYPES = ["CW", "PHONE", "DATA"]
# Band definitions
BANDS = [
Band(name="2200m", start_freq=135700, end_freq=137800, color="#ff4500", contrast_color="white"),
Band(name="600m", start_freq=472000, end_freq=479000, color="#1e90ff", contrast_color="white"),
Band(name="160m", start_freq=1800000, end_freq=2000000, color="#7cfc00", contrast_color="black"),
Band(name="80m", start_freq=3500000, end_freq=4000000, color="#e550e5", contrast_color="black"),
Band(name="60m", start_freq=5250000, end_freq=5410000, color="#00008b", contrast_color="white"),
Band(name="40m", start_freq=7000000, end_freq=7300000, color="#5959ff", contrast_color="white"),
Band(name="30m", start_freq=10100000, end_freq=10150000, color="#62d962", contrast_color="black"),
Band(name="20m", start_freq=14000000, end_freq=14350000, color="#f2c40c", contrast_color="black"),
Band(name="17m", start_freq=18068000, end_freq=18168000, color="#f2f261", contrast_color="black"),
Band(name="15m", start_freq=21000000, end_freq=21450000, color="#cca166", contrast_color="black"),
Band(name="12m", start_freq=24890000, end_freq=24990000, color="#b22222", contrast_color="white"),
Band(name="11m", start_freq=26965000, end_freq=27405000, color="#00ff00", contrast_color="black"),
Band(name="10m", start_freq=28000000, end_freq=29700000, color="#ff69b4", contrast_color="black"),
Band(name="6m", start_freq=50000000, end_freq=54000000, color="#FF0000", contrast_color="white"),
Band(name="5m", start_freq=56000000, end_freq=60500000, color="#e0e0e0", contrast_color="black"),
Band(name="4m", start_freq=70000000, end_freq=70500000, color="#cc0044", contrast_color="white"),
Band(name="2m", start_freq=144000000, end_freq=148000000, color="#FF1493", contrast_color="black"),
Band(name="1.25m", start_freq=219000000, end_freq=225000000, color="#CCFF00", contrast_color="black"),
Band(name="70cm", start_freq=420000000, end_freq=450000000, color="#999900", contrast_color="white"),
Band(name="23cm", start_freq=1240000000, end_freq=1325000000, color="#5AB8C7", contrast_color="black"),
Band(name="2.4GHz", start_freq=2300000000, end_freq=2450000000, color="#FF7F50", contrast_color="black"),
Band(name="5.8GHz", start_freq=5725000000, end_freq=5850000000, color="#cc0099", contrast_color="white"),
Band(name="10GHz", start_freq=10000000000, end_freq=10500000000, color="#696969", contrast_color="white"),
Band(name="24GHz", start_freq=24000000000, end_freq=24050000000, color="#f3edc6", contrast_color="black"),
Band(name="47GHz", start_freq=47000000000, end_freq=47200000000, color="#ffe786", contrast_color="black"),
Band(name="76GHz", start_freq=75500000000, end_freq=81500000000, color="#baf9d8", contrast_color="black")]
UNKNOWN_BAND = Band(name="Unknown", start_freq=0, end_freq=0, color="black", contrast_color="white")
Band(name="2200m", start_freq=135700, end_freq=137800),
Band(name="600m", start_freq=472000, end_freq=479000),
Band(name="160m", start_freq=1800000, end_freq=2000000),
Band(name="80m", start_freq=3500000, end_freq=4000000),
Band(name="60m", start_freq=5250000, end_freq=5410000),
Band(name="40m", start_freq=7000000, end_freq=7300000),
Band(name="30m", start_freq=10100000, end_freq=10150000),
Band(name="20m", start_freq=14000000, end_freq=14350000),
Band(name="17m", start_freq=18068000, end_freq=18168000),
Band(name="15m", start_freq=21000000, end_freq=21450000),
Band(name="12m", start_freq=24890000, end_freq=24990000),
Band(name="11m", start_freq=26965000, end_freq=27405000),
Band(name="10m", start_freq=28000000, end_freq=29700000),
Band(name="6m", start_freq=50000000, end_freq=54000000),
Band(name="5m", start_freq=56000000, end_freq=60500000),
Band(name="4m", start_freq=70000000, end_freq=70500000),
Band(name="2m", start_freq=144000000, end_freq=148000000),
Band(name="1.25m", start_freq=219000000, end_freq=225000000),
Band(name="70cm", start_freq=420000000, end_freq=450000000),
Band(name="23cm", start_freq=1240000000, end_freq=1325000000),
Band(name="13cm", start_freq=2300000000, end_freq=2450000000),
Band(name="5.8GHz", start_freq=5725000000, end_freq=5850000000),
Band(name="10GHz", start_freq=10000000000, end_freq=10500000000),
Band(name="24GHz", start_freq=24000000000, end_freq=24050000000),
Band(name="47GHz", start_freq=47000000000, end_freq=47200000000),
Band(name="76GHz", start_freq=75500000000, end_freq=81500000000)]
UNKNOWN_BAND = Band(name="Unknown", start_freq=0, end_freq=0)
# Continents
CONTINENTS = ["EU", "NA", "SA", "AS", "AF", "OC", "AN"]

View File

@@ -8,14 +8,6 @@ from core.constants import SIGS, HTTP_HEADERS
from core.geo_utils import wab_wai_square_to_lat_lon
# Utility function to get the icon for a named SIG. If no match is found, the "circle-question" icon will be returned.
def get_icon_for_sig(sig):
for s in SIGS:
if s.name == sig:
return s.icon
return "circle-question"
# Utility function to get the regex string for a SIG reference for a named SIG. If no match is found, None will be returned.
def get_ref_regex_for_sig(sig):
for s in SIGS:

View File

@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
import pytz
from core.lookup_helper import lookup_helper
from core.sig_utils import get_icon_for_sig, populate_sig_ref_info
from core.sig_utils import populate_sig_ref_info
# Data class that defines an alert.
@@ -55,8 +55,6 @@ class Alert:
sig_refs: list = None
# Activation score. SOTA only
activation_score: int = None
# Icon, from the Font Awesome set. This is fairly opinionated but is here to help the alerthole web UI and Field alertter. Does not include the "fa-" prefix.
icon: str = None
# Whether this alert is for a DXpedition, as opposed to e.g. an xOTA programme.
is_dxpedition: bool = False
# Where we got the alert from, e.g. "POTA", "SOTA"...
@@ -109,10 +107,6 @@ class Alert:
if self.sig_refs and len(self.sig_refs) > 0 and not self.sig:
self.sig = self.sig_refs[0].sig
# Icon from SIG
if self.sig and not self.icon:
self.icon = get_icon_for_sig(self.sig)
# DX operator details lookup, using QRZ.com. This should be the last resort compared to taking the data from
# the actual alertting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon instead of
# the one from the park reference they're at.

View File

@@ -8,8 +8,4 @@ class Band:
# Start frequency, in Hz
start_freq: float
# Stop frequency, in Hz
end_freq: float
# Colour to use for this band, as per PSK Reporter
color: str
# Contrast colour to use for text against a background of the band colour
contrast_color: str
end_freq: float

View File

@@ -7,8 +7,5 @@ class SIG:
name: str
# Description, e.g. "Parks on the Air"
description: str
# Icon to use for it, from the Font Awesome set. This is fairly opinionated but is here to help the Spothole web UI
# and Field Spotter. Does not include the "fa-" prefix.
icon: str
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
ref_regex: str = None

View File

@@ -11,7 +11,7 @@ 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.sig_utils import get_icon_for_sig, populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig
from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig
from data.sig_ref import SIGRef
@@ -109,16 +109,6 @@ class Spot:
# Activation score. SOTA only
activation_score: int = None
# Display guidance (optional)
# Icon, from the Font Awesome set. This is fairly opinionated but is here to help the Spothole web UI and Field
# Spotter. Does not include the "fa-" prefix.
icon: str = None
# 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. A contrast colour is also provided which will be black or white.
band_color: str = None
band_contrast_color: str = None
# Timing info
# Time of the spot, UTC seconds since UNIX epoch
@@ -214,8 +204,6 @@ class Spot:
if self.freq and not self.band:
band = lookup_helper.infer_band_from_freq(self.freq)
self.band = band.name
self.band_color = band.color
self.band_contrast_color = band.contrast_color
# Mode from comments or bandplan
if self.mode:
@@ -296,14 +284,6 @@ class Spot:
if self.sig_refs and len(self.sig_refs) > 0 and not self.sig:
self.sig = self.sig_refs[0].sig
# Icon from SIG if we have one
if 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
if self.dx_grid and not self.dx_latitude:
ll = locator_to_latlong(self.dx_grid)

18
datafiles/39c3-tota.csv Normal file
View File

@@ -0,0 +1,18 @@
ref,lat,lon
T-01,53.56278090617755,9.984341869295505
T-02,53.562383404176416,9.98551893027115
T-03,53.56170184391514,9.985416035619778
T-04,53.562026534393176,9.986372919078974
T-11,53.56284641242506,9.98475590239655
T-12,53.562431705517035,9.98551675702443
T-13,53.56223704898424,9.985774520335664
T-14,53.5617893512591,9.986344302837976
T-21,53.56284641242506,9.98475590239655
T-22,53.56245816412497,9.985456089490567
T-23,53.56199560857136,9.985636761412673
T-24,53.5617893512591,9.986344302837976
T-31,53.56247470064887,9.985611427551902
T-32,53.5617893512591,9.986344302837976
T-41,53.56245039134992,9.985486136112701
T-91,53.56147934973529,9.984626806439744
T-92,53.561396810300735,9.987553052152899
1 ref lat lon
2 T-01 53.56278090617755 9.984341869295505
3 T-02 53.562383404176416 9.98551893027115
4 T-03 53.56170184391514 9.985416035619778
5 T-04 53.562026534393176 9.986372919078974
6 T-11 53.56284641242506 9.98475590239655
7 T-12 53.562431705517035 9.98551675702443
8 T-13 53.56223704898424 9.985774520335664
9 T-14 53.5617893512591 9.986344302837976
10 T-21 53.56284641242506 9.98475590239655
11 T-22 53.56245816412497 9.985456089490567
12 T-23 53.56199560857136 9.985636761412673
13 T-24 53.5617893512591 9.986344302837976
14 T-31 53.56247470064887 9.985611427551902
15 T-32 53.5617893512591 9.986344302837976
16 T-41 53.56245039134992 9.985486136112701
17 T-91 53.56147934973529 9.984626806439744
18 T-92 53.561396810300735 9.987553052152899

View File

@@ -13,4 +13,5 @@ pyproj~=3.7.2
prometheus_client~=0.23.1
beautifulsoup4~=4.14.2
websocket-client~=1.9.0
tornado~=6.5.4
tornado~=6.5.4
tornado_eventsource~=3.0.0

View File

@@ -1,13 +1,18 @@
import json
import logging
from datetime import datetime
from queue import Queue
import pytz
import tornado
import tornado_eventsource.handler
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything
SSE_HANDLER_MAX_QUEUE_SIZE = 100
SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
# API request handler for /api/v1/alerts
class APIAlertsHandler(tornado.web.RequestHandler):
@@ -43,28 +48,66 @@ class APIAlertsHandler(tornado.web.RequestHandler):
self.set_header("Content-Type", "application/json")
# API request handler for /api/v1/alerts/stream
class APIAlertsStreamHandler(tornado.web.RequestHandler):
def get(self):
# todo
# try:
# # Metrics
# api_requests_counter.inc()
#
# response.content_type = 'text/event-stream'
# response.cache_control = 'no-cache'
# yield 'retry: 1000\n\n'
#
# alert_queue = Queue(maxsize=100)
# self.sse_alert_queues.append(alert_queue)
# while True:
# if alert_queue.empty():
# gevent.sleep(1)
# else:
# alert = alert_queue.get()
# yield 'data: ' + json.dumps(alert, default=serialize_everything) + '\n\n'
# except Exception as e:
# logging.warn("Exception when serving SSE socket", e)
pass
class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
def initialize(self, sse_alert_queues, web_server_metrics):
self.sse_alert_queues = sse_alert_queues
self.web_server_metrics = web_server_metrics
# Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data
def custom_headers(self):
return {"Cache-Control": "no-store",
"X-Accel-Buffering": "no"}
def open(self):
try:
# Metrics
self.web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self.web_server_metrics["api_access_counter"] += 1
self.web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
self.query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# Create a alert queue and add it to the web server's list. The web server will fill this when alerts arrive
self.alert_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE)
self.sse_alert_queues.append(self.alert_queue)
# Set up a timed callback to check if anything is in the queue
self.heartbeat = tornado.ioloop.PeriodicCallback(self._callback, SSE_HANDLER_QUEUE_CHECK_INTERVAL)
self.heartbeat.start()
except Exception as e:
logging.warn("Exception when serving SSE socket", e)
# When the user closes the socket, empty our queue and remove it from the list so the server no longer fills it
def close(self):
try:
if self.alert_queue in self.sse_alert_queues:
self.sse_alert_queues.remove(self.alert_queue)
self.alert_queue.empty()
except:
pass
self.alert_queue = None
super().close()
# Callback to check if anything has arrived in the queue, and if so send it to the client
def _callback(self):
try:
if self.alert_queue:
while not self.alert_queue.empty():
alert = self.alert_queue.get()
# If the new alert matches our param filters, send it to the client. If not, ignore it.
if alert_allowed_by_query(alert, self.query_params):
self.write_message(msg=json.dumps(alert, default=serialize_everything))
if self.alert_queue not in self.sse_alert_queues:
logging.error("Web server cleared up a queue of an active connection!")
self.close()
except:
logging.warn("Exception in SSE callback, connection will be closed.")
self.close()
@@ -127,4 +170,10 @@ def alert_allowed_by_query(alert, query):
dx_call_includes = query.get(k).strip()
if not alert.dx_call or dx_call_includes.upper() not in alert.dx_call.upper():
return False
case "text_includes":
text_includes = query.get(k).strip()
if (not alert.dx_call or text_includes.upper() not in alert.dx_call.upper()) \
and (not alert.comment or text_includes.upper() not in alert.comment.upper()) \
and (not alert.freqs_modes or text_includes.upper() not in alert.freqs_modes.upper()):
return False
return True

View File

@@ -1,13 +1,18 @@
import json
import logging
from datetime import datetime, timedelta
from queue import Queue
import pytz
import tornado
import tornado_eventsource.handler
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything
SSE_HANDLER_MAX_QUEUE_SIZE = 1000
SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
# API request handler for /api/v1/spots
class APISpotsHandler(tornado.web.RequestHandler):
@@ -44,28 +49,67 @@ class APISpotsHandler(tornado.web.RequestHandler):
# API request handler for /api/v1/spots/stream
class APISpotsStreamHandler(tornado.web.RequestHandler):
def get(self):
# todo
# try:
# # Metrics
# api_requests_counter.inc()
#
# response.content_type = 'text/event-stream'
# response.cache_control = 'no-cache'
# yield 'retry: 1000\n\n'
#
# spot_queue = Queue(maxsize=100)
# self.sse_spot_queues.append(spot_queue)
# while True:
# if spot_queue.empty():
# gevent.sleep(1)
# else:
# spot = spot_queue.get()
# yield 'data: ' + json.dumps(spot, default=serialize_everything) + '\n\n'
# except Exception as e:
# logging.warn("Exception when serving SSE socket", e)
pass
class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
def initialize(self, sse_spot_queues, web_server_metrics):
self.sse_spot_queues = sse_spot_queues
self.web_server_metrics = web_server_metrics
# Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data
def custom_headers(self):
return {"Cache-Control": "no-store",
"X-Accel-Buffering": "no"}
# Called once on the client opening a connection, set things up
def open(self):
try:
# Metrics
self.web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self.web_server_metrics["api_access_counter"] += 1
self.web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
self.query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# Create a spot queue and add it to the web server's list. The web server will fill this when spots arrive
self.spot_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE)
self.sse_spot_queues.append(self.spot_queue)
# Set up a timed callback to check if anything is in the queue
self.heartbeat = tornado.ioloop.PeriodicCallback(self._callback, SSE_HANDLER_QUEUE_CHECK_INTERVAL)
self.heartbeat.start()
except Exception as e:
logging.warn("Exception when serving SSE socket", e)
# When the user closes the socket, empty our queue and remove it from the list so the server no longer fills it
def close(self):
try:
if self.spot_queue in self.sse_spot_queues:
self.sse_spot_queues.remove(self.spot_queue)
self.spot_queue.empty()
except:
pass
self.spot_queue = None
super().close()
# Callback to check if anything has arrived in the queue, and if so send it to the client
def _callback(self):
try:
if self.spot_queue:
while not self.spot_queue.empty():
spot = self.spot_queue.get()
# If the new spot matches our param filters, send it to the client. If not, ignore it.
if spot_allowed_by_query(spot, self.query_params):
self.write_message(msg=json.dumps(spot, default=serialize_everything))
if self.spot_queue not in self.sse_spot_queues:
logging.error("Web server cleared up a queue of an active connection!")
self.close()
except:
logging.warn("Exception in SSE callback, connection will be closed.")
self.close()
@@ -177,6 +221,11 @@ def spot_allowed_by_query(spot, query):
dx_call_includes = query.get(k).strip()
if not spot.dx_call or dx_call_includes.upper() not in spot.dx_call.upper():
return False
case "text_includes":
text_includes = query.get(k).strip()
if (not spot.dx_call or text_includes.upper() not in spot.dx_call.upper()) \
and (not spot.comment or text_includes.upper() not in spot.comment.upper()):
return False
case "allow_qrt":
# If false, spots that are flagged as QRT are not returned.
prevent_qrt = query.get(k).upper() == "FALSE"

View File

@@ -1,5 +1,5 @@
import tornado
from prometheus_client.openmetrics.exposition import CONTENT_TYPE_LATEST
from prometheus_client import CONTENT_TYPE_LATEST
from core.prometheus_metrics_handler import get_metrics

View File

@@ -1,4 +1,5 @@
import asyncio
import logging
import os
import tornado
@@ -15,8 +16,6 @@ from server.handlers.pagetemplate import PageTemplateHandler
# Provides the public-facing web server.
# TODO SSE API responses
# TODO clean_up_sse_queues
class WebServer:
# Constructor
def __init__(self, spots, alerts, status_data, port):
@@ -49,8 +48,8 @@ class WebServer:
# Routes for API calls
(r"/api/v1/spots", APISpotsHandler, {"spots": self.spots, "web_server_metrics": self.web_server_metrics}),
(r"/api/v1/alerts", APIAlertsHandler, {"alerts": self.alerts, "web_server_metrics": self.web_server_metrics}),
(r"/api/v1/spots/stream", APISpotsStreamHandler), # todo provide queues?
(r"/api/v1/alerts/stream", APIAlertsStreamHandler), # todo provide queues?
(r"/api/v1/spots/stream", APISpotsStreamHandler, {"sse_spot_queues": self.sse_spot_queues, "web_server_metrics": self.web_server_metrics}),
(r"/api/v1/alerts/stream", APIAlertsStreamHandler, {"sse_alert_queues": self.sse_alert_queues, "web_server_metrics": self.web_server_metrics}),
(r"/api/v1/options", APIOptionsHandler, {"status_data": self.status_data, "web_server_metrics": self.web_server_metrics}),
(r"/api/v1/status", APIStatusHandler, {"status_data": self.status_data, "web_server_metrics": self.web_server_metrics}),
(r"/api/v1/lookup/call", APILookupCallHandler, {"web_server_metrics": self.web_server_metrics}),
@@ -71,37 +70,51 @@ class WebServer:
(r"/(.*)", StaticFileHandler, {"path": os.path.join(os.path.dirname(__file__), "../webassets")}),
],
template_path=os.path.join(os.path.dirname(__file__), "../templates"),
debug=True) # todo set false
debug=False)
app.listen(self.port)
await self.shutdown_event.wait()
# Internal method called when a new spot is added to the system. This is used to ping any SSE clients that are
# awaiting a server-sent message with new spots.
def notify_new_spot(self, spot):
# todo
# for queue in self.sse_spot_queues:
# try:
# queue.put(spot)
# except:
# # Cleanup thread was probably deleting the queue, that's fine
# pass
for queue in self.sse_spot_queues:
try:
queue.put(spot)
except:
# Cleanup thread was probably deleting the queue, that's fine
pass
pass
# Internal method called when a new alert is added to the system. This is used to ping any SSE clients that are
# awaiting a server-sent message with new spots.
def notify_new_alert(self, alert):
# todo
# for queue in self.sse_alert_queues:
# try:
# queue.put(alert)
# except:
# # Cleanup thread was probably deleting the queue, that's fine
# pass
for queue in self.sse_alert_queues:
try:
queue.put(alert)
except:
# Cleanup thread was probably deleting the queue, that's fine
pass
pass
# Clean up any SSE queues that are growing too large; probably their client disconnected.
# Clean up any SSE queues that are growing too large; probably their client disconnected and we didn't catch it
# properly for some reason.
def clean_up_sse_queues(self):
# todo
# self.sse_spot_queues = [q for q in self.sse_spot_queues if not q.full()]
# self.sse_alert_queues = [q for q in self.sse_alert_queues if not q.full()]
for q in self.sse_spot_queues:
try:
if q.full():
logging.warn("A full SSE spot queue was found, presumably because the client disconnected strangely. It has been removed.")
self.sse_spot_queues.remove(q)
q.empty()
except:
# Probably got deleted already on another thread
pass
for q in self.sse_alert_queues:
try:
if q.full():
logging.warn("A full SSE alert queue was found, presumably because the client disconnected strangely. It has been removed.")
self.sse_alert_queues.remove(q)
q.empty()
except:
# Probably got deleted already on another thread
pass
pass

View File

@@ -51,7 +51,6 @@ class APRSIS(SpotProvider):
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,
icon="tower-cell",
time=datetime.now(pytz.UTC).timestamp()) # APRS-IS spots are live so we can assume spot time is "now"
# Add to our list

View File

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

View File

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

View File

@@ -32,6 +32,9 @@ class SpotProvider:
# their infer_missing() method called to complete their data set. This is called by the API-querying
# subclasses on receiving spots.
def submit_batch(self, spots):
# Sort the batch so that earliest ones go in first. This helps keep the ordering correct when spots are fired
# off to SSE listeners.
spots = sorted(spots, key=lambda spot: (spot.time if spot and spot.time else 0))
for spot in spots:
if datetime.fromtimestamp(spot.time, pytz.UTC) > self.last_spot_time:
# Fill in any blanks and add to the list

View File

@@ -61,7 +61,6 @@ class UKPacketNet(HTTPSpotProvider):
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"],

View File

@@ -47,6 +47,7 @@ class WebsocketSpotProvider(SpotProvider):
logging.debug("Connecting to " + self.name + " spot API...")
self.status = "Connecting"
self.ws = create_connection(self.url, header=HTTP_HEADERS)
self.status = "Connected"
data = self.ws.recv()
if data:
try:

View File

@@ -1,4 +1,6 @@
import csv
import json
import logging
from datetime import datetime
import pytz
@@ -9,31 +11,45 @@ from spotproviders.websocket_spot_provider import WebsocketSpotProvider
# Spot provider for servers based on the "xOTA" software at https://github.com/nischu/xOTA/
# The provider typically doesn't give us a lat/lon or SIG explicitly, so our own config provides this information. This
# functionality is implemented for TOTA events.
# The provider typically doesn't give us a lat/lon or SIG explicitly, so our own config provides a SIG and a reference
# to a local CSV file with location information. This functionality is implemented for TOTA events, of which there are
# several - so a plain lookup of a "TOTA reference" doesn't make sense, it depends on which TOTA and hence which server
# supplied the data, which is why the CSV location lookup is here and not in sig_utils.
class XOTA(WebsocketSpotProvider):
FIXED_LATITUDE = None
FIXED_LONGITUDE = None
LOCATION_DATA = {}
SIG = None
def __init__(self, provider_config):
super().__init__(provider_config, provider_config["url"])
self.FIXED_LATITUDE = provider_config["latitude"] if "latitude" in provider_config else None
self.FIXED_LONGITUDE = provider_config["longitude"] if "longitude" in provider_config else None
locations_csv = provider_config["locations-csv"] if "locations-csv" in provider_config else None
self.SIG = provider_config["sig"] if "sig" in provider_config else None
# Load location data
if locations_csv:
try:
f = open(locations_csv)
csv_data = f.read()
dr = csv.DictReader(csv_data.splitlines())
for row in dr:
self.LOCATION_DATA[row["ref"]] = {"lat": row["lat"], "lon": row["lon"]}
except:
logging.exception("Could not look up location data for XOTA source.")
def ws_message_to_spot(self, bytes):
string = bytes.decode("utf-8")
source_spot = json.loads(string)
ref_id = source_spot["reference"]["title"]
lat = float(self.LOCATION_DATA[ref_id]["lat"]) if ref_id in self.LOCATION_DATA else None
lon = float(self.LOCATION_DATA[ref_id]["lon"]) if ref_id in self.LOCATION_DATA else None
spot = Spot(source=self.name,
source_id=source_spot["id"],
dx_call=source_spot["stationCallSign"].upper(),
freq=float(source_spot["freq"]) * 1000,
mode=source_spot["mode"].upper(),
sig=self.SIG,
sig_refs=[SIGRef(id=source_spot["reference"]["title"], sig=self.SIG, url=source_spot["reference"]["website"])],
sig_refs=[SIGRef(id=ref_id, sig=self.SIG, url=source_spot["reference"]["website"], latitude=lat, longitude=lon)],
time=datetime.now(pytz.UTC).timestamp(),
dx_latitude=self.FIXED_LATITUDE,
dx_longitude=self.FIXED_LONGITUDE,
dx_latitude=lat,
dx_longitude=lon,
qrt=source_spot["state"] != "active")
return spot

View File

@@ -63,7 +63,7 @@
<p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p>
</div>
<script src="/js/common.js?v=2"></script>
<script src="/js/common.js?v=5"></script>
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -69,8 +69,8 @@
</div>
<script src="/js/common.js?v=2"></script>
<script src="/js/add-spot.js?v=2"></script>
<script src="/js/common.js?v=5"></script>
<script src="/js/add-spot.js?v=5"></script>
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -168,8 +168,8 @@
</div>
<script src="/js/common.js?v=2"></script>
<script src="/js/alerts.js?v=2"></script>
<script src="/js/common.js?v=5"></script>
<script src="/js/alerts.js?v=5"></script>
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -117,6 +117,11 @@
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
<label class="form-check-label" for="darkMode">Dark mode</label>
</div>
<p class="card-text spothole-card-text">
Band color scheme<br/>
<select id="band-color-scheme" class="storeable-select form-select d-inline-block" oninput="setBandColorSchemeFromUI();" style="display: inline-block;">
</select>
</p>
</div>
</div>
</div>
@@ -129,9 +134,9 @@
</div>
<script src="/js/common.js?v=2"></script>
<script src="/js/spotsbandsandmap.js?v=2"></script>
<script src="/js/bands.js?v=2"></script>
<script src="/js/common.js?v=5"></script>
<script src="/js/spotsbandsandmap.js?v=5"></script>
<script src="/js/bands.js?v=5"></script>
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -44,6 +44,12 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/tinycolor2@1.6.0/cjs/tinycolor.min.js"></script>
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=5"></script>
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=5"></script>
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=5"></script>
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=5"></script>
</head>
<body>
@@ -63,7 +69,7 @@
<li class="nav-item ms-4"><a href="/bands" class="nav-link" id="nav-link-bands"><i class="fa-solid fa-ruler-vertical"></i> Bands</a></li>
<li class="nav-item ms-4"><a href="/alerts" class="nav-link" id="nav-link-alerts"><i class="fa-solid fa-bell"></i> Alerts</a></li>
{% if allow_spotting %}
<li class="nav-item ms-4"><a href="/add-spot" class="nav-link" id="nav-link-add-spot"><i class="fa-solid fa-comment"></i> Add Spot</a></li>
<li class="nav-item ms-4"><a href="/add-spot" class="nav-link" id="nav-link-add-spot"><i class="fa-solid fa-comment"></i> Add&nbsp;Spot</a></li>
{% end %}
<li class="nav-item ms-4"><a href="/status" class="nav-link" id="nav-link-status"><i class="fa-solid fa-chart-simple"></i> Status</a></li>
<li class="nav-item ms-4"><a href="/about" class="nav-link" id="nav-link-about"><i class="fa-solid fa-circle-info"></i> About</a></li>

View File

@@ -129,6 +129,11 @@
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
<label class="form-check-label" for="darkMode">Dark mode</label>
</div>
<p class="card-text spothole-card-text">
Band color scheme<br/>
<select id="band-color-scheme" class="storeable-select form-select d-inline-block" oninput="setBandColorSchemeFromUI();" style="display: inline-block;">
</select>
</p>
</div>
</div>
</div>
@@ -147,9 +152,9 @@
<script src="https://cdn.jsdelivr.net/npm/leaflet.geodesic"></script>
<script src="https://cdn.jsdelivr.net/npm/@joergdietrich/leaflet.terminator@1.1.0/L.Terminator.min.js"></script>
<script src="/js/common.js?v=2"></script>
<script src="/js/spotsbandsandmap.js?v=2"></script>
<script src="/js/map.js?v=2"></script>
<script src="/js/common.js?v=5"></script>
<script src="/js/spotsbandsandmap.js?v=5"></script>
<script src="/js/map.js?v=5"></script>
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -10,14 +10,21 @@
<div class="mt-3">
<div id="settingsButtonRow" class="row">
<div class="col-auto me-auto pt-3">
<div class="col-lg-6 me-auto pt-3 hideonmobile">
<p id="timing-container">Loading...</p>
</div>
<div class="col-auto">
<div class="col-lg-6 text-end">
<p class="d-inline-flex gap-1">
<span style="position: relative;">
<i class="fa-solid fa-magnifying-glass" style="position: absolute; left: 0px; top: 2px; padding: 10px; pointer-events: none;"></i>
<input id="filter-dx-call" type="search" class="form-control" oninput="filtersUpdated();" placeholder="Callsign">
<span class="btn-group" role="group">
<input type="radio" class="btn-check" name="runPause" id="runButton" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="runButton"><i class="fa-solid fa-play"></i> Run</label>
<input type="radio" class="btn-check" name="runPause" id="pauseButton" autocomplete="off">
<label class="btn btn-outline-primary" for="pauseButton"><i class="fa-solid fa-pause"></i> Pause</label>
</span>
<span class="hideonmobile" style="position: relative;">
<i id="searchicon" class="fa-solid fa-magnifying-glass"></i>
<input id="search" type="search" class="form-control" oninput="filtersUpdated();" placeholder="Search">
</span>
<button id="filters-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i> Filters</button>
<button id="display-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i> Display</button>
@@ -147,12 +154,17 @@
<div class="card">
<div class="card-body">
<h5 class="card-title">Theme</h5>
<div class="form-group">
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
<label class="form-check-label" for="darkMode">Dark mode</label>
</div>
<div class="form-group">
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
<label class="form-check-label" for="darkMode">Dark mode</label>
</div>
</div>
<p class="card-text spothole-card-text">
Band color scheme<br/>
<select id="band-color-scheme" class="storeable-select form-select d-inline-block" oninput="setBandColorSchemeFromUI();" style="display: inline-block;">
</select>
</p>
</div>
</div>
</div>
@@ -211,9 +223,9 @@
</div>
<script src="/js/common.js?v=2"></script>
<script src="/js/spotsbandsandmap.js?v=2"></script>
<script src="/js/spots.js?v=2"></script>
<script src="/js/common.js?v=5"></script>
<script src="/js/spotsbandsandmap.js?v=5"></script>
<script src="/js/spots.js?v=5"></script>
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -3,8 +3,8 @@
<div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div>
<script src="/js/common.js?v=2"></script>
<script src="/js/status.js?v=2"></script>
<script src="/js/common.js?v=5"></script>
<script src="/js/status.js?v=5"></script>
<script>$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -9,12 +9,18 @@ info:
The API calls described below allow third-party software to access data from Spothole, and receive data on spots and alerts in a consistent format regardless of the data sources used by Spothole itself. Utility calls are also provided for general data lookups.
Please note that the data coming out of Spothole is only as good as the data going in. People mis-hear and make typos when spotting callsigns all the time, and there are plenty of areas where Spothole's location data may be inaccurate. If you are doing something where accuracy is important, such as contesting, you should not rely on Spothole's data to fill in any gaps in your log.
## Changelog
### 1.1
Added Server-Sent Event API endpoint. Removed band colour and icon information from spots.
contact:
email: ian@ianrenton.com
license:
name: The Unlicense
url: https://unlicense.org/#the-unlicense
version: v1
version: v1.1
servers:
- url: https://spothole.app/api/v1
paths:
@@ -115,7 +121,7 @@ paths:
default: false
- name: dx_call_includes
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 spots 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."
required: false
schema:
type: string
@@ -125,6 +131,12 @@ paths:
required: false
schema:
type: string
- name: text_includes
in: query
description: "Limit the spots to only ones where some significant text (DX callsign or comment) includes the supplied string (case-insensitive)."
required: false
schema:
type: string
- name: needs_good_location
in: query
description: "Return only spots with a 'good' location. (See the spot `dx_location_good` parameter for details. Useful for map-based clients, to avoid spots with 'bad' locations e.g. loads of cluster spots ending up in the centre of the DXCC entitity.)"
@@ -215,7 +227,7 @@ paths:
$ref: "#/components/schemas/Continent"
- name: dx_call_includes
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 spots 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."
required: false
schema:
type: string
@@ -225,6 +237,12 @@ paths:
required: false
schema:
type: string
- name: text_includes
in: query
description: "Limit the spots to only ones where some significant text (DX callsign or comment) includes the supplied string (case-insensitive)."
required: false
schema:
type: string
- name: needs_good_location
in: query
description: "Return only spots with a 'good' location. (See the spot `dx_location_good` parameter for details. Useful for map-based clients, to avoid spots with 'bad' locations e.g. loads of cluster spots ending up in the centre of the DXCC entitity.)"
@@ -304,6 +322,12 @@ paths:
required: false
schema:
type: string
- name: text_includes
in: query
description: "Limit the alerts to only ones where some significant text (DX callsign, freqs/modes, or comment) includes the supplied string (case-insensitive)."
required: false
schema:
type: string
responses:
'200':
description: Success
@@ -359,6 +383,12 @@ paths:
required: false
schema:
type: string
- name: text_includes
in: query
description: "Limit the alerts to only ones where some significant text (DX callsign, freqs/modes, or comment) includes the supplied string (case-insensitive)."
required: false
schema:
type: string
responses:
'200':
description: Success
@@ -1060,18 +1090,6 @@ components:
type: integer
description: Activation score. SOTA only
example: 0
icon:
type: string
descripton: Icon, from the Font Awesome set. This is fairly opinionated but is here to help the Spothole web UI and Field Spotter. Does not include the "fa-" prefix.
example: tree
band_color:
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.
example: "#ff0000"
band_contrast_color:
type: string
descripton: Black or white, whichever best contrasts with "band_color".
example: "white"
qrt:
type: boolean
description: QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
@@ -1180,10 +1198,6 @@ components:
type: integer
description: Activation score. SOTA only
example: 0
icon:
type: string
descripton: Icon, from the Font Awesome set. This is fairly opinionated but is here to help the Spothole web UI and Field Spotter. Does not include the "fa-" prefix.
example: tree
source:
type: string
description: Where we got the alert from.
@@ -1259,14 +1273,6 @@ components:
type: int
description: The end frequency of this band, in Hz.
example: 7200000
color:
type: string
description: The color associated with this mode, as used on PSK Reporter.
example: "#5959ff"
contrast_color:
type: string
description: Black or white, whichever provides the best contrast against the band colour.
example: white
SIG:
type: object
@@ -1278,10 +1284,6 @@ components:
type: string
description: The full name of the SIG
example: Parks on the Air
icon:
type: string
description: Icon, from the Font Awesome set. This is fairly opinionated but is here to help the Spothole web UI and Field Spotter. Does not include the "fa-" prefix.
example: tree
ref_regex:
type: string
description: Regex that matches this SIG's reference IDs. Generally for Spothole's own internal use, clients probably won't need this.

View File

@@ -80,17 +80,22 @@ div.container {
/* SPOTS/ALERTS PAGES, SETTINGS/STATUS AREAS */
input#filter-dx-call {
input#search {
max-width: 12em;
margin-left: 1rem;
margin-right: 1rem;
padding-left: 2em;
}
div.appearing-panel {
display: none;
i#searchicon {
position: absolute;
left: 1rem;
top: 2px;
padding: 10px;
pointer-events: none;
}
button#add-spot-button {
div.appearing-panel {
display: none;
}
@@ -219,6 +224,10 @@ div#map {
filter: invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%);
}
/* Make buttons overlaid on the map have a non-transparent fill so you can see the text better */
.btn-outline-primary {
--bs-btn-bg: var(--bs-body-bg) !important;
}
/* BANDS PANEL */
@@ -340,11 +349,6 @@ div.band-spot:hover span.band-spot-info {
max-height: 26em;
overflow: scroll;
}
/* Filter/search DX Call field should be smaller on mobile */
input#filter-dx-call {
max-width: 9em;
margin-right: 0;
}
}
@media (min-width: 992px) {

View File

@@ -243,7 +243,7 @@ function addAlertRowsToTable(tbody, alerts) {
$tr.append(`<td class='hideonmobile'>${commentText}</td>`);
}
if (showSource) {
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid fa-${a["icon"]}'></i></span> ${sigSourceText}</td>`);
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid ${sigToIcon(a["sig"], "fa-globe-africa")}'></i></span> ${sigSourceText}</td>`);
}
if (showRef) {
$tr.append(`<td class='hideonmobile'>${sig_refs}</td>`);
@@ -257,7 +257,7 @@ function addAlertRowsToTable(tbody, alerts) {
}
$td2 = $("<td colspan='100'>");
if (showSource) {
$td2.append(`<span class='icon-wrapper'><i class='fa-solid fa-${a["icon"]}'></i></span> `);
$td2.append(`<span class='icon-wrapper'><i class='fa-solid ${sigToIcon(a["sig"], "fa-globe-africa")}'></i></span> `);
}
if (showRef) {
$td2.append(`${sig_refs} `);
@@ -351,4 +351,12 @@ $(document).ready(function() {
loadOptions();
// Update the refresh timing display every second
setInterval(updateRefreshDisplay, 1000);
});
// Reload alerts on becoming visible. This forces a refresh when used as a PWA and the user switches back to the PWA
// after some time has passed with it in the background.
addEventListener("visibilitychange", (event) => {
if (!document.hidden) {
loadAlerts();
}
});

View File

@@ -70,7 +70,7 @@ function updateBands() {
var table = $('<table id="bands-table">').append('<thead><tr></tr></thead><tbody><tr></tr></tbody>');
bandToSpots.forEach(function (spotList, bandName) {
// Get the colours for the band from the first spot, and prepare the header
table.find('thead tr').append(`<th style='background-color:${spotList[0].band_color}; color:${spotList[0].band_contrast_color}'>${spotList[0].band}</th>`);
table.find('thead tr').append(`<th style='background-color:${bandToColor(spotList[0].band)}; color:${bandToContrastColor(spotList[0].band)}'>${spotList[0].band}</th>`);
// Get the band data to fetch start and end frequencies
let band = options["bands"].filter(function (b) {
@@ -145,7 +145,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}${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>`);
bandSpotsDiv.append(`<div class="band-spot" style="top: ${s['pxDownBandLabel']}px; border-top: 1px solid ${bandToColor(s['band'])}; border-left: 5px solid ${bandToColor(s['band'])}; border-bottom: 1px solid ${bandToColor(s['band'])}; border-right: 1px solid ${bandToColor(s['band'])};"><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
@@ -167,7 +167,7 @@ function updateBands() {
ctx.beginPath();
ctx.lineWidth = 2;
ctx.lineCap = "round";
ctx.strokeStyle = s.band_color;
ctx.strokeStyle = bandToColor(s['band']);
ctx.moveTo(0, pxDownBandFreq);
ctx.lineTo(BAND_COLUMN_CANVAS_WIDTH_PX, pxDownBandLabel);
ctx.stroke();
@@ -228,6 +228,21 @@ function loadOptions() {
// Store options
options = jsonData;
// Populate the Display panel
options["web-ui-options"]["max-spot-age"].forEach(sc => $("#max-spot-age").append($('<option>', {
value: sc * 60,
text: sc
})));
$("#max-spot-age").val(options["web-ui-options"]["max-spot-age-default"] * 60);
getAvailableBandColorSchemes().forEach(sc => $("#band-color-scheme").append($('<option>', {
value: sc,
text: sc
})));
// First pass loading settings, so we can load the band colour scheme before the filters that need to use it
loadSettings();
setBandColorScheme($("#band-color-scheme option:selected").val());
// Add CSS for band toggle buttons
addBandToggleColourCSS(options["bands"]);
@@ -239,13 +254,6 @@ function loadOptions() {
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
generateMultiToggleFilterCard("#source-options", "source", options["spot_sources"]);
// Populate the Display panel
options["web-ui-options"]["max-spot-age"].forEach(sc => $("#max-spot-age").append($('<option>', {
value: sc * 60,
text: sc
})));
$("#max-spot-age").val(options["web-ui-options"]["max-spot-age-default"] * 60);
// Load URL params. These may select things from the various filter & display options, so the function needs
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
// loading settings, so this needs to be called before that.

View File

@@ -2,9 +2,6 @@
var options = {};
// Last time we updated the spots/alerts list on display.
var lastUpdateTime;
// Whether "embedded mode" is being used. This removes headers and footers, maximises the remaining content, and
// uses URL params to configure the interface options rather than using the user's localstorage.
var embeddedMode = false;
// Load and apply any URL params. This is used for "embedded mode" where another site can embed a version of
// Spothole and provide its own interface options rather than using the user's saved ones. These may select things
@@ -18,7 +15,7 @@ function loadURLParams() {
// top-level html element to use CSS selectors to remove bits of UI.
let embedded = params.get("embedded");
if (embedded != null && embedded === "true") {
embeddedMode = true;
useLocalStorage = false;
$("html").attr("embedded-mode", "true");
}
@@ -117,47 +114,22 @@ function toggleFilterButtons(filterQuery, state) {
function updateRefreshDisplay() {
if (lastUpdateTime != null) {
let secSinceUpdate = moment.duration(moment().diff(lastUpdateTime)).asSeconds();
if (typeof REFRESH_INTERVAL_SEC !== 'undefined' && REFRESH_INTERVAL_SEC != null) {
let count = REFRESH_INTERVAL_SEC;
let updatingString = "Updating..."
if (secSinceUpdate < REFRESH_INTERVAL_SEC) {
count = REFRESH_INTERVAL_SEC - secSinceUpdate;
if (count <= 60) {
var number = count.toFixed(0);
updatingString = "<span class='nowrap'>Updating in " + number + " second" + (number != "1" ? "s" : "") + ".</span>";
} else {
var number = Math.round(count / 60.0).toFixed(0);
updatingString = "<span class='nowrap'>Updating in " + number + " minute" + (number != "1" ? "s" : "") + ".</span>";
}
let count = REFRESH_INTERVAL_SEC;
let updatingString = "Updating..."
if (secSinceUpdate < REFRESH_INTERVAL_SEC) {
count = REFRESH_INTERVAL_SEC - secSinceUpdate;
if (count <= 60) {
var number = count.toFixed(0);
updatingString = "<span class='nowrap'>Updating in " + number + " second" + (number != "1" ? "s" : "") + ".</span>";
} else {
var number = Math.round(count / 60.0).toFixed(0);
updatingString = "<span class='nowrap'>Updating in " + number + " minute" + (number != "1" ? "s" : "") + ".</span>";
}
$("#timing-container").html("Last updated at " + lastUpdateTime.format('HH:mm') + " UTC. " + updatingString);
} else {
$("#timing-container").html("Connected to live spot server. Last spot at " + lastUpdateTime.format('HH:mm') + " UTC.");
}
$("#timing-container").html("Last updated at " + lastUpdateTime.format('HH:mm') + " UTC. " + updatingString);
}
}
// Utility function to escape HTML characters from a string.
function escapeHtml(str) {
if (typeof str !== 'string') {
return '';
}
const escapeCharacter = (match) => {
switch (match) {
case '&': return '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
case '\'': return '&#039;';
case '`': return '&#096;';
default: return match;
}
};
return str.replace(/[&<>"'`]/g, escapeCharacter);
}
// When the "use local time" field is changed, reload the table and save settings
function timeZoneUpdated() {
updateTable();
@@ -170,106 +142,6 @@ function columnsUpdated() {
saveSettings();
}
// Calculate great circle bearing between two lat/lon points.
function calcBearing(lat1, lon1, lat2, lon2) {
lat1 *= Math.PI / 180;
lon1 *= Math.PI / 180;
lat2 *= Math.PI / 180;
lon2 *= Math.PI / 180;
var lonDelta = lon2 - lon1;
var y = Math.sin(lonDelta) * Math.cos(lat2);
var x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(lonDelta);
var bearing = Math.atan2(y, x);
bearing = bearing * (180 / Math.PI);
if ( bearing < 0 ) { bearing += 360; }
return bearing;
}
// Convert a Maidenhead grid reference of arbitrary precision to the lat/long of the centre point of the square.
// Returns null if the grid format is invalid.
function latLonForGridCentre(grid) {
let [lat, lon, latCellSize, lonCellSize] = latLonForGridSWCornerPlusSize(grid);
if (lat != null && lon != null && latCellSize != null && lonCellSize != null) {
return [lat + latCellSize / 2.0, lon + lonCellSize / 2.0];
} else {
return null;
}
}
// Convert a Maidenhead grid reference of arbitrary precision to lat/long, including in the result the size of the
// lowest grid square. This is a utility method used by the main methods that return the centre, southwest, and
// northeast coordinates of a grid square.
// The return type is always an array of size 4. The elements in it are null if the grid format is invalid.
function latLonForGridSWCornerPlusSize(grid) {
// Make sure we are in upper case so our maths works. Case is arbitrary for Maidenhead references
grid = grid.toUpperCase();
// Return null if our Maidenhead string is invalid or too short
let len = grid.length;
if (len <= 0 || (len % 2) !== 0) {
return [null, null, null, null];
}
let lat = 0.0; // aggregated latitude
let lon = 0.0; // aggregated longitude
let latCellSize = 10; // Size in degrees latitude of the current cell. Starts at 20 and gets smaller as the calculation progresses
let lonCellSize = 20; // Size in degrees longitude of the current cell. Starts at 20 and gets smaller as the calculation progresses
let latCellNo; // grid latitude cell number this time
let lonCellNo; // grid longitude cell number this time
// Iterate through blocks (two-character sections)
for (let block = 0; block * 2 < len; block += 1) {
if (block % 2 === 0) {
// Letters in this block
lonCellNo = grid.charCodeAt(block * 2) - 'A'.charCodeAt(0);
latCellNo = grid.charCodeAt(block * 2 + 1) - 'A'.charCodeAt(0);
// Bail if the values aren't in range. Allowed values are A-R (0-17) for the first letter block, or
// A-X (0-23) thereafter.
let maxCellNo = (block === 0) ? 17 : 23;
if (latCellNo < 0 || latCellNo > maxCellNo || lonCellNo < 0 || lonCellNo > maxCellNo) {
return [null, null, null, null];
}
} else {
// Numbers in this block
lonCellNo = parseInt(grid.charAt(block * 2));
latCellNo = parseInt(grid.charAt(block * 2 + 1));
// Bail if the values aren't in range 0-9..
if (latCellNo < 0 || latCellNo > 9 || lonCellNo < 0 || lonCellNo > 9) {
return [null, null, null, null];
}
}
// Aggregate the angles
lat += latCellNo * latCellSize;
lon += lonCellNo * lonCellSize;
// Reduce the cell size for the next block, unless we are on the last cell.
if (block * 2 < len - 2) {
// Still have more work to do, so reduce the cell size
if (block % 2 === 0) {
// Just dealt with letters, next block will be numbers so cells will be 1/10 the current size
latCellSize = latCellSize / 10.0;
lonCellSize = lonCellSize / 10.0;
} else {
// Just dealt with numbers, next block will be letters so cells will be 1/24 the current size
latCellSize = latCellSize / 24.0;
lonCellSize = lonCellSize / 24.0;
}
}
}
// Offset back to (-180, -90) where the grid starts
lon -= 180.0;
lat -= 90.0;
// Return nulls on maths errors
if (isNaN(lat) || isNaN(lon) || isNaN(latCellSize) || isNaN(lonCellSize)) {
return [null, null, null, null];
}
return [lat, lon, latCellSize, lonCellSize];
}
// Function to set dark mode on or off
function enableDarkMode(dark) {
$("html").attr("data-bs-theme", dark ? "dark" : "light");
@@ -293,37 +165,6 @@ function usePreferredTheme() {
}
}
// Save settings to local storage. Suppressed if "embedded mode" is in use.
function saveSettings() {
if (!embeddedMode) {
// Find all storeable UI elements, store a key of "element id:property name" mapped to the value of that
// property. For a checkbox, that's the "checked" property.
$(".storeable-checkbox").each(function() {
localStorage.setItem("#" + $(this)[0].id + ":checked", JSON.stringify($(this)[0].checked));
});
$(".storeable-select").each(function() {
localStorage.setItem("#" + $(this)[0].id + ":value", JSON.stringify($(this)[0].value));
});
$(".storeable-text").each(function() {
localStorage.setItem("#" + $(this)[0].id + ":value", JSON.stringify($(this)[0].value));
});
}
}
// Load settings from local storage and set up the filter selectors. Suppressed if "embedded mode" is in use.
function loadSettings() {
if (!embeddedMode) {
// Find all local storage entries and push their data to the corresponding UI element
Object.keys(localStorage).forEach(function(key) {
if (key.startsWith("#") && key.includes(":")) {
// Split the key back into an element ID and a property
var split = key.split(":");
$(split[0]).prop(split[1], JSON.parse(localStorage.getItem(key)));
}
});
}
}
// Startup
$(document).ready(function() {
usePreferredTheme();

View File

@@ -45,12 +45,16 @@ function updateMap() {
// Create geodesics if required
if ($("#mapShowGeodesics")[0].checked && s["de_latitude"] != null && s["de_longitude"] != null) {
var geodesic = L.geodesic([[s["de_latitude"], s["de_longitude"]], m.getLatLng()], {
color: s["band_color"],
wrap: false,
steps: 5
});
geodesicsLayer.addLayer(geodesic);
try {
var geodesic = L.geodesic([[s["de_latitude"], s["de_longitude"]], m.getLatLng()], {
color: bandToColor(s['band']),
wrap: false,
steps: 5
});
geodesicsLayer.addLayer(geodesic);
} catch (e) {
// Not sure what causes these but better to continue than to crash out
}
}
});
}
@@ -58,9 +62,9 @@ function updateMap() {
// Get an icon for a spot, based on its band, using PSK Reporter colours, its program etc.
function getIcon(s) {
return L.ExtraMarkers.icon({
icon: "fa-" + s["icon"],
iconColor: s["band_contrast_color"],
markerColor: s["band_color"],
icon: sigToIcon(s["sig"], "fa-tower-cell"),
iconColor: bandToContrastColor(s["band"]),
markerColor: bandToColor(s["band"]),
shape: 'circle',
prefix: 'fa',
svg: true
@@ -136,7 +140,7 @@ function getTooltipText(s) {
ttt += "<br/>";
// Source / SIG / Ref
ttt += `<span class='nowrap'><span class='icon-wrapper'><i class='fa-solid fa-${s["icon"]}'></i></span>&nbsp;${sigSourceText} ${sig_refs}</span><br/>`;
ttt += `<span class='nowrap'><span class='icon-wrapper'><i class='fa-solid ${sigToIcon(s["sig"], "fa-tower-cell")}'></i></span>&nbsp;${sigSourceText} ${sig_refs}</span><br/>`;
// Time
ttt += `<span class='icon-wrapper'><i class='fa-solid fa-clock markerPopupIcon'></i></span>&nbsp;${moment.unix(s["time"]).fromNow()}`;
@@ -156,6 +160,21 @@ function loadOptions() {
// Store options
options = jsonData;
// Populate the Display panel
options["web-ui-options"]["max-spot-age"].forEach(sc => $("#max-spot-age").append($('<option>', {
value: sc * 60,
text: sc
})));
$("#max-spot-age").val(options["web-ui-options"]["max-spot-age-default"] * 60);
getAvailableBandColorSchemes().forEach(sc => $("#band-color-scheme").append($('<option>', {
value: sc,
text: sc
})));
// First pass loading settings, so we can load the band colour scheme before the filters that need to use it
loadSettings();
setBandColorScheme($("#band-color-scheme option:selected").val());
// Add CSS for band toggle buttons
addBandToggleColourCSS(options["bands"]);
@@ -167,13 +186,6 @@ function loadOptions() {
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
generateMultiToggleFilterCard("#source-options", "source", options["spot_sources"]);
// Populate the Display panel
options["web-ui-options"]["max-spot-age"].forEach(sc => $("#max-spot-age").append($('<option>', {
value: sc * 60,
text: sc
})));
$("#max-spot-age").val(options["web-ui-options"]["max-spot-age-default"] * 60);
// Load URL params. These may select things from the various filter & display options, so the function needs
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
// loading settings, so this needs to be called before that.

View File

@@ -1,35 +1,45 @@
// SSE event source
let evtSource;
let restartSSEOnErrorTimeoutId;
// Table row count, to alternate shading
let rowCount = 0;
// Load spots and populate the table.
function loadSpots() {
// If we have an ongoing SSE connection, stop it so it doesn't interfere with our reload
if (evtSource != null) {
evtSource.close();
}
// Make the new query
$.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) {
// Store last updated time
lastUpdateTime = moment.utc();
updateRefreshDisplay();
updateTimingDisplayRunPause();
// Store data
spots = jsonData;
// Update table
updateTable();
// Start SSE connection to fetch updates in the background
restartSSEConnection();
// Start SSE connection to fetch updates in the background, if we are in "run" mode
let run = $('#runButton:checked').val();
if (run) {
startSSEConnection();
}
});
}
// Start an SSE connection (closing an existing one if it exists). This will then be used to add to the table on the
// fly.
function restartSSEConnection() {
function startSSEConnection() {
if (evtSource != null) {
evtSource.close();
}
evtSource = new EventSource('/api/v1/spots/stream');
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString());
evtSource.onmessage = function(event) {
// Store last updated time
lastUpdateTime = moment.utc();
updateRefreshDisplay();
updateTimingDisplayRunPause();
// Get the new spot
newSpot = JSON.parse(event.data);
// Awful fudge to ensure new incoming spots at the top of the list don't have timestamps that make them look
@@ -42,17 +52,38 @@ function restartSSEConnection() {
// Add spot to internal data store
spots.unshift(newSpot);
spots = spots.slice(0, -1);
// Add spot to table
// Work out if we need to remove an old spot
if (spots.length > $("#spots-to-fetch option:selected").val()) {
spots = spots.slice(0, -1);
// Drop oldest spot off the end of the table. This is two rows because of the mobile view extra rows
$("#table tbody tr").last().remove();
$("#table tbody tr").last().remove();
}
// If we had zero spots before (i.e. one now), the table will have a "No spots" row that we need to remove now
// that we have one.
if (spots.length == 1) {
$("#table tbody tr").last().remove();
}
// Add the new spot to table
addSpotToTopOfTable(newSpot, true);
};
evtSource.onerror = function(err) {
evtSource.close();
setTimeout(restartSSEConnection, 1000);
if (evtSource != null) {
evtSource.close();
}
clearTimeout(restartSSEOnErrorTimeoutId)
restartSSEOnErrorTimeoutId = setTimeout(startSSEConnection, 1000);
};
}
// Update the special timing display for the live spots page, which varies depending on run/pause selection.
function updateTimingDisplayRunPause() {
let run = $('#runButton:checked').val();
$("#timing-container").html((run ? "Connected to server. Last update at " : "Paused at ") + lastUpdateTime.format('HH:mm') + " UTC.");
}
// Build a query string for the API, based on the filters that the user has selected.
function buildQueryString() {
var str = "?";
@@ -62,8 +93,8 @@ function buildQueryString() {
}
});
str = str + "limit=" + $("#spots-to-fetch option:selected").val();
if ($("#filter-dx-call").val() != "") {
str = str + "&dx_call_includes=" + encodeURIComponent($("#filter-dx-call").val());
if ($("#search").val() != "") {
str = str + "&text_includes=" + encodeURIComponent($("#search").val());
}
return str;
}
@@ -123,8 +154,8 @@ function updateTable() {
table.find('tbody').append('<tr class="table-danger"><td colspan="100" style="text-align:center;">No spots match your filters.</td></tr>');
}
spots.reverse();
spots.forEach(s => addSpotToTopOfTable(s, false));
let spotsNewestFirst = spots.toReversed();
spotsNewestFirst.forEach(s => addSpotToTopOfTable(s, false));
}
// Add rows corresponding to a new spot to the top of the table
@@ -158,9 +189,6 @@ function createNewTableRowsForSpot(s, highlightNew) {
// Create row
let $tr = $('<tr>');
if (highlightNew) {
$tr.addClass("new");
}
// Apply striping to the table. We can't just use Bootstrap's table-striped class because we have all sorts of
// extra faff to deal with, like the mobile view having extra rows, and the On Now / Next 24h / Later banners
@@ -171,7 +199,13 @@ function createNewTableRowsForSpot(s, highlightNew) {
// Show faded out if QRT
if (s["qrt"] == true) {
$tr.addClass("table-faded");
$tr.addClass("table-faded");
}
// If we are asked to highlight new rows (i.e. this row is being added "live" via the SSE client and not as a bulk
// reload of the whole table)
if (highlightNew) {
$tr.addClass("new");
}
// Format a UTC or local time for display
@@ -216,9 +250,8 @@ function createNewTableRowsForSpot(s, highlightNew) {
// Format the mode
mode_string = s["mode"];
if (s["mode"] == null) {
mode_string = "???";
}
if (s["mode_source"] == "BANDPLAN") {
mode_string = "";
} else if (s["mode_source"] == "BANDPLAN") {
mode_string = mode_string + "<span class='mode-q hideonmobile'><i class='fa-solid fa-circle-question' title='The mode was not reported via the spotting service. This is a guess based on the frequency.'></i></span>";
}
@@ -254,9 +287,9 @@ function createNewTableRowsForSpot(s, highlightNew) {
var items = []
for (var i = 0; i < s["sig_refs"].length; i++) {
if (s["sig_refs"][i]["url"] != null) {
items[i] = `<a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${s["sig_refs"][i]["id"]}</a>`
items[i] = `<span style="white-space: nowrap;"><a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${s["sig_refs"][i]["id"]}</a></span>`
} else {
items[i] = `${s["sig_refs"][i]["id"]}`
items[i] = `<span style="white-space: nowrap;">${s["sig_refs"][i]["id"]}</span>`
}
}
sig_refs = items.join(", ");
@@ -292,10 +325,10 @@ function createNewTableRowsForSpot(s, highlightNew) {
$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"] : ""}'>${dx_call}</a></td>`);
$tr.append(`<td class='nowrap'><span class='flag-wrapper' 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='${(s["freq"] != null) ? "color: " + s["band_color"] : "display: none;"}'>&#9632;</span>${freq_string}</td>`);
$tr.append(`<td class='nowrap'><span class='band-bullet' title='${bandFullName}' style='${(s["freq"] != null) ? "color: " + bandToColor(s["band"]) : "display: none;"}'>&#9632;</span>${freq_string}</td>`);
}
if (showMode) {
$tr.append(`<td class='nowrap'>${mode_string}</td>`);
@@ -307,10 +340,10 @@ function createNewTableRowsForSpot(s, highlightNew) {
$tr.append(`<td class='nowrap hideonmobile'>${bearingText}</td>`);
}
if (showType) {
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid fa-${s["icon"]}'></i></span> ${typeText}</td>`);
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid ${sigToIcon(s["sig"], "fa-tower-cell")}'></i></span> ${typeText}</td>`);
}
if (showRef) {
$tr.append(`<td class='hideonmobile'>${sig_refs}</td>`);
$tr.append(`<td class='hideonmobile' style='max-width: 11em;'>${sig_refs}</td>`);
}
if (showDE) {
$tr.append(`<td class='nowrap hideonmobile'><span class='flag-wrapper' title='${de_country}'>${de_flag}</span>${de_call}</td>`);
@@ -318,24 +351,38 @@ function createNewTableRowsForSpot(s, highlightNew) {
// Second row for mobile view only, containing type, ref & comment
$tr2 = $("<tr class='hidenotonmobile'>");
// Apply styles as per the first row
if (rowCount % 2 == 1) {
$tr2.addClass("table-active");
}
if (s["qrt"] == true) {
$tr2.addClass("table-faded");
$tr2.addClass("table-faded");
}
if (highlightNew) {
$tr2.addClass("new");
}
$td2 = $("<td colspan='100'>");
$td2floatleft = $(`<div style="float: left;">`);
if (showType) {
$td2.append(`<span class='icon-wrapper'><i class='fa-solid fa-${s["icon"]}'></i></span> ${typeText} `);
$td2floatleft.append(`<span class='icon-wrapper'><i class='fa-solid ${sigToIcon(s["sig"], "fa-tower-cell")}'></i></span> ${typeText} `);
}
if (showRef) {
$td2.append(`${sig_refs} `);
$td2floatleft.append(`${sig_refs} `);
}
$td2.append($td2floatleft);
$td2floatright = $(`<div style="float: right;">`);
if (showBearing) {
$td2.append(` &nbsp; Bearing: ${bearingText} `);
$td2floatright.append(`${bearingText} &nbsp;`);
}
if (showDE) {
$td2floatright.append(` de ${de_call} &nbsp;`);
}
$td2.append($td2floatright);
$td2.append(`</div><div style="clear: both;"></div>`);
if (showComment) {
$td2.append(`<br/>${commentText}`);
$td2.append(`${commentText}`);
}
$tr2.append($td2);
@@ -351,6 +398,21 @@ function loadOptions() {
// Store options
options = jsonData;
// Populate the Display panel
options["web-ui-options"]["spot-count"].forEach(sc => $("#spots-to-fetch").append($('<option>', {
value: sc,
text: sc
})));
$("#spots-to-fetch").val(options["web-ui-options"]["spot-count-default"]);
getAvailableBandColorSchemes().forEach(sc => $("#band-color-scheme").append($('<option>', {
value: sc,
text: sc
})));
// First pass loading settings, so we can load the band colour scheme before the filters that need to use it
loadSettings();
setBandColorScheme($("#band-color-scheme option:selected").val());
// Add CSS for band toggle buttons
addBandToggleColourCSS(options["bands"]);
@@ -362,13 +424,6 @@ function loadOptions() {
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
generateMultiToggleFilterCard("#source-options", "source", options["spot_sources"]);
// Populate the Display panel
options["web-ui-options"]["spot-count"].forEach(sc => $("#spots-to-fetch").append($('<option>', {
value: sc,
text: sc
})));
$("#spots-to-fetch").val(options["web-ui-options"]["spot-count-default"]);
// Load URL params. These may select things from the various filter & display options, so the function needs
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
// loading settings, so this needs to be called before that.
@@ -385,11 +440,6 @@ function loadOptions() {
$("#tableShowBearing").prop('checked', false);
}
// Show the Add Spot button if spotting is allowed
if (options["spot_allowed"]) {
$("#add-spot-button").show();
}
// Load spots (this will also set up the SSE connection to update them too)
loadSpots();
});
@@ -459,4 +509,19 @@ $(document).ready(function() {
loadOptions();
// Display intro box
displayIntroBox();
// Set up run/pause toggles
$("#runButton").change(function() {
// Need to start the SSE connection but also do a full re-query to catch up anything that we missed, so we
// might as well just call loadSpots again which will trigger it all
loadSpots();
updateTimingDisplayRunPause();
});
$("#pauseButton").change(function() {
// If we are pausing and have an open SSE connection, stop it
if (evtSource != null) {
evtSource.close();
}
updateTimingDisplayRunPause();
});
});

View File

@@ -8,8 +8,8 @@ function addBandToggleColourCSS(band_options) {
band_options.forEach(o => {
// CSS doesn't like IDs with decimal points in, so we need to replace that
var cssFormattedBandName = o['name'] ? o['name'].replace('.', 'p') : "unknown";
$style.append(`#filter-button-label-band-${cssFormattedBandName} { border-color: ${o['color']}; color: var(--bs-primary);}`);
$style.append(`.btn-check:checked + #filter-button-label-band-${cssFormattedBandName} { background-color: ${o['color']}; color: ${o['contrast_color']};}`);
$style.append(`#filter-button-label-band-${cssFormattedBandName} { border-color: ${bandToColor(o['name'])}; color: var(--bs-primary);}`);
$style.append(`.btn-check:checked + #filter-button-label-band-${cssFormattedBandName} { background-color: ${bandToColor(o['name'])}; color: ${bandToContrastColor(o['name'])};}`);
});
$('html > head').append($style);
}
@@ -23,15 +23,25 @@ function generateBandsMultiToggleFilterCard(band_options) {
var cssFormattedBandName = o['name'] ? o['name'].replace('.', 'p') : "unknown";
$("#band-options").append(`<input type="checkbox" class="btn-check filter-button-band storeable-checkbox" name="options" id="filter-button-band-${cssFormattedBandName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline" id="filter-button-label-band-${cssFormattedBandName}" for="filter-button-band-${cssFormattedBandName}">${o['name']}</label> `);
});
// Create All/None buttons
$("#band-options").append(` <span style="display: inline-block"><button id="filter-button-band-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', true);">All</button>&nbsp;<button id="filter-button-band-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', false);">None</button></span>`);
// Create All/None/Ham HF buttons
$("#band-options").append(` <span style="display: inline-block"><button id="filter-button-band-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', true);">All</button> <button id="filter-button-band-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', false);">None</button> <button id="filter-button-band-none" type="button" class="btn btn-outline-secondary" onclick="setHamHFBandToggles();">Ham HF</button></span>`);
}
// Set the band toggles so that only the amateur radio HF bands are selected. This includes 160m and 6m because that's
// widely expected by hams to be included. Special case of toggleFilterButtons().
function setHamHFBandToggles() {
const hamHFBands = ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"];
$(".filter-button-band").each(function() {
$(this).prop('checked', hamHFBands.includes($(this).val().replace("filter-button-band-", "")));
});
filtersUpdated();
}
// 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> `);
$("#sig-options").append(`<input type="checkbox" class="btn-check filter-button-sig storeable-checkbox" name="options" id="filter-button-sig-${o['name']}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-primary" id="filter-button-label-sig-${o['name']}" for="filter-button-sig-${o['name']}" title="${o['description']}"><i class="fa-solid ${sigToIcon(o['name'], 'fa-tower-cell')}"></i> ${o['name']}</label> `);
});
// 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> `);
@@ -49,4 +59,20 @@ function filtersUpdated() {
function toggleDarkMode() {
enableDarkMode($("#darkMode")[0].checked);
saveSettings();
}
}
// Function to update the band colour scheme in spots, bands and map pages
function setBandColorSchemeFromUI() {
setBandColorScheme($("#band-color-scheme option:selected").val());
saveSettings();
// Fudge a full reload because we need to update not just colours in the list/map/bands but also the filters
window.location.reload();
}
// Reload spots on becoming visible. This forces a refresh when used as a PWA and the user switches back to the PWA
// after some time has passed with it in the background.
addEventListener("visibilitychange", (event) => {
if (!document.hidden) {
loadSpots();
}
});