Zum Inhalt springen
CASOON

Bluetooth Low Energy technisch umsetzen: Architektur und Best Practices

Architektur, Web Bluetooth API, Designregeln und Fallbacks – ohne Hype

10 Minuten
Bluetooth Low Energy technisch umsetzen: Architektur und Best Practices
#Bluetooth Low Energy #BLE #Web Bluetooth #JavaScript
SerieBLE als Gamechanger
Teil 3 von 3

Die ersten beiden Teile dieser Serie haben gezeigt, wo BLE echte Probleme löst. Jetzt geht es ans Eingemachte: Wie baut man das technisch? Welche Architektur funktioniert? Was sind die Fallstricke?

Dieser Artikel ist für Entwickler, die BLE-Features in Web-Apps umsetzen wollen – pragmatisch, ohne Hype.

Der technische Kern

Was ein BLE-Beacon wirklich ist

Ein Beacon ist ein dummes Signal. Mehr nicht.

Beacon sendet alle 100-1000ms: ├── UUID (Identifikator) ├── Major (Gruppe, z.B. Stockwerk) ├── Minor (Einzelpunkt, z.B. Raum) └── TX Power (Sendestärke für Distanzberechnung)

Der Beacon weiß nichts. Er sendet nur. Die gesamte Intelligenz liegt in der empfangenden Anwendung.

Beacon-Formate:

  • iBeacon: Apple-Standard, weit verbreitet
  • Eddystone: Google-Standard, kann URLs senden
  • AltBeacon: Open Source Alternative

Für Web-Apps ist das Format meist egal – wichtig ist die UUID.

Die drei Schichten

┌─────────────────────────────────────────┐ │ Web-App (UI) │ │ Zeigt Kontext, reagiert │ └────────────────────┬────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ Backend (Logik) │ │ Events, Regeln, Persistenz │ └────────────────────┬────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ BLE-Beacons (Signal) │ │ Dumme Sender, keine Logik │ └─────────────────────────────────────────┘

Datenfluss:

  1. Beacon sendet UUID
  2. Browser empfängt via Web Bluetooth API
  3. App sendet Beacon-ID an Backend
  4. Backend liefert Kontext zurück
  5. UI reagiert

Web Bluetooth API: Das Wesentliche

Browser-Support

BrowserSupport
Chrome (Desktop)✅ Voll
Chrome (Android)✅ Voll
Edge✅ Voll
Opera✅ Voll
Firefox❌ Nein
Safari❌ Nein

Realität: ~65% der Nutzer können Web Bluetooth nutzen. Für den Rest braucht man Fallbacks.

Grundlegende Nutzung

// Permission anfordern und Gerät scannen
async function scanForBeacons() {
  try {
    // Nutzer muss aktiv zustimmen (Klick erforderlich)
    const device = await navigator.bluetooth.requestDevice({
      acceptAllDevices: true,
      optionalServices: ['battery_service']
    });
    
    console.log('Gerät gefunden:', device.name);
    
    // Verbindung herstellen
    const server = await device.gatt.connect();
    
    // Services lesen
    const services = await server.getPrimaryServices();
    
  } catch (error) {
    console.error('Bluetooth-Fehler:', error);
  }
}

// Muss durch User-Geste getriggert werden
button.addEventListener('click', scanForBeacons);

Monitoring vs. Ranging

Monitoring: Erkennt, ob ein Beacon in Reichweite ist

  • Batterieschonend
  • Hintergrund-fähig (mit Einschränkungen)
  • Für die meisten Use Cases ausreichend

Ranging: Misst Entfernung zum Beacon

  • Batterie-intensiv
  • Erfordert aktive App
  • Nötig für präzise Navigation

Empfehlung: Starte mit Monitoring. Ranging nur, wenn wirklich nötig.

// Monitoring-Ansatz: Einfach prüfen, ob Beacon da ist
class BeaconMonitor {
  constructor(targetUUIDs) {
    this.targetUUIDs = targetUUIDs;
    this.currentZone = null;
  }
  
  async startMonitoring() {
    // Scan starten
    const device = await navigator.bluetooth.requestDevice({
      filters: this.targetUUIDs.map(uuid => ({
        services: [uuid]
      }))
    });
    
    if (device) {
      this.onBeaconDetected(device);
    }
  }
  
  onBeaconDetected(device) {
    const newZone = this.getZoneFromDevice(device);
    
    if (newZone !== this.currentZone) {
      this.currentZone = newZone;
      this.notifyZoneChange(newZone);
    }
  }
  
  notifyZoneChange(zone) {
    // Backend informieren
    fetch('/api/zone-change', {
      method: 'POST',
      body: JSON.stringify({ zone, timestamp: Date.now() })
    });
  }
}

Architektur für Production

Kommunikation: WebSocket oder SSE?

WebSocket: Bidirektional

  • Gut für Echtzeit-Interaktion
  • Komplexer zu implementieren
  • Höherer Ressourcenverbrauch

Server-Sent Events (SSE): Unidirektional (Server → Client)

  • Einfacher
  • Reicht für die meisten Beacon-Apps
  • Automatische Reconnection
// SSE für Zone-Updates
class ZoneEventStream {
  constructor(userId) {
    this.eventSource = new EventSource(`/api/zones/stream?user=${userId}`);
    
    this.eventSource.onmessage = (event) => {
      const data = JSON.parse(event.data);
      this.handleZoneEvent(data);
    };
    
    this.eventSource.onerror = () => {
      // Automatische Reconnection durch Browser
      console.log('Verbindung unterbrochen, reconnecting...');
    };
  }
  
  handleZoneEvent(data) {
    switch (data.type) {
      case 'content_update':
        this.showContent(data.content);
        break;
      case 'navigation_hint':
        this.showNavigation(data.hint);
        break;
      case 'alert':
        this.showAlert(data.message);
        break;
    }
  }
}

Backend-Struktur

Backend
├── /api/beacon
│   ├── POST /detect      → Beacon-Erkennung melden
│   └── GET /zones        → Alle Zonen abrufen

├── /api/content
│   └── GET /:zoneId      → Content für Zone

├── /api/stream
│   └── GET /events       → SSE-Stream für Updates

└── Services
    ├── ZoneResolver      → UUID → Zone-Mapping
    ├── ContentService    → Zone → Content-Mapping
    └── EventDispatcher   → Echtzeit-Events

Datenmodell

// Zone
{
  id: "zone-lobby-north",
  name: "Eingangshalle Nord",
  beacons: ["uuid-1", "uuid-2"],  // Mehrere Beacons pro Zone
  parentZone: "zone-lobby",
  contentId: "content-welcome"
}

// Beacon
{
  uuid: "uuid-1",
  major: 1,
  minor: 101,
  location: "Säule A3",
  batteryLevel: 87,
  lastSeen: "2025-12-21T10:30:00Z"
}

// Event
{
  type: "zone_enter",
  userId: "user-123",
  zoneId: "zone-lobby-north",
  timestamp: "2025-12-21T10:30:00Z",
  previousZone: "zone-entrance"
}

Wichtige Designregeln

1. Permission-UX ernst nehmen

// SCHLECHT: Sofort nach Bluetooth fragen
window.onload = () => {
  navigator.bluetooth.requestDevice({...}); // Wird blockiert!
};

// GUT: Kontext erklären, dann fragen
const startButton = document.getElementById('start-navigation');
const explainer = document.getElementById('bluetooth-explainer');

// Erst erklären
explainer.innerHTML = `
  <h3>Navigation aktivieren</h3>
  <p>Um Sie durch das Gebäude zu führen, benötigen wir 
     Zugriff auf Bluetooth.</p>
  <ul>
    <li>Wir tracken Sie nicht</li>
    <li>Daten bleiben auf Ihrem Gerät</li>
    <li>Sie können jederzeit stoppen</li>
  </ul>
`;

// Dann Button anbieten
startButton.addEventListener('click', async () => {
  const device = await navigator.bluetooth.requestDevice({...});
});

2. Privacy by Design

// Lokale Verarbeitung bevorzugen
class PrivacyFirstBeaconHandler {
  constructor() {
    this.zoneMapping = new Map(); // Lokal cachen
  }
  
  async onBeaconDetected(uuid) {
    // 1. Lokal nachschlagen, wenn möglich
    if (this.zoneMapping.has(uuid)) {
      return this.zoneMapping.get(uuid);
    }
    
    // 2. Nur UUID senden, nicht User-ID
    const response = await fetch(`/api/zone?beacon=${uuid}`);
    const zone = await response.json();
    
    // 3. Lokal cachen
    this.zoneMapping.set(uuid, zone);
    
    return zone;
  }
}

Privacy-Prinzipien:

  • Keine User-IDs, wenn nicht nötig
  • Lokale Verarbeitung bevorzugen
  • Bewegungsdaten nicht speichern
  • Opt-in, nicht Opt-out

3. Fallback ohne BLE

class AdaptiveLocationService {
  constructor() {
    this.method = this.detectBestMethod();
  }
  
  detectBestMethod() {
    if ('bluetooth' in navigator) {
      return 'ble';
    }
    if ('geolocation' in navigator) {
      return 'gps'; // Outdoor-Fallback
    }
    return 'manual';
  }
  
  async getCurrentZone() {
    switch (this.method) {
      case 'ble':
        return this.getZoneFromBLE();
      case 'gps':
        return this.getZoneFromGPS();
      case 'manual':
        return this.showManualSelector();
    }
  }
  
  showManualSelector() {
    // QR-Code-Scanner oder Dropdown
    return new Promise((resolve) => {
      const modal = new ZoneSelectorModal();
      modal.onSelect = (zone) => resolve(zone);
      modal.show();
    });
  }
}

4. Batterie schonen

class BatteryAwareScanner {
  constructor() {
    this.scanInterval = 1000; // Start: 1 Sekunde
  }
  
  async adaptToContext() {
    // Bewegung erkennen
    if ('DeviceMotionEvent' in window) {
      window.addEventListener('devicemotion', (e) => {
        const moving = this.isMoving(e);
        
        // Schneller scannen, wenn in Bewegung
        this.scanInterval = moving ? 500 : 3000;
      });
    }
    
    // Batterie berücksichtigen
    if ('getBattery' in navigator) {
      const battery = await navigator.getBattery();
      
      if (battery.level < 0.2) {
        this.scanInterval = 5000; // Batterie sparen
        this.showLowBatteryHint();
      }
    }
  }
}

Häufige Fehler und Lösungen

Fehler 1: Zu viele Beacons

Problem: 50 Beacons, die alle gleichzeitig senden, überlasten den Scanner.

Lösung: Zonen-basiert denken. Ein Beacon pro Entscheidungspunkt, nicht pro Quadratmeter.

Fehler 2: Ranging für alles

Problem: Ständige Distanzmessung frisst Batterie.

Lösung: Monitoring für Zone-Erkennung. Ranging nur für die letzten Meter (z.B. Exponat im Museum).

Fehler 3: Keine Offline-Fähigkeit

Problem: Kein Backend-Zugang = keine Navigation.

Lösung: Zone-Content im Service Worker cachen.

// Service Worker
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('zone-content-v1').then((cache) => {
      return cache.addAll([
        '/api/zones',
        '/api/content/zone-1',
        '/api/content/zone-2',
        // ...
      ]);
    })
  );
});

Fehler 4: Keine Beacon-Wartung

Problem: Batterie leer, Beacon verrutscht, niemand merkt es.

Lösung: Beacon-Health-Monitoring.

// Backend: Beacon-Status tracken
class BeaconHealthMonitor {
  checkHealth() {
    const now = Date.now();
    
    for (const beacon of this.beacons) {
      const lastSeen = beacon.lastDetection;
      const silent = now - lastSeen;
      
      if (silent > 24 * 60 * 60 * 1000) { // 24 Stunden
        this.alertMaintenance(beacon, 'Nicht erkannt seit 24h');
      }
      
      if (beacon.batteryLevel < 20) {
        this.alertMaintenance(beacon, 'Batterie niedrig');
      }
    }
  }
}

Schnellstart-Setup

Für den schnellen Start:

Hardware

  • 5-10 Beacons (ca. 15-30€/Stück)
  • Smartphone mit Chrome zum Testen

Software

Frontend (PWA) ├── index.html ├── app.js → Web Bluetooth Logic ├── zones.js → Zone-Handling └── sw.js → Offline-Cache Backend (Node/Express) ├── server.js ├── routes/ │ ├── beacon.js → Beacon-API │ └── content.js → Content-API └── data/ └── zones.json → Zone-Konfiguration

Erste Schritte

  1. Beacons konfigurieren (UUIDs vergeben)
  2. Zone-Mapping erstellen
  3. Web-App mit Permission-Flow bauen
  4. Content pro Zone anlegen
  5. Testen, iterieren

Beacon-Hardware auswählen

Die Wahl der richtigen Beacons ist entscheidend für den Projekterfolg. Nicht jeder Beacon passt zu jedem Einsatzzweck.

Wichtige Kriterien

KriteriumWorauf achten
Reichweite10-30m für Indoor, konfigurierbar
Batterie2-5 Jahre Laufzeit, austauschbar
GehäuseWetterfest für Außenbereiche
KonfigurationApp oder Cloud-Management
ProtokolleiBeacon + Eddystone für Flexibilität

Empfehlungen nach Einsatzbereich

Museum / Retail:

  • Kleine, unauffällige Beacons
  • Mittlere Reichweite (10-15m)
  • Lange Batterielaufzeit wichtig

Krankenhaus / Industrie:

  • Robustes Gehäuse
  • Hohe Sendeleistung für Überwindung von Hindernissen
  • Zentrale Verwaltung über Cloud

Outdoor / Events:

  • Wetterfestes Gehäuse (IP67)
  • Hohe Reichweite (30m+)
  • Einfacher Batteriewechsel

Beacon-Konfiguration

Die meisten Beacons lassen sich über Hersteller-Apps konfigurieren:

Typische Einstellungen: ├── UUID: Eindeutige Projekt-ID ├── Major: Gebäude oder Bereich (0-65535) ├── Minor: Einzelner Standort (0-65535) ├── TX Power: Sendestärke (-40 bis +4 dBm) └── Intervall: Sendefrequenz (100-1000ms)

Faustregel für TX Power:

  • Höher = mehr Reichweite, mehr Batterieverbrauch
  • Niedriger = präzisere Erkennung, längere Laufzeit

Signalstärke und Distanzberechnung

Die Distanz zu einem Beacon wird über die Signalstärke (RSSI) geschätzt – aber das ist fehleranfällig.

Warum RSSI unzuverlässig ist

Ideale Bedingungen: RSSI -50 dBm → ca. 1 Meter RSSI -70 dBm → ca. 5 Meter RSSI -90 dBm → ca. 15 Meter Realität: ├── Mensch zwischen Beacon und Smartphone: -10 bis -20 dBm ├── Metallregal in der Nähe: ±15 dBm Schwankung ├── Smartphone in Hosentasche: -5 bis -15 dBm └── Mehrere Beacons gleichzeitig: Interferenz

Pragmatischer Umgang mit Distanz

Statt exakte Meter zu berechnen, besser in Zonen denken:

class ProximityZone {
  // Statt: "Beacon ist 3.7m entfernt"
  // Besser: "Beacon ist in Zone NEAR"
  
  static fromRSSI(rssi) {
    if (rssi > -50) return 'IMMEDIATE';  // < 0.5m
    if (rssi > -70) return 'NEAR';        // 0.5-3m
    if (rssi > -85) return 'FAR';         // 3-10m
    return 'UNKNOWN';                      // > 10m oder gestört
  }
  
  static getConfidence(readings) {
    // Mehrere Messungen mitteln
    const avg = readings.reduce((a, b) => a + b) / readings.length;
    const variance = this.calculateVariance(readings, avg);
    
    // Hohe Varianz = geringe Konfidenz
    return variance < 5 ? 'HIGH' : variance < 15 ? 'MEDIUM' : 'LOW';
  }
}

Glättung für stabilere Werte

class RSSISmoother {
  constructor(windowSize = 5) {
    this.readings = [];
    this.windowSize = windowSize;
  }
  
  addReading(rssi) {
    this.readings.push(rssi);
    if (this.readings.length > this.windowSize) {
      this.readings.shift();
    }
  }
  
  getSmoothedRSSI() {
    if (this.readings.length === 0) return null;
    
    // Median statt Durchschnitt (robuster gegen Ausreißer)
    const sorted = [...this.readings].sort((a, b) => a - b);
    const mid = Math.floor(sorted.length / 2);
    
    return sorted.length % 2 
      ? sorted[mid] 
      : (sorted[mid - 1] + sorted[mid]) / 2;
  }
}

Testing und Debugging

BLE-Apps sind schwer zu testen. Hier sind bewährte Strategien.

Entwicklungs-Setup

Mock-Beacons für lokales Testing:

class MockBeaconService {
  constructor() {
    this.mockBeacons = [
      { uuid: 'test-uuid-1', major: 1, minor: 101, rssi: -65 },
      { uuid: 'test-uuid-2', major: 1, minor: 102, rssi: -78 },
    ];
  }
  
  async scan() {
    // Simulierte Verzögerung
    await new Promise(r => setTimeout(r, 500));
    
    // Zufällige RSSI-Schwankung simulieren
    return this.mockBeacons.map(b => ({
      ...b,
      rssi: b.rssi + (Math.random() * 10 - 5)
    }));
  }
}

// In der App
const beaconService = process.env.NODE_ENV === 'development'
  ? new MockBeaconService()
  : new RealBeaconService();

Chrome DevTools für Web Bluetooth:

  1. chrome://bluetooth-internals öffnen
  2. Aktive Verbindungen und Scans einsehen
  3. GATT-Services und Characteristics inspizieren

Logging-Strategie

class BeaconLogger {
  constructor(enabled = true) {
    this.enabled = enabled;
    this.events = [];
  }
  
  log(event, data) {
    if (!this.enabled) return;
    
    const entry = {
      timestamp: new Date().toISOString(),
      event,
      data,
      battery: navigator.getBattery?.() || null
    };
    
    this.events.push(entry);
    console.log(`[BLE] ${event}:`, data);
    
    // Optional: An Backend senden für Analyse
    if (this.events.length >= 50) {
      this.flush();
    }
  }
  
  flush() {
    fetch('/api/logs/beacon', {
      method: 'POST',
      body: JSON.stringify(this.events)
    });
    this.events = [];
  }
}

// Nutzung
const logger = new BeaconLogger();
logger.log('beacon_detected', { uuid: 'xxx', rssi: -67 });
logger.log('zone_changed', { from: 'lobby', to: 'floor-1' });

Feldtests durchführen

Checkliste für Vor-Ort-Tests:

  1. Reichweiten-Test: An verschiedenen Positionen RSSI messen
  2. Interferenz-Test: Mit vielen Menschen im Raum testen
  3. Batterie-Test: App über längere Zeit laufen lassen
  4. Edge-Cases: Zwischen Zonen stehen, schnell gehen
  5. Offline-Test: WLAN deaktivieren, Verhalten prüfen

Skalierung und Performance

Was bei 10 Beacons funktioniert, kann bei 100 Beacons scheitern.

Client-seitige Optimierung

class ScalableBeaconHandler {
  constructor() {
    this.knownBeacons = new Map();
    this.processingQueue = [];
    this.isProcessing = false;
  }
  
  onBeaconDetected(beacon) {
    // Duplikate früh filtern
    const key = `${beacon.uuid}-${beacon.major}-${beacon.minor}`;
    const lastSeen = this.knownBeacons.get(key);
    
    if (lastSeen && Date.now() - lastSeen < 1000) {
      return; // Innerhalb 1s bereits verarbeitet
    }
    
    this.knownBeacons.set(key, Date.now());
    this.processingQueue.push(beacon);
    this.processQueue();
  }
  
  async processQueue() {
    if (this.isProcessing || this.processingQueue.length === 0) return;
    
    this.isProcessing = true;
    
    // Batch-Verarbeitung
    const batch = this.processingQueue.splice(0, 10);
    
    try {
      await fetch('/api/beacons/batch', {
        method: 'POST',
        body: JSON.stringify(batch)
      });
    } finally {
      this.isProcessing = false;
      if (this.processingQueue.length > 0) {
        requestAnimationFrame(() => this.processQueue());
      }
    }
  }
}

Backend-seitige Optimierung

// Redis für schnelles Zone-Lookup
class ZoneCacheService {
  constructor(redis) {
    this.redis = redis;
    this.localCache = new Map();
  }
  
  async getZone(beaconUUID) {
    // 1. Lokaler Cache (schnellste Option)
    if (this.localCache.has(beaconUUID)) {
      return this.localCache.get(beaconUUID);
    }
    
    // 2. Redis (schnell, geteilt zwischen Instanzen)
    const cached = await this.redis.get(`zone:${beaconUUID}`);
    if (cached) {
      const zone = JSON.parse(cached);
      this.localCache.set(beaconUUID, zone);
      return zone;
    }
    
    // 3. Datenbank (langsam, aber autoritativ)
    const zone = await this.db.zones.findByBeacon(beaconUUID);
    
    // Cachen für nächstes Mal
    await this.redis.setex(`zone:${beaconUUID}`, 3600, JSON.stringify(zone));
    this.localCache.set(beaconUUID, zone);
    
    return zone;
  }
}

Metriken sammeln

// Wichtige Metriken für BLE-Apps
const metrics = {
  // Performance
  scanDuration: [],        // Wie lange dauert ein Scan?
  zoneResolutionTime: [],  // Wie schnell wird Zone aufgelöst?
  
  // Qualität
  beaconsPerScan: [],      // Wie viele Beacons pro Scan?
  zoneChangesPerMinute: [],// Wie oft wechselt die Zone?
  
  // Fehler
  scanFailures: 0,         // Wie oft schlägt Scan fehl?
  permissionDenied: 0,     // Wie oft wird Permission verweigert?
  
  // Business
  contentViews: {},        // Welche Inhalte werden angezeigt?
  navigationCompleted: 0,  // Wie oft wird Navigation abgeschlossen?
};

Komplexität managen

BLE-Web-Apps sind technisch machbar. Die Herausforderung liegt nicht in der API, sondern in den Randfällen:

  • Browser-Support (Fallbacks nötig)
  • Permission-UX (Nutzer müssen verstehen, warum)
  • Batterie (Monitoring vor Ranging)
  • Wartung (Beacons sind physische Geräte)
  • Signalqualität (RSSI ist unzuverlässig)
  • Skalierung (viele Beacons, viele Nutzer)

Der Schlüssel ist Schichtentrennung:

  • Beacons sind dumm
  • Browser ist der Sensor
  • Backend liefert Kontext
  • UI reagiert

Wer das versteht, kann robuste BLE-Web-Apps bauen – ohne den Hype, mit echtem Nutzen.

Weiterführende Ressourcen

Spezifikationen:

Tools:

Beacon-Hersteller:

  • Kontakt.io, Estimote, Minew – etablierte Anbieter mit Cloud-Management
  • Günstige China-Beacons – für Prototypen, aber weniger zuverlässig