diff --git a/README.md b/README.md index 89f8100..c1962d1 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,14 @@ More will be added soon to allow customisation of filters and other display prop One of the key strengths of Spothole is that the API is well-defined and open to anyone to use. This means you can build your own software that uses data from Spothole. +As well as the main API endpoints to fetch spots and alerts, with various possible query parameters, there are also Server-Sent Events (SSE) API endpoints to receive a live feed, plus various utility lookup endpoints for things like callsign and park data. + Various approaches exist to writing your own client, but in general: * Refer to the API docs. These are built on an OpenAPI definition file (`/webassets/apidocs/openapi.yml`), which you can automatically use to generate a client skeleton using various software. * Call the main "spots" or "alerts" API endpoints to get the data you want. Apply filters if necessary. * Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do that first before calling the spots/alerts APIs, to allow you to populate your filters correctly. -* Refer to the provided HTML/JS interface for a reference +* Refer to the provided HTML/JS interface for a reference on different approaches. For example, the "map" and "bands" pages simply query the main spot API on a timer, whereas the main/spots page combines this approach with using the Server-Sent Events (SSE) endpoint to update live. * Let me know if you get stuck, I'm happy to help! ## Running your own copy diff --git a/server/webserver.py b/server/webserver.py index b807372..f6b0906 100644 --- a/server/webserver.py +++ b/server/webserver.py @@ -110,34 +110,40 @@ class WebServer: # Serve the SSE JSON API /spots/stream endpoint def serve_sse_spots_api(self): - response.content_type = 'text/event-stream' - response.cache_control = 'no-cache' - yield 'retry: 1000\n\n' + try: + response.content_type = 'text/event-stream' + response.cache_control = 'no-cache' + yield 'retry: 1000\n\n' - spot_queue = Queue(maxsize=100) - self.sse_spot_queues.append(spot_queue) - while True: - if spot_queue.empty(): - gevent.sleep(1) - else: - spot = spot_queue.get() - yield 'data: ' + json.dumps(spot, default=serialize_everything) + '\n\n' + spot_queue = Queue(maxsize=100) + self.sse_spot_queues.append(spot_queue) + while True: + if spot_queue.empty(): + gevent.sleep(1) + else: + spot = spot_queue.get() + yield 'data: ' + json.dumps(spot, default=serialize_everything) + '\n\n' + except Exception as e: + logging.warn("Exception when serving SSE socket", e) # Serve the SSE JSON API /alerts/stream endpoint def serve_sse_alerts_api(self): - response.content_type = 'text/event-stream' - response.cache_control = 'no-cache' - yield 'retry: 1000\n\n' + try: + response.content_type = 'text/event-stream' + response.cache_control = 'no-cache' + yield 'retry: 1000\n\n' - alert_queue = Queue(maxsize=100) - self.sse_alert_queues.append(alert_queue) - while True: - if alert_queue.empty(): - gevent.sleep(1) - else: - alert = alert_queue.get() - yield 'data: ' + json.dumps(alert, default=serialize_everything) + '\n\n' + alert_queue = Queue(maxsize=100) + self.sse_alert_queues.append(alert_queue) + while True: + if alert_queue.empty(): + gevent.sleep(1) + else: + alert = alert_queue.get() + yield 'data: ' + json.dumps(alert, default=serialize_everything) + '\n\n' + except Exception as e: + logging.warn("Exception when serving SSE socket", e) # Look up data for a callsign def serve_call_lookup_api(self): diff --git a/spothole.py b/spothole.py index cc4f81b..a8a80c7 100644 --- a/spothole.py +++ b/spothole.py @@ -1,14 +1,18 @@ # Main script +from time import sleep + +import gevent +from gevent import monkey; monkey.patch_all() + import importlib import logging import signal import sys from diskcache import Cache -from gevent import monkey; monkey.patch_all() from core.cleanup import CleanupTimer -from core.config import config, WEB_SERVER_PORT, SERVER_OWNER_CALLSIGN, MAX_SPOT_AGE +from core.config import config, WEB_SERVER_PORT, SERVER_OWNER_CALLSIGN from core.constants import SOFTWARE_NAME, SOFTWARE_VERSION from core.lookup_helper import lookup_helper from core.status_reporter import StatusReporter @@ -22,10 +26,13 @@ status_data = {} spot_providers = [] alert_providers = [] cleanup_timer = None +run = True # Shutdown function def shutdown(sig, frame): + global run + logging.info("Stopping program, this may take a few seconds...") for p in spot_providers: if p.enabled: @@ -37,6 +44,7 @@ def shutdown(sig, frame): lookup_helper.stop() spots.close() alerts.close() + run = False # Utility method to get a spot provider based on the class specified in its config entry. @@ -105,3 +113,7 @@ if __name__ == '__main__': status_reporter.start() logging.info("Startup complete.") + + while run: + gevent.sleep(1) + exit(0) diff --git a/webassets/apidocs/openapi.yml b/webassets/apidocs/openapi.yml index a925549..2dde702 100644 --- a/webassets/apidocs/openapi.yml +++ b/webassets/apidocs/openapi.yml @@ -155,7 +155,7 @@ paths: - Spots summary: Get spot stream description: Request a Server-Sent Event stream which will return individual spots immediately when they are added to the system. Only spots that match the provided filters will be returned. - operationId: spots/stream + operationId: spots-stream parameters: - name: source in: query @@ -321,7 +321,7 @@ paths: - Alerts summary: Get alert stream description: Request a Server-Sent Event stream which will return individual alerts immediately when they are added to the system. Only alerts that match the provided filters will be returned. - operationId: alerts/stream + operationId: alerts-stream parameters: - name: max_duration in: query diff --git a/webassets/js/bands.js b/webassets/js/bands.js index 616a2c0..219dc12 100644 --- a/webassets/js/bands.js +++ b/webassets/js/bands.js @@ -1,3 +1,6 @@ +// How often to query the server? +const REFRESH_INTERVAL_SEC = 60; + // A couple of constants that must match what's in CSS. We need to know them before the content actually renders, so we // can't just ask the elements themselves for their dimensions. BAND_COLUMN_HEIGHT_EM = 62; diff --git a/webassets/js/common.js b/webassets/js/common.js index 21c5179..fa3655d 100644 --- a/webassets/js/common.js +++ b/webassets/js/common.js @@ -116,17 +116,20 @@ function toggleFilterButtons(filterQuery, state) { // Update the refresh timing display function updateRefreshDisplay() { if (lastUpdateTime != null) { - let count = REFRESH_INTERVAL_SEC; let secSinceUpdate = moment.duration(moment().diff(lastUpdateTime)).asSeconds(); - updatingString = "Updating..." - if (secSinceUpdate < REFRESH_INTERVAL_SEC) { - count = REFRESH_INTERVAL_SEC - secSinceUpdate; - if (count <= 60) { - var number = count.toFixed(0); - updatingString = "Updating in " + number + " second" + (number != "1" ? "s" : "") + "."; - } else { - var number = Math.round(count / 60.0).toFixed(0); - updatingString = "Updating in " + number + " minute" + (number != "1" ? "s" : "") + "."; + let updatingString = " Connected to live spot server."; + if (typeof REFRESH_INTERVAL_SEC !== 'undefined' && REFRESH_INTERVAL_SEC != null) { + let count = REFRESH_INTERVAL_SEC; + updatingString = "Updating..." + if (secSinceUpdate < REFRESH_INTERVAL_SEC) { + count = REFRESH_INTERVAL_SEC - secSinceUpdate; + if (count <= 60) { + var number = count.toFixed(0); + updatingString = "Updating in " + number + " second" + (number != "1" ? "s" : "") + "."; + } else { + var number = Math.round(count / 60.0).toFixed(0); + updatingString = "Updating in " + number + " minute" + (number != "1" ? "s" : "") + "."; + } } } $("#timing-container").html("Last updated at " + lastUpdateTime.format('HH:mm') + " UTC. " + updatingString); diff --git a/webassets/js/map.js b/webassets/js/map.js index 6495316..d2df3e1 100644 --- a/webassets/js/map.js +++ b/webassets/js/map.js @@ -1,3 +1,6 @@ +// How often to query the server? +const REFRESH_INTERVAL_SEC = 60; + // Map layers var markersLayer; var geodesicsLayer; diff --git a/webassets/js/spots.js b/webassets/js/spots.js index 7496e6c..e261783 100644 --- a/webassets/js/spots.js +++ b/webassets/js/spots.js @@ -1,3 +1,6 @@ +// SSE event source +let evtSource; + // Load spots and populate the table. function loadSpots() { $.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) { @@ -8,9 +11,36 @@ function loadSpots() { spots = jsonData; // Update table updateTable(); + // Start SSE connection to fetch updates in the background + restartSSEConnection(); }); } +// Start an SSE connection (closing an existing one if it exists). This will then be used to add to the table on the +// fly. +function restartSSEConnection() { + if (evtSource != null) { + evtSource.close(); + } + evtSource = new EventSource('/api/v1/spots/stream'); + + evtSource.onmessage = function(event) { + // Store last updated time + lastUpdateTime = moment.utc(); + updateRefreshDisplay(); + // Add spot to table + newSpot = JSON.parse(event.data); + console.log(newSpot); + spots.unshift(newSpot); + spots = spots.slice(0, -1); + updateTable(); + }; + + evtSource.onerror = function(err) { + setTimeout(restartSSEConnection(), 1000); + }; +} + // Build a query string for the API, based on the filters that the user has selected. function buildQueryString() { var str = "?"; @@ -316,9 +346,8 @@ function loadOptions() { $("#add-spot-button").show(); } - // Load spots and set up the timer + // Load spots (this will also set up the SSE connection to update them too) loadSpots(); - setInterval(loadSpots, REFRESH_INTERVAL_SEC * 1000); }); } @@ -384,8 +413,6 @@ function displayIntroBox() { $(document).ready(function() { // Call loadOptions(), this will then trigger loading spots and setting up timers. loadOptions(); - // Update the refresh timing display every second - setInterval(updateRefreshDisplay, 1000); // Display intro box displayIntroBox(); }); \ No newline at end of file diff --git a/webassets/js/spotsbandsandmap.js b/webassets/js/spotsbandsandmap.js index 1c53364..5dcf048 100644 --- a/webassets/js/spotsbandsandmap.js +++ b/webassets/js/spotsbandsandmap.js @@ -1,6 +1,3 @@ -// How often to query the server? -const REFRESH_INTERVAL_SEC = 60; - // Storage for the spot data that the server gives us. var spots = []