Support BOTA alerts. Closes #58

This commit is contained in:
Ian Renton
2025-10-31 14:06:22 +00:00
parent 193838b9d3
commit 65d546ef7e
15 changed files with 107 additions and 29 deletions

View File

@@ -10,7 +10,7 @@ The API is deliberately well-defined with an OpenAPI specification and auto-gene
Spothole itself is also open source, Public Domain licenced code that anyone can take and modify. Spothole itself is also open source, Public Domain licenced code that anyone can take and modify.
Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, the UK Packet Repeater Network, and NG3K. Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, the UK Packet Repeater Network, and NG3K.
![Screenshot](/images/screenshot2.png) ![Screenshot](/images/screenshot2.png)

48
alertproviders/bota.py Normal file
View File

@@ -0,0 +1,48 @@
from datetime import datetime, timedelta
import pytz
from bs4 import BeautifulSoup
from alertproviders.http_alert_provider import HTTPAlertProvider
from core.sig_utils import get_icon_for_sig
from data.alert import Alert
from data.sig_ref import SIGRef
# Alert provider for Beaches on the Air
class BOTA(HTTPAlertProvider):
POLL_INTERVAL_SEC = 3600
ALERTS_URL = "https://www.beachesontheair.com/"
def __init__(self, provider_config):
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def http_response_to_alerts(self, http_response):
new_alerts = []
# Find the table of upcoming alerts
bs = BeautifulSoup(http_response.content.decode(), features="lxml")
tbody = bs.body.find('div', attrs={'class': 'view-activations-public'}).find('table', attrs={'class': 'views-table'}).find('tbody')
for row in tbody.find_all('tr'):
cells = row.find_all('td')
first_cell_text = str(cells[0].find('a').contents[0]).strip()
ref_name = first_cell_text.split(" by ")[0]
dx_call = str(cells[1].find('a').contents[0]).strip().upper()
# Get the date, dealing with the fact we get no year so have to figure out if it's last year or next year
date_text = str(cells[2].find('span').contents[0]).strip()
date_time = datetime.strptime(date_text,"%d %b - %H:%M UTC").replace(tzinfo=pytz.UTC)
date_time = date_time.replace(year=datetime.now(pytz.UTC).year)
# If this was more than a day ago, activation is actually next year
if date_time < datetime.now(pytz.UTC) - timedelta(days=1):
date_time = date_time.replace(year=datetime.now(pytz.UTC).year + 1)
# Convert to our alert format
alert = Alert(source=self.name,
dx_calls=[dx_call],
sig="BOTA",
sig_refs=[SIGRef(id=ref_name, name=ref_name, url="https://www.beachesontheair.com/beaches/" + ref_name.lower().replace(" ", "-"))],
icon=get_icon_for_sig("BOTA"),
start_time=date_time.timestamp(),
is_dxpedition=False)
new_alerts.append(alert)
return new_alerts

View File

@@ -104,6 +104,10 @@ alert-providers:
class: "WOTA" class: "WOTA"
name: "WOTA" name: "WOTA"
enabled: true enabled: true
-
class: "BOTA"
name: "BOTA"
enabled: true
- -
class: "NG3K" class: "NG3K"
name: "NG3K" name: "NG3K"

View File

@@ -27,7 +27,8 @@ SIGS = [
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award", icon="earth-oceania", ref_regex=r""), SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award", icon="earth-oceania", ref_regex=r""),
SIG(name="WAB", description="Worked All Britain", icon="table-cells-large", ref_regex=r"[A-Z]{1,2}[0-9]{2}"), SIG(name="WAB", description="Worked All Britain", icon="table-cells-large", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
SIG(name="WAI", description="Worked All Ireland", icon="table-cells-large", ref_regex=r"[A-Z][0-9]{2}"), SIG(name="WAI", description="Worked All Ireland", icon="table-cells-large", ref_regex=r"[A-Z][0-9]{2}"),
SIG(name="WOTA", description="Wainwrights on the Air", icon="w", ref_regex=r"[A-Z]{3}-[0-9]{2}") SIG(name="WOTA", description="Wainwrights on the Air", icon="w", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
SIG(name="BOTA", description="Beaches on the Air", icon="water", ref_regex=None)
] ]
# 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".

View File

@@ -11,4 +11,4 @@ class SIG:
# and Field Spotter. Does not include the "fa-" prefix. # and Field Spotter. Does not include the "fa-" prefix.
icon: str icon: str
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+". # Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
ref_regex: str ref_regex: str = None

View File

@@ -12,3 +12,4 @@ requests-sse~=0.5.2
rss-parser~=2.1.1 rss-parser~=2.1.1
pyproj~=3.7.2 pyproj~=3.7.2
prometheus_client~=0.23.1 prometheus_client~=0.23.1
beautifulsoup4~=4.14.2

View File

@@ -1,5 +1,5 @@
import logging import logging
from datetime import datetime, timezone from datetime import datetime
from threading import Thread from threading import Thread
import aprslib import aprslib
@@ -58,5 +58,5 @@ class APRSIS(SpotProvider):
self.submit(spot) self.submit(spot)
self.status = "OK" self.status = "OK"
self.last_update_time = datetime.now(timezone.utc) self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Data received from APRS-IS.") logging.debug("Data received from APRS-IS.")

View File

@@ -1,17 +1,16 @@
import logging import logging
import re import re
from datetime import datetime, timezone from datetime import datetime
from threading import Thread from threading import Thread
from time import sleep from time import sleep
import pytz import pytz
import telnetlib3 import telnetlib3
from core.constants import SIGS from core.config import SERVER_OWNER_CALLSIGN
from core.sig_utils import ANY_SIG_REGEX, ANY_XOTA_SIG_REF_REGEX, get_icon_for_sig, get_ref_regex_for_sig from core.sig_utils import ANY_SIG_REGEX, get_icon_for_sig, get_ref_regex_for_sig
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from core.config import SERVER_OWNER_CALLSIGN
from spotproviders.spot_provider import SpotProvider from spotproviders.spot_provider import SpotProvider
@@ -97,7 +96,7 @@ class DXCluster(SpotProvider):
self.submit(spot) self.submit(spot)
self.status = "OK" self.status = "OK"
self.last_update_time = datetime.now(timezone.utc) self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Data received from DX Cluster " + self.hostname + ".") logging.debug("Data received from DX Cluster " + self.hostname + ".")
except Exception as e: except Exception as e:

View File

@@ -1,14 +1,14 @@
import logging import logging
import re import re
from datetime import datetime, timezone from datetime import datetime
from threading import Thread from threading import Thread
from time import sleep from time import sleep
import pytz import pytz
import telnetlib3 import telnetlib3
from data.spot import Spot
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from data.spot import Spot
from spotproviders.spot_provider import SpotProvider from spotproviders.spot_provider import SpotProvider
@@ -77,7 +77,7 @@ class RBN(SpotProvider):
self.submit(spot) self.submit(spot)
self.status = "OK" self.status = "OK"
self.last_update_time = datetime.now(timezone.utc) self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Data received from RBN on port " + str(self.port) + ".") logging.debug("Data received from RBN on port " + str(self.port) + ".")
except Exception as e: except Exception as e:

View File

@@ -74,14 +74,15 @@ class WOTA(HTTPSpotProvider):
time=time.timestamp()) time=time.timestamp())
# WOTA name/grid/lat/lon lookup # WOTA name/grid/lat/lon lookup
wota_data = self.LIST_CACHE.get(self.LIST_URL, headers=HTTP_HEADERS).json() if ref:
for feature in wota_data["features"]: wota_data = self.LIST_CACHE.get(self.LIST_URL, headers=HTTP_HEADERS).json()
if feature["properties"]["wotaId"] == spot.sig_refs[0]: for feature in wota_data["features"]:
spot.sig_refs[0].name = feature["properties"]["title"] if feature["properties"]["wotaId"] == ref:
spot.dx_latitude = feature["geometry"]["coordinates"][1] spot.sig_refs[0].name = feature["properties"]["title"]
spot.dx_longitude = feature["geometry"]["coordinates"][0] spot.dx_latitude = feature["geometry"]["coordinates"][1]
spot.dx_grid = feature["properties"]["qthLocator"] spot.dx_longitude = feature["geometry"]["coordinates"][0]
break spot.dx_grid = feature["properties"]["qthLocator"]
break
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots

View File

@@ -17,8 +17,9 @@
<p>In amateur radio terminology, the "DX" contact is the "interesting" one that is using the frequency shown. They might be on a remote island or just in a local park, but either way it's interesting enough that someone has "spotted" them. The callsign listed under "DE" is the person who spotted the "DX" operator. "Modes" are the type of communication they are using. You might see "CW" which is Morse Code, or voice "modes" like SSB or FM, or more exotic "data" modes which are used for computer-to-computer communication.</p> <p>In amateur radio terminology, the "DX" contact is the "interesting" one that is using the frequency shown. They might be on a remote island or just in a local park, but either way it's interesting enough that someone has "spotted" them. The callsign listed under "DE" is the person who spotted the "DX" operator. "Modes" are the type of communication they are using. You might see "CW" which is Morse Code, or voice "modes" like SSB or FM, or more exotic "data" modes which are used for computer-to-computer communication.</p>
<h4 class="mt-4">What data sources are supported?</h4> <h4 class="mt-4">What data sources are supported?</h4>
<p>Spothole can retrieve spots from: Telnet-based DX clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, and the UK Packet Repeater Network.</p> <p>Spothole can retrieve spots from: Telnet-based DX clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, and the UK Packet Repeater Network.</p>
<p>Spothole can retrieve alerts from: NG3K, POTA, SOTA, WWFF, Parks 'n' Peaks, and WOTA.</p> <p>Spothole can retrieve alerts from: NG3K, POTA, SOTA, WWFF, Parks 'n' Peaks, WOTA and BOTA.</p>
<p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, IOTA, MOTS, ARLHS, ILLW, SIOTA, WCA, ZLOTA, KRMNPA, WOTA, WAB & WAI.</p> <p>Note that the server owner has not necessarily enabled all these data sources. In particular it is common to disable RBN, to avoid the server being swamped with FT8 traffic, and to disable APRS-IS and UK Packet Net so that the server only displays stations where there is likely to be an operator physically present for a QSO.</p>
<p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, IOTA, MOTS, ARLHS, ILLW, SIOTA, WCA, ZLOTA, KRMNPA, WOTA, BOTA, WAB & WAI.</p>
<h4 class="mt-4">How is this better than DXheat, DXsummit, POTA's own website, etc?</h4> <h4 class="mt-4">How is this better than DXheat, DXsummit, POTA's own website, etc?</h4>
<p>It's probably not? But it's nice to have choice.</p> <p>It's probably not? But it's nice to have choice.</p>
<p>I think it's got two key advantages over those sites:</p> <p>I think it's got two key advantages over those sites:</p>

View File

@@ -62,6 +62,7 @@ paths:
- ParksNPeaks - ParksNPeaks
- ZLOTA - ZLOTA
- WOTA - WOTA
- BOTA
- Cluster - Cluster
- RBN - RBN
- APRS-IS - APRS-IS
@@ -87,6 +88,7 @@ paths:
- ZLOTA - ZLOTA
- IOTA - IOTA
- WOTA - WOTA
- BOTA
- WAB - WAB
- WAI - WAI
- name: needs_sig - name: needs_sig
@@ -284,6 +286,7 @@ paths:
- ParksNPeaks - ParksNPeaks
- ZLOTA - ZLOTA
- WOTA - WOTA
- BOTA
- Cluster - Cluster
- RBN - RBN
- APRS-IS - APRS-IS
@@ -309,6 +312,7 @@ paths:
- ZLOTA - ZLOTA
- IOTA - IOTA
- WOTA - WOTA
- BOTA
- WAB - WAB
- WAI - WAI
- name: dx_continent - name: dx_continent
@@ -768,6 +772,7 @@ components:
- ZLOTA - ZLOTA
- IOTA - IOTA
- WOTA - WOTA
- BOTA
- WAB - WAB
- WAI - WAI
example: POTA example: POTA
@@ -921,6 +926,7 @@ components:
- ZLOTA - ZLOTA
- IOTA - IOTA
- WOTA - WOTA
- BOTA
- WAB - WAB
- WAI - WAI
example: POTA example: POTA
@@ -950,6 +956,7 @@ components:
- ParksNPeaks - ParksNPeaks
- ZLOTA - ZLOTA
- WOTA - WOTA
- BOTA
- Cluster - Cluster
- RBN - RBN
- APRS-IS - APRS-IS

View File

@@ -213,9 +213,17 @@ function addAlertRowsToTable(tbody, alerts) {
} }
// Format sig_refs // Format sig_refs
var sig_refs = "" var sig_refs = "";
if (a["sig_refs"]) { if (a["sig_refs"] != null) {
sig_refs = a["sig_refs"].map(a => `<span class='nowrap'>${a}</span>`).join(", "); var items = []
for (var i = 0; i < a["sig_refs"].length; i++) {
if (a["sig_refs"][i]["url"] != null) {
items[i] = `<a href='${a["sig_refs"][i]["url"]}' title='${a["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${a["sig_refs"][i]["id"]}</a>`
} else {
items[i] = `${a["sig_refs"][i]["id"]}`
}
}
sig_refs = items.join(", ");
} }
// Populate the row // Populate the row

View File

@@ -109,7 +109,11 @@ function getTooltipText(s) {
if (s["sig_refs"] != null) { if (s["sig_refs"] != null) {
var items = [] var items = []
for (var i = 0; i < s["sig_refs"].length; i++) { for (var i = 0; i < s["sig_refs"].length; i++) {
items[i] = `<a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${s["sig_refs"][i]["id"]}</a>` if (s["sig_refs"][i]["url"] != null) {
items[i] = `<a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${s["sig_refs"][i]["id"]}</a>`
} else {
items[i] = `${s["sig_refs"][i]["id"]}`
}
} }
sig_refs = items.join(", "); sig_refs = items.join(", ");
} }

View File

@@ -172,7 +172,11 @@ function updateTable() {
if (s["sig_refs"] != null) { if (s["sig_refs"] != null) {
var items = [] var items = []
for (var i = 0; i < s["sig_refs"].length; i++) { for (var i = 0; i < s["sig_refs"].length; i++) {
items[i] = `<a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${s["sig_refs"][i]["id"]}</a>` if (s["sig_refs"][i]["url"] != null) {
items[i] = `<a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${s["sig_refs"][i]["id"]}</a>`
} else {
items[i] = `${s["sig_refs"][i]["id"]}`
}
} }
sig_refs = items.join(", "); sig_refs = items.join(", ");
} }