Files
spothole/data/spot.py
2025-09-29 20:45:39 +01:00

174 lines
7.6 KiB
Python

import json
import uuid
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_family_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
# Data class that defines a spot.
@dataclass
class Spot:
# Globally unique identifier for the spot
guid: str = str(uuid.uuid4())
# 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_family: str = None
# Frequency, in kHz
freq: float = None
# Band, defined by the frequency, e.g. "40m" or "70cm"
band: str = None
# Colour to use for the band
band_color: str = None
# Contrast colour to use for text on a background of band_color
band_contrast_color: 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)
# 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
# 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 = None
# QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
qrt: bool = None
# 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):
# 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 not self.dx_flag:
self.dx_flag = DXCC_FLAGS[self.dx_dxcc_id]
# 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
self.band_color = band.color
self.band_contrast_color = band.contrast_color
# Mode from comments, mode family from mode
if self.comment and not self.mode:
self.mode=infer_mode_from_comment(self.comment)
if self.mode and not self.mode_family:
self.mode_family=infer_mode_family_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)
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)
# JSON serialise
def to_json(self):
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)