diff --git a/config-example.yml b/config-example.yml index 53938ee..683ae72 100644 --- a/config-example.yml +++ b/config-example.yml @@ -165,6 +165,9 @@ alert_providers: - class: "NG3K" enabled: true + - class: "WA7BNM" + enabled: true + - class: "RSGBHFContests" enabled: true diff --git a/providers/alert/ical_alert_provider.py b/providers/alert/ical_alert_provider.py new file mode 100644 index 0000000..cf81959 --- /dev/null +++ b/providers/alert/ical_alert_provider.py @@ -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() diff --git a/providers/alert/rsgb_ical_alert_provider.py b/providers/alert/rsgb_ical_alert_provider.py index 80b2af1..16d56b2 100644 --- a/providers/alert/rsgb_ical_alert_provider.py +++ b/providers/alert/rsgb_ical_alert_provider.py @@ -1,101 +1,75 @@ import re -from datetime import datetime, time -from typing import cast -import pytz -from icalendar import Calendar, Event +from icalendar import Event from core.enums import AlertType, Continent from data.alert import Alert -from providers.alert.http_alert_provider import HTTPAlertProvider +from providers.alert.ical_alert_provider import ICALAlertProvider -class RSGBICALAlertProvider(HTTPAlertProvider): - """Generic alert provider for RSGB contest ICAL calendars.""" +class RSGBICALAlertProvider(ICALAlertProvider): + """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): super().__init__(name, provider_config, url, poll_interval) FREQ_PATTERN = re.compile(r"([\d.]+(?:MHz|GHz))|SHF") - def _http_response_to_alerts(self, http_response): - new_alerts = [] - cal = Calendar.from_ical(http_response.content) + def event_to_alert(self, event: Event) -> Alert: + """Convert an iCal event in RSGB's format to an Alert object.""" - # Iterate through events - for component in cal.walk(): - if component.name != "VEVENT": - continue + summary = str(event.get("summary", "")).strip() - event = cast(Event, component) - summary = str(event.get("summary", "")).strip() + # Ensure summaries start with "RSGB" to avoid any confusion + if not summary.startswith("RSGB "): + summary = "RSGB " + summary - # Ensure summaries start with "RSGB" to avoid any confusion - if not summary.startswith("RSGB "): - summary = "RSGB " + summary + # Extract freq from summary. HF contests don't give frequencies, all VHF ones do + match = self.FREQ_PATTERN.search(summary) + if match: + freqs_modes = match.group() + else: + freqs_modes = "HF bands" + freqs_modes = freqs_modes + ", " - # Extract freq from summary - freqs_modes = "" + # Extract mode from summary + 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 - match = self.FREQ_PATTERN.search(summary) - if match: - freqs_modes = match.group() - else: - freqs_modes = "HF bands" - freqs_modes = freqs_modes + ", " + 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 - # Extract mode from summary - 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" + # 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, + ) - 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=[], - 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() + return alert diff --git a/providers/alert/wa7bnm.py b/providers/alert/wa7bnm.py new file mode 100644 index 0000000..498c194 --- /dev/null +++ b/providers/alert/wa7bnm.py @@ -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 diff --git a/requirements.txt b/requirements.txt index ecc7b22..1487fde 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ pyyaml~=6.0.3 requests-cache~=1.2.1 -pyhamtools~=0.12.0 +pyhamtools~=0.13.2 telnetlib3~=2.0.8 pytz~=2025.2 requests~=2.32.4 diff --git a/static/js/alerts.js b/static/js/alerts.js index 2fbfef2..2c71454 100644 --- a/static/js/alerts.js +++ b/static/js/alerts.js @@ -233,7 +233,7 @@ function addAlertRowsToTable(tbody, alerts) { commentText = escapeHtml(a["comment"]); } if (a["url"] != null) { - commentText = `${commentText}`; + commentText += ` 🔗`; } // Sig or fallback to source diff --git a/templates/add_spot.html b/templates/add_spot.html index f00f363..b0312f5 100644 --- a/templates/add_spot.html +++ b/templates/add_spot.html @@ -77,7 +77,7 @@ - + diff --git a/templates/alerts.html b/templates/alerts.html index bb56ac1..99c0a30 100644 --- a/templates/alerts.html +++ b/templates/alerts.html @@ -83,7 +83,7 @@ - + diff --git a/templates/bands.html b/templates/bands.html index 2a7bf4c..9fa229b 100644 --- a/templates/bands.html +++ b/templates/bands.html @@ -76,8 +76,8 @@ - - + + diff --git a/templates/base.html b/templates/base.html index 0f15f3c..d107aa7 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,6 +1,6 @@ {% extends "skeleton.html" %} {% block head_extra %} - + @@ -16,10 +16,10 @@ window.fetchEventSource = fetchEventSource; - - - - + + + + {% end %} {% block body %}