mirror of
https://github.com/smittix/intercept.git
synced 2026-06-11 07:23:30 -07:00
feat: WiFi Locate mode, mobile nav groups, v2.24.0
Add WiFi Locate mode for locating access points by BSSID with real-time signal meter, distance estimation, RSSI history chart, and audio proximity tones. Includes hand-off from WiFi detail drawer, environment presets (Free Space/Outdoor/Indoor), and signal-lost detection. Also includes: - Mobile navigation reorganized into labeled groups (SIG/TRK/SPC/WIFI/INTEL/SYS) - flask-limiter made optional with graceful degradation - Fix radiosonde setup missing semver Python dependency - Documentation updates (FEATURES, USAGE, UI_GUIDE, GitHub Pages site) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
/* WiFi Locate Mode Styles */
|
||||
|
||||
/* Environment preset grid */
|
||||
.wfl-env-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wfl-env-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 8px 4px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.wfl-env-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.wfl-env-btn.active {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border-color: var(--accent-green, #00ff88);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.wfl-env-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wfl-env-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.wfl-env-n {
|
||||
font-size: 9px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
VISUALS CONTAINER
|
||||
============================================ */
|
||||
|
||||
.wfl-visuals-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PROXIMITY HUD
|
||||
============================================ */
|
||||
|
||||
.wfl-hud {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wfl-hud-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.wfl-hud-target {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wfl-target-ssid {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wfl-target-bssid {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.wfl-hud-audio-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfl-hud-audio-toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wfl-hud-stop-btn {
|
||||
padding: 5px 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #ff3366;
|
||||
background: rgba(255, 51, 102, 0.1);
|
||||
border: 1px solid rgba(255, 51, 102, 0.3);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wfl-hud-stop-btn:hover {
|
||||
background: rgba(255, 51, 102, 0.2);
|
||||
border-color: #ff3366;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RSSI DISPLAY — big dBm number
|
||||
============================================ */
|
||||
|
||||
.wfl-rssi-display {
|
||||
font-size: 64px;
|
||||
font-weight: 800;
|
||||
font-family: var(--font-mono);
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
color: var(--text-dim);
|
||||
transition: color 0.3s;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.wfl-rssi-display.good {
|
||||
color: #22c55e;
|
||||
text-shadow: 0 0 20px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.wfl-rssi-display.medium {
|
||||
color: #eab308;
|
||||
text-shadow: 0 0 20px rgba(234, 179, 8, 0.2);
|
||||
}
|
||||
|
||||
.wfl-rssi-display.weak {
|
||||
color: #ef4444;
|
||||
text-shadow: 0 0 20px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DISTANCE ESTIMATE
|
||||
============================================ */
|
||||
|
||||
.wfl-distance {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SIGNAL BAR — 20 horizontal segments
|
||||
============================================ */
|
||||
|
||||
.wfl-bar-container {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
padding: 8px 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wfl-bar-segment {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
flex: 1;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.wfl-bar-segment.active:nth-child(-n+7) {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.wfl-bar-segment.active:nth-child(n+8):nth-child(-n+14) {
|
||||
background: #eab308;
|
||||
box-shadow: 0 0 6px rgba(234, 179, 8, 0.3);
|
||||
}
|
||||
|
||||
.wfl-bar-segment.active:nth-child(n+15) {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RSSI CHART — canvas wrapper
|
||||
============================================ */
|
||||
|
||||
.wfl-rssi-chart-container {
|
||||
height: 120px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wfl-rssi-chart-container .wfl-chart-label {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 8px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
#wflRssiChart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
STATS — 4-column grid
|
||||
============================================ */
|
||||
|
||||
.wfl-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.wfl-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.wfl-stat-value {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.wfl-stat-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
SIGNAL LOST OVERLAY
|
||||
============================================ */
|
||||
|
||||
.wfl-signal-lost {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #ef4444;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 4px;
|
||||
text-transform: uppercase;
|
||||
z-index: 10;
|
||||
animation: wfl-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes wfl-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
WAITING STATE
|
||||
============================================ */
|
||||
|
||||
.wfl-waiting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LOCATE BUTTON — WiFi detail drawer
|
||||
============================================ */
|
||||
|
||||
.wfl-locate-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--accent-green, #00ff88);
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.wfl-locate-btn:hover {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
border-color: var(--accent-green, #00ff88);
|
||||
}
|
||||
|
||||
.wfl-locate-btn svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.wfl-rssi-display {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.wfl-bar-segment {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.wfl-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.wfl-hud-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wfl-rssi-chart-container {
|
||||
height: 90px;
|
||||
}
|
||||
}
|
||||
@@ -322,6 +322,57 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ============== MOBILE NAV GROUPS ============== */
|
||||
.mobile-nav-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-nav-group-label {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
padding: 4px 6px 4px 8px;
|
||||
white-space: nowrap;
|
||||
border-left: 2px solid var(--border-color, #1f2937);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-nav-group:first-child .mobile-nav-group-label {
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* ============== MOBILE NAV UTILITIES ============== */
|
||||
.mobile-nav-utils {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid var(--accent-cyan, #4a9eff);
|
||||
}
|
||||
|
||||
/* Hide mobile nav utilities on desktop (desktop has .nav-utilities) */
|
||||
@media (min-width: 1024px) {
|
||||
.mobile-nav-utils {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============== TABLET: WRAP MOBILE NAV ============== */
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.mobile-nav-bar {
|
||||
flex-wrap: wrap;
|
||||
overflow-x: visible;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide mobile nav bar on desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.mobile-nav-bar {
|
||||
|
||||
@@ -8,6 +8,7 @@ const CheatSheets = (function () {
|
||||
wifi: { title: 'WiFi Scanner', icon: '📡', hardware: 'WiFi adapter (monitor mode)', description: 'Scans WiFi networks and clients via airodump-ng or nmcli.', whatToExpect: 'SSIDs, BSSIDs, channel, signal strength, encryption type.', tips: ['Run airmon-ng check kill before monitoring', 'Proximity radar shows signal strength', 'TSCM baseline detects rogue APs'] },
|
||||
bluetooth: { title: 'Bluetooth Scanner', icon: '🔵', hardware: 'Built-in or USB Bluetooth adapter', description: 'Scans BLE and classic Bluetooth devices. Identifies trackers.', whatToExpect: 'Device names, MACs, RSSI, manufacturer, tracker type.', tips: ['Proximity radar shows device distance', 'Known tracker DB has 47K+ fingerprints', 'Use BT Locate to physically find a tracker'] },
|
||||
bt_locate: { title: 'BT Locate (SAR)', icon: '🎯', hardware: 'Bluetooth adapter + optional GPS', description: 'SAR Bluetooth locator. Tracks RSSI over time to triangulate position.', whatToExpect: 'RSSI chart, proximity band (IMMEDIATE/NEAR/FAR), GPS trail.', tips: ['Handoff from Bluetooth mode to lock onto a device', 'Indoor n=3.0 gives better distance estimates', 'Follow the heat trail toward stronger signal'] },
|
||||
wifi_locate: { title: 'WiFi Locate', icon: '📶', hardware: 'WiFi adapter (monitor mode)', description: 'Locate a WiFi AP by BSSID with real-time signal strength tracking.', whatToExpect: 'Big dBm meter, signal bar, RSSI chart, distance estimate, proximity beeps.', tips: ['Handoff from WiFi mode — click Locate on any network', 'Deep scan required for continuous RSSI updates', 'Indoor n=3.5 gives better distance estimates indoors', 'Enable audio for proximity tones that speed up as you get closer'] },
|
||||
meshtastic: { title: 'Meshtastic', icon: '🕸️', hardware: 'Meshtastic LoRa node (USB)', description: 'Monitors Meshtastic LoRa mesh network messages and positions.', whatToExpect: 'Text messages, node map, telemetry.', tips: ['Default channel must match your mesh', 'Long-Fast has best range', 'GPS nodes appear on map automatically'] },
|
||||
adsb: { title: 'ADS-B Aircraft', icon: '✈️', hardware: 'RTL-SDR + 1090MHz antenna', description: 'Tracks aircraft via ADS-B Mode S transponders using dump1090.', whatToExpect: 'Flight numbers, positions, altitude, speed, squawk codes.', tips: ['1090MHz — use a dedicated antenna', 'Emergency squawks: 7500 hijack, 7600 radio fail, 7700 emergency', 'Full Dashboard shows map view'] },
|
||||
ais: { title: 'AIS Vessels', icon: '🚢', hardware: 'RTL-SDR + VHF antenna (162 MHz)', description: 'Tracks marine vessels via AIS using AIS-catcher.', whatToExpect: 'MMSI, vessel names, positions, speed, heading, cargo type.', tips: ['VHF antenna centered at 162MHz works best', 'DSC distress alerts appear in red', 'Coastline range ~40 nautical miles'] },
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
/**
|
||||
* 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.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,
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user