mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-02-04 17:24:30 +00:00
Compare commits
42 Commits
3-sse-endp
...
web_ui_opt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ed0eed10b | ||
|
|
221fade44b | ||
|
|
721d345332 | ||
|
|
bf2f5956fc | ||
|
|
7f4556a340 | ||
|
|
33de618808 | ||
|
|
edb8dd5e0e | ||
|
|
b62ef6a9a0 | ||
|
|
7952ad22eb | ||
|
|
33bdcca990 | ||
|
|
261912b6e1 | ||
|
|
bb75b4ec2f | ||
|
|
0babf0a6be | ||
|
|
65957b4c01 | ||
|
|
522f90af97 | ||
|
|
4d344021c7 | ||
|
|
abdf8d3065 | ||
|
|
67b9c3bc50 | ||
|
|
9b3536d740 | ||
|
|
897901e105 | ||
|
|
059d9364eb | ||
|
|
a3ca590ca3 | ||
|
|
cfff8dd832 | ||
|
|
d1a5bfe9c3 | ||
|
|
da2827f559 | ||
|
|
220c9378cf | ||
|
|
e1cdc5b857 | ||
|
|
5482da0e69 | ||
|
|
f31148686d | ||
|
|
a444be8fe9 | ||
|
|
3f117a47d6 | ||
|
|
06d582ae2d | ||
|
|
5bf45dba46 | ||
|
|
f4ae6b610e | ||
|
|
6af15e4cfd | ||
|
|
6d9bf3d4ec | ||
|
|
9b737a8176 | ||
|
|
05bc65337f | ||
|
|
d2c1dbb377 | ||
|
|
6cf1b38355 | ||
|
|
ac566553d8 | ||
|
|
bcc40d1416 |
109
README.md
109
README.md
@@ -10,7 +10,7 @@ The API is deliberately well-defined with an OpenAPI specification and auto-gene
|
||||
|
||||
Spothole itself is also open source, Public Domain licenced code that anyone can take and modify.
|
||||
|
||||
Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, the UK Packet Repeater Network, NG3K, and any site based on the xOTA software by nischu.
|
||||
Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, LLOTA, WWTOTA, the UK Packet Repeater Network, NG3K, and any site based on the xOTA software by nischu.
|
||||
|
||||

|
||||
|
||||
@@ -30,14 +30,14 @@ URL parameters can be used to trigger an "embedded" mode which hides the headers
|
||||
|
||||
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.com/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.com/?embedded=true`. For dark mode showing 70cm TOTA spots only, use `https://spothole.com/?embedded=true&dark-mode=true&filter-sigs=TOTA&filter-bands=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.
|
||||
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. |
|
||||
| `color-scheme` | `light`, `dark`, `auto` | `auto` | `?color-scheme=dark` | Forces light or dark mode in preference to the operating system default. |
|
||||
| `time-zone` | `UTC`, `local` | `UTC` | `?time-zone=local` | Sets times to be in UTC or local time. |
|
||||
| `limit` | 10, 25, 50, 100 | 50 | `?limit=50` | Sets the number of spots that will be displayed on the main spots page |
|
||||
| `limit` | 25, 50, 100, 200, 500 | 100 | `?limit=100` | Sets the number of alerts that will be displayed on the alerts page |
|
||||
@@ -83,6 +83,8 @@ cp config-example.yml config.yml
|
||||
|
||||
Then edit `config.yml` in your text editor of choice to set up the software as you like it. Mostly, this will involve enabling or disabling the various providers of spot and alert data.
|
||||
|
||||
By default, all outdoor programme providers are enabled, as is one cluster node and the NG3K DXpedition data. The RBN spot providers are turned off by default due to the volume of traffic from CW/RTTY/FT8 skimmers, and the APRS and Packet spot providers are off by default on the assumption that Spothole users want a spot with a human at the other end of it, but all can be easily re-enabled.
|
||||
|
||||
`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.
|
||||
|
||||
Clublog API keys are free, but you'll need to get your own by submitting a helpdesk ticket and explaining what you'll use it for. The admin team are happy with the rate of requests made by my Spothole server, so unless you change the source code of yours to radically increase the rate of querying Clublog, I'm sure they will be fine with your server too.
|
||||
@@ -102,6 +104,102 @@ The software can take a few seconds to start up, mostly because it is downloadin
|
||||
|
||||
If you see some errors on startup, check your configuration, e.g. in case you have specified a port for the web server that is already in use by something else.
|
||||
|
||||
### Multiple cluster nodes with different settings
|
||||
|
||||
Dan, S50U has written in with his Spothole cluster settings. He is using a cluster node which provides RBN spots, and uses different SSIDs on his callsign to get different settings when logged into the same cluster node. For example:
|
||||
|
||||
```
|
||||
-
|
||||
class: "DXCluster"
|
||||
name: "S50CLX"
|
||||
enabled: true
|
||||
host: "s50clx.si"
|
||||
port: 41112
|
||||
login_prompt: "login: "
|
||||
login_callsign: "callsign-10"
|
||||
```
|
||||
|
||||
Telnet to DXSpider and log in with "callsign-10" and execute the following commands:
|
||||
|
||||
`CLEAR/SPOTS ALL` (delete all previous filters)<br/>
|
||||
`UNSET/ANN` (stop announce messages)<br/>
|
||||
`UNSET/WCY` (stop wcy messages)<br/>
|
||||
`UNSET/WWV` (stop wwv messages)<br/>
|
||||
`SET/DX` (enable human DX spots)
|
||||
|
||||
```
|
||||
-
|
||||
class: "DXCluster"
|
||||
name: "RBN CW"
|
||||
enabled: true
|
||||
host: "s50clx.si"
|
||||
port: 41112
|
||||
login_prompt: "login: "
|
||||
login_callsign: "callsign-11"
|
||||
allow_rbn_spots: true
|
||||
enabled-by-default-in-web-ui: false
|
||||
```
|
||||
|
||||
Telnet to DXSpider and log in with "callsign-11" and execute the following commands:
|
||||
|
||||
`CLEAR/SPOTS ALL` (delete all previous filters)<br/>
|
||||
`UNSET/ANN` (stop announce messages)<br/>
|
||||
`UNSET/WCY` (stop wcy messages)<br/>
|
||||
`UNSET/WWV` (stop wwv messages)<br/>
|
||||
`UNSET/DX` (stop human DX spots)<br/>
|
||||
`SET/SKIMMER CW` (enable CW RBN spots)
|
||||
|
||||
```
|
||||
-
|
||||
class: "DXCluster"
|
||||
name: "RBN RTTY"
|
||||
enabled: true
|
||||
host: "s50clx.si"
|
||||
port: 41112
|
||||
login_prompt: "login: "
|
||||
login_callsign: "callsign-12"
|
||||
allow_rbn_spots: true
|
||||
enabled-by-default-in-web-ui: false
|
||||
```
|
||||
|
||||
Telnet to DXSpider and log in with "callsign-12" and execute the following commands:
|
||||
|
||||
`CLEAR/SPOTS ALL` (delete all previous filters)<br/>
|
||||
`UNSET/ANN` (stop announce messages)<br/>
|
||||
`UNSET/WCY` (stop wcy messages)<br/>
|
||||
`UNSET/WWV` (stop wwv messages)<br/>
|
||||
`UNSET/DX` (stop human DX spots)<br/>
|
||||
`SET/SKIMMER RTTY` (enable RTTY RBN spots)
|
||||
|
||||
```
|
||||
-
|
||||
class: "DXCluster"
|
||||
name: "RBN FT4/8"
|
||||
enabled: true
|
||||
host: "s50clx.si"
|
||||
port: 41112
|
||||
login_prompt: "login: "
|
||||
login_callsign: "callsign-13"
|
||||
allow_rbn_spots: true
|
||||
enabled-by-default-in-web-ui: false
|
||||
```
|
||||
|
||||
Telnet to DXSpider and log in with "callsign-13" and execute the following commands:
|
||||
|
||||
`CLEAR/SPOTS ALL` (delete all previous filters)<br/>
|
||||
`UNSET/ANN` (stop announce messages)<br/>
|
||||
`UNSET/WCY` (stop wcy messages)<br/>
|
||||
`UNSET/WWV` (stop wwv messages)<br/>
|
||||
`UNSET/DX` (stop human DX spots)<br/>
|
||||
`SET/SKIMMER FT` (enable FT RBN spots)
|
||||
|
||||
For each callsign-SSID, we also specify our basic information with commands:
|
||||
|
||||
`SET/NAME Spothole10`, Spothole11... etc.<br/>
|
||||
`SET/QTH Cerkno`<br/>
|
||||
`SET/QRA JN66XD`<br/>
|
||||
`SET/HOME S50CLX`
|
||||
|
||||
### systemd configuration
|
||||
|
||||
If you want Spothole to run automatically on startup on a Linux distribution that uses `systemd`, follow the instructions here. For distros that don't use `systemd`, or Windows/OSX/etc., you can find generic instructions for your OS online.
|
||||
@@ -157,6 +255,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;
|
||||
}
|
||||
}
|
||||
@@ -219,6 +319,7 @@ 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
|
||||
|
||||
@@ -20,7 +20,11 @@ class BOTA(HTTPAlertProvider):
|
||||
new_alerts = []
|
||||
# Find the table of upcoming alerts
|
||||
bs = BeautifulSoup(http_response.content.decode(), features="lxml")
|
||||
tbody = bs.body.find('div', attrs={'class': 'view-activations-public'}).find('table', attrs={'class': 'views-table'}).find('tbody')
|
||||
div = bs.body.find('div', attrs={'class': 'view-activations-public'})
|
||||
if div:
|
||||
table = div.find('table', attrs={'class': 'views-table'})
|
||||
if table:
|
||||
tbody = table.find('tbody')
|
||||
for row in tbody.find_all('tr'):
|
||||
cells = row.find_all('td')
|
||||
first_cell_text = str(cells[0].find('a').contents[0]).strip()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -49,6 +49,14 @@ spot-providers:
|
||||
class: "WOTA"
|
||||
name: "WOTA"
|
||||
enabled: true
|
||||
-
|
||||
class: "LLOTA"
|
||||
name: "LLOTA"
|
||||
enabled: true
|
||||
-
|
||||
class: "WWTOTA"
|
||||
name: "WWTOTA"
|
||||
enabled: true
|
||||
-
|
||||
class: "APRSIS"
|
||||
name: "APRS-IS"
|
||||
@@ -59,39 +67,63 @@ spot-providers:
|
||||
enabled: true
|
||||
host: "hrd.wa9pie.net"
|
||||
port: 8000
|
||||
login_prompt: "login: "
|
||||
# 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
|
||||
login_prompt: "Please enter your call: "
|
||||
# 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: "wss://dev.39c3.totawatch.de/api/spot/live"
|
||||
# Fixed SIG/latitude/longitude for all spots from a provider is currently only a feature for the "XOTA" provider,
|
||||
# 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.
|
||||
@@ -157,3 +189,5 @@ web-ui-options:
|
||||
max-spot-age-default: 30
|
||||
alert-count: [25, 50, 100, 200, 500]
|
||||
alert-count-default: 100
|
||||
default-color-scheme: "auto"
|
||||
default-band-color-scheme: "PSK Reporter (Adjusted)"
|
||||
@@ -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,12 @@ 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)]
|
||||
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
|
||||
# one of our proviers. We set that to also be enabled by default.
|
||||
if ALLOW_SPOTTING:
|
||||
WEB_UI_OPTIONS["spot-providers-enabled-by-default"].append("API")
|
||||
|
||||
@@ -4,7 +4,7 @@ from data.sig import SIG
|
||||
|
||||
# General software
|
||||
SOFTWARE_NAME = "Spothole by M0TRT"
|
||||
SOFTWARE_VERSION = "1.1-pre"
|
||||
SOFTWARE_VERSION = "1.3-pre"
|
||||
|
||||
# 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,80 @@ 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="LLOTA", description="Lagos y Lagunas on the Air", ref_regex=r"[A-Z]{2}\-\d{4}"),
|
||||
SIG(name="WWTOTA", description="Towers on the Air", ref_regex=r"[A-Z]{2}R\-\d{4}"),
|
||||
SIG(name="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", "MSK144"]
|
||||
DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"]
|
||||
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
|
||||
MODE_TYPES = ["CW", "PHONE", "DATA"]
|
||||
|
||||
# Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode
|
||||
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots
|
||||
# that match a key in this table will be converted to the corresponding value, so only the modes above will actually be
|
||||
# present in the spots.
|
||||
MODE_ALIASES = {
|
||||
"RTT": "RTTY",
|
||||
"BPSK": "PSK",
|
||||
"PSK31": "PSK",
|
||||
"BPSK31": "PSK",
|
||||
"MFSK": "FSK",
|
||||
"MFSK32": "FSK",
|
||||
"DIGI": "DATA",
|
||||
"DIGITAL": "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"]
|
||||
|
||||
@@ -16,7 +16,7 @@ from requests_cache import CachedSession
|
||||
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
|
||||
from core.config import config
|
||||
from core.constants import BANDS, UNKNOWN_BAND, CW_MODES, PHONE_MODES, DATA_MODES, ALL_MODES, \
|
||||
HTTP_HEADERS, HAMQTH_PRG
|
||||
HTTP_HEADERS, HAMQTH_PRG, MODE_ALIASES
|
||||
|
||||
|
||||
# Singleton class that provides lookup functionality.
|
||||
@@ -140,12 +140,14 @@ class LookupHelper:
|
||||
# database live if possible.
|
||||
def download_clublog_ctyxml(self):
|
||||
try:
|
||||
logging.info("Downloading Clublog cty.xml...")
|
||||
logging.info("Downloading Clublog cty.xml.gz...")
|
||||
response = self.CLUBLOG_CTY_XML_CACHE.get("https://cdn.clublog.org/cty.php?api=" + self.CLUBLOG_API_KEY,
|
||||
headers=HTTP_HEADERS)
|
||||
logging.info("Caching Clublog cty.xml.gz...")
|
||||
open(self.CLUBLOG_XML_DOWNLOAD_LOCATION + ".gz", 'wb').write(response.content)
|
||||
with gzip.open(self.CLUBLOG_XML_DOWNLOAD_LOCATION + ".gz", "rb") as uncompressed:
|
||||
file_content = uncompressed.read()
|
||||
logging.info("Caching Clublog cty.xml...")
|
||||
with open(self.CLUBLOG_XML_DOWNLOAD_LOCATION, "wb") as f:
|
||||
f.write(file_content)
|
||||
f.flush()
|
||||
@@ -160,6 +162,9 @@ class LookupHelper:
|
||||
for mode in ALL_MODES:
|
||||
if mode in comment.upper():
|
||||
return mode
|
||||
for mode in MODE_ALIASES.keys():
|
||||
if mode in comment.upper():
|
||||
return MODE_ALIASES[mode]
|
||||
return None
|
||||
|
||||
# Infer a "mode family" from a mode.
|
||||
@@ -413,7 +418,12 @@ class LookupHelper:
|
||||
# Infer a grid locator from a callsign (using DXCC, probably very inaccurate)
|
||||
def infer_grid_from_callsign_dxcc(self, call):
|
||||
latlon = self.infer_latlon_from_callsign_dxcc(call)
|
||||
return latlong_to_locator(latlon[0], latlon[1], 8)
|
||||
grid = None
|
||||
try:
|
||||
grid = latlong_to_locator(latlon[0], latlon[1], 8)
|
||||
except:
|
||||
logging.debug("Invalid lat/lon received for DXCC")
|
||||
return grid
|
||||
|
||||
# Infer a mode from the frequency (in Hz) according to the band plan. Just a guess really.
|
||||
def infer_mode_from_frequency(self, freq):
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import csv
|
||||
import logging
|
||||
|
||||
from pyhamtools.locator import latlong_to_locator
|
||||
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
|
||||
|
||||
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
|
||||
|
||||
|
||||
# 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.
|
||||
def get_ref_regex_for_sig(sig):
|
||||
for s in SIGS:
|
||||
@@ -29,7 +21,7 @@ def get_ref_regex_for_sig(sig):
|
||||
# Note there is currently no support for KRMNPA location lookup, see issue #61.
|
||||
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.")
|
||||
logging.warning("Failed to look up sig_ref info, sig or id were not set.")
|
||||
|
||||
sig = sig_ref.sig
|
||||
ref_id = sig_ref.id
|
||||
@@ -54,6 +46,7 @@ def populate_sig_ref_info(sig_ref):
|
||||
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/" + ref_id,
|
||||
headers=HTTP_HEADERS).json()
|
||||
@@ -80,9 +73,9 @@ def populate_sig_ref_info(sig_ref):
|
||||
if row["reference"] == ref_id:
|
||||
sig_ref.name = row["name"] if "name" in row else None
|
||||
sig_ref.url = "https://wwff.co/directory/?showRef=" + ref_id
|
||||
sig_ref.grid = row["iaruLocator"] if "iaruLocator" in row 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
|
||||
sig_ref.grid = row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None
|
||||
sig_ref.latitude = float(row["latitude"]) if "latitude" in row and row["latitude"] != "-" else None
|
||||
sig_ref.longitude = float(row["longitude"]) if "longitude" in row and row["longitude"] != "-" else None
|
||||
break
|
||||
elif sig.upper() == "SIOTA":
|
||||
siota_csv_data = SEMI_STATIC_URL_DATA_CACHE.get("https://www.silosontheair.com/data/silos.csv",
|
||||
@@ -119,7 +112,10 @@ def populate_sig_ref_info(sig_ref):
|
||||
if asset["code"] == ref_id:
|
||||
sig_ref.name = asset["name"]
|
||||
sig_ref.url = "https://ontheair.nz/assets/ZLI_OT-030" + ref_id.replace("/", "_")
|
||||
try:
|
||||
sig_ref.grid = latlong_to_locator(asset["y"], asset["x"], 6)
|
||||
except:
|
||||
logging.debug("Invalid lat/lon received for reference")
|
||||
sig_ref.latitude = asset["y"]
|
||||
sig_ref.longitude = asset["x"]
|
||||
break
|
||||
@@ -127,15 +123,34 @@ def populate_sig_ref_info(sig_ref):
|
||||
if not sig_ref.name:
|
||||
sig_ref.name = sig_ref.id
|
||||
sig_ref.url = "https://www.beachesontheair.com/beaches/" + sig_ref.name.lower().replace(" ", "-")
|
||||
elif sig.upper() == "LLOTA":
|
||||
data = SEMI_STATIC_URL_DATA_CACHE.get("https://llota.app/api/public/references", headers=HTTP_HEADERS).json()
|
||||
if data:
|
||||
for ref in data:
|
||||
if ref["reference_code"] == ref_id:
|
||||
sig_ref.name = ref["name"]
|
||||
sig_ref.url = "https://llota.app/list/ref/" + ref_id
|
||||
sig_ref.grid = ref["grid_locator"]
|
||||
ll = locator_to_latlong(sig_ref.grid)
|
||||
sig_ref.latitude = ll[0]
|
||||
sig_ref.longitude = ll[1]
|
||||
break
|
||||
elif sig.upper() == "WWTOTA":
|
||||
if not sig_ref.name:
|
||||
sig_ref.name = sig_ref.id
|
||||
sig_ref.url = "https://wwtota.com/seznam/karta_rozhledny.php?ref=" + sig_ref.name
|
||||
elif sig.upper() == "WAB" or sig.upper() == "WAI":
|
||||
ll = wab_wai_square_to_lat_lon(ref_id)
|
||||
if ll:
|
||||
sig_ref.name = ref_id
|
||||
try:
|
||||
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 " + ref_id + ".")
|
||||
logging.debug("Invalid lat/lon received for reference")
|
||||
except:
|
||||
logging.warning("Failed to look up sig_ref info for " + sig + " ref " + ref_id + ".")
|
||||
return sig_ref
|
||||
|
||||
|
||||
|
||||
@@ -47,13 +47,13 @@ class StatusReporter:
|
||||
self.status_data["spot_providers"] = list(
|
||||
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||
"last_updated": p.last_update_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time else 0,
|
||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
|
||||
"last_spot": p.last_spot_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if p.last_spot_time else 0}, self.spot_providers))
|
||||
tzinfo=pytz.UTC).timestamp() if p.last_spot_time.year > 2000 else 0}, self.spot_providers))
|
||||
self.status_data["alert_providers"] = list(
|
||||
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||
"last_updated": p.last_update_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time else 0},
|
||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
||||
self.alert_providers))
|
||||
self.status_data["cleanup"] = {"status": self.cleanup_timer.status,
|
||||
"last_ran": self.cleanup_timer.last_cleanup_time.replace(
|
||||
|
||||
@@ -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, populate_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"...
|
||||
@@ -109,10 +105,6 @@ class Alert:
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
40
data/spot.py
40
data/spot.py
@@ -10,8 +10,9 @@ import pytz
|
||||
from pyhamtools.locator import locator_to_latlong, latlong_to_locator
|
||||
|
||||
from core.config import MAX_SPOT_AGE
|
||||
from core.constants import MODE_ALIASES
|
||||
from core.lookup_helper import lookup_helper
|
||||
from core.sig_utils import get_icon_for_sig, populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig
|
||||
from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig
|
||||
from data.sig_ref import SIGRef
|
||||
|
||||
|
||||
@@ -106,18 +107,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
|
||||
|
||||
@@ -214,8 +203,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:
|
||||
@@ -227,17 +214,16 @@ class Spot:
|
||||
self.mode = lookup_helper.infer_mode_from_frequency(self.freq)
|
||||
self.mode_source = "BANDPLAN"
|
||||
|
||||
# Normalise "generic digital" modes. "DIGITAL", "DIGI" and "DATA" are just the same thing with no extra
|
||||
# information, so standardise on "DATA"
|
||||
if self.mode == "DIGI" or self.mode == "DIGITAL":
|
||||
self.mode = "DATA"
|
||||
# Normalise mode if necessary.
|
||||
if self.mode in MODE_ALIASES:
|
||||
self.mode = MODE_ALIASES[self.mode]
|
||||
|
||||
# Mode type from mode
|
||||
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.
|
||||
@@ -296,19 +282,15 @@ 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)
|
||||
|
||||
# Default "radio" icon if nothing else has set it
|
||||
if not self.icon:
|
||||
self.icon = "tower-cell"
|
||||
|
||||
# DX Grid to lat/lon and vice versa in case one is missing
|
||||
if self.dx_grid and not self.dx_latitude:
|
||||
try:
|
||||
print(json.dumps(self))
|
||||
ll = locator_to_latlong(self.dx_grid)
|
||||
self.dx_latitude = ll[0]
|
||||
self.dx_longitude = ll[1]
|
||||
except:
|
||||
logging.debug("Invalid grid received for spot")
|
||||
if self.dx_latitude and self.dx_longitude and not self.dx_grid:
|
||||
try:
|
||||
self.dx_grid = latlong_to_locator(self.dx_latitude, self.dx_longitude, 8)
|
||||
|
||||
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
|
||||
|
@@ -53,6 +53,11 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
||||
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
|
||||
|
||||
@@ -34,8 +34,7 @@ class APIOptionsHandler(tornado.web.RequestHandler):
|
||||
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}
|
||||
"spot_allowed": ALLOW_SPOTTING}
|
||||
# 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:
|
||||
|
||||
@@ -54,6 +54,11 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
||||
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:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import tornado
|
||||
from prometheus_client.openmetrics.exposition import CONTENT_TYPE_LATEST
|
||||
from prometheus_client import CONTENT_TYPE_LATEST
|
||||
|
||||
from core.prometheus_metrics_handler import get_metrics
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ from datetime import datetime
|
||||
|
||||
import pytz
|
||||
import tornado
|
||||
import json
|
||||
|
||||
from core.config import ALLOW_SPOTTING
|
||||
from core.config import ALLOW_SPOTTING, WEB_UI_OPTIONS
|
||||
from core.constants import SOFTWARE_VERSION
|
||||
from core.prometheus_metrics_handler import page_requests_counter
|
||||
|
||||
@@ -22,5 +23,6 @@ class PageTemplateHandler(tornado.web.RequestHandler):
|
||||
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)
|
||||
self.render(self.template_name + ".html", software_version=SOFTWARE_VERSION, allow_spotting=ALLOW_SPOTTING,
|
||||
web_ui_options=json.dumps(WEB_UI_OPTIONS))
|
||||
|
||||
|
||||
@@ -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="tower-cell",
|
||||
time=spot_datetime.timestamp())
|
||||
|
||||
# Add to our list
|
||||
|
||||
41
spotproviders/llota.py
Normal file
41
spotproviders/llota.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from datetime import datetime
|
||||
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||
|
||||
|
||||
# Spot provider for Lagos y Lagunas On the Air
|
||||
class LLOTA(HTTPSpotProvider):
|
||||
POLL_INTERVAL_SEC = 120
|
||||
SPOTS_URL = "https://llota.app/api/public/spots"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
# Iterate through source data
|
||||
for source_spot in http_response.json():
|
||||
# Find the most recent spotter and comment from the history array
|
||||
comment = None
|
||||
spotter = None
|
||||
if "history" in source_spot and len(source_spot["history"]) > 0:
|
||||
comment = source_spot["history"][-1]["comment"]
|
||||
spotter = source_spot["history"][-1]["spotter_callsign"]
|
||||
# Convert to our spot format
|
||||
spot = Spot(source=self.name,
|
||||
source_id=source_spot["id"],
|
||||
dx_call=source_spot["callsign"].upper(),
|
||||
de_call=spotter.upper() if spotter else None,
|
||||
freq=float(source_spot["frequency"]) * 1000000,
|
||||
mode=source_spot["mode"].upper(),
|
||||
comment=comment,
|
||||
sig="LLOTA",
|
||||
sig_refs=[SIGRef(id=source_spot["reference"], sig="LLOTA", name=source_spot["reference_name"])],
|
||||
time=datetime.fromisoformat(source_spot["updated_at"].replace("Z", "+00:00")).timestamp())
|
||||
|
||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
||||
# that for us.
|
||||
new_spots.append(spot)
|
||||
return new_spots
|
||||
@@ -11,8 +11,6 @@ from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||
class POTA(HTTPSpotProvider):
|
||||
POLL_INTERVAL_SEC = 120
|
||||
SPOTS_URL = "https://api.pota.app/spot/activator"
|
||||
# Might need to look up extra park data
|
||||
PARK_URL_ROOT = "https://api.pota.app/park/"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
@@ -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"].replace("Z", "+00:00")).timestamp())
|
||||
|
||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
||||
# that for us.
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -30,7 +30,7 @@ class WWBOTA(SSESpotProvider):
|
||||
comment=source_spot["comment"],
|
||||
sig="WWBOTA",
|
||||
sig_refs=refs,
|
||||
time=datetime.fromisoformat(source_spot["time"]).timestamp(),
|
||||
time=datetime.fromisoformat(source_spot["time"].replace("Z", "+00:00")).timestamp(),
|
||||
# WWBOTA spots can contain multiple references for bunkers being activated simultaneously. For
|
||||
# now, we will just pick the first one to use as our grid, latitude and longitude.
|
||||
dx_grid=source_spot["references"][0]["locator"],
|
||||
|
||||
41
spotproviders/wwtota.py
Normal file
41
spotproviders/wwtota.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from datetime import datetime
|
||||
|
||||
import json
|
||||
import pytz
|
||||
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||
|
||||
|
||||
# Spot provider for Towers on the Air
|
||||
class WWTOTA(HTTPSpotProvider):
|
||||
POLL_INTERVAL_SEC = 120
|
||||
SPOTS_URL = "https://wwtota.com/api/cluster_live.php"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
response_fixed = http_response.text.replace("\\/", "/")
|
||||
response_json = json.loads(response_fixed)
|
||||
|
||||
# Iterate through source data
|
||||
for source_spot in response_json["spots"]:
|
||||
# Convert to our spot format
|
||||
likely_freq = float(source_spot["freq"]) * 1000
|
||||
if likely_freq < 1000000:
|
||||
likely_freq = likely_freq * 1000
|
||||
spot = Spot(source=self.name,
|
||||
dx_call=source_spot["call"].upper(),
|
||||
freq=likely_freq,
|
||||
comment=source_spot["comment"],
|
||||
sig="WWTOTA",
|
||||
sig_refs=[SIGRef(id=source_spot["ref"], sig="WWTOTA")],
|
||||
time=datetime.strptime(response_json["updated"][:10] + source_spot["time"], "%Y-%m-%d%H:%M").timestamp())
|
||||
|
||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
||||
# that for us.
|
||||
new_spots.append(spot)
|
||||
return new_spots
|
||||
@@ -1,4 +1,6 @@
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
@@ -9,31 +11,45 @@ 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.
|
||||
# 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):
|
||||
FIXED_LATITUDE = None
|
||||
FIXED_LONGITUDE = None
|
||||
LOCATION_DATA = {}
|
||||
SIG = None
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, provider_config["url"])
|
||||
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
|
||||
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
|
||||
|
||||
# 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"])],
|
||||
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=self.FIXED_LATITUDE,
|
||||
dx_longitude=self.FIXED_LONGITUDE,
|
||||
dx_latitude=lat,
|
||||
dx_longitude=lon,
|
||||
qrt=source_spot["state"] != "active")
|
||||
return spot
|
||||
|
||||
@@ -35,7 +35,7 @@ class ZLOTA(HTTPSpotProvider):
|
||||
comment=source_spot["comments"],
|
||||
sig="ZLOTA",
|
||||
sig_refs=[SIGRef(id=source_spot["reference"], sig="ZLOTA", name=source_spot["name"])],
|
||||
time=datetime.fromisoformat(source_spot["referenced_time"]).astimezone(pytz.UTC).timestamp())
|
||||
time=datetime.fromisoformat(source_spot["referenced_time"].replace("Z", "+00:00")).astimezone(pytz.UTC).timestamp())
|
||||
|
||||
new_spots.append(spot)
|
||||
return new_spots
|
||||
|
||||
@@ -25,10 +25,10 @@
|
||||
<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: <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 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>, <a href="https://llota.app">LLOTA</a>, <a href="https://wwtota.com">WWTOTA</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>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), Lagos y Lagunas On the Air (LLOTA), Towers on the Air (WWTOTA), 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>
|
||||
@@ -63,7 +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=2"></script>
|
||||
<script src="/js/common.js?v=7"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -69,8 +69,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=2"></script>
|
||||
<script src="/js/add-spot.js?v=2"></script>
|
||||
<script src="/js/common.js?v=7"></script>
|
||||
<script src="/js/add-spot.js?v=7"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -8,8 +8,8 @@
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<p class="d-inline-flex gap-1">
|
||||
<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>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,12 +112,14 @@
|
||||
<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">
|
||||
<label class="form-check-label" for="color-scheme">UI color scheme</label>
|
||||
<select id="color-scheme" class="storeable-select form-select d-inline-block" oninput="setColorSchemeFromUI();" style="display: inline-block;">
|
||||
<option value="auto">Automatic</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,8 +170,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=2"></script>
|
||||
<script src="/js/alerts.js?v=2"></script>
|
||||
<script src="/js/common.js?v=7"></script>
|
||||
<script src="/js/alerts.js?v=7"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -8,8 +8,8 @@
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<p class="d-inline-flex gap-1">
|
||||
<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>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,12 +112,19 @@
|
||||
<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">
|
||||
<label class="form-check-label" for="color-scheme">UI color scheme</label>
|
||||
<select id="color-scheme" class="storeable-select form-select d-inline-block" oninput="setColorSchemeFromUI();" style="display: inline-block;">
|
||||
<option value="auto">Automatic</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</p>
|
||||
<p class="card-text spothole-card-text">
|
||||
<label class="form-check-label" for="band-color-scheme">Band color scheme</label><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>
|
||||
@@ -129,9 +136,9 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=2"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=2"></script>
|
||||
<script src="/js/bands.js?v=2"></script>
|
||||
<script src="/js/common.js?v=7"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=7"></script>
|
||||
<script src="/js/bands.js?v=7"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -44,6 +44,16 @@
|
||||
<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=7"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=7"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=7"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=7"></script>
|
||||
<script>
|
||||
// Get Web UI Options from the backend to the frontend as a JS object.
|
||||
let web_ui_options = JSON.parse('{{ web_ui_options }}'.replace(/"/g, '\"'));
|
||||
</script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<div class="col-auto me-auto pt-3"></div>
|
||||
<div class="col-auto">
|
||||
<p class="d-inline-flex gap-1">
|
||||
<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>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,12 +124,19 @@
|
||||
<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">
|
||||
<label class="form-check-label" for="color-scheme">UI color scheme</label>
|
||||
<select id="color-scheme" class="storeable-select form-select d-inline-block" oninput="setColorSchemeFromUI();" style="display: inline-block;">
|
||||
<option value="auto">Automatic</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</p>
|
||||
<p class="card-text spothole-card-text">
|
||||
<label class="form-check-label" for="band-color-scheme">Band color scheme</label><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>
|
||||
@@ -147,9 +154,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?v=2"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=2"></script>
|
||||
<script src="/js/map.js?v=2"></script>
|
||||
<script src="/js/common.js?v=7"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=7"></script>
|
||||
<script src="/js/map.js?v=7"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -10,24 +10,25 @@
|
||||
|
||||
<div class="mt-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-lg-6 text-end">
|
||||
<div class="col-4">
|
||||
<p class="d-inline-flex gap-1">
|
||||
<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>
|
||||
<label class="btn btn-outline-primary" for="runButton"><i class="fa-solid fa-play"></i><span class="hideonmobile"> Run</span></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>
|
||||
<label class="btn btn-outline-primary" for="pauseButton"><i class="fa-solid fa-pause"></i><span class="hideonmobile"> Pause</span></label>
|
||||
</span>
|
||||
<span class="hideonmobile" style="position: relative;">
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-8 text-end">
|
||||
<p class="d-inline-flex gap-1">
|
||||
<span 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>
|
||||
<button id="filters-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i><span class="hideonmobile"> Filters</span></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><span class="hideonmobile"> Display</span></button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,12 +155,19 @@
|
||||
<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">
|
||||
<label class="form-check-label" for="color-scheme">UI color scheme</label>
|
||||
<select id="color-scheme" class="storeable-select form-select d-inline-block" oninput="setColorSchemeFromUI();" style="display: inline-block;">
|
||||
<option value="auto">Automatic</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</p>
|
||||
<p class="card-text spothole-card-text">
|
||||
<label class="form-check-label" for="band-color-scheme">Band color scheme</label><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>
|
||||
@@ -218,9 +226,9 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=2"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=2"></script>
|
||||
<script src="/js/spots.js?v=2"></script>
|
||||
<script src="/js/common.js?v=7"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=7"></script>
|
||||
<script src="/js/spots.js?v=7"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
<div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div>
|
||||
|
||||
<script src="/js/common.js?v=2"></script>
|
||||
<script src="/js/status.js?v=2"></script>
|
||||
<script src="/js/common.js?v=7"></script>
|
||||
<script src="/js/status.js?v=7"></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>
|
||||
@@ -1,4 +1,5 @@
|
||||
openapi: 3.0.4
|
||||
$schema: "https://spec.openapis.org/oas/3.1.0"
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Spothole API
|
||||
description: |-
|
||||
@@ -9,12 +10,20 @@ info:
|
||||
The API calls described below allow third-party software to access data from Spothole, and receive data on spots and alerts in a consistent format regardless of the data sources used by Spothole itself. Utility calls are also provided for general data lookups.
|
||||
|
||||
Please note that the data coming out of Spothole is only as good as the data going in. People mis-hear and make typos when spotting callsigns all the time, and there are plenty of areas where Spothole's location data may be inaccurate. If you are doing something where accuracy is important, such as contesting, you should not rely on Spothole's data to fill in any gaps in your log.
|
||||
|
||||
## Changelog
|
||||
|
||||
### 1.1
|
||||
|
||||
* Added Server-Sent Event API endpoints for spots and alerts.
|
||||
* Removed band colour and icon information from spots.
|
||||
* Moved activation_score from top-level in Spot and Alert to be part of the SIGRef
|
||||
contact:
|
||||
email: ian@ianrenton.com
|
||||
license:
|
||||
name: The Unlicense
|
||||
url: https://unlicense.org/#the-unlicense
|
||||
version: v1
|
||||
version: v1.1
|
||||
servers:
|
||||
- url: https://spothole.app/api/v1
|
||||
paths:
|
||||
@@ -549,6 +558,12 @@ paths:
|
||||
type: integer
|
||||
example: 30
|
||||
description: The suggested default "maximum spot age" that the web UI should retrieve from the API
|
||||
spot-providers-enabled-by-default:
|
||||
type: array
|
||||
description: A list of the spot providers that should be enabled in the web UI on first load, if the user hasn't already got a localStorage setting that sets their preference. This is to allow some high-volume providers like RBN to be enabled in Spothole's back-end and displayable in the web UI if the user wants, but by default the experience will not include them.
|
||||
items:
|
||||
type: string
|
||||
example: "POTA"
|
||||
alert-count:
|
||||
type: array
|
||||
description: An array of suggested "alert counts" that the web UI can retrieve from the API
|
||||
@@ -737,6 +752,8 @@ components:
|
||||
- ParksNPeaks
|
||||
- ZLOTA
|
||||
- WOTA
|
||||
- LLOTA
|
||||
- WWTOTA
|
||||
- Cluster
|
||||
- RBN
|
||||
- APRS-IS
|
||||
@@ -762,6 +779,8 @@ components:
|
||||
- IOTA
|
||||
- WOTA
|
||||
- BOTA
|
||||
- LLOTA
|
||||
- WWTOTA
|
||||
- WAB
|
||||
- WAI
|
||||
- TOTA
|
||||
@@ -786,6 +805,8 @@ components:
|
||||
- IOTA
|
||||
- WOTA
|
||||
- BOTA
|
||||
- LLOTA
|
||||
- WWTOTA
|
||||
- WAB
|
||||
- WAI
|
||||
- TOTA
|
||||
@@ -850,7 +871,6 @@ components:
|
||||
- DSTAR
|
||||
- C4FM
|
||||
- M17
|
||||
- DIGI
|
||||
- DATA
|
||||
- FT8
|
||||
- FT4
|
||||
@@ -858,12 +878,9 @@ components:
|
||||
- SSTV
|
||||
- JS8
|
||||
- HELL
|
||||
- BPSK
|
||||
- PSK
|
||||
- BPSK31
|
||||
- OLIVIA
|
||||
- MFSK
|
||||
- MFSK32
|
||||
- PSK
|
||||
- FSK
|
||||
- PKT
|
||||
- MSK144
|
||||
example: SSB
|
||||
@@ -934,6 +951,10 @@ components:
|
||||
type: number
|
||||
description: Longitude of the reference, in degrees, if known.
|
||||
example: -1.2345
|
||||
activation_score:
|
||||
type: integer
|
||||
description: Activation score. SOTA only
|
||||
example: 0
|
||||
|
||||
Spot:
|
||||
type: object
|
||||
@@ -1080,22 +1101,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/SIGRef'
|
||||
description: SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
||||
activation_score:
|
||||
type: integer
|
||||
description: Activation score. SOTA only
|
||||
example: 0
|
||||
icon:
|
||||
type: string
|
||||
descripton: 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.
|
||||
example: tree
|
||||
band_color:
|
||||
type: string
|
||||
descripton: 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.
|
||||
example: "#ff0000"
|
||||
band_contrast_color:
|
||||
type: string
|
||||
descripton: Black or white, whichever best contrasts with "band_color".
|
||||
example: "white"
|
||||
qrt:
|
||||
type: boolean
|
||||
description: QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
|
||||
@@ -1200,14 +1205,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/SIGRef'
|
||||
description: SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
||||
activation_score:
|
||||
type: integer
|
||||
description: Activation score. SOTA only
|
||||
example: 0
|
||||
icon:
|
||||
type: string
|
||||
descripton: 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.
|
||||
example: tree
|
||||
source:
|
||||
type: string
|
||||
description: Where we got the alert from.
|
||||
@@ -1243,11 +1240,11 @@ components:
|
||||
example: OK
|
||||
last_updated:
|
||||
type: number
|
||||
description: The last time at which this provider received data, UTC seconds since UNIX epoch.
|
||||
description: The last time at which this provider received data, UTC seconds since UNIX epoch. If this is zero, the spot provider has never updated.
|
||||
example: 1759579508
|
||||
last_spot:
|
||||
type: number
|
||||
description: The time of the latest spot received by this provider, UTC seconds since UNIX epoch.
|
||||
description: The time of the latest spot received by this provider, UTC seconds since UNIX epoch. If this is zero, the spot provider has never received a spot that was accepted by the system.
|
||||
example: 1759579508
|
||||
|
||||
AlertProviderStatus:
|
||||
@@ -1266,7 +1263,7 @@ components:
|
||||
example: OK
|
||||
last_updated:
|
||||
type: number
|
||||
description: The last time at which this provider received data, UTC seconds since UNIX epoch.
|
||||
description: The last time at which this provider received data, UTC seconds since UNIX epoch. If this is zero, the alert provider has never updated.
|
||||
example: 1759579508
|
||||
|
||||
Band:
|
||||
@@ -1283,14 +1280,6 @@ components:
|
||||
type: int
|
||||
description: The end frequency of this band, in Hz.
|
||||
example: 7200000
|
||||
color:
|
||||
type: string
|
||||
description: The color associated with this mode, as used on PSK Reporter.
|
||||
example: "#5959ff"
|
||||
contrast_color:
|
||||
type: string
|
||||
description: Black or white, whichever provides the best contrast against the band colour.
|
||||
example: white
|
||||
|
||||
SIG:
|
||||
type: object
|
||||
@@ -1302,10 +1291,6 @@ components:
|
||||
type: string
|
||||
description: The full name of the SIG
|
||||
example: Parks on the Air
|
||||
icon:
|
||||
type: string
|
||||
description: 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.
|
||||
example: tree
|
||||
ref_regex:
|
||||
type: string
|
||||
description: Regex that matches this SIG's reference IDs. Generally for Spothole's own internal use, clients probably won't need this.
|
||||
|
||||
@@ -224,6 +224,10 @@ div#map {
|
||||
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 */
|
||||
@@ -345,6 +349,9 @@ div.band-spot:hover span.band-spot-info {
|
||||
max-height: 26em;
|
||||
overflow: scroll;
|
||||
}
|
||||
input#search {
|
||||
max-width: 7em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
|
||||
@@ -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} `);
|
||||
@@ -286,11 +286,11 @@ function loadOptions() {
|
||||
generateMultiToggleFilterCard("#source-options", "source", options["alert_sources"]);
|
||||
|
||||
// Populate the Display panel
|
||||
options["web-ui-options"]["alert-count"].forEach(sc => $("#alerts-to-fetch").append($('<option>', {
|
||||
web_ui_options["alert-count"].forEach(sc => $("#alerts-to-fetch").append($('<option>', {
|
||||
value: sc,
|
||||
text: sc
|
||||
})));
|
||||
$("#alerts-to-fetch").val(options["web-ui-options"]["alert-count-default"]);
|
||||
$("#alerts-to-fetch").val(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
|
||||
@@ -312,12 +312,6 @@ 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
|
||||
@@ -352,3 +346,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();
|
||||
}
|
||||
});
|
||||
@@ -26,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", "sig"].forEach(fn => {
|
||||
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
|
||||
if (!allFilterOptionsSelected(fn)) {
|
||||
str = str + getQueryStringFor(fn) + "&";
|
||||
}
|
||||
@@ -70,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) {
|
||||
@@ -145,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
|
||||
@@ -167,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();
|
||||
@@ -228,6 +228,22 @@ function loadOptions() {
|
||||
// Store options
|
||||
options = jsonData;
|
||||
|
||||
// Populate the Display panel
|
||||
web_ui_options["max-spot-age"].forEach(sc => $("#max-spot-age").append($('<option>', {
|
||||
value: sc * 60,
|
||||
text: sc
|
||||
})));
|
||||
$("#max-spot-age").val(web_ui_options["max-spot-age-default"] * 60);
|
||||
getAvailableBandColorSchemes().forEach(sc => $("#band-color-scheme").append($('<option>', {
|
||||
value: sc,
|
||||
text: sc
|
||||
})));
|
||||
$("#band-color-scheme").val(web_ui_options["default-band-color-scheme"]);
|
||||
|
||||
// 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"]);
|
||||
|
||||
@@ -236,15 +252,8 @@ function loadOptions() {
|
||||
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"]);
|
||||
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);
|
||||
generateModesMultiToggleFilterCard(options["modes"]);
|
||||
generateSourcesMultiToggleFilterCard(options["spot_sources"], 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
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
var options = {};
|
||||
// Last time we updated the spots/alerts list on display.
|
||||
var lastUpdateTime;
|
||||
// Whether "embedded mode" is being used. This removes headers and footers, maximises the remaining content, and
|
||||
// uses URL params to configure the interface options rather than using the user's localstorage.
|
||||
var embeddedMode = false;
|
||||
|
||||
// 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
|
||||
@@ -18,20 +15,20 @@ function loadURLParams() {
|
||||
// top-level html element to use CSS selectors to remove bits of UI.
|
||||
let embedded = params.get("embedded");
|
||||
if (embedded != null && embedded === "true") {
|
||||
embeddedMode = 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, "color-scheme", "color-scheme");
|
||||
updateSelectFromParam(params, "time-zone", "time-zone"); // 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, "mode", "mode");
|
||||
updateFilterFromParam(params, "dx_continent", "dx_continent");
|
||||
updateFilterFromParam(params, "de_continent", "de_continent");
|
||||
}
|
||||
@@ -41,10 +38,6 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +46,10 @@ function updateSelectFromParam(params, paramName, selectID) {
|
||||
let v = params.get(paramName);
|
||||
if (v != null) {
|
||||
$("#" + selectID).prop("value", v);
|
||||
// Extra check if this is the "color scheme" select
|
||||
if (selectID == "color-scheme") {
|
||||
setColorScheme(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,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();
|
||||
@@ -166,161 +142,50 @@ 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 the colour scheme based on the state of the UI select box
|
||||
function setColorSchemeFromUI() {
|
||||
setColorScheme($("#color-scheme option:selected").val());
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
// 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];
|
||||
} else {
|
||||
return null;
|
||||
// Function to set the color scheme. Supported values: "dark", "light", "auto"
|
||||
function setColorScheme(mode) {
|
||||
let effectiveModeDark = mode == "dark";
|
||||
if (mode == "auto") {
|
||||
effectiveModeDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
}
|
||||
|
||||
// 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];
|
||||
}
|
||||
|
||||
// Function to set dark mode on or off
|
||||
function enableDarkMode(dark) {
|
||||
$("html").attr("data-bs-theme", dark ? "dark" : "light");
|
||||
$("html").attr("data-bs-theme", effectiveModeDark ? "dark" : "light");
|
||||
const metaThemeColor = document.querySelector("meta[name=theme-color]");
|
||||
metaThemeColor.setAttribute("content", dark ? "black" : "white");
|
||||
metaThemeColor.setAttribute("content", effectiveModeDark ? "black" : "white");
|
||||
const metaAppleStatusBarStyle = document.querySelector("meta[name=apple-mobile-web-app-status-bar-style]");
|
||||
metaAppleStatusBarStyle.setAttribute("content", dark ? "black-translucent" : "white-translucent");
|
||||
metaAppleStatusBarStyle.setAttribute("content", effectiveModeDark ? "black-translucent" : "white-translucent");
|
||||
}
|
||||
|
||||
// Startup function to determine whether to use light or dark mode
|
||||
// Startup function to determine whether to use light or dark mode, or leave as auto
|
||||
function usePreferredTheme() {
|
||||
// First, work out if we have ever explicitly saved the value of our toggle
|
||||
let val = localStorage.getItem("#darkMode:checked");
|
||||
// Set the value of the select box to the server's default
|
||||
$("#color-scheme").val(web_ui_options["default-color-scheme"]);
|
||||
|
||||
// Work out if we have ever explicitly saved the value of our select box. If so, we set our colour scheme now based
|
||||
// on that. If not, we let the select retain its default value from Spothole config and apply that.
|
||||
let val = localStorage.getItem("#color-scheme:value");
|
||||
if (val != null) {
|
||||
enableDarkMode(JSON.parse(val));
|
||||
setColorScheme(JSON.parse(val));
|
||||
} else {
|
||||
// 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);
|
||||
setColorSchemeFromUI();
|
||||
}
|
||||
}
|
||||
|
||||
// Save settings to local storage. Suppressed if "embedded mode" is in use.
|
||||
function saveSettings() {
|
||||
if (!embeddedMode) {
|
||||
// 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));
|
||||
// Sets up a listener on the OS light-dark theme change. If the Spothole user theme is set to Auto, the UI will be
|
||||
// updated, otherwise if the Spothole user theme is forced to light or dark, that preference will remain.
|
||||
function listenForOSThemeChange() {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
setColorScheme($("#color-scheme option:selected").val());
|
||||
});
|
||||
$(".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. Suppressed if "embedded mode" is in use.
|
||||
function loadSettings() {
|
||||
if (!embeddedMode) {
|
||||
// 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)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Startup
|
||||
$(document).ready(function() {
|
||||
usePreferredTheme();
|
||||
listenForOSThemeChange();
|
||||
});
|
||||
|
||||
@@ -20,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", "sig"].forEach(fn => {
|
||||
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
|
||||
if (!allFilterOptionsSelected(fn)) {
|
||||
str = str + getQueryStringFor(fn) + "&";
|
||||
}
|
||||
@@ -45,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
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -58,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
|
||||
@@ -136,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()}`;
|
||||
@@ -156,6 +160,22 @@ function loadOptions() {
|
||||
// Store options
|
||||
options = jsonData;
|
||||
|
||||
// Populate the Display panel
|
||||
web_ui_options["max-spot-age"].forEach(sc => $("#max-spot-age").append($('<option>', {
|
||||
value: sc * 60,
|
||||
text: sc
|
||||
})));
|
||||
$("#max-spot-age").val(web_ui_options["max-spot-age-default"] * 60);
|
||||
getAvailableBandColorSchemes().forEach(sc => $("#band-color-scheme").append($('<option>', {
|
||||
value: sc,
|
||||
text: sc
|
||||
})));
|
||||
$("#band-color-scheme").val(web_ui_options["default-band-color-scheme"]);
|
||||
|
||||
// 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"]);
|
||||
|
||||
@@ -164,15 +184,8 @@ function loadOptions() {
|
||||
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"]);
|
||||
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);
|
||||
generateModesMultiToggleFilterCard(options["modes"]);
|
||||
generateSourcesMultiToggleFilterCard(options["spot_sources"], 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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SSE event source
|
||||
let evtSource;
|
||||
let restartSSEOnErrorTimeoutId;
|
||||
// Table row count, to alternate shading
|
||||
let rowCount = 0;
|
||||
|
||||
@@ -12,9 +13,6 @@ function loadSpots() {
|
||||
|
||||
// Make the new query
|
||||
$.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) {
|
||||
// Store last updated time
|
||||
lastUpdateTime = moment.utc();
|
||||
updateTimingDisplayRunPause();
|
||||
// Store data
|
||||
spots = jsonData;
|
||||
// Update table
|
||||
@@ -30,12 +28,12 @@ function loadSpots() {
|
||||
// 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
|
||||
@@ -66,21 +64,18 @@ function startSSEConnection() {
|
||||
};
|
||||
|
||||
evtSource.onerror = function(err) {
|
||||
if (evtSource != null) {
|
||||
evtSource.close();
|
||||
setTimeout(startSSEConnection, 1000);
|
||||
}
|
||||
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", "sig"].forEach(fn => {
|
||||
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
|
||||
if (!allFilterOptionsSelected(fn)) {
|
||||
str = str + getQueryStringFor(fn) + "&";
|
||||
}
|
||||
@@ -147,8 +142,8 @@ function updateTable() {
|
||||
table.find('tbody').append('<tr class="table-danger"><td colspan="100" style="text-align:center;">No spots match your filters.</td></tr>');
|
||||
}
|
||||
|
||||
spots.reverse();
|
||||
spots.forEach(s => addSpotToTopOfTable(s, false));
|
||||
let spotsNewestFirst = spots.toReversed();
|
||||
spotsNewestFirst.forEach(s => addSpotToTopOfTable(s, false));
|
||||
}
|
||||
|
||||
// Add rows corresponding to a new spot to the top of the table
|
||||
@@ -182,9 +177,6 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
||||
|
||||
// Create row
|
||||
let $tr = $('<tr>');
|
||||
if (highlightNew) {
|
||||
$tr.addClass("new");
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -198,6 +190,12 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
||||
$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) {
|
||||
@@ -277,9 +275,9 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
||||
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(", ");
|
||||
@@ -315,10 +313,10 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
||||
$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>`);
|
||||
@@ -330,10 +328,10 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
||||
$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>`);
|
||||
@@ -341,24 +339,38 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
||||
|
||||
// Second row for mobile view only, containing type, ref & comment
|
||||
$tr2 = $("<tr class='hidenotonmobile'>");
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -374,6 +386,23 @@ function loadOptions() {
|
||||
// Store options
|
||||
options = jsonData;
|
||||
|
||||
// Populate the Display panel
|
||||
web_ui_options["spot-count"].forEach(sc => $("#spots-to-fetch").append($('<option>', {
|
||||
value: sc,
|
||||
text: sc,
|
||||
selected: sc == web_ui_options["spot-count-default"] // todo remove this?
|
||||
})));
|
||||
$("#spots-to-fetch").val(web_ui_options["spot-count-default"]); // todo setting val doesn't update UI?
|
||||
getAvailableBandColorSchemes().forEach(sc => $("#band-color-scheme").append($('<option>', {
|
||||
value: sc,
|
||||
text: sc
|
||||
})));
|
||||
$("#band-color-scheme").val(web_ui_options["default-band-color-scheme"]);
|
||||
|
||||
// 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"]);
|
||||
|
||||
@@ -382,15 +411,8 @@ function loadOptions() {
|
||||
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"]);
|
||||
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"]);
|
||||
generateModesMultiToggleFilterCard(options["modes"]);
|
||||
generateSourcesMultiToggleFilterCard(options["spot_sources"], 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
|
||||
@@ -483,13 +505,11 @@ $(document).ready(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();
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,9 @@ var spots = []
|
||||
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']};}`);
|
||||
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);
|
||||
}
|
||||
@@ -18,20 +17,29 @@ function addBandToggleColourCSS(band_options) {
|
||||
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> `);
|
||||
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 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>`);
|
||||
// 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 => {
|
||||
$("#sig-options").append(`<input type="checkbox" class="btn-check filter-button-sig storeable-checkbox" name="options" id="filter-button-sig-${o['name']}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-primary" id="filter-button-label-sig-${o['name']}" for="filter-button-sig-${o['name']}" title="${o['description']}"><i class="fa-solid fa-${o['icon']}"></i> ${o['name']}</label> `);
|
||||
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> `);
|
||||
@@ -39,14 +47,81 @@ function generateSIGsMultiToggleFilterCard(sig_options) {
|
||||
$("#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 modes filter card. This one is also a special case.
|
||||
function generateModesMultiToggleFilterCard(mode_options) {
|
||||
// Create a button for each option
|
||||
mode_options.forEach(o => {
|
||||
var domSafeName = o.replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||
$("#mode-options").append(`<input type="checkbox" class="btn-check filter-button-mode storeable-checkbox" name="options" id="filter-button-mode-${domSafeName}" value="${o}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-primary" id="filter-button-label-mode-${domSafeName}" for="filter-button-mode-${domSafeName}">${o}</label> `);
|
||||
});
|
||||
// Create All/None buttons
|
||||
$("#mode-options").append(` <span style="display: inline-block"><button id="filter-button-mode-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('mode', true);">All</button> <button id="filter-button-mode-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('mode', false);">None</button></span>`);
|
||||
// Create category buttons
|
||||
$("#mode-options").append(` <button id="filter-button-mode-av" type="button" class="btn btn-outline-secondary" onclick="toggleAnalogVoiceModeToggles();">Analog Voice</button> <button id="filter-button-mode-dv" type="button" class="btn btn-outline-secondary" onclick="toggleDigitalVoiceModeToggles();">Digital Voice</button> <button id="filter-button-mode-digi" type="button" class="btn btn-outline-secondary" onclick="toggleDigiModeToggles();">Digimodes</button></span>`);
|
||||
}
|
||||
|
||||
// Toggle the mode toggles that relate to Analog Voice.
|
||||
function toggleAnalogVoiceModeToggles() {
|
||||
toggleToggles("mode", ["PHONE", "SSB", "LSB", "USB", "AM", "FM"]);
|
||||
}
|
||||
|
||||
// Toggle the mode toggles that relate to Digital Voice.
|
||||
function toggleDigitalVoiceModeToggles() {
|
||||
toggleToggles("mode", ["DV", "DMR", "DSTAR", "C4FM", "M17"]);
|
||||
}
|
||||
|
||||
// Toggle the mode toggles that relate to Digimodes.
|
||||
function toggleDigiModeToggles() {
|
||||
toggleToggles("mode", ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"]);
|
||||
}
|
||||
|
||||
// Toggle the a set of toggles of the given type (e.g. "mode") that match the given values (e.g. ["SSB", "AM", "FM"]).
|
||||
function toggleToggles(type, values) {
|
||||
let toggle = null;
|
||||
$(".filter-button-" + type).each(function() {
|
||||
console.log($(this));
|
||||
if (values.includes($(this).val().replace("filter-button-" + type, ""))) {
|
||||
if (toggle == null) {
|
||||
toggle = !$(this).prop('checked');
|
||||
}
|
||||
$(this).prop('checked', toggle);
|
||||
}
|
||||
});
|
||||
filtersUpdated();
|
||||
}
|
||||
|
||||
// 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);
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
@@ -22,14 +22,14 @@ function loadStatus() {
|
||||
jsonData["spot_providers"].forEach(p => {
|
||||
$("#status-container").append(generateStatusCard("Spot Provider: " + p["name"], [
|
||||
`Status: ${p["status"]}`,
|
||||
`Last Updated: ${p["enabled"] ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}`,
|
||||
`Latest Spot: ${p["enabled"] ? moment.unix(p["last_spot"]).utc().fromNow() : "N/A"}`
|
||||
`Last Updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}`,
|
||||
`Latest Spot: ${(p["enabled"] && p["last_spot"] > 0) ? moment.unix(p["last_spot"]).utc().fromNow() : "N/A"}`
|
||||
]));
|
||||
});
|
||||
jsonData["alert_providers"].forEach(p => {
|
||||
$("#status-container").append(generateStatusCard("Alert Provider: " + p["name"], [
|
||||
`Status: ${p["status"]}`,
|
||||
`Last Updated: ${p["enabled"] ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}`
|
||||
`Last Updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}`
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user