mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-09-20 06:17:41 +00:00
Add support for WA7BNM Contest Calendar. Closes #106
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/add-spot.js?v=1789023274"></script>
|
<script src="/static/js/add-spot.js?v=1789055140"></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>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/alerts.js?v=1789023274"></script>
|
<script src="/static/js/alerts.js?v=1789055140"></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>
|
||||||
|
|||||||
@@ -76,8 +76,8 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/spotsbandsandmap.js?v=1789023274"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1789055140"></script>
|
||||||
<script src="/static/js/bands.js?v=1789023274"></script>
|
<script src="/static/js/bands.js?v=1789055140"></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
@@ -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=1789055140" 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=1789055140"></script>
|
||||||
<script src="/static/js/ui-ham.js?v=1789023274"></script>
|
<script src="/static/js/ui-ham.js?v=1789055140"></script>
|
||||||
<script src="/static/js/geo.js?v=1789023274"></script>
|
<script src="/static/js/geo.js?v=1789055140"></script>
|
||||||
<script src="/static/js/common.js?v=1789023274"></script>
|
<script src="/static/js/common.js?v=1789055140"></script>
|
||||||
{% end %}
|
{% end %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -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=1789055140"></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
@@ -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=1789055140"></script>
|
||||||
<script src="/static/js/map.js?v=1789023274"></script>
|
<script src="/static/js/map.js?v=1789055140"></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>
|
||||||
|
|||||||
@@ -113,8 +113,8 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/spotsbandsandmap.js?v=1789023274"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1789055140"></script>
|
||||||
<script src="/static/js/spots.js?v=1789023274"></script>
|
<script src="/static/js/spots.js?v=1789055140"></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>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/status.js?v=1789023274"></script>
|
<script src="/static/js/status.js?v=1789055140"></script>
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$("#nav-link-status").addClass("active");
|
$("#nav-link-status").addClass("active");
|
||||||
|
|||||||
Reference in New Issue
Block a user