Autogenerated type safety parameterisation of all methods

This commit is contained in:
Ian Renton
2026-09-20 20:02:19 +01:00
parent 6037e742cc
commit 324dd1414b
132 changed files with 1228 additions and 706 deletions
+11 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+9 -6
View File
@@ -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")
+16 -12
View File
@@ -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."""
+8 -3
View File
@@ -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
+8 -6
View File
@@ -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", "")
+8 -5
View File
@@ -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
View File
@@ -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():
+9 -7
View File
@@ -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", "")
+25 -16
View File
@@ -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."""
+18 -14
View File
@@ -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."""
+9 -7
View File
@@ -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
+7 -3
View File
@@ -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)
+7 -3
View File
@@ -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():
+17 -13
View File
@@ -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."""
+9 -6
View File
@@ -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")
+8 -5
View File
@@ -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")
+9 -5
View File
@@ -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")
+9 -4
View File
@@ -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']}"
+9 -5
View File
@@ -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")