From 5e56cd3b197c7b15fb05d3b8a183c45747ebf01f Mon Sep 17 00:00:00 2001 From: Ian Renton Date: Fri, 11 Sep 2026 15:55:57 +0100 Subject: [PATCH] Add telnet server --- Dockerfile | 1 + config-example.yml | 6 + core/config.py | 2 + core/status_reporter.py | 2 +- docs/docker.md | 1 + docs/modifying.md | 5 +- spothole.py | 10 +- telnetserver/telnetserver.py | 168 ++++++++++++++++++ templates/add_spot.html | 2 +- templates/alerts.html | 2 +- templates/bands.html | 4 +- templates/base.html | 10 +- templates/conditions.html | 2 +- templates/map.html | 4 +- templates/spots.html | 4 +- templates/status.html | 2 +- {server => webserver}/handlers/api/addspot.py | 0 {server => webserver}/handlers/api/alerts.py | 0 {server => webserver}/handlers/api/dxstats.py | 0 {server => webserver}/handlers/api/lookups.py | 0 {server => webserver}/handlers/api/options.py | 0 .../handlers/api/solar_conditions.py | 0 {server => webserver}/handlers/api/spots.py | 0 {server => webserver}/handlers/api/status.py | 0 .../handlers/api/v1_addspot.py | 0 .../handlers/api/v1_compatability.py | 0 .../handlers/api/v1_spots.py | 2 +- .../handlers/manifesthandler.py | 0 {server => webserver}/handlers/metrics.py | 0 .../handlers/pagetemplate.py | 0 {server => webserver}/sse_broadcaster.py | 0 {server => webserver}/webserver.py | 30 ++-- 32 files changed, 222 insertions(+), 35 deletions(-) create mode 100644 telnetserver/telnetserver.py rename {server => webserver}/handlers/api/addspot.py (100%) rename {server => webserver}/handlers/api/alerts.py (100%) rename {server => webserver}/handlers/api/dxstats.py (100%) rename {server => webserver}/handlers/api/lookups.py (100%) rename {server => webserver}/handlers/api/options.py (100%) rename {server => webserver}/handlers/api/solar_conditions.py (100%) rename {server => webserver}/handlers/api/spots.py (100%) rename {server => webserver}/handlers/api/status.py (100%) rename {server => webserver}/handlers/api/v1_addspot.py (100%) rename {server => webserver}/handlers/api/v1_compatability.py (100%) rename {server => webserver}/handlers/api/v1_spots.py (96%) rename {server => webserver}/handlers/manifesthandler.py (100%) rename {server => webserver}/handlers/metrics.py (100%) rename {server => webserver}/handlers/pagetemplate.py (100%) rename {server => webserver}/sse_broadcaster.py (100%) rename {server => webserver}/webserver.py (90%) diff --git a/Dockerfile b/Dockerfile index 3946a0c..8730cf8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,5 +4,6 @@ WORKDIR /app COPY . . RUN pip install --no-cache-dir -r requirements.txt EXPOSE 8080 +EXPOSE 7373 CMD ["python3", "spothole.py"] \ No newline at end of file diff --git a/config-example.yml b/config-example.yml index b91fcf1..ec61412 100644 --- a/config-example.yml +++ b/config-example.yml @@ -17,6 +17,12 @@ api_only_mode: false # The base URL at which the software runs. base_url: "http://localhost:8080" +# Whether to run a telnet spot server as well as the web interface +telnet_server_enabled: false + +# Port to run the telnet server on +telnet_server_port: 7373 + # Spot providers to use. This is an example set, tailor it to your liking by commenting and uncommenting. # RBN and APRS-IS are supported but have such a high data rate, you probably don't want them enabled. # Each provider needs a class and an enabled/disabled state. Some require more config such as hostnames/IP diff --git a/core/config.py b/core/config.py index 5371c2c..fc07e4b 100644 --- a/core/config.py +++ b/core/config.py @@ -24,6 +24,8 @@ MAX_SPOT_AGE = config.get("max_spot_age_sec", 3600) MAX_ALERT_AGE = config.get("max_alert_age_sec", 604800) SERVER_OWNER_CALLSIGN = config.get("server_owner_callsign", "N0CALL") WEB_SERVER_PORT = config.get("web_server_port", 8080) +TELNET_SERVER_ENABLED = config.get("telnet_server_enabled", False) +TELNET_SERVER_PORT = config.get("telnet_server_port", 7373) ALLOW_SPOTTING = config.get("allow_spotting", True) ALLOW_UPSTREAM_SPOTTING = config.get("allow_upstream_spotting", True) WEB_UI_OPTIONS = config.get("web_ui_options", {}) diff --git a/core/status_reporter.py b/core/status_reporter.py index de9e07b..74ae112 100644 --- a/core/status_reporter.py +++ b/core/status_reporter.py @@ -11,7 +11,7 @@ from core.constants import SOFTWARE_VERSION from core.data_providers import DATA_PROVIDERS from core.data_store import DATA_STORE from core.prometheus_metrics_handler import alerts_gauge, memory_use_gauge, spots_gauge -from server.webserver import WEB_SERVER +from webserver.webserver import WEB_SERVER class StatusReporter: diff --git a/docs/docker.md b/docs/docker.md index db81bdb..5671daf 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -13,6 +13,7 @@ services: restart: unless-stopped ports: - "8080:8080" + - "7373:7373" # For telnet if required volumes: - ./config.yml:/app/config.yml - ./cache:/app/cache diff --git a/docs/modifying.md b/docs/modifying.md index e00cb97..62326e9 100644 --- a/docs/modifying.md +++ b/docs/modifying.md @@ -16,9 +16,12 @@ To navigate your way around the source code, this list may help. * `/providers/solarconditions` - Classes providing solar and propagation by accessing the APIs of other services * `/providers/staticdata` - Classes providing static lookup data by accessing bundled data files or the APIs of other services +* `/providers/callsign` - Classes providing callsign lookup data by accessing bundled data files or the APIs of other + services * `/providers/sigrefdata` - Classes providing SIG reference lookup data by accessing bundled data files or the APIs of other services -* `/server` - Classes for running Spothole's own web server +* `/webserver` - Classes for running Spothole's own web server +* `/telnetserver` - Classes for running Spothole's telnet server * `spothole.py` - Main application script *Templates* diff --git a/spothole.py b/spothole.py index 64ef753..b9835b5 100644 --- a/spothole.py +++ b/spothole.py @@ -5,12 +5,13 @@ import signal import sys from core.cleanup import CLEANUP_TIMER -from core.config import LOG_LEVEL, SERVER_OWNER_CALLSIGN +from core.config import LOG_LEVEL, SERVER_OWNER_CALLSIGN, TELNET_SERVER_ENABLED, TELNET_SERVER_PORT from core.constants import SOFTWARE_VERSION from core.data_providers import DATA_PROVIDERS from core.data_store import DATA_STORE from core.status_reporter import StatusReporter -from server.webserver import WEB_SERVER +from telnetserver.telnetserver import TELNET_SERVER +from webserver.webserver import WEB_SERVER logger = logging.getLogger(__name__) @@ -20,6 +21,7 @@ def shutdown(_signum=None, _frame=None): logger.info("Stopping program...") WEB_SERVER.stop() + TELNET_SERVER.stop() DATA_PROVIDERS.stop() CLEANUP_TIMER.stop() DATA_STORE.close() @@ -60,6 +62,10 @@ if __name__ == "__main__": # Set up the web server WEB_SERVER.setup() + # Run the telnet server + if TELNET_SERVER_ENABLED: + TELNET_SERVER.start(port=TELNET_SERVER_PORT) + # Run the web server. This is the blocking call that keeps the application running in the main thread, so this must # be the last thing we do. web_server.stop() triggers an await condition in the web server which finishes the main # thread. diff --git a/telnetserver/telnetserver.py b/telnetserver/telnetserver.py new file mode 100644 index 0000000..7991951 --- /dev/null +++ b/telnetserver/telnetserver.py @@ -0,0 +1,168 @@ +import asyncio +import logging +import threading +from datetime import datetime + +import pytz + +from core.config import SERVER_OWNER_CALLSIGN +from core.constants import SOFTWARE_VERSION +from core.data_store import DATA_STORE +from data.spot import Spot + +logger = logging.getLogger(__name__) + + +class TelnetServer: + """A telnet server designed to provide spots in the same format as DXSpider, for compatibility with desktop loggers.""" + + def __init__(self): + self._port = None + self._running = False + self._clients = set() + self._loop = None + + def start(self, port=7373): + """Starts the telnet server""" + + self._port = port + + # Start telnet server on the async loop + def run_loop(): + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._loop.run_until_complete(self._start_internal()) + self._loop.run_forever() + + # Start the network thread as a daemon so it exits cleanly when the main script stops + t = threading.Thread(target=run_loop, daemon=True) + t.start() + logger.debug("Telnet server background thread spawned") + + # Listen for new spots and alerts being added to the cache, so we can notify SSE clients immediately + DATA_STORE.spots.add_listener(self.publish) + self._running = True + + async def _start_internal(self): + logger.info(f"Telnet server listening on port {self._port}") + await asyncio.start_server(self._handle_client, "0.0.0.0", self._port) + + async def _handle_client(self, reader, writer): + """Handles a new client connection""" + + logger.debug("Telnet client connected") + self._clients.add(writer) + + # Print MOTD + try: + motd = ( + f"Welcome to Spothole v{SOFTWARE_VERSION}.\r\nThis server is run by {SERVER_OWNER_CALLSIGN}.\r\n===\r\n" + ) + writer.write(motd.encode("ascii")) + await writer.drain() + except Exception: + pass + + # Set up buffer for user input + input_buffer = "" + + try: + # Read forever, picking out any commands. Currently we just support "exit" + while True: + data = await reader.read(1024) + if not data: + break + + text = data.decode("ascii", errors="ignore") + + for char in text: + if char in ("\r", "\n"): + # User pressed Enter, evaluate the command + command = input_buffer.strip().lower() + input_buffer = "" + + if command == "exit": + writer.write(b"Goodbye!\r\n") + await writer.drain() + # Exit the while read loop, this will disconnect the client. + return + + elif char in ("\b", "\x7f"): + # Handle backspaces + input_buffer = input_buffer[:-1] + else: + input_buffer += char + + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Exception handling telnet client") + finally: + logger.debug("Telnet client disconnected") + self._clients.remove(writer) + writer.close() + await writer.wait_closed() + + def stop(self): + """Stops the telnet server""" + + if self._loop and self._loop.is_running(): + logger.debug("Stopping telnet server...") + self._loop.call_soon_threadsafe(self._loop.stop) + + async def _stop_internal(self): + """Stops the telnet server""" + + for writer in list(self._clients): + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + self._clients.clear() + + def publish(self, spot: Spot): + """Callback from the data store when a spot is added""" + + if self._running and self._clients and self._loop and self._loop.is_running(): + asyncio.run_coroutine_threadsafe(self._broadcast_spot_internal(spot), self._loop) + + async def _broadcast_spot_internal(self, spot: Spot): + """Internal version, run on async loop for thread safety?""" + + # Ensure ASCII formatting for telnet clients + encoded_line = self._format_dxspider_spot(spot).encode("ascii", errors="ignore") + + # Try to write to all clients, and in the process find the ones that are disconnected + disconnected_clients = set() + for writer in self._clients: + try: + writer.write(encoded_line) + await writer.drain() + except Exception: + disconnected_clients.add(writer) + + # Clean up any disconnected connections caught during writing + for writer in disconnected_clients: + self._clients.discard(writer) + + @staticmethod + def _format_dxspider_spot(spot: Spot) -> str: + """Formats a spot into the format DXspider uses: + DX de CALLSIGN: FREQUENCY DX_CALLSIGN COMMENTS TIME_UTC""" + + de_call = f"{spot.de_call + ':' if spot.de_call else '???:'!s:<9}" + frequency = f"{(spot.freq / 1000.0):8.1f}" + dx_call = f"{spot.dx_call!s:<12}" + comment = f"{spot.comment[:29]:<30}" + if spot.time: + timestamp = datetime.fromtimestamp(spot.time, tz=pytz.utc).strftime("%H%M") + "Z" + else: + timestamp = datetime.now(tz=pytz.utc).strftime("%H%M") + "Z" + + # Combine into classic DXSpider output string followed by network line breaks + return f"DX de {de_call} {frequency} {dx_call} {comment} {timestamp}\r\n" + + +# Global object +TELNET_SERVER = TelnetServer() diff --git a/templates/add_spot.html b/templates/add_spot.html index 183f60d..4682df8 100644 --- a/templates/add_spot.html +++ b/templates/add_spot.html @@ -77,7 +77,7 @@ - + diff --git a/templates/alerts.html b/templates/alerts.html index 124d935..291a87d 100644 --- a/templates/alerts.html +++ b/templates/alerts.html @@ -83,7 +83,7 @@ - + diff --git a/templates/bands.html b/templates/bands.html index b40747f..bc2db5a 100644 --- a/templates/bands.html +++ b/templates/bands.html @@ -76,8 +76,8 @@ - - + + diff --git a/templates/base.html b/templates/base.html index 31abd4e..202abcf 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,6 +1,6 @@ {% extends "skeleton.html" %} {% block head_extra %} - + @@ -16,10 +16,10 @@ window.fetchEventSource = fetchEventSource; - - - - + + + + {% end %} {% block body %}
diff --git a/templates/conditions.html b/templates/conditions.html index 34413eb..93b2d03 100644 --- a/templates/conditions.html +++ b/templates/conditions.html @@ -284,7 +284,7 @@
- + diff --git a/templates/map.html b/templates/map.html index 24383ae..5cb8217 100644 --- a/templates/map.html +++ b/templates/map.html @@ -113,8 +113,8 @@ const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}"; - - + + diff --git a/templates/spots.html b/templates/spots.html index 14ec22a..b642786 100644 --- a/templates/spots.html +++ b/templates/spots.html @@ -113,8 +113,8 @@ - - + + diff --git a/templates/status.html b/templates/status.html index 6d0faf3..96a2e55 100644 --- a/templates/status.html +++ b/templates/status.html @@ -86,7 +86,7 @@ - +