/** * SubGHz Transceiver Mode * HackRF One SubGHz signal capture, decode, replay, and spectrum analysis */ const SubGhz = (function() { let eventSource = null; let statusTimer = null; let statusPollTimer = null; let rxStartTime = null; let sweepCanvas = null; let sweepCtx = null; let sweepData = []; let pendingTxCaptureId = null; let pendingTxCaptureMeta = null; let pendingTxBursts = []; let txTimelineDragState = null; let rxScopeCanvas = null; let rxScopeCtx = null; let rxScopeData = []; let rxScopeResizeObserver = null; let rxWaterfallCanvas = null; let rxWaterfallCtx = null; let rxWaterfallPalette = null; let rxWaterfallResizeObserver = null; let rxWaterfallPaused = false; let rxWaterfallFloor = 20; let rxWaterfallRange = 180; let decodeScopeCanvas = null; let decodeScopeCtx = null; let decodeScopeData = []; let decodeScopeResizeObserver = null; let decodeWaterfallCanvas = null; let decodeWaterfallCtx = null; let decodeWaterfallPalette = null; let decodeWaterfallResizeObserver = null; // Dashboard state let activePanel = null; // null = hub, 'rx'|'sweep'|'tx'|'saved' let signalCount = 0; let captureCount = 0; let consoleEntries = []; let consoleCollapsed = false; let currentPhase = null; // 'tuning'|'listening'|'decoding'|null let currentMode = 'idle'; // tracks backend mode for timer/strip let lastRawLine = ''; let lastRawLineTs = 0; let lastBurstLineTs = 0; let burstBadgeTimer = null; let lastRxHintTs = 0; let captureSelectMode = false; let selectedCaptureIds = new Set(); let latestCaptures = []; let lastTxCaptureId = null; let lastTxRequest = null; let txModalIntent = 'tx'; // HackRF detection let hackrfDetected = false; let rtl433Detected = false; let sweepDetected = false; // Interactive sweep state const SWEEP_PAD = { top: 20, right: 20, bottom: 30, left: 50 }; const SWEEP_POWER_MIN = -100; const SWEEP_POWER_MAX = 0; let sweepHoverFreq = null; let sweepHoverPower = null; let sweepSelectedFreq = null; let sweepPeaks = []; let sweepPeakHold = []; let sweepInteractionBound = false; let sweepResizeObserver = null; let sweepTooltipEl = null; let sweepCtxMenuEl = null; let sweepActionBarEl = null; let sweepDismissHandler = null; /** * Initialize the SubGHz mode */ function init() { loadCaptures(); startStream(); startStatusPolling(); syncTriggerControls(); // Check HackRF availability and restore panel state fetch('/subghz/status') .then(r => r.json()) .then(data => { updateDeviceStatus(data); updateStatusUI(data); const mode = data.mode || 'idle'; if (mode === 'decode') { // Legacy decode mode may still be running via API, but this UI // intentionally focuses on RAW capture/replay/sweep. showHub(); showConsole(); startStatusTimer(); addConsoleEntry('Decode mode is disabled in this UI layout.', 'warn'); } else if (mode === 'rx') { showPanel('rx'); updateRxDisplay(getParams()); initRxScope(); initRxWaterfall(); syncWaterfallControls(); showConsole(); startStatusTimer(); } else if (mode === 'sweep') { showPanel('sweep'); initSweepCanvas(); showConsole(); } else if (mode === 'tx') { showPanel('tx'); showConsole(); startStatusTimer(); } else { showHub(); } }) .catch(() => showHub()); } function syncTriggerControls() { const enabled = !!document.getElementById('subghzTriggerEnabled')?.checked; const preEl = document.getElementById('subghzTriggerPreMs'); const postEl = document.getElementById('subghzTriggerPostMs'); if (preEl) preEl.disabled = !enabled; if (postEl) postEl.disabled = !enabled; } function startStatusPolling() { if (statusPollTimer) clearInterval(statusPollTimer); const refresh = () => { fetch('/subghz/status') .then(r => r.json()) .then(data => { updateDeviceStatus(data); updateStatusUI(data); }) .catch(() => {}); }; refresh(); statusPollTimer = setInterval(refresh, 3000); } // ------ DEVICE DETECTION ------ function updateDeviceStatus(data) { const hackrfAvailable = !!data.hackrf_available; const hackrfInfoAvailable = data.hackrf_info_available !== false; const hackrfDetectionPaused = data.hackrf_detection_paused === true; const hackrfConnectedRaw = data.hackrf_connected; const hackrfConnected = hackrfConnectedRaw === true; const hackrfKnownDisconnected = hackrfConnectedRaw === false; const hackrfDetectUnknown = hackrfAvailable && !hackrfConnected && !hackrfKnownDisconnected; hackrfDetected = hackrfConnected; rtl433Detected = !!data.rtl433_available; sweepDetected = !!data.sweep_available; // Sidebar device indicator const dot = document.getElementById('subghzDeviceDot'); const label = document.getElementById('subghzDeviceLabel'); if (dot) { dot.className = 'subghz-device-dot'; if (hackrfDetectUnknown) { dot.classList.add('unknown'); } else { dot.classList.add(hackrfConnected ? 'connected' : 'disconnected'); } } if (label) { if (hackrfConnected) { label.textContent = 'HackRF Connected'; } else if (!hackrfAvailable) { label.textContent = 'HackRF Tools Missing'; } else if (hackrfDetectUnknown && hackrfDetectionPaused) { label.textContent = 'HackRF Status Paused (active stream)'; } else if (hackrfDetectUnknown && !hackrfInfoAvailable) { label.textContent = 'HackRF Detection Unavailable'; } else if (hackrfDetectUnknown) { label.textContent = 'HackRF Status Unknown'; } else { label.textContent = 'HackRF Not Detected'; } label.classList.toggle('error', !hackrfConnected && hackrfKnownDisconnected); } // Tool badges setToolBadge('subghzToolHackrf', hackrfAvailable); setToolBadge('subghzToolSweep', sweepDetected); // Stats strip device badge const stripDot = document.getElementById('subghzStripDeviceDot'); if (stripDot) { stripDot.className = 'subghz-strip-device-dot'; if (hackrfDetectUnknown) { stripDot.classList.add('unknown'); } else { stripDot.classList.add(hackrfConnected ? 'connected' : 'disconnected'); } } } function setToolBadge(id, available) { const el = document.getElementById(id); if (!el) return; el.classList.toggle('available', available); el.classList.toggle('missing', !available); } /** * Set frequency from preset button */ function setFreq(mhz) { const el = document.getElementById('subghzFrequency'); if (el) el.value = mhz; } /** * Switch between RAW receive / sweep sidebar tabs. * Only toggles sidebar tab content visibility — does NOT open visuals panels. */ function switchTab(tab) { document.querySelectorAll('.subghz-tab').forEach(t => { t.classList.toggle('active', t.dataset.tab === tab); }); const tabRx = document.getElementById('subghzTabRx'); const tabSweep = document.getElementById('subghzTabSweep'); if (tabRx) tabRx.classList.toggle('active', tab === 'rx'); if (tabSweep) tabSweep.classList.toggle('active', tab === 'sweep'); } /** * Get common parameters from inputs */ function getParams() { const freqMhz = parseFloat(document.getElementById('subghzFrequency')?.value || '433.92'); const serial = (document.getElementById('subghzDeviceSerial')?.value || '').trim(); const params = { frequency_hz: Math.round(freqMhz * 1000000), lna_gain: parseInt(document.getElementById('subghzLnaGain')?.value || '24'), vga_gain: parseInt(document.getElementById('subghzVgaGain')?.value || '20'), sample_rate: parseInt(document.getElementById('subghzSampleRate')?.value || '2000000'), }; const triggerEnabled = !!document.getElementById('subghzTriggerEnabled')?.checked; params.trigger_enabled = triggerEnabled; if (triggerEnabled) { params.trigger_pre_ms = parseInt(document.getElementById('subghzTriggerPreMs')?.value || '350'); params.trigger_post_ms = parseInt(document.getElementById('subghzTriggerPostMs')?.value || '700'); } if (serial) params.device_serial = serial; return params; } // ------ COORDINATE HELPERS ------ function sweepPixelToFreqPower(canvasX, canvasY) { if (!sweepCanvas || sweepData.length < 2) return { freq: 0, power: 0, inChart: false }; const w = sweepCanvas.width; const h = sweepCanvas.height; const chartW = w - SWEEP_PAD.left - SWEEP_PAD.right; const chartH = h - SWEEP_PAD.top - SWEEP_PAD.bottom; const inChart = canvasX >= SWEEP_PAD.left && canvasX <= w - SWEEP_PAD.right && canvasY >= SWEEP_PAD.top && canvasY <= h - SWEEP_PAD.bottom; const ratio = Math.max(0, Math.min(1, (canvasX - SWEEP_PAD.left) / chartW)); const freqMin = sweepData[0].freq; const freqMax = sweepData[sweepData.length - 1].freq; const freq = freqMin + ratio * (freqMax - freqMin); const powerRatio = Math.max(0, Math.min(1, (h - SWEEP_PAD.bottom - canvasY) / chartH)); const power = SWEEP_POWER_MIN + powerRatio * (SWEEP_POWER_MAX - SWEEP_POWER_MIN); return { freq, power, inChart }; } function sweepFreqToPixelX(freqMhz) { if (!sweepCanvas || sweepData.length < 2) return 0; const chartW = sweepCanvas.width - SWEEP_PAD.left - SWEEP_PAD.right; const freqMin = sweepData[0].freq; const freqMax = sweepData[sweepData.length - 1].freq; const ratio = (freqMhz - freqMin) / (freqMax - freqMin); return SWEEP_PAD.left + ratio * chartW; } function interpolatePower(freqMhz) { if (sweepData.length === 0) return SWEEP_POWER_MIN; if (sweepData.length === 1) return sweepData[0].power; let lo = 0, hi = sweepData.length - 1; if (freqMhz <= sweepData[lo].freq) return sweepData[lo].power; if (freqMhz >= sweepData[hi].freq) return sweepData[hi].power; while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (sweepData[mid].freq <= freqMhz) lo = mid; else hi = mid; } const t = (freqMhz - sweepData[lo].freq) / (sweepData[hi].freq - sweepData[lo].freq); return sweepData[lo].power + t * (sweepData[hi].power - sweepData[lo].power); } // ------ RX SCOPE ------ function initRxScope() { rxScopeCanvas = document.getElementById('subghzRxScope'); if (!rxScopeCanvas) return; rxScopeCtx = rxScopeCanvas.getContext('2d'); resizeRxScope(); if (!rxScopeResizeObserver && rxScopeCanvas.parentElement) { rxScopeResizeObserver = new ResizeObserver(() => { resizeRxScope(); drawRxScope(); }); rxScopeResizeObserver.observe(rxScopeCanvas.parentElement); } drawRxScope(); } function initDecodeScope() { decodeScopeCanvas = document.getElementById('subghzDecodeScope'); if (!decodeScopeCanvas) return; decodeScopeCtx = decodeScopeCanvas.getContext('2d'); resizeDecodeScope(); if (!decodeScopeResizeObserver && decodeScopeCanvas.parentElement) { decodeScopeResizeObserver = new ResizeObserver(() => { resizeDecodeScope(); drawDecodeScope(); }); decodeScopeResizeObserver.observe(decodeScopeCanvas.parentElement); } drawDecodeScope(); } function initRxWaterfall() { rxWaterfallCanvas = document.getElementById('subghzRxWaterfall'); if (!rxWaterfallCanvas) return; rxWaterfallCtx = rxWaterfallCanvas.getContext('2d'); rxWaterfallPalette = rxWaterfallPalette || buildWaterfallPalette(); resizeRxWaterfall(); clearWaterfall(rxWaterfallCtx, rxWaterfallCanvas); syncWaterfallControls(); if (!rxWaterfallResizeObserver && rxWaterfallCanvas.parentElement) { rxWaterfallResizeObserver = new ResizeObserver(() => { resizeRxWaterfall(); clearWaterfall(rxWaterfallCtx, rxWaterfallCanvas); }); rxWaterfallResizeObserver.observe(rxWaterfallCanvas.parentElement); } } function initDecodeWaterfall() { decodeWaterfallCanvas = document.getElementById('subghzDecodeWaterfall'); if (!decodeWaterfallCanvas) return; decodeWaterfallCtx = decodeWaterfallCanvas.getContext('2d'); decodeWaterfallPalette = decodeWaterfallPalette || buildWaterfallPalette(); resizeDecodeWaterfall(); clearWaterfall(decodeWaterfallCtx, decodeWaterfallCanvas); if (!decodeWaterfallResizeObserver && decodeWaterfallCanvas.parentElement) { decodeWaterfallResizeObserver = new ResizeObserver(() => { resizeDecodeWaterfall(); clearWaterfall(decodeWaterfallCtx, decodeWaterfallCanvas); }); decodeWaterfallResizeObserver.observe(decodeWaterfallCanvas.parentElement); } } function resizeRxScope() { if (!rxScopeCanvas || !rxScopeCanvas.parentElement) return; const rect = rxScopeCanvas.parentElement.getBoundingClientRect(); rxScopeCanvas.width = Math.max(10, rect.width); rxScopeCanvas.height = Math.max(10, rect.height); } function resizeDecodeScope() { if (!decodeScopeCanvas || !decodeScopeCanvas.parentElement) return; const rect = decodeScopeCanvas.parentElement.getBoundingClientRect(); decodeScopeCanvas.width = Math.max(10, rect.width); decodeScopeCanvas.height = Math.max(10, rect.height); } function resizeRxWaterfall() { if (!rxWaterfallCanvas || !rxWaterfallCanvas.parentElement) return; const rect = rxWaterfallCanvas.parentElement.getBoundingClientRect(); rxWaterfallCanvas.width = Math.max(10, rect.width); rxWaterfallCanvas.height = Math.max(10, rect.height); } function resizeDecodeWaterfall() { if (!decodeWaterfallCanvas || !decodeWaterfallCanvas.parentElement) return; const rect = decodeWaterfallCanvas.parentElement.getBoundingClientRect(); decodeWaterfallCanvas.width = Math.max(10, rect.width); decodeWaterfallCanvas.height = Math.max(10, rect.height); } function updateRxLevel(level) { updateLevel('subghzRxLevel', level); } function updateDecodeLevel(level) { updateLevel('subghzDecodeLevel', level); } function updateRxWaveform(samples) { if (!Array.isArray(samples)) return; if (!rxScopeCanvas) initRxScope(); rxScopeData = samples; drawRxScope(); } function updateDecodeWaveform(samples) { if (!Array.isArray(samples)) return; if (!decodeScopeCanvas) initDecodeScope(); decodeScopeData = samples; drawDecodeScope(); } function updateRxSpectrum(bins) { if (!Array.isArray(bins) || !bins.length) return; if (rxWaterfallPaused) return; if (!rxWaterfallCanvas) initRxWaterfall(); drawWaterfallRow(rxWaterfallCtx, rxWaterfallCanvas, rxWaterfallPalette, bins, rxWaterfallFloor, rxWaterfallRange); } function updateDecodeSpectrum(bins) { if (!Array.isArray(bins) || !bins.length) return; if (!decodeWaterfallCanvas) initDecodeWaterfall(); drawWaterfallRow(decodeWaterfallCtx, decodeWaterfallCanvas, decodeWaterfallPalette, bins, rxWaterfallFloor, rxWaterfallRange); } function drawRxScope() { drawScope(rxScopeCtx, rxScopeCanvas, rxScopeData); } function drawDecodeScope() { drawScope(decodeScopeCtx, decodeScopeCanvas, decodeScopeData); } function buildWaterfallPalette() { const stops = [ { v: 0, c: [7, 11, 18] }, { v: 64, c: [11, 42, 111] }, { v: 128, c: [0, 212, 255] }, { v: 192, c: [255, 170, 0] }, { v: 255, c: [255, 255, 255] }, ]; const palette = new Array(256); for (let i = 0; i < stops.length - 1; i++) { const a = stops[i]; const b = stops[i + 1]; const span = b.v - a.v; for (let v = a.v; v <= b.v; v++) { const t = span === 0 ? 0 : (v - a.v) / span; const r = Math.round(a.c[0] + (b.c[0] - a.c[0]) * t); const g = Math.round(a.c[1] + (b.c[1] - a.c[1]) * t); const bch = Math.round(a.c[2] + (b.c[2] - a.c[2]) * t); palette[v] = [r, g, bch]; } } return palette; } function drawScope(ctx, canvas, data) { if (!ctx || !canvas) return; const w = canvas.width; const h = canvas.height; ctx.clearRect(0, 0, w, h); ctx.fillStyle = '#0d1117'; ctx.fillRect(0, 0, w, h); ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; ctx.lineWidth = 1; for (let i = 1; i < 4; i++) { const y = (h / 4) * i; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; ctx.beginPath(); ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2); ctx.stroke(); if (!data || !data.length) return; let peak = 0; for (let i = 0; i < data.length; i++) { const abs = Math.abs(Number(data[i]) || 0); if (abs > peak) peak = abs; } // Auto-scale low-amplitude noise/signal so activity is visible. const gain = peak > 0 ? Math.min(12, 0.92 / peak) : 1; ctx.strokeStyle = '#00d4ff'; ctx.lineWidth = 1.5; ctx.beginPath(); const n = data.length; if (n === 1) { const v = Math.max(-1, Math.min(1, (Number(data[0]) || 0) * gain)); const y = (0.5 - (v / 2)) * h; ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); return; } for (let i = 0; i < n; i++) { const x = (i / (n - 1)) * w; const v = Math.max(-1, Math.min(1, (Number(data[i]) || 0) * gain)); const y = (0.5 - (v / 2)) * h; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); } function clearWaterfall(ctx, canvas) { if (!ctx || !canvas) return; ctx.fillStyle = '#0d1117'; ctx.fillRect(0, 0, canvas.width, canvas.height); } function drawWaterfallRow(ctx, canvas, palette, bins, floor, range) { if (!ctx || !canvas) return; const w = canvas.width; const h = canvas.height; if (h < 2 || w < 2) return; // Shift image down by 1px ctx.drawImage(canvas, 0, 0, w, h - 1, 0, 1, w, h - 1); const row = ctx.createImageData(w, 1); const data = row.data; const paletteRef = palette || buildWaterfallPalette(); for (let x = 0; x < w; x++) { const idx = Math.floor((x / (w - 1)) * (bins.length - 1)); const raw = Math.max(0, Math.min(255, bins[idx] || 0)); const rangeVal = Math.max(16, range || 180); const normalized = Math.max(0, Math.min(1, (raw - (floor || 0)) / rangeVal)); const val = Math.round(normalized * 255); const c = paletteRef[val] || [0, 0, 0]; const offset = x * 4; data[offset] = c[0]; data[offset + 1] = c[1]; data[offset + 2] = c[2]; data[offset + 3] = 255; } ctx.putImageData(row, 0, 0); } function updateLevel(id, level) { const el = document.getElementById(id); if (!el) return; const clamped = Math.max(0, Math.min(100, Number(level) || 0)); // Boost low-level values so weak-but-real activity is visible. const boosted = clamped <= 0 ? 0 : Math.min(100, Math.round(Math.sqrt(clamped / 100) * 100)); el.style.width = boosted + '%'; } function syncWaterfallControls() { const floorEl = document.getElementById('subghzWfFloor'); const rangeEl = document.getElementById('subghzWfRange'); const floorVal = document.getElementById('subghzWfFloorVal'); const rangeVal = document.getElementById('subghzWfRangeVal'); const pauseBtn = document.getElementById('subghzWfPauseBtn'); if (floorEl) floorEl.value = rxWaterfallFloor; if (rangeEl) rangeEl.value = rxWaterfallRange; if (floorVal) floorVal.textContent = String(rxWaterfallFloor); if (rangeVal) rangeVal.textContent = String(rxWaterfallRange); if (pauseBtn) { pauseBtn.textContent = rxWaterfallPaused ? 'RESUME' : 'PAUSE'; pauseBtn.classList.toggle('paused', rxWaterfallPaused); } } function setWaterfallFloor(value) { const next = Math.max(0, Math.min(200, parseInt(value, 10) || 0)); rxWaterfallFloor = next; syncWaterfallControls(); } function setWaterfallRange(value) { const next = Math.max(16, Math.min(255, parseInt(value, 10) || 180)); rxWaterfallRange = next; syncWaterfallControls(); } function toggleWaterfall() { rxWaterfallPaused = !rxWaterfallPaused; syncWaterfallControls(); } function updateRxStats(stats) { const sizeEl = document.getElementById('subghzRxFileSize'); const rateEl = document.getElementById('subghzRxRate'); if (sizeEl) sizeEl.textContent = formatBytes(stats.file_size || 0); if (rateEl) rateEl.textContent = (stats.rate_kb ? stats.rate_kb.toFixed(1) : '0') + ' KB/s'; } function resetRxVisuals() { rxScopeData = []; updateRxLevel(0); drawRxScope(); clearWaterfall(rxWaterfallCtx, rxWaterfallCanvas); updateRxStats({ file_size: 0, rate_kb: 0 }); updateRxHint('', 0, ''); } function resetDecodeVisuals() { decodeScopeData = []; updateDecodeLevel(0); drawDecodeScope(); clearWaterfall(decodeWaterfallCtx, decodeWaterfallCanvas); } // ------ STATUS ------ function updateStatusUI(data) { const dot = document.getElementById('subghzStatusDot'); const text = document.getElementById('subghzStatusText'); const timer = document.getElementById('subghzStatusTimer'); const mode = data.mode || 'idle'; currentMode = mode; if (dot) { dot.className = 'subghz-status-dot'; if (mode !== 'idle') dot.classList.add(mode); } const labels = { idle: 'Idle', rx: 'Capturing', decode: 'Decoding', tx: 'Transmitting', sweep: 'Sweeping' }; if (text) text.textContent = labels[mode] || mode; if (timer && data.elapsed_seconds) { timer.textContent = formatDuration(data.elapsed_seconds); } else if (timer) { timer.textContent = ''; } // Toggle sidebar buttons toggleButtons(mode); // Update stats strip updateStatsStrip(mode); // RX recording indicator const rec = document.getElementById('subghzRxRecording'); if (rec) rec.style.display = (mode === 'rx') ? 'flex' : 'none'; if (mode === 'idle') { if (burstBadgeTimer) { clearTimeout(burstBadgeTimer); burstBadgeTimer = null; } setBurstIndicator('idle', 'NO BURST'); setRxBurstPill('idle', 'IDLE'); updateRxHint('', 0, ''); setBurstCanvasHighlight('rx', false); setBurstCanvasHighlight('decode', false); } if (activePanel === 'tx') { updateTxPanelState(mode === 'tx'); } } function toggleButtons(mode) { const setEnabled = (id, enabled) => { const el = document.getElementById(id); if (!el) return; el.disabled = !enabled; el.classList.toggle('disabled', !enabled); }; const enableMap = [ ['subghzRxStartBtn', mode === 'idle'], ['subghzRxStopBtn', mode === 'rx'], ['subghzRxStartBtnPanel', mode === 'idle'], ['subghzRxStopBtnPanel', mode === 'rx'], ['subghzSweepStartBtn', mode === 'idle'], ['subghzSweepStopBtn', mode === 'sweep'], ['subghzSweepStartBtnPanel', mode === 'idle'], ['subghzSweepStopBtnPanel', mode === 'sweep'], ]; for (const [id, enabled] of enableMap) { setEnabled(id, enabled); } } function formatDuration(seconds) { const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return m > 0 ? `${m}m ${s}s` : `${s}s`; } function formatBytes(bytes) { const sizes = ['B', 'KB', 'MB', 'GB']; let val = Math.max(0, Number(bytes) || 0); let idx = 0; while (val >= 1024 && idx < sizes.length - 1) { val /= 1024; idx += 1; } const fixed = idx === 0 ? 0 : 1; return `${val.toFixed(fixed)} ${sizes[idx]}`; } function startStatusTimer() { rxStartTime = Date.now(); if (statusTimer) clearInterval(statusTimer); statusTimer = setInterval(() => { const elapsed = (Date.now() - rxStartTime) / 1000; const formatted = formatDuration(elapsed); // Update sidebar timer const timer = document.getElementById('subghzStatusTimer'); if (timer) timer.textContent = formatted; // Update stats strip timer const stripTimer = document.getElementById('subghzStripTimer'); if (stripTimer) stripTimer.textContent = formatted; // Update TX elapsed if TX panel is active if (currentMode === 'tx') { const txElapsed = document.getElementById('subghzTxElapsed'); if (txElapsed) txElapsed.textContent = formatted; } }, 1000); } function stopStatusTimer() { if (statusTimer) { clearInterval(statusTimer); statusTimer = null; } rxStartTime = null; const timer = document.getElementById('subghzStatusTimer'); if (timer) timer.textContent = ''; const stripTimer = document.getElementById('subghzStripTimer'); if (stripTimer) stripTimer.textContent = ''; } // ------ RECEIVE ------ function startRx() { const params = getParams(); fetch('/subghz/receive/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params), }) .then(r => r.json()) .then(data => { if (data.status === 'started') { updateStatusUI({ mode: 'rx' }); startStatusTimer(); showPanel('rx'); updateRxDisplay(params); initRxScope(); initRxWaterfall(); syncWaterfallControls(); resetRxVisuals(); showConsole(); addConsoleEntry('RX capture started at ' + (params.frequency_hz / 1e6).toFixed(3) + ' MHz', 'info'); if (params.trigger_enabled) { const pre = Number(params.trigger_pre_ms || 0); const post = Number(params.trigger_post_ms || 0); addConsoleEntry( `Smart trigger armed (pre ${pre}ms / post ${post}ms)`, 'info' ); } updatePhaseIndicator('tuning'); setTimeout(() => updatePhaseIndicator('listening'), 500); } else { addConsoleEntry(data.message || 'Failed to start capture', 'error'); alert(data.message || 'Failed to start capture'); } }) .catch(err => alert('Error: ' + err.message)); } function stopRx() { fetch('/subghz/receive/stop', { method: 'POST' }) .then(r => r.json()) .then(data => { updateStatusUI({ mode: 'idle' }); stopStatusTimer(); resetRxVisuals(); addConsoleEntry('Capture stopped', 'warn'); updatePhaseIndicator(null); loadCaptures(); }) .catch(err => alert('Error: ' + err.message)); } // ------ DECODE ------ function startDecode() { const params = getParams(); clearDecodeOutput(); fetch('/subghz/decode/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params), }) .then(r => r.json()) .then(data => { if (data.status === 'started') { updateStatusUI({ mode: 'decode' }); showPanel('decode'); showConsole(); initDecodeScope(); initDecodeWaterfall(); resetDecodeVisuals(); addConsoleEntry('Decode started at ' + (params.frequency_hz / 1e6).toFixed(3) + ' MHz', 'info'); addConsoleEntry('[decode] Profile: ' + (params.decode_profile || 'weather'), 'info'); if (data.sample_rate && Number(data.sample_rate) !== Number(params.sample_rate)) { addConsoleEntry( '[decode] Sample rate adjusted to ' + (data.sample_rate / 1000).toFixed(0) + ' kHz for stability', 'info' ); } updatePhaseIndicator('tuning'); setTimeout(() => updatePhaseIndicator('listening'), 800); } else { addConsoleEntry(data.message || 'Failed to start decode', 'error'); alert(data.message || 'Failed to start decode'); } }) .catch(err => alert('Error: ' + err.message)); } function stopDecode() { fetch('/subghz/decode/stop', { method: 'POST' }) .then(r => r.json()) .then(() => { updateStatusUI({ mode: 'idle' }); addConsoleEntry('Decode stopped', 'warn'); resetDecodeVisuals(); updatePhaseIndicator(null); }) .catch(err => alert('Error: ' + err.message)); } function clearDecodeOutput() { const el = document.getElementById('subghzDecodeOutput'); if (el) el.innerHTML = '
Waiting for signals...
'; lastRawLine = ''; lastRawLineTs = 0; lastBurstLineTs = 0; } function appendDecodeEntry(data) { const el = document.getElementById('subghzDecodeOutput'); if (!el) return; // Remove empty placeholder const empty = el.querySelector('.subghz-empty'); if (empty) empty.remove(); const entry = document.createElement('div'); entry.className = 'subghz-decode-entry'; const model = data.model || 'Unknown'; const isRaw = model.toLowerCase() === 'raw'; if (isRaw) { const rawText = String(data.text || '').trim(); const now = Date.now(); if (rawText && rawText === lastRawLine && (now - lastRawLineTs) < 2500) { return; } lastRawLine = rawText; lastRawLineTs = now; } if (isRaw) { entry.classList.add('is-raw'); } let html = `${escapeHtml(model)}`; if (isRaw && typeof data.text === 'string') { html += `: ${escapeHtml(data.text)}`; } else { const skipKeys = ['type', 'model', 'time', 'mic']; for (const [key, value] of Object.entries(data)) { if (skipKeys.includes(key)) continue; html += `${escapeHtml(key)}: ${escapeHtml(String(value))} `; } } entry.innerHTML = html; el.appendChild(entry); el.scrollTop = el.scrollHeight; while (el.children.length > 200) { el.removeChild(el.firstChild); } // Dashboard updates if (!isRaw) { signalCount++; updateStatsStrip('decode'); addConsoleEntry('Signal: ' + model, 'success'); } updatePhaseIndicator('decoding'); } function setBurstCanvasHighlight(mode, active) { const targets = mode === 'decode' ? ['subghzDecodeScope', 'subghzDecodeWaterfall'] : ['subghzRxScope', 'subghzRxWaterfall']; for (const id of targets) { const canvas = document.getElementById(id); const host = canvas?.parentElement; if (host) host.classList.toggle('burst-active', !!active); } } function setBurstIndicator(state, text) { const badge = document.getElementById('subghzBurstIndicator'); const label = document.getElementById('subghzBurstText'); if (!badge || !label) return; badge.classList.remove('active', 'recent'); if (state === 'active') badge.classList.add('active'); if (state === 'recent') badge.classList.add('recent'); label.textContent = text || 'NO BURST'; } function setRxBurstPill(state, text) { const pill = document.getElementById('subghzRxBurstPill'); if (!pill) return; pill.classList.remove('active', 'recent'); if (state === 'active') pill.classList.add('active'); if (state === 'recent') pill.classList.add('recent'); pill.textContent = text || 'IDLE'; } function updateRxHint(hint, confidence, protocolHint) { const textEl = document.getElementById('subghzRxHintText'); const confEl = document.getElementById('subghzRxHintConfidence'); if (textEl) { if (hint) { textEl.textContent = protocolHint ? `${hint} - ${protocolHint}` : hint; } else { textEl.textContent = 'No modulation hint yet'; } } if (confEl) { if (typeof confidence === 'number' && confidence > 0) { confEl.textContent = `${Math.round(confidence * 100)}%`; } else { confEl.textContent = '--'; } } } function clearBurstIndicatorLater(delayMs) { if (burstBadgeTimer) clearTimeout(burstBadgeTimer); burstBadgeTimer = setTimeout(() => { setBurstIndicator('idle', 'NO BURST'); setRxBurstPill('idle', 'IDLE'); setBurstCanvasHighlight('rx', false); setBurstCanvasHighlight('decode', false); }, delayMs); } function handleRxBurst(data) { if (!data) return; const mode = data.mode === 'decode' ? 'decode' : 'rx'; if (data.event === 'start') { const startOffset = Math.max(0, Number(data.start_offset_s || 0)); setBurstIndicator('active', `LIVE ${mode.toUpperCase()} +${startOffset.toFixed(2)}s`); if (mode === 'rx') setRxBurstPill('active', 'BURST'); setBurstCanvasHighlight(mode, true); if (burstBadgeTimer) { clearTimeout(burstBadgeTimer); burstBadgeTimer = null; } return; } if (data.event !== 'end') return; const now = Date.now(); if ((now - lastBurstLineTs) < 250) return; lastBurstLineTs = now; const durationMs = Math.max(0, parseInt(data.duration_ms || 0, 10) || 0); const peakLevel = Math.max(0, Math.min(100, parseInt(data.peak_level || 0, 10) || 0)); const startOffset = Math.max(0, Number(data.start_offset_s || 0)); const modHint = typeof data.modulation_hint === 'string' ? data.modulation_hint.trim() : ''; const fp = typeof data.fingerprint === 'string' ? data.fingerprint.trim() : ''; const extras = [ modHint ? modHint : '', fp ? `fp ${fp.slice(0, 8)}` : '', ].filter(Boolean).join(' • '); const burstMsg = `RF burst ${durationMs} ms @ +${startOffset.toFixed(2)}s (peak ${peakLevel}%)${extras ? ' - ' + extras : ''}`; setBurstCanvasHighlight(mode, false); setBurstIndicator('recent', `${durationMs}ms - ${peakLevel}%`); if (mode === 'rx') setRxBurstPill('recent', `${durationMs}ms`); clearBurstIndicatorLater(2200); addConsoleEntry(`[${mode}] ${burstMsg}`, 'success'); if (mode === 'decode') { appendDecodeEntry({ model: 'RF Burst', duration_ms: durationMs, peak_level: `${peakLevel}%`, offset_s: startOffset.toFixed(2), }); } } // ------ TRANSMIT ------ function estimateCaptureDurationSeconds(capture) { if (!capture) return 0; const direct = Number(capture.duration_seconds || 0); if (direct > 0) return direct; const sr = Number(capture.sample_rate || 0); const size = Number(capture.size_bytes || 0); if (sr > 0 && size > 0) return size / (sr * 2); return 0; } function syncTxSegmentSelection(changedField) { const startEl = document.getElementById('subghzTxSegmentStart'); const endEl = document.getElementById('subghzTxSegmentEnd'); const enabledEl = document.getElementById('subghzTxSegmentEnabled'); const summaryEl = document.getElementById('subghzTxSegmentSummary'); const totalEl = document.getElementById('subghzTxModalDuration'); const total = estimateCaptureDurationSeconds(pendingTxCaptureMeta); const segmentEnabled = !!enabledEl?.checked && total > 0; if (startEl) startEl.disabled = !segmentEnabled; if (endEl) endEl.disabled = !segmentEnabled; if (!segmentEnabled) { if (summaryEl) summaryEl.textContent = `Full capture (${total.toFixed(3)} s)`; return; } let start = Math.max(0, Number(startEl?.value || 0)); let end = Math.max(0, Number(endEl?.value || total)); if (changedField === 'start' && end <= start) end = Math.min(total, start + 0.05); if (changedField === 'end' && end <= start) start = Math.max(0, end - 0.05); start = Math.max(0, Math.min(total, start)); end = Math.max(start + 0.01, Math.min(total, end)); if (startEl) startEl.value = start.toFixed(3); if (endEl) endEl.value = end.toFixed(3); if (totalEl) totalEl.textContent = `${total.toFixed(3)} s`; if (summaryEl) summaryEl.textContent = `Segment ${start.toFixed(3)}s - ${end.toFixed(3)}s (${(end - start).toFixed(3)} s)`; } function applyTxBurstSegment(startSeconds, durationSeconds, paddingSeconds) { const total = estimateCaptureDurationSeconds(pendingTxCaptureMeta); if (total <= 0) return; const pad = Math.max(0, Number(paddingSeconds || 0)); const start = Math.max(0, Number(startSeconds || 0) - pad); const end = Math.min(total, Number(startSeconds || 0) + Number(durationSeconds || 0) + pad); const enabledEl = document.getElementById('subghzTxSegmentEnabled'); const startEl = document.getElementById('subghzTxSegmentStart'); const endEl = document.getElementById('subghzTxSegmentEnd'); if (enabledEl) enabledEl.checked = true; if (startEl) startEl.value = start.toFixed(3); if (endEl) endEl.value = end.toFixed(3); syncTxSegmentSelection('end'); } function setTxTimelineRangeText(text) { const rangeEl = document.getElementById('subghzTxBurstRange'); if (rangeEl) rangeEl.textContent = text; } function bindTxTimelineEditor(timeline, totalSeconds) { if (!timeline || totalSeconds <= 0) return; const selection = timeline.querySelector('.subghz-tx-burst-selection'); if (!selection) return; timeline.onmousedown = (event) => { if (event.button !== 0) return; if (event.target?.classList?.contains('subghz-tx-burst-marker')) return; const rect = timeline.getBoundingClientRect(); const startPx = Math.max(0, Math.min(rect.width, event.clientX - rect.left)); txTimelineDragState = { rect, startPx, currentPx: startPx }; timeline.classList.add('dragging'); selection.style.display = ''; selection.style.left = `${startPx}px`; selection.style.width = '1px'; setTxTimelineRangeText('Drag to define TX segment'); event.preventDefault(); }; const onMove = (event) => { if (!txTimelineDragState) return; const { rect, startPx } = txTimelineDragState; const currentPx = Math.max(0, Math.min(rect.width, event.clientX - rect.left)); txTimelineDragState.currentPx = currentPx; const left = Math.min(startPx, currentPx); const width = Math.max(1, Math.abs(currentPx - startPx)); selection.style.left = `${left}px`; selection.style.width = `${width}px`; const startSec = (left / rect.width) * totalSeconds; const endSec = ((left + width) / rect.width) * totalSeconds; setTxTimelineRangeText( `Selected ${startSec.toFixed(3)}s - ${endSec.toFixed(3)}s (${(endSec - startSec).toFixed(3)}s)` ); }; const onUp = () => { if (!txTimelineDragState) return; const { rect, startPx, currentPx } = txTimelineDragState; txTimelineDragState = null; timeline.classList.remove('dragging'); const left = Math.min(startPx, currentPx); const right = Math.max(startPx, currentPx); const startSec = (left / rect.width) * totalSeconds; const endSec = (right / rect.width) * totalSeconds; const minSpanSeconds = Math.max(0.01, totalSeconds * 0.0025); if ((endSec - startSec) >= minSpanSeconds) { applyTxBurstSegment(startSec, endSec - startSec, 0.0); setTxTimelineRangeText( `Segment ${startSec.toFixed(3)}s - ${endSec.toFixed(3)}s (${(endSec - startSec).toFixed(3)}s)` ); } else { selection.style.display = 'none'; setTxTimelineRangeText('Drag on timeline to select TX segment'); } }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); timeline.onmouseleave = () => {}; timeline.dataset.editorBound = '1'; timeline._txEditorCleanup = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; } function renderTxBurstAssist(capture) { const section = document.getElementById('subghzTxBurstAssist'); const timeline = document.getElementById('subghzTxBurstTimeline'); const list = document.getElementById('subghzTxBurstList'); if (!section || !timeline || !list) return; if (typeof timeline._txEditorCleanup === 'function') { timeline._txEditorCleanup(); timeline._txEditorCleanup = null; } pendingTxBursts = Array.isArray(capture?.bursts) ? capture.bursts .map(b => ({ start_seconds: Math.max(0, Number(b.start_seconds || 0)), duration_seconds: Math.max(0, Number(b.duration_seconds || 0)), peak_level: Math.max(0, Math.min(100, Number(b.peak_level || 0))), modulation_hint: typeof b.modulation_hint === 'string' ? b.modulation_hint : '', modulation_confidence: Math.max(0, Math.min(1, Number(b.modulation_confidence || 0))), fingerprint: typeof b.fingerprint === 'string' ? b.fingerprint : '', })) .filter(b => b.duration_seconds > 0) .sort((a, b) => a.start_seconds - b.start_seconds) : []; timeline.innerHTML = ''; list.innerHTML = ''; timeline.classList.remove('dragging'); const selection = document.createElement('div'); selection.className = 'subghz-tx-burst-selection'; timeline.appendChild(selection); const total = estimateCaptureDurationSeconds(capture); setTxTimelineRangeText('Drag on timeline to select TX segment'); if (!pendingTxBursts.length || total <= 0) { section.style.display = ''; const empty = document.createElement('div'); empty.className = 'subghz-tx-burst-empty'; empty.textContent = 'No burst markers in this capture yet. Record a fresh RAW capture to auto-mark burst timings.'; list.appendChild(empty); bindTxTimelineEditor(timeline, Math.max(0, total)); return; } section.style.display = ''; const showBursts = pendingTxBursts.slice(0, 60); for (let i = 0; i < showBursts.length; i++) { const burst = showBursts[i]; const leftPct = Math.max(0, Math.min(100, (burst.start_seconds / total) * 100)); const widthPct = Math.max(0.35, Math.min(100, (burst.duration_seconds / total) * 100)); const marker = document.createElement('button'); marker.type = 'button'; marker.className = 'subghz-tx-burst-marker'; marker.style.left = `${leftPct}%`; marker.style.width = `${widthPct}%`; marker.title = `Burst ${i + 1}: +${burst.start_seconds.toFixed(3)}s for ${burst.duration_seconds.toFixed(3)}s`; marker.addEventListener('click', () => { applyTxBurstSegment(burst.start_seconds, burst.duration_seconds, 0.06); }); timeline.appendChild(marker); const row = document.createElement('div'); row.className = 'subghz-tx-burst-item'; const text = document.createElement('span'); const burstParts = [ `#${i + 1}`, `+${burst.start_seconds.toFixed(3)}s`, `${burst.duration_seconds.toFixed(3)}s`, `peak ${burst.peak_level}%`, ]; if (burst.modulation_hint) { burstParts.push(`${burst.modulation_hint} ${Math.round(burst.modulation_confidence * 100)}%`); } if (burst.fingerprint) { burstParts.push(`fp ${burst.fingerprint.slice(0, 8)}`); } text.textContent = burstParts.join(' '); const useBtn = document.createElement('button'); useBtn.type = 'button'; useBtn.textContent = 'Use'; useBtn.addEventListener('click', () => { applyTxBurstSegment(burst.start_seconds, burst.duration_seconds, 0.06); }); row.appendChild(text); row.appendChild(useBtn); list.appendChild(row); } bindTxTimelineEditor(timeline, total); } function cleanupTxModalState(closeOverlay = true, clearCapture = true) { if (clearCapture) { pendingTxCaptureId = null; pendingTxCaptureMeta = null; } pendingTxBursts = []; txTimelineDragState = null; txModalIntent = 'tx'; const timeline = document.getElementById('subghzTxBurstTimeline'); if (timeline && typeof timeline._txEditorCleanup === 'function') { timeline._txEditorCleanup(); timeline._txEditorCleanup = null; } if (closeOverlay) { const overlay = document.getElementById('subghzTxModalOverlay'); if (overlay) overlay.classList.remove('active'); } } function pickStrongestBurstSegment(totalDuration, paddingSeconds = 0.06) { if (!Array.isArray(pendingTxBursts) || pendingTxBursts.length === 0 || totalDuration <= 0) return null; const strongest = pendingTxBursts .slice() .sort((a, b) => { const peakDiff = Number(b.peak_level || 0) - Number(a.peak_level || 0); if (peakDiff !== 0) return peakDiff; return Number(b.duration_seconds || 0) - Number(a.duration_seconds || 0); })[0]; const startRaw = Number(strongest?.start_seconds || 0); const durRaw = Number(strongest?.duration_seconds || 0); if (durRaw <= 0) return null; const start = Math.max(0, startRaw - paddingSeconds); const end = Math.min(totalDuration, startRaw + durRaw + paddingSeconds); if (end <= start) return null; return { start_seconds: Number(start.toFixed(3)), duration_seconds: Number((end - start).toFixed(3)), }; } function populateTxModalFromCapture(capture) { if (!capture) return; pendingTxCaptureMeta = capture; const freqMhz = (Number(capture.frequency_hz || 0) / 1000000).toFixed(3); const freqEl = document.getElementById('subghzTxModalFreq'); if (freqEl) freqEl.textContent = freqMhz + ' MHz'; const total = estimateCaptureDurationSeconds(capture); const durationEl = document.getElementById('subghzTxModalDuration'); if (durationEl) durationEl.textContent = `${total.toFixed(3)} s`; const enabledEl = document.getElementById('subghzTxSegmentEnabled'); const startEl = document.getElementById('subghzTxSegmentStart'); const endEl = document.getElementById('subghzTxSegmentEnd'); if (enabledEl) enabledEl.checked = false; if (startEl) { startEl.value = '0.000'; startEl.min = '0'; startEl.max = total.toFixed(3); startEl.step = '0.01'; } if (endEl) { endEl.value = total.toFixed(3); endEl.min = '0'; endEl.max = total.toFixed(3); endEl.step = '0.01'; } syncTxSegmentSelection(); renderTxBurstAssist(capture); if (txModalIntent === 'trim') { if (enabledEl) enabledEl.checked = true; const auto = pickStrongestBurstSegment(total, 0.06); if (auto) { applyTxBurstSegment(auto.start_seconds, auto.duration_seconds, 0); setTxTimelineRangeText( `Trim target ${auto.start_seconds.toFixed(3)}s - ${(auto.start_seconds + auto.duration_seconds).toFixed(3)}s` ); } else { syncTxSegmentSelection(); setTxTimelineRangeText('Select a segment, then click Trim + Save'); } } } function getModalTxSegment(options = {}) { const allowAutoBurst = options.allowAutoBurst === true; const requireSelection = options.requireSelection === true; const totalDuration = estimateCaptureDurationSeconds(pendingTxCaptureMeta); if (totalDuration <= 0) { return { error: 'Capture duration unavailable' }; } const segmentEnabled = !!document.getElementById('subghzTxSegmentEnabled')?.checked; if (segmentEnabled) { const startVal = Number(document.getElementById('subghzTxSegmentStart')?.value || 0); const endVal = Number(document.getElementById('subghzTxSegmentEnd')?.value || 0); const startSeconds = Math.max(0, Math.min(totalDuration, startVal)); const endSeconds = Math.max(0, Math.min(totalDuration, endVal)); const durationSeconds = endSeconds - startSeconds; if (durationSeconds <= 0) { return { error: 'Segment end must be greater than start' }; } return { start_seconds: Number(startSeconds.toFixed(3)), duration_seconds: Number(durationSeconds.toFixed(3)), source: 'manual', }; } if (allowAutoBurst) { const auto = pickStrongestBurstSegment(totalDuration, 0.06); if (auto) { return { ...auto, source: 'auto-burst' }; } } if (requireSelection) { return { error: 'Select a segment on the timeline first' }; } return null; } function buildTxRequest(captureId, segment) { const txGain = parseInt(document.getElementById('subghzTxGain')?.value || '20', 10); const maxDuration = parseInt(document.getElementById('subghzTxMaxDuration')?.value || '10', 10); const serial = (document.getElementById('subghzDeviceSerial')?.value || '').trim(); const body = { capture_id: captureId, tx_gain: txGain, max_duration: maxDuration, }; if (segment && Number(segment.duration_seconds || 0) > 0) { body.start_seconds = Number(segment.start_seconds.toFixed(3)); body.duration_seconds = Number(segment.duration_seconds.toFixed(3)); } if (serial) body.device_serial = serial; return body; } function transmitWithBody(body, logMessage, logLevel) { const txGain = Number(body.tx_gain || 0); showPanel('tx'); updateTxPanelState(true); showConsole(); addConsoleEntry(logMessage || 'Preparing transmission...', logLevel || 'warn'); fetch('/subghz/transmit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) .then(r => r.json()) .then(data => { if (data.status === 'transmitting') { lastTxCaptureId = body.capture_id; const txSegment = data.segment && typeof data.segment === 'object' ? { start_seconds: Number(data.segment.start_seconds || 0), duration_seconds: Number(data.segment.duration_seconds || 0), } : (typeof body.start_seconds === 'number' && typeof body.duration_seconds === 'number' ? { start_seconds: body.start_seconds, duration_seconds: body.duration_seconds } : null); lastTxRequest = { capture_id: body.capture_id }; if (txSegment && txSegment.duration_seconds > 0) { lastTxRequest.start_seconds = Number(txSegment.start_seconds.toFixed(3)); lastTxRequest.duration_seconds = Number(txSegment.duration_seconds.toFixed(3)); } updateStatusUI({ mode: 'tx' }); updateTxPanelState(true); startStatusTimer(); addConsoleEntry('Transmitting on ' + ((data.frequency_hz || 0) / 1e6).toFixed(3) + ' MHz', 'warn'); if (txSegment && txSegment.duration_seconds > 0) { addConsoleEntry( `TX segment ${txSegment.start_seconds.toFixed(3)}s + ${txSegment.duration_seconds.toFixed(3)}s`, 'info' ); } const freqDisplay = document.getElementById('subghzTxFreqDisplay'); const gainDisplay = document.getElementById('subghzTxGainDisplay'); if (freqDisplay && data.frequency_hz) freqDisplay.textContent = (data.frequency_hz / 1e6).toFixed(3) + ' MHz'; if (gainDisplay) gainDisplay.textContent = txGain + ' dB'; } else { updateTxPanelState(false); addConsoleEntry(data.message || 'TX failed', 'error'); alert(data.message || 'TX failed'); } }) .catch(err => { updateTxPanelState(false); alert('TX error: ' + err.message); }); } function showTxConfirm(captureId, intent) { txModalIntent = intent === 'trim' ? 'trim' : 'tx'; pendingTxCaptureId = captureId; pendingTxCaptureMeta = null; pendingTxBursts = []; const burstAssist = document.getElementById('subghzTxBurstAssist'); if (burstAssist) burstAssist.style.display = 'none'; const overlay = document.getElementById('subghzTxModalOverlay'); if (overlay) overlay.classList.add('active'); fetch(`/subghz/captures/${encodeURIComponent(captureId)}`) .then(r => r.json()) .then(data => { if (data.capture) { populateTxModalFromCapture(data.capture); } else { throw new Error('Capture not found'); } }) .catch(() => { const durationEl = document.getElementById('subghzTxModalDuration'); if (durationEl) durationEl.textContent = '--'; const summaryEl = document.getElementById('subghzTxSegmentSummary'); if (summaryEl) summaryEl.textContent = 'Segment controls unavailable'; const burstAssistEl = document.getElementById('subghzTxBurstAssist'); if (burstAssistEl) burstAssistEl.style.display = 'none'; }); } function showTrimCapture(captureId) { showTxConfirm(captureId, 'trim'); } function cancelTx() { cleanupTxModalState(true, true); } function confirmTx() { if (!pendingTxCaptureId) return; const segment = getModalTxSegment({ allowAutoBurst: false, requireSelection: false }); if (segment && segment.error) { alert(segment.error); return; } const body = buildTxRequest(pendingTxCaptureId, segment); cleanupTxModalState(true, true); transmitWithBody(body, 'Preparing transmission...', 'warn'); } function trimCaptureSelection() { if (!pendingTxCaptureId) return; const segment = getModalTxSegment({ allowAutoBurst: true, requireSelection: true }); if (!segment || segment.error) { alert(segment?.error || 'Select a segment before trimming'); return; } const trimBtn = document.getElementById('subghzTxTrimBtn'); const originalText = trimBtn?.textContent || 'Trim + Save'; if (trimBtn) { trimBtn.disabled = true; trimBtn.textContent = 'Trimming...'; } fetch(`/subghz/captures/${encodeURIComponent(pendingTxCaptureId)}/trim`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ start_seconds: segment.start_seconds, duration_seconds: segment.duration_seconds, }), }) .then(async r => ({ ok: r.ok, data: await r.json() })) .then(({ ok, data }) => { if (!ok || data.status === 'error') { throw new Error(data.message || 'Trim failed'); } if (!data.capture) { throw new Error('Trim completed but capture metadata missing'); } pendingTxCaptureId = data.capture.id; txModalIntent = 'tx'; populateTxModalFromCapture(data.capture); loadCaptures(); addConsoleEntry( `Trimmed capture saved (${segment.duration_seconds.toFixed(3)}s).`, 'success' ); }) .catch(err => { alert('Trim failed: ' + err.message); }) .finally(() => { if (trimBtn) { trimBtn.disabled = false; trimBtn.textContent = originalText; } }); } function stopTx() { fetch('/subghz/transmit/stop', { method: 'POST' }) .then(r => r.json()) .then(() => { finalizeTxUi('Transmission stopped'); }) .catch(err => alert('Error: ' + err.message)); } function finalizeTxUi(message) { updateStatusUI({ mode: 'idle' }); updateTxPanelState(false); stopStatusTimer(); if (message) addConsoleEntry(message, 'info'); updatePhaseIndicator(null); loadCaptures(); } function updateTxPanelState(transmitting) { const txDisplay = document.getElementById('subghzTxDisplay'); const label = document.getElementById('subghzTxStateLabel'); const stopBtn = document.getElementById('subghzTxStopBtn'); const chooseBtn = document.getElementById('subghzTxChooseCaptureBtn'); const replayBtn = document.getElementById('subghzTxReplayLastBtn'); if (txDisplay) { txDisplay.classList.toggle('transmitting', !!transmitting); txDisplay.classList.toggle('idle', !transmitting); } if (label) label.textContent = transmitting ? 'TRANSMITTING' : 'READY'; if (stopBtn) { stopBtn.style.display = transmitting ? '' : 'none'; stopBtn.disabled = !transmitting; } if (chooseBtn) { chooseBtn.style.display = transmitting ? 'none' : ''; chooseBtn.disabled = !!transmitting; } if (replayBtn) { const canReplay = !!(lastTxRequest && lastTxRequest.capture_id); replayBtn.style.display = (!transmitting && canReplay) ? '' : 'none'; replayBtn.disabled = transmitting || !canReplay; } } function replayLastTx() { if (!lastTxRequest || !lastTxRequest.capture_id) { addConsoleEntry('No previous transmission capture selected yet.', 'warn'); return; } const body = buildTxRequest(lastTxRequest.capture_id, ( typeof lastTxRequest.start_seconds === 'number' && typeof lastTxRequest.duration_seconds === 'number' ) ? { start_seconds: lastTxRequest.start_seconds, duration_seconds: lastTxRequest.duration_seconds, } : null); transmitWithBody(body, 'Replaying last selected segment...', 'info'); } // ------ SWEEP ------ function startSweep() { const startMhz = parseFloat(document.getElementById('subghzSweepStart')?.value || '300'); const endMhz = parseFloat(document.getElementById('subghzSweepEnd')?.value || '928'); const serial = (document.getElementById('subghzDeviceSerial')?.value || '').trim(); sweepData = []; showPanel('sweep'); initSweepCanvas(); const body = { freq_start_mhz: startMhz, freq_end_mhz: endMhz, }; if (serial) body.device_serial = serial; fetch('/subghz/sweep/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) .then(r => r.json()) .then(data => { if (data.status === 'started') { updateStatusUI({ mode: 'sweep' }); showConsole(); addConsoleEntry('Sweep ' + startMhz + ' - ' + endMhz + ' MHz', 'info'); updatePhaseIndicator('tuning'); setTimeout(() => updatePhaseIndicator('listening'), 300); } else { addConsoleEntry(data.message || 'Failed to start sweep', 'error'); alert(data.message || 'Failed to start sweep'); } }) .catch(err => alert('Error: ' + err.message)); } function stopSweep() { fetch('/subghz/sweep/stop', { method: 'POST' }) .then(r => r.json()) .then(() => { updateStatusUI({ mode: 'idle' }); addConsoleEntry('Sweep stopped', 'warn'); updatePhaseIndicator(null); }) .catch(err => alert('Error: ' + err.message)); } function initSweepCanvas() { sweepCanvas = document.getElementById('subghzSweepCanvas'); if (!sweepCanvas) return; sweepCtx = sweepCanvas.getContext('2d'); resizeSweepCanvas(); bindSweepInteraction(); if (!sweepResizeObserver && sweepCanvas.parentElement) { sweepResizeObserver = new ResizeObserver(() => { resizeSweepCanvas(); drawSweepChart(); }); sweepResizeObserver.observe(sweepCanvas.parentElement); } } function resizeSweepCanvas() { if (!sweepCanvas || !sweepCanvas.parentElement) return; const rect = sweepCanvas.parentElement.getBoundingClientRect(); sweepCanvas.width = rect.width - 24; sweepCanvas.height = rect.height - 24; } function updateSweepChart(points) { for (const pt of points) { const idx = sweepData.findIndex(d => Math.abs(d.freq - pt.freq) < 0.01); if (idx >= 0) { sweepData[idx].power = pt.power; } else { sweepData.push(pt); } } sweepData.sort((a, b) => a.freq - b.freq); detectPeaks(); drawSweepChart(); } function detectPeaks() { if (sweepData.length < 5) { sweepPeaks = []; return; } const now = Date.now(); const candidates = []; for (let i = 2; i < sweepData.length - 2; i++) { const p = sweepData[i].power; if (p > sweepData[i - 1].power && p > sweepData[i + 1].power && p > sweepData[i - 2].power && p > sweepData[i + 2].power) { let leftMin = p, rightMin = p; for (let j = 1; j <= 20 && i - j >= 0; j++) leftMin = Math.min(leftMin, sweepData[i - j].power); for (let j = 1; j <= 20 && i + j < sweepData.length; j++) rightMin = Math.min(rightMin, sweepData[i + j].power); const prominence = p - Math.max(leftMin, rightMin); if (prominence >= 10) { candidates.push({ freq: sweepData[i].freq, power: p, prominence }); } } } candidates.sort((a, b) => b.power - a.power); sweepPeaks = candidates.slice(0, 10); for (const peak of sweepPeaks) { const existing = sweepPeakHold.find(h => Math.abs(h.freq - peak.freq) < 0.5); if (existing) { if (peak.power >= existing.power) { existing.power = peak.power; existing.ts = now; } } else { sweepPeakHold.push({ freq: peak.freq, power: peak.power, ts: now }); } } sweepPeakHold = sweepPeakHold.filter(h => now - h.ts < 5000); updatePeakList(); } function drawSweepChart() { if (!sweepCtx || !sweepCanvas || sweepData.length < 2) return; const ctx = sweepCtx; const w = sweepCanvas.width; const h = sweepCanvas.height; const pad = SWEEP_PAD; ctx.clearRect(0, 0, w, h); ctx.fillStyle = '#0d1117'; ctx.fillRect(0, 0, w, h); const freqMin = sweepData[0].freq; const freqMax = sweepData[sweepData.length - 1].freq; const powerMin = SWEEP_POWER_MIN; const powerMax = SWEEP_POWER_MAX; const chartW = w - pad.left - pad.right; const chartH = h - pad.top - pad.bottom; const freqToX = f => pad.left + ((f - freqMin) / (freqMax - freqMin)) * chartW; const powerToY = p => pad.top + chartH - ((p - powerMin) / (powerMax - powerMin)) * chartH; // Grid ctx.strokeStyle = '#1a1f2e'; ctx.lineWidth = 1; ctx.font = '10px Roboto Condensed, monospace'; ctx.fillStyle = '#666'; for (let db = powerMin; db <= powerMax; db += 20) { const y = powerToY(db); ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(w - pad.right, y); ctx.stroke(); ctx.fillText(db + ' dB', 4, y + 3); } const freqRange = freqMax - freqMin; const freqStep = freqRange > 500 ? 100 : freqRange > 200 ? 50 : freqRange > 50 ? 10 : 5; for (let f = Math.ceil(freqMin / freqStep) * freqStep; f <= freqMax; f += freqStep) { const x = freqToX(f); ctx.beginPath(); ctx.moveTo(x, pad.top); ctx.lineTo(x, h - pad.bottom); ctx.stroke(); ctx.fillText(f + '', x - 10, h - 8); } // Spectrum line ctx.beginPath(); ctx.strokeStyle = '#00d4ff'; ctx.lineWidth = 1.5; for (let i = 0; i < sweepData.length; i++) { const x = freqToX(sweepData[i].freq); const y = powerToY(sweepData[i].power); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); // Fill under curve ctx.lineTo(freqToX(freqMax), powerToY(powerMin)); ctx.lineTo(freqToX(freqMin), powerToY(powerMin)); ctx.closePath(); ctx.fillStyle = 'rgba(0, 212, 255, 0.05)'; ctx.fill(); // Peak hold dashes const now = Date.now(); ctx.strokeStyle = 'rgba(255, 170, 0, 0.4)'; ctx.lineWidth = 2; for (const hold of sweepPeakHold) { const age = (now - hold.ts) / 5000; ctx.globalAlpha = 1 - age; const x = freqToX(hold.freq); const y = powerToY(hold.power); ctx.beginPath(); ctx.moveTo(x - 6, y); ctx.lineTo(x + 6, y); ctx.stroke(); } ctx.globalAlpha = 1; // Peak markers for (const peak of sweepPeaks) { const x = freqToX(peak.freq); const y = powerToY(peak.power); ctx.fillStyle = '#ffaa00'; ctx.beginPath(); ctx.moveTo(x, y - 8); ctx.lineTo(x - 4, y - 2); ctx.lineTo(x + 4, y - 2); ctx.closePath(); ctx.fill(); ctx.font = '9px Roboto Condensed, monospace'; ctx.fillStyle = 'rgba(255, 170, 0, 0.8)'; ctx.textAlign = 'center'; ctx.fillText(peak.freq.toFixed(1), x, y - 10); } ctx.textAlign = 'start'; // Active frequency marker const activeFreq = parseFloat(document.getElementById('subghzFrequency')?.value); if (activeFreq && activeFreq >= freqMin && activeFreq <= freqMax) { const x = freqToX(activeFreq); ctx.save(); ctx.setLineDash([6, 4]); ctx.strokeStyle = 'rgba(0, 255, 136, 0.6)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, pad.top); ctx.lineTo(x, h - pad.bottom); ctx.stroke(); ctx.restore(); } // Selected frequency marker if (sweepSelectedFreq !== null && sweepSelectedFreq >= freqMin && sweepSelectedFreq <= freqMax) { const x = freqToX(sweepSelectedFreq); ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, pad.top); ctx.lineTo(x, h - pad.bottom); ctx.stroke(); } // Hover cursor line if (sweepHoverFreq !== null && sweepHoverFreq >= freqMin && sweepHoverFreq <= freqMax) { const x = freqToX(sweepHoverFreq); ctx.save(); ctx.setLineDash([3, 3]); ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, pad.top); ctx.lineTo(x, h - pad.bottom); ctx.stroke(); ctx.restore(); } } // ------ SWEEP INTERACTION ------ function bindSweepInteraction() { if (sweepInteractionBound || !sweepCanvas) return; sweepInteractionBound = true; sweepCanvas.style.cursor = 'crosshair'; if (!sweepTooltipEl) { sweepTooltipEl = document.createElement('div'); sweepTooltipEl.className = 'subghz-sweep-tooltip'; document.body.appendChild(sweepTooltipEl); } if (!sweepCtxMenuEl) { sweepCtxMenuEl = document.createElement('div'); sweepCtxMenuEl.className = 'subghz-sweep-ctx-menu'; document.body.appendChild(sweepCtxMenuEl); } sweepDismissHandler = (e) => { if (sweepCtxMenuEl && !sweepCtxMenuEl.contains(e.target)) { sweepCtxMenuEl.style.display = 'none'; } if (sweepActionBarEl && !sweepActionBarEl.contains(e.target) && e.target !== sweepCanvas) { sweepActionBarEl.classList.remove('visible'); } }; document.addEventListener('click', sweepDismissHandler); function mouseToCanvas(e) { const rect = sweepCanvas.getBoundingClientRect(); const scaleX = sweepCanvas.width / rect.width; const scaleY = sweepCanvas.height / rect.height; return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY, }; } sweepCanvas.addEventListener('mousemove', (e) => { const { x, y } = mouseToCanvas(e); const info = sweepPixelToFreqPower(x, y); if (!info.inChart || sweepData.length < 2) { sweepHoverFreq = null; sweepHoverPower = null; if (sweepTooltipEl) sweepTooltipEl.style.display = 'none'; drawSweepChart(); return; } sweepHoverFreq = info.freq; sweepHoverPower = interpolatePower(info.freq); if (sweepTooltipEl) { sweepTooltipEl.innerHTML = '' + sweepHoverFreq.toFixed(3) + ' MHz' + ' · ' + '' + sweepHoverPower.toFixed(1) + ' dB'; sweepTooltipEl.style.left = (e.clientX + 14) + 'px'; sweepTooltipEl.style.top = (e.clientY - 30) + 'px'; sweepTooltipEl.style.display = 'block'; } drawSweepChart(); }); sweepCanvas.addEventListener('mouseleave', () => { sweepHoverFreq = null; sweepHoverPower = null; if (sweepTooltipEl) sweepTooltipEl.style.display = 'none'; drawSweepChart(); }); sweepCanvas.addEventListener('click', (e) => { const { x, y } = mouseToCanvas(e); const info = sweepPixelToFreqPower(x, y); if (!info.inChart || sweepData.length < 2) return; sweepSelectedFreq = info.freq; tuneFromSweep(info.freq); showSweepActionBar(e.clientX, e.clientY, info.freq); drawSweepChart(); }); sweepCanvas.addEventListener('contextmenu', (e) => { e.preventDefault(); const { x, y } = mouseToCanvas(e); const info = sweepPixelToFreqPower(x, y); if (!info.inChart || sweepData.length < 2) return; const freq = info.freq; const freqStr = freq.toFixed(3); sweepCtxMenuEl.innerHTML = '
' + freqStr + ' MHz
' + '
Tune Here
' + '
Open RAW at ' + freqStr + ' MHz
'; sweepCtxMenuEl.style.left = e.clientX + 'px'; sweepCtxMenuEl.style.top = e.clientY + 'px'; sweepCtxMenuEl.style.display = 'block'; sweepCtxMenuEl.querySelectorAll('.subghz-ctx-item').forEach(item => { item.onclick = () => { sweepCtxMenuEl.style.display = 'none'; const action = item.dataset.action; if (action === 'tune') tuneFromSweep(freq); else if (action === 'capture') tuneAndCapture(freq); }; }); }); } // ------ SWEEP ACTIONS ------ function tuneFromSweep(freqMhz) { const el = document.getElementById('subghzFrequency'); if (el) el.value = freqMhz.toFixed(3); sweepSelectedFreq = freqMhz; drawSweepChart(); } function tuneAndCapture(freqMhz) { tuneFromSweep(freqMhz); stopSweep(); hideSweepActionBar(); setTimeout(() => { showPanel('rx'); updateRxDisplay(getParams()); showConsole(); addConsoleEntry('Tuned to ' + freqMhz.toFixed(3) + ' MHz. Press Start to capture RAW.', 'info'); }, 300); } // ------ FLOATING ACTION BAR ------ function showSweepActionBar(clientX, clientY, freqMhz) { if (!sweepActionBarEl) { sweepActionBarEl = document.createElement('div'); sweepActionBarEl.className = 'subghz-sweep-action-bar'; document.body.appendChild(sweepActionBarEl); } sweepActionBarEl.innerHTML = '' + ''; sweepActionBarEl.querySelector('.tune').onclick = (e) => { e.stopPropagation(); tuneFromSweep(freqMhz); hideSweepActionBar(); }; sweepActionBarEl.querySelector('.capture').onclick = (e) => { e.stopPropagation(); tuneAndCapture(freqMhz); }; sweepActionBarEl.style.left = (clientX + 10) + 'px'; sweepActionBarEl.style.top = (clientY + 14) + 'px'; sweepActionBarEl.classList.remove('visible'); void sweepActionBarEl.offsetHeight; sweepActionBarEl.classList.add('visible'); } function hideSweepActionBar() { if (sweepActionBarEl) sweepActionBarEl.classList.remove('visible'); } // ------ PEAK LIST ------ function updatePeakList() { // Update sidebar, sweep panel, and any other peak lists const lists = [ document.getElementById('subghzPeakList'), document.getElementById('subghzSweepPeakList'), ]; for (const list of lists) { if (!list) continue; list.innerHTML = ''; for (const peak of sweepPeaks) { const item = document.createElement('div'); item.className = 'subghz-peak-item'; item.innerHTML = '' + peak.freq.toFixed(3) + ' MHz' + '' + peak.power.toFixed(1) + ' dB'; item.onclick = () => tuneFromSweep(peak.freq); list.appendChild(item); } } } // ------ CAPTURES LIBRARY ------ function loadCaptures() { fetch('/subghz/captures') .then(r => r.json()) .then(data => { const captures = data.captures || []; latestCaptures = captures; const validIds = new Set(captures.map(c => c.id)); selectedCaptureIds = new Set([...selectedCaptureIds].filter(id => validIds.has(id))); captureCount = captures.length; updateStatsStrip(); updateSavedSelectionUi(); renderCaptures(captures); }) .catch(() => {}); } function burstCountForCapture(cap) { return Array.isArray(cap?.bursts) ? cap.bursts.length : 0; } function updateSavedSelectionUi() { const selectBtn = document.getElementById('subghzSavedSelectBtn'); const selectAllBtn = document.getElementById('subghzSavedSelectAllBtn'); const deleteBtn = document.getElementById('subghzSavedDeleteSelectedBtn'); const countEl = document.getElementById('subghzSavedSelectionCount'); if (selectBtn) selectBtn.textContent = captureSelectMode ? 'Done' : 'Select'; if (selectAllBtn) selectAllBtn.style.display = captureSelectMode ? '' : 'none'; if (deleteBtn) { deleteBtn.style.display = captureSelectMode ? '' : 'none'; deleteBtn.disabled = selectedCaptureIds.size === 0; } if (countEl) { countEl.style.display = captureSelectMode ? '' : 'none'; countEl.textContent = `${selectedCaptureIds.size} selected`; } } function renderCaptures(captures) { // Render to both the visuals panel and the sidebar const targets = [ { list: document.getElementById('subghzCapturesList'), empty: document.getElementById('subghzCapturesEmpty'), selectable: true, }, { list: document.getElementById('subghzSidebarCaptures'), empty: document.getElementById('subghzSidebarCapturesEmpty'), selectable: false, }, ]; for (const { list, empty, selectable } of targets) { if (!list) continue; // Clear existing cards list.querySelectorAll('.subghz-capture-card').forEach(c => c.remove()); if (captures.length === 0) { if (empty) empty.style.display = ''; continue; } if (empty) empty.style.display = 'none'; for (const cap of captures) { const freqMhz = (cap.frequency_hz / 1000000).toFixed(3); const sizeKb = (cap.size_bytes / 1024).toFixed(1); const ts = cap.timestamp ? new Date(cap.timestamp).toLocaleString() : ''; const dur = cap.duration_seconds ? cap.duration_seconds.toFixed(1) + 's' : ''; const burstCount = burstCountForCapture(cap); const selected = selectedCaptureIds.has(cap.id); const modulationHint = typeof cap.modulation_hint === 'string' ? cap.modulation_hint : ''; const modulationConfidence = Number(cap.modulation_confidence || 0); const protocolHint = typeof cap.protocol_hint === 'string' ? cap.protocol_hint : ''; const dominantFingerprint = typeof cap.dominant_fingerprint === 'string' ? cap.dominant_fingerprint : ''; const fingerprintGroup = typeof cap.fingerprint_group === 'string' ? cap.fingerprint_group : ''; const fingerprintGroupSize = Number(cap.fingerprint_group_size || 0); const labelSource = typeof cap.label_source === 'string' ? cap.label_source : ''; const card = document.createElement('div'); card.className = 'subghz-capture-card'; if (burstCount > 0) card.classList.add('has-bursts'); if (selectable && captureSelectMode) card.classList.add('select-mode'); if (selectable && selected) card.classList.add('selected'); let actionsHtml = ''; if (selectable && captureSelectMode) { actionsHtml = `
`; } else { actionsHtml = `
`; } card.innerHTML = `
${escapeHtml(freqMhz)} MHz
${burstCount > 0 ? `${burstCount} burst${burstCount === 1 ? '' : 's'}` : ''} ${escapeHtml(ts)}
${burstCount > 0 ? `
BURSTS DETECTED ${burstCount}
` : ''} ${(cap.label || modulationHint || dominantFingerprint) ? `
${cap.label && labelSource === 'auto' ? `AUTO LABEL` : ''} ${modulationHint ? `${escapeHtml(modulationHint)} ${modulationConfidence > 0 ? Math.round(modulationConfidence * 100) + '%' : ''}` : ''} ${fingerprintGroup ? `${escapeHtml(fingerprintGroup)}${fingerprintGroupSize > 1 ? ' x' + fingerprintGroupSize : ''}` : ''}
` : ''} ${cap.label ? `
${escapeHtml(cap.label)}
` : ''} ${protocolHint ? `
${escapeHtml(protocolHint)}
` : ''} ${dominantFingerprint ? `
Fingerprint: ${escapeHtml(dominantFingerprint)}
` : ''}
${escapeHtml(dur)} ${escapeHtml(sizeKb)} KB ${escapeHtml(String(cap.sample_rate / 1000))} kHz
${actionsHtml} `; if (selectable && captureSelectMode) { card.addEventListener('click', (event) => toggleCaptureSelection(cap.id, event)); } list.appendChild(card); } } } function toggleCaptureSelectMode(forceValue) { captureSelectMode = typeof forceValue === 'boolean' ? forceValue : !captureSelectMode; if (!captureSelectMode) selectedCaptureIds.clear(); updateSavedSelectionUi(); renderCaptures(latestCaptures); } function selectAllCaptures() { if (!captureSelectMode) return; const allIds = latestCaptures.map(c => c.id); if (selectedCaptureIds.size >= allIds.length) { selectedCaptureIds.clear(); } else { selectedCaptureIds = new Set(allIds); } updateSavedSelectionUi(); renderCaptures(latestCaptures); } function toggleCaptureSelection(id, event) { if (event) { event.preventDefault(); event.stopPropagation(); } if (!captureSelectMode) return; if (selectedCaptureIds.has(id)) selectedCaptureIds.delete(id); else selectedCaptureIds.add(id); updateSavedSelectionUi(); renderCaptures(latestCaptures); } async function deleteSelectedCaptures() { if (!captureSelectMode || selectedCaptureIds.size === 0) return; const ids = [...selectedCaptureIds]; const confirmed = await AppFeedback.confirmAction({ title: 'Delete Captures', message: `Delete ${ids.length} selected capture${ids.length === 1 ? '' : 's'}? This cannot be undone.`, confirmLabel: 'Delete', confirmClass: 'btn-danger' }); if (!confirmed) return; Promise.all( ids.map(id => fetch(`/subghz/captures/${encodeURIComponent(id)}`, { method: 'DELETE' })) ) .then(() => { selectedCaptureIds.clear(); captureSelectMode = false; updateSavedSelectionUi(); loadCaptures(); }) .catch(err => alert('Error deleting captures: ' + err.message)); } async function deleteCapture(id) { const confirmed = await AppFeedback.confirmAction({ title: 'Delete Capture', message: 'Delete this capture? This cannot be undone.', confirmLabel: 'Delete', confirmClass: 'btn-danger' }); if (!confirmed) return; fetch(`/subghz/captures/${encodeURIComponent(id)}`, { method: 'DELETE' }) .then(r => r.json()) .then(() => loadCaptures()) .catch(err => alert('Error: ' + err.message)); } function renameCapture(id) { const label = prompt('Enter label for this capture:'); if (label === null) return; fetch(`/subghz/captures/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label: label }), }) .then(r => r.json()) .then(() => loadCaptures()) .catch(err => alert('Error: ' + err.message)); } function downloadCapture(id) { window.open(`/subghz/captures/${encodeURIComponent(id)}/download`, '_blank'); } // ------ SSE STREAM ------ function startStream() { if (eventSource) { eventSource.close(); } eventSource = new EventSource('/subghz/stream'); eventSource.onmessage = function(e) { try { const data = JSON.parse(e.data); handleEvent(data); } catch (err) { // Ignore parse errors (keepalives etc.) } }; eventSource.onerror = function() { setTimeout(() => { if (document.getElementById('subghzMode')?.classList.contains('active')) { startStream(); } }, 3000); }; } function handleEvent(data) { const type = data.type; if (type === 'status') { updateStatusUI(data); if (data.status === 'started') { if (data.mode === 'rx') startStatusTimer(); if (data.mode === 'decode') { showConsole(); } if (data.mode === 'sweep') { if (activePanel !== 'sweep') showPanel('sweep'); } } else if (data.status === 'stopped' || data.status === 'decode_stopped' || data.status === 'sweep_stopped') { stopStatusTimer(); resetRxVisuals(); resetDecodeVisuals(); addConsoleEntry('Operation stopped', 'warn'); updatePhaseIndicator(null); if (data.mode === 'idle') loadCaptures(); } } else if (type === 'decode') { appendDecodeEntry(data); } else if (type === 'decode_raw') { appendDecodeEntry({ model: 'Raw', text: data.text }); } else if (type === 'rx_level') { updateRxLevel(data.level); } else if (type === 'rx_waveform') { updateRxWaveform(data.samples); } else if (type === 'rx_spectrum') { updateRxSpectrum(data.bins); } else if (type === 'rx_stats') { updateRxStats(data); } else if (type === 'rx_hint') { const confidence = Number(data.confidence || 0); updateRxHint(data.modulation_hint || '', confidence, data.protocol_hint || ''); const now = Date.now(); if ((now - lastRxHintTs) > 4000 && data.modulation_hint) { lastRxHintTs = now; addConsoleEntry( `[rx] Hint: ${data.modulation_hint} (${Math.round(confidence * 100)}%)` + (data.protocol_hint ? ` - ${data.protocol_hint}` : ''), 'info' ); } } else if (type === 'decode_level') { updateDecodeLevel(data.level); } else if (type === 'decode_waveform') { updateDecodeWaveform(data.samples); } else if (type === 'decode_spectrum') { updateDecodeSpectrum(data.bins); } else if (type === 'rx_burst') { handleRxBurst(data); } else if (type === 'sweep') { if (data.points) { updateSweepChart(data.points); updatePhaseIndicator('decoding'); } } else if (type === 'tx_status') { if (data.status === 'transmitting') { updateStatusUI({ mode: 'tx' }); if (activePanel !== 'tx') showPanel('tx'); updateTxPanelState(true); startStatusTimer(); addConsoleEntry('Transmission started', 'warn'); } else if (data.status === 'tx_complete' || data.status === 'tx_stopped') { if (currentMode === 'tx' || activePanel === 'tx') { finalizeTxUi('Transmission ended'); } else { updateStatusUI({ mode: 'idle' }); stopStatusTimer(); loadCaptures(); } } } else if (type === 'info') { // rtl_433 stderr info lines if (data.text) addConsoleEntry(data.text, 'info'); } else if (type === 'error') { addConsoleEntry(data.message || 'Error', 'error'); updatePhaseIndicator('error'); alert(data.message || 'SubGHz error'); } } // ------ UTILITIES ------ function escapeHtml(str) { if (!str) return ''; const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } /** * Clean up when switching away from SubGHz mode */ function destroy() { if (eventSource) { eventSource.close(); eventSource = null; } if (statusPollTimer) { clearInterval(statusPollTimer); statusPollTimer = null; } if (burstBadgeTimer) { clearTimeout(burstBadgeTimer); burstBadgeTimer = null; } const txTimeline = document.getElementById('subghzTxBurstTimeline'); if (txTimeline && typeof txTimeline._txEditorCleanup === 'function') { txTimeline._txEditorCleanup(); txTimeline._txEditorCleanup = null; } txTimelineDragState = null; stopStatusTimer(); setBurstIndicator('idle', 'NO BURST'); setRxBurstPill('idle', 'IDLE'); setBurstCanvasHighlight('rx', false); setBurstCanvasHighlight('decode', false); // Clean up interactive sweep elements if (sweepTooltipEl) { sweepTooltipEl.remove(); sweepTooltipEl = null; } if (sweepCtxMenuEl) { sweepCtxMenuEl.remove(); sweepCtxMenuEl = null; } if (sweepActionBarEl) { sweepActionBarEl.remove(); sweepActionBarEl = null; } if (sweepDismissHandler) { document.removeEventListener('click', sweepDismissHandler); sweepDismissHandler = null; } if (sweepResizeObserver) { sweepResizeObserver.disconnect(); sweepResizeObserver = null; } sweepInteractionBound = false; sweepHoverFreq = null; sweepSelectedFreq = null; sweepPeaks = []; sweepPeakHold = []; if (rxScopeResizeObserver) { rxScopeResizeObserver.disconnect(); rxScopeResizeObserver = null; } if (rxWaterfallResizeObserver) { rxWaterfallResizeObserver.disconnect(); rxWaterfallResizeObserver = null; } rxScopeCanvas = null; rxScopeCtx = null; rxScopeData = []; rxWaterfallCanvas = null; rxWaterfallCtx = null; rxWaterfallPalette = null; rxWaterfallPaused = false; if (decodeScopeResizeObserver) { decodeScopeResizeObserver.disconnect(); decodeScopeResizeObserver = null; } if (decodeWaterfallResizeObserver) { decodeWaterfallResizeObserver.disconnect(); decodeWaterfallResizeObserver = null; } decodeScopeCanvas = null; decodeScopeCtx = null; decodeScopeData = []; decodeWaterfallCanvas = null; decodeWaterfallCtx = null; decodeWaterfallPalette = null; // Reset dashboard state activePanel = null; signalCount = 0; captureCount = 0; consoleEntries = []; consoleCollapsed = false; currentPhase = null; currentMode = 'idle'; lastRawLine = ''; lastRawLineTs = 0; lastBurstLineTs = 0; lastRxHintTs = 0; pendingTxBursts = []; captureSelectMode = false; selectedCaptureIds = new Set(); latestCaptures = []; lastTxCaptureId = null; lastTxRequest = null; txModalIntent = 'tx'; } // ------ DASHBOARD: HUB & PANELS ------ function showHub() { activePanel = null; const hub = document.getElementById('subghzActionHub'); if (hub) hub.style.display = ''; const panels = ['Rx', 'Sweep', 'Tx', 'Saved']; panels.forEach(p => { const el = document.getElementById('subghzPanel' + p); if (el) el.style.display = 'none'; }); updateStatsStrip('idle'); updateStatusUI({ mode: currentMode }); } function showPanel(panel) { activePanel = panel; const hub = document.getElementById('subghzActionHub'); if (hub) hub.style.display = 'none'; const panelMap = { rx: 'Rx', sweep: 'Sweep', tx: 'Tx', saved: 'Saved' }; Object.values(panelMap).forEach(p => { const el = document.getElementById('subghzPanel' + p); if (el) el.style.display = 'none'; }); const target = document.getElementById('subghzPanel' + (panelMap[panel] || '')); if (target) target.style.display = ''; if (panel === 'rx') { initRxScope(); initRxWaterfall(); syncWaterfallControls(); } else if (panel === 'saved') { updateSavedSelectionUi(); loadCaptures(); } else if (panel === 'tx') { updateTxPanelState(currentMode === 'tx'); } updateStatsStrip(); updateStatusUI({ mode: currentMode }); } function hubAction(action) { if (action === 'rx') { showPanel('rx'); updateRxDisplay(getParams()); showConsole(); addConsoleEntry('RAW panel ready. Press Start when you want to capture.', 'info'); } else if (action === 'txselect') { showPanel('saved'); loadCaptures(); } else if (action === 'sweep') { startSweep(); } else if (action === 'saved') { showPanel('saved'); loadCaptures(); } } function backToHub() { // Stop any running operation if (currentMode !== 'idle') { if (currentMode === 'rx') stopRx(); else if (currentMode === 'sweep') stopSweep(); else if (currentMode === 'tx') stopTx(); } showHub(); const consoleEl = document.getElementById('subghzConsole'); if (consoleEl) consoleEl.style.display = 'none'; updatePhaseIndicator(null); } function stopActive() { if (currentMode === 'rx') stopRx(); else if (currentMode === 'sweep') stopSweep(); else if (currentMode === 'tx') stopTx(); } // ------ DASHBOARD: STATS STRIP ------ function updateStatsStrip(mode) { const stripDot = document.getElementById('subghzStripDot'); const stripStatus = document.getElementById('subghzStripStatus'); const stripFreq = document.getElementById('subghzStripFreq'); const stripMode = document.getElementById('subghzStripMode'); const stripSignals = document.getElementById('subghzStripSignals'); const stripCaptures = document.getElementById('subghzStripCaptures'); if (!mode) mode = currentMode || 'idle'; if (stripDot) { stripDot.className = 'subghz-strip-dot'; if (mode !== 'idle' && mode !== 'saved') { stripDot.classList.add(mode, 'active'); } } const labels = { idle: 'Idle', rx: 'Capturing', decode: 'Decoding', tx: 'Transmitting', sweep: 'Sweeping', saved: 'Library' }; if (stripStatus) stripStatus.textContent = labels[mode] || mode; const freqEl = document.getElementById('subghzFrequency'); if (stripFreq && freqEl) { stripFreq.textContent = freqEl.value || '--'; } const modeLabels = { idle: '--', decode: 'READ', rx: 'RAW', sweep: 'SWEEP', tx: 'TX', saved: 'SAVED' }; if (stripMode) stripMode.textContent = modeLabels[mode] || '--'; if (stripSignals) stripSignals.textContent = signalCount; if (stripCaptures) stripCaptures.textContent = captureCount; } // ------ DASHBOARD: RX DISPLAY ------ function updateRxDisplay(params) { const freqEl = document.getElementById('subghzRxFreq'); const lnaEl = document.getElementById('subghzRxLna'); const vgaEl = document.getElementById('subghzRxVga'); const srEl = document.getElementById('subghzRxSampleRate'); if (freqEl) freqEl.textContent = (params.frequency_hz / 1e6).toFixed(3) + ' MHz'; if (lnaEl) lnaEl.textContent = params.lna_gain + ' dB'; if (vgaEl) vgaEl.textContent = params.vga_gain + ' dB'; if (srEl) srEl.textContent = (params.sample_rate / 1000) + ' kHz'; } // ------ DASHBOARD: CONSOLE ------ function addConsoleEntry(msg, level) { level = level || ''; const now = new Date(); const ts = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); consoleEntries.push({ ts, msg, level }); if (consoleEntries.length > 100) consoleEntries.shift(); const log = document.getElementById('subghzConsoleLog'); if (!log) return; const entry = document.createElement('div'); entry.className = 'subghz-log-entry'; entry.innerHTML = '' + escapeHtml(ts) + '' + '' + escapeHtml(msg) + ''; log.appendChild(entry); log.scrollTop = log.scrollHeight; while (log.children.length > 100) { log.removeChild(log.firstChild); } } function showConsole() { const consoleEl = document.getElementById('subghzConsole'); if (consoleEl) consoleEl.style.display = ''; } function toggleConsole() { consoleCollapsed = !consoleCollapsed; const body = document.getElementById('subghzConsoleBody'); const btn = document.getElementById('subghzConsoleToggleBtn'); if (body) body.classList.toggle('collapsed', consoleCollapsed); if (btn) btn.classList.toggle('collapsed', consoleCollapsed); } function clearConsole() { consoleEntries = []; const log = document.getElementById('subghzConsoleLog'); if (log) log.innerHTML = ''; } function updatePhaseIndicator(phase) { currentPhase = phase; const steps = ['tuning', 'listening', 'decoding']; const phaseEls = { tuning: document.getElementById('subghzPhaseTuning'), listening: document.getElementById('subghzPhaseListening'), decoding: document.getElementById('subghzPhaseDecoding'), }; if (!phase) { Object.values(phaseEls).forEach(el => { if (el) el.className = 'subghz-phase-step'; }); return; } if (phase === 'error') { Object.values(phaseEls).forEach(el => { if (el) { el.className = 'subghz-phase-step'; el.classList.add('error'); } }); return; } const activeIdx = steps.indexOf(phase); steps.forEach((step, idx) => { const el = phaseEls[step]; if (!el) return; el.className = 'subghz-phase-step'; if (idx < activeIdx) el.classList.add('completed'); else if (idx === activeIdx) el.classList.add('active'); }); } // ------ PUBLIC API ------ return { init, destroy, setFreq, syncTriggerControls, switchTab, startRx, stopRx, startDecode, stopDecode, startSweep, stopSweep, showTxConfirm, showTrimCapture, cancelTx, syncTxSegmentSelection, confirmTx, trimCaptureSelection, stopTx, replayLastTx, loadCaptures, toggleCaptureSelectMode, selectAllCaptures, deleteSelectedCaptures, toggleCaptureSelection, deleteCapture, renameCapture, downloadCapture, tuneFromSweep, tuneAndCapture, // Dashboard showHub, showPanel, hubAction, backToHub, stopActive, toggleConsole, clearConsole, // Waterfall controls toggleWaterfall, setWaterfallFloor, setWaterfallRange, }; })();