mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-09-21 14:57:42 +00:00
Autogenerated type safety parameterisation of all methods
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from threading import Event, Thread
|
||||
from typing import Any
|
||||
|
||||
import aprslib
|
||||
import pytz
|
||||
@@ -15,17 +18,17 @@ logger = logging.getLogger(__name__)
|
||||
class APRSIS(SpotProvider):
|
||||
"""Spot provider for the APRS-IS."""
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, provider_config: dict[str, Any]) -> None:
|
||||
super().__init__("APRS-IS", provider_config)
|
||||
self._thread = None
|
||||
self._aprsis = None
|
||||
self._stop_event = Event()
|
||||
self._thread: Thread | None = None
|
||||
self._aprsis: aprslib.IS | None = None
|
||||
self._stop_event: Event = Event()
|
||||
|
||||
def start(self):
|
||||
def start(self) -> None:
|
||||
self._thread = Thread(target=self._run, name="APRSISSpotProvider", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _run(self):
|
||||
def _run(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
self._aprsis = aprslib.IS(SERVER_OWNER_CALLSIGN)
|
||||
@@ -43,7 +46,7 @@ class APRSIS(SpotProvider):
|
||||
if not self._stop_event.is_set():
|
||||
self._stop_event.wait(timeout=5)
|
||||
|
||||
def stop(self):
|
||||
def stop(self) -> None:
|
||||
self.status = "Shutting down"
|
||||
self._stop_event.set()
|
||||
if self._aprsis:
|
||||
@@ -53,7 +56,7 @@ class APRSIS(SpotProvider):
|
||||
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: dict[str, Any]) -> None:
|
||||
try:
|
||||
# Split SSID in "from" call and store separately
|
||||
from_parts = str(data["from"]).split("-")
|
||||
|
||||
+18
-15
@@ -1,16 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
from datetime import datetime
|
||||
from threading import Event, Lock, Thread
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
import telnetlib3
|
||||
|
||||
from core.config import SERVER_OWNER_CALLSIGN
|
||||
from core.utils import decode_telnet_bytes
|
||||
from data.spot import Spot
|
||||
from providers.spot.spot_provider import SpotProvider
|
||||
from core.utils import decode_telnet_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,29 +31,29 @@ class DXCluster(SpotProvider):
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, provider_config: dict[str, Any]) -> None:
|
||||
"""Constructor requires hostname and port"""
|
||||
|
||||
name = provider_config.get("name", "Cluster")
|
||||
super().__init__(name, provider_config)
|
||||
self._hostname = provider_config["host"]
|
||||
self._port = provider_config["port"]
|
||||
self._login_prompt = provider_config.get("login_prompt", "login:")
|
||||
self._login_callsign = provider_config.get("login_callsign", SERVER_OWNER_CALLSIGN)
|
||||
self._allow_rbn_spots = provider_config.get("allow_rbn_spots", False)
|
||||
self._spot_line_pattern = (
|
||||
self._hostname: str = provider_config["host"]
|
||||
self._port: int = provider_config["port"]
|
||||
self._login_prompt: str = provider_config.get("login_prompt", "login:")
|
||||
self._login_callsign: str = provider_config.get("login_callsign", SERVER_OWNER_CALLSIGN)
|
||||
self._allow_rbn_spots: bool = provider_config.get("allow_rbn_spots", False)
|
||||
self._spot_line_pattern: re.Pattern[str] = (
|
||||
self._LINE_PATTERN_ALLOW_RBN if self._allow_rbn_spots else self._LINE_PATTERN_EXCLUDE_RBN
|
||||
)
|
||||
self._telnet = None
|
||||
self._telnet_lock = Lock()
|
||||
self._thread = None
|
||||
self._stop_event = Event()
|
||||
self._telnet: telnetlib3.Telnet | None = None
|
||||
self._telnet_lock: Lock = Lock()
|
||||
self._thread: Thread | None = None
|
||||
self._stop_event: Event = Event()
|
||||
|
||||
def start(self):
|
||||
def start(self) -> None:
|
||||
self._thread = Thread(target=self._handle, name=f"DXClusterSpotProvider-{self.name}", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
with self._telnet_lock:
|
||||
if self._telnet:
|
||||
@@ -64,7 +67,7 @@ class DXCluster(SpotProvider):
|
||||
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) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
connected = False
|
||||
while not connected and not self._stop_event.is_set():
|
||||
|
||||
+11
-7
@@ -1,7 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from core.enums import ActivityName, ActivityRefType, Mode
|
||||
@@ -21,14 +25,14 @@ class GMA(HTTPSpotProvider):
|
||||
# GMA spots don't contain the details of the programme they are for, we need a separate lookup for that
|
||||
REF_INFO_URL_ROOT = "https://www.gma.rocks/api/ref/?"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, provider_config: dict[str, Any]) -> None:
|
||||
# Ensure there is an API key in our config, and set up the query URL using it. If no key is provided,
|
||||
# disable this spot provider.
|
||||
self._api_key = provider_config.get("api_key", "")
|
||||
self._api_key: str = provider_config.get("api_key", "")
|
||||
if self._api_key == "":
|
||||
provider_config["enabled"] = False
|
||||
logger.warning("GMA spot provider configured but no api key was provided, this API will not be queried.")
|
||||
self._url_data_cache = URLDataCache("GMA")
|
||||
self._url_data_cache: URLDataCache = URLDataCache("GMA")
|
||||
|
||||
super().__init__(
|
||||
"GMA",
|
||||
@@ -37,8 +41,8 @@ class GMA(HTTPSpotProvider):
|
||||
self.POLL_INTERVAL_SEC,
|
||||
)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
|
||||
new_spots: list[Spot] = []
|
||||
# Iterate through source data
|
||||
if "RCD" in http_response.json():
|
||||
for source_spot in http_response.json()["RCD"]:
|
||||
@@ -172,10 +176,10 @@ class GMA(HTTPSpotProvider):
|
||||
|
||||
return new_spots
|
||||
|
||||
def can_submit_spot(self, activity):
|
||||
def can_submit_spot(self, activity: str) -> bool:
|
||||
return activity == ActivityName.GMA
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
|
||||
# TODO: Implement.
|
||||
# Spotting to GMA is documented: https://www.cqgma.org/api/doc/apigma_spot.pdf We (or the user) need a GMA account, and to send the password in plaintext(!!)
|
||||
raise NotImplementedError("GMA upstream spot submission is not yet implemented")
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
@@ -27,17 +30,17 @@ class HEMA(HTTPSpotProvider):
|
||||
FREQ_MODE_PATTERN = re.compile("^([\\d.]*) \\((.*)\\)$")
|
||||
SPOTTER_COMMENT_PATTERN = re.compile("^\\((.*)\\) (.*)$")
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, provider_config: dict[str, Any]) -> None:
|
||||
super().__init__("HEMA", provider_config, self.SPOT_SEED_URL, self.POLL_INTERVAL_SEC)
|
||||
self._spot_seed = ""
|
||||
self._spot_seed: str = ""
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
|
||||
# OK, source data is actually just the spot seed at this point. We'll then go on to fetch real data if we know
|
||||
# this has changed.
|
||||
spot_seed_changed = http_response.text != self._spot_seed
|
||||
self._spot_seed = http_response.text
|
||||
|
||||
new_spots = []
|
||||
new_spots: list[Spot] = []
|
||||
# OK, if the spot seed actually changed, now we make the real request for data.
|
||||
if spot_seed_changed:
|
||||
try:
|
||||
@@ -89,10 +92,10 @@ class HEMA(HTTPSpotProvider):
|
||||
logger.warning("Connection error when accessing HEMA spots API.")
|
||||
return new_spots
|
||||
|
||||
def can_submit_spot(self, activity):
|
||||
def can_submit_spot(self, activity: str) -> bool:
|
||||
return activity == ActivityName.HEMA
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
|
||||
# TODO: Implement. Currently blocked awaiting their API team to make a change to allow us to spot with a
|
||||
# reference and not a reference *number*.
|
||||
raise NotImplementedError("HEMA upstream spot submission is not yet implemented")
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from threading import Event, Thread
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, ConnectTimeout, JSONDecodeError, ReadTimeout
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from data.spot import Spot
|
||||
from providers.spot.spot_provider import SpotProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -16,22 +20,22 @@ class HTTPSpotProvider(SpotProvider):
|
||||
"""Generic spot provider class for providers that request data via HTTP(S). Just for convenience to avoid code
|
||||
duplication. Subclasses of this query the individual APIs for data."""
|
||||
|
||||
def __init__(self, name, provider_config, url, poll_interval):
|
||||
def __init__(self, name: str, provider_config: dict[str, Any], url: str, poll_interval: float) -> None:
|
||||
super().__init__(name, provider_config)
|
||||
self._url = url
|
||||
self._poll_interval = poll_interval
|
||||
self._thread = None
|
||||
self._stop_event = Event()
|
||||
self._wakeup_event = Event()
|
||||
self._url: str = url
|
||||
self._poll_interval: float = poll_interval
|
||||
self._thread: Thread | None = None
|
||||
self._stop_event: Event = Event()
|
||||
self._wakeup_event: Event = Event()
|
||||
|
||||
def start(self):
|
||||
def start(self) -> None:
|
||||
# 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.
|
||||
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}", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
self._wakeup_event.set()
|
||||
if self._thread:
|
||||
@@ -39,12 +43,12 @@ class HTTPSpotProvider(SpotProvider):
|
||||
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) -> None:
|
||||
"""Trigger an immediate poll without waiting for the normal interval."""
|
||||
|
||||
self._wakeup_event.set()
|
||||
|
||||
def _run(self):
|
||||
def _run(self) -> None:
|
||||
while True:
|
||||
self._wakeup_event.clear()
|
||||
self._poll()
|
||||
@@ -52,7 +56,7 @@ class HTTPSpotProvider(SpotProvider):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
def _poll(self):
|
||||
def _poll(self) -> None:
|
||||
try:
|
||||
# Request data from API
|
||||
logger.debug(f"Polling {self.name} spot API...")
|
||||
@@ -86,7 +90,7 @@ class HTTPSpotProvider(SpotProvider):
|
||||
logger.exception(f"Exception in HTTP Spot Provider ({self.name})")
|
||||
self._stop_event.wait(timeout=1)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot] | None:
|
||||
"""Convert an HTTP response returned by the API into spot data. The whole response is provided here so the subclass
|
||||
implementations can check for HTTP status codes if necessary, and handle the response as JSON, XML, text, whatever
|
||||
the API actually provides."""
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from core.enums import ActivityName, ActivityRefType, Mode
|
||||
from data.activity_ref import ActivityRef
|
||||
@@ -12,11 +17,11 @@ class LLOTA(HTTPSpotProvider):
|
||||
POLL_INTERVAL_SEC = 120
|
||||
SPOTS_URL = "https://llota.app/api/public/spots"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, provider_config: dict[str, Any]) -> None:
|
||||
super().__init__("LLOTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
|
||||
new_spots: list[Spot] = []
|
||||
# Iterate through source data
|
||||
for source_spot in http_response.json():
|
||||
# Find the most recent spotter and comment from the history array
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import ClassVar
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
@@ -33,11 +35,11 @@ class ParksNPeaks(HTTPSpotProvider):
|
||||
ActivityName.SANPCPA,
|
||||
]
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, provider_config: dict[str, Any]) -> None:
|
||||
super().__init__("ParksNPeaks", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
|
||||
new_spots: list[Spot] = []
|
||||
# Iterate through source data
|
||||
if http_response and http_response != "":
|
||||
for source_spot in http_response.json():
|
||||
@@ -117,10 +119,10 @@ class ParksNPeaks(HTTPSpotProvider):
|
||||
new_spots.append(spot)
|
||||
return new_spots
|
||||
|
||||
def can_submit_spot(self, activity):
|
||||
def can_submit_spot(self, activity: str) -> bool:
|
||||
return activity in self.SUBMITTABLE_ACTIVITIES
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
|
||||
# TODO test this works
|
||||
user_id = credentials.get("user_id", "")
|
||||
api_key = credentials.get("api_key", "")
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
@@ -17,11 +20,11 @@ class POTA(HTTPSpotProvider):
|
||||
SPOTS_URL = "https://api.pota.app/spot/activator"
|
||||
SUBMIT_URL = "https://api.pota.app/spot"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, provider_config: dict[str, Any]) -> None:
|
||||
super().__init__("POTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
|
||||
new_spots: list[Spot] = []
|
||||
# Iterate through source data
|
||||
for source_spot in http_response.json():
|
||||
# Convert to our spot format
|
||||
@@ -57,10 +60,10 @@ class POTA(HTTPSpotProvider):
|
||||
new_spots.append(spot)
|
||||
return new_spots
|
||||
|
||||
def can_submit_spot(self, activity):
|
||||
def can_submit_spot(self, activity: str) -> bool:
|
||||
return activity == ActivityName.POTA
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
|
||||
sig_ref = spot.sig_refs[0].id if spot.sig_refs else None
|
||||
if sig_ref:
|
||||
body = {
|
||||
|
||||
+13
-10
@@ -1,16 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
from datetime import datetime
|
||||
from threading import Event, Lock, Thread
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
import telnetlib3
|
||||
|
||||
from core.config import SERVER_OWNER_CALLSIGN
|
||||
from core.utils import decode_telnet_bytes
|
||||
from data.spot import Spot
|
||||
from providers.spot.spot_provider import SpotProvider
|
||||
from core.utils import decode_telnet_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,22 +27,22 @@ class RBN(SpotProvider):
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, provider_config: dict[str, Any]) -> None:
|
||||
"""Constructor requires port number."""
|
||||
|
||||
name = provider_config.get("name", "RBN")
|
||||
super().__init__(name, provider_config)
|
||||
self._port = provider_config["port"]
|
||||
self._telnet = None
|
||||
self._telnet_lock = Lock()
|
||||
self._thread = None
|
||||
self._stop_event = Event()
|
||||
self._port: int = provider_config["port"]
|
||||
self._telnet: telnetlib3.Telnet | None = None
|
||||
self._telnet_lock: Lock = Lock()
|
||||
self._thread: Thread | None = None
|
||||
self._stop_event: Event = Event()
|
||||
|
||||
def start(self):
|
||||
def start(self) -> None:
|
||||
self._thread = Thread(target=self._handle, name=f"RBNSpotProvider-{self.name}", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
with self._telnet_lock:
|
||||
if self._telnet:
|
||||
@@ -53,7 +56,7 @@ class RBN(SpotProvider):
|
||||
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) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
connected = False
|
||||
while not connected and not self._stop_event.is_set():
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import ClassVar
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import requests
|
||||
from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
|
||||
@@ -27,17 +29,17 @@ class SOTA(HTTPSpotProvider):
|
||||
SUBMIT_URL = "https://api-db2.sota.org.uk/api/spots"
|
||||
VALID_MODES: ClassVar[list[str]] = ["AM", "CW", "Data", "DV", "FM", "SSB"]
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, provider_config: dict[str, Any]) -> None:
|
||||
super().__init__("SOTA", provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
|
||||
self._api_epoch = ""
|
||||
self._api_epoch: str = ""
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
|
||||
# OK, source data is actually just the epoch at this point. We'll then go on to fetch real data if we know this
|
||||
# has changed.
|
||||
epoch_changed = http_response.text != self._api_epoch
|
||||
self._api_epoch = http_response.text
|
||||
|
||||
new_spots = []
|
||||
new_spots: list[Spot] = []
|
||||
# OK, if the epoch actually changed, now we make the real request for data.
|
||||
if epoch_changed:
|
||||
try:
|
||||
@@ -83,10 +85,10 @@ class SOTA(HTTPSpotProvider):
|
||||
logger.warning("Timeout when accessing SOTA spots API.")
|
||||
return new_spots
|
||||
|
||||
def can_submit_spot(self, activity):
|
||||
def can_submit_spot(self, activity: str) -> bool:
|
||||
return activity == ActivityName.SOTA
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
|
||||
# TODO test this method works
|
||||
access_token = credentials.get("access_token", "")
|
||||
id_token = credentials.get("id_token", "")
|
||||
|
||||
@@ -1,30 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytz
|
||||
|
||||
from core.data_store import DATA_STORE
|
||||
from core.live_data_cache import LiveDataCache
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Deferred to avoid a circular import: data.spot imports core.call_lookup_helper, which imports
|
||||
# core.data_providers, which imports this module.
|
||||
from data.spot import Spot
|
||||
|
||||
|
||||
class SpotProvider:
|
||||
"""Generic spot provider class. Subclasses of this query the individual APIs for data."""
|
||||
|
||||
def __init__(self, name, provider_config):
|
||||
def __init__(self, name: str, provider_config: dict[str, Any]) -> None:
|
||||
"""Constructor"""
|
||||
|
||||
self.name = name
|
||||
self.enabled = provider_config.get("enabled", True)
|
||||
self.enabled_by_default_in_web_ui = provider_config.get("enabled_by_default_in_web_ui", True)
|
||||
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||
self.last_spot_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||
self.status = "Not Started" if self.enabled else "Disabled"
|
||||
self._spots = DATA_STORE.spots
|
||||
self.name: str = name
|
||||
self.enabled: bool = provider_config.get("enabled", True)
|
||||
self.enabled_by_default_in_web_ui: bool = provider_config.get("enabled_by_default_in_web_ui", True)
|
||||
self.last_update_time: datetime = datetime.min.replace(tzinfo=pytz.UTC)
|
||||
self.last_spot_time: datetime = datetime.min.replace(tzinfo=pytz.UTC)
|
||||
self.status: str = "Not Started" if self.enabled else "Disabled"
|
||||
self._spots: LiveDataCache[Spot] = DATA_STORE.spots
|
||||
|
||||
def start(self):
|
||||
def start(self) -> None:
|
||||
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
|
||||
|
||||
raise NotImplementedError("Subclasses must implement this method")
|
||||
|
||||
def _submit_batch(self, spots):
|
||||
def _submit_batch(self, spots: list[Spot]) -> None:
|
||||
"""Submit a batch of spots retrieved from the provider. Only spots that are newer than the last spot retrieved
|
||||
by this provider will be added to the spot list, to prevent duplications. Spots passing the check will also have
|
||||
their infer_missing() method called to complete their data set. This is called by the API-querying
|
||||
@@ -41,7 +50,7 @@ class SpotProvider:
|
||||
if spots:
|
||||
self.last_spot_time = datetime.fromtimestamp(max(s.time for s in spots), pytz.UTC)
|
||||
|
||||
def _submit(self, spot):
|
||||
def _submit(self, spot: Spot) -> None:
|
||||
"""Submit a single spot retrieved from the provider. This will be added to the list regardless of its age. Spots
|
||||
passing the check will also have their infer_missing() method called to complete their data set. This is called by
|
||||
the data streaming subclasses, which can be relied upon not to re-provide old spots."""
|
||||
@@ -51,27 +60,27 @@ class SpotProvider:
|
||||
self._add_spot(spot)
|
||||
self.last_spot_time = datetime.fromtimestamp(spot.time, pytz.UTC)
|
||||
|
||||
def _add_spot(self, spot):
|
||||
def _add_spot(self, spot: Spot) -> None:
|
||||
if not spot.expired():
|
||||
self._spots.set(spot.id, spot)
|
||||
|
||||
def stop(self):
|
||||
def stop(self) -> None:
|
||||
"""Stop any threads and prepare for application shutdown"""
|
||||
|
||||
raise NotImplementedError("Subclasses must implement this method")
|
||||
|
||||
def can_submit_spot(self, activity):
|
||||
def can_submit_spot(self, activity: str) -> bool:
|
||||
"""Return True if this provider supports submitting spots upstream for the given activity."""
|
||||
|
||||
return False
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
|
||||
"""Submit a spot upstream to this provider's API. credentials is a dict with provider-specific keys.
|
||||
Raises an exception with a descriptive message on failure."""
|
||||
|
||||
raise NotImplementedError("This provider does not support spot submission")
|
||||
|
||||
def force_poll(self):
|
||||
def force_poll(self) -> None:
|
||||
"""Trigger an immediate poll without waiting for the normal interval. Default implementation here does nothing
|
||||
because not all spot providers have a polling mechanism. Providers that do should override this method."""
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from threading import Event, Lock, Thread
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
from requests_sse import EventSource, InvalidStatusCodeError
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from data.spot import Spot
|
||||
from providers.spot.spot_provider import SpotProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -14,23 +18,23 @@ logger = logging.getLogger(__name__)
|
||||
class SSESpotProvider(SpotProvider):
|
||||
"""Spot provider using Server-Sent Events."""
|
||||
|
||||
def __init__(self, name, provider_config, url):
|
||||
def __init__(self, name: str, provider_config: dict[str, Any], url: str) -> None:
|
||||
super().__init__(name, provider_config)
|
||||
self._url = url
|
||||
self._thread = None
|
||||
self._last_event_id = None
|
||||
self._stop_event = Event()
|
||||
self._event_source_lock = Lock()
|
||||
self._event_source = None
|
||||
self._url: str = url
|
||||
self._thread: Thread | None = None
|
||||
self._last_event_id: str | None = None
|
||||
self._stop_event: Event = Event()
|
||||
self._event_source_lock: Lock = Lock()
|
||||
self._event_source: EventSource | None = None
|
||||
|
||||
def start(self):
|
||||
def start(self) -> None:
|
||||
logger.info(f"Set up SSE connection to {self.name} spot API.")
|
||||
self._stop_event.clear()
|
||||
self._thread = Thread(target=self._run, name=f"SSESpotProvider-{self.name}")
|
||||
self._thread.daemon = True
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
|
||||
with self._event_source_lock:
|
||||
@@ -46,17 +50,17 @@ class SSESpotProvider(SpotProvider):
|
||||
if self._thread.is_alive():
|
||||
logger.warning(f"{self.name} SSE worker thread did not exit on time and will be killed.")
|
||||
|
||||
def _on_open(self):
|
||||
def _on_open(self) -> None:
|
||||
self.status = "Waiting for Data"
|
||||
|
||||
def _on_error(self):
|
||||
def _on_error(self) -> None:
|
||||
self.status = "Connecting"
|
||||
|
||||
def _set_event_source(self, event_source):
|
||||
def _set_event_source(self, event_source: EventSource | None) -> None:
|
||||
with self._event_source_lock:
|
||||
self._event_source = event_source
|
||||
|
||||
def _run(self):
|
||||
def _run(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
logger.debug(f"Connecting to {self.name} spot API...")
|
||||
@@ -102,7 +106,7 @@ class SSESpotProvider(SpotProvider):
|
||||
self.status = "Disconnected"
|
||||
self._stop_event.wait(timeout=5) # Wait before trying to reconnect
|
||||
|
||||
def _sse_message_to_spot(self, message_data):
|
||||
def _sse_message_to_spot(self, message_data: str) -> Spot | None:
|
||||
"""Convert an SSE message received from the API into a spot. The whole message data is provided here so the subclass
|
||||
implementations can handle the message as JSON, XML, text, whatever the API actually provides."""
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import ClassVar
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import requests
|
||||
|
||||
@@ -36,11 +38,11 @@ class Tiles(HTTPSpotProvider):
|
||||
"Other",
|
||||
]
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, provider_config: dict[str, Any]) -> None:
|
||||
super().__init__("Tiles", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
|
||||
new_spots: list[Spot] = []
|
||||
# Iterate through source data
|
||||
for source_spot in http_response.json()["spots"]:
|
||||
# Convert to our spot format
|
||||
@@ -84,10 +86,10 @@ class Tiles(HTTPSpotProvider):
|
||||
new_spots.append(spot)
|
||||
return new_spots
|
||||
|
||||
def can_submit_spot(self, activity):
|
||||
def can_submit_spot(self, activity: str) -> bool:
|
||||
return activity == ActivityName.TILES
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
|
||||
# Tiles on the air currently only supports *self* spots
|
||||
if spot.dx_call == spot.de_call:
|
||||
# Figure out a valid mode. Borrowed this from PoLo :)
|
||||
@@ -127,7 +129,7 @@ class Tiles(HTTPSpotProvider):
|
||||
|
||||
# Utility function to keep the first decimal point in a given string but remove any others. Used to parse Tiles'
|
||||
# strange frequency format where we can sometimes have e.g. "14.123.5".
|
||||
def strip_extra_decimal_points(s):
|
||||
def strip_extra_decimal_points(s: str) -> str:
|
||||
parts = s.split(".", 1)
|
||||
if len(parts) == 1:
|
||||
return s
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from core.enums import ActivityName, ActivityRefType
|
||||
from data.activity_ref import ActivityRef
|
||||
@@ -15,11 +19,11 @@ class Towers(HTTPSpotProvider):
|
||||
POLL_INTERVAL_SEC = 120
|
||||
SPOTS_URL = "https://wwtota.com/api/cluster_live.php"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, provider_config: dict[str, Any]) -> None:
|
||||
super().__init__("Towers", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
|
||||
new_spots: list[Spot] = []
|
||||
response_fixed = http_response.text.replace("\\/", "/")
|
||||
response_json = json.loads(response_fixed)
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from core.enums import Mode
|
||||
from data.spot import Spot
|
||||
@@ -14,11 +18,11 @@ class UKPacketNet(HTTPSpotProvider):
|
||||
POLL_INTERVAL_SEC = 600
|
||||
SPOTS_URL = "https://nodes.ukpacketradio.network/api/nodedata"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, provider_config: dict[str, Any]) -> None:
|
||||
super().__init__("UK Packet Net", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
|
||||
new_spots: list[Spot] = []
|
||||
# Iterate through source data
|
||||
nodes = http_response.json()["nodes"]
|
||||
for node in nodes.values():
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from threading import Event, Thread
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
from websocket import create_connection
|
||||
from websocket import WebSocket, create_connection
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from data.spot import Spot
|
||||
from providers.spot.spot_provider import SpotProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -14,22 +18,22 @@ logger = logging.getLogger(__name__)
|
||||
class WebsocketSpotProvider(SpotProvider):
|
||||
"""Spot provider using websockets."""
|
||||
|
||||
def __init__(self, name, provider_config, url):
|
||||
def __init__(self, name: str, provider_config: dict[str, Any], url: str) -> None:
|
||||
super().__init__(name, provider_config)
|
||||
self._url = url
|
||||
self._ws = None
|
||||
self._thread = None
|
||||
self._stop_event = Event()
|
||||
self._last_event_id = None
|
||||
self._url: str = url
|
||||
self._ws: WebSocket | None = None
|
||||
self._thread: Thread | None = None
|
||||
self._stop_event: Event = Event()
|
||||
self._last_event_id: str | None = None
|
||||
|
||||
def start(self):
|
||||
def start(self) -> None:
|
||||
logger.info(f"Set up websocket connection to {self.name} spot API.")
|
||||
self._stop_event.clear()
|
||||
self._thread = Thread(target=self._run, name=f"WebsocketSpotProvider-{self.name}")
|
||||
self._thread.daemon = True
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
if self._ws:
|
||||
self._ws.close()
|
||||
@@ -38,13 +42,13 @@ class WebsocketSpotProvider(SpotProvider):
|
||||
if self._thread.is_alive():
|
||||
logger.warning(f"{self.name} websocket worker thread did not exit on time and will be killed.")
|
||||
|
||||
def _on_open(self):
|
||||
def _on_open(self) -> None:
|
||||
self.status = "Waiting for Data"
|
||||
|
||||
def _on_error(self):
|
||||
def _on_error(self) -> None:
|
||||
self.status = "Connecting"
|
||||
|
||||
def _run(self):
|
||||
def _run(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
logger.debug(f"Connecting to {self.name} spot API...")
|
||||
@@ -86,7 +90,7 @@ class WebsocketSpotProvider(SpotProvider):
|
||||
if not self._stop_event.is_set():
|
||||
self._stop_event.wait(timeout=5) # Wait before trying to reconnect
|
||||
|
||||
def _ws_message_to_spot(self, b):
|
||||
def _ws_message_to_spot(self, b: str | bytes) -> Spot | None:
|
||||
"""Convert a WS message received from the API into a spot. The exact message data (in bytes) is provided here so the
|
||||
subclass implementations can handle the message as string, JSON, XML, whatever the API actually provides."""
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
from typing import Any, cast
|
||||
from xml.parsers.expat import ExpatError
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
from rss_parser import Parser
|
||||
from rss_parser.models.rss import RSS
|
||||
|
||||
@@ -23,11 +26,11 @@ class WOTA(HTTPSpotProvider):
|
||||
SPOTS_URL = "https://www.wota.org.uk/spots_rss.php"
|
||||
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, provider_config: dict[str, Any]) -> None:
|
||||
super().__init__("WOTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
|
||||
new_spots: list[Spot] = []
|
||||
try:
|
||||
rss = cast(RSS, Parser.parse(http_response.content.decode("utf-8-sig")))
|
||||
# Iterate through source data
|
||||
@@ -110,9 +113,9 @@ class WOTA(HTTPSpotProvider):
|
||||
|
||||
return new_spots
|
||||
|
||||
def can_submit_spot(self, activity):
|
||||
def can_submit_spot(self, activity: str) -> bool:
|
||||
return activity == ActivityName.WOTA
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
|
||||
# TODO Ask M5TEA if he's happy to share how this is done from his app
|
||||
raise NotImplementedError("WOTA upstream spot submission is not yet implemented")
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from core.enums import ActivityName, ActivityRefType, Mode
|
||||
from data.activity_ref import ActivityRef
|
||||
@@ -12,14 +15,14 @@ class WWBOTA(SSESpotProvider):
|
||||
|
||||
SPOTS_URL = "https://api.wwbota.net/spots/"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, provider_config: dict[str, Any]) -> None:
|
||||
super().__init__("WWBOTA", provider_config, self.SPOTS_URL)
|
||||
|
||||
def _sse_message_to_spot(self, message_data):
|
||||
def _sse_message_to_spot(self, message_data: str) -> Spot | None:
|
||||
source_spot = json.loads(message_data)
|
||||
# Convert to our spot format. First we unpack references, because WWBOTA spots can have more than one for
|
||||
# n-fer activations.
|
||||
refs = []
|
||||
refs: list[ActivityRef] = []
|
||||
for ref in source_spot["references"]:
|
||||
activity_ref = ActivityRef(
|
||||
id=ref["reference"],
|
||||
@@ -52,9 +55,9 @@ class WWBOTA(SSESpotProvider):
|
||||
# WWBOTA does support a special "Test" spot type, we need to avoid adding that.
|
||||
return spot if source_spot["type"] != "Test" else None
|
||||
|
||||
def can_submit_spot(self, activity):
|
||||
def can_submit_spot(self, activity: str) -> bool:
|
||||
return activity == ActivityName.WWBOTA
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
|
||||
# TODO: Implement. WWBOTA API docs cover this: https://api.wwbota.org/#tag/Spots/operation/create_spot_spots__post
|
||||
raise NotImplementedError("WWBOTA upstream spot submission is not yet implemented")
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from core.enums import ActivityName, ActivityRefType, Mode
|
||||
from data.activity_ref import ActivityRef
|
||||
@@ -14,11 +18,11 @@ class WWFF(HTTPSpotProvider):
|
||||
POLL_INTERVAL_SEC = 120
|
||||
SPOTS_URL = "https://spots.wwff.co/static/spots.json"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, provider_config: dict[str, Any]) -> None:
|
||||
super().__init__("WWFF", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
|
||||
new_spots: list[Spot] = []
|
||||
# Iterate through source data
|
||||
for source_spot in http_response.json():
|
||||
# Convert to our spot format
|
||||
@@ -51,10 +55,10 @@ class WWFF(HTTPSpotProvider):
|
||||
new_spots.append(spot)
|
||||
return new_spots
|
||||
|
||||
def can_submit_spot(self, activity):
|
||||
def can_submit_spot(self, activity: str) -> bool:
|
||||
return activity == ActivityName.WWFF
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
|
||||
# TODO: Implement. Spotting to WWFF should be possible, need to look up the Spotline docs or copy approach from
|
||||
# PoLo. Either way I think we need an API key for the app (but maybe not for the user?)
|
||||
raise NotImplementedError("WWFF upstream spot submission is not yet implemented")
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
|
||||
@@ -17,15 +20,17 @@ class XOTA(WebsocketSpotProvider):
|
||||
is why we also provide a sig_ref_prefix in our config. This is applied to the reference ID, so e.g. "T-01" at C3
|
||||
might become "C3 T-01". This allows us to provide location lookups for TOTA at several conferences."""
|
||||
|
||||
ACTIVITY = None
|
||||
ACTIVITY: str | None = None
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, provider_config: dict[str, Any]) -> None:
|
||||
name = provider_config.get("name", "xOTA")
|
||||
super().__init__(name, provider_config, provider_config["url"])
|
||||
self.ACTIVITY = str(provider_config["sig"]) if "sig" in provider_config else None
|
||||
self._activity_ref_prefix = str(provider_config["sig_ref_prefix"]) if "sig_ref_prefix" in provider_config else ""
|
||||
self._activity_ref_prefix: str = (
|
||||
str(provider_config["sig_ref_prefix"]) if "sig_ref_prefix" in provider_config else ""
|
||||
)
|
||||
|
||||
def _ws_message_to_spot(self, b):
|
||||
def _ws_message_to_spot(self, b: str | bytes) -> Spot | None:
|
||||
string = b.decode("utf-8")
|
||||
source_spot = json.loads(string)
|
||||
ref_id = f"{self._activity_ref_prefix} {source_spot['reference']['title']}"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from core.enums import ActivityName, Mode
|
||||
from data.activity_ref import ActivityRef
|
||||
@@ -14,11 +18,11 @@ class ZLOTA(HTTPSpotProvider):
|
||||
POLL_INTERVAL_SEC = 120
|
||||
SPOTS_URL = "https://ontheair.nz/api/spots?zlota_only=true"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
def __init__(self, provider_config: dict[str, Any]) -> None:
|
||||
super().__init__("ZLOTA", provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
def _http_response_to_spots(self, http_response: requests.Response) -> list[Spot]:
|
||||
new_spots: list[Spot] = []
|
||||
# Iterate through source data
|
||||
for source_spot in http_response.json():
|
||||
# Frequency is often inconsistent as to whether it's in Hz or kHz. Make a guess.
|
||||
@@ -51,9 +55,9 @@ class ZLOTA(HTTPSpotProvider):
|
||||
new_spots.append(spot)
|
||||
return new_spots
|
||||
|
||||
def can_submit_spot(self, activity):
|
||||
def can_submit_spot(self, activity: str) -> bool:
|
||||
return activity == ActivityName.ZLOTA
|
||||
|
||||
def submit_spot(self, spot, credentials):
|
||||
def submit_spot(self, spot: Spot, credentials: dict[str, str]) -> None:
|
||||
# TODO: Implement. Spotting to ZLOTA is supported via POST, see https://ontheair.nz/api
|
||||
raise NotImplementedError("ZLOTA upstream spot submission is not yet implemented")
|
||||
|
||||
Reference in New Issue
Block a user