Files
intercept/static/js/modes/wifi_locate.js
Smittix 05412fbfc3 fix(wifi_locate): read correct RSSI field from SSE network events
Backend sends rssi_current but frontend was reading net.signal || net.rssi,
causing RSSI to parse as NaN and silently skipping all meter/audio updates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:27:12 +00:00

557 lines
19 KiB
JavaScript

/**
* WiFi Locate — WiFi AP Location Mode
* Real-time signal strength meter with proximity audio for locating WiFi devices by BSSID.
* Reuses existing WiFi v2 API (/wifi/v2/start, /wifi/v2/stop, /wifi/v2/stream, /wifi/v2/status).
*/
const WiFiLocate = (function() {
'use strict';
const API_BASE = '/wifi/v2';
const MAX_RSSI_POINTS = 60;
const SIGNAL_LOST_TIMEOUT_MS = 30000;
const BAR_SEGMENTS = 20;
const TX_POWER = -30;
const ENV_PATH_LOSS = {
FREE_SPACE: 2.0,
OUTDOOR: 2.8,
INDOOR: 3.5,
};
let eventSource = null;
let targetBssid = null;
let targetSsid = null;
let rssiHistory = [];
let chartCanvas = null;
let chartCtx = null;
let audioCtx = null;
let audioEnabled = false;
let beepTimer = null;
let currentEnvironment = 'OUTDOOR';
let handoffData = null;
let modeActive = false;
let locateActive = false;
let rssiMin = null;
let rssiMax = null;
let rssiSum = 0;
let rssiCount = 0;
let lastUpdateTime = 0;
let signalLostTimer = null;
function debugLog(...args) {
console.debug('[WiFiLocate]', ...args);
}
// ========================================================================
// Lifecycle
// ========================================================================
function init() {
modeActive = true;
chartCanvas = document.getElementById('wflRssiChart');
chartCtx = chartCanvas ? chartCanvas.getContext('2d') : null;
buildBarSegments();
}
function start() {
const bssidInput = document.getElementById('wflBssid');
const bssid = (bssidInput?.value || '').trim().toUpperCase();
if (!bssid || !/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/.test(bssid)) {
if (typeof showNotification === 'function') {
showNotification('Invalid BSSID', 'Enter a valid MAC address (AA:BB:CC:DD:EE:FF)');
}
return;
}
targetBssid = bssid;
targetSsid = handoffData?.ssid || null;
locateActive = true;
// Reset stats
rssiHistory = [];
rssiMin = null;
rssiMax = null;
rssiSum = 0;
rssiCount = 0;
lastUpdateTime = 0;
// Update UI
updateTargetDisplay();
showHud(true);
updateStatDisplay('--', '--', '--', '--');
updateRssiDisplay('--', '');
updateDistanceDisplay('--');
clearBarSegments();
hideSignalLost();
// Toggle buttons
const startBtn = document.getElementById('wflStartBtn');
const stopBtn = document.getElementById('wflStopBtn');
const statusEl = document.getElementById('wflScanStatus');
if (startBtn) startBtn.style.display = 'none';
if (stopBtn) stopBtn.style.display = '';
if (statusEl) statusEl.style.display = '';
// Check if WiFi scan is running, auto-start deep scan if needed
checkAndStartScan().then(() => {
connectSSE();
});
}
function stop() {
locateActive = false;
// Close SSE
if (eventSource) {
eventSource.close();
eventSource = null;
}
// Clear timers
clearBeepTimer();
clearSignalLostTimer();
// Stop audio
stopAudio();
// Toggle buttons
const startBtn = document.getElementById('wflStartBtn');
const stopBtn = document.getElementById('wflStopBtn');
const statusEl = document.getElementById('wflScanStatus');
if (startBtn) startBtn.style.display = '';
if (stopBtn) stopBtn.style.display = 'none';
if (statusEl) statusEl.style.display = 'none';
// Show idle UI
showHud(false);
}
function destroy() {
stop();
modeActive = false;
targetBssid = null;
targetSsid = null;
}
function setActiveMode(active) {
modeActive = active;
}
// ========================================================================
// WiFi Scan Management
// ========================================================================
async function checkAndStartScan() {
try {
const resp = await fetch(`${API_BASE}/scan/status`);
const data = await resp.json();
if (data.scanning && data.scan_type === 'deep') {
debugLog('Deep scan already running');
return;
}
// Auto-start deep scan
debugLog('Starting deep scan for locate');
await fetch(`${API_BASE}/scan/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scan_type: 'deep' }),
});
} catch (e) {
debugLog('Error checking/starting scan:', e);
}
}
// ========================================================================
// SSE Connection
// ========================================================================
function connectSSE() {
if (eventSource) {
eventSource.close();
}
const streamUrl = `${API_BASE}/stream`;
eventSource = new EventSource(streamUrl);
eventSource.onopen = () => {
debugLog('SSE connected');
};
eventSource.onmessage = (event) => {
if (!locateActive || !targetBssid) return;
try {
const data = JSON.parse(event.data);
if (data.type === 'keepalive') return;
// Filter for our target BSSID
if (data.type === 'network_update' && data.network) {
const net = data.network;
const bssid = (net.bssid || '').toUpperCase();
if (bssid === targetBssid) {
const rssi = parseInt(net.rssi_current ?? net.signal ?? net.rssi, 10);
if (!isNaN(rssi)) {
// Pick up SSID if we don't have it yet
if (!targetSsid && net.essid) {
targetSsid = net.essid;
updateTargetDisplay();
}
updateMeter(rssi);
}
}
}
} catch (e) {
debugLog('SSE parse error:', e);
}
};
eventSource.onerror = () => {
debugLog('SSE error, reconnecting...');
if (locateActive) {
setTimeout(() => {
if (locateActive) connectSSE();
}, 3000);
}
};
}
// ========================================================================
// Signal Processing
// ========================================================================
function updateMeter(rssi) {
lastUpdateTime = Date.now();
hideSignalLost();
resetSignalLostTimer();
// Update stats
rssiCount++;
rssiSum += rssi;
if (rssiMin === null || rssi < rssiMin) rssiMin = rssi;
if (rssiMax === null || rssi > rssiMax) rssiMax = rssi;
const avg = Math.round(rssiSum / rssiCount);
// Update history
rssiHistory.push(rssi);
if (rssiHistory.length > MAX_RSSI_POINTS) {
rssiHistory.shift();
}
// Determine strength class
let cls = 'weak';
if (rssi >= -50) cls = 'good';
else if (rssi >= -70) cls = 'medium';
// Update displays
updateRssiDisplay(rssi, cls);
updateDistanceDisplay(estimateDistance(rssi));
updateBarSegments(rssi);
updateStatDisplay(rssi, rssiMin, rssiMax, avg);
drawRssiChart();
// Audio
if (audioEnabled) {
scheduleBeeps(rssi);
}
}
function estimateDistance(rssi) {
const n = ENV_PATH_LOSS[currentEnvironment] || 2.8;
const dist = Math.pow(10, (TX_POWER - rssi) / (10 * n));
if (dist < 1) return dist.toFixed(2) + ' m';
if (dist < 100) return dist.toFixed(1) + ' m';
return Math.round(dist) + ' m';
}
// ========================================================================
// UI Updates
// ========================================================================
function showHud(show) {
const hud = document.getElementById('wflHud');
const waiting = document.getElementById('wflWaiting');
if (hud) hud.style.display = show ? '' : 'none';
if (waiting) waiting.style.display = show ? 'none' : '';
}
function updateTargetDisplay() {
const ssidEl = document.getElementById('wflTargetSsid');
const bssidEl = document.getElementById('wflTargetBssid');
if (ssidEl) ssidEl.textContent = targetSsid || 'Unknown SSID';
if (bssidEl) bssidEl.textContent = targetBssid || '--';
}
function updateRssiDisplay(value, cls) {
const el = document.getElementById('wflRssiValue');
if (!el) return;
el.textContent = typeof value === 'number' ? value + ' dBm' : value;
el.className = 'wfl-rssi-display' + (cls ? ' ' + cls : '');
}
function updateDistanceDisplay(text) {
const el = document.getElementById('wflDistance');
if (el) el.textContent = text;
}
function updateStatDisplay(current, min, max, avg) {
const set = (id, v) => {
const el = document.getElementById(id);
if (el) el.textContent = v;
};
set('wflStatCurrent', typeof current === 'number' ? current + ' dBm' : current);
set('wflStatMin', typeof min === 'number' ? min + ' dBm' : min);
set('wflStatMax', typeof max === 'number' ? max + ' dBm' : max);
set('wflStatAvg', typeof avg === 'number' ? avg + ' dBm' : avg);
}
// ========================================================================
// Bar Segments
// ========================================================================
function buildBarSegments() {
const container = document.getElementById('wflBarContainer');
if (!container || container.children.length === BAR_SEGMENTS) return;
container.innerHTML = '';
for (let i = 0; i < BAR_SEGMENTS; i++) {
const seg = document.createElement('div');
seg.className = 'wfl-bar-segment';
container.appendChild(seg);
}
}
function updateBarSegments(rssi) {
const container = document.getElementById('wflBarContainer');
if (!container) return;
// Map RSSI -100..-20 to 0..20 active segments
const strength = Math.max(0, Math.min(1, (rssi + 100) / 80));
const activeCount = Math.round(strength * BAR_SEGMENTS);
const segments = container.children;
for (let i = 0; i < segments.length; i++) {
segments[i].classList.toggle('active', i < activeCount);
}
}
function clearBarSegments() {
const container = document.getElementById('wflBarContainer');
if (!container) return;
for (let i = 0; i < container.children.length; i++) {
container.children[i].classList.remove('active');
}
}
// ========================================================================
// RSSI Chart
// ========================================================================
function drawRssiChart() {
if (!chartCtx || !chartCanvas) return;
const w = chartCanvas.width = chartCanvas.parentElement.clientWidth - 16;
const h = chartCanvas.height = chartCanvas.parentElement.clientHeight - 24;
chartCtx.clearRect(0, 0, w, h);
if (rssiHistory.length < 2) return;
const minR = -100, maxR = -20;
const range = maxR - minR;
// Grid lines
chartCtx.strokeStyle = 'rgba(255,255,255,0.05)';
chartCtx.lineWidth = 1;
[-30, -50, -70, -90].forEach(v => {
const y = h - ((v - minR) / range) * h;
chartCtx.beginPath();
chartCtx.moveTo(0, y);
chartCtx.lineTo(w, y);
chartCtx.stroke();
});
// Draw RSSI line
const step = w / (MAX_RSSI_POINTS - 1);
chartCtx.beginPath();
chartCtx.strokeStyle = '#00ff88';
chartCtx.lineWidth = 2;
rssiHistory.forEach((rssi, i) => {
const x = i * step;
const y = h - ((rssi - minR) / range) * h;
if (i === 0) chartCtx.moveTo(x, y);
else chartCtx.lineTo(x, y);
});
chartCtx.stroke();
// Fill under
const lastIdx = rssiHistory.length - 1;
chartCtx.lineTo(lastIdx * step, h);
chartCtx.lineTo(0, h);
chartCtx.closePath();
chartCtx.fillStyle = 'rgba(0,255,136,0.08)';
chartCtx.fill();
}
// ========================================================================
// Audio Proximity
// ========================================================================
function playTone(freq, duration) {
if (!audioCtx || audioCtx.state !== 'running') return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.frequency.value = freq;
osc.type = 'sine';
gain.gain.value = 0.2;
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
osc.start();
osc.stop(audioCtx.currentTime + duration);
}
function playProximityTone(rssi) {
if (!audioCtx || audioCtx.state !== 'running') return;
const strength = Math.max(0, Math.min(1, (rssi + 100) / 70));
const freq = 400 + strength * 800;
const duration = 0.06 + (1 - strength) * 0.12;
playTone(freq, duration);
}
function scheduleBeeps(rssi) {
clearBeepTimer();
playProximityTone(rssi);
// Repeat interval: stronger signal = faster beeps
const strength = Math.max(0, Math.min(1, (rssi + 100) / 70));
const interval = 1200 - strength * 1000; // 1200ms (weak) to 200ms (strong)
beepTimer = setInterval(() => {
if (audioEnabled && locateActive) {
playProximityTone(rssi);
} else {
clearBeepTimer();
}
}, interval);
}
function clearBeepTimer() {
if (beepTimer) {
clearInterval(beepTimer);
beepTimer = null;
}
}
function toggleAudio() {
const cb = document.getElementById('wflAudioEnable');
audioEnabled = cb?.checked || false;
if (audioEnabled) {
if (!audioCtx) {
try {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
} catch (e) {
console.error('[WiFiLocate] AudioContext creation failed:', e);
return;
}
}
audioCtx.resume().then(() => {
playTone(600, 0.08);
});
} else {
stopAudio();
}
}
function stopAudio() {
audioEnabled = false;
clearBeepTimer();
const cb = document.getElementById('wflAudioEnable');
if (cb) cb.checked = false;
}
// ========================================================================
// Signal Lost Timer
// ========================================================================
function resetSignalLostTimer() {
clearSignalLostTimer();
signalLostTimer = setTimeout(() => {
if (locateActive) showSignalLost();
}, SIGNAL_LOST_TIMEOUT_MS);
}
function clearSignalLostTimer() {
if (signalLostTimer) {
clearTimeout(signalLostTimer);
signalLostTimer = null;
}
}
function showSignalLost() {
const el = document.getElementById('wflSignalLost');
if (el) el.style.display = '';
clearBeepTimer();
}
function hideSignalLost() {
const el = document.getElementById('wflSignalLost');
if (el) el.style.display = 'none';
}
// ========================================================================
// Environment
// ========================================================================
function setEnvironment(env) {
currentEnvironment = env;
document.querySelectorAll('.wfl-env-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.env === env);
});
// Recalc distance with last known RSSI
if (rssiHistory.length > 0) {
const lastRssi = rssiHistory[rssiHistory.length - 1];
updateDistanceDisplay(estimateDistance(lastRssi));
}
}
// ========================================================================
// Handoff from WiFi mode
// ========================================================================
function handoff(info) {
handoffData = info;
const bssidInput = document.getElementById('wflBssid');
if (bssidInput) bssidInput.value = info.bssid || '';
targetSsid = info.ssid || null;
const card = document.getElementById('wflHandoffCard');
const nameEl = document.getElementById('wflHandoffName');
const metaEl = document.getElementById('wflHandoffMeta');
if (card) card.style.display = '';
if (nameEl) nameEl.textContent = info.ssid || 'Hidden Network';
if (metaEl) metaEl.textContent = info.bssid || '';
// Switch to WiFi Locate mode
if (typeof switchMode === 'function') {
switchMode('wifi_locate');
}
}
function clearHandoff() {
handoffData = null;
const card = document.getElementById('wflHandoffCard');
if (card) card.style.display = 'none';
}
// ========================================================================
// Public API
// ========================================================================
return {
init,
start,
stop,
destroy,
handoff,
clearHandoff,
setEnvironment,
toggleAudio,
setActiveMode,
};
})();