mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-02-04 17:24:30 +00:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
059d9364eb | ||
|
|
a3ca590ca3 | ||
|
|
cfff8dd832 | ||
|
|
d1a5bfe9c3 | ||
|
|
da2827f559 | ||
|
|
220c9378cf | ||
|
|
e1cdc5b857 | ||
|
|
5482da0e69 | ||
|
|
f31148686d | ||
|
|
a444be8fe9 | ||
|
|
3f117a47d6 | ||
|
|
06d582ae2d | ||
|
|
5bf45dba46 | ||
|
|
f4ae6b610e | ||
|
|
6af15e4cfd | ||
|
|
6d9bf3d4ec | ||
|
|
9b737a8176 | ||
|
|
05bc65337f | ||
|
|
d2c1dbb377 | ||
|
|
6cf1b38355 | ||
|
|
ac566553d8 | ||
|
|
bcc40d1416 | ||
|
|
2fead92dc5 | ||
|
|
e8ca488001 | ||
|
|
61fc0b9d0f | ||
|
|
70dc1b495c | ||
|
|
7fe478e040 | ||
|
|
926cf5caaf | ||
|
|
ae1caaa40f | ||
|
|
6116d19580 | ||
|
|
86beb27ebf | ||
|
|
d463403018 | ||
|
|
23a6e08777 | ||
|
|
61784e8af6 | ||
|
|
fd246fc17b | ||
|
|
fb935138a1 | ||
|
|
1f66da062b | ||
|
|
70a7bd4814 | ||
|
|
fd2986f310 | ||
|
|
befaceb2f5 | ||
|
|
81da836bae | ||
|
|
c95c6bb347 | ||
|
|
968576f74c | ||
|
|
2a5e8301af | ||
|
|
040ef3ec00 | ||
|
|
ac9e2ff054 | ||
|
|
6eaaca3a6f | ||
|
|
097c75eadd | ||
|
|
27db248398 | ||
|
|
b00b4130c5 | ||
|
|
b3be6b5ca4 | ||
|
|
210a0564aa | ||
|
|
03af6858b4 | ||
|
|
e86d6b8c28 | ||
|
|
9d130712d8 | ||
|
|
8a82f81ec4 | ||
|
|
ca31d23b4a | ||
|
|
8a4f23ac72 | ||
|
|
3da8c80ad6 | ||
|
|
0fa8b44c9c | ||
|
|
4aa7b91092 | ||
|
|
e7469db99e | ||
|
|
9d9f4609f0 | ||
|
|
368e69bf00 | ||
|
|
9bdd0ab1de | ||
|
|
255719f3b5 | ||
|
|
f21ea0ae5d | ||
|
|
2be2af176c | ||
|
|
0c8973bbc6 | ||
|
|
296cdb3795 |
90
README.md
90
README.md
@@ -16,11 +16,58 @@ Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), th
|
||||
|
||||

|
||||
|
||||
### Accessing the public version
|
||||
## Accessing the public version
|
||||
|
||||
You can access the public version's web interface at [https://spothole.app](https://spothole.app), and see [https://spothole.app/apidocs](https://spothole.app/apidocs) for the API details.
|
||||
|
||||
### Running your own copy
|
||||
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.
|
||||
|
||||
## Embedding Spothole in another website
|
||||
|
||||
You can embed Spothole 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. |
|
||||
| `dark-mode` | `true`, `false` | `false` | `?dark-mode=true` | Enables dark mode. |
|
||||
| `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. |
|
||||
|
||||
More will be added soon to allow customisation of filters and other display properties.
|
||||
|
||||
## 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. 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!
|
||||
|
||||
## 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.
|
||||
|
||||
To download and set up Spothole on a Debian server, run the following commands. Other operating systems will likely be similar.
|
||||
|
||||
@@ -34,7 +81,7 @@ 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.
|
||||
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.
|
||||
|
||||
`config.yml` has some entries for QRZ.com username & password, and Clublog API keys. If provided, these allow Spothole to retrieve more information about DX spots, such as the country their callsign corresponds to. The software will work just fine without them, but you may find a few country flags etc. are less accurate or missing.
|
||||
|
||||
@@ -57,6 +104,8 @@ If you see some errors on startup, check your configuration, e.g. in case you ha
|
||||
|
||||
### 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:
|
||||
|
||||
```
|
||||
@@ -87,7 +136,9 @@ Check the service has started up correctly with `sudo journalctl -u spothole -f`
|
||||
|
||||
### nginx Reverse Proxy configuration
|
||||
|
||||
It's best not to serve Spothole directly on port 80, as that requires root privileges and prevents us using HTTPS, amongst other reasons. 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`.
|
||||
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.
|
||||
|
||||
@@ -106,6 +157,8 @@ server {
|
||||
|
||||
location / {
|
||||
add_header Access-Control-Allow-Origin $xssorigin;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
}
|
||||
}
|
||||
@@ -135,17 +188,11 @@ You should now be able to access the web interface by going to the domain from y
|
||||
|
||||
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.
|
||||
|
||||
### Writing your own client
|
||||
## Modifying the source code
|
||||
|
||||
Various approaches exist to writing your own client, but in general:
|
||||
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.
|
||||
|
||||
* 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" API to get the data you want. 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 API.
|
||||
* Refer to the provided HTML/JS interface for a reference
|
||||
* Let me know if you get stuck, I'm happy to help!
|
||||
|
||||
### Structure of the source code
|
||||
### Code structure
|
||||
|
||||
To navigate your way around the source code, this list may help.
|
||||
|
||||
@@ -159,7 +206,7 @@ To navigate your way around the source code, this list may help.
|
||||
|
||||
*Templates*
|
||||
|
||||
* `/views` - Templates used for constructing Spothole's user-targeted HTML pages
|
||||
* `/templates` - Templates used for constructing Spothole's user-targeted HTML pages
|
||||
|
||||
*HTML/JS/CSS front-end code*
|
||||
|
||||
@@ -174,25 +221,28 @@ To navigate your way around the source code, this list may help.
|
||||
|
||||
* `/` - 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 provider, simply add a module to the `providers` 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.)
|
||||
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 "Provider"; if it operates by polling an HTTP Server on a timer, it can instead extend "HTTPProvider" where some of the work is done for you.
|
||||
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 `Provider` 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 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 `HTTPProvider` 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.
|
||||
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 `providers` section of `config.yml`, and your provider should be instantiated on startup.
|
||||
Finally, simply add the appropriate config to the `spot_providers` section of `config.yml`, and your provider should be instantiated on startup.
|
||||
|
||||
### Thanks
|
||||
The same approach as above is also used for alert providers.
|
||||
|
||||
## Thanks
|
||||
|
||||
As well as being my work, I have also gratefully received feature patches from Steven, M1SDH.
|
||||
|
||||
|
||||
@@ -15,10 +15,12 @@ class AlertProvider:
|
||||
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||
self.status = "Not Started" if self.enabled else "Disabled"
|
||||
self.alerts = None
|
||||
self.web_server = None
|
||||
|
||||
# Set up the provider, e.g. giving it the alert list to work from
|
||||
def setup(self, alerts):
|
||||
def setup(self, alerts, web_server):
|
||||
self.alerts = alerts
|
||||
self.web_server = web_server
|
||||
|
||||
# Start the provider. This should return immediately after spawning threads to access the remote resources
|
||||
def start(self):
|
||||
@@ -28,12 +30,20 @@ class AlertProvider:
|
||||
# because alerts could be created at any point for any time in the future. Rely on hashcode-based id matching
|
||||
# to deal with duplicates.
|
||||
def submit_batch(self, alerts):
|
||||
# Sort the batch so that earliest ones go in first. This helps keep the ordering correct when alerts are fired
|
||||
# off to SSE listeners.
|
||||
alerts = sorted(alerts, key=lambda alert: (alert.start_time if alert and alert.start_time else 0))
|
||||
for alert in alerts:
|
||||
# Fill in any blanks
|
||||
# Fill in any blanks and add to the list
|
||||
alert.infer_missing()
|
||||
# Add to the list, provided it heas not already expired.
|
||||
self.add_alert(alert)
|
||||
|
||||
def add_alert(self, alert):
|
||||
if not alert.expired():
|
||||
self.alerts.add(alert.id, alert, expire=MAX_ALERT_AGE)
|
||||
# Ping the web server in case we have any SSE connections that need to see this immediately
|
||||
if self.web_server:
|
||||
self.web_server.notify_new_alert(alert)
|
||||
|
||||
# Stop any threads and prepare for application shutdown
|
||||
def stop(self):
|
||||
|
||||
@@ -10,7 +10,7 @@ from data.sig_ref import SIGRef
|
||||
|
||||
# Alert provider for Beaches on the Air
|
||||
class BOTA(HTTPAlertProvider):
|
||||
POLL_INTERVAL_SEC = 3600
|
||||
POLL_INTERVAL_SEC = 1800
|
||||
ALERTS_URL = "https://www.beachesontheair.com/"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
|
||||
@@ -10,7 +10,7 @@ from data.alert import Alert
|
||||
|
||||
# Alert provider NG3K DXpedition list
|
||||
class NG3K(HTTPAlertProvider):
|
||||
POLL_INTERVAL_SEC = 3600
|
||||
POLL_INTERVAL_SEC = 1800
|
||||
ALERTS_URL = "https://www.ng3k.com/adxo.xml"
|
||||
AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE)
|
||||
|
||||
@@ -76,7 +76,6 @@ class NG3K(HTTPAlertProvider):
|
||||
dx_country=dx_country,
|
||||
freqs_modes=bands + (("; " + modes) if modes != "" else ""),
|
||||
comment=by + "; " + comment + "; " + qsl_info,
|
||||
icon="globe-africa",
|
||||
start_time=start_timestamp,
|
||||
end_time=end_timestamp,
|
||||
is_dxpedition=True)
|
||||
|
||||
@@ -10,7 +10,7 @@ from data.sig_ref import SIGRef
|
||||
|
||||
# Alert provider for Parks n Peaks
|
||||
class ParksNPeaks(HTTPAlertProvider):
|
||||
POLL_INTERVAL_SEC = 3600
|
||||
POLL_INTERVAL_SEC = 1800
|
||||
ALERTS_URL = "http://parksnpeaks.org/api/ALERTS/"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
|
||||
@@ -9,7 +9,7 @@ from data.sig_ref import SIGRef
|
||||
|
||||
# Alert provider for Parks on the Air
|
||||
class POTA(HTTPAlertProvider):
|
||||
POLL_INTERVAL_SEC = 3600
|
||||
POLL_INTERVAL_SEC = 1800
|
||||
ALERTS_URL = "https://api.pota.app/activation"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
|
||||
@@ -9,7 +9,7 @@ from data.sig_ref import SIGRef
|
||||
|
||||
# Alert provider for Summits on the Air
|
||||
class SOTA(HTTPAlertProvider):
|
||||
POLL_INTERVAL_SEC = 3600
|
||||
POLL_INTERVAL_SEC = 1800
|
||||
ALERTS_URL = "https://api-db2.sota.org.uk/api/alerts/365/all/all"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
@@ -20,13 +20,18 @@ class SOTA(HTTPAlertProvider):
|
||||
# Iterate through source data
|
||||
for source_alert in http_response.json():
|
||||
# Convert to our alert format
|
||||
details = source_alert["summitDetails"].split(", ")
|
||||
summit_name = details[0]
|
||||
summit_points = None
|
||||
if len(details) > 2:
|
||||
summit_points = int(details[-1].split(" ")[0])
|
||||
alert = Alert(source=self.name,
|
||||
source_id=source_alert["id"],
|
||||
dx_calls=[source_alert["activatingCallsign"].upper()],
|
||||
dx_names=[source_alert["activatorName"].upper()],
|
||||
freqs_modes=source_alert["frequency"],
|
||||
comment=source_alert["comments"],
|
||||
sig_refs=[SIGRef(id=source_alert["associationCode"] + "/" + source_alert["summitCode"], sig="SOTA", name=source_alert["summitDetails"])],
|
||||
sig_refs=[SIGRef(id=source_alert["associationCode"] + "/" + source_alert["summitCode"], sig="SOTA", name=summit_name, activation_score=summit_points)],
|
||||
start_time=datetime.strptime(source_alert["dateActivated"],
|
||||
"%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=pytz.UTC).timestamp(),
|
||||
is_dxpedition=False)
|
||||
|
||||
@@ -10,7 +10,7 @@ from data.sig_ref import SIGRef
|
||||
|
||||
# Alert provider for Wainwrights on the Air
|
||||
class WOTA(HTTPAlertProvider):
|
||||
POLL_INTERVAL_SEC = 3600
|
||||
POLL_INTERVAL_SEC = 1800
|
||||
ALERTS_URL = "https://www.wota.org.uk/alerts_rss.php"
|
||||
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from data.sig_ref import SIGRef
|
||||
|
||||
# Alert provider for Worldwide Flora and Fauna
|
||||
class WWFF(HTTPAlertProvider):
|
||||
POLL_INTERVAL_SEC = 3600
|
||||
POLL_INTERVAL_SEC = 1800
|
||||
ALERTS_URL = "https://spots.wwff.co/static/agendas.json"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
|
||||
@@ -59,39 +59,63 @@ spot-providers:
|
||||
enabled: true
|
||||
host: "hrd.wa9pie.net"
|
||||
port: 8000
|
||||
# Prompt the cluster node gives when asking for a callsign to log in. Varies between cluster node software.
|
||||
login_prompt: "login:"
|
||||
# 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: "DXCluster"
|
||||
name: "W3LPL Cluster"
|
||||
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"
|
||||
name: "RBN CW/RTTY"
|
||||
enabled: false
|
||||
port: 7000
|
||||
# This setting doesn't affect the spot provider itself, or anything in the back-end of Spothole, just the web UI.
|
||||
# By default spots from all enabled providers will be shown in the web UI. However, you might want RBN data to be
|
||||
# received by Spothole but not shown on the web UI unless the user explicitly turns it on. For that behaviour,
|
||||
# set enabled to true, but enabled-by-default-in-web-ui to false.
|
||||
enabled-by-default-in-web-ui: false
|
||||
-
|
||||
class: "RBN"
|
||||
name: "RBN FT8"
|
||||
enabled: false
|
||||
port: 7001
|
||||
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"
|
||||
name: "39C3 TOTA"
|
||||
enabled: false
|
||||
url: "https://39c3.c3nav.de/"
|
||||
# Fixed SIG/latitude/longitude for all spots from a provider is currently only a feature for the "XOTA" provider,
|
||||
url: "wss://dev.39c3.totawatch.de/api/spot/live"
|
||||
# Fixed SIG for all spots from a provider & location CSV are currently only a feature for the "XOTA" provider,
|
||||
# the software found at https://github.com/nischu/xOTA/. This is because this is a generic backend for xOTA
|
||||
# programmes and so different URLs provide different programmes.
|
||||
sig: "TOTA"
|
||||
latitude: 53.5622678
|
||||
longitude: 9.9855205
|
||||
locations-csv: "datafiles/39c3-tota.csv"
|
||||
|
||||
|
||||
# Alert providers to use. Same setup as the spot providers list above.
|
||||
|
||||
@@ -10,9 +10,10 @@ import pytz
|
||||
class CleanupTimer:
|
||||
|
||||
# Constructor
|
||||
def __init__(self, spots, alerts, cleanup_interval):
|
||||
def __init__(self, spots, alerts, web_server, cleanup_interval):
|
||||
self.spots = spots
|
||||
self.alerts = alerts
|
||||
self.web_server = web_server
|
||||
self.cleanup_interval = cleanup_interval
|
||||
self.cleanup_timer = None
|
||||
self.last_cleanup_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||
@@ -29,16 +30,30 @@ class CleanupTimer:
|
||||
# Perform cleanup and reschedule next timer
|
||||
def cleanup(self):
|
||||
try:
|
||||
# Perform cleanup
|
||||
# Perform cleanup via letting the data expire
|
||||
self.spots.expire()
|
||||
self.alerts.expire()
|
||||
|
||||
# Alerts can persist in the system for a while, so we want to explicitly clean up any alerts that have
|
||||
# expired
|
||||
# Explicitly clean up any spots and alerts that have expired
|
||||
for id in list(self.spots.iterkeys()):
|
||||
try:
|
||||
spot = self.spots[id]
|
||||
if spot.expired():
|
||||
self.spots.delete(id)
|
||||
except KeyError:
|
||||
# Must have already been deleted, OK with that
|
||||
pass
|
||||
for id in list(self.alerts.iterkeys()):
|
||||
try:
|
||||
alert = self.alerts[id]
|
||||
if alert.expired():
|
||||
self.alerts.delete(id)
|
||||
except KeyError:
|
||||
# Must have already been deleted, OK with that
|
||||
pass
|
||||
|
||||
# Clean up web server SSE spot/alert queues
|
||||
self.web_server.clean_up_sse_queues()
|
||||
|
||||
self.status = "OK"
|
||||
self.last_cleanup_time = datetime.now(pytz.UTC)
|
||||
|
||||
@@ -5,7 +5,8 @@ import yaml
|
||||
|
||||
# Check you have a config file
|
||||
if not os.path.isfile("config.yml"):
|
||||
logging.error("Your config file is missing. Ensure you have copied config-example.yml to config.yml and updated it according to your needs.")
|
||||
logging.error(
|
||||
"Your config file is missing. Ensure you have copied config-example.yml to config.yml and updated it according to your needs.")
|
||||
exit()
|
||||
|
||||
# Load config
|
||||
@@ -18,3 +19,8 @@ SERVER_OWNER_CALLSIGN = config["server-owner-callsign"]
|
||||
WEB_SERVER_PORT = config["web-server-port"]
|
||||
ALLOW_SPOTTING = config["allow-spotting"]
|
||||
WEB_UI_OPTIONS = config["web-ui-options"]
|
||||
|
||||
# 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.
|
||||
WEB_UI_OPTIONS["spot-providers-enabled-by-default"] = [p["name"] for p in config["spot-providers"] if p["enabled"] and (
|
||||
"enabled-by-default-in-web-ui" not in p or p["enabled-by-default-in-web-ui"] == True)]
|
||||
|
||||
@@ -4,7 +4,7 @@ from data.sig import SIG
|
||||
|
||||
# General software
|
||||
SOFTWARE_NAME = "Spothole by M0TRT"
|
||||
SOFTWARE_VERSION = "1.1-pre"
|
||||
SOFTWARE_VERSION = "1.1"
|
||||
|
||||
# HTTP headers used for spot providers that use HTTP
|
||||
HTTP_HEADERS = {"User-Agent": SOFTWARE_NAME + ", v" + SOFTWARE_VERSION + " (operated by " + SERVER_OWNER_CALLSIGN + ")"}
|
||||
@@ -12,63 +12,63 @@ HAMQTH_PRG = (SOFTWARE_NAME + " v" + SOFTWARE_VERSION + " operated by " + SERVER
|
||||
|
||||
# Special Interest Groups
|
||||
SIGS = [
|
||||
SIG(name="POTA", description="Parks on the Air", icon="tree", ref_regex=r"[A-Z]{2}\-\d{4,5}"),
|
||||
SIG(name="SOTA", description="Summits on the Air", icon="mountain-sun", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
|
||||
SIG(name="WWFF", description="World Wide Flora & Fauna", icon="seedling", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"),
|
||||
SIG(name="GMA", description="Global Mountain Activity", icon="person-hiking", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
|
||||
SIG(name="WWBOTA", description="Worldwide Bunkers on the Air", icon="radiation", ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}"),
|
||||
SIG(name="HEMA", description="HuMPs Excluding Marilyns Award", icon="mound", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}"),
|
||||
SIG(name="IOTA", description="Islands on the Air", icon="umbrella-beach", ref_regex=r"[A-Z]{2}\-\d{3}"),
|
||||
SIG(name="MOTA", description="Mills on the Air", icon="fan", ref_regex=r"X\d{4-6}"),
|
||||
SIG(name="ARLHS", description="Amateur Radio Lighthouse Society", icon="tower-observation", ref_regex=r"[A-Z]{3}\-\d{3,4}"),
|
||||
SIG(name="ILLW", description="International Lighthouse & Lightship Weekend", icon="tower-observation", ref_regex=r"[A-Z]{2}\d{4}"),
|
||||
SIG(name="SIOTA", description="Silos on the Air", icon="wheat-awn", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"),
|
||||
SIG(name="WCA", description="World Castles Award", icon="chess-rook", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"),
|
||||
SIG(name="ZLOTA", description="New Zealand on the Air", icon="kiwi-bird", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}"),
|
||||
SIG(name="WOTA", description="Wainwrights on the Air", icon="w", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
|
||||
SIG(name="BOTA", description="Beaches on the Air", icon="water"),
|
||||
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award", icon="earth-oceania"),
|
||||
SIG(name="WAB", description="Worked All Britain", icon="table-cells-large", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
|
||||
SIG(name="WAI", description="Worked All Ireland", icon="table-cells-large", ref_regex=r"[A-Z][0-9]{2}"),
|
||||
SIG(name="TOTA", description="Toilets on the Air", icon="toilet", ref_regex=r"T\-[0-9]{2}")
|
||||
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="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}")
|
||||
]
|
||||
|
||||
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
|
||||
CW_MODES = ["CW"]
|
||||
PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"]
|
||||
DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "BPSK", "PSK", "PSK31", "BPSK31", "OLIVIA", "MFSK", "MFSK32", "PKT"]
|
||||
DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "BPSK", "PSK", "PSK31", "BPSK31", "OLIVIA", "MFSK", "MFSK32", "PKT", "MSK144"]
|
||||
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
|
||||
MODE_TYPES = ["CW", "PHONE", "DATA"]
|
||||
|
||||
# Band definitions
|
||||
BANDS = [
|
||||
Band(name="2200m", start_freq=135700, end_freq=137800, color="#ff4500", contrast_color="white"),
|
||||
Band(name="600m", start_freq=472000, end_freq=479000, color="#1e90ff", contrast_color="white"),
|
||||
Band(name="160m", start_freq=1800000, end_freq=2000000, color="#7cfc00", contrast_color="black"),
|
||||
Band(name="80m", start_freq=3500000, end_freq=4000000, color="#e550e5", contrast_color="black"),
|
||||
Band(name="60m", start_freq=5250000, end_freq=5410000, color="#00008b", contrast_color="white"),
|
||||
Band(name="40m", start_freq=7000000, end_freq=7300000, color="#5959ff", contrast_color="white"),
|
||||
Band(name="30m", start_freq=10100000, end_freq=10150000, color="#62d962", contrast_color="black"),
|
||||
Band(name="20m", start_freq=14000000, end_freq=14350000, color="#f2c40c", contrast_color="black"),
|
||||
Band(name="17m", start_freq=18068000, end_freq=18168000, color="#f2f261", contrast_color="black"),
|
||||
Band(name="15m", start_freq=21000000, end_freq=21450000, color="#cca166", contrast_color="black"),
|
||||
Band(name="12m", start_freq=24890000, end_freq=24990000, color="#b22222", contrast_color="white"),
|
||||
Band(name="11m", start_freq=26965000, end_freq=27405000, color="#00ff00", contrast_color="black"),
|
||||
Band(name="10m", start_freq=28000000, end_freq=29700000, color="#ff69b4", contrast_color="black"),
|
||||
Band(name="6m", start_freq=50000000, end_freq=54000000, color="#FF0000", contrast_color="white"),
|
||||
Band(name="5m", start_freq=56000000, end_freq=60500000, color="#e0e0e0", contrast_color="black"),
|
||||
Band(name="4m", start_freq=70000000, end_freq=70500000, color="#cc0044", contrast_color="white"),
|
||||
Band(name="2m", start_freq=144000000, end_freq=148000000, color="#FF1493", contrast_color="black"),
|
||||
Band(name="1.25m", start_freq=219000000, end_freq=225000000, color="#CCFF00", contrast_color="black"),
|
||||
Band(name="70cm", start_freq=420000000, end_freq=450000000, color="#999900", contrast_color="white"),
|
||||
Band(name="23cm", start_freq=1240000000, end_freq=1325000000, color="#5AB8C7", contrast_color="black"),
|
||||
Band(name="2.4GHz", start_freq=2300000000, end_freq=2450000000, color="#FF7F50", contrast_color="black"),
|
||||
Band(name="5.8GHz", start_freq=5725000000, end_freq=5850000000, color="#cc0099", contrast_color="white"),
|
||||
Band(name="10GHz", start_freq=10000000000, end_freq=10500000000, color="#696969", contrast_color="white"),
|
||||
Band(name="24GHz", start_freq=24000000000, end_freq=24050000000, color="#f3edc6", contrast_color="black"),
|
||||
Band(name="47GHz", start_freq=47000000000, end_freq=47200000000, color="#ffe786", contrast_color="black"),
|
||||
Band(name="76GHz", start_freq=75500000000, end_freq=81500000000, color="#baf9d8", contrast_color="black")]
|
||||
UNKNOWN_BAND = Band(name="Unknown", start_freq=0, end_freq=0, color="black", contrast_color="white")
|
||||
Band(name="2200m", start_freq=135700, end_freq=137800),
|
||||
Band(name="600m", start_freq=472000, end_freq=479000),
|
||||
Band(name="160m", start_freq=1800000, end_freq=2000000),
|
||||
Band(name="80m", start_freq=3500000, end_freq=4000000),
|
||||
Band(name="60m", start_freq=5250000, end_freq=5410000),
|
||||
Band(name="40m", start_freq=7000000, end_freq=7300000),
|
||||
Band(name="30m", start_freq=10100000, end_freq=10150000),
|
||||
Band(name="20m", start_freq=14000000, end_freq=14350000),
|
||||
Band(name="17m", start_freq=18068000, end_freq=18168000),
|
||||
Band(name="15m", start_freq=21000000, end_freq=21450000),
|
||||
Band(name="12m", start_freq=24890000, end_freq=24990000),
|
||||
Band(name="11m", start_freq=26965000, end_freq=27405000),
|
||||
Band(name="10m", start_freq=28000000, end_freq=29700000),
|
||||
Band(name="6m", start_freq=50000000, end_freq=54000000),
|
||||
Band(name="5m", start_freq=56000000, end_freq=60500000),
|
||||
Band(name="4m", start_freq=70000000, end_freq=70500000),
|
||||
Band(name="2m", start_freq=144000000, end_freq=148000000),
|
||||
Band(name="1.25m", start_freq=219000000, end_freq=225000000),
|
||||
Band(name="70cm", start_freq=420000000, end_freq=450000000),
|
||||
Band(name="23cm", start_freq=1240000000, end_freq=1325000000),
|
||||
Band(name="13cm", start_freq=2300000000, end_freq=2450000000),
|
||||
Band(name="5.8GHz", start_freq=5725000000, end_freq=5850000000),
|
||||
Band(name="10GHz", start_freq=10000000000, end_freq=10500000000),
|
||||
Band(name="24GHz", start_freq=24000000000, end_freq=24050000000),
|
||||
Band(name="47GHz", start_freq=47000000000, end_freq=47200000000),
|
||||
Band(name="76GHz", start_freq=75500000000, end_freq=81500000000)]
|
||||
UNKNOWN_BAND = Band(name="Unknown", start_freq=0, end_freq=0)
|
||||
|
||||
# Continents
|
||||
CONTINENTS = ["EU", "NA", "SA", "AS", "AF", "OC", "AN"]
|
||||
|
||||
@@ -358,10 +358,10 @@ class LookupHelper:
|
||||
# Coordinates that look default are rejected (apologies if your position really is 0,0, enjoy your voyage)
|
||||
def infer_latlon_from_callsign_online_lookup(self, call):
|
||||
data = self.get_qrz_data_for_callsign(call)
|
||||
if data and "latitude" in data and "longitude" in data and (data["latitude"] != 0 or data["longitude"] != 0):
|
||||
if data and "latitude" in data and "longitude" in data and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float(data["latitude"]) < 89.9:
|
||||
return [data["latitude"], data["longitude"]]
|
||||
data = self.get_hamqth_data_for_callsign(call)
|
||||
if data and "latitude" in data and "longitude" in data and (data["latitude"] != 0 or data["longitude"] != 0):
|
||||
if data and "latitude" in data and "longitude" in data and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float(data["latitude"]) < 89.9:
|
||||
return [data["latitude"], data["longitude"]]
|
||||
else:
|
||||
return None
|
||||
@@ -418,7 +418,20 @@ class LookupHelper:
|
||||
# Infer a mode from the frequency (in Hz) according to the band plan. Just a guess really.
|
||||
def infer_mode_from_frequency(self, freq):
|
||||
try:
|
||||
return freq_to_band(freq / 1000.0)["mode"]
|
||||
khz = freq / 1000.0
|
||||
mode = freq_to_band(khz)["mode"]
|
||||
# Some additional common digimode ranges in addition to what the 3rd-party freq_to_band function returns.
|
||||
# This is mostly here just because freq_to_band is very specific about things like FT8 frequencies, and e.g.
|
||||
# a spot at 7074.5 kHz will be indicated as LSB, even though it's clearly in the FT8 range. Future updates
|
||||
# might include other common digimode centres of activity here, but this achieves the main goal of keeping
|
||||
# large numbers of clearly-FT* spots off the list of people filtering out digimodes.
|
||||
if (7074 <= khz < 7077) or (10136 <= khz < 10139) or (14074 <= khz < 14077) or (18100 <= khz < 18103) or (
|
||||
21074 <= khz < 21077) or (24915 <= khz < 24918) or (28074 <= khz < 28077):
|
||||
mode = "FT8"
|
||||
if (7047.5 <= khz < 7050.5) or (10140 <= khz < 10143) or (14080 <= khz < 14083) or (
|
||||
18104 <= khz < 18107) or (21140 <= khz < 21143) or (24919 <= khz < 24922) or (28180 <= khz < 28183):
|
||||
mode = "FT4"
|
||||
return mode
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@@ -442,6 +455,11 @@ class LookupHelper:
|
||||
# QRZ had no info for the call, that's OK. Cache a None so we don't try to look this up again
|
||||
self.QRZ_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
|
||||
return None
|
||||
except (Exception):
|
||||
# General exception like a timeout when communicating with QRZ. Return None this time, but don't cache
|
||||
# that, so we can try again next time.
|
||||
logging.error("Exception when looking up QRZ data")
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
from bottle import response
|
||||
from prometheus_client import CollectorRegistry, generate_latest, CONTENT_TYPE_LATEST, Counter, disable_created_metrics, \
|
||||
Gauge
|
||||
from prometheus_client import CollectorRegistry, generate_latest, Counter, disable_created_metrics, Gauge
|
||||
|
||||
disable_created_metrics()
|
||||
# Prometheus metrics registry
|
||||
@@ -33,8 +31,6 @@ memory_use_gauge = Gauge(
|
||||
)
|
||||
|
||||
|
||||
# Get a Prometheus metrics response for Bottle
|
||||
# Get a Prometheus metrics response for the web server
|
||||
def get_metrics():
|
||||
response.content_type = CONTENT_TYPE_LATEST
|
||||
response.status = 200
|
||||
return generate_latest(registry)
|
||||
|
||||
@@ -6,15 +6,6 @@ from pyhamtools.locator import latlong_to_locator
|
||||
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
|
||||
from data.sig_ref import SIGRef
|
||||
|
||||
|
||||
# Utility function to get the icon for a named SIG. If no match is found, the "circle-question" icon will be returned.
|
||||
def get_icon_for_sig(sig):
|
||||
for s in SIGS:
|
||||
if s.name == sig:
|
||||
return s.icon
|
||||
return "circle-question"
|
||||
|
||||
|
||||
# Utility function to get the regex string for a SIG reference for a named SIG. If no match is found, None will be returned.
|
||||
@@ -25,46 +16,52 @@ def get_ref_regex_for_sig(sig):
|
||||
return None
|
||||
|
||||
|
||||
# Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid.
|
||||
# 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.
|
||||
# Note there is currently no support for KRMNPA location lookup, see issue #61.
|
||||
def get_sig_ref_info(sig, sig_ref_id):
|
||||
sig_ref = SIGRef(id=sig_ref_id, sig=sig)
|
||||
def populate_sig_ref_info(sig_ref):
|
||||
if sig_ref.sig is None or sig_ref.id is None:
|
||||
logging.warn("Failed to look up sig_ref info, sig or id were not set.")
|
||||
|
||||
sig = sig_ref.sig
|
||||
ref_id = sig_ref.id
|
||||
try:
|
||||
if sig.upper() == "POTA":
|
||||
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api.pota.app/park/" + sig_ref_id, headers=HTTP_HEADERS).json()
|
||||
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api.pota.app/park/" + ref_id, headers=HTTP_HEADERS).json()
|
||||
if data:
|
||||
fullname = data["name"] if "name" in data else None
|
||||
if fullname and "parktypeDesc" in data and data["parktypeDesc"] != "":
|
||||
fullname = fullname + " " + data["parktypeDesc"]
|
||||
sig_ref.name = fullname
|
||||
sig_ref.url = "https://pota.app/#/park/" + sig_ref_id
|
||||
sig_ref.url = "https://pota.app/#/park/" + ref_id
|
||||
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 sig.upper() == "SOTA":
|
||||
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api-db2.sota.org.uk/api/summits/" + sig_ref_id,
|
||||
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api-db2.sota.org.uk/api/summits/" + ref_id,
|
||||
headers=HTTP_HEADERS).json()
|
||||
if data:
|
||||
sig_ref.name = data["name"] if "name" in data else None
|
||||
sig_ref.url = "https://www.sotadata.org.uk/en/summit/" + sig_ref_id
|
||||
sig_ref.url = "https://www.sotadata.org.uk/en/summit/" + 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
|
||||
sig_ref.activation_score = data["points"] if "points" in data else None
|
||||
elif sig.upper() == "WWBOTA":
|
||||
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + sig_ref_id,
|
||||
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + ref_id,
|
||||
headers=HTTP_HEADERS).json()
|
||||
if data:
|
||||
sig_ref.name = data["name"] if "name" in data else None
|
||||
sig_ref.url = "https://bunkerwiki.org/?s=" + sig_ref_id if sig_ref_id.startswith("B/G") else None
|
||||
sig_ref.url = "https://bunkerwiki.org/?s=" + ref_id if ref_id.startswith("B/G") else None
|
||||
sig_ref.grid = data["locator"] if "locator" in data else None
|
||||
sig_ref.latitude = data["lat"] if "lat" in data else None
|
||||
sig_ref.longitude = data["long"] if "long" in data else None
|
||||
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/?" + sig_ref_id,
|
||||
data = SEMI_STATIC_URL_DATA_CACHE.get("https://www.cqgma.org/api/ref/?" + ref_id,
|
||||
headers=HTTP_HEADERS).json()
|
||||
if data:
|
||||
sig_ref.name = data["name"] if "name" in data else None
|
||||
sig_ref.url = "https://www.cqgma.org/zinfo.php?ref=" + sig_ref_id
|
||||
sig_ref.url = "https://www.cqgma.org/zinfo.php?ref=" + ref_id
|
||||
sig_ref.grid = data["locator"] if "locator" in data else None
|
||||
sig_ref.latitude = data["latitude"] if "latitude" in data else None
|
||||
sig_ref.longitude = data["longitude"] if "longitude" in data else None
|
||||
@@ -73,9 +70,9 @@ def get_sig_ref_info(sig, sig_ref_id):
|
||||
headers=HTTP_HEADERS)
|
||||
wwff_dr = csv.DictReader(wwff_csv_data.content.decode().splitlines())
|
||||
for row in wwff_dr:
|
||||
if row["reference"] == sig_ref_id:
|
||||
if row["reference"] == ref_id:
|
||||
sig_ref.name = row["name"] if "name" in row else None
|
||||
sig_ref.url = "https://wwff.co/directory/?showRef=" + sig_ref_id
|
||||
sig_ref.url = "https://wwff.co/directory/?showRef=" + ref_id
|
||||
sig_ref.grid = row["iaruLocator"] if "iaruLocator" in row else None
|
||||
sig_ref.latitude = float(row["latitude"]) if "latitude" in row else None
|
||||
sig_ref.longitude = float(row["longitude"]) if "longitude" in row else None
|
||||
@@ -85,7 +82,7 @@ def get_sig_ref_info(sig, sig_ref_id):
|
||||
headers=HTTP_HEADERS)
|
||||
siota_dr = csv.DictReader(siota_csv_data.content.decode().splitlines())
|
||||
for row in siota_dr:
|
||||
if row["SILO_CODE"] == sig_ref_id:
|
||||
if row["SILO_CODE"] == ref_id:
|
||||
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
|
||||
@@ -96,13 +93,13 @@ def get_sig_ref_info(sig, sig_ref_id):
|
||||
headers=HTTP_HEADERS).json()
|
||||
if data:
|
||||
for feature in data["features"]:
|
||||
if feature["properties"]["wotaId"] == sig_ref_id:
|
||||
if feature["properties"]["wotaId"] == ref_id:
|
||||
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
|
||||
sig_ref.url = "https://www.wota.org.uk/MM_" + sig_ref_id
|
||||
if sig_ref_id.upper().startswith("LDO-"):
|
||||
number = int(sig_ref_id.upper().replace("LDO-", ""))
|
||||
sig_ref.url = "https://www.wota.org.uk/MM_" + ref_id
|
||||
if ref_id.upper().startswith("LDO-"):
|
||||
number = int(ref_id.upper().replace("LDO-", ""))
|
||||
sig_ref.url = "https://www.wota.org.uk/MM_LDO-" + str(number + 214)
|
||||
sig_ref.grid = feature["properties"]["qthLocator"]
|
||||
sig_ref.latitude = feature["geometry"]["coordinates"][1]
|
||||
@@ -112,9 +109,9 @@ def get_sig_ref_info(sig, sig_ref_id):
|
||||
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"] == sig_ref_id:
|
||||
if asset["code"] == ref_id:
|
||||
sig_ref.name = asset["name"]
|
||||
sig_ref.url = "https://ontheair.nz/assets/ZLI_OT-030" + sig_ref_id.replace("/", "_")
|
||||
sig_ref.url = "https://ontheair.nz/assets/ZLI_OT-030" + ref_id.replace("/", "_")
|
||||
sig_ref.grid = latlong_to_locator(asset["y"], asset["x"], 6)
|
||||
sig_ref.latitude = asset["y"]
|
||||
sig_ref.longitude = asset["x"]
|
||||
@@ -124,14 +121,14 @@ def get_sig_ref_info(sig, sig_ref_id):
|
||||
sig_ref.name = sig_ref.id
|
||||
sig_ref.url = "https://www.beachesontheair.com/beaches/" + sig_ref.name.lower().replace(" ", "-")
|
||||
elif sig.upper() == "WAB" or sig.upper() == "WAI":
|
||||
ll = wab_wai_square_to_lat_lon(sig_ref_id)
|
||||
ll = wab_wai_square_to_lat_lon(ref_id)
|
||||
if ll:
|
||||
sig_ref.name = sig_ref_id
|
||||
sig_ref.name = ref_id
|
||||
sig_ref.grid = latlong_to_locator(ll[0], ll[1], 6)
|
||||
sig_ref.latitude = ll[0]
|
||||
sig_ref.longitude = ll[1]
|
||||
except:
|
||||
logging.warn("Failed to look up sig_ref info for " + sig + " ref " + sig_ref_id + ".")
|
||||
logging.warn("Failed to look up sig_ref info for " + sig + " ref " + ref_id + ".")
|
||||
return sig_ref
|
||||
|
||||
|
||||
|
||||
@@ -58,13 +58,17 @@ class StatusReporter:
|
||||
self.status_data["cleanup"] = {"status": self.cleanup_timer.status,
|
||||
"last_ran": self.cleanup_timer.last_cleanup_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if self.cleanup_timer.last_cleanup_time else 0}
|
||||
self.status_data["webserver"] = {"status": self.web_server.status,
|
||||
"last_api_access": self.web_server.last_api_access_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if self.web_server.last_api_access_time else 0,
|
||||
"api_access_count": self.web_server.api_access_counter,
|
||||
"last_page_access": self.web_server.last_page_access_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if self.web_server.last_page_access_time else 0,
|
||||
"page_access_count": self.web_server.page_access_counter}
|
||||
self.status_data["webserver"] = {"status": self.web_server.web_server_metrics["status"],
|
||||
"last_api_access": self.web_server.web_server_metrics[
|
||||
"last_api_access_time"].replace(
|
||||
tzinfo=pytz.UTC).timestamp() if self.web_server.web_server_metrics[
|
||||
"last_api_access_time"] else 0,
|
||||
"api_access_count": self.web_server.web_server_metrics["api_access_counter"],
|
||||
"last_page_access": self.web_server.web_server_metrics[
|
||||
"last_page_access_time"].replace(
|
||||
tzinfo=pytz.UTC).timestamp() if self.web_server.web_server_metrics[
|
||||
"last_page_access_time"] else 0,
|
||||
"page_access_count": self.web_server.web_server_metrics["page_access_counter"]}
|
||||
|
||||
# Update Prometheus metrics
|
||||
memory_use_gauge.set(psutil.Process(os.getpid()).memory_info().rss * 1024)
|
||||
|
||||
5
core/utils.py
Normal file
5
core/utils.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# Convert objects to serialisable things. Used by JSON serialiser as a default when it encounters unserializable things.
|
||||
# Just converts objects to dict. Try to avoid doing anything clever here when serialising spots, because we also need
|
||||
# to receive spots without complex handling.
|
||||
def serialize_everything(obj):
|
||||
return obj.__dict__
|
||||
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
|
||||
import pytz
|
||||
|
||||
from core.lookup_helper import lookup_helper
|
||||
from core.sig_utils import get_icon_for_sig, get_sig_ref_info
|
||||
from core.sig_utils import populate_sig_ref_info
|
||||
|
||||
|
||||
# Data class that defines an alert.
|
||||
@@ -53,10 +53,6 @@ class Alert:
|
||||
sig: str = None
|
||||
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
||||
sig_refs: list = None
|
||||
# Activation score. SOTA only
|
||||
activation_score: int = None
|
||||
# Icon, from the Font Awesome set. This is fairly opinionated but is here to help the alerthole web UI and Field alertter. Does not include the "fa-" prefix.
|
||||
icon: str = None
|
||||
# Whether this alert is for a DXpedition, as opposed to e.g. an xOTA programme.
|
||||
is_dxpedition: bool = False
|
||||
# Where we got the alert from, e.g. "POTA", "SOTA"...
|
||||
@@ -102,20 +98,13 @@ class Alert:
|
||||
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
|
||||
if self.sig_refs and len(self.sig_refs) > 0:
|
||||
for sig_ref in self.sig_refs:
|
||||
lookup_data = get_sig_ref_info(sig_ref.sig, sig_ref.id)
|
||||
if lookup_data:
|
||||
# Update the sig_ref data from the lookup
|
||||
sig_ref.__dict__.update(lookup_data.__dict__)
|
||||
populate_sig_ref_info(sig_ref)
|
||||
|
||||
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
|
||||
# and apply it to the whole spot.
|
||||
if self.sig_refs and len(self.sig_refs) > 0 and not self.sig:
|
||||
self.sig = self.sig_refs[0].sig
|
||||
|
||||
# Icon from SIG
|
||||
if self.sig and not self.icon:
|
||||
self.icon = get_icon_for_sig(self.sig)
|
||||
|
||||
# DX operator details lookup, using QRZ.com. This should be the last resort compared to taking the data from
|
||||
# the actual alertting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon instead of
|
||||
# the one from the park reference they're at.
|
||||
@@ -137,7 +126,7 @@ class Alert:
|
||||
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
|
||||
|
||||
# Decide if this alert has expired (in which case it should not be added to the system in the first place, and not
|
||||
# returned by the web server if later requested, and removed by the cleanup functions. "Expired" is defined as
|
||||
# returned by the web server if later requested, and removed by the cleanup functions). "Expired" is defined as
|
||||
# either having an end_time in the past, or if it only has a start_time, then that start time was more than 3 hours
|
||||
# ago. If it somehow doesn't have a start_time either, it is considered to be expired.
|
||||
def expired(self):
|
||||
|
||||
@@ -9,7 +9,3 @@ class Band:
|
||||
start_freq: float
|
||||
# Stop frequency, in Hz
|
||||
end_freq: float
|
||||
# Colour to use for this band, as per PSK Reporter
|
||||
color: str
|
||||
# Contrast colour to use for text against a background of the band colour
|
||||
contrast_color: str
|
||||
@@ -7,8 +7,5 @@ class SIG:
|
||||
name: str
|
||||
# Description, e.g. "Parks on the Air"
|
||||
description: str
|
||||
# Icon to use for it, from the Font Awesome set. This is fairly opinionated but is here to help the Spothole web UI
|
||||
# and Field Spotter. Does not include the "fa-" prefix.
|
||||
icon: str
|
||||
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
|
||||
ref_regex: str = None
|
||||
@@ -18,3 +18,5 @@ class SIGRef:
|
||||
longitude: float = None
|
||||
# Maidenhead grid reference of the reference, if known.
|
||||
grid: str = None
|
||||
# Activation score. SOTA only
|
||||
activation_score: int = None
|
||||
60
data/spot.py
60
data/spot.py
@@ -4,13 +4,14 @@ import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytz
|
||||
from pyhamtools.locator import locator_to_latlong, latlong_to_locator
|
||||
|
||||
from core.config import MAX_SPOT_AGE
|
||||
from core.lookup_helper import lookup_helper
|
||||
from core.sig_utils import get_icon_for_sig, get_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
|
||||
from data.sig_ref import SIGRef
|
||||
|
||||
|
||||
@@ -105,18 +106,6 @@ class Spot:
|
||||
sig: str = None
|
||||
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
||||
sig_refs: list = None
|
||||
# Activation score. SOTA only
|
||||
activation_score: int = None
|
||||
|
||||
# Display guidance (optional)
|
||||
|
||||
# Icon, from the Font Awesome set. This is fairly opinionated but is here to help the Spothole web UI and Field
|
||||
# Spotter. Does not include the "fa-" prefix.
|
||||
icon: str = None
|
||||
# Colour to represent this spot, if a client chooses to colour spots based on their frequency band, using PSK
|
||||
# Reporter's default colours. HTML colour e.g. hex. A contrast colour is also provided which will be black or white.
|
||||
band_color: str = None
|
||||
band_contrast_color: str = None
|
||||
|
||||
# Timing info
|
||||
|
||||
@@ -213,8 +202,6 @@ class Spot:
|
||||
if self.freq and not self.band:
|
||||
band = lookup_helper.infer_band_from_freq(self.freq)
|
||||
self.band = band.name
|
||||
self.band_color = band.color
|
||||
self.band_contrast_color = band.contrast_color
|
||||
|
||||
# Mode from comments or bandplan
|
||||
if self.mode:
|
||||
@@ -235,8 +222,8 @@ class Spot:
|
||||
if self.mode and not self.mode_type:
|
||||
self.mode_type = lookup_helper.infer_mode_type_from_mode(self.mode)
|
||||
|
||||
# If we have a latitude at this point, it can only have been provided by the spot itself
|
||||
if self.dx_latitude:
|
||||
# If we have a latitude or grid at this point, it can only have been provided by the spot itself
|
||||
if self.dx_latitude or self.dx_grid:
|
||||
self.dx_location_source = "SPOT"
|
||||
|
||||
# Set the top-level "SIG" if it is missing but we have at least one SIG ref.
|
||||
@@ -245,9 +232,11 @@ class Spot:
|
||||
|
||||
# See if we already have a SIG reference, but the comment looks like it contains more for the same SIG. This
|
||||
# should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002".
|
||||
if self.comment and self.sig_refs and len(self.sig_refs) > 0:
|
||||
if self.comment and self.sig_refs and len(self.sig_refs) > 0 and self.sig_refs[0].sig:
|
||||
sig = self.sig_refs[0].sig.upper()
|
||||
all_comment_ref_matches = re.finditer(r"(^|\W)(" + get_ref_regex_for_sig(sig) + r")(^|\W)", self.comment, re.IGNORECASE)
|
||||
regex = get_ref_regex_for_sig(sig)
|
||||
if regex:
|
||||
all_comment_ref_matches = re.finditer(r"(^|\W)(" + regex + r")(^|\W)", self.comment, re.IGNORECASE)
|
||||
for ref_match in all_comment_ref_matches:
|
||||
self.append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig))
|
||||
|
||||
@@ -276,16 +265,13 @@ class Spot:
|
||||
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
|
||||
if self.sig_refs and len(self.sig_refs) > 0:
|
||||
for sig_ref in self.sig_refs:
|
||||
lookup_data = get_sig_ref_info(sig_ref.sig, sig_ref.id)
|
||||
if lookup_data:
|
||||
# Update the sig_ref data from the lookup
|
||||
sig_ref.__dict__.update(lookup_data.__dict__)
|
||||
sig_ref = populate_sig_ref_info(sig_ref)
|
||||
# If the spot itself doesn't have location yet, but the SIG ref does, extract it
|
||||
if lookup_data.grid and not self.dx_grid:
|
||||
self.dx_grid = lookup_data.grid
|
||||
if lookup_data.latitude and not self.dx_latitude:
|
||||
self.dx_latitude = lookup_data.latitude
|
||||
self.dx_longitude = lookup_data.longitude
|
||||
if sig_ref.grid and not self.dx_grid:
|
||||
self.dx_grid = sig_ref.grid
|
||||
if sig_ref.latitude and not self.dx_latitude:
|
||||
self.dx_latitude = sig_ref.latitude
|
||||
self.dx_longitude = sig_ref.longitude
|
||||
if self.sig == "WAB" or self.sig == "WAI":
|
||||
self.dx_location_source = "WAB/WAI GRID"
|
||||
else:
|
||||
@@ -296,10 +282,6 @@ class Spot:
|
||||
if self.sig_refs and len(self.sig_refs) > 0 and not self.sig:
|
||||
self.sig = self.sig_refs[0].sig
|
||||
|
||||
# Icon from SIG if we have one
|
||||
if self.sig:
|
||||
self.icon = get_icon_for_sig(self.sig)
|
||||
|
||||
# DX Grid to lat/lon and vice versa in case one is missing
|
||||
if self.dx_grid and not self.dx_latitude:
|
||||
ll = locator_to_latlong(self.dx_grid)
|
||||
@@ -348,7 +330,8 @@ class Spot:
|
||||
|
||||
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
|
||||
# is likely at home.
|
||||
self.dx_location_good = (self.dx_location_source == "SPOT" or self.dx_location_source == "SIG REF LOOKUP"
|
||||
self.dx_location_good = self.dx_latitude and self.dx_longitude and (
|
||||
self.dx_location_source == "SPOT" or self.dx_location_source == "SIG REF LOOKUP"
|
||||
or self.dx_location_source == "WAB/WAI GRID"
|
||||
or (self.dx_location_source == "HOME QTH" and not "/" in self.dx_call))
|
||||
|
||||
@@ -380,7 +363,7 @@ class Spot:
|
||||
self_copy.received_time_iso = ""
|
||||
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
|
||||
|
||||
# JSON serialise
|
||||
# JSON sspoterialise
|
||||
def to_json(self):
|
||||
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
|
||||
|
||||
@@ -396,3 +379,10 @@ class Spot:
|
||||
if sig_ref.id == new_sig_ref.id and sig_ref.sig == new_sig_ref.sig:
|
||||
return
|
||||
self.sig_refs.append(new_sig_ref)
|
||||
|
||||
# Decide if this spot has expired (in which case it should not be added to the system in the first place, and not
|
||||
# returned by the web server if later requested, and removed by the cleanup functions). "Expired" is defined as
|
||||
# either having a time further ago than the server's MAX_SPOT_AGE. If it somehow doesn't have a time either, it is
|
||||
# considered to be expired.
|
||||
def expired(self):
|
||||
return not self.time or self.time < (datetime.now(pytz.UTC) - timedelta(seconds=MAX_SPOT_AGE)).timestamp()
|
||||
18
datafiles/39c3-tota.csv
Normal file
18
datafiles/39c3-tota.csv
Normal file
@@ -0,0 +1,18 @@
|
||||
ref,lat,lon
|
||||
T-01,53.56278090617755,9.984341869295505
|
||||
T-02,53.562383404176416,9.98551893027115
|
||||
T-03,53.56170184391514,9.985416035619778
|
||||
T-04,53.562026534393176,9.986372919078974
|
||||
T-11,53.56284641242506,9.98475590239655
|
||||
T-12,53.562431705517035,9.98551675702443
|
||||
T-13,53.56223704898424,9.985774520335664
|
||||
T-14,53.5617893512591,9.986344302837976
|
||||
T-21,53.56284641242506,9.98475590239655
|
||||
T-22,53.56245816412497,9.985456089490567
|
||||
T-23,53.56199560857136,9.985636761412673
|
||||
T-24,53.5617893512591,9.986344302837976
|
||||
T-31,53.56247470064887,9.985611427551902
|
||||
T-32,53.5617893512591,9.986344302837976
|
||||
T-41,53.56245039134992,9.985486136112701
|
||||
T-91,53.56147934973529,9.984626806439744
|
||||
T-92,53.561396810300735,9.987553052152899
|
||||
|
@@ -1,5 +1,4 @@
|
||||
pyyaml~=6.0.3
|
||||
bottle~=0.13.4
|
||||
requests-cache~=1.2.1
|
||||
pyhamtools~=0.12.0
|
||||
telnetlib3~=2.0.8
|
||||
@@ -13,3 +12,6 @@ rss-parser~=2.1.1
|
||||
pyproj~=3.7.2
|
||||
prometheus_client~=0.23.1
|
||||
beautifulsoup4~=4.14.2
|
||||
websocket-client~=1.9.0
|
||||
tornado~=6.5.4
|
||||
tornado_eventsource~=3.0.0
|
||||
142
server/handlers/api/addspot.py
Normal file
142
server/handlers/api/addspot.py
Normal file
@@ -0,0 +1,142 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
import tornado
|
||||
|
||||
from core.config import ALLOW_SPOTTING, MAX_SPOT_AGE
|
||||
from core.constants import UNKNOWN_BAND
|
||||
from core.lookup_helper import lookup_helper
|
||||
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 data.spot import Spot
|
||||
|
||||
|
||||
# API request handler for /api/v1/spot (POST)
|
||||
class APISpotHandler(tornado.web.RequestHandler):
|
||||
def initialize(self, spots, web_server_metrics):
|
||||
self.spots = spots
|
||||
self.web_server_metrics = web_server_metrics
|
||||
|
||||
def post(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()
|
||||
|
||||
# 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.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
|
||||
# Reject if format not json
|
||||
if 'Content-Type' not in self.request.headers or self.request.headers.get('Content-Type') != "application/json":
|
||||
self.set_status(415)
|
||||
self.write(json.dumps("Error - request Content-Type must be application/json", default=serialize_everything))
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
|
||||
# Reject if request body is empty
|
||||
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.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
|
||||
# Read in the request body as JSON then convert to a Spot object
|
||||
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.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
|
||||
# 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.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.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
|
||||
# Reject if frequency not in a known band
|
||||
if lookup_helper.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.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
|
||||
# Reject if grid formatting incorrect
|
||||
if spot.dx_grid and not re.match(
|
||||
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.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
|
||||
# Reject if sig_ref format incorrect for sig
|
||||
if spot.sig and spot.sig_refs and len(spot.sig_refs) > 0 and spot.sig_refs[0].id and get_ref_regex_for_sig(
|
||||
spot.sig) and not re.match(get_ref_regex_for_sig(spot.sig), spot.sig_refs[0].id):
|
||||
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.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
|
||||
# infer missing data, and add it to our database.
|
||||
spot.source = "API"
|
||||
spot.infer_missing()
|
||||
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
|
||||
|
||||
self.write(json.dumps("OK", default=serialize_everything))
|
||||
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 - " + str(e), default=serialize_everything))
|
||||
self.set_status(500)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
179
server/handlers/api/alerts.py
Normal file
179
server/handlers/api/alerts.py
Normal file
@@ -0,0 +1,179 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from queue import Queue
|
||||
|
||||
import pytz
|
||||
import tornado
|
||||
import tornado_eventsource.handler
|
||||
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import serialize_everything
|
||||
|
||||
SSE_HANDLER_MAX_QUEUE_SIZE = 100
|
||||
SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
|
||||
|
||||
|
||||
# API request handler for /api/v1/alerts
|
||||
class APIAlertsHandler(tornado.web.RequestHandler):
|
||||
def initialize(self, alerts, web_server_metrics):
|
||||
self.alerts = alerts
|
||||
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()
|
||||
|
||||
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
||||
# reduce that to just the first entry, and convert bytes to string
|
||||
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
||||
|
||||
# Fetch all alerts matching the query
|
||||
data = get_alert_list_with_filters(self.alerts, query_params)
|
||||
self.write(json.dumps(data, default=serialize_everything))
|
||||
self.set_status(200)
|
||||
except ValueError as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Bad request - " + str(e), default=serialize_everything))
|
||||
self.set_status(400)
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Error - " + str(e), default=serialize_everything))
|
||||
self.set_status(500)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
|
||||
# API request handler for /api/v1/alerts/stream
|
||||
class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
||||
def initialize(self, sse_alert_queues, web_server_metrics):
|
||||
self.sse_alert_queues = sse_alert_queues
|
||||
self.web_server_metrics = web_server_metrics
|
||||
|
||||
# Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data
|
||||
def custom_headers(self):
|
||||
return {"Cache-Control": "no-store",
|
||||
"X-Accel-Buffering": "no"}
|
||||
|
||||
def open(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()
|
||||
|
||||
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
||||
# reduce that to just the first entry, and convert bytes to string
|
||||
self.query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
||||
|
||||
# Create a alert queue and add it to the web server's list. The web server will fill this when alerts arrive
|
||||
self.alert_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE)
|
||||
self.sse_alert_queues.append(self.alert_queue)
|
||||
|
||||
# Set up a timed callback to check if anything is in the queue
|
||||
self.heartbeat = tornado.ioloop.PeriodicCallback(self._callback, SSE_HANDLER_QUEUE_CHECK_INTERVAL)
|
||||
self.heartbeat.start()
|
||||
|
||||
except Exception as e:
|
||||
logging.warn("Exception when serving SSE socket", e)
|
||||
|
||||
# When the user closes the socket, empty our queue and remove it from the list so the server no longer fills it
|
||||
def close(self):
|
||||
try:
|
||||
if self.alert_queue in self.sse_alert_queues:
|
||||
self.sse_alert_queues.remove(self.alert_queue)
|
||||
self.alert_queue.empty()
|
||||
except:
|
||||
pass
|
||||
self.alert_queue = None
|
||||
super().close()
|
||||
|
||||
# Callback to check if anything has arrived in the queue, and if so send it to the client
|
||||
def _callback(self):
|
||||
try:
|
||||
if self.alert_queue:
|
||||
while not self.alert_queue.empty():
|
||||
alert = self.alert_queue.get()
|
||||
# If the new alert matches our param filters, send it to the client. If not, ignore it.
|
||||
if alert_allowed_by_query(alert, self.query_params):
|
||||
self.write_message(msg=json.dumps(alert, default=serialize_everything))
|
||||
|
||||
if self.alert_queue not in self.sse_alert_queues:
|
||||
logging.error("Web server cleared up a queue of an active connection!")
|
||||
self.close()
|
||||
except:
|
||||
logging.warn("Exception in SSE callback, connection will be closed.")
|
||||
self.close()
|
||||
|
||||
|
||||
|
||||
|
||||
# Utility method to apply filters to the overall alert list and return only a subset. Enables query parameters in
|
||||
# the main "alerts" GET call.
|
||||
def get_alert_list_with_filters(all_alerts, query):
|
||||
# Create a shallow copy of the alert list ordered by start time, then filter the list to reduce it only to alerts
|
||||
# that match the filter parameters in the query string. Finally, apply a limit to the number of alerts returned.
|
||||
# The list of query string filters is defined in the API docs.
|
||||
alert_ids = list(all_alerts.iterkeys())
|
||||
alerts = []
|
||||
for k in alert_ids:
|
||||
a = all_alerts.get(k)
|
||||
if a is not None:
|
||||
alerts.append(a)
|
||||
alerts = sorted(alerts, key=lambda alert: (alert.start_time if alert and alert.start_time else 0))
|
||||
alerts = list(filter(lambda alert: alert_allowed_by_query(alert, query), alerts))
|
||||
if "limit" in query.keys():
|
||||
alerts = alerts[:int(query.get("limit"))]
|
||||
return alerts
|
||||
|
||||
# Given URL query params and an alert, figure out if the alert "passes" the requested filters or is rejected. The list
|
||||
# of query parameters and their function is defined in the API docs.
|
||||
def alert_allowed_by_query(alert, query):
|
||||
for k in query.keys():
|
||||
match k:
|
||||
case "received_since":
|
||||
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC)
|
||||
if not alert.received_time or alert.received_time <= since:
|
||||
return False
|
||||
case "max_duration":
|
||||
max_duration = int(query.get(k))
|
||||
# Check the duration if end_time is provided. If end_time is not provided, assume the activation is
|
||||
# "short", i.e. it always passes this check. If dxpeditions_skip_max_duration_check is true and
|
||||
# the alert is a dxpedition, it also always passes the check.
|
||||
if alert.is_dxpedition and (bool(query.get(
|
||||
"dxpeditions_skip_max_duration_check")) if "dxpeditions_skip_max_duration_check" in query.keys() else False):
|
||||
continue
|
||||
if alert.end_time and alert.start_time and alert.end_time - alert.start_time > max_duration:
|
||||
return False
|
||||
case "source":
|
||||
sources = query.get(k).split(",")
|
||||
if not alert.source or alert.source not in sources:
|
||||
return False
|
||||
case "sig":
|
||||
# If a list of sigs is provided, the alert must have a sig and it must match one of them.
|
||||
# The special "sig" "NO_SIG", when supplied in the list, mathches alerts with no sig.
|
||||
sigs = query.get(k).split(",")
|
||||
include_no_sig = "NO_SIG" in sigs
|
||||
if not alert.sig and not include_no_sig:
|
||||
return False
|
||||
if alert.sig and alert.sig not in sigs:
|
||||
return False
|
||||
case "dx_continent":
|
||||
dxconts = query.get(k).split(",")
|
||||
if not alert.dx_continent or alert.dx_continent not in dxconts:
|
||||
return False
|
||||
case "dx_call_includes":
|
||||
dx_call_includes = query.get(k).strip()
|
||||
if not alert.dx_call or dx_call_includes.upper() not in alert.dx_call.upper():
|
||||
return False
|
||||
case "text_includes":
|
||||
text_includes = query.get(k).strip()
|
||||
if (not alert.dx_call or text_includes.upper() not in alert.dx_call.upper()) \
|
||||
and (not alert.comment or text_includes.upper() not in alert.comment.upper()) \
|
||||
and (not alert.freqs_modes or text_includes.upper() not in alert.freqs_modes.upper()):
|
||||
return False
|
||||
return True
|
||||
121
server/handlers/api/lookups.py
Normal file
121
server/handlers/api/lookups.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
import tornado
|
||||
|
||||
from core.constants import SIGS
|
||||
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 data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
|
||||
|
||||
# API request handler for /api/v1/lookup/call
|
||||
class APILookupCallHandler(tornado.web.RequestHandler):
|
||||
def initialize(self, web_server_metrics):
|
||||
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()
|
||||
|
||||
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
||||
# reduce that to just the first entry, and convert bytes to string
|
||||
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
||||
|
||||
# The "call" query param must exist and look like a callsign
|
||||
if "call" in query_params.keys():
|
||||
call = query_params.get("call").upper()
|
||||
if re.match(r"^[A-Z0-9/\-]*$", call):
|
||||
# Take the callsign, make a "fake spot" so we can run infer_missing() on it, then repack the
|
||||
# resulting data in the correct way for the API response.
|
||||
fake_spot = Spot(dx_call=call)
|
||||
fake_spot.infer_missing()
|
||||
data = {
|
||||
"call": call,
|
||||
"name": fake_spot.dx_name,
|
||||
"qth": fake_spot.dx_qth,
|
||||
"country": fake_spot.dx_country,
|
||||
"flag": fake_spot.dx_flag,
|
||||
"continent": fake_spot.dx_continent,
|
||||
"dxcc_id": fake_spot.dx_dxcc_id,
|
||||
"cq_zone": fake_spot.dx_cq_zone,
|
||||
"itu_zone": fake_spot.dx_itu_zone,
|
||||
"grid": fake_spot.dx_grid,
|
||||
"latitude": fake_spot.dx_latitude,
|
||||
"longitude": fake_spot.dx_longitude,
|
||||
"location_source": fake_spot.dx_location_source
|
||||
}
|
||||
self.write(json.dumps(data, default=serialize_everything))
|
||||
|
||||
else:
|
||||
self.write(json.dumps("Error - '" + call + "' does not look like a valid callsign.",
|
||||
default=serialize_everything))
|
||||
self.set_status(422)
|
||||
else:
|
||||
self.write(json.dumps("Error - call must be provided", default=serialize_everything))
|
||||
self.set_status(422)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Error - " + str(e), default=serialize_everything))
|
||||
self.set_status(500)
|
||||
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
|
||||
|
||||
# API request handler for /api/v1/lookup/sigref
|
||||
class APILookupSIGRefHandler(tornado.web.RequestHandler):
|
||||
def initialize(self, web_server_metrics):
|
||||
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()
|
||||
|
||||
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
||||
# reduce that to just the first entry, and convert bytes to string
|
||||
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
||||
|
||||
# "sig" and "id" query params must exist, SIG must be known, and if we have a reference regex for that SIG,
|
||||
# the provided id must match it.
|
||||
if "sig" in query_params.keys() and "id" in query_params.keys():
|
||||
sig = query_params.get("sig").upper()
|
||||
id = query_params.get("id").upper()
|
||||
if sig in list(map(lambda p: p.name, SIGS)):
|
||||
if not get_ref_regex_for_sig(sig) or re.match(get_ref_regex_for_sig(sig), id):
|
||||
data = populate_sig_ref_info(SIGRef(id=id, sig=sig))
|
||||
self.write(json.dumps(data, default=serialize_everything))
|
||||
|
||||
else:
|
||||
self.write(
|
||||
json.dumps("Error - '" + id + "' does not look like a valid reference ID for " + sig + ".",
|
||||
default=serialize_everything))
|
||||
self.set_status(422)
|
||||
else:
|
||||
self.write(json.dumps("Error - sig '" + sig + "' is not known.", default=serialize_everything))
|
||||
self.set_status(422)
|
||||
else:
|
||||
self.write(json.dumps("Error - sig and id must be provided", default=serialize_everything))
|
||||
self.set_status(422)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Error - " + str(e), default=serialize_everything))
|
||||
self.set_status(500)
|
||||
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
48
server/handlers/api/options.py
Normal file
48
server/handlers/api/options.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
import tornado
|
||||
|
||||
from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING, WEB_UI_OPTIONS
|
||||
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import serialize_everything
|
||||
|
||||
|
||||
# API request handler for /api/v1/options
|
||||
class APIOptionsHandler(tornado.web.RequestHandler):
|
||||
def initialize(self, status_data, web_server_metrics):
|
||||
self.status_data = status_data
|
||||
self.web_server_metrics = web_server_metrics
|
||||
|
||||
def get(self):
|
||||
# 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()
|
||||
|
||||
options = {"bands": BANDS,
|
||||
"modes": ALL_MODES,
|
||||
"mode_types": MODE_TYPES,
|
||||
"sigs": SIGS,
|
||||
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle things that aren't even available.
|
||||
"spot_sources": list(
|
||||
map(lambda p: p["name"], filter(lambda p: p["enabled"], self.status_data["spot_providers"]))),
|
||||
"alert_sources": list(
|
||||
map(lambda p: p["name"], filter(lambda p: p["enabled"], self.status_data["alert_providers"]))),
|
||||
"continents": CONTINENTS,
|
||||
"max_spot_age": MAX_SPOT_AGE,
|
||||
"spot_allowed": ALLOW_SPOTTING,
|
||||
"web-ui-options": WEB_UI_OPTIONS}
|
||||
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
|
||||
# one of our proviers.
|
||||
if ALLOW_SPOTTING:
|
||||
options["spot_sources"].append("API")
|
||||
options["web-ui-options"]["spot-providers-enabled-by-default"].append("API")
|
||||
|
||||
self.write(json.dumps(options, default=serialize_everything))
|
||||
self.set_status(200)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
239
server/handlers/api/spots.py
Normal file
239
server/handlers/api/spots.py
Normal file
@@ -0,0 +1,239 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from queue import Queue
|
||||
|
||||
import pytz
|
||||
import tornado
|
||||
import tornado_eventsource.handler
|
||||
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import serialize_everything
|
||||
|
||||
SSE_HANDLER_MAX_QUEUE_SIZE = 1000
|
||||
SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
|
||||
|
||||
|
||||
# API request handler for /api/v1/spots
|
||||
class APISpotsHandler(tornado.web.RequestHandler):
|
||||
def initialize(self, spots, web_server_metrics):
|
||||
self.spots = spots
|
||||
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()
|
||||
|
||||
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
||||
# reduce that to just the first entry, and convert bytes to string
|
||||
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
||||
|
||||
# Fetch all spots matching the query
|
||||
data = get_spot_list_with_filters(self.spots, query_params)
|
||||
self.write(json.dumps(data, default=serialize_everything))
|
||||
self.set_status(200)
|
||||
except ValueError as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Bad request - " + str(e), default=serialize_everything))
|
||||
self.set_status(400)
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
self.write(json.dumps("Error - " + str(e), default=serialize_everything))
|
||||
self.set_status(500)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
|
||||
|
||||
# API request handler for /api/v1/spots/stream
|
||||
class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
||||
def initialize(self, sse_spot_queues, web_server_metrics):
|
||||
self.sse_spot_queues = sse_spot_queues
|
||||
self.web_server_metrics = web_server_metrics
|
||||
|
||||
# Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data
|
||||
def custom_headers(self):
|
||||
return {"Cache-Control": "no-store",
|
||||
"X-Accel-Buffering": "no"}
|
||||
|
||||
# Called once on the client opening a connection, set things up
|
||||
def open(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()
|
||||
|
||||
# request.arguments contains lists for each param key because technically the client can supply multiple,
|
||||
# reduce that to just the first entry, and convert bytes to string
|
||||
self.query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
|
||||
|
||||
# Create a spot queue and add it to the web server's list. The web server will fill this when spots arrive
|
||||
self.spot_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE)
|
||||
self.sse_spot_queues.append(self.spot_queue)
|
||||
|
||||
# Set up a timed callback to check if anything is in the queue
|
||||
self.heartbeat = tornado.ioloop.PeriodicCallback(self._callback, SSE_HANDLER_QUEUE_CHECK_INTERVAL)
|
||||
self.heartbeat.start()
|
||||
|
||||
except Exception as e:
|
||||
logging.warn("Exception when serving SSE socket", e)
|
||||
|
||||
# When the user closes the socket, empty our queue and remove it from the list so the server no longer fills it
|
||||
def close(self):
|
||||
try:
|
||||
if self.spot_queue in self.sse_spot_queues:
|
||||
self.sse_spot_queues.remove(self.spot_queue)
|
||||
self.spot_queue.empty()
|
||||
except:
|
||||
pass
|
||||
self.spot_queue = None
|
||||
super().close()
|
||||
|
||||
# Callback to check if anything has arrived in the queue, and if so send it to the client
|
||||
def _callback(self):
|
||||
try:
|
||||
if self.spot_queue:
|
||||
while not self.spot_queue.empty():
|
||||
spot = self.spot_queue.get()
|
||||
# If the new spot matches our param filters, send it to the client. If not, ignore it.
|
||||
if spot_allowed_by_query(spot, self.query_params):
|
||||
self.write_message(msg=json.dumps(spot, default=serialize_everything))
|
||||
|
||||
if self.spot_queue not in self.sse_spot_queues:
|
||||
logging.error("Web server cleared up a queue of an active connection!")
|
||||
self.close()
|
||||
except:
|
||||
logging.warn("Exception in SSE callback, connection will be closed.")
|
||||
self.close()
|
||||
|
||||
|
||||
|
||||
# Utility method to apply filters to the overall spot list and return only a subset. Enables query parameters in
|
||||
# the main "spots" GET call.
|
||||
def get_spot_list_with_filters(all_spots, query):
|
||||
# Create a shallow copy of the spot list, ordered by spot time, then filter the list to reduce it only to spots
|
||||
# that match the filter parameters in the query string. Finally, apply a limit to the number of spots returned.
|
||||
# The list of query string filters is defined in the API docs.
|
||||
spot_ids = list(all_spots.iterkeys())
|
||||
spots = []
|
||||
for k in spot_ids:
|
||||
s = all_spots.get(k)
|
||||
if s is not None:
|
||||
spots.append(s)
|
||||
spots = sorted(spots, key=lambda spot: (spot.time if spot and spot.time else 0), reverse=True)
|
||||
spots = list(filter(lambda spot: spot_allowed_by_query(spot, query), spots))
|
||||
if "limit" in query.keys():
|
||||
spots = spots[:int(query.get("limit"))]
|
||||
|
||||
# Ensure only the latest spot of each callsign-SSID combo is present in the list. This relies on the
|
||||
# list being in reverse time order, so if any future change allows re-ordering the list, that should
|
||||
# be done *after* this. SSIDs are deliberately included here (see issue #68) because e.g. M0TRT-7
|
||||
# and M0TRT-9 APRS transponders could well be in different locations, on different frequencies etc.
|
||||
# This is a special consideration for the geo map and band map views (and Field Spotter) because while
|
||||
# duplicates are fine in the main spot list (e.g. different cluster spots of the same DX) this doesn't
|
||||
# work well for the other views.
|
||||
if "dedupe" in query.keys():
|
||||
dedupe = query.get("dedupe").upper() == "TRUE"
|
||||
if dedupe:
|
||||
spots_temp = []
|
||||
already_seen = []
|
||||
for s in spots:
|
||||
call_plus_ssid = s.dx_call + (s.dx_ssid if s.dx_ssid else "")
|
||||
if call_plus_ssid not in already_seen:
|
||||
spots_temp.append(s)
|
||||
already_seen.append(call_plus_ssid)
|
||||
spots = spots_temp
|
||||
|
||||
return spots
|
||||
|
||||
# Given URL query params and a spot, figure out if the spot "passes" the requested filters or is rejected. The list
|
||||
# of query parameters and their function is defined in the API docs.
|
||||
def spot_allowed_by_query(spot, query):
|
||||
for k in query.keys():
|
||||
match k:
|
||||
case "since":
|
||||
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC).timestamp()
|
||||
if not spot.time or spot.time <= since:
|
||||
return False
|
||||
case "max_age":
|
||||
max_age = int(query.get(k))
|
||||
since = (datetime.now(pytz.UTC) - timedelta(seconds=max_age)).timestamp()
|
||||
if not spot.time or spot.time <= since:
|
||||
return False
|
||||
case "received_since":
|
||||
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC).timestamp()
|
||||
if not spot.received_time or spot.received_time <= since:
|
||||
return False
|
||||
case "source":
|
||||
sources = query.get(k).split(",")
|
||||
if not spot.source or spot.source not in sources:
|
||||
return False
|
||||
case "sig":
|
||||
# If a list of sigs is provided, the spot must have a sig and it must match one of them.
|
||||
# The special "sig" "NO_SIG", when supplied in the list, mathches spots with no sig.
|
||||
sigs = query.get(k).split(",")
|
||||
include_no_sig = "NO_SIG" in sigs
|
||||
if not spot.sig and not include_no_sig:
|
||||
return False
|
||||
if spot.sig and spot.sig not in sigs:
|
||||
return False
|
||||
case "needs_sig":
|
||||
# If true, a sig is required, regardless of what it is, it just can't be missing. Mutually
|
||||
# exclusive with supplying the special "NO_SIG" parameter to the "sig" query param.
|
||||
needs_sig = query.get(k).upper() == "TRUE"
|
||||
if needs_sig and not spot.sig:
|
||||
return False
|
||||
case "needs_sig_ref":
|
||||
# If true, at least one sig ref is required, regardless of what it is, it just can't be missing.
|
||||
needs_sig_ref = query.get(k).upper() == "TRUE"
|
||||
if needs_sig_ref and (not spot.sig_refs or len(spot.sig_refs) == 0):
|
||||
return False
|
||||
case "band":
|
||||
bands = query.get(k).split(",")
|
||||
if not spot.band or spot.band not in bands:
|
||||
return False
|
||||
case "mode":
|
||||
modes = query.get(k).split(",")
|
||||
if not spot.mode or spot.mode not in modes:
|
||||
return False
|
||||
case "mode_type":
|
||||
mode_types = query.get(k).split(",")
|
||||
if not spot.mode_type or spot.mode_type not in mode_types:
|
||||
return False
|
||||
case "dx_continent":
|
||||
dxconts = query.get(k).split(",")
|
||||
if not spot.dx_continent or spot.dx_continent not in dxconts:
|
||||
return False
|
||||
case "de_continent":
|
||||
deconts = query.get(k).split(",")
|
||||
if not spot.de_continent or spot.de_continent not in deconts:
|
||||
return False
|
||||
case "comment_includes":
|
||||
comment_includes = query.get(k).strip()
|
||||
if not spot.comment or comment_includes.upper() not in spot.comment.upper():
|
||||
return False
|
||||
case "dx_call_includes":
|
||||
dx_call_includes = query.get(k).strip()
|
||||
if not spot.dx_call or dx_call_includes.upper() not in spot.dx_call.upper():
|
||||
return False
|
||||
case "text_includes":
|
||||
text_includes = query.get(k).strip()
|
||||
if (not spot.dx_call or text_includes.upper() not in spot.dx_call.upper()) \
|
||||
and (not spot.comment or text_includes.upper() not in spot.comment.upper()):
|
||||
return False
|
||||
case "allow_qrt":
|
||||
# If false, spots that are flagged as QRT are not returned.
|
||||
prevent_qrt = query.get(k).upper() == "FALSE"
|
||||
if prevent_qrt and spot.qrt and spot.qrt == True:
|
||||
return False
|
||||
case "needs_good_location":
|
||||
# If true, spots require a "good" location to be returned
|
||||
needs_good_location = query.get(k).upper() == "TRUE"
|
||||
if needs_good_location and not spot.dx_location_good:
|
||||
return False
|
||||
return True
|
||||
27
server/handlers/api/status.py
Normal file
27
server/handlers/api/status.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
import tornado
|
||||
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import serialize_everything
|
||||
|
||||
|
||||
# API request handler for /api/v1/status
|
||||
class APIStatusHandler(tornado.web.RequestHandler):
|
||||
def initialize(self, status_data, web_server_metrics):
|
||||
self.status_data = status_data
|
||||
self.web_server_metrics = web_server_metrics
|
||||
|
||||
def get(self):
|
||||
# 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.set_status(200)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
12
server/handlers/metrics.py
Normal file
12
server/handlers/metrics.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import tornado
|
||||
from prometheus_client import CONTENT_TYPE_LATEST
|
||||
|
||||
from core.prometheus_metrics_handler import get_metrics
|
||||
|
||||
|
||||
# Handler for Prometheus metrics endpoint
|
||||
class PrometheusMetricsHandler(tornado.web.RequestHandler):
|
||||
def get(self):
|
||||
self.write(get_metrics())
|
||||
self.set_status(200)
|
||||
self.set_header('Content-Type', CONTENT_TYPE_LATEST)
|
||||
26
server/handlers/pagetemplate.py
Normal file
26
server/handlers/pagetemplate.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
import tornado
|
||||
|
||||
from core.config import ALLOW_SPOTTING
|
||||
from core.constants import SOFTWARE_VERSION
|
||||
from core.prometheus_metrics_handler import page_requests_counter
|
||||
|
||||
|
||||
# Handler for all HTML pages generated from templates
|
||||
class PageTemplateHandler(tornado.web.RequestHandler):
|
||||
def initialize(self, template_name, web_server_metrics):
|
||||
self.template_name = template_name
|
||||
self.web_server_metrics = web_server_metrics
|
||||
|
||||
def get(self):
|
||||
# Metrics
|
||||
self.web_server_metrics["last_page_access_time"] = datetime.now(pytz.UTC)
|
||||
self.web_server_metrics["page_access_counter"] += 1
|
||||
self.web_server_metrics["status"] = "OK"
|
||||
page_requests_counter.inc()
|
||||
|
||||
# Load named template, and provide variables used in templates
|
||||
self.render(self.template_name + ".html", software_version=SOFTWARE_VERSION, allow_spotting=ALLOW_SPOTTING)
|
||||
|
||||
@@ -1,486 +1,120 @@
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Thread
|
||||
import os
|
||||
|
||||
import bottle
|
||||
import pytz
|
||||
from bottle import run, request, response, template
|
||||
import tornado
|
||||
from tornado.web import StaticFileHandler
|
||||
|
||||
from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING, WEB_UI_OPTIONS
|
||||
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS, SOFTWARE_VERSION, UNKNOWN_BAND
|
||||
from core.lookup_helper import lookup_helper
|
||||
from core.prometheus_metrics_handler import page_requests_counter, get_metrics, api_requests_counter
|
||||
from core.sig_utils import get_ref_regex_for_sig, get_sig_ref_info
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from server.handlers.api.addspot import APISpotHandler
|
||||
from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
|
||||
from server.handlers.api.lookups import APILookupCallHandler, APILookupSIGRefHandler
|
||||
from server.handlers.api.options import APIOptionsHandler
|
||||
from server.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
|
||||
from server.handlers.api.status import APIStatusHandler
|
||||
from server.handlers.metrics import PrometheusMetricsHandler
|
||||
from server.handlers.pagetemplate import PageTemplateHandler
|
||||
|
||||
|
||||
# Provides the public-facing web server.
|
||||
class WebServer:
|
||||
|
||||
# Constructor
|
||||
def __init__(self, spots, alerts, status_data, port):
|
||||
self.last_page_access_time = None
|
||||
self.last_api_access_time = None
|
||||
self.page_access_counter = 0
|
||||
self.api_access_counter = 0
|
||||
self.spots = spots
|
||||
self.alerts = alerts
|
||||
self.sse_spot_queues = []
|
||||
self.sse_alert_queues = []
|
||||
self.status_data = status_data
|
||||
self.port = port
|
||||
self.thread = Thread(target=self.run)
|
||||
self.thread.daemon = True
|
||||
self.status = "Starting"
|
||||
|
||||
# Base template data
|
||||
bottle.BaseTemplate.defaults['software_version'] = SOFTWARE_VERSION
|
||||
bottle.BaseTemplate.defaults['allow_spotting'] = ALLOW_SPOTTING
|
||||
|
||||
# Routes for API calls
|
||||
bottle.get("/api/v1/spots")(lambda: self.serve_spots_api())
|
||||
bottle.get("/api/v1/alerts")(lambda: self.serve_alerts_api())
|
||||
bottle.get("/api/v1/options")(lambda: self.serve_api(self.get_options()))
|
||||
bottle.get("/api/v1/status")(lambda: self.serve_api(self.status_data))
|
||||
bottle.get("/api/v1/lookup/call")(lambda: self.serve_call_lookup_api())
|
||||
bottle.get("/api/v1/lookup/sigref")(lambda: self.serve_sig_ref_lookup_api())
|
||||
bottle.post("/api/v1/spot")(lambda: self.accept_spot())
|
||||
# Routes for templated pages
|
||||
bottle.get("/")(lambda: self.serve_template('webpage_spots'))
|
||||
bottle.get("/map")(lambda: self.serve_template('webpage_map'))
|
||||
bottle.get("/bands")(lambda: self.serve_template('webpage_bands'))
|
||||
bottle.get("/alerts")(lambda: self.serve_template('webpage_alerts'))
|
||||
bottle.get("/add-spot")(lambda: self.serve_template('webpage_add_spot'))
|
||||
bottle.get("/status")(lambda: self.serve_template('webpage_status'))
|
||||
bottle.get("/about")(lambda: self.serve_template('webpage_about'))
|
||||
bottle.get("/apidocs")(lambda: self.serve_template('webpage_apidocs'))
|
||||
# Route for Prometheus metrics
|
||||
bottle.get("/metrics")(lambda: self.serve_prometheus_metrics())
|
||||
# Default route to serve from "webassets"
|
||||
bottle.get("/<filepath:path>")(self.serve_static_file)
|
||||
self.shutdown_event = asyncio.Event()
|
||||
self.web_server_metrics = {
|
||||
"last_page_access_time": None,
|
||||
"last_api_access_time": None,
|
||||
"page_access_counter": 0,
|
||||
"api_access_counter": 0,
|
||||
"status": "Starting"
|
||||
}
|
||||
|
||||
# Start the web server
|
||||
def start(self):
|
||||
self.thread.start()
|
||||
asyncio.run(self.start_inner())
|
||||
|
||||
# Run the web server itself. This blocks until the server is shut down, so it runs in a separate thread.
|
||||
def run(self):
|
||||
logging.info("Starting web server on port " + str(self.port) + "...")
|
||||
self.status = "Waiting"
|
||||
run(host='localhost', port=self.port)
|
||||
# Stop the web server
|
||||
def stop(self):
|
||||
self.shutdown_event.set()
|
||||
|
||||
# Serve the JSON API /spots endpoint
|
||||
def serve_spots_api(self):
|
||||
# Start method (async). Sets up the Tornado application.
|
||||
async def start_inner(self):
|
||||
app = tornado.web.Application([
|
||||
# Routes for API calls
|
||||
(r"/api/v1/spots", APISpotsHandler, {"spots": self.spots, "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/api/v1/alerts", APIAlertsHandler, {"alerts": self.alerts, "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/api/v1/spots/stream", APISpotsStreamHandler, {"sse_spot_queues": self.sse_spot_queues, "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/api/v1/alerts/stream", APIAlertsStreamHandler, {"sse_alert_queues": self.sse_alert_queues, "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/api/v1/options", APIOptionsHandler, {"status_data": self.status_data, "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/api/v1/status", APIStatusHandler, {"status_data": self.status_data, "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/api/v1/lookup/call", APILookupCallHandler, {"web_server_metrics": self.web_server_metrics}),
|
||||
(r"/api/v1/lookup/sigref", APILookupSIGRefHandler, {"web_server_metrics": self.web_server_metrics}),
|
||||
(r"/api/v1/spot", APISpotHandler, {"spots": self.spots, "web_server_metrics": self.web_server_metrics}),
|
||||
# Routes for templated pages
|
||||
(r"/", PageTemplateHandler, {"template_name": "spots", "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/map", PageTemplateHandler, {"template_name": "map", "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/bands", PageTemplateHandler, {"template_name": "bands", "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/alerts", PageTemplateHandler, {"template_name": "alerts", "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/add-spot", PageTemplateHandler, {"template_name": "add_spot", "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/status", PageTemplateHandler, {"template_name": "status", "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/about", PageTemplateHandler, {"template_name": "about", "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/apidocs", PageTemplateHandler, {"template_name": "apidocs", "web_server_metrics": self.web_server_metrics}),
|
||||
# Route for Prometheus metrics
|
||||
(r"/metrics", PrometheusMetricsHandler),
|
||||
# Default route to serve from "webassets"
|
||||
(r"/(.*)", StaticFileHandler, {"path": os.path.join(os.path.dirname(__file__), "../webassets")}),
|
||||
],
|
||||
template_path=os.path.join(os.path.dirname(__file__), "../templates"),
|
||||
debug=False)
|
||||
app.listen(self.port)
|
||||
await self.shutdown_event.wait()
|
||||
|
||||
# Internal method called when a new spot is added to the system. This is used to ping any SSE clients that are
|
||||
# awaiting a server-sent message with new spots.
|
||||
def notify_new_spot(self, spot):
|
||||
for queue in self.sse_spot_queues:
|
||||
try:
|
||||
data = self.get_spot_list_with_filters()
|
||||
return self.serve_api(data)
|
||||
except ValueError as e:
|
||||
logging.error(e)
|
||||
response.content_type = 'application/json'
|
||||
response.status = 400
|
||||
return json.dumps("Bad request - " + str(e), default=serialize_everything)
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
response.content_type = 'application/json'
|
||||
response.status = 500
|
||||
return json.dumps("Error - " + str(e), default=serialize_everything)
|
||||
queue.put(spot)
|
||||
except:
|
||||
# Cleanup thread was probably deleting the queue, that's fine
|
||||
pass
|
||||
pass
|
||||
|
||||
# Serve the JSON API /alerts endpoint
|
||||
def serve_alerts_api(self):
|
||||
# Internal method called when a new alert is added to the system. This is used to ping any SSE clients that are
|
||||
# awaiting a server-sent message with new spots.
|
||||
def notify_new_alert(self, alert):
|
||||
for queue in self.sse_alert_queues:
|
||||
try:
|
||||
data = self.get_alert_list_with_filters()
|
||||
return self.serve_api(data)
|
||||
except ValueError as e:
|
||||
logging.error(e)
|
||||
response.content_type = 'application/json'
|
||||
response.status = 400
|
||||
return json.dumps("Bad request - " + str(e), default=serialize_everything)
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
response.content_type = 'application/json'
|
||||
response.status = 500
|
||||
return json.dumps("Error - " + str(e), default=serialize_everything)
|
||||
queue.put(alert)
|
||||
except:
|
||||
# Cleanup thread was probably deleting the queue, that's fine
|
||||
pass
|
||||
pass
|
||||
|
||||
# Look up data for a callsign
|
||||
def serve_call_lookup_api(self):
|
||||
# Clean up any SSE queues that are growing too large; probably their client disconnected and we didn't catch it
|
||||
# properly for some reason.
|
||||
def clean_up_sse_queues(self):
|
||||
for q in self.sse_spot_queues:
|
||||
try:
|
||||
# Reject if no callsign
|
||||
query = bottle.request.query
|
||||
if not "call" in query.keys():
|
||||
response.content_type = 'application/json'
|
||||
response.status = 422
|
||||
return json.dumps("Error - call must be provided", default=serialize_everything)
|
||||
call = query.get("call").upper()
|
||||
|
||||
# Reject badly formatted callsigns
|
||||
if not re.match(r"^[A-Za-z0-9/\-]*$", call):
|
||||
response.content_type = 'application/json'
|
||||
response.status = 422
|
||||
return json.dumps("Error - '" + call + "' does not look like a valid callsign.",
|
||||
default=serialize_everything)
|
||||
|
||||
# Take the callsign, make a "fake spot" so we can run infer_missing() on it, then repack the resulting data
|
||||
# in the correct way for the API response.
|
||||
fake_spot = Spot(dx_call=call)
|
||||
fake_spot.infer_missing()
|
||||
return self.serve_api({
|
||||
"call": call,
|
||||
"name": fake_spot.dx_name,
|
||||
"qth": fake_spot.dx_qth,
|
||||
"country": fake_spot.dx_country,
|
||||
"flag": fake_spot.dx_flag,
|
||||
"continent": fake_spot.dx_continent,
|
||||
"dxcc_id": fake_spot.dx_dxcc_id,
|
||||
"cq_zone": fake_spot.dx_cq_zone,
|
||||
"itu_zone": fake_spot.dx_itu_zone,
|
||||
"grid": fake_spot.dx_grid,
|
||||
"latitude": fake_spot.dx_latitude,
|
||||
"longitude": fake_spot.dx_longitude,
|
||||
"location_source": fake_spot.dx_location_source
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
response.content_type = 'application/json'
|
||||
response.status = 500
|
||||
return json.dumps("Error - " + str(e), default=serialize_everything)
|
||||
|
||||
# Look up data for a SIG reference
|
||||
def serve_sig_ref_lookup_api(self):
|
||||
if q.full():
|
||||
logging.warn("A full SSE spot queue was found, presumably because the client disconnected strangely. It has been removed.")
|
||||
self.sse_spot_queues.remove(q)
|
||||
q.empty()
|
||||
except:
|
||||
# Probably got deleted already on another thread
|
||||
pass
|
||||
for q in self.sse_alert_queues:
|
||||
try:
|
||||
# Reject if no sig or sig_ref
|
||||
query = bottle.request.query
|
||||
if not "sig" in query.keys() or not "id" in query.keys():
|
||||
response.content_type = 'application/json'
|
||||
response.status = 422
|
||||
return json.dumps("Error - sig and id must be provided", default=serialize_everything)
|
||||
sig = query.get("sig").upper()
|
||||
id = query.get("id").upper()
|
||||
|
||||
# Reject if sig unknown
|
||||
if not sig in list(map(lambda p: p.name, SIGS)):
|
||||
response.content_type = 'application/json'
|
||||
response.status = 422
|
||||
return json.dumps("Error - sig '" + sig + "' is not known.", default=serialize_everything)
|
||||
|
||||
# Reject if sig_ref format incorrect for sig
|
||||
if get_ref_regex_for_sig(sig) and not re.match(get_ref_regex_for_sig(sig), id):
|
||||
response.content_type = 'application/json'
|
||||
response.status = 422
|
||||
return json.dumps("Error - '" + id + "' does not look like a valid reference ID for " + sig + ".", default=serialize_everything)
|
||||
|
||||
data = get_sig_ref_info(sig, id)
|
||||
return self.serve_api(data)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
response.content_type = 'application/json'
|
||||
response.status = 500
|
||||
return json.dumps("Error - " + str(e), default=serialize_everything)
|
||||
|
||||
# Serve a JSON API endpoint
|
||||
def serve_api(self, data):
|
||||
self.last_api_access_time = datetime.now(pytz.UTC)
|
||||
self.api_access_counter += 1
|
||||
api_requests_counter.inc()
|
||||
self.status = "OK"
|
||||
response.content_type = 'application/json'
|
||||
response.set_header('Cache-Control', 'no-store')
|
||||
return json.dumps(data, default=serialize_everything)
|
||||
|
||||
# Accept a spot
|
||||
def accept_spot(self):
|
||||
self.last_api_access_time = datetime.now(pytz.UTC)
|
||||
self.api_access_counter += 1
|
||||
api_requests_counter.inc()
|
||||
self.status = "OK"
|
||||
|
||||
try:
|
||||
# Reject if not allowed
|
||||
if not ALLOW_SPOTTING:
|
||||
response.content_type = 'application/json'
|
||||
response.status = 401
|
||||
return json.dumps("Error - this server does not allow new spots to be added via the API.",
|
||||
default=serialize_everything)
|
||||
|
||||
# Reject if format not json
|
||||
if not request.get_header('Content-Type') or request.get_header('Content-Type') != "application/json":
|
||||
response.content_type = 'application/json'
|
||||
response.status = 415
|
||||
return json.dumps("Error - request Content-Type must be application/json", default=serialize_everything)
|
||||
|
||||
# Reject if request body is empty
|
||||
post_data = request.body.read()
|
||||
if not post_data:
|
||||
response.content_type = 'application/json'
|
||||
response.status = 422
|
||||
return json.dumps("Error - request body is empty", default=serialize_everything)
|
||||
|
||||
# Read in the request body as JSON then convert to a Spot object
|
||||
json_spot = json.loads(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:
|
||||
response.content_type = 'application/json'
|
||||
response.status = 422
|
||||
return json.dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum.",
|
||||
default=serialize_everything)
|
||||
|
||||
# Reject invalid-looking callsigns
|
||||
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call):
|
||||
response.content_type = 'application/json'
|
||||
response.status = 422
|
||||
return json.dumps("Error - '" + spot.dx_call + "' does not look like a valid callsign.",
|
||||
default=serialize_everything)
|
||||
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call):
|
||||
response.content_type = 'application/json'
|
||||
response.status = 422
|
||||
return json.dumps("Error - '" + spot.de_call + "' does not look like a valid callsign.",
|
||||
default=serialize_everything)
|
||||
|
||||
# Reject if frequency not in a known band
|
||||
if lookup_helper.infer_band_from_freq(spot.freq) == UNKNOWN_BAND:
|
||||
response.content_type = 'application/json'
|
||||
response.status = 422
|
||||
return json.dumps("Error - Frequency of " + str(spot.freq / 1000.0) + "kHz is not in a known band.", default=serialize_everything)
|
||||
|
||||
# Reject if grid formatting incorrect
|
||||
if spot.dx_grid and not re.match(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()):
|
||||
response.content_type = 'application/json'
|
||||
response.status = 422
|
||||
return json.dumps("Error - '" + spot.dx_grid + "' does not look like a valid Maidenhead grid.", default=serialize_everything)
|
||||
|
||||
# Reject if sig_ref format incorrect for sig
|
||||
if spot.sig and spot.sig_refs and len(spot.sig_refs) > 0 and spot.sig_refs[0].id and get_ref_regex_for_sig(spot.sig) and not re.match(get_ref_regex_for_sig(spot.sig), spot.sig_refs[0].id):
|
||||
response.content_type = 'application/json'
|
||||
response.status = 422
|
||||
return json.dumps("Error - '" + spot.sig_refs[0].id + "' does not look like a valid reference for " + spot.sig + ".", default=serialize_everything)
|
||||
|
||||
# infer missing data, and add it to our database.
|
||||
spot.source = "API"
|
||||
if not spot.sig:
|
||||
spot.icon = "desktop"
|
||||
spot.infer_missing()
|
||||
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
|
||||
|
||||
response.content_type = 'application/json'
|
||||
response.set_header('Cache-Control', 'no-store')
|
||||
response.status = 201
|
||||
return json.dumps("OK", default=serialize_everything)
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
response.content_type = 'application/json'
|
||||
response.status = 500
|
||||
return json.dumps("Error - " + str(e), default=serialize_everything)
|
||||
|
||||
# Serve a templated page
|
||||
def serve_template(self, template_name):
|
||||
self.last_page_access_time = datetime.now(pytz.UTC)
|
||||
self.page_access_counter += 1
|
||||
page_requests_counter.inc()
|
||||
self.status = "OK"
|
||||
return template(template_name)
|
||||
|
||||
# Serve general static files from "webassets" directory.
|
||||
def serve_static_file(self, filepath):
|
||||
return bottle.static_file(filepath, root="webassets")
|
||||
|
||||
# Serve Prometheus metrics
|
||||
def serve_prometheus_metrics(self):
|
||||
return get_metrics()
|
||||
|
||||
# Utility method to apply filters to the overall spot list and return only a subset. Enables query parameters in
|
||||
# the main "spots" GET call.
|
||||
def get_spot_list_with_filters(self):
|
||||
# Get the query (and the right one, with Bottle magic. This is a MultiDict object)
|
||||
query = bottle.request.query
|
||||
|
||||
# Create a shallow copy of the spot list, ordered by spot time. We'll then filter it accordingly.
|
||||
# We can filter by spot time and received time with "since" and "received_since", which take a UNIX timestamp
|
||||
# in seconds UTC.
|
||||
# We can also filter by source, sig, band, mode, dx_continent and de_continent. Each of these accepts a single
|
||||
# value or a comma-separated list.
|
||||
# We can filter by comments, accepting a single string, where the API will only return spots where the comment
|
||||
# contains the provided value (case-insensitive).
|
||||
# We can "de-dupe" spots, so only the latest spot will be sent for each callsign.
|
||||
# We can provide a "limit" number as well. Spots are always returned newest-first; "limit" limits to only the
|
||||
# most recent X spots.
|
||||
spot_ids = list(self.spots.iterkeys())
|
||||
spots = []
|
||||
for k in spot_ids:
|
||||
s = self.spots.get(k)
|
||||
if s is not None:
|
||||
spots.append(s)
|
||||
spots = sorted(spots, key=lambda spot: (spot.time if spot and spot.time else 0), reverse=True)
|
||||
for k in query.keys():
|
||||
match k:
|
||||
case "since":
|
||||
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC).timestamp()
|
||||
spots = [s for s in spots if s.time and s.time > since]
|
||||
case "max_age":
|
||||
max_age = int(query.get(k))
|
||||
since = (datetime.now(pytz.UTC) - timedelta(seconds=max_age)).timestamp()
|
||||
spots = [s for s in spots if s.time and s.time > since]
|
||||
case "received_since":
|
||||
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC).timestamp()
|
||||
spots = [s for s in spots if s.received_time and s.received_time > since]
|
||||
case "source":
|
||||
sources = query.get(k).split(",")
|
||||
spots = [s for s in spots if s.source and s.source in sources]
|
||||
case "sig":
|
||||
# If a list of sigs is provided, the spot must have a sig and it must match one of them
|
||||
sigs = query.get(k).split(",")
|
||||
spots = [s for s in spots if s.sig and s.sig in sigs]
|
||||
case "needs_sig":
|
||||
# If true, a sig is required, regardless of what it is, it just can't be missing.
|
||||
needs_sig = query.get(k).upper() == "TRUE"
|
||||
if needs_sig:
|
||||
spots = [s for s in spots if s.sig]
|
||||
case "needs_sig_ref":
|
||||
# If true, at least one sig ref is required, regardless of what it is, it just can't be missing.
|
||||
needs_sig_ref = query.get(k).upper() == "TRUE"
|
||||
if needs_sig_ref:
|
||||
spots = [s for s in spots if s.sig_refs and len(s.sig_refs) > 0]
|
||||
case "band":
|
||||
bands = query.get(k).split(",")
|
||||
spots = [s for s in spots if s.band and s.band in bands]
|
||||
case "mode":
|
||||
modes = query.get(k).split(",")
|
||||
spots = [s for s in spots if s.mode in modes]
|
||||
case "mode_type":
|
||||
mode_families = query.get(k).split(",")
|
||||
spots = [s for s in spots if s.mode_type and s.mode_type in mode_families]
|
||||
case "dx_continent":
|
||||
dxconts = query.get(k).split(",")
|
||||
spots = [s for s in spots if s.dx_continent and s.dx_continent in dxconts]
|
||||
case "de_continent":
|
||||
deconts = query.get(k).split(",")
|
||||
spots = [s for s in spots if s.de_continent and s.de_continent in deconts]
|
||||
case "comment_includes":
|
||||
comment_includes = query.get(k).strip()
|
||||
spots = [s for s in spots if s.comment and comment_includes.upper() in s.comment.upper()]
|
||||
case "dx_call_includes":
|
||||
dx_call_includes = query.get(k).strip()
|
||||
spots = [s for s in spots if s.dx_call and dx_call_includes.upper() in s.dx_call.upper()]
|
||||
case "allow_qrt":
|
||||
# If false, spots that are flagged as QRT are not returned.
|
||||
prevent_qrt = query.get(k).upper() == "FALSE"
|
||||
if prevent_qrt:
|
||||
spots = [s for s in spots if not s.qrt or s.qrt == False]
|
||||
case "needs_good_location":
|
||||
# If true, spots require a "good" location to be returned
|
||||
needs_good_location = query.get(k).upper() == "TRUE"
|
||||
if needs_good_location:
|
||||
spots = [s for s in spots if s.dx_location_good]
|
||||
case "dedupe":
|
||||
# Ensure only the latest spot of each callsign-SSID combo is present in the list. This relies on the
|
||||
# list being in reverse time order, so if any future change allows re-ordering the list, that should
|
||||
# be done *after* this. SSIDs are deliberately included here (see issue #68) because e.g. M0TRT-7
|
||||
# and M0TRT-9 APRS transponders could well be in different locations, on different frequencies etc.
|
||||
dedupe = query.get(k).upper() == "TRUE"
|
||||
if dedupe:
|
||||
spots_temp = []
|
||||
already_seen = []
|
||||
for s in spots:
|
||||
call_plus_ssid = s.dx_call + (s.dx_ssid if s.dx_ssid else "")
|
||||
if call_plus_ssid not in already_seen:
|
||||
spots_temp.append(s)
|
||||
already_seen.append(call_plus_ssid)
|
||||
spots = spots_temp
|
||||
# If we have a "limit" parameter, we apply that last, regardless of where it appeared in the list of keys.
|
||||
if "limit" in query.keys():
|
||||
spots = spots[:int(query.get("limit"))]
|
||||
return spots
|
||||
|
||||
# Utility method to apply filters to the overall alert list and return only a subset. Enables query parameters in
|
||||
# the main "alerts" GET call.
|
||||
def get_alert_list_with_filters(self):
|
||||
# Get the query (and the right one, with Bottle magic. This is a MultiDict object)
|
||||
query = bottle.request.query
|
||||
|
||||
# Create a shallow copy of the alert list, ordered by start time. We'll then filter it accordingly.
|
||||
# We can filter by received time with "received_since", which take a UNIX timestamp in seconds UTC.
|
||||
# We can also filter by source, sig, and dx_continent. Each of these accepts a single
|
||||
# value or a comma-separated list.
|
||||
# We can provide a "limit" number as well. Alerts are always returned newest-first; "limit" limits to only the
|
||||
# most recent X alerts.
|
||||
alert_ids = list(self.alerts.iterkeys())
|
||||
alerts = []
|
||||
for k in alert_ids:
|
||||
a = self.alerts.get(k)
|
||||
if a is not None:
|
||||
alerts.append(a)
|
||||
# We never want alerts that seem to be in the past
|
||||
alerts = list(filter(lambda alert: not alert.expired(), alerts))
|
||||
alerts = sorted(alerts, key=lambda alert: (alert.start_time if alert and alert.start_time else 0))
|
||||
for k in query.keys():
|
||||
match k:
|
||||
case "received_since":
|
||||
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC)
|
||||
alerts = [a for a in alerts if a.received_time and a.received_time > since]
|
||||
case "max_duration":
|
||||
max_duration = int(query.get(k))
|
||||
# Check the duration if end_time is provided. If end_time is not provided, assume the activation is
|
||||
# "short", i.e. it always passes this check. If dxpeditions_skip_max_duration_check is true and
|
||||
# the alert is a dxpedition, it also always passes the check.
|
||||
dxpeditions_skip_check = bool(query.get(
|
||||
"dxpeditions_skip_max_duration_check")) if "dxpeditions_skip_max_duration_check" in query.keys() else False
|
||||
alerts = [a for a in alerts if (a.end_time and a.end_time - a.start_time <= max_duration) or
|
||||
not a.end_time or (dxpeditions_skip_check and a.is_dxpedition)]
|
||||
case "source":
|
||||
sources = query.get(k).split(",")
|
||||
alerts = [a for a in alerts if a.source and a.source in sources]
|
||||
case "sig":
|
||||
sigs = query.get(k).split(",")
|
||||
alerts = [a for a in alerts if a.sig and a.sig in sigs]
|
||||
case "dx_continent":
|
||||
dxconts = query.get(k).split(",")
|
||||
alerts = [a for a in alerts if a.dx_continent and a.dx_continent in dxconts]
|
||||
case "dx_call_includes":
|
||||
dx_call_includes = query.get(k).strip()
|
||||
spots = [a for a in alerts if a.dx_call and dx_call_includes.upper() in a.dx_call.upper()]
|
||||
# If we have a "limit" parameter, we apply that last, regardless of where it appeared in the list of keys.
|
||||
if "limit" in query.keys():
|
||||
alerts = alerts[:int(query.get("limit"))]
|
||||
return alerts
|
||||
|
||||
# Return all the "options" for various things that the server is aware of. This can be fetched with an API call.
|
||||
# The idea is that this will include most of the things that can be provided as queries to the main spots call,
|
||||
# and thus a client can use this data to configure its filter controls.
|
||||
def get_options(self):
|
||||
options = {"bands": BANDS,
|
||||
"modes": ALL_MODES,
|
||||
"mode_types": MODE_TYPES,
|
||||
"sigs": SIGS,
|
||||
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle things that aren't even available.
|
||||
"spot_sources": list(
|
||||
map(lambda p: p["name"], filter(lambda p: p["enabled"], self.status_data["spot_providers"]))),
|
||||
"alert_sources": list(
|
||||
map(lambda p: p["name"], filter(lambda p: p["enabled"], self.status_data["alert_providers"]))),
|
||||
"continents": CONTINENTS,
|
||||
"max_spot_age": MAX_SPOT_AGE,
|
||||
"spot_allowed": ALLOW_SPOTTING,
|
||||
"web-ui-options": WEB_UI_OPTIONS}
|
||||
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
|
||||
# one of our proviers.
|
||||
if ALLOW_SPOTTING:
|
||||
options["spot_sources"].append("API")
|
||||
|
||||
return options
|
||||
|
||||
|
||||
# Convert objects to serialisable things. Used by JSON serialiser as a default when it encounters unserializable things.
|
||||
# Just converts objects to dict. Try to avoid doing anything clever here when serialising spots, because we also need
|
||||
# to receive spots without complex handling.
|
||||
def serialize_everything(obj):
|
||||
return obj.__dict__
|
||||
if q.full():
|
||||
logging.warn("A full SSE alert queue was found, presumably because the client disconnected strangely. It has been removed.")
|
||||
self.sse_alert_queues.remove(q)
|
||||
q.empty()
|
||||
except:
|
||||
# Probably got deleted already on another thread
|
||||
pass
|
||||
pass
|
||||
|
||||
27
spothole.py
27
spothole.py
@@ -1,6 +1,7 @@
|
||||
# Main script
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
|
||||
@@ -16,15 +17,20 @@ from server.webserver import WebServer
|
||||
# Globals
|
||||
spots = Cache('cache/spots_cache')
|
||||
alerts = Cache('cache/alerts_cache')
|
||||
web_server = None
|
||||
status_data = {}
|
||||
spot_providers = []
|
||||
alert_providers = []
|
||||
cleanup_timer = None
|
||||
run = True
|
||||
|
||||
|
||||
# Shutdown function
|
||||
def shutdown(sig, frame):
|
||||
logging.info("Stopping program, this may take a few seconds...")
|
||||
global run
|
||||
|
||||
logging.info("Stopping program...")
|
||||
web_server.stop()
|
||||
for p in spot_providers:
|
||||
if p.enabled:
|
||||
p.stop()
|
||||
@@ -35,6 +41,7 @@ def shutdown(sig, frame):
|
||||
lookup_helper.stop()
|
||||
spots.close()
|
||||
alerts.close()
|
||||
os._exit(0)
|
||||
|
||||
|
||||
# Utility method to get a spot provider based on the class specified in its config entry.
|
||||
@@ -72,11 +79,14 @@ if __name__ == '__main__':
|
||||
# Set up lookup helper
|
||||
lookup_helper.start()
|
||||
|
||||
# Set up web server
|
||||
web_server = WebServer(spots=spots, alerts=alerts, status_data=status_data, port=WEB_SERVER_PORT)
|
||||
|
||||
# Fetch, set up and start spot providers
|
||||
for entry in config["spot-providers"]:
|
||||
spot_providers.append(get_spot_provider_from_config(entry))
|
||||
for p in spot_providers:
|
||||
p.setup(spots=spots)
|
||||
p.setup(spots=spots, web_server=web_server)
|
||||
if p.enabled:
|
||||
p.start()
|
||||
|
||||
@@ -84,18 +94,14 @@ if __name__ == '__main__':
|
||||
for entry in config["alert-providers"]:
|
||||
alert_providers.append(get_alert_provider_from_config(entry))
|
||||
for p in alert_providers:
|
||||
p.setup(alerts=alerts)
|
||||
p.setup(alerts=alerts, web_server=web_server)
|
||||
if p.enabled:
|
||||
p.start()
|
||||
|
||||
# Set up timer to clear spot list of old data
|
||||
cleanup_timer = CleanupTimer(spots=spots, alerts=alerts, cleanup_interval=60)
|
||||
cleanup_timer = CleanupTimer(spots=spots, alerts=alerts, web_server=web_server, cleanup_interval=60)
|
||||
cleanup_timer.start()
|
||||
|
||||
# Set up web server
|
||||
web_server = WebServer(spots=spots, alerts=alerts, status_data=status_data, port=WEB_SERVER_PORT)
|
||||
web_server.start()
|
||||
|
||||
# Set up status reporter
|
||||
status_reporter = StatusReporter(status_data=status_data, spots=spots, alerts=alerts, web_server=web_server,
|
||||
cleanup_timer=cleanup_timer, spot_providers=spot_providers,
|
||||
@@ -103,3 +109,8 @@ if __name__ == '__main__':
|
||||
status_reporter.start()
|
||||
|
||||
logging.info("Startup complete.")
|
||||
|
||||
# Run the web server. This is the blocking call that keeps the application running in the main thread, so this must
|
||||
# be the last thing we do. web_server.stop() triggers an await condition in the web server which finishes the main
|
||||
# thread.
|
||||
web_server.start()
|
||||
|
||||
@@ -51,7 +51,6 @@ class APRSIS(SpotProvider):
|
||||
comment=data["comment"] if "comment" in data else None,
|
||||
dx_latitude=data["latitude"] if "latitude" in data else None,
|
||||
dx_longitude=data["longitude"] if "longitude" in data else None,
|
||||
icon="tower-cell",
|
||||
time=datetime.now(pytz.UTC).timestamp()) # APRS-IS spots are live so we can assume spot time is "now"
|
||||
|
||||
# Add to our list
|
||||
|
||||
@@ -12,22 +12,27 @@ from data.spot import Spot
|
||||
from spotproviders.spot_provider import SpotProvider
|
||||
|
||||
|
||||
# Spot provider for a DX Cluster. Hostname port and login_prompt provided as parameters.
|
||||
# Spot provider for a DX Cluster. Hostname, port, login_prompt, login_callsign and allow_rbn_spots are provided in config.
|
||||
# See config-example.yml for examples.
|
||||
class DXCluster(SpotProvider):
|
||||
# Note the callsign pattern deliberately excludes calls ending in "-#", which are from RBN and can be enabled by
|
||||
# default on some clusters. If you want RBN spots, there is a separate provider for that.
|
||||
CALLSIGN_PATTERN = "([a-z|0-9|/]+)"
|
||||
FREQUENCY_PATTERN = "([0-9|.]+)"
|
||||
LINE_PATTERN = re.compile(
|
||||
LINE_PATTERN_EXCLUDE_RBN = re.compile(
|
||||
"^DX de " + CALLSIGN_PATTERN + ":\\s+" + FREQUENCY_PATTERN + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)",
|
||||
re.IGNORECASE)
|
||||
LINE_PATTERN_ALLOW_RBN = re.compile(
|
||||
"^DX de " + CALLSIGN_PATTERN + "-?#?:\\s+" + FREQUENCY_PATTERN + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)",
|
||||
re.IGNORECASE)
|
||||
|
||||
# Constructor requires hostname and port
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config)
|
||||
self.hostname = provider_config["host"]
|
||||
self.port = provider_config["port"]
|
||||
self.login_prompt = provider_config["login_prompt"]
|
||||
self.login_prompt = provider_config["login_prompt"] if "login_prompt" in provider_config else "login:"
|
||||
self.login_callsign = provider_config["login_callsign"] if "login_callsign" in provider_config else SERVER_OWNER_CALLSIGN
|
||||
self.allow_rbn_spots = provider_config["allow_rbn_spots"] if "allow_rbn_spots" in provider_config else False
|
||||
self.spot_line_pattern = self.LINE_PATTERN_ALLOW_RBN if self.allow_rbn_spots else self.LINE_PATTERN_EXCLUDE_RBN
|
||||
self.telnet = None
|
||||
self.thread = Thread(target=self.handle)
|
||||
self.thread.daemon = True
|
||||
@@ -50,7 +55,7 @@ class DXCluster(SpotProvider):
|
||||
logging.info("DX Cluster " + self.hostname + " connecting...")
|
||||
self.telnet = telnetlib3.Telnet(self.hostname, self.port)
|
||||
self.telnet.read_until(self.login_prompt.encode("latin-1"))
|
||||
self.telnet.write((SERVER_OWNER_CALLSIGN + "\n").encode("latin-1"))
|
||||
self.telnet.write((self.login_callsign + "\n").encode("latin-1"))
|
||||
connected = True
|
||||
logging.info("DX Cluster " + self.hostname + " connected.")
|
||||
except Exception as e:
|
||||
@@ -63,7 +68,7 @@ class DXCluster(SpotProvider):
|
||||
try:
|
||||
# Check new telnet info against regular expression
|
||||
telnet_output = self.telnet.read_until("\n".encode("latin-1"))
|
||||
match = self.LINE_PATTERN.match(telnet_output.decode("latin-1"))
|
||||
match = self.spot_line_pattern.match(telnet_output.decode("latin-1"))
|
||||
if match:
|
||||
spot_time = datetime.strptime(match.group(5), "%H%MZ")
|
||||
spot_datetime = datetime.combine(datetime.today(), spot_time.time()).replace(tzinfo=pytz.UTC)
|
||||
@@ -72,7 +77,6 @@ class DXCluster(SpotProvider):
|
||||
de_call=match.group(1),
|
||||
freq=float(match.group(2)) * 1000,
|
||||
comment=match.group(4).strip(),
|
||||
icon="desktop",
|
||||
time=spot_datetime.timestamp())
|
||||
|
||||
# Add to our list
|
||||
|
||||
@@ -41,6 +41,8 @@ class GMA(HTTPSpotProvider):
|
||||
dx_longitude=float(source_spot["LON"]) if (source_spot["LON"] and source_spot["LON"] != "") else None)
|
||||
|
||||
# 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
|
||||
@@ -50,7 +52,8 @@ class GMA(HTTPSpotProvider):
|
||||
# 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 "reftype" in ref_info and ref_info["reftype"] not in ["POTA", "WWFF"] and (ref_info["reftype"] != "Summit" or ref_info["sota"] == ""):
|
||||
if "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"
|
||||
@@ -79,4 +82,6 @@ class GMA(HTTPSpotProvider):
|
||||
# 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:
|
||||
logging.warn("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot["REF"] + ", ignoring this spot for now")
|
||||
return new_spots
|
||||
|
||||
@@ -70,7 +70,6 @@ class RBN(SpotProvider):
|
||||
de_call=match.group(1),
|
||||
freq=float(match.group(2)) * 1000,
|
||||
comment=match.group(4).strip(),
|
||||
icon="tower-cell",
|
||||
time=spot_datetime.timestamp())
|
||||
|
||||
# Add to our list
|
||||
|
||||
@@ -45,9 +45,8 @@ 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"])],
|
||||
time=datetime.fromisoformat(source_spot["timeStamp"]).timestamp(),
|
||||
activation_score=source_spot["points"])
|
||||
sig_refs=[SIGRef(id=source_spot["summitCode"], sig="SOTA", name=source_spot["summitName"], activation_score=source_spot["points"])],
|
||||
time=datetime.fromisoformat(source_spot["timeStamp"]).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.
|
||||
|
||||
@@ -16,10 +16,12 @@ class SpotProvider:
|
||||
self.last_spot_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||
self.status = "Not Started" if self.enabled else "Disabled"
|
||||
self.spots = None
|
||||
self.web_server = None
|
||||
|
||||
# Set up the provider, e.g. giving it the spot list to work from
|
||||
def setup(self, spots):
|
||||
def setup(self, spots, web_server):
|
||||
self.spots = spots
|
||||
self.web_server = web_server
|
||||
|
||||
# Start the provider. This should return immediately after spawning threads to access the remote resources
|
||||
def start(self):
|
||||
@@ -30,24 +32,32 @@ class SpotProvider:
|
||||
# their infer_missing() method called to complete their data set. This is called by the API-querying
|
||||
# subclasses on receiving spots.
|
||||
def submit_batch(self, spots):
|
||||
# Sort the batch so that earliest ones go in first. This helps keep the ordering correct when spots are fired
|
||||
# off to SSE listeners.
|
||||
spots = sorted(spots, key=lambda spot: (spot.time if spot and spot.time else 0))
|
||||
for spot in spots:
|
||||
if datetime.fromtimestamp(spot.time, pytz.UTC) > self.last_spot_time:
|
||||
# Fill in any blanks
|
||||
# Fill in any blanks and add to the list
|
||||
spot.infer_missing()
|
||||
# Add to the list
|
||||
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
|
||||
self.add_spot(spot)
|
||||
self.last_spot_time = datetime.fromtimestamp(max(map(lambda s: s.time, spots)), pytz.UTC)
|
||||
|
||||
# Submit a single spot retrieved from the provider. This will be added to the list regardless of its age. Spots
|
||||
# passing the check will also have their infer_missing() method called to complete their data set. This is called by
|
||||
# the data streaming subclasses, which can be relied upon not to re-provide old spots.
|
||||
def submit(self, spot):
|
||||
# Fill in any blanks
|
||||
# Fill in any blanks and add to the list
|
||||
spot.infer_missing()
|
||||
# Add to the list
|
||||
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
|
||||
self.add_spot(spot)
|
||||
self.last_spot_time = datetime.fromtimestamp(spot.time, pytz.UTC)
|
||||
|
||||
def add_spot(self, spot):
|
||||
if not spot.expired():
|
||||
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
|
||||
# Ping the web server in case we have any SSE connections that need to see this immediately
|
||||
if self.web_server:
|
||||
self.web_server.notify_new_spot(spot)
|
||||
|
||||
# Stop any threads and prepare for application shutdown
|
||||
def stop(self):
|
||||
raise NotImplementedError("Subclasses must implement this method")
|
||||
@@ -61,7 +61,6 @@ class UKPacketNet(HTTPSpotProvider):
|
||||
freq=freq,
|
||||
mode="PKT",
|
||||
comment=comment,
|
||||
icon="tower-cell",
|
||||
time=datetime.strptime(heard["lastHeard"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp(),
|
||||
de_grid=node["location"]["locator"] if "locator" in node["location"] else None,
|
||||
de_latitude=node["location"]["coords"]["lat"],
|
||||
|
||||
75
spotproviders/websocket_spot_provider.py
Normal file
75
spotproviders/websocket_spot_provider.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from threading import Thread
|
||||
from time import sleep
|
||||
|
||||
import pytz
|
||||
from websocket import create_connection
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from spotproviders.spot_provider import SpotProvider
|
||||
|
||||
|
||||
# Spot provider using websockets.
|
||||
class WebsocketSpotProvider(SpotProvider):
|
||||
|
||||
def __init__(self, provider_config, url):
|
||||
super().__init__(provider_config)
|
||||
self.url = url
|
||||
self.ws = None
|
||||
self.thread = None
|
||||
self.stopped = False
|
||||
self.last_event_id = None
|
||||
|
||||
def start(self):
|
||||
logging.info("Set up websocket connection to " + self.name + " spot API.")
|
||||
self.stopped = False
|
||||
self.thread = Thread(target=self.run)
|
||||
self.thread.daemon = True
|
||||
self.thread.start()
|
||||
|
||||
def stop(self):
|
||||
self.stopped = True
|
||||
if self.ws:
|
||||
self.ws.close()
|
||||
if self.thread:
|
||||
self.thread.join()
|
||||
|
||||
def _on_open(self):
|
||||
self.status = "Waiting for Data"
|
||||
|
||||
def _on_error(self):
|
||||
self.status = "Connecting"
|
||||
|
||||
def run(self):
|
||||
while not self.stopped:
|
||||
try:
|
||||
logging.debug("Connecting to " + self.name + " spot API...")
|
||||
self.status = "Connecting"
|
||||
self.ws = create_connection(self.url, header=HTTP_HEADERS)
|
||||
self.status = "Connected"
|
||||
data = self.ws.recv()
|
||||
if data:
|
||||
try:
|
||||
new_spot = self.ws_message_to_spot(data)
|
||||
if new_spot:
|
||||
self.submit(new_spot)
|
||||
|
||||
self.status = "OK"
|
||||
self.last_update_time = datetime.now(pytz.UTC)
|
||||
logging.debug("Received data from " + self.name + " spot API.")
|
||||
|
||||
except Exception as e:
|
||||
logging.exception("Exception processing message from Websocket Spot Provider (" + self.name + ")")
|
||||
|
||||
except Exception as e:
|
||||
self.status = "Error"
|
||||
logging.exception("Exception in Websocket Spot Provider (" + self.name + ")", e)
|
||||
else:
|
||||
self.status = "Disconnected"
|
||||
sleep(5) # Wait before trying to reconnect
|
||||
|
||||
# Convert a WS message received from the API into a spot. The exact message data (in bytes) is provided here so the
|
||||
# subclass implementations can handle the message as string, JSON, XML, whatever the API actually provides.
|
||||
def ws_message_to_spot(self, bytes):
|
||||
raise NotImplementedError("Subclasses must implement this method")
|
||||
@@ -47,6 +47,7 @@ class WOTA(HTTPSpotProvider):
|
||||
freq_mode = desc_split[0].replace("Frequencies/modes:", "").strip()
|
||||
freq_mode_split = re.split(r'[\-\s]+', freq_mode)
|
||||
freq_hz = float(freq_mode_split[0]) * 1000000
|
||||
if len(freq_mode_split) > 1:
|
||||
mode = freq_mode_split[1].upper()
|
||||
|
||||
comment = None
|
||||
|
||||
@@ -1,43 +1,55 @@
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||
from spotproviders.websocket_spot_provider import WebsocketSpotProvider
|
||||
|
||||
|
||||
# Spot provider for servers based on the "xOTA" software at https://github.com/nischu/xOTA/
|
||||
# The provider typically doesn't give us a lat/lon or SIG explicitly, so our own config provides this information. This
|
||||
# functionality is implemented for TOTA events.
|
||||
class XOTA(HTTPSpotProvider):
|
||||
POLL_INTERVAL_SEC = 300
|
||||
FIXED_LATITUDE = None
|
||||
FIXED_LONGITUDE = None
|
||||
# The provider typically doesn't give us a lat/lon or SIG explicitly, so our own config provides a SIG and a reference
|
||||
# to a local CSV file with location information. This functionality is implemented for TOTA events, of which there are
|
||||
# several - so a plain lookup of a "TOTA reference" doesn't make sense, it depends on which TOTA and hence which server
|
||||
# supplied the data, which is why the CSV location lookup is here and not in sig_utils.
|
||||
class XOTA(WebsocketSpotProvider):
|
||||
LOCATION_DATA = {}
|
||||
SIG = None
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, provider_config["url"] + "/api/spot/all", self.POLL_INTERVAL_SEC)
|
||||
self.FIXED_LATITUDE = provider_config["latitude"] if "latitude" in provider_config else None
|
||||
self.FIXED_LONGITUDE = provider_config["longitude"] if "longitude" in provider_config else None
|
||||
super().__init__(provider_config, provider_config["url"])
|
||||
locations_csv = provider_config["locations-csv"] if "locations-csv" in provider_config else None
|
||||
self.SIG = provider_config["sig"] if "sig" in provider_config else None
|
||||
|
||||
def http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
# Iterate through source data
|
||||
for source_spot in http_response.json():
|
||||
# Convert to our spot format
|
||||
# Load location data
|
||||
if locations_csv:
|
||||
try:
|
||||
f = open(locations_csv)
|
||||
csv_data = f.read()
|
||||
dr = csv.DictReader(csv_data.splitlines())
|
||||
for row in dr:
|
||||
self.LOCATION_DATA[row["ref"]] = {"lat": row["lat"], "lon": row["lon"]}
|
||||
except:
|
||||
logging.exception("Could not look up location data for XOTA source.")
|
||||
|
||||
def ws_message_to_spot(self, bytes):
|
||||
string = bytes.decode("utf-8")
|
||||
source_spot = json.loads(string)
|
||||
ref_id = source_spot["reference"]["title"]
|
||||
lat = float(self.LOCATION_DATA[ref_id]["lat"]) if ref_id in self.LOCATION_DATA else None
|
||||
lon = float(self.LOCATION_DATA[ref_id]["lon"]) if ref_id in self.LOCATION_DATA else None
|
||||
spot = Spot(source=self.name,
|
||||
source_id=source_spot["id"],
|
||||
dx_call=source_spot["stationCallSign"].upper(),
|
||||
freq=float(source_spot["freq"]) * 1000,
|
||||
mode=source_spot["mode"].upper(),
|
||||
sig=self.SIG,
|
||||
sig_refs=[SIGRef(id=source_spot["reference"]["title"], sig=self.SIG, url=source_spot["reference"]["website"])],
|
||||
time=datetime.fromisoformat(source_spot["modificationDate"]).timestamp(),
|
||||
dx_latitude=self.FIXED_LATITUDE,
|
||||
dx_longitude=self.FIXED_LONGITUDE,
|
||||
sig_refs=[SIGRef(id=ref_id, sig=self.SIG, url=source_spot["reference"]["website"], latitude=lat, longitude=lon)],
|
||||
time=datetime.now(pytz.UTC).timestamp(),
|
||||
dx_latitude=lat,
|
||||
dx_longitude=lon,
|
||||
qrt=source_spot["state"] != "active")
|
||||
|
||||
# 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)
|
||||
return new_spots
|
||||
return spot
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
% rebase('webpage_base.tpl')
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<div id="info-container" class="mt-4">
|
||||
<h2 class="mt-4 mb-4">About Spothole</h2>
|
||||
<p>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.</p>
|
||||
<p>While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outdoor activity programmes for amateur radio, Spothole differentiates itself by supporting a large number of data sources, and by being "API first" rather than just providing a web front-end. This allows other software to be built on top of it.</p>
|
||||
<p>While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outdoor activity programmes for amateur radio, Spothole differentiates itself by supporting a larger number of data sources, and by being "API first" rather than just providing a web front-end. This allows other software to be built on top of it.</p>
|
||||
<p>The API is deliberately well-defined with an <a href="/apidocs/openapi.yml">OpenAPI specification</a> and <a href="/apidocs">API documentation</a>. The API delivers spots in a consistent format regardless of the data source, freeing developers from needing to know how each individual data source presents its data.</p>
|
||||
<p>Spothole itself is also open source, Public Domain licenced code that anyone can take and modify. <a href="https://git.ianrenton.com/ian/metaspot/">The source code is here</a>. If you want to run your own copy of Spothole, or start modifying it for your own purposes, the <a href="https://git.ianrenton.com/ian/spothole/src/branch/main/README.md">README file</a> contains a description of how the software works and how it's laid out, as well as instructions for configuring systemd, nginx and anything else you might need to run your own server.</p>
|
||||
<p>The software was written by <a href="https://ianrenton.com">Ian Renton, MØTRT</a> and other contributors. Full details are available in the README.</p>
|
||||
<p>Spothole itself is also open source, Public Domain licenced code that anyone can take and modify. <a href="https://git.ianrenton.com/ian/metaspot/">The source code is here</a>.</p>
|
||||
<p>The software was written by <a href="https://ianrenton.com">Ian Renton, MØTRT</a> and other contributors. Full details are available in the <a href="https://git.ianrenton.com/ian/spothole/src/branch/main/README.md">README file</a>.</p>
|
||||
<p>This server is running Spothole version {{software_version}}.</p>
|
||||
<h2 class="mt-4 mb-4">Using Spothole</h2>
|
||||
<p>There are a number of different ways to use Spothole, depending on what you want to do with it and your level of technical skill:</p>
|
||||
<ol><li>You can <b>use it on the web</b>, like you are (probably) doing right now. This is how most people use it, to look up spots and alerts, and make interesting QSOs.</li>
|
||||
<li>If you are using an Android or iOS device, you can <b>"install" it on your device</b>. Spothole is a Progressive Web App, meaning it's not delivered through app stores, but if you open the page on Chrome (Android) or Safari (iOS) there will be an option in the menu to install it. It will then appear in your main app menu.</li>
|
||||
<li>You can <b>embed the web interface in another website</b> to show its spots in a custom dashboard or the like. The usage is explained in more detail in the <a href="https://git.ianrenton.com/ian/spothole/src/branch/main/README.md">README file</a>.</li>
|
||||
<li>You can <b>write your own client using the Spothole API</b>, using the main Spothole instance to provide data, and do whatever you like with it. The README contains guidance on how to do this, and the full API docs are linked above. You can also find reference implementations in the form of Spothole's own web-based front end, plus my other two tools built on Spothole: <a href="https://fieldspotter.radio">Field Spotter</a> and the <a href="https://qsomap.m0trt.radio">QSO Map Tool</a>.</li>
|
||||
<li>If you want to <b>run your own version of Spothole</b> so you can customise the configuration, such as enabling sources that I disable on the main instance, you can do that too. The README contains not only advice on how to set up Spothole but how to get it auto-starting with systemd, using an nginx reverse proxy, and setting up HTTPS support with certbot.</li>
|
||||
<li>Finally, you can of course download the source code and <b>develop Spothole to meet your needs</b>. Whether you contribute your changes back to the main repository is up to you. As usual, the README file contains some advice on the structure of the repository, and how to get started writing your own spot provider.</li></ol>
|
||||
<h2 id="faq" class="mt-4">FAQ</h2>
|
||||
<h4 class="mt-4">"Spots"? "DX Clusters"? What does any of this mean?</h4>
|
||||
<p>This is a tool for amateur ("ham") radio users. Many amateur radio operators like to make contacts with others who are doing something more interesting than sitting in their home "shack", such as people in rarely-seen countries, remote islands, or on mountaintops. Such operators are often "spotted", i.e. when someone speaks to them, they will put the details such as their operating frequency into an online system, to let others know where to find them. A DX Cluster is one type of those systems. Most outdoor radio awards programmes, such as "Parks on the Air" (POTA) have their own websites for posting spots.</p>
|
||||
@@ -16,10 +25,18 @@
|
||||
<h4 class="mt-4">What are "DX", "DE" and modes?</h4>
|
||||
<p>In amateur radio terminology, the "DX" contact is the "interesting" one that is using the frequency shown and looking for callers. They might be on a remote island or just in a local park, but either way it's interesting enough that someone has "spotted" them. The callsign listed under "DE" is the person who entered the spot of the "DX" operator. "Modes" are the type of communication they are using. For example you might see "CW" which is Morse Code, or voice "modes" like SSB or FM, or more exotic "data" modes which are used for computer-to-computer communication.</p>
|
||||
<h4 class="mt-4">What data sources are supported?</h4>
|
||||
<p>Spothole can retrieve spots from: Telnet-based DX clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, the UK Packet Repeater Network, and any site based on the xOTA software by nischu.</p>
|
||||
<p>Spothole can retrieve alerts from: NG3K, POTA, SOTA, WWFF, Parks 'n' Peaks, WOTA and BOTA.</p>
|
||||
<p>Spothole can retrieve spots from: <a href="https://www.dxcluster.info/telnet/">Telnet-based DX clusters</a>, the <a href="https://www.reversebeacon.net/">Reverse Beacon Network (RBN)</a>, the <a href="https://www.aprs-is.net/">APRS Internet Service (APRS-IS)</a>, <a href="https://pota.app">POTA</a>, <a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a href="https://www.cqgma.org/">GMA</a>, <a href="https://wwbota.net/">WWBOTA</a>, <a href="http://www.hema.org.uk/">HEMA</a>, <a href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://ontheair.nz">ZLOTA</a>, <a href="https://www.wota.org.uk/">WOTA</a>, the <a href="https://ukpacketradio.network/">UK Packet Repeater Network</a>, and any site based on the <a href="https://github.com/nischu/xOTA">xOTA software by nischu</a>.</p>
|
||||
<p>Spothole can retrieve alerts from: <a href="https://www.ng3k.com/">NG3K</a>, <a href="https://pota.app">POTA</a>, <a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://www.wota.org.uk/">WOTA</a> and <a href="https://www.beachesontheair.com/">BOTA</a>.</p>
|
||||
<p>Note that the server owner has not necessarily enabled all these data sources. In particular it is common to disable RBN, to avoid the server being swamped with FT8 traffic, and to disable APRS-IS and UK Packet Net so that the server only displays stations where there is likely to be an operator physically present for a QSO.</p>
|
||||
<p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: Parks on the Air (POTA), Summits on the Air (SOTA), Worldwide Flora & Fauna (WWFF), Global Mountain Activity (GMA), Worldwide Bunkers on the Air (WWBOTA), HuMPs Excluding Marilyns Award (HEMA), Islands on the Air (IOTA), Mills on the Air (MOTA), the Amateur Radio Lighthouse Socirty (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National Parks Award (KRMNPA), Wainwrights on the Air (WOTA), Beaches on the Air (BOTA), Worked All Britain (WAB), Worked All Ireland (WAI), and Toilets on the Air (TOTA).</p>
|
||||
<p>As of the time of writing in November 2025, I think Spothole captures essentially all outdoor radio programmes that have a defined reference list, and almost certainly those that have a spotting/alerting API. If you know of one I've missed, please let me know!</p>
|
||||
<h4 class="mt-4">Why can I filter spots by both SIG and Source? Isn't that basically the same thing?</h4>
|
||||
<p>Mostly, but not quite. While POTA spots generally come from the POTA source and so on, there are a few exceptions:</p>
|
||||
<ol><li>Sources like GMA and Parks 'n' Peaks provide spots for multiple different programmes (SIGs).</li>
|
||||
<li>Cluster spots may name SIGs in their comment, in which case the source remains the Cluster, but a SIG is assigned.</li>
|
||||
<li>Some SIGs, such as Worked all Britain (WAB), don't have their own spotting site and can <em>only</em> be identified through comments on spots retrieved from other sources.</li>
|
||||
<li>SIGs have well-defined names, whereas the server owner may name the sources as they see fit.</li></ol>
|
||||
<p>Spothole's web interface exists not just for the end user, but also as a reference implementation for the API, so I have chosen to demonstrate both methods of filtering.</p>
|
||||
<h4 class="mt-4">How is this better than DXheat, DXsummit, POTA's own website, etc?</h4>
|
||||
<p>It's probably not? But it's nice to have choice.</p>
|
||||
<p>I think it's got three key advantages over those sites:</p>
|
||||
@@ -30,7 +47,7 @@
|
||||
<p>Spothole is a Progressive Web App, which means you can install it on an Android or iOS device by opening the site in Chrome or Safari respectively, and clicking "Install" on the pop-up panel. It'll only prompt you once, so if you dismiss the prompt and change your mind, you'll find an Install / Add to Home Screen option on your browser's menu.</p>
|
||||
<p>Installing Spothole on your phone is completely optional, the website works exactly the same way as the "app" does.</p>
|
||||
<h4 class="mt-4">Why hasn't my spot/alert shown up yet?</h4>
|
||||
<p>To avoid putting too much load on the various servers that Spothole connects to, the Spothole server only polls them once every two minutes for spots, and once every hour for alerts. (Some sources, such as DX clusters, RBN, APRS-IS and WWBOTA use a non-polling mechanism, and their updates will therefore arrive more quickly.) Then if you are using the web interface, that has its own rate at which it reloads the data from Spothole, which is once a minute for spots or 30 minutes for alerts. So you could be waiting around three minutes to see a newly added spot, or 90 minutes to see a newly added alert.</p>
|
||||
<p>To avoid putting too much load on the various servers that Spothole connects to, the Spothole server only polls them once every two minutes for spots, and once every 30 minutes for alerts. (Some sources, such as DX clusters, RBN, APRS-IS and WWBOTA use a non-polling mechanism, and their updates will therefore arrive more quickly.) Then if you are using the web interface, that has its own rate at which it fetches the data from Spothole. This is instant for the main spots list, with new spots appearing immediately at the top of the list, while the map and bands displays update once a minute, and the alerts display updates once every 5 minutes. So you could be waiting around three minutes to see a newly added spot, or 40 minutes to see a newly added alert.</p>
|
||||
<h4 class="mt-4">What licence does Spothole use?</h4>
|
||||
<p>Spothole's source code is licenced under the Public Domain. You can write a Spothole client, run your own server, modify it however you like, you can claim you wrote it and charge people £1000 for a copy, I don't really mind. (Please don't do the last one. But if you're using my code for something cool, it would be nice to hear from you!)</p>
|
||||
<h2 class="mt-4">Data Accuracy</h2>
|
||||
@@ -46,4 +63,7 @@
|
||||
<p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p>
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=6"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -1,4 +1,5 @@
|
||||
% rebase('webpage_base.tpl')
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<div id="add-spot-intro-box" class="permanently-dismissible-box mt-3">
|
||||
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||
@@ -68,6 +69,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js"></script>
|
||||
<script src="/js/add-spot.js"></script>
|
||||
<script src="/js/common.js?v=6"></script>
|
||||
<script src="/js/add-spot.js?v=6"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -1,7 +1,8 @@
|
||||
% rebase('webpage_base.tpl')
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="row">
|
||||
<div id="settingsButtonRow" class="row">
|
||||
<div class="col-auto me-auto pt-3">
|
||||
<p id="timing-container">Loading...</p>
|
||||
</div>
|
||||
@@ -107,6 +108,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Theme</h5>
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
|
||||
<label class="form-check-label" for="darkMode">Dark mode</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -148,10 +162,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="table-container"></div>
|
||||
<div id="table-container">
|
||||
<table id="table" class="table"><thead><tr class="table-primary"></tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js"></script>
|
||||
<script src="/js/alerts.js"></script>
|
||||
<script src="/js/common.js?v=6"></script>
|
||||
<script src="/js/alerts.js?v=6"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -1,5 +1,8 @@
|
||||
% rebase('webpage_base.tpl')
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<redoc spec-url="/apidocs/openapi.yml"></redoc>
|
||||
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"> </script>
|
||||
<script>$(document).ready(function() { $("#nav-link-api").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -1,7 +1,8 @@
|
||||
% rebase('webpage_base.tpl')
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="row">
|
||||
<div id="settingsButtonRow" class="row">
|
||||
<div class="col-auto me-auto pt-3">
|
||||
<p id="timing-container">Loading...</p>
|
||||
</div>
|
||||
@@ -26,7 +27,7 @@
|
||||
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row row-cols-1 g-4 mb-4">
|
||||
<div class="row row-cols-1 g-4 mb-4 row-cols-md-3">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -35,8 +36,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">SIGs</h5>
|
||||
<p id="sig-options" class="card-text spothole-card-text"></p>
|
||||
</div>
|
||||
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Sources</h5>
|
||||
<p id="source-options" class="card-text spothole-card-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row-cols-1 row-cols-md-3 g-4">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -61,14 +78,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Sources</h5>
|
||||
<p id="source-options" class="card-text spothole-card-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,6 +108,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Theme</h5>
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
|
||||
<label class="form-check-label" for="darkMode">Dark mode</label>
|
||||
</div>
|
||||
<p class="card-text spothole-card-text">
|
||||
Band color scheme<br/>
|
||||
<select id="band-color-scheme" class="storeable-select form-select d-inline-block" oninput="setBandColorSchemeFromUI();" style="display: inline-block;">
|
||||
</select>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,7 +134,9 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js"></script>
|
||||
<script src="/js/spotandmap.js"></script>
|
||||
<script src="/js/bands.js"></script>
|
||||
<script src="/js/common.js?v=6"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=6"></script>
|
||||
<script src="/js/bands.js?v=6"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="white"/>
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
@@ -35,7 +35,7 @@
|
||||
<link rel="alternate icon" type="image/png" href="/img/icon-192.png">
|
||||
<link rel="alternate icon" type="image/png" href="/img/icon-32.png">
|
||||
<link rel="alternate icon" type="image/png" href="/img/icon-16.png">
|
||||
<link rel="alternate icon" type="image/x-icon" href="/img/favicon.ico">
|
||||
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico">
|
||||
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
|
||||
@@ -44,27 +44,33 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/tinycolor2@1.6.0/cjs/tinycolor.min.js"></script>
|
||||
|
||||
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=6"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=6"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=6"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=6"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<nav class="navbar navbar-expand-lg bg-body p-0 border-bottom">
|
||||
<nav id="header" class="navbar navbar-expand-lg bg-body p-0 border-bottom">
|
||||
<div class="container-fluid p-0">
|
||||
<a class="navbar-brand" href="/">
|
||||
<img src="/img/logo.png" width="192" height="60" alt="Spothole">
|
||||
<img src="/img/logo.png" class="logo" width="192" height="60" alt="Spothole">
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarTogglerDemo02" aria-controls="navbarTogglerDemo02" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-toggler-content" aria-controls="navbar-toggler-content" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarTogglerDemo02">
|
||||
<div class="collapse navbar-collapse" id="navbar-toggler-content">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item ms-4"><a href="/" class="nav-link" id="nav-link-spots"><i class="fa-solid fa-tower-cell"></i> Spots</a></li>
|
||||
<li class="nav-item ms-4"><a href="/map" class="nav-link" id="nav-link-map"><i class="fa-solid fa-map"></i> Map</a></li>
|
||||
<li class="nav-item ms-4"><a href="/bands" class="nav-link" id="nav-link-bands"><i class="fa-solid fa-ruler-vertical"></i> Bands</a></li>
|
||||
<li class="nav-item ms-4"><a href="/alerts" class="nav-link" id="nav-link-alerts"><i class="fa-solid fa-bell"></i> Alerts</a></li>
|
||||
% if allow_spotting:
|
||||
<li class="nav-item ms-4"><a href="/add-spot" class="nav-link" id="nav-link-add-spot"><i class="fa-solid fa-comment"></i> Add Spot</a></li>
|
||||
% end
|
||||
{% if allow_spotting %}
|
||||
<li class="nav-item ms-4"><a href="/add-spot" class="nav-link" id="nav-link-add-spot"><i class="fa-solid fa-comment"></i> Add Spot</a></li>
|
||||
{% end %}
|
||||
<li class="nav-item ms-4"><a href="/status" class="nav-link" id="nav-link-status"><i class="fa-solid fa-chart-simple"></i> Status</a></li>
|
||||
<li class="nav-item ms-4"><a href="/about" class="nav-link" id="nav-link-about"><i class="fa-solid fa-circle-info"></i> About</a></li>
|
||||
<li class="nav-item ms-4"><a href="/apidocs" class="nav-link" id="nav-link-api"><i class="fa-solid fa-gear"></i> API</a></li>
|
||||
@@ -75,11 +81,11 @@
|
||||
|
||||
<main>
|
||||
|
||||
{{!base}}
|
||||
{% block content %}{% end %}
|
||||
|
||||
</main>
|
||||
|
||||
<div class="hideonmobile hideonmap">
|
||||
<div id="footer" class="hideonmobile hideonmap">
|
||||
<footer class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top">
|
||||
<p class="col-md-4 mb-0 text-body-secondary">Made with love by <a href="https://ianrenton.com" class="text-body-secondary">Ian, MØTRT</a> and other contributors.</p>
|
||||
<p class="col-md-4 mb-0 justify-content-center text-body-secondary" style="text-align: center;">Spothole v{{software_version}}</p>
|
||||
@@ -101,5 +107,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="embeddedModeFooter" class="text-body-secondary pt-2 px-3 pb-1">Powered by <img src="/img/logo.png" class="logo" width="96" height="30" alt="Spothole"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +1,8 @@
|
||||
% rebase('webpage_base.tpl')
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<div id="map">
|
||||
<div id="maptools" class="mt-3 px-3" style="z-index: 1002; position: relative;">
|
||||
<div id="settingsButtonRowMap" class="mt-3 px-3" style="z-index: 1002; position: relative;">
|
||||
<div class="row">
|
||||
<div class="col-auto me-auto pt-3"></div>
|
||||
<div class="col-auto">
|
||||
@@ -25,7 +26,7 @@
|
||||
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row row-cols-1 g-4 mb-4">
|
||||
<div class="row row-cols-1 g-4 mb-4 row-cols-md-3">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -34,8 +35,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">SIGs</h5>
|
||||
<p id="sig-options" class="card-text spothole-card-text"></p>
|
||||
</div>
|
||||
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Sources</h5>
|
||||
<p id="source-options" class="card-text spothole-card-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row-cols-1 row-cols-md-3 g-4">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -60,14 +77,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Sources</h5>
|
||||
<p id="source-options" class="card-text spothole-card-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,6 +120,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Theme</h5>
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
|
||||
<label class="form-check-label" for="darkMode">Dark mode</label>
|
||||
</div>
|
||||
<p class="card-text spothole-card-text">
|
||||
Band color scheme<br/>
|
||||
<select id="band-color-scheme" class="storeable-select form-select d-inline-block" oninput="setBandColorSchemeFromUI();" style="display: inline-block;">
|
||||
</select>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,7 +152,9 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/leaflet.geodesic"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@joergdietrich/leaflet.terminator@1.1.0/L.Terminator.min.js"></script>
|
||||
|
||||
<script src="/js/common.js"></script>
|
||||
<script src="/js/spotandmap.js"></script>
|
||||
<script src="/js/map.js"></script>
|
||||
<script src="/js/common.js?v=6"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=6"></script>
|
||||
<script src="/js/map.js?v=6"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -1,4 +1,5 @@
|
||||
% rebase('webpage_base.tpl')
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<div id="intro-box" class="permanently-dismissible-box mt-3">
|
||||
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||
@@ -8,15 +9,22 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="row">
|
||||
<div class="col-auto me-auto pt-3">
|
||||
<div id="settingsButtonRow" class="row">
|
||||
<div class="col-lg-6 me-auto pt-3 hideonmobile">
|
||||
<p id="timing-container">Loading...</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="col-lg-6 text-end">
|
||||
<p class="d-inline-flex gap-1">
|
||||
<span style="position: relative;">
|
||||
<i class="fa-solid fa-magnifying-glass" style="position: absolute; left: 0px; top: 2px; padding: 10px; pointer-events: none;"></i>
|
||||
<input id="filter-dx-call" type="search" class="form-control" oninput="filtersUpdated();" placeholder="Callsign">
|
||||
<span class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" name="runPause" id="runButton" autocomplete="off" checked>
|
||||
<label class="btn btn-outline-primary" for="runButton"><i class="fa-solid fa-play"></i> Run</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="runPause" id="pauseButton" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="pauseButton"><i class="fa-solid fa-pause"></i> Pause</label>
|
||||
</span>
|
||||
<span class="hideonmobile" style="position: relative;">
|
||||
<i id="searchicon" class="fa-solid fa-magnifying-glass"></i>
|
||||
<input id="search" type="search" class="form-control" oninput="filtersUpdated();" placeholder="Search">
|
||||
</span>
|
||||
<button id="filters-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i> Filters</button>
|
||||
<button id="display-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i> Display</button>
|
||||
@@ -37,7 +45,7 @@
|
||||
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row row-cols-1 g-4 mb-4">
|
||||
<div class="row row-cols-1 g-4 mb-4 row-cols-md-3">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -46,8 +54,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">SIGs</h5>
|
||||
<p id="sig-options" class="card-text spothole-card-text"></p>
|
||||
</div>
|
||||
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Sources</h5>
|
||||
<p id="source-options" class="card-text spothole-card-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row-cols-1 row-cols-md-3 g-4">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -72,14 +96,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Sources</h5>
|
||||
<p id="source-options" class="card-text spothole-card-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,6 +139,35 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Location</h5>
|
||||
<div class="form-group spothole-card-text">
|
||||
<label for="userGrid">Your grid:</label>
|
||||
<input type="text" class="storeable-text form-control" id="userGrid" placeholder="AA00aa" oninput="userGridUpdated();" style="width: 10em; display: inline-block;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Theme</h5>
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
|
||||
<label class="form-check-label" for="darkMode">Dark mode</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="card-text spothole-card-text">
|
||||
Band color scheme<br/>
|
||||
<select id="band-color-scheme" class="storeable-select form-select d-inline-block" oninput="setBandColorSchemeFromUI();" style="display: inline-block;">
|
||||
</select>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -168,26 +213,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Location</h5>
|
||||
<div class="form-group spothole-card-text">
|
||||
<label for="userGrid">Your grid:</label>
|
||||
<input type="text" class="storeable-text form-control" id="userGrid" placeholder="AA00aa" oninput="userGridUpdated();" style="width: 10em; display: inline-block;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="table-container"></div>
|
||||
<div id="table-container">
|
||||
<table id="table" class="table"><thead><tr class="table-primary"></tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js"></script>
|
||||
<script src="/js/spotandmap.js"></script>
|
||||
<script src="/js/spots.js"></script>
|
||||
<script src="/js/common.js?v=6"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=6"></script>
|
||||
<script src="/js/spots.js?v=6"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -1,7 +1,10 @@
|
||||
% rebase('webpage_base.tpl')
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div>
|
||||
|
||||
<script src="/js/common.js"></script>
|
||||
<script src="/js/status.js"></script>
|
||||
<script src="/js/common.js?v=6"></script>
|
||||
<script src="/js/status.js?v=6"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
10
webassets/.idea/.gitignore
generated
vendored
Normal file
10
webassets/.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
6
webassets/.idea/vcs.xml
generated
Normal file
6
webassets/.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,38 @@
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* In embedded mode, hide header/footer/settings. "#header div" is kind of janky but for some reason if we hide the
|
||||
whole of #header, the map vertical sizing breaks. */
|
||||
[embedded-mode=true] #header div, [embedded-mode=true] #footer,
|
||||
[embedded-mode=true] #settingsButtonRow, [embedded-mode=true] #settingsButtonRowMap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Display floating footer in embedded mode only */
|
||||
#embeddedModeFooter {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: var(--bs-body-bg);
|
||||
border-radius: 1em 0 0 0;
|
||||
font-size: 0.9em;
|
||||
border-top: 1px solid grey;
|
||||
border-left: 1px solid grey;
|
||||
}
|
||||
[embedded-mode=true] #embeddedModeFooter {
|
||||
display: block;
|
||||
}
|
||||
#embeddedModeFooter img.logo {
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
/* Invert logo colours in dark mode */
|
||||
[data-bs-theme=dark] .logo {
|
||||
filter: invert(100%) hue-rotate(180deg) brightness(80%);
|
||||
}
|
||||
|
||||
|
||||
/* INTRO/WARNING BOXES */
|
||||
|
||||
@@ -26,6 +58,11 @@ div.container {
|
||||
min-height:100svh;
|
||||
}
|
||||
|
||||
[embedded-mode=true] div.container {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
|
||||
/* ABOUT PAGE */
|
||||
|
||||
@@ -43,17 +80,22 @@ div.container {
|
||||
|
||||
/* SPOTS/ALERTS PAGES, SETTINGS/STATUS AREAS */
|
||||
|
||||
input#filter-dx-call {
|
||||
input#search {
|
||||
max-width: 12em;
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
div.appearing-panel {
|
||||
display: none;
|
||||
i#searchicon {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 2px;
|
||||
padding: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
button#add-spot-button {
|
||||
div.appearing-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -137,6 +179,31 @@ tr.table-faded td span {
|
||||
text-decoration: line-through !important;
|
||||
}
|
||||
|
||||
/* New spot styles */
|
||||
tr.new td {
|
||||
animation: 2s linear newspotanim;
|
||||
}
|
||||
@keyframes newspotanim {
|
||||
0% {
|
||||
background-color: var(--bs-success-border-subtle);
|
||||
}
|
||||
100% {
|
||||
background-color: intial;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fudge apply our own "dark primary" and "dark danger" backgrounds as Bootstrap doesn't do this itself */
|
||||
[data-bs-theme=dark] tr.table-primary {
|
||||
--bs-table-bg: #053680;
|
||||
--bs-table-border-color: #021b42;
|
||||
--bs-table-color: white;
|
||||
}
|
||||
[data-bs-theme=dark] tr.table-danger {
|
||||
--bs-table-bg: #74272e;
|
||||
--bs-table-border-color: #530208;
|
||||
--bs-table-color: white;
|
||||
}
|
||||
|
||||
|
||||
/* MAP */
|
||||
div#map {
|
||||
@@ -152,6 +219,16 @@ div#map {
|
||||
font-family: var(--bs-body-font-family) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] .leaflet-layer,
|
||||
[data-bs-theme=dark] .leaflet-control-attribution {
|
||||
filter: invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%);
|
||||
}
|
||||
|
||||
/* Make buttons overlaid on the map have a non-transparent fill so you can see the text better */
|
||||
.btn-outline-primary {
|
||||
--bs-btn-bg: var(--bs-body-bg) !important;
|
||||
}
|
||||
|
||||
|
||||
/* BANDS PANEL */
|
||||
|
||||
@@ -227,6 +304,10 @@ div.band-spot {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] div.band-spot {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
div.band-spot:hover {
|
||||
z-index: 999;
|
||||
}
|
||||
@@ -261,18 +342,13 @@ div.band-spot:hover span.band-spot-info {
|
||||
margin-right: -1em;
|
||||
}
|
||||
/* Avoid map page filters panel being larger than the map itself */
|
||||
#maptools .appearing-panel {
|
||||
#settingsButtonRowMap .appearing-panel {
|
||||
max-height: 30em;
|
||||
}
|
||||
#maptools .appearing-panel .card-body {
|
||||
#settingsButtonRowMap .appearing-panel .card-body {
|
||||
max-height: 26em;
|
||||
overflow: scroll;
|
||||
}
|
||||
/* Filter/search DX Call field should be smaller on mobile */
|
||||
input#filter-dx-call {
|
||||
max-width: 9em;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
|
||||
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
@@ -1,5 +1,5 @@
|
||||
// How often to query the server?
|
||||
const REFRESH_INTERVAL_SEC = 60 * 30;
|
||||
const REFRESH_INTERVAL_SEC = 60 * 10;
|
||||
|
||||
// Storage for the alert data that the server gives us.
|
||||
var alerts = []
|
||||
@@ -51,7 +51,8 @@ function updateTable() {
|
||||
var showRef = $("#tableShowRef")[0].checked;
|
||||
|
||||
// Populate table with headers
|
||||
let table = $('<table class="table table-hover">').append('<thead><tr class="table-primary"></tr></thead><tbody></tbody>');
|
||||
let table = $("#table");
|
||||
table.find('thead tr').empty();
|
||||
if (showStartTime) {
|
||||
table.find('thead tr').append(`<th>${useLocalTime ? "Start (Local)" : "Start UTC"}</th>`);
|
||||
}
|
||||
@@ -74,6 +75,8 @@ function updateTable() {
|
||||
table.find('thead tr').append(`<th class='hideonmobile'>Ref.</th>`);
|
||||
}
|
||||
|
||||
table.find('tbody').empty();
|
||||
|
||||
// Split alerts into three types, each of which will get its own table header: On now, next 24h, and later. "On now"
|
||||
// is considered to be events with an end_time where start<now<end, or events with no end time that started in the
|
||||
// last hour.
|
||||
@@ -100,9 +103,6 @@ function updateTable() {
|
||||
if (onNow.length == 0 && next24h.length == 0 && later.length == 0) {
|
||||
table.find('tbody').append('<tr class="table-danger"><td colspan="100" style="text-align:center;">No alerts match your filters.</td></tr>');
|
||||
}
|
||||
|
||||
// Update DOM
|
||||
$('#table-container').html(table);
|
||||
}
|
||||
|
||||
// Add a row to tbody for each alert in the provided list
|
||||
@@ -243,7 +243,7 @@ function addAlertRowsToTable(tbody, alerts) {
|
||||
$tr.append(`<td class='hideonmobile'>${commentText}</td>`);
|
||||
}
|
||||
if (showSource) {
|
||||
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid fa-${a["icon"]}'></i></span> ${sigSourceText}</td>`);
|
||||
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid ${sigToIcon(a["sig"], "fa-globe-africa")}'></i></span> ${sigSourceText}</td>`);
|
||||
}
|
||||
if (showRef) {
|
||||
$tr.append(`<td class='hideonmobile'>${sig_refs}</td>`);
|
||||
@@ -257,7 +257,7 @@ function addAlertRowsToTable(tbody, alerts) {
|
||||
}
|
||||
$td2 = $("<td colspan='100'>");
|
||||
if (showSource) {
|
||||
$td2.append(`<span class='icon-wrapper'><i class='fa-solid fa-${a["icon"]}'></i></span> `);
|
||||
$td2.append(`<span class='icon-wrapper'><i class='fa-solid ${sigToIcon(a["sig"], "fa-globe-africa")}'></i></span> `);
|
||||
}
|
||||
if (showRef) {
|
||||
$td2.append(`${sig_refs} `);
|
||||
@@ -292,6 +292,11 @@ function loadOptions() {
|
||||
})));
|
||||
$("#alerts-to-fetch").val(options["web-ui-options"]["alert-count-default"]);
|
||||
|
||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||
// loading settings, so this needs to be called before that.
|
||||
loadURLParams();
|
||||
|
||||
// Load filters from settings storage
|
||||
loadSettings();
|
||||
|
||||
@@ -307,6 +312,12 @@ function filtersUpdated() {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
// Function to set dark mode based on the state of the UI toggle in spots, bands and map pages
|
||||
function toggleDarkMode() {
|
||||
enableDarkMode($("#darkMode")[0].checked);
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
// React to toggling/closing panels
|
||||
function toggleFiltersPanel() {
|
||||
// If we are going to display the filters panel, hide the display panel
|
||||
@@ -341,3 +352,11 @@ $(document).ready(function() {
|
||||
// Update the refresh timing display every second
|
||||
setInterval(updateRefreshDisplay, 1000);
|
||||
});
|
||||
|
||||
// Reload alerts on becoming visible. This forces a refresh when used as a PWA and the user switches back to the PWA
|
||||
// after some time has passed with it in the background.
|
||||
addEventListener("visibilitychange", (event) => {
|
||||
if (!document.hidden) {
|
||||
loadAlerts();
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
// How often to query the server?
|
||||
const REFRESH_INTERVAL_SEC = 60;
|
||||
|
||||
// A couple of constants that must match what's in CSS. We need to know them before the content actually renders, so we
|
||||
// can't just ask the elements themselves for their dimensions.
|
||||
BAND_COLUMN_HEIGHT_EM = 62;
|
||||
@@ -23,7 +26,7 @@ function loadSpots() {
|
||||
// Build a query string for the API, based on the filters that the user has selected.
|
||||
function buildQueryString() {
|
||||
var str = "?";
|
||||
["dx_continent", "de_continent", "mode_type", "source", "band"].forEach(fn => {
|
||||
["dx_continent", "de_continent", "mode_type", "source", "band", "sig"].forEach(fn => {
|
||||
if (!allFilterOptionsSelected(fn)) {
|
||||
str = str + getQueryStringFor(fn) + "&";
|
||||
}
|
||||
@@ -67,7 +70,7 @@ function updateBands() {
|
||||
var table = $('<table id="bands-table">').append('<thead><tr></tr></thead><tbody><tr></tr></tbody>');
|
||||
bandToSpots.forEach(function (spotList, bandName) {
|
||||
// Get the colours for the band from the first spot, and prepare the header
|
||||
table.find('thead tr').append(`<th style='background-color:${spotList[0].band_color}; color:${spotList[0].band_contrast_color}'>${spotList[0].band}</th>`);
|
||||
table.find('thead tr').append(`<th style='background-color:${bandToColor(spotList[0].band)}; color:${bandToContrastColor(spotList[0].band)}'>${spotList[0].band}</th>`);
|
||||
|
||||
// Get the band data to fetch start and end frequencies
|
||||
let band = options["bands"].filter(function (b) {
|
||||
@@ -142,7 +145,7 @@ function updateBands() {
|
||||
|
||||
// Now each spot is tagged with how far down the div it should go, add them to the DOM.
|
||||
spotList.forEach(s => {
|
||||
bandSpotsDiv.append(`<div class="band-spot" style="top: ${s['pxDownBandLabel']}px; border-top: 1px solid ${s.band_color}; border-left: 5px solid ${s.band_color}; border-bottom: 1px solid ${s.band_color}; border-right: 1px solid ${s.band_color};"><span class="band-spot-call">${s.dx_call}${s.dx_ssid != null ? "-" + s.dx_ssid : ""}</span><span class="band-spot-info">${s.dx_call}${s.dx_ssid != null ? "-" + s.dx_ssid : ""} ${(s.freq/1000000).toFixed(3)} ${s.mode}</span></div>`);
|
||||
bandSpotsDiv.append(`<div class="band-spot" style="top: ${s['pxDownBandLabel']}px; border-top: 1px solid ${bandToColor(s['band'])}; border-left: 5px solid ${bandToColor(s['band'])}; border-bottom: 1px solid ${bandToColor(s['band'])}; border-right: 1px solid ${bandToColor(s['band'])};"><span class="band-spot-call">${s.dx_call}${s.dx_ssid != null ? "-" + s.dx_ssid : ""}</span><span class="band-spot-info">${s.dx_call}${s.dx_ssid != null ? "-" + s.dx_ssid : ""} ${(s.freq/1000000).toFixed(3)} ${s.mode}</span></div>`);
|
||||
});
|
||||
|
||||
// Work out how tall the canvas should be. Normally this is matching the normal band column height, but if some
|
||||
@@ -164,7 +167,7 @@ function updateBands() {
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = "round";
|
||||
ctx.strokeStyle = s.band_color;
|
||||
ctx.strokeStyle = bandToColor(s['band']);
|
||||
ctx.moveTo(0, pxDownBandFreq);
|
||||
ctx.lineTo(BAND_COLUMN_CANVAS_WIDTH_PX, pxDownBandLabel);
|
||||
ctx.stroke();
|
||||
@@ -225,22 +228,36 @@ function loadOptions() {
|
||||
// Store options
|
||||
options = jsonData;
|
||||
|
||||
// Add CSS for band toggle buttons
|
||||
addBandToggleColourCSS(options["bands"]);
|
||||
|
||||
// Populate the filters panel
|
||||
generateBandsMultiToggleFilterCard(options["bands"]);
|
||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
|
||||
generateMultiToggleFilterCard("#source-options", "source", options["spot_sources"]);
|
||||
|
||||
// Populate the Display panel
|
||||
options["web-ui-options"]["max-spot-age"].forEach(sc => $("#max-spot-age").append($('<option>', {
|
||||
value: sc * 60,
|
||||
text: sc
|
||||
})));
|
||||
$("#max-spot-age").val(options["web-ui-options"]["max-spot-age-default"] * 60);
|
||||
getAvailableBandColorSchemes().forEach(sc => $("#band-color-scheme").append($('<option>', {
|
||||
value: sc,
|
||||
text: sc
|
||||
})));
|
||||
|
||||
// First pass loading settings, so we can load the band colour scheme before the filters that need to use it
|
||||
loadSettings();
|
||||
setBandColorScheme($("#band-color-scheme option:selected").val());
|
||||
|
||||
// Add CSS for band toggle buttons
|
||||
addBandToggleColourCSS(options["bands"]);
|
||||
|
||||
// Populate the filters panel
|
||||
generateBandsMultiToggleFilterCard(options["bands"]);
|
||||
generateSIGsMultiToggleFilterCard(options["sigs"]);
|
||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
|
||||
generateSourcesMultiToggleFilterCard(options["spot_sources"], options["web-ui-options"]["spot-providers-enabled-by-default"]);
|
||||
|
||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||
// loading settings, so this needs to be called before that.
|
||||
loadURLParams();
|
||||
|
||||
// Load settings from settings storage now all the controls are available
|
||||
loadSettings();
|
||||
|
||||
@@ -3,6 +3,70 @@ var options = {};
|
||||
// Last time we updated the spots/alerts list on display.
|
||||
var lastUpdateTime;
|
||||
|
||||
// Load and apply any URL params. This is used for "embedded mode" where another site can embed a version of
|
||||
// Spothole and provide its own interface options rather than using the user's saved ones. These may select things
|
||||
// from the various filter & display options, so this function needs to be called after these are set up, but if
|
||||
// the URL params ask for "embedded mode", this will suppress loading settings, so this needs to be called before
|
||||
// that occurs..
|
||||
function loadURLParams() {
|
||||
let params = new URLSearchParams(document.location.search);
|
||||
|
||||
// Handle embedded mode. We set a global to e.g. suppress loading/saving settings, and apply an attribute to the
|
||||
// top-level html element to use CSS selectors to remove bits of UI.
|
||||
let embedded = params.get("embedded");
|
||||
if (embedded != null && embedded === "true") {
|
||||
useLocalStorage = false;
|
||||
$("html").attr("embedded-mode", "true");
|
||||
}
|
||||
|
||||
// Handle other params
|
||||
updateCheckboxFromParam(params, "dark-mode", "darkMode");
|
||||
updateSelectFromParam(params, "time-zone", "timeZone"); // Only on Spots and Alerts pages
|
||||
updateSelectFromParam(params, "limit", "spots-to-fetch"); // Only on Spots page
|
||||
updateSelectFromParam(params, "limit", "alerts-to-fetch"); // Only on Alerts page
|
||||
updateSelectFromParam(params, "max_age", "max-spot-age"); // Only on Map & Bands pages
|
||||
updateFilterFromParam(params, "band", "band");
|
||||
updateFilterFromParam(params, "sig", "sig");
|
||||
updateFilterFromParam(params, "source", "source");
|
||||
updateFilterFromParam(params, "mode_type", "mode_type");
|
||||
updateFilterFromParam(params, "dx_continent", "dx_continent");
|
||||
updateFilterFromParam(params, "de_continent", "de_continent");
|
||||
}
|
||||
|
||||
// Update an HTML checkbox element so that its selected matches the given parameter (which must have a true or false value)
|
||||
function updateCheckboxFromParam(params, paramName, checkboxID) {
|
||||
let v = params.get(paramName);
|
||||
if (v != null) {
|
||||
$("#" + checkboxID).prop("checked", (v === "true") ? true : false);
|
||||
// Extra check if this is the "dark mode" toggle
|
||||
if (checkboxID == "darkMode") {
|
||||
enableDarkMode((v === "true") ? true : false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update an HTML select element so that its value matches the given parameter
|
||||
function updateSelectFromParam(params, paramName, selectID) {
|
||||
let v = params.get(paramName);
|
||||
if (v != null) {
|
||||
$("#" + selectID).prop("value", v);
|
||||
}
|
||||
}
|
||||
|
||||
// Update a set of HTML checkbox elements describing a filter of the given name, so that any items named in the
|
||||
// parameter (as a comma-separated list) will be enabled, and all others disabled. e.g. if paramName is
|
||||
// "filter-band" and the params contain "filter-band=20m,40m", and prefix is "band", then #filter-button-band-30m
|
||||
// would be disabled but #filter-button-band-20m and #filter-button-band-40m would be enabled.
|
||||
function updateFilterFromParam(params, paramName, filterName) {
|
||||
let v = params.get(paramName);
|
||||
if (v != null) {
|
||||
// First uncheck all options for the filter
|
||||
$(".filter-button-" + filterName).prop("checked", false);
|
||||
// Now find out which ones should be enabled
|
||||
let s = v.split(",");
|
||||
s.forEach(val => $("#filter-button-" + filterName + "-" + val).prop("checked", true));
|
||||
}
|
||||
}
|
||||
|
||||
// For a parameter, such as dx_continent, get the query string for the current filter options.
|
||||
function getQueryStringFor(parameter) {
|
||||
@@ -49,9 +113,9 @@ function toggleFilterButtons(filterQuery, state) {
|
||||
// Update the refresh timing display
|
||||
function updateRefreshDisplay() {
|
||||
if (lastUpdateTime != null) {
|
||||
let count = REFRESH_INTERVAL_SEC;
|
||||
let secSinceUpdate = moment.duration(moment().diff(lastUpdateTime)).asSeconds();
|
||||
updatingString = "Updating..."
|
||||
let count = REFRESH_INTERVAL_SEC;
|
||||
let updatingString = "Updating..."
|
||||
if (secSinceUpdate < REFRESH_INTERVAL_SEC) {
|
||||
count = REFRESH_INTERVAL_SEC - secSinceUpdate;
|
||||
if (count <= 60) {
|
||||
@@ -66,27 +130,6 @@ function updateRefreshDisplay() {
|
||||
}
|
||||
}
|
||||
|
||||
// Utility function to escape HTML characters from a string.
|
||||
function escapeHtml(str) {
|
||||
if (typeof str !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const escapeCharacter = (match) => {
|
||||
switch (match) {
|
||||
case '&': return '&';
|
||||
case '<': return '<';
|
||||
case '>': return '>';
|
||||
case '"': return '"';
|
||||
case '\'': return ''';
|
||||
case '`': return '`';
|
||||
default: return match;
|
||||
}
|
||||
};
|
||||
|
||||
return str.replace(/[&<>"'`]/g, escapeCharacter);
|
||||
}
|
||||
|
||||
// When the "use local time" field is changed, reload the table and save settings
|
||||
function timeZoneUpdated() {
|
||||
updateTable();
|
||||
@@ -99,129 +142,30 @@ function columnsUpdated() {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
// Calculate great circle bearing between two lat/lon points.
|
||||
function calcBearing(lat1, lon1, lat2, lon2) {
|
||||
lat1 *= Math.PI / 180;
|
||||
lon1 *= Math.PI / 180;
|
||||
lat2 *= Math.PI / 180;
|
||||
lon2 *= Math.PI / 180;
|
||||
var lonDelta = lon2 - lon1;
|
||||
var y = Math.sin(lonDelta) * Math.cos(lat2);
|
||||
var x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(lonDelta);
|
||||
var bearing = Math.atan2(y, x);
|
||||
bearing = bearing * (180 / Math.PI);
|
||||
if ( bearing < 0 ) { bearing += 360; }
|
||||
return bearing;
|
||||
// Function to set dark mode on or off
|
||||
function enableDarkMode(dark) {
|
||||
$("html").attr("data-bs-theme", dark ? "dark" : "light");
|
||||
const metaThemeColor = document.querySelector("meta[name=theme-color]");
|
||||
metaThemeColor.setAttribute("content", dark ? "black" : "white");
|
||||
const metaAppleStatusBarStyle = document.querySelector("meta[name=apple-mobile-web-app-status-bar-style]");
|
||||
metaAppleStatusBarStyle.setAttribute("content", dark ? "black-translucent" : "white-translucent");
|
||||
}
|
||||
|
||||
// Convert a Maidenhead grid reference of arbitrary precision to the lat/long of the centre point of the square.
|
||||
// Returns null if the grid format is invalid.
|
||||
function latLonForGridCentre(grid) {
|
||||
let [lat, lon, latCellSize, lonCellSize] = latLonForGridSWCornerPlusSize(grid);
|
||||
if (lat != null && lon != null && latCellSize != null && lonCellSize != null) {
|
||||
return [lat + latCellSize / 2.0, lon + lonCellSize / 2.0];
|
||||
// Startup function to determine whether to use light or dark mode
|
||||
function usePreferredTheme() {
|
||||
// First, work out if we have ever explicitly saved the value of our toggle
|
||||
let val = localStorage.getItem("#darkMode:checked");
|
||||
if (val != null) {
|
||||
enableDarkMode(JSON.parse(val));
|
||||
} else {
|
||||
return null;
|
||||
// Never set it before, so use the system default theme and set the toggle up to match
|
||||
let dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
enableDarkMode(dark);
|
||||
$("#darkMode").prop('checked', dark);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert a Maidenhead grid reference of arbitrary precision to lat/long, including in the result the size of the
|
||||
// lowest grid square. This is a utility method used by the main methods that return the centre, southwest, and
|
||||
// northeast coordinates of a grid square.
|
||||
// The return type is always an array of size 4. The elements in it are null if the grid format is invalid.
|
||||
function latLonForGridSWCornerPlusSize(grid) {
|
||||
// Make sure we are in upper case so our maths works. Case is arbitrary for Maidenhead references
|
||||
grid = grid.toUpperCase();
|
||||
|
||||
// Return null if our Maidenhead string is invalid or too short
|
||||
let len = grid.length;
|
||||
if (len <= 0 || (len % 2) !== 0) {
|
||||
return [null, null, null, null];
|
||||
}
|
||||
|
||||
let lat = 0.0; // aggregated latitude
|
||||
let lon = 0.0; // aggregated longitude
|
||||
let latCellSize = 10; // Size in degrees latitude of the current cell. Starts at 20 and gets smaller as the calculation progresses
|
||||
let lonCellSize = 20; // Size in degrees longitude of the current cell. Starts at 20 and gets smaller as the calculation progresses
|
||||
let latCellNo; // grid latitude cell number this time
|
||||
let lonCellNo; // grid longitude cell number this time
|
||||
|
||||
// Iterate through blocks (two-character sections)
|
||||
for (let block = 0; block * 2 < len; block += 1) {
|
||||
if (block % 2 === 0) {
|
||||
// Letters in this block
|
||||
lonCellNo = grid.charCodeAt(block * 2) - 'A'.charCodeAt(0);
|
||||
latCellNo = grid.charCodeAt(block * 2 + 1) - 'A'.charCodeAt(0);
|
||||
// Bail if the values aren't in range. Allowed values are A-R (0-17) for the first letter block, or
|
||||
// A-X (0-23) thereafter.
|
||||
let maxCellNo = (block === 0) ? 17 : 23;
|
||||
if (latCellNo < 0 || latCellNo > maxCellNo || lonCellNo < 0 || lonCellNo > maxCellNo) {
|
||||
return [null, null, null, null];
|
||||
}
|
||||
} else {
|
||||
// Numbers in this block
|
||||
lonCellNo = parseInt(grid.charAt(block * 2));
|
||||
latCellNo = parseInt(grid.charAt(block * 2 + 1));
|
||||
// Bail if the values aren't in range 0-9..
|
||||
if (latCellNo < 0 || latCellNo > 9 || lonCellNo < 0 || lonCellNo > 9) {
|
||||
return [null, null, null, null];
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate the angles
|
||||
lat += latCellNo * latCellSize;
|
||||
lon += lonCellNo * lonCellSize;
|
||||
|
||||
// Reduce the cell size for the next block, unless we are on the last cell.
|
||||
if (block * 2 < len - 2) {
|
||||
// Still have more work to do, so reduce the cell size
|
||||
if (block % 2 === 0) {
|
||||
// Just dealt with letters, next block will be numbers so cells will be 1/10 the current size
|
||||
latCellSize = latCellSize / 10.0;
|
||||
lonCellSize = lonCellSize / 10.0;
|
||||
} else {
|
||||
// Just dealt with numbers, next block will be letters so cells will be 1/24 the current size
|
||||
latCellSize = latCellSize / 24.0;
|
||||
lonCellSize = lonCellSize / 24.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Offset back to (-180, -90) where the grid starts
|
||||
lon -= 180.0;
|
||||
lat -= 90.0;
|
||||
|
||||
// Return nulls on maths errors
|
||||
if (isNaN(lat) || isNaN(lon) || isNaN(latCellSize) || isNaN(lonCellSize)) {
|
||||
return [null, null, null, null];
|
||||
}
|
||||
|
||||
return [lat, lon, latCellSize, lonCellSize];
|
||||
}
|
||||
|
||||
// Save settings to local storage
|
||||
function saveSettings() {
|
||||
// Find all storeable UI elements, store a key of "element id:property name" mapped to the value of that
|
||||
// property. For a checkbox, that's the "checked" property.
|
||||
$(".storeable-checkbox").each(function() {
|
||||
localStorage.setItem("#" + $(this)[0].id + ":checked", JSON.stringify($(this)[0].checked));
|
||||
// Startup
|
||||
$(document).ready(function() {
|
||||
usePreferredTheme();
|
||||
});
|
||||
$(".storeable-select").each(function() {
|
||||
localStorage.setItem("#" + $(this)[0].id + ":value", JSON.stringify($(this)[0].value));
|
||||
});
|
||||
$(".storeable-text").each(function() {
|
||||
localStorage.setItem("#" + $(this)[0].id + ":value", JSON.stringify($(this)[0].value));
|
||||
});
|
||||
}
|
||||
|
||||
// Load settings from local storage and set up the filter selectors
|
||||
function loadSettings() {
|
||||
// Find all local storage entries and push their data to the corresponding UI element
|
||||
Object.keys(localStorage).forEach(function(key) {
|
||||
if (key.startsWith("#") && key.includes(":")) {
|
||||
// Split the key back into an element ID and a property
|
||||
var split = key.split(":");
|
||||
$(split[0]).prop(split[1], JSON.parse(localStorage.getItem(key)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// How often to query the server?
|
||||
const REFRESH_INTERVAL_SEC = 60;
|
||||
|
||||
// Map layers
|
||||
var markersLayer;
|
||||
var geodesicsLayer;
|
||||
@@ -17,7 +20,7 @@ function loadSpots() {
|
||||
// Build a query string for the API, based on the filters that the user has selected.
|
||||
function buildQueryString() {
|
||||
var str = "?";
|
||||
["dx_continent", "de_continent", "mode_type", "source", "band"].forEach(fn => {
|
||||
["dx_continent", "de_continent", "mode_type", "source", "band", "sig"].forEach(fn => {
|
||||
if (!allFilterOptionsSelected(fn)) {
|
||||
str = str + getQueryStringFor(fn) + "&";
|
||||
}
|
||||
@@ -42,12 +45,16 @@ function updateMap() {
|
||||
|
||||
// Create geodesics if required
|
||||
if ($("#mapShowGeodesics")[0].checked && s["de_latitude"] != null && s["de_longitude"] != null) {
|
||||
try {
|
||||
var geodesic = L.geodesic([[s["de_latitude"], s["de_longitude"]], m.getLatLng()], {
|
||||
color: s["band_color"],
|
||||
color: bandToColor(s['band']),
|
||||
wrap: false,
|
||||
steps: 5
|
||||
});
|
||||
geodesicsLayer.addLayer(geodesic);
|
||||
} catch (e) {
|
||||
// Not sure what causes these but better to continue than to crash out
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -55,9 +62,9 @@ function updateMap() {
|
||||
// Get an icon for a spot, based on its band, using PSK Reporter colours, its program etc.
|
||||
function getIcon(s) {
|
||||
return L.ExtraMarkers.icon({
|
||||
icon: "fa-" + s["icon"],
|
||||
iconColor: s["band_contrast_color"],
|
||||
markerColor: s["band_color"],
|
||||
icon: sigToIcon(s["sig"], "fa-tower-cell"),
|
||||
iconColor: bandToContrastColor(s["band"]),
|
||||
markerColor: bandToColor(s["band"]),
|
||||
shape: 'circle',
|
||||
prefix: 'fa',
|
||||
svg: true
|
||||
@@ -133,7 +140,7 @@ function getTooltipText(s) {
|
||||
ttt += "<br/>";
|
||||
|
||||
// Source / SIG / Ref
|
||||
ttt += `<span class='nowrap'><span class='icon-wrapper'><i class='fa-solid fa-${s["icon"]}'></i></span> ${sigSourceText} ${sig_refs}</span><br/>`;
|
||||
ttt += `<span class='nowrap'><span class='icon-wrapper'><i class='fa-solid ${sigToIcon(s["sig"], "fa-tower-cell")}'></i></span> ${sigSourceText} ${sig_refs}</span><br/>`;
|
||||
|
||||
// Time
|
||||
ttt += `<span class='icon-wrapper'><i class='fa-solid fa-clock markerPopupIcon'></i></span> ${moment.unix(s["time"]).fromNow()}`;
|
||||
@@ -153,22 +160,36 @@ function loadOptions() {
|
||||
// Store options
|
||||
options = jsonData;
|
||||
|
||||
// Add CSS for band toggle buttons
|
||||
addBandToggleColourCSS(options["bands"]);
|
||||
|
||||
// Populate the filters panel
|
||||
generateBandsMultiToggleFilterCard(options["bands"]);
|
||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
|
||||
generateMultiToggleFilterCard("#source-options", "source", options["spot_sources"]);
|
||||
|
||||
// Populate the Display panel
|
||||
options["web-ui-options"]["max-spot-age"].forEach(sc => $("#max-spot-age").append($('<option>', {
|
||||
value: sc * 60,
|
||||
text: sc
|
||||
})));
|
||||
$("#max-spot-age").val(options["web-ui-options"]["max-spot-age-default"] * 60);
|
||||
getAvailableBandColorSchemes().forEach(sc => $("#band-color-scheme").append($('<option>', {
|
||||
value: sc,
|
||||
text: sc
|
||||
})));
|
||||
|
||||
// First pass loading settings, so we can load the band colour scheme before the filters that need to use it
|
||||
loadSettings();
|
||||
setBandColorScheme($("#band-color-scheme option:selected").val());
|
||||
|
||||
// Add CSS for band toggle buttons
|
||||
addBandToggleColourCSS(options["bands"]);
|
||||
|
||||
// Populate the filters panel
|
||||
generateBandsMultiToggleFilterCard(options["bands"]);
|
||||
generateSIGsMultiToggleFilterCard(options["sigs"]);
|
||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
|
||||
generateSourcesMultiToggleFilterCard(options["spot_sources"], options["web-ui-options"]["spot-providers-enabled-by-default"]);
|
||||
|
||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||
// loading settings, so this needs to be called before that.
|
||||
loadURLParams();
|
||||
|
||||
// Load settings from settings storage now all the controls are available
|
||||
loadSettings();
|
||||
@@ -257,6 +278,6 @@ $(document).ready(function() {
|
||||
// Call loadOptions(), this will then trigger loading spots and setting up timers.
|
||||
loadOptions();
|
||||
// Prevent mouse scroll and touch actions in the popup menus being passed through to the map
|
||||
L.DomEvent.disableScrollPropagation(document.getElementById('maptools'));
|
||||
L.DomEvent.disableClickPropagation(document.getElementById('maptools'));
|
||||
L.DomEvent.disableScrollPropagation(document.getElementById('settingsButtonRowMap'));
|
||||
L.DomEvent.disableClickPropagation(document.getElementById('settingsButtonRowMap'));
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
// How often to query the server?
|
||||
const REFRESH_INTERVAL_SEC = 60;
|
||||
|
||||
// Storage for the spot data that the server gives us.
|
||||
var spots = []
|
||||
|
||||
// Dynamically add CSS code for the band toggle buttons to be in the appropriate colour.
|
||||
// Some band names contain decimal points which are not allowed in CSS classes, so we text-replace them to "p".
|
||||
function addBandToggleColourCSS(band_options) {
|
||||
var $style = $('<style>');
|
||||
band_options.forEach(o => {
|
||||
// CSS doesn't like IDs with decimal points in, so we need to replace that
|
||||
var cssFormattedBandName = o['name'] ? o['name'].replace('.', 'p') : "unknown";
|
||||
$style.append(`#filter-button-label-band-${cssFormattedBandName} { border-color: ${o['color']}; color: var(--bs-primary);}`);
|
||||
$style.append(`.btn-check:checked + #filter-button-label-band-${cssFormattedBandName} { background-color: ${o['color']}; color: ${o['contrast_color']};}`);
|
||||
});
|
||||
$('html > head').append($style);
|
||||
}
|
||||
|
||||
// Generate bands filter card. This one is a special case.
|
||||
function generateBandsMultiToggleFilterCard(band_options) {
|
||||
// Create a button for each option
|
||||
band_options.forEach(o => {
|
||||
// CSS doesn't like IDs with decimal points in, so we need to replace that in the same way as when we originally
|
||||
// queried the options endpoint and set our CSS.
|
||||
var cssFormattedBandName = o['name'] ? o['name'].replace('.', 'p') : "unknown";
|
||||
$("#band-options").append(`<input type="checkbox" class="btn-check filter-button-band storeable-checkbox" name="options" id="filter-button-band-${cssFormattedBandName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline" id="filter-button-label-band-${cssFormattedBandName}" for="filter-button-band-${cssFormattedBandName}">${o['name']}</label> `);
|
||||
});
|
||||
// Create All/None buttons
|
||||
$("#band-options").append(` <span style="display: inline-block"><button id="filter-button-band-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', true);">All</button> <button id="filter-button-band-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', false);">None</button></span>`);
|
||||
}
|
||||
|
||||
// Method called when any filter is changed to reload the spots and persist the filter settings.
|
||||
function filtersUpdated() {
|
||||
loadSpots();
|
||||
saveSettings();
|
||||
}
|
||||
@@ -1,27 +1,100 @@
|
||||
// SSE event source
|
||||
let evtSource;
|
||||
let restartSSEOnErrorTimeoutId;
|
||||
// Table row count, to alternate shading
|
||||
let rowCount = 0;
|
||||
|
||||
// Load spots and populate the table.
|
||||
function loadSpots() {
|
||||
// If we have an ongoing SSE connection, stop it so it doesn't interfere with our reload
|
||||
if (evtSource != null) {
|
||||
evtSource.close();
|
||||
}
|
||||
|
||||
// Make the new query
|
||||
$.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) {
|
||||
// Store last updated time
|
||||
lastUpdateTime = moment.utc();
|
||||
updateRefreshDisplay();
|
||||
updateTimingDisplayRunPause();
|
||||
// Store data
|
||||
spots = jsonData;
|
||||
// Update table
|
||||
updateTable();
|
||||
// Start SSE connection to fetch updates in the background, if we are in "run" mode
|
||||
let run = $('#runButton:checked').val();
|
||||
if (run) {
|
||||
startSSEConnection();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start an SSE connection (closing an existing one if it exists). This will then be used to add to the table on the
|
||||
// fly.
|
||||
function startSSEConnection() {
|
||||
if (evtSource != null) {
|
||||
evtSource.close();
|
||||
}
|
||||
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString());
|
||||
|
||||
evtSource.onmessage = function(event) {
|
||||
// Store last updated time
|
||||
lastUpdateTime = moment.utc();
|
||||
updateTimingDisplayRunPause();
|
||||
// Get the new spot
|
||||
newSpot = JSON.parse(event.data);
|
||||
// Awful fudge to ensure new incoming spots at the top of the list don't have timestamps that make them look
|
||||
// like they belong further down the list. If the spot is older than the latest one we already have, bump its
|
||||
// time up to match it. This isn't great but since we poll spot providers every 2 minutes anyway, it shouldn't
|
||||
// be too far wrong.
|
||||
if (spots.length > 0) {
|
||||
newSpot["time"] = Math.max(newSpot["time"], Math.max(...spots.map(s => s["time"])))
|
||||
}
|
||||
|
||||
// Add spot to internal data store
|
||||
spots.unshift(newSpot);
|
||||
// Work out if we need to remove an old spot
|
||||
if (spots.length > $("#spots-to-fetch option:selected").val()) {
|
||||
spots = spots.slice(0, -1);
|
||||
// Drop oldest spot off the end of the table. This is two rows because of the mobile view extra rows
|
||||
$("#table tbody tr").last().remove();
|
||||
$("#table tbody tr").last().remove();
|
||||
}
|
||||
// If we had zero spots before (i.e. one now), the table will have a "No spots" row that we need to remove now
|
||||
// that we have one.
|
||||
if (spots.length == 1) {
|
||||
$("#table tbody tr").last().remove();
|
||||
}
|
||||
|
||||
// Add the new spot to table
|
||||
addSpotToTopOfTable(newSpot, true);
|
||||
};
|
||||
|
||||
evtSource.onerror = function(err) {
|
||||
if (evtSource != null) {
|
||||
evtSource.close();
|
||||
}
|
||||
clearTimeout(restartSSEOnErrorTimeoutId)
|
||||
restartSSEOnErrorTimeoutId = setTimeout(startSSEConnection, 1000);
|
||||
};
|
||||
}
|
||||
|
||||
// Update the special timing display for the live spots page, which varies depending on run/pause selection.
|
||||
function updateTimingDisplayRunPause() {
|
||||
let run = $('#runButton:checked').val();
|
||||
$("#timing-container").html((run ? "Connected to server. Last update at " : "Paused at ") + lastUpdateTime.format('HH:mm') + " UTC.");
|
||||
}
|
||||
|
||||
// Build a query string for the API, based on the filters that the user has selected.
|
||||
function buildQueryString() {
|
||||
var str = "?";
|
||||
["dx_continent", "de_continent", "mode_type", "source", "band"].forEach(fn => {
|
||||
["dx_continent", "de_continent", "mode_type", "source", "band", "sig"].forEach(fn => {
|
||||
if (!allFilterOptionsSelected(fn)) {
|
||||
str = str + getQueryStringFor(fn) + "&";
|
||||
}
|
||||
});
|
||||
str = str + "limit=" + $("#spots-to-fetch option:selected").val();
|
||||
if ($("#filter-dx-call").val() != "") {
|
||||
str = str + "&dx_call_includes=" + encodeURIComponent($("#filter-dx-call").val());
|
||||
if ($("#search").val() != "") {
|
||||
str = str + "&text_includes=" + encodeURIComponent($("#search").val());
|
||||
}
|
||||
return str;
|
||||
}
|
||||
@@ -46,7 +119,8 @@ function updateTable() {
|
||||
var showDE = $("#tableShowDE")[0].checked;
|
||||
|
||||
// Populate table with headers
|
||||
let table = $('<table class="table table-hover">').append('<thead><tr class="table-primary"></tr></thead><tbody></tbody>');
|
||||
let table = $("#table");
|
||||
table.find('thead tr').empty();
|
||||
if (showTime) {
|
||||
table.find('thead tr').append(`<th>${useLocalTime ? "Local" : "UTC"}</th>`);
|
||||
}
|
||||
@@ -75,19 +149,51 @@ function updateTable() {
|
||||
table.find('thead tr').append(`<th class='hideonmobile'>DE</th>`);
|
||||
}
|
||||
|
||||
table.find('tbody').empty();
|
||||
if (spots.length == 0) {
|
||||
table.find('tbody').append('<tr class="table-danger"><td colspan="100" style="text-align:center;">No spots match your filters.</td></tr>');
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
spots.forEach(s => {
|
||||
let spotsNewestFirst = spots.toReversed();
|
||||
spotsNewestFirst.forEach(s => addSpotToTopOfTable(s, false));
|
||||
}
|
||||
|
||||
// Add rows corresponding to a new spot to the top of the table
|
||||
// highlightNew = false for an initial load, true for new SSE-loaded spots
|
||||
function addSpotToTopOfTable(s, highlightNew) {
|
||||
let rows = createNewTableRowsForSpot(s, highlightNew);
|
||||
$("#table").find('tbody').prepend(rows[1]);
|
||||
$("#table").find('tbody').prepend(rows[0]);
|
||||
}
|
||||
|
||||
// Turn a spot into a set of table rows to represent it. This is actually two table rows because we need a second
|
||||
// separate row for the mobile view.
|
||||
// highlightNew = false for an initial load, true for new SSE-loaded spots
|
||||
function createNewTableRowsForSpot(s, highlightNew) {
|
||||
// Use local time instead of UTC?
|
||||
var useLocalTime = $("#timeZone")[0].value == "local";
|
||||
|
||||
// Get user grid if valid, this will be null if it's not.
|
||||
var userPos = latLonForGridCentre($("#userGrid").val());
|
||||
|
||||
// Table data toggles
|
||||
var showTime = $("#tableShowTime")[0].checked;
|
||||
var showDX = $("#tableShowDX")[0].checked;
|
||||
var showFreq = $("#tableShowFreq")[0].checked;
|
||||
var showMode = $("#tableShowMode")[0].checked;
|
||||
var showComment = $("#tableShowComment")[0].checked;
|
||||
var showBearing = $("#tableShowBearing")[0].checked && userPos != null;
|
||||
var showType = $("#tableShowType")[0].checked;
|
||||
var showRef = $("#tableShowRef")[0].checked;
|
||||
var showDE = $("#tableShowDE")[0].checked;
|
||||
|
||||
// Create row
|
||||
let $tr = $('<tr>');
|
||||
|
||||
// Apply striping to the table. We can't just use Bootstrap's table-striped class because we have all sorts of
|
||||
// extra faff to deal with, like the mobile view having extra rows, and the On Now / Next 24h / Later banners
|
||||
// which cause the table-striped colouring to go awry.
|
||||
if (count % 2 == 1) {
|
||||
if (rowCount % 2 == 1) {
|
||||
$tr.addClass("table-active");
|
||||
}
|
||||
|
||||
@@ -96,6 +202,12 @@ function updateTable() {
|
||||
$tr.addClass("table-faded");
|
||||
}
|
||||
|
||||
// If we are asked to highlight new rows (i.e. this row is being added "live" via the SSE client and not as a bulk
|
||||
// reload of the whole table)
|
||||
if (highlightNew) {
|
||||
$tr.addClass("new");
|
||||
}
|
||||
|
||||
// Format a UTC or local time for display
|
||||
var time = moment.unix(s["time"]).utc();
|
||||
if (useLocalTime) {
|
||||
@@ -138,9 +250,8 @@ function updateTable() {
|
||||
// Format the mode
|
||||
mode_string = s["mode"];
|
||||
if (s["mode"] == null) {
|
||||
mode_string = "???";
|
||||
}
|
||||
if (s["mode_source"] == "BANDPLAN") {
|
||||
mode_string = "";
|
||||
} else if (s["mode_source"] == "BANDPLAN") {
|
||||
mode_string = mode_string + "<span class='mode-q hideonmobile'><i class='fa-solid fa-circle-question' title='The mode was not reported via the spotting service. This is a guess based on the frequency.'></i></span>";
|
||||
}
|
||||
|
||||
@@ -176,9 +287,9 @@ function updateTable() {
|
||||
var items = []
|
||||
for (var i = 0; i < s["sig_refs"].length; i++) {
|
||||
if (s["sig_refs"][i]["url"] != null) {
|
||||
items[i] = `<a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${s["sig_refs"][i]["id"]}</a>`
|
||||
items[i] = `<span style="white-space: nowrap;"><a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${s["sig_refs"][i]["id"]}</a></span>`
|
||||
} else {
|
||||
items[i] = `${s["sig_refs"][i]["id"]}`
|
||||
items[i] = `<span style="white-space: nowrap;">${s["sig_refs"][i]["id"]}</span>`
|
||||
}
|
||||
}
|
||||
sig_refs = items.join(", ");
|
||||
@@ -214,10 +325,10 @@ function updateTable() {
|
||||
$tr.append(`<td class='nowrap'>${time_formatted}</td>`);
|
||||
}
|
||||
if (showDX) {
|
||||
$tr.append(`<td class='nowrap'><span class='flag-wrapper hideonmobile' title='${dx_country}'>${dx_flag}</span><a class='dx-link' href='https://qrz.com/db/${s["dx_call"]}' target='_new' title='${s["dx_name"] != null ? s["dx_name"] : ""}'>${dx_call}</a></td>`);
|
||||
$tr.append(`<td class='nowrap'><span class='flag-wrapper' title='${dx_country}'>${dx_flag}</span><a class='dx-link' href='https://qrz.com/db/${s["dx_call"]}' target='_new' title='${s["dx_name"] != null ? s["dx_name"] : ""}'>${dx_call}</a></td>`);
|
||||
}
|
||||
if (showFreq) {
|
||||
$tr.append(`<td class='nowrap'><span class='band-bullet' title='${bandFullName}' style='${(s["freq"] != null) ? "color: " + s["band_color"] : "display: none;"}'>■</span>${freq_string}</td>`);
|
||||
$tr.append(`<td class='nowrap'><span class='band-bullet' title='${bandFullName}' style='${(s["freq"] != null) ? "color: " + bandToColor(s["band"]) : "display: none;"}'>■</span>${freq_string}</td>`);
|
||||
}
|
||||
if (showMode) {
|
||||
$tr.append(`<td class='nowrap'>${mode_string}</td>`);
|
||||
@@ -229,45 +340,55 @@ function updateTable() {
|
||||
$tr.append(`<td class='nowrap hideonmobile'>${bearingText}</td>`);
|
||||
}
|
||||
if (showType) {
|
||||
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid fa-${s["icon"]}'></i></span> ${typeText}</td>`);
|
||||
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid ${sigToIcon(s["sig"], "fa-tower-cell")}'></i></span> ${typeText}</td>`);
|
||||
}
|
||||
if (showRef) {
|
||||
$tr.append(`<td class='hideonmobile'>${sig_refs}</td>`);
|
||||
$tr.append(`<td class='hideonmobile' style='max-width: 11em;'>${sig_refs}</td>`);
|
||||
}
|
||||
if (showDE) {
|
||||
$tr.append(`<td class='nowrap hideonmobile'><span class='flag-wrapper' title='${de_country}'>${de_flag}</span>${de_call}</td>`);
|
||||
}
|
||||
table.find('tbody').append($tr);
|
||||
|
||||
// Second row for mobile view only, containing type, ref & comment
|
||||
$tr2 = $("<tr class='hidenotonmobile'>");
|
||||
if (count % 2 == 1) {
|
||||
|
||||
// Apply styles as per the first row
|
||||
if (rowCount % 2 == 1) {
|
||||
$tr2.addClass("table-active");
|
||||
}
|
||||
if (s["qrt"] == true) {
|
||||
$tr2.addClass("table-faded");
|
||||
}
|
||||
if (highlightNew) {
|
||||
$tr2.addClass("new");
|
||||
}
|
||||
|
||||
$td2 = $("<td colspan='100'>");
|
||||
$td2floatleft = $(`<div style="float: left;">`);
|
||||
if (showType) {
|
||||
$td2.append(`<span class='icon-wrapper'><i class='fa-solid fa-${s["icon"]}'></i></span> ${typeText} `);
|
||||
$td2floatleft.append(`<span class='icon-wrapper'><i class='fa-solid ${sigToIcon(s["sig"], "fa-tower-cell")}'></i></span> ${typeText} `);
|
||||
}
|
||||
if (showRef) {
|
||||
$td2.append(`${sig_refs} `);
|
||||
$td2floatleft.append(`${sig_refs} `);
|
||||
}
|
||||
$td2.append($td2floatleft);
|
||||
$td2floatright = $(`<div style="float: right;">`);
|
||||
if (showBearing) {
|
||||
$td2.append(` Bearing: ${bearingText} `);
|
||||
$td2floatright.append(`${bearingText} `);
|
||||
}
|
||||
if (showDE) {
|
||||
$td2floatright.append(` de ${de_call} `);
|
||||
}
|
||||
$td2.append($td2floatright);
|
||||
$td2.append(`</div><div style="clear: both;"></div>`);
|
||||
if (showComment) {
|
||||
$td2.append(`<br/>${commentText}`);
|
||||
$td2.append(`${commentText}`);
|
||||
}
|
||||
$tr2.append($td2);
|
||||
table.find('tbody').append($tr2);
|
||||
|
||||
count++;
|
||||
});
|
||||
rowCount++;
|
||||
|
||||
// Update DOM
|
||||
$('#table-container').html(table);
|
||||
return [$tr, $tr2];
|
||||
}
|
||||
|
||||
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
|
||||
@@ -277,22 +398,36 @@ function loadOptions() {
|
||||
// Store options
|
||||
options = jsonData;
|
||||
|
||||
// Add CSS for band toggle buttons
|
||||
addBandToggleColourCSS(options["bands"]);
|
||||
|
||||
// Populate the filters panel
|
||||
generateBandsMultiToggleFilterCard(options["bands"]);
|
||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
|
||||
generateMultiToggleFilterCard("#source-options", "source", options["spot_sources"]);
|
||||
|
||||
// Populate the Display panel
|
||||
options["web-ui-options"]["spot-count"].forEach(sc => $("#spots-to-fetch").append($('<option>', {
|
||||
value: sc,
|
||||
text: sc
|
||||
})));
|
||||
$("#spots-to-fetch").val(options["web-ui-options"]["spot-count-default"]);
|
||||
getAvailableBandColorSchemes().forEach(sc => $("#band-color-scheme").append($('<option>', {
|
||||
value: sc,
|
||||
text: sc
|
||||
})));
|
||||
|
||||
// First pass loading settings, so we can load the band colour scheme before the filters that need to use it
|
||||
loadSettings();
|
||||
setBandColorScheme($("#band-color-scheme option:selected").val());
|
||||
|
||||
// Add CSS for band toggle buttons
|
||||
addBandToggleColourCSS(options["bands"]);
|
||||
|
||||
// Populate the filters panel
|
||||
generateBandsMultiToggleFilterCard(options["bands"]);
|
||||
generateSIGsMultiToggleFilterCard(options["sigs"]);
|
||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
|
||||
generateSourcesMultiToggleFilterCard(options["spot_sources"], options["web-ui-options"]["spot-providers-enabled-by-default"]);
|
||||
|
||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||
// loading settings, so this needs to be called before that.
|
||||
loadURLParams();
|
||||
|
||||
// Load settings from settings storage now all the controls are available
|
||||
loadSettings();
|
||||
@@ -305,14 +440,8 @@ function loadOptions() {
|
||||
$("#tableShowBearing").prop('checked', false);
|
||||
}
|
||||
|
||||
// Show the Add Spot button if spotting is allowed
|
||||
if (options["spot_allowed"]) {
|
||||
$("#add-spot-button").show();
|
||||
}
|
||||
|
||||
// Load spots and set up the timer
|
||||
// Load spots (this will also set up the SSE connection to update them too)
|
||||
loadSpots();
|
||||
setInterval(loadSpots, REFRESH_INTERVAL_SEC * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -378,8 +507,21 @@ function displayIntroBox() {
|
||||
$(document).ready(function() {
|
||||
// Call loadOptions(), this will then trigger loading spots and setting up timers.
|
||||
loadOptions();
|
||||
// Update the refresh timing display every second
|
||||
setInterval(updateRefreshDisplay, 1000);
|
||||
// Display intro box
|
||||
displayIntroBox();
|
||||
|
||||
// Set up run/pause toggles
|
||||
$("#runButton").change(function() {
|
||||
// Need to start the SSE connection but also do a full re-query to catch up anything that we missed, so we
|
||||
// might as well just call loadSpots again which will trigger it all
|
||||
loadSpots();
|
||||
updateTimingDisplayRunPause();
|
||||
});
|
||||
$("#pauseButton").change(function() {
|
||||
// If we are pausing and have an open SSE connection, stop it
|
||||
if (evtSource != null) {
|
||||
evtSource.close();
|
||||
}
|
||||
updateTimingDisplayRunPause();
|
||||
});
|
||||
});
|
||||
90
webassets/js/spotsbandsandmap.js
Normal file
90
webassets/js/spotsbandsandmap.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// Storage for the spot data that the server gives us.
|
||||
var spots = []
|
||||
|
||||
// Dynamically add CSS code for the band toggle buttons to be in the appropriate colour.
|
||||
// Some band names contain decimal points which are not allowed in CSS classes, so we text-replace them to "p".
|
||||
function addBandToggleColourCSS(band_options) {
|
||||
var $style = $('<style>');
|
||||
band_options.forEach(o => {
|
||||
var domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||
$style.append(`#filter-button-label-band-${domSafeName} { border-color: ${bandToColor(o['name'])}; color: var(--bs-primary);}`);
|
||||
$style.append(`.btn-check:checked + #filter-button-label-band-${domSafeName} { background-color: ${bandToColor(o['name'])}; color: ${bandToContrastColor(o['name'])};}`);
|
||||
});
|
||||
$('html > head').append($style);
|
||||
}
|
||||
|
||||
// Generate bands filter card. This one is a special case.
|
||||
function generateBandsMultiToggleFilterCard(band_options) {
|
||||
// Create a button for each option
|
||||
band_options.forEach(o => {
|
||||
var domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||
$("#band-options").append(`<input type="checkbox" class="btn-check filter-button-band storeable-checkbox" name="options" id="filter-button-band-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline" id="filter-button-label-band-${domSafeName}" for="filter-button-band-${domSafeName}">${o['name']}</label> `);
|
||||
});
|
||||
// Create All/None/Ham HF buttons
|
||||
$("#band-options").append(` <span style="display: inline-block"><button id="filter-button-band-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', true);">All</button> <button id="filter-button-band-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', false);">None</button> <button id="filter-button-band-none" type="button" class="btn btn-outline-secondary" onclick="setHamHFBandToggles();">Ham HF</button></span>`);
|
||||
}
|
||||
|
||||
// Set the band toggles so that only the amateur radio HF bands are selected. This includes 160m and 6m because that's
|
||||
// widely expected by hams to be included. Special case of toggleFilterButtons().
|
||||
function setHamHFBandToggles() {
|
||||
const hamHFBands = ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"];
|
||||
$(".filter-button-band").each(function() {
|
||||
$(this).prop('checked', hamHFBands.includes($(this).val().replace("filter-button-band-", "")));
|
||||
});
|
||||
filtersUpdated();
|
||||
}
|
||||
|
||||
// Generate SIGs filter card. This one is also a special case.
|
||||
function generateSIGsMultiToggleFilterCard(sig_options) {
|
||||
// Create a button for each option
|
||||
sig_options.forEach(o => {
|
||||
var domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||
$("#sig-options").append(`<input type="checkbox" class="btn-check filter-button-sig storeable-checkbox" name="options" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-primary" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${sigToIcon(o['name'], 'fa-tower-cell')}"></i> ${o['name']}</label> `);
|
||||
});
|
||||
// Create a bonus "NO_SIG" / "General DX" option
|
||||
$("#sig-options").append(`<input type="checkbox" class="btn-check filter-button-sig storeable-checkbox" name="options" id="filter-button-sig-NO_SIG" value="NO_SIG" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-primary" id="filter-button-label-sig-NO_SIG" for="filter-button-sig-NO_SIG"><i class="fa-solid fa-tower-cell"></i> General DX</label> `);
|
||||
// Create All/None buttons
|
||||
$("#sig-options").append(` <span style="display: inline-block"><button id="filter-button-sig-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('sig', true);">All</button> <button id="filter-button-sig-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('sig', false);">None</button></span>`);
|
||||
}
|
||||
|
||||
// Generate Sources filter card. This one is a minor special case as we create the buttons in the normal way, but then
|
||||
// set which ones are enabled by default based on config rather than having them all enabled by default. We also sanitise
|
||||
// names here for HTML elements.
|
||||
function generateSourcesMultiToggleFilterCard(source_options, sources_enabled_by_default) {
|
||||
// Create a button for each option
|
||||
source_options.forEach(o => {
|
||||
var enable = sources_enabled_by_default.includes(o);
|
||||
var domSafeName = o.replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||
$("#source-options").append(`<input type="checkbox" class="btn-check filter-button-source storeable-checkbox" name="options" id="filter-button-source-${domSafeName}" value="${o}" autocomplete="off" onClick="filtersUpdated()" ${enable ? "checked" : ""}><label class="btn btn-outline-primary" for="filter-button-source-${domSafeName}">${o}</label> `);
|
||||
});
|
||||
// Create All/None buttons
|
||||
$("#source-options").append(` <span style="display: inline-block"><button id="filter-button-source-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('source', true);">All</button> <button id="filter-button-source-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('source', false);">None</button></span>`);
|
||||
}
|
||||
|
||||
// Method called when any filter is changed to reload the spots and persist the filter settings.
|
||||
function filtersUpdated() {
|
||||
loadSpots();
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
// Function to set dark mode based on the state of the UI toggle in spots, bands and map pages
|
||||
function toggleDarkMode() {
|
||||
enableDarkMode($("#darkMode")[0].checked);
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
// Function to update the band colour scheme in spots, bands and map pages
|
||||
function setBandColorSchemeFromUI() {
|
||||
setBandColorScheme($("#band-color-scheme option:selected").val());
|
||||
saveSettings();
|
||||
// Fudge a full reload because we need to update not just colours in the list/map/bands but also the filters
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// Reload spots on becoming visible. This forces a refresh when used as a PWA and the user switches back to the PWA
|
||||
// after some time has passed with it in the background.
|
||||
addEventListener("visibilitychange", (event) => {
|
||||
if (!document.hidden) {
|
||||
loadSpots();
|
||||
}
|
||||
});
|
||||
@@ -6,7 +6,14 @@ const CACHE_URLS = [
|
||||
'apidocs/openapi.yml',
|
||||
'about',
|
||||
'css/style.css',
|
||||
'js/code.js',
|
||||
'js/add-spot.js',
|
||||
'js/alerts.js',
|
||||
'js/bands.js',
|
||||
'js/common.js',
|
||||
'js/map.js',
|
||||
'js/spots.js',
|
||||
'js/spotsbandsandmap.js',
|
||||
'js/status.js',
|
||||
'img/logo.png',
|
||||
'img/favicon.ico',
|
||||
'img/icon-32.png',
|
||||
|
||||
Reference in New Issue
Block a user