mirror of
https://github.com/smittix/intercept.git
synced 2026-07-05 16:18:12 -07:00
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:
@@ -614,9 +614,11 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
|||||||
tscm_devices.append({
|
tscm_devices.append({
|
||||||
'mac': device.address,
|
'mac': device.address,
|
||||||
'address_type': device.address_type,
|
'address_type': device.address_type,
|
||||||
|
'device_key': device.device_key,
|
||||||
'name': device.name or 'Unknown',
|
'name': device.name or 'Unknown',
|
||||||
'rssi': device.rssi_current or -100,
|
'rssi': device.rssi_current or -100,
|
||||||
'rssi_median': device.rssi_median,
|
'rssi_median': device.rssi_median,
|
||||||
|
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
||||||
'type': _classify_device_type(device),
|
'type': _classify_device_type(device),
|
||||||
'manufacturer': device.manufacturer_name,
|
'manufacturer': device.manufacturer_name,
|
||||||
'protocol': device.protocol,
|
'protocol': device.protocol,
|
||||||
@@ -624,6 +626,11 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
|||||||
'last_seen': device.last_seen.isoformat(),
|
'last_seen': device.last_seen.isoformat(),
|
||||||
'seen_count': device.seen_count,
|
'seen_count': device.seen_count,
|
||||||
'range_band': device.range_band,
|
'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': {
|
'heuristics': {
|
||||||
'is_new': device.is_new,
|
'is_new': device.is_new,
|
||||||
'is_persistent': device.is_persistent,
|
'is_persistent': device.is_persistent,
|
||||||
@@ -637,6 +644,171 @@ def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
|||||||
return tscm_devices
|
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:
|
def _classify_device_type(device: BTDeviceAggregate) -> str:
|
||||||
"""Classify device type from available data."""
|
"""Classify device type from available data."""
|
||||||
name_lower = (device.name or '').lower()
|
name_lower = (device.name or '').lower()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
@@ -35,6 +35,11 @@ const BluetoothMode = (function() {
|
|||||||
// Zone counts for proximity display
|
// Zone counts for proximity display
|
||||||
let zoneCounts = { veryClose: 0, close: 0, nearby: 0, far: 0 };
|
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
|
* Initialize the Bluetooth mode
|
||||||
*/
|
*/
|
||||||
@@ -60,7 +65,11 @@ const BluetoothMode = (function() {
|
|||||||
// Check scan status (in case page was reloaded during scan)
|
// Check scan status (in case page was reloaded during scan)
|
||||||
checkScanStatus();
|
checkScanStatus();
|
||||||
|
|
||||||
// Initialize proximity visualization
|
// Initialize proximity visualization (new radar + heatmap)
|
||||||
|
initProximityRadar();
|
||||||
|
initTimelineHeatmap();
|
||||||
|
|
||||||
|
// Initialize legacy heatmap (zone counts)
|
||||||
initHeatmap();
|
initHeatmap();
|
||||||
|
|
||||||
// Initialize timeline as collapsed
|
// Initialize timeline as collapsed
|
||||||
@@ -70,6 +79,134 @@ const BluetoothMode = (function() {
|
|||||||
updateVisualizationPanels();
|
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
|
* Initialize proximity zones display
|
||||||
*/
|
*/
|
||||||
@@ -485,6 +622,11 @@ const BluetoothMode = (function() {
|
|||||||
};
|
};
|
||||||
updateVisualizationPanels();
|
updateVisualizationPanels();
|
||||||
updateProximityZones();
|
updateProximityZones();
|
||||||
|
|
||||||
|
// Clear radar
|
||||||
|
if (radarInitialized && typeof ProximityRadar !== 'undefined') {
|
||||||
|
ProximityRadar.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEventStream() {
|
function startEventStream() {
|
||||||
@@ -529,6 +671,9 @@ const BluetoothMode = (function() {
|
|||||||
updateVisualizationPanels();
|
updateVisualizationPanels();
|
||||||
updateProximityZones();
|
updateProximityZones();
|
||||||
|
|
||||||
|
// Update new proximity radar
|
||||||
|
updateRadar();
|
||||||
|
|
||||||
// Feed to activity timeline
|
// Feed to activity timeline
|
||||||
addToTimeline(device);
|
addToTimeline(device);
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-22
@@ -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/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/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/device-cards.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/proximity-viz.css') }}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -706,29 +707,28 @@
|
|||||||
<div class="bt-layout-container" id="btLayoutContainer" style="display: none;">
|
<div class="bt-layout-container" id="btLayoutContainer" style="display: none;">
|
||||||
<!-- Left: Bluetooth Visualizations -->
|
<!-- Left: Bluetooth Visualizations -->
|
||||||
<div class="wifi-visuals" id="btVisuals">
|
<div class="wifi-visuals" id="btVisuals">
|
||||||
<!-- Row 1: Proximity Zones + Device Types -->
|
<!-- Row 1: Proximity Radar + Device Types -->
|
||||||
<div class="wifi-visual-panel">
|
<div class="wifi-visual-panel" style="grid-row: span 2;">
|
||||||
<h5>Proximity Zones</h5>
|
<h5>Proximity Radar</h5>
|
||||||
<div id="btProximityZones" style="display: flex; flex-direction: column; gap: 6px; padding: 8px 0;">
|
<div id="btProximityRadar" style="display: flex; justify-content: center; padding: 8px 0;"></div>
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
<div id="btRadarControls" style="display: flex; gap: 6px; justify-content: center; margin-top: 8px; flex-wrap: wrap;">
|
||||||
<div style="width: 10px; height: 10px; border-radius: 50%; background: #22c55e;"></div>
|
<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>
|
||||||
<span style="flex: 1; font-size: 11px; color: #888;">Very Close</span>
|
<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>
|
||||||
<span id="btZoneVeryClose" style="font-size: 14px; font-weight: 600; color: #22c55e;">0</span>
|
<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>
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
<div style="text-align: center;">
|
||||||
<div style="width: 10px; height: 10px; border-radius: 50%; background: #84cc16;"></div>
|
<span id="btZoneNear" style="font-size: 18px; font-weight: 600; color: #eab308;">0</span>
|
||||||
<span style="flex: 1; font-size: 11px; color: #888;">Close</span>
|
<div style="color: #666;">Near</div>
|
||||||
<span id="btZoneClose" style="font-size: 14px; font-weight: 600; color: #84cc16;">0</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
<div style="text-align: center;">
|
||||||
<div style="width: 10px; height: 10px; border-radius: 50%; background: #eab308;"></div>
|
<span id="btZoneFar" style="font-size: 18px; font-weight: 600; color: #ef4444;">0</span>
|
||||||
<span style="flex: 1; font-size: 11px; color: #888;">Nearby</span>
|
<div style="color: #666;">Far</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -778,7 +778,12 @@
|
|||||||
FindMy-compatible devices...</div>
|
FindMy-compatible devices...</div>
|
||||||
</div>
|
</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 class="wifi-visual-panel" style="grid-column: span 2;">
|
||||||
<div id="bluetoothTimelineContainer"></div>
|
<div id="bluetoothTimelineContainer"></div>
|
||||||
</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/rssi-sparkline.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/components/message-card.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/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/bluetooth.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -8,12 +8,17 @@ device aggregation, RSSI statistics, and observable heuristics.
|
|||||||
from .aggregator import DeviceAggregator
|
from .aggregator import DeviceAggregator
|
||||||
from .capability_check import check_capabilities, quick_adapter_check
|
from .capability_check import check_capabilities, quick_adapter_check
|
||||||
from .constants import (
|
from .constants import (
|
||||||
# Range bands
|
# Range bands (legacy)
|
||||||
RANGE_VERY_CLOSE,
|
RANGE_VERY_CLOSE,
|
||||||
RANGE_CLOSE,
|
RANGE_CLOSE,
|
||||||
RANGE_NEARBY,
|
RANGE_NEARBY,
|
||||||
RANGE_FAR,
|
RANGE_FAR,
|
||||||
RANGE_UNKNOWN,
|
RANGE_UNKNOWN,
|
||||||
|
# Proximity bands (new)
|
||||||
|
PROXIMITY_IMMEDIATE,
|
||||||
|
PROXIMITY_NEAR,
|
||||||
|
PROXIMITY_FAR,
|
||||||
|
PROXIMITY_UNKNOWN,
|
||||||
# Protocols
|
# Protocols
|
||||||
PROTOCOL_BLE,
|
PROTOCOL_BLE,
|
||||||
PROTOCOL_CLASSIC,
|
PROTOCOL_CLASSIC,
|
||||||
@@ -25,8 +30,11 @@ from .constants import (
|
|||||||
ADDRESS_TYPE_RPA,
|
ADDRESS_TYPE_RPA,
|
||||||
ADDRESS_TYPE_NRPA,
|
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 .heuristics import HeuristicsEngine, evaluate_device_heuristics, evaluate_all_devices
|
||||||
from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities
|
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
|
from .scanner import BluetoothScanner, get_bluetooth_scanner, reset_bluetooth_scanner
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -44,6 +52,21 @@ __all__ = [
|
|||||||
# Aggregator
|
# Aggregator
|
||||||
'DeviceAggregator',
|
'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
|
# Heuristics
|
||||||
'HeuristicsEngine',
|
'HeuristicsEngine',
|
||||||
'evaluate_device_heuristics',
|
'evaluate_device_heuristics',
|
||||||
@@ -53,15 +76,25 @@ __all__ = [
|
|||||||
'check_capabilities',
|
'check_capabilities',
|
||||||
'quick_adapter_check',
|
'quick_adapter_check',
|
||||||
|
|
||||||
# Constants
|
# Constants - Range bands (legacy)
|
||||||
'RANGE_VERY_CLOSE',
|
'RANGE_VERY_CLOSE',
|
||||||
'RANGE_CLOSE',
|
'RANGE_CLOSE',
|
||||||
'RANGE_NEARBY',
|
'RANGE_NEARBY',
|
||||||
'RANGE_FAR',
|
'RANGE_FAR',
|
||||||
'RANGE_UNKNOWN',
|
'RANGE_UNKNOWN',
|
||||||
|
|
||||||
|
# Constants - Proximity bands (new)
|
||||||
|
'PROXIMITY_IMMEDIATE',
|
||||||
|
'PROXIMITY_NEAR',
|
||||||
|
'PROXIMITY_FAR',
|
||||||
|
'PROXIMITY_UNKNOWN',
|
||||||
|
|
||||||
|
# Constants - Protocols
|
||||||
'PROTOCOL_BLE',
|
'PROTOCOL_BLE',
|
||||||
'PROTOCOL_CLASSIC',
|
'PROTOCOL_CLASSIC',
|
||||||
'PROTOCOL_AUTO',
|
'PROTOCOL_AUTO',
|
||||||
|
|
||||||
|
# Constants - Address types
|
||||||
'ADDRESS_TYPE_PUBLIC',
|
'ADDRESS_TYPE_PUBLIC',
|
||||||
'ADDRESS_TYPE_RANDOM',
|
'ADDRESS_TYPE_RANDOM',
|
||||||
'ADDRESS_TYPE_RANDOM_STATIC',
|
'ADDRESS_TYPE_RANDOM_STATIC',
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ from .constants import (
|
|||||||
PROTOCOL_CLASSIC,
|
PROTOCOL_CLASSIC,
|
||||||
)
|
)
|
||||||
from .models import BTObservation, BTDeviceAggregate
|
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:
|
class DeviceAggregator:
|
||||||
@@ -53,6 +56,13 @@ class DeviceAggregator:
|
|||||||
self._baseline_device_ids: set[str] = set()
|
self._baseline_device_ids: set[str] = set()
|
||||||
self._baseline_set_time: Optional[datetime] = None
|
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:
|
def ingest(self, observation: BTObservation) -> BTDeviceAggregate:
|
||||||
"""
|
"""
|
||||||
Ingest a new observation and update the device aggregate.
|
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.in_baseline = device_id in self._baseline_device_ids
|
||||||
device.is_new = not device.in_baseline and self._baseline_set_time is not None
|
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
|
return device
|
||||||
|
|
||||||
def _infer_protocol(self, observation: BTObservation) -> str:
|
def _infer_protocol(self, observation: BTObservation) -> str:
|
||||||
@@ -219,6 +266,31 @@ class DeviceAggregator:
|
|||||||
device.range_band = RANGE_UNKNOWN
|
device.range_band = RANGE_UNKNOWN
|
||||||
device.range_confidence = confidence * 0.5 # Reduced confidence for 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:
|
def _merge_device_info(self, device: BTDeviceAggregate, observation: BTObservation) -> None:
|
||||||
"""Merge observation data into device aggregate (prefer non-None values)."""
|
"""Merge observation data into device aggregate (prefer non-None values)."""
|
||||||
# Name (prefer longer names as they're usually more complete)
|
# Name (prefer longer names as they're usually more complete)
|
||||||
@@ -345,3 +417,107 @@ class DeviceAggregator:
|
|||||||
def has_baseline(self) -> bool:
|
def has_baseline(self) -> bool:
|
||||||
"""Whether a baseline is set."""
|
"""Whether a baseline is set."""
|
||||||
return self._baseline_set_time is not None
|
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()
|
||||||
|
|||||||
@@ -120,6 +120,63 @@ RANGE_NEARBY = 'nearby'
|
|||||||
RANGE_FAR = 'far'
|
RANGE_FAR = 'far'
|
||||||
RANGE_UNKNOWN = 'unknown'
|
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)
|
# COMMON MANUFACTURER IDS (OUI -> Name mapping for common vendors)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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
|
||||||
@@ -11,8 +11,13 @@ from typing import Optional
|
|||||||
from .constants import (
|
from .constants import (
|
||||||
MANUFACTURER_NAMES,
|
MANUFACTURER_NAMES,
|
||||||
ADDRESS_TYPE_PUBLIC,
|
ADDRESS_TYPE_PUBLIC,
|
||||||
|
ADDRESS_TYPE_RANDOM,
|
||||||
|
ADDRESS_TYPE_RANDOM_STATIC,
|
||||||
|
ADDRESS_TYPE_RPA,
|
||||||
|
ADDRESS_TYPE_NRPA,
|
||||||
RANGE_UNKNOWN,
|
RANGE_UNKNOWN,
|
||||||
PROTOCOL_BLE,
|
PROTOCOL_BLE,
|
||||||
|
PROXIMITY_UNKNOWN,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -100,10 +105,21 @@ class BTDeviceAggregate:
|
|||||||
rssi_variance: Optional[float] = None
|
rssi_variance: Optional[float] = None
|
||||||
rssi_confidence: float = 0.0 # 0.0-1.0
|
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_band: str = RANGE_UNKNOWN
|
||||||
range_confidence: float = 0.0
|
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)
|
# Device info (merged from observations)
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
manufacturer_id: Optional[int] = None
|
manufacturer_id: Optional[int] = None
|
||||||
@@ -193,10 +209,21 @@ class BTDeviceAggregate:
|
|||||||
'rssi_confidence': round(self.rssi_confidence, 2),
|
'rssi_confidence': round(self.rssi_confidence, 2),
|
||||||
'rssi_history': self.get_rssi_history(),
|
'rssi_history': self.get_rssi_history(),
|
||||||
|
|
||||||
# Range
|
# Range (legacy)
|
||||||
'range_band': self.range_band,
|
'range_band': self.range_band,
|
||||||
'range_confidence': round(self.range_confidence, 2),
|
'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
|
# Device info
|
||||||
'name': self.name,
|
'name': self.name,
|
||||||
'manufacturer_id': self.manufacturer_id,
|
'manufacturer_id': self.manufacturer_id,
|
||||||
@@ -231,6 +258,7 @@ class BTDeviceAggregate:
|
|||||||
"""Compact dictionary for list views."""
|
"""Compact dictionary for list views."""
|
||||||
return {
|
return {
|
||||||
'device_id': self.device_id,
|
'device_id': self.device_id,
|
||||||
|
'device_key': self.device_key,
|
||||||
'address': self.address,
|
'address': self.address,
|
||||||
'address_type': self.address_type,
|
'address_type': self.address_type,
|
||||||
'protocol': self.protocol,
|
'protocol': self.protocol,
|
||||||
@@ -238,7 +266,12 @@ class BTDeviceAggregate:
|
|||||||
'manufacturer_name': self.manufacturer_name,
|
'manufacturer_name': self.manufacturer_name,
|
||||||
'rssi_current': self.rssi_current,
|
'rssi_current': self.rssi_current,
|
||||||
'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None,
|
'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,
|
'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(),
|
'last_seen': self.last_seen.isoformat(),
|
||||||
'age_seconds': self.age_seconds,
|
'age_seconds': self.age_seconds,
|
||||||
'seen_count': self.seen_count,
|
'seen_count': self.seen_count,
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user