Merge branch '95-send-spots-to-xota' into 2.0-pre

# Conflicts:
#	README.md
#	core/config.py
#	server/handlers/api/addspot.py
#	server/handlers/api/lookups.py
#	server/handlers/api/options.py
#	server/webserver.py
#	spothole.py
#	static/apidocs/openapi.yml
This commit is contained in:
Ian Renton
2026-08-08 09:32:27 +01:00
38 changed files with 947 additions and 213 deletions
+219 -52
View File
@@ -1,7 +1,35 @@
// Credentials schema per provider name. Defines the fields to collect and how to label them.
const PROVIDER_CREDENTIAL_SCHEMAS = {
// todo Figure out SOTA authentication
// see e.g. https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAAccount.jsx
// https://github.com/ham2k/app-polo/blob/main/src/store/apis/apiSOTA/apiSOTA.js
// Refresh token? Way to show user that they need to log in again because cached credentials aren't valid?
// todo type: text/password distinction on text boxes so API keys can be obscured
"SOTA": [
{key: "access_token", label: "SOTA Access Token", help: ""},
{key: "id_token", label: "SOTA ID Token", help: "TODO SOTA authentication to provide this..."}
],
"ParksNPeaks": [
{key: "user_id", label: "Parks N Peaks User ID", help: ""},
{key: "api_key", label: "Parks N Peaks API Key", help: "Get your API key from your Parks N Peaks account."}
],
"ZLOTA": [
{key: "user_id", label: "ZLOTA User ID", help: ""},
{key: "api_key", label: "ZLOTA User PIN", help: "Get your PIN from your ZLOTA account."}
],
"Tiles": [
{
key: "offline_spot_gateway_pin",
label: "Offline Spot Gateway PIN",
help: "Get your PIN from your Tiles on the Air account profile."
}
]
};
// Load server options. Once a successful callback is made from this, we can populate the choice boxes in the form and load
// any saved values from local storage.
function loadOptions() {
$.getJSON('/api/v1/options', function (jsonData) {
$.getJSON('/api/v2/options', function (jsonData) {
// Store options
options = jsonData;
@@ -21,11 +49,144 @@ function loadOptions() {
}));
});
// Load reCAPTCHA if a site key is configured (key is inlined into page by server)
if (window._recaptchaSiteKey) {
loadRecaptcha(window._recaptchaSiteKey);
}
// Load settings from settings storage now all the controls are available
loadSettings();
// Update the upstream area for any pre-selected SIG
updateUpstreamArea();
});
}
// Load and inject the reCAPTCHA script
function loadRecaptcha(siteKey) {
window._recaptchaSiteKey = siteKey;
if (!document.getElementById('recaptcha-script')) {
const script = document.createElement('script');
script.id = 'recaptcha-script';
script.src = 'https://www.google.com/recaptcha/api.js?render=explicit&onload=renderRecaptcha';
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
$("#recaptcha-area").show();
}
// Called by reCAPTCHA after its script loads
function renderRecaptcha() {
window._recaptchaWidgetId = grecaptcha.render('recaptcha-widget', {
sitekey: window._recaptchaSiteKey,
size: 'normal'
});
}
// Update the "Send spot to..." area based on the currently selected SIG
function updateUpstreamArea() {
if (!window._allowUpstreamSpotting || !options || !options["spot_submit_providers"]) {
$("#upstream-area").hide();
return;
}
const sig = $("#sig").val();
const providers = (sig && options["spot_submit_providers"][sig]) ? options["spot_submit_providers"][sig] : [];
if (providers.length === 0) {
$("#upstream-area").hide();
return;
}
$("#upstream-area").show();
// Update the provider selector
$("#upstream-provider-select").empty();
$.each(providers, function (i, name) {
$("#upstream-provider-select").append($('<option>', {value: name, text: name}));
});
if (providers.length > 1) {
$("#upstream-provider-label").text("upstream spot sources:");
$("#upstream-provider-select-col").show();
} else {
$("#upstream-provider-label").text(providers[0]);
$("#upstream-provider-select-col").hide();
}
// Show the credentials button if this provider has an authentication mechanism and we need input from the user
updateCredentialsButton();
}
// Update the credentials button visibility based on selected provider
function updateCredentialsButton() {
const providerName = getSelectedUpstreamProvider();
if (providerName && PROVIDER_CREDENTIAL_SCHEMAS[providerName]) {
$("#upstream-credentials-btn").show();
} else {
$("#upstream-credentials-btn").hide();
}
}
// Get the currently selected upstream provider name
function getSelectedUpstreamProvider() {
const providers = (options && options["spot_submit_providers"] && $("#sig").val())
? (options["spot_submit_providers"][$("#sig").val()] || [])
: [];
if (providers.length === 0) return null;
if (providers.length === 1) return providers[0];
return $("#upstream-provider-select").val();
}
// Show the credentials modal for the currently selected upstream provider
function showCredentialsModal() {
const providerName = getSelectedUpstreamProvider();
if (!providerName || !PROVIDER_CREDENTIAL_SCHEMAS[providerName]) return;
const schema = PROVIDER_CREDENTIAL_SCHEMAS[providerName];
const stored = loadCredentials(providerName);
$("#credentials-provider-name").text(providerName);
$("#credentials-fields").empty();
$.each(schema, function (i, field) {
const val = stored[field.key] || "";
let html = '<div class="mb-3">';
html += '<label for="cred-' + field.key + '" class="form-label">' + field.label + '</label>';
html += '<input type="text" class="form-control" id="cred-' + field.key + '" value="' + $('<div>').text(val).html() + '">';
if (field.help) {
html += '<div class="form-text">' + field.help + '</div>';
}
html += '</div>';
$("#credentials-fields").append(html);
});
// Store provider name for saveCredentials()
$("#credentials-modal").data("provider", providerName);
new bootstrap.Modal(document.getElementById('credentials-modal')).show();
}
// Save credentials from the modal to local storage
function saveCredentials() {
const providerName = $("#credentials-modal").data("provider");
if (!providerName || !PROVIDER_CREDENTIAL_SCHEMAS[providerName]) return;
const schema = PROVIDER_CREDENTIAL_SCHEMAS[providerName];
const creds = {};
$.each(schema, function (i, field) {
creds[field.key] = $("#cred-" + field.key).val();
});
localStorage.setItem("upstream-credentials-" + providerName, JSON.stringify(creds));
bootstrap.Modal.getInstance(document.getElementById('credentials-modal')).hide();
}
// Load credentials for a provider from local storage
function loadCredentials(providerName) {
const stored = localStorage.getItem("upstream-credentials-" + providerName);
return stored ? JSON.parse(stored) : {};
}
// Method called to add a spot to the server
function addSpot() {
try {
@@ -42,57 +203,65 @@ function addSpot() {
const comment = $("#comment").val();
const de = $("#de-call").val().toUpperCase();
// Prepare the spot object for the server
const spot = {};
if (dx !== "") {
spot["dx_call"] = dx;
} else {
showAddSpotError("A DX callsign is required in order to spot.");
return;
}
if (freqStr !== "") {
spot["freq"] = parseFloat(freqStr) * 1000;
} else {
showAddSpotError("A frequency is required in order to spot.");
return;
}
if (mode !== "") {
spot["mode"] = mode;
}
if (sig !== "") {
spot["sig"] = sig;
}
if (sigRef !== "") {
spot["sig_refs"] = [{sig: sig, id: sigRef}];
}
if (dxGrid !== "") {
spot["dx_grid"] = dxGrid;
}
if (comment !== "") {
spot["comment"] = comment;
}
if (de !== "") {
spot["de_call"] = de;
} else {
showAddSpotError("A spotter callsign is required in order to spot.");
return;
}
spot["dx_call"] = dx;
spot["freq"] = parseFloat(freqStr) * 1000;
if (mode !== "") spot["mode"] = mode;
if (sig !== "") spot["sig"] = sig;
if (sigRef !== "") spot["sig_refs"] = [{sig: sig, id: sigRef}];
if (dxGrid !== "") spot["dx_grid"] = dxGrid;
if (comment !== "") spot["comment"] = comment;
spot["de_call"] = de;
spot["time"] = moment.utc().valueOf() / 1000.0;
$.ajax("/api/v1/spot", {
data: JSON.stringify(spot),
// Prepare "handling" structure to tell the server what to do with this spot
const handling = {};
// Add CAPTCHA token if reCAPTCHA is loaded
if (window._recaptchaWidgetId !== undefined) {
handling["captcha_token"] = grecaptcha.getResponse(window._recaptchaWidgetId);
}
// Upstream submission
const submitUpstream = $("#submit-upstream").is(":checked");
const upstreamProviderName = getSelectedUpstreamProvider();
if (submitUpstream && upstreamProviderName) {
handling["submit_upstream"] = true;
handling["upstream_provider"] = upstreamProviderName;
handling["upstream_credentials"] = loadCredentials(upstreamProviderName);
}
$.ajax("/api/v2/spot", {
data: JSON.stringify({spot, handling}),
contentType: 'application/json',
type: 'POST',
timeout: 10000,
success: async function () {
$("#result-good").html("<div class='alert alert-success fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-check'></i> Spot submitted. Returning you to the spots list...</div>");
success: async function (result) {
// Reset CAPTCHA for next use
if (window._recaptchaWidgetId !== undefined) {
grecaptcha.reset(window._recaptchaWidgetId);
}
if (result && result.startsWith && result.startsWith("Warning")) {
$("#result-good").html("<div class='alert alert-warning fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-triangle-exclamation'></i> " + result + " Returning you to the spots list...</div>");
} else {
$("#result-good").html("<div class='alert alert-success fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-check'></i> Spot submitted. Returning you to the spots list...</div>");
}
$("#result-bad").html("");
setTimeout(() => {
$("#result-good").hide();
window.location.replace("/");
}, 1000);
}, 2000);
},
error: function (result) {
showAddSpotError(result.responseText.slice(1, -1));
if (window._recaptchaWidgetId !== undefined) {
grecaptcha.reset(window._recaptchaWidgetId);
}
if (result.responseText) {
showAddSpotError(result.responseText.slice(1, -1));
} else {
showAddSpotError("The server did not return a response.");
}
}
});
} catch (error) {
@@ -121,20 +290,18 @@ $("#mode").change(function () {
$(this).val($(this).val().trim().toUpperCase());
});
// Display the intro box, unless the user has already dismissed it once.
function displayIntroBox() {
if (localStorage.getItem("add-spot-intro-box-dismissed") == null) {
$("#add-spot-intro-box").show();
}
$("#add-spot-intro-box-dismiss").click(function () {
localStorage.setItem("add-spot-intro-box-dismissed", true);
});
}
// Update upstream area and credentials button when SIG changes
$("#sig").change(function () {
updateUpstreamArea();
});
// Update credentials button when provider selector changes
$("#upstream-provider-select").change(function () {
updateCredentialsButton();
});
// Startup
$(document).ready(function () {
// Load options
loadOptions();
// Display intro box
displayIntroBox();
});
});
+4 -7
View File
@@ -8,7 +8,7 @@ let alerts = [];
// Load alerts and populate the table.
function loadAlerts() {
$.getJSON('/api/v1/alerts' + buildQueryString(false), function (jsonData) {
$.ajax({url: '/api/v2/alerts' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
// Store last updated time
lastUpdateTime = moment.utc();
updateRefreshDisplay();
@@ -16,11 +16,11 @@ function loadAlerts() {
alerts = jsonData;
// Update table
updateTable();
});
}});
}
// Build a query string for the API, based on the filters that the user has selected.
function buildQueryString(includeCredentials) {
function buildQueryString() {
let str = "?";
["dx_continent", "source"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) {
@@ -35,9 +35,6 @@ function buildQueryString(includeCredentials) {
if ($("#dxpeditions_skip_max_duration_check")[0].checked) {
str = str + "&dxpeditions_skip_max_duration_check=true";
}
if (includeCredentials) {
str = str + getCredentialQueryString();
}
return str;
}
@@ -282,7 +279,7 @@ function addAlertRowsToTable(tbody, alerts) {
// Load server options. Once a successful callback is made from this, we then query alerts.
function loadOptions() {
$.getJSON('/api/v1/options', function (jsonData) {
$.getJSON('/api/v2/options', function (jsonData) {
// Store options
options = jsonData;
+3 -3
View File
@@ -20,7 +20,7 @@ function loadSpots() {
if (evtSource != null) {
evtSource.close();
}
$.getJSON('/api/v1/spots' + buildQueryString(), function (jsonData) {
$.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
// Store data
spots = jsonData;
// Update bands display
@@ -35,7 +35,7 @@ function startSSEConnection() {
if (evtSource != null) {
evtSource.close();
}
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString());
evtSource = new EventSource('/api/v2/spots/stream' + buildQueryString());
evtSource.onmessage = function (event) {
const newSpot = JSON.parse(event.data);
@@ -273,7 +273,7 @@ function removeDuplicatesForBandPanel(spotList) {
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
// spots repeatedly.
function loadOptions() {
$.getJSON('/api/v1/options', function (jsonData) {
$.getJSON('/api/v2/options', function (jsonData) {
// Store options
options = jsonData;
+8 -8
View File
@@ -250,23 +250,23 @@ function closeDataPanel() {
closePanel("#data-area");
}
// Build a query string fragment containing any QRZ.com / HamQTH credentials the user has supplied,
// Build a headers object containing any QRZ.com / HamQTH credentials the user has supplied,
// provided the corresponding "enabled" checkbox is ticked.
function getCredentialQueryString() {
let str = "";
function getCredentialHeaders() {
const headers = {};
if ($("#qrz-enabled")[0] && $("#qrz-enabled")[0].checked) {
const qrzUsername = $("#qrz-username").val();
const qrzPassword = $("#qrz-password").val();
if (qrzUsername) str += "&qrz_username=" + encodeURIComponent(qrzUsername);
if (qrzPassword) str += "&qrz_password=" + encodeURIComponent(qrzPassword);
if (qrzUsername) headers["X-QRZ-Username"] = qrzUsername;
if (qrzPassword) headers["X-QRZ-Password"] = qrzPassword;
}
if ($("#hamqth-enabled")[0] && $("#hamqth-enabled")[0].checked) {
const hamqthUsername = $("#hamqth-username").val();
const hamqthPassword = $("#hamqth-password").val();
if (hamqthUsername) str += "&hamqth_username=" + encodeURIComponent(hamqthUsername);
if (hamqthPassword) str += "&hamqth_password=" + encodeURIComponent(hamqthPassword);
if (hamqthUsername) headers["X-HamQTH-Username"] = hamqthUsername;
if (hamqthPassword) headers["X-HamQTH-Password"] = hamqthPassword;
}
return str;
return headers;
}
+3 -3
View File
@@ -10,7 +10,7 @@ let ionosondeChart = null;
// Load solar conditions
function loadSolarConditions() {
$.getJSON('/api/v1/solar', function (jsonData) {
$.getJSON('/api/v2/solar', function (jsonData) {
// HF
@@ -539,7 +539,7 @@ function renderIonosondeData() {
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
ctx.setLineDash([]);
// Add an extra vertical line for 30MHz, which should correspond to the top of the chart and avoid having
// Add an extra horizontal line for 30MHz, which should correspond to the top of the chart and avoid having
// no top "border" gridline
const y30 = scales.y.getPixelForValue(30);
if (y30 >= chartArea.top && y30 <= chartArea.bottom) {
@@ -660,7 +660,7 @@ function dxStatsContientChanged() {
// Fetch DX stats from the API and render
function loadDxStats() {
$.getJSON('/api/v1/dxstats', function (jsonData) {
$.getJSON('/api/v2/dxstats', function (jsonData) {
dxStatsData = jsonData;
renderDxStats();
});
+4 -7
View File
@@ -45,7 +45,7 @@ function loadSpots() {
// 2) (If we have credentials) reload with them, replacing what's already there,
// 3) Subscribe to the SSE endpoint (with credentials if we have them) so that updates come with augmented
// data if they can.
$.getJSON('/api/v1/spots' + buildQueryString(false), function (jsonData) {
$.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
// Store data
spots = jsonData;
// Update map
@@ -68,7 +68,7 @@ function loadSpots() {
// now
startSSEConnection();
}
});
}});
}
// Start the SSE connection to receive new spots as they arrive
@@ -163,7 +163,7 @@ function removeSpotFromMap(key) {
}
// Build a query string for the API, based on the filters that the user has selected.
function buildQueryString(includeCredentials) {
function buildQueryString() {
let str = "?";
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) {
@@ -173,9 +173,6 @@ function buildQueryString(includeCredentials) {
str = str + "max_age=" + $("#max-spot-age option:selected").val();
// Additional filters for the map view: No dupes, no QRT, only spots with good locations
str = str + "&dedupe=true&allow_qrt=false";
if (includeCredentials) {
str = str + getCredentialQueryString();
}
return str;
}
@@ -304,7 +301,7 @@ function getTooltipText(s) {
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
// spots repeatedly.
function loadOptions() {
$.getJSON('/api/v1/options', function (jsonData) {
$.getJSON('/api/v2/options', function (jsonData) {
// Store options
options = jsonData;
+5 -8
View File
@@ -20,7 +20,7 @@ function loadSpots() {
}
// Make the new query
$.getJSON('/api/v1/spots' + buildQueryString(false), function (jsonData) {
$.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
// Store data
spots = jsonData;
// Update table
@@ -30,7 +30,7 @@ function loadSpots() {
if (run) {
startSSEConnection();
}
});
}});
}
// Start an SSE connection (closing an existing one if it exists). This will then be used to add to the table on the
@@ -39,7 +39,7 @@ function startSSEConnection() {
if (evtSource != null) {
evtSource.close();
}
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString(true));
evtSource = new EventSource('/api/v2/spots/stream' + buildQueryString());
evtSource.onmessage = function (event) {
// Get the new spot
@@ -86,7 +86,7 @@ function startSSEConnection() {
}
// Build a query string for the API, based on the filters that the user has selected.
function buildQueryString(includeCredentials) {
function buildQueryString() {
let str = "?";
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) {
@@ -97,9 +97,6 @@ function buildQueryString(includeCredentials) {
if ($("#search").val() !== "") {
str = str + "&text_includes=" + encodeURIComponent($("#search").val());
}
if (includeCredentials) {
str = str + getCredentialQueryString();
}
return str;
}
@@ -418,7 +415,7 @@ function createNewTableRowsForSpot(s, highlightNew) {
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
// spots repeatedly.
function loadOptions() {
$.getJSON('/api/v1/options', function (jsonData) {
$.getJSON('/api/v2/options', function (jsonData) {
// Store options
options = jsonData;
+1 -1
View File
@@ -1,6 +1,6 @@
// Load server status
function loadStatus() {
$.getJSON('/api/v1/status', function (jsonData) {
$.getJSON('/api/v2/status', function (jsonData) {
$("#software-version").text(jsonData["software-version"]);
$("#server-owner-callsign").text(jsonData["server-owner-callsign"]);
$("#up-since").text(moment().subtract(jsonData["uptime"], 'seconds').fromNow());
+84 -1
View File
@@ -296,6 +296,16 @@ function setBandColorScheme(scheme) {
return ret;
}
// Get the list of known bands
function getKnownBands() {
return Array.from(Object.keys(BAND_COLOR_SCHEMES[bandColorScheme]));
}
// Get the list of available band colour schemes
function getAvailableBandColorSchemes() {
return Array.from(Object.keys(BAND_COLOR_SCHEMES));
}
// Band name to colour (in the current colour scheme). If the band is unknown, black will be returned.
function bandToColor(band) {
let col = (band != null) ? BAND_COLOR_SCHEMES[bandColorScheme][band] : null;
@@ -314,6 +324,22 @@ function bandToContrastColor(band) {
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 = {
"POTA": "fa-tree",
"SOTA": "fa-mountain-sun",
@@ -340,6 +366,31 @@ const SIG_ICONS = {
"Toilets": "fa-toilet"
}
const SIG_NAMES = {
"POTA": "Parks on the Air",
"SOTA": "Summits on the Air",
"WWFF": "Worldwide Flora & Fauna",
"GMA": "Global Mountain Activity",
"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",
"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
function sigToIcon(sig, defaultIcon) {
let col = (sig != null) ? SIG_ICONS[sig] : null;
@@ -353,4 +404,36 @@ function sigToIcon(sig, defaultIcon) {
return 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;
}