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:
User
2026-02-01 00:34:49 +01:00
parent 0e99232582
commit dda8455813
3 changed files with 154 additions and 48 deletions

View File

@@ -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()

View File

@@ -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
})

View File

@@ -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 = {