Merge branch 'main' into 95-send-spots-to-xota

# Conflicts:
#	README.md
#	server/handlers/api/addspot.py
#	server/handlers/api/options.py
#	spotproviders/tiles.py
#	templates/about.html
#	templates/add_spot.html
#	templates/alerts.html
#	templates/api_only_home.html
#	templates/bands.html
#	templates/base.html
#	templates/conditions.html
#	templates/map.html
#	templates/spots.html
#	templates/status.html
#	webassets/css/style.css
#	webassets/js/add-spot.js
#	webassets/js/geo.js
#	webassets/js/ui-ham.js
#	webassets/js/utils.js
This commit is contained in:
Ian Renton
2026-06-19 21:48:10 +01:00
91 changed files with 1835 additions and 1261 deletions

View File

@@ -37,20 +37,20 @@ class APRSIS(SpotProvider):
def _handle(self, data):
# Split SSID in "from" call and store separately
from_parts = data["from"].split("-").upper()
dx_call = from_parts[0]
dx_ssid = from_parts[1] if len(from_parts) > 1 else None
via_parts = data["via"].split("-").upper()
de_call = via_parts[0]
de_ssid = via_parts[1] if len(via_parts) > 1 else None
from_parts = str(data["from"]).split("-")
dx_call = from_parts[0].upper()
dx_ssid = from_parts[1].upper() if len(from_parts) > 1 else None
via_parts = str(data["via"]).split("-")
de_call = via_parts[0].upper()
de_ssid = via_parts[1].upper() if len(via_parts) > 1 else None
spot = Spot(source="APRS-IS",
dx_call=dx_call,
dx_ssid=dx_ssid,
de_call=de_call,
de_ssid=de_ssid,
comment=data["comment"] if "comment" in data else None,
dx_latitude=data["latitude"] if "latitude" in data else None,
dx_longitude=data["longitude"] if "longitude" in data else None,
comment=str(data["comment"]) if "comment" in data else None,
dx_latitude=float(data["latitude"]) if "latitude" in data else None,
dx_longitude=float(data["longitude"]) if "longitude" in data else None,
time=datetime.now(
pytz.UTC).timestamp()) # APRS-IS spots are live so we can assume spot time is "now"

View File

@@ -72,7 +72,8 @@ class DXCluster(SpotProvider):
match = self._spot_line_pattern.match(telnet_output.decode("latin-1"))
if match:
spot_time = datetime.strptime(match.group(5), "%H%MZ")
spot_datetime = datetime.combine(datetime.now(pytz.UTC).date(), spot_time.time(), tzinfo=pytz.UTC)
spot_datetime = datetime.combine(datetime.now(pytz.UTC).date(), spot_time.time(),
tzinfo=pytz.UTC)
spot = Spot(source=self.name,
dx_call=match.group(3),
de_call=match.group(1),

View File

@@ -38,10 +38,10 @@ class GMA(HTTPSpotProvider):
time=datetime.strptime(source_spot["DATE"] + source_spot["TIME"], "%Y%m%d%H%M").replace(
tzinfo=pytz.UTC).timestamp(),
dx_latitude=float(source_spot["LAT"]) if (
source_spot["LAT"] and source_spot["LAT"] != "") else None,
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)
# GMA doesn't give what programme (SIG) the reference is for until we separately look it up.
if "REF" in source_spot:
@@ -55,7 +55,7 @@ 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 "reftype" in ref_info and ref_info["reftype"] not in ["POTA", "WWFF"] and (
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":

View File

@@ -45,6 +45,8 @@ class HEMA(HTTPSpotProvider):
# Fiddle with some data to extract bits we need. Freq/mode and spotter/comment come in combined fields.
freq_mode_match = re.search(self.FREQ_MODE_PATTERN, spot_items[5])
spotter_comment_match = re.search(self.SPOTTER_COMMENT_PATTERN, spot_items[6])
if not freq_mode_match or not spotter_comment_match:
continue
# Convert to our spot format
spot = Spot(source=self.name,

View File

@@ -22,8 +22,8 @@ class LLOTA(HTTPSpotProvider):
comment = None
spotter = None
if "history" in source_spot and len(source_spot["history"]) > 0:
comment = source_spot["history"][-1]["comment"]
spotter = source_spot["history"][-1]["spotter_callsign"]
comment = str(source_spot["history"][-1]["comment"])
spotter = str(source_spot["history"][-1]["spotter_callsign"])
# Convert to our spot format
spot = Spot(source=self.name,
source_id=source_spot["id"],

View File

@@ -43,9 +43,9 @@ class ParksNPeaks(HTTPSpotProvider):
tzinfo=pytz.UTC).timestamp())
# Extract a de_call if it's in the comment but not in the "actSpoter" field
m = re.search(r"\(de ([A-Za-z0-9]*)\)", spot.comment)
m = re.search(r"\(de ([A-Za-z0-9]*)\)", spot.comment or "")
if not spot.de_call and m:
spot.de_call = m.group(1)
spot.de_call = str(m.group(1))
# Record SIG information. Sometimes we get a "SIG" of "QRP", which we ignore as it's not a programme with a
# defined set of references
@@ -53,11 +53,12 @@ class ParksNPeaks(HTTPSpotProvider):
sig_ref = source_spot["actSiteID"]
if sig and sig != "" and sig != "QRP" and sig_ref and sig_ref != "":
spot.sig = sig
spot.sig_refs = [SIGRef(id=source_spot["actSiteID"], sig=source_spot["actClass"].upper())]
sig_refs = [SIGRef(id=source_spot["actSiteID"], sig=source_spot["actClass"].upper())]
spot.sig_refs = sig_refs
# Free text location is not present in all spots, so only add it if it's set
if "actLocation" in source_spot and source_spot["actLocation"] != "":
spot.sig_refs[0].name = source_spot["actLocation"]
sig_refs[0].name = source_spot["actLocation"]
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before
if sig not in ["POTA", "SOTA", "WWFF", "SIOTA", "ZLOTA", "KRMNPA"]:

View File

@@ -46,7 +46,7 @@ class RBN(SpotProvider):
self.status = "Connecting"
logging.info("RBN port " + str(self._port) + " connecting...")
self._telnet = telnetlib3.Telnet("telnet.reversebeacon.net", self._port)
telnet_output = self._telnet.read_until("Please enter your call: ".encode("latin-1"))
self._telnet.read_until("Please enter your call: ".encode("latin-1"))
self._telnet.write((SERVER_OWNER_CALLSIGN + "\n").encode("latin-1"))
connected = True
logging.info("RBN port " + str(self._port) + " connected.")
@@ -63,7 +63,8 @@ class RBN(SpotProvider):
match = self._LINE_PATTERN.match(telnet_output.decode("latin-1"))
if match:
spot_time = datetime.strptime(match.group(5), "%H%MZ")
spot_datetime = datetime.combine(datetime.now(pytz.UTC).date(), spot_time.time(), tzinfo=pytz.UTC)
spot_datetime = datetime.combine(datetime.now(pytz.UTC).date(), spot_time.time(),
tzinfo=pytz.UTC)
spot = Spot(source=self.name,
dx_call=match.group(3),
de_call=match.group(1),

View File

@@ -46,7 +46,7 @@ class SOTA(HTTPSpotProvider):
dx_name=source_spot["activatorName"],
de_call=source_spot["callsign"].upper(),
freq=(float(source_spot["frequency"]) * 1000000) if (
source_spot["frequency"] is not None) else None,
source_spot["frequency"] is not None) else None,
# Seen SOTA spots with no frequency!
mode=source_spot["mode"].upper(),
comment=source_spot["comments"],

View File

@@ -35,7 +35,8 @@ class Tiles(HTTPSpotProvider):
sig="Tiles",
# Tiles spots can include POTA & SOTA references, but ignore those on the basis that we will get them separately from the POTA/SOTA providers anyway.
# Just take the grid reference itself as the single Tiles SIG reference.
sig_refs=[SIGRef(id=source_spot["maidenhead_grid"], sig="Tiles", name=source_spot["maidenhead_grid"])],
sig_refs=[SIGRef(id=source_spot["maidenhead_grid"], sig="Tiles",
name=source_spot["maidenhead_grid"])],
time=datetime.fromisoformat(source_spot["created_at"].replace("Z", "+00:00")).timestamp(),
dx_grid=source_spot["maidenhead_grid"],
dx_latitude=source_spot["latitude"],
@@ -94,4 +95,4 @@ def strip_extra_decimal_points(s):
parts = s.split('.', 1)
if len(parts) == 1:
return s
return parts[0] + '.' + parts[1].replace('.', '')
return parts[0] + '.' + parts[1].replace('.', '')

View File

@@ -1,9 +1,11 @@
import logging
import re
from datetime import datetime
from typing import cast
import pytz
from rss_parser import Parser
from rss_parser.models.rss import RSS
from data.sig_ref import SIGRef
from data.spot import Spot
@@ -23,7 +25,7 @@ class WOTA(HTTPSpotProvider):
def _http_response_to_spots(self, http_response):
new_spots = []
rss = Parser.parse(http_response.content.decode())
rss = cast(RSS, Parser.parse(http_response.content.decode()))
# Iterate through source data
for source_spot in rss.channel.items:
@@ -39,15 +41,15 @@ class WOTA(HTTPSpotProvider):
ref_name = None
if len(title_split) > 1:
ref_split = title_split[1].split(" - ")
ref = ref_split[0]
ref = str(ref_split[0])
if len(ref_split) > 1:
ref_name = ref_split[1]
ref_name = str(ref_split[1])
# Pick apart the description
desc_split = source_spot.description.split(". ")
freq_mode = desc_split[0].replace("Frequencies/modes:", "").strip()
freq_mode_split = re.split(r'[\-\s]+', freq_mode)
freq_hz = float(freq_mode_split[0].replace("'",".")) * 1000000
freq_hz = float(freq_mode_split[0].replace("'", ".")) * 1000000
mode = None
if len(freq_mode_split) > 1:
mode = freq_mode_split[1].upper()

View File

@@ -1,6 +1,5 @@
from datetime import datetime
import json
from datetime import datetime
from data.sig_ref import SIGRef
from data.spot import Spot

View File

@@ -22,8 +22,8 @@ class XOTA(WebsocketSpotProvider):
def __init__(self, provider_config):
super().__init__(provider_config, provider_config["url"])
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
locations_csv = str(provider_config["locations-csv"]) if "locations-csv" in provider_config else None
self.SIG = str(provider_config["sig"]) if "sig" in provider_config else None
# Load location data
if locations_csv:
@@ -48,8 +48,9 @@ class XOTA(WebsocketSpotProvider):
freq=float(source_spot["freq"]) * 1000,
mode=source_spot["mode"].upper(),
sig=self.SIG,
sig_refs=[SIGRef(id=ref_id, sig=self.SIG, url=source_spot["reference"]["website"], latitude=lat,
longitude=lon)],
sig_refs=[
SIGRef(id=ref_id, sig=self.SIG or "", url=source_spot["reference"]["website"], latitude=lat,
longitude=lon)],
time=datetime.now(pytz.UTC).timestamp(),
dx_latitude=lat,
dx_longitude=lon,