import copy import hashlib import json import logging 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.lookup_helper import lookup_helper # Data class that defines a spot. @dataclass class Spot: # Unique identifier for the spot id: str = None # DX (spotted) operator info # Callsign of the operator that has been spotted dx_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 flag of the DX operator dx_flag: str = None # Continent of the DX operator dx_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 # 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 # 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 Location source. Indicates how accurate the location might be. Values: "SPOT", "QRZ, "DXCC", "NONE" dx_location_source: str = "NONE" # DX Location good. Indicates that the software thinks the location data is good enough to plot on a map. dx_location_good: bool = False # DE (Spotter) info # Callsign of the spotter de_call: str = None # Country of the spotter de_country: str = None # Country flag of the spotter de_flag: str = None # Continent of the spotter de_continent: str = 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 # 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 # General QSO info # 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 # Comment left by the spotter, if any comment: str = 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 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 # SIG reference URLs sig_refs_urls: list = None # Activation score. SOTA only activation_score: int = None # Display guidance (optional) # 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" # Colour to represent this spot, if a client chooses to colour spots based on their frequency band, using PSK # Reporter's default colours. HTML colour e.g. hex. A contrast colour is also provided which will be black or white. band_color: str = None band_contrast_color: str = None # Timing info # 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 # Source info # 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 = lookup_helper.infer_country_from_callsign(self.dx_call) if self.dx_call and not self.dx_continent: self.dx_continent = lookup_helper.infer_continent_from_callsign(self.dx_call) if self.dx_call and not self.dx_cq_zone: self.dx_cq_zone = lookup_helper.infer_cq_zone_from_callsign(self.dx_call) if self.dx_call and not self.dx_itu_zone: self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_call) if self.dx_call and not self.dx_dxcc_id: self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_call) if self.dx_dxcc_id and self.dx_dxcc_id in DXCC_FLAGS 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. # DE of "RBNHOLE" and "SOTAMAT" are not things we can look up location for if self.de_call != "RBNHOLE" and self.de_call != "SOTAMAT": if self.de_call and not self.de_country: self.de_country = lookup_helper.infer_country_from_callsign(self.de_call) if self.de_call and not self.de_continent: self.de_continent = lookup_helper.infer_continent_from_callsign(self.de_call) if self.de_call and not self.de_dxcc_id: self.de_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.de_call) if self.de_dxcc_id and self.de_dxcc_id in DXCC_FLAGS 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 = lookup_helper.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 or bandplan if self.mode: self.mode_source = "SPOT" if self.comment and not self.mode: self.mode = lookup_helper.infer_mode_from_comment(self.comment) self.mode_source = "COMMENT" if self.freq and not self.mode: self.mode = lookup_helper.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 = lookup_helper.infer_mode_type_from_mode(self.mode) # DX Grid to lat/lon and vice versa if self.dx_grid and not self.dx_latitude: ll = locator_to_latlong(self.dx_grid) self.dx_latitude = ll[0] self.dx_longitude = ll[1] if self.dx_latitude and self.dx_longitude and not self.dx_grid: try: self.dx_grid = latlong_to_locator(self.dx_latitude, self.dx_longitude, 8) except: logging.debug("Invalid lat/lon received for spot") if self.dx_latitude: self.dx_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 = lookup_helper.infer_name_from_callsign(self.dx_call) if self.dx_call and not self.dx_latitude: latlon = lookup_helper.infer_latlon_from_callsign_qrz(self.dx_call) if latlon: self.dx_latitude = latlon[0] self.dx_longitude = latlon[1] self.dx_grid = lookup_helper.infer_grid_from_callsign_qrz(self.dx_call) self.dx_location_source = "QRZ" # Last resort for getting a DX position, use the DXCC entity. if self.dx_call and not self.dx_latitude: latlon = lookup_helper.infer_latlon_from_callsign_dxcc(self.dx_call) if latlon: self.dx_latitude = latlon[0] self.dx_longitude = latlon[1] self.dx_grid = lookup_helper.infer_grid_from_callsign_dxcc(self.dx_call) self.dx_location_source = "DXCC" # 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_location_source == "SPOT" or ( self.dx_location_source == "QRZ" and not "/" in self.dx_call) # DE of "RBNHOLE" and "SOTAMAT" are not things we can look up location for if self.de_call != "RBNHOLE" and self.de_call != "SOTAMAT": # DE operator position lookup, using QRZ.com. if self.de_call and not self.de_latitude: latlon = lookup_helper.infer_latlon_from_callsign_qrz(self.de_call) if latlon: self.de_latitude = latlon[0] self.de_longitude = latlon[1] self.de_grid = lookup_helper.infer_grid_from_callsign_qrz(self.de_call) # Last resort for getting a DE position, use the DXCC entity. if self.de_call and not self.de_latitude: latlon = lookup_helper.infer_latlon_from_callsign_dxcc(self.de_call) if latlon: self.de_latitude = latlon[0] self.de_longitude = latlon[1] self.de_grid = lookup_helper.infer_grid_from_callsign_dxcc(self.de_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)