9f122f47aa
Make a fresh deploy (especially Docker) work with no manual steps and behave well on real-world hosts: - Auto-scan skips subnets larger than /20 (e.g. a docker bridge 172.17.0.0/16 = 65k hosts) so AUTO discovery no longer stalls startup or hammers the network on any host with Docker. Manual scan shares the same MIN_SCAN_PREFIX. - Docker: config.json is auto-seeded from config.json.example by a new docker-entrypoint.sh and persisted on a ./data directory mount — no more single-file bind-mount footgun, no manual cp before first run. - Config path is overridable via ADSBIT_CONFIG (defaults to ./config.json for bare metal; /app/data/config.json in the container). - New /health endpoint (status/version/receivers/flights) + Docker HEALTHCHECK. - Clean SIGTERM/SIGINT shutdown (docker stop / systemctl stop) — no traceback. - .gitignore/.dockerignore exclude the data/ config volume. - README/CLAUDE.md updated; VERSION bumped to 1.1.1. Verified: image builds, fresh `docker run` seeds config and reports healthy, oversized subnets skipped in-container, /health responds, graceful shutdown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1321 lines
46 KiB
Python
Executable File
1321 lines
46 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""ADS-Bit Server - Connects directly to ADS-B receivers"""
|
|
import asyncio
|
|
import socket
|
|
import json
|
|
import time
|
|
import os
|
|
import copy
|
|
import re
|
|
import secrets
|
|
import ipaddress
|
|
from pathlib import Path
|
|
from dataclasses import dataclass, asdict
|
|
from datetime import datetime
|
|
from typing import Dict, Set, List
|
|
from aiohttp import web, ClientSession, ClientTimeout
|
|
import netifaces
|
|
import bcrypt
|
|
|
|
VERSION = "1.1.1"
|
|
|
|
WEB_DIR = Path(__file__).parent
|
|
# Config path is overridable (handy for containers persisting config on a
|
|
# named volume); defaults to config.json next to this script for bare-metal.
|
|
CONFIG_FILE = Path(os.environ.get("ADSBIT_CONFIG") or (WEB_DIR / "config.json"))
|
|
|
|
# Smallest subnet prefix (largest network) we'll auto-scan: /20 = 4094 hosts.
|
|
# Anything bigger (e.g. a docker /16) is skipped to keep scans fast and safe.
|
|
MIN_SCAN_PREFIX = 20
|
|
|
|
# Track server start time
|
|
SERVER_START_TIME = time.time()
|
|
|
|
# Default configuration - all possible settings with sane defaults
|
|
DEFAULT_CONFIG = {
|
|
"setup_complete": False,
|
|
"admin": {
|
|
"password_hash": "",
|
|
"session_secret": ""
|
|
},
|
|
"receivers": "AUTO",
|
|
"receiver_port": 30003,
|
|
"location": {
|
|
"name": "My Location",
|
|
"lat": 0.0,
|
|
"lon": 0.0
|
|
},
|
|
"web_port": 2001,
|
|
"theme": "desert",
|
|
"site": {
|
|
"title": "ADS-Bit",
|
|
"subtitle": "Retro Flight Tracker"
|
|
},
|
|
"display": {
|
|
"temperature_unit": "F",
|
|
"show_weather": True,
|
|
"show_sidebar": True,
|
|
"default_view_direction": 0
|
|
},
|
|
"tuning": {
|
|
"flight_timeout_seconds": 60,
|
|
"broadcast_interval_seconds": 1,
|
|
"cleanup_interval_seconds": 10,
|
|
"receiver_reconnect_seconds": 5
|
|
},
|
|
"theme_names": {}
|
|
}
|
|
|
|
|
|
def deep_merge(base: dict, override: dict) -> dict:
|
|
"""Deep merge override into base, returning new dict. Base values used as defaults."""
|
|
result = copy.deepcopy(base)
|
|
for key, value in override.items():
|
|
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
result[key] = deep_merge(result[key], value)
|
|
else:
|
|
result[key] = copy.deepcopy(value)
|
|
return result
|
|
|
|
|
|
# Active configuration
|
|
config = copy.deepcopy(DEFAULT_CONFIG)
|
|
|
|
|
|
def load_config():
|
|
"""Load configuration from config.json, deep-merged over defaults."""
|
|
global config
|
|
if CONFIG_FILE.exists():
|
|
try:
|
|
with open(CONFIG_FILE, 'r') as f:
|
|
loaded = json.load(f)
|
|
config = deep_merge(DEFAULT_CONFIG, loaded)
|
|
print(f"Loaded config from {CONFIG_FILE}")
|
|
except Exception as e:
|
|
print(f"Error loading config: {e}, using defaults")
|
|
config = copy.deepcopy(DEFAULT_CONFIG)
|
|
else:
|
|
print(f"No config file found at {CONFIG_FILE}, using defaults")
|
|
config = copy.deepcopy(DEFAULT_CONFIG)
|
|
|
|
print(f" Setup complete: {config['setup_complete']}")
|
|
print(f" Receivers: {config['receivers']}")
|
|
print(f" Receiver port: {config['receiver_port']}")
|
|
print(f" Location: {config['location']['name']} ({config['location']['lat']}, {config['location']['lon']})")
|
|
print(f" Web port: {config['web_port']}")
|
|
print(f" Theme: {config.get('theme', 'desert')}")
|
|
|
|
|
|
def save_config():
|
|
"""Persist current config to config.json."""
|
|
try:
|
|
with open(CONFIG_FILE, 'w') as f:
|
|
json.dump(config, f, indent=2)
|
|
print(f"Config saved to {CONFIG_FILE}")
|
|
except Exception as e:
|
|
print(f"Error saving config: {e}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Session management (in-memory, lost on restart)
|
|
# ---------------------------------------------------------------------------
|
|
sessions: Dict[str, float] = {} # token -> expiry timestamp
|
|
SESSION_DURATION = 86400 # 24 hours
|
|
|
|
|
|
def create_session() -> str:
|
|
token = secrets.token_hex(32)
|
|
sessions[token] = time.time() + SESSION_DURATION
|
|
return token
|
|
|
|
|
|
def validate_session(token: str) -> bool:
|
|
if not token:
|
|
return False
|
|
expiry = sessions.get(token)
|
|
if expiry is None:
|
|
return False
|
|
if time.time() > expiry:
|
|
sessions.pop(token, None)
|
|
return False
|
|
return True
|
|
|
|
|
|
def destroy_session(token: str):
|
|
sessions.pop(token, None)
|
|
|
|
|
|
def get_session_token(request) -> str:
|
|
"""Extract session token from cookie."""
|
|
return request.cookies.get('adsbit_session', '')
|
|
|
|
|
|
def require_auth(handler):
|
|
"""Decorator that checks for valid admin session."""
|
|
async def wrapper(request):
|
|
token = get_session_token(request)
|
|
if not validate_session(token):
|
|
return web.json_response({"error": "Unauthorized"}, status=401)
|
|
return await handler(request)
|
|
return wrapper
|
|
|
|
|
|
def require_setup_incomplete(handler):
|
|
"""Decorator that only allows access when setup is not complete."""
|
|
async def wrapper(request):
|
|
if config.get("setup_complete", False):
|
|
return web.json_response({"error": "Setup already complete"}, status=403)
|
|
return await handler(request)
|
|
return wrapper
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Flight data storage
|
|
# ---------------------------------------------------------------------------
|
|
flights: Dict[str, dict] = {}
|
|
connected_clients: Set = set()
|
|
receivers: List[str] = []
|
|
receiver_tasks: List[asyncio.Task] = []
|
|
emitter_categories: Dict[str, str] = {} # ICAO hex -> ADS-B emitter category (A1, A3, A7, etc.)
|
|
|
|
# Live per-receiver connection health, keyed by IP. Each entry:
|
|
# {"state": "connecting"|"connected"|"down", "last_msg": ts|None,
|
|
# "msg_count": int, "connected_since": ts|None, "error": str}
|
|
receiver_status: Dict[str, dict] = {}
|
|
|
|
# A receiver is considered "receiving" if it produced a message this recently.
|
|
RECEIVER_DATA_FRESH_SECONDS = 30
|
|
|
|
|
|
def reset_receiver_status(ips):
|
|
"""Replace the tracked receiver set, marking each as connecting."""
|
|
global receiver_status
|
|
receiver_status = {
|
|
ip: {"state": "connecting", "last_msg": None,
|
|
"msg_count": 0, "connected_since": None, "error": ""}
|
|
for ip in ips
|
|
}
|
|
|
|
|
|
def describe_receiver(ip, reachable=None):
|
|
"""Build a display-friendly status object for one receiver.
|
|
|
|
state -> (label, color):
|
|
down/missing -> DOWN (red)
|
|
connecting -> CONNECTING (gray)
|
|
connected + data -> RECEIVING (green)
|
|
connected, no data -> NO DATA (yellow) (socket up; maybe no aircraft in range)
|
|
reachable: optional active port-probe result that overrides a stale state.
|
|
"""
|
|
st = receiver_status.get(ip)
|
|
now = time.time()
|
|
if st is None:
|
|
state, last_age, msg_count = "down", None, 0
|
|
else:
|
|
state = st["state"]
|
|
last_age = (now - st["last_msg"]) if st["last_msg"] else None
|
|
msg_count = st["msg_count"]
|
|
|
|
# An active probe result is authoritative about reachability.
|
|
if reachable is False:
|
|
state = "down"
|
|
elif reachable is True and state == "down":
|
|
state = "connected"
|
|
|
|
if state == "down":
|
|
label, color = "DOWN", "red"
|
|
elif state == "connecting":
|
|
label, color = "CONNECTING", "gray"
|
|
elif last_age is not None and last_age <= RECEIVER_DATA_FRESH_SECONDS:
|
|
label, color = "RECEIVING", "green"
|
|
else:
|
|
label, color = "NO DATA", "yellow"
|
|
|
|
return {
|
|
"ip": ip,
|
|
"state": state,
|
|
"label": label,
|
|
"color": color,
|
|
"msg_count": msg_count,
|
|
"last_msg_age": round(last_age, 1) if last_age is not None else None,
|
|
"reachable": reachable,
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class SBSMessage:
|
|
msg_type: str
|
|
icao: str
|
|
callsign: str = ""
|
|
altitude: int = 0
|
|
speed: float = 0
|
|
track: float = 0
|
|
lat: float = 0
|
|
lon: float = 0
|
|
vrate: int = 0
|
|
squawk: str = ""
|
|
ground: bool = False
|
|
|
|
|
|
def parse_sbs_message(line: str):
|
|
"""Parse SBS/BaseStation format message"""
|
|
parts = line.strip().split(',')
|
|
if len(parts) < 10:
|
|
return None
|
|
|
|
msg_type = parts[0]
|
|
if msg_type not in ['MSG']:
|
|
return None
|
|
|
|
icao = parts[4].strip()
|
|
if not icao:
|
|
return None
|
|
|
|
msg = SBSMessage(msg_type=msg_type, icao=icao)
|
|
|
|
if len(parts) > 10 and parts[10].strip():
|
|
msg.callsign = parts[10].strip()
|
|
if len(parts) > 11 and parts[11].strip():
|
|
try:
|
|
msg.altitude = int(parts[11])
|
|
except ValueError:
|
|
pass
|
|
if len(parts) > 12 and parts[12].strip():
|
|
try:
|
|
msg.speed = float(parts[12])
|
|
except ValueError:
|
|
pass
|
|
if len(parts) > 13 and parts[13].strip():
|
|
try:
|
|
msg.track = float(parts[13])
|
|
except ValueError:
|
|
pass
|
|
if len(parts) > 14 and parts[14].strip():
|
|
try:
|
|
msg.lat = float(parts[14])
|
|
except ValueError:
|
|
pass
|
|
if len(parts) > 15 and parts[15].strip():
|
|
try:
|
|
msg.lon = float(parts[15])
|
|
except ValueError:
|
|
pass
|
|
if len(parts) > 16 and parts[16].strip():
|
|
try:
|
|
msg.vrate = int(parts[16])
|
|
except ValueError:
|
|
pass
|
|
if len(parts) > 17 and parts[17].strip():
|
|
msg.squawk = parts[17].strip()
|
|
if len(parts) > 21 and parts[21].strip() == '-1':
|
|
msg.ground = True
|
|
|
|
return msg
|
|
|
|
|
|
async def connect_to_receiver(host: str, port: int = 30003):
|
|
"""Connect to an SBS receiver and process messages"""
|
|
reconnect_delay = config['tuning']['receiver_reconnect_seconds']
|
|
print(f"Connecting to receiver at {host}:{port}...")
|
|
# Ensure a status entry exists even if this task started outside a reset.
|
|
receiver_status.setdefault(host, {"state": "connecting", "last_msg": None,
|
|
"msg_count": 0, "connected_since": None, "error": ""})
|
|
while True:
|
|
try:
|
|
receiver_status[host]["state"] = "connecting"
|
|
reader, writer = await asyncio.open_connection(host, port)
|
|
print(f"Connected to {host}:{port}")
|
|
receiver_status[host]["state"] = "connected"
|
|
receiver_status[host]["connected_since"] = time.time()
|
|
receiver_status[host]["error"] = ""
|
|
|
|
while True:
|
|
line = await reader.readline()
|
|
if not line:
|
|
break
|
|
|
|
line = line.decode('utf-8', errors='ignore')
|
|
msg = parse_sbs_message(line)
|
|
if msg and msg.icao:
|
|
receiver_status[host]["last_msg"] = time.time()
|
|
receiver_status[host]["msg_count"] += 1
|
|
if msg.icao not in flights:
|
|
flights[msg.icao] = {
|
|
'icao': msg.icao,
|
|
'callsign': '',
|
|
'altitude': 0,
|
|
'speed': 0,
|
|
'track': 0,
|
|
'lat': 0,
|
|
'lon': 0,
|
|
'vrate': 0,
|
|
'squawk': '',
|
|
'ground': False,
|
|
'category': emitter_categories.get(msg.icao.upper(), ''),
|
|
'last_seen': time.time()
|
|
}
|
|
|
|
flight = flights[msg.icao]
|
|
if msg.callsign:
|
|
flight['callsign'] = msg.callsign
|
|
if msg.altitude:
|
|
flight['altitude'] = msg.altitude
|
|
if msg.speed:
|
|
flight['speed'] = msg.speed
|
|
if msg.track:
|
|
flight['track'] = msg.track
|
|
if msg.lat:
|
|
flight['lat'] = msg.lat
|
|
if msg.lon:
|
|
flight['lon'] = msg.lon
|
|
if msg.vrate:
|
|
flight['vrate'] = msg.vrate
|
|
if msg.squawk:
|
|
flight['squawk'] = msg.squawk
|
|
flight['ground'] = msg.ground
|
|
flight['last_seen'] = time.time()
|
|
|
|
# readline returned empty -> peer closed the connection.
|
|
receiver_status[host]["state"] = "down"
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
except Exception as e:
|
|
print(f"Receiver {host}:{port} error: {e}")
|
|
receiver_status[host]["state"] = "down"
|
|
receiver_status[host]["error"] = str(e)
|
|
await asyncio.sleep(reconnect_delay)
|
|
|
|
|
|
async def _scan_subnet(network, port):
|
|
"""Scan a single subnet for hosts with an open port. Returns list of IPs."""
|
|
found = []
|
|
print(f"Scanning {network} ({network.num_addresses} hosts) on port {port}...")
|
|
host_ips = [str(host) for host in network.hosts()]
|
|
|
|
async def check_ip(test_ip):
|
|
if await test_port(test_ip, port):
|
|
return test_ip
|
|
return None
|
|
|
|
results = await asyncio.gather(*[check_ip(h) for h in host_ips])
|
|
for result in results:
|
|
if result:
|
|
print(f"Found receiver at {result}:{port}")
|
|
found.append(result)
|
|
return found
|
|
|
|
|
|
async def scan_for_receivers():
|
|
"""Scan local network for ADS-B receivers on configured port"""
|
|
port = config['receiver_port']
|
|
print(f"Scanning for ADS-B receivers on port {port}...")
|
|
found = []
|
|
|
|
for iface in netifaces.interfaces():
|
|
addrs = netifaces.ifaddresses(iface)
|
|
if netifaces.AF_INET in addrs:
|
|
for addr in addrs[netifaces.AF_INET]:
|
|
ip = addr.get('addr')
|
|
netmask = addr.get('netmask')
|
|
if ip and netmask and not ip.startswith('127.'):
|
|
try:
|
|
network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False)
|
|
# Skip subnets too large to scan quickly (e.g. a docker
|
|
# bridge /16 = 65k hosts). Without this, AUTO discovery
|
|
# hammers the network and stalls startup on any host with
|
|
# Docker or a large/16. Use MANUAL or a custom subnet for
|
|
# receivers that live on such networks.
|
|
if network.prefixlen < MIN_SCAN_PREFIX:
|
|
print(f"Skipping {network} on {iface}: too large "
|
|
f"({network.num_addresses} hosts) for auto-scan")
|
|
continue
|
|
found.extend(await _scan_subnet(network, port))
|
|
except ValueError as e:
|
|
print(f"Invalid network {ip}/{netmask}: {e}")
|
|
|
|
return found
|
|
|
|
|
|
async def test_port(ip: str, port: int, timeout: float = 0.5):
|
|
"""Test if a port is open"""
|
|
try:
|
|
conn = asyncio.open_connection(ip, port)
|
|
reader, writer = await asyncio.wait_for(conn, timeout=timeout)
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
return True
|
|
except:
|
|
return False
|
|
|
|
|
|
async def poll_aircraft_json():
|
|
"""Poll dump1090/readsb JSON API to get ADS-B emitter categories.
|
|
|
|
The SBS/BaseStation protocol (port 30003) doesn't include emitter category,
|
|
but the dump1090 JSON API exposes the raw ADS-B category field (A1=Light,
|
|
A2=Small, A3=Large, A5=Heavy, A7=Rotorcraft, etc.). This task periodically
|
|
fetches that data and merges it into the flights dict.
|
|
"""
|
|
# Wait for receivers to be determined before starting
|
|
await asyncio.sleep(5)
|
|
|
|
json_api_urls = []
|
|
for receiver_ip in receivers:
|
|
json_api_urls.append(f"http://{receiver_ip}/dump1090/data/aircraft.json")
|
|
|
|
if not json_api_urls:
|
|
print("No receivers for JSON API polling")
|
|
return
|
|
|
|
timeout = ClientTimeout(total=5)
|
|
print(f"Starting JSON API polling for emitter categories: {json_api_urls}")
|
|
|
|
async with ClientSession(timeout=timeout) as session:
|
|
while True:
|
|
for url in json_api_urls:
|
|
try:
|
|
async with session.get(url) as resp:
|
|
if resp.status == 200:
|
|
data = await resp.json()
|
|
aircraft_list = data.get("aircraft", [])
|
|
count = 0
|
|
for ac in aircraft_list:
|
|
hex_code = ac.get("hex", "").strip().upper()
|
|
cat = ac.get("category", "")
|
|
if hex_code and cat:
|
|
emitter_categories[hex_code] = cat
|
|
# Merge into active flight if present
|
|
if hex_code in flights:
|
|
flights[hex_code]["category"] = cat
|
|
count += 1
|
|
except Exception:
|
|
pass # JSON API not available on this receiver, use heuristics
|
|
|
|
await asyncio.sleep(5)
|
|
|
|
|
|
async def cleanup_old_flights():
|
|
"""Remove flights not seen recently (uses tuning config)."""
|
|
while True:
|
|
interval = config['tuning']['cleanup_interval_seconds']
|
|
timeout = config['tuning']['flight_timeout_seconds']
|
|
await asyncio.sleep(interval)
|
|
now = time.time()
|
|
to_remove = [icao for icao, flight in flights.items()
|
|
if now - flight['last_seen'] > timeout]
|
|
for icao in to_remove:
|
|
del flights[icao]
|
|
|
|
|
|
async def broadcast_flights():
|
|
"""Broadcast flight data to all connected WebSocket clients."""
|
|
while True:
|
|
interval = config['tuning']['broadcast_interval_seconds']
|
|
await asyncio.sleep(interval)
|
|
if connected_clients:
|
|
flight_list = [f for f in flights.values() if f['lat'] and f['lon']]
|
|
message = json.dumps({
|
|
'type': 'flights',
|
|
'flights': flight_list,
|
|
'count': len(flight_list)
|
|
})
|
|
|
|
dead_clients = set()
|
|
for client in connected_clients:
|
|
try:
|
|
await client.send_str(message)
|
|
except:
|
|
dead_clients.add(client)
|
|
|
|
connected_clients.difference_update(dead_clients)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WebSocket handler
|
|
# ---------------------------------------------------------------------------
|
|
async def websocket_handler(request):
|
|
"""Handle WebSocket connections from browser"""
|
|
ws = web.WebSocketResponse()
|
|
await ws.prepare(request)
|
|
|
|
connected_clients.add(ws)
|
|
print(f"WebSocket client connected ({len(connected_clients)} total)")
|
|
|
|
try:
|
|
async for msg in ws:
|
|
pass
|
|
finally:
|
|
connected_clients.discard(ws)
|
|
print(f"WebSocket client disconnected ({len(connected_clients)} remaining)")
|
|
|
|
return ws
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API routes (no auth)
|
|
# ---------------------------------------------------------------------------
|
|
async def handle_receiver_location(request):
|
|
"""Return receiver location from config."""
|
|
return web.json_response({
|
|
"lat": config["location"]["lat"],
|
|
"lon": config["location"]["lon"],
|
|
"name": config["location"]["name"]
|
|
})
|
|
|
|
|
|
async def handle_config(request):
|
|
"""Return client-relevant configuration (public, no secrets)."""
|
|
return web.json_response({
|
|
"version": VERSION,
|
|
"theme": config.get("theme", "desert"),
|
|
"location": config["location"],
|
|
"receivers": receivers,
|
|
"site": config.get("site", DEFAULT_CONFIG["site"]),
|
|
"display": config.get("display", DEFAULT_CONFIG["display"])
|
|
})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth routes
|
|
# ---------------------------------------------------------------------------
|
|
async def handle_auth_login(request):
|
|
"""POST /api/auth/login - Authenticate with admin password."""
|
|
try:
|
|
body = await request.json()
|
|
except:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
password = body.get("password", "")
|
|
stored_hash = config.get("admin", {}).get("password_hash", "")
|
|
|
|
if not stored_hash:
|
|
return web.json_response({"error": "No admin password set"}, status=403)
|
|
|
|
try:
|
|
if bcrypt.checkpw(password.encode('utf-8'), stored_hash.encode('utf-8')):
|
|
token = create_session()
|
|
resp = web.json_response({"ok": True})
|
|
resp.set_cookie('adsbit_session', token,
|
|
max_age=SESSION_DURATION,
|
|
httponly=True,
|
|
samesite='Lax',
|
|
path='/')
|
|
return resp
|
|
except Exception:
|
|
pass
|
|
|
|
return web.json_response({"error": "Invalid password"}, status=401)
|
|
|
|
|
|
async def handle_auth_logout(request):
|
|
"""POST /api/auth/logout - Destroy session."""
|
|
token = get_session_token(request)
|
|
destroy_session(token)
|
|
resp = web.json_response({"ok": True})
|
|
resp.del_cookie('adsbit_session', path='/')
|
|
return resp
|
|
|
|
|
|
async def handle_auth_check(request):
|
|
"""GET /api/auth/check - Check if current session is valid."""
|
|
token = get_session_token(request)
|
|
if validate_session(token):
|
|
return web.json_response({"authenticated": True})
|
|
return web.json_response({"authenticated": False}, status=401)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Setup routes (only when setup_complete is false)
|
|
# ---------------------------------------------------------------------------
|
|
@require_setup_incomplete
|
|
async def handle_setup_password(request):
|
|
"""POST /api/setup/password - Set initial admin password."""
|
|
try:
|
|
body = await request.json()
|
|
except:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
password = body.get("password", "")
|
|
if len(password) < 4:
|
|
return web.json_response({"error": "Password must be at least 4 characters"}, status=400)
|
|
|
|
hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
|
config['admin']['password_hash'] = hashed
|
|
config['admin']['session_secret'] = secrets.token_hex(16)
|
|
save_config()
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
@require_setup_incomplete
|
|
async def handle_setup_location(request):
|
|
"""POST /api/setup/location - Set location during setup."""
|
|
try:
|
|
body = await request.json()
|
|
except:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
config['location']['name'] = body.get("name", config['location']['name'])
|
|
config['location']['lat'] = float(body.get("lat", config['location']['lat']))
|
|
config['location']['lon'] = float(body.get("lon", config['location']['lon']))
|
|
save_config()
|
|
return web.json_response({"ok": True, "location": config['location']})
|
|
|
|
|
|
@require_setup_incomplete
|
|
async def handle_setup_receivers(request):
|
|
"""POST /api/setup/receivers - Configure receivers during setup."""
|
|
try:
|
|
body = await request.json()
|
|
except:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
mode = body.get("mode", "AUTO")
|
|
if mode == "AUTO":
|
|
config['receivers'] = "AUTO"
|
|
else:
|
|
ips = body.get("ips", [])
|
|
if isinstance(ips, list) and ips:
|
|
config['receivers'] = ips
|
|
else:
|
|
config['receivers'] = "AUTO"
|
|
|
|
if "port" in body:
|
|
config['receiver_port'] = int(body['port'])
|
|
|
|
save_config()
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
@require_setup_incomplete
|
|
async def handle_setup_scan(request):
|
|
"""GET /api/setup/scan - Scan for receivers during setup."""
|
|
found = await scan_for_receivers()
|
|
return web.json_response({"receivers": found})
|
|
|
|
|
|
@require_setup_incomplete
|
|
async def handle_setup_theme(request):
|
|
"""POST /api/setup/theme - Set theme and preferences during setup."""
|
|
try:
|
|
body = await request.json()
|
|
except:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
if "theme" in body:
|
|
config['theme'] = body['theme']
|
|
if "temperature_unit" in body:
|
|
config['display']['temperature_unit'] = body['temperature_unit']
|
|
if "site_title" in body:
|
|
config['site']['title'] = body['site_title']
|
|
if "site_subtitle" in body:
|
|
config['site']['subtitle'] = body['site_subtitle']
|
|
|
|
save_config()
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
@require_setup_incomplete
|
|
async def handle_setup_complete(request):
|
|
"""POST /api/setup/complete - Mark setup as done."""
|
|
# Verify a password was set
|
|
if not config.get('admin', {}).get('password_hash', ''):
|
|
return web.json_response({"error": "Admin password must be set first"}, status=400)
|
|
|
|
config['setup_complete'] = True
|
|
save_config()
|
|
|
|
# Restart receiver connections with new config
|
|
await restart_receiver_connections()
|
|
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Admin API routes (require auth)
|
|
# ---------------------------------------------------------------------------
|
|
@require_auth
|
|
async def handle_admin_config(request):
|
|
"""GET /api/admin/config - Return full config (minus password hash)."""
|
|
safe_config = copy.deepcopy(config)
|
|
safe_config['admin'] = {"password_set": bool(config['admin']['password_hash'])}
|
|
return web.json_response(safe_config)
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_config_update(request):
|
|
"""PUT /api/admin/config - Update config sections."""
|
|
try:
|
|
body = await request.json()
|
|
except:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
# Updatable sections
|
|
if "location" in body:
|
|
loc = body["location"]
|
|
if "name" in loc:
|
|
config['location']['name'] = loc['name']
|
|
if "lat" in loc:
|
|
config['location']['lat'] = float(loc['lat'])
|
|
if "lon" in loc:
|
|
config['location']['lon'] = float(loc['lon'])
|
|
|
|
if "receivers" in body:
|
|
config['receivers'] = body['receivers']
|
|
|
|
if "receiver_port" in body:
|
|
config['receiver_port'] = int(body['receiver_port'])
|
|
|
|
if "theme" in body:
|
|
config['theme'] = body['theme']
|
|
|
|
if "site" in body:
|
|
for k in ('title', 'subtitle'):
|
|
if k in body['site']:
|
|
config['site'][k] = body['site'][k]
|
|
|
|
if "display" in body:
|
|
for k in ('temperature_unit', 'show_weather', 'show_sidebar', 'default_view_direction'):
|
|
if k in body['display']:
|
|
config['display'][k] = body['display'][k]
|
|
|
|
if "tuning" in body:
|
|
for k in ('flight_timeout_seconds', 'broadcast_interval_seconds',
|
|
'cleanup_interval_seconds', 'receiver_reconnect_seconds'):
|
|
if k in body['tuning']:
|
|
config['tuning'][k] = int(body['tuning'][k])
|
|
|
|
save_config()
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_status(request):
|
|
"""GET /api/admin/status - Live dashboard data."""
|
|
flight_list = [f for f in flights.values() if f['lat'] and f['lon']]
|
|
uptime = int(time.time() - SERVER_START_TIME)
|
|
hours, remainder = divmod(uptime, 3600)
|
|
minutes, seconds = divmod(remainder, 60)
|
|
|
|
return web.json_response({
|
|
"active_flights": len(flight_list),
|
|
"total_tracked": len(flights),
|
|
"connected_viewers": len(connected_clients),
|
|
"receivers": receivers,
|
|
"receiver_count": len(receivers),
|
|
"uptime": f"{hours}h {minutes}m {seconds}s",
|
|
"uptime_seconds": uptime
|
|
})
|
|
|
|
|
|
VALID_DIRECTIONS = ("north", "east", "south", "west")
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_themes(request):
|
|
"""GET /api/admin/themes - List available themes with display names and directions."""
|
|
bg_dir = WEB_DIR / "backgrounds"
|
|
themes = []
|
|
theme_names = config.get("theme_names", {})
|
|
if bg_dir.exists():
|
|
for d in sorted(bg_dir.iterdir()):
|
|
if d.is_dir():
|
|
directions = {}
|
|
for direction in VALID_DIRECTIONS:
|
|
directions[direction] = (d / f"{direction}.png").exists()
|
|
display_name = theme_names.get(d.name, d.name.replace("_", " ").replace("-", " ").title())
|
|
themes.append({
|
|
"id": d.name,
|
|
"name": display_name,
|
|
"directions": directions,
|
|
})
|
|
return web.json_response({"themes": themes, "active": config.get("theme", "desert")})
|
|
|
|
|
|
def _valid_theme_id(theme_id: str) -> bool:
|
|
"""Reject path-traversal attempts in theme IDs."""
|
|
return bool(theme_id) and '/' not in theme_id and '\\' not in theme_id and theme_id not in ('.', '..')
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_theme_create(request):
|
|
"""POST /api/admin/themes - Create a new theme."""
|
|
try:
|
|
body = await request.json()
|
|
except:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
name = body.get("name", "").strip()
|
|
if not name:
|
|
return web.json_response({"error": "Theme name is required"}, status=400)
|
|
|
|
# Generate slug from name
|
|
slug = re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')
|
|
if not slug or not _valid_theme_id(slug):
|
|
return web.json_response({"error": "Invalid theme name"}, status=400)
|
|
|
|
bg_dir = WEB_DIR / "backgrounds" / slug
|
|
if bg_dir.exists():
|
|
return web.json_response({"error": "Theme already exists"}, status=409)
|
|
|
|
bg_dir.mkdir(parents=True, exist_ok=True)
|
|
config.setdefault("theme_names", {})[slug] = name
|
|
save_config()
|
|
return web.json_response({"ok": True, "id": slug, "name": name})
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_theme_rename(request):
|
|
"""PUT /api/admin/themes/{id} - Rename a theme's display name."""
|
|
theme_id = request.match_info.get("id", "")
|
|
if not _valid_theme_id(theme_id):
|
|
return web.json_response({"error": "Invalid theme ID"}, status=400)
|
|
bg_dir = WEB_DIR / "backgrounds" / theme_id
|
|
if not bg_dir.exists() or not bg_dir.is_dir():
|
|
return web.json_response({"error": "Theme not found"}, status=404)
|
|
|
|
try:
|
|
body = await request.json()
|
|
except:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
name = body.get("name", "").strip()
|
|
if not name:
|
|
return web.json_response({"error": "Display name is required"}, status=400)
|
|
|
|
config.setdefault("theme_names", {})[theme_id] = name
|
|
save_config()
|
|
return web.json_response({"ok": True, "id": theme_id, "name": name})
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_theme_upload(request):
|
|
"""POST /api/admin/themes/{id}/{direction} - Upload a background PNG."""
|
|
theme_id = request.match_info.get("id", "")
|
|
direction = request.match_info.get("direction", "")
|
|
|
|
if not _valid_theme_id(theme_id):
|
|
return web.json_response({"error": "Invalid theme ID"}, status=400)
|
|
if direction not in VALID_DIRECTIONS:
|
|
return web.json_response({"error": f"Invalid direction: {direction}"}, status=400)
|
|
|
|
bg_dir = WEB_DIR / "backgrounds" / theme_id
|
|
if not bg_dir.exists() or not bg_dir.is_dir():
|
|
return web.json_response({"error": "Theme not found"}, status=404)
|
|
|
|
reader = await request.multipart()
|
|
field = await reader.next()
|
|
if field is None or field.name != "file":
|
|
return web.json_response({"error": "No file field in upload"}, status=400)
|
|
|
|
# Read the uploaded file (limit to 10 MB for background images)
|
|
data = bytearray()
|
|
while True:
|
|
chunk = await field.read_chunk(8192)
|
|
if not chunk:
|
|
break
|
|
data.extend(chunk)
|
|
if len(data) > 10 * 1024 * 1024:
|
|
return web.json_response({"error": "File too large (max 10 MB)"}, status=400)
|
|
|
|
# Validate PNG header
|
|
if data[:8] != b'\x89PNG\r\n\x1a\n':
|
|
return web.json_response({"error": "File is not a valid PNG"}, status=400)
|
|
|
|
filepath = bg_dir / f"{direction}.png"
|
|
with open(filepath, "wb") as f:
|
|
f.write(data)
|
|
|
|
return web.json_response({"ok": True, "id": theme_id, "direction": direction})
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_password(request):
|
|
"""POST /api/admin/password - Change admin password."""
|
|
try:
|
|
body = await request.json()
|
|
except:
|
|
return web.json_response({"error": "Invalid JSON"}, status=400)
|
|
|
|
current = body.get("current_password", "")
|
|
new_pw = body.get("new_password", "")
|
|
|
|
if len(new_pw) < 4:
|
|
return web.json_response({"error": "Password must be at least 4 characters"}, status=400)
|
|
|
|
stored_hash = config.get("admin", {}).get("password_hash", "")
|
|
if stored_hash:
|
|
try:
|
|
if not bcrypt.checkpw(current.encode('utf-8'), stored_hash.encode('utf-8')):
|
|
return web.json_response({"error": "Current password is incorrect"}, status=401)
|
|
except Exception:
|
|
return web.json_response({"error": "Current password is incorrect"}, status=401)
|
|
|
|
hashed = bcrypt.hashpw(new_pw.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
|
config['admin']['password_hash'] = hashed
|
|
save_config()
|
|
return web.json_response({"ok": True})
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_network_info(request):
|
|
"""GET /api/admin/network-info - Return the server's network interfaces."""
|
|
interfaces = []
|
|
for iface in netifaces.interfaces():
|
|
addrs = netifaces.ifaddresses(iface)
|
|
if netifaces.AF_INET in addrs:
|
|
for addr in addrs[netifaces.AF_INET]:
|
|
ip = addr.get('addr')
|
|
netmask = addr.get('netmask')
|
|
if ip and netmask and not ip.startswith('127.'):
|
|
try:
|
|
network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False)
|
|
interfaces.append({
|
|
"name": iface,
|
|
"ip": ip,
|
|
"netmask": netmask,
|
|
"network": str(network)
|
|
})
|
|
except ValueError:
|
|
pass
|
|
return web.json_response({"interfaces": interfaces})
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_scan_receivers(request):
|
|
"""POST /api/admin/scan-receivers - Trigger a receiver scan."""
|
|
# Check for optional subnet in request body
|
|
subnet = None
|
|
try:
|
|
body = await request.json()
|
|
subnet = body.get("subnet")
|
|
except:
|
|
pass # No body or invalid JSON — fall back to full scan
|
|
|
|
if subnet:
|
|
# Validate and scan a specific subnet
|
|
try:
|
|
network = ipaddress.IPv4Network(subnet, strict=False)
|
|
except (ValueError, TypeError):
|
|
return web.json_response(
|
|
{"error": f"Invalid subnet: {subnet}"}, status=400)
|
|
|
|
if network.prefixlen < MIN_SCAN_PREFIX:
|
|
return web.json_response(
|
|
{"error": f"Subnet too large (max /{MIN_SCAN_PREFIX} = "
|
|
f"{2 ** (32 - MIN_SCAN_PREFIX) - 2} hosts)"}, status=400)
|
|
|
|
port = config['receiver_port']
|
|
found = await _scan_subnet(network, port)
|
|
else:
|
|
found = await scan_for_receivers()
|
|
|
|
return web.json_response({"receivers": found})
|
|
|
|
|
|
# All valid sprite type names
|
|
SPRITE_TYPES = [
|
|
"smallProp", "regionalJet", "narrowBody", "wideBody",
|
|
"heavy", "helicopter", "balloon", "glider", "uav"
|
|
]
|
|
|
|
# Which ADS-B emitter categories map to each sprite
|
|
SPRITE_CATEGORY_MAP = {
|
|
"smallProp": "A1, B4",
|
|
"regionalJet": "A2",
|
|
"narrowBody": "A3, A4, A6",
|
|
"wideBody": "A5 (lower alt/speed)",
|
|
"heavy": "A5 (high alt/speed)",
|
|
"helicopter": "A7",
|
|
"balloon": "B2",
|
|
"glider": "B1",
|
|
"uav": "B6",
|
|
}
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_sprites(request):
|
|
"""GET /api/admin/sprites - List all sprite types with exists/url info."""
|
|
sprites = []
|
|
for stype in SPRITE_TYPES:
|
|
filepath = WEB_DIR / "images" / f"{stype}.png"
|
|
sprites.append({
|
|
"type": stype,
|
|
"exists": filepath.exists(),
|
|
"url": f"/images/{stype}.png",
|
|
"categories": SPRITE_CATEGORY_MAP.get(stype, ""),
|
|
})
|
|
return web.json_response({"sprites": sprites})
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_sprite_upload(request):
|
|
"""POST /api/admin/sprites/{type} - Upload/replace a sprite PNG."""
|
|
stype = request.match_info.get("type", "")
|
|
if stype not in SPRITE_TYPES:
|
|
return web.json_response(
|
|
{"error": f"Invalid sprite type: {stype}"}, status=400
|
|
)
|
|
|
|
reader = await request.multipart()
|
|
field = await reader.next()
|
|
if field is None or field.name != "file":
|
|
return web.json_response({"error": "No file field in upload"}, status=400)
|
|
|
|
# Read the uploaded file (limit to 5 MB)
|
|
data = bytearray()
|
|
while True:
|
|
chunk = await field.read_chunk(8192)
|
|
if not chunk:
|
|
break
|
|
data.extend(chunk)
|
|
if len(data) > 5 * 1024 * 1024:
|
|
return web.json_response({"error": "File too large (max 5 MB)"}, status=400)
|
|
|
|
# Validate PNG header
|
|
if data[:8] != b'\x89PNG\r\n\x1a\n':
|
|
return web.json_response({"error": "File is not a valid PNG"}, status=400)
|
|
|
|
# Save to images directory
|
|
filepath = WEB_DIR / "images" / f"{stype}.png"
|
|
with open(filepath, "wb") as f:
|
|
f.write(data)
|
|
|
|
return web.json_response({"ok": True, "type": stype})
|
|
|
|
|
|
async def restart_receiver_connections():
|
|
"""Cancel existing receiver tasks and start new ones."""
|
|
global receivers, receiver_tasks
|
|
|
|
# Cancel old tasks and let them unwind cleanly. Awaiting the cancelled
|
|
# tasks here (with return_exceptions) absorbs their CancelledError so it
|
|
# can never propagate into the main event loop.
|
|
old_tasks = list(receiver_tasks)
|
|
receiver_tasks.clear()
|
|
for task in old_tasks:
|
|
task.cancel()
|
|
if old_tasks:
|
|
await asyncio.gather(*old_tasks, return_exceptions=True)
|
|
|
|
# Determine receivers
|
|
receiver_config = config["receivers"]
|
|
receiver_port = config["receiver_port"]
|
|
|
|
try:
|
|
if receiver_config == "AUTO":
|
|
found = await scan_for_receivers()
|
|
receivers = found
|
|
elif isinstance(receiver_config, list):
|
|
receivers = list(receiver_config)
|
|
elif isinstance(receiver_config, str) and receiver_config != "AUTO":
|
|
receivers = [receiver_config]
|
|
else:
|
|
receivers = []
|
|
except Exception as e:
|
|
print(f"Error determining receivers: {e}")
|
|
receivers = []
|
|
|
|
# Reset health tracking to the new receiver set.
|
|
reset_receiver_status(receivers)
|
|
|
|
# Start new tasks
|
|
for r in receivers:
|
|
task = asyncio.create_task(connect_to_receiver(r, receiver_port))
|
|
receiver_tasks.append(task)
|
|
|
|
print(f"Receiver connections restarted: {len(receivers)} receiver(s)")
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_restart_receivers(request):
|
|
"""POST /api/admin/restart-receivers - Restart all receiver connections."""
|
|
await restart_receiver_connections()
|
|
return web.json_response({"ok": True, "receivers": receivers})
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_receiver_status(request):
|
|
"""GET /api/admin/receiver-status - Live health of configured receivers."""
|
|
statuses = [describe_receiver(ip) for ip in receivers]
|
|
return web.json_response({
|
|
"receivers": statuses,
|
|
"configured": config["receivers"], # "AUTO" or a list of IPs
|
|
"count": len(receivers),
|
|
})
|
|
|
|
|
|
@require_auth
|
|
async def handle_admin_test_receivers(request):
|
|
"""POST /api/admin/test-receivers - Actively probe each configured receiver
|
|
and report reachability combined with live data-flow state."""
|
|
port = config["receiver_port"]
|
|
probes = await asyncio.gather(*[test_port(ip, port) for ip in receivers])
|
|
statuses = [describe_receiver(ip, reachable=reachable)
|
|
for ip, reachable in zip(receivers, probes)]
|
|
return web.json_response({"receivers": statuses, "count": len(receivers)})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Static file serving with setup redirect
|
|
# ---------------------------------------------------------------------------
|
|
async def handle_http(request):
|
|
"""Serve static files, with setup redirect logic."""
|
|
path = request.path
|
|
|
|
# Root redirect: if setup not complete, redirect to /setup
|
|
if path == '/' or path == '/index.html':
|
|
if not config.get('setup_complete', False):
|
|
raise web.HTTPFound('/setup')
|
|
path = '/index.html'
|
|
|
|
# Serve /setup -> setup/setup.html
|
|
if path == '/setup' or path == '/setup/':
|
|
file_path = WEB_DIR / 'setup' / 'setup.html'
|
|
if file_path.exists():
|
|
return web.FileResponse(file_path)
|
|
return web.Response(status=404, text="Setup page not found")
|
|
|
|
# Serve /admin -> admin/admin.html
|
|
if path == '/admin' or path == '/admin/':
|
|
file_path = WEB_DIR / 'admin' / 'admin.html'
|
|
if file_path.exists():
|
|
return web.FileResponse(file_path)
|
|
return web.Response(status=404, text="Admin page not found")
|
|
|
|
file_path = WEB_DIR / path.lstrip('/')
|
|
if file_path.exists() and file_path.is_file():
|
|
return web.FileResponse(file_path)
|
|
return web.Response(status=404, text="Not Found")
|
|
|
|
|
|
async def handle_health(request):
|
|
"""GET /health - liveness/readiness probe (no auth, no secrets)."""
|
|
return web.json_response({
|
|
"status": "ok",
|
|
"version": VERSION,
|
|
"receivers": len(receivers),
|
|
"flights": len(flights),
|
|
"uptime_seconds": int(time.time() - SERVER_START_TIME),
|
|
})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Application setup
|
|
# ---------------------------------------------------------------------------
|
|
async def start_http_server():
|
|
"""Start HTTP server with WebSocket support."""
|
|
port = config["web_port"]
|
|
app = web.Application()
|
|
|
|
# WebSocket
|
|
app.router.add_get('/ws', websocket_handler)
|
|
|
|
# Public API
|
|
app.router.add_get('/health', handle_health)
|
|
app.router.add_get('/api/receiver-location', handle_receiver_location)
|
|
app.router.add_get('/api/config', handle_config)
|
|
|
|
# Auth API
|
|
app.router.add_post('/api/auth/login', handle_auth_login)
|
|
app.router.add_post('/api/auth/logout', handle_auth_logout)
|
|
app.router.add_get('/api/auth/check', handle_auth_check)
|
|
|
|
# Setup API
|
|
app.router.add_post('/api/setup/password', handle_setup_password)
|
|
app.router.add_post('/api/setup/location', handle_setup_location)
|
|
app.router.add_post('/api/setup/receivers', handle_setup_receivers)
|
|
app.router.add_get('/api/setup/scan', handle_setup_scan)
|
|
app.router.add_post('/api/setup/theme', handle_setup_theme)
|
|
app.router.add_post('/api/setup/complete', handle_setup_complete)
|
|
|
|
# Admin API
|
|
app.router.add_get('/api/admin/config', handle_admin_config)
|
|
app.router.add_put('/api/admin/config', handle_admin_config_update)
|
|
app.router.add_get('/api/admin/status', handle_admin_status)
|
|
app.router.add_get('/api/admin/themes', handle_admin_themes)
|
|
app.router.add_post('/api/admin/themes', handle_admin_theme_create)
|
|
app.router.add_put('/api/admin/themes/{id}', handle_admin_theme_rename)
|
|
app.router.add_post('/api/admin/themes/{id}/{direction}', handle_admin_theme_upload)
|
|
app.router.add_post('/api/admin/password', handle_admin_password)
|
|
app.router.add_get('/api/admin/network-info', handle_admin_network_info)
|
|
app.router.add_post('/api/admin/scan-receivers', handle_admin_scan_receivers)
|
|
app.router.add_post('/api/admin/restart-receivers', handle_admin_restart_receivers)
|
|
app.router.add_get('/api/admin/receiver-status', handle_admin_receiver_status)
|
|
app.router.add_post('/api/admin/test-receivers', handle_admin_test_receivers)
|
|
app.router.add_get('/api/admin/sprites', handle_admin_sprites)
|
|
app.router.add_post('/api/admin/sprites/{type}', handle_admin_sprite_upload)
|
|
|
|
# Static files (must be last - catch-all)
|
|
app.router.add_get('/{tail:.*}', handle_http)
|
|
|
|
runner = web.AppRunner(app)
|
|
await runner.setup()
|
|
site = web.TCPSite(runner, '0.0.0.0', port)
|
|
await site.start()
|
|
print(f"HTTP server running on http://0.0.0.0:{port}")
|
|
if not config.get('setup_complete', False):
|
|
print(f"First-run setup at http://0.0.0.0:{port}/setup")
|
|
print(f"Admin panel at http://0.0.0.0:{port}/admin")
|
|
|
|
|
|
async def main():
|
|
"""Main entry point"""
|
|
global receivers, receiver_tasks
|
|
|
|
print(f"ADS-Bit Server v{VERSION} Starting...")
|
|
|
|
load_config()
|
|
|
|
print(f"Access at http://0.0.0.0:{config['web_port']}")
|
|
|
|
# Get receivers based on config
|
|
receiver_config = config["receivers"]
|
|
receiver_port = config["receiver_port"]
|
|
|
|
if receiver_config == "AUTO":
|
|
found = await scan_for_receivers()
|
|
receivers = found
|
|
elif isinstance(receiver_config, list):
|
|
receivers = receiver_config
|
|
print(f"Using configured receivers: {receivers}")
|
|
elif isinstance(receiver_config, str):
|
|
receivers = [receiver_config]
|
|
print(f"Using configured receiver: {receivers[0]}")
|
|
|
|
if not receivers:
|
|
print("WARNING: No receivers found or configured!")
|
|
else:
|
|
print(f"Using {len(receivers)} receiver(s)")
|
|
|
|
reset_receiver_status(receivers)
|
|
|
|
# Start all tasks
|
|
tasks = [
|
|
start_http_server(),
|
|
cleanup_old_flights(),
|
|
broadcast_flights(),
|
|
poll_aircraft_json(),
|
|
]
|
|
|
|
# Connect to all receivers. These run as independent background tasks and
|
|
# are deliberately NOT awaited in the gather below: restarting receivers
|
|
# cancels them, and a cancelled task inside the main gather would tear down
|
|
# the whole server. They still run as long as the event loop is alive.
|
|
for receiver in receivers:
|
|
task = asyncio.ensure_future(connect_to_receiver(receiver, receiver_port))
|
|
receiver_tasks.append(task)
|
|
|
|
# Only the core services keep the server alive.
|
|
await asyncio.gather(*tasks)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Make SIGTERM (docker stop, systemctl stop) raise KeyboardInterrupt like
|
|
# SIGINT does, so shutdown is clean and quiet instead of a stack trace.
|
|
import signal
|
|
signal.signal(signal.SIGTERM, signal.default_int_handler)
|
|
try:
|
|
asyncio.run(main())
|
|
except (KeyboardInterrupt, SystemExit):
|
|
print("ADS-Bit server shutting down.")
|