Implement a max spot age filter. Closes #18

This commit is contained in:
Ian Renton
2025-10-02 09:57:25 +01:00
parent 9640c0e0c1
commit 1a9dc0b634
8 changed files with 49 additions and 17 deletions

View File

@@ -10,4 +10,8 @@ if not os.path.isfile("config.yml"):
# Load config # Load config
config = yaml.safe_load(open("config.yml")) config = yaml.safe_load(open("config.yml"))
logging.info("Loaded config.") logging.info("Loaded config.")
MAX_SPOT_AGE = config["max-spot-age-sec"]
SERVER_OWNER_CALLSIGN = config["server-owner-callsign"]
WEB_SERVER_PORT = config["web-server-port"]

10
main.py
View File

@@ -10,7 +10,8 @@ import psutil
import pytz import pytz
from core.cleanup import CleanupTimer from core.cleanup import CleanupTimer
from core.config import config from core.config import config, MAX_SPOT_AGE, WEB_SERVER_PORT, SERVER_OWNER_CALLSIGN
from core.constants import SOFTWARE_VERSION
from providers.aprsis import APRSIS from providers.aprsis import APRSIS
from providers.dxcluster import DXCluster from providers.dxcluster import DXCluster
from providers.gma import GMA from providers.gma import GMA
@@ -76,8 +77,11 @@ if __name__ == '__main__':
formatter = logging.Formatter("%(message)s") formatter = logging.Formatter("%(message)s")
handler.setFormatter(formatter) handler.setFormatter(formatter)
root.addHandler(handler) root.addHandler(handler)
logging.info("Starting...") logging.info("Starting...")
startup_time = datetime.now(pytz.UTC) startup_time = datetime.now(pytz.UTC)
status_data["software-version"] = SOFTWARE_VERSION
status_data["server-owner-callsign"] = SERVER_OWNER_CALLSIGN
# Shut down gracefully on SIGINT # Shut down gracefully on SIGINT
signal.signal(signal.SIGINT, shutdown) signal.signal(signal.SIGINT, shutdown)
@@ -92,11 +96,11 @@ if __name__ == '__main__':
p.start() p.start()
# Set up timer to clear spot list of old data # Set up timer to clear spot list of old data
cleanup_timer = CleanupTimer(spot_list=spot_list, cleanup_interval=60, max_spot_age=config["max-spot-age-sec"]) cleanup_timer = CleanupTimer(spot_list=spot_list, cleanup_interval=60, max_spot_age=MAX_SPOT_AGE)
cleanup_timer.start() cleanup_timer.start()
# Set up web server # Set up web server
web_server = WebServer(spot_list=spot_list, status_data=status_data, port=config["web-server-port"]) web_server = WebServer(spot_list=spot_list, status_data=status_data, port=WEB_SERVER_PORT)
web_server.start() web_server.start()
logging.info("Startup complete.") logging.info("Startup complete.")

View File

@@ -5,7 +5,7 @@ from threading import Thread
import aprslib import aprslib
import pytz import pytz
from core.config import config from core.config import SERVER_OWNER_CALLSIGN
from data.spot import Spot from data.spot import Spot
from providers.provider import Provider from providers.provider import Provider
@@ -23,7 +23,7 @@ class APRSIS(Provider):
self.thread.start() self.thread.start()
def connect(self): def connect(self):
self.aprsis = aprslib.IS(config["server-owner-callsign"]) self.aprsis = aprslib.IS(SERVER_OWNER_CALLSIGN)
self.status = "Connecting" self.status = "Connecting"
logging.info("APRS-IS connecting...") logging.info("APRS-IS connecting...")
self.aprsis.connect() self.aprsis.connect()

View File

@@ -8,7 +8,7 @@ import pytz
import telnetlib3 import telnetlib3
from data.spot import Spot from data.spot import Spot
from core.config import config from core.config import SERVER_OWNER_CALLSIGN
from providers.provider import Provider from providers.provider import Provider
@@ -47,7 +47,7 @@ class DXCluster(Provider):
logging.info("DX Cluster " + self.hostname + " connecting...") logging.info("DX Cluster " + self.hostname + " connecting...")
self.telnet = telnetlib3.Telnet(self.hostname, self.port) self.telnet = telnetlib3.Telnet(self.hostname, self.port)
self.telnet.read_until("login: ".encode("latin-1")) self.telnet.read_until("login: ".encode("latin-1"))
self.telnet.write((config["server-owner-callsign"] + "\n").encode("latin-1")) self.telnet.write((SERVER_OWNER_CALLSIGN + "\n").encode("latin-1"))
connected = True connected = True
logging.info("DX Cluster " + self.hostname + " connected.") logging.info("DX Cluster " + self.hostname + " connected.")
except Exception as e: except Exception as e:

View File

@@ -3,14 +3,14 @@ from datetime import datetime
import pytz import pytz
from core.constants import SOFTWARE_NAME, SOFTWARE_VERSION from core.constants import SOFTWARE_NAME, SOFTWARE_VERSION
from core.config import config from core.config import config, SERVER_OWNER_CALLSIGN
# Generic data provider class. Subclasses of this query the individual APIs for data. # Generic data provider class. Subclasses of this query the individual APIs for data.
class Provider: class Provider:
# HTTP headers used for providers that use HTTP # HTTP headers used for providers that use HTTP
HTTP_HEADERS = { "User-Agent": SOFTWARE_NAME + " " + SOFTWARE_VERSION + " (operated by " + config["server-owner-callsign"] + ")" } HTTP_HEADERS = { "User-Agent": SOFTWARE_NAME + " " + SOFTWARE_VERSION + " (operated by " + SERVER_OWNER_CALLSIGN + ")" }
# Constructor # Constructor
def __init__(self, provider_config): def __init__(self, provider_config):

View File

@@ -8,7 +8,7 @@ import pytz
import telnetlib3 import telnetlib3
from data.spot import Spot from data.spot import Spot
from core.config import config from core.config import SERVER_OWNER_CALLSIGN
from providers.provider import Provider from providers.provider import Provider
@@ -48,7 +48,7 @@ class RBN(Provider):
logging.info("RBN port " + str(self.port) + " connecting...") logging.info("RBN port " + str(self.port) + " connecting...")
self.telnet = telnetlib3.Telnet("telnet.reversebeacon.net", self.port) self.telnet = telnetlib3.Telnet("telnet.reversebeacon.net", self.port)
telnet_output = self.telnet.read_until("Please enter your call: ".encode("latin-1")) telnet_output = self.telnet.read_until("Please enter your call: ".encode("latin-1"))
self.telnet.write((config["server-owner-callsign"] + "\n").encode("latin-1")) self.telnet.write((SERVER_OWNER_CALLSIGN + "\n").encode("latin-1"))
connected = True connected = True
logging.info("RBN port " + str(self.port) + " connected.") logging.info("RBN port " + str(self.port) + " connected.")
except Exception as e: except Exception as e:

View File

@@ -1,12 +1,13 @@
import json import json
import logging import logging
from datetime import datetime from datetime import datetime, timedelta
from threading import Thread from threading import Thread
import bottle import bottle
import pytz import pytz
from bottle import run, response from bottle import run, response
from core.config import MAX_SPOT_AGE
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, SOURCES, CONTINENTS from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, SOURCES, CONTINENTS
from core.utils import serialize_everything from core.utils import serialize_everything
@@ -100,6 +101,10 @@ class WebServer:
case "since": case "since":
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC) since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC)
spots = [s for s in spots if s.time > since] spots = [s for s in spots if s.time > since]
case "max_age":
max_age = int(query.get(k))
since = datetime.now(pytz.UTC) - timedelta(seconds=max_age)
spots = [s for s in spots if s.time > since]
case "received_since": case "received_since":
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC) since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC)
spots = [s for s in spots if s.received_time > since] spots = [s for s in spots if s.received_time > since]
@@ -138,4 +143,5 @@ class WebServer:
"mode_types": MODE_TYPES, "mode_types": MODE_TYPES,
"sigs": SIGS, "sigs": SIGS,
"sources": SOURCES, "sources": SOURCES,
"continents": CONTINENTS} "continents": CONTINENTS,
"max_spot_age": MAX_SPOT_AGE}

View File

@@ -4,7 +4,7 @@ info:
description: |- description: |-
(S)pothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data. (S)pothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data.
While there are other web-based interfaces to DX clusters, and sites that aggregate spots from various outfoor activity programmes for amateur radio, (S)pothole differentiates itself by supporting a large number of data sources, and by being "API first" rather than just providing a web front-end. This allows other software to be built on top of it. Spothole itself is also open source, Public Domain licenced code that anyone can take and modify. While there are other web-based interfaces to DX clusters, and sites that aggregate spots from various outfoor activity programmes for amateur radio, (S)pothole differentiates itself by supporting a large number of data sources, and by being "API first" rather than just providing a web front-end. This allows other software to be built on top of it. (S)pothole itself is also open source, Public Domain licenced code that anyone can take and modify.
contact: contact:
email: ian@ianrenton.com email: ian@ianrenton.com
license: license:
@@ -30,13 +30,19 @@ paths:
type: integer type: integer
- name: since - name: since
in: query in: query
description: Limit the spots to only ones at this time or later. Time in UTC seconds since UNIX epoch. description: Limit the spots to only ones at this time or later. Time in UTC seconds since UNIX epoch. Equivalent to "max_age" but saves the client having to work out how many seconds ago "midnight" was.
required: false
schema:
type: integer
- name: max_age
in: query
description: Limit the spots to only ones received in the last 'n' seconds. Equivalent to "since" but saves the client having to work out what time was 'n' seconds ago on every call. Refer to the "max_spot_age" in the /options call to figure out what the maximum useful value you can provide is. Larger values will still be accepted, there just won't be any spots in the system older than max_spot_age.
required: false required: false
schema: schema:
type: integer type: integer
- name: received_since - name: received_since
in: query in: query
description: Limit the spots to only ones that the system found out about at this time or later. Time in UTC seconds since UNIX epoch. If you are using a front-end that tracks the last time it queried the API and requests spots since then, you want *this* version of the query parameter, not "since", because otherwise it may miss things. description: Limit the spots to only ones that the system found out about at this time or later. Time in UTC seconds since UNIX epoch. If you are using a front-end that tracks the last time it queried the API and requests spots since then, you want *this* version of the query parameter, not "since", because otherwise it may miss things. The logic is "greater than" rather than "greater than or equal to", so you can submit the time of the last received item back to this call and you will get all the more recent spots back, without duplicating the previous latest spot.
required: false required: false
schema: schema:
type: integer type: integer
@@ -188,6 +194,14 @@ paths:
schema: schema:
type: object type: object
properties: properties:
"software-version":
type: string
description: The version number of the software.
example: "1.0.1"
"server-owner-callsign":
type: string
description: The callsign of this server's operator.
example: "M0TRT"
"uptime": "uptime":
type: string type: string
description: The amount of time the software has been running for. description: The amount of time the software has been running for.
@@ -283,6 +297,10 @@ paths:
items: items:
type: string type: string
example: "EU" example: "EU"
max_spot_age:
type: integer
description: The maximum age, in seconds, of any spot before it will be deleted by the system. When querying the /api/spots endpoint and providing a "max_age" or "since" parameter, there is no point providing a number larger than this, because the system drops all spots older than this.
example: 3600
components: components:
schemas: schemas: