diff --git a/README.md b/README.md index e7c3180..253ba89 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,8 @@ Finally, simply add the appropriate config to the `providers` section of `config ### Thanks +As well as being my work, I have also gratefully received feature patches from Steven, M1SDH. + The project contains a self-hosted copy of Font Awesome's free library, in the `/webasset/fa/` directory. This is subject to Font Awesome's licence and is not covered by the overall licence declared in the `LICENSE` file. This approach was taken in preference to using their hosted kits due to the popularity of this project exceeding the page view limit for their free hosted offering. The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries such as jQuery and moment.js. This project would not have been possible without these libraries, so many thanks to their developers. diff --git a/requirements.txt b/requirements.txt index 41a362a..5584971 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ pytz~=2025.2 requests~=2.32.5 aprslib~=0.7.2 diskcache~=5.6.3 -psutil~=7.1.0 \ No newline at end of file +psutil~=7.1.0 +requests-sse~=0.5.2 diff --git a/spotproviders/sse_spot_provider.py b/spotproviders/sse_spot_provider.py new file mode 100644 index 0000000..ad0c635 --- /dev/null +++ b/spotproviders/sse_spot_provider.py @@ -0,0 +1,65 @@ +import logging +from datetime import datetime +from threading import Thread +from time import sleep + +import pytz +from requests_sse import EventSource + +from spotproviders.spot_provider import SpotProvider + +# Spot provider using Server-Sent Events. +class SSESpotProvider(SpotProvider): + + def __init__(self, provider_config, url): + super().__init__(provider_config) + self.url = url + self.event_source = None + self.thread = None + self.stopped = False + self.last_event_id = None + + def start(self): + logging.info("Set up SSE connection to " + self.name + " spot API.") + self.stopped = False + self.thread = Thread(target=self.run) + self.thread.daemon = True + self.thread.start() + + def stop(self): + self.stopped = True + if self.event_source: + self.event_source.close() + if self.thread: + self.thread.join() + + def run(self): + while not self.stopped: + try: + logging.debug("Connecting to " + self.name + " spot API...") + with EventSource(self.url, headers=self.HTTP_HEADERS, latest_event_id=self.last_event_id, timeout=30) as event_source: + self.event_source = event_source + for event in self.event_source: + if event.type == 'message': + try: + self.last_event_id = event.last_event_id + new_spot = self.sse_message_to_spot(event.data) + if new_spot: + self.submit(new_spot) + + self.status = "OK" + self.last_update_time = datetime.now(pytz.UTC) + logging.debug("Received data from " + self.name + " spot API.") + + except Exception as e: + logging.exception("Exception processing message from SSE Spot Provider (" + self.name + ")") + + except Exception as e: + self.status = "Error" + logging.exception("Exception in SSE Spot Provider (" + self.name + ")") + sleep(5) # Wait before trying to reconnect + + # Convert an SSE message received from the API into a spot. The whole message data is provided here so the subclass + # implementations can handle the message as JSON, XML, text, whatever the API actually provides. + def sse_message_to_spot(self, message_data): + raise NotImplementedError("Subclasses must implement this method") \ No newline at end of file diff --git a/spotproviders/wwbota.py b/spotproviders/wwbota.py index 81c7bcf..a208aeb 100644 --- a/spotproviders/wwbota.py +++ b/spotproviders/wwbota.py @@ -1,48 +1,44 @@ +import json from datetime import datetime from data.spot import Spot -from spotproviders.http_spot_provider import HTTPSpotProvider +from spotproviders.sse_spot_provider import SSESpotProvider # Spot provider for Worldwide Bunkers on the Air -class WWBOTA(HTTPSpotProvider): - POLL_INTERVAL_SEC = 120 +class WWBOTA(SSESpotProvider): SPOTS_URL = "https://api.wwbota.org/spots/" def __init__(self, provider_config): - super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC) + super().__init__(provider_config, self.SPOTS_URL) - def http_response_to_spots(self, http_response): - new_spots = [] - # Iterate through source data - for source_spot in http_response.json(): - # Convert to our spot format. First we unpack references, because WWBOTA spots can have more than one for - # n-fer activations. - refs = [] - ref_names = [] - for ref in source_spot["references"]: - refs.append(ref["reference"]) - ref_names.append(ref["name"]) - spot = Spot(source=self.name, - dx_call=source_spot["call"].upper(), - de_call=source_spot["spotter"].upper(), - freq=float(source_spot["freq"]) * 1000000, - mode=source_spot["mode"].upper(), - comment=source_spot["comment"], - sig="WWBOTA", - sig_refs=refs, - sig_refs_names=ref_names, - icon="radiation", - time=datetime.fromisoformat(source_spot["time"]).timestamp(), - # WWBOTA spots can contain multiple references for bunkers being activated simultaneously. For - # now, we will just pick the first one to use as our grid, latitude and longitude. - grid=source_spot["references"][0]["locator"], - latitude=source_spot["references"][0]["lat"], - longitude=source_spot["references"][0]["long"], - qrt=source_spot["type"] == "QRT") + def sse_message_to_spot(self, message): + source_spot = json.loads(message) + # Convert to our spot format. First we unpack references, because WWBOTA spots can have more than one for + # n-fer activations. + refs = [] + ref_names = [] + for ref in source_spot["references"]: + refs.append(ref["reference"]) + ref_names.append(ref["name"]) - # Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do - # that for us. But WWBOTA does support a special "Test" spot type, we need to avoid adding that. - if source_spot["type"] != "Test": - new_spots.append(spot) - return new_spots \ No newline at end of file + spot = Spot(source=self.name, + dx_call=source_spot["call"].upper(), + de_call=source_spot["spotter"].upper(), + freq=float(source_spot["freq"]) * 1000000, + mode=source_spot["mode"].upper(), + comment=source_spot["comment"], + sig="WWBOTA", + sig_refs=refs, + sig_refs_names=ref_names, + icon="radiation", + time=datetime.fromisoformat(source_spot["time"]).timestamp(), + # WWBOTA spots can contain multiple references for bunkers being activated simultaneously. For + # now, we will just pick the first one to use as our grid, latitude and longitude. + grid=source_spot["references"][0]["locator"], + latitude=source_spot["references"][0]["lat"], + longitude=source_spot["references"][0]["long"], + qrt=source_spot["type"] == "QRT") + + # WWBOTA does support a special "Test" spot type, we need to avoid adding that. + return spot if source_spot["type"] != "Test" else None diff --git a/views/webpage_about.tpl b/views/webpage_about.tpl index d0692b6..81fadd4 100644 --- a/views/webpage_about.tpl +++ b/views/webpage_about.tpl @@ -7,6 +7,6 @@

The API is deliberately well-defined with an OpenAPI specification and auto-generated API documentation. The API delivers spots in a consistent format regardless of the data source, freeing developers from needing to know how each individual data source presents its data.

Spothole itself is also open source, Public Domain licenced code that anyone can take and modify. The source code is here. If you want to run your own copy of Spothole, or start modifying it for your own purposes, the README file contains a description of how the software works and how it's laid out, as well as instructions for configuring systemd, nginx and anything else you might need to run your own server.

Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, and Parks 'n' Peaks.

-

The software was written by Ian Renton, MØTRT.

+

The software was written by Ian Renton, MØTRT and other contributors. Full details are available in the README.

« Back home

\ No newline at end of file