diff --git a/routes/sensor.py b/routes/sensor.py index e5a719e..a60210c 100644 --- a/routes/sensor.py +++ b/routes/sensor.py @@ -18,8 +18,8 @@ from utils.validation import ( validate_frequency, validate_device_index, validate_gain, validate_ppm, validate_rtl_tcp_host, validate_rtl_tcp_port ) -from utils.sse import format_sse -from utils.event_pipeline import process_event +from utils.sse import format_sse +from utils.event_pipeline import process_event from utils.process import safe_terminate, register_process, unregister_process from utils.sdr import SDRFactory, SDRType @@ -45,6 +45,21 @@ def stream_sensor_output(process: subprocess.Popen[bytes]) -> None: data['type'] = 'sensor' app_module.sensor_queue.put(data) + # Push scope event when signal level data is present + rssi = data.get('rssi') + snr = data.get('snr') + noise = data.get('noise') + if rssi is not None or snr is not None: + try: + app_module.sensor_queue.put_nowait({ + 'type': 'scope', + 'rssi': rssi if rssi is not None else 0, + 'snr': snr if snr is not None else 0, + 'noise': noise if noise is not None else 0, + }) + except queue.Full: + pass + # Log if enabled if app_module.logging_enabled: try: @@ -158,6 +173,9 @@ def start_sensor() -> Response: full_cmd = ' '.join(cmd) logger.info(f"Running: {full_cmd}") + # Add signal level metadata so the frontend scope can display RSSI/SNR + cmd.extend(['-M', 'level']) + try: app_module.sensor_process = subprocess.Popen( cmd, @@ -232,13 +250,13 @@ def stream_sensor() -> Response: while True: try: - msg = app_module.sensor_queue.get(timeout=1) - last_keepalive = time.time() - try: - process_event('sensor', msg, msg.get('type')) - except Exception: - pass - yield format_sse(msg) + msg = app_module.sensor_queue.get(timeout=1) + last_keepalive = time.time() + try: + process_event('sensor', msg, msg.get('type')) + except Exception: + pass + yield format_sse(msg) except queue.Empty: now = time.time() if now - last_keepalive >= keepalive_interval: diff --git a/templates/index.html b/templates/index.html index e0bab5d..a2772b4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2200,10 +2200,6 @@ - - - - + + + + + + + +
@@ -3185,6 +3201,160 @@ } } + // --- Sensor Signal Scope --- + let sensorScopeCtx = null; + let sensorScopeAnim = null; + let sensorScopeHistory = []; + const SENSOR_SCOPE_LEN = 200; + let sensorScopeRssi = 0; + let sensorScopeSnr = 0; + let sensorScopeTargetRssi = 0; + let sensorScopeTargetSnr = 0; + let sensorScopeMsgBurst = 0; + let sensorScopeLastPulse = 0; + + function initSensorScope() { + const canvas = document.getElementById('sensorScopeCanvas'); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * (window.devicePixelRatio || 1); + canvas.height = rect.height * (window.devicePixelRatio || 1); + sensorScopeCtx = canvas.getContext('2d'); + sensorScopeHistory = new Array(SENSOR_SCOPE_LEN).fill(0); + sensorScopeRssi = 0; + sensorScopeSnr = 0; + sensorScopeTargetRssi = 0; + sensorScopeTargetSnr = 0; + sensorScopeMsgBurst = 0; + sensorScopeLastPulse = 0; + drawSensorScope(); + } + + function drawSensorScope() { + const ctx = sensorScopeCtx; + if (!ctx) return; + const W = ctx.canvas.width; + const H = ctx.canvas.height; + const midY = H / 2; + + // Phosphor persistence + ctx.fillStyle = 'rgba(5, 5, 16, 0.3)'; + ctx.fillRect(0, 0, W, H); + + // Smooth towards targets (decay when no new packets) + sensorScopeRssi += (sensorScopeTargetRssi - sensorScopeRssi) * 0.25; + sensorScopeSnr += (sensorScopeTargetSnr - sensorScopeSnr) * 0.15; + + // Decay targets back to zero between packets + sensorScopeTargetRssi *= 0.97; + sensorScopeTargetSnr *= 0.97; + + // RSSI is typically negative dBm (e.g. -0.1 to -30+) + // Normalize: map absolute RSSI to 0-1 range (0 dB = max, -40 dB = min) + const rssiNorm = Math.min(Math.max(Math.abs(sensorScopeRssi) / 40, 0), 1.0); + sensorScopeHistory.push(rssiNorm); + if (sensorScopeHistory.length > SENSOR_SCOPE_LEN) { + sensorScopeHistory.shift(); + } + + // Grid lines + ctx.strokeStyle = 'rgba(40, 80, 40, 0.4)'; + ctx.lineWidth = 1; + for (let g = 0.25; g < 1; g += 0.25) { + const gy = midY - g * midY; + const gy2 = midY + g * midY; + ctx.beginPath(); + ctx.moveTo(0, gy); ctx.lineTo(W, gy); + ctx.moveTo(0, gy2); ctx.lineTo(W, gy2); + ctx.stroke(); + } + + // Center baseline + ctx.strokeStyle = 'rgba(60, 100, 60, 0.5)'; + ctx.beginPath(); + ctx.moveTo(0, midY); + ctx.lineTo(W, midY); + ctx.stroke(); + + // Waveform (mirrored, green theme for 433) + const stepX = W / SENSOR_SCOPE_LEN; + ctx.strokeStyle = '#0f0'; + ctx.lineWidth = 1.5; + ctx.shadowColor = '#0f0'; + ctx.shadowBlur = 4; + + // Upper half + ctx.beginPath(); + for (let i = 0; i < sensorScopeHistory.length; i++) { + const x = i * stepX; + const amp = sensorScopeHistory[i] * midY * 0.9; + const y = midY - amp; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Lower half (mirror) + ctx.beginPath(); + for (let i = 0; i < sensorScopeHistory.length; i++) { + const x = i * stepX; + const amp = sensorScopeHistory[i] * midY * 0.9; + const y = midY + amp; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + ctx.shadowBlur = 0; + + // SNR indicator (amber dashed line) + const snrNorm = Math.min(Math.max(Math.abs(sensorScopeSnr) / 40, 0), 1.0); + if (snrNorm > 0.01) { + const snrY = midY - snrNorm * midY * 0.9; + ctx.strokeStyle = 'rgba(255, 170, 0, 0.6)'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(0, snrY); + ctx.lineTo(W, snrY); + ctx.stroke(); + ctx.setLineDash([]); + } + + // Sensor decode flash (green overlay) + if (sensorScopeMsgBurst > 0.01) { + ctx.fillStyle = `rgba(0, 255, 100, ${sensorScopeMsgBurst * 0.15})`; + ctx.fillRect(0, 0, W, H); + sensorScopeMsgBurst *= 0.88; + } + + // Update labels + const rssiLabel = document.getElementById('sensorScopeRssiLabel'); + const snrLabel = document.getElementById('sensorScopeSnrLabel'); + const statusLabel = document.getElementById('sensorScopeStatusLabel'); + if (rssiLabel) rssiLabel.textContent = sensorScopeRssi < -0.5 ? sensorScopeRssi.toFixed(1) : '--'; + if (snrLabel) snrLabel.textContent = sensorScopeSnr > 0.5 ? sensorScopeSnr.toFixed(1) : '--'; + if (statusLabel) { + if (Math.abs(sensorScopeRssi) > 1) { + statusLabel.textContent = 'SIGNAL'; + statusLabel.style.color = '#0f0'; + } else { + statusLabel.textContent = 'MONITORING'; + statusLabel.style.color = '#555'; + } + } + + sensorScopeAnim = requestAnimationFrame(drawSensorScope); + } + + function stopSensorScope() { + if (sensorScopeAnim) { + cancelAnimationFrame(sensorScopeAnim); + sensorScopeAnim = null; + } + sensorScopeCtx = null; + } + // Start sensor decoding function startSensorDecoding() { const freq = document.getElementById('sensorFrequency').value; @@ -3374,6 +3544,18 @@ document.getElementById('statusText').textContent = running ? 'Listening...' : 'Idle'; document.getElementById('startSensorBtn').style.display = running ? 'none' : 'block'; document.getElementById('stopSensorBtn').style.display = running ? 'block' : 'none'; + + // Signal scope + const scopePanel = document.getElementById('sensorScopePanel'); + if (scopePanel) { + if (running) { + scopePanel.style.display = 'block'; + initSensorScope(); + } else { + stopSensorScope(); + scopePanel.style.display = 'none'; + } + } } function startSensorStream() { @@ -3391,6 +3573,9 @@ const data = JSON.parse(e.data); if (data.type === 'sensor') { addSensorReading(data); + } else if (data.type === 'scope') { + sensorScopeTargetRssi = data.rssi; + sensorScopeTargetSnr = data.snr; } else if (data.type === 'status') { if (data.text === 'stopped') { setSensorRunning(false); @@ -3415,6 +3600,9 @@ playAlert(); pulseSignal(); + // Flash sensor scope green on decode + sensorScopeMsgBurst = 1.0; + sensorCount++; document.getElementById('sensorCount').textContent = sensorCount;