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

@@ -23,38 +23,38 @@ class Spot:
"""Data class that defines a spot."""
# Unique identifier for the spot
id: str = None
id: str | None = None
# DX (spotted) operator info
# Callsign of the operator that has been spotted
dx_call: str = None
dx_call: str | None = None
# Name of the operator that has been spotted
dx_name: str = None
dx_name: str | None = None
# QTH of the operator that has been spotted. This could be from any SIG refs or could be from online lookup of their
# home QTH.
dx_qth: str = None
dx_qth: str | None = None
# Country of the DX operator
dx_country: str = None
dx_country: str | None = None
# Country flag of the DX operator
dx_flag: str = None
dx_flag: str | None = None
# Continent of the DX operator
dx_continent: str = None
dx_continent: str | None = None
# DXCC ID of the DX operator
dx_dxcc_id: int = None
dx_dxcc_id: int | None = None
# CQ zone of the DX operator
dx_cq_zone: int = None
dx_cq_zone: int | None = None
# ITU zone of the DX operator
dx_itu_zone: int = None
dx_itu_zone: int | None = None
# If this is an APRS/Packet/etc spot, what SSID was the DX operator using?
dx_ssid: str = None
dx_ssid: str | None = None
# Maidenhead grid locator for the DX. This could be from a geographical reference e.g. POTA, or just from the
# country
dx_grid: str = None
dx_grid: str | None = None
# Latitude & longitude of the DX, in degrees. This could be from a geographical reference e.g. POTA, or from a QRZ
# lookup
dx_latitude: float = None
dx_longitude: float = None
dx_latitude: float | None = None
dx_longitude: float | None = None
# DX Location source. Indicates how accurate the location might be. Values: "SPOT", "WAB/WAI GRID", "HOME QTH",
# "DXCC", "NONE"
dx_location_source: str = "NONE"
@@ -66,70 +66,70 @@ class Spot:
# DE (Spotter) info
# Callsign of the spotter
de_call: str = None
de_call: str | None = None
# Country of the spotter
de_country: str = None
de_country: str | None = None
# Country flag of the spotter
de_flag: str = None
de_flag: str | None = None
# Continent of the spotter
de_continent: str = None
de_continent: str | None = None
# DXCC ID of the spotter
de_dxcc_id: int = None
de_dxcc_id: int | None = None
# If this is an APRS/Packet/etc spot, what SSID was the spotter/receiver using?
de_ssid: str = None
de_ssid: str | None = None
# Maidenhead grid locator for the spotter. This is not going to be from a xOTA reference so it will likely just be
# a QRZ or DXCC lookup. If the spotter is also portable, this is probably wrong, but it's good enough for some
# simple mapping.
de_grid: str = None
de_grid: str | None = None
# Latitude & longitude of the DX, in degrees. This is not going to be from a xOTA reference so it will likely just
# be a QRZ or DXCC lookup. If the spotter is also portable, this is probably wrong, but it's good enough for some
# simple mapping.
de_latitude: float = None
de_longitude: float = None
de_latitude: float | None = None
de_longitude: float | None = None
# General QSO info
# Reported mode, such as SSB, PHONE, CW, FT8...
mode: str = None
mode: str | None = None
# Inferred mode "family". One of "CW", "PHONE" or "DIGI".
mode_type: str = None
mode_type: str | None = None
# Source of the mode information. "SPOT", "COMMENT", "BANDPLAN" or "NONE"
mode_source: str = "NONE"
# Frequency, in Hz
freq: float = None
freq: float | None = None
# Band, defined by the frequency, e.g. "40m" or "70cm"
band: str = None
band: str | None = None
# Comment left by the spotter, if any
comment: str = None
comment: str | None = None
# QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
qrt: bool = False
# Special Interest Group info
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
sig: str = None
sig: str | None = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
sig_refs: list = None
sig_refs: list | None = None
# Timing info
# Time of the spot, UTC seconds since UNIX epoch
time: float = None
time: float | None = None
# Time of the spot, ISO 8601
time_iso: str = None
time_iso: str | None = None
# Time that this software received the spot, UTC seconds since UNIX epoch. This is used with the "since_received"
# call to our API to receive all data that is new to us, even if by a quirk of the API it might be older than the
# list time the client polled the API.
received_time: float = None
received_time: float | None = None
# Time that this software received the spot, ISO 8601
received_time_iso: str = None
received_time_iso: str | None = None
# Source info
# Where we got the spot from, e.g. "POTA", "Cluster"...
source: str = None
source: str | None = None
# The ID the source gave it, if any.
source_id: str = None
source_id: str | None = None
def infer_missing(self, credentials=None):
"""Infer missing parameters where possible"""
@@ -342,9 +342,10 @@ class Spot:
# Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable string,
# otherwise see what they have set on an online lookup service.
if self.sig_refs and len(self.sig_refs) > 0:
self.dx_qth = self.sig_refs[0].id
qth = self.sig_refs[0].id
if self.sig_refs[0].name:
self.dx_qth = self.dx_qth + " " + self.sig_refs[0].name
qth += " " + self.sig_refs[0].name
self.dx_qth = qth
else:
self.dx_qth = lookup_helper.infer_qth_from_callsign_online_lookup(self.dx_call, credentials)
@@ -360,10 +361,11 @@ class Spot:
# It looks like we can sometimes get a string into lat/lon, so try to parse as float, reject if not valid
if isinstance(self.dx_latitude, str) or isinstance(self.dx_longitude, str):
try:
self.dx_latitude = float(self.dx_latitude)
self.dx_longitude = float(self.dx_longitude)
self.dx_latitude = float(str(self.dx_latitude))
self.dx_longitude = float(str(self.dx_longitude))
except (TypeError, ValueError):
logging.warning("Received non-numeric strings in lat/lon (" + str(self.dx_latitude) + ", " + str(self.dx_longitude) + ") for call " + self.dx_call + ", rejecting it")
logging.warning("Received non-numeric strings in lat/lon (" + str(self.dx_latitude) + ", " + str(
self.dx_longitude) + ") for call " + str(self.dx_call) + ", rejecting it")
self.dx_latitude = None
self.dx_longitude = None
@@ -381,10 +383,10 @@ class Spot:
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
# is likely at home.
self.dx_location_good = self.dx_latitude and self.dx_longitude and (
self.dx_location_good = bool(self.dx_latitude and self.dx_longitude and (
self.dx_location_source == "SPOT" or self.dx_location_source == "SIG REF LOOKUP"
or self.dx_location_source == "WAB/WAI GRID"
or (self.dx_location_source == "HOME QTH" and not "/" in self.dx_call))
or (self.dx_location_source == "HOME QTH" and "/" not in (self.dx_call or ""))))
# DE with no digits and APRS servers starting "T2" are not things we can look up location for
if self.de_call and any(char.isdigit() for char in self.de_call) and not (
@@ -413,16 +415,16 @@ class Spot:
def _append_sig_ref_if_missing(self, new_sig_ref):
"""Append a sig_ref to the list, so long as it's not already there."""
if not self.sig_refs:
self.sig_refs = []
sig_refs = self.sig_refs or []
self.sig_refs = sig_refs
new_sig_ref.id = new_sig_ref.id.strip().upper()
new_sig_ref.sig = new_sig_ref.sig.strip().upper()
if new_sig_ref.id == "":
return
for sig_ref in self.sig_refs:
for sig_ref in sig_refs:
if sig_ref.id == new_sig_ref.id and sig_ref.sig == new_sig_ref.sig:
return
self.sig_refs.append(new_sig_ref)
sig_refs.append(new_sig_ref)
def expired(self):
"""Decide if this spot has expired (in which case it should not be added to the system in the first place, and not