Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9ce8db48b | ||
|
|
f08ff030e3 | ||
|
|
3ab2375a32 | ||
|
|
985646bb94 | ||
|
|
8d28a29f82 | ||
|
|
4f9ec65d9a | ||
|
|
f68b244e38 | ||
|
|
56a29b6203 | ||
|
|
5699627069 | ||
|
|
66081f32f9 | ||
|
|
4f8f748d88 | ||
|
|
c2ecef1003 | ||
|
|
266468f938 | ||
|
|
fffd230633 | ||
|
|
3c1b1821f5 | ||
|
|
77d7fd70d9 | ||
|
|
c3bcc1190d | ||
|
|
ecab8d6d16 | ||
|
|
0cea777616 | ||
|
|
3c4c595288 | ||
|
|
555a8418a9 | ||
|
|
7ee08e2fcd | ||
|
|
4353c9ebf9 | ||
|
|
4a9ff2c636 | ||
|
|
e7253c0916 | ||
|
|
ba08555a30 | ||
|
|
374a326874 | ||
|
|
c397009ada | ||
|
|
66010dc682 | ||
|
|
2fc9658571 | ||
|
|
34e3933d5d | ||
|
|
eee0d70140 | ||
|
|
600a330e63 | ||
|
|
f2a6bfa515 | ||
|
|
4d29e0026f | ||
|
|
c7b6163440 | ||
|
|
c51648738f | ||
|
|
88923bc96e | ||
|
|
565a3f7564 | ||
|
|
b09108b41b | ||
|
|
72f367f607 | ||
|
|
998b394d2c | ||
|
|
d1df772649 | ||
|
|
215b61593b | ||
|
|
eb1d575623 | ||
|
|
e4c3a52299 | ||
|
|
21a3ae70b5 | ||
|
|
d4d43a43c8 | ||
|
|
f28bcc2464 | ||
|
|
615e1183a8 | ||
|
|
0163643533 | ||
|
|
757071972a | ||
|
|
273db04bb0 | ||
|
|
89bb5d5e3e |
@@ -0,0 +1,7 @@
|
||||
/.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
/config.yml
|
||||
/cache/
|
||||
.git
|
||||
.gitignore
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding">
|
||||
<file url="file://$PROJECT_DIR$/datafiles/MUNICIPIOS.csv" charset="ISO-8859-1" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -2,8 +2,9 @@
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/webassets/vendor" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/static/vendor" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.13 virtualenv at ~/code/spothole/.venv" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
||||
@@ -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"]
|
||||
@@ -1,4 +1,4 @@
|
||||
# 
|
||||
# 
|
||||
|
||||
Spothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open
|
||||
JSON API as well as a website to browse the data.
|
||||
@@ -19,6 +19,8 @@ Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), th
|
||||
SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, LLOTA, WWTOTA, Tiles on the Air, the UK Packet
|
||||
Repeater Network, NG3K, and any site based on the xOTA software by nischu.
|
||||
|
||||
Additional Special Interest Groups (SIGs) without their own specific data source include WAB, WAI and DME.
|
||||
|
||||

|
||||
|
||||

|
||||
@@ -31,501 +33,38 @@ 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
|
||||
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
|
||||
ways to do this; see below.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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 (`/webassets/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 "map" and "bands"
|
||||
pages simply query the main spot API on a timer, whereas the main/spots page combines 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 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!
|
||||
|
||||
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 too.
|
||||
|
||||
```nginx
|
||||
server {
|
||||
server_name spothole.app;
|
||||
|
||||
# Wellknown area for Lets Encrypt
|
||||
location /.well-known/ {
|
||||
alias /var/www/html/.well-known/;
|
||||
}
|
||||
|
||||
# SSE endpoints
|
||||
location ~ ^/api/v1/(spots|alerts)/stream {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
|
||||
# Allow keep-alive
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# Set correct content type for SSE API calls
|
||||
add_header Content-Type text/event-stream always;
|
||||
|
||||
# Set remove buffering, remove caching, add suitable timeouts for SSE API calls
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 24h;
|
||||
proxy_connect_timeout 10s;
|
||||
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;
|
||||
|
||||
# Pass on IP address and host information to Spothole, in case logging this information is required
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Other API endpoints
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
|
||||
# Allow keep-alive
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# Set up buffering, remove caching, add suitable timeouts for API calls
|
||||
proxy_buffering on;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_connect_timeout 10s;
|
||||
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;
|
||||
|
||||
# Pass on IP address and host information to Spothole, in case logging this information is required
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Static assets
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
|
||||
# Allow keep-alive
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# Set up buffering and caching, add suitable timeouts for static asset requests
|
||||
proxy_buffering on;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_connect_timeout 10s;
|
||||
add_header Cache-Control "public, max-age=3600, must-revalidate" always;
|
||||
|
||||
# Pass on IP address and host information to Spothole, in case logging this information is required
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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 scripts
|
||||
* `/data` - Data storage classes
|
||||
* `/spotproviders` - Classes providing spots by accessing the APIs of other services
|
||||
* `/alertproviders` - Classes providing alerts by accessing the APIs of other services
|
||||
* `/solarconditionsproviders` - Classes providing solar and propagation by accessing the APIs of other services
|
||||
* `/server` - Classes for running Spothole's own web server
|
||||
|
||||
*Templates*
|
||||
|
||||
* `/templates` - Templates used for constructing Spothole's user-targeted HTML pages
|
||||
|
||||
*HTML/JS/CSS front-end code*
|
||||
|
||||
* `/webassets` - Root for static files served by the web server
|
||||
* `/webassets/apidocs` - Contains the OpenAPI spec (`openapi.yml`)
|
||||
* `/webassets/css` - CSS files used by the web front-end
|
||||
* `/webassets/img` - image files used by the web front-end
|
||||
* `/webassets/js` - JavaScript used by the web front-end
|
||||
* `/webassets/vendor` - Third-party libraries (CSS, JS, fonts and images)
|
||||
|
||||
*Miscellaneous*
|
||||
|
||||
* `/` - Main script (`spothole.py`), pip `requirements.txt`, config, README, etc.
|
||||
* `/images` - Image sources
|
||||
* `/datafiles` - Local data sources (differentiated from the majority of data files which are loaded from URLs and
|
||||
cached in `/cache`)
|
||||
* `/cache` - Directory where static-ish data downloaded from the internet is cached to avoid rapid re-requests, and
|
||||
where spot/alert data is cached so that it survives a software restart. 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 `spotproviders` 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 alert providers.
|
||||
You are more than welcome to use the data and the API that Spothole provides to power your own software, to run your
|
||||
own Spothole server, or to modify it in any way. More details can be found in the following sections:
|
||||
|
||||
* [Embedding Spothole in another website](docs/embedding.md)
|
||||
* [Writing your own client](docs/clients.md)
|
||||
* [Running your own copy](docs/running.md)
|
||||
* [nginx reverse proxy configuration](docs/nginx.md)
|
||||
* [systemd service file](docs/systemd.md)
|
||||
* [Running in Docker](docs/docker.md)
|
||||
* [Use of multiple cluster logins](docs/multicluster.md)
|
||||
* [Modifying the source code](docs/modifying.md)
|
||||
|
||||
## Thanks
|
||||
|
||||
As well as being my work, I have also gratefully received feature patches from Steven, M1SDH.
|
||||
|
||||
The project contains GeoJSON files for CQ and ITU zones, in the `/datafiles/` directory. These are MIT-licenced and, to
|
||||
my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet.
|
||||
my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet. `/datafiles` also contains a
|
||||
`MUNICIPIOS.csv` file, from the "Nomenclátor Geográfico de Municipios y Entidades de Población" data set sourced from
|
||||
[el Centro Nacional de Información Geográfica](https://centrodedescargas.cnig.es/CentroDescargas/home).
|
||||
`didbase-stations.csv` and the TOTA CSV files were created by me based on publicly available data from GIRO and from
|
||||
maps of conference centres.
|
||||
|
||||
The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the
|
||||
`/webassets/img/flags/` directory.
|
||||
`/static/img/flags/` directory.
|
||||
|
||||
The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries.
|
||||
This project would not have been possible without these libraries, so many thanks to their developers.
|
||||
|
||||
### Third Party Libraries
|
||||
|
||||
A number of third-party libraries are self-hosted in the `/webassets/vendor/` directory. These files are subject to
|
||||
A number of third-party libraries are self-hosted in the `/static/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
|
||||
|
||||
@@ -8,10 +8,10 @@ from core.config import MAX_ALERT_AGE
|
||||
class AlertProvider:
|
||||
"""Generic alert provider class. Subclasses of this query the individual APIs for alerts."""
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, name, provider_config):
|
||||
"""Constructor"""
|
||||
|
||||
self.name = provider_config["name"]
|
||||
self.name = name
|
||||
self.enabled = provider_config["enabled"]
|
||||
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||
self.status = "Not Started" if self.enabled else "Disabled"
|
||||
|
||||
@@ -15,7 +15,7 @@ class BOTA(HTTPAlertProvider):
|
||||
ALERTS_URL = "https://www.beachesontheair.com/"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("BOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_alerts(self, http_response):
|
||||
new_alerts = []
|
||||
|
||||
@@ -4,6 +4,7 @@ from threading import Thread, Event
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||
|
||||
from alertproviders.alert_provider import AlertProvider
|
||||
from core.constants import HTTP_HEADERS
|
||||
@@ -13,8 +14,8 @@ class HTTPAlertProvider(AlertProvider):
|
||||
"""Generic alert provider class for providers that request data via HTTP(S). Just for convenience to avoid code
|
||||
duplication. Subclasses of this query the individual APIs for data."""
|
||||
|
||||
def __init__(self, provider_config, url, poll_interval):
|
||||
super().__init__(provider_config)
|
||||
def __init__(self, name, provider_config, url, poll_interval):
|
||||
super().__init__(name, provider_config)
|
||||
self._url = url
|
||||
self._poll_interval = poll_interval
|
||||
self._thread = None
|
||||
@@ -41,6 +42,8 @@ class HTTPAlertProvider(AlertProvider):
|
||||
# Request data from API
|
||||
logging.debug("Polling " + self.name + " alert API...")
|
||||
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
|
||||
# Check response code was good
|
||||
if http_response.ok:
|
||||
# Pass off to the subclass for processing
|
||||
new_alerts = self._http_response_to_alerts(http_response)
|
||||
# Submit the new alerts for processing. There might not be any alerts for the less popular programs.
|
||||
@@ -50,7 +53,14 @@ class HTTPAlertProvider(AlertProvider):
|
||||
self.status = "OK"
|
||||
self.last_update_time = datetime.now(pytz.UTC)
|
||||
logging.debug("Received data from " + self.name + " alert API.")
|
||||
else:
|
||||
self.status = "Error"
|
||||
logging.warning(f"HTTP {http_response.status_code} when calling {self.name} alerts API.")
|
||||
|
||||
except ConnectionError:
|
||||
logging.warning(f"Connection error when accessing {self.name} alerts API.")
|
||||
except (ConnectTimeout, ReadTimeout):
|
||||
logging.warning(f"Timeout when accessing {self.name} alerts API.")
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.exception("Exception in HTTP JSON Alert Provider (" + self.name + ")")
|
||||
|
||||
@@ -18,7 +18,7 @@ class NG3K(HTTPAlertProvider):
|
||||
AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE)
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_alerts(self, http_response):
|
||||
new_alerts = []
|
||||
|
||||
@@ -15,7 +15,7 @@ class ParksNPeaks(HTTPAlertProvider):
|
||||
ALERTS_URL = "https://parksnpeaks.org/api/ALERTS/"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("ParksNPeaks", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_alerts(self, http_response):
|
||||
new_alerts = []
|
||||
@@ -33,18 +33,24 @@ class ParksNPeaks(HTTPAlertProvider):
|
||||
start_time = datetime.strptime(source_alert["alTime"], "%Y-%m-%d %H:%M:%S").replace(
|
||||
tzinfo=pytz.UTC).timestamp()
|
||||
|
||||
sigrefs = []
|
||||
# PnP can give us an alert of class "QRP" which is the only one that's not a real SIG in Spothole's list,
|
||||
# so mask this out if we got it.
|
||||
if sig != "QRP":
|
||||
sigrefs = [SIGRef(id=sig_ref, sig=sig, name=sig_ref_name)]
|
||||
|
||||
# Convert to our alert format
|
||||
alert = Alert(source=self.name,
|
||||
source_id=source_alert["alID"],
|
||||
dx_calls=[source_alert["CallSign"].upper()],
|
||||
freqs_modes=source_alert["Freq"] + " " + source_alert["MODE"],
|
||||
comment=source_alert["Comments"],
|
||||
sig_refs=[SIGRef(id=sig_ref, sig=sig, name=sig_ref_name)],
|
||||
sig_refs=sigrefs,
|
||||
start_time=start_time,
|
||||
is_dxpedition=False)
|
||||
|
||||
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before
|
||||
if sig and sig not in ["POTA", "SOTA", "WWFF", "SiOTA", "ZLOTA", "KRMNPA"]:
|
||||
if sig and sig not in ["POTA", "SOTA", "WWFF", "SiOTA", "ZLOTA", "KRMNPA", "LLOTA", "QRP"]:
|
||||
logging.warning("PNP alert found with sig " + sig + ", developer needs to add support for this!")
|
||||
|
||||
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. Otherwise, add to
|
||||
|
||||
@@ -14,7 +14,7 @@ class POTA(HTTPAlertProvider):
|
||||
ALERTS_URL = "https://api.pota.app/activation"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("POTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_alerts(self, http_response):
|
||||
new_alerts = []
|
||||
|
||||
@@ -14,7 +14,7 @@ class SOTA(HTTPAlertProvider):
|
||||
ALERTS_URL = "https://api-db2.sota.org.uk/api/alerts/365/all/all"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("SOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_alerts(self, http_response):
|
||||
new_alerts = []
|
||||
|
||||
@@ -18,7 +18,7 @@ class WOTA(HTTPAlertProvider):
|
||||
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("WOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_alerts(self, http_response):
|
||||
new_alerts = []
|
||||
|
||||
@@ -14,7 +14,7 @@ class WWFF(HTTPAlertProvider):
|
||||
ALERTS_URL = "https://spots.wwff.co/static/agendas.json"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("WWFF", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_alerts(self, http_response):
|
||||
new_alerts = []
|
||||
|
||||
@@ -19,66 +19,55 @@ base-url: "http://localhost:8080"
|
||||
|
||||
# Spot providers to use. This is an example set, tailor it to your liking by commenting and uncommenting.
|
||||
# RBN and APRS-IS are supported but have such a high data rate, you probably don't want them enabled.
|
||||
# Each provider needs a class, a name, and an enabled/disabled state. Some require more config such as hostnames/IP
|
||||
# Each provider needs a class and an enabled/disabled state. Some require more config such as hostnames/IP
|
||||
# addresses and ports. You can duplicate them if you like, e.g. to support several DX clusters. RBN uses two ports, 7000
|
||||
# for CW/RTTY and 7001 for FT8, so if you want both, you need two entries, as shown below.
|
||||
# Feel free to write your own provider classes! There are details in the README.
|
||||
spot-providers:
|
||||
- class: "POTA"
|
||||
name: "POTA"
|
||||
enabled: true
|
||||
|
||||
- class: "SOTA"
|
||||
name: "SOTA"
|
||||
enabled: true
|
||||
|
||||
- class: "WWFF"
|
||||
name: "WWFF"
|
||||
enabled: true
|
||||
|
||||
- class: "WWBOTA"
|
||||
name: "WWBOTA"
|
||||
enabled: true
|
||||
|
||||
- class: "GMA"
|
||||
name: "GMA"
|
||||
enabled: true
|
||||
# GMA requires an API key to fetch spots. After creating an account on cqgma.org, email support and request one.
|
||||
api-key: ""
|
||||
|
||||
- class: "HEMA"
|
||||
name: "HEMA"
|
||||
enabled: true
|
||||
|
||||
- class: "ParksNPeaks"
|
||||
name: "ParksNPeaks"
|
||||
enabled: true
|
||||
|
||||
- class: "ZLOTA"
|
||||
name: "ZLOTA"
|
||||
enabled: true
|
||||
|
||||
- class: "WOTA"
|
||||
name: "WOTA"
|
||||
enabled: true
|
||||
|
||||
- class: "LLOTA"
|
||||
name: "LLOTA"
|
||||
enabled: true
|
||||
|
||||
- class: "WWTOTA"
|
||||
name: "WWTOTA"
|
||||
- class: "Towers"
|
||||
enabled: true
|
||||
|
||||
- class: "Tiles"
|
||||
name: "Tiles"
|
||||
enabled: true
|
||||
|
||||
- class: "APRSIS"
|
||||
name: "APRS-IS"
|
||||
enabled: false
|
||||
|
||||
- class: "DXCluster"
|
||||
# Clusters have a "name" property in case you want to say which cluster node the data is from, or connect to
|
||||
# several clusters and name them separately.
|
||||
name: "HRD Cluster"
|
||||
enabled: true
|
||||
host: "hrd.wa9pie.net"
|
||||
@@ -99,18 +88,12 @@ spot-providers:
|
||||
enabled: false
|
||||
host: "w3lpl.net"
|
||||
port: 7373
|
||||
# Prompt the cluster node gives when asking for a callsign to log in. Varies between cluster node software.
|
||||
login_prompt: "Please enter your call:"
|
||||
# Callsign Spothole will use to log into this cluster. Ensure the SSID (e.g. -99) is different to any personal
|
||||
# connection you might make to this cluster node.
|
||||
login_callsign: "N0CALL-99"
|
||||
# Whether to allow RBN spots that come via this cluster. If you don't want RBN spots or you are making a separate
|
||||
# connection to RBN directly, leave this as False. If you want RBN spots from this cluster, set this to True. (Make
|
||||
# sure you aren't also separately connecting to RBN directly, otherwise you may get duplicate spots.) Note that not
|
||||
# all clusters sent RBN spots anyway.
|
||||
allow_rbn_spots: false
|
||||
|
||||
- class: "RBN"
|
||||
- # RBN sources have a "name" property in case you want to differentiate between the CW/RTTY and FT8 sources.
|
||||
name: "RBN CW/RTTY"
|
||||
enabled: false
|
||||
port: 7000
|
||||
@@ -127,11 +110,12 @@ spot-providers:
|
||||
enabled-by-default-in-web-ui: false
|
||||
|
||||
- class: "UKPacketNet"
|
||||
name: "UK Packet Radio Net"
|
||||
enabled: false
|
||||
enabled-by-default-in-web-ui: false
|
||||
|
||||
- class: "XOTA"
|
||||
# xOTA sources have a "name" property so you can define what programme it is for, since xOTA is generic software
|
||||
# for running "something-on-the-air" programmes.
|
||||
name: "39C3 TOTA"
|
||||
enabled: false
|
||||
url: "wss://39c3.totawatch.de/api/spot/live"
|
||||
@@ -152,31 +136,24 @@ spot-providers:
|
||||
# Alert providers to use. Same setup as the spot providers list above.
|
||||
alert-providers:
|
||||
- class: "POTA"
|
||||
name: "POTA"
|
||||
enabled: true
|
||||
|
||||
- class: "SOTA"
|
||||
name: "SOTA"
|
||||
enabled: true
|
||||
|
||||
- class: "WWFF"
|
||||
name: "WWFF"
|
||||
enabled: true
|
||||
|
||||
- class: "ParksNPeaks"
|
||||
name: "ParksNPeaks"
|
||||
enabled: true
|
||||
|
||||
- class: "WOTA"
|
||||
name: "WOTA"
|
||||
enabled: true
|
||||
|
||||
- class: "BOTA"
|
||||
name: "BOTA"
|
||||
enabled: true
|
||||
|
||||
- class: "NG3K"
|
||||
name: "NG3K"
|
||||
enabled: true
|
||||
|
||||
|
||||
@@ -184,19 +161,15 @@ alert-providers:
|
||||
# conditions, etc.) and make it available via the /api/v1/solar endpoint.
|
||||
solar-condition-providers:
|
||||
- class: "HamQSL"
|
||||
name: "HamQSL"
|
||||
enabled: true
|
||||
|
||||
- class: "NOAA3dayForecast"
|
||||
name: "NOAA 3-day Forecast"
|
||||
enabled: true
|
||||
|
||||
- class: "GIROIonosonde"
|
||||
name: "GIRO Ionosonde Data"
|
||||
enabled: true
|
||||
|
||||
- class: "KC2GProp"
|
||||
name: "KC2G Propagation Data"
|
||||
enabled: true
|
||||
|
||||
# Maximum time to keep spots and alerts in the system before deleting them. By default, one hour for spots and one week
|
||||
@@ -211,6 +184,14 @@ clublog-api-key: ""
|
||||
# Allow submitting spots to the Spothole API?
|
||||
allow-spotting: true
|
||||
|
||||
# Log web requests? Useful to see what your users are requesting in terms of pages and API endpoints, but will fill up
|
||||
# your log quickly on a popular server.
|
||||
log-web-requests: false
|
||||
|
||||
# Minimum severity of log statements to print to the system log. Defaults to INFO, change to DEBUG if you need more
|
||||
# information.
|
||||
log-level: INFO
|
||||
|
||||
# Options for the web UI.
|
||||
web-ui-options:
|
||||
spot-count: [ 10, 25, 50, 100 ]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import threading
|
||||
from datetime import timedelta
|
||||
|
||||
from requests_cache import CachedSession
|
||||
@@ -5,6 +6,22 @@ from requests_cache import CachedSession
|
||||
# Cache for "semi-static" data such as the locations of parks, CSVs of reference lists, etc.
|
||||
# This has an expiry time of 30 days, so will re-request from the source after that amount
|
||||
# of time has passed. This is used throughout Spothole to cache data that does not change
|
||||
# rapidly.
|
||||
SEMI_STATIC_URL_DATA_CACHE = CachedSession("cache/semi_static_url_data_cache",
|
||||
expire_after=timedelta(days=30))
|
||||
# rapidly. The ThreadSafeSession construct here protects it against some multithreading
|
||||
# contention weirdness we sometimes used to see on startup where the cache was hammered
|
||||
# pretty hard. The expanded list of allowable_codes ensures we also cache and return 400-type
|
||||
# responses, e.g "this SOTA summit ref doesn't actually exist", to avoid hammering remote
|
||||
# servers for data they've told us they can't provide.
|
||||
_session = CachedSession("cache/semi_static_url_data_cache", expire_after=timedelta(days=30),
|
||||
allowable_codes=(200, 400, 401, 403, 404))
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
class _ThreadSafeSession:
|
||||
"""Wraps CachedSession with a lock to prevent concurrent SQLite access across threads."""
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
with _lock:
|
||||
return _session.get(*args, **kwargs)
|
||||
|
||||
|
||||
SEMI_STATIC_URL_DATA_CACHE = _ThreadSafeSession()
|
||||
|
||||
@@ -22,6 +22,8 @@ WEB_SERVER_PORT = config["web-server-port"]
|
||||
ALLOW_SPOTTING = config["allow-spotting"]
|
||||
WEB_UI_OPTIONS = config["web-ui-options"]
|
||||
API_ONLY_MODE = config.get("api-only-mode", False)
|
||||
LOG_LEVEL = config.get("log-level", "INFO")
|
||||
LOG_WEB_REQUESTS = config.get("log-web-requests", False)
|
||||
|
||||
# For ease of config, each spot provider owns its own config about whether it should be enabled by default in the web UI
|
||||
# but for consistency we provide this to the front-end in web-ui-options because it has no impact outside of the web UI.
|
||||
|
||||
@@ -3,7 +3,7 @@ from data.band import Band
|
||||
from data.sig import SIG
|
||||
|
||||
# General software
|
||||
SOFTWARE_VERSION = "1.4-pre"
|
||||
SOFTWARE_VERSION = "1.5"
|
||||
|
||||
# HTTP headers used for spot providers that use HTTP
|
||||
HTTP_HEADERS = {"User-Agent": "Spothole v" + SOFTWARE_VERSION + " (operated by " + SERVER_OWNER_CALLSIGN + ")"}
|
||||
@@ -11,28 +11,29 @@ HAMQTH_PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_C
|
||||
|
||||
# Special Interest Groups
|
||||
SIGS = [
|
||||
SIG(name="POTA", description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}"),
|
||||
SIG(name="SOTA", description="Summits on the Air", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
|
||||
SIG(name="WWFF", description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"),
|
||||
SIG(name="GMA", description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
|
||||
SIG(name="WWBOTA", description="Worldwide Bunkers on the Air", ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}"),
|
||||
SIG(name="HEMA", description="HuMPs Excluding Marilyns Award", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}"),
|
||||
SIG(name="IOTA", description="Islands on the Air", ref_regex=r"[A-Z]{2}\-\d{3}"),
|
||||
SIG(name="MOTA", description="Mills on the Air", ref_regex=r"X\d{4,6}"),
|
||||
SIG(name="ARLHS", description="Amateur Radio Lighthouse Society", ref_regex=r"[A-Z]{3}\-\d{3,4}"),
|
||||
SIG(name="ILLW", description="International Lighthouse & Lightship Weekend", ref_regex=r"[A-Z]{2}\d{4}"),
|
||||
SIG(name="SIOTA", description="Silos on the Air", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"),
|
||||
SIG(name="WCA", description="World Castles Award", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"),
|
||||
SIG(name="ZLOTA", description="New Zealand on the Air", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}"),
|
||||
SIG(name="WOTA", description="Wainwrights on the Air", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
|
||||
SIG(name="BOTA", description="Beaches on the Air"),
|
||||
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award"),
|
||||
SIG(name="LLOTA", description="Lagos y Lagunas on the Air", ref_regex=r"[A-Z]{2}\-\d{4}"),
|
||||
SIG(name="WWTOTA", description="Towers on the Air", ref_regex=r"[A-Z]{2}R\-\d{4}"),
|
||||
SIG(name="Tiles", description="Tiles on the Air", ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}"),
|
||||
SIG(name="WAB", description="Worked All Britain", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
|
||||
SIG(name="WAI", description="Worked All Ireland", ref_regex=r"[A-Z][0-9]{2}"),
|
||||
SIG(name="TOTA", description="Toilets on the Air", ref_regex=r"T\-[0-9]{2}")
|
||||
SIG(name="POTA", comment_names=["POTA"], description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}"),
|
||||
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="GMA", comment_names=["GMA"], description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
|
||||
SIG(name="WWBOTA", comment_names=["WWBOTA", "BOTA"], description="Worldwide Bunkers on the Air", ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}"),
|
||||
SIG(name="HEMA", comment_names=["HEMA"], description="HuMPs Excluding Marilyns Award", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}"),
|
||||
SIG(name="IOTA", comment_names=["IOTA"], description="Islands on the Air", ref_regex=r"[A-Z]{2}\-\d{3}"),
|
||||
SIG(name="MOTA", comment_names=["MOTA"], description="Mills on the Air", ref_regex=r"X\d{4,6}"),
|
||||
SIG(name="ARLHS", comment_names=["ARLHS"], description="Amateur Radio Lighthouse Society", ref_regex=r"[A-Z]{3}\-\d{3,4}"),
|
||||
SIG(name="ILLW", comment_names=["ILLW"], description="International Lighthouse & Lightship Weekend", ref_regex=r"[A-Z]{2}\d{4}"),
|
||||
SIG(name="SiOTA", comment_names=["SIOTA"], description="Silos on the Air", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"),
|
||||
SIG(name="WCA", comment_names=["WCA"], description="World Castles Award", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"),
|
||||
SIG(name="ZLOTA", comment_names=["ZLOTA"], description="New Zealand on the Air", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}"),
|
||||
SIG(name="WOTA", comment_names=["WOTA"], description="Wainwrights on the Air", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
|
||||
SIG(name="BOTA", comment_names=[], description="Beaches on the Air"),
|
||||
SIG(name="KRMNPA", comment_names=["KRMNPA"], description="Keith Roget Memorial National Parks Award"),
|
||||
SIG(name="LLOTA", comment_names=["LLOTA"], description="Lagos y Lagunas on the Air", ref_regex=r"LL[A-Z]{2}\-\d{4}"),
|
||||
SIG(name="Towers", comment_names=["TOTA"], description="Towers on the Air", ref_regex=r"[A-Z]{2,3}R\-\d{4}"),
|
||||
SIG(name="Tiles", comment_names=[], description="Tiles on the Air", ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}"),
|
||||
SIG(name="WAB", comment_names=["WAB"], description="Worked All Britain", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
|
||||
SIG(name="WAI", comment_names=["WAI"], description="Worked All Ireland", ref_regex=r"[A-Z][0-9]{2}"),
|
||||
SIG(name="DME", comment_names=["DME"], description="Diplomas de Municipios Españoles", ref_regex=r"\d{4,5}"),
|
||||
SIG(name="Toilets", comment_names=[], description="Toilets on the Air", ref_regex=r"T\-[0-9]{2}")
|
||||
]
|
||||
|
||||
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
|
||||
@@ -101,5 +102,6 @@ PROPAGATION_MODES = {
|
||||
"AU": "Aurora",
|
||||
"MS": "Meteor scatter",
|
||||
"RS": "Rain scatter",
|
||||
"AS": "Aircraft scatter"
|
||||
"AS": "Aircraft scatter",
|
||||
"SAT": "Satellite"
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import re
|
||||
import urllib.parse
|
||||
from datetime import timedelta
|
||||
|
||||
import requests
|
||||
import xmltodict
|
||||
from diskcache import Cache
|
||||
from pyhamtools import LookupLib, Callinfo, callinfo
|
||||
from pyhamtools.exceptions import APIKeyMissingError
|
||||
from pyhamtools.frequency import freq_to_band
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||
from requests_cache import CachedSession
|
||||
|
||||
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
|
||||
@@ -143,13 +143,21 @@ class LookupHelper:
|
||||
try:
|
||||
logging.info("Downloading Country-files.com cty.plist...")
|
||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.country-files.com/cty/cty.plist",
|
||||
headers=HTTP_HEADERS).text
|
||||
headers=HTTP_HEADERS)
|
||||
|
||||
if response.ok:
|
||||
with open(self._country_files_cty_plist_download_location, "w") as f:
|
||||
f.write(response)
|
||||
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
|
||||
@@ -161,13 +169,21 @@ class LookupHelper:
|
||||
logging.info("Downloading dxcc.json...")
|
||||
response = SEMI_STATIC_URL_DATA_CACHE.get(
|
||||
"https://raw.githubusercontent.com/k0swe/dxcc-json/refs/heads/main/dxcc.json",
|
||||
headers=HTTP_HEADERS).text
|
||||
headers=HTTP_HEADERS)
|
||||
|
||||
if response.ok:
|
||||
with open(self._dxcc_json_download_location, "w") as f:
|
||||
f.write(response)
|
||||
f.write(response.text)
|
||||
f.flush()
|
||||
return True
|
||||
else:
|
||||
logging.warning(f"HTTP {response.status_code} when downloading dxcc.json.")
|
||||
return False
|
||||
|
||||
except ConnectionError:
|
||||
logging.warning(f"Connection error when downloading dxcc.json.")
|
||||
except (ConnectTimeout, ReadTimeout):
|
||||
logging.warning(f"Timeout when downloading dxcc.json.")
|
||||
except Exception as e:
|
||||
logging.error("Exception when downloading dxcc.json", e)
|
||||
return False
|
||||
@@ -490,25 +506,47 @@ class LookupHelper:
|
||||
|
||||
# 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:
|
||||
lookup_response = requests.get(
|
||||
response = SEMI_STATIC_URL_DATA_CACHE.get(
|
||||
self._qrz_base_url + "?s=" + session_key + "&callsign=" + urllib.parse.quote_plus(lookup_call),
|
||||
headers=HTTP_HEADERS, timeout=10).content
|
||||
raw = xmltodict.parse(lookup_response).get("QRZDatabase", {}).get("Callsign")
|
||||
if raw:
|
||||
data = _normalize_qrz_data(raw)
|
||||
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 QRZ data")
|
||||
return None
|
||||
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
|
||||
@@ -546,23 +584,36 @@ class LookupHelper:
|
||||
|
||||
# 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:
|
||||
lookup_data = SEMI_STATIC_URL_DATA_CACHE.get(
|
||||
response = SEMI_STATIC_URL_DATA_CACHE.get(
|
||||
self._hamqth_base_url + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
|
||||
lookup_call) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS).content
|
||||
data = xmltodict.parse(lookup_data)["HamQTH"]["search"]
|
||||
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 HamQTH data")
|
||||
return None
|
||||
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
|
||||
|
||||
@@ -2,11 +2,25 @@ import csv
|
||||
import logging
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
|
||||
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||
|
||||
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
|
||||
from core.constants import SIGS, HTTP_HEADERS
|
||||
from core.geo_utils import wab_wai_square_to_lat_lon
|
||||
|
||||
# Load Spanish municipality data for the DME programme. There's no convenient lookup API for this, so we embed the data
|
||||
# file in Spothole and load it on startup.
|
||||
with open("datafiles/MUNICIPIOS.csv", encoding="latin-1") as _f:
|
||||
_DME_INDEX = {row["COD_INE"][:5]: row for row in csv.DictReader(_f, delimiter=";")}
|
||||
# Caches for data for the SIGs where we have to download a whole global reference list, rather than looking up a single
|
||||
# reference. These get populated from the SEMI_STATIC_URL_DATA_CACHE only if the data actually came
|
||||
# live from the internet, to avoid repopulating them every time we pull the same data from the cache.
|
||||
_WWFF_INDEX_CACHE = {}
|
||||
_SIOTA_INDEX_CACHE = {}
|
||||
_WOTA_INDEX_CACHE = {}
|
||||
_ZLOTA_INDEX_CACHE = {}
|
||||
_LLOTA_INDEX_CACHE = {}
|
||||
|
||||
|
||||
def get_ref_regex_for_sig(sig):
|
||||
"""Utility function to get the regex string for a SIG reference for a named SIG. If no match is found, None will be returned."""
|
||||
@@ -17,6 +31,16 @@ def get_ref_regex_for_sig(sig):
|
||||
return None
|
||||
|
||||
|
||||
def get_sig_name_from_comment_name(sig):
|
||||
"""Utility function to get the name of a SIG from its "comment name". Generally these will be the same but there are
|
||||
some cases (e.g. is "TOTA" Towers, Tiles or Toilets?) where we need to transform one to the other."""
|
||||
|
||||
for s in SIGS:
|
||||
if any(n.upper() == sig.upper() for n in s.comment_names):
|
||||
return s.name
|
||||
return None
|
||||
|
||||
|
||||
def populate_sig_ref_info(sig_ref):
|
||||
"""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.
|
||||
@@ -29,7 +53,9 @@ def populate_sig_ref_info(sig_ref):
|
||||
ref_id = sig_ref.id
|
||||
try:
|
||||
if sig.upper() == "POTA":
|
||||
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api.pota.app/park/" + ref_id, headers=HTTP_HEADERS).json()
|
||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://api.pota.app/park/" + ref_id, headers=HTTP_HEADERS)
|
||||
if response.ok:
|
||||
data = response.json()
|
||||
if data:
|
||||
fullname = str(data["name"]) if "name" in data else None
|
||||
if fullname and "parktypeDesc" in data and data["parktypeDesc"] != "":
|
||||
@@ -39,9 +65,16 @@ def populate_sig_ref_info(sig_ref):
|
||||
sig_ref.grid = data["grid6"] if "grid6" in data else None
|
||||
sig_ref.latitude = data["latitude"] if "latitude" in data else None
|
||||
sig_ref.longitude = data["longitude"] if "longitude" in data else None
|
||||
elif not response.from_cache:
|
||||
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
|
||||
elif not response.from_cache:
|
||||
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
||||
|
||||
elif sig.upper() == "SOTA":
|
||||
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api-db2.sota.org.uk/api/summits/" + ref_id,
|
||||
headers=HTTP_HEADERS).json()
|
||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://api-db2.sota.org.uk/api/summits/" + ref_id,
|
||||
headers=HTTP_HEADERS)
|
||||
if response.ok:
|
||||
data = response.json()
|
||||
if data:
|
||||
sig_ref.name = data["name"] if "name" in data else None
|
||||
sig_ref.url = "https://www.sotadata.org.uk/en/summit/" + ref_id
|
||||
@@ -49,52 +82,108 @@ def populate_sig_ref_info(sig_ref):
|
||||
sig_ref.latitude = data["latitude"] if "latitude" in data else None
|
||||
sig_ref.longitude = data["longitude"] if "longitude" in data else None
|
||||
sig_ref.activation_score = data["points"] if "points" in data else None
|
||||
elif not response.from_cache:
|
||||
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
|
||||
elif not response.from_cache:
|
||||
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
||||
|
||||
elif sig.upper() == "WWBOTA":
|
||||
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + ref_id,
|
||||
headers=HTTP_HEADERS).json()
|
||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + ref_id,
|
||||
headers=HTTP_HEADERS)
|
||||
if response.ok:
|
||||
data = response.json()
|
||||
if data:
|
||||
sig_ref.name = data["name"] if "name" in data else None
|
||||
sig_ref.url = "https://bunkerwiki.org/?s=" + ref_id if ref_id.startswith("B/G") else None
|
||||
sig_ref.grid = data["locator"] if "locator" in data else None
|
||||
sig_ref.latitude = data["lat"] if "lat" in data else None
|
||||
sig_ref.longitude = data["long"] if "long" in data else None
|
||||
elif not response.from_cache:
|
||||
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
|
||||
elif not response.from_cache:
|
||||
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
||||
|
||||
elif sig.upper() == "GMA" or sig.upper() == "ARLHS" or sig.upper() == "ILLW" or sig.upper() == "WCA" or sig.upper() == "MOTA" or sig.upper() == "IOTA":
|
||||
data = SEMI_STATIC_URL_DATA_CACHE.get("https://www.cqgma.org/api/ref/?" + ref_id,
|
||||
headers=HTTP_HEADERS).json()
|
||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.cqgma.org/api/ref/?" + ref_id,
|
||||
headers=HTTP_HEADERS)
|
||||
if response.ok:
|
||||
data = response.json()
|
||||
if data:
|
||||
sig_ref.name = data["name"] if "name" in data else None
|
||||
sig_ref.url = "https://www.cqgma.org/zinfo.php?ref=" + ref_id
|
||||
sig_ref.grid = data["locator"] if "locator" in data else None
|
||||
sig_ref.latitude = data["latitude"] if "latitude" in data else None
|
||||
sig_ref.longitude = data["longitude"] if "longitude" in data else None
|
||||
|
||||
# For some things (just IOTA?) the GMA actually returns a box where "latitude" and "longitude" are
|
||||
# the zeroest corner of the box, then "lat2" and "lng2" provide the other corner. We detect this
|
||||
# and provide a single lat/lon for the centre. Otherwise if we don't have these extra parameters,
|
||||
# just use the single point we have.
|
||||
if data.get("latitude") is not None and data.get("longitude") is not None and data.get(
|
||||
"lat2") is not None and data.get("lng2") is not None:
|
||||
sig_ref.latitude = (float(data["latitude"]) + float(data["lat2"])) / 2.0
|
||||
sig_ref.longitude = (float(data["longitude"]) + float(data["lng2"])) / 2.0
|
||||
else:
|
||||
sig_ref.latitude = float(data["latitude"]) if data.get("latitude") is not None else None
|
||||
sig_ref.longitude = float(data["longitude"]) if data.get("longitude") is not None else None
|
||||
elif not response.from_cache:
|
||||
logging.warning("Malformed response looking up %s ref %s via GMA", sig, ref_id)
|
||||
elif not response.from_cache:
|
||||
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
||||
|
||||
elif sig.upper() == "WWFF":
|
||||
wwff_csv_data = SEMI_STATIC_URL_DATA_CACHE.get("https://wwff.co/wwff-data/wwff_directory.csv",
|
||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://wwff.co/wwff-data/wwff_directory.csv",
|
||||
headers=HTTP_HEADERS)
|
||||
wwff_index = {row["reference"]: row for row in csv.DictReader(wwff_csv_data.content.decode().splitlines())}
|
||||
row = wwff_index.get(ref_id)
|
||||
if response.ok:
|
||||
global _WWFF_INDEX_CACHE
|
||||
if not bool(_WWFF_INDEX_CACHE) or not response.from_cache:
|
||||
# New data from WWFF, update our internal map
|
||||
_WWFF_INDEX_CACHE = {row["reference"]: row for row in
|
||||
csv.DictReader(response.content.decode().splitlines())}
|
||||
row = _WWFF_INDEX_CACHE.get(ref_id)
|
||||
if row:
|
||||
sig_ref.name = row["name"] if "name" in row else None
|
||||
sig_ref.url = "https://wwff.co/directory/?showRef=" + ref_id
|
||||
sig_ref.grid = row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None
|
||||
sig_ref.latitude = float(row["latitude"]) if "latitude" in row and row["latitude"] != "-" else None
|
||||
sig_ref.longitude = float(row["longitude"]) if "longitude" in row and row["longitude"] != "-" else None
|
||||
sig_ref.longitude = float(row["longitude"]) if "longitude" in row and row[
|
||||
"longitude"] != "-" else None
|
||||
elif not response.from_cache:
|
||||
logging.warning("WWFF database did not contain data for ref %s", ref_id)
|
||||
elif not response.from_cache:
|
||||
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
||||
|
||||
elif sig.upper() == "SIOTA":
|
||||
siota_csv_data = SEMI_STATIC_URL_DATA_CACHE.get("https://www.silosontheair.com/data/silos.csv",
|
||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.silosontheair.com/data/silos.csv",
|
||||
headers=HTTP_HEADERS)
|
||||
siota_index = {row["SILO_CODE"]: row for row in
|
||||
csv.DictReader(siota_csv_data.content.decode().splitlines())}
|
||||
row = siota_index.get(ref_id)
|
||||
if response.ok:
|
||||
global _SIOTA_INDEX_CACHE
|
||||
if not bool(_SIOTA_INDEX_CACHE) or not response.from_cache:
|
||||
# New data from SIOTA, update our internal map
|
||||
_SIOTA_INDEX_CACHE = {row["SILO_CODE"]: row for row in
|
||||
csv.DictReader(response.content.decode().splitlines())}
|
||||
row = _SIOTA_INDEX_CACHE.get(ref_id)
|
||||
if row:
|
||||
sig_ref.name = row["NAME"] if "NAME" in row else None
|
||||
sig_ref.grid = row["LOCATOR"] if "LOCATOR" in row else None
|
||||
sig_ref.latitude = float(row["LAT"]) if "LAT" in row else None
|
||||
sig_ref.longitude = float(row["LNG"]) if "LNG" in row else None
|
||||
elif not response.from_cache:
|
||||
logging.warning("SIOTA database did not contain data for ref %s", ref_id)
|
||||
elif not response.from_cache:
|
||||
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
||||
|
||||
elif sig.upper() == "WOTA":
|
||||
data = SEMI_STATIC_URL_DATA_CACHE.get("https://www.wota.org.uk/mapping/data/summits.json",
|
||||
headers=HTTP_HEADERS).json()
|
||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.wota.org.uk/mapping/data/summits.json",
|
||||
headers=HTTP_HEADERS)
|
||||
if response.ok:
|
||||
data = response.json()
|
||||
if data:
|
||||
for feature in data["features"]:
|
||||
if feature["properties"]["wotaId"] == ref_id:
|
||||
global _WOTA_INDEX_CACHE
|
||||
if not bool(_WOTA_INDEX_CACHE) or not response.from_cache:
|
||||
# New data from WOTA, update our internal map
|
||||
_WOTA_INDEX_CACHE = {feature["properties"]["wotaId"]: feature for feature in
|
||||
data.get("features", [])}
|
||||
feature = _WOTA_INDEX_CACHE.get(ref_id)
|
||||
if feature:
|
||||
sig_ref.name = feature["properties"]["title"]
|
||||
# Fudge WOTA URLs. Outlying fell (LDO) URLs don't match their ID numbers but require 214 to be
|
||||
# added to them
|
||||
@@ -105,42 +194,68 @@ def populate_sig_ref_info(sig_ref):
|
||||
sig_ref.grid = feature["properties"]["qthLocator"]
|
||||
sig_ref.latitude = feature["geometry"]["coordinates"][1]
|
||||
sig_ref.longitude = feature["geometry"]["coordinates"][0]
|
||||
break
|
||||
elif not response.from_cache:
|
||||
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
|
||||
elif not response.from_cache:
|
||||
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
||||
|
||||
elif sig.upper() == "ZLOTA":
|
||||
data = SEMI_STATIC_URL_DATA_CACHE.get("https://ontheair.nz/assets/assets.json", headers=HTTP_HEADERS).json()
|
||||
if data:
|
||||
for asset in data:
|
||||
if asset["code"] == ref_id:
|
||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://ontheair.nz/assets/assets.json", headers=HTTP_HEADERS)
|
||||
if response.ok:
|
||||
data = response.json()
|
||||
if isinstance(data, list):
|
||||
global _ZLOTA_INDEX_CACHE
|
||||
if not bool(_ZLOTA_INDEX_CACHE) or not response.from_cache:
|
||||
# New data from ZLOTA, update our internal map
|
||||
_ZLOTA_INDEX_CACHE = {asset["code"]: asset for asset in data}
|
||||
asset = _ZLOTA_INDEX_CACHE.get(ref_id)
|
||||
if asset:
|
||||
sig_ref.name = asset["name"]
|
||||
sig_ref.url = "https://ontheair.nz/assets/ZLI_OT-030" + ref_id.replace("/", "_")
|
||||
sig_ref.url = "https://ontheair.nz/assets/" + ref_id.replace("/", "_")
|
||||
try:
|
||||
sig_ref.grid = latlong_to_locator(asset["y"], asset["x"], 6)
|
||||
except:
|
||||
logging.debug("Invalid lat/lon received for reference")
|
||||
sig_ref.latitude = asset["y"]
|
||||
sig_ref.longitude = asset["x"]
|
||||
break
|
||||
elif not response.from_cache:
|
||||
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
|
||||
elif not response.from_cache:
|
||||
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
||||
|
||||
elif sig.upper() == "BOTA":
|
||||
if not sig_ref.name:
|
||||
sig_ref.name = sig_ref.id
|
||||
sig_ref.url = "https://www.beachesontheair.com/beaches/" + sig_ref.name.lower().replace(" ", "-")
|
||||
|
||||
elif sig.upper() == "LLOTA":
|
||||
data = SEMI_STATIC_URL_DATA_CACHE.get("https://llota.app/api/public/references",
|
||||
headers=HTTP_HEADERS).json()
|
||||
if data:
|
||||
for ref in data:
|
||||
if ref["reference_code"] == ref_id:
|
||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://llota.app/api/public/references",
|
||||
headers=HTTP_HEADERS)
|
||||
if response.ok:
|
||||
data = response.json()
|
||||
if isinstance(data, list):
|
||||
global _LLOTA_INDEX_CACHE
|
||||
if not bool(_LLOTA_INDEX_CACHE) or not response.from_cache:
|
||||
# New data from LLOTA, update our internal map
|
||||
_LLOTA_INDEX_CACHE = {ref["reference_code"]: ref for ref in data}
|
||||
ref = _LLOTA_INDEX_CACHE.get(ref_id)
|
||||
if ref:
|
||||
sig_ref.name = str(ref["name"])
|
||||
sig_ref.url = "https://llota.app/list/ref/" + ref_id
|
||||
sig_ref.grid = str(ref["grid_locator"])
|
||||
ll = locator_to_latlong(sig_ref.grid)
|
||||
sig_ref.latitude = ll[0]
|
||||
sig_ref.longitude = ll[1]
|
||||
break
|
||||
elif not response.from_cache:
|
||||
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
|
||||
elif not response.from_cache:
|
||||
logging.warning("HTTP %d looking up %s ref %s", response.status_code, sig, ref_id)
|
||||
|
||||
elif sig.upper() == "WWTOTA":
|
||||
if not sig_ref.name:
|
||||
sig_ref.name = sig_ref.id
|
||||
sig_ref.url = "https://wwtota.com/seznam/karta_rozhledny.php?ref=" + str(sig_ref.name)
|
||||
|
||||
elif sig.upper() == "TILES":
|
||||
# Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same
|
||||
if not sig_ref.name:
|
||||
@@ -151,6 +266,7 @@ def populate_sig_ref_info(sig_ref):
|
||||
ll = locator_to_latlong(str(sig_ref.grid))
|
||||
sig_ref.latitude = ll[0]
|
||||
sig_ref.longitude = ll[1]
|
||||
|
||||
elif sig.upper() == "WAB" or sig.upper() == "WAI":
|
||||
ll = wab_wai_square_to_lat_lon(ref_id)
|
||||
if ll:
|
||||
@@ -160,14 +276,33 @@ def populate_sig_ref_info(sig_ref):
|
||||
sig_ref.latitude = ll[0]
|
||||
sig_ref.longitude = ll[1]
|
||||
except:
|
||||
logging.debug("Invalid lat/lon received for reference")
|
||||
except:
|
||||
logging.warning("Failed to look up sig_ref info for " + sig + " ref " + ref_id + ".")
|
||||
logging.warning("Invalid lat/lon received for WAB/WAI reference")
|
||||
|
||||
elif sig.upper() == "DME":
|
||||
# Zero-pad to 5 digits to match our source data
|
||||
row = _DME_INDEX.get(ref_id.zfill(5))
|
||||
if row:
|
||||
sig_ref.name = row["NOMBRE_ACTUAL"] + ", " + row["PROVINCIA"]
|
||||
sig_ref.latitude = float(row["LATITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get(
|
||||
"LATITUD_ETRS89_REGCAN95") else None
|
||||
sig_ref.longitude = float(row["LONGITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get(
|
||||
"LONGITUD_ETRS89_REGCAN95") else None
|
||||
if sig_ref.latitude and sig_ref.longitude:
|
||||
try:
|
||||
sig_ref.grid = latlong_to_locator(sig_ref.latitude, sig_ref.longitude, 6)
|
||||
except Exception:
|
||||
logging.warning("Invalid lat/lon received for DME reference")
|
||||
else:
|
||||
logging.warning("DME database did not contain data for ref %s", ref_id)
|
||||
|
||||
except ConnectionError:
|
||||
logging.warning("Connection error when looking up sig_ref info for " + sig + " ref " + ref_id)
|
||||
except (ConnectTimeout, ReadTimeout):
|
||||
logging.warning(f"Timeout when looking up sig_ref info for " + sig + " ref " + ref_id)
|
||||
except Exception:
|
||||
logging.error("Exception when looking up sig_ref info for " + sig + " ref " + ref_id, exc_info=True)
|
||||
return sig_ref
|
||||
|
||||
|
||||
# Regex matching any SIG
|
||||
ANY_SIG_REGEX = r"(" + r"|".join(list(map(lambda p: p.name, SIGS))) + r")"
|
||||
|
||||
# Regex matching any SIG reference
|
||||
ANY_XOTA_SIG_REF_REGEX = r"[\w\/]+\-\d+"
|
||||
# Regex matching any SIG's "comment name", i.e. how it may be referred to in spot comments
|
||||
ANY_SIG_REGEX = r"(" + r"|".join(n for s in SIGS for n in s.comment_names) + r")"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
def serialize_everything(obj):
|
||||
"""Convert objects to serialisable things. Used by JSON serialiser as a default when it encounters unserializable things.
|
||||
Just converts objects to dict. Try to avoid doing anything clever here when serialising spots, because we also need
|
||||
to receive spots without complex handling."""
|
||||
return obj.__dict__
|
||||
import simplejson
|
||||
|
||||
|
||||
def safe_json_dumps(obj):
|
||||
"""Safe version of json.dumps that also converts objects to dicts so they can be output, and ignores NaN floats
|
||||
which are invalid in JSON."""
|
||||
|
||||
return simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, default=lambda o: o.__dict__)
|
||||
|
||||
|
||||
def empty_queue(q):
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class SIG:
|
||||
"""Data class that defines a Special Interest Group."""
|
||||
"""Data class that defines a Special Interest Group. Each contains a name and a longer form description.
|
||||
They also contain comment_names which attempts to separate out the way people might refer to it in
|
||||
cluster comments from how it is referred to in the UI & API. (For example, "TOTA" in cluster spot comments
|
||||
almost always means Towers on the Air, but no single programme is referred to in the UI as "TOTA" as
|
||||
it's ambiguous between Towers, Toilets and Tiles. And while Beaches got the name "BOTA" first, "BOTA" spots
|
||||
are much more likely to be bunkers.) Finally, there is a ref_regex which provides a regular expression to
|
||||
match what references (such as parks and summits) look like for that programme."""
|
||||
|
||||
# SIG name, e.g. "POTA"
|
||||
# SIG name as used in the UI and API, e.g. "Towers"
|
||||
name: str
|
||||
# Description, e.g. "Parks on the Air"
|
||||
# Description, e.g. "Towers on the Air"
|
||||
description: str
|
||||
# SIG names as they might appear in cluster spot comments, e.g. ["TOTA"]
|
||||
comment_names: list[str] = field(default_factory=list)
|
||||
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
|
||||
ref_regex: str = None
|
||||
ref_regex: str | None = None
|
||||
|
||||
@@ -14,7 +14,7 @@ from core.constants import MODE_ALIASES, PROPAGATION_MODES
|
||||
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
||||
from core.lookup_helper import lookup_helper, infer_band_from_freq, infer_mode_from_comment, \
|
||||
infer_mode_from_frequency, infer_mode_type_from_mode
|
||||
from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig
|
||||
from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig, get_sig_name_from_comment_name
|
||||
from data.sig_ref import SIGRef
|
||||
|
||||
|
||||
@@ -133,6 +133,17 @@ class Spot:
|
||||
# The ID the source gave it, if any.
|
||||
source_id: str | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Normalise fields that don't survive a plain dict to Spot conversion. This is used in the "add spot" API
|
||||
endpoint where the client is submitting JSON, and we want to recreate a full Spot object, including nested
|
||||
objects such as the sig_refs list.."""
|
||||
|
||||
if self.sig_refs:
|
||||
self.sig_refs = [
|
||||
sig_ref if isinstance(sig_ref, SIGRef) else SIGRef(**sig_ref)
|
||||
for sig_ref in self.sig_refs
|
||||
]
|
||||
|
||||
def infer_missing(self, credentials=None):
|
||||
"""Infer missing parameters where possible"""
|
||||
|
||||
@@ -255,7 +266,7 @@ class Spot:
|
||||
for sig_match in sig_matches:
|
||||
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster
|
||||
# spots where the comment is just "POTA".
|
||||
found_sig = sig_match.group(2).upper()
|
||||
found_sig = get_sig_name_from_comment_name(sig_match.group(2))
|
||||
if not self.sig:
|
||||
self.sig = found_sig
|
||||
|
||||
@@ -263,7 +274,7 @@ class Spot:
|
||||
# If so, add that to the sig_refs list for this spot.
|
||||
ref_regex = get_ref_regex_for_sig(found_sig)
|
||||
if ref_regex:
|
||||
ref_matches = re.finditer(r"(^|\W)" + found_sig + r"($|\W)(" + ref_regex + r")($|\W)", self.comment,
|
||||
ref_matches = re.finditer(r"(^|\W)" + found_sig + r"([ -])(" + ref_regex + r")($|\W)", self.comment,
|
||||
re.IGNORECASE)
|
||||
for ref_match in ref_matches:
|
||||
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(3).upper(), sig=found_sig))
|
||||
@@ -290,29 +301,42 @@ class Spot:
|
||||
if self.sig_refs and len(self.sig_refs) > 0 and not self.sig:
|
||||
self.sig = self.sig_refs[0].sig
|
||||
|
||||
# Parse "de_grid<prop_mode>dx_grid" structures from the comment, e.g. "JN61ES<ES>JM56XT" or "JO02GQ<>KN17LG".
|
||||
# Parse "de_grid<prop_mode>dx_grid" structures from the comment, e.g. "JN61ES(ES)JM56XT" or "JO02GQ<>KN17LG".
|
||||
# These are common on cluster spots and can provide grid references in preference to e.g. QRZ lookup, as well as
|
||||
# being the only source we have for propagation mode.
|
||||
# being the only source we have for propagation mode. Brace for nightmare regex from hell.
|
||||
if self.comment:
|
||||
grid_mode_grid_match = re.search(
|
||||
r'\b([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)<([^>]*)>([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\b',
|
||||
r'\b([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)(?:<([^>]*)>|\(([^)]*)\))([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.de_grid:
|
||||
self.de_grid = grid_mode_grid_match.group(1).upper()
|
||||
if not self.dx_grid:
|
||||
self.dx_grid = grid_mode_grid_match.group(3).upper()
|
||||
self.dx_grid = grid_mode_grid_match.group(4).upper()
|
||||
self.dx_location_source = "SPOT"
|
||||
|
||||
# And extract propagation mode:
|
||||
mode_tag = grid_mode_grid_match.group(2).upper()
|
||||
# And extract propagation mode (group 2 for <...>, group 3 for (...)):
|
||||
mode_tag = (grid_mode_grid_match.group(2) or grid_mode_grid_match.group(3) or "").upper()
|
||||
if mode_tag and not self.propagation_mode:
|
||||
if mode_tag in PROPAGATION_MODES:
|
||||
self.propagation_mode = PROPAGATION_MODES[mode_tag]
|
||||
else:
|
||||
self.propagation_mode = mode_tag
|
||||
logging.info("Seen a new propagation mode tag not yet in the system: {}", mode_tag)
|
||||
logging.info("Seen a new propagation mode tag not yet in the system: %s", mode_tag)
|
||||
|
||||
# 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
|
||||
if self.dx_grid and not self.dx_latitude:
|
||||
|
||||
@@ -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/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.)
|
||||
@@ -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/v1/(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,69 @@
|
||||
## 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 scripts
|
||||
* `/data` - Data storage classes
|
||||
* `/spotproviders` - Classes providing spots by accessing the APIs of other services
|
||||
* `/alertproviders` - Classes providing alerts by accessing the APIs of other services
|
||||
* `/solarconditionsproviders` - Classes providing solar and propagation by accessing the APIs of other services
|
||||
* `/server` - Classes for running Spothole's own web server
|
||||
|
||||
*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/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*
|
||||
|
||||
* `/` - Main script (`spothole.py`), pip `requirements.txt`, config, README, etc.
|
||||
* `/docs` - Documentation
|
||||
* `/images` - Image sources
|
||||
* `/datafiles` - Local data sources (differentiated from the majority of data files which are loaded from URLs and
|
||||
cached in `/cache`)
|
||||
* `/cache` - Directory where static-ish data downloaded from the internet is cached to avoid rapid re-requests, and
|
||||
where spot/alert data is cached so that it survives a software restart. 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 `spotproviders` 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 alert providers.
|
||||
@@ -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`
|
||||
@@ -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/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.
|
||||
@@ -0,0 +1,53 @@
|
||||
## 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!
|
||||
|
||||
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`.
|
||||
@@ -17,3 +17,4 @@ websocket-client~=1.8.0
|
||||
tornado~=6.4.2
|
||||
tornado_eventsource~=3.0.0
|
||||
geopandas~=0.13.2
|
||||
simplejson~=4.1.1
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
@@ -14,8 +13,7 @@ from core.constants import UNKNOWN_BAND
|
||||
from core.lookup_helper import infer_band_from_freq
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.sig_utils import get_ref_regex_for_sig
|
||||
from core.utils import serialize_everything
|
||||
from data.sig_ref import SIGRef
|
||||
from core.utils import safe_json_dumps
|
||||
from data.spot import Spot
|
||||
|
||||
|
||||
@@ -42,8 +40,7 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
# Reject if not allowed
|
||||
if not ALLOW_SPOTTING:
|
||||
self.set_status(401)
|
||||
self.write(json.dumps("Error - this server does not allow new spots to be added via the API.",
|
||||
default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - this server does not allow new spots to be added via the API."))
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
@@ -51,8 +48,7 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
# Reject if format not json
|
||||
if not self.request.headers.get('Content-Type', '').startswith("application/json"):
|
||||
self.set_status(415)
|
||||
self.write(
|
||||
json.dumps("Error - request Content-Type must be application/json", default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - request Content-Type must be application/json"))
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
@@ -61,7 +57,7 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
post_data = self.request.body
|
||||
if not post_data:
|
||||
self.set_status(422)
|
||||
self.write(json.dumps("Error - request body is empty", default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - request body is empty"))
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
@@ -70,19 +66,10 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
json_spot = tornado.escape.json_decode(post_data)
|
||||
spot = Spot(**json_spot)
|
||||
|
||||
# Converting to a spot object this way won't have coped with sig_ref objects, so fix that. (Would be nice to
|
||||
# redo this in a functional style)
|
||||
if spot.sig_refs:
|
||||
real_sig_refs = []
|
||||
for dict_obj in spot.sig_refs:
|
||||
real_sig_refs.append(json.loads(json.dumps(dict_obj), object_hook=lambda d: SIGRef(**d)))
|
||||
spot.sig_refs = real_sig_refs
|
||||
|
||||
# Reject if no timestamp, frequency, dx_call or de_call
|
||||
if not spot.time or not spot.dx_call or not spot.freq or not spot.de_call:
|
||||
self.set_status(422)
|
||||
self.write(json.dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum.",
|
||||
default=serialize_everything))
|
||||
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("Content-Type", "application/json")
|
||||
return
|
||||
@@ -90,15 +77,13 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
# Reject invalid-looking callsigns
|
||||
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call):
|
||||
self.set_status(422)
|
||||
self.write(json.dumps("Error - '" + spot.dx_call + "' does not look like a valid callsign.",
|
||||
default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - '" + spot.dx_call + "' does not look like a valid callsign."))
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call):
|
||||
self.set_status(422)
|
||||
self.write(json.dumps("Error - '" + spot.de_call + "' does not look like a valid callsign.",
|
||||
default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - '" + spot.de_call + "' does not look like a valid callsign."))
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
@@ -106,8 +91,7 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
# Reject if frequency not in a known band
|
||||
if infer_band_from_freq(spot.freq) == UNKNOWN_BAND:
|
||||
self.set_status(422)
|
||||
self.write(json.dumps("Error - Frequency of " + str(spot.freq / 1000.0) + "kHz is not in a known band.",
|
||||
default=serialize_everything))
|
||||
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("Content-Type", "application/json")
|
||||
return
|
||||
@@ -117,8 +101,7 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
r"^([A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}|[A-R]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2})$",
|
||||
spot.dx_grid.upper()):
|
||||
self.set_status(422)
|
||||
self.write(json.dumps("Error - '" + spot.dx_grid + "' does not look like a valid Maidenhead grid.",
|
||||
default=serialize_everything))
|
||||
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("Content-Type", "application/json")
|
||||
return
|
||||
@@ -127,9 +110,8 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
if spot.sig and spot.sig_refs and len(spot.sig_refs) > 0 and spot.sig_refs[0].id and get_ref_regex_for_sig(
|
||||
spot.sig) and not re.match(get_ref_regex_for_sig(spot.sig), spot.sig_refs[0].id):
|
||||
self.set_status(422)
|
||||
self.write(json.dumps(
|
||||
"Error - '" + spot.sig_refs[0].id + "' does not look like a valid reference for " + spot.sig + ".",
|
||||
default=serialize_everything))
|
||||
self.write(safe_json_dumps(
|
||||
"Error - '" + spot.sig_refs[0].id + "' does not look like a valid reference for " + spot.sig + "."))
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
@@ -139,14 +121,14 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
spot.infer_missing()
|
||||
self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
|
||||
|
||||
self.write(json.dumps("OK", default=serialize_everything))
|
||||
self.write(safe_json_dumps("OK"))
|
||||
self.set_status(201)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
|
||||
logging.error("Exception when handling client request to add spot API: %s", e, exc_info=True)
|
||||
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||
self.set_status(500)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from queue import Queue
|
||||
@@ -12,7 +11,7 @@ from tornado import httputil
|
||||
from tornado.web import Application
|
||||
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import serialize_everything, empty_queue
|
||||
from core.utils import safe_json_dumps, empty_queue
|
||||
from data.lookup_credentials import extract_credentials
|
||||
|
||||
SSE_HANDLER_MAX_QUEUE_SIZE = 100
|
||||
@@ -57,15 +56,14 @@ class APIAlertsHandler(tornado.web.RequestHandler):
|
||||
data = get_alert_list_with_filters(self._alerts, query_params)
|
||||
if credentials:
|
||||
data = self._enrich(data, credentials)
|
||||
self.write(json.dumps(data, default=serialize_everything))
|
||||
self.write(safe_json_dumps(data))
|
||||
self.set_status(200)
|
||||
except ValueError as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Bad request - " + str(e), default=serialize_everything))
|
||||
self.write(safe_json_dumps("Bad request - " + str(e)))
|
||||
self.set_status(400)
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
|
||||
logging.error("Exception when handling client request to alerts API: %s", e, exc_info=True)
|
||||
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||
self.set_status(500)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
@@ -150,7 +148,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
||||
if self._credentials:
|
||||
alert = copy.deepcopy(alert)
|
||||
alert.infer_missing(self._credentials)
|
||||
self.write_message(msg=json.dumps(alert, default=serialize_everything))
|
||||
self.write_message(msg=safe_json_dumps(alert))
|
||||
|
||||
else:
|
||||
# Send a keepalive comment if the queue was empty
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import logging
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -9,6 +10,7 @@ from tornado import httputil
|
||||
from tornado.web import Application
|
||||
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import safe_json_dumps
|
||||
|
||||
CONTINENTS = ["EU", "NA", "SA", "AS", "AF", "OC", "AN"]
|
||||
BANDS = ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"]
|
||||
@@ -29,6 +31,7 @@ class APIDxStatsHandler(tornado.web.RequestHandler):
|
||||
self._web_server_metrics = web_server_metrics
|
||||
|
||||
def get(self):
|
||||
try:
|
||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
||||
self._web_server_metrics["api_access_counter"] += 1
|
||||
self._web_server_metrics["status"] = "OK"
|
||||
@@ -55,3 +58,8 @@ class APIDxStatsHandler(tornado.web.RequestHandler):
|
||||
self.set_status(200)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
|
||||
except Exception as e:
|
||||
logging.error("Exception when handling client request to dx stats API: %s", e, exc_info=True)
|
||||
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||
self.set_status(500)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
@@ -13,7 +12,7 @@ from core.constants import SIGS
|
||||
from core.geo_utils import lat_lon_for_grid_sw_corner_plus_size, lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.sig_utils import get_ref_regex_for_sig, populate_sig_ref_info
|
||||
from core.utils import serialize_everything
|
||||
from core.utils import safe_json_dumps
|
||||
from data.lookup_credentials import extract_credentials
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
@@ -65,19 +64,18 @@ class APILookupCallHandler(tornado.web.RequestHandler):
|
||||
"longitude": fake_spot.dx_longitude,
|
||||
"location_source": fake_spot.dx_location_source
|
||||
}
|
||||
self.write(json.dumps(data, default=serialize_everything))
|
||||
self.write(safe_json_dumps(data))
|
||||
|
||||
else:
|
||||
self.write(json.dumps("Error - '" + call + "' does not look like a valid callsign.",
|
||||
default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - '" + call + "' does not look like a valid callsign."))
|
||||
self.set_status(422)
|
||||
else:
|
||||
self.write(json.dumps("Error - call must be provided", default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - call must be provided"))
|
||||
self.set_status(422)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
|
||||
logging.error("Exception when handling client request to call lookup API: %s", e, exc_info=True)
|
||||
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||
self.set_status(500)
|
||||
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
@@ -111,27 +109,25 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
|
||||
if "sig" in query_params.keys() and "id" in query_params.keys():
|
||||
sig = str(query_params.get("sig")).upper()
|
||||
ref_id = str(query_params.get("id")).upper()
|
||||
if sig in list(map(lambda p: p.name, SIGS)):
|
||||
if sig in list(map(lambda p: p.name.upper(), SIGS)):
|
||||
if not get_ref_regex_for_sig(sig) or re.match(get_ref_regex_for_sig(sig), ref_id):
|
||||
data = populate_sig_ref_info(SIGRef(id=ref_id, sig=sig))
|
||||
self.write(json.dumps(data, default=serialize_everything))
|
||||
self.write(safe_json_dumps(data))
|
||||
|
||||
else:
|
||||
self.write(
|
||||
json.dumps(
|
||||
"Error - '" + ref_id + "' does not look like a valid reference ID for " + sig + ".",
|
||||
default=serialize_everything))
|
||||
self.write(safe_json_dumps(
|
||||
"Error - '" + ref_id + "' does not look like a valid reference ID for " + sig + "."))
|
||||
self.set_status(422)
|
||||
else:
|
||||
self.write(json.dumps("Error - sig '" + sig + "' is not known.", default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - sig '" + sig + "' is not known."))
|
||||
self.set_status(422)
|
||||
else:
|
||||
self.write(json.dumps("Error - sig and id must be provided", default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - sig and id must be provided"))
|
||||
self.set_status(422)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
|
||||
logging.error("Exception when handling client request to sig ref lookup API: %s", e, exc_info=True)
|
||||
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||
self.set_status(500)
|
||||
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
@@ -185,15 +181,15 @@ class APILookupGridHandler(tornado.web.RequestHandler):
|
||||
"latitude": lat + lat_cell_size,
|
||||
"longitude": lon + lon_cell_size,
|
||||
}}
|
||||
self.write(json.dumps(response, default=serialize_everything))
|
||||
self.write(safe_json_dumps(response))
|
||||
|
||||
else:
|
||||
self.write(json.dumps("Error - grid must be provided", default=serialize_everything))
|
||||
self.write(safe_json_dumps("Error - grid must be provided"))
|
||||
self.set_status(422)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
|
||||
logging.error("Exception when handling client request to grid ref lookup API: %s", e, exc_info=True)
|
||||
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||
self.set_status(500)
|
||||
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -10,7 +10,7 @@ from tornado.web import Application
|
||||
from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING
|
||||
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS, PROPAGATION_MODES
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import serialize_everything
|
||||
from core.utils import safe_json_dumps
|
||||
|
||||
|
||||
class APIOptionsHandler(tornado.web.RequestHandler):
|
||||
@@ -26,6 +26,7 @@ class APIOptionsHandler(tornado.web.RequestHandler):
|
||||
self._web_server_metrics = web_server_metrics
|
||||
|
||||
def get(self):
|
||||
try:
|
||||
# Metrics
|
||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
||||
self._web_server_metrics["api_access_counter"] += 1
|
||||
@@ -42,7 +43,7 @@ class APIOptionsHandler(tornado.web.RequestHandler):
|
||||
"alert_sources": list(
|
||||
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"]))),
|
||||
"continents": CONTINENTS,
|
||||
"propagation_modes": PROPAGATION_MODES.values(),
|
||||
"propagation_modes": list(PROPAGATION_MODES.values()),
|
||||
"max_spot_age": MAX_SPOT_AGE,
|
||||
"spot_allowed": ALLOW_SPOTTING}
|
||||
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
|
||||
@@ -50,7 +51,12 @@ class APIOptionsHandler(tornado.web.RequestHandler):
|
||||
if ALLOW_SPOTTING:
|
||||
options["spot_sources"].append("API")
|
||||
|
||||
self.write(json.dumps(options, default=serialize_everything))
|
||||
self.write(safe_json_dumps(options))
|
||||
self.set_status(200)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
|
||||
except Exception as e:
|
||||
logging.error("Exception when handling client request to options API: %s", e, exc_info=True)
|
||||
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||
self.set_status(500)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -7,6 +8,7 @@ from tornado import httputil
|
||||
from tornado.web import Application
|
||||
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import safe_json_dumps
|
||||
|
||||
|
||||
class APISolarConditionsHandler(tornado.web.RequestHandler):
|
||||
@@ -22,6 +24,7 @@ class APISolarConditionsHandler(tornado.web.RequestHandler):
|
||||
self._web_server_metrics = web_server_metrics
|
||||
|
||||
def get(self):
|
||||
try:
|
||||
# Metrics
|
||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
||||
self._web_server_metrics["api_access_counter"] += 1
|
||||
@@ -32,3 +35,8 @@ class APISolarConditionsHandler(tornado.web.RequestHandler):
|
||||
self.set_status(200)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
|
||||
except Exception as e:
|
||||
logging.error("Exception when handling client request to solar conditions API: %s", e, exc_info=True)
|
||||
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||
self.set_status(500)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from queue import Queue
|
||||
@@ -12,7 +11,7 @@ from tornado import httputil
|
||||
from tornado.web import Application
|
||||
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import serialize_everything, empty_queue
|
||||
from core.utils import safe_json_dumps, empty_queue
|
||||
from data.lookup_credentials import extract_credentials
|
||||
|
||||
SSE_HANDLER_MAX_QUEUE_SIZE = 1000
|
||||
@@ -57,15 +56,14 @@ class APISpotsHandler(tornado.web.RequestHandler):
|
||||
data = get_spot_list_with_filters(self._spots, query_params)
|
||||
if credentials:
|
||||
data = self._enrich(data, credentials)
|
||||
self.write(json.dumps(data, default=serialize_everything))
|
||||
self.write(safe_json_dumps(data))
|
||||
self.set_status(200)
|
||||
except ValueError as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Bad request - " + str(e), default=serialize_everything))
|
||||
self.write(safe_json_dumps("Bad request - " + str(e)))
|
||||
self.set_status(400)
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything))
|
||||
logging.error("Excedption when handling client request to spots API: %s", e, exc_info=True)
|
||||
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||
self.set_status(500)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
@@ -152,7 +150,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
||||
if self._credentials:
|
||||
spot = copy.deepcopy(spot)
|
||||
spot.infer_missing(self._credentials)
|
||||
self.write_message(msg=json.dumps(spot, default=serialize_everything))
|
||||
self.write_message(msg=safe_json_dumps(spot))
|
||||
|
||||
else:
|
||||
# Send a keepalive comment if the queue was empty
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -8,7 +8,7 @@ from tornado import httputil
|
||||
from tornado.web import Application
|
||||
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import serialize_everything
|
||||
from core.utils import safe_json_dumps
|
||||
|
||||
|
||||
class APIStatusHandler(tornado.web.RequestHandler):
|
||||
@@ -24,13 +24,19 @@ class APIStatusHandler(tornado.web.RequestHandler):
|
||||
self._web_server_metrics = web_server_metrics
|
||||
|
||||
def get(self):
|
||||
try:
|
||||
# Metrics
|
||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
||||
self._web_server_metrics["api_access_counter"] += 1
|
||||
self._web_server_metrics["status"] = "OK"
|
||||
api_requests_counter.inc()
|
||||
|
||||
self.write(json.dumps(self._status_data, default=serialize_everything))
|
||||
self.write(safe_json_dumps(self._status_data))
|
||||
self.set_status(200)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
|
||||
except Exception as e:
|
||||
logging.error("Exception when handling client request to status API: %s", e, exc_info=True)
|
||||
self.write(safe_json_dumps("Error - an internal server error occurred."))
|
||||
self.set_status(500)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import tornado.web
|
||||
|
||||
from core.config import BASE_URL
|
||||
|
||||
|
||||
class ManifestHandler(tornado.web.RequestHandler):
|
||||
"""Handler for manifest.webmanifest, which needs BASE_URL inserted and a custom content-type"""
|
||||
|
||||
def get(self):
|
||||
self.set_header("Content-Type", "application/manifest+json; charset=UTF-8")
|
||||
self.render("manifest.webmanifest", baseurl=BASE_URL)
|
||||
@@ -5,7 +5,7 @@ import os
|
||||
import tornado
|
||||
from tornado.web import StaticFileHandler
|
||||
|
||||
from core.config import ALLOW_SPOTTING, WEB_SERVER_PORT, API_ONLY_MODE
|
||||
from core.config import ALLOW_SPOTTING, WEB_SERVER_PORT, API_ONLY_MODE, LOG_WEB_REQUESTS, BASE_URL
|
||||
from core.utils import empty_queue
|
||||
from server.handlers.api.addspot import APISpotHandler
|
||||
from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
|
||||
@@ -15,6 +15,7 @@ from server.handlers.api.options import APIOptionsHandler
|
||||
from server.handlers.api.solar_conditions import APISolarConditionsHandler
|
||||
from server.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
|
||||
from server.handlers.api.status import APIStatusHandler
|
||||
from server.handlers.manifesthandler import ManifestHandler
|
||||
from server.handlers.metrics import PrometheusMetricsHandler
|
||||
from server.handlers.pagetemplate import PageTemplateHandler
|
||||
|
||||
@@ -99,11 +100,16 @@ class WebServer:
|
||||
if ALLOW_SPOTTING:
|
||||
ui_routes += [(r"/add-spot", PageTemplateHandler, {"template_name": "add_spot", **handler_opts})]
|
||||
|
||||
# API docs, Prometheus metrics, and finally static assets are always available regardless of API-only mode.
|
||||
# API docs, Prometheus metrics, webapp manifest and static assets are always available regardless of API-only
|
||||
# mode.
|
||||
misc_routes = [
|
||||
(r"/apidocs", PageTemplateHandler, {"template_name": "apidocs", **handler_opts}),
|
||||
(r"/metrics", PrometheusMetricsHandler),
|
||||
(r"/(.*)", StaticFileHandler, {"path": os.path.join(_HERE, "../webassets")})
|
||||
(r"/manifest.webmanifest", ManifestHandler),
|
||||
# If e.g. nginx is configured as a reverse proxy with a hard-coded path to static files, as per the README,
|
||||
# this will never have to handle anything, but having it here allows Spothole to work without nginx for
|
||||
# testing.
|
||||
(r"/static/(.*)", StaticFileHandler, {"path": os.path.join(_HERE, "../static")})
|
||||
]
|
||||
|
||||
app = tornado.web.Application(api_routes + ui_routes + misc_routes,
|
||||
@@ -112,6 +118,7 @@ class WebServer:
|
||||
debug=False)
|
||||
app.listen(self._port, xheaders=True)
|
||||
logging.info("Web server running on port " + str(WEB_SERVER_PORT))
|
||||
logging.info("You can access your copy of Spothole at " + BASE_URL)
|
||||
await self._shutdown_event.wait()
|
||||
|
||||
def notify_new_spot(self, spot):
|
||||
@@ -165,8 +172,10 @@ class WebServer:
|
||||
pass
|
||||
|
||||
def request_log(handler):
|
||||
"""Custom log function to provide more data about requests."""
|
||||
"""Custom log function to provide more data about requests when enabled, and to provide the ability to turn off
|
||||
web request logging altogetether."""
|
||||
|
||||
if LOG_WEB_REQUESTS:
|
||||
if handler.get_status() < 500:
|
||||
log_method = logging.info
|
||||
else:
|
||||
|
||||
@@ -5,6 +5,7 @@ from threading import Thread, Event
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from solarconditionsproviders.ionosonde_utils import compute_band_states
|
||||
@@ -29,7 +30,7 @@ class GIROIonosonde(SolarConditionsProvider):
|
||||
data, but is less reliable and often offline."""
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config)
|
||||
super().__init__("GIRO Ionosonde Data", provider_config)
|
||||
self._stations = self._load_stations()
|
||||
self._thread = None
|
||||
self._stop_event = Event()
|
||||
@@ -127,10 +128,18 @@ class GIROIonosonde(SolarConditionsProvider):
|
||||
from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S")
|
||||
to_str = to_time.strftime("%Y.%m.%d+%H:%M:%S")
|
||||
url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUFD,fmin&DMUF=3000&fromDate={from_str}&toDate={to_str}"
|
||||
response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15))
|
||||
if response.status_code != 200:
|
||||
try:
|
||||
http_response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15))
|
||||
if not http_response.ok:
|
||||
logging.warning(f"HTTP {http_response.status_code} when calling Giro ionosonde API.")
|
||||
return None, None, None
|
||||
return self._parse_all(http_response.text)
|
||||
except (ConnectTimeout, ReadTimeout):
|
||||
logging.warning(f"Timeout when accessing Giro ionosonde API.")
|
||||
return None, None, None
|
||||
except ConnectionError:
|
||||
logging.warning("Connection error when accessing Giro ionosonde API.")
|
||||
return None, None, None
|
||||
return self._parse_all(response.text)
|
||||
|
||||
@staticmethod
|
||||
def _parse_all(text):
|
||||
|
||||
@@ -15,13 +15,9 @@ class HamQSL(HTTPSolarConditionsProvider):
|
||||
Provides solar flux index, geomagnetic indices, and HF/VHF propagation condition summaries."""
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, URL, POLL_INTERVAL)
|
||||
super().__init__("HamQSL", provider_config, URL, POLL_INTERVAL)
|
||||
|
||||
def _http_response_to_solar_conditions(self, http_response):
|
||||
if http_response.status_code != 200:
|
||||
logging.warning("HamQSL solar conditions API returned HTTP " + str(http_response.status_code))
|
||||
return None
|
||||
|
||||
root = ElementTree.fromstring(http_response.text)
|
||||
sd = root.find("solardata")
|
||||
if sd is None:
|
||||
|
||||
@@ -4,6 +4,7 @@ from threading import Thread, Event
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from solarconditionsproviders.solar_conditions_provider import SolarConditionsProvider
|
||||
@@ -13,8 +14,8 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
|
||||
"""Generic solar conditions provider for providers that request data via HTTP(S). Subclasses implement
|
||||
_http_response_to_solar_conditions() to parse the specific API response format."""
|
||||
|
||||
def __init__(self, provider_config, url, poll_interval):
|
||||
super().__init__(provider_config)
|
||||
def __init__(self, name, provider_config, url, poll_interval):
|
||||
super().__init__(name, provider_config)
|
||||
self._url = url
|
||||
self._poll_interval = poll_interval
|
||||
self._thread = None
|
||||
@@ -39,13 +40,22 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
|
||||
try:
|
||||
logging.debug("Polling " + self.name + " solar conditions API...")
|
||||
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
|
||||
# Check response code was good
|
||||
if http_response.ok:
|
||||
new_data = self._http_response_to_solar_conditions(http_response)
|
||||
self.update_data(new_data)
|
||||
|
||||
self.status = "OK"
|
||||
self.last_update_time = datetime.now(pytz.UTC)
|
||||
logging.debug("Received data from " + self.name + " solar conditions API.")
|
||||
else:
|
||||
self.status = "Error"
|
||||
logging.warning(f"HTTP {http_response.status_code} when calling {self.name} solar conditions API.")
|
||||
|
||||
except ConnectionError:
|
||||
logging.warning(f"Connection error when accessing {self.name} solar conditions API.")
|
||||
except (ConnectTimeout, ReadTimeout):
|
||||
logging.warning(f"Timeout when accessing {self.name} solar conditions API.")
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.exception("Exception in HTTP Solar Conditions Provider (" + self.name + ")")
|
||||
|
||||
@@ -4,6 +4,7 @@ from threading import Thread, Event
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from solarconditionsproviders.ionosonde_utils import compute_band_states
|
||||
@@ -23,7 +24,7 @@ class KC2GProp(SolarConditionsProvider):
|
||||
online, but has fewer stations and does not provide LUF data."""
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config)
|
||||
super().__init__("KC2G Propagation Data", provider_config)
|
||||
self._thread = None
|
||||
self._stop_event = Event()
|
||||
|
||||
@@ -44,9 +45,9 @@ class KC2GProp(SolarConditionsProvider):
|
||||
def _poll(self):
|
||||
try:
|
||||
logging.debug("Polling KC2G ionosonde data...")
|
||||
response = requests.get(KC2G_URL, headers=HTTP_HEADERS, timeout=(5, 30))
|
||||
if response.status_code != 200:
|
||||
logging.warning(f"KC2G ionosonde API returned HTTP {response.status_code}")
|
||||
http_response = requests.get(KC2G_URL, headers=HTTP_HEADERS, timeout=(5, 30))
|
||||
if not http_response.ok:
|
||||
logging.warning(f"HTTP {http_response.status_code} when calling KG2G ionosonde API.")
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -57,7 +58,7 @@ class KC2GProp(SolarConditionsProvider):
|
||||
ionosonde_data = dict(self._solar_conditions.ionosonde_data or {})
|
||||
updated_count = 0
|
||||
|
||||
for reading in response.json():
|
||||
for reading in http_response.json():
|
||||
station = reading.get("station", {})
|
||||
ursi = station.get("code")
|
||||
name = station.get("name")
|
||||
@@ -115,6 +116,10 @@ class KC2GProp(SolarConditionsProvider):
|
||||
self.last_update_time = datetime.now(pytz.UTC)
|
||||
logging.debug(f"Updated KC2G ionosonde data for {updated_count} stations.")
|
||||
|
||||
except ConnectionError:
|
||||
logging.warning("Connection error when accessing KC2G ionosonde API.")
|
||||
except (ConnectTimeout, ReadTimeout):
|
||||
logging.warning(f"Timeout when accessing KC2G ionosonde API.")
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.exception("Exception in KC2G ionosonde data provider")
|
||||
|
||||
@@ -13,7 +13,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
|
||||
corresponding fields in the solar conditions object.."""
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, URL, POLL_INTERVAL)
|
||||
super().__init__("NOAA 3-day Forecast", provider_config, URL, POLL_INTERVAL)
|
||||
|
||||
@staticmethod
|
||||
def _parse_percentage_table(lines, section_header, year):
|
||||
@@ -80,10 +80,6 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
|
||||
return result if result else None
|
||||
|
||||
def _http_response_to_solar_conditions(self, http_response):
|
||||
if http_response.status_code != 200:
|
||||
logging.warning("NOAA K-index forecast API returned HTTP " + str(http_response.status_code))
|
||||
return None
|
||||
|
||||
lines = http_response.text.splitlines()
|
||||
|
||||
# Find the "NOAA Kp index breakdown" section header
|
||||
|
||||
@@ -7,11 +7,11 @@ class SolarConditionsProvider:
|
||||
"""Generic solar conditions provider class. Subclasses of this query individual APIs for space weather and
|
||||
propagation data."""
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, name, provider_config):
|
||||
"""Constructor"""
|
||||
|
||||
self._solar_conditions_cache = None
|
||||
self.name = provider_config["name"]
|
||||
self.name = name
|
||||
self.enabled = provider_config["enabled"]
|
||||
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||
self.status = "Not Started" if self.enabled else "Disabled"
|
||||
|
||||
@@ -8,7 +8,7 @@ import sys
|
||||
from diskcache import Cache
|
||||
|
||||
from core.cleanup import CleanupTimer
|
||||
from core.config import config, SERVER_OWNER_CALLSIGN
|
||||
from core.config import config, SERVER_OWNER_CALLSIGN, LOG_LEVEL
|
||||
from core.constants import SOFTWARE_VERSION
|
||||
from core.lookup_helper import lookup_helper
|
||||
from core.status_reporter import StatusReporter
|
||||
@@ -84,9 +84,9 @@ def get_solar_conditions_provider_from_config(config_providers_entry):
|
||||
if __name__ == '__main__':
|
||||
# Set up logging
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.INFO)
|
||||
root.setLevel(LOG_LEVEL)
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setLevel(logging.INFO)
|
||||
handler.setLevel(LOG_LEVEL)
|
||||
formatter = logging.Formatter("%(levelname)s : %(message)s")
|
||||
handler.setFormatter(formatter)
|
||||
root.handlers.clear()
|
||||
|
||||
@@ -14,7 +14,7 @@ class APRSIS(SpotProvider):
|
||||
"""Spot provider for the APRS-IS."""
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config)
|
||||
super().__init__("APRS-IS", provider_config)
|
||||
self._thread = Thread(target=self._connect)
|
||||
self._thread.daemon = True
|
||||
self._aprsis = None
|
||||
|
||||
@@ -26,7 +26,8 @@ class DXCluster(SpotProvider):
|
||||
def __init__(self, provider_config):
|
||||
"""Constructor requires hostname and port"""
|
||||
|
||||
super().__init__(provider_config)
|
||||
name = provider_config["name"] if "name" in provider_config else "Cluster"
|
||||
super().__init__(name, provider_config)
|
||||
self._hostname = provider_config["host"]
|
||||
self._port = provider_config["port"]
|
||||
self._login_prompt = provider_config["login_prompt"] if "login_prompt" in provider_config else "login:"
|
||||
@@ -59,6 +60,10 @@ class DXCluster(SpotProvider):
|
||||
self._telnet.write((self._login_callsign + "\n").encode("latin-1"))
|
||||
connected = True
|
||||
logging.info("DX Cluster " + self._hostname + " connected.")
|
||||
except ConnectionRefusedError:
|
||||
self.status = "Error"
|
||||
logging.warning("Connection refused to DX cluster " + self._hostname)
|
||||
sleep(300)
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.exception("Exception while connecting to DX Cluster Provider (" + self._hostname + ").")
|
||||
|
||||
@@ -14,9 +14,9 @@ class GMA(HTTPSpotProvider):
|
||||
"""Spot provider for General Mountain Activity"""
|
||||
|
||||
POLL_INTERVAL_SEC = 120
|
||||
SPOTS_URL = "https://www.cqgma.org/api/spots/25/"
|
||||
SPOTS_URL = "https://www.gma.rocks/api/spots/25/"
|
||||
# GMA spots don't contain the details of the programme they are for, we need a separate lookup for that
|
||||
REF_INFO_URL_ROOT = "https://www.cqgma.org/api/ref/?"
|
||||
REF_INFO_URL_ROOT = "https://www.gma.rocks/api/ref/?"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
# Ensure there is an API key in our config, and set up the query URL using it. If no key is provided,
|
||||
@@ -26,7 +26,7 @@ class GMA(HTTPSpotProvider):
|
||||
provider_config["enabled"] = False
|
||||
logging.warning("GMA spot provider configured but no api key was provided, this API will not be queried.")
|
||||
|
||||
super().__init__(provider_config, self.SPOTS_URL + "?key=" + self.api_key, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("GMA", provider_config, self.SPOTS_URL + "?key=" + self.api_key, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
@@ -34,37 +34,43 @@ class GMA(HTTPSpotProvider):
|
||||
if "RCD" in http_response.json():
|
||||
for source_spot in http_response.json()["RCD"]:
|
||||
# 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,
|
||||
dx_call=source_spot["ACTIVATOR"].upper(),
|
||||
de_call=source_spot["SPOTTER"].upper(),
|
||||
freq=float(source_spot["QRG"]) * 1000 if (source_spot["QRG"] != "") else None,
|
||||
# Seen GMA spots with no frequency
|
||||
mode=source_spot["MODE"].upper() if "<>" not in source_spot["MODE"] else None,
|
||||
# Seen GMA spots with no frequency or with "QRT" in this field
|
||||
freq=float(source_spot["QRG"]) * 1000 if (
|
||||
source_spot["QRG"] != "" and source_spot["QRG"] != "QRT") else None,
|
||||
# Filter out some weird mode strings
|
||||
mode=source_spot["MODE"].upper() if "<>" not in source_spot["MODE"] else None,
|
||||
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(
|
||||
tzinfo=pytz.UTC).timestamp(),
|
||||
dx_latitude=float(source_spot["LAT"]) if (
|
||||
source_spot["LAT"] and source_spot["LAT"] != "") else None,
|
||||
# Seen GMA spots with no (or empty) lat/lon
|
||||
dx_longitude=float(source_spot["LON"]) if (
|
||||
source_spot["LON"] and source_spot["LON"] != "") else None)
|
||||
dx_latitude=lat,
|
||||
dx_longitude=lon,
|
||||
qrt=source_spot["QRG"] == "QRT")
|
||||
|
||||
# GMA doesn't give what programme (SIG) the reference is for until we separately look it up.
|
||||
if "REF" in source_spot:
|
||||
try:
|
||||
ref_response = SEMI_STATIC_URL_DATA_CACHE.get(self.REF_INFO_URL_ROOT + source_spot["REF"],
|
||||
headers=HTTP_HEADERS)
|
||||
# Sometimes this is blank, so handle that
|
||||
if ref_response.text is not None and ref_response.text != "":
|
||||
# Sometimes this is blank even if it's a 200 response, so handle that
|
||||
if ref_response.ok and ref_response.text is not None and ref_response.text != "":
|
||||
ref_info = ref_response.json()
|
||||
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. POTA and WWFF
|
||||
# spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA
|
||||
# and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry
|
||||
# to determine if it's a SOTA summit.
|
||||
if spot.sig_refs and "reftype" in ref_info and ref_info["reftype"] not in ["POTA", "WWFF"] and (
|
||||
ref_info["reftype"] != "Summit" or "sota" not in ref_info or ref_info["sota"] == ""):
|
||||
if spot.sig_refs and "reftype" in ref_info and ref_info["reftype"] not in ["POTA",
|
||||
"WWFF"] and (
|
||||
ref_info["reftype"] != "Summit" or "sota" not in ref_info or ref_info[
|
||||
"sota"] == ""):
|
||||
match ref_info["reftype"]:
|
||||
case "Summit":
|
||||
spot.sig_refs[0].sig = "GMA"
|
||||
@@ -90,9 +96,17 @@ class GMA(HTTPSpotProvider):
|
||||
spot.sig_refs[0].sig = ref_info["reftype"]
|
||||
spot.sig = ref_info["reftype"]
|
||||
|
||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
||||
# that for us.
|
||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point;
|
||||
# other code will do that for us.
|
||||
new_spots.append(spot)
|
||||
|
||||
elif not ref_response.from_cache:
|
||||
if not ref_response.ok:
|
||||
logging.warning(
|
||||
f"HTTP {ref_response.status_code} when looking up GMA ref {source_spot["REF"]}")
|
||||
else:
|
||||
logging.warning(
|
||||
f"GMA API returned a malformed response when looking up ref {source_spot["REF"]}")
|
||||
except:
|
||||
logging.warning("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot[
|
||||
"REF"] + ", ignoring this spot for now")
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from data.sig_ref import SIGRef
|
||||
@@ -23,7 +25,7 @@ class HEMA(HTTPSpotProvider):
|
||||
SPOTTER_COMMENT_PATTERN = re.compile("^\\((.*)\\) (.*)$")
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.SPOT_SEED_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("HEMA", provider_config, self.SPOT_SEED_URL, self.POLL_INTERVAL_SEC)
|
||||
self._spot_seed = ""
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
@@ -35,6 +37,7 @@ class HEMA(HTTPSpotProvider):
|
||||
new_spots = []
|
||||
# OK, if the spot seed actually changed, now we make the real request for data.
|
||||
if spot_seed_changed:
|
||||
try:
|
||||
source_data = requests.get(self.SPOTS_URL, headers=HTTP_HEADERS, timeout=(5, 30))
|
||||
source_data_items = source_data.text.split("=")
|
||||
# Iterate through source data items.
|
||||
@@ -56,13 +59,18 @@ class HEMA(HTTPSpotProvider):
|
||||
mode=freq_mode_match.group(2).upper(),
|
||||
comment=spotter_comment_match.group(2),
|
||||
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(
|
||||
tzinfo=pytz.UTC).timestamp(),
|
||||
dx_latitude=float(spot_items[7]),
|
||||
dx_longitude=float(spot_items[8]))
|
||||
|
||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
||||
# that for us.
|
||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other
|
||||
# code will do that for us.
|
||||
new_spots.append(spot)
|
||||
except (ConnectTimeout, ReadTimeout):
|
||||
logging.warning(f"Timeout when accessing HEMA spots API.")
|
||||
except ConnectionError:
|
||||
logging.warning("Connection error when accessing HEMA spots API.")
|
||||
return new_spots
|
||||
|
||||
@@ -4,6 +4,8 @@ from threading import Thread, Event
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from requests import ReadTimeout
|
||||
from requests.exceptions import ConnectionError, ConnectTimeout
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from spotproviders.spot_provider import SpotProvider
|
||||
@@ -13,8 +15,8 @@ class HTTPSpotProvider(SpotProvider):
|
||||
"""Generic spot provider class for providers that request data via HTTP(S). Just for convenience to avoid code
|
||||
duplication. Subclasses of this query the individual APIs for data."""
|
||||
|
||||
def __init__(self, provider_config, url, poll_interval):
|
||||
super().__init__(provider_config)
|
||||
def __init__(self, name, provider_config, url, poll_interval):
|
||||
super().__init__(name, provider_config)
|
||||
self._url = url
|
||||
self._poll_interval = poll_interval
|
||||
self._thread = None
|
||||
@@ -41,6 +43,8 @@ class HTTPSpotProvider(SpotProvider):
|
||||
# Request data from API
|
||||
logging.debug("Polling " + self.name + " spot API...")
|
||||
http_response = requests.get(self._url, headers=HTTP_HEADERS, timeout=(5, 30))
|
||||
# Check response code was good
|
||||
if http_response.ok:
|
||||
# Pass off to the subclass for processing
|
||||
new_spots = self._http_response_to_spots(http_response)
|
||||
# Submit the new spots for processing. There might not be any spots for the less popular programs.
|
||||
@@ -50,7 +54,14 @@ class HTTPSpotProvider(SpotProvider):
|
||||
self.status = "OK"
|
||||
self.last_update_time = datetime.now(pytz.UTC)
|
||||
logging.debug("Received data from " + self.name + " spot API.")
|
||||
else:
|
||||
self.status = "Error"
|
||||
logging.warning(f"HTTP {http_response.status_code} when calling {self.name} spot API.")
|
||||
|
||||
except ConnectionError:
|
||||
logging.warning(f"Connection error when accessing {self.name} spots API.")
|
||||
except (ConnectTimeout, ReadTimeout):
|
||||
logging.warning(f"Timeout when accessing {self.name} spots API.")
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.exception("Exception in HTTP JSON Spot Provider (" + self.name + ")")
|
||||
|
||||
@@ -12,7 +12,7 @@ class LLOTA(HTTPSpotProvider):
|
||||
SPOTS_URL = "https://llota.app/api/public/spots"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("LLOTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
|
||||
@@ -17,7 +17,7 @@ class ParksNPeaks(HTTPSpotProvider):
|
||||
SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("ParksNPeaks", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
@@ -30,8 +30,8 @@ class ParksNPeaks(HTTPSpotProvider):
|
||||
dx_call=source_spot["actCallsign"].upper(),
|
||||
de_call=source_spot["actSpoter"].upper() if source_spot["actSpoter"] != "" else None,
|
||||
# typo exists in API
|
||||
freq=float(source_spot["actFreq"].replace(",", "")) * 1000000 if (
|
||||
source_spot["actFreq"] != "") else None,
|
||||
freq=float(source_spot["actFreq"].replace(",", "").replace("+-", "")
|
||||
.replace("+/-", "").strip()) * 1000000 if (source_spot["actFreq"] != "") else None,
|
||||
# Seen PNP spots with empty frequency, and with comma-separated thousands digits
|
||||
mode=source_spot["actMode"].upper(),
|
||||
comment=source_spot["actComments"],
|
||||
@@ -57,7 +57,7 @@ class ParksNPeaks(HTTPSpotProvider):
|
||||
sig_refs[0].name = source_spot["actLocation"]
|
||||
|
||||
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before
|
||||
if sig not in ["POTA", "SOTA", "WWFF", "SIOTA", "ZLOTA", "KRMNPA"]:
|
||||
if sig not in ["POTA", "SOTA", "WWFF", "SIOTA", "ZLOTA", "KRMNPA", "LLOTA"]:
|
||||
logging.warning("PNP spot found with sig " + sig + ", developer needs to add support for this!")
|
||||
|
||||
# Add new spot to the list
|
||||
|
||||
@@ -14,7 +14,7 @@ class POTA(HTTPSpotProvider):
|
||||
SPOTS_URL = "https://api.pota.app/spot/activator"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("POTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
@@ -29,7 +29,8 @@ class POTA(HTTPSpotProvider):
|
||||
mode=source_spot["mode"].upper(),
|
||||
comment=source_spot["comments"],
|
||||
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(
|
||||
tzinfo=pytz.UTC).timestamp(),
|
||||
dx_grid=source_spot["grid6"],
|
||||
|
||||
@@ -23,7 +23,8 @@ class RBN(SpotProvider):
|
||||
def __init__(self, provider_config):
|
||||
"""Constructor requires port number."""
|
||||
|
||||
super().__init__(provider_config)
|
||||
name = provider_config["name"] if "name" in provider_config else "RBN"
|
||||
super().__init__(name, provider_config)
|
||||
self._port = provider_config["port"]
|
||||
self._telnet = None
|
||||
self._thread = Thread(target=self._handle)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from data.sig_ref import SIGRef
|
||||
@@ -17,11 +19,9 @@ class SOTA(HTTPSpotProvider):
|
||||
# The actual data lookup all happens after parsing and checking the 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"
|
||||
# SOTA spots don't contain lat/lon, we need a separate lookup for that
|
||||
SUMMIT_URL_ROOT = "https://api-db2.sota.org.uk/api/summits/"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("SOTA", provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
|
||||
self._api_epoch = ""
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
@@ -33,6 +33,7 @@ class SOTA(HTTPSpotProvider):
|
||||
new_spots = []
|
||||
# OK, if the epoch actually changed, now we make the real request for data.
|
||||
if epoch_changed:
|
||||
try:
|
||||
source_data = requests.get(self.SPOTS_URL, headers=HTTP_HEADERS, timeout=(5, 30)).json()
|
||||
# Iterate through source data
|
||||
for source_spot in source_data:
|
||||
@@ -48,11 +49,19 @@ class SOTA(HTTPSpotProvider):
|
||||
mode=source_spot["mode"].upper(),
|
||||
comment=source_spot["comments"],
|
||||
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"])],
|
||||
dx_latitude=source_spot["latitude"],
|
||||
dx_longitude=source_spot["longitude"],
|
||||
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
|
||||
# that for us.
|
||||
new_spots.append(spot)
|
||||
except ConnectionError:
|
||||
logging.warning("Connection error when accessing SOTA spots API")
|
||||
except (ConnectTimeout, ReadTimeout):
|
||||
logging.warning(f"Timeout when accessing SOTA spots API.")
|
||||
return new_spots
|
||||
|
||||
@@ -8,10 +8,10 @@ from core.config import MAX_SPOT_AGE
|
||||
class SpotProvider:
|
||||
"""Generic spot provider class. Subclasses of this query the individual APIs for data."""
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, name, provider_config):
|
||||
"""Constructor"""
|
||||
|
||||
self.name = provider_config["name"]
|
||||
self.name = name
|
||||
self.enabled = provider_config["enabled"]
|
||||
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||
self.last_spot_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||
|
||||
@@ -13,8 +13,8 @@ from spotproviders.spot_provider import SpotProvider
|
||||
class SSESpotProvider(SpotProvider):
|
||||
"""Spot provider using Server-Sent Events."""
|
||||
|
||||
def __init__(self, provider_config, url):
|
||||
super().__init__(provider_config)
|
||||
def __init__(self, name, provider_config, url):
|
||||
super().__init__(name, provider_config)
|
||||
self._url = url
|
||||
self._event_source = None
|
||||
self._thread = None
|
||||
|
||||
@@ -12,7 +12,7 @@ class Tiles(HTTPSpotProvider):
|
||||
SPOTS_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/spots?active_hours=24"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("Tiles", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
@@ -31,7 +31,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.
|
||||
# Just take the grid reference itself as the single Tiles SIG reference.
|
||||
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(),
|
||||
dx_grid=source_spot["maidenhead_grid"],
|
||||
dx_latitude=source_spot["latitude"],
|
||||
|
||||
@@ -6,14 +6,14 @@ from data.spot import Spot
|
||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||
|
||||
|
||||
class WWTOTA(HTTPSpotProvider):
|
||||
class Towers(HTTPSpotProvider):
|
||||
"""Spot provider for Towers on the Air"""
|
||||
|
||||
POLL_INTERVAL_SEC = 120
|
||||
SPOTS_URL = "https://wwtota.com/api/cluster_live.php"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("Towers", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
@@ -14,7 +14,7 @@ class UKPacketNet(HTTPSpotProvider):
|
||||
SPOTS_URL = "https://nodes.ukpacketradio.network/api/nodedata"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("UK Packet Net", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
|
||||
@@ -13,8 +13,8 @@ from spotproviders.spot_provider import SpotProvider
|
||||
class WebsocketSpotProvider(SpotProvider):
|
||||
"""Spot provider using websockets."""
|
||||
|
||||
def __init__(self, provider_config, url):
|
||||
super().__init__(provider_config)
|
||||
def __init__(self, name, provider_config, url):
|
||||
super().__init__(name, provider_config)
|
||||
self._url = url
|
||||
self._ws = None
|
||||
self._thread = None
|
||||
|
||||
@@ -21,7 +21,7 @@ class WOTA(HTTPSpotProvider):
|
||||
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("WOTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
|
||||
@@ -12,7 +12,7 @@ class WWBOTA(SSESpotProvider):
|
||||
SPOTS_URL = "https://api.wwbota.net/spots/"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.SPOTS_URL)
|
||||
super().__init__("WWBOTA", provider_config, self.SPOTS_URL)
|
||||
|
||||
def _sse_message_to_spot(self, message):
|
||||
source_spot = json.loads(message)
|
||||
@@ -20,7 +20,8 @@ class WWBOTA(SSESpotProvider):
|
||||
# n-fer activations.
|
||||
refs = []
|
||||
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)
|
||||
|
||||
spot = Spot(source=self.name,
|
||||
|
||||
@@ -14,7 +14,7 @@ class WWFF(HTTPSpotProvider):
|
||||
SPOTS_URL = "https://spots.wwff.co/static/spots.json"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("WWFF", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
@@ -29,7 +29,8 @@ class WWFF(HTTPSpotProvider):
|
||||
mode=source_spot["mode"].upper(),
|
||||
comment=source_spot["remarks"],
|
||||
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(),
|
||||
dx_latitude=source_spot["latitude"],
|
||||
dx_longitude=source_spot["longitude"])
|
||||
|
||||
@@ -21,7 +21,8 @@ class XOTA(WebsocketSpotProvider):
|
||||
SIG = None
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, provider_config["url"])
|
||||
name = provider_config["name"] if "name" in provider_config else "xOTA"
|
||||
super().__init__(name, provider_config, provider_config["url"])
|
||||
locations_csv = str(provider_config["locations-csv"]) if "locations-csv" in provider_config else None
|
||||
self.SIG = str(provider_config["sig"]) if "sig" in provider_config else None
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class ZLOTA(HTTPSpotProvider):
|
||||
LIST_URL = "https://ontheair.nz/assets/assets.json"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
super().__init__("ZLOTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
|
||||
@@ -11,13 +11,18 @@ info:
|
||||
|
||||
Please note that the data coming out of Spothole is only as good as the data going in. People mis-hear and make typos when spotting callsigns all the time, and there are plenty of areas where Spothole's location data may be inaccurate. If you are doing something where accuracy is important, such as contesting, you should not rely on Spothole's data to fill in any gaps in your log.
|
||||
|
||||
Spothole's source code is located at https://git.ianrenton.com/ian/spothole and the README there provides setup instructions if you would like to run your own copy. A demonstration server of Spothole is located at https://spothole.app.
|
||||
Spothole's source code is located at https://git.ianrenton.com/ian/spothole and the README there provides setup instructions if you would like to run your own copy. A demonstration server of Spothole is located at https://spothole.app. The README also contains some examples of how you could query the API of the demonstration server to integrate the data into your own apps.
|
||||
|
||||
## Changelog
|
||||
|
||||
### 1.4
|
||||
|
||||
* Spots can now include a "propagation_mode" field, and the `/options` call enumerates the options that can have.
|
||||
* Added support for the Diploma de Municipios Españoles (DME) SIG
|
||||
* Renamed some SIGs to avoid confusion between Towers, Tiles and Toilets
|
||||
* Added `comment_names` to SIGs in the `/options`, to reflect how they might be referred to in spot comments where
|
||||
it differs from their `name`.
|
||||
* Added `propagation_mode` field to spots
|
||||
|
||||
### 1.3
|
||||
|
||||
@@ -771,7 +776,7 @@ components:
|
||||
- ZLOTA
|
||||
- WOTA
|
||||
- LLOTA
|
||||
- WWTOTA
|
||||
- Towers
|
||||
- Tiles
|
||||
- Cluster
|
||||
- RBN
|
||||
@@ -790,7 +795,7 @@ components:
|
||||
- HEMA
|
||||
- WCA
|
||||
- MOTA
|
||||
- SIOTA
|
||||
- SiOTA
|
||||
- ARLHS
|
||||
- ILLW
|
||||
- ZLOTA
|
||||
@@ -799,11 +804,12 @@ components:
|
||||
- WOTA
|
||||
- BOTA
|
||||
- LLOTA
|
||||
- WWTOTA
|
||||
- Towers
|
||||
- Tiles
|
||||
- WAB
|
||||
- WAI
|
||||
- TOTA
|
||||
- DME
|
||||
- Toilets
|
||||
example: POTA
|
||||
|
||||
SIGNameIncludingNoSIG:
|
||||
@@ -1373,6 +1379,16 @@ components:
|
||||
type: string
|
||||
description: The full name of the SIG
|
||||
example: Parks on the Air
|
||||
comment_names:
|
||||
type: array
|
||||
description: >
|
||||
Names by which this SIG may be referred to in cluster spot comments. Most SIGs have a
|
||||
single entry matching their programme name (e.g. ["POTA"]), but some have none (where
|
||||
the name is ambiguous with other SIGs, such as Tiles and Toilets on the Air) or multiple
|
||||
entries (e.g. WWBOTA accepts both "WWBOTA" and "BOTA" since the latter is often used).
|
||||
items:
|
||||
type: string
|
||||
example: [ "TOTA" ]
|
||||
ref_regex:
|
||||
type: string
|
||||
description: >
|
||||
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |