NEWS
Airthings Wave Plus per MQTT einbinden
-
Moin zusammen,
ich habe mein Airthings 2930 Wave Plus erfolgreich per MQTT eingebunden.
Benötigt wird ein ESP32 D1 mini.
Die erforderlichen Biblioteken in ArduinoIDE einbinden, WLAN eintragen und MQTT Daten. Bei Bedarf kann auch eine JSON Struktur aktiviert werden.
Wenn man den Debugmodus aktiviert, kann man per Konsole beobachten was gerade passiert.
Ich übernehme keine Garantie und keine Haftung, der Sketch ist mit Hilfe von ChatGPT entstanden, vielleicht kann es einer von euch gebrauchen.Viele Grüße
Leif/*************************************************************** * Airthings Wave Plus -> MQTT Bridge (ESP32 / NimBLE) * ------------------------------------------------------------- * Version: 1.2.7 * Stand: 2026-04-04 ***************************************************************/ #include <NimBLEDevice.h> #include <WiFi.h> #include <PubSubClient.h> #include <vector> /* ============================================================ * KONFIGURATION * ============================================================ */ // ---------- Debug ---------- #define DEBUG_MODE false #define DEBUG_SHOW_ALL_BLE_DEVICES false #define DEBUG_MQTT_SINGLE_TOPICS false // ---------- WLAN ---------- #define WIFI_SSID "" #define WIFI_PASS "" // ---------- MQTT ---------- #define MQTT_HOST "192.168.xxx.xxx" #define MQTT_PORT 1883 #define MQTT_USER "" #define MQTT_PASS "" #define MQTT_CLIENT_ID "airthings_waveplus_bridge" #define MQTT_RETAIN true #define MQTT_PUBLISH_DELAY_MS 120 #define MQTT_POST_PUBLISH_WAIT_MS 1200 #define WIFI_POST_MQTT_WAIT_MS 1000 #define MQTT_BUFFER_SIZE 512 // ---------- Versandarten ---------- #define ENABLE_SINGLE_TOPICS true #define ENABLE_JSON_TOPIC false // ---------- MQTT Topics ---------- #define TOPIC_RADON_24HR "airthings/Keller/radon24hour" #define TOPIC_RADON_LIFETIME "airthings/Keller/radonLifetime" #define TOPIC_TEMPERATURE "airthings/Keller/temperature" #define TOPIC_HUMIDITY "airthings/Keller/humidity" #define TOPIC_PRESSURE "airthings/Keller/pressure" #define TOPIC_VOC "airthings/Keller/voc" #define TOPIC_CO2 "airthings/Keller/co2" #define TOPIC_BATTERY_RAW "airthings/Keller/maybebattery" #define TOPIC_RUN_COUNTER "airthings/Keller/runCounter" #define TOPIC_JSON_SNAPSHOT "airthings/Keller/json" // ---------- Timing ---------- #define READ_WAIT_SECONDS (60 * 60) #define READ_WAIT_RETRY_SECONDS 30 // Scan-Dauer in Millisekunden #define BLE_SCAN_DURATION_MS 20000UL #define BLE_SCAN_END_GRACE_MS 300UL #define BLE_SCAN_GUARD_EXTRA_MS 5000UL #define NIMBLE_POST_DEINIT_MS 300UL // Retry-Logik beim Lesen #define READ_RETRY_ATTEMPTS 2 #define READ_RETRY_DELAY_MS 1200UL #define WIFI_CONNECT_WAIT_SECONDS 30 #define WIFI_RETRY_INTERVAL_MS 500 // ---------- Validierung ---------- #define VALIDATE_MEASUREMENTS true #define VALID_TEMP_MIN_C (-40.0f) #define VALID_TEMP_MAX_C (85.0f) #define VALID_HUM_MIN_PERCENT (0.0f) #define VALID_HUM_MAX_PERCENT (100.0f) #define VALID_PRESSURE_MIN_HPA (850.0f) #define VALID_PRESSURE_MAX_HPA (1150.0f) #define VALID_CO2_MAX 10000 #define VALID_VOC_MAX 10000 #define INVALID_U16_VALUE 65535U #define INVALID_HUM_SENTINEL 127.5f // ---------- Konstanten ---------- #define US_TO_S_FACTOR 1000000ULL #define SECONDS_TO_MILLIS 1000UL #define RADON_MAX_VALID 16383 #define MAX_CANDIDATES 20 // Airthings UUIDs static NimBLEUUID serviceUUID("b42e1c08-ade7-11e4-89d3-123b93f75cba"); static NimBLEUUID currentValuesUUID("b42e2a68-ade7-11e4-89d3-123b93f75cba"); /* ============================================================ * DATENSTRUKTUREN * ============================================================ */ struct AirthingsData { bool valid = false; bool hasExtendedPayload = false; bool measurementPlausible = false; float humidity = 0.0f; int radon = 0; int radonLongTerm = 0; float temperature = 0.0f; float pressure = 0.0f; int co2 = 0; int voc = 0; uint16_t batteryRawCandidate = 0; String invalidReason = ""; }; struct CandidateDevice { NimBLEAddress address; String addressText; int rssi = -999; bool hasServiceUuid = false; String manufacturerData; String name; }; /* ============================================================ * GLOBALE VARIABLEN * ============================================================ */ std::vector<CandidateDevice> g_candidates; RTC_DATA_ATTR uint32_t g_runCounter = 0; /* ============================================================ * HELFER * ============================================================ */ uint16_t unpackU16(uint8_t lowByte, uint8_t highByte) { return (static_cast<uint16_t>(highByte) << 8) | lowByte; } int16_t unpackS16(uint8_t lowByte, uint8_t highByte) { return static_cast<int16_t>((static_cast<uint16_t>(highByte) << 8) | lowByte); } void deepSleepSeconds(uint32_t seconds) { Serial.flush(); esp_sleep_enable_timer_wakeup(static_cast<uint64_t>(seconds) * US_TO_S_FACTOR); esp_deep_sleep_start(); } String bytesToHexString(const std::string& data) { if (data.empty()) return ""; String out = ""; const uint8_t* raw = reinterpret_cast<const uint8_t*>(data.data()); for (size_t i = 0; i < data.length(); i++) { if (raw[i] < 16) out += "0"; out += String(raw[i], HEX); } out.toUpperCase(); return out; } bool candidateAlreadyExists(const NimBLEAddress& address) { for (size_t i = 0; i < g_candidates.size(); i++) { if (g_candidates[i].address == address) { return true; } } return false; } void logMqttPublishResult(const char* topic, const char* payload, bool ok) { if (!DEBUG_MQTT_SINGLE_TOPICS) return; Serial.print("MQTT publish ["); Serial.print(topic); Serial.print("] = "); Serial.print(payload); Serial.print(" -> "); Serial.println(ok ? "OK" : "FEHLER"); } bool nearlyEqualFloat(float a, float b, float epsilon = 0.01f) { return fabs(a - b) < epsilon; } bool validateMeasurements(AirthingsData& result) { if (!VALIDATE_MEASUREMENTS) { result.measurementPlausible = true; return true; } if (result.co2 == (int)INVALID_U16_VALUE) { result.invalidReason = "CO2 invalid sentinel 65535"; return false; } if (result.voc == (int)INVALID_U16_VALUE) { result.invalidReason = "VOC invalid sentinel 65535"; return false; } if (nearlyEqualFloat(result.humidity, INVALID_HUM_SENTINEL)) { result.invalidReason = "humidity invalid sentinel 127.5"; return false; } if (result.humidity < VALID_HUM_MIN_PERCENT || result.humidity > VALID_HUM_MAX_PERCENT) { result.invalidReason = "humidity out of range"; return false; } if (result.temperature < VALID_TEMP_MIN_C || result.temperature > VALID_TEMP_MAX_C) { result.invalidReason = "temperature out of range"; return false; } if (result.pressure < VALID_PRESSURE_MIN_HPA || result.pressure > VALID_PRESSURE_MAX_HPA) { result.invalidReason = "pressure out of range"; return false; } if (result.co2 < 0 || result.co2 > VALID_CO2_MAX) { result.invalidReason = "CO2 out of range"; return false; } if (result.voc < 0 || result.voc > VALID_VOC_MAX) { result.invalidReason = "VOC out of range"; return false; } result.measurementPlausible = true; return true; } /* ============================================================ * KANDIDATEN-ERKENNUNG * ============================================================ */ bool looksLikeAirthingsCandidate(const NimBLEAdvertisedDevice* device) { if (device == nullptr) return false; if (device->haveServiceUUID() && device->isAdvertisingService(serviceUUID)) { return true; } if (device->haveManufacturerData()) { String mfg = bytesToHexString(device->getManufacturerData()); if (mfg.startsWith("34039584A6AE")) { return true; } } return false; } void addCandidate(const NimBLEAdvertisedDevice* device) { if (device == nullptr) return; if (g_candidates.size() >= MAX_CANDIDATES) return; if (candidateAlreadyExists(device->getAddress())) return; CandidateDevice entry; entry.address = device->getAddress(); entry.addressText = String(device->getAddress().toString().c_str()); entry.rssi = device->getRSSI(); entry.hasServiceUuid = device->haveServiceUUID(); entry.manufacturerData = device->haveManufacturerData() ? bytesToHexString(device->getManufacturerData()) : ""; entry.name = device->haveName() ? String(device->getName().c_str()) : ""; g_candidates.push_back(entry); Serial.print("Added candidate: "); Serial.print(entry.addressText); Serial.print(" RSSI="); Serial.println(entry.rssi); } /* ============================================================ * NIMBLE CALLBACK * ============================================================ */ class FoundDeviceCallback : public NimBLEScanCallbacks { public: void onResult(const NimBLEAdvertisedDevice* device) override { if (device == nullptr) return; if (DEBUG_SHOW_ALL_BLE_DEVICES) { Serial.println("---- BLE device found ----"); Serial.print("Address: "); Serial.println(device->getAddress().toString().c_str()); Serial.print("RSSI: "); Serial.println(device->getRSSI()); Serial.print("Name: "); if (device->haveName()) { Serial.println(device->getName().c_str()); } else { Serial.println("(none)"); } Serial.print("Has service UUID: "); Serial.println(device->haveServiceUUID() ? "yes" : "no"); Serial.print("Manufacturer data: "); if (device->haveManufacturerData()) { Serial.println(bytesToHexString(device->getManufacturerData())); } else { Serial.println("(none)"); } Serial.println("--------------------------"); } if (looksLikeAirthingsCandidate(device)) { addCandidate(device); } } }; FoundDeviceCallback g_foundDeviceCallback; /* ============================================================ * SCAN / LESEN * ============================================================ */ bool scanForAirthingsCandidates() { Serial.println("Scanning for airthings devices"); g_candidates.clear(); NimBLEDevice::init(""); NimBLEScan* scan = NimBLEDevice::getScan(); if (scan == nullptr) { Serial.println("Failed to get NimBLE scan instance."); return false; } scan->setScanCallbacks(&g_foundDeviceCallback, false); scan->setActiveScan(false); scan->setInterval(100); scan->setWindow(100); scan->setMaxResults(0); scan->clearResults(); unsigned long scanStartMs = millis(); Serial.print("BLE scan start, target ms: "); Serial.println(BLE_SCAN_DURATION_MS); if (!scan->start(BLE_SCAN_DURATION_MS, false)) { Serial.println("Failed to start BLE scan."); return false; } unsigned long guardStart = millis(); while (scan->isScanning()) { delay(50); if (millis() - guardStart > (BLE_SCAN_DURATION_MS + BLE_SCAN_GUARD_EXTRA_MS)) { Serial.println("BLE scan timeout guard triggered."); scan->stop(); break; } } delay(BLE_SCAN_END_GRACE_MS); unsigned long scanEndMs = millis(); Serial.print("BLE scan end, elapsed ms: "); Serial.println(scanEndMs - scanStartMs); Serial.print("Candidate count after scan: "); Serial.println((int)g_candidates.size()); return !g_candidates.empty(); } bool parsePayload(const std::string& data, AirthingsData& result) { if (data.length() < 16) { Serial.print("Payload too short: "); Serial.println((unsigned int)data.length()); return false; } const uint8_t* raw = reinterpret_cast<const uint8_t*>(data.data()); result.humidity = raw[1] / 2.0f; result.radon = unpackU16(raw[4], raw[5]); result.radonLongTerm = unpackU16(raw[6], raw[7]); result.temperature = unpackS16(raw[8], raw[9]) / 100.0f; result.pressure = unpackU16(raw[10], raw[11]) / 50.0f; result.co2 = unpackU16(raw[12], raw[13]); result.voc = unpackU16(raw[14], raw[15]); if (result.radon <= 0 || result.radon > RADON_MAX_VALID) result.radon = 0; if (result.radonLongTerm <= 0 || result.radonLongTerm > RADON_MAX_VALID) result.radonLongTerm = 0; if (data.length() >= 20) { result.hasExtendedPayload = true; result.batteryRawCandidate = unpackU16(raw[18], raw[19]); } result.valid = true; return validateMeasurements(result); } bool readFromCandidateOnce(const CandidateDevice& candidate, AirthingsData& result) { Serial.print("Connecting to candidate: "); Serial.println(candidate.addressText); NimBLEClient* client = NimBLEDevice::createClient(); if (client == nullptr) { Serial.println("Failed to create NimBLE client."); return false; } bool success = false; do { if (!client->connect(candidate.address)) { Serial.println("Failed to connect."); break; } Serial.println("Connected!"); Serial.println("Retrieving service reference..."); NimBLERemoteService* remoteService = client->getService(serviceUUID); if (remoteService == nullptr) { Serial.println("Candidate does not provide expected service."); break; } Serial.println("Reading radon/temperature/humidity/pressure/CO2/VOC..."); NimBLERemoteCharacteristic* currentValuesCharacteristic = remoteService->getCharacteristic(currentValuesUUID); if (currentValuesCharacteristic == nullptr) { Serial.println("Failed to read from the candidate!"); break; } std::string data = currentValuesCharacteristic->readValue(); if (!parsePayload(data, result)) { Serial.print("Failed to parse/validate payload: "); Serial.println(result.invalidReason); break; } if (DEBUG_MODE) { Serial.print("Humidity: "); Serial.println(result.humidity, 1); Serial.print("Radon: "); Serial.println(result.radon); Serial.print("Radon long term: "); Serial.println(result.radonLongTerm); Serial.print("Temperature: "); Serial.println(result.temperature, 2); Serial.print("Pressure: "); Serial.println(result.pressure, 2); Serial.print("CO2: "); Serial.println(result.co2); Serial.print("VOC: "); Serial.println(result.voc); if (result.hasExtendedPayload) { Serial.print("Battery raw candidate (experimental): "); Serial.println(result.batteryRawCandidate); } } success = true; } while (false); if (client->isConnected()) { client->disconnect(); } NimBLEDevice::deleteClient(client); return success; } bool readAirthings(AirthingsData& result) { Serial.println(); for (size_t candidateIndex = 0; candidateIndex < g_candidates.size(); candidateIndex++) { const CandidateDevice& candidate = g_candidates[candidateIndex]; Serial.print("Trying candidate "); Serial.print((int)(candidateIndex + 1)); Serial.print("/"); Serial.println((int)g_candidates.size()); for (uint8_t attempt = 1; attempt <= READ_RETRY_ATTEMPTS; attempt++) { if (DEBUG_MODE) { Serial.print("Read attempt "); Serial.print(attempt); Serial.print("/"); Serial.println(READ_RETRY_ATTEMPTS); } if (readFromCandidateOnce(candidate, result)) { NimBLEScan* scan = NimBLEDevice::getScan(); if (scan != nullptr) { scan->clearResults(); } NimBLEDevice::deinit(true); delay(NIMBLE_POST_DEINIT_MS); return true; } if (attempt < READ_RETRY_ATTEMPTS) { delay(READ_RETRY_DELAY_MS); } } } NimBLEScan* scan = NimBLEDevice::getScan(); if (scan != nullptr) { scan->clearResults(); } NimBLEDevice::deinit(true); delay(NIMBLE_POST_DEINIT_MS); return false; } /* ============================================================ * WLAN * ============================================================ */ bool connectWiFi() { Serial.println("Connecting WiFi..."); WiFi.persistent(false); WiFi.mode(WIFI_STA); delay(100); WiFi.begin(WIFI_SSID, WIFI_PASS); unsigned long start = millis(); while (WiFi.status() != WL_CONNECTED && millis() - start < (WIFI_CONNECT_WAIT_SECONDS * SECONDS_TO_MILLIS)) { delay(WIFI_RETRY_INTERVAL_MS); Serial.print("."); } Serial.println(); if (WiFi.status() != WL_CONNECTED) { Serial.println("Failed to connect to wifi"); return false; } Serial.println("WiFi connected."); return true; } void disconnectWiFi() { delay(WIFI_POST_MQTT_WAIT_MS); WiFi.disconnect(true, true); WiFi.mode(WIFI_OFF); } /* ============================================================ * MQTT * ============================================================ */ bool mqttPublishInt(PubSubClient& mqtt, const char* topic, int value) { char payload[16]; snprintf(payload, sizeof(payload), "%d", value); bool ok = mqtt.publish(topic, payload, MQTT_RETAIN); mqtt.loop(); delay(MQTT_PUBLISH_DELAY_MS); logMqttPublishResult(topic, payload, ok); return ok; } bool mqttPublishUInt(PubSubClient& mqtt, const char* topic, uint32_t value) { char payload[20]; snprintf(payload, sizeof(payload), "%lu", (unsigned long)value); bool ok = mqtt.publish(topic, payload, MQTT_RETAIN); mqtt.loop(); delay(MQTT_PUBLISH_DELAY_MS); logMqttPublishResult(topic, payload, ok); return ok; } bool mqttPublishFloat(PubSubClient& mqtt, const char* topic, float value, uint8_t decimals) { char payload[24]; dtostrf(value, 0, decimals, payload); char* trimmed = payload; while (*trimmed == ' ') trimmed++; bool ok = mqtt.publish(topic, trimmed, MQTT_RETAIN); mqtt.loop(); delay(MQTT_PUBLISH_DELAY_MS); logMqttPublishResult(topic, trimmed, ok); return ok; } bool mqttPublishJsonSnapshot(PubSubClient& mqtt, const AirthingsData& values) { char jsonPayload[320]; snprintf( jsonPayload, sizeof(jsonPayload), "{\"rc\":%lu,\"r24\":%d,\"rlt\":%d," "\"t\":%.2f,\"h\":%.1f,\"p\":%.2f,\"c\":%d,\"v\":%d,\"b\":%u}", (unsigned long)g_runCounter, values.radon, values.radonLongTerm, values.temperature, values.humidity, values.pressure, values.co2, values.voc, values.batteryRawCandidate ); bool ok = mqtt.publish(TOPIC_JSON_SNAPSHOT, jsonPayload, MQTT_RETAIN); mqtt.loop(); delay(MQTT_PUBLISH_DELAY_MS); if (DEBUG_MQTT_SINGLE_TOPICS) { Serial.print("MQTT publish ["); Serial.print(TOPIC_JSON_SNAPSHOT); Serial.print("] -> "); Serial.println(ok ? "OK" : "FEHLER"); } return ok; } bool publishSingleTopics(PubSubClient& mqtt, const AirthingsData& values) { bool ok = true; ok &= mqttPublishInt(mqtt, TOPIC_RADON_24HR, values.radon); ok &= mqttPublishInt(mqtt, TOPIC_RADON_LIFETIME, values.radonLongTerm); ok &= mqttPublishFloat(mqtt, TOPIC_TEMPERATURE, values.temperature, 2); ok &= mqttPublishFloat(mqtt, TOPIC_HUMIDITY, values.humidity, 1); ok &= mqttPublishFloat(mqtt, TOPIC_PRESSURE, values.pressure, 2); ok &= mqttPublishInt(mqtt, TOPIC_VOC, values.voc); ok &= mqttPublishInt(mqtt, TOPIC_CO2, values.co2); ok &= mqttPublishUInt(mqtt, TOPIC_RUN_COUNTER, g_runCounter); if (values.hasExtendedPayload) { ok &= mqttPublishUInt(mqtt, TOPIC_BATTERY_RAW, values.batteryRawCandidate); } return ok; } bool publishMQTT(const AirthingsData& values) { WiFiClient espClient; PubSubClient mqtt(espClient); mqtt.setServer(MQTT_HOST, MQTT_PORT); mqtt.setBufferSize(MQTT_BUFFER_SIZE); if (!mqtt.connect(MQTT_CLIENT_ID, MQTT_USER, MQTT_PASS)) { Serial.println("Unable to connect/publish to mqtt server."); return false; } bool ok = true; bool publishModeValid = false; if (ENABLE_SINGLE_TOPICS) { publishModeValid = true; ok &= publishSingleTopics(mqtt, values); } if (ENABLE_JSON_TOPIC) { publishModeValid = true; ok &= mqttPublishJsonSnapshot(mqtt, values); } if (!publishModeValid) { Serial.println("No MQTT publish mode enabled."); mqtt.disconnect(); return false; } mqtt.loop(); delay(MQTT_POST_PUBLISH_WAIT_MS); mqtt.disconnect(); delay(300); if (!ok) { Serial.println("MQTT publish failed."); return false; } Serial.println("MQTT publish complete."); return true; } /* ============================================================ * SETUP / LOOP * ============================================================ */ void setup() { Serial.begin(115200); delay(200); g_runCounter++; AirthingsData values; uint32_t sleepSeconds = READ_WAIT_RETRY_SECONDS; if (!scanForAirthingsCandidates()) { Serial.printf("\nFAILED to find any Airthings candidate devices. Sleeping for %i seconds before retrying.\n", READ_WAIT_RETRY_SECONDS); deepSleepSeconds(READ_WAIT_RETRY_SECONDS); return; } if (!readAirthings(values)) { Serial.printf("\nReading FAILED. Sleeping for %i seconds before retrying.\n", READ_WAIT_RETRY_SECONDS); deepSleepSeconds(READ_WAIT_RETRY_SECONDS); return; } if (!connectWiFi()) { disconnectWiFi(); Serial.printf("\nReading FAILED. Sleeping for %i seconds before retrying.\n", READ_WAIT_RETRY_SECONDS); deepSleepSeconds(READ_WAIT_RETRY_SECONDS); return; } if (publishMQTT(values)) { sleepSeconds = READ_WAIT_SECONDS; Serial.printf("\nReading complete. Sleeping for %i seconds before taking another reading.\n", READ_WAIT_SECONDS); } else { sleepSeconds = READ_WAIT_RETRY_SECONDS; Serial.printf("\nReading FAILED. Sleeping for %i seconds before retrying.\n", READ_WAIT_RETRY_SECONDS); } disconnectWiFi(); delay(500); deepSleepSeconds(sleepSeconds); } void loop() { delay(1); }
Hey! Du scheinst an dieser Unterhaltung interessiert zu sein, hast aber noch kein Konto.
Hast du es satt, bei jedem Besuch durch die gleichen Beiträge zu scrollen? Wenn du dich für ein Konto anmeldest, kommst du immer genau dorthin zurück, wo du zuvor warst, und kannst dich über neue Antworten benachrichtigen lassen (entweder per E-Mail oder Push-Benachrichtigung). Du kannst auch Lesezeichen speichern und Beiträge positiv bewerten, um anderen Community-Mitgliedern deine Wertschätzung zu zeigen.
Mit deinem Input könnte dieser Beitrag noch besser werden 💗
Registrieren Anmelden