From d80c4cfbeb3960f09ac2913642ca7593bf01a976 Mon Sep 17 00:00:00 2001 From: Ian Renton Date: Sun, 2 Nov 2025 16:52:27 +0000 Subject: [PATCH] Provide an externally usable callsign lookup feature. #73 --- data/spot.py | 60 +++++++++++----------- server/webserver.py | 44 ++++++++++++++++ webassets/apidocs/openapi.yml | 97 +++++++++++++++++++++++++++++++++-- webassets/js/spots.js | 2 +- 4 files changed, 170 insertions(+), 33 deletions(-) diff --git a/data/spot.py b/data/spot.py index 7679053..71a69f1 100644 --- a/data/spot.py +++ b/data/spot.py @@ -35,8 +35,6 @@ class Spot: 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 @@ -50,11 +48,12 @@ class Spot: # lookup dx_latitude: 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 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 - # doesn't have a suffix like /P. + # true if the location source is "SPOT" or "WAB/WAI GRID", or if the location source is "HOME QTH" and the DX + # callsign doesn't have a suffix like /P. dx_location_good: bool = False # DE (Spotter) info @@ -67,6 +66,8 @@ class Spot: de_flag: str = None # Continent of the spotter 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? 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 @@ -196,12 +197,12 @@ class Spot: # 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 - 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 not self.de_country: + 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 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: + if 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: + if 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] @@ -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 # 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() all_comment_refs = re.findall(get_ref_regex_for_sig(sig), self.comment) 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 # 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". - sig_matches = re.finditer(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", self.comment, re.IGNORECASE) - for sig_match in sig_matches: - # First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster - # spots where the comment is just "POTA". - found_sig = sig_match.group(2).upper() - if not self.sig: - self.sig = found_sig + if self.comment: + sig_matches = re.finditer(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", self.comment, re.IGNORECASE) + for sig_match in sig_matches: + # First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster + # spots where the comment is just "POTA". + found_sig = sig_match.group(2).upper() + 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. - # If so, add that to the sig_refs list for this spot. - ref_regex = get_ref_regex_for_sig(found_sig) - if ref_regex: - ref_matches = re.finditer(r"(^|\W)" + found_sig + r"($|\W)(" + ref_regex + r")($|\W)", self.comment, re.IGNORECASE) - for ref_match in ref_matches: - self.append_sig_ref_if_missing(SIGRef(id=ref_match.group(3).upper(), sig=found_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. + ref_regex = get_ref_regex_for_sig(found_sig) + if ref_regex: + ref_matches = re.finditer(r"(^|\W)" + found_sig + r"($|\W)(" + ref_regex + r")($|\W)", self.comment, re.IGNORECASE) + for ref_match in ref_matches: + 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 # 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_longitude = latlon[1] 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. if self.dx_call and not self.dx_latitude: @@ -333,12 +335,12 @@ class Spot: # is likely at home. 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 == "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 - 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. - 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) if latlon: self.de_latitude = latlon[0] @@ -346,7 +348,7 @@ class Spot: 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: + if not self.de_latitude: latlon = lookup_helper.infer_latlon_from_callsign_dxcc(self.de_call) if latlon: self.de_latitude = latlon[0] diff --git a/server/webserver.py b/server/webserver.py index eb7204c..9041a63 100644 --- a/server/webserver.py +++ b/server/webserver.py @@ -43,6 +43,7 @@ class WebServer: 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/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.post("/api/v1/spot")(lambda: self.accept_spot()) # Routes for templated pages @@ -101,6 +102,49 @@ class WebServer: response.status = 500 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 def serve_sig_ref_lookup_api(self): try: diff --git a/webassets/apidocs/openapi.yml b/webassets/apidocs/openapi.yml index a1b1d29..26d7065 100644 --- a/webassets/apidocs/openapi.yml +++ b/webassets/apidocs/openapi.yml @@ -488,11 +488,102 @@ paths: 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: get: tags: - 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. operationId: sigref parameters: @@ -709,13 +800,13 @@ components: - SPOT - "SIG REF LOOKUP" - "WAB/WAI GRID" - - QRZ + - "HOME QTH" - DXCC - NONE example: SPOT dx_location_good: 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 de_call: type: string diff --git a/webassets/js/spots.js b/webassets/js/spots.js index 921f277..7e9f95f 100644 --- a/webassets/js/spots.js +++ b/webassets/js/spots.js @@ -156,7 +156,7 @@ function updateTable() { var bearing = calcBearing(userPos[0], userPos[1], s["dx_latitude"], s["dx_longitude"]); bearingText = bearing.toFixed(0).padStart(3, '0') + "°"; 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 + ""; } else { bearingText = bearingText + "";