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 += '