mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 06:01:56 -07:00
feat: add Meteor Scatter mode for VHF beacon ping detection
Full-stack meteor scatter monitoring mode that captures IQ data from an RTL-SDR, computes FFT waterfall frames via WebSocket, and runs a real-time detection engine to identify transient VHF reflections from meteor ionization trails (e.g. GRAVES radar at 143.050 MHz). Backend: MeteorDetector with EMA noise floor, SNR threshold state machine (IDLE/DETECTING/ACTIVE/COOLDOWN), hysteresis, and CSV/JSON export. WebSocket at /ws/meteor for binary waterfall frames, SSE at /meteor/stream for detection events and stats. Frontend: spectrum + waterfall + timeline canvases, event table with SNR/duration/confidence, stats strip, turbo colour LUT. Uses shared SDR device selection panel with conflict tracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 '<span class="ms-tag ' + cls + '">' + t + '</span>';
|
||||
}).join('');
|
||||
|
||||
html += '<tr>' +
|
||||
'<td>' + timeStr + '</td>' +
|
||||
'<td>' + e.duration_ms.toFixed(0) + ' ms</td>' +
|
||||
'<td class="' + snrClass + '">' + e.snr_db.toFixed(1) + '</td>' +
|
||||
'<td>' + (e.freq_offset_hz || 0).toFixed(0) + '</td>' +
|
||||
'<td>' + (e.confidence * 100).toFixed(0) + '%</td>' +
|
||||
'<td>' + tagsHtml + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
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,
|
||||
};
|
||||
})();
|
||||
+92
-5
@@ -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 @@
|
||||
<div id="radiosondeCardContainer" class="radiosonde-card-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Meteor Scatter Visuals -->
|
||||
<div id="meteorVisuals" class="meteor-visuals-container" style="display: none;">
|
||||
<div class="ms-headline">
|
||||
<div class="ms-headline-left">
|
||||
<span id="meteorStatusChip" class="ms-headline-tag idle">IDLE</span>
|
||||
<span class="ms-headline-sub" id="meteorFreqLabel">143.050 MHz</span>
|
||||
</div>
|
||||
<div class="ms-headline-right">
|
||||
<span id="meteorStateTag" class="ms-headline-tag idle">IDLE</span>
|
||||
<button id="meteorStartBtn" class="preset-btn" style="font-size: 10px; padding: 2px 10px;">Start</button>
|
||||
<button id="meteorStopBtn" class="preset-btn" style="font-size: 10px; padding: 2px 10px;" disabled>Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-stats-strip">
|
||||
<div class="ms-stat-cell">
|
||||
<span class="ms-stat-label">Total Pings</span>
|
||||
<span class="ms-stat-value highlight" id="meteorStatPingsTotal">0</span>
|
||||
</div>
|
||||
<div class="ms-stat-cell">
|
||||
<span class="ms-stat-label">Last 10 Min</span>
|
||||
<span class="ms-stat-value" id="meteorStatPings10min">0</span>
|
||||
</div>
|
||||
<div class="ms-stat-cell">
|
||||
<span class="ms-stat-label">Strongest SNR</span>
|
||||
<span class="ms-stat-value" id="meteorStatStrongest">0.0 dB</span>
|
||||
</div>
|
||||
<div class="ms-stat-cell">
|
||||
<span class="ms-stat-label">Noise Floor</span>
|
||||
<span class="ms-stat-value" id="meteorStatNoiseFloor">-100.0 dB</span>
|
||||
</div>
|
||||
<div class="ms-stat-cell">
|
||||
<span class="ms-stat-label">Uptime</span>
|
||||
<span class="ms-stat-value" id="meteorStatUptime">0:00</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-spectrum-wrap">
|
||||
<canvas id="meteorSpectrumCanvas"></canvas>
|
||||
</div>
|
||||
<div class="ms-waterfall-wrap">
|
||||
<canvas id="meteorWaterfallCanvas"></canvas>
|
||||
<div class="ms-empty-state" id="meteorEmptyState">
|
||||
<div class="ms-empty-icon">☄</div>
|
||||
<div class="ms-empty-text">
|
||||
Configure frequency and press Start to begin meteor scatter monitoring.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-timeline-wrap">
|
||||
<canvas id="meteorTimelineCanvas"></canvas>
|
||||
</div>
|
||||
<div class="ms-events-panel">
|
||||
<div class="ms-events-header">
|
||||
<span class="ms-events-title">Detected Pings</span>
|
||||
<span class="ms-events-count" id="meteorEventsCount">0 events</span>
|
||||
</div>
|
||||
<div class="ms-events-scroll">
|
||||
<table class="ms-events-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Duration</th>
|
||||
<th>SNR</th>
|
||||
<th>Offset</th>
|
||||
<th>Conf</th>
|
||||
<th>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="meteorEventsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Health Visuals -->
|
||||
<div id="systemVisuals" class="sys-visuals-container" style="display: none;">
|
||||
<div class="sys-dashboard">
|
||||
@@ -3283,6 +3358,7 @@
|
||||
<script src="{{ url_for('static', filename='js/modes/morse.js') }}?v={{ version }}&r=morse_iq12"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/system.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/meteor.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
|
||||
@@ -3428,6 +3504,7 @@
|
||||
sstv_general: { label: 'HF SSTV', indicator: 'HF SSTV', outputTitle: 'HF SSTV Decoder', group: 'space' },
|
||||
wefax: { label: 'WeFax', indicator: 'WEFAX', outputTitle: 'Weather Fax Decoder', group: 'space' },
|
||||
spaceweather: { label: 'Space Weather', indicator: 'SPACE WX', outputTitle: 'Space Weather Monitor', group: 'space' },
|
||||
meteor: { label: 'Meteor', indicator: 'METEOR', outputTitle: 'Meteor Scatter Monitor', group: 'space' },
|
||||
wifi: { label: 'WiFi', indicator: 'WIFI', outputTitle: 'WiFi Scanner', group: 'wireless' },
|
||||
bluetooth: { label: 'Bluetooth', indicator: 'BLUETOOTH', outputTitle: 'Bluetooth Scanner', group: 'wireless' },
|
||||
bt_locate: { label: 'BT Locate', indicator: 'BT LOCATE', outputTitle: 'BT Locate — SAR Tracker', group: 'wireless' },
|
||||
@@ -4183,6 +4260,7 @@
|
||||
acars: () => { if (acarsMainEventSource) { acarsMainEventSource.close(); acarsMainEventSource = null; } },
|
||||
vdl2: () => { if (vdl2MainEventSource) { vdl2MainEventSource.close(); vdl2MainEventSource = null; } },
|
||||
radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } },
|
||||
meteor: () => typeof MeteorScatter !== 'undefined' && MeteorScatter.destroy?.(),
|
||||
};
|
||||
if (previousMode && previousMode !== mode && moduleDestroyMap[previousMode]) {
|
||||
try { moduleDestroyMap[previousMode](); } catch(e) { console.warn(`[switchMode] destroy ${previousMode} failed:`, e); }
|
||||
@@ -4237,6 +4315,7 @@
|
||||
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
|
||||
document.getElementById('waterfallMode')?.classList.toggle('active', mode === 'waterfall');
|
||||
document.getElementById('morseMode')?.classList.toggle('active', mode === 'morse');
|
||||
document.getElementById('meteorMode')?.classList.toggle('active', mode === 'meteor');
|
||||
document.getElementById('systemMode')?.classList.toggle('active', mode === 'system');
|
||||
|
||||
|
||||
@@ -4280,6 +4359,7 @@
|
||||
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
|
||||
const waterfallVisuals = document.getElementById('waterfallVisuals');
|
||||
const radiosondeVisuals = document.getElementById('radiosondeVisuals');
|
||||
const meteorVisuals = document.getElementById('meteorVisuals');
|
||||
const systemVisuals = document.getElementById('systemVisuals');
|
||||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
@@ -4299,6 +4379,7 @@
|
||||
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
|
||||
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? 'flex' : 'none';
|
||||
if (radiosondeVisuals) radiosondeVisuals.style.display = mode === 'radiosonde' ? 'flex' : 'none';
|
||||
if (meteorVisuals) meteorVisuals.style.display = mode === 'meteor' ? 'flex' : 'none';
|
||||
if (systemVisuals) systemVisuals.style.display = mode === 'system' ? 'flex' : 'none';
|
||||
|
||||
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
|
||||
@@ -4351,7 +4432,7 @@
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
const reconPanel = document.getElementById('reconPanel');
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'system') {
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'meteor' || mode === 'system') {
|
||||
if (reconPanel) reconPanel.style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
@@ -4372,17 +4453,21 @@
|
||||
// Show RTL-SDR device section for modes that use it
|
||||
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
||||
if (rtlDeviceSection) {
|
||||
rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'morse' || mode === 'radiosonde') ? 'block' : 'none';
|
||||
rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'morse' || mode === 'radiosonde' || mode === 'meteor') ? 'block' : 'none';
|
||||
// Save original sidebar position of SDR device section (once)
|
||||
if (!rtlDeviceSection._origParent) {
|
||||
rtlDeviceSection._origParent = rtlDeviceSection.parentNode;
|
||||
rtlDeviceSection._origNext = rtlDeviceSection.nextElementSibling;
|
||||
}
|
||||
// For morse mode, move SDR device section inside the morse panel after the title
|
||||
// For morse/radiosonde modes, move SDR device section inside the panel after the title
|
||||
const morsePanel = document.getElementById('morseMode');
|
||||
const radiosondePanel = document.getElementById('radiosondeMode');
|
||||
if (mode === 'morse' && morsePanel) {
|
||||
const firstSection = morsePanel.querySelector('.section');
|
||||
if (firstSection) firstSection.after(rtlDeviceSection);
|
||||
} else if (mode === 'radiosonde' && radiosondePanel) {
|
||||
const firstSection = radiosondePanel.querySelector('.section');
|
||||
if (firstSection) firstSection.after(rtlDeviceSection);
|
||||
} else if (rtlDeviceSection._origParent && rtlDeviceSection.parentNode !== rtlDeviceSection._origParent) {
|
||||
// Restore to original sidebar position when leaving morse mode
|
||||
if (rtlDeviceSection._origNext) {
|
||||
@@ -4402,8 +4487,8 @@
|
||||
// Hide output console for modes with their own visualizations
|
||||
const outputEl = document.getElementById('output');
|
||||
const statusBar = document.querySelector('.status-bar');
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall' || mode === 'morse' || mode === 'system') ? 'none' : 'block';
|
||||
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'morse' || mode === 'system') ? 'none' : 'flex';
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall' || mode === 'morse' || mode === 'meteor' || mode === 'system') ? 'none' : 'block';
|
||||
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'morse' || mode === 'meteor' || mode === 'system') ? 'none' : 'flex';
|
||||
|
||||
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
|
||||
if (mode !== 'meshtastic') {
|
||||
@@ -4483,6 +4568,8 @@
|
||||
setTimeout(() => {
|
||||
if (radiosondeMap) radiosondeMap.invalidateSize();
|
||||
}, 100);
|
||||
} else if (mode === 'meteor') {
|
||||
MeteorScatter.init();
|
||||
} else if (mode === 'system') {
|
||||
SystemHealth.init();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<!-- METEOR SCATTER MODE -->
|
||||
<div id="meteorMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>Meteor Scatter Monitor</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||
Monitor VHF beacon reflections from meteor ionization trails.
|
||||
Detects transient pings on continuous beacons like GRAVES (143.050 MHz).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Frequency</h3>
|
||||
<div class="form-group">
|
||||
<label>Center Frequency (MHz)</label>
|
||||
<input type="number" id="meteorFrequency" value="143.050" step="0.001" min="24" max="1766">
|
||||
</div>
|
||||
<div style="display: flex; gap: 6px; margin-top: 6px;">
|
||||
<button class="preset-btn" onclick="document.getElementById('meteorFrequency').value='143.050'" style="flex: 1;">
|
||||
GRAVES 143.050
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Capture Settings</h3>
|
||||
<div class="form-group">
|
||||
<label>Gain (0 = auto)</label>
|
||||
<input type="number" id="meteorGain" value="0" step="0.1" min="0" max="50">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Sample Rate</label>
|
||||
<select id="meteorSampleRate">
|
||||
<option value="250000">250 kHz</option>
|
||||
<option value="1024000" selected>1024 kHz</option>
|
||||
<option value="2048000">2048 kHz</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>FFT Size</label>
|
||||
<select id="meteorFFTSize">
|
||||
<option value="256">256</option>
|
||||
<option value="512">512</option>
|
||||
<option value="1024" selected>1024</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Frame Rate (FPS)</label>
|
||||
<input type="number" id="meteorFPS" value="20" step="1" min="5" max="30">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Detection</h3>
|
||||
<div class="form-group">
|
||||
<label>SNR Threshold (dB): <span id="meteorSNRValue">6</span></label>
|
||||
<input type="range" id="meteorSNRThreshold" value="6" min="3" max="30" step="0.5"
|
||||
oninput="document.getElementById('meteorSNRValue').textContent=this.value">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Min Duration (ms): <span id="meteorMinDurValue">50</span></label>
|
||||
<input type="range" id="meteorMinDuration" value="50" min="20" max="500" step="10"
|
||||
oninput="document.getElementById('meteorMinDurValue').textContent=this.value">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Cooldown (ms): <span id="meteorCooldownValue">200</span></label>
|
||||
<input type="range" id="meteorCooldown" value="200" min="100" max="2000" step="50"
|
||||
oninput="document.getElementById('meteorCooldownValue').textContent=this.value">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Freq Drift Tolerance (Hz): <span id="meteorDriftValue">500</span></label>
|
||||
<input type="range" id="meteorFreqDrift" value="500" min="100" max="5000" step="100"
|
||||
oninput="document.getElementById('meteorDriftValue').textContent=this.value">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Export</h3>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button class="preset-btn" onclick="MeteorScatter.exportCSV()" style="flex: 1;">CSV</button>
|
||||
<button class="preset-btn" onclick="MeteorScatter.exportJSON()" style="flex: 1;">JSON</button>
|
||||
<button class="preset-btn" onclick="MeteorScatter.clearEvents()" style="flex: 1; color: var(--accent-red, #ff6b6b);">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>About Meteor Scatter</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim);">
|
||||
Meteor scatter communications exploit the brief ionization trails left by
|
||||
meteors entering the atmosphere. These trails reflect VHF radio signals,
|
||||
creating detectable "pings" lasting milliseconds to seconds.
|
||||
</p>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-top: 8px;">
|
||||
The GRAVES radar in France (143.050 MHz) is a primary target — its continuous
|
||||
beacon produces clear reflections off meteor trails visible across Europe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Antenna Guide</h3>
|
||||
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">VHF Yagi (Best)</strong>
|
||||
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
||||
<li><strong style="color: var(--text-primary);">Frequency:</strong> 143 MHz (2m band)</li>
|
||||
<li><strong style="color: var(--text-primary);">Direction:</strong> South (from Europe, toward GRAVES)</li>
|
||||
<li><strong style="color: var(--text-primary);">Elevation:</strong> ~45° above horizon</li>
|
||||
<li><strong style="color: var(--text-primary);">Polarization:</strong> Horizontal</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
|
||||
<strong style="color: var(--accent-cyan); font-size: 12px;">Quick Reference</strong>
|
||||
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">GRAVES frequency</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">143.050 MHz</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~52 cm</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Peak meteor showers</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Perseids (Aug), Geminids (Dec)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 3px 4px; color: var(--text-dim);">Typical ping duration</td>
|
||||
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">50 ms – 10 s</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,6 +107,7 @@
|
||||
{{ mode_item('wefax', 'WeFax', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>') }}
|
||||
{{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||
{{ mode_item('spaceweather', 'Space Weather', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>') }}
|
||||
{{ mode_item('meteor', 'Meteor Scatter', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L2 22"/><path d="M18 2h4v4"/><circle cx="8" cy="16" r="4"/><path d="M16 6l-4 4"/></svg>') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -233,6 +234,7 @@
|
||||
{{ mobile_item('wefax', 'WeFax', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>') }}
|
||||
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
||||
{{ mobile_item('spaceweather', 'SpaceWx', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg>') }}
|
||||
{{ mobile_item('meteor', 'Meteor', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L2 22"/><path d="M18 2h4v4"/><circle cx="8" cy="16" r="4"/><path d="M16 6l-4 4"/></svg>') }}
|
||||
{# Wireless #}
|
||||
{{ mobile_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg>') }}
|
||||
{{ mobile_item('bluetooth', 'BT', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user