From 4303d34471ab212965f5487c862b18c3e76b656f Mon Sep 17 00:00:00 2001 From: erik Date: Sat, 14 Feb 2026 01:58:04 +0100 Subject: [PATCH] Update vcs.xml, AndroidManifest.xml, BleScanner.kt, and 4 more files Sensor Fusion --- App v2/.idea/vcs.xml | 6 + App v2/app/src/main/AndroidManifest.xml | 1 + .../bleindoorpositioning/ble/BleScanner.kt | 59 ++++-- .../positioning/PositionKalmanFilter.kt | 179 +++++++++++++++++ .../positioning/SensorFusionManager.kt | 189 ++++++++++++++++++ .../ui/screens/PositioningScreen.kt | 3 + CLAUDE.md | 122 ++++++----- 7 files changed, 500 insertions(+), 59 deletions(-) create mode 100644 App v2/.idea/vcs.xml create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/PositionKalmanFilter.kt create mode 100644 App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/SensorFusionManager.kt diff --git a/App v2/.idea/vcs.xml b/App v2/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/App v2/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/App v2/app/src/main/AndroidManifest.xml b/App v2/app/src/main/AndroidManifest.xml index aa47874..d7b5282 100644 --- a/App v2/app/src/main/AndroidManifest.xml +++ b/App v2/app/src/main/AndroidManifest.xml @@ -18,6 +18,7 @@ + diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/ble/BleScanner.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ble/BleScanner.kt index b50b500..61c3bf6 100644 --- a/App v2/app/src/main/java/com/erik/bleindoorpositioning/ble/BleScanner.kt +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ble/BleScanner.kt @@ -14,15 +14,18 @@ import com.erik.bleindoorpositioning.model.PositionResult import com.erik.bleindoorpositioning.model.ScannedBeacon import com.erik.bleindoorpositioning.positioning.BeaconFilterManager import com.erik.bleindoorpositioning.positioning.KalmanFilter +import com.erik.bleindoorpositioning.positioning.MovingAverageFilter import com.erik.bleindoorpositioning.positioning.PositionCalculator +import com.erik.bleindoorpositioning.positioning.PositionKalmanFilter import com.erik.bleindoorpositioning.positioning.PositionSmoother +import com.erik.bleindoorpositioning.positioning.SensorFusionManager import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import java.util.concurrent.TimeUnit -class BleScanner(context: Context, private val configRepository: ConfigRepository, private val coroutineScope: CoroutineScope) { +class BleScanner(private val context: Context, private val configRepository: ConfigRepository, private val coroutineScope: CoroutineScope) { companion object { private const val TAG = "BleScanner" @@ -52,18 +55,34 @@ class BleScanner(context: Context, private val configRepository: ConfigRepositor private var retryAttempt = 0 // RSSI-Filterung - private val filterManager = BeaconFilterManager { - KalmanFilter( - processNoise = 0.5, // TODO: Kalibrieren - measurementNoise = 10.0 // TODO: Kalibrieren - ) + private val filterManager = BeaconFilterManager { // Filter für einzelne Beacon-RSSI-Werte + MovingAverageFilter(windowSize = 20) // 20 Messpunkte für stabile Mittelwertbildung } // Positionsberechnung (wird bei jedem Scan-Start neu initialisiert) private var positionCalculator: PositionCalculator? = null // Positions-Glättung - private val positionSmoother = PositionSmoother(alpha = 0.15) + private val positionSmoother = PositionSmoother(alpha = 0.25) // Reaktiver, da EKF schon glättet + + // Sensor-Fusion (EKF + PDR) + private val positionKalmanFilter = PositionKalmanFilter(processNoise = 0.5) // Standard: Balance zwischen PDR und BLE + private var isEkfInitialized = false + private var sensorFusionManager: SensorFusionManager? = null + private val lock = Any() + + init { + sensorFusionManager = SensorFusionManager(context) { heading, stepLength -> + synchronized(lock) { + positionKalmanFilter.predict(heading, stepLength) + val fusedPos = positionKalmanFilter.getPosition() + val smoothed = positionSmoother.update(fusedPos) + _position.value?.let { prev -> + _position.value = prev.copy(position = smoothed) + } + } + } + } // Öffentliche State Flows private val _beacons = MutableStateFlow>(emptyMap()) @@ -109,10 +128,18 @@ class BleScanner(context: Context, private val configRepository: ConfigRepositor val rssiMap = freshBeacons.mapValues { it.value.filteredRssi } val rawResult = positionCalculator?.calculate(rssiMap) - // Position glätten - _position.value = rawResult?.let { raw -> - val smoothed = positionSmoother.update(raw.position) - raw.copy(position = smoothed) + // Sensor-Fusion: BLE dominiert, PDR ergänzt zwischen Updates + rawResult?.let { raw -> + synchronized(lock) { + if (!isEkfInitialized) { + positionKalmanFilter.initialize(raw.position) + isEkfInitialized = true + } + positionKalmanFilter.update(raw.position, measurementNoise = 2.0) + val fusedPosition = positionKalmanFilter.getPosition() + val smoothed = positionSmoother.update(fusedPosition) + _position.value = raw.copy(position = smoothed) + } } Log.d(TAG, "Beacon ${beacon.minor}: " + @@ -211,6 +238,7 @@ class BleScanner(context: Context, private val configRepository: ConfigRepositor scanner?.startScan(null, settings, scanCallback) _isScanning.value = true + sensorFusionManager?.start() Log.d(TAG, "Scanning started") // Start proactive scan restart job @@ -238,6 +266,8 @@ class BleScanner(context: Context, private val configRepository: ConfigRepositor val config = configRepository.loadConfig() if (config.beacons.size < 3) { Log.w(TAG, "Weniger als 3 Beacons konfiguriert, Positionsberechnung nicht möglich") + positionCalculator = null + return } positionCalculator = PositionCalculator( rssiAtOneMeter = BeaconData.RSSI_AT_ONE_METER, @@ -262,7 +292,12 @@ class BleScanner(context: Context, private val configRepository: ConfigRepositor retryAttempt = 0 _isScanning.value = false filterManager.reset() - positionSmoother.reset() + synchronized(lock) { + positionSmoother.reset() + positionKalmanFilter.reset() + isEkfInitialized = false + } + sensorFusionManager?.stop() Log.d(TAG, "Scanning stopped") } } diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/PositionKalmanFilter.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/PositionKalmanFilter.kt new file mode 100644 index 0000000..deef3c8 --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/PositionKalmanFilter.kt @@ -0,0 +1,179 @@ +package com.erik.bleindoorpositioning.positioning + +import com.erik.bleindoorpositioning.model.Position +import kotlin.math.cos +import kotlin.math.sin + +/** + * Ein einfacher Erweiterter Kalman-Filter (EKF) zur Schätzung der 2D-Position. + * Dieser Filter fusioniert relative Positionsänderungen aus PDR (Pedestrian Dead Reckoning) + * mit absoluten Positionen aus BLE-Messungen. + * + * Der Zustandsvektor ist [x, y]. Die Geschwindigkeit ist nicht Teil des Zustands. + * + * @param processNoise Die Varianz des Prozessrauschens für die Vorhersage. + * Dies repräsentiert die Unsicherheit des Schrittmodells. + */ +class PositionKalmanFilter(processNoise: Double = 0.5) { + + // Zustandsvektor: [x, y] + private var state = doubleArrayOf(0.0, 0.0) + + // Kovarianzmatrix P (2x2) + private var covariance = arrayOf( + doubleArrayOf(1.0, 0.0), + doubleArrayOf(0.0, 1.0) + ) + + // Prozessrauschkovarianz Q (2x2) + private val processNoiseQ = arrayOf( + doubleArrayOf(processNoise, 0.0), + doubleArrayOf(0.0, processNoise) + ) + + /** + * Initialisiert den Filterzustand mit einer Startposition. + * Setzt auch die Kovarianz auf einen Anfangswert zurück, der eine hohe Unsicherheit darstellt. + * + * @param startPosition Die anfängliche Position. + */ + fun initialize(startPosition: Position) { + state[0] = startPosition.x + state[1] = startPosition.y + // Setze die Kovarianz auf einen Zustand hoher anfänglicher Unsicherheit zurück + covariance = arrayOf( + doubleArrayOf(1.0, 0.0), + doubleArrayOf(0.0, 1.0) + ) + } + + /** + * Setzt den Filterzustand und die Kovarianz auf die Standardwerte zurück. + */ + fun reset() { + state = doubleArrayOf(0.0, 0.0) + covariance = arrayOf( + doubleArrayOf(1.0, 0.0), + doubleArrayOf(0.0, 1.0) + ) + } + + /** + * Der Vorhersageschritt (Prediction). + * Aktualisiert den Zustand basierend auf einem Bewegungsschritt (z. B. von PDR). + * + * @param heading Die Bewegungsrichtung im Bogenmaß. + * @param stepLength Die Länge des Schrittes in Metern. + */ + fun predict(heading: Double, stepLength: Double) { + // --- Zustand vorhersagen --- + // x_new = x + stepLength * sin(heading) + // y_new = y + stepLength * cos(heading) + state[0] += stepLength * sin(heading) + state[1] += stepLength * cos(heading) + + // --- Kovarianz vorhersagen --- + // P_pred = F * P * F^T + Q + // Die Jacobi-Matrix F des Prozessmodells ist hier die Einheitsmatrix, + // da die neue Position linear von der alten abhängt (x_new = x + delta_x). + // Daher vereinfacht sich die Formel zu: P_pred = P + Q. + covariance[0][0] += processNoiseQ[0][0] + covariance[1][1] += processNoiseQ[1][1] + } + + /** + * Der Korrekturschritt (Update). + * Korrigiert den Zustand basierend auf einer absoluten Positionsmessung (z. B. von BLE). + * + * @param blePosition Die gemessene Position. + * @param measurementNoise Die Varianz des Messrauschens. Repräsentiert die + * Unsicherheit der BLE-Positionsmessung. + */ + fun update(blePosition: Position, measurementNoise: Double = 2.0) { + // --- Messrauschkovarianz R --- + val r = arrayOf( + doubleArrayOf(measurementNoise, 0.0), + doubleArrayOf(0.0, measurementNoise) + ) + + // --- Messmodell H --- + // Das Messmodell ist linear und direkt, da die Messung [x_ble, y_ble] + // direkt dem Zustand [x, y] entspricht. + // H ist daher die 2x2-Einheitsmatrix. + + // --- Innovations-Kovarianz S --- + // S = H * P * H^T + R + // Da H die Einheitsmatrix ist: S = P + R + val s = arrayOf( + doubleArrayOf(covariance[0][0] + r[0][0], covariance[0][1]), + doubleArrayOf(covariance[1][0], covariance[1][1] + r[1][1]) + ) + + // --- Inverse von S berechnen --- + val detS = s[0][0] * s[1][1] - s[0][1] * s[1][0] + if (detS == 0.0) return // Singularität vermeiden + val invDetS = 1.0 / detS + val sInverse = arrayOf( + doubleArrayOf(s[1][1] * invDetS, -s[0][1] * invDetS), + doubleArrayOf(-s[1][0] * invDetS, s[0][0] * invDetS) + ) + + // --- Kalman-Gewinn K --- + // K = P * H^T * S^-1 + // Da H die Einheitsmatrix ist: K = P * S^-1 + val k = arrayOf( + doubleArrayOf( + covariance[0][0] * sInverse[0][0] + covariance[0][1] * sInverse[1][0], + covariance[0][0] * sInverse[0][1] + covariance[0][1] * sInverse[1][1] + ), + doubleArrayOf( + covariance[1][0] * sInverse[0][0] + covariance[1][1] * sInverse[1][0], + covariance[1][0] * sInverse[0][1] + covariance[1][1] * sInverse[1][1] + ) + ) + + // --- Zustand aktualisieren --- + // state_new = state + K * (z - H * state) + // Da H die Einheitsmatrix ist: state_new = state + K * (z - state) + val measurement = doubleArrayOf(blePosition.x, blePosition.y) + val innovation = doubleArrayOf(measurement[0] - state[0], measurement[1] - state[1]) + + state[0] += k[0][0] * innovation[0] + k[0][1] * innovation[1] + state[1] += k[1][0] * innovation[0] + k[1][1] * innovation[1] + + // --- Kovarianz aktualisieren --- + // P_new = (I - K * H) * P + // Da H die Einheitsmatrix ist: P_new = (I - K) * P + val iMinusK = arrayOf( + doubleArrayOf(1.0 - k[0][0], -k[0][1]), + doubleArrayOf(-k[1][0], 1.0 - k[1][1]) + ) + + val newCovariance = arrayOf( + doubleArrayOf( + iMinusK[0][0] * covariance[0][0] + iMinusK[0][1] * covariance[1][0], + iMinusK[0][0] * covariance[0][1] + iMinusK[0][1] * covariance[1][1] + ), + doubleArrayOf( + iMinusK[1][0] * covariance[0][0] + iMinusK[1][1] * covariance[1][0], + iMinusK[1][0] * covariance[0][1] + iMinusK[1][1] * covariance[1][1] + ) + ) + // Minimale Kovarianz erzwingen, damit der Filter nie zu "sicher" wird + // und immer auf neue Messungen reagiert + val minCovariance = 0.1 + newCovariance[0][0] = newCovariance[0][0].coerceAtLeast(minCovariance) + newCovariance[1][1] = newCovariance[1][1].coerceAtLeast(minCovariance) + + covariance = newCovariance + } + + /** + * Gibt die aktuell geschätzte Position zurück. + * + * @return Die fusionierte Position als [Position]-Objekt. + */ + fun getPosition(): Position { + return Position(state[0], state[1]) + } +} diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/SensorFusionManager.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/SensorFusionManager.kt new file mode 100644 index 0000000..18f0342 --- /dev/null +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/positioning/SensorFusionManager.kt @@ -0,0 +1,189 @@ +package com.erik.bleindoorpositioning.positioning + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.util.Log +import kotlin.math.atan2 +import kotlin.math.sqrt + +/** + * Callback-Interface für Schritt-Events + * @param heading Bewegungsrichtung in Radiant (0 = Nord, π/2 = Ost) + * @param stepLength Schrittlänge in Metern + */ +fun interface StepListener { + fun onStep(heading: Double, stepLength: Double) +} + +/** + * SensorFusionManager für Pedestrian Dead Reckoning (PDR) + * + * Nutzt Android-Sensoren: + * - TYPE_STEP_DETECTOR: Schritterkennung + * - TYPE_ROTATION_VECTOR: Geräteausrichtung (Azimuth/Heading) + * - TYPE_LINEAR_ACCELERATION: Stillstandserkennung + dynamische Schrittlänge + * + * @param context Android Context für SensorManager-Zugriff + * @param listener Callback für Schritt-Events + */ +class SensorFusionManager( + context: Context, + private val listener: StepListener +) : SensorEventListener { + + private val sensorManager: SensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + + // Sensoren + private val stepDetector: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR) + private val rotationVector: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) + private val linearAcceleration: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION) + + val isAvailable: Boolean = (stepDetector != null && rotationVector != null && linearAcceleration != null) + + // State + private var currentHeading: Double = 0.0 // Azimuth in Radiant + private val accelerationWindow = ArrayDeque(ACCELERATION_WINDOW_SIZE) + private var lastAccelerationMagnitude: Float = 0f + + // Konfiguration + private val baseStepLength: Double = 0.7 // Meter + private val minStepLength: Double = 0.5 // Meter + private val maxStepLength: Double = 1.0 // Meter + + init { + if (!isAvailable) { + val missingSensors = mutableListOf() + if (stepDetector == null) missingSensors.add("TYPE_STEP_DETECTOR") + if (rotationVector == null) missingSensors.add("TYPE_ROTATION_VECTOR") + if (linearAcceleration == null) missingSensors.add("TYPE_LINEAR_ACCELERATION") + Log.w(TAG, "SensorFusionManager is not available. Missing sensors: ${missingSensors.joinToString()}") + } + } + + /** + * Startet Sensor-Listeners + */ + fun start() { + if (!isAvailable) return + stepDetector?.let { + sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_FASTEST) + } + rotationVector?.let { + sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_GAME) + } + linearAcceleration?.let { + sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_FASTEST) + } + } + + /** + * Stoppt Sensor-Listeners + */ + fun stop() { + if (!isAvailable) return + sensorManager.unregisterListener(this) + accelerationWindow.clear() + } + + override fun onSensorChanged(event: SensorEvent) { + when (event.sensor.type) { + Sensor.TYPE_STEP_DETECTOR -> handleStepDetected() + Sensor.TYPE_ROTATION_VECTOR -> handleRotationVector(event.values) + Sensor.TYPE_LINEAR_ACCELERATION -> handleLinearAcceleration(event.values) + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { + // Nicht benötigt für PDR + } + + /** + * Verarbeitet Schritt-Event + * Prüft Stillstand und berechnet dynamische Schrittlänge + */ + private fun handleStepDetected() { + // Stillstandserkennung: Varianz der Acceleration prüfen + if (isStationary()) { + return // Kein Step wenn Nutzer steht + } + + // Dynamische Schrittlänge basierend auf Acceleration-Magnitude + val stepLength = calculateStepLength(lastAccelerationMagnitude) + + listener.onStep(currentHeading, stepLength) + } + + /** + * Extrahiert Azimuth (Heading) aus Rotation Vector + * @param values Rotation Vector [x, y, z, optional scalar] + */ + private fun handleRotationVector(values: FloatArray) { + val rotationMatrix = FloatArray(9) + SensorManager.getRotationMatrixFromVector(rotationMatrix, values) + + val orientation = FloatArray(3) + SensorManager.getOrientation(rotationMatrix, orientation) + + // orientation[0] = Azimuth (um Z-Achse, -π bis π) + // 0 = Nord, π/2 = Ost, -π/2 = West, ±π = Süd + currentHeading = orientation[0].toDouble() + } + + /** + * Verarbeitet Linear Acceleration für Stillstandserkennung + * @param values [x, y, z] Acceleration in m/s² (ohne Gravitation) + */ + private fun handleLinearAcceleration(values: FloatArray) { + val magnitude = sqrt( + values[0] * values[0] + + values[1] * values[1] + + values[2] * values[2] + ) + + lastAccelerationMagnitude = magnitude + + // Sliding Window für Varianz-Berechnung + if (accelerationWindow.size >= ACCELERATION_WINDOW_SIZE) { + accelerationWindow.removeFirst() + } + accelerationWindow.addLast(magnitude) + } + + /** + * Prüft ob Nutzer stillsteht (geringe Varianz der Acceleration) + * @return true wenn stillstehend + */ + private fun isStationary(): Boolean { + if (accelerationWindow.size < ACCELERATION_WINDOW_SIZE) { + return false // Noch nicht genug Daten + } + + val mean = accelerationWindow.average() + val variance = accelerationWindow.map { (it - mean) * (it - mean) }.average() + + return variance < STATIONARY_VARIANCE_THRESHOLD + } + + /** + * Berechnet dynamische Schrittlänge basierend auf Acceleration + * Höhere Beschleunigung = längerer Schritt (Laufen vs. Gehen) + * @param accelerationMagnitude Betrag der Linear Acceleration in m/s² + * @return Schrittlänge in Metern (zwischen minStepLength und maxStepLength) + */ + private fun calculateStepLength(accelerationMagnitude: Float): Double { + // Mapping: 0-2 m/s² (Gehen) -> 0.5-0.7m, 2-5 m/s² (schnelles Gehen/Laufen) -> 0.7-1.0m + val normalized = (accelerationMagnitude / 5.0).coerceIn(0.0, 1.0) + val stepLength = baseStepLength + normalized * (maxStepLength - baseStepLength) + + return stepLength.coerceIn(minStepLength, maxStepLength) + } + + companion object { + private const val TAG = "SensorFusionManager" + private const val ACCELERATION_WINDOW_SIZE = 10 // Anzahl Samples für Varianz + private const val STATIONARY_VARIANCE_THRESHOLD = 0.05 // m²/s⁴ (experimentell anpassen) + } +} diff --git a/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/screens/PositioningScreen.kt b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/screens/PositioningScreen.kt index e45fc91..edef716 100644 --- a/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/screens/PositioningScreen.kt +++ b/App v2/app/src/main/java/com/erik/bleindoorpositioning/ui/screens/PositioningScreen.kt @@ -41,6 +41,9 @@ fun PositioningScreen( ) { val permissions = buildList { add(Manifest.permission.ACCESS_FINE_LOCATION) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + add(Manifest.permission.ACTIVITY_RECOGNITION) + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { add(Manifest.permission.BLUETOOTH_SCAN) add(Manifest.permission.BLUETOOTH_CONNECT) diff --git a/CLAUDE.md b/CLAUDE.md index 8392b3e..6067b96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ Minimiere Claude-Tokens: - eigenständig große Implementierungen schreiben - ganze Dateien neu ausgeben (außer Gemini liefert es als Diff) - lange Analysen/Prosatexte erzeugen - - “Best guess” ohne Gemini machen + - "Best guess" ohne Gemini machen 4) Kontext-Minimierung: - Öffne nur Dateien, die wirklich benötigt werden. @@ -79,35 +79,37 @@ D) Ausführen: ## ############################################################################################# -## Project Overview +## Projektübersicht -BLE Indoor Positioning system consisting of two components: -1. **Android App** (Kotlin/Compose) — estimates user position via RSSI-based trilateration. Active version in `App v2/`. -2. **ESP32 Firmware** (Arduino) — iBeacon sender on Seeed XIAO ESP32-C3. Active version in `ESP32 iBeacon Code/ESP32_iBeacon_v2/`. +BLE-basiertes Indoor-Positionierungssystem, bestehend aus zwei Komponenten: +1. **Android App** (Kotlin/Compose) — schätzt die Nutzerposition mittels RSSI-basierter Trilateration. Die aktive Version befindet sich in `App v2/`. **Die Version in `App v1_2/` ist eine Legacy-Version und soll NICHT bearbeitet werden.** +2. **ESP32 Firmware** (Arduino) — iBeacon-Sender auf einem Seeed XIAO ESP32-C3. Die aktive Version befindet sich in `ESP32 iBeacon Code/ESP32_iBeacon_v2/`. ## Build & Run -### Android App (from `App v2/`) +### Android App + +Alle Gradle-Kommandos müssen aus dem `App v2/`-Verzeichnis ausgeführt werden (`cd App v2/`). ```bash -./gradlew assembleDebug # Debug build -./gradlew test # Unit tests -./gradlew connectedAndroidTest # Instrumented tests (device/emulator required) -./gradlew clean assembleDebug # Clean build +./gradlew assembleDebug # Debug-Build +./gradlew test # Unit-Tests +./gradlew connectedAndroidTest # Instrumentierte Tests (Gerät/Emulator erforderlich) +./gradlew clean assembleDebug # Clean-Build -# Single test class +# Einzelne Testklasse ./gradlew test --tests "com.erik.bleindoorpositioning.ExampleUnitTest" ``` - Compile/Target SDK: 36, Min SDK: 26 -- Java 11 compatibility, Kotlin 2.0.21 with Compose compiler plugin +- Java 11-kompatibel, Kotlin 2.0.21 mit Compose Compiler-Plugin - AGP 9.0.0, Compose BOM 2024.09.00 -- Dependencies managed via `gradle/libs.versions.toml` -- Additional: kotlinx-serialization (JSON config persistence), material-icons-extended (Sonne/Mond Icons) +- Abhängigkeiten werden über `gradle/libs.versions.toml` verwaltet. +- Zusätzlich: kotlinx-serialization (Persistenz der JSON-Konfiguration), material-icons-extended (Sonne/Mond-Icons) ### ESP32 Firmware -Flash via Arduino IDE (Board: XIAO_ESP32C3). Before flashing each beacon, set `BEACON_MINOR` in `ESP32_iBeacon_v2.ino` to a unique ID (1, 2, 3, ...). +Flashen über die Arduino IDE (Board: XIAO_ESP32C3). Vor dem Flashen jedes Beacons muss `BEACON_MINOR` in `ESP32_iBeacon_v2.ino` auf eine eindeutige ID (1, 2, 3, ...) gesetzt werden. **ESP32-C3 Besonderheiten:** - Nutzt native USB-CDC → `delay(2000)` am Anfang von `setup()` nötig, damit Serial-Ausgaben nicht verloren gehen @@ -115,56 +117,82 @@ Flash via Arduino IDE (Board: XIAO_ESP32C3). Before flashing each beacon, set `B - Header: `esp_mac.h` statt `esp_system.h` (ESP-IDF v4.0+), `esp_bt_device.h` existiert nicht auf ESP32-C3 - Optional: `USE_CUSTOM_MAC` aktivieren falls ein Android-Gerät den Beacon nicht findet (MAC-Blacklisting-Workaround) -## Architecture +## Architektur ### Android App -**Data flow:** BLE Hardware → `BleScanner` → `BeaconFilterManager` (RSSI filtering + Ausreißer-Erkennung) → `PositioningCalculator` (Trilateration + Raumbegrenzung) → `PositionSmoother` (2D-EMA Glättung) → UI via `StateFlow` +**Datenfluss:** +``` +BLE Hardware → BleScanner → BeaconFilterManager (RSSI-Filterung + Ausreißer-Erkennung) + → PositionCalculator (Trilateration + Raumbegrenzung) → EKF update() ─┐ + ├→ EKF.getPosition() → PositionSmoother (2D-EMA) → UI via StateFlow +Step Detector → SensorFusionManager (PDR: Heading + Schrittlänge) → EKF predict() ─┘ +``` -Key layers (`app/src/main/java/com/erik/bleindoorpositioning/`): +Wichtige Layer (`app/src/main/java/com/erik/bleindoorpositioning/`): -- **`ble/`**: `BleScanner` parses iBeacon packets (Apple 0x4C00 manufacturer ID), validates UUID, extracts Minor/Major/RSSI. Filtert veraltete Beacons (>3s) vor Positionsberechnung aus. Auto-Restart alle 25 Min (Android BLE Scan-Limit). Exponential Backoff bei Scan-Fehlern. `reloadConfig()` lädt Config dynamisch neu. `BeaconData` holds default calibration constants (RSSI_AT_ONE_METER, PATH_LOSS_EXPONENT, BEACON_UUID). -- **`positioning/`**: `RssiFilter` interface with `KalmanFilter`/`ExponentialMovingAverageFilter`/`MovingAverageFilter` implementations. `BeaconFilterManager` manages one filter per beacon (keyed by Minor ID) with outlier rejection (Schwellwert: 20 dBm). `PositioningCalculator` runs a 3-stage pipeline: IDW weighted centroid (exponent=4.0) → Gauss-Newton trilateration (weight=1/d³) → adaptive blending based on minimum distance (0.5m=100% IDW, 1.5m=100% Gauss-Newton), mit Raumbegrenzung (Clamping auf dynamische Stockwerk-Grenzen aus Räumen+Beacons). Beacon-spezifische `rssiAtOneMeter` und `pathLossExponent` Werte. `PositionSmoother` applies 2D EMA (alpha=0.15) on the final position to reduce jumping. -- **`map/`**: `CoordinateMapper` transforms meters↔pixels with dynamic bounding box (supports negative coordinates). `FloorplanView` draws floor with multiple rooms, beacons, distance circles, and position on a Compose Canvas. Interactive: Pinch-to-Zoom (0.5x–5x), Pan, Double-Tap reset. Zoom-kompensierte Schrift/Borders. Dark Mode kompatible Farben via MaterialTheme. -- **`ui/`**: Tab-Navigation mit `MainScreen` (Positioning + Config Tabs). Dark/Light Mode Toggle (Sonne/Mond-Icon, persistent). `PositioningScreen`: Karte mit Vollbild-Modus, einklappbare Beacon-Liste, Scan-Kontrolle. `ConfigScreen`: Scrollbar mit einklappbaren Sektionen (Stockwerk, Räume, Beacons, Anlernen). Auto-Save bei Änderungen. Raum-Verwaltung (Add/Edit/Delete), Beacon-Verwaltung mit Detail-Dialog (Position, RSSI@1m, Path Loss Exponent), Beacon-Anlern-Funktion via BLE-Scan. -- **`data/`**: `AppConfig` (floorWidth/Height, rooms, beacons). `ConfigRepository` persistiert Config in SharedPreferences (JSON/kotlinx.serialization). Legacy-Migration für alte Configs. -- **`model/`**: `BeaconConfig` (position, rssiAtOneMeter, pathLossExponent), `RoomConfig` (name, position, size), `ScannedBeacon` (live scan data with `lastSeen` timestamp, uuid), `Position`/`PositionResult`. +- **`ble/`**: `BleScanner` parst iBeacon-Pakete (Apple 0x4C00 Manufacturer-ID), validiert die UUID und extrahiert Minor/Major/RSSI. Veraltete Beacons (>3s) werden vor der Positionsberechnung ausgefiltert. Auto-Restart alle 25 Min (Android BLE Scan-Limit). Exponentielles Backoff bei Scan-Fehlern. `reloadConfig()` lädt die Konfiguration dynamisch neu. `BeaconData` enthält die `BEACON_UUID` und Standard-Kalibrierungskonstanten. +- **`positioning/`**: `RssiFilter`-Interface mit `KalmanFilter`-, `ExponentialMovingAverageFilter`- und `MovingAverageFilter`-Implementierungen. Aktuell wird `MovingAverageFilter(windowSize=20)` verwendet. Der `BeaconFilterManager` verwaltet einen Filter pro Beacon (Schlüssel: Minor ID) mit Ausreißer-Erkennung (Schwellwert: 20 dBm). `PositionCalculator` führt eine 3-stufige Pipeline aus: IDW Weighted Centroid (Exponent=4.0) → Gauss-Newton-Trilateration (Gewicht=1/d³) → adaptives Blending basierend auf der minimalen Distanz (0.5m=100% IDW, 1.5m=100% Gauss-Newton), mit Raumbegrenzung (Clamping auf dynamische Stockwerk-Grenzen). Er verwendet Beacon-spezifische Kalibrierungswerte (siehe Sektion "Kalibrierungsparameter"). `PositionSmoother` wendet einen 2D-EMA (alpha=0.25) auf die finale Position an, um Sprünge zu reduzieren. `PositionKalmanFilter` implementiert einen Extended Kalman Filter (EKF) zur Sensorfusion: `predict()` mit PDR-Schritten, `update()` mit BLE-Positionen (Zustand=[x,y], Prozessrauschen=0.5, Messrauschen=2.0, minimale Kovarianz=0.1 um Einfrieren über Zeit zu verhindern). `SensorFusionManager` ist **optional** und stellt PDR (Pedestrian Dead Reckoning) über Android-Sensoren (`TYPE_STEP_DETECTOR`, `TYPE_ROTATION_VECTOR`, `TYPE_LINEAR_ACCELERATION`) bereit. Er verfügt über Stillstandserkennung (Varianz-Schwellwert=0.05, Fenster=10 Samples) und eine dynamische Schrittlängenschätzung (0.5–1.0m). Sensor-Delays: `SENSOR_DELAY_FASTEST` für Step Detector und Linear Acceleration, `SENSOR_DELAY_GAME` für Rotation Vector. **Graceful Degradation:** Wenn die benötigten Sensoren auf dem Gerät nicht verfügbar sind, läuft die App in einem reinen BLE-Modus ohne Schrittverfolgung. Die Verfügbarkeit der Sensoren wird über die `isAvailable`-Property geprüft. +- **`map/`**: `CoordinateMapper` transformiert Meter↔Pixel mit einer dynamischen Bounding Box (unterstützt negative Koordinaten). `FloorplanView` zeichnet den Grundriss mit mehreren Räumen, Beacons, Distanzkreisen und der Position auf einer Compose Canvas. Interaktiv: Pinch-to-Zoom (0.5x–5x), Schwenken, Doppeltipp zum Zurücksetzen. Zoom-kompensierte Schriftgrößen und Strichstärken. Dark-Mode-kompatible Farben über MaterialTheme. +- **`ui/`**: Tab-Navigation mit `MainScreen` (Positionierungs- und Konfigurations-Tabs). Dark/Light-Mode-Umschalter (Sonne/Mond-Icon, persistent). `PositioningScreen`: Karte mit Vollbild-Modus, einklappbare Beacon-Liste, Scan-Kontrolle. `ConfigScreen`: Scrollbar mit einklappbaren Sektionen (Stockwerk, Räume, Beacons, Anlernen). Auto-Save bei Änderungen. Raum-Verwaltung (Hinzufügen/Bearbeiten/Löschen), Beacon-Verwaltung mit Detail-Dialog (Position, RSSI @1m, Path Loss Exponent), Beacon-Anlern-Funktion via BLE-Scan. +- **`data/`**: `AppConfig` (floorWidth/Height, rooms, beacons). `ConfigRepository` persistiert die Konfiguration in den SharedPreferences (JSON/kotlinx.serialization). Inklusive Legacy-Migration für alte Konfigurationsformate. +- **`model/`**: `BeaconConfig` (Position, rssiAtOneMeter, pathLossExponent), `RoomConfig` (Name, Position, Größe), `ScannedBeacon` (Live-Scan-Daten mit `lastSeen`-Zeitstempel, UUID), `Position`/`PositionResult`. -`MainActivity` creates `BleScanner` + `ConfigRepository` and passes them to `MainScreen`. Lifecycle-Handling: onPause stoppt Scan, onResume startet wieder. No DI framework — direct instantiation. All state is reactive via Kotlin `StateFlow`, observed in Compose with `collectAsState()`. Material 3 Theme mit Schwarz/Lila Farbschema. +`MainActivity` erzeugt `BleScanner` und `ConfigRepository` und übergibt sie an `MainScreen`. Lifecycle-Handling: `onPause` stoppt den Scan, `onResume` startet ihn wieder. Kein DI-Framework — direkte Instanziierung. Der gesamte Zustand ist reaktiv über Kotlin `StateFlow` und wird in Compose mit `collectAsState()` beobachtet. Material 3 Theme mit einem Schwarz/Lila-Farbschema. ### ESP32 Firmware -Pure iBeacon advertiser (no GATT service, no connections). Key configuration: -- Advertising interval: 100ms (fixed) -- TX Power: +9 dBm (maximum for ESP32-C3), set via `esp_ble_tx_power_set()` for both ADV and DEFAULT -- Signal Power: -65 dBm @ 1m (must match `RSSI_AT_ONE_METER` in Android app) -- Advertising Flags: 0x06 (LE General Discoverable + BR/EDR Not Supported) -- BLE Sleep enabled between advertising events for battery operation -- `BEACON_MINOR` identifies each physical beacon — must match beacon Minor ID in the app's Config tab -- Optional MAC-Override (`USE_CUSTOM_MAC`) for Android BLE-Blacklisting workaround +Reiner iBeacon-Advertiser (kein GATT-Service, keine Verbindungen). Wichtige Konfiguration: +- Advertising-Intervall: 100ms (fest) +- TX Power: +9 dBm (Maximum für ESP32-C3), gesetzt über `esp_ble_tx_power_set()` für ADV und DEFAULT +- Signal Power: -65 dBm @ 1m (muss mit `rssiAtOneMeter` in der Android-App übereinstimmen) +- Advertising-Flags: 0x06 (LE General Discoverable + BR/EDR Not Supported) +- BLE Sleep zwischen den Advertising-Events für Batteriebetrieb aktiviert +- `BEACON_MINOR` identifiziert jeden physischen Beacon — muss mit der Beacon Minor ID in der App-Konfiguration übereinstimmen +- Optionaler MAC-Override (`USE_CUSTOM_MAC`) als Workaround für Android BLE-Blacklisting -## Calibration Parameters +## Kalibrierungsparameter ### Android App -Beacon-Kalibrierung ist jetzt **pro Beacon konfigurierbar** im Config-Tab der App: -- `rssiAtOneMeter`: Measured RSSI at 1m distance (default: -65.0 dBm) -- `pathLossExponent`: Environment-dependent (default: 5.5; free-space=2.0, indoor=2.5-5.5) -- Position (x, y) in meters — supports negative coordinates +Die Beacon-Kalibrierung ist **pro Beacon konfigurierbar** im Config-Tab der App: +- `rssiAtOneMeter`: Gemessener RSSI bei 1m Abstand (Standard: -65.0 dBm) +- `pathLossExponent`: Umgebungsabhängig (Standard: 5.5; Freifeld=2.0, Innenraum=2.5-5.5) +- Position (x, y) in Metern — unterstützt negative Koordinaten -Globale Defaults in `BeaconData.kt`: -- `RSSI_AT_ONE_METER`: Default -65.0 dBm -- `PATH_LOSS_EXPONENT`: Default 5.5 -- `BEACON_UUID`: Must match UUID on ESP32 beacons (`87b99b2c-9080-4d04-8f00-a5d9a3f9a0e7`) +Globale Standardwerte in `BeaconData.kt`: +- `RSSI_AT_ONE_METER`: Standardwert -65.0 dBm +- `PATH_LOSS_EXPONENT`: Standardwert 5.5 +- `BEACON_UUID`: Muss mit der UUID auf den ESP32-Beacons übereinstimmen (`87b99b2c-9080-4d04-8f00-a5d9a3f9a0e7`) Stockwerk-Konfiguration (Config-Tab): - `floorWidth` / `floorHeight`: Stockwerk-Dimensionen in Metern - `rooms`: Liste von Räumen (Name, Position, Größe) — beliebig viele, auch an negativen Koordinaten ### ESP32 (ESP32_iBeacon_v2.ino) -- `SIGNAL_POWER_1M`: Must match `RSSI_AT_ONE_METER` in the Android app (currently 0xBF = -65 dBm) -- `BEACON_MINOR`: Unique per beacon, must match beacon Minor ID configured in the app's Config tab +- `SIGNAL_POWER_1M`: Muss mit `rssiAtOneMeter` in der Android-App übereinstimmen (aktuell 0xBF = -65 dBm) +- `BEACON_MINOR`: Eindeutig pro Beacon, muss mit der Beacon Minor ID übereinstimmen, die im Config-Tab der App konfiguriert ist. -## Known Issues / Troubleshooting +## Tuning-Parameter (Positionierungspipeline) + +Die Positionierung durchläuft mehrere Glättungsstufen. Beim Tuning beachten: + +**Pipeline:** RSSI → MovingAverageFilter → PositionCalculator → EKF → PositionSmoother → UI + +| Parameter | Datei | Aktuell | Effekt | +|---|---|---|---| +| `MovingAverageFilter(windowSize)` | BleScanner.kt | 20 | Mehr = stabilere RSSI, aber trägere Reaktion | +| `BeaconFilterManager(outlierThreshold)` | BleScanner.kt | 20.0 dBm | Höher = mehr RSSI-Sprünge erlaubt | +| `PositionKalmanFilter(processNoise)` | BleScanner.kt | 0.5 | Höher = EKF folgt BLE schneller, PDR weniger Gewicht | +| `positionKalmanFilter.update(measurementNoise)` | BleScanner.kt | 2.0 | Niedriger = mehr Vertrauen in BLE-Position | +| `PositionSmoother(alpha)` | BleScanner.kt | 0.25 | Höher = reaktiver, niedriger = glatter | +| `BEACON_MAX_AGE_MS` | BleScanner.kt | 3000 | Kürzer = weniger Einfluss veralteter Beacons | +| `minCovariance` | PositionKalmanFilter.kt | 0.1 | Verhindert EKF-Einfrieren über Zeit | +| `STATIONARY_VARIANCE_THRESHOLD` | SensorFusionManager.kt | 0.05 | Niedriger = weniger Schritte als Stillstand gefiltert | +| `ACCELERATION_WINDOW_SIZE` | SensorFusionManager.kt | 10 | Weniger = schnellere Stillstandserkennung | + +**Bekanntes Problem:** Zu viele Glättungsstufen hintereinander können die Position extrem träge machen. Beim Debugging einzelne Stufen testweise deaktivieren (EKF bypass / Smoother bypass), um die Ursache zu isolieren. Der EKF kann über Zeit einfrieren wenn die Kovarianz zu klein wird — daher `minCovariance`-Clamp. + +## Bekannte Probleme / Fehlerbehebung ### ESP32 Beacon nicht von Android erkannt Wenn ein ESP32-C3 Beacon auf einem bestimmten Android-Gerät nicht erkannt wird (auch nicht in nRF Connect), aber auf anderen Geräten sichtbar ist: @@ -176,6 +204,6 @@ Wenn ein ESP32-C3 Beacon auf einem bestimmten Android-Gerät nicht erkannt wird ### ESP32-C3 Serial Monitor leer Der XIAO ESP32-C3 nutzt native USB-CDC. Frühe `Serial.print()`-Aufrufe gehen verloren. Fix: `delay(2000)` nach `Serial.begin()`. -## Language +## Sprache -The codebase, comments, and documentation are in German. Respond in German when working with this project. +Codebasis, Kommentare und Dokumentation sind auf Deutsch. Antworte auf Deutsch, wenn du mit diesem Projekt arbeitest.