Compare commits

...
5 Commits
19 changed files with 127 additions and 50 deletions
+9 -6
View File
@@ -162,6 +162,9 @@ alert_providers:
- class: "BOTA"
enabled: true
- class: "Hamsat"
enabled: true
- class: "NG3K"
enabled: true
@@ -308,27 +311,27 @@ callsign_data_providers:
priority: 2
# No server-side credentials for HamQTH. Users must provide their own.
- class: "CountryFiles"
priority: 3
enabled: true
- class: "ClublogAPI"
# Querying the Clublog API directly doesn't provide any more data than the XML version, it just provides slightly
# more up-to-date information in the rare case that the prefix data changes, at a significant cost of looking up
# every callsign via an API call. Normally left disabled but it exists as an option.
enabled: false
priority: 3
priority: 4
# API key for Clublog to look up information. Required in order to enable this provider. Unlike QRZ and HamQTH,
# Clublog uses an API key issued to Spothole, not to the end user.
api_key: ""
- class: "ClublogXML"
enabled: true
priority: 4
priority: 5
# API key for Clublog to look up information. Required in order to enable this provider. You will need to request
# one via their helpdesk portal if you want to use callsign lookups from Clublog.
api_key: ""
- class: "CountryFiles"
priority: 5
enabled: true
# Maximum time to keep spots and alerts in the system before deleting them. By default, one hour for spots and one week
# for alerts.
+16
View File
@@ -12,6 +12,22 @@ HAMQTH_PRG = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}"
# Special Interest Groups
SIGS = [
SIG(
name="AMSAT",
comment_names=[],
description="Amateur Radio Satellites",
sig_type=SIGType.WORLDWIDE,
icon="fa-satellite",
refs_globally_unique=False,
),
SIG(
name="EME",
comment_names=[],
description="Moonbounce",
sig_type=SIGType.WORLDWIDE,
icon="fa-moon",
refs_globally_unique=False,
),
SIG(
name="POTA",
comment_names=["POTA"],
+1 -3
View File
@@ -71,7 +71,6 @@ class ModeSource(str, Enum):
SPOT = "SPOT"
COMMENT = "COMMENT"
BANDPLAN = "BANDPLAN"
NONE = "NONE"
class LocationSourceForSpot(str, Enum):
@@ -82,7 +81,6 @@ class LocationSourceForSpot(str, Enum):
GRID = "GRID"
HOME_QTH = "HOME QTH"
DXCC = "DXCC"
NONE = "NONE"
class LocationSourceForCallsign(str, Enum):
@@ -90,7 +88,6 @@ class LocationSourceForCallsign(str, Enum):
HOME_QTH = "HOME QTH"
DXCC = "DXCC"
NONE = "NONE"
class SIGRefType(str, Enum):
@@ -119,6 +116,7 @@ class AlertType(str, Enum):
"""Type of an alert."""
XOTA = "XOTA"
SATELLITE = "SATELLITE"
DXPEDITION = "DXPEDITION"
CONTEST = "CONTEST"
+3 -2
View File
@@ -43,8 +43,9 @@ def get_sig_ref_info(sig_name, ref_id):
# database.
if sig_name.upper() == "DME":
match = re.match(r"DME[\- ](\d{3,5})", ref_id, re.IGNORECASE)
number = match.group(1)
ref_id = f"DME-{number.zfill(5)}"
if match:
number = match.group(1)
ref_id = f"DME-{number.zfill(5)}"
# DTMBA spotters sometimes include spaces and dashes, our regex allows them but they must be removed here so we
# can look up against the official list which doesn't have them
+7 -5
View File
@@ -1,7 +1,7 @@
import hashlib
import json
import logging
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime, timedelta
import pytz
@@ -64,7 +64,7 @@ class Alert:
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
sig: str | None = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
sig_refs: list | None = None
sig_refs: list = field(default_factory=list)
# Timing info
@@ -128,13 +128,13 @@ class Alert:
# Fetch SIG data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a reference
# in its initial call, we use this code to populate the rest of the data. This includes working out grid refs
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
if self.sig_refs and len(self.sig_refs) > 0:
if self.sig_refs:
for sig_ref in self.sig_refs:
populate_missing_sig_ref_info(sig_ref)
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
# and apply it to the whole spot.
if self.sig_refs and len(self.sig_refs) > 0 and self.sig_refs[0] and not self.sig:
if self.sig_refs and self.sig_refs[0] and not self.sig:
self.sig = self.sig_refs[0].sig
# Create an ID based on the source and source ID if possible, as these guaranee uniqueness. If there is no
@@ -158,7 +158,9 @@ class Alert:
self.icon = "fa-globe-africa"
elif self.alert_type == AlertType.CONTEST:
self.icon = "fa-trophy"
elif self.sig_refs and len(self.sig_refs) > 0 and self.sig_refs[0].icon:
elif self.alert_type == AlertType.CONTEST:
self.icon = "fa-satellite"
elif self.sig_refs and self.sig_refs[0].icon:
self.icon = self.sig_refs[0].icon
except Exception:
+1 -1
View File
@@ -38,7 +38,7 @@ class Callsign:
# ITU zone in which the callsign indicates they are operating
itu_zone: int | None = None
# Location source
location_source: LocationSourceForCallsign = LocationSourceForCallsign.NONE
location_source: LocationSourceForCallsign | None = None
def fully_populated(self):
"""Utility method to indicate that the callsign data is fully populated. Multiple providers can return data for
+2 -2
View File
@@ -8,10 +8,10 @@ class SIGRef:
"""Data class that defines a Special Interest Group "info" or reference. As well as the basic reference ID we include a
name and a lookup URL."""
# Reference ID, e.g. "GB-0001".
id: str
# SIG that this reference is in, e.g. "POTA".
sig: str
# Reference ID, e.g. "GB-0001".
id: str | None = None
# Name of the reference, e.g. "Null Country Park", if known.
name: str | None = None
# Type of the reference, e.g. "Park", if known.
+22 -14
View File
@@ -2,7 +2,7 @@ import hashlib
import json
import logging
import re
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from math import isnan
@@ -70,7 +70,7 @@ class Spot:
dx_latitude: float | None = None
dx_longitude: float | None = None
# DX Location source. Indicates how accurate the location might be.
dx_location_source: LocationSourceForSpot = LocationSourceForSpot.NONE
dx_location_source: LocationSourceForSpot | None = None
# DX Location good. Indicates that the software thinks the location data is good enough to plot on a map. This is
# true if the location source is "SPOT", "SIG REF LOOKUP" or "GRID", or if the location source is "HOME QTH" and the
# DX callsign doesn't have a suffix like /P.
@@ -107,7 +107,7 @@ class Spot:
# Inferred mode "family".
mode_type: ModeType | None = None
# Source of the mode information.
mode_source: ModeSource = ModeSource.NONE
mode_source: ModeSource | None = None
# Frequency, in Hz
freq: float | None = None
# Band, defined by the frequency, e.g. "40m" or "70cm"
@@ -124,7 +124,7 @@ class Spot:
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
sig: str | None = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
sig_refs: list | None = None
sig_refs: list = field(default_factory=list)
# Timing info
@@ -262,12 +262,12 @@ class Spot:
self.dx_location_source = LocationSourceForSpot.SPOT
# Set the top-level "SIG" if it is missing but we have at least one SIG ref.
if not self.sig and self.sig_refs and len(self.sig_refs) > 0:
if not self.sig and self.sig_refs:
self.sig = self.sig_refs[0].sig.upper()
# See if we already have a SIG reference, but the comment looks like it contains more for the same SIG. This
# should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002".
if self.comment and self.sig_refs and len(self.sig_refs) > 0 and self.sig_refs[0].sig:
if self.comment and self.sig_refs and self.sig_refs[0].sig:
sig = self.sig_refs[0].sig.upper()
regex = get_ref_regex_for_sig(sig)
if regex:
@@ -315,7 +315,7 @@ class Spot:
# Fetch SIG data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a reference
# in its initial call, we use this code to populate the rest of the data. This includes working out grid refs
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
if self.sig_refs and len(self.sig_refs) > 0:
if self.sig_refs:
for sig_ref in self.sig_refs:
sig_ref = populate_missing_sig_ref_info(sig_ref)
# If the spot itself doesn't have location yet, but the SIG ref does, extract it
@@ -331,7 +331,7 @@ class Spot:
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
# and apply it to the whole spot.
if self.sig_refs and len(self.sig_refs) > 0 and not self.sig:
if self.sig_refs and not self.sig:
self.sig = self.sig_refs[0].sig
# Parse "de_grid<prop_mode>dx_grid" structures from the comment, e.g. "JN61ES(ES)JM56XT" or "JO02GQ<>KN17LG".
@@ -359,6 +359,16 @@ class Spot:
self.propagation_mode = mode_tag
logger.info(f"Seen a new propagation mode tag not yet in the system: {mode_tag}")
# Set SIGs based on propagation mode
if self.propagation_mode == "Satellite":
if not self.sig:
self.sig = "AMSAT"
self.sig_refs.append(SIGRef(sig="AMSAT"))
if self.propagation_mode == "Earth-Moon-Earth":
if not self.sig:
self.sig = "EME"
self.sig_refs.append(SIGRef(sig="EME"))
# Parse "de_grid -> dx_grid" structures from the comment
if self.comment:
grid_mode_grid_match = re.search(
@@ -415,7 +425,7 @@ class Spot:
# Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable string,
# otherwise see what they have set on an online lookup service.
if self.sig_refs and len(self.sig_refs) > 0:
if self.sig_refs:
qth = self.sig_refs[0].id
if self.sig_refs[0].name:
qth += f" {self.sig_refs[0].name}"
@@ -462,7 +472,7 @@ class Spot:
# Icon for the spot should be the icon of the first SIG ref if present, otherwise a radio tower
self.icon = "fa-tower-cell"
if self.sig_refs and len(self.sig_refs) > 0 and self.sig_refs[0].icon:
if self.sig_refs and self.sig_refs[0].icon:
self.icon = self.sig_refs[0].icon
except Exception:
@@ -476,16 +486,14 @@ class Spot:
def _append_sig_ref_if_missing(self, new_sig_ref):
"""Append a sig_ref to the list, so long as it's not already there."""
sig_refs = self.sig_refs or []
self.sig_refs = sig_refs
new_sig_ref.id = new_sig_ref.id.strip().upper()
new_sig_ref.sig = new_sig_ref.sig.strip().upper()
if new_sig_ref.id == "":
return
for sig_ref in sig_refs:
for sig_ref in self.sig_refs:
if sig_ref.id == new_sig_ref.id and sig_ref.sig == new_sig_ref.sig:
return
sig_refs.append(new_sig_ref)
self.sig_refs.append(new_sig_ref)
def expired(self):
"""Decide if this spot has expired (in which case it should not be added to the system in the first place, and not
+49
View File
@@ -0,0 +1,49 @@
from datetime import datetime
import pytz
from core.enums import AlertType
from data.alert import Alert
from data.sig_ref import SIGRef
from providers.alert.http_alert_provider import HTTPAlertProvider
class Hamsat(HTTPAlertProvider):
"""Alert provider for Hamsat (hams.at)"""
POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://hams.at/api/alerts"
def __init__(self, provider_config):
super().__init__("Hamsat", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def _http_response_to_alerts(self, http_response):
new_alerts = []
# Iterate through source data
for source_alert in http_response.json()["data"]:
# Convert to our alert format
alert = Alert(
source=self.name,
source_id=source_alert["id"],
dx_calls=[source_alert["callsign"].upper()],
freqs_modes=f"{source_alert['mhz']!s} {source_alert['mhz_direction']}, {source_alert['mode']}",
comment=source_alert["comment"],
# Fudge a SIG ref to provide the remaining bits of data we need: the satellite and the operator's grid
sig_refs=[
SIGRef(
sig="AMSAT",
id=f"{source_alert['satellite']['name']} from {source_alert['grids'][0]}",
)
],
start_time=datetime.strptime(source_alert["aos_at"], "%Y-%m-%dT%H:%M:%SZ")
.replace(tzinfo=pytz.UTC)
.timestamp(),
end_time=datetime.strptime(source_alert["los_at"], "%Y-%m-%dT%H:%M:%SZ")
.replace(tzinfo=pytz.UTC)
.timestamp(),
alert_type=AlertType.SATELLITE,
)
# Add to our list
new_alerts.append(alert)
return new_alerts
+1 -1
View File
@@ -17,7 +17,7 @@ info:
### 2.1
* Added DTMBA, FEA, BIWOTA, COTA & PGA SIGs
* Added AMSAT, EME, DTMBA, FEA, BIWOTA, COTA & PGA SIGs
* Removed the distinction between LSB & USB (both will now show as SSB) and between the various digital voice modes, which will now show as DV.
* Added `sig_type`, `icon`, `region_flag` and `refs_globally_unique` to SIG data
* Added `icon` to spot and alert data
+1 -1
View File
@@ -106,7 +106,7 @@
Faros de España (FEA), Diploma Muesos de España (DMUE), Diploma Castillos de España (DCE), Diploma Monumentos y
Vestigios de España (DMVE), Diploma Estaciones de Ferrocarril de España (DEFE), Diploma Teatri Musei e Belle
Arti (DTMBA), British Inland Waterways on the Air (BIWOTA), Castles on the Air (COTA), Polish Gmina Award (PGA),
and Toilets on the Air.</p>
EME/Moonbounce, Amateur Satellite (AMSAT), and Toilets on the Air.</p>
<p>As of the time of writing in August 2026, I think Spothole captures most outdoor radio programmes that have a
defined, downloadable reference list, and almost certainly those that have a spotting/alerting API. If you know
of one I've missed, please let me know!</p>
+1 -1
View File
@@ -77,7 +77,7 @@
</div>
<script src="/static/js/add-spot.js?v=1789059482"></script>
<script src="/static/js/add-spot.js?v=1789116473"></script>
<script>$(document).ready(function () {
$("#nav-link-add-spot").addClass("active");
}); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -83,7 +83,7 @@
</div>
<script src="/static/js/alerts.js?v=1789059482"></script>
<script src="/static/js/alerts.js?v=1789116474"></script>
<script>$(document).ready(function () {
$("#nav-link-alerts").addClass("active");
}); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -76,8 +76,8 @@
</div>
<script src="/static/js/spotsbandsandmap.js?v=1789059482"></script>
<script src="/static/js/bands.js?v=1789059482"></script>
<script src="/static/js/spotsbandsandmap.js?v=1789116473"></script>
<script src="/static/js/bands.js?v=1789116473"></script>
<script>$(document).ready(function () {
$("#nav-link-bands").addClass("active");
}); <!-- highlight active page in nav --></script>
+5 -5
View File
@@ -1,6 +1,6 @@
{% extends "skeleton.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/css/style.css?v=1789059482" type="text/css">
<link rel="stylesheet" href="/static/css/style.css?v=1789116473" type="text/css">
<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/solid-6.7.2.min.css" rel="stylesheet">
@@ -16,10 +16,10 @@
window.fetchEventSource = fetchEventSource;
</script>
<script src="/static/js/utils.js?v=1789059482"></script>
<script src="/static/js/ui-ham.js?v=1789059482"></script>
<script src="/static/js/geo.js?v=1789059482"></script>
<script src="/static/js/common.js?v=1789059482"></script>
<script src="/static/js/utils.js?v=1789116473"></script>
<script src="/static/js/ui-ham.js?v=1789116473"></script>
<script src="/static/js/geo.js?v=1789116473"></script>
<script src="/static/js/common.js?v=1789116473"></script>
{% end %}
{% block body %}
<div class="container">
+1 -1
View File
@@ -284,7 +284,7 @@
</div>
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
<script src="/static/js/conditions.js?v=1789059482"></script>
<script src="/static/js/conditions.js?v=1789116473"></script>
<script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active");
}); <!-- 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', '') }}";
</script>
<script src="/static/js/spotsbandsandmap.js?v=1789059482"></script>
<script src="/static/js/map.js?v=1789059482"></script>
<script src="/static/js/spotsbandsandmap.js?v=1789116474"></script>
<script src="/static/js/map.js?v=1789116474"></script>
<script>$(document).ready(function () {
$("#nav-link-map").addClass("active");
}); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -113,8 +113,8 @@
</div>
<script src="/static/js/spotsbandsandmap.js?v=1789059482"></script>
<script src="/static/js/spots.js?v=1789059482"></script>
<script src="/static/js/spotsbandsandmap.js?v=1789116473"></script>
<script src="/static/js/spots.js?v=1789116473"></script>
<script>$(document).ready(function () {
$("#nav-link-spots").addClass("active");
}); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -86,7 +86,7 @@
</div>
</div>
<script src="/static/js/status.js?v=1789059482"></script>
<script src="/static/js/status.js?v=1789116473"></script>
<script>
$(document).ready(function () {
$("#nav-link-status").addClass("active");