diff --git a/routes/adsb.py b/routes/adsb.py index 2ee4511..230c40a 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -35,6 +35,7 @@ from utils.constants import ( ADSB_UPDATE_INTERVAL, DUMP1090_START_WAIT, ) +from utils import aircraft_db adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb') @@ -43,6 +44,14 @@ adsb_using_service = False adsb_connected = False adsb_messages_received = 0 adsb_last_message_time = None +adsb_bytes_received = 0 +adsb_lines_received = 0 + +# Track ICAOs already looked up in aircraft database (avoid repeated lookups) +_looked_up_icaos: set[str] = set() + +# Load aircraft database at module init +aircraft_db.load_database() # Common installation paths for dump1090 (when not in PATH) DUMP1090_PATHS = [ @@ -91,7 +100,7 @@ def check_dump1090_service(): def parse_sbs_stream(service_addr): """Parse SBS format data from dump1090 SBS port.""" - global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time + global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received host, port = service_addr.split(':') port = int(port) @@ -111,12 +120,16 @@ def parse_sbs_stream(service_addr): buffer = "" last_update = time.time() pending_updates = set() + adsb_bytes_received = 0 + adsb_lines_received = 0 while adsb_using_service: try: data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore') if not data: + logger.warning("SBS connection closed (no data)") break + adsb_bytes_received += len(data) buffer += data while '\n' in buffer: @@ -125,8 +138,15 @@ def parse_sbs_stream(service_addr): if not line: continue + adsb_lines_received += 1 + # Log first few lines for debugging + if adsb_lines_received <= 3: + logger.info(f"SBS line {adsb_lines_received}: {line[:100]}") + parts = line.split(',') if len(parts) < 11 or parts[0] != 'MSG': + if adsb_lines_received <= 5: + logger.debug(f"Skipping non-MSG line: {line[:50]}") continue msg_type = parts[1] @@ -136,6 +156,18 @@ def parse_sbs_stream(service_addr): aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao} + # Look up aircraft type from database (once per ICAO) + if icao not in _looked_up_icaos: + _looked_up_icaos.add(icao) + db_info = aircraft_db.lookup(icao) + if db_info: + if db_info['registration']: + aircraft['registration'] = db_info['registration'] + if db_info['type_code']: + aircraft['type_code'] = db_info['type_code'] + if db_info['type_desc']: + aircraft['type_desc'] = db_info['type_desc'] + if msg_type == '1' and len(parts) > 10: callsign = parts[10].strip() if callsign: @@ -154,7 +186,7 @@ def parse_sbs_stream(service_addr): except (ValueError, TypeError): pass - elif msg_type == '4' and len(parts) > 13: + elif msg_type == '4' and len(parts) > 16: if parts[12]: try: aircraft['speed'] = int(float(parts[12])) @@ -165,6 +197,11 @@ def parse_sbs_stream(service_addr): aircraft['heading'] = int(float(parts[13])) except (ValueError, TypeError): pass + if parts[16]: + try: + aircraft['vertical_rate'] = int(float(parts[16])) + except (ValueError, TypeError): + pass elif msg_type == '5' and len(parts) > 11: if parts[10]: @@ -242,15 +279,23 @@ def check_adsb_tools(): @adsb_bp.route('/status') def adsb_status(): """Get ADS-B tracking status for debugging.""" + # Check if dump1090 process is still running + dump1090_running = False + if app_module.adsb_process: + dump1090_running = app_module.adsb_process.poll() is None + return jsonify({ 'tracking_active': adsb_using_service, 'connected_to_sbs': adsb_connected, 'messages_received': adsb_messages_received, + 'bytes_received': adsb_bytes_received, + 'lines_received': adsb_lines_received, 'last_message_time': adsb_last_message_time, 'aircraft_count': len(app_module.adsb_aircraft), 'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data 'queue_size': app_module.adsb_queue.qsize(), 'dump1090_path': find_dump1090(), + 'dump1090_running': dump1090_running, 'port_30003_open': check_dump1090_service() is not None }) @@ -349,16 +394,29 @@ def start_adsb(): app_module.adsb_process = subprocess.Popen( cmd, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL + stderr=subprocess.PIPE ) time.sleep(DUMP1090_START_WAIT) if app_module.adsb_process.poll() is not None: + # Process exited - try to get error message + stderr_output = '' + if app_module.adsb_process.stderr: + try: + stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip() + except Exception: + pass if sdr_type == SDRType.RTL_SDR: - return jsonify({'status': 'error', 'message': 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'}) + error_msg = 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.' + if stderr_output: + error_msg += f' Error: {stderr_output[:200]}' + return jsonify({'status': 'error', 'message': error_msg}) else: - return jsonify({'status': 'error', 'message': f'ADS-B decoder failed to start for {sdr_type.value}. Ensure readsb is installed with SoapySDR support and the device is connected.'}) + error_msg = f'ADS-B decoder failed to start for {sdr_type.value}. Ensure readsb is installed with SoapySDR support and the device is connected.' + if stderr_output: + error_msg += f' Error: {stderr_output[:200]}' + return jsonify({'status': 'error', 'message': error_msg}) adsb_using_service = True thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True) @@ -385,6 +443,7 @@ def stop_adsb(): adsb_using_service = False app_module.adsb_aircraft.clear() + _looked_up_icaos.clear() return jsonify({'status': 'stopped'}) @@ -415,3 +474,38 @@ def stream_adsb(): def adsb_dashboard(): """Popout ADS-B dashboard.""" return render_template('adsb_dashboard.html') + + +# ============================================ +# AIRCRAFT DATABASE MANAGEMENT +# ============================================ + +@adsb_bp.route('/aircraft-db/status') +def aircraft_db_status(): + """Get aircraft database status.""" + return jsonify(aircraft_db.get_db_status()) + + +@adsb_bp.route('/aircraft-db/check-updates') +def aircraft_db_check_updates(): + """Check for aircraft database updates.""" + result = aircraft_db.check_for_updates() + return jsonify(result) + + +@adsb_bp.route('/aircraft-db/download', methods=['POST']) +def aircraft_db_download(): + """Download/update aircraft database.""" + global _looked_up_icaos + result = aircraft_db.download_database() + if result.get('success'): + # Clear lookup cache so new data is used + _looked_up_icaos.clear() + return jsonify(result) + + +@adsb_bp.route('/aircraft-db/delete', methods=['POST']) +def aircraft_db_delete(): + """Delete aircraft database.""" + result = aircraft_db.delete_database() + return jsonify(result) diff --git a/utils/aircraft_db.py b/utils/aircraft_db.py new file mode 100644 index 0000000..b59fdc3 --- /dev/null +++ b/utils/aircraft_db.py @@ -0,0 +1,268 @@ +"""Aircraft database for ICAO hex to type/registration lookup.""" + +from __future__ import annotations + +import json +import logging +import os +import threading +import time +from datetime import datetime +from typing import Any +from urllib.request import urlopen, Request +from urllib.error import URLError + +logger = logging.getLogger('intercept.aircraft_db') + +# Database file location (project root) +DB_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DB_FILE = os.path.join(DB_DIR, 'aircraft_db.json') +DB_META_FILE = os.path.join(DB_DIR, 'aircraft_db_meta.json') + +# Mictronics database URLs (raw GitHub) +AIRCRAFT_DB_URL = 'https://raw.githubusercontent.com/Mictronics/readsb-protobuf/dev/webapp/src/db/aircrafts.json' +TYPES_DB_URL = 'https://raw.githubusercontent.com/Mictronics/readsb-protobuf/dev/webapp/src/db/types.json' +GITHUB_API_URL = 'https://api.github.com/repos/Mictronics/readsb-protobuf/commits?path=webapp/src/db/aircrafts.json&per_page=1' + +# In-memory cache +_aircraft_cache: dict[str, dict[str, str]] = {} +_types_cache: dict[str, str] = {} +_cache_lock = threading.Lock() +_db_loaded = False +_db_version: str | None = None +_update_available: bool = False +_latest_version: str | None = None + + +def get_db_status() -> dict[str, Any]: + """Get current database status.""" + exists = os.path.exists(DB_FILE) + meta = _load_meta() + + return { + 'installed': exists, + 'version': meta.get('version') if meta else None, + 'downloaded': meta.get('downloaded') if meta else None, + 'aircraft_count': len(_aircraft_cache) if _db_loaded else 0, + 'update_available': _update_available, + 'latest_version': _latest_version, + } + + +def _load_meta() -> dict[str, Any] | None: + """Load database metadata.""" + try: + if os.path.exists(DB_META_FILE): + with open(DB_META_FILE, 'r') as f: + return json.load(f) + except Exception as e: + logger.warning(f"Error loading aircraft db meta: {e}") + return None + + +def _save_meta(version: str) -> None: + """Save database metadata.""" + try: + meta = { + 'version': version, + 'downloaded': datetime.utcnow().isoformat() + 'Z', + } + with open(DB_META_FILE, 'w') as f: + json.dump(meta, f, indent=2) + except Exception as e: + logger.warning(f"Error saving aircraft db meta: {e}") + + +def load_database() -> bool: + """Load aircraft database into memory. Returns True if successful.""" + global _aircraft_cache, _types_cache, _db_loaded, _db_version + + if not os.path.exists(DB_FILE): + logger.info("Aircraft database not installed") + return False + + try: + with _cache_lock: + with open(DB_FILE, 'r') as f: + data = json.load(f) + + _aircraft_cache = data.get('aircraft', {}) + _types_cache = data.get('types', {}) + _db_loaded = True + + meta = _load_meta() + _db_version = meta.get('version') if meta else 'unknown' + + logger.info(f"Loaded aircraft database: {len(_aircraft_cache)} aircraft, {len(_types_cache)} types") + return True + except Exception as e: + logger.error(f"Error loading aircraft database: {e}") + return False + + +def lookup(icao: str) -> dict[str, str] | None: + """ + Look up aircraft by ICAO hex code. + + Returns dict with keys: registration, type_code, type_desc + Or None if not found. + """ + if not _db_loaded: + return None + + icao_upper = icao.upper() + + with _cache_lock: + aircraft = _aircraft_cache.get(icao_upper) + if not aircraft: + return None + + # Database format is array: [registration, type_code, flags, ...] + # Handle both list format (from Mictronics) and dict format (legacy) + if isinstance(aircraft, list): + reg = aircraft[0] if len(aircraft) > 0 else '' + type_code = aircraft[1] if len(aircraft) > 1 else '' + else: + # Dict format fallback + reg = aircraft.get('r', '') + type_code = aircraft.get('t', '') + + # Look up type description + type_desc = '' + if type_code and type_code in _types_cache: + type_desc = _types_cache[type_code] + + return { + 'registration': reg, + 'type_code': type_code, + 'type_desc': type_desc, + } + + +def check_for_updates() -> dict[str, Any]: + """ + Check GitHub for database updates. + Returns status dict with update_available flag. + """ + global _update_available, _latest_version + + try: + req = Request(GITHUB_API_URL, headers={'User-Agent': 'Intercept-SIGINT'}) + with urlopen(req, timeout=10) as response: + commits = json.loads(response.read().decode('utf-8')) + + if commits and len(commits) > 0: + latest_sha = commits[0]['sha'][:8] + latest_date = commits[0]['commit']['committer']['date'] + _latest_version = f"{latest_date[:10]}_{latest_sha}" + + meta = _load_meta() + current_version = meta.get('version') if meta else None + + _update_available = current_version != _latest_version + + return { + 'success': True, + 'current_version': current_version, + 'latest_version': _latest_version, + 'update_available': _update_available, + } + except URLError as e: + logger.warning(f"Failed to check for updates: {e}") + return {'success': False, 'error': str(e)} + except Exception as e: + logger.warning(f"Error checking for updates: {e}") + return {'success': False, 'error': str(e)} + + return {'success': False, 'error': 'Unknown error'} + + +def download_database(progress_callback=None) -> dict[str, Any]: + """ + Download latest aircraft database from Mictronics repo. + Returns status dict. + """ + global _update_available + + try: + if progress_callback: + progress_callback('Downloading aircraft database...') + + # Download aircraft database + req = Request(AIRCRAFT_DB_URL, headers={'User-Agent': 'Intercept-SIGINT'}) + with urlopen(req, timeout=60) as response: + aircraft_data = json.loads(response.read().decode('utf-8')) + + if progress_callback: + progress_callback('Downloading type codes...') + + # Download types database + req = Request(TYPES_DB_URL, headers={'User-Agent': 'Intercept-SIGINT'}) + with urlopen(req, timeout=30) as response: + types_data = json.loads(response.read().decode('utf-8')) + + if progress_callback: + progress_callback('Processing database...') + + # Combine into single file + combined = { + 'aircraft': aircraft_data, + 'types': types_data, + } + + # Save to file + with open(DB_FILE, 'w') as f: + json.dump(combined, f, separators=(',', ':')) # Compact JSON + + # Get version from GitHub + version = datetime.utcnow().strftime('%Y-%m-%d') + try: + req = Request(GITHUB_API_URL, headers={'User-Agent': 'Intercept-SIGINT'}) + with urlopen(req, timeout=10) as response: + commits = json.loads(response.read().decode('utf-8')) + if commits: + sha = commits[0]['sha'][:8] + date = commits[0]['commit']['committer']['date'][:10] + version = f"{date}_{sha}" + except Exception: + pass + + _save_meta(version) + _update_available = False + + # Reload into memory + load_database() + + return { + 'success': True, + 'message': f'Downloaded {len(aircraft_data)} aircraft, {len(types_data)} types', + 'version': version, + } + + except URLError as e: + logger.error(f"Download failed: {e}") + return {'success': False, 'error': f'Download failed: {e}'} + except Exception as e: + logger.error(f"Error downloading database: {e}") + return {'success': False, 'error': str(e)} + + +def delete_database() -> dict[str, Any]: + """Delete local database files.""" + global _aircraft_cache, _types_cache, _db_loaded, _db_version + + try: + with _cache_lock: + _aircraft_cache = {} + _types_cache = {} + _db_loaded = False + _db_version = None + + if os.path.exists(DB_FILE): + os.remove(DB_FILE) + if os.path.exists(DB_META_FILE): + os.remove(DB_META_FILE) + + return {'success': True, 'message': 'Database deleted'} + except Exception as e: + return {'success': False, 'error': str(e)}