feat: add multi-scanner trilateration and signal heat map

- Add database methods for multi-scanner RSSI queries
- Add weighted trilateration function supporting 2+ scanners
- Add /api/positions/trilaterated endpoint
- Add /api/heatmap/signal endpoint for heat map data
- Update frontend to show trilaterated positions with gold markers
- Add heat map toggle button for signal coverage visualization

Trilateration uses RSSI from multiple scanners to calculate device
positions with confidence scores. Devices seen by 2+ scanners within
60 seconds get trilaterated positions shown with gold border markers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
User
2026-02-01 10:33:57 +01:00
parent 5fbf096a04
commit 24de6c7f06
6 changed files with 448 additions and 6 deletions

View File

@@ -496,6 +496,56 @@ class DeviceDatabase:
max_distance_m=round(stats['max_distance_m'], 2) if stats['max_distance_m'] else 0
)
def get_device_multi_scanner_rssi(self, device_id: str, seconds: int = 60) -> list[dict]:
"""Get recent RSSI readings per scanner for a device.
Args:
device_id: The device MAC address
seconds: Time window in seconds (default 60)
Returns:
List of dicts with scanner_id, avg_rssi, sample_count, last_seen
"""
conn = self._get_connection()
cursor = conn.cursor()
query = """
SELECT scanner_id,
AVG(rssi) as avg_rssi,
COUNT(*) as sample_count,
MAX(timestamp) as last_seen
FROM rssi_history
WHERE device_id = ?
AND scanner_id IS NOT NULL
AND timestamp >= datetime('now', '-' || ? || ' seconds')
GROUP BY scanner_id
"""
cursor.execute(query, (device_id, seconds))
return [dict(r) for r in cursor.fetchall()]
def get_devices_seen_by_multiple_scanners(self, seconds: int = 60) -> list[str]:
"""Get device IDs seen by 2+ scanners recently.
Args:
seconds: Time window in seconds (default 60)
Returns:
List of device IDs seen by multiple scanners
"""
conn = self._get_connection()
cursor = conn.cursor()
query = """
SELECT device_id
FROM rssi_history
WHERE scanner_id IS NOT NULL
AND timestamp >= datetime('now', '-' || ? || ' seconds')
GROUP BY device_id
HAVING COUNT(DISTINCT scanner_id) >= 2
"""
cursor.execute(query, (seconds,))
return [r['device_id'] for r in cursor.fetchall()]
def get_movement_events(self, device_id: Optional[str] = None,
since: Optional[str] = None,
limit: int = 100) -> list[dict]:

View File

@@ -118,3 +118,85 @@ def trilaterate_2d(
y = (A * F - D * C) / denom
return (x, y)
def trilaterate_weighted_latlon(
scanner_data: list[dict]
) -> tuple[float, float, float] | None:
"""
Weighted trilateration using lat/lon coordinates.
Args:
scanner_data: List of dicts with:
- lat: Scanner latitude
- lon: Scanner longitude
- distance: Estimated distance in meters
- rssi: Signal strength (for weighting)
Returns:
(lat, lon, confidence) or None if insufficient data
"""
if len(scanner_data) < 2:
return None
# Calculate weights from RSSI (higher = better)
for s in scanner_data:
s['weight'] = 10 ** ((s['rssi'] + 100) / 40) # Normalize -100 to 0 range
total_weight = sum(s['weight'] for s in scanner_data)
if len(scanner_data) == 2:
# Two scanners: weighted average along line between them
s1, s2 = scanner_data[0], scanner_data[1]
# Calculate position along line based on distance ratio
total_dist = s1['distance'] + s2['distance']
if total_dist == 0:
ratio = 0.5
else:
ratio = s1['distance'] / total_dist
# Weighted interpolation
lat = s1['lat'] + ratio * (s2['lat'] - s1['lat'])
lon = s1['lon'] + ratio * (s2['lon'] - s1['lon'])
# Lower confidence for 2-scanner solution
confidence = 0.5
return (lat, lon, confidence)
# 3+ scanners: convert to local XY and trilaterate
# Use centroid as origin
center_lat = sum(s['lat'] for s in scanner_data) / len(scanner_data)
center_lon = sum(s['lon'] for s in scanner_data) / len(scanner_data)
# Convert to meters from centroid
positions = []
distances = []
for s in scanner_data:
x = (s['lon'] - center_lon) * 111000 * math.cos(math.radians(center_lat))
y = (s['lat'] - center_lat) * 111000
positions.append((x, y))
distances.append(s['distance'])
# Use existing trilaterate_2d
result = trilaterate_2d(positions, distances)
if result is None:
return None
x, y = result
# Convert back to lat/lon
lat = center_lat + (y / 111000)
lon = center_lon + (x / (111000 * math.cos(math.radians(center_lat))))
# Calculate confidence based on residuals
residuals = []
for i, s in enumerate(scanner_data):
calc_dist = math.sqrt((x - positions[i][0])**2 + (y - positions[i][1])**2)
residuals.append(abs(calc_dist - distances[i]))
avg_residual = sum(residuals) / len(residuals)
avg_distance = sum(distances) / len(distances)
confidence = max(0, min(1, 1 - (avg_residual / max(avg_distance, 1))))
return (lat, lon, confidence)

View File

@@ -1294,6 +1294,132 @@ def create_app(config: Config | None = None) -> Flask:
return jsonify(activity)
# ==================== Trilateration API ====================
@app.route("/api/positions/trilaterated")
def api_trilaterated_positions():
"""Get trilaterated positions for devices seen by multiple scanners."""
from ..distance import trilaterate_weighted_latlon, estimate_distance
db = app.config.get("DATABASE")
if not db:
return jsonify({"error": "Database not enabled"}), 503
seconds = int(request.args.get("seconds", 60))
# Get devices seen by multiple scanners
device_ids = db.get_devices_seen_by_multiple_scanners(seconds=seconds)
# Get all scanner positions (local + peers)
scanner_positions = {}
# Local scanner
local = app.config.get("SCANNER_IDENTITY", {})
if local.get("id"):
scanner_positions[local["id"]] = {
"lat": local.get("latitude"),
"lon": local.get("longitude")
}
# Peer scanners
peers = db.get_peers()
for peer in peers:
scanner_positions[peer["scanner_id"]] = {
"lat": peer.get("latitude"),
"lon": peer.get("longitude")
}
results = []
for device_id in device_ids:
scanner_rssi = db.get_device_multi_scanner_rssi(device_id, seconds=seconds)
# Build scanner data for trilateration
scanner_data = []
for sr in scanner_rssi:
sid = sr["scanner_id"]
if sid in scanner_positions and scanner_positions[sid].get("lat"):
pos = scanner_positions[sid]
dist = estimate_distance(int(sr["avg_rssi"]), tx_power=-65)
scanner_data.append({
"scanner_id": sid,
"lat": pos["lat"],
"lon": pos["lon"],
"distance": dist,
"rssi": sr["avg_rssi"]
})
if len(scanner_data) < 2:
continue
# Calculate trilaterated position
result = trilaterate_weighted_latlon(scanner_data)
if result:
lat, lon, confidence = result
results.append({
"device_id": device_id,
"position": {"lat": lat, "lon": lon},
"confidence": round(confidence, 2),
"scanners": [s["scanner_id"] for s in scanner_data],
"method": "trilateration" if len(scanner_data) >= 3 else "weighted_average"
})
return jsonify({"devices": results, "count": len(results)})
@app.route("/api/heatmap/signal")
def api_signal_heatmap():
"""Get signal strength data for heat map visualization."""
db = app.config.get("DATABASE")
if not db:
return jsonify({"error": "Database not enabled"}), 503
floor = request.args.get("floor")
# Get scanner positions as anchor points (max signal)
points = []
# Local scanner
local = app.config.get("SCANNER_IDENTITY", {})
if local.get("latitude"):
points.append({
"lat": local["latitude"],
"lon": local["longitude"],
"signal": -30, # Scanner location = max signal
"weight": 1.0
})
# Peer scanners
peers = db.get_peers()
for peer in peers:
if peer.get("latitude"):
points.append({
"lat": peer["latitude"],
"lon": peer["longitude"],
"signal": -30,
"weight": 1.0
})
# Get recent device observations with positions
# Use trilaterated positions if available, otherwise source scanner positions
devices = db.get_all_devices(device_type="bluetooth", limit=100)
for dev in devices:
# Get most recent RSSI for this device
rssi_history = db.get_device_rssi_history(dev["device_id"], limit=1)
if rssi_history:
last_rssi = rssi_history[0].rssi
# Get device source info
if dev.get("source_scanner_lat") and dev.get("source_scanner_lon"):
# Calculate rough position from source scanner
from ..distance import estimate_distance
dist = estimate_distance(last_rssi, tx_power=-65)
points.append({
"lat": dev["source_scanner_lat"],
"lon": dev["source_scanner_lon"],
"signal": last_rssi,
"weight": max(0.1, min(0.8, 1 - (dist / 50))) # Weight by distance
})
return jsonify({"points": points, "count": len(points)})
@app.route("/api/history/stats")
def api_history_stats():
"""Get database statistics"""

View File

@@ -832,6 +832,10 @@ body {
color: var(--color-primary);
}
.popup-position-status .status-value.trilaterated {
color: #ffd700;
}
.popup-source-info {
margin-top: 6px;
padding: 4px 8px;
@@ -974,6 +978,39 @@ body {
background: rgba(0, 200, 255, 0.9);
}
/* Trilaterated device markers - gold border */
.marker-3d.trilaterated .marker-icon {
border: 2px dashed #ffd700 !important;
}
.marker-3d.trilaterated.high-confidence .marker-icon {
border-style: solid !important;
box-shadow: 0 0 10px rgba(255, 215, 0, 0.7), 0 2px 8px rgba(0, 0, 0, 0.5) !important;
}
.trilat-badge {
position: absolute;
top: -6px;
right: -6px;
background: #ffd700;
color: #000;
font-size: 7px;
padding: 1px 3px;
border-radius: 3px;
font-weight: bold;
}
/* Heat map toggle button */
.filter-btn.heatmap {
color: #ff6b6b;
border-color: #ff6b6b;
}
.filter-btn.heatmap.active {
background: rgba(255, 107, 107, 0.2);
color: #ff6b6b;
}
/* Floor Controls */
.floor-section {
display: block;

View File

@@ -24,6 +24,13 @@ let deviceSources = {}; // { deviceId: { scanner_id, lat, lon } }
// Peer scanner positions - loaded from /api/peers (live positions)
let peerScanners = {}; // { scanner_id: { lat, lon, floor, name } }
// Trilateration state - positions calculated from multiple scanner RSSI
let trilateratedPositions = {}; // { deviceId: { lat, lon, confidence, scanners, method } }
let trilaterationEnabled = true;
// Heat map state
let heatMapEnabled = false;
// Auto-scan state
let autoScanEnabled = false;
let autoScanPollInterval = null;
@@ -131,6 +138,7 @@ document.addEventListener('DOMContentLoaded', () => {
loadLatestScan();
loadAutoScanStatus();
loadDevicePositions(); // Load saved manual positions
loadTrilateratedPositions(); // Load multi-scanner trilaterated positions
// Initialize 3D map as default view
setTimeout(() => {
@@ -287,6 +295,9 @@ function handleWebSocketScanUpdate(data) {
document.getElementById('bt-count').textContent = scanData.bluetooth_devices.length;
document.getElementById('bt-list-count').textContent = scanData.bluetooth_devices.length;
// Refresh trilaterated positions (non-blocking)
loadTrilateratedPositions();
// Refresh views
drawRadar();
update3DMarkers();
@@ -1256,13 +1267,53 @@ async function loadDevicePositions() {
}
}
// Get device position (manual or RSSI-based)
// Load trilaterated positions from multi-scanner RSSI data
async function loadTrilateratedPositions() {
if (!trilaterationEnabled) return;
try {
const resp = await fetch('/api/positions/trilaterated');
if (!resp.ok) return;
const data = await resp.json();
trilateratedPositions = {};
(data.devices || []).forEach(d => {
trilateratedPositions[d.device_id] = {
lat: d.position.lat,
lon: d.position.lon,
confidence: d.confidence,
scanners: d.scanners,
method: d.method
};
});
console.log(`[Trilateration] Loaded ${data.count} positions`);
} catch (e) {
console.error('[Trilateration] Error:', e);
}
}
// Get device position (manual, trilaterated, or RSSI-based)
// Priority: 1. Manual position, 2. Trilaterated (if confidence > 0.3), 3. RSSI-based
// Uses source scanner position for synced devices so they don't move when local scanner moves
function getDevicePosition(device, scannerLat, scannerLon, minDistanceM) {
const deviceId = device.bssid || device.address;
const customPos = manualPositions[deviceId];
const sourceInfo = deviceSources[deviceId];
// Check for trilaterated position first (if enabled and confident)
const triData = trilateratedPositions[deviceId];
if (trilaterationEnabled && triData && triData.confidence > 0.3) {
return {
lat: triData.lat,
lon: triData.lon,
isManual: false,
isTrilaterated: true,
confidence: triData.confidence,
scannerCount: triData.scanners.length,
method: triData.method
};
}
// Determine which scanner position to use for this device
// If device was synced from another scanner, use that scanner's CURRENT position
let baseLat = scannerLat;
@@ -1780,26 +1831,33 @@ function update3DMarkers() {
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 isTrilaterated = pos.isTrilaterated === true;
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');
if (isTrilaterated) {
el.classList.add('trilaterated');
if (pos.confidence >= 0.7) el.classList.add('high-confidence');
}
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)' : ''}${hasManualPosition ? ' (Manual position)' : ''}${missCount > 0 ? ` (fading: ${missCount}/${MAX_MISSED_SCANS})` : ''}`;
const trilatBadge = isTrilaterated ? `<span class="trilat-badge">${pos.scannerCount}📡</span>` : '';
el.innerHTML = `<div class="marker-icon">${isMoving ? '🟣' : '🔵'}${trilatBadge}</div><div class="marker-floor">${btFloorLabel}</div>`;
el.title = `${dev.name} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}${isMoving ? ' (MOVING)' : ''}${isTrilaterated ? ` (Trilaterated: ${pos.scannerCount} scanners, ${Math.round(pos.confidence*100)}% conf)` : ''}${hasManualPosition ? ' (Manual position)' : ''}${missCount > 0 ? ` (fading: ${missCount}/${MAX_MISSED_SCANS})` : ''}`;
const btDistLabel = hasCustomDist ? `${effectiveDist}m (custom)` : `~${dev.estimated_distance_m}m`;
const positionStatus = hasManualPosition ? 'Manual' : 'Auto';
const positionClass = hasManualPosition ? 'manual' : 'auto';
const positionStatus = isTrilaterated ? `Trilaterated (${pos.scannerCount} scanners)` : (hasManualPosition ? 'Manual' : 'Auto');
const positionClass = isTrilaterated ? 'trilaterated' : (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 dragHint = isDraggable && !hasManualPosition && !isTrilaterated ? '<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 btSourceInfo = pos.isRemoteSource ? `<div class="popup-source-info">📡 Source: ${pos.sourceScanner}</div>` : '';
const trilatInfo = isTrilaterated ? `<div class="popup-source-info" style="background:rgba(255,215,0,0.1);border-color:rgba(255,215,0,0.3);color:#ffd700;">📐 ${pos.method}: ${Math.round(pos.confidence*100)}% confidence</div>` : '';
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(`
<strong>${isMoving ? '🟣' : '🔵'} ${escapeHtml(dev.name)}</strong>${isMoving ? ' <span style="color:#9b59b6;font-size:0.8em;">(Moving)</span>' : ''}<br>
@@ -1808,6 +1866,7 @@ function update3DMarkers() {
Type: ${escapeHtml(dev.device_type)}<br>
${escapeHtml(dev.manufacturer)}<br>
${btSourceInfo}
${trilatInfo}
<div class="popup-floor-control">
<label>Floor:</label>
<select onchange="updateDeviceFloor('${btDeviceId}', this.value)">
@@ -2221,6 +2280,9 @@ async function performLiveBTScan() {
document.getElementById('bt-count').textContent = scanData.bluetooth_devices.length;
document.getElementById('bt-list-count').textContent = scanData.bluetooth_devices.length;
// Refresh trilaterated positions (non-blocking)
loadTrilateratedPositions();
// Refresh views
drawRadar();
update3DMarkers();
@@ -2477,3 +2539,85 @@ function clearAllTrails() {
deviceTrails = {};
}
// ========== Heat Map Functions ==========
// Toggle heat map layer
function toggleHeatMap() {
heatMapEnabled = !heatMapEnabled;
const btn = document.getElementById('btn-heatmap');
if (btn) btn.classList.toggle('active', heatMapEnabled);
if (heatMapEnabled) {
renderHeatMap();
} else {
removeHeatMap();
}
}
// Render heat map layer on 3D map
async function renderHeatMap() {
if (!map3d || !map3dLoaded) {
console.log('[HeatMap] 3D map not ready');
return;
}
try {
const resp = await fetch('/api/heatmap/signal');
if (!resp.ok) return;
const data = await resp.json();
console.log(`[HeatMap] Loaded ${data.count} points`);
// Remove existing heat map if present
removeHeatMap();
// Add heat map source
map3d.addSource('signal-heatmap', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: data.points.map(p => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
properties: { weight: p.weight }
}))
}
});
// Add heat map layer
map3d.addLayer({
id: 'signal-heatmap-layer',
type: 'heatmap',
source: 'signal-heatmap',
paint: {
'heatmap-weight': ['get', 'weight'],
'heatmap-intensity': 0.6,
'heatmap-radius': 40,
'heatmap-opacity': 0.6,
'heatmap-color': [
'interpolate', ['linear'], ['heatmap-density'],
0, 'rgba(0,0,255,0)',
0.3, 'rgba(0,255,255,0.5)',
0.5, 'rgba(0,255,0,0.6)',
0.7, 'rgba(255,255,0,0.7)',
1, 'rgba(255,0,0,0.8)'
]
}
});
} catch (e) {
console.error('[HeatMap] Error:', e);
}
}
// Remove heat map layer from 3D map
function removeHeatMap() {
if (!map3d) return;
if (map3d.getLayer('signal-heatmap-layer')) {
map3d.removeLayer('signal-heatmap-layer');
}
if (map3d.getSource('signal-heatmap')) {
map3d.removeSource('signal-heatmap');
}
}

View File

@@ -33,6 +33,9 @@
<span class="filter-indicator"></span>
<span>Bluetooth</span>
</button>
<button id="btn-heatmap" class="filter-btn heatmap" onclick="toggleHeatMap()">
<span>🌡️ Heat Map</span>
</button>
</div>
<canvas id="radar-canvas" class="radar-canvas"></canvas>