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:
Smittix
2026-03-10 22:49:03 +00:00
parent e383575c80
commit ab033b35d3
19 changed files with 1328 additions and 53 deletions

View File

@@ -2,6 +2,18 @@
All notable changes to iNTERCEPT will be documented in this file.
## [2.24.0] - 2026-03-10
### Added
- **WiFi Locate Mode** - Locate WiFi access points by BSSID with real-time signal meter, distance estimation, RSSI chart, and audio proximity tones. Hand-off from WiFi detail drawer, environment presets (Free Space/Outdoor/Indoor), and signal-lost detection.
### Changed
- Mobile navigation bar reorganized into labeled groups (SIG, TRK, SPC, WIFI, INTEL, SYS) for better usability
- flask-limiter made optional — rate limiting degrades gracefully if package is missing
### Fixed
- Radiosonde setup missing `semver` Python dependency — `setup.sh` now explicitly installs it alongside `requirements.txt`
## [2.23.0] - 2026-02-27
### Added

View File

@@ -204,7 +204,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& cd /tmp \
&& git clone --depth 1 https://github.com/projecthorus/radiosonde_auto_rx.git \
&& cd radiosonde_auto_rx/auto_rx \
&& pip install --no-cache-dir -r requirements.txt \
&& pip install --no-cache-dir -r requirements.txt semver \
&& bash build.sh \
&& mkdir -p /opt/radiosonde_auto_rx/auto_rx \
&& cp -r . /opt/radiosonde_auto_rx/auto_rx/ \

View File

@@ -45,6 +45,7 @@ Support the developer of this open-source project
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
- **Bluetooth Scanning** - Device discovery and tracker detection (with Ubertooth support)
- **BT Locate** - SAR Bluetooth device location with GPS-tagged signal trail mapping and proximity alerts
- **WiFi Locate** - Locate WiFi access points by BSSID with real-time signal meter, distance estimation, and proximity audio
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
- **Meshtastic** - LoRa mesh network integration

25
app.py
View File

@@ -42,8 +42,12 @@ from utils.constants import (
QUEUE_MAX_SIZE,
)
import logging
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
try:
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
_has_limiter = True
except ImportError:
_has_limiter = False
# Track application start time for uptime calculation
import time as _time
_app_start_time = _time.time()
@@ -54,11 +58,24 @@ app = Flask(__name__)
app.secret_key = "signals_intelligence_secret" # Required for flash messages
# Set up rate limiting
limiter = Limiter(
if _has_limiter:
limiter = Limiter(
key_func=get_remote_address,
app=app,
storage_uri="memory://",
)
)
else:
logging.getLogger('intercept').warning(
"flask-limiter not installed rate limiting disabled. "
"Install with: pip install flask-limiter"
)
class _NoopLimiter:
"""Stub so @limiter.limit() decorators are silently ignored."""
def limit(self, *a, **kw):
def decorator(f):
return f
return decorator
limiter = _NoopLimiter()
# Disable Werkzeug debugger PIN (not needed for local development tool)
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'

View File

@@ -7,10 +7,20 @@ import os
import sys
# Application version
VERSION = "2.23.0"
VERSION = "2.24.0"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
{
"version": "2.24.0",
"date": "March 2026",
"highlights": [
"WiFi Locate mode — locate access points by BSSID with real-time signal meter, distance estimation, RSSI chart, and audio proximity tones",
"Mobile navigation reorganized into labeled groups for better usability",
"flask-limiter made optional for graceful degradation",
"Radiosonde setup fix — missing semver dependency",
]
},
{
"version": "2.23.0",
"date": "February 2026",

View File

@@ -276,6 +276,34 @@ Search and rescue Bluetooth device location with GPS-tagged signal trail mapping
- Bluetooth adapter (built-in or USB)
- GPS receiver (optional, falls back to manual coordinates)
## WiFi Locate
Locate a WiFi access point by BSSID using real-time signal strength tracking.
### Core Features
- **Target by BSSID** - Enter any MAC address or hand off from the WiFi scanner
- **Real-time signal meter** - Large dBm display with color-coded strength (good/medium/weak)
- **20-segment signal bar** - Visual proximity indicator with red/yellow/green segments
- **RSSI history chart** - Canvas sparkline showing signal trend over time
- **Distance estimation** - Log-distance path loss model with configurable environment presets
- **Audio proximity alerts** - Web Audio API tones that increase in pitch and frequency as signal strengthens
- **Signal lost detection** - 30-second timeout with visual overlay when target disappears
- **Hand-off from WiFi mode** - One-click transfer from WiFi detail drawer to WiFi Locate
- **Stats tracking** - Current, min, max, and average RSSI across session
### Environment Presets
- **Open Field** (n=2.0) - Free space path loss
- **Outdoor** (n=2.8) - Typical outdoor environment (default)
- **Indoor** (n=3.5) - Indoor with walls and obstacles
### Mode Transition
- WiFi scan is preserved when switching between WiFi and WiFi Locate modes
- Deep scan auto-starts if not already running
### Requirements
- WiFi adapter capable of monitor mode
- aircrack-ng suite for deep scanning
## GPS Mode
Real-time GPS position tracking with live map visualization.

View File

@@ -212,6 +212,7 @@ Extended base for full-screen dashboards (maps, visualizations).
| `websdr` | WebSDR |
| `subghz` | Sub-GHz analyzer |
| `bt_locate` | BT Locate |
| `wifi_locate` | WiFi Locate |
| `analytics` | Analytics dashboard |
| `spaceweather` | Space weather |
### Navigation Groups
@@ -220,7 +221,7 @@ The navigation is organized into groups:
- **Signals**: Pager, 433MHz, Meters, Listening Post, SubGHz
- **Tracking**: Aircraft, Vessels, APRS, GPS
- **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, Space Weather
- **Wireless**: WiFi, Bluetooth, BT Locate, Meshtastic
- **Wireless**: WiFi, Bluetooth, BT Locate, WiFi Locate, Meshtastic
- **Intel**: TSCM, Analytics, Spy Stations, WebSDR
---

View File

@@ -377,6 +377,39 @@ Digital Selective Calling monitoring runs alongside AIS:
- The RSSI chart shows signal trend over time — use it to determine if you're getting closer
- Clear the trail when starting a new search area
## WiFi Locate Mode
1. **Set Target** - Enter a BSSID (MAC address) in AA:BB:CC:DD:EE:FF format, or hand off from WiFi mode
2. **Choose Environment** - Select the RF environment preset:
- **Open Field** (n=2.0) - Best for open areas with line-of-sight
- **Outdoor** (n=2.8) - Default, works well in most outdoor settings
- **Indoor** (n=3.5) - For buildings with walls and obstacles
3. **Start Locate** - Click "Start Locate" to begin tracking
4. **Monitor Signal** - The HUD shows:
- Large dBm reading with color coding (green/yellow/red)
- 20-segment signal bar for quick visual reference
- Estimated distance based on path loss model
- RSSI history chart for trend analysis
- Current/min/max/average statistics
5. **Follow the Signal** - Move towards stronger signal (higher RSSI / closer distance)
6. **Audio Alerts** - Enable audio for proximity tones that speed up as signal strengthens
### Hand-off from WiFi Mode
1. Open WiFi scanning mode and start a deep scan
2. Click any network to open the detail drawer
3. Click the "Locate" button in the drawer header
4. WiFi Locate opens with the BSSID and SSID pre-filled
5. Click "Start Locate" to begin tracking
### Tips
- Deep scan is required for continuous RSSI updates — WiFi Locate auto-starts it if needed
- The WiFi scan is preserved when switching between WiFi and WiFi Locate modes
- Signal lost overlay appears after 30 seconds without an update from the target
- The distance estimate is approximate — environment preset significantly affects accuracy
- Indoor environments with walls attenuate signal more than open field
## GPS Mode
1. **Start GPS** - Click "Start" to connect to gpsd and begin position tracking

View File

@@ -192,6 +192,11 @@
<h3>BT Locate</h3>
<p>SAR Bluetooth device location with GPS-tagged signal trail mapping, IRK-based RPA resolution, and proximity audio alerts.</p>
</div>
<div class="feature-card" data-category="wireless">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="10" r="2"/><path d="M12 14v-2"/></svg></div>
<h3>WiFi Locate</h3>
<p>Locate WiFi access points by BSSID with real-time signal meter, distance estimation, RSSI chart, and audio proximity tones.</p>
</div>
<div class="feature-card" data-category="intel">
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s-8-4.5-8-11.8A8 8 0 0 1 12 2a8 8 0 0 1 8 8.2c0 7.3-8 11.8-8 11.8z"/><circle cx="12" cy="10" r="3"/><path d="M12 2v3"/><path d="M4.93 4.93l2.12 2.12"/><path d="M20 12h-3"/></svg></div>
<h3>TSCM</h3>

View File

@@ -1,6 +1,6 @@
[project]
name = "intercept"
version = "2.23.0"
version = "2.24.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"

View File

@@ -1060,13 +1060,13 @@ install_radiosonde_auto_rx() {
info "Installing Python dependencies..."
cd "$tmp_dir/radiosonde_auto_rx/auto_rx"
if [ -x "$project_dir/venv/bin/pip" ]; then
"$project_dir/venv/bin/pip" install --quiet -r requirements.txt || {
"$project_dir/venv/bin/pip" install --quiet -r requirements.txt semver || {
warn "Failed to install radiosonde_auto_rx Python dependencies"
exit 1
}
else
pip3 install --quiet --break-system-packages -r requirements.txt 2>/dev/null \
|| pip3 install --quiet -r requirements.txt || {
pip3 install --quiet --break-system-packages -r requirements.txt semver 2>/dev/null \
|| pip3 install --quiet -r requirements.txt semver || {
warn "Failed to install radiosonde_auto_rx Python dependencies"
exit 1
}

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -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'] },

View File

@@ -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,
};
})();

View File

@@ -81,6 +81,7 @@
gps: "{{ url_for('static', filename='css/modes/gps.css') }}",
subghz: "{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9",
bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4",
wifi_locate: "{{ url_for('static', filename='css/modes/wifi_locate.css') }}?v={{ version }}&r=wflocate1",
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}",
wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}",
morse: "{{ url_for('static', filename='css/modes/morse.css') }}",
@@ -369,6 +370,10 @@
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/><path d="M9.5 8.5l3 3 2-4-2 4-3 3"/></svg></span>
<span class="mode-name">BT Locate</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('wifi_locate')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="10" r="2"/><path d="M12 14v-2"/></svg></span>
<span class="mode-name">WF Locate</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('meshtastic')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span>
<span class="mode-name">Meshtastic</span>
@@ -721,6 +726,7 @@
{% include 'partials/modes/subghz.html' %}
{% include 'partials/modes/bt_locate.html' %}
{% include 'partials/modes/wifi_locate.html' %}
{% include 'partials/modes/waterfall.html' %}
{% include 'partials/modes/meteor.html' %}
{% include 'partials/modes/system.html' %}
@@ -889,6 +895,10 @@
<span class="wifi-detail-essid" id="wifiDetailEssid">Network Name</span>
<span class="wifi-detail-bssid" id="wifiDetailBssid">00:00:00:00:00:00</span>
</div>
<button class="wfl-locate-btn" onclick="WiFiLocate.handoff({bssid: document.getElementById('wifiDetailBssid')?.textContent, ssid: document.getElementById('wifiDetailEssid')?.textContent})" title="Locate this AP">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>
Locate
</button>
<button class="wifi-detail-close" onclick="WiFiMode.closeDetail()">&times;</button>
</div>
<div class="wifi-detail-content" id="wifiDetailContent">
@@ -1963,6 +1973,39 @@
</div>
</div>
<!-- WiFi Locate Dashboard -->
<div id="wflVisuals" class="wfl-visuals-container" style="display: none;">
<div class="wfl-hud" id="wflHud" style="display: none;">
<div class="wfl-hud-header">
<div class="wfl-hud-target">
<span class="wfl-target-ssid" id="wflTargetSsid">--</span>
<span class="wfl-target-bssid" id="wflTargetBssid">--</span>
</div>
<label class="wfl-hud-audio-toggle">
<input type="checkbox" id="wflAudioEnable" onchange="WiFiLocate.toggleAudio()"> Audio
</label>
<button class="wfl-hud-stop-btn" onclick="WiFiLocate.stop()">Stop Tracking</button>
</div>
<div class="wfl-rssi-display" id="wflRssiValue">--</div>
<div class="wfl-distance" id="wflDistance">--</div>
<div class="wfl-bar-container" id="wflBarContainer"></div>
<div class="wfl-rssi-chart-container">
<span class="wfl-chart-label">RSSI History</span>
<canvas id="wflRssiChart"></canvas>
</div>
<div class="wfl-stats">
<div class="wfl-stat"><span class="wfl-stat-value" id="wflStatCurrent">--</span><span class="wfl-stat-label">Current</span></div>
<div class="wfl-stat"><span class="wfl-stat-value" id="wflStatMin">--</span><span class="wfl-stat-label">Min</span></div>
<div class="wfl-stat"><span class="wfl-stat-value" id="wflStatMax">--</span><span class="wfl-stat-label">Max</span></div>
<div class="wfl-stat"><span class="wfl-stat-value" id="wflStatAvg">--</span><span class="wfl-stat-label">Avg</span></div>
</div>
<div class="wfl-signal-lost" id="wflSignalLost" style="display: none;">SIGNAL LOST</div>
</div>
<div class="wfl-waiting" id="wflWaiting">
<p>Enter a target BSSID and click Start Locate</p>
</div>
</div>
<!-- WebSDR Dashboard -->
<div id="websdrVisuals" style="display: none; padding: 12px; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow: hidden;">
<!-- Audio Control Bar (hidden until connected) -->
@@ -3391,6 +3434,7 @@
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script>
<script src="{{ url_for('static', filename='js/modes/wifi_locate.js') }}?v={{ version }}&r=wflocate1"></script>
<script src="{{ url_for('static', filename='js/modes/wefax.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/morse.js') }}?v={{ version }}&r=morse_iq12"></script>
<script src="{{ url_for('static', filename='js/modes/ook.js') }}?v={{ version }}&r=ook2"></script>
@@ -3546,6 +3590,7 @@
wifi: { label: 'WiFi', indicator: 'WIFI', outputTitle: 'WiFi Scanner', group: 'wireless' },
bluetooth: { label: 'Bluetooth', indicator: 'BLUETOOTH', outputTitle: 'Bluetooth Scanner', group: 'wireless' },
bt_locate: { label: 'BT Locate', indicator: 'BT LOCATE', outputTitle: 'BT Locate — SAR Tracker', group: 'wireless' },
wifi_locate: { label: 'WiFi Locate', indicator: 'WF LOCATE', outputTitle: 'WiFi Locate', group: 'wireless' },
meshtastic: { label: 'Meshtastic', indicator: 'MESHTASTIC', outputTitle: 'Meshtastic Mesh Monitor', group: 'wireless' },
tscm: { label: 'TSCM', indicator: 'TSCM', outputTitle: 'TSCM Counter-Surveillance', group: 'intel' },
spystations: { label: 'Spy Stations', indicator: 'SPY STATIONS', outputTitle: 'Spy Stations', group: 'intel' },
@@ -4099,6 +4144,7 @@
bluetooth: () => typeof BluetoothMode !== 'undefined' && BluetoothMode.destroy?.(),
wifi: () => typeof WiFiMode !== 'undefined' && WiFiMode.destroy?.(),
bt_locate: () => typeof BtLocate !== 'undefined' && BtLocate.destroy?.(),
wifi_locate: () => typeof WiFiLocate !== 'undefined' && WiFiLocate.destroy?.(),
sstv: () => typeof SSTV !== 'undefined' && SSTV.destroy?.(),
sstv_general: () => typeof SSTVGeneral !== 'undefined' && SSTVGeneral.destroy?.(),
websdr: () => typeof WebSDR !== 'undefined' && WebSDR.destroy?.(),
@@ -4317,7 +4363,10 @@
&& typeof WiFiMode.isScanning === 'function'
&& WiFiMode.isScanning()
) || isWifiRunning;
if (wifiScanActive) {
const isWifiModeTransition =
(currentMode === 'wifi' && mode === 'wifi_locate') ||
(currentMode === 'wifi_locate' && mode === 'wifi');
if (wifiScanActive && !isWifiModeTransition) {
stopTasks.push(awaitStopAction('wifi', () => stopWifiScan(), LOCAL_STOP_TIMEOUT_MS));
}
const btScanActive = (typeof BluetoothMode !== 'undefined' &&
@@ -4378,6 +4427,10 @@
document.querySelectorAll('.mobile-nav-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === mode);
});
const activeMobileBtn = document.querySelector('.mobile-nav-btn.active');
if (activeMobileBtn) {
activeMobileBtn.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}
document.getElementById('pagerMode')?.classList.toggle('active', mode === 'pager');
document.getElementById('sensorMode')?.classList.toggle('active', mode === 'sensor');
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
@@ -4390,6 +4443,7 @@
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
document.getElementById('btLocateMode')?.classList.toggle('active', mode === 'bt_locate');
document.getElementById('wflMode')?.classList.toggle('active', mode === 'wifi_locate');
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
@@ -4442,6 +4496,7 @@
const websdrVisuals = document.getElementById('websdrVisuals');
const subghzVisuals = document.getElementById('subghzVisuals');
const btLocateVisuals = document.getElementById('btLocateVisuals');
const wflVisuals = document.getElementById('wflVisuals');
const wefaxVisuals = document.getElementById('wefaxVisuals');
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
const waterfallVisuals = document.getElementById('waterfallVisuals');
@@ -4466,6 +4521,7 @@
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
if (wflVisuals) wflVisuals.style.display = mode === 'wifi_locate' ? 'flex' : 'none';
if (wefaxVisuals) wefaxVisuals.style.display = mode === 'wefax' ? 'flex' : 'none';
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? 'flex' : 'none';
@@ -4477,6 +4533,9 @@
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
BtLocate.setActiveMode(mode === 'bt_locate');
}
if (typeof WiFiLocate !== 'undefined' && WiFiLocate.setActiveMode) {
WiFiLocate.setActiveMode(mode === 'wifi_locate');
}
// Hide sidebar by default for Meshtastic mode, show for others
const mainContent = document.querySelector('.main-content');
@@ -4655,6 +4714,8 @@
setTimeout(() => {
if (typeof BtLocate !== 'undefined' && BtLocate.invalidateMap) BtLocate.invalidateMap();
}, 320);
} else if (mode === 'wifi_locate') {
WiFiLocate.init();
} else if (mode === 'wefax') {
WeFax.init();
} else if (mode === 'spaceweather') {

View File

@@ -51,6 +51,7 @@
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span><span class="desc">WiFi - Network scanner</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span><span class="desc">Bluetooth - BT/BLE scanner</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/><path d="M9.5 8.5l3 3 2-4-2 4-3 3"/></svg></span><span class="desc">BT Locate - Bluetooth device locator</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="10" r="2"/><path d="M12 14v-2"/></svg></span><span class="desc">WiFi Locate - WiFi AP locator</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span><span class="desc">TSCM - Counter-surveillance</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span><span class="desc">Satellite - Pass prediction</span></div>
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg></span><span class="desc">ISS SSTV - Space station image decoder</span></div>
@@ -249,6 +250,16 @@
<li>Useful for finding lost devices or detecting unwanted trackers</li>
</ul>
<h3>WiFi Locate Mode</h3>
<ul class="tip-list">
<li>Locate a WiFi access point by BSSID using real-time signal strength</li>
<li>Big dBm meter, 20-segment signal bar, and RSSI history chart</li>
<li>Distance estimation using log-distance path loss model with environment presets</li>
<li>Audio proximity tones that speed up as signal strengthens</li>
<li>Hand off from WiFi mode &mdash; click "Locate" on any network in the detail drawer</li>
<li>Deep scan auto-starts if not already running</li>
</ul>
<h3>TSCM Mode</h3>
<ul class="tip-list">
<li>Technical Surveillance Countermeasures sweep</li>
@@ -362,6 +373,7 @@
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
<li><strong>Bluetooth:</strong> Bluetooth adapter, bluez (hcitool/bluetoothctl)</li>
<li><strong>BT Locate:</strong> Bluetooth adapter, bluez</li>
<li><strong>WiFi Locate:</strong> WiFi adapter (monitor mode), aircrack-ng suite</li>
<li><strong>TSCM:</strong> WiFi adapter, Bluetooth adapter, RTL-SDR (all optional)</li>
<li>Run as root/sudo for full hardware access</li>
</ul>

View File

@@ -0,0 +1,66 @@
<!-- WIFI LOCATE MODE -->
<div id="wflMode" class="mode-content">
<div class="section">
<h3>WiFi Locate</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Locate a WiFi access point by BSSID &mdash; real-time signal strength meter with proximity audio.
</p>
</div>
<!-- Target Lock -->
<div class="section">
<h3>Target</h3>
<div id="wflHandoffCard" style="display: none; background: rgba(0,255,136,0.08); border: 1px solid rgba(0,255,136,0.3); border-radius: 6px; padding: 8px; margin-bottom: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 10px; color: var(--accent-green); text-transform: uppercase; font-weight: 600;">Handed off from WiFi</span>
<button onclick="WiFiLocate.clearHandoff()" style="background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 10px;">&times;</button>
</div>
<div id="wflHandoffName" style="font-size: 12px; font-weight: 600; color: var(--text-primary); margin-top: 4px;"></div>
<div id="wflHandoffMeta" style="font-size: 10px; color: var(--text-dim); font-family: var(--font-mono);"></div>
</div>
<label class="input-label">BSSID (MAC Address)</label>
<input type="text" id="wflBssid" class="text-input" placeholder="AA:BB:CC:DD:EE:FF" style="font-family: var(--font-mono); font-size: 11px;">
</div>
<!-- Environment Preset -->
<div class="section">
<h3>Environment</h3>
<div class="wfl-env-grid">
<button class="wfl-env-btn" data-env="FREE_SPACE" onclick="WiFiLocate.setEnvironment('FREE_SPACE')">
<span class="wfl-env-icon">&#127968;</span>
<span class="wfl-env-label">Open Field</span>
<span class="wfl-env-n">n=2.0</span>
</button>
<button class="wfl-env-btn active" data-env="OUTDOOR" onclick="WiFiLocate.setEnvironment('OUTDOOR')">
<span class="wfl-env-icon">&#127795;</span>
<span class="wfl-env-label">Outdoor</span>
<span class="wfl-env-n">n=2.8</span>
</button>
<button class="wfl-env-btn" data-env="INDOOR" onclick="WiFiLocate.setEnvironment('INDOOR')">
<span class="wfl-env-icon">&#127970;</span>
<span class="wfl-env-label">Indoor</span>
<span class="wfl-env-n">n=3.5</span>
</button>
</div>
</div>
<!-- Info note -->
<div class="section">
<p class="info-text" style="font-size: 10px; color: var(--text-dim);">
Deep scan recommended for continuous RSSI tracking. Will auto-start if not running.
</p>
</div>
<!-- Controls -->
<div class="section">
<div style="display: flex; gap: 6px;">
<button class="run-btn" id="wflStartBtn" onclick="WiFiLocate.start()">Start Locate</button>
<button class="stop-btn" id="wflStopBtn" onclick="WiFiLocate.stop()" style="display: none;">Stop</button>
</div>
<div id="wflScanStatus" style="display: none; margin-top: 6px; font-size: 10px; color: var(--text-dim);">
<span id="wflScanDot" style="display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #22c55e; margin-right: 4px; vertical-align: middle;"></span>
<span id="wflScanText">WiFi scanner active</span>
</div>
</div>
</div>

View File

@@ -124,6 +124,7 @@
{{ mode_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg>') }}
{{ mode_item('bluetooth', 'Bluetooth', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
{{ mode_item('bt_locate', 'BT Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/><path d="M9.5 8.5l3 3 2-4-2 4-3 3"/></svg>') }}
{{ mode_item('wifi_locate', 'WF Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="10" r="2"/><path d="M12 14v-2"/></svg>') }}
{{ mode_item('meshtastic', 'Meshtastic', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
</div>
</div>
@@ -213,19 +214,31 @@
{# Mobile Navigation Bar #}
<nav class="mobile-nav-bar" id="mobileNavBar">
{# Signals #}
{# Signals Group #}
<div class="mobile-nav-group" data-group="signals">
<span class="mobile-nav-group-label">SIG</span>
{{ mobile_item('pager', 'Pager', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg>') }}
{{ mobile_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
{{ mobile_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h4l3-8 3 16 3-8h4"/></svg>') }}
{{ mobile_item('morse', 'Morse', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg>') }}
{{ mobile_item('ook', 'OOK', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h3"/><path d="M19 12h3"/><rect x="5" y="8" width="4" height="8" rx="1"/><rect x="10" y="9" width="4" height="6" rx="1"/><rect x="15" y="7" width="4" height="10" rx="1"/></svg>') }}
{# Tracking #}
</div>
{# Tracking Group #}
<div class="mobile-nav-group" data-group="tracking">
<span class="mobile-nav-group-label">TRK</span>
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
{{ mobile_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}
{{ mobile_item('aprs', 'APRS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>') }}
{{ mobile_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
{# Space #}
{{ mobile_item('radiosonde', 'Sonde', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v6"/><circle cx="12" cy="12" r="4"/><path d="M12 16v6"/><path d="M4.93 4.93l4.24 4.24"/><path d="M14.83 14.83l4.24 4.24"/></svg>') }}
</div>
{# Space Group #}
<div class="mobile-nav-group" data-group="space">
<span class="mobile-nav-group-label">SPC</span>
{% if is_index_page %}
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg>') }}
{% else %}
@@ -237,19 +250,42 @@
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
{{ mobile_item('spaceweather', 'SpaceWx', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg>') }}
{{ mobile_item('meteor', 'Meteor', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 2L2 22"/><path d="M18 2h4v4"/><circle cx="8" cy="16" r="4"/><path d="M16 6l-4 4"/></svg>') }}
{# Wireless #}
</div>
{# Wireless Group #}
<div class="mobile-nav-group" data-group="wireless">
<span class="mobile-nav-group-label">WLS</span>
{{ mobile_item('wifi', 'WiFi', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg>') }}
{{ mobile_item('bluetooth', 'BT', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg>') }}
{{ mobile_item('bt_locate', 'Locate', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
{{ mobile_item('wifi_locate', 'WF Loc', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/><circle cx="12" cy="10" r="2"/></svg>') }}
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
{# Intel #}
</div>
{# Intel Group #}
<div class="mobile-nav-group" data-group="intel">
<span class="mobile-nav-group-label">INT</span>
{{ mobile_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
{{ mobile_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
{# New modes #}
{{ mobile_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h4l3-8 3 16 3-8h4"/></svg>') }}
{# System #}
</div>
{# System Group #}
<div class="mobile-nav-group" data-group="system">
<span class="mobile-nav-group-label">SYS</span>
{{ mobile_item('system', 'System', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>') }}
</div>
{# Utility Buttons (theme, settings, help) #}
<div class="mobile-nav-utils">
<button type="button" class="mobile-nav-btn" onclick="toggleTheme()" aria-label="Toggle theme" title="Toggle theme">
<span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></span>
</button>
<button type="button" class="mobile-nav-btn" onclick="showSettings()" aria-label="Settings" title="Settings">
<span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
</button>
<button type="button" class="mobile-nav-btn" onclick="showHelp()" aria-label="Help" title="Help">?</button>
</div>
</nav>
{# JavaScript stub for pages that don't have switchMode defined #}