diff --git a/README.md b/README.md index 391f1f2..f304a09 100644 --- a/README.md +++ b/README.md @@ -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. -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.  diff --git a/alertproviders/bota.py b/alertproviders/bota.py new file mode 100644 index 0000000..0365e31 --- /dev/null +++ b/alertproviders/bota.py @@ -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 diff --git a/config-example.yml b/config-example.yml index 296d831..99922b1 100644 --- a/config-example.yml +++ b/config-example.yml @@ -104,6 +104,10 @@ alert-providers: class: "WOTA" name: "WOTA" enabled: true + - + class: "BOTA" + name: "BOTA" + enabled: true - class: "NG3K" name: "NG3K" diff --git a/core/constants.py b/core/constants.py index 2c19a39..2a26bf6 100644 --- a/core/constants.py +++ b/core/constants.py @@ -27,7 +27,8 @@ SIGS = [ 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="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". diff --git a/data/sig.py b/data/sig.py index 5d6e2d2..a0de179 100644 --- a/data/sig.py +++ b/data/sig.py @@ -11,4 +11,4 @@ class SIG: # and Field Spotter. Does not include the "fa-" prefix. icon: str # Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+". - ref_regex: str \ No newline at end of file + ref_regex: str = None \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 1dff514..95b5bde 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,5 @@ psutil~=7.1.0 requests-sse~=0.5.2 rss-parser~=2.1.1 pyproj~=3.7.2 -prometheus_client~=0.23.1 \ No newline at end of file +prometheus_client~=0.23.1 +beautifulsoup4~=4.14.2 \ No newline at end of file diff --git a/spotproviders/aprsis.py b/spotproviders/aprsis.py index 30b089c..fa56bb9 100644 --- a/spotproviders/aprsis.py +++ b/spotproviders/aprsis.py @@ -1,5 +1,5 @@ import logging -from datetime import datetime, timezone +from datetime import datetime from threading import Thread import aprslib @@ -58,5 +58,5 @@ class APRSIS(SpotProvider): self.submit(spot) 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.") \ No newline at end of file diff --git a/spotproviders/dxcluster.py b/spotproviders/dxcluster.py index 1ddbd11..729d4ac 100644 --- a/spotproviders/dxcluster.py +++ b/spotproviders/dxcluster.py @@ -1,17 +1,16 @@ import logging import re -from datetime import datetime, timezone +from datetime import datetime from threading import Thread from time import sleep import pytz import telnetlib3 -from core.constants import SIGS -from core.sig_utils import ANY_SIG_REGEX, ANY_XOTA_SIG_REF_REGEX, get_icon_for_sig, get_ref_regex_for_sig +from core.config import SERVER_OWNER_CALLSIGN +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.spot import Spot -from core.config import SERVER_OWNER_CALLSIGN from spotproviders.spot_provider import SpotProvider @@ -97,7 +96,7 @@ class DXCluster(SpotProvider): self.submit(spot) 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 + ".") except Exception as e: diff --git a/spotproviders/rbn.py b/spotproviders/rbn.py index c42224a..1e09d17 100644 --- a/spotproviders/rbn.py +++ b/spotproviders/rbn.py @@ -1,14 +1,14 @@ import logging import re -from datetime import datetime, timezone +from datetime import datetime from threading import Thread from time import sleep import pytz import telnetlib3 -from data.spot import Spot from core.config import SERVER_OWNER_CALLSIGN +from data.spot import Spot from spotproviders.spot_provider import SpotProvider @@ -77,7 +77,7 @@ class RBN(SpotProvider): self.submit(spot) 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) + ".") except Exception as e: diff --git a/spotproviders/wota.py b/spotproviders/wota.py index 17af734..2837cef 100644 --- a/spotproviders/wota.py +++ b/spotproviders/wota.py @@ -74,14 +74,15 @@ class WOTA(HTTPSpotProvider): time=time.timestamp()) # WOTA name/grid/lat/lon lookup - wota_data = self.LIST_CACHE.get(self.LIST_URL, headers=HTTP_HEADERS).json() - for feature in wota_data["features"]: - if feature["properties"]["wotaId"] == spot.sig_refs[0]: - spot.sig_refs[0].name = feature["properties"]["title"] - spot.dx_latitude = feature["geometry"]["coordinates"][1] - spot.dx_longitude = feature["geometry"]["coordinates"][0] - spot.dx_grid = feature["properties"]["qthLocator"] - break + if ref: + wota_data = self.LIST_CACHE.get(self.LIST_URL, headers=HTTP_HEADERS).json() + for feature in wota_data["features"]: + if feature["properties"]["wotaId"] == ref: + spot.sig_refs[0].name = feature["properties"]["title"] + spot.dx_latitude = feature["geometry"]["coordinates"][1] + spot.dx_longitude = feature["geometry"]["coordinates"][0] + spot.dx_grid = feature["properties"]["qthLocator"] + break new_spots.append(spot) return new_spots diff --git a/views/webpage_about.tpl b/views/webpage_about.tpl index 4efff9f..85956c3 100644 --- a/views/webpage_about.tpl +++ b/views/webpage_about.tpl @@ -17,8 +17,9 @@
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.
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.
-Spothole can retrieve alerts from: NG3K, POTA, SOTA, WWFF, Parks 'n' Peaks, and WOTA.
-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.
+Spothole can retrieve alerts from: NG3K, POTA, SOTA, WWFF, Parks 'n' Peaks, WOTA and BOTA.
+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.
+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.
It's probably not? But it's nice to have choice.
I think it's got two key advantages over those sites:
diff --git a/webassets/apidocs/openapi.yml b/webassets/apidocs/openapi.yml index fa0745a..31498c9 100644 --- a/webassets/apidocs/openapi.yml +++ b/webassets/apidocs/openapi.yml @@ -62,6 +62,7 @@ paths: - ParksNPeaks - ZLOTA - WOTA + - BOTA - Cluster - RBN - APRS-IS @@ -87,6 +88,7 @@ paths: - ZLOTA - IOTA - WOTA + - BOTA - WAB - WAI - name: needs_sig @@ -284,6 +286,7 @@ paths: - ParksNPeaks - ZLOTA - WOTA + - BOTA - Cluster - RBN - APRS-IS @@ -309,6 +312,7 @@ paths: - ZLOTA - IOTA - WOTA + - BOTA - WAB - WAI - name: dx_continent @@ -768,6 +772,7 @@ components: - ZLOTA - IOTA - WOTA + - BOTA - WAB - WAI example: POTA @@ -921,6 +926,7 @@ components: - ZLOTA - IOTA - WOTA + - BOTA - WAB - WAI example: POTA @@ -950,6 +956,7 @@ components: - ParksNPeaks - ZLOTA - WOTA + - BOTA - Cluster - RBN - APRS-IS diff --git a/webassets/js/alerts.js b/webassets/js/alerts.js index 909861d..dd8777e 100644 --- a/webassets/js/alerts.js +++ b/webassets/js/alerts.js @@ -213,9 +213,17 @@ function addAlertRowsToTable(tbody, alerts) { } // Format sig_refs - var sig_refs = "" - if (a["sig_refs"]) { - sig_refs = a["sig_refs"].map(a => `${a}`).join(", "); + var sig_refs = ""; + if (a["sig_refs"] != null) { + var items = [] + for (var i = 0; i < a["sig_refs"].length; i++) { + if (a["sig_refs"][i]["url"] != null) { + items[i] = `${a["sig_refs"][i]["id"]}` + } else { + items[i] = `${a["sig_refs"][i]["id"]}` + } + } + sig_refs = items.join(", "); } // Populate the row diff --git a/webassets/js/map.js b/webassets/js/map.js index b861356..d04da63 100644 --- a/webassets/js/map.js +++ b/webassets/js/map.js @@ -109,7 +109,11 @@ function getTooltipText(s) { if (s["sig_refs"] != null) { var items = [] for (var i = 0; i < s["sig_refs"].length; i++) { - items[i] = `${s["sig_refs"][i]["id"]}` + if (s["sig_refs"][i]["url"] != null) { + items[i] = `${s["sig_refs"][i]["id"]}` + } else { + items[i] = `${s["sig_refs"][i]["id"]}` + } } sig_refs = items.join(", "); } diff --git a/webassets/js/spots.js b/webassets/js/spots.js index 689cac1..6cee5df 100644 --- a/webassets/js/spots.js +++ b/webassets/js/spots.js @@ -172,7 +172,11 @@ function updateTable() { if (s["sig_refs"] != null) { var items = [] for (var i = 0; i < s["sig_refs"].length; i++) { - items[i] = `${s["sig_refs"][i]["id"]}` + if (s["sig_refs"][i]["url"] != null) { + items[i] = `${s["sig_refs"][i]["id"]}` + } else { + items[i] = `${s["sig_refs"][i]["id"]}` + } } sig_refs = items.join(", "); }