170 Commits

Author SHA1 Message Date
Ian Renton
33bdcca990 Proper fix for BOTA alerts 2026-01-18 12:47:34 +00:00
Ian Renton
261912b6e1 Release 1.2 2026-01-18 12:22:03 +00:00
Ian Renton
0babf0a6be Support LLOTA #98 2026-01-18 12:10:16 +00:00
Ian Renton
65957b4c01 Fix a bug where the "last updated time"/"last spot time" of providers that have never updated would be sent as a large negative number and represented on the web UI as e.g. "2026 years ago". 2026-01-18 07:52:06 +00:00
Ian Renton
522f90af97 Fix a bug where some WWFF references had "-" for lat/lon/grid and Spothole did not deal with them well. 2026-01-18 07:40:51 +00:00
Ian Renton
4d344021c7 Allow filtering based on mode, not just mode type. #96 2026-01-17 09:03:27 +00:00
Ian Renton
abdf8d3065 Fix a bug where an exception would be shown when parsing the BOTA page if there were no upcoming activations. 2026-01-13 21:38:58 +00:00
Ian Renton
67b9c3bc50 Bring back the search box on the mobile spots list, I want this for WFD 2026-01-13 21:34:54 +00:00
Ian Renton
9b3536d740 Ensure "RTT" as a mode is understood as "RTTY" and similar. 2026-01-12 20:33:33 +00:00
Ian Renton
897901e105 Replace "Z" in ISO timestamps with "+00:00" for backwards compatibility with older versions of Python 2026-01-12 19:30:19 +00:00
Ian Renton
059d9364eb Project version bump 2026-01-11 15:35:39 +00:00
Ian Renton
a3ca590ca3 JS import version bump 2026-01-11 15:14:34 +00:00
Ian Renton
cfff8dd832 Allow providers to be off-by-default in the web UI. Closes #93 2026-01-11 15:03:17 +00:00
Ian Renton
d1a5bfe9c3 Make allowing RBN spots via cluster a configurable option. 2026-01-11 12:09:36 +00:00
Ian Renton
da2827f559 Improve backwards compatibility by allowing login_callsign (and login_prompt) to be missing in DX cluster provider config. 2026-01-11 08:37:05 +00:00
Ian Renton
220c9378cf Log into clusters with a custom callsign/SSID 2026-01-10 10:06:48 +00:00
Ian Renton
e1cdc5b857 Move activation_score into SIGRef. Closes #91 2026-01-02 09:51:03 +00:00
Ian Renton
5482da0e69 Fix a bug where supplying a grid reference when adding a spot to the API resulted in dx_location_source=NONE in the spot object. Closes #90 2026-01-02 09:37:57 +00:00
Ian Renton
f31148686d Change 2.4GHz to 13cm to match Field Spotter naming convention #88 2026-01-01 17:33:46 +00:00
Ian Renton
a444be8fe9 Extract common JS into a new project #88 2026-01-01 17:07:59 +00:00
Ian Renton
3f117a47d6 JS faff #88 2025-12-31 10:14:52 +00:00
Ian Renton
06d582ae2d Separate colours and icons out of the Spothole API and re-implement them in the client; provide new colour schemes. #88 2025-12-30 19:08:27 +00:00
Ian Renton
5bf45dba46 Ham HF band toggle preset and prevent some multiple-SSE shenanigans when searching and typing letters quickly 2025-12-30 14:51:49 +00:00
Ian Renton
f4ae6b610e Fix spot table reversing bug and add "de" callsign to mobile view 2025-12-30 09:06:43 +00:00
Ian Renton
6af15e4cfd Reload spots/alerts on visibility change. Closes #89 2025-12-27 15:57:38 +00:00
Ian Renton
6d9bf3d4ec Update docs 2025-12-26 22:14:22 +00:00
Ian Renton
9b737a8176 39C3 TOTA location lookup 2025-12-26 09:14:49 +00:00
Ian Renton
05bc65337f Fix a bug in the mobile view where the second line doesn't get painted green for SSE new spots. Closes #87 2025-12-24 11:16:03 +00:00
Ian Renton
d2c1dbb377 Fix a bug in the mobile view where the second line doesn't get painted green for SSE new spots. Closes #87 2025-12-24 11:14:03 +00:00
Ian Renton
6cf1b38355 Fix metrics content type? 2025-12-24 10:10:46 +00:00
Ian Renton
ac566553d8 nginx config #3 2025-12-24 09:47:26 +00:00
Ian Renton
bcc40d1416 SSE custom headers #3 2025-12-24 09:44:55 +00:00
Ian Renton
2fead92dc5 SSE updates every 5 seconds is probably fine, we don't really need every second. #3 2025-12-24 08:57:38 +00:00
Ian Renton
e8ca488001 Run/Pause button #3 2025-12-24 08:53:44 +00:00
Ian Renton
61fc0b9d0f Starting to implement Run/Pause switch #3 2025-12-23 22:52:21 +00:00
Ian Renton
70dc1b495c Fix SSE connections not respecting filters #3 2025-12-23 22:24:30 +00:00
Ian Renton
7fe478e040 Minor tweak #3 2025-12-23 21:58:32 +00:00
Ian Renton
926cf5caaf Fix handling new spots by SSE when there weren't any others #3 2025-12-23 21:58:25 +00:00
Ian Renton
ae1caaa40f Fix handling new spots by SSE when there weren't the max number already #3 2025-12-23 21:45:17 +00:00
Ian Renton
6116d19580 Fix issue with SSE queues getting lost #3 2025-12-23 21:26:39 +00:00
Ian Renton
86beb27ebf Implement SSE endpoints in Tornado #3 2025-12-23 21:01:41 +00:00
Ian Renton
d463403018 Implement web server metrics in Tornado #3 2025-12-23 14:23:50 +00:00
Ian Renton
23a6e08777 Implement more request handlers in Tornado #3 2025-12-23 14:05:28 +00:00
Ian Renton
61784e8af6 Split up some code for sanity #3 2025-12-23 11:51:00 +00:00
Ian Renton
fd246fc17b Partial reimplementation of the web server using Tornado #3 2025-12-23 11:03:01 +00:00
Ian Renton
fb935138a1 Improvements to spot timing display #3 2025-12-22 20:44:50 +00:00
Ian Renton
1f66da062b Style improvements and fixes #3 2025-12-22 16:35:28 +00:00
Ian Renton
70a7bd4814 Use SSE frontend #3 2025-12-22 15:47:45 +00:00
Ian Renton
fd2986f310 First attempt at SSE backend #3 2025-12-22 13:02:11 +00:00
Ian Renton
befaceb2f5 First attempt at SSE backend #3 2025-12-22 12:20:32 +00:00
Ian Renton
81da836bae First attempt at SSE backend #3 2025-12-22 12:11:25 +00:00
Ian Renton
c95c6bb347 First attempt at SSE backend #3 2025-12-22 12:04:35 +00:00
Ian Renton
968576f74c Re-implement xOTA using Websocket client 2025-12-22 09:35:40 +00:00
Ian Renton
2a5e8301af Reject lat/longs within 0.1deg latitude of the poles. These are almost always a result of someone having a default grid of "AA00AA" set. Apologies to any hams at the Amundsen-Scott research station. 2025-12-17 10:16:50 +00:00
Ian Renton
040ef3ec00 Reject lat/longs within 0.1deg latitude of the poles. These are almost always a result of someone having a default grid of "AA00AA" set. Apologies to any hams at the Amundsen-Scott research station. 2025-12-17 10:07:35 +00:00
Ian Renton
ac9e2ff054 Defensive coding 2025-12-15 12:26:41 +00:00
Ian Renton
6eaaca3a6f Up poll interval because TOTA activations are quick 2025-12-15 12:22:39 +00:00
Ian Renton
097c75eadd Improve SIG Ref lookup 2025-12-15 12:13:41 +00:00
Ian Renton
27db248398 39C3 TOTA URL 2025-12-15 12:13:32 +00:00
Ian Renton
b00b4130c5 PWA style tweaks 2025-11-30 18:33:49 +00:00
Ian Renton
b3be6b5ca4 Hacky attempt to force browsers to invalidate caches of JS files 2025-11-30 17:55:35 +00:00
Ian Renton
210a0564aa Enable embedded-mode support for filters 2025-11-30 16:46:19 +00:00
Ian Renton
03af6858b4 Tiny footer in embedded mode 2025-11-30 15:13:53 +00:00
Ian Renton
e86d6b8c28 Improve handling of buggy WOTA spot 2025-11-30 11:30:15 +00:00
Ian Renton
9d130712d8 Add URL params parsing and concept of "embedded mode" 2025-11-30 11:26:15 +00:00
Ian Renton
8a82f81ec4 Dark mode 2025-11-30 09:31:37 +00:00
Ian Renton
ca31d23b4a Defensive coding 2025-11-29 16:15:49 +00:00
Ian Renton
8a4f23ac72 Improve expired spot handling and efficiency of handling expired spots during web requests. 2025-11-29 16:12:44 +00:00
Ian Renton
3da8c80ad6 Defensive coding 2025-11-29 15:50:55 +00:00
Ian Renton
0fa8b44c9c Defensive coding 2025-11-29 15:04:19 +00:00
Ian Renton
4aa7b91092 Fix a bug where a spot with no DX lat/lon could still be marked as having "good location" 2025-11-29 15:01:05 +00:00
Ian Renton
e7469db99e README updates 2025-11-29 11:58:41 +00:00
Ian Renton
9d9f4609f0 Doc tweaks 2025-11-26 22:12:20 +00:00
Ian Renton
368e69bf00 Use tower-cell icon for cluster/unknown spots rather than the desktop icon 2025-11-26 21:49:11 +00:00
Ian Renton
9bdd0ab1de Add filtering based on SIG to the web UI. #84 2025-11-26 21:43:10 +00:00
Ian Renton
255719f3b5 Add a special 'NO_SIG' option to 'sig' query params, which will allow us to filter out all xOTA spots/alerts, leaving just the generic ones. #84 2025-11-26 21:13:14 +00:00
Ian Renton
f21ea0ae5d Remove duplicated enums in spec #83 2025-11-26 20:29:35 +00:00
Ian Renton
2be2af176c Merge branch '82-tota'
# Conflicts:
#	webassets/apidocs/openapi.yml
2025-11-26 20:29:05 +00:00
Ian Renton
583735c99f Remove start/end dates #82 2025-11-26 07:40:46 +00:00
Ian Renton
0c8973bbc6 Remove duplicated enums in spec #83 2025-11-25 22:03:09 +00:00
Ian Renton
296cdb3795 Wider ranges to detect FT8/FT4 in "Guess mode based on frequency" function #85 2025-11-25 21:32:48 +00:00
Ian Renton
6c9f3136b8 First pass at TOTA support #82 2025-11-24 21:57:29 +00:00
Ian Renton
4e427f26c3 About page updates 2025-11-23 11:23:13 +00:00
ian
714151a6b4 Update views/webpage_about.tpl 2025-11-23 10:58:26 +00:00
Ian Renton
0ccc2bd15d Minor tweaks 2025-11-17 17:58:52 +00:00
Ian Renton
5724c4c7ea Minor tweaks 2025-11-17 17:50:29 +00:00
Ian Renton
94c0cad769 Improve SIG regexes to specify numbers of digits 2025-11-17 17:41:01 +00:00
Ian Renton
452e4beb29 Fix imports 2025-11-17 17:22:12 +00:00
Ian Renton
b132fe8a39 Fix a bug where SIG API spots could be re-tagged as another SIG e.g. WAB if that was named in the comment. 2025-11-17 17:19:43 +00:00
Ian Renton
e525aaed92 Fix a bug where spothole was too keen on extracting secondary references for xOTA programmes from comments, and was not checking that the "references" it found were surrounded by whitespace. 2025-11-16 17:46:40 +00:00
Ian Renton
92b7110356 Merge remote-tracking branch 'origin/main' 2025-11-16 17:46:05 +00:00
Ian Renton
114eacb9dc Fix a bug where spothole was too keen on extracting secondary references for xOTA programmes from comments, and was not checking that the "references" it found were surrounded by whitespace. 2025-11-16 17:45:58 +00:00
Ian Renton
2a90b17b6b Fix URLs for WOTA outlying fells 2025-11-14 14:37:36 +00:00
Ian Renton
ae075f3ac7 Version number bump 2025-11-13 21:52:13 +00:00
Ian Renton
efa9806c64 Look up K0SWE's dxcc.json rather than using our own tables. Closes #80 2025-11-13 21:51:20 +00:00
Ian Renton
03829831c0 Fix debug code commit 2025-11-13 21:47:05 +00:00
Ian Renton
4f83468309 Add config for "Number of Spots" and "Spot Age" values used in the web UI. Closes #79 2025-11-13 21:18:27 +00:00
Ian Renton
2165ebc103 DXCC 999 2025-11-13 20:10:53 +00:00
Ian Renton
cf46017917 Fix WOTA parsing bug 2025-11-12 17:40:24 +00:00
Ian Renton
c30e1616d3 Image-based flags 2025-11-11 06:30:17 +00:00
Ian Renton
422c917073 Docs tweak 2025-11-10 19:30:40 +00:00
Ian Renton
cad1f5cfdf Defensive coding fix 2025-11-10 19:03:12 +00:00
Ian Renton
78f8cd26f0 Possible emoji flag fix for Windows/Chrome 2025-11-10 19:01:25 +00:00
Ian Renton
d6cc2673dd Search input should have search type 2025-11-08 18:44:37 +00:00
Ian Renton
8f553a59f8 Doc tweaks 2025-11-08 18:23:11 +00:00
Ian Renton
f1841ca59e v1.0 release 2025-11-08 11:44:11 +00:00
Ian Renton
85e0a7354c Reject "AA00aa" grids and 0/0 latlons from online lookup 2025-11-03 20:14:41 +00:00
Ian Renton
2ccfa28119 Get "qth" friendly name from QRZ/clublog and return in the callsign lookup. Closes #77 2025-11-02 20:51:16 +00:00
Ian Renton
b313735e28 Add missing break statements 2025-11-02 20:38:30 +00:00
Ian Renton
bbaa3597f6 Implement WWFF reference lookup. Closes #76 2025-11-02 20:37:30 +00:00
Ian Renton
e61d7bedb4 Exception handling #74 2025-11-02 18:00:24 +00:00
Ian Renton
ebf07f352f Exception handling #74 2025-11-02 17:59:37 +00:00
Ian Renton
e83ddead62 Tidy up some issues with alerts #74 2025-11-02 17:54:34 +00:00
Ian Renton
b8e1506846 Use HamQTH as a lookup provider. Closes #73 2025-11-02 17:45:54 +00:00
Ian Renton
d80c4cfbeb Provide an externally usable callsign lookup feature. #73 2025-11-02 16:52:27 +00:00
Ian Renton
92af0761aa Move checks for multiple references in comments out of POTA and DX Cluster classes into the main infer_missing() function for spots. #54 2025-11-02 16:18:33 +00:00
Ian Renton
286ff66721 Refactor looking up SIG reference details into a common location, taking it out of the individual spot providers. This means we can now look up references properly from Cluster spot comments, etc. Closes #74 as there is no longer any duplication of these lookups. Works towards #54 as sig_refs now specify their sig internally. 2025-11-02 15:45:19 +00:00
Ian Renton
28010a68ae Single common URL cache for semi-static lookups #74 2025-11-02 14:22:15 +00:00
Ian Renton
0e8c7873d8 Lookup for sig_ref data #73 2025-11-02 14:13:03 +00:00
Ian Renton
649b57a570 FIx a bug where touch scrolling on the map's filters popup would still be passed through to the map. Closes #72 2025-11-02 12:07:32 +00:00
Ian Renton
fa92657d9c Fix old alerts not getting deleted 2025-11-01 17:25:20 +00:00
Ian Renton
30fc333c8b Fix scrolling map filters panel on mobile 2025-11-01 17:05:47 +00:00
Ian Renton
0570b39e09 Add Spot page to allow sig and sig_ref entries. Closes #71 2025-11-01 12:38:57 +00:00
Ian Renton
1ed543872a Add Spot page to take mode options from API #71 2025-11-01 12:03:11 +00:00
Ian Renton
812d031a2c Fix link 2025-11-01 11:45:21 +00:00
Ian Renton
471c487132 Allow adding the DX grid when spotting #71 2025-11-01 11:11:33 +00:00
Ian Renton
57d950c1ca Fix search box appearance on mobile 2025-11-01 10:35:47 +00:00
Ian Renton
a3ec923c56 Improve add spot page warning and server-side validation. #71 2025-11-01 10:29:18 +00:00
Ian Renton
69821f817b Extract "add spot" into its own page 2025-11-01 08:52:46 +00:00
ian
0c79436399 Update views/webpage_spots.tpl 2025-10-31 19:21:01 +00:00
Ian Renton
3964134db9 Add dx_call_includes filter input on web UI 2025-10-31 17:52:29 +00:00
Ian Renton
04435e770a Add dx_call_includes filter 2025-10-31 17:33:27 +00:00
Ian Renton
a4645171e4 Thanks 2025-10-31 14:24:04 +00:00
Ian Renton
65d546ef7e Support BOTA alerts. Closes #58 2025-10-31 14:06:22 +00:00
Ian Renton
193838b9d3 Fix colours of table rows and JS exception on sig_refs being null. 2025-10-31 10:50:49 +00:00
Ian Renton
0c5b5f2062 Update API to have a sensible grouping of sig_refs rather than separate arrays of sig_refs, sig_refs_names and sig_refs_urls 2025-10-31 09:51:54 +00:00
Ian Renton
6c95e845a4 Add prometheus metrics endpoint. Closes #67 2025-10-30 13:37:53 +00:00
Ian Renton
9768f976c5 Add prometheus metrics endpoint. Closes #67 2025-10-30 13:32:10 +00:00
Ian Renton
a9003162cc Docs 2025-10-30 12:34:00 +00:00
Ian Renton
ab371e8df6 Support spot input from UK Packet Radio network. Not strictly required for #68 but helps with testing. 2025-10-30 12:05:13 +00:00
Ian Renton
6ce66fdb62 Take SSIDs into account in the dedupe spots function #68 2025-10-30 10:51:56 +00:00
Ian Renton
8ec3a67cf5 Rename dx_aprs_ssid to just dx_ssid, and add support for de_ssid. Add SSIDs to the web interface. #68 2025-10-30 10:47:57 +00:00
ian
3ea782579b Support Packet (PKT) as a digi mode 2025-10-25 18:13:57 +00:00
Ian Renton
8b036ddb46 Fix copy/paste error in WOTA support 2025-10-25 10:44:54 +01:00
Ian Renton
3f827c597b Extract spotter information from comments of RBNHole and SOTAMAT posts 2025-10-25 10:34:52 +01:00
Ian Renton
587d3b4cf1 Typo 2025-10-25 10:34:19 +01:00
Ian Renton
6eb1bd5ef1 Stop cleaning up comments, Spothole should be agnostic to that kind of thing. 2025-10-25 10:16:15 +01:00
Ian Renton
0ead59a985 Add missing providers to config-example.yml 2025-10-25 10:04:24 +01:00
Ian Renton
82b3c262b6 Apple Touch icon #66 2025-10-25 09:51:04 +01:00
Ian Renton
80b5077496 Apple Touch icon #66 2025-10-25 09:49:32 +01:00
Ian Renton
3625998f46 Finish support for WOTA #63 2025-10-25 09:37:41 +01:00
Ian Renton
e31c750b41 Mouseover callsign to reveal operator name 2025-10-25 09:36:30 +01:00
Ian Renton
ab05824c5d Fix a bug that caused repeated lookup attempts for callsigns that were unknown to QRZ/ClubLog and especially those with prefixes/suffixes. 2025-10-25 09:33:44 +01:00
Ian Renton
bb7b6d6f3c Remove console.log 2025-10-23 15:47:35 +01:00
Ian Renton
2c8d18685c Fix escape sequence error in API spec #65 2025-10-23 15:43:37 +01:00
Ian Renton
090310240f Add WOTA location lookup #63 2025-10-23 15:32:38 +01:00
Ian Renton
f2f03b135f Fix a bug with time zone reporting in alerts. 2025-10-23 09:37:22 +01:00
Ian Renton
5d4b3d500d Get ZLOTA spots from its own API rather than PnP. Closes #37 2025-10-23 08:47:00 +01:00
Ian Renton
65d83d2339 Merge remote-tracking branch 'origin/main' 2025-10-23 08:15:42 +01:00
Ian Renton
5093a8d3d1 Support WOTA alerts, need to see a spot before we can support spots properly. #63 2025-10-23 08:15:16 +01:00
Ian Renton
bdd31f6993 FAQ 2025-10-22 21:38:24 +01:00
Ian Renton
1bad16f478 Remove WIP warning from bands display #48 2025-10-21 17:31:08 +01:00
Ian Renton
ae8be4446c New mode seen 2025-10-21 17:26:52 +01:00
Ian Renton
3515fbd5c7 Complete (?) bands display. Closes #48 2025-10-21 17:23:34 +01:00
Ian Renton
f5e50dc5b4 Extend canvas when required #48 2025-10-21 16:51:04 +01:00
Ian Renton
001ec2c9b9 Extend canvas when required #48 2025-10-21 16:43:24 +01:00
Ian Renton
be86160e9c Better rollover and h-scroll on header #48 2025-10-21 16:18:15 +01:00
Ian Renton
0b3b35db35 First pass of the new style of band panel #48 2025-10-21 16:04:10 +01:00
Ian Renton
6e9bab5eee Bands panel layout tweaks #48 2025-10-21 14:02:43 +01:00
Ian Renton
229228d209 Apply SSE Spot Provider patch by Steven M1SDH 2025-10-21 08:11:43 +01:00
484 changed files with 4791 additions and 3496 deletions

102
README.md
View File

@@ -10,15 +10,64 @@ 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. 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, and Parks 'n' Peaks. 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, the UK Packet Repeater Network, NG3K, and any site based on the xOTA software by nischu.
![Screenshot](/images/screenshot2.png) ![Screenshot](/images/screenshot2.png)
### Accessing the public version ![Screenshot](/images/screenshot3.png)
## Accessing the public version
You can access the public version's web interface at [https://spothole.app](https://spothole.app), and see [https://spothole.app/apidocs](https://spothole.app/apidocs) for the API details. You can access the public version's web interface at [https://spothole.app](https://spothole.app), and see [https://spothole.app/apidocs](https://spothole.app/apidocs) for the API details.
### Running your own copy This is a Progressive Web App, so you can also "install" it to your Android or iOS device by accessing it in Chrome or Safari respectively, and following the menu-driven process for installing PWAs.
## Embedding Spothole in another website
You can embed Spothole in another website, e.g. for use as part of a ham radio custom dashboard.
URL parameters can be used to trigger an "embedded" mode which hides the headers, footers and settings. In this mode, you provide configuration for the various filter and display options via additional URL parameters. Any settings that the user has set for Spothole are ignored. This is so that the embedding site can select, for example, their choice of dark mode or SIG filters, which will not impact how Spothole appears when the user accesses it directly. Effectively, it becomes separate to their normal Spothole settings.
Setting `embedded` to true is important for the rest of the settings to be applied; otherwise, the user's defaults will be used in preference to the URL params.
These are supplied with the URL to the page you want to embed, for example for an embedded version of the band map in dark mode, use `https://spothole.app/bands?embedded=true&dark-mode=true`. For an embedded version of the main spots/home page in the system light/dark mode, use `https://spothole.app/?embedded=true`. For dark mode showing 70cm TOTA spots only, use `https://spothole.app/?embedded=true&dark-mode=true&sig=TOTA&band=70cm`. Providing no URL params causes the page to be loaded in the normal way it would when accessed directly in the user's browser.
The supported parameters are as follows. Generally these match the equivalent parameters in the real Spothole API, where a mapping exists.
| Name | Allowed Values | Default | Example | Description |
|----------------|-----------------------|---------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `embedded` | `true`, `false` | `false` | `?embedded=true` | Enables embedded mode. |
| `dark-mode` | `true`, `false` | `false` | `?dark-mode=true` | Enables dark mode. |
| `time-zone` | `UTC`, `local` | `UTC` | `?time-zone=local` | Sets times to be in UTC or local time. |
| `limit` | 10, 25, 50, 100 | 50 | `?limit=50` | Sets the number of spots that will be displayed on the main spots page |
| `limit` | 25, 50, 100, 200, 500 | 100 | `?limit=100` | Sets the number of alerts that will be displayed on the alerts page |
| `max_age` | 300, 600, 1800, 3600 | 1800 | `?max_age=1800` | Sets the maximum age of spots displayed on the map and bands pages, in seconds. |
| `band` | Comma-separated list | (all) | `?band=20m,40m` | Sets the list of bands that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
| `sig` | Comma-separated list | (all) | `?sig=POTA,SOTA,NO_SIG` | Sets the list of SIGs that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
| `source` | Comma-separated list | (all) | `?source=Cluster` | Sets the list of sources that will be shown on any spot or alert pages. Available options match the labels of the buttons in the standard web interface. |
| `mode_type` | Comma-separated list | (all) | `?mode_type=PHONE,CW` | Sets the list of mode types that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
| `dx_continent` | Comma-separated list | (all) | `?dx_continent=NA,SA` | Sets the list of DX Continents that will be shown on any spot or alert pages. Available options match the labels of the buttons in the standard web interface. |
| `de_continent` | Comma-separated list | (all) | `?de_continent=EU` | Sets the list of DE Continents that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
More will be added soon to allow customisation of filters and other display properties.
## Writing your own client
One of the key strengths of Spothole is that the API is well-defined and open to anyone to use. This means you can build your own software that uses data from Spothole.
As well as the main API endpoints to fetch spots and alerts, with various possible query parameters, there are also Server-Sent Events (SSE) API endpoints to receive a live feed, plus various utility lookup endpoints for things like callsign and park data.
Various approaches exist to writing your own client, but in general:
* Refer to the API docs. These are built on an OpenAPI definition file (`/webassets/apidocs/openapi.yml`), which you can automatically use to generate a client skeleton using various software.
* Call the main "spots" or "alerts" API endpoints to get the data you want. Apply filters if necessary.
* Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do that first before calling the spots/alerts APIs, to allow you to populate your filters correctly.
* Refer to the provided HTML/JS interface for a reference on different approaches. For example, the "map" and "bands" pages simply query the main spot API on a timer, whereas the main/spots page combines this approach with using the Server-Sent Events (SSE) endpoint to update live.
* Let me know if you get stuck, I'm happy to help!
## Running your own copy
If you want to run a copy of Spothole with different configuration settings than the main instance, you can download it and run it on your own local machine or server.
To download and set up Spothole on a Debian server, run the following commands. Other operating systems will likely be similar. To download and set up Spothole on a Debian server, run the following commands. Other operating systems will likely be similar.
@@ -32,7 +81,7 @@ deactivate
cp config-example.yml config.yml cp config-example.yml config.yml
``` ```
Then edit `config.yml` in your text editor of choice to set up the software as you like it. Then edit `config.yml` in your text editor of choice to set up the software as you like it. Mostly, this will involve enabling or disabling the various providers of spot and alert data.
`config.yml` has some entries for QRZ.com username & password, and Clublog API keys. If provided, these allow Spothole to retrieve more information about DX spots, such as the country their callsign corresponds to. The software will work just fine without them, but you may find a few country flags etc. are less accurate or missing. `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.
@@ -55,6 +104,8 @@ If you see some errors on startup, check your configuration, e.g. in case you ha
### systemd configuration ### systemd configuration
If you want Spothole to run automatically on startup on a Linux distribution that uses `systemd`, follow the instructions here. For distros that don't use `systemd`, or Windows/OSX/etc., you can find generic instructions for your OS online.
Create a file at `/etc/systemd/system/spothole.service`. Give it the following content, adjusting for the user you want to run it as and the directory in which you have installed it: Create a file at `/etc/systemd/system/spothole.service`. Give it the following content, adjusting for the user you want to run it as and the directory in which you have installed it:
``` ```
@@ -85,7 +136,9 @@ Check the service has started up correctly with `sudo journalctl -u spothole -f`
### nginx Reverse Proxy configuration ### nginx Reverse Proxy configuration
It's best not to serve Spothole directly on port 80, as that requires root privileges and prevents us using HTTPS, amongst other reasons. To set up nginx as a reverse proxy that sits in front of Spothole, first ensure it's installed e.g. `sudo apt install nginx`, and enabled e.g. `sudo systemd enable nginx`. Web servers generally serve their pages from port 80. However, it's best not to serve Spothole's web interface directly on port 80, as that requires root privileges on a Linux system. It also and prevents us using HTTPS to serve a secure site, since Spothole itself doesn't directly support acting as an HTTPS server. The normal solution to this is to use a "reverse proxy" setup, where a general web server handles HTTP and HTTP requests (to port 80 & 443 respectively), then passes on the request to the back-end application (in this case Spothole). nginx is a common choice for this general web server.
To set up nginx as a reverse proxy that sits in front of Spothole, first ensure it's installed e.g. `sudo apt install nginx`, and enabled e.g. `sudo systemd enable nginx`.
Create a file at `/etc/nginx/sites-available/` called `spothole`. Give it the following contents, replacing `spothole.app` with the domain name on which you want to run Spothole. If you changed the port on which Spothole runs, update that on the "proxy_pass" line too. Create a file at `/etc/nginx/sites-available/` called `spothole`. Give it the following contents, replacing `spothole.app` with the domain name on which you want to run Spothole. If you changed the port on which Spothole runs, update that on the "proxy_pass" line too.
@@ -104,6 +157,8 @@ server {
location / { location / {
add_header Access-Control-Allow-Origin $xssorigin; add_header Access-Control-Allow-Origin $xssorigin;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_pass http://127.0.0.1:8080; proxy_pass http://127.0.0.1:8080;
} }
} }
@@ -133,17 +188,11 @@ You should now be able to access the web interface by going to the domain from y
Once that's working, [install certbot](https://certbot.eff.org/instructions?ws=nginx&os=snap) onto your server. Run it as root, and when prompted pick your domain name from the list. After a few seconds, it should successfully provision a certificate and modify your nginx config files automatically. You should then be able to access the site via HTTPS. Once that's working, [install certbot](https://certbot.eff.org/instructions?ws=nginx&os=snap) onto your server. Run it as root, and when prompted pick your domain name from the list. After a few seconds, it should successfully provision a certificate and modify your nginx config files automatically. You should then be able to access the site via HTTPS.
### Writing your own client ## Modifying the source code
Various approaches exist to writing your own client, but in general: Spothole is Public Domain licenced, so you can grab the source code and start modifying it for your own needs. Contributions of code back to the main repository are encouraged, but completely optional.
* Refer to the API docs. These are built on an OpenAPI definition file (`/webassets/apidocs/openapi.yml`), which you can automatically use to generate a client skeleton using various software. ### Code structure
* Call the main "spots" API to get the data you want. Apply filters if necessary.
* Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do that first before calling the spots API.
* Refer to the provided HTML/JS interface for a reference
* Let me know if you get stuck, I'm happy to help!
### Structure of the source code
To navigate your way around the source code, this list may help. To navigate your way around the source code, this list may help.
@@ -157,7 +206,7 @@ To navigate your way around the source code, this list may help.
*Templates* *Templates*
* `/views` - Templates used for constructing Spothole's user-targeted HTML pages * `/templates` - Templates used for constructing Spothole's user-targeted HTML pages
*HTML/JS/CSS front-end code* *HTML/JS/CSS front-end code*
@@ -172,30 +221,37 @@ To navigate your way around the source code, this list may help.
* `/` - Main script (`spothole.py`), pip `requirements.txt`, config, README, etc. * `/` - Main script (`spothole.py`), pip `requirements.txt`, config, README, etc.
* `/images` - Image sources * `/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. * `/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 ### Extending the server
Spothole is designed to be easily extensible. If you want to write your own provider, simply add a module to the `providers` package containing your class. (Currently, in order to be loaded correctly, the module (file) name should be the same as the class name, but lower case.) Spothole is designed to be easily extensible. If you want to write your own spot provider, for example, simply add a module to the `spotproviders` package containing your class. (Currently, in order to be loaded correctly, the module (file) name should be the same as the class name, but lower case.)
Your class should extend "Provider"; if it operates by polling an HTTP Server on a timer, it can instead extend "HTTPProvider" where some of the work is done for you. Your class should extend "SpotProvider"; if it operates by polling an HTTP Server on a timer, it can instead extend "HTTPSpotProvider" where some of the work is done for you.
The class will need to implement a constructor that takes in the `provider_config` and provides it to the superclass constructor, while also taking any other config parameters it needs. The class will need to implement a constructor that takes in the `provider_config` and provides it to the superclass constructor, while also taking any other config parameters it needs.
If you're extending the base `Provider` class, you will need to implement `start()` and `stop()` methods that start and stop a separate thread which handles the provider's processing needs. The thread should call `submit()` or `submit_batch()` when it has one or more spots to report. If you're extending the base `SpotProvider` class, you will need to implement `start()` and `stop()` methods that start and stop a separate thread which handles the provider's processing needs. The thread should call `submit()` or `submit_batch()` when it has one or more spots to report.
If you're extending the `HTTPProvider` class, you will need to provide a URI to query and an interval to the superclass constructor. You'll then need to implement the `http_response_to_spots()` method which is called when new data is retrieved. Your implementation should then call `submit()` or `submit_batch()` when it has one or more spots to report. If you're extending the `HTTPSpotProvider` class, you will need to provide a URI to query and an interval to the superclass constructor. You'll then need to implement the `http_response_to_spots()` method which is called when new data is retrieved. Your implementation should then call `submit()` or `submit_batch()` when it has one or more spots to report.
When constructing spots, use the comments in the Spot class and the existing implementations as an example. All parameters are optional, but you will at least want to provide a `time` (which must be timezone-aware) and a `dx_call`. When constructing spots, use the comments in the Spot class and the existing implementations as an example. All parameters are optional, but you will at least want to provide a `time` (which must be timezone-aware) and a `dx_call`.
Finally, simply add the appropriate config to the `providers` section of `config.yml`, and your provider should be instantiated on startup. Finally, simply add the appropriate config to the `spot_providers` section of `config.yml`, and your provider should be instantiated on startup.
### Thanks The same approach as above is also used for alert providers.
## Thanks
As well as being my work, I have also gratefully received feature patches from Steven, M1SDH. As well as being my work, I have also gratefully received feature patches from Steven, M1SDH.
The project contains a self-hosted copy of Font Awesome's free library, in the `/webasset/fa/` directory. This is subject to Font Awesome's licence and is not covered by the overall licence declared in the `LICENSE` file. This approach was taken in preference to using their hosted kits due to the popularity of this project exceeding the page view limit for their free hosted offering. The project contains a self-hosted copy of Font Awesome's free library, in the `/webassets/fa/` directory. This is subject to Font Awesome's licence and is not covered by the overall licence declared in the `LICENSE` file. This approach was taken in preference to using their hosted kits due to the popularity of this project exceeding the page view limit for their free hosted offering.
The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries such as jQuery and moment.js. This project would not have been possible without these libraries, so many thanks to their developers. The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the `/webassets/img/flags/` directory.
The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries such as jQuery, Leaflet and Bootstrap. This project would not have been possible without these libraries, so many thanks to their developers.
Particular thanks go to country-files.com for providing country lookup data for amateur radio, to K0SWE for [this JSON-formatted DXCC data](https://github.com/k0swe/dxcc-json/), and to the developers of `pyhamtools` for making it easy to use country-files.com data as well as QRZ.com and Clublog lookup.
The project's name was suggested by Harm, DK4HAA. Thanks! The project's name was suggested by Harm, DK4HAA. Thanks!

View File

@@ -1,9 +1,8 @@
from datetime import datetime, timedelta from datetime import datetime
import pytz import pytz
from core.config import SERVER_OWNER_CALLSIGN, MAX_ALERT_AGE from core.config import MAX_ALERT_AGE
from core.constants import SOFTWARE_NAME, SOFTWARE_VERSION
# Generic alert provider class. Subclasses of this query the individual APIs for alerts. # Generic alert provider class. Subclasses of this query the individual APIs for alerts.
@@ -16,10 +15,12 @@ class AlertProvider:
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC) self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled" self.status = "Not Started" if self.enabled else "Disabled"
self.alerts = None self.alerts = None
self.web_server = None
# Set up the provider, e.g. giving it the alert list to work from # Set up the provider, e.g. giving it the alert list to work from
def setup(self, alerts): def setup(self, alerts, web_server):
self.alerts = alerts self.alerts = alerts
self.web_server = web_server
# Start the provider. This should return immediately after spawning threads to access the remote resources # Start the provider. This should return immediately after spawning threads to access the remote resources
def start(self): def start(self):
@@ -29,12 +30,20 @@ class AlertProvider:
# because alerts could be created at any point for any time in the future. Rely on hashcode-based id matching # because alerts could be created at any point for any time in the future. Rely on hashcode-based id matching
# to deal with duplicates. # to deal with duplicates.
def submit_batch(self, alerts): def submit_batch(self, alerts):
# Sort the batch so that earliest ones go in first. This helps keep the ordering correct when alerts are fired
# off to SSE listeners.
alerts = sorted(alerts, key=lambda alert: (alert.start_time if alert and alert.start_time else 0))
for alert in alerts: for alert in alerts:
# Fill in any blanks # Fill in any blanks and add to the list
alert.infer_missing() alert.infer_missing()
# Add to the list, provided it heas not already expired. self.add_alert(alert)
if not alert.expired():
self.alerts.add(alert.id, alert, expire=MAX_ALERT_AGE) def add_alert(self, alert):
if not alert.expired():
self.alerts.add(alert.id, alert, expire=MAX_ALERT_AGE)
# Ping the web server in case we have any SSE connections that need to see this immediately
if self.web_server:
self.web_server.notify_new_alert(alert)
# Stop any threads and prepare for application shutdown # Stop any threads and prepare for application shutdown
def stop(self): def stop(self):

50
alertproviders/bota.py Normal file
View File

@@ -0,0 +1,50 @@
from datetime import datetime, timedelta
import pytz
from bs4 import BeautifulSoup
from alertproviders.http_alert_provider import HTTPAlertProvider
from data.alert import Alert
from data.sig_ref import SIGRef
# Alert provider for Beaches on the Air
class BOTA(HTTPAlertProvider):
POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://www.beachesontheair.com/"
def __init__(self, provider_config):
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def http_response_to_alerts(self, http_response):
new_alerts = []
# Find the table of upcoming alerts
bs = BeautifulSoup(http_response.content.decode(), features="lxml")
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()
ref_name = first_cell_text.split(" by ")[0]
dx_call = str(cells[1].find('a').contents[0]).strip().upper()
# Get the date, dealing with the fact we get no year so have to figure out if it's last year or next year
date_text = str(cells[2].find('span').contents[0]).strip()
date_time = datetime.strptime(date_text,"%d %b - %H:%M UTC").replace(tzinfo=pytz.UTC)
date_time = date_time.replace(year=datetime.now(pytz.UTC).year)
# If this was more than a day ago, activation is actually next year
if date_time < datetime.now(pytz.UTC) - timedelta(days=1):
date_time = date_time.replace(year=datetime.now(pytz.UTC).year + 1)
# Convert to our alert format
alert = Alert(source=self.name,
dx_calls=[dx_call],
sig_refs=[SIGRef(id=ref_name, sig="BOTA")],
start_time=date_time.timestamp(),
is_dxpedition=False)
new_alerts.append(alert)
return new_alerts

View File

@@ -10,7 +10,7 @@ from data.alert import Alert
# Alert provider NG3K DXpedition list # Alert provider NG3K DXpedition list
class NG3K(HTTPAlertProvider): class NG3K(HTTPAlertProvider):
POLL_INTERVAL_SEC = 3600 POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://www.ng3k.com/adxo.xml" ALERTS_URL = "https://www.ng3k.com/adxo.xml"
AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE) AS_CALL_PATTERN = re.compile("as ([a-z0-9/]+)", re.IGNORECASE)
@@ -76,7 +76,6 @@ class NG3K(HTTPAlertProvider):
dx_country=dx_country, dx_country=dx_country,
freqs_modes=bands + (("; " + modes) if modes != "" else ""), freqs_modes=bands + (("; " + modes) if modes != "" else ""),
comment=by + "; " + comment + "; " + qsl_info, comment=by + "; " + comment + "; " + qsl_info,
icon="globe-africa",
start_time=start_timestamp, start_time=start_timestamp,
end_time=end_timestamp, end_time=end_timestamp,
is_dxpedition=True) is_dxpedition=True)

View File

@@ -4,13 +4,13 @@ from datetime import datetime
import pytz import pytz
from alertproviders.http_alert_provider import HTTPAlertProvider from alertproviders.http_alert_provider import HTTPAlertProvider
from core.sig_utils import get_icon_for_sig
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef
# Alert provider for Parks n Peaks # Alert provider for Parks n Peaks
class ParksNPeaks(HTTPAlertProvider): class ParksNPeaks(HTTPAlertProvider):
POLL_INTERVAL_SEC = 3600 POLL_INTERVAL_SEC = 1800
ALERTS_URL = "http://parksnpeaks.org/api/ALERTS/" ALERTS_URL = "http://parksnpeaks.org/api/ALERTS/"
def __init__(self, provider_config): def __init__(self, provider_config):
@@ -21,6 +21,7 @@ class ParksNPeaks(HTTPAlertProvider):
# Iterate through source data # Iterate through source data
for source_alert in http_response.json(): for source_alert in http_response.json():
# Calculate some things # Calculate some things
sig = source_alert["Class"]
if " - " in source_alert["Location"]: if " - " in source_alert["Location"]:
split = source_alert["Location"].split(" - ") split = source_alert["Location"].split(" - ")
sig_ref = split[0] sig_ref = split[0]
@@ -37,19 +38,17 @@ class ParksNPeaks(HTTPAlertProvider):
dx_calls=[source_alert["CallSign"].upper()], dx_calls=[source_alert["CallSign"].upper()],
freqs_modes=source_alert["Freq"] + " " + source_alert["MODE"], freqs_modes=source_alert["Freq"] + " " + source_alert["MODE"],
comment=source_alert["Comments"], comment=source_alert["Comments"],
sig=source_alert["Class"], sig_refs=[SIGRef(id=sig_ref, sig=sig, name=sig_ref_name)],
sig_refs=[sig_ref],
sig_refs_names=[sig_ref_name],
icon=get_icon_for_sig(source_alert["Class"]),
start_time=start_time, start_time=start_time,
is_dxpedition=False) is_dxpedition=False)
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before # Log a warning for the developer if PnP gives us an unknown programme we've never seen before
if alert.sig not in ["POTA", "SOTA", "WWFF", "SiOTA", "ZLOTA", "KRMNPA"]: if sig and sig not in ["POTA", "SOTA", "WWFF", "SiOTA", "ZLOTA", "KRMNPA"]:
logging.warn("PNP alert found with sig " + alert.sig + ", developer needs to add support for this!") logging.warn("PNP alert found with sig " + sig + ", developer needs to add support for this!")
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. Otherwise, add to # If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. Otherwise, add to
# the alert list. # the alert list. Note that while ZLOTA has its own spots API, it doesn't have its own alerts API. So that
if alert.sig not in ["POTA", "SOTA", "WWFF"]: # means the PnP *spot* provider rejects ZLOTA spots here, but the PnP *alerts* provider here allows ZLOTA.
if sig not in ["POTA", "SOTA", "WWFF"]:
new_alerts.append(alert) new_alerts.append(alert)
return new_alerts return new_alerts

View File

@@ -3,13 +3,13 @@ from datetime import datetime
import pytz import pytz
from alertproviders.http_alert_provider import HTTPAlertProvider from alertproviders.http_alert_provider import HTTPAlertProvider
from core.sig_utils import get_icon_for_sig
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef
# Alert provider for Parks on the Air # Alert provider for Parks on the Air
class POTA(HTTPAlertProvider): class POTA(HTTPAlertProvider):
POLL_INTERVAL_SEC = 3600 POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://api.pota.app/activation" ALERTS_URL = "https://api.pota.app/activation"
def __init__(self, provider_config): def __init__(self, provider_config):
@@ -25,10 +25,7 @@ class POTA(HTTPAlertProvider):
dx_calls=[source_alert["activator"].upper()], dx_calls=[source_alert["activator"].upper()],
freqs_modes=source_alert["frequencies"], freqs_modes=source_alert["frequencies"],
comment=source_alert["comments"], comment=source_alert["comments"],
sig="POTA", sig_refs=[SIGRef(id=source_alert["reference"], sig="POTA", name=source_alert["name"], url="https://pota.app/#/park/" + source_alert["reference"])],
sig_refs=[source_alert["reference"]],
sig_refs_names=[source_alert["name"]],
icon=get_icon_for_sig("POTA"),
start_time=datetime.strptime(source_alert["startDate"] + source_alert["startTime"], start_time=datetime.strptime(source_alert["startDate"] + source_alert["startTime"],
"%Y-%m-%d%H:%M").replace(tzinfo=pytz.UTC).timestamp(), "%Y-%m-%d%H:%M").replace(tzinfo=pytz.UTC).timestamp(),
end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"], end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"],

View File

@@ -3,13 +3,13 @@ from datetime import datetime
import pytz import pytz
from alertproviders.http_alert_provider import HTTPAlertProvider from alertproviders.http_alert_provider import HTTPAlertProvider
from core.sig_utils import get_icon_for_sig
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef
# Alert provider for Summits on the Air # Alert provider for Summits on the Air
class SOTA(HTTPAlertProvider): class SOTA(HTTPAlertProvider):
POLL_INTERVAL_SEC = 3600 POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://api-db2.sota.org.uk/api/alerts/365/all/all" ALERTS_URL = "https://api-db2.sota.org.uk/api/alerts/365/all/all"
def __init__(self, provider_config): def __init__(self, provider_config):
@@ -20,16 +20,18 @@ class SOTA(HTTPAlertProvider):
# Iterate through source data # Iterate through source data
for source_alert in http_response.json(): for source_alert in http_response.json():
# Convert to our alert format # 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, alert = Alert(source=self.name,
source_id=source_alert["id"], source_id=source_alert["id"],
dx_calls=[source_alert["activatingCallsign"].upper()], dx_calls=[source_alert["activatingCallsign"].upper()],
dx_names=[source_alert["activatorName"].upper()], dx_names=[source_alert["activatorName"].upper()],
freqs_modes=source_alert["frequency"], freqs_modes=source_alert["frequency"],
comment=source_alert["comments"], comment=source_alert["comments"],
sig="SOTA", sig_refs=[SIGRef(id=source_alert["associationCode"] + "/" + source_alert["summitCode"], sig="SOTA", name=summit_name, activation_score=summit_points)],
sig_refs=[source_alert["associationCode"] + "/" + source_alert["summitCode"]],
sig_refs_names=[source_alert["summitDetails"]],
icon=get_icon_for_sig("SOTA"),
start_time=datetime.strptime(source_alert["dateActivated"], start_time=datetime.strptime(source_alert["dateActivated"],
"%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=pytz.UTC).timestamp(), "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=pytz.UTC).timestamp(),
is_dxpedition=False) is_dxpedition=False)

61
alertproviders/wota.py Normal file
View File

@@ -0,0 +1,61 @@
from datetime import datetime
import pytz
from rss_parser import RSSParser
from alertproviders.http_alert_provider import HTTPAlertProvider
from data.alert import Alert
from data.sig_ref import SIGRef
# Alert provider for Wainwrights on the Air
class WOTA(HTTPAlertProvider):
POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://www.wota.org.uk/alerts_rss.php"
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
def __init__(self, provider_config):
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
def http_response_to_alerts(self, http_response):
new_alerts = []
rss = RSSParser.parse(http_response.content.decode())
# Iterate through source data
for source_alert in rss.channel.items:
# Reject GUID missing or zero
if not source_alert.guid or not source_alert.guid.content or source_alert.guid.content == "http://www.wota.org.uk/alerts/0":
continue
# Pick apart the title
title_split = source_alert.title.split(" on ")
dx_call = title_split[0]
ref = None
ref_name = None
if len(title_split) > 1:
ref_split = title_split[1].split(" - ")
ref = ref_split[0]
if len(ref_split) > 1:
ref_name = ref_split[1]
# Pick apart the description
desc_split = source_alert.description.split(". ")
freqs_modes = desc_split[0].replace("Frequencies/modes:", "").strip()
comment = None
if len(desc_split) > 1:
comment = desc_split[1].strip()
time = datetime.strptime(source_alert.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
# Convert to our alert format
alert = Alert(source=self.name,
source_id=source_alert.guid.content,
dx_calls=[dx_call],
freqs_modes=freqs_modes,
comment=comment,
sig_refs=[SIGRef(id=ref, sig="WOTA", name=ref_name)] if ref else [],
start_time=time.timestamp())
# Add to our list.
new_alerts.append(alert)
return new_alerts

View File

@@ -3,13 +3,13 @@ from datetime import datetime
import pytz import pytz
from alertproviders.http_alert_provider import HTTPAlertProvider from alertproviders.http_alert_provider import HTTPAlertProvider
from core.sig_utils import get_icon_for_sig
from data.alert import Alert from data.alert import Alert
from data.sig_ref import SIGRef
# Alert provider for Worldwide Flora and Fauna # Alert provider for Worldwide Flora and Fauna
class WWFF(HTTPAlertProvider): class WWFF(HTTPAlertProvider):
POLL_INTERVAL_SEC = 3600 POLL_INTERVAL_SEC = 1800
ALERTS_URL = "https://spots.wwff.co/static/agendas.json" ALERTS_URL = "https://spots.wwff.co/static/agendas.json"
def __init__(self, provider_config): def __init__(self, provider_config):
@@ -25,9 +25,7 @@ class WWFF(HTTPAlertProvider):
dx_calls=[source_alert["activator_call"].upper()], dx_calls=[source_alert["activator_call"].upper()],
freqs_modes=source_alert["band"] + " " + source_alert["mode"], freqs_modes=source_alert["band"] + " " + source_alert["mode"],
comment=source_alert["remarks"], comment=source_alert["remarks"],
sig="WWFF", sig_refs=[SIGRef(id=source_alert["reference"], sig="WWFF")],
sig_refs=[source_alert["reference"]],
icon=get_icon_for_sig("WWFF"),
start_time=datetime.strptime(source_alert["utc_start"], start_time=datetime.strptime(source_alert["utc_start"],
"%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp(), "%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp(),
end_time=datetime.strptime(source_alert["utc_end"], end_time=datetime.strptime(source_alert["utc_end"],

View File

@@ -41,6 +41,18 @@ spot-providers:
class: "ParksNPeaks" class: "ParksNPeaks"
name: "ParksNPeaks" name: "ParksNPeaks"
enabled: true enabled: true
-
class: "ZLOTA"
name: "ZLOTA"
enabled: true
-
class: "WOTA"
name: "WOTA"
enabled: true
-
class: "LLOTA"
name: "LLOTA"
enabled: true
- -
class: "APRSIS" class: "APRSIS"
name: "APRS-IS" name: "APRS-IS"
@@ -51,24 +63,64 @@ spot-providers:
enabled: true enabled: true
host: "hrd.wa9pie.net" host: "hrd.wa9pie.net"
port: 8000 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" class: "DXCluster"
name: "W3LPL Cluster" name: "W3LPL Cluster"
enabled: false enabled: false
host: "w3lpl.net" host: "w3lpl.net"
port: 7373 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" class: "RBN"
name: "RBN CW/RTTY" name: "RBN CW/RTTY"
enabled: false enabled: false
port: 7000 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" class: "RBN"
name: "RBN FT8" name: "RBN FT8"
enabled: false enabled: false
port: 7001 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 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"
locations-csv: "datafiles/39c3-tota.csv"
# Alert providers to use. Same setup as the spot providers list above. # Alert providers to use. Same setup as the spot providers list above.
alert-providers: alert-providers:
@@ -88,6 +140,14 @@ alert-providers:
class: "ParksNPeaks" class: "ParksNPeaks"
name: "ParksNPeaks" name: "ParksNPeaks"
enabled: true enabled: true
-
class: "WOTA"
name: "WOTA"
enabled: true
-
class: "BOTA"
name: "BOTA"
enabled: true
- -
class: "NG3K" class: "NG3K"
name: "NG3K" name: "NG3K"
@@ -103,12 +163,25 @@ max-alert-age-sec: 604800
# Login for QRZ.com to look up information. Optional. You will need an "XML Subscriber" (paid) package to retrieve all # Login for QRZ.com to look up information. Optional. You will need an "XML Subscriber" (paid) package to retrieve all
# the data for a callsign via their system. # the data for a callsign via their system.
qrz-username: "N0CALL" qrz-username: ""
qrz-password: "" qrz-password: ""
# Login for HamQTH to look up information. Optional.
hamqth-username: ""
hamqth-password: ""
# API key for Clublog to look up information. Optional. You sill need to request one via their helpdesk portal if you # API key for Clublog to look up information. Optional. You sill need to request one via their helpdesk portal if you
# want to use callsign lookups from Clublog. # want to use callsign lookups from Clublog.
clublog-api-key: "" clublog-api-key: ""
# Allow submitting spots to the Spothole API? # Allow submitting spots to the Spothole API?
allow-spotting: true allow-spotting: true
# Options for the web UI.
web-ui-options:
spot-count: [10, 25, 50, 100]
spot-count-default: 50
max-spot-age: [5, 10, 30, 60]
max-spot-age-default: 30
alert-count: [25, 50, 100, 200, 500]
alert-count-default: 100

10
core/cache_utils.py Normal file
View File

@@ -0,0 +1,10 @@
from datetime import timedelta
from requests_cache import CachedSession
# Cache for "semi-static" data such as the locations of parks, CSVs of reference lists, etc.
# This has an expiry time of 30 days, so will re-request from the source after that amount
# of time has passed. This is used throughout Spothole to cache data that does not change
# rapidly.
SEMI_STATIC_URL_DATA_CACHE = CachedSession("cache/semi_static_url_data_cache",
expire_after=timedelta(days=30))

View File

@@ -1,5 +1,5 @@
import logging import logging
from datetime import datetime, timedelta from datetime import datetime
from threading import Timer from threading import Timer
from time import sleep from time import sleep
@@ -10,9 +10,10 @@ import pytz
class CleanupTimer: class CleanupTimer:
# Constructor # Constructor
def __init__(self, spots, alerts, cleanup_interval): def __init__(self, spots, alerts, web_server, cleanup_interval):
self.spots = spots self.spots = spots
self.alerts = alerts self.alerts = alerts
self.web_server = web_server
self.cleanup_interval = cleanup_interval self.cleanup_interval = cleanup_interval
self.cleanup_timer = None self.cleanup_timer = None
self.last_cleanup_time = datetime.min.replace(tzinfo=pytz.UTC) self.last_cleanup_time = datetime.min.replace(tzinfo=pytz.UTC)
@@ -29,16 +30,30 @@ class CleanupTimer:
# Perform cleanup and reschedule next timer # Perform cleanup and reschedule next timer
def cleanup(self): def cleanup(self):
try: try:
# Perform cleanup # Perform cleanup via letting the data expire
self.spots.expire() self.spots.expire()
self.alerts.expire() self.alerts.expire()
# Alerts can persist in the system for a while, so we want to explicitly clean up any alerts that have # Explicitly clean up any spots and alerts that have expired
# expired for id in list(self.spots.iterkeys()):
try:
spot = self.spots[id]
if spot.expired():
self.spots.delete(id)
except KeyError:
# Must have already been deleted, OK with that
pass
for id in list(self.alerts.iterkeys()): for id in list(self.alerts.iterkeys()):
alert = self.alerts[id] try:
if alert.expired(): alert = self.alerts[id]
self.alerts.evict(id) if alert.expired():
self.alerts.delete(id)
except KeyError:
# Must have already been deleted, OK with that
pass
# Clean up web server SSE spot/alert queues
self.web_server.clean_up_sse_queues()
self.status = "OK" self.status = "OK"
self.last_cleanup_time = datetime.now(pytz.UTC) self.last_cleanup_time = datetime.now(pytz.UTC)

View File

@@ -5,7 +5,8 @@ import yaml
# Check you have a config file # Check you have a config file
if not os.path.isfile("config.yml"): 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() exit()
# Load config # Load config
@@ -16,4 +17,10 @@ MAX_SPOT_AGE = config["max-spot-age-sec"]
MAX_ALERT_AGE = config["max-alert-age-sec"] MAX_ALERT_AGE = config["max-alert-age-sec"]
SERVER_OWNER_CALLSIGN = config["server-owner-callsign"] SERVER_OWNER_CALLSIGN = config["server-owner-callsign"]
WEB_SERVER_PORT = config["web-server-port"] WEB_SERVER_PORT = config["web-server-port"]
ALLOW_SPOTTING = config["allow-spotting"] 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)]

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,23 @@
import gzip import gzip
import json
import logging import logging
import re
import urllib.parse
from datetime import timedelta from datetime import timedelta
import xmltodict
from diskcache import Cache from diskcache import Cache
from pyhamtools import LookupLib, Callinfo from pyhamtools import LookupLib, Callinfo, callinfo
from pyhamtools.exceptions import APIKeyMissingError from pyhamtools.exceptions import APIKeyMissingError
from pyhamtools.frequency import freq_to_band from pyhamtools.frequency import freq_to_band
from pyhamtools.locator import latlong_to_locator from pyhamtools.locator import latlong_to_locator
from requests_cache import CachedSession from requests_cache import CachedSession
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
from core.config import config from core.config import config
from core.constants import BANDS, UNKNOWN_BAND, CW_MODES, PHONE_MODES, DATA_MODES, ALL_MODES, \ from core.constants import BANDS, UNKNOWN_BAND, CW_MODES, PHONE_MODES, DATA_MODES, ALL_MODES, \
QRZCQ_CALLSIGN_LOOKUP_DATA, HTTP_HEADERS HTTP_HEADERS, HAMQTH_PRG, MODE_ALIASES
# Singleton class that provides lookup functionality. # Singleton class that provides lookup functionality.
class LookupHelper: class LookupHelper:
@@ -31,18 +37,26 @@ class LookupHelper:
self.QRZ_CALLSIGN_DATA_CACHE = None self.QRZ_CALLSIGN_DATA_CACHE = None
self.LOOKUP_LIB_QRZ = None self.LOOKUP_LIB_QRZ = None
self.QRZ_AVAILABLE = None self.QRZ_AVAILABLE = None
self.HAMQTH_AVAILABLE = None
self.HAMQTH_CALLSIGN_DATA_CACHE = None
self.HAMQTH_BASE_URL = "https://www.hamqth.com/xml.php"
# HamQTH session keys expire after an hour. Rather than working out how much time has passed manually, we cheat
# and cache the HTTP response for 55 minutes, so when the login URL is queried within 55 minutes of the previous
# time, you just get the cached response.
self.HAMQTH_SESSION_LOOKUP_CACHE = CachedSession("cache/hamqth_session_cache",
expire_after=timedelta(minutes=55))
self.CALL_INFO_BASIC = None self.CALL_INFO_BASIC = None
self.LOOKUP_LIB_BASIC = None self.LOOKUP_LIB_BASIC = None
self.COUNTRY_FILES_CTY_PLIST_DOWNLOAD_LOCATION = None self.COUNTRY_FILES_CTY_PLIST_DOWNLOAD_LOCATION = None
self.COUNTRY_FILES_CTY_PLIST_CACHE = None self.DXCC_JSON_DOWNLOAD_LOCATION = None
self.DXCC_DATA = None
def start(self): def start(self):
# Lookup helpers from pyhamtools. We use four (!) of these. The simplest is country-files.com, which downloads the data # Lookup helpers from pyhamtools. We use five (!) of these. The simplest is country-files.com, which downloads
# once on startup, and requires no login/key, but does not have the best coverage. # the data once on startup, and requires no login/key, but does not have the best coverage.
# If the user provides login details/API keys, we also set up helpers for QRZ.com, Clublog (live API request), and # If the user provides login details/API keys, we also set up helpers for QRZ.com, HamQTH, Clublog (live API
# Clublog (XML download). The lookup functions iterate through these in a sensible order, looking for suitable data. # request), and Clublog (XML download). The lookup functions iterate through these in a sensible order, looking
self.COUNTRY_FILES_CTY_PLIST_CACHE = CachedSession("cache/country_files_city_plist_cache", # for suitable data.
expire_after=timedelta(days=10))
self.COUNTRY_FILES_CTY_PLIST_DOWNLOAD_LOCATION = "cache/cty.plist" self.COUNTRY_FILES_CTY_PLIST_DOWNLOAD_LOCATION = "cache/cty.plist"
success = self.download_country_files_cty_plist() success = self.download_country_files_cty_plist()
if success: if success:
@@ -52,12 +66,15 @@ class LookupHelper:
self.LOOKUP_LIB_BASIC = LookupLib(lookuptype="countryfile") self.LOOKUP_LIB_BASIC = LookupLib(lookuptype="countryfile")
self.CALL_INFO_BASIC = Callinfo(self.LOOKUP_LIB_BASIC) self.CALL_INFO_BASIC = Callinfo(self.LOOKUP_LIB_BASIC)
self.QRZ_AVAILABLE = config["qrz-password"] != "" self.QRZ_AVAILABLE = config["qrz-username"] != "" and config["qrz-password"] != ""
if self.QRZ_AVAILABLE: if self.QRZ_AVAILABLE:
self.LOOKUP_LIB_QRZ = LookupLib(lookuptype="qrz", username=config["qrz-username"], self.LOOKUP_LIB_QRZ = LookupLib(lookuptype="qrz", username=config["qrz-username"],
pwd=config["qrz-password"]) pwd=config["qrz-password"])
self.QRZ_CALLSIGN_DATA_CACHE = Cache('cache/qrz_callsign_lookup_cache') self.QRZ_CALLSIGN_DATA_CACHE = Cache('cache/qrz_callsign_lookup_cache')
self.HAMQTH_AVAILABLE = config["hamqth-username"] != "" and config["hamqth-password"] != ""
self.HAMQTH_CALLSIGN_DATA_CACHE = Cache('cache/hamqth_callsign_lookup_cache')
self.CLUBLOG_API_KEY = config["clublog-api-key"] self.CLUBLOG_API_KEY = config["clublog-api-key"]
self.CLUBLOG_CTY_XML_CACHE = CachedSession("cache/clublog_cty_xml_cache", expire_after=timedelta(days=10)) self.CLUBLOG_CTY_XML_CACHE = CachedSession("cache/clublog_cty_xml_cache", expire_after=timedelta(days=10))
self.CLUBLOG_API_AVAILABLE = self.CLUBLOG_API_KEY != "" self.CLUBLOG_API_AVAILABLE = self.CLUBLOG_API_KEY != ""
@@ -71,6 +88,19 @@ class LookupHelper:
filename=self.CLUBLOG_XML_DOWNLOAD_LOCATION) filename=self.CLUBLOG_XML_DOWNLOAD_LOCATION)
self.CLUBLOG_CALLSIGN_DATA_CACHE = Cache('cache/clublog_callsign_lookup_cache') self.CLUBLOG_CALLSIGN_DATA_CACHE = Cache('cache/clublog_callsign_lookup_cache')
# We also get a lookup of DXCC data from K0SWE to use for additional lookups of e.g. flags.
self.DXCC_JSON_DOWNLOAD_LOCATION = "cache/dxcc.json"
success = self.download_dxcc_json()
if success:
with open(self.DXCC_JSON_DOWNLOAD_LOCATION) as f:
tmp_dxcc_data = json.load(f)["dxcc"]
# Reformat as a map for faster lookup
self.DXCC_DATA = {}
for dxcc in tmp_dxcc_data:
self.DXCC_DATA[dxcc["entityCode"]] = dxcc
else:
logging.error("Could not download DXCC data, flags and similar data may be missing!")
# Download the cty.plist file from country-files.com on first startup. The pyhamtools lib can actually download and use # Download the cty.plist file from country-files.com on first startup. The pyhamtools lib can actually download and use
# this itself, but it's occasionally offline which causes it to throw an error. By downloading it separately, we can # this itself, but it's occasionally offline which causes it to throw an error. By downloading it separately, we can
# catch errors and handle them, falling back to a previous copy of the file in the cache, and we can use the # catch errors and handle them, falling back to a previous copy of the file in the cache, and we can use the
@@ -78,8 +108,8 @@ class LookupHelper:
def download_country_files_cty_plist(self): def download_country_files_cty_plist(self):
try: try:
logging.info("Downloading Country-files.com cty.plist...") logging.info("Downloading Country-files.com cty.plist...")
response = self.COUNTRY_FILES_CTY_PLIST_CACHE.get("https://www.country-files.com/cty/cty.plist", response = SEMI_STATIC_URL_DATA_CACHE.get("https://www.country-files.com/cty/cty.plist",
headers=HTTP_HEADERS).text headers=HTTP_HEADERS).text
with open(self.COUNTRY_FILES_CTY_PLIST_DOWNLOAD_LOCATION, "w") as f: with open(self.COUNTRY_FILES_CTY_PLIST_DOWNLOAD_LOCATION, "w") as f:
f.write(response) f.write(response)
@@ -90,6 +120,22 @@ class LookupHelper:
logging.error("Exception when downloading Clublog cty.xml", e) logging.error("Exception when downloading Clublog cty.xml", e)
return False return False
# Download the dxcc.json file on first startup.
def download_dxcc_json(self):
try:
logging.info("Downloading dxcc.json...")
response = SEMI_STATIC_URL_DATA_CACHE.get("https://raw.githubusercontent.com/k0swe/dxcc-json/refs/heads/main/dxcc.json",
headers=HTTP_HEADERS).text
with open(self.DXCC_JSON_DOWNLOAD_LOCATION, "w") as f:
f.write(response)
f.flush()
return True
except Exception as e:
logging.error("Exception when downloading dxcc.json", e)
return False
# Download the cty.xml (gzipped) file from Clublog on first startup, so we can use it in preference to querying the # Download the cty.xml (gzipped) file from Clublog on first startup, so we can use it in preference to querying the
# database live if possible. # database live if possible.
def download_clublog_ctyxml(self): def download_clublog_ctyxml(self):
@@ -114,6 +160,9 @@ class LookupHelper:
for mode in ALL_MODES: for mode in ALL_MODES:
if mode in comment.upper(): if mode in comment.upper():
return mode return mode
for mode in MODE_ALIASES.keys():
if mode in comment.upper():
return MODE_ALIASES[mode]
return None return None
# Infer a "mode family" from a mode. # Infer a "mode family" from a mode.
@@ -141,14 +190,19 @@ class LookupHelper:
try: try:
# Start with the basic country-files.com-based decoder. # Start with the basic country-files.com-based decoder.
country = self.CALL_INFO_BASIC.get_country_name(call) country = self.CALL_INFO_BASIC.get_country_name(call)
except KeyError as e: except (KeyError, ValueError) as e:
country = None country = None
# Couldn't get anything from basic call info database, try QRZ.com # Couldn't get anything from basic call info database, try QRZ.com
if not country: if not country:
qrz_data = self.get_qrz_data_for_callsign(call) qrz_data = self.get_qrz_data_for_callsign(call)
if qrz_data and "country" in qrz_data: if qrz_data and "country" in qrz_data:
country = qrz_data["country"] country = qrz_data["country"]
# Couldn't get anything from QRZ.com database, try Clublog data # Couldn't get anything from QRZ.com database, try HamQTH
if not country:
hamqth_data = self.get_hamqth_data_for_callsign(call)
if hamqth_data and "country" in hamqth_data:
country = hamqth_data["country"]
# Couldn't get anything from HamQTH database, try Clublog data
if not country: if not country:
clublog_data = self.get_clublog_xml_data_for_callsign(call) clublog_data = self.get_clublog_xml_data_for_callsign(call)
if clublog_data and "Name" in clublog_data: if clublog_data and "Name" in clublog_data:
@@ -157,27 +211,31 @@ class LookupHelper:
clublog_data = self.get_clublog_api_data_for_callsign(call) clublog_data = self.get_clublog_api_data_for_callsign(call)
if clublog_data and "Name" in clublog_data: if clublog_data and "Name" in clublog_data:
country = clublog_data["Name"] country = clublog_data["Name"]
# Couldn't get anything from Clublog database, try QRZCQ data # Couldn't get anything from Clublog database, try DXCC data
if not country: if not country:
qrzcq_data = self.get_qrzcq_data_for_callsign(call) dxcc_data = self.get_dxcc_data_for_callsign(call)
if qrzcq_data and "country" in qrzcq_data: if dxcc_data and "name" in dxcc_data:
country = qrzcq_data["country"] country = dxcc_data["name"]
return country return country
# Infer a DXCC ID from a callsign # Infer a DXCC ID from a callsign
def infer_dxcc_id_from_callsign(self, call): def infer_dxcc_id_from_callsign(self, call):
self.get_clublog_xml_data_for_callsign("M0TRT")
try: try:
# Start with the basic country-files.com-based decoder. # Start with the basic country-files.com-based decoder.
dxcc = self.CALL_INFO_BASIC.get_adif_id(call) dxcc = self.CALL_INFO_BASIC.get_adif_id(call)
except KeyError as e: except (KeyError, ValueError) as e:
dxcc = None dxcc = None
# Couldn't get anything from basic call info database, try QRZ.com # Couldn't get anything from basic call info database, try QRZ.com
if not dxcc: if not dxcc:
qrz_data = self.get_qrz_data_for_callsign(call) qrz_data = self.get_qrz_data_for_callsign(call)
if qrz_data and "adif" in qrz_data: if qrz_data and "adif" in qrz_data:
dxcc = qrz_data["adif"] dxcc = qrz_data["adif"]
# Couldn't get anything from QRZ.com database, try Clublog data # Couldn't get anything from QRZ.com database, try HamQTH
if not dxcc:
hamqth_data = self.get_hamqth_data_for_callsign(call)
if hamqth_data and "adif" in hamqth_data:
dxcc = hamqth_data["adif"]
# Couldn't get anything from HamQTH database, try Clublog data
if not dxcc: if not dxcc:
clublog_data = self.get_clublog_xml_data_for_callsign(call) clublog_data = self.get_clublog_xml_data_for_callsign(call)
if clublog_data and "DXCC" in clublog_data: if clublog_data and "DXCC" in clublog_data:
@@ -186,11 +244,11 @@ class LookupHelper:
clublog_data = self.get_clublog_api_data_for_callsign(call) clublog_data = self.get_clublog_api_data_for_callsign(call)
if clublog_data and "DXCC" in clublog_data: if clublog_data and "DXCC" in clublog_data:
dxcc = clublog_data["DXCC"] dxcc = clublog_data["DXCC"]
# Couldn't get anything from Clublog database, try QRZCQ data # Couldn't get anything from Clublog database, try DXCC data
if not dxcc: if not dxcc:
qrzcq_data = self.get_qrzcq_data_for_callsign(call) dxcc_data = self.get_dxcc_data_for_callsign(call)
if qrzcq_data and "dxcc" in qrzcq_data: if dxcc_data and "entityCode" in dxcc_data:
dxcc = qrzcq_data["dxcc"] dxcc = dxcc_data["entityCode"]
return dxcc return dxcc
# Infer a continent shortcode from a callsign # Infer a continent shortcode from a callsign
@@ -198,9 +256,14 @@ class LookupHelper:
try: try:
# Start with the basic country-files.com-based decoder. # Start with the basic country-files.com-based decoder.
continent = self.CALL_INFO_BASIC.get_continent(call) continent = self.CALL_INFO_BASIC.get_continent(call)
except KeyError as e: except (KeyError, ValueError) as e:
continent = None continent = None
# Couldn't get anything from basic call info database, try Clublog data # Couldn't get anything from basic call info database, try HamQTH
if not continent:
hamqth_data = self.get_hamqth_data_for_callsign(call)
if hamqth_data and "continent" in hamqth_data:
country = hamqth_data["continent"]
# Couldn't get anything from HamQTH database, try Clublog data
if not continent: if not continent:
clublog_data = self.get_clublog_xml_data_for_callsign(call) clublog_data = self.get_clublog_xml_data_for_callsign(call)
if clublog_data and "Continent" in clublog_data: if clublog_data and "Continent" in clublog_data:
@@ -209,11 +272,12 @@ class LookupHelper:
clublog_data = self.get_clublog_api_data_for_callsign(call) clublog_data = self.get_clublog_api_data_for_callsign(call)
if clublog_data and "Continent" in clublog_data: if clublog_data and "Continent" in clublog_data:
continent = clublog_data["Continent"] continent = clublog_data["Continent"]
# Couldn't get anything from Clublog database, try QRZCQ data # Couldn't get anything from Clublog database, try DXCC data
if not continent: if not continent:
qrzcq_data = self.get_qrzcq_data_for_callsign(call) dxcc_data = self.get_dxcc_data_for_callsign(call)
if qrzcq_data and "continent" in qrzcq_data: # Some DXCCs are in two continents, if so don't use the continent data as we can't be sure
continent = qrzcq_data["continent"] if dxcc_data and "continent" in dxcc_data and len(dxcc_data["continent"]) == 1:
continent = dxcc_data["continent"][0]
return continent return continent
# Infer a CQ zone from a callsign # Infer a CQ zone from a callsign
@@ -221,14 +285,19 @@ class LookupHelper:
try: try:
# Start with the basic country-files.com-based decoder. # Start with the basic country-files.com-based decoder.
cqz = self.CALL_INFO_BASIC.get_cqz(call) cqz = self.CALL_INFO_BASIC.get_cqz(call)
except KeyError as e: except (KeyError, ValueError) as e:
cqz = None cqz = None
# Couldn't get anything from basic call info database, try QRZ.com # Couldn't get anything from basic call info database, try QRZ.com
if not cqz: if not cqz:
qrz_data = self.get_qrz_data_for_callsign(call) qrz_data = self.get_qrz_data_for_callsign(call)
if qrz_data and "cqz" in qrz_data: if qrz_data and "cqz" in qrz_data:
cqz = qrz_data["cqz"] cqz = qrz_data["cqz"]
# Couldn't get anything from QRZ.com database, try Clublog data # Couldn't get anything from QRZ.com database, try HamQTH
if not cqz:
hamqth_data = self.get_hamqth_data_for_callsign(call)
if hamqth_data and "cq" in hamqth_data:
cqz = hamqth_data["cq"]
# Couldn't get anything from HamQTH database, try Clublog data
if not cqz: if not cqz:
clublog_data = self.get_clublog_xml_data_for_callsign(call) clublog_data = self.get_clublog_xml_data_for_callsign(call)
if clublog_data and "CQZ" in clublog_data: if clublog_data and "CQZ" in clublog_data:
@@ -237,11 +306,12 @@ class LookupHelper:
clublog_data = self.get_clublog_api_data_for_callsign(call) clublog_data = self.get_clublog_api_data_for_callsign(call)
if clublog_data and "CQZ" in clublog_data: if clublog_data and "CQZ" in clublog_data:
cqz = clublog_data["CQZ"] cqz = clublog_data["CQZ"]
# Couldn't get anything from Clublog database, try QRZCQ data # Couldn't get anything from Clublog database, try DXCC data
if not cqz: if not cqz:
qrzcq_data = self.get_qrzcq_data_for_callsign(call) dxcc_data = self.get_dxcc_data_for_callsign(call)
if qrzcq_data and "cqz" in qrzcq_data: # Some DXCCs are in multiple zones, if so don't use the zone data as we can't be sure
cqz = qrzcq_data["cqz"] if dxcc_data and "cq" in dxcc_data and len(dxcc_data["cq"]) == 1:
cqz = dxcc_data["cq"][0]
return cqz return cqz
# Infer a ITU zone from a callsign # Infer a ITU zone from a callsign
@@ -249,105 +319,76 @@ class LookupHelper:
try: try:
# Start with the basic country-files.com-based decoder. # Start with the basic country-files.com-based decoder.
ituz = self.CALL_INFO_BASIC.get_ituz(call) ituz = self.CALL_INFO_BASIC.get_ituz(call)
except KeyError as e: except (KeyError, ValueError) as e:
ituz = None ituz = None
# Couldn't get anything from basic call info database, try QRZ.com # Couldn't get anything from basic call info database, try QRZ.com
if not ituz: if not ituz:
qrz_data = self.get_qrz_data_for_callsign(call) qrz_data = self.get_qrz_data_for_callsign(call)
if qrz_data and "ituz" in qrz_data: if qrz_data and "ituz" in qrz_data:
ituz = qrz_data["ituz"] ituz = qrz_data["ituz"]
# Couldn't get anything from QRZ.com database, Clublog doesn't provide this, so try QRZCQ data # Couldn't get anything from QRZ.com database, try HamQTH
if not ituz: if not ituz:
qrzcq_data = self.get_qrzcq_data_for_callsign(call) hamqth_data = self.get_hamqth_data_for_callsign(call)
if qrzcq_data and "ituz" in qrzcq_data: if hamqth_data and "itu" in hamqth_data:
ituz = qrzcq_data["ituz"] ituz = hamqth_data["itu"]
# Couldn't get anything from HamQTH database, Clublog doesn't provide this, so try DXCC data
if not ituz:
dxcc_data = self.get_dxcc_data_for_callsign(call)
# Some DXCCs are in multiple zones, if so don't use the zone data as we can't be sure
if dxcc_data and "itu" in dxcc_data and len(dxcc_data["itu"]) == 1:
ituz = dxcc_data["itu"]
return ituz return ituz
# Utility method to get QRZ.com data from cache if possible, if not get it from the API and cache it # Get an emoji flag for a given DXCC entity ID
def get_qrz_data_for_callsign(self, call): def get_flag_for_dxcc(self, dxcc):
# Fetch from cache if we can, otherwise fetch from the API and cache it return self.DXCC_DATA[dxcc]["flag"] if dxcc in self.DXCC_DATA else None
qrz_data = self.QRZ_CALLSIGN_DATA_CACHE.get(call)
if qrz_data:
return qrz_data
elif self.QRZ_AVAILABLE:
try:
data = self.LOOKUP_LIB_QRZ.lookup_callsign(callsign=call)
self.QRZ_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
return data
except KeyError:
# QRZ had no info for the call, that's OK. Cache a None so we don't try to look this up again
self.QRZ_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
return None
else:
return None
# Utility method to get Clublog API data from cache if possible, if not get it from the API and cache it # Infer an operator name from a callsign (requires QRZ.com/HamQTH)
def get_clublog_api_data_for_callsign(self, call): def infer_name_from_callsign_online_lookup(self, call):
# Fetch from cache if we can, otherwise fetch from the API and cache it
clublog_data = self.CLUBLOG_CALLSIGN_DATA_CACHE.get(call)
if clublog_data:
return clublog_data
elif self.CLUBLOG_API_AVAILABLE:
try:
data = self.LOOKUP_LIB_CLUBLOG_API.lookup_callsign(callsign=call)
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
return data
except KeyError:
# Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
return None
except APIKeyMissingError:
# User API key was wrong, warn
logging.error("Could not look up via Clublog API, key " + self.CLUBLOG_API_KEY + " was rejected.")
return None
else:
return None
# Utility method to get Clublog XML data from file
def get_clublog_xml_data_for_callsign(self, call):
if self.CLUBLOG_XML_AVAILABLE:
try:
data = self.LOOKUP_LIB_CLUBLOG_XML.lookup_callsign(callsign=call)
return data
except KeyError:
# Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
return None
else:
return None
# Utility method to get QRZCQ data from our constants table, if we can find it
def get_qrzcq_data_for_callsign(self, call):
# Iterate in reverse order - see comments on the data structure itself
for entry in reversed(QRZCQ_CALLSIGN_LOOKUP_DATA):
if call.startswith(entry["prefix"]):
return entry
return None
# Infer an operator name from a callsign (requires QRZ.com)
def infer_name_from_callsign(self, call):
data = self.get_qrz_data_for_callsign(call) data = self.get_qrz_data_for_callsign(call)
if data and "fname" in data: if data and "fname" in data:
name = data["fname"] name = data["fname"]
if "name" in data: if "name" in data:
name = name + " " + data["name"] name = name + " " + data["name"]
return name return name
data = self.get_hamqth_data_for_callsign(call)
if data and "nick" in data:
return data["nick"]
else: else:
return None return None
# Infer a latitude and longitude from a callsign (requires QRZ.com) # Infer a latitude and longitude from a callsign (requires QRZ.com/HamQTH)
def infer_latlon_from_callsign_qrz(self, call): # Coordinates that look default are rejected (apologies if your position really is 0,0, enjoy your voyage)
def infer_latlon_from_callsign_online_lookup(self, call):
data = self.get_qrz_data_for_callsign(call) data = self.get_qrz_data_for_callsign(call)
if data and "latitude" in data and "longitude" in data: if data and "latitude" in data and "longitude" in data and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float(data["latitude"]) < 89.9:
return [data["latitude"], data["longitude"]]
data = self.get_hamqth_data_for_callsign(call)
if data and "latitude" in data and "longitude" in data and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float(data["latitude"]) < 89.9:
return [data["latitude"], data["longitude"]] return [data["latitude"], data["longitude"]]
else: else:
return None return None
# Infer a grid locator from a callsign (requires QRZ.com) # Infer a grid locator from a callsign (requires QRZ.com/HamQTH).
def infer_grid_from_callsign_qrz(self, call): # Grids that look default are rejected (apologies if your grid really is AA00aa, enjoy your research)
def infer_grid_from_callsign_online_lookup(self, call):
data = self.get_qrz_data_for_callsign(call) data = self.get_qrz_data_for_callsign(call)
if data and "locator" in data: if data and "locator" in data and data["locator"].upper() != "AA00" and data["locator"].upper() != "AA00AA" and data["locator"].upper() != "AA00AA00":
return data["locator"] return data["locator"]
data = self.get_hamqth_data_for_callsign(call)
if data and "grid" in data and data["grid"].upper() != "AA00" and data["grid"].upper() != "AA00AA" and data["grid"].upper() != "AA00AA00":
return data["grid"]
else:
return None
# Infer a textual QTH from a callsign (requires QRZ.com/HamQTH)
def infer_qth_from_callsign_online_lookup(self, call):
data = self.get_qrz_data_for_callsign(call)
if data and "addr2" in data:
return data["addr2"]
data = self.get_hamqth_data_for_callsign(call)
if data and "qth" in data:
return data["qth"]
else: else:
return None return None
@@ -375,19 +416,156 @@ class LookupHelper:
# Infer a grid locator from a callsign (using DXCC, probably very inaccurate) # Infer a grid locator from a callsign (using DXCC, probably very inaccurate)
def infer_grid_from_callsign_dxcc(self, call): def infer_grid_from_callsign_dxcc(self, call):
latlon = self.infer_latlon_from_callsign_dxcc(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. # Infer a mode from the frequency (in Hz) according to the band plan. Just a guess really.
def infer_mode_from_frequency(self, freq): def infer_mode_from_frequency(self, freq):
try: try:
return freq_to_band(freq / 1000.0)["mode"] khz = freq / 1000.0
mode = freq_to_band(khz)["mode"]
# Some additional common digimode ranges in addition to what the 3rd-party freq_to_band function returns.
# This is mostly here just because freq_to_band is very specific about things like FT8 frequencies, and e.g.
# a spot at 7074.5 kHz will be indicated as LSB, even though it's clearly in the FT8 range. Future updates
# might include other common digimode centres of activity here, but this achieves the main goal of keeping
# large numbers of clearly-FT* spots off the list of people filtering out digimodes.
if (7074 <= khz < 7077) or (10136 <= khz < 10139) or (14074 <= khz < 14077) or (18100 <= khz < 18103) or (
21074 <= khz < 21077) or (24915 <= khz < 24918) or (28074 <= khz < 28077):
mode = "FT8"
if (7047.5 <= khz < 7050.5) or (10140 <= khz < 10143) or (14080 <= khz < 14083) or (
18104 <= khz < 18107) or (21140 <= khz < 21143) or (24919 <= khz < 24922) or (28180 <= khz < 28183):
mode = "FT4"
return mode
except KeyError: except KeyError:
return None return None
# Utility method to get QRZ.com data from cache if possible, if not get it from the API and cache it
def get_qrz_data_for_callsign(self, call):
# Fetch from cache if we can, otherwise fetch from the API and cache it
if call in self.QRZ_CALLSIGN_DATA_CACHE:
return self.QRZ_CALLSIGN_DATA_CACHE.get(call)
elif self.QRZ_AVAILABLE:
try:
data = self.LOOKUP_LIB_QRZ.lookup_callsign(callsign=call)
self.QRZ_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
# QRZ had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call.
try:
data = self.LOOKUP_LIB_QRZ.lookup_callsign(callsign=callinfo.Callinfo.get_homecall(call))
self.QRZ_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
# QRZ had no info for the call, that's OK. Cache a None so we don't try to look this up again
self.QRZ_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
return None
except (Exception):
# General exception like a timeout when communicating with QRZ. Return None this time, but don't cache
# that, so we can try again next time.
logging.error("Exception when looking up QRZ data")
return None
else:
return None
# Utility method to get HamQTH data from cache if possible, if not get it from the API and cache it
def get_hamqth_data_for_callsign(self, call):
# Fetch from cache if we can, otherwise fetch from the API and cache it
if call in self.HAMQTH_CALLSIGN_DATA_CACHE:
return self.HAMQTH_CALLSIGN_DATA_CACHE.get(call)
elif self.HAMQTH_AVAILABLE:
try:
# First we need to log in and get a session token.
session_data = self.HAMQTH_SESSION_LOOKUP_CACHE.get(
self.HAMQTH_BASE_URL + "?u=" + urllib.parse.quote_plus(config["hamqth-username"]) +
"&p=" + urllib.parse.quote_plus(config["hamqth-password"]), headers=HTTP_HEADERS).content
dict_data = xmltodict.parse(session_data)
if "session_id" in dict_data["HamQTH"]["session"]:
session_id = dict_data["HamQTH"]["session"]["session_id"]
# Now look up the actual data.
try:
lookup_data = SEMI_STATIC_URL_DATA_CACHE.get(
self.HAMQTH_BASE_URL + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
call) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS).content
data = xmltodict.parse(lookup_data)["HamQTH"]["search"]
self.HAMQTH_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
# HamQTH had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call.
try:
lookup_data = SEMI_STATIC_URL_DATA_CACHE.get(
self.HAMQTH_BASE_URL + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
callinfo.Callinfo.get_homecall(call)) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS).content
data = xmltodict.parse(lookup_data)["HamQTH"]["search"]
self.HAMQTH_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
# HamQTH had no info for the call, that's OK. Cache a None so we don't try to look this up again
self.HAMQTH_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
return None
else:
logging.warn("HamQTH login details incorrect, failed to look up with HamQTH.")
except:
logging.error("Exception when looking up HamQTH data")
return None
# Utility method to get Clublog API data from cache if possible, if not get it from the API and cache it
def get_clublog_api_data_for_callsign(self, call):
# Fetch from cache if we can, otherwise fetch from the API and cache it
if call in self.CLUBLOG_CALLSIGN_DATA_CACHE:
return self.CLUBLOG_CALLSIGN_DATA_CACHE.get(call)
elif self.CLUBLOG_API_AVAILABLE:
try:
data = self.LOOKUP_LIB_CLUBLOG_API.lookup_callsign(callsign=call)
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
# Clublog had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call.
try:
data = self.LOOKUP_LIB_CLUBLOG_API.lookup_callsign(callsign=callinfo.Callinfo.get_homecall(call))
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
# Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
return None
except APIKeyMissingError:
# User API key was wrong, warn
logging.error("Could not look up via Clublog API, key " + self.CLUBLOG_API_KEY + " was rejected.")
return None
else:
return None
# Utility method to get Clublog XML data from file
def get_clublog_xml_data_for_callsign(self, call):
if self.CLUBLOG_XML_AVAILABLE:
try:
data = self.LOOKUP_LIB_CLUBLOG_XML.lookup_callsign(callsign=call)
return data
except (KeyError, ValueError):
# Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
return None
else:
return None
# Utility method to get generic DXCC data from our lookup table, if we can find it
def get_dxcc_data_for_callsign(self, call):
for entry in self.DXCC_DATA.values():
if re.match(entry["prefixRegex"], call):
return entry
return None
# Shutdown method to close down any caches neatly. # Shutdown method to close down any caches neatly.
def stop(self): def stop(self):
self.QRZ_CALLSIGN_DATA_CACHE.close() self.QRZ_CALLSIGN_DATA_CACHE.close()
self.CLUBLOG_CALLSIGN_DATA_CACHE.close() self.CLUBLOG_CALLSIGN_DATA_CACHE.close()
# Singleton object # Singleton object
lookup_helper = LookupHelper() lookup_helper = LookupHelper()

View File

@@ -0,0 +1,36 @@
from prometheus_client import CollectorRegistry, generate_latest, Counter, disable_created_metrics, Gauge
disable_created_metrics()
# Prometheus metrics registry
registry = CollectorRegistry()
page_requests_counter = Counter(
"spothole_page_requests",
"Total number of page requests received",
registry=registry,
)
api_requests_counter = Counter(
"spothole_api_requests",
"Total number of API requests received",
registry=registry
)
spots_gauge = Gauge(
"spothole_spots",
"Number of spots currently in the software",
registry=registry
)
alerts_gauge = Gauge(
"spothole_alerts",
"Number of alerts currently in the software",
registry=registry
)
memory_use_gauge = Gauge(
"spothole_memory_usage_bytes",
"Current memory usage of the software in bytes",
registry=registry
)
# Get a Prometheus metrics response for the web server
def get_metrics():
return generate_latest(registry)

View File

@@ -1,21 +1,157 @@
from core.constants import SIGS import csv
import logging
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. # 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): def get_ref_regex_for_sig(sig):
for s in SIGS: for s in SIGS:
if s.name == sig: if s.name.upper() == sig.upper():
return s.ref_regex return s.ref_regex
return None return None
# Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object which
# must at minimum have a "sig" and an "id". The rest of the object will be populated and returned.
# Note there is currently no support for KRMNPA location lookup, see issue #61.
def populate_sig_ref_info(sig_ref):
if sig_ref.sig is None or sig_ref.id is None:
logging.warning("Failed to look up sig_ref info, sig or id were not set.")
sig = sig_ref.sig
ref_id = sig_ref.id
try:
if sig.upper() == "POTA":
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api.pota.app/park/" + ref_id, headers=HTTP_HEADERS).json()
if data:
fullname = data["name"] if "name" in data else None
if fullname and "parktypeDesc" in data and data["parktypeDesc"] != "":
fullname = fullname + " " + data["parktypeDesc"]
sig_ref.name = fullname
sig_ref.url = "https://pota.app/#/park/" + ref_id
sig_ref.grid = data["grid6"] if "grid6" in data else None
sig_ref.latitude = data["latitude"] if "latitude" in data else None
sig_ref.longitude = data["longitude"] if "longitude" in data else None
elif sig.upper() == "SOTA":
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api-db2.sota.org.uk/api/summits/" + ref_id,
headers=HTTP_HEADERS).json()
if data:
sig_ref.name = data["name"] if "name" in data else None
sig_ref.url = "https://www.sotadata.org.uk/en/summit/" + ref_id
sig_ref.grid = data["locator"] if "locator" in data else None
sig_ref.latitude = data["latitude"] if "latitude" in data else None
sig_ref.longitude = data["longitude"] if "longitude" in data else None
sig_ref.activation_score = data["points"] if "points" in data else None
elif sig.upper() == "WWBOTA":
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + ref_id,
headers=HTTP_HEADERS).json()
if data:
sig_ref.name = data["name"] if "name" in data else None
sig_ref.url = "https://bunkerwiki.org/?s=" + ref_id if ref_id.startswith("B/G") else None
sig_ref.grid = data["locator"] if "locator" in data else None
sig_ref.latitude = data["lat"] if "lat" in data else None
sig_ref.longitude = data["long"] if "long" in data else None
elif sig.upper() == "GMA" or sig.upper() == "ARLHS" or sig.upper() == "ILLW" or sig.upper() == "WCA" or sig.upper() == "MOTA" or sig.upper() == "IOTA":
data = SEMI_STATIC_URL_DATA_CACHE.get("https://www.cqgma.org/api/ref/?" + ref_id,
headers=HTTP_HEADERS).json()
if data:
sig_ref.name = data["name"] if "name" in data else None
sig_ref.url = "https://www.cqgma.org/zinfo.php?ref=" + ref_id
sig_ref.grid = data["locator"] if "locator" in data else None
sig_ref.latitude = data["latitude"] if "latitude" in data else None
sig_ref.longitude = data["longitude"] if "longitude" in data else None
elif sig.upper() == "WWFF":
wwff_csv_data = SEMI_STATIC_URL_DATA_CACHE.get("https://wwff.co/wwff-data/wwff_directory.csv",
headers=HTTP_HEADERS)
wwff_dr = csv.DictReader(wwff_csv_data.content.decode().splitlines())
for row in wwff_dr:
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 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",
headers=HTTP_HEADERS)
siota_dr = csv.DictReader(siota_csv_data.content.decode().splitlines())
for row in siota_dr:
if row["SILO_CODE"] == ref_id:
sig_ref.name = row["NAME"] if "NAME" in row else None
sig_ref.grid = row["LOCATOR"] if "LOCATOR" in row else None
sig_ref.latitude = float(row["LAT"]) if "LAT" in row else None
sig_ref.longitude = float(row["LNG"]) if "LNG" in row else None
break
elif sig.upper() == "WOTA":
data = SEMI_STATIC_URL_DATA_CACHE.get("https://www.wota.org.uk/mapping/data/summits.json",
headers=HTTP_HEADERS).json()
if data:
for feature in data["features"]:
if feature["properties"]["wotaId"] == ref_id:
sig_ref.name = feature["properties"]["title"]
# Fudge WOTA URLs. Outlying fell (LDO) URLs don't match their ID numbers but require 214 to be
# added to them
sig_ref.url = "https://www.wota.org.uk/MM_" + ref_id
if ref_id.upper().startswith("LDO-"):
number = int(ref_id.upper().replace("LDO-", ""))
sig_ref.url = "https://www.wota.org.uk/MM_LDO-" + str(number + 214)
sig_ref.grid = feature["properties"]["qthLocator"]
sig_ref.latitude = feature["geometry"]["coordinates"][1]
sig_ref.longitude = feature["geometry"]["coordinates"][0]
break
elif sig.upper() == "ZLOTA":
data = SEMI_STATIC_URL_DATA_CACHE.get("https://ontheair.nz/assets/assets.json", headers=HTTP_HEADERS).json()
if data:
for asset in data:
if asset["code"] == ref_id:
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
elif sig.upper() == "BOTA":
if not sig_ref.name:
sig_ref.name = sig_ref.id
sig_ref.url = "https://www.beachesontheair.com/beaches/" + sig_ref.name.lower().replace(" ", "-")
elif sig.upper() == "LLOTA":
data = SEMI_STATIC_URL_DATA_CACHE.get("https://llota.app/api/public/references", headers=HTTP_HEADERS).json()
if data:
for ref in data:
if ref["reference_code"] == ref_id:
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() == "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.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
# Regex matching any SIG # Regex matching any SIG
ANY_SIG_REGEX = r"(" + r"|".join(list(map(lambda p: p.name, SIGS))) + r")" ANY_SIG_REGEX = r"(" + r"|".join(list(map(lambda p: p.name, SIGS))) + r")"
# Regex matching any SIG reference # Regex matching any SIG reference
ANY_XOTA_SIG_REF_REGEX = r"[\w\/]+\-\d+" ANY_XOTA_SIG_REF_REGEX = r"[\w\/]+\-\d+"

View File

@@ -7,6 +7,7 @@ import pytz
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from core.constants import SOFTWARE_VERSION from core.constants import SOFTWARE_VERSION
from core.prometheus_metrics_handler import memory_use_gauge, spots_gauge, alerts_gauge
# Provides a timed update of the application's status data. # Provides a timed update of the application's status data.
@@ -46,22 +47,33 @@ class StatusReporter:
self.status_data["spot_providers"] = list( self.status_data["spot_providers"] = list(
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status, map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
"last_updated": p.last_update_time.replace( "last_updated": p.last_update_time.replace(
tzinfo=pytz.UTC).timestamp() if p.last_update_time else 0, tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0,
"last_spot": p.last_spot_time.replace( "last_spot": p.last_spot_time.replace(
tzinfo=pytz.UTC).timestamp() if p.last_spot_time 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( self.status_data["alert_providers"] = list(
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status, map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
"last_updated": p.last_update_time.replace( "last_updated": p.last_update_time.replace(
tzinfo=pytz.UTC).timestamp() if p.last_update_time else 0}, tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
self.alert_providers)) self.alert_providers))
self.status_data["cleanup"] = {"status": self.cleanup_timer.status, self.status_data["cleanup"] = {"status": self.cleanup_timer.status,
"last_ran": self.cleanup_timer.last_cleanup_time.replace( "last_ran": self.cleanup_timer.last_cleanup_time.replace(
tzinfo=pytz.UTC).timestamp() if self.cleanup_timer.last_cleanup_time else 0} tzinfo=pytz.UTC).timestamp() if self.cleanup_timer.last_cleanup_time else 0}
self.status_data["webserver"] = {"status": self.web_server.status, self.status_data["webserver"] = {"status": self.web_server.web_server_metrics["status"],
"last_api_access": self.web_server.last_api_access_time.replace( "last_api_access": self.web_server.web_server_metrics[
tzinfo=pytz.UTC).timestamp() if self.web_server.last_api_access_time else 0, "last_api_access_time"].replace(
"last_page_access": self.web_server.last_page_access_time.replace( tzinfo=pytz.UTC).timestamp() if self.web_server.web_server_metrics[
tzinfo=pytz.UTC).timestamp() if self.web_server.last_page_access_time else 0} "last_api_access_time"] else 0,
"api_access_count": self.web_server.web_server_metrics["api_access_counter"],
"last_page_access": self.web_server.web_server_metrics[
"last_page_access_time"].replace(
tzinfo=pytz.UTC).timestamp() if self.web_server.web_server_metrics[
"last_page_access_time"] else 0,
"page_access_count": self.web_server.web_server_metrics["page_access_counter"]}
# Update Prometheus metrics
memory_use_gauge.set(psutil.Process(os.getpid()).memory_info().rss * 1024)
spots_gauge.set(len(self.spots))
alerts_gauge.set(len(self.alerts))
self.run_timer = Timer(self.run_interval, self.run) self.run_timer = Timer(self.run_interval, self.run)
self.run_timer.start() self.run_timer.start()

5
core/utils.py Normal file
View File

@@ -0,0 +1,5 @@
# Convert objects to serialisable things. Used by JSON serialiser as a default when it encounters unserializable things.
# Just converts objects to dict. Try to avoid doing anything clever here when serialising spots, because we also need
# to receive spots without complex handling.
def serialize_everything(obj):
return obj.__dict__

View File

@@ -1,15 +1,13 @@
import copy import copy
import hashlib import hashlib
import json import json
import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
import pytz import pytz
from core.constants import DXCC_FLAGS
from core.lookup_helper import lookup_helper from core.lookup_helper import lookup_helper
from core.sig_utils import get_icon_for_sig from core.sig_utils import populate_sig_ref_info
# Data class that defines an alert. # Data class that defines an alert.
@@ -55,12 +53,6 @@ class Alert:
sig: str = None sig: str = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO # SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
sig_refs: list = None sig_refs: list = None
# SIG reference names
sig_refs_names: 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. # Whether this alert is for a DXpedition, as opposed to e.g. an xOTA programme.
is_dxpedition: bool = False is_dxpedition: bool = False
# Where we got the alert from, e.g. "POTA", "SOTA"... # Where we got the alert from, e.g. "POTA", "SOTA"...
@@ -98,23 +90,26 @@ class Alert:
self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_calls[0]) self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_calls[0])
if self.dx_calls and self.dx_calls[0] and not self.dx_dxcc_id: if self.dx_calls and self.dx_calls[0] and not self.dx_dxcc_id:
self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_calls[0]) self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_calls[0])
if self.dx_dxcc_id and self.dx_dxcc_id in DXCC_FLAGS and not self.dx_flag: if self.dx_dxcc_id and not self.dx_flag:
self.dx_flag = DXCC_FLAGS[self.dx_dxcc_id] self.dx_flag = lookup_helper.get_flag_for_dxcc(self.dx_dxcc_id)
# Icon from SIG # Fetch SIG data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a reference
if self.sig and not self.icon: # in its initial call, we use this code to populate the rest of the data. This includes working out grid refs
self.icon = get_icon_for_sig(self.sig) # from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
if self.sig_refs and len(self.sig_refs) > 0:
for sig_ref in self.sig_refs:
populate_sig_ref_info(sig_ref)
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
# and apply it to the whole spot.
if self.sig_refs and len(self.sig_refs) > 0 and not self.sig:
self.sig = self.sig_refs[0].sig
# DX operator details lookup, using QRZ.com. This should be the last resort compared to taking the data from # 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 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. # the one from the park reference they're at.
if self.dx_calls and not self.dx_names: if self.dx_calls and not self.dx_names:
self.dx_names = list(map(lambda c: lookup_helper.infer_name_from_callsign(c), self.dx_calls)) self.dx_names = list(map(lambda c: lookup_helper.infer_name_from_callsign_online_lookup(c), self.dx_calls))
# Clean up comments
if self.comment:
comment = re.sub(r"\(de [A-Za-z0-9]*\)", "", self.comment)
self.comment = comment.strip()
# Always create an ID based on a hash of every parameter *except* received_time. This is used as the index # Always create an ID based on a hash of every parameter *except* received_time. This is used as the index
# to a map, which as a byproduct avoids us having multiple duplicate copies of the object that are identical # to a map, which as a byproduct avoids us having multiple duplicate copies of the object that are identical
@@ -131,7 +126,7 @@ class Alert:
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True) return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
# Decide if this alert has expired (in which case it should not be added to the system in the first place, and not # Decide if this alert has expired (in which case it should not be added to the system in the first place, and not
# returned by the web server if later requested, and removed by the cleanup functions. "Expired" is defined as # returned by the web server if later requested, and removed by the cleanup functions). "Expired" is defined as
# either having an end_time in the past, or if it only has a start_time, then that start time was more than 3 hours # either having an end_time in the past, or if it only has a start_time, then that start time was more than 3 hours
# ago. If it somehow doesn't have a start_time either, it is considered to be expired. # ago. If it somehow doesn't have a start_time either, it is considered to be expired.
def expired(self): def expired(self):

View File

@@ -8,8 +8,4 @@ class Band:
# Start frequency, in Hz # Start frequency, in Hz
start_freq: float start_freq: float
# Stop frequency, in Hz # Stop frequency, in Hz
end_freq: float 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

View File

@@ -7,8 +7,5 @@ class SIG:
name: str name: str
# Description, e.g. "Parks on the Air" # Description, e.g. "Parks on the Air"
description: str 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+". # Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
ref_regex: str ref_regex: str = None

22
data/sig_ref.py Normal file
View File

@@ -0,0 +1,22 @@
from dataclasses import dataclass
# Data class that defines a Special Interest Group "info" or reference. As well as the basic reference ID we include a
# name and a lookup URL.
@dataclass
class SIGRef:
# Reference ID, e.g. "GB-0001".
id: str
# SIG that this reference is in, e.g. "POTA".
sig: str
# Name of the reference, e.g. "Null Country Park", if known.
name: str = None
# URL to look up more information about the reference, if known.
url: str = None
# Latitude of the reference, if known.
latitude: float = None
# Longitude of the reference, if known.
longitude: float = None
# Maidenhead grid reference of the reference, if known.
grid: str = None
# Activation score. SOTA only
activation_score: int = None

View File

@@ -4,15 +4,16 @@ import json
import logging import logging
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime, timedelta
import pytz import pytz
from pyhamtools.locator import locator_to_latlong, latlong_to_locator from pyhamtools.locator import locator_to_latlong, latlong_to_locator
from core.constants import DXCC_FLAGS from core.config import MAX_SPOT_AGE
from core.geo_utils import wab_wai_square_to_lat_lon from core.constants import MODE_ALIASES
from core.lookup_helper import lookup_helper from core.lookup_helper import lookup_helper
from core.sig_utils import get_icon_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
# Data class that defines a spot. # Data class that defines a spot.
@@ -27,6 +28,9 @@ class Spot:
dx_call: str = None dx_call: str = None
# Name of the operator that has been spotted # Name of the operator that has been spotted
dx_name: str = None dx_name: str = None
# QTH of the operator that has been spotted. This could be from any SIG refs or could be from online lookup of their
# home QTH.
dx_qth: str = None
# Country of the DX operator # Country of the DX operator
dx_country: str = None dx_country: str = None
# Country flag of the DX operator # Country flag of the DX operator
@@ -35,15 +39,12 @@ class Spot:
dx_continent: str = None dx_continent: str = None
# DXCC ID of the DX operator # DXCC ID of the DX operator
dx_dxcc_id: int = None dx_dxcc_id: int = None
# DXCC ID of the spotter
de_dxcc_id: int = None
# CQ zone of the DX operator # CQ zone of the DX operator
dx_cq_zone: int = None dx_cq_zone: int = None
# ITU zone of the DX operator # ITU zone of the DX operator
dx_itu_zone: int = None dx_itu_zone: int = None
# If this is an APRS spot, what SSID was the DX operator using? # If this is an APRS/Packet/etc spot, what SSID was the DX operator using?
# This is a string not an int for now, as I often see non-numeric ones somehow dx_ssid: str = None
dx_aprs_ssid: str = None
# Maidenhead grid locator for the DX. This could be from a geographical reference e.g. POTA, or just from the # Maidenhead grid locator for the DX. This could be from a geographical reference e.g. POTA, or just from the
# country # country
dx_grid: str = None dx_grid: str = None
@@ -51,11 +52,12 @@ class Spot:
# lookup # lookup
dx_latitude: float = None dx_latitude: float = None
dx_longitude: float = None dx_longitude: float = None
# DX Location source. Indicates how accurate the location might be. Values: "SPOT", "WAB/WAI GRID", "QRZ", "DXCC", "NONE" # DX Location source. Indicates how accurate the location might be. Values: "SPOT", "WAB/WAI GRID", "HOME QTH",
# "DXCC", "NONE"
dx_location_source: str = "NONE" dx_location_source: str = "NONE"
# DX Location good. Indicates that the software thinks the location data is good enough to plot on a map. This is # DX Location good. Indicates that the software thinks the location data is good enough to plot on a map. This is
# true if the location source is "SPOT" or "WAB/WAI GRID", or if the location source is "QRZ" and the DX callsign # true if the location source is "SPOT" or "WAB/WAI GRID", or if the location source is "HOME QTH" and the DX
# doesn't have a suffix like /P. # callsign doesn't have a suffix like /P.
dx_location_good: bool = False dx_location_good: bool = False
# DE (Spotter) info # DE (Spotter) info
@@ -68,6 +70,10 @@ class Spot:
de_flag: str = None de_flag: str = None
# Continent of the spotter # Continent of the spotter
de_continent: str = None de_continent: str = None
# DXCC ID of the spotter
de_dxcc_id: int = None
# If this is an APRS/Packet/etc spot, what SSID was the spotter/receiver using?
de_ssid: str = None
# Maidenhead grid locator for the spotter. This is not going to be from a xOTA reference so it will likely just be # Maidenhead grid locator for the spotter. This is not going to be from a xOTA reference so it will likely just be
# a QRZ or DXCC lookup. If the spotter is also portable, this is probably wrong, but it's good enough for some # a QRZ or DXCC lookup. If the spotter is also portable, this is probably wrong, but it's good enough for some
# simple mapping. # simple mapping.
@@ -101,22 +107,6 @@ class Spot:
sig: str = None sig: str = None
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO # SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
sig_refs: list = None sig_refs: list = None
# SIG reference names
sig_refs_names: list = None
# SIG reference URLs
sig_refs_urls: 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 # Timing info
@@ -157,7 +147,10 @@ class Spot:
# Clean up DX call if it has an SSID or -# from RBN # Clean up DX call if it has an SSID or -# from RBN
if self.dx_call and "-" in self.dx_call: if self.dx_call and "-" in self.dx_call:
self.dx_call = self.dx_call.split("-")[0] split = self.dx_call.split("-")
self.dx_call = split[0]
if len(split) > 1 and split[1] != "#":
self.dx_ssid = split[1]
# DX country, continent, zones etc. from callsign # DX country, continent, zones etc. from callsign
if self.dx_call and not self.dx_country: if self.dx_call and not self.dx_country:
@@ -170,31 +163,46 @@ class Spot:
self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_call) self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_call)
if self.dx_call and not self.dx_dxcc_id: if self.dx_call and not self.dx_dxcc_id:
self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_call) self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_call)
if self.dx_dxcc_id and self.dx_dxcc_id in DXCC_FLAGS and not self.dx_flag: if self.dx_dxcc_id and not self.dx_flag:
self.dx_flag = DXCC_FLAGS[self.dx_dxcc_id] self.dx_flag = lookup_helper.get_flag_for_dxcc(self.dx_dxcc_id)
# Clean up spotter call if it has an SSID or -# from RBN # Clean up spotter call if it has an SSID or -# from RBN
if self.de_call and "-" in self.de_call: if self.de_call and "-" in self.de_call:
self.de_call = self.de_call.split("-")[0] split = self.de_call.split("-")
self.de_call = split[0]
if len(split) > 1 and split[1] != "#":
self.de_ssid = split[1]
# If we have a spotter of "RBNHOLE", we should have the actual spotter callsign in the comment, so extract it.
# RBNHole posts come from a number of providers, so it's dealt with here in the generic spot handling code.
if self.de_call == "RBNHOLE" and self.comment:
rbnhole_call_match = re.search(r"\Wat ([a-z0-9/]+)\W", self.comment, re.IGNORECASE)
if rbnhole_call_match:
self.de_call = rbnhole_call_match.group(1).upper()
# If we have a spotter of "SOTAMAT", we might have the actual spotter callsign in the comment, if so extract it.
# SOTAMAT can do POTA as well as SOTA, so it's dealt with here in the generic spot handling code.
if self.de_call == "SOTAMAT" and self.comment:
sotamat_call_match = re.search(r"\Wfrom ([a-z0-9/]+)]", self.comment, re.IGNORECASE)
if sotamat_call_match:
self.de_call = sotamat_call_match.group(1).upper()
# Spotter country, continent, zones etc. from callsign. # Spotter country, continent, zones etc. from callsign.
# DE of "RBNHOLE" and "SOTAMAT" are not things we can look up location for # DE call with no digits, or APRS servers starting "T2" are not things we can look up location for
if self.de_call != "RBNHOLE" and self.de_call != "SOTAMAT": if self.de_call and any(char.isdigit() for char in self.de_call) and not (self.de_call.startswith("T2") and self.source == "APRS-IS"):
if self.de_call and not self.de_country: if not self.de_country:
self.de_country = lookup_helper.infer_country_from_callsign(self.de_call) self.de_country = lookup_helper.infer_country_from_callsign(self.de_call)
if self.de_call and not self.de_continent: if not self.de_continent:
self.de_continent = lookup_helper.infer_continent_from_callsign(self.de_call) self.de_continent = lookup_helper.infer_continent_from_callsign(self.de_call)
if self.de_call and not self.de_dxcc_id: if not self.de_dxcc_id:
self.de_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.de_call) self.de_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.de_call)
if self.de_dxcc_id and self.de_dxcc_id in DXCC_FLAGS and not self.de_flag: if self.de_dxcc_id and not self.de_flag:
self.de_flag = DXCC_FLAGS[self.de_dxcc_id] self.de_flag = lookup_helper.get_flag_for_dxcc(self.de_dxcc_id)
# Band from frequency # Band from frequency
if self.freq and not self.band: if self.freq and not self.band:
band = lookup_helper.infer_band_from_freq(self.freq) band = lookup_helper.infer_band_from_freq(self.freq)
self.band = band.name self.band = band.name
self.band_color = band.color
self.band_contrast_color = band.contrast_color
# Mode from comments or bandplan # Mode from comments or bandplan
if self.mode: if self.mode:
@@ -206,69 +214,114 @@ class Spot:
self.mode = lookup_helper.infer_mode_from_frequency(self.freq) self.mode = lookup_helper.infer_mode_from_frequency(self.freq)
self.mode_source = "BANDPLAN" self.mode_source = "BANDPLAN"
# Normalise "generic digital" modes. "DIGITAL", "DIGI" and "DATA" are just the same thing with no extra # Normalise mode if necessary.
# information, so standardise on "DATA" if self.mode in MODE_ALIASES:
if self.mode == "DIGI" or self.mode == "DIGITAL": self.mode = MODE_ALIASES[self.mode]
self.mode = "DATA"
# Mode type from mode # Mode type from mode
if self.mode and not self.mode_type: if self.mode and not self.mode_type:
self.mode_type = lookup_helper.infer_mode_type_from_mode(self.mode) self.mode_type = lookup_helper.infer_mode_type_from_mode(self.mode)
# Icon from SIG # If we have a latitude or grid at this point, it can only have been provided by the spot itself
if self.sig and not self.icon: if self.dx_latitude or self.dx_grid:
self.icon = get_icon_for_sig(self.sig) self.dx_location_source = "SPOT"
# DX Grid to lat/lon and vice versa # Set the top-level "SIG" if it is missing but we have at least one SIG ref.
if not self.sig and self.sig_refs and len(self.sig_refs) > 0:
self.sig = self.sig_refs[0].sig.upper()
# See if we already have a SIG reference, but the comment looks like it contains more for the same SIG. This
# should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002".
if self.comment and self.sig_refs and len(self.sig_refs) > 0 and self.sig_refs[0].sig:
sig = self.sig_refs[0].sig.upper()
regex = get_ref_regex_for_sig(sig)
if regex:
all_comment_ref_matches = re.finditer(r"(^|\W)(" + regex + r")(^|\W)", self.comment, re.IGNORECASE)
for ref_match in all_comment_ref_matches:
self.append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig))
# See if the comment looks like it contains any SIGs (and optionally SIG references) that we can
# add to the spot. This should catch cluster spot comments like "POTA GB-0001 WWFF GFF-0001" and e.g. POTA
# comments like "also WWFF GFF-0001".
if self.comment:
sig_matches = re.finditer(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
for sig_match in sig_matches:
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster
# spots where the comment is just "POTA".
found_sig = sig_match.group(2).upper()
if not self.sig:
self.sig = found_sig
# Now look to see if that SIG name was followed by something that looks like a reference ID for that SIG.
# If so, add that to the sig_refs list for this spot.
ref_regex = get_ref_regex_for_sig(found_sig)
if ref_regex:
ref_matches = re.finditer(r"(^|\W)" + found_sig + r"($|\W)(" + ref_regex + r")($|\W)", self.comment, re.IGNORECASE)
for ref_match in ref_matches:
self.append_sig_ref_if_missing(SIGRef(id=ref_match.group(3).upper(), sig=found_sig))
# Fetch SIG data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a reference
# in its initial call, we use this code to populate the rest of the data. This includes working out grid refs
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
if self.sig_refs and len(self.sig_refs) > 0:
for sig_ref in self.sig_refs:
sig_ref = populate_sig_ref_info(sig_ref)
# If the spot itself doesn't have location yet, but the SIG ref does, extract it
if sig_ref.grid and not self.dx_grid:
self.dx_grid = sig_ref.grid
if sig_ref.latitude and not self.dx_latitude:
self.dx_latitude = sig_ref.latitude
self.dx_longitude = sig_ref.longitude
if self.sig == "WAB" or self.sig == "WAI":
self.dx_location_source = "WAB/WAI GRID"
else:
self.dx_location_source = "SIG REF LOOKUP"
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
# and apply it to the whole spot.
if self.sig_refs and len(self.sig_refs) > 0 and not self.sig:
self.sig = self.sig_refs[0].sig
# DX Grid to lat/lon and vice versa in case one is missing
if self.dx_grid and not self.dx_latitude: if self.dx_grid and not self.dx_latitude:
ll = locator_to_latlong(self.dx_grid) try:
self.dx_latitude = ll[0] print(json.dumps(self))
self.dx_longitude = ll[1] 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: if self.dx_latitude and self.dx_longitude and not self.dx_grid:
try: try:
self.dx_grid = latlong_to_locator(self.dx_latitude, self.dx_longitude, 8) self.dx_grid = latlong_to_locator(self.dx_latitude, self.dx_longitude, 8)
except: except:
logging.debug("Invalid lat/lon received for spot") logging.debug("Invalid lat/lon received for spot")
if self.dx_latitude:
self.dx_location_source = "SPOT"
# WAB/WAI grid to lat/lon
if not self.dx_latitude and self.sig and self.sig_refs and len(self.sig_refs) > 0 and (
self.sig == "WAB" or self.sig == "WAI"):
ll = wab_wai_square_to_lat_lon(self.sig_refs[0])
if ll:
self.dx_latitude = ll[0]
self.dx_longitude = ll[1]
try:
self.dx_grid = latlong_to_locator(self.dx_latitude, self.dx_longitude, 8)
except:
logging.debug("Invalid lat/lon received from WAB/WAI grid")
self.dx_location_source = "WAB/WAI GRID"
# QRT comment detection # QRT comment detection
if self.comment and not self.qrt: if self.comment and not self.qrt:
self.qrt = "QRT" in self.comment.upper() self.qrt = "QRT" in self.comment.upper()
# Clean up comments
if self.comment:
comment = re.sub(r"\(de [A-Za-z0-9]*\)", "", self.comment)
comment = re.sub(r"\[.*]:", "", comment)
comment = re.sub(r"\[.*]", "", comment)
comment = re.sub(r"\"\"", "", comment)
self.comment = comment.strip()
# DX operator details lookup, using QRZ.com. This should be the last resort compared to taking the data from # DX operator details lookup, using QRZ.com. This should be the last resort compared to taking the data from
# the actual spotting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon instead of # the actual spotting 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. # the one from the park reference they're at.
if self.dx_call and not self.dx_name: if self.dx_call and not self.dx_name:
self.dx_name = lookup_helper.infer_name_from_callsign(self.dx_call) self.dx_name = lookup_helper.infer_name_from_callsign_online_lookup(self.dx_call)
if self.dx_call and not self.dx_latitude: if self.dx_call and not self.dx_latitude:
latlon = lookup_helper.infer_latlon_from_callsign_qrz(self.dx_call) latlon = lookup_helper.infer_latlon_from_callsign_online_lookup(self.dx_call)
if latlon: if latlon:
self.dx_latitude = latlon[0] self.dx_latitude = latlon[0]
self.dx_longitude = latlon[1] self.dx_longitude = latlon[1]
self.dx_grid = lookup_helper.infer_grid_from_callsign_qrz(self.dx_call) self.dx_grid = lookup_helper.infer_grid_from_callsign_online_lookup(self.dx_call)
self.dx_location_source = "QRZ" self.dx_location_source = "HOME QTH"
# Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable stirng,
# otherwise see what they have set on an online lookup service.
if self.sig_refs and len(self.sig_refs) > 0:
self.dx_qth = self.sig_refs[0].id
if self.sig_refs[0].name:
self.dx_qth = self.dx_qth + " " + self.sig_refs[0].name
else:
self.dx_qth = lookup_helper.infer_qth_from_callsign_online_lookup(self.dx_call)
# Last resort for getting a DX position, use the DXCC entity. # Last resort for getting a DX position, use the DXCC entity.
if self.dx_call and not self.dx_latitude: if self.dx_call and not self.dx_latitude:
@@ -281,21 +334,23 @@ class Spot:
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator # DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
# is likely at home. # is likely at home.
self.dx_location_good = self.dx_location_source == "SPOT" or self.dx_location_source == "WAB/WAI GRID" or ( self.dx_location_good = self.dx_latitude and self.dx_longitude and (
self.dx_location_source == "QRZ" and not "/" in self.dx_call) self.dx_location_source == "SPOT" or self.dx_location_source == "SIG REF LOOKUP"
or self.dx_location_source == "WAB/WAI GRID"
or (self.dx_location_source == "HOME QTH" and not "/" in self.dx_call))
# DE of "RBNHOLE", "SOTAMAT" and "ZLOTA" are not things we can look up location for # DE with no digits and APRS servers starting "T2" are not things we can look up location for
if self.de_call != "RBNHOLE" and self.de_call != "SOTAMAT" and self.de_call != "ZLOTA": if self.de_call and any(char.isdigit() for char in self.de_call) and not (self.de_call.startswith("T2") and self.source == "APRS-IS"):
# DE operator position lookup, using QRZ.com. # DE operator position lookup, using QRZ.com.
if self.de_call and not self.de_latitude: if not self.de_latitude:
latlon = lookup_helper.infer_latlon_from_callsign_qrz(self.de_call) latlon = lookup_helper.infer_latlon_from_callsign_online_lookup(self.de_call)
if latlon: if latlon:
self.de_latitude = latlon[0] self.de_latitude = latlon[0]
self.de_longitude = latlon[1] self.de_longitude = latlon[1]
self.de_grid = lookup_helper.infer_grid_from_callsign_qrz(self.de_call) self.de_grid = lookup_helper.infer_grid_from_callsign_online_lookup(self.de_call)
# Last resort for getting a DE position, use the DXCC entity. # Last resort for getting a DE position, use the DXCC entity.
if self.de_call and not self.de_latitude: if not self.de_latitude:
latlon = lookup_helper.infer_latlon_from_callsign_dxcc(self.de_call) latlon = lookup_helper.infer_latlon_from_callsign_dxcc(self.de_call)
if latlon: if latlon:
self.de_latitude = latlon[0] self.de_latitude = latlon[0]
@@ -312,6 +367,26 @@ class Spot:
self_copy.received_time_iso = "" self_copy.received_time_iso = ""
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest() self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
# JSON serialise # JSON sspoterialise
def to_json(self): def to_json(self):
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True) return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
# Append a sig_ref to the list, so long as it's not already there.
def append_sig_ref_if_missing(self, new_sig_ref):
if not self.sig_refs:
self.sig_refs = []
new_sig_ref.id = new_sig_ref.id.strip().upper()
new_sig_ref.sig = new_sig_ref.sig.strip().upper()
if new_sig_ref.id == "":
return
for sig_ref in self.sig_refs:
if sig_ref.id == new_sig_ref.id and sig_ref.sig == new_sig_ref.sig:
return
self.sig_refs.append(new_sig_ref)
# Decide if this spot has expired (in which case it should not be added to the system in the first place, and not
# returned by the web server if later requested, and removed by the cleanup functions). "Expired" is defined as
# either having a time further ago than the server's MAX_SPOT_AGE. If it somehow doesn't have a time either, it is
# considered to be expired.
def expired(self):
return not self.time or self.time < (datetime.now(pytz.UTC) - timedelta(seconds=MAX_SPOT_AGE)).timestamp()

18
datafiles/39c3-tota.csv Normal file
View File

@@ -0,0 +1,18 @@
ref,lat,lon
T-01,53.56278090617755,9.984341869295505
T-02,53.562383404176416,9.98551893027115
T-03,53.56170184391514,9.985416035619778
T-04,53.562026534393176,9.986372919078974
T-11,53.56284641242506,9.98475590239655
T-12,53.562431705517035,9.98551675702443
T-13,53.56223704898424,9.985774520335664
T-14,53.5617893512591,9.986344302837976
T-21,53.56284641242506,9.98475590239655
T-22,53.56245816412497,9.985456089490567
T-23,53.56199560857136,9.985636761412673
T-24,53.5617893512591,9.986344302837976
T-31,53.56247470064887,9.985611427551902
T-32,53.5617893512591,9.986344302837976
T-41,53.56245039134992,9.985486136112701
T-91,53.56147934973529,9.984626806439744
T-92,53.561396810300735,9.987553052152899
1 ref lat lon
2 T-01 53.56278090617755 9.984341869295505
3 T-02 53.562383404176416 9.98551893027115
4 T-03 53.56170184391514 9.985416035619778
5 T-04 53.562026534393176 9.986372919078974
6 T-11 53.56284641242506 9.98475590239655
7 T-12 53.562431705517035 9.98551675702443
8 T-13 53.56223704898424 9.985774520335664
9 T-14 53.5617893512591 9.986344302837976
10 T-21 53.56284641242506 9.98475590239655
11 T-22 53.56245816412497 9.985456089490567
12 T-23 53.56199560857136 9.985636761412673
13 T-24 53.5617893512591 9.986344302837976
14 T-31 53.56247470064887 9.985611427551902
15 T-32 53.5617893512591 9.986344302837976
16 T-41 53.56245039134992 9.985486136112701
17 T-91 53.56147934973529 9.984626806439744
18 T-92 53.561396810300735 9.987553052152899

BIN
images/screenshot3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

View File

@@ -1,5 +1,4 @@
pyyaml~=6.0.3 pyyaml~=6.0.3
bottle~=0.13.4
requests-cache~=1.2.1 requests-cache~=1.2.1
pyhamtools~=0.12.0 pyhamtools~=0.12.0
telnetlib3~=2.0.8 telnetlib3~=2.0.8
@@ -10,4 +9,9 @@ diskcache~=5.6.3
psutil~=7.1.0 psutil~=7.1.0
requests-sse~=0.5.2 requests-sse~=0.5.2
rss-parser~=2.1.1 rss-parser~=2.1.1
pyproj~=3.7.2 pyproj~=3.7.2
prometheus_client~=0.23.1
beautifulsoup4~=4.14.2
websocket-client~=1.9.0
tornado~=6.5.4
tornado_eventsource~=3.0.0

View File

@@ -0,0 +1,142 @@
import json
import logging
import re
from datetime import datetime
import pytz
import tornado
from core.config import ALLOW_SPOTTING, MAX_SPOT_AGE
from core.constants import UNKNOWN_BAND
from core.lookup_helper import lookup_helper
from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig
from core.utils import serialize_everything
from data.sig_ref import SIGRef
from data.spot import Spot
# API request handler for /api/v1/spot (POST)
class APISpotHandler(tornado.web.RequestHandler):
def initialize(self, spots, web_server_metrics):
self.spots = spots
self.web_server_metrics = web_server_metrics
def post(self):
try:
# Metrics
self.web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self.web_server_metrics["api_access_counter"] += 1
self.web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# Reject if not allowed
if not ALLOW_SPOTTING:
self.set_status(401)
self.write(json.dumps("Error - this server does not allow new spots to be added via the API.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Reject if format not json
if 'Content-Type' not in self.request.headers or self.request.headers.get('Content-Type') != "application/json":
self.set_status(415)
self.write(json.dumps("Error - request Content-Type must be application/json", default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Reject if request body is empty
post_data = self.request.body
if not post_data:
self.set_status(422)
self.write(json.dumps("Error - request body is empty", default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Read in the request body as JSON then convert to a Spot object
json_spot = tornado.escape.json_decode(post_data)
spot = Spot(**json_spot)
# Converting to a spot object this way won't have coped with sig_ref objects, so fix that. (Would be nice to
# redo this in a functional style)
if spot.sig_refs:
real_sig_refs = []
for dict_obj in spot.sig_refs:
real_sig_refs.append(json.loads(json.dumps(dict_obj), object_hook=lambda d: SIGRef(**d)))
spot.sig_refs = real_sig_refs
# Reject if no timestamp, frequency, dx_call or de_call
if not spot.time or not spot.dx_call or not spot.freq or not spot.de_call:
self.set_status(422)
self.write(json.dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Reject invalid-looking callsigns
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call):
self.set_status(422)
self.write(json.dumps("Error - '" + spot.dx_call + "' does not look like a valid callsign.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call):
self.set_status(422)
self.write(json.dumps("Error - '" + spot.de_call + "' does not look like a valid callsign.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Reject if frequency not in a known band
if lookup_helper.infer_band_from_freq(spot.freq) == UNKNOWN_BAND:
self.set_status(422)
self.write(json.dumps("Error - Frequency of " + str(spot.freq / 1000.0) + "kHz is not in a known band.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Reject if grid formatting incorrect
if spot.dx_grid and not re.match(
r"^([A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}|[A-R]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2})$",
spot.dx_grid.upper()):
self.set_status(422)
self.write(json.dumps("Error - '" + spot.dx_grid + "' does not look like a valid Maidenhead grid.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Reject if sig_ref format incorrect for sig
if spot.sig and spot.sig_refs and len(spot.sig_refs) > 0 and spot.sig_refs[0].id and get_ref_regex_for_sig(
spot.sig) and not re.match(get_ref_regex_for_sig(spot.sig), spot.sig_refs[0].id):
self.set_status(422)
self.write(json.dumps(
"Error - '" + spot.sig_refs[0].id + "' does not look like a valid reference for " + spot.sig + ".",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# infer missing data, and add it to our database.
spot.source = "API"
spot.infer_missing()
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
self.write(json.dumps("OK", default=serialize_everything))
self.set_status(201)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
except Exception as e:
logging.error(e)
self.write(json.dumps("Error - " + str(e), default=serialize_everything))
self.set_status(500)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")

View File

@@ -0,0 +1,179 @@
import json
import logging
from datetime import datetime
from queue import Queue
import pytz
import tornado
import tornado_eventsource.handler
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything
SSE_HANDLER_MAX_QUEUE_SIZE = 100
SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
# API request handler for /api/v1/alerts
class APIAlertsHandler(tornado.web.RequestHandler):
def initialize(self, alerts, web_server_metrics):
self.alerts = alerts
self.web_server_metrics = web_server_metrics
def get(self):
try:
# Metrics
self.web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self.web_server_metrics["api_access_counter"] += 1
self.web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# Fetch all alerts matching the query
data = get_alert_list_with_filters(self.alerts, query_params)
self.write(json.dumps(data, default=serialize_everything))
self.set_status(200)
except ValueError as e:
logging.error(e)
self.write(json.dumps("Bad request - " + str(e), default=serialize_everything))
self.set_status(400)
except Exception as e:
logging.error(e)
self.write(json.dumps("Error - " + str(e), default=serialize_everything))
self.set_status(500)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
# API request handler for /api/v1/alerts/stream
class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
def initialize(self, sse_alert_queues, web_server_metrics):
self.sse_alert_queues = sse_alert_queues
self.web_server_metrics = web_server_metrics
# Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data
def custom_headers(self):
return {"Cache-Control": "no-store",
"X-Accel-Buffering": "no"}
def open(self):
try:
# Metrics
self.web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self.web_server_metrics["api_access_counter"] += 1
self.web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
self.query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# Create a alert queue and add it to the web server's list. The web server will fill this when alerts arrive
self.alert_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE)
self.sse_alert_queues.append(self.alert_queue)
# Set up a timed callback to check if anything is in the queue
self.heartbeat = tornado.ioloop.PeriodicCallback(self._callback, SSE_HANDLER_QUEUE_CHECK_INTERVAL)
self.heartbeat.start()
except Exception as e:
logging.warn("Exception when serving SSE socket", e)
# When the user closes the socket, empty our queue and remove it from the list so the server no longer fills it
def close(self):
try:
if self.alert_queue in self.sse_alert_queues:
self.sse_alert_queues.remove(self.alert_queue)
self.alert_queue.empty()
except:
pass
self.alert_queue = None
super().close()
# Callback to check if anything has arrived in the queue, and if so send it to the client
def _callback(self):
try:
if self.alert_queue:
while not self.alert_queue.empty():
alert = self.alert_queue.get()
# If the new alert matches our param filters, send it to the client. If not, ignore it.
if alert_allowed_by_query(alert, self.query_params):
self.write_message(msg=json.dumps(alert, default=serialize_everything))
if self.alert_queue not in self.sse_alert_queues:
logging.error("Web server cleared up a queue of an active connection!")
self.close()
except:
logging.warn("Exception in SSE callback, connection will be closed.")
self.close()
# Utility method to apply filters to the overall alert list and return only a subset. Enables query parameters in
# the main "alerts" GET call.
def get_alert_list_with_filters(all_alerts, query):
# Create a shallow copy of the alert list ordered by start time, then filter the list to reduce it only to alerts
# that match the filter parameters in the query string. Finally, apply a limit to the number of alerts returned.
# The list of query string filters is defined in the API docs.
alert_ids = list(all_alerts.iterkeys())
alerts = []
for k in alert_ids:
a = all_alerts.get(k)
if a is not None:
alerts.append(a)
alerts = sorted(alerts, key=lambda alert: (alert.start_time if alert and alert.start_time else 0))
alerts = list(filter(lambda alert: alert_allowed_by_query(alert, query), alerts))
if "limit" in query.keys():
alerts = alerts[:int(query.get("limit"))]
return alerts
# Given URL query params and an alert, figure out if the alert "passes" the requested filters or is rejected. The list
# of query parameters and their function is defined in the API docs.
def alert_allowed_by_query(alert, query):
for k in query.keys():
match k:
case "received_since":
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC)
if not alert.received_time or alert.received_time <= since:
return False
case "max_duration":
max_duration = int(query.get(k))
# Check the duration if end_time is provided. If end_time is not provided, assume the activation is
# "short", i.e. it always passes this check. If dxpeditions_skip_max_duration_check is true and
# the alert is a dxpedition, it also always passes the check.
if alert.is_dxpedition and (bool(query.get(
"dxpeditions_skip_max_duration_check")) if "dxpeditions_skip_max_duration_check" in query.keys() else False):
continue
if alert.end_time and alert.start_time and alert.end_time - alert.start_time > max_duration:
return False
case "source":
sources = query.get(k).split(",")
if not alert.source or alert.source not in sources:
return False
case "sig":
# If a list of sigs is provided, the alert must have a sig and it must match one of them.
# The special "sig" "NO_SIG", when supplied in the list, mathches alerts with no sig.
sigs = query.get(k).split(",")
include_no_sig = "NO_SIG" in sigs
if not alert.sig and not include_no_sig:
return False
if alert.sig and alert.sig not in sigs:
return False
case "dx_continent":
dxconts = query.get(k).split(",")
if not alert.dx_continent or alert.dx_continent not in dxconts:
return False
case "dx_call_includes":
dx_call_includes = query.get(k).strip()
if not alert.dx_call or dx_call_includes.upper() not in alert.dx_call.upper():
return False
case "text_includes":
text_includes = query.get(k).strip()
if (not alert.dx_call or text_includes.upper() not in alert.dx_call.upper()) \
and (not alert.comment or text_includes.upper() not in alert.comment.upper()) \
and (not alert.freqs_modes or text_includes.upper() not in alert.freqs_modes.upper()):
return False
return True

View File

@@ -0,0 +1,121 @@
import json
import logging
import re
from datetime import datetime
import pytz
import tornado
from core.constants import SIGS
from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig, populate_sig_ref_info
from core.utils import serialize_everything
from data.sig_ref import SIGRef
from data.spot import Spot
# API request handler for /api/v1/lookup/call
class APILookupCallHandler(tornado.web.RequestHandler):
def initialize(self, web_server_metrics):
self.web_server_metrics = web_server_metrics
def get(self):
try:
# Metrics
self.web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self.web_server_metrics["api_access_counter"] += 1
self.web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# The "call" query param must exist and look like a callsign
if "call" in query_params.keys():
call = query_params.get("call").upper()
if re.match(r"^[A-Z0-9/\-]*$", call):
# Take the callsign, make a "fake spot" so we can run infer_missing() on it, then repack the
# resulting data in the correct way for the API response.
fake_spot = Spot(dx_call=call)
fake_spot.infer_missing()
data = {
"call": call,
"name": fake_spot.dx_name,
"qth": fake_spot.dx_qth,
"country": fake_spot.dx_country,
"flag": fake_spot.dx_flag,
"continent": fake_spot.dx_continent,
"dxcc_id": fake_spot.dx_dxcc_id,
"cq_zone": fake_spot.dx_cq_zone,
"itu_zone": fake_spot.dx_itu_zone,
"grid": fake_spot.dx_grid,
"latitude": fake_spot.dx_latitude,
"longitude": fake_spot.dx_longitude,
"location_source": fake_spot.dx_location_source
}
self.write(json.dumps(data, default=serialize_everything))
else:
self.write(json.dumps("Error - '" + call + "' does not look like a valid callsign.",
default=serialize_everything))
self.set_status(422)
else:
self.write(json.dumps("Error - call must be provided", default=serialize_everything))
self.set_status(422)
except Exception as e:
logging.error(e)
self.write(json.dumps("Error - " + str(e), default=serialize_everything))
self.set_status(500)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
# API request handler for /api/v1/lookup/sigref
class APILookupSIGRefHandler(tornado.web.RequestHandler):
def initialize(self, web_server_metrics):
self.web_server_metrics = web_server_metrics
def get(self):
try:
# Metrics
self.web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self.web_server_metrics["api_access_counter"] += 1
self.web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# "sig" and "id" query params must exist, SIG must be known, and if we have a reference regex for that SIG,
# the provided id must match it.
if "sig" in query_params.keys() and "id" in query_params.keys():
sig = query_params.get("sig").upper()
id = query_params.get("id").upper()
if sig in list(map(lambda p: p.name, SIGS)):
if not get_ref_regex_for_sig(sig) or re.match(get_ref_regex_for_sig(sig), id):
data = populate_sig_ref_info(SIGRef(id=id, sig=sig))
self.write(json.dumps(data, default=serialize_everything))
else:
self.write(
json.dumps("Error - '" + id + "' does not look like a valid reference ID for " + sig + ".",
default=serialize_everything))
self.set_status(422)
else:
self.write(json.dumps("Error - sig '" + sig + "' is not known.", default=serialize_everything))
self.set_status(422)
else:
self.write(json.dumps("Error - sig and id must be provided", default=serialize_everything))
self.set_status(422)
except Exception as e:
logging.error(e)
self.write(json.dumps("Error - " + str(e), default=serialize_everything))
self.set_status(500)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")

View File

@@ -0,0 +1,48 @@
import json
from datetime import datetime
import pytz
import tornado
from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING, WEB_UI_OPTIONS
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything
# API request handler for /api/v1/options
class APIOptionsHandler(tornado.web.RequestHandler):
def initialize(self, status_data, web_server_metrics):
self.status_data = status_data
self.web_server_metrics = web_server_metrics
def get(self):
# Metrics
self.web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self.web_server_metrics["api_access_counter"] += 1
self.web_server_metrics["status"] = "OK"
api_requests_counter.inc()
options = {"bands": BANDS,
"modes": ALL_MODES,
"mode_types": MODE_TYPES,
"sigs": SIGS,
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle things that aren't even available.
"spot_sources": list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self.status_data["spot_providers"]))),
"alert_sources": list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self.status_data["alert_providers"]))),
"continents": CONTINENTS,
"max_spot_age": MAX_SPOT_AGE,
"spot_allowed": ALLOW_SPOTTING,
"web-ui-options": WEB_UI_OPTIONS}
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
# one of our proviers.
if ALLOW_SPOTTING:
options["spot_sources"].append("API")
options["web-ui-options"]["spot-providers-enabled-by-default"].append("API")
self.write(json.dumps(options, default=serialize_everything))
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")

View File

@@ -0,0 +1,239 @@
import json
import logging
from datetime import datetime, timedelta
from queue import Queue
import pytz
import tornado
import tornado_eventsource.handler
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything
SSE_HANDLER_MAX_QUEUE_SIZE = 1000
SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
# API request handler for /api/v1/spots
class APISpotsHandler(tornado.web.RequestHandler):
def initialize(self, spots, web_server_metrics):
self.spots = spots
self.web_server_metrics = web_server_metrics
def get(self):
try:
# Metrics
self.web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self.web_server_metrics["api_access_counter"] += 1
self.web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# Fetch all spots matching the query
data = get_spot_list_with_filters(self.spots, query_params)
self.write(json.dumps(data, default=serialize_everything))
self.set_status(200)
except ValueError as e:
logging.error(e)
self.write(json.dumps("Bad request - " + str(e), default=serialize_everything))
self.set_status(400)
except Exception as e:
logging.error(e)
self.write(json.dumps("Error - " + str(e), default=serialize_everything))
self.set_status(500)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
# API request handler for /api/v1/spots/stream
class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
def initialize(self, sse_spot_queues, web_server_metrics):
self.sse_spot_queues = sse_spot_queues
self.web_server_metrics = web_server_metrics
# Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data
def custom_headers(self):
return {"Cache-Control": "no-store",
"X-Accel-Buffering": "no"}
# Called once on the client opening a connection, set things up
def open(self):
try:
# Metrics
self.web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self.web_server_metrics["api_access_counter"] += 1
self.web_server_metrics["status"] = "OK"
api_requests_counter.inc()
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
self.query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# Create a spot queue and add it to the web server's list. The web server will fill this when spots arrive
self.spot_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE)
self.sse_spot_queues.append(self.spot_queue)
# Set up a timed callback to check if anything is in the queue
self.heartbeat = tornado.ioloop.PeriodicCallback(self._callback, SSE_HANDLER_QUEUE_CHECK_INTERVAL)
self.heartbeat.start()
except Exception as e:
logging.warn("Exception when serving SSE socket", e)
# When the user closes the socket, empty our queue and remove it from the list so the server no longer fills it
def close(self):
try:
if self.spot_queue in self.sse_spot_queues:
self.sse_spot_queues.remove(self.spot_queue)
self.spot_queue.empty()
except:
pass
self.spot_queue = None
super().close()
# Callback to check if anything has arrived in the queue, and if so send it to the client
def _callback(self):
try:
if self.spot_queue:
while not self.spot_queue.empty():
spot = self.spot_queue.get()
# If the new spot matches our param filters, send it to the client. If not, ignore it.
if spot_allowed_by_query(spot, self.query_params):
self.write_message(msg=json.dumps(spot, default=serialize_everything))
if self.spot_queue not in self.sse_spot_queues:
logging.error("Web server cleared up a queue of an active connection!")
self.close()
except:
logging.warn("Exception in SSE callback, connection will be closed.")
self.close()
# Utility method to apply filters to the overall spot list and return only a subset. Enables query parameters in
# the main "spots" GET call.
def get_spot_list_with_filters(all_spots, query):
# Create a shallow copy of the spot list, ordered by spot time, then filter the list to reduce it only to spots
# that match the filter parameters in the query string. Finally, apply a limit to the number of spots returned.
# The list of query string filters is defined in the API docs.
spot_ids = list(all_spots.iterkeys())
spots = []
for k in spot_ids:
s = all_spots.get(k)
if s is not None:
spots.append(s)
spots = sorted(spots, key=lambda spot: (spot.time if spot and spot.time else 0), reverse=True)
spots = list(filter(lambda spot: spot_allowed_by_query(spot, query), spots))
if "limit" in query.keys():
spots = spots[:int(query.get("limit"))]
# Ensure only the latest spot of each callsign-SSID combo is present in the list. This relies on the
# list being in reverse time order, so if any future change allows re-ordering the list, that should
# be done *after* this. SSIDs are deliberately included here (see issue #68) because e.g. M0TRT-7
# and M0TRT-9 APRS transponders could well be in different locations, on different frequencies etc.
# This is a special consideration for the geo map and band map views (and Field Spotter) because while
# duplicates are fine in the main spot list (e.g. different cluster spots of the same DX) this doesn't
# work well for the other views.
if "dedupe" in query.keys():
dedupe = query.get("dedupe").upper() == "TRUE"
if dedupe:
spots_temp = []
already_seen = []
for s in spots:
call_plus_ssid = s.dx_call + (s.dx_ssid if s.dx_ssid else "")
if call_plus_ssid not in already_seen:
spots_temp.append(s)
already_seen.append(call_plus_ssid)
spots = spots_temp
return spots
# Given URL query params and a spot, figure out if the spot "passes" the requested filters or is rejected. The list
# of query parameters and their function is defined in the API docs.
def spot_allowed_by_query(spot, query):
for k in query.keys():
match k:
case "since":
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC).timestamp()
if not spot.time or spot.time <= since:
return False
case "max_age":
max_age = int(query.get(k))
since = (datetime.now(pytz.UTC) - timedelta(seconds=max_age)).timestamp()
if not spot.time or spot.time <= since:
return False
case "received_since":
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC).timestamp()
if not spot.received_time or spot.received_time <= since:
return False
case "source":
sources = query.get(k).split(",")
if not spot.source or spot.source not in sources:
return False
case "sig":
# If a list of sigs is provided, the spot must have a sig and it must match one of them.
# The special "sig" "NO_SIG", when supplied in the list, mathches spots with no sig.
sigs = query.get(k).split(",")
include_no_sig = "NO_SIG" in sigs
if not spot.sig and not include_no_sig:
return False
if spot.sig and spot.sig not in sigs:
return False
case "needs_sig":
# If true, a sig is required, regardless of what it is, it just can't be missing. Mutually
# exclusive with supplying the special "NO_SIG" parameter to the "sig" query param.
needs_sig = query.get(k).upper() == "TRUE"
if needs_sig and not spot.sig:
return False
case "needs_sig_ref":
# If true, at least one sig ref is required, regardless of what it is, it just can't be missing.
needs_sig_ref = query.get(k).upper() == "TRUE"
if needs_sig_ref and (not spot.sig_refs or len(spot.sig_refs) == 0):
return False
case "band":
bands = query.get(k).split(",")
if not spot.band or spot.band not in bands:
return False
case "mode":
modes = query.get(k).split(",")
if not spot.mode or spot.mode not in modes:
return False
case "mode_type":
mode_types = query.get(k).split(",")
if not spot.mode_type or spot.mode_type not in mode_types:
return False
case "dx_continent":
dxconts = query.get(k).split(",")
if not spot.dx_continent or spot.dx_continent not in dxconts:
return False
case "de_continent":
deconts = query.get(k).split(",")
if not spot.de_continent or spot.de_continent not in deconts:
return False
case "comment_includes":
comment_includes = query.get(k).strip()
if not spot.comment or comment_includes.upper() not in spot.comment.upper():
return False
case "dx_call_includes":
dx_call_includes = query.get(k).strip()
if not spot.dx_call or dx_call_includes.upper() not in spot.dx_call.upper():
return False
case "text_includes":
text_includes = query.get(k).strip()
if (not spot.dx_call or text_includes.upper() not in spot.dx_call.upper()) \
and (not spot.comment or text_includes.upper() not in spot.comment.upper()):
return False
case "allow_qrt":
# If false, spots that are flagged as QRT are not returned.
prevent_qrt = query.get(k).upper() == "FALSE"
if prevent_qrt and spot.qrt and spot.qrt == True:
return False
case "needs_good_location":
# If true, spots require a "good" location to be returned
needs_good_location = query.get(k).upper() == "TRUE"
if needs_good_location and not spot.dx_location_good:
return False
return True

View File

@@ -0,0 +1,27 @@
import json
from datetime import datetime
import pytz
import tornado
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything
# API request handler for /api/v1/status
class APIStatusHandler(tornado.web.RequestHandler):
def initialize(self, status_data, web_server_metrics):
self.status_data = status_data
self.web_server_metrics = web_server_metrics
def get(self):
# Metrics
self.web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
self.web_server_metrics["api_access_counter"] += 1
self.web_server_metrics["status"] = "OK"
api_requests_counter.inc()
self.write(json.dumps(self.status_data, default=serialize_everything))
self.set_status(200)
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")

View File

@@ -0,0 +1,12 @@
import tornado
from prometheus_client import CONTENT_TYPE_LATEST
from core.prometheus_metrics_handler import get_metrics
# Handler for Prometheus metrics endpoint
class PrometheusMetricsHandler(tornado.web.RequestHandler):
def get(self):
self.write(get_metrics())
self.set_status(200)
self.set_header('Content-Type', CONTENT_TYPE_LATEST)

View File

@@ -0,0 +1,26 @@
from datetime import datetime
import pytz
import tornado
from core.config import ALLOW_SPOTTING
from core.constants import SOFTWARE_VERSION
from core.prometheus_metrics_handler import page_requests_counter
# Handler for all HTML pages generated from templates
class PageTemplateHandler(tornado.web.RequestHandler):
def initialize(self, template_name, web_server_metrics):
self.template_name = template_name
self.web_server_metrics = web_server_metrics
def get(self):
# Metrics
self.web_server_metrics["last_page_access_time"] = datetime.now(pytz.UTC)
self.web_server_metrics["page_access_counter"] += 1
self.web_server_metrics["status"] = "OK"
page_requests_counter.inc()
# Load named template, and provide variables used in templates
self.render(self.template_name + ".html", software_version=SOFTWARE_VERSION, allow_spotting=ALLOW_SPOTTING)

View File

@@ -1,338 +1,120 @@
import json import asyncio
import logging import logging
from datetime import datetime, timedelta import os
from threading import Thread
import bottle import tornado
import pytz from tornado.web import StaticFileHandler
from bottle import run, request, response, template
from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING from server.handlers.api.addspot import APISpotHandler
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS, SOFTWARE_VERSION from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
from data.spot import Spot from server.handlers.api.lookups import APILookupCallHandler, APILookupSIGRefHandler
from server.handlers.api.options import APIOptionsHandler
from server.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
from server.handlers.api.status import APIStatusHandler
from server.handlers.metrics import PrometheusMetricsHandler
from server.handlers.pagetemplate import PageTemplateHandler
# Provides the public-facing web server. # Provides the public-facing web server.
class WebServer: class WebServer:
# Constructor # Constructor
def __init__(self, spots, alerts, status_data, port): def __init__(self, spots, alerts, status_data, port):
self.last_page_access_time = None
self.last_api_access_time = None
self.spots = spots self.spots = spots
self.alerts = alerts self.alerts = alerts
self.sse_spot_queues = []
self.sse_alert_queues = []
self.status_data = status_data self.status_data = status_data
self.port = port self.port = port
self.thread = Thread(target=self.run) self.shutdown_event = asyncio.Event()
self.thread.daemon = True self.web_server_metrics = {
self.status = "Starting" "last_page_access_time": None,
"last_api_access_time": None,
# Base template data "page_access_counter": 0,
bottle.BaseTemplate.defaults['software_version'] = SOFTWARE_VERSION "api_access_counter": 0,
"status": "Starting"
# Routes for API calls }
bottle.get("/api/v1/spots")(lambda: self.serve_spots_api())
bottle.get("/api/v1/alerts")(lambda: self.serve_alerts_api())
bottle.get("/api/v1/options")(lambda: self.serve_api(self.get_options()))
bottle.get("/api/v1/status")(lambda: self.serve_api(self.status_data))
bottle.post("/api/v1/spot")(lambda: self.accept_spot())
# Routes for templated pages
bottle.get("/")(lambda: self.serve_template('webpage_spots'))
bottle.get("/map")(lambda: self.serve_template('webpage_map'))
bottle.get("/bands")(lambda: self.serve_template('webpage_bands'))
bottle.get("/alerts")(lambda: self.serve_template('webpage_alerts'))
bottle.get("/status")(lambda: self.serve_template('webpage_status'))
bottle.get("/about")(lambda: self.serve_template('webpage_about'))
bottle.get("/apidocs")(lambda: self.serve_template('webpage_apidocs'))
# Default route to serve from "webassets"
bottle.get("/<filepath:path>")(self.serve_static_file)
# Start the web server # Start the web server
def start(self): def start(self):
self.thread.start() asyncio.run(self.start_inner())
# Run the web server itself. This blocks until the server is shut down, so it runs in a separate thread. # Stop the web server
def run(self): def stop(self):
logging.info("Starting web server on port " + str(self.port) + "...") self.shutdown_event.set()
self.status = "Waiting"
run(host='localhost', port=self.port)
# Serve the JSON API /spots endpoint # Start method (async). Sets up the Tornado application.
def serve_spots_api(self): async def start_inner(self):
try: app = tornado.web.Application([
data = self.get_spot_list_with_filters() # Routes for API calls
return self.serve_api(data) (r"/api/v1/spots", APISpotsHandler, {"spots": self.spots, "web_server_metrics": self.web_server_metrics}),
except ValueError as e: (r"/api/v1/alerts", APIAlertsHandler, {"alerts": self.alerts, "web_server_metrics": self.web_server_metrics}),
logging.error(e) (r"/api/v1/spots/stream", APISpotsStreamHandler, {"sse_spot_queues": self.sse_spot_queues, "web_server_metrics": self.web_server_metrics}),
response.content_type = 'application/json' (r"/api/v1/alerts/stream", APIAlertsStreamHandler, {"sse_alert_queues": self.sse_alert_queues, "web_server_metrics": self.web_server_metrics}),
response.status = 400 (r"/api/v1/options", APIOptionsHandler, {"status_data": self.status_data, "web_server_metrics": self.web_server_metrics}),
return json.dumps("Bad request - " + str(e), default=serialize_everything) (r"/api/v1/status", APIStatusHandler, {"status_data": self.status_data, "web_server_metrics": self.web_server_metrics}),
except Exception as e: (r"/api/v1/lookup/call", APILookupCallHandler, {"web_server_metrics": self.web_server_metrics}),
logging.error(e) (r"/api/v1/lookup/sigref", APILookupSIGRefHandler, {"web_server_metrics": self.web_server_metrics}),
response.content_type = 'application/json' (r"/api/v1/spot", APISpotHandler, {"spots": self.spots, "web_server_metrics": self.web_server_metrics}),
response.status = 500 # Routes for templated pages
return json.dumps("Error - " + str(e), default=serialize_everything) (r"/", PageTemplateHandler, {"template_name": "spots", "web_server_metrics": self.web_server_metrics}),
(r"/map", PageTemplateHandler, {"template_name": "map", "web_server_metrics": self.web_server_metrics}),
(r"/bands", PageTemplateHandler, {"template_name": "bands", "web_server_metrics": self.web_server_metrics}),
(r"/alerts", PageTemplateHandler, {"template_name": "alerts", "web_server_metrics": self.web_server_metrics}),
(r"/add-spot", PageTemplateHandler, {"template_name": "add_spot", "web_server_metrics": self.web_server_metrics}),
(r"/status", PageTemplateHandler, {"template_name": "status", "web_server_metrics": self.web_server_metrics}),
(r"/about", PageTemplateHandler, {"template_name": "about", "web_server_metrics": self.web_server_metrics}),
(r"/apidocs", PageTemplateHandler, {"template_name": "apidocs", "web_server_metrics": self.web_server_metrics}),
# Route for Prometheus metrics
(r"/metrics", PrometheusMetricsHandler),
# Default route to serve from "webassets"
(r"/(.*)", StaticFileHandler, {"path": os.path.join(os.path.dirname(__file__), "../webassets")}),
],
template_path=os.path.join(os.path.dirname(__file__), "../templates"),
debug=False)
app.listen(self.port)
await self.shutdown_event.wait()
# Serve the JSON API /alerts endpoint # Internal method called when a new spot is added to the system. This is used to ping any SSE clients that are
def serve_alerts_api(self): # awaiting a server-sent message with new spots.
try: def notify_new_spot(self, spot):
data = self.get_alert_list_with_filters() for queue in self.sse_spot_queues:
return self.serve_api(data) try:
except ValueError as e: queue.put(spot)
logging.error(e) except:
response.content_type = 'application/json' # Cleanup thread was probably deleting the queue, that's fine
response.status = 400 pass
return json.dumps("Bad request - " + str(e), default=serialize_everything) pass
except Exception as e:
logging.error(e)
response.content_type = 'application/json'
response.status = 500
return json.dumps("Error - " + str(e), default=serialize_everything)
# Serve a JSON API endpoint # Internal method called when a new alert is added to the system. This is used to ping any SSE clients that are
def serve_api(self, data): # awaiting a server-sent message with new spots.
self.last_api_access_time = datetime.now(pytz.UTC) def notify_new_alert(self, alert):
self.status = "OK" for queue in self.sse_alert_queues:
response.content_type = 'application/json' try:
response.set_header('Cache-Control', 'no-store') queue.put(alert)
return json.dumps(data, default=serialize_everything) except:
# Cleanup thread was probably deleting the queue, that's fine
pass
pass
# Accept a spot # Clean up any SSE queues that are growing too large; probably their client disconnected and we didn't catch it
def accept_spot(self): # properly for some reason.
self.last_api_access_time = datetime.now(pytz.UTC) def clean_up_sse_queues(self):
self.status = "OK" for q in self.sse_spot_queues:
try:
try: if q.full():
# Reject if not allowed logging.warn("A full SSE spot queue was found, presumably because the client disconnected strangely. It has been removed.")
if not ALLOW_SPOTTING: self.sse_spot_queues.remove(q)
response.content_type = 'application/json' q.empty()
response.status = 401 except:
return json.dumps("Error - this server does not allow new spots to be added via the API.", # Probably got deleted already on another thread
default=serialize_everything) pass
for q in self.sse_alert_queues:
# Reject if format not json try:
if not request.get_header('Content-Type') or request.get_header('Content-Type') != "application/json": if q.full():
response.content_type = 'application/json' logging.warn("A full SSE alert queue was found, presumably because the client disconnected strangely. It has been removed.")
response.status = 415 self.sse_alert_queues.remove(q)
return json.dumps("Error - request Content-Type must be application/json", default=serialize_everything) q.empty()
except:
# Reject if request body is empty # Probably got deleted already on another thread
post_data = request.body.read() pass
if not post_data: pass
response.content_type = 'application/json'
response.status = 422
return json.dumps("Error - request body is empty", default=serialize_everything)
# Read in the request body as JSON then convert to a Spot object
json_spot = json.loads(post_data)
spot = Spot(**json_spot)
# Reject if no timestamp or dx_call
if not spot.time or not spot.dx_call:
response.content_type = 'application/json'
response.status = 422
return json.dumps("Error - 'time' and 'dx_call' must be provided as a minimum.",
default=serialize_everything)
# infer missing data, and add it to our database.
spot.source = "API"
spot.icon = "desktop"
spot.infer_missing()
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
response.content_type = 'application/json'
response.set_header('Cache-Control', 'no-store')
response.status = 201
return json.dumps("OK", default=serialize_everything)
except Exception as e:
logging.error(e)
response.content_type = 'application/json'
response.status = 500
return json.dumps("Error - " + str(e), default=serialize_everything)
# Serve a templated page
def serve_template(self, template_name):
self.last_page_access_time = datetime.now(pytz.UTC)
self.status = "OK"
return template(template_name)
# Serve general static files from "webassets" directory.
def serve_static_file(self, filepath):
return bottle.static_file(filepath, root="webassets")
# Utility method to apply filters to the overall spot list and return only a subset. Enables query parameters in
# the main "spots" GET call.
def get_spot_list_with_filters(self):
# Get the query (and the right one, with Bottle magic. This is a MultiDict object)
query = bottle.request.query
# Create a shallow copy of the spot list, ordered by spot time. We'll then filter it accordingly.
# We can filter by spot time and received time with "since" and "received_since", which take a UNIX timestamp
# in seconds UTC.
# We can also filter by source, sig, band, mode, dx_continent and de_continent. Each of these accepts a single
# value or a comma-separated list.
# We can filter by comments, accepting a single string, where the API will only return spots where the comment
# contains the provided value (case-insensitive).
# We can "de-dupe" spots, so only the latest spot will be sent for each callsign.
# We can provide a "limit" number as well. Spots are always returned newest-first; "limit" limits to only the
# most recent X spots.
spot_ids = list(self.spots.iterkeys())
spots = []
for k in spot_ids:
s = self.spots.get(k)
if s is not None:
spots.append(s)
spots = sorted(spots, key=lambda spot: (spot.time if spot and spot.time else 0), reverse=True)
for k in query.keys():
match k:
case "since":
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC).timestamp()
spots = [s for s in spots if s.time and s.time > since]
case "max_age":
max_age = int(query.get(k))
since = (datetime.now(pytz.UTC) - timedelta(seconds=max_age)).timestamp()
spots = [s for s in spots if s.time and s.time > since]
case "received_since":
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC).timestamp()
spots = [s for s in spots if s.received_time and s.received_time > since]
case "source":
sources = query.get(k).split(",")
spots = [s for s in spots if s.source and s.source in sources]
case "sig":
# If a list of sigs is provided, the spot must have a sig and it must match one of them
sigs = query.get(k).split(",")
spots = [s for s in spots if s.sig and s.sig in sigs]
case "needs_sig":
# If true, a sig is required, regardless of what it is, it just can't be missing.
needs_sig = query.get(k).upper() == "TRUE"
if needs_sig:
spots = [s for s in spots if s.sig]
case "needs_sig_ref":
# If true, at least one sig ref is required, regardless of what it is, it just can't be missing.
needs_sig_ref = query.get(k).upper() == "TRUE"
if needs_sig_ref:
spots = [s for s in spots if s.sig_refs and len(s.sig_refs) > 0]
case "band":
bands = query.get(k).split(",")
spots = [s for s in spots if s.band and s.band in bands]
case "mode":
modes = query.get(k).split(",")
spots = [s for s in spots if s.mode in modes]
case "mode_type":
mode_families = query.get(k).split(",")
spots = [s for s in spots if s.mode_type and s.mode_type in mode_families]
case "dx_continent":
dxconts = query.get(k).split(",")
spots = [s for s in spots if s.dx_continent and s.dx_continent in dxconts]
case "de_continent":
deconts = query.get(k).split(",")
spots = [s for s in spots if s.de_continent and s.de_continent in deconts]
case "comment_includes":
comment_includes = query.get(k).strip()
spots = [s for s in spots if s.comment and comment_includes.upper() in s.comment.upper()]
case "allow_qrt":
# If false, spots that are flagged as QRT are not returned.
prevent_qrt = query.get(k).upper() == "FALSE"
if prevent_qrt:
spots = [s for s in spots if not s.qrt or s.qrt == False]
case "needs_good_location":
# If true, spots require a "good" location to be returned
needs_good_location = query.get(k).upper() == "TRUE"
if needs_good_location:
spots = [s for s in spots if s.dx_location_good]
case "dedupe":
# Ensure only the latest spot of each callsign is present in the list. This relies on the list being
# in reverse time order, so if any future change allows re-ordering the list, that should be done
# *after* this.
dedupe = query.get(k).upper() == "TRUE"
if dedupe:
spots_temp = []
already_seen = []
for s in spots:
if s.dx_call not in already_seen:
spots_temp.append(s)
already_seen.append(s.dx_call)
spots = spots_temp
# If we have a "limit" parameter, we apply that last, regardless of where it appeared in the list of keys.
if "limit" in query.keys():
spots = spots[:int(query.get("limit"))]
return spots
# Utility method to apply filters to the overall alert list and return only a subset. Enables query parameters in
# the main "alerts" GET call.
def get_alert_list_with_filters(self):
# Get the query (and the right one, with Bottle magic. This is a MultiDict object)
query = bottle.request.query
# Create a shallow copy of the alert list, ordered by start time. We'll then filter it accordingly.
# We can filter by received time with "received_since", which take a UNIX timestamp in seconds UTC.
# We can also filter by source, sig, and dx_continent. Each of these accepts a single
# value or a comma-separated list.
# We can provide a "limit" number as well. Alerts are always returned newest-first; "limit" limits to only the
# most recent X alerts.
alert_ids = list(self.alerts.iterkeys())
alerts = []
for k in alert_ids:
a = self.alerts.get(k)
if a is not None:
alerts.append(a)
# We never want alerts that seem to be in the past
alerts = list(filter(lambda alert: not alert.expired(), alerts))
alerts = sorted(alerts, key=lambda alert: (alert.start_time if alert and alert.start_time else 0))
for k in query.keys():
match k:
case "received_since":
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC)
alerts = [a for a in alerts if a.received_time and a.received_time > since]
case "max_duration":
max_duration = int(query.get(k))
# Check the duration if end_time is provided. If end_time is not provided, assume the activation is
# "short", i.e. it always passes this check. If dxpeditions_skip_max_duration_check is true and
# the alert is a dxpedition, it also always passes the check.
dxpeditions_skip_check = bool(query.get(
"dxpeditions_skip_max_duration_check")) if "dxpeditions_skip_max_duration_check" in query.keys() else False
alerts = [a for a in alerts if (a.end_time and a.end_time - a.start_time <= max_duration) or
not a.end_time or (dxpeditions_skip_check and a.is_dxpedition)]
case "source":
sources = query.get(k).split(",")
alerts = [a for a in alerts if a.source and a.source in sources]
case "sig":
sigs = query.get(k).split(",")
alerts = [a for a in alerts if a.sig and a.sig in sigs]
case "dx_continent":
dxconts = query.get(k).split(",")
alerts = [a for a in alerts if a.dx_continent and a.dx_continent in dxconts]
# If we have a "limit" parameter, we apply that last, regardless of where it appeared in the list of keys.
if "limit" in query.keys():
alerts = alerts[:int(query.get("limit"))]
return alerts
# Return all the "options" for various things that the server is aware of. This can be fetched with an API call.
# The idea is that this will include most of the things that can be provided as queries to the main spots call,
# and thus a client can use this data to configure its filter controls.
def get_options(self):
options = {"bands": BANDS,
"modes": ALL_MODES,
"mode_types": MODE_TYPES,
"sigs": SIGS,
# Spot/alert sources are filtered for only ones that are enabled in config, no point letting the user toggle things that aren't even available.
"spot_sources": list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self.status_data["spot_providers"]))),
"alert_sources": list(
map(lambda p: p["name"], filter(lambda p: p["enabled"], self.status_data["alert_providers"]))),
"continents": CONTINENTS,
"max_spot_age": MAX_SPOT_AGE,
"spot_allowed": ALLOW_SPOTTING}
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
# one of our proviers.
if ALLOW_SPOTTING:
options["spot_sources"].append("API")
return options
# Convert objects to serialisable things. Used by JSON serialiser as a default when it encounters unserializable things.
# Just converts objects to dict. Try to avoid doing anything clever here when serialising spots, because we also need
# to receive spots without complex handling.
def serialize_everything(obj):
return obj.__dict__

View File

@@ -1,6 +1,7 @@
# Main script # Main script
import importlib import importlib
import logging import logging
import os
import signal import signal
import sys import sys
@@ -16,15 +17,20 @@ from server.webserver import WebServer
# Globals # Globals
spots = Cache('cache/spots_cache') spots = Cache('cache/spots_cache')
alerts = Cache('cache/alerts_cache') alerts = Cache('cache/alerts_cache')
web_server = None
status_data = {} status_data = {}
spot_providers = [] spot_providers = []
alert_providers = [] alert_providers = []
cleanup_timer = None cleanup_timer = None
run = True
# Shutdown function # Shutdown function
def shutdown(sig, frame): def shutdown(sig, frame):
logging.info("Stopping program, this may take a few seconds...") global run
logging.info("Stopping program...")
web_server.stop()
for p in spot_providers: for p in spot_providers:
if p.enabled: if p.enabled:
p.stop() p.stop()
@@ -35,6 +41,7 @@ def shutdown(sig, frame):
lookup_helper.stop() lookup_helper.stop()
spots.close() spots.close()
alerts.close() alerts.close()
os._exit(0)
# Utility method to get a spot provider based on the class specified in its config entry. # Utility method to get a spot provider based on the class specified in its config entry.
@@ -72,11 +79,14 @@ if __name__ == '__main__':
# Set up lookup helper # Set up lookup helper
lookup_helper.start() lookup_helper.start()
# Set up web server
web_server = WebServer(spots=spots, alerts=alerts, status_data=status_data, port=WEB_SERVER_PORT)
# Fetch, set up and start spot providers # Fetch, set up and start spot providers
for entry in config["spot-providers"]: for entry in config["spot-providers"]:
spot_providers.append(get_spot_provider_from_config(entry)) spot_providers.append(get_spot_provider_from_config(entry))
for p in spot_providers: for p in spot_providers:
p.setup(spots=spots) p.setup(spots=spots, web_server=web_server)
if p.enabled: if p.enabled:
p.start() p.start()
@@ -84,18 +94,14 @@ if __name__ == '__main__':
for entry in config["alert-providers"]: for entry in config["alert-providers"]:
alert_providers.append(get_alert_provider_from_config(entry)) alert_providers.append(get_alert_provider_from_config(entry))
for p in alert_providers: for p in alert_providers:
p.setup(alerts=alerts) p.setup(alerts=alerts, web_server=web_server)
if p.enabled: if p.enabled:
p.start() p.start()
# Set up timer to clear spot list of old data # Set up timer to clear spot list of old data
cleanup_timer = CleanupTimer(spots=spots, alerts=alerts, cleanup_interval=60) cleanup_timer = CleanupTimer(spots=spots, alerts=alerts, web_server=web_server, cleanup_interval=60)
cleanup_timer.start() cleanup_timer.start()
# Set up web server
web_server = WebServer(spots=spots, alerts=alerts, status_data=status_data, port=WEB_SERVER_PORT)
web_server.start()
# Set up status reporter # Set up status reporter
status_reporter = StatusReporter(status_data=status_data, spots=spots, alerts=alerts, web_server=web_server, status_reporter = StatusReporter(status_data=status_data, spots=spots, alerts=alerts, web_server=web_server,
cleanup_timer=cleanup_timer, spot_providers=spot_providers, cleanup_timer=cleanup_timer, spot_providers=spot_providers,
@@ -103,3 +109,8 @@ if __name__ == '__main__':
status_reporter.start() status_reporter.start()
logging.info("Startup complete.") logging.info("Startup complete.")
# Run the web server. This is the blocking call that keeps the application running in the main thread, so this must
# be the last thing we do. web_server.stop() triggers an await condition in the web server which finishes the main
# thread.
web_server.start()

View File

@@ -1,5 +1,5 @@
import logging import logging
from datetime import datetime, timezone from datetime import datetime
from threading import Thread from threading import Thread
import aprslib import aprslib
@@ -37,22 +37,25 @@ class APRSIS(SpotProvider):
def handle(self, data): def handle(self, data):
# Split SSID in "from" call and store separately # Split SSID in "from" call and store separately
from_parts = data["from"].split("-") from_parts = data["from"].split("-").upper()
dx_call = from_parts[0] dx_call = from_parts[0]
dx_aprs_ssid = from_parts[1] if len(from_parts) > 1 else None dx_ssid = from_parts[1] if len(from_parts) > 1 else None
via_parts = data["via"].split("-").upper()
de_call = via_parts[0]
de_ssid = via_parts[1] if len(via_parts) > 1 else None
spot = Spot(source="APRS-IS", spot = Spot(source="APRS-IS",
dx_call=dx_call, dx_call=dx_call,
dx_aprs_ssid=dx_aprs_ssid, dx_ssid=dx_ssid,
de_call=data["via"], de_call=de_call,
de_ssid=de_ssid,
comment=data["comment"] if "comment" in data else None, comment=data["comment"] if "comment" in data else None,
dx_latitude=data["latitude"] if "latitude" in data else None, dx_latitude=data["latitude"] if "latitude" in data else None,
dx_longitude=data["longitude"] if "longitude" 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" time=datetime.now(pytz.UTC).timestamp()) # APRS-IS spots are live so we can assume spot time is "now"
# Add to our list # Add to our list
self.submit(spot) self.submit(spot)
self.status = "OK" self.status = "OK"
self.last_update_time = datetime.now(timezone.utc) self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Data received from APRS-IS.") logging.debug("Data received from APRS-IS.")

View File

@@ -1,27 +1,27 @@
import logging import logging
import re import re
from datetime import datetime, timezone from datetime import datetime
from threading import Thread from threading import Thread
from time import sleep from time import sleep
import pytz import pytz
import telnetlib3 import telnetlib3
from core.constants import SIGS
from core.sig_utils import ANY_SIG_REGEX, ANY_XOTA_SIG_REF_REGEX, get_icon_for_sig, get_ref_regex_for_sig
from data.spot import Spot
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from data.spot import Spot
from spotproviders.spot_provider import SpotProvider 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): 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|/]+)" CALLSIGN_PATTERN = "([a-z|0-9|/]+)"
FREQUENCY_PATTERM = "([0-9|.]+)" FREQUENCY_PATTERN = "([0-9|.]+)"
LINE_PATTERN = re.compile( LINE_PATTERN_EXCLUDE_RBN = re.compile(
"^DX de " + CALLSIGN_PATTERN + ":\\s+" + FREQUENCY_PATTERM + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)", "^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) re.IGNORECASE)
# Constructor requires hostname and port # Constructor requires hostname and port
@@ -29,7 +29,10 @@ class DXCluster(SpotProvider):
super().__init__(provider_config) super().__init__(provider_config)
self.hostname = provider_config["host"] self.hostname = provider_config["host"]
self.port = provider_config["port"] self.port = provider_config["port"]
self.login_prompt = provider_config["login_prompt"] 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.telnet = None
self.thread = Thread(target=self.handle) self.thread = Thread(target=self.handle)
self.thread.daemon = True self.thread.daemon = True
@@ -52,7 +55,7 @@ class DXCluster(SpotProvider):
logging.info("DX Cluster " + self.hostname + " connecting...") logging.info("DX Cluster " + self.hostname + " connecting...")
self.telnet = telnetlib3.Telnet(self.hostname, self.port) self.telnet = telnetlib3.Telnet(self.hostname, self.port)
self.telnet.read_until(self.login_prompt.encode("latin-1")) 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 connected = True
logging.info("DX Cluster " + self.hostname + " connected.") logging.info("DX Cluster " + self.hostname + " connected.")
except Exception as e: except Exception as e:
@@ -65,7 +68,7 @@ class DXCluster(SpotProvider):
try: try:
# Check new telnet info against regular expression # Check new telnet info against regular expression
telnet_output = self.telnet.read_until("\n".encode("latin-1")) 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: if match:
spot_time = datetime.strptime(match.group(5), "%H%MZ") spot_time = datetime.strptime(match.group(5), "%H%MZ")
spot_datetime = datetime.combine(datetime.today(), spot_time.time()).replace(tzinfo=pytz.UTC) spot_datetime = datetime.combine(datetime.today(), spot_time.time()).replace(tzinfo=pytz.UTC)
@@ -74,29 +77,13 @@ class DXCluster(SpotProvider):
de_call=match.group(1), de_call=match.group(1),
freq=float(match.group(2)) * 1000, freq=float(match.group(2)) * 1000,
comment=match.group(4).strip(), comment=match.group(4).strip(),
icon="desktop",
time=spot_datetime.timestamp()) time=spot_datetime.timestamp())
# See if the comment looks like it contains a SIG (and optionally SIG reference). Currently,
# only one sig ref is supported. Note that this code is specifically in the DX Cluster class and
# not in the general "spot" infer_missing() method. Because we only support one SIG per spot
# at the moment (see issue #54), we don't want to risk e.g. a POTA spot with comment "WWFF GFF-0001"
# being converted into a WWFF spot.
sig_match = re.search(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", spot.comment, re.IGNORECASE)
if sig_match:
spot.sig = sig_match.group(2).upper()
spot.icon = get_icon_for_sig(spot.sig)
ref_regex = get_ref_regex_for_sig(spot.sig)
if ref_regex:
sig_ref_match = re.search(r"(^|\W)" + spot.sig + r"($|\W)(" + ref_regex + r")($|\W)", spot.comment, re.IGNORECASE)
if sig_ref_match:
spot.sig_refs = [sig_ref_match.group(3).upper()]
# Add to our list # Add to our list
self.submit(spot) self.submit(spot)
self.status = "OK" self.status = "OK"
self.last_update_time = datetime.now(timezone.utc) self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Data received from DX Cluster " + self.hostname + ".") logging.debug("Data received from DX Cluster " + self.hostname + ".")
except Exception as e: except Exception as e:

View File

@@ -1,11 +1,11 @@
import logging import logging
from datetime import datetime, timedelta from datetime import datetime
import pytz import pytz
from requests_cache import CachedSession
from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.sig_utils import get_icon_for_sig from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider from spotproviders.http_spot_provider import HTTPSpotProvider
@@ -16,8 +16,6 @@ class GMA(HTTPSpotProvider):
SPOTS_URL = "https://www.cqgma.org/api/spots/25/" SPOTS_URL = "https://www.cqgma.org/api/spots/25/"
# GMA spots don't contain the details of the programme they are for, we need a separate lookup for that # GMA spots don't contain the details of the programme they are for, we need a separate lookup for that
REF_INFO_URL_ROOT = "https://www.cqgma.org/api/ref/?" REF_INFO_URL_ROOT = "https://www.cqgma.org/api/ref/?"
REF_INFO_CACHE_TIME_DAYS = 30
REF_INFO_CACHE = CachedSession("cache/gma_ref_info_cache", expire_after=timedelta(days=REF_INFO_CACHE_TIME_DAYS))
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
@@ -35,9 +33,7 @@ class GMA(HTTPSpotProvider):
mode=source_spot["MODE"].upper() if "<>" not in source_spot["MODE"] else None, mode=source_spot["MODE"].upper() if "<>" not in source_spot["MODE"] else None,
# Filter out some weird mode strings # Filter out some weird mode strings
comment=source_spot["TEXT"], comment=source_spot["TEXT"],
sig_refs=[source_spot["REF"]], sig_refs=[SIGRef(id=source_spot["REF"], sig="", name=source_spot["NAME"])],
sig_refs_names=[source_spot["NAME"]],
sig_refs_urls=["https://www.cqgma.org/zinfo.php?ref=" + source_spot["REF"]],
time=datetime.strptime(source_spot["DATE"] + source_spot["TIME"], "%Y%m%d%H%M").replace( time=datetime.strptime(source_spot["DATE"] + source_spot["TIME"], "%Y%m%d%H%M").replace(
tzinfo=pytz.UTC).timestamp(), tzinfo=pytz.UTC).timestamp(),
dx_latitude=float(source_spot["LAT"]) if (source_spot["LAT"] and source_spot["LAT"] != "") else None, dx_latitude=float(source_spot["LAT"]) if (source_spot["LAT"] and source_spot["LAT"] != "") else None,
@@ -45,36 +41,47 @@ class GMA(HTTPSpotProvider):
dx_longitude=float(source_spot["LON"]) if (source_spot["LON"] and source_spot["LON"] != "") else None) dx_longitude=float(source_spot["LON"]) if (source_spot["LON"] and source_spot["LON"] != "") else None)
# GMA doesn't give what programme (SIG) the reference is for until we separately look it up. # GMA doesn't give what programme (SIG) the reference is for until we separately look it up.
ref_response = self.REF_INFO_CACHE.get(self.REF_INFO_URL_ROOT + source_spot["REF"], if "REF" in source_spot:
headers=HTTP_HEADERS) try:
# Sometimes this is blank, so handle that ref_response = SEMI_STATIC_URL_DATA_CACHE.get(self.REF_INFO_URL_ROOT + source_spot["REF"],
if ref_response.text is not None and ref_response.text != "": headers=HTTP_HEADERS)
ref_info = ref_response.json() # Sometimes this is blank, so handle that
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. POTA and WWFF if ref_response.text is not None and ref_response.text != "":
# spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA ref_info = ref_response.json()
# and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry # If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. POTA and WWFF
# to determine if it's a SOTA summit. # spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA
if ref_info["reftype"] not in ["POTA", "WWFF"] and (ref_info["reftype"] != "Summit" or ref_info["sota"] == ""): # and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry
match ref_info["reftype"]: # to determine if it's a SOTA summit.
case "Summit": if "reftype" in ref_info and ref_info["reftype"] not in ["POTA", "WWFF"] and (
spot.sig = "GMA" ref_info["reftype"] != "Summit" or "sota" not in ref_info or ref_info["sota"] == ""):
case "IOTA Island": match ref_info["reftype"]:
spot.sig = "IOTA" case "Summit":
case "Lighthouse (ILLW)": spot.sig_refs[0].sig = "GMA"
spot.sig = "ILLW" spot.sig = "GMA"
case "Lighthouse (ARLHS)": case "IOTA Island":
spot.sig = "ARLHS" spot.sig_refs[0].sig = "IOTA"
case "Castle": spot.sig = "IOTA"
spot.sig = "WCA" case "Lighthouse (ILLW)":
case "Mill": spot.sig_refs[0].sig = "ILLW"
spot.sig = "MOTA" spot.sig = "ILLW"
case _: case "Lighthouse (ARLHS)":
logging.warn("GMA spot found with ref type " + ref_info[ spot.sig_refs[0].sig = "ARLHS"
"reftype"] + ", developer needs to add support for this!") spot.sig = "ARLHS"
spot.sig = ref_info["reftype"] case "Castle":
spot.icon = get_icon_for_sig(spot.sig) spot.sig_refs[0].sig = "WCA"
spot.sig = "WCA"
case "Mill":
spot.sig_refs[0].sig = "MOTA"
spot.sig = "MOTA"
case _:
logging.warn("GMA spot found with ref type " + ref_info[
"reftype"] + ", developer needs to add support for this!")
spot.sig_refs[0].sig = ref_info["reftype"]
spot.sig = ref_info["reftype"]
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do # Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
# that for us. # that for us.
new_spots.append(spot) new_spots.append(spot)
except:
logging.warn("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot["REF"] + ", ignoring this spot for now")
return new_spots return new_spots

View File

@@ -5,7 +5,7 @@ import pytz
import requests import requests
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.sig_utils import get_icon_for_sig from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider from spotproviders.http_spot_provider import HTTPSpotProvider
@@ -53,9 +53,7 @@ class HEMA(HTTPSpotProvider):
mode=freq_mode_match.group(2).upper(), mode=freq_mode_match.group(2).upper(),
comment=spotter_comment_match.group(2), comment=spotter_comment_match.group(2),
sig="HEMA", sig="HEMA",
sig_refs=[spot_items[3].upper()], sig_refs=[SIGRef(id=spot_items[3].upper(), sig="HEMA", name=spot_items[4])],
sig_refs_names=[spot_items[4]],
icon=get_icon_for_sig("HEMA"),
time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M").replace(tzinfo=pytz.UTC).timestamp(), time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M").replace(tzinfo=pytz.UTC).timestamp(),
dx_latitude=float(spot_items[7]), dx_latitude=float(spot_items[7]),
dx_longitude=float(spot_items[8])) dx_longitude=float(spot_items[8]))

41
spotproviders/llota.py Normal file
View 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

View File

@@ -1,13 +1,10 @@
import csv
import logging import logging
import re import re
from datetime import datetime, timedelta from datetime import datetime
import pytz import pytz
from requests_cache import CachedSession
from core.constants import HTTP_HEADERS from data.sig_ref import SIGRef
from core.sig_utils import get_icon_for_sig
from data.spot import Spot from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider from spotproviders.http_spot_provider import HTTPSpotProvider
@@ -17,11 +14,6 @@ class ParksNPeaks(HTTPSpotProvider):
POLL_INTERVAL_SEC = 120 POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://www.parksnpeaks.org/api/ALL" SPOTS_URL = "https://www.parksnpeaks.org/api/ALL"
SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv" SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv"
SIOTA_LIST_CACHE_TIME_DAYS = 30
SIOTA_LIST_CACHE = CachedSession("cache/siota_data_cache", expire_after=timedelta(days=SIOTA_LIST_CACHE_TIME_DAYS))
ZLOTA_LIST_URL = "https://ontheair.nz/assets/assets.json"
ZLOTA_LIST_CACHE_TIME_DAYS = 30
ZLOTA_LIST_CACHE = CachedSession("cache/zlota_data_cache", expire_after=timedelta(days=ZLOTA_LIST_CACHE_TIME_DAYS))
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
@@ -40,50 +32,26 @@ class ParksNPeaks(HTTPSpotProvider):
# Seen PNP spots with empty frequency, and with comma-separated thousands digits # Seen PNP spots with empty frequency, and with comma-separated thousands digits
mode=source_spot["actMode"].upper(), mode=source_spot["actMode"].upper(),
comment=source_spot["actComments"], comment=source_spot["actComments"],
sig=source_spot["actClass"], sig=source_spot["actClass"].upper(),
sig_refs=[source_spot["actSiteID"]], sig_refs=[SIGRef(id=source_spot["actSiteID"], sig=source_spot["actClass"].upper())],
icon=get_icon_for_sig(source_spot["actClass"]),
time=datetime.strptime(source_spot["actTime"], "%Y-%m-%d %H:%M:%S").replace( time=datetime.strptime(source_spot["actTime"], "%Y-%m-%d %H:%M:%S").replace(
tzinfo=pytz.UTC).timestamp()) tzinfo=pytz.UTC).timestamp())
# Free text location is not present in all spots, so only add it if it's set # Free text location is not present in all spots, so only add it if it's set
if "actLocation" in source_spot and source_spot["actLocation"] != "": if "actLocation" in source_spot and source_spot["actLocation"] != "":
spot.sig_refs_names = [source_spot["actLocation"]] spot.sig_refs[0].name = source_spot["actLocation"]
# Extract a de_call if it's in the comment but not in the "actSpoter" field # Extract a de_call if it's in the comment but not in the "actSpoter" field
m = re.search(r"\(de ([A-Za-z0-9]*)\)", spot.comment) m = re.search(r"\(de ([A-Za-z0-9]*)\)", spot.comment)
if (not spot.de_call or spot.de_call == "ZLOTA") and m: if not spot.de_call and m:
spot.de_call = m.group(1) spot.de_call = m.group(1)
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before # Log a warning for the developer if PnP gives us an unknown programme we've never seen before
if spot.sig not in ["POTA", "SOTA", "WWFF", "SiOTA", "ZLOTA", "KRMNPA"]: if spot.sig_refs[0].sig not in ["POTA", "SOTA", "WWFF", "SIOTA", "ZLOTA", "KRMNPA"]:
logging.warn("PNP spot found with sig " + spot.sig + ", developer needs to add support for this!") logging.warn("PNP spot found with sig " + spot.sig + ", developer needs to add support for this!")
# SiOTA lat/lon/grid lookup # If this is POTA, SOTA, WWFF or ZLOTA data we already have it through other means, so ignore. Otherwise,
if spot.sig == "SiOTA": # add to the spot list.
siota_csv_data = self.SIOTA_LIST_CACHE.get(self.SIOTA_LIST_URL, headers=HTTP_HEADERS) if spot.sig_refs[0].sig not in ["POTA", "SOTA", "WWFF", "ZLOTA"]:
siota_dr = csv.DictReader(siota_csv_data.content.decode().splitlines())
for row in siota_dr:
if row["SILO_CODE"] == spot.sig_refs[0]:
spot.dx_latitude = float(row["LAT"])
spot.dx_longitude = float(row["LNG"])
spot.dx_grid = row["LOCATOR"]
break
# ZLOTA name/lat/lon lookup
if spot.sig == "ZLOTA":
zlota_data = self.ZLOTA_LIST_CACHE.get(self.ZLOTA_LIST_URL, headers=HTTP_HEADERS).json()
for asset in zlota_data:
if asset["code"] == spot.sig_refs[0]:
spot.sig_refs_names = [asset["name"]]
spot.dx_latitude = asset["y"]
spot.dx_longitude = asset["x"]
break
# Note there is currently no support for KRMNPA location lookup, see issue #61.
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. Otherwise, add to
# the spot list.
if spot.sig not in ["POTA", "SOTA", "WWFF"]:
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots

View File

@@ -1,11 +1,8 @@
import re from datetime import datetime
from datetime import datetime, timedelta
import pytz import pytz
from requests_cache import CachedSession
from core.constants import HTTP_HEADERS from data.sig_ref import SIGRef
from core.sig_utils import get_icon_for_sig, get_ref_regex_for_sig
from data.spot import Spot from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider from spotproviders.http_spot_provider import HTTPSpotProvider
@@ -14,11 +11,6 @@ from spotproviders.http_spot_provider import HTTPSpotProvider
class POTA(HTTPSpotProvider): class POTA(HTTPSpotProvider):
POLL_INTERVAL_SEC = 120 POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://api.pota.app/spot/activator" SPOTS_URL = "https://api.pota.app/spot/activator"
# Might need to look up extra park data
PARK_URL_ROOT = "https://api.pota.app/park/"
PARK_DATA_CACHE_TIME_DAYS = 30
PARK_DATA_CACHE = CachedSession("cache/pota_park_data_cache",
expire_after=timedelta(days=PARK_DATA_CACHE_TIME_DAYS))
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
@@ -36,29 +28,13 @@ class POTA(HTTPSpotProvider):
mode=source_spot["mode"].upper(), mode=source_spot["mode"].upper(),
comment=source_spot["comments"], comment=source_spot["comments"],
sig="POTA", sig="POTA",
sig_refs=[source_spot["reference"]], sig_refs=[SIGRef(id=source_spot["reference"], sig="POTA", name=source_spot["name"])],
sig_refs_names=[source_spot["name"]],
sig_refs_urls=["https://pota.app/#/park/" + source_spot["reference"]],
icon=get_icon_for_sig("POTA"),
time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S").replace( time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S").replace(
tzinfo=pytz.UTC).timestamp(), tzinfo=pytz.UTC).timestamp(),
dx_grid=source_spot["grid6"], dx_grid=source_spot["grid6"],
dx_latitude=source_spot["latitude"], dx_latitude=source_spot["latitude"],
dx_longitude=source_spot["longitude"]) dx_longitude=source_spot["longitude"])
# Sometimes we can get other refs in the comments for n-fer activations, extract them
all_comment_refs = re.findall(get_ref_regex_for_sig("POTA"), spot.comment)
for r in all_comment_refs:
if r not in spot.sig_refs:
spot.sig_refs.append(r.upper())
spot.sig_refs_urls.append("https://pota.app/#/park/" + r.upper())
# Now we need to look up the name of that reference from the API, because the comment won't have it
park_response = self.PARK_DATA_CACHE.get(self.PARK_URL_ROOT + r.upper(), headers=HTTP_HEADERS)
park_data = park_response.json()
if park_data and "name" in park_data:
spot.sig_refs_names.append(park_data["name"])
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do # Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
# that for us. # that for us.
new_spots.append(spot) new_spots.append(spot)

View File

@@ -1,14 +1,14 @@
import logging import logging
import re import re
from datetime import datetime, timezone from datetime import datetime
from threading import Thread from threading import Thread
from time import sleep from time import sleep
import pytz import pytz
import telnetlib3 import telnetlib3
from data.spot import Spot
from core.config import SERVER_OWNER_CALLSIGN from core.config import SERVER_OWNER_CALLSIGN
from data.spot import Spot
from spotproviders.spot_provider import SpotProvider from spotproviders.spot_provider import SpotProvider
@@ -70,14 +70,13 @@ class RBN(SpotProvider):
de_call=match.group(1), de_call=match.group(1),
freq=float(match.group(2)) * 1000, freq=float(match.group(2)) * 1000,
comment=match.group(4).strip(), comment=match.group(4).strip(),
icon="tower-cell",
time=spot_datetime.timestamp()) time=spot_datetime.timestamp())
# Add to our list # Add to our list
self.submit(spot) self.submit(spot)
self.status = "OK" self.status = "OK"
self.last_update_time = datetime.now(timezone.utc) self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Data received from RBN on port " + str(self.port) + ".") logging.debug("Data received from RBN on port " + str(self.port) + ".")
except Exception as e: except Exception as e:

View File

@@ -1,11 +1,9 @@
import logging from datetime import datetime
from datetime import datetime, timedelta
import requests import requests
from requests_cache import CachedSession
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from core.sig_utils import get_icon_for_sig from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider from spotproviders.http_spot_provider import HTTPSpotProvider
@@ -20,8 +18,6 @@ class SOTA(HTTPSpotProvider):
SPOTS_URL = "https://api-db2.sota.org.uk/api/spots/60/all/all" SPOTS_URL = "https://api-db2.sota.org.uk/api/spots/60/all/all"
# SOTA spots don't contain lat/lon, we need a separate lookup for that # SOTA spots don't contain lat/lon, we need a separate lookup for that
SUMMIT_URL_ROOT = "https://api-db2.sota.org.uk/api/summits/" SUMMIT_URL_ROOT = "https://api-db2.sota.org.uk/api/summits/"
SUMMIT_DATA_CACHE_TIME_DAYS = 30
SUMMIT_DATA_CACHE = CachedSession("cache/sota_summit_data_cache", expire_after=timedelta(days=SUMMIT_DATA_CACHE_TIME_DAYS))
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC) super().__init__(provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
@@ -49,22 +45,8 @@ class SOTA(HTTPSpotProvider):
mode=source_spot["mode"].upper(), mode=source_spot["mode"].upper(),
comment=source_spot["comments"], comment=source_spot["comments"],
sig="SOTA", sig="SOTA",
sig_refs=[source_spot["summitCode"]], sig_refs=[SIGRef(id=source_spot["summitCode"], sig="SOTA", name=source_spot["summitName"], activation_score=source_spot["points"])],
sig_refs_names=[source_spot["summitName"]], time=datetime.fromisoformat(source_spot["timeStamp"].replace("Z", "+00:00")).timestamp())
sig_refs_urls=["https://www.sotadata.org.uk/en/summit/" + source_spot["summitCode"]],
icon=get_icon_for_sig("SOTA"),
time=datetime.fromisoformat(source_spot["timeStamp"]).timestamp(),
activation_score=source_spot["points"])
# SOTA doesn't give summit lat/lon/grid in the main call, so we need another separate call for this
try:
summit_response = self.SUMMIT_DATA_CACHE.get(self.SUMMIT_URL_ROOT + source_spot["summitCode"], headers=HTTP_HEADERS)
summit_data = summit_response.json()
spot.dx_grid = summit_data["locator"]
spot.dx_latitude = summit_data["latitude"]
spot.dx_longitude = summit_data["longitude"]
except Exception:
logging.warn("Looking up summit " + source_spot["summitCode"] + " from the SOTA API failed. No summit data was available.")
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do # Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
# that for us. # that for us.

View File

@@ -2,8 +2,7 @@ from datetime import datetime
import pytz import pytz
from core.constants import SOFTWARE_NAME, SOFTWARE_VERSION from core.config import MAX_SPOT_AGE
from core.config import SERVER_OWNER_CALLSIGN, MAX_SPOT_AGE
# Generic spot provider class. Subclasses of this query the individual APIs for data. # Generic spot provider class. Subclasses of this query the individual APIs for data.
@@ -17,10 +16,12 @@ class SpotProvider:
self.last_spot_time = datetime.min.replace(tzinfo=pytz.UTC) self.last_spot_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled" self.status = "Not Started" if self.enabled else "Disabled"
self.spots = None self.spots = None
self.web_server = None
# Set up the provider, e.g. giving it the spot list to work from # Set up the provider, e.g. giving it the spot list to work from
def setup(self, spots): def setup(self, spots, web_server):
self.spots = spots self.spots = spots
self.web_server = web_server
# Start the provider. This should return immediately after spawning threads to access the remote resources # Start the provider. This should return immediately after spawning threads to access the remote resources
def start(self): def start(self):
@@ -31,24 +32,32 @@ class SpotProvider:
# their infer_missing() method called to complete their data set. This is called by the API-querying # their infer_missing() method called to complete their data set. This is called by the API-querying
# subclasses on receiving spots. # subclasses on receiving spots.
def submit_batch(self, spots): def submit_batch(self, spots):
# Sort the batch so that earliest ones go in first. This helps keep the ordering correct when spots are fired
# off to SSE listeners.
spots = sorted(spots, key=lambda spot: (spot.time if spot and spot.time else 0))
for spot in spots: for spot in spots:
if datetime.fromtimestamp(spot.time, pytz.UTC) > self.last_spot_time: if datetime.fromtimestamp(spot.time, pytz.UTC) > self.last_spot_time:
# Fill in any blanks # Fill in any blanks and add to the list
spot.infer_missing() spot.infer_missing()
# Add to the list self.add_spot(spot)
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
self.last_spot_time = datetime.fromtimestamp(max(map(lambda s: s.time, spots)), pytz.UTC) self.last_spot_time = datetime.fromtimestamp(max(map(lambda s: s.time, spots)), pytz.UTC)
# Submit a single spot retrieved from the provider. This will be added to the list regardless of its age. Spots # Submit a single spot retrieved from the provider. This will be added to the list regardless of its age. Spots
# passing the check will also have their infer_missing() method called to complete their data set. This is called by # passing the check will also have their infer_missing() method called to complete their data set. This is called by
# the data streaming subclasses, which can be relied upon not to re-provide old spots. # the data streaming subclasses, which can be relied upon not to re-provide old spots.
def submit(self, spot): def submit(self, spot):
# Fill in any blanks # Fill in any blanks and add to the list
spot.infer_missing() spot.infer_missing()
# Add to the list self.add_spot(spot)
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
self.last_spot_time = datetime.fromtimestamp(spot.time, pytz.UTC) self.last_spot_time = datetime.fromtimestamp(spot.time, pytz.UTC)
def add_spot(self, spot):
if not spot.expired():
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
# Ping the web server in case we have any SSE connections that need to see this immediately
if self.web_server:
self.web_server.notify_new_spot(spot)
# Stop any threads and prepare for application shutdown # Stop any threads and prepare for application shutdown
def stop(self): def stop(self):
raise NotImplementedError("Subclasses must implement this method") raise NotImplementedError("Subclasses must implement this method")

View File

@@ -9,6 +9,7 @@ from requests_sse import EventSource
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS
from spotproviders.spot_provider import SpotProvider from spotproviders.spot_provider import SpotProvider
# Spot provider using Server-Sent Events. # Spot provider using Server-Sent Events.
class SSESpotProvider(SpotProvider): class SSESpotProvider(SpotProvider):
@@ -34,11 +35,19 @@ class SSESpotProvider(SpotProvider):
if self.thread: if self.thread:
self.thread.join() self.thread.join()
def _on_open(self):
self.status = "Waiting for Data"
def _on_error(self):
self.status = "Connecting"
def run(self): def run(self):
while not self.stopped: while not self.stopped:
try: try:
logging.debug("Connecting to " + self.name + " spot API...") logging.debug("Connecting to " + self.name + " spot API...")
with EventSource(self.url, headers=HTTP_HEADERS, latest_event_id=self.last_event_id, timeout=30) as event_source: self.status = "Connecting"
with EventSource(self.url, headers=HTTP_HEADERS, latest_event_id=self.last_event_id, timeout=30,
on_open=self._on_open, on_error=self._on_error) as event_source:
self.event_source = event_source self.event_source = event_source
for event in self.event_source: for event in self.event_source:
if event.type == 'message': if event.type == 'message':
@@ -58,6 +67,8 @@ class SSESpotProvider(SpotProvider):
except Exception as e: except Exception as e:
self.status = "Error" self.status = "Error"
logging.exception("Exception in SSE Spot Provider (" + self.name + ")") logging.exception("Exception in SSE Spot Provider (" + self.name + ")")
else:
self.status = "Disconnected"
sleep(5) # Wait before trying to reconnect sleep(5) # Wait before trying to reconnect
# Convert an SSE message received from the API into a spot. The whole message data is provided here so the subclass # Convert an SSE message received from the API into a spot. The whole message data is provided here so the subclass

View File

@@ -0,0 +1,84 @@
import re
from datetime import datetime
import pytz
from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider
# Spot provider for UK Packet Radio network API
class UKPacketNet(HTTPSpotProvider):
POLL_INTERVAL_SEC = 600
SPOTS_URL = "https://nodes.ukpacketradio.network/api/nodedata"
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
nodes = http_response.json()["nodes"]
for callsign, node in nodes.items():
# The node corresponse to the spotter here. It has an "mheard" section which indicates which nodes it has
# recently heard, which will be our "DX". But "mheard" stations are not necessarily over RF, they could be
# via the internet, so we also need to look up the "port" on which the station was heard, and check that it
# is RF.
if "mheard" in node:
for heard in node["mheard"]:
heard_port_id = heard["port"]
if "ports" in node:
for listed_port in node["ports"]:
if listed_port["id"] == heard_port_id and listed_port["linkType"] == "RF":
# This is another packet station heard over RF, so we are good to create a Spot object.
# First build a "full" comment combining some of the extra info
comment = listed_port["comment"] if "comment" in listed_port else ""
comment = (comment + " " + listed_port["mode"]) if "mode" in listed_port else comment
comment = (comment + " " + listed_port["modulation"]) if "modulation" in listed_port else comment
comment = (comment + " " + str(listed_port["baud"]) + " baud") if "baud" in listed_port and listed_port["baud"] > 0 else comment
# Get frequency from the comment if it's not set properly in the data structure. This is
# very hacky but a lot of node comments contain their frequency as the first or second
# word of their comment, but not in the proper data structure field.
freq = listed_port["freq"] if "freq" in listed_port and listed_port["freq"] > 0 else None
if not freq and comment:
possible_freq = comment.split(" ")[0].upper().replace("MHZ", "")
if re.match(r"^[0-9.]+$", possible_freq) and possible_freq != "1200" and possible_freq != "9600":
freq = float(possible_freq) * 1000000
if not freq and len(comment.split(" ")) > 1:
possible_freq = comment.split(" ")[1].upper().replace("MHZ", "")
if re.match(r"^[0-9.]+$", possible_freq) and possible_freq != "1200" and possible_freq != "9600":
freq = float(possible_freq) * 1000000
# Check for a found frequency likely having been in kHz, sorry to all GHz packet folks
if freq and freq > 1000000000:
freq = freq / 1000
# Now build the spot object
spot = Spot(source=self.name,
dx_call=heard["callsign"].upper(),
de_call=node["callsign"].upper(),
freq=freq,
mode="PKT",
comment=comment,
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"],
de_longitude=node["location"]["coords"]["lon"])
# 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)
break
# Now we have a list of every node that heard every other node via RF, as Spothole spots. What each spot doesn't
# yet have is a DX lat/lon/grid, because the data doesn't provide the location of the "mheard" stations within
# the structure. However, each "heard" station should also be represented in the list somewhere with its own
# data, and we can use that to look these up.
for spot in new_spots:
if spot.dx_call in nodes:
spot.dx_grid = nodes[spot.dx_call]["location"]["locator"] if "locator" in nodes[spot.dx_call]["location"] else None
spot.dx_latitude = nodes[spot.dx_call]["location"]["coords"]["lat"]
spot.dx_longitude = nodes[spot.dx_call]["location"]["coords"]["lon"]
return new_spots

View File

@@ -0,0 +1,75 @@
import logging
from datetime import datetime
from threading import Thread
from time import sleep
import pytz
from websocket import create_connection
from core.constants import HTTP_HEADERS
from spotproviders.spot_provider import SpotProvider
# Spot provider using websockets.
class WebsocketSpotProvider(SpotProvider):
def __init__(self, provider_config, url):
super().__init__(provider_config)
self.url = url
self.ws = None
self.thread = None
self.stopped = False
self.last_event_id = None
def start(self):
logging.info("Set up websocket connection to " + self.name + " spot API.")
self.stopped = False
self.thread = Thread(target=self.run)
self.thread.daemon = True
self.thread.start()
def stop(self):
self.stopped = True
if self.ws:
self.ws.close()
if self.thread:
self.thread.join()
def _on_open(self):
self.status = "Waiting for Data"
def _on_error(self):
self.status = "Connecting"
def run(self):
while not self.stopped:
try:
logging.debug("Connecting to " + self.name + " spot API...")
self.status = "Connecting"
self.ws = create_connection(self.url, header=HTTP_HEADERS)
self.status = "Connected"
data = self.ws.recv()
if data:
try:
new_spot = self.ws_message_to_spot(data)
if new_spot:
self.submit(new_spot)
self.status = "OK"
self.last_update_time = datetime.now(pytz.UTC)
logging.debug("Received data from " + self.name + " spot API.")
except Exception as e:
logging.exception("Exception processing message from Websocket Spot Provider (" + self.name + ")")
except Exception as e:
self.status = "Error"
logging.exception("Exception in Websocket Spot Provider (" + self.name + ")", e)
else:
self.status = "Disconnected"
sleep(5) # Wait before trying to reconnect
# Convert a WS message received from the API into a spot. The exact message data (in bytes) is provided here so the
# subclass implementations can handle the message as string, JSON, XML, whatever the API actually provides.
def ws_message_to_spot(self, bytes):
raise NotImplementedError("Subclasses must implement this method")

77
spotproviders/wota.py Normal file
View File

@@ -0,0 +1,77 @@
import logging
import re
from datetime import datetime
import pytz
from rss_parser import RSSParser
from data.sig_ref import SIGRef
from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider
# Spot provider for Wainwrights on the Air
class WOTA(HTTPSpotProvider):
POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://www.wota.org.uk/spots_rss.php"
LIST_URL = "https://www.wota.org.uk/mapping/data/summits.json"
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
def __init__(self, provider_config):
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
def http_response_to_spots(self, http_response):
new_spots = []
rss = RSSParser.parse(http_response.content.decode())
# Iterate through source data
for source_spot in rss.channel.items:
try:
# Reject GUID missing or zero
if not source_spot.guid or not source_spot.guid.content or source_spot.guid.content == "http://www.wota.org.uk/spots/0":
continue
# Pick apart the title
title_split = source_spot.title.split(" on ")
dx_call = title_split[0]
ref = None
ref_name = None
if len(title_split) > 1:
ref_split = title_split[1].split(" - ")
ref = ref_split[0]
if len(ref_split) > 1:
ref_name = ref_split[1]
# Pick apart the description
desc_split = source_spot.description.split(". ")
freq_mode = desc_split[0].replace("Frequencies/modes:", "").strip()
freq_mode_split = re.split(r'[\-\s]+', freq_mode)
freq_hz = float(freq_mode_split[0]) * 1000000
if len(freq_mode_split) > 1:
mode = freq_mode_split[1].upper()
comment = None
if len(desc_split) > 1:
comment = desc_split[1].strip()
spotter = None
if len(desc_split) > 2:
spotter = desc_split[2].replace("Spotted by ", "").replace(".", "").upper().strip()
time = datetime.strptime(source_spot.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
# Convert to our spot format
spot = Spot(source=self.name,
source_id=source_spot.guid.content,
dx_call=dx_call,
de_call=spotter,
freq=freq_hz,
mode=mode,
comment=comment,
sig="WOTA",
sig_refs=[SIGRef(id=ref, sig="WOTA", name=ref_name)] if ref else [],
time=time.timestamp())
new_spots.append(spot)
except Exception as e:
logging.error("Exception parsing WOTA spot", e)
return new_spots

View File

@@ -1,7 +1,7 @@
import json import json
from datetime import datetime from datetime import datetime
from core.sig_utils import get_icon_for_sig from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from spotproviders.sse_spot_provider import SSESpotProvider from spotproviders.sse_spot_provider import SSESpotProvider
@@ -18,17 +18,9 @@ class WWBOTA(SSESpotProvider):
# Convert to our spot format. First we unpack references, because WWBOTA spots can have more than one for # Convert to our spot format. First we unpack references, because WWBOTA spots can have more than one for
# n-fer activations. # n-fer activations.
refs = [] refs = []
ref_names = []
ref_urls = []
for ref in source_spot["references"]: for ref in source_spot["references"]:
refs.append(ref["reference"]) sigref = SIGRef(id=ref["reference"], sig="WWBOTA", name=ref["name"])
ref_names.append(ref["name"]) refs.append(sigref)
# Bunkerbase URLs only work for UK bunkers, so only add a URL if we have a B/G prefix. In theory this could
# lead to array alignment mismatches if there was e.g. a B/F bunker followed by a B/G one, we'd end up with
# the B/G URL in index 0. But in practice there are no overlaps between B/G bunkers and any others, so an
# activation will either be entirely B/G or not B/G at all.
if ref["reference"].startswith("B/G"):
ref_urls.append("https://bunkerwiki.org/?s=" + ref["reference"])
spot = Spot(source=self.name, spot = Spot(source=self.name,
dx_call=source_spot["call"].upper(), dx_call=source_spot["call"].upper(),
@@ -38,9 +30,7 @@ class WWBOTA(SSESpotProvider):
comment=source_spot["comment"], comment=source_spot["comment"],
sig="WWBOTA", sig="WWBOTA",
sig_refs=refs, sig_refs=refs,
sig_refs_names=ref_names, time=datetime.fromisoformat(source_spot["time"].replace("Z", "+00:00")).timestamp(),
icon=get_icon_for_sig("WWBOTA"),
time=datetime.fromisoformat(source_spot["time"]).timestamp(),
# WWBOTA spots can contain multiple references for bunkers being activated simultaneously. For # 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. # now, we will just pick the first one to use as our grid, latitude and longitude.
dx_grid=source_spot["references"][0]["locator"], dx_grid=source_spot["references"][0]["locator"],

View File

@@ -2,7 +2,7 @@ from datetime import datetime
import pytz import pytz
from core.sig_utils import get_icon_for_sig from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider from spotproviders.http_spot_provider import HTTPSpotProvider
@@ -28,10 +28,7 @@ class WWFF(HTTPSpotProvider):
mode=source_spot["mode"].upper(), mode=source_spot["mode"].upper(),
comment=source_spot["remarks"], comment=source_spot["remarks"],
sig="WWFF", sig="WWFF",
sig_refs=[source_spot["reference"]], sig_refs=[SIGRef(id=source_spot["reference"], sig="WWFF", name=source_spot["reference_name"])],
sig_refs_names=[source_spot["reference_name"]],
sig_refs_urls=["https://wwff.co/directory/?showRef=" + source_spot["reference"]],
icon=get_icon_for_sig("WWFF"),
time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC).timestamp(), time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC).timestamp(),
dx_latitude=source_spot["latitude"], dx_latitude=source_spot["latitude"],
dx_longitude=source_spot["longitude"]) dx_longitude=source_spot["longitude"])

55
spotproviders/xota.py Normal file
View File

@@ -0,0 +1,55 @@
import csv
import json
import logging
from datetime import datetime
import pytz
from data.sig_ref import SIGRef
from data.spot import Spot
from spotproviders.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 a SIG and a reference
# to a local CSV file with location information. This functionality is implemented for TOTA events, of which there are
# several - so a plain lookup of a "TOTA reference" doesn't make sense, it depends on which TOTA and hence which server
# supplied the data, which is why the CSV location lookup is here and not in sig_utils.
class XOTA(WebsocketSpotProvider):
LOCATION_DATA = {}
SIG = None
def __init__(self, provider_config):
super().__init__(provider_config, provider_config["url"])
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=ref_id, sig=self.SIG, url=source_spot["reference"]["website"], latitude=lat, longitude=lon)],
time=datetime.now(pytz.UTC).timestamp(),
dx_latitude=lat,
dx_longitude=lon,
qrt=source_spot["state"] != "active")
return spot

41
spotproviders/zlota.py Normal file
View File

@@ -0,0 +1,41 @@
from datetime import datetime
import pytz
from data.sig_ref import SIGRef
from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider
# Spot provider for ZLOTA
class ZLOTA(HTTPSpotProvider):
POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://ontheair.nz/api/spots?zlota_only=true"
LIST_URL = "https://ontheair.nz/assets/assets.json"
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():
# Frequency is often inconsistent as to whether it's in Hz or kHz. Make a guess.
freq_hz = float(source_spot["frequency"])
if freq_hz < 1000000:
freq_hz = freq_hz * 1000
# Convert to our spot format
spot = Spot(source=self.name,
source_id=source_spot["id"],
dx_call=source_spot["activator"].upper(),
de_call=source_spot["spotter"].upper(),
freq=freq_hz,
mode=source_spot["mode"].upper().strip(),
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"].replace("Z", "+00:00")).astimezone(pytz.UTC).timestamp())
new_spots.append(spot)
return new_spots

69
templates/about.html Normal file
View File

@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block content %}
<div id="info-container" class="mt-4">
<h2 class="mt-4 mb-4">About Spothole</h2>
<p>Spothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data.</p>
<p>While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outdoor activity programmes for amateur radio, Spothole differentiates itself by supporting a larger number of data sources, and by being "API first" rather than just providing a web front-end. This allows other software to be built on top of it.</p>
<p>The API is deliberately well-defined with an <a href="/apidocs/openapi.yml">OpenAPI specification</a> and <a href="/apidocs">API documentation</a>. The API delivers spots in a consistent format regardless of the data source, freeing developers from needing to know how each individual data source presents its data.</p>
<p>Spothole itself is also open source, Public Domain licenced code that anyone can take and modify. <a href="https://git.ianrenton.com/ian/metaspot/">The source code is here</a>.</p>
<p>The software was written by <a href="https://ianrenton.com">Ian Renton, MØTRT</a> and other contributors. Full details are available in the <a href="https://git.ianrenton.com/ian/spothole/src/branch/main/README.md">README file</a>.</p>
<p>This server is running Spothole version {{software_version}}.</p>
<h2 class="mt-4 mb-4">Using Spothole</h2>
<p>There are a number of different ways to use Spothole, depending on what you want to do with it and your level of technical skill:</p>
<ol><li>You can <b>use it on the web</b>, like you are (probably) doing right now. This is how most people use it, to look up spots and alerts, and make interesting QSOs.</li>
<li>If you are using an Android or iOS device, you can <b>"install" it on your device</b>. Spothole is a Progressive Web App, meaning it's not delivered through app stores, but if you open the page on Chrome (Android) or Safari (iOS) there will be an option in the menu to install it. It will then appear in your main app menu.</li>
<li>You can <b>embed the web interface in another website</b> to show its spots in a custom dashboard or the like. The usage is explained in more detail in the <a href="https://git.ianrenton.com/ian/spothole/src/branch/main/README.md">README file</a>.</li>
<li>You can <b>write your own client using the Spothole API</b>, using the main Spothole instance to provide data, and do whatever you like with it. The README contains guidance on how to do this, and the full API docs are linked above. You can also find reference implementations in the form of Spothole's own web-based front end, plus my other two tools built on Spothole: <a href="https://fieldspotter.radio">Field Spotter</a> and the <a href="https://qsomap.m0trt.radio">QSO Map Tool</a>.</li>
<li>If you want to <b>run your own version of Spothole</b> so you can customise the configuration, such as enabling sources that I disable on the main instance, you can do that too. The README contains not only advice on how to set up Spothole but how to get it auto-starting with systemd, using an nginx reverse proxy, and setting up HTTPS support with certbot.</li>
<li>Finally, you can of course download the source code and <b>develop Spothole to meet your needs</b>. Whether you contribute your changes back to the main repository is up to you. As usual, the README file contains some advice on the structure of the repository, and how to get started writing your own spot provider.</li></ol>
<h2 id="faq" class="mt-4">FAQ</h2>
<h4 class="mt-4">"Spots"? "DX Clusters"? What does any of this mean?</h4>
<p>This is a tool for amateur ("ham") radio users. Many amateur radio operators like to make contacts with others who are doing something more interesting than sitting in their home "shack", such as people in rarely-seen countries, remote islands, or on mountaintops. Such operators are often "spotted", i.e. when someone speaks to them, they will put the details such as their operating frequency into an online system, to let others know where to find them. A DX Cluster is one type of those systems. Most outdoor radio awards programmes, such as "Parks on the Air" (POTA) have their own websites for posting spots.</p>
<p>Spothole is an "aggregator" for those spots, so it checks lots of different services for data, and brings it all together in one place. So no matter what kinds of interesting spots you are looking for, you can find them here.</p>
<p>As well as spots, it also provides a similar feed of "alerts". This is where amateur radio users who are going to interesting places soon will announce their intentions.</p>
<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>, <a href="https://llota.app">LLOTA</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), 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>
<ol><li>Sources like GMA and Parks 'n' Peaks provide spots for multiple different programmes (SIGs).</li>
<li>Cluster spots may name SIGs in their comment, in which case the source remains the Cluster, but a SIG is assigned.</li>
<li>Some SIGs, such as Worked all Britain (WAB), don't have their own spotting site and can <em>only</em> be identified through comments on spots retrieved from other sources.</li>
<li>SIGs have well-defined names, whereas the server owner may name the sources as they see fit.</li></ol>
<p>Spothole's web interface exists not just for the end user, but also as a reference implementation for the API, so I have chosen to demonstrate both methods of filtering.</p>
<h4 class="mt-4">How is this better than DXheat, DXsummit, POTA's own website, etc?</h4>
<p>It's probably not? But it's nice to have choice.</p>
<p>I think it's got three key advantages over those sites:</p>
<ol><li>It provides a public, <a href="/apidocs">well-documented API</a> with an <a href="/apidocs/openapi.yml">OpenAPI specification</a>. Other sites don't have official APIs or don't bother documenting them publicly, because they want people to use their web page. I like Spothole's web page, but you don't have to use it&mdash;if you're a programmer, you can build your own software on Spothole's API. Spothole does the hard work of taking all the various data sources and providing a consistent, well-documented data set. You can then do the fun bit of writing your own application.</li>
<li>It grabs data from a lot more sources. I've seen other sites that pull in DX Cluster and POTA spots together, but nothing on the scale of what Spothole supports.</li>
<li>Spothole is open source, so anyone can contribute the code to support a new data source or add new features, and share them with the community.</li></ol>
<h4 class="mt-4">Why does this website ask me if I want to install it?</h4>
<p>Spothole is a Progressive Web App, which means you can install it on an Android or iOS device by opening the site in Chrome or Safari respectively, and clicking "Install" on the pop-up panel. It'll only prompt you once, so if you dismiss the prompt and change your mind, you'll find an Install / Add to Home Screen option on your browser's menu.</p>
<p>Installing Spothole on your phone is completely optional, the website works exactly the same way as the "app" does.</p>
<h4 class="mt-4">Why hasn't my spot/alert shown up yet?</h4>
<p>To avoid putting too much load on the various servers that Spothole connects to, the Spothole server only polls them once every two minutes for spots, and once every 30 minutes for alerts. (Some sources, such as DX clusters, RBN, APRS-IS and WWBOTA use a non-polling mechanism, and their updates will therefore arrive more quickly.) Then if you are using the web interface, that has its own rate at which it fetches the data from Spothole. This is instant for the main spots list, with new spots appearing immediately at the top of the list, while the map and bands displays update once a minute, and the alerts display updates once every 5 minutes. So you could be waiting around three minutes to see a newly added spot, or 40 minutes to see a newly added alert.</p>
<h4 class="mt-4">What licence does Spothole use?</h4>
<p>Spothole's source code is licenced under the Public Domain. You can write a Spothole client, run your own server, modify it however you like, you can claim you wrote it and charge people £1000 for a copy, I don't really mind. (Please don't do the last one. But if you're using my code for something cool, it would be nice to hear from you!)</p>
<h2 class="mt-4">Data Accuracy</h2>
<p>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. There are also plenty of cases where Spothole's data, particularly location data, may be inaccurate. For example, there are POTA parks that span multiple US states, countries that span multiple CQ zones, portable operators with no requirement to sign /P, etc. 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.</p>
<h2 id="privacy" class="mt-4">Privacy</h2>
<p>Spothole collects no data about you, and there is no way to enter personally identifying information into the site apart from by spotting and alerting through Spothole or the various services it connects to. All spots and alerts are "timed out" and deleted from the system after a set interval, which by default is one hour for spots and one week for alerts.</p>
<p>Settings you select from Spothole's menus are sent to the server, in order to provide the data with the requested filters. They are also stored in your browser's local storage, so that your preferences are remembered between sessions.</p>
<p>There are no trackers, no ads, and no cookies.</p>
<p>Spothole is open source, so you can audit <a href="https://git.ianrenton.com/ian/spothole">the code</a> if you like.</p>
<h2 class="mt-4">Thanks</h2>
<p>This project would not have been possible without those volunteers who have taken it upon themselves to run DX clusters, xOTA programmes, DXpedition lists, callsign lookup databases, and other online tools on which Spothole's data is based.</p>
<p>Spothole is also dependent on a number of Python libraries, in particular pyhamtools, and many JavaScript libraries, as well as the Font Awesome icon set and flag icons from the Noto Color Emoji set.</p>
<p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p>
</div>
<script src="/js/common.js?v=6"></script>
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

76
templates/add_spot.html Normal file
View File

@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block content %}
<div id="add-spot-intro-box" class="permanently-dismissible-box mt-3">
<div class="alert alert-primary alert-dismissible fade show" role="alert">
<i class="fa-solid fa-circle-info"></i> <strong>Adding spots to Spothole</strong><br/>This page is implemented as a proof of concept for adding spots to the Spothole system. Currently, spots added in this way are only visible within Spothole and are not sent "upstream" to DX clusters or xOTA spotting sites. The functionality might be extended to include this in future if there is demand for it. If you'd like this to be added, please give a thumbs-up on <a href="https://git.ianrenton.com/ian/spothole/issues/39" target="_new" class="alert-link">issue #39</a> or get in touch via email.
<button type="button" id="add-spot-intro-box-dismiss" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
<div class="mt-3">
<div id="add-spot-area" class="card mb-3">
<div class="card-header text-white bg-primary">
<div class="row">
<div class="col-auto me-auto">
Add a Spot
</div>
</div>
</div>
<div class="card-body">
<form class="row g-3">
<div class="col-auto">
<label for="dx-call" class="form-label">DX Call *</label>
<input type="text" class="form-control" id="dx-call" placeholder="N0CALL" style="max-width: 8em;">
</div>
<div class="col-auto">
<label for="freq" class="form-label">Frequency (kHz) *</label>
<input type="text" class="form-control" id="freq" placeholder="e.g. 14100" style="max-width: 8em;">
</div>
<div class="col-auto">
<label for="mode" class="form-label">Mode</label>
<select id="mode" class="form-select">
<option value="" selected></option>
</select>
</div>
<div class="col-auto">
<label for="sig" class="form-label">SIG</label>
<select id="sig" class="form-select">
<option value="" selected></option>
</select>
</div>
<div class="col-auto">
<label for="sig-ref" class="form-label">SIG Reference</label>
<input type="text" class="form-control" id="sig-ref" placeholder="e.g. GB-0001" style="max-width: 8em;">
</div>
<div class="col-auto">
<label for="dx-grid" class="form-label">DX Grid</label>
<input type="text" class="form-control" id="dx-grid" placeholder="e.g. AA00aa" style="max-width: 8em;">
</div>
<div class="col-auto">
<label for="comment" class="form-label">Comment</label>
<input type="text" class="form-control" id="comment" placeholder="e.g. 59 TNX QSO 73" style="max-width: 12em;">
</div>
<div class="col-auto">
<label for="de-call" class="form-label">Your Call *</label>
<input type="text" class="form-control storeable-text" id="de-call" placeholder="N0CALL" style="max-width: 8em;">
</div>
<div class="col-auto">
<button type="button" class="btn btn-primary" style="margin-top: 2em;" onclick="addSpot();">Spot</button>
</div>
</form>
<div id="result-good"></div>
<div id="result-bad"></div>
<p class="small mt-4 mb-1">* Required field</p>
</div>
</div>
</div>
<script src="/js/common.js?v=6"></script>
<script src="/js/add-spot.js?v=6"></script>
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -1,7 +1,8 @@
% rebase('webpage_base.tpl') {% extends "base.html" %}
{% block content %}
<div class="mt-3"> <div class="mt-3">
<div class="row"> <div id="settingsButtonRow" class="row">
<div class="col-auto me-auto pt-3"> <div class="col-auto me-auto pt-3">
<p id="timing-container">Loading...</p> <p id="timing-container">Loading...</p>
</div> </div>
@@ -101,17 +102,25 @@
<h5 class="card-title">Number of Alerts</h5> <h5 class="card-title">Number of Alerts</h5>
<p class="card-text spothole-card-text">Show up to <p class="card-text spothole-card-text">Show up to
<select id="alerts-to-fetch" class="storeable-select form-select ms-2" oninput="filtersUpdated();" style="width: 5em;display: inline-block;"> <select id="alerts-to-fetch" class="storeable-select form-select ms-2" oninput="filtersUpdated();" style="width: 5em;display: inline-block;">
<option value="25">25</option>
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="200">200</option>
<option value="500">500</option>
</select> </select>
alerts alerts
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Theme</h5>
<div class="form-group">
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
<label class="form-check-label" for="darkMode">Dark mode</label>
</div>
</div>
</div>
</div>
</div>
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@@ -153,10 +162,14 @@
</div> </div>
</div> </div>
<div id="table-container"></div> <div id="table-container">
<table id="table" class="table"><thead><tr class="table-primary"></tr></thead><tbody></tbody></table>
</div>
</div> </div>
<script src="/js/common.js"></script> <script src="/js/common.js?v=6"></script>
<script src="/js/alerts.js"></script> <script src="/js/alerts.js?v=6"></script>
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -1,5 +1,8 @@
% rebase('webpage_base.tpl') {% extends "base.html" %}
{% block content %}
<redoc spec-url="/apidocs/openapi.yml"></redoc> <redoc spec-url="/apidocs/openapi.yml"></redoc>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"> </script> <script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"> </script>
<script>$(document).ready(function() { $("#nav-link-api").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-api").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -1,13 +1,8 @@
% rebase('webpage_base.tpl') {% extends "base.html" %}
{% block content %}
<div class="mt-3"> <div class="mt-3">
<div class="alert alert-warning" role="alert"> <div id="settingsButtonRow" class="row">
<i class="fa-solid fa-triangle-exclamation"></i> This page is a work in progress. It will be refined as Spothole heads towards v1.0.
</div>
</div>
<div class="mt-3">
<div class="row">
<div class="col-auto me-auto pt-3"> <div class="col-auto me-auto pt-3">
<p id="timing-container">Loading...</p> <p id="timing-container">Loading...</p>
</div> </div>
@@ -32,7 +27,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row row-cols-1 g-4 mb-4"> <div class="row row-cols-1 g-4 mb-4 row-cols-md-3">
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@@ -41,8 +36,24 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">SIGs</h5>
<p id="sig-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Sources</h5>
<p id="source-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
</div> </div>
<div class="row row-cols-1 row-cols-md-4 g-4"> <div class="row row-cols-1 row-cols-md-3 g-4">
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@@ -67,14 +78,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Sources</h5>
<p id="source-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -99,16 +102,30 @@
<h5 class="card-title">Spot Age</h5> <h5 class="card-title">Spot Age</h5>
<p class="card-text spothole-card-text">Last <p class="card-text spothole-card-text">Last
<select id="max-spot-age" class="storeable-select form-select ms-2 me-2 d-inline-block" oninput="filtersUpdated();" style="width: 5em; display: inline-block;"> <select id="max-spot-age" class="storeable-select form-select ms-2 me-2 d-inline-block" oninput="filtersUpdated();" style="width: 5em; display: inline-block;">
<option value="300">5</option>
<option value="600">10</option>
<option value="1800" selected>30</option>
<option value="3600">60</option>
</select> </select>
minutes minutes
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Theme</h5>
<div class="form-group">
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
<label class="form-check-label" for="darkMode">Dark mode</label>
</div>
<p class="card-text spothole-card-text">
Band color scheme<br/>
<select id="band-color-scheme" class="storeable-select form-select d-inline-block" oninput="setBandColorSchemeFromUI();" style="display: inline-block;">
</select>
</p>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -117,7 +134,9 @@
</div> </div>
<script src="/js/common.js"></script> <script src="/js/common.js?v=6"></script>
<script src="/js/spotandmap.js"></script> <script src="/js/spotsbandsandmap.js?v=6"></script>
<script src="/js/bands.js"></script> <script src="/js/bands.js?v=6"></script>
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="color-scheme" content="light"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="white"/> <meta name="theme-color" content="white"/>
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
@@ -31,10 +31,11 @@
<link href="/fa/css/solid.min.css" rel="stylesheet" /> <link href="/fa/css/solid.min.css" rel="stylesheet" />
<link rel="icon" type="image/png" href="/img/icon-512.png"> <link rel="icon" type="image/png" href="/img/icon-512.png">
<link rel="apple-touch-icon" href="img/icon-512-pwa.png">
<link rel="alternate icon" type="image/png" href="/img/icon-192.png"> <link rel="alternate icon" type="image/png" href="/img/icon-192.png">
<link rel="alternate icon" type="image/png" href="/img/icon-32.png"> <link rel="alternate icon" type="image/png" href="/img/icon-32.png">
<link rel="alternate icon" type="image/png" href="/img/icon-16.png"> <link rel="alternate icon" type="image/png" href="/img/icon-16.png">
<link rel="alternate icon" type="image/x-icon" href="/img/favicon.ico"> <link rel="alternate icon" type="image/x-icon" href="/favicon.ico">
<link rel="manifest" href="manifest.webmanifest"> <link rel="manifest" href="manifest.webmanifest">
@@ -43,24 +44,33 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/tinycolor2@1.6.0/cjs/tinycolor.min.js"></script>
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=6"></script>
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=6"></script>
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=6"></script>
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=6"></script>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<nav class="navbar navbar-expand-lg bg-body p-0 border-bottom"> <nav id="header" class="navbar navbar-expand-lg bg-body p-0 border-bottom">
<div class="container-fluid p-0"> <div class="container-fluid p-0">
<a class="navbar-brand" href="/"> <a class="navbar-brand" href="/">
<img src="/img/logo.png" width="192" height="60" alt="Spothole"> <img src="/img/logo.png" class="logo" width="192" height="60" alt="Spothole">
</a> </a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarTogglerDemo02" aria-controls="navbarTogglerDemo02" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-toggler-content" aria-controls="navbar-toggler-content" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navbarTogglerDemo02"> <div class="collapse navbar-collapse" id="navbar-toggler-content">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item ms-4"><a href="/" class="nav-link" id="nav-link-spots"><i class="fa-solid fa-tower-cell"></i> Spots</a></li> <li class="nav-item ms-4"><a href="/" class="nav-link" id="nav-link-spots"><i class="fa-solid fa-tower-cell"></i> Spots</a></li>
<li class="nav-item ms-4"><a href="/map" class="nav-link" id="nav-link-map"><i class="fa-solid fa-map"></i> Map</a></li> <li class="nav-item ms-4"><a href="/map" class="nav-link" id="nav-link-map"><i class="fa-solid fa-map"></i> Map</a></li>
<li class="nav-item ms-4"><a href="/bands" class="nav-link" id="nav-link-bands"><i class="fa-solid fa-ruler-vertical"></i> Bands</a></li> <li class="nav-item ms-4"><a href="/bands" class="nav-link" id="nav-link-bands"><i class="fa-solid fa-ruler-vertical"></i> Bands</a></li>
<li class="nav-item ms-4"><a href="/alerts" class="nav-link" id="nav-link-alerts"><i class="fa-solid fa-bell"></i> Alerts</a></li> <li class="nav-item ms-4"><a href="/alerts" class="nav-link" id="nav-link-alerts"><i class="fa-solid fa-bell"></i> Alerts</a></li>
{% if allow_spotting %}
<li class="nav-item ms-4"><a href="/add-spot" class="nav-link" id="nav-link-add-spot"><i class="fa-solid fa-comment"></i> Add&nbsp;Spot</a></li>
{% end %}
<li class="nav-item ms-4"><a href="/status" class="nav-link" id="nav-link-status"><i class="fa-solid fa-chart-simple"></i> Status</a></li> <li class="nav-item ms-4"><a href="/status" class="nav-link" id="nav-link-status"><i class="fa-solid fa-chart-simple"></i> Status</a></li>
<li class="nav-item ms-4"><a href="/about" class="nav-link" id="nav-link-about"><i class="fa-solid fa-circle-info"></i> About</a></li> <li class="nav-item ms-4"><a href="/about" class="nav-link" id="nav-link-about"><i class="fa-solid fa-circle-info"></i> About</a></li>
<li class="nav-item ms-4"><a href="/apidocs" class="nav-link" id="nav-link-api"><i class="fa-solid fa-gear"></i> API</a></li> <li class="nav-item ms-4"><a href="/apidocs" class="nav-link" id="nav-link-api"><i class="fa-solid fa-gear"></i> API</a></li>
@@ -71,11 +81,11 @@
<main> <main>
{{!base}} {% block content %}{% end %}
</main> </main>
<div class="hideonmobile hideonmap"> <div id="footer" class="hideonmobile hideonmap">
<footer class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top"> <footer class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top">
<p class="col-md-4 mb-0 text-body-secondary">Made with love by <a href="https://ianrenton.com" class="text-body-secondary">Ian, MØTRT</a> and other contributors.</p> <p class="col-md-4 mb-0 text-body-secondary">Made with love by <a href="https://ianrenton.com" class="text-body-secondary">Ian, MØTRT</a> and other contributors.</p>
<p class="col-md-4 mb-0 justify-content-center text-body-secondary" style="text-align: center;">Spothole v{{software_version}}</p> <p class="col-md-4 mb-0 justify-content-center text-body-secondary" style="text-align: center;">Spothole v{{software_version}}</p>
@@ -97,5 +107,7 @@
</div> </div>
</div> </div>
<div id="embeddedModeFooter" class="text-body-secondary pt-2 px-3 pb-1">Powered by <img src="/img/logo.png" class="logo" width="96" height="30" alt="Spothole"></div>
</body> </body>
</html> </html>

View File

@@ -1,7 +1,8 @@
% rebase('webpage_base.tpl') {% extends "base.html" %}
{% block content %}
<div id="map"> <div id="map">
<div class="mt-3 px-3" style="z-index: 1002; position: relative;"> <div id="settingsButtonRowMap" class="mt-3 px-3" style="z-index: 1002; position: relative;">
<div class="row"> <div class="row">
<div class="col-auto me-auto pt-3"></div> <div class="col-auto me-auto pt-3"></div>
<div class="col-auto"> <div class="col-auto">
@@ -25,7 +26,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row row-cols-1 g-4 mb-4"> <div class="row row-cols-1 g-4 mb-4 row-cols-md-3">
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@@ -34,8 +35,24 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">SIGs</h5>
<p id="sig-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Sources</h5>
<p id="source-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
</div> </div>
<div class="row row-cols-1 row-cols-md-4 g-4"> <div class="row row-cols-1 row-cols-md-3 g-4">
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@@ -60,14 +77,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Sources</h5>
<p id="source-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -92,10 +101,6 @@
<h5 class="card-title">Spot Age</h5> <h5 class="card-title">Spot Age</h5>
<p class="card-text spothole-card-text">Last <p class="card-text spothole-card-text">Last
<select id="max-spot-age" class="storeable-select form-select ms-2 me-2 d-inline-block" oninput="filtersUpdated();" style="width: 5em; display: inline-block;"> <select id="max-spot-age" class="storeable-select form-select ms-2 me-2 d-inline-block" oninput="filtersUpdated();" style="width: 5em; display: inline-block;">
<option value="300">5</option>
<option value="600">10</option>
<option value="1800" selected>30</option>
<option value="3600">60</option>
</select> </select>
minutes minutes
</p> </p>
@@ -115,6 +120,24 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Theme</h5>
<div class="form-group">
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
<label class="form-check-label" for="darkMode">Dark mode</label>
</div>
<p class="card-text spothole-card-text">
Band color scheme<br/>
<select id="band-color-scheme" class="storeable-select form-select d-inline-block" oninput="setBandColorSchemeFromUI();" style="display: inline-block;">
</select>
</p>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -129,7 +152,9 @@
<script src="https://cdn.jsdelivr.net/npm/leaflet.geodesic"></script> <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="https://cdn.jsdelivr.net/npm/@joergdietrich/leaflet.terminator@1.1.0/L.Terminator.min.js"></script>
<script src="/js/common.js"></script> <script src="/js/common.js?v=6"></script>
<script src="/js/spotandmap.js"></script> <script src="/js/spotsbandsandmap.js?v=6"></script>
<script src="/js/map.js"></script> <script src="/js/map.js?v=6"></script>
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -1,6 +1,7 @@
% rebase('webpage_base.tpl') {% extends "base.html" %}
{% block content %}
<div id="intro-box" class="mt-3"> <div id="intro-box" class="permanently-dismissible-box mt-3">
<div class="alert alert-primary alert-dismissible fade show" role="alert"> <div class="alert alert-primary alert-dismissible fade show" role="alert">
<i class="fa-solid fa-circle-info"></i> <strong>What is Spothole?</strong><br/>Spothole is an aggregator of amateur radio spots from DX clusters and outdoor activity programmes. It's free for anyone to use and includes an API that developers can build other applications on. For more information, check out the <a href="/about" class="alert-link">"About" page</a>. If that sounds like nonsense to you, you can visit <a href="/about#faq" class="alert-link">the FAQ section</a> to learn more. <i class="fa-solid fa-circle-info"></i> <strong>What is Spothole?</strong><br/>Spothole is an aggregator of amateur radio spots from DX clusters and outdoor activity programmes. It's free for anyone to use and includes an API that developers can build other applications on. For more information, check out the <a href="/about" class="alert-link">"About" page</a>. If that sounds like nonsense to you, you can visit <a href="/about#faq" class="alert-link">the FAQ section</a> to learn more.
<button type="button" id="intro-box-dismiss" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" id="intro-box-dismiss" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
@@ -8,15 +9,25 @@
</div> </div>
<div class="mt-3"> <div class="mt-3">
<div class="row"> <div id="settingsButtonRow" class="row">
<div class="col-auto me-auto pt-3"> <div class="col-lg-6 me-auto pt-3 hideonmobile">
<p id="timing-container">Loading...</p> <p id="timing-container">Loading...</p>
</div> </div>
<div class="col-auto"> <div class="col-lg-6 text-end">
<p class="d-inline-flex gap-1"> <p class="d-inline-flex gap-1">
<button id="add-spot-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleAddSpotPanel();"><i class="fa-solid fa-comment"></i> Add Spot</button> <span class="btn-group" role="group">
<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> <input type="radio" class="btn-check" name="runPause" id="runButton" autocomplete="off" checked>
<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> <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><span class="hideonmobile"> Pause</span></label>
</span>
<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><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> </p>
</div> </div>
</div> </div>
@@ -34,7 +45,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row row-cols-1 g-4 mb-4"> <div class="row row-cols-1 g-4 mb-4 row-cols-md-3">
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@@ -43,8 +54,24 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">SIGs</h5>
<p id="sig-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Sources</h5>
<p id="source-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
</div> </div>
<div class="row row-cols-1 row-cols-md-4 g-4"> <div class="row row-cols-1 row-cols-md-3 g-4">
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@@ -69,14 +96,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Sources</h5>
<p id="source-options" class="card-text spothole-card-text"></p>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -114,16 +133,41 @@
<h5 class="card-title">Number of Spots</h5> <h5 class="card-title">Number of Spots</h5>
<p class="card-text spothole-card-text">Show up to <p class="card-text spothole-card-text">Show up to
<select id="spots-to-fetch" class="storeable-select form-select ms-2 me-2 d-inline-block" oninput="filtersUpdated();" style="width: 5em; display: inline-block;"> <select id="spots-to-fetch" class="storeable-select form-select ms-2 me-2 d-inline-block" oninput="filtersUpdated();" style="width: 5em; display: inline-block;">
<option value="10">10</option>
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
</select> </select>
spots spots
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Location</h5>
<div class="form-group spothole-card-text">
<label for="userGrid">Your grid:</label>
<input type="text" class="storeable-text form-control" id="userGrid" placeholder="AA00aa" oninput="userGridUpdated();" style="width: 10em; display: inline-block;">
</div>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Theme</h5>
<div class="form-group">
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
<label class="form-check-label" for="darkMode">Dark mode</label>
</div>
</div>
<p class="card-text spothole-card-text">
Band color scheme<br/>
<select id="band-color-scheme" class="storeable-select form-select d-inline-block" oninput="setBandColorSchemeFromUI();" style="display: inline-block;">
</select>
</p>
</div>
</div>
</div>
<div class="col"> <div class="col">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
@@ -169,75 +213,19 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col">
<div class="card">
<div class="card-body">
<h5 class="card-title">Location</h5>
<div class="form-group spothole-card-text">
<label for="userGrid">Your grid:</label>
<input type="text" class="storeable-text form-control" id="userGrid" placeholder="AA00aa" oninput="userGridUpdated();" style="width: 10em; display: inline-block;">
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div id="add-spot-area" class="appearing-panel card mb-3"> <div id="table-container">
<div class="card-header text-white bg-primary"> <table id="table" class="table"><thead><tr class="table-primary"></tr></thead><tbody></tbody></table>
<div class="row">
<div class="col-auto me-auto">
Add a Spot
</div>
<div class="col-auto d-inline-flex">
<button id="close-add-spot-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeAddSpotPanel();"></button>
</div>
</div>
</div>
<div class="card-body">
<form class="row g-2">
<div class="col-auto">
<label for="add-spot-dx-call" class="form-label">DX Call</label>
<input type="text" class="form-control" id="add-spot-dx-call" placeholder="N0CALL" style="max-width: 8em;">
</div>
<div class="col-auto">
<label for="add-spot-freq" class="form-label">Frequency (kHz)</label>
<input type="text" class="form-control" id="add-spot-freq" placeholder="14100" style="max-width: 8em;">
</div>
<div class="col-auto">
<label for="add-spot-mode" class="form-label">Mode</label>
<input type="text" class="form-control" id="add-spot-mode" placeholder="SSB" style="max-width: 6em;">
</div>
<div class="col-auto">
<label for="add-spot-comment" class="form-label">Comment</label>
<input type="text" class="form-control" id="add-spot-comment" placeholder="59 TNX QSO 73" style="max-width: 12em;">
</div>
<div class="col-auto">
<label for="add-spot-de-call" class="form-label">Your Call</label>
<input type="text" class="form-control" id="add-spot-de-call" placeholder="N0CALL" style="max-width: 8em;">
</div>
<div class="col-auto">
<button type="button" class="btn btn-primary" style="margin-top: 2em;" onclick="addSpot();">Spot</button>
<span id="post-spot-result-good"></span>
</div>
</form>
<div id="post-spot-result-bad"></div>
<div class="alert alert-warning alert-dismissible fade show mb-0 mt-4" role="alert">
Please note that spots added to Spothole are not currently sent "upstream" to DX clusters or xOTA spotting sites.
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
</div> </div>
<div id="table-container"></div>
</div> </div>
<script src="/js/common.js"></script> <script src="/js/common.js?v=6"></script>
<script src="/js/spotandmap.js"></script> <script src="/js/spotsbandsandmap.js?v=6"></script>
<script src="/js/spots.js"></script> <script src="/js/spots.js?v=6"></script>
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -1,7 +1,10 @@
% rebase('webpage_base.tpl') {% extends "base.html" %}
{% block content %}
<div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div> <div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div>
<script src="/js/common.js"></script> <script src="/js/common.js?v=6"></script>
<script src="/js/status.js"></script> <script src="/js/status.js?v=6"></script>
<script>$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}

View File

@@ -1,36 +0,0 @@
% rebase('webpage_base.tpl')
<div id="info-container" class="mt-4">
<h2 class="mt-4 mb-4">About Spothole</h2>
<p>Spothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data.</p>
<p>While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outdoor activity programmes for amateur radio, Spothole differentiates itself by supporting a large number of data sources, and by being "API first" rather than just providing a web front-end. This allows other software to be built on top of it.</p>
<p>The API is deliberately well-defined with an <a href="/apidocs/openapi.yml">OpenAPI specification</a> and <a href="/apidocs">API documentation</a>. The API delivers spots in a consistent format regardless of the data source, freeing developers from needing to know how each individual data source presents its data.</p>
<p>Spothole itself is also open source, Public Domain licenced code that anyone can take and modify. <a href="https://git.ianrenton.com/ian/metaspot/">The source code is here</a>. If you want to run your own copy of Spothole, or start modifying it for your own purposes, the <a href="https://git.ianrenton.com/ian/spothole/src/branch/main/README.md">README file</a> contains a description of how the software works and how it's laid out, as well as instructions for configuring systemd, nginx and anything else you might need to run your own server.</p>
<p>Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, and Parks 'n' Peaks.</p>
<p>The software was written by <a href="https://ianrenton.com">Ian Renton, MØTRT</a> and other contributors. Full details are available in the README.</p>
<p>This server is running Spothole version {{software_version}}.</p>
<h2 id="faq" class="mt-4">FAQ</h2>
<h4 class="mt-4">"Spots"? "DX Clusters"? What does any of this mean?</h4>
<p>This is a tool for amateur ("ham") radio users. Many amateur radio operators like to make contacts with others who are doing something more interesting than sitting in their home "shack", such as people in rarely-seen countries, remote islands, or on mountaintops. Such operators are often "spotted", i.e. when someone speaks to them, they will put the details such as their operating frequency into an online system, to let others know where to find them. A DX Cluster is one type of those systems. Most outdoor radio awards programmes, such as "Parks on the Air" (POTA) have their own websites for posting spots.</p>
<p>Spothole is an "aggregator" for those spots, so it checks lots of different services for data, and brings it all together in one place. So no matter what kinds of interesting spots you are looking for, you can find them here.</p>
<p>As well as spots, it also provides a similar feed of "alerts". This is where amateur radio users who are going to interesting places soon will announce their intentions.</p>
<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. 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 spotted the "DX" operator. "Modes" are the type of communication they are using. 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">How is this better than DXheat, DXsummit, POTA's own website, etc?</h4>
<p>It's probably not? But it's nice to have choice.</p>
<p>I think it's got two key advantages over those sites:</p>
<ol><li>It provides a public, <a href="/apidocs">well-documented API</a> with an <a href="/apidocs/openapi.yml">OpenAPI specification</a>. Other sites don't have official APIs or don't bother documenting them publicly, because they want people to use their web page. I like Spothole's web page, but you don't have to use it&mdash;if you're a programmer, you can build your own software on Spothole's API. Spothole does the hard work of taking all the various data sources and providing a consistent, well-documented data set. You can then do the fun bit of writing your own application.</li>
<li>It grabs data from a lot more sources, and it's easy to add more. Since it's open source, anyone can contribute a new data source and share it with the community.</li></ol>
<h4 class="mt-4">Why does this website ask me if I want to install it?</h4>
<p>Spothole is a Progressive Web App, which means you can install it on an Android or iOS device by opening the site in Chrome or Safari respectively, and clicking "Install" on the pop-up panel. It'll only prompt you once, so if you dismiss the prompt and change your mind, you'll find an Install / Add to Home Screen option on your browser's menu.</p>
<p>Installing Spothole on your phone is completely optional, the website works exactly the same way as the "app" does.</p>
<h4 class="mt-4">What licence does Spothole use?</h4>
<p>Spothole's source code is licenced under the Public Domain. You can write a Spothole client, run your own server, modify it however you like, you can claim you wrote it and charge people £1000 for a copy, I don't really mind. (Please don't do the last one. But if you're using my code for something cool, it would be nice to hear from you!)</p>
<h2 id="privacy" class="mt-4">Privacy</h2>
<p>Spothole collects no data about you, and there is no way to enter personally identifying information into the site apart from by spotting and alerting through Spothole or the various services it connects to. All spots and alerts are "timed out" and deleted from the system after a set interval, which by default is one hour for spots and one week for alerts.</p>
<p>Settings you select from Spothole's menus are sent to the server, in order to provide the data with the requested filters. They are also stored in your browser's local storage, so that your preferences are remembered between sessions.</p>
<p>There are no trackers, no ads, and no cookies.</p>
<p>Spothole is open source, so you can audit <a href="https://git.ianrenton.com/ian/spothole">the code</a> if you like.</p>
</div>
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>

10
webassets/.idea/.gitignore generated vendored Normal file
View 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
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,42 @@
font-weight: bold; font-weight: bold;
} }
/* In embedded mode, hide header/footer/settings. "#header div" is kind of janky but for some reason if we hide the
whole of #header, the map vertical sizing breaks. */
[embedded-mode=true] #header div, [embedded-mode=true] #footer,
[embedded-mode=true] #settingsButtonRow, [embedded-mode=true] #settingsButtonRowMap {
display: none;
}
/* Display floating footer in embedded mode only */
#embeddedModeFooter {
display: none;
position: fixed;
bottom: 0;
right: 0;
background: var(--bs-body-bg);
border-radius: 1em 0 0 0;
font-size: 0.9em;
border-top: 1px solid grey;
border-left: 1px solid grey;
}
[embedded-mode=true] #embeddedModeFooter {
display: block;
}
#embeddedModeFooter img.logo {
position: relative;
top: -2px;
}
/* Invert logo colours in dark mode */
[data-bs-theme=dark] .logo {
filter: invert(100%) hue-rotate(180deg) brightness(80%);
}
/* INTRO/WARNING BOXES */ /* INTRO/WARNING BOXES */
#intro-box { .permanently-dismissible-box {
display: none; display: none;
} }
@@ -26,8 +58,13 @@ div.container {
min-height:100svh; min-height:100svh;
} }
[embedded-mode=true] div.container {
width: 100% !important;
max-width: 100% !important;
}
/* ABOUT PAGE*/
/* ABOUT PAGE */
#info-container{ #info-container{
width: 100%; width: 100%;
@@ -43,11 +80,22 @@ div.container {
/* SPOTS/ALERTS PAGES, SETTINGS/STATUS AREAS */ /* SPOTS/ALERTS PAGES, SETTINGS/STATUS AREAS */
div.appearing-panel { input#search {
display: none; max-width: 12em;
margin-left: 1rem;
margin-right: 1rem;
padding-left: 2em;
} }
button#add-spot-button { i#searchicon {
position: absolute;
left: 1rem;
top: 2px;
padding: 10px;
pointer-events: none;
}
div.appearing-panel {
display: none; display: none;
} }
@@ -59,25 +107,22 @@ button#add-spot-button {
/* SPOTS/ALERTS PAGES, MAIN TABLE */ /* SPOTS/ALERTS PAGES, MAIN TABLE */
/* Custom version of Bootstrap table colouring to colour 2 in every 4 rows, because of our second row per spot that
appears on mobile */
.table-striped-custom > tbody > tr:nth-of-type(4n+3) > *,
.table-striped-custom > tbody > tr:nth-of-type(4n+4) > * {
--bs-table-color-type: var(--bs-table-striped-color);
--bs-table-bg-type: var(--bs-table-striped-bg);
}
td.nowrap, span.nowrap { td.nowrap, span.nowrap {
text-wrap: nowrap; text-wrap: nowrap;
} }
span.flag-wrapper { span.flag-wrapper {
display: inline-block; display: inline-block;
width: 1.7em; width: 1.8em;
text-align: center; text-align: center;
cursor: default; cursor: default;
} }
img.flag {
position: relative;
top: -2px;
}
span.band-bullet { span.band-bullet {
display: inline-block; display: inline-block;
cursor: default; cursor: default;
@@ -134,6 +179,31 @@ tr.table-faded td span {
text-decoration: line-through !important; text-decoration: line-through !important;
} }
/* New spot styles */
tr.new td {
animation: 2s linear newspotanim;
}
@keyframes newspotanim {
0% {
background-color: var(--bs-success-border-subtle);
}
100% {
background-color: intial;
}
}
/* Fudge apply our own "dark primary" and "dark danger" backgrounds as Bootstrap doesn't do this itself */
[data-bs-theme=dark] tr.table-primary {
--bs-table-bg: #053680;
--bs-table-border-color: #021b42;
--bs-table-color: white;
}
[data-bs-theme=dark] tr.table-danger {
--bs-table-bg: #74272e;
--bs-table-border-color: #530208;
--bs-table-color: white;
}
/* MAP */ /* MAP */
div#map { div#map {
@@ -149,121 +219,139 @@ div#map {
font-family: var(--bs-body-font-family) !important; font-family: var(--bs-body-font-family) !important;
} }
[data-bs-theme=dark] .leaflet-layer,
[data-bs-theme=dark] .leaflet-control-attribution {
filter: invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%);
}
/* Make buttons overlaid on the map have a non-transparent fill so you can see the text better */
.btn-outline-primary {
--bs-btn-bg: var(--bs-body-bg) !important;
}
/* BANDS PANEL */ /* BANDS PANEL */
div#bands-container { div#bands-container {
min-height: 64em;
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow-x: auto; overflow-x: auto;
overflow-y: auto;
white-space: nowrap; white-space: nowrap;
display: flex;
overscroll-behavior-x: none; overscroll-behavior-x: none;
} }
/* Bands panel inner layout */ #bands-table {
div.bandCol { min-width: 100%;
height: 100%;
min-width: 8em;
display: flex;
flex-flow: column;
overflow-y: clip;
} }
div.bandColHeader { #bands-table th {
flex: 0 1 auto; width: 20%;
} max-height: 40px;
min-width: 12em;
div.bandColMiddle { padding: 0.5em;
flex: 1 1 auto;
}
div.bandColMiddle ul {
display: table;
table-layout: fixed;
width: 100%;
min-height: 100%;
margin: 0;
padding: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
div.bandColMiddle ul li {
display: table-row;
line-height: 0.5em;
}
/*noinspection CssUnusedSymbol*/
div.bandColMiddle ul li.withSpots {
line-height: 1em;
}
div.bandColMiddle ul li span {
display: table-cell;
vertical-align: middle;
}
div.bandColMiddle ul {
display: table;
table-layout: fixed;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
border-left: 2px dotted;
}
div.bandColHeader {
text-align: center; text-align: center;
font-weight: bold; font-weight: bold;
padding: 0.5em;
} }
div.bandColMiddle { #bands-table td {
margin-left: 3px; width: 20%;
border-left: 2px dotted var(--text); min-width: 12em;
height: 62em;
} }
div.bandColSpot { div.band-container {
display: block; height: 62em;
width: 20%;
min-width: 12em;
position: relative;
}
div.band-markers {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 13;
border-left: 2px dotted black;
}
div.band-spots {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 15;
}
canvas.band-lines-canvas {
width: 5em;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 11;
}
div.band-spot {
position: absolute;
left: 5em;
padding: 0 0.25em;
background-color: white;
border-radius: 3px; border-radius: 3px;
padding: 3px; cursor: default;
background: lightyellow;
margin-right: 2em;
} }
span.bandColSpot { [data-bs-theme=dark] div.band-spot {
vertical-align: bottom; background-color: black;
display: inline !important;
} }
/* Don't wrap frequencies */ div.band-spot:hover {
span.bandColSpotFreq { z-index: 999;
white-space: nowrap;
display: inline !important;
} }
span.bandColSpotMode { div.band-spot span.band-spot-call {
padding-left: 0.5em; display: inline;
font-size: 0.8em; }
line-height: 0.4em;
div.band-spot:hover span.band-spot-call {
display: none;
}
div.band-spot span.band-spot-info {
display: none;
}
div.band-spot:hover span.band-spot-info {
display: inline;
} }
/* GENERAL MOBILE SUPPORT */ /* GENERAL MOBILE SUPPORT */
@media (max-width: 991.99px) { @media (max-width: 991.99px) {
/* General "hide this on mobile" class */
.hideonmobile { .hideonmobile {
display: none !important; display: none !important;
} }
/* Make map stretch to horizontal screen edges */
div#map, div#table-container, div#bands-container { div#map, div#table-container, div#bands-container {
margin-left: -1em; margin-left: -1em;
margin-right: -1em; margin-right: -1em;
} }
/* Avoid map page filters panel being larger than the map itself */
#settingsButtonRowMap .appearing-panel {
max-height: 30em;
}
#settingsButtonRowMap .appearing-panel .card-body {
max-height: 26em;
overflow: scroll;
}
input#search {
max-width: 7em;
}
} }
@media (min-width: 992px) { @media (min-width: 992px) {

View File

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

BIN
webassets/img/flags/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
webassets/img/flags/10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

BIN
webassets/img/flags/100.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
webassets/img/flags/101.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

BIN
webassets/img/flags/102.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

BIN
webassets/img/flags/103.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
webassets/img/flags/104.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
webassets/img/flags/105.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
webassets/img/flags/106.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
webassets/img/flags/107.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
webassets/img/flags/108.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

BIN
webassets/img/flags/109.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
webassets/img/flags/11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
webassets/img/flags/110.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
webassets/img/flags/111.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

BIN
webassets/img/flags/112.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
webassets/img/flags/113.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

BIN
webassets/img/flags/114.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
webassets/img/flags/115.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

BIN
webassets/img/flags/116.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
webassets/img/flags/117.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

BIN
webassets/img/flags/118.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
webassets/img/flags/119.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

BIN
webassets/img/flags/12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

BIN
webassets/img/flags/120.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

BIN
webassets/img/flags/122.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
webassets/img/flags/123.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
webassets/img/flags/124.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

BIN
webassets/img/flags/125.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
webassets/img/flags/126.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Some files were not shown because too many files have changed in this diff Show More