Add proximity radar visualization and signal history heatmap

Backend:
- Add device_key.py for stable device identification (identity > public MAC > fingerprint)
- Add distance.py with DistanceEstimator class (path-loss formula, EMA smoothing, confidence scoring)
- Add ring_buffer.py for time-windowed RSSI observation storage
- Extend BTDeviceAggregate with proximity_band, estimated_distance_m, distance_confidence, rssi_ema
- Add new API endpoints: /proximity/snapshot, /heatmap/data, /devices/<key>/timeseries
- Update TSCM integration to include new proximity fields

Frontend:
- Add proximity-radar.js: SVG radar with concentric rings, device dots positioned by distance
- Add timeline-heatmap.js: RSSI history grid with time buckets and color-coded signal strength
- Update bluetooth.js to initialize and feed data to new components
- Replace zone counters with radar visualization and zone summary
- Add proximity-viz.css for component styling

Tests:
- Add test_bluetooth_proximity.py with unit tests for device key stability, EMA smoothing,
  distance estimation, band classification, and ring buffer functionality

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-01-21 19:25:33 +00:00
parent bd7c83b18c
commit 7957176e59
14 changed files with 2870 additions and 27 deletions

View File

@@ -614,9 +614,11 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
tscm_devices.append({
'mac': device.address,
'address_type': device.address_type,
'device_key': device.device_key,
'name': device.name or 'Unknown',
'rssi': device.rssi_current or -100,
'rssi_median': device.rssi_median,
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
'type': _classify_device_type(device),
'manufacturer': device.manufacturer_name,
'protocol': device.protocol,
@@ -624,6 +626,11 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
'last_seen': device.last_seen.isoformat(),
'seen_count': device.seen_count,
'range_band': device.range_band,
'proximity_band': device.proximity_band,
'estimated_distance_m': round(device.estimated_distance_m, 2) if device.estimated_distance_m else None,
'distance_confidence': round(device.distance_confidence, 2),
'is_randomized_mac': device.is_randomized_mac,
'threat_tags': device.threat_tags,
'heuristics': {
'is_new': device.is_new,
'is_persistent': device.is_persistent,
@@ -637,6 +644,171 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
return tscm_devices
# =============================================================================
# PROXIMITY & HEATMAP ENDPOINTS
# =============================================================================
@bluetooth_v2_bp.route('/proximity/snapshot', methods=['GET'])
def get_proximity_snapshot():
"""
Get proximity snapshot for radar visualization.
All active devices with proximity data including estimated distance,
proximity band, and confidence scores.
Query parameters:
- max_age: Maximum age in seconds (default: 60)
- min_confidence: Minimum distance confidence (default: 0)
Returns:
JSON with proximity data for all active devices.
"""
scanner = get_bluetooth_scanner()
max_age = request.args.get('max_age', 60, type=float)
min_confidence = request.args.get('min_confidence', 0.0, type=float)
devices = scanner.get_devices(max_age_seconds=max_age)
# Filter by confidence if specified
if min_confidence > 0:
devices = [d for d in devices if d.distance_confidence >= min_confidence]
# Build proximity snapshot
snapshot = {
'timestamp': datetime.now().isoformat(),
'device_count': len(devices),
'zone_counts': {
'immediate': 0,
'near': 0,
'far': 0,
'unknown': 0,
},
'devices': [],
}
for device in devices:
# Count by zone
band = device.proximity_band or 'unknown'
if band in snapshot['zone_counts']:
snapshot['zone_counts'][band] += 1
else:
snapshot['zone_counts']['unknown'] += 1
snapshot['devices'].append({
'device_key': device.device_key,
'device_id': device.device_id,
'name': device.name,
'address': device.address,
'rssi_current': device.rssi_current,
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
'estimated_distance_m': round(device.estimated_distance_m, 2) if device.estimated_distance_m else None,
'proximity_band': device.proximity_band,
'distance_confidence': round(device.distance_confidence, 2),
'is_new': device.is_new,
'is_randomized_mac': device.is_randomized_mac,
'in_baseline': device.in_baseline,
'heuristic_flags': device.heuristic_flags,
'last_seen': device.last_seen.isoformat(),
'age_seconds': round(device.age_seconds, 1),
})
return jsonify(snapshot)
@bluetooth_v2_bp.route('/heatmap/data', methods=['GET'])
def get_heatmap_data():
"""
Get heatmap data for timeline visualization.
Returns top N devices with downsampled RSSI timeseries.
Query parameters:
- top_n: Number of devices (default: 20)
- window_minutes: Time window (default: 10)
- bucket_seconds: Bucket size for downsampling (default: 10)
- sort_by: Sort method - 'recency', 'strength', 'activity' (default: 'recency')
Returns:
JSON with device timeseries data for heatmap.
"""
scanner = get_bluetooth_scanner()
top_n = request.args.get('top_n', 20, type=int)
window_minutes = request.args.get('window_minutes', 10, type=int)
bucket_seconds = request.args.get('bucket_seconds', 10, type=int)
sort_by = request.args.get('sort_by', 'recency')
# Validate sort_by
if sort_by not in ('recency', 'strength', 'activity'):
sort_by = 'recency'
# Get heatmap data from aggregator
heatmap_data = scanner._aggregator.get_heatmap_data(
top_n=top_n,
window_minutes=window_minutes,
bucket_seconds=bucket_seconds,
sort_by=sort_by,
)
return jsonify(heatmap_data)
@bluetooth_v2_bp.route('/devices/<path:device_key>/timeseries', methods=['GET'])
def get_device_timeseries(device_key: str):
"""
Get timeseries data for a specific device.
Path parameters:
- device_key: Stable device identifier
Query parameters:
- window_minutes: Time window (default: 30)
- bucket_seconds: Bucket size for downsampling (default: 10)
Returns:
JSON with device timeseries data.
"""
scanner = get_bluetooth_scanner()
window_minutes = request.args.get('window_minutes', 30, type=int)
bucket_seconds = request.args.get('bucket_seconds', 10, type=int)
# URL decode device key
from urllib.parse import unquote
device_key = unquote(device_key)
# Get device info
device = scanner._aggregator.get_device_by_key(device_key)
# Get timeseries data
timeseries = scanner._aggregator.get_timeseries(
device_key=device_key,
window_minutes=window_minutes,
downsample_seconds=bucket_seconds,
)
result = {
'device_key': device_key,
'window_minutes': window_minutes,
'bucket_seconds': bucket_seconds,
'observation_count': len(timeseries),
'timeseries': timeseries,
}
if device:
result.update({
'name': device.name,
'address': device.address,
'rssi_current': device.rssi_current,
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
'proximity_band': device.proximity_band,
'estimated_distance_m': round(device.estimated_distance_m, 2) if device.estimated_distance_m else None,
})
return jsonify(result)
def _classify_device_type(device: BTDeviceAggregate) -> str:
"""Classify device type from available data."""
name_lower = (device.name or '').lower()

View File

@@ -0,0 +1,287 @@
/**
* Proximity Visualization Components
* Styles for radar and timeline heatmap
*/
/* ============================================
PROXIMITY RADAR
============================================ */
.proximity-radar-svg {
display: block;
margin: 0 auto;
}
.radar-device {
transition: transform 0.2s ease;
}
.radar-device:hover {
transform: scale(1.3);
}
.radar-dot-pulse circle:first-child {
animation: radar-pulse 1.5s ease-out infinite;
}
@keyframes radar-pulse {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(2);
opacity: 0;
}
}
.radar-sweep {
transform-origin: 50% 50%;
}
/* Radar filter buttons */
.bt-radar-filter-btn {
transition: all 0.2s ease;
}
.bt-radar-filter-btn:hover {
background: var(--bg-hover, #333) !important;
color: #fff !important;
}
.bt-radar-filter-btn.active {
background: #00d4ff !important;
color: #000 !important;
border-color: #00d4ff !important;
}
#btRadarPauseBtn.active {
background: #f97316 !important;
color: #000 !important;
border-color: #f97316 !important;
}
/* ============================================
TIMELINE HEATMAP
============================================ */
.timeline-heatmap-controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 8px 0;
margin-bottom: 8px;
border-bottom: 1px solid var(--border-color, #333);
}
.heatmap-control-group {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-dim, #888);
}
.heatmap-select {
background: var(--bg-tertiary, #1a1a1a);
border: 1px solid var(--border-color, #333);
border-radius: 4px;
color: var(--text-primary, #e0e0e0);
font-size: 10px;
padding: 4px 8px;
cursor: pointer;
}
.heatmap-select:hover {
border-color: var(--accent-color, #00d4ff);
}
.heatmap-btn {
background: var(--bg-tertiary, #1a1a1a);
border: 1px solid var(--border-color, #333);
border-radius: 4px;
color: var(--text-dim, #888);
font-size: 10px;
padding: 4px 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.heatmap-btn:hover {
background: var(--bg-hover, #252525);
color: var(--text-primary, #e0e0e0);
}
.heatmap-btn.active {
background: #f97316;
color: #000;
border-color: #f97316;
}
.timeline-heatmap-content {
max-height: 300px;
overflow-y: auto;
overflow-x: auto;
}
.heatmap-loading,
.heatmap-empty,
.heatmap-error {
color: var(--text-dim, #666);
text-align: center;
padding: 30px;
font-size: 12px;
}
.heatmap-error {
color: #ef4444;
}
.heatmap-grid {
display: flex;
flex-direction: column;
gap: 2px;
min-width: max-content;
}
.heatmap-row {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 0;
cursor: pointer;
border-radius: 4px;
transition: background 0.2s ease;
}
.heatmap-row:hover:not(.heatmap-header) {
background: rgba(255, 255, 255, 0.05);
}
.heatmap-row.selected {
background: rgba(0, 212, 255, 0.1);
outline: 1px solid rgba(0, 212, 255, 0.3);
}
.heatmap-header {
cursor: default;
border-bottom: 1px solid var(--border-color, #333);
margin-bottom: 4px;
}
.heatmap-label {
width: 120px;
min-width: 120px;
display: flex;
flex-direction: column;
gap: 2px;
padding-right: 8px;
overflow: hidden;
}
.heatmap-label .device-name {
font-size: 10px;
color: var(--text-primary, #e0e0e0);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.heatmap-label .device-rssi {
font-size: 9px;
color: var(--text-dim, #666);
font-family: monospace;
}
.heatmap-cells {
display: flex;
gap: 1px;
}
.heatmap-cell {
border-radius: 2px;
transition: transform 0.1s ease;
}
.heatmap-cell:hover {
transform: scale(1.5);
z-index: 10;
position: relative;
}
.heatmap-time-label {
font-size: 8px;
color: var(--text-dim, #666);
text-align: center;
transform: rotate(-45deg);
white-space: nowrap;
}
.heatmap-legend {
display: flex;
align-items: center;
gap: 12px;
padding-top: 8px;
margin-top: 8px;
border-top: 1px solid var(--border-color, #333);
font-size: 10px;
color: var(--text-dim, #666);
}
.legend-label {
font-weight: 500;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
}
/* ============================================
ZONE SUMMARY
============================================ */
#btZoneSummary {
padding: 8px 0;
}
#btZoneSummary > div {
min-width: 60px;
}
/* ============================================
RESPONSIVE ADJUSTMENTS
============================================ */
@media (max-width: 768px) {
.timeline-heatmap-controls {
flex-direction: column;
align-items: stretch;
}
.heatmap-control-group {
justify-content: space-between;
}
.proximity-radar-svg {
max-width: 100%;
height: auto;
}
#btRadarControls {
flex-direction: column;
gap: 4px;
}
#btZoneSummary {
flex-wrap: wrap;
}
}

View File

@@ -0,0 +1,369 @@
/**
* Proximity Radar Component
*
* SVG-based circular radar visualization for Bluetooth device proximity.
* Displays devices positioned by estimated distance with concentric rings
* for proximity bands.
*/
const ProximityRadar = (function() {
'use strict';
// Configuration
const CONFIG = {
size: 280,
padding: 20,
centerRadius: 8,
rings: [
{ band: 'immediate', radius: 0.25, color: '#22c55e', label: '< 1m' },
{ band: 'near', radius: 0.5, color: '#eab308', label: '1-3m' },
{ band: 'far', radius: 0.85, color: '#ef4444', label: '3-10m' },
],
dotMinSize: 4,
dotMaxSize: 12,
pulseAnimationDuration: 2000,
newDeviceThreshold: 30, // seconds
};
// State
let container = null;
let svg = null;
let devices = new Map();
let isPaused = false;
let activeFilter = null;
let onDeviceClick = null;
/**
* Initialize the radar component
*/
function init(containerId, options = {}) {
container = document.getElementById(containerId);
if (!container) {
console.error('[ProximityRadar] Container not found:', containerId);
return;
}
if (options.onDeviceClick) {
onDeviceClick = options.onDeviceClick;
}
createSVG();
}
/**
* Create the SVG radar structure
*/
function createSVG() {
const size = CONFIG.size;
const center = size / 2;
container.innerHTML = `
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" class="proximity-radar-svg">
<defs>
<radialGradient id="radarGradient" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="rgba(0, 212, 255, 0.1)" />
<stop offset="100%" stop-color="rgba(0, 212, 255, 0)" />
</radialGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Background gradient -->
<circle cx="${center}" cy="${center}" r="${center - CONFIG.padding}"
fill="url(#radarGradient)" />
<!-- Proximity rings -->
<g class="radar-rings">
${CONFIG.rings.map((ring, i) => {
const r = ring.radius * (center - CONFIG.padding);
return `
<circle cx="${center}" cy="${center}" r="${r}"
fill="none" stroke="${ring.color}" stroke-opacity="0.3"
stroke-width="1" stroke-dasharray="4,4" />
<text x="${center}" y="${center - r + 12}"
text-anchor="middle" fill="${ring.color}" fill-opacity="0.6"
font-size="9" font-family="monospace">${ring.label}</text>
`;
}).join('')}
</g>
<!-- Sweep line (animated) -->
<line class="radar-sweep" x1="${center}" y1="${center}"
x2="${center}" y2="${CONFIG.padding}"
stroke="rgba(0, 212, 255, 0.5)" stroke-width="1" />
<!-- Center point -->
<circle cx="${center}" cy="${center}" r="${CONFIG.centerRadius}"
fill="#00d4ff" filter="url(#glow)" />
<!-- Device dots container -->
<g class="radar-devices"></g>
<!-- Legend -->
<g class="radar-legend" transform="translate(${size - 70}, ${size - 55})">
<text x="0" y="0" fill="#666" font-size="8">PROXIMITY</text>
<text x="0" y="0" fill="#666" font-size="7" font-style="italic"
transform="translate(0, 10)">(signal strength)</text>
</g>
</svg>
`;
svg = container.querySelector('svg');
// Add sweep animation
animateSweep();
}
/**
* Animate the radar sweep line
*/
function animateSweep() {
const sweepLine = svg.querySelector('.radar-sweep');
if (!sweepLine) return;
let angle = 0;
const center = CONFIG.size / 2;
function rotate() {
if (isPaused) {
requestAnimationFrame(rotate);
return;
}
angle = (angle + 1) % 360;
const rad = (angle * Math.PI) / 180;
const radius = center - CONFIG.padding;
const x2 = center + Math.sin(rad) * radius;
const y2 = center - Math.cos(rad) * radius;
sweepLine.setAttribute('x2', x2);
sweepLine.setAttribute('y2', y2);
requestAnimationFrame(rotate);
}
requestAnimationFrame(rotate);
}
/**
* Update devices on the radar
*/
function updateDevices(deviceList) {
if (isPaused) return;
// Update device map
deviceList.forEach(device => {
devices.set(device.device_key, device);
});
// Apply filter and render
renderDevices();
}
/**
* Render device dots on the radar
*/
function renderDevices() {
const devicesGroup = svg.querySelector('.radar-devices');
if (!devicesGroup) return;
const center = CONFIG.size / 2;
const maxRadius = center - CONFIG.padding;
// Filter devices
let visibleDevices = Array.from(devices.values());
if (activeFilter === 'newOnly') {
visibleDevices = visibleDevices.filter(d => d.is_new || d.age_seconds < CONFIG.newDeviceThreshold);
} else if (activeFilter === 'strongest') {
visibleDevices = visibleDevices
.filter(d => d.rssi_current != null)
.sort((a, b) => (b.rssi_current || -100) - (a.rssi_current || -100))
.slice(0, 10);
} else if (activeFilter === 'unapproved') {
visibleDevices = visibleDevices.filter(d => !d.in_baseline);
}
// Build SVG for each device
const dots = visibleDevices.map(device => {
// Calculate position
const { x, y, radius } = calculateDevicePosition(device, center, maxRadius);
// Calculate dot size based on confidence
const confidence = device.distance_confidence || 0.5;
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
// Get color based on proximity band
const color = getBandColor(device.proximity_band);
// Check if newly seen (pulse animation)
const isNew = device.age_seconds < 5;
const pulseClass = isNew ? 'radar-dot-pulse' : '';
return `
<g class="radar-device ${pulseClass}" data-device-key="${escapeAttr(device.device_key)}"
transform="translate(${x}, ${y})" style="cursor: pointer;">
<circle r="${dotSize}" fill="${color}"
fill-opacity="${0.4 + confidence * 0.5}"
stroke="${color}" stroke-width="1" />
${device.is_new ? `<circle r="${dotSize + 3}" fill="none" stroke="#3b82f6" stroke-width="1" stroke-dasharray="2,2" />` : ''}
<title>${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)</title>
</g>
`;
}).join('');
devicesGroup.innerHTML = dots;
// Attach click handlers
devicesGroup.querySelectorAll('.radar-device').forEach(el => {
el.addEventListener('click', (e) => {
const deviceKey = el.getAttribute('data-device-key');
if (onDeviceClick && deviceKey) {
onDeviceClick(deviceKey);
}
});
});
}
/**
* Calculate device position on radar
*/
function calculateDevicePosition(device, center, maxRadius) {
// Calculate radius based on proximity band/distance
let radiusRatio;
const band = device.proximity_band || 'unknown';
if (device.estimated_distance_m != null) {
// Use actual distance (log scale)
const maxDistance = 15;
radiusRatio = Math.min(1, Math.log10(device.estimated_distance_m + 1) / Math.log10(maxDistance + 1));
} else {
// Use band-based positioning
switch (band) {
case 'immediate': radiusRatio = 0.15; break;
case 'near': radiusRatio = 0.4; break;
case 'far': radiusRatio = 0.7; break;
default: radiusRatio = 0.9; break;
}
}
// Calculate angle based on device key hash (stable positioning)
const angle = hashToAngle(device.device_key || device.device_id);
const radius = radiusRatio * maxRadius;
const x = center + Math.sin(angle) * radius;
const y = center - Math.cos(angle) * radius;
return { x, y, radius };
}
/**
* Hash string to angle for stable positioning
*/
function hashToAngle(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash = hash & hash;
}
return (Math.abs(hash) % 360) * (Math.PI / 180);
}
/**
* Get color for proximity band
*/
function getBandColor(band) {
switch (band) {
case 'immediate': return '#22c55e';
case 'near': return '#eab308';
case 'far': return '#ef4444';
default: return '#6b7280';
}
}
/**
* Set filter mode
*/
function setFilter(filter) {
activeFilter = filter === activeFilter ? null : filter;
renderDevices();
}
/**
* Toggle pause state
*/
function setPaused(paused) {
isPaused = paused;
}
/**
* Clear all devices
*/
function clear() {
devices.clear();
renderDevices();
}
/**
* Get zone counts
*/
function getZoneCounts() {
const counts = { immediate: 0, near: 0, far: 0, unknown: 0 };
devices.forEach(device => {
const band = device.proximity_band || 'unknown';
if (counts.hasOwnProperty(band)) {
counts[band]++;
} else {
counts.unknown++;
}
});
return counts;
}
/**
* Escape HTML for safe rendering
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
/**
* Escape attribute value
*/
function escapeAttr(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// Public API
return {
init,
updateDevices,
setFilter,
setPaused,
clear,
getZoneCounts,
isPaused: () => isPaused,
getFilter: () => activeFilter,
};
})();
// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = ProximityRadar;
}
window.ProximityRadar = ProximityRadar;

View File

@@ -0,0 +1,409 @@
/**
* Timeline Heatmap Component
*
* Displays RSSI signal history as a heatmap grid.
* Y-axis: devices, X-axis: time buckets, Cell color: RSSI strength
*/
const TimelineHeatmap = (function() {
'use strict';
// Configuration
const CONFIG = {
cellWidth: 8,
cellHeight: 20,
labelWidth: 120,
maxDevices: 20,
refreshInterval: 5000,
// RSSI color scale (green = strong, red = weak)
colorScale: [
{ rssi: -40, color: '#22c55e' }, // Strong - green
{ rssi: -55, color: '#84cc16' }, // Good - lime
{ rssi: -65, color: '#eab308' }, // Medium - yellow
{ rssi: -75, color: '#f97316' }, // Weak - orange
{ rssi: -90, color: '#ef4444' }, // Very weak - red
],
noDataColor: '#2a2a3e',
};
// State
let container = null;
let contentEl = null;
let controlsEl = null;
let data = null;
let isPaused = false;
let refreshTimer = null;
let selectedDeviceKey = null;
let onDeviceSelect = null;
// Settings
let settings = {
windowMinutes: 10,
bucketSeconds: 10,
sortBy: 'recency',
topN: 20,
};
/**
* Initialize the heatmap component
*/
function init(containerId, options = {}) {
container = document.getElementById(containerId);
if (!container) {
console.error('[TimelineHeatmap] Container not found:', containerId);
return;
}
if (options.onDeviceSelect) {
onDeviceSelect = options.onDeviceSelect;
}
// Merge options into settings
Object.assign(settings, options);
createStructure();
startAutoRefresh();
}
/**
* Create the heatmap DOM structure
*/
function createStructure() {
container.innerHTML = `
<div class="timeline-heatmap-controls">
<div class="heatmap-control-group">
<label>Window:</label>
<select id="heatmapWindow" class="heatmap-select">
<option value="10" ${settings.windowMinutes === 10 ? 'selected' : ''}>10 min</option>
<option value="30" ${settings.windowMinutes === 30 ? 'selected' : ''}>30 min</option>
<option value="60" ${settings.windowMinutes === 60 ? 'selected' : ''}>60 min</option>
</select>
</div>
<div class="heatmap-control-group">
<label>Bucket:</label>
<select id="heatmapBucket" class="heatmap-select">
<option value="10" ${settings.bucketSeconds === 10 ? 'selected' : ''}>10s</option>
<option value="30" ${settings.bucketSeconds === 30 ? 'selected' : ''}>30s</option>
<option value="60" ${settings.bucketSeconds === 60 ? 'selected' : ''}>60s</option>
</select>
</div>
<div class="heatmap-control-group">
<label>Sort:</label>
<select id="heatmapSort" class="heatmap-select">
<option value="recency" ${settings.sortBy === 'recency' ? 'selected' : ''}>Recent</option>
<option value="strength" ${settings.sortBy === 'strength' ? 'selected' : ''}>Strength</option>
<option value="activity" ${settings.sortBy === 'activity' ? 'selected' : ''}>Activity</option>
</select>
</div>
<button id="heatmapPauseBtn" class="heatmap-btn ${isPaused ? 'active' : ''}">
${isPaused ? 'Resume' : 'Pause'}
</button>
</div>
<div class="timeline-heatmap-content">
<div class="heatmap-loading">Loading signal history...</div>
</div>
<div class="heatmap-legend">
<span class="legend-label">Signal:</span>
<span class="legend-item"><span class="legend-color" style="background: #22c55e;"></span>Strong</span>
<span class="legend-item"><span class="legend-color" style="background: #eab308;"></span>Medium</span>
<span class="legend-item"><span class="legend-color" style="background: #ef4444;"></span>Weak</span>
<span class="legend-item"><span class="legend-color" style="background: ${CONFIG.noDataColor};"></span>No data</span>
</div>
`;
contentEl = container.querySelector('.timeline-heatmap-content');
controlsEl = container.querySelector('.timeline-heatmap-controls');
// Attach event listeners
attachEventListeners();
}
/**
* Attach event listeners to controls
*/
function attachEventListeners() {
const windowSelect = container.querySelector('#heatmapWindow');
const bucketSelect = container.querySelector('#heatmapBucket');
const sortSelect = container.querySelector('#heatmapSort');
const pauseBtn = container.querySelector('#heatmapPauseBtn');
windowSelect?.addEventListener('change', (e) => {
settings.windowMinutes = parseInt(e.target.value, 10);
refresh();
});
bucketSelect?.addEventListener('change', (e) => {
settings.bucketSeconds = parseInt(e.target.value, 10);
refresh();
});
sortSelect?.addEventListener('change', (e) => {
settings.sortBy = e.target.value;
refresh();
});
pauseBtn?.addEventListener('click', () => {
isPaused = !isPaused;
pauseBtn.textContent = isPaused ? 'Resume' : 'Pause';
pauseBtn.classList.toggle('active', isPaused);
});
}
/**
* Start auto-refresh timer
*/
function startAutoRefresh() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(() => {
if (!isPaused) {
refresh();
}
}, CONFIG.refreshInterval);
}
/**
* Fetch and render heatmap data
*/
async function refresh() {
if (!container) return;
try {
const params = new URLSearchParams({
top_n: settings.topN,
window_minutes: settings.windowMinutes,
bucket_seconds: settings.bucketSeconds,
sort_by: settings.sortBy,
});
const response = await fetch(`/api/bluetooth/heatmap/data?${params}`);
if (!response.ok) throw new Error('Failed to fetch heatmap data');
data = await response.json();
render();
} catch (err) {
console.error('[TimelineHeatmap] Refresh error:', err);
contentEl.innerHTML = '<div class="heatmap-error">Failed to load data</div>';
}
}
/**
* Render the heatmap grid
*/
function render() {
if (!data || !data.devices || data.devices.length === 0) {
contentEl.innerHTML = '<div class="heatmap-empty">No signal history available yet</div>';
return;
}
// Calculate time buckets
const windowMs = settings.windowMinutes * 60 * 1000;
const bucketMs = settings.bucketSeconds * 1000;
const numBuckets = Math.ceil(windowMs / bucketMs);
const now = new Date();
// Generate time labels
const timeLabels = [];
for (let i = 0; i < numBuckets; i++) {
const time = new Date(now.getTime() - (numBuckets - 1 - i) * bucketMs);
if (i % Math.ceil(numBuckets / 6) === 0) {
timeLabels.push(time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
} else {
timeLabels.push('');
}
}
// Build heatmap HTML
let html = '<div class="heatmap-grid">';
// Time axis header
html += `<div class="heatmap-row heatmap-header">
<div class="heatmap-label"></div>
<div class="heatmap-cells">
${timeLabels.map(label =>
`<div class="heatmap-time-label" style="width: ${CONFIG.cellWidth}px;">${label}</div>`
).join('')}
</div>
</div>`;
// Device rows
data.devices.forEach(device => {
const isSelected = device.device_key === selectedDeviceKey;
const rowClass = isSelected ? 'heatmap-row selected' : 'heatmap-row';
// Create lookup for timeseries data
const tsLookup = new Map();
device.timeseries.forEach(point => {
const ts = new Date(point.timestamp).getTime();
tsLookup.set(ts, point.rssi);
});
// Generate cells for each time bucket
const cells = [];
for (let i = 0; i < numBuckets; i++) {
const bucketTime = new Date(now.getTime() - (numBuckets - 1 - i) * bucketMs);
const bucketKey = Math.floor(bucketTime.getTime() / bucketMs) * bucketMs;
// Find closest timestamp in data
let rssi = null;
const tolerance = bucketMs;
tsLookup.forEach((val, ts) => {
if (Math.abs(ts - bucketKey) < tolerance) {
rssi = val;
}
});
const color = rssi !== null ? getRssiColor(rssi) : CONFIG.noDataColor;
const title = rssi !== null ? `${rssi} dBm` : 'No data';
cells.push(`<div class="heatmap-cell" style="width: ${CONFIG.cellWidth}px; height: ${CONFIG.cellHeight}px; background: ${color};" title="${title}"></div>`);
}
const displayName = device.name || formatAddress(device.address) || device.device_key.substring(0, 12);
const rssiDisplay = device.rssi_ema != null ? `${Math.round(device.rssi_ema)} dBm` : '--';
html += `
<div class="${rowClass}" data-device-key="${escapeAttr(device.device_key)}">
<div class="heatmap-label" title="${escapeHtml(device.name || device.address || '')}">
<span class="device-name">${escapeHtml(displayName)}</span>
<span class="device-rssi">${rssiDisplay}</span>
</div>
<div class="heatmap-cells">${cells.join('')}</div>
</div>
`;
});
html += '</div>';
contentEl.innerHTML = html;
// Attach row click handlers
contentEl.querySelectorAll('.heatmap-row:not(.heatmap-header)').forEach(row => {
row.addEventListener('click', () => {
const deviceKey = row.getAttribute('data-device-key');
selectDevice(deviceKey);
});
});
}
/**
* Get color for RSSI value
*/
function getRssiColor(rssi) {
const scale = CONFIG.colorScale;
// Find the appropriate color from scale
for (let i = 0; i < scale.length; i++) {
if (rssi >= scale[i].rssi) {
return scale[i].color;
}
}
return scale[scale.length - 1].color;
}
/**
* Format MAC address for display
*/
function formatAddress(address) {
if (!address) return null;
const parts = address.split(':');
if (parts.length === 6) {
return `${parts[0]}:${parts[1]}:..${parts[5]}`;
}
return address;
}
/**
* Select a device row
*/
function selectDevice(deviceKey) {
selectedDeviceKey = deviceKey === selectedDeviceKey ? null : deviceKey;
// Update row highlighting
contentEl.querySelectorAll('.heatmap-row').forEach(row => {
const isSelected = row.getAttribute('data-device-key') === selectedDeviceKey;
row.classList.toggle('selected', isSelected);
});
// Callback
if (onDeviceSelect && selectedDeviceKey) {
const device = data?.devices?.find(d => d.device_key === selectedDeviceKey);
onDeviceSelect(selectedDeviceKey, device);
}
}
/**
* Update with new data directly (for SSE integration)
*/
function updateData(newData) {
if (isPaused) return;
data = newData;
render();
}
/**
* Set paused state
*/
function setPaused(paused) {
isPaused = paused;
const pauseBtn = container?.querySelector('#heatmapPauseBtn');
if (pauseBtn) {
pauseBtn.textContent = isPaused ? 'Resume' : 'Pause';
pauseBtn.classList.toggle('active', isPaused);
}
}
/**
* Destroy the component
*/
function destroy() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
if (container) {
container.innerHTML = '';
}
}
/**
* Escape HTML for safe rendering
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
/**
* Escape attribute value
*/
function escapeAttr(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// Public API
return {
init,
refresh,
updateData,
setPaused,
destroy,
selectDevice,
getSelectedDevice: () => selectedDeviceKey,
isPaused: () => isPaused,
};
})();
// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = TimelineHeatmap;
}
window.TimelineHeatmap = TimelineHeatmap;

View File

@@ -35,6 +35,11 @@ const BluetoothMode = (function() {
// Zone counts for proximity display
let zoneCounts = { veryClose: 0, close: 0, nearby: 0, far: 0 };
// New visualization components
let radarInitialized = false;
let heatmapInitialized = false;
let radarPaused = false;
/**
* Initialize the Bluetooth mode
*/
@@ -60,7 +65,11 @@ const BluetoothMode = (function() {
// Check scan status (in case page was reloaded during scan)
checkScanStatus();
// Initialize proximity visualization
// Initialize proximity visualization (new radar + heatmap)
initProximityRadar();
initTimelineHeatmap();
// Initialize legacy heatmap (zone counts)
initHeatmap();
// Initialize timeline as collapsed
@@ -70,6 +79,134 @@ const BluetoothMode = (function() {
updateVisualizationPanels();
}
/**
* Initialize the new proximity radar component
*/
function initProximityRadar() {
const radarContainer = document.getElementById('btProximityRadar');
if (!radarContainer) return;
if (typeof ProximityRadar !== 'undefined') {
ProximityRadar.init('btProximityRadar', {
onDeviceClick: (deviceKey) => {
// Find device by key and show modal
const device = Array.from(devices.values()).find(d => d.device_key === deviceKey);
if (device) {
selectDevice(device.device_id);
}
}
});
radarInitialized = true;
// Setup radar controls
setupRadarControls();
}
}
/**
* Setup radar control button handlers
*/
function setupRadarControls() {
// Filter buttons
document.querySelectorAll('#btRadarControls button[data-filter]').forEach(btn => {
btn.addEventListener('click', () => {
const filter = btn.getAttribute('data-filter');
if (typeof ProximityRadar !== 'undefined') {
ProximityRadar.setFilter(filter);
// Update button states
document.querySelectorAll('#btRadarControls button[data-filter]').forEach(b => {
b.classList.remove('active');
});
if (ProximityRadar.getFilter() === filter) {
btn.classList.add('active');
}
}
});
});
// Pause button
const pauseBtn = document.getElementById('btRadarPauseBtn');
if (pauseBtn) {
pauseBtn.addEventListener('click', () => {
radarPaused = !radarPaused;
if (typeof ProximityRadar !== 'undefined') {
ProximityRadar.setPaused(radarPaused);
}
pauseBtn.textContent = radarPaused ? 'Resume' : 'Pause';
pauseBtn.classList.toggle('active', radarPaused);
});
}
}
/**
* Initialize the timeline heatmap component
*/
function initTimelineHeatmap() {
const heatmapContainer = document.getElementById('btTimelineHeatmap');
if (!heatmapContainer) return;
if (typeof TimelineHeatmap !== 'undefined') {
TimelineHeatmap.init('btTimelineHeatmap', {
windowMinutes: 10,
bucketSeconds: 10,
sortBy: 'recency',
onDeviceSelect: (deviceKey, device) => {
// Find device and show modal
const fullDevice = Array.from(devices.values()).find(d => d.device_key === deviceKey);
if (fullDevice) {
selectDevice(fullDevice.device_id);
}
}
});
heatmapInitialized = true;
}
}
/**
* Update the proximity radar with current devices
*/
function updateRadar() {
if (!radarInitialized || typeof ProximityRadar === 'undefined') return;
// Convert devices map to array for radar
const deviceList = Array.from(devices.values()).map(d => ({
device_key: d.device_key || d.device_id,
device_id: d.device_id,
name: d.name,
address: d.address,
rssi_current: d.rssi_current,
rssi_ema: d.rssi_ema,
estimated_distance_m: d.estimated_distance_m,
proximity_band: d.proximity_band || 'unknown',
distance_confidence: d.distance_confidence || 0.5,
is_new: d.is_new || !d.in_baseline,
is_randomized_mac: d.is_randomized_mac,
in_baseline: d.in_baseline,
heuristic_flags: d.heuristic_flags || [],
age_seconds: d.age_seconds || 0,
}));
ProximityRadar.updateDevices(deviceList);
// Update zone counts from radar
const counts = ProximityRadar.getZoneCounts();
updateProximityZoneCounts(counts);
}
/**
* Update proximity zone counts display (new system)
*/
function updateProximityZoneCounts(counts) {
const immediateEl = document.getElementById('btZoneImmediate');
const nearEl = document.getElementById('btZoneNear');
const farEl = document.getElementById('btZoneFar');
if (immediateEl) immediateEl.textContent = counts.immediate || 0;
if (nearEl) nearEl.textContent = counts.near || 0;
if (farEl) farEl.textContent = counts.far || 0;
}
/**
* Initialize proximity zones display
*/
@@ -485,6 +622,11 @@ const BluetoothMode = (function() {
};
updateVisualizationPanels();
updateProximityZones();
// Clear radar
if (radarInitialized && typeof ProximityRadar !== 'undefined') {
ProximityRadar.clear();
}
}
function startEventStream() {
@@ -529,6 +671,9 @@ const BluetoothMode = (function() {
updateVisualizationPanels();
updateProximityZones();
// Update new proximity radar
updateRadar();
// Feed to activity timeline
addToTimeline(device);
}

View File

@@ -20,6 +20,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-timeline.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/device-cards.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/proximity-viz.css') }}">
</head>
<body>
@@ -706,29 +707,28 @@
<div class="bt-layout-container" id="btLayoutContainer" style="display: none;">
<!-- Left: Bluetooth Visualizations -->
<div class="wifi-visuals" id="btVisuals">
<!-- Row 1: Proximity Zones + Device Types -->
<div class="wifi-visual-panel">
<h5>Proximity Zones</h5>
<div id="btProximityZones" style="display: flex; flex-direction: column; gap: 6px; padding: 8px 0;">
<div style="display: flex; align-items: center; gap: 8px;">
<div style="width: 10px; height: 10px; border-radius: 50%; background: #22c55e;"></div>
<span style="flex: 1; font-size: 11px; color: #888;">Very Close</span>
<span id="btZoneVeryClose" style="font-size: 14px; font-weight: 600; color: #22c55e;">0</span>
<!-- Row 1: Proximity Radar + Device Types -->
<div class="wifi-visual-panel" style="grid-row: span 2;">
<h5>Proximity Radar</h5>
<div id="btProximityRadar" style="display: flex; justify-content: center; padding: 8px 0;"></div>
<div id="btRadarControls" style="display: flex; gap: 6px; justify-content: center; margin-top: 8px; flex-wrap: wrap;">
<button data-filter="newOnly" class="bt-radar-filter-btn" style="padding: 4px 10px; font-size: 10px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 4px; color: #888; cursor: pointer;">New Only</button>
<button data-filter="strongest" class="bt-radar-filter-btn" style="padding: 4px 10px; font-size: 10px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 4px; color: #888; cursor: pointer;">Strongest</button>
<button data-filter="unapproved" class="bt-radar-filter-btn" style="padding: 4px 10px; font-size: 10px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 4px; color: #888; cursor: pointer;">Unapproved</button>
<button id="btRadarPauseBtn" style="padding: 4px 10px; font-size: 10px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 4px; color: #888; cursor: pointer;">Pause</button>
</div>
<div id="btZoneSummary" style="display: flex; justify-content: center; gap: 16px; margin-top: 12px; font-size: 11px;">
<div style="text-align: center;">
<span id="btZoneImmediate" style="font-size: 18px; font-weight: 600; color: #22c55e;">0</span>
<div style="color: #666;">Immediate</div>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<div style="width: 10px; height: 10px; border-radius: 50%; background: #84cc16;"></div>
<span style="flex: 1; font-size: 11px; color: #888;">Close</span>
<span id="btZoneClose" style="font-size: 14px; font-weight: 600; color: #84cc16;">0</span>
<div style="text-align: center;">
<span id="btZoneNear" style="font-size: 18px; font-weight: 600; color: #eab308;">0</span>
<div style="color: #666;">Near</div>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<div style="width: 10px; height: 10px; border-radius: 50%; background: #eab308;"></div>
<span style="flex: 1; font-size: 11px; color: #888;">Nearby</span>
<span id="btZoneNearby" style="font-size: 14px; font-weight: 600; color: #eab308;">0</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<div style="width: 10px; height: 10px; border-radius: 50%; background: #ef4444;"></div>
<span style="flex: 1; font-size: 11px; color: #888;">Far</span>
<span id="btZoneFar" style="font-size: 14px; font-weight: 600; color: #ef4444;">0</span>
<div style="text-align: center;">
<span id="btZoneFar" style="font-size: 18px; font-weight: 600; color: #ef4444;">0</span>
<div style="color: #666;">Far</div>
</div>
</div>
</div>
@@ -778,7 +778,12 @@
FindMy-compatible devices...</div>
</div>
</div>
<!-- Device Activity Timeline -->
<!-- Signal History Heatmap -->
<div class="wifi-visual-panel" style="grid-column: span 2;">
<h5>Signal History</h5>
<div id="btTimelineHeatmap"></div>
</div>
<!-- Device Activity Timeline (legacy) -->
<div class="wifi-visual-panel" style="grid-column: span 2;">
<div id="bluetoothTimelineContainer"></div>
</div>
@@ -1512,6 +1517,8 @@
<script src="{{ url_for('static', filename='js/components/rssi-sparkline.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/message-card.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/device-card.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/proximity-radar.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/timeline-heatmap.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/bluetooth.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>

View File

@@ -0,0 +1,426 @@
"""
Unit tests for Bluetooth proximity visualization features.
Tests device key stability, EMA smoothing, distance estimation,
band classification, and ring buffer functionality.
"""
import pytest
from datetime import datetime, timedelta
from unittest.mock import patch
from utils.bluetooth.device_key import (
generate_device_key,
is_randomized_mac,
extract_key_type,
)
from utils.bluetooth.distance import (
DistanceEstimator,
ProximityBand,
RSSI_THRESHOLD_IMMEDIATE,
RSSI_THRESHOLD_NEAR,
RSSI_THRESHOLD_FAR,
)
from utils.bluetooth.ring_buffer import RingBuffer
class TestDeviceKey:
"""Tests for stable device key generation."""
def test_identity_address_takes_priority(self):
"""Identity address should always be used when available."""
key = generate_device_key(
address='AA:BB:CC:DD:EE:FF',
address_type='rpa',
identity_address='11:22:33:44:55:66',
name='Test Device',
manufacturer_id=76,
)
assert key == 'id:11:22:33:44:55:66'
def test_public_mac_used_directly(self):
"""Public MAC addresses should be used directly."""
key = generate_device_key(
address='AA:BB:CC:DD:EE:FF',
address_type='public',
)
assert key == 'mac:AA:BB:CC:DD:EE:FF'
def test_static_random_mac_used_directly(self):
"""Random static addresses should be used directly."""
key = generate_device_key(
address='CA:BB:CC:DD:EE:FF',
address_type='random_static',
)
assert key == 'mac:CA:BB:CC:DD:EE:FF'
def test_random_address_fingerprint_with_name(self):
"""Random addresses should generate fingerprint from name."""
key = generate_device_key(
address='AA:BB:CC:DD:EE:FF',
address_type='rpa',
name='AirPods Pro',
)
assert key.startswith('fp:')
assert len(key) == 19 # 'fp:' + 16 hex chars
def test_random_address_fingerprint_stability(self):
"""Same name/mfr/services should produce same fingerprint key."""
key1 = generate_device_key(
address='AA:BB:CC:DD:EE:FF',
address_type='rpa',
name='AirPods Pro',
manufacturer_id=76,
)
key2 = generate_device_key(
address='11:22:33:44:55:66', # Different address
address_type='nrpa',
name='AirPods Pro',
manufacturer_id=76,
)
assert key1 == key2
def test_different_names_produce_different_keys(self):
"""Different names should produce different fingerprint keys."""
key1 = generate_device_key(
address='AA:BB:CC:DD:EE:FF',
address_type='rpa',
name='AirPods Pro',
)
key2 = generate_device_key(
address='AA:BB:CC:DD:EE:FF',
address_type='rpa',
name='AirPods Max',
)
assert key1 != key2
def test_random_address_fallback_to_mac(self):
"""Random addresses without fingerprint data fall back to MAC."""
key = generate_device_key(
address='AA:BB:CC:DD:EE:FF',
address_type='rpa',
# No name, manufacturer, or services
)
assert key == 'mac:AA:BB:CC:DD:EE:FF'
def test_is_randomized_mac_public(self):
"""Public addresses are not randomized."""
assert is_randomized_mac('public') is False
def test_is_randomized_mac_random_static(self):
"""Random static addresses are not randomized."""
assert is_randomized_mac('random_static') is False
def test_is_randomized_mac_rpa(self):
"""RPA addresses are randomized."""
assert is_randomized_mac('rpa') is True
def test_is_randomized_mac_nrpa(self):
"""NRPA addresses are randomized."""
assert is_randomized_mac('nrpa') is True
def test_extract_key_type_id(self):
"""Extract type from identity key."""
assert extract_key_type('id:11:22:33:44:55:66') == 'id'
def test_extract_key_type_mac(self):
"""Extract type from MAC key."""
assert extract_key_type('mac:AA:BB:CC:DD:EE:FF') == 'mac'
def test_extract_key_type_fingerprint(self):
"""Extract type from fingerprint key."""
assert extract_key_type('fp:abcd1234efgh5678') == 'fp'
class TestDistanceEstimator:
"""Tests for distance estimation and EMA smoothing."""
@pytest.fixture
def estimator(self):
"""Create a distance estimator instance."""
return DistanceEstimator()
def test_ema_first_value_initializes(self, estimator):
"""First EMA value should equal the input."""
ema = estimator.apply_ema_smoothing(current=-50, prev_ema=None)
assert ema == -50.0
def test_ema_subsequent_values_weighted(self, estimator):
"""Subsequent EMA values should be weighted correctly."""
# Default alpha is 0.3
# new_ema = 0.3 * current + 0.7 * prev_ema
ema = estimator.apply_ema_smoothing(current=-60, prev_ema=-50.0)
expected = 0.3 * (-60) + 0.7 * (-50) # -18 + -35 = -53
assert ema == expected
def test_ema_custom_alpha(self, estimator):
"""Custom alpha should be applied correctly."""
ema = estimator.apply_ema_smoothing(current=-60, prev_ema=-50.0, alpha=0.5)
expected = 0.5 * (-60) + 0.5 * (-50) # -30 + -25 = -55
assert ema == expected
def test_distance_with_tx_power_path_loss(self, estimator):
"""Distance should be calculated using path-loss formula with TX power."""
# Formula: d = 10^((tx_power - rssi) / (10 * n)), n=2.5
distance, confidence = estimator.estimate_distance(rssi=-69, tx_power=-59)
# ((-59) - (-69)) / 25 = 10/25 = 0.4
# 10^0.4 = ~2.51 meters
assert 2.0 < distance < 3.0
assert confidence >= 0.5 # Higher confidence with TX power
def test_distance_without_tx_power_band_based(self, estimator):
"""Distance should use band estimation without TX power."""
distance, confidence = estimator.estimate_distance(rssi=-50, tx_power=None)
assert distance is not None
assert confidence < 0.5 # Lower confidence without TX power
def test_distance_null_rssi(self, estimator):
"""Null RSSI should return None distance."""
distance, confidence = estimator.estimate_distance(rssi=None)
assert distance is None
assert confidence == 0.0
def test_band_classification_immediate(self, estimator):
"""Strong RSSI should classify as immediate."""
band = estimator.classify_proximity_band(rssi_ema=-35)
assert band == ProximityBand.IMMEDIATE
def test_band_classification_near(self, estimator):
"""Medium RSSI should classify as near."""
band = estimator.classify_proximity_band(rssi_ema=-50)
assert band == ProximityBand.NEAR
def test_band_classification_far(self, estimator):
"""Weak RSSI should classify as far."""
band = estimator.classify_proximity_band(rssi_ema=-70)
assert band == ProximityBand.FAR
def test_band_classification_unknown(self, estimator):
"""Very weak or null RSSI should classify as unknown."""
band = estimator.classify_proximity_band(rssi_ema=-80)
assert band == ProximityBand.UNKNOWN
band = estimator.classify_proximity_band(rssi_ema=None)
assert band == ProximityBand.UNKNOWN
def test_band_classification_by_distance(self, estimator):
"""Distance-based classification should work."""
assert estimator.classify_proximity_band(distance_m=0.5) == ProximityBand.IMMEDIATE
assert estimator.classify_proximity_band(distance_m=2.0) == ProximityBand.NEAR
assert estimator.classify_proximity_band(distance_m=5.0) == ProximityBand.FAR
assert estimator.classify_proximity_band(distance_m=15.0) == ProximityBand.UNKNOWN
def test_confidence_higher_with_tx_power(self, estimator):
"""Confidence should be higher with TX power than without."""
_, conf_with_tx = estimator.estimate_distance(rssi=-60, tx_power=-59)
_, conf_without_tx = estimator.estimate_distance(rssi=-60, tx_power=None)
assert conf_with_tx > conf_without_tx
def test_confidence_lower_with_high_variance(self, estimator):
"""High variance should reduce confidence."""
_, conf_low_var = estimator.estimate_distance(rssi=-60, tx_power=-59, variance=10)
_, conf_high_var = estimator.estimate_distance(rssi=-60, tx_power=-59, variance=150)
assert conf_low_var > conf_high_var
def test_get_rssi_60s_window(self, estimator):
"""60-second window should return correct min/max."""
now = datetime.now()
samples = [
(now - timedelta(seconds=30), -50),
(now - timedelta(seconds=20), -60),
(now - timedelta(seconds=10), -55),
(now - timedelta(seconds=90), -40), # Outside window
]
min_rssi, max_rssi = estimator.get_rssi_60s_window(samples, window_seconds=60)
assert min_rssi == -60
assert max_rssi == -50
def test_get_rssi_60s_window_empty(self, estimator):
"""Empty samples should return None."""
min_rssi, max_rssi = estimator.get_rssi_60s_window([])
assert min_rssi is None
assert max_rssi is None
class TestRingBuffer:
"""Tests for ring buffer time-windowed storage."""
@pytest.fixture
def buffer(self):
"""Create a ring buffer instance."""
return RingBuffer(
retention_minutes=30,
min_interval_seconds=2.0,
max_observations_per_device=100,
)
def test_ingest_new_device(self, buffer):
"""Ingesting a new device should succeed."""
now = datetime.now()
result = buffer.ingest('device:1', rssi=-50, timestamp=now)
assert result is True
assert buffer.get_device_count() == 1
assert buffer.get_observation_count('device:1') == 1
def test_ingest_rate_limited(self, buffer):
"""Ingestion should be rate-limited to min_interval."""
now = datetime.now()
buffer.ingest('device:1', rssi=-50, timestamp=now)
# Try to ingest again within rate limit (1 second later)
result = buffer.ingest('device:1', rssi=-55, timestamp=now + timedelta(seconds=1))
assert result is False
assert buffer.get_observation_count('device:1') == 1
def test_ingest_after_interval(self, buffer):
"""Ingestion should succeed after min_interval."""
now = datetime.now()
buffer.ingest('device:1', rssi=-50, timestamp=now)
# Ingest after rate limit passes (3 seconds later)
result = buffer.ingest('device:1', rssi=-55, timestamp=now + timedelta(seconds=3))
assert result is True
assert buffer.get_observation_count('device:1') == 2
def test_prune_old_observations(self, buffer):
"""Old observations should be pruned."""
now = datetime.now()
old_time = now - timedelta(minutes=45) # Older than retention
buffer.ingest('device:1', rssi=-50, timestamp=old_time)
buffer.ingest('device:2', rssi=-60, timestamp=now)
removed = buffer.prune_old()
assert removed == 1
assert buffer.get_device_count() == 1
def test_get_timeseries(self, buffer):
"""Timeseries should return downsampled data."""
now = datetime.now()
# Add observations
for i in range(10):
ts = now - timedelta(seconds=i * 5)
buffer.ingest('device:1', rssi=-50 - i, timestamp=ts)
timeseries = buffer.get_timeseries('device:1', window_minutes=5, downsample_seconds=10)
assert isinstance(timeseries, list)
assert len(timeseries) > 0
for point in timeseries:
assert 'timestamp' in point
assert 'rssi' in point
def test_get_timeseries_empty_device(self, buffer):
"""Unknown device should return empty timeseries."""
timeseries = buffer.get_timeseries('unknown:device')
assert timeseries == []
def test_get_all_timeseries_sorted_by_recency(self, buffer):
"""All timeseries should be sorted by recency."""
now = datetime.now()
buffer.ingest('device:old', rssi=-50, timestamp=now - timedelta(minutes=5))
buffer.ingest('device:new', rssi=-60, timestamp=now)
all_ts = buffer.get_all_timeseries(sort_by='recency')
keys = list(all_ts.keys())
assert keys[0] == 'device:new' # Most recent first
def test_get_all_timeseries_sorted_by_strength(self, buffer):
"""All timeseries should be sortable by signal strength."""
now = datetime.now()
buffer.ingest('device:weak', rssi=-80, timestamp=now)
buffer.ingest('device:strong', rssi=-40, timestamp=now + timedelta(seconds=3))
all_ts = buffer.get_all_timeseries(sort_by='strength')
keys = list(all_ts.keys())
assert keys[0] == 'device:strong' # Strongest first
def test_get_all_timeseries_top_n_limit(self, buffer):
"""Top N should limit returned devices."""
now = datetime.now()
for i in range(10):
buffer.ingest(f'device:{i}', rssi=-50, timestamp=now + timedelta(seconds=i * 3))
all_ts = buffer.get_all_timeseries(top_n=5)
assert len(all_ts) == 5
def test_clear(self, buffer):
"""Clear should remove all observations."""
now = datetime.now()
buffer.ingest('device:1', rssi=-50, timestamp=now)
buffer.ingest('device:2', rssi=-60, timestamp=now)
buffer.clear()
assert buffer.get_device_count() == 0
def test_downsampling_bucket_average(self, buffer):
"""Downsampling should average RSSI in each bucket."""
now = datetime.now()
# Add multiple observations in same 10s bucket
buffer._observations['device:1'] = [
(now, -50),
(now + timedelta(seconds=1), -60),
(now + timedelta(seconds=2), -55),
]
buffer._last_ingested['device:1'] = now + timedelta(seconds=2)
timeseries = buffer.get_timeseries('device:1', window_minutes=5, downsample_seconds=10)
assert len(timeseries) == 1
# Average of -50, -60, -55 = -55
assert timeseries[0]['rssi'] == -55.0
def test_get_device_stats(self, buffer):
"""Device stats should return correct values."""
now = datetime.now()
buffer._observations['device:1'] = [
(now - timedelta(seconds=10), -50),
(now - timedelta(seconds=5), -60),
(now, -55),
]
stats = buffer.get_device_stats('device:1')
assert stats is not None
assert stats['observation_count'] == 3
assert stats['rssi_min'] == -60
assert stats['rssi_max'] == -50
assert stats['rssi_avg'] == -55.0
def test_get_device_stats_unknown_device(self, buffer):
"""Unknown device should return None."""
stats = buffer.get_device_stats('unknown:device')
assert stats is None
class TestProximityBand:
"""Tests for ProximityBand enum."""
def test_proximity_band_str(self):
"""ProximityBand should convert to string correctly."""
assert str(ProximityBand.IMMEDIATE) == 'immediate'
assert str(ProximityBand.NEAR) == 'near'
assert str(ProximityBand.FAR) == 'far'
assert str(ProximityBand.UNKNOWN) == 'unknown'
def test_proximity_band_values(self):
"""ProximityBand values should match expected strings."""
assert ProximityBand.IMMEDIATE.value == 'immediate'
assert ProximityBand.NEAR.value == 'near'
assert ProximityBand.FAR.value == 'far'
assert ProximityBand.UNKNOWN.value == 'unknown'
class TestRssiThresholds:
"""Tests for RSSI threshold constants."""
def test_threshold_order(self):
"""Thresholds should be in descending order."""
assert RSSI_THRESHOLD_IMMEDIATE > RSSI_THRESHOLD_NEAR
assert RSSI_THRESHOLD_NEAR > RSSI_THRESHOLD_FAR
def test_threshold_values(self):
"""Threshold values should match expected dBm levels."""
assert RSSI_THRESHOLD_IMMEDIATE == -40
assert RSSI_THRESHOLD_NEAR == -55
assert RSSI_THRESHOLD_FAR == -75

View File

@@ -8,12 +8,17 @@ device aggregation, RSSI statistics, and observable heuristics.
from .aggregator import DeviceAggregator
from .capability_check import check_capabilities, quick_adapter_check
from .constants import (
# Range bands
# Range bands (legacy)
RANGE_VERY_CLOSE,
RANGE_CLOSE,
RANGE_NEARBY,
RANGE_FAR,
RANGE_UNKNOWN,
# Proximity bands (new)
PROXIMITY_IMMEDIATE,
PROXIMITY_NEAR,
PROXIMITY_FAR,
PROXIMITY_UNKNOWN,
# Protocols
PROTOCOL_BLE,
PROTOCOL_CLASSIC,
@@ -25,8 +30,11 @@ from .constants import (
ADDRESS_TYPE_RPA,
ADDRESS_TYPE_NRPA,
)
from .device_key import generate_device_key, is_randomized_mac, extract_key_type
from .distance import DistanceEstimator, ProximityBand, get_distance_estimator
from .heuristics import HeuristicsEngine, evaluate_device_heuristics, evaluate_all_devices
from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities
from .ring_buffer import RingBuffer, get_ring_buffer, reset_ring_buffer
from .scanner import BluetoothScanner, get_bluetooth_scanner, reset_bluetooth_scanner
__all__ = [
@@ -44,6 +52,21 @@ __all__ = [
# Aggregator
'DeviceAggregator',
# Device key generation
'generate_device_key',
'is_randomized_mac',
'extract_key_type',
# Distance estimation
'DistanceEstimator',
'ProximityBand',
'get_distance_estimator',
# Ring buffer
'RingBuffer',
'get_ring_buffer',
'reset_ring_buffer',
# Heuristics
'HeuristicsEngine',
'evaluate_device_heuristics',
@@ -53,15 +76,25 @@ __all__ = [
'check_capabilities',
'quick_adapter_check',
# Constants
# Constants - Range bands (legacy)
'RANGE_VERY_CLOSE',
'RANGE_CLOSE',
'RANGE_NEARBY',
'RANGE_FAR',
'RANGE_UNKNOWN',
# Constants - Proximity bands (new)
'PROXIMITY_IMMEDIATE',
'PROXIMITY_NEAR',
'PROXIMITY_FAR',
'PROXIMITY_UNKNOWN',
# Constants - Protocols
'PROTOCOL_BLE',
'PROTOCOL_CLASSIC',
'PROTOCOL_AUTO',
# Constants - Address types
'ADDRESS_TYPE_PUBLIC',
'ADDRESS_TYPE_RANDOM',
'ADDRESS_TYPE_RANDOM_STATIC',

View File

@@ -36,6 +36,9 @@ from .constants import (
PROTOCOL_CLASSIC,
)
from .models import BTObservation, BTDeviceAggregate
from .device_key import generate_device_key, is_randomized_mac
from .distance import DistanceEstimator, get_distance_estimator
from .ring_buffer import RingBuffer, get_ring_buffer
class DeviceAggregator:
@@ -53,6 +56,13 @@ class DeviceAggregator:
self._baseline_device_ids: set[str] = set()
self._baseline_set_time: Optional[datetime] = None
# Proximity estimation components
self._distance_estimator = get_distance_estimator()
self._ring_buffer = get_ring_buffer()
# Device key mapping (device_id -> device_key)
self._device_keys: dict[str, str] = {}
def ingest(self, observation: BTObservation) -> BTDeviceAggregate:
"""
Ingest a new observation and update the device aggregate.
@@ -119,6 +129,43 @@ class DeviceAggregator:
device.in_baseline = device_id in self._baseline_device_ids
device.is_new = not device.in_baseline and self._baseline_set_time is not None
# Generate stable device key
device_key = generate_device_key(
address=observation.address,
address_type=observation.address_type,
name=device.name,
manufacturer_id=device.manufacturer_id,
service_uuids=device.service_uuids if device.service_uuids else None,
)
device.device_key = device_key
self._device_keys[device_id] = device_key
# Check if randomized MAC
device.is_randomized_mac = is_randomized_mac(observation.address_type)
# Apply EMA smoothing to RSSI
if observation.rssi is not None:
device.rssi_ema = self._distance_estimator.apply_ema_smoothing(
current=observation.rssi,
prev_ema=device.rssi_ema,
)
# Get 60-second min/max
device.rssi_60s_min, device.rssi_60s_max = self._distance_estimator.get_rssi_60s_window(
device.rssi_samples,
window_seconds=60,
)
# Store in ring buffer for heatmap
self._ring_buffer.ingest(
device_key=device_key,
rssi=observation.rssi,
timestamp=observation.timestamp,
)
# Estimate distance and proximity band
self._update_proximity(device)
return device
def _infer_protocol(self, observation: BTObservation) -> str:
@@ -219,6 +266,31 @@ class DeviceAggregator:
device.range_band = RANGE_UNKNOWN
device.range_confidence = confidence * 0.5 # Reduced confidence for unknown
def _update_proximity(self, device: BTDeviceAggregate) -> None:
"""Update proximity estimation for a device."""
if device.rssi_ema is None:
device.proximity_band = 'unknown'
device.estimated_distance_m = None
device.distance_confidence = 0.0
return
# Estimate distance
distance, confidence = self._distance_estimator.estimate_distance(
rssi=device.rssi_ema,
tx_power=device.tx_power,
variance=device.rssi_variance,
)
device.estimated_distance_m = distance
device.distance_confidence = confidence
# Classify proximity band
band = self._distance_estimator.classify_proximity_band(
distance_m=distance,
rssi_ema=device.rssi_ema,
)
device.proximity_band = str(band)
def _merge_device_info(self, device: BTDeviceAggregate, observation: BTObservation) -> None:
"""Merge observation data into device aggregate (prefer non-None values)."""
# Name (prefer longer names as they're usually more complete)
@@ -345,3 +417,107 @@ class DeviceAggregator:
def has_baseline(self) -> bool:
"""Whether a baseline is set."""
return self._baseline_set_time is not None
@property
def ring_buffer(self) -> RingBuffer:
"""Access the ring buffer for timeseries data."""
return self._ring_buffer
def get_device_by_key(self, device_key: str) -> Optional[BTDeviceAggregate]:
"""Get a device by its stable device key."""
with self._lock:
# Find device_id from device_key
for device_id, key in self._device_keys.items():
if key == device_key:
return self._devices.get(device_id)
return None
def get_timeseries(
self,
device_key: str,
window_minutes: int = 30,
downsample_seconds: int = 10,
) -> list[dict]:
"""
Get timeseries data for a device.
Args:
device_key: Stable device identifier.
window_minutes: Time window in minutes.
downsample_seconds: Bucket size for downsampling.
Returns:
List of {timestamp, rssi} dicts.
"""
return self._ring_buffer.get_timeseries(
device_key=device_key,
window_minutes=window_minutes,
downsample_seconds=downsample_seconds,
)
def get_heatmap_data(
self,
top_n: int = 20,
window_minutes: int = 10,
bucket_seconds: int = 10,
sort_by: str = 'recency',
) -> dict:
"""
Get heatmap data for visualization.
Args:
top_n: Number of devices to include.
window_minutes: Time window.
bucket_seconds: Bucket size for downsampling.
sort_by: Sort method ('recency', 'strength', 'activity').
Returns:
Dict with device timeseries and metadata.
"""
# Get timeseries data from ring buffer
timeseries = self._ring_buffer.get_all_timeseries(
window_minutes=window_minutes,
downsample_seconds=bucket_seconds,
top_n=top_n,
sort_by=sort_by,
)
# Enrich with device metadata
result = {
'window_minutes': window_minutes,
'bucket_seconds': bucket_seconds,
'devices': [],
}
with self._lock:
for device_key, ts_data in timeseries.items():
device = self.get_device_by_key(device_key)
device_info = {
'device_key': device_key,
'timeseries': ts_data,
}
if device:
device_info.update({
'name': device.name,
'address': device.address,
'rssi_current': device.rssi_current,
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
'proximity_band': device.proximity_band,
})
else:
device_info.update({
'name': None,
'address': None,
'rssi_current': None,
'rssi_ema': None,
'proximity_band': 'unknown',
})
result['devices'].append(device_info)
return result
def prune_ring_buffer(self) -> int:
"""Prune old observations from ring buffer."""
return self._ring_buffer.prune_old()

View File

@@ -120,6 +120,63 @@ RANGE_NEARBY = 'nearby'
RANGE_FAR = 'far'
RANGE_UNKNOWN = 'unknown'
# =============================================================================
# PROXIMITY BANDS (new visualization system)
# =============================================================================
PROXIMITY_IMMEDIATE = 'immediate' # < 1m
PROXIMITY_NEAR = 'near' # 1-3m
PROXIMITY_FAR = 'far' # 3-10m
PROXIMITY_UNKNOWN = 'unknown'
# RSSI thresholds for proximity band classification (dBm)
PROXIMITY_RSSI_IMMEDIATE = -40 # >= -40 dBm -> immediate
PROXIMITY_RSSI_NEAR = -55 # >= -55 dBm -> near
PROXIMITY_RSSI_FAR = -75 # >= -75 dBm -> far
# =============================================================================
# DISTANCE ESTIMATION SETTINGS
# =============================================================================
# Path-loss exponent for indoor environments (typical range: 2-4)
DISTANCE_PATH_LOSS_EXPONENT = 2.5
# Reference RSSI at 1 meter (typical BLE value)
DISTANCE_RSSI_AT_1M = -59
# EMA smoothing alpha (higher = more responsive, lower = smoother)
DISTANCE_EMA_ALPHA = 0.3
# Variance thresholds for confidence scoring (dBm^2)
DISTANCE_LOW_VARIANCE = 25.0 # High confidence
DISTANCE_HIGH_VARIANCE = 100.0 # Low confidence
# =============================================================================
# RING BUFFER SETTINGS
# =============================================================================
# Observation retention period (minutes)
RING_BUFFER_RETENTION_MINUTES = 30
# Minimum interval between observations per device (seconds)
RING_BUFFER_MIN_INTERVAL_SECONDS = 2.0
# Maximum observations stored per device
RING_BUFFER_MAX_OBSERVATIONS = 1000
# =============================================================================
# HEATMAP SETTINGS
# =============================================================================
# Default time window for heatmap (minutes)
HEATMAP_DEFAULT_WINDOW_MINUTES = 10
# Default bucket size for downsampling (seconds)
HEATMAP_DEFAULT_BUCKET_SECONDS = 10
# Maximum devices to show in heatmap
HEATMAP_MAX_DEVICES = 50
# =============================================================================
# COMMON MANUFACTURER IDS (OUI -> Name mapping for common vendors)
# =============================================================================

View File

@@ -0,0 +1,120 @@
"""
Stable device key generation for Bluetooth devices.
Generates consistent identifiers for devices even when MAC addresses rotate.
"""
from __future__ import annotations
import hashlib
from typing import Optional
from .constants import (
ADDRESS_TYPE_PUBLIC,
ADDRESS_TYPE_RANDOM_STATIC,
)
def generate_device_key(
address: str,
address_type: str,
identity_address: Optional[str] = None,
name: Optional[str] = None,
manufacturer_id: Optional[int] = None,
service_uuids: Optional[list[str]] = None,
) -> str:
"""
Generate a stable device key for identifying a Bluetooth device.
Priority order:
1. identity_address -> "id:{address}" (resolved from RPA via IRK)
2. public/static MAC -> "mac:{address}" (stable addresses)
3. Random address -> "fp:{hash}" (fingerprint from device characteristics)
Args:
address: The Bluetooth address (MAC).
address_type: Type of address (public, random, random_static, rpa, nrpa).
identity_address: Resolved identity address if available.
name: Device name if available.
manufacturer_id: Manufacturer ID if available.
service_uuids: List of service UUIDs if available.
Returns:
A stable device key string.
"""
# Priority 1: Use identity address if available (resolved RPA)
if identity_address:
return f"id:{identity_address.upper()}"
# Priority 2: Use public or random_static addresses directly
if address_type in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC):
return f"mac:{address.upper()}"
# Priority 3: Generate fingerprint hash for random addresses
return _generate_fingerprint_key(address, name, manufacturer_id, service_uuids)
def _generate_fingerprint_key(
address: str,
name: Optional[str],
manufacturer_id: Optional[int],
service_uuids: Optional[list[str]],
) -> str:
"""
Generate a fingerprint-based key for devices with random addresses.
Uses device characteristics to create a stable identifier when the
MAC address rotates.
"""
# Build fingerprint components
components = []
# Include name if available (most stable identifier for random MACs)
if name:
components.append(f"name:{name}")
# Include manufacturer ID
if manufacturer_id is not None:
components.append(f"mfr:{manufacturer_id}")
# Include sorted service UUIDs
if service_uuids:
sorted_uuids = sorted(set(service_uuids))
components.append(f"svc:{','.join(sorted_uuids)}")
# If we have enough characteristics, generate a hash
if components:
fingerprint_str = "|".join(components)
hash_digest = hashlib.sha256(fingerprint_str.encode()).hexdigest()[:16]
return f"fp:{hash_digest}"
# Fallback: use address directly (least stable for random MACs)
return f"mac:{address.upper()}"
def is_randomized_mac(address_type: str) -> bool:
"""
Check if an address type indicates a randomized MAC.
Args:
address_type: The address type string.
Returns:
True if the address is randomized, False otherwise.
"""
return address_type not in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC)
def extract_key_type(device_key: str) -> str:
"""
Extract the key type prefix from a device key.
Args:
device_key: The device key string.
Returns:
The key type ('id', 'mac', or 'fp').
"""
if ':' in device_key:
return device_key.split(':', 1)[0]
return 'unknown'

274
utils/bluetooth/distance.py Normal file
View File

@@ -0,0 +1,274 @@
"""
Distance estimation for Bluetooth devices.
Provides path-loss based distance calculation, band classification,
and EMA smoothing for RSSI values.
"""
from __future__ import annotations
from enum import Enum
from typing import Optional
class ProximityBand(str, Enum):
"""Proximity band classifications."""
IMMEDIATE = 'immediate' # < 1m
NEAR = 'near' # 1-3m
FAR = 'far' # 3-10m
UNKNOWN = 'unknown' # Cannot determine
def __str__(self) -> str:
return self.value
# Default path-loss exponent for indoor environments
DEFAULT_PATH_LOSS_EXPONENT = 2.5
# RSSI thresholds for band classification (dBm)
RSSI_THRESHOLD_IMMEDIATE = -40 # >= -40 dBm
RSSI_THRESHOLD_NEAR = -55 # >= -55 dBm
RSSI_THRESHOLD_FAR = -75 # >= -75 dBm
# Default reference RSSI at 1 meter (typical BLE)
DEFAULT_RSSI_AT_1M = -59
# Default EMA alpha
DEFAULT_EMA_ALPHA = 0.3
# Variance thresholds for confidence scoring
LOW_VARIANCE_THRESHOLD = 25.0 # dBm^2
HIGH_VARIANCE_THRESHOLD = 100.0 # dBm^2
class DistanceEstimator:
"""
Estimates distance to Bluetooth devices based on RSSI.
Uses path-loss formula when TX power is available, falls back to
band-based estimation otherwise.
"""
def __init__(
self,
path_loss_exponent: float = DEFAULT_PATH_LOSS_EXPONENT,
rssi_at_1m: int = DEFAULT_RSSI_AT_1M,
ema_alpha: float = DEFAULT_EMA_ALPHA,
):
"""
Initialize the distance estimator.
Args:
path_loss_exponent: Path-loss exponent (n), typically 2-4.
rssi_at_1m: Reference RSSI at 1 meter.
ema_alpha: Smoothing factor for EMA (0-1).
"""
self.path_loss_exponent = path_loss_exponent
self.rssi_at_1m = rssi_at_1m
self.ema_alpha = ema_alpha
def estimate_distance(
self,
rssi: float,
tx_power: Optional[int] = None,
variance: Optional[float] = None,
) -> tuple[Optional[float], float]:
"""
Estimate distance to a device based on RSSI.
Args:
rssi: Current RSSI value (dBm).
tx_power: Transmitted power at 1m (dBm), if advertised.
variance: RSSI variance for confidence scoring.
Returns:
Tuple of (distance_m, confidence) where distance_m may be None
if estimation fails, and confidence is 0.0-1.0.
"""
if rssi is None or rssi > 0:
return None, 0.0
# Calculate base confidence from variance
base_confidence = self._calculate_variance_confidence(variance)
if tx_power is not None:
# Use path-loss formula: d = 10^((tx_power - rssi) / (10 * n))
distance = self._path_loss_distance(rssi, tx_power)
# Higher confidence with TX power
confidence = min(1.0, base_confidence * 1.2) if base_confidence > 0 else 0.6
return distance, confidence
else:
# Fall back to band-based estimation
distance = self._estimate_from_bands(rssi)
# Lower confidence without TX power
confidence = base_confidence * 0.6 if base_confidence > 0 else 0.3
return distance, confidence
def _path_loss_distance(self, rssi: float, tx_power: int) -> float:
"""
Calculate distance using path-loss formula.
Formula: d = 10^((tx_power - rssi) / (10 * n))
Args:
rssi: Current RSSI value.
tx_power: Transmitted power at 1m.
Returns:
Estimated distance in meters.
"""
exponent = (tx_power - rssi) / (10 * self.path_loss_exponent)
distance = 10 ** exponent
# Clamp to reasonable range
return max(0.1, min(100.0, distance))
def _estimate_from_bands(self, rssi: float) -> float:
"""
Estimate distance based on RSSI bands when TX power unavailable.
Uses calibrated thresholds to provide rough distance estimate.
Args:
rssi: Current RSSI value.
Returns:
Estimated distance in meters (midpoint of band).
"""
if rssi >= RSSI_THRESHOLD_IMMEDIATE:
return 0.5 # Immediate: ~0.5m
elif rssi >= RSSI_THRESHOLD_NEAR:
return 2.0 # Near: ~2m
elif rssi >= RSSI_THRESHOLD_FAR:
return 6.0 # Far: ~6m
else:
return 15.0 # Very far: ~15m
def _calculate_variance_confidence(self, variance: Optional[float]) -> float:
"""
Calculate confidence based on RSSI variance.
Lower variance = higher confidence.
Args:
variance: RSSI variance value.
Returns:
Confidence factor (0.0-1.0).
"""
if variance is None:
return 0.5 # Unknown variance
if variance <= LOW_VARIANCE_THRESHOLD:
return 0.9 # High confidence - stable signal
elif variance <= HIGH_VARIANCE_THRESHOLD:
# Linear interpolation between thresholds
ratio = (variance - LOW_VARIANCE_THRESHOLD) / (HIGH_VARIANCE_THRESHOLD - LOW_VARIANCE_THRESHOLD)
return 0.9 - (ratio * 0.5) # 0.9 to 0.4
else:
return 0.3 # Low confidence - unstable signal
def classify_proximity_band(
self,
distance_m: Optional[float] = None,
rssi_ema: Optional[float] = None,
) -> ProximityBand:
"""
Classify device into a proximity band.
Uses distance if available, falls back to RSSI-based classification.
Args:
distance_m: Estimated distance in meters.
rssi_ema: Smoothed RSSI value.
Returns:
ProximityBand classification.
"""
# Prefer distance-based classification
if distance_m is not None:
if distance_m < 1.0:
return ProximityBand.IMMEDIATE
elif distance_m < 3.0:
return ProximityBand.NEAR
elif distance_m < 10.0:
return ProximityBand.FAR
else:
return ProximityBand.UNKNOWN
# Fall back to RSSI-based classification
if rssi_ema is not None:
if rssi_ema >= RSSI_THRESHOLD_IMMEDIATE:
return ProximityBand.IMMEDIATE
elif rssi_ema >= RSSI_THRESHOLD_NEAR:
return ProximityBand.NEAR
elif rssi_ema >= RSSI_THRESHOLD_FAR:
return ProximityBand.FAR
return ProximityBand.UNKNOWN
def apply_ema_smoothing(
self,
current: int,
prev_ema: Optional[float] = None,
alpha: Optional[float] = None,
) -> float:
"""
Apply Exponential Moving Average smoothing to RSSI.
Formula: new_ema = alpha * current + (1-alpha) * prev_ema
Args:
current: Current RSSI value.
prev_ema: Previous EMA value (None for first value).
alpha: Smoothing factor (0-1), uses instance default if None.
Returns:
New EMA value.
"""
if alpha is None:
alpha = self.ema_alpha
if prev_ema is None:
return float(current)
return alpha * current + (1 - alpha) * prev_ema
def get_rssi_60s_window(
self,
rssi_samples: list[tuple],
window_seconds: int = 60,
) -> tuple[Optional[int], Optional[int]]:
"""
Get min/max RSSI from the last N seconds.
Args:
rssi_samples: List of (timestamp, rssi) tuples.
window_seconds: Window size in seconds.
Returns:
Tuple of (min_rssi, max_rssi) or (None, None) if no samples.
"""
from datetime import datetime, timedelta
if not rssi_samples:
return None, None
cutoff = datetime.now() - timedelta(seconds=window_seconds)
recent_rssi = [rssi for ts, rssi in rssi_samples if ts >= cutoff]
if not recent_rssi:
return None, None
return min(recent_rssi), max(recent_rssi)
# Module-level instance for convenience
_default_estimator: Optional[DistanceEstimator] = None
def get_distance_estimator() -> DistanceEstimator:
"""Get or create the default distance estimator instance."""
global _default_estimator
if _default_estimator is None:
_default_estimator = DistanceEstimator()
return _default_estimator

View File

@@ -11,8 +11,13 @@ from typing import Optional
from .constants import (
MANUFACTURER_NAMES,
ADDRESS_TYPE_PUBLIC,
ADDRESS_TYPE_RANDOM,
ADDRESS_TYPE_RANDOM_STATIC,
ADDRESS_TYPE_RPA,
ADDRESS_TYPE_NRPA,
RANGE_UNKNOWN,
PROTOCOL_BLE,
PROXIMITY_UNKNOWN,
)
@@ -100,10 +105,21 @@ class BTDeviceAggregate:
rssi_variance: Optional[float] = None
rssi_confidence: float = 0.0 # 0.0-1.0
# Range band (very_close/close/nearby/far/unknown)
# Range band (very_close/close/nearby/far/unknown) - legacy
range_band: str = RANGE_UNKNOWN
range_confidence: float = 0.0
# Proximity band (new system: immediate/near/far/unknown)
device_key: Optional[str] = None
proximity_band: str = PROXIMITY_UNKNOWN
estimated_distance_m: Optional[float] = None
distance_confidence: float = 0.0
rssi_ema: Optional[float] = None
rssi_60s_min: Optional[int] = None
rssi_60s_max: Optional[int] = None
is_randomized_mac: bool = False
threat_tags: list[str] = field(default_factory=list)
# Device info (merged from observations)
name: Optional[str] = None
manufacturer_id: Optional[int] = None
@@ -193,10 +209,21 @@ class BTDeviceAggregate:
'rssi_confidence': round(self.rssi_confidence, 2),
'rssi_history': self.get_rssi_history(),
# Range
# Range (legacy)
'range_band': self.range_band,
'range_confidence': round(self.range_confidence, 2),
# Proximity (new system)
'device_key': self.device_key,
'proximity_band': self.proximity_band,
'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
'distance_confidence': round(self.distance_confidence, 2),
'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None,
'rssi_60s_min': self.rssi_60s_min,
'rssi_60s_max': self.rssi_60s_max,
'is_randomized_mac': self.is_randomized_mac,
'threat_tags': self.threat_tags,
# Device info
'name': self.name,
'manufacturer_id': self.manufacturer_id,
@@ -231,6 +258,7 @@ class BTDeviceAggregate:
"""Compact dictionary for list views."""
return {
'device_id': self.device_id,
'device_key': self.device_key,
'address': self.address,
'address_type': self.address_type,
'protocol': self.protocol,
@@ -238,7 +266,12 @@ class BTDeviceAggregate:
'manufacturer_name': self.manufacturer_name,
'rssi_current': self.rssi_current,
'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None,
'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None,
'range_band': self.range_band,
'proximity_band': self.proximity_band,
'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
'distance_confidence': round(self.distance_confidence, 2),
'is_randomized_mac': self.is_randomized_mac,
'last_seen': self.last_seen.isoformat(),
'age_seconds': self.age_seconds,
'seen_count': self.seen_count,

View File

@@ -0,0 +1,335 @@
"""
Ring buffer for time-windowed Bluetooth observation storage.
Provides efficient storage of RSSI observations with rate limiting,
automatic pruning, and downsampling for visualization.
"""
from __future__ import annotations
import threading
from collections import deque
from datetime import datetime, timedelta
from typing import Optional
# Default configuration
DEFAULT_RETENTION_MINUTES = 30
DEFAULT_MIN_INTERVAL_SECONDS = 2.0
DEFAULT_MAX_OBSERVATIONS_PER_DEVICE = 1000
class RingBuffer:
"""
Time-windowed ring buffer for Bluetooth RSSI observations.
Features:
- Rate-limited ingestion (max 1 observation per device per interval)
- Automatic pruning of old observations
- Downsampling for efficient visualization
- Thread-safe operations
"""
def __init__(
self,
retention_minutes: int = DEFAULT_RETENTION_MINUTES,
min_interval_seconds: float = DEFAULT_MIN_INTERVAL_SECONDS,
max_observations_per_device: int = DEFAULT_MAX_OBSERVATIONS_PER_DEVICE,
):
"""
Initialize the ring buffer.
Args:
retention_minutes: How long to keep observations (minutes).
min_interval_seconds: Minimum time between observations per device.
max_observations_per_device: Maximum observations stored per device.
"""
self.retention_minutes = retention_minutes
self.min_interval_seconds = min_interval_seconds
self.max_observations_per_device = max_observations_per_device
# device_key -> deque[(timestamp, rssi)]
self._observations: dict[str, deque[tuple[datetime, int]]] = {}
# device_key -> last_ingested_timestamp
self._last_ingested: dict[str, datetime] = {}
self._lock = threading.Lock()
def ingest(
self,
device_key: str,
rssi: int,
timestamp: Optional[datetime] = None,
) -> bool:
"""
Ingest an RSSI observation for a device.
Rate-limited to prevent flooding from high-frequency advertisers.
Args:
device_key: Stable device identifier.
rssi: RSSI value in dBm.
timestamp: Observation timestamp (defaults to now).
Returns:
True if observation was stored, False if rate-limited.
"""
if timestamp is None:
timestamp = datetime.now()
with self._lock:
# Check rate limit
last_time = self._last_ingested.get(device_key)
if last_time is not None:
elapsed = (timestamp - last_time).total_seconds()
if elapsed < self.min_interval_seconds:
return False
# Initialize deque for new device
if device_key not in self._observations:
self._observations[device_key] = deque(
maxlen=self.max_observations_per_device
)
# Store observation
self._observations[device_key].append((timestamp, rssi))
self._last_ingested[device_key] = timestamp
return True
def get_timeseries(
self,
device_key: str,
window_minutes: Optional[int] = None,
downsample_seconds: int = 10,
) -> list[dict]:
"""
Get downsampled timeseries data for a device.
Args:
device_key: Device identifier.
window_minutes: Time window (defaults to retention period).
downsample_seconds: Bucket size for downsampling.
Returns:
List of dicts with 'timestamp' and 'rssi' keys.
"""
if window_minutes is None:
window_minutes = self.retention_minutes
cutoff = datetime.now() - timedelta(minutes=window_minutes)
with self._lock:
obs = self._observations.get(device_key)
if not obs:
return []
# Filter to window and downsample
return self._downsample(
[(ts, rssi) for ts, rssi in obs if ts >= cutoff],
downsample_seconds,
)
def get_all_timeseries(
self,
window_minutes: Optional[int] = None,
downsample_seconds: int = 10,
top_n: Optional[int] = None,
sort_by: str = 'recency',
) -> dict[str, list[dict]]:
"""
Get downsampled timeseries for all devices.
Args:
window_minutes: Time window.
downsample_seconds: Bucket size for downsampling.
top_n: Limit to top N devices.
sort_by: Sort method ('recency', 'strength', 'activity').
Returns:
Dict mapping device_key to timeseries data.
"""
if window_minutes is None:
window_minutes = self.retention_minutes
cutoff = datetime.now() - timedelta(minutes=window_minutes)
with self._lock:
# Build list of (device_key, last_seen, avg_rssi, count)
device_info = []
for device_key, obs in self._observations.items():
recent = [(ts, rssi) for ts, rssi in obs if ts >= cutoff]
if not recent:
continue
last_seen = max(ts for ts, _ in recent)
avg_rssi = sum(rssi for _, rssi in recent) / len(recent)
device_info.append((device_key, last_seen, avg_rssi, len(recent)))
# Sort based on criteria
if sort_by == 'strength':
device_info.sort(key=lambda x: x[2], reverse=True) # Higher RSSI first
elif sort_by == 'activity':
device_info.sort(key=lambda x: x[3], reverse=True) # More observations first
else: # recency
device_info.sort(key=lambda x: x[1], reverse=True) # Most recent first
# Limit to top N
if top_n is not None:
device_info = device_info[:top_n]
# Build result
result = {}
for device_key, _, _, _ in device_info:
obs = self._observations.get(device_key, [])
recent = [(ts, rssi) for ts, rssi in obs if ts >= cutoff]
result[device_key] = self._downsample(recent, downsample_seconds)
return result
def _downsample(
self,
observations: list[tuple[datetime, int]],
bucket_seconds: int,
) -> list[dict]:
"""
Downsample observations into time buckets.
Uses average RSSI for each bucket.
Args:
observations: List of (timestamp, rssi) tuples.
bucket_seconds: Size of each bucket in seconds.
Returns:
List of dicts with 'timestamp' and 'rssi'.
"""
if not observations:
return []
# Group into buckets
buckets: dict[datetime, list[int]] = {}
for ts, rssi in observations:
# Round timestamp to bucket boundary
bucket_ts = ts.replace(
second=(ts.second // bucket_seconds) * bucket_seconds,
microsecond=0,
)
if bucket_ts not in buckets:
buckets[bucket_ts] = []
buckets[bucket_ts].append(rssi)
# Calculate average for each bucket
result = []
for bucket_ts in sorted(buckets.keys()):
rssi_values = buckets[bucket_ts]
avg_rssi = sum(rssi_values) / len(rssi_values)
result.append({
'timestamp': bucket_ts.isoformat(),
'rssi': round(avg_rssi, 1),
})
return result
def prune_old(self) -> int:
"""
Remove observations older than retention period.
Returns:
Number of observations removed.
"""
cutoff = datetime.now() - timedelta(minutes=self.retention_minutes)
removed = 0
with self._lock:
empty_devices = []
for device_key, obs in self._observations.items():
initial_len = len(obs)
# Remove old observations from the left
while obs and obs[0][0] < cutoff:
obs.popleft()
removed += initial_len - len(obs)
if not obs:
empty_devices.append(device_key)
# Clean up empty device entries
for device_key in empty_devices:
del self._observations[device_key]
self._last_ingested.pop(device_key, None)
return removed
def get_device_count(self) -> int:
"""Get number of devices with stored observations."""
with self._lock:
return len(self._observations)
def get_observation_count(self, device_key: Optional[str] = None) -> int:
"""
Get total observation count.
Args:
device_key: If specified, count only for this device.
Returns:
Number of stored observations.
"""
with self._lock:
if device_key:
obs = self._observations.get(device_key)
return len(obs) if obs else 0
return sum(len(obs) for obs in self._observations.values())
def clear(self) -> None:
"""Clear all stored observations."""
with self._lock:
self._observations.clear()
self._last_ingested.clear()
def get_device_stats(self, device_key: str) -> Optional[dict]:
"""
Get statistics for a specific device.
Args:
device_key: Device identifier.
Returns:
Dict with stats or None if device not found.
"""
with self._lock:
obs = self._observations.get(device_key)
if not obs:
return None
rssi_values = [rssi for _, rssi in obs]
timestamps = [ts for ts, _ in obs]
return {
'observation_count': len(obs),
'first_observation': min(timestamps).isoformat(),
'last_observation': max(timestamps).isoformat(),
'rssi_min': min(rssi_values),
'rssi_max': max(rssi_values),
'rssi_avg': sum(rssi_values) / len(rssi_values),
}
# Module-level instance for shared access
_ring_buffer: Optional[RingBuffer] = None
def get_ring_buffer() -> RingBuffer:
"""Get or create the shared ring buffer instance."""
global _ring_buffer
if _ring_buffer is None:
_ring_buffer = RingBuffer()
return _ring_buffer
def reset_ring_buffer() -> None:
"""Reset the shared ring buffer instance."""
global _ring_buffer
if _ring_buffer is not None:
_ring_buffer.clear()
_ring_buffer = None