mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-08-06 10:31:42 +00:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecab8d6d16 | ||
|
|
0cea777616 | ||
|
|
3c4c595288 | ||
|
|
555a8418a9 | ||
|
|
7ee08e2fcd | ||
|
|
4353c9ebf9 | ||
|
|
4a9ff2c636 | ||
|
|
e7253c0916 | ||
|
|
ba08555a30 | ||
|
|
374a326874 | ||
|
|
c397009ada | ||
|
|
66010dc682 | ||
|
|
2fc9658571 | ||
|
|
34e3933d5d | ||
|
|
eee0d70140 | ||
|
|
600a330e63 | ||
|
|
f2a6bfa515 | ||
|
|
4d29e0026f | ||
|
|
c7b6163440 | ||
|
|
c51648738f | ||
|
|
88923bc96e | ||
|
|
565a3f7564 | ||
|
|
b09108b41b | ||
|
|
72f367f607 | ||
|
|
998b394d2c | ||
|
|
d1df772649 | ||
|
|
215b61593b | ||
|
|
eb1d575623 | ||
|
|
e4c3a52299 |
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding">
|
||||||
|
<file url="file://$PROJECT_DIR$/datafiles/MUNICIPIOS.csv" charset="ISO-8859-1" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+1
@@ -2,6 +2,7 @@
|
|||||||
<module type="PYTHON_MODULE" version="4">
|
<module type="PYTHON_MODULE" version="4">
|
||||||
<component name="NewModuleRootManager">
|
<component name="NewModuleRootManager">
|
||||||
<content url="file://$MODULE_DIR$">
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/webassets/vendor" />
|
<excludeFolder url="file://$MODULE_DIR$/webassets/vendor" />
|
||||||
</content>
|
</content>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), th
|
|||||||
SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, LLOTA, WWTOTA, Tiles on the Air, the UK Packet
|
SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, LLOTA, WWTOTA, Tiles on the Air, the UK Packet
|
||||||
Repeater Network, NG3K, and any site based on the xOTA software by nischu.
|
Repeater Network, NG3K, and any site based on the xOTA software by nischu.
|
||||||
|
|
||||||
|
Additional Special Interest Groups (SIGs) without their own specific data source include WAB, WAI and DME.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
@@ -93,9 +95,9 @@ Various approaches exist to writing your own client, but in general:
|
|||||||
`https://spothole.app/api/v1/spots` once every few minutes. Apply filters if necessary.
|
`https://spothole.app/api/v1/spots` once every few minutes. Apply filters if necessary.
|
||||||
* Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do that
|
* Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do that
|
||||||
first before calling the spots/alerts APIs, to allow you to populate your filters correctly.
|
first before calling the spots/alerts APIs, to allow you to populate your filters correctly.
|
||||||
* Refer to the provided HTML/JS interface for a reference on different approaches. For example, the "map" and "bands"
|
* Refer to the provided HTML/JS interface for a reference on different approaches. For example, the "alerts"/"upcoming"
|
||||||
pages simply query the main spot API on a timer, whereas the main/spots page combines this approach with using the
|
page simply query the main spot API on a timer, whereas the spots, map and bands pages combine this approach with
|
||||||
Server-Sent Events (SSE) endpoint to update live.
|
using the Server-Sent Events (SSE) endpoint to update live.
|
||||||
* Let me know if you get stuck, I'm happy to help.
|
* Let me know if you get stuck, I'm happy to help.
|
||||||
|
|
||||||
Please don't hammer the API with an unnecessarily high request rate. For example, Spothole only queries the POTA API
|
Please don't hammer the API with an unnecessarily high request rate. For example, Spothole only queries the POTA API
|
||||||
@@ -316,33 +318,49 @@ To set up nginx as a reverse proxy that sits in front of Spothole, first ensure
|
|||||||
|
|
||||||
Create a file at `/etc/nginx/sites-available/` called `spothole`. Give it the following contents, replacing
|
Create a file at `/etc/nginx/sites-available/` called `spothole`. Give it the following contents, replacing
|
||||||
`spothole.app` with the domain name on which you want to run Spothole. If you changed the port on which Spothole runs,
|
`spothole.app` with the domain name on which you want to run Spothole. If you changed the port on which Spothole runs,
|
||||||
update that on the "proxy_pass" line too.
|
update that on the "proxy_pass" line, and if you installed Spothole somewhere other than `/home/spothole/spothole`,
|
||||||
|
adjust the alias location for serving static files.
|
||||||
|
|
||||||
|
(The latter section, configuring the nginx server to serve static files directly, improves efficiency because it saves
|
||||||
|
Spothole itself from serving JS, CSS etc. files. If you can't do this for some reason, e.g. your nginx and spothole are
|
||||||
|
on different computers, you can omit the `location /static/ {}` block.)
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
server_name spothole.app;
|
server_name spothole.app;
|
||||||
|
|
||||||
|
# Global proxy settings
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_buffering on;
|
||||||
|
|
||||||
|
# Pass on IP address and host information to Spothole, in case logging this information is required
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
# Wellknown area for Lets Encrypt
|
# Wellknown area for Lets Encrypt
|
||||||
location /.well-known/ {
|
location /.well-known/ {
|
||||||
alias /var/www/html/.well-known/;
|
alias /var/www/html/.well-known/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Load static assets directly from the Spothole webassets directory
|
||||||
|
location /static/ {
|
||||||
|
alias /home/spothole/spothole/webassets/;
|
||||||
|
expires 1h;
|
||||||
|
add_header Cache-Control "public, max-age=3600, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
# SSE endpoints
|
# SSE endpoints
|
||||||
location ~ ^/api/v1/(spots|alerts)/stream {
|
location ~ ^/api/v1/(spots|alerts)/stream/? {
|
||||||
proxy_pass http://127.0.0.1:8080;
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
|
||||||
# Allow keep-alive
|
# Remove buffering, remove caching, add suitable timeouts for SSE API calls
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Connection "";
|
|
||||||
|
|
||||||
# Set correct content type for SSE API calls
|
|
||||||
add_header Content-Type text/event-stream always;
|
|
||||||
|
|
||||||
# Set remove buffering, remove caching, add suitable timeouts for SSE API calls
|
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
proxy_read_timeout 24h;
|
proxy_read_timeout 24h;
|
||||||
proxy_connect_timeout 10s;
|
|
||||||
proxy_send_timeout 24h;
|
proxy_send_timeout 24h;
|
||||||
proxy_set_header X-Accel-Buffering no;
|
proxy_set_header X-Accel-Buffering no;
|
||||||
add_header Cache-Control no-store always;
|
add_header Cache-Control no-store always;
|
||||||
@@ -350,59 +368,28 @@ server {
|
|||||||
# Allow cross-origin requests to API
|
# Allow cross-origin requests to API
|
||||||
proxy_hide_header Access-Control-Allow-Origin;
|
proxy_hide_header Access-Control-Allow-Origin;
|
||||||
add_header Access-Control-Allow-Origin * always;
|
add_header Access-Control-Allow-Origin * always;
|
||||||
|
|
||||||
# Pass on IP address and host information to Spothole, in case logging this information is required
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header Host $http_host;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Other API endpoints
|
# Other API endpoints
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://127.0.0.1:8080;
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
|
||||||
# Allow keep-alive
|
# Remove buffering, remove caching, add suitable timeouts for API calls
|
||||||
proxy_http_version 1.1;
|
proxy_buffering off;
|
||||||
proxy_set_header Connection "";
|
|
||||||
|
|
||||||
# Set up buffering, remove caching, add suitable timeouts for API calls
|
|
||||||
proxy_buffering on;
|
|
||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
proxy_read_timeout 30s;
|
proxy_read_timeout 30s;
|
||||||
proxy_connect_timeout 10s;
|
|
||||||
add_header Cache-Control no-store always;
|
add_header Cache-Control no-store always;
|
||||||
|
|
||||||
# Allow cross-origin requests to API
|
# Allow cross-origin requests to API
|
||||||
proxy_hide_header Access-Control-Allow-Origin;
|
proxy_hide_header Access-Control-Allow-Origin;
|
||||||
add_header Access-Control-Allow-Origin * always;
|
add_header Access-Control-Allow-Origin * always;
|
||||||
|
|
||||||
# Pass on IP address and host information to Spothole, in case logging this information is required
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header Host $http_host;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Static assets
|
# Templated pages
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8080;
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
|
||||||
# Allow keep-alive
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Connection "";
|
|
||||||
|
|
||||||
# Set up buffering and caching, add suitable timeouts for static asset requests
|
|
||||||
proxy_buffering on;
|
|
||||||
proxy_read_timeout 30s;
|
proxy_read_timeout 30s;
|
||||||
proxy_connect_timeout 10s;
|
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||||
add_header Cache-Control "public, max-age=3600, must-revalidate" always;
|
|
||||||
|
|
||||||
# Pass on IP address and host information to Spothole, in case logging this information is required
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header Host $http_host;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -465,7 +452,7 @@ To navigate your way around the source code, this list may help.
|
|||||||
|
|
||||||
*HTML/JS/CSS front-end code*
|
*HTML/JS/CSS front-end code*
|
||||||
|
|
||||||
* `/webassets` - Root for static files served by the web server
|
* `/webassets` - Root for static files served by the web server. These are all served from a path starting `/static/`.
|
||||||
* `/webassets/apidocs` - Contains the OpenAPI spec (`openapi.yml`)
|
* `/webassets/apidocs` - Contains the OpenAPI spec (`openapi.yml`)
|
||||||
* `/webassets/css` - CSS files used by the web front-end
|
* `/webassets/css` - CSS files used by the web front-end
|
||||||
* `/webassets/img` - image files used by the web front-end
|
* `/webassets/img` - image files used by the web front-end
|
||||||
@@ -515,7 +502,11 @@ The same approach as above is also used for alert providers.
|
|||||||
As well as being my work, I have also gratefully received feature patches from Steven, M1SDH.
|
As well as being my work, I have also gratefully received feature patches from Steven, M1SDH.
|
||||||
|
|
||||||
The project contains GeoJSON files for CQ and ITU zones, in the `/datafiles/` directory. These are MIT-licenced and, to
|
The project contains GeoJSON files for CQ and ITU zones, in the `/datafiles/` directory. These are MIT-licenced and, to
|
||||||
my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet.
|
my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet. `/datafiles` also contains a
|
||||||
|
`MUNICIPIOS.csv` file, from the "Nomenclátor Geográfico de Municipios y Entidades de Población" data set sourced from
|
||||||
|
[el Centro Nacional de Información Geográfica](https://centrodedescargas.cnig.es/CentroDescargas/home).
|
||||||
|
`didbase-stations.csv` and the TOTA CSV files were created by me based on publicly available data from GIRO and from
|
||||||
|
maps of conference centres.
|
||||||
|
|
||||||
The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the
|
The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the
|
||||||
`/webassets/img/flags/` directory.
|
`/webassets/img/flags/` directory.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from threading import Thread, Event
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
|
from requests.exceptions import ConnectionError
|
||||||
|
|
||||||
from alertproviders.alert_provider import AlertProvider
|
from alertproviders.alert_provider import AlertProvider
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
@@ -41,6 +42,8 @@ class HTTPAlertProvider(AlertProvider):
|
|||||||
# Request data from API
|
# Request data from API
|
||||||
logging.debug("Polling " + self.name + " alert API...")
|
logging.debug("Polling " + self.name + " alert API...")
|
||||||
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
|
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
|
||||||
|
# Check response code was good
|
||||||
|
if http_response.ok:
|
||||||
# Pass off to the subclass for processing
|
# Pass off to the subclass for processing
|
||||||
new_alerts = self._http_response_to_alerts(http_response)
|
new_alerts = self._http_response_to_alerts(http_response)
|
||||||
# Submit the new alerts for processing. There might not be any alerts for the less popular programs.
|
# Submit the new alerts for processing. There might not be any alerts for the less popular programs.
|
||||||
@@ -50,7 +53,12 @@ class HTTPAlertProvider(AlertProvider):
|
|||||||
self.status = "OK"
|
self.status = "OK"
|
||||||
self.last_update_time = datetime.now(pytz.UTC)
|
self.last_update_time = datetime.now(pytz.UTC)
|
||||||
logging.debug("Received data from " + self.name + " alert API.")
|
logging.debug("Received data from " + self.name + " alert API.")
|
||||||
|
else:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.warning(f"HTTP {http_response.status_code} when calling {self.name} alerts API.")
|
||||||
|
|
||||||
|
except ConnectionError:
|
||||||
|
logging.warning(f"Connection error when accessing {self.name} alerts API.")
|
||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logging.exception("Exception in HTTP JSON Alert Provider (" + self.name + ")")
|
logging.exception("Exception in HTTP JSON Alert Provider (" + self.name + ")")
|
||||||
|
|||||||
+10
-2
@@ -66,8 +66,8 @@ spot-providers:
|
|||||||
name: "LLOTA"
|
name: "LLOTA"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "WWTOTA"
|
- class: "Towers"
|
||||||
name: "WWTOTA"
|
name: "Towers"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "Tiles"
|
- class: "Tiles"
|
||||||
@@ -211,6 +211,14 @@ clublog-api-key: ""
|
|||||||
# Allow submitting spots to the Spothole API?
|
# Allow submitting spots to the Spothole API?
|
||||||
allow-spotting: true
|
allow-spotting: true
|
||||||
|
|
||||||
|
# Log web requests? Useful to see what your users are requesting in terms of pages and API endpoints, but will fill up
|
||||||
|
# your log quickly on a popular server.
|
||||||
|
log-web-requests: false
|
||||||
|
|
||||||
|
# Minimum severity of log statements to print to the system log. Defaults to INFO, change to DEBUG if you need more
|
||||||
|
# information.
|
||||||
|
log-level: INFO
|
||||||
|
|
||||||
# Options for the web UI.
|
# Options for the web UI.
|
||||||
web-ui-options:
|
web-ui-options:
|
||||||
spot-count: [ 10, 25, 50, 100 ]
|
spot-count: [ 10, 25, 50, 100 ]
|
||||||
|
|||||||
+5
-2
@@ -8,8 +8,11 @@ from requests_cache import CachedSession
|
|||||||
# of time has passed. This is used throughout Spothole to cache data that does not change
|
# of time has passed. This is used throughout Spothole to cache data that does not change
|
||||||
# rapidly. The ThreadSafeSession construct here protects it against some multithreading
|
# rapidly. The ThreadSafeSession construct here protects it against some multithreading
|
||||||
# contention weirdness we sometimes used to see on startup where the cache was hammered
|
# contention weirdness we sometimes used to see on startup where the cache was hammered
|
||||||
# pretty hard.
|
# pretty hard. The expanded list of allowable_codes ensures we also cache and return 400-type
|
||||||
_session = CachedSession("cache/semi_static_url_data_cache", expire_after=timedelta(days=30))
|
# responses, e.g "this SOTA summit ref doesn't actually exist", to avoid hammering remote
|
||||||
|
# servers for data they've told us they can't provide.
|
||||||
|
_session = CachedSession("cache/semi_static_url_data_cache", expire_after=timedelta(days=30),
|
||||||
|
allowable_codes=(200, 400, 401, 403, 404))
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ WEB_SERVER_PORT = config["web-server-port"]
|
|||||||
ALLOW_SPOTTING = config["allow-spotting"]
|
ALLOW_SPOTTING = config["allow-spotting"]
|
||||||
WEB_UI_OPTIONS = config["web-ui-options"]
|
WEB_UI_OPTIONS = config["web-ui-options"]
|
||||||
API_ONLY_MODE = config.get("api-only-mode", False)
|
API_ONLY_MODE = config.get("api-only-mode", False)
|
||||||
|
LOG_LEVEL = config.get("log-level", "INFO")
|
||||||
|
LOG_WEB_REQUESTS = config.get("log-web-requests", False)
|
||||||
|
|
||||||
# For ease of config, each spot provider owns its own config about whether it should be enabled by default in the web UI
|
# For ease of config, each spot provider owns its own config about whether it should be enabled by default in the web UI
|
||||||
# but for consistency we provide this to the front-end in web-ui-options because it has no impact outside of the web UI.
|
# but for consistency we provide this to the front-end in web-ui-options because it has no impact outside of the web UI.
|
||||||
|
|||||||
+25
-24
@@ -11,29 +11,29 @@ HAMQTH_PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_C
|
|||||||
|
|
||||||
# Special Interest Groups
|
# Special Interest Groups
|
||||||
SIGS = [
|
SIGS = [
|
||||||
SIG(name="POTA", description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}"),
|
SIG(name="POTA", comment_names=["POTA"], description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}"),
|
||||||
SIG(name="SOTA", description="Summits on the Air", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
|
SIG(name="SOTA", comment_names=["SOTA"], description="Summits on the Air", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
|
||||||
SIG(name="WWFF", description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"),
|
SIG(name="WWFF", comment_names=["WWFF"], description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"),
|
||||||
SIG(name="GMA", description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
|
SIG(name="GMA", comment_names=["GMA"], description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
|
||||||
SIG(name="WWBOTA", description="Worldwide Bunkers on the Air", ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}"),
|
SIG(name="WWBOTA", comment_names=["WWBOTA", "BOTA"], description="Worldwide Bunkers on the Air", ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}"),
|
||||||
SIG(name="HEMA", description="HuMPs Excluding Marilyns Award", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}"),
|
SIG(name="HEMA", comment_names=["HEMA"], description="HuMPs Excluding Marilyns Award", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}"),
|
||||||
SIG(name="IOTA", description="Islands on the Air", ref_regex=r"[A-Z]{2}\-\d{3}"),
|
SIG(name="IOTA", comment_names=["IOTA"], description="Islands on the Air", ref_regex=r"[A-Z]{2}\-\d{3}"),
|
||||||
SIG(name="MOTA", description="Mills on the Air", ref_regex=r"X\d{4,6}"),
|
SIG(name="MOTA", comment_names=["MOTA"], description="Mills on the Air", ref_regex=r"X\d{4,6}"),
|
||||||
SIG(name="ARLHS", description="Amateur Radio Lighthouse Society", ref_regex=r"[A-Z]{3}\-\d{3,4}"),
|
SIG(name="ARLHS", comment_names=["ARLHS"], description="Amateur Radio Lighthouse Society", ref_regex=r"[A-Z]{3}\-\d{3,4}"),
|
||||||
SIG(name="ILLW", description="International Lighthouse & Lightship Weekend", ref_regex=r"[A-Z]{2}\d{4}"),
|
SIG(name="ILLW", comment_names=["ILLW"], description="International Lighthouse & Lightship Weekend", ref_regex=r"[A-Z]{2}\d{4}"),
|
||||||
SIG(name="SIOTA", description="Silos on the Air", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"),
|
SIG(name="SiOTA", comment_names=["SIOTA"], description="Silos on the Air", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"),
|
||||||
SIG(name="WCA", description="World Castles Award", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"),
|
SIG(name="WCA", comment_names=["WCA"], description="World Castles Award", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"),
|
||||||
SIG(name="ZLOTA", description="New Zealand on the Air", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}"),
|
SIG(name="ZLOTA", comment_names=["ZLOTA"], description="New Zealand on the Air", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}"),
|
||||||
SIG(name="WOTA", description="Wainwrights on the Air", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
|
SIG(name="WOTA", comment_names=["WOTA"], description="Wainwrights on the Air", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
|
||||||
SIG(name="BOTA", description="Beaches on the Air"),
|
SIG(name="BOTA", comment_names=[], description="Beaches on the Air"),
|
||||||
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award"),
|
SIG(name="KRMNPA", comment_names=["KRMNPA"], description="Keith Roget Memorial National Parks Award"),
|
||||||
SIG(name="LLOTA", description="Lagos y Lagunas on the Air", ref_regex=r"[A-Z]{2}\-\d{4}"),
|
SIG(name="LLOTA", comment_names=["LLOTA"], description="Lagos y Lagunas on the Air", ref_regex=r"LL[A-Z]{2}\-\d{4}"),
|
||||||
SIG(name="WWTOTA", description="Towers on the Air", ref_regex=r"[A-Z]{2}R\-\d{4}"),
|
SIG(name="Towers", comment_names=["TOTA"], description="Towers on the Air", ref_regex=r"[A-Z]{2,3}R\-\d{4}"),
|
||||||
SIG(name="Tiles", description="Tiles on the Air", ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}"),
|
SIG(name="Tiles", comment_names=[], description="Tiles on the Air", ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}"),
|
||||||
SIG(name="WAB", description="Worked All Britain", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
|
SIG(name="WAB", comment_names=["WAB"], description="Worked All Britain", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
|
||||||
SIG(name="WAI", description="Worked All Ireland", ref_regex=r"[A-Z][0-9]{2}"),
|
SIG(name="WAI", comment_names=["WAI"], description="Worked All Ireland", ref_regex=r"[A-Z][0-9]{2}"),
|
||||||
SIG(name="DME", description="Diplomas de Municipios Españoles", ref_regex=r"\d{4,5}"),
|
SIG(name="DME", comment_names=["DME"], description="Diplomas de Municipios Españoles", ref_regex=r"\d{4,5}"),
|
||||||
SIG(name="TOTA", description="Toilets on the Air", ref_regex=r"T\-[0-9]{2}")
|
SIG(name="Toilets", comment_names=[], description="Toilets on the Air", ref_regex=r"T\-[0-9]{2}")
|
||||||
]
|
]
|
||||||
|
|
||||||
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
|
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
|
||||||
@@ -102,5 +102,6 @@ PROPAGATION_MODES = {
|
|||||||
"AU": "Aurora",
|
"AU": "Aurora",
|
||||||
"MS": "Meteor scatter",
|
"MS": "Meteor scatter",
|
||||||
"RS": "Rain scatter",
|
"RS": "Rain scatter",
|
||||||
"AS": "Aircraft scatter"
|
"AS": "Aircraft scatter",
|
||||||
|
"SAT": "Satellite"
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-17
@@ -5,13 +5,13 @@ import re
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import requests
|
|
||||||
import xmltodict
|
import xmltodict
|
||||||
from diskcache import Cache
|
from diskcache import Cache
|
||||||
from pyhamtools import LookupLib, Callinfo, callinfo
|
from pyhamtools import LookupLib, Callinfo, callinfo
|
||||||
from pyhamtools.exceptions import APIKeyMissingError
|
from pyhamtools.exceptions import APIKeyMissingError
|
||||||
from pyhamtools.frequency import freq_to_band
|
from pyhamtools.frequency import freq_to_band
|
||||||
from pyhamtools.locator import latlong_to_locator
|
from pyhamtools.locator import latlong_to_locator
|
||||||
|
from requests.exceptions import ConnectionError
|
||||||
from requests_cache import CachedSession
|
from requests_cache import CachedSession
|
||||||
|
|
||||||
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
|
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
|
||||||
@@ -143,13 +143,19 @@ class LookupHelper:
|
|||||||
try:
|
try:
|
||||||
logging.info("Downloading Country-files.com cty.plist...")
|
logging.info("Downloading Country-files.com cty.plist...")
|
||||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.country-files.com/cty/cty.plist",
|
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.country-files.com/cty/cty.plist",
|
||||||
headers=HTTP_HEADERS).text
|
headers=HTTP_HEADERS)
|
||||||
|
|
||||||
|
if response.ok:
|
||||||
with open(self._country_files_cty_plist_download_location, "w") as f:
|
with open(self._country_files_cty_plist_download_location, "w") as f:
|
||||||
f.write(response)
|
f.write(response.text)
|
||||||
f.flush()
|
f.flush()
|
||||||
return True
|
return True
|
||||||
|
else:
|
||||||
|
logging.warning(f"HTTP {response.status_code} when downloading Country-files.com cty.plist.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except ConnectionError:
|
||||||
|
logging.warning(f"Connection error when downloading Clublog cty.xml.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error("Exception when downloading Clublog cty.xml", e)
|
logging.error("Exception when downloading Clublog cty.xml", e)
|
||||||
return False
|
return False
|
||||||
@@ -161,13 +167,19 @@ class LookupHelper:
|
|||||||
logging.info("Downloading dxcc.json...")
|
logging.info("Downloading dxcc.json...")
|
||||||
response = SEMI_STATIC_URL_DATA_CACHE.get(
|
response = SEMI_STATIC_URL_DATA_CACHE.get(
|
||||||
"https://raw.githubusercontent.com/k0swe/dxcc-json/refs/heads/main/dxcc.json",
|
"https://raw.githubusercontent.com/k0swe/dxcc-json/refs/heads/main/dxcc.json",
|
||||||
headers=HTTP_HEADERS).text
|
headers=HTTP_HEADERS)
|
||||||
|
|
||||||
|
if response.ok:
|
||||||
with open(self._dxcc_json_download_location, "w") as f:
|
with open(self._dxcc_json_download_location, "w") as f:
|
||||||
f.write(response)
|
f.write(response.text)
|
||||||
f.flush()
|
f.flush()
|
||||||
return True
|
return True
|
||||||
|
else:
|
||||||
|
logging.warning(f"HTTP {response.status_code} when downloading dxcc.json.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except ConnectionError:
|
||||||
|
logging.warning(f"Connection error when downloading dxcc.json.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error("Exception when downloading dxcc.json", e)
|
logging.error("Exception when downloading dxcc.json", e)
|
||||||
return False
|
return False
|
||||||
@@ -490,25 +502,44 @@ class LookupHelper:
|
|||||||
|
|
||||||
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
|
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
|
||||||
calls_to_try = [call]
|
calls_to_try = [call]
|
||||||
|
try:
|
||||||
home_call = callinfo.Callinfo.get_homecall(call)
|
home_call = callinfo.Callinfo.get_homecall(call)
|
||||||
if home_call != call:
|
if home_call != call:
|
||||||
calls_to_try.append(home_call)
|
calls_to_try.append(home_call)
|
||||||
|
except ValueError:
|
||||||
|
logging.debug("Could not look up home call for callsign %s", call)
|
||||||
|
|
||||||
for lookup_call in calls_to_try:
|
for lookup_call in calls_to_try:
|
||||||
try:
|
try:
|
||||||
lookup_response = requests.get(
|
response = SEMI_STATIC_URL_DATA_CACHE.get(
|
||||||
self._qrz_base_url + "?s=" + session_key + "&callsign=" + urllib.parse.quote_plus(lookup_call),
|
self._qrz_base_url + "?s=" + session_key + "&callsign=" + urllib.parse.quote_plus(lookup_call),
|
||||||
headers=HTTP_HEADERS, timeout=10).content
|
headers=HTTP_HEADERS, timeout=10)
|
||||||
raw = xmltodict.parse(lookup_response).get("QRZDatabase", {}).get("Callsign")
|
if response.ok:
|
||||||
if raw:
|
qrz_response = xmltodict.parse(response.content).get("QRZDatabase", {})
|
||||||
data = _normalize_qrz_data(raw)
|
if qrz_response:
|
||||||
|
if "Callsign" in qrz_response:
|
||||||
|
data = _normalize_qrz_data(qrz_response.get("Callsign"))
|
||||||
self._qrz_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
|
self._qrz_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
|
||||||
return data
|
return data
|
||||||
|
elif "Session" in qrz_response and "Error" in qrz_response.get("Session"):
|
||||||
|
# Errors here are normally just "callsign not in database", no need to log that ourselves
|
||||||
|
# above debug level.
|
||||||
|
logging.debug("QRZ returned an error looking up callsign %s: %s", lookup_call,
|
||||||
|
qrz_response.get("Session").get("Error"))
|
||||||
|
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("QRZ returned a malformed response looking up callsign %s", lookup_call)
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("HTTP %d looking up callsign %s using QRZ", lookup_call)
|
||||||
|
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
continue
|
continue
|
||||||
|
except ConnectionError:
|
||||||
|
logging.warning(f"Connection error when looking up callsign %s using QRZ", lookup_call)
|
||||||
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.error("Exception when looking up QRZ data")
|
logging.error("Exception when looking up callsign %s using QRZ", lookup_call, exc_info=True)
|
||||||
return None
|
continue
|
||||||
|
|
||||||
# Not found in QRZ; cache None so we don't keep retrying
|
# Not found in QRZ; cache None so we don't keep retrying
|
||||||
self._qrz_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
|
self._qrz_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
|
||||||
@@ -546,23 +577,33 @@ class LookupHelper:
|
|||||||
|
|
||||||
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
|
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
|
||||||
calls_to_try = [call]
|
calls_to_try = [call]
|
||||||
|
try:
|
||||||
home_call = callinfo.Callinfo.get_homecall(call)
|
home_call = callinfo.Callinfo.get_homecall(call)
|
||||||
if home_call != call:
|
if home_call != call:
|
||||||
calls_to_try.append(home_call)
|
calls_to_try.append(home_call)
|
||||||
|
except ValueError:
|
||||||
|
logging.debug("Could not look up home call for callsign %s", call)
|
||||||
|
|
||||||
for lookup_call in calls_to_try:
|
for lookup_call in calls_to_try:
|
||||||
try:
|
try:
|
||||||
lookup_data = SEMI_STATIC_URL_DATA_CACHE.get(
|
response = SEMI_STATIC_URL_DATA_CACHE.get(
|
||||||
self._hamqth_base_url + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
|
self._hamqth_base_url + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
|
||||||
lookup_call) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS).content
|
lookup_call) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS)
|
||||||
data = xmltodict.parse(lookup_data)["HamQTH"]["search"]
|
if response.ok:
|
||||||
|
data = xmltodict.parse(response.content)["HamQTH"]["search"]
|
||||||
self._hamqth_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
|
self._hamqth_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
|
||||||
return data
|
return data
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("HTTP %d looking up callsign %s using HamQTH", response.status_code, lookup_call)
|
||||||
|
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
continue
|
continue
|
||||||
|
except ConnectionError:
|
||||||
|
logging.warning(f"Connection error when looking up callsign %s using HamQTH", lookup_call)
|
||||||
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.error("Exception when looking up HamQTH data")
|
logging.error("Exception when looking up callsign %s using HamQTH", lookup_call, exc_info=True)
|
||||||
return None
|
continue
|
||||||
|
|
||||||
# Not found in HamQTH; cache None so we don't keep retrying
|
# Not found in HamQTH; cache None so we don't keep retrying
|
||||||
self._hamqth_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
|
self._hamqth_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
|
||||||
|
|||||||
+115
-49
@@ -2,13 +2,16 @@ import csv
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
|
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
|
||||||
|
from requests.exceptions import ConnectionError
|
||||||
|
|
||||||
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
|
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
|
||||||
from core.constants import SIGS, HTTP_HEADERS
|
from core.constants import SIGS, HTTP_HEADERS
|
||||||
from core.geo_utils import wab_wai_square_to_lat_lon
|
from core.geo_utils import wab_wai_square_to_lat_lon
|
||||||
|
|
||||||
with open("datafiles/dme-geodata.csv", encoding="utf-8") as _f:
|
# Load Spanish municipality data for the DME programme. There's no convenient lookup API for this, so we embed the data
|
||||||
_DME_INDEX = {row["dme"]: row for row in csv.DictReader(_f)}
|
# file in Spothole and load it on startup.
|
||||||
|
with open("datafiles/MUNICIPIOS.csv", encoding="latin-1") as _f:
|
||||||
|
_DME_INDEX = {row["COD_INE"][:5]: row for row in csv.DictReader(_f, delimiter=";")}
|
||||||
|
|
||||||
|
|
||||||
def get_ref_regex_for_sig(sig):
|
def get_ref_regex_for_sig(sig):
|
||||||
@@ -20,6 +23,16 @@ def get_ref_regex_for_sig(sig):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_sig_name_from_comment_name(sig):
|
||||||
|
"""Utility function to get the name of a SIG from its "comment name". Generally these will be the same but there are
|
||||||
|
some cases (e.g. is "TOTA" Towers, Tiles or Toilets?) where we need to transform one to the other."""
|
||||||
|
|
||||||
|
for s in SIGS:
|
||||||
|
if any(n.upper() == sig.upper() for n in s.comment_names):
|
||||||
|
return s.name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def populate_sig_ref_info(sig_ref):
|
def populate_sig_ref_info(sig_ref):
|
||||||
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object which
|
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object which
|
||||||
must at minimum have a "sig" and an "id". The rest of the object will be populated and returned.
|
must at minimum have a "sig" and an "id". The rest of the object will be populated and returned.
|
||||||
@@ -33,9 +46,8 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
try:
|
try:
|
||||||
if sig.upper() == "POTA":
|
if sig.upper() == "POTA":
|
||||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://api.pota.app/park/" + ref_id, headers=HTTP_HEADERS)
|
response = SEMI_STATIC_URL_DATA_CACHE.get("https://api.pota.app/park/" + ref_id, headers=HTTP_HEADERS)
|
||||||
if not response.ok:
|
if response.ok:
|
||||||
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
data = response.json()
|
||||||
data = response.json() if response.ok else None
|
|
||||||
if data:
|
if data:
|
||||||
fullname = str(data["name"]) if "name" in data else None
|
fullname = str(data["name"]) if "name" in data else None
|
||||||
if fullname and "parktypeDesc" in data and data["parktypeDesc"] != "":
|
if fullname and "parktypeDesc" in data and data["parktypeDesc"] != "":
|
||||||
@@ -45,12 +57,16 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
sig_ref.grid = data["grid6"] if "grid6" in data else None
|
sig_ref.grid = data["grid6"] if "grid6" in data else None
|
||||||
sig_ref.latitude = data["latitude"] if "latitude" in data else None
|
sig_ref.latitude = data["latitude"] if "latitude" in data else None
|
||||||
sig_ref.longitude = data["longitude"] if "longitude" in data else None
|
sig_ref.longitude = data["longitude"] if "longitude" in data else None
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
||||||
|
|
||||||
elif sig.upper() == "SOTA":
|
elif sig.upper() == "SOTA":
|
||||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://api-db2.sota.org.uk/api/summits/" + ref_id,
|
response = SEMI_STATIC_URL_DATA_CACHE.get("https://api-db2.sota.org.uk/api/summits/" + ref_id,
|
||||||
headers=HTTP_HEADERS)
|
headers=HTTP_HEADERS)
|
||||||
if not response.ok:
|
if response.ok:
|
||||||
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
data = response.json()
|
||||||
data = response.json() if response.ok else None
|
|
||||||
if data:
|
if data:
|
||||||
sig_ref.name = data["name"] if "name" in data else None
|
sig_ref.name = data["name"] if "name" in data else None
|
||||||
sig_ref.url = "https://www.sotadata.org.uk/en/summit/" + ref_id
|
sig_ref.url = "https://www.sotadata.org.uk/en/summit/" + ref_id
|
||||||
@@ -58,64 +74,92 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
sig_ref.latitude = data["latitude"] if "latitude" in data else None
|
sig_ref.latitude = data["latitude"] if "latitude" in data else None
|
||||||
sig_ref.longitude = data["longitude"] if "longitude" in data else None
|
sig_ref.longitude = data["longitude"] if "longitude" in data else None
|
||||||
sig_ref.activation_score = data["points"] if "points" in data else None
|
sig_ref.activation_score = data["points"] if "points" in data else None
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
||||||
|
|
||||||
elif sig.upper() == "WWBOTA":
|
elif sig.upper() == "WWBOTA":
|
||||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + ref_id,
|
response = SEMI_STATIC_URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + ref_id,
|
||||||
headers=HTTP_HEADERS)
|
headers=HTTP_HEADERS)
|
||||||
if not response.ok:
|
if response.ok:
|
||||||
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
data = response.json()
|
||||||
data = response.json() if response.ok else None
|
|
||||||
if data:
|
if data:
|
||||||
sig_ref.name = data["name"] if "name" in data else None
|
sig_ref.name = data["name"] if "name" in data else None
|
||||||
sig_ref.url = "https://bunkerwiki.org/?s=" + ref_id if ref_id.startswith("B/G") else None
|
sig_ref.url = "https://bunkerwiki.org/?s=" + ref_id if ref_id.startswith("B/G") else None
|
||||||
sig_ref.grid = data["locator"] if "locator" in data else None
|
sig_ref.grid = data["locator"] if "locator" in data else None
|
||||||
sig_ref.latitude = data["lat"] if "lat" in data else None
|
sig_ref.latitude = data["lat"] if "lat" in data else None
|
||||||
sig_ref.longitude = data["long"] if "long" in data else None
|
sig_ref.longitude = data["long"] if "long" in data else None
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
||||||
|
|
||||||
elif sig.upper() == "GMA" or sig.upper() == "ARLHS" or sig.upper() == "ILLW" or sig.upper() == "WCA" or sig.upper() == "MOTA" or sig.upper() == "IOTA":
|
elif sig.upper() == "GMA" or sig.upper() == "ARLHS" or sig.upper() == "ILLW" or sig.upper() == "WCA" or sig.upper() == "MOTA" or sig.upper() == "IOTA":
|
||||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.cqgma.org/api/ref/?" + ref_id,
|
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.cqgma.org/api/ref/?" + ref_id,
|
||||||
headers=HTTP_HEADERS)
|
headers=HTTP_HEADERS)
|
||||||
if not response.ok:
|
if response.ok:
|
||||||
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
data = response.json()
|
||||||
data = response.json() if response.ok else None
|
|
||||||
if data:
|
if data:
|
||||||
sig_ref.name = data["name"] if "name" in data else None
|
sig_ref.name = data["name"] if "name" in data else None
|
||||||
sig_ref.url = "https://www.cqgma.org/zinfo.php?ref=" + ref_id
|
sig_ref.url = "https://www.cqgma.org/zinfo.php?ref=" + ref_id
|
||||||
sig_ref.grid = data["locator"] if "locator" in data else None
|
sig_ref.grid = data["locator"] if "locator" in data else None
|
||||||
sig_ref.latitude = data["latitude"] if "latitude" in data else None
|
|
||||||
sig_ref.longitude = data["longitude"] if "longitude" in data else None
|
# For some things (just IOTA?) the GMA actually returns a box where "latitude" and "longitude" are
|
||||||
|
# the zeroest corner of the box, then "lat2" and "lng2" provide the other corner. We detect this
|
||||||
|
# and provide a single lat/lon for the centre. Otherwise if we don't have these extra parameters,
|
||||||
|
# just use the single point we have.
|
||||||
|
if "latitude" in data and "longitude" in data and "lat2" in data and "lng2" in data:
|
||||||
|
sig_ref.latitude = (float(data["latitude"]) + float(data["lat2"])) / 2.0
|
||||||
|
sig_ref.longitude = (float(data["longitude"]) + float(data["lng2"])) / 2.0
|
||||||
|
else:
|
||||||
|
sig_ref.latitude = float(data["latitude"]) if "latitude" in data else None
|
||||||
|
sig_ref.longitude = float(data["longitude"]) if "longitude" in data else None
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("Malformed response looking up %s ref %s via GMA", sig, ref_id)
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
||||||
|
|
||||||
elif sig.upper() == "WWFF":
|
elif sig.upper() == "WWFF":
|
||||||
wwff_response = SEMI_STATIC_URL_DATA_CACHE.get("https://wwff.co/wwff-data/wwff_directory.csv",
|
response = SEMI_STATIC_URL_DATA_CACHE.get("https://wwff.co/wwff-data/wwff_directory.csv",
|
||||||
headers=HTTP_HEADERS)
|
headers=HTTP_HEADERS)
|
||||||
if not wwff_response.ok:
|
if response.ok:
|
||||||
logging.warning("HTTP %d looking up %s ref %s", wwff_response.status_code, sig, ref_id)
|
wwff_index = {row["reference"]: row for row in csv.DictReader(response.content.decode().splitlines())}
|
||||||
return sig_ref
|
|
||||||
wwff_index = {row["reference"]: row for row in csv.DictReader(wwff_response.content.decode().splitlines())}
|
|
||||||
row = wwff_index.get(ref_id)
|
row = wwff_index.get(ref_id)
|
||||||
if row:
|
if row:
|
||||||
sig_ref.name = row["name"] if "name" in row else None
|
sig_ref.name = row["name"] if "name" in row else None
|
||||||
sig_ref.url = "https://wwff.co/directory/?showRef=" + ref_id
|
sig_ref.url = "https://wwff.co/directory/?showRef=" + ref_id
|
||||||
sig_ref.grid = row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None
|
sig_ref.grid = row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None
|
||||||
sig_ref.latitude = float(row["latitude"]) if "latitude" in row and row["latitude"] != "-" else None
|
sig_ref.latitude = float(row["latitude"]) if "latitude" in row and row["latitude"] != "-" else None
|
||||||
sig_ref.longitude = float(row["longitude"]) if "longitude" in row and row["longitude"] != "-" else None
|
sig_ref.longitude = float(row["longitude"]) if "longitude" in row and row[
|
||||||
|
"longitude"] != "-" else None
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("WWFF database did not contain data for ref %s", ref_id)
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
||||||
|
|
||||||
elif sig.upper() == "SIOTA":
|
elif sig.upper() == "SIOTA":
|
||||||
siota_response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.silosontheair.com/data/silos.csv",
|
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.silosontheair.com/data/silos.csv",
|
||||||
headers=HTTP_HEADERS)
|
headers=HTTP_HEADERS)
|
||||||
if not siota_response.ok:
|
if response.ok:
|
||||||
logging.warning("HTTP %d looking up %s ref %s", siota_response.status_code, sig, ref_id)
|
|
||||||
return sig_ref
|
|
||||||
siota_index = {row["SILO_CODE"]: row for row in
|
siota_index = {row["SILO_CODE"]: row for row in
|
||||||
csv.DictReader(siota_response.content.decode().splitlines())}
|
csv.DictReader(response.content.decode().splitlines())}
|
||||||
row = siota_index.get(ref_id)
|
row = siota_index.get(ref_id)
|
||||||
if row:
|
if row:
|
||||||
sig_ref.name = row["NAME"] if "NAME" in row else None
|
sig_ref.name = row["NAME"] if "NAME" in row else None
|
||||||
sig_ref.grid = row["LOCATOR"] if "LOCATOR" in row else None
|
sig_ref.grid = row["LOCATOR"] if "LOCATOR" in row else None
|
||||||
sig_ref.latitude = float(row["LAT"]) if "LAT" in row else None
|
sig_ref.latitude = float(row["LAT"]) if "LAT" in row else None
|
||||||
sig_ref.longitude = float(row["LNG"]) if "LNG" in row else None
|
sig_ref.longitude = float(row["LNG"]) if "LNG" in row else None
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("SIOTA database did not contain data for ref %s", ref_id)
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
||||||
|
|
||||||
elif sig.upper() == "WOTA":
|
elif sig.upper() == "WOTA":
|
||||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.wota.org.uk/mapping/data/summits.json",
|
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.wota.org.uk/mapping/data/summits.json",
|
||||||
headers=HTTP_HEADERS)
|
headers=HTTP_HEADERS)
|
||||||
if not response.ok:
|
if response.ok:
|
||||||
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
data = response.json()
|
||||||
data = response.json() if response.ok else None
|
|
||||||
if data:
|
if data:
|
||||||
for feature in data.get("features", []):
|
for feature in data.get("features", []):
|
||||||
if feature["properties"]["wotaId"] == ref_id:
|
if feature["properties"]["wotaId"] == ref_id:
|
||||||
@@ -130,16 +174,20 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
sig_ref.latitude = feature["geometry"]["coordinates"][1]
|
sig_ref.latitude = feature["geometry"]["coordinates"][1]
|
||||||
sig_ref.longitude = feature["geometry"]["coordinates"][0]
|
sig_ref.longitude = feature["geometry"]["coordinates"][0]
|
||||||
break
|
break
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
||||||
|
|
||||||
elif sig.upper() == "ZLOTA":
|
elif sig.upper() == "ZLOTA":
|
||||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://ontheair.nz/assets/assets.json", headers=HTTP_HEADERS)
|
response = SEMI_STATIC_URL_DATA_CACHE.get("https://ontheair.nz/assets/assets.json", headers=HTTP_HEADERS)
|
||||||
if not response.ok:
|
if response.ok:
|
||||||
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
data = response.json()
|
||||||
data = response.json() if response.ok else None
|
|
||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
for asset in data:
|
for asset in data:
|
||||||
if asset["code"] == ref_id:
|
if asset["code"] == ref_id:
|
||||||
sig_ref.name = asset["name"]
|
sig_ref.name = asset["name"]
|
||||||
sig_ref.url = "https://ontheair.nz/assets/ZLI_OT-030" + ref_id.replace("/", "_")
|
sig_ref.url = "https://ontheair.nz/assets/" + ref_id.replace("/", "_")
|
||||||
try:
|
try:
|
||||||
sig_ref.grid = latlong_to_locator(asset["y"], asset["x"], 6)
|
sig_ref.grid = latlong_to_locator(asset["y"], asset["x"], 6)
|
||||||
except:
|
except:
|
||||||
@@ -147,16 +195,21 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
sig_ref.latitude = asset["y"]
|
sig_ref.latitude = asset["y"]
|
||||||
sig_ref.longitude = asset["x"]
|
sig_ref.longitude = asset["x"]
|
||||||
break
|
break
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
||||||
|
|
||||||
elif sig.upper() == "BOTA":
|
elif sig.upper() == "BOTA":
|
||||||
if not sig_ref.name:
|
if not sig_ref.name:
|
||||||
sig_ref.name = sig_ref.id
|
sig_ref.name = sig_ref.id
|
||||||
sig_ref.url = "https://www.beachesontheair.com/beaches/" + sig_ref.name.lower().replace(" ", "-")
|
sig_ref.url = "https://www.beachesontheair.com/beaches/" + sig_ref.name.lower().replace(" ", "-")
|
||||||
|
|
||||||
elif sig.upper() == "LLOTA":
|
elif sig.upper() == "LLOTA":
|
||||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://llota.app/api/public/references",
|
response = SEMI_STATIC_URL_DATA_CACHE.get("https://llota.app/api/public/references",
|
||||||
headers=HTTP_HEADERS)
|
headers=HTTP_HEADERS)
|
||||||
if not response.ok:
|
if response.ok:
|
||||||
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
data = response.json()
|
||||||
data = response.json() if response.ok else None
|
|
||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
for ref in data:
|
for ref in data:
|
||||||
if ref["reference_code"] == ref_id:
|
if ref["reference_code"] == ref_id:
|
||||||
@@ -167,10 +220,16 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
sig_ref.latitude = ll[0]
|
sig_ref.latitude = ll[0]
|
||||||
sig_ref.longitude = ll[1]
|
sig_ref.longitude = ll[1]
|
||||||
break
|
break
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
||||||
|
|
||||||
elif sig.upper() == "WWTOTA":
|
elif sig.upper() == "WWTOTA":
|
||||||
if not sig_ref.name:
|
if not sig_ref.name:
|
||||||
sig_ref.name = sig_ref.id
|
sig_ref.name = sig_ref.id
|
||||||
sig_ref.url = "https://wwtota.com/seznam/karta_rozhledny.php?ref=" + str(sig_ref.name)
|
sig_ref.url = "https://wwtota.com/seznam/karta_rozhledny.php?ref=" + str(sig_ref.name)
|
||||||
|
|
||||||
elif sig.upper() == "TILES":
|
elif sig.upper() == "TILES":
|
||||||
# Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same
|
# Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same
|
||||||
if not sig_ref.name:
|
if not sig_ref.name:
|
||||||
@@ -181,6 +240,7 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
ll = locator_to_latlong(str(sig_ref.grid))
|
ll = locator_to_latlong(str(sig_ref.grid))
|
||||||
sig_ref.latitude = ll[0]
|
sig_ref.latitude = ll[0]
|
||||||
sig_ref.longitude = ll[1]
|
sig_ref.longitude = ll[1]
|
||||||
|
|
||||||
elif sig.upper() == "WAB" or sig.upper() == "WAI":
|
elif sig.upper() == "WAB" or sig.upper() == "WAI":
|
||||||
ll = wab_wai_square_to_lat_lon(ref_id)
|
ll = wab_wai_square_to_lat_lon(ref_id)
|
||||||
if ll:
|
if ll:
|
||||||
@@ -190,25 +250,31 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
sig_ref.latitude = ll[0]
|
sig_ref.latitude = ll[0]
|
||||||
sig_ref.longitude = ll[1]
|
sig_ref.longitude = ll[1]
|
||||||
except:
|
except:
|
||||||
logging.debug("Invalid lat/lon received for reference")
|
logging.warning("Invalid lat/lon received for WAB/WAI reference")
|
||||||
|
|
||||||
elif sig.upper() == "DME":
|
elif sig.upper() == "DME":
|
||||||
row = _DME_INDEX.get(ref_id)
|
# Zero-pad to 5 digits to match our source data
|
||||||
|
row = _DME_INDEX.get(ref_id.zfill(5))
|
||||||
if row:
|
if row:
|
||||||
sig_ref.name = row["municipio"] + ", " + row["provincia"]
|
sig_ref.name = row["NOMBRE_ACTUAL"] + ", " + row["PROVINCIA"]
|
||||||
sig_ref.latitude = float(row["lat"]) if row.get("lat") else None
|
sig_ref.latitude = float(row["LATITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get(
|
||||||
sig_ref.longitude = float(row["lon"]) if row.get("lon") else None
|
"LATITUD_ETRS89_REGCAN95") else None
|
||||||
|
sig_ref.longitude = float(row["LONGITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get(
|
||||||
|
"LONGITUD_ETRS89_REGCAN95") else None
|
||||||
if sig_ref.latitude and sig_ref.longitude:
|
if sig_ref.latitude and sig_ref.longitude:
|
||||||
try:
|
try:
|
||||||
sig_ref.grid = latlong_to_locator(sig_ref.latitude, sig_ref.longitude, 6)
|
sig_ref.grid = latlong_to_locator(sig_ref.latitude, sig_ref.longitude, 6)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.debug("Invalid lat/lon received for reference")
|
logging.warning("Invalid lat/lon received for DME reference")
|
||||||
|
else:
|
||||||
|
logging.warning("DME database did not contain data for ref %s", ref_id)
|
||||||
|
|
||||||
|
except ConnectionError:
|
||||||
|
logging.warning("Connection error when looking up sig_ref info for " + sig + " ref " + ref_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.warning("Failed to look up sig_ref info for " + sig + " ref " + ref_id, exc_info=True)
|
logging.error("Exception when looking up sig_ref info for " + sig + " ref " + ref_id, exc_info=True)
|
||||||
return sig_ref
|
return sig_ref
|
||||||
|
|
||||||
|
|
||||||
# Regex matching any SIG
|
# Regex matching any SIG's "comment name", i.e. how it may be referred to in spot comments
|
||||||
ANY_SIG_REGEX = r"(" + r"|".join(list(map(lambda p: p.name, SIGS))) + r")"
|
ANY_SIG_REGEX = r"(" + r"|".join(n for s in SIGS for n in s.comment_names) + r")"
|
||||||
|
|
||||||
# Regex matching any SIG reference
|
|
||||||
ANY_XOTA_SIG_REF_REGEX = r"[\w\/]+\-\d+"
|
|
||||||
|
|||||||
+8
-5
@@ -1,8 +1,11 @@
|
|||||||
def serialize_everything(obj):
|
import simplejson
|
||||||
"""Convert objects to serialisable things. Used by JSON serialiser as a default when it encounters unserializable things.
|
|
||||||
Just converts objects to dict. Try to avoid doing anything clever here when serialising spots, because we also need
|
|
||||||
to receive spots without complex handling."""
|
def safe_json_dumps(obj):
|
||||||
return obj.__dict__
|
"""Safe version of json.dumps that also converts objects to dicts so they can be output, and ignores NaN floats
|
||||||
|
which are invalid in JSON."""
|
||||||
|
|
||||||
|
return simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, default=lambda o: o.__dict__)
|
||||||
|
|
||||||
|
|
||||||
def empty_queue(q):
|
def empty_queue(q):
|
||||||
|
|||||||
+13
-5
@@ -1,13 +1,21 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SIG:
|
class SIG:
|
||||||
"""Data class that defines a Special Interest Group."""
|
"""Data class that defines a Special Interest Group. Each contains a name and a longer form description.
|
||||||
|
They also contain comment_names which attempts to separate out the way people might refer to it in
|
||||||
|
cluster comments from how it is referred to in the UI & API. (For example, "TOTA" in cluster spot comments
|
||||||
|
almost always means Towers on the Air, but no single programme is referred to in the UI as "TOTA" as
|
||||||
|
it's ambiguous between Towers, Toilets and Tiles. And while Beaches got the name "BOTA" first, "BOTA" spots
|
||||||
|
are much more likely to be bunkers.) Finally, there is a ref_regex which provides a regular expression to
|
||||||
|
match what references (such as parks and summits) look like for that programme."""
|
||||||
|
|
||||||
# SIG name, e.g. "POTA"
|
# SIG name as used in the UI and API, e.g. "Towers"
|
||||||
name: str
|
name: str
|
||||||
# Description, e.g. "Parks on the Air"
|
# Description, e.g. "Towers on the Air"
|
||||||
description: str
|
description: str
|
||||||
|
# SIG names as they might appear in cluster spot comments, e.g. ["TOTA"]
|
||||||
|
comment_names: list[str] = field(default_factory=list)
|
||||||
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
|
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
|
||||||
ref_regex: str = None
|
ref_regex: str | None = None
|
||||||
|
|||||||
+14
-3
@@ -14,7 +14,7 @@ from core.constants import MODE_ALIASES, PROPAGATION_MODES
|
|||||||
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
||||||
from core.lookup_helper import lookup_helper, infer_band_from_freq, infer_mode_from_comment, \
|
from core.lookup_helper import lookup_helper, infer_band_from_freq, infer_mode_from_comment, \
|
||||||
infer_mode_from_frequency, infer_mode_type_from_mode
|
infer_mode_from_frequency, infer_mode_type_from_mode
|
||||||
from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig
|
from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig, get_sig_name_from_comment_name
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
|
|
||||||
|
|
||||||
@@ -133,6 +133,17 @@ class Spot:
|
|||||||
# The ID the source gave it, if any.
|
# The ID the source gave it, if any.
|
||||||
source_id: str | None = None
|
source_id: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
"""Normalise fields that don't survive a plain dict to Spot conversion. This is used in the "add spot" API
|
||||||
|
endpoint where the client is submitting JSON, and we want to recreate a full Spot object, including nested
|
||||||
|
objects such as the sig_refs list.."""
|
||||||
|
|
||||||
|
if self.sig_refs:
|
||||||
|
self.sig_refs = [
|
||||||
|
sig_ref if isinstance(sig_ref, SIGRef) else SIGRef(**sig_ref)
|
||||||
|
for sig_ref in self.sig_refs
|
||||||
|
]
|
||||||
|
|
||||||
def infer_missing(self, credentials=None):
|
def infer_missing(self, credentials=None):
|
||||||
"""Infer missing parameters where possible"""
|
"""Infer missing parameters where possible"""
|
||||||
|
|
||||||
@@ -255,7 +266,7 @@ class Spot:
|
|||||||
for sig_match in sig_matches:
|
for sig_match in sig_matches:
|
||||||
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster
|
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster
|
||||||
# spots where the comment is just "POTA".
|
# spots where the comment is just "POTA".
|
||||||
found_sig = sig_match.group(2).upper()
|
found_sig = get_sig_name_from_comment_name(sig_match.group(2))
|
||||||
if not self.sig:
|
if not self.sig:
|
||||||
self.sig = found_sig
|
self.sig = found_sig
|
||||||
|
|
||||||
@@ -312,7 +323,7 @@ class Spot:
|
|||||||
self.propagation_mode = PROPAGATION_MODES[mode_tag]
|
self.propagation_mode = PROPAGATION_MODES[mode_tag]
|
||||||
else:
|
else:
|
||||||
self.propagation_mode = mode_tag
|
self.propagation_mode = mode_tag
|
||||||
logging.info("Seen a new propagation mode tag not yet in the system: {}", mode_tag)
|
logging.info("Seen a new propagation mode tag not yet in the system: %s", mode_tag)
|
||||||
|
|
||||||
# DX Grid to lat/lon and vice versa in case one is missing
|
# DX Grid to lat/lon and vice versa in case one is missing
|
||||||
if self.dx_grid and not self.dx_latitude:
|
if self.dx_grid and not self.dx_latitude:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -17,3 +17,4 @@ websocket-client~=1.8.0
|
|||||||
tornado~=6.4.2
|
tornado~=6.4.2
|
||||||
tornado_eventsource~=3.0.0
|
tornado_eventsource~=3.0.0
|
||||||
geopandas~=0.13.2
|
geopandas~=0.13.2
|
||||||
|
simplejson~=4.1.1
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -14,8 +13,7 @@ from core.constants import UNKNOWN_BAND
|
|||||||
from core.lookup_helper import infer_band_from_freq
|
from core.lookup_helper import infer_band_from_freq
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
from core.prometheus_metrics_handler import api_requests_counter
|
||||||
from core.sig_utils import get_ref_regex_for_sig
|
from core.sig_utils import get_ref_regex_for_sig
|
||||||
from core.utils import serialize_everything
|
from core.utils import safe_json_dumps
|
||||||
from data.sig_ref import SIGRef
|
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
|
|
||||||
|
|
||||||
@@ -42,8 +40,7 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
# Reject if not allowed
|
# Reject if not allowed
|
||||||
if not ALLOW_SPOTTING:
|
if not ALLOW_SPOTTING:
|
||||||
self.set_status(401)
|
self.set_status(401)
|
||||||
self.write(json.dumps("Error - this server does not allow new spots to be added via the API.",
|
self.write(safe_json_dumps("Error - this server does not allow new spots to be added via the API."))
|
||||||
default=serialize_everything))
|
|
||||||
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
|
||||||
@@ -51,8 +48,7 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
# Reject if format not json
|
# Reject if format not json
|
||||||
if not self.request.headers.get('Content-Type', '').startswith("application/json"):
|
if not self.request.headers.get('Content-Type', '').startswith("application/json"):
|
||||||
self.set_status(415)
|
self.set_status(415)
|
||||||
self.write(
|
self.write(safe_json_dumps("Error - request Content-Type must be application/json"))
|
||||||
json.dumps("Error - request Content-Type must be application/json", default=serialize_everything))
|
|
||||||
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
|
||||||
@@ -61,7 +57,7 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
post_data = self.request.body
|
post_data = self.request.body
|
||||||
if not post_data:
|
if not post_data:
|
||||||
self.set_status(422)
|
self.set_status(422)
|
||||||
self.write(json.dumps("Error - request body is empty", default=serialize_everything))
|
self.write(safe_json_dumps("Error - request body is empty"))
|
||||||
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
|
||||||
@@ -70,19 +66,10 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
json_spot = tornado.escape.json_decode(post_data)
|
json_spot = tornado.escape.json_decode(post_data)
|
||||||
spot = Spot(**json_spot)
|
spot = Spot(**json_spot)
|
||||||
|
|
||||||
# Converting to a spot object this way won't have coped with sig_ref objects, so fix that. (Would be nice to
|
|
||||||
# redo this in a functional style)
|
|
||||||
if spot.sig_refs:
|
|
||||||
real_sig_refs = []
|
|
||||||
for dict_obj in spot.sig_refs:
|
|
||||||
real_sig_refs.append(json.loads(json.dumps(dict_obj), object_hook=lambda d: SIGRef(**d)))
|
|
||||||
spot.sig_refs = real_sig_refs
|
|
||||||
|
|
||||||
# Reject if no timestamp, frequency, dx_call or de_call
|
# Reject if no timestamp, frequency, dx_call or de_call
|
||||||
if not spot.time or not spot.dx_call or not spot.freq or not spot.de_call:
|
if not spot.time or not spot.dx_call or not spot.freq or not spot.de_call:
|
||||||
self.set_status(422)
|
self.set_status(422)
|
||||||
self.write(json.dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum.",
|
self.write(safe_json_dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum."))
|
||||||
default=serialize_everything))
|
|
||||||
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
|
||||||
@@ -90,15 +77,13 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
# Reject invalid-looking callsigns
|
# Reject invalid-looking callsigns
|
||||||
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call):
|
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call):
|
||||||
self.set_status(422)
|
self.set_status(422)
|
||||||
self.write(json.dumps("Error - '" + spot.dx_call + "' does not look like a valid callsign.",
|
self.write(safe_json_dumps("Error - '" + spot.dx_call + "' does not look like a valid callsign."))
|
||||||
default=serialize_everything))
|
|
||||||
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 re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call):
|
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call):
|
||||||
self.set_status(422)
|
self.set_status(422)
|
||||||
self.write(json.dumps("Error - '" + spot.de_call + "' does not look like a valid callsign.",
|
self.write(safe_json_dumps("Error - '" + spot.de_call + "' does not look like a valid callsign."))
|
||||||
default=serialize_everything))
|
|
||||||
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
|
||||||
@@ -106,8 +91,7 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
# Reject if frequency not in a known band
|
# Reject if frequency not in a known band
|
||||||
if infer_band_from_freq(spot.freq) == UNKNOWN_BAND:
|
if infer_band_from_freq(spot.freq) == UNKNOWN_BAND:
|
||||||
self.set_status(422)
|
self.set_status(422)
|
||||||
self.write(json.dumps("Error - Frequency of " + str(spot.freq / 1000.0) + "kHz is not in a known band.",
|
self.write(safe_json_dumps("Error - Frequency of " + str(spot.freq / 1000.0) + "kHz is not in a known band."))
|
||||||
default=serialize_everything))
|
|
||||||
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
|
||||||
@@ -117,8 +101,7 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
r"^([A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}|[A-R]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2})$",
|
r"^([A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}|[A-R]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2})$",
|
||||||
spot.dx_grid.upper()):
|
spot.dx_grid.upper()):
|
||||||
self.set_status(422)
|
self.set_status(422)
|
||||||
self.write(json.dumps("Error - '" + spot.dx_grid + "' does not look like a valid Maidenhead grid.",
|
self.write(safe_json_dumps("Error - '" + spot.dx_grid + "' does not look like a valid Maidenhead grid."))
|
||||||
default=serialize_everything))
|
|
||||||
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
|
||||||
@@ -127,9 +110,8 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
if spot.sig and spot.sig_refs and len(spot.sig_refs) > 0 and spot.sig_refs[0].id and get_ref_regex_for_sig(
|
if spot.sig and spot.sig_refs and len(spot.sig_refs) > 0 and spot.sig_refs[0].id and get_ref_regex_for_sig(
|
||||||
spot.sig) and not re.match(get_ref_regex_for_sig(spot.sig), spot.sig_refs[0].id):
|
spot.sig) and not re.match(get_ref_regex_for_sig(spot.sig), spot.sig_refs[0].id):
|
||||||
self.set_status(422)
|
self.set_status(422)
|
||||||
self.write(json.dumps(
|
self.write(safe_json_dumps(
|
||||||
"Error - '" + spot.sig_refs[0].id + "' does not look like a valid reference for " + spot.sig + ".",
|
"Error - '" + spot.sig_refs[0].id + "' does not look like a valid reference for " + spot.sig + "."))
|
||||||
default=serialize_everything))
|
|
||||||
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
|
||||||
@@ -139,14 +121,14 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
spot.infer_missing()
|
spot.infer_missing()
|
||||||
self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
|
self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
|
||||||
|
|
||||||
self.write(json.dumps("OK", default=serialize_everything))
|
self.write(safe_json_dumps("OK"))
|
||||||
self.set_status(201)
|
self.set_status(201)
|
||||||
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")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(e)
|
logging.error("Exception when handling client request to add spot API: %s", e, exc_info=True)
|
||||||
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
|
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||||
self.set_status(500)
|
self.set_status(500)
|
||||||
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")
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import copy
|
import copy
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from queue import Queue
|
from queue import Queue
|
||||||
@@ -12,7 +11,7 @@ from tornado import httputil
|
|||||||
from tornado.web import Application
|
from tornado.web import Application
|
||||||
|
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
from core.prometheus_metrics_handler import api_requests_counter
|
||||||
from core.utils import serialize_everything, empty_queue
|
from core.utils import safe_json_dumps, empty_queue
|
||||||
from data.lookup_credentials import extract_credentials
|
from data.lookup_credentials import extract_credentials
|
||||||
|
|
||||||
SSE_HANDLER_MAX_QUEUE_SIZE = 100
|
SSE_HANDLER_MAX_QUEUE_SIZE = 100
|
||||||
@@ -57,15 +56,14 @@ class APIAlertsHandler(tornado.web.RequestHandler):
|
|||||||
data = get_alert_list_with_filters(self._alerts, query_params)
|
data = get_alert_list_with_filters(self._alerts, query_params)
|
||||||
if credentials:
|
if credentials:
|
||||||
data = self._enrich(data, credentials)
|
data = self._enrich(data, credentials)
|
||||||
self.write(json.dumps(data, default=serialize_everything))
|
self.write(safe_json_dumps(data))
|
||||||
self.set_status(200)
|
self.set_status(200)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logging.error(e)
|
self.write(safe_json_dumps("Bad request - " + str(e)))
|
||||||
self.write(json.dumps("Bad request - " + str(e), default=serialize_everything))
|
|
||||||
self.set_status(400)
|
self.set_status(400)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(e)
|
logging.error("Exception when handling client request to alerts API: %s", e, exc_info=True)
|
||||||
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
|
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||||
self.set_status(500)
|
self.set_status(500)
|
||||||
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")
|
||||||
@@ -150,7 +148,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
|||||||
if self._credentials:
|
if self._credentials:
|
||||||
alert = copy.deepcopy(alert)
|
alert = copy.deepcopy(alert)
|
||||||
alert.infer_missing(self._credentials)
|
alert.infer_missing(self._credentials)
|
||||||
self.write_message(msg=json.dumps(alert, default=serialize_everything))
|
self.write_message(msg=safe_json_dumps(alert))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Send a keepalive comment if the queue was empty
|
# Send a keepalive comment if the queue was empty
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -9,6 +10,7 @@ from tornado import httputil
|
|||||||
from tornado.web import Application
|
from tornado.web import Application
|
||||||
|
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
from core.prometheus_metrics_handler import api_requests_counter
|
||||||
|
from core.utils import safe_json_dumps
|
||||||
|
|
||||||
CONTINENTS = ["EU", "NA", "SA", "AS", "AF", "OC", "AN"]
|
CONTINENTS = ["EU", "NA", "SA", "AS", "AF", "OC", "AN"]
|
||||||
BANDS = ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"]
|
BANDS = ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"]
|
||||||
@@ -29,6 +31,7 @@ class APIDxStatsHandler(tornado.web.RequestHandler):
|
|||||||
self._web_server_metrics = web_server_metrics
|
self._web_server_metrics = web_server_metrics
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
|
try:
|
||||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
||||||
self._web_server_metrics["api_access_counter"] += 1
|
self._web_server_metrics["api_access_counter"] += 1
|
||||||
self._web_server_metrics["status"] = "OK"
|
self._web_server_metrics["status"] = "OK"
|
||||||
@@ -55,3 +58,8 @@ class APIDxStatsHandler(tornado.web.RequestHandler):
|
|||||||
self.set_status(200)
|
self.set_status(200)
|
||||||
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")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("Exception when handling client request to dx stats API: %s", e, exc_info=True)
|
||||||
|
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||||
|
self.set_status(500)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -13,7 +12,7 @@ from core.constants import SIGS
|
|||||||
from core.geo_utils import lat_lon_for_grid_sw_corner_plus_size, lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
from core.geo_utils import lat_lon_for_grid_sw_corner_plus_size, lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
from core.prometheus_metrics_handler import api_requests_counter
|
||||||
from core.sig_utils import get_ref_regex_for_sig, populate_sig_ref_info
|
from core.sig_utils import get_ref_regex_for_sig, populate_sig_ref_info
|
||||||
from core.utils import serialize_everything
|
from core.utils import safe_json_dumps
|
||||||
from data.lookup_credentials import extract_credentials
|
from data.lookup_credentials import extract_credentials
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
@@ -65,19 +64,18 @@ class APILookupCallHandler(tornado.web.RequestHandler):
|
|||||||
"longitude": fake_spot.dx_longitude,
|
"longitude": fake_spot.dx_longitude,
|
||||||
"location_source": fake_spot.dx_location_source
|
"location_source": fake_spot.dx_location_source
|
||||||
}
|
}
|
||||||
self.write(json.dumps(data, default=serialize_everything))
|
self.write(safe_json_dumps(data))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.write(json.dumps("Error - '" + call + "' does not look like a valid callsign.",
|
self.write(safe_json_dumps("Error - '" + call + "' does not look like a valid callsign."))
|
||||||
default=serialize_everything))
|
|
||||||
self.set_status(422)
|
self.set_status(422)
|
||||||
else:
|
else:
|
||||||
self.write(json.dumps("Error - call must be provided", default=serialize_everything))
|
self.write(safe_json_dumps("Error - call must be provided"))
|
||||||
self.set_status(422)
|
self.set_status(422)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(e)
|
logging.error("Exception when handling client request to call lookup API: %s", e, exc_info=True)
|
||||||
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
|
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||||
self.set_status(500)
|
self.set_status(500)
|
||||||
|
|
||||||
self.set_header("Cache-Control", "no-store")
|
self.set_header("Cache-Control", "no-store")
|
||||||
@@ -111,27 +109,25 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
|
|||||||
if "sig" in query_params.keys() and "id" in query_params.keys():
|
if "sig" in query_params.keys() and "id" in query_params.keys():
|
||||||
sig = str(query_params.get("sig")).upper()
|
sig = str(query_params.get("sig")).upper()
|
||||||
ref_id = str(query_params.get("id")).upper()
|
ref_id = str(query_params.get("id")).upper()
|
||||||
if sig in list(map(lambda p: p.name, SIGS)):
|
if sig in list(map(lambda p: p.name.upper(), SIGS)):
|
||||||
if not get_ref_regex_for_sig(sig) or re.match(get_ref_regex_for_sig(sig), ref_id):
|
if not get_ref_regex_for_sig(sig) or re.match(get_ref_regex_for_sig(sig), ref_id):
|
||||||
data = populate_sig_ref_info(SIGRef(id=ref_id, sig=sig))
|
data = populate_sig_ref_info(SIGRef(id=ref_id, sig=sig))
|
||||||
self.write(json.dumps(data, default=serialize_everything))
|
self.write(safe_json_dumps(data))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.write(
|
self.write(safe_json_dumps(
|
||||||
json.dumps(
|
"Error - '" + ref_id + "' does not look like a valid reference ID for " + sig + "."))
|
||||||
"Error - '" + ref_id + "' does not look like a valid reference ID for " + sig + ".",
|
|
||||||
default=serialize_everything))
|
|
||||||
self.set_status(422)
|
self.set_status(422)
|
||||||
else:
|
else:
|
||||||
self.write(json.dumps("Error - sig '" + sig + "' is not known.", default=serialize_everything))
|
self.write(safe_json_dumps("Error - sig '" + sig + "' is not known."))
|
||||||
self.set_status(422)
|
self.set_status(422)
|
||||||
else:
|
else:
|
||||||
self.write(json.dumps("Error - sig and id must be provided", default=serialize_everything))
|
self.write(safe_json_dumps("Error - sig and id must be provided"))
|
||||||
self.set_status(422)
|
self.set_status(422)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(e)
|
logging.error("Exception when handling client request to sig ref lookup API: %s", e, exc_info=True)
|
||||||
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
|
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||||
self.set_status(500)
|
self.set_status(500)
|
||||||
|
|
||||||
self.set_header("Cache-Control", "no-store")
|
self.set_header("Cache-Control", "no-store")
|
||||||
@@ -185,15 +181,15 @@ class APILookupGridHandler(tornado.web.RequestHandler):
|
|||||||
"latitude": lat + lat_cell_size,
|
"latitude": lat + lat_cell_size,
|
||||||
"longitude": lon + lon_cell_size,
|
"longitude": lon + lon_cell_size,
|
||||||
}}
|
}}
|
||||||
self.write(json.dumps(response, default=serialize_everything))
|
self.write(safe_json_dumps(response))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.write(json.dumps("Error - grid must be provided", default=serialize_everything))
|
self.write(safe_json_dumps("Error - grid must be provided"))
|
||||||
self.set_status(422)
|
self.set_status(422)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(e)
|
logging.error("Exception when handling client request to grid ref lookup API: %s", e, exc_info=True)
|
||||||
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
|
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||||
self.set_status(500)
|
self.set_status(500)
|
||||||
|
|
||||||
self.set_header("Cache-Control", "no-store")
|
self.set_header("Cache-Control", "no-store")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import json
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ from tornado.web import Application
|
|||||||
from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING
|
from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING
|
||||||
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS, PROPAGATION_MODES
|
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS, PROPAGATION_MODES
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
from core.prometheus_metrics_handler import api_requests_counter
|
||||||
from core.utils import serialize_everything
|
from core.utils import safe_json_dumps
|
||||||
|
|
||||||
|
|
||||||
class APIOptionsHandler(tornado.web.RequestHandler):
|
class APIOptionsHandler(tornado.web.RequestHandler):
|
||||||
@@ -26,6 +26,7 @@ class APIOptionsHandler(tornado.web.RequestHandler):
|
|||||||
self._web_server_metrics = web_server_metrics
|
self._web_server_metrics = web_server_metrics
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
|
try:
|
||||||
# Metrics
|
# Metrics
|
||||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
||||||
self._web_server_metrics["api_access_counter"] += 1
|
self._web_server_metrics["api_access_counter"] += 1
|
||||||
@@ -50,7 +51,12 @@ class APIOptionsHandler(tornado.web.RequestHandler):
|
|||||||
if ALLOW_SPOTTING:
|
if ALLOW_SPOTTING:
|
||||||
options["spot_sources"].append("API")
|
options["spot_sources"].append("API")
|
||||||
|
|
||||||
self.write(json.dumps(options, default=serialize_everything))
|
self.write(safe_json_dumps(options))
|
||||||
self.set_status(200)
|
self.set_status(200)
|
||||||
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")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("Exception when handling client request to options API: %s", e, exc_info=True)
|
||||||
|
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||||
|
self.set_status(500)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ from tornado import httputil
|
|||||||
from tornado.web import Application
|
from tornado.web import Application
|
||||||
|
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
from core.prometheus_metrics_handler import api_requests_counter
|
||||||
|
from core.utils import safe_json_dumps
|
||||||
|
|
||||||
|
|
||||||
class APISolarConditionsHandler(tornado.web.RequestHandler):
|
class APISolarConditionsHandler(tornado.web.RequestHandler):
|
||||||
@@ -22,6 +24,7 @@ class APISolarConditionsHandler(tornado.web.RequestHandler):
|
|||||||
self._web_server_metrics = web_server_metrics
|
self._web_server_metrics = web_server_metrics
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
|
try:
|
||||||
# Metrics
|
# Metrics
|
||||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
||||||
self._web_server_metrics["api_access_counter"] += 1
|
self._web_server_metrics["api_access_counter"] += 1
|
||||||
@@ -32,3 +35,8 @@ class APISolarConditionsHandler(tornado.web.RequestHandler):
|
|||||||
self.set_status(200)
|
self.set_status(200)
|
||||||
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")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("Exception when handling client request to solar conditions API: %s", e, exc_info=True)
|
||||||
|
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||||
|
self.set_status(500)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import copy
|
import copy
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from queue import Queue
|
from queue import Queue
|
||||||
@@ -12,7 +11,7 @@ from tornado import httputil
|
|||||||
from tornado.web import Application
|
from tornado.web import Application
|
||||||
|
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
from core.prometheus_metrics_handler import api_requests_counter
|
||||||
from core.utils import serialize_everything, empty_queue
|
from core.utils import safe_json_dumps, empty_queue
|
||||||
from data.lookup_credentials import extract_credentials
|
from data.lookup_credentials import extract_credentials
|
||||||
|
|
||||||
SSE_HANDLER_MAX_QUEUE_SIZE = 1000
|
SSE_HANDLER_MAX_QUEUE_SIZE = 1000
|
||||||
@@ -57,15 +56,14 @@ class APISpotsHandler(tornado.web.RequestHandler):
|
|||||||
data = get_spot_list_with_filters(self._spots, query_params)
|
data = get_spot_list_with_filters(self._spots, query_params)
|
||||||
if credentials:
|
if credentials:
|
||||||
data = self._enrich(data, credentials)
|
data = self._enrich(data, credentials)
|
||||||
self.write(json.dumps(data, default=serialize_everything))
|
self.write(safe_json_dumps(data))
|
||||||
self.set_status(200)
|
self.set_status(200)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logging.error(e)
|
self.write(safe_json_dumps("Bad request - " + str(e)))
|
||||||
self.write(json.dumps("Bad request - " + str(e), default=serialize_everything))
|
|
||||||
self.set_status(400)
|
self.set_status(400)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(e)
|
logging.error("Excedption when handling client request to spots API: %s", e, exc_info=True)
|
||||||
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
|
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||||
self.set_status(500)
|
self.set_status(500)
|
||||||
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")
|
||||||
@@ -152,7 +150,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
|||||||
if self._credentials:
|
if self._credentials:
|
||||||
spot = copy.deepcopy(spot)
|
spot = copy.deepcopy(spot)
|
||||||
spot.infer_missing(self._credentials)
|
spot.infer_missing(self._credentials)
|
||||||
self.write_message(msg=json.dumps(spot, default=serialize_everything))
|
self.write_message(msg=safe_json_dumps(spot))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Send a keepalive comment if the queue was empty
|
# Send a keepalive comment if the queue was empty
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import json
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ from tornado import httputil
|
|||||||
from tornado.web import Application
|
from tornado.web import Application
|
||||||
|
|
||||||
from core.prometheus_metrics_handler import api_requests_counter
|
from core.prometheus_metrics_handler import api_requests_counter
|
||||||
from core.utils import serialize_everything
|
from core.utils import safe_json_dumps
|
||||||
|
|
||||||
|
|
||||||
class APIStatusHandler(tornado.web.RequestHandler):
|
class APIStatusHandler(tornado.web.RequestHandler):
|
||||||
@@ -24,13 +24,19 @@ class APIStatusHandler(tornado.web.RequestHandler):
|
|||||||
self._web_server_metrics = web_server_metrics
|
self._web_server_metrics = web_server_metrics
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
|
try:
|
||||||
# Metrics
|
# Metrics
|
||||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
||||||
self._web_server_metrics["api_access_counter"] += 1
|
self._web_server_metrics["api_access_counter"] += 1
|
||||||
self._web_server_metrics["status"] = "OK"
|
self._web_server_metrics["status"] = "OK"
|
||||||
api_requests_counter.inc()
|
api_requests_counter.inc()
|
||||||
|
|
||||||
self.write(json.dumps(self._status_data, default=serialize_everything))
|
self.write(safe_json_dumps(self._status_data))
|
||||||
self.set_status(200)
|
self.set_status(200)
|
||||||
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")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("Exception when handling client request to status API: %s", e, exc_info=True)
|
||||||
|
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||||
|
self.set_status(500)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import tornado.web
|
||||||
|
|
||||||
|
from core.config import BASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestHandler(tornado.web.RequestHandler):
|
||||||
|
"""Handler for manifest.webmanifest, which needs BASE_URL inserted and a custom content-type"""
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
self.set_header("Content-Type", "application/manifest+json; charset=UTF-8")
|
||||||
|
self.render("manifest.webmanifest", baseurl=BASE_URL)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from tornado.web import StaticFileHandler, HTTPError
|
||||||
|
|
||||||
|
|
||||||
|
class QuietStaticFileHandler(StaticFileHandler):
|
||||||
|
"""Minor override of logging in StaticFileHandler to log HTTP errors at debug level instead of their usual
|
||||||
|
warning level. Without this, attacks on Spothole which try to do path traversal attacks would log exceptions
|
||||||
|
from inside Tornado, and the server logs would contain a lot of this type of content. This effectively changes
|
||||||
|
the log level of these exceptions to DEBUG so they are only logged if DEBUG level logging is enabled."""
|
||||||
|
|
||||||
|
def log_exception(self, typ, value, tb):
|
||||||
|
if isinstance(value, HTTPError):
|
||||||
|
logging.debug(value)
|
||||||
|
return
|
||||||
|
super().log_exception(typ, value, tb)
|
||||||
+10
-4
@@ -5,7 +5,7 @@ import os
|
|||||||
import tornado
|
import tornado
|
||||||
from tornado.web import StaticFileHandler
|
from tornado.web import StaticFileHandler
|
||||||
|
|
||||||
from core.config import ALLOW_SPOTTING, WEB_SERVER_PORT, API_ONLY_MODE
|
from core.config import ALLOW_SPOTTING, WEB_SERVER_PORT, API_ONLY_MODE, LOG_WEB_REQUESTS, BASE_URL
|
||||||
from core.utils import empty_queue
|
from core.utils import empty_queue
|
||||||
from server.handlers.api.addspot import APISpotHandler
|
from server.handlers.api.addspot import APISpotHandler
|
||||||
from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
|
from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
|
||||||
@@ -15,8 +15,10 @@ from server.handlers.api.options import APIOptionsHandler
|
|||||||
from server.handlers.api.solar_conditions import APISolarConditionsHandler
|
from server.handlers.api.solar_conditions import APISolarConditionsHandler
|
||||||
from server.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
|
from server.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
|
||||||
from server.handlers.api.status import APIStatusHandler
|
from server.handlers.api.status import APIStatusHandler
|
||||||
|
from server.handlers.manifesthandler import ManifestHandler
|
||||||
from server.handlers.metrics import PrometheusMetricsHandler
|
from server.handlers.metrics import PrometheusMetricsHandler
|
||||||
from server.handlers.pagetemplate import PageTemplateHandler
|
from server.handlers.pagetemplate import PageTemplateHandler
|
||||||
|
from server.handlers.quietstaticfilehandler import QuietStaticFileHandler
|
||||||
|
|
||||||
_HERE = os.path.dirname(__file__ or "")
|
_HERE = os.path.dirname(__file__ or "")
|
||||||
|
|
||||||
@@ -99,11 +101,12 @@ class WebServer:
|
|||||||
if ALLOW_SPOTTING:
|
if ALLOW_SPOTTING:
|
||||||
ui_routes += [(r"/add-spot", PageTemplateHandler, {"template_name": "add_spot", **handler_opts})]
|
ui_routes += [(r"/add-spot", PageTemplateHandler, {"template_name": "add_spot", **handler_opts})]
|
||||||
|
|
||||||
# API docs, Prometheus metrics, and finally static assets are always available regardless of API-only mode.
|
# API docs, Prometheus metrics, webapp manifest and static assets are always available regardless of API-only mode.
|
||||||
misc_routes = [
|
misc_routes = [
|
||||||
(r"/apidocs", PageTemplateHandler, {"template_name": "apidocs", **handler_opts}),
|
(r"/apidocs", PageTemplateHandler, {"template_name": "apidocs", **handler_opts}),
|
||||||
(r"/metrics", PrometheusMetricsHandler),
|
(r"/metrics", PrometheusMetricsHandler),
|
||||||
(r"/(.*)", StaticFileHandler, {"path": os.path.join(_HERE, "../webassets")})
|
(r"/manifest.webmanifest", ManifestHandler),
|
||||||
|
(r"/static/(.*)", QuietStaticFileHandler, {"path": os.path.join(_HERE, "../webassets")})
|
||||||
]
|
]
|
||||||
|
|
||||||
app = tornado.web.Application(api_routes + ui_routes + misc_routes,
|
app = tornado.web.Application(api_routes + ui_routes + misc_routes,
|
||||||
@@ -112,6 +115,7 @@ class WebServer:
|
|||||||
debug=False)
|
debug=False)
|
||||||
app.listen(self._port, xheaders=True)
|
app.listen(self._port, xheaders=True)
|
||||||
logging.info("Web server running on port " + str(WEB_SERVER_PORT))
|
logging.info("Web server running on port " + str(WEB_SERVER_PORT))
|
||||||
|
logging.info("You can access your copy of Spothole at " + BASE_URL)
|
||||||
await self._shutdown_event.wait()
|
await self._shutdown_event.wait()
|
||||||
|
|
||||||
def notify_new_spot(self, spot):
|
def notify_new_spot(self, spot):
|
||||||
@@ -165,8 +169,10 @@ class WebServer:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def request_log(handler):
|
def request_log(handler):
|
||||||
"""Custom log function to provide more data about requests."""
|
"""Custom log function to provide more data about requests when enabled, and to provide the ability to turn off
|
||||||
|
web request logging altogetether."""
|
||||||
|
|
||||||
|
if LOG_WEB_REQUESTS:
|
||||||
if handler.get_status() < 500:
|
if handler.get_status() < 500:
|
||||||
log_method = logging.info
|
log_method = logging.info
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from threading import Thread, Event
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
|
from requests.exceptions import ConnectionError
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from solarconditionsproviders.ionosonde_utils import compute_band_states
|
from solarconditionsproviders.ionosonde_utils import compute_band_states
|
||||||
@@ -127,10 +128,15 @@ class GIROIonosonde(SolarConditionsProvider):
|
|||||||
from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S")
|
from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S")
|
||||||
to_str = to_time.strftime("%Y.%m.%d+%H:%M:%S")
|
to_str = to_time.strftime("%Y.%m.%d+%H:%M:%S")
|
||||||
url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUFD,fmin&DMUF=3000&fromDate={from_str}&toDate={to_str}"
|
url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUFD,fmin&DMUF=3000&fromDate={from_str}&toDate={to_str}"
|
||||||
response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15))
|
try:
|
||||||
if response.status_code != 200:
|
http_response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15))
|
||||||
|
if not http_response.ok:
|
||||||
|
logging.warning(f"HTTP {http_response.status_code} when calling Giro ionosonde API.")
|
||||||
|
return None, None, None
|
||||||
|
return self._parse_all(http_response.text)
|
||||||
|
except ConnectionError:
|
||||||
|
logging.warning("Connection error when accessing Giro ionosonde API.")
|
||||||
return None, None, None
|
return None, None, None
|
||||||
return self._parse_all(response.text)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_all(text):
|
def _parse_all(text):
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ class HamQSL(HTTPSolarConditionsProvider):
|
|||||||
super().__init__(provider_config, URL, POLL_INTERVAL)
|
super().__init__(provider_config, URL, POLL_INTERVAL)
|
||||||
|
|
||||||
def _http_response_to_solar_conditions(self, http_response):
|
def _http_response_to_solar_conditions(self, http_response):
|
||||||
if http_response.status_code != 200:
|
|
||||||
logging.warning("HamQSL solar conditions API returned HTTP " + str(http_response.status_code))
|
|
||||||
return None
|
|
||||||
|
|
||||||
root = ElementTree.fromstring(http_response.text)
|
root = ElementTree.fromstring(http_response.text)
|
||||||
sd = root.find("solardata")
|
sd = root.find("solardata")
|
||||||
if sd is None:
|
if sd is None:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from threading import Thread, Event
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
|
from requests.exceptions import ConnectionError
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from solarconditionsproviders.solar_conditions_provider import SolarConditionsProvider
|
from solarconditionsproviders.solar_conditions_provider import SolarConditionsProvider
|
||||||
@@ -39,13 +40,20 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
|
|||||||
try:
|
try:
|
||||||
logging.debug("Polling " + self.name + " solar conditions API...")
|
logging.debug("Polling " + self.name + " solar conditions API...")
|
||||||
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
|
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
|
||||||
|
# Check response code was good
|
||||||
|
if http_response.ok:
|
||||||
new_data = self._http_response_to_solar_conditions(http_response)
|
new_data = self._http_response_to_solar_conditions(http_response)
|
||||||
self.update_data(new_data)
|
self.update_data(new_data)
|
||||||
|
|
||||||
self.status = "OK"
|
self.status = "OK"
|
||||||
self.last_update_time = datetime.now(pytz.UTC)
|
self.last_update_time = datetime.now(pytz.UTC)
|
||||||
logging.debug("Received data from " + self.name + " solar conditions API.")
|
logging.debug("Received data from " + self.name + " solar conditions API.")
|
||||||
|
else:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.warning(f"HTTP {http_response.status_code} when calling {self.name} solar conditions API.")
|
||||||
|
|
||||||
|
except ConnectionError:
|
||||||
|
logging.warning(f"Connection error when accessing {self.name} solar conditions API.")
|
||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logging.exception("Exception in HTTP Solar Conditions Provider (" + self.name + ")")
|
logging.exception("Exception in HTTP Solar Conditions Provider (" + self.name + ")")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from threading import Thread, Event
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
|
from requests.exceptions import ConnectionError
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from solarconditionsproviders.ionosonde_utils import compute_band_states
|
from solarconditionsproviders.ionosonde_utils import compute_band_states
|
||||||
@@ -44,9 +45,9 @@ class KC2GProp(SolarConditionsProvider):
|
|||||||
def _poll(self):
|
def _poll(self):
|
||||||
try:
|
try:
|
||||||
logging.debug("Polling KC2G ionosonde data...")
|
logging.debug("Polling KC2G ionosonde data...")
|
||||||
response = requests.get(KC2G_URL, headers=HTTP_HEADERS, timeout=(5, 30))
|
http_response = requests.get(KC2G_URL, headers=HTTP_HEADERS, timeout=(5, 30))
|
||||||
if response.status_code != 200:
|
if not http_response.ok:
|
||||||
logging.warning(f"KC2G ionosonde API returned HTTP {response.status_code}")
|
logging.warning(f"HTTP {http_response.status_code} when calling KG2G ionosonde API.")
|
||||||
return
|
return
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -57,7 +58,7 @@ class KC2GProp(SolarConditionsProvider):
|
|||||||
ionosonde_data = dict(self._solar_conditions.ionosonde_data or {})
|
ionosonde_data = dict(self._solar_conditions.ionosonde_data or {})
|
||||||
updated_count = 0
|
updated_count = 0
|
||||||
|
|
||||||
for reading in response.json():
|
for reading in http_response.json():
|
||||||
station = reading.get("station", {})
|
station = reading.get("station", {})
|
||||||
ursi = station.get("code")
|
ursi = station.get("code")
|
||||||
name = station.get("name")
|
name = station.get("name")
|
||||||
@@ -115,6 +116,8 @@ class KC2GProp(SolarConditionsProvider):
|
|||||||
self.last_update_time = datetime.now(pytz.UTC)
|
self.last_update_time = datetime.now(pytz.UTC)
|
||||||
logging.debug(f"Updated KC2G ionosonde data for {updated_count} stations.")
|
logging.debug(f"Updated KC2G ionosonde data for {updated_count} stations.")
|
||||||
|
|
||||||
|
except ConnectionError:
|
||||||
|
logging.warning("Connection error when accessing KC2G ionosonde API.")
|
||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logging.exception("Exception in KC2G ionosonde data provider")
|
logging.exception("Exception in KC2G ionosonde data provider")
|
||||||
|
|||||||
@@ -80,10 +80,6 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
|
|||||||
return result if result else None
|
return result if result else None
|
||||||
|
|
||||||
def _http_response_to_solar_conditions(self, http_response):
|
def _http_response_to_solar_conditions(self, http_response):
|
||||||
if http_response.status_code != 200:
|
|
||||||
logging.warning("NOAA K-index forecast API returned HTTP " + str(http_response.status_code))
|
|
||||||
return None
|
|
||||||
|
|
||||||
lines = http_response.text.splitlines()
|
lines = http_response.text.splitlines()
|
||||||
|
|
||||||
# Find the "NOAA Kp index breakdown" section header
|
# Find the "NOAA Kp index breakdown" section header
|
||||||
|
|||||||
+3
-3
@@ -8,7 +8,7 @@ import sys
|
|||||||
from diskcache import Cache
|
from diskcache import Cache
|
||||||
|
|
||||||
from core.cleanup import CleanupTimer
|
from core.cleanup import CleanupTimer
|
||||||
from core.config import config, SERVER_OWNER_CALLSIGN
|
from core.config import config, SERVER_OWNER_CALLSIGN, LOG_LEVEL
|
||||||
from core.constants import SOFTWARE_VERSION
|
from core.constants import SOFTWARE_VERSION
|
||||||
from core.lookup_helper import lookup_helper
|
from core.lookup_helper import lookup_helper
|
||||||
from core.status_reporter import StatusReporter
|
from core.status_reporter import StatusReporter
|
||||||
@@ -84,9 +84,9 @@ def get_solar_conditions_provider_from_config(config_providers_entry):
|
|||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
# Set up logging
|
# Set up logging
|
||||||
root = logging.getLogger()
|
root = logging.getLogger()
|
||||||
root.setLevel(logging.INFO)
|
root.setLevel(LOG_LEVEL)
|
||||||
handler = logging.StreamHandler(sys.stdout)
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
handler.setLevel(logging.INFO)
|
handler.setLevel(LOG_LEVEL)
|
||||||
formatter = logging.Formatter("%(levelname)s : %(message)s")
|
formatter = logging.Formatter("%(levelname)s : %(message)s")
|
||||||
handler.setFormatter(formatter)
|
handler.setFormatter(formatter)
|
||||||
root.handlers.clear()
|
root.handlers.clear()
|
||||||
|
|||||||
+25
-13
@@ -14,9 +14,9 @@ class GMA(HTTPSpotProvider):
|
|||||||
"""Spot provider for General Mountain Activity"""
|
"""Spot provider for General Mountain Activity"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
SPOTS_URL = "https://www.cqgma.org/api/spots/25/"
|
SPOTS_URL = "https://www.gma.rocks/api/spots/25/"
|
||||||
# GMA spots don't contain the details of the programme they are for, we need a separate lookup for that
|
# GMA spots don't contain the details of the programme they are for, we need a separate lookup for that
|
||||||
REF_INFO_URL_ROOT = "https://www.cqgma.org/api/ref/?"
|
REF_INFO_URL_ROOT = "https://www.gma.rocks/api/ref/?"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
# Ensure there is an API key in our config, and set up the query URL using it. If no key is provided,
|
# Ensure there is an API key in our config, and set up the query URL using it. If no key is provided,
|
||||||
@@ -37,34 +37,38 @@ class GMA(HTTPSpotProvider):
|
|||||||
spot = Spot(source=self.name,
|
spot = Spot(source=self.name,
|
||||||
dx_call=source_spot["ACTIVATOR"].upper(),
|
dx_call=source_spot["ACTIVATOR"].upper(),
|
||||||
de_call=source_spot["SPOTTER"].upper(),
|
de_call=source_spot["SPOTTER"].upper(),
|
||||||
freq=float(source_spot["QRG"]) * 1000 if (source_spot["QRG"] != "") else None,
|
# Seen GMA spots with no frequency or with "QRT" in this field
|
||||||
# Seen GMA spots with no frequency
|
freq=float(source_spot["QRG"]) * 1000 if (
|
||||||
mode=source_spot["MODE"].upper() if "<>" not in source_spot["MODE"] else None,
|
source_spot["QRG"] != "" and source_spot["QRG"] != "QRT") else None,
|
||||||
# Filter out some weird mode strings
|
# Filter out some weird mode strings
|
||||||
|
mode=source_spot["MODE"].upper() if "<>" not in source_spot["MODE"] else None,
|
||||||
comment=source_spot["TEXT"],
|
comment=source_spot["TEXT"],
|
||||||
sig_refs=[SIGRef(id=source_spot["REF"], sig="", name=source_spot["NAME"])],
|
sig_refs=[SIGRef(id=source_spot["REF"], sig="", name=source_spot["NAME"])],
|
||||||
time=datetime.strptime(source_spot["DATE"] + source_spot["TIME"], "%Y%m%d%H%M").replace(
|
time=datetime.strptime(source_spot["DATE"] + source_spot["TIME"], "%Y%m%d%H%M").replace(
|
||||||
tzinfo=pytz.UTC).timestamp(),
|
tzinfo=pytz.UTC).timestamp(),
|
||||||
|
# Seen GMA spots with no (or empty) lat/lon
|
||||||
dx_latitude=float(source_spot["LAT"]) if (
|
dx_latitude=float(source_spot["LAT"]) if (
|
||||||
source_spot["LAT"] and source_spot["LAT"] != "") else None,
|
source_spot["LAT"] and source_spot["LAT"] != "") else None,
|
||||||
# Seen GMA spots with no (or empty) lat/lon
|
|
||||||
dx_longitude=float(source_spot["LON"]) if (
|
dx_longitude=float(source_spot["LON"]) if (
|
||||||
source_spot["LON"] and source_spot["LON"] != "") else None)
|
source_spot["LON"] and source_spot["LON"] != "") else None,
|
||||||
|
qrt=source_spot["QRG"] == "QRT")
|
||||||
|
|
||||||
# GMA doesn't give what programme (SIG) the reference is for until we separately look it up.
|
# GMA doesn't give what programme (SIG) the reference is for until we separately look it up.
|
||||||
if "REF" in source_spot:
|
if "REF" in source_spot:
|
||||||
try:
|
try:
|
||||||
ref_response = SEMI_STATIC_URL_DATA_CACHE.get(self.REF_INFO_URL_ROOT + source_spot["REF"],
|
ref_response = SEMI_STATIC_URL_DATA_CACHE.get(self.REF_INFO_URL_ROOT + source_spot["REF"],
|
||||||
headers=HTTP_HEADERS)
|
headers=HTTP_HEADERS)
|
||||||
# Sometimes this is blank, so handle that
|
# Sometimes this is blank even if it's a 200 response, so handle that
|
||||||
if ref_response.text is not None and ref_response.text != "":
|
if ref_response.ok and ref_response.text is not None and ref_response.text != "":
|
||||||
ref_info = ref_response.json()
|
ref_info = ref_response.json()
|
||||||
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. POTA and WWFF
|
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. POTA and WWFF
|
||||||
# spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA
|
# spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA
|
||||||
# and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry
|
# and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry
|
||||||
# to determine if it's a SOTA summit.
|
# to determine if it's a SOTA summit.
|
||||||
if spot.sig_refs and "reftype" in ref_info and ref_info["reftype"] not in ["POTA", "WWFF"] and (
|
if spot.sig_refs and "reftype" in ref_info and ref_info["reftype"] not in ["POTA",
|
||||||
ref_info["reftype"] != "Summit" or "sota" not in ref_info or ref_info["sota"] == ""):
|
"WWFF"] and (
|
||||||
|
ref_info["reftype"] != "Summit" or "sota" not in ref_info or ref_info[
|
||||||
|
"sota"] == ""):
|
||||||
match ref_info["reftype"]:
|
match ref_info["reftype"]:
|
||||||
case "Summit":
|
case "Summit":
|
||||||
spot.sig_refs[0].sig = "GMA"
|
spot.sig_refs[0].sig = "GMA"
|
||||||
@@ -90,9 +94,17 @@ class GMA(HTTPSpotProvider):
|
|||||||
spot.sig_refs[0].sig = ref_info["reftype"]
|
spot.sig_refs[0].sig = ref_info["reftype"]
|
||||||
spot.sig = ref_info["reftype"]
|
spot.sig = ref_info["reftype"]
|
||||||
|
|
||||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point;
|
||||||
# that for us.
|
# other code will do that for us.
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
|
|
||||||
|
elif not ref_response.from_cache:
|
||||||
|
if not ref_response.ok:
|
||||||
|
logging.warning(
|
||||||
|
f"HTTP {ref_response.status_code} when looking up GMA ref {source_spot["REF"]}")
|
||||||
|
else:
|
||||||
|
logging.warning(
|
||||||
|
f"GMA API returned a malformed response when looking up ref {source_spot["REF"]}")
|
||||||
except:
|
except:
|
||||||
logging.warning("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot[
|
logging.warning("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot[
|
||||||
"REF"] + ", ignoring this spot for now")
|
"REF"] + ", ignoring this spot for now")
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
|
from requests.exceptions import ConnectionError
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
@@ -35,6 +37,7 @@ class HEMA(HTTPSpotProvider):
|
|||||||
new_spots = []
|
new_spots = []
|
||||||
# OK, if the spot seed actually changed, now we make the real request for data.
|
# OK, if the spot seed actually changed, now we make the real request for data.
|
||||||
if spot_seed_changed:
|
if spot_seed_changed:
|
||||||
|
try:
|
||||||
source_data = requests.get(self.SPOTS_URL, headers=HTTP_HEADERS, timeout=(5, 30))
|
source_data = requests.get(self.SPOTS_URL, headers=HTTP_HEADERS, timeout=(5, 30))
|
||||||
source_data_items = source_data.text.split("=")
|
source_data_items = source_data.text.split("=")
|
||||||
# Iterate through source data items.
|
# Iterate through source data items.
|
||||||
@@ -62,7 +65,9 @@ class HEMA(HTTPSpotProvider):
|
|||||||
dx_latitude=float(spot_items[7]),
|
dx_latitude=float(spot_items[7]),
|
||||||
dx_longitude=float(spot_items[8]))
|
dx_longitude=float(spot_items[8]))
|
||||||
|
|
||||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other
|
||||||
# that for us.
|
# code will do that for us.
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
|
except ConnectionError:
|
||||||
|
logging.warning("Connection error when accessing HEMA spots API.")
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from threading import Thread, Event
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
|
from requests.exceptions import ConnectionError
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from spotproviders.spot_provider import SpotProvider
|
from spotproviders.spot_provider import SpotProvider
|
||||||
@@ -41,6 +42,8 @@ class HTTPSpotProvider(SpotProvider):
|
|||||||
# Request data from API
|
# Request data from API
|
||||||
logging.debug("Polling " + self.name + " spot API...")
|
logging.debug("Polling " + self.name + " spot API...")
|
||||||
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
|
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
|
||||||
|
# Check response code was good
|
||||||
|
if http_response.ok:
|
||||||
# Pass off to the subclass for processing
|
# Pass off to the subclass for processing
|
||||||
new_spots = self._http_response_to_spots(http_response)
|
new_spots = self._http_response_to_spots(http_response)
|
||||||
# Submit the new spots for processing. There might not be any spots for the less popular programs.
|
# Submit the new spots for processing. There might not be any spots for the less popular programs.
|
||||||
@@ -50,7 +53,12 @@ class HTTPSpotProvider(SpotProvider):
|
|||||||
self.status = "OK"
|
self.status = "OK"
|
||||||
self.last_update_time = datetime.now(pytz.UTC)
|
self.last_update_time = datetime.now(pytz.UTC)
|
||||||
logging.debug("Received data from " + self.name + " spot API.")
|
logging.debug("Received data from " + self.name + " spot API.")
|
||||||
|
else:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.warning(f"HTTP {http_response.status_code} when calling {self.name} spot API.")
|
||||||
|
|
||||||
|
except ConnectionError:
|
||||||
|
logging.warning(f"Connection error when accessing {self.name} spots API.")
|
||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logging.exception("Exception in HTTP JSON Spot Provider (" + self.name + ")")
|
logging.exception("Exception in HTTP JSON Spot Provider (" + self.name + ")")
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
dx_call=source_spot["actCallsign"].upper(),
|
dx_call=source_spot["actCallsign"].upper(),
|
||||||
de_call=source_spot["actSpoter"].upper() if source_spot["actSpoter"] != "" else None,
|
de_call=source_spot["actSpoter"].upper() if source_spot["actSpoter"] != "" else None,
|
||||||
# typo exists in API
|
# typo exists in API
|
||||||
freq=float(source_spot["actFreq"].replace(",", "")) * 1000000 if (
|
freq=float(source_spot["actFreq"].replace(",", "").replace("+-", "")
|
||||||
source_spot["actFreq"] != "") else None,
|
.replace("+/-", "").strip()) * 1000000 if (source_spot["actFreq"] != "") else None,
|
||||||
# Seen PNP spots with empty frequency, and with comma-separated thousands digits
|
# Seen PNP spots with empty frequency, and with comma-separated thousands digits
|
||||||
mode=source_spot["actMode"].upper(),
|
mode=source_spot["actMode"].upper(),
|
||||||
comment=source_spot["actComments"],
|
comment=source_spot["actComments"],
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from requests.exceptions import ConnectionError
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
@@ -33,6 +35,7 @@ class SOTA(HTTPSpotProvider):
|
|||||||
new_spots = []
|
new_spots = []
|
||||||
# OK, if the epoch actually changed, now we make the real request for data.
|
# OK, if the epoch actually changed, now we make the real request for data.
|
||||||
if epoch_changed:
|
if epoch_changed:
|
||||||
|
try:
|
||||||
source_data = requests.get(self.SPOTS_URL, headers=HTTP_HEADERS, timeout=(5, 30)).json()
|
source_data = requests.get(self.SPOTS_URL, headers=HTTP_HEADERS, timeout=(5, 30)).json()
|
||||||
# Iterate through source data
|
# Iterate through source data
|
||||||
for source_spot in source_data:
|
for source_spot in source_data:
|
||||||
@@ -55,4 +58,6 @@ class SOTA(HTTPSpotProvider):
|
|||||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
||||||
# that for us.
|
# that for us.
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
|
except ConnectionError:
|
||||||
|
logging.warning("Connection error when accessing SOTA spots API")
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from data.spot import Spot
|
|||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
|
|
||||||
class WWTOTA(HTTPSpotProvider):
|
class Towers(HTTPSpotProvider):
|
||||||
"""Spot provider for Towers on the Air"""
|
"""Spot provider for Towers on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
+13
-4
@@ -101,9 +101,9 @@
|
|||||||
(MOTA), the Amateur Radio Lighthouse Socirty (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos
|
(MOTA), the Amateur Radio Lighthouse Socirty (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos
|
||||||
on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National
|
on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National
|
||||||
Parks Award (KRMNPA), Wainwrights on the Air (WOTA), Beaches on the Air (BOTA), Lagos y Lagunas On the Air
|
Parks Award (KRMNPA), Wainwrights on the Air (WOTA), Beaches on the Air (BOTA), Lagos y Lagunas On the Air
|
||||||
(LLOTA), Towers on the Air (WWTOTA), Tiles on the Air, Worked All Britain (WAB), Worked All Ireland (WAI), and
|
(LLOTA), Towers on the Air (WWTOTA), Tiles on the Air, Worked All Britain (WAB), Worked All Ireland (WAI), el
|
||||||
Toilets on the Air (TOTA).</p>
|
Diploma Municipios de España (DME) and Toilets on the Air (TOTA).</p>
|
||||||
<p>As of the time of writing in November 2025, I think Spothole captures essentially all outdoor radio programmes
|
<p>As of the time of writing in June 2026, I think Spothole captures essentially all outdoor radio programmes
|
||||||
that have a defined reference list, and almost certainly those that have a spotting/alerting API. If you know of
|
that have a defined reference list, and almost certainly those that have a spotting/alerting API. If you know of
|
||||||
one I've missed, please let me know!</p>
|
one I've missed, please let me know!</p>
|
||||||
<h4 class="mt-4">Why can I filter spots by both SIG and Source? Isn't that basically the same thing?</h4>
|
<h4 class="mt-4">Why can I filter spots by both SIG and Source? Isn't that basically the same thing?</h4>
|
||||||
@@ -159,13 +159,22 @@
|
|||||||
modify it however you like, you can claim you wrote it and charge people £1000 for a copy, I don't really mind.
|
modify it however you like, you can claim you wrote it and charge people £1000 for a copy, I don't really mind.
|
||||||
(Please don't do the last one. But if you're using my code for something cool, it would be nice to hear from
|
(Please don't do the last one. But if you're using my code for something cool, it would be nice to hear from
|
||||||
you!)</p>
|
you!)</p>
|
||||||
<h2 class="mt-4">Data Accuracy</h2>
|
<h2 id="accuracy" class="mt-4">Data Accuracy</h2>
|
||||||
<p>Please note that the data coming out of Spothole is only as good as the data going in. People mis-hear and make
|
<p>Please note that the data coming out of Spothole is only as good as the data going in. People mis-hear and make
|
||||||
typos when spotting callsigns all the time. There are also plenty of cases where Spothole's data, particularly
|
typos when spotting callsigns all the time. There are also plenty of cases where Spothole's data, particularly
|
||||||
location data, may be inaccurate. For example, there are POTA parks that span multiple US states, countries that
|
location data, may be inaccurate. For example, there are POTA parks that span multiple US states, countries that
|
||||||
span multiple CQ zones, portable operators with no requirement to sign /P, etc. If you are doing something where
|
span multiple CQ zones, portable operators with no requirement to sign /P, etc. If you are doing something where
|
||||||
accuracy is important, such as contesting, you should not rely on Spothole's data to fill in any gaps in your
|
accuracy is important, such as contesting, you should not rely on Spothole's data to fill in any gaps in your
|
||||||
log.</p>
|
log.</p>
|
||||||
|
<p>In the Spothole user interface, under "Your Data", you can enter your credentials for QRZ.com and/or HamQTH if
|
||||||
|
you have them. This allows Spothole to augment its data with lookups from these services. See the following
|
||||||
|
section, Privacy, for details of how these are handled. If you are looking at the map and see lots of spots in
|
||||||
|
the geographic centre of countries, allowing these lookups will help. For QRZ.com a paid account is required to
|
||||||
|
look up operator locations.</p>
|
||||||
|
<p>IOTA in particular causes a mapping problem, firstly that there is no official set of geodata beyond the names of
|
||||||
|
islands. We also have no way of telling whether priority should be given to IOTA reference or e.g. QRZ lookup,
|
||||||
|
for example for a G callsign in EU-005 Great Britain the QRZ home address is probably more accurate, but in
|
||||||
|
EU-002 Aaland islands, it's more likely a DXpedition and the IOTA would be more accurate.</p>
|
||||||
<h2 id="privacy" class="mt-4">Privacy</h2>
|
<h2 id="privacy" class="mt-4">Privacy</h2>
|
||||||
<p>Spothole collects no data about you on a permanent basis. All spots and alerts are "timed out" and deleted from
|
<p>Spothole collects no data about you on a permanent basis. All spots and alerts are "timed out" and deleted from
|
||||||
the system after a set interval, which by default is one hour for spots and one week for alerts.</p>
|
the system after a set interval, which by default is one hour for spots and one week for alerts.</p>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/add-spot.js?v=1782076701"></script>
|
<script src="/static/js/add-spot.js?v=1784995281"></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>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/alerts.js?v=1782076701"></script>
|
<script src="/static/js/alerts.js?v=1784995281"></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>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{% extends "skeleton.html" %}
|
{% extends "skeleton.html" %}
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link href="/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
|
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
|
||||||
{% end %}
|
{% end %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="container mt-5">
|
<div class="container mt-5">
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
<img src="/img/logo.png" width="192" height="60" alt="Spothole">
|
<img src="/static/img/logo.png" width="192" height="60" alt="Spothole">
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "skeleton.html" %}
|
{% extends "skeleton.html" %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<redoc spec-url="/apidocs/openapi.yml"></redoc>
|
<redoc spec-url="/static/apidocs/openapi.yml"></redoc>
|
||||||
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
|
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<div id="settingsButtonRow" class="row mb-3">
|
<div id="settingsButtonRow" class="row mb-3">
|
||||||
<div class="col-auto me-auto pt-3">
|
<div class="col-auto me-auto pt-3"></div>
|
||||||
{% module Template("widgets/refresh-timer.html", web_ui_options=web_ui_options) %}
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<div class="d-inline-flex gap-1">
|
<div class="d-inline-flex gap-1">
|
||||||
{% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options) %}
|
{% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options) %}
|
||||||
@@ -77,8 +75,8 @@
|
|||||||
<script>
|
<script>
|
||||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=1782076701"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1784995281"></script>
|
||||||
<script src="/js/bands.js?v=1782076701"></script>
|
<script src="/static/js/bands.js?v=1784995281"></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>
|
||||||
|
|||||||
+15
-14
@@ -1,26 +1,26 @@
|
|||||||
{% extends "skeleton.html" %}
|
{% extends "skeleton.html" %}
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/css/style.css?v=1782076701" type="text/css">
|
<link rel="stylesheet" href="/static/css/style.css?v=1784995281" type="text/css">
|
||||||
<link href="/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="/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="/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
|
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
|
||||||
|
|
||||||
<script src="/vendor/js/jquery-3.7.1.min.js"></script>
|
<script src="/static/vendor/js/jquery-3.7.1.min.js"></script>
|
||||||
<script src="/vendor/js/moment-2.29.4.min.js"></script>
|
<script src="/static/vendor/js/moment-2.29.4.min.js"></script>
|
||||||
<script src="/vendor/js/bootstrap-5.3.8.bundle.min.js"></script>
|
<script src="/static/vendor/js/bootstrap-5.3.8.bundle.min.js"></script>
|
||||||
<script src="/vendor/js/tinycolor2-1.6.0.min.js"></script>
|
<script src="/static/vendor/js/tinycolor2-1.6.0.min.js"></script>
|
||||||
|
|
||||||
<script src="/js/utils.js?v=1782076701"></script>
|
<script src="/static/js/utils.js?v=1784995281"></script>
|
||||||
<script src="/js/ui-ham.js?v=1782076701"></script>
|
<script src="/static/js/ui-ham.js?v=1784995281"></script>
|
||||||
<script src="/js/geo.js?v=1782076701"></script>
|
<script src="/static/js/geo.js?v=1784995281"></script>
|
||||||
<script src="/js/common.js?v=1782076701"></script>
|
<script src="/static/js/common.js?v=1784995281"></script>
|
||||||
{% end %}
|
{% end %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<nav id="header" class="navbar navbar-expand-lg bg-body p-0 border-bottom">
|
<nav id="header" class="navbar navbar-expand-lg bg-body p-0 border-bottom">
|
||||||
<div class="container-fluid p-0">
|
<div class="container-fluid p-0">
|
||||||
<a class="navbar-brand" href="/">
|
<a class="navbar-brand" href="/">
|
||||||
<img src="/img/logo.png" class="logo" width="192" height="60" alt="Spothole">
|
<img src="/static/img/logo.png" class="logo" width="192" height="60" alt="Spothole">
|
||||||
</a>
|
</a>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
|
||||||
data-bs-target="#navbar-toggler-content" aria-controls="navbar-toggler-content"
|
data-bs-target="#navbar-toggler-content" aria-controls="navbar-toggler-content"
|
||||||
@@ -87,7 +87,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="embeddedModeFooter" class="text-body-secondary pt-2 px-3 pb-1">Powered by <img src="/img/logo.png" class="logo"
|
<div id="embeddedModeFooter" class="text-body-secondary pt-2 px-3 pb-1">Powered by <img src="/static/img/logo.png"
|
||||||
|
class="logo"
|
||||||
width="96" height="30"
|
width="96" height="30"
|
||||||
alt="Spothole"></div>
|
alt="Spothole"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -283,8 +283,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/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="/js/conditions.js?v=1782076701"></script>
|
<script src="/static/js/conditions.js?v=1784995281"></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>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"short_name": "Spothole",
|
"short_name": "Spothole",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"start_url": "https://spothole.app/",
|
"start_url": "{{ baseurl }}",
|
||||||
"background_color": "white",
|
"background_color": "white",
|
||||||
"theme_color": "white",
|
"theme_color": "white",
|
||||||
"description": "An Amateur Radio spotting tool bringing together DX clusters and outdoor programmes, providing a universal JSON API and web interface.",
|
"description": "An Amateur Radio spotting tool bringing together DX clusters and outdoor programmes, providing a universal JSON API and web interface.",
|
||||||
@@ -13,17 +13,17 @@
|
|||||||
"prefer_related_applications": false,
|
"prefer_related_applications": false,
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/img/icon-192-pwa.png",
|
"src": "/static/img/icon-192-pwa.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/img/icon-512-pwa.png",
|
"src": "/static/img/icon-512-pwa.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"url": "https://spothole.app"
|
"url": "{{ baseurl }}"
|
||||||
}
|
}
|
||||||
+29
-16
@@ -1,6 +1,19 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
|
<div id="map-intro-box" class="permanently-dismissible-box mt-3">
|
||||||
|
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||||
|
<i class="fa-solid fa-circle-info"></i> <strong>Spothole's map</strong><br/>Spothole pulls in location data from
|
||||||
|
a number of sources to try and provide the best map possible. However, please don't trust them to be anything
|
||||||
|
other than a rough guide. In particular, if you are seeing lots of spots in the centre of a country, please
|
||||||
|
consider opening the "Your Data" menu and providing QRZ.com and/or HamQTH credentials so that Spothole can find
|
||||||
|
more accurate locations for these spots. More information can be found in the
|
||||||
|
<a href="/about#accuracy" class="alert-link">Data Accuracy section of the About page</a>.
|
||||||
|
<button type="button" id="map-intro-box-dismiss" class="btn-close" data-bs-dismiss="alert"
|
||||||
|
aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="map">
|
<div id="map">
|
||||||
<div id="settingsButtonRowMap" class="mt-3 px-3">
|
<div id="settingsButtonRowMap" class="mt-3 px-3">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
@@ -77,26 +90,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<link rel="stylesheet" href="/vendor/css/leaflet-1.9.4.min.css">
|
<link rel="stylesheet" href="/static/vendor/css/leaflet-1.9.4.min.css">
|
||||||
<link rel="stylesheet" href="/vendor/css/leaflet-extra-markers-1.2.2.min.css">
|
<link rel="stylesheet" href="/static/vendor/css/leaflet-extra-markers-1.2.2.min.css">
|
||||||
<script src="/vendor/js/leaflet-1.9.4.min.js"></script>
|
<script src="/static/vendor/js/leaflet-1.9.4.min.js"></script>
|
||||||
<script src="/vendor/js/oms-leaflet-0.2.7.min.js"></script>
|
<script src="/static/vendor/js/oms-leaflet-0.2.7.min.js"></script>
|
||||||
<script src="/vendor/js/leaflet-providers-2.0.0.js"></script>
|
<script src="/static/vendor/js/leaflet-providers-2.0.0.js"></script>
|
||||||
<script src="/vendor/js/leaflet-extra-markers-1.2.2.min.js"></script>
|
<script src="/static/vendor/js/leaflet-extra-markers-1.2.2.min.js"></script>
|
||||||
<script src="/vendor/js/leaflet-geodesic-2.7.2.umd.min.js"></script>
|
<script src="/static/vendor/js/leaflet-geodesic-2.7.2.umd.min.js"></script>
|
||||||
<script src="/vendor/js/leaflet-vectorgrid-1.3.0.js"></script>
|
<script src="/static/vendor/js/leaflet-vectorgrid-1.3.0.js"></script>
|
||||||
<script src="/vendor/js/text-image-0.7.0.js"></script>
|
<script src="/static/vendor/js/text-image-0.7.0.js"></script>
|
||||||
<script src="/vendor/js/leaflet-terminator-1.1.0.min.js"></script>
|
<script src="/static/vendor/js/leaflet-terminator-1.1.0.min.js"></script>
|
||||||
<script src="/vendor/js/leaflet-maidenhead.js"></script>
|
<script src="/static/vendor/js/leaflet-maidenhead.js"></script>
|
||||||
<script src="/vendor/js/leaflet-ituzones.js"></script>
|
<script src="/static/vendor/js/leaflet-ituzones.js"></script>
|
||||||
<script src="/vendor/js/leaflet-cqzones.js"></script>
|
<script src="/static/vendor/js/leaflet-cqzones.js"></script>
|
||||||
<script src="/vendor/js/leaflet-workedallbritainireland.js"></script>
|
<script src="/static/vendor/js/leaflet-workedallbritainireland.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=1782076701"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1784995281"></script>
|
||||||
<script src="/js/map.js?v=1782076701"></script>
|
<script src="/static/js/map.js?v=1784995281"></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>
|
||||||
|
|||||||
+11
-11
@@ -15,10 +15,10 @@
|
|||||||
content="An Amateur Radio spotting tool bringing together DX clusters and outdoor programmes, providing a universal JSON API and web interface."/>
|
content="An Amateur Radio spotting tool bringing together DX clusters and outdoor programmes, providing a universal JSON API and web interface."/>
|
||||||
<meta property="og:description"
|
<meta property="og:description"
|
||||||
content="An Amateur Radio spotting tool bringing together DX clusters and outdoor programmes, providing a universal JSON API and web interface."/>
|
content="An Amateur Radio spotting tool bringing together DX clusters and outdoor programmes, providing a universal JSON API and web interface."/>
|
||||||
<link rel="canonical" href="https://spothole.app/"/>
|
<link rel="canonical" href="{{ baseurl }}"/>
|
||||||
<meta property="og:url" content="https://spothole.app/"/>
|
<meta property="og:url" content="{{ baseurl }}"/>
|
||||||
<meta property="og:image" content="https://spothole.app/img/banner.png"/>
|
<meta property="og:image" content="{{ baseurl }}/static/img/banner.png"/>
|
||||||
<meta property="twitter:image" content="https://spothole.app/img/banner.png"/>
|
<meta property="twitter:image" content="{{ baseurl }}/static/img/banner.png"/>
|
||||||
<meta name="twitter:card" content="summary_large_image"/>
|
<meta name="twitter:card" content="summary_large_image"/>
|
||||||
<meta name="author" content="Ian Renton"/>
|
<meta name="author" content="Ian Renton"/>
|
||||||
<meta property="og:locale" content="en_GB"/>
|
<meta property="og:locale" content="en_GB"/>
|
||||||
@@ -26,14 +26,14 @@
|
|||||||
|
|
||||||
<title>Spothole</title>
|
<title>Spothole</title>
|
||||||
|
|
||||||
<link rel="icon" type="image/png" href="/img/icon-512.png">
|
<link rel="icon" type="image/png" href="/static/img/icon-512.png">
|
||||||
<link rel="apple-touch-icon" href="img/icon-512-pwa.png">
|
<link rel="apple-touch-icon" href="/static/img/icon-512-pwa.png">
|
||||||
<link rel="alternate icon" type="image/png" href="/img/icon-192.png">
|
<link rel="alternate icon" type="image/png" href="/static/img/icon-192.png">
|
||||||
<link rel="alternate icon" type="image/png" href="/img/icon-32.png">
|
<link rel="alternate icon" type="image/png" href="/static/img/icon-32.png">
|
||||||
<link rel="alternate icon" type="image/png" href="/img/icon-16.png">
|
<link rel="alternate icon" type="image/png" href="/static/img/icon-16.png">
|
||||||
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico">
|
<link rel="alternate icon" type="image/x-icon" href="/static/favicon.ico">
|
||||||
|
|
||||||
<link rel="manifest" href="manifest.webmanifest">
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
|
|
||||||
{% block head_extra %}{% end %}
|
{% block head_extra %}{% end %}
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -116,8 +116,8 @@
|
|||||||
<script>
|
<script>
|
||||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||||
</script>
|
</script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=1782076701"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1784995281"></script>
|
||||||
<script src="/js/spots.js?v=1782076701"></script>
|
<script src="/static/js/spots.js?v=1784995281"></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>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/status.js?v=1782076701"></script>
|
<script src="/static/js/status.js?v=1784995281"></script>
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$("#nav-link-status").addClass("active");
|
$("#nav-link-status").addClass("active");
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ info:
|
|||||||
### 1.4
|
### 1.4
|
||||||
|
|
||||||
* Spots can now include a "propagation_mode" field, and the `/options` call enumerates the options that can have.
|
* Spots can now include a "propagation_mode" field, and the `/options` call enumerates the options that can have.
|
||||||
|
* Added support for the Diploma de Municipios Españoles (DME) SIG
|
||||||
|
* Renamed some SIGs to avoid confusion between Towers, Tiles and Toilets
|
||||||
|
* Added `comment_names` to SIGs in the `/options`, to reflect how they might be referred to in spot comments where
|
||||||
|
it differs from their `name`.
|
||||||
|
* Added `propagation_mode` field to spots
|
||||||
|
|
||||||
### 1.3
|
### 1.3
|
||||||
|
|
||||||
@@ -771,7 +776,7 @@ components:
|
|||||||
- ZLOTA
|
- ZLOTA
|
||||||
- WOTA
|
- WOTA
|
||||||
- LLOTA
|
- LLOTA
|
||||||
- WWTOTA
|
- Towers
|
||||||
- Tiles
|
- Tiles
|
||||||
- Cluster
|
- Cluster
|
||||||
- RBN
|
- RBN
|
||||||
@@ -790,7 +795,7 @@ components:
|
|||||||
- HEMA
|
- HEMA
|
||||||
- WCA
|
- WCA
|
||||||
- MOTA
|
- MOTA
|
||||||
- SIOTA
|
- SiOTA
|
||||||
- ARLHS
|
- ARLHS
|
||||||
- ILLW
|
- ILLW
|
||||||
- ZLOTA
|
- ZLOTA
|
||||||
@@ -799,11 +804,12 @@ components:
|
|||||||
- WOTA
|
- WOTA
|
||||||
- BOTA
|
- BOTA
|
||||||
- LLOTA
|
- LLOTA
|
||||||
- WWTOTA
|
- Towers
|
||||||
- Tiles
|
- Tiles
|
||||||
- WAB
|
- WAB
|
||||||
- WAI
|
- WAI
|
||||||
- TOTA
|
- DME
|
||||||
|
- Toilets
|
||||||
example: POTA
|
example: POTA
|
||||||
|
|
||||||
SIGNameIncludingNoSIG:
|
SIGNameIncludingNoSIG:
|
||||||
@@ -1373,6 +1379,16 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: The full name of the SIG
|
description: The full name of the SIG
|
||||||
example: Parks on the Air
|
example: Parks on the Air
|
||||||
|
comment_names:
|
||||||
|
type: array
|
||||||
|
description: >
|
||||||
|
Names by which this SIG may be referred to in cluster spot comments. Most SIGs have a
|
||||||
|
single entry matching their programme name (e.g. ["POTA"]), but some have none (where
|
||||||
|
the name is ambiguous with other SIGs, such as Tiles and Toilets on the Air) or multiple
|
||||||
|
entries (e.g. WWBOTA accepts both "WWBOTA" and "BOTA" since the latter is often used).
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: [ "TOTA" ]
|
||||||
ref_regex:
|
ref_regex:
|
||||||
type: string
|
type: string
|
||||||
description: >
|
description: >
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function addSpot() {
|
|||||||
spot["sig"] = sig;
|
spot["sig"] = sig;
|
||||||
}
|
}
|
||||||
if (sigRef !== "") {
|
if (sigRef !== "") {
|
||||||
spot["sig_refs"] = [{id: sigRef}];
|
spot["sig_refs"] = [{sig: sig, id: sigRef}];
|
||||||
}
|
}
|
||||||
if (dxGrid !== "") {
|
if (dxGrid !== "") {
|
||||||
spot["dx_grid"] = dxGrid;
|
spot["dx_grid"] = dxGrid;
|
||||||
|
|||||||
+24
-1
@@ -1,5 +1,7 @@
|
|||||||
// How often to query the server?
|
// How often to query the server?
|
||||||
const REFRESH_INTERVAL_SEC = 60 * 10;
|
const REFRESH_INTERVAL_SEC = 60 * 10;
|
||||||
|
// Last time the alerts list was updated on display.
|
||||||
|
let lastUpdateTime;
|
||||||
|
|
||||||
// Storage for the alert data that the server gives us.
|
// Storage for the alert data that the server gives us.
|
||||||
let alerts = [];
|
let alerts = [];
|
||||||
@@ -182,7 +184,7 @@ function addAlertRowsToTable(tbody, alerts) {
|
|||||||
// Format DX flag
|
// Format DX flag
|
||||||
let dx_flag = "<i class='fa-solid fa-globe-africa'></i>";
|
let dx_flag = "<i class='fa-solid fa-globe-africa'></i>";
|
||||||
if (a["dx_dxcc_id"] && a["dx_dxcc_id"] != null && a["dx_dxcc_id"] !== 0) {
|
if (a["dx_dxcc_id"] && a["dx_dxcc_id"] != null && a["dx_dxcc_id"] !== 0) {
|
||||||
dx_flag = `<img src="img/flags/${a['dx_dxcc_id']}.png" class="flag" width="24" alt="${dx_country}" title="${dx_country}"/>`;
|
dx_flag = `<img src="static/img/flags/${a['dx_dxcc_id']}.png" class="flag" width="24" alt="${dx_country}" title="${dx_country}"/>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format dx calls
|
// Format dx calls
|
||||||
@@ -309,6 +311,27 @@ function filtersUpdated() {
|
|||||||
saveSettings();
|
saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the refresh timing display
|
||||||
|
function updateRefreshDisplay() {
|
||||||
|
if (lastUpdateTime != null) {
|
||||||
|
let secSinceUpdate = moment.duration(moment().diff(lastUpdateTime)).asSeconds();
|
||||||
|
let count = REFRESH_INTERVAL_SEC;
|
||||||
|
let updatingString = "Updating..."
|
||||||
|
if (secSinceUpdate < REFRESH_INTERVAL_SEC) {
|
||||||
|
count = REFRESH_INTERVAL_SEC - secSinceUpdate;
|
||||||
|
let number;
|
||||||
|
if (count <= 60) {
|
||||||
|
number = count.toFixed(0);
|
||||||
|
updatingString = "<span class='nowrap'>Updating in " + number + " second" + (number !== "1" ? "s" : "") + ".</span>";
|
||||||
|
} else {
|
||||||
|
number = Math.round(count / 60.0).toFixed(0);
|
||||||
|
updatingString = "<span class='nowrap'>Updating in " + number + " minute" + (number !== "1" ? "s" : "") + ".</span>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$("#timing-container").html("Last updated at " + lastUpdateTime.format('HH:mm') + " UTC. " + updatingString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Startup
|
// Startup
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
// Call loadOptions(), this will then trigger loading alerts and setting up timers.
|
// Call loadOptions(), this will then trigger loading alerts and setting up timers.
|
||||||
|
|||||||
+67
-17
@@ -1,5 +1,9 @@
|
|||||||
// How often to query the server?
|
// SSE connection for live spot updates
|
||||||
const REFRESH_INTERVAL_SEC = 60;
|
let evtSource;
|
||||||
|
let restartSSEOnErrorTimeoutId;
|
||||||
|
// Debounce timer so rapid SSE bursts only trigger one updateBands() call, as these could trigger a flash of the user
|
||||||
|
// visible content
|
||||||
|
let updateBandsDebounceId;
|
||||||
|
|
||||||
// A couple of constants that must match what's in CSS. We need to know them before the content actually renders, so we
|
// A couple of constants that must match what's in CSS. We need to know them before the content actually renders, so we
|
||||||
// can't just ask the elements themselves for their dimensions.
|
// can't just ask the elements themselves for their dimensions.
|
||||||
@@ -12,19 +16,62 @@ BAND_COLUMN_SPOT_DIV_HEIGHT_PX = BAND_COLUMN_FONT_SIZE * 1.6;
|
|||||||
|
|
||||||
// Load spots and populate the bands display.
|
// Load spots and populate the bands display.
|
||||||
function loadSpots() {
|
function loadSpots() {
|
||||||
$.getJSON('/api/v1/spots' + buildQueryString(false), function (jsonData) {
|
// Close any existing SSE connection before fetching fresh data
|
||||||
// Store last updated time
|
if (evtSource != null) {
|
||||||
lastUpdateTime = moment.utc();
|
evtSource.close();
|
||||||
updateRefreshDisplay();
|
}
|
||||||
|
$.getJSON('/api/v1/spots' + buildQueryString(), function (jsonData) {
|
||||||
// Store data
|
// Store data
|
||||||
spots = jsonData;
|
spots = jsonData;
|
||||||
// Update bands display
|
// Update bands display
|
||||||
updateBands();
|
updateBands();
|
||||||
|
// Start the ongoing SSE connection
|
||||||
|
startSSEConnection();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a query string for the API, based on the filters that the user has selected.
|
// Start an SSE connection to receive new spots as they arrive.
|
||||||
function buildQueryString(includeCredentials) {
|
function startSSEConnection() {
|
||||||
|
if (evtSource != null) {
|
||||||
|
evtSource.close();
|
||||||
|
}
|
||||||
|
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString());
|
||||||
|
|
||||||
|
evtSource.onmessage = function (event) {
|
||||||
|
const newSpot = JSON.parse(event.data);
|
||||||
|
|
||||||
|
// Replace any existing spot for this callsign
|
||||||
|
spots = spots.filter(s => newSpot["dx_call"] !== s["dx_call"]);
|
||||||
|
spots.unshift(newSpot);
|
||||||
|
|
||||||
|
// Debounce. Wait 500ms after the last message before re-rendering to avoid too many ugly flashes
|
||||||
|
clearTimeout(updateBandsDebounceId);
|
||||||
|
updateBandsDebounceId = setTimeout(updateBands, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
evtSource.onerror = function () {
|
||||||
|
if (evtSource != null) {
|
||||||
|
evtSource.close();
|
||||||
|
}
|
||||||
|
clearTimeout(restartSSEOnErrorTimeoutId);
|
||||||
|
restartSSEOnErrorTimeoutId = setTimeout(startSSEConnection, 1000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove spots from the display that are older than the selected max age.
|
||||||
|
function expireOldSpots() {
|
||||||
|
const maxAgeSeconds = parseInt($("#max-spot-age option:selected").val());
|
||||||
|
const cutoff = (Date.now() / 1000) - maxAgeSeconds;
|
||||||
|
const before = spots.length;
|
||||||
|
spots = spots.filter(s => s["time"] && s["time"] >= cutoff);
|
||||||
|
if (spots.length !== before) {
|
||||||
|
updateBands();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a query string for the API, based on the filters that the user has selected. There's no need for credentials
|
||||||
|
// in the bands page's version of this, because nothing QRZ.com/HamQTH can provide will affect the display.
|
||||||
|
function buildQueryString() {
|
||||||
let str = "?";
|
let str = "?";
|
||||||
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
|
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
|
||||||
if (!allFilterOptionsSelected(fn)) {
|
if (!allFilterOptionsSelected(fn)) {
|
||||||
@@ -34,9 +81,6 @@ function buildQueryString(includeCredentials) {
|
|||||||
str = str + "max_age=" + $("#max-spot-age option:selected").val();
|
str = str + "max_age=" + $("#max-spot-age option:selected").val();
|
||||||
// Additional filters for the bands view: No dupes, no QRT
|
// Additional filters for the bands view: No dupes, no QRT
|
||||||
str = str + "&dedupe=true&allow_qrt=false";
|
str = str + "&dedupe=true&allow_qrt=false";
|
||||||
if (includeCredentials) {
|
|
||||||
str = str + getCredentialQueryString();
|
|
||||||
}
|
|
||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,22 +301,28 @@ function loadOptions() {
|
|||||||
// Load settings from settings storage now all the controls are available
|
// Load settings from settings storage now all the controls are available
|
||||||
loadSettings();
|
loadSettings();
|
||||||
|
|
||||||
// Load spots and set up the timer
|
// Load spots and start SSE for live updates
|
||||||
loadSpots();
|
loadSpots();
|
||||||
setInterval(loadSpots, REFRESH_INTERVAL_SEC * 1000);
|
// Set up spot expiry checker
|
||||||
|
setInterval(expireOldSpots, 60 * 1000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method called when any display property is changed to reload the map and persist the display settings.
|
// Method called when any display property is changed to reload the bands display and persist settings.
|
||||||
function displayUpdated() {
|
function displayUpdated() {
|
||||||
updateMap();
|
updateBands();
|
||||||
saveSettings();
|
saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Startup
|
// Startup
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
|
// Close SSE connection cleanly when navigating away
|
||||||
|
window.addEventListener('beforeunload', function () {
|
||||||
|
if (evtSource != null) {
|
||||||
|
evtSource.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Call loadOptions(), this will then trigger loading spots and setting up timers.
|
// Call loadOptions(), this will then trigger loading spots and setting up timers.
|
||||||
loadOptions();
|
loadOptions();
|
||||||
// Update the refresh timing display every second
|
|
||||||
setInterval(updateRefreshDisplay, 1000);
|
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
// Storage for the options that the server gives us. This will define our filters.
|
// Storage for the options that the server gives us. This will define our filters.
|
||||||
let options = {};
|
let options = {};
|
||||||
// Last time we updated the spots/alerts list on display.
|
|
||||||
let lastUpdateTime;
|
|
||||||
// Normally load user settings from local storage, unless embedded mode is in use
|
// Normally load user settings from local storage, unless embedded mode is in use
|
||||||
let useLocalStorage = true;
|
let useLocalStorage = true;
|
||||||
|
|
||||||
@@ -145,27 +143,6 @@ function toggleFilterButtons(filterQuery, state) {
|
|||||||
filtersUpdated();
|
filtersUpdated();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the refresh timing display
|
|
||||||
function updateRefreshDisplay() {
|
|
||||||
if (lastUpdateTime != null) {
|
|
||||||
let secSinceUpdate = moment.duration(moment().diff(lastUpdateTime)).asSeconds();
|
|
||||||
let count = REFRESH_INTERVAL_SEC;
|
|
||||||
let updatingString = "Updating..."
|
|
||||||
if (secSinceUpdate < REFRESH_INTERVAL_SEC) {
|
|
||||||
count = REFRESH_INTERVAL_SEC - secSinceUpdate;
|
|
||||||
let number;
|
|
||||||
if (count <= 60) {
|
|
||||||
number = count.toFixed(0);
|
|
||||||
updatingString = "<span class='nowrap'>Updating in " + number + " second" + (number !== "1" ? "s" : "") + ".</span>";
|
|
||||||
} else {
|
|
||||||
number = Math.round(count / 60.0).toFixed(0);
|
|
||||||
updatingString = "<span class='nowrap'>Updating in " + number + " minute" + (number !== "1" ? "s" : "") + ".</span>";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$("#timing-container").html("Last updated at " + lastUpdateTime.format('HH:mm') + " UTC. " + updatingString);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// When the "use local time" field is changed, reload the table and save settings
|
// When the "use local time" field is changed, reload the table and save settings
|
||||||
function timeZoneUpdated() {
|
function timeZoneUpdated() {
|
||||||
updateTable();
|
updateTable();
|
||||||
|
|||||||
+154
-24
@@ -1,6 +1,3 @@
|
|||||||
// How often to query the server?
|
|
||||||
const REFRESH_INTERVAL_SEC = 60;
|
|
||||||
|
|
||||||
// Colours
|
// Colours
|
||||||
const MAIDENHEAD_GRID_COLOR_LIGHT = 'rgba(200, 140, 140, 1.0)';
|
const MAIDENHEAD_GRID_COLOR_LIGHT = 'rgba(200, 140, 140, 1.0)';
|
||||||
const CQ_ZONES_COLOR_LIGHT = 'rgba(140, 200, 140, 1.0)';
|
const CQ_ZONES_COLOR_LIGHT = 'rgba(140, 200, 140, 1.0)';
|
||||||
@@ -11,6 +8,15 @@ const CQ_ZONES_COLOR_DARK = 'rgba(60, 120, 60, 1.0)';
|
|||||||
const ITU_ZONES_COLOR_DARK = 'rgba(120, 120, 60, 1.0)';
|
const ITU_ZONES_COLOR_DARK = 'rgba(120, 120, 60, 1.0)';
|
||||||
const WAB_WAI_GRID_COLOR_DARK = 'rgba(60, 60, 120, 1.0)';
|
const WAB_WAI_GRID_COLOR_DARK = 'rgba(60, 60, 120, 1.0)';
|
||||||
|
|
||||||
|
// SSE connection for live spot updates
|
||||||
|
let evtSource;
|
||||||
|
let restartSSEOnErrorTimeoutId;
|
||||||
|
// Map dx_call to a pair of marker & geodesic line, so we can de-duplicate spots as they arrive and only show
|
||||||
|
// one marker per dx_call. The key here is actually dx_call + SSID if the spot gives us an SSID; this is mostly for
|
||||||
|
// if APRS spots are enabled so we can ID a home and mobile station separately rather than our marker oscillating
|
||||||
|
// between the two as updates come in.
|
||||||
|
let spotMarkers = new Map();
|
||||||
|
|
||||||
// Map layers
|
// Map layers
|
||||||
let backgroundTileLayer;
|
let backgroundTileLayer;
|
||||||
let markersLayer;
|
let markersLayer;
|
||||||
@@ -28,7 +34,18 @@ let firstLoad = true;
|
|||||||
|
|
||||||
// Load spots and populate the map.
|
// Load spots and populate the map.
|
||||||
function loadSpots() {
|
function loadSpots() {
|
||||||
$.getJSON('/api/v1/spots' + buildQueryString(true), function (jsonData) {
|
// Close any existing SSE connection before fetching fresh data
|
||||||
|
if (evtSource != null) {
|
||||||
|
evtSource.close();
|
||||||
|
}
|
||||||
|
// Including QRZ/HamQTH lookups to improve positions causes the load to be really slow, so on first load
|
||||||
|
// the user would be waiting ages without data and will think it's broken. We therefore load in several
|
||||||
|
// stages:
|
||||||
|
// 1) Load without any credentials
|
||||||
|
// 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) {
|
||||||
// Store data
|
// Store data
|
||||||
spots = jsonData;
|
spots = jsonData;
|
||||||
// Update map
|
// Update map
|
||||||
@@ -36,7 +53,113 @@ function loadSpots() {
|
|||||||
if ($("#showTerminator")[0].checked) {
|
if ($("#showTerminator")[0].checked) {
|
||||||
terminator.setTime();
|
terminator.setTime();
|
||||||
}
|
}
|
||||||
|
// Check if we have any credentials to use
|
||||||
|
if (getCredentialQueryString() !== "") {
|
||||||
|
// OK, we have credentials and have loaded once without them so the user has a basic map. Now reload
|
||||||
|
// with the credentials and replace what's on the map, so we can improve the data.
|
||||||
|
$.getJSON('/api/v1/spots' + buildQueryString(true), function (jsonData2) {
|
||||||
|
spots = jsonData2;
|
||||||
|
updateMap();
|
||||||
|
// Now start the ongoing SSE connection
|
||||||
|
startSSEConnection();
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// We had no credentials with which to augment the data anyway, so just start the SSE connection
|
||||||
|
// now
|
||||||
|
startSSEConnection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the SSE connection to receive new spots as they arrive
|
||||||
|
function startSSEConnection() {
|
||||||
|
if (evtSource != null) {
|
||||||
|
evtSource.close();
|
||||||
|
}
|
||||||
|
// SSE is going to fetch only a few spots at a time, so now we include QRZ/HamQTH credentials because the delay won't be significant.
|
||||||
|
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString(true));
|
||||||
|
|
||||||
|
evtSource.onmessage = function (event) {
|
||||||
|
const newSpot = JSON.parse(event.data);
|
||||||
|
const key = spotKey(newSpot);
|
||||||
|
|
||||||
|
// Remove existing marker/geodesic for this callsign if present
|
||||||
|
removeSpotFromMap(key);
|
||||||
|
spots = spots.filter(s => spotKey(s) !== key);
|
||||||
|
|
||||||
|
// Skip spots with no map coordinates
|
||||||
|
if (newSpot["dx_latitude"] == null || newSpot["dx_longitude"] == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to data store and map
|
||||||
|
spots.unshift(newSpot);
|
||||||
|
addSpotToMap(newSpot);
|
||||||
|
};
|
||||||
|
|
||||||
|
evtSource.onerror = function () {
|
||||||
|
if (evtSource != null) {
|
||||||
|
evtSource.close();
|
||||||
|
}
|
||||||
|
clearTimeout(restartSSEOnErrorTimeoutId);
|
||||||
|
restartSSEOnErrorTimeoutId = setTimeout(startSSEConnection, 1000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove spots from the map that are older than the selected max age.
|
||||||
|
function expireOldSpots() {
|
||||||
|
const maxAgeSeconds = parseInt($("#max-spot-age option:selected").val());
|
||||||
|
const cutoff = (Date.now() / 1000) - maxAgeSeconds;
|
||||||
|
spots = spots.filter(function (s) {
|
||||||
|
if (s["time"] && s["time"] < cutoff) {
|
||||||
|
removeSpotFromMap(spotKey(s));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a unique key for a spot based on its callsign and SSID. This is in case the APRS spot source is enabled and
|
||||||
|
// we want to display all SSIDs for a given callsign, not just the latest one.
|
||||||
|
function spotKey(s) {
|
||||||
|
return s["dx_call"] + (s["dx_ssid"] ? "-" + s["dx_ssid"] : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a single spot's marker and geodesic to the map and to spotMarkers
|
||||||
|
function addSpotToMap(s) {
|
||||||
|
const m = L.marker([s["dx_latitude"], s["dx_longitude"]], {icon: getIcon(s)});
|
||||||
|
m.bindPopup(getTooltipText(s));
|
||||||
|
markersLayer.addLayer(m);
|
||||||
|
oms.addMarker(m);
|
||||||
|
|
||||||
|
let geodesic = null;
|
||||||
|
if ($("#mapShowGeodesics")[0].checked && s["de_latitude"] != null && s["de_longitude"] != null) {
|
||||||
|
try {
|
||||||
|
geodesic = L.geodesic([[s["de_latitude"], s["de_longitude"]], m.getLatLng()], {
|
||||||
|
color: bandToColor(s['band']),
|
||||||
|
wrap: false,
|
||||||
|
steps: 5
|
||||||
|
});
|
||||||
|
geodesicsLayer.addLayer(geodesic);
|
||||||
|
} catch (e) {
|
||||||
|
// Not sure what causes these but better to continue than to crash out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spotMarkers.set(spotKey(s), {marker: m, geodesic: geodesic});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a spot's marker and geodesic from the map and from spotMarkers.
|
||||||
|
function removeSpotFromMap(key) {
|
||||||
|
const entry = spotMarkers.get(key);
|
||||||
|
if (entry) {
|
||||||
|
markersLayer.removeLayer(entry.marker);
|
||||||
|
oms.removeMarker(entry.marker);
|
||||||
|
if (entry.geodesic) {
|
||||||
|
geodesicsLayer.removeLayer(entry.geodesic);
|
||||||
|
}
|
||||||
|
spotMarkers.delete(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a query string for the API, based on the filters that the user has selected.
|
// Build a query string for the API, based on the filters that the user has selected.
|
||||||
@@ -62,27 +185,14 @@ function updateMap() {
|
|||||||
markersLayer.clearLayers();
|
markersLayer.clearLayers();
|
||||||
geodesicsLayer.clearLayers();
|
geodesicsLayer.clearLayers();
|
||||||
oms.clearMarkers();
|
oms.clearMarkers();
|
||||||
|
spotMarkers.clear();
|
||||||
|
|
||||||
// Make new markers for all spots that match the filter
|
// Make new markers for all spots
|
||||||
spots.forEach(function (s) {
|
spots.forEach(function (s) {
|
||||||
const m = L.marker([s["dx_latitude"], s["dx_longitude"]], {icon: getIcon(s)});
|
if (s["dx_latitude"] == null || s["dx_longitude"] == null) {
|
||||||
m.bindPopup(getTooltipText(s));
|
return;
|
||||||
markersLayer.addLayer(m);
|
|
||||||
oms.addMarker(m);
|
|
||||||
|
|
||||||
// Create geodesics if required
|
|
||||||
if ($("#mapShowGeodesics")[0].checked && s["de_latitude"] != null && s["de_longitude"] != null) {
|
|
||||||
try {
|
|
||||||
const geodesic = L.geodesic([[s["de_latitude"], s["de_longitude"]], m.getLatLng()], {
|
|
||||||
color: bandToColor(s['band']),
|
|
||||||
wrap: false,
|
|
||||||
steps: 5
|
|
||||||
});
|
|
||||||
geodesicsLayer.addLayer(geodesic);
|
|
||||||
} catch (e) {
|
|
||||||
// Not sure what causes these but better to continue than to crash out
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
addSpotToMap(s);
|
||||||
});
|
});
|
||||||
|
|
||||||
// On first load, zoom to the extent of the markers
|
// On first load, zoom to the extent of the markers
|
||||||
@@ -239,9 +349,10 @@ function loadOptions() {
|
|||||||
enableITUZones($("#showITUZones")[0].checked);
|
enableITUZones($("#showITUZones")[0].checked);
|
||||||
enableWABWAIGrid($("#showWABWAIGrid")[0].checked);
|
enableWABWAIGrid($("#showWABWAIGrid")[0].checked);
|
||||||
|
|
||||||
// Load spots and set up the timer
|
// Load spots and start SSE for live updates
|
||||||
loadSpots();
|
loadSpots();
|
||||||
setInterval(loadSpots, REFRESH_INTERVAL_SEC * 1000);
|
// Set up spot expiry checker
|
||||||
|
setInterval(expireOldSpots, 60 * 1000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,14 +592,33 @@ function setUpMap() {
|
|||||||
map.setView([30, 0], 3);
|
map.setView([30, 0], 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display the intro box, unless the user has already dismissed it once.
|
||||||
|
function displayIntroBox() {
|
||||||
|
if (localStorage.getItem("map-intro-box-dismissed") == null) {
|
||||||
|
$("#map-intro-box").show();
|
||||||
|
}
|
||||||
|
$("#map-intro-box-dismiss").click(function () {
|
||||||
|
localStorage.setItem("map-intro-box-dismissed", true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Startup
|
// Startup
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
|
// Close SSE connection cleanly when navigating away
|
||||||
|
window.addEventListener('beforeunload', function () {
|
||||||
|
if (evtSource != null) {
|
||||||
|
evtSource.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Hide the extra things that need to be hidden on this page
|
// Hide the extra things that need to be hidden on this page
|
||||||
$(".hideonmap").hide();
|
$(".hideonmap").hide();
|
||||||
// Set up map
|
// Set up map
|
||||||
setUpMap();
|
setUpMap();
|
||||||
// Call loadOptions(), this will then trigger loading spots and setting up timers.
|
// Call loadOptions(), this will then trigger loading spots and setting up timers.
|
||||||
loadOptions();
|
loadOptions();
|
||||||
|
// Display intro box
|
||||||
|
displayIntroBox();
|
||||||
// Prevent mouse scroll and touch actions in the popup menus being passed through to the map
|
// Prevent mouse scroll and touch actions in the popup menus being passed through to the map
|
||||||
L.DomEvent.disableScrollPropagation(document.getElementById('settingsButtonRowMap'));
|
L.DomEvent.disableScrollPropagation(document.getElementById('settingsButtonRowMap'));
|
||||||
L.DomEvent.disableClickPropagation(document.getElementById('settingsButtonRowMap'));
|
L.DomEvent.disableClickPropagation(document.getElementById('settingsButtonRowMap'));
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
|||||||
dx_flag = "";
|
dx_flag = "";
|
||||||
}
|
}
|
||||||
if (s["dx_dxcc_id"] && s["dx_dxcc_id"] != null && s["dx_dxcc_id"] !== 0) {
|
if (s["dx_dxcc_id"] && s["dx_dxcc_id"] != null && s["dx_dxcc_id"] !== 0) {
|
||||||
dx_flag = `<img src="img/flags/${s['dx_dxcc_id']}.png" class="flag" width="24" alt="${dx_country}" title="${dx_country}"/>`;
|
dx_flag = `<img src="static/img/flags/${s['dx_dxcc_id']}.png" class="flag" width="24" alt="${dx_country}" title="${dx_country}"/>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format the frequency
|
// Format the frequency
|
||||||
@@ -319,7 +319,7 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
|||||||
// Format DE flag
|
// Format DE flag
|
||||||
let de_flag = "<i class='fa-solid fa-circle-question'></i>";
|
let de_flag = "<i class='fa-solid fa-circle-question'></i>";
|
||||||
if (s["de_dxcc_id"] && s["de_dxcc_id"] != null && s["de_dxcc_id"] !== 0) {
|
if (s["de_dxcc_id"] && s["de_dxcc_id"] != null && s["de_dxcc_id"] !== 0) {
|
||||||
de_flag = `<img src="img/flags/${s['de_dxcc_id']}.png" class="flag" width="24" alt="${de_country}" title="${de_country}"/>`;
|
de_flag = `<img src="static/img/flags/${s['de_dxcc_id']}.png" class="flag" width="24" alt="${de_country}" title="${de_country}"/>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format de call
|
// Format de call
|
||||||
|
|||||||
@@ -332,12 +332,12 @@ const SIG_ICONS = {
|
|||||||
"BOTA": "fa-umbrella-beach",
|
"BOTA": "fa-umbrella-beach",
|
||||||
"KRMNPA": "fa-earth-oceania",
|
"KRMNPA": "fa-earth-oceania",
|
||||||
"LLOTA": "fa-water",
|
"LLOTA": "fa-water",
|
||||||
"WWTOTA": "fa-tower-observation",
|
"Towers": "fa-tower-observation",
|
||||||
"WAB": "fa-table-cells-large",
|
"WAB": "fa-table-cells-large",
|
||||||
"WAI": "fa-table-cells-large",
|
"WAI": "fa-table-cells-large",
|
||||||
"DME": "fa-building",
|
"DME": "fa-building",
|
||||||
"Tiles": "fa-square",
|
"Tiles": "fa-square",
|
||||||
"TOTA": "fa-toilet"
|
"Toilets": "fa-toilet"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the Font Awesome icon for a given SIG. If the SIG is unknown, the provided default symbol will be returned
|
// Get the Font Awesome icon for a given SIG. If the SIG is unknown, the provided default symbol will be returned
|
||||||
|
|||||||
+52
-23
@@ -1,28 +1,57 @@
|
|||||||
const CACHE_NAME = 'Spothole';
|
const CACHE_NAME = 'Spothole';
|
||||||
const CACHE_URLS = [
|
const CACHE_URLS = [
|
||||||
'index.html',
|
'static/apidocs/openapi.yml',
|
||||||
'./',
|
'static/audio/ping.mp3',
|
||||||
'apidocs',
|
'static/css/style.css',
|
||||||
'apidocs/openapi.yml',
|
'static/js/add-spot.js',
|
||||||
'about',
|
'static/js/alerts.js',
|
||||||
'css/style.css',
|
'static/js/bands.js',
|
||||||
'js/add-spot.js',
|
'static/js/common.js',
|
||||||
'js/alerts.js',
|
'static/js/geo.js',
|
||||||
'js/bands.js',
|
'static/js/map.js',
|
||||||
'js/common.js',
|
'static/js/spots.js',
|
||||||
'js/map.js',
|
'static/js/spotsbandsandmap.js',
|
||||||
'js/spots.js',
|
'static/js/status.js',
|
||||||
'js/spotsbandsandmap.js',
|
'static/js/ui-ham.js',
|
||||||
'js/status.js',
|
'static/js/utils.js',
|
||||||
'img/logo.png',
|
'static/img/logo.png',
|
||||||
'img/favicon.ico',
|
'static/img/favicon.ico',
|
||||||
'img/icon-32.png',
|
'static/img/icon-32.png',
|
||||||
'img/icon-192.png',
|
'static/img/icon-192.png',
|
||||||
'img/icon-512.png',
|
'static/img/icon-512.png',
|
||||||
'fa/css/fontawesome.min.css',
|
'static/vendor/img/markers_default@2x.png',
|
||||||
'fa/css/solid.min.css',
|
'static/vendor/img/markers_shadow.png',
|
||||||
'fa/webfonts/fa-solid-900.ttf',
|
'static/vendor/img/markers_default.png',
|
||||||
'fa/webfonts/fa-solid-900.woff2'
|
'static/vendor/img/markers_shadow@2x.png',
|
||||||
|
'static/vendor/css/bootstrap-5.3.8.min.css',
|
||||||
|
'static/vendor/css/images/marker-icon.png',
|
||||||
|
'static/vendor/css/images/layers-2x.png',
|
||||||
|
'static/vendor/css/images/marker-shadow.png',
|
||||||
|
'static/vendor/css/images/layers.png',
|
||||||
|
'static/vendor/css/images/marker-icon-2x.png',
|
||||||
|
'static/vendor/css/leaflet-extra-markers-1.2.2.min.css',
|
||||||
|
'static/vendor/css/leaflet-1.9.4.min.css',
|
||||||
|
'static/vendor/css/fontawesome-6.7.2.min.css',
|
||||||
|
'static/vendor/css/solid-6.7.2.min.css',
|
||||||
|
'static/vendor/js/jquery-3.7.1.min.js',
|
||||||
|
'static/vendor/js/chart-4.4.9.umd.min.js',
|
||||||
|
'static/vendor/js/oms-leaflet-0.2.7.min.js',
|
||||||
|
'static/vendor/js/leaflet-workedallbritainireland.js',
|
||||||
|
'static/vendor/js/leaflet-providers-2.0.0.js',
|
||||||
|
'static/vendor/js/text-image-0.7.0.js',
|
||||||
|
'static/vendor/js/leaflet-vectorgrid-1.3.0.js',
|
||||||
|
'static/vendor/js/leaflet-ituzones.js',
|
||||||
|
'static/vendor/js/tinycolor2-1.6.0.min.js',
|
||||||
|
'static/vendor/js/bootstrap-5.3.8.bundle.min.js',
|
||||||
|
'static/vendor/js/moment-2.29.4.min.js',
|
||||||
|
'static/vendor/js/leaflet-cqzones.js',
|
||||||
|
'static/vendor/js/leaflet-extra-markers-1.2.2.min.js',
|
||||||
|
'static/vendor/js/leaflet-terminator-1.1.0.min.js',
|
||||||
|
'static/vendor/js/leaflet-1.9.4.min.js',
|
||||||
|
'static/vendor/js/leaflet-geodesic-2.7.2.umd.min.js',
|
||||||
|
'static/vendor/js/leaflet-maidenhead.js',
|
||||||
|
'static/vendor/webfonts/fa-solid-900.ttf',
|
||||||
|
'static/vendor/webfonts/fa-solid-900.woff2'
|
||||||
];
|
];
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user