Compare commits

...
2 Commits
Author SHA1 Message Date
Ian Renton 3b575de36a Fix SSE timeout 2026-09-10 17:58:02 +01:00
Ian Renton 94dc9044c4 Add support for WA7BNM Contest Calendar. Closes #106 2026-09-10 16:45:40 +01:00
15 changed files with 164 additions and 99 deletions
+3
View File
@@ -165,6 +165,9 @@ alert_providers:
- class: "NG3K" - class: "NG3K"
enabled: true enabled: true
- class: "WA7BNM"
enabled: true
- class: "RSGBHFContests" - class: "RSGBHFContests"
enabled: true enabled: true
+47
View File
@@ -0,0 +1,47 @@
from datetime import datetime, time
from typing import cast
import pytz
from icalendar import Calendar, Event
from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider
class ICALAlertProvider(HTTPAlertProvider):
"""Generic alert provider for iCal calendars. Defines an abstract method event_to_alert(event) that subclasses must
implement, and use it to convert an iCal event to an Alert object based on whatever format their iCal events use."""
def __init__(self, name, provider_config, url, poll_interval):
super().__init__(name, provider_config, url, poll_interval)
def _http_response_to_alerts(self, http_response):
new_alerts = []
cal = Calendar.from_ical(http_response.content)
# Iterate through events, passing each one in turn to the subclass' event_to_alert method to turn it into
# a Spothole alert object
for component in cal.walk():
if component.name != "VEVENT":
continue
event = cast(Event, component)
alert = self.event_to_alert(event)
new_alerts.append(alert)
return new_alerts
def event_to_alert(self, event: Event) -> Alert:
"""Convert an ICal event to an Alert object. Subclasses must implement this method."""
@staticmethod
def _to_utc_timestamp(value):
"""Convert a date or datetime value from an iCal field into a UTC UNIX timestamp."""
# Datetime object so we can treat it as-is, check if it has a non-UTC tz and convert it if necessary
if isinstance(value, datetime):
if value.tzinfo is None:
value = pytz.UTC.localize(value)
return value.astimezone(pytz.UTC).timestamp()
# Date object so this is an all day event
return pytz.UTC.localize(datetime.combine(value, time.min)).timestamp()
+55 -81
View File
@@ -1,101 +1,75 @@
import re import re
from datetime import datetime, time
from typing import cast
import pytz from icalendar import Event
from icalendar import Calendar, Event
from core.enums import AlertType, Continent from core.enums import AlertType, Continent
from data.alert import Alert from data.alert import Alert
from providers.alert.http_alert_provider import HTTPAlertProvider from providers.alert.ical_alert_provider import ICALAlertProvider
class RSGBICALAlertProvider(HTTPAlertProvider): class RSGBICALAlertProvider(ICALAlertProvider):
"""Generic alert provider for RSGB contest ICAL calendars.""" """Generic alert provider for RSGB contest iCal calendars. Builds on the generic iCal alert provider by adding
handling specific to how RSGB's iCal events are formatted. This is still effectively an abstract class itself;
RSGB has two contest calendars (HF & VHF) that each subclass this."""
def __init__(self, name, provider_config, url, poll_interval): def __init__(self, name, provider_config, url, poll_interval):
super().__init__(name, provider_config, url, poll_interval) super().__init__(name, provider_config, url, poll_interval)
FREQ_PATTERN = re.compile(r"([\d.]+(?:MHz|GHz))|SHF") FREQ_PATTERN = re.compile(r"([\d.]+(?:MHz|GHz))|SHF")
def _http_response_to_alerts(self, http_response): def event_to_alert(self, event: Event) -> Alert:
new_alerts = [] """Convert an iCal event in RSGB's format to an Alert object."""
cal = Calendar.from_ical(http_response.content)
# Iterate through events summary = str(event.get("summary", "")).strip()
for component in cal.walk():
if component.name != "VEVENT":
continue
event = cast(Event, component) # Ensure summaries start with "RSGB" to avoid any confusion
summary = str(event.get("summary", "")).strip() if not summary.startswith("RSGB "):
summary = "RSGB " + summary
# Ensure summaries start with "RSGB" to avoid any confusion # Extract freq from summary. HF contests don't give frequencies, all VHF ones do
if not summary.startswith("RSGB "): match = self.FREQ_PATTERN.search(summary)
summary = "RSGB " + summary if match:
freqs_modes = match.group()
else:
freqs_modes = "HF bands"
freqs_modes = freqs_modes + ", "
# Extract freq from summary # Extract mode from summary
freqs_modes = "" if "FMAC" in summary:
freqs_modes = freqs_modes + "FM"
elif "CW" in summary:
freqs_modes = freqs_modes + "CW"
elif "SSB" in summary:
freqs_modes = freqs_modes + "SSB"
elif "FT8" in summary:
freqs_modes = freqs_modes + "FT8"
elif "FT4" in summary:
freqs_modes = freqs_modes + "FT4"
elif "DATA" in summary:
freqs_modes = freqs_modes + "Data modes"
else:
freqs_modes = freqs_modes + "All modes"
# HF contests don't give frequencies, all VHF ones do dtstart = event.get("dtstart")
match = self.FREQ_PATTERN.search(summary) dtend = event.get("dtend")
if match: start_timestamp = self._to_utc_timestamp(dtstart.dt)
freqs_modes = match.group() end_timestamp = self._to_utc_timestamp(dtend.dt) - 1 if dtend is not None else start_timestamp
else:
freqs_modes = "HF bands"
freqs_modes = freqs_modes + ", "
# Extract mode from summary # Convert to our alert format
if "FMAC" in summary: alert = Alert(
freqs_modes = freqs_modes + "FM" source=self.name,
elif "CW" in summary: dx_calls=[],
freqs_modes = freqs_modes + "CW" dx_country="United Kingdom",
elif "SSB" in summary: dx_dxcc_id=235,
freqs_modes = freqs_modes + "SSB" dx_continent=Continent.EU,
elif "FT8" in summary: dx_cq_zone=14,
freqs_modes = freqs_modes + "FT8" dx_itu_zone=27,
elif "FT4" in summary: dx_flag="🇬🇧",
freqs_modes = freqs_modes + "FT4" freqs_modes=freqs_modes,
elif "DATA" in summary: comment=summary,
freqs_modes = freqs_modes + "Data modes" start_time=start_timestamp,
else: end_time=end_timestamp,
freqs_modes = freqs_modes + "All modes" alert_type=AlertType.CONTEST,
)
dtstart = event.get("dtstart") return alert
dtend = event.get("dtend")
start_timestamp = self._to_utc_timestamp(dtstart.dt)
end_timestamp = self._to_utc_timestamp(dtend.dt) - 1 if dtend is not None else start_timestamp
# Convert to our alert format
alert = Alert(
source=self.name,
dx_calls=[],
dx_country="United Kingdom",
dx_dxcc_id=235,
dx_continent=Continent.EU,
dx_cq_zone=14,
dx_itu_zone=27,
dx_flag="🇬🇧",
freqs_modes=freqs_modes,
comment=summary,
start_time=start_timestamp,
end_time=end_timestamp,
alert_type=AlertType.CONTEST,
)
# Add to our list.
new_alerts.append(alert)
return new_alerts
@staticmethod
def _to_utc_timestamp(value):
"""Convert a date or datetime value from an iCal field into a UTC UNIX timestamp."""
# Datetime object so we can treat it as-is, check if it has a non-UTC tz and convert it if necessary
if isinstance(value, datetime):
if value.tzinfo is None:
value = pytz.UTC.localize(value)
return value.astimezone(pytz.UTC).timestamp()
# Date object so this is an all day event
return pytz.UTC.localize(datetime.combine(value, time.min)).timestamp()
+41
View File
@@ -0,0 +1,41 @@
from icalendar import Event
from core.enums import AlertType
from data.alert import Alert
from providers.alert.ical_alert_provider import ICALAlertProvider
class WA7BNM(ICALAlertProvider):
"""Alert provider for the WA7BNM contest calendar."""
POLL_INTERVAL_DAYS = 1
ALERTS_URL = "https://contestcalendar.com/weeklycontcustom.php"
def __init__(self, provider_config):
super().__init__(
"WA7BNM Contest Calendar", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60
)
def event_to_alert(self, event: Event) -> Alert:
"""Convert an iCal event in WA7BNM's format to an Alert object."""
summary = str(event.get("summary", ""))
url = str(event.get("url", ""))
dtstart = event.get("dtstart")
dtend = event.get("dtend")
start_timestamp = self._to_utc_timestamp(dtstart.dt)
end_timestamp = self._to_utc_timestamp(dtend.dt) - 1 if dtend is not None else start_timestamp
# Convert to our alert format
alert = Alert(
source=self.name,
dx_calls=[],
comment=summary,
url=url,
start_time=start_timestamp,
end_time=end_timestamp,
alert_type=AlertType.CONTEST,
)
return alert
+1 -1
View File
@@ -65,7 +65,7 @@ class SSESpotProvider(SpotProvider):
self._url, self._url,
headers=HTTP_HEADERS, headers=HTTP_HEADERS,
latest_event_id=self._last_event_id, latest_event_id=self._last_event_id,
timeout=10, timeout=30,
on_open=self._on_open, on_open=self._on_open,
on_error=self._on_error, on_error=self._on_error,
) as event_source: ) as event_source:
+1 -1
View File
@@ -1,6 +1,6 @@
pyyaml~=6.0.3 pyyaml~=6.0.3
requests-cache~=1.2.1 requests-cache~=1.2.1
pyhamtools~=0.12.0 pyhamtools~=0.13.2
telnetlib3~=2.0.8 telnetlib3~=2.0.8
pytz~=2025.2 pytz~=2025.2
requests~=2.32.4 requests~=2.32.4
+1 -1
View File
@@ -233,7 +233,7 @@ function addAlertRowsToTable(tbody, alerts) {
commentText = escapeHtml(a["comment"]); commentText = escapeHtml(a["comment"]);
} }
if (a["url"] != null) { if (a["url"] != null) {
commentText = `<a href="${escapeHtml(a['url'])}" target="_new">${commentText}</a>`; commentText += ` <a href="${escapeHtml(a['url'])}" target="_new" style="text-decoration: none">🔗</a>`;
} }
// Sig or fallback to source // Sig or fallback to source
+1 -1
View File
@@ -77,7 +77,7 @@
</div> </div>
<script src="/static/js/add-spot.js?v=1789023274"></script> <script src="/static/js/add-spot.js?v=1789059482"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-add-spot").addClass("active"); $("#nav-link-add-spot").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -83,7 +83,7 @@
</div> </div>
<script src="/static/js/alerts.js?v=1789023274"></script> <script src="/static/js/alerts.js?v=1789059482"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-alerts").addClass("active"); $("#nav-link-alerts").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -76,8 +76,8 @@
</div> </div>
<script src="/static/js/spotsbandsandmap.js?v=1789023274"></script> <script src="/static/js/spotsbandsandmap.js?v=1789059482"></script>
<script src="/static/js/bands.js?v=1789023274"></script> <script src="/static/js/bands.js?v=1789059482"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-bands").addClass("active"); $("#nav-link-bands").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+5 -5
View File
@@ -1,6 +1,6 @@
{% extends "skeleton.html" %} {% extends "skeleton.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/css/style.css?v=1789023274" type="text/css"> <link rel="stylesheet" href="/static/css/style.css?v=1789059482" type="text/css">
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet"> <link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet"> <link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet"> <link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
@@ -16,10 +16,10 @@
window.fetchEventSource = fetchEventSource; window.fetchEventSource = fetchEventSource;
</script> </script>
<script src="/static/js/utils.js?v=1789023274"></script> <script src="/static/js/utils.js?v=1789059482"></script>
<script src="/static/js/ui-ham.js?v=1789023274"></script> <script src="/static/js/ui-ham.js?v=1789059482"></script>
<script src="/static/js/geo.js?v=1789023274"></script> <script src="/static/js/geo.js?v=1789059482"></script>
<script src="/static/js/common.js?v=1789023274"></script> <script src="/static/js/common.js?v=1789059482"></script>
{% end %} {% end %}
{% block body %} {% block body %}
<div class="container"> <div class="container">
+1 -1
View File
@@ -284,7 +284,7 @@
</div> </div>
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script> <script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
<script src="/static/js/conditions.js?v=1789023274"></script> <script src="/static/js/conditions.js?v=1789059482"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active"); $("#nav-link-conditions").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -113,8 +113,8 @@
const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}"; const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}";
</script> </script>
<script src="/static/js/spotsbandsandmap.js?v=1789023274"></script> <script src="/static/js/spotsbandsandmap.js?v=1789059482"></script>
<script src="/static/js/map.js?v=1789023274"></script> <script src="/static/js/map.js?v=1789059482"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-map").addClass("active"); $("#nav-link-map").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -113,8 +113,8 @@
</div> </div>
<script src="/static/js/spotsbandsandmap.js?v=1789023274"></script> <script src="/static/js/spotsbandsandmap.js?v=1789059482"></script>
<script src="/static/js/spots.js?v=1789023274"></script> <script src="/static/js/spots.js?v=1789059482"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-spots").addClass("active"); $("#nav-link-spots").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -86,7 +86,7 @@
</div> </div>
</div> </div>
<script src="/static/js/status.js?v=1789023274"></script> <script src="/static/js/status.js?v=1789059482"></script>
<script> <script>
$(document).ready(function () { $(document).ready(function () {
$("#nav-link-status").addClass("active"); $("#nav-link-status").addClass("active");