feat: add Home Assistant integration and improve CLI/UI

Home Assistant Integration:
- New homeassistant.py module with webhook support
- Webhooks for scan results, new devices, and device departures
- Absence detection with configurable timeout
- Documentation in docs/HOME_ASSISTANT.md

CLI Improvements:
- Replace 'web' command with start/stop/restart/status
- Background daemon mode with PID file management
- Foreground mode for debugging (--foreground)

Web UI Enhancements:
- Improved device list styling and layout
- Better floor assignment UI
- Enhanced map visualization

Documentation:
- Add CHANGELOG.md
- Add docs/API.md with full endpoint reference
- Add docs/CHEATSHEET.md for quick reference
- Update project documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
User
2026-02-01 03:31:02 +01:00
parent 0ffd220022
commit 7cc7c47805
16 changed files with 2704 additions and 108 deletions

View File

@@ -22,11 +22,12 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
rf-mapper scan # Scan and save results
rf-mapper start # Start web server (background)
rf-mapper start --foreground # Start in foreground (debug)
rf-mapper stop # Stop web server
rf-mapper restart # Restart web server
rf-mapper status # Check if running
rf-mapper scan -l kitchen # Scan with location label
rf-mapper visualize # Visualize latest scan
rf-mapper analyze # Analyze RF environment
rf-mapper web # Start web server
rf-mapper config # Show current configuration
Note: Requires sudo for WiFi/Bluetooth scanning.
@@ -95,33 +96,79 @@ Note: Requires sudo for WiFi/Bluetooth scanning.
# List command
subparsers.add_parser('list', help='List saved scans')
# Web server command
web_parser = subparsers.add_parser('web', help='Start web server')
web_parser.add_argument(
# Start command
start_parser = subparsers.add_parser('start', help='Start web server')
start_parser.add_argument(
'-H', '--host',
help='Host to bind to (default from config)'
)
web_parser.add_argument(
start_parser.add_argument(
'-p', '--port',
type=int,
help='Port to listen on (default from config)'
)
web_parser.add_argument(
start_parser.add_argument(
'-f', '--foreground',
action='store_true',
help='Run in foreground (default: background daemon)'
)
start_parser.add_argument(
'--debug',
action='store_true',
help='Enable debug mode'
help='Enable Flask debug mode'
)
web_parser.add_argument(
start_parser.add_argument(
'--profile-requests',
action='store_true',
help='Enable per-request profiling (saves profiles to data/profiles/)'
)
web_parser.add_argument(
start_parser.add_argument(
'--log-requests',
action='store_true',
help='Log all requests to data/logs/requests_YYYYMMDD.log'
)
# Stop command
subparsers.add_parser('stop', help='Stop background web server')
# Restart command
restart_parser = subparsers.add_parser('restart', help='Restart web server')
restart_parser.add_argument(
'-H', '--host',
help='Host to bind to (default from config)'
)
restart_parser.add_argument(
'-p', '--port',
type=int,
help='Port to listen on (default from config)'
)
restart_parser.add_argument(
'--debug',
action='store_true',
help='Enable Flask debug mode'
)
restart_parser.add_argument(
'--profile-requests',
action='store_true',
help='Enable per-request profiling'
)
restart_parser.add_argument(
'--log-requests',
action='store_true',
help='Log all requests'
)
# Status command
subparsers.add_parser('status', help='Check if web server is running')
# Deprecated: web command (alias for start --foreground)
web_parser = subparsers.add_parser('web', help='[DEPRECATED] Use "start" instead')
web_parser.add_argument('-H', '--host', help='Host to bind to')
web_parser.add_argument('-p', '--port', type=int, help='Port to listen on')
web_parser.add_argument('--debug', action='store_true', help='Enable debug mode')
web_parser.add_argument('--profile-requests', action='store_true', help='Enable profiling')
web_parser.add_argument('--log-requests', action='store_true', help='Log requests')
# Config command
config_parser = subparsers.add_parser('config', help='Show/edit configuration')
config_parser.add_argument(
@@ -152,10 +199,18 @@ Note: Requires sudo for WiFi/Bluetooth scanning.
run_analyze(args, data_dir)
elif args.command == 'list':
run_list(data_dir)
elif args.command == 'web':
run_web(args, config)
elif args.command == 'start':
run_start(args, config, data_dir)
elif args.command == 'stop':
run_stop(data_dir)
elif args.command == 'restart':
run_restart(args, config, data_dir)
elif args.command == 'status':
run_status(data_dir)
elif args.command == 'config':
run_config(args, config)
elif args.command == 'web':
run_web_deprecated(args, config, data_dir)
else:
# Default: run interactive scan
run_interactive(config, data_dir)
@@ -339,26 +394,6 @@ def run_list(data_dir: Path):
print(f"{ts:<20} {loc:<20} {wifi_count:>6} {bt_count:>6}")
def run_web(args, config: Config):
"""Start the web server"""
from .web.app import run_server
host = args.host
port = args.port
debug = args.debug
profile_requests = getattr(args, 'profile_requests', False)
log_requests = getattr(args, 'log_requests', False)
run_server(
host=host,
port=port,
debug=debug,
config=config,
profile_requests=profile_requests,
log_requests=log_requests
)
def run_config(args, config: Config):
"""Show or edit configuration"""
if args.set_gps:
@@ -401,5 +436,249 @@ Home Assistant:
""")
def get_pid_file(data_dir: Path) -> Path:
"""Get path to PID file"""
return data_dir / "rf-mapper.pid"
def get_start_time_file(data_dir: Path) -> Path:
"""Get path to start time file"""
return data_dir / "rf-mapper.started"
def read_pid(data_dir: Path) -> int | None:
"""Read PID from file, return None if not exists or invalid"""
pid_file = get_pid_file(data_dir)
if not pid_file.exists():
return None
try:
pid = int(pid_file.read_text().strip())
return pid
except (ValueError, OSError):
return None
def read_start_time(data_dir: Path) -> float | None:
"""Read start timestamp from file"""
start_file = get_start_time_file(data_dir)
if not start_file.exists():
return None
try:
return float(start_file.read_text().strip())
except (ValueError, OSError):
return None
def format_uptime(seconds: float) -> str:
"""Format uptime in human-readable form"""
if seconds < 60:
return f"{int(seconds)}s"
elif seconds < 3600:
mins = int(seconds // 60)
secs = int(seconds % 60)
return f"{mins}m {secs}s"
elif seconds < 86400:
hours = int(seconds // 3600)
mins = int((seconds % 3600) // 60)
return f"{hours}h {mins}m"
else:
days = int(seconds // 86400)
hours = int((seconds % 86400) // 3600)
return f"{days}d {hours}h"
def is_process_running(pid: int) -> bool:
"""Check if process with given PID is running"""
import os
import signal
try:
os.kill(pid, 0)
return True
except OSError:
return False
def run_web_deprecated(args, config: Config, data_dir: Path):
"""Deprecated: run web server (forwards to start --foreground)"""
import sys
print("\033[33m" + "=" * 60 + "\033[0m")
print("\033[33mWARNING: 'rf-mapper web' is deprecated.\033[0m")
print("\033[33mUse 'rf-mapper start' instead (runs in background).\033[0m")
print("\033[33mUse 'rf-mapper start -f' for foreground mode.\033[0m")
print("\033[33mThis command will be removed in a future version.\033[0m")
print("\033[33m" + "=" * 60 + "\033[0m\n")
# Run in foreground (old behavior)
from .web.app import run_server
host = args.host or config.web.host
port = args.port or config.web.port
debug = getattr(args, 'debug', False)
profile_requests = getattr(args, 'profile_requests', False)
log_requests = getattr(args, 'log_requests', False)
run_server(
host=host,
port=port,
debug=debug,
config=config,
profile_requests=profile_requests,
log_requests=log_requests
)
def run_start(args, config: Config, data_dir: Path):
"""Start web server (foreground or background)"""
import os
import subprocess
import time
host = args.host or config.web.host
port = args.port or config.web.port
debug = getattr(args, 'debug', False)
profile_requests = getattr(args, 'profile_requests', False)
log_requests = getattr(args, 'log_requests', False)
foreground = getattr(args, 'foreground', False)
pid_file = get_pid_file(data_dir)
start_file = get_start_time_file(data_dir)
# Check if already running (skip check if we're the spawned foreground process)
if not foreground:
pid = read_pid(data_dir)
if pid and is_process_running(pid):
print(f"RF Mapper is already running (PID: {pid})")
print(f"Use 'rf-mapper stop' to stop it, or 'rf-mapper restart' to restart")
sys.exit(1)
if foreground:
# Run in foreground (blocking)
from .web.app import run_server
# Write PID file for status command
pid_file.write_text(str(os.getpid()))
start_file.write_text(str(time.time()))
try:
run_server(
host=host,
port=port,
debug=debug,
config=config,
profile_requests=profile_requests,
log_requests=log_requests
)
finally:
# Cleanup PID file on exit
pid_file.unlink(missing_ok=True)
start_file.unlink(missing_ok=True)
else:
# Run in background (daemon mode)
# Build command for subprocess
cmd = [sys.executable, "-m", "rf_mapper", "start", "-H", host, "-p", str(port), "--foreground"]
if debug:
cmd.append("--debug")
if profile_requests:
cmd.append("--profile-requests")
if log_requests:
cmd.append("--log-requests")
# Start process in background
log_file = data_dir / "rf-mapper.log"
with open(log_file, 'a') as log:
process = subprocess.Popen(
cmd,
stdout=log,
stderr=log,
start_new_session=True
)
# Wait briefly for process to start and write PID file
time.sleep(1)
# Verify it started successfully
if not is_process_running(process.pid):
print("RF Mapper failed to start. Check log:")
print(f" {log_file}")
sys.exit(1)
print(f"RF Mapper started (PID: {process.pid})")
print(f" URL: http://{host}:{port}")
print(f" Log: {log_file}")
print(f"\nUse 'rf-mapper stop' to stop the server")
def run_stop(data_dir: Path):
"""Stop background web server"""
import os
import signal
pid = read_pid(data_dir)
if not pid:
print("RF Mapper is not running (no PID file)")
sys.exit(1)
if not is_process_running(pid):
print(f"RF Mapper is not running (stale PID: {pid})")
get_pid_file(data_dir).unlink(missing_ok=True)
get_start_time_file(data_dir).unlink(missing_ok=True)
sys.exit(1)
# Send SIGTERM
try:
os.kill(pid, signal.SIGTERM)
print(f"RF Mapper stopped (PID: {pid})")
get_pid_file(data_dir).unlink(missing_ok=True)
get_start_time_file(data_dir).unlink(missing_ok=True)
except OSError as e:
print(f"Failed to stop RF Mapper: {e}")
sys.exit(1)
def run_restart(args, config: Config, data_dir: Path):
"""Restart background web server"""
import time
pid = read_pid(data_dir)
if pid and is_process_running(pid):
print("Stopping RF Mapper...")
run_stop(data_dir)
time.sleep(1)
print("Starting RF Mapper...")
run_start(args, config, data_dir)
def run_status(data_dir: Path):
"""Check if web server is running"""
import time
pid = read_pid(data_dir)
if not pid:
print("RF Mapper is not running (no PID file)")
sys.exit(1)
if is_process_running(pid):
print(f"RF Mapper is running (PID: {pid})")
# Show uptime
start_time = read_start_time(data_dir)
if start_time:
uptime_secs = time.time() - start_time
print(f" Uptime: {format_uptime(uptime_secs)}")
log_file = data_dir / "rf-mapper.log"
if log_file.exists():
print(f" Log: {log_file}")
sys.exit(0)
else:
print(f"RF Mapper is not running (stale PID: {pid})")
get_pid_file(data_dir).unlink(missing_ok=True)
get_start_time_file(data_dir).unlink(missing_ok=True)
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,168 @@
"""Home Assistant webhook integration for RF Mapper."""
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
import requests
logger = logging.getLogger(__name__)
@dataclass
class HAWebhookConfig:
"""Configuration for Home Assistant webhook integration."""
enabled: bool = False
url: str = "http://192.168.129.10:8123"
webhook_scan: str = "rf_mapper_scan"
webhook_new_device: str = "rf_mapper_new_device"
webhook_device_gone: str = "rf_mapper_device_gone"
device_timeout_minutes: int = 5
timeout_seconds: int = 5
class HAWebhooks:
"""Home Assistant webhook sender for RF Mapper events."""
def __init__(self, config: HAWebhookConfig):
"""Initialize webhook sender with configuration.
Args:
config: HAWebhookConfig with HA URL and webhook IDs
"""
self.config = config
def _send(self, webhook_id: str, data: dict) -> bool:
"""Send data to a Home Assistant webhook.
Args:
webhook_id: The webhook ID configured in HA
data: JSON-serializable data to send
Returns:
True if webhook was sent successfully, False otherwise
"""
if not self.config.enabled:
return False
url = f"{self.config.url.rstrip('/')}/api/webhook/{webhook_id}"
try:
resp = requests.post(
url,
json=data,
timeout=self.config.timeout_seconds,
headers={"Content-Type": "application/json"}
)
if resp.status_code == 200:
logger.debug(f"[HA Webhook] Sent to {webhook_id}: {len(data)} items")
return True
else:
logger.warning(
f"[HA Webhook] {webhook_id} returned status {resp.status_code}"
)
return False
except requests.exceptions.Timeout:
logger.warning(f"[HA Webhook] Timeout sending to {webhook_id}")
return False
except requests.exceptions.ConnectionError as e:
logger.warning(f"[HA Webhook] Connection error: {e}")
return False
except Exception as e:
logger.error(f"[HA Webhook] Error sending to {webhook_id}: {e}")
return False
def send_scan_results(
self,
devices: list[dict],
scanner: dict,
scan_type: str = "bluetooth"
) -> bool:
"""Send scan results to Home Assistant for presence tracking.
Args:
devices: List of device dicts with id, name, rssi, distance, floor
scanner: Scanner identity dict with id, name, latitude, longitude, floor
scan_type: Type of scan ('bluetooth', 'wifi', or 'both')
Returns:
True if sent successfully
"""
return self._send(self.config.webhook_scan, {
"timestamp": datetime.now().isoformat(),
"scan_type": scan_type,
"scanner": scanner,
"scanner_floor": scanner.get("floor"), # Backward compatibility
"device_count": len(devices),
"devices": devices
})
def send_new_device(
self,
device_id: str,
name: str,
device_type: str,
scanner: Optional[dict] = None,
manufacturer: Optional[str] = None,
rssi: Optional[int] = None,
distance_m: Optional[float] = None
) -> bool:
"""Alert Home Assistant about a new device detection.
Args:
device_id: MAC address or unique ID
name: Device name (SSID for WiFi, advertised name for BT)
device_type: 'wifi' or 'bluetooth'
scanner: Scanner identity dict that detected the device
manufacturer: Manufacturer from OUI lookup
rssi: Signal strength at detection
distance_m: Estimated distance at detection
Returns:
True if sent successfully
"""
payload = {
"timestamp": datetime.now().isoformat(),
"device_id": device_id,
"name": name,
"device_type": device_type,
"manufacturer": manufacturer or "Unknown",
"rssi": rssi,
"distance_m": distance_m
}
if scanner:
payload["scanner"] = scanner
return self._send(self.config.webhook_new_device, payload)
def send_device_gone(
self,
device_id: str,
name: str,
last_seen: str,
device_type: str = "bluetooth",
last_scanner: Optional[dict] = None
) -> bool:
"""Alert Home Assistant that a device has departed.
Args:
device_id: MAC address or unique ID
name: Device name
last_seen: ISO timestamp of last observation
device_type: 'wifi' or 'bluetooth'
last_scanner: Scanner identity dict that last saw the device
Returns:
True if sent successfully
"""
payload = {
"timestamp": datetime.now().isoformat(),
"device_id": device_id,
"name": name,
"device_type": device_type,
"last_seen": last_seen
}
if last_scanner:
payload["last_scanner"] = last_scanner
return self._send(self.config.webhook_device_gone, payload)

View File

@@ -183,6 +183,33 @@ body {
background: #111;
}
/* Show Trail button in popup */
.popup-trail-btn {
margin-top: 8px;
padding: 6px 12px;
background: #e67e22;
color: white;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 0.8rem;
width: 100%;
transition: background 0.2s;
}
.popup-trail-btn:hover {
background: #d35400;
}
.popup-trail-btn.active {
background: #27ae60;
}
.popup-trail-btn.loading {
opacity: 0.7;
cursor: wait;
}
.filter-btn:hover {
transform: scale(1.05);
}
@@ -775,6 +802,50 @@ body {
opacity: 0.7;
}
/* Position status in popup */
.popup-position-status {
margin-top: 8px;
padding: 6px 8px;
background: var(--bg-primary);
border-radius: var(--border-radius);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
}
.popup-position-status .status-label {
color: var(--color-text-muted);
}
.popup-position-status .status-value {
font-weight: 600;
}
.popup-position-status .status-value.manual {
color: #f39c12;
}
.popup-position-status .status-value.auto {
color: var(--color-primary);
}
.popup-reset-btn {
margin-top: 4px;
padding: 4px 10px;
background: #e74c3c;
color: white;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 0.75rem;
transition: background 0.2s;
}
.popup-reset-btn:hover {
background: #c0392b;
}
/* Live Track Button */
#live-track-btn.active {
background: var(--color-accent);
@@ -831,6 +902,21 @@ body {
transform: scale(1.2);
}
/* Draggable markers (floor-assigned devices) */
.marker-3d.draggable {
cursor: grab;
}
.marker-3d.draggable:active {
cursor: grabbing;
}
/* Manual position indicator */
.marker-3d.has-manual-position .marker-icon {
border: 3px solid #f39c12 !important;
box-shadow: 0 0 15px #f39c12, 0 2px 8px rgba(0, 0, 0, 0.5) !important;
}
.marker-3d .marker-floor {
font-size: 10px;
font-weight: bold;

View File

@@ -8,10 +8,16 @@ let scanData = null;
let map = null;
let markers = [];
let filters = {
wifi: true,
wifi: false,
bluetooth: true
};
// Device trails state - stores trail data per device
let deviceTrails = {}; // { deviceId: { type, name, points: [{ timestamp, distance, lat, lon }, ...] } }
// Device manual positions - loaded from database
let manualPositions = {}; // { deviceId: { lat_offset, lon_offset } }
// Auto-scan state
let autoScanEnabled = false;
let autoScanPollInterval = null;
@@ -29,6 +35,10 @@ const MIN_SAMPLES_FOR_MOVEMENT = 3; // Need at least this many samples before de
// Store distance history per device: { address: { samples: [], timestamps: [] } }
let deviceDistanceHistory = {};
// Track consecutive missed detections per device: { address: missCount }
let deviceMissCount = {};
const MAX_MISSED_SCANS = 5; // Remove device after this many consecutive misses (~20s with 4s interval)
// Calculate mean of array
function mean(arr) {
if (arr.length === 0) return 0;
@@ -110,6 +120,7 @@ document.addEventListener('DOMContentLoaded', () => {
initFloorSelector();
loadLatestScan();
loadAutoScanStatus();
loadDevicePositions(); // Load saved manual positions
// Initialize 3D map as default view
setTimeout(() => {
@@ -483,6 +494,9 @@ function drawRadar() {
const size = Math.max(3, 10 + (dev.rssi + 90) / 5);
const isMoving = dev.is_moving === true;
const missCount = dev.miss_count || 0;
// Calculate opacity: 1.0 -> 0.6 -> 0.3 based on miss count
const opacity = missCount === 0 ? 1.0 : (missCount === 1 ? 0.6 : 0.3);
const color = isMoving ? '#9b59b6' : APP_CONFIG.colors.bluetooth;
// Store position for hit detection
@@ -494,6 +508,10 @@ function drawRadar() {
radius: Math.max(size / 2, 8) // Minimum clickable area
});
// Save context for opacity
ctx.save();
ctx.globalAlpha = opacity;
// Draw glow
const gradient = ctx.createRadialGradient(x, y, 0, x, y, size * 2);
if (isMoving) {
@@ -522,6 +540,9 @@ function drawRadar() {
ctx.beginPath();
ctx.arc(x, y, size / 2, 0, Math.PI * 2);
ctx.fill();
// Restore context (for opacity)
ctx.restore();
});
// Legend
@@ -609,11 +630,16 @@ function updateMapMarkers() {
const hasCustomDist = dev.custom_distance_m !== null && dev.custom_distance_m !== undefined;
const distLabel = hasCustomDist ? `${dist}m (custom)` : `~${dev.estimated_distance_m}m`;
// Calculate opacity based on miss count
const missCount = dev.miss_count || 0;
const opacity = missCount === 0 ? 0.6 : (missCount === 1 ? 0.4 : 0.2);
const marker = L.circleMarker([lat + latOffset, lon + lonOffset], {
radius: Math.max(4, 8 + (dev.rssi + 90) / 10),
color: APP_CONFIG.colors.bluetooth,
fillColor: APP_CONFIG.colors.bluetooth,
fillOpacity: 0.6,
fillOpacity: opacity,
opacity: opacity + 0.2,
weight: 2
}).addTo(map).bindPopup(`
<strong>🔵 ${escapeHtml(dev.name)}</strong><br>
@@ -991,6 +1017,168 @@ async function stopAutoScan() {
}
}
// ========== Device Position Functions ==========
// Load saved device positions from database
async function loadDevicePositions() {
try {
const response = await fetch('/api/device/floors');
if (response.ok) {
const data = await response.json();
// Handle both old format (just floors) and new format (floors + positions)
if (data.positions) {
manualPositions = data.positions;
console.log('[Positions] Loaded', Object.keys(manualPositions).length, 'manual positions');
}
}
} catch (error) {
console.error('Error loading device positions:', error);
}
}
// Get device position (manual or RSSI-based)
function getDevicePosition(device, scannerLat, scannerLon, minDistanceM) {
const deviceId = device.bssid || device.address;
const customPos = manualPositions[deviceId];
// If device has manual position, use it
if (customPos && customPos.lat_offset != null && customPos.lon_offset != null) {
return {
lat: scannerLat + customPos.lat_offset,
lon: scannerLon + customPos.lon_offset,
isManual: true
};
}
// Otherwise calculate from RSSI/distance
const effectiveDist = getEffectiveDistance(device);
const dist = Math.max(effectiveDist, minDistanceM);
const angle = hashString(deviceId) % 360;
const latOffset = (dist * Math.cos(angle * Math.PI / 180)) / 111000;
const lonOffset = (dist * Math.sin(angle * Math.PI / 180)) / (111000 * Math.cos(scannerLat * Math.PI / 180));
return {
lat: scannerLat + latOffset,
lon: scannerLon + lonOffset,
isManual: false
};
}
// Update device position via API (called after drag)
async function updateDevicePosition(deviceId, latOffset, lonOffset) {
try {
const response = await fetch(`/api/device/${encodeURIComponent(deviceId)}/position`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lat_offset: latOffset,
lon_offset: lonOffset
})
});
if (response.ok) {
const data = await response.json();
console.log('[Position] Updated', deviceId, 'to offset', latOffset.toFixed(6), lonOffset.toFixed(6));
// Update local cache
manualPositions[deviceId] = { lat_offset: latOffset, lon_offset: lonOffset };
return true;
} else {
const error = await response.json();
console.error('[Position] Failed to update:', error.error);
return false;
}
} catch (error) {
console.error('[Position] Error updating position:', error);
return false;
}
}
// Reset device position to auto (RSSI-based)
async function resetDevicePosition(deviceId) {
try {
const response = await fetch(`/api/device/${encodeURIComponent(deviceId)}/position`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lat_offset: null,
lon_offset: null
})
});
if (response.ok) {
console.log('[Position] Reset', deviceId, 'to auto');
// Remove from local cache
delete manualPositions[deviceId];
// Refresh markers to show new position
update3DMarkers();
return true;
} else {
console.error('[Position] Failed to reset position');
return false;
}
} catch (error) {
console.error('[Position] Error resetting position:', error);
return false;
}
}
// Handle scanner (primary source) drag end
async function onScannerDragEnd(marker) {
const lngLat = marker.getLngLat();
const newLat = lngLat.lat;
const newLon = lngLat.lng;
console.log('[Scanner] Repositioned to', newLat.toFixed(6), newLon.toFixed(6));
// Update the input fields
document.getElementById('lat-input').value = newLat.toFixed(6);
document.getElementById('lon-input').value = newLon.toFixed(6);
// Persist to config file (survives restarts)
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gps: { latitude: newLat, longitude: newLon },
save: true
})
});
if (response.ok) {
console.log('[Scanner] Position saved to config');
}
} catch (error) {
console.error('[Scanner] Error saving position:', error);
}
// Refresh all markers (device positions are relative to scanner)
update3DMarkers();
}
// Handle marker drag end
async function onMarkerDragEnd(marker, deviceId, scannerLat, scannerLon) {
const lngLat = marker.getLngLat();
const latOffset = lngLat.lat - scannerLat;
const lonOffset = lngLat.lng - scannerLon;
const success = await updateDevicePosition(deviceId, latOffset, lonOffset);
if (success) {
// Update marker element to show manual position indicator
const el = marker.getElement();
if (el) {
el.classList.add('has-manual-position');
}
// Refresh markers to update popup content
update3DMarkers();
} else {
// Revert marker to original position on failure
update3DMarkers();
}
}
// ========== 3D Map Functions ==========
// Initialize 3D map with MapLibre GL
@@ -1153,17 +1341,31 @@ function update3DMarkers() {
const pixelsPerFloor = 18;
const groundFloor = buildingConfig.groundFloorNumber || 0;
// Add scanner position marker at center
// Add scanner position marker at center (draggable for fine-grained positioning)
const scannerEl = document.createElement('div');
scannerEl.className = 'marker-3d center';
scannerEl.className = 'marker-3d center draggable';
scannerEl.innerHTML = `<div class="marker-icon">📍</div><div class="marker-floor">F${scannerFloor}</div>`;
scannerEl.title = `Your Position - Floor ${scannerFloor}`;
scannerEl.title = `Your Position - Floor ${scannerFloor} (drag to reposition)`;
const scannerOffset = (scannerFloor - groundFloor) * pixelsPerFloor;
const scannerMarker = new maplibregl.Marker({ element: scannerEl, offset: [0, -scannerOffset] })
const scannerMarker = new maplibregl.Marker({
element: scannerEl,
offset: [0, -scannerOffset],
draggable: true
})
.setLngLat([lon, lat])
.setPopup(new maplibregl.Popup().setHTML(`<strong>📍 Your Position</strong><br>Floor: ${scannerFloor}`))
.setPopup(new maplibregl.Popup().setHTML(`
<strong>📍 Your Position</strong><br>
Floor: ${scannerFloor}<br>
Lat: ${lat.toFixed(6)}<br>
Lon: ${lon.toFixed(6)}<br>
<div style="font-size:0.7rem;color:#888;margin-top:4px;">Drag marker to reposition</div>
`))
.addTo(map3d);
// Handle scanner marker drag
scannerMarker.on('dragend', () => onScannerDragEnd(scannerMarker));
scannerMarker._deviceId = '__scanner__';
map3dMarkers.push(scannerMarker);
if (!scanData) return;
@@ -1179,24 +1381,32 @@ function update3DMarkers() {
// Add WiFi markers
filteredWifi.forEach((net) => {
const effectiveDist = getEffectiveDistance(net);
const dist = Math.max(effectiveDist, minDistanceM);
const angle = hashString(net.bssid) % 360;
const latOffset = (dist * Math.cos(angle * Math.PI / 180)) / 111000;
const lonOffset = (dist * Math.sin(angle * Math.PI / 180)) / (111000 * Math.cos(lat * Math.PI / 180));
const wifiDeviceId = net.bssid;
const deviceFloor = net.floor !== null && net.floor !== undefined ? net.floor : null;
const hasCustomDist = net.custom_distance_m !== null && net.custom_distance_m !== undefined;
const effectiveDist = getEffectiveDistance(net);
// Get position (manual or RSSI-based)
const pos = getDevicePosition(net, lat, lon, minDistanceM);
const hasManualPosition = pos.isManual;
// Determine if marker should be draggable (only floor-assigned devices)
const isDraggable = deviceFloor !== null;
const floorLabel = deviceFloor !== null ? `F${deviceFloor}` : 'F?';
const el = document.createElement('div');
el.className = 'marker-3d wifi';
if (isDraggable) el.classList.add('draggable');
if (hasManualPosition) el.classList.add('has-manual-position');
el.innerHTML = `<div class="marker-icon">📶</div><div class="marker-floor">${floorLabel}</div>`;
el.title = `${net.ssid} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}`;
el.title = `${net.ssid} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}${hasManualPosition ? ' (Manual position)' : ''}`;
const wifiDeviceId = net.bssid;
const distLabel = hasCustomDist ? `${effectiveDist}m (custom)` : `~${net.estimated_distance_m}m`;
const positionStatus = hasManualPosition ? 'Manual' : 'Auto';
const positionClass = hasManualPosition ? 'manual' : 'auto';
const resetBtn = hasManualPosition ? `<button class="popup-reset-btn" onclick="resetDevicePosition('${wifiDeviceId}')">Reset to Auto</button>` : '';
const dragHint = isDraggable && !hasManualPosition ? '<div style="font-size:0.7rem;color:#888;margin-top:4px;">Drag marker to set position</div>' : '';
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(`
<strong>📶 ${escapeHtml(net.ssid)}</strong><br>
Signal: ${net.rssi} dBm<br>
@@ -1216,14 +1426,30 @@ function update3DMarkers() {
placeholder="${net.estimated_distance_m}"
onchange="updateDeviceDistance('${wifiDeviceId}', this.value)">
</div>
<div class="popup-position-status">
<span class="status-label">Position:</span>
<span class="status-value ${positionClass}">${positionStatus}</span>
</div>
${resetBtn}
${dragHint}
`);
// Offset marker up based on floor (unknown floors at ground level)
const wifiOffset = deviceFloor !== null ? (deviceFloor - groundFloor) * pixelsPerFloor : 0;
const marker = new maplibregl.Marker({ element: el, offset: [0, -wifiOffset] })
.setLngLat([lon + lonOffset, lat + latOffset])
const marker = new maplibregl.Marker({
element: el,
offset: [0, -wifiOffset],
draggable: isDraggable
})
.setLngLat([pos.lon, pos.lat])
.setPopup(popup)
.addTo(map3d);
// Handle drag end for floor-assigned devices
if (isDraggable) {
marker.on('dragend', () => onMarkerDragEnd(marker, wifiDeviceId, lat, lon));
}
marker._deviceId = wifiDeviceId;
popup.on('open', () => { openPopupDeviceId = wifiDeviceId; });
popup.on('close', () => { if (openPopupDeviceId === wifiDeviceId) openPopupDeviceId = null; });
@@ -1232,27 +1458,45 @@ function update3DMarkers() {
// Add Bluetooth markers
filteredBt.forEach((dev) => {
const effectiveDist = getEffectiveDistance(dev);
const dist = Math.max(effectiveDist, minDistanceM);
const angle = hashString(dev.address) % 360;
const latOffset = (dist * Math.cos(angle * Math.PI / 180)) / 111000;
const lonOffset = (dist * Math.sin(angle * Math.PI / 180)) / (111000 * Math.cos(lat * Math.PI / 180));
const btDeviceId = dev.address;
const deviceFloor = dev.floor !== null && dev.floor !== undefined ? dev.floor : null;
const hasCustomDist = dev.custom_distance_m !== null && dev.custom_distance_m !== undefined;
const effectiveDist = getEffectiveDistance(dev);
// Get position (manual or RSSI-based)
const pos = getDevicePosition(dev, lat, lon, minDistanceM);
const hasManualPosition = pos.isManual;
// Determine if marker should be draggable (only floor-assigned devices)
const isDraggable = deviceFloor !== null;
const btFloorLabel = deviceFloor !== null ? `F${deviceFloor}` : 'F?';
const isMoving = dev.is_moving === true;
const missCount = dev.miss_count || 0;
// Calculate opacity: 1.0 -> 0.6 -> 0.3 based on miss count
const opacity = missCount === 0 ? 1.0 : (missCount === 1 ? 0.6 : 0.3);
const el = document.createElement('div');
el.className = `marker-3d bluetooth${isMoving ? ' moving' : ''}`;
if (isDraggable) el.classList.add('draggable');
if (hasManualPosition) el.classList.add('has-manual-position');
el.style.opacity = opacity;
el.style.transition = 'opacity 0.5s ease';
el.innerHTML = `<div class="marker-icon">${isMoving ? '🟣' : '🔵'}</div><div class="marker-floor">${btFloorLabel}</div>`;
el.title = `${dev.name} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}${isMoving ? ' (MOVING)' : ''}`;
el.title = `${dev.name} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}${isMoving ? ' (MOVING)' : ''}${hasManualPosition ? ' (Manual position)' : ''}${missCount > 0 ? ` (fading: ${missCount}/${MAX_MISSED_SCANS})` : ''}`;
const btDeviceId = dev.address;
const btDistLabel = hasCustomDist ? `${effectiveDist}m (custom)` : `~${dev.estimated_distance_m}m`;
const positionStatus = hasManualPosition ? 'Manual' : 'Auto';
const positionClass = hasManualPosition ? 'manual' : 'auto';
const resetBtn = hasManualPosition ? `<button class="popup-reset-btn" onclick="resetDevicePosition('${btDeviceId}')">Reset to Auto</button>` : '';
const dragHint = isDraggable && !hasManualPosition ? '<div style="font-size:0.7rem;color:#888;margin-top:4px;">Drag marker to set position</div>' : '';
const trailBtnHtml = isMoving ? `
<button class="popup-trail-btn" id="trail-btn-${btDeviceId.replace(/:/g, '')}"
onclick="toggleDeviceTrail('${btDeviceId}', '${escapeHtml(dev.name)}', 'bluetooth')">
Show Trail
</button>` : '';
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(`
<strong>🔵 ${escapeHtml(dev.name)}</strong><br>
<strong>${isMoving ? '🟣' : '🔵'} ${escapeHtml(dev.name)}</strong>${isMoving ? ' <span style="color:#9b59b6;font-size:0.8em;">(Moving)</span>' : ''}<br>
Signal: ${dev.rssi} dBm<br>
Distance: ${btDistLabel}<br>
Type: ${escapeHtml(dev.device_type)}<br>
@@ -1270,14 +1514,31 @@ function update3DMarkers() {
placeholder="${dev.estimated_distance_m}"
onchange="updateDeviceDistance('${btDeviceId}', this.value)">
</div>
<div class="popup-position-status">
<span class="status-label">Position:</span>
<span class="status-value ${positionClass}">${positionStatus}</span>
</div>
${resetBtn}
${dragHint}
${trailBtnHtml}
`);
// Offset marker up based on floor (unknown floors at ground level)
const btOffset = deviceFloor !== null ? (deviceFloor - groundFloor) * pixelsPerFloor : 0;
const marker = new maplibregl.Marker({ element: el, offset: [0, -btOffset] })
.setLngLat([lon + lonOffset, lat + latOffset])
const marker = new maplibregl.Marker({
element: el,
offset: [0, -btOffset],
draggable: isDraggable
})
.setLngLat([pos.lon, pos.lat])
.setPopup(popup)
.addTo(map3d);
// Handle drag end for floor-assigned devices
if (isDraggable) {
marker.on('dragend', () => onMarkerDragEnd(marker, btDeviceId, lat, lon));
}
marker._deviceId = btDeviceId;
popup.on('open', () => { openPopupDeviceId = btDeviceId; });
popup.on('close', () => { if (openPopupDeviceId === btDeviceId) openPopupDeviceId = null; });
@@ -1560,11 +1821,14 @@ async function performLiveBTScan() {
if (response.ok) {
const data = await response.json();
const newBt = data.bluetooth_devices || [];
// Track which devices were detected in this scan
const detectedAddresses = new Set(newBt.map(d => d.address));
// Merge BT data with existing scan data, preserving custom distances and floors
if (scanData) {
const existingBt = scanData.bluetooth_devices || [];
const newBt = data.bluetooth_devices || [];
// Update existing devices with new RSSI, add new devices
newBt.forEach(newDev => {
@@ -1574,12 +1838,16 @@ async function performLiveBTScan() {
// Check for movement using statistical analysis
const moving = isDeviceMoving(newDev.address, newDist);
// Reset miss count - device was detected
deviceMissCount[newDev.address] = 0;
if (existing) {
// Update RSSI and estimated distance, preserve custom values
existing.rssi = newDev.rssi;
existing.estimated_distance_m = newDev.estimated_distance_m;
existing.signal_quality = newDev.signal_quality;
existing.is_moving = moving;
existing.miss_count = 0;
// Preserve floor and custom_distance_m if set
} else {
// New device, add it
@@ -1588,13 +1856,39 @@ async function performLiveBTScan() {
}
});
scanData.bluetooth_devices = existingBt;
// Increment miss count for devices not detected in this scan
existingBt.forEach(dev => {
if (!detectedAddresses.has(dev.address)) {
deviceMissCount[dev.address] = (deviceMissCount[dev.address] || 0) + 1;
dev.miss_count = deviceMissCount[dev.address];
}
});
// Filter out devices that have been missed too many times
const filteredBt = existingBt.filter(dev => {
const missCount = deviceMissCount[dev.address] || 0;
if (missCount >= MAX_MISSED_SCANS) {
// Clean up tracking data for removed device
delete deviceMissCount[dev.address];
delete deviceDistanceHistory[dev.address];
// Clear trail if showing
if (deviceTrails[dev.address]) {
clearDeviceTrail(dev.address);
}
console.log(`[Live] Removed ${dev.name} (missed ${missCount} scans)`);
return false;
}
return true;
});
scanData.bluetooth_devices = filteredBt;
} else {
// No existing scan data, use BT-only data
data.bluetooth_devices.forEach(dev => {
// Initialize history with first sample, not moving yet
isDeviceMoving(dev.address, dev.estimated_distance_m);
dev.is_moving = false;
deviceMissCount[dev.address] = 0;
});
scanData = {
wifi_networks: [],
@@ -1607,7 +1901,7 @@ async function performLiveBTScan() {
const status = document.getElementById('scan-status');
if (status) {
const movingCount = scanData.bluetooth_devices.filter(d => d.is_moving).length;
status.textContent = `Live: ${data.bluetooth_devices.length} BT (${movingCount} moving) @ ${new Date().toLocaleTimeString()}`;
status.textContent = `Live: ${scanData.bluetooth_devices.length} BT (${movingCount} moving) @ ${new Date().toLocaleTimeString()}`;
}
// Update BT count
@@ -1623,3 +1917,250 @@ async function performLiveBTScan() {
console.error('Live BT scan error:', error);
}
}
// ========== Device Trails Functions ==========
// Toggle trail for a specific device (called from popup button)
async function toggleDeviceTrail(deviceId, deviceName, deviceType) {
const btnId = `trail-btn-${deviceId.replace(/:/g, '')}`;
const btn = document.getElementById(btnId);
// Check if trail already exists for this device
if (deviceTrails[deviceId]) {
// Hide trail
clearDeviceTrail(deviceId);
if (btn) {
btn.textContent = 'Show Trail';
btn.classList.remove('active');
}
console.log(`[Trails] Hidden trail for ${deviceName}`);
} else {
// Show trail - fetch and render
if (btn) {
btn.textContent = 'Loading...';
btn.classList.add('loading');
}
await fetchDeviceTrail(deviceId, deviceName, deviceType);
renderDeviceTrail(deviceId);
if (btn) {
btn.textContent = 'Hide Trail';
btn.classList.remove('loading');
btn.classList.add('active');
}
console.log(`[Trails] Showing trail for ${deviceName}`);
}
}
// Fetch trail data for a single device
async function fetchDeviceTrail(deviceId, deviceName, deviceType) {
try {
// Get last 100 observations for trail
const response = await fetch(`/api/history/devices/${encodeURIComponent(deviceId)}/rssi?limit=100`);
if (!response.ok) {
console.error(`[Trails] Failed to fetch trail for ${deviceId}`);
return;
}
const data = await response.json();
const observations = data.observations || [];
if (observations.length < 2) {
console.log(`[Trails] Not enough data points for ${deviceId} (${observations.length})`);
return;
}
// Convert observations to trail points with positions
const lat = parseFloat(document.getElementById('lat-input').value) || APP_CONFIG.defaultLat;
const lon = parseFloat(document.getElementById('lon-input').value) || APP_CONFIG.defaultLon;
const angle = hashString(deviceId) % 360;
const trailPoints = observations.map(obs => {
const dist = obs.distance_m || 5;
const latOffset = (dist * Math.cos(angle * Math.PI / 180)) / 111000;
const lonOffset = (dist * Math.sin(angle * Math.PI / 180)) / (111000 * Math.cos(lat * Math.PI / 180));
return {
timestamp: obs.timestamp,
distance: dist,
lat: lat + latOffset,
lon: lon + lonOffset,
rssi: obs.rssi,
floor: obs.floor
};
});
// Store trail data
deviceTrails[deviceId] = {
type: deviceType,
name: deviceName,
points: trailPoints.reverse() // Oldest first for line drawing
};
console.log(`[Trails] Fetched ${trailPoints.length} points for ${deviceName}`);
} catch (error) {
console.error(`[Trails] Error fetching trail for ${deviceId}:`, error);
}
}
// Render trail for a single device on the 3D map
function renderDeviceTrail(deviceId) {
if (!map3d || !map3dLoaded) return;
const trail = deviceTrails[deviceId];
if (!trail || trail.points.length < 2) return;
const safeId = deviceId.replace(/:/g, '-');
const sourceId = `trail-source-${safeId}`;
const layerId = `trail-layer-${safeId}`;
const pointsLayerId = `trail-points-${safeId}`;
// Remove existing trail for this device if any
clearDeviceTrailLayers(safeId);
// Create GeoJSON line coordinates
const coordinates = trail.points.map(p => [p.lon, p.lat]);
// Purple color for moving devices
const color = '#9b59b6';
// Add source for trail line
map3d.addSource(sourceId, {
type: 'geojson',
data: {
type: 'Feature',
properties: {
deviceId: deviceId,
name: trail.name,
type: trail.type
},
geometry: {
type: 'LineString',
coordinates: coordinates
}
}
});
// Add trail line layer
map3d.addLayer({
id: layerId,
type: 'line',
source: sourceId,
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': color,
'line-width': 3,
'line-opacity': 0.8,
'line-dasharray': [2, 1]
}
});
// Add trail points (circles at each observation)
const pointFeatures = trail.points.map((p, i) => ({
type: 'Feature',
properties: {
index: i,
timestamp: p.timestamp,
rssi: p.rssi,
distance: p.distance
},
geometry: {
type: 'Point',
coordinates: [p.lon, p.lat]
}
}));
map3d.addSource(`${sourceId}-points`, {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: pointFeatures
}
});
map3d.addLayer({
id: pointsLayerId,
type: 'circle',
source: `${sourceId}-points`,
paint: {
'circle-radius': 4,
'circle-color': color,
'circle-opacity': 0.6,
'circle-stroke-width': 1,
'circle-stroke-color': '#ffffff',
'circle-stroke-opacity': 0.5
}
});
console.log(`[Trails] Rendered trail for ${trail.name} with ${trail.points.length} points`);
}
// Clear trail for a specific device
function clearDeviceTrail(deviceId) {
const safeId = deviceId.replace(/:/g, '-');
clearDeviceTrailLayers(safeId);
delete deviceTrails[deviceId];
}
// Clear trail layers for a device (by safe ID)
function clearDeviceTrailLayers(safeId) {
if (!map3d || !map3dLoaded) return;
const sourceId = `trail-source-${safeId}`;
const layerId = `trail-layer-${safeId}`;
const pointsLayerId = `trail-points-${safeId}`;
// Remove layers first
if (map3d.getLayer(layerId)) {
map3d.removeLayer(layerId);
}
if (map3d.getLayer(pointsLayerId)) {
map3d.removeLayer(pointsLayerId);
}
// Remove sources
if (map3d.getSource(sourceId)) {
map3d.removeSource(sourceId);
}
if (map3d.getSource(`${sourceId}-points`)) {
map3d.removeSource(`${sourceId}-points`);
}
}
// Clear all trails from the 3D map
function clearAllTrails() {
if (!map3d || !map3dLoaded) return;
// Remove all trail layers and sources
const style = map3d.getStyle();
if (!style || !style.layers) return;
// Find and remove trail layers
const layersToRemove = style.layers
.filter(layer => layer.id.startsWith('trail-'))
.map(layer => layer.id);
layersToRemove.forEach(layerId => {
if (map3d.getLayer(layerId)) {
map3d.removeLayer(layerId);
}
});
// Find and remove trail sources
const sources = Object.keys(style.sources || {});
sources.forEach(sourceId => {
if (sourceId.startsWith('trail-')) {
if (map3d.getSource(sourceId)) {
map3d.removeSource(sourceId);
}
}
});
deviceTrails = {};
}

View File

@@ -25,7 +25,7 @@
</div>
<div class="filter-controls">
<button id="filter-wifi" class="filter-btn wifi" onclick="toggleFilter('wifi')">
<button id="filter-wifi" class="filter-btn wifi inactive" onclick="toggleFilter('wifi')">
<span class="filter-indicator"></span>
<span>WiFi</span>
</button>
@@ -76,15 +76,9 @@
</div>
<aside class="sidebar">
<div class="section">
<div class="section-header">
<span class="section-title">📍 Position</span>
</div>
<div class="position-input">
<input type="number" id="lat-input" placeholder="Latitude" step="0.0001" value="{{ lat }}">
<input type="number" id="lon-input" placeholder="Longitude" step="0.0001" value="{{ lon }}">
</div>
</div>
<!-- Hidden inputs to preserve GPS values for JS -->
<input type="hidden" id="lat-input" value="{{ lat }}">
<input type="hidden" id="lon-input" value="{{ lon }}">
<div class="section floor-section" id="floor-section">
<div class="section-header">