Provide an externally usable callsign lookup feature. #73

This commit is contained in:
Ian Renton
2025-11-02 16:52:27 +00:00
parent 92af0761aa
commit d80c4cfbeb
4 changed files with 170 additions and 33 deletions

View File

@@ -35,8 +35,6 @@ class Spot:
dx_continent: str = None dx_continent: str = None
# DXCC ID of the DX operator # DXCC ID of the DX operator
dx_dxcc_id: int = None dx_dxcc_id: int = None
# DXCC ID of the spotter
de_dxcc_id: int = None
# CQ zone of the DX operator # CQ zone of the DX operator
dx_cq_zone: int = None dx_cq_zone: int = None
# ITU zone of the DX operator # ITU zone of the DX operator
@@ -50,11 +48,12 @@ class Spot:
# lookup # lookup
dx_latitude: float = None dx_latitude: float = None
dx_longitude: float = None dx_longitude: float = None
# DX Location source. Indicates how accurate the location might be. Values: "SPOT", "WAB/WAI GRID", "QRZ", "DXCC", "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" dx_location_source: str = "NONE"
# DX Location good. Indicates that the software thinks the location data is good enough to plot on a map. This is # DX Location good. Indicates that the software thinks the location data is good enough to plot on a map. This is
# true if the location source is "SPOT" or "WAB/WAI GRID", or if the location source is "QRZ" and the DX callsign # true if the location source is "SPOT" or "WAB/WAI GRID", or if the location source is "HOME QTH" and the DX
# doesn't have a suffix like /P. # callsign doesn't have a suffix like /P.
dx_location_good: bool = False dx_location_good: bool = False
# DE (Spotter) info # DE (Spotter) info
@@ -67,6 +66,8 @@ class Spot:
de_flag: str = None de_flag: str = None
# Continent of the spotter # Continent of the spotter
de_continent: str = None de_continent: str = None
# DXCC ID of the spotter
de_dxcc_id: int = None
# If this is an APRS/Packet/etc spot, what SSID was the spotter/receiver using? # If this is an APRS/Packet/etc spot, what SSID was the spotter/receiver using?
de_ssid: str = None de_ssid: str = None
# Maidenhead grid locator for the spotter. This is not going to be from a xOTA reference so it will likely just be # Maidenhead grid locator for the spotter. This is not going to be from a xOTA reference so it will likely just be
@@ -196,12 +197,12 @@ class Spot:
# Spotter country, continent, zones etc. from callsign. # Spotter country, continent, zones etc. from callsign.
# DE call with no digits, or APRS servers starting "T2" are not things we can look up location for # DE call with no digits, or APRS servers starting "T2" are not things we can look up location for
if any(char.isdigit() for char in self.de_call) and not (self.de_call.startswith("T2") and self.source == "APRS-IS"): if self.de_call and any(char.isdigit() for char in self.de_call) and not (self.de_call.startswith("T2") and self.source == "APRS-IS"):
if self.de_call and not self.de_country: if not self.de_country:
self.de_country = lookup_helper.infer_country_from_callsign(self.de_call) self.de_country = lookup_helper.infer_country_from_callsign(self.de_call)
if self.de_call and not self.de_continent: if not self.de_continent:
self.de_continent = lookup_helper.infer_continent_from_callsign(self.de_call) self.de_continent = lookup_helper.infer_continent_from_callsign(self.de_call)
if self.de_call and not self.de_dxcc_id: if not self.de_dxcc_id:
self.de_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.de_call) 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: 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] self.de_flag = DXCC_FLAGS[self.de_dxcc_id]
@@ -238,7 +239,7 @@ class Spot:
# See if we already have a SIG reference, but the comment looks like it contains more for the same SIG. This # See if we already have a SIG reference, but the comment looks like it contains more for the same SIG. This
# should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002". # should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002".
if self.sig_refs and len(self.sig_refs) > 0: if self.comment and self.sig_refs and len(self.sig_refs) > 0:
sig = self.sig_refs[0].sig.upper() sig = self.sig_refs[0].sig.upper()
all_comment_refs = re.findall(get_ref_regex_for_sig(sig), self.comment) all_comment_refs = re.findall(get_ref_regex_for_sig(sig), self.comment)
for ref in all_comment_refs: for ref in all_comment_refs:
@@ -247,21 +248,22 @@ class Spot:
# See if the comment looks like it contains any SIGs (and optionally SIG references) that we can # See if the comment looks like it contains any SIGs (and optionally SIG references) that we can
# add to the spot. This should catch cluster spot comments like "POTA GB-0001 WWFF GFF-0001" and e.g. POTA # add to the spot. This should catch cluster spot comments like "POTA GB-0001 WWFF GFF-0001" and e.g. POTA
# comments like "also WWFF GFF-0001". # comments like "also WWFF GFF-0001".
sig_matches = re.finditer(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", self.comment, re.IGNORECASE) if self.comment:
for sig_match in sig_matches: sig_matches = re.finditer(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster for sig_match in sig_matches:
# spots where the comment is just "POTA". # First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster
found_sig = sig_match.group(2).upper() # spots where the comment is just "POTA".
if not self.sig: found_sig = sig_match.group(2).upper()
self.sig = found_sig if not self.sig:
self.sig = found_sig
# Now look to see if that SIG name was followed by something that looks like a reference ID for that SIG. # Now look to see if that SIG name was followed by something that looks like a reference ID for that SIG.
# If so, add that to the sig_refs list for this spot. # If so, add that to the sig_refs list for this spot.
ref_regex = get_ref_regex_for_sig(found_sig) ref_regex = get_ref_regex_for_sig(found_sig)
if ref_regex: if ref_regex:
ref_matches = re.finditer(r"(^|\W)" + found_sig + r"($|\W)(" + ref_regex + r")($|\W)", self.comment, re.IGNORECASE) ref_matches = re.finditer(r"(^|\W)" + found_sig + r"($|\W)(" + ref_regex + r")($|\W)", self.comment, re.IGNORECASE)
for ref_match in ref_matches: for ref_match in ref_matches:
self.append_sig_ref_if_missing(SIGRef(id=ref_match.group(3).upper(), sig=found_sig)) self.append_sig_ref_if_missing(SIGRef(id=ref_match.group(3).upper(), sig=found_sig))
# Fetch SIG data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a reference # Fetch SIG data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a reference
# in its initial call, we use this code to populate the rest of the data. This includes working out grid refs # in its initial call, we use this code to populate the rest of the data. This includes working out grid refs
@@ -318,7 +320,7 @@ class Spot:
self.dx_latitude = latlon[0] self.dx_latitude = latlon[0]
self.dx_longitude = latlon[1] self.dx_longitude = latlon[1]
self.dx_grid = lookup_helper.infer_grid_from_callsign_qrz(self.dx_call) self.dx_grid = lookup_helper.infer_grid_from_callsign_qrz(self.dx_call)
self.dx_location_source = "QRZ" self.dx_location_source = "HOME QTH"
# Last resort for getting a DX position, use the DXCC entity. # Last resort for getting a DX position, use the DXCC entity.
if self.dx_call and not self.dx_latitude: if self.dx_call and not self.dx_latitude:
@@ -333,12 +335,12 @@ class Spot:
# is likely at home. # is likely at home.
self.dx_location_good = (self.dx_location_source == "SPOT" or self.dx_location_source == "SIG REF LOOKUP" self.dx_location_good = (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 == "WAB/WAI GRID"
or (self.dx_location_source == "QRZ" and not "/" in self.dx_call)) or (self.dx_location_source == "HOME QTH" and not "/" in self.dx_call))
# DE with no digits and APRS servers starting "T2" are not things we can look up location for # DE with no digits and APRS servers starting "T2" are not things we can look up location for
if any(char.isdigit() for char in self.de_call) and not (self.de_call.startswith("T2") and self.source == "APRS-IS"): if self.de_call and any(char.isdigit() for char in self.de_call) and not (self.de_call.startswith("T2") and self.source == "APRS-IS"):
# DE operator position lookup, using QRZ.com. # DE operator position lookup, using QRZ.com.
if self.de_call and not self.de_latitude: if not self.de_latitude:
latlon = lookup_helper.infer_latlon_from_callsign_qrz(self.de_call) latlon = lookup_helper.infer_latlon_from_callsign_qrz(self.de_call)
if latlon: if latlon:
self.de_latitude = latlon[0] self.de_latitude = latlon[0]
@@ -346,7 +348,7 @@ class Spot:
self.de_grid = lookup_helper.infer_grid_from_callsign_qrz(self.de_call) self.de_grid = lookup_helper.infer_grid_from_callsign_qrz(self.de_call)
# Last resort for getting a DE position, use the DXCC entity. # Last resort for getting a DE position, use the DXCC entity.
if self.de_call and not self.de_latitude: if not self.de_latitude:
latlon = lookup_helper.infer_latlon_from_callsign_dxcc(self.de_call) latlon = lookup_helper.infer_latlon_from_callsign_dxcc(self.de_call)
if latlon: if latlon:
self.de_latitude = latlon[0] self.de_latitude = latlon[0]

View File

@@ -43,6 +43,7 @@ class WebServer:
bottle.get("/api/v1/alerts")(lambda: self.serve_alerts_api()) bottle.get("/api/v1/alerts")(lambda: self.serve_alerts_api())
bottle.get("/api/v1/options")(lambda: self.serve_api(self.get_options())) bottle.get("/api/v1/options")(lambda: self.serve_api(self.get_options()))
bottle.get("/api/v1/status")(lambda: self.serve_api(self.status_data)) bottle.get("/api/v1/status")(lambda: self.serve_api(self.status_data))
bottle.get("/api/v1/lookup/call")(lambda: self.serve_call_lookup_api())
bottle.get("/api/v1/lookup/sigref")(lambda: self.serve_sig_ref_lookup_api()) bottle.get("/api/v1/lookup/sigref")(lambda: self.serve_sig_ref_lookup_api())
bottle.post("/api/v1/spot")(lambda: self.accept_spot()) bottle.post("/api/v1/spot")(lambda: self.accept_spot())
# Routes for templated pages # Routes for templated pages
@@ -101,6 +102,49 @@ class WebServer:
response.status = 500 response.status = 500
return json.dumps("Error - " + str(e), default=serialize_everything) return json.dumps("Error - " + str(e), default=serialize_everything)
# Look up data for a callsign
def serve_call_lookup_api(self):
try:
# Reject if no callsign
query = bottle.request.query
if not "call" in query.keys():
response.content_type = 'application/json'
response.status = 422
return json.dumps("Error - call must be provided", default=serialize_everything)
call = query.get("call").upper()
# Reject badly formatted callsigns
if not re.match(r"^[A-Za-z0-9/\-]*$", call):
response.content_type = 'application/json'
response.status = 422
return json.dumps("Error - '" + call + "' does not look like a valid callsign.",
default=serialize_everything)
# Take the callsign, make a "fake spot" so we can run infer_missing() on it, then repack the resulting data
# in the correct way for the API response.
fake_spot = Spot(dx_call=call)
fake_spot.infer_missing()
return self.serve_api({
"call": call,
"name": fake_spot.dx_name,
"country": fake_spot.dx_country,
"flag": fake_spot.dx_flag,
"continent": fake_spot.dx_continent,
"dxcc_id": fake_spot.dx_dxcc_id,
"cq_zone": fake_spot.dx_cq_zone,
"itu_zone": fake_spot.dx_itu_zone,
"grid": fake_spot.dx_grid,
"latitude": fake_spot.dx_latitude,
"longitude": fake_spot.dx_longitude,
"location_source": fake_spot.dx_location_source
})
except Exception as e:
logging.error(e)
response.content_type = 'application/json'
response.status = 500
return json.dumps("Error - " + str(e), default=serialize_everything)
# Look up data for a SIG reference # Look up data for a SIG reference
def serve_sig_ref_lookup_api(self): def serve_sig_ref_lookup_api(self):
try: try:

View File

@@ -488,11 +488,102 @@ paths:
example: true example: true
/lookup/call:
get:
tags:
- Utilities
summary: Look up callsign details
description: Perform a lookup of data about a certain callsign, using any of the lookup services available to the Spothole server.
operationId: call
parameters:
- name: call
in: query
description: A callsign
required: true
type: string
example: M0TRT
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
call:
type: string
description: Callsign, as provided to the API
example: M0TRT
name:
type: string
description: Name of the operator
example: Ian
country:
type: string
description: Country of the operator
example: United Kingdom
flag:
type: string
description: Country flag of the operator
example: ""
continent:
type: string
description: Continent of the operator
enum:
- EU
- NA
- SA
- AS
- AF
- OC
- AN
example: EU
dxcc_id:
type: integer
description: DXCC ID of the operator
example: 235
cq_zone:
type: integer
description: CQ zone of the operator
example: 27
itu_zone:
type: integer
description: ITU zone of the operator
example: 14
grid:
type: string
description: Maidenhead grid locator for the operator's QTH. This could be from an online lookup service, or just based on the DXCC.
example: IO91aa
latitude:
type: number
description: Latitude of the operator's QTH, in degrees. This could be from an online lookup service, or just based on the DXCC.
example: 51.2345
longitude:
type: number
description: Longitude of the opertor's QTH, in degrees. This could be from an online lookup service, or just based on the DXCC.
example: -1.2345
location_source:
type: string
description: Where we got the location (grid/latitude/longitude) from. Unlike a spot where we might have a summit position or WAB square, here the only options are an online QTH lookup, or a location based purely on DXCC, or nothing.
enum:
- "HOME QTH"
- DXCC
- NONE
example: "HOME QTH"
'422':
description: Validation error e.g. callsign missing or format incorrect
content:
application/json:
schema:
type: string
example: "Failed"
/lookup/sigref: /lookup/sigref:
get: get:
tags: tags:
- Utilities - Utilities
summary: Look up SIG Ref details summary: Look up SIG ref details
description: Perform a lookup of data about a certain reference, providing the SIG and the ID of the reference. A SIGRef structure will be returned containing the SIG and ID, plus any other information Spothole could find about it. description: Perform a lookup of data about a certain reference, providing the SIG and the ID of the reference. A SIGRef structure will be returned containing the SIG and ID, plus any other information Spothole could find about it.
operationId: sigref operationId: sigref
parameters: parameters:
@@ -709,13 +800,13 @@ components:
- SPOT - SPOT
- "SIG REF LOOKUP" - "SIG REF LOOKUP"
- "WAB/WAI GRID" - "WAB/WAI GRID"
- QRZ - "HOME QTH"
- DXCC - DXCC
- NONE - NONE
example: SPOT example: SPOT
dx_location_good: dx_location_good:
type: boolean type: boolean
description: Does the software think the location is good enough to put a marker on a map? This is true if the source is "SPOT", "SIG REF LOOKUP" or "WAB/WAI GRID", or alternatively if the source is "QRZ" and the callsign doesn't have a slash in it (i.e. operator likely at home). description: Does the software think the location is good enough to put a marker on a map? This is true if the source is "SPOT", "SIG REF LOOKUP" or "WAB/WAI GRID", or alternatively if the source is "HOME QTH" and the callsign doesn't have a slash in it (i.e. operator likely at home).
example: true example: true
de_call: de_call:
type: string type: string

View File

@@ -156,7 +156,7 @@ function updateTable() {
var bearing = calcBearing(userPos[0], userPos[1], s["dx_latitude"], s["dx_longitude"]); var bearing = calcBearing(userPos[0], userPos[1], s["dx_latitude"], s["dx_longitude"]);
bearingText = bearing.toFixed(0).padStart(3, '0') + "°"; bearingText = bearing.toFixed(0).padStart(3, '0') + "°";
if (s["dx_location_good"] == null || s["dx_location_good"] == false) { if (s["dx_location_good"] == null || s["dx_location_good"] == false) {
if (s["dx_location_source"] == "QRZ") { if (s["dx_location_source"] == "HOME QTH") {
bearingText = bearingText + "<span class='bearing-q hideonmobile'><i class='fa-solid fa-circle-question' title='The position was not reported via the spotting service. We had to fall back to a QRZ \"home\" location for a portable/mobile/alternative spot, so this bearing may not be accurate if the DX is close to you..'></i></span>"; bearingText = bearingText + "<span class='bearing-q hideonmobile'><i class='fa-solid fa-circle-question' title='The position was not reported via the spotting service. We had to fall back to a QRZ \"home\" location for a portable/mobile/alternative spot, so this bearing may not be accurate if the DX is close to you..'></i></span>";
} else { } else {
bearingText = bearingText + "<span class='bearing-q hideonmobile'><i class='fa-solid fa-circle-question' title='The position was not reported via the spotting service. We had to fall back to just using the centre of a DXCC entity, so this bearing may not be accurate if the DX is close to you.'></i></span>"; bearingText = bearingText + "<span class='bearing-q hideonmobile'><i class='fa-solid fa-circle-question' title='The position was not reported via the spotting service. We had to fall back to just using the centre of a DXCC entity, so this bearing may not be accurate if the DX is close to you.'></i></span>";