Compare commits

11 Commits

Author SHA1 Message Date
ian
3ea782579b Support Packet (PKT) as a digi mode 2025-10-25 18:13:57 +00:00
Ian Renton
8b036ddb46 Fix copy/paste error in WOTA support 2025-10-25 10:44:54 +01:00
Ian Renton
3f827c597b Extract spotter information from comments of RBNHole and SOTAMAT posts 2025-10-25 10:34:52 +01:00
Ian Renton
587d3b4cf1 Typo 2025-10-25 10:34:19 +01:00
Ian Renton
6eb1bd5ef1 Stop cleaning up comments, Spothole should be agnostic to that kind of thing. 2025-10-25 10:16:15 +01:00
Ian Renton
0ead59a985 Add missing providers to config-example.yml 2025-10-25 10:04:24 +01:00
Ian Renton
82b3c262b6 Apple Touch icon #66 2025-10-25 09:51:04 +01:00
Ian Renton
80b5077496 Apple Touch icon #66 2025-10-25 09:49:32 +01:00
Ian Renton
3625998f46 Finish support for WOTA #63 2025-10-25 09:37:41 +01:00
Ian Renton
e31c750b41 Mouseover callsign to reveal operator name 2025-10-25 09:36:30 +01:00
Ian Renton
ab05824c5d Fix a bug that caused repeated lookup attempts for callsigns that were unknown to QRZ/ClubLog and especially those with prefixes/suffixes. 2025-10-25 09:33:44 +01:00
10 changed files with 112 additions and 49 deletions

View File

@@ -1,4 +1,3 @@
import re
from datetime import datetime from datetime import datetime
import pytz import pytz
@@ -23,6 +22,11 @@ class WOTA(HTTPAlertProvider):
rss = RSSParser.parse(http_response.content.decode()) rss = RSSParser.parse(http_response.content.decode())
# Iterate through source data # Iterate through source data
for source_alert in rss.channel.items: for source_alert in rss.channel.items:
# Reject GUID missing or zero
if not source_alert.guid or not source_alert.guid.content or source_alert.guid.content == "http://www.wota.org.uk/alerts/0":
continue
# Pick apart the title # Pick apart the title
title_split = source_alert.title.split(" on ") title_split = source_alert.title.split(" on ")
dx_call = title_split[0] dx_call = title_split[0]

View File

@@ -41,6 +41,14 @@ spot-providers:
class: "ParksNPeaks" class: "ParksNPeaks"
name: "ParksNPeaks" name: "ParksNPeaks"
enabled: true enabled: true
-
class: "ZLOTA"
name: "ZLOTA"
enabled: true
-
class: "WOTA"
name: "WOTA"
enabled: true
- -
class: "APRSIS" class: "APRSIS"
name: "APRS-IS" name: "APRS-IS"
@@ -88,6 +96,10 @@ alert-providers:
class: "ParksNPeaks" class: "ParksNPeaks"
name: "ParksNPeaks" name: "ParksNPeaks"
enabled: true enabled: true
-
class: "WOTA"
name: "WOTA"
enabled: true
- -
class: "NG3K" class: "NG3K"
name: "NG3K" name: "NG3K"

View File

@@ -33,7 +33,7 @@ SIGS = [
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA". # Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
CW_MODES = ["CW"] CW_MODES = ["CW"]
PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"] PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"]
DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "BPSK", "PSK", "PSK31", "BPSK31", "OLIVIA", "MFSK", "MFSK32"] DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "BPSK", "PSK", "PSK31", "BPSK31", "OLIVIA", "MFSK", "MFSK32", "PKT"]
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
MODE_TYPES = ["CW", "PHONE", "DATA"] MODE_TYPES = ["CW", "PHONE", "DATA"]

View File

@@ -3,7 +3,7 @@ import logging
from datetime import timedelta from datetime import timedelta
from diskcache import Cache from diskcache import Cache
from pyhamtools import LookupLib, Callinfo from pyhamtools import LookupLib, Callinfo, callinfo
from pyhamtools.exceptions import APIKeyMissingError from pyhamtools.exceptions import APIKeyMissingError
from pyhamtools.frequency import freq_to_band from pyhamtools.frequency import freq_to_band
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
@@ -266,36 +266,46 @@ class LookupHelper:
# Utility method to get QRZ.com data from cache if possible, if not get it from the API and cache it # Utility method to get QRZ.com data from cache if possible, if not get it from the API and cache it
def get_qrz_data_for_callsign(self, call): def get_qrz_data_for_callsign(self, call):
# Fetch from cache if we can, otherwise fetch from the API and cache it # Fetch from cache if we can, otherwise fetch from the API and cache it
qrz_data = self.QRZ_CALLSIGN_DATA_CACHE.get(call) if call in self.QRZ_CALLSIGN_DATA_CACHE:
if qrz_data: return self.QRZ_CALLSIGN_DATA_CACHE.get(call)
return qrz_data
elif self.QRZ_AVAILABLE: elif self.QRZ_AVAILABLE:
try: try:
data = self.LOOKUP_LIB_QRZ.lookup_callsign(callsign=call) data = self.LOOKUP_LIB_QRZ.lookup_callsign(callsign=call)
self.QRZ_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds self.QRZ_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
return data return data
except KeyError: except KeyError:
# QRZ had no info for the call, that's OK. Cache a None so we don't try to look this up again # QRZ had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call.
self.QRZ_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds try:
return None data = self.LOOKUP_LIB_QRZ.lookup_callsign(callsign=callinfo.Callinfo.get_homecall(call))
self.QRZ_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
return data
except KeyError:
# QRZ had no info for the call, that's OK. Cache a None so we don't try to look this up again
self.QRZ_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
return None
else: else:
return None return None
# Utility method to get Clublog API data from cache if possible, if not get it from the API and cache it # Utility method to get Clublog API data from cache if possible, if not get it from the API and cache it
def get_clublog_api_data_for_callsign(self, call): def get_clublog_api_data_for_callsign(self, call):
# Fetch from cache if we can, otherwise fetch from the API and cache it # Fetch from cache if we can, otherwise fetch from the API and cache it
clublog_data = self.CLUBLOG_CALLSIGN_DATA_CACHE.get(call) if call in self.CLUBLOG_CALLSIGN_DATA_CACHE:
if clublog_data: return self.CLUBLOG_CALLSIGN_DATA_CACHE.get(call)
return clublog_data
elif self.CLUBLOG_API_AVAILABLE: elif self.CLUBLOG_API_AVAILABLE:
try: try:
data = self.LOOKUP_LIB_CLUBLOG_API.lookup_callsign(callsign=call) data = self.LOOKUP_LIB_CLUBLOG_API.lookup_callsign(callsign=call)
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
return data return data
except KeyError: except KeyError:
# Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again # Clublog had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call.
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds try:
return None data = self.LOOKUP_LIB_CLUBLOG_API.lookup_callsign(callsign=callinfo.Callinfo.get_homecall(call))
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
return data
except KeyError:
# Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
return None
except APIKeyMissingError: except APIKeyMissingError:
# User API key was wrong, warn # User API key was wrong, warn
logging.error("Could not look up via Clublog API, key " + self.CLUBLOG_API_KEY + " was rejected.") logging.error("Could not look up via Clublog API, key " + self.CLUBLOG_API_KEY + " was rejected.")

View File

@@ -111,11 +111,6 @@ class Alert:
if self.dx_calls and not self.dx_names: if self.dx_calls and not self.dx_names:
self.dx_names = list(map(lambda c: lookup_helper.infer_name_from_callsign(c), self.dx_calls)) self.dx_names = list(map(lambda c: lookup_helper.infer_name_from_callsign(c), self.dx_calls))
# Clean up comments
if self.comment:
comment = re.sub(r"\(de [A-Za-z0-9]*\)", "", self.comment)
self.comment = comment.strip()
# Always create an ID based on a hash of every parameter *except* received_time. This is used as the index # 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 # 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() # apart from that they were retrieved from the API at different times. Note that the simple Python hash()

View File

@@ -177,6 +177,20 @@ class Spot:
if self.de_call and "-" in self.de_call: if self.de_call and "-" in self.de_call:
self.de_call = self.de_call.split("-")[0] self.de_call = self.de_call.split("-")[0]
# If we have a spotter of "RBNHOLE", we should have the actual spotter callsign in the comment, so extract it.
# RBNHole posts come from a number of providers, so it's dealt with here in the generic spot handling code.
if self.de_call == "RBNHOLE" and self.comment:
rbnhole_call_match = re.search(r"\Wat ([a-z0-9/]+)\W", self.comment, re.IGNORECASE)
if rbnhole_call_match:
self.de_call = rbnhole_call_match.group(1).upper()
# If we have a spotter of "SOTAMAT", we might have the actual spotter callsign in the comment, if so extract it.
# SOTAMAT can do POTA as well as SOTA, so it's dealt with here in the generic spot handling code.
if self.de_call == "SOTAMAT" and self.comment:
sotamat_call_match = re.search(r"\Wfrom ([a-z0-9/]+)]", self.comment, re.IGNORECASE)
if sotamat_call_match:
self.de_call = sotamat_call_match.group(1).upper()
# Spotter country, continent, zones etc. from callsign. # Spotter country, continent, zones etc. from callsign.
# DE of "RBNHOLE" and "SOTAMAT" are not things we can look up location for # 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 != "RBNHOLE" and self.de_call != "SOTAMAT":
@@ -249,14 +263,6 @@ class Spot:
if self.comment and not self.qrt: if self.comment and not self.qrt:
self.qrt = "QRT" in self.comment.upper() self.qrt = "QRT" in self.comment.upper()
# Clean up comments
if self.comment:
comment = re.sub(r"\(de [A-Za-z0-9]*\)", "", self.comment)
comment = re.sub(r"\[.*]:", "", comment)
comment = re.sub(r"\[.*]", "", comment)
comment = re.sub(r"\"\"", "", comment)
self.comment = comment.strip()
# DX operator details lookup, using QRZ.com. This should be the last resort compared to taking the data from # 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 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. # the one from the park reference they're at.
@@ -284,8 +290,8 @@ class Spot:
self.dx_location_good = self.dx_location_source == "SPOT" or self.dx_location_source == "WAB/WAI GRID" or ( self.dx_location_good = self.dx_location_source == "SPOT" or self.dx_location_source == "WAB/WAI GRID" or (
self.dx_location_source == "QRZ" and not "/" in self.dx_call) self.dx_location_source == "QRZ" and not "/" in self.dx_call)
# DE of "RBNHOLE", "SOTAMAT" and "ZLOTA" are not things we can look up location for # DE of "RBNHOLE" and "SOTAMAT" are not things we can look up location for
if self.de_call != "RBNHOLE" and self.de_call != "SOTAMAT" and self.de_call != "ZLOTA": if self.de_call != "RBNHOLE" and self.de_call != "SOTAMAT":
# DE operator position lookup, using QRZ.com. # DE operator position lookup, using QRZ.com.
if self.de_call and not self.de_latitude: if self.de_call and 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)

View File

@@ -19,9 +19,9 @@ class DXCluster(SpotProvider):
# Note the callsign pattern deliberately excludes calls ending in "-#", which are from RBN and can be enabled by # Note the callsign pattern deliberately excludes calls ending in "-#", which are from RBN and can be enabled by
# default on some clusters. If you want RBN spots, there is a separate provider for that. # default on some clusters. If you want RBN spots, there is a separate provider for that.
CALLSIGN_PATTERN = "([a-z|0-9|/]+)" CALLSIGN_PATTERN = "([a-z|0-9|/]+)"
FREQUENCY_PATTERM = "([0-9|.]+)" FREQUENCY_PATTERN = "([0-9|.]+)"
LINE_PATTERN = re.compile( LINE_PATTERN = re.compile(
"^DX de " + CALLSIGN_PATTERN + ":\\s+" + FREQUENCY_PATTERM + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)", "^DX de " + CALLSIGN_PATTERN + ":\\s+" + FREQUENCY_PATTERN + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)",
re.IGNORECASE) re.IGNORECASE)
# Constructor requires hostname and port # Constructor requires hostname and port

View File

@@ -1,10 +1,11 @@
import re from datetime import timedelta, datetime
from datetime import timedelta
import pytz
from requests_cache import CachedSession from requests_cache import CachedSession
from rss_parser import RSSParser from rss_parser import RSSParser
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.sig_utils import get_icon_for_sig
from data.spot import Spot from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider from spotproviders.http_spot_provider import HTTPSpotProvider
@@ -16,6 +17,7 @@ class WOTA(HTTPSpotProvider):
LIST_URL = "https://www.wota.org.uk/mapping/data/summits.json" LIST_URL = "https://www.wota.org.uk/mapping/data/summits.json"
LIST_CACHE_TIME_DAYS = 30 LIST_CACHE_TIME_DAYS = 30
LIST_CACHE = CachedSession("cache/wota_data_cache", expire_after=timedelta(days=LIST_CACHE_TIME_DAYS)) LIST_CACHE = CachedSession("cache/wota_data_cache", expire_after=timedelta(days=LIST_CACHE_TIME_DAYS))
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
@@ -24,22 +26,53 @@ class WOTA(HTTPSpotProvider):
new_spots = [] new_spots = []
rss = RSSParser.parse(http_response.content.decode()) rss = RSSParser.parse(http_response.content.decode())
# Iterate through source data # Iterate through source data
for source_alert in rss.channel.items: for source_spot in rss.channel.items:
break
# TODO: Need to see a live spot to know what this feed looks like # Reject GUID missing or zero
if not source_spot.guid or not source_spot.guid.content or source_spot.guid.content == "http://www.wota.org.uk/spots/0":
continue
# Pick apart the title
title_split = source_spot.title.split(" on ")
dx_call = title_split[0]
ref = None
ref_name = None
if len(title_split) > 1:
ref_split = title_split[1].split(" - ")
ref = ref_split[0]
if len(ref_split) > 1:
ref_name = ref_split[1]
# Pick apart the description
desc_split = source_spot.description.split(". ")
freq_mode = desc_split[0].replace("Frequencies/modes:", "").strip()
freq_mode_split = freq_mode.split("-")
freq_hz = float(freq_mode_split[0]) * 1000000
mode = freq_mode_split[1]
comment = None
if len(desc_split) > 1:
comment = desc_split[1].strip()
spotter = None
if len(desc_split) > 2:
spotter = desc_split[2].replace("Spotted by ", "").replace(".", "").strip()
time = datetime.strptime(source_spot.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
# Convert to our spot format # Convert to our spot format
# spot = Spot(source=self.name, spot = Spot(source=self.name,
# source_id=source_spot["id"], source_id=source_spot.guid.content,
# dx_call=source_spot["activator"].upper(), dx_call=dx_call,
# de_call=source_spot["spotter"].upper(), de_call=spotter,
# freq=freq_hz, freq=freq_hz,
# mode=source_spot["mode"].upper().strip(), mode=mode,
# comment=source_spot["comments"], comment=comment,
# sig="WOTA", sig="WOTA",
# sig_refs=[source_spot["reference"]], sig_refs=[ref] if ref else [],
# icon=get_icon_for_sig("WOTA"), sig_refs_names=[ref_name] if ref_name else [],
# time=datetime.fromisoformat(source_spot["referenced_time"]).astimezone(pytz.UTC).timestamp()) sig_refs_urls="https://www.wota.org.uk/MM_" + ref if ref else [],
icon=get_icon_for_sig("WOTA"),
time=time.timestamp())
# WOTA name/lat/lon lookup # WOTA name/lat/lon lookup
wota_data = self.LIST_CACHE.get(self.LIST_URL, headers=HTTP_HEADERS).json() wota_data = self.LIST_CACHE.get(self.LIST_URL, headers=HTTP_HEADERS).json()
@@ -50,4 +83,6 @@ class WOTA(HTTPSpotProvider):
spot.dx_longitude = feature["geometry"]["coordinates"][0] spot.dx_longitude = feature["geometry"]["coordinates"][0]
spot.dx_grid = feature["properties"]["qthLocator"] spot.dx_grid = feature["properties"]["qthLocator"]
break break
new_spots.append(spot)
return new_spots return new_spots

View File

@@ -31,6 +31,7 @@
<link href="/fa/css/solid.min.css" rel="stylesheet" /> <link href="/fa/css/solid.min.css" rel="stylesheet" />
<link rel="icon" type="image/png" href="/img/icon-512.png"> <link rel="icon" type="image/png" href="/img/icon-512.png">
<link rel="apple-touch-icon" href="img/icon-512-pwa.png">
<link rel="alternate icon" type="image/png" href="/img/icon-192.png"> <link rel="alternate icon" type="image/png" href="/img/icon-192.png">
<link rel="alternate icon" type="image/png" href="/img/icon-32.png"> <link rel="alternate icon" type="image/png" href="/img/icon-32.png">
<link rel="alternate icon" type="image/png" href="/img/icon-16.png"> <link rel="alternate icon" type="image/png" href="/img/icon-16.png">

View File

@@ -185,7 +185,7 @@ function updateTable() {
$tr.append(`<td class='nowrap'>${time_formatted}</td>`); $tr.append(`<td class='nowrap'>${time_formatted}</td>`);
} }
if (showDX) { if (showDX) {
$tr.append(`<td class='nowrap'><span class='flag-wrapper hideonmobile' title='${dx_country}'>${dx_flag}</span><a class='dx-link' href='https://qrz.com/db/${s["dx_call"]}' target='_new'>${s["dx_call"]}</a></td>`); $tr.append(`<td class='nowrap'><span class='flag-wrapper hideonmobile' title='${dx_country}'>${dx_flag}</span><a class='dx-link' href='https://qrz.com/db/${s["dx_call"]}' target='_new' title='${s["dx_name"] != null ? s["dx_name"] : ""}'>${s["dx_call"]}</a></td>`);
} }
if (showFreq) { if (showFreq) {
$tr.append(`<td class='nowrap'><span class='band-bullet' title='${bandFullName}' style='color: ${s["band_color"]}'>&#9632;</span>${freq_string}</td>`); $tr.append(`<td class='nowrap'><span class='band-bullet' title='${bandFullName}' style='color: ${s["band_color"]}'>&#9632;</span>${freq_string}</td>`);