mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Replace IBM Plex Mono, Space Mono, and JetBrains Mono with Roboto Condensed across all CSS variables, inline styles, canvas ctx.font references, and Google Fonts CDN links. Updates 28 files covering templates, stylesheets, and JS modules for consistent typography. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2747 lines
107 KiB
JavaScript
2747 lines
107 KiB
JavaScript
/**
|
|
* 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 = '<div class="subghz-empty">Waiting for signals...</div>';
|
|
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 = `<span class="subghz-decode-model">${escapeHtml(model)}</span>`;
|
|
if (isRaw && typeof data.text === 'string') {
|
|
html += `<span class="subghz-decode-rawtext">: ${escapeHtml(data.text)}</span>`;
|
|
} else {
|
|
const skipKeys = ['type', 'model', 'time', 'mic'];
|
|
for (const [key, value] of Object.entries(data)) {
|
|
if (skipKeys.includes(key)) continue;
|
|
html += `<span class="subghz-decode-field"><strong>${escapeHtml(key)}:</strong> ${escapeHtml(String(value))}</span> `;
|
|
}
|
|
}
|
|
|
|
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 =
|
|
'<span class="tip-freq">' + sweepHoverFreq.toFixed(3) + ' MHz</span>' +
|
|
' · ' +
|
|
'<span class="tip-power">' + sweepHoverPower.toFixed(1) + ' dB</span>';
|
|
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 =
|
|
'<div class="subghz-ctx-header">' + freqStr + ' MHz</div>' +
|
|
'<div class="subghz-ctx-item" data-action="tune"><span class="ctx-icon">▶</span>Tune Here</div>' +
|
|
'<div class="subghz-ctx-item" data-action="capture"><span class="ctx-icon">●</span>Open RAW at ' + freqStr + ' MHz</div>';
|
|
|
|
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 =
|
|
'<button class="subghz-action-btn tune">Tune</button>' +
|
|
'<button class="subghz-action-btn capture">Open RAW</button>';
|
|
|
|
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 =
|
|
'<span class="peak-freq">' + peak.freq.toFixed(3) + ' MHz</span>' +
|
|
'<span class="peak-power">' + peak.power.toFixed(1) + ' dB</span>';
|
|
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 = `
|
|
<div class="subghz-capture-actions select-mode">
|
|
<button class="select-btn ${selected ? 'selected' : ''}" onclick="SubGhz.toggleCaptureSelection('${escapeHtml(cap.id)}', event)">
|
|
${selected ? 'Selected' : 'Select'}
|
|
</button>
|
|
</div>
|
|
`;
|
|
} else {
|
|
actionsHtml = `
|
|
<div class="subghz-capture-actions">
|
|
<button class="replay-btn" onclick="SubGhz.showTxConfirm('${escapeHtml(cap.id)}')">Replay</button>
|
|
<button class="trim-btn" onclick="SubGhz.showTrimCapture('${escapeHtml(cap.id)}')">Trim</button>
|
|
<button onclick="SubGhz.renameCapture('${escapeHtml(cap.id)}')">Rename</button>
|
|
<button onclick="SubGhz.downloadCapture('${escapeHtml(cap.id)}')">Download</button>
|
|
<button class="delete-btn" onclick="SubGhz.deleteCapture('${escapeHtml(cap.id)}')">Delete</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
card.innerHTML = `
|
|
<div class="subghz-capture-header">
|
|
<span class="subghz-capture-freq">${escapeHtml(freqMhz)} MHz</span>
|
|
<div class="subghz-capture-header-right">
|
|
${burstCount > 0 ? `<span class="subghz-capture-burst-badge">${burstCount} burst${burstCount === 1 ? '' : 's'}</span>` : ''}
|
|
<span class="subghz-capture-time">${escapeHtml(ts)}</span>
|
|
</div>
|
|
</div>
|
|
${burstCount > 0 ? `
|
|
<div class="subghz-capture-burst-line">
|
|
<span class="subghz-capture-burst-flag">BURSTS DETECTED</span>
|
|
<span class="subghz-capture-burst-count">${burstCount}</span>
|
|
</div>
|
|
` : ''}
|
|
${(cap.label || modulationHint || dominantFingerprint) ? `
|
|
<div class="subghz-capture-tag-row">
|
|
${cap.label && labelSource === 'auto' ? `<span class="subghz-capture-tag auto">AUTO LABEL</span>` : ''}
|
|
${modulationHint ? `<span class="subghz-capture-tag hint">${escapeHtml(modulationHint)} ${modulationConfidence > 0 ? Math.round(modulationConfidence * 100) + '%' : ''}</span>` : ''}
|
|
${fingerprintGroup ? `<span class="subghz-capture-tag fingerprint">${escapeHtml(fingerprintGroup)}${fingerprintGroupSize > 1 ? ' x' + fingerprintGroupSize : ''}</span>` : ''}
|
|
</div>
|
|
` : ''}
|
|
${cap.label ? `<div class="subghz-capture-label">${escapeHtml(cap.label)}</div>` : ''}
|
|
${protocolHint ? `<div class="subghz-capture-label">${escapeHtml(protocolHint)}</div>` : ''}
|
|
${dominantFingerprint ? `<div class="subghz-capture-label">Fingerprint: ${escapeHtml(dominantFingerprint)}</div>` : ''}
|
|
<div class="subghz-capture-meta">
|
|
<span>${escapeHtml(dur)}</span>
|
|
<span>${escapeHtml(sizeKb)} KB</span>
|
|
<span>${escapeHtml(String(cap.sample_rate / 1000))} kHz</span>
|
|
</div>
|
|
${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);
|
|
}
|
|
|
|
function deleteSelectedCaptures() {
|
|
if (!captureSelectMode || selectedCaptureIds.size === 0) return;
|
|
const ids = [...selectedCaptureIds];
|
|
if (!confirm(`Delete ${ids.length} selected capture${ids.length === 1 ? '' : 's'}?`)) 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));
|
|
}
|
|
|
|
function deleteCapture(id) {
|
|
if (!confirm('Delete this capture?')) 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 = '<span class="subghz-log-ts">' + escapeHtml(ts) + '</span>' +
|
|
'<span class="subghz-log-msg ' + escapeHtml(level) + '">' + escapeHtml(msg) + '</span>';
|
|
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,
|
|
};
|
|
})();
|