mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2025-10-27 08:49:27 +00:00
224 lines
10 KiB
Python
224 lines
10 KiB
Python
import copy
|
|
import hashlib
|
|
import json
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
|
|
import pytz
|
|
from pyhamtools.locator import locator_to_latlong, latlong_to_locator
|
|
|
|
from core.constants import DXCC_FLAGS
|
|
from core.utils import infer_mode_type_from_mode, infer_band_from_freq, infer_continent_from_callsign, \
|
|
infer_country_from_callsign, infer_cq_zone_from_callsign, infer_itu_zone_from_callsign, infer_dxcc_id_from_callsign, \
|
|
infer_mode_from_comment, infer_name_from_callsign, infer_latlon_from_callsign_dxcc, infer_grid_from_callsign_dxcc, \
|
|
infer_latlon_from_callsign_qrz, infer_grid_from_callsign_qrz, infer_mode_from_frequency
|
|
|
|
|
|
# Data class that defines a spot.
|
|
@dataclass
|
|
class Spot:
|
|
# Unique identifier for the spot
|
|
id: str = None
|
|
# Callsign of the operator that has been spotted
|
|
dx_call: str = None
|
|
# Callsign of the operator that has spotted them
|
|
de_call: str = None
|
|
# Name of the operator that has been spotted
|
|
dx_name: str = None
|
|
# Country of the DX operator
|
|
dx_country: str = None
|
|
# Country of the spotter
|
|
de_country: str = None
|
|
# Country flag of the DX operator
|
|
dx_flag: str = None
|
|
# Country flag of the spotter
|
|
de_flag: str = None
|
|
# Continent of the DX operator
|
|
dx_continent: str = None
|
|
# Continent of the spotter
|
|
de_continent: str = None
|
|
# DXCC ID of the DX operator
|
|
dx_dxcc_id: int = None
|
|
# DXCC ID of the spotter
|
|
de_dxcc_id: int = None
|
|
# CQ zone of the DX operator
|
|
dx_cq_zone: int = None
|
|
# ITU zone of the DX operator
|
|
dx_itu_zone: int = None
|
|
# If this is an APRS spot, what SSID was the DX operator using?
|
|
# This is a string not an int for now, as I often see non-numeric ones somehow
|
|
dx_aprs_ssid: str = None
|
|
# Reported mode, such as SSB, PHONE, CW, FT8...
|
|
mode: str = None
|
|
# Inferred mode "family". One of "CW", "PHONE" or "DIGI".
|
|
mode_type: str = None
|
|
# Source of the mode information. "SPOT", "COMMENT", "BANDPLAN" or "NONE"
|
|
mode_source: str = "NONE"
|
|
# Frequency, in Hz
|
|
freq: float = None
|
|
# Band, defined by the frequency, e.g. "40m" or "70cm"
|
|
band: str = None
|
|
# 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
|
|
sig: str = None
|
|
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
|
sig_refs: list = None
|
|
# SIG reference names
|
|
sig_refs_names: list = None
|
|
# Activation score. SOTA only
|
|
activation_score: int = None
|
|
# Icon, from the Font Awesome set. This is fairly opinionated but is here to help the Spothole web UI and Field Spotter. Does not include the "fa-" prefix.
|
|
icon: str = "question"
|
|
# Maidenhead grid locator for the spot. This could be from a geographical reference e.g. POTA, or just from the country
|
|
grid: str = None
|
|
# Latitude & longitude, in degrees. This could be from a geographical reference e.g. POTA, or from a QRZ lookup
|
|
latitude: float = None
|
|
longitude: float = None
|
|
# Location source. Indicates how accurate the location might be. Values: "SPOT", "QRZ, "DXCC", "NONE"
|
|
location_source: str = "NONE"
|
|
# Location good. Indicates that the software thinks the location data is good enough to plot on a map.
|
|
location_good: bool = False
|
|
# QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
|
|
qrt: bool = False
|
|
# Where we got the spot from, e.g. "POTA", "Cluster"...
|
|
source: str = None
|
|
# The ID the source gave it, if any.
|
|
source_id: str = None
|
|
|
|
# Infer missing parameters where possible
|
|
def infer_missing(self):
|
|
# 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 = 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:
|
|
self.dx_call = self.dx_call.split("-")[0]
|
|
|
|
# DX country, continent, zones etc. from callsign
|
|
if self.dx_call and not self.dx_country:
|
|
self.dx_country = infer_country_from_callsign(self.dx_call)
|
|
if self.dx_call and not self.dx_continent:
|
|
self.dx_continent = infer_continent_from_callsign(self.dx_call)
|
|
if self.dx_call and not self.dx_cq_zone:
|
|
self.dx_cq_zone = infer_cq_zone_from_callsign(self.dx_call)
|
|
if self.dx_call and not self.dx_itu_zone:
|
|
self.dx_itu_zone = infer_itu_zone_from_callsign(self.dx_call)
|
|
if self.dx_call and not self.dx_dxcc_id:
|
|
self.dx_dxcc_id = infer_dxcc_id_from_callsign(self.dx_call)
|
|
if self.dx_dxcc_id and DXCC_FLAGS[self.dx_dxcc_id] and not self.dx_flag:
|
|
self.dx_flag = DXCC_FLAGS[self.dx_dxcc_id]
|
|
|
|
# Clean up spotter call if it has an SSID or -# from RBN
|
|
if self.de_call and "-" in self.de_call:
|
|
self.de_call = self.de_call.split("-")[0]
|
|
|
|
# Spotter country, continent, zones etc. from callsign
|
|
if self.de_call and not self.de_country:
|
|
self.de_country = infer_country_from_callsign(self.de_call)
|
|
if self.de_call and not self.de_continent:
|
|
self.de_continent = infer_continent_from_callsign(self.de_call)
|
|
if self.de_call and not self.de_dxcc_id:
|
|
self.de_dxcc_id = infer_dxcc_id_from_callsign(self.de_call)
|
|
if self.de_dxcc_id and not self.de_flag:
|
|
self.de_flag = DXCC_FLAGS[self.de_dxcc_id]
|
|
|
|
# Band from frequency
|
|
if self.freq and not self.band:
|
|
band = infer_band_from_freq(self.freq)
|
|
self.band = band.name
|
|
|
|
# Mode from comments or bandplan
|
|
if self.mode:
|
|
self.mode_source = "SPOT"
|
|
if self.comment and not self.mode:
|
|
self.mode = infer_mode_from_comment(self.comment)
|
|
self.mode_source = "COMMENT"
|
|
if self.freq and not self.mode:
|
|
self.mode = infer_mode_from_frequency(self.freq)
|
|
self.mode_source = "BANDPLAN"
|
|
|
|
# Normalise "generic digital" modes. "DIGITAL", "DIGI" and "DATA" are just the same thing with no extra
|
|
# information, so standardise on "DATA"
|
|
if self.mode == "DIGI" or self.mode == "DIGITAL":
|
|
self.mode = "DATA"
|
|
|
|
# Mode type from mode
|
|
if self.mode and not self.mode_type:
|
|
self.mode_type = infer_mode_type_from_mode(self.mode)
|
|
|
|
# Grid to lat/lon and vice versa
|
|
if self.grid and not self.latitude:
|
|
ll = locator_to_latlong(self.grid)
|
|
self.latitude = ll[0]
|
|
self.longitude = ll[1]
|
|
if self.latitude and self.longitude and not self.grid:
|
|
self.grid = latlong_to_locator(self.latitude, self.longitude, 8)
|
|
if self.latitude:
|
|
self.location_source = "SPOT"
|
|
|
|
# QRT comment detection
|
|
if self.comment and not self.qrt:
|
|
self.qrt = "QRT" in self.comment.upper()
|
|
|
|
# DX operator details lookup, using QRZ.com. This should be the last resort compared to taking the data from
|
|
# the actual spotting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon instead of
|
|
# the one from the park reference they're at.
|
|
if self.dx_call and not self.dx_name:
|
|
self.dx_name = infer_name_from_callsign(self.dx_call)
|
|
if self.dx_call and not self.latitude:
|
|
latlon = infer_latlon_from_callsign_qrz(self.dx_call)
|
|
if latlon:
|
|
self.latitude = latlon[0]
|
|
self.longitude = latlon[1]
|
|
self.grid = infer_grid_from_callsign_qrz(self.dx_call)
|
|
self.location_source = "QRZ"
|
|
|
|
# Last resort for getting a position, use the DXCC entity.
|
|
if self.dx_call and not self.latitude:
|
|
latlon = infer_latlon_from_callsign_dxcc(self.dx_call)
|
|
if latlon:
|
|
self.latitude = latlon[0]
|
|
self.longitude = latlon[1]
|
|
self.grid = infer_grid_from_callsign_dxcc(self.dx_call)
|
|
self.location_source = "DXCC"
|
|
|
|
# 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.location_good = self.location_source == "SPOT" or (self.location_source == "QRZ" and not "/" in self.dx_call)
|
|
|
|
# Always create an ID based on a hash of every parameter *except* received_time. This is used as the index
|
|
# to a map, which as a byproduct avoids us having multiple duplicate copies of the object that are identical
|
|
# apart from that they were retrieved from the API at different times. Note that the simple Python hash()
|
|
# function includes a seed randomly generated at runtime; this is therefore not consistent between runs. But we
|
|
# use diskcache to store our data between runs, so we use SHA256 which does not include this random element.
|
|
self_copy = copy.deepcopy(self)
|
|
self_copy.received_time = 0
|
|
self_copy.received_time_iso = ""
|
|
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
|
|
|
|
# JSON serialise
|
|
def to_json(self):
|
|
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True) |