mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-09-20 06:17:41 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f8ff46426 | ||
|
|
2e51d5fdb2 | ||
|
|
3bab3cb784 | ||
|
|
a4245ed5ce | ||
|
|
64bb8ef3eb |
+9
-6
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user