mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-09-20 14:27:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f96e0ede1 | ||
|
|
190c9f6cef | ||
|
|
d33259d619 | ||
|
|
2df7ecf4b5 | ||
|
|
1b41520f7c | ||
|
|
ed172a0063 | ||
|
|
f45b7d51b0 | ||
|
|
0eb4553406 | ||
|
|
682e2c267c | ||
|
|
f39215ecdd | ||
|
|
c9c8ffc1f7 | ||
|
|
59d5f61d90 | ||
|
|
ab81c136cc | ||
|
|
c1c9bcd5ae | ||
|
|
f0df4f38ca | ||
|
|
a24e03028b | ||
|
|
54d779f036 | ||
|
|
7bdc26c926 | ||
|
|
eea045e4b8 | ||
|
|
4b51dd9ba5 | ||
|
|
c2c3981cda | ||
|
|
1a1743d11c | ||
|
|
40033d122e | ||
|
|
e5caf7353d | ||
|
|
ab79e7e01c | ||
|
|
75905c5ef7 | ||
|
|
0ad0d25c70 | ||
|
|
51798fbc81 | ||
|
|
cea90695ba | ||
|
|
bf9b4ac68c | ||
|
|
81cd686a00 | ||
|
|
556ea56378 | ||
|
|
a367888e14 | ||
|
|
29d8654234 | ||
|
|
d79c8f72c8 | ||
|
|
a03e1336c8 | ||
|
|
0fa8cd763d | ||
|
|
4261c60d74 | ||
|
|
29eea1edc0 | ||
|
|
7c458a8c5b | ||
|
|
4a09e46fe0 | ||
|
|
e3df512b9e | ||
|
|
ee45a15b4e | ||
|
|
5e56cd3b19 | ||
|
|
04f5df5260 | ||
|
|
8f8ff46426 | ||
|
|
2e51d5fdb2 | ||
|
|
3bab3cb784 | ||
|
|
a4245ed5ce | ||
|
|
64bb8ef3eb | ||
|
|
3b575de36a | ||
|
|
94dc9044c4 | ||
|
|
be625c40a4 | ||
|
|
0fcc459cc4 | ||
|
|
e52790f07d | ||
|
|
237cdaa091 | ||
|
|
df724409a5 | ||
|
|
3e4327ec0c | ||
|
|
d6e53c14e9 | ||
|
|
d3d1d20821 | ||
|
|
ab86bdc4d6 | ||
|
|
d7202f208c | ||
|
|
9d117a6069 | ||
|
|
606cf1be68 | ||
|
|
5a6fc19ab5 | ||
|
|
9258d680fc | ||
|
|
8189be699e | ||
|
|
fa2e4b929a | ||
|
|
55eaa871e9 | ||
|
|
1b62d4742b | ||
|
|
b0caf697ae | ||
|
|
a33f02491d | ||
|
|
892827f1f8 | ||
|
|
e4738b2c7d | ||
|
|
f42fd237b1 | ||
|
|
d83a979e46 | ||
|
|
12e9b6a4ca | ||
|
|
8345238d76 | ||
|
|
bc326388ac | ||
|
|
df684a601d | ||
|
|
9083470482 |
+7
@@ -65,6 +65,13 @@
|
|||||||
</list>
|
</list>
|
||||||
</option>
|
</option>
|
||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PyStubPackagesAdvertiser" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredPackages">
|
||||||
|
<list>
|
||||||
|
<option value="pandas" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
|
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
|
||||||
<option name="processCode" value="true" />
|
<option name="processCode" value="true" />
|
||||||
<option name="processLiterals" value="true" />
|
<option name="processLiterals" value="true" />
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ WORKDIR /app
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
EXPOSE 7373
|
||||||
|
|
||||||
CMD ["python3", "spothole.py"]
|
CMD ["python3", "spothole.py"]
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
# 
|
# 
|
||||||
|
|
||||||
Spothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open
|
Spothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open
|
||||||
JSON API as well as a website to browse the data.
|
JSON API as well as a website to browse the data, and its own telnet server for integration with desktop loggers.
|
||||||
|
|
||||||
|
A "main" or example server runs at [https://spothole.app](https://spothole.app/) in case you'd like to try it out.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -17,10 +19,10 @@ Spothole itself is also open source, Public Domain licenced code that anyone can
|
|||||||
|
|
||||||
Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA,
|
Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA,
|
||||||
SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, LLOTA, WWTOTA, Tiles on the Air, the UK Packet
|
SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, LLOTA, WWTOTA, Tiles on the Air, the UK Packet
|
||||||
Repeater Network, NG3K, and any site based on the xOTA software by nischu.
|
Repeater Network, NG3K, and any site based on the xOTA software by nischu. It also integrates with QRZ.com and HamQTH,
|
||||||
|
retrieves solar data from various sources, provides information about upcoming contests, and more.
|
||||||
|
|
||||||
Additional Special Interest Groups (SIGs) without their own specific data source include KRMNPA, SANPCPA, WAB, WAI and
|
You can read more about Spothole on [the "About" page of the main server instance](https://spothole.app/about).
|
||||||
DME.
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -50,7 +52,9 @@ Spothole server, or to modify it in any way. More details can be found in the fo
|
|||||||
|
|
||||||
## Thanks
|
## Thanks
|
||||||
|
|
||||||
As well as being my work, I have also gratefully received feature patches from Steven, M1SDH.
|
[Spothole's "About" page](https://spothole.app/about) contains the full details of the thanks I owe to the various
|
||||||
|
programme teams, software library providers, bug fixers etc. The extra detail below contains the extra detail of
|
||||||
|
specific files in this repository which are not all my own work and may be subject to other licences.
|
||||||
|
|
||||||
The project contains GeoJSON files for CQ and ITU zones, in the `/datafiles/` directory. These are MIT-licenced and, to
|
The project contains GeoJSON files for CQ and ITU zones, in the `/datafiles/` directory. These are MIT-licenced and, to
|
||||||
my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet. `/datafiles` also contains a
|
my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet. `/datafiles` also contains a
|
||||||
@@ -62,26 +66,9 @@ conference centres.
|
|||||||
The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the
|
The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the
|
||||||
`/static/img/flags/` directory.
|
`/static/img/flags/` directory.
|
||||||
|
|
||||||
The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries.
|
The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries
|
||||||
This project would not have been possible without these libraries, so many thanks to their developers.
|
which are self-hosted in the `/static/vendor/` directory. This project would not have been possible without these
|
||||||
|
libraries, so many thanks to their developers. These libraries are subject to their own licences and are not covered by
|
||||||
### Third Party Libraries
|
the overall licence declared in Spothole's `LICENSE` file.
|
||||||
|
|
||||||
A number of third-party libraries are self-hosted in the `/static/vendor/` directory. These files are subject to their
|
|
||||||
own licences and are not covered by the overall licence declared in the `LICENSE` file.
|
|
||||||
|
|
||||||
A number of third-party libraries are self-hosted in the `/webassets/vendor/` directory. These files are subject to
|
|
||||||
their own licences and are not covered by the overall licence declared in the `LICENSE` file.
|
|
||||||
|
|
||||||
Sound effects are CC0-licenced and sourced from [Pixabay](https://pixabay.com).
|
Sound effects are CC0-licenced and sourced from [Pixabay](https://pixabay.com).
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Amateur radio clusters, outdoor programmes, propagation data providers etc. are almost all volunteer-run services that
|
|
||||||
make no or little profit, and are done for the love of amateur radio. Services like Spothole, which build on top of
|
|
||||||
them, are truly standing on the shoulders of giants. None of this would have been possible without the hard work and
|
|
||||||
dedication of many other people within the amaetur radio community.
|
|
||||||
|
|
||||||
The project's name was suggested by Harm, DK4HAA. Thanks!
|
|
||||||
|
|||||||
+57
-12
@@ -17,6 +17,15 @@ api_only_mode: false
|
|||||||
# The base URL at which the software runs.
|
# The base URL at which the software runs.
|
||||||
base_url: "http://localhost:8080"
|
base_url: "http://localhost:8080"
|
||||||
|
|
||||||
|
# Whether to run a telnet spot server as well as the web interface
|
||||||
|
telnet_server_enabled: false
|
||||||
|
|
||||||
|
# Telnet server address. This is not really needed by the software itself, just displayed in documentation.
|
||||||
|
telnet_server_address: "localhost"
|
||||||
|
|
||||||
|
# Port to run the telnet server on
|
||||||
|
telnet_server_port: 7373
|
||||||
|
|
||||||
# Spot providers to use. This is an example set, tailor it to your liking by commenting and uncommenting.
|
# Spot providers to use. This is an example set, tailor it to your liking by commenting and uncommenting.
|
||||||
# RBN and APRS-IS are supported but have such a high data rate, you probably don't want them enabled.
|
# RBN and APRS-IS are supported but have such a high data rate, you probably don't want them enabled.
|
||||||
# Each provider needs a class and an enabled/disabled state. Some require more config such as hostnames/IP
|
# Each provider needs a class and an enabled/disabled state. Some require more config such as hostnames/IP
|
||||||
@@ -119,12 +128,12 @@ spot_providers:
|
|||||||
name: "C3 TOTA"
|
name: "C3 TOTA"
|
||||||
enabled: false
|
enabled: false
|
||||||
url: "wss://39c3.totawatch.de/api/spot/live"
|
url: "wss://39c3.totawatch.de/api/spot/live"
|
||||||
# For the "XOTA" provider, a SIG must be set menually here because xOTA is a generic backend for xOTA
|
# For the "XOTA" provider, an activity must be set manually here because xOTA is a generic backend for xOTA
|
||||||
# programmes and so different URLs potentially provide different programmes.
|
# programmes and so different URLs potentially provide different programmes.
|
||||||
sig: "Toilets"
|
sig: "Toilets"
|
||||||
# For Toilets on the Air, we prefix the SIG references (T-01 etc) with some characters that define the conference:
|
# For Toilets on the Air, we prefix the activity references (T-01 etc) with some characters that define the
|
||||||
# C3, EH or HOPE - so we can look up the correct locations in our database, because each conference starts from T-01
|
# conference: C3, EH or HOPE - so we can look up the correct locations in our database, because each conference
|
||||||
# but refers to a toilet in a different building (or continent!)
|
# starts from T-01 but refers to a toilet in a different building (or continent!)
|
||||||
sig_ref_prefix: "C3"
|
sig_ref_prefix: "C3"
|
||||||
|
|
||||||
- class: "XOTA"
|
- class: "XOTA"
|
||||||
@@ -162,9 +171,21 @@ alert_providers:
|
|||||||
- class: "BOTA"
|
- class: "BOTA"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
- class: "Hamsat"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
- class: "NG3K"
|
- class: "NG3K"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
- class: "WA7BNM"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- class: "RSGBHFContests"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- class: "RSGBVHFContests"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
|
||||||
# Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band
|
# Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band
|
||||||
# conditions, etc.) and make it available via the /api/v2/solar endpoint.
|
# conditions, etc.) and make it available via the /api/v2/solar endpoint.
|
||||||
@@ -195,8 +216,8 @@ static_data_providers:
|
|||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
|
||||||
# SIG reference data providers to use. These allow Spothole to download, for example, the WWFF directory that maps WWFF
|
# Activity reference data providers to use. These allow Spothole to download, for example, the WWFF directory that
|
||||||
# park IDs to their name and location.
|
# maps WWFF park IDs to their name and location.
|
||||||
sig_ref_data_providers:
|
sig_ref_data_providers:
|
||||||
- class: "POTA"
|
- class: "POTA"
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -246,12 +267,36 @@ sig_ref_data_providers:
|
|||||||
- class: "DME"
|
- class: "DME"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
- class: "FEA"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- class: "DMVE"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- class: "DMUE"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- class: "DCE"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- class: "DEFE"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
- class: "KRMNPA"
|
- class: "KRMNPA"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
- class: "SANPCPA"
|
- class: "SANPCPA"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
|
- class: "DTMBA"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- class: "COTA"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
- class: "PGA"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
- class: "Toilets"
|
- class: "Toilets"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
@@ -275,27 +320,27 @@ callsign_data_providers:
|
|||||||
priority: 2
|
priority: 2
|
||||||
# No server-side credentials for HamQTH. Users must provide their own.
|
# No server-side credentials for HamQTH. Users must provide their own.
|
||||||
|
|
||||||
|
- class: "CountryFiles"
|
||||||
|
priority: 3
|
||||||
|
enabled: true
|
||||||
|
|
||||||
- class: "ClublogAPI"
|
- class: "ClublogAPI"
|
||||||
# Querying the Clublog API directly doesn't provide any more data than the XML version, it just provides slightly
|
# Querying the Clublog API directly doesn't provide any more data than the XML version, it just provides slightly
|
||||||
# more up-to-date information in the rare case that the prefix data changes, at a significant cost of looking up
|
# more up-to-date information in the rare case that the prefix data changes, at a significant cost of looking up
|
||||||
# every callsign via an API call. Normally left disabled but it exists as an option.
|
# every callsign via an API call. Normally left disabled but it exists as an option.
|
||||||
enabled: false
|
enabled: false
|
||||||
priority: 3
|
priority: 4
|
||||||
# API key for Clublog to look up information. Required in order to enable this provider. Unlike QRZ and HamQTH,
|
# API key for Clublog to look up information. Required in order to enable this provider. Unlike QRZ and HamQTH,
|
||||||
# Clublog uses an API key issued to Spothole, not to the end user.
|
# Clublog uses an API key issued to Spothole, not to the end user.
|
||||||
api_key: ""
|
api_key: ""
|
||||||
|
|
||||||
- class: "ClublogXML"
|
- class: "ClublogXML"
|
||||||
enabled: true
|
enabled: true
|
||||||
priority: 4
|
priority: 5
|
||||||
# API key for Clublog to look up information. Required in order to enable this provider. You will need to request
|
# API key for Clublog to look up information. Required in order to enable this provider. You will need to request
|
||||||
# one via their helpdesk portal if you want to use callsign lookups from Clublog.
|
# one via their helpdesk portal if you want to use callsign lookups from Clublog.
|
||||||
api_key: ""
|
api_key: ""
|
||||||
|
|
||||||
- class: "CountryFiles"
|
|
||||||
priority: 5
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
|
|
||||||
# Maximum time to keep spots and alerts in the system before deleting them. By default, one hour for spots and one week
|
# Maximum time to keep spots and alerts in the system before deleting them. By default, one hour for spots and one week
|
||||||
# for alerts.
|
# for alerts.
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
|
||||||
|
|
||||||
|
from core.activity_utils import get_activity_by_name
|
||||||
|
from core.data_store import DATA_STORE
|
||||||
|
from core.enums import ActivityName, ActivityRefType
|
||||||
|
from core.geo_utils import wab_wai_square_to_lat_lon
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_activity_ref_info(activity_name, ref_id):
|
||||||
|
"""Look up details of an activity reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in an
|
||||||
|
activity name and a reference ID (both strings) and returns an ActivityRef object populated with as much data
|
||||||
|
as we can find. This makes use of activity ref data in the data store, live lookups from the web, or just
|
||||||
|
automatic calculation depending on which activity we are getting data for."""
|
||||||
|
|
||||||
|
if activity_name is None or activity_name == "" or ref_id is None or ref_id == "":
|
||||||
|
logger.debug("Failed to look up activity ref info, activity or ref were not set.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Sometimes we allow spaces instead of dashes in references due to common usage that way, but official reference
|
||||||
|
# lists never do, so convert them here.
|
||||||
|
ref_id = ref_id.replace(" ", "-")
|
||||||
|
|
||||||
|
# Prepare the object to be returned
|
||||||
|
activity_ref = ActivityRef(sig=activity_name, id=ref_id)
|
||||||
|
|
||||||
|
# We can always get the reference type and the icon from the activity itself
|
||||||
|
activity = get_activity_by_name(activity_name)
|
||||||
|
if activity:
|
||||||
|
activity_ref.ref_type = activity.ref_type
|
||||||
|
activity_ref.icon = activity.icon
|
||||||
|
|
||||||
|
try:
|
||||||
|
### FUDGES ###
|
||||||
|
#
|
||||||
|
# DME fudge. Our database has leading zeros padding to 5 digits which is the expected format, but not all
|
||||||
|
# activators add leading zeros. We also need to normalise "DME 01234" to "DME-01234" to match what's in our
|
||||||
|
# database.
|
||||||
|
if activity_name.upper() == ActivityName.DME:
|
||||||
|
match = re.match(r"DME[\- ](\d{3,5})", ref_id, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
number = match.group(1)
|
||||||
|
ref_id = f"DME-{number.zfill(5)}"
|
||||||
|
|
||||||
|
# DTMBA spotters sometimes include spaces and dashes, our regex allows them but they must be removed here so we
|
||||||
|
# can look up against the official list which doesn't have them
|
||||||
|
if activity_name.upper() == ActivityName.DTMBA:
|
||||||
|
ref_id = ref_id.replace("-", "").replace(" ", "")
|
||||||
|
|
||||||
|
### NO DATA ACTIVITIES ###
|
||||||
|
#
|
||||||
|
# If the activity is HEMA or BIWOTA, we have no way to either generate useful data or look it up on a
|
||||||
|
# reference list, so just skip the lookup here.
|
||||||
|
if activity_name.upper() == ActivityName.HEMA or activity_name.upper() == ActivityName.BIWOTA:
|
||||||
|
return activity_ref
|
||||||
|
|
||||||
|
### PROGRAMMATIC DATA GENERATION INSTEAD OF LOOKUPS ###
|
||||||
|
#
|
||||||
|
# If the activity is Tiles, WAB, WAI or BOTA (Beaches), we don't have anything to look up from the data
|
||||||
|
# store, we can calculate all the information we are going to get directly.
|
||||||
|
if activity_name.upper() == ActivityName.TILES.upper():
|
||||||
|
# Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same
|
||||||
|
if not activity_ref.name:
|
||||||
|
activity_ref.name = activity_ref.id
|
||||||
|
if not activity_ref.grid:
|
||||||
|
activity_ref.grid = activity_ref.id
|
||||||
|
if activity_ref.grid and (not activity_ref.latitude or not activity_ref.longitude):
|
||||||
|
ll = locator_to_latlong(str(activity_ref.grid))
|
||||||
|
activity_ref.latitude = ll[0]
|
||||||
|
activity_ref.longitude = ll[1]
|
||||||
|
return activity_ref
|
||||||
|
|
||||||
|
elif activity_name.upper() == ActivityName.WAB or activity_name.upper() == ActivityName.WAI:
|
||||||
|
ll = wab_wai_square_to_lat_lon(ref_id)
|
||||||
|
if ll:
|
||||||
|
activity_ref.name = ref_id
|
||||||
|
try:
|
||||||
|
activity_ref.grid = latlong_to_locator(ll[0], ll[1], 6)
|
||||||
|
activity_ref.latitude = ll[0]
|
||||||
|
activity_ref.longitude = ll[1]
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Invalid lat/lon received for WAB/WAI reference", exc_info=True)
|
||||||
|
return activity_ref
|
||||||
|
|
||||||
|
elif activity_name.upper() == ActivityName.BOTA:
|
||||||
|
# For BOTA all we can ever generate is the URL, there is no data file or lookup for lat/longs
|
||||||
|
if not activity_ref.name:
|
||||||
|
activity_ref.name = activity_ref.id
|
||||||
|
if activity_ref.name:
|
||||||
|
activity_ref.url = (
|
||||||
|
f"https://www.beachesontheair.com/beaches/{activity_ref.name.lower().replace(' ', '-')}"
|
||||||
|
)
|
||||||
|
return activity_ref
|
||||||
|
|
||||||
|
elif activity_name.upper() == ActivityName.GMA_ISLANDS.upper():
|
||||||
|
# GMA Islands is a bit of a mess of GMA and IOTA references. Try looking them both up and see what returns
|
||||||
|
# the best result.
|
||||||
|
iota_lookup = get_activity_ref_info(ActivityName.IOTA, ref_id)
|
||||||
|
gma_lookup = get_activity_ref_info(ActivityName.GMA, ref_id)
|
||||||
|
for key, value in iota_lookup.__dict__.items():
|
||||||
|
if value is not None and activity_ref.__dict__.get(key) is None:
|
||||||
|
activity_ref.__dict__[key] = value
|
||||||
|
for key, value in gma_lookup.__dict__.items():
|
||||||
|
if value is not None and activity_ref.__dict__.get(key) is None:
|
||||||
|
activity_ref.__dict__[key] = value
|
||||||
|
activity_ref.ref_type = ActivityRefType.ISLAND
|
||||||
|
return activity_ref
|
||||||
|
|
||||||
|
### ACTUAL LOOKUP ###
|
||||||
|
#
|
||||||
|
# OK, this is something we have to look up. Now check to see if our data store contains reference data and if
|
||||||
|
# so, copy the data into the activity_ref object
|
||||||
|
key = f"{activity_name}:{ref_id}"
|
||||||
|
try:
|
||||||
|
lookup_data = DATA_STORE.activity_refs.get(key) if key in DATA_STORE.activity_refs else None
|
||||||
|
if lookup_data:
|
||||||
|
for attr, value in lookup_data.__dict__.items():
|
||||||
|
if value is not None and activity_ref.__dict__.get(attr) is None:
|
||||||
|
activity_ref.__dict__[attr] = value
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Maybe a super new reference we don't know about yet, but more likely a typo or a test reference,
|
||||||
|
# just silently ignore it.
|
||||||
|
logger.debug(f"{activity_name} database did not contain data for ref {ref_id}")
|
||||||
|
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
# Catch exceptions due to e.g. old versions of objects in the cache that are no longer compatible,
|
||||||
|
# and remove them from the cache.
|
||||||
|
del DATA_STORE.activity_refs[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"Exception when looking up activity ref info for {activity_name} ref {ref_id}")
|
||||||
|
return activity_ref
|
||||||
|
|
||||||
|
|
||||||
|
def populate_missing_activity_ref_info(activity_ref):
|
||||||
|
"""Look up details of an activity reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in an
|
||||||
|
activity_ref object which must at minimum have a "sig" and an "id". The rest of the object will be populated
|
||||||
|
and returned. Any data currently in the object will be kept, only missing data in the object will be populated
|
||||||
|
if it can be determined."""
|
||||||
|
|
||||||
|
lookup_data = get_activity_ref_info(activity_ref.sig, activity_ref.id)
|
||||||
|
|
||||||
|
if lookup_data:
|
||||||
|
# Copy new activity ref data into existing object where data was previously missing
|
||||||
|
for key, value in lookup_data.__dict__.items():
|
||||||
|
if value is not None and activity_ref.__dict__.get(key) is None:
|
||||||
|
activity_ref.__dict__[key] = value
|
||||||
|
|
||||||
|
return activity_ref
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from data.activities import ACTIVITIES
|
||||||
|
|
||||||
|
|
||||||
|
def get_activity_by_name(name):
|
||||||
|
"""Utility function to resolve an arbitrary, case-insensitive activity name string (e.g. from a spot comment, a
|
||||||
|
provider, or an API request) to the matching known Activity. Returns None if no match is found."""
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
for activity_name, activity in ACTIVITIES.items():
|
||||||
|
if activity_name.upper() == name.upper():
|
||||||
|
return activity
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_ref_regex_for_activity(activity):
|
||||||
|
"""Utility function to get the regex string for an activity reference for a named activity. If no match is
|
||||||
|
found, None will be returned."""
|
||||||
|
|
||||||
|
found = get_activity_by_name(activity)
|
||||||
|
return found.ref_regex if found else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_icon_for_activity(activity):
|
||||||
|
"""Utility function to get the icon for a named activity. If no match is found, None will be returned."""
|
||||||
|
|
||||||
|
found = get_activity_by_name(activity)
|
||||||
|
return found.icon if found else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_activity_name_from_comment_name(activity):
|
||||||
|
"""Utility function to get the name of an activity from its "comment name". Generally these will be the same
|
||||||
|
but there are some cases (e.g. is "TOTA" Towers, Tiles or Toilets?) where we need to transform one to the
|
||||||
|
other."""
|
||||||
|
|
||||||
|
for activity_name, a in ACTIVITIES.items():
|
||||||
|
if any(n.upper() == activity.upper() for n in a.comment_names):
|
||||||
|
return activity_name
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Regex matching any activity's "comment name", i.e. how it may be referred to in spot comments
|
||||||
|
ANY_ACTIVITY_REGEX = rf"({'|'.join(n for a in ACTIVITIES.values() for n in a.comment_names)})"
|
||||||
@@ -34,6 +34,10 @@ class CleanupTimer:
|
|||||||
"""Stop any threads and prepare for application shutdown"""
|
"""Stop any threads and prepare for application shutdown"""
|
||||||
|
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=5)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning("Cleanup worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while not self._stop_event.wait(timeout=self._cleanup_interval):
|
while not self._stop_event.wait(timeout=self._cleanup_interval):
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ MAX_SPOT_AGE = config.get("max_spot_age_sec", 3600)
|
|||||||
MAX_ALERT_AGE = config.get("max_alert_age_sec", 604800)
|
MAX_ALERT_AGE = config.get("max_alert_age_sec", 604800)
|
||||||
SERVER_OWNER_CALLSIGN = config.get("server_owner_callsign", "N0CALL")
|
SERVER_OWNER_CALLSIGN = config.get("server_owner_callsign", "N0CALL")
|
||||||
WEB_SERVER_PORT = config.get("web_server_port", 8080)
|
WEB_SERVER_PORT = config.get("web_server_port", 8080)
|
||||||
|
TELNET_SERVER_ENABLED = config.get("telnet_server_enabled", False)
|
||||||
|
TELNET_SERVER_ADDRESS = config.get("telnet_server_address", "localhost")
|
||||||
|
TELNET_SERVER_PORT = config.get("telnet_server_port", 7373)
|
||||||
ALLOW_SPOTTING = config.get("allow_spotting", True)
|
ALLOW_SPOTTING = config.get("allow_spotting", True)
|
||||||
ALLOW_UPSTREAM_SPOTTING = config.get("allow_upstream_spotting", True)
|
ALLOW_UPSTREAM_SPOTTING = config.get("allow_upstream_spotting", True)
|
||||||
WEB_UI_OPTIONS = config.get("web_ui_options", {})
|
WEB_UI_OPTIONS = config.get("web_ui_options", {})
|
||||||
|
|||||||
+3
-205
@@ -1,213 +1,13 @@
|
|||||||
from core.config import SERVER_OWNER_CALLSIGN
|
from core.config import SERVER_OWNER_CALLSIGN
|
||||||
from data.band import Band
|
from data.band import Band
|
||||||
from data.sig import SIG
|
|
||||||
|
|
||||||
# General software
|
# General software
|
||||||
SOFTWARE_VERSION = "2.0.5"
|
SOFTWARE_VERSION = "2.2-pre"
|
||||||
|
|
||||||
# HTTP headers used for spot providers that use HTTP
|
# HTTP headers used for spot providers that use HTTP
|
||||||
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
|
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
|
||||||
HAMQTH_PRG = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}".replace(" ", "_")
|
HAMQTH_PRG = f"Spothole v{SOFTWARE_VERSION} operated by {SERVER_OWNER_CALLSIGN}".replace(" ", "_")
|
||||||
|
|
||||||
# Special Interest Groups
|
|
||||||
SIGS = [
|
|
||||||
SIG(
|
|
||||||
name="POTA",
|
|
||||||
comment_names=["POTA"],
|
|
||||||
description="Parks on the Air",
|
|
||||||
ref_regex=r"[A-Z]{2}\-\d{4,5}|K\-TEST",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="SOTA",
|
|
||||||
comment_names=["SOTA"],
|
|
||||||
description="Summits on the Air",
|
|
||||||
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="WWFF",
|
|
||||||
comment_names=["WWFF"],
|
|
||||||
description="World Wide Flora & Fauna",
|
|
||||||
ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="GMA",
|
|
||||||
comment_names=["GMA"],
|
|
||||||
description="Global Mountain Activity",
|
|
||||||
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="WWBOTA",
|
|
||||||
comment_names=["WWBOTA", "BOTA"],
|
|
||||||
description="Worldwide Bunkers on the Air",
|
|
||||||
ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="HEMA",
|
|
||||||
comment_names=["HEMA"],
|
|
||||||
description="HuMPs Excluding Marilyns Award",
|
|
||||||
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="IOTA",
|
|
||||||
comment_names=["IOTA"],
|
|
||||||
description="Islands on the Air",
|
|
||||||
ref_regex=r"[A-Z]{2}\-\d{3}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="GMA Islands",
|
|
||||||
comment_names=[],
|
|
||||||
description="Global Mountain Activity - Islands",
|
|
||||||
ref_regex=r"(([A-Z]{2}\-\d{3})|([A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}))",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="MOTA",
|
|
||||||
comment_names=["MOTA"],
|
|
||||||
description="Mills on the Air",
|
|
||||||
ref_regex=r"X\d{4,6}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="ARLHS",
|
|
||||||
comment_names=["ARLHS"],
|
|
||||||
description="Amateur Radio Lighthouse Society",
|
|
||||||
ref_regex=r"[A-Z]{3}[\- ]\d{3,4}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="ILLW",
|
|
||||||
comment_names=["ILLW"],
|
|
||||||
description="International Lighthouse & Lightship Weekend",
|
|
||||||
ref_regex=r"[A-Z]{2}\d{4}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="SIOTA",
|
|
||||||
comment_names=["SIOTA"],
|
|
||||||
description="Silos on the Air",
|
|
||||||
ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="WCA",
|
|
||||||
comment_names=["WCA"],
|
|
||||||
description="World Castles Award",
|
|
||||||
ref_regex=r"[A-Z0-9]{1,3}\-\d{5}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="ZLOTA",
|
|
||||||
comment_names=["ZLOTA"],
|
|
||||||
description="New Zealand on the Air",
|
|
||||||
ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="WOTA",
|
|
||||||
comment_names=["WOTA"],
|
|
||||||
description="Wainwrights on the Air",
|
|
||||||
ref_regex=r"[A-Z]{3}-[0-9]{2}",
|
|
||||||
),
|
|
||||||
SIG(name="BOTA", comment_names=[], description="Beaches on the Air"),
|
|
||||||
SIG(
|
|
||||||
name="KRMNPA",
|
|
||||||
comment_names=["KRMNPA"],
|
|
||||||
description="Keith Roget Memorial National Parks Award",
|
|
||||||
ref_regex=r"VKFF\-\d{4}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="SANPCPA",
|
|
||||||
comment_names=["SANPCPA"],
|
|
||||||
description="South Australian National Parks and Conservation Parks Award",
|
|
||||||
ref_regex=r"VKFF\-\d{4}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="LLOTA",
|
|
||||||
comment_names=["LLOTA"],
|
|
||||||
description="Lagos y Lagunas on the Air",
|
|
||||||
ref_regex=r"LL[A-Z]{2}\-\d{4}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="Towers",
|
|
||||||
comment_names=["TOTA"],
|
|
||||||
description="Towers on the Air",
|
|
||||||
ref_regex=r"[A-Z]{2,3}R\-\d{4}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="Tiles",
|
|
||||||
comment_names=[],
|
|
||||||
description="Tiles on the Air",
|
|
||||||
ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="WAB",
|
|
||||||
comment_names=["WAB"],
|
|
||||||
description="Worked All Britain",
|
|
||||||
ref_regex=r"[A-Z]{1,2}[0-9]{2}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="WAI",
|
|
||||||
comment_names=["WAI"],
|
|
||||||
description="Worked All Ireland",
|
|
||||||
ref_regex=r"[A-Z][0-9]{2}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="DME",
|
|
||||||
comment_names=["DME"],
|
|
||||||
description="Diplomas de Municipios Españoles",
|
|
||||||
ref_regex=r"\d{4,5}",
|
|
||||||
),
|
|
||||||
SIG(
|
|
||||||
name="Toilets",
|
|
||||||
comment_names=[],
|
|
||||||
description="Toilets on the Air",
|
|
||||||
ref_regex=r"T\-[0-9]{2}",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
|
|
||||||
CW_MODES = ["CW"]
|
|
||||||
PHONE_MODES = [
|
|
||||||
"PHONE",
|
|
||||||
"SSB",
|
|
||||||
"USB",
|
|
||||||
"LSB",
|
|
||||||
"AM",
|
|
||||||
"FM",
|
|
||||||
"DV",
|
|
||||||
"DMR",
|
|
||||||
"DSTAR",
|
|
||||||
"C4FM",
|
|
||||||
"FUSION",
|
|
||||||
"M17",
|
|
||||||
]
|
|
||||||
DATA_MODES = [
|
|
||||||
"DATA",
|
|
||||||
"FT8",
|
|
||||||
"FT4",
|
|
||||||
"RTTY",
|
|
||||||
"SSTV",
|
|
||||||
"JS8",
|
|
||||||
"HELL",
|
|
||||||
"PSK",
|
|
||||||
"FSK",
|
|
||||||
"OLIVIA",
|
|
||||||
"PKT",
|
|
||||||
"MSK144",
|
|
||||||
]
|
|
||||||
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
|
|
||||||
MODE_TYPES = ["CW", "PHONE", "DATA"]
|
|
||||||
SSB_SUB_MODES = ["USB", "LSB"]
|
|
||||||
DV_SUB_MODES = ["DMR", "DSTAR", "C4FM", "FUSION", "M17"]
|
|
||||||
|
|
||||||
# Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode
|
|
||||||
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots
|
|
||||||
# that match a key in this table will be converted to the corresponding value, so only the modes above will actually be
|
|
||||||
# present in the spots.
|
|
||||||
MODE_ALIASES = {
|
|
||||||
"RTT": "RTTY",
|
|
||||||
"BPSK": "PSK",
|
|
||||||
"PSK31": "PSK",
|
|
||||||
"BPSK31": "PSK",
|
|
||||||
"MFSK": "FSK",
|
|
||||||
"MFSK32": "FSK",
|
|
||||||
"DIGI": "DATA",
|
|
||||||
"DIGITAL": "DATA",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Band definitions
|
# Band definitions
|
||||||
BANDS = [
|
BANDS = [
|
||||||
Band(name="2200m", start_freq=135700, end_freq=137800),
|
Band(name="2200m", start_freq=135700, end_freq=137800),
|
||||||
@@ -223,7 +23,7 @@ BANDS = [
|
|||||||
Band(name="12m", start_freq=24890000, end_freq=24990000, is_ham_hf=True),
|
Band(name="12m", start_freq=24890000, end_freq=24990000, is_ham_hf=True),
|
||||||
Band(name="11m", start_freq=26965000, end_freq=27405000),
|
Band(name="11m", start_freq=26965000, end_freq=27405000),
|
||||||
Band(name="10m", start_freq=28000000, end_freq=29700000, is_ham_hf=True),
|
Band(name="10m", start_freq=28000000, end_freq=29700000, is_ham_hf=True),
|
||||||
Band(name="6m", start_freq=50000000, end_freq=54000000),
|
Band(name="6m", start_freq=50000000, end_freq=54000000, is_ham_hf=True),
|
||||||
Band(name="5m", start_freq=56000000, end_freq=60500000),
|
Band(name="5m", start_freq=56000000, end_freq=60500000),
|
||||||
Band(name="4m", start_freq=70000000, end_freq=70500000),
|
Band(name="4m", start_freq=70000000, end_freq=70500000),
|
||||||
Band(name="2m", start_freq=144000000, end_freq=148000000),
|
Band(name="2m", start_freq=144000000, end_freq=148000000),
|
||||||
@@ -239,9 +39,6 @@ BANDS = [
|
|||||||
]
|
]
|
||||||
UNKNOWN_BAND = Band(name="Unknown", start_freq=0, end_freq=0)
|
UNKNOWN_BAND = Band(name="Unknown", start_freq=0, end_freq=0)
|
||||||
|
|
||||||
# Continents
|
|
||||||
CONTINENTS = ["EU", "NA", "SA", "AS", "AF", "OC", "AN"]
|
|
||||||
|
|
||||||
# Propagation modes used in VHF/UHF DX cluster comments, e.g. "JN61ES<ES>JM56XT". I don't think there's an official list
|
# Propagation modes used in VHF/UHF DX cluster comments, e.g. "JN61ES<ES>JM56XT". I don't think there's an official list
|
||||||
# of these anywhere, but here are some I've seen or seen reference to
|
# of these anywhere, but here are some I've seen or seen reference to
|
||||||
PROPAGATION_MODES = {
|
PROPAGATION_MODES = {
|
||||||
@@ -254,5 +51,6 @@ PROPAGATION_MODES = {
|
|||||||
"MS": "Meteor scatter",
|
"MS": "Meteor scatter",
|
||||||
"RS": "Rain scatter",
|
"RS": "Rain scatter",
|
||||||
"AS": "Aircraft scatter",
|
"AS": "Aircraft scatter",
|
||||||
|
"ACS": "Aircraft scatter",
|
||||||
"SAT": "Satellite",
|
"SAT": "Satellite",
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-34
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
from core.config import config, create_provider_from_config
|
from core.config import config, create_provider_from_config
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ class DataProviders:
|
|||||||
self.static_data_providers = []
|
self.static_data_providers = []
|
||||||
self.sig_ref_data_providers = []
|
self.sig_ref_data_providers = []
|
||||||
self.callsign_data_providers = []
|
self.callsign_data_providers = []
|
||||||
|
self._startup_timers = []
|
||||||
|
|
||||||
def setup(self):
|
def setup(self):
|
||||||
for entry in config["spot_providers"]:
|
for entry in config["spot_providers"]:
|
||||||
@@ -27,7 +29,7 @@ class DataProviders:
|
|||||||
for entry in config.get("static_data_providers", []):
|
for entry in config.get("static_data_providers", []):
|
||||||
self.static_data_providers.append(create_provider_from_config("providers.staticdata", entry))
|
self.static_data_providers.append(create_provider_from_config("providers.staticdata", entry))
|
||||||
for entry in config.get("sig_ref_data_providers", []):
|
for entry in config.get("sig_ref_data_providers", []):
|
||||||
self.sig_ref_data_providers.append(create_provider_from_config("providers.sigrefdata", entry))
|
self.sig_ref_data_providers.append(create_provider_from_config("providers.activityrefdata", entry))
|
||||||
for entry in config.get("callsign_data_providers", []):
|
for entry in config.get("callsign_data_providers", []):
|
||||||
self.callsign_data_providers.append(create_provider_from_config("providers.callsigndata", entry))
|
self.callsign_data_providers.append(create_provider_from_config("providers.callsigndata", entry))
|
||||||
|
|
||||||
@@ -43,41 +45,58 @@ class DataProviders:
|
|||||||
def start(self):
|
def start(self):
|
||||||
# Start data providers before spot/alert providers so the lookup data is there already for incoming spots.
|
# Start data providers before spot/alert providers so the lookup data is there already for incoming spots.
|
||||||
# Each category is fired off after a small delay to give the rest of Spothole chance to start up.
|
# Each category is fired off after a small delay to give the rest of Spothole chance to start up.
|
||||||
threading.Timer(5.0, lambda: self.start_providers(self.static_data_providers, "static data")).start()
|
self._startup_timers = [
|
||||||
threading.Timer(
|
threading.Timer(5.0, lambda: self.start_providers(self.static_data_providers, "static data")),
|
||||||
10.0,
|
threading.Timer(10.0, lambda: self.start_providers(self.callsign_data_providers, "callsign data")),
|
||||||
lambda: self.start_providers(self.callsign_data_providers, "callsign data"),
|
threading.Timer(15.0, lambda: self.start_providers(self.spot_providers, "spot")),
|
||||||
).start()
|
threading.Timer(20.0, lambda: self.start_providers(self.alert_providers, "alert")),
|
||||||
threading.Timer(15.0, lambda: self.start_providers(self.spot_providers, "spot")).start()
|
threading.Timer(
|
||||||
threading.Timer(20.0, lambda: self.start_providers(self.alert_providers, "alert")).start()
|
25.0,
|
||||||
threading.Timer(
|
lambda: self.start_providers(self.solar_condition_providers, "solar condition"),
|
||||||
25.0,
|
),
|
||||||
lambda: self.start_providers(self.solar_condition_providers, "solar condition"),
|
threading.Timer(30.0, lambda: self.start_providers(self.sig_ref_data_providers, "activity ref data")),
|
||||||
).start()
|
]
|
||||||
threading.Timer(
|
for t in self._startup_timers:
|
||||||
30.0,
|
t.daemon = True
|
||||||
lambda: self.start_providers(self.sig_ref_data_providers, "SIG ref data"),
|
t.start()
|
||||||
).start()
|
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
for sp in self.spot_providers:
|
# Cancel any startup timers that haven't fired yet
|
||||||
if sp.enabled:
|
for t in self._startup_timers:
|
||||||
sp.stop()
|
t.cancel()
|
||||||
for ap in self.alert_providers:
|
|
||||||
if ap.enabled:
|
# Stop all providers
|
||||||
ap.stop()
|
all_providers = [
|
||||||
for scp in self.solar_condition_providers:
|
p
|
||||||
if scp.enabled:
|
for p in (
|
||||||
scp.stop()
|
self.spot_providers
|
||||||
for srdp in self.sig_ref_data_providers:
|
+ self.alert_providers
|
||||||
if srdp.enabled:
|
+ self.solar_condition_providers
|
||||||
srdp.stop()
|
+ self.sig_ref_data_providers
|
||||||
for sdp in self.static_data_providers:
|
+ self.static_data_providers
|
||||||
if sdp.enabled:
|
+ self.callsign_data_providers
|
||||||
sdp.stop()
|
)
|
||||||
for cdp in self.callsign_data_providers:
|
if p.enabled
|
||||||
if cdp.enabled:
|
]
|
||||||
cdp.stop()
|
if not all_providers:
|
||||||
|
return
|
||||||
|
|
||||||
|
def stop_provider(p):
|
||||||
|
try:
|
||||||
|
p.stop()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Exception stopping provider")
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=stop_provider, args=(p,), daemon=True) for p in all_providers]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
deadline = time.monotonic() + 15
|
||||||
|
for t in threads:
|
||||||
|
t.join(timeout=max(0.0, deadline - time.monotonic()))
|
||||||
|
still_running = [t for t in threads if t.is_alive()]
|
||||||
|
if still_running:
|
||||||
|
logger.warning("Some threads did not stop in time!")
|
||||||
|
|
||||||
|
|
||||||
# Global object
|
# Global object
|
||||||
|
|||||||
+15
-14
@@ -15,8 +15,8 @@ CACHE_DIR = "./cache/"
|
|||||||
|
|
||||||
|
|
||||||
class DataStore:
|
class DataStore:
|
||||||
"""Data caching/storage object. Handles storage of spots, alerts, solar conditions, SIG reference data, and callsign
|
"""Data caching/storage object. Handles storage of spots, alerts, solar conditions, activity reference data, and
|
||||||
lookup data using different caching strategies for each."""
|
callsign lookup data using different caching strategies for each."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Constants
|
# Constants
|
||||||
@@ -34,7 +34,7 @@ class DataStore:
|
|||||||
self.callsign_data_hamqth = None
|
self.callsign_data_hamqth = None
|
||||||
self.dxcc_data = None
|
self.dxcc_data = None
|
||||||
self.dxcc_lookup_by_call_regex = []
|
self.dxcc_lookup_by_call_regex = []
|
||||||
self.sigrefs = None
|
self.activity_refs = None
|
||||||
self.status = None
|
self.status = None
|
||||||
self.solar_conditions = None
|
self.solar_conditions = None
|
||||||
# ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be
|
# ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be
|
||||||
@@ -51,16 +51,16 @@ class DataStore:
|
|||||||
self.solar_conditions = SingleObjectDataCache(f"{CACHE_DIR}solar", SolarConditions())
|
self.solar_conditions = SingleObjectDataCache(f"{CACHE_DIR}solar", SolarConditions())
|
||||||
self.status = SingleObjectDataCache(f"{CACHE_DIR}status", {})
|
self.status = SingleObjectDataCache(f"{CACHE_DIR}status", {})
|
||||||
|
|
||||||
# Standard disk cache for static reference and SIG ref data. Separate provider threads will repopulate these on
|
# Standard disk cache for static reference and activity ref data. Separate provider threads will repopulate
|
||||||
# a regular basis but there's no need for a TTL since old data is better than no data.
|
# these on a regular basis but there's no need for a TTL since old data is better than no data.
|
||||||
self.dxcc_data = diskcache.Cache(f"{CACHE_DIR}dxcc_data")
|
self.dxcc_data = diskcache.Cache(f"{CACHE_DIR}dxcc_data")
|
||||||
self.regenerate_call_regex_to_dxcc_entity_map()
|
self.regenerate_call_regex_to_dxcc_entity_map()
|
||||||
|
|
||||||
# For SIG reference data specifically, we need to key on both SIG *and* reference, and trying to do two layers
|
# For activity reference data specifically, we need to key on both activity *and* reference, and trying to do
|
||||||
# of dict in diskcache absolutely destroys performance with unpickling huge dicts, so we have an ugly "SIG:ref"
|
# two layers of dict in diskcache absolutely destroys performance with unpickling huge dicts, so we have an
|
||||||
# syntax for keys to keep it a single level.
|
# ugly "activity:ref" syntax for keys to keep it a single level.
|
||||||
self.sigrefs = diskcache.Cache(f"{CACHE_DIR}sigrefs")
|
self.activity_refs = diskcache.Cache(f"{CACHE_DIR}activity_refs")
|
||||||
logger.info(f"Loaded data for {len(self.sigrefs)} SIG references.")
|
logger.info(f"Loaded data for {len(self.activity_refs)} activity references.")
|
||||||
|
|
||||||
# Standard disk cache for callsign data. This data does have a TTL to trigger an occasional re-lookup.
|
# Standard disk cache for callsign data. This data does have a TTL to trigger an occasional re-lookup.
|
||||||
# Old data *is* better than no data, but we can't have a background thread re-looking-up every callsign
|
# Old data *is* better than no data, but we can't have a background thread re-looking-up every callsign
|
||||||
@@ -82,8 +82,8 @@ class DataStore:
|
|||||||
logger.info(f"Loaded data for {len(unique_keys)} callsigns.")
|
logger.info(f"Loaded data for {len(unique_keys)} callsigns.")
|
||||||
|
|
||||||
# Special caches for spots and alerts, which have TTL and write snapshots to disk at an interval. We
|
# Special caches for spots and alerts, which have TTL and write snapshots to disk at an interval. We
|
||||||
# specifically load these caches *last* so that any sigref and callsign data is already loaded from disk cache
|
# specifically load these caches *last* so that any activity ref and callsign data is already loaded from disk
|
||||||
# before the spots and alerts are live in the system.
|
# cache before the spots and alerts are live in the system.
|
||||||
self.spots = LiveDataCache(
|
self.spots = LiveDataCache(
|
||||||
maxsize=self._MAX_SPOT_COUNT,
|
maxsize=self._MAX_SPOT_COUNT,
|
||||||
ttl=MAX_SPOT_AGE,
|
ttl=MAX_SPOT_AGE,
|
||||||
@@ -106,16 +106,17 @@ class DataStore:
|
|||||||
and testing the callsign every time is expensive. So instead we build a separate in-memory lookup of compiled
|
and testing the callsign every time is expensive. So instead we build a separate in-memory lookup of compiled
|
||||||
regex against DXCC entity code, as a list of tuples we can iterate through."""
|
regex against DXCC entity code, as a list of tuples we can iterate through."""
|
||||||
|
|
||||||
|
self.dxcc_lookup_by_call_regex = []
|
||||||
for entry in [DATA_STORE.dxcc_data[key] for key in DATA_STORE.dxcc_data]:
|
for entry in [DATA_STORE.dxcc_data[key] for key in DATA_STORE.dxcc_data]:
|
||||||
self.dxcc_lookup_by_call_regex.append((re.compile(entry["prefixRegex"]), entry["entityCode"]))
|
self.dxcc_lookup_by_call_regex.append((re.compile(entry["prefixRegex"]), entry["entityCode"]))
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.spots.close()
|
self.spots.close()
|
||||||
self.alerts.close()
|
self.alerts.close()
|
||||||
self.solar.close()
|
self.solar_conditions.close()
|
||||||
self.status.close()
|
self.status.close()
|
||||||
self.dxcc_data.close()
|
self.dxcc_data.close()
|
||||||
self.sigrefs.close()
|
self.activity_refs.close()
|
||||||
self.callsign_data_countryfiles.close()
|
self.callsign_data_countryfiles.close()
|
||||||
self.callsign_data_clublogxml.close()
|
self.callsign_data_clublogxml.close()
|
||||||
self.callsign_data_clublogapi.close()
|
self.callsign_data_clublogapi.close()
|
||||||
|
|||||||
+200
@@ -0,0 +1,200 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class Continent(str, Enum):
|
||||||
|
EU = "EU"
|
||||||
|
NA = "NA"
|
||||||
|
SA = "SA"
|
||||||
|
AS = "AS"
|
||||||
|
AF = "AF"
|
||||||
|
OC = "OC"
|
||||||
|
AN = "AN"
|
||||||
|
|
||||||
|
|
||||||
|
class Mode(str, Enum):
|
||||||
|
CW = "CW"
|
||||||
|
PHONE = "PHONE"
|
||||||
|
SSB = "SSB"
|
||||||
|
AM = "AM"
|
||||||
|
FM = "FM"
|
||||||
|
DV = "DV"
|
||||||
|
DATA = "DATA"
|
||||||
|
FT8 = "FT8"
|
||||||
|
FT4 = "FT4"
|
||||||
|
RTTY = "RTTY"
|
||||||
|
SSTV = "SSTV"
|
||||||
|
JS8 = "JS8"
|
||||||
|
HELL = "HELL"
|
||||||
|
PSK = "PSK"
|
||||||
|
FSK = "FSK"
|
||||||
|
OLIVIA = "OLIVIA"
|
||||||
|
PKT = "PKT"
|
||||||
|
MSK144 = "MSK144"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cw(self) -> bool:
|
||||||
|
return self == Mode.CW
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_phone(self) -> bool:
|
||||||
|
return self in {Mode.PHONE, Mode.SSB, Mode.AM, Mode.FM, Mode.DV}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_data(self) -> bool:
|
||||||
|
return not (self.is_cw or self.is_phone)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_name(name):
|
||||||
|
"""Convert a string to an enum mode using the alias table."""
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return Mode(name.upper())
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
try:
|
||||||
|
return Mode(MODE_ALIASES[name.upper()])
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ModeType(str, Enum):
|
||||||
|
PHONE = "PHONE"
|
||||||
|
CW = "CW"
|
||||||
|
DATA = "DATA"
|
||||||
|
|
||||||
|
|
||||||
|
class ModeSource(str, Enum):
|
||||||
|
"""Where the mode data came from in a spot."""
|
||||||
|
|
||||||
|
SPOT = "SPOT"
|
||||||
|
COMMENT = "COMMENT"
|
||||||
|
BANDPLAN = "BANDPLAN"
|
||||||
|
|
||||||
|
|
||||||
|
class LocationSourceForSpot(str, Enum):
|
||||||
|
"""Where the location data came from in a spot."""
|
||||||
|
|
||||||
|
SPOT = "SPOT"
|
||||||
|
SIG_REF_LOOKUP = "SIG REF LOOKUP"
|
||||||
|
GRID = "GRID"
|
||||||
|
HOME_QTH = "HOME QTH"
|
||||||
|
DXCC = "DXCC"
|
||||||
|
|
||||||
|
|
||||||
|
class LocationSourceForCallsign(str, Enum):
|
||||||
|
"""Where the location data came from in a callsign data object."""
|
||||||
|
|
||||||
|
HOME_QTH = "HOME QTH"
|
||||||
|
DXCC = "DXCC"
|
||||||
|
|
||||||
|
|
||||||
|
# Definitions of every activity Spothole knows about, keyed by the ActivityName enum. An enum is used for the
|
||||||
|
# names to avoid typos when using literal strings like "POTA" all over the place.
|
||||||
|
class ActivityName(str, Enum):
|
||||||
|
"""Canonical name of every activity. Spothole uses these rather than literals like "POTA" around the code
|
||||||
|
to ensure I don't accidentally introduce typos."""
|
||||||
|
|
||||||
|
CONTEST = "Contest"
|
||||||
|
DXPEDITION = "DXpedition"
|
||||||
|
SATELLITE = "Satellite"
|
||||||
|
EME = "EME"
|
||||||
|
AERONAUTICAL_MOBILE = "/AM"
|
||||||
|
MARITIME_MOBILE = "/MM"
|
||||||
|
QRP = "QRP"
|
||||||
|
POTA = "POTA"
|
||||||
|
SOTA = "SOTA"
|
||||||
|
WWFF = "WWFF"
|
||||||
|
GMA = "GMA"
|
||||||
|
WWBOTA = "WWBOTA"
|
||||||
|
HEMA = "HEMA"
|
||||||
|
IOTA = "IOTA"
|
||||||
|
GMA_ISLANDS = "GMA Islands"
|
||||||
|
ARLHS = "ARLHS"
|
||||||
|
ILLW = "ILLW"
|
||||||
|
MOTA = "MOTA"
|
||||||
|
SIOTA = "SIOTA"
|
||||||
|
WCA = "WCA"
|
||||||
|
ZLOTA = "ZLOTA"
|
||||||
|
WOTA = "WOTA"
|
||||||
|
BOTA = "BOTA"
|
||||||
|
KRMNPA = "KRMNPA"
|
||||||
|
SANPCPA = "SANPCPA"
|
||||||
|
LLOTA = "LLOTA"
|
||||||
|
TOWERS = "Towers"
|
||||||
|
TILES = "Tiles"
|
||||||
|
RADAR_RALLY = "RaDAR Rally"
|
||||||
|
WAB = "WAB"
|
||||||
|
WAI = "WAI"
|
||||||
|
DMF = "DMF"
|
||||||
|
DME = "DME"
|
||||||
|
FEA = "FEA"
|
||||||
|
DMUE = "DMUE"
|
||||||
|
DMVE = "DMVE"
|
||||||
|
DCE = "DCE"
|
||||||
|
DEFE = "DEFE"
|
||||||
|
DTMBA = "DTMBA"
|
||||||
|
BIWOTA = "BIWOTA"
|
||||||
|
COTA = "COTA"
|
||||||
|
PGA = "PGA"
|
||||||
|
TOILETS = "Toilets"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self.value)
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityRefType(str, Enum):
|
||||||
|
"""Type of an activity reference."""
|
||||||
|
|
||||||
|
PARK = "PARK"
|
||||||
|
SUMMIT = "SUMMIT"
|
||||||
|
CASTLE = "CASTLE"
|
||||||
|
LIGHTHOUSE = "LIGHTHOUSE"
|
||||||
|
ISLAND = "ISLAND"
|
||||||
|
BUNKER = "BUNKER"
|
||||||
|
MILL = "MILL"
|
||||||
|
SILO = "SILO"
|
||||||
|
BEACH = "BEACH"
|
||||||
|
LAKE = "LAKE"
|
||||||
|
TOWER = "TOWER"
|
||||||
|
WATERWAY = "WATERWAY"
|
||||||
|
TOWN = "TOWN"
|
||||||
|
BUILDING = "BUILDING"
|
||||||
|
REGION = "REGION"
|
||||||
|
GRID = "GRID"
|
||||||
|
TOILET = "TOILET"
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityType(str, Enum):
|
||||||
|
"""Type of an activity. Used to group them in the web UI."""
|
||||||
|
|
||||||
|
TRADITIONAL = "TRADITIONAL"
|
||||||
|
ADVENTURE = "ADVENTURE"
|
||||||
|
REGIONAL = "REGIONAL"
|
||||||
|
EVENT = "EVENT"
|
||||||
|
|
||||||
|
|
||||||
|
# Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode
|
||||||
|
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots
|
||||||
|
# that match a key in this table will be converted to the corresponding value, so only the modes above will actually be
|
||||||
|
# present in the spots.
|
||||||
|
MODE_ALIASES = {
|
||||||
|
"USB": "SSB",
|
||||||
|
"LSB": "SSB",
|
||||||
|
"DIGITALVOICE": "DV",
|
||||||
|
"DIGITALVOI": "DV",
|
||||||
|
"DMR": "DV",
|
||||||
|
"DSTAR": "DV",
|
||||||
|
"C4FM": "DV",
|
||||||
|
"FUSION": "DV",
|
||||||
|
"M17": "DV",
|
||||||
|
"RTT": "RTTY",
|
||||||
|
"BPSK": "PSK",
|
||||||
|
"PSK31": "PSK",
|
||||||
|
"BPSK31": "PSK",
|
||||||
|
"MFSK": "FSK",
|
||||||
|
"MFSK32": "FSK",
|
||||||
|
"DIGI": "DATA",
|
||||||
|
"DIGITAL": "DATA",
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from math import floor
|
from math import floor, isnan
|
||||||
|
|
||||||
from pyproj import Transformer
|
from pyproj import Transformer
|
||||||
from shapely.geometry import Point, Polygon
|
from shapely.geometry import Point, Polygon
|
||||||
@@ -156,7 +156,7 @@ def lat_lon_for_grid_sw_corner_plus_size(grid):
|
|||||||
lat -= 90.0
|
lat -= 90.0
|
||||||
|
|
||||||
# Return None values on maths errors
|
# Return None values on maths errors
|
||||||
if any(x != x for x in [lat, lon, lat_cell_size, lon_cell_size]):
|
if any(isnan(x) for x in [lat, lon, lat_cell_size, lon_cell_size]):
|
||||||
return None, None, None, None
|
return None, None, None, None
|
||||||
|
|
||||||
return lat, lon, lat_cell_size, lon_cell_size
|
return lat, lon, lat_cell_size, lon_cell_size
|
||||||
|
|||||||
+18
-5
@@ -22,6 +22,8 @@ class LiveDataCache:
|
|||||||
self._listeners_lock = threading.Lock()
|
self._listeners_lock = threading.Lock()
|
||||||
self._snapshot_dir = snapshot_dir
|
self._snapshot_dir = snapshot_dir
|
||||||
self._disk_cache = diskcache.Cache(str(snapshot_dir))
|
self._disk_cache = diskcache.Cache(str(snapshot_dir))
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._snapshot_thread = None
|
||||||
self._load_snapshot()
|
self._load_snapshot()
|
||||||
self._start_periodic_snapshot(snapshot_interval_sec)
|
self._start_periodic_snapshot(snapshot_interval_sec)
|
||||||
|
|
||||||
@@ -75,7 +77,12 @@ class LiveDataCache:
|
|||||||
logger.exception(f"Failed to write snapshot to {self._snapshot_dir}")
|
logger.exception(f"Failed to write snapshot to {self._snapshot_dir}")
|
||||||
|
|
||||||
def _load_snapshot(self):
|
def _load_snapshot(self):
|
||||||
data = self._disk_cache.get("snapshot")
|
try:
|
||||||
|
data = self._disk_cache.get("snapshot")
|
||||||
|
except Exception: # noqa: BLE001 (If any exceptions we treat the data as junk and start from scratch, it's probably due to a version upgrade having incompatible data structures, fine to not log the exception in this case)
|
||||||
|
logger.warning(f"Failed to load snapshot from {self._snapshot_dir}, clearing it.")
|
||||||
|
self._disk_cache.clear()
|
||||||
|
return
|
||||||
if not data:
|
if not data:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -89,13 +96,19 @@ class LiveDataCache:
|
|||||||
|
|
||||||
def _start_periodic_snapshot(self, interval):
|
def _start_periodic_snapshot(self, interval):
|
||||||
def loop():
|
def loop():
|
||||||
while True:
|
while not self._stop_event.wait(timeout=interval):
|
||||||
time.sleep(interval)
|
|
||||||
self.save_snapshot()
|
self.save_snapshot()
|
||||||
|
|
||||||
t = threading.Thread(target=loop, name=f"LiveDataCache-Snapshot-{self._snapshot_dir}")
|
self._snapshot_thread = threading.Thread(
|
||||||
t.start()
|
target=loop, name=f"LiveDataCache-Snapshot-{self._snapshot_dir}", daemon=True
|
||||||
|
)
|
||||||
|
self._snapshot_thread.start()
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._snapshot_thread:
|
||||||
|
self._snapshot_thread.join(timeout=15)
|
||||||
|
if self._snapshot_thread.is_alive():
|
||||||
|
logger.warning(f"LiveDataCache snapshot thread for {self._snapshot_dir} did not exit on time.")
|
||||||
self.save_snapshot()
|
self.save_snapshot()
|
||||||
self._disk_cache.close()
|
self._disk_cache.close()
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
|
|
||||||
|
|
||||||
from core.data_store import DATA_STORE
|
|
||||||
from core.geo_utils import wab_wai_square_to_lat_lon
|
|
||||||
from data.sig_ref import SIGRef
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def get_sig_ref_info(sig, ref_id):
|
|
||||||
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig name and
|
|
||||||
a reference ID (both strings) and returns a SigRef object populated with as much data as we can find. This makes
|
|
||||||
use of SIG ref data in the data store, live lookups from the web, or just automatic calculation depending on which
|
|
||||||
SIG we are getting data for."""
|
|
||||||
|
|
||||||
if sig is None or sig == "" or ref_id is None or ref_id == "":
|
|
||||||
logger.debug("Failed to look up sig_ref info, sig or ref were not set.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
sig_ref = SIGRef(sig=sig, id=ref_id)
|
|
||||||
|
|
||||||
try:
|
|
||||||
### FUDGES ###
|
|
||||||
#
|
|
||||||
# DME fudge. Our database has leading zeros padding to 5 digits which is the expected format, but not all
|
|
||||||
# activators add leading zeros.
|
|
||||||
if sig.upper() == "DME":
|
|
||||||
ref_id = ref_id.zfill(5)
|
|
||||||
|
|
||||||
# If the SIG is HEMA, we have no current lookup for this so just skip the lookup here.
|
|
||||||
if sig.upper() == "HEMA":
|
|
||||||
sig_ref.ref_type = "Summit"
|
|
||||||
return sig_ref
|
|
||||||
|
|
||||||
### PROGRAMMATIC DATA GENERATION INSTEAD OF LOOKUPS ###
|
|
||||||
#
|
|
||||||
# If the SIG is Tiles, WAB, WAI or BOTA (Beaches), we don't have anything to look up from the data store, we can
|
|
||||||
# calculate all the information we are going to get directly.
|
|
||||||
if sig.upper() == "TILES":
|
|
||||||
# Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same
|
|
||||||
sig_ref.ref_type = "Grid"
|
|
||||||
if not sig_ref.name:
|
|
||||||
sig_ref.name = sig_ref.id
|
|
||||||
if not sig_ref.grid:
|
|
||||||
sig_ref.grid = sig_ref.id
|
|
||||||
if sig_ref.grid and (not sig_ref.latitude or not sig_ref.longitude):
|
|
||||||
ll = locator_to_latlong(str(sig_ref.grid))
|
|
||||||
sig_ref.latitude = ll[0]
|
|
||||||
sig_ref.longitude = ll[1]
|
|
||||||
return sig_ref
|
|
||||||
|
|
||||||
elif sig.upper() == "WAB" or sig.upper() == "WAI":
|
|
||||||
sig_ref.ref_type = "Grid"
|
|
||||||
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 Exception:
|
|
||||||
logger.warning("Invalid lat/lon received for WAB/WAI reference")
|
|
||||||
return sig_ref
|
|
||||||
|
|
||||||
elif sig.upper() == "BOTA":
|
|
||||||
# For BOTA all we can ever generate is the URL, there is no data file or lookup for lat/longs
|
|
||||||
sig_ref.ref_type = "Beach"
|
|
||||||
if not sig_ref.name:
|
|
||||||
sig_ref.name = sig_ref.id
|
|
||||||
if sig_ref.name:
|
|
||||||
sig_ref.url = f"https://www.beachesontheair.com/beaches/{sig_ref.name.lower().replace(' ', '-')}"
|
|
||||||
return sig_ref
|
|
||||||
|
|
||||||
elif sig.upper() == "GMA Islands":
|
|
||||||
# GMA Islands is a bit of a mess of GMA and IOTA references. Try looking them both up and see what returns
|
|
||||||
# the best result.
|
|
||||||
iota_lookup = get_sig_ref_info("IOTA", ref_id)
|
|
||||||
gma_lookup = get_sig_ref_info("GMA", ref_id)
|
|
||||||
for key, value in iota_lookup.__dict__.items():
|
|
||||||
if value is not None and sig_ref.__dict__.get(key) is None:
|
|
||||||
sig_ref.__dict__[key] = value
|
|
||||||
for key, value in gma_lookup.__dict__.items():
|
|
||||||
if value is not None and sig_ref.__dict__.get(key) is None:
|
|
||||||
sig_ref.__dict__[key] = value
|
|
||||||
sig_ref.ref_type = "Island"
|
|
||||||
return sig_ref
|
|
||||||
|
|
||||||
### ACTUAL LOOKUP ###
|
|
||||||
#
|
|
||||||
# OK, this is something we have to look up. Now check to see if our data store contains reference data and use
|
|
||||||
# that.
|
|
||||||
key = f"{sig}:{ref_id}"
|
|
||||||
lookup_data = DATA_STORE.sigrefs.get(key) if key in DATA_STORE.sigrefs else None
|
|
||||||
if lookup_data:
|
|
||||||
return lookup_data
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Maybe a super new reference we don't know about yet, but more likely a typo or a test reference,
|
|
||||||
# just silently ignore it.
|
|
||||||
logger.debug(f"{sig} database did not contain data for ref {ref_id}")
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
logger.exception(f"Exception when looking up sig_ref info for {sig} ref {ref_id}")
|
|
||||||
return sig_ref
|
|
||||||
|
|
||||||
|
|
||||||
def populate_missing_sig_ref_info(sig_ref):
|
|
||||||
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object
|
|
||||||
which must at minimum have a "sig" and an "id". The rest of the object will be populated and returned. Any data
|
|
||||||
currently in the object will be kept, only missing data in the object will be populated if it can be determined."""
|
|
||||||
|
|
||||||
lookup_data = get_sig_ref_info(sig_ref.sig, sig_ref.id)
|
|
||||||
|
|
||||||
if lookup_data:
|
|
||||||
# Copy new sig ref data into existing object where data was previously missing
|
|
||||||
for key, value in lookup_data.__dict__.items():
|
|
||||||
if value is not None and sig_ref.__dict__.get(key) is None:
|
|
||||||
sig_ref.__dict__[key] = value
|
|
||||||
|
|
||||||
return sig_ref
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
from core.constants import SIGS
|
|
||||||
|
|
||||||
|
|
||||||
def get_ref_regex_for_sig(sig):
|
|
||||||
"""Utility function to get the regex string for a SIG reference for a named SIG. If no match is found, None will be returned."""
|
|
||||||
|
|
||||||
for s in SIGS:
|
|
||||||
if s.name.upper() == sig.upper():
|
|
||||||
return s.ref_regex
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_sig_name_from_comment_name(sig):
|
|
||||||
"""Utility function to get the name of a SIG from its "comment name". Generally these will be the same but there are
|
|
||||||
some cases (e.g. is "TOTA" Towers, Tiles or Toilets?) where we need to transform one to the other."""
|
|
||||||
|
|
||||||
for s in SIGS:
|
|
||||||
if any(n.upper() == sig.upper() for n in s.comment_names):
|
|
||||||
return s.name
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# Regex matching any SIG's "comment name", i.e. how it may be referred to in spot comments
|
|
||||||
ANY_SIG_REGEX = rf"({'|'.join(n for s in SIGS for n in s.comment_names)})"
|
|
||||||
@@ -7,10 +7,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class SingleObjectDataCache:
|
class SingleObjectDataCache:
|
||||||
"""Cache for status and solar conditions. This uses DiskCache, but unlike the standard DiskCache users like SIG and
|
"""Cache for status and solar conditions. This uses DiskCache, but unlike the standard DiskCache users like
|
||||||
callsign lookup handlers, status and solar conditions are persisted as a single object. If we just load the object
|
activity ref and callsign lookup handlers, status and solar conditions are persisted as a single object. If we
|
||||||
from DiskCache and modify it, DiskCache doesn't know that it's been updated and needs re-caching, so we provide a
|
just load the object from DiskCache and modify it, DiskCache doesn't know that it's been updated and needs
|
||||||
store() method that any functions updating the object can call afterwards."""
|
re-caching, so we provide a store() method that any functions updating the object can call afterwards."""
|
||||||
|
|
||||||
def __init__(self, cache_dir, object_if_empty):
|
def __init__(self, cache_dir, object_if_empty):
|
||||||
"""Initialize a SingleObjectDataCache. Provide the directory to load the cache from and save it to. If the cache
|
"""Initialize a SingleObjectDataCache. Provide the directory to load the cache from and save it to. If the cache
|
||||||
@@ -21,7 +21,13 @@ class SingleObjectDataCache:
|
|||||||
# This cache stores a single object, doesn't matter what it's called so "object" will do
|
# This cache stores a single object, doesn't matter what it's called so "object" will do
|
||||||
if "object" not in self._cache:
|
if "object" not in self._cache:
|
||||||
self._cache.add("object", object_if_empty)
|
self._cache.add("object", object_if_empty)
|
||||||
self._obj = self._cache.get("object")
|
try:
|
||||||
|
self._obj = self._cache.get("object")
|
||||||
|
except Exception: # noqa: BLE001 (If any exceptions we treat the data as junk and start from scratch, it's probably due to a version upgrade having incompatible data structures, fine to not log the exception in this case)
|
||||||
|
logger.warning(f"Failed to load cache from {cache_dir}, clearing it.")
|
||||||
|
self._cache.clear()
|
||||||
|
self._cache.add("object", object_if_empty)
|
||||||
|
self._obj = object_if_empty
|
||||||
|
|
||||||
def get(self):
|
def get(self):
|
||||||
"""Get the data object. This can then be manipulated as necessary across multiple threads. Any function
|
"""Get the data object. This can then be manipulated as necessary across multiple threads. Any function
|
||||||
|
|||||||
+17
-15
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from threading import Event, Thread
|
from threading import Event, Thread
|
||||||
@@ -11,7 +12,10 @@ from core.constants import SOFTWARE_VERSION
|
|||||||
from core.data_providers import DATA_PROVIDERS
|
from core.data_providers import DATA_PROVIDERS
|
||||||
from core.data_store import DATA_STORE
|
from core.data_store import DATA_STORE
|
||||||
from core.prometheus_metrics_handler import alerts_gauge, memory_use_gauge, spots_gauge
|
from core.prometheus_metrics_handler import alerts_gauge, memory_use_gauge, spots_gauge
|
||||||
from server.webserver import WEB_SERVER
|
from telnetserver.telnetserver import TELNET_SERVER
|
||||||
|
from webserver.webserver import WEB_SERVER
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class StatusReporter:
|
class StatusReporter:
|
||||||
@@ -32,13 +36,17 @@ class StatusReporter:
|
|||||||
def start(self):
|
def start(self):
|
||||||
"""Start the reporter thread"""
|
"""Start the reporter thread"""
|
||||||
|
|
||||||
self._thread = Thread(target=self._run, name="StatusReporter")
|
self._thread = Thread(target=self._run, name="StatusReporter", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Stop any threads and prepare for application shutdown"""
|
"""Stop any threads and prepare for application shutdown"""
|
||||||
|
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=15)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning("Status reporter worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
"""Thread entry point: report immediately on startup, then on each interval until stopped"""
|
"""Thread entry point: report immediately on startup, then on each interval until stopped"""
|
||||||
@@ -134,19 +142,13 @@ class StatusReporter:
|
|||||||
else 0,
|
else 0,
|
||||||
}
|
}
|
||||||
DATA_STORE.status.get()["webserver"] = {
|
DATA_STORE.status.get()["webserver"] = {
|
||||||
"status": WEB_SERVER.web_server_metrics["status"],
|
"status": WEB_SERVER.web_server_metrics.status,
|
||||||
"last_api_access": WEB_SERVER.web_server_metrics["last_api_access_time"]
|
"api_requests_per_hour": WEB_SERVER.web_server_metrics.api_requests_per_hour(),
|
||||||
.replace(tzinfo=pytz.UTC)
|
"page_requests_per_hour": WEB_SERVER.web_server_metrics.page_requests_per_hour(),
|
||||||
.timestamp()
|
"sse_client_count": WEB_SERVER.sse_client_count,
|
||||||
if WEB_SERVER.web_server_metrics["last_api_access_time"]
|
}
|
||||||
else 0,
|
DATA_STORE.status.get()["telnet"] = {
|
||||||
"api_access_count": WEB_SERVER.web_server_metrics["api_access_counter"],
|
"client_count": TELNET_SERVER.client_count,
|
||||||
"last_page_access": WEB_SERVER.web_server_metrics["last_page_access_time"]
|
|
||||||
.replace(tzinfo=pytz.UTC)
|
|
||||||
.timestamp()
|
|
||||||
if WEB_SERVER.web_server_metrics["last_page_access_time"]
|
|
||||||
else 0,
|
|
||||||
"page_access_count": WEB_SERVER.web_server_metrics["page_access_counter"],
|
|
||||||
}
|
}
|
||||||
DATA_STORE.status.store()
|
DATA_STORE.status.store()
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,13 @@ class URLDataCache(CachedSession):
|
|||||||
used across multiple threads, though note that URL lookups will block each other this way, so it is still better to
|
used across multiple threads, though note that URL lookups will block each other this way, so it is still better to
|
||||||
create one of these objects per thread if possible."""
|
create one of these objects per thread if possible."""
|
||||||
|
|
||||||
_lock = threading.Lock()
|
|
||||||
|
|
||||||
def __init__(self, name):
|
def __init__(self, name):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
f"{CACHE_DIR}urls/{name}",
|
f"{CACHE_DIR}urls/{name}",
|
||||||
expire_after=timedelta(days=1),
|
expire_after=timedelta(days=1),
|
||||||
allowable_codes=(200, 400, 401, 403, 404),
|
allowable_codes=(200, 400, 401, 403, 404),
|
||||||
)
|
)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
def get(self, *args, **kwargs):
|
def get(self, *args, **kwargs):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|||||||
+44
-29
@@ -1,20 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
import simplejson
|
import simplejson
|
||||||
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 core.constants import (
|
from core.constants import BANDS, UNKNOWN_BAND
|
||||||
ALL_MODES,
|
|
||||||
BANDS,
|
|
||||||
CW_MODES,
|
|
||||||
DATA_MODES,
|
|
||||||
MODE_ALIASES,
|
|
||||||
PHONE_MODES,
|
|
||||||
UNKNOWN_BAND,
|
|
||||||
)
|
|
||||||
from core.data_store import DATA_STORE
|
from core.data_store import DATA_STORE
|
||||||
from data.callsign import Callsign
|
from core.enums import MODE_ALIASES, Continent, Mode, ModeType
|
||||||
|
from data.callsign import Callsign, LocationSourceForCallsign
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -26,28 +20,40 @@ def safe_json_dumps(obj):
|
|||||||
return simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, default=lambda o: o.__dict__)
|
return simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, default=lambda o: o.__dict__)
|
||||||
|
|
||||||
|
|
||||||
def infer_mode_from_comment(comment):
|
def infer_mode_from_comment(comment: str) -> Mode | None:
|
||||||
"""Infer a mode from the comment"""
|
"""Infer a mode from the comment"""
|
||||||
|
|
||||||
for mode in ALL_MODES:
|
if not comment:
|
||||||
if mode in comment.upper():
|
return None
|
||||||
|
|
||||||
|
for mode in Mode:
|
||||||
|
if re.search(r"(^|\W)" + mode + r"($|\W)", comment, re.IGNORECASE):
|
||||||
return mode
|
return mode
|
||||||
for mode in MODE_ALIASES:
|
for alias in MODE_ALIASES:
|
||||||
if mode in comment.upper():
|
if re.search(r"(^|\W)" + alias + r"($|\W)", comment, re.IGNORECASE):
|
||||||
return MODE_ALIASES[mode]
|
return Mode(MODE_ALIASES[alias])
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def infer_mode_type_from_mode(mode):
|
def infer_mode_type_from_mode(mode: str) -> ModeType | None:
|
||||||
"""Infer a "mode family" from a mode."""
|
"""Infer a "mode family" from a mode ."""
|
||||||
|
|
||||||
if mode.upper() in CW_MODES:
|
if not mode:
|
||||||
return "CW"
|
return None
|
||||||
elif mode.upper() in PHONE_MODES:
|
|
||||||
return "PHONE"
|
if mode in MODE_ALIASES:
|
||||||
elif mode.upper() in DATA_MODES:
|
mode = MODE_ALIASES[mode]
|
||||||
return "DATA"
|
|
||||||
else:
|
try:
|
||||||
|
mode = Mode(mode.upper())
|
||||||
|
if mode.is_cw:
|
||||||
|
return ModeType.CW
|
||||||
|
if mode.is_phone:
|
||||||
|
return ModeType.PHONE
|
||||||
|
return ModeType.DATA
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
if mode.upper() != "OTHER" and mode != "?":
|
if mode.upper() != "OTHER" and mode != "?":
|
||||||
logger.warning(f"Found an unrecognised mode: {mode}. Developer should categorise this.")
|
logger.warning(f"Found an unrecognised mode: {mode}. Developer should categorise this.")
|
||||||
return None
|
return None
|
||||||
@@ -70,7 +76,7 @@ def infer_mode_from_frequency(freq):
|
|||||||
mode = freq_to_band(khz)["mode"]
|
mode = freq_to_band(khz)["mode"]
|
||||||
# Some additional common digimode ranges in addition to what the 3rd-party freq_to_band function returns.
|
# 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.
|
# 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
|
# a spot at 7074.5 kHz will be indicated as SSB, 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
|
# 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.
|
# large numbers of clearly-FT* spots off the list of people filtering out digimodes.
|
||||||
if (
|
if (
|
||||||
@@ -93,7 +99,16 @@ def infer_mode_from_frequency(freq):
|
|||||||
or (28180 <= khz < 28183)
|
or (28180 <= khz < 28183)
|
||||||
):
|
):
|
||||||
mode = "FT4"
|
mode = "FT4"
|
||||||
return mode
|
|
||||||
|
if not mode:
|
||||||
|
return None
|
||||||
|
|
||||||
|
mode = mode.upper()
|
||||||
|
if mode in MODE_ALIASES:
|
||||||
|
mode = MODE_ALIASES[mode]
|
||||||
|
|
||||||
|
return Mode(mode)
|
||||||
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -115,7 +130,7 @@ def get_callsign_object_from_pyhamtools_callinfo(callsign, callinfo):
|
|||||||
|
|
||||||
country = data.get("country", None)
|
country = data.get("country", None)
|
||||||
dxcc_id = data.get("adif", None)
|
dxcc_id = data.get("adif", None)
|
||||||
continent = data.get("continent", None)
|
continent = Continent(data["continent"]) if "continent" in data else None
|
||||||
cq_zone = data.get("cqz", None)
|
cq_zone = data.get("cqz", None)
|
||||||
itu_zone = data.get("ituz", None)
|
itu_zone = data.get("ituz", None)
|
||||||
lat = float(data["latitude"]) if "latitude" in data else None
|
lat = float(data["latitude"]) if "latitude" in data else None
|
||||||
@@ -135,7 +150,7 @@ def get_callsign_object_from_pyhamtools_callinfo(callsign, callinfo):
|
|||||||
latitude=lat,
|
latitude=lat,
|
||||||
longitude=lon,
|
longitude=lon,
|
||||||
grid=grid,
|
grid=grid,
|
||||||
location_source="DXCC",
|
location_source=LocationSourceForCallsign.DXCC,
|
||||||
)
|
)
|
||||||
|
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
|
|||||||
@@ -0,0 +1,501 @@
|
|||||||
|
from core.enums import ActivityName, ActivityRefType, ActivityType
|
||||||
|
from data.activity import Activity
|
||||||
|
|
||||||
|
ACTIVITIES: dict[ActivityName, Activity] = {
|
||||||
|
ActivityName.CONTEST: Activity(
|
||||||
|
name=ActivityName.CONTEST,
|
||||||
|
description="Contest",
|
||||||
|
sig_type=ActivityType.TRADITIONAL,
|
||||||
|
has_refs=False,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
# No sensible way to determine *which* contest, but if we set comment_names=["CONTEST"] then at least
|
||||||
|
# any spots with "contest" in the comment will get allocated to this activity.
|
||||||
|
comment_names=["CONTEST", "TEST"],
|
||||||
|
icon="fa-trophy",
|
||||||
|
alerts_possible=True,
|
||||||
|
),
|
||||||
|
ActivityName.DXPEDITION: Activity(
|
||||||
|
name=ActivityName.DXPEDITION,
|
||||||
|
description="Radio expedition to a remote location",
|
||||||
|
sig_type=ActivityType.TRADITIONAL,
|
||||||
|
has_refs=False,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
# DXpedition stations are never really spotted with "DXpedition" in the comments, but we can assign
|
||||||
|
# this activity to a spot other ways.
|
||||||
|
comment_names=[],
|
||||||
|
icon="fa-book-atlas",
|
||||||
|
alerts_possible=True,
|
||||||
|
),
|
||||||
|
ActivityName.SATELLITE: Activity(
|
||||||
|
name=ActivityName.SATELLITE,
|
||||||
|
description="Amateur Radio Satellite",
|
||||||
|
sig_type=ActivityType.TRADITIONAL,
|
||||||
|
has_refs=False,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
comment_names=[],
|
||||||
|
icon="fa-satellite",
|
||||||
|
alerts_possible=True,
|
||||||
|
),
|
||||||
|
ActivityName.EME: Activity(
|
||||||
|
name=ActivityName.EME,
|
||||||
|
description="Earth-Moon-Earth (Moonbounce)",
|
||||||
|
sig_type=ActivityType.TRADITIONAL,
|
||||||
|
has_refs=False,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
comment_names=[],
|
||||||
|
icon="fa-moon",
|
||||||
|
),
|
||||||
|
ActivityName.AERONAUTICAL_MOBILE: Activity(
|
||||||
|
name=ActivityName.AERONAUTICAL_MOBILE,
|
||||||
|
description="Aeronautical Mobile",
|
||||||
|
sig_type=ActivityType.TRADITIONAL,
|
||||||
|
has_refs=False,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
# Don't pick /AM out of comments, spot.py will handle picking it out of the callsign
|
||||||
|
comment_names=[],
|
||||||
|
icon="fa-plane",
|
||||||
|
),
|
||||||
|
ActivityName.MARITIME_MOBILE: Activity(
|
||||||
|
name=ActivityName.MARITIME_MOBILE,
|
||||||
|
description="Maritime Mobile",
|
||||||
|
sig_type=ActivityType.TRADITIONAL,
|
||||||
|
has_refs=False,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
# Don't pick /MM out of comments, spot.py will handle picking it out of the callsign
|
||||||
|
comment_names=[],
|
||||||
|
icon="fa-sailboat",
|
||||||
|
),
|
||||||
|
ActivityName.QRP: Activity(
|
||||||
|
name=ActivityName.QRP,
|
||||||
|
description="Low power",
|
||||||
|
sig_type=ActivityType.TRADITIONAL,
|
||||||
|
has_refs=False,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
comment_names=["QRP"],
|
||||||
|
icon="fa-volume-low",
|
||||||
|
alerts_possible=True,
|
||||||
|
),
|
||||||
|
ActivityName.POTA: Activity(
|
||||||
|
name=ActivityName.POTA,
|
||||||
|
description="Parks on the Air",
|
||||||
|
sig_type=ActivityType.ADVENTURE,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.PARK,
|
||||||
|
ref_regex=r"[A-Z]{2}\-\d{4,5}|K\-TEST",
|
||||||
|
comment_names=["POTA"],
|
||||||
|
icon="fa-tree",
|
||||||
|
alerts_possible=True,
|
||||||
|
),
|
||||||
|
ActivityName.SOTA: Activity(
|
||||||
|
name=ActivityName.SOTA,
|
||||||
|
description="Summits on the Air",
|
||||||
|
sig_type=ActivityType.ADVENTURE,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=True,
|
||||||
|
ref_type=ActivityRefType.SUMMIT,
|
||||||
|
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
|
||||||
|
comment_names=["SOTA"],
|
||||||
|
icon="fa-mountain-sun",
|
||||||
|
alerts_possible=True,
|
||||||
|
),
|
||||||
|
ActivityName.WWFF: Activity(
|
||||||
|
name=ActivityName.WWFF,
|
||||||
|
description="World Wide Flora & Fauna",
|
||||||
|
sig_type=ActivityType.ADVENTURE,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=True,
|
||||||
|
ref_type=ActivityRefType.PARK,
|
||||||
|
ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}",
|
||||||
|
comment_names=["WWFF"],
|
||||||
|
icon="fa-seedling",
|
||||||
|
alerts_possible=True,
|
||||||
|
),
|
||||||
|
ActivityName.GMA: Activity(
|
||||||
|
name=ActivityName.GMA,
|
||||||
|
description="Global Mountain Activity",
|
||||||
|
sig_type=ActivityType.ADVENTURE,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.SUMMIT,
|
||||||
|
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}",
|
||||||
|
comment_names=["GMA"],
|
||||||
|
icon="fa-person-hiking",
|
||||||
|
),
|
||||||
|
ActivityName.WWBOTA: Activity(
|
||||||
|
name=ActivityName.WWBOTA,
|
||||||
|
description="Worldwide Bunkers on the Air",
|
||||||
|
sig_type=ActivityType.ADVENTURE,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=True,
|
||||||
|
ref_type=ActivityRefType.BUNKER,
|
||||||
|
ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}",
|
||||||
|
comment_names=["WWBOTA", "BOTA"],
|
||||||
|
icon="fa-radiation",
|
||||||
|
),
|
||||||
|
ActivityName.HEMA: Activity(
|
||||||
|
name=ActivityName.HEMA,
|
||||||
|
description="HuMPs Excluding Marilyns Award",
|
||||||
|
sig_type=ActivityType.ADVENTURE,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.SUMMIT,
|
||||||
|
ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}",
|
||||||
|
comment_names=["HEMA"],
|
||||||
|
icon="fa-mound",
|
||||||
|
alerts_possible=True,
|
||||||
|
),
|
||||||
|
ActivityName.IOTA: Activity(
|
||||||
|
name=ActivityName.IOTA,
|
||||||
|
description="Islands on the Air",
|
||||||
|
sig_type=ActivityType.ADVENTURE,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=True,
|
||||||
|
ref_type=ActivityRefType.ISLAND,
|
||||||
|
ref_regex=r"(?:AF|AN|AS|EU|NA|OC|SA)\-\d{3}",
|
||||||
|
comment_names=["IOTA"],
|
||||||
|
icon="fa-book-atlas",
|
||||||
|
),
|
||||||
|
ActivityName.GMA_ISLANDS: Activity(
|
||||||
|
name=ActivityName.GMA_ISLANDS,
|
||||||
|
description="Global Mountain Activity - Islands",
|
||||||
|
sig_type=ActivityType.ADVENTURE,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.ISLAND,
|
||||||
|
ref_regex=r"(?:(?:[A-Z]{2}\-\d{3})|(?:[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}))",
|
||||||
|
comment_names=[],
|
||||||
|
icon="fa-person-hiking",
|
||||||
|
),
|
||||||
|
ActivityName.ARLHS: Activity(
|
||||||
|
name=ActivityName.ARLHS,
|
||||||
|
description="Amateur Radio Lighthouse Society",
|
||||||
|
sig_type=ActivityType.ADVENTURE,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.LIGHTHOUSE,
|
||||||
|
ref_regex=r"[A-Z]{3}[\- ]\d{3,4}",
|
||||||
|
comment_names=["ARLHS"],
|
||||||
|
icon="fa-house-flood-water",
|
||||||
|
),
|
||||||
|
ActivityName.ILLW: Activity(
|
||||||
|
name=ActivityName.ILLW,
|
||||||
|
description="International Lighthouse & Lightship Weekend",
|
||||||
|
sig_type=ActivityType.EVENT,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.LIGHTHOUSE,
|
||||||
|
ref_regex=r"[A-Z]{2}\d{4}",
|
||||||
|
comment_names=["ILLW"],
|
||||||
|
icon="fa-house-flood-water",
|
||||||
|
),
|
||||||
|
ActivityName.MOTA: Activity(
|
||||||
|
name=ActivityName.MOTA,
|
||||||
|
description="Mills on the Air",
|
||||||
|
sig_type=ActivityType.EVENT,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=True,
|
||||||
|
ref_type=ActivityRefType.MILL,
|
||||||
|
ref_regex=r"X\d{4,6}",
|
||||||
|
comment_names=["MOTA"],
|
||||||
|
icon="fa-fan",
|
||||||
|
),
|
||||||
|
ActivityName.SIOTA: Activity(
|
||||||
|
name=ActivityName.SIOTA,
|
||||||
|
description="Silos on the Air",
|
||||||
|
sig_type=ActivityType.ADVENTURE,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.SILO,
|
||||||
|
ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d",
|
||||||
|
comment_names=["SIOTA"],
|
||||||
|
icon="fa-wheat-awn",
|
||||||
|
alerts_possible=True,
|
||||||
|
),
|
||||||
|
ActivityName.WCA: Activity(
|
||||||
|
name=ActivityName.WCA,
|
||||||
|
description="World Castles Award",
|
||||||
|
sig_type=ActivityType.ADVENTURE,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.CASTLE,
|
||||||
|
ref_regex=r"[A-Z0-9]{1,3}\-\d{5}",
|
||||||
|
comment_names=["WCA"],
|
||||||
|
icon="fa-chess-rook",
|
||||||
|
),
|
||||||
|
ActivityName.ZLOTA: Activity(
|
||||||
|
name=ActivityName.ZLOTA,
|
||||||
|
description="New Zealand on the Air",
|
||||||
|
sig_type=ActivityType.REGIONAL,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=True,
|
||||||
|
ref_type=None,
|
||||||
|
ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}",
|
||||||
|
comment_names=["ZLOTA"],
|
||||||
|
icon="fa-kiwi-bird",
|
||||||
|
region_flag="🇳🇿",
|
||||||
|
alerts_possible=True,
|
||||||
|
),
|
||||||
|
ActivityName.WOTA: Activity(
|
||||||
|
name=ActivityName.WOTA,
|
||||||
|
description="Wainwrights on the Air",
|
||||||
|
sig_type=ActivityType.REGIONAL,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.SUMMIT,
|
||||||
|
ref_regex=r"[A-Z]{3}-[0-9]{2}",
|
||||||
|
comment_names=["WOTA"],
|
||||||
|
icon="fa-w",
|
||||||
|
region_flag="🇬🇧",
|
||||||
|
alerts_possible=True,
|
||||||
|
),
|
||||||
|
ActivityName.BOTA: Activity(
|
||||||
|
name=ActivityName.BOTA,
|
||||||
|
description="Beaches on the Air",
|
||||||
|
sig_type=ActivityType.ADVENTURE,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.BEACH,
|
||||||
|
comment_names=[],
|
||||||
|
icon="fa-umbrella-beach",
|
||||||
|
alerts_possible=True,
|
||||||
|
),
|
||||||
|
ActivityName.KRMNPA: Activity(
|
||||||
|
name=ActivityName.KRMNPA,
|
||||||
|
description="Keith Roget Memorial National Parks Award",
|
||||||
|
sig_type=ActivityType.REGIONAL,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.PARK,
|
||||||
|
ref_regex=r"VKFF\-\d{4}",
|
||||||
|
comment_names=["KRMNPA"],
|
||||||
|
icon="fa-earth-oceania",
|
||||||
|
region_flag="🇦🇺",
|
||||||
|
alerts_possible=True,
|
||||||
|
),
|
||||||
|
ActivityName.SANPCPA: Activity(
|
||||||
|
name=ActivityName.SANPCPA,
|
||||||
|
description="South Australian National Parks and Conservation Parks Award",
|
||||||
|
sig_type=ActivityType.REGIONAL,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.PARK,
|
||||||
|
ref_regex=r"VKFF\-\d{4}",
|
||||||
|
comment_names=["SANPCPA"],
|
||||||
|
icon="fa-earth-oceania",
|
||||||
|
region_flag="🇦🇺",
|
||||||
|
alerts_possible=True,
|
||||||
|
),
|
||||||
|
ActivityName.LLOTA: Activity(
|
||||||
|
name=ActivityName.LLOTA,
|
||||||
|
description="Lagos y Lagunas on the Air",
|
||||||
|
sig_type=ActivityType.ADVENTURE,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=True,
|
||||||
|
ref_type=ActivityRefType.LAKE,
|
||||||
|
ref_regex=r"LL[A-Z]{2}\-\d{4}",
|
||||||
|
comment_names=["LLOTA"],
|
||||||
|
icon="fa-water",
|
||||||
|
alerts_possible=True,
|
||||||
|
),
|
||||||
|
ActivityName.TOWERS: Activity(
|
||||||
|
name=ActivityName.TOWERS,
|
||||||
|
description="Towers on the Air",
|
||||||
|
sig_type=ActivityType.ADVENTURE,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.TOWER,
|
||||||
|
ref_regex=r"[A-Z]{2,3}R\-\d{4}",
|
||||||
|
comment_names=["TOTA"],
|
||||||
|
icon="fa-tower-observation",
|
||||||
|
),
|
||||||
|
ActivityName.TILES: Activity(
|
||||||
|
name=ActivityName.TILES,
|
||||||
|
description="Tiles on the Air",
|
||||||
|
sig_type=ActivityType.ADVENTURE,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.GRID,
|
||||||
|
ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}",
|
||||||
|
comment_names=[],
|
||||||
|
icon="fa-square",
|
||||||
|
),
|
||||||
|
ActivityName.RADAR_RALLY: Activity(
|
||||||
|
name=ActivityName.RADAR_RALLY,
|
||||||
|
description="RaDAR Rally",
|
||||||
|
sig_type=ActivityType.EVENT,
|
||||||
|
has_refs=False,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
comment_names=["RaDAR"],
|
||||||
|
icon="fa-headset",
|
||||||
|
),
|
||||||
|
ActivityName.WAB: Activity(
|
||||||
|
name=ActivityName.WAB,
|
||||||
|
description="Worked All Britain",
|
||||||
|
sig_type=ActivityType.REGIONAL,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.GRID,
|
||||||
|
ref_regex=r"[A-Z]{1,2}[0-9]{2}",
|
||||||
|
comment_names=["WAB"],
|
||||||
|
icon="fa-table-cells-large",
|
||||||
|
region_flag="🇬🇧",
|
||||||
|
),
|
||||||
|
ActivityName.WAI: Activity(
|
||||||
|
name=ActivityName.WAI,
|
||||||
|
description="Worked All Ireland",
|
||||||
|
sig_type=ActivityType.REGIONAL,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.GRID,
|
||||||
|
ref_regex=r"[A-Z][0-9]{2}",
|
||||||
|
comment_names=["WAI"],
|
||||||
|
icon="fa-table-cells-large",
|
||||||
|
region_flag="🇮🇪",
|
||||||
|
),
|
||||||
|
ActivityName.DMF: Activity(
|
||||||
|
name=ActivityName.DMF,
|
||||||
|
description="Diplôme des Moulins de France",
|
||||||
|
sig_type=ActivityType.REGIONAL,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.MILL,
|
||||||
|
comment_names=["DMF"],
|
||||||
|
icon="fa-fan",
|
||||||
|
region_flag="🇫🇷",
|
||||||
|
),
|
||||||
|
ActivityName.DME: Activity(
|
||||||
|
name=ActivityName.DME,
|
||||||
|
description="Diploma Municipios de España",
|
||||||
|
sig_type=ActivityType.REGIONAL,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=True,
|
||||||
|
ref_type=ActivityRefType.TOWN,
|
||||||
|
ref_regex=r"DME[\- ]\d{3,5}",
|
||||||
|
comment_names=["DME"],
|
||||||
|
icon="fa-building",
|
||||||
|
region_flag="🇪🇸",
|
||||||
|
),
|
||||||
|
ActivityName.FEA: Activity(
|
||||||
|
name=ActivityName.FEA,
|
||||||
|
description="Diploma Faros de España",
|
||||||
|
sig_type=ActivityType.REGIONAL,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=True,
|
||||||
|
ref_type=ActivityRefType.LIGHTHOUSE,
|
||||||
|
# FEA references are technically [DE]\-\d{4}(\.\d)? but spotters always seem to miss out the D- or E-
|
||||||
|
# prefix and just use FEA-1234 or FEA 1234, so allow for that. The FEA activity ref data provider adds both
|
||||||
|
# forms to the database.
|
||||||
|
ref_regex=r"(?:[DE]|FEA)[\- ]\d{4}(?:\.\d)?",
|
||||||
|
comment_names=["FEA"],
|
||||||
|
icon="fa-house-flood-water",
|
||||||
|
region_flag="🇪🇸",
|
||||||
|
),
|
||||||
|
ActivityName.DMUE: Activity(
|
||||||
|
name=ActivityName.DMUE,
|
||||||
|
description="Diploma Museos de España",
|
||||||
|
sig_type=ActivityType.REGIONAL,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=True,
|
||||||
|
ref_type=ActivityRefType.BUILDING,
|
||||||
|
ref_regex=r"MUE[A-Z]{2}-\d{3}",
|
||||||
|
comment_names=["DMUE"],
|
||||||
|
icon="fa-landmark",
|
||||||
|
region_flag="🇪🇸",
|
||||||
|
),
|
||||||
|
ActivityName.DMVE: Activity(
|
||||||
|
name=ActivityName.DMVE,
|
||||||
|
description="Diploma Monumentos y Vestigios de España",
|
||||||
|
sig_type=ActivityType.REGIONAL,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=True,
|
||||||
|
ref_type=ActivityRefType.BUILDING,
|
||||||
|
ref_regex=r"MV[A-Z]{1,2}-\d{4}",
|
||||||
|
comment_names=["DMVE"],
|
||||||
|
icon="fa-monument",
|
||||||
|
region_flag="🇪🇸",
|
||||||
|
),
|
||||||
|
ActivityName.DCE: Activity(
|
||||||
|
name=ActivityName.DCE,
|
||||||
|
description="Diploma Castillos de España",
|
||||||
|
sig_type=ActivityType.REGIONAL,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.CASTLE,
|
||||||
|
ref_regex=r"C[A-Z]{1,2}-\d{3}",
|
||||||
|
comment_names=["DCE"],
|
||||||
|
icon="fa-chess-rook",
|
||||||
|
region_flag="🇪🇸",
|
||||||
|
),
|
||||||
|
ActivityName.DEFE: Activity(
|
||||||
|
name=ActivityName.DEFE,
|
||||||
|
description="Diploma Estaciones de Ferrocarril de España",
|
||||||
|
sig_type=ActivityType.REGIONAL,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=True,
|
||||||
|
ref_type=ActivityRefType.BUILDING,
|
||||||
|
ref_regex=r"EF[A-Z]{1,2}-\d{3}",
|
||||||
|
comment_names=["DEFE"],
|
||||||
|
icon="fa-train",
|
||||||
|
region_flag="🇪🇸",
|
||||||
|
),
|
||||||
|
ActivityName.DTMBA: Activity(
|
||||||
|
name=ActivityName.DTMBA,
|
||||||
|
description="Diploma Teatri Musei e Belle Arti",
|
||||||
|
sig_type=ActivityType.REGIONAL,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=True,
|
||||||
|
ref_type=ActivityRefType.BUILDING,
|
||||||
|
ref_regex=r"I-?[0-9]{3,4}\s?[A-Z]{2}",
|
||||||
|
comment_names=["DTMBA"],
|
||||||
|
icon="fa-landmark",
|
||||||
|
region_flag="🇮🇹",
|
||||||
|
),
|
||||||
|
ActivityName.BIWOTA: Activity(
|
||||||
|
name=ActivityName.BIWOTA,
|
||||||
|
description="British Inland Waterways on the Air",
|
||||||
|
sig_type=ActivityType.EVENT,
|
||||||
|
has_refs=False,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.WATERWAY,
|
||||||
|
comment_names=["BIWOTA"],
|
||||||
|
icon="fa-ship",
|
||||||
|
region_flag="🇬🇧",
|
||||||
|
),
|
||||||
|
ActivityName.COTA: Activity(
|
||||||
|
name=ActivityName.COTA,
|
||||||
|
description="Castles on the Air",
|
||||||
|
sig_type=ActivityType.REGIONAL,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.CASTLE,
|
||||||
|
ref_regex=r"[A-Z]{3}\-[0-9]{3,5}",
|
||||||
|
comment_names=["COTA"],
|
||||||
|
icon="fa-chess-rook",
|
||||||
|
region_flag="🇩🇪",
|
||||||
|
),
|
||||||
|
ActivityName.PGA: Activity(
|
||||||
|
name=ActivityName.PGA,
|
||||||
|
description="Polish Gmina Award",
|
||||||
|
sig_type=ActivityType.REGIONAL,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=False,
|
||||||
|
ref_type=ActivityRefType.REGION,
|
||||||
|
ref_regex=r"[A-Z]{2}[0-9]{2}",
|
||||||
|
comment_names=["PGA"],
|
||||||
|
icon="fa-g",
|
||||||
|
region_flag="🇵🇱",
|
||||||
|
),
|
||||||
|
ActivityName.TOILETS: Activity(
|
||||||
|
name=ActivityName.TOILETS,
|
||||||
|
description="Toilets on the Air",
|
||||||
|
sig_type=ActivityType.EVENT,
|
||||||
|
has_refs=True,
|
||||||
|
refs_globally_unique=True,
|
||||||
|
ref_type=ActivityRefType.TOILET,
|
||||||
|
ref_regex=r"T\-[0-9]{2}",
|
||||||
|
comment_names=[],
|
||||||
|
icon="fa-toilet",
|
||||||
|
region_flag="🏴☠️",
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from core.enums import ActivityName, ActivityRefType, ActivityType
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Activity:
|
||||||
|
"""Data class that defines an Activity (formerly referred to as a "Special Interest Group" or "SIG", a term
|
||||||
|
which is still used for the `sig` field name in the API for backwards compatibility). Each contains a name and
|
||||||
|
a longer form description. They also contain comment_names which attempts to separate out the way people might
|
||||||
|
refer to it in cluster comments from how it is referred to in the UI & API. (For example, "TOTA" in cluster
|
||||||
|
spot comments almost always means Towers on the Air, but no single programme is referred to in the UI as "TOTA"
|
||||||
|
as it's ambiguous between Towers, Toilets and Tiles. And while Beaches got the name "BOTA" first, "BOTA" spots
|
||||||
|
are much more likely to be bunkers.) Finally, there is a ref_regex which provides a regular expression to
|
||||||
|
match what references (such as parks and summits) look like for that programme."""
|
||||||
|
|
||||||
|
# Activity name as used in the UI and API, e.g. "Towers"
|
||||||
|
name: ActivityName
|
||||||
|
# Description, e.g. "Towers on the Air"
|
||||||
|
description: str
|
||||||
|
# Type, either Worldwide, Regional or Event. Used for sorting in the web UI.
|
||||||
|
# Note: this field is still named "sig_type" in the API for backwards compatibility.
|
||||||
|
sig_type: ActivityType
|
||||||
|
# Whether this activity has a fixed set of references (e.g. parks) with some sort of ID to "activate"
|
||||||
|
has_refs: bool
|
||||||
|
# Identifies that the activity's reference ID structure defined by its regex is unique across all programmes and
|
||||||
|
# anything else we expect a user to put in a spot comment, and therefore we can pull references out of
|
||||||
|
# spot comments without also needing to see the activity name first. For example, "OHFF-1234" or "B/G-1234" are
|
||||||
|
# obviously WWFF and WWBOTA, nothing else looks like those. But "SZ09" could be WAB or Tiles, "GB1234" could
|
||||||
|
# conceivably be POTA or ILLW, etc. So in those cases we need to see the name of the activity itself immediately
|
||||||
|
# before it.
|
||||||
|
refs_globally_unique: bool
|
||||||
|
# Reference type, what gets activated e.g. Park, Summit. May be None if the activity is for multiple types of
|
||||||
|
# things, in which case the spot data will have to provide this instead.
|
||||||
|
ref_type: ActivityRefType | None = None
|
||||||
|
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+". Only set when has_references is True and we know
|
||||||
|
# the fixed format references take for this activity.
|
||||||
|
ref_regex: str | None = None
|
||||||
|
# Activity names as they might appear in cluster spot comments, e.g. ["TOTA"]
|
||||||
|
comment_names: list[str] = field(default_factory=list)
|
||||||
|
# Icon to use in the UI when referencing this activity. Chosen from the Font Awesome set.
|
||||||
|
icon: str | None = None
|
||||||
|
# Emoji flag for the country or region where this activity is relevant, if any. If None, this implies the
|
||||||
|
# activity is in worldwide usage.
|
||||||
|
region_flag: str | None = None
|
||||||
|
# Can alerts ever have this activity? A lot of activities can be figured out from spot comments but can never
|
||||||
|
# occur in alerts, so to avoid showing all activities on the "upcoming" page, we allow it to filter based on
|
||||||
|
# this.
|
||||||
|
alerts_possible: bool = False
|
||||||
@@ -1,21 +1,25 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from core.enums import ActivityRefType
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SIGRef:
|
class ActivityRef:
|
||||||
"""Data class that defines a Special Interest Group "info" or reference. As well as the basic reference ID we include a
|
"""Data class that defines an Activity "info" or reference. As well as the basic reference ID we include a
|
||||||
name and a lookup URL."""
|
name and a lookup URL."""
|
||||||
|
|
||||||
# Reference ID, e.g. "GB-0001".
|
# Activity that this reference is in, e.g. "POTA". Still named "sig" for backwards compatibility with the API.
|
||||||
id: str
|
|
||||||
# SIG that this reference is in, e.g. "POTA".
|
|
||||||
sig: str
|
sig: str
|
||||||
|
# Reference ID, e.g. "GB-0001".
|
||||||
|
id: str | None = None
|
||||||
# Name of the reference, e.g. "Null Country Park", if known.
|
# Name of the reference, e.g. "Null Country Park", if known.
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
# Type of the reference, e.g. "Park", if known.
|
# Type of the reference, e.g. "Park", if known.
|
||||||
ref_type: str | None = None
|
ref_type: ActivityRefType | None = None
|
||||||
# URL to look up more information about the reference, if known.
|
# URL to look up more information about the reference, if known.
|
||||||
url: str | None = None
|
url: str | None = None
|
||||||
|
# Icon to use for the reference, derived from the activity. Chosen from the Font Awesome set.
|
||||||
|
icon: str | None = None
|
||||||
# Latitude of the reference, in degrees, if known.
|
# Latitude of the reference, in degrees, if known.
|
||||||
latitude: float | None = None
|
latitude: float | None = None
|
||||||
# Longitude of the reference, in degrees, if known.
|
# Longitude of the reference, in degrees, if known.
|
||||||
+50
-22
@@ -1,13 +1,15 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
|
from core.activity_lookup_helper import populate_missing_activity_ref_info
|
||||||
|
from core.activity_utils import get_icon_for_activity
|
||||||
from core.call_lookup_helper import get_call_info
|
from core.call_lookup_helper import get_call_info
|
||||||
from core.sig_lookup_helper import populate_missing_sig_ref_info
|
from core.enums import Continent
|
||||||
from core.utils import get_flag_for_dxcc
|
from core.utils import get_flag_for_dxcc
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -19,6 +21,9 @@ class Alert:
|
|||||||
|
|
||||||
# Unique identifier for the alert
|
# Unique identifier for the alert
|
||||||
id: str | None = None
|
id: str | None = None
|
||||||
|
|
||||||
|
# DX (alerting) operator info
|
||||||
|
|
||||||
# Callsigns of the operators that has been alerted
|
# Callsigns of the operators that has been alerted
|
||||||
dx_calls: list | None = None
|
dx_calls: list | None = None
|
||||||
# Names of the operators that has been alerted
|
# Names of the operators that has been alerted
|
||||||
@@ -28,13 +33,16 @@ class Alert:
|
|||||||
# Country flag of the DX operator
|
# Country flag of the DX operator
|
||||||
dx_flag: str | None = None
|
dx_flag: str | None = None
|
||||||
# Continent of the DX operator
|
# Continent of the DX operator
|
||||||
dx_continent: str | None = None
|
dx_continent: Continent | None = None
|
||||||
# DXCC ID of the DX operator
|
# DXCC ID of the DX operator
|
||||||
dx_dxcc_id: int | None = None
|
dx_dxcc_id: int | None = None
|
||||||
# CQ zone of the DX operator
|
# CQ zone of the DX operator
|
||||||
dx_cq_zone: int | None = None
|
dx_cq_zone: int | None = None
|
||||||
# ITU zone of the DX operator
|
# ITU zone of the DX operator
|
||||||
dx_itu_zone: int | None = None
|
dx_itu_zone: int | None = None
|
||||||
|
|
||||||
|
# General alert info
|
||||||
|
|
||||||
# Intended frequencies & modes of operation. Essentially just a different kind of comment field.
|
# Intended frequencies & modes of operation. Essentially just a different kind of comment field.
|
||||||
freqs_modes: str | None = None
|
freqs_modes: str | None = None
|
||||||
# Start time of the activation, UTC seconds since UNIX epoch
|
# Start time of the activation, UTC seconds since UNIX epoch
|
||||||
@@ -45,25 +53,40 @@ class Alert:
|
|||||||
end_time: float | None = None
|
end_time: float | None = None
|
||||||
# End time of the activation of the alert, ISO 8601
|
# End time of the activation of the alert, ISO 8601
|
||||||
end_time_iso: str | None = None
|
end_time_iso: str | None = None
|
||||||
|
# Comment made by the alerter, if any
|
||||||
|
comment: str | None = None
|
||||||
|
# A URL link to more information, if any
|
||||||
|
url: str | None = None
|
||||||
|
|
||||||
|
# Activity info
|
||||||
|
|
||||||
|
# Activity (e.g. outdoor activity programme such as POTA). Still named "sig" for API backwards compatibility.
|
||||||
|
sig: str | None = None
|
||||||
|
# Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO. Still named
|
||||||
|
# "sig_refs" for API backwards compatibility.
|
||||||
|
sig_refs: list = field(default_factory=list)
|
||||||
|
|
||||||
|
# Timing info
|
||||||
|
|
||||||
# Time that this software received the alert, UTC seconds since UNIX epoch. This is used with the "since_received"
|
# Time that this software received the alert, UTC seconds since UNIX epoch. This is used with the "since_received"
|
||||||
# call to our API to receive all data that is new to us, even if by a quirk of the API it might be older than the
|
# call to our API to receive all data that is new to us, even if by a quirk of the API it might be older than the
|
||||||
# list time the client polled the API.
|
# list time the client polled the API.
|
||||||
received_time: float | None = None
|
received_time: float | None = None
|
||||||
# Time that this software received the alert, ISO 8601
|
# Time that this software received the alert, ISO 8601
|
||||||
received_time_iso: str | None = None
|
received_time_iso: str | None = None
|
||||||
# Comment made by the alerter, if any
|
|
||||||
comment: str | None = None
|
# Source info
|
||||||
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
|
|
||||||
sig: str | None = None
|
|
||||||
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
|
||||||
sig_refs: list | None = None
|
|
||||||
# Whether this alert is for a DXpedition, as opposed to e.g. an xOTA programme.
|
|
||||||
is_dxpedition: bool = False
|
|
||||||
# Where we got the alert from, e.g. "POTA", "SOTA"...
|
# Where we got the alert from, e.g. "POTA", "SOTA"...
|
||||||
source: str | None = None
|
source: str | None = None
|
||||||
# The ID the source gave it, if any.
|
# The ID the source gave it, if any.
|
||||||
source_id: str | None = None
|
source_id: str | None = None
|
||||||
|
|
||||||
|
# Display info
|
||||||
|
|
||||||
|
# Icon to use when displaying this alert in the web UI. Chosen from the Font Awesome set.
|
||||||
|
icon: str | None = None
|
||||||
|
|
||||||
def infer_missing(self, credentials=None):
|
def infer_missing(self, credentials=None):
|
||||||
"""Infer missing parameters where possible"""
|
"""Infer missing parameters where possible"""
|
||||||
|
|
||||||
@@ -91,8 +114,8 @@ class Alert:
|
|||||||
call_info = get_call_info(self.dx_calls[0], credentials)
|
call_info = get_call_info(self.dx_calls[0], credentials)
|
||||||
if self.dx_calls and self.dx_calls[0] and not self.dx_country:
|
if self.dx_calls and self.dx_calls[0] and not self.dx_country:
|
||||||
self.dx_country = call_info.country
|
self.dx_country = call_info.country
|
||||||
if self.dx_calls and self.dx_calls[0] and not self.dx_continent:
|
if self.dx_calls and self.dx_calls[0] and call_info.continent and not self.dx_continent:
|
||||||
self.dx_continent = call_info.continent
|
self.dx_continent = Continent(call_info.continent)
|
||||||
if self.dx_calls and self.dx_calls[0] and not self.dx_cq_zone:
|
if self.dx_calls and self.dx_calls[0] and not self.dx_cq_zone:
|
||||||
self.dx_cq_zone = call_info.cq_zone
|
self.dx_cq_zone = call_info.cq_zone
|
||||||
if self.dx_calls and self.dx_calls[0] and not self.dx_itu_zone:
|
if self.dx_calls and self.dx_calls[0] and not self.dx_itu_zone:
|
||||||
@@ -102,16 +125,16 @@ class Alert:
|
|||||||
if self.dx_dxcc_id and not self.dx_flag:
|
if self.dx_dxcc_id and not self.dx_flag:
|
||||||
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
|
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
|
||||||
|
|
||||||
# Fetch SIG data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a reference
|
# Fetch activity data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a
|
||||||
# in its initial call, we use this code to populate the rest of the data. This includes working out grid refs
|
# reference in its initial call, we use this code to populate the rest of the data. This includes working
|
||||||
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
|
# out grid refs from WAB and WAI, which count as an activity even though there's no real lookup, just maths
|
||||||
if self.sig_refs and len(self.sig_refs) > 0:
|
if self.sig_refs:
|
||||||
for sig_ref in self.sig_refs:
|
for activity_ref in self.sig_refs:
|
||||||
populate_missing_sig_ref_info(sig_ref)
|
populate_missing_activity_ref_info(activity_ref)
|
||||||
|
|
||||||
# If the spot itself doesn't have a SIG yet, but we have at least one SIG reference, take that reference's SIG
|
# If the spot itself doesn't have an activity yet, but we have at least one activity reference, take that
|
||||||
# and apply it to the whole spot.
|
# reference's activity and apply it to the whole spot.
|
||||||
if self.sig_refs and len(self.sig_refs) > 0 and self.sig_refs[0] and not self.sig:
|
if self.sig_refs and self.sig_refs[0] and not self.sig:
|
||||||
self.sig = self.sig_refs[0].sig
|
self.sig = self.sig_refs[0].sig
|
||||||
|
|
||||||
# Create an ID based on the source and source ID if possible, as these guaranee uniqueness. If there is no
|
# Create an ID based on the source and source ID if possible, as these guaranee uniqueness. If there is no
|
||||||
@@ -129,6 +152,11 @@ class Alert:
|
|||||||
if self.dx_calls and not self.dx_names:
|
if self.dx_calls and not self.dx_names:
|
||||||
self.dx_names = [get_call_info(c, credentials).name for c in self.dx_calls]
|
self.dx_names = [get_call_info(c, credentials).name for c in self.dx_calls]
|
||||||
|
|
||||||
|
# Icon for the alert should be the icon of its activity if known, otherwise a radio tower
|
||||||
|
self.icon = "fa-tower-cell"
|
||||||
|
if self.sig and (activity_icon := get_icon_for_activity(self.sig)):
|
||||||
|
self.icon = activity_icon
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Exception while inferring missing data from spot")
|
logger.exception("Exception while inferring missing data from spot")
|
||||||
|
|
||||||
|
|||||||
+7
-5
@@ -1,12 +1,14 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from core.enums import Continent, LocationSourceForCallsign
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Callsign:
|
class Callsign:
|
||||||
"""Data class that defines a callsign and the data associated with it. This will have been retrieved by a callsign
|
"""Data class that defines a callsign and the data associated with it. This will have been retrieved by a callsign
|
||||||
lookup provider using data files or online lookup. This can be used to infer missing data for a spot, though if the
|
lookup provider using data files or online lookup. This can be used to infer missing data for a spot, though if the
|
||||||
spot has a SIG (e.g. POTA) reference this data for their home location (or even just their country) will be less
|
spot has an activity (e.g. POTA) reference this data for their home location (or even just their country) will be
|
||||||
accurate and should not be used in preference to that."""
|
less accurate and should not be used in preference to that."""
|
||||||
|
|
||||||
# Callsign as spotted
|
# Callsign as spotted
|
||||||
call: str
|
call: str
|
||||||
@@ -28,15 +30,15 @@ class Callsign:
|
|||||||
# Country in which the callsign indicates they are operating
|
# Country in which the callsign indicates they are operating
|
||||||
country: str | None = None
|
country: str | None = None
|
||||||
# Continent in which the callsign indicates they are operating
|
# Continent in which the callsign indicates they are operating
|
||||||
continent: str | None = None
|
continent: Continent | None = None
|
||||||
# DXCC ID in which the callsign indicates they are operating
|
# DXCC ID in which the callsign indicates they are operating
|
||||||
dxcc_id: int | None = None
|
dxcc_id: int | None = None
|
||||||
# CQ zone in which the callsign indicates they are operating
|
# CQ zone in which the callsign indicates they are operating
|
||||||
cq_zone: int | None = None
|
cq_zone: int | None = None
|
||||||
# ITU zone in which the callsign indicates they are operating
|
# ITU zone in which the callsign indicates they are operating
|
||||||
itu_zone: int | None = None
|
itu_zone: int | None = None
|
||||||
# Location source. This can be "HOME QTH" or "DXCC" depending on which provider gave us a location
|
# Location source
|
||||||
location_source: str | None = None
|
location_source: LocationSourceForCallsign | None = None
|
||||||
|
|
||||||
def fully_populated(self):
|
def fully_populated(self):
|
||||||
"""Utility method to indicate that the callsign data is fully populated. Multiple providers can return data for
|
"""Utility method to indicate that the callsign data is fully populated. Multiple providers can return data for
|
||||||
|
|||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SIG:
|
|
||||||
"""Data class that defines a Special Interest Group. Each contains a name and a longer form description.
|
|
||||||
They also contain comment_names which attempts to separate out the way people might refer to it in
|
|
||||||
cluster comments from how it is referred to in the UI & API. (For example, "TOTA" in cluster spot comments
|
|
||||||
almost always means Towers on the Air, but no single programme is referred to in the UI as "TOTA" as
|
|
||||||
it's ambiguous between Towers, Toilets and Tiles. And while Beaches got the name "BOTA" first, "BOTA" spots
|
|
||||||
are much more likely to be bunkers.) Finally, there is a ref_regex which provides a regular expression to
|
|
||||||
match what references (such as parks and summits) look like for that programme."""
|
|
||||||
|
|
||||||
# SIG name as used in the UI and API, e.g. "Towers"
|
|
||||||
name: str
|
|
||||||
# Description, e.g. "Towers on the Air"
|
|
||||||
description: str
|
|
||||||
# SIG names as they might appear in cluster spot comments, e.g. ["TOTA"]
|
|
||||||
comment_names: list[str] = field(default_factory=list)
|
|
||||||
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
|
|
||||||
ref_regex: str | None = None
|
|
||||||
+199
-112
@@ -2,23 +2,27 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from math import isnan
|
from math import isnan
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
|
from pyhamtools.locator import latlong_to_locator, locator_to_latlong
|
||||||
|
|
||||||
|
from core.activity_lookup_helper import populate_missing_activity_ref_info
|
||||||
|
from core.activity_utils import (
|
||||||
|
ANY_ACTIVITY_REGEX,
|
||||||
|
get_activity_by_name,
|
||||||
|
get_activity_name_from_comment_name,
|
||||||
|
get_icon_for_activity,
|
||||||
|
get_ref_regex_for_activity,
|
||||||
|
)
|
||||||
from core.call_lookup_helper import get_call_info
|
from core.call_lookup_helper import get_call_info
|
||||||
from core.config import MAX_SPOT_AGE
|
from core.config import MAX_SPOT_AGE
|
||||||
from core.constants import MODE_ALIASES, PROPAGATION_MODES
|
from core.constants import PROPAGATION_MODES
|
||||||
|
from core.data_store import DATA_STORE
|
||||||
|
from core.enums import ActivityName, Continent, LocationSourceForSpot, Mode, ModeSource, ModeType
|
||||||
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
|
||||||
from core.sig_lookup_helper import populate_missing_sig_ref_info
|
|
||||||
from core.sig_utils import (
|
|
||||||
ANY_SIG_REGEX,
|
|
||||||
get_ref_regex_for_sig,
|
|
||||||
get_sig_name_from_comment_name,
|
|
||||||
)
|
|
||||||
from core.utils import (
|
from core.utils import (
|
||||||
get_flag_for_dxcc,
|
get_flag_for_dxcc,
|
||||||
infer_band_from_freq,
|
infer_band_from_freq,
|
||||||
@@ -26,7 +30,8 @@ from core.utils import (
|
|||||||
infer_mode_from_frequency,
|
infer_mode_from_frequency,
|
||||||
infer_mode_type_from_mode,
|
infer_mode_type_from_mode,
|
||||||
)
|
)
|
||||||
from data.sig_ref import SIGRef
|
from data.activities import ACTIVITIES
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -44,15 +49,15 @@ class Spot:
|
|||||||
dx_call: str | None = None
|
dx_call: str | None = None
|
||||||
# Name of the operator that has been spotted
|
# Name of the operator that has been spotted
|
||||||
dx_name: str | None = None
|
dx_name: str | None = None
|
||||||
# QTH of the operator that has been spotted. This could be from any SIG refs or could be from online lookup of their
|
# QTH of the operator that has been spotted. This could be from any activity refs or could be from online lookup of
|
||||||
# home QTH.
|
# their home QTH.
|
||||||
dx_qth: str | None = None
|
dx_qth: str | None = None
|
||||||
# Country of the DX operator
|
# Country of the DX operator
|
||||||
dx_country: str | None = None
|
dx_country: str | None = None
|
||||||
# Country flag of the DX operator
|
# Country flag of the DX operator
|
||||||
dx_flag: str | None = None
|
dx_flag: str | None = None
|
||||||
# Continent of the DX operator
|
# Continent of the DX operator
|
||||||
dx_continent: str | None = None
|
dx_continent: Continent | None = None
|
||||||
# DXCC ID of the DX operator
|
# DXCC ID of the DX operator
|
||||||
dx_dxcc_id: int | None = None
|
dx_dxcc_id: int | None = None
|
||||||
# CQ zone of the DX operator
|
# CQ zone of the DX operator
|
||||||
@@ -68,12 +73,11 @@ class Spot:
|
|||||||
# lookup
|
# lookup
|
||||||
dx_latitude: float | None = None
|
dx_latitude: float | None = None
|
||||||
dx_longitude: float | None = None
|
dx_longitude: float | None = None
|
||||||
# DX Location source. Indicates how accurate the location might be. Values: "SPOT", "GRID", "SIG REF LOOKUP",
|
# DX Location source. Indicates how accurate the location might be.
|
||||||
# "HOME QTH", "DXCC", "NONE"
|
dx_location_source: LocationSourceForSpot | None = 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", "SIG REF LOOKUP" or "GRID", or if the location source is "HOME QTH" and the
|
# true if the location source is "SPOT", "SIG REF LOOKUP" or "GRID", or if the location source is "HOME QTH" and
|
||||||
# DX callsign doesn't have a suffix like /P.
|
# the DX callsign doesn't have a suffix like /P. (Location source retains "SIG" wording for API compatibility.)
|
||||||
dx_location_good: bool = False
|
dx_location_good: bool = False
|
||||||
|
|
||||||
# DE (Spotter) info
|
# DE (Spotter) info
|
||||||
@@ -85,7 +89,7 @@ class Spot:
|
|||||||
# Country flag of the spotter
|
# Country flag of the spotter
|
||||||
de_flag: str | None = None
|
de_flag: str | None = None
|
||||||
# Continent of the spotter
|
# Continent of the spotter
|
||||||
de_continent: str | None = None
|
de_continent: Continent | None = None
|
||||||
# DXCC ID of the spotter
|
# DXCC ID of the spotter
|
||||||
de_dxcc_id: int | None = None
|
de_dxcc_id: int | None = None
|
||||||
# If this is an APRS/Packet/etc spot, what SSID was the spotter/receiver using?
|
# If this is an APRS/Packet/etc spot, what SSID was the spotter/receiver using?
|
||||||
@@ -103,11 +107,11 @@ class Spot:
|
|||||||
# General QSO info
|
# General QSO info
|
||||||
|
|
||||||
# Reported mode, such as SSB, PHONE, CW, FT8...
|
# Reported mode, such as SSB, PHONE, CW, FT8...
|
||||||
mode: str | None = None
|
mode: Mode | None = None
|
||||||
# Inferred mode "family". One of "CW", "PHONE" or "DIGI".
|
# Inferred mode "family".
|
||||||
mode_type: str | None = None
|
mode_type: ModeType | None = None
|
||||||
# Source of the mode information. "SPOT", "COMMENT", "BANDPLAN" or "NONE"
|
# Source of the mode information.
|
||||||
mode_source: str = "NONE"
|
mode_source: ModeSource | None = None
|
||||||
# Frequency, in Hz
|
# Frequency, in Hz
|
||||||
freq: float | None = None
|
freq: float | None = None
|
||||||
# Band, defined by the frequency, e.g. "40m" or "70cm"
|
# Band, defined by the frequency, e.g. "40m" or "70cm"
|
||||||
@@ -119,12 +123,13 @@ class Spot:
|
|||||||
# QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
|
# QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
|
||||||
qrt: bool = False
|
qrt: bool = False
|
||||||
|
|
||||||
# Special Interest Group info
|
# Activity info
|
||||||
|
|
||||||
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
|
# Activity (e.g. outdoor activity programme such as POTA). Still named "sig" for API backwards compatibility.
|
||||||
sig: str | None = None
|
sig: str | None = None
|
||||||
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
# Activity references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO. Still named
|
||||||
sig_refs: list | None = None
|
# "sig_refs" for API backwards compatibility.
|
||||||
|
sig_refs: list = field(default_factory=list)
|
||||||
|
|
||||||
# Timing info
|
# Timing info
|
||||||
|
|
||||||
@@ -146,13 +151,21 @@ class Spot:
|
|||||||
# The ID the source gave it, if any.
|
# The ID the source gave it, if any.
|
||||||
source_id: str | None = None
|
source_id: str | None = None
|
||||||
|
|
||||||
|
# Display info
|
||||||
|
|
||||||
|
# Icon to use when displaying this spot in the web UI. Chosen from the Font Awesome set.
|
||||||
|
icon: str | None = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
"""Normalise fields that don't survive a plain dict to Spot conversion. This is used in the "add spot" API
|
"""Normalise fields that don't survive a plain dict to Spot conversion. This is used in the "add spot" API
|
||||||
endpoint where the client is submitting JSON, and we want to recreate a full Spot object, including nested
|
endpoint where the client is submitting JSON, and we want to recreate a full Spot object, including nested
|
||||||
objects such as the sig_refs list.."""
|
objects such as the sig_refs list.."""
|
||||||
|
|
||||||
if self.sig_refs:
|
if self.sig_refs:
|
||||||
self.sig_refs = [sig_ref if isinstance(sig_ref, SIGRef) else SIGRef(**sig_ref) for sig_ref in self.sig_refs]
|
self.sig_refs = [
|
||||||
|
activity_ref if isinstance(activity_ref, ActivityRef) else ActivityRef(**activity_ref)
|
||||||
|
for activity_ref in self.sig_refs
|
||||||
|
]
|
||||||
|
|
||||||
def infer_missing(self, credentials=None):
|
def infer_missing(self, credentials=None):
|
||||||
"""Infer missing parameters where possible"""
|
"""Infer missing parameters where possible"""
|
||||||
@@ -184,8 +197,8 @@ class Spot:
|
|||||||
dx_call_info = get_call_info(self.dx_call, credentials)
|
dx_call_info = get_call_info(self.dx_call, credentials)
|
||||||
if self.dx_call and not self.dx_country:
|
if self.dx_call and not self.dx_country:
|
||||||
self.dx_country = dx_call_info.country
|
self.dx_country = dx_call_info.country
|
||||||
if self.dx_call and not self.dx_continent:
|
if self.dx_call and dx_call_info.continent and not self.dx_continent:
|
||||||
self.dx_continent = dx_call_info.continent
|
self.dx_continent = Continent(dx_call_info.continent)
|
||||||
if self.dx_call and not self.dx_dxcc_id:
|
if self.dx_call and not self.dx_dxcc_id:
|
||||||
self.dx_dxcc_id = dx_call_info.dxcc_id
|
self.dx_dxcc_id = dx_call_info.dxcc_id
|
||||||
if self.dx_dxcc_id and not self.dx_flag:
|
if self.dx_dxcc_id and not self.dx_flag:
|
||||||
@@ -222,8 +235,8 @@ class Spot:
|
|||||||
):
|
):
|
||||||
if not self.de_country:
|
if not self.de_country:
|
||||||
self.de_country = de_call_info.country
|
self.de_country = de_call_info.country
|
||||||
if not self.de_continent:
|
if de_call_info.continent and not self.de_continent:
|
||||||
self.de_continent = de_call_info.continent
|
self.de_continent = Continent(de_call_info.continent)
|
||||||
if not self.de_dxcc_id:
|
if not self.de_dxcc_id:
|
||||||
self.de_dxcc_id = de_call_info.dxcc_id
|
self.de_dxcc_id = de_call_info.dxcc_id
|
||||||
if self.de_dxcc_id and not self.de_flag:
|
if self.de_dxcc_id and not self.de_flag:
|
||||||
@@ -240,17 +253,13 @@ class Spot:
|
|||||||
|
|
||||||
# Mode from comments or bandplan
|
# Mode from comments or bandplan
|
||||||
if self.mode:
|
if self.mode:
|
||||||
self.mode_source = "SPOT"
|
self.mode_source = ModeSource.SPOT
|
||||||
if self.comment and not self.mode:
|
if self.comment and not self.mode:
|
||||||
self.mode = infer_mode_from_comment(self.comment)
|
self.mode = infer_mode_from_comment(self.comment)
|
||||||
self.mode_source = "COMMENT"
|
self.mode_source = ModeSource.COMMENT
|
||||||
if self.freq and not self.mode:
|
if self.freq and not self.mode:
|
||||||
self.mode = infer_mode_from_frequency(self.freq)
|
self.mode = infer_mode_from_frequency(self.freq)
|
||||||
self.mode_source = "BANDPLAN"
|
self.mode_source = ModeSource.BANDPLAN
|
||||||
|
|
||||||
# Normalise mode if necessary.
|
|
||||||
if self.mode in MODE_ALIASES:
|
|
||||||
self.mode = MODE_ALIASES[self.mode]
|
|
||||||
|
|
||||||
# Mode type from mode
|
# Mode type from mode
|
||||||
if self.mode and not self.mode_type:
|
if self.mode and not self.mode_type:
|
||||||
@@ -258,86 +267,112 @@ class Spot:
|
|||||||
|
|
||||||
# If we have a latitude or grid at this point, it can only have been provided by the spot itself
|
# If we have a latitude or grid at this point, it can only have been provided by the spot itself
|
||||||
if self.dx_latitude or self.dx_grid:
|
if self.dx_latitude or self.dx_grid:
|
||||||
self.dx_location_source = "SPOT"
|
self.dx_location_source = LocationSourceForSpot.SPOT
|
||||||
|
|
||||||
# Set the top-level "SIG" if it is missing but we have at least one SIG ref.
|
# Set the top-level activity if it is missing but we have at least one activity ref.
|
||||||
if not self.sig and self.sig_refs and len(self.sig_refs) > 0:
|
if not self.sig and self.sig_refs:
|
||||||
self.sig = self.sig_refs[0].sig.upper()
|
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
|
# See if we already have an activity reference, but the comment looks like it contains more for the same
|
||||||
# should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002".
|
# activity. 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:
|
if self.comment and self.sig_refs and self.sig_refs[0].sig:
|
||||||
sig = self.sig_refs[0].sig.upper()
|
activity = self.sig_refs[0].sig.upper()
|
||||||
regex = get_ref_regex_for_sig(sig)
|
regex = get_ref_regex_for_activity(activity)
|
||||||
if regex:
|
if regex:
|
||||||
all_comment_ref_matches = re.finditer(r"(^|\W)(" + regex + r")(^|\W)", self.comment, re.IGNORECASE)
|
all_comment_ref_matches = re.finditer(r"(^|\W)(" + regex + r")($|\W)", self.comment, re.IGNORECASE)
|
||||||
for ref_match in all_comment_ref_matches:
|
for ref_match in all_comment_ref_matches:
|
||||||
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig))
|
self._append_activity_ref_if_missing(ActivityRef(id=ref_match.group(2).upper(), sig=activity))
|
||||||
|
|
||||||
# See if the comment looks like it contains any SIGs (and optionally SIG references) that we can
|
# See if the comment looks like it contains any activities (and optionally activity references) that we
|
||||||
# add to the spot. This should catch cluster spot comments like "POTA GB-0001 WWFF GFF-0001" and e.g. POTA
|
# can add to the spot. This should catch cluster spot comments like "POTA GB-0001 WWFF GFF-0001" and e.g.
|
||||||
# comments like "also WWFF GFF-0001".
|
# POTA comments like "also WWFF GFF-0001".
|
||||||
if self.comment:
|
if self.comment:
|
||||||
sig_matches = re.finditer(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
|
activity_matches = re.finditer(r"(^|\W)" + ANY_ACTIVITY_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
|
||||||
for sig_match in sig_matches:
|
for activity_match in activity_matches:
|
||||||
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster
|
# First of all, if we haven't got an activity for this spot set yet, now we have. This covers
|
||||||
# spots where the comment is just "POTA".
|
# things like cluster spots where the comment is just "POTA".
|
||||||
found_sig = get_sig_name_from_comment_name(sig_match.group(2))
|
found_activity = get_activity_name_from_comment_name(activity_match.group(2))
|
||||||
if not self.sig:
|
if not self.sig:
|
||||||
self.sig = found_sig
|
self.sig = found_activity
|
||||||
|
|
||||||
# Now look to see if that SIG name was followed by something that looks like a reference ID for that SIG.
|
# Now look to see if that activity name was followed by something that looks like a reference ID
|
||||||
# If so, add that to the sig_refs list for this spot.
|
# for that activity. If so, add that to the sig_refs list for this spot.
|
||||||
ref_regex = get_ref_regex_for_sig(found_sig)
|
found_activity_info = get_activity_by_name(found_activity)
|
||||||
if ref_regex:
|
if found_activity_info and found_activity_info.has_refs and found_activity_info.ref_regex:
|
||||||
ref_matches = re.finditer(
|
ref_matches = re.finditer(
|
||||||
r"(^|\W)" + found_sig + r"([ -])(" + ref_regex + r")($|\W)",
|
r"(^|\W)" + found_activity + r"([ -])(" + found_activity_info.ref_regex + r")($|\W)",
|
||||||
self.comment,
|
self.comment,
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
for ref_match in ref_matches:
|
for ref_match in ref_matches:
|
||||||
self._append_sig_ref_if_missing(SIGRef(id=ref_match.group(3).upper(), sig=found_sig))
|
self._append_activity_ref_if_missing(
|
||||||
|
ActivityRef(id=ref_match.group(3).upper(), sig=found_activity)
|
||||||
|
)
|
||||||
|
|
||||||
# Fetch SIG data. In case a particular API doesn't provide a full set of name, lat, lon & grid for a reference
|
# See if the comment looks like it contains any activity references *without* the corresponding activity
|
||||||
# in its initial call, we use this code to populate the rest of the data. This includes working out grid refs
|
# name, but where the activity reference is unique-looking enough that we can't confuse it with any other
|
||||||
# from WAB and WAI, which count as a SIG even though there's no real lookup, just maths
|
# activity.
|
||||||
if self.sig_refs and len(self.sig_refs) > 0:
|
if self.comment:
|
||||||
for sig_ref in self.sig_refs:
|
for activity in ACTIVITIES.values():
|
||||||
sig_ref = populate_missing_sig_ref_info(sig_ref)
|
if activity.has_refs and activity.refs_globally_unique and activity.ref_regex:
|
||||||
# If the spot itself doesn't have location yet, but the SIG ref does, extract it
|
ref_matches = re.finditer(
|
||||||
if sig_ref.grid and not self.dx_grid:
|
r"(^|\W)(" + activity.ref_regex + r")($|\W)", self.comment, re.IGNORECASE
|
||||||
self.dx_grid = sig_ref.grid
|
)
|
||||||
if sig_ref.latitude and not self.dx_latitude and sig_ref.longitude and not self.dx_longitude:
|
for ref_match in ref_matches:
|
||||||
self.dx_latitude = sig_ref.latitude
|
# First of all, if we haven't got an activity for this spot set yet, now we have. This
|
||||||
self.dx_longitude = sig_ref.longitude
|
# covers things like cluster spots where the comment is just "OHFF-1234", now we know
|
||||||
if self.sig == "WAB" or self.sig == "WAI" or self.sig == "Tiles":
|
# it's WWFF.
|
||||||
self.dx_location_source = "GRID"
|
if not self.sig:
|
||||||
|
self.sig = activity.name
|
||||||
|
self._append_activity_ref_if_missing(
|
||||||
|
ActivityRef(id=ref_match.group(2).upper(), sig=activity.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch activity 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 an activity even though there's no real lookup, just maths
|
||||||
|
if self.sig_refs:
|
||||||
|
for activity_ref in self.sig_refs:
|
||||||
|
activity_ref = populate_missing_activity_ref_info(activity_ref)
|
||||||
|
# If the spot itself doesn't have location yet, but the activity ref does, extract it
|
||||||
|
if activity_ref.grid and not self.dx_grid:
|
||||||
|
self.dx_grid = activity_ref.grid
|
||||||
|
if (
|
||||||
|
activity_ref.latitude
|
||||||
|
and not self.dx_latitude
|
||||||
|
and activity_ref.longitude
|
||||||
|
and not self.dx_longitude
|
||||||
|
):
|
||||||
|
self.dx_latitude = activity_ref.latitude
|
||||||
|
self.dx_longitude = activity_ref.longitude
|
||||||
|
if self.sig in (ActivityName.WAB, ActivityName.WAI, ActivityName.TILES):
|
||||||
|
self.dx_location_source = LocationSourceForSpot.GRID
|
||||||
else:
|
else:
|
||||||
self.dx_location_source = "SIG REF LOOKUP"
|
self.dx_location_source = LocationSourceForSpot.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
|
# If the spot itself doesn't have an activity yet, but we have at least one activity reference, take that
|
||||||
# and apply it to the whole spot.
|
# reference's activity and apply it to the whole spot.
|
||||||
if self.sig_refs and len(self.sig_refs) > 0 and not self.sig:
|
if self.sig_refs and not self.sig:
|
||||||
self.sig = self.sig_refs[0].sig
|
self.sig = self.sig_refs[0].sig
|
||||||
|
|
||||||
# Parse "de_grid<prop_mode>dx_grid" structures from the comment, e.g. "JN61ES(ES)JM56XT" or "JO02GQ<>KN17LG".
|
# Parse "de_grid<prop_mode>dx_grid" structures from the comment, e.g. "JN61ES(ES)JM56XT" or "JO02GQ<>KN17LG".
|
||||||
# These are common on cluster spots and can provide grid references in preference to e.g. QRZ lookup, as well as
|
# These are common on cluster spots and can provide grid references in preference to e.g. QRZ lookup, as well as
|
||||||
# being the only source we have for propagation mode. Brace for nightmare regex from hell.
|
# being the only source we have for propagation mode. Brace for nightmare regex from hell.
|
||||||
if self.comment:
|
if self.comment:
|
||||||
grid_mode_grid_match = re.search(
|
grid_match = re.search(
|
||||||
r"\b([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)(?:<([^>]*)>|\(([^)]*)\))([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\b",
|
r"\b([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)(?:<([^>]*)>|\(([^)]*)\))([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\b",
|
||||||
self.comment,
|
self.comment,
|
||||||
)
|
)
|
||||||
if grid_mode_grid_match:
|
if grid_match:
|
||||||
# regex matches, so extract grids:
|
# regex matches, so extract grids:
|
||||||
if not self.de_grid:
|
if not self.de_grid:
|
||||||
self.de_grid = grid_mode_grid_match.group(1).upper()
|
self.de_grid = grid_match.group(1).upper()
|
||||||
if not self.dx_grid:
|
if not self.dx_grid:
|
||||||
self.dx_grid = grid_mode_grid_match.group(4).upper()
|
self.dx_grid = grid_match.group(4).upper()
|
||||||
self.dx_location_source = "GRID"
|
self.dx_location_source = LocationSourceForSpot.GRID
|
||||||
|
|
||||||
# And extract propagation mode (group 2 for <...>, group 3 for (...)):
|
# And extract propagation mode (group 2 for <...>, group 3 for (...)):
|
||||||
mode_tag = (grid_mode_grid_match.group(2) or grid_mode_grid_match.group(3) or "").upper()
|
mode_tag = (grid_match.group(2) or grid_match.group(3) or "").upper()
|
||||||
if mode_tag and not self.propagation_mode:
|
if mode_tag and not self.propagation_mode:
|
||||||
if mode_tag in PROPAGATION_MODES:
|
if mode_tag in PROPAGATION_MODES:
|
||||||
self.propagation_mode = PROPAGATION_MODES[mode_tag]
|
self.propagation_mode = PROPAGATION_MODES[mode_tag]
|
||||||
@@ -345,19 +380,59 @@ class Spot:
|
|||||||
self.propagation_mode = mode_tag
|
self.propagation_mode = mode_tag
|
||||||
logger.info(f"Seen a new propagation mode tag not yet in the system: {mode_tag}")
|
logger.info(f"Seen a new propagation mode tag not yet in the system: {mode_tag}")
|
||||||
|
|
||||||
|
# Set activities based on propagation mode
|
||||||
|
if self.propagation_mode == "Satellite" and not self.sig:
|
||||||
|
self.sig = ActivityName.SATELLITE
|
||||||
|
if self.propagation_mode == "Earth-Moon-Earth" and not self.sig:
|
||||||
|
self.sig = ActivityName.EME
|
||||||
|
|
||||||
|
# Parse 6-digit grid structures from the comment if the frequency is VHF or above, as it's common to see
|
||||||
|
# VHF+ operators spotted with their grid
|
||||||
|
if self.comment and self.freq and self.freq > 30000000:
|
||||||
|
grid_match = re.search(
|
||||||
|
r"\b([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\b",
|
||||||
|
self.comment,
|
||||||
|
)
|
||||||
|
if grid_match and not self.dx_grid:
|
||||||
|
self.dx_grid = grid_match.group(1).upper()
|
||||||
|
self.dx_location_source = LocationSourceForSpot.GRID
|
||||||
|
|
||||||
|
# Set activities based on the DX callsign suffix
|
||||||
|
if self.dx_call and not self.sig:
|
||||||
|
if self.dx_call.upper().endswith(ActivityName.AERONAUTICAL_MOBILE):
|
||||||
|
self.sig = ActivityName.AERONAUTICAL_MOBILE
|
||||||
|
elif self.dx_call.upper().endswith(ActivityName.MARITIME_MOBILE):
|
||||||
|
self.sig = ActivityName.MARITIME_MOBILE
|
||||||
|
|
||||||
|
# Alright, now let's get really fancy. Check if the DX callsign matches one taking part in a currently
|
||||||
|
# running DXpedition which we know from the alerts list.
|
||||||
|
if self.dx_call and not self.sig:
|
||||||
|
now = datetime.now(pytz.UTC).timestamp()
|
||||||
|
for alert in DATA_STORE.alerts.values():
|
||||||
|
if (
|
||||||
|
alert.sig == ActivityName.DXPEDITION
|
||||||
|
and alert.dx_calls
|
||||||
|
and alert.start_time
|
||||||
|
and alert.end_time
|
||||||
|
and alert.start_time < now < alert.end_time
|
||||||
|
and self.dx_call.upper() in [c.upper() for c in alert.dx_calls if c]
|
||||||
|
):
|
||||||
|
self.sig = ActivityName.DXPEDITION
|
||||||
|
break
|
||||||
|
|
||||||
# Parse "de_grid -> dx_grid" structures from the comment
|
# Parse "de_grid -> dx_grid" structures from the comment
|
||||||
if self.comment:
|
if self.comment:
|
||||||
grid_mode_grid_match = re.search(
|
grid_match = re.search(
|
||||||
r"\b([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\s*->\s*([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\b",
|
r"\b([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\s*->\s*([A-Ra-r]{2}\d{2}(?:[A-Xa-x]{2}(?:\d{2})?)?)\b",
|
||||||
self.comment,
|
self.comment,
|
||||||
)
|
)
|
||||||
if grid_mode_grid_match:
|
if grid_match:
|
||||||
# regex matches, so extract grids:
|
# regex matches, so extract grids:
|
||||||
if not self.dx_grid:
|
if not self.dx_grid:
|
||||||
self.dx_grid = grid_mode_grid_match.group(1).upper()
|
self.dx_grid = grid_match.group(1).upper()
|
||||||
self.dx_location_source = "GRID"
|
self.dx_location_source = LocationSourceForSpot.GRID
|
||||||
if not self.de_grid:
|
if not self.de_grid:
|
||||||
self.de_grid = grid_mode_grid_match.group(2).upper()
|
self.de_grid = grid_match.group(2).upper()
|
||||||
|
|
||||||
# DX Grid to lat/lon and vice versa in case one is missing
|
# DX Grid to lat/lon and vice versa in case one is missing
|
||||||
if self.dx_grid and (not self.dx_latitude or not self.dx_longitude):
|
if self.dx_grid and (not self.dx_latitude or not self.dx_longitude):
|
||||||
@@ -366,12 +441,12 @@ class Spot:
|
|||||||
self.dx_latitude = ll[0]
|
self.dx_latitude = ll[0]
|
||||||
self.dx_longitude = ll[1]
|
self.dx_longitude = ll[1]
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Invalid grid received for spot")
|
logger.debug("Invalid grid received for spot", exc_info=True)
|
||||||
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 Exception:
|
except Exception:
|
||||||
logger.debug("Invalid lat/lon received for spot")
|
logger.debug("Invalid lat/lon received for spot", exc_info=True)
|
||||||
|
|
||||||
# QRT comment detection
|
# QRT comment detection
|
||||||
if self.comment and not self.qrt:
|
if self.comment and not self.qrt:
|
||||||
@@ -399,9 +474,9 @@ class Spot:
|
|||||||
self.dx_grid = dx_call_info.grid
|
self.dx_grid = dx_call_info.grid
|
||||||
self.dx_location_source = dx_call_info.location_source
|
self.dx_location_source = dx_call_info.location_source
|
||||||
|
|
||||||
# Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable string,
|
# Determine a "QTH" string. If we have an activity ref, pick the first one and turn it into a suitable
|
||||||
# otherwise see what they have set on an online lookup service.
|
# string, otherwise see what they have set on an online lookup service.
|
||||||
if self.sig_refs and len(self.sig_refs) > 0:
|
if self.sig_refs:
|
||||||
qth = self.sig_refs[0].id
|
qth = self.sig_refs[0].id
|
||||||
if self.sig_refs[0].name:
|
if self.sig_refs[0].name:
|
||||||
qth += f" {self.sig_refs[0].name}"
|
qth += f" {self.sig_refs[0].name}"
|
||||||
@@ -421,16 +496,25 @@ class Spot:
|
|||||||
elif self.dx_call:
|
elif self.dx_call:
|
||||||
self.dx_itu_zone = dx_call_info.itu_zone
|
self.dx_itu_zone = dx_call_info.itu_zone
|
||||||
|
|
||||||
|
# DXCC lookup from callsign if nothing else has provided it
|
||||||
|
if self.dx_call and not self.dx_dxcc_id:
|
||||||
|
for regex, entity_code in DATA_STORE.dxcc_lookup_by_call_regex:
|
||||||
|
if regex.pattern and regex.match(self.dx_call):
|
||||||
|
self.dx_dxcc_id = entity_code
|
||||||
|
break
|
||||||
|
if self.dx_dxcc_id and not self.dx_flag:
|
||||||
|
self.dx_flag = get_flag_for_dxcc(self.dx_dxcc_id)
|
||||||
|
|
||||||
# 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 = bool(
|
self.dx_location_good = bool(
|
||||||
self.dx_latitude
|
self.dx_latitude
|
||||||
and self.dx_longitude
|
and self.dx_longitude
|
||||||
and (
|
and (
|
||||||
self.dx_location_source == "SPOT"
|
self.dx_location_source == LocationSourceForSpot.SPOT
|
||||||
or self.dx_location_source == "SIG REF LOOKUP"
|
or self.dx_location_source == LocationSourceForSpot.SIG_REF_LOOKUP
|
||||||
or self.dx_location_source == "GRID"
|
or self.dx_location_source == LocationSourceForSpot.GRID
|
||||||
or (self.dx_location_source == "HOME QTH" and "/" not in (self.dx_call or ""))
|
or (self.dx_location_source == LocationSourceForSpot.HOME_QTH and "/" not in (self.dx_call or ""))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -446,6 +530,11 @@ class Spot:
|
|||||||
self.de_longitude = de_call_info.longitude
|
self.de_longitude = de_call_info.longitude
|
||||||
self.de_grid = de_call_info.grid
|
self.de_grid = de_call_info.grid
|
||||||
|
|
||||||
|
# Icon for the spot should be the icon of its activity if known, otherwise a radio tower
|
||||||
|
self.icon = "fa-tower-cell"
|
||||||
|
if self.sig and (activity_icon := get_icon_for_activity(self.sig)):
|
||||||
|
self.icon = activity_icon
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Exception while inferring missing data from spot")
|
logger.exception("Exception while inferring missing data from spot")
|
||||||
|
|
||||||
@@ -454,19 +543,17 @@ class Spot:
|
|||||||
|
|
||||||
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
|
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
|
||||||
|
|
||||||
def _append_sig_ref_if_missing(self, new_sig_ref):
|
def _append_activity_ref_if_missing(self, new_activity_ref):
|
||||||
"""Append a sig_ref to the list, so long as it's not already there."""
|
"""Append an activity ref to the list, so long as it's not already there."""
|
||||||
|
|
||||||
sig_refs = self.sig_refs or []
|
new_activity_ref.id = new_activity_ref.id.strip().upper()
|
||||||
self.sig_refs = sig_refs
|
new_activity_ref.sig = new_activity_ref.sig.strip().upper()
|
||||||
new_sig_ref.id = new_sig_ref.id.strip().upper()
|
if new_activity_ref.id == "":
|
||||||
new_sig_ref.sig = new_sig_ref.sig.strip().upper()
|
|
||||||
if new_sig_ref.id == "":
|
|
||||||
return
|
return
|
||||||
for sig_ref in sig_refs:
|
for activity_ref in self.sig_refs:
|
||||||
if sig_ref.id == new_sig_ref.id and sig_ref.sig == new_sig_ref.sig:
|
if activity_ref.id == new_activity_ref.id and activity_ref.sig == new_activity_ref.sig:
|
||||||
return
|
return
|
||||||
sig_refs.append(new_sig_ref)
|
self.sig_refs.append(new_activity_ref)
|
||||||
|
|
||||||
def expired(self):
|
def expired(self):
|
||||||
"""Decide if this spot has expired (in which case it should not be added to the system in the first place, and not
|
"""Decide if this spot has expired (in which case it should not be added to the system in the first place, and not
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
AA343,"Almaty, Kazakhstan"
|
AA343,"Almaty, Kazakhstan"
|
||||||
AL945,"Alpena, United States"
|
AL945,"Alpena, United States"
|
||||||
AT138,"Athens, Greece"
|
AT138,"Athens, Greece"
|
||||||
AU930,"Austin, United States"
|
|
||||||
BR52P,"Brisbane, Australia"
|
|
||||||
BVJ03,"Boa Vista, Brazil"
|
BVJ03,"Boa Vista, Brazil"
|
||||||
CAJ2M,"Cachoeira Paulista, Brazil"
|
CAJ2M,"Cachoeira Paulista, Brazil"
|
||||||
CB53N,"Canberra, Australia"
|
CB53N,"Canberra, Australia"
|
||||||
CGK21,"Campo Grande, Brazil"
|
|
||||||
DB049,"Dourbes, Belgium"
|
DB049,"Dourbes, Belgium"
|
||||||
DW41K,"Darwin, Australia"
|
DW41K,"Darwin, Australia"
|
||||||
EA036,"El Arenosillo, Spain"
|
EA036,"El Arenosillo, Spain"
|
||||||
@@ -18,24 +15,18 @@ GM037,"Gibilmanna, Italy"
|
|||||||
GR13L,"Grahamstown, South Africa"
|
GR13L,"Grahamstown, South Africa"
|
||||||
HE13N,"Hermanus, South Africa"
|
HE13N,"Hermanus, South Africa"
|
||||||
HO54K,"Hobart, Australia"
|
HO54K,"Hobart, Australia"
|
||||||
IC437,"I-Cheon, South Korea"
|
|
||||||
IF843,"Idaho National Laboratory, United States"
|
IF843,"Idaho National Laboratory, United States"
|
||||||
JI91J,"Jicamarca, Peru"
|
JI91J,"Jicamarca, Peru"
|
||||||
JR055,"Juliusruh, Germany"
|
JR055,"Juliusruh, Germany"
|
||||||
LAA38,"Lajes Terceira Island, Portugal"
|
LAA38,"Lajes Terceira Island, Portugal"
|
||||||
LL721,"Lualualei, United States"
|
LL721,"Lualualei, United States"
|
||||||
LM42B,"Learmonth, Australia"
|
|
||||||
LV12P,"Louisvale, South Africa"
|
|
||||||
MHJ45,"Millstone Hill, United States"
|
MHJ45,"Millstone Hill, United States"
|
||||||
ML10L,"Malindi, Kenya"
|
|
||||||
NI135,"Nicosia, Cyprus"
|
NI135,"Nicosia, Cyprus"
|
||||||
NI63_,"Norfolk, Australia"
|
NI63_,"Norfolk, Australia"
|
||||||
PA836,"Portt Arguello, United States"
|
PA836,"Port Arguello, United States"
|
||||||
PE43K,"Perth, Australia"
|
PE43K,"Perth, Australia"
|
||||||
PF765,"Poker Flat, United States"
|
|
||||||
PQ052,"Pruhonice, Czechia"
|
PQ052,"Pruhonice, Czechia"
|
||||||
RO041,"Rome, Italy"
|
RO041,"Rome, Italy"
|
||||||
SAA0K,"Saoluis, Brazil"
|
|
||||||
SO148,"Sopron, Hungary"
|
SO148,"Sopron, Hungary"
|
||||||
TR169,"Tromso, Norway"
|
TR169,"Tromso, Norway"
|
||||||
TV51R,"Townsville, Australia"
|
TV51R,"Townsville, Australia"
|
||||||
|
|||||||
|
+13
-11
@@ -13,6 +13,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
- "7373:7373" # For telnet if required
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yml:/app/config.yml
|
- ./config.yml:/app/config.yml
|
||||||
- ./cache:/app/cache
|
- ./cache:/app/cache
|
||||||
@@ -23,15 +24,16 @@ You can replace `#main` with any other branch or tag reference, for example `#1.
|
|||||||
|
|
||||||
Save the file. You will still need to create a copy of `config-example.yml` and name it `config.yml`, though with the
|
Save the file. You will still need to create a copy of `config-example.yml` and name it `config.yml`, though with the
|
||||||
Docker setup nothing has actually been downloaded yet, so you will have to copy the example from the repository some
|
Docker setup nothing has actually been downloaded yet, so you will have to copy the example from the repository some
|
||||||
other way, e.g. [from the repo in a web browser](https://git.ianrenton.com/ian/spothole/src/branch/main/config-example.yml).
|
other way,
|
||||||
|
e.g. [from the repo in a web browser](https://git.ianrenton.com/ian/spothole/src/branch/main/config-example.yml).
|
||||||
|
|
||||||
With that in place, run `docker compose up` and you should be good to go. To detach, press `d` or run the command with
|
With that in place, run `docker compose up` and you should be good to go. To detach, press `d` or run the command with
|
||||||
the `-d` flag.
|
the `-d` flag.
|
||||||
|
|
||||||
### nginx Reverse Proxy with Docker
|
### nginx Reverse Proxy with Docker
|
||||||
|
|
||||||
In a containerised setup, it's typical to run an nginx reverse proxy in one container, alongside certbot for renewal
|
In a containerised setup, it's typical to run an nginx reverse proxy in one container, alongside certbot for renewal of
|
||||||
of HTTPS certificates, and then applications like Spothole in a separate container. In this case, there are a couple of
|
HTTPS certificates, and then applications like Spothole in a separate container. In this case, there are a couple of
|
||||||
variations of the docker compose file above, and the nginx reverse proxy configuration covered [here](./nginx.md), that
|
variations of the docker compose file above, and the nginx reverse proxy configuration covered [here](./nginx.md), that
|
||||||
you will want to make.
|
you will want to make.
|
||||||
|
|
||||||
@@ -60,8 +62,8 @@ networks:
|
|||||||
```
|
```
|
||||||
|
|
||||||
In your nginx site configuration, you'll want to refer to the Spothole container directly, and drop the block that
|
In your nginx site configuration, you'll want to refer to the Spothole container directly, and drop the block that
|
||||||
allows nginx to access static files directly, as these will be inaccessible in another container. So you may end up
|
allows nginx to access static files directly, as these will be inaccessible in another container. So you may end up with
|
||||||
with something like:
|
something like:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
@@ -146,13 +148,13 @@ server {
|
|||||||
```
|
```
|
||||||
|
|
||||||
If desired, you could even change the port on which Spothole runs from 8080 to a plain 80, in which case your
|
If desired, you could even change the port on which Spothole runs from 8080 to a plain 80, in which case your
|
||||||
`proxy_pass` statements could drop the `:8080` suffix. Since Spothole is in a container, it can serve HTTP on port 80
|
`proxy_pass` statements could drop the `:8080` suffix. Since Spothole is in a container, it can serve HTTP on port 80 if
|
||||||
if desired, because it doesn't conflict with the host system.
|
desired, because it doesn't conflict with the host system.
|
||||||
|
|
||||||
### Restoring the static files bypass
|
### Restoring the static files bypass
|
||||||
|
|
||||||
If you would still like to bypass Spothole's web server for the static files, and serve them with nginx, you can
|
If you would still like to bypass Spothole's web server for the static files, and serve them with nginx, you can do. The
|
||||||
do. The easiest way is to run another nginx container to serve the files, so your Spothole `compose.yaml` becomes:
|
easiest way is to run another nginx container to serve the files, so your Spothole `compose.yaml` becomes:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -183,8 +185,8 @@ networks:
|
|||||||
external: true
|
external: true
|
||||||
```
|
```
|
||||||
|
|
||||||
Then you can re-add the block that handles the `/static` path in your nginx reverse proxy config, but this time
|
Then you can re-add the block that handles the `/static` path in your nginx reverse proxy config, but this time point it
|
||||||
point it at the new container rather than at a filesystem path:
|
at the new container rather than at a filesystem path:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
# Load static assets from the spothole-static-nginx container
|
# Load static assets from the spothole-static-nginx container
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@ You can embed Spothole's web interface in another website, e.g. for use as part
|
|||||||
URL parameters can be used to trigger an "embedded" mode which hides the headers, footers and settings. In this mode,
|
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
|
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
|
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
|
dark mode or activity filters, which will not impact how Spothole appears when the user accesses it directly. Effectively, it
|
||||||
becomes separate to their normal Spothole settings.
|
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
|
Setting `embedded` to true is important for the rest of the settings to be applied; otherwise, the user's defaults will
|
||||||
@@ -29,7 +29,7 @@ a mapping exists.
|
|||||||
| `limit` | 25, 50, 100, 200, 500 | 100 | `?limit=100` | Sets the number of alerts that will be displayed on the alerts 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. |
|
| `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. |
|
| `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. |
|
| `sig` | Comma-separated list | (all) | `?sig=POTA,SOTA,NO_SIG` | Sets the list of activities 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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
||||||
|
|||||||
+8
-5
@@ -16,9 +16,12 @@ To navigate your way around the source code, this list may help.
|
|||||||
* `/providers/solarconditions` - Classes providing solar and propagation by accessing the APIs of other services
|
* `/providers/solarconditions` - Classes providing solar and propagation by accessing the APIs of other services
|
||||||
* `/providers/staticdata` - Classes providing static lookup data by accessing bundled data files or the APIs of other
|
* `/providers/staticdata` - Classes providing static lookup data by accessing bundled data files or the APIs of other
|
||||||
services
|
services
|
||||||
* `/providers/sigrefdata` - Classes providing SIG reference lookup data by accessing bundled data files or the APIs of
|
* `/providers/callsign` - Classes providing callsign lookup data by accessing bundled data files or the APIs of other
|
||||||
other services
|
services
|
||||||
* `/server` - Classes for running Spothole's own web server
|
* `/providers/activityrefdata` - Classes providing activity reference lookup data by accessing bundled data files or
|
||||||
|
the APIs of other services
|
||||||
|
* `/webserver` - Classes for running Spothole's own web server
|
||||||
|
* `/telnetserver` - Classes for running Spothole's telnet server
|
||||||
* `spothole.py` - Main application script
|
* `spothole.py` - Main application script
|
||||||
|
|
||||||
*Templates*
|
*Templates*
|
||||||
@@ -48,8 +51,8 @@ To navigate your way around the source code, this list may help.
|
|||||||
### Extending the server
|
### Extending the server
|
||||||
|
|
||||||
Spothole is designed to be easily extensible. If you want to write your own spot provider, for example, simply add a
|
Spothole is designed to be easily extensible. If you want to write your own spot provider, for example, simply add a
|
||||||
module to the `providers.spot` package containing your class. (Currently, in order to be loaded correctly, the module (
|
module to the `providers.spot` 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.)
|
(file) name should be the same as the class name, but lower case.)
|
||||||
|
|
||||||
Your class should extend "SpotProvider"; if it operates by polling an HTTP Server on a timer, it can instead extend "
|
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.
|
HTTPSpotProvider" where some of the work is done for you.
|
||||||
|
|||||||
+10
-15
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Web servers generally serve their pages from port 80. However, it's best not to serve Spothole's web interface directly
|
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
|
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
|
site, since Spothole itself doesn't directly support acting as an HTTPS server. The normal solution to this is to use a
|
||||||
a "reverse proxy" setup, where a general web server handles HTTP and HTTP requests (to port 80 & 443 respectively), then
|
"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
|
passes on the request to the back-end application (in this case Spothole). nginx is a common choice for this general web
|
||||||
server.
|
server.
|
||||||
|
|
||||||
@@ -89,19 +89,14 @@ server {
|
|||||||
```
|
```
|
||||||
|
|
||||||
One further change you might want to make to the file above is the `add_header Access-Control-Allow-Origin` statements.
|
One further change you might want to make to the file above is the `add_header Access-Control-Allow-Origin` statements.
|
||||||
These are what's used on
|
These are what's used on my own Spothole server to make sure that other third-party web-based software can get the data
|
||||||
my own Spothole server to make sure that other third-party web-based software can get the data from my instance, and
|
from my instance, and applies to any endpoint underneath `/api`. If you want *your* Spothole instance to be set up the
|
||||||
applies to any endpoint underneath `/api`. If you want
|
same way, so that others can write software in JavaScript that can access it, leave this intact. But if you want your
|
||||||
*your* Spothole instance to be set up the same way, so that others can write software in JavaScript that can access it,
|
Spothole instance to only be usable by scripts running on the web server you write, you can remove these lines. (Note
|
||||||
leave this intact. But if you want your Spothole instance to only be usable by scripts running on the web server you
|
that this doesn't stop other people writing *non-web-based* software that accesses your Spothole API—the
|
||||||
write,
|
enforcement of cross-origin headers only happens within the user's browser. If you need to lock your instance down so
|
||||||
you can remove these lines. (Note that this doesn't stop other people writing *non-web-based* software that accesses
|
that no-one else can access it with *any* software, that's an aspect of nginx or firewall config that you will need to
|
||||||
your
|
find help with elsewhere.)
|
||||||
Spothole API—the enforcement of cross-origin headers only happens within the user's browser. If you need to lock
|
|
||||||
your
|
|
||||||
instance down so that no-one else can access it with *any* software, that's an aspect of nginx or firewall config that
|
|
||||||
you will need
|
|
||||||
to find help with elsewhere.)
|
|
||||||
|
|
||||||
Now, make a symbolic link to enable the site:
|
Now, make a symbolic link to enable the site:
|
||||||
|
|
||||||
|
|||||||
+14
-9
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from threading import Event
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
@@ -8,18 +9,20 @@ from core.data_store import DATA_STORE
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SIGRefDataProvider:
|
class ActivityRefDataProvider:
|
||||||
"""Generic SIG reference data provider class. Subclasses of this query the individual URLs or files for data."""
|
"""Generic activity reference data provider class. Subclasses of this query the individual URLs or files for
|
||||||
|
data."""
|
||||||
|
|
||||||
def __init__(self, sig_name, provider_config):
|
def __init__(self, sig_name, provider_config):
|
||||||
"""Constructor"""
|
"""Constructor. Note the parameter and attribute are still named "sig_name" for consistency with the API's
|
||||||
|
"sig" field name."""
|
||||||
|
|
||||||
self.sig_name = sig_name
|
self.sig_name = sig_name
|
||||||
self.enabled = provider_config["enabled"]
|
self.enabled = provider_config["enabled"]
|
||||||
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||||
self.status = "Not Started" if self.enabled else "Disabled"
|
self.status = "Not Started" if self.enabled else "Disabled"
|
||||||
self.reference_count = 0
|
self.reference_count = 0
|
||||||
self._stop = False
|
self._stop_event = Event()
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""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"""
|
||||||
@@ -30,20 +33,22 @@ class SIGRefDataProvider:
|
|||||||
"""Stop any threads and prepare for application shutdown. Subclasses should implement this method and call
|
"""Stop any threads and prepare for application shutdown. Subclasses should implement this method and call
|
||||||
super()."""
|
super()."""
|
||||||
|
|
||||||
self._stop = True
|
self._stop_event.set()
|
||||||
|
|
||||||
def _add_data(self, new_data):
|
def _add_data(self, new_data):
|
||||||
"""Add all the provided reference data objects to the data store."""
|
"""Add all the provided reference data objects to the data store."""
|
||||||
|
|
||||||
# with transact() batches all writes together to save making thousands of individual sqlite writes
|
# with transact() batches all writes together to save making thousands of individual sqlite writes. However,
|
||||||
with DATA_STORE.sigrefs.transact():
|
# that means that each provider holds the lock while it writes, and the default behaviour for other attempted
|
||||||
|
# transact()s is to fail if they can't get the lock (?!). This behaviour is fixed by retry=True.
|
||||||
|
with DATA_STORE.activity_refs.transact(retry=True):
|
||||||
for d in new_data:
|
for d in new_data:
|
||||||
DATA_STORE.sigrefs.set(f"{self.sig_name}:{d.id}", d)
|
DATA_STORE.activity_refs.set(f"{self.sig_name}:{d.id}", d)
|
||||||
|
|
||||||
# For the big data sources, loading will take a few minutes. If we want to shut down the software neatly
|
# For the big data sources, loading will take a few minutes. If we want to shut down the software neatly
|
||||||
# within the first few minutes of startup, we need a way to abort this expensive process of filling up the
|
# within the first few minutes of startup, we need a way to abort this expensive process of filling up the
|
||||||
# disk cache.
|
# disk cache.
|
||||||
if self._stop:
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
self.reference_count = len(new_data)
|
self.reference_count = len(new_data)
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
import csv
|
import csv
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType
|
||||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
from data.activity_ref import ActivityRef
|
||||||
FileDownloadSIGRefDataProvider,
|
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||||
|
FileDownloadActivityRefDataProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ARLHS(FileDownloadSIGRefDataProvider):
|
class ARLHS(FileDownloadActivityRefDataProvider):
|
||||||
"""SIG ref data provider for Amateur Radio Light House Society"""
|
"""Activity ref data provider for Amateur Radio Light House Society"""
|
||||||
|
|
||||||
POLL_INTERVAL_DAYS = 30
|
POLL_INTERVAL_DAYS = 30
|
||||||
SIG = "ARLHS"
|
ACTIVITY = ActivityName.ARLHS
|
||||||
DATA_URL = "https://www.gma.rocks/download/lighthouse.csv"
|
DATA_URL = "https://www.gma.rocks/download/lighthouse.csv"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
def _http_response_to_data(self, http_response):
|
def _http_response_to_data(self, http_response):
|
||||||
new_data = []
|
new_data = []
|
||||||
@@ -23,11 +24,11 @@ class ARLHS(FileDownloadSIGRefDataProvider):
|
|||||||
if "ARLHS" in row and row["ARLHS"] != "":
|
if "ARLHS" in row and row["ARLHS"] != "":
|
||||||
ref_id = row["ARLHS"]
|
ref_id = row["ARLHS"]
|
||||||
new_data.append(
|
new_data.append(
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
sig=self.SIG,
|
sig=self.ACTIVITY,
|
||||||
id=ref_id,
|
id=ref_id,
|
||||||
name=row.get("Name", None),
|
name=row.get("Name", None),
|
||||||
ref_type="Lighthouse",
|
ref_type=ActivityRefType.LIGHTHOUSE,
|
||||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||||
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
||||||
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
||||||
@@ -40,7 +41,7 @@ class ARLHS(FileDownloadSIGRefDataProvider):
|
|||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
# is available for other threads e.g. the web server.
|
# is available for other threads e.g. the web server.
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from time import sleep
|
||||||
|
|
||||||
|
from pyhamtools.locator import latlong_to_locator
|
||||||
|
|
||||||
|
from core.enums import ActivityName, ActivityRefType
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
|
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class COTA(FileDownloadActivityRefDataProvider):
|
||||||
|
"""Activity ref data provider for Castles on the Air"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 30
|
||||||
|
ACTIVITY = ActivityName.COTA
|
||||||
|
DATA_URL = "https://www.cotagroup.org/cotagroup/map/data/castles-all-7d90ee2a5e1175e5dece1bbf9dc87504.json"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
|
def _http_response_to_data(self, http_response):
|
||||||
|
new_data = []
|
||||||
|
data = http_response.json()
|
||||||
|
if isinstance(data, list):
|
||||||
|
for ref in data[2]:
|
||||||
|
ref_id = ref[3]
|
||||||
|
name = ref[4]
|
||||||
|
lat = float(ref[0])
|
||||||
|
lon = float(ref[1])
|
||||||
|
grid = latlong_to_locator(lat, lon)
|
||||||
|
|
||||||
|
new_data.append(
|
||||||
|
ActivityRef(
|
||||||
|
sig=self.ACTIVITY,
|
||||||
|
id=ref_id,
|
||||||
|
name=name,
|
||||||
|
ref_type=ActivityRefType.CASTLE,
|
||||||
|
grid=grid,
|
||||||
|
latitude=lat,
|
||||||
|
longitude=lon,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest
|
||||||
|
# of the data in this case
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
|
# is available for other threads e.g. the web server.
|
||||||
|
sleep(0.001)
|
||||||
|
|
||||||
|
return new_data
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import io
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from core.enums import ActivityName, ActivityRefType
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
|
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class DCE(FileDownloadActivityRefDataProvider):
|
||||||
|
"""Activity ref data provider for Diploma Castillos de España"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 365
|
||||||
|
ACTIVITY = ActivityName.DCE
|
||||||
|
DATA_URL = "https://www.acracb.org/dce/descargas/General/directorio_referencias_dce.xls"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
|
def _http_response_to_data(self, http_response):
|
||||||
|
new_data = []
|
||||||
|
|
||||||
|
file_stream = io.BytesIO(http_response.content)
|
||||||
|
df = pd.read_excel(file_stream, engine="xlrd", header=None)
|
||||||
|
|
||||||
|
for index, row in df.iterrows():
|
||||||
|
if row.iloc[0] and row.iloc[2]:
|
||||||
|
new_data.append(
|
||||||
|
ActivityRef(sig=self.ACTIVITY, id=row.iloc[0].strip(), name=row.iloc[2].strip(), ref_type=ActivityRefType.CASTLE)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||||
|
# the data in this case
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
|
# is available for other threads e.g. the web server.
|
||||||
|
sleep(0.001)
|
||||||
|
|
||||||
|
return new_data
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import io
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from core.enums import ActivityName, ActivityRefType
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
|
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class DEFE(FileDownloadActivityRefDataProvider):
|
||||||
|
"""Activity ref data provider for Diploma Estationes de Ferrocarril de España"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 365
|
||||||
|
ACTIVITY = ActivityName.DEFE
|
||||||
|
DATA_URL = "https://www.acracb.org/defe/descargas/General/directorio_referencias_defe.xls"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
|
def _http_response_to_data(self, http_response):
|
||||||
|
new_data = []
|
||||||
|
|
||||||
|
file_stream = io.BytesIO(http_response.content)
|
||||||
|
df = pd.read_excel(file_stream, engine="xlrd", header=None)
|
||||||
|
|
||||||
|
for index, row in df.iterrows():
|
||||||
|
# Skip the header row
|
||||||
|
if str(row.iloc[0]) == "NºDEFE":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if row.iloc[0] and row.iloc[1]:
|
||||||
|
new_data.append(
|
||||||
|
ActivityRef(sig=self.ACTIVITY, id=row.iloc[0].strip(), name=row.iloc[1].strip(), ref_type=ActivityRefType.BUILDING)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||||
|
# the data in this case
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
|
# is available for other threads e.g. the web server.
|
||||||
|
sleep(0.001)
|
||||||
|
|
||||||
|
return new_data
|
||||||
@@ -3,26 +3,31 @@ from time import sleep
|
|||||||
|
|
||||||
from pyhamtools.locator import latlong_to_locator
|
from pyhamtools.locator import latlong_to_locator
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType
|
||||||
from providers.sigrefdata.local_file_sig_ref_data_provider import (
|
from data.activity_ref import ActivityRef
|
||||||
LocalFileSIGRefDataProvider,
|
from providers.activityrefdata.local_file_activity_ref_data_provider import (
|
||||||
|
LocalFileActivityRefDataProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class DME(LocalFileSIGRefDataProvider):
|
class DME(LocalFileActivityRefDataProvider):
|
||||||
"""SIG ref data provider for Diploma Municipios de Espana"""
|
"""Activity ref data provider for Diploma Municipios de Espana"""
|
||||||
|
|
||||||
SIG = "DME"
|
ACTIVITY = ActivityName.DME
|
||||||
PATH = "datafiles/MUNICIPIOS.csv"
|
PATH = "datafiles/MUNICIPIOS.csv"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(self.SIG, provider_config, self.PATH)
|
super().__init__(self.ACTIVITY, provider_config, self.PATH)
|
||||||
|
|
||||||
def _file_to_data(self, path):
|
def _file_to_data(self, path):
|
||||||
new_data = []
|
new_data = []
|
||||||
with open(path, encoding="latin-1") as _f:
|
with open(path, encoding="latin-1") as _f:
|
||||||
for row in csv.DictReader(_f, delimiter=";"):
|
for row in csv.DictReader(_f, delimiter=";"):
|
||||||
ref_id = row["COD_INE"][:5]
|
# Store reference IDs with the "DME-" prefix rather than just the number. This will prevent Spothole
|
||||||
|
# from agressively thinking every number in a spot comment is DME after it's seen "DME" once. The only
|
||||||
|
# numbers that count are straight after "DME " or "DME-". The dash versus space is normalised in
|
||||||
|
# activity_lookup_helper.py.
|
||||||
|
ref_id = "DME-" + row["COD_INE"][:5]
|
||||||
latitude = (
|
latitude = (
|
||||||
float(row["LATITUD_ETRS89_REGCAN95"].replace(",", "."))
|
float(row["LATITUD_ETRS89_REGCAN95"].replace(",", "."))
|
||||||
if row.get("LATITUD_ETRS89_REGCAN95")
|
if row.get("LATITUD_ETRS89_REGCAN95")
|
||||||
@@ -34,10 +39,10 @@ class DME(LocalFileSIGRefDataProvider):
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
ref = SIGRef(
|
ref = ActivityRef(
|
||||||
sig=self.SIG,
|
sig=self.ACTIVITY,
|
||||||
id=ref_id,
|
id=ref_id,
|
||||||
ref_type="Town",
|
ref_type=ActivityRefType.TOWN,
|
||||||
name=f"{row['NOMBRE_ACTUAL']}, {row['PROVINCIA']}",
|
name=f"{row['NOMBRE_ACTUAL']}, {row['PROVINCIA']}",
|
||||||
latitude=latitude,
|
latitude=latitude,
|
||||||
longitude=longitude,
|
longitude=longitude,
|
||||||
@@ -48,10 +53,10 @@ class DME(LocalFileSIGRefDataProvider):
|
|||||||
|
|
||||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest
|
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest
|
||||||
# of the data in this case
|
# of the data in this case
|
||||||
if self._stop:
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
# is available for other threads e.g. the web server.
|
# is available for other threads e.g. the web server.
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import csv
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
from core.enums import ActivityName, ActivityRefType
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
|
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class DMUE(FileDownloadActivityRefDataProvider):
|
||||||
|
"""Activity ref data provider for Diploma Museos de España"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 365
|
||||||
|
ACTIVITY = ActivityName.DMUE
|
||||||
|
DATA_URL = "https://dmue.radiogalena.es/nom_dmue.csv"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
|
def _http_response_to_data(self, http_response):
|
||||||
|
new_data = []
|
||||||
|
|
||||||
|
for row in csv.reader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"):
|
||||||
|
if len(row) > 1 and row[0] and row[1]:
|
||||||
|
new_data.append(
|
||||||
|
ActivityRef(sig=self.ACTIVITY, id=row[0].strip(), name=row[1].strip(), ref_type=ActivityRefType.BUILDING)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||||
|
# the data in this case
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
|
# is available for other threads e.g. the web server.
|
||||||
|
sleep(0.001)
|
||||||
|
|
||||||
|
return new_data
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import io
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from core.enums import ActivityName, ActivityRefType
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
|
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class DMVE(FileDownloadActivityRefDataProvider):
|
||||||
|
"""Activity ref data provider for Diploma Monumentos y Vestigios de España"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 365
|
||||||
|
ACTIVITY = ActivityName.DMVE
|
||||||
|
DATA_URL = "https://www.acracb.org/dmve/descargas/General/directorio_referencias_dmve.xls"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
|
def _http_response_to_data(self, http_response):
|
||||||
|
new_data = []
|
||||||
|
|
||||||
|
file_stream = io.BytesIO(http_response.content)
|
||||||
|
# Despide the .xls extension this is actually an xlsx file, so we need openpyxl not xlrd
|
||||||
|
df = pd.read_excel(file_stream, engine="openpyxl", header=None)
|
||||||
|
|
||||||
|
for index, row in df.iterrows():
|
||||||
|
ref = row.iloc[0]
|
||||||
|
name = row.iloc[1]
|
||||||
|
|
||||||
|
# Skip the header row and blank rows
|
||||||
|
if str(ref) == "REF.":
|
||||||
|
continue
|
||||||
|
if pd.isna(ref) or pd.isna(name):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ref and name:
|
||||||
|
new_data.append(ActivityRef(sig=self.ACTIVITY, id=ref.strip(), name=name.strip(), ref_type=ActivityRefType.BUILDING))
|
||||||
|
|
||||||
|
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||||
|
# the data in this case
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
|
# is available for other threads e.g. the web server.
|
||||||
|
sleep(0.001)
|
||||||
|
|
||||||
|
return new_data
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from time import sleep
|
||||||
|
|
||||||
|
from core.enums import ActivityName, ActivityRefType
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
|
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class DTMBA(FileDownloadActivityRefDataProvider):
|
||||||
|
"""Activity ref data provider for Diploma Teatri Musei Belle Arti"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 30
|
||||||
|
ACTIVITY = ActivityName.DTMBA
|
||||||
|
DATA_URL = "https://www.iu1fig.com/share/iz0eik/dtmba/export.php"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
|
def _http_response_to_data(self, http_response):
|
||||||
|
new_data = []
|
||||||
|
for row in http_response.content.decode("utf-8-sig").splitlines():
|
||||||
|
split = row.split(";")
|
||||||
|
ref_id = split[0]
|
||||||
|
ref_name = split[1]
|
||||||
|
new_data.append(ActivityRef(sig=self.ACTIVITY, id=ref_id, name=ref_name, ref_type=ActivityRefType.BUILDING))
|
||||||
|
|
||||||
|
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||||
|
# the data in this case
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
|
# is available for other threads e.g. the web server.
|
||||||
|
sleep(0.001)
|
||||||
|
|
||||||
|
return new_data
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
import pdfplumber
|
||||||
|
|
||||||
|
from core.enums import ActivityName, ActivityRefType
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
|
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class FEA(FileDownloadActivityRefDataProvider):
|
||||||
|
"""Activity ref data provider for Diploma Faros de España"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 30
|
||||||
|
ACTIVITY = ActivityName.FEA
|
||||||
|
DATA_URL = "http://ea5ol.net/Lista%20Faros.pdf"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
|
def _http_response_to_data(self, http_response):
|
||||||
|
new_data = []
|
||||||
|
|
||||||
|
# Use PDFPlumber to extract the tables in the PDF
|
||||||
|
with pdfplumber.open(BytesIO(http_response.content)) as pdf:
|
||||||
|
all_rows = []
|
||||||
|
|
||||||
|
for page_number, page in enumerate(pdf.pages, start=1):
|
||||||
|
tables = page.extract_tables()
|
||||||
|
|
||||||
|
for table_number, table in enumerate(tables, start=1):
|
||||||
|
if not table:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rows = [row for row in table if any(cell and cell.strip() for cell in row)]
|
||||||
|
all_rows.extend(rows)
|
||||||
|
|
||||||
|
for row in all_rows:
|
||||||
|
if not "REF" in row[0] and not "\n" in row[0]:
|
||||||
|
# FEA references are technically [DE]\-\d{4}(\.\d)? but spotters always seem to miss out the D- or E-
|
||||||
|
# prefix and just use FEA-1234 or FEA 1234, so we add both copies to the database.
|
||||||
|
ref_id_1 = row[0].strip()
|
||||||
|
ref_id_2 = ref_id_1.replace("D-", "FEA-").replace("E-", "FEA-")
|
||||||
|
new_data.append(ActivityRef(sig=self.ACTIVITY, id=ref_id_1, name=row[1].strip(), ref_type=ActivityRefType.LIGHTHOUSE))
|
||||||
|
new_data.append(ActivityRef(sig=self.ACTIVITY, id=ref_id_2, name=row[1].strip(), ref_type=ActivityRefType.LIGHTHOUSE))
|
||||||
|
|
||||||
|
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest of
|
||||||
|
# the data in this case
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
|
# is available for other threads e.g. the web server.
|
||||||
|
sleep(0.001)
|
||||||
|
|
||||||
|
return new_data
|
||||||
+22
-19
@@ -1,19 +1,20 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from threading import Event, Thread
|
from threading import Thread
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from core.url_data_cache import URLDataCache
|
from core.url_data_cache import URLDataCache
|
||||||
from providers.sigrefdata.sig_ref_data_provider import SIGRefDataProvider
|
from providers.activityrefdata.activity_ref_data_provider import ActivityRefDataProvider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
|
class FileDownloadActivityRefDataProvider(ActivityRefDataProvider):
|
||||||
"""Generic SIG ref data provider class for providers that fetch their data from the web by downloading a file."""
|
"""Generic activity ref data provider class for providers that fetch their data from the web by downloading a
|
||||||
|
file."""
|
||||||
|
|
||||||
def __init__(self, sig_name, provider_config, url, poll_interval):
|
def __init__(self, sig_name, provider_config, url, poll_interval):
|
||||||
"""Set up the provider, note poll_interval is in *days*."""
|
"""Set up the provider, note poll_interval is in *days*."""
|
||||||
@@ -21,19 +22,21 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
|
|||||||
self._url = url
|
self._url = url
|
||||||
self._poll_interval = poll_interval
|
self._poll_interval = poll_interval
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._stop_event = Event()
|
self._url_data_cache = URLDataCache(f"activity_ref_data_{sig_name}")
|
||||||
self._url_data_cache = URLDataCache(f"sigrefdata_{sig_name}")
|
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
||||||
# subsequent polls, so start() returns immediately and the application can continue starting.
|
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||||
logger.info(f"Set up query of {self.sig_name} SIG ref data every {self._poll_interval!s} days.")
|
logger.info(f"Set up query of {self.sig_name} activity ref data every {self._poll_interval!s} days.")
|
||||||
self._thread = Thread(target=self._run, name=f"FileDownloadSIGRefDataProvider-{self.sig_name}")
|
self._thread = Thread(target=self._run, name=f"FileDownloadActivityRefDataProvider-{self.sig_name}", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
super().stop()
|
super().stop()
|
||||||
self._stop_event.set()
|
if self._thread:
|
||||||
|
self._thread.join(timeout=12)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning(f"{self.sig_name} activity ref data worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while True:
|
while True:
|
||||||
@@ -45,37 +48,37 @@ class FileDownloadSIGRefDataProvider(SIGRefDataProvider):
|
|||||||
try:
|
try:
|
||||||
# Request data from API. Use the data cache (with a TTL of 1 day) here, not as the main mechanism for
|
# Request data from API. Use the data cache (with a TTL of 1 day) here, not as the main mechanism for
|
||||||
# caching, but just so continual restarts of the software during testing don't hammer the servers.
|
# caching, but just so continual restarts of the software during testing don't hammer the servers.
|
||||||
logger.debug(f"Downloading {self.sig_name} SIG ref data...")
|
logger.debug(f"Downloading {self.sig_name} activity ref data...")
|
||||||
http_response = self._url_data_cache.get(self._url, headers=HTTP_HEADERS)
|
http_response = self._url_data_cache.get(self._url, headers=HTTP_HEADERS)
|
||||||
# Check response code was good
|
# Check response code was good
|
||||||
if http_response.ok:
|
if http_response.ok:
|
||||||
# Pass off to the subclass for processing
|
# Pass off to the subclass for processing
|
||||||
new_data = self._http_response_to_data(http_response)
|
new_data = self._http_response_to_data(http_response)
|
||||||
# Add the new data to the SIG Ref data store
|
# Add the new data to the activity ref data store
|
||||||
if new_data:
|
if new_data:
|
||||||
self._add_data(new_data)
|
self._add_data(new_data)
|
||||||
|
|
||||||
self.status = "OK"
|
self.status = "OK"
|
||||||
self.last_update_time = datetime.now(pytz.UTC)
|
self.last_update_time = datetime.now(pytz.UTC)
|
||||||
logger.debug(f"Received SIG ref data for {self.sig_name}")
|
logger.debug(f"Received activity ref data for {self.sig_name}")
|
||||||
else:
|
else:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logger.warning(f"HTTP {http_response.status_code} when downloading SIG ref data for {self.sig_name}.")
|
logger.warning(f"HTTP {http_response.status_code} when downloading activity ref data for {self.sig_name}.")
|
||||||
|
|
||||||
except ConnectionError:
|
except ConnectionError:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logger.warning(f"Connection error when downloading SIG ref data for {self.sig_name}.")
|
logger.warning(f"Connection error when downloading activity ref data for {self.sig_name}.")
|
||||||
except (ConnectTimeout, ReadTimeout):
|
except (ConnectTimeout, ReadTimeout):
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logger.warning(f"Timeout when downloading SIG ref data for {self.sig_name}.")
|
logger.warning(f"Timeout when downloading activity ref data for {self.sig_name}.")
|
||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logger.exception(f"Exception in HTTP SIG Ref Data Provider ({self.sig_name})")
|
logger.exception(f"Exception in HTTP Activity Ref Data Provider ({self.sig_name})")
|
||||||
self._stop_event.wait(timeout=1)
|
self._stop_event.wait(timeout=1)
|
||||||
|
|
||||||
def _http_response_to_data(self, http_response):
|
def _http_response_to_data(self, http_response):
|
||||||
"""Convert an HTTP response returned by the server into SIG Ref data. The whole response is provided here so the
|
"""Convert an HTTP response returned by the server into activity ref data. The whole response is provided here
|
||||||
subclass implementations can check for HTTP status codes if necessary, and handle the response as JSON, CSV,
|
so the subclass implementations can check for HTTP status codes if necessary, and handle the response as
|
||||||
whatever the remote file actually is."""
|
JSON, CSV, whatever the remote file actually is."""
|
||||||
|
|
||||||
raise NotImplementedError("Subclasses must implement this method")
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
@@ -1,32 +1,33 @@
|
|||||||
import csv
|
import csv
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType
|
||||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
from data.activity_ref import ActivityRef
|
||||||
FileDownloadSIGRefDataProvider,
|
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||||
|
FileDownloadActivityRefDataProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class GMA(FileDownloadSIGRefDataProvider):
|
class GMA(FileDownloadActivityRefDataProvider):
|
||||||
"""SIG ref data provider for Global Mountain Activity"""
|
"""Activity ref data provider for Global Mountain Activity"""
|
||||||
|
|
||||||
POLL_INTERVAL_DAYS = 30
|
POLL_INTERVAL_DAYS = 30
|
||||||
SIG = "GMA"
|
ACTIVITY = ActivityName.GMA
|
||||||
DATA_URL = "https://www.gma.rocks/download/summits.csv"
|
DATA_URL = "https://www.gma.rocks/download/summits.csv"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
def _http_response_to_data(self, http_response):
|
def _http_response_to_data(self, http_response):
|
||||||
new_data = []
|
new_data = []
|
||||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
|
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
|
||||||
ref_id = row["Reference"]
|
ref_id = row["Reference"]
|
||||||
new_data.append(
|
new_data.append(
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
sig=self.SIG,
|
sig=self.ACTIVITY,
|
||||||
id=ref_id,
|
id=ref_id,
|
||||||
name=row.get("Name", None),
|
name=row.get("Name", None),
|
||||||
ref_type="Summit",
|
ref_type=ActivityRefType.SUMMIT,
|
||||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||||
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
||||||
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
||||||
@@ -42,7 +43,7 @@ class GMA(FileDownloadSIGRefDataProvider):
|
|||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
# is available for other threads e.g. the web server.
|
# is available for other threads e.g. the web server.
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
import csv
|
import csv
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType
|
||||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
from data.activity_ref import ActivityRef
|
||||||
FileDownloadSIGRefDataProvider,
|
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||||
|
FileDownloadActivityRefDataProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ILLW(FileDownloadSIGRefDataProvider):
|
class ILLW(FileDownloadActivityRefDataProvider):
|
||||||
"""SIG ref data provider for International Lighthouse & Lightship Weekend"""
|
"""Activity ref data provider for International Lighthouse & Lightship Weekend"""
|
||||||
|
|
||||||
POLL_INTERVAL_DAYS = 30
|
POLL_INTERVAL_DAYS = 30
|
||||||
SIG = "ILLW"
|
ACTIVITY = ActivityName.ILLW
|
||||||
DATA_URL = "https://www.gma.rocks/download/lighthouse.csv"
|
DATA_URL = "https://www.gma.rocks/download/lighthouse.csv"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
def _http_response_to_data(self, http_response):
|
def _http_response_to_data(self, http_response):
|
||||||
new_data = []
|
new_data = []
|
||||||
@@ -23,11 +24,11 @@ class ILLW(FileDownloadSIGRefDataProvider):
|
|||||||
if "ILLW" in row and row["ILLW"] != "":
|
if "ILLW" in row and row["ILLW"] != "":
|
||||||
ref_id = row["ILLW"]
|
ref_id = row["ILLW"]
|
||||||
new_data.append(
|
new_data.append(
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
sig=self.SIG,
|
sig=self.ACTIVITY,
|
||||||
id=ref_id,
|
id=ref_id,
|
||||||
name=row.get("Name", None),
|
name=row.get("Name", None),
|
||||||
ref_type="Lighthouse",
|
ref_type=ActivityRefType.LIGHTHOUSE,
|
||||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||||
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
||||||
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
||||||
@@ -40,7 +41,7 @@ class ILLW(FileDownloadSIGRefDataProvider):
|
|||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
# is available for other threads e.g. the web server.
|
# is available for other threads e.g. the web server.
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
@@ -3,23 +3,24 @@ from time import sleep
|
|||||||
|
|
||||||
from pyhamtools.locator import latlong_to_locator
|
from pyhamtools.locator import latlong_to_locator
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType
|
||||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
from data.activity_ref import ActivityRef
|
||||||
FileDownloadSIGRefDataProvider,
|
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||||
|
FileDownloadActivityRefDataProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class IOTA(FileDownloadSIGRefDataProvider):
|
class IOTA(FileDownloadActivityRefDataProvider):
|
||||||
"""SIG ref data provider for Islands on the Air"""
|
"""Activity ref data provider for Islands on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_DAYS = 365
|
POLL_INTERVAL_DAYS = 365
|
||||||
SIG = "IOTA"
|
ACTIVITY = ActivityName.IOTA
|
||||||
DATA_URL = "https://www.iota-world.org/islands-on-the-air/downloads/download-file.html?path=groups.json"
|
DATA_URL = "https://www.iota-world.org/islands-on-the-air/downloads/download-file.html?path=groups.json"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
def _http_response_to_data(self, http_response):
|
def _http_response_to_data(self, http_response):
|
||||||
new_data = []
|
new_data = []
|
||||||
@@ -40,11 +41,11 @@ class IOTA(FileDownloadSIGRefDataProvider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
new_data.append(
|
new_data.append(
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
sig=self.SIG,
|
sig=self.ACTIVITY,
|
||||||
id=ref_id,
|
id=ref_id,
|
||||||
name=ref["name"],
|
name=ref["name"],
|
||||||
ref_type="Island",
|
ref_type=ActivityRefType.ISLAND,
|
||||||
grid=grid,
|
grid=grid,
|
||||||
latitude=latitude,
|
latitude=latitude,
|
||||||
longitude=longitude,
|
longitude=longitude,
|
||||||
@@ -56,7 +57,7 @@ class IOTA(FileDownloadSIGRefDataProvider):
|
|||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
# is available for other threads e.g. the web server.
|
# is available for other threads e.g. the web server.
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from core.enums import ActivityName
|
||||||
|
from providers.activityrefdata.pnp_kml_activity_ref_data_provider import (
|
||||||
|
ParksNPeaksKMLActivityRefDataProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class KRMNPA(ParksNPeaksKMLActivityRefDataProvider):
|
||||||
|
"""Activity ref data provider for the Keith Roget Memorrial National Parks Award (KRMNPA)."""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 365
|
||||||
|
ACTIVITY = ActivityName.KRMNPA
|
||||||
|
DATA_URL = "https://parksnpeaks.org/getPOI.php?poiClass=KRMNPA&poiFormat=4"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
@@ -2,21 +2,22 @@ from time import sleep
|
|||||||
|
|
||||||
from pyhamtools.locator import locator_to_latlong
|
from pyhamtools.locator import locator_to_latlong
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType
|
||||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
from data.activity_ref import ActivityRef
|
||||||
FileDownloadSIGRefDataProvider,
|
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||||
|
FileDownloadActivityRefDataProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class LLOTA(FileDownloadSIGRefDataProvider):
|
class LLOTA(FileDownloadActivityRefDataProvider):
|
||||||
"""SIG ref data provider for Lagos y Lagunas on the Air"""
|
"""Activity ref data provider for Lagos y Lagunas on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_DAYS = 7
|
POLL_INTERVAL_DAYS = 7
|
||||||
SIG = "LLOTA"
|
ACTIVITY = ActivityName.LLOTA
|
||||||
DATA_URL = "https://llota.app/api/public/references"
|
DATA_URL = "https://llota.app/api/public/references"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
def _http_response_to_data(self, http_response):
|
def _http_response_to_data(self, http_response):
|
||||||
new_data = []
|
new_data = []
|
||||||
@@ -28,11 +29,11 @@ class LLOTA(FileDownloadSIGRefDataProvider):
|
|||||||
ll = locator_to_latlong(grid)
|
ll = locator_to_latlong(grid)
|
||||||
|
|
||||||
new_data.append(
|
new_data.append(
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
sig=self.SIG,
|
sig=self.ACTIVITY,
|
||||||
id=ref_id,
|
id=ref_id,
|
||||||
name=str(ref["name"]),
|
name=str(ref["name"]),
|
||||||
ref_type="Lake",
|
ref_type=ActivityRefType.LAKE,
|
||||||
url=f"https://llota.app/list/ref/{ref_id}",
|
url=f"https://llota.app/list/ref/{ref_id}",
|
||||||
grid=grid,
|
grid=grid,
|
||||||
latitude=ll[0],
|
latitude=ll[0],
|
||||||
@@ -45,7 +46,7 @@ class LLOTA(FileDownloadSIGRefDataProvider):
|
|||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
# is available for other threads e.g. the web server.
|
# is available for other threads e.g. the web server.
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from providers.activityrefdata.activity_ref_data_provider import ActivityRefDataProvider
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LocalFileActivityRefDataProvider(ActivityRefDataProvider):
|
||||||
|
"""Generic activity ref data provider class for providers that fetch their data from a local file on startup."""
|
||||||
|
|
||||||
|
def __init__(self, sig_name, provider_config, path):
|
||||||
|
super().__init__(sig_name, provider_config)
|
||||||
|
self._path = path
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
logger.debug(f"Loading {self.sig_name} activity ref data from file.")
|
||||||
|
try:
|
||||||
|
new_data = self._file_to_data(self._path)
|
||||||
|
if new_data:
|
||||||
|
self._add_data(new_data)
|
||||||
|
self.status = "OK"
|
||||||
|
self.last_update_time = datetime.now(pytz.UTC)
|
||||||
|
else:
|
||||||
|
self.status = "Error"
|
||||||
|
logger.info(f"Failed to load activity ref data for {self.sig_name}")
|
||||||
|
except Exception:
|
||||||
|
self.status = "Error"
|
||||||
|
logger.exception(f"Exception in local file Activity Ref Data Provider ({self.sig_name})")
|
||||||
|
|
||||||
|
def _file_to_data(self, path):
|
||||||
|
"""Load a file on the given path and turn it into activity ref data."""
|
||||||
|
|
||||||
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
@@ -1,32 +1,33 @@
|
|||||||
import csv
|
import csv
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType
|
||||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
from data.activity_ref import ActivityRef
|
||||||
FileDownloadSIGRefDataProvider,
|
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||||
|
FileDownloadActivityRefDataProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class MOTA(FileDownloadSIGRefDataProvider):
|
class MOTA(FileDownloadActivityRefDataProvider):
|
||||||
"""SIG ref data provider for Mills on the Air"""
|
"""Activity ref data provider for Mills on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_DAYS = 30
|
POLL_INTERVAL_DAYS = 30
|
||||||
SIG = "MOTA"
|
ACTIVITY = ActivityName.MOTA
|
||||||
DATA_URL = "https://www.gma.rocks/download/mills.csv"
|
DATA_URL = "https://www.gma.rocks/download/mills.csv"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
def _http_response_to_data(self, http_response):
|
def _http_response_to_data(self, http_response):
|
||||||
new_data = []
|
new_data = []
|
||||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
|
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()[1:]):
|
||||||
ref_id = row["Reference"]
|
ref_id = row["Reference"]
|
||||||
new_data.append(
|
new_data.append(
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
sig=self.SIG,
|
sig=self.ACTIVITY,
|
||||||
id=ref_id,
|
id=ref_id,
|
||||||
name=row.get("Name", None),
|
name=row.get("Name", None),
|
||||||
ref_type="Mill",
|
ref_type=ActivityRefType.MILL,
|
||||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||||
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
latitude=float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None,
|
||||||
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
longitude=float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None,
|
||||||
@@ -39,7 +40,7 @@ class MOTA(FileDownloadSIGRefDataProvider):
|
|||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
# is available for other threads e.g. the web server.
|
# is available for other threads e.g. the web server.
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from time import sleep
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from core.enums import ActivityName, ActivityRefType
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
|
from providers.activityrefdata.file_download_activity_ref_data_provider import FileDownloadActivityRefDataProvider
|
||||||
|
|
||||||
|
|
||||||
|
class PGA(FileDownloadActivityRefDataProvider):
|
||||||
|
"""Activity ref data provider for Polish Gmina Award"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 30
|
||||||
|
ACTIVITY = ActivityName.PGA
|
||||||
|
DATA_URL = "http://www.spga.pl/lista_pga2.php"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
|
def _http_response_to_data(self, http_response):
|
||||||
|
new_data = []
|
||||||
|
soup = BeautifulSoup(http_response.text, "html.parser")
|
||||||
|
|
||||||
|
# Iterate through tables in the page
|
||||||
|
for table in soup.find_all("table"):
|
||||||
|
header_cells = table.find_all(["th", "td"], limit=10)
|
||||||
|
header_texts = [c.get_text(strip=True) for c in header_cells]
|
||||||
|
|
||||||
|
# If it has "PGA" and "Nazwa" in the header, it's the main data table
|
||||||
|
if any("PGA" in t for t in header_texts) and any("Nazwa" in t for t in header_texts):
|
||||||
|
# Iterate through all rows except the first
|
||||||
|
rows = table.find_all("tr")
|
||||||
|
for row in rows[1:]:
|
||||||
|
cells = row.find_all("td")
|
||||||
|
ref_id = cells[0].get_text(strip=True)
|
||||||
|
name = cells[1].get_text(strip=True)
|
||||||
|
if not ref_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_data.append(
|
||||||
|
ActivityRef(
|
||||||
|
sig=self.ACTIVITY,
|
||||||
|
id=ref_id,
|
||||||
|
name=name,
|
||||||
|
ref_type=ActivityRefType.REGION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest
|
||||||
|
# of the data in this case
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
|
# is available for other threads e.g. the web server.
|
||||||
|
sleep(0.001)
|
||||||
|
|
||||||
|
return new_data
|
||||||
+10
-9
@@ -4,15 +4,16 @@ from time import sleep
|
|||||||
from fastkml import kml
|
from fastkml import kml
|
||||||
from pyhamtools.locator import latlong_to_locator
|
from pyhamtools.locator import latlong_to_locator
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityRefType
|
||||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
from data.activity_ref import ActivityRef
|
||||||
FileDownloadSIGRefDataProvider,
|
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||||
|
FileDownloadActivityRefDataProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ParksNPeaksKMLSIGRefDataProvider(FileDownloadSIGRefDataProvider):
|
class ParksNPeaksKMLActivityRefDataProvider(FileDownloadActivityRefDataProvider):
|
||||||
"""Base class for SIG ref data providers that use parksnpeaks.org KML POI feeds and have references that use the
|
"""Base class for activity ref data providers that use parksnpeaks.org KML POI feeds and have references that
|
||||||
VKFF refs rather than their own system (i.e. KRMNPA and SANPCPA)."""
|
use the VKFF refs rather than their own system (i.e. KRMNPA and SANPCPA)."""
|
||||||
|
|
||||||
REF_PATTERN = re.compile(r"VKFF-\d+")
|
REF_PATTERN = re.compile(r"VKFF-\d+")
|
||||||
|
|
||||||
@@ -39,11 +40,11 @@ class ParksNPeaksKMLSIGRefDataProvider(FileDownloadSIGRefDataProvider):
|
|||||||
|
|
||||||
longitude, latitude = placemark.geometry.x, placemark.geometry.y
|
longitude, latitude = placemark.geometry.x, placemark.geometry.y
|
||||||
|
|
||||||
ref = SIGRef(
|
ref = ActivityRef(
|
||||||
sig=self.sig_name,
|
sig=self.sig_name,
|
||||||
id=ref_id,
|
id=ref_id,
|
||||||
name=placemark.name,
|
name=placemark.name,
|
||||||
ref_type="Park",
|
ref_type=ActivityRefType.PARK,
|
||||||
url=f"https://parksnpeaks.org/getPark.php?actPark={ref_id}",
|
url=f"https://parksnpeaks.org/getPark.php?actPark={ref_id}",
|
||||||
latitude=latitude,
|
latitude=latitude,
|
||||||
longitude=longitude,
|
longitude=longitude,
|
||||||
@@ -57,7 +58,7 @@ class ParksNPeaksKMLSIGRefDataProvider(FileDownloadSIGRefDataProvider):
|
|||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
return new_data
|
return new_data
|
||||||
|
|
||||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
# is available for other threads e.g. the web server.
|
# is available for other threads e.g. the web server.
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
@@ -1,32 +1,33 @@
|
|||||||
import csv
|
import csv
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType
|
||||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
from data.activity_ref import ActivityRef
|
||||||
FileDownloadSIGRefDataProvider,
|
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||||
|
FileDownloadActivityRefDataProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class POTA(FileDownloadSIGRefDataProvider):
|
class POTA(FileDownloadActivityRefDataProvider):
|
||||||
"""SIG ref data provider for Parks on the Air"""
|
"""Activity ref data provider for Parks on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_DAYS = 7
|
POLL_INTERVAL_DAYS = 7
|
||||||
SIG = "POTA"
|
ACTIVITY = ActivityName.POTA
|
||||||
DATA_URL = "https://pota.app/all_parks_ext.csv"
|
DATA_URL = "https://pota.app/all_parks_ext.csv"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
def _http_response_to_data(self, http_response):
|
def _http_response_to_data(self, http_response):
|
||||||
new_data = []
|
new_data = []
|
||||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
|
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
|
||||||
ref_id = row["reference"]
|
ref_id = row["reference"]
|
||||||
new_data.append(
|
new_data.append(
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
sig=self.SIG,
|
sig=self.ACTIVITY,
|
||||||
id=ref_id,
|
id=ref_id,
|
||||||
name=row.get("name", None),
|
name=row.get("name", None),
|
||||||
ref_type="Park",
|
ref_type=ActivityRefType.PARK,
|
||||||
url=f"https://pota.app/#/park/{ref_id}",
|
url=f"https://pota.app/#/park/{ref_id}",
|
||||||
grid=row.get("grid", None),
|
grid=row.get("grid", None),
|
||||||
latitude=float(row["latitude"]) if "latitude" in row and row["latitude"] != "" else None,
|
latitude=float(row["latitude"]) if "latitude" in row and row["latitude"] != "" else None,
|
||||||
@@ -39,7 +40,7 @@ class POTA(FileDownloadSIGRefDataProvider):
|
|||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
# is available for other threads e.g. the web server.
|
# is available for other threads e.g. the web server.
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from core.enums import ActivityName
|
||||||
|
from providers.activityrefdata.pnp_kml_activity_ref_data_provider import (
|
||||||
|
ParksNPeaksKMLActivityRefDataProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SANPCPA(ParksNPeaksKMLActivityRefDataProvider):
|
||||||
|
"""Activity ref data provider for the South Australia National Parks and Conservation Parks Award (SANPCPA)."""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 365
|
||||||
|
ACTIVITY = ActivityName.SANPCPA
|
||||||
|
DATA_URL = "https://parksnpeaks.org/getPOI.php?poiClass=SANPCPA&poiFormat=4"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
@@ -1,32 +1,33 @@
|
|||||||
import csv
|
import csv
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType
|
||||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
from data.activity_ref import ActivityRef
|
||||||
FileDownloadSIGRefDataProvider,
|
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||||
|
FileDownloadActivityRefDataProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SIOTA(FileDownloadSIGRefDataProvider):
|
class SIOTA(FileDownloadActivityRefDataProvider):
|
||||||
"""SIG ref data provider for Silos on the Air"""
|
"""Activity ref data provider for Silos on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_DAYS = 30
|
POLL_INTERVAL_DAYS = 30
|
||||||
SIG = "SIOTA"
|
ACTIVITY = ActivityName.SIOTA
|
||||||
DATA_URL = "https://www.silosontheair.com/data/silos.csv"
|
DATA_URL = "https://www.silosontheair.com/data/silos.csv"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
def _http_response_to_data(self, http_response):
|
def _http_response_to_data(self, http_response):
|
||||||
new_data = []
|
new_data = []
|
||||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
|
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
|
||||||
ref_id = row["SILO_CODE"]
|
ref_id = row["SILO_CODE"]
|
||||||
new_data.append(
|
new_data.append(
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
sig=self.SIG,
|
sig=self.ACTIVITY,
|
||||||
id=ref_id,
|
id=ref_id,
|
||||||
name=row.get("NAME", None),
|
name=row.get("NAME", None),
|
||||||
ref_type="Silo",
|
ref_type=ActivityRefType.SILO,
|
||||||
grid=row.get("LOCATOR", None),
|
grid=row.get("LOCATOR", None),
|
||||||
latitude=float(row["LAT"]) if "LAT" in row else None,
|
latitude=float(row["LAT"]) if "LAT" in row else None,
|
||||||
longitude=float(row["LNG"]) if "LNG" in row else None,
|
longitude=float(row["LNG"]) if "LNG" in row else None,
|
||||||
@@ -38,7 +39,7 @@ class SIOTA(FileDownloadSIGRefDataProvider):
|
|||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
# is available for other threads e.g. the web server.
|
# is available for other threads e.g. the web server.
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
@@ -3,21 +3,22 @@ from time import sleep
|
|||||||
|
|
||||||
from pyhamtools.locator import latlong_to_locator
|
from pyhamtools.locator import latlong_to_locator
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType
|
||||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
from data.activity_ref import ActivityRef
|
||||||
FileDownloadSIGRefDataProvider,
|
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||||
|
FileDownloadActivityRefDataProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SOTA(FileDownloadSIGRefDataProvider):
|
class SOTA(FileDownloadActivityRefDataProvider):
|
||||||
"""SIG ref data provider for Summits on the Air"""
|
"""Activity ref data provider for Summits on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_DAYS = 30
|
POLL_INTERVAL_DAYS = 30
|
||||||
SIG = "SOTA"
|
ACTIVITY = ActivityName.SOTA
|
||||||
DATA_URL = "https://storage.sota.org.uk/summitslist.csv"
|
DATA_URL = "https://storage.sota.org.uk/summitslist.csv"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
def _http_response_to_data(self, http_response):
|
def _http_response_to_data(self, http_response):
|
||||||
new_data = []
|
new_data = []
|
||||||
@@ -26,11 +27,11 @@ class SOTA(FileDownloadSIGRefDataProvider):
|
|||||||
latitude = float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None
|
latitude = float(row["Latitude"]) if "Latitude" in row and row["Latitude"] != "" else None
|
||||||
longitude = float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None
|
longitude = float(row["Longitude"]) if "Longitude" in row and row["Longitude"] != "" else None
|
||||||
altitude = float(row["AltM"]) if "AltM" in row and row["AltM"] != "" else None
|
altitude = float(row["AltM"]) if "AltM" in row and row["AltM"] != "" else None
|
||||||
ref = SIGRef(
|
ref = ActivityRef(
|
||||||
sig=self.SIG,
|
sig=self.ACTIVITY,
|
||||||
id=ref_id,
|
id=ref_id,
|
||||||
name=row.get("SummitName", None),
|
name=row.get("SummitName", None),
|
||||||
ref_type="Summit",
|
ref_type=ActivityRefType.SUMMIT,
|
||||||
url=f"https://www.sotadata.org.uk/en/summit/{ref_id}",
|
url=f"https://www.sotadata.org.uk/en/summit/{ref_id}",
|
||||||
latitude=latitude,
|
latitude=latitude,
|
||||||
longitude=longitude,
|
longitude=longitude,
|
||||||
@@ -46,7 +47,7 @@ class SOTA(FileDownloadSIGRefDataProvider):
|
|||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
# is available for other threads e.g. the web server.
|
# is available for other threads e.g. the web server.
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
import csv
|
import csv
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType
|
||||||
from providers.sigrefdata.local_file_sig_ref_data_provider import (
|
from data.activity_ref import ActivityRef
|
||||||
LocalFileSIGRefDataProvider,
|
from providers.activityrefdata.local_file_activity_ref_data_provider import (
|
||||||
|
LocalFileActivityRefDataProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Toilets(LocalFileSIGRefDataProvider):
|
class Toilets(LocalFileActivityRefDataProvider):
|
||||||
"""SIG ref data provider for Toilets on the Air"""
|
"""Activity ref data provider for Toilets on the Air"""
|
||||||
|
|
||||||
SIG = "Toilets"
|
ACTIVITY = ActivityName.TOILETS
|
||||||
PATH = "datafiles/toilets.csv"
|
PATH = "datafiles/toilets.csv"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(self.SIG, provider_config, self.PATH)
|
super().__init__(self.ACTIVITY, provider_config, self.PATH)
|
||||||
|
|
||||||
def _file_to_data(self, path):
|
def _file_to_data(self, path):
|
||||||
new_data = []
|
new_data = []
|
||||||
@@ -22,11 +23,11 @@ class Toilets(LocalFileSIGRefDataProvider):
|
|||||||
dr = csv.DictReader(csv_data.splitlines())
|
dr = csv.DictReader(csv_data.splitlines())
|
||||||
for row in dr:
|
for row in dr:
|
||||||
new_data.append(
|
new_data.append(
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
sig=self.SIG,
|
sig=self.ACTIVITY,
|
||||||
id=row["ref"],
|
id=row["ref"],
|
||||||
name=row["ref"],
|
name=row["ref"],
|
||||||
ref_type="Toilet",
|
ref_type=ActivityRefType.TOILET,
|
||||||
latitude=float(row["lat"]),
|
latitude=float(row["lat"]),
|
||||||
longitude=float(row["lon"]),
|
longitude=float(row["lon"]),
|
||||||
)
|
)
|
||||||
@@ -34,7 +35,7 @@ class Toilets(LocalFileSIGRefDataProvider):
|
|||||||
|
|
||||||
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest
|
# Bail out if a stop has been requested, i.e. the program is shutting down - no need to parse the rest
|
||||||
# of the data in this case
|
# of the data in this case
|
||||||
if self._stop:
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
return new_data
|
return new_data
|
||||||
@@ -1,32 +1,33 @@
|
|||||||
import csv
|
import csv
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType
|
||||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
from data.activity_ref import ActivityRef
|
||||||
FileDownloadSIGRefDataProvider,
|
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||||
|
FileDownloadActivityRefDataProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Towers(FileDownloadSIGRefDataProvider):
|
class Towers(FileDownloadActivityRefDataProvider):
|
||||||
"""SIG ref data provider for Towers on the Air"""
|
"""Activity ref data provider for Towers on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_DAYS = 30
|
POLL_INTERVAL_DAYS = 30
|
||||||
SIG = "Towers"
|
ACTIVITY = ActivityName.TOWERS
|
||||||
DATA_URL = "https://wwtota.com/servis/generate_csv.php?ref=&filter=all"
|
DATA_URL = "https://wwtota.com/servis/generate_csv.php?ref=&filter=all"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
def _http_response_to_data(self, http_response):
|
def _http_response_to_data(self, http_response):
|
||||||
new_data = []
|
new_data = []
|
||||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"):
|
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines(), delimiter=";"):
|
||||||
ref_id = row["Ref"]
|
ref_id = row["Ref"]
|
||||||
new_data.append(
|
new_data.append(
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
sig=self.SIG,
|
sig=self.ACTIVITY,
|
||||||
id=ref_id,
|
id=ref_id,
|
||||||
name=row.get("Nazev", None),
|
name=row.get("Nazev", None),
|
||||||
ref_type="Tower",
|
ref_type=ActivityRefType.TOWER,
|
||||||
url=f"https://wwtota.com/seznam/karta_rozhledny.php?ref={ref_id}",
|
url=f"https://wwtota.com/seznam/karta_rozhledny.php?ref={ref_id}",
|
||||||
grid=row["Lokator"] if "Lokator" in row and row["Lokator"] != "" else None,
|
grid=row["Lokator"] if "Lokator" in row and row["Lokator"] != "" else None,
|
||||||
latitude=float(row["Lat"]) if "Lat" in row and row["Lat"] != "" else None,
|
latitude=float(row["Lat"]) if "Lat" in row and row["Lat"] != "" else None,
|
||||||
@@ -39,7 +40,7 @@ class Towers(FileDownloadSIGRefDataProvider):
|
|||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
# is available for other threads e.g. the web server.
|
# is available for other threads e.g. the web server.
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
@@ -4,23 +4,24 @@ from time import sleep
|
|||||||
|
|
||||||
from pyhamtools.locator import latlong_to_locator
|
from pyhamtools.locator import latlong_to_locator
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType
|
||||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
from data.activity_ref import ActivityRef
|
||||||
FileDownloadSIGRefDataProvider,
|
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||||
|
FileDownloadActivityRefDataProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class WCA(FileDownloadSIGRefDataProvider):
|
class WCA(FileDownloadActivityRefDataProvider):
|
||||||
"""SIG ref data provider for World Castles Award"""
|
"""Activity ref data provider for World Castles Award"""
|
||||||
|
|
||||||
POLL_INTERVAL_DAYS = 30
|
POLL_INTERVAL_DAYS = 30
|
||||||
SIG = "WCA"
|
ACTIVITY = ActivityName.WCA
|
||||||
DATA_URL = "https://polo.ham2k.com/data/activities/wca/all-castles.csv"
|
DATA_URL = "https://polo.ham2k.com/data/activities/wca/all-castles.csv"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
def _http_response_to_data(self, http_response):
|
def _http_response_to_data(self, http_response):
|
||||||
new_data = []
|
new_data = []
|
||||||
@@ -41,11 +42,11 @@ class WCA(FileDownloadSIGRefDataProvider):
|
|||||||
logger.debug(f"Encountered dodgy formatting in WCA CSV, skipping location data for {ref_id}")
|
logger.debug(f"Encountered dodgy formatting in WCA CSV, skipping location data for {ref_id}")
|
||||||
|
|
||||||
new_data.append(
|
new_data.append(
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
sig=self.SIG,
|
sig=self.ACTIVITY,
|
||||||
id=ref_id,
|
id=ref_id,
|
||||||
name=row.get("CLEAN NAME", None),
|
name=row.get("CLEAN NAME", None),
|
||||||
ref_type="Castle",
|
ref_type=ActivityRefType.CASTLE,
|
||||||
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
url=f"https://www.cqgma.org/zinfo.php?ref={ref_id}",
|
||||||
latitude=latitude,
|
latitude=latitude,
|
||||||
longitude=longitude,
|
longitude=longitude,
|
||||||
@@ -58,7 +59,7 @@ class WCA(FileDownloadSIGRefDataProvider):
|
|||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
# is available for other threads e.g. the web server.
|
# is available for other threads e.g. the web server.
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType
|
||||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
from data.activity_ref import ActivityRef
|
||||||
FileDownloadSIGRefDataProvider,
|
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||||
|
FileDownloadActivityRefDataProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class WOTA(FileDownloadSIGRefDataProvider):
|
class WOTA(FileDownloadActivityRefDataProvider):
|
||||||
"""SIG ref data provider for Wainwrights on the Air"""
|
"""Activity ref data provider for Wainwrights on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_DAYS = 365
|
POLL_INTERVAL_DAYS = 365
|
||||||
SIG = "WOTA"
|
ACTIVITY = ActivityName.WOTA
|
||||||
DATA_URL = "https://www.wota.org.uk/mapping/data/summits.json"
|
DATA_URL = "https://www.wota.org.uk/mapping/data/summits.json"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
def _http_response_to_data(self, http_response):
|
def _http_response_to_data(self, http_response):
|
||||||
new_data = []
|
new_data = []
|
||||||
@@ -28,12 +29,12 @@ class WOTA(FileDownloadSIGRefDataProvider):
|
|||||||
url = f"https://www.wota.org.uk/MM_LDO-{number + 214!s}"
|
url = f"https://www.wota.org.uk/MM_LDO-{number + 214!s}"
|
||||||
|
|
||||||
new_data.append(
|
new_data.append(
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
sig=self.SIG,
|
sig=self.ACTIVITY,
|
||||||
id=ref_id,
|
id=ref_id,
|
||||||
name=feature["properties"]["title"],
|
name=feature["properties"]["title"],
|
||||||
url=url,
|
url=url,
|
||||||
ref_type="Summit",
|
ref_type=ActivityRefType.SUMMIT,
|
||||||
grid=feature["properties"]["qthLocator"],
|
grid=feature["properties"]["qthLocator"],
|
||||||
latitude=feature["geometry"]["coordinates"][1],
|
latitude=feature["geometry"]["coordinates"][1],
|
||||||
longitude=feature["geometry"]["coordinates"][0],
|
longitude=feature["geometry"]["coordinates"][0],
|
||||||
@@ -46,7 +47,7 @@ class WOTA(FileDownloadSIGRefDataProvider):
|
|||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
# is available for other threads e.g. the web server.
|
# is available for other threads e.g. the web server.
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
@@ -1,32 +1,33 @@
|
|||||||
import csv
|
import csv
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType
|
||||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
from data.activity_ref import ActivityRef
|
||||||
FileDownloadSIGRefDataProvider,
|
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||||
|
FileDownloadActivityRefDataProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class WWBOTA(FileDownloadSIGRefDataProvider):
|
class WWBOTA(FileDownloadActivityRefDataProvider):
|
||||||
"""SIG ref data provider for Worldwide Bunkers on the Air"""
|
"""Activity ref data provider for Worldwide Bunkers on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_DAYS = 30
|
POLL_INTERVAL_DAYS = 30
|
||||||
SIG = "WWBOTA"
|
ACTIVITY = ActivityName.WWBOTA
|
||||||
DATA_URL = "https://api.wwbota.org/bunkers/?format=CSV"
|
DATA_URL = "https://api.wwbota.org/bunkers/?format=CSV"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
def _http_response_to_data(self, http_response):
|
def _http_response_to_data(self, http_response):
|
||||||
new_data = []
|
new_data = []
|
||||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
|
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
|
||||||
ref_id = row["Reference"]
|
ref_id = row["Reference"]
|
||||||
new_data.append(
|
new_data.append(
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
sig=self.SIG,
|
sig=self.ACTIVITY,
|
||||||
id=ref_id,
|
id=ref_id,
|
||||||
name=row.get("Name", None),
|
name=row.get("Name", None),
|
||||||
ref_type="Bunker",
|
ref_type=ActivityRefType.BUNKER,
|
||||||
url=f"https://bunkerwiki.org/?s={ref_id}" if ref_id.startswith("B/G") else None,
|
url=f"https://bunkerwiki.org/?s={ref_id}" if ref_id.startswith("B/G") else None,
|
||||||
grid=row["Locator"] if "Locator" in row and row["Locator"] != "" else None,
|
grid=row["Locator"] if "Locator" in row and row["Locator"] != "" else None,
|
||||||
latitude=float(row["Lat"]) if "Lat" in row and row["Lat"] != "" else None,
|
latitude=float(row["Lat"]) if "Lat" in row and row["Lat"] != "" else None,
|
||||||
@@ -39,7 +40,7 @@ class WWBOTA(FileDownloadSIGRefDataProvider):
|
|||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
# is available for other threads e.g. the web server.
|
# is available for other threads e.g. the web server.
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
@@ -1,32 +1,33 @@
|
|||||||
import csv
|
import csv
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType
|
||||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
from data.activity_ref import ActivityRef
|
||||||
FileDownloadSIGRefDataProvider,
|
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||||
|
FileDownloadActivityRefDataProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class WWFF(FileDownloadSIGRefDataProvider):
|
class WWFF(FileDownloadActivityRefDataProvider):
|
||||||
"""SIG ref data provider for Worldwide Flora & Fauna"""
|
"""Activity ref data provider for Worldwide Flora & Fauna"""
|
||||||
|
|
||||||
POLL_INTERVAL_DAYS = 30
|
POLL_INTERVAL_DAYS = 30
|
||||||
SIG = "WWFF"
|
ACTIVITY = ActivityName.WWFF
|
||||||
DATA_URL = "https://wwff.co/wwff-data/wwff_directory.csv"
|
DATA_URL = "https://wwff.co/wwff-data/wwff_directory.csv"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
def _http_response_to_data(self, http_response):
|
def _http_response_to_data(self, http_response):
|
||||||
new_data = []
|
new_data = []
|
||||||
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
|
for row in csv.DictReader(http_response.content.decode("utf-8-sig").splitlines()):
|
||||||
ref_id = row["reference"]
|
ref_id = row["reference"]
|
||||||
new_data.append(
|
new_data.append(
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
sig=self.SIG,
|
sig=self.ACTIVITY,
|
||||||
id=ref_id,
|
id=ref_id,
|
||||||
name=row.get("name", None),
|
name=row.get("name", None),
|
||||||
ref_type="Park",
|
ref_type=ActivityRefType.PARK,
|
||||||
url=f"https://wwff.co/directory/?showRef={ref_id}",
|
url=f"https://wwff.co/directory/?showRef={ref_id}",
|
||||||
grid=row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None,
|
grid=row["iaruLocator"] if "iaruLocator" in row and row["iaruLocator"] != "-" else None,
|
||||||
latitude=float(row["latitude"])
|
latitude=float(row["latitude"])
|
||||||
@@ -43,7 +44,7 @@ class WWFF(FileDownloadSIGRefDataProvider):
|
|||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
# is available for other threads e.g. the web server.
|
# is available for other threads e.g. the web server.
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
@@ -2,21 +2,22 @@ from time import sleep
|
|||||||
|
|
||||||
from pyhamtools.locator import latlong_to_locator
|
from pyhamtools.locator import latlong_to_locator
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType
|
||||||
from providers.sigrefdata.file_download_sig_ref_data_provider import (
|
from data.activity_ref import ActivityRef
|
||||||
FileDownloadSIGRefDataProvider,
|
from providers.activityrefdata.file_download_activity_ref_data_provider import (
|
||||||
|
FileDownloadActivityRefDataProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ZLOTA(FileDownloadSIGRefDataProvider):
|
class ZLOTA(FileDownloadActivityRefDataProvider):
|
||||||
"""SIG ref data provider for New Zealand on the Air"""
|
"""Activity ref data provider for New Zealand on the Air"""
|
||||||
|
|
||||||
POLL_INTERVAL_DAYS = 30
|
POLL_INTERVAL_DAYS = 30
|
||||||
SIG = "ZLOTA"
|
ACTIVITY = ActivityName.ZLOTA
|
||||||
DATA_URL = "https://ontheair.nz/assets/assets.json"
|
DATA_URL = "https://ontheair.nz/assets/assets.json"
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
super().__init__(self.ACTIVITY, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
||||||
|
|
||||||
def _http_response_to_data(self, http_response):
|
def _http_response_to_data(self, http_response):
|
||||||
new_data = []
|
new_data = []
|
||||||
@@ -26,12 +27,16 @@ class ZLOTA(FileDownloadSIGRefDataProvider):
|
|||||||
ref_id = ref["code"]
|
ref_id = ref["code"]
|
||||||
latitude = ref["latitude"]
|
latitude = ref["latitude"]
|
||||||
longitude = ref["longitude"]
|
longitude = ref["longitude"]
|
||||||
|
try:
|
||||||
|
ref_type = ActivityRefType(ref["asset_type"].title().upper())
|
||||||
|
except ValueError:
|
||||||
|
ref_type = None
|
||||||
|
|
||||||
new_ref = SIGRef(
|
new_ref = ActivityRef(
|
||||||
sig=self.SIG,
|
sig=self.ACTIVITY,
|
||||||
id=ref_id,
|
id=ref_id,
|
||||||
name=ref["name"],
|
name=ref["name"],
|
||||||
ref_type=ref["asset_type"].title(),
|
ref_type=ref_type,
|
||||||
url=f"https://ontheair.nz/assets/{ref_id.replace('/', '_')}",
|
url=f"https://ontheair.nz/assets/{ref_id.replace('/', '_')}",
|
||||||
latitude=latitude,
|
latitude=latitude,
|
||||||
longitude=longitude,
|
longitude=longitude,
|
||||||
@@ -53,7 +58,7 @@ class ZLOTA(FileDownloadSIGRefDataProvider):
|
|||||||
if self._stop_event.is_set():
|
if self._stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Very short pause. This will extend the time to handle sig refs by a few seconds but will ensure some time
|
# Very short pause. This will extend the time to handle activity refs by a few seconds but will ensure some time
|
||||||
# is available for other threads e.g. the web server.
|
# is available for other threads e.g. the web server.
|
||||||
sleep(0.001)
|
sleep(0.001)
|
||||||
|
|
||||||
@@ -3,8 +3,9 @@ from datetime import datetime, timedelta
|
|||||||
import pytz
|
import pytz
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from core.enums import ActivityName
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
from data.alert import Alert
|
from data.alert import Alert
|
||||||
from data.sig_ref import SIGRef
|
|
||||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||||
|
|
||||||
|
|
||||||
@@ -55,9 +56,9 @@ class BOTA(HTTPAlertProvider):
|
|||||||
alert = Alert(
|
alert = Alert(
|
||||||
source=self.name,
|
source=self.name,
|
||||||
dx_calls=[dx_call],
|
dx_calls=[dx_call],
|
||||||
sig_refs=[SIGRef(id=ref_name, sig="BOTA")],
|
sig=ActivityName.BOTA,
|
||||||
|
sig_refs=[ActivityRef(id=ref_name, sig=ActivityName.BOTA)],
|
||||||
start_time=date_time.timestamp(),
|
start_time=date_time.timestamp(),
|
||||||
is_dxpedition=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
new_alerts.append(alert)
|
new_alerts.append(alert)
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from core.enums import ActivityName
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
|
from data.alert import Alert
|
||||||
|
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||||
|
|
||||||
|
|
||||||
|
class Hamsat(HTTPAlertProvider):
|
||||||
|
"""Alert provider for Hamsat (hams.at)"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_SEC = 1800
|
||||||
|
ALERTS_URL = "https://hams.at/api/alerts"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__("Hamsat", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
|
def _http_response_to_alerts(self, http_response):
|
||||||
|
new_alerts = []
|
||||||
|
# Iterate through source data
|
||||||
|
for source_alert in http_response.json()["data"]:
|
||||||
|
# Convert to our alert format
|
||||||
|
freqs_modes = source_alert.get("mode", "")
|
||||||
|
if "mhz" in source_alert:
|
||||||
|
if "mhz_direction" in source_alert:
|
||||||
|
freqs_modes = f"{source_alert['mhz']!s} {source_alert['mhz_direction']}, {freqs_modes}"
|
||||||
|
else:
|
||||||
|
freqs_modes = f"{source_alert['mhz']!s}, {freqs_modes}"
|
||||||
|
|
||||||
|
alert = Alert(
|
||||||
|
source=self.name,
|
||||||
|
source_id=source_alert["id"],
|
||||||
|
dx_calls=[source_alert["callsign"].upper()],
|
||||||
|
freqs_modes=freqs_modes,
|
||||||
|
comment=source_alert["comment"],
|
||||||
|
sig=ActivityName.SATELLITE,
|
||||||
|
# Fudge an activity ref to provide the remaining bits of data we need: the satellite and the operator's grid
|
||||||
|
sig_refs=[
|
||||||
|
ActivityRef(
|
||||||
|
sig=ActivityName.SATELLITE,
|
||||||
|
id=f"{source_alert['satellite']['name']} from {source_alert['grids'][0]}",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
start_time=datetime.strptime(source_alert["aos_at"], "%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
.replace(tzinfo=pytz.UTC)
|
||||||
|
.timestamp(),
|
||||||
|
end_time=datetime.strptime(source_alert["los_at"], "%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
.replace(tzinfo=pytz.UTC)
|
||||||
|
.timestamp(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add to our list
|
||||||
|
new_alerts.append(alert)
|
||||||
|
return new_alerts
|
||||||
@@ -4,7 +4,7 @@ from threading import Event, Thread
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
from requests.exceptions import ConnectionError, ConnectTimeout, JSONDecodeError, ReadTimeout
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from providers.alert.alert_provider import AlertProvider
|
from providers.alert.alert_provider import AlertProvider
|
||||||
@@ -27,11 +27,15 @@ class HTTPAlertProvider(AlertProvider):
|
|||||||
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
||||||
# subsequent polls, so start() returns immediately and the application can continue starting.
|
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||||
logger.info(f"Set up query of {self.name} alert API every {self._poll_interval!s} seconds.")
|
logger.info(f"Set up query of {self.name} alert API every {self._poll_interval!s} seconds.")
|
||||||
self._thread = Thread(target=self._run, name=f"HTTPAlertProvider-{self.name}")
|
self._thread = Thread(target=self._run, name=f"HTTPAlertProvider-{self.name}", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=12)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning(f"{self.name} alert worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while True:
|
while True:
|
||||||
@@ -60,9 +64,14 @@ class HTTPAlertProvider(AlertProvider):
|
|||||||
logger.warning(f"HTTP {http_response.status_code} when calling {self.name} alerts API.")
|
logger.warning(f"HTTP {http_response.status_code} when calling {self.name} alerts API.")
|
||||||
|
|
||||||
except ConnectionError:
|
except ConnectionError:
|
||||||
|
self.status = "Error"
|
||||||
logger.warning(f"Connection error when accessing {self.name} alerts API.")
|
logger.warning(f"Connection error when accessing {self.name} alerts API.")
|
||||||
except (ConnectTimeout, ReadTimeout):
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
self.status = "Error"
|
||||||
logger.warning(f"Timeout when accessing {self.name} alerts API.")
|
logger.warning(f"Timeout when accessing {self.name} alerts API.")
|
||||||
|
except JSONDecodeError:
|
||||||
|
self.status = "Error"
|
||||||
|
logger.warning(f"Invalid or empty JSON response from {self.name} alert API.")
|
||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logger.exception(f"Exception in HTTP JSON Alert Provider ({self.name})")
|
logger.exception(f"Exception in HTTP JSON Alert Provider ({self.name})")
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from datetime import datetime, time
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from icalendar import Calendar, Event
|
||||||
|
|
||||||
|
from data.alert import Alert
|
||||||
|
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||||
|
|
||||||
|
|
||||||
|
class ICALAlertProvider(HTTPAlertProvider):
|
||||||
|
"""Generic alert provider for iCal calendars. Defines an abstract method event_to_alert(event) that subclasses must
|
||||||
|
implement, and use it to convert an iCal event to an Alert object based on whatever format their iCal events use."""
|
||||||
|
|
||||||
|
def __init__(self, name, provider_config, url, poll_interval):
|
||||||
|
super().__init__(name, provider_config, url, poll_interval)
|
||||||
|
|
||||||
|
def _http_response_to_alerts(self, http_response):
|
||||||
|
new_alerts = []
|
||||||
|
cal = Calendar.from_ical(http_response.content)
|
||||||
|
|
||||||
|
# Iterate through events, passing each one in turn to the subclass' event_to_alert method to turn it into
|
||||||
|
# a Spothole alert object
|
||||||
|
for component in cal.walk():
|
||||||
|
if component.name != "VEVENT":
|
||||||
|
continue
|
||||||
|
|
||||||
|
event = cast(Event, component)
|
||||||
|
alert = self.event_to_alert(event)
|
||||||
|
new_alerts.append(alert)
|
||||||
|
return new_alerts
|
||||||
|
|
||||||
|
def event_to_alert(self, event: Event) -> Alert:
|
||||||
|
"""Convert an ICal event to an Alert object. Subclasses must implement this method."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_utc_timestamp(value):
|
||||||
|
"""Convert a date or datetime value from an iCal field into a UTC UNIX timestamp."""
|
||||||
|
|
||||||
|
# Datetime object so we can treat it as-is, check if it has a non-UTC tz and convert it if necessary
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = pytz.UTC.localize(value)
|
||||||
|
return value.astimezone(pytz.UTC).timestamp()
|
||||||
|
|
||||||
|
# Date object so this is an all day event
|
||||||
|
return pytz.UTC.localize(datetime.combine(value, time.min)).timestamp()
|
||||||
@@ -6,19 +6,20 @@ import pytz
|
|||||||
from rss_parser import Parser
|
from rss_parser import Parser
|
||||||
from rss_parser.models.rss import RSS
|
from rss_parser.models.rss import RSS
|
||||||
|
|
||||||
|
from core.enums import ActivityName
|
||||||
from data.alert import Alert
|
from data.alert import Alert
|
||||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||||
|
|
||||||
|
|
||||||
class NG3K(HTTPAlertProvider):
|
class NG3K(HTTPAlertProvider):
|
||||||
"""Alert provider NG3K DXpedition list"""
|
"""Alert provider for NG3K DXpedition list"""
|
||||||
|
|
||||||
POLL_INTERVAL_SEC = 1800
|
POLL_INTERVAL_DAYS = 1
|
||||||
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)
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("NG3K", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
|
||||||
|
|
||||||
def _http_response_to_alerts(self, http_response):
|
def _http_response_to_alerts(self, http_response):
|
||||||
new_alerts = []
|
new_alerts = []
|
||||||
@@ -88,7 +89,7 @@ class NG3K(HTTPAlertProvider):
|
|||||||
comment=f"{by}; {comment}; {qsl_info}",
|
comment=f"{by}; {comment}; {qsl_info}",
|
||||||
start_time=start_timestamp,
|
start_time=start_timestamp,
|
||||||
end_time=end_timestamp,
|
end_time=end_timestamp,
|
||||||
is_dxpedition=True,
|
sig=ActivityName.DXPEDITION,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add to our list.
|
# Add to our list.
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ from datetime import datetime
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
|
from core.enums import ActivityName
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
from data.alert import Alert
|
from data.alert import Alert
|
||||||
from data.sig_ref import SIGRef
|
|
||||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -24,23 +25,19 @@ 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"].upper()
|
activity = source_alert["Class"].upper()
|
||||||
if " - " in source_alert["Location"]:
|
if " - " in source_alert["Location"]:
|
||||||
split = source_alert["Location"].split(" - ")
|
split = source_alert["Location"].split(" - ")
|
||||||
sig_ref = split[0]
|
ref_id = split[0]
|
||||||
sig_ref_name = split[1]
|
ref_name = split[1]
|
||||||
else:
|
else:
|
||||||
sig_ref = source_alert["WWFFID"]
|
ref_id = source_alert["WWFFID"]
|
||||||
sig_ref_name = source_alert["Location"]
|
ref_name = source_alert["Location"]
|
||||||
start_time = (
|
start_time = (
|
||||||
datetime.strptime(source_alert["alTime"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp()
|
datetime.strptime(source_alert["alTime"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp()
|
||||||
)
|
)
|
||||||
|
|
||||||
sigrefs = []
|
activity_refs = [ActivityRef(id=ref_id, sig=activity, name=ref_name)]
|
||||||
# PnP can give us an alert of class "QRP" which is the only one that's not a real SIG in Spothole's list,
|
|
||||||
# so mask this out if we got it.
|
|
||||||
if sig != "QRP":
|
|
||||||
sigrefs = [SIGRef(id=sig_ref, sig=sig, name=sig_ref_name)]
|
|
||||||
|
|
||||||
# Convert to our alert format
|
# Convert to our alert format
|
||||||
alert = Alert(
|
alert = Alert(
|
||||||
@@ -49,28 +46,29 @@ class ParksNPeaks(HTTPAlertProvider):
|
|||||||
dx_calls=[source_alert["CallSign"].upper()],
|
dx_calls=[source_alert["CallSign"].upper()],
|
||||||
freqs_modes=f"{source_alert['Freq']} {source_alert['MODE']}",
|
freqs_modes=f"{source_alert['Freq']} {source_alert['MODE']}",
|
||||||
comment=source_alert["Comments"],
|
comment=source_alert["Comments"],
|
||||||
sig_refs=sigrefs,
|
sig=activity,
|
||||||
|
sig_refs=activity_refs,
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
is_dxpedition=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before
|
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before
|
||||||
if sig and sig not in [
|
if activity and activity not in [
|
||||||
"POTA",
|
ActivityName.POTA,
|
||||||
"SOTA",
|
ActivityName.SOTA,
|
||||||
"WWFF",
|
ActivityName.WWFF,
|
||||||
"SIOTA",
|
ActivityName.HEMA,
|
||||||
"ZLOTA",
|
ActivityName.SIOTA,
|
||||||
"KRMNPA",
|
ActivityName.ZLOTA,
|
||||||
"SANPCPA",
|
ActivityName.KRMNPA,
|
||||||
"LLOTA",
|
ActivityName.SANPCPA,
|
||||||
"QRP",
|
ActivityName.LLOTA,
|
||||||
|
ActivityName.QRP,
|
||||||
]:
|
]:
|
||||||
logger.warning(f"PNP alert found with sig {sig}, developer needs to add support for this!")
|
logger.warning(f"PNP alert found with activity {activity}, 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. Note that while ZLOTA has its own spots API, it doesn't have its own alerts API. So that
|
# the alert list. Note that while ZLOTA has its own spots API, it doesn't have its own alerts API. So that
|
||||||
# means the PnP *spot* provider rejects ZLOTA spots here, but the PnP *alerts* provider here allows ZLOTA.
|
# means the PnP *spot* provider rejects ZLOTA spots here, but the PnP *alerts* provider here allows ZLOTA.
|
||||||
if sig not in ["POTA", "SOTA", "WWFF"]:
|
if activity not in [ActivityName.POTA, ActivityName.SOTA, ActivityName.WWFF]:
|
||||||
new_alerts.append(alert)
|
new_alerts.append(alert)
|
||||||
return new_alerts
|
return new_alerts
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ from datetime import datetime
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
|
from core.enums import ActivityName
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
from data.alert import Alert
|
from data.alert import Alert
|
||||||
from data.sig_ref import SIGRef
|
|
||||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||||
|
|
||||||
|
|
||||||
@@ -27,10 +28,11 @@ 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=ActivityName.POTA,
|
||||||
sig_refs=[
|
sig_refs=[
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
id=source_alert["reference"],
|
id=source_alert["reference"],
|
||||||
sig="POTA",
|
sig=ActivityName.POTA,
|
||||||
name=source_alert["name"],
|
name=source_alert["name"],
|
||||||
url=f"https://pota.app/#/park/{source_alert['reference']}",
|
url=f"https://pota.app/#/park/{source_alert['reference']}",
|
||||||
)
|
)
|
||||||
@@ -44,7 +46,6 @@ class POTA(HTTPAlertProvider):
|
|||||||
end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"], "%Y-%m-%d%H:%M")
|
end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"], "%Y-%m-%d%H:%M")
|
||||||
.replace(tzinfo=pytz.UTC)
|
.replace(tzinfo=pytz.UTC)
|
||||||
.timestamp(),
|
.timestamp(),
|
||||||
is_dxpedition=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add to our list, but exclude any old spots that POTA can sometimes give us where even the end time is
|
# Add to our list, but exclude any old spots that POTA can sometimes give us where even the end time is
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from icalendar import Event
|
||||||
|
|
||||||
|
from core.enums import ActivityName, Continent
|
||||||
|
from data.alert import Alert
|
||||||
|
from providers.alert.ical_alert_provider import ICALAlertProvider
|
||||||
|
|
||||||
|
|
||||||
|
class RSGBICALAlertProvider(ICALAlertProvider):
|
||||||
|
"""Generic alert provider for RSGB contest iCal calendars. Builds on the generic iCal alert provider by adding
|
||||||
|
handling specific to how RSGB's iCal events are formatted. This is still effectively an abstract class itself;
|
||||||
|
RSGB has two contest calendars (HF & VHF) that each subclass this."""
|
||||||
|
|
||||||
|
def __init__(self, name, provider_config, url, poll_interval):
|
||||||
|
super().__init__(name, provider_config, url, poll_interval)
|
||||||
|
|
||||||
|
FREQ_PATTERN = re.compile(r"([\d.]+(?:MHz|GHz))|SHF")
|
||||||
|
|
||||||
|
def event_to_alert(self, event: Event) -> Alert:
|
||||||
|
"""Convert an iCal event in RSGB's format to an Alert object."""
|
||||||
|
|
||||||
|
summary = str(event.get("summary", "")).strip()
|
||||||
|
|
||||||
|
# Ensure summaries start with "RSGB" to avoid any confusion
|
||||||
|
if not summary.startswith("RSGB "):
|
||||||
|
summary = "RSGB " + summary
|
||||||
|
|
||||||
|
# Extract freq from summary. HF contests don't give frequencies, all VHF ones do
|
||||||
|
match = self.FREQ_PATTERN.search(summary)
|
||||||
|
if match:
|
||||||
|
freqs_modes = match.group()
|
||||||
|
else:
|
||||||
|
freqs_modes = "HF bands"
|
||||||
|
freqs_modes = freqs_modes + ", "
|
||||||
|
|
||||||
|
# Extract mode from summary
|
||||||
|
if "FMAC" in summary:
|
||||||
|
freqs_modes = freqs_modes + "FM"
|
||||||
|
elif "CW" in summary:
|
||||||
|
freqs_modes = freqs_modes + "CW"
|
||||||
|
elif "SSB" in summary:
|
||||||
|
freqs_modes = freqs_modes + "SSB"
|
||||||
|
elif "FT8" in summary:
|
||||||
|
freqs_modes = freqs_modes + "FT8"
|
||||||
|
elif "FT4" in summary:
|
||||||
|
freqs_modes = freqs_modes + "FT4"
|
||||||
|
elif "DATA" in summary:
|
||||||
|
freqs_modes = freqs_modes + "Data modes"
|
||||||
|
else:
|
||||||
|
freqs_modes = freqs_modes + "All modes"
|
||||||
|
|
||||||
|
dtstart = event.get("dtstart")
|
||||||
|
dtend = event.get("dtend")
|
||||||
|
start_timestamp = self._to_utc_timestamp(dtstart.dt)
|
||||||
|
end_timestamp = self._to_utc_timestamp(dtend.dt) - 1 if dtend is not None else start_timestamp
|
||||||
|
|
||||||
|
# Convert to our alert format
|
||||||
|
alert = Alert(
|
||||||
|
source=self.name,
|
||||||
|
dx_calls=[],
|
||||||
|
dx_country="United Kingdom",
|
||||||
|
dx_dxcc_id=235,
|
||||||
|
dx_continent=Continent.EU,
|
||||||
|
dx_cq_zone=14,
|
||||||
|
dx_itu_zone=27,
|
||||||
|
dx_flag="🇬🇧",
|
||||||
|
freqs_modes=freqs_modes,
|
||||||
|
comment=summary,
|
||||||
|
start_time=start_timestamp,
|
||||||
|
end_time=end_timestamp,
|
||||||
|
sig=ActivityName.CONTEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
return alert
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from providers.alert.rsgb_ical_alert_provider import RSGBICALAlertProvider
|
||||||
|
|
||||||
|
|
||||||
|
class RSGBHFContests(RSGBICALAlertProvider):
|
||||||
|
"""Alert provider for RSGB HF Contest calendar"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 30
|
||||||
|
ALERTS_URL = "https://calendar.google.com/calendar/ical/a5ff31ebb1b4834dc7fff4c5415ae8251c6a9aa11f98c6af6e472b6c552b1915%40group.calendar.google.com/public/basic.ics"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__("RSGB HF Contests", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from providers.alert.rsgb_ical_alert_provider import RSGBICALAlertProvider
|
||||||
|
|
||||||
|
|
||||||
|
class RSGBVHFContests(RSGBICALAlertProvider):
|
||||||
|
"""Alert provider for RSGB VHF Contest calendar"""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 30
|
||||||
|
ALERTS_URL = "https://calendar.google.com/calendar/ical/40f3552bff39a016f1cdca205864177070dcad68d55be17eb061cb021f39f96c%40group.calendar.google.com/public/basic.ics"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__("RSGB VHF Contests", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60)
|
||||||
@@ -2,8 +2,9 @@ from datetime import datetime
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
|
from core.enums import ActivityName
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
from data.alert import Alert
|
from data.alert import Alert
|
||||||
from data.sig_ref import SIGRef
|
|
||||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||||
|
|
||||||
|
|
||||||
@@ -33,10 +34,11 @@ class SOTA(HTTPAlertProvider):
|
|||||||
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=ActivityName.SOTA,
|
||||||
sig_refs=[
|
sig_refs=[
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
id=f"{source_alert['associationCode']}/{source_alert['summitCode']}",
|
id=f"{source_alert['associationCode']}/{source_alert['summitCode']}",
|
||||||
sig="SOTA",
|
sig=ActivityName.SOTA,
|
||||||
name=summit_name,
|
name=summit_name,
|
||||||
activation_score=summit_points,
|
activation_score=summit_points,
|
||||||
)
|
)
|
||||||
@@ -44,7 +46,6 @@ class SOTA(HTTPAlertProvider):
|
|||||||
start_time=datetime.strptime(source_alert["dateActivated"], "%Y-%m-%dT%H:%M:%SZ")
|
start_time=datetime.strptime(source_alert["dateActivated"], "%Y-%m-%dT%H:%M:%SZ")
|
||||||
.replace(tzinfo=pytz.UTC)
|
.replace(tzinfo=pytz.UTC)
|
||||||
.timestamp(),
|
.timestamp(),
|
||||||
is_dxpedition=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add to our list
|
# Add to our list
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from icalendar import Event
|
||||||
|
|
||||||
|
from core.enums import ActivityName
|
||||||
|
from data.alert import Alert
|
||||||
|
from providers.alert.ical_alert_provider import ICALAlertProvider
|
||||||
|
|
||||||
|
|
||||||
|
class WA7BNM(ICALAlertProvider):
|
||||||
|
"""Alert provider for the WA7BNM contest calendar."""
|
||||||
|
|
||||||
|
POLL_INTERVAL_DAYS = 1
|
||||||
|
ALERTS_URL = "https://contestcalendar.com/weeklycontcustom.php"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(
|
||||||
|
"WA7BNM Contest Calendar", provider_config, self.ALERTS_URL, self.POLL_INTERVAL_DAYS * 24 * 60 * 60
|
||||||
|
)
|
||||||
|
|
||||||
|
def event_to_alert(self, event: Event) -> Alert:
|
||||||
|
"""Convert an iCal event in WA7BNM's format to an Alert object."""
|
||||||
|
|
||||||
|
summary = str(event.get("summary", ""))
|
||||||
|
url = str(event.get("url", ""))
|
||||||
|
|
||||||
|
dtstart = event.get("dtstart")
|
||||||
|
dtend = event.get("dtend")
|
||||||
|
start_timestamp = self._to_utc_timestamp(dtstart.dt)
|
||||||
|
end_timestamp = self._to_utc_timestamp(dtend.dt) - 1 if dtend is not None else start_timestamp
|
||||||
|
|
||||||
|
# Convert to our alert format
|
||||||
|
alert = Alert(
|
||||||
|
source=self.name,
|
||||||
|
dx_calls=[],
|
||||||
|
comment=summary,
|
||||||
|
url=url,
|
||||||
|
start_time=start_timestamp,
|
||||||
|
end_time=end_timestamp,
|
||||||
|
sig=ActivityName.CONTEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
return alert
|
||||||
@@ -7,8 +7,9 @@ import pytz
|
|||||||
from rss_parser import Parser as RSSParser
|
from rss_parser import Parser as RSSParser
|
||||||
from rss_parser.models.rss import RSS
|
from rss_parser.models.rss import RSS
|
||||||
|
|
||||||
|
from core.enums import ActivityName
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
from data.alert import Alert
|
from data.alert import Alert
|
||||||
from data.sig_ref import SIGRef
|
|
||||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -52,7 +53,7 @@ class WOTA(HTTPAlertProvider):
|
|||||||
if len(ref_split) > 1:
|
if len(ref_split) > 1:
|
||||||
ref_name = str(ref_split[1])
|
ref_name = str(ref_split[1])
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(f"Could not parse WOTA alert title: {source_alert.description}")
|
logger.warning(f"Could not parse WOTA alert title: {source_alert.description}", exc_info=True)
|
||||||
|
|
||||||
# Pick apart the description
|
# Pick apart the description
|
||||||
comment = None
|
comment = None
|
||||||
@@ -63,7 +64,7 @@ class WOTA(HTTPAlertProvider):
|
|||||||
if len(desc_split) > 1:
|
if len(desc_split) > 1:
|
||||||
comment = desc_split[1].strip()
|
comment = desc_split[1].strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning(f"Could not parse WOTA alert description: {source_alert.description}")
|
logger.warning(f"Could not parse WOTA alert description: {source_alert.description}", exc_info=True)
|
||||||
|
|
||||||
time = datetime.strptime(source_alert.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
|
time = datetime.strptime(source_alert.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ class WOTA(HTTPAlertProvider):
|
|||||||
dx_calls=[dx_call],
|
dx_calls=[dx_call],
|
||||||
freqs_modes=freqs_modes,
|
freqs_modes=freqs_modes,
|
||||||
comment=comment,
|
comment=comment,
|
||||||
sig_refs=[SIGRef(id=ref, sig="WOTA", name=ref_name)] if ref else [],
|
sig_refs=[ActivityRef(id=ref, sig=ActivityName.WOTA, name=ref_name)] if ref else [],
|
||||||
start_time=time.timestamp(),
|
start_time=time.timestamp(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ from datetime import datetime
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
|
from core.enums import ActivityName
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
from data.alert import Alert
|
from data.alert import Alert
|
||||||
from data.sig_ref import SIGRef
|
|
||||||
from providers.alert.http_alert_provider import HTTPAlertProvider
|
from providers.alert.http_alert_provider import HTTPAlertProvider
|
||||||
|
|
||||||
|
|
||||||
@@ -27,14 +28,14 @@ class WWFF(HTTPAlertProvider):
|
|||||||
dx_calls=[source_alert["activator_call"].upper()],
|
dx_calls=[source_alert["activator_call"].upper()],
|
||||||
freqs_modes=f"{source_alert['band']} {source_alert['mode']}",
|
freqs_modes=f"{source_alert['band']} {source_alert['mode']}",
|
||||||
comment=source_alert["remarks"],
|
comment=source_alert["remarks"],
|
||||||
sig_refs=[SIGRef(id=source_alert["reference"], sig="WWFF")],
|
sig=ActivityName.WWFF,
|
||||||
|
sig_refs=[ActivityRef(id=source_alert["reference"], sig=ActivityName.WWFF)],
|
||||||
start_time=datetime.strptime(source_alert["utc_start"], "%Y-%m-%d %H:%M:%S")
|
start_time=datetime.strptime(source_alert["utc_start"], "%Y-%m-%d %H:%M:%S")
|
||||||
.replace(tzinfo=pytz.UTC)
|
.replace(tzinfo=pytz.UTC)
|
||||||
.timestamp(),
|
.timestamp(),
|
||||||
end_time=datetime.strptime(source_alert["utc_end"], "%Y-%m-%d %H:%M:%S")
|
end_time=datetime.strptime(source_alert["utc_end"], "%Y-%m-%d %H:%M:%S")
|
||||||
.replace(tzinfo=pytz.UTC)
|
.replace(tzinfo=pytz.UTC)
|
||||||
.timestamp(),
|
.timestamp(),
|
||||||
is_dxpedition=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add to our list
|
# Add to our list
|
||||||
|
|||||||
@@ -42,7 +42,13 @@ class CallsignDataProvider:
|
|||||||
|
|
||||||
if self.enabled:
|
if self.enabled:
|
||||||
if callsign in self._storage:
|
if callsign in self._storage:
|
||||||
return self._storage[callsign]
|
try:
|
||||||
|
return self._storage[callsign]
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
# Catch exceptions due to e.g. old versions of objects in the cache that are no longer compatible,
|
||||||
|
# and remove them from the cache.
|
||||||
|
del self._storage[callsign]
|
||||||
|
return None
|
||||||
else:
|
else:
|
||||||
c = self._perform_new_lookup(callsign, lookup_credentials)
|
c = self._perform_new_lookup(callsign, lookup_credentials)
|
||||||
if c:
|
if c:
|
||||||
|
|||||||
@@ -32,11 +32,15 @@ class FileDownloadCallsignDataProvider(CallsignDataProvider):
|
|||||||
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
||||||
# subsequent polls, so start() returns immediately and the application can continue starting.
|
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||||
logger.info(f"Set up query of {self.name} callsign reference data every {self._poll_interval!s} days.")
|
logger.info(f"Set up query of {self.name} callsign reference data every {self._poll_interval!s} days.")
|
||||||
self._thread = Thread(target=self._run, name=f"FileDownloadCallsignDataProvider-{self.name}")
|
self._thread = Thread(target=self._run, name=f"FileDownloadCallsignDataProvider-{self.name}", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=12)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning(f"{self.name} callsign data worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ from requests_cache import CachedSession
|
|||||||
from core.config import SERVER_OWNER_CALLSIGN
|
from core.config import SERVER_OWNER_CALLSIGN
|
||||||
from core.constants import HTTP_HEADERS, SOFTWARE_VERSION
|
from core.constants import HTTP_HEADERS, SOFTWARE_VERSION
|
||||||
from core.data_store import CACHE_DIR, DATA_STORE
|
from core.data_store import CACHE_DIR, DATA_STORE
|
||||||
|
from core.enums import Continent
|
||||||
from core.url_data_cache import URLDataCache
|
from core.url_data_cache import URLDataCache
|
||||||
from data.callsign import Callsign
|
from data.callsign import Callsign, LocationSourceForCallsign
|
||||||
from providers.callsigndata.api_query_callsign_data_provider import (
|
from providers.callsigndata.api_query_callsign_data_provider import (
|
||||||
APIQueryCallsignDataProvider,
|
APIQueryCallsignDataProvider,
|
||||||
)
|
)
|
||||||
@@ -60,7 +61,7 @@ class HamQTH(APIQueryCallsignDataProvider):
|
|||||||
logger.debug("HamQTH login details incorrect, failed to look up with HamQTH.")
|
logger.debug("HamQTH login details incorrect, failed to look up with HamQTH.")
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.error("Exception when getting HamQTH session key")
|
logger.exception("Exception when getting HamQTH session key")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not session_id:
|
if not session_id:
|
||||||
@@ -123,8 +124,8 @@ class HamQTH(APIQueryCallsignDataProvider):
|
|||||||
lat = None
|
lat = None
|
||||||
lon = None
|
lon = None
|
||||||
if (
|
if (
|
||||||
"latitude" in data
|
data.get("latitude") is not None
|
||||||
and "longitude" in data
|
and data.get("longitude") is not None
|
||||||
and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0)
|
and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0)
|
||||||
and -89.9 < float(data["latitude"]) < 89.9
|
and -89.9 < float(data["latitude"]) < 89.9
|
||||||
):
|
):
|
||||||
@@ -133,7 +134,7 @@ class HamQTH(APIQueryCallsignDataProvider):
|
|||||||
|
|
||||||
# Check for sensible grids
|
# Check for sensible grids
|
||||||
grid = None
|
grid = None
|
||||||
if "grid" in data and not data["grid"].startswith("AA00"):
|
if data.get("grid") and not data["grid"].startswith("AA00"):
|
||||||
grid = data["grid"]
|
grid = data["grid"]
|
||||||
|
|
||||||
return Callsign(
|
return Callsign(
|
||||||
@@ -142,12 +143,12 @@ class HamQTH(APIQueryCallsignDataProvider):
|
|||||||
name=data.get("nick", None),
|
name=data.get("nick", None),
|
||||||
qth=data.get("qth", None),
|
qth=data.get("qth", None),
|
||||||
country=data.get("country", None),
|
country=data.get("country", None),
|
||||||
continent=data.get("continent", None),
|
continent=Continent(data["continent"]) if "continent" in data else None,
|
||||||
latitude=lat,
|
latitude=lat,
|
||||||
longitude=lon,
|
longitude=lon,
|
||||||
grid=grid,
|
grid=grid,
|
||||||
dxcc_id=int(data["adif"]) if "adif" in data else None,
|
dxcc_id=int(data["adif"]) if data.get("adif") is not None else None,
|
||||||
cq_zone=int(data["cq"]) if "cq" in data else None,
|
cq_zone=int(data["cq"]) if data.get("cq") is not None else None,
|
||||||
itu_zone=int(data["itu"]) if "itu" in data else None,
|
itu_zone=int(data["itu"]) if data.get("itu") is not None else None,
|
||||||
location_source="HOME QTH",
|
location_source=LocationSourceForCallsign.HOME_QTH,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ from requests_cache import CachedSession
|
|||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from core.data_store import CACHE_DIR, DATA_STORE
|
from core.data_store import CACHE_DIR, DATA_STORE
|
||||||
|
from core.enums import Continent, LocationSourceForCallsign
|
||||||
from core.url_data_cache import URLDataCache
|
from core.url_data_cache import URLDataCache
|
||||||
from data.callsign import Callsign
|
from data.callsign import Callsign
|
||||||
from providers.callsigndata.api_query_callsign_data_provider import (
|
from providers.callsigndata.api_query_callsign_data_provider import APIQueryCallsignDataProvider
|
||||||
APIQueryCallsignDataProvider,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -58,7 +57,7 @@ class QRZ(APIQueryCallsignDataProvider):
|
|||||||
logger.debug("QRZ.com login details incorrect, failed to look up with QRZ.")
|
logger.debug("QRZ.com login details incorrect, failed to look up with QRZ.")
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.error("Exception when getting QRZ.com session key")
|
logger.exception("Exception when getting QRZ.com session key")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not session_key:
|
if not session_key:
|
||||||
@@ -151,8 +150,8 @@ class QRZ(APIQueryCallsignDataProvider):
|
|||||||
lat = None
|
lat = None
|
||||||
lon = None
|
lon = None
|
||||||
if (
|
if (
|
||||||
"latitude" in data
|
data.get("latitude") is not None
|
||||||
and "longitude" in data
|
and data.get("longitude") is not None
|
||||||
and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0)
|
and (float(data["latitude"]) != 0 or float(data["longitude"]) != 0)
|
||||||
and -89.9 < float(data["latitude"]) < 89.9
|
and -89.9 < float(data["latitude"]) < 89.9
|
||||||
):
|
):
|
||||||
@@ -161,7 +160,7 @@ class QRZ(APIQueryCallsignDataProvider):
|
|||||||
|
|
||||||
# Check for sensible grids
|
# Check for sensible grids
|
||||||
grid = None
|
grid = None
|
||||||
if "grid" in data and not data["grid"].startswith("AA00"):
|
if data.get("grid") and not data["grid"].startswith("AA00"):
|
||||||
grid = data["grid"]
|
grid = data["grid"]
|
||||||
|
|
||||||
return Callsign(
|
return Callsign(
|
||||||
@@ -170,12 +169,12 @@ class QRZ(APIQueryCallsignDataProvider):
|
|||||||
name=name,
|
name=name,
|
||||||
qth=data.get("addr2", None),
|
qth=data.get("addr2", None),
|
||||||
country=data.get("country", None),
|
country=data.get("country", None),
|
||||||
continent=data.get("continent", None),
|
continent=Continent(data["continent"]) if "continent" in data else None,
|
||||||
latitude=lat,
|
latitude=lat,
|
||||||
longitude=lon,
|
longitude=lon,
|
||||||
grid=grid,
|
grid=grid,
|
||||||
dxcc_id=int(data["adif"]) if "adif" in data else None,
|
dxcc_id=int(data["adif"]) if data.get("adif") is not None else None,
|
||||||
cq_zone=int(data["cqzone"]) if "cqzone" in data else None,
|
cq_zone=int(data["cqzone"]) if data.get("cqzone") is not None else None,
|
||||||
itu_zone=int(data["ituzone"]) if "ituzone" in data else None,
|
itu_zone=int(data["ituzone"]) if data.get("ituzone") is not None else None,
|
||||||
location_source="HOME QTH",
|
location_source=LocationSourceForCallsign.HOME_QTH,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
from providers.sigrefdata.pnp_kml_sig_ref_data_provider import (
|
|
||||||
ParksNPeaksKMLSIGRefDataProvider,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class KRMNPA(ParksNPeaksKMLSIGRefDataProvider):
|
|
||||||
"""SIG ref data provider for the Keith Roget Memorrial National Parks Award (KRMNPA)."""
|
|
||||||
|
|
||||||
POLL_INTERVAL_DAYS = 365
|
|
||||||
SIG = "KRMNPA"
|
|
||||||
DATA_URL = "https://parksnpeaks.org/getPOI.php?poiClass=KRMNPA&poiFormat=4"
|
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
|
||||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import pytz
|
|
||||||
|
|
||||||
from providers.sigrefdata.sig_ref_data_provider import SIGRefDataProvider
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class LocalFileSIGRefDataProvider(SIGRefDataProvider):
|
|
||||||
"""Generic SIG ref data provider class for providers that fetch their data from a local file on startup."""
|
|
||||||
|
|
||||||
def __init__(self, sig, provider_config, path):
|
|
||||||
super().__init__(sig, provider_config)
|
|
||||||
self._path = path
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
logger.debug(f"Loading {self.sig_name} SIG ref data from file.")
|
|
||||||
try:
|
|
||||||
new_data = self._file_to_data(self._path)
|
|
||||||
if new_data:
|
|
||||||
self._add_data(new_data)
|
|
||||||
self.status = "OK"
|
|
||||||
self.last_update_time = datetime.now(pytz.UTC)
|
|
||||||
else:
|
|
||||||
self.status = "Error"
|
|
||||||
logger.info(f"Failed to load SIG ref data for {self.sig_name}")
|
|
||||||
except Exception:
|
|
||||||
self.status = "Error"
|
|
||||||
logger.exception(f"Exception in local file SIG Ref Data Provider ({self.sig_name})")
|
|
||||||
|
|
||||||
def _file_to_data(self, path):
|
|
||||||
"""Load a file on the given path and turn it into SIG Ref data."""
|
|
||||||
|
|
||||||
raise NotImplementedError("Subclasses must implement this method")
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from providers.sigrefdata.pnp_kml_sig_ref_data_provider import (
|
|
||||||
ParksNPeaksKMLSIGRefDataProvider,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SANPCPA(ParksNPeaksKMLSIGRefDataProvider):
|
|
||||||
"""SIG ref data provider for the South Australia National Parks and Conservation Parks Award (SANPCPA)."""
|
|
||||||
|
|
||||||
POLL_INTERVAL_DAYS = 365
|
|
||||||
SIG = "SANPCPA"
|
|
||||||
DATA_URL = "https://parksnpeaks.org/getPOI.php?poiClass=SANPCPA&poiFormat=4"
|
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
|
||||||
super().__init__(self.SIG, provider_config, self.DATA_URL, self.POLL_INTERVAL_DAYS)
|
|
||||||
@@ -67,11 +67,15 @@ class GIROIonosonde(SolarConditionsProvider):
|
|||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
logger.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
|
logger.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
|
||||||
self._thread = Thread(target=self._run, name="GIROIonosondeDataProvider")
|
self._thread = Thread(target=self._run, name="GIROIonosondeDataProvider", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=12)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning("GIRO ionosonde worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
# Real interval at which we poll is the "once per hour" divided by the number of stations, so each one gets
|
# Real interval at which we poll is the "once per hour" divided by the number of stations, so each one gets
|
||||||
|
|||||||
@@ -25,11 +25,15 @@ class HTTPSolarConditionsProvider(SolarConditionsProvider):
|
|||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
logger.info(f"Set up query of {self.name} solar conditions API every {self._poll_interval!s} seconds.")
|
logger.info(f"Set up query of {self.name} solar conditions API every {self._poll_interval!s} seconds.")
|
||||||
self._thread = Thread(target=self._run, name=f"HTTPSolarConditionsProvider-{self.name}")
|
self._thread = Thread(target=self._run, name=f"HTTPSolarConditionsProvider-{self.name}", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=12)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning(f"{self.name} solar conditions worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -32,11 +32,15 @@ class KC2GProp(SolarConditionsProvider):
|
|||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
logger.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.")
|
logger.info(f"Set up query of KC2G ionosonde data API every {POLL_INTERVAL} seconds.")
|
||||||
self._thread = Thread(target=self._run, name="KC2GPropProvider")
|
self._thread = Thread(target=self._run, name="KC2GPropProvider", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=12)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning("KC2G ionosonde worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ class NOAA3dayForecast(HTTPSolarConditionsProvider):
|
|||||||
column_dates = []
|
column_dates = []
|
||||||
for month_str, day_str in date_matches:
|
for month_str, day_str in date_matches:
|
||||||
try:
|
try:
|
||||||
column_dates.append(datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").date())
|
column_dates.append(
|
||||||
|
datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").replace(tzinfo=timezone.utc).date()
|
||||||
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"NOAA K-index forecast: could not parse date: {month_str} {day_str} {year}")
|
logger.warning(f"NOAA K-index forecast: could not parse date: {month_str} {day_str} {year}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
+55
-35
@@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from threading import Thread
|
from threading import Event, Thread
|
||||||
|
|
||||||
import aprslib
|
import aprslib
|
||||||
import pytz
|
import pytz
|
||||||
@@ -17,49 +17,69 @@ class APRSIS(SpotProvider):
|
|||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__("APRS-IS", provider_config)
|
super().__init__("APRS-IS", provider_config)
|
||||||
self._thread = Thread(target=self._connect, name="APRSISSpotProvider")
|
self._thread = None
|
||||||
self._thread.daemon = True
|
|
||||||
self._aprsis = None
|
self._aprsis = None
|
||||||
|
self._stop_event = Event()
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
self._thread = Thread(target=self._run, name="APRSISSpotProvider", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def _connect(self):
|
def _run(self):
|
||||||
self._aprsis = aprslib.IS(SERVER_OWNER_CALLSIGN)
|
while not self._stop_event.is_set():
|
||||||
self.status = "Connecting"
|
try:
|
||||||
logger.info("APRS-IS connecting...")
|
self._aprsis = aprslib.IS(SERVER_OWNER_CALLSIGN)
|
||||||
self._aprsis.connect()
|
self.status = "Connecting"
|
||||||
self._aprsis.consumer(self._handle)
|
logger.info("APRS-IS connecting...")
|
||||||
logger.info("APRS-IS connected.")
|
self._aprsis.connect()
|
||||||
|
logger.info("APRS-IS connected.")
|
||||||
|
self._aprsis.consumer(self._handle, immortal=True)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
if not self._stop_event.is_set():
|
||||||
|
self.status = "Error"
|
||||||
|
logger.exception("Exception in APRS-IS provider")
|
||||||
|
|
||||||
|
if not self._stop_event.is_set():
|
||||||
|
self._stop_event.wait(timeout=5)
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self.status = "Shutting down"
|
self.status = "Shutting down"
|
||||||
self._aprsis.close()
|
self._stop_event.set()
|
||||||
self._thread.join()
|
if self._aprsis:
|
||||||
|
self._aprsis.close()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=5)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning("APRS-IS worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _handle(self, data):
|
def _handle(self, data):
|
||||||
# Split SSID in "from" call and store separately
|
try:
|
||||||
from_parts = str(data["from"]).split("-")
|
# Split SSID in "from" call and store separately
|
||||||
dx_call = from_parts[0].upper()
|
from_parts = str(data["from"]).split("-")
|
||||||
dx_ssid = from_parts[1].upper() if len(from_parts) > 1 else None
|
dx_call = from_parts[0].upper()
|
||||||
via_parts = str(data["via"]).split("-")
|
dx_ssid = from_parts[1].upper() if len(from_parts) > 1 else None
|
||||||
de_call = via_parts[0].upper()
|
via_parts = str(data["via"]).split("-")
|
||||||
de_ssid = via_parts[1].upper() if len(via_parts) > 1 else None
|
de_call = via_parts[0].upper()
|
||||||
spot = Spot(
|
de_ssid = via_parts[1].upper() if len(via_parts) > 1 else None
|
||||||
source="APRS-IS",
|
spot = Spot(
|
||||||
dx_call=dx_call,
|
source="APRS-IS",
|
||||||
dx_ssid=dx_ssid,
|
dx_call=dx_call,
|
||||||
de_call=de_call,
|
dx_ssid=dx_ssid,
|
||||||
de_ssid=de_ssid,
|
de_call=de_call,
|
||||||
comment=str(data["comment"]) if "comment" in data else None,
|
de_ssid=de_ssid,
|
||||||
dx_latitude=float(data["latitude"]) if "latitude" in data else None,
|
comment=str(data["comment"]) if "comment" in data else None,
|
||||||
dx_longitude=float(data["longitude"]) if "longitude" in data else None,
|
dx_latitude=float(data["latitude"]) if data.get("latitude") is not None else None,
|
||||||
time=datetime.now(pytz.UTC).timestamp(),
|
dx_longitude=float(data["longitude"]) if data.get("longitude") is not None else None,
|
||||||
) # 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(pytz.UTC)
|
self.last_update_time = datetime.now(pytz.UTC)
|
||||||
logger.debug("Data received from APRS-IS.")
|
logger.debug("Data received from APRS-IS.")
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Exception handling APRS-IS packet")
|
||||||
|
|||||||
+36
-20
@@ -1,8 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from threading import Thread
|
from threading import Event, Lock, Thread
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import telnetlib3
|
import telnetlib3
|
||||||
@@ -41,27 +41,43 @@ class DXCluster(SpotProvider):
|
|||||||
self._LINE_PATTERN_ALLOW_RBN if self._allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN
|
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, name=f"DXClusterSpotProvider-{self.name}")
|
self._telnet_lock = Lock()
|
||||||
self._thread.daemon = True
|
self._thread = None
|
||||||
self._running = True
|
self._stop_event = Event()
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
self._thread = Thread(target=self._handle, name=f"DXClusterSpotProvider-{self.name}", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._running = False
|
self._stop_event.set()
|
||||||
if self._telnet:
|
with self._telnet_lock:
|
||||||
self._telnet.close()
|
if self._telnet:
|
||||||
self._thread.join()
|
try:
|
||||||
|
self._telnet.sock.shutdown(socket.SHUT_RDWR)
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pass
|
||||||
|
self._telnet.close()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=5)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning(f"DX Cluster {self._hostname} worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _handle(self):
|
def _handle(self):
|
||||||
while self._running:
|
while not self._stop_event.is_set():
|
||||||
connected = False
|
connected = False
|
||||||
while not connected and self._running:
|
while not connected and not self._stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
self.status = "Connecting"
|
self.status = "Connecting"
|
||||||
logger.info(f"DX Cluster {self._hostname} connecting...")
|
logger.info(f"DX Cluster {self._hostname} connecting...")
|
||||||
self._telnet = telnetlib3.Telnet(self._hostname, self._port)
|
new_telnet = telnetlib3.Telnet(self._hostname, self._port)
|
||||||
|
with self._telnet_lock:
|
||||||
|
self._telnet = new_telnet
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
# stop() was called while we were connecting, close the connection rather than trying to
|
||||||
|
# read when we know it won't work
|
||||||
|
new_telnet.close()
|
||||||
|
break
|
||||||
self._telnet.read_until(self._login_prompt.encode("latin-1"))
|
self._telnet.read_until(self._login_prompt.encode("latin-1"))
|
||||||
self._telnet.write(f"{self._login_callsign}\n".encode("latin-1"))
|
self._telnet.write(f"{self._login_callsign}\n".encode("latin-1"))
|
||||||
connected = True
|
connected = True
|
||||||
@@ -69,20 +85,20 @@ class DXCluster(SpotProvider):
|
|||||||
except ConnectionRefusedError:
|
except ConnectionRefusedError:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logger.warning(f"Connection refused to DX cluster {self._hostname}")
|
logger.warning(f"Connection refused to DX cluster {self._hostname}")
|
||||||
sleep(300)
|
self._stop_event.wait(timeout=300)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logger.exception(f"Exception while connecting to DX Cluster Provider ({self._hostname}).")
|
logger.exception(f"Exception while connecting to DX Cluster Provider ({self._hostname}).")
|
||||||
sleep(5)
|
self._stop_event.wait(timeout=5)
|
||||||
|
|
||||||
self.status = "Waiting for Data"
|
self.status = "Waiting for Data"
|
||||||
while connected and self._running:
|
while connected and not self._stop_event.is_set():
|
||||||
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._spot_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").replace(tzinfo=pytz.UTC)
|
||||||
spot_datetime = datetime.combine(
|
spot_datetime = datetime.combine(
|
||||||
datetime.now(pytz.UTC).date(),
|
datetime.now(pytz.UTC).date(),
|
||||||
spot_time.time(),
|
spot_time.time(),
|
||||||
@@ -106,19 +122,19 @@ class DXCluster(SpotProvider):
|
|||||||
|
|
||||||
except EOFError:
|
except EOFError:
|
||||||
connected = False
|
connected = False
|
||||||
if self._running:
|
if not self._stop_event.is_set():
|
||||||
self.status = "Restarting"
|
self.status = "Restarting"
|
||||||
logger.warning(f"Disconnected from DX Cluster {self._hostname}. Reconnecting...")
|
logger.warning(f"Disconnected from DX Cluster {self._hostname}. Reconnecting...")
|
||||||
sleep(5)
|
self._stop_event.wait(timeout=5)
|
||||||
else:
|
else:
|
||||||
logger.info(f"DX Cluster {self._hostname} shutting down...")
|
logger.info(f"DX Cluster {self._hostname} shutting down...")
|
||||||
self.status = "Shutting down"
|
self.status = "Shutting down"
|
||||||
except Exception:
|
except Exception:
|
||||||
connected = False
|
connected = False
|
||||||
if self._running:
|
if not self._stop_event.is_set():
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logger.exception(f"Exception in DX Cluster Provider ({self._hostname})")
|
logger.exception(f"Exception in DX Cluster Provider ({self._hostname})")
|
||||||
sleep(5)
|
self._stop_event.wait(timeout=5)
|
||||||
else:
|
else:
|
||||||
logger.info(f"DX Cluster {self._hostname} shutting down...")
|
logger.info(f"DX Cluster {self._hostname} shutting down...")
|
||||||
self.status = "Shutting down"
|
self.status = "Shutting down"
|
||||||
|
|||||||
+45
-34
@@ -4,8 +4,9 @@ from datetime import datetime
|
|||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
|
from core.enums import ActivityName, ActivityRefType, Mode
|
||||||
from core.url_data_cache import URLDataCache
|
from core.url_data_cache import URLDataCache
|
||||||
from data.sig_ref import SIGRef
|
from data.activity_ref import ActivityRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
@@ -65,10 +66,10 @@ class GMA(HTTPSpotProvider):
|
|||||||
if (source_spot["QRG"] != "" and source_spot["QRG"] != "QRT")
|
if (source_spot["QRG"] != "" and source_spot["QRG"] != "QRT")
|
||||||
else None,
|
else None,
|
||||||
# Filter out some weird mode strings
|
# Filter out some weird mode strings
|
||||||
mode=source_spot["MODE"].upper() if "<>" not in source_spot["MODE"] else None,
|
mode=Mode.from_name(source_spot["MODE"].upper()) if "<>" not in source_spot["MODE"] else None,
|
||||||
comment=source_spot["TEXT"],
|
comment=source_spot["TEXT"],
|
||||||
sig_refs=[
|
sig_refs=[
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
id=source_spot["REF"],
|
id=source_spot["REF"],
|
||||||
sig="",
|
sig="",
|
||||||
name=source_spot["NAME"],
|
name=source_spot["NAME"],
|
||||||
@@ -82,7 +83,7 @@ class GMA(HTTPSpotProvider):
|
|||||||
qrt=source_spot["QRG"] == "QRT",
|
qrt=source_spot["QRG"] == "QRT",
|
||||||
)
|
)
|
||||||
|
|
||||||
# GMA doesn't give what programme (SIG) the reference is for until we separately look it up.
|
# GMA doesn't give what programme (activity) the reference is for until we separately look it up.
|
||||||
if "REF" in source_spot:
|
if "REF" in source_spot:
|
||||||
try:
|
try:
|
||||||
ref_response = self._url_data_cache.get(
|
ref_response = self._url_data_cache.get(
|
||||||
@@ -97,41 +98,51 @@ class GMA(HTTPSpotProvider):
|
|||||||
and ref_response.text != "\n"
|
and ref_response.text != "\n"
|
||||||
):
|
):
|
||||||
ref_info = ref_response.json()
|
ref_info = ref_response.json()
|
||||||
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. POTA and WWFF
|
if spot.sig_refs and ref_info and "reftype" in ref_info:
|
||||||
# spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA
|
|
||||||
# and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry
|
|
||||||
# to determine if it's a SOTA summit.
|
|
||||||
if (
|
|
||||||
spot.sig_refs
|
|
||||||
and ref_info
|
|
||||||
and "reftype" in ref_info
|
|
||||||
and ref_info["reftype"] not in ["POTA", "WWFF"]
|
|
||||||
and (
|
|
||||||
ref_info["reftype"] != "Summit" or "sota" not in ref_info or ref_info["sota"] == ""
|
|
||||||
)
|
|
||||||
):
|
|
||||||
match ref_info["reftype"]:
|
match ref_info["reftype"]:
|
||||||
case "Summit":
|
case "Summit":
|
||||||
spot.sig_refs[0].sig = "GMA"
|
# Summits are a bit complicated, they can be SOTA or GMA depending on the
|
||||||
spot.sig = "GMA"
|
# separate "sota" field:
|
||||||
|
if "sota" in ref_info and ref_info["sota"] != "":
|
||||||
|
spot.sig_refs[0].sig = ActivityName.SOTA
|
||||||
|
spot.sig_refs[0].ref_type = ActivityRefType.SUMMIT
|
||||||
|
spot.sig = ActivityName.SOTA
|
||||||
|
else:
|
||||||
|
spot.sig_refs[0].sig = ActivityName.GMA
|
||||||
|
spot.sig_refs[0].ref_type = ActivityRefType.SUMMIT
|
||||||
|
spot.sig = ActivityName.GMA
|
||||||
|
case "POTA":
|
||||||
|
spot.sig_refs[0].sig = ActivityName.POTA
|
||||||
|
spot.sig_refs[0].ref_type = ActivityRefType.PARK
|
||||||
|
spot.sig = ActivityName.POTA
|
||||||
|
case "WWFF":
|
||||||
|
spot.sig_refs[0].sig = ActivityName.WWFF
|
||||||
|
spot.sig_refs[0].ref_type = ActivityRefType.PARK
|
||||||
|
spot.sig = ActivityName.WWFF
|
||||||
case "IOTA Island":
|
case "IOTA Island":
|
||||||
spot.sig_refs[0].sig = "IOTA"
|
spot.sig_refs[0].sig = ActivityName.IOTA
|
||||||
spot.sig = "IOTA"
|
spot.sig_refs[0].ref_type = ActivityRefType.ISLAND
|
||||||
|
spot.sig = ActivityName.IOTA
|
||||||
case "GMA Island":
|
case "GMA Island":
|
||||||
spot.sig_refs[0].sig = "GMA Islands"
|
spot.sig_refs[0].sig = ActivityName.GMA_ISLANDS
|
||||||
spot.sig = "GMA Islands"
|
spot.sig_refs[0].ref_type = ActivityRefType.ISLAND
|
||||||
|
spot.sig = ActivityName.GMA_ISLANDS
|
||||||
case "Lighthouse (ILLW)":
|
case "Lighthouse (ILLW)":
|
||||||
spot.sig_refs[0].sig = "ILLW"
|
spot.sig_refs[0].sig = ActivityName.ILLW
|
||||||
spot.sig = "ILLW"
|
spot.sig_refs[0].ref_type = ActivityRefType.LIGHTHOUSE
|
||||||
|
spot.sig = ActivityName.ILLW
|
||||||
case "Lighthouse (ARLHS)":
|
case "Lighthouse (ARLHS)":
|
||||||
spot.sig_refs[0].sig = "ARLHS"
|
spot.sig_refs[0].sig = ActivityName.ARLHS
|
||||||
spot.sig = "ARLHS"
|
spot.sig_refs[0].ref_type = ActivityRefType.LIGHTHOUSE
|
||||||
|
spot.sig = ActivityName.ARLHS
|
||||||
case "Castle":
|
case "Castle":
|
||||||
spot.sig_refs[0].sig = "WCA"
|
spot.sig_refs[0].sig = ActivityName.WCA
|
||||||
spot.sig = "WCA"
|
spot.sig_refs[0].ref_type = ActivityRefType.CASTLE
|
||||||
|
spot.sig = ActivityName.WCA
|
||||||
case "Mill":
|
case "Mill":
|
||||||
spot.sig_refs[0].sig = "MOTA"
|
spot.sig_refs[0].sig = ActivityName.MOTA
|
||||||
spot.sig = "MOTA"
|
spot.sig_refs[0].ref_type = ActivityRefType.MILL
|
||||||
|
spot.sig = ActivityName.MOTA
|
||||||
case _:
|
case _:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"GMA spot found with ref type {ref_info['reftype']}, developer needs to add support for this!"
|
f"GMA spot found with ref type {ref_info['reftype']}, developer needs to add support for this!"
|
||||||
@@ -150,7 +161,7 @@ class GMA(HTTPSpotProvider):
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
f"Exception when looking up {self.REF_INFO_URL_ROOT}{source_spot['REF']}, SIG data will not be populated for the spot."
|
f"Exception when looking up {self.REF_INFO_URL_ROOT}{source_spot['REF']}, activity data will not be populated for the spot."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point;
|
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point;
|
||||||
@@ -161,8 +172,8 @@ class GMA(HTTPSpotProvider):
|
|||||||
|
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
def can_submit_spot(self, sig):
|
def can_submit_spot(self, activity):
|
||||||
return sig == "GMA"
|
return activity == ActivityName.GMA
|
||||||
|
|
||||||
def submit_spot(self, spot, credentials):
|
def submit_spot(self, spot, credentials):
|
||||||
# TODO: Implement.
|
# TODO: Implement.
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import requests
|
|||||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType, Mode
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
@@ -59,16 +60,17 @@ class HEMA(HTTPSpotProvider):
|
|||||||
dx_call=spot_items[2].upper(),
|
dx_call=spot_items[2].upper(),
|
||||||
de_call=spotter_comment_match.group(1).upper(),
|
de_call=spotter_comment_match.group(1).upper(),
|
||||||
freq=float(freq_mode_match.group(1)) * 1000000,
|
freq=float(freq_mode_match.group(1)) * 1000000,
|
||||||
mode=freq_mode_match.group(2).upper(),
|
mode=Mode.from_name(freq_mode_match.group(2).upper()),
|
||||||
comment=spotter_comment_match.group(2),
|
comment=spotter_comment_match.group(2),
|
||||||
sig="HEMA",
|
sig=ActivityName.HEMA,
|
||||||
sig_refs=[
|
sig_refs=[
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
id=spot_items[3].upper(),
|
id=spot_items[3].upper(),
|
||||||
sig="HEMA",
|
sig=ActivityName.HEMA,
|
||||||
name=spot_items[4],
|
name=spot_items[4],
|
||||||
latitude=float(spot_items[7]),
|
latitude=float(spot_items[7]),
|
||||||
longitude=float(spot_items[8]),
|
longitude=float(spot_items[8]),
|
||||||
|
ref_type=ActivityRefType.SUMMIT,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M")
|
time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M")
|
||||||
@@ -87,8 +89,8 @@ class HEMA(HTTPSpotProvider):
|
|||||||
logger.warning("Connection error when accessing HEMA spots API.")
|
logger.warning("Connection error when accessing HEMA spots API.")
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
def can_submit_spot(self, sig):
|
def can_submit_spot(self, activity):
|
||||||
return sig == "HEMA"
|
return activity == ActivityName.HEMA
|
||||||
|
|
||||||
def submit_spot(self, spot, credentials):
|
def submit_spot(self, spot, credentials):
|
||||||
# TODO: Implement. Currently blocked awaiting their API team to make a change to allow us to spot with a
|
# TODO: Implement. Currently blocked awaiting their API team to make a change to allow us to spot with a
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from threading import Event, Thread
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
from requests.exceptions import ConnectionError, ConnectTimeout, JSONDecodeError, ReadTimeout
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from providers.spot.spot_provider import SpotProvider
|
from providers.spot.spot_provider import SpotProvider
|
||||||
@@ -28,12 +28,16 @@ class HTTPSpotProvider(SpotProvider):
|
|||||||
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
|
||||||
# subsequent polls, so start() returns immediately and the application can continue starting.
|
# subsequent polls, so start() returns immediately and the application can continue starting.
|
||||||
logger.info(f"Set up query of {self.name} spot API every {self._poll_interval!s} seconds.")
|
logger.info(f"Set up query of {self.name} spot API every {self._poll_interval!s} seconds.")
|
||||||
self._thread = Thread(target=self._run, name=f"HTTPSpotProvider-{self.name}")
|
self._thread = Thread(target=self._run, name=f"HTTPSpotProvider-{self.name}", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
self._wakeup_event.set()
|
self._wakeup_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=12)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning(f"{self.name} spot worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def force_poll(self):
|
def force_poll(self):
|
||||||
"""Trigger an immediate poll without waiting for the normal interval."""
|
"""Trigger an immediate poll without waiting for the normal interval."""
|
||||||
@@ -69,9 +73,14 @@ class HTTPSpotProvider(SpotProvider):
|
|||||||
logger.warning(f"HTTP {http_response.status_code} when calling {self.name} spot API.")
|
logger.warning(f"HTTP {http_response.status_code} when calling {self.name} spot API.")
|
||||||
|
|
||||||
except ConnectionError:
|
except ConnectionError:
|
||||||
|
self.status = "Error"
|
||||||
logger.warning(f"Connection error when accessing {self.name} spots API.")
|
logger.warning(f"Connection error when accessing {self.name} spots API.")
|
||||||
except (ConnectTimeout, ReadTimeout):
|
except (ConnectTimeout, ReadTimeout):
|
||||||
|
self.status = "Error"
|
||||||
logger.warning(f"Timeout when accessing {self.name} spots API.")
|
logger.warning(f"Timeout when accessing {self.name} spots API.")
|
||||||
|
except JSONDecodeError:
|
||||||
|
self.status = "Error"
|
||||||
|
logger.warning(f"Invalid or empty JSON response from {self.name} spots API.")
|
||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logger.exception(f"Exception in HTTP Spot Provider ({self.name})")
|
logger.exception(f"Exception in HTTP Spot Provider ({self.name})")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType, Mode
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
@@ -31,14 +32,15 @@ class LLOTA(HTTPSpotProvider):
|
|||||||
dx_call=source_spot["callsign"].upper(),
|
dx_call=source_spot["callsign"].upper(),
|
||||||
de_call=spotter.upper() if spotter else None,
|
de_call=spotter.upper() if spotter else None,
|
||||||
freq=float(source_spot["frequency"]) * 1000000,
|
freq=float(source_spot["frequency"]) * 1000000,
|
||||||
mode=source_spot["mode"].upper(),
|
mode=Mode.from_name(source_spot["mode"].upper()),
|
||||||
comment=comment,
|
comment=comment,
|
||||||
sig="LLOTA",
|
sig=ActivityName.LLOTA,
|
||||||
sig_refs=[
|
sig_refs=[
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
id=source_spot["reference"],
|
id=source_spot["reference"],
|
||||||
sig="LLOTA",
|
sig=ActivityName.LLOTA,
|
||||||
name=source_spot["reference_name"],
|
name=source_spot["reference_name"],
|
||||||
|
ref_type=ActivityRefType.LAKE,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
time=datetime.fromisoformat(source_spot["updated_at"].replace("Z", "+00:00")).timestamp(),
|
time=datetime.fromisoformat(source_spot["updated_at"].replace("Z", "+00:00")).timestamp(),
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, Mode
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
@@ -19,17 +21,16 @@ 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"
|
||||||
SUBMIT_URL = "https://www.parksnpeaks.org/api/SPOT/"
|
SUBMIT_URL = "https://www.parksnpeaks.org/api/SPOT/"
|
||||||
SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv"
|
SUBMITTABLE_ACTIVITIES: ClassVar[list[ActivityName]] = [
|
||||||
SUBMITTABLE_SIGS = [
|
ActivityName.POTA,
|
||||||
"POTA",
|
ActivityName.SOTA,
|
||||||
"SOTA",
|
ActivityName.WWFF,
|
||||||
"WWFF",
|
ActivityName.HEMA,
|
||||||
"HEMA",
|
ActivityName.WOTA,
|
||||||
"WOTA",
|
ActivityName.ZLOTA,
|
||||||
"ZLOTA",
|
ActivityName.SIOTA,
|
||||||
"SIOTA",
|
ActivityName.KRMNPA,
|
||||||
"KRMNPA",
|
ActivityName.SANPCPA,
|
||||||
"SANPCPA",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
@@ -52,7 +53,7 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
if (source_spot["actFreq"] != "")
|
if (source_spot["actFreq"] != "")
|
||||||
else None,
|
else None,
|
||||||
# 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=Mode.from_name(source_spot["actMode"].upper()),
|
||||||
comment=source_spot["actComments"],
|
comment=source_spot["actComments"],
|
||||||
time=datetime.strptime(source_spot["actTime"], "%Y-%m-%d %H:%M:%S")
|
time=datetime.strptime(source_spot["actTime"], "%Y-%m-%d %H:%M:%S")
|
||||||
.replace(tzinfo=pytz.UTC)
|
.replace(tzinfo=pytz.UTC)
|
||||||
@@ -64,43 +65,60 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
if not spot.de_call and m:
|
if not spot.de_call and m:
|
||||||
spot.de_call = str(m.group(1))
|
spot.de_call = str(m.group(1))
|
||||||
|
|
||||||
# Record SIG information. Sometimes we get a "SIG" of "QRP", which we ignore as it's not a programme with a
|
# Record activity information
|
||||||
# defined set of references
|
activity = source_spot["actClass"].upper()
|
||||||
sig = source_spot["actClass"].upper()
|
ref_id = source_spot["actSiteID"]
|
||||||
sig_ref = source_spot["actSiteID"]
|
|
||||||
if sig and sig != "" and sig != "QRP" and sig_ref and sig_ref != "":
|
|
||||||
spot.sig = sig
|
|
||||||
sig_refs = [
|
|
||||||
SIGRef(
|
|
||||||
id=source_spot["actSiteID"],
|
|
||||||
sig=source_spot["actClass"].upper(),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
spot.sig_refs = sig_refs
|
|
||||||
|
|
||||||
# Free text location is not present in all spots, so only add it if it's set
|
if activity:
|
||||||
if "actLocation" in source_spot and source_spot["actLocation"] != "":
|
spot.sig = activity
|
||||||
sig_refs[0].name = source_spot["actLocation"]
|
|
||||||
|
if ref_id:
|
||||||
|
activity_refs = [
|
||||||
|
ActivityRef(
|
||||||
|
id=ref_id,
|
||||||
|
sig=activity,
|
||||||
|
# Free text location is not present in all spots, so only add it if it's set
|
||||||
|
name=source_spot["actLocation"]
|
||||||
|
if "actLocation" in source_spot and source_spot["actLocation"] != ""
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
spot.sig_refs = activity_refs
|
||||||
|
|
||||||
|
else:
|
||||||
|
# If no actSiteID is set, e.g. because actClass is "QRP", sometimes we still have an actLocation
|
||||||
|
# which is free text like "SOTA G/SC-001". If we have that, and not a normal comment field, use
|
||||||
|
# that location as the comment field so the information doesn't get lost.
|
||||||
|
if (
|
||||||
|
"actLocation" in source_spot
|
||||||
|
and source_spot["actLocation"] != ""
|
||||||
|
and ("actComments" not in source_spot or source_spot["actComments"] == "")
|
||||||
|
):
|
||||||
|
spot.comment = source_spot["actLocation"]
|
||||||
|
|
||||||
# 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 sig not in [
|
if activity not in [
|
||||||
"POTA",
|
ActivityName.POTA,
|
||||||
"SOTA",
|
ActivityName.SOTA,
|
||||||
"WWFF",
|
ActivityName.WWFF,
|
||||||
"SIOTA",
|
ActivityName.HEMA,
|
||||||
"ZLOTA",
|
ActivityName.SIOTA,
|
||||||
"KRMNPA",
|
ActivityName.ZLOTA,
|
||||||
"SANPCPA",
|
ActivityName.KRMNPA,
|
||||||
"LLOTA",
|
ActivityName.SANPCPA,
|
||||||
|
ActivityName.LLOTA,
|
||||||
|
ActivityName.QRP,
|
||||||
]:
|
]:
|
||||||
logger.warning(f"PNP spot found with sig {sig}, developer needs to add support for this!")
|
logger.warning(
|
||||||
|
f"PNP spot found with activity {activity}, developer needs to add support for this!"
|
||||||
|
)
|
||||||
|
|
||||||
# Add new spot to the list
|
# Add new spot to the list
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
def can_submit_spot(self, sig):
|
def can_submit_spot(self, activity):
|
||||||
return sig in self.SUBMITTABLE_SIGS
|
return activity in self.SUBMITTABLE_ACTIVITIES
|
||||||
|
|
||||||
def submit_spot(self, spot, credentials):
|
def submit_spot(self, spot, credentials):
|
||||||
# TODO test this works
|
# TODO test this works
|
||||||
@@ -110,11 +128,11 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Parks N Peaks user ID and API key are required. Get yours from your Parks N Peaks account."
|
"Parks N Peaks user ID and API key are required. Get yours from your Parks N Peaks account."
|
||||||
)
|
)
|
||||||
sig_ref = spot.sig_refs[0].id if spot.sig_refs else ""
|
ref_id = spot.sig_refs[0].id if spot.sig_refs else ""
|
||||||
body = {
|
body = {
|
||||||
"actClass": spot.sig or "",
|
"actClass": spot.sig or "",
|
||||||
"actCallsign": spot.dx_call,
|
"actCallsign": spot.dx_call,
|
||||||
"actSite": sig_ref,
|
"actSite": ref_id,
|
||||||
"mode": spot.mode or "",
|
"mode": spot.mode or "",
|
||||||
"freq": str(spot.freq / 1000000.0),
|
"freq": str(spot.freq / 1000000.0),
|
||||||
"comments": spot.comment or "",
|
"comments": spot.comment or "",
|
||||||
|
|||||||
+10
-8
@@ -4,7 +4,8 @@ import pytz
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType, Mode
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
@@ -29,17 +30,18 @@ class POTA(HTTPSpotProvider):
|
|||||||
source_id=source_spot["spotId"],
|
source_id=source_spot["spotId"],
|
||||||
dx_call=source_spot["activator"].upper(),
|
dx_call=source_spot["activator"].upper(),
|
||||||
de_call=source_spot["spotter"].upper(),
|
de_call=source_spot["spotter"].upper(),
|
||||||
freq=float(source_spot["frequency"]) * 1000,
|
freq=float(source_spot["frequency"]) * 1000 if source_spot["frequency"] != "INVALID" else None,
|
||||||
mode=source_spot["mode"].upper(),
|
mode=Mode.from_name(source_spot["mode"].upper()),
|
||||||
comment=source_spot["comments"],
|
comment=source_spot["comments"],
|
||||||
sig="POTA",
|
sig=ActivityName.POTA,
|
||||||
sig_refs=[
|
sig_refs=[
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
id=source_spot["reference"],
|
id=source_spot["reference"],
|
||||||
sig="POTA",
|
sig=ActivityName.POTA,
|
||||||
name=source_spot["name"],
|
name=source_spot["name"],
|
||||||
latitude=source_spot["latitude"],
|
latitude=source_spot["latitude"],
|
||||||
longitude=source_spot["longitude"],
|
longitude=source_spot["longitude"],
|
||||||
|
ref_type=ActivityRefType.PARK
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S")
|
time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S")
|
||||||
@@ -55,8 +57,8 @@ class POTA(HTTPSpotProvider):
|
|||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
def can_submit_spot(self, sig):
|
def can_submit_spot(self, activity):
|
||||||
return sig == "POTA"
|
return activity == ActivityName.POTA
|
||||||
|
|
||||||
def submit_spot(self, spot, credentials):
|
def submit_spot(self, spot, credentials):
|
||||||
sig_ref = spot.sig_refs[0].id if spot.sig_refs else None
|
sig_ref = spot.sig_refs[0].id if spot.sig_refs else None
|
||||||
|
|||||||
+35
-19
@@ -1,8 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from threading import Thread
|
from threading import Event, Lock, Thread
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import telnetlib3
|
import telnetlib3
|
||||||
@@ -30,27 +30,43 @@ class RBN(SpotProvider):
|
|||||||
super().__init__(name, provider_config)
|
super().__init__(name, provider_config)
|
||||||
self._port = provider_config["port"]
|
self._port = provider_config["port"]
|
||||||
self._telnet = None
|
self._telnet = None
|
||||||
self._thread = Thread(target=self._handle, name=f"RBNSpotProvider-{self.name}")
|
self._telnet_lock = Lock()
|
||||||
self._thread.daemon = True
|
self._thread = None
|
||||||
self._running = True
|
self._stop_event = Event()
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
self._thread = Thread(target=self._handle, name=f"RBNSpotProvider-{self.name}", daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._running = False
|
self._stop_event.set()
|
||||||
if self._telnet:
|
with self._telnet_lock:
|
||||||
self._telnet.close()
|
if self._telnet:
|
||||||
self._thread.join()
|
try:
|
||||||
|
self._telnet.sock.shutdown(socket.SHUT_RDWR)
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pass
|
||||||
|
self._telnet.close()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=5)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
logger.warning(f"RBN (port {self._port!s}) worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
def _handle(self):
|
def _handle(self):
|
||||||
while self._running:
|
while not self._stop_event.is_set():
|
||||||
connected = False
|
connected = False
|
||||||
while not connected and self._running:
|
while not connected and not self._stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
self.status = "Connecting"
|
self.status = "Connecting"
|
||||||
logger.info(f"RBN port {self._port!s} connecting...")
|
logger.info(f"RBN port {self._port!s} connecting...")
|
||||||
self._telnet = telnetlib3.Telnet("telnet.reversebeacon.net", self._port)
|
new_telnet = telnetlib3.Telnet("telnet.reversebeacon.net", self._port)
|
||||||
|
with self._telnet_lock:
|
||||||
|
self._telnet = new_telnet
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
# stop() was called while we were connecting, close the connection rather than trying to
|
||||||
|
# read when we know it won't work
|
||||||
|
new_telnet.close()
|
||||||
|
break
|
||||||
self._telnet.read_until("Please enter your call: ".encode("latin-1"))
|
self._telnet.read_until("Please enter your call: ".encode("latin-1"))
|
||||||
self._telnet.write(f"{SERVER_OWNER_CALLSIGN}\n".encode("latin-1"))
|
self._telnet.write(f"{SERVER_OWNER_CALLSIGN}\n".encode("latin-1"))
|
||||||
connected = True
|
connected = True
|
||||||
@@ -58,16 +74,16 @@ class RBN(SpotProvider):
|
|||||||
except Exception:
|
except Exception:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logger.exception(f"Exception while connecting to RBN (port {self._port!s}).")
|
logger.exception(f"Exception while connecting to RBN (port {self._port!s}).")
|
||||||
sleep(5)
|
self._stop_event.wait(timeout=5)
|
||||||
|
|
||||||
self.status = "Waiting for Data"
|
self.status = "Waiting for Data"
|
||||||
while connected and self._running:
|
while connected and not self._stop_event.is_set():
|
||||||
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._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").replace(tzinfo=pytz.UTC)
|
||||||
spot_datetime = datetime.combine(
|
spot_datetime = datetime.combine(
|
||||||
datetime.now(pytz.UTC).date(),
|
datetime.now(pytz.UTC).date(),
|
||||||
spot_time.time(),
|
spot_time.time(),
|
||||||
@@ -91,19 +107,19 @@ class RBN(SpotProvider):
|
|||||||
|
|
||||||
except EOFError:
|
except EOFError:
|
||||||
connected = False
|
connected = False
|
||||||
if self._running:
|
if not self._stop_event.is_set():
|
||||||
self.status = "Restarting"
|
self.status = "Restarting"
|
||||||
logger.warning(f"Disconnected from RBN provider (port {self._port!s}). Reconnecting...")
|
logger.warning(f"Disconnected from RBN provider (port {self._port!s}). Reconnecting...")
|
||||||
sleep(5)
|
self._stop_event.wait(timeout=5)
|
||||||
else:
|
else:
|
||||||
logger.info(f"RBN provider (port {self._port!s}) shutting down...")
|
logger.info(f"RBN provider (port {self._port!s}) shutting down...")
|
||||||
self.status = "Shutting down"
|
self.status = "Shutting down"
|
||||||
except Exception:
|
except Exception:
|
||||||
connected = False
|
connected = False
|
||||||
if self._running:
|
if not self._stop_event.is_set():
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logger.exception(f"Exception in RBN provider (port {self._port!s})")
|
logger.exception(f"Exception in RBN provider (port {self._port!s})")
|
||||||
sleep(5)
|
self._stop_event.wait(timeout=5)
|
||||||
else:
|
else:
|
||||||
logger.info(f"RBN provider (port {self._port!s}) shutting down...")
|
logger.info(f"RBN provider (port {self._port!s}) shutting down...")
|
||||||
self.status = "Shutting down"
|
self.status = "Shutting down"
|
||||||
|
|||||||
+13
-15
@@ -1,11 +1,13 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
||||||
|
|
||||||
from core.constants import DV_SUB_MODES, HTTP_HEADERS, SSB_SUB_MODES
|
from core.constants import HTTP_HEADERS
|
||||||
from data.sig_ref import SIGRef
|
from core.enums import ActivityName, ActivityRefType, Mode
|
||||||
|
from data.activity_ref import ActivityRef
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from providers.spot.http_spot_provider import HTTPSpotProvider
|
from providers.spot.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
@@ -23,7 +25,7 @@ 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"
|
||||||
|
|
||||||
SUBMIT_URL = "https://api-db2.sota.org.uk/api/spots"
|
SUBMIT_URL = "https://api-db2.sota.org.uk/api/spots"
|
||||||
VALID_MODES = ["AM", "CW", "Data", "DV", "FM", "SSB"]
|
VALID_MODES: ClassVar[list[str]] = ["AM", "CW", "Data", "DV", "FM", "SSB"]
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__("SOTA", provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
|
super().__init__("SOTA", provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
|
||||||
@@ -53,17 +55,18 @@ class SOTA(HTTPSpotProvider):
|
|||||||
if (source_spot["frequency"] is not None)
|
if (source_spot["frequency"] is not None)
|
||||||
else None,
|
else None,
|
||||||
# Seen SOTA spots with no frequency!
|
# Seen SOTA spots with no frequency!
|
||||||
mode=source_spot["mode"].upper(),
|
mode=Mode.from_name(source_spot["mode"].upper()),
|
||||||
comment=source_spot["comments"],
|
comment=source_spot["comments"],
|
||||||
sig="SOTA",
|
sig=ActivityName.SOTA,
|
||||||
sig_refs=[
|
sig_refs=[
|
||||||
SIGRef(
|
ActivityRef(
|
||||||
id=source_spot["summitCode"],
|
id=source_spot["summitCode"],
|
||||||
sig="SOTA",
|
sig=ActivityName.SOTA,
|
||||||
name=source_spot["summitName"],
|
name=source_spot["summitName"],
|
||||||
latitude=source_spot["latitude"],
|
latitude=source_spot["latitude"],
|
||||||
longitude=source_spot["longitude"],
|
longitude=source_spot["longitude"],
|
||||||
activation_score=source_spot["points"],
|
activation_score=source_spot["points"],
|
||||||
|
ref_type=ActivityRefType.SUMMIT,
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
dx_latitude=source_spot["latitude"],
|
dx_latitude=source_spot["latitude"],
|
||||||
@@ -80,8 +83,8 @@ class SOTA(HTTPSpotProvider):
|
|||||||
logger.warning("Timeout when accessing SOTA spots API.")
|
logger.warning("Timeout when accessing SOTA spots API.")
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|
||||||
def can_submit_spot(self, sig):
|
def can_submit_spot(self, activity):
|
||||||
return sig == "SOTA"
|
return activity == ActivityName.SOTA
|
||||||
|
|
||||||
def submit_spot(self, spot, credentials):
|
def submit_spot(self, spot, credentials):
|
||||||
# TODO test this method works
|
# TODO test this method works
|
||||||
@@ -98,12 +101,7 @@ class SOTA(HTTPSpotProvider):
|
|||||||
# https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js
|
# https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js
|
||||||
mode = spot.mode
|
mode = spot.mode
|
||||||
if mode and mode not in self.VALID_MODES:
|
if mode and mode not in self.VALID_MODES:
|
||||||
if mode in SSB_SUB_MODES:
|
mode = "Data"
|
||||||
mode = "SSB"
|
|
||||||
elif mode in DV_SUB_MODES:
|
|
||||||
mode = "DV"
|
|
||||||
else:
|
|
||||||
mode = "Data"
|
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
"activatorCallsign": spot.dx_call,
|
"activatorCallsign": spot.dx_call,
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ class SpotProvider:
|
|||||||
|
|
||||||
raise NotImplementedError("Subclasses must implement this method")
|
raise NotImplementedError("Subclasses must implement this method")
|
||||||
|
|
||||||
def can_submit_spot(self, sig):
|
def can_submit_spot(self, activity):
|
||||||
"""Return True if this provider supports submitting spots upstream for the given SIG."""
|
"""Return True if this provider supports submitting spots upstream for the given activity."""
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class SSESpotProvider(SpotProvider):
|
|||||||
logger.exception(f"Exception closing SSE connection for {self.name} during stop()")
|
logger.exception(f"Exception closing SSE connection for {self.name} during stop()")
|
||||||
|
|
||||||
if self._thread:
|
if self._thread:
|
||||||
self._thread.join(timeout=15)
|
self._thread.join(timeout=5)
|
||||||
if self._thread.is_alive():
|
if self._thread.is_alive():
|
||||||
logger.warning(f"{self.name} SSE worker thread did not exit on time and will be killed.")
|
logger.warning(f"{self.name} SSE worker thread did not exit on time and will be killed.")
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ class SSESpotProvider(SpotProvider):
|
|||||||
self._url,
|
self._url,
|
||||||
headers=HTTP_HEADERS,
|
headers=HTTP_HEADERS,
|
||||||
latest_event_id=self._last_event_id,
|
latest_event_id=self._last_event_id,
|
||||||
timeout=10,
|
timeout=30,
|
||||||
on_open=self._on_open,
|
on_open=self._on_open,
|
||||||
on_error=self._on_error,
|
on_error=self._on_error,
|
||||||
) as event_source:
|
) as event_source:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user