Skip to content

BLE-Kommunikation mit dem ESP32

Für die Kommunikation zwischen App und ESP32 wird Bluetooth Low Energy verwendet, da es eine direkte Verbindung ohne zusätzliches WLAN-Netzwerk ermöglicht und sowohl von Android als auch von iOS gut unterstützt wird.

Die gesamte BLE-Logik ist in der Datei BLEManager gekapselt, sodass Initialisierung, Verbindungsstatus, Empfang von Befehlen und Senden von Daten zentral verwaltet werden.

Grundprinzip der BLE-Kommunikation

Der ESP32 arbeitet in dieser Bibliothek als BLE-Server. Das Smartphone beziehungsweise die App verbindet sich als BLE-Client mit diesem Server. Innerhalb des BLE-Servers wird ein eigener Service erstellt. Dieser Service besitzt zwei wichtige Characteristics:

  1. Eine TX-Characteristic, über die der ESP32 Daten an die App sendet.
  2. Eine RX-Characteristic, über die die App Daten an den ESP32 sendet.

Die TX-Characteristic verwendet die Eigenschaft PROPERTY_NOTIFY. Das bedeutet, dass der ESP32 neue Werte aktiv an die App schicken kann, ohne dass die App ständig selbst nachfragen muss.

Die RX-Characteristic verwendet PROPERTY_WRITE und PROPERTY_WRITE_NR. Dadurch kann die App Befehle an den ESP32 schreiben. Diese Befehle werden anschließend im Code ausgewertet.

graph LR
    App["📱 App<br/>(BLE-Client)"]
    ESP["ESP32<br/>(BLE-Server)"]

    App -->|RX-Characteristic: Befehle| ESP
    ESP -->|TX-Characteristic: Notify| App

Initialisierung mit setupBLE()

Die Funktion setupBLE(const char* deviceName) startet die Bluetooth-Funktionalität des ESP32.

Zuerst wird BLE mit dem übergebenen Gerätenamen initialisiert. Danach wird ein BLE-Server erstellt und mit Callback-Funktionen verbunden. Diese Callbacks reagieren darauf, ob sich ein Smartphone verbindet oder die Verbindung wieder trennt.

Anschließend lädt die Funktion die gespeicherten UUIDs für den BLE-Service sowie für die TX- und RX-Characteristics. Zusätzlich wird eine Geräte-ID geladen. Diese Werte werden dauerhaft im Speicher des ESP32 abgelegt, sodass sie nach einem Neustart gleich bleiben.

Danach erstellt setupBLE() den BLE-Service und die beiden Characteristics. Die TX-Characteristic ist für Benachrichtigungen an die App zuständig. Die RX-Characteristic empfängt Befehle von der App. Abschließend wird der Service gestartet und der ESP32 beginnt mit dem Advertising. Dadurch wird er für andere Bluetooth-Geräte sichtbar und kann von der App gefunden werden.

sequenceDiagram
    participant Boot
    participant BLE as BLEDevice/Server
    participant NVS as Preferences (NVS)
    participant Adv as Advertising

    Boot->>BLE: BLEDevice::init(deviceName)
    Boot->>NVS: getBleServiceUuid/TxUuid/RxUuid/DeviceId
    NVS-->>Boot: gespeicherte UUIDs + Geräte-ID
    Boot->>BLE: createService + TX-/RX-Characteristic
    Boot->>Adv: addServiceUUID + start()
    Adv-->>Boot: sichtbar für App
void setupBLE(const char* deviceName) {
  BLEDevice::init(deviceName);
  pServer = BLEDevice::createServer();
  pServer->setCallbacks(new BleCallbacks());

  String serviceUuid = getBleServiceUuid();
  String txUuid = getBleTxUuid();
  String rxUuid = getBleRxUuid();

  BLEService* service = pServer->createService(serviceUuid.c_str());
  pTxCharacteristic = service->createCharacteristic(
    txUuid.c_str(), BLECharacteristic::PROPERTY_NOTIFY);
  pTxCharacteristic->addDescriptor(new BLE2902());

  BLECharacteristic* rxCharacteristic = service->createCharacteristic(
    rxUuid.c_str(),
    BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR);
  rxCharacteristic->setCallbacks(new RxCallbacks());

  service->start();
  BLEAdvertising* advertising = BLEDevice::getAdvertising();
  advertising->addServiceUUID(service->getUUID());
  advertising->setScanResponse(true);
  advertising->start();
}

Verbindungsstatus

Die Klasse BleCallbacks reagiert auf Verbindungsereignisse.

Wenn sich ein BLE-Client verbindet, wird in onConnect() die Variable deviceConnected auf true gesetzt. Zusätzlich wird eine Meldung über die serielle Schnittstelle ausgegeben.

Wenn die Verbindung getrennt wird, setzt onDisconnect() die Variable wieder auf false. Danach startet der ESP32 das Advertising erneut. Dadurch kann sich ein Smartphone nach einem Verbindungsabbruch wieder mit dem ESP32 verbinden.

Die Funktion isBLEConnected() gibt den aktuellen Verbindungsstatus zurück. Andere Programmteile können dadurch prüfen, ob aktuell ein BLE-Gerät verbunden ist.

class BleCallbacks : public BLEServerCallbacks {
  void onConnect(BLEServer* server) override {
    deviceConnected = true;
  }
  void onDisconnect(BLEServer* server) override {
    deviceConnected = false;
    server->startAdvertising();
  }
};

bool isBLEConnected() { return deviceConnected; }

Empfang von Daten

Der Empfang von Daten wird über die Klasse RxCallbacks gesteuert. Sobald die App einen Wert in die RX-Characteristic schreibt, wird die Funktion onWrite() ausgeführt.

Der empfangene Wert wird zunächst als Text gelesen, bereinigt und über die serielle Schnittstelle ausgegeben. Danach kann der Text auf unterschiedliche Arten weiterverarbeitet werden.

Wenn eine allgemeine Callback-Funktion mit setBLELineCallback() registriert wurde, wird jede empfangene Zeile an diese Funktion weitergegeben. Dadurch kann der Hauptcode flexibel auf beliebige BLE-Nachrichten reagieren.

Zusätzlich erkennt der Code bestimmte Befehlsformate, zum Beispiel PIN:<pin>:<wert> oder LED:<ON|OFF|Wert>:

flowchart TD
    A[onWrite: Zeile empfangen] --> B{"g_lineCb registriert?"}
    B -->|ja| C[Callback mit gesamter Zeile aufrufen]
    A --> D{"Zeile beginnt mit PIN:?"}
    D -->|ja| E["pin, value extrahieren -> g_pinCb(pin, value)"]
    A --> F{"Zeile beginnt mit LED:?"}
    F -->|ja| G["ON/OFF/Zahl parsen -> g_pinCb(2, value)"]
class RxCallbacks : public BLECharacteristicCallbacks {
  void onWrite(BLECharacteristic* characteristic) override {
    std::string raw = characteristic->getValue();
    if (raw.empty()) return;

    String line = String(raw.c_str());
    line.trim();

    if (g_lineCb) g_lineCb(line);

    if (line.startsWith("PIN:")) {
      int firstColon = line.indexOf(':');
      int secondColon = line.indexOf(':', firstColon + 1);
      if (firstColon > 0 && secondColon > firstColon && g_pinCb) {
        g_pinCb(line.substring(firstColon + 1, secondColon).toInt(),
                line.substring(secondColon + 1).toInt());
      }
    } else if (line.startsWith("LED:") && g_pinCb) {
      String arg = line.substring(4);
      arg.trim();
      arg.toUpperCase();
      int value = (arg == "ON" || arg == "HIGH") ? 255
                : (arg == "OFF" || arg == "LOW") ? 0
                : constrain(arg.toInt(), 0, 255);
      g_pinCb(2, value);
    }
  }
};

void setBLELineCallback(BLELineCallback cb) { g_lineCb = cb; }
void setBLEPinCommandCallback(PinCommandCallback cb) { g_pinCb = cb; }

Senden von Daten an die App

Die Funktion sendBLEValue(float value, int decimals) sendet einen Zahlenwert an die App. Der Wert wird zuerst in einen Text umgewandelt. Über den Parameter decimals kann festgelegt werden, mit wie vielen Nachkommastellen der Wert übertragen werden soll.

Beispiel:

sendBLEValue(23.456, 2);

Dieser Aufruf würde den Wert 23.46 über BLE senden.

Die Funktion sendBLEJson(const String& payload) sendet dagegen einen kompletten Text beziehungsweise JSON-String an die App. Diese Funktion eignet sich besonders, wenn mehrere Messwerte oder Zustände strukturiert übertragen werden sollen.

Beispiel:

{
  "temperature": 23.4,
  "led": 255
}

Durch die Verwendung von JSON können die Daten auf App-Seite einfach ausgewertet werden.

void sendBLEValue(float value, int decimals) {
  if (!pTxCharacteristic) {
    return;
  }

  char buffer[32];
  int length = snprintf(buffer, sizeof(buffer), "%.*f", decimals, value);
  pTxCharacteristic->setValue((uint8_t*)buffer, length);
  pTxCharacteristic->notify();
}

void sendBLEJson(const String& payload) {
  if (!pTxCharacteristic) {
    return;
  }

  pTxCharacteristic->setValue(payload.c_str());
  pTxCharacteristic->notify();
}

Persistente UUIDs

BLE verwendet sogenannte UUIDs, um Services und Characteristics eindeutig zu identifizieren. In dieser Bibliothek werden diese UUIDs nicht bei jedem Start neu festgelegt, sondern dauerhaft gespeichert.

Die Funktion makeUuidV4() erzeugt eine zufällige UUID im Version-4-Format. Die Funktion ensureBleUuid() prüft anschließend, ob bereits eine UUID im nichtflüchtigen Speicher des ESP32 vorhanden ist. Falls nicht, wird eine neue UUID erzeugt und gespeichert.

Die drei öffentlichen Funktionen

  • getBleServiceUuid()
  • getBleTxUuid()
  • getBleRxUuid()

geben jeweils die gespeicherte UUID für den Service, die TX-Characteristic und die RX-Characteristic zurück.

Der Vorteil dieser Lösung ist, dass ein Gerät nach einem Neustart weiterhin dieselbe BLE-Identität besitzt. Das erleichtert die Wiedererkennung durch die App.

Neben den BLE-UUIDs erzeugt die Bibliothek nach demselben Prinzip auch eine kurze, sechsstellige Geräte-ID (makeDeviceId() / ensureDeviceId() im Preferences-Namespace device). Diese kann in der App angezeigt oder zur Identifikation eines bestimmten ESP32 genutzt werden.

flowchart LR
    Start["getBleServiceUuid() / getBleTxUuid() /<br/>getBleRxUuid() / getDeviceId()"] --> Check{"Wert in NVS<br/>vorhanden?"}
    Check -->|ja| Return[gespeicherten Wert zurückgeben]
    Check -->|nein| Gen["makeUuidV4() bzw. makeDeviceId()"]
    Gen --> Save["in NVS speichern (Preferences)"]
    Save --> Return
static String ensureBleUuid(const char* key) {
  Preferences prefs;
  prefs.begin("ble", false);
  String v = prefs.getString(key, "");
  if (v.length() == 0) {
    v = makeUuidV4();
    prefs.putString(key, v);
  }
  prefs.end();
  return v;
}

String getBleServiceUuid() { return ensureBleUuid("service_uuid"); }
String getBleTxUuid() { return ensureBleUuid("tx_uuid"); }
String getBleRxUuid() { return ensureBleUuid("rx_uuid"); }

Siehe auch