diff --git a/src/rf_mapper/database.py b/src/rf_mapper/database.py index 232335f..5e541e3 100644 --- a/src/rf_mapper/database.py +++ b/src/rf_mapper/database.py @@ -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() diff --git a/src/rf_mapper/web/app.py b/src/rf_mapper/web/app.py index 3e653e0..ae879ff 100644 --- a/src/rf_mapper/web/app.py +++ b/src/rf_mapper/web/app.py @@ -693,7 +693,7 @@ def create_app(config: Config | None = None) -> Flask: @app.route("/api/device//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 }) diff --git a/src/rf_mapper/web/static/js/app.js b/src/rf_mapper/web/static/js/app.js index 386f652..e62f669 100644 --- a/src/rf_mapper/web/static/js/app.js +++ b/src/rf_mapper/web/static/js/app.js @@ -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 = {