Allow adding spots. Convert timestamps in the API to UNIX seconds. #2

This commit is contained in:
Ian Renton
2025-10-04 13:28:22 +01:00
parent 0419aab83f
commit 5f16abf709
16 changed files with 51 additions and 37 deletions

View File

@@ -150,9 +150,7 @@ def infer_mode_from_frequency(freq):
# Convert objects to serialisable things. Used by JSON serialiser as a default when it encounters unserializable things.
# Converts datetimes to ISO.
# Anything else it tries to convert to a dict.
# 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.
def serialize_everything(obj):
if isinstance(obj, datetime):
return obj.isoformat()
return obj.__dict__

View File

@@ -57,12 +57,16 @@ class Spot:
freq: float = None
# Band, defined by the frequency, e.g. "40m" or "70cm"
band: str = None
# Time of the spot
time: datetime = None
# Time that this software received the spot. 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: datetime = datetime.now(pytz.UTC)
# Time of the spot, UTC seconds since UNIX epoch
time: float = None
# Time of the spot, ISO 8601
time_iso: str = 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
# Time that this software received the spot, ISO 8601
received_time_iso: str = None
# Comment left by the spotter, if any
comment: str = None
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
@@ -96,9 +100,20 @@ class Spot:
# Always create a GUID
self.guid = str(uuid.uuid4())
# If we somehow don't have a time, set it to some far past value so it sorts at the bottom of the list
# If we somehow don't have a spot time, set it to zero so it sorts off the bottom of any list but
# clients can still reliably parse it as a number.
if not self.time:
self.time = datetime.min
self.time = 0
# If we don't have a received time, this has just been received so set that to "now"
if not self.received_time:
self.received_time = datetime.now(pytz.UTC).timestamp()
# Fill in ISO versions of times, in case the client prefers that
if self.time and not self.time_iso:
self.time_iso = datetime.fromtimestamp(self.time, pytz.UTC).isoformat()
if self.received_time and not self.received_time_iso:
self.received_time_iso = datetime.fromtimestamp(self.received_time, pytz.UTC).isoformat()
# Clean up DX call if it has an SSID or -# from RBN
if self.dx_call and "-" in self.dx_call:

View File

@@ -48,7 +48,7 @@ class APRSIS(Provider):
latitude=data["latitude"] if "latitude" in data else None,
longitude=data["longitude"] if "longitude" in data else None,
icon="tower-cell",
time=datetime.now(pytz.UTC)) # APRS-IS spots are live so we can assume spot time is "now"
time=datetime.now(pytz.UTC).timestamp()) # APRS-IS spots are live so we can assume spot time is "now"
# Add to our list
self.submit(spot)

View File

@@ -70,7 +70,7 @@ class DXCluster(Provider):
freq=float(match.group(2)) * 1000,
comment=match.group(4).strip(),
icon="desktop",
time=spot_datetime)
time=spot_datetime.timestamp())
# Add to our list
self.submit(spot)

View File

@@ -36,7 +36,7 @@ class GMA(HTTPProvider):
sig_refs=[source_spot["REF"]],
sig_refs_names=[source_spot["NAME"]],
time=datetime.strptime(source_spot["DATE"] + source_spot["TIME"], "%Y%m%d%H%M").replace(
tzinfo=pytz.UTC),
tzinfo=pytz.UTC).timestamp(),
latitude=float(source_spot["LAT"]) if (source_spot["LAT"] != "") else None,
# Seen GMA spots with no lat/lon
longitude=float(source_spot["LON"]) if (source_spot["LON"] != "") else None)

View File

@@ -55,7 +55,7 @@ class HEMA(HTTPProvider):
sig_refs=[spot_items[3].upper()],
sig_refs_names=[spot_items[4]],
icon="mound",
time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M").replace(tzinfo=pytz.UTC),
time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M").replace(tzinfo=pytz.UTC).timestamp(),
latitude=float(spot_items[7]),
longitude=float(spot_items[8]))

View File

@@ -30,7 +30,7 @@ class ParksNPeaks(HTTPProvider):
sig=source_spot["actClass"],
sig_refs=[source_spot["actSiteID"]],
icon="question", # todo determine from actClass
time=datetime.strptime(source_spot["actTime"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC))
time=datetime.strptime(source_spot["actTime"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp())
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore.
if spot.sig not in ["POTA", "SOTA", "WWFF"]:

View File

@@ -30,7 +30,7 @@ class POTA(HTTPProvider):
sig_refs=[source_spot["reference"]],
sig_refs_names=[source_spot["name"]],
icon="tree",
time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=pytz.UTC),
time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=pytz.UTC).timestamp(),
grid=source_spot["grid6"],
latitude=source_spot["latitude"],
longitude=source_spot["longitude"])

View File

@@ -35,12 +35,12 @@ class Provider:
# subclasses on receiving spots.
def submit_batch(self, spots):
for spot in spots:
if spot.time > self.last_spot_time:
if datetime.fromtimestamp(spot.time, pytz.UTC) > self.last_spot_time:
# Fill in any blanks
spot.infer_missing()
# Add to the list
self.spots.add(spot.guid, spot, expire=MAX_SPOT_AGE)
self.last_spot_time = max(map(lambda s: s.time, spots))
self.last_spot_time = datetime.fromtimestamp(max(map(lambda s: s.time, spots)), pytz.UTC)
# Submit a single spot retrieved from the provider. This will be added to the list regardless of its age. Spots
# passing the check will also have their infer_missing() method called to complete their data set. This is called by
@@ -50,7 +50,7 @@ class Provider:
spot.infer_missing()
# Add to the list
self.spots.add(spot.guid, spot, expire=MAX_SPOT_AGE)
self.last_spot_time = spot.time
self.last_spot_time = datetime.fromtimestamp(spot.time, pytz.UTC)
# Stop any threads and prepare for application shutdown
def stop(self):

View File

@@ -71,7 +71,7 @@ class RBN(Provider):
freq=float(match.group(2)) * 1000,
comment=match.group(4).strip(),
icon="tower-cell",
time=spot_datetime)
time=spot_datetime.timestamp())
# Add to our list
self.submit(spot)

View File

@@ -49,7 +49,7 @@ class SOTA(HTTPProvider):
sig_refs=[source_spot["summitCode"]],
sig_refs_names=[source_spot["summitName"]],
icon="mountain-sun",
time=datetime.fromisoformat(source_spot["timeStamp"]),
time=datetime.fromisoformat(source_spot["timeStamp"]).timestamp(),
activation_score=source_spot["points"])
# SOTA doesn't give summit lat/lon/grid in the main call, so we need another separate call for this

View File

@@ -33,7 +33,7 @@ class WWBOTA(HTTPProvider):
sig_refs=refs,
sig_refs_names=ref_names,
icon="radiation",
time=datetime.fromisoformat(source_spot["time"]),
time=datetime.fromisoformat(source_spot["time"]).timestamp(),
# WWBOTA spots can contain multiple references for bunkers being activated simultaneously. For
# now, we will just pick the first one to use as our grid, latitude and longitude.
grid=source_spot["references"][0]["locator"],

View File

@@ -30,7 +30,7 @@ class WWFF(HTTPProvider):
sig_refs=[source_spot["reference"]],
sig_refs_names=[source_spot["reference_name"]],
icon="seedling",
time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC),
time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC).timestamp(),
latitude=source_spot["latitude"],
longitude=source_spot["longitude"])

View File

@@ -87,10 +87,11 @@ class WebServer:
response.content_type = 'application/json'
response.set_header('Cache-Control', 'no-store')
return json.dumps("OK", default=serialize_everything)
except Exception:
response.content_type = 'application/json'
response.status = 422
return json.dumps("An error occurred parsing your spot. Check it is compliant with the API.", default=serialize_everything)
except Exception as e:
logging.error(e)
response.content_type = 'application/json'
response.status = 422
return json.dumps("An error occurred parsing your spot: " + str(e), default=serialize_everything)
# Serve a templated page
def serve_template(self, template_name):

View File

@@ -312,7 +312,7 @@ paths:
operationId: spot
parameters:
- name: spot
description: The spot data to post
description: The spot data to post. The structure must contain at least "time" and "dx_call" to be accepted.
required: true
schema:
type:
@@ -487,13 +487,13 @@ components:
- Unknown
example: 40m
time:
type: string
description: Time of the spot, ISO 8601 format
example: 2025-09-28T19:12:41Z
type: number
description: Time of the spot, UTC seconds since UNIX epoch
example: 1759579508
received_time:
type: string
description: Time that this software received the spot, ISO 8601 format. 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.
example: 2025-09-28T19:12:41Z
type: number
description: 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.
example: 1759579508
comment:
type: string
description: Comment left by the spotter, if any

View File

@@ -83,7 +83,7 @@ function updateTable() {
}
// Format a UTC time for display
var time = moment.utc(s["time"], moment.ISO_8601);
var time = moment.unix(s["time"]).utc();
var time_formatted = time.format("HH:mm")
// Format dx country