Fix floor persistence and improve movement detection
- Store floor assignments in SQLite database for persistence - Floor now saves correctly for live-tracked BT devices - Add statistical movement detection (5-sample average + stddev) - Require 1.5m + 2σ deviation to mark device as moving - Reduces false positives from RSSI noise/fluctuations - Add /api/device/floors endpoint for bulk floor retrieval Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,7 @@ class DeviceDatabase:
|
||||
total_observations INTEGER DEFAULT 0,
|
||||
custom_label TEXT, -- User-assigned name
|
||||
is_favorite INTEGER DEFAULT 0,
|
||||
assigned_floor INTEGER, -- User-assigned floor
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -160,6 +161,12 @@ class DeviceDatabase:
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_movement_device ON movement_events(device_id, timestamp)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_alerts_type ON alerts(alert_type, acknowledged)")
|
||||
|
||||
# Add assigned_floor column if it doesn't exist (migration for existing DBs)
|
||||
try:
|
||||
cursor.execute("ALTER TABLE devices ADD COLUMN assigned_floor INTEGER")
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
|
||||
conn.commit()
|
||||
|
||||
def record_scan(self, scan_id: str, timestamp: str, location_label: str,
|
||||
@@ -463,6 +470,34 @@ class DeviceDatabase:
|
||||
""", (1 if is_favorite else 0, device_id))
|
||||
conn.commit()
|
||||
|
||||
def set_device_floor(self, device_id: str, floor: Optional[int]):
|
||||
"""Set assigned floor for a device"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE devices SET assigned_floor = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE device_id = ?
|
||||
""", (floor, device_id))
|
||||
conn.commit()
|
||||
|
||||
def get_device_floor(self, device_id: str) -> Optional[int]:
|
||||
"""Get assigned floor for a device"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT assigned_floor FROM devices WHERE device_id = ?", (device_id,))
|
||||
row = cursor.fetchone()
|
||||
return row['assigned_floor'] if row else None
|
||||
|
||||
def get_all_device_floors(self) -> dict:
|
||||
"""Get all device floor assignments as a dict"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT device_id, assigned_floor FROM devices WHERE assigned_floor IS NOT NULL")
|
||||
return {row['device_id']: row['assigned_floor'] for row in cursor.fetchall()}
|
||||
|
||||
def get_recent_activity(self, hours: int = 24) -> dict:
|
||||
"""Get activity summary for the last N hours"""
|
||||
conn = self._get_connection()
|
||||
|
||||
@@ -693,7 +693,7 @@ def create_app(config: Config | None = None) -> Flask:
|
||||
|
||||
@app.route("/api/device/<device_id>/floor", methods=["POST"])
|
||||
def api_device_floor(device_id: str):
|
||||
"""Assign floor to a device in the most recent scan"""
|
||||
"""Assign floor to a device - stores in database for persistence"""
|
||||
data = request.get_json() or {}
|
||||
floor = data.get("floor")
|
||||
height_m = data.get("height_m")
|
||||
@@ -701,50 +701,46 @@ def create_app(config: Config | None = None) -> Flask:
|
||||
if floor is None and height_m is None:
|
||||
return jsonify({"error": "Must provide floor or height_m"}), 400
|
||||
|
||||
# Load the most recent scan
|
||||
# Store floor in database for persistence
|
||||
db = app.config.get("DATABASE")
|
||||
if db and floor is not None:
|
||||
db.set_device_floor(device_id, int(floor))
|
||||
|
||||
# Also update the most recent scan file if device exists there
|
||||
data_dir = app.config["DATA_DIR"]
|
||||
scan_files = sorted(data_dir.glob("scan_*.json"), reverse=True)
|
||||
device_type = "unknown"
|
||||
|
||||
if not scan_files:
|
||||
return jsonify({"error": "No scans found"}), 404
|
||||
if scan_files:
|
||||
scan_file = scan_files[0]
|
||||
with open(scan_file) as f:
|
||||
scan = json.load(f)
|
||||
|
||||
scan_file = scan_files[0]
|
||||
with open(scan_file) as f:
|
||||
scan = json.load(f)
|
||||
|
||||
# Find and update the device
|
||||
updated = False
|
||||
device_type = None
|
||||
|
||||
# Check WiFi networks (by BSSID)
|
||||
for net in scan.get("wifi_networks", []):
|
||||
if net.get("bssid") == device_id:
|
||||
if floor is not None:
|
||||
net["floor"] = int(floor)
|
||||
if height_m is not None:
|
||||
net["height_m"] = float(height_m)
|
||||
updated = True
|
||||
device_type = "wifi"
|
||||
break
|
||||
|
||||
# Check Bluetooth devices (by address)
|
||||
if not updated:
|
||||
for dev in scan.get("bluetooth_devices", []):
|
||||
if dev.get("address") == device_id:
|
||||
updated = False
|
||||
for net in scan.get("wifi_networks", []):
|
||||
if net.get("bssid") == device_id:
|
||||
if floor is not None:
|
||||
dev["floor"] = int(floor)
|
||||
net["floor"] = int(floor)
|
||||
if height_m is not None:
|
||||
dev["height_m"] = float(height_m)
|
||||
net["height_m"] = float(height_m)
|
||||
updated = True
|
||||
device_type = "bluetooth"
|
||||
device_type = "wifi"
|
||||
break
|
||||
|
||||
if not updated:
|
||||
return jsonify({"error": "Device not found"}), 404
|
||||
if not updated:
|
||||
for dev in scan.get("bluetooth_devices", []):
|
||||
if dev.get("address") == device_id:
|
||||
if floor is not None:
|
||||
dev["floor"] = int(floor)
|
||||
if height_m is not None:
|
||||
dev["height_m"] = float(height_m)
|
||||
updated = True
|
||||
device_type = "bluetooth"
|
||||
break
|
||||
|
||||
# Save the updated scan
|
||||
with open(scan_file, "w") as f:
|
||||
json.dump(scan, f, indent=2)
|
||||
if updated:
|
||||
with open(scan_file, "w") as f:
|
||||
json.dump(scan, f, indent=2)
|
||||
|
||||
return jsonify({
|
||||
"status": "updated",
|
||||
@@ -812,6 +808,14 @@ def create_app(config: Config | None = None) -> Flask:
|
||||
# Store previous distances for movement detection logging
|
||||
_bt_previous_distances: dict = {}
|
||||
|
||||
@app.route("/api/device/floors", methods=["GET"])
|
||||
def api_device_floors():
|
||||
"""Get all saved floor assignments"""
|
||||
db = app.config.get("DATABASE")
|
||||
if not db:
|
||||
return jsonify({})
|
||||
return jsonify(db.get_all_device_floors())
|
||||
|
||||
@app.route("/api/scan/bt", methods=["POST"])
|
||||
def api_scan_bt():
|
||||
"""Quick Bluetooth-only scan for real-time tracking using bleak (BLE)"""
|
||||
@@ -842,6 +846,10 @@ def create_app(config: Config | None = None) -> Flask:
|
||||
print(f"[BT] Bleak scan error: {e}")
|
||||
bt = []
|
||||
|
||||
# Get saved floor assignments from database
|
||||
db = app.config.get("DATABASE")
|
||||
saved_floors = db.get_all_device_floors() if db else {}
|
||||
|
||||
# The scanner already does auto-identification if enabled
|
||||
# Just use the device_type already assigned by the scanner
|
||||
|
||||
@@ -885,6 +893,9 @@ def create_app(config: Config | None = None) -> Flask:
|
||||
scan_id=None # Live tracking, no scan_id
|
||||
)
|
||||
|
||||
# Get saved floor from database
|
||||
saved_floor = saved_floors.get(addr)
|
||||
|
||||
response_data["bluetooth_devices"].append({
|
||||
"address": addr,
|
||||
"name": name,
|
||||
@@ -894,7 +905,7 @@ def create_app(config: Config | None = None) -> Flask:
|
||||
"manufacturer": "",
|
||||
"estimated_distance_m": round(dist, 2),
|
||||
"signal_quality": "Fair",
|
||||
"floor": None,
|
||||
"floor": saved_floor,
|
||||
"height_m": None
|
||||
})
|
||||
|
||||
|
||||
@@ -21,9 +21,72 @@ let liveTrackingEnabled = false;
|
||||
let liveTrackingInterval = null;
|
||||
const LIVE_TRACKING_INTERVAL_MS = 4000; // 4 seconds
|
||||
|
||||
// Track previous distances for movement detection
|
||||
let previousDistances = {};
|
||||
const MOVEMENT_THRESHOLD = 0.5; // meters - consider moving if distance changed by this much
|
||||
// Statistical movement detection
|
||||
const SAMPLE_HISTORY_SIZE = 5; // Number of samples to keep for averaging
|
||||
const MOVEMENT_THRESHOLD = 1.5; // meters - movement must exceed this + stddev margin
|
||||
const MIN_SAMPLES_FOR_MOVEMENT = 3; // Need at least this many samples before detecting movement
|
||||
|
||||
// Store distance history per device: { address: { samples: [], timestamps: [] } }
|
||||
let deviceDistanceHistory = {};
|
||||
|
||||
// Calculate mean of array
|
||||
function mean(arr) {
|
||||
if (arr.length === 0) return 0;
|
||||
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||
}
|
||||
|
||||
// Calculate standard deviation
|
||||
function stddev(arr) {
|
||||
if (arr.length < 2) return 0;
|
||||
const avg = mean(arr);
|
||||
const squareDiffs = arr.map(x => Math.pow(x - avg, 2));
|
||||
return Math.sqrt(mean(squareDiffs));
|
||||
}
|
||||
|
||||
// Check if device is moving based on statistical analysis
|
||||
function isDeviceMoving(address, newDistance) {
|
||||
// Initialize history if needed
|
||||
if (!deviceDistanceHistory[address]) {
|
||||
deviceDistanceHistory[address] = { samples: [], timestamps: [] };
|
||||
}
|
||||
|
||||
const history = deviceDistanceHistory[address];
|
||||
const now = Date.now();
|
||||
|
||||
// Add new sample
|
||||
history.samples.push(newDistance);
|
||||
history.timestamps.push(now);
|
||||
|
||||
// Remove old samples (keep last SAMPLE_HISTORY_SIZE)
|
||||
while (history.samples.length > SAMPLE_HISTORY_SIZE) {
|
||||
history.samples.shift();
|
||||
history.timestamps.shift();
|
||||
}
|
||||
|
||||
// Need minimum samples to determine movement
|
||||
if (history.samples.length < MIN_SAMPLES_FOR_MOVEMENT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate statistics from older samples (exclude the newest one)
|
||||
const olderSamples = history.samples.slice(0, -1);
|
||||
const avg = mean(olderSamples);
|
||||
const sd = stddev(olderSamples);
|
||||
|
||||
// Movement threshold = base threshold + 2 standard deviations (95% confidence)
|
||||
const dynamicThreshold = MOVEMENT_THRESHOLD + (2 * sd);
|
||||
|
||||
// Check if current reading deviates significantly from the average
|
||||
const deviation = Math.abs(newDistance - avg);
|
||||
const isMoving = deviation > dynamicThreshold;
|
||||
|
||||
// Debug log for significant movements
|
||||
if (isMoving) {
|
||||
console.log(`[Movement] ${address}: dist=${newDistance.toFixed(2)}m, avg=${avg.toFixed(2)}m, sd=${sd.toFixed(2)}, threshold=${dynamicThreshold.toFixed(2)}m`);
|
||||
}
|
||||
|
||||
return isMoving;
|
||||
}
|
||||
|
||||
// Device positions for hit detection (radar view)
|
||||
let devicePositions = [];
|
||||
@@ -1485,23 +1548,19 @@ async function performLiveBTScan() {
|
||||
const existing = existingBt.find(d => d.address === newDev.address);
|
||||
const newDist = newDev.estimated_distance_m;
|
||||
|
||||
// Check for movement
|
||||
const prevDist = previousDistances[newDev.address];
|
||||
const isMoving = prevDist !== undefined && Math.abs(newDist - prevDist) > MOVEMENT_THRESHOLD;
|
||||
|
||||
// Store current distance for next comparison
|
||||
previousDistances[newDev.address] = newDist;
|
||||
// Check for movement using statistical analysis
|
||||
const moving = isDeviceMoving(newDev.address, newDist);
|
||||
|
||||
if (existing) {
|
||||
// Update RSSI and estimated distance, preserve custom values
|
||||
existing.rssi = newDev.rssi;
|
||||
existing.estimated_distance_m = newDev.estimated_distance_m;
|
||||
existing.signal_quality = newDev.signal_quality;
|
||||
existing.is_moving = isMoving;
|
||||
existing.is_moving = moving;
|
||||
// Preserve floor and custom_distance_m if set
|
||||
} else {
|
||||
// New device, add it
|
||||
newDev.is_moving = isMoving;
|
||||
newDev.is_moving = moving;
|
||||
existingBt.push(newDev);
|
||||
}
|
||||
});
|
||||
@@ -1510,7 +1569,8 @@ async function performLiveBTScan() {
|
||||
} else {
|
||||
// No existing scan data, use BT-only data
|
||||
data.bluetooth_devices.forEach(dev => {
|
||||
previousDistances[dev.address] = dev.estimated_distance_m;
|
||||
// Initialize history with first sample, not moving yet
|
||||
isDeviceMoving(dev.address, dev.estimated_distance_m);
|
||||
dev.is_moving = false;
|
||||
});
|
||||
scanData = {
|
||||
|
||||
Reference in New Issue
Block a user