diff --git a/app.py b/app.py index e6f5b05..db8145f 100644 --- a/app.py +++ b/app.py @@ -208,6 +208,11 @@ morse_process = None morse_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) morse_lock = threading.Lock() +# Meteor scatter detection +meteor_process = None +meteor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) +meteor_lock = threading.Lock() + # Deauth Attack Detection deauth_detector = None deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) @@ -696,6 +701,29 @@ def _get_subghz_active() -> bool: return False +def _get_singleton_running(module_path: str, getter_name: str, attr: str) -> bool: + """Safely check if a singleton-based mode is running without creating instances.""" + try: + import importlib + mod = importlib.import_module(module_path) + getter = getattr(mod, getter_name) + instance = getter() + if instance is None: + return False + return bool(getattr(instance, attr, False)) + except Exception: + return False + + +def _get_tscm_active() -> bool: + """Check if a TSCM sweep is running.""" + try: + from routes.tscm import _sweep_running + return bool(_sweep_running) + except Exception: + return False + + def _get_bluetooth_health() -> tuple[bool, int]: """Return Bluetooth active state and best-effort device count.""" legacy_running = bt_process is not None and (bt_process.poll() is None if bt_process else False) @@ -774,6 +802,15 @@ def health_check() -> Response: 'radiosonde': radiosonde_process is not None and (radiosonde_process.poll() is None if radiosonde_process else False), 'morse': morse_process is not None and (morse_process.poll() is None if morse_process else False), 'subghz': _get_subghz_active(), + 'rtlamr': rtlamr_process is not None and (rtlamr_process.poll() is None if rtlamr_process else False), + 'meshtastic': _get_singleton_running('utils.meshtastic', 'get_meshtastic_client', 'is_running'), + 'sstv': _get_singleton_running('utils.sstv', 'get_sstv_decoder', 'is_running'), + 'weathersat': _get_singleton_running('utils.weather_sat', 'get_weather_sat_decoder', 'is_running'), + 'wefax': _get_singleton_running('utils.wefax', 'get_wefax_decoder', 'is_running'), + 'sstv_general': _get_singleton_running('utils.sstv', 'get_general_sstv_decoder', 'is_running'), + 'tscm': _get_tscm_active(), + 'gps': _get_singleton_running('utils.gps', 'get_gps_reader', 'is_running'), + 'bt_locate': _get_singleton_running('utils.bt_locate', 'get_locate_session', 'is_active'), }, 'data': { 'aircraft_count': len(adsb_aircraft), @@ -978,6 +1015,13 @@ def _init_app() -> None: except ImportError: pass + # Initialize WebSocket for meteor scatter monitoring + try: + from routes.meteor_websocket import init_meteor_websocket + init_meteor_websocket(app) + except ImportError: + pass + # Defer heavy/network operations so the worker can serve requests immediately import threading diff --git a/routes/__init__.py b/routes/__init__.py index 61c1ba6..3f77052 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -16,6 +16,7 @@ def register_blueprints(app): from .gps import gps_bp from .listening_post import receiver_bp from .meshtastic import meshtastic_bp + from .meteor_websocket import meteor_bp from .morse import morse_bp from .offline import offline_bp from .pager import pager_bp @@ -76,6 +77,7 @@ def register_blueprints(app): app.register_blueprint(space_weather_bp) # Space weather monitoring app.register_blueprint(signalid_bp) # External signal ID enrichment app.register_blueprint(wefax_bp) # WeFax HF weather fax decoder + app.register_blueprint(meteor_bp) # Meteor scatter detection app.register_blueprint(morse_bp) # CW/Morse code decoder app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking app.register_blueprint(system_bp) # System health monitoring diff --git a/routes/meteor_websocket.py b/routes/meteor_websocket.py new file mode 100644 index 0000000..2f75466 --- /dev/null +++ b/routes/meteor_websocket.py @@ -0,0 +1,597 @@ +"""WebSocket-based meteor scatter monitoring with waterfall display and ping detection. + +Provides: +- WebSocket at /ws/meteor for binary waterfall frames (reuses waterfall_fft pipeline) +- SSE at /meteor/stream for detection events and stats +- REST endpoints for status, events, and export +""" + +from __future__ import annotations + +import json +import queue +import shutil +import socket +import subprocess +import threading +import time +from contextlib import suppress +from typing import Any + +from flask import Blueprint, Flask, Response, jsonify, request + +try: + from flask_sock import Sock + WEBSOCKET_AVAILABLE = True +except ImportError: + WEBSOCKET_AVAILABLE = False + Sock = None + +from utils.logging import get_logger +from utils.meteor_detector import MeteorDetector +from utils.process import register_process, safe_terminate, unregister_process +from utils.sdr import SDRFactory, SDRType +from utils.sdr.base import SDRCapabilities, SDRDevice +from utils.sse import sse_stream_fanout +from utils.validation import validate_device_index, validate_frequency, validate_gain +from utils.waterfall_fft import ( + build_binary_frame, + compute_power_spectrum, + cu8_to_complex, + quantize_to_uint8, +) + +logger = get_logger('intercept.meteor') + +# Module-level shared state +_state_lock = threading.Lock() +_state: dict[str, Any] = { + 'running': False, + 'device': None, + 'frequency_mhz': 0.0, + 'sample_rate': 0, +} +_detector: MeteorDetector | None = None +_sse_queue: queue.Queue = queue.Queue(maxsize=500) + +# Maximum bandwidth per SDR type (Hz) +MAX_BANDWIDTH = { + SDRType.RTL_SDR: 2400000, + SDRType.HACKRF: 20000000, + SDRType.LIME_SDR: 20000000, + SDRType.AIRSPY: 10000000, + SDRType.SDRPLAY: 2000000, +} + + +def _push_sse(data: dict[str, Any]) -> None: + """Push a message to the SSE queue, dropping oldest if full.""" + try: + _sse_queue.put_nowait(data) + except queue.Full: + try: + _sse_queue.get_nowait() + _sse_queue.put_nowait(data) + except (queue.Empty, queue.Full): + pass + + +def _resolve_sdr_type(sdr_type_str: str) -> SDRType: + mapping = { + 'rtlsdr': SDRType.RTL_SDR, + 'rtl_sdr': SDRType.RTL_SDR, + 'hackrf': SDRType.HACKRF, + 'limesdr': SDRType.LIME_SDR, + 'airspy': SDRType.AIRSPY, + 'sdrplay': SDRType.SDRPLAY, + } + return mapping.get(sdr_type_str.lower(), SDRType.RTL_SDR) + + +def _build_dummy_device(device_index: int, sdr_type: SDRType) -> SDRDevice: + builder = SDRFactory.get_builder(sdr_type) + caps = builder.get_capabilities() + return SDRDevice( + sdr_type=sdr_type, + index=device_index, + name=f'{sdr_type.value}-{device_index}', + serial='N/A', + driver=sdr_type.value, + capabilities=caps, + ) + + +def _pick_sample_rate(span_hz: int, caps: SDRCapabilities, sdr_type: SDRType) -> int: + valid_rates = sorted({int(r) for r in caps.sample_rates if int(r) > 0}) + if valid_rates: + return min(valid_rates, key=lambda rate: abs(rate - span_hz)) + max_bw = MAX_BANDWIDTH.get(sdr_type, 2400000) + return max(62500, min(span_hz, max_bw)) + + +# ── Blueprint for REST/SSE endpoints ── + +meteor_bp = Blueprint('meteor', __name__, url_prefix='/meteor') + + +@meteor_bp.route('/status') +def meteor_status(): + """Return current meteor monitoring status.""" + with _state_lock: + running = _state['running'] + freq = _state['frequency_mhz'] + device = _state['device'] + sr = _state['sample_rate'] + + detector = _detector + stats = None + if detector: + stats = detector._build_stats(time.time()) + + return jsonify({ + 'running': running, + 'frequency_mhz': freq, + 'device': device, + 'sample_rate': sr, + 'stats': stats, + }) + + +@meteor_bp.route('/stream') +def meteor_stream(): + """SSE endpoint for meteor detection events and stats.""" + response = Response( + sse_stream_fanout( + source_queue=_sse_queue, + channel_key='meteor', + timeout=1.0, + keepalive_interval=30.0, + ), + mimetype='text/event-stream', + ) + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + response.headers['Connection'] = 'keep-alive' + return response + + +@meteor_bp.route('/events') +def meteor_events(): + """Return detected events as JSON.""" + detector = _detector + if not detector: + return jsonify({'events': []}) + limit = request.args.get('limit', 500, type=int) + return jsonify({'events': detector.get_events(limit=limit)}) + + +@meteor_bp.route('/events/export') +def meteor_events_export(): + """Export events as CSV or JSON.""" + detector = _detector + if not detector: + return jsonify({'error': 'No active session'}), 400 + + fmt = request.args.get('format', 'json').lower() + if fmt == 'csv': + csv_data = detector.export_events_csv() + return Response( + csv_data, + mimetype='text/csv', + headers={'Content-Disposition': 'attachment; filename=meteor_events.csv'}, + ) + else: + json_data = detector.export_events_json() + return Response( + json_data, + mimetype='application/json', + headers={'Content-Disposition': 'attachment; filename=meteor_events.json'}, + ) + + +@meteor_bp.route('/events/clear', methods=['POST']) +def meteor_events_clear(): + """Clear all detected events.""" + detector = _detector + if not detector: + return jsonify({'cleared': 0}) + count = detector.clear_events() + return jsonify({'cleared': count}) + + +# ── WebSocket handler ── + +def init_meteor_websocket(app: Flask): + """Initialize WebSocket meteor scatter streaming.""" + global _detector + + if not WEBSOCKET_AVAILABLE: + logger.warning("flask-sock not installed, WebSocket meteor disabled") + return + + sock = Sock(app) + + @sock.route('/ws/meteor') + def meteor_stream_ws(ws): + """WebSocket endpoint for meteor scatter waterfall + detection.""" + global _detector + logger.info("WebSocket meteor client connected") + + import app as app_module + + iq_process = None + reader_thread = None + stop_event = threading.Event() + claimed_device = None + claimed_sdr_type = 'rtlsdr' + send_queue: queue.Queue = queue.Queue(maxsize=120) + + try: + while True: + # Drain send queue + while True: + try: + outgoing = send_queue.get_nowait() + except queue.Empty: + break + try: + ws.send(outgoing) + except Exception: + stop_event.set() + break + + try: + msg = ws.receive(timeout=0.01) + except Exception as e: + err = str(e).lower() + if "closed" in err: + break + if "timed out" not in err: + logger.error(f"WebSocket receive error: {e}") + continue + + if msg is None: + if not ws.connected: + break + if stop_event.is_set(): + break + continue + + try: + data = json.loads(msg) + except (json.JSONDecodeError, TypeError): + continue + + cmd = data.get('cmd') + + if cmd == 'start': + # Stop any existing capture + was_restarting = iq_process is not None + stop_event.set() + if reader_thread and reader_thread.is_alive(): + reader_thread.join(timeout=2) + if iq_process: + safe_terminate(iq_process) + unregister_process(iq_process) + iq_process = None + if claimed_device is not None: + app_module.release_sdr_device(claimed_device, claimed_sdr_type) + claimed_device = None + with _state_lock: + _state['running'] = False + stop_event.clear() + while not send_queue.empty(): + try: + send_queue.get_nowait() + except queue.Empty: + break + if was_restarting: + time.sleep(0.5) + + # Parse config + try: + frequency_mhz = float(data.get('frequency_mhz', 143.05)) + validate_frequency(frequency_mhz) + gain_raw = data.get('gain') + if gain_raw is None or str(gain_raw).lower() == 'auto': + gain = None + else: + gain = validate_gain(float(gain_raw)) + device_index = validate_device_index(int(data.get('device', 0))) + sdr_type_str = data.get('sdr_type', 'rtlsdr') + sample_rate_req = int(data.get('sample_rate', 250000)) + fft_size = int(data.get('fft_size', 1024)) + fps = int(data.get('fps', 20)) + avg_count = int(data.get('avg_count', 4)) + ppm = data.get('ppm') + if ppm is not None: + ppm = int(ppm) + bias_t = bool(data.get('bias_t', False)) + + # Detection settings + snr_threshold = float(data.get('snr_threshold', 6.0)) + min_duration = float(data.get('min_duration_ms', 50.0)) + cooldown = float(data.get('cooldown_ms', 200.0)) + freq_drift = float(data.get('freq_drift_tolerance_hz', 500.0)) + except (TypeError, ValueError) as exc: + ws.send(json.dumps({ + 'status': 'error', + 'message': f'Invalid configuration: {exc}', + })) + continue + + # Clamp values + fft_size = max(256, min(4096, fft_size)) + fps = max(5, min(30, fps)) + avg_count = max(1, min(16, avg_count)) + + # Resolve SDR type and sample rate + sdr_type = _resolve_sdr_type(sdr_type_str) + builder = SDRFactory.get_builder(sdr_type) + caps = builder.get_capabilities() + sample_rate = _pick_sample_rate(sample_rate_req, caps, sdr_type) + + # Compute frequency range + span_mhz = sample_rate / 1e6 + start_freq = frequency_mhz - span_mhz / 2 + end_freq = frequency_mhz + span_mhz / 2 + + # Claim SDR device + max_claim_attempts = 4 if was_restarting else 1 + claim_err = None + for _attempt in range(max_claim_attempts): + claim_err = app_module.claim_sdr_device(device_index, 'meteor', sdr_type_str) + if not claim_err: + break + if _attempt < max_claim_attempts - 1: + time.sleep(0.4) + if claim_err: + ws.send(json.dumps({ + 'status': 'error', + 'message': claim_err, + 'error_type': 'DEVICE_BUSY', + })) + continue + claimed_device = device_index + claimed_sdr_type = sdr_type_str + + # Build I/Q capture command + try: + device = _build_dummy_device(device_index, sdr_type) + iq_cmd = builder.build_iq_capture_command( + device=device, + frequency_mhz=frequency_mhz, + sample_rate=sample_rate, + gain=gain, + ppm=ppm, + bias_t=bias_t, + ) + except NotImplementedError as e: + app_module.release_sdr_device(device_index, sdr_type_str) + claimed_device = None + ws.send(json.dumps({'status': 'error', 'message': str(e)})) + continue + + # Check binary exists + if not shutil.which(iq_cmd[0]): + app_module.release_sdr_device(device_index, sdr_type_str) + claimed_device = None + ws.send(json.dumps({ + 'status': 'error', + 'message': f'Required tool "{iq_cmd[0]}" not found.', + })) + continue + + # Spawn I/Q capture + max_attempts = 3 if was_restarting else 1 + try: + for attempt in range(max_attempts): + logger.info( + f"Starting meteor I/Q capture: {frequency_mhz:.6f} MHz, " + f"sr={sample_rate}, fft={fft_size}" + ) + iq_process = subprocess.Popen( + iq_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + register_process(iq_process) + + time.sleep(0.3) + if iq_process.poll() is not None: + stderr_out = '' + if iq_process.stderr: + with suppress(Exception): + stderr_out = iq_process.stderr.read().decode('utf-8', errors='replace').strip() + unregister_process(iq_process) + iq_process = None + if attempt < max_attempts - 1: + time.sleep(0.5) + continue + detail = f": {stderr_out}" if stderr_out else "" + raise RuntimeError(f"I/Q process exited immediately{detail}") + break + except Exception as e: + logger.error(f"Failed to start meteor I/Q capture: {e}") + if iq_process: + safe_terminate(iq_process) + unregister_process(iq_process) + iq_process = None + app_module.release_sdr_device(device_index, sdr_type_str) + claimed_device = None + ws.send(json.dumps({ + 'status': 'error', + 'message': f'Failed to start I/Q capture: {e}', + })) + continue + + # Initialize detector + _detector = MeteorDetector( + snr_threshold_db=snr_threshold, + min_duration_ms=min_duration, + cooldown_ms=cooldown, + freq_drift_tolerance_hz=freq_drift, + ) + + with _state_lock: + _state['running'] = True + _state['device'] = device_index + _state['frequency_mhz'] = frequency_mhz + _state['sample_rate'] = sample_rate + + # Send confirmation + ws.send(json.dumps({ + 'status': 'started', + 'frequency_mhz': frequency_mhz, + 'start_freq': start_freq, + 'end_freq': end_freq, + 'fft_size': fft_size, + 'sample_rate': sample_rate, + 'span_mhz': span_mhz, + })) + + # Start FFT reader + detection thread + def fft_reader( + proc, _send_q, stop_evt, detector, + _fft_size, _avg_count, _fps, _sample_rate, + _start_freq, _end_freq, _freq_mhz, + ): + required_fft_samples = _fft_size * _avg_count + timeslice_samples = max(required_fft_samples, int(_sample_rate / max(1, _fps))) + bytes_per_frame = timeslice_samples * 2 + frame_interval = 1.0 / _fps + start_freq_hz = _start_freq * 1e6 + end_freq_hz = _end_freq * 1e6 + last_stats_push = 0.0 + + try: + while not stop_evt.is_set(): + if proc.poll() is not None: + break + + frame_start = time.monotonic() + + # Read raw I/Q + raw = b'' + remaining = bytes_per_frame + while remaining > 0 and not stop_evt.is_set(): + chunk = proc.stdout.read(min(remaining, 65536)) + if not chunk: + break + raw += chunk + remaining -= len(chunk) + + if len(raw) < _fft_size * 2: + break + + # FFT pipeline + samples = cu8_to_complex(raw) + fft_samples = samples[-required_fft_samples:] if len(samples) > required_fft_samples else samples + power_db = compute_power_spectrum( + fft_samples, + fft_size=_fft_size, + avg_count=_avg_count, + ) + quantized = quantize_to_uint8(power_db) + frame = build_binary_frame(_start_freq, _end_freq, quantized) + + # Send waterfall frame via WS + with suppress(queue.Full): + _send_q.put_nowait(frame) + + # Run detection on raw dB spectrum + now = time.time() + stats, event = detector.process_frame( + power_db, start_freq_hz, end_freq_hz, now, + ) + + # Push event immediately via SSE + if event: + _push_sse({ + 'type': 'event', + 'event': event.to_dict(), + }) + # Also send as JSON via WS for immediate UI update + event_msg = json.dumps({ + 'type': 'detection', + 'event': event.to_dict(), + }) + with suppress(queue.Full): + _send_q.put_nowait(event_msg) + + # Push stats every ~1s via SSE + if now - last_stats_push >= 1.0: + _push_sse(stats) + last_stats_push = now + + # Pace to target FPS + elapsed = time.monotonic() - frame_start + sleep_time = frame_interval - elapsed + if sleep_time > 0: + stop_evt.wait(sleep_time) + + except Exception as e: + logger.debug(f"Meteor FFT reader stopped: {e}") + + reader_thread = threading.Thread( + target=fft_reader, + args=( + iq_process, send_queue, stop_event, _detector, + fft_size, avg_count, fps, sample_rate, + start_freq, end_freq, frequency_mhz, + ), + daemon=True, + ) + reader_thread.start() + + elif cmd == 'update_threshold': + detector = _detector + if detector: + detector.update_settings( + snr_threshold_db=data.get('snr_threshold'), + min_duration_ms=data.get('min_duration_ms'), + cooldown_ms=data.get('cooldown_ms'), + freq_drift_tolerance_hz=data.get('freq_drift_tolerance_hz'), + ) + ws.send(json.dumps({'status': 'threshold_updated'})) + + elif cmd == 'stop': + stop_event.set() + if reader_thread and reader_thread.is_alive(): + reader_thread.join(timeout=2) + reader_thread = None + if iq_process: + safe_terminate(iq_process) + unregister_process(iq_process) + iq_process = None + if claimed_device is not None: + app_module.release_sdr_device(claimed_device, claimed_sdr_type) + claimed_device = None + with _state_lock: + _state['running'] = False + _state['device'] = None + stop_event.clear() + ws.send(json.dumps({'status': 'stopped'})) + + except Exception as e: + logger.info(f"WebSocket meteor closed: {e}") + finally: + stop_event.set() + if reader_thread and reader_thread.is_alive(): + reader_thread.join(timeout=2) + if iq_process: + safe_terminate(iq_process) + unregister_process(iq_process) + if claimed_device is not None: + app_module.release_sdr_device(claimed_device, claimed_sdr_type) + with _state_lock: + _state['running'] = False + _state['device'] = None + with suppress(Exception): + ws.close() + with suppress(Exception): + ws.sock.shutdown(socket.SHUT_RDWR) + with suppress(Exception): + ws.sock.close() + logger.info("WebSocket meteor client disconnected") diff --git a/static/css/modes/meteor.css b/static/css/modes/meteor.css new file mode 100644 index 0000000..67f9e02 --- /dev/null +++ b/static/css/modes/meteor.css @@ -0,0 +1,344 @@ +/* Meteor Scatter Mode Styles */ + +.meteor-visuals-container { + --ms-border: rgba(92, 255, 170, 0.24); + --ms-surface: linear-gradient(180deg, rgba(12, 19, 31, 0.97) 0%, rgba(5, 9, 17, 0.98) 100%); + --ms-accent: #6bffb8; + --ms-accent-dim: rgba(107, 255, 184, 0.13); + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; + background: radial-gradient(circle at 14% -18%, rgba(107, 255, 184, 0.15) 0%, rgba(107, 255, 184, 0) 38%), + radial-gradient(circle at 86% -26%, rgba(255, 200, 54, 0.12) 0%, rgba(255, 200, 54, 0) 36%), + #03070f; + border: 1px solid var(--ms-border); + border-radius: 10px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03), 0 10px 34px rgba(2, 8, 22, 0.55); + position: relative; +} + +/* ── Headline Bar ── */ + +.ms-headline { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: rgba(8, 14, 25, 0.86); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; +} + +.ms-headline-left, +.ms-headline-right { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.ms-headline-tag { + border-radius: 999px; + padding: 1px 8px; + border: 1px solid rgba(107, 255, 184, 0.45); + background: var(--ms-accent-dim); + color: var(--ms-accent); + font-size: 10px; + font-family: var(--font-mono, monospace); + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; +} + +.ms-headline-tag.idle { + border-color: rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.06); + color: var(--text-dim, #667); +} + +.ms-headline-tag.detecting { + border-color: rgba(255, 215, 0, 0.5); + background: rgba(255, 215, 0, 0.12); + color: #ffd700; + animation: ms-pulse 1s ease-in-out infinite; +} + +.ms-headline-sub { + font-size: 11px; + color: var(--text-dim); + font-family: var(--font-mono, monospace); + white-space: nowrap; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +/* ── Stats Strip ── */ + +.ms-stats-strip { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 8px; + padding: 8px 12px; + background: var(--ms-surface); + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + flex-shrink: 0; +} + +.ms-stat-cell { + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 8px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 6px; +} + +.ms-stat-label { + font-size: 9px; + color: var(--text-dim, #667); + font-family: var(--font-mono, monospace); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.ms-stat-value { + font-size: 13px; + color: var(--text-primary, #eee); + font-family: var(--font-mono, monospace); + font-weight: 600; +} + +.ms-stat-value.highlight { + color: var(--ms-accent); +} + +/* ── Canvas Areas ── */ + +.ms-spectrum-wrap { + position: relative; + height: 80px; + flex-shrink: 0; + background: rgba(0, 0, 0, 0.3); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.ms-spectrum-wrap canvas { + width: 100%; + height: 100%; + display: block; +} + +.ms-waterfall-wrap { + position: relative; + flex: 1; + min-height: 200px; + background: #000; + overflow: hidden; +} + +.ms-waterfall-wrap canvas { + width: 100%; + height: 100%; + display: block; +} + +.ms-timeline-wrap { + position: relative; + height: 60px; + flex-shrink: 0; + background: rgba(0, 0, 0, 0.3); + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.ms-timeline-wrap canvas { + width: 100%; + height: 100%; + display: block; +} + +/* ── Events Panel ── */ + +.ms-events-panel { + flex-shrink: 0; + max-height: 200px; + overflow: hidden; + display: flex; + flex-direction: column; + background: rgba(8, 14, 25, 0.9); + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.ms-events-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.03); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.ms-events-title { + font-size: 10px; + color: var(--text-dim, #667); + font-family: var(--font-mono, monospace); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.ms-events-count { + font-size: 10px; + color: var(--ms-accent); + font-family: var(--font-mono, monospace); +} + +.ms-events-scroll { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +.ms-events-table { + width: 100%; + border-collapse: collapse; + font-family: var(--font-mono, monospace); + font-size: 10px; +} + +.ms-events-table thead { + position: sticky; + top: 0; + z-index: 1; +} + +.ms-events-table th { + padding: 4px 8px; + text-align: left; + color: var(--text-dim, #667); + font-weight: 500; + background: rgba(8, 14, 25, 0.95); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 9px; +} + +.ms-events-table td { + padding: 3px 8px; + color: var(--text-secondary, #aab); + border-bottom: 1px solid rgba(255, 255, 255, 0.03); +} + +.ms-events-table tr:hover td { + background: rgba(107, 255, 184, 0.04); +} + +.ms-events-table .ms-snr-strong { + color: var(--ms-accent); + font-weight: 600; +} + +.ms-events-table .ms-snr-moderate { + color: #ffd782; +} + +.ms-events-table .ms-snr-weak { + color: var(--text-dim, #667); +} + +.ms-tag { + display: inline-block; + padding: 0 4px; + border-radius: 3px; + font-size: 9px; + margin-right: 3px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.04); + color: var(--text-dim, #667); +} + +.ms-tag.strong { + border-color: rgba(107, 255, 184, 0.3); + background: rgba(107, 255, 184, 0.08); + color: var(--ms-accent); +} + +.ms-tag.moderate { + border-color: rgba(255, 215, 130, 0.3); + background: rgba(255, 215, 130, 0.08); + color: #ffd782; +} + +/* ── Ping Highlight Animation ── */ + +@keyframes ms-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +@keyframes ms-ping-flash { + 0% { + box-shadow: inset 0 0 20px rgba(107, 255, 184, 0.3); + } + 100% { + box-shadow: inset 0 0 0 rgba(107, 255, 184, 0); + } +} + +.ms-ping-flash { + animation: ms-ping-flash 0.5s ease-out; +} + +/* ── Empty State ── */ + +.ms-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + gap: 12px; + color: var(--text-dim, #667); + font-family: var(--font-mono, monospace); + font-size: 12px; + text-align: center; + padding: 40px; +} + +.ms-empty-state .ms-empty-icon { + font-size: 40px; + opacity: 0.4; +} + +.ms-empty-state .ms-empty-text { + font-size: 11px; + opacity: 0.6; + max-width: 280px; + line-height: 1.5; +} + +/* ── Responsive ── */ + +@media (max-width: 900px) { + .ms-stats-strip { + grid-template-columns: repeat(3, 1fr); + } + .ms-events-panel { + max-height: 150px; + } +} + +@media (max-width: 600px) { + .ms-stats-strip { + grid-template-columns: repeat(2, 1fr); + } + .ms-spectrum-wrap { + height: 60px; + } + .ms-timeline-wrap { + height: 40px; + } +} diff --git a/static/js/core/cheat-sheets.js b/static/js/core/cheat-sheets.js index 9bfc56a..f223079 100644 --- a/static/js/core/cheat-sheets.js +++ b/static/js/core/cheat-sheets.js @@ -16,14 +16,17 @@ const CheatSheets = (function () { sstv: { title: 'ISS SSTV', icon: '🖼️', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead — check pass times'] }, weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1–137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] }, sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] }, - gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] }, + gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] }, spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] }, - tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] }, - spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Spectrum Waterfall to tune directly', 'STANAG and HF mil signals are common'] }, + tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] }, + spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Spectrum Waterfall to tune directly', 'STANAG and HF mil signals are common'] }, websdr: { title: 'WebSDR', icon: '🌐', hardware: 'None (uses remote SDR servers)', description: 'Access remote WebSDR receivers worldwide for HF shortwave listening.', whatToExpect: 'Live audio from global HF receivers, waterfall display.', tips: ['websdr.org lists available servers', 'Good for HF when local antenna is lacking', 'Use in-app player for seamless experience'] }, subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] }, rtlamr: { title: 'Utility Meter Reader', icon: '⚡', hardware: 'RTL-SDR dongle', description: 'Reads AMI/AMR smart utility meter broadcasts via rtlamr.', whatToExpect: 'Meter IDs, consumption readings, interval data.', tips: ['Most meters broadcast on 915 MHz', 'MSG types 5, 7, 13, 21 most common', 'Consumption data is read-only public broadcast'] }, - waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] }, + waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] }, + radiosonde: { title: 'Radiosonde Tracker', icon: '🎈', hardware: 'RTL-SDR dongle', description: 'Tracks weather balloons via radiosonde telemetry using radiosonde_auto_rx.', whatToExpect: 'Position, altitude, temperature, humidity, pressure from active sondes.', tips: ['Sondes transmit on 400–406 MHz', 'Set your region to narrow the scan range', 'Gain 40 dB is a good starting point'] }, + morse: { title: 'CW/Morse Decoder', icon: '📡', hardware: 'RTL-SDR + HF antenna (or upconverter)', description: 'Decodes CW Morse code via Goertzel tone detection or OOK envelope detection.', whatToExpect: 'Decoded Morse characters, WPM estimate, signal level.', tips: ['CW Tone mode for HF amateur bands (e.g. 7.030, 14.060 MHz)', 'OOK Envelope mode for ISM/UHF signals', 'Use band presets for quick tuning to CW sub-bands'] }, + meteor: { title: 'Meteor Scatter', icon: '☄️', hardware: 'RTL-SDR + VHF antenna (143 MHz)', description: 'Monitors VHF beacon reflections from meteor ionization trails.', whatToExpect: 'Waterfall display with transient ping detections and event logging.', tips: ['GRAVES radar at 143.050 MHz is the primary target', 'Use a Yagi pointed south (from Europe) for best results', 'Peak activity during annual meteor showers (Perseids, Geminids)'] }, }; function show(mode) { diff --git a/static/js/core/run-state.js b/static/js/core/run-state.js index c481934..9671b0b 100644 --- a/static/js/core/run-state.js +++ b/static/js/core/run-state.js @@ -2,12 +2,13 @@ const RunState = (function() { 'use strict'; const REFRESH_MS = 5000; - const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'subghz']; + const CHIP_MODES = ['pager', 'sensor', 'wifi', 'bluetooth', 'adsb', 'ais', 'acars', 'vdl2', 'aprs', 'dsc', 'subghz', 'radiosonde', 'morse', 'rtlamr', 'meshtastic', 'sstv', 'weathersat', 'wefax', 'sstv_general', 'tscm', 'gps', 'bt_locate', 'meteor']; const MODE_ALIASES = { bt: 'bluetooth', - bt_locate: 'bluetooth', btlocate: 'bluetooth', aircraft: 'adsb', + sonde: 'radiosonde', + weather_sat: 'weathersat', }; const modeLabels = { @@ -22,6 +23,18 @@ const RunState = (function() { aprs: 'APRS', dsc: 'DSC', subghz: 'SubGHz', + radiosonde: 'Sonde', + morse: 'Morse', + rtlamr: 'Meter', + meshtastic: 'Mesh', + sstv: 'SSTV', + weathersat: 'WxSat', + wefax: 'WeFax', + sstv_general: 'HF SSTV', + tscm: 'TSCM', + gps: 'GPS', + bt_locate: 'BT Loc', + meteor: 'Meteor', }; let refreshTimer = null; @@ -181,6 +194,17 @@ const RunState = (function() { if (normalized.includes('aprs')) return 'aprs'; if (normalized.includes('dsc')) return 'dsc'; if (normalized.includes('subghz')) return 'subghz'; + if (normalized.includes('radiosonde') || normalized.includes('sonde')) return 'radiosonde'; + if (normalized.includes('morse')) return 'morse'; + if (normalized.includes('meter') || normalized.includes('rtlamr')) return 'rtlamr'; + if (normalized.includes('meshtastic') || normalized.includes('mesh')) return 'meshtastic'; + if (normalized.includes('hf sstv') || normalized.includes('sstv general')) return 'sstv_general'; + if (normalized.includes('sstv')) return 'sstv'; + if (normalized.includes('weather') && normalized.includes('sat')) return 'weathersat'; + if (normalized.includes('wefax') || normalized.includes('weather fax')) return 'wefax'; + if (normalized.includes('tscm')) return 'tscm'; + if (normalized.includes('gps')) return 'gps'; + if (normalized.includes('bt loc')) return 'bt_locate'; if (normalized.includes('433')) return 'sensor'; return 'pager'; } @@ -196,9 +220,7 @@ const RunState = (function() { processes.bluetooth = Boolean( processes.bluetooth || processes.bt || - processes.bt_scan || - processes.btlocate || - processes.bt_locate + processes.bt_scan ); processes.wifi = Boolean( processes.wifi || diff --git a/static/js/modes/meteor.js b/static/js/modes/meteor.js new file mode 100644 index 0000000..ed39b6a --- /dev/null +++ b/static/js/modes/meteor.js @@ -0,0 +1,554 @@ +/** + * Meteor Scatter Monitor — IIFE module + * + * WebSocket for binary waterfall frames, SSE for detection events/stats. + * Renders spectrum, waterfall, timeline, and an event table. + */ +const MeteorScatter = (function () { + 'use strict'; + + // ── State ── + let _active = false; + let _running = false; + let _ws = null; + let _sse = null; + + // Canvas refs + let _specCanvas = null, _specCtx = null; + let _wfCanvas = null, _wfCtx = null; + let _tlCanvas = null, _tlCtx = null; + + // Data + let _events = []; + let _stats = {}; + let _timelineBins = new Array(60).fill(0); // pings per minute, last 60 min + let _timelineBinStart = 0; + + // Config (read from sidebar controls) + let _startFreqMhz = 0; + let _endFreqMhz = 0; + let _fftSize = 1024; + + // Colour LUT (turbo palette) + const _lut = _buildTurboLUT(); + + // ── Public API ── + + function init() { + _active = true; + _specCanvas = document.getElementById('meteorSpectrumCanvas'); + _wfCanvas = document.getElementById('meteorWaterfallCanvas'); + _tlCanvas = document.getElementById('meteorTimelineCanvas'); + + if (_specCanvas) _specCtx = _specCanvas.getContext('2d'); + if (_wfCanvas) _wfCtx = _wfCanvas.getContext('2d'); + if (_tlCanvas) _tlCtx = _tlCanvas.getContext('2d'); + + _resizeCanvases(); + window.addEventListener('resize', _resizeCanvases); + + // Wire up start/stop buttons + const startBtn = document.getElementById('meteorStartBtn'); + const stopBtn = document.getElementById('meteorStopBtn'); + if (startBtn) startBtn.addEventListener('click', start); + if (stopBtn) stopBtn.addEventListener('click', stop); + + _renderEmptyState(); + } + + function destroy() { + _active = false; + stop(); + window.removeEventListener('resize', _resizeCanvases); + _specCanvas = _wfCanvas = _tlCanvas = null; + _specCtx = _wfCtx = _tlCtx = null; + } + + function start() { + if (_running) stop(); + + const freq = parseFloat(document.getElementById('meteorFrequency')?.value) || 143.05; + const gain = parseFloat(document.getElementById('meteorGain')?.value) || 0; + const sampleRate = parseInt(document.getElementById('meteorSampleRate')?.value) || 1024000; + const fftSize = parseInt(document.getElementById('meteorFFTSize')?.value) || 1024; + const fps = parseInt(document.getElementById('meteorFPS')?.value) || 20; + const snrThreshold = parseFloat(document.getElementById('meteorSNRThreshold')?.value) || 6; + const minDuration = parseFloat(document.getElementById('meteorMinDuration')?.value) || 50; + const cooldown = parseFloat(document.getElementById('meteorCooldown')?.value) || 200; + const freqDrift = parseFloat(document.getElementById('meteorFreqDrift')?.value) || 500; + + // Read from shared SDR device panel + const device = parseInt(document.getElementById('deviceSelect')?.value || '0', 10); + const sdrType = document.getElementById('sdrTypeSelect')?.value || 'rtlsdr'; + const biasT = (typeof getBiasTEnabled === 'function') ? getBiasTEnabled() : false; + + // Check device availability before starting + if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('meteor')) { + return; + } + + _fftSize = fftSize; + _events = []; + _stats = {}; + + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${proto}//${location.host}/ws/meteor`; + + try { + _ws = new WebSocket(wsUrl); + _ws.binaryType = 'arraybuffer'; + } catch (e) { + console.error('Meteor WS connect failed:', e); + return; + } + + _ws.onopen = function () { + _running = true; + _updateUI(); + _ws.send(JSON.stringify({ + cmd: 'start', + frequency_mhz: freq, + gain: gain === 0 ? 'auto' : gain, + sample_rate: sampleRate, + fft_size: fftSize, + fps: fps, + device: device, + sdr_type: sdrType, + bias_t: biasT, + snr_threshold: snrThreshold, + min_duration_ms: minDuration, + cooldown_ms: cooldown, + freq_drift_tolerance_hz: freqDrift, + })); + + // Reserve device in shared tracking + if (typeof reserveDevice === 'function') { + reserveDevice(device, 'meteor', sdrType); + } + }; + + _ws.onmessage = function (evt) { + if (evt.data instanceof ArrayBuffer) { + _onBinaryFrame(evt.data); + } else { + try { + const msg = JSON.parse(evt.data); + _onJsonMessage(msg); + } catch (e) { /* ignore */ } + } + }; + + _ws.onclose = function () { + _running = false; + if (typeof releaseDevice === 'function') releaseDevice('meteor'); + _updateUI(); + }; + + _ws.onerror = function () { + _running = false; + if (typeof releaseDevice === 'function') releaseDevice('meteor'); + _updateUI(); + }; + + // Start SSE for events/stats + _startSSE(); + } + + function stop() { + if (_ws && _ws.readyState === WebSocket.OPEN) { + try { _ws.send(JSON.stringify({ cmd: 'stop' })); } catch (e) { /* */ } + } + if (_ws) { + try { _ws.close(); } catch (e) { /* */ } + _ws = null; + } + _stopSSE(); + _running = false; + if (typeof releaseDevice === 'function') releaseDevice('meteor'); + _updateUI(); + } + + function exportCSV() { + _downloadExport('csv'); + } + + function exportJSON() { + _downloadExport('json'); + } + + function clearEvents() { + fetch('/meteor/events/clear', { method: 'POST' }) + .then(r => r.json()) + .then(() => { + _events = []; + _renderEvents(); + }) + .catch(e => console.error('Clear events failed:', e)); + } + + // ── SSE ── + + function _startSSE() { + _stopSSE(); + _sse = new EventSource('/meteor/stream'); + _sse.onmessage = function (evt) { + try { + const data = JSON.parse(evt.data); + if (data.type === 'event') { + _events.unshift(data.event); + if (_events.length > 500) _events.length = 500; + _renderEvents(); + _addToTimeline(data.event); + _flashPing(); + } else if (data.type === 'stats') { + _stats = data; + _renderStats(); + } + } catch (e) { /* ignore */ } + }; + } + + function _stopSSE() { + if (_sse) { + _sse.close(); + _sse = null; + } + } + + // ── Binary Frame Handling ── + + function _parseFrame(buf) { + if (!buf || buf.byteLength < 11) return null; + const view = new DataView(buf); + if (view.getUint8(0) !== 0x01) return null; + const startMhz = view.getFloat32(1, true); + const endMhz = view.getFloat32(5, true); + const numBins = view.getUint16(9, true); + if (buf.byteLength < 11 + numBins) return null; + const bins = new Uint8Array(buf, 11, numBins); + return { numBins, bins, startMhz, endMhz }; + } + + function _onBinaryFrame(buf) { + const frame = _parseFrame(buf); + if (!frame) return; + + _startFreqMhz = frame.startMhz; + _endFreqMhz = frame.endMhz; + + _drawSpectrum(frame.bins); + _scrollWaterfall(frame.bins); + } + + function _onJsonMessage(msg) { + if (msg.status === 'started') { + _startFreqMhz = msg.start_freq || 0; + _endFreqMhz = msg.end_freq || 0; + _fftSize = msg.fft_size || _fftSize; + _running = true; + _hideEmptyState(); + _updateUI(); + } else if (msg.status === 'stopped') { + _running = false; + _updateUI(); + } else if (msg.status === 'error') { + console.error('Meteor error:', msg.message); + _running = false; + _updateUI(); + } else if (msg.type === 'detection') { + // Inline detection via WS — handled by SSE primarily + } + } + + // ── Canvas Drawing ── + + function _resizeCanvases() { + [_specCanvas, _wfCanvas, _tlCanvas].forEach(function (c) { + if (!c) return; + const rect = c.parentElement.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + c.width = Math.round(rect.width * dpr); + c.height = Math.round(rect.height * dpr); + }); + } + + function _drawSpectrum(bins) { + const ctx = _specCtx; + const canvas = _specCanvas; + if (!ctx || !canvas) return; + + const w = canvas.width; + const h = canvas.height; + ctx.clearRect(0, 0, w, h); + + // Background + ctx.fillStyle = 'rgba(3, 7, 15, 0.9)'; + ctx.fillRect(0, 0, w, h); + + // Draw noise floor line + const nf = _stats.current_noise_floor; + if (nf !== undefined) { + const nfY = h - ((nf + 100) / 100) * h; // rough mapping + ctx.strokeStyle = 'rgba(255, 100, 100, 0.3)'; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(0, nfY); + ctx.lineTo(w, nfY); + ctx.stroke(); + ctx.setLineDash([]); + } + + // Draw spectrum line + const n = bins.length; + if (n === 0) return; + const xStep = w / n; + + ctx.strokeStyle = 'rgba(107, 255, 184, 0.8)'; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let i = 0; i < n; i++) { + const x = i * xStep; + const y = h - (bins[i] / 255) * h; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Fill under curve + ctx.lineTo(w, h); + ctx.lineTo(0, h); + ctx.closePath(); + ctx.fillStyle = 'rgba(107, 255, 184, 0.08)'; + ctx.fill(); + } + + function _scrollWaterfall(bins) { + const ctx = _wfCtx; + const canvas = _wfCanvas; + if (!ctx || !canvas) return; + + const w = canvas.width; + const h = canvas.height; + + // Scroll existing content down by 1 pixel + const existing = ctx.getImageData(0, 0, w, h - 1); + ctx.putImageData(existing, 0, 1); + + // Draw new top row + const row = ctx.createImageData(w, 1); + const data = row.data; + const n = bins.length; + + for (let x = 0; x < w; x++) { + const binIdx = Math.floor((x / w) * n); + const val = Math.min(255, Math.max(0, bins[binIdx] || 0)); + const lutOff = val * 3; + const px = x * 4; + data[px] = _lut[lutOff]; + data[px + 1] = _lut[lutOff + 1]; + data[px + 2] = _lut[lutOff + 2]; + data[px + 3] = 255; + } + ctx.putImageData(row, 0, 0); + } + + function _drawTimeline() { + const ctx = _tlCtx; + const canvas = _tlCanvas; + if (!ctx || !canvas) return; + + const w = canvas.width; + const h = canvas.height; + ctx.clearRect(0, 0, w, h); + + ctx.fillStyle = 'rgba(3, 7, 15, 0.9)'; + ctx.fillRect(0, 0, w, h); + + const bins = _timelineBins; + const maxVal = Math.max(1, ...bins); + const barWidth = w / bins.length; + const padding = 4; + + for (let i = 0; i < bins.length; i++) { + const val = bins[i]; + if (val === 0) continue; + const barH = ((val / maxVal) * (h - padding * 2)); + const x = i * barWidth + 1; + const y = h - padding - barH; + + ctx.fillStyle = val > maxVal * 0.7 + ? 'rgba(107, 255, 184, 0.8)' + : val > maxVal * 0.3 + ? 'rgba(107, 255, 184, 0.5)' + : 'rgba(107, 255, 184, 0.25)'; + ctx.fillRect(x, y, Math.max(1, barWidth - 2), barH); + } + + // Label + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.font = '9px monospace'; + ctx.fillText('PINGS/MIN (60 MIN)', 8, 12); + } + + // ── Timeline Binning ── + + function _addToTimeline(event) { + const now = Math.floor(Date.now() / 60000); // current minute + if (_timelineBinStart === 0) _timelineBinStart = now - 59; + + const binIdx = now - _timelineBinStart; + if (binIdx >= _timelineBins.length) { + // Shift bins + const shift = binIdx - _timelineBins.length + 1; + _timelineBins = _timelineBins.slice(shift).concat(new Array(shift).fill(0)); + _timelineBinStart += shift; + } + const idx = now - _timelineBinStart; + if (idx >= 0 && idx < _timelineBins.length) { + _timelineBins[idx]++; + } + _drawTimeline(); + } + + // ── UI Rendering ── + + function _renderStats() { + _setText('meteorStatPingsTotal', _stats.pings_total || 0); + _setText('meteorStatPings10min', _stats.pings_last_10min || 0); + _setText('meteorStatStrongest', (_stats.strongest_snr || 0).toFixed(1) + ' dB'); + _setText('meteorStatNoiseFloor', (_stats.current_noise_floor || -100).toFixed(1) + ' dB'); + _setText('meteorStatUptime', _formatUptime(_stats.uptime_s || 0)); + + const stateTag = document.getElementById('meteorStateTag'); + if (stateTag) { + const state = _stats.state || 'idle'; + stateTag.textContent = state.toUpperCase(); + stateTag.className = 'ms-headline-tag ' + state; + } + } + + function _renderEvents() { + const tbody = document.getElementById('meteorEventsBody'); + if (!tbody) return; + + const countEl = document.getElementById('meteorEventsCount'); + if (countEl) countEl.textContent = _events.length + ' events'; + + // Only show last 100 in DOM for performance + const display = _events.slice(0, 100); + let html = ''; + for (const e of display) { + const ts = new Date(e.start_ts * 1000); + const timeStr = ts.toLocaleTimeString('en-GB', { hour12: false }); + const snrClass = e.snr_db >= 20 ? 'ms-snr-strong' : e.snr_db >= 10 ? 'ms-snr-moderate' : 'ms-snr-weak'; + const tagsHtml = (e.tags || []).map(function (t) { + const cls = t === 'strong' ? 'strong' : t === 'moderate' ? 'moderate' : ''; + return '' + t + ''; + }).join(''); + + html += '' + + '' + timeStr + '' + + '' + e.duration_ms.toFixed(0) + ' ms' + + '' + e.snr_db.toFixed(1) + '' + + '' + (e.freq_offset_hz || 0).toFixed(0) + '' + + '' + (e.confidence * 100).toFixed(0) + '%' + + '' + tagsHtml + '' + + ''; + } + tbody.innerHTML = html; + } + + function _updateUI() { + const startBtn = document.getElementById('meteorStartBtn'); + const stopBtn = document.getElementById('meteorStopBtn'); + const statusChip = document.getElementById('meteorStatusChip'); + + if (startBtn) startBtn.disabled = _running; + if (stopBtn) stopBtn.disabled = !_running; + if (statusChip) { + statusChip.textContent = _running ? 'RUNNING' : 'IDLE'; + statusChip.className = 'ms-headline-tag' + (_running ? '' : ' idle'); + } + } + + function _flashPing() { + const container = document.getElementById('meteorVisuals'); + if (!container) return; + container.classList.remove('ms-ping-flash'); + void container.offsetWidth; // force reflow + container.classList.add('ms-ping-flash'); + } + + function _renderEmptyState() { + const container = document.getElementById('meteorEmptyState'); + if (container) container.style.display = 'flex'; + } + + function _hideEmptyState() { + const container = document.getElementById('meteorEmptyState'); + if (container) container.style.display = 'none'; + } + + function _setText(id, val) { + const el = document.getElementById(id); + if (el) el.textContent = val; + } + + function _formatUptime(s) { + if (!s || s < 0) return '0:00'; + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = Math.floor(s % 60); + if (h > 0) return h + ':' + String(m).padStart(2, '0') + ':' + String(sec).padStart(2, '0'); + return m + ':' + String(sec).padStart(2, '0'); + } + + // ── Export ── + + function _downloadExport(fmt) { + const url = '/meteor/events/export?format=' + fmt; + const a = document.createElement('a'); + a.href = url; + a.download = 'meteor_events.' + fmt; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } + + // ── Turbo LUT ── + + function _buildTurboLUT() { + const stops = [ + [0, [48, 18, 59]], + [0.25, [65, 182, 196]], + [0.5, [253, 231, 37]], + [0.75, [246, 114, 48]], + [1, [122, 4, 3]] + ]; + const lut = new Uint8Array(256 * 3); + for (let i = 0; i < 256; i++) { + const t = i / 255; + let s = 0; + while (s < stops.length - 2 && t > stops[s + 1][0]) s++; + const t0 = stops[s][0], t1 = stops[s + 1][0]; + const local = t0 === t1 ? 0 : (t - t0) / (t1 - t0); + const c0 = stops[s][1], c1 = stops[s + 1][1]; + lut[i * 3] = Math.round(c0[0] + (c1[0] - c0[0]) * local); + lut[i * 3 + 1] = Math.round(c0[1] + (c1[1] - c0[1]) * local); + lut[i * 3 + 2] = Math.round(c0[2] + (c1[2] - c0[2]) * local); + } + return lut; + } + + // ── Expose ── + + return { + init: init, + destroy: destroy, + start: start, + stop: stop, + exportCSV: exportCSV, + exportJSON: exportJSON, + clearEvents: clearEvents, + }; +})(); diff --git a/templates/index.html b/templates/index.html index e100781..1e57013 100644 --- a/templates/index.html +++ b/templates/index.html @@ -85,6 +85,7 @@ wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}", morse: "{{ url_for('static', filename='css/modes/morse.css') }}", radiosonde: "{{ url_for('static', filename='css/modes/radiosonde.css') }}", + meteor: "{{ url_for('static', filename='css/modes/meteor.css') }}", system: "{{ url_for('static', filename='css/modes/system.css') }}" }; window.INTERCEPT_MODE_STYLE_LOADED = {}; @@ -714,6 +715,7 @@ {% include 'partials/modes/bt_locate.html' %} {% include 'partials/modes/waterfall.html' %} + {% include 'partials/modes/meteor.html' %} {% include 'partials/modes/system.html' %} @@ -3160,6 +3162,79 @@
+ + + @@ -233,6 +234,7 @@ {{ mobile_item('wefax', 'WeFax', '') }} {{ mobile_item('sstv_general', 'HF SSTV', '') }} {{ mobile_item('spaceweather', 'SpaceWx', '') }} + {{ mobile_item('meteor', 'Meteor', '') }} {# Wireless #} {{ mobile_item('wifi', 'WiFi', '') }} {{ mobile_item('bluetooth', 'BT', '') }} diff --git a/tests/test_meteor_detector.py b/tests/test_meteor_detector.py new file mode 100644 index 0000000..bebb471 --- /dev/null +++ b/tests/test_meteor_detector.py @@ -0,0 +1,338 @@ +"""Unit tests for utils/meteor_detector.py.""" + +import json +import time + +import numpy as np +import pytest + +from utils.meteor_detector import MeteorDetector, MeteorEvent, PingState + + +@pytest.fixture +def detector(): + """Create a detector with test-friendly defaults.""" + return MeteorDetector( + snr_threshold_db=6.0, + min_duration_ms=50.0, + cooldown_ms=200.0, + freq_drift_tolerance_hz=500.0, + noise_alpha=0.5, # fast adaptation for tests + ) + + +def _make_noise(fft_size=256, noise_level=-80.0, rng=None): + """Generate a noise-floor FFT frame.""" + if rng is None: + rng = np.random.default_rng(42) + return (noise_level + rng.normal(0, 1, fft_size)).astype(np.float32) + + +def _inject_signal(frame, bin_index, power_db): + """Inject a signal at a specific bin.""" + out = frame.copy() + out[bin_index] = power_db + return out + + +class TestMeteorDetectorBasic: + """Basic construction and property tests.""" + + def test_initial_state(self, detector): + assert detector.state == PingState.IDLE + assert detector._pings_total == 0 + assert detector._events == [] + + def test_update_settings(self, detector): + detector.update_settings(snr_threshold_db=10.0, min_duration_ms=100.0) + assert detector.snr_threshold_db == 10.0 + assert detector.min_duration_ms == 100.0 + + def test_reset(self, detector): + detector._pings_total = 5 + detector._events.append(MeteorEvent( + id='test', start_ts=0, end_ts=1, duration_ms=100, + peak_db=-40, snr_db=20, center_freq_hz=143e6, + peak_freq_hz=143e6, freq_offset_hz=0, confidence=0.8, + )) + detector.reset() + assert detector._pings_total == 0 + assert detector._events == [] + assert detector.state == PingState.IDLE + + +class TestNoiseFloor: + """Noise floor tracking tests.""" + + def test_noise_floor_initialized_on_first_frame(self, detector): + frame = _make_noise() + detector.process_frame(frame, 142e6, 144e6, timestamp=1.0) + assert detector._noise_initialized + assert detector._noise_floor is not None + + def test_noise_floor_stable_without_signal(self, detector): + rng = np.random.default_rng(123) + for i in range(50): + frame = _make_noise(rng=rng) + detector.process_frame(frame, 142e6, 144e6, timestamp=float(i)) + + # Noise floor should be close to -80 dB + median_nf = float(np.median(detector._noise_floor)) + assert -82 < median_nf < -78 + + +class TestDetectionStateMachine: + """State machine transition tests.""" + + def test_no_detection_on_pure_noise(self, detector): + rng = np.random.default_rng(42) + for i in range(100): + frame = _make_noise(rng=rng) + stats, event = detector.process_frame(frame, 142e6, 144e6, timestamp=float(i) * 0.05) + assert event is None + assert detector._pings_total == 0 + + def test_detect_strong_ping(self, detector): + rng = np.random.default_rng(42) + fft_size = 256 + center_bin = fft_size // 2 + ts = 0.0 + + # Prime noise floor with 20 frames + for _ in range(20): + frame = _make_noise(fft_size, rng=rng) + detector.process_frame(frame, 142e6, 144e6, timestamp=ts) + ts += 0.05 + + # Inject signal for enough frames to exceed min_duration_ms (50ms) + # At 0.05s per frame, need 2+ frames + events = [] + for _ in range(5): + frame = _make_noise(fft_size, rng=rng) + frame = _inject_signal(frame, center_bin, -40.0) # ~40 dB above noise + stats, event = detector.process_frame(frame, 142e6, 144e6, timestamp=ts) + if event: + events.append(event) + ts += 0.05 + + # Signal drops — should enter cooldown + for _ in range(10): + frame = _make_noise(fft_size, rng=rng) + stats, event = detector.process_frame(frame, 142e6, 144e6, timestamp=ts) + if event: + events.append(event) + ts += 0.05 + + assert len(events) == 1 + evt = events[0] + assert evt.snr_db > 10 + assert evt.duration_ms > 0 + assert evt.confidence > 0 + + def test_false_alarm_short_burst(self, detector): + """A signal below min_duration should not produce an event.""" + rng = np.random.default_rng(42) + fft_size = 256 + center_bin = fft_size // 2 + ts = 0.0 + + # Prime noise floor + for _ in range(20): + frame = _make_noise(fft_size, rng=rng) + detector.process_frame(frame, 142e6, 144e6, timestamp=ts) + ts += 0.001 # 1ms per frame + + # Single frame with signal (1ms < 50ms min_duration) + frame = _make_noise(fft_size, rng=rng) + frame = _inject_signal(frame, center_bin, -40.0) + stats, event = detector.process_frame(frame, 142e6, 144e6, timestamp=ts) + ts += 0.001 + + # Immediately back to noise + frame = _make_noise(fft_size, rng=rng) + stats, event = detector.process_frame(frame, 142e6, 144e6, timestamp=ts) + + assert event is None + assert detector.state == PingState.IDLE + + +class TestEventProperties: + """Test event metadata and tags.""" + + def _generate_event(self, detector, snr_offset=40.0, num_signal_frames=10): + rng = np.random.default_rng(99) + fft_size = 256 + center_bin = fft_size // 2 + ts = 0.0 + + # Prime noise floor + for _ in range(30): + frame = _make_noise(fft_size, rng=rng) + detector.process_frame(frame, 142e6, 144e6, timestamp=ts) + ts += 0.05 + + # Signal frames + for _ in range(num_signal_frames): + frame = _make_noise(fft_size, rng=rng) + frame = _inject_signal(frame, center_bin, -80 + snr_offset) + detector.process_frame(frame, 142e6, 144e6, timestamp=ts) + ts += 0.05 + + # Cooldown frames + events = [] + for _ in range(20): + frame = _make_noise(fft_size, rng=rng) + stats, event = detector.process_frame(frame, 142e6, 144e6, timestamp=ts) + if event: + events.append(event) + ts += 0.05 + + return events + + def test_event_has_required_fields(self, detector): + events = self._generate_event(detector) + assert len(events) >= 1 + e = events[0] + assert e.id + assert e.start_ts > 0 + assert e.end_ts > e.start_ts + assert e.duration_ms > 0 + assert e.peak_db != 0 + assert e.snr_db > 0 + assert 0 <= e.confidence <= 1 + assert isinstance(e.tags, list) + + def test_event_to_dict(self, detector): + events = self._generate_event(detector) + d = events[0].to_dict() + assert isinstance(d, dict) + assert 'id' in d + assert 'snr_db' in d + assert 'tags' in d + + def test_strong_tag(self, detector): + events = self._generate_event(detector, snr_offset=60) + assert len(events) >= 1 + assert 'strong' in events[0].tags + + +class TestStats: + """Stats computation tests.""" + + def test_stats_structure(self, detector): + frame = _make_noise() + stats, _ = detector.process_frame(frame, 142e6, 144e6, timestamp=time.time()) + assert stats['type'] == 'stats' + assert 'pings_total' in stats + assert 'pings_last_10min' in stats + assert 'strongest_snr' in stats + assert 'current_noise_floor' in stats + assert 'uptime_s' in stats + assert 'state' in stats + + def test_pings_total_increments(self, detector): + rng = np.random.default_rng(42) + fft_size = 256 + center_bin = fft_size // 2 + ts = 0.0 + + # Prime + for _ in range(20): + frame = _make_noise(fft_size, rng=rng) + detector.process_frame(frame, 142e6, 144e6, timestamp=ts) + ts += 0.05 + + # Two separate pings + for _ in range(2): + for _ in range(5): + frame = _make_noise(fft_size, rng=rng) + frame = _inject_signal(frame, center_bin, -40.0) + detector.process_frame(frame, 142e6, 144e6, timestamp=ts) + ts += 0.05 + # Gap + for _ in range(15): + frame = _make_noise(fft_size, rng=rng) + detector.process_frame(frame, 142e6, 144e6, timestamp=ts) + ts += 0.05 + + assert detector._pings_total == 2 + + +class TestExport: + """Export functionality tests.""" + + def test_export_csv(self, detector): + detector._events.append(MeteorEvent( + id='abc', start_ts=1000.0, end_ts=1000.5, duration_ms=500, + peak_db=-40, snr_db=20, center_freq_hz=143e6, + peak_freq_hz=143.001e6, freq_offset_hz=1000, confidence=0.85, + tags=['strong', 'medium'], + )) + csv = detector.export_events_csv() + assert 'abc' in csv + assert 'strong;medium' in csv + + def test_export_json(self, detector): + detector._events.append(MeteorEvent( + id='def', start_ts=2000.0, end_ts=2001.0, duration_ms=1000, + peak_db=-35, snr_db=25, center_freq_hz=143e6, + peak_freq_hz=143e6, freq_offset_hz=0, confidence=0.9, + )) + data = json.loads(detector.export_events_json()) + assert len(data) == 1 + assert data[0]['id'] == 'def' + + def test_get_events(self, detector): + for i in range(10): + detector._events.append(MeteorEvent( + id=str(i), start_ts=float(i), end_ts=float(i) + 0.1, + duration_ms=100, peak_db=-40, snr_db=15, + center_freq_hz=143e6, peak_freq_hz=143e6, + freq_offset_hz=0, confidence=0.7, + )) + events = detector.get_events(limit=5) + assert len(events) == 5 + + def test_clear_events(self, detector): + detector._events.append(MeteorEvent( + id='x', start_ts=0, end_ts=1, duration_ms=100, + peak_db=-40, snr_db=15, center_freq_hz=143e6, + peak_freq_hz=143e6, freq_offset_hz=0, confidence=0.7, + )) + detector._pings_total = 1 + count = detector.clear_events() + assert count == 1 + assert len(detector._events) == 0 + assert detector._pings_total == 0 + + +class TestFreqWindow: + """Test frequency windowing.""" + + def test_freq_window_limits_detection_range(self): + detector = MeteorDetector( + snr_threshold_db=6.0, + min_duration_ms=10.0, + cooldown_ms=50.0, + noise_alpha=0.5, + freq_window_hz=100000, # 100 kHz window + ) + rng = np.random.default_rng(42) + fft_size = 256 + ts = 0.0 + + # Prime + for _ in range(20): + frame = _make_noise(fft_size, rng=rng) + detector.process_frame(frame, 142e6, 144e6, timestamp=ts) + ts += 0.01 + + # Signal at edge of spectrum (outside 100 kHz window around center) + # Center is 143 MHz, window is 142.95-143.05 MHz + # Bin 0 corresponds to 142 MHz — outside window + frame = _make_noise(fft_size, rng=rng) + frame = _inject_signal(frame, 5, -30.0) # near start, outside window + stats, event = detector.process_frame(frame, 142e6, 144e6, timestamp=ts) + # Should not trigger since signal is outside the freq window + # (the windowed slice won't contain bin 5) + assert event is None diff --git a/utils/meteor_detector.py b/utils/meteor_detector.py new file mode 100644 index 0000000..6ce45be --- /dev/null +++ b/utils/meteor_detector.py @@ -0,0 +1,358 @@ +"""Meteor scatter ping detection engine. + +Processes FFT power spectrum frames to detect transient VHF reflections +from meteor ionization trails (e.g. GRAVES radar at 143.050 MHz). +""" + +from __future__ import annotations + +import csv +import enum +import io +import json +import time +import uuid +from dataclasses import asdict, dataclass, field +from typing import Any + +import numpy as np + + +class PingState(enum.Enum): + """Detection state machine states.""" + IDLE = 'idle' + DETECTING = 'detecting' + ACTIVE = 'active' + COOLDOWN = 'cooldown' + + +@dataclass +class MeteorEvent: + """A detected meteor scatter ping.""" + id: str + start_ts: float + end_ts: float + duration_ms: float + peak_db: float + snr_db: float + center_freq_hz: float + peak_freq_hz: float + freq_offset_hz: float + confidence: float + tags: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +class MeteorDetector: + """Detects meteor scatter pings from FFT power spectrum frames. + + Uses a rolling noise floor with exponential moving average and a + state machine with hysteresis to classify transient signal bursts. + + Args: + snr_threshold_db: Minimum SNR above noise floor to trigger detection. + min_duration_ms: Minimum burst duration to classify as a ping. + cooldown_ms: Holdoff time after signal drops before returning to IDLE. + freq_drift_tolerance_hz: Maximum allowed frequency drift during a ping. + noise_alpha: EMA smoothing factor for noise floor (smaller = slower). + freq_window_hz: Bandwidth around center to monitor (None = full span). + """ + + def __init__( + self, + snr_threshold_db: float = 6.0, + min_duration_ms: float = 50.0, + cooldown_ms: float = 200.0, + freq_drift_tolerance_hz: float = 500.0, + noise_alpha: float = 0.01, + freq_window_hz: float | None = None, + ): + self.snr_threshold_db = snr_threshold_db + self.min_duration_ms = min_duration_ms + self.cooldown_ms = cooldown_ms + self.freq_drift_tolerance_hz = freq_drift_tolerance_hz + self.noise_alpha = noise_alpha + self.freq_window_hz = freq_window_hz + + # State machine + self._state = PingState.IDLE + self._detect_start_ts: float = 0.0 + self._cooldown_start_ts: float = 0.0 + self._peak_db: float = -999.0 + self._peak_snr: float = 0.0 + self._peak_freq_hz: float = 0.0 + self._center_freq_hz: float = 0.0 + + # Noise floor (initialized on first frame) + self._noise_floor: np.ndarray | None = None + self._noise_initialized = False + + # Session stats + self._events: list[MeteorEvent] = [] + self._pings_total = 0 + self._strongest_snr = 0.0 + self._start_time = time.time() + self._current_noise_floor_db = -100.0 + + @property + def state(self) -> PingState: + return self._state + + def update_settings( + self, + snr_threshold_db: float | None = None, + min_duration_ms: float | None = None, + cooldown_ms: float | None = None, + freq_drift_tolerance_hz: float | None = None, + ) -> None: + """Update detection parameters at runtime.""" + if snr_threshold_db is not None: + self.snr_threshold_db = float(snr_threshold_db) + if min_duration_ms is not None: + self.min_duration_ms = float(min_duration_ms) + if cooldown_ms is not None: + self.cooldown_ms = float(cooldown_ms) + if freq_drift_tolerance_hz is not None: + self.freq_drift_tolerance_hz = float(freq_drift_tolerance_hz) + + def process_frame( + self, + power_spectrum_db: np.ndarray, + freq_start_hz: float, + freq_end_hz: float, + timestamp: float | None = None, + ) -> tuple[dict[str, Any], MeteorEvent | None]: + """Process a single FFT power spectrum frame. + + Args: + power_spectrum_db: Power spectrum in dB (float32, fftshift'd). + freq_start_hz: Start frequency of the spectrum in Hz. + freq_end_hz: End frequency of the spectrum in Hz. + timestamp: Frame timestamp (defaults to current time). + + Returns: + Tuple of (stats_dict, detected_event_or_None). + """ + ts = timestamp or time.time() + num_bins = len(power_spectrum_db) + bin_width_hz = (freq_end_hz - freq_start_hz) / max(1, num_bins) + + # Determine frequency window of interest + if self.freq_window_hz and self.freq_window_hz > 0: + center_hz = (freq_start_hz + freq_end_hz) / 2.0 + win_start = center_hz - self.freq_window_hz / 2.0 + win_end = center_hz + self.freq_window_hz / 2.0 + start_bin = max(0, int((win_start - freq_start_hz) / bin_width_hz)) + end_bin = min(num_bins, int((win_end - freq_start_hz) / bin_width_hz) + 1) + else: + start_bin = 0 + end_bin = num_bins + + window = power_spectrum_db[start_bin:end_bin] + if len(window) == 0: + return self._build_stats(ts), None + + # Update rolling noise floor via EMA + if not self._noise_initialized: + self._noise_floor = window.copy().astype(np.float64) + self._noise_initialized = True + else: + # Only update noise floor from bins that are NOT currently elevated + # (prevents signal from raising the noise floor) + if self._noise_floor is not None and len(self._noise_floor) == len(window): + mask = window < (self._noise_floor + self.snr_threshold_db * 0.5) + alpha = self.noise_alpha + self._noise_floor[mask] = ( + (1 - alpha) * self._noise_floor[mask] + alpha * window[mask].astype(np.float64) + ) + else: + self._noise_floor = window.copy().astype(np.float64) + + # Compute SNR + noise_floor_f32 = self._noise_floor.astype(np.float32) + snr = window - noise_floor_f32 + peak_bin = int(np.argmax(snr)) + peak_snr = float(snr[peak_bin]) + peak_db = float(window[peak_bin]) + peak_freq_hz = freq_start_hz + (start_bin + peak_bin) * bin_width_hz + + self._current_noise_floor_db = float(np.median(noise_floor_f32)) + + # State machine + event = None + above_threshold = peak_snr >= self.snr_threshold_db + + if self._state == PingState.IDLE: + if above_threshold: + self._state = PingState.DETECTING + self._detect_start_ts = ts + self._peak_db = peak_db + self._peak_snr = peak_snr + self._peak_freq_hz = peak_freq_hz + self._center_freq_hz = peak_freq_hz + + elif self._state == PingState.DETECTING: + if above_threshold: + # Track peak values + if peak_snr > self._peak_snr: + self._peak_snr = peak_snr + self._peak_db = peak_db + self._peak_freq_hz = peak_freq_hz + + # Check if minimum duration met + elapsed_ms = (ts - self._detect_start_ts) * 1000.0 + if elapsed_ms >= self.min_duration_ms: + self._state = PingState.ACTIVE + else: + # Signal dropped before min duration — false alarm + self._state = PingState.IDLE + + elif self._state == PingState.ACTIVE: + if above_threshold: + # Continue tracking + if peak_snr > self._peak_snr: + self._peak_snr = peak_snr + self._peak_db = peak_db + self._peak_freq_hz = peak_freq_hz + else: + # Signal dropped — enter cooldown + self._state = PingState.COOLDOWN + self._cooldown_start_ts = ts + + elif self._state == PingState.COOLDOWN: + if above_threshold: + # Signal returned within cooldown — still same ping + freq_drift = abs(peak_freq_hz - self._center_freq_hz) + if freq_drift <= self.freq_drift_tolerance_hz: + self._state = PingState.ACTIVE + if peak_snr > self._peak_snr: + self._peak_snr = peak_snr + self._peak_db = peak_db + self._peak_freq_hz = peak_freq_hz + else: + # Frequency drifted too far — finalize this event, start new detection + event = self._finalize_event(ts) + self._state = PingState.DETECTING + self._detect_start_ts = ts + self._peak_db = peak_db + self._peak_snr = peak_snr + self._peak_freq_hz = peak_freq_hz + self._center_freq_hz = peak_freq_hz + else: + # Check if cooldown expired + cooldown_elapsed_ms = (ts - self._cooldown_start_ts) * 1000.0 + if cooldown_elapsed_ms >= self.cooldown_ms: + event = self._finalize_event(ts) + self._state = PingState.IDLE + + return self._build_stats(ts), event + + def _finalize_event(self, end_ts: float) -> MeteorEvent: + """Create a MeteorEvent from the current detection state.""" + duration_ms = (end_ts - self._detect_start_ts) * 1000.0 + freq_offset_hz = self._peak_freq_hz - self._center_freq_hz + + # Confidence based on SNR and duration + snr_factor = min(1.0, self._peak_snr / (self.snr_threshold_db * 3)) + dur_factor = min(1.0, duration_ms / 2000.0) + confidence = round(0.6 * snr_factor + 0.4 * dur_factor, 2) + + # Tags + tags: list[str] = [] + if self._peak_snr >= 20: + tags.append('strong') + elif self._peak_snr >= 10: + tags.append('moderate') + else: + tags.append('weak') + if duration_ms >= 5000: + tags.append('long-duration') + elif duration_ms >= 1000: + tags.append('medium') + else: + tags.append('short') + + event = MeteorEvent( + id=str(uuid.uuid4())[:8], + start_ts=self._detect_start_ts, + end_ts=end_ts, + duration_ms=round(duration_ms, 1), + peak_db=round(self._peak_db, 1), + snr_db=round(self._peak_snr, 1), + center_freq_hz=round(self._center_freq_hz, 1), + peak_freq_hz=round(self._peak_freq_hz, 1), + freq_offset_hz=round(freq_offset_hz, 1), + confidence=confidence, + tags=tags, + ) + + self._events.append(event) + self._pings_total += 1 + if self._peak_snr > self._strongest_snr: + self._strongest_snr = self._peak_snr + + return event + + def _build_stats(self, ts: float) -> dict[str, Any]: + """Build current session stats.""" + uptime_s = ts - self._start_time + + # Count pings in last 10 minutes + cutoff = ts - 600 + pings_last_10min = sum(1 for e in self._events if e.start_ts >= cutoff) + + return { + 'type': 'stats', + 'state': self._state.value, + 'pings_total': self._pings_total, + 'pings_last_10min': pings_last_10min, + 'strongest_snr': round(self._strongest_snr, 1), + 'current_noise_floor': round(self._current_noise_floor_db, 1), + 'uptime_s': round(uptime_s, 1), + } + + def get_events(self, limit: int = 500) -> list[dict[str, Any]]: + """Return recent events as dicts.""" + return [e.to_dict() for e in self._events[-limit:]] + + def clear_events(self) -> int: + """Clear all events. Returns count cleared.""" + count = len(self._events) + self._events.clear() + self._pings_total = 0 + self._strongest_snr = 0.0 + return count + + def export_events_csv(self) -> str: + """Export events as CSV string.""" + output = io.StringIO() + writer = csv.writer(output) + writer.writerow([ + 'id', 'start_ts', 'end_ts', 'duration_ms', 'peak_db', + 'snr_db', 'center_freq_hz', 'peak_freq_hz', 'freq_offset_hz', + 'confidence', 'tags', + ]) + for e in self._events: + writer.writerow([ + e.id, e.start_ts, e.end_ts, e.duration_ms, e.peak_db, + e.snr_db, e.center_freq_hz, e.peak_freq_hz, e.freq_offset_hz, + e.confidence, ';'.join(e.tags), + ]) + return output.getvalue() + + def export_events_json(self) -> str: + """Export events as JSON string.""" + return json.dumps([e.to_dict() for e in self._events], indent=2) + + def reset(self) -> None: + """Full reset of detector state.""" + self._state = PingState.IDLE + self._noise_floor = None + self._noise_initialized = False + self._events.clear() + self._pings_total = 0 + self._strongest_snr = 0.0 + self._current_noise_floor_db = -100.0 + self._start_time = time.time()