mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-08-09 03:51:41 +00:00
Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac35a920cf | ||
|
|
4e7c5a05aa | ||
|
|
981cea0866 | ||
|
|
d9ce8db48b | ||
|
|
f08ff030e3 | ||
|
|
3ab2375a32 | ||
|
|
985646bb94 | ||
|
|
8d28a29f82 | ||
|
|
4f9ec65d9a | ||
|
|
602551330e | ||
|
|
f68b244e38 | ||
|
|
56a29b6203 | ||
|
|
7dfcdb00e8 | ||
|
|
5699627069 | ||
|
|
66081f32f9 | ||
|
|
4f8f748d88 | ||
|
|
62b3414d29 | ||
|
|
459d999f57 | ||
|
|
27d73c27d6 | ||
|
|
f82d611b7e | ||
|
|
50ab27e4a2 | ||
|
|
e31b145b8e | ||
|
|
cee9188538 | ||
|
|
2e6d8e4b4a | ||
|
|
c2ecef1003 | ||
|
|
11a236e668 | ||
|
|
fa6c3c0a55 | ||
|
|
3b5a4efb0b | ||
|
|
59fa6500eb | ||
|
|
7a34526a91 | ||
|
|
d1c4dd4e4c | ||
|
|
692fa83323 | ||
|
|
6062211bc7 | ||
|
|
ec5984ec35 | ||
|
|
2affe460a5 | ||
|
|
277e374994 | ||
|
|
8d09484425 | ||
|
|
e08a183d1b | ||
|
|
ae17839096 | ||
|
|
1e42c69b78 | ||
|
|
20966cc7cf | ||
|
|
172a31bb18 | ||
|
|
88f055384d | ||
|
|
4408203d55 | ||
|
|
af9f542740 | ||
|
|
b81f5eeb5a | ||
|
|
fd21e01c9d | ||
|
|
1afb407ca5 | ||
|
|
930d5357fe |
@@ -0,0 +1,7 @@
|
|||||||
|
/.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
/config.yml
|
||||||
|
/cache/
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["python3", "spothole.py"]
|
||||||
@@ -33,471 +33,17 @@ see [https://spothole.app/apidocs](https://spothole.app/apidocs) for the API det
|
|||||||
This is a Progressive Web App, so you can also "install" it to your Android or iOS device by accessing it in Chrome or
|
This is a Progressive Web App, so you can also "install" it to your Android or iOS device by accessing it in Chrome or
|
||||||
Safari respectively, and following the menu-driven process for installing PWAs.
|
Safari respectively, and following the menu-driven process for installing PWAs.
|
||||||
|
|
||||||
You are more than welcome to use the data and the API that Spothole provides to power your own software. There are many
|
You are more than welcome to use the data and the API that Spothole provides to power your own software, to run your
|
||||||
ways to do this; see below.
|
own Spothole server, or to modify it in any way. More details can be found in the following sections:
|
||||||
|
|
||||||
## Embedding Spothole in another website
|
* [Embedding Spothole in another website](docs/embedding.md)
|
||||||
|
* [Writing your own client](docs/clients.md)
|
||||||
You can embed Spothole's web interface in another website, e.g. for use as part of a ham radio custom dashboard.
|
* [Running your own copy](docs/running.md)
|
||||||
|
* [nginx reverse proxy configuration](docs/nginx.md)
|
||||||
URL parameters can be used to trigger an "embedded" mode which hides the headers, footers and settings. In this mode,
|
* [systemd service file](docs/systemd.md)
|
||||||
you provide configuration for the various filter and display options via additional URL parameters. Any settings that
|
* [Running in Docker](docs/docker.md)
|
||||||
the user has set for Spothole are ignored. This is so that the embedding site can select, for example, their choice of
|
* [Use of multiple cluster logins](docs/multicluster.md)
|
||||||
dark mode or SIG filters, which will not impact how Spothole appears when the user accesses it directly. Effectively, it
|
* [Modifying the source code](docs/modifying.md)
|
||||||
becomes separate to their normal Spothole settings.
|
|
||||||
|
|
||||||
Setting `embedded` to true is important for the rest of the settings to be applied; otherwise, the user's defaults will
|
|
||||||
be used in preference to the URL params.
|
|
||||||
|
|
||||||
These are supplied with the URL to the page you want to embed, for example for an embedded version of the band map in
|
|
||||||
dark mode, use `https://spothole.app/bands?embedded=true&dark-mode=true`. For an embedded version of the main spots/home
|
|
||||||
page in the system light/dark mode, use `https://spothole.app/?embedded=true`. For dark mode showing 70cm TOTA spots
|
|
||||||
only, use `https://spothole.app/?embedded=true&dark-mode=true&sig=TOTA&band=70cm`. Providing no URL params causes the
|
|
||||||
page to be loaded in the normal way it would when accessed directly in the user's browser.
|
|
||||||
|
|
||||||
The supported parameters are as follows. Generally these match the equivalent parameters in the real Spothole API, where
|
|
||||||
a mapping exists.
|
|
||||||
|
|
||||||
| Name | Allowed Values | Default | Example | Description |
|
|
||||||
|------------------|-------------------------|---------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
||||||
| `embedded` | `true`, `false` | `false` | `?embedded=true` | Enables embedded mode. |
|
|
||||||
| `color-scheme` | `light`, `dark`, `auto` | `auto` | `?color-scheme=dark` | Forces light or dark mode in preference to the operating system default. |
|
|
||||||
| `time-zone` | `UTC`, `local` | `UTC` | `?time-zone=local` | Sets times to be in UTC or local time. |
|
|
||||||
| `limit` | 10, 25, 50, 100 | 50 | `?limit=50` | Sets the number of spots that will be displayed on the main spots page |
|
|
||||||
| `limit` | 25, 50, 100, 200, 500 | 100 | `?limit=100` | Sets the number of alerts that will be displayed on the alerts page |
|
|
||||||
| `max_age` | 300, 600, 1800, 3600 | 1800 | `?max_age=1800` | Sets the maximum age of spots displayed on the map and bands pages, in seconds. |
|
|
||||||
| `band` | Comma-separated list | (all) | `?band=20m,40m` | Sets the list of bands that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
|
|
||||||
| `sig` | Comma-separated list | (all) | `?sig=POTA,SOTA,NO_SIG` | Sets the list of SIGs that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
|
|
||||||
| `source` | Comma-separated list | (all) | `?source=Cluster` | Sets the list of sources that will be shown on any spot or alert pages. Available options match the labels of the buttons in the standard web interface. |
|
|
||||||
| `mode_type` | Comma-separated list | (all) | `?mode_type=PHONE,CW` | Sets the list of mode types that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
|
|
||||||
| `dx_continent` | Comma-separated list | (all) | `?dx_continent=NA,SA` | Sets the list of DX Continents that will be shown on any spot or alert pages. Available options match the labels of the buttons in the standard web interface. |
|
|
||||||
| `de_continent` | Comma-separated list | (all) | `?de_continent=EU` | Sets the list of DE Continents that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
|
|
||||||
| `map-center-lat` | Numeric (decimal) | (auto) | `?map-center-lat=51.5` | Sets the initial latitude of the map centre on the map page. If omitted, the map auto-fits to the loaded spots. |
|
|
||||||
| `map-center-lon` | Numeric (decimal) | (auto) | `?map-center-lon=-0.1` | Sets the initial longitude of the map centre on the map page. If omitted, the map auto-fits to the loaded spots. |
|
|
||||||
| `map-zoom` | Numeric (integer) | (auto) | `?map-zoom=6` | Sets the initial zoom level of the map on the map page. If omitted, the map auto-fits to the loaded spots. |
|
|
||||||
|
|
||||||
See the comment at the end of the next section regarding reliability and uptime of the "main" server.
|
|
||||||
|
|
||||||
## Writing your own client
|
|
||||||
|
|
||||||
One of the key strengths of Spothole is that the API is well-defined and open to anyone to use. This means you can build
|
|
||||||
your own software that uses data from Spothole.
|
|
||||||
|
|
||||||
As well as the main API endpoints to fetch spots and alerts, with various possible query parameters, there are also
|
|
||||||
Server-Sent Events (SSE) API endpoints to receive a live feed, plus various utility lookup endpoints for things like
|
|
||||||
callsign and park data.
|
|
||||||
|
|
||||||
Various approaches exist to writing your own client, but in general:
|
|
||||||
|
|
||||||
* Refer to the API docs. These are built on an OpenAPI definition file (`/static/apidocs/openapi.yml`), which you can
|
|
||||||
automatically use to generate a client skeleton using various software.
|
|
||||||
* Call the main "spots" or "alerts" API endpoints to get the data you want. For example, your app could call
|
|
||||||
`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
|
|
||||||
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 "alerts"/"upcoming"
|
|
||||||
page simply query the main spot API on a timer, whereas the spots, map and bands pages combine this approach with
|
|
||||||
using the Server-Sent Events (SSE) endpoint to update live.
|
|
||||||
* 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
|
|
||||||
once every two minutes, so if your client is interested in POTA data there's no need to poll Spothole any more often
|
|
||||||
than that.
|
|
||||||
|
|
||||||
If you absolutely must be informed within seconds of a spot arriving in Spothole, please use the SSE endpoints instead,
|
|
||||||
e.g. `https://spothole.app/api/v1/spots/stream`.
|
|
||||||
|
|
||||||
If you want to handle different types of spot or alert differently within your client, please consider making a single
|
|
||||||
request to the Spothole API to retrieve all the data, then filtering on your side. For example, call
|
|
||||||
`https://spothole.app/api/v1/spots?sig=POTA,SOTA` rather than making two separate calls to
|
|
||||||
`https://spothole.app/api/v1/spots?sig=POTA` and `https://spothole.app/api/v1/spots?sig=SOTA`.
|
|
||||||
|
|
||||||
Remember, here at Spothole Inc. we offer an industry-standard "five nines" uptime on our server, with our own unique
|
|
||||||
twist: we don't tell you which side of the decimal point the nines start! (Translation: This is a hobby project.
|
|
||||||
`spothole.app` runs on the same server as my blog and other stuff. It might go down without warning. By all means base
|
|
||||||
your own project on data from the main server if you like, but if you want any control over reliability and downtime,
|
|
||||||
please run your own copy instead.)
|
|
||||||
|
|
||||||
## Running your own copy
|
|
||||||
|
|
||||||
If you want to run a copy of Spothole with different configuration settings than the main instance, you can download it
|
|
||||||
and run it on your own local machine or server.
|
|
||||||
|
|
||||||
You will require Python version 3.8 or later. If you encounter an error about `gdal-config` during the following
|
|
||||||
process, you will also need `libgdal-dev` installed.
|
|
||||||
|
|
||||||
To download and set up Spothole on a Debian server, run the following commands. Other operating systems will likely be
|
|
||||||
similar.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone ssh://git@git.ianrenton.com/ian/spothole.git
|
|
||||||
cd spothole
|
|
||||||
python3 -m venv ./.venv
|
|
||||||
source .venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
deactivate
|
|
||||||
cp config-example.yml config.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
Then edit `config.yml` in your text editor of choice to set up the software as you like it. Mostly, this will involve
|
|
||||||
enabling or disabling the various providers of spot, alert and other data.
|
|
||||||
|
|
||||||
By default, all outdoor programme providers are enabled, as is one cluster node and the NG3K DXpedition data. The RBN
|
|
||||||
spot providers are turned off by default due to the volume of traffic from CW/RTTY/FT8 skimmers, and the APRS and Packet
|
|
||||||
spot providers are off by default on the assumption that Spothole users want a spot with a human at the other end of it,
|
|
||||||
but all can be easily re-enabled.
|
|
||||||
|
|
||||||
Other parameters you will want to update include the base URL to your instance, and whether you want to serve a full
|
|
||||||
web-based DX cluster interface or just the API endpoints for client software to use.
|
|
||||||
|
|
||||||
`config.yml` has an entry for a Clublog API key. If provided, this will allow Spothole to retrieve some more information
|
|
||||||
about DX spots. The software will work just fine without it, but you may find a few country flags etc. are less accurate
|
|
||||||
or missing. Clublog API keys are free, but you'll need to get your own by submitting a helpdesk ticket and explaining
|
|
||||||
what you'll use it for. The admin team are happy with the rate of requests made by my Spothole server, so unless you
|
|
||||||
change the source code of yours to radically increase the rate of querying Clublog, I'm sure they will be fine with your
|
|
||||||
server too.
|
|
||||||
|
|
||||||
Once you're happy with the content of `config.yml`, you can proceed to running the software.
|
|
||||||
|
|
||||||
To run the software this time and any future times you want to run it directly from the command line:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source .venv/bin/activate
|
|
||||||
python3 spothole.py
|
|
||||||
```
|
|
||||||
|
|
||||||
The software can take a few seconds to start up, mostly because it is downloading an updated file to match callsigns to
|
|
||||||
countries. This is normal, don't panic!
|
|
||||||
|
|
||||||
If you see some errors on startup, check your configuration, e.g. in case you have specified a port for the web server
|
|
||||||
that is already in use by something else.
|
|
||||||
|
|
||||||
### Multiple cluster nodes with different settings
|
|
||||||
|
|
||||||
Dan, S50U has written in with his Spothole cluster settings. He is using a cluster node which provides RBN spots, and
|
|
||||||
uses different SSIDs on his callsign to get different settings when logged into the same cluster node. For example:
|
|
||||||
|
|
||||||
```
|
|
||||||
-
|
|
||||||
class: "DXCluster"
|
|
||||||
name: "S50CLX"
|
|
||||||
enabled: true
|
|
||||||
host: "s50clx.si"
|
|
||||||
port: 41112
|
|
||||||
login_prompt: "login: "
|
|
||||||
login_callsign: "callsign-10"
|
|
||||||
```
|
|
||||||
|
|
||||||
Telnet to DXSpider and log in with "callsign-10" and execute the following commands:
|
|
||||||
|
|
||||||
`CLEAR/SPOTS ALL` (delete all previous filters)<br/>
|
|
||||||
`UNSET/ANN` (stop announce messages)<br/>
|
|
||||||
`UNSET/WCY` (stop wcy messages)<br/>
|
|
||||||
`UNSET/WWV` (stop wwv messages)<br/>
|
|
||||||
`SET/DX` (enable human DX spots)
|
|
||||||
|
|
||||||
```
|
|
||||||
-
|
|
||||||
class: "DXCluster"
|
|
||||||
name: "RBN CW"
|
|
||||||
enabled: true
|
|
||||||
host: "s50clx.si"
|
|
||||||
port: 41112
|
|
||||||
login_prompt: "login: "
|
|
||||||
login_callsign: "callsign-11"
|
|
||||||
allow_rbn_spots: true
|
|
||||||
enabled-by-default-in-web-ui: false
|
|
||||||
```
|
|
||||||
|
|
||||||
Telnet to DXSpider and log in with "callsign-11" and execute the following commands:
|
|
||||||
|
|
||||||
`CLEAR/SPOTS ALL` (delete all previous filters)<br/>
|
|
||||||
`UNSET/ANN` (stop announce messages)<br/>
|
|
||||||
`UNSET/WCY` (stop wcy messages)<br/>
|
|
||||||
`UNSET/WWV` (stop wwv messages)<br/>
|
|
||||||
`UNSET/DX` (stop human DX spots)<br/>
|
|
||||||
`SET/SKIMMER CW` (enable CW RBN spots)
|
|
||||||
|
|
||||||
```
|
|
||||||
-
|
|
||||||
class: "DXCluster"
|
|
||||||
name: "RBN RTTY"
|
|
||||||
enabled: true
|
|
||||||
host: "s50clx.si"
|
|
||||||
port: 41112
|
|
||||||
login_prompt: "login: "
|
|
||||||
login_callsign: "callsign-12"
|
|
||||||
allow_rbn_spots: true
|
|
||||||
enabled-by-default-in-web-ui: false
|
|
||||||
```
|
|
||||||
|
|
||||||
Telnet to DXSpider and log in with "callsign-12" and execute the following commands:
|
|
||||||
|
|
||||||
`CLEAR/SPOTS ALL` (delete all previous filters)<br/>
|
|
||||||
`UNSET/ANN` (stop announce messages)<br/>
|
|
||||||
`UNSET/WCY` (stop wcy messages)<br/>
|
|
||||||
`UNSET/WWV` (stop wwv messages)<br/>
|
|
||||||
`UNSET/DX` (stop human DX spots)<br/>
|
|
||||||
`SET/SKIMMER RTTY` (enable RTTY RBN spots)
|
|
||||||
|
|
||||||
```
|
|
||||||
-
|
|
||||||
class: "DXCluster"
|
|
||||||
name: "RBN FT4/8"
|
|
||||||
enabled: true
|
|
||||||
host: "s50clx.si"
|
|
||||||
port: 41112
|
|
||||||
login_prompt: "login: "
|
|
||||||
login_callsign: "callsign-13"
|
|
||||||
allow_rbn_spots: true
|
|
||||||
enabled-by-default-in-web-ui: false
|
|
||||||
```
|
|
||||||
|
|
||||||
Telnet to DXSpider and log in with "callsign-13" and execute the following commands:
|
|
||||||
|
|
||||||
`CLEAR/SPOTS ALL` (delete all previous filters)<br/>
|
|
||||||
`UNSET/ANN` (stop announce messages)<br/>
|
|
||||||
`UNSET/WCY` (stop wcy messages)<br/>
|
|
||||||
`UNSET/WWV` (stop wwv messages)<br/>
|
|
||||||
`UNSET/DX` (stop human DX spots)<br/>
|
|
||||||
`SET/SKIMMER FT` (enable FT RBN spots)
|
|
||||||
|
|
||||||
For each callsign-SSID, we also specify our basic information with commands:
|
|
||||||
|
|
||||||
`SET/NAME Spothole10`, Spothole11... etc.<br/>
|
|
||||||
`SET/QTH Cerkno`<br/>
|
|
||||||
`SET/QRA JN66XD`<br/>
|
|
||||||
`SET/HOME S50CLX`
|
|
||||||
|
|
||||||
### systemd configuration
|
|
||||||
|
|
||||||
If you want Spothole to run automatically on startup on a Linux distribution that uses `systemd`, follow the
|
|
||||||
instructions here. For distros that don't use `systemd`, or Windows/OSX/etc., you can find generic instructions for your
|
|
||||||
OS online.
|
|
||||||
|
|
||||||
Create a file at `/etc/systemd/system/spothole.service`. Give it the following content, adjusting for the user you want
|
|
||||||
to run it as and the directory in which you have installed it:
|
|
||||||
|
|
||||||
```
|
|
||||||
[Unit]
|
|
||||||
Description=Spothole
|
|
||||||
After=syslog.target network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=spothole
|
|
||||||
WorkingDirectory=/home/spothole/spothole
|
|
||||||
ExecStart=/home/spothole/spothole/.venv/bin/python /home/spothole/spothole/spothole.py --serve-in-foreground
|
|
||||||
Restart=on-abort
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the following:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable spothole
|
|
||||||
sudo systemctl start spothole
|
|
||||||
```
|
|
||||||
|
|
||||||
Check the service has started up correctly with `sudo journalctl -u spothole -f`.
|
|
||||||
|
|
||||||
### nginx Reverse Proxy configuration
|
|
||||||
|
|
||||||
Web servers generally serve their pages from port 80. However, it's best not to serve Spothole's web interface directly
|
|
||||||
on port 80, as that requires root privileges on a Linux system. It also and prevents us using HTTPS to serve a secure
|
|
||||||
site, since Spothole itself doesn't directly support acting as an HTTPS server. The normal solution to this is to use a
|
|
||||||
"reverse proxy" setup, where a general web server handles HTTP and HTTP requests (to port 80 & 443 respectively), then
|
|
||||||
passes on the request to the back-end application (in this case Spothole). nginx is a common choice for this general web
|
|
||||||
server.
|
|
||||||
|
|
||||||
To set up nginx as a reverse proxy that sits in front of Spothole, first ensure it's installed e.g.
|
|
||||||
`sudo apt install nginx`, and enabled e.g. `sudo systemd enable nginx`.
|
|
||||||
|
|
||||||
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,
|
|
||||||
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
|
|
||||||
server {
|
|
||||||
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
|
|
||||||
location /.well-known/ {
|
|
||||||
alias /var/www/html/.well-known/;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Load static assets directly from the Spothole static directory
|
|
||||||
location /static/ {
|
|
||||||
alias /home/spothole/spothole/static/;
|
|
||||||
expires 1h;
|
|
||||||
add_header Cache-Control "public, max-age=3600, must-revalidate";
|
|
||||||
}
|
|
||||||
|
|
||||||
# SSE endpoints
|
|
||||||
location ~ ^/api/v1/(spots|alerts)/stream/? {
|
|
||||||
proxy_pass http://127.0.0.1:8080;
|
|
||||||
|
|
||||||
# Remove buffering, remove caching, add suitable timeouts for SSE API calls
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
proxy_read_timeout 24h;
|
|
||||||
proxy_send_timeout 24h;
|
|
||||||
proxy_set_header X-Accel-Buffering no;
|
|
||||||
add_header Cache-Control no-store always;
|
|
||||||
|
|
||||||
# Allow cross-origin requests to API
|
|
||||||
proxy_hide_header Access-Control-Allow-Origin;
|
|
||||||
add_header Access-Control-Allow-Origin * always;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Other API endpoints
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://127.0.0.1:8080;
|
|
||||||
|
|
||||||
# Remove buffering, remove caching, add suitable timeouts for API calls
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
proxy_read_timeout 30s;
|
|
||||||
add_header Cache-Control no-store always;
|
|
||||||
|
|
||||||
# Allow cross-origin requests to API
|
|
||||||
proxy_hide_header Access-Control-Allow-Origin;
|
|
||||||
add_header Access-Control-Allow-Origin * always;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Templated pages
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:8080;
|
|
||||||
proxy_read_timeout 30s;
|
|
||||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
One further change you might want to make to the file above is the `add_header Access-Control-Allow-Origin` statements.
|
|
||||||
These are what's used on my own Spothole server to make sure that other third-party web-based software can get the data
|
|
||||||
from my instance, and applies to any endpoint underneath `/api`. If you want *your* Spothole instance to be set up the
|
|
||||||
same way, so that others can write software in JavaScript that can access it, leave this intact. But if you want your
|
|
||||||
Spothole instance to only be usable by scripts running on the web server you write, you can remove these lines. (Note
|
|
||||||
that this doesn't stop other people writing *non-web-based* software that accesses your Spothole API—the
|
|
||||||
enforcement of cross-origin headers only happens within the user's browser. If you need to lock your instance down so
|
|
||||||
that no-one else can access it with *any* software, that's an aspect of nginx or firewall config that you will need to
|
|
||||||
find help with elsewhere.)
|
|
||||||
|
|
||||||
Now, make a symbolic link to enable the site:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /etc/nginx/sites-enabled
|
|
||||||
sudo ln -sf ../sites-available/spothole
|
|
||||||
```
|
|
||||||
|
|
||||||
Test that your nginx config isn't broken using `nginx -t`. If it works, restart nginx with
|
|
||||||
`sudo systemctl restart nginx`.
|
|
||||||
|
|
||||||
If you haven't already done so, set up a DNS entry to make sure requests for your domain name end up at the server
|
|
||||||
that's running Spothole.
|
|
||||||
|
|
||||||
You should now be able to access the web interface by going to the domain from your browser.
|
|
||||||
|
|
||||||
Once that's working, [install certbot](https://certbot.eff.org/instructions?ws=nginx&os=snap) onto your server. Run it
|
|
||||||
as root, and when prompted pick your domain name from the list. After a few seconds, it should successfully provision a
|
|
||||||
certificate and modify your nginx config files automatically. You should then be able to access the site via HTTPS.
|
|
||||||
|
|
||||||
## Modifying the source code
|
|
||||||
|
|
||||||
Spothole is Public Domain licenced, so you can grab the source code and start modifying it for your own needs.
|
|
||||||
Contributions of code back to the main repository are encouraged, but completely optional.
|
|
||||||
|
|
||||||
### Code structure
|
|
||||||
|
|
||||||
To navigate your way around the source code, this list may help.
|
|
||||||
|
|
||||||
*Python back-end code*
|
|
||||||
|
|
||||||
* `/core` - Core classes and utilities
|
|
||||||
* `/data` - Data storage classes
|
|
||||||
* `/providers/spot` - Classes providing spots by accessing the APIs of other services
|
|
||||||
* `/providers/alert` - Classes providing alerts by accessing the APIs of other services
|
|
||||||
* `/providers/solarconditions` - Classes providing solar and propagation by accessing the APIs of other services
|
|
||||||
* `/providers/staticdata` - Classes providing static lookup data by accessing bundled data files or the APIs of other
|
|
||||||
services
|
|
||||||
* `/providers/sigrefdata` - Classes providing SIG reference lookup data by accessing bundled data files or the APIs of
|
|
||||||
other services
|
|
||||||
* `/server` - Classes for running Spothole's own web server
|
|
||||||
* `spothole.py` - Main application script
|
|
||||||
|
|
||||||
*Templates*
|
|
||||||
|
|
||||||
* `/templates` - Templates used for constructing Spothole's user-targeted HTML pages
|
|
||||||
|
|
||||||
*HTML/JS/CSS front-end code*
|
|
||||||
|
|
||||||
* `/static` - Root for static files served by the web server. These are all served from a path starting `/static/`.
|
|
||||||
* `/static/apidocs` - Contains the OpenAPI spec (`openapi.yml`)
|
|
||||||
* `/static/audio` - Audio files used by the web front-end
|
|
||||||
* `/static/css` - CSS files used by the web front-end
|
|
||||||
* `/static/img` - image files used by the web front-end
|
|
||||||
* `/static/js` - JavaScript used by the web front-end
|
|
||||||
* `/static/vendor` - Third-party libraries (CSS, JS, fonts and images)
|
|
||||||
|
|
||||||
*Miscellaneous*
|
|
||||||
|
|
||||||
* `/` - pip `requirements.txt`, config, README, etc.
|
|
||||||
* `/images` - Image sources
|
|
||||||
* `/datafiles` - Local data files, used by some providers when the data will never change and/or is not easily available
|
|
||||||
online in a format Spothole can handle
|
|
||||||
* `/cache` - Directory where Spothole stores all the data it uses that should be persisted to disk. Created on first
|
|
||||||
run.
|
|
||||||
|
|
||||||
### Extending the server
|
|
||||||
|
|
||||||
Spothole is designed to be easily extensible. If you want to write your own spot provider, for example, simply add a
|
|
||||||
module to the `providers.spot` package containing your class. (Currently, in order to be loaded correctly, the module
|
|
||||||
(file) name should be the same as the class name, but lower case.)
|
|
||||||
|
|
||||||
Your class should extend "SpotProvider"; if it operates by polling an HTTP Server on a timer, it can instead extend "
|
|
||||||
HTTPSpotProvider" where some of the work is done for you.
|
|
||||||
|
|
||||||
The class will need to implement a constructor that takes in the `provider_config` and provides it to the superclass
|
|
||||||
constructor, while also taking any other config parameters it needs.
|
|
||||||
|
|
||||||
If you're extending the base `SpotProvider` class, you will need to implement `start()` and `stop()` methods that start
|
|
||||||
and stop a separate thread which handles the provider's processing needs. The thread should call `submit()` or
|
|
||||||
`submit_batch()` when it has one or more spots to report.
|
|
||||||
|
|
||||||
If you're extending the `HTTPSpotProvider` class, you will need to provide a URI to query and an interval to the
|
|
||||||
superclass constructor. You'll then need to implement the `http_response_to_spots()` method which is called when new
|
|
||||||
data is retrieved. Your implementation should then call `submit()` or `submit_batch()` when it has one or more spots to
|
|
||||||
report.
|
|
||||||
|
|
||||||
When constructing spots, use the comments in the Spot class and the existing implementations as an example. All
|
|
||||||
parameters are optional, but you will at least want to provide a `time` (which must be timezone-aware) and a `dx_call`.
|
|
||||||
|
|
||||||
Finally, simply add the appropriate config to the `spot_providers` section of `config.yml`, and your provider should be
|
|
||||||
instantiated on startup.
|
|
||||||
|
|
||||||
The same approach as above is also used for alerts, and other types of providers. Give me a shout if you need any
|
|
||||||
advice.
|
|
||||||
|
|
||||||
## Thanks
|
## Thanks
|
||||||
|
|
||||||
@@ -518,8 +64,11 @@ This project would not have been possible without these libraries, so many thank
|
|||||||
|
|
||||||
### Third Party Libraries
|
### Third Party Libraries
|
||||||
|
|
||||||
A number of third-party libraries are self-hosted in the `/static/vendor/` directory. These files are subject to their
|
A number of third-party libraries are self-hosted in the `/static/vendor/` directory. These files are subject to
|
||||||
own licences and are not covered by the overall licence declared in the `LICENSE` file.
|
their own licences and are not covered by the overall licence declared in the `LICENSE` file.
|
||||||
|
|
||||||
|
A number of third-party libraries are self-hosted in the `/webassets/vendor/` directory. These files are subject to
|
||||||
|
their own licences and are not covered by the overall licence declared in the `LICENSE` file.
|
||||||
|
|
||||||
Particular thanks go to country-files.com for providing country lookup data for amateur radio, to K0SWE
|
Particular thanks go to country-files.com for providing country lookup data for amateur radio, to K0SWE
|
||||||
for [this JSON-formatted DXCC data](https://github.com/k0swe/dxcc-json/), and to the developers of `pyhamtools` for
|
for [this JSON-formatted DXCC data](https://github.com/k0swe/dxcc-json/), and to the developers of `pyhamtools` for
|
||||||
|
|||||||
+71
-18
@@ -166,7 +166,23 @@ alert-providers:
|
|||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
|
||||||
# Static reference data providers to use. This allows Spothole to download data such as mapping between callsign
|
# Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band
|
||||||
|
# conditions, etc.) and make it available via the /api/v2/solar endpoint.
|
||||||
|
solar-condition-providers:
|
||||||
|
- class: "HamQSL"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- class: "NOAA3dayForecast"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- class: "GIROIonosonde"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- class: "KC2GProp"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
|
||||||
|
# Static reference data providers to use. These allow Spothole to download data such as mapping between callsign
|
||||||
# prefixes and DXCC entities.
|
# prefixes and DXCC entities.
|
||||||
static-data-providers:
|
static-data-providers:
|
||||||
- class: "K0SWE"
|
- class: "K0SWE"
|
||||||
@@ -179,7 +195,7 @@ static-data-providers:
|
|||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
|
||||||
# SIG reference data providers to use. This allows Spothole to download, for example, the WWFF directory that maps WWFF
|
# SIG reference data providers to use. These allow Spothole to download, for example, the WWFF directory that maps WWFF
|
||||||
# park IDs to their name and location.
|
# park IDs to their name and location.
|
||||||
sig-ref-data-providers:
|
sig-ref-data-providers:
|
||||||
- class: "POTA"
|
- class: "POTA"
|
||||||
@@ -233,33 +249,70 @@ sig-ref-data-providers:
|
|||||||
- class: "Toilets"
|
- class: "Toilets"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
# Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band
|
|
||||||
# conditions, etc.) and make it available via the /api/v1/solar endpoint.
|
# Callsign data providers to use. These allow Spothole to provide information about callsigns, either from static
|
||||||
solar-condition-providers:
|
# lookups or from web-based services such as QRZ. Note that use of QRZ and HamQTH is *on behalf of the user*, and
|
||||||
- class: "HamQSL"
|
# requires the user to enter their credentials into the website UI or provide them in an API call. Spothole will not
|
||||||
|
# look up all calls via QRZ/HamQTH using the server owner's credentials, as this is against their usage policy.
|
||||||
|
callsign-data-providers:
|
||||||
|
- class: "QRZ"
|
||||||
|
enabled: true
|
||||||
|
# Callsign data providers can often provide conflicting data, e.g. a home location from QRZ vs the centre of the
|
||||||
|
# DXCC entity from a country file lookup. The priority flag sets which source "wins" in the event of conflict.
|
||||||
|
# Lower numbers take priority over higher numbers. Generally then, QRZ/HamQTH should have low numbers as they are
|
||||||
|
# likely to have the most accurate data.
|
||||||
|
priority: 1
|
||||||
|
# No server-side credentials for QRZ. Users must provide their own as per QRZ policy.
|
||||||
|
|
||||||
|
- class: "HamQTH"
|
||||||
|
enabled: true
|
||||||
|
priority: 2
|
||||||
|
# No server-side credentials for HamQTH. Users must provide their own.
|
||||||
|
|
||||||
|
- class: "ClublogAPI"
|
||||||
|
# Querying the Clublog API directly doesn't provide any more data than the XML version, it just provides slightly
|
||||||
|
# more up-to-date information in the rare case that the prefix data changes, at a significant cost of looking up
|
||||||
|
# every callsign via an API call. Normally left disabled but it exists as an option.
|
||||||
|
enabled: false
|
||||||
|
priority: 3
|
||||||
|
# API key for Clublog to look up information. Required in order to enable this provider. Unlike QRZ and HamQTH,
|
||||||
|
# Clublog uses an API key issued to Spothole, not to the end user.
|
||||||
|
api-key: ""
|
||||||
|
|
||||||
|
- class: "ClublogXML"
|
||||||
|
enabled: true
|
||||||
|
priority: 4
|
||||||
|
# API key for Clublog to look up information. Required in order to enable this provider. You will need to request
|
||||||
|
# one via their helpdesk portal if you want to use callsign lookups from Clublog.
|
||||||
|
api-key: ""
|
||||||
|
|
||||||
|
- class: "CountryFiles"
|
||||||
|
priority: 5
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "NOAA3dayForecast"
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
- class: "GIROIonosonde"
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
- class: "KC2GProp"
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
# Maximum time to keep spots and alerts in the system before deleting them. By default, one hour for spots and one week
|
# Maximum time to keep spots and alerts in the system before deleting them. By default, one hour for spots and one week
|
||||||
# for alerts.
|
# for alerts.
|
||||||
max-spot-age-sec: 3600
|
max-spot-age-sec: 3600
|
||||||
max-alert-age-sec: 604800
|
max-alert-age-sec: 604800
|
||||||
|
|
||||||
# API key for Clublog to look up information. Optional. You sill need to request one via their helpdesk portal if you
|
|
||||||
# want to use callsign lookups from Clublog.
|
|
||||||
clublog-api-key: ""
|
|
||||||
|
|
||||||
# Allow submitting spots to the Spothole API?
|
# Allow submitting spots to the Spothole API?
|
||||||
allow-spotting: true
|
allow-spotting: true
|
||||||
|
|
||||||
|
# Allow upstream submission of spots to external providers (POTA, SOTA, etc.) via the API?
|
||||||
|
# Requires allow-spotting to also be true. Set to false to only accept spots into the local
|
||||||
|
# Spothole database, without forwarding them to any external service.
|
||||||
|
allow-upstream-spotting: true
|
||||||
|
|
||||||
|
# Google reCAPTCHA v2 keys for CAPTCHA protection on upstream spot submission. Both keys must be set to enable CAPTCHA.
|
||||||
|
# Leave both empty to disable CAPTCHA (e.g. for a private/trusted server) or if allow-spotting is false, in which case
|
||||||
|
# they will do nothing. Note that with CAPTCHA enabled, this will prevent third-party clients submitting spots through
|
||||||
|
# Spothole unless the clients are web-based, use the same site key, have their domains enabled in your reCAPTCHA config,
|
||||||
|
# and of course their user solves the CAPTCHA.
|
||||||
|
# You can sign up for reCAPTCHA at https://www.google.com/recaptcha/
|
||||||
|
recaptcha-site-key: ""
|
||||||
|
recaptcha-secret-key: ""
|
||||||
|
|
||||||
# Log web requests? Useful to see what your users are requesting in terms of pages and API endpoints, but will fill up
|
# 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.
|
# your log quickly on a popular server.
|
||||||
log-web-requests: false
|
log-web-requests: false
|
||||||
|
|||||||
+30
-632
@@ -1,632 +1,30 @@
|
|||||||
import gzip
|
from core.data_providers import DATA_PROVIDERS
|
||||||
import logging
|
from data.callsign import Callsign
|
||||||
import urllib.parse
|
|
||||||
from datetime import timedelta
|
|
||||||
|
def get_call_info(callsign, lookup_credentials):
|
||||||
import xmltodict
|
"""Utility method to get the best set of data for a callsign as we can, using all enabled providers.
|
||||||
from diskcache import Cache
|
lookup_credentials is an optional object that carries the user's QRZ.com/HamQTH credentials, if they provided them,
|
||||||
from pyhamtools import LookupLib, Callinfo, callinfo
|
to enable lookup using those providers."""
|
||||||
from pyhamtools.exceptions import APIKeyMissingError
|
|
||||||
from pyhamtools.locator import latlong_to_locator
|
callsign_data = Callsign(call=callsign)
|
||||||
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
|
||||||
from requests_cache import CachedSession
|
if callsign:
|
||||||
|
# Sort callsign providers by priority order, so we query the highest priority (lowest numbers) first, and only
|
||||||
from core.config import config
|
# query other providers for data we are missing as we go along.
|
||||||
from core.constants import HTTP_HEADERS, HAMQTH_PRG
|
for p in sorted(DATA_PROVIDERS.callsign_data_providers, key=lambda p: p.priority):
|
||||||
from core.data_store import DATA_STORE
|
if p.enabled:
|
||||||
from core.url_data_cache import URLDataCache
|
# Get new lookup data
|
||||||
|
data = p.lookup(callsign, lookup_credentials)
|
||||||
# QRZ XML field names differ from pyhamtools' normalised names; map them here.
|
if data:
|
||||||
_QRZ_FIELD_MAP = {
|
# If we have new data for fields that were previously unpopulated, add them in
|
||||||
"lat": "latitude",
|
for key, value in data.__dict__.items():
|
||||||
"lon": "longitude",
|
if value is not None and callsign_data.__dict__.get(key) is None:
|
||||||
"grid": "locator",
|
callsign_data.__dict__[key] = value
|
||||||
"ituzone": "ituz",
|
|
||||||
"cqzone": "cqz",
|
# If callsign data is fully populated, avoid looking up using other providers as their data will not
|
||||||
}
|
# be used
|
||||||
_QRZ_INT_FIELDS = {"adif", "cqz", "ituz"}
|
if callsign_data.fully_populated():
|
||||||
_QRZ_FLOAT_FIELDS = {"latitude", "longitude"}
|
break
|
||||||
_URL_DATA_CACHE = URLDataCache("callsign_lookup")
|
|
||||||
|
return callsign_data
|
||||||
|
|
||||||
def _normalize_qrz_data(raw):
|
|
||||||
data = {}
|
|
||||||
for k, v in raw.items():
|
|
||||||
if v is None:
|
|
||||||
continue
|
|
||||||
mapped_key = _QRZ_FIELD_MAP.get(k, k)
|
|
||||||
if mapped_key in _QRZ_INT_FIELDS:
|
|
||||||
try:
|
|
||||||
v = int(v)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
elif mapped_key in _QRZ_FLOAT_FIELDS:
|
|
||||||
try:
|
|
||||||
v = float(v)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
data[mapped_key] = v
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class LookupHelper:
|
|
||||||
"""Singleton class that provides lookup functionality."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Create the lookup helper. Note that nothing actually happens until the start() method is called, and that all
|
|
||||||
lookup methods will fail if start() has not yet been called. This therefore needs starting before any spot or
|
|
||||||
alert handlers are created."""
|
|
||||||
|
|
||||||
self._clublog_callsign_data_cache = None
|
|
||||||
self._lookup_lib_clublog_xml = None
|
|
||||||
self._clublog_xml_available = None
|
|
||||||
self._lookup_lib_clublog_api = None
|
|
||||||
self._clublog_xml_download_location = None
|
|
||||||
self._clublog_api_available = None
|
|
||||||
self._clublog_cty_xml_cache = None
|
|
||||||
self._clublog_api_key = None
|
|
||||||
self._qrz_callsign_data_cache = None
|
|
||||||
self._qrz_base_url = "https://xmldata.qrz.com/xml/current/"
|
|
||||||
# QRZ session keys expire after an hour; cache the login response for 55 minutes.
|
|
||||||
self._qrz_session_cache = CachedSession("cache/qrz_session_cache",
|
|
||||||
expire_after=timedelta(minutes=55))
|
|
||||||
self._hamqth_callsign_data_cache = None
|
|
||||||
self._hamqth_base_url = "https://www.hamqth.com/xml.php"
|
|
||||||
# HamQTH session keys expire after an hour. Rather than working out how much time has passed manually, we cheat
|
|
||||||
# and cache the HTTP response for 55 minutes, so when the login URL is queried within 55 minutes of the previous
|
|
||||||
# time, you just get the cached response.
|
|
||||||
self._hamqth_session_lookup_cache = CachedSession("cache/hamqth_session_cache",
|
|
||||||
expire_after=timedelta(minutes=55))
|
|
||||||
self._call_info_basic = None
|
|
||||||
self._lookup_lib_basic = None
|
|
||||||
self._country_files_cty_plist_download_location = None
|
|
||||||
self._dxcc_json_download_location = None
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
# Lookup helpers from pyhamtools. We use five (!) of these. The simplest is country-files.com, which downloads
|
|
||||||
# the data once on startup, and requires no login/key, but does not have the best coverage.
|
|
||||||
# If the user provides login details/API keys, we also set up helpers for QRZ.com, HamQTH, Clublog (live API
|
|
||||||
# request), and Clublog (XML download). The lookup functions iterate through these in a sensible order, looking
|
|
||||||
# for suitable data.
|
|
||||||
self._country_files_cty_plist_download_location = "cache/cty.plist"
|
|
||||||
success = self._download_country_files_cty_plist()
|
|
||||||
if success:
|
|
||||||
self._lookup_lib_basic = LookupLib(lookuptype="countryfile",
|
|
||||||
filename=self._country_files_cty_plist_download_location)
|
|
||||||
else:
|
|
||||||
self._lookup_lib_basic = LookupLib(lookuptype="countryfile")
|
|
||||||
self._call_info_basic = Callinfo(self._lookup_lib_basic)
|
|
||||||
|
|
||||||
self._qrz_callsign_data_cache = Cache('cache/qrz_callsign_lookup_cache')
|
|
||||||
|
|
||||||
self._hamqth_callsign_data_cache = Cache('cache/hamqth_callsign_lookup_cache')
|
|
||||||
|
|
||||||
self._clublog_api_key = str(config["clublog-api-key"])
|
|
||||||
self._clublog_cty_xml_cache = CachedSession("cache/clublog_cty_xml_cache", expire_after=timedelta(days=10))
|
|
||||||
self._clublog_api_available = self._clublog_api_key != ""
|
|
||||||
self._clublog_xml_download_location = "cache/cty.xml"
|
|
||||||
if self._clublog_api_available:
|
|
||||||
self._lookup_lib_clublog_api = LookupLib(lookuptype="clublogapi", apikey=self._clublog_api_key)
|
|
||||||
success = self._download_clublog_ctyxml()
|
|
||||||
self._clublog_xml_available = success
|
|
||||||
if success:
|
|
||||||
self._lookup_lib_clublog_xml = LookupLib(lookuptype="clublogxml",
|
|
||||||
filename=self._clublog_xml_download_location)
|
|
||||||
self._clublog_callsign_data_cache = Cache('cache/clublog_callsign_lookup_cache')
|
|
||||||
|
|
||||||
|
|
||||||
def _download_country_files_cty_plist(self):
|
|
||||||
"""Download the cty.plist file from country-files.com on first startup. The pyhamtools lib can actually download and use
|
|
||||||
this itself, but it's occasionally offline which causes it to throw an error. By downloading it separately, we can
|
|
||||||
catch errors and handle them, falling back to a previous copy of the file in the cache, and we can use the
|
|
||||||
requests_cache library to prevent re-downloading too quickly if the software keeps restarting."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
logging.info("Downloading Country-files.com cty.plist...")
|
|
||||||
response = _URL_DATA_CACHE.get("https://www.country-files.com/cty/cty.plist",
|
|
||||||
headers=HTTP_HEADERS)
|
|
||||||
|
|
||||||
if response.ok:
|
|
||||||
with open(self._country_files_cty_plist_download_location, "w") as f:
|
|
||||||
f.write(response.text)
|
|
||||||
f.flush()
|
|
||||||
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 (ConnectTimeout, ReadTimeout):
|
|
||||||
logging.warning(f"Timeout when downloading Clublog cty.xml.")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error("Exception when downloading Clublog cty.xml", e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _download_clublog_ctyxml(self):
|
|
||||||
"""Download the cty.xml (gzipped) file from Clublog on first startup, so we can use it in preference to querying the
|
|
||||||
database live if possible."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
logging.info("Downloading Clublog cty.xml.gz...")
|
|
||||||
response = self._clublog_cty_xml_cache.get("https://cdn.clublog.org/cty.php?api=" + self._clublog_api_key,
|
|
||||||
headers=HTTP_HEADERS)
|
|
||||||
logging.info("Caching Clublog cty.xml.gz...")
|
|
||||||
open(self._clublog_xml_download_location + ".gz", 'wb').write(response.content)
|
|
||||||
with gzip.open(self._clublog_xml_download_location + ".gz", "rb") as uncompressed:
|
|
||||||
file_content = uncompressed.read()
|
|
||||||
assert isinstance(file_content, bytes)
|
|
||||||
logging.info("Caching Clublog cty.xml...")
|
|
||||||
with open(self._clublog_xml_download_location, "wb") as f:
|
|
||||||
f.write(file_content)
|
|
||||||
f.flush()
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logging.error("Exception when downloading Clublog cty.xml", e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def infer_country_from_callsign(self, call, credentials=None):
|
|
||||||
"""Infer a country name from a callsign"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Start with the basic country-files.com-based decoder.
|
|
||||||
country = self._call_info_basic.get_country_name(call)
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
country = None
|
|
||||||
# Couldn't get anything from basic call info database, try QRZ.com
|
|
||||||
if not country:
|
|
||||||
qrz_data = self._get_qrz_data_for_callsign(call, credentials)
|
|
||||||
if qrz_data and "country" in qrz_data:
|
|
||||||
country = qrz_data["country"]
|
|
||||||
# Couldn't get anything from QRZ.com database, try HamQTH
|
|
||||||
if not country:
|
|
||||||
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
|
|
||||||
if hamqth_data and "country" in hamqth_data:
|
|
||||||
country = hamqth_data["country"]
|
|
||||||
# Couldn't get anything from HamQTH database, try Clublog data
|
|
||||||
if not country:
|
|
||||||
clublog_data = self._get_clublog_xml_data_for_callsign(call)
|
|
||||||
if clublog_data and "Name" in clublog_data:
|
|
||||||
country = clublog_data["Name"]
|
|
||||||
if not country:
|
|
||||||
clublog_data = self._get_clublog_api_data_for_callsign(call)
|
|
||||||
if clublog_data and "Name" in clublog_data:
|
|
||||||
country = clublog_data["Name"]
|
|
||||||
# Couldn't get anything from Clublog database, try DXCC data
|
|
||||||
if not country:
|
|
||||||
dxcc_data = self._get_dxcc_data_for_callsign(call)
|
|
||||||
if dxcc_data and "name" in dxcc_data:
|
|
||||||
country = dxcc_data["name"]
|
|
||||||
return country
|
|
||||||
|
|
||||||
def infer_dxcc_id_from_callsign(self, call, credentials=None):
|
|
||||||
"""Infer a DXCC ID from a callsign"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Start with the basic country-files.com-based decoder.
|
|
||||||
dxcc = self._call_info_basic.get_adif_id(call)
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
dxcc = None
|
|
||||||
# Couldn't get anything from basic call info database, try QRZ.com
|
|
||||||
if not dxcc:
|
|
||||||
qrz_data = self._get_qrz_data_for_callsign(call, credentials)
|
|
||||||
if qrz_data and "adif" in qrz_data:
|
|
||||||
dxcc = qrz_data["adif"]
|
|
||||||
# Couldn't get anything from QRZ.com database, try HamQTH
|
|
||||||
if not dxcc:
|
|
||||||
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
|
|
||||||
if hamqth_data and "adif" in hamqth_data:
|
|
||||||
dxcc = hamqth_data["adif"]
|
|
||||||
# Couldn't get anything from HamQTH database, try Clublog data
|
|
||||||
if not dxcc:
|
|
||||||
clublog_data = self._get_clublog_xml_data_for_callsign(call)
|
|
||||||
if clublog_data and "DXCC" in clublog_data:
|
|
||||||
dxcc = clublog_data["DXCC"]
|
|
||||||
if not dxcc:
|
|
||||||
clublog_data = self._get_clublog_api_data_for_callsign(call)
|
|
||||||
if clublog_data and "DXCC" in clublog_data:
|
|
||||||
dxcc = clublog_data["DXCC"]
|
|
||||||
# Couldn't get anything from Clublog database, try DXCC data
|
|
||||||
if not dxcc:
|
|
||||||
dxcc_data = self._get_dxcc_data_for_callsign(call)
|
|
||||||
if dxcc_data and "entityCode" in dxcc_data:
|
|
||||||
dxcc = dxcc_data["entityCode"]
|
|
||||||
return dxcc
|
|
||||||
|
|
||||||
def infer_continent_from_callsign(self, call, credentials=None):
|
|
||||||
"""Infer a continent shortcode from a callsign"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Start with the basic country-files.com-based decoder.
|
|
||||||
continent = self._call_info_basic.get_continent(call)
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
continent = None
|
|
||||||
# Couldn't get anything from basic call info database, try HamQTH
|
|
||||||
if not continent:
|
|
||||||
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
|
|
||||||
if hamqth_data and "continent" in hamqth_data:
|
|
||||||
continent = hamqth_data["continent"]
|
|
||||||
# Couldn't get anything from HamQTH database, try Clublog data
|
|
||||||
if not continent:
|
|
||||||
clublog_data = self._get_clublog_xml_data_for_callsign(call)
|
|
||||||
if clublog_data and "Continent" in clublog_data:
|
|
||||||
continent = clublog_data["Continent"]
|
|
||||||
if not continent:
|
|
||||||
clublog_data = self._get_clublog_api_data_for_callsign(call)
|
|
||||||
if clublog_data and "Continent" in clublog_data:
|
|
||||||
continent = clublog_data["Continent"]
|
|
||||||
# Couldn't get anything from Clublog database, try DXCC data
|
|
||||||
if not continent:
|
|
||||||
dxcc_data = self._get_dxcc_data_for_callsign(call)
|
|
||||||
# Some DXCCs are in two continents, if so don't use the continent data as we can't be sure
|
|
||||||
if dxcc_data and "continent" in dxcc_data and len(dxcc_data["continent"]) == 1:
|
|
||||||
continent = dxcc_data["continent"][0]
|
|
||||||
return continent
|
|
||||||
|
|
||||||
def infer_cq_zone_from_callsign(self, call, credentials=None):
|
|
||||||
"""Infer a CQ zone from a callsign"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Start with the basic country-files.com-based decoder.
|
|
||||||
cqz = self._call_info_basic.get_cqz(call)
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
cqz = None
|
|
||||||
# Couldn't get anything from basic call info database, try QRZ.com
|
|
||||||
if not cqz:
|
|
||||||
qrz_data = self._get_qrz_data_for_callsign(call, credentials)
|
|
||||||
if qrz_data and "cqz" in qrz_data:
|
|
||||||
cqz = qrz_data["cqz"]
|
|
||||||
# Couldn't get anything from QRZ.com database, try HamQTH
|
|
||||||
if not cqz:
|
|
||||||
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
|
|
||||||
if hamqth_data and "cq" in hamqth_data:
|
|
||||||
cqz = hamqth_data["cq"]
|
|
||||||
# Couldn't get anything from HamQTH database, try Clublog data
|
|
||||||
if not cqz:
|
|
||||||
clublog_data = self._get_clublog_xml_data_for_callsign(call)
|
|
||||||
if clublog_data and "CQZ" in clublog_data:
|
|
||||||
cqz = clublog_data["CQZ"]
|
|
||||||
if not cqz:
|
|
||||||
clublog_data = self._get_clublog_api_data_for_callsign(call)
|
|
||||||
if clublog_data and "CQZ" in clublog_data:
|
|
||||||
cqz = clublog_data["CQZ"]
|
|
||||||
# Couldn't get anything from Clublog database, try DXCC data
|
|
||||||
if not cqz:
|
|
||||||
dxcc_data = self._get_dxcc_data_for_callsign(call)
|
|
||||||
# Some DXCCs are in multiple zones, if so don't use the zone data as we can't be sure
|
|
||||||
if dxcc_data and "cq" in dxcc_data and len(dxcc_data["cq"]) == 1:
|
|
||||||
cqz = dxcc_data["cq"][0]
|
|
||||||
return cqz
|
|
||||||
|
|
||||||
def infer_itu_zone_from_callsign(self, call, credentials=None):
|
|
||||||
"""Infer a ITU zone from a callsign"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Start with the basic country-files.com-based decoder.
|
|
||||||
ituz = self._call_info_basic.get_ituz(call)
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
ituz = None
|
|
||||||
# Couldn't get anything from basic call info database, try QRZ.com
|
|
||||||
if not ituz:
|
|
||||||
qrz_data = self._get_qrz_data_for_callsign(call, credentials)
|
|
||||||
if qrz_data and "ituz" in qrz_data:
|
|
||||||
ituz = qrz_data["ituz"]
|
|
||||||
# Couldn't get anything from QRZ.com database, try HamQTH
|
|
||||||
if not ituz:
|
|
||||||
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
|
|
||||||
if hamqth_data and "itu" in hamqth_data:
|
|
||||||
ituz = hamqth_data["itu"]
|
|
||||||
# Couldn't get anything from HamQTH database, Clublog doesn't provide this, so try DXCC data
|
|
||||||
if not ituz:
|
|
||||||
dxcc_data = self._get_dxcc_data_for_callsign(call)
|
|
||||||
# Some DXCCs are in multiple zones, if so don't use the zone data as we can't be sure
|
|
||||||
if dxcc_data and "itu" in dxcc_data and len(dxcc_data["itu"]) == 1:
|
|
||||||
ituz = dxcc_data["itu"]
|
|
||||||
return ituz
|
|
||||||
|
|
||||||
def infer_name_from_callsign_online_lookup(self, call, credentials=None):
|
|
||||||
"""Infer an operator name from a callsign (requires QRZ.com/HamQTH)"""
|
|
||||||
|
|
||||||
data = self._get_qrz_data_for_callsign(call, credentials)
|
|
||||||
if data and "name_fmt" in data:
|
|
||||||
return data["name_fmt"]
|
|
||||||
if data and "fname" in data:
|
|
||||||
name = data["fname"]
|
|
||||||
if "nick" in data:
|
|
||||||
name = name + " \"" + data["nick"] + "\""
|
|
||||||
if "name" in data:
|
|
||||||
name = name + " " + data["name"]
|
|
||||||
return name
|
|
||||||
data = self._get_hamqth_data_for_callsign(call, credentials)
|
|
||||||
if data and "nick" in data:
|
|
||||||
return data["nick"]
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def infer_latlon_from_callsign_online_lookup(self, call, credentials=None):
|
|
||||||
"""Infer a latitude and longitude from a callsign (requires QRZ.com/HamQTH)
|
|
||||||
Coordinates that look default are rejected (apologies if your position really is 0,0, enjoy your voyage)"""
|
|
||||||
|
|
||||||
data = self._get_qrz_data_for_callsign(call, credentials)
|
|
||||||
if data and "latitude" in data and "longitude" in data and (
|
|
||||||
float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float(
|
|
||||||
data["latitude"]) < 89.9:
|
|
||||||
return [float(data["latitude"]), float(data["longitude"])]
|
|
||||||
data = self._get_hamqth_data_for_callsign(call, credentials)
|
|
||||||
if data and "latitude" in data and "longitude" in data and (
|
|
||||||
float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float(
|
|
||||||
data["latitude"]) < 89.9:
|
|
||||||
return [float(data["latitude"]), float(data["longitude"])]
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def infer_grid_from_callsign_online_lookup(self, call, credentials=None):
|
|
||||||
"""Infer a grid locator from a callsign (requires QRZ.com/HamQTH).
|
|
||||||
Grids that look default are rejected (apologies if your grid really is AA00aa, enjoy your research)"""
|
|
||||||
|
|
||||||
data = self._get_qrz_data_for_callsign(call, credentials)
|
|
||||||
if data and "locator" in data and data["locator"].upper() != "AA00" and data["locator"].upper() != "AA00AA" and \
|
|
||||||
data["locator"].upper() != "AA00AA00":
|
|
||||||
return data["locator"]
|
|
||||||
data = self._get_hamqth_data_for_callsign(call, credentials)
|
|
||||||
if data and "grid" in data and data["grid"].upper() != "AA00" and data["grid"].upper() != "AA00AA" and data[
|
|
||||||
"grid"].upper() != "AA00AA00":
|
|
||||||
return data["grid"]
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def infer_qth_from_callsign_online_lookup(self, call, credentials=None):
|
|
||||||
"""Infer a textual QTH from a callsign (requires QRZ.com/HamQTH)"""
|
|
||||||
|
|
||||||
data = self._get_qrz_data_for_callsign(call, credentials)
|
|
||||||
if data and "addr2" in data:
|
|
||||||
return data["addr2"]
|
|
||||||
data = self._get_hamqth_data_for_callsign(call, credentials)
|
|
||||||
if data and "qth" in data:
|
|
||||||
return data["qth"]
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def infer_latlon_from_callsign_dxcc(self, call):
|
|
||||||
"""Infer a latitude and longitude from a callsign (using DXCC, probably very inaccurate)"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = self._call_info_basic.get_lat_long(call)
|
|
||||||
if data and "latitude" in data and "longitude" in data:
|
|
||||||
loc = [float(data["latitude"]), float(data["longitude"])]
|
|
||||||
else:
|
|
||||||
loc = None
|
|
||||||
except KeyError:
|
|
||||||
loc = None
|
|
||||||
# Couldn't get anything from basic call info database, try Clublog data
|
|
||||||
if not loc:
|
|
||||||
data = self._get_clublog_xml_data_for_callsign(call)
|
|
||||||
if data and "Lat" in data and "Lon" in data:
|
|
||||||
loc = [float(data["Lat"]), float(data["Lon"])]
|
|
||||||
if not loc:
|
|
||||||
data = self._get_clublog_api_data_for_callsign(call)
|
|
||||||
if data and "Lat" in data and "Lon" in data:
|
|
||||||
loc = [float(data["Lat"]), float(data["Lon"])]
|
|
||||||
return loc
|
|
||||||
|
|
||||||
def infer_grid_from_callsign_dxcc(self, call):
|
|
||||||
"""Infer a grid locator from a callsign (using DXCC, probably very inaccurate)"""
|
|
||||||
|
|
||||||
latlon = self.infer_latlon_from_callsign_dxcc(call) or []
|
|
||||||
grid = None
|
|
||||||
if latlon:
|
|
||||||
try:
|
|
||||||
grid = latlong_to_locator(latlon[0], latlon[1], 8)
|
|
||||||
except:
|
|
||||||
logging.debug("Invalid lat/lon received for DXCC")
|
|
||||||
return grid
|
|
||||||
|
|
||||||
def _get_qrz_data_for_callsign(self, call, credentials) -> dict | None:
|
|
||||||
"""Utility method to get QRZ.com data from cache if possible, if not get it from the API and cache it.
|
|
||||||
Returns None immediately if no credentials are provided."""
|
|
||||||
|
|
||||||
# Return from cache if available (a cached None means 'not found in QRZ')
|
|
||||||
if call in self._qrz_callsign_data_cache:
|
|
||||||
return self._qrz_callsign_data_cache.get(call)
|
|
||||||
|
|
||||||
# Obtain session key from credentials
|
|
||||||
session_key = None
|
|
||||||
if credentials and credentials.qrz_session_key:
|
|
||||||
session_key = credentials.qrz_session_key
|
|
||||||
elif credentials and credentials.qrz_username and credentials.qrz_password:
|
|
||||||
try:
|
|
||||||
login_response = self._qrz_session_cache.get(
|
|
||||||
self._qrz_base_url + "?username=" + urllib.parse.quote_plus(credentials.qrz_username) +
|
|
||||||
"&password=" + urllib.parse.quote_plus(credentials.qrz_password) + "&agent=spothole",
|
|
||||||
headers=HTTP_HEADERS).content
|
|
||||||
login_data = xmltodict.parse(login_response)
|
|
||||||
session = login_data.get("QRZDatabase", {}).get("Session", {})
|
|
||||||
if "Key" in session:
|
|
||||||
session_key = str(session["Key"])
|
|
||||||
else:
|
|
||||||
logging.warning("QRZ.com login details incorrect, failed to look up with QRZ.")
|
|
||||||
return None
|
|
||||||
except Exception:
|
|
||||||
logging.error("Exception when getting QRZ.com session key")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not session_key:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
|
|
||||||
calls_to_try = [call]
|
|
||||||
try:
|
|
||||||
home_call = callinfo.Callinfo.get_homecall(call)
|
|
||||||
if home_call != 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:
|
|
||||||
try:
|
|
||||||
response = _URL_DATA_CACHE.get(
|
|
||||||
self._qrz_base_url + "?s=" + session_key + "&callsign=" + urllib.parse.quote_plus(lookup_call),
|
|
||||||
headers=HTTP_HEADERS, timeout=10)
|
|
||||||
if response.ok:
|
|
||||||
qrz_response = xmltodict.parse(response.content).get("QRZDatabase", {})
|
|
||||||
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
|
|
||||||
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):
|
|
||||||
continue
|
|
||||||
except ConnectionError:
|
|
||||||
logging.warning(f"Connection error when looking up callsign %s using QRZ", lookup_call)
|
|
||||||
continue
|
|
||||||
except (ConnectTimeout, ReadTimeout):
|
|
||||||
logging.warning(f"Timeout when looking up callsign %s using QRZ.", lookup_call)
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
logging.error("Exception when looking up callsign %s using QRZ", lookup_call, exc_info=True)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 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
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_hamqth_data_for_callsign(self, call, credentials) -> dict | None:
|
|
||||||
"""Utility method to get HamQTH data from cache if possible, if not get it from the API and cache it.
|
|
||||||
Returns None immediately if no credentials are provided."""
|
|
||||||
|
|
||||||
# Return from cache if available
|
|
||||||
if call in self._hamqth_callsign_data_cache:
|
|
||||||
return self._hamqth_callsign_data_cache.get(call)
|
|
||||||
|
|
||||||
# Obtain session ID from credentials
|
|
||||||
session_id = None
|
|
||||||
if credentials and credentials.hamqth_session_id:
|
|
||||||
session_id = credentials.hamqth_session_id
|
|
||||||
elif credentials and credentials.hamqth_username and credentials.hamqth_password:
|
|
||||||
try:
|
|
||||||
session_data = self._hamqth_session_lookup_cache.get(
|
|
||||||
self._hamqth_base_url + "?u=" + urllib.parse.quote_plus(credentials.hamqth_username) +
|
|
||||||
"&p=" + urllib.parse.quote_plus(credentials.hamqth_password), headers=HTTP_HEADERS).content
|
|
||||||
dict_data = xmltodict.parse(session_data)
|
|
||||||
if "session_id" in dict_data["HamQTH"]["session"]:
|
|
||||||
session_id = str(dict_data["HamQTH"]["session"]["session_id"])
|
|
||||||
else:
|
|
||||||
logging.warning("HamQTH login details incorrect, failed to look up with HamQTH.")
|
|
||||||
return None
|
|
||||||
except Exception:
|
|
||||||
logging.error("Exception when getting HamQTH session ID")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not session_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
|
|
||||||
calls_to_try = [call]
|
|
||||||
try:
|
|
||||||
home_call = callinfo.Callinfo.get_homecall(call)
|
|
||||||
if home_call != 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:
|
|
||||||
try:
|
|
||||||
response = _URL_DATA_CACHE.get(
|
|
||||||
self._hamqth_base_url + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
|
|
||||||
lookup_call) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS)
|
|
||||||
if response.ok:
|
|
||||||
data = xmltodict.parse(response.content)["HamQTH"]["search"]
|
|
||||||
self._hamqth_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
|
|
||||||
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):
|
|
||||||
continue
|
|
||||||
except ConnectionError:
|
|
||||||
logging.warning(f"Connection error when looking up callsign %s using HamQTH", lookup_call)
|
|
||||||
continue
|
|
||||||
except (ConnectTimeout, ReadTimeout):
|
|
||||||
logging.warning(f"Timeout when looking up callsign %s using HamQTH", lookup_call)
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
logging.error("Exception when looking up callsign %s using HamQTH", lookup_call, exc_info=True)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 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
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_clublog_api_data_for_callsign(self, call) -> dict | None:
|
|
||||||
"""Utility method to get Clublog API data from cache if possible, if not get it from the API and cache it"""
|
|
||||||
|
|
||||||
# Fetch from cache if we can, otherwise fetch from the API and cache it
|
|
||||||
if call in self._clublog_callsign_data_cache:
|
|
||||||
return self._clublog_callsign_data_cache.get(call)
|
|
||||||
elif self._clublog_api_available:
|
|
||||||
try:
|
|
||||||
data = self._lookup_lib_clublog_api.lookup_callsign(callsign=call)
|
|
||||||
self._clublog_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
|
|
||||||
return data
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
# Clublog had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call.
|
|
||||||
try:
|
|
||||||
data = self._lookup_lib_clublog_api.lookup_callsign(callsign=callinfo.Callinfo.get_homecall(call))
|
|
||||||
self._clublog_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
|
|
||||||
return data
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
# Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again
|
|
||||||
self._clublog_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
|
|
||||||
return None
|
|
||||||
except APIKeyMissingError:
|
|
||||||
# User API key was wrong, warn
|
|
||||||
logging.error("Could not look up via Clublog API, key " + self._clublog_api_key + " was rejected.")
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_clublog_xml_data_for_callsign(self, call) -> dict | None:
|
|
||||||
"""Utility method to get Clublog XML data from file"""
|
|
||||||
|
|
||||||
if self._clublog_xml_available:
|
|
||||||
try:
|
|
||||||
data = self._lookup_lib_clublog_xml.lookup_callsign(callsign=call)
|
|
||||||
return data
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
# Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again
|
|
||||||
self._clublog_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_dxcc_data_for_callsign(self, call) -> dict | None:
|
|
||||||
"""Utility method to get generic DXCC data from our lookup table, if we can find it"""
|
|
||||||
|
|
||||||
for pattern, entity_code in DATA_STORE.dxcc_lookup_by_call_regex:
|
|
||||||
if pattern.match(call):
|
|
||||||
return DATA_STORE.dxcc_data[entity_code]
|
|
||||||
return None
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
"""Shutdown method to close down any caches neatly."""
|
|
||||||
|
|
||||||
self._qrz_callsign_data_cache.close()
|
|
||||||
self._hamqth_callsign_data_cache.close()
|
|
||||||
self._clublog_callsign_data_cache.close()
|
|
||||||
|
|
||||||
|
|
||||||
# Singleton object
|
|
||||||
lookup_helper = LookupHelper()
|
|
||||||
+21
-43
@@ -15,14 +15,17 @@ with open("config.yml") as f:
|
|||||||
config = yaml.safe_load(f)
|
config = yaml.safe_load(f)
|
||||||
logging.info("Loaded config.")
|
logging.info("Loaded config.")
|
||||||
|
|
||||||
BASE_URL = config["base-url"]
|
BASE_URL = config.get("base-url", "http://localhost:8080")
|
||||||
MAX_SPOT_AGE = config["max-spot-age-sec"]
|
MAX_SPOT_AGE = config.get("max-spot-age-sec", 3600)
|
||||||
MAX_ALERT_AGE = config["max-alert-age-sec"]
|
MAX_ALERT_AGE = config.get("max-alert-age-sec", 604800)
|
||||||
SERVER_OWNER_CALLSIGN = config["server-owner-callsign"]
|
SERVER_OWNER_CALLSIGN = config.get("server-owner-callsign", "N0CALL")
|
||||||
WEB_SERVER_PORT = config["web-server-port"]
|
WEB_SERVER_PORT = config.get("web-server-port", 8080)
|
||||||
ALLOW_SPOTTING = config["allow-spotting"]
|
ALLOW_SPOTTING = config.get("allow-spotting", True)
|
||||||
WEB_UI_OPTIONS = config["web-ui-options"]
|
ALLOW_UPSTREAM_SPOTTING = config.get("allow-upstream-spotting", True)
|
||||||
|
WEB_UI_OPTIONS = config.get("web-ui-options", {})
|
||||||
API_ONLY_MODE = config.get("api-only-mode", False)
|
API_ONLY_MODE = config.get("api-only-mode", False)
|
||||||
|
RECAPTCHA_SECRET_KEY = config.get("recaptcha-secret-key", "")
|
||||||
|
RECAPTCHA_SITE_KEY = config.get("recaptcha-site-key", "")
|
||||||
LOG_LEVEL = config.get("log-level", "INFO")
|
LOG_LEVEL = config.get("log-level", "INFO")
|
||||||
LOG_WEB_REQUESTS = config.get("log-web-requests", False)
|
LOG_WEB_REQUESTS = config.get("log-web-requests", False)
|
||||||
|
|
||||||
@@ -30,47 +33,22 @@ LOG_WEB_REQUESTS = config.get("log-web-requests", False)
|
|||||||
# 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.
|
||||||
WEB_UI_OPTIONS["spot-providers-enabled-by-default"] = [p["name"] for p in config["spot-providers"] if p["enabled"] and (
|
WEB_UI_OPTIONS["spot-providers-enabled-by-default"] = [p["name"] for p in config["spot-providers"] if p["enabled"] and (
|
||||||
"enabled-by-default-in-web-ui" not in p or p["enabled-by-default-in-web-ui"])]
|
"enabled-by-default-in-web-ui" not in p or p["enabled-by-default-in-web-ui"])]
|
||||||
|
WEB_UI_OPTIONS["qrz-enabled"] = any(p["class"] == "QRZ" and p["enabled"] for p in config["callsign-data-providers"])
|
||||||
|
WEB_UI_OPTIONS["hamqth-enabled"] = any(p["class"] == "HamQTH" and p["enabled"] for p in config["callsign-data-providers"])
|
||||||
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
|
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
|
||||||
# one of our proviers. We set that to also be enabled by default.
|
# one of our proviers. We set that to also be enabled by default. We can also include the reCaptcha site key so the UI
|
||||||
|
# can access it.
|
||||||
if ALLOW_SPOTTING:
|
if ALLOW_SPOTTING:
|
||||||
WEB_UI_OPTIONS["spot-providers-enabled-by-default"].append("API")
|
WEB_UI_OPTIONS["spot-providers-enabled-by-default"].append("API")
|
||||||
|
WEB_UI_OPTIONS["recaptcha-site-key"] = RECAPTCHA_SITE_KEY
|
||||||
|
WEB_UI_OPTIONS["allow-upstream-spotting"] = ALLOW_SPOTTING and ALLOW_UPSTREAM_SPOTTING
|
||||||
|
|
||||||
|
|
||||||
def get_spot_provider_from_config(config_providers_entry):
|
def create_provider_from_config(package, config_providers_entry):
|
||||||
"""Utility method to get a spot provider based on the class specified in its config entry."""
|
"""Utility method to get a provider based on the class specified in its config entry. You must also provide the
|
||||||
|
package to look for it in, as there are several types of provider. e.g. package "providers.spot", where the config
|
||||||
|
entry is for a POTA spot provider."""
|
||||||
|
|
||||||
module = importlib.import_module('providers.spot.' + config_providers_entry["class"].lower())
|
module = importlib.import_module(package + "." + config_providers_entry["class"].lower())
|
||||||
provider_class = getattr(module, config_providers_entry["class"])
|
|
||||||
return provider_class(config_providers_entry)
|
|
||||||
|
|
||||||
|
|
||||||
def get_alert_provider_from_config(config_providers_entry):
|
|
||||||
"""Utility method to get an alert provider based on the class specified in its config entry."""
|
|
||||||
|
|
||||||
module = importlib.import_module('providers.alert.' + config_providers_entry["class"].lower())
|
|
||||||
provider_class = getattr(module, config_providers_entry["class"])
|
|
||||||
return provider_class(config_providers_entry)
|
|
||||||
|
|
||||||
|
|
||||||
def get_solar_conditions_provider_from_config(config_providers_entry):
|
|
||||||
"""Utility method to get a solar conditions provider based on the class specified in its config entry."""
|
|
||||||
|
|
||||||
module = importlib.import_module('providers.solarconditions.' + config_providers_entry["class"].lower())
|
|
||||||
provider_class = getattr(module, config_providers_entry["class"])
|
|
||||||
return provider_class(config_providers_entry)
|
|
||||||
|
|
||||||
|
|
||||||
def get_static_data_provider_from_config(config_providers_entry):
|
|
||||||
"""Utility method to get a static reference data provider based on the class specified in its config entry."""
|
|
||||||
|
|
||||||
module = importlib.import_module('providers.staticdata.' + config_providers_entry["class"].lower())
|
|
||||||
provider_class = getattr(module, config_providers_entry["class"])
|
|
||||||
return provider_class(config_providers_entry)
|
|
||||||
|
|
||||||
|
|
||||||
def get_sig_ref_data_provider_from_config(config_providers_entry):
|
|
||||||
"""Utility method to get a SIG reference data provider based on the class specified in its config entry."""
|
|
||||||
|
|
||||||
module = importlib.import_module('providers.sigrefdata.' + config_providers_entry["class"].lower())
|
|
||||||
provider_class = getattr(module, config_providers_entry["class"])
|
provider_class = getattr(module, config_providers_entry["class"])
|
||||||
return provider_class(config_providers_entry)
|
return provider_class(config_providers_entry)
|
||||||
|
|||||||
+5
-3
@@ -3,7 +3,7 @@ from data.band import Band
|
|||||||
from data.sig import SIG
|
from data.sig import SIG
|
||||||
|
|
||||||
# General software
|
# General software
|
||||||
SOFTWARE_VERSION = "1.4-pre"
|
SOFTWARE_VERSION = "2.0-pre"
|
||||||
|
|
||||||
# HTTP headers used for spot providers that use HTTP
|
# HTTP headers used for spot providers that use HTTP
|
||||||
HTTP_HEADERS = {"User-Agent": "Spothole v" + SOFTWARE_VERSION + " (operated by " + SERVER_OWNER_CALLSIGN + ")"}
|
HTTP_HEADERS = {"User-Agent": "Spothole v" + SOFTWARE_VERSION + " (operated by " + SERVER_OWNER_CALLSIGN + ")"}
|
||||||
@@ -11,7 +11,7 @@ HAMQTH_PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_C
|
|||||||
|
|
||||||
# Special Interest Groups
|
# Special Interest Groups
|
||||||
SIGS = [
|
SIGS = [
|
||||||
SIG(name="POTA", comment_names=["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}|K\-TEST"),
|
||||||
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="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", comment_names=["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", comment_names=["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}"),
|
||||||
@@ -39,10 +39,12 @@ SIGS = [
|
|||||||
|
|
||||||
# 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".
|
||||||
CW_MODES = ["CW"]
|
CW_MODES = ["CW"]
|
||||||
PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"]
|
PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "FUSION", "M17"]
|
||||||
DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"]
|
DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"]
|
||||||
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
|
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
|
||||||
MODE_TYPES = ["CW", "PHONE", "DATA"]
|
MODE_TYPES = ["CW", "PHONE", "DATA"]
|
||||||
|
SSB_SUB_MODES = ["USB", "LSB"]
|
||||||
|
DV_SUB_MODES = ["DMR", "DSTAR", "C4FM", "FUSION", "M17"]
|
||||||
|
|
||||||
# Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode
|
# Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode
|
||||||
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots
|
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from core.config import config, create_provider_from_config
|
||||||
|
|
||||||
|
|
||||||
|
class DataProviders:
|
||||||
|
"""Global object for storing data providers."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.spot_providers = []
|
||||||
|
self.alert_providers = []
|
||||||
|
self.solar_condition_providers = []
|
||||||
|
self.static_data_providers = []
|
||||||
|
self.sig_ref_data_providers = []
|
||||||
|
self.callsign_data_providers = []
|
||||||
|
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
for entry in config["spot-providers"]:
|
||||||
|
self.spot_providers.append(create_provider_from_config("providers.spot", entry))
|
||||||
|
for entry in config["alert-providers"]:
|
||||||
|
self.alert_providers.append(create_provider_from_config("providers.alert", entry))
|
||||||
|
for entry in config.get("solar-condition-providers", []):
|
||||||
|
self.solar_condition_providers.append(create_provider_from_config("providers.solarconditions", entry))
|
||||||
|
for entry in config.get("static-data-providers", []):
|
||||||
|
self.static_data_providers.append(create_provider_from_config("providers.staticdata", entry))
|
||||||
|
for entry in config.get("sig-ref-data-providers", []):
|
||||||
|
self.sig_ref_data_providers.append(create_provider_from_config("providers.sigrefdata", entry))
|
||||||
|
for entry in config.get("callsign-data-providers", []):
|
||||||
|
self.callsign_data_providers.append(create_provider_from_config("providers.callsigndata", entry))
|
||||||
|
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
# Start data providers before spot/alert providers so the lookup data is there already for incoming spots
|
||||||
|
for p in self.static_data_providers:
|
||||||
|
if p.enabled:
|
||||||
|
p.start()
|
||||||
|
for p in self.sig_ref_data_providers:
|
||||||
|
if p.enabled:
|
||||||
|
p.start()
|
||||||
|
for p in self.callsign_data_providers:
|
||||||
|
if p.enabled:
|
||||||
|
p.start()
|
||||||
|
for p in self.spot_providers:
|
||||||
|
if p.enabled:
|
||||||
|
p.start()
|
||||||
|
for p in self.alert_providers:
|
||||||
|
if p.enabled:
|
||||||
|
p.start()
|
||||||
|
for p in self.solar_condition_providers:
|
||||||
|
if p.enabled:
|
||||||
|
p.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
for sp in self.spot_providers:
|
||||||
|
if sp.enabled:
|
||||||
|
sp.stop()
|
||||||
|
for ap in self.alert_providers:
|
||||||
|
if ap.enabled:
|
||||||
|
ap.stop()
|
||||||
|
for scp in self.solar_condition_providers:
|
||||||
|
if scp.enabled:
|
||||||
|
scp.stop()
|
||||||
|
for srdp in self.sig_ref_data_providers:
|
||||||
|
if srdp.enabled:
|
||||||
|
srdp.stop()
|
||||||
|
for sdp in self.static_data_providers:
|
||||||
|
if sdp.enabled:
|
||||||
|
sdp.stop()
|
||||||
|
for cdp in self.callsign_data_providers:
|
||||||
|
if cdp.enabled:
|
||||||
|
cdp.stop()
|
||||||
|
|
||||||
|
# Global object
|
||||||
|
DATA_PROVIDERS = DataProviders()
|
||||||
+23
-7
@@ -20,17 +20,21 @@ class DataStore:
|
|||||||
self._MAX_SPOT_COUNT = 100000
|
self._MAX_SPOT_COUNT = 100000
|
||||||
self._MAX_ALERT_COUNT = 100000
|
self._MAX_ALERT_COUNT = 100000
|
||||||
self._SPOT_ALERT_SNAPSHOT_INTERVAL_SEC = 300
|
self._SPOT_ALERT_SNAPSHOT_INTERVAL_SEC = 300
|
||||||
self._CALLSIGN_DATA_TTL_SEC = 30 * 24 * 60 * 60
|
self.CALLSIGN_DATA_TTL_SEC = 30 * 24 * 60 * 60
|
||||||
# Caches
|
# Caches
|
||||||
self.alerts = None
|
self.alerts = None
|
||||||
self.spots = None
|
self.spots = None
|
||||||
self.callsigns = None
|
self.callsign_data_countryfiles = None
|
||||||
|
self.callsign_data_clublogxml = None
|
||||||
|
self.callsign_data_clublogapi = None
|
||||||
|
self.callsign_data_qrz = None
|
||||||
|
self.callsign_data_hamqth = None
|
||||||
self.dxcc_data = None
|
self.dxcc_data = None
|
||||||
self.dxcc_lookup_by_call_regex = []
|
self.dxcc_lookup_by_call_regex = []
|
||||||
self.sigrefs = None
|
self.sigrefs = None
|
||||||
self.status_data = None
|
self.status_data = {}
|
||||||
self._status = None
|
self._status = None
|
||||||
self.solar_conditions = None
|
self.solar_conditions = {}
|
||||||
self._solar = None
|
self._solar = None
|
||||||
# ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be
|
# ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be
|
||||||
# caches, they can just be straight objects
|
# caches, they can just be straight objects
|
||||||
@@ -65,8 +69,16 @@ class DataStore:
|
|||||||
# Standard disk cache for callsign data. This data does have a TTL to trigger an occasional re-lookup.
|
# Standard disk cache for callsign data. This data does have a TTL to trigger an occasional re-lookup.
|
||||||
# Old data *is* better than no data, but we can't have a background thread re-looking-up every callsign
|
# Old data *is* better than no data, but we can't have a background thread re-looking-up every callsign
|
||||||
# we've seen, so we rely on them timing out and this triggering another lookup.
|
# we've seen, so we rely on them timing out and this triggering another lookup.
|
||||||
self.callsigns = diskcache.Cache(CACHE_DIR + "callsigns")
|
self.callsign_data_countryfiles = diskcache.Cache(CACHE_DIR + "callsign_data_countryfiles")
|
||||||
logging.info(f"Loaded data for %d callsigns.", len(self.callsigns))
|
self.callsign_data_clublogxml = diskcache.Cache(CACHE_DIR + "callsign_data_clublogxml")
|
||||||
|
self.callsign_data_clublogapi = diskcache.Cache(CACHE_DIR + "callsign_data_clublogapi")
|
||||||
|
self.callsign_data_qrz = diskcache.Cache(CACHE_DIR + "callsign_data_qrz")
|
||||||
|
self.callsign_data_hamqth = diskcache.Cache(CACHE_DIR + "callsign_data_hamqth")
|
||||||
|
unique_keys = set()
|
||||||
|
for c in [self.callsign_data_countryfiles, self.callsign_data_clublogxml, self.callsign_data_clublogapi,
|
||||||
|
self.callsign_data_qrz, self.callsign_data_hamqth]:
|
||||||
|
unique_keys.update(c)
|
||||||
|
logging.info(f"Loaded data for %d callsigns.", len(unique_keys))
|
||||||
|
|
||||||
# Special caches for spots and alerts, which have TTL and write snapshots to disk at an interval. We
|
# Special caches for spots and alerts, which have TTL and write snapshots to disk at an interval. We
|
||||||
# specifically load these caches *last* so that any sigref and callsign data is already loaded from disk cache
|
# specifically load these caches *last* so that any sigref and callsign data is already loaded from disk cache
|
||||||
@@ -97,7 +109,11 @@ class DataStore:
|
|||||||
self._status.close()
|
self._status.close()
|
||||||
self.dxcc_data.close()
|
self.dxcc_data.close()
|
||||||
self.sigrefs.close()
|
self.sigrefs.close()
|
||||||
self.callsigns.close()
|
self.callsign_data_countryfiles.close()
|
||||||
|
self.callsign_data_clublogxml.close()
|
||||||
|
self.callsign_data_clublogapi.close()
|
||||||
|
self.callsign_data_qrz.close()
|
||||||
|
self.callsign_data_hamqth.close()
|
||||||
|
|
||||||
# Global object
|
# Global object
|
||||||
DATA_STORE = DataStore()
|
DATA_STORE = DataStore()
|
||||||
@@ -92,7 +92,7 @@ class LiveDataCache:
|
|||||||
time.sleep(interval)
|
time.sleep(interval)
|
||||||
self.save_snapshot()
|
self.save_snapshot()
|
||||||
|
|
||||||
t = threading.Thread(target=loop, daemon=True, name=f"snapshot-{self._snapshot_dir}")
|
t = threading.Thread(target=loop, name=f"LiveDataCache-Snapshot-{self._snapshot_dir}")
|
||||||
t.start()
|
t.start()
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
|
|||||||
+30
-15
@@ -4,21 +4,20 @@ from pyhamtools.locator import locator_to_latlong, latlong_to_locator
|
|||||||
|
|
||||||
from core.data_store import DATA_STORE
|
from core.data_store import DATA_STORE
|
||||||
from core.geo_utils import wab_wai_square_to_lat_lon
|
from core.geo_utils import wab_wai_square_to_lat_lon
|
||||||
|
from data.sig_ref import SIGRef
|
||||||
|
|
||||||
|
|
||||||
def populate_sig_ref_info(sig_ref):
|
def get_sig_ref_info(sig, ref_id):
|
||||||
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object
|
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig name and
|
||||||
which must at minimum have a "sig" and an "id". The rest of the object will be populated and returned. This makes
|
a reference ID (both strings) and returns a SigRef object populated with as much data as we can find. This makes
|
||||||
use of SIG ref data in the data store, live lookups from the web, or just automatic calculation depending on which
|
use of SIG ref data in the data store, live lookups from the web, or just automatic calculation depending on which
|
||||||
SIG we are getting data for. Any data currently in the object will be kept, only missing data in the object will
|
SIG we are getting data for."""
|
||||||
be populated if it can be determined."""
|
|
||||||
|
|
||||||
if sig_ref.sig is None or sig_ref.sig == "" or sig_ref.id is None or sig_ref.id == "":
|
if sig is None or sig == "" or ref_id is None or ref_id == "":
|
||||||
logging.debug("Failed to look up sig_ref info, sig or id were not set.")
|
logging.debug("Failed to look up sig_ref info, sig or ref were not set.")
|
||||||
return sig_ref
|
return None
|
||||||
|
|
||||||
sig = sig_ref.sig
|
sig_ref = SIGRef(sig=sig, id=ref_id)
|
||||||
ref_id = sig_ref.id
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
### FUDGES ###
|
### FUDGES ###
|
||||||
@@ -81,13 +80,29 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
key = sig + ":" + ref_id
|
key = sig + ":" + ref_id
|
||||||
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
|
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
|
||||||
if lookup_data:
|
if lookup_data:
|
||||||
# Copy new sig ref data into existing object where data was previously missing
|
return lookup_data
|
||||||
for key, value in lookup_data.__dict__.items():
|
|
||||||
if value is not None and sig_ref.__dict__.get(key) is None:
|
|
||||||
sig_ref.__dict__[key] = value
|
|
||||||
else:
|
else:
|
||||||
logging.warning("%s database did not contain data for ref %s", sig, ref_id)
|
# Maybe a super new reference we don't know about yet, but more likely a typo or a test reference,
|
||||||
|
# just silently ignore it.
|
||||||
|
logging.debug("%s database did not contain data for ref %s", sig, ref_id)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.error("Exception when looking 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
|
||||||
|
|
||||||
|
|
||||||
|
def populate_missing_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 must at minimum have a "sig" and an "id". The rest of the object will be populated and returned. Any data
|
||||||
|
currently in the object will be kept, only missing data in the object will be populated if it can be determined."""
|
||||||
|
|
||||||
|
lookup_data = get_sig_ref_info(sig_ref.sig, sig_ref.id)
|
||||||
|
|
||||||
|
if lookup_data:
|
||||||
|
# Copy new sig ref data into existing object where data was previously missing
|
||||||
|
for key, value in lookup_data.__dict__.items():
|
||||||
|
if value is not None and sig_ref.__dict__.get(key) is None:
|
||||||
|
sig_ref.__dict__[key] = value
|
||||||
|
|
||||||
|
return sig_ref
|
||||||
|
|||||||
+38
-37
@@ -7,24 +7,19 @@ import pytz
|
|||||||
|
|
||||||
from core.config import SERVER_OWNER_CALLSIGN
|
from core.config import SERVER_OWNER_CALLSIGN
|
||||||
from core.constants import SOFTWARE_VERSION
|
from core.constants import SOFTWARE_VERSION
|
||||||
|
from core.data_providers import DATA_PROVIDERS
|
||||||
from core.data_store import DATA_STORE
|
from core.data_store import DATA_STORE
|
||||||
from core.prometheus_metrics_handler import memory_use_gauge, spots_gauge, alerts_gauge
|
from core.prometheus_metrics_handler import memory_use_gauge, spots_gauge, alerts_gauge
|
||||||
|
from server.webserver import WEB_SERVER
|
||||||
|
|
||||||
|
|
||||||
class StatusReporter:
|
class StatusReporter:
|
||||||
"""Provides a timed update of the application's status data."""
|
"""Provides a timed update of the application's status data."""
|
||||||
|
|
||||||
def __init__(self, run_interval, web_server, spot_providers, alert_providers, solar_condition_providers,
|
def __init__(self, run_interval):
|
||||||
static_data_providers, sig_ref_data_providers):
|
|
||||||
"""Constructor"""
|
"""Constructor"""
|
||||||
|
|
||||||
self._run_interval = run_interval
|
self._run_interval = run_interval
|
||||||
self._web_server = web_server
|
|
||||||
self._spot_providers = spot_providers
|
|
||||||
self._alert_providers = alert_providers
|
|
||||||
self._solar_condition_providers = solar_condition_providers
|
|
||||||
self._static_data_providers = static_data_providers
|
|
||||||
self._sig_ref_data_providers = sig_ref_data_providers
|
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._stop_event = Event()
|
self._stop_event = Event()
|
||||||
self._startup_time = datetime.now(pytz.UTC)
|
self._startup_time = datetime.now(pytz.UTC)
|
||||||
@@ -35,7 +30,7 @@ class StatusReporter:
|
|||||||
def start(self):
|
def start(self):
|
||||||
"""Start the reporter thread"""
|
"""Start the reporter thread"""
|
||||||
|
|
||||||
self._thread = Thread(target=self._run, daemon=True)
|
self._thread = Thread(target=self._run, name="StatusReporter")
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
@@ -59,45 +54,51 @@ class StatusReporter:
|
|||||||
DATA_STORE.status_data["num_spots"] = len(DATA_STORE.spots.values())
|
DATA_STORE.status_data["num_spots"] = len(DATA_STORE.spots.values())
|
||||||
DATA_STORE.status_data["num_alerts"] = len(DATA_STORE.alerts.values())
|
DATA_STORE.status_data["num_alerts"] = len(DATA_STORE.alerts.values())
|
||||||
DATA_STORE.status_data["spot_providers"] = list(
|
DATA_STORE.status_data["spot_providers"] = list(
|
||||||
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||||
"last_updated": p.last_update_time.replace(
|
"last_updated": p.last_update_time.replace(
|
||||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
|
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
|
||||||
"last_spot": p.last_spot_time.replace(
|
"last_spot": p.last_spot_time.replace(
|
||||||
tzinfo=pytz.UTC).timestamp() if p.last_spot_time.year > 2000 else 0},
|
tzinfo=pytz.UTC).timestamp() if p.last_spot_time.year > 2000 else 0},
|
||||||
self._spot_providers))
|
DATA_PROVIDERS.spot_providers))
|
||||||
DATA_STORE.status_data["alert_providers"] = list(
|
DATA_STORE.status_data["alert_providers"] = list(
|
||||||
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||||
"last_updated": p.last_update_time.replace(
|
"last_updated": p.last_update_time.replace(
|
||||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
||||||
self._alert_providers))
|
DATA_PROVIDERS.alert_providers))
|
||||||
DATA_STORE.status_data["solar_condition_providers"] = list(
|
DATA_STORE.status_data["solar_condition_providers"] = list(
|
||||||
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||||
"last_updated": p.last_update_time.replace(
|
"last_updated": p.last_update_time.replace(
|
||||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
||||||
self._solar_condition_providers))
|
DATA_PROVIDERS.solar_condition_providers))
|
||||||
DATA_STORE.status_data["static_data_providers"] = list(
|
DATA_STORE.status_data["static_data_providers"] = list(
|
||||||
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||||
"last_updated": p.last_update_time.replace(
|
"last_updated": p.last_update_time.replace(
|
||||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
||||||
self._static_data_providers))
|
DATA_PROVIDERS.static_data_providers))
|
||||||
DATA_STORE.status_data["sig_ref_data_providers"] = list(
|
DATA_STORE.status_data["sig_ref_data_providers"] = list(
|
||||||
map(lambda p: {"sig_name": p.sig_name, "enabled": p.enabled, "status": p.status,
|
map(lambda p: {"sig_name": p.sig_name, "enabled": p.enabled, "status": p.status,
|
||||||
"last_updated": p.last_update_time.replace(
|
"last_updated": p.last_update_time.replace(
|
||||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
|
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
|
||||||
"reference_count": p.reference_count},
|
"reference_count": p.reference_count},
|
||||||
self._sig_ref_data_providers))
|
DATA_PROVIDERS.sig_ref_data_providers))
|
||||||
DATA_STORE.status_data["webserver"] = {"status": self._web_server.web_server_metrics["status"],
|
DATA_STORE.status_data["callsign_data_providers"] = list(
|
||||||
"last_api_access": self._web_server.web_server_metrics[
|
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||||
"last_api_access_time"].replace(
|
"last_updated": p.last_update_time.replace(
|
||||||
tzinfo=pytz.UTC).timestamp() if self._web_server.web_server_metrics[
|
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
|
||||||
"last_api_access_time"] else 0,
|
"lookup_count": p.lookup_count},
|
||||||
"api_access_count": self._web_server.web_server_metrics["api_access_counter"],
|
DATA_PROVIDERS.callsign_data_providers))
|
||||||
"last_page_access": self._web_server.web_server_metrics[
|
DATA_STORE.status_data["webserver"] = {"status": WEB_SERVER.web_server_metrics["status"],
|
||||||
"last_page_access_time"].replace(
|
"last_api_access": WEB_SERVER.web_server_metrics[
|
||||||
tzinfo=pytz.UTC).timestamp() if self._web_server.web_server_metrics[
|
"last_api_access_time"].replace(
|
||||||
"last_page_access_time"] else 0,
|
tzinfo=pytz.UTC).timestamp() if WEB_SERVER.web_server_metrics[
|
||||||
"page_access_count": self._web_server.web_server_metrics[
|
"last_api_access_time"] else 0,
|
||||||
"page_access_counter"]}
|
"api_access_count": WEB_SERVER.web_server_metrics["api_access_counter"],
|
||||||
|
"last_page_access": WEB_SERVER.web_server_metrics[
|
||||||
|
"last_page_access_time"].replace(
|
||||||
|
tzinfo=pytz.UTC).timestamp() if WEB_SERVER.web_server_metrics[
|
||||||
|
"last_page_access_time"] else 0,
|
||||||
|
"page_access_count": WEB_SERVER.web_server_metrics[
|
||||||
|
"page_access_counter"]}
|
||||||
|
|
||||||
# Update Prometheus metrics
|
# Update Prometheus metrics
|
||||||
memory_use_gauge.set(psutil.Process(os.getpid()).memory_info().rss)
|
memory_use_gauge.set(psutil.Process(os.getpid()).memory_info().rss)
|
||||||
|
|||||||
+39
-1
@@ -2,9 +2,11 @@ import logging
|
|||||||
|
|
||||||
import simplejson
|
import simplejson
|
||||||
from pyhamtools.frequency import freq_to_band
|
from pyhamtools.frequency import freq_to_band
|
||||||
|
from pyhamtools.locator import latlong_to_locator
|
||||||
|
|
||||||
from core.constants import UNKNOWN_BAND, BANDS, CW_MODES, PHONE_MODES, DATA_MODES, MODE_ALIASES, ALL_MODES
|
from core.constants import UNKNOWN_BAND, BANDS, CW_MODES, PHONE_MODES, DATA_MODES, MODE_ALIASES, ALL_MODES
|
||||||
from core.data_store import DATA_STORE
|
from core.data_store import DATA_STORE
|
||||||
|
from data.callsign import Callsign
|
||||||
|
|
||||||
|
|
||||||
def safe_json_dumps(obj):
|
def safe_json_dumps(obj):
|
||||||
@@ -76,4 +78,40 @@ def get_flag_for_dxcc(dxcc):
|
|||||||
"""Get an emoji flag for a given DXCC entity ID"""
|
"""Get an emoji flag for a given DXCC entity ID"""
|
||||||
|
|
||||||
dxcc_data = DATA_STORE.dxcc_data[dxcc] if dxcc in DATA_STORE.dxcc_data else None
|
dxcc_data = DATA_STORE.dxcc_data[dxcc] if dxcc in DATA_STORE.dxcc_data else None
|
||||||
return dxcc_data["flag"] if dxcc_data else None
|
return dxcc_data["flag"] if dxcc_data else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_callsign_object_from_pyhamtools_callinfo(callsign, callinfo):
|
||||||
|
"""Utility function to take the data provided by a PyHamTools CallInfo object and populate our own Callsign data
|
||||||
|
object from it"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
home_call = callinfo.get_homecall(callsign)
|
||||||
|
data = callinfo.get_all(callsign)
|
||||||
|
|
||||||
|
country = data["country"] if "country" in data else None
|
||||||
|
dxcc_id = data["adif"] if "adif" in data else None
|
||||||
|
continent = data["continent"] if "continent" in data else None
|
||||||
|
cq_zone = data["cqz"] if "cqz" in data else None
|
||||||
|
itu_zone = data["ituz"] if "ituz" in data else None
|
||||||
|
lat = float(data["latitude"]) if "latitude" in data else None
|
||||||
|
lon = float(data["longitude"]) if "longitude" in data else None
|
||||||
|
grid = None
|
||||||
|
if lat and lon:
|
||||||
|
grid = latlong_to_locator(lat, lon)
|
||||||
|
|
||||||
|
return Callsign(call=callsign,
|
||||||
|
home_call=home_call,
|
||||||
|
country=country,
|
||||||
|
dxcc_id=dxcc_id,
|
||||||
|
continent=continent,
|
||||||
|
cq_zone=cq_zone,
|
||||||
|
itu_zone=itu_zone,
|
||||||
|
latitude=lat,
|
||||||
|
longitude=lon,
|
||||||
|
grid=grid,
|
||||||
|
location_source="DXCC")
|
||||||
|
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
# Unknown callsign, can't look anything up, return a Callsign object with basic data so that gets cached
|
||||||
|
return Callsign(call=callsign)
|
||||||
|
|||||||
+11
-12
@@ -7,8 +7,8 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
from core.call_lookup_helper import lookup_helper
|
from core.call_lookup_helper import get_call_info
|
||||||
from core.sig_lookup_helper import populate_sig_ref_info
|
from core.sig_lookup_helper import populate_missing_sig_ref_info
|
||||||
from core.utils import get_flag_for_dxcc
|
from core.utils import get_flag_for_dxcc
|
||||||
|
|
||||||
|
|
||||||
@@ -86,16 +86,17 @@ class Alert:
|
|||||||
|
|
||||||
# DX country, continent, zones etc. from callsign. CQ/ITU zone are better looked up with a location but we don't
|
# DX country, continent, zones etc. from callsign. CQ/ITU zone are better looked up with a location but we don't
|
||||||
# have a real location for alerts.
|
# have a real location for alerts.
|
||||||
|
call_info = get_call_info(self.dx_calls[0], credentials)
|
||||||
if self.dx_calls and self.dx_calls[0] and not self.dx_country:
|
if self.dx_calls and self.dx_calls[0] and not self.dx_country:
|
||||||
self.dx_country = lookup_helper.infer_country_from_callsign(self.dx_calls[0], credentials)
|
self.dx_country = call_info.country
|
||||||
if self.dx_calls and self.dx_calls[0] and not self.dx_continent:
|
if self.dx_calls and self.dx_calls[0] and not self.dx_continent:
|
||||||
self.dx_continent = lookup_helper.infer_continent_from_callsign(self.dx_calls[0], credentials)
|
self.dx_continent = call_info.continent
|
||||||
if self.dx_calls and self.dx_calls[0] and not self.dx_cq_zone:
|
if self.dx_calls and self.dx_calls[0] and not self.dx_cq_zone:
|
||||||
self.dx_cq_zone = lookup_helper.infer_cq_zone_from_callsign(self.dx_calls[0], credentials)
|
self.dx_cq_zone = call_info.cq_zone
|
||||||
if self.dx_calls and self.dx_calls[0] and not self.dx_itu_zone:
|
if self.dx_calls and self.dx_calls[0] and not self.dx_itu_zone:
|
||||||
self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_calls[0], credentials)
|
self.dx_itu_zone = call_info.itu_zone
|
||||||
if self.dx_calls and self.dx_calls[0] and not self.dx_dxcc_id:
|
if self.dx_calls and self.dx_calls[0] and not self.dx_dxcc_id:
|
||||||
self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_calls[0], credentials)
|
self.dx_dxcc_id = call_info.dxcc_id
|
||||||
if self.dx_dxcc_id and not self.dx_flag:
|
if self.dx_dxcc_id and not self.dx_flag:
|
||||||
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
|
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
|
||||||
|
|
||||||
@@ -104,7 +105,7 @@ class Alert:
|
|||||||
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
|
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
|
||||||
if self.sig_refs and len(self.sig_refs) > 0:
|
if self.sig_refs and len(self.sig_refs) > 0:
|
||||||
for sig_ref in self.sig_refs:
|
for sig_ref in self.sig_refs:
|
||||||
populate_sig_ref_info(sig_ref)
|
populate_missing_sig_ref_info(sig_ref)
|
||||||
|
|
||||||
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
|
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
|
||||||
# and apply it to the whole spot.
|
# and apply it to the whole spot.
|
||||||
@@ -124,12 +125,10 @@ class Alert:
|
|||||||
self_copy.received_time_iso = ""
|
self_copy.received_time_iso = ""
|
||||||
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
|
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
# DX operator details lookup, using QRZ.com/HamQTH. This should be the last resort compared to taking the data
|
# DX operator name lookup, using QRZ.com/HamQTH.
|
||||||
# from the actual alerting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon
|
|
||||||
# instead of the one from the park reference they're at.
|
|
||||||
if self.dx_calls and not self.dx_names:
|
if self.dx_calls and not self.dx_names:
|
||||||
self.dx_names = list(
|
self.dx_names = list(
|
||||||
map(lambda c: lookup_helper.infer_name_from_callsign_online_lookup(c, credentials), self.dx_calls))
|
map(lambda c: get_call_info(c, credentials).name, self.dx_calls))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error("Exception while inferring missing data from spot", e, exc_info=True)
|
logging.error("Exception while inferring missing data from spot", e, exc_info=True)
|
||||||
|
|||||||
+15
-6
@@ -11,7 +11,7 @@ class Callsign:
|
|||||||
# Callsign as spotted
|
# Callsign as spotted
|
||||||
call: str
|
call: str
|
||||||
# "Home" call, i.e. with any prefixes and suffixes stripped off
|
# "Home" call, i.e. with any prefixes and suffixes stripped off
|
||||||
home_call: str
|
home_call: str | None = None
|
||||||
# Operator name
|
# Operator name
|
||||||
name : str | None = None
|
name : str | None = None
|
||||||
# QTH (location), free text
|
# QTH (location), free text
|
||||||
@@ -26,13 +26,22 @@ class Callsign:
|
|||||||
# the centre of the country they're operating in if no other data is available.
|
# the centre of the country they're operating in if no other data is available.
|
||||||
longitude : float | None = None
|
longitude : float | None = None
|
||||||
# Country in which the callsign indicates they are operating
|
# Country in which the callsign indicates they are operating
|
||||||
dx_country: str | None = None
|
country: str | None = None
|
||||||
# Continent in which the callsign indicates they are operating
|
# Continent in which the callsign indicates they are operating
|
||||||
dx_continent: str | None = None
|
continent: str | None = None
|
||||||
# DXCC ID in which the callsign indicates they are operating
|
# DXCC ID in which the callsign indicates they are operating
|
||||||
dx_dxcc_id: int | None = None
|
dxcc_id: int | None = None
|
||||||
# CQ zone in which the callsign indicates they are operating
|
# CQ zone in which the callsign indicates they are operating
|
||||||
dx_cq_zone: int | None = None
|
cq_zone: int | None = None
|
||||||
# ITU zone in which the callsign indicates they are operating
|
# ITU zone in which the callsign indicates they are operating
|
||||||
dx_itu_zone: int | None = None
|
itu_zone: int | None = None
|
||||||
|
# Location source. This can be "HOME QTH" or "DXCC" depending on which provider gave us a location
|
||||||
|
location_source: str | None = None
|
||||||
|
|
||||||
|
def fully_populated(self):
|
||||||
|
"""Utility method to indicate that the callsign data is fully populated. Multiple providers can return data for
|
||||||
|
a callsign, and we try them in sequence until we have all the data we can, in which case there's no point
|
||||||
|
querying any other providers."""
|
||||||
|
return self.home_call is not None and self.name is not None and self.qth is not None and self.grid is not None\
|
||||||
|
and self.latitude is not None and self.longitude is not None and self.country is not None and self.continent\
|
||||||
|
is not None and self.dxcc_id is not None and self.cq_zone is not None and self.itu_zone is not None
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ class LookupCredentials:
|
|||||||
hamqth_session_id: str = "" # alternative to username/password
|
hamqth_session_id: str = "" # alternative to username/password
|
||||||
|
|
||||||
|
|
||||||
def extract_credentials(query_params):
|
def extract_credentials(headers):
|
||||||
"""Build a LookupCredentials from HTTP query params; returns None if no usable credentials are present."""
|
"""Build a LookupCredentials from HTTP request headers; returns None if no usable credentials are present."""
|
||||||
creds = LookupCredentials(
|
creds = LookupCredentials(
|
||||||
qrz_username=query_params.get("qrz_username", ""),
|
qrz_username=headers.get("X-QRZ-Username", ""),
|
||||||
qrz_password=query_params.get("qrz_password", ""),
|
qrz_password=headers.get("X-QRZ-Password", ""),
|
||||||
qrz_session_key=query_params.get("qrz_session_key", ""),
|
qrz_session_key=headers.get("X-QRZ-Session-Key", ""),
|
||||||
hamqth_username=query_params.get("hamqth_username", ""),
|
hamqth_username=headers.get("X-HamQTH-Username", ""),
|
||||||
hamqth_password=query_params.get("hamqth_password", ""),
|
hamqth_password=headers.get("X-HamQTH-Password", ""),
|
||||||
hamqth_session_id=query_params.get("hamqth_session_id", ""),
|
hamqth_session_id=headers.get("X-HamQTH-Session-ID", ""),
|
||||||
)
|
)
|
||||||
has_qrz = creds.qrz_session_key or (creds.qrz_username and creds.qrz_password)
|
has_qrz = creds.qrz_session_key or (creds.qrz_username and creds.qrz_password)
|
||||||
has_hamqth = creds.hamqth_session_id or (creds.hamqth_username and creds.hamqth_password)
|
has_hamqth = creds.hamqth_session_id or (creds.hamqth_username and creds.hamqth_password)
|
||||||
|
|||||||
+38
-55
@@ -12,9 +12,9 @@ from pyhamtools.locator import locator_to_latlong, latlong_to_locator
|
|||||||
from core.config import MAX_SPOT_AGE
|
from core.config import MAX_SPOT_AGE
|
||||||
from core.constants import MODE_ALIASES, PROPAGATION_MODES
|
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.call_lookup_helper import lookup_helper
|
from core.call_lookup_helper import get_call_info
|
||||||
from core.sig_utils import ANY_SIG_REGEX, get_ref_regex_for_sig, get_sig_name_from_comment_name
|
from core.sig_utils import ANY_SIG_REGEX, get_ref_regex_for_sig, get_sig_name_from_comment_name
|
||||||
from core.sig_lookup_helper import populate_sig_ref_info
|
from core.sig_lookup_helper import populate_missing_sig_ref_info
|
||||||
from core.utils import infer_band_from_freq, infer_mode_from_comment, \
|
from core.utils import infer_band_from_freq, infer_mode_from_comment, \
|
||||||
infer_mode_from_frequency, infer_mode_type_from_mode, get_flag_for_dxcc
|
infer_mode_from_frequency, infer_mode_type_from_mode, get_flag_for_dxcc
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
@@ -173,12 +173,13 @@ class Spot:
|
|||||||
self.dx_ssid = split[1]
|
self.dx_ssid = split[1]
|
||||||
|
|
||||||
# DX country, continent etc. from callsign
|
# DX country, continent etc. from callsign
|
||||||
|
dx_call_info = get_call_info(self.dx_call, credentials)
|
||||||
if self.dx_call and not self.dx_country:
|
if self.dx_call and not self.dx_country:
|
||||||
self.dx_country = lookup_helper.infer_country_from_callsign(self.dx_call, credentials)
|
self.dx_country = dx_call_info.country
|
||||||
if self.dx_call and not self.dx_continent:
|
if self.dx_call and not self.dx_continent:
|
||||||
self.dx_continent = lookup_helper.infer_continent_from_callsign(self.dx_call, credentials)
|
self.dx_continent = dx_call_info.continent
|
||||||
if self.dx_call and not self.dx_dxcc_id:
|
if self.dx_call and not self.dx_dxcc_id:
|
||||||
self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_call, credentials)
|
self.dx_dxcc_id = dx_call_info.dxcc_id
|
||||||
if self.dx_dxcc_id and not self.dx_flag:
|
if self.dx_dxcc_id and not self.dx_flag:
|
||||||
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
|
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
|
||||||
|
|
||||||
@@ -205,14 +206,15 @@ class Spot:
|
|||||||
|
|
||||||
# Spotter country, continent, zones etc. from callsign.
|
# Spotter country, continent, zones etc. from callsign.
|
||||||
# DE call with no digits, or APRS servers starting "T2" are not things we can look up location for
|
# DE call with no digits, or APRS servers starting "T2" are not things we can look up location for
|
||||||
|
de_call_info = get_call_info(self.de_call, credentials)
|
||||||
if self.de_call and any(char.isdigit() for char in self.de_call) and not (
|
if self.de_call and any(char.isdigit() for char in self.de_call) and not (
|
||||||
self.de_call.startswith("T2") and self.source == "APRS-IS"):
|
self.de_call.startswith("T2") and self.source == "APRS-IS"):
|
||||||
if not self.de_country:
|
if not self.de_country:
|
||||||
self.de_country = lookup_helper.infer_country_from_callsign(self.de_call, credentials)
|
self.de_country = de_call_info.country
|
||||||
if not self.de_continent:
|
if not self.de_continent:
|
||||||
self.de_continent = lookup_helper.infer_continent_from_callsign(self.de_call, credentials)
|
self.de_continent = de_call_info.continent
|
||||||
if not self.de_dxcc_id:
|
if not self.de_dxcc_id:
|
||||||
self.de_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.de_call, credentials)
|
self.de_dxcc_id = de_call_info.dxcc_id
|
||||||
if self.de_dxcc_id and not self.de_flag:
|
if self.de_dxcc_id and not self.de_flag:
|
||||||
self.de_flag = get_flag_for_dxcc(self.de_dxcc_id)
|
self.de_flag = get_flag_for_dxcc(self.de_dxcc_id)
|
||||||
|
|
||||||
@@ -287,7 +289,7 @@ class Spot:
|
|||||||
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
|
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
|
||||||
if self.sig_refs and len(self.sig_refs) > 0:
|
if self.sig_refs and len(self.sig_refs) > 0:
|
||||||
for sig_ref in self.sig_refs:
|
for sig_ref in self.sig_refs:
|
||||||
sig_ref = populate_sig_ref_info(sig_ref)
|
sig_ref = populate_missing_sig_ref_info(sig_ref)
|
||||||
# If the spot itself doesn't have location yet, but the SIG ref does, extract it
|
# If the spot itself doesn't have location yet, but the SIG ref does, extract it
|
||||||
if sig_ref.grid and not self.dx_grid:
|
if sig_ref.grid and not self.dx_grid:
|
||||||
self.dx_grid = sig_ref.grid
|
self.dx_grid = sig_ref.grid
|
||||||
@@ -328,6 +330,19 @@ class Spot:
|
|||||||
self.propagation_mode = mode_tag
|
self.propagation_mode = mode_tag
|
||||||
logging.info("Seen a new propagation mode tag not yet in the system: %s", mode_tag)
|
logging.info("Seen a new propagation mode tag not yet in the system: %s", mode_tag)
|
||||||
|
|
||||||
|
# Parse "de_grid -> dx_grid" structures from the comment
|
||||||
|
if self.comment:
|
||||||
|
grid_mode_grid_match = re.search(
|
||||||
|
r'\b([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\s*->\s*([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\b',
|
||||||
|
self.comment)
|
||||||
|
if grid_mode_grid_match:
|
||||||
|
# regex matches, so extract grids:
|
||||||
|
if not self.dx_grid:
|
||||||
|
self.dx_grid = grid_mode_grid_match.group(1).upper()
|
||||||
|
self.dx_location_source = "SPOT"
|
||||||
|
if not self.de_grid:
|
||||||
|
self.de_grid = grid_mode_grid_match.group(2).upper()
|
||||||
|
|
||||||
# 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:
|
||||||
try:
|
try:
|
||||||
@@ -359,18 +374,16 @@ class Spot:
|
|||||||
self_copy.received_time_iso = ""
|
self_copy.received_time_iso = ""
|
||||||
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
|
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
# DX operator details lookup, using QRZ.com/HamQTH. This should be the last resort compared to taking the data
|
# DX operator details lookup. This should be the last resort compared to taking the data from the actual
|
||||||
# from the actual spotting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon
|
# spotting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon or DXCC lat/lon
|
||||||
# instead of the one from the park reference they're at.
|
# instead of the one from the park reference they're at.
|
||||||
if self.dx_call and not self.dx_name:
|
if self.dx_call and not self.dx_name:
|
||||||
self.dx_name = lookup_helper.infer_name_from_callsign_online_lookup(self.dx_call, credentials)
|
self.dx_name = dx_call_info.name
|
||||||
if self.dx_call and not self.dx_latitude:
|
if self.dx_call and not self.dx_latitude:
|
||||||
latlon = lookup_helper.infer_latlon_from_callsign_online_lookup(self.dx_call, credentials)
|
self.dx_latitude = dx_call_info.latitude
|
||||||
if latlon:
|
self.dx_longitude = dx_call_info.longitude
|
||||||
self.dx_latitude = latlon[0]
|
self.dx_grid = dx_call_info.grid
|
||||||
self.dx_longitude = latlon[1]
|
self.dx_location_source = dx_call_info.location_source
|
||||||
self.dx_grid = lookup_helper.infer_grid_from_callsign_online_lookup(self.dx_call, credentials)
|
|
||||||
self.dx_location_source = "HOME QTH"
|
|
||||||
|
|
||||||
# Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable string,
|
# Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable string,
|
||||||
# otherwise see what they have set on an online lookup service.
|
# otherwise see what they have set on an online lookup service.
|
||||||
@@ -380,39 +393,19 @@ class Spot:
|
|||||||
qth += " " + self.sig_refs[0].name
|
qth += " " + self.sig_refs[0].name
|
||||||
self.dx_qth = qth
|
self.dx_qth = qth
|
||||||
else:
|
else:
|
||||||
self.dx_qth = lookup_helper.infer_qth_from_callsign_online_lookup(self.dx_call, credentials)
|
self.dx_qth = dx_call_info.qth
|
||||||
|
|
||||||
# Last resort for getting a DX position, use the DXCC entity.
|
|
||||||
if self.dx_call and not self.dx_latitude:
|
|
||||||
latlon = lookup_helper.infer_latlon_from_callsign_dxcc(self.dx_call)
|
|
||||||
if latlon:
|
|
||||||
self.dx_latitude = latlon[0]
|
|
||||||
self.dx_longitude = latlon[1]
|
|
||||||
self.dx_grid = lookup_helper.infer_grid_from_callsign_dxcc(self.dx_call)
|
|
||||||
self.dx_location_source = "DXCC"
|
|
||||||
|
|
||||||
# It looks like we can sometimes get a string into lat/lon, so try to parse as float, reject if not valid
|
|
||||||
if isinstance(self.dx_latitude, str) or isinstance(self.dx_longitude, str):
|
|
||||||
try:
|
|
||||||
self.dx_latitude = float(str(self.dx_latitude))
|
|
||||||
self.dx_longitude = float(str(self.dx_longitude))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
logging.warning("Received non-numeric strings in lat/lon (" + str(self.dx_latitude) + ", " + str(
|
|
||||||
self.dx_longitude) + ") for call " + str(self.dx_call) + ", rejecting it")
|
|
||||||
self.dx_latitude = None
|
|
||||||
self.dx_longitude = None
|
|
||||||
|
|
||||||
# CQ and ITU zone lookup, preferably from location but failing that, from callsign
|
# CQ and ITU zone lookup, preferably from location but failing that, from callsign
|
||||||
if not self.dx_cq_zone:
|
if not self.dx_cq_zone:
|
||||||
if self.dx_latitude:
|
if self.dx_latitude:
|
||||||
self.dx_cq_zone = lat_lon_to_cq_zone(self.dx_latitude, self.dx_longitude)
|
self.dx_cq_zone = lat_lon_to_cq_zone(self.dx_latitude, self.dx_longitude)
|
||||||
elif self.dx_call:
|
elif self.dx_call:
|
||||||
self.dx_cq_zone = lookup_helper.infer_cq_zone_from_callsign(self.dx_call, credentials)
|
self.dx_cq_zone = dx_call_info.cq_zone
|
||||||
if not self.dx_itu_zone:
|
if not self.dx_itu_zone:
|
||||||
if self.dx_latitude:
|
if self.dx_latitude:
|
||||||
self.dx_itu_zone = lat_lon_to_itu_zone(self.dx_latitude, self.dx_longitude)
|
self.dx_itu_zone = lat_lon_to_itu_zone(self.dx_latitude, self.dx_longitude)
|
||||||
elif self.dx_call:
|
elif self.dx_call:
|
||||||
self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_call, credentials)
|
self.dx_itu_zone = dx_call_info.itu_zone
|
||||||
|
|
||||||
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
|
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
|
||||||
# is likely at home.
|
# is likely at home.
|
||||||
@@ -424,21 +417,11 @@ class Spot:
|
|||||||
# DE with no digits and APRS servers starting "T2" are not things we can look up location for
|
# DE with no digits and APRS servers starting "T2" are not things we can look up location for
|
||||||
if self.de_call and any(char.isdigit() for char in self.de_call) and not (
|
if self.de_call and any(char.isdigit() for char in self.de_call) and not (
|
||||||
self.de_call.startswith("T2") and self.source == "APRS-IS"):
|
self.de_call.startswith("T2") and self.source == "APRS-IS"):
|
||||||
# DE operator position lookup, using QRZ.com/HamQTH.
|
# DE operator location lookup
|
||||||
if not self.de_latitude:
|
if not self.de_latitude:
|
||||||
latlon = lookup_helper.infer_latlon_from_callsign_online_lookup(self.de_call, credentials)
|
self.de_latitude = de_call_info.latitude
|
||||||
if latlon:
|
self.de_longitude = de_call_info.longitude
|
||||||
self.de_latitude = latlon[0]
|
self.de_grid = de_call_info.grid
|
||||||
self.de_longitude = latlon[1]
|
|
||||||
self.de_grid = lookup_helper.infer_grid_from_callsign_online_lookup(self.de_call, credentials)
|
|
||||||
|
|
||||||
# Last resort for getting a DE position, use the DXCC entity.
|
|
||||||
if not self.de_latitude:
|
|
||||||
latlon = lookup_helper.infer_latlon_from_callsign_dxcc(self.de_call)
|
|
||||||
if latlon:
|
|
||||||
self.de_latitude = latlon[0]
|
|
||||||
self.de_longitude = latlon[1]
|
|
||||||
self.de_grid = lookup_helper.infer_grid_from_callsign_dxcc(self.de_call)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error("Exception while inferring missing data from spot", e, exc_info=True)
|
logging.error("Exception while inferring missing data from spot", e, exc_info=True)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
## Writing your own client
|
||||||
|
|
||||||
|
One of the key strengths of Spothole is that the API is well-defined and open to anyone to use. This means you can build
|
||||||
|
your own software that uses data from Spothole.
|
||||||
|
|
||||||
|
As well as the main API endpoints to fetch spots and alerts, with various possible query parameters, there are also
|
||||||
|
Server-Sent Events (SSE) API endpoints to receive a live feed, plus various utility lookup endpoints for things like
|
||||||
|
callsign and park data.
|
||||||
|
|
||||||
|
Various approaches exist to writing your own client, but in general:
|
||||||
|
|
||||||
|
* Refer to the API docs. These are built on an OpenAPI definition file (`/static/apidocs/openapi.yml`), which you can
|
||||||
|
automatically use to generate a client skeleton using various software.
|
||||||
|
* Call the main "spots" or "alerts" API endpoints to get the data you want. For example, your app could call
|
||||||
|
`https://spothole.app/api/v2/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
|
||||||
|
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 "alerts"/"upcoming"
|
||||||
|
page simply query the main spot API on a timer, whereas the spots, map and bands pages combine this approach with
|
||||||
|
using the Server-Sent Events (SSE) endpoint to update live.
|
||||||
|
* 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
|
||||||
|
once every two minutes, so if your client is interested in POTA data there's no need to poll Spothole any more often
|
||||||
|
than that.
|
||||||
|
|
||||||
|
If you absolutely must be informed within seconds of a spot arriving in Spothole, please use the SSE endpoints instead,
|
||||||
|
e.g. `https://spothole.app/api/v2/spots/stream`.
|
||||||
|
|
||||||
|
If you want to handle different types of spot or alert differently within your client, please consider making a single
|
||||||
|
request to the Spothole API to retrieve all the data, then filtering on your side. For example, call
|
||||||
|
`https://spothole.app/api/v2/spots?sig=POTA,SOTA` rather than making two separate calls to
|
||||||
|
`https://spothole.app/api/v2/spots?sig=POTA` and `https://spothole.app/api/v2/spots?sig=SOTA`.
|
||||||
|
|
||||||
|
Remember, here at Spothole Inc. we offer an industry-standard "five nines" uptime on our server, with our own unique
|
||||||
|
twist: we don't tell you which side of the decimal point the nines start! (Translation: This is a hobby project.
|
||||||
|
`spothole.app` runs on the same server as my blog and other stuff. It might go down without warning. By all means base
|
||||||
|
your own project on data from the main server if you like, but if you want any control over reliability and downtime,
|
||||||
|
please run your own copy instead.)
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
## Running using Docker
|
||||||
|
|
||||||
|
Spothole comes with a Docker configuration to make it easy to run it in a containerised environment. To set it up using
|
||||||
|
Docker, the easiest way is to use a Docker Compose file. Create a new directory such as `/opt/docker/spothole` and
|
||||||
|
create a `compose.yaml` file inside it with the following contents:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
spothole:
|
||||||
|
container_name: spothole
|
||||||
|
build:
|
||||||
|
context: https://git.ianrenton.com/ian/spothole.git#main
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./config.yml:/app/config.yml
|
||||||
|
- ./cache:/app/cache
|
||||||
|
```
|
||||||
|
|
||||||
|
You can replace `#main` with any other branch or tag reference, for example `#1.5` to pin the build to tagged version
|
||||||
|
1.5.
|
||||||
|
|
||||||
|
Save the file. You will still need to create a copy of `config-example.yml` and name it `config.yml`, though with the
|
||||||
|
Docker setup nothing has actually been downloaded yet, so you will have to copy the example from the repository some
|
||||||
|
other way, e.g. [from the repo in a web browser](https://git.ianrenton.com/ian/spothole/src/branch/main/config-example.yml).
|
||||||
|
|
||||||
|
With that in place, run `docker compose up` and you should be good to go. To detach, press `d` or run the command with
|
||||||
|
the `-d` flag.
|
||||||
|
|
||||||
|
### nginx Reverse Proxy with Docker
|
||||||
|
|
||||||
|
In a containerised setup, it's typical to run an nginx reverse proxy in one container, alongside certbot for renewal
|
||||||
|
of HTTPS certificates, and then applications like Spothole in a separate container. In this case, there are a couple of
|
||||||
|
variations of the docker compose file above, and the nginx reverse proxy configuration covered [here](./nginx.md), that
|
||||||
|
you will want to make.
|
||||||
|
|
||||||
|
1. A port mapping is no longer required in the docker compose file; nginx will access into the docker container directly
|
||||||
|
on e.g. `http://spothole:8080`
|
||||||
|
2. Spothole and nginx will need to be on the same docker network.
|
||||||
|
|
||||||
|
So your `compose.yaml` might look like this:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
spothole:
|
||||||
|
container_name: spothole
|
||||||
|
build:
|
||||||
|
context: https://git.ianrenton.com/ian/spothole.git#main
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- docker-network
|
||||||
|
volumes:
|
||||||
|
- ./config.yml:/app/config.yml
|
||||||
|
- ./cache:/app/cache
|
||||||
|
|
||||||
|
networks:
|
||||||
|
docker-network:
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
In your nginx site configuration, you'll want to refer to the Spothole container directly, and drop the block that
|
||||||
|
allows nginx to access static files directly, as these will be inaccessible in another container. So you may end up
|
||||||
|
with something like:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
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
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SSE endpoints
|
||||||
|
location ~ ^/api/v2/(spots|alerts)/stream/? {
|
||||||
|
proxy_pass http://spothole:8080;
|
||||||
|
|
||||||
|
# Remove buffering, remove caching, add suitable timeouts for SSE API calls
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 24h;
|
||||||
|
proxy_send_timeout 24h;
|
||||||
|
proxy_set_header X-Accel-Buffering no;
|
||||||
|
add_header Cache-Control no-store always;
|
||||||
|
|
||||||
|
# Allow cross-origin requests to API
|
||||||
|
proxy_hide_header Access-Control-Allow-Origin;
|
||||||
|
add_header Access-Control-Allow-Origin * always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Other API endpoints
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://spothole:8080;
|
||||||
|
|
||||||
|
# Remove buffering, remove caching, add suitable timeouts for API calls
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
add_header Cache-Control no-store always;
|
||||||
|
|
||||||
|
# Allow cross-origin requests to API
|
||||||
|
proxy_hide_header Access-Control-Allow-Origin;
|
||||||
|
add_header Access-Control-Allow-Origin * always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Templated pages and static assets
|
||||||
|
location / {
|
||||||
|
proxy_pass http://spothole:8080;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/spothole.app/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/spothole.app/privkey.pem;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
server {
|
||||||
|
if ($host = spothole.app) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server_name spothole.app;
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If desired, you could even change the port on which Spothole runs from 8080 to a plain 80, in which case your
|
||||||
|
`proxy_pass` statements could drop the `:8080` suffix. Since Spothole is in a container, it can serve HTTP on port 80
|
||||||
|
if desired, because it doesn't conflict with the host system.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
## Embedding Spothole in another website
|
||||||
|
|
||||||
|
You can embed Spothole's web interface in another website, e.g. for use as part of a ham radio custom dashboard.
|
||||||
|
|
||||||
|
URL parameters can be used to trigger an "embedded" mode which hides the headers, footers and settings. In this mode,
|
||||||
|
you provide configuration for the various filter and display options via additional URL parameters. Any settings that
|
||||||
|
the user has set for Spothole are ignored. This is so that the embedding site can select, for example, their choice of
|
||||||
|
dark mode or SIG filters, which will not impact how Spothole appears when the user accesses it directly. Effectively, it
|
||||||
|
becomes separate to their normal Spothole settings.
|
||||||
|
|
||||||
|
Setting `embedded` to true is important for the rest of the settings to be applied; otherwise, the user's defaults will
|
||||||
|
be used in preference to the URL params.
|
||||||
|
|
||||||
|
These are supplied with the URL to the page you want to embed, for example for an embedded version of the band map in
|
||||||
|
dark mode, use `https://spothole.app/bands?embedded=true&dark-mode=true`. For an embedded version of the main spots/home
|
||||||
|
page in the system light/dark mode, use `https://spothole.app/?embedded=true`. For dark mode showing 70cm TOTA spots
|
||||||
|
only, use `https://spothole.app/?embedded=true&dark-mode=true&sig=TOTA&band=70cm`. Providing no URL params causes the
|
||||||
|
page to be loaded in the normal way it would when accessed directly in the user's browser.
|
||||||
|
|
||||||
|
The supported parameters are as follows. Generally these match the equivalent parameters in the real Spothole API, where
|
||||||
|
a mapping exists.
|
||||||
|
|
||||||
|
| Name | Allowed Values | Default | Example | Description |
|
||||||
|
|------------------|-------------------------|---------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `embedded` | `true`, `false` | `false` | `?embedded=true` | Enables embedded mode. |
|
||||||
|
| `color-scheme` | `light`, `dark`, `auto` | `auto` | `?color-scheme=dark` | Forces light or dark mode in preference to the operating system default. |
|
||||||
|
| `time-zone` | `UTC`, `local` | `UTC` | `?time-zone=local` | Sets times to be in UTC or local time. |
|
||||||
|
| `limit` | 10, 25, 50, 100 | 50 | `?limit=50` | Sets the number of spots that will be displayed on the main spots page |
|
||||||
|
| `limit` | 25, 50, 100, 200, 500 | 100 | `?limit=100` | Sets the number of alerts that will be displayed on the alerts page |
|
||||||
|
| `max_age` | 300, 600, 1800, 3600 | 1800 | `?max_age=1800` | Sets the maximum age of spots displayed on the map and bands pages, in seconds. |
|
||||||
|
| `band` | Comma-separated list | (all) | `?band=20m,40m` | Sets the list of bands that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
|
||||||
|
| `sig` | Comma-separated list | (all) | `?sig=POTA,SOTA,NO_SIG` | Sets the list of SIGs that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
|
||||||
|
| `source` | Comma-separated list | (all) | `?source=Cluster` | Sets the list of sources that will be shown on any spot or alert pages. Available options match the labels of the buttons in the standard web interface. |
|
||||||
|
| `mode_type` | Comma-separated list | (all) | `?mode_type=PHONE,CW` | Sets the list of mode types that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
|
||||||
|
| `dx_continent` | Comma-separated list | (all) | `?dx_continent=NA,SA` | Sets the list of DX Continents that will be shown on any spot or alert pages. Available options match the labels of the buttons in the standard web interface. |
|
||||||
|
| `de_continent` | Comma-separated list | (all) | `?de_continent=EU` | Sets the list of DE Continents that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
|
||||||
|
| `map-center-lat` | Numeric (decimal) | (auto) | `?map-center-lat=51.5` | Sets the initial latitude of the map centre on the map page. If omitted, the map auto-fits to the loaded spots. |
|
||||||
|
| `map-center-lon` | Numeric (decimal) | (auto) | `?map-center-lon=-0.1` | Sets the initial longitude of the map centre on the map page. If omitted, the map auto-fits to the loaded spots. |
|
||||||
|
| `map-zoom` | Numeric (integer) | (auto) | `?map-zoom=6` | Sets the initial zoom level of the map on the map page. If omitted, the map auto-fits to the loaded spots. |
|
||||||
|
|
||||||
|
See the comment at the end of the next section regarding reliability and uptime of the "main" server.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
## Modifying the source code
|
||||||
|
|
||||||
|
Spothole is Public Domain licenced, so you can grab the source code and start modifying it for your own needs.
|
||||||
|
Contributions of code back to the main repository are encouraged, but completely optional.
|
||||||
|
|
||||||
|
### Code structure
|
||||||
|
|
||||||
|
To navigate your way around the source code, this list may help.
|
||||||
|
|
||||||
|
*Python back-end code*
|
||||||
|
|
||||||
|
* `/core` - Core classes and utilities
|
||||||
|
* `/data` - Data storage classes
|
||||||
|
* `/providers/spot` - Classes providing spots by accessing the APIs of other services
|
||||||
|
* `/providers/alert` - Classes providing alerts by accessing the APIs of other services
|
||||||
|
* `/providers/solarconditions` - Classes providing solar and propagation by accessing the APIs of other services
|
||||||
|
* `/providers/staticdata` - Classes providing static lookup data by accessing bundled data files or the APIs of other
|
||||||
|
services
|
||||||
|
* `/providers/sigrefdata` - Classes providing SIG reference lookup data by accessing bundled data files or the APIs of
|
||||||
|
other services
|
||||||
|
* `/server` - Classes for running Spothole's own web server
|
||||||
|
* `spothole.py` - Main application script
|
||||||
|
|
||||||
|
*Templates*
|
||||||
|
|
||||||
|
* `/templates` - Templates used for constructing Spothole's user-targeted HTML pages
|
||||||
|
|
||||||
|
*HTML/JS/CSS front-end code*
|
||||||
|
|
||||||
|
* `/static` - Root for static files served by the web server. These are all served from a path starting `/static/`.
|
||||||
|
* `/static/apidocs` - Contains the OpenAPI spec (`openapi.yml`)
|
||||||
|
* `/static/audio` - Audio files used by the web front-end
|
||||||
|
* `/static/css` - CSS files used by the web front-end
|
||||||
|
* `/static/img` - image files used by the web front-end
|
||||||
|
* `/static/js` - JavaScript used by the web front-end
|
||||||
|
* `/static/vendor` - Third-party libraries (CSS, JS, fonts and images)
|
||||||
|
|
||||||
|
*Miscellaneous*
|
||||||
|
|
||||||
|
* `/` - pip `requirements.txt`, config, README, etc.
|
||||||
|
* `/docs` - Documentation
|
||||||
|
* `/images` - Image sources
|
||||||
|
* `/datafiles` - Local data files, used by some providers when the data will never change and/or is not easily available
|
||||||
|
online in a format Spothole can handle
|
||||||
|
* `/cache` - Directory where Spothole stores all the data it uses that should be persisted to disk. Created on first
|
||||||
|
run.
|
||||||
|
|
||||||
|
### Extending the server
|
||||||
|
|
||||||
|
Spothole is designed to be easily extensible. If you want to write your own spot provider, for example, simply add a
|
||||||
|
module to the `providers.spot` package containing your class. (Currently, in order to be loaded correctly, the module (
|
||||||
|
file) name should be the same as the class name, but lower case.)
|
||||||
|
|
||||||
|
Your class should extend "SpotProvider"; if it operates by polling an HTTP Server on a timer, it can instead extend "
|
||||||
|
HTTPSpotProvider" where some of the work is done for you.
|
||||||
|
|
||||||
|
The class will need to implement a constructor that takes in the `provider_config` and provides it to the superclass
|
||||||
|
constructor, while also taking any other config parameters it needs.
|
||||||
|
|
||||||
|
If you're extending the base `SpotProvider` class, you will need to implement `start()` and `stop()` methods that start
|
||||||
|
and stop a separate thread which handles the provider's processing needs. The thread should call `submit()` or
|
||||||
|
`submit_batch()` when it has one or more spots to report.
|
||||||
|
|
||||||
|
If you're extending the `HTTPSpotProvider` class, you will need to provide a URI to query and an interval to the
|
||||||
|
superclass constructor. You'll then need to implement the `http_response_to_spots()` method which is called when new
|
||||||
|
data is retrieved. Your implementation should then call `submit()` or `submit_batch()` when it has one or more spots to
|
||||||
|
report.
|
||||||
|
|
||||||
|
When constructing spots, use the comments in the Spot class and the existing implementations as an example. All
|
||||||
|
parameters are optional, but you will at least want to provide a `time` (which must be timezone-aware) and a `dx_call`.
|
||||||
|
|
||||||
|
Finally, simply add the appropriate config to the `spot_providers` section of `config.yml`, and your provider should be
|
||||||
|
instantiated on startup.
|
||||||
|
|
||||||
|
The same approach as above is also used for alerts, and other types of providers. Give me a shout if you need any
|
||||||
|
advice.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
## Multiple cluster nodes with different settings
|
||||||
|
|
||||||
|
Dan, S50U has written in with his Spothole cluster settings. He is using a cluster node which provides RBN spots, and
|
||||||
|
uses different SSIDs on his callsign to get different settings when logged into the same cluster node. For example:
|
||||||
|
|
||||||
|
```
|
||||||
|
-
|
||||||
|
class: "DXCluster"
|
||||||
|
name: "S50CLX"
|
||||||
|
enabled: true
|
||||||
|
host: "s50clx.si"
|
||||||
|
port: 41112
|
||||||
|
login_prompt: "login: "
|
||||||
|
login_callsign: "callsign-10"
|
||||||
|
```
|
||||||
|
|
||||||
|
Telnet to DXSpider and log in with "callsign-10" and execute the following commands:
|
||||||
|
|
||||||
|
`CLEAR/SPOTS ALL` (delete all previous filters)<br/>
|
||||||
|
`UNSET/ANN` (stop announce messages)<br/>
|
||||||
|
`UNSET/WCY` (stop wcy messages)<br/>
|
||||||
|
`UNSET/WWV` (stop wwv messages)<br/>
|
||||||
|
`SET/DX` (enable human DX spots)
|
||||||
|
|
||||||
|
```
|
||||||
|
-
|
||||||
|
class: "DXCluster"
|
||||||
|
name: "RBN CW"
|
||||||
|
enabled: true
|
||||||
|
host: "s50clx.si"
|
||||||
|
port: 41112
|
||||||
|
login_prompt: "login: "
|
||||||
|
login_callsign: "callsign-11"
|
||||||
|
allow_rbn_spots: true
|
||||||
|
enabled-by-default-in-web-ui: false
|
||||||
|
```
|
||||||
|
|
||||||
|
Telnet to DXSpider and log in with "callsign-11" and execute the following commands:
|
||||||
|
|
||||||
|
`CLEAR/SPOTS ALL` (delete all previous filters)<br/>
|
||||||
|
`UNSET/ANN` (stop announce messages)<br/>
|
||||||
|
`UNSET/WCY` (stop wcy messages)<br/>
|
||||||
|
`UNSET/WWV` (stop wwv messages)<br/>
|
||||||
|
`UNSET/DX` (stop human DX spots)<br/>
|
||||||
|
`SET/SKIMMER CW` (enable CW RBN spots)
|
||||||
|
|
||||||
|
```
|
||||||
|
-
|
||||||
|
class: "DXCluster"
|
||||||
|
name: "RBN RTTY"
|
||||||
|
enabled: true
|
||||||
|
host: "s50clx.si"
|
||||||
|
port: 41112
|
||||||
|
login_prompt: "login: "
|
||||||
|
login_callsign: "callsign-12"
|
||||||
|
allow_rbn_spots: true
|
||||||
|
enabled-by-default-in-web-ui: false
|
||||||
|
```
|
||||||
|
|
||||||
|
Telnet to DXSpider and log in with "callsign-12" and execute the following commands:
|
||||||
|
|
||||||
|
`CLEAR/SPOTS ALL` (delete all previous filters)<br/>
|
||||||
|
`UNSET/ANN` (stop announce messages)<br/>
|
||||||
|
`UNSET/WCY` (stop wcy messages)<br/>
|
||||||
|
`UNSET/WWV` (stop wwv messages)<br/>
|
||||||
|
`UNSET/DX` (stop human DX spots)<br/>
|
||||||
|
`SET/SKIMMER RTTY` (enable RTTY RBN spots)
|
||||||
|
|
||||||
|
```
|
||||||
|
-
|
||||||
|
class: "DXCluster"
|
||||||
|
name: "RBN FT4/8"
|
||||||
|
enabled: true
|
||||||
|
host: "s50clx.si"
|
||||||
|
port: 41112
|
||||||
|
login_prompt: "login: "
|
||||||
|
login_callsign: "callsign-13"
|
||||||
|
allow_rbn_spots: true
|
||||||
|
enabled-by-default-in-web-ui: false
|
||||||
|
```
|
||||||
|
|
||||||
|
Telnet to DXSpider and log in with "callsign-13" and execute the following commands:
|
||||||
|
|
||||||
|
`CLEAR/SPOTS ALL` (delete all previous filters)<br/>
|
||||||
|
`UNSET/ANN` (stop announce messages)<br/>
|
||||||
|
`UNSET/WCY` (stop wcy messages)<br/>
|
||||||
|
`UNSET/WWV` (stop wwv messages)<br/>
|
||||||
|
`UNSET/DX` (stop human DX spots)<br/>
|
||||||
|
`SET/SKIMMER FT` (enable FT RBN spots)
|
||||||
|
|
||||||
|
For each callsign-SSID, we also specify our basic information with commands:
|
||||||
|
|
||||||
|
`SET/NAME Spothole10`, Spothole11... etc.<br/>
|
||||||
|
`SET/QTH Cerkno`<br/>
|
||||||
|
`SET/QRA JN66XD`<br/>
|
||||||
|
`SET/HOME S50CLX`
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
## nginx Reverse Proxy configuration
|
||||||
|
|
||||||
|
Web servers generally serve their pages from port 80. However, it's best not to serve Spothole's web interface directly
|
||||||
|
on port 80, as that requires root privileges on a Linux system. It also and prevents us using HTTPS to serve a secure
|
||||||
|
site, since Spothole itself doesn't directly support acting as an HTTPS server. The normal solution to this is to use
|
||||||
|
a "reverse proxy" setup, where a general web server handles HTTP and HTTP requests (to port 80 & 443 respectively), then
|
||||||
|
passes on the request to the back-end application (in this case Spothole). nginx is a common choice for this general web
|
||||||
|
server.
|
||||||
|
|
||||||
|
To set up nginx as a reverse proxy that sits in front of Spothole, first ensure it's installed e.g.
|
||||||
|
`sudo apt install nginx`, and enabled e.g. `sudo systemd enable nginx`.
|
||||||
|
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
server {
|
||||||
|
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
|
||||||
|
location /.well-known/ {
|
||||||
|
alias /var/www/html/.well-known/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load static assets directly from the Spothole static directory
|
||||||
|
location /static/ {
|
||||||
|
alias /home/spothole/spothole/static/;
|
||||||
|
expires 1h;
|
||||||
|
add_header Cache-Control "public, max-age=3600, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SSE endpoints
|
||||||
|
location ~ ^/api/v2/(spots|alerts)/stream/? {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
|
||||||
|
# Remove buffering, remove caching, add suitable timeouts for SSE API calls
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 24h;
|
||||||
|
proxy_send_timeout 24h;
|
||||||
|
proxy_set_header X-Accel-Buffering no;
|
||||||
|
add_header Cache-Control no-store always;
|
||||||
|
|
||||||
|
# Allow cross-origin requests to API
|
||||||
|
proxy_hide_header Access-Control-Allow-Origin;
|
||||||
|
add_header Access-Control-Allow-Origin * always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Other API endpoints
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
|
||||||
|
# Remove buffering, remove caching, add suitable timeouts for API calls
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
add_header Cache-Control no-store always;
|
||||||
|
|
||||||
|
# Allow cross-origin requests to API
|
||||||
|
proxy_hide_header Access-Control-Allow-Origin;
|
||||||
|
add_header Access-Control-Allow-Origin * always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Templated pages
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
One further change you might want to make to the file above is the `add_header Access-Control-Allow-Origin` statements.
|
||||||
|
These are what's used on
|
||||||
|
my own Spothole server to make sure that other third-party web-based software can get the data from my instance, and
|
||||||
|
applies to any endpoint underneath `/api`. If you want
|
||||||
|
*your* Spothole instance to be set up the same way, so that others can write software in JavaScript that can access it,
|
||||||
|
leave this intact. But if you want your Spothole instance to only be usable by scripts running on the web server you
|
||||||
|
write,
|
||||||
|
you can remove these lines. (Note that this doesn't stop other people writing *non-web-based* software that accesses
|
||||||
|
your
|
||||||
|
Spothole API—the enforcement of cross-origin headers only happens within the user's browser. If you need to lock
|
||||||
|
your
|
||||||
|
instance down so that no-one else can access it with *any* software, that's an aspect of nginx or firewall config that
|
||||||
|
you will need
|
||||||
|
to find help with elsewhere.)
|
||||||
|
|
||||||
|
Now, make a symbolic link to enable the site:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /etc/nginx/sites-enabled
|
||||||
|
sudo ln -sf ../sites-available/spothole
|
||||||
|
```
|
||||||
|
|
||||||
|
Test that your nginx config isn't broken using `nginx -t`. If it works, restart nginx with
|
||||||
|
`sudo systemctl restart nginx`.
|
||||||
|
|
||||||
|
If you haven't already done so, set up a DNS entry to make sure requests for your domain name end up at the server
|
||||||
|
that's running Spothole.
|
||||||
|
|
||||||
|
You should now be able to access the web interface by going to the domain from your browser.
|
||||||
|
|
||||||
|
Once that's working, [install certbot](https://certbot.eff.org/instructions?ws=nginx&os=snap) onto your server. Run it
|
||||||
|
as root, and when prompted pick your domain name from the list. After a few seconds, it should successfully provision a
|
||||||
|
certificate and modify your nginx config files automatically. You should then be able to access the site via HTTPS.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
## Running your own copy
|
||||||
|
|
||||||
|
If you want to run a copy of Spothole with different configuration settings than the main instance, you can download it
|
||||||
|
and run it on your own local machine or server.
|
||||||
|
|
||||||
|
You will require Python version 3.8 or later. If you encounter an error about `gdal-config` during the following
|
||||||
|
process, you will also need `libgdal-dev` installed.
|
||||||
|
|
||||||
|
To download and set up Spothole on a Debian server, run the following commands. Other operating systems will likely be
|
||||||
|
similar.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone ssh://git@git.ianrenton.com/ian/spothole.git
|
||||||
|
cd spothole
|
||||||
|
python3 -m venv ./.venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
deactivate
|
||||||
|
cp config-example.yml config.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Then edit `config.yml` in your text editor of choice to set up the software as you like it. Mostly, this will involve
|
||||||
|
enabling or disabling the various providers of spot and alert data.
|
||||||
|
|
||||||
|
By default, all outdoor programme providers are enabled, as is one cluster node and the NG3K DXpedition data. The RBN
|
||||||
|
spot providers are turned off by default due to the volume of traffic from CW/RTTY/FT8 skimmers, and the APRS and Packet
|
||||||
|
spot providers are off by default on the assumption that Spothole users want a spot with a human at the other end of it,
|
||||||
|
but all can be easily re-enabled.
|
||||||
|
|
||||||
|
Other parameters you will want to update include the base URL to your instance, and whether you want to serve a full
|
||||||
|
web-based DX cluster interface or just the API endpoints for client software to use.
|
||||||
|
|
||||||
|
`config.yml` has an entry for a Clublog API key. If provided, this will allow Spothole to retrieve some more information
|
||||||
|
about DX spots. The software will work just fine without it, but you may find a few country flags etc. are less accurate
|
||||||
|
or missing. Clublog API keys are free, but you'll need to get your own by submitting a helpdesk ticket and explaining
|
||||||
|
what you'll use it for. The admin team are happy with the rate of requests made by my Spothole server, so unless you
|
||||||
|
change the source code of yours to radically increase the rate of querying Clublog, I'm sure they will be fine with your
|
||||||
|
server too.
|
||||||
|
|
||||||
|
Once you're happy with the content of `config.yml`, you can proceed to running the software.
|
||||||
|
|
||||||
|
To run the software this time and any future times you want to run it directly from the command line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
python3 spothole.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The software can take a few seconds to start up, mostly because it is downloading an updated file to match callsigns to
|
||||||
|
countries. This is normal, don't panic! Once you see `You can access your copy of Spothole at
|
||||||
|
http://localhost:8080` in the log, your server is good to go.
|
||||||
|
|
||||||
|
If you see some errors on startup, check your configuration, e.g. in case you have specified a port for the web server
|
||||||
|
that is already in use by something else.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
## systemd configuration
|
||||||
|
|
||||||
|
If you want Spothole to run automatically on startup on a Linux distribution that uses `systemd`, follow the
|
||||||
|
instructions here. For distros that don't use `systemd`, or Windows/OSX/etc., you can find generic instructions for your
|
||||||
|
OS online.
|
||||||
|
|
||||||
|
Create a file at `/etc/systemd/system/spothole.service`. Give it the following content, adjusting for the user you want
|
||||||
|
to run it as and the directory in which you have installed it:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Unit]
|
||||||
|
Description=Spothole
|
||||||
|
After=syslog.target network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=spothole
|
||||||
|
WorkingDirectory=/home/spothole/spothole
|
||||||
|
ExecStart=/home/spothole/spothole/.venv/bin/python /home/spothole/spothole/spothole.py --serve-in-foreground
|
||||||
|
Restart=on-abort
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the following:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable spothole
|
||||||
|
sudo systemctl start spothole
|
||||||
|
```
|
||||||
|
|
||||||
|
Check the service has started up correctly with `sudo journalctl -u spothole -f`.
|
||||||
@@ -25,7 +25,7 @@ class HTTPAlertProvider(AlertProvider):
|
|||||||
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
||||||
# subsequent polls, so start() returns immediately and the application can continue starting.
|
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||||
logging.info("Set up query of " + self.name + " alert API every " + str(self._poll_interval) + " seconds.")
|
logging.info("Set up query of " + self.name + " alert API every " + str(self._poll_interval) + " seconds.")
|
||||||
self._thread = Thread(target=self._run, daemon=True)
|
self._thread = Thread(target=self._run, name=f"HTTPAlertProvider-{self.name}")
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from threading import Thread, Event
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from requests import ReadTimeout
|
||||||
|
from requests.exceptions import ConnectionError, ConnectTimeout
|
||||||
|
|
||||||
|
from core.constants import HTTP_HEADERS
|
||||||
|
from core.url_data_cache import URLDataCache
|
||||||
|
from providers.callsigndata.callsign_data_provider import CallsignDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class APIQueryCallsignDataProvider(CallsignDataProvider):
|
||||||
|
"""Generic callsign data provider class for providers that fetch their data from the web on-demand using an API."""
|
||||||
|
|
||||||
|
def __init__(self, name, provider_config, storage):
|
||||||
|
""" Set up the provider."""
|
||||||
|
super().__init__(name, provider_config, storage)
|
||||||
|
|
||||||
|
if self.enabled:
|
||||||
|
self.status = "Ready"
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
pass
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from core.data_store import DATA_STORE
|
||||||
|
|
||||||
|
|
||||||
|
class CallsignDataProvider:
|
||||||
|
"""Generic callsign reference data provider class. Subclasses of this set up the various mechanisms via which
|
||||||
|
Spothole can look up data for callsigns."""
|
||||||
|
|
||||||
|
def __init__(self, name, provider_config, storage):
|
||||||
|
"""Constructor. As well as name and config, provide the storage object from DATA_STORE that will be used to
|
||||||
|
store the result of lookups to speed up future access."""
|
||||||
|
|
||||||
|
self.name = name
|
||||||
|
self.enabled = provider_config["enabled"]
|
||||||
|
self.priority = int(provider_config["priority"])
|
||||||
|
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||||
|
self.status = "Not Started" if self.enabled else "Disabled"
|
||||||
|
self.lookup_count = 0
|
||||||
|
self._storage = storage
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start the provider. This should return immediately after spawning threads to access remote resources, if
|
||||||
|
needed."""
|
||||||
|
|
||||||
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop any threads and prepare for application shutdown"""
|
||||||
|
|
||||||
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
|
|
||||||
|
def lookup(self, callsign, lookup_credentials):
|
||||||
|
"""Looks up data for the provided callsign. Takes a LookupCredentials object, which provides any credentials
|
||||||
|
that have been provided by the user for this session (QRZ.com/HamQTH) to allow us to look up using those
|
||||||
|
services on the user's behalf. (Clublog is looked up using an API key owned by the server and provided in its
|
||||||
|
config file, so users need not provide their own.) Returns a Callsign object with as much data populated as
|
||||||
|
possible. Data is cached internally for a set period of 30 days to avoid the need to request data from servers
|
||||||
|
each time."""
|
||||||
|
|
||||||
|
if self.enabled:
|
||||||
|
if callsign in self._storage:
|
||||||
|
return self._storage[callsign]
|
||||||
|
else:
|
||||||
|
c = self._perform_new_lookup(callsign, lookup_credentials)
|
||||||
|
if c:
|
||||||
|
self._storage.set(callsign, c, expire=DATA_STORE.CALLSIGN_DATA_TTL_SEC)
|
||||||
|
return c
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _perform_new_lookup(self, callsign, lookup_credentials):
|
||||||
|
"""Makes a new request to the data source for callsign data."""
|
||||||
|
|
||||||
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from pyhamtools import LookupLib, Callinfo
|
||||||
|
|
||||||
|
from core.data_store import DATA_STORE
|
||||||
|
from core.utils import get_callsign_object_from_pyhamtools_callinfo
|
||||||
|
from data.callsign import Callsign
|
||||||
|
from providers.callsigndata.api_query_callsign_data_provider import APIQueryCallsignDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class ClublogAPI(APIQueryCallsignDataProvider):
|
||||||
|
"""Callsign data provider for Clublog's API."""
|
||||||
|
|
||||||
|
_callinfo = None
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
# API key required for this provider
|
||||||
|
self._api_key = provider_config.get("api-key", "")
|
||||||
|
if self._api_key != "":
|
||||||
|
lookuplib = LookupLib(lookuptype="clublogapi", apikey=self._api_key)
|
||||||
|
self._callinfo = Callinfo(lookuplib)
|
||||||
|
else:
|
||||||
|
provider_config["enabled"] = False
|
||||||
|
logging.warning(
|
||||||
|
"Clublog XML callsign data provider configured but no api key was provided, this has been disabled.")
|
||||||
|
|
||||||
|
super().__init__("Clublog API", provider_config, DATA_STORE.callsign_data_clublogapi)
|
||||||
|
|
||||||
|
|
||||||
|
def _perform_new_lookup(self, callsign, lookup_credentials):
|
||||||
|
callsign_data = Callsign(call=callsign)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._callinfo:
|
||||||
|
callsign_data = get_callsign_object_from_pyhamtools_callinfo(callsign, self._callinfo)
|
||||||
|
self.status = "OK"
|
||||||
|
self.last_update_time = datetime.now(pytz.UTC)
|
||||||
|
self.lookup_count += 1
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.error("Exception when looking up data from Clublog API", e, exc_info=True)
|
||||||
|
|
||||||
|
return callsign_data
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import gzip
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from pyhamtools import LookupLib, Callinfo
|
||||||
|
|
||||||
|
from core.data_store import DATA_STORE
|
||||||
|
from core.utils import get_callsign_object_from_pyhamtools_callinfo
|
||||||
|
from data.callsign import Callsign
|
||||||
|
from providers.callsigndata.file_download_callsign_data_provider import FileDownloadCallsignDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class ClublogXML(FileDownloadCallsignDataProvider):
|
||||||
|
"""Callsign data provider for ClubLog's Country File, which provides basic callsign to DXCC entity mapping."""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 30
|
||||||
|
DATA_URL = "https://cdn.clublog.org/cty.php"
|
||||||
|
CACHE_PATH_ZIPPED = "cache/cty.xml.gz"
|
||||||
|
CACHE_PATH_UNZIPPED = "cache/cty.xml"
|
||||||
|
_callinfo = None
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
# API key required for this provider
|
||||||
|
self._api_key = provider_config.get("api-key", "")
|
||||||
|
if self._api_key == "":
|
||||||
|
provider_config["enabled"] = False
|
||||||
|
logging.warning(
|
||||||
|
"Clublog XML callsign data provider configured but no api key was provided, this has been disabled.")
|
||||||
|
|
||||||
|
super().__init__("Clublog XML", provider_config, self.DATA_URL + "?api=" + self._api_key,
|
||||||
|
self.CACHE_PATH_ZIPPED, self.POLL_INTERVAL_DAYS, DATA_STORE.callsign_data_clublogxml)
|
||||||
|
|
||||||
|
def _handle_file(self, path):
|
||||||
|
try:
|
||||||
|
# The download from Clublog is gzipped so we need to uncompress that and re-save as a separate file that
|
||||||
|
# the LookupLib can actually use.
|
||||||
|
with gzip.open(path, "rb") as uncompressed:
|
||||||
|
file_content = uncompressed.read()
|
||||||
|
assert isinstance(file_content, bytes)
|
||||||
|
with open(self.CACHE_PATH_UNZIPPED, "wb") as f:
|
||||||
|
f.write(file_content)
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
# Now load the data
|
||||||
|
lookuplib = LookupLib(lookuptype="clublogxml", filename=self.CACHE_PATH_UNZIPPED)
|
||||||
|
self._callinfo = Callinfo(lookuplib)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("Exception when loading Clublog XML.", e, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _perform_new_lookup(self, callsign, lookup_credentials):
|
||||||
|
callsign_data = Callsign(call=callsign)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._callinfo:
|
||||||
|
callsign_data = get_callsign_object_from_pyhamtools_callinfo(callsign, self._callinfo)
|
||||||
|
self.status = "OK"
|
||||||
|
self.lookup_count += 1
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.error("Exception when looking up data from Clublog XML data", e, exc_info=True)
|
||||||
|
|
||||||
|
return callsign_data
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from pyhamtools import LookupLib, Callinfo
|
||||||
|
|
||||||
|
from core.data_store import DATA_STORE
|
||||||
|
from core.utils import get_callsign_object_from_pyhamtools_callinfo
|
||||||
|
from data.callsign import Callsign
|
||||||
|
from providers.callsigndata.file_download_callsign_data_provider import FileDownloadCallsignDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class CountryFiles(FileDownloadCallsignDataProvider):
|
||||||
|
"""Callsign data provider for Country-files.com, which provides basic callsign to DXCC entity mapping."""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 30
|
||||||
|
DATA_URL = "https://www.country-files.com/cty/cty.plist"
|
||||||
|
CACHE_PATH = "cache/cty.plist"
|
||||||
|
_callinfo = None
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__("CountryFiles.com", provider_config, self.DATA_URL, self.CACHE_PATH, self.POLL_INTERVAL_DAYS,
|
||||||
|
DATA_STORE.callsign_data_countryfiles)
|
||||||
|
|
||||||
|
def _handle_file(self, path):
|
||||||
|
try:
|
||||||
|
lookuplib = LookupLib(lookuptype="countryfile", filename=path)
|
||||||
|
self._callinfo = Callinfo(lookuplib)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("Exception when loading Country Files cty.plist.", e, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _perform_new_lookup(self, callsign, lookup_credentials):
|
||||||
|
callsign_data = Callsign(call=callsign)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._callinfo:
|
||||||
|
callsign_data = get_callsign_object_from_pyhamtools_callinfo(callsign, self._callinfo)
|
||||||
|
self.status = "OK"
|
||||||
|
self.lookup_count += 1
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.error("Exception when looking up data from Country file", e, exc_info=True)
|
||||||
|
|
||||||
|
return callsign_data
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from threading import Thread, Event
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from requests import ReadTimeout
|
||||||
|
from requests.exceptions import ConnectionError, ConnectTimeout
|
||||||
|
|
||||||
|
from core.constants import HTTP_HEADERS
|
||||||
|
from core.url_data_cache import URLDataCache
|
||||||
|
from providers.callsigndata.callsign_data_provider import CallsignDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class FileDownloadCallsignDataProvider(CallsignDataProvider):
|
||||||
|
"""Generic callsign data provider class for providers that fetch their data from the web by downloading a file."""
|
||||||
|
|
||||||
|
def __init__(self, name, provider_config, url, cache_file_path, poll_interval, storage):
|
||||||
|
""" Set up the provider, note poll_interval is in *days*."""
|
||||||
|
super().__init__(name, provider_config, storage)
|
||||||
|
self._url = url
|
||||||
|
self._cache_file_path = cache_file_path
|
||||||
|
self._poll_interval = poll_interval
|
||||||
|
self._thread = None
|
||||||
|
self._stop_event = Event()
|
||||||
|
self._url_data_cache = URLDataCache("callsigndata_" + name)
|
||||||
|
|
||||||
|
if self.enabled:
|
||||||
|
self.status = "Ready"
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
||||||
|
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||||
|
logging.info(
|
||||||
|
"Set up query of " + self.name + " callsign reference data every " + str(self._poll_interval) + " days.")
|
||||||
|
self._thread = Thread(target=self._run, name=f"FileDownloadCallsignDataProvider-{self.name}")
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop_event.set()
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
while True:
|
||||||
|
self._poll()
|
||||||
|
if self._stop_event.wait(timeout=self._poll_interval * 60 * 60 * 24):
|
||||||
|
break
|
||||||
|
|
||||||
|
def _poll(self):
|
||||||
|
try:
|
||||||
|
# Request the file. Use the data cache (with a TTL of 1 day) here, not as the main mechanism for
|
||||||
|
# caching, but just so continual restarts of the software during testing don't hammer the servers.
|
||||||
|
logging.debug("Downloading " + self.name + " callsign reference data...")
|
||||||
|
http_response = self._url_data_cache.get(self._url, headers=HTTP_HEADERS)
|
||||||
|
# Check response code was good
|
||||||
|
if http_response.ok:
|
||||||
|
# Save the data to a local file
|
||||||
|
with open(self._cache_file_path, "wb") as f:
|
||||||
|
f.write(http_response.content)
|
||||||
|
f.flush()
|
||||||
|
# Pass off to the subclass for processing
|
||||||
|
ok = self._handle_file(self._cache_file_path)
|
||||||
|
if ok:
|
||||||
|
self.status = "OK"
|
||||||
|
self.last_update_time = datetime.now(pytz.UTC)
|
||||||
|
logging.info("Updated callsign reference data from " + self.name)
|
||||||
|
else:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.warning(f"Error updating callsign reference data from {self.name}.")
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.warning(f"HTTP {http_response.status_code} when downloading callsign reference data from {self.name}.")
|
||||||
|
|
||||||
|
except ConnectionError:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.warning(f"Connection error when downloading callsign reference data from {self.name}.")
|
||||||
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
self.status = "Error"
|
||||||
|
logging.warning(f"Timeout when downloading callsign reference data from {self.name}.")
|
||||||
|
except Exception:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.exception("Exception in callsign reference data provider (" + self.name + ")")
|
||||||
|
self._stop_event.wait(timeout=1)
|
||||||
|
|
||||||
|
def _handle_file(self, path):
|
||||||
|
"""Handle an updated file downloaded from the server. Return true if successful, false otherwise."""
|
||||||
|
|
||||||
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import logging
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
import xmltodict
|
||||||
|
from pyhamtools import callinfo
|
||||||
|
from requests import ConnectTimeout, ReadTimeout
|
||||||
|
from requests_cache import CachedSession
|
||||||
|
|
||||||
|
from core.config import SERVER_OWNER_CALLSIGN
|
||||||
|
from core.constants import HTTP_HEADERS, SOFTWARE_VERSION
|
||||||
|
from core.data_store import DATA_STORE, CACHE_DIR
|
||||||
|
from core.url_data_cache import URLDataCache
|
||||||
|
from data.callsign import Callsign
|
||||||
|
from providers.callsigndata.api_query_callsign_data_provider import APIQueryCallsignDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class HamQTH(APIQueryCallsignDataProvider):
|
||||||
|
"""Callsign data provider for HamQTH."""
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__("HamQTH", provider_config, DATA_STORE.callsign_data_hamqth)
|
||||||
|
self._HAMQTH_BASE_URL = "https://www.hamqth.com/xml.php"
|
||||||
|
self._PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_CALLSIGN).replace(" ", "_")
|
||||||
|
self._URL_DATA_CACHE = URLDataCache("hamqth")
|
||||||
|
# Separate URL cache for session key lookups. Once a session key is returned from logging in with a username
|
||||||
|
# and password, this is valid for an hour, so our cache stores this specifically for 55 minutes.
|
||||||
|
self._CREDENTIALS_CACHE = CachedSession(CACHE_DIR + "/urls/hamqth-creds",
|
||||||
|
expire_after=timedelta(minutes=55))
|
||||||
|
|
||||||
|
def _perform_new_lookup(self, callsign, lookup_credentials):
|
||||||
|
# If we don't have HamQTH credentials, skip this lookup Return None so we don't *cache* the lack of data, because
|
||||||
|
# # someone might provide credentials next time around.
|
||||||
|
if not lookup_credentials or not ((lookup_credentials.hamqth_username and lookup_credentials.hamqth_password)
|
||||||
|
or lookup_credentials.hamqth_session_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Obtain session key from credentials, by looking it up from username & password if necessary.
|
||||||
|
session_id = None
|
||||||
|
if lookup_credentials.hamqth_session_id:
|
||||||
|
session_id = lookup_credentials.hamqth_session_id
|
||||||
|
elif lookup_credentials.hamqth_username and lookup_credentials.hamqth_password:
|
||||||
|
try:
|
||||||
|
session_data = self._CREDENTIALS_CACHE.get(
|
||||||
|
self._HAMQTH_BASE_URL + "?u=" + urllib.parse.quote_plus(lookup_credentials.hamqth_username) +
|
||||||
|
"&p=" + urllib.parse.quote_plus(lookup_credentials.hamqth_password),
|
||||||
|
headers=HTTP_HEADERS).content
|
||||||
|
dict_data = xmltodict.parse(session_data)
|
||||||
|
if "session_id" in dict_data["HamQTH"]["session"]:
|
||||||
|
session_id = str(dict_data["HamQTH"]["session"]["session_id"])
|
||||||
|
else:
|
||||||
|
# Log this failure at debug level only, not our problem if user entered the wrong password.
|
||||||
|
logging.debug("HamQTH login details incorrect, failed to look up with HamQTH.")
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
logging.error("Exception when getting HamQTH session key")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not session_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
|
||||||
|
calls_to_try = [callsign]
|
||||||
|
try:
|
||||||
|
home_call = callinfo.Callinfo.get_homecall(callsign)
|
||||||
|
if home_call != callsign:
|
||||||
|
calls_to_try.append(home_call)
|
||||||
|
except ValueError:
|
||||||
|
logging.debug("Could not look up home call for callsign %s", callsign)
|
||||||
|
|
||||||
|
# Try looking up each call using the API
|
||||||
|
for lookup_call in calls_to_try:
|
||||||
|
try:
|
||||||
|
response = self._URL_DATA_CACHE.get(
|
||||||
|
self._HAMQTH_BASE_URL + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
|
||||||
|
lookup_call) + "&prg=" + self._PRG, headers=HTTP_HEADERS, timeout=10)
|
||||||
|
if response.ok:
|
||||||
|
# Found data, convert it to our object and return it
|
||||||
|
data = xmltodict.parse(response.content)["HamQTH"]["search"]
|
||||||
|
self.status = "OK"
|
||||||
|
self.last_update_time = datetime.now(pytz.UTC)
|
||||||
|
self.lookup_count += 1
|
||||||
|
return self.hamqth_response_to_callsign(callsign, data)
|
||||||
|
|
||||||
|
elif not response.from_cache:
|
||||||
|
logging.warning("HTTP %d looking up callsign %s using HamQTH", response.status_code,
|
||||||
|
lookup_call)
|
||||||
|
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
continue
|
||||||
|
except ConnectionError:
|
||||||
|
logging.warning(f"Connection error when looking up callsign %s using HamQTH", lookup_call)
|
||||||
|
continue
|
||||||
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
logging.warning(f"Timeout when looking up callsign %s using HamQTH", lookup_call)
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
logging.error("Exception when looking up callsign %s using HamQTH", lookup_call, exc_info=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Not found in HamQTH; return a Callsign object with no data so we cache that and don't keep retrying
|
||||||
|
return Callsign(call=callsign)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.error("Exception when looking up data from HamQTH", e, exc_info=True)
|
||||||
|
# Return None, this won't be cached so we will be asked to query data again for this call next time.
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hamqth_response_to_callsign(callsign, data):
|
||||||
|
"""Convert the "Callsign" block in HamQTH's API response to our own Callsign object."""
|
||||||
|
|
||||||
|
# Check for sensible latitudes
|
||||||
|
lat = None
|
||||||
|
lon = None
|
||||||
|
if "latitude" in data and "longitude" in data and (
|
||||||
|
float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float(
|
||||||
|
data["latitude"]) < 89.9:
|
||||||
|
lat = float(data["latitude"])
|
||||||
|
lon = float(data["longitude"])
|
||||||
|
|
||||||
|
# Check for sensible grids
|
||||||
|
grid = None
|
||||||
|
if "grid" in data and not data["grid"].startswith("AA00"):
|
||||||
|
grid = data["grid"]
|
||||||
|
|
||||||
|
return Callsign(call=callsign,
|
||||||
|
home_call=callinfo.Callinfo.get_homecall(callsign),
|
||||||
|
name=data["nick"] if "nick" in data else None,
|
||||||
|
qth=data["qth"] if "qth" in data else None,
|
||||||
|
country=data["country"] if "country" in data else None,
|
||||||
|
continent=data["continent"] if "continent" in data else None,
|
||||||
|
latitude=lat,
|
||||||
|
longitude=lon,
|
||||||
|
grid=grid,
|
||||||
|
dxcc_id=int(data["adif"]) if "adif" in data else None,
|
||||||
|
cq_zone=int(data["cq"]) if "cq" in data else None,
|
||||||
|
itu_zone=int(data["itu"]) if "itu" in data else None,
|
||||||
|
location_source="HOME QTH")
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import logging
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
import xmltodict
|
||||||
|
from pyhamtools import callinfo
|
||||||
|
from requests import ConnectTimeout, ReadTimeout
|
||||||
|
from requests_cache import CachedSession
|
||||||
|
|
||||||
|
from core.constants import HTTP_HEADERS
|
||||||
|
from core.data_store import DATA_STORE, CACHE_DIR
|
||||||
|
from core.url_data_cache import URLDataCache
|
||||||
|
from data.callsign import Callsign
|
||||||
|
from providers.callsigndata.api_query_callsign_data_provider import APIQueryCallsignDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class QRZ(APIQueryCallsignDataProvider):
|
||||||
|
"""Callsign data provider for QRZ.com."""
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__("QRZ.com", provider_config, DATA_STORE.callsign_data_qrz)
|
||||||
|
self._QRZ_BASE_URL = "https://xmldata.qrz.com/xml/current/"
|
||||||
|
self._URL_DATA_CACHE = URLDataCache("qrz")
|
||||||
|
# Separate URL cache for session key lookups. Once a session key is returned from logging in with a username
|
||||||
|
# and password, this is valid for an hour, so our cache stores this specifically for 55 minutes.
|
||||||
|
self._CREDENTIALS_CACHE = CachedSession(CACHE_DIR + "/urls/qrz-creds",
|
||||||
|
expire_after=timedelta(minutes=55))
|
||||||
|
|
||||||
|
def _perform_new_lookup(self, callsign, lookup_credentials):
|
||||||
|
# If we don't have QRZ credentials, skip this lookup. Return None so we don't *cache* the lack of data, because
|
||||||
|
# someone might provide credentials next time around.
|
||||||
|
if not lookup_credentials or not ((lookup_credentials.qrz_username and lookup_credentials.qrz_password)
|
||||||
|
or lookup_credentials.qrz_session_key):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Obtain session key from credentials, by looking it up from username & password if necessary.
|
||||||
|
session_key = None
|
||||||
|
if lookup_credentials.qrz_session_key:
|
||||||
|
session_key = lookup_credentials.qrz_session_key
|
||||||
|
elif lookup_credentials.qrz_username and lookup_credentials.qrz_password:
|
||||||
|
try:
|
||||||
|
login_response = self._CREDENTIALS_CACHE.get(
|
||||||
|
self._QRZ_BASE_URL + "?username=" + urllib.parse.quote_plus(lookup_credentials.qrz_username) +
|
||||||
|
"&password=" + urllib.parse.quote_plus(lookup_credentials.qrz_password) + "&agent=spothole",
|
||||||
|
headers=HTTP_HEADERS).content
|
||||||
|
login_data = xmltodict.parse(login_response)
|
||||||
|
session = login_data.get("QRZDatabase", {}).get("Session", {})
|
||||||
|
if "Key" in session:
|
||||||
|
session_key = str(session["Key"])
|
||||||
|
else:
|
||||||
|
# Log this failure at debug level only, not our problem if user entered the wrong password.
|
||||||
|
logging.debug("QRZ.com login details incorrect, failed to look up with QRZ.")
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
logging.error("Exception when getting QRZ.com session key")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not session_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
|
||||||
|
calls_to_try = [callsign]
|
||||||
|
try:
|
||||||
|
home_call = callinfo.Callinfo.get_homecall(callsign)
|
||||||
|
if home_call != callsign:
|
||||||
|
calls_to_try.append(home_call)
|
||||||
|
except ValueError:
|
||||||
|
logging.debug("Could not look up home call for callsign %s", callsign)
|
||||||
|
|
||||||
|
# Try looking up each call using the API
|
||||||
|
for lookup_call in calls_to_try:
|
||||||
|
try:
|
||||||
|
response = self._URL_DATA_CACHE.get(
|
||||||
|
self._QRZ_BASE_URL + "?s=" + session_key + "&callsign=" + urllib.parse.quote_plus(lookup_call),
|
||||||
|
headers=HTTP_HEADERS, timeout=10)
|
||||||
|
if response.ok:
|
||||||
|
qrz_response = xmltodict.parse(response.content).get("QRZDatabase", {})
|
||||||
|
if qrz_response:
|
||||||
|
if "Callsign" in qrz_response:
|
||||||
|
qrz_data = qrz_response.get("Callsign")
|
||||||
|
self.status = "OK"
|
||||||
|
self.last_update_time = datetime.now(pytz.UTC)
|
||||||
|
self.lookup_count += 1
|
||||||
|
# Found data, convert it to our object and return it
|
||||||
|
return self.qrz_response_to_callsign(callsign, qrz_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):
|
||||||
|
continue
|
||||||
|
except ConnectionError:
|
||||||
|
logging.warning(f"Connection error when looking up callsign %s using QRZ", lookup_call)
|
||||||
|
continue
|
||||||
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
logging.warning(f"Timeout when looking up callsign %s using QRZ.", lookup_call)
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
logging.error("Exception when looking up callsign %s using QRZ", lookup_call, exc_info=True)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Not found in QRZ; return a Callsign object with no data so we cache that and don't keep retrying
|
||||||
|
return Callsign(call=callsign)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.error("Exception when looking up data from QRZ.com", e, exc_info=True)
|
||||||
|
# Return None, this won't be cached so we will be asked to query data again for this call next time.
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def qrz_response_to_callsign(callsign, data):
|
||||||
|
"""Convert the "Callsign" block in QRZ's API response to our own Callsign object."""
|
||||||
|
|
||||||
|
# Get a name
|
||||||
|
name = None
|
||||||
|
if "name_fmt" in data:
|
||||||
|
name = data["name_fmt"]
|
||||||
|
if "fname" in data:
|
||||||
|
name = data["fname"]
|
||||||
|
if "nick" in data:
|
||||||
|
name = name + " \"" + data["nick"] + "\""
|
||||||
|
if "name" in data:
|
||||||
|
name = name + " " + data["name"]
|
||||||
|
|
||||||
|
# Check for sensible latitudes
|
||||||
|
lat = None
|
||||||
|
lon = None
|
||||||
|
if "latitude" in data and "longitude" in data and (
|
||||||
|
float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float(
|
||||||
|
data["latitude"]) < 89.9:
|
||||||
|
lat = float(data["latitude"])
|
||||||
|
lon = float(data["longitude"])
|
||||||
|
|
||||||
|
# Check for sensible grids
|
||||||
|
grid = None
|
||||||
|
if "grid" in data and not data["grid"].startswith("AA00"):
|
||||||
|
grid = data["grid"]
|
||||||
|
|
||||||
|
return Callsign(call=callsign,
|
||||||
|
home_call=callinfo.Callinfo.get_homecall(callsign),
|
||||||
|
name=name,
|
||||||
|
qth=data["addr2"] if "addr2" in data else None,
|
||||||
|
country=data["country"] if "country" in data else None,
|
||||||
|
continent=data["continent"] if "continent" in data else None,
|
||||||
|
latitude=lat,
|
||||||
|
longitude=lon,
|
||||||
|
grid=grid,
|
||||||
|
dxcc_id=int(data["adif"]) if "adif" in data else None,
|
||||||
|
cq_zone=int(data["cqzone"]) if "cqzone" in data else None,
|
||||||
|
itu_zone=int(data["ituzone"]) if "ituzone" in data else None,
|
||||||
|
location_source="HOME QTH")
|
||||||
@@ -28,7 +28,7 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
|
|||||||
# subsequent polls, so start() returns immediately and the application can continue starting.
|
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||||
logging.info(
|
logging.info(
|
||||||
"Set up query of " + self.sig_name + " SIG ref data every " + str(self._poll_interval) + " days.")
|
"Set up query of " + self.sig_name + " SIG ref data every " + str(self._poll_interval) + " days.")
|
||||||
self._thread = Thread(target=self._run, daemon=True)
|
self._thread = Thread(target=self._run, name=f"FileDownloadSIGRefDataProvider-{self.sig_name}")
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class GIROIonosonde(SolarConditionsProvider):
|
|||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
logging.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
|
logging.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
|
||||||
self._thread = Thread(target=self._run, daemon=True)
|
self._thread = Thread(target=self._run, name="GIROIonosondeDataProvider")
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
|
|||||||
def start(self):
|
def start(self):
|
||||||
logging.info(
|
logging.info(
|
||||||
"Set up query of " + self.name + " solar conditions API every " + str(self._poll_interval) + " seconds.")
|
"Set up query of " + self.name + " solar conditions API every " + str(self._poll_interval) + " seconds.")
|
||||||
self._thread = Thread(target=self._run, daemon=True)
|
self._thread = Thread(target=self._run, name=f"HTTPSolarConditionsProvider-{self.name}")
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class KC2GProp(SolarConditionsProvider):
|
|||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
logging.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.")
|
logging.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.")
|
||||||
self._thread = Thread(target=self._run, daemon=True)
|
self._thread = Thread(target=self._run, name="KC2GPropProvider")
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class APRSIS(SpotProvider):
|
|||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__("APRS-IS", provider_config)
|
super().__init__("APRS-IS", provider_config)
|
||||||
self._thread = Thread(target=self._connect)
|
self._thread = Thread(target=self._connect, name="APRSISSpotProvider")
|
||||||
self._thread.daemon = True
|
self._thread.daemon = True
|
||||||
self._aprsis = None
|
self._aprsis = None
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class DXCluster(SpotProvider):
|
|||||||
self._allow_rbn_spots = provider_config["allow_rbn_spots"] if "allow_rbn_spots" in provider_config else False
|
self._allow_rbn_spots = provider_config["allow_rbn_spots"] if "allow_rbn_spots" in provider_config else False
|
||||||
self._spot_line_pattern = self._LINE_PATTERN_ALLOW_RBN if self._allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN
|
self._spot_line_pattern = self._LINE_PATTERN_ALLOW_RBN if self._allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN
|
||||||
self._telnet = None
|
self._telnet = None
|
||||||
self._thread = Thread(target=self._handle)
|
self._thread = Thread(target=self._handle, name=f"DXClusterSpotProvider-{self.name}")
|
||||||
self._thread.daemon = True
|
self._thread.daemon = True
|
||||||
self._running = True
|
self._running = True
|
||||||
|
|
||||||
|
|||||||
+16
-6
@@ -35,6 +35,10 @@ class GMA(HTTPSpotProvider):
|
|||||||
if "RCD" in http_response.json():
|
if "RCD" in http_response.json():
|
||||||
for source_spot in http_response.json()["RCD"]:
|
for source_spot in http_response.json()["RCD"]:
|
||||||
# Convert to our spot format
|
# Convert to our spot format
|
||||||
|
# Seen GMA spots with no (or empty) lat/lon
|
||||||
|
lat = float(source_spot["LAT"]) if (source_spot["LAT"] and source_spot["LAT"] != "") else None
|
||||||
|
lon = float(source_spot["LON"]) if (source_spot["LON"] and source_spot["LON"] != "") else None
|
||||||
|
|
||||||
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(),
|
||||||
@@ -44,14 +48,12 @@ class GMA(HTTPSpotProvider):
|
|||||||
# 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,
|
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"], latitude=lat,
|
||||||
|
longitude=lon)],
|
||||||
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=lat,
|
||||||
dx_latitude=float(source_spot["LAT"]) if (
|
dx_longitude=lon,
|
||||||
source_spot["LAT"] and source_spot["LAT"] != "") else None,
|
|
||||||
dx_longitude=float(source_spot["LON"]) if (
|
|
||||||
source_spot["LON"] and source_spot["LON"] != "") else None,
|
|
||||||
qrt=source_spot["QRG"] == "QRT")
|
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.
|
||||||
@@ -113,3 +115,11 @@ class GMA(HTTPSpotProvider):
|
|||||||
logging.warning(f"The GMA API returned an unexpected response (HTTP {http_response.status_code}).")
|
logging.warning(f"The GMA API returned an unexpected response (HTTP {http_response.status_code}).")
|
||||||
|
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
|
def can_submit_spot(self, sig):
|
||||||
|
return sig == "GMA"
|
||||||
|
|
||||||
|
def submit_spot(self, spot, credentials):
|
||||||
|
# TODO: Implement.
|
||||||
|
# Spotting to GMA is documented: https://www.cqgma.org/api/doc/apigma_spot.pdf We (or the user) need a GMA account, and to send the password in plaintext(!!)
|
||||||
|
raise NotImplementedError("GMA upstream spot submission is not yet implemented")
|
||||||
|
|||||||
+10
-1
@@ -59,7 +59,8 @@ class HEMA(HTTPSpotProvider):
|
|||||||
mode=freq_mode_match.group(2).upper(),
|
mode=freq_mode_match.group(2).upper(),
|
||||||
comment=spotter_comment_match.group(2),
|
comment=spotter_comment_match.group(2),
|
||||||
sig="HEMA",
|
sig="HEMA",
|
||||||
sig_refs=[SIGRef(id=spot_items[3].upper(), sig="HEMA", name=spot_items[4])],
|
sig_refs=[SIGRef(id=spot_items[3].upper(), sig="HEMA", name=spot_items[4],
|
||||||
|
latitude=float(spot_items[7]), longitude=float(spot_items[8]))],
|
||||||
time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M").replace(
|
time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M").replace(
|
||||||
tzinfo=pytz.UTC).timestamp(),
|
tzinfo=pytz.UTC).timestamp(),
|
||||||
dx_latitude=float(spot_items[7]),
|
dx_latitude=float(spot_items[7]),
|
||||||
@@ -73,3 +74,11 @@ class HEMA(HTTPSpotProvider):
|
|||||||
except ConnectionError:
|
except ConnectionError:
|
||||||
logging.warning("Connection error when accessing HEMA spots API.")
|
logging.warning("Connection error when accessing HEMA spots API.")
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
|
def can_submit_spot(self, sig):
|
||||||
|
return sig == "HEMA"
|
||||||
|
|
||||||
|
def submit_spot(self, spot, credentials):
|
||||||
|
# TODO: Implement. Currently blocked awaiting their API team to make a change to allow us to spot with a
|
||||||
|
# reference and not a reference *number*.
|
||||||
|
raise NotImplementedError("HEMA upstream spot submission is not yet implemented")
|
||||||
|
|||||||
@@ -21,21 +21,30 @@ class HTTPSpotProvider(SpotProvider):
|
|||||||
self._poll_interval = poll_interval
|
self._poll_interval = poll_interval
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._stop_event = Event()
|
self._stop_event = Event()
|
||||||
|
self._wakeup_event = Event()
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
||||||
# subsequent polls, so start() returns immediately and the application can continue starting.
|
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||||
logging.info("Set up query of " + self.name + " spot API every " + str(self._poll_interval) + " seconds.")
|
logging.info("Set up query of " + self.name + " spot API every " + str(self._poll_interval) + " seconds.")
|
||||||
self._thread = Thread(target=self._run, daemon=True)
|
self._thread = Thread(target=self._run, name=f"HTTPSpotProvider-{self.name}")
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
self._wakeup_event.set()
|
||||||
|
|
||||||
|
def force_poll(self):
|
||||||
|
"""Trigger an immediate poll without waiting for the normal interval."""
|
||||||
|
|
||||||
|
self._wakeup_event.set()
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while True:
|
while True:
|
||||||
|
self._wakeup_event.clear()
|
||||||
self._poll()
|
self._poll()
|
||||||
if self._stop_event.wait(timeout=self._poll_interval):
|
self._wakeup_event.wait(timeout=self._poll_interval)
|
||||||
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
def _poll(self):
|
def _poll(self):
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import re
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from core.constants import HTTP_HEADERS
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||||
@@ -14,7 +16,9 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
|
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
SPOTS_URL = "https://www.parksnpeaks.org/api/ALL"
|
SPOTS_URL = "https://www.parksnpeaks.org/api/ALL"
|
||||||
|
SUBMIT_URL = "https://www.parksnpeaks.org/api/SPOT/"
|
||||||
SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv"
|
SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv"
|
||||||
|
SUBMITTABLE_SIGS = ["POTA", "SOTA", "WWFF", "HEMA", "WOTA", "ZLOTA", "SIOTA", "KRMNPA"]
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__("ParksNPeaks", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("ParksNPeaks", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
@@ -63,3 +67,28 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
# Add new spot to the list
|
# Add new spot to the list
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
|
def can_submit_spot(self, sig):
|
||||||
|
return sig in self.SUBMITTABLE_SIGS
|
||||||
|
|
||||||
|
def submit_spot(self, spot, credentials):
|
||||||
|
# TODO test this works
|
||||||
|
user_id = credentials.get("user_id", "")
|
||||||
|
api_key = credentials.get("api_key", "")
|
||||||
|
if not user_id or not api_key:
|
||||||
|
raise ValueError(
|
||||||
|
"Parks N Peaks user ID and API key are required. Get yours from your Parks N Peaks account.")
|
||||||
|
sig_ref = spot.sig_refs[0].id if spot.sig_refs else ""
|
||||||
|
body = {
|
||||||
|
"actClass": spot.sig or "",
|
||||||
|
"actCallsign": spot.dx_call,
|
||||||
|
"actSite": sig_ref,
|
||||||
|
"mode": spot.mode or "",
|
||||||
|
"freq": str(spot.freq / 1000000.0),
|
||||||
|
"comments": spot.comment or "",
|
||||||
|
"userID": user_id,
|
||||||
|
"APIKey": api_key,
|
||||||
|
}
|
||||||
|
response = requests.post(self.SUBMIT_URL, json=body, headers=HTTP_HEADERS, timeout=(5, 30))
|
||||||
|
if not response.ok:
|
||||||
|
raise RuntimeError("Parks N Peaks API returned " + str(response.status_code) + ": " + response.text)
|
||||||
|
|||||||
+27
-1
@@ -1,7 +1,9 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from core.constants import HTTP_HEADERS
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||||
@@ -12,6 +14,7 @@ class POTA(HTTPSpotProvider):
|
|||||||
|
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
SPOTS_URL = "https://api.pota.app/spot/activator"
|
SPOTS_URL = "https://api.pota.app/spot/activator"
|
||||||
|
SUBMIT_URL = "https://api.pota.app/spot"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__("POTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("POTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
@@ -29,7 +32,8 @@ class POTA(HTTPSpotProvider):
|
|||||||
mode=source_spot["mode"].upper(),
|
mode=source_spot["mode"].upper(),
|
||||||
comment=source_spot["comments"],
|
comment=source_spot["comments"],
|
||||||
sig="POTA",
|
sig="POTA",
|
||||||
sig_refs=[SIGRef(id=source_spot["reference"], sig="POTA", name=source_spot["name"])],
|
sig_refs=[SIGRef(id=source_spot["reference"], sig="POTA", name=source_spot["name"],
|
||||||
|
latitude=source_spot["latitude"], longitude=source_spot["longitude"])],
|
||||||
time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S").replace(
|
time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S").replace(
|
||||||
tzinfo=pytz.UTC).timestamp(),
|
tzinfo=pytz.UTC).timestamp(),
|
||||||
dx_grid=source_spot["grid6"],
|
dx_grid=source_spot["grid6"],
|
||||||
@@ -40,3 +44,25 @@ class POTA(HTTPSpotProvider):
|
|||||||
# that for us.
|
# that for us.
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
|
def can_submit_spot(self, sig):
|
||||||
|
return sig == "POTA"
|
||||||
|
|
||||||
|
def submit_spot(self, spot, credentials):
|
||||||
|
sig_ref = spot.sig_refs[0].id if spot.sig_refs else None
|
||||||
|
if sig_ref:
|
||||||
|
body = {
|
||||||
|
"activator": spot.dx_call,
|
||||||
|
"spotter": spot.de_call,
|
||||||
|
"frequency": str(spot.freq / 1000.0),
|
||||||
|
"mode": spot.mode or "",
|
||||||
|
"reference": sig_ref,
|
||||||
|
"comments": spot.comment or "",
|
||||||
|
"source": "Spothole",
|
||||||
|
}
|
||||||
|
headers = {**HTTP_HEADERS, "Content-Type": "application/json"}
|
||||||
|
response = requests.post(self.SUBMIT_URL, json=body, headers=headers, timeout=(5, 30))
|
||||||
|
if not response.ok:
|
||||||
|
raise RuntimeError("POTA API returned " + str(response.status_code) + ": " + response.text)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("Park reference is required for submitting POTA spots.")
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class RBN(SpotProvider):
|
|||||||
super().__init__(name, provider_config)
|
super().__init__(name, provider_config)
|
||||||
self._port = provider_config["port"]
|
self._port = provider_config["port"]
|
||||||
self._telnet = None
|
self._telnet = None
|
||||||
self._thread = Thread(target=self._handle)
|
self._thread = Thread(target=self._handle, name=f"RBNSpotProvider-{self.name}")
|
||||||
self._thread.daemon = True
|
self._thread.daemon = True
|
||||||
self._running = True
|
self._running = True
|
||||||
|
|
||||||
|
|||||||
+52
-2
@@ -4,7 +4,7 @@ from datetime import datetime
|
|||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS, SSB_SUB_MODES, DV_SUB_MODES
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||||
@@ -20,6 +20,9 @@ class SOTA(HTTPSpotProvider):
|
|||||||
EPOCH_URL = "https://api-db2.sota.org.uk/api/spots/epoch"
|
EPOCH_URL = "https://api-db2.sota.org.uk/api/spots/epoch"
|
||||||
SPOTS_URL = "https://api-db2.sota.org.uk/api/spots/60/all/all"
|
SPOTS_URL = "https://api-db2.sota.org.uk/api/spots/60/all/all"
|
||||||
|
|
||||||
|
SUBMIT_URL = "https://api-db2.sota.org.uk/api/spots"
|
||||||
|
VALID_MODES = ["AM", "CW", "Data", "DV", "FM", "SSB"]
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__("SOTA", provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("SOTA", provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
|
||||||
self._api_epoch = ""
|
self._api_epoch = ""
|
||||||
@@ -49,8 +52,12 @@ class SOTA(HTTPSpotProvider):
|
|||||||
mode=source_spot["mode"].upper(),
|
mode=source_spot["mode"].upper(),
|
||||||
comment=source_spot["comments"],
|
comment=source_spot["comments"],
|
||||||
sig="SOTA",
|
sig="SOTA",
|
||||||
sig_refs=[SIGRef(id=source_spot["summitCode"], sig="SOTA", name=source_spot["summitName"],
|
sig_refs=[SIGRef(id=source_spot["summitCode"], sig="SOTA",
|
||||||
|
name=source_spot["summitName"], latitude=source_spot["latitude"],
|
||||||
|
longitude=source_spot["longitude"],
|
||||||
activation_score=source_spot["points"])],
|
activation_score=source_spot["points"])],
|
||||||
|
dx_latitude=source_spot["latitude"],
|
||||||
|
dx_longitude=source_spot["longitude"],
|
||||||
time=datetime.fromisoformat(source_spot["timeStamp"].replace("Z", "+00:00")).timestamp())
|
time=datetime.fromisoformat(source_spot["timeStamp"].replace("Z", "+00:00")).timestamp())
|
||||||
|
|
||||||
# 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
|
||||||
@@ -61,3 +68,46 @@ class SOTA(HTTPSpotProvider):
|
|||||||
except (ConnectTimeout, ReadTimeout):
|
except (ConnectTimeout, ReadTimeout):
|
||||||
logging.warning(f"Timeout when accessing SOTA spots API.")
|
logging.warning(f"Timeout when accessing SOTA spots API.")
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
|
def can_submit_spot(self, sig):
|
||||||
|
return sig == "SOTA"
|
||||||
|
|
||||||
|
def submit_spot(self, spot, credentials):
|
||||||
|
# TODO test this method works
|
||||||
|
access_token = credentials.get("access_token", "")
|
||||||
|
id_token = credentials.get("id_token", "")
|
||||||
|
if not access_token or not id_token:
|
||||||
|
raise ValueError("SOTA API tokens are required. Please log into SOTA in order to spot to it.")
|
||||||
|
sig_ref = spot.sig_refs[0].id if spot.sig_refs else ""
|
||||||
|
if sig_ref:
|
||||||
|
# Split reference into association and summit codes
|
||||||
|
ref_split = sig_ref.split("/")
|
||||||
|
|
||||||
|
# Figure out a valid mode. Borrowed this from PoLo :)
|
||||||
|
# https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js
|
||||||
|
mode = spot.mode
|
||||||
|
if mode and mode not in self.VALID_MODES:
|
||||||
|
if mode in SSB_SUB_MODES:
|
||||||
|
mode = "SSB"
|
||||||
|
elif mode in DV_SUB_MODES:
|
||||||
|
mode = "DV"
|
||||||
|
else:
|
||||||
|
mode = "Data"
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"activatorCallsign": spot.dx_call,
|
||||||
|
"associationCode": ref_split[0],
|
||||||
|
"summitCode": ref_split[1],
|
||||||
|
"frequency": spot.freq / 1000000.0,
|
||||||
|
"mode": mode or "",
|
||||||
|
"callsign": spot.de_call,
|
||||||
|
"comments": spot.comment or "",
|
||||||
|
"type": "TEST" # todo replatce with NORMAL/QRT once testing complete
|
||||||
|
}
|
||||||
|
headers = {**HTTP_HEADERS, "Authorization": "bearer " + access_token, "id_token": id_token,
|
||||||
|
"Content-Type": "application/json"}
|
||||||
|
response = requests.post(self.SUBMIT_URL, json=body, headers=headers, timeout=(5, 30))
|
||||||
|
if not response.ok:
|
||||||
|
raise RuntimeError("SOTA API returned " + str(response.status_code) + ": " + response.text)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("Summit reference is required for submitting SOTA spots.")
|
||||||
|
|||||||
@@ -58,3 +58,20 @@ class SpotProvider:
|
|||||||
"""Stop any threads and prepare for application shutdown"""
|
"""Stop any threads and prepare for application shutdown"""
|
||||||
|
|
||||||
raise NotImplementedError("Subclasses must implement this method")
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
|
|
||||||
|
def can_submit_spot(self, sig):
|
||||||
|
"""Return True if this provider supports submitting spots upstream for the given SIG."""
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def submit_spot(self, spot, credentials):
|
||||||
|
"""Submit a spot upstream to this provider's API. credentials is a dict with provider-specific keys.
|
||||||
|
Raises an exception with a descriptive message on failure."""
|
||||||
|
|
||||||
|
raise NotImplementedError("This provider does not support spot submission")
|
||||||
|
|
||||||
|
def force_poll(self):
|
||||||
|
"""Trigger an immediate poll without waiting for the normal interval. Default implementation here does nothing
|
||||||
|
because not all spot providers have a polling mechanism. Providers that do should override this method."""
|
||||||
|
|
||||||
|
return
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from threading import Thread
|
from threading import Event, Lock, Thread
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from requests_sse import EventSource
|
from requests_sse import EventSource
|
||||||
@@ -16,24 +15,35 @@ class SSESpotProvider(SpotProvider):
|
|||||||
def __init__(self, name, provider_config, url):
|
def __init__(self, name, provider_config, url):
|
||||||
super().__init__(name, provider_config)
|
super().__init__(name, provider_config)
|
||||||
self._url = url
|
self._url = url
|
||||||
self._event_source = None
|
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._stopped = False
|
|
||||||
self._last_event_id = None
|
self._last_event_id = None
|
||||||
|
self._stop_event = Event()
|
||||||
|
self._event_source_lock = Lock()
|
||||||
|
self._event_source = None
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
logging.info("Set up SSE connection to " + self.name + " spot API.")
|
logging.info("Set up SSE connection to " + self.name + " spot API.")
|
||||||
self._stopped = False
|
self._stop_event.clear()
|
||||||
self._thread = Thread(target=self._run)
|
self._thread = Thread(target=self._run, name=f"SSESpotProvider-{self.name}")
|
||||||
self._thread.daemon = True
|
self._thread.daemon = True
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._stopped = True
|
self._stop_event.set()
|
||||||
if self._event_source:
|
|
||||||
self._event_source.close()
|
with self._event_source_lock:
|
||||||
|
event_source = self._event_source
|
||||||
|
if event_source:
|
||||||
|
try:
|
||||||
|
event_source.close()
|
||||||
|
except Exception:
|
||||||
|
logging.exception(
|
||||||
|
"Exception closing SSE connection for " + self.name + " during stop()")
|
||||||
|
|
||||||
if self._thread:
|
if self._thread:
|
||||||
self._thread.join()
|
self._thread.join(timeout=15)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logging.warning(self.name + " SSE worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _on_open(self):
|
def _on_open(self):
|
||||||
self.status = "Waiting for Data"
|
self.status = "Waiting for Data"
|
||||||
@@ -41,36 +51,45 @@ class SSESpotProvider(SpotProvider):
|
|||||||
def _on_error(self):
|
def _on_error(self):
|
||||||
self.status = "Connecting"
|
self.status = "Connecting"
|
||||||
|
|
||||||
|
def _set_event_source(self, event_source):
|
||||||
|
with self._event_source_lock:
|
||||||
|
self._event_source = event_source
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while not self._stopped:
|
while not self._stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
logging.debug("Connecting to " + self.name + " spot API...")
|
logging.debug("Connecting to " + self.name + " spot API...")
|
||||||
self.status = "Connecting"
|
self.status = "Connecting"
|
||||||
with EventSource(self._url, headers=HTTP_HEADERS, latest_event_id=self._last_event_id, timeout=30,
|
with EventSource(self._url, headers=HTTP_HEADERS, latest_event_id=self._last_event_id, timeout=10,
|
||||||
on_open=self._on_open, on_error=self._on_error) as event_source:
|
on_open=self._on_open, on_error=self._on_error) as event_source:
|
||||||
self._event_source = event_source
|
self._set_event_source(event_source)
|
||||||
for event in self._event_source:
|
try:
|
||||||
if event.type == 'message':
|
for event in event_source:
|
||||||
try:
|
if self._stop_event.is_set():
|
||||||
self._last_event_id = event.last_event_id
|
break
|
||||||
new_spot = self._sse_message_to_spot(event.data)
|
if event.type == 'message':
|
||||||
if new_spot:
|
try:
|
||||||
self._submit(new_spot)
|
self._last_event_id = event.last_event_id
|
||||||
|
new_spot = self._sse_message_to_spot(event.data)
|
||||||
|
if new_spot:
|
||||||
|
self._submit(new_spot)
|
||||||
|
|
||||||
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.")
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception(
|
logging.exception(
|
||||||
"Exception processing message from SSE Spot Provider (" + self.name + ")")
|
"Exception processing message from SSE Spot Provider (" + self.name + ")")
|
||||||
|
finally:
|
||||||
|
self._set_event_source(None)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logging.exception("Exception in SSE Spot Provider (" + self.name + ")")
|
logging.exception("Exception in SSE Spot Provider (" + self.name + ")")
|
||||||
else:
|
else:
|
||||||
self.status = "Disconnected"
|
self.status = "Disconnected"
|
||||||
sleep(5) # Wait before trying to reconnect
|
self._stop_event.wait(timeout=5) # Wait before trying to reconnect
|
||||||
|
|
||||||
def _sse_message_to_spot(self, message_data):
|
def _sse_message_to_spot(self, message_data):
|
||||||
"""Convert an SSE message received from the API into a spot. The whole message data is provided here so the subclass
|
"""Convert an SSE message received from the API into a spot. The whole message data is provided here so the subclass
|
||||||
|
|||||||
+51
-1
@@ -1,5 +1,8 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from core.constants import HTTP_HEADERS, SSB_SUB_MODES
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||||
@@ -10,6 +13,9 @@ class Tiles(HTTPSpotProvider):
|
|||||||
|
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
SPOTS_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/spots?active_hours=24"
|
SPOTS_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/spots?active_hours=24"
|
||||||
|
SUBMIT_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/self-spot"
|
||||||
|
VALID_MODES = ["SSB", "CW", "FT8", "FT4", "FM", "DMR", "D-STAR", "M17", "AX.25", "JS8Call", "PSK31", "Olivia",
|
||||||
|
"VarAC", "Other"]
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__("Tiles", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("Tiles", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
@@ -31,7 +37,8 @@ class Tiles(HTTPSpotProvider):
|
|||||||
# Tiles spots can include POTA & SOTA references, but ignore those on the basis that we will get them separately from the POTA/SOTA providers anyway.
|
# Tiles spots can include POTA & SOTA references, but ignore those on the basis that we will get them separately from the POTA/SOTA providers anyway.
|
||||||
# Just take the grid reference itself as the single Tiles SIG reference.
|
# Just take the grid reference itself as the single Tiles SIG reference.
|
||||||
sig_refs=[SIGRef(id=source_spot["maidenhead_grid"], sig="Tiles",
|
sig_refs=[SIGRef(id=source_spot["maidenhead_grid"], sig="Tiles",
|
||||||
name=source_spot["maidenhead_grid"])],
|
name=source_spot["maidenhead_grid"], latitude=source_spot["latitude"],
|
||||||
|
longitude=source_spot["longitude"])],
|
||||||
time=datetime.fromisoformat(source_spot["created_at"].replace("Z", "+00:00")).timestamp(),
|
time=datetime.fromisoformat(source_spot["created_at"].replace("Z", "+00:00")).timestamp(),
|
||||||
dx_grid=source_spot["maidenhead_grid"],
|
dx_grid=source_spot["maidenhead_grid"],
|
||||||
dx_latitude=source_spot["latitude"],
|
dx_latitude=source_spot["latitude"],
|
||||||
@@ -42,6 +49,49 @@ class Tiles(HTTPSpotProvider):
|
|||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
|
def can_submit_spot(self, sig):
|
||||||
|
return sig == "Tiles"
|
||||||
|
|
||||||
|
def submit_spot(self, spot, credentials):
|
||||||
|
# Tiles on the air currently only supports *self* spots
|
||||||
|
if spot.dx_call == spot.de_call:
|
||||||
|
|
||||||
|
# Figure out a valid mode. Borrowed this from PoLo :)
|
||||||
|
# https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js
|
||||||
|
if spot.mode:
|
||||||
|
mode = spot.mode
|
||||||
|
if mode not in self.VALID_MODES:
|
||||||
|
if mode in SSB_SUB_MODES:
|
||||||
|
mode = "SSB"
|
||||||
|
elif mode == "OLIVIA":
|
||||||
|
mode = "Olivia"
|
||||||
|
elif mode == "JS8":
|
||||||
|
mode = "JS8Call"
|
||||||
|
else:
|
||||||
|
mode = "Other"
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"call_sign": spot.dx_call,
|
||||||
|
"frequency": str(spot.freq / 1000000.0),
|
||||||
|
"mode": mode or "",
|
||||||
|
"grid": spot.dx_grid or "",
|
||||||
|
"comment": spot.comment or "",
|
||||||
|
"lat": spot.dx_latitude or None,
|
||||||
|
"lon": spot.dx_longitude or None,
|
||||||
|
"qrt": spot.qrt or False,
|
||||||
|
"pin": credentials.get("offline_spot_gateway_pin", "")
|
||||||
|
}
|
||||||
|
headers = {**HTTP_HEADERS, "Content-Type": "application/json"}
|
||||||
|
response = requests.post(self.SUBMIT_URL, json=body, headers=headers, timeout=(5, 30))
|
||||||
|
if not response.ok:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Tiles on the Air API returned " + str(response.status_code) + ": " + response.text)
|
||||||
|
else:
|
||||||
|
raise RuntimeError("The Tiles on the Air API requires a mode to be set.")
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
"The Tiles on the Air API only supports self-spots, the DX call and spotter call must match.")
|
||||||
|
|
||||||
|
|
||||||
# Utility function to keep the first decimal point in a given string but remove any others. Used to parse Tiles'
|
# Utility function to keep the first decimal point in a given string but remove any others. Used to parse Tiles'
|
||||||
# strange frequency format where we can sometimes have e.g. "14.123.5".
|
# strange frequency format where we can sometimes have e.g. "14.123.5".
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class WebsocketSpotProvider(SpotProvider):
|
|||||||
def start(self):
|
def start(self):
|
||||||
logging.info("Set up websocket connection to " + self.name + " spot API.")
|
logging.info("Set up websocket connection to " + self.name + " spot API.")
|
||||||
self._stopped = False
|
self._stopped = False
|
||||||
self._thread = Thread(target=self._run)
|
self._thread = Thread(target=self._run, name=f"WebsocketSpotProvider-{self.name}")
|
||||||
self._thread.daemon = True
|
self._thread.daemon = True
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
|
|||||||
@@ -79,3 +79,10 @@ class WOTA(HTTPSpotProvider):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error("Exception parsing WOTA spot", e)
|
logging.error("Exception parsing WOTA spot", e)
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
|
def can_submit_spot(self, sig):
|
||||||
|
return sig == "WOTA"
|
||||||
|
|
||||||
|
def submit_spot(self, spot, credentials):
|
||||||
|
# TODO Ask M5TEA if he's happy to share how this is done from his app
|
||||||
|
raise NotImplementedError("WOTA upstream spot submission is not yet implemented")
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ class WWBOTA(SSESpotProvider):
|
|||||||
# n-fer activations.
|
# n-fer activations.
|
||||||
refs = []
|
refs = []
|
||||||
for ref in source_spot["references"]:
|
for ref in source_spot["references"]:
|
||||||
sigref = SIGRef(id=ref["reference"], sig="WWBOTA", name=ref["name"])
|
sigref = SIGRef(id=ref["reference"], sig="WWBOTA", name=ref["name"], latitude=ref["lat"],
|
||||||
|
longitude=ref["long"])
|
||||||
refs.append(sigref)
|
refs.append(sigref)
|
||||||
|
|
||||||
spot = Spot(source=self.name,
|
spot = Spot(source=self.name,
|
||||||
@@ -41,3 +42,10 @@ class WWBOTA(SSESpotProvider):
|
|||||||
|
|
||||||
# WWBOTA does support a special "Test" spot type, we need to avoid adding that.
|
# WWBOTA does support a special "Test" spot type, we need to avoid adding that.
|
||||||
return spot if source_spot["type"] != "Test" else None
|
return spot if source_spot["type"] != "Test" else None
|
||||||
|
|
||||||
|
def can_submit_spot(self, sig):
|
||||||
|
return sig == "WWBOTA"
|
||||||
|
|
||||||
|
def submit_spot(self, spot, credentials):
|
||||||
|
# TODO: Implement. WWBOTA API docs cover this: https://api.wwbota.org/#tag/Spots/operation/create_spot_spots__post
|
||||||
|
raise NotImplementedError("WWBOTA upstream spot submission is not yet implemented")
|
||||||
|
|||||||
+10
-1
@@ -29,7 +29,8 @@ class WWFF(HTTPSpotProvider):
|
|||||||
mode=source_spot["mode"].upper(),
|
mode=source_spot["mode"].upper(),
|
||||||
comment=source_spot["remarks"],
|
comment=source_spot["remarks"],
|
||||||
sig="WWFF",
|
sig="WWFF",
|
||||||
sig_refs=[SIGRef(id=source_spot["reference"], sig="WWFF", name=source_spot["reference_name"])],
|
sig_refs=[SIGRef(id=source_spot["reference"], sig="WWFF", name=source_spot["reference_name"],
|
||||||
|
latitude=source_spot["latitude"], longitude=source_spot["longitude"])],
|
||||||
time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC).timestamp(),
|
time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC).timestamp(),
|
||||||
dx_latitude=source_spot["latitude"],
|
dx_latitude=source_spot["latitude"],
|
||||||
dx_longitude=source_spot["longitude"])
|
dx_longitude=source_spot["longitude"])
|
||||||
@@ -38,3 +39,11 @@ class WWFF(HTTPSpotProvider):
|
|||||||
# that for us.
|
# that for us.
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
|
def can_submit_spot(self, sig):
|
||||||
|
return sig == "WWFF"
|
||||||
|
|
||||||
|
def submit_spot(self, spot, credentials):
|
||||||
|
# TODO: Implement. Spotting to WWFF should be possible, need to look up the Spotline docs or copy approach from
|
||||||
|
# PoLo. Either way I think we need an API key for the app (but maybe not for the user?)
|
||||||
|
raise NotImplementedError("WWFF upstream spot submission is not yet implemented")
|
||||||
|
|||||||
@@ -41,3 +41,10 @@ class ZLOTA(HTTPSpotProvider):
|
|||||||
|
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
|
def can_submit_spot(self, sig):
|
||||||
|
return sig == "ZLOTA"
|
||||||
|
|
||||||
|
def submit_spot(self, spot, credentials):
|
||||||
|
# TODO: Implement. Spotting to ZLOTA is supported via POST, see https://ontheair.nz/api
|
||||||
|
raise NotImplementedError("ZLOTA upstream spot submission is not yet implemented")
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class FileDownloadStaticDataProvider(StaticDataProvider):
|
|||||||
# subsequent polls, so start() returns immediately and the application can continue starting.
|
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||||
logging.info(
|
logging.info(
|
||||||
"Set up query of " + self.name + " static reference data every " + str(self._poll_interval) + " days.")
|
"Set up query of " + self.name + " static reference data every " + str(self._poll_interval) + " days.")
|
||||||
self._thread = Thread(target=self._run, daemon=True)
|
self._thread = Thread(target=self._run, name=f"FileDownloadStaticDataProvider-{self.name}")
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
|||||||
+143
-16
@@ -1,33 +1,40 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
import requests
|
||||||
import tornado
|
import tornado
|
||||||
from tornado import httputil
|
from tornado import httputil
|
||||||
from tornado.web import Application
|
from tornado.web import Application
|
||||||
|
|
||||||
from core.config import ALLOW_SPOTTING
|
from core.config import ALLOW_SPOTTING, ALLOW_UPSTREAM_SPOTTING, RECAPTCHA_SECRET_KEY
|
||||||
from core.constants import UNKNOWN_BAND
|
from core.constants import UNKNOWN_BAND
|
||||||
from core.utils 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 infer_band_from_freq
|
||||||
from core.utils import safe_json_dumps
|
from core.utils import safe_json_dumps
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
|
from providers.spot.spot_provider import SpotProvider
|
||||||
|
|
||||||
|
RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
|
||||||
|
|
||||||
|
|
||||||
class APISpotHandler(tornado.web.RequestHandler):
|
class APISpotHandler(tornado.web.RequestHandler):
|
||||||
"""API request handler for /api/v1/spot (POST)"""
|
"""API request handler for /api/v2/spot (POST)"""
|
||||||
|
|
||||||
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
||||||
self._spots = None
|
self._spots = None
|
||||||
self._web_server_metrics = None
|
self._web_server_metrics = None
|
||||||
|
self._spot_providers = None
|
||||||
super().__init__(application, request, **kwargs)
|
super().__init__(application, request, **kwargs)
|
||||||
|
|
||||||
def initialize(self, spots, web_server_metrics):
|
def initialize(self, spots, web_server_metrics, spot_providers=None):
|
||||||
self._spots = spots
|
self._spots = spots
|
||||||
self._web_server_metrics = web_server_metrics
|
self._web_server_metrics = web_server_metrics
|
||||||
|
self._spot_providers = spot_providers or []
|
||||||
|
|
||||||
def post(self):
|
def post(self):
|
||||||
try:
|
try:
|
||||||
@@ -62,14 +69,49 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Read in the request body as JSON then convert to a Spot object
|
# Read in the request body as JSON
|
||||||
json_spot = tornado.escape.json_decode(post_data)
|
json_body = tornado.escape.json_decode(post_data)
|
||||||
spot = Spot(**json_spot)
|
|
||||||
|
# Extract the "spot" and "handling" sub-objects from the request body
|
||||||
|
spot_data = json_body.get("spot", {})
|
||||||
|
handling = json_body.get("handling", {})
|
||||||
|
|
||||||
|
# Extract individual parameters that say how this spot should be handled by the server
|
||||||
|
submit_upstream = handling.get("submit_upstream", False)
|
||||||
|
upstream_provider_name = handling.get("upstream_provider", None)
|
||||||
|
upstream_credentials = handling.get("upstream_credentials", {})
|
||||||
|
captcha_token = handling.get("captcha_token", None)
|
||||||
|
|
||||||
|
|
||||||
|
# Spothole v2.0 release only: deny upstream spotting. Spothole API breaking changes were in v2.0 but
|
||||||
|
# functionality is not ready yet. TODO
|
||||||
|
submit_upstream = False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Verify CAPTCHA if required
|
||||||
|
if RECAPTCHA_SECRET_KEY:
|
||||||
|
if not captcha_token:
|
||||||
|
self.set_status(422)
|
||||||
|
self.write(safe_json_dumps("Error - CAPTCHA token is required for spot submission."))
|
||||||
|
self.set_header("Cache-Control", "no-store")
|
||||||
|
self.set_header("Content-Type", "application/json")
|
||||||
|
return
|
||||||
|
if not self._verify_recaptcha(captcha_token):
|
||||||
|
self.set_status(422)
|
||||||
|
self.write(safe_json_dumps("Error - CAPTCHA verification failed."))
|
||||||
|
self.set_header("Cache-Control", "no-store")
|
||||||
|
self.set_header("Content-Type", "application/json")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Convert spot field to a Spot object
|
||||||
|
spot = Spot(**spot_data)
|
||||||
|
|
||||||
# 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(safe_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."))
|
||||||
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
|
||||||
@@ -91,7 +133,8 @@ 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(safe_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."))
|
||||||
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
|
||||||
@@ -101,7 +144,8 @@ 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(safe_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."))
|
||||||
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
|
||||||
@@ -116,13 +160,75 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
self.set_header("Content-Type", "application/json")
|
self.set_header("Content-Type", "application/json")
|
||||||
return
|
return
|
||||||
|
|
||||||
# infer missing data, and add it to our database.
|
# Reject upstream submission if not permitted
|
||||||
spot.source = "API"
|
if submit_upstream and not ALLOW_UPSTREAM_SPOTTING:
|
||||||
spot.infer_missing()
|
self.set_status(403)
|
||||||
self._spots.set(spot.id, spot)
|
self.write(safe_json_dumps("Error - this server does not allow upstream spot submission."))
|
||||||
|
self.set_header("Cache-Control", "no-store")
|
||||||
|
self.set_header("Content-Type", "application/json")
|
||||||
|
return
|
||||||
|
|
||||||
self.write(safe_json_dumps("OK"))
|
# Validate upstream submission requirements
|
||||||
self.set_status(201)
|
if submit_upstream and upstream_provider_name:
|
||||||
|
if not spot.sig:
|
||||||
|
self.set_status(422)
|
||||||
|
self.write(safe_json_dumps("Error - a SIG must be selected to submit upstream."))
|
||||||
|
self.set_header("Cache-Control", "no-store")
|
||||||
|
self.set_header("Content-Type", "application/json")
|
||||||
|
return
|
||||||
|
if not spot.sig_refs and upstream_provider_name != "Tiles":
|
||||||
|
self.set_status(422)
|
||||||
|
self.write(safe_json_dumps("Error - a SIG reference is required to submit upstream."))
|
||||||
|
self.set_header("Cache-Control", "no-store")
|
||||||
|
self.set_header("Content-Type", "application/json")
|
||||||
|
return
|
||||||
|
if not spot.dx_grid and upstream_provider_name == "Tiles":
|
||||||
|
self.set_status(422)
|
||||||
|
self.write(
|
||||||
|
safe_json_dumps("Error - a grid reference is required to submit upstream to Tiles on the Air."))
|
||||||
|
self.set_header("Cache-Control", "no-store")
|
||||||
|
self.set_header("Content-Type", "application/json")
|
||||||
|
return
|
||||||
|
if not spot.mode and upstream_provider_name == "Tiles":
|
||||||
|
self.set_status(422)
|
||||||
|
self.write(safe_json_dumps("Error - a mode is required to submit upstream to Tiles on the Air."))
|
||||||
|
self.set_header("Cache-Control", "no-store")
|
||||||
|
self.set_header("Content-Type", "application/json")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Submit upstream if requested
|
||||||
|
upstream_warning = None
|
||||||
|
if submit_upstream and upstream_provider_name:
|
||||||
|
provider = self._find_provider(upstream_provider_name, spot.sig)
|
||||||
|
if provider:
|
||||||
|
try:
|
||||||
|
# Submit spot to the upstream provider
|
||||||
|
provider.submit_spot(spot, upstream_credentials)
|
||||||
|
# Trigger a re-poll after 1 second so the spot appears quickly
|
||||||
|
threading.Timer(1.0, provider.force_poll).start()
|
||||||
|
except NotImplementedError as e:
|
||||||
|
upstream_warning = str(e)
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning("Failed to submit spot upstream to " + upstream_provider_name + ": " + str(e))
|
||||||
|
upstream_warning = "Spot was saved locally but upstream submission to " + upstream_provider_name + " failed: " + str(
|
||||||
|
e)
|
||||||
|
else:
|
||||||
|
upstream_warning = "No enabled provider named '" + upstream_provider_name + "' supports upstream submission for " + (
|
||||||
|
spot.sig if spot.sig else "") + " spots."
|
||||||
|
|
||||||
|
# If we successfully submitted the spot upstream, don't add it direct to Spothole, otherwise it will be a
|
||||||
|
# duplicate with what immediately comes back from the API. But if we weren't asked to send it upstream, or
|
||||||
|
# we were but it failed, we should still add it to our database anyway.
|
||||||
|
if not submit_upstream or upstream_warning:
|
||||||
|
spot.infer_missing()
|
||||||
|
self._spots.set(spot.id, spot)
|
||||||
|
|
||||||
|
if upstream_warning:
|
||||||
|
self.write(safe_json_dumps("Warning - " + upstream_warning))
|
||||||
|
self.set_status(201)
|
||||||
|
else:
|
||||||
|
self.write(safe_json_dumps("OK"))
|
||||||
|
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")
|
||||||
|
|
||||||
@@ -132,3 +238,24 @@ class APISpotHandler(tornado.web.RequestHandler):
|
|||||||
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")
|
||||||
|
|
||||||
|
def _find_provider(self, provider_name, sig) -> SpotProvider | None:
|
||||||
|
"""Find an enabled provider by name that can submit spots for the given SIG."""
|
||||||
|
|
||||||
|
for p in self._spot_providers:
|
||||||
|
if p.enabled and p.name == provider_name and p.can_submit_spot(sig):
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _verify_recaptcha(token):
|
||||||
|
"""Verify a Google reCAPTCHA v2 token. Returns True if valid."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(RECAPTCHA_VERIFY_URL,
|
||||||
|
data={"secret": RECAPTCHA_SECRET_KEY, "response": token},
|
||||||
|
timeout=(5, 10))
|
||||||
|
return response.ok and response.json().get("success", False)
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning("reCAPTCHA verification request failed: " + str(e))
|
||||||
|
return False
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from data.lookup_credentials import extract_credentials
|
|||||||
|
|
||||||
|
|
||||||
class APIAlertsHandler(tornado.web.RequestHandler):
|
class APIAlertsHandler(tornado.web.RequestHandler):
|
||||||
"""API request handler for /api/v1/alerts"""
|
"""API request handler for /api/v2/alerts"""
|
||||||
|
|
||||||
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
||||||
self._alerts = None
|
self._alerts = None
|
||||||
@@ -48,7 +48,7 @@ class APIAlertsHandler(tornado.web.RequestHandler):
|
|||||||
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
||||||
|
|
||||||
# Fetch all alerts matching the query, then optionally enrich with online data
|
# Fetch all alerts matching the query, then optionally enrich with online data
|
||||||
credentials = extract_credentials(query_params)
|
credentials = extract_credentials(self.request.headers)
|
||||||
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)
|
||||||
@@ -66,7 +66,7 @@ class APIAlertsHandler(tornado.web.RequestHandler):
|
|||||||
|
|
||||||
|
|
||||||
class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
||||||
"""API request handler for /api/v1/alerts/stream"""
|
"""API request handler for /api/v2/alerts/stream"""
|
||||||
|
|
||||||
def __init__(self, application, request, **kwargs: Any):
|
def __init__(self, application, request, **kwargs: Any):
|
||||||
self._sse_alert_broadcaster = None
|
self._sse_alert_broadcaster = None
|
||||||
@@ -96,7 +96,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
|||||||
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
||||||
# reduce that to just the first entry, and convert bytes to string
|
# reduce that to just the first entry, and convert bytes to string
|
||||||
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
||||||
self._credentials = extract_credentials(self._query_params)
|
self._credentials = extract_credentials(self.request.headers)
|
||||||
|
|
||||||
# Flush headers immediately so nginx doesn't time out waiting for a response
|
# Flush headers immediately so nginx doesn't time out waiting for a response
|
||||||
self.write_message("keepalive", "")
|
self.write_message("keepalive", "")
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ BANDS_SET = frozenset(BANDS)
|
|||||||
|
|
||||||
|
|
||||||
class APIDxStatsHandler(tornado.web.RequestHandler):
|
class APIDxStatsHandler(tornado.web.RequestHandler):
|
||||||
"""API request handler for /api/v1/dxstats"""
|
"""API request handler for /api/v2/dxstats"""
|
||||||
|
|
||||||
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
||||||
self._spots = None
|
self._spots = None
|
||||||
|
|||||||
@@ -8,19 +8,19 @@ import tornado
|
|||||||
from tornado import httputil
|
from tornado import httputil
|
||||||
from tornado.web import Application
|
from tornado.web import Application
|
||||||
|
|
||||||
|
from core.call_lookup_helper import get_call_info
|
||||||
from core.constants import SIGS
|
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_lookup_helper import populate_missing_sig_ref_info
|
||||||
from core.sig_utils import get_ref_regex_for_sig
|
from core.sig_utils import get_ref_regex_for_sig
|
||||||
from core.sig_lookup_helper import populate_sig_ref_info
|
|
||||||
from core.utils import safe_json_dumps
|
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
|
|
||||||
|
|
||||||
|
|
||||||
class APILookupCallHandler(tornado.web.RequestHandler):
|
class APILookupCallHandler(tornado.web.RequestHandler):
|
||||||
"""API request handler for /api/v1/lookup/call"""
|
"""API request handler for /api/v2/lookup/call"""
|
||||||
|
|
||||||
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
||||||
self._web_server_metrics = None
|
self._web_server_metrics = None
|
||||||
@@ -45,27 +45,9 @@ class APILookupCallHandler(tornado.web.RequestHandler):
|
|||||||
if "call" in query_params.keys():
|
if "call" in query_params.keys():
|
||||||
call = str(query_params.get("call")).upper()
|
call = str(query_params.get("call")).upper()
|
||||||
if re.match(r"^[A-Z0-9/\-]*$", call):
|
if re.match(r"^[A-Z0-9/\-]*$", call):
|
||||||
# Take the callsign, make a "fake spot" so we can run infer_missing() on it, then repack the
|
credentials = extract_credentials(self.request.headers)
|
||||||
# resulting data in the correct way for the API response.
|
callsign_data = get_call_info(call, credentials)
|
||||||
credentials = extract_credentials(query_params)
|
self.write(safe_json_dumps(callsign_data))
|
||||||
fake_spot = Spot(dx_call=call)
|
|
||||||
fake_spot.infer_missing(credentials)
|
|
||||||
data = {
|
|
||||||
"call": call,
|
|
||||||
"name": fake_spot.dx_name,
|
|
||||||
"qth": fake_spot.dx_qth,
|
|
||||||
"country": fake_spot.dx_country,
|
|
||||||
"flag": fake_spot.dx_flag,
|
|
||||||
"continent": fake_spot.dx_continent,
|
|
||||||
"dxcc_id": fake_spot.dx_dxcc_id,
|
|
||||||
"cq_zone": fake_spot.dx_cq_zone,
|
|
||||||
"itu_zone": fake_spot.dx_itu_zone,
|
|
||||||
"grid": fake_spot.dx_grid,
|
|
||||||
"latitude": fake_spot.dx_latitude,
|
|
||||||
"longitude": fake_spot.dx_longitude,
|
|
||||||
"location_source": fake_spot.dx_location_source
|
|
||||||
}
|
|
||||||
self.write(safe_json_dumps(data))
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.write(safe_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."))
|
||||||
@@ -84,7 +66,7 @@ class APILookupCallHandler(tornado.web.RequestHandler):
|
|||||||
|
|
||||||
|
|
||||||
class APILookupSIGRefHandler(tornado.web.RequestHandler):
|
class APILookupSIGRefHandler(tornado.web.RequestHandler):
|
||||||
"""API request handler for /api/v1/lookup/sigref"""
|
"""API request handler for /api/v2/lookup/sigref"""
|
||||||
|
|
||||||
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
||||||
self._web_server_metrics = None
|
self._web_server_metrics = None
|
||||||
@@ -112,7 +94,7 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
|
|||||||
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.upper(), 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_missing_sig_ref_info(SIGRef(id=ref_id, sig=sig))
|
||||||
self.write(safe_json_dumps(data))
|
self.write(safe_json_dumps(data))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -136,7 +118,7 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
|
|||||||
|
|
||||||
|
|
||||||
class APILookupGridHandler(tornado.web.RequestHandler):
|
class APILookupGridHandler(tornado.web.RequestHandler):
|
||||||
"""API request handler for /api/v1/lookup/grid"""
|
"""API request handler for /api/v2/lookup/grid"""
|
||||||
|
|
||||||
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
||||||
self._web_server_metrics = None
|
self._web_server_metrics = None
|
||||||
|
|||||||
@@ -14,16 +14,18 @@ from core.utils import safe_json_dumps
|
|||||||
|
|
||||||
|
|
||||||
class APIOptionsHandler(tornado.web.RequestHandler):
|
class APIOptionsHandler(tornado.web.RequestHandler):
|
||||||
"""API request handler for /api/v1/options"""
|
"""API request handler for /api/v2/options"""
|
||||||
|
|
||||||
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
||||||
self._status_data = None
|
self._status_data = None
|
||||||
self._web_server_metrics = None
|
self._web_server_metrics = None
|
||||||
|
self._spot_providers = None
|
||||||
super().__init__(application, request, **kwargs)
|
super().__init__(application, request, **kwargs)
|
||||||
|
|
||||||
def initialize(self, status_data, web_server_metrics):
|
def initialize(self, status_data, web_server_metrics, spot_providers=None):
|
||||||
self._status_data = status_data
|
self._status_data = status_data
|
||||||
self._web_server_metrics = web_server_metrics
|
self._web_server_metrics = web_server_metrics
|
||||||
|
self._spot_providers = spot_providers or []
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
try:
|
try:
|
||||||
@@ -33,23 +35,42 @@ class APIOptionsHandler(tornado.web.RequestHandler):
|
|||||||
self._web_server_metrics["status"] = "OK"
|
self._web_server_metrics["status"] = "OK"
|
||||||
api_requests_counter.inc()
|
api_requests_counter.inc()
|
||||||
|
|
||||||
|
# Build a map of SIG name -> list of provider names that can submit spots for that SIG
|
||||||
|
spot_submit_providers = {}
|
||||||
|
|
||||||
|
# Spothole v2.0 - disable this for now, API changes are in but this functionality is not ready yet. TODO
|
||||||
|
# for provider in self._spot_providers:
|
||||||
|
# if not provider.enabled:
|
||||||
|
# continue
|
||||||
|
# for sig in SIGS:
|
||||||
|
# if provider.can_submit_spot(sig.name):
|
||||||
|
# spot_submit_providers.setdefault(sig.name, []).append(provider.name)
|
||||||
|
|
||||||
|
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle
|
||||||
|
# things that aren't even available.
|
||||||
|
spot_providers: list = list(
|
||||||
|
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["spot_providers"])))
|
||||||
|
alert_providers = list(
|
||||||
|
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"])))
|
||||||
|
callsign_data_providers = list(
|
||||||
|
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["callsign_data_providers"])))
|
||||||
|
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
|
||||||
|
# one of our providers.
|
||||||
|
if ALLOW_SPOTTING:
|
||||||
|
spot_providers.append("API")
|
||||||
|
|
||||||
options = {"bands": BANDS,
|
options = {"bands": BANDS,
|
||||||
"modes": ALL_MODES,
|
"modes": ALL_MODES,
|
||||||
"mode_types": MODE_TYPES,
|
"mode_types": MODE_TYPES,
|
||||||
"sigs": SIGS,
|
"sigs": SIGS,
|
||||||
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle things that aren't even available.
|
"spot_providers": spot_providers,
|
||||||
"spot_sources": list(
|
"alert_providers": alert_providers,
|
||||||
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["spot_providers"]))),
|
"callsign_data_providers": callsign_data_providers,
|
||||||
"alert_sources": list(
|
|
||||||
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"]))),
|
|
||||||
"continents": CONTINENTS,
|
"continents": CONTINENTS,
|
||||||
"propagation_modes": list(PROPAGATION_MODES.values()),
|
"propagation_modes": list(PROPAGATION_MODES.values()),
|
||||||
"max_spot_age": MAX_SPOT_AGE,
|
"max_spot_age": MAX_SPOT_AGE,
|
||||||
"spot_allowed": ALLOW_SPOTTING}
|
"spot_allowed": ALLOW_SPOTTING,
|
||||||
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
|
"spot_submit_providers": spot_submit_providers}
|
||||||
# one of our proviers.
|
|
||||||
if ALLOW_SPOTTING:
|
|
||||||
options["spot_sources"].append("API")
|
|
||||||
|
|
||||||
self.write(safe_json_dumps(options))
|
self.write(safe_json_dumps(options))
|
||||||
self.set_status(200)
|
self.set_status(200)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from core.utils import safe_json_dumps
|
|||||||
|
|
||||||
|
|
||||||
class APISolarConditionsHandler(tornado.web.RequestHandler):
|
class APISolarConditionsHandler(tornado.web.RequestHandler):
|
||||||
"""API request handler for /api/v1/solar"""
|
"""API request handler for /api/v2/solar"""
|
||||||
|
|
||||||
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
||||||
self._solar_conditions = None
|
self._solar_conditions = None
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from data.lookup_credentials import extract_credentials
|
|||||||
|
|
||||||
|
|
||||||
class APISpotsHandler(tornado.web.RequestHandler):
|
class APISpotsHandler(tornado.web.RequestHandler):
|
||||||
"""API request handler for /api/v1/spots"""
|
"""API request handler for /api/v2/spots"""
|
||||||
|
|
||||||
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
||||||
self._spots = None
|
self._spots = None
|
||||||
@@ -48,7 +48,7 @@ class APISpotsHandler(tornado.web.RequestHandler):
|
|||||||
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
||||||
|
|
||||||
# Fetch all spots matching the query, then optionally enrich with online data
|
# Fetch all spots matching the query, then optionally enrich with online data
|
||||||
credentials = extract_credentials(query_params)
|
credentials = extract_credentials(self.request.headers)
|
||||||
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)
|
||||||
@@ -66,7 +66,7 @@ class APISpotsHandler(tornado.web.RequestHandler):
|
|||||||
|
|
||||||
|
|
||||||
class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
||||||
"""API request handler for /api/v1/spots/stream"""
|
"""API request handler for /api/v2/spots/stream"""
|
||||||
|
|
||||||
def __init__(self, application, request, **kwargs: Any):
|
def __init__(self, application, request, **kwargs: Any):
|
||||||
self._sse_spot_broadcaster = None
|
self._sse_spot_broadcaster = None
|
||||||
@@ -98,7 +98,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
|||||||
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
||||||
# reduce that to just the first entry, and convert bytes to string
|
# reduce that to just the first entry, and convert bytes to string
|
||||||
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
||||||
self._credentials = extract_credentials(self._query_params)
|
self._credentials = extract_credentials(self.request.headers)
|
||||||
|
|
||||||
# Flush headers immediately so nginx doesn't time out waiting for a response
|
# Flush headers immediately so nginx doesn't time out waiting for a response
|
||||||
self.write_message("keepalive", "")
|
self.write_message("keepalive", "")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from core.utils import safe_json_dumps
|
|||||||
|
|
||||||
|
|
||||||
class APIStatusHandler(tornado.web.RequestHandler):
|
class APIStatusHandler(tornado.web.RequestHandler):
|
||||||
"""API request handler for /api/v1/status"""
|
"""API request handler for /api/v2/status"""
|
||||||
|
|
||||||
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
def __init__(self, application: "Application", request: httputil.HTTPServerRequest, **kwargs: Any):
|
||||||
self._status_data = None
|
self._status_data = None
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import tornado
|
||||||
|
|
||||||
|
from core.utils import safe_json_dumps
|
||||||
|
|
||||||
|
|
||||||
|
class V1GoneHandler(tornado.web.RequestHandler):
|
||||||
|
"""Returns 410 Gone with a message for any endpoints in the old API that have breaking changes in the new one or
|
||||||
|
have been retired."""
|
||||||
|
|
||||||
|
def post(self):
|
||||||
|
self.set_status(410)
|
||||||
|
self.write(safe_json_dumps(
|
||||||
|
"This API endpoint has a breaking change or has been removed in the current version of the Spothole API. Please see /apidocs for details of the current API version and the endpoints available."))
|
||||||
|
self.set_header("Cache-Control", "no-store")
|
||||||
|
self.set_header("Content-Type", "application/json")
|
||||||
|
|
||||||
|
|
||||||
|
class V1RedirectHandler(tornado.web.RequestHandler):
|
||||||
|
"""Returns 308 Permanent Redirect from any path in the old API to the new one, where there were no breaking changes."""
|
||||||
|
|
||||||
|
def get(self, path):
|
||||||
|
new_url = "/api/v2/" + path
|
||||||
|
if self.request.query:
|
||||||
|
new_url += "?" + self.request.query
|
||||||
|
self.set_status(308)
|
||||||
|
self.set_header("Location", new_url)
|
||||||
|
self.finish()
|
||||||
@@ -11,6 +11,9 @@ class SSEBroadcaster:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._handlers = set()
|
self._handlers = set()
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self._loop = None
|
||||||
|
|
||||||
|
def bind_to_web_server_loop(self):
|
||||||
self._loop = IOLoop.current()
|
self._loop = IOLoop.current()
|
||||||
|
|
||||||
def register(self, handler):
|
def register(self, handler):
|
||||||
@@ -22,9 +25,9 @@ class SSEBroadcaster:
|
|||||||
self._handlers.discard(handler)
|
self._handlers.discard(handler)
|
||||||
|
|
||||||
def publish(self, value):
|
def publish(self, value):
|
||||||
self._loop.add_callback(self._fan_out, value)
|
self._loop.add_callback(self._broadcast, value)
|
||||||
|
|
||||||
def _fan_out(self, value):
|
def _broadcast(self, value):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
handlers = list(self._handlers)
|
handlers = list(self._handlers)
|
||||||
for handler in handlers:
|
for handler in handlers:
|
||||||
|
|||||||
+33
-13
@@ -6,6 +6,7 @@ import tornado
|
|||||||
from tornado.web import StaticFileHandler
|
from tornado.web import StaticFileHandler
|
||||||
|
|
||||||
from core.config import ALLOW_SPOTTING, WEB_SERVER_PORT, API_ONLY_MODE, LOG_WEB_REQUESTS, BASE_URL
|
from core.config import ALLOW_SPOTTING, WEB_SERVER_PORT, API_ONLY_MODE, LOG_WEB_REQUESTS, BASE_URL
|
||||||
|
from core.data_providers import DATA_PROVIDERS
|
||||||
from core.data_store import DATA_STORE
|
from core.data_store import DATA_STORE
|
||||||
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,6 +16,7 @@ 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.api.v1_compatability import V1RedirectHandler, V1GoneHandler
|
||||||
from server.handlers.manifesthandler import ManifestHandler
|
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
|
||||||
@@ -30,6 +32,7 @@ class WebServer:
|
|||||||
"""Constructor"""
|
"""Constructor"""
|
||||||
|
|
||||||
self._data_store = DATA_STORE
|
self._data_store = DATA_STORE
|
||||||
|
self._data_providers = DATA_PROVIDERS
|
||||||
self._spot_broadcaster = SSEBroadcaster()
|
self._spot_broadcaster = SSEBroadcaster()
|
||||||
self._alert_broadcaster = SSEBroadcaster()
|
self._alert_broadcaster = SSEBroadcaster()
|
||||||
self._port = WEB_SERVER_PORT
|
self._port = WEB_SERVER_PORT
|
||||||
@@ -43,6 +46,7 @@ class WebServer:
|
|||||||
"status": "Starting"
|
"status": "Starting"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
# Listen for new spots and alerts being added to the cache, so we can notify SSE clients immediately
|
# Listen for new spots and alerts being added to the cache, so we can notify SSE clients immediately
|
||||||
DATA_STORE.spots.add_listener(self._spot_broadcaster.publish)
|
DATA_STORE.spots.add_listener(self._spot_broadcaster.publish)
|
||||||
DATA_STORE.alerts.add_listener(self._alert_broadcaster.publish)
|
DATA_STORE.alerts.add_listener(self._alert_broadcaster.publish)
|
||||||
@@ -60,27 +64,39 @@ class WebServer:
|
|||||||
async def _start_inner(self):
|
async def _start_inner(self):
|
||||||
"""Start method (async). Sets up the Tornado application."""
|
"""Start method (async). Sets up the Tornado application."""
|
||||||
|
|
||||||
|
# Bind the SSE broadcasters to the web server's loop, so they fire correctly
|
||||||
|
self._spot_broadcaster.bind_to_web_server_loop()
|
||||||
|
self._alert_broadcaster.bind_to_web_server_loop()
|
||||||
|
|
||||||
# Prepare a list of common arguments that are passed in to every API & page handler. This is just a basic thing
|
# Prepare a list of common arguments that are passed in to every API & page handler. This is just a basic thing
|
||||||
# to avoid copy-pasting the same thing to every route declaration below.
|
# to avoid copy-pasting the same thing to every route declaration below.
|
||||||
handler_opts = {"web_server_metrics": self.web_server_metrics}
|
handler_opts = {"web_server_metrics": self.web_server_metrics}
|
||||||
|
|
||||||
# API endpoints are always enabled
|
# API endpoints are always enabled
|
||||||
api_routes = [
|
api_routes = [
|
||||||
(r"/api/v1/spots", APISpotsHandler, {"spots": self._data_store.spots, **handler_opts}),
|
(r"/api/v2/spots", APISpotsHandler, {"spots": self._data_store.spots, **handler_opts}),
|
||||||
(r"/api/v1/alerts", APIAlertsHandler, {"alerts": self._data_store.alerts, **handler_opts}),
|
(r"/api/v2/alerts", APIAlertsHandler, {"alerts": self._data_store.alerts, **handler_opts}),
|
||||||
(r"/api/v1/spots/stream", APISpotsStreamHandler,
|
(r"/api/v2/spots/stream", APISpotsStreamHandler,
|
||||||
{"sse_spot_broadcaster": self._spot_broadcaster, **handler_opts}),
|
{"sse_spot_broadcaster": self._spot_broadcaster, **handler_opts}),
|
||||||
(r"/api/v1/alerts/stream", APIAlertsStreamHandler,
|
(r"/api/v2/alerts/stream", APIAlertsStreamHandler,
|
||||||
{"sse_alert_broadcaster": self._alert_broadcaster, **handler_opts}),
|
{"sse_alert_broadcaster": self._alert_broadcaster, **handler_opts}),
|
||||||
(r"/api/v1/solar", APISolarConditionsHandler, {"solar_conditions": self._data_store.solar_conditions,
|
(r"/api/v2/solar", APISolarConditionsHandler, {"solar_conditions": self._data_store.solar_conditions,
|
||||||
**handler_opts}),
|
**handler_opts}),
|
||||||
(r"/api/v1/dxstats", APIDxStatsHandler, {"spots": self._data_store.spots, **handler_opts}),
|
(r"/api/v2/dxstats", APIDxStatsHandler, {"spots": self._data_store.spots, **handler_opts}),
|
||||||
(r"/api/v1/options", APIOptionsHandler, {"status_data": self._data_store.status_data, **handler_opts}),
|
(r"/api/v2/options", APIOptionsHandler, {"status_data": self._data_store.status_data, **handler_opts}),
|
||||||
(r"/api/v1/status", APIStatusHandler, {"status_data": self._data_store.status_data, **handler_opts}),
|
(r"/api/v2/status", APIStatusHandler, {"status_data": self._data_store.status_data, **handler_opts}),
|
||||||
(r"/api/v1/lookup/call", APILookupCallHandler, {**handler_opts}),
|
(r"/api/v2/lookup/call", APILookupCallHandler, {**handler_opts}),
|
||||||
(r"/api/v1/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}),
|
(r"/api/v2/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}),
|
||||||
(r"/api/v1/lookup/grid", APILookupGridHandler, {**handler_opts}),
|
(r"/api/v2/lookup/grid", APILookupGridHandler, {**handler_opts}),
|
||||||
(r"/api/v1/spot", APISpotHandler, {"spots": self._data_store.spots, **handler_opts}),
|
(r"/api/v2/spot", APISpotHandler,
|
||||||
|
{"spots": self._data_store.spots, "spot_providers": self._data_providers, **handler_opts}),
|
||||||
|
]
|
||||||
|
|
||||||
|
# v1 API redirects. Most v1 enpoints are unchanged in v2, and get an HTTP 308 redirect to the v2 API. The ones
|
||||||
|
# that have the major breaking changes get a bespoke handler.
|
||||||
|
v1_compat_routes = [
|
||||||
|
(r"/api/v1/spot", V1GoneHandler),
|
||||||
|
(r"/api/v1/(.*)", V1RedirectHandler),
|
||||||
]
|
]
|
||||||
|
|
||||||
# If in API-only mode, serve a basic homepage; in normal mode, serve the usual UI routes
|
# If in API-only mode, serve a basic homepage; in normal mode, serve the usual UI routes
|
||||||
@@ -115,7 +131,7 @@ class WebServer:
|
|||||||
(r"/static/(.*)", StaticFileHandler, {"path": os.path.join(_HERE, "../static")})
|
(r"/static/(.*)", StaticFileHandler, {"path": os.path.join(_HERE, "../static")})
|
||||||
]
|
]
|
||||||
|
|
||||||
app = tornado.web.Application(api_routes + ui_routes + misc_routes,
|
app = tornado.web.Application(api_routes + v1_compat_routes + ui_routes + misc_routes,
|
||||||
template_path=os.path.join(_HERE, "../templates"),
|
template_path=os.path.join(_HERE, "../templates"),
|
||||||
log_function=request_log,
|
log_function=request_log,
|
||||||
debug=False)
|
debug=False)
|
||||||
@@ -145,3 +161,7 @@ def request_log(handler):
|
|||||||
f'{handler.get_status()} {request.request_time():.2f}ms | '
|
f'{handler.get_status()} {request.request_time():.2f}ms | '
|
||||||
f'Ref: {referrer} | UA: {user_agent}'
|
f'Ref: {referrer} | UA: {user_agent}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Global object
|
||||||
|
WEB_SERVER = WebServer()
|
||||||
|
|||||||
+14
-76
@@ -4,49 +4,24 @@ import os
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from core.config import config, SERVER_OWNER_CALLSIGN, LOG_LEVEL, get_sig_ref_data_provider_from_config, \
|
from core.config import SERVER_OWNER_CALLSIGN, LOG_LEVEL
|
||||||
get_spot_provider_from_config, get_alert_provider_from_config, get_solar_conditions_provider_from_config, \
|
|
||||||
get_static_data_provider_from_config
|
|
||||||
from core.constants import SOFTWARE_VERSION
|
from core.constants import SOFTWARE_VERSION
|
||||||
|
from core.data_providers import DATA_PROVIDERS
|
||||||
from core.data_store import DATA_STORE
|
from core.data_store import DATA_STORE
|
||||||
from core.call_lookup_helper import lookup_helper
|
|
||||||
from core.status_reporter import StatusReporter
|
from core.status_reporter import StatusReporter
|
||||||
from server.webserver import WebServer
|
from server.webserver import WEB_SERVER
|
||||||
|
|
||||||
# Globals
|
# Globals
|
||||||
web_server = None
|
|
||||||
spot_providers = []
|
|
||||||
alert_providers = []
|
|
||||||
solar_condition_providers = []
|
|
||||||
static_data_providers = []
|
|
||||||
sig_ref_data_providers = []
|
|
||||||
cleanup_timer = None
|
|
||||||
run = True
|
run = True
|
||||||
|
|
||||||
|
|
||||||
def shutdown(_signum=None, _frame=None):
|
def shutdown(_signum=None, _frame=None):
|
||||||
"""Shutdown function"""
|
"""Shutdown function"""
|
||||||
|
|
||||||
global run
|
global run
|
||||||
|
|
||||||
logging.info("Stopping program...")
|
logging.info("Stopping program...")
|
||||||
if web_server:
|
WEB_SERVER.stop()
|
||||||
web_server.stop()
|
DATA_PROVIDERS.stop()
|
||||||
for sp in spot_providers:
|
|
||||||
if sp.enabled:
|
|
||||||
sp.stop()
|
|
||||||
for ap in alert_providers:
|
|
||||||
if ap.enabled:
|
|
||||||
ap.stop()
|
|
||||||
for scp in solar_condition_providers:
|
|
||||||
if scp.enabled:
|
|
||||||
scp.stop()
|
|
||||||
for srdp in sig_ref_data_providers:
|
|
||||||
if srdp.enabled:
|
|
||||||
srdp.stop()
|
|
||||||
for srdp in static_data_providers:
|
|
||||||
if srdp.enabled:
|
|
||||||
srdp.stop()
|
|
||||||
DATA_STORE.close()
|
DATA_STORE.close()
|
||||||
os._exit(0)
|
os._exit(0)
|
||||||
|
|
||||||
@@ -73,57 +48,20 @@ if __name__ == '__main__':
|
|||||||
# Set up data store
|
# Set up data store
|
||||||
DATA_STORE.setup()
|
DATA_STORE.setup()
|
||||||
|
|
||||||
# Set up lookup helper
|
# Set up and start data providers
|
||||||
lookup_helper.start()
|
DATA_PROVIDERS.setup()
|
||||||
|
DATA_PROVIDERS.start()
|
||||||
|
|
||||||
# Set up web server
|
# Set up and start status reporter
|
||||||
web_server = WebServer()
|
status_reporter = StatusReporter(run_interval=5)
|
||||||
|
|
||||||
# Fetch, set up and start spot providers
|
|
||||||
for entry in config["spot-providers"]:
|
|
||||||
spot_providers.append(get_spot_provider_from_config(entry))
|
|
||||||
for p in spot_providers:
|
|
||||||
if p.enabled:
|
|
||||||
p.start()
|
|
||||||
|
|
||||||
# Fetch, set up and start alert providers
|
|
||||||
for entry in config["alert-providers"]:
|
|
||||||
alert_providers.append(get_alert_provider_from_config(entry))
|
|
||||||
for p in alert_providers:
|
|
||||||
if p.enabled:
|
|
||||||
p.start()
|
|
||||||
|
|
||||||
# Fetch, set up and start solar conditions providers
|
|
||||||
for entry in config.get("solar-condition-providers", []):
|
|
||||||
solar_condition_providers.append(get_solar_conditions_provider_from_config(entry))
|
|
||||||
for p in solar_condition_providers:
|
|
||||||
if p.enabled:
|
|
||||||
p.start()
|
|
||||||
|
|
||||||
# Fetch, set up and start static reference data providers
|
|
||||||
for entry in config.get("static-data-providers", []):
|
|
||||||
static_data_providers.append(get_static_data_provider_from_config(entry))
|
|
||||||
for p in static_data_providers:
|
|
||||||
if p.enabled:
|
|
||||||
p.start()
|
|
||||||
|
|
||||||
# Fetch, set up and start SIG reference data providers
|
|
||||||
for entry in config.get("sig-ref-data-providers", []):
|
|
||||||
sig_ref_data_providers.append(get_sig_ref_data_provider_from_config(entry))
|
|
||||||
for p in sig_ref_data_providers:
|
|
||||||
if p.enabled:
|
|
||||||
p.start()
|
|
||||||
|
|
||||||
# Set up status reporter
|
|
||||||
status_reporter = StatusReporter(web_server=web_server, spot_providers=spot_providers,
|
|
||||||
alert_providers=alert_providers, static_data_providers=static_data_providers,
|
|
||||||
sig_ref_data_providers=sig_ref_data_providers,
|
|
||||||
solar_condition_providers=solar_condition_providers, run_interval=5)
|
|
||||||
status_reporter.start()
|
status_reporter.start()
|
||||||
|
|
||||||
|
# Set up the web server
|
||||||
|
WEB_SERVER.setup()
|
||||||
|
|
||||||
logging.info("Startup complete.")
|
logging.info("Startup complete.")
|
||||||
|
|
||||||
# Run the web server. This is the blocking call that keeps the application running in the main thread, so this must
|
# Run the web server. This is the blocking call that keeps the application running in the main thread, so this must
|
||||||
# be the last thing we do. web_server.stop() triggers an await condition in the web server which finishes the main
|
# be the last thing we do. web_server.stop() triggers an await condition in the web server which finishes the main
|
||||||
# thread.
|
# thread.
|
||||||
web_server.start()
|
WEB_SERVER.start()
|
||||||
|
|||||||
+166
-44
@@ -15,6 +15,23 @@ info:
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### 2.0
|
||||||
|
|
||||||
|
* POST `/spot` now supports upstream submission to the spotting services associated with various SIGs.
|
||||||
|
* **Breaking change:** The "add spot" API has changed to enable this: instead of just posting the spot object itself as the JSON content of the POST, this has moved into a `spot` object within the structure. A new `handling` object alongside it contains the `submit_upstream`, `upstream_provider`, `upstream_credentials`, and `captcha_token` fields which control the server handling of the spot.
|
||||||
|
* POST `/spot` now supports Google reCaptcha and (if the site owner has set it up) now requires `captcha_token` in order to successfully submit. (This is used to lock down the submit function and prevent submission via Spothole by bots or third-party clients.)
|
||||||
|
* GET `/options` now returns `spot_submit_providers`, a map of SIG names to the names of providers that support upstream spot submission for that SIG. (This allows clients to present the user with options of where a new spot can be sent to.)
|
||||||
|
* **Breaking change:** A user's QRZ.com and HamQTH credentials are now supplied as request headers (`X-QRZ-Username`, `X-QRZ-Password`, `X-QRZ-Session-Key`, `X-HamQTH-Username`, `X-HamQTH-Password`, `X-HamQTH-Session-ID`) rather than query parameters, to keep credentials out of server logs.
|
||||||
|
* Added `sig_ref_data_providers`, `static_data_providers` and `callsign_data_providers` to `/status` response
|
||||||
|
* Added `callsign_data_providers` to `/options` response
|
||||||
|
* **Breaking change:** Removed `cleanup` from `/status` response
|
||||||
|
* **Breaking change:** in the `/options` response, renamed `spot_sources` and `alert_sources` to `spot_providers` and
|
||||||
|
`alert_providers`
|
||||||
|
|
||||||
|
### 1.5
|
||||||
|
|
||||||
|
No API changes.
|
||||||
|
|
||||||
### 1.4
|
### 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.
|
||||||
@@ -22,8 +39,6 @@ info:
|
|||||||
* Renamed some SIGs to avoid confusion between Towers, Tiles and Toilets
|
* 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
|
* 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`.
|
it differs from their `name`.
|
||||||
* Added `propagation_mode` field to spots
|
|
||||||
* Added `sig_ref_data_providers` and `static_data_providers` to status and removed `cleanup`
|
|
||||||
|
|
||||||
### 1.3
|
### 1.3
|
||||||
|
|
||||||
@@ -46,10 +61,10 @@ info:
|
|||||||
license:
|
license:
|
||||||
name: The Unlicense
|
name: The Unlicense
|
||||||
url: https://unlicense.org/#the-unlicense
|
url: https://unlicense.org/#the-unlicense
|
||||||
version: v1.4
|
version: 2.0
|
||||||
|
|
||||||
servers:
|
servers:
|
||||||
- url: https://spothole.app/api/v1
|
- url: https://spothole.app/api/v2
|
||||||
|
|
||||||
tags:
|
tags:
|
||||||
- name: Spots
|
- name: Spots
|
||||||
@@ -324,13 +339,14 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/CallLookup'
|
$ref: '#/components/schemas/CallsignData'
|
||||||
'422':
|
'422':
|
||||||
description: Validation error e.g. callsign missing or format incorrect
|
description: Validation error e.g. callsign missing or format incorrect
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
type: string
|
||||||
|
example: "Failed"
|
||||||
|
|
||||||
|
|
||||||
/lookup/sigref:
|
/lookup/sigref:
|
||||||
@@ -358,7 +374,8 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
type: string
|
||||||
|
example: "Failed"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -384,7 +401,8 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
type: string
|
||||||
|
example: "Failed"
|
||||||
|
|
||||||
|
|
||||||
/spot:
|
/spot:
|
||||||
@@ -393,50 +411,53 @@ paths:
|
|||||||
- Spots
|
- Spots
|
||||||
summary: Add a spot
|
summary: Add a spot
|
||||||
description: >
|
description: >
|
||||||
Supply a new spot object, which will be added to the system. Currently, this will not be
|
Supply a JSON object containing a `spot` sub-object (the spot data) and an optional `handling` sub-object
|
||||||
reported up the chain to a cluster, POTA, SOTA etc. This may be introduced in a future version.
|
containing server-side instructions such as upstream submission). Check `spot_submit_providers` in the
|
||||||
cURL example: `curl --request POST --header "Content-Type: application/json" --data
|
`/options` response to see which SIGs and providers support upstream submission. cURL example:
|
||||||
'{"dx_call":"M0TRT","time":1760019539, "freq":14200000, "comment":"Test spot please ignore",
|
`curl --request POST --header \"Content-Type: application/json\" --data '{\"spot\":{\"dx_call\":\"M0TRT\",\"time\":1760019539,\"freq\":14200000,\"comment\":\"Test spot please ignore\",\"de_call\":\"M0TRT\"}}' https://spothole.app/api/v2/spot`"
|
||||||
"de_call":"M0TRT"}' https://spothole.app/api/v1/spot`
|
|
||||||
operationId: spot
|
operationId: spot
|
||||||
requestBody:
|
requestBody:
|
||||||
description: The JSON spot object
|
description: Object containing a "spot" sub-object with the spot data, and an optional "handling" sub-object with server-side instructions of what to do with it.
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Spot'
|
$ref: '#/components/schemas/SpotSubmission'
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'201':
|
||||||
description: Success
|
description: Success
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/OkResponse'
|
type: string
|
||||||
|
example: "OK"
|
||||||
'415':
|
'415':
|
||||||
description: Incorrect Content-Type
|
description: Incorrect Content-Type
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
type: string
|
||||||
|
example: "Failed"
|
||||||
'422':
|
'422':
|
||||||
description: Validation error
|
description: Validation error
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
type: string
|
||||||
|
example: "Failed"
|
||||||
'500':
|
'500':
|
||||||
description: Internal server error
|
description: Internal server error
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
type: string
|
||||||
|
example: "Failed"
|
||||||
|
|
||||||
components:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
QrzUsername:
|
QrzUsername:
|
||||||
name: qrz_username
|
name: X-QRZ-Username
|
||||||
in: query
|
in: header
|
||||||
description: >
|
description: >
|
||||||
QRZ.com username for online callsign lookup, which will enrich the returned spots and alerts
|
QRZ.com username for online callsign lookup, which will enrich the returned spots and alerts
|
||||||
with extra data. Requires a QRZ.com XML Subscriber (paid) account. Supply together with
|
with extra data. Requires a QRZ.com XML Subscriber (paid) account. Supply together with
|
||||||
@@ -444,14 +465,14 @@ components:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
QrzPassword:
|
QrzPassword:
|
||||||
name: qrz_password
|
name: X-QRZ-Password
|
||||||
in: query
|
in: header
|
||||||
description: QRZ.com password. Supply together with `qrz_username`.
|
description: QRZ.com password. Supply together with `qrz_username`.
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
QrzSessionKey:
|
QrzSessionKey:
|
||||||
name: qrz_session_key
|
name: X-QRZ-Session-Key
|
||||||
in: query
|
in: header
|
||||||
description: >
|
description: >
|
||||||
A pre-obtained QRZ.com XML session key, as an alternative to supplying `qrz_username` and
|
A pre-obtained QRZ.com XML session key, as an alternative to supplying `qrz_username` and
|
||||||
`qrz_password`. See https://www.qrz.com/docs/xml/current_spec.html for details on how to
|
`qrz_password`. See https://www.qrz.com/docs/xml/current_spec.html for details on how to
|
||||||
@@ -459,22 +480,22 @@ components:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
HamqthUsername:
|
HamqthUsername:
|
||||||
name: hamqth_username
|
name: X-HamQTH-Username
|
||||||
in: query
|
in: header
|
||||||
description: >
|
description: >
|
||||||
HamQTH username for online callsign lookup, which will enrich the returned spots and alerts
|
HamQTH username for online callsign lookup, which will enrich the returned spots and alerts
|
||||||
with extra data. Supply together with `hamqth_password`, or supply `hamqth_session_id` instead.
|
with extra data. Supply together with `hamqth_password`, or supply `hamqth_session_id` instead.
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
HamqthPassword:
|
HamqthPassword:
|
||||||
name: hamqth_password
|
name: X-HamQTH-Password
|
||||||
in: query
|
in: header
|
||||||
description: HamQTH password. Supply together with `hamqth_username`.
|
description: HamQTH password. Supply together with `hamqth_username`.
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
HamqthSessionId:
|
HamqthSessionId:
|
||||||
name: hamqth_session_id
|
name: X-HamQTH-Session-ID
|
||||||
in: query
|
in: header
|
||||||
description: >
|
description: >
|
||||||
A pre-obtained HamQTH session ID, as an alternative to supplying `hamqth_username` and
|
A pre-obtained HamQTH session ID, as an alternative to supplying `hamqth_username` and
|
||||||
`hamqth_password`. See https://www.hamqth.com/developers.php for details on how to retrieve
|
`hamqth_password`. See https://www.hamqth.com/developers.php for details on how to retrieve
|
||||||
@@ -1185,6 +1206,54 @@ components:
|
|||||||
$ref: "#/components/schemas/PropagationMode"
|
$ref: "#/components/schemas/PropagationMode"
|
||||||
|
|
||||||
|
|
||||||
|
SpotSubmission:
|
||||||
|
description: >
|
||||||
|
Request body for POST /spot. Contains a "spot" sub-object with the spot data, and an optional
|
||||||
|
"handling" sub-object with server-side instructions consumed by Spothole.
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- spot
|
||||||
|
properties:
|
||||||
|
spot:
|
||||||
|
$ref: '#/components/schemas/Spot'
|
||||||
|
handling:
|
||||||
|
type: object
|
||||||
|
description: >
|
||||||
|
Optional server-side instructions for how to process this spot submission.
|
||||||
|
properties:
|
||||||
|
submit_upstream:
|
||||||
|
type: boolean
|
||||||
|
description: >
|
||||||
|
If true, forward the spot to an external upstream provider (e.g. POTA, SOTA) rather
|
||||||
|
than only adding it to this Spothole server. Requires `sig`, at least one `sig_refs`
|
||||||
|
entry, and `upstream_provider` to be set. Check `spot_submit_providers` in the
|
||||||
|
/options response to see which SIGs and providers support this.
|
||||||
|
default: false
|
||||||
|
upstream_provider:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
Name of the upstream provider to submit the spot to, e.g. "POTA" or "SOTA". Must
|
||||||
|
match one of the provider names returned in `spot_submit_providers` for the chosen SIG.
|
||||||
|
example: POTA
|
||||||
|
upstream_credentials:
|
||||||
|
type: object
|
||||||
|
description: >
|
||||||
|
Provider-specific credentials required to authenticate the upstream submission.
|
||||||
|
The required keys depend on the provider. Credentials are used only for the upstream
|
||||||
|
call and are never stored by Spothole.
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
example:
|
||||||
|
user_id: "12345"
|
||||||
|
api_key: "abc123"
|
||||||
|
captcha_token:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
A Google reCAPTCHA v2 response token. Required when submitting upstream if the
|
||||||
|
server has reCAPTCHA configured. Obtain the token by completing the reCAPTCHA
|
||||||
|
widget rendered on the Add Spot page.
|
||||||
|
example: "03AFY_a8Xq..."
|
||||||
|
|
||||||
SpotStream:
|
SpotStream:
|
||||||
type: object
|
type: object
|
||||||
description: A server-sent event containing a spot
|
description: A server-sent event containing a spot
|
||||||
@@ -1769,6 +1838,32 @@ components:
|
|||||||
description: The number of references fetched using this provider.
|
description: The number of references fetched using this provider.
|
||||||
example: 1234
|
example: 1234
|
||||||
|
|
||||||
|
CallsignDataProviderStatus:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
sig_name:
|
||||||
|
type: string
|
||||||
|
description: The name of the provider.
|
||||||
|
example: Country Files
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
description: Whether the provider is enabled or not.
|
||||||
|
example: true
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
description: The status of the provider.
|
||||||
|
example: OK
|
||||||
|
last_updated:
|
||||||
|
type: number
|
||||||
|
description: >
|
||||||
|
The last time at which this provider received data, UTC seconds since UNIX epoch. If this
|
||||||
|
is zero, the provider has never updated.
|
||||||
|
example: 1759579508
|
||||||
|
lookup_count:
|
||||||
|
type: number
|
||||||
|
description: The number of callsign lookups performed using this provider since the server was started.
|
||||||
|
example: 1234
|
||||||
|
|
||||||
SpotList:
|
SpotList:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -1779,14 +1874,6 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/Alert'
|
$ref: '#/components/schemas/Alert'
|
||||||
|
|
||||||
OkResponse:
|
|
||||||
type: string
|
|
||||||
example: "OK"
|
|
||||||
|
|
||||||
ErrorResponse:
|
|
||||||
type: string
|
|
||||||
example: "Failed"
|
|
||||||
|
|
||||||
DxStats:
|
DxStats:
|
||||||
type: object
|
type: object
|
||||||
description: Spot counts keyed by DE continent
|
description: Spot counts keyed by DE continent
|
||||||
@@ -1876,6 +1963,11 @@ components:
|
|||||||
description: An array of all the SIG reference data providers.
|
description: An array of all the SIG reference data providers.
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/SIGRefDataProviderStatus'
|
$ref: '#/components/schemas/SIGRefDataProviderStatus'
|
||||||
|
callsign_data_providers:
|
||||||
|
type: array
|
||||||
|
description: An array of all the callsign data providers.
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/CallsignDataProviderStatus'
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
type: object
|
type: object
|
||||||
@@ -1902,12 +1994,24 @@ components:
|
|||||||
description: An array of all the supported Special Interest Groups.
|
description: An array of all the supported Special Interest Groups.
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/SIG'
|
$ref: '#/components/schemas/SIG'
|
||||||
sources:
|
spot_providers:
|
||||||
type: array
|
type: array
|
||||||
description: An array of all the supported data sources.
|
description: An array of all the supported spot data sources.
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
example: "Cluster"
|
example: "Cluster"
|
||||||
|
alert_providers:
|
||||||
|
type: array
|
||||||
|
description: An array of all the supported alert data sources.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: "POTA"
|
||||||
|
callsign_data_providers:
|
||||||
|
type: array
|
||||||
|
description: An array of all the supported callsign lookup providers.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: "QRZ.com"
|
||||||
continents:
|
continents:
|
||||||
type: array
|
type: array
|
||||||
description: An array of all the supported continents.
|
description: An array of all the supported continents.
|
||||||
@@ -1923,7 +2027,7 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
description: >
|
description: >
|
||||||
The maximum age, in seconds, of any spot before it will be deleted by the system. When
|
The maximum age, in seconds, of any spot before it will be deleted by the system. When
|
||||||
querying the /api/v1/spots endpoint and providing a "max_age" or "since" parameter, there
|
querying the /api/v2/spots endpoint and providing a "max_age" or "since" parameter, there
|
||||||
is no point providing a number larger than this, because the system drops all spots older
|
is no point providing a number larger than this, because the system drops all spots older
|
||||||
than this.
|
than this.
|
||||||
example: 3600
|
example: 3600
|
||||||
@@ -1933,13 +2037,31 @@ components:
|
|||||||
Whether the POST /spot call, to add spots to the server directly via its API, is permitted
|
Whether the POST /spot call, to add spots to the server directly via its API, is permitted
|
||||||
on this server.
|
on this server.
|
||||||
example: true
|
example: true
|
||||||
|
spot_submit_providers:
|
||||||
|
type: object
|
||||||
|
description: >
|
||||||
|
A map of SIG name to a list of provider names that support upstream spot submission for that SIG.
|
||||||
|
If a SIG appears as a key here, the POST /spot endpoint accepts `submit_upstream: true` for
|
||||||
|
spots with that SIG, and will forward the spot to one of the listed providers. Omitted if no
|
||||||
|
providers support upstream submission.
|
||||||
|
additionalProperties:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example:
|
||||||
|
POTA: [ POTA ]
|
||||||
|
SOTA: [ SOTA, GMA, ParksNPeaks ]
|
||||||
|
|
||||||
CallLookup:
|
CallsignData:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
call:
|
call:
|
||||||
type: string
|
type: string
|
||||||
description: Callsign, as provided to the API
|
description: Callsign, as provided to the API
|
||||||
|
example: DL/M0TRT/P
|
||||||
|
home_call:
|
||||||
|
type: string
|
||||||
|
description: The "home" call, without prefixes or suffixes
|
||||||
example: M0TRT
|
example: M0TRT
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
+219
-52
@@ -1,7 +1,35 @@
|
|||||||
|
// Credentials schema per provider name. Defines the fields to collect and how to label them.
|
||||||
|
const PROVIDER_CREDENTIAL_SCHEMAS = {
|
||||||
|
// todo Figure out SOTA authentication
|
||||||
|
// see e.g. https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAAccount.jsx
|
||||||
|
// https://github.com/ham2k/app-polo/blob/main/src/store/apis/apiSOTA/apiSOTA.js
|
||||||
|
// Refresh token? Way to show user that they need to log in again because cached credentials aren't valid?
|
||||||
|
// todo type: text/password distinction on text boxes so API keys can be obscured
|
||||||
|
"SOTA": [
|
||||||
|
{key: "access_token", label: "SOTA Access Token", help: ""},
|
||||||
|
{key: "id_token", label: "SOTA ID Token", help: "TODO SOTA authentication to provide this..."}
|
||||||
|
],
|
||||||
|
"ParksNPeaks": [
|
||||||
|
{key: "user_id", label: "Parks N Peaks User ID", help: ""},
|
||||||
|
{key: "api_key", label: "Parks N Peaks API Key", help: "Get your API key from your Parks N Peaks account."}
|
||||||
|
],
|
||||||
|
"ZLOTA": [
|
||||||
|
{key: "user_id", label: "ZLOTA User ID", help: ""},
|
||||||
|
{key: "api_key", label: "ZLOTA User PIN", help: "Get your PIN from your ZLOTA account."}
|
||||||
|
],
|
||||||
|
"Tiles": [
|
||||||
|
{
|
||||||
|
key: "offline_spot_gateway_pin",
|
||||||
|
label: "Offline Spot Gateway PIN",
|
||||||
|
help: "Get your PIN from your Tiles on the Air account profile."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
// Load server options. Once a successful callback is made from this, we can populate the choice boxes in the form and load
|
// Load server options. Once a successful callback is made from this, we can populate the choice boxes in the form and load
|
||||||
// any saved values from local storage.
|
// any saved values from local storage.
|
||||||
function loadOptions() {
|
function loadOptions() {
|
||||||
$.getJSON('/api/v1/options', function (jsonData) {
|
$.getJSON('/api/v2/options', function (jsonData) {
|
||||||
// Store options
|
// Store options
|
||||||
options = jsonData;
|
options = jsonData;
|
||||||
|
|
||||||
@@ -21,11 +49,144 @@ function loadOptions() {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load reCAPTCHA if a site key is configured (key is inlined into page by server)
|
||||||
|
if (window._recaptchaSiteKey) {
|
||||||
|
loadRecaptcha(window._recaptchaSiteKey);
|
||||||
|
}
|
||||||
|
|
||||||
// Load settings from settings storage now all the controls are available
|
// Load settings from settings storage now all the controls are available
|
||||||
loadSettings();
|
loadSettings();
|
||||||
|
|
||||||
|
// Update the upstream area for any pre-selected SIG
|
||||||
|
updateUpstreamArea();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load and inject the reCAPTCHA script
|
||||||
|
function loadRecaptcha(siteKey) {
|
||||||
|
window._recaptchaSiteKey = siteKey;
|
||||||
|
if (!document.getElementById('recaptcha-script')) {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.id = 'recaptcha-script';
|
||||||
|
script.src = 'https://www.google.com/recaptcha/api.js?render=explicit&onload=renderRecaptcha';
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
$("#recaptcha-area").show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by reCAPTCHA after its script loads
|
||||||
|
function renderRecaptcha() {
|
||||||
|
window._recaptchaWidgetId = grecaptcha.render('recaptcha-widget', {
|
||||||
|
sitekey: window._recaptchaSiteKey,
|
||||||
|
size: 'normal'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the "Send spot to..." area based on the currently selected SIG
|
||||||
|
function updateUpstreamArea() {
|
||||||
|
if (!window._allowUpstreamSpotting || !options || !options["spot_submit_providers"]) {
|
||||||
|
$("#upstream-area").hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sig = $("#sig").val();
|
||||||
|
const providers = (sig && options["spot_submit_providers"][sig]) ? options["spot_submit_providers"][sig] : [];
|
||||||
|
|
||||||
|
if (providers.length === 0) {
|
||||||
|
$("#upstream-area").hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#upstream-area").show();
|
||||||
|
|
||||||
|
// Update the provider selector
|
||||||
|
$("#upstream-provider-select").empty();
|
||||||
|
$.each(providers, function (i, name) {
|
||||||
|
$("#upstream-provider-select").append($('<option>', {value: name, text: name}));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (providers.length > 1) {
|
||||||
|
$("#upstream-provider-label").text("upstream spot sources:");
|
||||||
|
$("#upstream-provider-select-col").show();
|
||||||
|
} else {
|
||||||
|
$("#upstream-provider-label").text(providers[0]);
|
||||||
|
$("#upstream-provider-select-col").hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the credentials button if this provider has an authentication mechanism and we need input from the user
|
||||||
|
updateCredentialsButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the credentials button visibility based on selected provider
|
||||||
|
function updateCredentialsButton() {
|
||||||
|
const providerName = getSelectedUpstreamProvider();
|
||||||
|
if (providerName && PROVIDER_CREDENTIAL_SCHEMAS[providerName]) {
|
||||||
|
$("#upstream-credentials-btn").show();
|
||||||
|
} else {
|
||||||
|
$("#upstream-credentials-btn").hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the currently selected upstream provider name
|
||||||
|
function getSelectedUpstreamProvider() {
|
||||||
|
const providers = (options && options["spot_submit_providers"] && $("#sig").val())
|
||||||
|
? (options["spot_submit_providers"][$("#sig").val()] || [])
|
||||||
|
: [];
|
||||||
|
if (providers.length === 0) return null;
|
||||||
|
if (providers.length === 1) return providers[0];
|
||||||
|
return $("#upstream-provider-select").val();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the credentials modal for the currently selected upstream provider
|
||||||
|
function showCredentialsModal() {
|
||||||
|
const providerName = getSelectedUpstreamProvider();
|
||||||
|
if (!providerName || !PROVIDER_CREDENTIAL_SCHEMAS[providerName]) return;
|
||||||
|
|
||||||
|
const schema = PROVIDER_CREDENTIAL_SCHEMAS[providerName];
|
||||||
|
const stored = loadCredentials(providerName);
|
||||||
|
|
||||||
|
$("#credentials-provider-name").text(providerName);
|
||||||
|
$("#credentials-fields").empty();
|
||||||
|
|
||||||
|
$.each(schema, function (i, field) {
|
||||||
|
const val = stored[field.key] || "";
|
||||||
|
let html = '<div class="mb-3">';
|
||||||
|
html += '<label for="cred-' + field.key + '" class="form-label">' + field.label + '</label>';
|
||||||
|
html += '<input type="text" class="form-control" id="cred-' + field.key + '" value="' + $('<div>').text(val).html() + '">';
|
||||||
|
if (field.help) {
|
||||||
|
html += '<div class="form-text">' + field.help + '</div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
$("#credentials-fields").append(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store provider name for saveCredentials()
|
||||||
|
$("#credentials-modal").data("provider", providerName);
|
||||||
|
new bootstrap.Modal(document.getElementById('credentials-modal')).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save credentials from the modal to local storage
|
||||||
|
function saveCredentials() {
|
||||||
|
const providerName = $("#credentials-modal").data("provider");
|
||||||
|
if (!providerName || !PROVIDER_CREDENTIAL_SCHEMAS[providerName]) return;
|
||||||
|
|
||||||
|
const schema = PROVIDER_CREDENTIAL_SCHEMAS[providerName];
|
||||||
|
const creds = {};
|
||||||
|
$.each(schema, function (i, field) {
|
||||||
|
creds[field.key] = $("#cred-" + field.key).val();
|
||||||
|
});
|
||||||
|
localStorage.setItem("upstream-credentials-" + providerName, JSON.stringify(creds));
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('credentials-modal')).hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load credentials for a provider from local storage
|
||||||
|
function loadCredentials(providerName) {
|
||||||
|
const stored = localStorage.getItem("upstream-credentials-" + providerName);
|
||||||
|
return stored ? JSON.parse(stored) : {};
|
||||||
|
}
|
||||||
|
|
||||||
// Method called to add a spot to the server
|
// Method called to add a spot to the server
|
||||||
function addSpot() {
|
function addSpot() {
|
||||||
try {
|
try {
|
||||||
@@ -42,57 +203,65 @@ function addSpot() {
|
|||||||
const comment = $("#comment").val();
|
const comment = $("#comment").val();
|
||||||
const de = $("#de-call").val().toUpperCase();
|
const de = $("#de-call").val().toUpperCase();
|
||||||
|
|
||||||
|
// Prepare the spot object for the server
|
||||||
const spot = {};
|
const spot = {};
|
||||||
if (dx !== "") {
|
spot["dx_call"] = dx;
|
||||||
spot["dx_call"] = dx;
|
spot["freq"] = parseFloat(freqStr) * 1000;
|
||||||
} else {
|
if (mode !== "") spot["mode"] = mode;
|
||||||
showAddSpotError("A DX callsign is required in order to spot.");
|
if (sig !== "") spot["sig"] = sig;
|
||||||
return;
|
if (sigRef !== "") spot["sig_refs"] = [{sig: sig, id: sigRef}];
|
||||||
}
|
if (dxGrid !== "") spot["dx_grid"] = dxGrid;
|
||||||
if (freqStr !== "") {
|
if (comment !== "") spot["comment"] = comment;
|
||||||
spot["freq"] = parseFloat(freqStr) * 1000;
|
spot["de_call"] = de;
|
||||||
} else {
|
|
||||||
showAddSpotError("A frequency is required in order to spot.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mode !== "") {
|
|
||||||
spot["mode"] = mode;
|
|
||||||
}
|
|
||||||
if (sig !== "") {
|
|
||||||
spot["sig"] = sig;
|
|
||||||
}
|
|
||||||
if (sigRef !== "") {
|
|
||||||
spot["sig_refs"] = [{sig: sig, id: sigRef}];
|
|
||||||
}
|
|
||||||
if (dxGrid !== "") {
|
|
||||||
spot["dx_grid"] = dxGrid;
|
|
||||||
}
|
|
||||||
if (comment !== "") {
|
|
||||||
spot["comment"] = comment;
|
|
||||||
}
|
|
||||||
if (de !== "") {
|
|
||||||
spot["de_call"] = de;
|
|
||||||
} else {
|
|
||||||
showAddSpotError("A spotter callsign is required in order to spot.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
spot["time"] = moment.utc().valueOf() / 1000.0;
|
spot["time"] = moment.utc().valueOf() / 1000.0;
|
||||||
|
|
||||||
$.ajax("/api/v1/spot", {
|
// Prepare "handling" structure to tell the server what to do with this spot
|
||||||
data: JSON.stringify(spot),
|
const handling = {};
|
||||||
|
|
||||||
|
// Add CAPTCHA token if reCAPTCHA is loaded
|
||||||
|
if (window._recaptchaWidgetId !== undefined) {
|
||||||
|
handling["captcha_token"] = grecaptcha.getResponse(window._recaptchaWidgetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upstream submission
|
||||||
|
const submitUpstream = $("#submit-upstream").is(":checked");
|
||||||
|
const upstreamProviderName = getSelectedUpstreamProvider();
|
||||||
|
if (submitUpstream && upstreamProviderName) {
|
||||||
|
handling["submit_upstream"] = true;
|
||||||
|
handling["upstream_provider"] = upstreamProviderName;
|
||||||
|
handling["upstream_credentials"] = loadCredentials(upstreamProviderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
$.ajax("/api/v2/spot", {
|
||||||
|
data: JSON.stringify({spot, handling}),
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
success: async function () {
|
success: async function (result) {
|
||||||
$("#result-good").html("<div class='alert alert-success fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-check'></i> Spot submitted. Returning you to the spots list...</div>");
|
// Reset CAPTCHA for next use
|
||||||
|
if (window._recaptchaWidgetId !== undefined) {
|
||||||
|
grecaptcha.reset(window._recaptchaWidgetId);
|
||||||
|
}
|
||||||
|
if (result && result.startsWith && result.startsWith("Warning")) {
|
||||||
|
$("#result-good").html("<div class='alert alert-warning fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-triangle-exclamation'></i> " + result + " Returning you to the spots list...</div>");
|
||||||
|
} else {
|
||||||
|
$("#result-good").html("<div class='alert alert-success fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-check'></i> Spot submitted. Returning you to the spots list...</div>");
|
||||||
|
}
|
||||||
$("#result-bad").html("");
|
$("#result-bad").html("");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
$("#result-good").hide();
|
$("#result-good").hide();
|
||||||
window.location.replace("/");
|
window.location.replace("/");
|
||||||
}, 1000);
|
}, 2000);
|
||||||
},
|
},
|
||||||
error: function (result) {
|
error: function (result) {
|
||||||
showAddSpotError(result.responseText.slice(1, -1));
|
if (window._recaptchaWidgetId !== undefined) {
|
||||||
|
grecaptcha.reset(window._recaptchaWidgetId);
|
||||||
|
}
|
||||||
|
if (result.responseText) {
|
||||||
|
showAddSpotError(result.responseText.slice(1, -1));
|
||||||
|
} else {
|
||||||
|
showAddSpotError("The server did not return a response.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -121,20 +290,18 @@ $("#mode").change(function () {
|
|||||||
$(this).val($(this).val().trim().toUpperCase());
|
$(this).val($(this).val().trim().toUpperCase());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Display the intro box, unless the user has already dismissed it once.
|
// Update upstream area and credentials button when SIG changes
|
||||||
function displayIntroBox() {
|
$("#sig").change(function () {
|
||||||
if (localStorage.getItem("add-spot-intro-box-dismissed") == null) {
|
updateUpstreamArea();
|
||||||
$("#add-spot-intro-box").show();
|
});
|
||||||
}
|
|
||||||
$("#add-spot-intro-box-dismiss").click(function () {
|
// Update credentials button when provider selector changes
|
||||||
localStorage.setItem("add-spot-intro-box-dismissed", true);
|
$("#upstream-provider-select").change(function () {
|
||||||
});
|
updateCredentialsButton();
|
||||||
}
|
});
|
||||||
|
|
||||||
// Startup
|
// Startup
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
// Load options
|
// Load options
|
||||||
loadOptions();
|
loadOptions();
|
||||||
// Display intro box
|
});
|
||||||
displayIntroBox();
|
|
||||||
});
|
|
||||||
|
|||||||
+5
-8
@@ -8,7 +8,7 @@ let alerts = [];
|
|||||||
|
|
||||||
// Load alerts and populate the table.
|
// Load alerts and populate the table.
|
||||||
function loadAlerts() {
|
function loadAlerts() {
|
||||||
$.getJSON('/api/v1/alerts' + buildQueryString(false), function (jsonData) {
|
$.ajax({url: '/api/v2/alerts' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
|
||||||
// Store last updated time
|
// Store last updated time
|
||||||
lastUpdateTime = moment.utc();
|
lastUpdateTime = moment.utc();
|
||||||
updateRefreshDisplay();
|
updateRefreshDisplay();
|
||||||
@@ -16,11 +16,11 @@ function loadAlerts() {
|
|||||||
alerts = jsonData;
|
alerts = jsonData;
|
||||||
// Update table
|
// Update table
|
||||||
updateTable();
|
updateTable();
|
||||||
});
|
}});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
function buildQueryString(includeCredentials) {
|
function buildQueryString() {
|
||||||
let str = "?";
|
let str = "?";
|
||||||
["dx_continent", "source"].forEach(fn => {
|
["dx_continent", "source"].forEach(fn => {
|
||||||
if (!allFilterOptionsSelected(fn)) {
|
if (!allFilterOptionsSelected(fn)) {
|
||||||
@@ -35,9 +35,6 @@ function buildQueryString(includeCredentials) {
|
|||||||
if ($("#dxpeditions_skip_max_duration_check")[0].checked) {
|
if ($("#dxpeditions_skip_max_duration_check")[0].checked) {
|
||||||
str = str + "&dxpeditions_skip_max_duration_check=true";
|
str = str + "&dxpeditions_skip_max_duration_check=true";
|
||||||
}
|
}
|
||||||
if (includeCredentials) {
|
|
||||||
str = str + getCredentialQueryString();
|
|
||||||
}
|
|
||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,13 +279,13 @@ function addAlertRowsToTable(tbody, alerts) {
|
|||||||
|
|
||||||
// Load server options. Once a successful callback is made from this, we then query alerts.
|
// Load server options. Once a successful callback is made from this, we then query alerts.
|
||||||
function loadOptions() {
|
function loadOptions() {
|
||||||
$.getJSON('/api/v1/options', function (jsonData) {
|
$.getJSON('/api/v2/options', function (jsonData) {
|
||||||
// Store options
|
// Store options
|
||||||
options = jsonData;
|
options = jsonData;
|
||||||
|
|
||||||
// Populate the filters panel
|
// Populate the filters panel
|
||||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||||
generateMultiToggleFilterCard("#source-options", "source", options["alert_sources"]);
|
generateMultiToggleFilterCard("#source-options", "source", options["alert_providers"]);
|
||||||
|
|
||||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||||
|
|||||||
+11
-10
@@ -20,13 +20,14 @@ function loadSpots() {
|
|||||||
if (evtSource != null) {
|
if (evtSource != null) {
|
||||||
evtSource.close();
|
evtSource.close();
|
||||||
}
|
}
|
||||||
$.getJSON('/api/v1/spots' + buildQueryString(), function (jsonData) {
|
$.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
|
||||||
// Store data
|
// Store data
|
||||||
spots = jsonData;
|
spots = jsonData;
|
||||||
// Update bands display
|
// Update bands display
|
||||||
updateBands();
|
updateBands();
|
||||||
// Start the ongoing SSE connection
|
// Start the ongoing SSE connection
|
||||||
startSSEConnection();
|
startSSEConnection();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ function startSSEConnection() {
|
|||||||
if (evtSource != null) {
|
if (evtSource != null) {
|
||||||
evtSource.close();
|
evtSource.close();
|
||||||
}
|
}
|
||||||
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString());
|
evtSource = new EventSource('/api/v2/spots/stream' + buildQueryString());
|
||||||
|
|
||||||
evtSource.onmessage = function (event) {
|
evtSource.onmessage = function (event) {
|
||||||
const newSpot = JSON.parse(event.data);
|
const newSpot = JSON.parse(event.data);
|
||||||
@@ -273,7 +274,7 @@ function removeDuplicatesForBandPanel(spotList) {
|
|||||||
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
|
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
|
||||||
// spots repeatedly.
|
// spots repeatedly.
|
||||||
function loadOptions() {
|
function loadOptions() {
|
||||||
$.getJSON('/api/v1/options', function (jsonData) {
|
$.getJSON('/api/v2/options', function (jsonData) {
|
||||||
// Store options
|
// Store options
|
||||||
options = jsonData;
|
options = jsonData;
|
||||||
|
|
||||||
@@ -291,7 +292,7 @@ function loadOptions() {
|
|||||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
||||||
generateModesMultiToggleFilterCard(options["modes"]);
|
generateModesMultiToggleFilterCard(options["modes"]);
|
||||||
generateSourcesMultiToggleFilterCard(options["spot_sources"], spotProvidersEnabledByDefault);
|
generateSourcesMultiToggleFilterCard(options["spot_providers"], spotProvidersEnabledByDefault);
|
||||||
|
|
||||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||||
|
|||||||
+8
-8
@@ -250,23 +250,23 @@ function closeDataPanel() {
|
|||||||
closePanel("#data-area");
|
closePanel("#data-area");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a query string fragment containing any QRZ.com / HamQTH credentials the user has supplied,
|
// Build a headers object containing any QRZ.com / HamQTH credentials the user has supplied,
|
||||||
// provided the corresponding "enabled" checkbox is ticked.
|
// provided the corresponding "enabled" checkbox is ticked.
|
||||||
function getCredentialQueryString() {
|
function getCredentialHeaders() {
|
||||||
let str = "";
|
const headers = {};
|
||||||
if ($("#qrz-enabled")[0] && $("#qrz-enabled")[0].checked) {
|
if ($("#qrz-enabled")[0] && $("#qrz-enabled")[0].checked) {
|
||||||
const qrzUsername = $("#qrz-username").val();
|
const qrzUsername = $("#qrz-username").val();
|
||||||
const qrzPassword = $("#qrz-password").val();
|
const qrzPassword = $("#qrz-password").val();
|
||||||
if (qrzUsername) str += "&qrz_username=" + encodeURIComponent(qrzUsername);
|
if (qrzUsername) headers["X-QRZ-Username"] = qrzUsername;
|
||||||
if (qrzPassword) str += "&qrz_password=" + encodeURIComponent(qrzPassword);
|
if (qrzPassword) headers["X-QRZ-Password"] = qrzPassword;
|
||||||
}
|
}
|
||||||
if ($("#hamqth-enabled")[0] && $("#hamqth-enabled")[0].checked) {
|
if ($("#hamqth-enabled")[0] && $("#hamqth-enabled")[0].checked) {
|
||||||
const hamqthUsername = $("#hamqth-username").val();
|
const hamqthUsername = $("#hamqth-username").val();
|
||||||
const hamqthPassword = $("#hamqth-password").val();
|
const hamqthPassword = $("#hamqth-password").val();
|
||||||
if (hamqthUsername) str += "&hamqth_username=" + encodeURIComponent(hamqthUsername);
|
if (hamqthUsername) headers["X-HamQTH-Username"] = hamqthUsername;
|
||||||
if (hamqthPassword) str += "&hamqth_password=" + encodeURIComponent(hamqthPassword);
|
if (hamqthPassword) headers["X-HamQTH-Password"] = hamqthPassword;
|
||||||
}
|
}
|
||||||
return str;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ let ionosondeChart = null;
|
|||||||
|
|
||||||
// Load solar conditions
|
// Load solar conditions
|
||||||
function loadSolarConditions() {
|
function loadSolarConditions() {
|
||||||
$.getJSON('/api/v1/solar', function (jsonData) {
|
$.getJSON('/api/v2/solar', function (jsonData) {
|
||||||
|
|
||||||
// HF
|
// HF
|
||||||
|
|
||||||
@@ -539,7 +539,7 @@ function renderIonosondeData() {
|
|||||||
ctx.strokeStyle = gridColor;
|
ctx.strokeStyle = gridColor;
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
// Add an extra vertical line for 30MHz, which should correspond to the top of the chart and avoid having
|
// Add an extra horizontal line for 30MHz, which should correspond to the top of the chart and avoid having
|
||||||
// no top "border" gridline
|
// no top "border" gridline
|
||||||
const y30 = scales.y.getPixelForValue(30);
|
const y30 = scales.y.getPixelForValue(30);
|
||||||
if (y30 >= chartArea.top && y30 <= chartArea.bottom) {
|
if (y30 >= chartArea.top && y30 <= chartArea.bottom) {
|
||||||
@@ -660,7 +660,7 @@ function dxStatsContientChanged() {
|
|||||||
|
|
||||||
// Fetch DX stats from the API and render
|
// Fetch DX stats from the API and render
|
||||||
function loadDxStats() {
|
function loadDxStats() {
|
||||||
$.getJSON('/api/v1/dxstats', function (jsonData) {
|
$.getJSON('/api/v2/dxstats', function (jsonData) {
|
||||||
dxStatsData = jsonData;
|
dxStatsData = jsonData;
|
||||||
renderDxStats();
|
renderDxStats();
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-8
@@ -45,7 +45,7 @@ function loadSpots() {
|
|||||||
// 2) (If we have credentials) reload with them, replacing what's already there,
|
// 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
|
// 3) Subscribe to the SSE endpoint (with credentials if we have them) so that updates come with augmented
|
||||||
// data if they can.
|
// data if they can.
|
||||||
$.getJSON('/api/v1/spots' + buildQueryString(false), function (jsonData) {
|
$.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
|
||||||
// Store data
|
// Store data
|
||||||
spots = jsonData;
|
spots = jsonData;
|
||||||
// Update map
|
// Update map
|
||||||
@@ -68,7 +68,7 @@ function loadSpots() {
|
|||||||
// now
|
// now
|
||||||
startSSEConnection();
|
startSSEConnection();
|
||||||
}
|
}
|
||||||
});
|
}});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the SSE connection to receive new spots as they arrive
|
// Start the SSE connection to receive new spots as they arrive
|
||||||
@@ -163,7 +163,7 @@ function removeSpotFromMap(key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build a query string for the API, based on the filters that the user has selected.
|
// Build a query string for the API, based on the filters that the user has selected.
|
||||||
function buildQueryString(includeCredentials) {
|
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)) {
|
||||||
@@ -173,9 +173,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 map view: No dupes, no QRT, only spots with good locations
|
// Additional filters for the map view: No dupes, no QRT, only spots with good locations
|
||||||
str = str + "&dedupe=true&allow_qrt=false";
|
str = str + "&dedupe=true&allow_qrt=false";
|
||||||
if (includeCredentials) {
|
|
||||||
str = str + getCredentialQueryString();
|
|
||||||
}
|
|
||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +301,7 @@ function getTooltipText(s) {
|
|||||||
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
|
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
|
||||||
// spots repeatedly.
|
// spots repeatedly.
|
||||||
function loadOptions() {
|
function loadOptions() {
|
||||||
$.getJSON('/api/v1/options', function (jsonData) {
|
$.getJSON('/api/v2/options', function (jsonData) {
|
||||||
// Store options
|
// Store options
|
||||||
options = jsonData;
|
options = jsonData;
|
||||||
|
|
||||||
@@ -322,7 +319,7 @@ function loadOptions() {
|
|||||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
||||||
generateModesMultiToggleFilterCard(options["modes"]);
|
generateModesMultiToggleFilterCard(options["modes"]);
|
||||||
generateSourcesMultiToggleFilterCard(options["spot_sources"], spotProvidersEnabledByDefault);
|
generateSourcesMultiToggleFilterCard(options["spot_providers"], spotProvidersEnabledByDefault);
|
||||||
|
|
||||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||||
|
|||||||
+6
-9
@@ -20,7 +20,7 @@ function loadSpots() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Make the new query
|
// Make the new query
|
||||||
$.getJSON('/api/v1/spots' + buildQueryString(false), function (jsonData) {
|
$.ajax({url: '/api/v2/spots' + buildQueryString(), dataType: 'json', headers: getCredentialHeaders(), success: function (jsonData) {
|
||||||
// Store data
|
// Store data
|
||||||
spots = jsonData;
|
spots = jsonData;
|
||||||
// Update table
|
// Update table
|
||||||
@@ -30,7 +30,7 @@ function loadSpots() {
|
|||||||
if (run) {
|
if (run) {
|
||||||
startSSEConnection();
|
startSSEConnection();
|
||||||
}
|
}
|
||||||
});
|
}});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start an SSE connection (closing an existing one if it exists). This will then be used to add to the table on the
|
// Start an SSE connection (closing an existing one if it exists). This will then be used to add to the table on the
|
||||||
@@ -39,7 +39,7 @@ function startSSEConnection() {
|
|||||||
if (evtSource != null) {
|
if (evtSource != null) {
|
||||||
evtSource.close();
|
evtSource.close();
|
||||||
}
|
}
|
||||||
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString(true));
|
evtSource = new EventSource('/api/v2/spots/stream' + buildQueryString());
|
||||||
|
|
||||||
evtSource.onmessage = function (event) {
|
evtSource.onmessage = function (event) {
|
||||||
// Get the new spot
|
// Get the new spot
|
||||||
@@ -86,7 +86,7 @@ function startSSEConnection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build a query string for the API, based on the filters that the user has selected.
|
// Build a query string for the API, based on the filters that the user has selected.
|
||||||
function buildQueryString(includeCredentials) {
|
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)) {
|
||||||
@@ -97,9 +97,6 @@ function buildQueryString(includeCredentials) {
|
|||||||
if ($("#search").val() !== "") {
|
if ($("#search").val() !== "") {
|
||||||
str = str + "&text_includes=" + encodeURIComponent($("#search").val());
|
str = str + "&text_includes=" + encodeURIComponent($("#search").val());
|
||||||
}
|
}
|
||||||
if (includeCredentials) {
|
|
||||||
str = str + getCredentialQueryString();
|
|
||||||
}
|
|
||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,7 +415,7 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
|||||||
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
|
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
|
||||||
// spots repeatedly.
|
// spots repeatedly.
|
||||||
function loadOptions() {
|
function loadOptions() {
|
||||||
$.getJSON('/api/v1/options', function (jsonData) {
|
$.getJSON('/api/v2/options', function (jsonData) {
|
||||||
// Store options
|
// Store options
|
||||||
options = jsonData;
|
options = jsonData;
|
||||||
|
|
||||||
@@ -436,7 +433,7 @@ function loadOptions() {
|
|||||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
||||||
generateModesMultiToggleFilterCard(options["modes"]);
|
generateModesMultiToggleFilterCard(options["modes"]);
|
||||||
generateSourcesMultiToggleFilterCard(options["spot_sources"], spotProvidersEnabledByDefault);
|
generateSourcesMultiToggleFilterCard(options["spot_providers"], spotProvidersEnabledByDefault);
|
||||||
|
|
||||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||||
|
|||||||
+17
-7
@@ -1,6 +1,6 @@
|
|||||||
// Load server status
|
// Load server status
|
||||||
function loadStatus() {
|
function loadStatus() {
|
||||||
$.getJSON('/api/v1/status', function (jsonData) {
|
$.getJSON('/api/v2/status', function (jsonData) {
|
||||||
$("#software-version").text(jsonData["software-version"]);
|
$("#software-version").text(jsonData["software-version"]);
|
||||||
$("#server-owner-callsign").text(jsonData["server-owner-callsign"]);
|
$("#server-owner-callsign").text(jsonData["server-owner-callsign"]);
|
||||||
$("#up-since").text(moment().subtract(jsonData["uptime"], 'seconds').fromNow());
|
$("#up-since").text(moment().subtract(jsonData["uptime"], 'seconds').fromNow());
|
||||||
@@ -14,7 +14,7 @@ function loadStatus() {
|
|||||||
|
|
||||||
jsonData["spot_providers"].forEach(p => {
|
jsonData["spot_providers"].forEach(p => {
|
||||||
$("#spot-providers-status-container").append(`
|
$("#spot-providers-status-container").append(`
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
<div class="col"><strong>${p["name"]}</strong></div>
|
<div class="col"><strong>${p["name"]}</strong></div>
|
||||||
<div class="col">Status: ${p["status"]}</div>
|
<div class="col">Status: ${p["status"]}</div>
|
||||||
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
||||||
@@ -24,7 +24,7 @@ function loadStatus() {
|
|||||||
|
|
||||||
jsonData["alert_providers"].forEach(p => {
|
jsonData["alert_providers"].forEach(p => {
|
||||||
$("#alert-providers-status-container").append(`
|
$("#alert-providers-status-container").append(`
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
<div class="col"><strong>${p["name"]}</strong></div>
|
<div class="col"><strong>${p["name"]}</strong></div>
|
||||||
<div class="col">Status: ${p["status"]}</div>
|
<div class="col">Status: ${p["status"]}</div>
|
||||||
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
||||||
@@ -33,7 +33,7 @@ function loadStatus() {
|
|||||||
|
|
||||||
jsonData["solar_condition_providers"].forEach(p => {
|
jsonData["solar_condition_providers"].forEach(p => {
|
||||||
$("#condition-providers-status-container").append(`
|
$("#condition-providers-status-container").append(`
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
<div class="col"><strong>${p["name"]}</strong></div>
|
<div class="col"><strong>${p["name"]}</strong></div>
|
||||||
<div class="col">Status: ${p["status"]}</div>
|
<div class="col">Status: ${p["status"]}</div>
|
||||||
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
||||||
@@ -42,7 +42,7 @@ function loadStatus() {
|
|||||||
|
|
||||||
jsonData["static_data_providers"].forEach(p => {
|
jsonData["static_data_providers"].forEach(p => {
|
||||||
$("#static-data-providers-status-container").append(`
|
$("#static-data-providers-status-container").append(`
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
<div class="col"><strong>${p["name"]}</strong></div>
|
<div class="col"><strong>${p["name"]}</strong></div>
|
||||||
<div class="col">Status: ${p["status"]}</div>
|
<div class="col">Status: ${p["status"]}</div>
|
||||||
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
||||||
@@ -51,11 +51,21 @@ function loadStatus() {
|
|||||||
|
|
||||||
jsonData["sig_ref_data_providers"].forEach(p => {
|
jsonData["sig_ref_data_providers"].forEach(p => {
|
||||||
$("#sig-ref-data-providers-status-container").append(`
|
$("#sig-ref-data-providers-status-container").append(`
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
<div class="col"><strong>${p["sig_name"]}</strong></div>
|
<div class="col"><strong>${p["sig_name"]}</strong></div>
|
||||||
<div class="col">Status: ${p["status"]}</div>
|
<div class="col">Status: ${p["status"]}</div>
|
||||||
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
||||||
<div class="col">References: ${(p["enabled"] && p["reference_count"] > 0) ? p["reference_count"] : "N/A"}</div>
|
<div class="col">References: ${p["enabled"] ? p["reference_count"] : "N/A"}</div>
|
||||||
|
</div>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
jsonData["callsign_data_providers"].forEach(p => {
|
||||||
|
$("#callsign-data-providers-status-container").append(`
|
||||||
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
|
<div class="col"><strong>${p["name"]}</strong></div>
|
||||||
|
<div class="col">Status: ${p["status"]}</div>
|
||||||
|
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
||||||
|
<div class="col">Lookups: ${p["enabled"] ? p["lookup_count"] : "N/A"}</div>
|
||||||
</div>`);
|
</div>`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+84
-1
@@ -296,6 +296,16 @@ function setBandColorScheme(scheme) {
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the list of known bands
|
||||||
|
function getKnownBands() {
|
||||||
|
return Array.from(Object.keys(BAND_COLOR_SCHEMES[bandColorScheme]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the list of available band colour schemes
|
||||||
|
function getAvailableBandColorSchemes() {
|
||||||
|
return Array.from(Object.keys(BAND_COLOR_SCHEMES));
|
||||||
|
}
|
||||||
|
|
||||||
// Band name to colour (in the current colour scheme). If the band is unknown, black will be returned.
|
// Band name to colour (in the current colour scheme). If the band is unknown, black will be returned.
|
||||||
function bandToColor(band) {
|
function bandToColor(band) {
|
||||||
let col = (band != null) ? BAND_COLOR_SCHEMES[bandColorScheme][band] : null;
|
let col = (band != null) ? BAND_COLOR_SCHEMES[bandColorScheme][band] : null;
|
||||||
@@ -314,6 +324,22 @@ function bandToContrastColor(band) {
|
|||||||
return (lum > 128) ? "#000000" : "#ffffff";
|
return (lum > 128) ? "#000000" : "#ffffff";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MODE_TYPE_COLOR_SCHEMES = {
|
||||||
|
"CW": "red",
|
||||||
|
"PHONE": "green",
|
||||||
|
"DATA": "blue"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode type (CW, PHONE, DATA) to colour. If the mode type is unknown, black will be returned.
|
||||||
|
function modeTypeToColor(modeType) {
|
||||||
|
let col = (modeType != null) ? MODE_TYPE_COLOR_SCHEMES[modeType.toUpperCase()] : null;
|
||||||
|
if (col) {
|
||||||
|
return col;
|
||||||
|
} else {
|
||||||
|
return "#000000";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const SIG_ICONS = {
|
const SIG_ICONS = {
|
||||||
"POTA": "fa-tree",
|
"POTA": "fa-tree",
|
||||||
"SOTA": "fa-mountain-sun",
|
"SOTA": "fa-mountain-sun",
|
||||||
@@ -340,6 +366,31 @@ const SIG_ICONS = {
|
|||||||
"Toilets": "fa-toilet"
|
"Toilets": "fa-toilet"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SIG_NAMES = {
|
||||||
|
"POTA": "Parks on the Air",
|
||||||
|
"SOTA": "Summits on the Air",
|
||||||
|
"WWFF": "Worldwide Flora & Fauna",
|
||||||
|
"GMA": "Global Mountain Activity",
|
||||||
|
"WWBOTA": "Bunkers on the Air",
|
||||||
|
"HEMA": "Humps Excluding Marilyns Award",
|
||||||
|
"IOTA": "Islands on the Air",
|
||||||
|
"MOTA": "Mills on the Air",
|
||||||
|
"ARLHS": "Amateur Radio Lighthouse Society",
|
||||||
|
"ILLW": "International Lighthouse Lightship Weekend",
|
||||||
|
"SIOTA": "Silos on the Air",
|
||||||
|
"WCA": "World Castles Award",
|
||||||
|
"ZLOTA": "New Zealand on the Air",
|
||||||
|
"WOTA": "Wainwrights on the Air",
|
||||||
|
"BOTA": "Beaches on the Air",
|
||||||
|
"KRMNPA": "Keith Roget Memorial National Parks Award",
|
||||||
|
"LLOTA": "Lagos y Lagunas on the Air",
|
||||||
|
"WWTOTA": "Towers on the Air",
|
||||||
|
"WAB": "Worked All Britain",
|
||||||
|
"WAI": "Worked All Ireland",
|
||||||
|
"Tiles": "Tiles on the Air",
|
||||||
|
"TOTA": "Toilets on the Air"
|
||||||
|
}
|
||||||
|
|
||||||
// Get the Font Awesome icon for a given SIG. If the SIG is unknown, the provided default symbol will be returned
|
// Get the Font Awesome icon for a given SIG. If the SIG is unknown, the provided default symbol will be returned
|
||||||
function sigToIcon(sig, defaultIcon) {
|
function sigToIcon(sig, defaultIcon) {
|
||||||
let col = (sig != null) ? SIG_ICONS[sig] : null;
|
let col = (sig != null) ? SIG_ICONS[sig] : null;
|
||||||
@@ -353,4 +404,36 @@ function sigToIcon(sig, defaultIcon) {
|
|||||||
return defaultIcon;
|
return defaultIcon;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the full name for a given SIG abbreviation. If the SIG is unknown, an empty string will be returned.
|
||||||
|
function sigToName(sig) {
|
||||||
|
let col = (sig != null) ? SIG_NAMES[sig] : null;
|
||||||
|
if (col) {
|
||||||
|
return col;
|
||||||
|
} else {
|
||||||
|
let col = (sig != null) ? SIG_NAMES[sig.toUpperCase()] : null;
|
||||||
|
if (col) {
|
||||||
|
return col;
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the list of known SIGs
|
||||||
|
function getKnownSIGs() {
|
||||||
|
return Array.from(Object.keys(SIG_ICONS));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format a Maidenhead grid with alternating alphabetic blocks in lower case
|
||||||
|
function formatGrid(grid) {
|
||||||
|
grid = grid.toUpperCase();
|
||||||
|
if (grid.length >= 6) {
|
||||||
|
grid = grid.substring(0, 4) + grid.substring(4, 6).toLowerCase() + grid.substring(6);
|
||||||
|
}
|
||||||
|
if (grid.length >= 12) {
|
||||||
|
grid = grid.substring(0, 10) + grid.substring(10, 12).toLowerCase() + grid.substring(14);
|
||||||
|
}
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|||||||
+3
-3
@@ -16,7 +16,7 @@ L.WorkedAllBritainIreland = L.LayerGroup.extend({
|
|||||||
|
|
||||||
// Workaround to load the geodesy modules in non-modular code. Once we have loaded all three modules, trigger a
|
// Workaround to load the geodesy modules in non-modular code. Once we have loaded all three modules, trigger a
|
||||||
// first draw.
|
// first draw.
|
||||||
import("https://misc.ianrenton.com/Leaflet.WorkedAllBritainIreland/modules/geodesy/osgridref.js")
|
import(new URL('./modules/geodesy/osgridref.js', import.meta.url).href)
|
||||||
.then(module => {
|
.then(module => {
|
||||||
this._osGridLibrary = module;
|
this._osGridLibrary = module;
|
||||||
if (this._ieGridLibrary && this._utmLibrary) {
|
if (this._ieGridLibrary && this._utmLibrary) {
|
||||||
@@ -27,7 +27,7 @@ L.WorkedAllBritainIreland = L.LayerGroup.extend({
|
|||||||
console.log("Error loading OS Grid Ref library, GB WAB squares may not be available.");
|
console.log("Error loading OS Grid Ref library, GB WAB squares may not be available.");
|
||||||
console.log(error);
|
console.log(error);
|
||||||
});
|
});
|
||||||
import("https://misc.ianrenton.com/Leaflet.WorkedAllBritainIreland/modules/geodesy/iegridref.js")
|
import(new URL('./modules/geodesy/iegridref.js', import.meta.url).href)
|
||||||
.then(module => {
|
.then(module => {
|
||||||
this._ieGridLibrary = module;
|
this._ieGridLibrary = module;
|
||||||
if (this._osGridLibrary && this._utmLibrary) {
|
if (this._osGridLibrary && this._utmLibrary) {
|
||||||
@@ -38,7 +38,7 @@ L.WorkedAllBritainIreland = L.LayerGroup.extend({
|
|||||||
console.log("Error loading IE Grid Ref library, NI WAB squares may not be available.");
|
console.log("Error loading IE Grid Ref library, NI WAB squares may not be available.");
|
||||||
console.log(error);
|
console.log(error);
|
||||||
});
|
});
|
||||||
import("https://misc.ianrenton.com/Leaflet.WorkedAllBritainIreland/modules/geodesy/utm_ci.js")
|
import(new URL('./modules/geodesy/utm_ci.js', import.meta.url).href)
|
||||||
.then(module => {
|
.then(module => {
|
||||||
this._utmLibrary = module;
|
this._utmLibrary = module;
|
||||||
if (this._osGridLibrary && this._ieGridLibrary) {
|
if (this._osGridLibrary && this._ieGridLibrary) {
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2014 Chris Veness
|
||||||
|
With some additional code & modifications by Ian Renton, 2025
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+326
@@ -0,0 +1,326 @@
|
|||||||
|
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
|
||||||
|
/* Ordnance Survey of Ireland Grid Reference funcs (c) Chris Veness 2005-2021 & Ian Renton 2025 */
|
||||||
|
/* MIT Licence */
|
||||||
|
/* www.movable-type.co.uk/scripts/latlong-gridref.html */
|
||||||
|
/* www.movable-type.co.uk/scripts/geodesy-library.html#IeGridRef */
|
||||||
|
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
|
||||||
|
|
||||||
|
import LatLonEllipsoidal, { Dms } from 'https://cdn.jsdelivr.net/npm/geodesy@2/latlon-ellipsoidal-datum.js';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordnance Survey of Ireland & Northern Ireland grid reference calculations, based on the
|
||||||
|
* IeGridRef class in the geodesy library at https://github.com/chrisveness/geodesy
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* IeGridRef - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
|
||||||
|
|
||||||
|
|
||||||
|
const nationalGrid = {
|
||||||
|
trueOrigin: { lat: 53.5, lon: -8 }, // true origin of Irish grid 53°30′N, 8°W
|
||||||
|
falseOrigin: { easting: -200e3, northing: -250e3 }, // easting & northing of false origin, metres from true origin
|
||||||
|
scaleFactor: 1.000035, // scale factor on central meridian
|
||||||
|
ellipsoid: LatLonEllipsoidal.ellipsoids.Airy1830,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Irish Grid References with methods to parse and convert them to latitude/longitude points.
|
||||||
|
*/
|
||||||
|
class IeGridRef {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an IeGridRef object.
|
||||||
|
*
|
||||||
|
* @param {number} easting - Easting in metres from OS Grid false origin.
|
||||||
|
* @param {number} northing - Northing in metres from OS Grid false origin.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* import IeGridRef from '/js/geodesy/IeGridRef.js';
|
||||||
|
* const gridref = new IeGridRef(651409, 313177);
|
||||||
|
*/
|
||||||
|
constructor(easting, northing) {
|
||||||
|
this.easting = Number(easting);
|
||||||
|
this.northing = Number(northing);
|
||||||
|
|
||||||
|
if (isNaN(easting) || this.easting<0 || this.easting>7000e3) throw new RangeError(`invalid easting ‘${easting}’`);
|
||||||
|
if (isNaN(northing) || this.northing<0 || this.northing>13000e3) throw new RangeError(`invalid northing ‘${northing}’`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts ‘this’ Irish Grid Reference easting/northing coordinate to latitude/longitude
|
||||||
|
* (SW corner of grid square).
|
||||||
|
*
|
||||||
|
* While OS Grid References are based on OSGB-36, the Ordnance Survey have deprecated the use of
|
||||||
|
* OSGB-36 for latitude/longitude coordinates (in favour of WGS-84), hence this function returns
|
||||||
|
* WGS-84 by default, with OSGB-36 as an option. See www.ordnancesurvey.co.uk/blog/2014/12/2.
|
||||||
|
*
|
||||||
|
* Note formulation implemented here due to Thomas, Redfearn, etc is as published by OS, but is
|
||||||
|
* inferior to Krüger as used by e.g. Karney 2011.
|
||||||
|
*
|
||||||
|
* @param {LatLon.datum} [datum=WGS84] - Datum to convert grid reference into.
|
||||||
|
* @returns {LatLon} Latitude/longitude of supplied grid reference.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const gridref = new IeGridRef(651409.903, 313177.270);
|
||||||
|
* const pWgs84 = gridref.toLatLon(); // 52°39′28.723″N, 001°42′57.787″E
|
||||||
|
* // to obtain (historical) OSGB36 lat/lon point:
|
||||||
|
* const pOsgb = gridref.toLatLon(LatLon.datums.OSGB36); // 52°39′27.253″N, 001°43′04.518″E
|
||||||
|
*/
|
||||||
|
toLatLon(datum=LatLonEllipsoidal.datums.WGS84) {
|
||||||
|
const { easting: E, northing: N } = this;
|
||||||
|
|
||||||
|
const { a, b } = nationalGrid.ellipsoid; // a = 6377563.396, b = 6356256.909
|
||||||
|
const φ0 = nationalGrid.trueOrigin.lat.toRadians(); // latitude of true origin
|
||||||
|
const λ0 = nationalGrid.trueOrigin.lon.toRadians(); // longitude of true origin
|
||||||
|
const E0 = -nationalGrid.falseOrigin.easting; // easting of true origin
|
||||||
|
const N0 = -nationalGrid.falseOrigin.northing; // northing of true origin
|
||||||
|
const F0 = nationalGrid.scaleFactor; // scale factor
|
||||||
|
|
||||||
|
const e2 = 1 - (b*b)/(a*a); // eccentricity squared
|
||||||
|
const n = (a-b)/(a+b), n2 = n*n, n3 = n*n*n; // n, n², n³
|
||||||
|
|
||||||
|
let φ=φ0, M=0;
|
||||||
|
do {
|
||||||
|
φ = (N-N0-M)/(a*F0) + φ;
|
||||||
|
|
||||||
|
const Ma = (1 + n + (5/4)*n2 + (5/4)*n3) * (φ-φ0);
|
||||||
|
const Mb = (3*n + 3*n2 + (21/8)*n3) * Math.sin(φ-φ0) * Math.cos(φ+φ0);
|
||||||
|
const Mc = ((15/8)*n2 + (15/8)*n3) * Math.sin(2*(φ-φ0)) * Math.cos(2*(φ+φ0));
|
||||||
|
const Md = (35/24)*n3 * Math.sin(3*(φ-φ0)) * Math.cos(3*(φ+φ0));
|
||||||
|
M = b * F0 * (Ma - Mb + Mc - Md); // meridional arc
|
||||||
|
|
||||||
|
} while (Math.abs(N-N0-M) >= 0.00001); // ie until < 0.01mm
|
||||||
|
|
||||||
|
const cosφ = Math.cos(φ), sinφ = Math.sin(φ);
|
||||||
|
const ν = a*F0/Math.sqrt(1-e2*sinφ*sinφ); // nu = transverse radius of curvature
|
||||||
|
const ρ = a*F0*(1-e2)/Math.pow(1-e2*sinφ*sinφ, 1.5); // rho = meridional radius of curvature
|
||||||
|
const η2 = ν/ρ-1; // eta = ?
|
||||||
|
|
||||||
|
const tanφ = Math.tan(φ);
|
||||||
|
const tan2φ = tanφ*tanφ, tan4φ = tan2φ*tan2φ, tan6φ = tan4φ*tan2φ;
|
||||||
|
const secφ = 1/cosφ;
|
||||||
|
const ν3 = ν*ν*ν, ν5 = ν3*ν*ν, ν7 = ν5*ν*ν;
|
||||||
|
const VII = tanφ/(2*ρ*ν);
|
||||||
|
const VIII = tanφ/(24*ρ*ν3)*(5+3*tan2φ+η2-9*tan2φ*η2);
|
||||||
|
const IX = tanφ/(720*ρ*ν5)*(61+90*tan2φ+45*tan4φ);
|
||||||
|
const X = secφ/ν;
|
||||||
|
const XI = secφ/(6*ν3)*(ν/ρ+2*tan2φ);
|
||||||
|
const XII = secφ/(120*ν5)*(5+28*tan2φ+24*tan4φ);
|
||||||
|
const XIIA = secφ/(5040*ν7)*(61+662*tan2φ+1320*tan4φ+720*tan6φ);
|
||||||
|
|
||||||
|
const dE = (E-E0), dE2 = dE*dE, dE3 = dE2*dE, dE4 = dE2*dE2, dE5 = dE3*dE2, dE6 = dE4*dE2, dE7 = dE5*dE2;
|
||||||
|
φ = φ - VII*dE2 + VIII*dE4 - IX*dE6;
|
||||||
|
const λ = λ0 + X*dE - XI*dE3 + XII*dE5 - XIIA*dE7;
|
||||||
|
|
||||||
|
let point = new LatLon_IeGridRef(φ.toDegrees(), λ.toDegrees(), 0, LatLonEllipsoidal.datums.OSGB36);
|
||||||
|
|
||||||
|
if (datum != LatLonEllipsoidal.datums.OSGB36) {
|
||||||
|
// if point is required in datum other than OSGB36, convert it
|
||||||
|
point = point.convertDatum(datum);
|
||||||
|
// convertDatum() gives us a LatLon: convert to LatLon_IeGridRef which includes toOsGrid()
|
||||||
|
point = new LatLon_IeGridRef(point.lat, point.lon, point.height, point.datum);
|
||||||
|
}
|
||||||
|
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses grid reference to IeGridRef object.
|
||||||
|
*
|
||||||
|
* Accepts standard grid references (eg 'G 387 148'), with or without whitespace separators, from
|
||||||
|
* two-digit references up to 10-digit references (1m × 1m square), or fully numeric comma-separated
|
||||||
|
* references in metres (eg '438700,114800').
|
||||||
|
*
|
||||||
|
* @param {string} gridref - Standard format OS Grid Reference.
|
||||||
|
* @returns {IeGridRef} Numeric version of grid reference in metres from false origin (SW corner of
|
||||||
|
* supplied grid square).
|
||||||
|
* @throws {Error} Invalid grid reference.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const grid = IeGridRef.parse('G 51409 13177'); // grid: { easting: 651409, northing: 313177 }
|
||||||
|
*/
|
||||||
|
static parse(gridref) {
|
||||||
|
gridref = String(gridref).trim();
|
||||||
|
|
||||||
|
// check for fully numeric comma-separated gridref format
|
||||||
|
let match = gridref.match(/^(\d+),\s*(\d+)$/);
|
||||||
|
if (match) return new IeGridRef(match[1], match[2]);
|
||||||
|
|
||||||
|
// validate format
|
||||||
|
match = gridref.match(/^[ABCDEFGHJKLMNOPQRSTUVWXYZ]\s*[0-9]+\s*[0-9]+$/i);
|
||||||
|
if (!match) throw new Error(`invalid grid reference ‘${gridref}’`);
|
||||||
|
|
||||||
|
// get numeric values of letter references, mapping A->0, B->1, C->2, etc:
|
||||||
|
let l1 = gridref.toUpperCase().charCodeAt(0) - 'A'.charCodeAt(0); // 100km square
|
||||||
|
// shuffle down letters after 'I' since 'I' is not used in grid:
|
||||||
|
if (l1 > 7) l1--;
|
||||||
|
|
||||||
|
// convert grid letters into 100km-square indexes from false origin (grid square SV):
|
||||||
|
const e100km = l1 % 5;
|
||||||
|
const n100km = 4 - Math.floor(l1 / 5);
|
||||||
|
|
||||||
|
// skip grid letters to get numeric (easting/northing) part of ref
|
||||||
|
let en = gridref.slice(1).trim().split(/\s+/);
|
||||||
|
// if e/n not whitespace separated, split half way
|
||||||
|
if (en.length == 1) en = [ en[0].slice(0, en[0].length / 2), en[0].slice(en[0].length / 2) ];
|
||||||
|
|
||||||
|
// validation
|
||||||
|
if (en[0].length != en[1].length) throw new Error(`invalid grid reference ‘${gridref}’`);
|
||||||
|
|
||||||
|
// standardise to 10-digit refs (metres)
|
||||||
|
en[0] = en[0].padEnd(5, '0');
|
||||||
|
en[1] = en[1].padEnd(5, '0');
|
||||||
|
|
||||||
|
const e = e100km + en[0];
|
||||||
|
const n = n100km + en[1];
|
||||||
|
|
||||||
|
return new IeGridRef(e, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts ‘this’ numeric grid reference to standard OS of Ireland Grid Reference.
|
||||||
|
*
|
||||||
|
* @param {number} [digits=10] - Precision of returned grid reference (10 digits = metres);
|
||||||
|
* digits=0 will return grid reference in numeric format.
|
||||||
|
* @returns {string} This grid reference in standard format.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const gridref = new IeGridRef(651409, 313177).toString(8); // 'TG 5140 1317'
|
||||||
|
* const gridref = new IeGridRef(651409, 313177).toString(0); // '651409,313177'
|
||||||
|
*/
|
||||||
|
toString(digits=10) {
|
||||||
|
if (![ 0,2,4,6,8,10,12,14,16 ].includes(Number(digits))) throw new RangeError(`invalid precision ‘${digits}’`); // eslint-disable-line comma-spacing
|
||||||
|
|
||||||
|
let { easting: e, northing: n } = this;
|
||||||
|
|
||||||
|
// use digits = 0 to return numeric format (in metres) - note northing may be >= 1e7
|
||||||
|
if (digits == 0) {
|
||||||
|
const format = { useGrouping: false, minimumIntegerDigits: 6, maximumFractionDigits: 3 };
|
||||||
|
const ePad = e.toLocaleString('en', format);
|
||||||
|
const nPad = n.toLocaleString('en', format);
|
||||||
|
return `${ePad},${nPad}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the 100km-grid indices
|
||||||
|
const e100km = Math.floor(e / 100000), n100km = Math.floor(n / 100000);
|
||||||
|
|
||||||
|
// translate those into the numeric equivalent of the grid letters
|
||||||
|
let l1 = (n100km) * 5 % 25 + e100km % 5;
|
||||||
|
return null; // haven't done this maths yet
|
||||||
|
|
||||||
|
// compensate for skipped 'I' and calculate grid letter
|
||||||
|
if (l1 > 7) l1++;
|
||||||
|
const letter = String.fromCharCode(l1 + 'A'.charCodeAt(0));
|
||||||
|
|
||||||
|
// strip 100km-grid indices from easting & northing, and reduce precision
|
||||||
|
e = Math.floor((e % 100000) / Math.pow(10, 5 - digits / 2));
|
||||||
|
n = Math.floor((n % 100000) / Math.pow(10, 5 - digits / 2));
|
||||||
|
|
||||||
|
// pad eastings & northings with leading zeros
|
||||||
|
e = e.toString().padStart(digits/2, '0');
|
||||||
|
n = n.toString().padStart(digits/2, '0');
|
||||||
|
|
||||||
|
return `${letter} ${e} ${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* LatLon_IeGridRef - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends LatLon class with method to convert LatLon point to Irish Grid Reference.
|
||||||
|
*
|
||||||
|
* @extends LatLonEllipsoidal
|
||||||
|
*/
|
||||||
|
class LatLon_IeGridRef extends LatLonEllipsoidal {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts latitude/longitude to Ordnance Survey of Ireland grid reference easting/northing coordinate.
|
||||||
|
*
|
||||||
|
* @returns {IeGridRef} Irish Grid Reference easting/northing.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const grid = new LatLon(52.65798, 1.71605).toOsGrid(); // TG 51409 13177
|
||||||
|
* // for conversion of (historical) OSGB36 latitude/longitude point:
|
||||||
|
* const grid = new LatLon(52.65798, 1.71605).toOsGrid(LatLon.datums.OSGB36);
|
||||||
|
*/
|
||||||
|
toOsGrid() {
|
||||||
|
// if necessary convert to OSGB36 first
|
||||||
|
const point = this.datum == LatLonEllipsoidal.datums.OSGB36
|
||||||
|
? this
|
||||||
|
: this.convertDatum(LatLonEllipsoidal.datums.OSGB36);
|
||||||
|
|
||||||
|
const φ = point.lat.toRadians();
|
||||||
|
const λ = point.lon.toRadians();
|
||||||
|
|
||||||
|
const { a, b } = nationalGrid.ellipsoid; // a = 6377563.396, b = 6356256.909
|
||||||
|
const φ0 = nationalGrid.trueOrigin.lat.toRadians(); // latitude of true origin
|
||||||
|
const λ0 = nationalGrid.trueOrigin.lon.toRadians(); // longitude of true origin
|
||||||
|
const E0 = -nationalGrid.falseOrigin.easting; // easting of true origin
|
||||||
|
const N0 = -nationalGrid.falseOrigin.northing; // northing of true origin
|
||||||
|
const F0 = nationalGrid.scaleFactor; // scale factor
|
||||||
|
|
||||||
|
const e2 = 1 - (b*b)/(a*a); // eccentricity squared
|
||||||
|
const n = (a-b)/(a+b), n2 = n*n, n3 = n*n*n; // n, n², n³
|
||||||
|
|
||||||
|
const cosφ = Math.cos(φ), sinφ = Math.sin(φ);
|
||||||
|
const ν = a*F0/Math.sqrt(1-e2*sinφ*sinφ); // nu = transverse radius of curvature
|
||||||
|
const ρ = a*F0*(1-e2)/Math.pow(1-e2*sinφ*sinφ, 1.5); // rho = meridional radius of curvature
|
||||||
|
const η2 = ν/ρ-1; // eta = ?
|
||||||
|
|
||||||
|
const Ma = (1 + n + (5/4)*n2 + (5/4)*n3) * (φ-φ0);
|
||||||
|
const Mb = (3*n + 3*n2 + (21/8)*n3) * Math.sin(φ-φ0) * Math.cos(φ+φ0);
|
||||||
|
const Mc = ((15/8)*n2 + (15/8)*n3) * Math.sin(2*(φ-φ0)) * Math.cos(2*(φ+φ0));
|
||||||
|
const Md = (35/24)*n3 * Math.sin(3*(φ-φ0)) * Math.cos(3*(φ+φ0));
|
||||||
|
const M = b * F0 * (Ma - Mb + Mc - Md); // meridional arc
|
||||||
|
|
||||||
|
const cos3φ = cosφ*cosφ*cosφ;
|
||||||
|
const cos5φ = cos3φ*cosφ*cosφ;
|
||||||
|
const tan2φ = Math.tan(φ)*Math.tan(φ);
|
||||||
|
const tan4φ = tan2φ*tan2φ;
|
||||||
|
|
||||||
|
const I = M + N0;
|
||||||
|
const II = (ν/2)*sinφ*cosφ;
|
||||||
|
const III = (ν/24)*sinφ*cos3φ*(5-tan2φ+9*η2);
|
||||||
|
const IIIA = (ν/720)*sinφ*cos5φ*(61-58*tan2φ+tan4φ);
|
||||||
|
const IV = ν*cosφ;
|
||||||
|
const V = (ν/6)*cos3φ*(ν/ρ-tan2φ);
|
||||||
|
const VI = (ν/120) * cos5φ * (5 - 18*tan2φ + tan4φ + 14*η2 - 58*tan2φ*η2);
|
||||||
|
|
||||||
|
const Δλ = λ-λ0;
|
||||||
|
const Δλ2 = Δλ*Δλ, Δλ3 = Δλ2*Δλ, Δλ4 = Δλ3*Δλ, Δλ5 = Δλ4*Δλ, Δλ6 = Δλ5*Δλ;
|
||||||
|
|
||||||
|
let N = I + II*Δλ2 + III*Δλ4 + IIIA*Δλ6;
|
||||||
|
let E = E0 + IV*Δλ + V*Δλ3 + VI*Δλ5;
|
||||||
|
|
||||||
|
N = Number(N.toFixed(3)); // round to mm precision
|
||||||
|
E = Number(E.toFixed(3));
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new IeGridRef(E, N); // note: gets truncated to SW corner of 1m grid square
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`${e.message} from (${point.lat.toFixed(6)},${point.lon.toFixed(6)}).toOsGrid()`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override LatLonEllipsoidal.convertDatum() with version which returns LatLon_IeGridRef.
|
||||||
|
*/
|
||||||
|
convertDatum(toDatum) {
|
||||||
|
const osieED = super.convertDatum(toDatum); // returns LatLonEllipsoidal_Datum
|
||||||
|
const osieOSGR = new LatLon_IeGridRef(osieED.lat, osieED.lon, osieED.height, osieED.datum);
|
||||||
|
return osieOSGR;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
|
||||||
|
|
||||||
|
export { IeGridRef as default, LatLon_IeGridRef as LatLon, Dms };
|
||||||
+348
@@ -0,0 +1,348 @@
|
|||||||
|
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
|
||||||
|
/* Ordnance Survey Grid Reference functions (c) Chris Veness 2005-2021 */
|
||||||
|
/* MIT Licence */
|
||||||
|
/* www.movable-type.co.uk/scripts/latlong-gridref.html */
|
||||||
|
/* www.movable-type.co.uk/scripts/geodesy-library.html#osgridref */
|
||||||
|
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
|
||||||
|
|
||||||
|
import LatLonEllipsoidal, { Dms } from 'https://cdn.jsdelivr.net/npm/geodesy@2/latlon-ellipsoidal-datum.js';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordnance Survey OSGB grid references provide geocoordinate references for UK mapping purposes.
|
||||||
|
*
|
||||||
|
* Formulation implemented here due to Thomas, Redfearn, etc is as published by OS, but is inferior
|
||||||
|
* to Krüger as used by e.g. Karney 2011.
|
||||||
|
*
|
||||||
|
* www.ordnancesurvey.co.uk/documents/resources/guide-coordinate-systems-great-britain.pdf.
|
||||||
|
*
|
||||||
|
* Note OSGB grid references cover Great Britain only; Ireland and the Channel Islands have their
|
||||||
|
* own references.
|
||||||
|
*
|
||||||
|
* Note that these formulae are based on ellipsoidal calculations, and according to the OS are
|
||||||
|
* accurate to about 4–5 metres – for greater accuracy, a geoid-based transformation (OSTN15) must
|
||||||
|
* be used.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Converted 2015 to work with WGS84 by default, OSGB36 as option;
|
||||||
|
* www.ordnancesurvey.co.uk/blog/2014/12/confirmation-on-changes-to-latitude-and-longitude
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/* OsGridRef - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
|
||||||
|
|
||||||
|
|
||||||
|
const nationalGrid = {
|
||||||
|
trueOrigin: { lat: 49, lon: -2 }, // true origin of grid 49°N,2°W on OSGB36 datum
|
||||||
|
falseOrigin: { easting: -400e3, northing: 100e3 }, // easting & northing of false origin, metres from true origin
|
||||||
|
scaleFactor: 0.9996012717, // scale factor on central meridian
|
||||||
|
ellipsoid: LatLonEllipsoidal.ellipsoids.Airy1830,
|
||||||
|
};
|
||||||
|
// note Irish National Grid uses t/o 53°30′N, 8°W, f/o 200kmW, 250kmS, scale factor 1.000035, on Airy 1830 Modified ellipsoid
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OS Grid References with methods to parse and convert them to latitude/longitude points.
|
||||||
|
*/
|
||||||
|
class OsGridRef {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an OsGridRef object.
|
||||||
|
*
|
||||||
|
* @param {number} easting - Easting in metres from OS Grid false origin.
|
||||||
|
* @param {number} northing - Northing in metres from OS Grid false origin.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* import OsGridRef from '/js/geodesy/osgridref.js';
|
||||||
|
* const gridref = new OsGridRef(651409, 313177);
|
||||||
|
*/
|
||||||
|
constructor(easting, northing) {
|
||||||
|
this.easting = Number(easting);
|
||||||
|
this.northing = Number(northing);
|
||||||
|
|
||||||
|
if (isNaN(easting) || this.easting<0 || this.easting>700e3) throw new RangeError(`invalid easting ‘${easting}’`);
|
||||||
|
if (isNaN(northing) || this.northing<0 || this.northing>1300e3) throw new RangeError(`invalid northing ‘${northing}’`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts ‘this’ Ordnance Survey Grid Reference easting/northing coordinate to latitude/longitude
|
||||||
|
* (SW corner of grid square).
|
||||||
|
*
|
||||||
|
* While OS Grid References are based on OSGB-36, the Ordnance Survey have deprecated the use of
|
||||||
|
* OSGB-36 for latitude/longitude coordinates (in favour of WGS-84), hence this function returns
|
||||||
|
* WGS-84 by default, with OSGB-36 as an option. See www.ordnancesurvey.co.uk/blog/2014/12/2.
|
||||||
|
*
|
||||||
|
* Note formulation implemented here due to Thomas, Redfearn, etc is as published by OS, but is
|
||||||
|
* inferior to Krüger as used by e.g. Karney 2011.
|
||||||
|
*
|
||||||
|
* @param {LatLon.datum} [datum=WGS84] - Datum to convert grid reference into.
|
||||||
|
* @returns {LatLon} Latitude/longitude of supplied grid reference.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const gridref = new OsGridRef(651409.903, 313177.270);
|
||||||
|
* const pWgs84 = gridref.toLatLon(); // 52°39′28.723″N, 001°42′57.787″E
|
||||||
|
* // to obtain (historical) OSGB36 lat/lon point:
|
||||||
|
* const pOsgb = gridref.toLatLon(LatLon.datums.OSGB36); // 52°39′27.253″N, 001°43′04.518″E
|
||||||
|
*/
|
||||||
|
toLatLon(datum=LatLonEllipsoidal.datums.WGS84) {
|
||||||
|
const { easting: E, northing: N } = this;
|
||||||
|
|
||||||
|
const { a, b } = nationalGrid.ellipsoid; // a = 6377563.396, b = 6356256.909
|
||||||
|
const φ0 = nationalGrid.trueOrigin.lat.toRadians(); // latitude of true origin, 49°N
|
||||||
|
const λ0 = nationalGrid.trueOrigin.lon.toRadians(); // longitude of true origin, 2°W
|
||||||
|
const E0 = -nationalGrid.falseOrigin.easting; // easting of true origin, 400km
|
||||||
|
const N0 = -nationalGrid.falseOrigin.northing; // northing of true origin, -100km
|
||||||
|
const F0 = nationalGrid.scaleFactor; // 0.9996012717
|
||||||
|
|
||||||
|
const e2 = 1 - (b*b)/(a*a); // eccentricity squared
|
||||||
|
const n = (a-b)/(a+b), n2 = n*n, n3 = n*n*n; // n, n², n³
|
||||||
|
|
||||||
|
let φ=φ0, M=0;
|
||||||
|
do {
|
||||||
|
φ = (N-N0-M)/(a*F0) + φ;
|
||||||
|
|
||||||
|
const Ma = (1 + n + (5/4)*n2 + (5/4)*n3) * (φ-φ0);
|
||||||
|
const Mb = (3*n + 3*n2 + (21/8)*n3) * Math.sin(φ-φ0) * Math.cos(φ+φ0);
|
||||||
|
const Mc = ((15/8)*n2 + (15/8)*n3) * Math.sin(2*(φ-φ0)) * Math.cos(2*(φ+φ0));
|
||||||
|
const Md = (35/24)*n3 * Math.sin(3*(φ-φ0)) * Math.cos(3*(φ+φ0));
|
||||||
|
M = b * F0 * (Ma - Mb + Mc - Md); // meridional arc
|
||||||
|
|
||||||
|
} while (Math.abs(N-N0-M) >= 0.00001); // ie until < 0.01mm
|
||||||
|
|
||||||
|
const cosφ = Math.cos(φ), sinφ = Math.sin(φ);
|
||||||
|
const ν = a*F0/Math.sqrt(1-e2*sinφ*sinφ); // nu = transverse radius of curvature
|
||||||
|
const ρ = a*F0*(1-e2)/Math.pow(1-e2*sinφ*sinφ, 1.5); // rho = meridional radius of curvature
|
||||||
|
const η2 = ν/ρ-1; // eta = ?
|
||||||
|
|
||||||
|
const tanφ = Math.tan(φ);
|
||||||
|
const tan2φ = tanφ*tanφ, tan4φ = tan2φ*tan2φ, tan6φ = tan4φ*tan2φ;
|
||||||
|
const secφ = 1/cosφ;
|
||||||
|
const ν3 = ν*ν*ν, ν5 = ν3*ν*ν, ν7 = ν5*ν*ν;
|
||||||
|
const VII = tanφ/(2*ρ*ν);
|
||||||
|
const VIII = tanφ/(24*ρ*ν3)*(5+3*tan2φ+η2-9*tan2φ*η2);
|
||||||
|
const IX = tanφ/(720*ρ*ν5)*(61+90*tan2φ+45*tan4φ);
|
||||||
|
const X = secφ/ν;
|
||||||
|
const XI = secφ/(6*ν3)*(ν/ρ+2*tan2φ);
|
||||||
|
const XII = secφ/(120*ν5)*(5+28*tan2φ+24*tan4φ);
|
||||||
|
const XIIA = secφ/(5040*ν7)*(61+662*tan2φ+1320*tan4φ+720*tan6φ);
|
||||||
|
|
||||||
|
const dE = (E-E0), dE2 = dE*dE, dE3 = dE2*dE, dE4 = dE2*dE2, dE5 = dE3*dE2, dE6 = dE4*dE2, dE7 = dE5*dE2;
|
||||||
|
φ = φ - VII*dE2 + VIII*dE4 - IX*dE6;
|
||||||
|
const λ = λ0 + X*dE - XI*dE3 + XII*dE5 - XIIA*dE7;
|
||||||
|
|
||||||
|
let point = new LatLon_OsGridRef(φ.toDegrees(), λ.toDegrees(), 0, LatLonEllipsoidal.datums.OSGB36);
|
||||||
|
|
||||||
|
if (datum != LatLonEllipsoidal.datums.OSGB36) {
|
||||||
|
// if point is required in datum other than OSGB36, convert it
|
||||||
|
point = point.convertDatum(datum);
|
||||||
|
// convertDatum() gives us a LatLon: convert to LatLon_OsGridRef which includes toOsGrid()
|
||||||
|
point = new LatLon_OsGridRef(point.lat, point.lon, point.height, point.datum);
|
||||||
|
}
|
||||||
|
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses grid reference to OsGridRef object.
|
||||||
|
*
|
||||||
|
* Accepts standard grid references (eg 'SU 387 148'), with or without whitespace separators, from
|
||||||
|
* two-digit references up to 10-digit references (1m × 1m square), or fully numeric comma-separated
|
||||||
|
* references in metres (eg '438700,114800').
|
||||||
|
*
|
||||||
|
* @param {string} gridref - Standard format OS Grid Reference.
|
||||||
|
* @returns {OsGridRef} Numeric version of grid reference in metres from false origin (SW corner of
|
||||||
|
* supplied grid square).
|
||||||
|
* @throws {Error} Invalid grid reference.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const grid = OsGridRef.parse('TG 51409 13177'); // grid: { easting: 651409, northing: 313177 }
|
||||||
|
*/
|
||||||
|
static parse(gridref) {
|
||||||
|
gridref = String(gridref).trim();
|
||||||
|
|
||||||
|
// check for fully numeric comma-separated gridref format
|
||||||
|
let match = gridref.match(/^(\d+),\s*(\d+)$/);
|
||||||
|
if (match) return new OsGridRef(match[1], match[2]);
|
||||||
|
|
||||||
|
// validate format
|
||||||
|
match = gridref.match(/^[HNOST][ABCDEFGHJKLMNOPQRSTUVWXYZ]\s*[0-9]+\s*[0-9]+$/i);
|
||||||
|
if (!match) throw new Error(`invalid grid reference ‘${gridref}’`);
|
||||||
|
|
||||||
|
// get numeric values of letter references, mapping A->0, B->1, C->2, etc:
|
||||||
|
let l1 = gridref.toUpperCase().charCodeAt(0) - 'A'.charCodeAt(0); // 500km square
|
||||||
|
let l2 = gridref.toUpperCase().charCodeAt(1) - 'A'.charCodeAt(0); // 100km square
|
||||||
|
// shuffle down letters after 'I' since 'I' is not used in grid:
|
||||||
|
if (l1 > 7) l1--;
|
||||||
|
if (l2 > 7) l2--;
|
||||||
|
|
||||||
|
// convert grid letters into 100km-square indexes from false origin (grid square SV):
|
||||||
|
const e100km = ((l1 - 2) % 5) * 5 + (l2 % 5);
|
||||||
|
const n100km = (19 - Math.floor(l1 / 5) * 5) - Math.floor(l2 / 5);
|
||||||
|
|
||||||
|
// skip grid letters to get numeric (easting/northing) part of ref
|
||||||
|
let en = gridref.slice(2).trim().split(/\s+/);
|
||||||
|
// if e/n not whitespace separated, split half way
|
||||||
|
if (en.length == 1) en = [ en[0].slice(0, en[0].length / 2), en[0].slice(en[0].length / 2) ];
|
||||||
|
|
||||||
|
// validation
|
||||||
|
if (en[0].length != en[1].length) throw new Error(`invalid grid reference ‘${gridref}’`);
|
||||||
|
|
||||||
|
// standardise to 10-digit refs (metres)
|
||||||
|
en[0] = en[0].padEnd(5, '0');
|
||||||
|
en[1] = en[1].padEnd(5, '0');
|
||||||
|
|
||||||
|
const e = e100km + en[0];
|
||||||
|
const n = n100km + en[1];
|
||||||
|
|
||||||
|
return new OsGridRef(e, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts ‘this’ numeric grid reference to standard OS Grid Reference.
|
||||||
|
*
|
||||||
|
* @param {number} [digits=10] - Precision of returned grid reference (10 digits = metres);
|
||||||
|
* digits=0 will return grid reference in numeric format.
|
||||||
|
* @returns {string} This grid reference in standard format.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const gridref = new OsGridRef(651409, 313177).toString(8); // 'TG 5140 1317'
|
||||||
|
* const gridref = new OsGridRef(651409, 313177).toString(0); // '651409,313177'
|
||||||
|
*/
|
||||||
|
toString(digits=10) {
|
||||||
|
if (![ 0,2,4,6,8,10,12,14,16 ].includes(Number(digits))) throw new RangeError(`invalid precision ‘${digits}’`); // eslint-disable-line comma-spacing
|
||||||
|
|
||||||
|
let { easting: e, northing: n } = this;
|
||||||
|
|
||||||
|
// use digits = 0 to return numeric format (in metres) - note northing may be >= 1e7
|
||||||
|
if (digits == 0) {
|
||||||
|
const format = { useGrouping: false, minimumIntegerDigits: 6, maximumFractionDigits: 3 };
|
||||||
|
const ePad = e.toLocaleString('en', format);
|
||||||
|
const nPad = n.toLocaleString('en', format);
|
||||||
|
return `${ePad},${nPad}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the 100km-grid indices
|
||||||
|
const e100km = Math.floor(e / 100000), n100km = Math.floor(n / 100000);
|
||||||
|
|
||||||
|
// translate those into numeric equivalents of the grid letters
|
||||||
|
let l1 = (19 - n100km) - (19 - n100km) % 5 + Math.floor((e100km + 10) / 5);
|
||||||
|
let l2 = (19 - n100km) * 5 % 25 + e100km % 5;
|
||||||
|
|
||||||
|
// compensate for skipped 'I' and build grid letter-pairs
|
||||||
|
if (l1 > 7) l1++;
|
||||||
|
if (l2 > 7) l2++;
|
||||||
|
const letterPair = String.fromCharCode(l1 + 'A'.charCodeAt(0), l2 + 'A'.charCodeAt(0));
|
||||||
|
|
||||||
|
// strip 100km-grid indices from easting & northing, and reduce precision
|
||||||
|
e = Math.floor((e % 100000) / Math.pow(10, 5 - digits / 2));
|
||||||
|
n = Math.floor((n % 100000) / Math.pow(10, 5 - digits / 2));
|
||||||
|
|
||||||
|
// pad eastings & northings with leading zeros
|
||||||
|
e = e.toString().padStart(digits/2, '0');
|
||||||
|
n = n.toString().padStart(digits/2, '0');
|
||||||
|
|
||||||
|
return `${letterPair} ${e} ${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* LatLon_OsGridRef - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends LatLon class with method to convert LatLon point to OS Grid Reference.
|
||||||
|
*
|
||||||
|
* @extends LatLonEllipsoidal
|
||||||
|
*/
|
||||||
|
class LatLon_OsGridRef extends LatLonEllipsoidal {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts latitude/longitude to Ordnance Survey grid reference easting/northing coordinate.
|
||||||
|
*
|
||||||
|
* @returns {OsGridRef} OS Grid Reference easting/northing.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const grid = new LatLon(52.65798, 1.71605).toOsGrid(); // TG 51409 13177
|
||||||
|
* // for conversion of (historical) OSGB36 latitude/longitude point:
|
||||||
|
* const grid = new LatLon(52.65798, 1.71605).toOsGrid(LatLon.datums.OSGB36);
|
||||||
|
*/
|
||||||
|
toOsGrid() {
|
||||||
|
// if necessary convert to OSGB36 first
|
||||||
|
const point = this.datum == LatLonEllipsoidal.datums.OSGB36
|
||||||
|
? this
|
||||||
|
: this.convertDatum(LatLonEllipsoidal.datums.OSGB36);
|
||||||
|
|
||||||
|
const φ = point.lat.toRadians();
|
||||||
|
const λ = point.lon.toRadians();
|
||||||
|
|
||||||
|
const { a, b } = nationalGrid.ellipsoid; // a = 6377563.396, b = 6356256.909
|
||||||
|
const φ0 = nationalGrid.trueOrigin.lat.toRadians(); // latitude of true origin, 49°N
|
||||||
|
const λ0 = nationalGrid.trueOrigin.lon.toRadians(); // longitude of true origin, 2°W
|
||||||
|
const E0 = -nationalGrid.falseOrigin.easting; // easting of true origin, 400km
|
||||||
|
const N0 = -nationalGrid.falseOrigin.northing; // northing of true origin, -100km
|
||||||
|
const F0 = nationalGrid.scaleFactor; // 0.9996012717
|
||||||
|
|
||||||
|
const e2 = 1 - (b*b)/(a*a); // eccentricity squared
|
||||||
|
const n = (a-b)/(a+b), n2 = n*n, n3 = n*n*n; // n, n², n³
|
||||||
|
|
||||||
|
const cosφ = Math.cos(φ), sinφ = Math.sin(φ);
|
||||||
|
const ν = a*F0/Math.sqrt(1-e2*sinφ*sinφ); // nu = transverse radius of curvature
|
||||||
|
const ρ = a*F0*(1-e2)/Math.pow(1-e2*sinφ*sinφ, 1.5); // rho = meridional radius of curvature
|
||||||
|
const η2 = ν/ρ-1; // eta = ?
|
||||||
|
|
||||||
|
const Ma = (1 + n + (5/4)*n2 + (5/4)*n3) * (φ-φ0);
|
||||||
|
const Mb = (3*n + 3*n2 + (21/8)*n3) * Math.sin(φ-φ0) * Math.cos(φ+φ0);
|
||||||
|
const Mc = ((15/8)*n2 + (15/8)*n3) * Math.sin(2*(φ-φ0)) * Math.cos(2*(φ+φ0));
|
||||||
|
const Md = (35/24)*n3 * Math.sin(3*(φ-φ0)) * Math.cos(3*(φ+φ0));
|
||||||
|
const M = b * F0 * (Ma - Mb + Mc - Md); // meridional arc
|
||||||
|
|
||||||
|
const cos3φ = cosφ*cosφ*cosφ;
|
||||||
|
const cos5φ = cos3φ*cosφ*cosφ;
|
||||||
|
const tan2φ = Math.tan(φ)*Math.tan(φ);
|
||||||
|
const tan4φ = tan2φ*tan2φ;
|
||||||
|
|
||||||
|
const I = M + N0;
|
||||||
|
const II = (ν/2)*sinφ*cosφ;
|
||||||
|
const III = (ν/24)*sinφ*cos3φ*(5-tan2φ+9*η2);
|
||||||
|
const IIIA = (ν/720)*sinφ*cos5φ*(61-58*tan2φ+tan4φ);
|
||||||
|
const IV = ν*cosφ;
|
||||||
|
const V = (ν/6)*cos3φ*(ν/ρ-tan2φ);
|
||||||
|
const VI = (ν/120) * cos5φ * (5 - 18*tan2φ + tan4φ + 14*η2 - 58*tan2φ*η2);
|
||||||
|
|
||||||
|
const Δλ = λ-λ0;
|
||||||
|
const Δλ2 = Δλ*Δλ, Δλ3 = Δλ2*Δλ, Δλ4 = Δλ3*Δλ, Δλ5 = Δλ4*Δλ, Δλ6 = Δλ5*Δλ;
|
||||||
|
|
||||||
|
let N = I + II*Δλ2 + III*Δλ4 + IIIA*Δλ6;
|
||||||
|
let E = E0 + IV*Δλ + V*Δλ3 + VI*Δλ5;
|
||||||
|
|
||||||
|
N = Number(N.toFixed(3)); // round to mm precision
|
||||||
|
E = Number(E.toFixed(3));
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new OsGridRef(E, N); // note: gets truncated to SW corner of 1m grid square
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`${e.message} from (${point.lat.toFixed(6)},${point.lon.toFixed(6)}).toOsGrid()`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override LatLonEllipsoidal.convertDatum() with version which returns LatLon_OsGridRef.
|
||||||
|
*/
|
||||||
|
convertDatum(toDatum) {
|
||||||
|
const osgbED = super.convertDatum(toDatum); // returns LatLonEllipsoidal_Datum
|
||||||
|
const osgbOSGR = new LatLon_OsGridRef(osgbED.lat, osgbED.lon, osgbED.height, osgbED.datum);
|
||||||
|
return osgbOSGR;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
|
||||||
|
|
||||||
|
export { OsGridRef as default, LatLon_OsGridRef as LatLon, Dms };
|
||||||
+413
@@ -0,0 +1,413 @@
|
|||||||
|
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
|
||||||
|
/* UTM / WGS-84 Conversion Functions (c) Chris Veness 2014-2022 & Ian Renton 2025 */
|
||||||
|
/* MIT Licence */
|
||||||
|
/* www.movable-type.co.uk/scripts/latlong-utm-mgrs.html */
|
||||||
|
/* www.movable-type.co.uk/scripts/geodesy-library.html#utm */
|
||||||
|
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
|
||||||
|
|
||||||
|
/* eslint-disable indent */
|
||||||
|
|
||||||
|
import LatLonEllipsoidal, { Dms } from 'https://cdn.jsdelivr.net/npm/geodesy@2/latlon-ellipsoidal-datum.js';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Universal Transverse Mercator (UTM) system is a 2-dimensional Cartesian coordinate system
|
||||||
|
* providing locations on the surface of the Earth.
|
||||||
|
*
|
||||||
|
* UTM is a set of 60 transverse Mercator projections, normally based on the WGS-84 ellipsoid.
|
||||||
|
* Within each zone, coordinates are represented as eastings and northings, measures in metres; e.g.
|
||||||
|
* ‘31 N 448251 5411932’.
|
||||||
|
*
|
||||||
|
* This method based on Karney 2011 ‘Transverse Mercator with an accuracy of a few nanometers’,
|
||||||
|
* building on Krüger 1912 ‘Konforme Abbildung des Erdellipsoids in der Ebene’.
|
||||||
|
*
|
||||||
|
* @module utm
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/* Utm - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UTM coordinates, with functions to parse them and convert them to LatLon points.
|
||||||
|
*/
|
||||||
|
class Utm {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Utm coordinate object comprising zone, hemisphere, easting, northing on a given
|
||||||
|
* datum (normally WGS84).
|
||||||
|
*
|
||||||
|
* @param {number} zone - UTM 6° longitudinal zone (1..60 covering 180°W..180°E).
|
||||||
|
* @param {string} hemisphere - N for northern hemisphere, S for southern hemisphere.
|
||||||
|
* @param {number} easting - Easting in metres from false easting (-500km from central meridian).
|
||||||
|
* @param {number} northing - Northing in metres from equator (N) or from false northing -10,000km (S).
|
||||||
|
* @param {LatLon.datums} [datum=WGS84] - Datum UTM coordinate is based on.
|
||||||
|
* @param {number} [convergence=null] - Meridian convergence (bearing of grid north
|
||||||
|
* clockwise from true north), in degrees.
|
||||||
|
* @param {number} [scale=null] - Grid scale factor.
|
||||||
|
* @params {boolean=true} verifyEN - Check easting/northing is within 'normal' values (may be
|
||||||
|
* suppressed for extended coherent coordinates or alternative datums
|
||||||
|
* e.g. ED50 (epsg.io/23029).
|
||||||
|
* @throws {TypeError} Invalid UTM coordinate.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* import Utm from '/js/geodesy/utm.js';
|
||||||
|
* const utmCoord = new Utm(31, 'N', 448251, 5411932);
|
||||||
|
*/
|
||||||
|
constructor(zone, hemisphere, easting, northing, datum=LatLonEllipsoidal.datums.WGS84, convergence=null, scale=null, verifyEN=true) {
|
||||||
|
if (!(1<=zone && zone<=60)) throw new RangeError(`invalid UTM zone ‘${zone}’`);
|
||||||
|
if (zone != parseInt(zone)) throw new RangeError(`invalid UTM zone ‘${zone}’`);
|
||||||
|
if (typeof hemisphere != 'string' || !hemisphere.match(/[NS]/i)) throw new RangeError(`invalid UTM hemisphere ‘${hemisphere}’`);
|
||||||
|
if (verifyEN) { // (rough) range-check of E/N values
|
||||||
|
if (!(0<=easting && easting<=1000e3)) throw new RangeError(`invalid UTM easting ‘${easting}’`);
|
||||||
|
if (hemisphere.toUpperCase()=='N' && !(0<=northing && northing<9329006)) throw new RangeError(`invalid UTM northing ‘${northing}’`);
|
||||||
|
if (hemisphere.toUpperCase()=='S' && !(1116914<northing && northing<=10000e3)) throw new RangeError(`invalid UTM northing ‘${northing}’`);
|
||||||
|
}
|
||||||
|
if (!datum || datum.ellipsoid==undefined) throw new TypeError(`unrecognised datum ‘${datum}’`);
|
||||||
|
|
||||||
|
this.zone = Number(zone);
|
||||||
|
this.hemisphere = hemisphere.toUpperCase();
|
||||||
|
this.easting = Number(easting);
|
||||||
|
this.northing = Number(northing);
|
||||||
|
this.datum = datum;
|
||||||
|
this.convergence = convergence===null ? null : Number(convergence);
|
||||||
|
this.scale = scale===null ? null : Number(scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts UTM zone/easting/northing coordinate to latitude/longitude.
|
||||||
|
*
|
||||||
|
* Implements Karney’s method, using Krüger series to order n⁶, giving results accurate to 5nm
|
||||||
|
* for distances up to 3900km from the central meridian.
|
||||||
|
*
|
||||||
|
* @param {Utm} utmCoord - UTM coordinate to be converted to latitude/longitude.
|
||||||
|
* @returns {LatLon} Latitude/longitude of supplied grid reference.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const grid = new Utm(31, 'N', 448251.795, 5411932.678);
|
||||||
|
* const latlong = grid.toLatLon(); // 48°51′29.52″N, 002°17′40.20″E
|
||||||
|
*/
|
||||||
|
toLatLon() {
|
||||||
|
const { zone: z, hemisphere: h } = this;
|
||||||
|
|
||||||
|
const falseEasting = 500e3, falseNorthing = 10000e3;
|
||||||
|
|
||||||
|
const { a, f } = this.datum.ellipsoid; // WGS-84: a = 6378137, f = 1/298.257223563;
|
||||||
|
|
||||||
|
const k0 = 0.9996; // UTM scale on the central meridian
|
||||||
|
|
||||||
|
const x = this.easting - falseEasting; // make x ± relative to central meridian
|
||||||
|
const y = h=='S' ? this.northing - falseNorthing : this.northing; // make y ± relative to equator
|
||||||
|
|
||||||
|
// ---- from Karney 2011 Eq 15-22, 36:
|
||||||
|
|
||||||
|
const e = Math.sqrt(f*(2-f)); // eccentricity
|
||||||
|
const n = f / (2 - f); // 3rd flattening
|
||||||
|
const n2 = n*n, n3 = n*n2, n4 = n*n3, n5 = n*n4, n6 = n*n5;
|
||||||
|
|
||||||
|
const A = a/(1+n) * (1 + 1/4*n2 + 1/64*n4 + 1/256*n6); // 2πA is the circumference of a meridian
|
||||||
|
|
||||||
|
const η = x / (k0*A);
|
||||||
|
const ξ = y / (k0*A);
|
||||||
|
|
||||||
|
const β = [ null, // note β is one-based array (6th order Krüger expressions)
|
||||||
|
1/2*n - 2/3*n2 + 37/96*n3 - 1/360*n4 - 81/512*n5 + 96199/604800*n6,
|
||||||
|
1/48*n2 + 1/15*n3 - 437/1440*n4 + 46/105*n5 - 1118711/3870720*n6,
|
||||||
|
17/480*n3 - 37/840*n4 - 209/4480*n5 + 5569/90720*n6,
|
||||||
|
4397/161280*n4 - 11/504*n5 - 830251/7257600*n6,
|
||||||
|
4583/161280*n5 - 108847/3991680*n6,
|
||||||
|
20648693/638668800*n6 ];
|
||||||
|
|
||||||
|
let ξʹ = ξ;
|
||||||
|
for (let j=1; j<=6; j++) ξʹ -= β[j] * Math.sin(2*j*ξ) * Math.cosh(2*j*η);
|
||||||
|
|
||||||
|
let ηʹ = η;
|
||||||
|
for (let j=1; j<=6; j++) ηʹ -= β[j] * Math.cos(2*j*ξ) * Math.sinh(2*j*η);
|
||||||
|
|
||||||
|
const sinhηʹ = Math.sinh(ηʹ);
|
||||||
|
const sinξʹ = Math.sin(ξʹ), cosξʹ = Math.cos(ξʹ);
|
||||||
|
|
||||||
|
const τʹ = sinξʹ / Math.sqrt(sinhηʹ*sinhηʹ + cosξʹ*cosξʹ);
|
||||||
|
|
||||||
|
let δτi = null;
|
||||||
|
let τi = τʹ;
|
||||||
|
do {
|
||||||
|
const σi = Math.sinh(e*Math.atanh(e*τi/Math.sqrt(1+τi*τi)));
|
||||||
|
const τiʹ = τi * Math.sqrt(1+σi*σi) - σi * Math.sqrt(1+τi*τi);
|
||||||
|
δτi = (τʹ - τiʹ)/Math.sqrt(1+τiʹ*τiʹ)
|
||||||
|
* (1 + (1-e*e)*τi*τi) / ((1-e*e)*Math.sqrt(1+τi*τi));
|
||||||
|
τi += δτi;
|
||||||
|
} while (Math.abs(δτi) > 1e-12); // using IEEE 754 δτi -> 0 after 2-3 iterations
|
||||||
|
// note relatively large convergence test as δτi toggles on ±1.12e-16 for eg 31 N 400000 5000000
|
||||||
|
const τ = τi;
|
||||||
|
|
||||||
|
const φ = Math.atan(τ);
|
||||||
|
|
||||||
|
let λ = Math.atan2(sinhηʹ, cosξʹ);
|
||||||
|
|
||||||
|
// ---- convergence: Karney 2011 Eq 26, 27
|
||||||
|
|
||||||
|
let p = 1;
|
||||||
|
for (let j=1; j<=6; j++) p -= 2*j*β[j] * Math.cos(2*j*ξ) * Math.cosh(2*j*η);
|
||||||
|
let q = 0;
|
||||||
|
for (let j=1; j<=6; j++) q += 2*j*β[j] * Math.sin(2*j*ξ) * Math.sinh(2*j*η);
|
||||||
|
|
||||||
|
const γʹ = Math.atan(Math.tan(ξʹ) * Math.tanh(ηʹ));
|
||||||
|
const γʺ = Math.atan2(q, p);
|
||||||
|
|
||||||
|
const γ = γʹ + γʺ;
|
||||||
|
|
||||||
|
// ---- scale: Karney 2011 Eq 28
|
||||||
|
|
||||||
|
const sinφ = Math.sin(φ);
|
||||||
|
const kʹ = Math.sqrt(1 - e*e*sinφ*sinφ) * Math.sqrt(1 + τ*τ) * Math.sqrt(sinhηʹ*sinhηʹ + cosξʹ*cosξʹ);
|
||||||
|
const kʺ = A / a / Math.sqrt(p*p + q*q);
|
||||||
|
|
||||||
|
const k = k0 * kʹ * kʺ;
|
||||||
|
|
||||||
|
// ------------
|
||||||
|
|
||||||
|
const λ0 = ((z-1)*6 - 180 + 3).toRadians(); // longitude of central meridian
|
||||||
|
λ += λ0; // move λ from zonal to global coordinates
|
||||||
|
|
||||||
|
// round to reasonable precision
|
||||||
|
const lat = Number(φ.toDegrees().toFixed(14)); // nm precision (1nm = 10^-14°)
|
||||||
|
const lon = Number(λ.toDegrees().toFixed(14)); // (strictly lat rounding should be φ⋅cosφ!)
|
||||||
|
const convergence = Number(γ.toDegrees().toFixed(9));
|
||||||
|
const scale = Number(k.toFixed(12));
|
||||||
|
|
||||||
|
const latLong = new LatLon_Utm(lat, lon, 0, this.datum);
|
||||||
|
// ... and add the convergence and scale into the LatLon object ... wonderful JavaScript!
|
||||||
|
latLong.convergence = convergence;
|
||||||
|
latLong.scale = scale;
|
||||||
|
|
||||||
|
return latLong;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a Channel Islands (WA/WV) grid reference.
|
||||||
|
*/
|
||||||
|
static parseChannelIslandGrid(gridref) {
|
||||||
|
// validate format
|
||||||
|
let match = gridref.match(/^W[AV]\s*[0-9]+\s*[0-9]+$/i);
|
||||||
|
if (!match) throw new Error(`invalid grid reference ‘${gridref}’`);
|
||||||
|
|
||||||
|
// skip grid letters to get numeric (easting/northing) part of ref
|
||||||
|
let en = gridref.slice(2).trim().split(/\s+/);
|
||||||
|
// if e/n not whitespace separated, split half way
|
||||||
|
if (en.length == 1) en = [ en[0].slice(0, en[0].length / 2), en[0].slice(en[0].length / 2) ];
|
||||||
|
|
||||||
|
// validation
|
||||||
|
if (en[0].length != en[1].length) throw new Error(`invalid grid reference ‘${gridref}’`);
|
||||||
|
|
||||||
|
// standardise to 10-digit refs (metres)
|
||||||
|
en[0] = en[0].padEnd(5, '0');
|
||||||
|
en[1] = en[1].padEnd(5, '0');
|
||||||
|
|
||||||
|
let utmCoord = "30 N ";
|
||||||
|
const e = 5 + en[0];
|
||||||
|
utmCoord += e + " ";
|
||||||
|
if (gridref.substring(0, 2) === "WA") {
|
||||||
|
const n = 55 + en[1];
|
||||||
|
utmCoord += n;
|
||||||
|
} else if (gridref.substring(0, 2) === "WV") {
|
||||||
|
const n = 54 + en[1];
|
||||||
|
utmCoord += n;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Utm.parse(utmCoord);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses string representation of UTM coordinate.
|
||||||
|
*
|
||||||
|
* A UTM coordinate comprises (space-separated)
|
||||||
|
* - zone
|
||||||
|
* - hemisphere
|
||||||
|
* - easting
|
||||||
|
* - northing.
|
||||||
|
*
|
||||||
|
* @param {string} utmCoord - UTM coordinate (WGS 84).
|
||||||
|
* @param {Datum} [datum=WGS84] - Datum coordinate is defined in (default WGS 84).
|
||||||
|
* @returns {Utm} Parsed UTM coordinate.
|
||||||
|
* @throws {TypeError} Invalid UTM coordinate.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const utmCoord = Utm.parse('31 N 448251 5411932');
|
||||||
|
* // utmCoord: {zone: 31, hemisphere: 'N', easting: 448251, northing: 5411932 }
|
||||||
|
*/
|
||||||
|
static parse(utmCoord, datum=LatLonEllipsoidal.datums.WGS84) {
|
||||||
|
// match separate elements (separated by whitespace)
|
||||||
|
utmCoord = utmCoord.trim().match(/\S+/g);
|
||||||
|
|
||||||
|
if (utmCoord==null || utmCoord.length!=4) throw new Error(`invalid UTM coordinate ‘${utmCoord}’`);
|
||||||
|
|
||||||
|
const zone = utmCoord[0], hemisphere = utmCoord[1], easting = utmCoord[2], northing = utmCoord[3];
|
||||||
|
|
||||||
|
return new this(zone, hemisphere, easting, northing, datum); // 'new this' as may return subclassed types
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a string representation of a UTM coordinate.
|
||||||
|
*
|
||||||
|
* To distinguish from MGRS grid zone designators, a space is left between the zone and the
|
||||||
|
* hemisphere.
|
||||||
|
*
|
||||||
|
* Note that UTM coordinates get rounded, not truncated (unlike MGRS grid references).
|
||||||
|
*
|
||||||
|
* @param {number} [digits=0] - Number of digits to appear after the decimal point (3 ≡ mm).
|
||||||
|
* @returns {string} A string representation of the coordinate.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const utm = new Utm('31', 'N', 448251, 5411932).toString(4); // 31 N 448251.0000 5411932.0000
|
||||||
|
*/
|
||||||
|
toString(digits=0) {
|
||||||
|
|
||||||
|
const z = this.zone.toString().padStart(2, '0');
|
||||||
|
const h = this.hemisphere;
|
||||||
|
const e = this.easting.toFixed(digits);
|
||||||
|
const n = this.northing.toFixed(digits);
|
||||||
|
|
||||||
|
return `${z} ${h} ${e} ${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* LatLon_Utm - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extends LatLon with method to convert LatLon points to UTM coordinates.
|
||||||
|
*
|
||||||
|
* @extends LatLon
|
||||||
|
*/
|
||||||
|
class LatLon_Utm extends LatLonEllipsoidal {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts latitude/longitude to UTM coordinate.
|
||||||
|
*
|
||||||
|
* Implements Karney’s method, using Krüger series to order n⁶, giving results accurate to 5nm
|
||||||
|
* for distances up to 3900km from the central meridian.
|
||||||
|
*
|
||||||
|
* @param {number} [zoneOverride] - Use specified zone rather than zone within which point lies;
|
||||||
|
* note overriding the UTM zone has the potential to result in negative eastings, and
|
||||||
|
* perverse results within Norway/Svalbard exceptions.
|
||||||
|
* @returns {Utm} UTM coordinate.
|
||||||
|
* @throws {TypeError} Latitude outside UTM limits.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const latlong = new LatLon(48.8582, 2.2945);
|
||||||
|
* const utmCoord = latlong.toUtm(); // 31 N 448252 5411933
|
||||||
|
*/
|
||||||
|
toUtm(zoneOverride=undefined) {
|
||||||
|
if (!(-80<=this.lat && this.lat<=84)) throw new RangeError(`latitude ‘${this.lat}’ outside UTM limits`);
|
||||||
|
|
||||||
|
const falseEasting = 500e3, falseNorthing = 10000e3;
|
||||||
|
|
||||||
|
let zone = zoneOverride || Math.floor((this.lon+180)/6) + 1; // longitudinal zone
|
||||||
|
let λ0 = ((zone-1)*6 - 180 + 3).toRadians(); // longitude of central meridian
|
||||||
|
|
||||||
|
// ---- handle Norway/Svalbard exceptions
|
||||||
|
// grid zones are 8° tall; 0°N is offset 10 into latitude bands array
|
||||||
|
const mgrsLatBands = 'CDEFGHJKLMNPQRSTUVWXX'; // X is repeated for 80-84°N
|
||||||
|
const latBand = mgrsLatBands.charAt(Math.floor(this.lat/8+10));
|
||||||
|
// adjust zone & central meridian for Norway
|
||||||
|
if (zone==31 && latBand=='V' && this.lon>= 3) { zone++; λ0 += (6).toRadians(); }
|
||||||
|
// adjust zone & central meridian for Svalbard
|
||||||
|
if (zone==32 && latBand=='X' && this.lon< 9) { zone--; λ0 -= (6).toRadians(); }
|
||||||
|
if (zone==32 && latBand=='X' && this.lon>= 9) { zone++; λ0 += (6).toRadians(); }
|
||||||
|
if (zone==34 && latBand=='X' && this.lon< 21) { zone--; λ0 -= (6).toRadians(); }
|
||||||
|
if (zone==34 && latBand=='X' && this.lon>=21) { zone++; λ0 += (6).toRadians(); }
|
||||||
|
if (zone==36 && latBand=='X' && this.lon< 33) { zone--; λ0 -= (6).toRadians(); }
|
||||||
|
if (zone==36 && latBand=='X' && this.lon>=33) { zone++; λ0 += (6).toRadians(); }
|
||||||
|
|
||||||
|
const φ = this.lat.toRadians(); // latitude ± from equator
|
||||||
|
const λ = this.lon.toRadians() - λ0; // longitude ± from central meridian
|
||||||
|
|
||||||
|
// allow alternative ellipsoid to be specified
|
||||||
|
const ellipsoid = this.datum ? this.datum.ellipsoid : LatLonEllipsoidal.ellipsoids.WGS84;
|
||||||
|
const { a, f } = ellipsoid; // WGS-84: a = 6378137, f = 1/298.257223563;
|
||||||
|
|
||||||
|
const k0 = 0.9996; // UTM scale on the central meridian
|
||||||
|
|
||||||
|
// ---- easting, northing: Karney 2011 Eq 7-14, 29, 35:
|
||||||
|
|
||||||
|
const e = Math.sqrt(f*(2-f)); // eccentricity
|
||||||
|
const n = f / (2 - f); // 3rd flattening
|
||||||
|
const n2 = n*n, n3 = n*n2, n4 = n*n3, n5 = n*n4, n6 = n*n5;
|
||||||
|
|
||||||
|
const cosλ = Math.cos(λ), sinλ = Math.sin(λ), tanλ = Math.tan(λ);
|
||||||
|
|
||||||
|
const τ = Math.tan(φ); // τ ≡ tanφ, τʹ ≡ tanφʹ; prime (ʹ) indicates angles on the conformal sphere
|
||||||
|
const σ = Math.sinh(e*Math.atanh(e*τ/Math.sqrt(1+τ*τ)));
|
||||||
|
|
||||||
|
const τʹ = τ*Math.sqrt(1+σ*σ) - σ*Math.sqrt(1+τ*τ);
|
||||||
|
|
||||||
|
const ξʹ = Math.atan2(τʹ, cosλ);
|
||||||
|
const ηʹ = Math.asinh(sinλ / Math.sqrt(τʹ*τʹ + cosλ*cosλ));
|
||||||
|
|
||||||
|
const A = a/(1+n) * (1 + 1/4*n2 + 1/64*n4 + 1/256*n6); // 2πA is the circumference of a meridian
|
||||||
|
|
||||||
|
const α = [ null, // note α is one-based array (6th order Krüger expressions)
|
||||||
|
1/2*n - 2/3*n2 + 5/16*n3 + 41/180*n4 - 127/288*n5 + 7891/37800*n6,
|
||||||
|
13/48*n2 - 3/5*n3 + 557/1440*n4 + 281/630*n5 - 1983433/1935360*n6,
|
||||||
|
61/240*n3 - 103/140*n4 + 15061/26880*n5 + 167603/181440*n6,
|
||||||
|
49561/161280*n4 - 179/168*n5 + 6601661/7257600*n6,
|
||||||
|
34729/80640*n5 - 3418889/1995840*n6,
|
||||||
|
212378941/319334400*n6 ];
|
||||||
|
|
||||||
|
let ξ = ξʹ;
|
||||||
|
for (let j=1; j<=6; j++) ξ += α[j] * Math.sin(2*j*ξʹ) * Math.cosh(2*j*ηʹ);
|
||||||
|
|
||||||
|
let η = ηʹ;
|
||||||
|
for (let j=1; j<=6; j++) η += α[j] * Math.cos(2*j*ξʹ) * Math.sinh(2*j*ηʹ);
|
||||||
|
|
||||||
|
let x = k0 * A * η;
|
||||||
|
let y = k0 * A * ξ;
|
||||||
|
|
||||||
|
// ---- convergence: Karney 2011 Eq 23, 24
|
||||||
|
|
||||||
|
let pʹ = 1;
|
||||||
|
for (let j=1; j<=6; j++) pʹ += 2*j*α[j] * Math.cos(2*j*ξʹ) * Math.cosh(2*j*ηʹ);
|
||||||
|
let qʹ = 0;
|
||||||
|
for (let j=1; j<=6; j++) qʹ += 2*j*α[j] * Math.sin(2*j*ξʹ) * Math.sinh(2*j*ηʹ);
|
||||||
|
|
||||||
|
const γʹ = Math.atan(τʹ / Math.sqrt(1+τʹ*τʹ)*tanλ);
|
||||||
|
const γʺ = Math.atan2(qʹ, pʹ);
|
||||||
|
|
||||||
|
const γ = γʹ + γʺ;
|
||||||
|
|
||||||
|
// ---- scale: Karney 2011 Eq 25
|
||||||
|
|
||||||
|
const sinφ = Math.sin(φ);
|
||||||
|
const kʹ = Math.sqrt(1 - e*e*sinφ*sinφ) * Math.sqrt(1 + τ*τ) / Math.sqrt(τʹ*τʹ + cosλ*cosλ);
|
||||||
|
const kʺ = A / a * Math.sqrt(pʹ*pʹ + qʹ*qʹ);
|
||||||
|
|
||||||
|
const k = k0 * kʹ * kʺ;
|
||||||
|
|
||||||
|
// ------------
|
||||||
|
|
||||||
|
// shift x/y to false origins
|
||||||
|
x = x + falseEasting; // make x relative to false easting
|
||||||
|
if (y < 0) y = y + falseNorthing; // make y in southern hemisphere relative to false northing
|
||||||
|
|
||||||
|
// round to reasonable precision
|
||||||
|
x = Number(x.toFixed(9)); // nm precision
|
||||||
|
y = Number(y.toFixed(9)); // nm precision
|
||||||
|
const convergence = Number(γ.toDegrees().toFixed(9));
|
||||||
|
const scale = Number(k.toFixed(12));
|
||||||
|
|
||||||
|
const h = this.lat>=0 ? 'N' : 'S'; // hemisphere
|
||||||
|
|
||||||
|
return new Utm(zone, h, x, y, this.datum, convergence, scale, !!zoneOverride);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
|
||||||
|
|
||||||
|
export { Utm as default, LatLon_Utm as LatLon, Dms };
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div id="add-spot-intro-box" class="permanently-dismissible-box mt-3">
|
<div id="add-spot-intro-box" class="permanently-dismissible-box mt-3">
|
||||||
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
<div class="alert alert-primary alert-dismissible fade show" role="alert"> <!-- TODO Remove when feature available -->
|
||||||
<i class="fa-solid fa-circle-info"></i> <strong>Adding spots to Spothole</strong><br/>This page is implemented
|
<i class="fa-solid fa-circle-info"></i> <strong>Adding spots to Spothole</strong><br/>This page is implemented
|
||||||
as a proof of concept for adding spots to the Spothole system. Currently, spots added in this way are only
|
as a proof of concept for adding spots to the Spothole system. Currently, spots added in this way are only
|
||||||
visible within Spothole and are not sent "upstream" to DX clusters or xOTA spotting sites. The functionality
|
visible within Spothole and are not sent "upstream" to DX clusters or xOTA spotting sites. The functionality
|
||||||
@@ -24,14 +24,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form class="row g-3">
|
<form class="row g-3" onsubmit="return addSpot();">
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label for="dx-call" class="form-label">DX Call *</label>
|
<label for="dx-call" class="form-label">DX Call *</label>
|
||||||
<input type="text" class="form-control input-narrow" id="dx-call" placeholder="N0CALL">
|
<input type="text" class="form-control input-narrow" id="dx-call" placeholder="N0CALL" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label for="freq" class="form-label">Frequency (kHz) *</label>
|
<label for="freq" class="form-label">Frequency (kHz) *</label>
|
||||||
<input type="text" class="form-control input-narrow" id="freq" placeholder="e.g. 14100">
|
<input type="text" class="form-control input-narrow" id="freq" placeholder="e.g. 14100" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label for="mode" class="form-label">Mode</label>
|
<label for="mode" class="form-label">Mode</label>
|
||||||
@@ -60,10 +60,10 @@
|
|||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<label for="de-call" class="form-label">Your Call *</label>
|
<label for="de-call" class="form-label">Your Call *</label>
|
||||||
<input type="text" class="form-control storeable-text input-narrow" id="de-call"
|
<input type="text" class="form-control storeable-text input-narrow" id="de-call"
|
||||||
placeholder="N0CALL">
|
placeholder="N0CALL" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<button type="button" class="btn btn-primary mt-2em" onclick="addSpot();">Spot</button>
|
<button type="submit" class="btn btn-primary mt-2em">Spot</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/add-spot.js?v=1785434214"></script>
|
<script src="/static/js/add-spot.js?v=1786175023"></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>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
</div>
|
</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,
|
||||||
|
show_data_button=web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"]) %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,19 +51,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"] %}
|
||||||
<div id="data-area" class="appearing-panel card mb-3">
|
<div id="data-area" class="appearing-panel card mb-3">
|
||||||
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
|
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4">
|
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||||
|
{% if web_ui_options["qrz-enabled"] %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
|
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
|
{% if web_ui_options["hamqth-enabled"] %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
|
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
|
|
||||||
<div id="table-container">
|
<div id="table-container">
|
||||||
<table id="table" class="table">
|
<table id="table" class="table">
|
||||||
@@ -75,7 +82,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/alerts.js?v=1785434213"></script>
|
<script src="/static/js/alerts.js?v=1786175024"></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>
|
||||||
|
|||||||
+10
-3
@@ -6,7 +6,8 @@
|
|||||||
<div class="col-auto me-auto pt-3"></div>
|
<div class="col-auto me-auto pt-3"></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,
|
||||||
|
show_data_button=web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"]) %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,19 +55,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"] %}
|
||||||
<div id="data-area" class="appearing-panel card mb-3">
|
<div id="data-area" class="appearing-panel card mb-3">
|
||||||
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
|
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4">
|
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||||
|
{% if web_ui_options["qrz-enabled"] %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
|
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
|
{% if web_ui_options["hamqth-enabled"] %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
|
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
|
|
||||||
<div id="bands-container"></div>
|
<div id="bands-container"></div>
|
||||||
|
|
||||||
@@ -75,8 +82,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="/static/js/spotsbandsandmap.js?v=1785434214"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1786175023"></script>
|
||||||
<script src="/static/js/bands.js?v=1785434214"></script>
|
<script src="/static/js/bands.js?v=1786175023"></script>
|
||||||
<script>$(document).ready(function () {
|
<script>$(document).ready(function () {
|
||||||
$("#nav-link-bands").addClass("active");
|
$("#nav-link-bands").addClass("active");
|
||||||
}); <!-- highlight active page in nav --></script>
|
}); <!-- highlight active page in nav --></script>
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{% extends "skeleton.html" %}
|
{% extends "skeleton.html" %}
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v=1785434213" type="text/css">
|
<link rel="stylesheet" href="/static/css/style.css?v=1786175023" type="text/css">
|
||||||
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
|
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
|
||||||
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
|
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
|
||||||
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
|
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
|
||||||
@@ -10,10 +10,10 @@
|
|||||||
<script src="/static/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="/static/vendor/js/tinycolor2-1.6.0.min.js"></script>
|
<script src="/static/vendor/js/tinycolor2-1.6.0.min.js"></script>
|
||||||
|
|
||||||
<script src="/static/js/utils.js?v=1785434213"></script>
|
<script src="/static/js/utils.js?v=1786175023"></script>
|
||||||
<script src="/static/js/ui-ham.js?v=1785434213"></script>
|
<script src="/static/js/ui-ham.js?v=1786175023"></script>
|
||||||
<script src="/static/js/geo.js?v=1785434213"></script>
|
<script src="/static/js/geo.js?v=1786175023"></script>
|
||||||
<script src="/static/js/common.js?v=1785434213"></script>
|
<script src="/static/js/common.js?v=1786175023"></script>
|
||||||
{% end %}
|
{% end %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -284,7 +284,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
|
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
|
||||||
<script src="/static/js/conditions.js?v=1785434213"></script>
|
<script src="/static/js/conditions.js?v=1786175023"></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>
|
||||||
|
|||||||
+11
-4
@@ -20,7 +20,8 @@
|
|||||||
<div class="col-auto me-auto pt-3"></div>
|
<div class="col-auto me-auto pt-3"></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,
|
||||||
|
show_data_button=web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"]) %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,19 +75,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"] %}
|
||||||
<div id="data-area" class="appearing-panel card mb-3">
|
<div id="data-area" class="appearing-panel card mb-3">
|
||||||
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
|
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4">
|
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||||
|
{% if web_ui_options["qrz-enabled"] %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
|
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
|
{% if web_ui_options["hamqth-enabled"] %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
|
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -103,13 +110,13 @@
|
|||||||
<script src="/static/vendor/js/leaflet-maidenhead.js"></script>
|
<script src="/static/vendor/js/leaflet-maidenhead.js"></script>
|
||||||
<script src="/static/vendor/js/leaflet-ituzones.js"></script>
|
<script src="/static/vendor/js/leaflet-ituzones.js"></script>
|
||||||
<script src="/static/vendor/js/leaflet-cqzones.js"></script>
|
<script src="/static/vendor/js/leaflet-cqzones.js"></script>
|
||||||
<script src="/static/vendor/js/leaflet-workedallbritainireland.js"></script>
|
<script src="/static/vendor/js/leaflet-workedallbritainireland.js" type="module"></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="/static/js/spotsbandsandmap.js?v=1785434213"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1786175024"></script>
|
||||||
<script src="/static/js/map.js?v=1785434213"></script>
|
<script src="/static/js/map.js?v=1786175024"></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>
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
<div class="col-md-8 text-end">
|
<div class="col-md-8 text-end">
|
||||||
<div class="d-inline-flex gap-3">
|
<div class="d-inline-flex gap-3">
|
||||||
{% module Template("widgets/search.html", web_ui_options=web_ui_options) %}
|
{% module Template("widgets/search.html", web_ui_options=web_ui_options) %}
|
||||||
{% 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,
|
||||||
|
show_data_button=web_ui_options["qrz-enabled"] or web_ui_options["hamqth-enabled"]) %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,12 +87,16 @@
|
|||||||
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
|
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4">
|
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||||
|
{% if web_ui_options["qrz-enabled"] %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
|
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
|
{% if web_ui_options["hamqth-enabled"] %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
|
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
|
||||||
</div>
|
</div>
|
||||||
|
{% end %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{% module Template("cards/location.html", web_ui_options=web_ui_options) %}
|
{% module Template("cards/location.html", web_ui_options=web_ui_options) %}
|
||||||
</div>
|
</div>
|
||||||
@@ -116,8 +121,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="/static/js/spotsbandsandmap.js?v=1785434213"></script>
|
<script src="/static/js/spotsbandsandmap.js?v=1786175023"></script>
|
||||||
<script src="/static/js/spots.js?v=1785434213"></script>
|
<script src="/static/js/spots.js?v=1786175023"></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>
|
||||||
|
|||||||
+13
-4
@@ -6,19 +6,19 @@
|
|||||||
Spothole
|
Spothole
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
<div class="col"><strong>Metadata</strong></div>
|
<div class="col"><strong>Metadata</strong></div>
|
||||||
<div class="col">Software Version: <span id="software-version"></span></div>
|
<div class="col">Software Version: <span id="software-version"></span></div>
|
||||||
<div class="col">Owner Callsign: <span id="server-owner-callsign"></span></div>
|
<div class="col">Owner Callsign: <span id="server-owner-callsign"></span></div>
|
||||||
<div class="col">Up since: <span id="up-since"></span></div>
|
<div class="col">Up since: <span id="up-since"></span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
<div class="col"><strong>Performance</strong></div>
|
<div class="col"><strong>Performance</strong></div>
|
||||||
<div class="col">Memory Use: <span id="memory-use"></span></div>
|
<div class="col">Memory Use: <span id="memory-use"></span></div>
|
||||||
<div class="col">Total Spots: <span id="total-spots"></span></div>
|
<div class="col">Total Spots: <span id="total-spots"></span></div>
|
||||||
<div class="col">Total Alerts: <span id="total-alerts"></span></div>
|
<div class="col">Total Alerts: <span id="total-alerts"></span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
<div class="row row-cols-1 row-cols-md-4 g-4 mb-4 mb-md-2">
|
||||||
<div class="col"><strong>Web Server</strong></div>
|
<div class="col"><strong>Web Server</strong></div>
|
||||||
<div class="col">Status: <span id="web-server-status"></span></div>
|
<div class="col">Status: <span id="web-server-status"></span></div>
|
||||||
<div class="col">Last API call: <span id="web-server-last-api"></span></div>
|
<div class="col">Last API call: <span id="web-server-last-api"></span></div>
|
||||||
@@ -72,7 +72,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/status.js?v=1785434213"></script>
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
Callsign Data Providers
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="callsign-data-providers-status-container">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/status.js?v=1786175023"></script>
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
$("#nav-link-status").addClass("active");
|
$("#nav-link-status").addClass("active");
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
<button id="display-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button"
|
<button id="display-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button"
|
||||||
onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i><span
|
onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i><span
|
||||||
class="hideonmobile"> Display</span></button>
|
class="hideonmobile"> Display</span></button>
|
||||||
|
{% if show_data_button %}
|
||||||
<button id="data-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button"
|
<button id="data-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button"
|
||||||
onclick="toggleDataPanel();"><i class="fa-solid fa-database"></i><span
|
onclick="toggleDataPanel();"><i class="fa-solid fa-database"></i><span
|
||||||
class="hideonmobile"> Your data</span></button>
|
class="hideonmobile"> Your data</span></button>
|
||||||
|
{% end %}
|
||||||
</div>
|
</div>
|
||||||
Reference in New Issue
Block a user