diff --git a/routes/__init__.py b/routes/__init__.py index 3e55949..92c269d 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -35,6 +35,7 @@ def register_blueprints(app): from .recordings import recordings_bp from .subghz import subghz_bp from .bt_locate import bt_locate_bp + from .analytics import analytics_bp app.register_blueprint(pager_bp) app.register_blueprint(sensor_bp) @@ -69,6 +70,7 @@ def register_blueprints(app): app.register_blueprint(recordings_bp) # Session recordings app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF) app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking + app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard # Initialize TSCM state with queue and lock from app import app as app_module diff --git a/routes/acars.py b/routes/acars.py index 72f8b7d..186970f 100644 --- a/routes/acars.py +++ b/routes/acars.py @@ -129,6 +129,13 @@ def stream_acars_output(process: subprocess.Popen, is_text_mode: bool = False) - app_module.acars_queue.put(data) + # Feed flight correlator + try: + from utils.flight_correlator import get_flight_correlator + get_flight_correlator().add_acars_message(data) + except Exception: + pass + # Log if enabled if app_module.logging_enabled: try: diff --git a/routes/adsb.py b/routes/adsb.py index 98c6d69..861a0d5 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -439,6 +439,12 @@ def parse_sbs_stream(service_addr): if parts[16]: try: aircraft['vertical_rate'] = int(float(parts[16])) + if abs(aircraft['vertical_rate']) > 4000: + process_event('adsb', { + 'type': 'vertical_rate_anomaly', 'icao': icao, + 'callsign': aircraft.get('callsign', ''), + 'vertical_rate': aircraft['vertical_rate'], + }, 'vertical_rate_anomaly') except (ValueError, TypeError): pass @@ -456,6 +462,14 @@ def parse_sbs_stream(service_addr): elif msg_type == '6' and len(parts) > 17: if parts[17]: aircraft['squawk'] = parts[17] + sq = parts[17].strip() + _EMERGENCY_SQUAWKS = {'7700': 'General Emergency', '7600': 'Comms Failure', '7500': 'Hijack'} + if sq in _EMERGENCY_SQUAWKS: + process_event('adsb', { + 'type': 'squawk_emergency', 'icao': icao, + 'callsign': aircraft.get('callsign', ''), + 'squawk': sq, 'meaning': _EMERGENCY_SQUAWKS[sq], + }, 'squawk_emergency') app_module.adsb_aircraft.set(icao, aircraft) pending_updates.add(icao) @@ -488,6 +502,19 @@ def parse_sbs_stream(service_addr): 'source_host': service_addr, 'snapshot': snapshot, }) + # Geofence check + _gf_lat = snapshot.get('lat') + _gf_lon = snapshot.get('lon') + if _gf_lat and _gf_lon: + try: + from utils.geofence import get_geofence_manager + for _gf_evt in get_geofence_manager().check_position( + update_icao, 'aircraft', _gf_lat, _gf_lon, + {'callsign': snapshot.get('callsign'), 'altitude': snapshot.get('altitude')} + ): + process_event('adsb', _gf_evt, 'geofence') + except Exception: + pass pending_updates.clear() last_update = now @@ -1103,3 +1130,17 @@ def aircraft_photo(registration: str): except Exception as e: logger.debug(f"Error fetching aircraft photo: {e}") return jsonify({'success': False, 'error': str(e)}), 500 + + +@adsb_bp.route('/aircraft//messages') +def get_aircraft_messages(icao: str): + """Get correlated ACARS/VDL2 messages for an aircraft.""" + if not icao or not all(c in '0123456789ABCDEFabcdef' for c in icao): + return jsonify({'status': 'error', 'message': 'Invalid ICAO'}), 400 + + aircraft = app_module.adsb_aircraft.get(icao.upper()) + callsign = aircraft.get('callsign') if aircraft else None + + from utils.flight_correlator import get_flight_correlator + messages = get_flight_correlator().get_messages_for_aircraft(icao=icao.upper(), callsign=callsign) + return jsonify({'status': 'success', 'icao': icao.upper(), **messages}) diff --git a/routes/ais.py b/routes/ais.py index b481fd8..414f97c 100644 --- a/routes/ais.py +++ b/routes/ais.py @@ -124,13 +124,27 @@ def parse_ais_stream(port: int): if now - last_update >= AIS_UPDATE_INTERVAL: for mmsi in pending_updates: if mmsi in app_module.ais_vessels: + _vessel_snap = app_module.ais_vessels[mmsi] try: app_module.ais_queue.put_nowait({ 'type': 'vessel', - **app_module.ais_vessels[mmsi] + **_vessel_snap }) except queue.Full: pass + # Geofence check + _v_lat = _vessel_snap.get('lat') + _v_lon = _vessel_snap.get('lon') + if _v_lat and _v_lon: + try: + from utils.geofence import get_geofence_manager + for _gf_evt in get_geofence_manager().check_position( + mmsi, 'vessel', _v_lat, _v_lon, + {'name': _vessel_snap.get('name'), 'ship_type': _vessel_snap.get('ship_type_text')} + ): + process_event('ais', _gf_evt, 'geofence') + except Exception: + pass pending_updates.clear() last_update = now @@ -282,6 +296,16 @@ def process_ais_message(msg: dict) -> dict | None: # Timestamp vessel['last_seen'] = time.time() + # Check for DSC DISTRESS matching this MMSI + try: + for _dsc_key, _dsc_msg in app_module.dsc_messages.items(): + if (str(_dsc_msg.get('source_mmsi', '')) == mmsi + and _dsc_msg.get('category', '').upper() == 'DISTRESS'): + vessel['dsc_distress'] = True + break + except Exception: + pass + return vessel @@ -502,6 +526,23 @@ def stream_ais(): return response +@ais_bp.route('/vessel//dsc') +def get_vessel_dsc(mmsi: str): + """Get DSC messages associated with a vessel MMSI.""" + if not mmsi or not mmsi.isdigit(): + return jsonify({'status': 'error', 'message': 'Invalid MMSI'}), 400 + + matches = [] + try: + for key, msg in app_module.dsc_messages.items(): + if str(msg.get('source_mmsi', '')) == mmsi: + matches.append(dict(msg)) + except Exception: + pass + + return jsonify({'status': 'success', 'mmsi': mmsi, 'dsc_messages': matches}) + + @ais_bp.route('/dashboard') def ais_dashboard(): """Popout AIS dashboard.""" diff --git a/routes/analytics.py b/routes/analytics.py new file mode 100644 index 0000000..6a5fe2b --- /dev/null +++ b/routes/analytics.py @@ -0,0 +1,182 @@ +"""Analytics dashboard: cross-mode summary, activity sparklines, export, geofence CRUD.""" + +from __future__ import annotations + +import csv +import io +import json + +from flask import Blueprint, Response, jsonify, request + +import app as app_module +from utils.analytics import ( + get_activity_tracker, + get_cross_mode_summary, + get_emergency_squawks, + get_mode_health, +) +from utils.flight_correlator import get_flight_correlator +from utils.geofence import get_geofence_manager +from utils.temporal_patterns import get_pattern_detector + +analytics_bp = Blueprint('analytics', __name__, url_prefix='/analytics') + + +# Map mode names to DataStore attribute(s) +MODE_STORES: dict[str, list[str]] = { + 'adsb': ['adsb_aircraft'], + 'ais': ['ais_vessels'], + 'wifi': ['wifi_networks', 'wifi_clients'], + 'bluetooth': ['bt_devices'], + 'dsc': ['dsc_messages'], +} + + +@analytics_bp.route('/summary') +def analytics_summary(): + """Return cross-mode counts, health, and emergency squawks.""" + return jsonify({ + 'status': 'success', + 'counts': get_cross_mode_summary(), + 'health': get_mode_health(), + 'squawks': get_emergency_squawks(), + 'flight_messages': { + 'acars': get_flight_correlator().acars_count, + 'vdl2': get_flight_correlator().vdl2_count, + }, + }) + + +@analytics_bp.route('/activity') +def analytics_activity(): + """Return sparkline arrays for each mode.""" + tracker = get_activity_tracker() + return jsonify({ + 'status': 'success', + 'sparklines': tracker.get_all_sparklines(), + }) + + +@analytics_bp.route('/squawks') +def analytics_squawks(): + """Return current emergency squawk codes from ADS-B.""" + return jsonify({ + 'status': 'success', + 'squawks': get_emergency_squawks(), + }) + + +@analytics_bp.route('/patterns') +def analytics_patterns(): + """Return detected temporal patterns.""" + return jsonify({ + 'status': 'success', + 'patterns': get_pattern_detector().get_all_patterns(), + }) + + +@analytics_bp.route('/export/') +def analytics_export(mode: str): + """Export current DataStore contents as JSON or CSV.""" + fmt = request.args.get('format', 'json').lower() + + if mode == 'sensor': + # Sensor doesn't use DataStore; return recent queue-based data + return jsonify({'status': 'success', 'data': [], 'message': 'Sensor data is stream-only'}) + + store_names = MODE_STORES.get(mode) + if not store_names: + return jsonify({'status': 'error', 'message': f'Unknown mode: {mode}'}), 400 + + all_items: list[dict] = [] + for store_name in store_names: + store = getattr(app_module, store_name, None) + if store is None: + continue + for key, value in store.items(): + item = dict(value) if isinstance(value, dict) else {'id': key, 'value': value} + item.setdefault('_store', store_name) + all_items.append(item) + + if fmt == 'csv': + if not all_items: + output = '' + else: + # Collect all keys across items + fieldnames: list[str] = [] + seen: set[str] = set() + for item in all_items: + for k in item: + if k not in seen: + fieldnames.append(k) + seen.add(k) + + buf = io.StringIO() + writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction='ignore') + writer.writeheader() + for item in all_items: + # Serialize non-scalar values + row = {} + for k in fieldnames: + v = item.get(k) + if isinstance(v, (dict, list)): + row[k] = json.dumps(v) + else: + row[k] = v + writer.writerow(row) + output = buf.getvalue() + + response = Response(output, mimetype='text/csv') + response.headers['Content-Disposition'] = f'attachment; filename={mode}_export.csv' + return response + + # Default: JSON + return jsonify({'status': 'success', 'mode': mode, 'count': len(all_items), 'data': all_items}) + + +# ========================================================================= +# Geofence CRUD +# ========================================================================= + +@analytics_bp.route('/geofences') +def list_geofences(): + return jsonify({ + 'status': 'success', + 'zones': get_geofence_manager().list_zones(), + }) + + +@analytics_bp.route('/geofences', methods=['POST']) +def create_geofence(): + data = request.get_json() or {} + name = data.get('name') + lat = data.get('lat') + lon = data.get('lon') + radius_m = data.get('radius_m') + + if not all([name, lat is not None, lon is not None, radius_m is not None]): + return jsonify({'status': 'error', 'message': 'name, lat, lon, radius_m are required'}), 400 + + try: + lat = float(lat) + lon = float(lon) + radius_m = float(radius_m) + except (TypeError, ValueError): + return jsonify({'status': 'error', 'message': 'lat, lon, radius_m must be numbers'}), 400 + + if not (-90 <= lat <= 90) or not (-180 <= lon <= 180): + return jsonify({'status': 'error', 'message': 'Invalid coordinates'}), 400 + if radius_m <= 0: + return jsonify({'status': 'error', 'message': 'radius_m must be positive'}), 400 + + alert_on = data.get('alert_on', 'enter_exit') + zone_id = get_geofence_manager().add_zone(name, lat, lon, radius_m, alert_on) + return jsonify({'status': 'success', 'zone_id': zone_id}) + + +@analytics_bp.route('/geofences/', methods=['DELETE']) +def delete_geofence(zone_id: int): + ok = get_geofence_manager().delete_zone(zone_id) + if not ok: + return jsonify({'status': 'error', 'message': 'Zone not found'}), 404 + return jsonify({'status': 'success'}) diff --git a/routes/aprs.py b/routes/aprs.py index 39f2516..8f2ce93 100644 --- a/routes/aprs.py +++ b/routes/aprs.py @@ -19,16 +19,16 @@ from typing import Generator, Optional from flask import Blueprint, jsonify, request, Response import app as app_module -from utils.logging import sensor_logger as logger -from utils.validation import validate_device_index, validate_gain, validate_ppm -from utils.sse import format_sse -from utils.event_pipeline import process_event -from utils.sdr import SDRFactory, SDRType -from utils.constants import ( - PROCESS_TERMINATE_TIMEOUT, - SSE_KEEPALIVE_INTERVAL, - SSE_QUEUE_TIMEOUT, - PROCESS_START_WAIT, +from utils.logging import sensor_logger as logger +from utils.validation import validate_device_index, validate_gain, validate_ppm +from utils.sse import format_sse +from utils.event_pipeline import process_event +from utils.sdr import SDRFactory, SDRType +from utils.constants import ( + PROCESS_TERMINATE_TIMEOUT, + SSE_KEEPALIVE_INTERVAL, + SSE_QUEUE_TIMEOUT, + PROCESS_START_WAIT, ) aprs_bp = Blueprint('aprs', __name__, url_prefix='/aprs') @@ -73,19 +73,19 @@ def find_multimon_ng() -> Optional[str]: return shutil.which('multimon-ng') -def find_rtl_fm() -> Optional[str]: - """Find rtl_fm binary.""" - return shutil.which('rtl_fm') - - -def find_rx_fm() -> Optional[str]: - """Find SoapySDR rx_fm binary.""" - return shutil.which('rx_fm') - - -def find_rtl_power() -> Optional[str]: - """Find rtl_power binary for spectrum scanning.""" - return shutil.which('rtl_power') +def find_rtl_fm() -> Optional[str]: + """Find rtl_fm binary.""" + return shutil.which('rtl_fm') + + +def find_rx_fm() -> Optional[str]: + """Find SoapySDR rx_fm binary.""" + return shutil.which('rx_fm') + + +def find_rtl_power() -> Optional[str]: + """Find rtl_power binary for spectrum scanning.""" + return shutil.which('rtl_power') # Path to direwolf config file @@ -1378,6 +1378,19 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces 'last_seen': packet.get('timestamp'), 'packet_type': packet.get('packet_type'), } + # Geofence check + _aprs_lat = packet.get('lat') + _aprs_lon = packet.get('lon') + if _aprs_lat and _aprs_lon: + try: + from utils.geofence import get_geofence_manager + for _gf_evt in get_geofence_manager().check_position( + callsign, 'aprs_station', _aprs_lat, _aprs_lon, + {'callsign': callsign} + ): + process_event('aprs', _gf_evt, 'geofence') + except Exception: + pass # Evict oldest stations when limit is exceeded if len(aprs_stations) > APRS_MAX_STATIONS: oldest = min( @@ -1420,22 +1433,22 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces @aprs_bp.route('/tools') -def check_aprs_tools() -> Response: - """Check for APRS decoding tools.""" - has_rtl_fm = find_rtl_fm() is not None - has_rx_fm = find_rx_fm() is not None - has_direwolf = find_direwolf() is not None - has_multimon = find_multimon_ng() is not None - has_fm_demod = has_rtl_fm or has_rx_fm - - return jsonify({ - 'rtl_fm': has_rtl_fm, - 'rx_fm': has_rx_fm, - 'direwolf': has_direwolf, - 'multimon_ng': has_multimon, - 'ready': has_fm_demod and (has_direwolf or has_multimon), - 'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None) - }) +def check_aprs_tools() -> Response: + """Check for APRS decoding tools.""" + has_rtl_fm = find_rtl_fm() is not None + has_rx_fm = find_rx_fm() is not None + has_direwolf = find_direwolf() is not None + has_multimon = find_multimon_ng() is not None + has_fm_demod = has_rtl_fm or has_rx_fm + + return jsonify({ + 'rtl_fm': has_rtl_fm, + 'rx_fm': has_rx_fm, + 'direwolf': has_direwolf, + 'multimon_ng': has_multimon, + 'ready': has_fm_demod and (has_direwolf or has_multimon), + 'decoder': 'direwolf' if has_direwolf else ('multimon-ng' if has_multimon else None) + }) @aprs_bp.route('/status') @@ -1476,12 +1489,12 @@ def start_aprs() -> Response: 'message': 'APRS decoder already running' }), 409 - # Check for decoder (prefer direwolf, fallback to multimon-ng) - direwolf_path = find_direwolf() - multimon_path = find_multimon_ng() - - if not direwolf_path and not multimon_path: - return jsonify({ + # Check for decoder (prefer direwolf, fallback to multimon-ng) + direwolf_path = find_direwolf() + multimon_path = find_multimon_ng() + + if not direwolf_path and not multimon_path: + return jsonify({ 'status': 'error', 'message': 'No APRS decoder found. Install direwolf or multimon-ng' }), 400 @@ -1489,31 +1502,31 @@ def start_aprs() -> Response: data = request.json or {} # Validate inputs - try: - device = validate_device_index(data.get('device', '0')) - gain = validate_gain(data.get('gain', '40')) - ppm = validate_ppm(data.get('ppm', '0')) - except ValueError as e: - return jsonify({'status': 'error', 'message': str(e)}), 400 - - sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower() - try: - sdr_type = SDRType(sdr_type_str) - except ValueError: - sdr_type = SDRType.RTL_SDR - - if sdr_type == SDRType.RTL_SDR: - if find_rtl_fm() is None: - return jsonify({ - 'status': 'error', - 'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr' - }), 400 - else: - if find_rx_fm() is None: - return jsonify({ - 'status': 'error', - 'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.' - }), 400 + try: + device = validate_device_index(data.get('device', '0')) + gain = validate_gain(data.get('gain', '40')) + ppm = validate_ppm(data.get('ppm', '0')) + except ValueError as e: + return jsonify({'status': 'error', 'message': str(e)}), 400 + + sdr_type_str = str(data.get('sdr_type', 'rtlsdr')).lower() + try: + sdr_type = SDRType(sdr_type_str) + except ValueError: + sdr_type = SDRType.RTL_SDR + + if sdr_type == SDRType.RTL_SDR: + if find_rtl_fm() is None: + return jsonify({ + 'status': 'error', + 'message': 'rtl_fm not found. Install with: sudo apt install rtl-sdr' + }), 400 + else: + if find_rx_fm() is None: + return jsonify({ + 'status': 'error', + 'message': f'rx_fm not found. Install SoapySDR tools for {sdr_type.value}.' + }), 400 # Reserve SDR device to prevent conflicts with other modes error = app_module.claim_sdr_device(device, 'aprs') @@ -1545,29 +1558,29 @@ def start_aprs() -> Response: aprs_last_packet_time = None aprs_stations = {} - # Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction. - try: - sdr_device = SDRFactory.create_default_device(sdr_type, index=device) - builder = SDRFactory.get_builder(sdr_type) - rtl_cmd = builder.build_fm_demod_command( - device=sdr_device, - frequency_mhz=float(frequency), - sample_rate=22050, - gain=float(gain) if gain and str(gain) != '0' else None, - ppm=int(ppm) if ppm and str(ppm) != '0' else None, - modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm', - squelch=None, - bias_t=bool(data.get('bias_t', False)), - ) - - if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-': - # APRS benefits from DC blocking + fast AGC on rtl_fm. - rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-'] - except Exception as e: - if aprs_active_device is not None: - app_module.release_sdr_device(aprs_active_device) - aprs_active_device = None - return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500 + # Build FM demod command for APRS (AFSK1200 @ 22050 Hz) via SDR abstraction. + try: + sdr_device = SDRFactory.create_default_device(sdr_type, index=device) + builder = SDRFactory.get_builder(sdr_type) + rtl_cmd = builder.build_fm_demod_command( + device=sdr_device, + frequency_mhz=float(frequency), + sample_rate=22050, + gain=float(gain) if gain and str(gain) != '0' else None, + ppm=int(ppm) if ppm and str(ppm) != '0' else None, + modulation='nfm' if sdr_type == SDRType.RTL_SDR else 'fm', + squelch=None, + bias_t=bool(data.get('bias_t', False)), + ) + + if sdr_type == SDRType.RTL_SDR and rtl_cmd and rtl_cmd[-1] == '-': + # APRS benefits from DC blocking + fast AGC on rtl_fm. + rtl_cmd = rtl_cmd[:-1] + ['-E', 'dc', '-A', 'fast', '-'] + except Exception as e: + if aprs_active_device is not None: + app_module.release_sdr_device(aprs_active_device) + aprs_active_device = None + return jsonify({'status': 'error', 'message': f'Failed to build SDR command: {e}'}), 500 # Build decoder command if direwolf_path: @@ -1690,14 +1703,14 @@ def start_aprs() -> Response: ) thread.start() - return jsonify({ - 'status': 'started', - 'frequency': frequency, - 'region': region, - 'device': device, - 'sdr_type': sdr_type.value, - 'decoder': decoder_name - }) + return jsonify({ + 'status': 'started', + 'frequency': frequency, + 'region': region, + 'device': device, + 'sdr_type': sdr_type.value, + 'decoder': decoder_name + }) except Exception as e: logger.error(f"Failed to start APRS decoder: {e}") diff --git a/routes/meshtastic.py b/routes/meshtastic.py index 6e69925..c72d040 100644 --- a/routes/meshtastic.py +++ b/routes/meshtastic.py @@ -1051,3 +1051,19 @@ def request_store_forward(): 'status': 'error', 'message': error or 'Failed to request S&F history' }), 500 + + +@meshtastic_bp.route('/topology') +def mesh_topology(): + """Return mesh network topology graph.""" + if not is_meshtastic_available(): + return jsonify({'status': 'error', 'message': 'Meshtastic SDK not installed'}), 400 + + client = get_meshtastic_client() + if not client or not client.is_running: + return jsonify({'status': 'error', 'message': 'Not connected'}), 400 + + return jsonify({ + 'status': 'success', + 'topology': client.get_topology(), + }) diff --git a/routes/sensor.py b/routes/sensor.py index ec3de30..98f8a04 100644 --- a/routes/sensor.py +++ b/routes/sensor.py @@ -28,6 +28,10 @@ sensor_bp = Blueprint('sensor', __name__) # Track which device is being used sensor_active_device: int | None = None +# RSSI history per device (model_id -> list of (timestamp, rssi)) +sensor_rssi_history: dict[str, list[tuple[float, float]]] = {} +_MAX_RSSI_HISTORY = 60 + def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: """Stream rtl_433 JSON output to queue.""" @@ -45,6 +49,17 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: data['type'] = 'sensor' app_module.sensor_queue.put(data) + # Track RSSI history per device + _model = data.get('model', '') + _dev_id = data.get('id', '') + _rssi_val = data.get('rssi') + if _rssi_val is not None and _model: + _hist_key = f"{_model}_{_dev_id}" + hist = sensor_rssi_history.setdefault(_hist_key, []) + hist.append((time.time(), float(_rssi_val))) + if len(hist) > _MAX_RSSI_HISTORY: + del hist[: len(hist) - _MAX_RSSI_HISTORY] + # Push scope event when signal level data is present rssi = data.get('rssi') snr = data.get('snr') @@ -283,3 +298,12 @@ def stream_sensor() -> Response: response.headers['X-Accel-Buffering'] = 'no' response.headers['Connection'] = 'keep-alive' return response + + +@sensor_bp.route('/sensor/rssi_history') +def get_rssi_history() -> Response: + """Return RSSI history for all tracked sensor devices.""" + result = {} + for key, entries in sensor_rssi_history.items(): + result[key] = [{'t': round(t, 1), 'rssi': rssi} for t, rssi in entries] + return jsonify({'status': 'success', 'devices': result}) diff --git a/routes/vdl2.py b/routes/vdl2.py index 04f3f44..79fe6fc 100644 --- a/routes/vdl2.py +++ b/routes/vdl2.py @@ -76,6 +76,13 @@ def stream_vdl2_output(process: subprocess.Popen) -> None: app_module.vdl2_queue.put(data) + # Feed flight correlator + try: + from utils.flight_correlator import get_flight_correlator + get_flight_correlator().add_vdl2_message(data) + except Exception: + pass + # Log if enabled if app_module.logging_enabled: try: diff --git a/static/css/modes/analytics.css b/static/css/modes/analytics.css new file mode 100644 index 0000000..f36aaf1 --- /dev/null +++ b/static/css/modes/analytics.css @@ -0,0 +1,243 @@ +/* Analytics Dashboard Styles */ + +.analytics-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: var(--space-3, 12px); + margin-bottom: var(--space-4, 16px); +} + +.analytics-card { + background: var(--bg-card, #151f2b); + border: 1px solid var(--border-color, #1e2d3d); + border-radius: var(--radius-md, 8px); + padding: var(--space-3, 12px); + text-align: center; + transition: var(--transition-fast, 150ms ease); +} + +.analytics-card:hover { + border-color: var(--accent-cyan, #4aa3ff); +} + +.analytics-card .card-count { + font-size: var(--text-2xl, 24px); + font-weight: 700; + color: var(--text-primary, #e0e6ed); + line-height: 1.2; +} + +.analytics-card .card-label { + font-size: var(--text-xs, 10px); + color: var(--text-dim, #5a6a7a); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-top: var(--space-1, 4px); +} + +.analytics-card .card-sparkline { + height: 24px; + margin-top: var(--space-2, 8px); +} + +.analytics-card .card-sparkline svg { + width: 100%; + height: 100%; +} + +.analytics-card .card-sparkline polyline { + fill: none; + stroke: var(--accent-cyan, #4aa3ff); + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* Health indicators */ +.analytics-health { + display: flex; + flex-wrap: wrap; + gap: var(--space-2, 8px); + margin-bottom: var(--space-4, 16px); +} + +.health-item { + display: flex; + align-items: center; + gap: var(--space-1, 4px); + font-size: var(--text-xs, 10px); + color: var(--text-dim, #5a6a7a); + text-transform: uppercase; +} + +.health-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent-red, #e25d5d); +} + +.health-dot.running { + background: var(--accent-green, #38c180); +} + +/* Emergency squawk panel */ +.squawk-emergency { + background: rgba(226, 93, 93, 0.1); + border: 1px solid var(--accent-red, #e25d5d); + border-radius: var(--radius-md, 8px); + padding: var(--space-3, 12px); + margin-bottom: var(--space-3, 12px); +} + +.squawk-emergency .squawk-title { + color: var(--accent-red, #e25d5d); + font-weight: 700; + font-size: var(--text-sm, 12px); + text-transform: uppercase; + margin-bottom: var(--space-2, 8px); +} + +.squawk-emergency .squawk-item { + font-size: var(--text-sm, 12px); + color: var(--text-primary, #e0e6ed); + padding: var(--space-1, 4px) 0; + border-bottom: 1px solid rgba(226, 93, 93, 0.2); +} + +.squawk-emergency .squawk-item:last-child { + border-bottom: none; +} + +/* Alert feed */ +.analytics-alert-feed { + max-height: 200px; + overflow-y: auto; + margin-bottom: var(--space-4, 16px); +} + +.analytics-alert-item { + display: flex; + align-items: flex-start; + gap: var(--space-2, 8px); + padding: var(--space-2, 8px); + border-bottom: 1px solid var(--border-color, #1e2d3d); + font-size: var(--text-xs, 10px); +} + +.analytics-alert-item .alert-severity { + padding: 1px 6px; + border-radius: var(--radius-sm, 4px); + font-weight: 600; + text-transform: uppercase; + font-size: 9px; + white-space: nowrap; +} + +.alert-severity.critical { background: var(--accent-red, #e25d5d); color: #fff; } +.alert-severity.high { background: var(--accent-orange, #d6a85e); color: #000; } +.alert-severity.medium { background: var(--accent-cyan, #4aa3ff); color: #fff; } +.alert-severity.low { background: var(--border-color, #1e2d3d); color: var(--text-dim, #5a6a7a); } + +/* Correlation panel */ +.analytics-correlation-pair { + display: flex; + align-items: center; + gap: var(--space-2, 8px); + padding: var(--space-2, 8px); + border-bottom: 1px solid var(--border-color, #1e2d3d); + font-size: var(--text-xs, 10px); +} + +.analytics-correlation-pair .confidence-bar { + height: 4px; + background: var(--bg-secondary, #101823); + border-radius: 2px; + flex: 1; + max-width: 60px; +} + +.analytics-correlation-pair .confidence-fill { + height: 100%; + background: var(--accent-green, #38c180); + border-radius: 2px; +} + +/* Geofence zone list */ +.geofence-zone-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-2, 8px); + border-bottom: 1px solid var(--border-color, #1e2d3d); + font-size: var(--text-xs, 10px); +} + +.geofence-zone-item .zone-name { + font-weight: 600; + color: var(--text-primary, #e0e6ed); +} + +.geofence-zone-item .zone-radius { + color: var(--text-dim, #5a6a7a); +} + +.geofence-zone-item .zone-delete { + cursor: pointer; + color: var(--accent-red, #e25d5d); + padding: 2px 6px; + border: 1px solid var(--accent-red, #e25d5d); + border-radius: var(--radius-sm, 4px); + background: transparent; + font-size: 9px; +} + +/* Export controls */ +.export-controls { + display: flex; + gap: var(--space-2, 8px); + align-items: center; + flex-wrap: wrap; +} + +.export-controls select, +.export-controls button { + font-size: var(--text-xs, 10px); + padding: var(--space-1, 4px) var(--space-2, 8px); + background: var(--bg-card, #151f2b); + color: var(--text-primary, #e0e6ed); + border: 1px solid var(--border-color, #1e2d3d); + border-radius: var(--radius-sm, 4px); +} + +.export-controls button { + cursor: pointer; + background: var(--accent-cyan, #4aa3ff); + color: #fff; + border-color: var(--accent-cyan, #4aa3ff); +} + +.export-controls button:hover { + opacity: 0.9; +} + +/* Section headers */ +.analytics-section-header { + font-size: var(--text-xs, 10px); + font-weight: 600; + color: var(--text-dim, #5a6a7a); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--space-2, 8px); + padding-bottom: var(--space-1, 4px); + border-bottom: 1px solid var(--border-color, #1e2d3d); +} + +/* Empty state */ +.analytics-empty { + text-align: center; + color: var(--text-dim, #5a6a7a); + font-size: var(--text-xs, 10px); + padding: var(--space-4, 16px); + font-style: italic; +} diff --git a/static/js/modes/analytics.js b/static/js/modes/analytics.js new file mode 100644 index 0000000..f724318 --- /dev/null +++ b/static/js/modes/analytics.js @@ -0,0 +1,211 @@ +/** + * Analytics Dashboard Module + * Cross-mode summary, sparklines, alerts, correlations, geofence management, export. + */ +const Analytics = (function () { + 'use strict'; + + let refreshTimer = null; + + function init() { + refresh(); + if (!refreshTimer) { + refreshTimer = setInterval(refresh, 5000); + } + } + + function destroy() { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + } + + function refresh() { + Promise.all([ + fetch('/analytics/summary').then(r => r.json()).catch(() => null), + fetch('/analytics/activity').then(r => r.json()).catch(() => null), + fetch('/alerts/events?limit=20').then(r => r.json()).catch(() => null), + fetch('/correlation').then(r => r.json()).catch(() => null), + fetch('/analytics/geofences').then(r => r.json()).catch(() => null), + ]).then(([summary, activity, alerts, correlations, geofences]) => { + if (summary) renderSummary(summary); + if (activity) renderSparklines(activity.sparklines || {}); + if (alerts) renderAlerts(alerts.events || []); + if (correlations) renderCorrelations(correlations); + if (geofences) renderGeofences(geofences.zones || []); + }); + } + + function renderSummary(data) { + const counts = data.counts || {}; + _setText('analyticsCountAdsb', counts.adsb || 0); + _setText('analyticsCountAis', counts.ais || 0); + _setText('analyticsCountWifi', counts.wifi || 0); + _setText('analyticsCountBt', counts.bluetooth || 0); + _setText('analyticsCountDsc', counts.dsc || 0); + + // Health + const health = data.health || {}; + const container = document.getElementById('analyticsHealth'); + if (container) { + let html = ''; + const modeLabels = { + pager: 'Pager', sensor: '433MHz', adsb: 'ADS-B', ais: 'AIS', + acars: 'ACARS', vdl2: 'VDL2', aprs: 'APRS', wifi: 'WiFi', + bluetooth: 'BT', dsc: 'DSC' + }; + for (const [mode, info] of Object.entries(health)) { + if (mode === 'sdr_devices') continue; + const running = info && info.running; + const label = modeLabels[mode] || mode; + html += '
' + _esc(label) + '
'; + } + container.innerHTML = html; + } + + // Squawks + const squawks = data.squawks || []; + const sqSection = document.getElementById('analyticsSquawkSection'); + const sqList = document.getElementById('analyticsSquawkList'); + if (sqSection && sqList) { + if (squawks.length > 0) { + sqSection.style.display = ''; + sqList.innerHTML = squawks.map(s => + '
' + _esc(s.squawk) + ' ' + + _esc(s.meaning) + ' — ' + _esc(s.callsign || s.icao) + '
' + ).join(''); + } else { + sqSection.style.display = 'none'; + } + } + } + + function renderSparklines(sparklines) { + const map = { + adsb: 'analyticsSparkAdsb', + ais: 'analyticsSparkAis', + wifi: 'analyticsSparkWifi', + bluetooth: 'analyticsSparkBt', + dsc: 'analyticsSparkDsc', + }; + + for (const [mode, elId] of Object.entries(map)) { + const el = document.getElementById(elId); + if (!el) continue; + const data = sparklines[mode] || []; + if (data.length < 2) { + el.innerHTML = ''; + continue; + } + const max = Math.max(...data, 1); + const w = 100; + const h = 24; + const step = w / (data.length - 1); + const points = data.map((v, i) => + (i * step).toFixed(1) + ',' + (h - (v / max) * (h - 2)).toFixed(1) + ).join(' '); + el.innerHTML = ''; + } + } + + function renderAlerts(events) { + const container = document.getElementById('analyticsAlertFeed'); + if (!container) return; + if (!events || events.length === 0) { + container.innerHTML = '
No recent alerts
'; + return; + } + container.innerHTML = events.slice(0, 20).map(e => { + const sev = e.severity || 'medium'; + const title = e.title || e.event_type || 'Alert'; + const time = e.created_at ? new Date(e.created_at).toLocaleTimeString() : ''; + return '
' + + '' + _esc(sev) + '' + + '' + _esc(title) + '' + + '' + _esc(time) + '' + + '
'; + }).join(''); + } + + function renderCorrelations(data) { + const container = document.getElementById('analyticsCorrelations'); + if (!container) return; + const pairs = (data && data.correlations) || []; + if (pairs.length === 0) { + container.innerHTML = '
No correlations detected
'; + return; + } + container.innerHTML = pairs.slice(0, 20).map(p => { + const conf = Math.round((p.confidence || 0) * 100); + return '
' + + '' + _esc(p.wifi_mac || '') + '' + + '' + + '' + _esc(p.bt_mac || '') + '' + + '
' + + '' + conf + '%' + + '
'; + }).join(''); + } + + function renderGeofences(zones) { + const container = document.getElementById('analyticsGeofenceList'); + if (!container) return; + if (!zones || zones.length === 0) { + container.innerHTML = '
No geofence zones defined
'; + return; + } + container.innerHTML = zones.map(z => + '
' + + '' + _esc(z.name) + '' + + '' + z.radius_m + 'm' + + '' + + '
' + ).join(''); + } + + function addGeofence() { + const name = prompt('Zone name:'); + if (!name) return; + const lat = parseFloat(prompt('Latitude:', '0')); + const lon = parseFloat(prompt('Longitude:', '0')); + const radius = parseFloat(prompt('Radius (meters):', '1000')); + if (isNaN(lat) || isNaN(lon) || isNaN(radius)) { + alert('Invalid input'); + return; + } + fetch('/analytics/geofences', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, lat, lon, radius_m: radius }), + }) + .then(r => r.json()) + .then(() => refresh()); + } + + function deleteGeofence(id) { + if (!confirm('Delete this geofence zone?')) return; + fetch('/analytics/geofences/' + id, { method: 'DELETE' }) + .then(r => r.json()) + .then(() => refresh()); + } + + function exportData(mode) { + const m = mode || (document.getElementById('exportMode') || {}).value || 'adsb'; + const f = (document.getElementById('exportFormat') || {}).value || 'json'; + window.open('/analytics/export/' + encodeURIComponent(m) + '?format=' + encodeURIComponent(f), '_blank'); + } + + // Helpers + function _setText(id, val) { + const el = document.getElementById(id); + if (el) el.textContent = val; + } + + function _esc(s) { + if (typeof s !== 'string') s = String(s == null ? '' : s); + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + return { init, destroy, refresh, addGeofence, deleteGeofence, exportData }; +})(); diff --git a/templates/index.html b/templates/index.html index acd96e0..6a97043 100644 --- a/templates/index.html +++ b/templates/index.html @@ -52,6 +52,7 @@ + @@ -554,6 +555,8 @@ {% include 'partials/modes/tscm.html' %} + {% include 'partials/modes/analytics.html' %} + {% include 'partials/modes/ais.html' %} {% include 'partials/modes/spy-stations.html' %} @@ -3024,6 +3027,7 @@ +