mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-08-06 10:31:42 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e3933d5d | ||
|
|
eee0d70140 | ||
|
|
600a330e63 | ||
|
|
f2a6bfa515 | ||
|
|
4d29e0026f | ||
|
|
c7b6163440 | ||
|
|
c51648738f | ||
|
|
88923bc96e | ||
|
|
565a3f7564 | ||
|
|
b09108b41b | ||
|
|
72f367f607 | ||
|
|
998b394d2c | ||
|
|
d1df772649 | ||
|
|
215b61593b | ||
|
|
eb1d575623 | ||
|
|
e4c3a52299 |
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding">
|
||||
<file url="file://$PROJECT_DIR$/datafiles/MUNICIPIOS.csv" charset="ISO-8859-1" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+1
@@ -2,6 +2,7 @@
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/webassets/vendor" />
|
||||
</content>
|
||||
|
||||
@@ -19,6 +19,8 @@ Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), th
|
||||
SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, LLOTA, WWTOTA, Tiles on the Air, the UK Packet
|
||||
Repeater Network, NG3K, and any site based on the xOTA software by nischu.
|
||||
|
||||
Additional Special Interest Groups (SIGs) without their own specific data source include WAB, WAI and DME.
|
||||
|
||||

|
||||
|
||||

|
||||
@@ -93,9 +95,9 @@ Various approaches exist to writing your own client, but in general:
|
||||
`https://spothole.app/api/v1/spots` once every few minutes. 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/alerts APIs, to allow you to populate your filters correctly.
|
||||
* Refer to the provided HTML/JS interface for a reference on different approaches. For example, the "map" and "bands"
|
||||
pages simply query the main spot API on a timer, whereas the main/spots page combines this approach with using the
|
||||
Server-Sent Events (SSE) endpoint to update live.
|
||||
* Refer to the provided HTML/JS interface for a reference on different approaches. For example, the "alerts"/"upcoming"
|
||||
page simply query the main spot API on a timer, whereas the spots, map and bands pages combine this approach with
|
||||
using the Server-Sent Events (SSE) endpoint to update live.
|
||||
* Let me know if you get stuck, I'm happy to help.
|
||||
|
||||
Please don't hammer the API with an unnecessarily high request rate. For example, Spothole only queries the POTA API
|
||||
@@ -515,7 +517,11 @@ The same approach as above is also used for alert providers.
|
||||
As well as being my work, I have also gratefully received feature patches from Steven, M1SDH.
|
||||
|
||||
The project contains GeoJSON files for CQ and ITU zones, in the `/datafiles/` directory. These are MIT-licenced and, to
|
||||
my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet.
|
||||
my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet. `/datafiles` also contains a
|
||||
`MUNICIPIOS.csv` file, from the "Nomenclátor Geográfico de Municipios y Entidades de Población" data set sourced from
|
||||
[el Centro Nacional de Información Geográfica](https://centrodedescargas.cnig.es/CentroDescargas/home).
|
||||
`didbase-stations.csv` and the TOTA CSV files were created by me based on publicly available data from GIRO and from
|
||||
maps of conference centres.
|
||||
|
||||
The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the
|
||||
`/webassets/img/flags/` directory.
|
||||
|
||||
+6
-2
@@ -66,8 +66,8 @@ spot-providers:
|
||||
name: "LLOTA"
|
||||
enabled: true
|
||||
|
||||
- class: "WWTOTA"
|
||||
name: "WWTOTA"
|
||||
- class: "Towers"
|
||||
name: "Towers"
|
||||
enabled: true
|
||||
|
||||
- class: "Tiles"
|
||||
@@ -211,6 +211,10 @@ clublog-api-key: ""
|
||||
# Allow submitting spots to the Spothole API?
|
||||
allow-spotting: true
|
||||
|
||||
# Log web requests? Useful to see what your users are requesting in terms of pages and API endpoints, but will fill up
|
||||
# your log quickly on a popular server.
|
||||
log-web-requests: false
|
||||
|
||||
# Options for the web UI.
|
||||
web-ui-options:
|
||||
spot-count: [ 10, 25, 50, 100 ]
|
||||
|
||||
@@ -22,6 +22,7 @@ WEB_SERVER_PORT = config["web-server-port"]
|
||||
ALLOW_SPOTTING = config["allow-spotting"]
|
||||
WEB_UI_OPTIONS = config["web-ui-options"]
|
||||
API_ONLY_MODE = config.get("api-only-mode", False)
|
||||
LOG_WEB_REQUESTS = config.get("log-web-requests", False)
|
||||
|
||||
# For ease of config, each spot provider owns its own config about whether it should be enabled by default in the web UI
|
||||
# but for consistency we provide this to the front-end in web-ui-options because it has no impact outside of the web UI.
|
||||
|
||||
+25
-24
@@ -11,29 +11,29 @@ HAMQTH_PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_C
|
||||
|
||||
# Special Interest Groups
|
||||
SIGS = [
|
||||
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="LLOTA", description="Lagos y Lagunas on the Air", ref_regex=r"[A-Z]{2}\-\d{4}"),
|
||||
SIG(name="WWTOTA", description="Towers on the Air", ref_regex=r"[A-Z]{2}R\-\d{4}"),
|
||||
SIG(name="Tiles", description="Tiles on the Air", ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}"),
|
||||
SIG(name="WAB", description="Worked All Britain", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
|
||||
SIG(name="WAI", description="Worked All Ireland", ref_regex=r"[A-Z][0-9]{2}"),
|
||||
SIG(name="DME", description="Diplomas de Municipios Españoles", ref_regex=r"\d{4,5}"),
|
||||
SIG(name="TOTA", description="Toilets on the Air", ref_regex=r"T\-[0-9]{2}")
|
||||
SIG(name="POTA", comment_names=["POTA"], description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}"),
|
||||
SIG(name="SOTA", comment_names=["SOTA"], description="Summits on the Air", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
|
||||
SIG(name="WWFF", comment_names=["WWFF"], description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"),
|
||||
SIG(name="GMA", comment_names=["GMA"], description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
|
||||
SIG(name="WWBOTA", comment_names=["WWBOTA", "BOTA"], description="Worldwide Bunkers on the Air", ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}"),
|
||||
SIG(name="HEMA", comment_names=["HEMA"], description="HuMPs Excluding Marilyns Award", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}"),
|
||||
SIG(name="IOTA", comment_names=["IOTA"], description="Islands on the Air", ref_regex=r"[A-Z]{2}\-\d{3}"),
|
||||
SIG(name="MOTA", comment_names=["MOTA"], description="Mills on the Air", ref_regex=r"X\d{4,6}"),
|
||||
SIG(name="ARLHS", comment_names=["ARLHS"], description="Amateur Radio Lighthouse Society", ref_regex=r"[A-Z]{3}\-\d{3,4}"),
|
||||
SIG(name="ILLW", comment_names=["ILLW"], description="International Lighthouse & Lightship Weekend", ref_regex=r"[A-Z]{2}\d{4}"),
|
||||
SIG(name="SiOTA", comment_names=["SIOTA"], description="Silos on the Air", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"),
|
||||
SIG(name="WCA", comment_names=["WCA"], description="World Castles Award", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"),
|
||||
SIG(name="ZLOTA", comment_names=["ZLOTA"], description="New Zealand on the Air", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}"),
|
||||
SIG(name="WOTA", comment_names=["WOTA"], description="Wainwrights on the Air", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
|
||||
SIG(name="BOTA", comment_names=[], description="Beaches on the Air"),
|
||||
SIG(name="KRMNPA", comment_names=["KRMNPA"], description="Keith Roget Memorial National Parks Award"),
|
||||
SIG(name="LLOTA", comment_names=["LLOTA"], description="Lagos y Lagunas on the Air", ref_regex=r"LL[A-Z]{2}\-\d{4}"),
|
||||
SIG(name="Towers", comment_names=["TOTA"], description="Towers on the Air", ref_regex=r"[A-Z]{2,3}R\-\d{4}"),
|
||||
SIG(name="Tiles", comment_names=[], description="Tiles on the Air", ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}"),
|
||||
SIG(name="WAB", comment_names=["WAB"], description="Worked All Britain", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
|
||||
SIG(name="WAI", comment_names=["WAI"], description="Worked All Ireland", ref_regex=r"[A-Z][0-9]{2}"),
|
||||
SIG(name="DME", comment_names=["DME"], description="Diplomas de Municipios Españoles", ref_regex=r"\d{4,5}"),
|
||||
SIG(name="Toilets", comment_names=[], 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".
|
||||
@@ -102,5 +102,6 @@ PROPAGATION_MODES = {
|
||||
"AU": "Aurora",
|
||||
"MS": "Meteor scatter",
|
||||
"RS": "Rain scatter",
|
||||
"AS": "Aircraft scatter"
|
||||
"AS": "Aircraft scatter",
|
||||
"SAT": "Satellite"
|
||||
}
|
||||
|
||||
+21
-11
@@ -7,8 +7,10 @@ from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
|
||||
from core.constants import SIGS, HTTP_HEADERS
|
||||
from core.geo_utils import wab_wai_square_to_lat_lon
|
||||
|
||||
with open("datafiles/dme-geodata.csv", encoding="utf-8") as _f:
|
||||
_DME_INDEX = {row["dme"]: row for row in csv.DictReader(_f)}
|
||||
# Load Spanish municipality data for the DME programme. There's no convenient lookup API for this, so we embed the data
|
||||
# file in Spothole and load it on startup.
|
||||
with open("datafiles/MUNICIPIOS.csv", encoding="latin-1") as _f:
|
||||
_DME_INDEX = {row["COD_INE"][:5]: row for row in csv.DictReader(_f, delimiter=";")}
|
||||
|
||||
|
||||
def get_ref_regex_for_sig(sig):
|
||||
@@ -20,6 +22,16 @@ def get_ref_regex_for_sig(sig):
|
||||
return None
|
||||
|
||||
|
||||
def get_sig_name_from_comment_name(sig):
|
||||
"""Utility function to get the name of a SIG from its "comment name". Generally these will be the same but there are
|
||||
some cases (e.g. is "TOTA" Towers, Tiles or Toilets?) where we need to transform one to the other."""
|
||||
|
||||
for s in SIGS:
|
||||
if any(n.upper() == sig.upper() for n in s.comment_names):
|
||||
return s.name
|
||||
return None
|
||||
|
||||
|
||||
def populate_sig_ref_info(sig_ref):
|
||||
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object which
|
||||
must at minimum have a "sig" and an "id". The rest of the object will be populated and returned.
|
||||
@@ -192,11 +204,12 @@ def populate_sig_ref_info(sig_ref):
|
||||
except:
|
||||
logging.debug("Invalid lat/lon received for reference")
|
||||
elif sig.upper() == "DME":
|
||||
row = _DME_INDEX.get(ref_id)
|
||||
# Zero-pad to 5 digits to match our source data
|
||||
row = _DME_INDEX.get(ref_id.zfill(5))
|
||||
if row:
|
||||
sig_ref.name = row["municipio"] + ", " + row["provincia"]
|
||||
sig_ref.latitude = float(row["lat"]) if row.get("lat") else None
|
||||
sig_ref.longitude = float(row["lon"]) if row.get("lon") else None
|
||||
sig_ref.name = row["NOMBRE_ACTUAL"] + ", " + row["PROVINCIA"]
|
||||
sig_ref.latitude = float(row["LATITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get("LATITUD_ETRS89_REGCAN95") else None
|
||||
sig_ref.longitude = float(row["LONGITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get("LONGITUD_ETRS89_REGCAN95") else None
|
||||
if sig_ref.latitude and sig_ref.longitude:
|
||||
try:
|
||||
sig_ref.grid = latlong_to_locator(sig_ref.latitude, sig_ref.longitude, 6)
|
||||
@@ -207,8 +220,5 @@ def populate_sig_ref_info(sig_ref):
|
||||
return sig_ref
|
||||
|
||||
|
||||
# Regex matching any SIG
|
||||
ANY_SIG_REGEX = r"(" + r"|".join(list(map(lambda p: p.name, SIGS))) + r")"
|
||||
|
||||
# Regex matching any SIG reference
|
||||
ANY_XOTA_SIG_REF_REGEX = r"[\w\/]+\-\d+"
|
||||
# Regex matching any SIG's "comment name", i.e. how it may be referred to in spot comments
|
||||
ANY_SIG_REGEX = r"(" + r"|".join(n for s in SIGS for n in s.comment_names) + r")"
|
||||
|
||||
+8
-5
@@ -1,8 +1,11 @@
|
||||
def serialize_everything(obj):
|
||||
"""Convert objects to serialisable things. Used by JSON serialiser as a default when it encounters unserializable things.
|
||||
Just converts objects to dict. Try to avoid doing anything clever here when serialising spots, because we also need
|
||||
to receive spots without complex handling."""
|
||||
return obj.__dict__
|
||||
import simplejson
|
||||
|
||||
|
||||
def safe_json_dumps(obj):
|
||||
"""Safe version of json.dumps that also converts objects to dicts so they can be output, and ignores NaN floats
|
||||
which are invalid in JSON."""
|
||||
|
||||
return simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, default=lambda o: o.__dict__)
|
||||
|
||||
|
||||
def empty_queue(q):
|
||||
|
||||
+13
-5
@@ -1,13 +1,21 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class SIG:
|
||||
"""Data class that defines a Special Interest Group."""
|
||||
"""Data class that defines a Special Interest Group. Each contains a name and a longer form description.
|
||||
They also contain comment_names which attempts to separate out the way people might refer to it in
|
||||
cluster comments from how it is referred to in the UI & API. (For example, "TOTA" in cluster spot comments
|
||||
almost always means Towers on the Air, but no single programme is referred to in the UI as "TOTA" as
|
||||
it's ambiguous between Towers, Toilets and Tiles. And while Beaches got the name "BOTA" first, "BOTA" spots
|
||||
are much more likely to be bunkers.) Finally, there is a ref_regex which provides a regular expression to
|
||||
match what references (such as parks and summits) look like for that programme."""
|
||||
|
||||
# SIG name, e.g. "POTA"
|
||||
# SIG name as used in the UI and API, e.g. "Towers"
|
||||
name: str
|
||||
# Description, e.g. "Parks on the Air"
|
||||
# Description, e.g. "Towers on the Air"
|
||||
description: str
|
||||
# SIG names as they might appear in cluster spot comments, e.g. ["TOTA"]
|
||||
comment_names: list[str] = field(default_factory=list)
|
||||
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
|
||||
ref_regex: str = None
|
||||
ref_regex: str | None = None
|
||||
|
||||
+14
-3
@@ -14,7 +14,7 @@ from core.constants import MODE_ALIASES, PROPAGATION_MODES
|
||||
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
||||
from core.lookup_helper import lookup_helper, infer_band_from_freq, infer_mode_from_comment, \
|
||||
infer_mode_from_frequency, infer_mode_type_from_mode
|
||||
from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig
|
||||
from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig, get_sig_name_from_comment_name
|
||||
from data.sig_ref import SIGRef
|
||||
|
||||
|
||||
@@ -133,6 +133,17 @@ class Spot:
|
||||
# The ID the source gave it, if any.
|
||||
source_id: str | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Normalise fields that don't survive a plain dict to Spot conversion. This is used in the "add spot" API
|
||||
endpoint where the client is submitting JSON, and we want to recreate a full Spot object, including nested
|
||||
objects such as the sig_refs list.."""
|
||||
|
||||
if self.sig_refs:
|
||||
self.sig_refs = [
|
||||
sig_ref if isinstance(sig_ref, SIGRef) else SIGRef(**sig_ref)
|
||||
for sig_ref in self.sig_refs
|
||||
]
|
||||
|
||||
def infer_missing(self, credentials=None):
|
||||
"""Infer missing parameters where possible"""
|
||||
|
||||
@@ -255,7 +266,7 @@ class Spot:
|
||||
for sig_match in sig_matches:
|
||||
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster
|
||||
# spots where the comment is just "POTA".
|
||||
found_sig = sig_match.group(2).upper()
|
||||
found_sig = get_sig_name_from_comment_name(sig_match.group(2))
|
||||
if not self.sig:
|
||||
self.sig = found_sig
|
||||
|
||||
@@ -312,7 +323,7 @@ class Spot:
|
||||
self.propagation_mode = PROPAGATION_MODES[mode_tag]
|
||||
else:
|
||||
self.propagation_mode = mode_tag
|
||||
logging.info("Seen a new propagation mode tag not yet in the system: {}", mode_tag)
|
||||
logging.info("Seen a new propagation mode tag not yet in the system: %s", mode_tag)
|
||||
|
||||
# DX Grid to lat/lon and vice versa in case one is missing
|
||||
if self.dx_grid and not self.dx_latitude:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -17,3 +17,4 @@ websocket-client~=1.8.0
|
||||
tornado~=6.4.2
|
||||
tornado_eventsource~=3.0.0
|
||||
geopandas~=0.13.2
|
||||
simplejson~=4.1.1
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
@@ -14,8 +13,7 @@ from core.constants import UNKNOWN_BAND
|
||||
from core.lookup_helper import infer_band_from_freq
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.sig_utils import get_ref_regex_for_sig
|
||||
from core.utils import serialize_everything
|
||||
from data.sig_ref import SIGRef
|
||||
from core.utils import safe_json_dumps
|
||||
from data.spot import Spot
|
||||
|
||||
|
||||
@@ -42,8 +40,7 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
# Reject if not allowed
|
||||
if not ALLOW_SPOTTING:
|
||||
self.set_status(401)
|
||||
self.write(json.dumps("Error - this server does not allow new spots to be added via the API.",
|
||||
default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - this server does not allow new spots to be added via the API."))
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
@@ -51,8 +48,7 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
# Reject if format not json
|
||||
if not self.request.headers.get('Content-Type', '').startswith("application/json"):
|
||||
self.set_status(415)
|
||||
self.write(
|
||||
json.dumps("Error - request Content-Type must be application/json", default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - request Content-Type must be application/json"))
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
@@ -61,7 +57,7 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
post_data = self.request.body
|
||||
if not post_data:
|
||||
self.set_status(422)
|
||||
self.write(json.dumps("Error - request body is empty", default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - request body is empty"))
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
@@ -70,19 +66,10 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
json_spot = tornado.escape.json_decode(post_data)
|
||||
spot = Spot(**json_spot)
|
||||
|
||||
# Converting to a spot object this way won't have coped with sig_ref objects, so fix that. (Would be nice to
|
||||
# redo this in a functional style)
|
||||
if spot.sig_refs:
|
||||
real_sig_refs = []
|
||||
for dict_obj in spot.sig_refs:
|
||||
real_sig_refs.append(json.loads(json.dumps(dict_obj), object_hook=lambda d: SIGRef(**d)))
|
||||
spot.sig_refs = real_sig_refs
|
||||
|
||||
# Reject if no timestamp, frequency, dx_call or de_call
|
||||
if not spot.time or not spot.dx_call or not spot.freq or not spot.de_call:
|
||||
self.set_status(422)
|
||||
self.write(json.dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum.",
|
||||
default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum."))
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
@@ -90,15 +77,13 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
# Reject invalid-looking callsigns
|
||||
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call):
|
||||
self.set_status(422)
|
||||
self.write(json.dumps("Error - '" + spot.dx_call + "' does not look like a valid callsign.",
|
||||
default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - '" + spot.dx_call + "' does not look like a valid callsign."))
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call):
|
||||
self.set_status(422)
|
||||
self.write(json.dumps("Error - '" + spot.de_call + "' does not look like a valid callsign.",
|
||||
default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - '" + spot.de_call + "' does not look like a valid callsign."))
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
@@ -106,8 +91,7 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
# Reject if frequency not in a known band
|
||||
if infer_band_from_freq(spot.freq) == UNKNOWN_BAND:
|
||||
self.set_status(422)
|
||||
self.write(json.dumps("Error - Frequency of " + str(spot.freq / 1000.0) + "kHz is not in a known band.",
|
||||
default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - Frequency of " + str(spot.freq / 1000.0) + "kHz is not in a known band."))
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
@@ -117,8 +101,7 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
r"^([A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}|[A-R]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2})$",
|
||||
spot.dx_grid.upper()):
|
||||
self.set_status(422)
|
||||
self.write(json.dumps("Error - '" + spot.dx_grid + "' does not look like a valid Maidenhead grid.",
|
||||
default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - '" + spot.dx_grid + "' does not look like a valid Maidenhead grid."))
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
@@ -127,9 +110,8 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
if spot.sig and spot.sig_refs and len(spot.sig_refs) > 0 and spot.sig_refs[0].id and get_ref_regex_for_sig(
|
||||
spot.sig) and not re.match(get_ref_regex_for_sig(spot.sig), spot.sig_refs[0].id):
|
||||
self.set_status(422)
|
||||
self.write(json.dumps(
|
||||
"Error - '" + spot.sig_refs[0].id + "' does not look like a valid reference for " + spot.sig + ".",
|
||||
default=serialize_everything))
|
||||
self.write(safe_json_dumps(
|
||||
"Error - '" + spot.sig_refs[0].id + "' does not look like a valid reference for " + spot.sig + "."))
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
@@ -139,14 +121,14 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
spot.infer_missing()
|
||||
self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
|
||||
|
||||
self.write(json.dumps("OK", default=serialize_everything))
|
||||
self.write(safe_json_dumps("OK"))
|
||||
self.set_status(201)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||
self.set_status(500)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from queue import Queue
|
||||
@@ -12,7 +11,7 @@ from tornado import httputil
|
||||
from tornado.web import Application
|
||||
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import serialize_everything, empty_queue
|
||||
from core.utils import safe_json_dumps, empty_queue
|
||||
from data.lookup_credentials import extract_credentials
|
||||
|
||||
SSE_HANDLER_MAX_QUEUE_SIZE = 100
|
||||
@@ -57,15 +56,15 @@ class APIAlertsHandler(tornado.web.RequestHandler):
|
||||
data = get_alert_list_with_filters(self._alerts, query_params)
|
||||
if credentials:
|
||||
data = self._enrich(data, credentials)
|
||||
self.write(json.dumps(data, default=serialize_everything))
|
||||
self.write(safe_json_dumps(data))
|
||||
self.set_status(200)
|
||||
except ValueError as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Bad request - " + str(e), default=serialize_everything))
|
||||
self.write(safe_json_dumps("Bad request - " + str(e)))
|
||||
self.set_status(400)
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||
self.set_status(500)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
@@ -150,7 +149,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
||||
if self._credentials:
|
||||
alert = copy.deepcopy(alert)
|
||||
alert.infer_missing(self._credentials)
|
||||
self.write_message(msg=json.dumps(alert, default=serialize_everything))
|
||||
self.write_message(msg=safe_json_dumps(alert))
|
||||
|
||||
else:
|
||||
# Send a keepalive comment if the queue was empty
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
@@ -13,7 +12,7 @@ from core.constants import SIGS
|
||||
from core.geo_utils import lat_lon_for_grid_sw_corner_plus_size, lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.sig_utils import get_ref_regex_for_sig, populate_sig_ref_info
|
||||
from core.utils import serialize_everything
|
||||
from core.utils import safe_json_dumps
|
||||
from data.lookup_credentials import extract_credentials
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
@@ -65,19 +64,18 @@ class APILookupCallHandler(tornado.web.RequestHandler):
|
||||
"longitude": fake_spot.dx_longitude,
|
||||
"location_source": fake_spot.dx_location_source
|
||||
}
|
||||
self.write(json.dumps(data, default=serialize_everything))
|
||||
self.write(safe_json_dumps(data))
|
||||
|
||||
else:
|
||||
self.write(json.dumps("Error - '" + call + "' does not look like a valid callsign.",
|
||||
default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - '" + call + "' does not look like a valid callsign."))
|
||||
self.set_status(422)
|
||||
else:
|
||||
self.write(json.dumps("Error - call must be provided", default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - call must be provided"))
|
||||
self.set_status(422)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||
self.set_status(500)
|
||||
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
@@ -111,27 +109,25 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
|
||||
if "sig" in query_params.keys() and "id" in query_params.keys():
|
||||
sig = str(query_params.get("sig")).upper()
|
||||
ref_id = str(query_params.get("id")).upper()
|
||||
if sig in list(map(lambda p: p.name, SIGS)):
|
||||
if sig in list(map(lambda p: p.name.upper(), SIGS)):
|
||||
if not get_ref_regex_for_sig(sig) or re.match(get_ref_regex_for_sig(sig), ref_id):
|
||||
data = populate_sig_ref_info(SIGRef(id=ref_id, sig=sig))
|
||||
self.write(json.dumps(data, default=serialize_everything))
|
||||
self.write(safe_json_dumps(data))
|
||||
|
||||
else:
|
||||
self.write(
|
||||
json.dumps(
|
||||
"Error - '" + ref_id + "' does not look like a valid reference ID for " + sig + ".",
|
||||
default=serialize_everything))
|
||||
self.write(safe_json_dumps(
|
||||
"Error - '" + ref_id + "' does not look like a valid reference ID for " + sig + "."))
|
||||
self.set_status(422)
|
||||
else:
|
||||
self.write(json.dumps("Error - sig '" + sig + "' is not known.", default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - sig '" + sig + "' is not known."))
|
||||
self.set_status(422)
|
||||
else:
|
||||
self.write(json.dumps("Error - sig and id must be provided", default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - sig and id must be provided"))
|
||||
self.set_status(422)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||
self.set_status(500)
|
||||
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
@@ -185,15 +181,15 @@ class APILookupGridHandler(tornado.web.RequestHandler):
|
||||
"latitude": lat + lat_cell_size,
|
||||
"longitude": lon + lon_cell_size,
|
||||
}}
|
||||
self.write(json.dumps(response, default=serialize_everything))
|
||||
self.write(safe_json_dumps(response))
|
||||
|
||||
else:
|
||||
self.write(json.dumps("Error - grid must be provided", default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - grid must be provided"))
|
||||
self.set_status(422)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||
self.set_status(500)
|
||||
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -10,7 +9,7 @@ from tornado.web import Application
|
||||
from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING
|
||||
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS, PROPAGATION_MODES
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import serialize_everything
|
||||
from core.utils import safe_json_dumps
|
||||
|
||||
|
||||
class APIOptionsHandler(tornado.web.RequestHandler):
|
||||
@@ -50,7 +49,7 @@ class APIOptionsHandler(tornado.web.RequestHandler):
|
||||
if ALLOW_SPOTTING:
|
||||
options["spot_sources"].append("API")
|
||||
|
||||
self.write(json.dumps(options, default=serialize_everything))
|
||||
self.write(safe_json_dumps(options))
|
||||
self.set_status(200)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from queue import Queue
|
||||
@@ -12,7 +11,7 @@ from tornado import httputil
|
||||
from tornado.web import Application
|
||||
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import serialize_everything, empty_queue
|
||||
from core.utils import safe_json_dumps, empty_queue
|
||||
from data.lookup_credentials import extract_credentials
|
||||
|
||||
SSE_HANDLER_MAX_QUEUE_SIZE = 1000
|
||||
@@ -57,15 +56,15 @@ class APISpotsHandler(tornado.web.RequestHandler):
|
||||
data = get_spot_list_with_filters(self._spots, query_params)
|
||||
if credentials:
|
||||
data = self._enrich(data, credentials)
|
||||
self.write(json.dumps(data, default=serialize_everything))
|
||||
self.write(safe_json_dumps(data))
|
||||
self.set_status(200)
|
||||
except ValueError as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Bad request - " + str(e), default=serialize_everything))
|
||||
self.write(safe_json_dumps("Bad request - " + str(e)))
|
||||
self.set_status(400)
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||
self.set_status(500)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
@@ -152,7 +151,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
||||
if self._credentials:
|
||||
spot = copy.deepcopy(spot)
|
||||
spot.infer_missing(self._credentials)
|
||||
self.write_message(msg=json.dumps(spot, default=serialize_everything))
|
||||
self.write_message(msg=safe_json_dumps(spot))
|
||||
|
||||
else:
|
||||
# Send a keepalive comment if the queue was empty
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -8,7 +7,7 @@ from tornado import httputil
|
||||
from tornado.web import Application
|
||||
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import serialize_everything
|
||||
from core.utils import safe_json_dumps
|
||||
|
||||
|
||||
class APIStatusHandler(tornado.web.RequestHandler):
|
||||
@@ -30,7 +29,7 @@ class APIStatusHandler(tornado.web.RequestHandler):
|
||||
self._web_server_metrics["status"] = "OK"
|
||||
api_requests_counter.inc()
|
||||
|
||||
self.write(json.dumps(self._status_data, default=serialize_everything))
|
||||
self.write(safe_json_dumps(self._status_data))
|
||||
self.set_status(200)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
|
||||
+5
-2
@@ -5,7 +5,7 @@ import os
|
||||
import tornado
|
||||
from tornado.web import StaticFileHandler
|
||||
|
||||
from core.config import ALLOW_SPOTTING, WEB_SERVER_PORT, API_ONLY_MODE
|
||||
from core.config import ALLOW_SPOTTING, WEB_SERVER_PORT, API_ONLY_MODE, LOG_WEB_REQUESTS, BASE_URL
|
||||
from core.utils import empty_queue
|
||||
from server.handlers.api.addspot import APISpotHandler
|
||||
from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
|
||||
@@ -112,6 +112,7 @@ class WebServer:
|
||||
debug=False)
|
||||
app.listen(self._port, xheaders=True)
|
||||
logging.info("Web server running on port " + str(WEB_SERVER_PORT))
|
||||
logging.info("You can access your copy of Spothole at " + BASE_URL)
|
||||
await self._shutdown_event.wait()
|
||||
|
||||
def notify_new_spot(self, spot):
|
||||
@@ -165,8 +166,10 @@ class WebServer:
|
||||
pass
|
||||
|
||||
def request_log(handler):
|
||||
"""Custom log function to provide more data about requests."""
|
||||
"""Custom log function to provide more data about requests when enabled, and to provide the ability to turn off
|
||||
web request logging altogetether."""
|
||||
|
||||
if LOG_WEB_REQUESTS:
|
||||
if handler.get_status() < 500:
|
||||
log_method = logging.info
|
||||
else:
|
||||
|
||||
+13
-9
@@ -14,9 +14,9 @@ class GMA(HTTPSpotProvider):
|
||||
"""Spot provider for General Mountain Activity"""
|
||||
|
||||
POLL_INTERVAL_SEC = 120
|
||||
SPOTS_URL = "https://www.cqgma.org/api/spots/25/"
|
||||
SPOTS_URL = "https://www.gma.rocks/api/spots/25/"
|
||||
# GMA spots don't contain the details of the programme they are for, we need a separate lookup for that
|
||||
REF_INFO_URL_ROOT = "https://www.cqgma.org/api/ref/?"
|
||||
REF_INFO_URL_ROOT = "https://www.gma.rocks/api/ref/?"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
# Ensure there is an API key in our config, and set up the query URL using it. If no key is provided,
|
||||
@@ -37,19 +37,21 @@ class GMA(HTTPSpotProvider):
|
||||
spot = Spot(source=self.name,
|
||||
dx_call=source_spot["ACTIVATOR"].upper(),
|
||||
de_call=source_spot["SPOTTER"].upper(),
|
||||
freq=float(source_spot["QRG"]) * 1000 if (source_spot["QRG"] != "") else None,
|
||||
# Seen GMA spots with no frequency
|
||||
mode=source_spot["MODE"].upper() if "<>" not in source_spot["MODE"] else None,
|
||||
# Seen GMA spots with no frequency or with "QRT" in this field
|
||||
freq=float(source_spot["QRG"]) * 1000 if (
|
||||
source_spot["QRG"] != "" and source_spot["QRG"] != "QRT") else None,
|
||||
# Filter out some weird mode strings
|
||||
mode=source_spot["MODE"].upper() if "<>" not in source_spot["MODE"] else None,
|
||||
comment=source_spot["TEXT"],
|
||||
sig_refs=[SIGRef(id=source_spot["REF"], sig="", name=source_spot["NAME"])],
|
||||
time=datetime.strptime(source_spot["DATE"] + source_spot["TIME"], "%Y%m%d%H%M").replace(
|
||||
tzinfo=pytz.UTC).timestamp(),
|
||||
# Seen GMA spots with no (or empty) lat/lon
|
||||
dx_latitude=float(source_spot["LAT"]) if (
|
||||
source_spot["LAT"] and source_spot["LAT"] != "") else None,
|
||||
# Seen GMA spots with no (or empty) lat/lon
|
||||
dx_longitude=float(source_spot["LON"]) if (
|
||||
source_spot["LON"] and source_spot["LON"] != "") else None)
|
||||
source_spot["LON"] and source_spot["LON"] != "") else None,
|
||||
qrt=source_spot["QRG"] == "QRT")
|
||||
|
||||
# GMA doesn't give what programme (SIG) the reference is for until we separately look it up.
|
||||
if "REF" in source_spot:
|
||||
@@ -63,8 +65,10 @@ class GMA(HTTPSpotProvider):
|
||||
# spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA
|
||||
# and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry
|
||||
# to determine if it's a SOTA summit.
|
||||
if spot.sig_refs and "reftype" in ref_info and ref_info["reftype"] not in ["POTA", "WWFF"] and (
|
||||
ref_info["reftype"] != "Summit" or "sota" not in ref_info or ref_info["sota"] == ""):
|
||||
if spot.sig_refs and "reftype" in ref_info and ref_info["reftype"] not in ["POTA",
|
||||
"WWFF"] and (
|
||||
ref_info["reftype"] != "Summit" or "sota" not in ref_info or ref_info[
|
||||
"sota"] == ""):
|
||||
match ref_info["reftype"]:
|
||||
case "Summit":
|
||||
spot.sig_refs[0].sig = "GMA"
|
||||
|
||||
@@ -30,8 +30,8 @@ class ParksNPeaks(HTTPSpotProvider):
|
||||
dx_call=source_spot["actCallsign"].upper(),
|
||||
de_call=source_spot["actSpoter"].upper() if source_spot["actSpoter"] != "" else None,
|
||||
# typo exists in API
|
||||
freq=float(source_spot["actFreq"].replace(",", "")) * 1000000 if (
|
||||
source_spot["actFreq"] != "") else None,
|
||||
freq=float(source_spot["actFreq"].replace(",", "").replace("+-", "")
|
||||
.replace("+/-", "").strip()) * 1000000 if (source_spot["actFreq"] != "") else None,
|
||||
# Seen PNP spots with empty frequency, and with comma-separated thousands digits
|
||||
mode=source_spot["actMode"].upper(),
|
||||
comment=source_spot["actComments"],
|
||||
|
||||
@@ -6,7 +6,7 @@ from data.spot import Spot
|
||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||
|
||||
|
||||
class WWTOTA(HTTPSpotProvider):
|
||||
class Towers(HTTPSpotProvider):
|
||||
"""Spot provider for Towers on the Air"""
|
||||
|
||||
POLL_INTERVAL_SEC = 120
|
||||
@@ -101,9 +101,9 @@
|
||||
(MOTA), the Amateur Radio Lighthouse Socirty (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos
|
||||
on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National
|
||||
Parks Award (KRMNPA), Wainwrights on the Air (WOTA), Beaches on the Air (BOTA), Lagos y Lagunas On the Air
|
||||
(LLOTA), Towers on the Air (WWTOTA), Tiles on the Air, 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
|
||||
(LLOTA), Towers on the Air (WWTOTA), Tiles on the Air, Worked All Britain (WAB), Worked All Ireland (WAI), el
|
||||
Diploma Municipios de España (DME) and Toilets on the Air (TOTA).</p>
|
||||
<p>As of the time of writing in June 2026, 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>
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/add-spot.js?v=1782076701"></script>
|
||||
<script src="/js/add-spot.js?v=1783702256"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-add-spot").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/alerts.js?v=1782076701"></script>
|
||||
<script src="/js/alerts.js?v=1783702256"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-alerts").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
|
||||
<div class="mt-3">
|
||||
<div id="settingsButtonRow" class="row mb-3">
|
||||
<div class="col-auto me-auto pt-3">
|
||||
{% module Template("widgets/refresh-timer.html", web_ui_options=web_ui_options) %}
|
||||
</div>
|
||||
<div class="col-auto me-auto pt-3"></div>
|
||||
<div class="col-auto">
|
||||
<div class="d-inline-flex gap-1">
|
||||
{% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options) %}
|
||||
@@ -77,8 +75,8 @@
|
||||
<script>
|
||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||
</script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1782076701"></script>
|
||||
<script src="/js/bands.js?v=1782076701"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1783702256"></script>
|
||||
<script src="/js/bands.js?v=1783702256"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-bands").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{% extends "skeleton.html" %}
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/css/style.css?v=1782076701" type="text/css">
|
||||
<link rel="stylesheet" href="/css/style.css?v=1783702256" type="text/css">
|
||||
<link href="/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
|
||||
<link href="/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
|
||||
<link href="/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
|
||||
@@ -10,10 +10,10 @@
|
||||
<script src="/vendor/js/bootstrap-5.3.8.bundle.min.js"></script>
|
||||
<script src="/vendor/js/tinycolor2-1.6.0.min.js"></script>
|
||||
|
||||
<script src="/js/utils.js?v=1782076701"></script>
|
||||
<script src="/js/ui-ham.js?v=1782076701"></script>
|
||||
<script src="/js/geo.js?v=1782076701"></script>
|
||||
<script src="/js/common.js?v=1782076701"></script>
|
||||
<script src="/js/utils.js?v=1783702256"></script>
|
||||
<script src="/js/ui-ham.js?v=1783702256"></script>
|
||||
<script src="/js/geo.js?v=1783702256"></script>
|
||||
<script src="/js/common.js?v=1783702256"></script>
|
||||
{% end %}
|
||||
{% block body %}
|
||||
<div class="container">
|
||||
|
||||
@@ -284,7 +284,7 @@
|
||||
</div>
|
||||
|
||||
<script src="/vendor/js/chart-4.4.9.umd.min.js"></script>
|
||||
<script src="/js/conditions.js?v=1782076701"></script>
|
||||
<script src="/js/conditions.js?v=1783702256"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-conditions").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
+2
-2
@@ -95,8 +95,8 @@
|
||||
<script>
|
||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||
</script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1782076701"></script>
|
||||
<script src="/js/map.js?v=1782076701"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1783702255"></script>
|
||||
<script src="/js/map.js?v=1783702255"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-map").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
@@ -116,8 +116,8 @@
|
||||
<script>
|
||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||
</script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1782076701"></script>
|
||||
<script src="/js/spots.js?v=1782076701"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1783702255"></script>
|
||||
<script src="/js/spots.js?v=1783702255"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-spots").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/status.js?v=1782076701"></script>
|
||||
<script src="/js/status.js?v=1783702256"></script>
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
$("#nav-link-status").addClass("active");
|
||||
|
||||
@@ -18,6 +18,11 @@ info:
|
||||
### 1.4
|
||||
|
||||
* Spots can now include a "propagation_mode" field, and the `/options` call enumerates the options that can have.
|
||||
* Added support for the Diploma de Municipios Españoles (DME) SIG
|
||||
* Renamed some SIGs to avoid confusion between Towers, Tiles and Toilets
|
||||
* Added `comment_names` to SIGs in the `/options`, to reflect how they might be referred to in spot comments where
|
||||
it differs from their `name`.
|
||||
* Added `propagation_mode` field to spots
|
||||
|
||||
### 1.3
|
||||
|
||||
@@ -771,7 +776,7 @@ components:
|
||||
- ZLOTA
|
||||
- WOTA
|
||||
- LLOTA
|
||||
- WWTOTA
|
||||
- Towers
|
||||
- Tiles
|
||||
- Cluster
|
||||
- RBN
|
||||
@@ -790,7 +795,7 @@ components:
|
||||
- HEMA
|
||||
- WCA
|
||||
- MOTA
|
||||
- SIOTA
|
||||
- SiOTA
|
||||
- ARLHS
|
||||
- ILLW
|
||||
- ZLOTA
|
||||
@@ -799,11 +804,12 @@ components:
|
||||
- WOTA
|
||||
- BOTA
|
||||
- LLOTA
|
||||
- WWTOTA
|
||||
- Towers
|
||||
- Tiles
|
||||
- WAB
|
||||
- WAI
|
||||
- TOTA
|
||||
- DME
|
||||
- Toilets
|
||||
example: POTA
|
||||
|
||||
SIGNameIncludingNoSIG:
|
||||
@@ -1373,6 +1379,16 @@ components:
|
||||
type: string
|
||||
description: The full name of the SIG
|
||||
example: Parks on the Air
|
||||
comment_names:
|
||||
type: array
|
||||
description: >
|
||||
Names by which this SIG may be referred to in cluster spot comments. Most SIGs have a
|
||||
single entry matching their programme name (e.g. ["POTA"]), but some have none (where
|
||||
the name is ambiguous with other SIGs, such as Tiles and Toilets on the Air) or multiple
|
||||
entries (e.g. WWBOTA accepts both "WWBOTA" and "BOTA" since the latter is often used).
|
||||
items:
|
||||
type: string
|
||||
example: [ "TOTA" ]
|
||||
ref_regex:
|
||||
type: string
|
||||
description: >
|
||||
|
||||
@@ -62,7 +62,7 @@ function addSpot() {
|
||||
spot["sig"] = sig;
|
||||
}
|
||||
if (sigRef !== "") {
|
||||
spot["sig_refs"] = [{id: sigRef}];
|
||||
spot["sig_refs"] = [{sig: sig, id: sigRef}];
|
||||
}
|
||||
if (dxGrid !== "") {
|
||||
spot["dx_grid"] = dxGrid;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// How often to query the server?
|
||||
const REFRESH_INTERVAL_SEC = 60 * 10;
|
||||
// Last time the alerts list was updated on display.
|
||||
let lastUpdateTime;
|
||||
|
||||
// Storage for the alert data that the server gives us.
|
||||
let alerts = [];
|
||||
@@ -309,6 +311,27 @@ function filtersUpdated() {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
// Update the refresh timing display
|
||||
function updateRefreshDisplay() {
|
||||
if (lastUpdateTime != null) {
|
||||
let secSinceUpdate = moment.duration(moment().diff(lastUpdateTime)).asSeconds();
|
||||
let count = REFRESH_INTERVAL_SEC;
|
||||
let updatingString = "Updating..."
|
||||
if (secSinceUpdate < REFRESH_INTERVAL_SEC) {
|
||||
count = REFRESH_INTERVAL_SEC - secSinceUpdate;
|
||||
let number;
|
||||
if (count <= 60) {
|
||||
number = count.toFixed(0);
|
||||
updatingString = "<span class='nowrap'>Updating in " + number + " second" + (number !== "1" ? "s" : "") + ".</span>";
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Startup
|
||||
$(document).ready(function () {
|
||||
// Call loadOptions(), this will then trigger loading alerts and setting up timers.
|
||||
|
||||
+67
-17
@@ -1,5 +1,9 @@
|
||||
// How often to query the server?
|
||||
const REFRESH_INTERVAL_SEC = 60;
|
||||
// SSE connection for live spot updates
|
||||
let evtSource;
|
||||
let restartSSEOnErrorTimeoutId;
|
||||
// Debounce timer so rapid SSE bursts only trigger one updateBands() call, as these could trigger a flash of the user
|
||||
// visible content
|
||||
let updateBandsDebounceId;
|
||||
|
||||
// A couple of constants that must match what's in CSS. We need to know them before the content actually renders, so we
|
||||
// can't just ask the elements themselves for their dimensions.
|
||||
@@ -12,19 +16,62 @@ BAND_COLUMN_SPOT_DIV_HEIGHT_PX = BAND_COLUMN_FONT_SIZE * 1.6;
|
||||
|
||||
// Load spots and populate the bands display.
|
||||
function loadSpots() {
|
||||
$.getJSON('/api/v1/spots' + buildQueryString(false), function (jsonData) {
|
||||
// Store last updated time
|
||||
lastUpdateTime = moment.utc();
|
||||
updateRefreshDisplay();
|
||||
// Close any existing SSE connection before fetching fresh data
|
||||
if (evtSource != null) {
|
||||
evtSource.close();
|
||||
}
|
||||
$.getJSON('/api/v1/spots' + buildQueryString(), function (jsonData) {
|
||||
// Store data
|
||||
spots = jsonData;
|
||||
// Update bands display
|
||||
updateBands();
|
||||
// Start the ongoing SSE connection
|
||||
startSSEConnection();
|
||||
});
|
||||
}
|
||||
|
||||
// Build a query string for the API, based on the filters that the user has selected.
|
||||
function buildQueryString(includeCredentials) {
|
||||
// Start an SSE connection to receive new spots as they arrive.
|
||||
function startSSEConnection() {
|
||||
if (evtSource != null) {
|
||||
evtSource.close();
|
||||
}
|
||||
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString());
|
||||
|
||||
evtSource.onmessage = function (event) {
|
||||
const newSpot = JSON.parse(event.data);
|
||||
|
||||
// Replace any existing spot for this callsign
|
||||
spots = spots.filter(s => newSpot["dx_call"] !== s["dx_call"]);
|
||||
spots.unshift(newSpot);
|
||||
|
||||
// Debounce. Wait 500ms after the last message before re-rendering to avoid too many ugly flashes
|
||||
clearTimeout(updateBandsDebounceId);
|
||||
updateBandsDebounceId = setTimeout(updateBands, 5000);
|
||||
};
|
||||
|
||||
evtSource.onerror = function () {
|
||||
if (evtSource != null) {
|
||||
evtSource.close();
|
||||
}
|
||||
clearTimeout(restartSSEOnErrorTimeoutId);
|
||||
restartSSEOnErrorTimeoutId = setTimeout(startSSEConnection, 1000);
|
||||
};
|
||||
}
|
||||
|
||||
// Remove spots from the display that are older than the selected max age.
|
||||
function expireOldSpots() {
|
||||
const maxAgeSeconds = parseInt($("#max-spot-age option:selected").val());
|
||||
const cutoff = (Date.now() / 1000) - maxAgeSeconds;
|
||||
const before = spots.length;
|
||||
spots = spots.filter(s => s["time"] && s["time"] >= cutoff);
|
||||
if (spots.length !== before) {
|
||||
updateBands();
|
||||
}
|
||||
}
|
||||
|
||||
// Build a query string for the API, based on the filters that the user has selected. There's no need for credentials
|
||||
// in the bands page's version of this, because nothing QRZ.com/HamQTH can provide will affect the display.
|
||||
function buildQueryString() {
|
||||
let str = "?";
|
||||
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
|
||||
if (!allFilterOptionsSelected(fn)) {
|
||||
@@ -34,9 +81,6 @@ function buildQueryString(includeCredentials) {
|
||||
str = str + "max_age=" + $("#max-spot-age option:selected").val();
|
||||
// Additional filters for the bands view: No dupes, no QRT
|
||||
str = str + "&dedupe=true&allow_qrt=false";
|
||||
if (includeCredentials) {
|
||||
str = str + getCredentialQueryString();
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
@@ -257,22 +301,28 @@ function loadOptions() {
|
||||
// Load settings from settings storage now all the controls are available
|
||||
loadSettings();
|
||||
|
||||
// Load spots and set up the timer
|
||||
// Load spots and start SSE for live updates
|
||||
loadSpots();
|
||||
setInterval(loadSpots, REFRESH_INTERVAL_SEC * 1000);
|
||||
// Set up spot expiry checker
|
||||
setInterval(expireOldSpots, 60 * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// Method called when any display property is changed to reload the map and persist the display settings.
|
||||
// Method called when any display property is changed to reload the bands display and persist settings.
|
||||
function displayUpdated() {
|
||||
updateMap();
|
||||
updateBands();
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
// Startup
|
||||
$(document).ready(function () {
|
||||
// Close SSE connection cleanly when navigating away
|
||||
window.addEventListener('beforeunload', function () {
|
||||
if (evtSource != null) {
|
||||
evtSource.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Call loadOptions(), this will then trigger loading spots and setting up timers.
|
||||
loadOptions();
|
||||
// Update the refresh timing display every second
|
||||
setInterval(updateRefreshDisplay, 1000);
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
// Storage for the options that the server gives us. This will define our filters.
|
||||
let options = {};
|
||||
// Last time we updated the spots/alerts list on display.
|
||||
let lastUpdateTime;
|
||||
// Normally load user settings from local storage, unless embedded mode is in use
|
||||
let useLocalStorage = true;
|
||||
|
||||
@@ -145,27 +143,6 @@ function toggleFilterButtons(filterQuery, state) {
|
||||
filtersUpdated();
|
||||
}
|
||||
|
||||
// Update the refresh timing display
|
||||
function updateRefreshDisplay() {
|
||||
if (lastUpdateTime != null) {
|
||||
let secSinceUpdate = moment.duration(moment().diff(lastUpdateTime)).asSeconds();
|
||||
let count = REFRESH_INTERVAL_SEC;
|
||||
let updatingString = "Updating..."
|
||||
if (secSinceUpdate < REFRESH_INTERVAL_SEC) {
|
||||
count = REFRESH_INTERVAL_SEC - secSinceUpdate;
|
||||
let number;
|
||||
if (count <= 60) {
|
||||
number = count.toFixed(0);
|
||||
updatingString = "<span class='nowrap'>Updating in " + number + " second" + (number !== "1" ? "s" : "") + ".</span>";
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// When the "use local time" field is changed, reload the table and save settings
|
||||
function timeZoneUpdated() {
|
||||
updateTable();
|
||||
|
||||
+124
-24
@@ -1,6 +1,3 @@
|
||||
// How often to query the server?
|
||||
const REFRESH_INTERVAL_SEC = 60;
|
||||
|
||||
// Colours
|
||||
const MAIDENHEAD_GRID_COLOR_LIGHT = 'rgba(200, 140, 140, 1.0)';
|
||||
const CQ_ZONES_COLOR_LIGHT = 'rgba(140, 200, 140, 1.0)';
|
||||
@@ -11,6 +8,15 @@ const CQ_ZONES_COLOR_DARK = 'rgba(60, 120, 60, 1.0)';
|
||||
const ITU_ZONES_COLOR_DARK = 'rgba(120, 120, 60, 1.0)';
|
||||
const WAB_WAI_GRID_COLOR_DARK = 'rgba(60, 60, 120, 1.0)';
|
||||
|
||||
// SSE connection for live spot updates
|
||||
let evtSource;
|
||||
let restartSSEOnErrorTimeoutId;
|
||||
// Map dx_call to a pair of marker & geodesic line, so we can de-duplicate spots as they arrive and only show
|
||||
// one marker per dx_call. The key here is actually dx_call + SSID if the spot gives us an SSID; this is mostly for
|
||||
// if APRS spots are enabled so we can ID a home and mobile station separately rather than our marker oscillating
|
||||
// between the two as updates come in.
|
||||
let spotMarkers = new Map();
|
||||
|
||||
// Map layers
|
||||
let backgroundTileLayer;
|
||||
let markersLayer;
|
||||
@@ -28,7 +34,13 @@ let firstLoad = true;
|
||||
|
||||
// Load spots and populate the map.
|
||||
function loadSpots() {
|
||||
$.getJSON('/api/v1/spots' + buildQueryString(true), function (jsonData) {
|
||||
// Close any existing SSE connection before fetching fresh data
|
||||
if (evtSource != null) {
|
||||
evtSource.close();
|
||||
}
|
||||
// On first fetch, don't include QRZ/HamQTH credentials. This will cause some inaccurate
|
||||
// marker positions but avoids having to wait a minute or more for all the lookups to fire.
|
||||
$.getJSON('/api/v1/spots' + buildQueryString(false), function (jsonData) {
|
||||
// Store data
|
||||
spots = jsonData;
|
||||
// Update map
|
||||
@@ -36,9 +48,102 @@ function loadSpots() {
|
||||
if ($("#showTerminator")[0].checked) {
|
||||
terminator.setTime();
|
||||
}
|
||||
// Start the ongoing SSE connection
|
||||
startSSEConnection();
|
||||
});
|
||||
}
|
||||
|
||||
// Start the SSE connection to receive new spots as they arrive
|
||||
function startSSEConnection() {
|
||||
if (evtSource != null) {
|
||||
evtSource.close();
|
||||
}
|
||||
// SSE is going to fetch only a few spots at a time, so now we include QRZ/HamQTH credentials because the delay won't be significant.
|
||||
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString(true));
|
||||
|
||||
evtSource.onmessage = function (event) {
|
||||
const newSpot = JSON.parse(event.data);
|
||||
const key = spotKey(newSpot);
|
||||
|
||||
// Remove existing marker/geodesic for this callsign if present
|
||||
removeSpotFromMap(key);
|
||||
spots = spots.filter(s => spotKey(s) !== key);
|
||||
|
||||
// Skip spots with no map coordinates
|
||||
if (newSpot["dx_latitude"] == null || newSpot["dx_longitude"] == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to data store and map
|
||||
spots.unshift(newSpot);
|
||||
addSpotToMap(newSpot);
|
||||
};
|
||||
|
||||
evtSource.onerror = function () {
|
||||
if (evtSource != null) {
|
||||
evtSource.close();
|
||||
}
|
||||
clearTimeout(restartSSEOnErrorTimeoutId);
|
||||
restartSSEOnErrorTimeoutId = setTimeout(startSSEConnection, 1000);
|
||||
};
|
||||
}
|
||||
|
||||
// Remove spots from the map that are older than the selected max age.
|
||||
function expireOldSpots() {
|
||||
const maxAgeSeconds = parseInt($("#max-spot-age option:selected").val());
|
||||
const cutoff = (Date.now() / 1000) - maxAgeSeconds;
|
||||
spots = spots.filter(function (s) {
|
||||
if (s["time"] && s["time"] < cutoff) {
|
||||
removeSpotFromMap(spotKey(s));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Returns a unique key for a spot based on its callsign and SSID. This is in case the APRS spot source is enabled and
|
||||
// we want to display all SSIDs for a given callsign, not just the latest one.
|
||||
function spotKey(s) {
|
||||
return s["dx_call"] + (s["dx_ssid"] ? "-" + s["dx_ssid"] : "");
|
||||
}
|
||||
|
||||
// Add a single spot's marker and geodesic to the map and to spotMarkers
|
||||
function addSpotToMap(s) {
|
||||
const m = L.marker([s["dx_latitude"], s["dx_longitude"]], {icon: getIcon(s)});
|
||||
m.bindPopup(getTooltipText(s));
|
||||
markersLayer.addLayer(m);
|
||||
oms.addMarker(m);
|
||||
|
||||
let geodesic = null;
|
||||
if ($("#mapShowGeodesics")[0].checked && s["de_latitude"] != null && s["de_longitude"] != null) {
|
||||
try {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
spotMarkers.set(spotKey(s), {marker: m, geodesic: geodesic});
|
||||
}
|
||||
|
||||
// Remove a spot's marker and geodesic from the map and from spotMarkers.
|
||||
function removeSpotFromMap(key) {
|
||||
const entry = spotMarkers.get(key);
|
||||
if (entry) {
|
||||
markersLayer.removeLayer(entry.marker);
|
||||
oms.removeMarker(entry.marker);
|
||||
if (entry.geodesic) {
|
||||
geodesicsLayer.removeLayer(entry.geodesic);
|
||||
}
|
||||
spotMarkers.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Build a query string for the API, based on the filters that the user has selected.
|
||||
function buildQueryString(includeCredentials) {
|
||||
let str = "?";
|
||||
@@ -62,27 +167,14 @@ function updateMap() {
|
||||
markersLayer.clearLayers();
|
||||
geodesicsLayer.clearLayers();
|
||||
oms.clearMarkers();
|
||||
spotMarkers.clear();
|
||||
|
||||
// Make new markers for all spots that match the filter
|
||||
// Make new markers for all spots
|
||||
spots.forEach(function (s) {
|
||||
const m = L.marker([s["dx_latitude"], s["dx_longitude"]], {icon: getIcon(s)});
|
||||
m.bindPopup(getTooltipText(s));
|
||||
markersLayer.addLayer(m);
|
||||
oms.addMarker(m);
|
||||
|
||||
// Create geodesics if required
|
||||
if ($("#mapShowGeodesics")[0].checked && s["de_latitude"] != null && s["de_longitude"] != null) {
|
||||
try {
|
||||
const 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
|
||||
}
|
||||
if (s["dx_latitude"] == null || s["dx_longitude"] == null) {
|
||||
return;
|
||||
}
|
||||
addSpotToMap(s);
|
||||
});
|
||||
|
||||
// On first load, zoom to the extent of the markers
|
||||
@@ -239,9 +331,10 @@ function loadOptions() {
|
||||
enableITUZones($("#showITUZones")[0].checked);
|
||||
enableWABWAIGrid($("#showWABWAIGrid")[0].checked);
|
||||
|
||||
// Load spots and set up the timer
|
||||
// Load spots and start SSE for live updates
|
||||
loadSpots();
|
||||
setInterval(loadSpots, REFRESH_INTERVAL_SEC * 1000);
|
||||
// Set up spot expiry checker
|
||||
setInterval(expireOldSpots, 60 * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -483,6 +576,13 @@ function setUpMap() {
|
||||
|
||||
// Startup
|
||||
$(document).ready(function () {
|
||||
// Close SSE connection cleanly when navigating away
|
||||
window.addEventListener('beforeunload', function () {
|
||||
if (evtSource != null) {
|
||||
evtSource.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Hide the extra things that need to be hidden on this page
|
||||
$(".hideonmap").hide();
|
||||
// Set up map
|
||||
|
||||
@@ -332,12 +332,12 @@ const SIG_ICONS = {
|
||||
"BOTA": "fa-umbrella-beach",
|
||||
"KRMNPA": "fa-earth-oceania",
|
||||
"LLOTA": "fa-water",
|
||||
"WWTOTA": "fa-tower-observation",
|
||||
"Towers": "fa-tower-observation",
|
||||
"WAB": "fa-table-cells-large",
|
||||
"WAI": "fa-table-cells-large",
|
||||
"DME": "fa-building",
|
||||
"Tiles": "fa-square",
|
||||
"TOTA": "fa-toilet"
|
||||
"Toilets": "fa-toilet"
|
||||
}
|
||||
|
||||
// Get the Font Awesome icon for a given SIG. If the SIG is unknown, the provided default symbol will be returned
|
||||
|
||||
Reference in New Issue
Block a user