mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-09-28 18:22:05 +00:00
Allow spotting upstream to cluster. #39
This commit is contained in:
+4
-3
@@ -350,9 +350,10 @@ max_alert_age_sec: 604800
|
|||||||
# Allow submitting spots to the Spothole API?
|
# Allow submitting spots to the Spothole API?
|
||||||
allow_spotting: true
|
allow_spotting: true
|
||||||
|
|
||||||
# Allow upstream submission of spots to external providers (POTA, SOTA, etc.) via the API?
|
# Allow upstream submission of spots to external providers via the API? Currently only DX cluster providers support
|
||||||
# Requires allow_spotting to also be true. Set to false to only accept spots into the local
|
# this, and will log in using the spotter's callsign to send the spot. Upstream spotting to POTA, SOTA etc. is a work
|
||||||
# Spothole database, without forwarding them to any external service.
|
# in progress. Requires allow_spotting to also be true. Set to false to only accept spots into the local Spothole
|
||||||
|
# database, without forwarding them to any external service.
|
||||||
allow_upstream_spotting: false
|
allow_upstream_spotting: false
|
||||||
|
|
||||||
# Google reCAPTCHA v2 keys for CAPTCHA protection on upstream spot submission. Both keys must be set to enable CAPTCHA.
|
# Google reCAPTCHA v2 keys for CAPTCHA protection on upstream spot submission. Both keys must be set to enable CAPTCHA.
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import re
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
import telnetlib3
|
||||||
|
|
||||||
from core.config import SERVER_OWNER_CALLSIGN
|
from core.config import SERVER_OWNER_CALLSIGN
|
||||||
|
from core.utils import decode_telnet_bytes
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
|
from providers.spot.spot_provider import SpotSubmissionError
|
||||||
from providers.spot.telnet_spot_provider import TelnetSpotProvider
|
from providers.spot.telnet_spot_provider import TelnetSpotProvider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -24,6 +27,9 @@ class DXCluster(TelnetSpotProvider):
|
|||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Timeout for each step of the telnet conversation when submitting a spot
|
||||||
|
_SUBMIT_TIMEOUT_SEC = 10
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
"""Constructor requires hostname and port"""
|
"""Constructor requires hostname and port"""
|
||||||
|
|
||||||
@@ -58,3 +64,73 @@ class DXCluster(TelnetSpotProvider):
|
|||||||
comment=match.group(4).strip(),
|
comment=match.group(4).strip(),
|
||||||
time=spot_datetime.timestamp(),
|
time=spot_datetime.timestamp(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def can_submit_spot(self, activity):
|
||||||
|
# Clusters accept spots for any activity, or for no activity at all.
|
||||||
|
return True
|
||||||
|
|
||||||
|
def submit_spot(self, spot, credentials):
|
||||||
|
"""Submit a spot to the cluster. This makes a new telnet connection to the same node that we receive spots from,
|
||||||
|
logs in as the spotter, sends the spot using the DX command, then logs out. If anything goes wrong we raise an
|
||||||
|
error which can then be logged by the caller and returned to the user as an error message in the API JSON / web
|
||||||
|
UI."""
|
||||||
|
|
||||||
|
command = self._build_dx_command(spot)
|
||||||
|
telnet = telnetlib3.Telnet(self._host, self._port, timeout=self._SUBMIT_TIMEOUT_SEC)
|
||||||
|
try:
|
||||||
|
# Wait for login prompt and log in
|
||||||
|
output = telnet.read_until(self._login_prompt.encode("latin-1"), timeout=self._SUBMIT_TIMEOUT_SEC)
|
||||||
|
if self._login_prompt.encode("latin-1") not in output:
|
||||||
|
logger.debug(f"{self.name} response while waiting for login prompt: {decode_telnet_bytes(output)}")
|
||||||
|
raise SpotSubmissionError(f"{self.name} did not give the expected login prompt.")
|
||||||
|
telnet.write(f"{spot.de_call}\n".encode("latin-1"))
|
||||||
|
|
||||||
|
# Wait for the command prompt. This is a line like "M0TRT de W3LPL 27-Sep-2026 0945Z dxspider >". We can't
|
||||||
|
# just wait for ">" as the welcome message might contain that character.
|
||||||
|
call = re.escape(spot.de_call).encode("latin-1")
|
||||||
|
prompt = re.compile(rb"(?im)^" + call + rb" de \S+.*>\s*$")
|
||||||
|
index, _, output = telnet.expect([prompt], timeout=self._SUBMIT_TIMEOUT_SEC)
|
||||||
|
if index < 0:
|
||||||
|
logger.debug(f"{self.name} response while waiting for command prompt: {decode_telnet_bytes(output)}")
|
||||||
|
raise SpotSubmissionError(f"{self.name} did not accept a login as {spot.de_call}.")
|
||||||
|
|
||||||
|
# Send the spot.
|
||||||
|
logger.debug(f"Submitting spot to {self.name} as {spot.de_call}: {command}")
|
||||||
|
telnet.write(f"{command}\n".encode("latin-1"))
|
||||||
|
|
||||||
|
# Log out
|
||||||
|
telnet.write(b"bye\n")
|
||||||
|
finally:
|
||||||
|
telnet.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_dx_command(spot):
|
||||||
|
"""Build the "DX <freq kHz> <dx call> <comment>" command used to submit a spot to the cluster. The mode, and
|
||||||
|
any activities and references e.g. "SSB POTA GB-1234 tnx 73"."""
|
||||||
|
|
||||||
|
freq = spot.freq
|
||||||
|
if not freq or not spot.dx_call:
|
||||||
|
raise ValueError("A frequency and DX callsign are required to submit a spot to a cluster.")
|
||||||
|
|
||||||
|
comment_parts = []
|
||||||
|
# Mode
|
||||||
|
if spot.mode:
|
||||||
|
comment_parts.append(spot.mode)
|
||||||
|
# Build list of activities with refs like "POTA GB-0001 SOTA G/SC-001"
|
||||||
|
activities_with_refs = set()
|
||||||
|
for activity_ref in spot.activity_refs:
|
||||||
|
if activity_ref.id:
|
||||||
|
comment_parts.append(f"{activity_ref.activity} {activity_ref.id}")
|
||||||
|
activities_with_refs.add(activity_ref.activity)
|
||||||
|
# If we have activities that don't have refs, add them to the comment
|
||||||
|
comment_parts.extend(str(a) for a in spot.activities if a not in activities_with_refs)
|
||||||
|
# Add the actual spot comment
|
||||||
|
if spot.comment:
|
||||||
|
comment_parts.append(spot.comment)
|
||||||
|
|
||||||
|
# Build command
|
||||||
|
command = f"DX {freq / 1000.0:.1f} {spot.dx_call} {' '.join(comment_parts)}"
|
||||||
|
|
||||||
|
# Strip anything that isn't printable ASCII, so that e.g. a newline in the comment can't be used to send
|
||||||
|
# exploit the system and send additional commands to the cluster as the spotter.
|
||||||
|
return re.sub(r"\s+", " ", re.sub(r"[^\x20-\x7E]", " ", command)).strip()
|
||||||
|
|||||||
@@ -158,8 +158,9 @@ class GMA(HTTPSpotProvider):
|
|||||||
|
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
|
# Upstream spotting disabled until implemented
|
||||||
def can_submit_spot(self, activity):
|
def can_submit_spot(self, activity):
|
||||||
return activity == ActivityName.GMA
|
return False # return activity == ActivityName.GMA
|
||||||
|
|
||||||
def submit_spot(self, spot, credentials):
|
def submit_spot(self, spot, credentials):
|
||||||
# TODO: Implement.
|
# TODO: Implement.
|
||||||
|
|||||||
@@ -89,8 +89,9 @@ class HEMA(HTTPSpotProvider):
|
|||||||
logger.warning("Connection error when accessing HEMA spots API.")
|
logger.warning("Connection error when accessing HEMA spots API.")
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
|
# Upstream spotting disabled until implemented
|
||||||
def can_submit_spot(self, activity):
|
def can_submit_spot(self, activity):
|
||||||
return activity == ActivityName.HEMA
|
return False # return activity == ActivityName.HEMA
|
||||||
|
|
||||||
def submit_spot(self, spot, credentials):
|
def submit_spot(self, spot, credentials):
|
||||||
# TODO: Implement. Currently blocked awaiting their API team to make a change to allow us to spot with a
|
# TODO: Implement. Currently blocked awaiting their API team to make a change to allow us to spot with a
|
||||||
|
|||||||
@@ -104,8 +104,9 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
|
# Upstream spotting disabled until properly tested
|
||||||
def can_submit_spot(self, activity):
|
def can_submit_spot(self, activity):
|
||||||
return activity in self.SUBMITTABLE_ACTIVITIES
|
return False # return activity in self.SUBMITTABLE_ACTIVITIES
|
||||||
|
|
||||||
def submit_spot(self, spot, credentials):
|
def submit_spot(self, spot, credentials):
|
||||||
# TODO test this works
|
# TODO test this works
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class POTA(HTTPSpotProvider):
|
|||||||
name=source_spot["name"],
|
name=source_spot["name"],
|
||||||
latitude=source_spot["latitude"],
|
latitude=source_spot["latitude"],
|
||||||
longitude=source_spot["longitude"],
|
longitude=source_spot["longitude"],
|
||||||
ref_type=ActivityRefType.PARK
|
ref_type=ActivityRefType.PARK,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S")
|
time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S")
|
||||||
@@ -57,8 +57,9 @@ class POTA(HTTPSpotProvider):
|
|||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
|
# Upstream spotting disabled until properly tested
|
||||||
def can_submit_spot(self, activity):
|
def can_submit_spot(self, activity):
|
||||||
return activity == ActivityName.POTA
|
return False # return activity == ActivityName.POTA
|
||||||
|
|
||||||
def submit_spot(self, spot, credentials):
|
def submit_spot(self, spot, credentials):
|
||||||
ref_id = spot.activity_refs[0].id if spot.activity_refs else None
|
ref_id = spot.activity_refs[0].id if spot.activity_refs else None
|
||||||
|
|||||||
@@ -83,8 +83,9 @@ class SOTA(HTTPSpotProvider):
|
|||||||
logger.warning("Timeout when accessing SOTA spots API.")
|
logger.warning("Timeout when accessing SOTA spots API.")
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
|
# Upstream spotting disabled until properly tested
|
||||||
def can_submit_spot(self, activity):
|
def can_submit_spot(self, activity):
|
||||||
return activity == ActivityName.SOTA
|
return False # return activity == ActivityName.SOTA
|
||||||
|
|
||||||
def submit_spot(self, spot, credentials):
|
def submit_spot(self, spot, credentials):
|
||||||
# TODO test this method works
|
# TODO test this method works
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import pytz
|
|||||||
from core.data_store import DATA_STORE
|
from core.data_store import DATA_STORE
|
||||||
|
|
||||||
|
|
||||||
|
class SpotSubmissionError(Exception):
|
||||||
|
"""Raised by a provider's submit_spot() when the upstream service refused or didn't accept the spot. The message
|
||||||
|
is returned in the API / showed on the web UI. This is probably a user error, so we log a warning rather than a big
|
||||||
|
stack trace like we would if we got some unexpected exception."""
|
||||||
|
|
||||||
|
|
||||||
class SpotProvider:
|
class SpotProvider:
|
||||||
"""Generic spot provider class. Subclasses of this query the individual APIs for data."""
|
"""Generic spot provider class. Subclasses of this query the individual APIs for data."""
|
||||||
|
|
||||||
@@ -61,7 +67,8 @@ class SpotProvider:
|
|||||||
raise NotImplementedError("Subclasses must implement this method")
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
|
|
||||||
def can_submit_spot(self, activity):
|
def can_submit_spot(self, activity):
|
||||||
"""Return True if this provider supports submitting spots upstream for the given activity."""
|
"""Return True if this provider supports submitting spots upstream for the given activity. Activity can be None,
|
||||||
|
to check whether this provider supports submitting spots that have no activity."""
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class Tiles(HTTPSpotProvider):
|
|||||||
name=source_spot["maidenhead_grid"],
|
name=source_spot["maidenhead_grid"],
|
||||||
latitude=source_spot["latitude"],
|
latitude=source_spot["latitude"],
|
||||||
longitude=source_spot["longitude"],
|
longitude=source_spot["longitude"],
|
||||||
ref_type=ActivityRefType.GRID
|
ref_type=ActivityRefType.GRID,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
time=datetime.fromisoformat(source_spot["created_at"].replace("Z", "+00:00")).timestamp(),
|
time=datetime.fromisoformat(source_spot["created_at"].replace("Z", "+00:00")).timestamp(),
|
||||||
@@ -84,8 +84,9 @@ class Tiles(HTTPSpotProvider):
|
|||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
|
# Upstream spotting disabled until properly tested
|
||||||
def can_submit_spot(self, activity):
|
def can_submit_spot(self, activity):
|
||||||
return activity == ActivityName.TILES
|
return False # return activity == ActivityName.TILES
|
||||||
|
|
||||||
def submit_spot(self, spot, credentials):
|
def submit_spot(self, spot, credentials):
|
||||||
# Tiles on the air currently only supports *self* spots
|
# Tiles on the air currently only supports *self* spots
|
||||||
|
|||||||
@@ -114,8 +114,9 @@ class WOTA(HTTPSpotProvider):
|
|||||||
|
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
|
# Upstream spotting disabled until implemented
|
||||||
def can_submit_spot(self, activity):
|
def can_submit_spot(self, activity):
|
||||||
return activity == ActivityName.WOTA
|
return False # return activity == ActivityName.WOTA
|
||||||
|
|
||||||
def submit_spot(self, spot, credentials):
|
def submit_spot(self, spot, credentials):
|
||||||
# TODO Ask M5TEA if he's happy to share how this is done from his app
|
# TODO Ask M5TEA if he's happy to share how this is done from his app
|
||||||
|
|||||||
@@ -52,8 +52,9 @@ class WWBOTA(SSESpotProvider):
|
|||||||
# WWBOTA does support a special "Test" spot type, we need to avoid adding that.
|
# WWBOTA does support a special "Test" spot type, we need to avoid adding that.
|
||||||
return spot if source_spot["type"] != "Test" else None
|
return spot if source_spot["type"] != "Test" else None
|
||||||
|
|
||||||
|
# Upstream spotting disabled until implemented
|
||||||
def can_submit_spot(self, activity):
|
def can_submit_spot(self, activity):
|
||||||
return activity == ActivityName.WWBOTA
|
return False # return activity == ActivityName.WWBOTA
|
||||||
|
|
||||||
def submit_spot(self, spot, credentials):
|
def submit_spot(self, spot, credentials):
|
||||||
# TODO: Implement. WWBOTA API docs cover this: https://api.wwbota.org/#tag/Spots/operation/create_spot_spots__post
|
# TODO: Implement. WWBOTA API docs cover this: https://api.wwbota.org/#tag/Spots/operation/create_spot_spots__post
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class WWFF(HTTPSpotProvider):
|
|||||||
name=source_spot["reference_name"],
|
name=source_spot["reference_name"],
|
||||||
latitude=source_spot["latitude"],
|
latitude=source_spot["latitude"],
|
||||||
longitude=source_spot["longitude"],
|
longitude=source_spot["longitude"],
|
||||||
ref_type=ActivityRefType.PARK
|
ref_type=ActivityRefType.PARK,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC).timestamp(),
|
time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC).timestamp(),
|
||||||
@@ -51,8 +51,9 @@ class WWFF(HTTPSpotProvider):
|
|||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
|
# Upstream spotting disabled until implemented
|
||||||
def can_submit_spot(self, activity):
|
def can_submit_spot(self, activity):
|
||||||
return activity == ActivityName.WWFF
|
return False # return activity == ActivityName.WWFF
|
||||||
|
|
||||||
def submit_spot(self, spot, credentials):
|
def submit_spot(self, spot, credentials):
|
||||||
# TODO: Implement. Spotting to WWFF should be possible, need to look up the Spotline docs or copy approach from
|
# TODO: Implement. Spotting to WWFF should be possible, need to look up the Spotline docs or copy approach from
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ class ZLOTA(HTTPSpotProvider):
|
|||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
|
# Upstream spotting disabled until implemented
|
||||||
def can_submit_spot(self, activity):
|
def can_submit_spot(self, activity):
|
||||||
return activity == ActivityName.ZLOTA
|
return False # return activity == ActivityName.ZLOTA
|
||||||
|
|
||||||
def submit_spot(self, spot, credentials):
|
def submit_spot(self, spot, credentials):
|
||||||
# TODO: Implement. Spotting to ZLOTA is supported via POST, see https://ontheair.nz/api
|
# TODO: Implement. Spotting to ZLOTA is supported via POST, see https://ontheair.nz/api
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ function addSpot() {
|
|||||||
data: JSON.stringify({spot, handling}),
|
data: JSON.stringify({spot, handling}),
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
timeout: 10000,
|
timeout: 60000,
|
||||||
success: async function (result) {
|
success: async function (result) {
|
||||||
// Reset CAPTCHA for next use
|
// Reset CAPTCHA for next use
|
||||||
if (window._recaptchaWidgetId !== undefined) {
|
if (window._recaptchaWidgetId !== undefined) {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ class TelnetServer:
|
|||||||
break
|
break
|
||||||
|
|
||||||
input_buffer, command = self._consume_input(input_buffer, data)
|
input_buffer, command = self._consume_input(input_buffer, data)
|
||||||
if command == "exit":
|
if command == "exit" or command == "bye":
|
||||||
writer.write(b"Goodbye!\r\n")
|
writer.write(b"Goodbye!\r\n")
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
# Exit the while read loop, this will disconnect the client.
|
# Exit the while read loop, this will disconnect the client.
|
||||||
|
|||||||
+10
-8
@@ -3,13 +3,15 @@
|
|||||||
|
|
||||||
<div id="add-spot-intro-box" class="permanently-dismissible-box mt-3">
|
<div id="add-spot-intro-box" class="permanently-dismissible-box mt-3">
|
||||||
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||||
<!-- TODO Remove when feature available -->
|
<i class="fa-solid fa-circle-info"></i> <strong>Adding spots to Spothole</strong><br/>
|
||||||
<i class="fa-solid fa-circle-info"></i> <strong>Adding spots to Spothole</strong><br/>This page is implemented
|
{% if web_ui_options["allow_upstream_spotting"] %}
|
||||||
as a proof of concept for adding spots to the Spothole system. Currently, spots added in this way are only
|
Spots added here are visible within Spothole. You can also choose to send your spot "upstream" to the DX
|
||||||
visible within Spothole and are not sent "upstream" to DX clusters or xOTA spotting sites. The functionality
|
cluster, in which case it is posted using the callsign you enter in "Your Call". Sending spots upstream to other
|
||||||
might be extended to include this in future if there is demand for it. If you'd like this to be added, please
|
spotting sites such as POTA and SOTA is currently a work in progress.
|
||||||
give a thumbs-up on <a href="https://git.ianrenton.com/ian/spothole/issues/39" target="_new" class="alert-link">issue
|
{% else %}
|
||||||
#39</a> or get in touch via email.
|
Spots added here are only visible within Spothole. This server does not allow spots to be sent "upstream" to DX
|
||||||
|
clusters or other spotting sites such as POTA and SOTA.
|
||||||
|
{% end %}
|
||||||
<button type="button" id="add-spot-intro-box-dismiss" class="btn-close" data-bs-dismiss="alert"
|
<button type="button" id="add-spot-intro-box-dismiss" class="btn-close" data-bs-dismiss="alert"
|
||||||
aria-label="Close"></button>
|
aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,7 +138,7 @@
|
|||||||
const ALLOW_UPSTREAM_SPOTTING = {% raw safe_json_dumps(web_ui_options["allow_upstream_spotting"]) %};
|
const ALLOW_UPSTREAM_SPOTTING = {% raw safe_json_dumps(web_ui_options["allow_upstream_spotting"]) %};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="/static/js/add-spot.js?v=1790443101"></script>
|
<script src="/static/js/add-spot.js?v=1790503760"></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>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/alerts.js?v=1790443102"></script>
|
<script src="/static/js/alerts.js?v=1790503760"></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>
|
||||||
|
|||||||
@@ -82,8 +82,8 @@
|
|||||||
const BANDS = {% raw safe_json_dumps(options["bands"]) %};
|
const BANDS = {% raw safe_json_dumps(options["bands"]) %};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="/static/js/spotsbandsandmap.js?v=1790443101"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1790503760"></script>
|
||||||
<script src="/static/js/bands.js?v=1790443101"></script>
|
<script src="/static/js/bands.js?v=1790503760"></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=1790443101" type="text/css">
|
<link rel="stylesheet" href="/static/css/style.css?v=1790503760" type="text/css">
|
||||||
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
|
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
|
||||||
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
|
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
|
||||||
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
|
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
window.fetchEventSource = fetchEventSource;
|
window.fetchEventSource = fetchEventSource;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="/static/js/utils.js?v=1790443101"></script>
|
<script src="/static/js/utils.js?v=1790503760"></script>
|
||||||
<script src="/static/js/ui-ham.js?v=1790443101"></script>
|
<script src="/static/js/ui-ham.js?v=1790503760"></script>
|
||||||
<script src="/static/js/geo.js?v=1790443101"></script>
|
<script src="/static/js/geo.js?v=1790503760"></script>
|
||||||
<script src="/static/js/common.js?v=1790443101"></script>
|
<script src="/static/js/common.js?v=1790503760"></script>
|
||||||
{% end %}
|
{% end %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -284,7 +284,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
|
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
|
||||||
<script src="/static/js/conditions.js?v=1790443101"></script>
|
<script src="/static/js/conditions.js?v=1790503760"></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>
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
data into a traditional desktop logging program. The data Spothole produces is compatible with DXSpider, and
|
data into a traditional desktop logging program. The data Spothole produces is compatible with DXSpider, and
|
||||||
therefore many loggers should accept it with no problems.</p>
|
therefore many loggers should accept it with no problems.</p>
|
||||||
<p>This is a relatively new feature however, so if you run into problems, please do let me know.</p>
|
<p>This is a relatively new feature however, so if you run into problems, please do let me know.</p>
|
||||||
<p>The one caveat with the Spothole telnet server is that <em>it does not support any commands</em>, other than <code>exit</code>.
|
<p>The one caveat with the Spothole telnet server is that <em>it does not support any commands</em>, other than
|
||||||
|
<code>exit</code> or <code>bye</code>.
|
||||||
You cannot therefore issue commands to retrieve past entries, set up filters, etc. If you need to filter the data,
|
You cannot therefore issue commands to retrieve past entries, set up filters, etc. If you need to filter the data,
|
||||||
many desktop logging programs support this client-side, so hopefully this is not too big of an obstacle.
|
many desktop logging programs support this client-side, so hopefully this is not too big of an obstacle. You also
|
||||||
|
can't currently send spots to Spothole using the Telnet server.
|
||||||
</p>
|
</p>
|
||||||
<p>To access Spothole via telnet, set up your logger with the server address <code>{{ telnet_server_address }}</code>
|
<p>To access Spothole via telnet, set up your logger with the server address <code>{{ telnet_server_address }}</code>
|
||||||
and port <code>{{ telnet_server_port }}</code>. You can also access it from a terminal with <code>telnet {{
|
and port <code>{{ telnet_server_port }}</code>. You can also access it from a terminal with <code>telnet {{
|
||||||
|
|||||||
@@ -127,7 +127,10 @@
|
|||||||
|
|
||||||
<h3><i class="fa-solid fa-comment"></i> Add Spot</h3>
|
<h3><i class="fa-solid fa-comment"></i> Add Spot</h3>
|
||||||
|
|
||||||
<p>The Add Spot page allows you to add a new spot into the Spothole system directly. Currently, this stays within
|
<p>The Add Spot page allows you to add a new spot into the Spothole system directly. If the server allows it, a "Send
|
||||||
Spothole and is not sent "upstream" to DX clusters, POTA, SOTA etc. regardless of any activity you select.</p>
|
spot to..." checkbox will also appear, allowing you to send your spot "upstream" to the DX cluster that Spothole
|
||||||
|
receives spots from. Spothole will log in to the cluster using the callsign you entered in "Your Call", so if you
|
||||||
|
are already connected to the same cluster node with that callsign, you may need to use a different SSID. Sending
|
||||||
|
spots upstream to POTA, SOTA etc. is not yet supported.</p>
|
||||||
|
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|||||||
+2
-2
@@ -115,8 +115,8 @@
|
|||||||
const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}";
|
const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="/static/js/spotsbandsandmap.js?v=1790443102"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1790503760"></script>
|
||||||
<script src="/static/js/map.js?v=1790443102"></script>
|
<script src="/static/js/map.js?v=1790503760"></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>
|
||||||
|
|||||||
@@ -127,8 +127,8 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/spotsbandsandmap.js?v=1790443101"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1790503760"></script>
|
||||||
<script src="/static/js/spots.js?v=1790443101"></script>
|
<script src="/static/js/spots.js?v=1790503760"></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>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/status.js?v=1790443101"></script>
|
<script src="/static/js/status.js?v=1790503760"></script>
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$("#nav-link-status").addClass("active");
|
$("#nav-link-status").addClass("active");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any
|
|||||||
import requests
|
import requests
|
||||||
import tornado
|
import tornado
|
||||||
from tornado import httputil
|
from tornado import httputil
|
||||||
|
from tornado.ioloop import IOLoop
|
||||||
from tornado.web import Application
|
from tornado.web import Application
|
||||||
|
|
||||||
from core.activity_utils import get_ref_regex_for_activity
|
from core.activity_utils import get_ref_regex_for_activity
|
||||||
@@ -13,7 +14,9 @@ from core.config import ALLOW_SPOTTING, ALLOW_UPSTREAM_SPOTTING, RECAPTCHA_SECRE
|
|||||||
from core.constants import UNKNOWN_BAND
|
from core.constants import UNKNOWN_BAND
|
||||||
from core.utils import infer_band_from_freq, safe_json_dumps
|
from core.utils import infer_band_from_freq, safe_json_dumps
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from providers.spot.spot_provider import SpotProvider
|
from providers.spot.dxcluster import DXCluster
|
||||||
|
from providers.spot.spot_provider import SpotProvider, SpotSubmissionError
|
||||||
|
from providers.spot.tiles import Tiles
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -37,7 +40,10 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
self._spots = spots
|
self._spots = spots
|
||||||
self._spot_providers = spot_providers or []
|
self._spot_providers = spot_providers or []
|
||||||
|
|
||||||
def post(self):
|
async def post(self):
|
||||||
|
"""Handle the post request with spot data. This is async because it could go on to submit spots to providers
|
||||||
|
which could be slow, so we need to stop it blocking the whole web server"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Reject if not allowed
|
# Reject if not allowed
|
||||||
if not ALLOW_SPOTTING:
|
if not ALLOW_SPOTTING:
|
||||||
@@ -77,10 +83,6 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
upstream_credentials = handling.get("upstream_credentials", {})
|
upstream_credentials = handling.get("upstream_credentials", {})
|
||||||
captcha_token = handling.get("captcha_token", None)
|
captcha_token = handling.get("captcha_token", None)
|
||||||
|
|
||||||
# Spothole v2.0 release only: deny upstream spotting. Spothole API breaking changes were in v2.0 but
|
|
||||||
# functionality is not ready yet. TODO
|
|
||||||
submit_upstream = False
|
|
||||||
|
|
||||||
# Verify CAPTCHA if required
|
# Verify CAPTCHA if required
|
||||||
if RECAPTCHA_SECRET_KEY:
|
if RECAPTCHA_SECRET_KEY:
|
||||||
if not captcha_token:
|
if not captcha_token:
|
||||||
@@ -89,7 +91,7 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
self.set_header("Cache-Control", "no-store")
|
self.set_header("Cache-Control", "no-store")
|
||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
return
|
return
|
||||||
if not self._verify_recaptcha(captcha_token):
|
if not await IOLoop.current().run_in_executor(None, self._verify_recaptcha, captcha_token):
|
||||||
self.set_status(422)
|
self.set_status(422)
|
||||||
self.write(safe_json_dumps("Error - CAPTCHA verification failed."))
|
self.write(safe_json_dumps("Error - CAPTCHA verification failed."))
|
||||||
self.set_header("Cache-Control", "no-store")
|
self.set_header("Cache-Control", "no-store")
|
||||||
@@ -166,20 +168,15 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
|
|
||||||
# Validate upstream submission requirements
|
# Validate upstream submission requirements
|
||||||
if submit_upstream and upstream_provider_name:
|
if submit_upstream and upstream_provider_name:
|
||||||
if not spot.activities:
|
is_cluster = isinstance(self._find_provider(upstream_provider_name, spot.activities), DXCluster)
|
||||||
# TODO when we allow spotting to cluster upstream, we need to remove this restriction
|
is_tiles = isinstance(self._find_provider(upstream_provider_name, spot.activities), Tiles)
|
||||||
self.set_status(422)
|
if not spot.activity_refs and not is_tiles and not is_cluster:
|
||||||
self.write(safe_json_dumps("Error - an activity must be selected to submit upstream."))
|
|
||||||
self.set_header("Cache-Control", "no-store")
|
|
||||||
self.set_header("Content-Type", "application/json")
|
|
||||||
return
|
|
||||||
if not spot.activity_refs and upstream_provider_name != "Tiles":
|
|
||||||
self.set_status(422)
|
self.set_status(422)
|
||||||
self.write(safe_json_dumps("Error - an activity reference is required to submit upstream."))
|
self.write(safe_json_dumps("Error - an activity reference is required to submit upstream."))
|
||||||
self.set_header("Cache-Control", "no-store")
|
self.set_header("Cache-Control", "no-store")
|
||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
return
|
return
|
||||||
if not spot.dx_grid and upstream_provider_name == "Tiles":
|
if not spot.dx_grid and is_tiles:
|
||||||
self.set_status(422)
|
self.set_status(422)
|
||||||
self.write(
|
self.write(
|
||||||
safe_json_dumps("Error - a grid reference is required to submit upstream to Tiles on the Air.")
|
safe_json_dumps("Error - a grid reference is required to submit upstream to Tiles on the Air.")
|
||||||
@@ -187,7 +184,7 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
self.set_header("Cache-Control", "no-store")
|
self.set_header("Cache-Control", "no-store")
|
||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
return
|
return
|
||||||
if not spot.mode and upstream_provider_name == "Tiles":
|
if not spot.mode and is_tiles:
|
||||||
self.set_status(422)
|
self.set_status(422)
|
||||||
self.write(safe_json_dumps("Error - a mode is required to submit upstream to Tiles on the Air."))
|
self.write(safe_json_dumps("Error - a mode is required to submit upstream to Tiles on the Air."))
|
||||||
self.set_header("Cache-Control", "no-store")
|
self.set_header("Cache-Control", "no-store")
|
||||||
@@ -200,19 +197,29 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
provider = self._find_provider(upstream_provider_name, spot.activities)
|
provider = self._find_provider(upstream_provider_name, spot.activities)
|
||||||
if provider:
|
if provider:
|
||||||
try:
|
try:
|
||||||
# Submit spot to the upstream provider
|
# Submit spot to the upstream provider. Run in a separate thread otherwise this blocks the whole
|
||||||
provider.submit_spot(spot, upstream_credentials)
|
# web server for everyone!
|
||||||
# Trigger a re-poll after 1 second so the spot appears quickly
|
await IOLoop.current().run_in_executor(None, provider.submit_spot, spot, upstream_credentials)
|
||||||
threading.Timer(1.0, provider.force_poll).start()
|
# Trigger a re-poll after 3 second so the spot appears quickly. (Submitting to a cluster node is
|
||||||
|
# slower than this, but we get data as a live stream from cluster anyway, so force_poll does
|
||||||
|
# nothing in that case. This is really just for the HTTP providers when we submit a spot to them)
|
||||||
|
threading.Timer(3.0, provider.force_poll).start()
|
||||||
except NotImplementedError as e:
|
except NotImplementedError as e:
|
||||||
upstream_warning = str(e)
|
upstream_warning = str(e)
|
||||||
|
except SpotSubmissionError as e:
|
||||||
|
logger.warning(f"Upstream submission to {upstream_provider_name} was not accepted: {e}")
|
||||||
|
upstream_warning = (
|
||||||
|
f"Spot was saved locally but upstream submission to {upstream_provider_name} failed: {e}"
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"Failed to submit spot upstream to {upstream_provider_name}")
|
logger.exception(f"Failed to submit spot upstream to {upstream_provider_name}")
|
||||||
upstream_warning = (
|
upstream_warning = (
|
||||||
f"Spot was saved locally but upstream submission to {upstream_provider_name} failed."
|
f"Spot was saved locally but upstream submission to {upstream_provider_name} failed."
|
||||||
)
|
)
|
||||||
else:
|
elif spot.activities:
|
||||||
upstream_warning = f"No enabled provider named '{upstream_provider_name}' supports upstream submission for {', '.join(spot.activities)} spots."
|
upstream_warning = f"No enabled provider named '{upstream_provider_name}' supports upstream submission for {', '.join(spot.activities)} spots."
|
||||||
|
else:
|
||||||
|
upstream_warning = f"No enabled provider named '{upstream_provider_name}' supports upstream submission for spots with no activity."
|
||||||
|
|
||||||
# If we successfully submitted the spot upstream, don't add it direct to Spothole, otherwise it will be a
|
# If we successfully submitted the spot upstream, don't add it direct to Spothole, otherwise it will be a
|
||||||
# duplicate with what immediately comes back from the API. But if we weren't asked to send it upstream, or
|
# duplicate with what immediately comes back from the API. But if we weren't asked to send it upstream, or
|
||||||
@@ -239,10 +246,11 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
|
|
||||||
def _find_provider(self, provider_name, activities) -> SpotProvider | None:
|
def _find_provider(self, provider_name, activities) -> SpotProvider | None:
|
||||||
"""Find an enabled provider by name that can submit spots for at least one of the given activities."""
|
"""Find an enabled provider by name that can submit spots for at least one of the given activities. If there
|
||||||
|
are no activities, find one that can submit spots with no activity."""
|
||||||
|
|
||||||
for p in self._spot_providers:
|
for p in self._spot_providers:
|
||||||
if p.enabled and p.name == provider_name and any(p.can_submit_spot(a) for a in activities):
|
if p.enabled and p.name == provider_name and any(p.can_submit_spot(a) for a in activities or [None]):
|
||||||
return p
|
return p
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -21,16 +21,18 @@ def build_options():
|
|||||||
"""Build the options data that is returned by the /options API and used to populate the web UI templates. This
|
"""Build the options data that is returned by the /options API and used to populate the web UI templates. This
|
||||||
doesn't change at runtime, so it is built once on first use and then cached."""
|
doesn't change at runtime, so it is built once on first use and then cached."""
|
||||||
|
|
||||||
# Build a map of activity name -> list of provider names that can submit spots for that activity
|
# Build a map of activity name -> list of provider names that can submit spots for that activity. Spots with no
|
||||||
|
# activity use the empty string as the key as that's effectively what is in the "blank" entry in the drop-down
|
||||||
|
# on the Add Spot page
|
||||||
spot_submit_providers = {}
|
spot_submit_providers = {}
|
||||||
|
for provider in DATA_PROVIDERS.spot_providers:
|
||||||
# Spothole v2.0 - disable this for now, API changes are in but this functionality is not ready yet. TODO
|
if not provider.enabled:
|
||||||
# for provider in DATA_PROVIDERS.spot_providers:
|
continue
|
||||||
# if not provider.enabled:
|
for activity_name in [None, *(activity.name for activity in ACTIVITIES.values())]:
|
||||||
# continue
|
if provider.can_submit_spot(activity_name):
|
||||||
# for activity in ACTIVITIES.values():
|
names = spot_submit_providers.setdefault(activity_name or "", [])
|
||||||
# if provider.can_submit_spot(activity.name):
|
if provider.name not in names:
|
||||||
# spot_submit_providers.setdefault(activity.name, []).append(provider.name)
|
names.append(provider.name)
|
||||||
|
|
||||||
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle
|
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle
|
||||||
# things that aren't even available.
|
# things that aren't even available.
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ class WebServer:
|
|||||||
APISpotHandler,
|
APISpotHandler,
|
||||||
{
|
{
|
||||||
"spots": self._data_store.spots,
|
"spots": self._data_store.spots,
|
||||||
"spot_providers": self._data_providers,
|
"spot_providers": self._data_providers.spot_providers,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -226,7 +226,7 @@ class WebServer:
|
|||||||
V2APISpotHandler,
|
V2APISpotHandler,
|
||||||
{
|
{
|
||||||
"spots": self._data_store.spots,
|
"spots": self._data_store.spots,
|
||||||
"spot_providers": self._data_providers,
|
"spot_providers": self._data_providers.spot_providers,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -277,7 +277,7 @@ class WebServer:
|
|||||||
V1APISpotHandler,
|
V1APISpotHandler,
|
||||||
{
|
{
|
||||||
"spots": self._data_store.spots,
|
"spots": self._data_store.spots,
|
||||||
"spot_providers": self._data_providers,
|
"spot_providers": self._data_providers.spot_providers,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user