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:
Smittix
2026-03-02 20:38:15 +00:00
parent e2e92b6b38
commit 7311dd10ab
12 changed files with 2499 additions and 14 deletions
+44
View File
@@ -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
+2
View File
@@ -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
+597
View File
@@ -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")
+344
View File
@@ -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;
}
}
+7 -4
View File
@@ -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.1137.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 400406 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) {
+27 -5
View File
@@ -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 ||
+554
View File
@@ -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
View File
@@ -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">&#9732;</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();
}
+134
View File
@@ -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 &mdash; 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&deg; 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 &ndash; 10 s</td>
</tr>
</table>
</div>
</div>
</div>
</div>
+2
View File
@@ -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>') }}
+338
View File
@@ -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
+358
View File
@@ -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()