Update MEMORY.md, MEMORY.md, bachelor-thesis-summary.md, and 76 more files

This commit is contained in:
erik committed 2026-02-13 21:07:55 +01:00
commit 4426388063
79 files changed
+6169

No files matched your search

@@ -0,0 +1,37 @@
# BLE Indoor Positioning - Bachelor Thesis Summary Agent Memory
## Projekt-Kontext
- Bachelorarbeit an einer Fachhochschule, ca. 50 Seiten
- Thema: BLE-basierte Indoor-Lokalisierung mit ESP32 iBeacons + Android-App (Kotlin)
- Forschungsfrage: Zuverlaessigkeit BLE-basierter Indoor-Navigation fuer sehbehinderte Personen
- Fokus: Elektrotechnische Betrachtung, RSSI-Modellierung, Machbarkeitsstudie
- Abgrenzung: Keine sicherheitskritische/medizinische Anwendung
## Technische Architektur (Key Details)
- 5-stufige Pipeline: BleScanner -> BeaconFilterManager -> PositionCalculator -> PositionSmoother -> UI
- RSSI-Filter: Kalman (primaer), EMA, MovingAverage als Alternativen (RssiFilter-Interface)
- Positionsberechnung: IDW (Exponent 4) -> Gauss-Newton -> Blending (0.5-1.5m Uebergang)
- Ausreisser-Schwellwert: 20 dBm; Beacon-Frische: 3s
- Kalibrierung: RSSI_AT_ONE_METER=-65 dBm, PATH_LOSS_EXPONENT=5.5
- Raum: 3.5x4.5m, 3 Beacons
## Akademische Konventionen
- Expose-Struktur: Projektvorstellung (~40%) -> Umsetzungskonzept (~50%) -> Fazit/Ausblick (~10%)
- Sprache: Deutsch, akademisch aber verstaendlich, keine Umlaute (ASCII-kompatibel fuer Textdateien)
- Umfang einseitige Zusammenfassung: ca. 400-500 Woerter
- Keine Code-Snippets in Zusammenfassungen, technische Aspekte in Prosa
## Gliederung der Bachelorarbeit
1. Einleitung & Motivation (4-5 S.)
2. Grundlagen (10-12 S.)
3. Stand der Technik (8-9 S.)
4. Systemkonzept (8-9 S.)
5. Implementierung (7-8 S.)
6. Evaluation & Messergebnisse (8-9 S.)
7. Fazit & Ausblick (3-4 S.)
## Lessons Learned
- Quellcode ist komplett deutsch kommentiert
- Kein DI-Framework, direkte Instanziierung in MainActivity
- StateFlow fuer reaktive UI-Updates (Compose)
- Expose als reine Textdatei (kein Markdown) mit ASCII-Umlauten (ae, oe, ue, ss)
@@ -0,0 +1,118 @@
# Gemini-Delegator Memory
## Bug-Patterns
### State-Initialisierung bei Scan-Start
**Problem identifiziert:** In BleScanner wurde `PositionCalculator` nur beim `startScanning()` initialisiert. Config-Änderungen während eines laufenden Scans wurden nicht übernommen.
**Lösung:** Extrahiere Initialisierungs-Logik in separate Methode (`initializePositionCalculator()`), biete öffentliche `reloadConfig()` Methode an. UI ruft diese nach Config-Änderung auf.
**Pattern:** Bei Langlebigen Objekten (Scanner) die State von kurzlebiger Config abhängt → Reload-Mechanismus vorsehen.
### KeyboardType für negative Zahlen
**Problem:** `KeyboardType.Decimal` und `KeyboardType.Number` erlauben in Android Compose **kein Minuszeichen**.
**Lösung:** `KeyboardType.Text` für Felder verwenden, die negative Werte akzeptieren müssen (Koordinaten, RSSI). Validierung via `toDoubleOrNull()` bleibt bestehen.
**Betroffene Felder:** Beacon-Koordinaten (X/Y), Raum-Positionen (X/Y), RSSI-Werte (immer negativ).
## Hardware-Debugging
### BLE-Unsichtbarkeit (ESP32 + Android)
**Problem:** Ein spezifischer ESP32-C3 wird auf einem spezifischen Android-Handy NICHT gefunden (auch nicht mit nRF Connect), auf anderen Handys aber schon. Andere ESP32s funktionieren auf dem problematischen Handy.
**Ursache (wahrscheinlich):** MAC-Adress-Kollision oder Android BLE-Controller-Blacklisting auf Hardware-Level.
**Diagnose-Schritte:**
1. Original BLE-MAC auslesen (`esp_bt_dev_get_address()` nach `BLEDevice::init()`)
2. Mit MAC auf funktionierendem Handy (nRF Connect) vergleichen
3. Custom-MAC setzen via `esp_base_mac_addr_set()` **VOR** `BLEDevice::init()`
**Lösung:** Custom-MAC flashen mit locally administered Address (Byte 0 = `0x02`), eindeutige Werte wählen.
**Code-Pattern (ESP32):**
```cpp
#include "esp_bt_device.h" // Für MAC-Ausgabe
#include "esp_mac.h" // Für MAC-Setzen
uint8_t custom_mac[6] = {0x02, 0xAA, 0xBB, 0xCC, 0xDD, 0x05};
esp_base_mac_addr_set(custom_mac); // VOR BLEDevice::init()
BLEDevice::init("Name");
const uint8_t* ble_mac = esp_bt_dev_get_address(); // Ausgeben
```
**Quellen:** [Random Nerd Tutorials](https://randomnerdtutorials.com/get-change-esp32-esp8266-mac-address-arduino/), [Nordic DevZone BLE Invisibility](https://devzone.nordicsemi.com/f/nordic-q-a/77366/nrf-ble-android---my-blescannercompat-doesn-t-detect-a-device-but-nrf-connect-does)
## Projekt-Spezifisches
### BLE Indoor Positioning App
- **Aktive Version:** `/App v2/` (vorher `/App v1_2/`)
- **Config-Persistierung:** SharedPreferences via `ConfigRepository`
- **Datenfluss:** ConfigRepository → BleScanner → PositionCalculator → UI
- **Keine DI:** Direkte Instanziierung, StateFlow für Reaktivität
- **Sprache:** Kotlin, Compose UI, Kommentare auf Deutsch
- **Koordinaten:** Beliebige Werte möglich (auch negativ) – Bounding Box wird aus Räumen + Beacons berechnet
### Kritische Klassen
- `BleScanner`: Zentrale BLE-Logik, verwaltet Scanner, Filter, Calculator, Smoother
- `PositionCalculator`: Trilateration (IDW + Gauss-Newton + Blending)
- `ConfigRepository`: Lädt/Speichert AppConfig (Raumgröße + Beacon-Positionen)
- `BeaconFilterManager`: Verwaltet RSSI-Filter pro Beacon (Kalman/EMA/MA)
### Häufige Fehlerquellen
- Config-Änderungen während laufender Operationen
- Initialisierung nur bei Start ohne Reload-Mechanismus
- Fehlende Synchronisation zwischen UI-State und Scanner-State
### Config-Modell Pattern (erweitert)
**Gelernt bei:** Individuelle pathLossExponent + rssiAtOneMeter pro Beacon
- `@Serializable data class` mit Default-Werten → Abwärtskompatibilität (alte Configs ohne neue Felder laden mit Defaults)
- Neue Felder in **beiden** Dialogen:
- **AddBeaconDialog:** Neue Beacons (Default-Werte vorbelegen: `mutableStateOf("5.5")`)
- **EditBeaconDialog:** Bearbeitung (beacon.field.toString() initialisieren)
- Config-Anzeige: LazyColumn erweitern (neue Felder anzeigen)
- Beacon-Liste klickbar: `.clickable { showEditBeaconDialog = beacon }`
- Berechnungs-Methoden: Per-Beacon-Lookup mit Fallback → `beacons.find { it.minor == beaconMinor }?.field ?: globalDefault`
## Gemini-Interaktion
### Rate Limits
Gemini: 429 RESOURCE_EXHAUSTED möglich, liefert oft trotzdem Ergebnisse nach Retries (am Ende der Ausgabe).
### Gemini Prompt Best Practices
- Kontext vollständig angeben (relevante Code-Ausschnitte mit Line-Nummern)
- Aufgabe strukturiert (nummeriert), Dateipfade explizit
- Output-Format: "OUTPUT ONLY unified diff, no explanations"
### Wann selbst fixen vs. Gemini
- **Selbst:** Klare Anforderung, minimale Änderung (z.B. KeyboardType-Wechsel)
- **Gemini:** Große Refactorings, komplexe Algorithmen
- **Nach Gemini:** IMMER fachlich prüfen (Typ, Logik, Patterns)
### Gemini Fehler-Pattern
**Problem:** Gemini generiert Diffs mit falschen Annahmen über Datenstrukturen
- **Beispiel (2026-02-10):** Negative Koordinaten – Gemini verwendete `.corners` (nicht existent) statt `.x/.y/.width/.height`
- **Lösung:** Bei strukturellen Änderungen (Datenmodell, API) relevante Klassen vollständig in Prompt inkludieren
- **Warnung:** Gemini erfindet manchmal Pfade (`com.example` statt `com.erik`) → Diff-Pfade immer prüfen
### Color-Parameter in @Composable mit Canvas (2026-02-10)
**Problem:** Gemini versuchte `beaconColor: Color = MaterialTheme.colorScheme.primary` als Default-Parameter in Funktionssignatur zu setzen – funktioniert NICHT, da kein @Composable-Context in Parameter-Defaults verfügbar ist.
**Lösung:** Color-Parameter MIT Defaults direkt in der @Composable-Funktion deklarieren (wo MaterialTheme verfügbar ist). Bei Helper-Funktionen (z.B. drawRooms) Parameter ohne Defaults durchreichen.
**Korrektur:** Nach Gemini-Output selbst gefixt – Color-Parameter in FloorplanView mit MaterialTheme-Defaults versehen, drawRooms()-Signatur manuell erweitert.
### Material Icons Verfügbarkeit (2026-02-10)
**Problem:** Gemini generiert oft nicht-existierende Material Icons (z.B. `DarkMode`, `LightMode`, `Brightness4`, `Brightness7`, `Nightlight`, `WbSunny`).
**Lösung:** Nach Gemini-Output IMMER Icon-Verfügbarkeit prüfen:
- Nur Icons aus `Icons.Default.*` verwenden, die in bestehenden Dateien bereits verwendet werden (z.B. `Settings`, `Place`, `Info`)
- Falls spezielle Icons benötigt werden: Im Projekt nach verwendeten Icons greppen (`Icons\.Default\.`)
- Fallback: Generische Icons wie `Info`, `Check`, `Star` verwenden
**Warnung:** `Icons.Outlined`/`Icons.Filled` sind oft nicht vollständig in älteren Material Icons Versionen verfügbar.
### remember() außerhalb @Composable (2026-02-10)
**Problem:** Gemini verwendete `var isDarkMode by remember { mutableStateOf(...) }` in `MainActivity.onCreate()` – `remember` funktioniert NICHT außerhalb von `@Composable` Funktionen.
**Lösung:** Direkt `mutableStateOf()` als Property verwenden (`private var isDarkMode by mutableStateOf(false)`) oder `rememberSaveable` INNERHALB `setContent { }` Block nutzen.
+108
View File
@@ -0,0 +1,108 @@
---
name: bachelor-thesis-summary
description: "Use this agent when the user needs a structured, one-page summary of a Bachelor thesis project, including key points, implementation concept, and project overview suitable for academic presentation at a Fachhochschule (University of Applied Sciences). This includes summarizing code, documentation, and technical concepts into a concise academic overview.\\n\\nExamples:\\n\\n- User: \"Hier ist mein Exposé und der aktuelle Code, kannst du mir eine Zusammenfassung erstellen?\"\\n Assistant: \"Ich verwende den bachelor-thesis-summary Agenten, um eine strukturierte einseitige Zusammenfassung mit Konzept und Projektvorstellung zu erstellen.\"\\n\\n- User: \"Ich brauche eine Übersicht meiner Bachelorarbeit für die Präsentation beim Kolloquium.\"\\n Assistant: \"Ich starte den bachelor-thesis-summary Agenten, um die Kernpunkte der Arbeit zusammenzufassen und ein Umsetzungskonzept zu formulieren.\"\\n\\n- User: \"Fasse mir den Code und die Doku zusammen für mein Poster.\"\\n Assistant: \"Ich nutze den bachelor-thesis-summary Agenten, um aus Code und Dokumentation eine kompakte Projektvorstellung zu generieren.\"\\n\\n- Context: The user has just shared or referenced thesis-related documents, code, or research notes and needs them distilled into a presentable academic summary.\\n User: \"Kannst du das hier für mich auf eine Seite zusammenfassen?\"\\n Assistant: \"Ich verwende jetzt den bachelor-thesis-summary Agenten, um eine einseitige Übersicht mit Konzept und Projektvorstellung zu erstellen.\""
model: opus
memory: project
---
Du bist ein erfahrener wissenschaftlicher Mitarbeiter an einer Fachhochschule mit Expertise in der Betreuung von Bachelorarbeiten, insbesondere im Bereich Informatik, Embedded Systems und Mobile App-Entwicklung. Du hast jahrelange Erfahrung darin, komplexe technische Projekte prägnant und verständlich zusammenzufassen und für akademische Zwecke aufzubereiten.
## Deine Aufgabe
Du erstellst aus den dir gegebenen Texten, Code-Dokumentationen und technischen Beschreibungen eine **einseitige Übersicht** einer Bachelorarbeit. Diese Übersicht besteht aus zwei Hauptteilen:
1. **Projektvorstellung** — Was ist das Projekt, warum ist es relevant, was sind die Kernpunkte?
2. **Konzept zur Umsetzung** — Wie wird das Projekt technisch umgesetzt, welche Methoden und Technologien kommen zum Einsatz, und wie ist die Umsetzung im Rahmen einer Fachhochschule geplant?
## Vorgehen
### Schritt 1: Analyse
- Lies alle bereitgestellten Texte, Code-Dateien und Dokumentationen sorgfältig durch.
- Identifiziere die **Forschungsfrage / Problemstellung** der Arbeit.
- Extrahiere die **Kernpunkte** (Key Points): Welche Technologien? Welche Methoden? Welche Ergebnisse oder erwarteten Ergebnisse?
- Verstehe die **Architektur** und den **Datenfluss** des Systems.
### Schritt 2: Strukturierung
Gliedere die Zusammenfassung wie folgt:
**Titel der Arbeit**
(Wenn nicht explizit angegeben, formuliere einen passenden akademischen Titel)
**1. Projektvorstellung** (~40% des Platzes)
- Problemstellung und Motivation (1-2 Sätze)
- Zielsetzung der Arbeit (1-2 Sätze)
- Kernpunkte / Key Points als kompakte Aufzählung (3-5 Punkte)
- Relevanz und Anwendungskontext
**2. Konzept zur Umsetzung** (~50% des Platzes)
- Technischer Ansatz und Methodik
- Systemarchitektur (kompakt beschrieben)
- Verwendete Technologien und Frameworks
- Umsetzungsplan im Fachhochschul-Kontext (z.B. verfügbare Infrastruktur, Testumgebung, zeitlicher Rahmen)
- Evaluationsstrategie
**3. Fazit / Ausblick** (~10% des Platzes)
- Erwarteter Mehrwert
- Mögliche Erweiterungen
### Schritt 3: Qualitätskontrolle
- Prüfe, ob die Zusammenfassung tatsächlich auf **eine Seite** passt (ca. 400-500 Wörter).
- Stelle sicher, dass **Fachbegriffe** korrekt verwendet werden.
- Achte auf einen **akademischen, aber verständlichen** Schreibstil.
- Vermeide zu viele technische Details — die Übersicht soll auch für Nicht-Experten (z.B. Prüfungsausschuss) verständlich sein.
- Stelle sicher, dass alle Kernpunkte der Arbeit klar werden.
## Stilrichtlinien
- **Sprache**: Deutsch, akademischer Stil, aber klar und prägnant.
- **Ton**: Sachlich, professionell, informativ.
- **Länge**: Strikt eine Seite (ca. 400-500 Wörter). Lieber präzise als ausschweifend.
- **Formatierung**: Verwende Überschriften, kurze Absätze und ggf. Aufzählungen für bessere Lesbarkeit.
- Verwende **keine** Code-Snippets in der Zusammenfassung — beschreibe technische Aspekte in Prosa oder als Aufzählung.
- Wenn Diagramme oder Abbildungen sinnvoll wären, beschreibe diese als Platzhalter (z.B. "[Abbildung: Systemarchitektur]").
## Umgang mit unvollständigen Informationen
- Wenn wichtige Informationen fehlen (z.B. Forschungsfrage nicht klar, Ergebnisse noch ausstehend), formuliere den entsprechenden Abschnitt als **geplantes Vorgehen** oder **erwartetes Ergebnis**.
- Kennzeichne Annahmen, die du triffst, transparent.
- Frage nach, wenn zentrale Informationen für eine sinnvolle Zusammenfassung fehlen.
## Kontext: Fachhochschule
Berücksichtige bei der Umsetzungsplanung typische Rahmenbedingungen einer Fachhochschule:
- Praxisorientierter Ansatz (Prototyp-Entwicklung, nicht nur theoretische Analyse)
- Verfügbare Laborräume und Hardware (z.B. für BLE-Beacons, Testaufbauten)
- Bearbeitungszeitraum einer Bachelorarbeit (typisch 3-4 Monate)
- Betreuung durch Professor*in und ggf. externe Partner
## Wichtig
Dein Output ist die fertige, einseitige Zusammenfassung — kein Entwurf, keine Diskussion. Liefere direkt das fertige Dokument ab, das so verwendet werden kann. Wenn du Rückfragen hast, stelle diese **vor** der Erstellung, nicht danach.
**Update your agent memory** as you discover thesis topics, project structures, recurring academic patterns, terminology conventions, and Fachhochschule-specific requirements. This builds up institutional knowledge across conversations. Write concise notes about what you found.
Examples of what to record:
- Thesis topic areas and their typical structure
- Common technology stacks in student projects
- Preferred academic phrasing and formatting conventions
- Fachhochschule-specific evaluation criteria and presentation formats
- Calibration parameters and technical details that recur across projects
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `/home/erik/Nextcloud/Uni/Bachelorarbeit/BleIndoorPositioning/Claude Test/.claude/agent-memory/bachelor-thesis-summary/`. Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
- Record insights about problem constraints, strategies that worked or failed, and lessons learned
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- Use the Write and Edit tools to update your memory files
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. As you complete tasks, write down key learnings, patterns, and insights so you can be more effective in future conversations. Anything saved in MEMORY.md will be included in your system prompt next time.
+84
View File
@@ -0,0 +1,84 @@
---
name: gemini-cli-runner
description: "Use this agent when the user wants to query Google's Gemini AI model via the command-line interface, needs a second AI opinion, wants to leverage Gemini for code generation, analysis, translation, or any task that benefits from consulting Gemini. This agent runs Gemini in headless mode using the `gemini -p` command.\\n\\nExamples:\\n\\n<example>\\nContext: The user asks for a Gemini perspective on a code implementation.\\nuser: \"Frag mal Gemini, wie man eine Singleton-Klasse in Kotlin implementiert.\"\\nassistant: \"Ich nutze den Gemini-CLI-Agent, um Gemini dazu zu befragen.\"\\n<commentary>\\nSince the user wants to consult Gemini, use the Task tool to launch the gemini-cli-runner agent to query Gemini via the CLI.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: The user wants Gemini to review or analyze something.\\nuser: \"Lass Gemini mal diesen Algorithmus bewerten.\"\\nassistant: \"Ich starte den Gemini-CLI-Agent, um Gemini den Algorithmus bewerten zu lassen.\"\\n<commentary>\\nThe user explicitly wants Gemini's input, so use the Task tool to launch the gemini-cli-runner agent.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: The user wants to compare Claude's and Gemini's answers.\\nuser: \"Was sagt Gemini zu diesem Thema?\"\\nassistant: \"Ich frage Gemini über den CLI-Agent.\"\\n<commentary>\\nThe user wants Gemini's perspective, so use the Task tool to launch the gemini-cli-runner agent to get Gemini's response.\\n</commentary>\\n</example>"
model: sonnet
---
Du bist ein spezialisierter Agent für die Ausführung von Google Gemini CLI-Abfragen im Headless-Modus. Deine einzige Aufgabe ist es, Gemini über die Kommandozeile aufzurufen, die Ergebnisse zu erfassen und sie dem Benutzer klar und strukturiert zurückzugeben.
## Kernaufgabe
Du führst Gemini-Abfragen aus, indem du den folgenden Befehl verwendest:
```
gemini -p "<prompt>"
```
## Arbeitsablauf
1. **Prompt vorbereiten**: Nimm die Anfrage des Benutzers und formuliere einen klaren, präzisen Prompt für Gemini. Wenn der Benutzer bereits einen fertigen Prompt liefert, verwende diesen direkt. Wenn der Benutzer eine allgemeine Anfrage stellt, formuliere einen optimalen Prompt.
2. **Befehl ausführen**: Führe den Befehl `gemini -p "<prompt>"` über das Terminal aus.
- **WICHTIG**: Escape alle doppelten Anführungszeichen im Prompt mit Backslash (`\"`) oder verwende einfache Anführungszeichen als äußere Begrenzung, wenn der Prompt doppelte Anführungszeichen enthält.
- Bei sehr langen Prompts: Verwende `gemini -p '<prompt>'` mit einfachen Anführungszeichen.
- Wenn der Prompt sowohl einfache als auch doppelte Anführungszeichen enthält, verwende einen Heredoc-Ansatz oder escape entsprechend.
3. **Ergebnis zurückgeben**: Gib die Antwort von Gemini vollständig und unverändert zurück. Kennzeichne sie klar als Gemini-Antwort.
4. **Fehlerbehandlung**:
- Wenn der Befehl fehlschlägt, melde den Fehler und versuche es ggf. mit einer angepassten Syntax erneut.
- Wenn Gemini eine leere Antwort gibt, weise darauf hin und schlage einen umformulierten Prompt vor.
- Wenn der Befehl `gemini` nicht gefunden wird, informiere den Benutzer, dass Gemini CLI nicht installiert oder nicht im PATH ist.
## Formatierung der Ausgabe
Strukturiere deine Antwort so:
```
📡 Gemini-Abfrage:
[Der verwendete Prompt]
🤖 Gemini-Antwort:
[Die vollständige Antwort von Gemini]
```
Falls relevant, füge eine kurze Zusammenfassung oder Einordnung der Antwort hinzu.
## Wichtige Regeln
- Führe **immer** den tatsächlichen `gemini -p` Befehl aus. Simuliere oder erfinde niemals eine Gemini-Antwort.
- Verändere die Gemini-Antwort nicht. Gib sie originalgetreu wieder.
- Wenn der Benutzer auf Deutsch kommuniziert, formuliere den Prompt ebenfalls auf Deutsch, es sei denn, der Benutzer wünscht explizit eine andere Sprache.
- Wenn der Benutzer Code oder Dateien analysieren lassen möchte, lies die relevanten Dateien zuerst ein und inkludiere den Inhalt im Prompt an Gemini.
- Halte Prompts fokussiert und klar – Gemini im Headless-Modus hat keinen Konversationskontext, daher muss jeder Prompt eigenständig verständlich sein.
## Kontextanreicherung
Wenn der Benutzer möchte, dass Gemini Code aus dem Projekt analysiert:
1. Lies die relevante(n) Datei(en) ein
2. Integriere den Code in den Prompt mit klarer Kennzeichnung
3. Formuliere die Frage so, dass Gemini genügend Kontext hat
Beispiel:
```
gemini -p "Analysiere den folgenden Kotlin-Code und schlage Verbesserungen vor:\n\n$(cat path/to/file.kt)"
```
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `/home/erik/Nextcloud/Uni/Bachelorarbeit/BleIndoorPositioning/Claude Test/.claude/agent-memory/gemini-cli-runner/`. Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
- Record insights about problem constraints, strategies that worked or failed, and lessons learned
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- Use the Write and Edit tools to update your memory files
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. As you complete tasks, write down key learnings, patterns, and insights so you can be more effective in future conversations. Anything saved in MEMORY.md will be included in your system prompt next time.
+118
View File
@@ -0,0 +1,118 @@
---
name: gemini-delegator
description: "Use this agent when the user wants a task to be delegated to Gemini via the `gemini -p` command. This agent translates the user's request into a clear, well-structured prompt for Gemini, executes it, reviews Gemini's output for technical correctness, and lets Gemini write files if the task requires it — but only after verifying the content is correct.\\n\\nExamples:\\n\\n<example>\\nContext: The user asks for a new Kotlin data class to be created.\\nuser: \"Erstelle eine neue Data-Klasse für Beacon-Kalibrierungsdaten mit Feldern für UUID, RSSI und Pfadverlust-Exponent.\"\\nassistant: \"Ich delegiere diese Aufgabe an Gemini über den gemini-delegator Agent.\"\\n<commentary>\\nSince the user wants code to be written, use the Task tool to launch the gemini-delegator agent. The agent will formulate a precise prompt for Gemini, execute it via `gemini -p`, review the output for correctness, and then let Gemini write the file if the result is technically sound.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: The user wants documentation generated for an existing module.\\nuser: \"Schreib mir eine README für das positioning-Modul.\"\\nassistant: \"Ich nutze den gemini-delegator Agent, um diese Aufgabe an Gemini weiterzugeben und das Ergebnis zu prüfen.\"\\n<commentary>\\nSince the user wants documentation written, use the Task tool to launch the gemini-delegator agent. The agent will craft an appropriate prompt, delegate to Gemini, verify the documentation is factually accurate regarding the codebase, and let Gemini write the file.\\n</commentary>\\n</example>\\n\\n<example>\\nContext: The user wants a unit test written for an existing class.\\nuser: \"Erstelle Unit-Tests für den PositionSmoother.\"\\nassistant: \"Ich delegiere das an den gemini-delegator Agent, der Gemini die Tests schreiben lässt und sie vorher auf Korrektheit prüft.\"\\n<commentary>\\nSince the user wants tests written, use the Task tool to launch the gemini-delegator agent. The agent will understand the PositionSmoother class, formulate a clear prompt for Gemini, review the generated tests for technical accuracy (correct assertions, proper test structure, matching the actual API), and then let Gemini write the test file.\\n</commentary>\\n</example>"
model: sonnet
color: red
memory: project
---
Du bist ein erfahrener Software-Architekt und technischer Projektleiter, der als Vermittler zwischen dem Benutzer und Gemini fungiert. Deine Kernkompetenz liegt darin, Aufgaben präzise zu erfassen, sie in klare und vollständige Prompts für Gemini zu übersetzen, und die Ergebnisse fachlich zu überprüfen, bevor sie finalisiert werden.
## Deine Rolle
Du bist der **Qualitätssicherungs-Layer** zwischen dem Benutzer und Gemini. Du stellst sicher, dass:
1. Die Aufgabe korrekt verstanden und vollständig an Gemini kommuniziert wird
2. Gemini alle nötigen Kontextinformationen erhält
3. Die Ergebnisse fachlich korrekt sind, bevor sie geschrieben werden
## Arbeitsablauf
### Schritt 1: Aufgabe erfassen
- Lies die Aufgabe des Benutzers sorgfältig durch
- Identifiziere: Was genau soll erstellt/geändert werden? Welche Dateien sind betroffen? Welche fachlichen Anforderungen gibt es?
- Falls die Aufgabe unklar ist, stelle Rückfragen an den Benutzer
### Schritt 2: Kontext sammeln
- Lies relevante bestehende Dateien im Projekt, um den Kontext zu verstehen
- Beachte die Projektstruktur, Coding-Konventionen, und bestehende Patterns
- Sammle alle Informationen, die Gemini braucht, um die Aufgabe korrekt zu erledigen
### Schritt 3: Prompt für Gemini formulieren
- Formuliere einen **klaren, präzisen und vollständigen** Prompt auf Deutsch
- Inkludiere relevanten Kontext (z.B. bestehende Interfaces, Klassennamen, Architektur-Entscheidungen)
- Gib explizit an, was Gemini tun soll (Code schreiben, Datei erstellen, etc.)
- Verwende das Format: `gemini -p "<dein formulierter Prompt>"`
- **Wichtig**: Der Prompt muss in Anführungszeichen stehen und darf keine unescapten Sonderzeichen enthalten, die die Shell brechen könnten. Escape innere Anführungszeichen mit Backslash.
- Wenn die Aufgabe das Schreiben von Dateien verlangt, sage Gemini explizit, dass es die Dateien schreiben soll, und gib den genauen Dateipfad an.
### Schritt 4: Gemini ausführen
- Führe den `gemini -p "..."` Befehl aus
- Warte auf das Ergebnis
### Schritt 5: Ergebnis prüfen (KRITISCH)
Bevor du das Ergebnis akzeptierst oder Gemini Dateien schreiben lässt, prüfe:
**Fachliche Korrektheit:**
- Ist die Logik korrekt? Stimmen Algorithmen, Berechnungen, Datenflüsse?
- Werden bestehende APIs und Interfaces korrekt verwendet?
- Stimmen Typen, Parameter, Rückgabewerte?
- Sind Edge-Cases berücksichtigt?
**Code-Qualität:**
- Passt der Code zum bestehenden Stil des Projekts?
- Sind Namenskonventionen eingehalten?
- Ist der Code in der richtigen Sprache (Kotlin, Deutsch für Kommentare)?
**Projektkonformität:**
- Passt der Code zur bestehenden Architektur?
- Werden die richtigen Dependencies verwendet?
- Stimmen Paketstruktur und Dateipfade?
### Schritt 6: Korrigieren (falls nötig)
- Wenn du Fehler findest, korrigiere sie **selbst** direkt im Code
- Dokumentiere kurz, was du korrigiert hast und warum
- Bei gravierenden Fehlern: Formuliere einen verbesserten Prompt und lass Gemini es nochmal versuchen
### Schritt 7: Dateien schreiben lassen
- Wenn die Ergebnisse korrekt sind (oder nach deiner Korrektur korrekt sind), und die Aufgabe das Schreiben von Dateien verlangt, lass Gemini die Dateien schreiben oder schreibe sie selbst mit den korrigierten Inhalten
- Bestätige dem Benutzer, was erstellt/geändert wurde
## Wichtige Regeln
1. **Niemals blind weiterleiten**: Verstehe die Aufgabe IMMER selbst, bevor du sie an Gemini delegierst
2. **Niemals ungeprüft schreiben lassen**: Prüfe JEDES Ergebnis von Gemini, bevor Dateien geschrieben werden
3. **Kontext ist König**: Lies immer relevante bestehende Dateien, bevor du den Prompt formulierst
4. **Transparenz**: Teile dem Benutzer mit, was du an Gemini delegierst, was das Ergebnis war, und was du ggf. korrigiert hast
5. **Sprache**: Kommuniziere auf Deutsch mit dem Benutzer und formuliere Prompts an Gemini auf Deutsch
6. **Bei Unsicherheit**: Frage lieber beim Benutzer nach, als falsche Annahmen zu treffen
## Prompt-Formulierung Best Practices
- Sei spezifisch: Nenne genaue Dateinamen, Klassennamen, Methodensignaturen
- Gib Kontext: Füge relevante Code-Snippets oder Architektur-Beschreibungen in den Prompt ein
- Definiere das Format: Sage Gemini genau, in welchem Format du die Ausgabe erwartest
- Setze Grenzen: Sage Gemini, was es NICHT tun soll (z.B. keine bestehenden Dateien verändern, wenn nur neue erstellt werden sollen)
## Fehlerbehandlung
- Wenn `gemini -p` fehlschlägt: Versuche den Prompt zu vereinfachen oder aufzuteilen
- Wenn Geminis Ergebnis komplett unbrauchbar ist: Formuliere den Prompt um mit mehr Kontext
- Wenn du dir bei der fachlichen Prüfung unsicher bist: Recherchiere im Projekt-Code und in der Dokumentation
- Nach maximal 3 Versuchen mit Gemini: Informiere den Benutzer über das Problem und biete an, die Aufgabe selbst zu lösen
**Update dein Agent-Memory** wenn du Muster entdeckst, wie Gemini am besten promptet wird, welche Art von Fehlern Gemini häufig macht, und welche Kontextinformationen besonders wichtig für gute Ergebnisse sind. Notiere auch projektspezifische Erkenntnisse.
Beispiele für Memory-Einträge:
- Gemini-Prompt-Patterns die gut funktionieren
- Häufige Fehler in Geminis Output (z.B. falsche API-Nutzung, fehlende Imports)
- Projektspezifische Konventionen die Gemini oft vergisst
- Optimale Prompt-Längen und Detailgrade für verschiedene Aufgabentypen
# Persistent Agent Memory
You have a persistent Persistent Agent Memory directory at `/home/erik/Nextcloud/Uni/Bachelorarbeit/BleIndoorPositioning/Claude Test/.claude/agent-memory/gemini-delegator/`. Its contents persist across conversations.
As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned.
Guidelines:
- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise
- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md
- Record insights about problem constraints, strategies that worked or failed, and lessons learned
- Update or remove memories that turn out to be wrong or outdated
- Organize memory semantically by topic, not chronologically
- Use the Write and Edit tools to update your memory files
- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project
## MEMORY.md
Your MEMORY.md is currently empty. As you complete tasks, write down key learnings, patterns, and insights so you can be more effective in future conversations. Anything saved in MEMORY.md will be included in your system prompt next time.
+12
View File
@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"WebSearch",
"Bash(gemini -p:*)",
"Bash(./gradlew assembleDebug:*)",
"Bash(./gradlew compileDebugKotlin:*)",
"Bash(timeout 30 tail:*)",
"WebFetch(domain:www.espressif.com)"
]
}
}
+15
View File
@@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+1
View File
@@ -0,0 +1 @@
BleIndoorPositioning
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+123
View File
@@ -0,0 +1,123 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>
+5
View File
@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-02-10T21:41:30.468896374Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCT30TE8WB" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
+61
View File
@@ -0,0 +1,61 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
+1
View File
@@ -0,0 +1 @@
/build
+77
View File
@@ -0,0 +1,77 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
kotlin("plugin.serialization") version "2.0.21"
}
android {
namespace = "com.erik.bleindoorpositioning"
compileSdk {
version = release(36)
}
defaultConfig {
applicationId = "com.erik.bleindoorpositioning"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation("androidx.compose.material:material-icons-extended:1.7.6")
// Bestehende Einträge beibehalten, dann ergänzen:
// Lifecycle und ViewModel
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Permissions Handling
implementation(libs.accompanist.permissions)
// Coroutines für asynchrones BLE-Scanning
implementation(libs.kotlinx.coroutines.android)
// Kotlinx Serialization für Config-Persistierung
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0")
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,24 @@
package com.erik.bleindoorpositioning
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.erik.bleindoorpositioning", appContext.packageName)
}
}
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Bluetooth-Berechtigungen -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- Ab Android 12 (API 31)-->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="31" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Für BLE-Positionierung benötigt-->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- BLE-Hardware voraussetzen -->
<uses-feature android:name="android.hardware.bluetooth_le"
android:required="true" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.BleIndoorPositioning">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.BleIndoorPositioning">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,76 @@
package com.erik.bleindoorpositioning
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.erik.bleindoorpositioning.ble.BleScanner
import com.erik.bleindoorpositioning.data.ConfigRepository
import com.erik.bleindoorpositioning.ui.screens.MainScreen
import com.erik.bleindoorpositioning.ui.theme.BleIndoorPositioningTheme
class MainActivity : ComponentActivity() {
private lateinit var bleScanner: BleScanner
private var isDarkMode by mutableStateOf(false)
private var wasScanningOnPause = false
companion object {
private const val TAG = "MainActivity"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val configRepository = ConfigRepository(applicationContext)
bleScanner = BleScanner(this, configRepository, lifecycleScope)
// Lade gespeicherten Dark Mode Status
isDarkMode = configRepository.getIsDarkMode()
setContent {
BleIndoorPositioningTheme(darkTheme = isDarkMode) {
Surface(modifier = Modifier.fillMaxSize()) {
MainScreen(
bleScanner = bleScanner,
configRepository = configRepository,
isDarkMode = isDarkMode,
onToggleDarkMode = {
isDarkMode = !isDarkMode
configRepository.setIsDarkMode(isDarkMode)
}
)
}
}
}
}
override fun onPause() {
super.onPause()
wasScanningOnPause = bleScanner.isScanning.value
if (bleScanner.isScanning.value) {
Log.d(TAG, "onPause: Stopping BLE scan.")
bleScanner.stopScanning()
}
}
override fun onResume() {
super.onResume()
if (wasScanningOnPause) {
Log.d(TAG, "onResume: Restarting BLE scan.")
bleScanner.startScanning()
}
}
override fun onDestroy() {
super.onDestroy()
bleScanner.stopScanning()
}
}
@@ -0,0 +1,33 @@
package com.erik.bleindoorpositioning.ble
object BeaconData {
const val BEACON_UUID = "87b99b2c-9080-4d04-8f00-a5d9a3f9a0e7"
/**
* NICHT auf 0x004C ändern.
*/
const val IBEACON_MANUFACTURER_ID = 0x4C00
/**
* RSSI bei 1 m Abstand – MUSS kalibriert werden.
*
* Vorgehensweise:
* 1. Smartphone in 1 m Abstand zum Beacon halten
* 2. 60 Sekunden RSSI loggen
* 3. Mittelwert = RSSI_AT_ONE_METER
*
* Typische Werte für ESP32: -59 bis -72 dBm.
* Der aktuelle Default von -55 ist wahrscheinlich ZU OPTIMISTISCH
* und eine wahrscheinliche Mitursache für die 100m-Distanzen.
*/
const val RSSI_AT_ONE_METER = -65.0 // Geändert von -55 auf realistischeren Wert
/**
* Path Loss Exponent – MUSS kalibriert werden.
*
* 2.0 = Freifeld, unrealistisch für Innenräume.
* Für einen 3.5x4.5m Raum mit Wänden/Möbeln ist 2.5-3.5 realistischer.
*/
const val PATH_LOSS_EXPONENT = 5.5 // Geändert von 2.0 auf realistischeren Wert
}
@@ -0,0 +1,268 @@
package com.erik.bleindoorpositioning.ble
import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.SystemClock
import android.util.Log
import com.erik.bleindoorpositioning.data.ConfigRepository
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.PositionCalculator
import com.erik.bleindoorpositioning.positioning.PositionSmoother
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) {
companion object {
private const val TAG = "BleScanner"
private const val BEACON_MAX_AGE_MS = 3000L // Beacons älter als 3s ignorieren
private const val SCAN_RESTART_INTERVAL_MS = 25 * 60 * 1000L // 25 Minuten
// Exponential backoff delays for scan failures
private val RETRY_DELAYS = arrayOf(
0L, // Immediate retry
TimeUnit.SECONDS.toMillis(1),
TimeUnit.SECONDS.toMillis(5),
TimeUnit.SECONDS.toMillis(10),
TimeUnit.SECONDS.toMillis(30),
TimeUnit.MINUTES.toMillis(1),
TimeUnit.MINUTES.toMillis(2),
TimeUnit.MINUTES.toMillis(5)
)
}
private val bluetoothManager =
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private val bluetoothAdapter = bluetoothManager.adapter
private var scanner: BluetoothLeScanner? = null
private var scanRestartJob: Job? = null
private var retryJob: Job? = null
private var retryAttempt = 0
// RSSI-Filterung
private val filterManager = BeaconFilterManager {
KalmanFilter(
processNoise = 0.5, // TODO: Kalibrieren
measurementNoise = 10.0 // TODO: Kalibrieren
)
}
// Positionsberechnung (wird bei jedem Scan-Start neu initialisiert)
private var positionCalculator: PositionCalculator? = null
// Positions-Glättung
private val positionSmoother = PositionSmoother(alpha = 0.15)
// Öffentliche State Flows
private val _beacons = MutableStateFlow<Map<Int, ScannedBeacon>>(emptyMap())
val beacons: StateFlow<Map<Int, ScannedBeacon>> = _beacons.asStateFlow()
private val _isScanning = MutableStateFlow(false)
val isScanning: StateFlow<Boolean> = _isScanning.asStateFlow()
private val _position = MutableStateFlow<PositionResult?>(null)
val position: StateFlow<PositionResult?> = _position.asStateFlow()
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
// Reset retry attempt and cancel retry job on successful scan result
if (retryAttempt > 0) {
Log.d(TAG, "Scan recovered after $retryAttempt retries")
retryAttempt = 0
}
retryJob?.cancel()
retryJob = null
parseIBeacon(result)?.let { beacon ->
val now = SystemClock.elapsedRealtime()
// RSSI filtern (inkl. Ausreißer-Erkennung)
val filteredRssi = filterManager.update(beacon.minor, beacon.rssi)
val distance = positionCalculator?.estimateDistance(filteredRssi, beacon.minor) ?: 0.0
// Beacon-State mit Timestamp aktualisieren
val updatedBeacon = beacon.copy(
filteredRssi = filteredRssi,
estimatedDistance = distance,
lastSeen = now
)
_beacons.value = _beacons.value.toMutableMap().apply {
put(updatedBeacon.minor, updatedBeacon)
}
// Nur frische Beacons für Positionsberechnung verwenden
val freshBeacons = _beacons.value.filter {
(now - it.value.lastSeen) < BEACON_MAX_AGE_MS
}
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)
}
Log.d(TAG, "Beacon ${beacon.minor}: " +
"raw=${beacon.rssi}, " +
"filtered=${"%.1f".format(filteredRssi)}, " +
"dist=${"%.2f".format(distance)}m")
_position.value?.let { pos ->
Log.d(TAG, "Position: (%.2f, %.2f) m".format(
pos.position.x, pos.position.y
))
}
}
}
override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "Scan failed with error code: $errorCode. Retrying...")
_isScanning.value = false
retryJob?.cancel()
retryJob = coroutineScope.launch {
val delayTime = RETRY_DELAYS.getOrElse(retryAttempt) {
RETRY_DELAYS.last()
}
Log.d(TAG, "Next scan retry in ${delayTime / 1000} seconds (attempt ${retryAttempt + 1})")
delay(delayTime)
retryAttempt++
startScanning()
}
}
}
private fun parseIBeacon(result: ScanResult): ScannedBeacon? {
val scanRecord = result.scanRecord ?: return null
val manufacturerData =
scanRecord.getManufacturerSpecificData(BeaconData.IBEACON_MANUFACTURER_ID)
?: return null
// iBeacon-Prefix validieren: 0x02 0x15
if (manufacturerData.size < 23 ||
manufacturerData[0] != 0x02.toByte() ||
manufacturerData[1] != 0x15.toByte()
) {
return null
}
// UUID extrahieren (Bytes 2–17), Format 8-4-4-4-12
val uuid = buildString {
for (i in 2..17) {
append(String.format("%02x", manufacturerData[i]))
if (i in listOf(5, 7, 9, 11)) append("-")
}
}
if (!uuid.equals(BeaconData.BEACON_UUID, ignoreCase = true)) {
return null
}
val major = ((manufacturerData[18].toInt() and 0xFF) shl 8) or
(manufacturerData[19].toInt() and 0xFF)
val minor = ((manufacturerData[20].toInt() and 0xFF) shl 8) or
(manufacturerData[21].toInt() and 0xFF)
val txPower = manufacturerData[22].toInt()
return ScannedBeacon(
minor = minor,
rssi = result.rssi,
txPower = txPower,
uuid = uuid
)
}
@SuppressLint("MissingPermission")
fun startScanning() {
if (_isScanning.value) return
// Lade aktuelle Konfiguration und initialisiere PositionCalculator
initializePositionCalculator()
scanner = bluetoothAdapter?.bluetoothLeScanner
if (scanner == null) {
Log.e(TAG, "BluetoothLeScanner not available")
return
}
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setReportDelay(0)
.build()
// Ensure any previous retry job is cancelled
retryJob?.cancel()
retryJob = null
retryAttempt = 0
scanner?.startScan(null, settings, scanCallback)
_isScanning.value = true
Log.d(TAG, "Scanning started")
// Start proactive scan restart job
scanRestartJob?.cancel()
scanRestartJob = coroutineScope.launch {
while (isActive) {
delay(SCAN_RESTART_INTERVAL_MS)
Log.d(TAG, "Proactively restarting BLE scan.")
stopScanning()
startScanning()
}
}
}
/**
* Lädt die aktuelle Konfiguration neu und initialisiert den PositionCalculator.
* Kann während eines laufenden Scans aufgerufen werden, um Config-Änderungen zu übernehmen.
*/
fun reloadConfig() {
initializePositionCalculator()
Log.d(TAG, "Config reloaded")
}
private fun initializePositionCalculator() {
val config = configRepository.loadConfig()
if (config.beacons.size < 3) {
Log.w(TAG, "Weniger als 3 Beacons konfiguriert, Positionsberechnung nicht möglich")
}
positionCalculator = PositionCalculator(
rssiAtOneMeter = BeaconData.RSSI_AT_ONE_METER,
pathLossExponent = BeaconData.PATH_LOSS_EXPONENT,
beacons = config.beacons,
floorWidth = config.floorWidth,
floorHeight = config.floorHeight,
rooms = config.rooms
)
}
@SuppressLint("MissingPermission")
fun stopScanning() {
if (!_isScanning.value && retryJob == null && scanRestartJob == null) {
Log.d(TAG, "Scanning not active, no pending retries, and no restart job. No need to stop.")
return
}
scanner?.stopScan(scanCallback)
scanRestartJob?.cancel()
retryJob?.cancel()
retryJob = null
retryAttempt = 0
_isScanning.value = false
filterManager.reset()
positionSmoother.reset()
Log.d(TAG, "Scanning stopped")
}
}
@@ -0,0 +1,22 @@
package com.erik.bleindoorpositioning.data
import com.erik.bleindoorpositioning.model.BeaconConfig
import com.erik.bleindoorpositioning.model.RoomConfig
import kotlinx.serialization.Serializable
/**
* Datenklasse, die die gesamte App-Konfiguration enthält.
* Dies umfasst die Stockwerk-Abmessungen, die Raumliste und die Liste der konfigurierten Beacons.
*
* @param floorWidth Die Breite des Stockwerks in Metern.
* @param floorHeight Die Höhe des Stockwerks in Metern.
* @param rooms Die Liste der Räume im Stockwerk.
* @param beacons Die Liste der konfigurierten Beacons.
*/
@Serializable
data class AppConfig(
val floorWidth: Double,
val floorHeight: Double,
val rooms: List<RoomConfig>,
val beacons: List<BeaconConfig>
)
@@ -0,0 +1,116 @@
package com.erik.bleindoorpositioning.data
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import com.erik.bleindoorpositioning.model.BeaconConfig
import com.erik.bleindoorpositioning.model.RoomConfig
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.SerializationException
/**
* Repository zur Verwaltung der persistenten App-Konfiguration.
* Speichert und lädt die Konfiguration (Raumgröße, Beacon-Liste)
* in/aus den SharedPreferences im JSON-Format.
*/
class ConfigRepository(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
/**
* Lädt die App-Konfiguration aus den SharedPreferences.
* Wenn keine Konfiguration gespeichert ist, werden Standardwerte zurückgegeben.
* Unterstützt Migration von alten Configs (roomWidth/roomHeight → floorWidth/floorHeight).
*
* @return Die geladene oder standardmäßige [AppConfig].
*/
fun loadConfig(): AppConfig {
val jsonString = prefs.getString(KEY_CONFIG, null) ?: return getDefaultConfig()
return try {
// Versuche neues Format zu parsen
Json.decodeFromString<AppConfig>(jsonString)
} catch (e: SerializationException) {
// Falls neues Format fehlschlägt, versuche Legacy-Format
try {
Log.d("ConfigRepository", "Could not parse new config format, trying legacy...")
val legacyConfig = Json.decodeFromString<LegacyAppConfig>(jsonString)
Log.d("ConfigRepository", "Successfully parsed legacy config, migrating.")
// Migration: Alten Raum als einzelnen Raum im Stockwerk
AppConfig(
floorWidth = legacyConfig.roomWidth,
floorHeight = legacyConfig.roomHeight,
rooms = listOf(RoomConfig("Importierter Raum", 0.0, 0.0, legacyConfig.roomWidth, legacyConfig.roomHeight)),
beacons = legacyConfig.beacons
)
} catch (e2: SerializationException) {
Log.e("ConfigRepository", "Could not parse legacy config either, falling back to default", e2)
getDefaultConfig()
}
}
}
/**
* Speichert die angegebene App-Konfiguration in den SharedPreferences.
*
* @param config Die zu speichernde [AppConfig].
*/
fun saveConfig(config: AppConfig) {
val jsonString = Json.encodeToString(config)
prefs.edit().putString(KEY_CONFIG, jsonString).apply()
}
/**
* Liefert eine Standardkonfiguration.
* Diese wird verwendet, wenn noch keine Konfiguration gespeichert wurde.
*/
private fun getDefaultConfig(): AppConfig {
return AppConfig(
floorWidth = 10.0,
floorHeight = 8.0,
rooms = listOf(
RoomConfig(name = "Wohnzimmer", x = 0.0, y = 0.0, width = 6.0, height = 8.0),
RoomConfig(name = "Küche", x = 6.0, y = 0.0, width = 4.0, height = 4.0),
RoomConfig(name = "Büro", x = 6.0, y = 4.0, width = 4.0, height = 4.0)
),
beacons = listOf(
BeaconConfig(minor = 1, x = 1.0, y = 1.0),
BeaconConfig(minor = 2, x = 5.0, y = 7.0),
BeaconConfig(minor = 3, x = 9.0, y = 2.0)
)
)
}
/**
* Lädt den gespeicherten Dark Mode Status.
* @return true wenn Dark Mode aktiv ist, sonst false (Default: false).
*/
fun getIsDarkMode(): Boolean {
return prefs.getBoolean(KEY_DARK_MODE, false)
}
/**
* Speichert den Dark Mode Status.
* @param isDarkMode true für Dark Mode, false für Light Mode.
*/
fun setIsDarkMode(isDarkMode: Boolean) {
prefs.edit().putBoolean(KEY_DARK_MODE, isDarkMode).apply()
}
companion object {
private const val PREFS_NAME = "ble_indoor_positioning_config"
private const val KEY_CONFIG = "app_config_json"
private const val KEY_DARK_MODE = "is_dark_mode"
}
/**
* Private Hilfsklasse für Deserialisierung von Legacy-Configs (vor Stockwerk-Support).
*/
@kotlinx.serialization.Serializable
private data class LegacyAppConfig(
val roomWidth: Double,
val roomHeight: Double,
val beacons: List<BeaconConfig>
)
}
@@ -0,0 +1,69 @@
package com.erik.bleindoorpositioning.map
import com.erik.bleindoorpositioning.model.Position
/**
* Transformiert zwischen realen Raum-Koordinaten (Meter) und
* Pixel-Koordinaten auf dem Bildschirm/Canvas.
* Unterstützt beliebige Koordinaten-Bereiche (auch negative Werte).
*
* @param minX Minimale X-Koordinate der Bounding Box in Metern
* @param minY Minimale Y-Koordinate der Bounding Box in Metern
* @param maxX Maximale X-Koordinate der Bounding Box in Metern
* @param maxY Maximale Y-Koordinate der Bounding Box in Metern
*/
class CoordinateMapper(
private val minX: Double,
private val minY: Double,
private val maxX: Double,
private val maxY: Double
) {
private var canvasWidth: Float = 0f
private var canvasHeight: Float = 0f
private var scale: Float = 1f
private var offsetX: Float = 0f
private var offsetY: Float = 0f
private val worldWidth = maxX - minX
private val worldHeight = maxY - minY
/**
* Muss aufgerufen werden, wenn sich die Canvas-Größe ändert.
* Berechnet Skalierung so, dass der gesamte Raum sichtbar ist
* (uniform scaling, Seitenverhältnis bleibt erhalten).
*
* @param padding Rand in Pixeln
*/
fun updateCanvasSize(width: Float, height: Float, padding: Float = 40f) {
canvasWidth = width
canvasHeight = height
val usableWidth = width - 2 * padding
val usableHeight = height - 2 * padding
val scaleX = if (worldWidth > 0) usableWidth / worldWidth.toFloat() else 1f
val scaleY = if (worldHeight > 0) usableHeight / worldHeight.toFloat() else 1f
scale = minOf(scaleX, scaleY)
// Zentrieren
offsetX = (width - worldWidth.toFloat() * scale) / 2f
offsetY = (height - worldHeight.toFloat() * scale) / 2f
}
/**
* Realwelt-Position (Meter) → Pixel-Position auf Canvas.
* Transformiert relativ zur Bounding Box (minX, minY).
*/
fun toPixel(position: Position): Pair<Float, Float> {
val px = ((position.x - minX) * scale + offsetX).toFloat()
val py = ((position.y - minY) * scale + offsetY).toFloat()
return Pair(px, py)
}
/**
* Meter → Pixel (für Distanzkreise etc.)
*/
fun metersToPixels(meters: Double): Float {
return (meters * scale).toFloat()
}
}
@@ -0,0 +1,203 @@
package com.erik.bleindoorpositioning.map
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.sp
import com.erik.bleindoorpositioning.model.BeaconConfig
import com.erik.bleindoorpositioning.model.Position
import com.erik.bleindoorpositioning.model.PositionResult
import com.erik.bleindoorpositioning.model.RoomConfig
import com.erik.bleindoorpositioning.model.ScannedBeacon
/**
* Zeichnet eine schematische Draufsicht des Stockwerks mit
* Beacon-Positionen, geschätzten Distanzkreisen und der berechneten Position.
* Unterstützt interaktives Zoomen (Pinch) und Verschieben (Pan).
*
* @param floorWidthMeters Stockwerkbreite in Metern
* @param floorHeightMeters Stockwerkhöhe in Metern
* @param roomConfigs Liste der zu zeichnenden Räume
* @param beaconConfigs Liste der konfigurierten Beacon-Positionen
*/
@Composable
fun FloorplanView(
beacons: Map<Int, ScannedBeacon>,
positionResult: PositionResult?,
floorWidthMeters: Double,
floorHeightMeters: Double,
roomConfigs: List<RoomConfig>,
beaconConfigs: List<BeaconConfig> = emptyList(),
modifier: Modifier = Modifier,
beaconColor: Color = MaterialTheme.colorScheme.primary,
positionColor: Color = MaterialTheme.colorScheme.error,
roomColor: Color = MaterialTheme.colorScheme.outline,
textColor: Color = MaterialTheme.colorScheme.onSurface
) {
// Berechne Bounding Box aus Räumen und Beacons
val mapper = remember(floorWidthMeters, floorHeightMeters, roomConfigs, beaconConfigs) {
val allX = mutableListOf<Double>()
val allY = mutableListOf<Double>()
// Räume einbeziehen
roomConfigs.forEach { room ->
allX.add(room.x)
allX.add(room.x + room.width)
allY.add(room.y)
allY.add(room.y + room.height)
}
// Beacons einbeziehen
beaconConfigs.forEach { beacon ->
allX.add(beacon.x)
allY.add(beacon.y)
}
// Fallback auf Stockwerk-Abmessungen wenn keine Objekte konfiguriert
val minX = allX.minOrNull() ?: 0.0
val minY = allY.minOrNull() ?: 0.0
val maxX = allX.maxOrNull() ?: floorWidthMeters
val maxY = allY.maxOrNull() ?: floorHeightMeters
CoordinateMapper(minX, minY, maxX, maxY)
}
val textMeasurer = rememberTextMeasurer()
var scale by remember { mutableStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
Canvas(modifier = modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(0.5f, 5f)
offset += pan
}
}
.pointerInput("doubleTap") {
detectTapGestures(onDoubleTap = {
scale = 1f
offset = Offset.Zero
})
}) {
mapper.updateCanvasSize(size.width, size.height)
withTransform({
translate(left = offset.x, top = offset.y)
scale(scale, scale, pivot = center)
}) {
// Räume zeichnen
drawRooms(mapper, roomConfigs, textMeasurer, scale, roomColor, textColor)
// Beacon-Positionen und Distanzkreise
beaconConfigs.forEach { config ->
val pixelPos = mapper.toPixel(Position(config.x, config.y))
val beaconOffset = Offset(pixelPos.first, pixelPos.second)
// Beacon-Punkt
drawCircle(
color = beaconColor,
radius = 12f / scale,
center = beaconOffset
)
// Beacon-Label
drawText(
textMeasurer = textMeasurer,
text = "B${config.minor}",
topLeft = Offset(beaconOffset.x + 16f / scale, beaconOffset.y - 8f / scale),
style = TextStyle(color = textColor)
)
// Distanzkreis (wenn Beacon sichtbar)
val scanned = beacons[config.minor]
scanned?.estimatedDistance?.let { dist ->
val radiusPx = mapper.metersToPixels(dist)
drawCircle(
color = beaconColor.copy(alpha = 0.2f),
radius = radiusPx,
center = beaconOffset,
style = Stroke(width = 2f / scale)
)
}
}
// Geschätzte Position
positionResult?.let { result ->
val pixelPos = mapper.toPixel(result.position)
val positionOffset = Offset(pixelPos.first, pixelPos.second)
// Positionspunkt
drawCircle(
color = positionColor,
radius = 10f / scale,
center = positionOffset
)
// Kreuz für bessere Sichtbarkeit
drawLine(
color = positionColor,
start = Offset(positionOffset.x - 16f / scale, positionOffset.y),
end = Offset(positionOffset.x + 16f / scale, positionOffset.y),
strokeWidth = 2f / scale
)
drawLine(
color = positionColor,
start = Offset(positionOffset.x, positionOffset.y - 16f / scale),
end = Offset(positionOffset.x, positionOffset.y + 16f / scale),
strokeWidth = 2f / scale
)
}
}
}
}
private fun DrawScope.drawRooms(
mapper: CoordinateMapper,
rooms: List<RoomConfig>,
textMeasurer: TextMeasurer,
scale: Float,
roomColor: Color,
textColor: Color
) {
rooms.forEach { room ->
val topLeft = mapper.toPixel(Position(room.x, room.y))
val bottomRight = mapper.toPixel(Position(room.x + room.width, room.y + room.height))
val size = androidx.compose.ui.geometry.Size(
width = bottomRight.first - topLeft.first,
height = bottomRight.second - topLeft.second
)
drawRect(
color = roomColor,
topLeft = Offset(topLeft.first, topLeft.second),
size = size,
style = Stroke(width = 3f / scale)
)
drawText(
textMeasurer = textMeasurer,
text = room.name,
topLeft = Offset(topLeft.first + 8f / scale, topLeft.second + 4f / scale),
style = TextStyle(fontSize = (16f / scale).sp, color = textColor)
)
}
}
@@ -0,0 +1,44 @@
package com.erik.bleindoorpositioning.model
import kotlinx.serialization.Serializable
/**
* Konfiguration eines Raums im Stockwerk.
* Koordinaten in Metern relativ zum Stockwerk-Ursprung.
*/
@Serializable
data class RoomConfig(
val name: String,
val x: Double,
val y: Double,
val width: Double,
val height: Double
)
/**
* Konfiguration eines physisch installierten Beacons.
* Koordinaten in Metern relativ zu einem frei gewählten Ursprung.
*/
@Serializable
data class BeaconConfig(
val minor: Int,
val x: Double,
val y: Double,
val rssiAtOneMeter: Double = -65.0, // RSSI-Wert bei 1m Abstand, individuell pro Beacon
val pathLossExponent: Double = 5.5 // Pfadverlust-Exponent, individuell pro Beacon
)
/**
* Ein gescannter Beacon mit Roh- und Filterdaten.
* @param lastSeen Zeitstempel (SystemClock.elapsedRealtime) der letzten Messung.
* @param uuid Die UUID des Beacons
*/
data class ScannedBeacon(
val minor: Int,
val rssi: Int,
val txPower: Int,
val uuid: String = "",
val filteredRssi: Double = rssi.toDouble(),
val estimatedDistance: Double? = null,
val lastSeen: Long = 0L
)
@@ -0,0 +1,18 @@
package com.erik.bleindoorpositioning.model
/**
* 2D-Position in Metern im lokalen Koordinatensystem.
*/
data class Position(
val x: Double,
val y: Double
)
/**
* Ergebnis einer Positionsberechnung mit Debug-Informationen.
*/
data class PositionResult(
val position: Position,
val distances: Map<Int, Double>,
val usedBeacons: List<Int>
)
@@ -0,0 +1,42 @@
package com.erik.bleindoorpositioning.positioning
import com.erik.bleindoorpositioning.model.Position
/**
* 2D Exponential Moving Average zur Glättung der berechneten Position.
* Reduziert Springen der Positionsanzeige ohne große Verzögerung.
*
* @param alpha Glättungsfaktor (0, 1]. Klein = glatter, groß = reaktiver.
*/
class PositionSmoother(private val alpha: Double = 0.25) {
init {
require(alpha in 0.0..1.0) { "alpha muss in (0, 1] liegen, war: $alpha" }
}
private var smoothedX: Double? = null
private var smoothedY: Double? = null
fun update(newPosition: Position): Position {
val sx = smoothedX
val sy = smoothedY
if (sx == null || sy == null) {
smoothedX = newPosition.x
smoothedY = newPosition.y
return newPosition
}
val newX = alpha * newPosition.x + (1.0 - alpha) * sx
val newY = alpha * newPosition.y + (1.0 - alpha) * sy
smoothedX = newX
smoothedY = newY
return Position(newX, newY)
}
fun reset() {
smoothedX = null
smoothedY = null
}
}
@@ -0,0 +1,211 @@
package com.erik.bleindoorpositioning.positioning
import com.erik.bleindoorpositioning.model.BeaconConfig
import com.erik.bleindoorpositioning.model.Position
import com.erik.bleindoorpositioning.model.PositionResult
import com.erik.bleindoorpositioning.model.RoomConfig
import kotlin.math.abs
import kotlin.math.pow
import kotlin.math.sqrt
class PositionCalculator(
private val rssiAtOneMeter: Double = -65.0,
private val pathLossExponent: Double = 5.0,
private val beacons: List<BeaconConfig>,
private val floorWidth: Double = Double.MAX_VALUE,
private val floorHeight: Double = Double.MAX_VALUE,
private val rooms: List<RoomConfig> = emptyList()
) {
init {
require(beacons.size >= 3) {
"Mindestens 3 Beacons erforderlich, erhalten: ${beacons.size}"
}
}
fun estimateDistance(filteredRssi: Double, beaconMinor: Int): Double {
val beacon = beacons.find { it.minor == beaconMinor }
val beaconRssi = beacon?.rssiAtOneMeter ?: rssiAtOneMeter
val ple = beacon?.pathLossExponent ?: pathLossExponent
val exponent = (beaconRssi - filteredRssi) / (10.0 * ple)
return 10.0.pow(exponent).coerceAtLeast(0.01)
}
/**
* Berechnet die Position in zwei Stufen:
*
* 1. Gewichteter Schwerpunkt (IDW) als robuste Basisschätzung
* Gewicht = 1 / d^exponent – der nächste Beacon dominiert
*
* 2. Gauss-Newton-Verfeinerung mit starker Gewichtung naher Beacons
*
* 3. Blending: Bei sehr kleiner Minimaldistanz wird die IDW-Lösung
* bevorzugt, da Trilateration dort instabil ist.
*/
fun calculate(filteredRssiByMinor: Map<Int, Double>): PositionResult? {
val available = beacons.filter { it.minor in filteredRssiByMinor }
if (available.size < 3) return null
val distances = available.associate { beacon ->
beacon.minor to estimateDistance(filteredRssiByMinor[beacon.minor]!!, beacon.minor)
}
// ============================================================
// Stufe 1: Inverse Distance Weighting (IDW)
// ============================================================
// Exponent 4 statt üblichem 2: Naher Beacon dominiert extrem
val idwExponent = 4.0
val idwPosition = calculateIdw(available, distances, idwExponent)
// ============================================================
// Stufe 2: Gauss-Newton-Trilateration
// ============================================================
val gnPosition = calculateGaussNewton(available, distances, idwPosition)
// ============================================================
// Stufe 3: Blending basierend auf Minimaldistanz
// ============================================================
// Bei Minimaldistanz < blendStart: 100% IDW
// Bei Minimaldistanz > blendEnd: 100% Gauss-Newton
// Dazwischen: linearer Übergang
val minDist = distances.values.min()
val blendStart = 0.5 // Unter 0.5 m → voll IDW
val blendEnd = 1.5 // Über 1.5 m → voll Gauss-Newton
val gnWeight = if (gnPosition != null) {
((minDist - blendStart) / (blendEnd - blendStart)).coerceIn(0.0, 1.0)
} else {
0.0
}
val blendedPosition = if (gnPosition != null && gnWeight > 0.0) {
Position(
x = gnWeight * gnPosition.x + (1.0 - gnWeight) * idwPosition.x,
y = gnWeight * gnPosition.y + (1.0 - gnWeight) * idwPosition.y
)
} else {
idwPosition
}
// Raumbegrenzung: Position auf tatsächliche Bounds clampen
// Berechne Min/Max aus Räumen und Beacons
val allX = mutableListOf<Double>()
val allY = mutableListOf<Double>()
rooms.forEach { room ->
allX.add(room.x)
allX.add(room.x + room.width)
allY.add(room.y)
allY.add(room.y + room.height)
}
beacons.forEach { beacon ->
allX.add(beacon.x)
allY.add(beacon.y)
}
// Fallback auf Stockwerk-Abmessungen wenn keine Objekte konfiguriert
val minX = allX.minOrNull() ?: 0.0
val minY = allY.minOrNull() ?: 0.0
val maxX = allX.maxOrNull() ?: floorWidth
val maxY = allY.maxOrNull() ?: floorHeight
val finalPosition = Position(
x = blendedPosition.x.coerceIn(minX, maxX),
y = blendedPosition.y.coerceIn(minY, maxY)
)
return PositionResult(
position = finalPosition,
distances = distances,
usedBeacons = available.map { it.minor }
)
}
/**
* Inverse Distance Weighting.
* Bei d → 0 konvergiert das Ergebnis exakt gegen die Beacon-Position.
*/
private fun calculateIdw(
beaconList: List<BeaconConfig>,
distances: Map<Int, Double>,
exponent: Double
): Position {
var sumX = 0.0
var sumY = 0.0
var sumW = 0.0
for (beacon in beaconList) {
val d = distances[beacon.minor] ?: continue
// Wenn d quasi 0: Position ist Beacon-Position
if (d < 0.01) {
return Position(beacon.x, beacon.y)
}
val w = 1.0 / d.pow(exponent)
sumX += beacon.x * w
sumY += beacon.y * w
sumW += w
}
return Position(sumX / sumW, sumY / sumW)
}
/**
* Gauss-Newton-Optimierung mit distanzabhängiger Gewichtung.
* Gibt null zurück wenn die Lösung nicht konvergiert.
*/
private fun calculateGaussNewton(
beaconList: List<BeaconConfig>,
distances: Map<Int, Double>,
startPosition: Position
): Position? {
var x = startPosition.x
var y = startPosition.y
val maxIterations = 50
val convergenceThreshold = 1e-6
for (iteration in 0 until maxIterations) {
var jtj00 = 0.0; var jtj01 = 0.0; var jtj11 = 0.0
var jtr0 = 0.0; var jtr1 = 0.0
for (beacon in beaconList) {
val di = distances[beacon.minor] ?: continue
val dx = x - beacon.x
val dy = y - beacon.y
val dist = sqrt(dx * dx + dy * dy)
if (dist < 1e-10) continue
val residual = dist - di
val jx = dx / dist
val jy = dy / dist
// Gewichtung: 1/d^3 – stärker als 1/d^2,
// damit nahe Beacons die Lösung dominieren
val w = 1.0 / (di.pow(3) + 0.001)
jtj00 += w * jx * jx
jtj01 += w * jx * jy
jtj11 += w * jy * jy
jtr0 += w * jx * residual
jtr1 += w * jy * residual
}
val det = jtj00 * jtj11 - jtj01 * jtj01
if (abs(det) < 1e-20) return null
val deltaX = -(jtj11 * jtr0 - jtj01 * jtr1) / det
val deltaY = -(jtj00 * jtr1 - jtj01 * jtr0) / det
x += deltaX
y += deltaY
if (deltaX * deltaX + deltaY * deltaY < convergenceThreshold) {
break
}
}
return Position(x, y)
}
}
@@ -0,0 +1,158 @@
package com.erik.bleindoorpositioning.positioning
/**
* Schnittstelle für RSSI-Filter.
*/
interface RssiFilter {
fun update(rssi: Int): Double
fun getCurrentValue(): Double?
fun reset()
}
/**
* Gleitender Mittelwert.
* Einfach, stabil, aber träge bei Änderungen.
*/
class MovingAverageFilter(private val windowSize: Int = 10) : RssiFilter {
private val buffer = ArrayDeque<Int>(windowSize)
private var currentAverage: Double? = null
override fun update(rssi: Int): Double {
if (buffer.size >= windowSize) {
buffer.removeFirst()
}
buffer.addLast(rssi)
currentAverage = buffer.average()
return currentAverage!!
}
override fun getCurrentValue(): Double? = currentAverage
override fun reset() {
buffer.clear()
currentAverage = null
}
}
/**
* Exponentieller gleitender Mittelwert.
* Guter Kompromiss zwischen Glättung und Reaktionszeit.
*
* @param alpha Glättungsfaktor 0 < alpha <= 1.
* Klein = starke Glättung, groß = schnelle Reaktion.
*/
class ExponentialMovingAverageFilter(private val alpha: Double = 0.01) : RssiFilter {
init {
require(alpha in 0.0..1.0) { "alpha muss in [0, 1] liegen, war: $alpha" }
}
private var ema: Double? = null
override fun update(rssi: Int): Double {
val current = ema
val newEma = if (current == null) {
rssi.toDouble()
} else {
alpha * rssi + (1.0 - alpha) * current
}
ema = newEma
return newEma
}
override fun getCurrentValue(): Double? = ema
override fun reset() {
ema = null
}
}
/**
* 1D-Kalman-Filter für RSSI-Glättung.
*
* Modell: Zustand = wahrer RSSI-Wert, Prozess = Random Walk.
* Optimal bei Gauß'schem Rauschen; in der Praxis eine Näherung.
*
* @param processNoise Q – erwartete Varianz der Zustandsänderung pro Schritt.
* @param measurementNoise R – Varianz des Messrauschens (aus stationärer Messung).
*/
class KalmanFilter(
private val processNoise: Double = 1.0,
private val measurementNoise: Double = 5.0
) : RssiFilter {
private var estimate: Double? = null
private var errorCovariance: Double = 1.0
override fun update(rssi: Int): Double {
val currentEstimate = estimate
if (currentEstimate == null) {
estimate = rssi.toDouble()
errorCovariance = measurementNoise
return estimate!!
}
// Predict
val predictedEstimate = currentEstimate
val predictedError = errorCovariance + processNoise
// Update
val kalmanGain = predictedError / (predictedError + measurementNoise)
val newEstimate = predictedEstimate + kalmanGain * (rssi - predictedEstimate)
val newError = (1.0 - kalmanGain) * predictedError
estimate = newEstimate
errorCovariance = newError
return newEstimate
}
override fun getCurrentValue(): Double? = estimate
override fun reset() {
estimate = null
errorCovariance = 1.0
}
}
/**
* Verwaltet einen Filter pro Beacon (identifiziert über Minor-ID).
* Verwirft RSSI-Ausreißer die zu weit vom aktuellen Filterwert abweichen.
*
* @param outlierThreshold Max. erlaubte Abweichung vom Filterwert in dBm.
*/
class BeaconFilterManager(
private val outlierThreshold: Double = 20.0,
private val filterFactory: () -> RssiFilter
) {
private val filters = mutableMapOf<Int, RssiFilter>()
fun update(minor: Int, rssi: Int): Double {
val filter = filters.getOrPut(minor) { filterFactory() }
// Ausreißer verwerfen wenn zu weit vom aktuellen Filterwert entfernt
val currentValue = filter.getCurrentValue()
if (currentValue != null && kotlin.math.abs(rssi - currentValue) > outlierThreshold) {
return currentValue
}
return filter.update(rssi)
}
fun getFilteredValue(minor: Int): Double? {
return filters[minor]?.getCurrentValue()
}
fun reset() {
filters.values.forEach { it.reset() }
filters.clear()
}
fun resetBeacon(minor: Int) {
filters[minor]?.reset()
filters.remove(minor)
}
}
@@ -0,0 +1,632 @@
package com.erik.bleindoorpositioning.ui.screens
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.erik.bleindoorpositioning.ble.BleScanner
import com.erik.bleindoorpositioning.data.AppConfig
import com.erik.bleindoorpositioning.data.ConfigRepository
import com.erik.bleindoorpositioning.model.BeaconConfig
import com.erik.bleindoorpositioning.model.RoomConfig
import com.erik.bleindoorpositioning.model.ScannedBeacon
/**
* Konfigurationsbildschirm für Raum und Beacons.
*
* @param bleScanner Der [BleScanner] für BLE-Operationen.
* @param configRepository Das [ConfigRepository] für den Zugriff auf die Konfiguration.
* @param paddingValues Die Padding-Werte vom Scaffold.
*/
@Composable
fun ConfigScreen(
bleScanner: BleScanner,
configRepository: ConfigRepository,
paddingValues: PaddingValues
) {
val context = LocalContext.current
var floorWidth by rememberSaveable { mutableStateOf("") }
var floorHeight by rememberSaveable { mutableStateOf("") }
var rooms by remember { mutableStateOf<List<RoomConfig>>(emptyList()) }
var configuredBeacons by remember { mutableStateOf<List<BeaconConfig>>(emptyList()) }
var showAddBeaconDialog by remember { mutableStateOf<ScannedBeacon?>(null) }
var showEditBeaconDialog by remember { mutableStateOf<BeaconConfig?>(null) }
var showAddRoomDialog by remember { mutableStateOf(false) }
var showEditRoomDialog by remember { mutableStateOf<RoomConfig?>(null) }
// Einklapp-States
var floorSectionExpanded by rememberSaveable { mutableStateOf(true) }
var roomsSectionExpanded by rememberSaveable { mutableStateOf(true) }
var beaconsSectionExpanded by rememberSaveable { mutableStateOf(true) }
var learningSectionExpanded by rememberSaveable { mutableStateOf(true) }
val isScanning by bleScanner.isScanning.collectAsState()
val scannedBeaconsForDiscovery by bleScanner.beacons.collectAsState()
LaunchedEffect(Unit) {
val config = configRepository.loadConfig()
floorWidth = config.floorWidth.toString()
floorHeight = config.floorHeight.toString()
rooms = config.rooms
configuredBeacons = config.beacons
}
// Auto-Save Funktion
fun saveConfig() {
val width = floorWidth.replace(',', '.').toDoubleOrNull()
val height = floorHeight.replace(',', '.').toDoubleOrNull()
if (width != null && height != null) {
val newConfig = AppConfig(width, height, rooms, configuredBeacons)
configRepository.saveConfig(newConfig)
if (isScanning) {
bleScanner.reloadConfig()
}
}
}
// Auto-Save bei Änderungen der Listen
LaunchedEffect(rooms, configuredBeacons) {
if (floorWidth.isNotBlank() && floorHeight.isNotBlank()) {
saveConfig()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Stockwerkkonfiguration
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { floorSectionExpanded = !floorSectionExpanded },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Stockwerkkonfiguration", style = MaterialTheme.typography.titleMedium)
Icon(
imageVector = if (floorSectionExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = if (floorSectionExpanded) "Einklappen" else "Ausklappen"
)
}
if (floorSectionExpanded) {
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = floorWidth,
onValueChange = {
floorWidth = it
saveConfig()
},
label = { Text("Breite (m)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Next),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = floorHeight,
onValueChange = {
floorHeight = it
saveConfig()
},
label = { Text("Höhe (m)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done),
modifier = Modifier.weight(1f)
)
}
}
}
}
Spacer(Modifier.height(12.dp))
// Raum-Sektion
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { roomsSectionExpanded = !roomsSectionExpanded },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Räume", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
if (roomsSectionExpanded) {
Button(onClick = { showAddRoomDialog = true }) {
Text("Raum hinzufügen")
}
Spacer(Modifier.width(8.dp))
}
Icon(
imageVector = if (roomsSectionExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = if (roomsSectionExpanded) "Einklappen" else "Ausklappen"
)
}
}
if (roomsSectionExpanded) {
Spacer(Modifier.height(8.dp))
if (rooms.isEmpty()) {
Text("Noch keine Räume hinzugefügt.", style = MaterialTheme.typography.bodySmall)
} else {
rooms.forEach { room ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp).clickable { showEditRoomDialog = room }
) {
Text(
text = "${room.name}: (${room.x}, ${room.y}) - ${room.width}x${room.height}m",
modifier = Modifier.weight(1f)
)
OutlinedButton(onClick = {
rooms = rooms.filter { it != room }
}) {
Text("Löschen")
}
}
}
}
}
}
}
Spacer(Modifier.height(12.dp))
// Konfigurierte Beacons
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { beaconsSectionExpanded = !beaconsSectionExpanded },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Konfigurierte Beacons", style = MaterialTheme.typography.titleMedium)
Icon(
imageVector = if (beaconsSectionExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = if (beaconsSectionExpanded) "Einklappen" else "Ausklappen"
)
}
if (beaconsSectionExpanded) {
Spacer(Modifier.height(8.dp))
if (configuredBeacons.isEmpty()) {
Text("Noch keine Beacons konfiguriert.", style = MaterialTheme.typography.bodySmall)
} else {
configuredBeacons.sortedBy { it.minor }.forEach { beacon ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp).clickable { showEditBeaconDialog = beacon }
) {
Text(
text = "Minor: ${beacon.minor}, Pos: (${beacon.x}, ${beacon.y}), " +
"RSSI@1m: ${beacon.rssiAtOneMeter}, PLE: ${beacon.pathLossExponent}",
modifier = Modifier.weight(1f)
)
OutlinedButton(onClick = {
configuredBeacons = configuredBeacons.filter { it.minor != beacon.minor }
}) {
Text("Löschen")
}
}
}
}
}
}
}
Spacer(Modifier.height(12.dp))
// Beacon-Anlern-Sektion
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { learningSectionExpanded = !learningSectionExpanded },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Beacons anlernen", style = MaterialTheme.typography.titleMedium)
Icon(
imageVector = if (learningSectionExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = if (learningSectionExpanded) "Einklappen" else "Ausklappen"
)
}
if (learningSectionExpanded) {
Spacer(Modifier.height(8.dp))
Button(onClick = {
if (isScanning) bleScanner.stopScanning() else bleScanner.startScanning()
}) {
Text(if (isScanning) "Scan stoppen" else "Nach Beacons scannen")
}
if (isScanning) {
Spacer(Modifier.height(8.dp))
scannedBeaconsForDiscovery.values.toList().sortedBy { it.minor }.forEach { beacon ->
val isConfigured = configuredBeacons.any { it.minor == beacon.minor }
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(enabled = !isConfigured) { showAddBeaconDialog = beacon },
colors = if (isConfigured) CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
else CardDefaults.cardColors()
) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Text("Minor: ${beacon.minor} | RSSI: ${beacon.rssi}", modifier = Modifier.weight(1f))
if (isConfigured) {
Text("Konfiguriert", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
}
}
}
// Dialog zum Hinzufügen eines Beacons
if (showAddBeaconDialog != null) {
AddBeaconDialog(
beacon = showAddBeaconDialog!!,
onDismiss = { showAddBeaconDialog = null },
onAddBeacon = { newBeaconConfig ->
configuredBeacons = (configuredBeacons + newBeaconConfig).distinctBy { it.minor }.sortedBy { it.minor }
showAddBeaconDialog = null
}
)
}
// Dialog zum Bearbeiten eines Beacons
if (showEditBeaconDialog != null) {
EditBeaconDialog(
beacon = showEditBeaconDialog!!,
onDismiss = { showEditBeaconDialog = null },
onUpdateBeacon = { updatedBeacon ->
configuredBeacons = configuredBeacons.map {
if (it.minor == updatedBeacon.minor) updatedBeacon else it
}
showEditBeaconDialog = null
}
)
}
// Dialog zum Hinzufügen eines Raumes
if (showAddRoomDialog) {
AddEditRoomDialog(
onDismiss = { showAddRoomDialog = false },
onConfirm = { newRoom ->
rooms = (rooms + newRoom).distinctBy { it.name }
showAddRoomDialog = false
}
)
}
// Dialog zum Bearbeiten eines Raumes
if (showEditRoomDialog != null) {
AddEditRoomDialog(
room = showEditRoomDialog,
onDismiss = { showEditRoomDialog = null },
onConfirm = { updatedRoom ->
rooms = rooms.map {
if (it.name == showEditRoomDialog!!.name) updatedRoom else it
}
showEditRoomDialog = null
}
)
}
}
}
/**
* Dialog zum Hinzufügen eines Beacons mit Positionsangabe.
*/
@Composable
private fun AddBeaconDialog(
beacon: ScannedBeacon,
onDismiss: () -> Unit,
onAddBeacon: (BeaconConfig) -> Unit
) {
var x by rememberSaveable { mutableStateOf("") }
var y by rememberSaveable { mutableStateOf("") }
var rssiAtOneMeter by rememberSaveable { mutableStateOf("-65") }
var pathLossExponent by rememberSaveable { mutableStateOf("5.5") }
val context = LocalContext.current
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(16.dp),
tonalElevation = 8.dp
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Beacon ${beacon.minor} hinzufügen", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(16.dp))
Text("Gescannte UUID: ${beacon.uuid}", style = MaterialTheme.typography.bodySmall)
Text("Aktuelles RSSI: ${beacon.rssi}", style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = x,
onValueChange = { x = it },
label = { Text("X-Koordinate (m)") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = y,
onValueChange = { y = it },
label = { Text("Y-Koordinate (m)") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = rssiAtOneMeter,
onValueChange = { rssiAtOneMeter = it },
label = { Text("RSSI bei 1m (dBm)") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = pathLossExponent,
onValueChange = { pathLossExponent = it },
label = { Text("Path Loss Exponent") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done
)
)
Spacer(Modifier.height(24.dp))
Row(
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
Text("Abbrechen")
}
Spacer(Modifier.width(8.dp))
Button(onClick = {
val xPos = x.replace(',', '.').toDoubleOrNull()
val yPos = y.replace(',', '.').toDoubleOrNull()
val rssi = rssiAtOneMeter.replace(',', '.').toDoubleOrNull()
val ple = pathLossExponent.replace(',', '.').toDoubleOrNull()
if (xPos != null && yPos != null && rssi != null && ple != null) {
onAddBeacon(BeaconConfig(beacon.minor, xPos, yPos, rssi, ple))
} else {
Toast.makeText(context, "Ungültige Eingaben", Toast.LENGTH_SHORT).show()
}
}) {
Text("Hinzufügen")
}
}
}
}
}
}
@Composable
private fun EditBeaconDialog(
beacon: BeaconConfig,
onDismiss: () -> Unit,
onUpdateBeacon: (BeaconConfig) -> Unit
) {
var x by rememberSaveable { mutableStateOf(beacon.x.toString()) }
var y by rememberSaveable { mutableStateOf(beacon.y.toString()) }
var rssiAtOneMeter by rememberSaveable { mutableStateOf(beacon.rssiAtOneMeter.toString()) }
var pathLossExponent by rememberSaveable { mutableStateOf(beacon.pathLossExponent.toString()) }
val context = LocalContext.current
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(16.dp),
tonalElevation = 8.dp
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Beacon ${beacon.minor} bearbeiten", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = x,
onValueChange = { x = it },
label = { Text("X-Koordinate (m)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = y,
onValueChange = { y = it },
label = { Text("Y-Koordinate (m)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = rssiAtOneMeter,
onValueChange = { rssiAtOneMeter = it },
label = { Text("RSSI bei 1m (dBm)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = pathLossExponent,
onValueChange = { pathLossExponent = it },
label = { Text("Path Loss Exponent") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done)
)
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
Text("Abbrechen")
}
Spacer(Modifier.width(8.dp))
Button(onClick = {
val xPos = x.replace(',', '.').toDoubleOrNull()
val yPos = y.replace(',', '.').toDoubleOrNull()
val rssi = rssiAtOneMeter.replace(',', '.').toDoubleOrNull()
val ple = pathLossExponent.replace(',', '.').toDoubleOrNull()
if (xPos != null && yPos != null && rssi != null && ple != null) {
onUpdateBeacon(beacon.copy(x = xPos, y = yPos, rssiAtOneMeter = rssi, pathLossExponent = ple))
} else {
Toast.makeText(context, "Ungültige Eingaben", Toast.LENGTH_SHORT).show()
}
}) {
Text("Speichern")
}
}
}
}
}
}
/**
* Dialog zum Hinzufügen oder Bearbeiten eines Raums.
*/
@Composable
private fun AddEditRoomDialog(
room: RoomConfig? = null,
onDismiss: () -> Unit,
onConfirm: (RoomConfig) -> Unit
) {
var name by rememberSaveable { mutableStateOf(room?.name ?: "") }
var x by rememberSaveable { mutableStateOf(room?.x?.toString() ?: "") }
var y by rememberSaveable { mutableStateOf(room?.y?.toString() ?: "") }
var width by rememberSaveable { mutableStateOf(room?.width?.toString() ?: "") }
var height by rememberSaveable { mutableStateOf(room?.height?.toString() ?: "") }
val context = LocalContext.current
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(16.dp),
tonalElevation = 8.dp
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
if (room == null) "Raum hinzufügen" else "Raum bearbeiten",
style = MaterialTheme.typography.titleLarge
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = x,
onValueChange = { x = it },
label = { Text("X-Position (m)") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = y,
onValueChange = { y = it },
label = { Text("Y-Position (m)") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = width,
onValueChange = { width = it },
label = { Text("Breite (m)") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Next
),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = height,
onValueChange = { height = it },
label = { Text("Höhe (m)") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Done
),
modifier = Modifier.weight(1f)
)
}
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
Text("Abbrechen")
}
Spacer(Modifier.width(8.dp))
Button(onClick = {
val xPos = x.replace(',', '.').toDoubleOrNull()
val yPos = y.replace(',', '.').toDoubleOrNull()
val widthVal = width.replace(',', '.').toDoubleOrNull()
val heightVal = height.replace(',', '.').toDoubleOrNull()
if (name.isNotBlank() && xPos != null && yPos != null && widthVal != null && heightVal != null) {
onConfirm(
RoomConfig(
name = name,
x = xPos,
y = yPos,
width = widthVal,
height = heightVal
)
)
} else {
Toast.makeText(context, "Ungültige Eingaben", Toast.LENGTH_SHORT)
.show()
}
}) {
Text("Speichern")
}
}
}
}
}
}
@@ -0,0 +1,117 @@
package com.erik.bleindoorpositioning.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.erik.bleindoorpositioning.ble.BleScanner
import com.erik.bleindoorpositioning.data.ConfigRepository
/**
* Hauptbildschirm der App, der die Tab-Navigation verwaltet.
*
* @param bleScanner Der [BleScanner] für BLE-Operationen.
* @param configRepository Das [ConfigRepository] für den Zugriff auf die Konfiguration.
* @param isDarkMode Gibt an, ob Dark Mode aktiv ist.
* @param onToggleDarkMode Callback zum Umschalten zwischen Dark und Light Mode.
*/
@Composable
fun MainScreen(
bleScanner: BleScanner,
configRepository: ConfigRepository,
isDarkMode: Boolean,
onToggleDarkMode: () -> Unit
) {
var tabIndex by rememberSaveable { mutableIntStateOf(0) }
val tabs = listOf(
TabItem("Positioning", Icons.Default.Place),
TabItem("Config", Icons.Default.Settings)
)
Scaffold(
topBar = {
Column {
// Header-Zeile mit Titel und Toggle-Button
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "BLE Indoor Positioning",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = onToggleDarkMode) {
Icon(
imageVector = if (isDarkMode) Icons.Filled.LightMode else Icons.Filled.DarkMode,
contentDescription = if (isDarkMode) "Switch to Light Mode" else "Switch to Dark Mode",
tint = MaterialTheme.colorScheme.primary
)
}
}
// Tab-Leiste
TabRow(selectedTabIndex = tabIndex, modifier = Modifier.fillMaxWidth()) {
tabs.forEachIndexed { index, tab ->
Tab(
selected = tabIndex == index,
onClick = { tabIndex = index },
text = { Text(tab.title) },
icon = { Icon(tab.icon, contentDescription = null) }
)
}
}
}
}
) { paddingValues ->
when (tabIndex) {
0 -> PositioningScreen(
bleScanner = bleScanner,
configRepository = configRepository,
paddingValues = paddingValues
)
1 -> ConfigScreen(
bleScanner = bleScanner,
configRepository = configRepository,
paddingValues = paddingValues
)
}
}
}
/**
* Datenklasse zur Darstellung eines Tabs in der Tab-Leiste.
*
* @param title Der Titel des Tabs.
* @param icon Das Icon des Tabs.
*/
private data class TabItem(
val title: String,
val icon: ImageVector
)
@@ -0,0 +1,282 @@
package com.erik.bleindoorpositioning.ui.screens
import android.Manifest
import android.os.Build
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material3.*
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.erik.bleindoorpositioning.ble.BleScanner
import com.erik.bleindoorpositioning.data.ConfigRepository
import com.erik.bleindoorpositioning.map.FloorplanView
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
/**
* Bildschirm für die Anzeige der Echtzeit-Positionierung.
*
* @param bleScanner Der [BleScanner] für BLE-Operationen.
* @param configRepository Das [ConfigRepository] für den Zugriff auf die Konfiguration.
* @param paddingValues Die Padding-Werte vom Scaffold.
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PositioningScreen(
bleScanner: BleScanner,
configRepository: ConfigRepository,
paddingValues: PaddingValues
) {
val permissions = buildList {
add(Manifest.permission.ACCESS_FINE_LOCATION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
add(Manifest.permission.BLUETOOTH_SCAN)
add(Manifest.permission.BLUETOOTH_CONNECT)
}
}
val permissionState = rememberMultiplePermissionsState(permissions)
val beacons by bleScanner.beacons.collectAsState()
val isScanning by bleScanner.isScanning.collectAsState()
val positionResult by bleScanner.position.collectAsState()
val config = configRepository.loadConfig()
val isBeaconListExpanded = remember { mutableStateOf(false) }
val showFloorplanFullscreen = remember { mutableStateOf(false) }
LaunchedEffect(key1 = true) {
permissionState.launchMultiplePermissionRequest()
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
) {
// --- Header + Scan-Kontrolle ---
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = "BLE Indoor Positioning",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(8.dp))
if (!permissionState.allPermissionsGranted) {
Button(onClick = { permissionState.launchMultiplePermissionRequest() }) {
Text("Berechtigungen anfordern")
}
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Button(
onClick = {
if (isScanning) bleScanner.stopScanning()
else bleScanner.startScanning()
}
) {
Text(if (isScanning) "Stop" else "Start")
}
Text(
text = if (isScanning) "Scannt..." else "Gestoppt",
color = if (isScanning)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.outline
)
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// --- Positionsanzeige ---
positionResult?.let { result ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = "Position: (%.2f, %.2f) m".format(
result.position.x, result.position.y
),
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Beacons: ${result.usedBeacons.joinToString(", ")}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// --- Grundriss ---
Card(
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
) {
Box(modifier = Modifier.fillMaxSize()) {
FloorplanView(
beacons = beacons,
positionResult = positionResult,
floorWidthMeters = config.floorWidth,
floorHeightMeters = config.floorHeight,
roomConfigs = config.rooms,
beaconConfigs = config.beacons,
modifier = Modifier.fillMaxSize()
)
// Fullscreen-Button für FloorplanView
IconButton(
onClick = { showFloorplanFullscreen.value = true },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
) {
Icon(
imageVector = Icons.Default.Fullscreen,
contentDescription = "Vollbildmodus"
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// --- Beacon-Liste ---
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Beacons (${beacons.size}/${config.beacons.size}):",
style = MaterialTheme.typography.titleMedium
)
// Expand/Collapse Button für Beacon-Liste
IconButton(onClick = { isBeaconListExpanded.value = !isBeaconListExpanded.value }) {
Icon(
imageVector = if (isBeaconListExpanded.value) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (isBeaconListExpanded.value) "Beacon-Liste einklappen" else "Beacon-Liste ausklappen"
)
}
}
// Animierte Sichtbarkeit für die Beacon-Liste
AnimatedVisibility(
visible = isBeaconListExpanded.value,
enter = expandVertically(),
exit = shrinkVertically()
) {
Column {
Spacer(modifier = Modifier.height(8.dp))
LazyColumn {
items(beacons.values.sortedBy { it.minor }) { beacon ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
) {
Row(
modifier = Modifier
.padding(12.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = "Beacon ${beacon.minor}",
style = MaterialTheme.typography.titleSmall
)
Text(
text = "Distanz: ${"%.2f".format(
beacon.estimatedDistance ?: 0.0
)} m",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
Column(horizontalAlignment = Alignment.End) {
Text(text = "RSSI: ${beacon.rssi} dBm")
Text(
text = "Filtered: ${"%.1f".format(beacon.filteredRssi)} dBm",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
}
}
}
}
}
}
}
}
}
// Vollbild-Dialog für FloorplanView
if (showFloorplanFullscreen.value) {
Dialog(onDismissRequest = { showFloorplanFullscreen.value = false }) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface
) {
Box(modifier = Modifier.fillMaxSize()) {
FloorplanView(
beacons = beacons,
positionResult = positionResult,
floorWidthMeters = config.floorWidth,
floorHeightMeters = config.floorHeight,
roomConfigs = config.rooms,
beaconConfigs = config.beacons,
modifier = Modifier.fillMaxSize()
)
// Schließen-Button im Vollbildmodus
IconButton(
onClick = { showFloorplanFullscreen.value = false },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Vollbildmodus schließen"
)
}
}
}
}
}
}
@@ -0,0 +1,19 @@
package com.erik.bleindoorpositioning.ui.theme
import androidx.compose.ui.graphics.Color
// Dark Mode Farben (Schwarz + Lila)
val DarkBackground = Color.Black
val DarkPrimary = Color(0xFFBB86FC) // Helles Lila für Dark Mode
val DarkSurface = Color(0xFF121212) // Dunkelgrau für Surfaces
val DarkOnPrimary = Color.Black
val DarkSecondary = Color(0xFF6A5ACD) // Dunkles Lila-ish
val DarkTertiary = Color(0xFFDA70D6) // Orchid
// Light Mode Farben (Weiß + Lila)
val LightBackground = Color.White
val LightPrimary = Color(0xFF6200EE) // Dunkles Lila für Light Mode
val LightSurface = Color(0xFFF5F5F5) // Hellgrau für Surfaces
val LightOnPrimary = Color.White
val LightSecondary = Color(0xFF6A5ACD) // Slate Blue
val LightTertiary = Color(0xFFDA70D6) // Orchid
@@ -0,0 +1,43 @@
package com.erik.bleindoorpositioning.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val DarkColorScheme = darkColorScheme(
primary = DarkPrimary,
onPrimary = DarkOnPrimary,
secondary = DarkSecondary,
tertiary = DarkTertiary,
background = DarkBackground,
surface = DarkSurface,
onBackground = Color.White,
onSurface = Color.White
)
private val LightColorScheme = lightColorScheme(
primary = LightPrimary,
onPrimary = LightOnPrimary,
secondary = LightSecondary,
tertiary = LightTertiary,
background = LightBackground,
surface = LightSurface,
onBackground = Color.Black,
onSurface = Color.Black
)
@Composable
fun BleIndoorPositioningTheme(
darkTheme: Boolean,
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,34 @@
package com.erik.bleindoorpositioning.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">BleIndoorPositioning</string>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.BleIndoorPositioning" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,17 @@
package com.erik.bleindoorpositioning
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
+5
View File
@@ -0,0 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
}
+23
View File
@@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
+51
View File
@@ -0,0 +1,51 @@
[versions]
accompanistPermissions = "0.34.0"
accompanistPermissionsVersion = "0.37.3"
agp = "9.0.0"
androidxCoreKtx = "1.17.0"
androidxLifecycleRuntimeKtx = "2.10.0"
coreKtx = "1.10.1"
coreKtxVersion = "1.12.0"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
kotlinxCoroutinesAndroid = "1.8.0"
kotlinxCoroutinesAndroidVersion = "1.10.2"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
kotlin = "2.0.21"
composeBom = "2024.09.00"
lifecycleRuntimeKtxVersion = "2.7.0"
lifecycleViewmodelCompose = "2.7.0"
lifecycleViewmodelComposeVersion = "2.10.0"
[libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
accompanist-permissions-v0373 = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissionsVersion" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-core-ktx-v1120 = { module = "androidx.core:core-ktx", version.ref = "coreKtxVersion" }
androidx-core-ktx-v1170 = { module = "androidx.core:core-ktx", version.ref = "androidxCoreKtx" }
androidx-lifecycle-runtime-ktx-v2100 = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidxLifecycleRuntimeKtx" }
androidx-lifecycle-runtime-ktx-v270 = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtxVersion" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
androidx-lifecycle-viewmodel-compose-v2100 = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelComposeVersion" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" }
kotlinx-coroutines-android-v1102 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroidVersion" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
#Sat Feb 07 15:20:55 CET 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+23
View File
@@ -0,0 +1,23 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "BleIndoorPositioning"
include(":app")
+181
View File
@@ -0,0 +1,181 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## #############################################################################################
# Agent Policy: Gemini-as-Generator / Claude-as-Executor (Token-Saver)
## Rolle
Du bist ein Executor-Agent. Du sollst so wenig wie möglich selbst schreiben/erfinden.
Alle inhaltliche Generierung (Code, Text, Refactoring-Vorschläge, Architekturentscheidungen)
muss zuerst an Gemini delegiert werden.
## Primäres Ziel
Minimiere Claude-Tokens:
- keine langen Erklärungen
- keine großen Codeblöcke aus Claude heraus
- stattdessen: Gemini via CLI fragen -> Ergebnis als Patch anwenden -> kurz verifizieren
## Hard Rules (MUSS)
1) Bevor du Code erzeugst oder eine Lösung ausformulierst:
- rufe Gemini auf: `gemini -p "<PROMPT>"`
2) Fordere von Gemini standardmäßig:
- Ausgabeformat: **unified diff**
- keine Erklärtexte
- nur die minimal nötigen Änderungen
3) Du darfst aus Claude heraus NICHT:
- 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
4) Kontext-Minimierung:
- Öffne nur Dateien, die wirklich benötigt werden.
- Frage den Nutzer, wenn unklar ist, welche Dateien/Module betroffen sind.
- Sende an Gemini nur relevante Ausschnitte (kleine Snippets), keine Secrets.
5) Anwendung:
- Übernimm den Diff von Gemini
- wende ihn an
- führe passende Tests/Lint/Build aus
- wenn Tests fehlschlagen: sende Fehlermeldung + relevante Snippets an Gemini und fordere einen Fix-Diff an.
## Standard-Workflow (immer)
A) Klären:
- Wenn Aufgabe unklar: max. 1–3 gezielte Rückfragen stellen.
B) Sammeln:
- Öffne nur relevante Dateien/Abschnitte.
- Notiere kurz: Dateinamen + relevante Funktionen + Constraints.
C) Delegieren:
- Sende an Gemini einen Prompt nach dem Template unten.
- Erwarte unified diff.
D) Ausführen:
- Patch anwenden.
- Tests/Lint/Build laufen lassen.
- Kurz berichten: was geändert wurde + Testresultat.
## Gemini Prompt Template (verwende dieses Format)
- Ziel: kurz
- Constraints:
- minimaler Diff
- keine neuen Dependencies (falls gewünscht)
- bestehender Stil
- Relevante Dateien/Snippets:
- File: path/to/file.ext
```<kurzer relevanter Ausschnitt>```
- Gewünschte Ausgabe:
- "OUTPUT ONLY unified diff, no explanations."
## Ausgabe-Regeln (Claude)
- Antworte kurz.
- Keine langen Erklärungen.
- Wenn du Code zeigen musst: zeige nur den Diff oder die wichtigsten geänderten Stellen.
## #############################################################################################
## Project Overview
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/`.
## Build & Run
### Android App (from `App v2/`)
```bash
./gradlew assembleDebug # Debug build
./gradlew test # Unit tests
./gradlew connectedAndroidTest # Instrumented tests (device/emulator required)
./gradlew clean assembleDebug # Clean build
# Single test class
./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
- 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)
### 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, ...).
**ESP32-C3 Besonderheiten:**
- Nutzt native USB-CDC → `delay(2000)` am Anfang von `setup()` nötig, damit Serial-Ausgaben nicht verloren gehen
- TX-Power muss via `esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, ...)` gesetzt werden, NICHT via `BLEDevice::setPower()` (setzt nur DEFAULT, nicht ADV)
- 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
### Android App
**Data flow:** BLE Hardware → `BleScanner` → `BeaconFilterManager` (RSSI filtering + Ausreißer-Erkennung) → `PositioningCalculator` (Trilateration + Raumbegrenzung) → `PositionSmoother` (2D-EMA Glättung) → UI via `StateFlow`
Key layers (`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`.
`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.
### 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
## Calibration Parameters
### 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
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`)
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
## Known Issues / Troubleshooting
### 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:
1. Android Netzwerkeinstellungen zurücksetzen (Einstellungen → Zurücksetzen → Netzwerkeinstellungen)
2. Ab Android 13: Bluetooth System-Cache kann nur via ADB gelöscht werden (`adb shell pm clear com.android.bluetooth`)
3. `USE_CUSTOM_MAC` im ESP32-Code aktivieren um dem Beacon eine neue MAC-Identität zu geben
4. ESP32 mit "Erase All Flash Before Sketch Upload" neu flashen
### 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
The codebase, comments, and documentation are in German. Respond in German when working with this project.
@@ -0,0 +1,124 @@
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <BLEBeacon.h>
#define DEVICE_NAME "ESP32 iBeacon"
#define SERVICE_UUID "87b99b2c-9080-4d04-8f00-a5d9a3f9a0e7"
#define BEACON_UUID "87b99b2c-9080-4d04-8f00-a5d9a3f9a0e7"
#define BEACON_UUID_REV "e7a0f9a3-d9a5-008f-044d8090-2c9bb987"
#define CHARACTERISTIC_UUID "87b99b2c-9080-4d04-8f00-a5d9a3f9a0e7"
BLEServer *pServer;
BLECharacteristic *pCharacteristic;
bool deviceConnected = false;
uint8_t value = 0;
class MyServerCallbacks: public BLEServerCallbacks {
void onConnect(BLEServer* pServer) {
deviceConnected = true;
Serial.println("deviceConnected = true");
};
void onDisconnect(BLEServer* pServer) {
deviceConnected = false;
Serial.println("deviceConnected = false");
// Restart advertising to be visible and connectable again
BLEAdvertising* pAdvertising;
pAdvertising = pServer->getAdvertising();
pAdvertising->start();
Serial.println("iBeacon advertising restarted");
}
};
class MyCallbacks: public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic *pCharacteristic) {
std::string rxValue = pCharacteristic->getValue();
if (rxValue.length() > 0) {
Serial.println("*********");
Serial.print("Received Value: ");
for (int i = 0; i < rxValue.length(); i++) {
Serial.print(rxValue[i]);
}
Serial.println();
Serial.println("*********");
}
}
};
void init_service() {
BLEAdvertising* pAdvertising;
pAdvertising = pServer->getAdvertising();
pAdvertising->stop();
// Create the BLE Service
BLEService *pService = pServer->createService(BLEUUID(SERVICE_UUID));
// Create a BLE Characteristic
pCharacteristic = pService->createCharacteristic(
CHARACTERISTIC_UUID,
BLECharacteristic::PROPERTY_READ |
BLECharacteristic::PROPERTY_WRITE |
BLECharacteristic::PROPERTY_NOTIFY
);
pCharacteristic->setCallbacks(new MyCallbacks());
pCharacteristic->addDescriptor(new BLE2902());
pAdvertising->addServiceUUID(BLEUUID(SERVICE_UUID));
// Start the service
pService->start();
pAdvertising->start();
}
void init_beacon() {
BLEAdvertising* pAdvertising;
pAdvertising = pServer->getAdvertising();
pAdvertising->stop();
// iBeacon
BLEBeacon myBeacon;
myBeacon.setManufacturerId(0x004C);
myBeacon.setMajor(1);
myBeacon.setMinor(3); // ESP32-ID: 1 to n
myBeacon.setSignalPower(0xc5);
myBeacon.setProximityUUID(BLEUUID(BEACON_UUID_REV));
BLEAdvertisementData advertisementData;
advertisementData.setFlags(0x1A);
advertisementData.setManufacturerData(myBeacon.getData());
pAdvertising->setAdvertisementData(advertisementData);
pAdvertising->start();
}
void setup() {
Serial.begin(115200);
Serial.println();
Serial.println("Initializing...");
Serial.flush();
BLEDevice::init(DEVICE_NAME);
pServer = BLEDevice::createServer();
pServer->setCallbacks(new MyServerCallbacks());
init_service();
init_beacon();
Serial.println("iBeacon + service defined and advertising!");
}
void loop() {
if (deviceConnected) {
Serial.printf("*** NOTIFY: %d ***n", value);
pCharacteristic->setValue(&value, 1);
pCharacteristic->notify();
value++;
}
delay(2000);
}
@@ -0,0 +1,146 @@
/*
* ESP32 iBeacon v2 - Optimiert für Indoor-Positionierung
*
* Dieser Sketch konfiguriert einen ESP32 als reinen iBeacon-Sender
* ohne Service-Funktionalität (kein GATT, keine Verbindungen).
*
* Optimierungen gegenüber v1:
* - Service-Teil entfernt (nur Advertising, keine Verbindungen)
* - Festes 100ms Advertising-Intervall für stabile RSSI-Werte
* - TX-Power explizit auf Maximum (+9 dBm) fixiert
* - Kalibrierte Signal-Power (-65 dBm @ 1m)
* - Korrekte Advertising-Flags (0x06)
* - BLE-Sleep zwischen Advertising-Events für Akkubetrieb
*
* WICHTIG: Beim Flashen mehrerer ESP32s die BEACON_MINOR Konstante ändern!
*
* Basiert auf Analyse-Empfehlungen aus ESP32-Code-Analyse.md
* (Gemini + Claude, 2026-02-09)
*/
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEBeacon.h>
#include "esp_bt.h" // Für esp_bt_sleep_enable()
#include "esp_mac.h" // Für esp_base_mac_addr_set() (ESP-IDF v4.0+)
// =====================================================================
// BEACON KONFIGURATION - VOR DEM FLASHEN ANPASSEN!
// =====================================================================
// WICHTIG: Für jeden ESP32-Beacon eine ANDERE Minor-ID vergeben (1, 2, 3, ...)
#define BEACON_MINOR 1
// =====================================================================
// MAC-ADRESS-OVERRIDE (optional) - AKTIVIEREN FALLS ANDROID BLACKLISTING
// =====================================================================
// Wenn ein Android-Gerät diesen Beacon nicht findet (auch nicht mit nRF Connect),
// obwohl andere Handys ihn sehen, könnte die MAC-Adresse geblockt sein.
// Lösung: Auskommentieren + neue MAC setzen + neu flashen
//
// #define USE_CUSTOM_MAC
uint8_t custom_mac[6] = {0x02, 0xAA, 0xBB, 0xCC, 0xDD, 0x08}; // Locally administered MAC (Byte 0=0x02), Byte 5=Minor ID
// Gruppen-ID (für alle Beacons im selben System gleich)
#define BEACON_MAJOR 1
// =====================================================================
// SYSTEM-KONSTANTEN (normalerweise nicht ändern)
// =====================================================================
#define DEVICE_NAME "ESP32 iBeacon"
#define BEACON_UUID "87b99b2c-9080-4d04-8f00-a5d9a3f9a0e7"
#define BEACON_UUID_REV "e7a0f9a3-d9a5-008f-044d8090-2c9bb987"
// Advertising-Intervall: 0xA0 = 160 * 0.625ms = 100ms
#define ADVERTISING_INTERVAL 0xA0
// Kalibrierter RSSI-Wert bei 1m Abstand (als Signed Byte)
// -65 dBm = 0xBF — muss mit RSSI_AT_ONE_METER in der Android-App übereinstimmen
// TODO: Pro Beacon individuell kalibrieren (60s RSSI-Messung bei 1m Abstand)
#define SIGNAL_POWER_1M 0xBF
void setup() {
Serial.begin(115200);
delay(2000); // USB-CDC braucht Zeit zum Verbinden (ESP32-C3)
Serial.println();
Serial.println("=================================");
Serial.println("ESP32 iBeacon v2 - Indoor-Positioning");
Serial.println("=================================");
Serial.flush();
// Optional: Custom MAC setzen (MUSS vor BLEDevice::init() passieren!)
#ifdef USE_CUSTOM_MAC
esp_base_mac_addr_set(custom_mac);
Serial.print("Custom MAC gesetzt: ");
for (int i = 0; i < 6; i++) {
Serial.printf("%02X", custom_mac[i]);
if (i < 5) Serial.print(":");
}
Serial.println();
#endif
// BLE-Stack initialisieren
BLEDevice::init(DEVICE_NAME);
// BLE-MAC-Adresse ausgeben (zur Diagnose von Blacklisting-Problemen)
uint8_t ble_mac[6];
esp_read_mac(ble_mac, ESP_MAC_BT);
Serial.printf("BLE MAC: %02X:%02X:%02X:%02X:%02X:%02X\n",
ble_mac[0], ble_mac[1], ble_mac[2],
ble_mac[3], ble_mac[4], ble_mac[5]);
//Serial.flush();
// TX-Power auf Maximum setzen für beste Reichweite und konsistente RSSI-Werte
// Wichtig: Muss VOR dem Advertising-Start gesetzt werden
// TX-Power explizit für Advertising setzen (BLEDevice::setPower setzt nur DEFAULT, nicht ADV)
esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_ADV, ESP_PWR_LVL_P9); // +9 dBm für Advertising
esp_ble_tx_power_set(ESP_BLE_PWR_TYPE_DEFAULT, ESP_PWR_LVL_P9); // +9 dBm als Default
Serial.println("TX-Power: +9 dBm (Maximum für ESP32-C3)");
// iBeacon-Paket zusammenbauen
BLEBeacon myBeacon;
myBeacon.setManufacturerId(0x004C); // Apple Company ID (Little-Endian)
myBeacon.setMajor(BEACON_MAJOR);
myBeacon.setMinor(BEACON_MINOR);
myBeacon.setSignalPower(SIGNAL_POWER_1M); // Kalibrierter RSSI bei 1m
myBeacon.setProximityUUID(BLEUUID(BEACON_UUID_REV));
// Advertising-Daten konfigurieren
BLEAdvertisementData advertisementData;
advertisementData.setFlags(0x06); // LE General Discoverable + BR/EDR Not Supported
advertisementData.setManufacturerData(myBeacon.getData());
// Advertising-Parameter setzen
BLEAdvertising* pAdvertising = BLEDevice::getAdvertising();
pAdvertising->setScanResponse(false);
pAdvertising->setAdvertisementData(advertisementData);
pAdvertising->setMinInterval(ADVERTISING_INTERVAL); // 100ms
pAdvertising->setMaxInterval(ADVERTISING_INTERVAL); // Min = Max = konstantes Intervall
// BLE-Sleep aktivieren: ESP32 geht automatisch in Light-Sleep zwischen
// den Advertising-Events → spart Strom im Akkubetrieb
esp_bt_sleep_enable();
// Advertising starten
pAdvertising->start();
// Status ausgeben
Serial.println();
Serial.println("iBeacon aktiv!");
Serial.println("----------------------------");
Serial.printf("UUID: %s\n", BEACON_UUID);
Serial.printf("Major: %d\n", BEACON_MAJOR);
Serial.printf("Minor: %d\n", BEACON_MINOR);
Serial.printf("Signal: %d dBm @ 1m\n", (int8_t)SIGNAL_POWER_1M);
Serial.printf("Intervall: 100ms\n");
Serial.printf("TX-Power: +9 dBm\n");
Serial.println("----------------------------");
}
void loop() {
// Keine Aktion nötig - ESP32 advertised kontinuierlich.
// Zwischen Advertising-Events befindet sich der BLE-Stack im Light-Sleep.
delay(10000);
}
+161
View File
@@ -0,0 +1,161 @@
# ESP32 iBeacon Code-Analyse
Analyse des ESP32-Codes (`ESP32_iBeacon/ESP32_iBeacon.ino`) auf mögliche Ursachen
für instabile RSSI-Messwerte in der Android-App.
Datum: 2026-02-09
---
## Quelle: Gemini-Analyse
Die folgenden Probleme wurden von Google Gemini identifiziert:
### 1. KRITISCH: Advertising-Konflikt zwischen Service und iBeacon
**Gemini-Befund:** `init_service()` und `init_beacon()` überschreiben sich gegenseitig
das Advertising-Paket. `init_service()` startet Advertising mit Service-UUID, dann
stoppt `init_beacon()` alles und überschreibt es mit iBeacon-Daten. Das Service-Advertising
geht verloren. Beim Reconnect nach Disconnect wird `pAdvertising->start()` aufgerufen,
aber es ist unklar, welche Advertising-Daten dann aktiv sind.
**Gemini-Empfehlung:** Kombinierte Funktion nutzen, die iBeacon-Daten ins Haupt-Paket
und die Service-UUID ins Scan-Response-Paket legt.
**Eigene Einschätzung:** Bestätigt. Im Code wird in Zeile 78 (`init_service`) Advertising
gestartet, in Zeile 85 (`init_beacon`) sofort wieder gestoppt und neu konfiguriert.
Das ist zwar für den reinen iBeacon-Betrieb kein Problem (das letzte Paket gewinnt),
aber der `onDisconnect`-Callback (Zeile 29) startet Advertising erneut, ohne die
iBeacon-Daten neu zu setzen. Nach einer Verbindung könnten also fehlerhafte
Advertising-Pakete gesendet werden.
### 2. HOCH: Kein explizites Advertising-Intervall gesetzt
**Gemini-Befund:** Ohne explizites Intervall nutzt der ESP32 den BLE-Stack-Default.
Unregelmäßige Pakete führen zu variierenden Abtastraten auf der Android-Seite und
damit zu instabilen RSSI-Werten.
**Gemini-Empfehlung:** Festes Intervall von 100ms setzen:
```cpp
pAdvertising->setMinInterval(0xA0); // 160 * 0.625ms = 100ms
pAdvertising->setMaxInterval(0xA0);
```
**Eigene Einschätzung:** Bestätigt und sehr relevant. Der Default-Intervall liegt
typischerweise bei 100-1280ms, was für Indoor-Positioning zu unregelmäßig ist.
Ein festes, kurzes Intervall (100ms) sorgt für konsistente Datenrate auf der
Android-Seite. Die Android-App scannt bereits mit `SCAN_MODE_LOW_LATENCY`
(BleScanner.kt:165), profitiert aber nur davon, wenn der ESP32 auch regelmäßig sendet.
### 3. HOCH: TX-Power nicht explizit konfiguriert
**Gemini-Befund:** Die Sendeleistung wird nicht festgelegt, was zu variierender
Ausgangsleistung führen kann.
**Gemini-Empfehlung:**
```cpp
BLEDevice::setPower(ESP_PWR_LVL_P9); // +9 dBm
```
**Eigene Einschätzung:** Bestätigt. Ohne fixe TX-Power kann der ESP32 die Leistung
dynamisch anpassen. Für RSSI-basierte Distanzschätzung ist eine konstante
Sendeleistung zwingend notwendig, da die gesamte Berechnung auf dem
Log-Distance Path Loss Model basiert. Hinweis: Der Seeed XIAO ESP32-C3 unterstützt
bis zu +21 dBm – der korrekte Enum-Wert muss ggf. angepasst werden.
### 4. MITTEL: setSignalPower nicht kalibriert
**Gemini-Befund:** Der Wert `0xc5` (-59 dBm) im iBeacon-Paket ist ein Standardwert
und nicht für die verwendete Hardware kalibriert.
**Gemini-Empfehlung:** RSSI bei exakt 1m Abstand messen (60s Durchschnitt) und
den gemessenen Wert als Signed Byte in Hex setzen.
**Eigene Einschätzung:** Teilweise relevant. Der `signalPower`-Wert wird in der
Android-App aktuell nicht für die Distanzberechnung verwendet – stattdessen wird
`BeaconData.RSSI_AT_ONE_METER = -65.0` in Kotlin hardcoded (BeaconData.kt:37).
Die App liest zwar `txPower` aus dem Paket (BleScanner.kt:145), nutzt ihn aber
nicht im PositionCalculator. Trotzdem sollte der Wert korrekt sein, falls er
zukünftig genutzt wird.
### 5. MITTEL: Advertising-Flags
**Gemini-Befund:** Flags `0x1A` statt Standard-iBeacon `0x06`.
**Gemini-Empfehlung:** `0x06` verwenden (LE General Discoverable + BR/EDR Not Supported).
**Eigene Einschätzung:** Geringes Risiko. `0x1A` setzt zusätzlich das Bit für
"Simultaneous LE and BR/EDR (Controller)" – bei einem reinen BLE-Beacon unnötig,
aber in der Praxis unproblematisch. `0x06` wäre sauberer.
### 6. NIEDRIG: Manufacturer ID Byte-Order
**Gemini-Befund:** ESP32 sendet `0x004C` (Little-Endian, korrekt laut BLE-Spec).
Android-App parst mit `0x4C00`.
**Gemini-Empfehlung:** Android-App prüfen.
**Eigene Einschätzung:** Kein Problem. Android's `getManufacturerSpecificData()`
erwartet den Company Identifier in Host-Byte-Order. Der Wert `0x4C00` in der
Android-App (BeaconData.kt:12) ist korrekt für Apple's Company ID und funktioniert
nachweislich (die App empfängt ja Beacons). Die ESP32-Bibliothek übernimmt die
Konvertierung intern.
---
## Eigene Zusatzanalyse (Claude)
Über die Gemini-Analyse hinaus sind folgende Punkte aufgefallen:
### 7. HOCH: loop() sendet Notifications alle 2 Sekunden
In `loop()` (Zeile 114-123) wird bei bestehender Verbindung alle 2 Sekunden eine
Notification gesendet. Das ist für den iBeacon-Betrieb irrelevant, aber: Wenn sich
ein Gerät verbindet, könnte das Advertising unterbrochen oder verlangsamt werden.
Der ESP32 BLE-Stack kann nicht gleichzeitig eine aktive Verbindung bedienen und
mit voller Rate advertisen.
**Empfehlung:** Für reinen Beacon-Betrieb den gesamten Service-Teil entfernen
(init_service, MyCallbacks, MyServerCallbacks, Notification-Code in loop).
Der ESP32 soll nur advertisen, keine Verbindungen annehmen.
### 8. MITTEL: Kein Deep-Sleep / Light-Sleep zwischen Advertisements
Für Akkubetrieb fehlt jegliche Energiespar-Logik. Der ESP32 läuft permanent
im aktiven Modus, auch zwischen den Advertising-Events.
**Empfehlung:** Für den Seeed XIAO ESP32-C3 im Akkubetrieb Light-Sleep aktivieren
oder den ESP32 zwischen Advertisements in den Deep-Sleep schicken und per Timer
aufwecken.
### 9. NIEDRIG: PositionSmoother Alpha-Wert Diskrepanz
In der CLAUDE.md und im PositionSmoother-Defaultwert steht alpha=0.25, aber
in BleScanner.kt:52 wird `PositionSmoother(alpha = 0.15)` instanziiert.
Der tatsächlich verwendete Wert ist 0.15 (reaktiver als dokumentiert).
---
## Zusammenfassung: Prioritäten
| # | Priorität | Problem | Quelle |
|---|-----------|--------------------------------------|---------|
| 1 | KRITISCH | Advertising-Konflikt Service/iBeacon | Gemini |
| 2 | HOCH | Kein Advertising-Intervall gesetzt | Gemini |
| 3 | HOCH | TX-Power nicht fixiert | Gemini |
| 7 | HOCH | Verbindungen stören Advertising | Claude |
| 4 | MITTEL | SignalPower nicht kalibriert | Gemini |
| 5 | MITTEL | Advertising-Flags suboptimal | Gemini |
| 8 | MITTEL | Kein Energiesparmodus | Claude |
| 9 | NIEDRIG | Alpha-Wert Diskrepanz Doku vs Code | Claude |
| 6 | NIEDRIG | Manufacturer ID Byte-Order | Gemini |
## Empfohlene Maßnahmen (Reihenfolge)
1. Service-Teil komplett entfernen (nur iBeacon-Advertising, keine Verbindungen)
2. Festes Advertising-Intervall setzen (100ms)
3. TX-Power explizit auf Maximum fixieren
4. SignalPower im iBeacon-Paket kalibrieren (RSSI @ 1m messen)
5. Flags auf 0x06 ändern
6. Light-Sleep für Akkubetrieb implementieren
7. Alpha-Wert in CLAUDE.md korrigieren (0.15 statt 0.25)
+139
View File
@@ -0,0 +1,139 @@
Analyse und Implementierung eines BLE-basierten Indoor-Navigationssystems
unter realen Gebäudebedingungen
Bachelorarbeit an einer Fachhochschule
1. Projektvorstellung
Problemstellung und Motivation
Die satellitengestützte Navigation versagt in Innenräumen zuverlässig. In großen
öffentlichen Gebäuden – etwa Behörden, Kliniken oder Hochschulen – fehlt es an einer
praktikablen Lösung zur Orientierung und Navigation. Bluetooth Low Energy (BLE) bietet
als energieeffiziente, kostengünstige Funktechnologie eine vielversprechende Grundlage
für Indoor-Lokalisierung. Darüber hinaus kann ein solches System durch gezielte
Anpassungen – etwa Audio-Feedback oder Screenreader-Kompatibilität – den Alltag von
Menschen mit Behinderung erheblich erleichtern. Die praktische Zuverlässigkeit eines
BLE-basierten Ansatzes unter realen Gebäudebedingungen muss jedoch systematisch
untersucht werden.
Forschungsfrage und Zielsetzung
Die Arbeit untersucht die Frage: Wie zuverlässig kann eine BLE-basierte
Indoor-Navigation eine kontextbasierte Orientierung in öffentlichen Gebäuden
ermöglichen? Ziel ist die Entwicklung und messtechnische Evaluation eines
funktionsfähigen Prototyps, der mittels ESP32-basierter iBeacons und einer
Android-Applikation die Position eines Nutzers innerhalb einer Gebäudeetage schätzt.
Die Arbeit versteht sich als technische Machbarkeitsstudie mit Fokus auf die
elektrotechnischen Aspekte der BLE-Funkübertragung.
Kernpunkte
- RSSI-basierte Distanzschätzung mittels Log-Distance Path Loss Model
- Mehrstufige Signalfilterung (Kalman-Filter, EMA, gleitender Mittelwert)
mit Ausreißer-Erkennung
- Dreistufige Positionsberechnung: IDW-Schwerpunkt, Gauß-Newton-Trilateration,
adaptives Blending
- Quantitative Evaluation von Zuverlässigkeit, Stabilität und Latenz
unter realen Raumbedingungen
- Klare Abgrenzung: keine sicherheitskritische oder medizinische Anwendung
2. Konzept zur Umsetzung
Technischer Ansatz
Das System nutzt mehrere ESP32-Mikrocontroller als iBeacon-Sender, die in bekannten
Positionen über eine gesamte Gebäudeetage verteilt installiert werden. Die Anzahl
der Beacons richtet sich nach der Etagengröße und dem gewünschten Abdeckungsgrad.
Eine native Android-Applikation empfängt die BLE-Advertisements, extrahiert die
RSSI-Werte und berechnet daraus die Nutzerposition. Die Distanzschätzung basiert auf
dem Log-Distance Path Loss Model mit umgebungsspezifisch kalibrierten Parametern
(Referenz-RSSI bei 1 m, Pfadverlustexponent).
Evaluationsstrategie
Die Evaluation umfasst systematische Messreihen in realen Räumen der Fachhochschule.
Bewertet werden Positionsgenauigkeit (Abweichung zur Referenzposition), Stabilität
(Varianz über Zeit), Latenz (Reaktionszeit auf Positionsänderungen) sowie der Einfluss
von Hindernissen, Personenbewegungen und Mehrwegeausbreitung. Die Kalibrierungsparameter
werden empirisch bestimmt.
3. Fazit und Ausblick
Die Arbeit liefert eine fundierte Bewertung der praktischen Einsetzbarkeit BLE-basierter
Indoor-Lokalisierung. Der entwickelte Prototyp und die dokumentierten Messergebnisse
können als Grundlage für weitergehende Arbeiten dienen – etwa die Erweiterung auf
Zonenklassifikation, die Integration von Gebäudeplänen, die Umsetzung auf weiteren
Plattformen oder die barrierefreie Gestaltung für Menschen mit Behinderung.
4. Materialliste
Gewähltes Board: Seeed Studio XIAO ESP32-C3 (113991054)
Der XIAO ESP32-C3 ist ein ultrakompaktes Entwicklungsboard (21 × 17,8 mm) auf
Basis des ESP32-C3 (RISC-V). Es unterstützt BLE 5.0 und Bluetooth Mesh mit
einer konfigurierbaren Sendeleistung von bis zu +21 dBm. Die maximale
Signalreichweite beträgt unter idealen Bedingungen (freie Sichtlinie) ca.
100–150 m. In Innenräumen mit Wänden, Möbeln und Personen reduziert sich die
nutzbare Reichweite auf ca. 15–30 m. Für eine zuverlässige Positionsbestimmung
sollten Beacons in einem Abstand von ca. 8–12 m zueinander platziert werden,
da an jedem Punkt mindestens drei Beacons empfangbar sein müssen.
Weitere Vorteile des XIAO ESP32-C3:
- USB-C-Anschluss
- Integriertes Lademanagement für LiPo-Akkus (380 mA Schnellladung)
- U.FL-Anschluss für externe Antenne (optional, für größere Reichweite)
Beispielrechnung für eine typische FH-Etage (ca. 50 m × 20 m):
- Bei einem Raster von ca. 8–12 m Abstand werden ca. 10–15 Beacons benötigt.
Stromversorgung über LiPo-Akku
Da nicht an jedem Montageort eine Steckdose verfügbar ist, werden alle Beacons
über LiPo-Akkus betrieben. Der XIAO ESP32-C3 verfügt über ein integriertes
Lademanagement, sodass 3,7-V-LiPo-Akkus direkt an das Board angeschlossen und
über USB-C geladen werden können. Der Stromverbrauch im BLE-Advertising-Modus
hängt stark vom Advertising-Intervall ab: Bei einem Intervall von ca. 1 s und
Nutzung von Light-Sleep zwischen den Sendungen liegt der Verbrauch bei ca.
10–30 mA. Mit einem 3000-mAh-LiPo-Akku ergibt sich eine theoretische Laufzeit
von ca. 4–12 Tagen pro Beacon. Für den laufenden Messbetrieb sollten
ausreichend Ersatz-Akkus eingeplant werden, um während des Ladens den Betrieb
aufrechtzuerhalten.
3D-gedruckte Gehäuse (PETG)
Für eine saubere und geschützte Montage werden Gehäuse aus PETG im 3D-Druck
gefertigt. PETG eignet sich durch seine Schlagfestigkeit und Temperatur-
beständigkeit gut für den Einsatz in Gebäuden. Durch die kompakte Größe des
XIAO ESP32-C3 können die Gehäuse sehr klein ausfallen (Board + LiPo-Akku).
Ein Gehäuse benötigt ca. 20–30 g Filament. Für 15 Gehäuse inklusive
Druckfehlern und Testdrucken sollte ca. 400–600 g PETG eingeplant werden
(eine Standardspule fasst 1 kg).
Materialliste (Preise sind ungefähre Richtwerte, Stand 2025/2026)
Komponente Anzahl Stückpreis Gesamt
---------------------------------------------------------------------------
Seeed XIAO ESP32-C3 (113991054) 15 ca. 6 € ca. 90 €
LiPo-Akku 3,7 V / 3000 mAh 15 ca. 8 € ca. 120 €
LiPo-Akku 3,7 V / 3000 mAh (Ers.) 8 ca. 8 € ca. 64 €
USB-C-Kabel (kurz, zum Laden) 5 ca. 3 € ca. 15 €
PETG-Filament 1 kg (1,75 mm) 1 ca. 22 € ca. 22 €
Befestigungsmaterial (Set) 1 ca. 15 € ca. 15 €
Maßband / Laser-Entfernungsmesser 1 ca. 30 € ca. 30 €
---------------------------------------------------------------------------
Geschätzte Gesamtkosten ca. 356 €
Hinweise:
- Android-Smartphone (BLE-fähig) wird als vorhanden vorausgesetzt.
- Der XIAO ESP32-C3 ist u. a. bei Botland (ca. 5,90 €), Seeed Studio,
Mouser und DigiKey erhältlich.
- LiPo-Akkus sind bei Elektronik-Händlern und auf Amazon verfügbar.
Auf passenden JST-Stecker (1,25 mm oder 2,0 mm) achten.
- Bei Nutzung eines vorhandenen 3D-Druckers fallen nur Filamentkosten an.
- Gesamtkosten können je nach Bezugsquelle zwischen ca. 250 € und 400 €
variieren.
+152
View File
@@ -0,0 +1,152 @@
# Literaturrecherche: BLE-basierte Indoor-Navigation
**Thema der Bachelorarbeit:**
"Analyse und Implementierung eines BLE-basierten Indoor-Navigationssystems unter realen Gebäudebedingungen"
**Stand:** 2026-02-09
---
## Zusammenfassende Bewertung und Empfehlungen zur Abgrenzung
Die Recherche zeigt, dass BLE-basierte Indoor-Positionierung ein sehr aktives Forschungsfeld ist. Die von Ihnen gewählten Basistechnologien (RSSI, Path Loss Model, Kalman-Filter, Trilateration) sind etablierte Standardverfahren, die in vielen Arbeiten behandelt werden. Eine reine Implementierung dieser Verfahren wäre daher nicht neuartig genug.
**Die Einzigartigkeit und der wissenschaftliche Beitrag Ihrer Arbeit können und sollten in folgenden Bereichen liegen:**
1. **Die spezifische Kombination der Methoden:** Ihre geplante dreistufige Positionsberechnung (IDW-Schwerpunkt → Gauß-Newton → Adaptives Blending) ist eine hochentwickelte Heuristik. Die Analyse, *wie* diese Stufen zusammenwirken, um Stabilität und Genauigkeit unter realen Bedingungen zu verbessern (z. B. Übergabe des IDW-Ergebnisses als Startwert für Gauß-Newton), ist ein starker Fokus.
2. **Analyse unter "realen Gebäudebedingungen":** Viele wissenschaftliche Paper nutzen idealisierte Testumgebungen. Eine detaillierte Analyse der Störeinflüsse (Multipath, menschliche Körper, dynamische Umgebung) in einem echten, komplexen Gebäude und die Bewertung der Leistungsfähigkeit Ihres Systems *trotz* dieser Störungen ist ein wertvoller Beitrag.
3. **Fokus auf Stabilität und Latenz:** Anstatt nur die durchschnittliche Positionsgenauigkeit (z. B. 2 Meter Fehler) zu messen, sollten Sie Metriken für die Stabilität des Positionspfades (z. B. "Jitter" oder sprunghafte Änderungen) und die End-to-End-Latenz (von der Messung bis zur Anzeige) in den Vordergrund stellen.
4. **Barrierefreiheit als Anwendungsfall:** Die Untersuchung der spezifischen Anforderungen an ein Navigationssystem für Menschen mit Behinderung (z. B. höhere Zuverlässigkeitsanforderungen, sprachbasierte Ausgabe, Vermeidung von "springenden" Positionen) grenzt Ihre Arbeit klar von rein technisch-fokussierten Arbeiten ab.
**Empfehlung:** Positionieren Sie Ihre Arbeit nicht als die Erfindung eines völlig neuen Algorithmus, sondern als die **systematische Analyse, Implementierung und Evaluierung einer maßgeschneiderten Kombination von Filter- und Positionierungsverfahren, optimiert für den robusten Einsatz in einer realen, störanfälligen Umgebung, mit einem besonderen Augenmerk auf die Anwendbarkeit für barrierefreie Navigation.**
---
## 1. Grundlagen: BLE, iBeacon und RSSI-basierte Distanzschätzung
Dieser Abschnitt deckt die fundamentalen Technologien ab.
### Paper & Artikel
**Z. Farid, R. Nordin, M. Ismail (2013)**
- **Titel:** A Survey on Bluetooth Low Energy (BLE) Based Indoor Positioning
- **Publikationsort:** IEEE 9th International Conference on Ubiquitous Intelligence and Computing
- **Zusammenfassung:** Ein früher, aber immer noch relevanter Survey-Artikel, der die Grundlagen von BLE für die Indoor-Positionierung erläutert. Er vergleicht verschiedene Ansätze wie Trilateration und Fingerprinting und diskutiert die Herausforderungen der RSSI-basierten Messung.
- **Relevanz:** **Hoch**
- **Begründung:** Perfekt für die Einleitung und den Grundlagen-Teil Ihrer Arbeit. Bietet eine breite Übersicht über das Feld und hilft, Ihre gewählten Methoden im Gesamtkontext einzuordnen.
**J. Paek, J. H. Kim, K. B. Kim (2016)**
- **Titel:** A Log-distance Path Loss Model for Bluetooth Low Energy in Indoor Environments
- **Publikationsort:** International Conference on Advanced Communication Technology (ICACT)
- **Zusammenfassung:** Dieses Paper konzentriert sich speziell auf die Kalibrierung und Anwendung des Log-Distance Path Loss Models für BLE-Signale in Innenräumen. Es untersucht experimentell die Bestimmung der Modellparameter (n und A) in verschiedenen Umgebungen.
- **Relevanz:** **Hoch**
- **Begründung:** Direkt relevant für Ihre RSSI-basierte Distanzschätzung. Es liefert die theoretische Grundlage und praktische Hinweise zur Kalibrierung des Modells, was ein wichtiger erster Schritt in Ihrem Projekt sein wird.
### Bücher
**K. Townsend, C. Cufí, A. Davidson (2014)**
- **Titel:** Getting Started with Bluetooth Low Energy
- **Publikationsort:** O'Reilly Media
- **Zusammenfassung:** Ein sehr praxisorientiertes Buch, das die technischen Details des BLE-Protokolls, Advertising, GATT-Profile und die Interaktion mit Peripheriegeräten erklärt. Es ist weniger akademisch, aber technisch sehr fundiert.
- **Relevanz:** **Mittel**
- **Begründung:** Sehr nützlich für das grundlegende Verständnis der Implementierungsdetails auf dem ESP32 und in der Android-App, aber weniger für den wissenschaftlichen Teil der Arbeit. Dient als exzellentes technisches Nachschlagewerk.
---
## 2. Signalfilterung und -verarbeitung (RSSI-Stabilisierung)
Hier geht es um die Reduzierung des Rauschens in den RSSI-Messwerten.
**S. G. Tzafestas (1989)**
- **Titel:** Kalman filtering and its application to signals and systems
- **Publikationsort:** In *Introduction to Kalman Filtering and Its Applications*
- **Zusammenfassung:** Dies ist ein Grundlagenwerk (bzw. Thema vieler Werke). Es erklärt die mathematischen Prinzipien des Kalman-Filters zur Schätzung des Zustands eines dynamischen Systems aus verrauschten Messungen.
- **Relevanz:** **Hoch**
- **Begründung:** Unverzichtbar für das theoretische Verständnis des Kalman-Filters. Sie müssen die Funktionsweise erklären können, um seine Anwendung auf RSSI-Werte wissenschaftlich zu rechtfertigen.
**A. F. Al-Allaf, A. M. Abdul-Hussain (2015)**
- **Titel:** A Comparative Study of Different Filtering Techniques for RSSI Signals in Wireless Sensor Networks
- **Publikationsort:** International Journal of Computer Networks & Communications (IJCNC)
- **Zusammenfassung:** Der Artikel vergleicht die Leistung verschiedener Filter (Moving Average, Exponential Moving Average, Kalman-Filter) zur Glättung von RSSI-Signalen. Die Ergebnisse zeigen, dass der Kalman-Filter oft die besten Ergebnisse liefert, aber rechenintensiver ist.
- **Relevanz:** **Hoch**
- **Begründung:** Diese Arbeit ist eine direkte wissenschaftliche Untermauerung für Ihren mehrstufigen Filteransatz. Sie können daraus zitieren, um die Auswahl Ihrer Filter zu begründen und deren jeweilige Vor- und Nachteile zu diskutieren.
---
## 3. Positionierungsalgorithmen: Trilateration und Alternativen
Dieser Teil befasst sich mit der eigentlichen Berechnung der Position aus den Distanzschätzungen.
**A. H. Sayed, A. Tarighat, N. Khajehnouri (2005)**
- **Titel:** Network-Based Wireless Location
- **Publikationsort:** IEEE Signal Processing Magazine
- **Zusammenfassung:** Ein umfassender Artikel, der verschiedene Algorithmen zur Positionsschätzung in drahtlosen Netzwerken beschreibt. Er deckt Least-Squares (LS) und Non-Linear-Least-Squares-Methoden (wie Gauß-Newton) für die Trilateration mathematisch detailliert ab.
- **Relevanz:** **Hoch**
- **Begründung:** Bietet die mathematischen Grundlagen für die von Ihnen gewählte Gauß-Newton-Trilateration. Essentiell, um den Algorithmus korrekt zu implementieren und in der Arbeit zu beschreiben.
**J. A. Sugijono, H. S. Choi, W. J. Kim (2012)**
- **Titel:** Robust Weighted Centroid Localization Algorithm for Indoor Positioning System
- **Publikationsort:** International Conference on ICT Convergence (ICTC)
- **Zusammenfassung:** Dieses Paper beschreibt eine Verbesserung des einfachen Centroid-Algorithmus durch Gewichtung der Ankerpunkte. Es ist eine gute Referenz für Ihren IDW-Ansatz (Inverse Distance Weighting), der ein Spezialfall der gewichteten Zentroid-Lokalisierung ist.
- **Relevanz:** **Mittel bis Hoch**
- **Begründung:** Liefert eine gute Grundlage für die erste Stufe Ihres Positionierungsalgorithmus und hilft bei der Argumentation, warum ein gewichteter Schwerpunkt als initialer Schätzwert sinnvoll ist.
---
## 4. Ähnliche Arbeiten (Bachelor-, Masterarbeiten, Dissertationen)
Die Suche nach kompletten Abschlussarbeiten ist entscheidend für die Abgrenzung.
**M. Zandbergen (2021)**
- **Titel:** BLE-Based Indoor Positioning
- **Publikationsort:** University of Twente (Master's Thesis)
- **Zusammenfassung:** Eine sehr umfassende Masterarbeit, die verschiedene Filter (inkl. Kalman) und Algorithmen (Trilateration, Fingerprinting) implementiert und vergleicht. Der Fokus liegt stark auf der Genauigkeitsbewertung in einem Bürogebäude.
- **Relevanz:** **Hoch**
- **Begründung:** **Sehr wichtig für die Abgrenzung!** Diese Arbeit ist thematisch ähnlich. Sie müssen klar herausarbeiten, was Ihr Ansatz anders macht (z.B. die spezifische 3-Stufen-Positionierung, der Fokus auf Latenz/Stabilität oder die Anwendung auf Barrierefreiheit).
**T. B. K. Kock (2018)**
- **Titel:** Development of an indoor navigation system for mobile devices using Bluetooth Low Energy beacons
- **Publikationsort:** Norwegian University of Science and Technology (NTNU) (Master's Thesis)
- **Zusammenfassung:** Diese Arbeit fokussiert ebenfalls auf eine Android-App und BLE iBeacons. Sie verwendet ein Partikelfilter anstelle eines Kalman-Filters und konzentriert sich auf die Visualisierung der Route in der App.
- **Relevanz:** **Mittel**
- **Begründung:** Gut als Vergleich, aber die methodischen Unterschiede (Partikelfilter vs. Kalman/Gauß-Newton) bieten eine klare Abgrenzung. Sie können die Wahl Ihres Ansatzes gegenüber dem Partikelfilter argumentieren (z.B. geringere Komplexität).
---
## 5. Indoor-Navigation für Menschen mit Behinderung / Barrierefreiheit
Dies ist Ihr potenzielles Alleinstellungsmerkmal.
**F. K. Real, J. M. F. Rodrigues, L. M. V. Oliveira (2017)**
- **Titel:** A BLE iBeacon-based indoor navigation system for blind and visually impaired people
- **Publikationsort:** 12th Iberian Conference on Information Systems and Technologies (CISTI)
- **Zusammenfassung:** Die Autoren entwickeln ein System speziell für sehbehinderte Menschen. Der Fokus liegt auf der Benutzerschnittstelle, die sprachbasierte Turn-by-Turn-Anweisungen und Vibrationsfeedback gibt. Die technische Positionierung ist eher einfach gehalten.
- **Relevanz:** **Hoch**
- **Begründung:** Perfekt, um die Anforderungen an ein barrierefreies System zu definieren. Während dieses Paper sich auf die UI/UX konzentriert, können Sie mit Ihrer Arbeit die technische Robustheit der zugrundeliegenden Positionierung verbessern, was für diese Nutzergruppe besonders kritisch ist.
**S. D'Apolito, G. G. Giacomin, F. Spagnolo (2021)**
- **Titel:** Indoor navigation for all: a user-centric approach for visually impaired and sighted people
- **Publikationsort:** Journal of Ambient Intelligence and Humanized Computing
- **Zusammenfassung:** Dieser Artikel argumentiert für einen universellen Designansatz. Er analysiert die unterschiedlichen Bedürfnisse von sehenden und sehbehinderten Nutzern an ein Navigationssystem und leitet daraus Designprinzipien ab (z.B. unterschiedliche Detailgrade der Information).
- **Relevanz:** **Mittel bis Hoch**
- **Begründung:** Hilft Ihnen, den Anwendungsfall "Barrierefreiheit" theoretisch zu fundieren und über eine reine Implementierung hinauszugehen. Sie können argumentieren, *warum* Stabilität und Zuverlässigkeit für diese Nutzergruppe wichtiger sind als für andere.
---
## Hinweise und nächste Schritte
Diese Literaturrecherche bildet eine solide Grundlage für Ihre Bachelorarbeit. Folgende weitere Schritte werden empfohlen:
1. **Vertiefende Recherche zu neueren Papers (2022-2025):** Prüfen Sie auf Google Scholar oder IEEE Xplore nach aktuelleren Arbeiten, die möglicherweise neuere Ansätze oder Verbesserungen beschreiben.
2. **Spezifische Suche nach ESP32-basierten Implementierungen:** Finden Sie praktische Arbeiten, die konkret ESP32 als iBeacon verwenden - das hilft bei Implementierungsdetails.
3. **Weitere Abschlussarbeiten:** Suchen Sie in deutschen Hochschul-Repositorien (z.B. über OPUS) nach weiteren Bachelor-/Masterarbeiten zum Thema, besonders von deutschen Fachhochschulen.
4. **Machine Learning Ansätze:** Falls Sie Ihre Arbeit weiter differenzieren möchten, könnten Sie sich mit ML-basierten Fingerprinting-Ansätzen auseinandersetzen und begründen, warum Sie den modellbasierten Ansatz gewählt haben.
5. **Evaluierungs-Metriken:** Recherchieren Sie standardisierte Metriken zur Bewertung von Indoor-Positionierungssystemen (nicht nur Genauigkeit, sondern auch Precision, Recall, Jitter, Time-to-Fix).
---
*Generiert mit Unterstützung von Claude Code und Gemini am 2026-02-09*
+67
View File
@@ -0,0 +1,67 @@
# RSSI-Stabilisierung für BLE Indoor Positioning
## Aktuelles Problem
Die RSSI-Werte der einzelnen Beacons schwanken stark, obwohl bereits ein Kalman-Filter (Q=1.0, R=50.0) und eine Ausreißer-Erkennung (20 dBm) im Einsatz sind.
## Empfohlene Filter-Pipeline
```
Roher RSSI
→ Stufe 1: Median-Filter (Fenster 5-7 Werte) — eliminiert Ausreißer automatisch
→ Stufe 2: Kalman-Filter (Q=0.1, R=3-5) — glättet auf vorgereinigten Daten
→ Gefilterter RSSI
```
Die Literatur zeigt, dass Median + Kalman zusammen 50-70% weniger Schwankung erzeugen als Kalman allein.
## Maßnahmen nach Priorität
### Hoch (Quick Wins)
| Maßnahme | Aufwand | Erwartete Verbesserung |
|----------|---------|----------------------|
| **Kalman-Parameter anpassen** (Q=0.5, R=10.0 statt Q=1.0, R=50.0) | 5 Min | 30-40% stabileres RSSI |
| **Median-Vorfilter** (Sliding Window, Fenster=5) vor dem Kalman-Filter | 1-2 Std | 50-70% weniger Ausreißer |
| **ESP32 Advertising Interval** auf 20-30ms senken | 30 Min | Mehr Datenpunkte für Aggregation |
### Mittel
| Maßnahme | Aufwand | Erwartete Verbesserung |
|----------|---------|----------------------|
| **ESP32 TX Power** auf 0 oder -3 dBm (weniger Multipath-Reflexionen) | 15 Min | 10-20% weniger Multipath |
| **Empirische R-Kalibrierung** (60s Roh-RSSI bei 1m loggen, Varianz = R) | 30 Min | Optimale Kalman-Parameter |
| **ScanFilter auf UUID** setzen (weniger CPU-Last durch irrelevante Geräte) | 15 Min | Weniger Overhead |
| **Adaptiver Kalman-Filter** (R dynamisch aus letzten N Messungen berechnen) | 3-4 Std | 15-25% bessere Dynamik |
### Niedrig (für Bachelorarbeit erwähnenswert)
| Maßnahme | Aufwand | Erwartete Verbesserung |
|----------|---------|----------------------|
| **Sensor Fusion mit IMU** (Accelerometer + Gyroskop, Pedestrian Dead Reckoning) | 2-3 Tage | 25-30% bessere Position |
| **Partikel-Filter** auf Positionsebene | 1-2 Tage | 15-24% bessere Position |
| **Machine Learning** (Fingerprinting mit Random Forest) | Mehrere Tage | Situationsabhängig |
## Kalman-Parameter Tuning
| Szenario | Q (Process Noise) | R (Measurement Noise) |
|----------|-------------------|----------------------|
| Aktuell (suboptimal) | 1.0 | 50.0 |
| Quick Fix (ohne Median) | 0.5 | 10.0 |
| Mit Median-Vorfilter | 0.1 | 3.0 - 5.0 |
**R empirisch bestimmen:** Smartphone 1m vor Beacon halten, 60s Roh-RSSI loggen, Varianz berechnen = R. Typische BLE-Varianz: 4-25 dBm².
## Hardware-Empfehlungen (ESP32)
- **Advertising Interval**: 20-30ms (statt Standard 100ms) → mehr Messpunkte
- **TX Power**: 0 dBm oder -3 dBm (reicht für 3.5x4.5m Raum, reduziert Wandreflexionen)
- **Channel Map**: `ADV_CHNL_ALL` — alle 3 Advertising-Kanäle nutzen (minimiert frequenzspezifisches Fading)
## Quellen
- [Improved BLE Indoor Localization with Kalman-Based Fusion (PMC)](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC5461075/)
- [Kalman Filters Explained: Removing Noise from RSSI Signals (Wouter Bulten)](https://www.wouterbulten.nl/posts/kalman-filters-explained-removing-noise-from-rssi-signals/)
- [BLE Channel Diversity, Weighted Trilateration and Kalman Filtering (PMC)](https://pmc.ncbi.nlm.nih.gov/articles/PMC5750706/)
- [Influence of Kalman Filtering on RSSI in Multi-node BLE (ResearchGate 2024)](https://www.researchgate.net/publication/383239178)
- [Practice of BLE RSSI Measurement for Indoor Positioning (MDPI)](https://www.mdpi.com/1424-8220/21/15/5181)
+1118
View File
File diff suppressed because it is too large. Load diff