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
+111 -41
View File
@@ -16,13 +16,22 @@ info:
## Changelog
### 2.0
* POST `/spot` now supports upstream submission to the spotting services associated with various SIGs.
* **Breaking change:** The "add spot" API has changed to enable this: 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.
* POST `/spot` now supports Google reCaptcha and (if the site owner has set it up) now requires `captcha_token` in order to successfully submit. (This is used to lock down the submit function and prevent submission via Spothole by bots or third-party clients.)
* GET `/options` now returns `spot_submit_providers`, a map of SIG names to the names of providers that support upstream spot submission for that SIG. (This allows clients to present the user with options of where a new spot can be sent to.)
* **Breaking change:** A user's QRZ.com and HamQTH credentials are now supplied as request headers (`X-QRZ-Username`, `X-QRZ-Password`, `X-QRZ-Session-Key`, `X-HamQTH-Username`, `X-HamQTH-Password`, `X-HamQTH-Session-ID`) rather than query parameters, to keep credentials out of server logs.
* Added `sig_ref_data_providers`, `static_data_providers` and `callsign_data_providers` to `/status` response
* Added `callsign_data_providers` to `/options` response
* BREAKING: Removed `cleanup` from `/status` response
* BREAKING: in the `/options` response, renamed `spot_sources` and `alert_sources` to `spot_providers` and
* **Breaking change:** Removed `cleanup` from `/status` response
* **Breaking change:** in the `/options` response, renamed `spot_sources` and `alert_sources` to `spot_providers` and
`alert_providers`
### 1.5
No API changes.
### 1.4
* Spots can now include a "propagation_mode" field, and the `/options` call enumerates the options that can have.
@@ -52,9 +61,10 @@ info:
license:
name: The Unlicense
url: https://unlicense.org/#the-unlicense
version: v2.0
version: 2.0
servers:
- url: https://spothole.app/api/v1
- url: https://spothole.app/api/v2
tags:
- name: Spots
@@ -335,7 +345,8 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
/lookup/sigref:
@@ -363,7 +374,8 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
@@ -389,7 +401,8 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
/spot:
@@ -398,50 +411,53 @@ paths:
- Spots
summary: Add a spot
description: >
Supply a new spot object, which will be added to the system. Currently, this will not be
reported up the chain to a cluster, POTA, SOTA etc. This may be introduced in a future version.
cURL example: `curl --request POST --header "Content-Type: application/json" --data
'{"dx_call":"M0TRT","time":1760019539, "freq":14200000, "comment":"Test spot please ignore",
"de_call":"M0TRT"}' https://spothole.app/api/v1/spot`
Supply a JSON object containing a `spot` sub-object (the spot data) and an optional `handling` sub-object
containing server-side instructions such as upstream submission). Check `spot_submit_providers` in the
`/options` response to see which SIGs and providers support upstream submission. cURL example:
`curl --request POST --header \"Content-Type: application/json\" --data '{\"spot\":{\"dx_call\":\"M0TRT\",\"time\":1760019539,\"freq\":14200000,\"comment\":\"Test spot please ignore\",\"de_call\":\"M0TRT\"}}' https://spothole.app/api/v2/spot`"
operationId: spot
requestBody:
description: The JSON spot object
description: Object containing a "spot" sub-object with the spot data, and an optional "handling" sub-object with server-side instructions of what to do with it.
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Spot'
$ref: '#/components/schemas/SpotSubmission'
responses:
'200':
'201':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/OkResponse'
type: string
example: "OK"
'415':
description: Incorrect Content-Type
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
'422':
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
type: string
example: "Failed"
components:
parameters:
QrzUsername:
name: qrz_username
in: query
name: X-QRZ-Username
in: header
description: >
QRZ.com username for online callsign lookup, which will enrich the returned spots and alerts
with extra data. Requires a QRZ.com XML Subscriber (paid) account. Supply together with
@@ -449,14 +465,14 @@ components:
schema:
type: string
QrzPassword:
name: qrz_password
in: query
name: X-QRZ-Password
in: header
description: QRZ.com password. Supply together with `qrz_username`.
schema:
type: string
QrzSessionKey:
name: qrz_session_key
in: query
name: X-QRZ-Session-Key
in: header
description: >
A pre-obtained QRZ.com XML session key, as an alternative to supplying `qrz_username` and
`qrz_password`. See https://www.qrz.com/docs/xml/current_spec.html for details on how to
@@ -464,22 +480,22 @@ components:
schema:
type: string
HamqthUsername:
name: hamqth_username
in: query
name: X-HamQTH-Username
in: header
description: >
HamQTH username for online callsign lookup, which will enrich the returned spots and alerts
with extra data. Supply together with `hamqth_password`, or supply `hamqth_session_id` instead.
schema:
type: string
HamqthPassword:
name: hamqth_password
in: query
name: X-HamQTH-Password
in: header
description: HamQTH password. Supply together with `hamqth_username`.
schema:
type: string
HamqthSessionId:
name: hamqth_session_id
in: query
name: X-HamQTH-Session-ID
in: header
description: >
A pre-obtained HamQTH session ID, as an alternative to supplying `hamqth_username` and
`hamqth_password`. See https://www.hamqth.com/developers.php for details on how to retrieve
@@ -1190,6 +1206,54 @@ components:
$ref: "#/components/schemas/PropagationMode"
SpotSubmission:
description: >
Request body for POST /spot. Contains a "spot" sub-object with the spot data, and an optional
"handling" sub-object with server-side instructions consumed by Spothole.
type: object
required:
- spot
properties:
spot:
$ref: '#/components/schemas/Spot'
handling:
type: object
description: >
Optional server-side instructions for how to process this spot submission.
properties:
submit_upstream:
type: boolean
description: >
If true, forward the spot to an external upstream provider (e.g. POTA, SOTA) rather
than only adding it to this Spothole server. Requires `sig`, at least one `sig_refs`
entry, and `upstream_provider` to be set. Check `spot_submit_providers` in the
/options response to see which SIGs and providers support this.
default: false
upstream_provider:
type: string
description: >
Name of the upstream provider to submit the spot to, e.g. "POTA" or "SOTA". Must
match one of the provider names returned in `spot_submit_providers` for the chosen SIG.
example: POTA
upstream_credentials:
type: object
description: >
Provider-specific credentials required to authenticate the upstream submission.
The required keys depend on the provider. Credentials are used only for the upstream
call and are never stored by Spothole.
additionalProperties:
type: string
example:
user_id: "12345"
api_key: "abc123"
captcha_token:
type: string
description: >
A Google reCAPTCHA v2 response token. Required when submitting upstream if the
server has reCAPTCHA configured. Obtain the token by completing the reCAPTCHA
widget rendered on the Add Spot page.
example: "03AFY_a8Xq..."
SpotStream:
type: object
description: A server-sent event containing a spot
@@ -1810,14 +1874,6 @@ components:
items:
$ref: '#/components/schemas/Alert'
OkResponse:
type: string
example: "OK"
ErrorResponse:
type: string
example: "Failed"
DxStats:
type: object
description: Spot counts keyed by DE continent
@@ -1971,7 +2027,7 @@ components:
type: integer
description: >
The maximum age, in seconds, of any spot before it will be deleted by the system. When
querying the /api/v1/spots endpoint and providing a "max_age" or "since" parameter, there
querying the /api/v2/spots endpoint and providing a "max_age" or "since" parameter, there
is no point providing a number larger than this, because the system drops all spots older
than this.
example: 3600
@@ -1981,6 +2037,20 @@ components:
Whether the POST /spot call, to add spots to the server directly via its API, is permitted
on this server.
example: true
spot_submit_providers:
type: object
description: >
A map of SIG name to a list of provider names that support upstream spot submission for that SIG.
If a SIG appears as a key here, the POST /spot endpoint accepts `submit_upstream: true` for
spots with that SIG, and will forward the spot to one of the listed providers. Omitted if no
providers support upstream submission.
additionalProperties:
type: array
items:
type: string
example:
POTA: [ POTA ]
SOTA: [ SOTA, GMA, ParksNPeaks ]
CallsignData:
type: object
+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;
}