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 5596846426
commit 4950a1a0a5
12 changed files with 2499 additions and 14 deletions
+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,
};
})();