mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-09-20 14:27:42 +00:00
Compare commits
18
Commits
2.0
...
12e9b6a4ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12e9b6a4ca | ||
|
|
8345238d76 | ||
|
|
bc326388ac | ||
|
|
df684a601d | ||
|
|
9083470482 | ||
|
|
ba56acd7ac | ||
|
|
8451ca2727 | ||
|
|
7ae77e9fb2 | ||
|
|
c3a360e691 | ||
|
|
2a4190ec4a | ||
|
|
c71c0e9d75 | ||
|
|
f2ef6e93fa | ||
|
|
d63897f8ad | ||
|
|
02e2730e47 | ||
|
|
b2719d2cd6 | ||
|
|
b9268dd4ca | ||
|
|
0cce38b0ab | ||
|
|
5a77248dbc |
Generated
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ProjectModuleManager">
|
<component name="ProjectModuleManager">
|
||||||
<modules>
|
<modules>
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/Spothole.iml" filepath="$PROJECT_DIR$/.idea/Spothole.iml" />
|
<module fileurl="file://$PROJECT_DIR$/.idea/spothole.iml" filepath="$PROJECT_DIR$/.idea/spothole.iml" />
|
||||||
</modules>
|
</modules>
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
<component name="ProjectRunConfigurationManager">
|
||||||
<configuration default="false" name="Run" type="PythonConfigurationType" factoryName="Python">
|
<configuration default="false" name="Run" type="PythonConfigurationType" factoryName="Python">
|
||||||
<module name="Spothole" />
|
<module name="spothole" />
|
||||||
<option name="ENV_FILES" value="" />
|
<option name="ENV_FILES" value="" />
|
||||||
<option name="INTERPRETER_OPTIONS" value="" />
|
<option name="INTERPRETER_OPTIONS" value="" />
|
||||||
<option name="PARENT_ENVS" value="true" />
|
<option name="PARENT_ENVS" value="true" />
|
||||||
|
|||||||
+11
-3
@@ -68,7 +68,7 @@ spot_providers:
|
|||||||
- class: "DXCluster"
|
- class: "DXCluster"
|
||||||
# Clusters have a "name" property in case you want to say which cluster node the data is from, or connect to
|
# Clusters have a "name" property in case you want to say which cluster node the data is from, or connect to
|
||||||
# several clusters and name them separately. The name will be used as the "source" field for the spots received.
|
# several clusters and name them separately. The name will be used as the "source" field for the spots received.
|
||||||
name: "HRD Cluster"
|
name: "Cluster"
|
||||||
enabled: true
|
enabled: true
|
||||||
host: "hrd.wa9pie.net"
|
host: "hrd.wa9pie.net"
|
||||||
port: 8000
|
port: 8000
|
||||||
@@ -84,7 +84,7 @@ spot_providers:
|
|||||||
allow_rbn_spots: false
|
allow_rbn_spots: false
|
||||||
|
|
||||||
- class: "DXCluster"
|
- class: "DXCluster"
|
||||||
name: "W3LPL Cluster"
|
name: "Cluster"
|
||||||
enabled: false
|
enabled: false
|
||||||
host: "w3lpl.net"
|
host: "w3lpl.net"
|
||||||
port: 7373
|
port: 7373
|
||||||
@@ -246,12 +246,18 @@ sig_ref_data_providers:
|
|||||||
- class: "DME"
|
- class: "DME"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
- class: "FEA"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
- class: "KRMNPA"
|
- class: "KRMNPA"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "SANPCPA"
|
- class: "SANPCPA"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
- class: "DTMBA"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
- class: "Toilets"
|
- class: "Toilets"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
@@ -345,4 +351,6 @@ web_ui_options:
|
|||||||
# (home) page, although being arbitrary HTML you can also use a div with absolute, relative, float placement etc. This
|
# (home) page, although being arbitrary HTML you can also use a div with absolute, relative, float placement etc. This
|
||||||
# is designed for a "donate/support the server" type button, though you are free to do whatever you want with it.
|
# is designed for a "donate/support the server" type button, though you are free to do whatever you want with it.
|
||||||
# As the server owner you are responsible for the safe usage of this option!
|
# As the server owner you are responsible for the safe usage of this option!
|
||||||
support_button_html: ""
|
support_button_html: ""
|
||||||
|
# CartoDB API key. Required to use the CartoDB layers without watermark. Get one from https://carto.com/basemaps/apikey/.
|
||||||
|
cartodb_api_key: ""
|
||||||
+30
-13
@@ -3,7 +3,7 @@ from data.band import Band
|
|||||||
from data.sig import SIG
|
from data.sig import SIG
|
||||||
|
|
||||||
# General software
|
# General software
|
||||||
SOFTWARE_VERSION = "2.0"
|
SOFTWARE_VERSION = "2.1-pre"
|
||||||
|
|
||||||
# HTTP headers used for spot providers that use HTTP
|
# HTTP headers used for spot providers that use HTTP
|
||||||
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
|
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
|
||||||
@@ -54,7 +54,7 @@ SIGS = [
|
|||||||
ref_regex=r"[A-Z]{2}\-\d{3}",
|
ref_regex=r"[A-Z]{2}\-\d{3}",
|
||||||
),
|
),
|
||||||
SIG(
|
SIG(
|
||||||
name="GMA Island",
|
name="GMA Islands",
|
||||||
comment_names=[],
|
comment_names=[],
|
||||||
description="Global Mountain Activity - Islands",
|
description="Global Mountain Activity - Islands",
|
||||||
ref_regex=r"(([A-Z]{2}\-\d{3})|([A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}))",
|
ref_regex=r"(([A-Z]{2}\-\d{3})|([A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}))",
|
||||||
@@ -147,9 +147,28 @@ SIGS = [
|
|||||||
SIG(
|
SIG(
|
||||||
name="DME",
|
name="DME",
|
||||||
comment_names=["DME"],
|
comment_names=["DME"],
|
||||||
description="Diplomas de Municipios Españoles",
|
description="Diploma Municipios de España",
|
||||||
ref_regex=r"\d{4,5}",
|
ref_regex=r"\d{4,5}",
|
||||||
),
|
),
|
||||||
|
SIG(
|
||||||
|
name="FEA",
|
||||||
|
comment_names=["FEA"],
|
||||||
|
description="Diploma Faros de España",
|
||||||
|
# FEA references are technically [DE]\-\d{4}(\.\d)? but spotters always seem to miss out the D- or E-
|
||||||
|
# prefix and just use FEA 1234, so we treat FEA reference IDs as if they were just the number.
|
||||||
|
ref_regex=r"\d{4}(\.\d)?",
|
||||||
|
),
|
||||||
|
SIG(
|
||||||
|
name="DTMBA",
|
||||||
|
comment_names=["DTMBA"],
|
||||||
|
description="Diploma Teatri Musei e Belle Arti",
|
||||||
|
ref_regex=r"I-?[0-9]{3,4}\s?[A-Z]{2}",
|
||||||
|
),
|
||||||
|
SIG(
|
||||||
|
name="BIOWTA",
|
||||||
|
comment_names=["BIWOTA"],
|
||||||
|
description="British Inland Waterways on the Air"
|
||||||
|
),
|
||||||
SIG(
|
SIG(
|
||||||
name="Toilets",
|
name="Toilets",
|
||||||
comment_names=[],
|
comment_names=[],
|
||||||
@@ -163,16 +182,9 @@ CW_MODES = ["CW"]
|
|||||||
PHONE_MODES = [
|
PHONE_MODES = [
|
||||||
"PHONE",
|
"PHONE",
|
||||||
"SSB",
|
"SSB",
|
||||||
"USB",
|
|
||||||
"LSB",
|
|
||||||
"AM",
|
"AM",
|
||||||
"FM",
|
"FM",
|
||||||
"DV",
|
"DV"
|
||||||
"DMR",
|
|
||||||
"DSTAR",
|
|
||||||
"C4FM",
|
|
||||||
"FUSION",
|
|
||||||
"M17",
|
|
||||||
]
|
]
|
||||||
DATA_MODES = [
|
DATA_MODES = [
|
||||||
"DATA",
|
"DATA",
|
||||||
@@ -190,14 +202,19 @@ DATA_MODES = [
|
|||||||
]
|
]
|
||||||
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
|
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
|
||||||
MODE_TYPES = ["CW", "PHONE", "DATA"]
|
MODE_TYPES = ["CW", "PHONE", "DATA"]
|
||||||
SSB_SUB_MODES = ["USB", "LSB"]
|
|
||||||
DV_SUB_MODES = ["DMR", "DSTAR", "C4FM", "FUSION", "M17"]
|
|
||||||
|
|
||||||
# Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode
|
# Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode
|
||||||
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots
|
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots
|
||||||
# that match a key in this table will be converted to the corresponding value, so only the modes above will actually be
|
# that match a key in this table will be converted to the corresponding value, so only the modes above will actually be
|
||||||
# present in the spots.
|
# present in the spots.
|
||||||
MODE_ALIASES = {
|
MODE_ALIASES = {
|
||||||
|
"USB": "SSB",
|
||||||
|
"LSB": "SSB",
|
||||||
|
"DMR": "DV",
|
||||||
|
"DSTAR": "DV",
|
||||||
|
"C4FM": "DV",
|
||||||
|
"FUSION": "DV",
|
||||||
|
"M17": "DV",
|
||||||
"RTT": "RTTY",
|
"RTT": "RTTY",
|
||||||
"BPSK": "PSK",
|
"BPSK": "PSK",
|
||||||
"PSK31": "PSK",
|
"PSK31": "PSK",
|
||||||
|
|||||||
+10
-19
@@ -6,6 +6,7 @@ import diskcache
|
|||||||
|
|
||||||
from core.config import MAX_ALERT_AGE, MAX_SPOT_AGE
|
from core.config import MAX_ALERT_AGE, MAX_SPOT_AGE
|
||||||
from core.live_data_cache import LiveDataCache
|
from core.live_data_cache import LiveDataCache
|
||||||
|
from core.single_object_data_cache import SingleObjectDataCache
|
||||||
from data.solar_conditions import SolarConditions
|
from data.solar_conditions import SolarConditions
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -34,10 +35,8 @@ class DataStore:
|
|||||||
self.dxcc_data = None
|
self.dxcc_data = None
|
||||||
self.dxcc_lookup_by_call_regex = []
|
self.dxcc_lookup_by_call_regex = []
|
||||||
self.sigrefs = None
|
self.sigrefs = None
|
||||||
self.status_data = {}
|
self.status = None
|
||||||
self._status = None
|
self.solar_conditions = None
|
||||||
self.solar_conditions = {}
|
|
||||||
self._solar = None
|
|
||||||
# ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be
|
# ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be
|
||||||
# caches, they can just be straight objects
|
# caches, they can just be straight objects
|
||||||
self.cq_zone_data = None
|
self.cq_zone_data = None
|
||||||
@@ -46,16 +45,11 @@ class DataStore:
|
|||||||
def setup(self):
|
def setup(self):
|
||||||
Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
|
Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Standard disk cache for solar data and status data, but each cache contains only a single object which we
|
# For solar data and status data, we use a wrapper around disk cache where each cache contains only a single
|
||||||
# expose to the wider application
|
# object exposed to the wider application, and provides a store() method for callers to notify diskcache that
|
||||||
self._solar = diskcache.Cache(f"{CACHE_DIR}solar")
|
# the object has changed and needs to be re-cached.
|
||||||
if "solar_conditions" not in self._solar:
|
self.solar_conditions = SingleObjectDataCache(f"{CACHE_DIR}solar", SolarConditions())
|
||||||
self._solar.add("solar_conditions", SolarConditions())
|
self.status = SingleObjectDataCache(f"{CACHE_DIR}status", {})
|
||||||
self.solar_conditions = self._solar.get("solar_conditions")
|
|
||||||
self._status = diskcache.Cache(f"{CACHE_DIR}status")
|
|
||||||
if "status_data" not in self._status:
|
|
||||||
self._status.add("status_data", {})
|
|
||||||
self.status_data = self._status.get("status_data")
|
|
||||||
|
|
||||||
# Standard disk cache for static reference and SIG ref data. Separate provider threads will repopulate these on
|
# Standard disk cache for static reference and SIG ref data. Separate provider threads will repopulate these on
|
||||||
# a regular basis but there's no need for a TTL since old data is better than no data.
|
# a regular basis but there's no need for a TTL since old data is better than no data.
|
||||||
@@ -116,13 +110,10 @@ class DataStore:
|
|||||||
self.dxcc_lookup_by_call_regex.append((re.compile(entry["prefixRegex"]), entry["entityCode"]))
|
self.dxcc_lookup_by_call_regex.append((re.compile(entry["prefixRegex"]), entry["entityCode"]))
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.spots.save_snapshot()
|
|
||||||
self.alerts.save_snapshot()
|
|
||||||
|
|
||||||
self.spots.close()
|
self.spots.close()
|
||||||
self.alerts.close()
|
self.alerts.close()
|
||||||
self._solar.close()
|
self.solar_conditions.close()
|
||||||
self._status.close()
|
self.status.close()
|
||||||
self.dxcc_data.close()
|
self.dxcc_data.close()
|
||||||
self.sigrefs.close()
|
self.sigrefs.close()
|
||||||
self.callsign_data_countryfiles.close()
|
self.callsign_data_countryfiles.close()
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ def get_sig_ref_info(sig, ref_id):
|
|||||||
if sig.upper() == "DME":
|
if sig.upper() == "DME":
|
||||||
ref_id = ref_id.zfill(5)
|
ref_id = ref_id.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
|
||||||
|
if sig.upper() == "DTMBA":
|
||||||
|
ref_id = ref_id.replace("-", "").replace(" ", "")
|
||||||
|
|
||||||
# If the SIG is HEMA, we have no current lookup for this so just skip the lookup here.
|
# If the SIG is HEMA, we have no current lookup for this so just skip the lookup here.
|
||||||
if sig.upper() == "HEMA":
|
if sig.upper() == "HEMA":
|
||||||
sig_ref.ref_type = "Summit"
|
sig_ref.ref_type = "Summit"
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import diskcache
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SingleObjectDataCache:
|
||||||
|
"""Cache for status and solar conditions. This uses DiskCache, but unlike the standard DiskCache users like SIG and
|
||||||
|
callsign lookup handlers, status and solar conditions are persisted as a single object. If we just load the object
|
||||||
|
from DiskCache and modify it, DiskCache doesn't know that it's been updated and needs re-caching, so we provide a
|
||||||
|
store() method that any functions updating the object can call afterwards."""
|
||||||
|
|
||||||
|
def __init__(self, cache_dir, object_if_empty):
|
||||||
|
"""Initialize a SingleObjectDataCache. Provide the directory to load the cache from and save it to. If the cache
|
||||||
|
is empty, the provided object_if_empty parameter will be used to initialise it."""
|
||||||
|
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._cache = diskcache.Cache(cache_dir)
|
||||||
|
# This cache stores a single object, doesn't matter what it's called so "object" will do
|
||||||
|
if "object" not in self._cache:
|
||||||
|
self._cache.add("object", object_if_empty)
|
||||||
|
self._obj = self._cache.get("object")
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
"""Get the data object. This can then be manipulated as necessary across multiple threads. Any function
|
||||||
|
modifying the object must remember to call store() afterwards."""
|
||||||
|
return self._obj
|
||||||
|
|
||||||
|
def store(self):
|
||||||
|
"""Store the updated object in the cache. Any function modifying the object must remember to call this
|
||||||
|
afterwards."""
|
||||||
|
with self._lock:
|
||||||
|
self._cache.set("object", self._obj)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.store()
|
||||||
|
self._cache.close()
|
||||||
+16
-14
@@ -25,8 +25,9 @@ class StatusReporter:
|
|||||||
self._stop_event = Event()
|
self._stop_event = Event()
|
||||||
self._startup_time = datetime.now(pytz.UTC)
|
self._startup_time = datetime.now(pytz.UTC)
|
||||||
|
|
||||||
DATA_STORE.status_data["software_version"] = SOFTWARE_VERSION
|
DATA_STORE.status.get()["software_version"] = SOFTWARE_VERSION
|
||||||
DATA_STORE.status_data["server_owner_callsign"] = SERVER_OWNER_CALLSIGN
|
DATA_STORE.status.get()["server_owner_callsign"] = SERVER_OWNER_CALLSIGN
|
||||||
|
DATA_STORE.status.store()
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Start the reporter thread"""
|
"""Start the reporter thread"""
|
||||||
@@ -50,11 +51,11 @@ class StatusReporter:
|
|||||||
def _report(self):
|
def _report(self):
|
||||||
"""Write status information"""
|
"""Write status information"""
|
||||||
|
|
||||||
DATA_STORE.status_data["uptime"] = (datetime.now(pytz.UTC) - self._startup_time).total_seconds()
|
DATA_STORE.status.get()["uptime"] = (datetime.now(pytz.UTC) - self._startup_time).total_seconds()
|
||||||
DATA_STORE.status_data["mem_use_mb"] = round(psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024), 3)
|
DATA_STORE.status.get()["mem_use_mb"] = round(psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024), 3)
|
||||||
DATA_STORE.status_data["num_spots"] = len(DATA_STORE.spots.values())
|
DATA_STORE.status.get()["num_spots"] = len(DATA_STORE.spots.values())
|
||||||
DATA_STORE.status_data["num_alerts"] = len(DATA_STORE.alerts.values())
|
DATA_STORE.status.get()["num_alerts"] = len(DATA_STORE.alerts.values())
|
||||||
DATA_STORE.status_data["spot_providers"] = [
|
DATA_STORE.status.get()["spot_providers"] = [
|
||||||
{
|
{
|
||||||
"name": p.name,
|
"name": p.name,
|
||||||
"enabled": p.enabled,
|
"enabled": p.enabled,
|
||||||
@@ -69,7 +70,7 @@ class StatusReporter:
|
|||||||
}
|
}
|
||||||
for p in DATA_PROVIDERS.spot_providers
|
for p in DATA_PROVIDERS.spot_providers
|
||||||
]
|
]
|
||||||
DATA_STORE.status_data["alert_providers"] = [
|
DATA_STORE.status.get()["alert_providers"] = [
|
||||||
{
|
{
|
||||||
"name": p.name,
|
"name": p.name,
|
||||||
"enabled": p.enabled,
|
"enabled": p.enabled,
|
||||||
@@ -80,7 +81,7 @@ class StatusReporter:
|
|||||||
}
|
}
|
||||||
for p in DATA_PROVIDERS.alert_providers
|
for p in DATA_PROVIDERS.alert_providers
|
||||||
]
|
]
|
||||||
DATA_STORE.status_data["solar_condition_providers"] = [
|
DATA_STORE.status.get()["solar_condition_providers"] = [
|
||||||
{
|
{
|
||||||
"name": p.name,
|
"name": p.name,
|
||||||
"enabled": p.enabled,
|
"enabled": p.enabled,
|
||||||
@@ -91,7 +92,7 @@ class StatusReporter:
|
|||||||
}
|
}
|
||||||
for p in DATA_PROVIDERS.solar_condition_providers
|
for p in DATA_PROVIDERS.solar_condition_providers
|
||||||
]
|
]
|
||||||
DATA_STORE.status_data["static_data_providers"] = [
|
DATA_STORE.status.get()["static_data_providers"] = [
|
||||||
{
|
{
|
||||||
"name": p.name,
|
"name": p.name,
|
||||||
"enabled": p.enabled,
|
"enabled": p.enabled,
|
||||||
@@ -102,7 +103,7 @@ class StatusReporter:
|
|||||||
}
|
}
|
||||||
for p in DATA_PROVIDERS.static_data_providers
|
for p in DATA_PROVIDERS.static_data_providers
|
||||||
]
|
]
|
||||||
DATA_STORE.status_data["sig_ref_data_providers"] = [
|
DATA_STORE.status.get()["sig_ref_data_providers"] = [
|
||||||
{
|
{
|
||||||
"sig_name": p.sig_name,
|
"sig_name": p.sig_name,
|
||||||
"enabled": p.enabled,
|
"enabled": p.enabled,
|
||||||
@@ -114,7 +115,7 @@ class StatusReporter:
|
|||||||
}
|
}
|
||||||
for p in DATA_PROVIDERS.sig_ref_data_providers
|
for p in DATA_PROVIDERS.sig_ref_data_providers
|
||||||
]
|
]
|
||||||
DATA_STORE.status_data["callsign_data_providers"] = [
|
DATA_STORE.status.get()["callsign_data_providers"] = [
|
||||||
{
|
{
|
||||||
"name": p.name,
|
"name": p.name,
|
||||||
"enabled": p.enabled,
|
"enabled": p.enabled,
|
||||||
@@ -126,13 +127,13 @@ class StatusReporter:
|
|||||||
}
|
}
|
||||||
for p in DATA_PROVIDERS.callsign_data_providers
|
for p in DATA_PROVIDERS.callsign_data_providers
|
||||||
]
|
]
|
||||||
DATA_STORE.status_data["cleanup"] = {
|
DATA_STORE.status.get()["cleanup"] = {
|
||||||
"status": CLEANUP_TIMER.status,
|
"status": CLEANUP_TIMER.status,
|
||||||
"last_ran": CLEANUP_TIMER.last_cleanup_time.replace(tzinfo=pytz.UTC).timestamp()
|
"last_ran": CLEANUP_TIMER.last_cleanup_time.replace(tzinfo=pytz.UTC).timestamp()
|
||||||
if CLEANUP_TIMER.last_cleanup_time
|
if CLEANUP_TIMER.last_cleanup_time
|
||||||
else 0,
|
else 0,
|
||||||
}
|
}
|
||||||
DATA_STORE.status_data["webserver"] = {
|
DATA_STORE.status.get()["webserver"] = {
|
||||||
"status": WEB_SERVER.web_server_metrics["status"],
|
"status": WEB_SERVER.web_server_metrics["status"],
|
||||||
"last_api_access": WEB_SERVER.web_server_metrics["last_api_access_time"]
|
"last_api_access": WEB_SERVER.web_server_metrics["last_api_access_time"]
|
||||||
.replace(tzinfo=pytz.UTC)
|
.replace(tzinfo=pytz.UTC)
|
||||||
@@ -147,6 +148,7 @@ class StatusReporter:
|
|||||||
else 0,
|
else 0,
|
||||||
"page_access_count": WEB_SERVER.web_server_metrics["page_access_counter"],
|
"page_access_count": WEB_SERVER.web_server_metrics["page_access_counter"],
|
||||||
}
|
}
|
||||||
|
DATA_STORE.status.store()
|
||||||
|
|
||||||
# Update Prometheus metrics
|
# Update Prometheus metrics
|
||||||
memory_use_gauge.set(psutil.Process(os.getpid()).memory_info().rss)
|
memory_use_gauge.set(psutil.Process(os.getpid()).memory_info().rss)
|
||||||
|
|||||||
+1
-1
@@ -70,7 +70,7 @@ def infer_mode_from_frequency(freq):
|
|||||||
mode = freq_to_band(khz)["mode"]
|
mode = freq_to_band(khz)["mode"]
|
||||||
# Some additional common digimode ranges in addition to what the 3rd-party freq_to_band function returns.
|
# Some additional common digimode ranges in addition to what the 3rd-party freq_to_band function returns.
|
||||||
# This is mostly here just because freq_to_band is very specific about things like FT8 frequencies, and e.g.
|
# This is mostly here just because freq_to_band is very specific about things like FT8 frequencies, and e.g.
|
||||||
# a spot at 7074.5 kHz will be indicated as LSB, even though it's clearly in the FT8 range. Future updates
|
# a spot at 7074.5 kHz will be indicated as SSB, even though it's clearly in the FT8 range. Future updates
|
||||||
# might include other common digimode centres of activity here, but this achieves the main goal of keeping
|
# might include other common digimode centres of activity here, but this achieves the main goal of keeping
|
||||||
# large numbers of clearly-FT* spots off the list of people filtering out digimodes.
|
# large numbers of clearly-FT* spots off the list of people filtering out digimodes.
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from time import sleep
|
||||||
|
|
||||||
|
from data.sig_ref import SIGRef
|
||||||
|
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class DTMBA(FileDownloadSIGRefDataProvider):
|
||||||
|
"""SIG ref data provider for Diploma Teatri Musei Belle Arti"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 30
|
||||||
|
SIG = "DTMBA"
|
||||||
|
DATA_URL = "https://www.iu1fig.com/share/iz0eik/dtmba/export.php"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
|
def _http_response_to_data(self, http_response):
|
||||||
|
new_data = []
|
||||||
|
for row in http_response.content.decode("utf-8-sig").splitlines():
|
||||||
|
split = row.split(";")
|
||||||
|
ref_id = split[0]
|
||||||
|
ref_name = split[1]
|
||||||
|
new_data.append(
|
||||||
|
SIGRef(
|
||||||
|
sig=self.SIG,
|
||||||
|
id=ref_id,
|
||||||
|
name=ref_name,
|
||||||
|
ref_type="Building"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||||
|
# the data in this case
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||||
|
# is available for other threads e.g. the web server.
|
||||||
|
sleep(0.001)
|
||||||
|
|
||||||
|
return new_data
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
import pdfplumber
|
||||||
|
|
||||||
|
from data.sig_ref import SIGRef
|
||||||
|
from providers.sigrefdata.file_download_sig_ref_data_provider import FileDownloadSIGRefDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class FEA(FileDownloadSIGRefDataProvider):
|
||||||
|
"""SIG ref data provider for Diploma Faros de España"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 30
|
||||||
|
SIG = "FEA"
|
||||||
|
DATA_URL = "http://ea5ol.net/Lista%20Faros.pdf"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
|
def _http_response_to_data(self, http_response):
|
||||||
|
new_data = []
|
||||||
|
|
||||||
|
# Use PDFPlumber to extract the tables in the PDF
|
||||||
|
with pdfplumber.open(BytesIO(http_response.content)) as pdf:
|
||||||
|
all_rows = []
|
||||||
|
|
||||||
|
for page_number, page in enumerate(pdf.pages, start=1):
|
||||||
|
tables = page.extract_tables()
|
||||||
|
|
||||||
|
for table_number, table in enumerate(tables, start=1):
|
||||||
|
if not table:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rows = [row for row in table if any(cell and cell.strip() for cell in row)]
|
||||||
|
all_rows.extend(rows)
|
||||||
|
|
||||||
|
for row in all_rows:
|
||||||
|
if not "REF" in row[0] and not "\n" in row[0]:
|
||||||
|
# FEA references are technically [DE]\-\d{4}(\.\d)? but spotters always seem to miss out the D- or E-
|
||||||
|
# prefix and just use FEA 1234, so we treat FEA reference IDs as if they were just the number.
|
||||||
|
ref_id = row[0].strip().replace("D-", "").replace("E-", "")
|
||||||
|
new_data.append(SIGRef(sig=self.SIG, id=ref_id, name=row[1].strip(), ref_type="Lighthouse"))
|
||||||
|
|
||||||
|
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||||
|
# the data in this case
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
||||||
|
# is available for other threads e.g. the web server.
|
||||||
|
sleep(0.001)
|
||||||
|
|
||||||
|
return new_data
|
||||||
@@ -20,7 +20,7 @@ POLL_INTERVAL = 7200
|
|||||||
# To avoid looking up all stations in the GIRO system and working out which ones are providing live data, this has been
|
# To avoid looking up all stations in the GIRO system and working out which ones are providing live data, this has been
|
||||||
# manually determined and a CSV provided of all the stations that we can query for live data.
|
# manually determined and a CSV provided of all the stations that we can query for live data.
|
||||||
STATIONS_INDEX = "datafiles/didbase-stations.csv"
|
STATIONS_INDEX = "datafiles/didbase-stations.csv"
|
||||||
LGDC_URL = "https://lgdc.uml.edu/common/DIDBGetValues"
|
LGDC_URL = "https://lgdc.uml.edu/fastchar/getbest"
|
||||||
HISTORY_HOURS = 24
|
HISTORY_HOURS = 24
|
||||||
|
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ class GIROIonosonde(SolarConditionsProvider):
|
|||||||
|
|
||||||
from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S")
|
from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S")
|
||||||
to_str = to_time.strftime("%Y.%m.%d+%H:%M:%S")
|
to_str = to_time.strftime("%Y.%m.%d+%H:%M:%S")
|
||||||
url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUFD,fmin&DMUF=3000&fromDate={from_str}&toDate={to_str}"
|
url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUF%28D%29,fmin&DMUF=3000&fromDate={from_str}&toDate={to_str}"
|
||||||
try:
|
try:
|
||||||
http_response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15))
|
http_response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15))
|
||||||
if not http_response.ok:
|
if not http_response.ok:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class SolarConditionsProvider:
|
|||||||
self.enabled = provider_config.get("enabled", True)
|
self.enabled = provider_config.get("enabled", True)
|
||||||
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||||
self.status = "Not Started" if self.enabled else "Disabled"
|
self.status = "Not Started" if self.enabled else "Disabled"
|
||||||
self._solar_conditions = DATA_STORE.solar_conditions
|
self._solar_conditions = DATA_STORE.solar_conditions.get()
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
|
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
|
||||||
@@ -36,3 +36,4 @@ class SolarConditionsProvider:
|
|||||||
if hasattr(self._solar_conditions, key):
|
if hasattr(self._solar_conditions, key):
|
||||||
setattr(self._solar_conditions, key, value)
|
setattr(self._solar_conditions, key, value)
|
||||||
self._solar_conditions.infer_descriptions()
|
self._solar_conditions.infer_descriptions()
|
||||||
|
DATA_STORE.solar_conditions.store()
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ class GMA(HTTPSpotProvider):
|
|||||||
spot.sig_refs[0].sig = "IOTA"
|
spot.sig_refs[0].sig = "IOTA"
|
||||||
spot.sig = "IOTA"
|
spot.sig = "IOTA"
|
||||||
case "GMA Island":
|
case "GMA Island":
|
||||||
spot.sig_refs[0].sig = "GMA Island"
|
spot.sig_refs[0].sig = "GMA Islands"
|
||||||
spot.sig = "GMA Island"
|
spot.sig = "GMA Islands"
|
||||||
case "Lighthouse (ILLW)":
|
case "Lighthouse (ILLW)":
|
||||||
spot.sig_refs[0].sig = "ILLW"
|
spot.sig_refs[0].sig = "ILLW"
|
||||||
spot.sig = "ILLW"
|
spot.sig = "ILLW"
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
SPOTS_URL = "https://www.parksnpeaks.org/api/ALL"
|
SPOTS_URL = "https://www.parksnpeaks.org/api/ALL"
|
||||||
SUBMIT_URL = "https://www.parksnpeaks.org/api/SPOT/"
|
SUBMIT_URL = "https://www.parksnpeaks.org/api/SPOT/"
|
||||||
SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv"
|
|
||||||
SUBMITTABLE_SIGS = [
|
SUBMITTABLE_SIGS = [
|
||||||
"POTA",
|
"POTA",
|
||||||
"SOTA",
|
"SOTA",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
|||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
||||||
|
|
||||||
from core.constants import DV_SUB_MODES, HTTP_HEADERS, SSB_SUB_MODES
|
from core.constants import HTTP_HEADERS
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||||
@@ -98,12 +98,7 @@ class SOTA(HTTPSpotProvider):
|
|||||||
# https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js
|
# https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js
|
||||||
mode = spot.mode
|
mode = spot.mode
|
||||||
if mode and mode not in self.VALID_MODES:
|
if mode and mode not in self.VALID_MODES:
|
||||||
if mode in SSB_SUB_MODES:
|
mode = "Data"
|
||||||
mode = "SSB"
|
|
||||||
elif mode in DV_SUB_MODES:
|
|
||||||
mode = "DV"
|
|
||||||
else:
|
|
||||||
mode = "Data"
|
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
"activatorCallsign": spot.dx_call,
|
"activatorCallsign": spot.dx_call,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS, SSB_SUB_MODES
|
from core.constants import HTTP_HEADERS
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||||
@@ -92,9 +92,7 @@ class Tiles(HTTPSpotProvider):
|
|||||||
if spot.mode:
|
if spot.mode:
|
||||||
mode = spot.mode
|
mode = spot.mode
|
||||||
if mode not in self.VALID_MODES:
|
if mode not in self.VALID_MODES:
|
||||||
if mode in SSB_SUB_MODES:
|
if mode == "OLIVIA":
|
||||||
mode = "SSB"
|
|
||||||
elif mode == "OLIVIA":
|
|
||||||
mode = "Olivia"
|
mode = "Olivia"
|
||||||
elif mode == "JS8":
|
elif mode == "JS8":
|
||||||
mode = "JS8Call"
|
mode = "JS8Call"
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "Spothole"
|
name = "spothole"
|
||||||
version = "2.0"
|
version = "2.1-pre"
|
||||||
authors = [
|
authors = [
|
||||||
{ name="Ian Renton", email="ian@ianrenton.com" },
|
{ name = "Ian Renton", email = "ian@ianrenton.com" },
|
||||||
]
|
]
|
||||||
description = "Spothole is a utility to aggregate \"spots\" from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data."
|
description = "Spothole is a utility to aggregate \"spots\" from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|||||||
+2
-1
@@ -20,4 +20,5 @@ geopandas~=0.13.2
|
|||||||
simplejson~=4.1.1
|
simplejson~=4.1.1
|
||||||
cachetools~=7.1.6
|
cachetools~=7.1.6
|
||||||
fastkml~=1.4.0
|
fastkml~=1.4.0
|
||||||
ruff~=0.16.3
|
ruff~=0.16.3
|
||||||
|
pdfplumber~=0.11.10
|
||||||
+3
-4
@@ -111,7 +111,7 @@ class WebServer:
|
|||||||
(
|
(
|
||||||
r"/api/v2/solar",
|
r"/api/v2/solar",
|
||||||
APISolarConditionsHandler,
|
APISolarConditionsHandler,
|
||||||
{"solar_conditions": self._data_store.solar_conditions, **handler_opts},
|
{"solar_conditions": self._data_store.solar_conditions.get(), **handler_opts},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"/api/v2/dxstats",
|
r"/api/v2/dxstats",
|
||||||
@@ -121,12 +121,12 @@ class WebServer:
|
|||||||
(
|
(
|
||||||
r"/api/v2/options",
|
r"/api/v2/options",
|
||||||
APIOptionsHandler,
|
APIOptionsHandler,
|
||||||
{"status_data": self._data_store.status_data, **handler_opts},
|
{"status_data": self._data_store.status.get(), **handler_opts},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
r"/api/v2/status",
|
r"/api/v2/status",
|
||||||
APIStatusHandler,
|
APIStatusHandler,
|
||||||
{"status_data": self._data_store.status_data, **handler_opts},
|
{"status_data": self._data_store.status.get(), **handler_opts},
|
||||||
),
|
),
|
||||||
(r"/api/v2/lookup/call", APILookupCallHandler, {**handler_opts}),
|
(r"/api/v2/lookup/call", APILookupCallHandler, {**handler_opts}),
|
||||||
(r"/api/v2/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}),
|
(r"/api/v2/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}),
|
||||||
@@ -160,7 +160,6 @@ class WebServer:
|
|||||||
V1APISpotHandler,
|
V1APISpotHandler,
|
||||||
{
|
{
|
||||||
"spots": self._data_store.spots,
|
"spots": self._data_store.spots,
|
||||||
"spot_providers": self._data_providers,
|
|
||||||
**handler_opts,
|
**handler_opts,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ info:
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### 2.1
|
||||||
|
|
||||||
|
* Added DTMBA, FEA and BIWOTA 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.
|
||||||
|
|
||||||
### 2.0
|
### 2.0
|
||||||
|
|
||||||
* **Breaking change:** The "add spot" API has changed to enable future support for upstream submission to the spotting services associated with various SIGs. Instead of just posting the spot object itself as the JSON content of the POST, this has moved into a `spot` object within the structure. A new `handling` object alongside it contains the `submit_upstream`, `upstream_provider`, `upstream_credentials`, and `captcha_token` fields which control the server handling of the spot.
|
* **Breaking change:** The "add spot" API has changed to enable future support for upstream submission to the spotting services associated with various SIGs. Instead of just posting the spot object itself as the JSON content of the POST, this has moved into a `spot` object within the structure. A new `handling` object alongside it contains the `submit_upstream`, `upstream_provider`, `upstream_credentials`, and `captcha_token` fields which control the server handling of the spot.
|
||||||
@@ -852,6 +857,9 @@ components:
|
|||||||
- WAB
|
- WAB
|
||||||
- WAI
|
- WAI
|
||||||
- DME
|
- DME
|
||||||
|
- FEA
|
||||||
|
- DTMBA
|
||||||
|
- BIWOTA
|
||||||
- Toilets
|
- Toilets
|
||||||
example: POTA
|
example: POTA
|
||||||
|
|
||||||
@@ -911,15 +919,9 @@ components:
|
|||||||
- CW
|
- CW
|
||||||
- PHONE
|
- PHONE
|
||||||
- SSB
|
- SSB
|
||||||
- USB
|
|
||||||
- LSB
|
|
||||||
- AM
|
- AM
|
||||||
- FM
|
- FM
|
||||||
- DV
|
- DV
|
||||||
- DMR
|
|
||||||
- DSTAR
|
|
||||||
- C4FM
|
|
||||||
- M17
|
|
||||||
- DATA
|
- DATA
|
||||||
- FT8
|
- FT8
|
||||||
- FT4
|
- FT4
|
||||||
@@ -2030,7 +2032,7 @@ components:
|
|||||||
description: An array of all the supported modes.
|
description: An array of all the supported modes.
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
example: "LSB"
|
example: "SSB"
|
||||||
mode_types:
|
mode_types:
|
||||||
type: array
|
type: array
|
||||||
description: An array of all the supported mode types.
|
description: An array of all the supported mode types.
|
||||||
|
|||||||
+6
-2
@@ -394,9 +394,11 @@ function setBasemap(basemapname) {
|
|||||||
}
|
}
|
||||||
// OpenStreetMap.Mapnik.Dark is a synthetic variant that uses Mapnik tiles with a CSS filter applied
|
// OpenStreetMap.Mapnik.Dark is a synthetic variant that uses Mapnik tiles with a CSS filter applied
|
||||||
const providerName = basemapname === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : basemapname;
|
const providerName = basemapname === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : basemapname;
|
||||||
|
const isCartoDB = basemapname === "CartoDB.Voyager" || basemapname === "CartoDB.DarkMatter";
|
||||||
backgroundTileLayer = L.tileLayer.provider(providerName, {
|
backgroundTileLayer = L.tileLayer.provider(providerName, {
|
||||||
opacity: parseFloat($("#basemapOpacity").val()),
|
opacity: parseFloat($("#basemapOpacity").val()),
|
||||||
edgeBufferTiles: 1
|
edgeBufferTiles: 1,
|
||||||
|
key: isCartoDB ? CARTODB_API_KEY : null
|
||||||
});
|
});
|
||||||
backgroundTileLayer.addTo(map);
|
backgroundTileLayer.addTo(map);
|
||||||
backgroundTileLayer.bringToBack();
|
backgroundTileLayer.bringToBack();
|
||||||
@@ -529,9 +531,11 @@ function setUpMap() {
|
|||||||
// Add basemap
|
// Add basemap
|
||||||
loadedBasemap = $("#basemap").val();
|
loadedBasemap = $("#basemap").val();
|
||||||
const initialProviderName = loadedBasemap === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : loadedBasemap;
|
const initialProviderName = loadedBasemap === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : loadedBasemap;
|
||||||
|
const isCartoDB = loadedBasemap === "CartoDB.Voyager" || loadedBasemap === "CartoDB.DarkMatter";
|
||||||
backgroundTileLayer = L.tileLayer.provider(initialProviderName, {
|
backgroundTileLayer = L.tileLayer.provider(initialProviderName, {
|
||||||
opacity: parseFloat($("#basemapOpacity").val()),
|
opacity: parseFloat($("#basemapOpacity").val()),
|
||||||
edgeBufferTiles: 1
|
edgeBufferTiles: 1,
|
||||||
|
key: isCartoDB ? CARTODB_API_KEY : null
|
||||||
});
|
});
|
||||||
backgroundTileLayer.addTo(map);
|
backgroundTileLayer.addTo(map);
|
||||||
backgroundTileLayer.bringToBack();
|
backgroundTileLayer.bringToBack();
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function setHamHFBandToggles() {
|
|||||||
|
|
||||||
// Generate SIGs filter card. This one is also a special case.
|
// Generate SIGs filter card. This one is also a special case.
|
||||||
function generateSIGsMultiToggleFilterCard(sig_options) {
|
function generateSIGsMultiToggleFilterCard(sig_options) {
|
||||||
const $grid = $('<div class="row row-cols-2 row-cols-xxl-3 g-1 mb-1">');
|
const $grid = $('<div class="row row-cols-2 g-1 mb-1">');
|
||||||
sig_options.forEach(o => {
|
sig_options.forEach(o => {
|
||||||
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||||
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${sigToIcon(o['name'], 'fa-tower-cell')}"></i> ${o['name']}</label></div></div>`);
|
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${sigToIcon(o['name'], 'fa-tower-cell')}"></i> ${o['name']}</label></div></div>`);
|
||||||
@@ -64,7 +64,7 @@ function generateModesMultiToggleFilterCard(mode_options) {
|
|||||||
|
|
||||||
// Set the mode toggles that relate to Analog Voice.
|
// Set the mode toggles that relate to Analog Voice.
|
||||||
function setVoiceModeToggles() {
|
function setVoiceModeToggles() {
|
||||||
const modes = ["PHONE", "SSB", "LSB", "USB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"];
|
const modes = ["PHONE", "SSB", "AM", "FM", "DV"];
|
||||||
$(".filter-button-mode").each(function () {
|
$(".filter-button-mode").each(function () {
|
||||||
$(this).prop('checked', modes.includes($(this).val().replace("filter-button-mode-", "")));
|
$(this).prop('checked', modes.includes($(this).val().replace("filter-button-mode-", "")));
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-75
@@ -324,28 +324,12 @@ function bandToContrastColor(band) {
|
|||||||
return (lum > 128) ? "#000000" : "#ffffff";
|
return (lum > 128) ? "#000000" : "#ffffff";
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODE_TYPE_COLOR_SCHEMES = {
|
|
||||||
"CW": "red",
|
|
||||||
"PHONE": "green",
|
|
||||||
"DATA": "blue"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mode type (CW, PHONE, DATA) to colour. If the mode type is unknown, black will be returned.
|
|
||||||
function modeTypeToColor(modeType) {
|
|
||||||
let col = (modeType != null) ? MODE_TYPE_COLOR_SCHEMES[modeType.toUpperCase()] : null;
|
|
||||||
if (col) {
|
|
||||||
return col;
|
|
||||||
} else {
|
|
||||||
return "#000000";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const SIG_ICONS = {
|
const SIG_ICONS = {
|
||||||
"POTA": "fa-tree",
|
"POTA": "fa-tree",
|
||||||
"SOTA": "fa-mountain-sun",
|
"SOTA": "fa-mountain-sun",
|
||||||
"WWFF": "fa-seedling",
|
"WWFF": "fa-seedling",
|
||||||
"GMA": "fa-person-hiking",
|
"GMA": "fa-person-hiking",
|
||||||
"GMA Island": "fa-person-hiking",
|
"GMA Islands": "fa-person-hiking",
|
||||||
"WWBOTA": "fa-radiation",
|
"WWBOTA": "fa-radiation",
|
||||||
"HEMA": "fa-mound",
|
"HEMA": "fa-mound",
|
||||||
"IOTA": "fa-book-atlas",
|
"IOTA": "fa-book-atlas",
|
||||||
@@ -364,37 +348,13 @@ const SIG_ICONS = {
|
|||||||
"WAB": "fa-table-cells-large",
|
"WAB": "fa-table-cells-large",
|
||||||
"WAI": "fa-table-cells-large",
|
"WAI": "fa-table-cells-large",
|
||||||
"DME": "fa-building",
|
"DME": "fa-building",
|
||||||
|
"FEA": "fa-house-flood-water",
|
||||||
"Tiles": "fa-square",
|
"Tiles": "fa-square",
|
||||||
|
"DTMBA": "fa-masks-theater",
|
||||||
|
"BIWOTA": "fa-ship",
|
||||||
"Toilets": "fa-toilet"
|
"Toilets": "fa-toilet"
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIG_NAMES = {
|
|
||||||
"POTA": "Parks on the Air",
|
|
||||||
"SOTA": "Summits on the Air",
|
|
||||||
"WWFF": "Worldwide Flora & Fauna",
|
|
||||||
"GMA": "Global Mountain Activity",
|
|
||||||
"GMA Island": "Global Mountain Activity - Islands",
|
|
||||||
"WWBOTA": "Bunkers on the Air",
|
|
||||||
"HEMA": "Humps Excluding Marilyns Award",
|
|
||||||
"IOTA": "Islands on the Air",
|
|
||||||
"MOTA": "Mills on the Air",
|
|
||||||
"ARLHS": "Amateur Radio Lighthouse Society",
|
|
||||||
"ILLW": "International Lighthouse Lightship Weekend",
|
|
||||||
"SIOTA": "Silos on the Air",
|
|
||||||
"WCA": "World Castles Award",
|
|
||||||
"ZLOTA": "New Zealand on the Air",
|
|
||||||
"WOTA": "Wainwrights on the Air",
|
|
||||||
"BOTA": "Beaches on the Air",
|
|
||||||
"KRMNPA": "Keith Roget Memorial National Parks Award",
|
|
||||||
"SANPCPA": "South Australia National Parks and Conservation Parks Award",
|
|
||||||
"LLOTA": "Lagos y Lagunas on the Air",
|
|
||||||
"WWTOTA": "Towers on the Air",
|
|
||||||
"WAB": "Worked All Britain",
|
|
||||||
"WAI": "Worked All Ireland",
|
|
||||||
"Tiles": "Tiles on the Air",
|
|
||||||
"TOTA": "Toilets on the Air"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the Font Awesome icon for a given SIG. If the SIG is unknown, the provided default symbol will be returned
|
// Get the Font Awesome icon for a given SIG. If the SIG is unknown, the provided default symbol will be returned
|
||||||
function sigToIcon(sig, defaultIcon) {
|
function sigToIcon(sig, defaultIcon) {
|
||||||
let col = (sig != null) ? SIG_ICONS[sig] : null;
|
let col = (sig != null) ? SIG_ICONS[sig] : null;
|
||||||
@@ -410,34 +370,3 @@ function sigToIcon(sig, defaultIcon) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the full name for a given SIG abbreviation. If the SIG is unknown, an empty string will be returned.
|
|
||||||
function sigToName(sig) {
|
|
||||||
let col = (sig != null) ? SIG_NAMES[sig] : null;
|
|
||||||
if (col) {
|
|
||||||
return col;
|
|
||||||
} else {
|
|
||||||
let col = (sig != null) ? SIG_NAMES[sig.toUpperCase()] : null;
|
|
||||||
if (col) {
|
|
||||||
return col;
|
|
||||||
} else {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the list of known SIGs
|
|
||||||
function getKnownSIGs() {
|
|
||||||
return Array.from(Object.keys(SIG_ICONS));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format a Maidenhead grid with alternating alphabetic blocks in lower case
|
|
||||||
function formatGrid(grid) {
|
|
||||||
grid = grid.toUpperCase();
|
|
||||||
if (grid.length >= 6) {
|
|
||||||
grid = grid.substring(0, 4) + grid.substring(4, 6).toLowerCase() + grid.substring(6);
|
|
||||||
}
|
|
||||||
if (grid.length >= 12) {
|
|
||||||
grid = grid.substring(0, 10) + grid.substring(10, 12).toLowerCase() + grid.substring(14);
|
|
||||||
}
|
|
||||||
return grid;
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-2
@@ -801,12 +801,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
CartoDB: {
|
CartoDB: {
|
||||||
url: 'https://{s}.basemaps.cartocdn.com/{variant}/{z}/{x}/{y}{r}.png',
|
url: 'https://{s}.basemaps.cartocdn.com/{variant}/{z}/{x}/{y}{r}.png?key={key}',
|
||||||
options: {
|
options: {
|
||||||
attribution: '{attribution.OpenStreetMap} © <a href="https://carto.com/attributions">CARTO</a>',
|
attribution: '{attribution.OpenStreetMap} © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
subdomains: 'abcd',
|
subdomains: 'abcd',
|
||||||
maxZoom: 20,
|
maxZoom: 20,
|
||||||
variant: 'light_all'
|
variant: 'light_all',
|
||||||
|
key: '<insert your API key here>'
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
Positron: 'light_all',
|
Positron: 'light_all',
|
||||||
|
|||||||
@@ -101,12 +101,13 @@
|
|||||||
(MOTA), the Amateur Radio Lighthouse Society (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos
|
(MOTA), the Amateur Radio Lighthouse Society (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos
|
||||||
on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National
|
on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National
|
||||||
Parks Award (KRMNPA), South Australia National Parks and Conservation Parks Award (SANPCPA), Wainwrights on the
|
Parks Award (KRMNPA), South Australia National Parks and Conservation Parks Award (SANPCPA), Wainwrights on the
|
||||||
Air (WOTA), Beaches on the Air (BOTA), Lagos y Lagunas On the Air (LLOTA), Towers on the Air (WWTOTA), Tiles on
|
Air (WOTA), Beaches on the Air (BOTA), Lagos y Lagunas On the Air (LLOTA), Towers on the Air, Tiles on
|
||||||
the Air, Worked All Britain (WAB), Worked All Ireland (WAI), el Diploma Municipios de España (DME) and Toilets
|
the Air, Worked All Britain (WAB), Worked All Ireland (WAI), el Diploma Municipios de España (DME), el Diploma
|
||||||
on the Air (TOTA).</p>
|
Faros de España (FEA), il Diploma Teatri Musei e Belle Arti (DTMBA), British Inland Waterways on the Air
|
||||||
<p>As of the time of writing in June 2026, I think Spothole captures essentially all outdoor radio programmes
|
(BIWOTA) and Toilets on the Air.</p>
|
||||||
that have a defined reference list, and almost certainly those that have a spotting/alerting API. If you know of
|
<p>As of the time of writing in August 2026, I think Spothole captures most outdoor radio programmes that have a
|
||||||
one I've missed, please let me know!</p>
|
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>
|
||||||
<h4 class="mt-4">Why can I filter spots by both SIG and Source? Isn't that basically the same thing?</h4>
|
<h4 class="mt-4">Why can I filter spots by both SIG and Source? Isn't that basically the same thing?</h4>
|
||||||
<p>Mostly, but not quite. While POTA spots generally come from the POTA source and so on, there are a few
|
<p>Mostly, but not quite. While POTA spots generally come from the POTA source and so on, there are a few
|
||||||
exceptions:</p>
|
exceptions:</p>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/add-spot.js?v=1787382663"></script>
|
<script src="/static/js/add-spot.js?v=1788171425"></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>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/alerts.js?v=1787382663"></script>
|
<script src="/static/js/alerts.js?v=1788171425"></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=1787382663"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1788171425"></script>
|
||||||
<script src="/static/js/bands.js?v=1787382663"></script>
|
<script src="/static/js/bands.js?v=1788171425"></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=1787382663" type="text/css">
|
<link rel="stylesheet" href="/static/css/style.css?v=1788171425" 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">
|
||||||
@@ -15,10 +15,10 @@
|
|||||||
window.fetchEventSource = fetchEventSource;
|
window.fetchEventSource = fetchEventSource;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="/static/js/utils.js?v=1787382663"></script>
|
<script src="/static/js/utils.js?v=1788171425"></script>
|
||||||
<script src="/static/js/ui-ham.js?v=1787382663"></script>
|
<script src="/static/js/ui-ham.js?v=1788171425"></script>
|
||||||
<script src="/static/js/geo.js?v=1787382663"></script>
|
<script src="/static/js/geo.js?v=1788171425"></script>
|
||||||
<script src="/static/js/common.js?v=1787382663"></script>
|
<script src="/static/js/common.js?v=1788171425"></script>
|
||||||
{% end %}
|
{% end %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
oninput="filtersUpdated();" style="width: 5em; display: inline-block;">
|
oninput="filtersUpdated();" style="width: 5em; display: inline-block;">
|
||||||
{% for a in web_ui_options["max_spot_age"] %}
|
{% for a in web_ui_options["max_spot_age"] %}
|
||||||
<option value="{{a*60}}" {% if web_ui_options[
|
<option value="{{a*60}}" {% if web_ui_options[
|
||||||
"max_spot_age_default"] == a*60 %}selected{% end %}>{{a}}</option>
|
"max_spot_age_default"] == a %}selected{% end %}>{{a}}</option>
|
||||||
{% end %}
|
{% end %}
|
||||||
</select>
|
</select>
|
||||||
minutes
|
minutes
|
||||||
|
|||||||
@@ -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=1787382663"></script>
|
<script src="/static/js/conditions.js?v=1788171425"></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>
|
||||||
|
|||||||
+6
-2
@@ -109,8 +109,12 @@
|
|||||||
<script src="/static/vendor/js/leaflet-cqzones.js"></script>
|
<script src="/static/vendor/js/leaflet-cqzones.js"></script>
|
||||||
<script src="/static/vendor/js/leaflet-workedallbritainireland.js" type="module"></script>
|
<script src="/static/vendor/js/leaflet-workedallbritainireland.js" type="module"></script>
|
||||||
|
|
||||||
<script src="/static/js/spotsbandsandmap.js?v=1787382663"></script>
|
<script>
|
||||||
<script src="/static/js/map.js?v=1787382663"></script>
|
const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script src="/static/js/spotsbandsandmap.js?v=1788171425"></script>
|
||||||
|
<script src="/static/js/map.js?v=1788171425"></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=1787382663"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1788171425"></script>
|
||||||
<script src="/static/js/spots.js?v=1787382663"></script>
|
<script src="/static/js/spots.js?v=1788171425"></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=1787382663"></script>
|
<script src="/static/js/status.js?v=1788171425"></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