Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5699627069 | ||
|
|
66081f32f9 | ||
|
|
4f8f748d88 | ||
|
|
c2ecef1003 | ||
|
|
266468f938 | ||
|
|
fffd230633 | ||
|
|
3c1b1821f5 | ||
|
|
77d7fd70d9 | ||
|
|
c3bcc1190d |
@@ -0,0 +1,7 @@
|
|||||||
|
/.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
/config.yml
|
||||||
|
/cache/
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<content url="file://$MODULE_DIR$">
|
<content url="file://$MODULE_DIR$">
|
||||||
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/webassets/vendor" />
|
<excludeFolder url="file://$MODULE_DIR$/static/vendor" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="jdk" jdkName="Python 3.13 virtualenv at ~/code/spothole/.venv" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="Python 3.13 virtualenv at ~/code/spothole/.venv" jdkType="Python SDK" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["python3", "spothole.py"]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# 
|
# 
|
||||||
|
|
||||||
Spothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open
|
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.
|
JSON API as well as a website to browse the data.
|
||||||
@@ -89,7 +89,7 @@ callsign and park data.
|
|||||||
|
|
||||||
Various approaches exist to writing your own client, but in general:
|
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
|
* Refer to the API docs. These are built on an OpenAPI definition file (`/static/apidocs/openapi.yml`), which you can
|
||||||
automatically use to generate a client skeleton using various software.
|
automatically use to generate a client skeleton using various software.
|
||||||
* Call the main "spots" or "alerts" API endpoints to get the data you want. For example, your app could call
|
* Call the main "spots" or "alerts" API endpoints to get the data you want. For example, your app could call
|
||||||
`https://spothole.app/api/v1/spots` once every few minutes. Apply filters if necessary.
|
`https://spothole.app/api/v1/spots` once every few minutes. Apply filters if necessary.
|
||||||
@@ -172,6 +172,33 @@ countries. This is normal, don't panic!
|
|||||||
If you see some errors on startup, check your configuration, e.g. in case you have specified a port for the web server
|
If you see some errors on startup, check your configuration, e.g. in case you have specified a port for the web server
|
||||||
that is already in use by something else.
|
that is already in use by something else.
|
||||||
|
|
||||||
|
### Running using Docker
|
||||||
|
|
||||||
|
Spothole comes with a Docker configuration to make it easy to run it in a containerised environment. To set it up using
|
||||||
|
Docker, the easiest way is to use a Docker Compose file. Create a new directory such as `/opt/docker/spothole` and
|
||||||
|
create a `compose.yaml` file inside it with the following contents:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
spothole:
|
||||||
|
container_name: spothole
|
||||||
|
build:
|
||||||
|
context: https://git.ianrenton.com/ian/spothole.git#main
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./config.yml:/app/config.yml
|
||||||
|
- ./cache:/app/cache
|
||||||
|
```
|
||||||
|
|
||||||
|
Save the file. You will still need to create a copy of `config-example.yml` and name it `config.yml`, though with the
|
||||||
|
Docker setup nothing has actually been downloaded yet, so you will have to copy the example from the repository some
|
||||||
|
other way, e.g. [from the repo in a web browser](https://git.ianrenton.com/ian/spothole/src/branch/main/config-example.yml).
|
||||||
|
|
||||||
|
With that in place, run `docker compose up` and you should be good to go. To detach, press `d` or run the command with
|
||||||
|
the `-d` flag.
|
||||||
|
|
||||||
### Multiple cluster nodes with different settings
|
### Multiple cluster nodes with different settings
|
||||||
|
|
||||||
Dan, S50U has written in with his Spothole cluster settings. He is using a cluster node which provides RBN spots, and
|
Dan, S50U has written in with his Spothole cluster settings. He is using a cluster node which provides RBN spots, and
|
||||||
@@ -346,9 +373,9 @@ server {
|
|||||||
alias /var/www/html/.well-known/;
|
alias /var/www/html/.well-known/;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Load static assets directly from the Spothole webassets directory
|
# Load static assets directly from the Spothole static directory
|
||||||
location /static/ {
|
location /static/ {
|
||||||
alias /home/spothole/spothole/webassets/;
|
alias /home/spothole/spothole/static/;
|
||||||
expires 1h;
|
expires 1h;
|
||||||
add_header Cache-Control "public, max-age=3600, must-revalidate";
|
add_header Cache-Control "public, max-age=3600, must-revalidate";
|
||||||
}
|
}
|
||||||
@@ -452,12 +479,12 @@ To navigate your way around the source code, this list may help.
|
|||||||
|
|
||||||
*HTML/JS/CSS front-end code*
|
*HTML/JS/CSS front-end code*
|
||||||
|
|
||||||
* `/webassets` - Root for static files served by the web server. These are all served from a path starting `/static/`.
|
* `/static` - Root for static files served by the web server. These are all served from a path starting `/static/`.
|
||||||
* `/webassets/apidocs` - Contains the OpenAPI spec (`openapi.yml`)
|
* `/static/apidocs` - Contains the OpenAPI spec (`openapi.yml`)
|
||||||
* `/webassets/css` - CSS files used by the web front-end
|
* `/static/css` - CSS files used by the web front-end
|
||||||
* `/webassets/img` - image files used by the web front-end
|
* `/static/img` - image files used by the web front-end
|
||||||
* `/webassets/js` - JavaScript used by the web front-end
|
* `/static/js` - JavaScript used by the web front-end
|
||||||
* `/webassets/vendor` - Third-party libraries (CSS, JS, fonts and images)
|
* `/static/vendor` - Third-party libraries (CSS, JS, fonts and images)
|
||||||
|
|
||||||
*Miscellaneous*
|
*Miscellaneous*
|
||||||
|
|
||||||
@@ -509,14 +536,14 @@ my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet. `/da
|
|||||||
maps of conference centres.
|
maps of conference centres.
|
||||||
|
|
||||||
The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the
|
The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the
|
||||||
`/webassets/img/flags/` directory.
|
`/static/img/flags/` directory.
|
||||||
|
|
||||||
The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries.
|
The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries.
|
||||||
This project would not have been possible without these libraries, so many thanks to their developers.
|
This project would not have been possible without these libraries, so many thanks to their developers.
|
||||||
|
|
||||||
### Third Party Libraries
|
### Third Party Libraries
|
||||||
|
|
||||||
A number of third-party libraries are self-hosted in the `/webassets/vendor/` directory. These files are subject to
|
A number of third-party libraries are self-hosted in the `/static/vendor/` directory. These files are subject to
|
||||||
their own licences and are not covered by the overall licence declared in the `LICENSE` file.
|
their own licences and are not covered by the overall licence declared in the `LICENSE` file.
|
||||||
|
|
||||||
Particular thanks go to country-files.com for providing country lookup data for amateur radio, to K0SWE
|
Particular thanks go to country-files.com for providing country lookup data for amateur radio, to K0SWE
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ from core.config import MAX_ALERT_AGE
|
|||||||
class AlertProvider:
|
class AlertProvider:
|
||||||
"""Generic alert provider class. Subclasses of this query the individual APIs for alerts."""
|
"""Generic alert provider class. Subclasses of this query the individual APIs for alerts."""
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, name, provider_config):
|
||||||
"""Constructor"""
|
"""Constructor"""
|
||||||
|
|
||||||
self.name = provider_config["name"]
|
self.name = name
|
||||||
self.enabled = provider_config["enabled"]
|
self.enabled = provider_config["enabled"]
|
||||||
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||||
self.status = "Not Started" if self.enabled else "Disabled"
|
self.status = "Not Started" if self.enabled else "Disabled"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class BOTA(HTTPAlertProvider):
|
|||||||
ALERTS_URL = "https://www.beachesontheair.com/"
|
ALERTS_URL = "https://www.beachesontheair.com/"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("BOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
def _http_response_to_alerts(self, http_response):
|
def _http_response_to_alerts(self, http_response):
|
||||||
new_alerts = []
|
new_alerts = []
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from threading import Thread, Event
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError
|
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||||
|
|
||||||
from alertproviders.alert_provider import AlertProvider
|
from alertproviders.alert_provider import AlertProvider
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
@@ -14,8 +14,8 @@ class HTTPAlertProvider(AlertProvider):
|
|||||||
"""Generic alert provider class for providers that request data via HTTP(S). Just for convenience to avoid code
|
"""Generic alert provider class for providers that request data via HTTP(S). Just for convenience to avoid code
|
||||||
duplication. Subclasses of this query the individual APIs for data."""
|
duplication. Subclasses of this query the individual APIs for data."""
|
||||||
|
|
||||||
def __init__(self, provider_config, url, poll_interval):
|
def __init__(self, name, provider_config, url, poll_interval):
|
||||||
super().__init__(provider_config)
|
super().__init__(name, provider_config)
|
||||||
self._url = url
|
self._url = url
|
||||||
self._poll_interval = poll_interval
|
self._poll_interval = poll_interval
|
||||||
self._thread = None
|
self._thread = None
|
||||||
@@ -59,6 +59,8 @@ class HTTPAlertProvider(AlertProvider):
|
|||||||
|
|
||||||
except ConnectionError:
|
except ConnectionError:
|
||||||
logging.warning(f"Connection error when accessing {self.name} alerts API.")
|
logging.warning(f"Connection error when accessing {self.name} alerts API.")
|
||||||
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
logging.warning(f"Timeout when accessing {self.name} alerts API.")
|
||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logging.exception("Exception in HTTP JSON Alert Provider (" + self.name + ")")
|
logging.exception("Exception in HTTP JSON Alert Provider (" + self.name + ")")
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class NG3K(HTTPAlertProvider):
|
|||||||
AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE)
|
AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE)
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
def _http_response_to_alerts(self, http_response):
|
def _http_response_to_alerts(self, http_response):
|
||||||
new_alerts = []
|
new_alerts = []
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class ParksNPeaks(HTTPAlertProvider):
|
|||||||
ALERTS_URL = "https://parksnpeaks.org/api/ALERTS/"
|
ALERTS_URL = "https://parksnpeaks.org/api/ALERTS/"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("ParksNPeaks", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
def _http_response_to_alerts(self, http_response):
|
def _http_response_to_alerts(self, http_response):
|
||||||
new_alerts = []
|
new_alerts = []
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class POTA(HTTPAlertProvider):
|
|||||||
ALERTS_URL = "https://api.pota.app/activation"
|
ALERTS_URL = "https://api.pota.app/activation"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("POTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
def _http_response_to_alerts(self, http_response):
|
def _http_response_to_alerts(self, http_response):
|
||||||
new_alerts = []
|
new_alerts = []
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class SOTA(HTTPAlertProvider):
|
|||||||
ALERTS_URL = "https://api-db2.sota.org.uk/api/alerts/365/all/all"
|
ALERTS_URL = "https://api-db2.sota.org.uk/api/alerts/365/all/all"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("SOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
def _http_response_to_alerts(self, http_response):
|
def _http_response_to_alerts(self, http_response):
|
||||||
new_alerts = []
|
new_alerts = []
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class WOTA(HTTPAlertProvider):
|
|||||||
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
|
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("WOTA", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
def _http_response_to_alerts(self, http_response):
|
def _http_response_to_alerts(self, http_response):
|
||||||
new_alerts = []
|
new_alerts = []
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class WWFF(HTTPAlertProvider):
|
|||||||
ALERTS_URL = "https://spots.wwff.co/static/agendas.json"
|
ALERTS_URL = "https://spots.wwff.co/static/agendas.json"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("WWFF", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
def _http_response_to_alerts(self, http_response):
|
def _http_response_to_alerts(self, http_response):
|
||||||
new_alerts = []
|
new_alerts = []
|
||||||
|
|||||||
@@ -19,66 +19,55 @@ base-url: "http://localhost:8080"
|
|||||||
|
|
||||||
# Spot providers to use. This is an example set, tailor it to your liking by commenting and uncommenting.
|
# Spot providers to use. This is an example set, tailor it to your liking by commenting and uncommenting.
|
||||||
# RBN and APRS-IS are supported but have such a high data rate, you probably don't want them enabled.
|
# RBN and APRS-IS are supported but have such a high data rate, you probably don't want them enabled.
|
||||||
# Each provider needs a class, a name, and an enabled/disabled state. Some require more config such as hostnames/IP
|
# Each provider needs a class and an enabled/disabled state. Some require more config such as hostnames/IP
|
||||||
# addresses and ports. You can duplicate them if you like, e.g. to support several DX clusters. RBN uses two ports, 7000
|
# addresses and ports. You can duplicate them if you like, e.g. to support several DX clusters. RBN uses two ports, 7000
|
||||||
# for CW/RTTY and 7001 for FT8, so if you want both, you need two entries, as shown below.
|
# for CW/RTTY and 7001 for FT8, so if you want both, you need two entries, as shown below.
|
||||||
# Feel free to write your own provider classes! There are details in the README.
|
# Feel free to write your own provider classes! There are details in the README.
|
||||||
spot-providers:
|
spot-providers:
|
||||||
- class: "POTA"
|
- class: "POTA"
|
||||||
name: "POTA"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "SOTA"
|
- class: "SOTA"
|
||||||
name: "SOTA"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "WWFF"
|
- class: "WWFF"
|
||||||
name: "WWFF"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "WWBOTA"
|
- class: "WWBOTA"
|
||||||
name: "WWBOTA"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "GMA"
|
- class: "GMA"
|
||||||
name: "GMA"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
# GMA requires an API key to fetch spots. After creating an account on cqgma.org, email support and request one.
|
# GMA requires an API key to fetch spots. After creating an account on cqgma.org, email support and request one.
|
||||||
api-key: ""
|
api-key: ""
|
||||||
|
|
||||||
- class: "HEMA"
|
- class: "HEMA"
|
||||||
name: "HEMA"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "ParksNPeaks"
|
- class: "ParksNPeaks"
|
||||||
name: "ParksNPeaks"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "ZLOTA"
|
- class: "ZLOTA"
|
||||||
name: "ZLOTA"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "WOTA"
|
- class: "WOTA"
|
||||||
name: "WOTA"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "LLOTA"
|
- class: "LLOTA"
|
||||||
name: "LLOTA"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "Towers"
|
- class: "Towers"
|
||||||
name: "Towers"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "Tiles"
|
- class: "Tiles"
|
||||||
name: "Tiles"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "APRSIS"
|
- class: "APRSIS"
|
||||||
name: "APRS-IS"
|
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
- class: "DXCluster"
|
- class: "DXCluster"
|
||||||
|
# Clusters have a "name" property in case you want to say which cluster node the data is from, or connect to
|
||||||
|
# several clusters and name them separately.
|
||||||
name: "HRD Cluster"
|
name: "HRD Cluster"
|
||||||
enabled: true
|
enabled: true
|
||||||
host: "hrd.wa9pie.net"
|
host: "hrd.wa9pie.net"
|
||||||
@@ -99,18 +88,12 @@ spot-providers:
|
|||||||
enabled: false
|
enabled: false
|
||||||
host: "w3lpl.net"
|
host: "w3lpl.net"
|
||||||
port: 7373
|
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:"
|
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"
|
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
|
allow_rbn_spots: false
|
||||||
|
|
||||||
- class: "RBN"
|
- class: "RBN"
|
||||||
|
- # RBN sources have a "name" property in case you want to differentiate between the CW/RTTY and FT8 sources.
|
||||||
name: "RBN CW/RTTY"
|
name: "RBN CW/RTTY"
|
||||||
enabled: false
|
enabled: false
|
||||||
port: 7000
|
port: 7000
|
||||||
@@ -127,11 +110,12 @@ spot-providers:
|
|||||||
enabled-by-default-in-web-ui: false
|
enabled-by-default-in-web-ui: false
|
||||||
|
|
||||||
- class: "UKPacketNet"
|
- class: "UKPacketNet"
|
||||||
name: "UK Packet Radio Net"
|
|
||||||
enabled: false
|
enabled: false
|
||||||
enabled-by-default-in-web-ui: false
|
enabled-by-default-in-web-ui: false
|
||||||
|
|
||||||
- class: "XOTA"
|
- class: "XOTA"
|
||||||
|
# xOTA sources have a "name" property so you can define what programme it is for, since xOTA is generic software
|
||||||
|
# for running "something-on-the-air" programmes.
|
||||||
name: "39C3 TOTA"
|
name: "39C3 TOTA"
|
||||||
enabled: false
|
enabled: false
|
||||||
url: "wss://39c3.totawatch.de/api/spot/live"
|
url: "wss://39c3.totawatch.de/api/spot/live"
|
||||||
@@ -152,31 +136,24 @@ spot-providers:
|
|||||||
# Alert providers to use. Same setup as the spot providers list above.
|
# Alert providers to use. Same setup as the spot providers list above.
|
||||||
alert-providers:
|
alert-providers:
|
||||||
- class: "POTA"
|
- class: "POTA"
|
||||||
name: "POTA"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "SOTA"
|
- class: "SOTA"
|
||||||
name: "SOTA"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "WWFF"
|
- class: "WWFF"
|
||||||
name: "WWFF"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "ParksNPeaks"
|
- class: "ParksNPeaks"
|
||||||
name: "ParksNPeaks"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "WOTA"
|
- class: "WOTA"
|
||||||
name: "WOTA"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "BOTA"
|
- class: "BOTA"
|
||||||
name: "BOTA"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "NG3K"
|
- class: "NG3K"
|
||||||
name: "NG3K"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
|
||||||
@@ -184,19 +161,15 @@ alert-providers:
|
|||||||
# conditions, etc.) and make it available via the /api/v1/solar endpoint.
|
# conditions, etc.) and make it available via the /api/v1/solar endpoint.
|
||||||
solar-condition-providers:
|
solar-condition-providers:
|
||||||
- class: "HamQSL"
|
- class: "HamQSL"
|
||||||
name: "HamQSL"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "NOAA3dayForecast"
|
- class: "NOAA3dayForecast"
|
||||||
name: "NOAA 3-day Forecast"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "GIROIonosonde"
|
- class: "GIROIonosonde"
|
||||||
name: "GIRO Ionosonde Data"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "KC2GProp"
|
- class: "KC2GProp"
|
||||||
name: "KC2G Propagation Data"
|
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
# Maximum time to keep spots and alerts in the system before deleting them. By default, one hour for spots and one week
|
# Maximum time to keep spots and alerts in the system before deleting them. By default, one hour for spots and one week
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from data.band import Band
|
|||||||
from data.sig import SIG
|
from data.sig import SIG
|
||||||
|
|
||||||
# General software
|
# General software
|
||||||
SOFTWARE_VERSION = "1.4-pre"
|
SOFTWARE_VERSION = "1.4"
|
||||||
|
|
||||||
# HTTP headers used for spot providers that use HTTP
|
# HTTP headers used for spot providers that use HTTP
|
||||||
HTTP_HEADERS = {"User-Agent": "Spothole v" + SOFTWARE_VERSION + " (operated by " + SERVER_OWNER_CALLSIGN + ")"}
|
HTTP_HEADERS = {"User-Agent": "Spothole v" + SOFTWARE_VERSION + " (operated by " + SERVER_OWNER_CALLSIGN + ")"}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from pyhamtools import LookupLib, Callinfo, callinfo
|
|||||||
from pyhamtools.exceptions import APIKeyMissingError
|
from pyhamtools.exceptions import APIKeyMissingError
|
||||||
from pyhamtools.frequency import freq_to_band
|
from pyhamtools.frequency import freq_to_band
|
||||||
from pyhamtools.locator import latlong_to_locator
|
from pyhamtools.locator import latlong_to_locator
|
||||||
from requests.exceptions import ConnectionError
|
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||||
from requests_cache import CachedSession
|
from requests_cache import CachedSession
|
||||||
|
|
||||||
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
|
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
|
||||||
@@ -156,6 +156,8 @@ class LookupHelper:
|
|||||||
|
|
||||||
except ConnectionError:
|
except ConnectionError:
|
||||||
logging.warning(f"Connection error when downloading Clublog cty.xml.")
|
logging.warning(f"Connection error when downloading Clublog cty.xml.")
|
||||||
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
logging.warning(f"Timeout when downloading Clublog cty.xml.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error("Exception when downloading Clublog cty.xml", e)
|
logging.error("Exception when downloading Clublog cty.xml", e)
|
||||||
return False
|
return False
|
||||||
@@ -180,6 +182,8 @@ class LookupHelper:
|
|||||||
|
|
||||||
except ConnectionError:
|
except ConnectionError:
|
||||||
logging.warning(f"Connection error when downloading dxcc.json.")
|
logging.warning(f"Connection error when downloading dxcc.json.")
|
||||||
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
logging.warning(f"Timeout when downloading dxcc.json.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error("Exception when downloading dxcc.json", e)
|
logging.error("Exception when downloading dxcc.json", e)
|
||||||
return False
|
return False
|
||||||
@@ -537,6 +541,9 @@ class LookupHelper:
|
|||||||
except ConnectionError:
|
except ConnectionError:
|
||||||
logging.warning(f"Connection error when looking up callsign %s using QRZ", lookup_call)
|
logging.warning(f"Connection error when looking up callsign %s using QRZ", lookup_call)
|
||||||
continue
|
continue
|
||||||
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
logging.warning(f"Timeout when looking up callsign %s using QRZ.", lookup_call)
|
||||||
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.error("Exception when looking up callsign %s using QRZ", lookup_call, exc_info=True)
|
logging.error("Exception when looking up callsign %s using QRZ", lookup_call, exc_info=True)
|
||||||
continue
|
continue
|
||||||
@@ -601,6 +608,9 @@ class LookupHelper:
|
|||||||
except ConnectionError:
|
except ConnectionError:
|
||||||
logging.warning(f"Connection error when looking up callsign %s using HamQTH", lookup_call)
|
logging.warning(f"Connection error when looking up callsign %s using HamQTH", lookup_call)
|
||||||
continue
|
continue
|
||||||
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
logging.warning(f"Timeout when looking up callsign %s using HamQTH", lookup_call)
|
||||||
|
continue
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.error("Exception when looking up callsign %s using HamQTH", lookup_call, exc_info=True)
|
logging.error("Exception when looking up callsign %s using HamQTH", lookup_call, exc_info=True)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import csv
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
|
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
|
||||||
from requests.exceptions import ConnectionError
|
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||||
|
|
||||||
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
|
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
|
||||||
from core.constants import SIGS, HTTP_HEADERS
|
from core.constants import SIGS, HTTP_HEADERS
|
||||||
@@ -12,6 +12,14 @@ from core.geo_utils import wab_wai_square_to_lat_lon
|
|||||||
# file in Spothole and load it on startup.
|
# file in Spothole and load it on startup.
|
||||||
with open("datafiles/MUNICIPIOS.csv", encoding="latin-1") as _f:
|
with open("datafiles/MUNICIPIOS.csv", encoding="latin-1") as _f:
|
||||||
_DME_INDEX = {row["COD_INE"][:5]: row for row in csv.DictReader(_f, delimiter=";")}
|
_DME_INDEX = {row["COD_INE"][:5]: row for row in csv.DictReader(_f, delimiter=";")}
|
||||||
|
# Caches for data for the SIGs where we have to download a whole global reference list, rather than looking up a single
|
||||||
|
# reference. These get populated from the SEMI_STATIC_URL_DATA_CACHE only if the data actually came
|
||||||
|
# live from the internet, to avoid repopulating them every time we pull the same data from the cache.
|
||||||
|
_WWFF_INDEX_CACHE = {}
|
||||||
|
_SIOTA_INDEX_CACHE = {}
|
||||||
|
_WOTA_INDEX_CACHE = {}
|
||||||
|
_ZLOTA_INDEX_CACHE = {}
|
||||||
|
_LLOTA_INDEX_CACHE = {}
|
||||||
|
|
||||||
|
|
||||||
def get_ref_regex_for_sig(sig):
|
def get_ref_regex_for_sig(sig):
|
||||||
@@ -109,12 +117,13 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
# the zeroest corner of the box, then "lat2" and "lng2" provide the other corner. We detect this
|
# the zeroest corner of the box, then "lat2" and "lng2" provide the other corner. We detect this
|
||||||
# and provide a single lat/lon for the centre. Otherwise if we don't have these extra parameters,
|
# and provide a single lat/lon for the centre. Otherwise if we don't have these extra parameters,
|
||||||
# just use the single point we have.
|
# just use the single point we have.
|
||||||
if "latitude" in data and "longitude" in data and "lat2" in data and "lng2" in data:
|
if data.get("latitude") is not None and data.get("longitude") is not None and data.get(
|
||||||
|
"lat2") is not None and data.get("lng2") is not None:
|
||||||
sig_ref.latitude = (float(data["latitude"]) + float(data["lat2"])) / 2.0
|
sig_ref.latitude = (float(data["latitude"]) + float(data["lat2"])) / 2.0
|
||||||
sig_ref.longitude = (float(data["longitude"]) + float(data["lng2"])) / 2.0
|
sig_ref.longitude = (float(data["longitude"]) + float(data["lng2"])) / 2.0
|
||||||
else:
|
else:
|
||||||
sig_ref.latitude = float(data["latitude"]) if "latitude" in data else None
|
sig_ref.latitude = float(data["latitude"]) if data.get("latitude") is not None else None
|
||||||
sig_ref.longitude = float(data["longitude"]) if "longitude" in data else None
|
sig_ref.longitude = float(data["longitude"]) if data.get("longitude") is not None else None
|
||||||
elif not response.from_cache:
|
elif not response.from_cache:
|
||||||
logging.warning("Malformed response looking up %s ref %s via GMA", sig, ref_id)
|
logging.warning("Malformed response looking up %s ref %s via GMA", sig, ref_id)
|
||||||
elif not response.from_cache:
|
elif not response.from_cache:
|
||||||
@@ -124,8 +133,12 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://wwff.co/wwff-data/wwff_directory.csv",
|
response = SEMI_STATIC_URL_DATA_CACHE.get("https://wwff.co/wwff-data/wwff_directory.csv",
|
||||||
headers=HTTP_HEADERS)
|
headers=HTTP_HEADERS)
|
||||||
if response.ok:
|
if response.ok:
|
||||||
wwff_index = {row["reference"]: row for row in csv.DictReader(response.content.decode().splitlines())}
|
global _WWFF_INDEX_CACHE
|
||||||
row = wwff_index.get(ref_id)
|
if not bool(_WWFF_INDEX_CACHE) or not response.from_cache:
|
||||||
|
# New data from WWFF, update our internal map
|
||||||
|
_WWFF_INDEX_CACHE = {row["reference"]: row for row in
|
||||||
|
csv.DictReader(response.content.decode().splitlines())}
|
||||||
|
row = _WWFF_INDEX_CACHE.get(ref_id)
|
||||||
if row:
|
if row:
|
||||||
sig_ref.name = row["name"] if "name" in row else None
|
sig_ref.name = row["name"] if "name" in row else None
|
||||||
sig_ref.url = "https://wwff.co/directory/?showRef=" + ref_id
|
sig_ref.url = "https://wwff.co/directory/?showRef=" + ref_id
|
||||||
@@ -142,9 +155,12 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.silosontheair.com/data/silos.csv",
|
response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.silosontheair.com/data/silos.csv",
|
||||||
headers=HTTP_HEADERS)
|
headers=HTTP_HEADERS)
|
||||||
if response.ok:
|
if response.ok:
|
||||||
siota_index = {row["SILO_CODE"]: row for row in
|
global _SIOTA_INDEX_CACHE
|
||||||
csv.DictReader(response.content.decode().splitlines())}
|
if not bool(_SIOTA_INDEX_CACHE) or not response.from_cache:
|
||||||
row = siota_index.get(ref_id)
|
# New data from SIOTA, update our internal map
|
||||||
|
_SIOTA_INDEX_CACHE = {row["SILO_CODE"]: row for row in
|
||||||
|
csv.DictReader(response.content.decode().splitlines())}
|
||||||
|
row = _SIOTA_INDEX_CACHE.get(ref_id)
|
||||||
if row:
|
if row:
|
||||||
sig_ref.name = row["NAME"] if "NAME" in row else None
|
sig_ref.name = row["NAME"] if "NAME" in row else None
|
||||||
sig_ref.grid = row["LOCATOR"] if "LOCATOR" in row else None
|
sig_ref.grid = row["LOCATOR"] if "LOCATOR" in row else None
|
||||||
@@ -161,19 +177,23 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
if response.ok:
|
if response.ok:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
if data:
|
if data:
|
||||||
for feature in data.get("features", []):
|
global _WOTA_INDEX_CACHE
|
||||||
if feature["properties"]["wotaId"] == ref_id:
|
if not bool(_WOTA_INDEX_CACHE) or not response.from_cache:
|
||||||
sig_ref.name = feature["properties"]["title"]
|
# New data from WOTA, update our internal map
|
||||||
# Fudge WOTA URLs. Outlying fell (LDO) URLs don't match their ID numbers but require 214 to be
|
_WOTA_INDEX_CACHE = {feature["properties"]["wotaId"]: feature for feature in
|
||||||
# added to them
|
data.get("features", [])}
|
||||||
sig_ref.url = "https://www.wota.org.uk/MM_" + ref_id
|
feature = _WOTA_INDEX_CACHE.get(ref_id)
|
||||||
if ref_id.upper().startswith("LDO-"):
|
if feature:
|
||||||
number = int(ref_id.upper().replace("LDO-", ""))
|
sig_ref.name = feature["properties"]["title"]
|
||||||
sig_ref.url = "https://www.wota.org.uk/MM_LDO-" + str(number + 214)
|
# Fudge WOTA URLs. Outlying fell (LDO) URLs don't match their ID numbers but require 214 to be
|
||||||
sig_ref.grid = feature["properties"]["qthLocator"]
|
# added to them
|
||||||
sig_ref.latitude = feature["geometry"]["coordinates"][1]
|
sig_ref.url = "https://www.wota.org.uk/MM_" + ref_id
|
||||||
sig_ref.longitude = feature["geometry"]["coordinates"][0]
|
if ref_id.upper().startswith("LDO-"):
|
||||||
break
|
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]
|
||||||
|
sig_ref.longitude = feature["geometry"]["coordinates"][0]
|
||||||
elif not response.from_cache:
|
elif not response.from_cache:
|
||||||
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
|
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
|
||||||
elif not response.from_cache:
|
elif not response.from_cache:
|
||||||
@@ -184,17 +204,20 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
if response.ok:
|
if response.ok:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
for asset in data:
|
global _ZLOTA_INDEX_CACHE
|
||||||
if asset["code"] == ref_id:
|
if not bool(_ZLOTA_INDEX_CACHE) or not response.from_cache:
|
||||||
sig_ref.name = asset["name"]
|
# New data from ZLOTA, update our internal map
|
||||||
sig_ref.url = "https://ontheair.nz/assets/" + ref_id.replace("/", "_")
|
_ZLOTA_INDEX_CACHE = {asset["code"]: asset for asset in data}
|
||||||
try:
|
asset = _ZLOTA_INDEX_CACHE.get(ref_id)
|
||||||
sig_ref.grid = latlong_to_locator(asset["y"], asset["x"], 6)
|
if asset:
|
||||||
except:
|
sig_ref.name = asset["name"]
|
||||||
logging.debug("Invalid lat/lon received for reference")
|
sig_ref.url = "https://ontheair.nz/assets/" + ref_id.replace("/", "_")
|
||||||
sig_ref.latitude = asset["y"]
|
try:
|
||||||
sig_ref.longitude = asset["x"]
|
sig_ref.grid = latlong_to_locator(asset["y"], asset["x"], 6)
|
||||||
break
|
except:
|
||||||
|
logging.debug("Invalid lat/lon received for reference")
|
||||||
|
sig_ref.latitude = asset["y"]
|
||||||
|
sig_ref.longitude = asset["x"]
|
||||||
elif not response.from_cache:
|
elif not response.from_cache:
|
||||||
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
|
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
|
||||||
elif not response.from_cache:
|
elif not response.from_cache:
|
||||||
@@ -211,15 +234,18 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
if response.ok:
|
if response.ok:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
for ref in data:
|
global _LLOTA_INDEX_CACHE
|
||||||
if ref["reference_code"] == ref_id:
|
if not bool(_LLOTA_INDEX_CACHE) or not response.from_cache:
|
||||||
sig_ref.name = str(ref["name"])
|
# New data from LLOTA, update our internal map
|
||||||
sig_ref.url = "https://llota.app/list/ref/" + ref_id
|
_LLOTA_INDEX_CACHE = {ref["reference_code"]: ref for ref in data}
|
||||||
sig_ref.grid = str(ref["grid_locator"])
|
ref = _LLOTA_INDEX_CACHE.get(ref_id)
|
||||||
ll = locator_to_latlong(sig_ref.grid)
|
if ref:
|
||||||
sig_ref.latitude = ll[0]
|
sig_ref.name = str(ref["name"])
|
||||||
sig_ref.longitude = ll[1]
|
sig_ref.url = "https://llota.app/list/ref/" + ref_id
|
||||||
break
|
sig_ref.grid = str(ref["grid_locator"])
|
||||||
|
ll = locator_to_latlong(sig_ref.grid)
|
||||||
|
sig_ref.latitude = ll[0]
|
||||||
|
sig_ref.longitude = ll[1]
|
||||||
elif not response.from_cache:
|
elif not response.from_cache:
|
||||||
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
|
logging.warning("Malformed response looking up %s ref %s", sig, ref_id)
|
||||||
elif not response.from_cache:
|
elif not response.from_cache:
|
||||||
@@ -271,6 +297,8 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
|
|
||||||
except ConnectionError:
|
except ConnectionError:
|
||||||
logging.warning("Connection error when looking up sig_ref info for " + sig + " ref " + ref_id)
|
logging.warning("Connection error when looking up sig_ref info for " + sig + " ref " + ref_id)
|
||||||
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
logging.warning(f"Timeout when looking up sig_ref info for " + sig + " ref " + ref_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.error("Exception when looking up sig_ref info for " + sig + " ref " + ref_id, exc_info=True)
|
logging.error("Exception when looking up sig_ref info for " + sig + " ref " + ref_id, exc_info=True)
|
||||||
return sig_ref
|
return sig_ref
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from tornado.web import StaticFileHandler, HTTPError
|
|
||||||
|
|
||||||
|
|
||||||
class QuietStaticFileHandler(StaticFileHandler):
|
|
||||||
"""Minor override of logging in StaticFileHandler to log HTTP errors at debug level instead of their usual
|
|
||||||
warning level. Without this, attacks on Spothole which try to do path traversal attacks would log exceptions
|
|
||||||
from inside Tornado, and the server logs would contain a lot of this type of content. This effectively changes
|
|
||||||
the log level of these exceptions to DEBUG so they are only logged if DEBUG level logging is enabled."""
|
|
||||||
|
|
||||||
def log_exception(self, typ, value, tb):
|
|
||||||
if isinstance(value, HTTPError):
|
|
||||||
logging.debug(value)
|
|
||||||
return
|
|
||||||
super().log_exception(typ, value, tb)
|
|
||||||
@@ -18,7 +18,6 @@ from server.handlers.api.status import APIStatusHandler
|
|||||||
from server.handlers.manifesthandler import ManifestHandler
|
from server.handlers.manifesthandler import ManifestHandler
|
||||||
from server.handlers.metrics import PrometheusMetricsHandler
|
from server.handlers.metrics import PrometheusMetricsHandler
|
||||||
from server.handlers.pagetemplate import PageTemplateHandler
|
from server.handlers.pagetemplate import PageTemplateHandler
|
||||||
from server.handlers.quietstaticfilehandler import QuietStaticFileHandler
|
|
||||||
|
|
||||||
_HERE = os.path.dirname(__file__ or "")
|
_HERE = os.path.dirname(__file__ or "")
|
||||||
|
|
||||||
@@ -101,12 +100,16 @@ class WebServer:
|
|||||||
if ALLOW_SPOTTING:
|
if ALLOW_SPOTTING:
|
||||||
ui_routes += [(r"/add-spot", PageTemplateHandler, {"template_name": "add_spot", **handler_opts})]
|
ui_routes += [(r"/add-spot", PageTemplateHandler, {"template_name": "add_spot", **handler_opts})]
|
||||||
|
|
||||||
# API docs, Prometheus metrics, webapp manifest and static assets are always available regardless of API-only mode.
|
# API docs, Prometheus metrics, webapp manifest and static assets are always available regardless of API-only
|
||||||
|
# mode.
|
||||||
misc_routes = [
|
misc_routes = [
|
||||||
(r"/apidocs", PageTemplateHandler, {"template_name": "apidocs", **handler_opts}),
|
(r"/apidocs", PageTemplateHandler, {"template_name": "apidocs", **handler_opts}),
|
||||||
(r"/metrics", PrometheusMetricsHandler),
|
(r"/metrics", PrometheusMetricsHandler),
|
||||||
(r"/manifest.webmanifest", ManifestHandler),
|
(r"/manifest.webmanifest", ManifestHandler),
|
||||||
(r"/static/(.*)", QuietStaticFileHandler, {"path": os.path.join(_HERE, "../webassets")})
|
# If e.g. nginx is configured as a reverse proxy with a hard-coded path to static files, as per the README,
|
||||||
|
# this will never have to handle anything, but having it here allows Spothole to work without nginx for
|
||||||
|
# testing.
|
||||||
|
(r"/static/(.*)", StaticFileHandler, {"path": os.path.join(_HERE, "../static")})
|
||||||
]
|
]
|
||||||
|
|
||||||
app = tornado.web.Application(api_routes + ui_routes + misc_routes,
|
app = tornado.web.Application(api_routes + ui_routes + misc_routes,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from threading import Thread, Event
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError
|
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from solarconditionsproviders.ionosonde_utils import compute_band_states
|
from solarconditionsproviders.ionosonde_utils import compute_band_states
|
||||||
@@ -30,7 +30,7 @@ class GIROIonosonde(SolarConditionsProvider):
|
|||||||
data, but is less reliable and often offline."""
|
data, but is less reliable and often offline."""
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config)
|
super().__init__("GIRO Ionosonde Data", provider_config)
|
||||||
self._stations = self._load_stations()
|
self._stations = self._load_stations()
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._stop_event = Event()
|
self._stop_event = Event()
|
||||||
@@ -134,6 +134,9 @@ class GIROIonosonde(SolarConditionsProvider):
|
|||||||
logging.warning(f"HTTP {http_response.status_code} when calling Giro ionosonde API.")
|
logging.warning(f"HTTP {http_response.status_code} when calling Giro ionosonde API.")
|
||||||
return None, None, None
|
return None, None, None
|
||||||
return self._parse_all(http_response.text)
|
return self._parse_all(http_response.text)
|
||||||
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
logging.warning(f"Timeout when accessing Giro ionosonde API.")
|
||||||
|
return None, None, None
|
||||||
except ConnectionError:
|
except ConnectionError:
|
||||||
logging.warning("Connection error when accessing Giro ionosonde API.")
|
logging.warning("Connection error when accessing Giro ionosonde API.")
|
||||||
return None, None, None
|
return None, None, None
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class HamQSL(HTTPSolarConditionsProvider):
|
|||||||
Provides solar flux index, geomagnetic indices, and HF/VHF propagation condition summaries."""
|
Provides solar flux index, geomagnetic indices, and HF/VHF propagation condition summaries."""
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, URL, POLL_INTERVAL)
|
super().__init__("HamQSL", provider_config, URL, POLL_INTERVAL)
|
||||||
|
|
||||||
def _http_response_to_solar_conditions(self, http_response):
|
def _http_response_to_solar_conditions(self, http_response):
|
||||||
root = ElementTree.fromstring(http_response.text)
|
root = ElementTree.fromstring(http_response.text)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from threading import Thread, Event
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError
|
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from solarconditionsproviders.solar_conditions_provider import SolarConditionsProvider
|
from solarconditionsproviders.solar_conditions_provider import SolarConditionsProvider
|
||||||
@@ -14,8 +14,8 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
|
|||||||
"""Generic solar conditions provider for providers that request data via HTTP(S). Subclasses implement
|
"""Generic solar conditions provider for providers that request data via HTTP(S). Subclasses implement
|
||||||
_http_response_to_solar_conditions() to parse the specific API response format."""
|
_http_response_to_solar_conditions() to parse the specific API response format."""
|
||||||
|
|
||||||
def __init__(self, provider_config, url, poll_interval):
|
def __init__(self, name, provider_config, url, poll_interval):
|
||||||
super().__init__(provider_config)
|
super().__init__(name, provider_config)
|
||||||
self._url = url
|
self._url = url
|
||||||
self._poll_interval = poll_interval
|
self._poll_interval = poll_interval
|
||||||
self._thread = None
|
self._thread = None
|
||||||
@@ -54,6 +54,8 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
|
|||||||
|
|
||||||
except ConnectionError:
|
except ConnectionError:
|
||||||
logging.warning(f"Connection error when accessing {self.name} solar conditions API.")
|
logging.warning(f"Connection error when accessing {self.name} solar conditions API.")
|
||||||
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
logging.warning(f"Timeout when accessing {self.name} solar conditions API.")
|
||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logging.exception("Exception in HTTP Solar Conditions Provider (" + self.name + ")")
|
logging.exception("Exception in HTTP Solar Conditions Provider (" + self.name + ")")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from threading import Thread, Event
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError
|
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from solarconditionsproviders.ionosonde_utils import compute_band_states
|
from solarconditionsproviders.ionosonde_utils import compute_band_states
|
||||||
@@ -24,7 +24,7 @@ class KC2GProp(SolarConditionsProvider):
|
|||||||
online, but has fewer stations and does not provide LUF data."""
|
online, but has fewer stations and does not provide LUF data."""
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config)
|
super().__init__("KC2G Propagation Data", provider_config)
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._stop_event = Event()
|
self._stop_event = Event()
|
||||||
|
|
||||||
@@ -118,6 +118,8 @@ class KC2GProp(SolarConditionsProvider):
|
|||||||
|
|
||||||
except ConnectionError:
|
except ConnectionError:
|
||||||
logging.warning("Connection error when accessing KC2G ionosonde API.")
|
logging.warning("Connection error when accessing KC2G ionosonde API.")
|
||||||
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
logging.warning(f"Timeout when accessing KC2G ionosonde API.")
|
||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logging.exception("Exception in KC2G ionosonde data provider")
|
logging.exception("Exception in KC2G ionosonde data provider")
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
|
|||||||
corresponding fields in the solar conditions object.."""
|
corresponding fields in the solar conditions object.."""
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, URL, POLL_INTERVAL)
|
super().__init__("NOAA 3-day Forecast", provider_config, URL, POLL_INTERVAL)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_percentage_table(lines, section_header, year):
|
def _parse_percentage_table(lines, section_header, year):
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ class SolarConditionsProvider:
|
|||||||
"""Generic solar conditions provider class. Subclasses of this query individual APIs for space weather and
|
"""Generic solar conditions provider class. Subclasses of this query individual APIs for space weather and
|
||||||
propagation data."""
|
propagation data."""
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, name, provider_config):
|
||||||
"""Constructor"""
|
"""Constructor"""
|
||||||
|
|
||||||
self._solar_conditions_cache = None
|
self._solar_conditions_cache = None
|
||||||
self.name = provider_config["name"]
|
self.name = name
|
||||||
self.enabled = provider_config["enabled"]
|
self.enabled = provider_config["enabled"]
|
||||||
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||||
self.status = "Not Started" if self.enabled else "Disabled"
|
self.status = "Not Started" if self.enabled else "Disabled"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class APRSIS(SpotProvider):
|
|||||||
"""Spot provider for the APRS-IS."""
|
"""Spot provider for the APRS-IS."""
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config)
|
super().__init__("APRS-IS", provider_config)
|
||||||
self._thread = Thread(target=self._connect)
|
self._thread = Thread(target=self._connect)
|
||||||
self._thread.daemon = True
|
self._thread.daemon = True
|
||||||
self._aprsis = None
|
self._aprsis = None
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ class DXCluster(SpotProvider):
|
|||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
"""Constructor requires hostname and port"""
|
"""Constructor requires hostname and port"""
|
||||||
|
|
||||||
super().__init__(provider_config)
|
name = provider_config["name"] if "name" in provider_config else "Cluster"
|
||||||
|
super().__init__(name, provider_config)
|
||||||
self._hostname = provider_config["host"]
|
self._hostname = provider_config["host"]
|
||||||
self._port = provider_config["port"]
|
self._port = provider_config["port"]
|
||||||
self._login_prompt = provider_config["login_prompt"] if "login_prompt" in provider_config else "login:"
|
self._login_prompt = provider_config["login_prompt"] if "login_prompt" in provider_config else "login:"
|
||||||
@@ -59,6 +60,10 @@ class DXCluster(SpotProvider):
|
|||||||
self._telnet.write((self._login_callsign + "\n").encode("latin-1"))
|
self._telnet.write((self._login_callsign + "\n").encode("latin-1"))
|
||||||
connected = True
|
connected = True
|
||||||
logging.info("DX Cluster " + self._hostname + " connected.")
|
logging.info("DX Cluster " + self._hostname + " connected.")
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
self.status = "Error"
|
||||||
|
logging.warning("Connection refused to DX cluster " + self._hostname)
|
||||||
|
sleep(300)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logging.exception("Exception while connecting to DX Cluster Provider (" + self._hostname + ").")
|
logging.exception("Exception while connecting to DX Cluster Provider (" + self._hostname + ").")
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class GMA(HTTPSpotProvider):
|
|||||||
provider_config["enabled"] = False
|
provider_config["enabled"] = False
|
||||||
logging.warning("GMA spot provider configured but no api key was provided, this API will not be queried.")
|
logging.warning("GMA spot provider configured but no api key was provided, this API will not be queried.")
|
||||||
|
|
||||||
super().__init__(provider_config, self.SPOTS_URL + "?key=" + self.api_key, self.POLL_INTERVAL_SEC)
|
super().__init__("GMA", provider_config, self.SPOTS_URL + "?key=" + self.api_key, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
def _http_response_to_spots(self, http_response):
|
def _http_response_to_spots(self, http_response):
|
||||||
new_spots = []
|
new_spots = []
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError
|
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
@@ -25,7 +25,7 @@ class HEMA(HTTPSpotProvider):
|
|||||||
SPOTTER_COMMENT_PATTERN = re.compile("^\\((.*)\\) (.*)$")
|
SPOTTER_COMMENT_PATTERN = re.compile("^\\((.*)\\) (.*)$")
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.SPOT_SEED_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("HEMA", provider_config, self.SPOT_SEED_URL, self.POLL_INTERVAL_SEC)
|
||||||
self._spot_seed = ""
|
self._spot_seed = ""
|
||||||
|
|
||||||
def _http_response_to_spots(self, http_response):
|
def _http_response_to_spots(self, http_response):
|
||||||
@@ -68,6 +68,8 @@ class HEMA(HTTPSpotProvider):
|
|||||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other
|
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other
|
||||||
# code will do that for us.
|
# code will do that for us.
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
logging.warning(f"Timeout when accessing HEMA spots API.")
|
||||||
except ConnectionError:
|
except ConnectionError:
|
||||||
logging.warning("Connection error when accessing HEMA spots API.")
|
logging.warning("Connection error when accessing HEMA spots API.")
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ from threading import Thread, Event
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError
|
from requests import ReadTimeout
|
||||||
|
from requests.exceptions import ConnectionError, ConnectTimeout
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from spotproviders.spot_provider import SpotProvider
|
from spotproviders.spot_provider import SpotProvider
|
||||||
@@ -14,8 +15,8 @@ class HTTPSpotProvider(SpotProvider):
|
|||||||
"""Generic spot provider class for providers that request data via HTTP(S). Just for convenience to avoid code
|
"""Generic spot provider class for providers that request data via HTTP(S). Just for convenience to avoid code
|
||||||
duplication. Subclasses of this query the individual APIs for data."""
|
duplication. Subclasses of this query the individual APIs for data."""
|
||||||
|
|
||||||
def __init__(self, provider_config, url, poll_interval):
|
def __init__(self, name, provider_config, url, poll_interval):
|
||||||
super().__init__(provider_config)
|
super().__init__(name, provider_config)
|
||||||
self._url = url
|
self._url = url
|
||||||
self._poll_interval = poll_interval
|
self._poll_interval = poll_interval
|
||||||
self._thread = None
|
self._thread = None
|
||||||
@@ -59,6 +60,8 @@ class HTTPSpotProvider(SpotProvider):
|
|||||||
|
|
||||||
except ConnectionError:
|
except ConnectionError:
|
||||||
logging.warning(f"Connection error when accessing {self.name} spots API.")
|
logging.warning(f"Connection error when accessing {self.name} spots API.")
|
||||||
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
logging.warning(f"Timeout when accessing {self.name} spots API.")
|
||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logging.exception("Exception in HTTP JSON Spot Provider (" + self.name + ")")
|
logging.exception("Exception in HTTP JSON Spot Provider (" + self.name + ")")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class LLOTA(HTTPSpotProvider):
|
|||||||
SPOTS_URL = "https://llota.app/api/public/spots"
|
SPOTS_URL = "https://llota.app/api/public/spots"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("LLOTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
def _http_response_to_spots(self, http_response):
|
def _http_response_to_spots(self, http_response):
|
||||||
new_spots = []
|
new_spots = []
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv"
|
SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("ParksNPeaks", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
def _http_response_to_spots(self, http_response):
|
def _http_response_to_spots(self, http_response):
|
||||||
new_spots = []
|
new_spots = []
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class POTA(HTTPSpotProvider):
|
|||||||
SPOTS_URL = "https://api.pota.app/spot/activator"
|
SPOTS_URL = "https://api.pota.app/spot/activator"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("POTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
def _http_response_to_spots(self, http_response):
|
def _http_response_to_spots(self, http_response):
|
||||||
new_spots = []
|
new_spots = []
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ class RBN(SpotProvider):
|
|||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
"""Constructor requires port number."""
|
"""Constructor requires port number."""
|
||||||
|
|
||||||
super().__init__(provider_config)
|
name = provider_config["name"] if "name" in provider_config else "RBN"
|
||||||
|
super().__init__(name, provider_config)
|
||||||
self._port = provider_config["port"]
|
self._port = provider_config["port"]
|
||||||
self._telnet = None
|
self._telnet = None
|
||||||
self._thread = Thread(target=self._handle)
|
self._thread = Thread(target=self._handle)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import logging
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError
|
from requests.exceptions import ConnectionError, ReadTimeout, ConnectTimeout
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
@@ -23,7 +23,7 @@ class SOTA(HTTPSpotProvider):
|
|||||||
SUMMIT_URL_ROOT = "https://api-db2.sota.org.uk/api/summits/"
|
SUMMIT_URL_ROOT = "https://api-db2.sota.org.uk/api/summits/"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("SOTA", provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
|
||||||
self._api_epoch = ""
|
self._api_epoch = ""
|
||||||
|
|
||||||
def _http_response_to_spots(self, http_response):
|
def _http_response_to_spots(self, http_response):
|
||||||
@@ -60,4 +60,6 @@ class SOTA(HTTPSpotProvider):
|
|||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
except ConnectionError:
|
except ConnectionError:
|
||||||
logging.warning("Connection error when accessing SOTA spots API")
|
logging.warning("Connection error when accessing SOTA spots API")
|
||||||
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
logging.warning(f"Timeout when accessing SOTA spots API.")
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ from core.config import MAX_SPOT_AGE
|
|||||||
class SpotProvider:
|
class SpotProvider:
|
||||||
"""Generic spot provider class. Subclasses of this query the individual APIs for data."""
|
"""Generic spot provider class. Subclasses of this query the individual APIs for data."""
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, name, provider_config):
|
||||||
"""Constructor"""
|
"""Constructor"""
|
||||||
|
|
||||||
self.name = provider_config["name"]
|
self.name = name
|
||||||
self.enabled = provider_config["enabled"]
|
self.enabled = provider_config["enabled"]
|
||||||
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||||
self.last_spot_time = datetime.min.replace(tzinfo=pytz.UTC)
|
self.last_spot_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ from spotproviders.spot_provider import SpotProvider
|
|||||||
class SSESpotProvider(SpotProvider):
|
class SSESpotProvider(SpotProvider):
|
||||||
"""Spot provider using Server-Sent Events."""
|
"""Spot provider using Server-Sent Events."""
|
||||||
|
|
||||||
def __init__(self, provider_config, url):
|
def __init__(self, name, provider_config, url):
|
||||||
super().__init__(provider_config)
|
super().__init__(name, provider_config)
|
||||||
self._url = url
|
self._url = url
|
||||||
self._event_source = None
|
self._event_source = None
|
||||||
self._thread = None
|
self._thread = None
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class Tiles(HTTPSpotProvider):
|
|||||||
SPOTS_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/spots?active_hours=24"
|
SPOTS_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/spots?active_hours=24"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("Tiles", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
def _http_response_to_spots(self, http_response):
|
def _http_response_to_spots(self, http_response):
|
||||||
new_spots = []
|
new_spots = []
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class Towers(HTTPSpotProvider):
|
|||||||
SPOTS_URL = "https://wwtota.com/api/cluster_live.php"
|
SPOTS_URL = "https://wwtota.com/api/cluster_live.php"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("Towers", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
def _http_response_to_spots(self, http_response):
|
def _http_response_to_spots(self, http_response):
|
||||||
new_spots = []
|
new_spots = []
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class UKPacketNet(HTTPSpotProvider):
|
|||||||
SPOTS_URL = "https://nodes.ukpacketradio.network/api/nodedata"
|
SPOTS_URL = "https://nodes.ukpacketradio.network/api/nodedata"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("UK Packet Net", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
def _http_response_to_spots(self, http_response):
|
def _http_response_to_spots(self, http_response):
|
||||||
new_spots = []
|
new_spots = []
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ from spotproviders.spot_provider import SpotProvider
|
|||||||
class WebsocketSpotProvider(SpotProvider):
|
class WebsocketSpotProvider(SpotProvider):
|
||||||
"""Spot provider using websockets."""
|
"""Spot provider using websockets."""
|
||||||
|
|
||||||
def __init__(self, provider_config, url):
|
def __init__(self, name, provider_config, url):
|
||||||
super().__init__(provider_config)
|
super().__init__(name, provider_config)
|
||||||
self._url = url
|
self._url = url
|
||||||
self._ws = None
|
self._ws = None
|
||||||
self._thread = None
|
self._thread = None
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class WOTA(HTTPSpotProvider):
|
|||||||
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
|
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("WOTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
def _http_response_to_spots(self, http_response):
|
def _http_response_to_spots(self, http_response):
|
||||||
new_spots = []
|
new_spots = []
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class WWBOTA(SSESpotProvider):
|
|||||||
SPOTS_URL = "https://api.wwbota.net/spots/"
|
SPOTS_URL = "https://api.wwbota.net/spots/"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.SPOTS_URL)
|
super().__init__("WWBOTA", provider_config, self.SPOTS_URL)
|
||||||
|
|
||||||
def _sse_message_to_spot(self, message):
|
def _sse_message_to_spot(self, message):
|
||||||
source_spot = json.loads(message)
|
source_spot = json.loads(message)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class WWFF(HTTPSpotProvider):
|
|||||||
SPOTS_URL = "https://spots.wwff.co/static/spots.json"
|
SPOTS_URL = "https://spots.wwff.co/static/spots.json"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("WWFF", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
def _http_response_to_spots(self, http_response):
|
def _http_response_to_spots(self, http_response):
|
||||||
new_spots = []
|
new_spots = []
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ class XOTA(WebsocketSpotProvider):
|
|||||||
SIG = None
|
SIG = None
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, provider_config["url"])
|
name = provider_config["name"] if "name" in provider_config else "xOTA"
|
||||||
|
super().__init__(name, provider_config, provider_config["url"])
|
||||||
locations_csv = str(provider_config["locations-csv"]) if "locations-csv" in provider_config else None
|
locations_csv = str(provider_config["locations-csv"]) if "locations-csv" in provider_config else None
|
||||||
self.SIG = str(provider_config["sig"]) if "sig" in provider_config else None
|
self.SIG = str(provider_config["sig"]) if "sig" in provider_config else None
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class ZLOTA(HTTPSpotProvider):
|
|||||||
LIST_URL = "https://ontheair.nz/assets/assets.json"
|
LIST_URL = "https://ontheair.nz/assets/assets.json"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("ZLOTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
def _http_response_to_spots(self, http_response):
|
def _http_response_to_spots(self, http_response):
|
||||||
new_spots = []
|
new_spots = []
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |