Update vcs.xml, AndroidManifest.xml, BleScanner.kt, and 4 more files

Sensor Fusion
This commit is contained in:
erik committed 2026-02-14 01:58:04 +01:00
1 parent 4426388063
commit 4303d34471
7 files changed
+499 -58

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
+1
View File
@@ -18,6 +18,7 @@
<!-- Für BLE-Positionierung benötigt--> <!-- Für BLE-Positionierung benötigt-->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
@@ -14,15 +14,18 @@ import com.erik.bleindoorpositioning.model.PositionResult
import com.erik.bleindoorpositioning.model.ScannedBeacon import com.erik.bleindoorpositioning.model.ScannedBeacon
import com.erik.bleindoorpositioning.positioning.BeaconFilterManager import com.erik.bleindoorpositioning.positioning.BeaconFilterManager
import com.erik.bleindoorpositioning.positioning.KalmanFilter import com.erik.bleindoorpositioning.positioning.KalmanFilter
import com.erik.bleindoorpositioning.positioning.MovingAverageFilter
import com.erik.bleindoorpositioning.positioning.PositionCalculator import com.erik.bleindoorpositioning.positioning.PositionCalculator
import com.erik.bleindoorpositioning.positioning.PositionKalmanFilter
import com.erik.bleindoorpositioning.positioning.PositionSmoother import com.erik.bleindoorpositioning.positioning.PositionSmoother
import com.erik.bleindoorpositioning.positioning.SensorFusionManager
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.TimeUnit 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 { companion object {
private const val TAG = "BleScanner" private const val TAG = "BleScanner"
@@ -52,18 +55,34 @@ class BleScanner(context: Context, private val configRepository: ConfigRepositor
private var retryAttempt = 0 private var retryAttempt = 0
// RSSI-Filterung // RSSI-Filterung
private val filterManager = BeaconFilterManager { private val filterManager = BeaconFilterManager { // Filter für einzelne Beacon-RSSI-Werte
KalmanFilter( MovingAverageFilter(windowSize = 20) // 20 Messpunkte für stabile Mittelwertbildung
processNoise = 0.5, // TODO: Kalibrieren
measurementNoise = 10.0 // TODO: Kalibrieren
)
} }
// Positionsberechnung (wird bei jedem Scan-Start neu initialisiert) // Positionsberechnung (wird bei jedem Scan-Start neu initialisiert)
private var positionCalculator: PositionCalculator? = null private var positionCalculator: PositionCalculator? = null
// Positions-Glättung // 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 // Öffentliche State Flows
private val _beacons = MutableStateFlow<Map<Int, ScannedBeacon>>(emptyMap()) private val _beacons = MutableStateFlow<Map<Int, ScannedBeacon>>(emptyMap())
@@ -109,10 +128,18 @@ class BleScanner(context: Context, private val configRepository: ConfigRepositor
val rssiMap = freshBeacons.mapValues { it.value.filteredRssi } val rssiMap = freshBeacons.mapValues { it.value.filteredRssi }
val rawResult = positionCalculator?.calculate(rssiMap) val rawResult = positionCalculator?.calculate(rssiMap)
// Position glätten // Sensor-Fusion: BLE dominiert, PDR ergänzt zwischen Updates
_position.value = rawResult?.let { raw -> rawResult?.let { raw ->
val smoothed = positionSmoother.update(raw.position) synchronized(lock) {
raw.copy(position = smoothed) 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}: " + Log.d(TAG, "Beacon ${beacon.minor}: " +
@@ -211,6 +238,7 @@ class BleScanner(context: Context, private val configRepository: ConfigRepositor
scanner?.startScan(null, settings, scanCallback) scanner?.startScan(null, settings, scanCallback)
_isScanning.value = true _isScanning.value = true
sensorFusionManager?.start()
Log.d(TAG, "Scanning started") Log.d(TAG, "Scanning started")
// Start proactive scan restart job // Start proactive scan restart job
@@ -238,6 +266,8 @@ class BleScanner(context: Context, private val configRepository: ConfigRepositor
val config = configRepository.loadConfig() val config = configRepository.loadConfig()
if (config.beacons.size < 3) { if (config.beacons.size < 3) {
Log.w(TAG, "Weniger als 3 Beacons konfiguriert, Positionsberechnung nicht möglich") Log.w(TAG, "Weniger als 3 Beacons konfiguriert, Positionsberechnung nicht möglich")
positionCalculator = null
return
} }
positionCalculator = PositionCalculator( positionCalculator = PositionCalculator(
rssiAtOneMeter = BeaconData.RSSI_AT_ONE_METER, rssiAtOneMeter = BeaconData.RSSI_AT_ONE_METER,
@@ -262,7 +292,12 @@ class BleScanner(context: Context, private val configRepository: ConfigRepositor
retryAttempt = 0 retryAttempt = 0
_isScanning.value = false _isScanning.value = false
filterManager.reset() filterManager.reset()
synchronized(lock) {
positionSmoother.reset() positionSmoother.reset()
positionKalmanFilter.reset()
isEkfInitialized = false
}
sensorFusionManager?.stop()
Log.d(TAG, "Scanning stopped") Log.d(TAG, "Scanning stopped")
} }
} }
@@ -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])
}
}
@@ -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<Float>(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<String>()
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)
}
}
@@ -41,6 +41,9 @@ fun PositioningScreen(
) { ) {
val permissions = buildList { val permissions = buildList {
add(Manifest.permission.ACCESS_FINE_LOCATION) 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
add(Manifest.permission.BLUETOOTH_SCAN) add(Manifest.permission.BLUETOOTH_SCAN)
add(Manifest.permission.BLUETOOTH_CONNECT) add(Manifest.permission.BLUETOOTH_CONNECT)
+75 -47
View File
@@ -30,7 +30,7 @@ Minimiere Claude-Tokens:
- eigenständig große Implementierungen schreiben - eigenständig große Implementierungen schreiben
- ganze Dateien neu ausgeben (außer Gemini liefert es als Diff) - ganze Dateien neu ausgeben (außer Gemini liefert es als Diff)
- lange Analysen/Prosatexte erzeugen - lange Analysen/Prosatexte erzeugen
- “Best guess” ohne Gemini machen - "Best guess" ohne Gemini machen
4) Kontext-Minimierung: 4) Kontext-Minimierung:
- Öffne nur Dateien, die wirklich benötigt werden. - Ö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: BLE-basiertes Indoor-Positionierungssystem, bestehend aus zwei Komponenten:
1. **Android App** (Kotlin/Compose) — estimates user position via RSSI-based trilateration. Active version in `App v2/`. 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 on Seeed XIAO ESP32-C3. Active version in `ESP32 iBeacon Code/ESP32_iBeacon_v2/`. 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 ## 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 ```bash
./gradlew assembleDebug # Debug build ./gradlew assembleDebug # Debug-Build
./gradlew test # Unit tests ./gradlew test # Unit-Tests
./gradlew connectedAndroidTest # Instrumented tests (device/emulator required) ./gradlew connectedAndroidTest # Instrumentierte Tests (Gerät/Emulator erforderlich)
./gradlew clean assembleDebug # Clean build ./gradlew clean assembleDebug # Clean-Build
# Single test class # Einzelne Testklasse
./gradlew test --tests "com.erik.bleindoorpositioning.ExampleUnitTest" ./gradlew test --tests "com.erik.bleindoorpositioning.ExampleUnitTest"
``` ```
- Compile/Target SDK: 36, Min SDK: 26 - 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 - AGP 9.0.0, Compose BOM 2024.09.00
- Dependencies managed via `gradle/libs.versions.toml` - Abhängigkeiten werden über `gradle/libs.versions.toml` verwaltet.
- Additional: kotlinx-serialization (JSON config persistence), material-icons-extended (Sonne/Mond Icons) - Zusätzlich: kotlinx-serialization (Persistenz der JSON-Konfiguration), material-icons-extended (Sonne/Mond-Icons)
### ESP32 Firmware ### 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:** **ESP32-C3 Besonderheiten:**
- Nutzt native USB-CDC → `delay(2000)` am Anfang von `setup()` nötig, damit Serial-Ausgaben nicht verloren gehen - 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 - 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) - Optional: `USE_CUSTOM_MAC` aktivieren falls ein Android-Gerät den Beacon nicht findet (MAC-Blacklisting-Workaround)
## Architecture ## Architektur
### Android App ### 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). - **`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 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. - **`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` 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. - **`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` (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. - **`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 Config in SharedPreferences (JSON/kotlinx.serialization). Legacy-Migration für alte Configs. - **`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, size), `ScannedBeacon` (live scan data with `lastSeen` timestamp, uuid), `Position`/`PositionResult`. - **`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 ### ESP32 Firmware
Pure iBeacon advertiser (no GATT service, no connections). Key configuration: Reiner iBeacon-Advertiser (kein GATT-Service, keine Verbindungen). Wichtige Konfiguration:
- Advertising interval: 100ms (fixed) - Advertising-Intervall: 100ms (fest)
- TX Power: +9 dBm (maximum for ESP32-C3), set via `esp_ble_tx_power_set()` for both ADV and DEFAULT - 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 (must match `RSSI_AT_ONE_METER` in Android app) - Signal Power: -65 dBm @ 1m (muss mit `rssiAtOneMeter` in der Android-App übereinstimmen)
- Advertising Flags: 0x06 (LE General Discoverable + BR/EDR Not Supported) - Advertising-Flags: 0x06 (LE General Discoverable + BR/EDR Not Supported)
- BLE Sleep enabled between advertising events for battery operation - BLE Sleep zwischen den Advertising-Events für Batteriebetrieb aktiviert
- `BEACON_MINOR` identifies each physical beacon — must match beacon Minor ID in the app's Config tab - `BEACON_MINOR` identifiziert jeden physischen Beacon — muss mit der Beacon Minor ID in der App-Konfiguration übereinstimmen
- Optional MAC-Override (`USE_CUSTOM_MAC`) for Android BLE-Blacklisting workaround - Optionaler MAC-Override (`USE_CUSTOM_MAC`) als Workaround für Android BLE-Blacklisting
## Calibration Parameters ## Kalibrierungsparameter
### Android App ### Android App
Beacon-Kalibrierung ist jetzt **pro Beacon konfigurierbar** im Config-Tab der App: Die Beacon-Kalibrierung ist **pro Beacon konfigurierbar** im Config-Tab der App:
- `rssiAtOneMeter`: Measured RSSI at 1m distance (default: -65.0 dBm) - `rssiAtOneMeter`: Gemessener RSSI bei 1m Abstand (Standard: -65.0 dBm)
- `pathLossExponent`: Environment-dependent (default: 5.5; free-space=2.0, indoor=2.5-5.5) - `pathLossExponent`: Umgebungsabhängig (Standard: 5.5; Freifeld=2.0, Innenraum=2.5-5.5)
- Position (x, y) in meters — supports negative coordinates - Position (x, y) in Metern — unterstützt negative Koordinaten
Globale Defaults in `BeaconData.kt`: Globale Standardwerte in `BeaconData.kt`:
- `RSSI_AT_ONE_METER`: Default -65.0 dBm - `RSSI_AT_ONE_METER`: Standardwert -65.0 dBm
- `PATH_LOSS_EXPONENT`: Default 5.5 - `PATH_LOSS_EXPONENT`: Standardwert 5.5
- `BEACON_UUID`: Must match UUID on ESP32 beacons (`87b99b2c-9080-4d04-8f00-a5d9a3f9a0e7`) - `BEACON_UUID`: Muss mit der UUID auf den ESP32-Beacons übereinstimmen (`87b99b2c-9080-4d04-8f00-a5d9a3f9a0e7`)
Stockwerk-Konfiguration (Config-Tab): Stockwerk-Konfiguration (Config-Tab):
- `floorWidth` / `floorHeight`: Stockwerk-Dimensionen in Metern - `floorWidth` / `floorHeight`: Stockwerk-Dimensionen in Metern
- `rooms`: Liste von Räumen (Name, Position, Größe) — beliebig viele, auch an negativen Koordinaten - `rooms`: Liste von Räumen (Name, Position, Größe) — beliebig viele, auch an negativen Koordinaten
### ESP32 (ESP32_iBeacon_v2.ino) ### ESP32 (ESP32_iBeacon_v2.ino)
- `SIGNAL_POWER_1M`: Must match `RSSI_AT_ONE_METER` in the Android app (currently 0xBF = -65 dBm) - `SIGNAL_POWER_1M`: Muss mit `rssiAtOneMeter` in der Android-App übereinstimmen (aktuell 0xBF = -65 dBm)
- `BEACON_MINOR`: Unique per beacon, must match beacon Minor ID configured in the app's Config tab - `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 ### 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: 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 ### 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()`. 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.