Zum Inhalt
BoilerBuddy

Beispiele

Beispiel: ESP32

Ein Arduino-Sketch für den ESP32, der DS18B20-Sensoren ausliest und jede Minute einen Batch an BoilerBuddy hochlädt, mit Hinweisen zu TLS, der Uhr und dem Puffer.

Mit einem ESP32 und ein paar DS18B20-Temperatursensoren misst du deine Solaranlage für sehr wenig Geld. Dieser Sketch liest die Sensoren jede Minute aus und schickt die Messwerte an BoilerBuddy. Er ist ein Beispiel zum Anpassen, keine fertige Firmware.

Was du brauchst

  • Ein ESP32-Board, die Arduino IDE mit dem ESP32-Core 3.x und die Bibliotheken ArduinoJson (Version 7), OneWire und DallasTemperature.
  • DS18B20-Sensoren, mit 4,7 kΩ zwischen Datenleitung und 3,3 V. Hier geht die Datenleitung an GPIO 4.
  • Ein Gerät im Portal mit Regelkreis 201 (T1 Kollektor, T2 Speicher unten, T3 Speicher oben) und ein API-Schlüssel. Siehe Dein eigenes BoilerBuddy bauen.

Einrichten

  1. Trag oben im Sketch dein WLAN, den API-Schlüssel und das Root-Zertifikat ein (siehe TLS unten).
  2. Lade den Sketch hoch und öffne den seriellen Monitor mit 115200 Baud. Beim Start gibt er die Adressen der Sensoren aus.
  3. Trag diese Adressen in PROBES ein, jeweils neben dem richtigen Code. Nach Adresse statt nach Reihenfolge, damit T1 der Kollektor bleibt, wenn du einen Sensor austauschst.
  4. Lade ihn noch einmal hoch und prüf, ob sich Zuletzt gesehen ändert und die Messwerte im Dashboard erscheinen.

Der Sketch

// BoilerBuddy example for an ESP32: DS18B20 temperature sensors -> device API v1.
// An example to adapt, not finished firmware. Board: Arduino-ESP32 core 3.x.
// Libraries: ArduinoJson 7, OneWire, DallasTemperature.

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <esp_random.h>
#include <esp_timer.h>
#include <time.h>

// ---- Settings ---------------------------------------------------------------
const char* WIFI_SSID = "your-network";
const char* WIFI_PASS = "your-password";
const char* API = "https://boilerbuddy.eu/api/v1";
const char* TOKEN = "bb_live_...";  // the API key of the device, from the portal
const char* FIRMWARE = "diy-esp32-0.1";
const int ONE_WIRE_PIN = 4;

// The root certificate the server's chain ends at, in PEM. See the text below.
const char* ROOT_CA = R"PEM(
-----BEGIN CERTIFICATE-----
...paste the root certificate here...
-----END CERTIFICATE-----
)PEM";

// Sensor codes from the device's circuit (here circuit 201), by DS18B20 address.
// The sketch prints the addresses it finds at startup: copy them in here.
struct Probe {
  DeviceAddress address;
  const char* code;
};
Probe PROBES[] = {
  {{0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, "T1"},  // collector
  {{0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, "T2"},  // tank bottom
  {{0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, "T3"},  // tank top
};
const int PROBE_COUNT = sizeof(PROBES) / sizeof(PROBES[0]);

const unsigned long MEASURE_MS = 60UL * 1000;  // intervals.scan_seconds of the config
const unsigned long STATUS_MS = 5UL * 60 * 1000;
const int BATCH_MAX = 100;                     // readings per upload (the API takes 1000)

// ---- Ring buffer in RAM: lost on a reboot or power cut -----------------------
struct Reading {
  time_t at;
  float value;
  uint8_t probe;  // index in PROBES
  bool ok;        // false: the sensor did not answer
};
const int BUFFER_SIZE = 1440;  // 8 hours of 3 sensors a minute, about 23 KB
Reading ring[BUFFER_SIZE];
int head = 0;          // oldest reading
int count = 0;         // readings in the buffer
int pendingCount = 0;  // readings in the batch being sent, 0 when none
char pendingUuid[37];  // its batch_uuid, the same on every retry

OneWire oneWire(ONE_WIRE_PIN);
DallasTemperature probes(&oneWire);
unsigned long lastMeasure, lastStatus, lastAttempt;
unsigned long waitMs = 0;  // before the next upload attempt
int failures = 0;

void push(const Reading& reading) {
  if (count == BUFFER_SIZE) {  // full: the oldest reading goes
    head = (head + 1) % BUFFER_SIZE;
    count--;
    pendingCount = 0;  // the batch changed: start a new one
  }
  ring[(head + count) % BUFFER_SIZE] = reading;
  count++;
}

void newUuid(char* out) {  // random, version 4
  uint8_t b[16];
  esp_fill_random(b, sizeof(b));
  b[6] = (b[6] & 0x0F) | 0x40;
  b[8] = (b[8] & 0x3F) | 0x80;
  snprintf(out, 37, "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
           b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
           b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]);
}

void isoTime(time_t t, char* out, size_t size) {  // 2026-10-15T09:30:00Z
  struct tm utc;
  gmtime_r(&t, &utc);
  strftime(out, size, "%Y-%m-%dT%H:%M:%SZ", &utc);
}

// One HTTPS request. Returns the HTTP status, or a negative number without an answer.
int request(const char* method, const char* path, const String& body,
            String* response, long* retryAfter) {
  WiFiClientSecure tls;
  tls.setCACert(ROOT_CA);
  HTTPClient http;
  http.begin(tls, String(API) + path);
  http.setConnectTimeout(5000);
  http.setTimeout(15000);
  http.addHeader("Authorization", String("Bearer ") + TOKEN);
  http.addHeader("Content-Type", "application/json");
  http.addHeader("Accept", "application/json");
  const char* keep[] = {"Retry-After"};
  http.collectHeaders(keep, 1);
  int status = http.sendRequest(method, body);
  if (response) *response = http.getString();
  if (retryAfter) *retryAfter = http.header("Retry-After").toInt();
  http.end();
  return status;
}

void measure() {
  probes.requestTemperatures();  // waits for the conversion, up to 750 ms
  time_t now = time(nullptr);
  for (int i = 0; i < PROBE_COUNT; i++) {
    float celsius = probes.getTempC(PROBES[i].address);
    bool ok = celsius != DEVICE_DISCONNECTED_C;
    push({now, ok ? celsius : 0.0f, (uint8_t)i, ok});
  }
}

void upload() {
  if (pendingCount == 0) {  // a new batch: the oldest readings
    pendingCount = min(count, BATCH_MAX);
    newUuid(pendingUuid);
  }

  JsonDocument batch;
  batch["batch_uuid"] = pendingUuid;
  JsonArray readings = batch["readings"].to<JsonArray>();
  char at[21];
  for (int i = 0; i < pendingCount; i++) {
    const Reading& r = ring[(head + i) % BUFFER_SIZE];
    JsonObject item = readings.add<JsonObject>();
    item["sensor"] = PROBES[r.probe].code;
    isoTime(r.at, at, sizeof(at));
    item["measured_at"] = at;
    if (r.ok) {
      item["value"] = r.value;
    } else {
      item["value"] = nullptr;
      item["quality"] = "no_response";
    }
  }
  String body, response;
  serializeJson(batch, body);

  long retryAfter = 0;
  int status = request("POST", "/device/readings", body, &response, &retryAfter);
  JsonDocument result;
  bool answered = status == 200 && !deserializeJson(result, response) && result["accepted"].is<int>();

  if (answered || status == 422) {
    if (answered) {
      // Every rejection is final here: this sketch never sends clock_synced false and
      // its buffer spans hours, so `retry: true` does not occur. Keep those readings
      // if you change either.
      for (JsonObject rejected : result["rejected"].as<JsonArray>()) {
        Serial.printf("Rejected #%d: %s\n", rejected["index"].as<int>(),
                      rejected["reason"].as<const char*>());
      }
      for (JsonVariant code : result["unmapped"].as<JsonArray>()) {
        Serial.printf("Not in the circuit: %s\n", code.as<const char*>());
      }
    } else {
      // 422: the batch as a whole is malformed. Sending it again will not help.
      Serial.printf("Batch refused: %s\n", response.c_str());
    }
    head = (head + pendingCount) % BUFFER_SIZE;
    count -= pendingCount;
    pendingCount = 0;
    failures = 0;
    waitMs = 10000;  // at most 6 uploads a minute while catching up
    return;
  }

  // No answer, 401, 429, 5xx: keep the batch and try again later, with the same uuid.
  if (status == 401) Serial.println("Token refused (401): check TOKEN in the portal.");
  failures++;
  waitMs = min(5000UL << min(failures - 1, 8), 15UL * 60 * 1000);  // up to 15 min
  waitMs = waitMs / 2 + esp_random() % (waitMs / 2);                // jitter
  if (status == 429 && retryAfter > 0) waitMs = max(waitMs, retryAfter * 1000UL);
  Serial.printf("Upload failed (%d), next try in %lu s\n", status, waitMs / 1000);
}

void sendStatus() {
  JsonDocument status;
  status["firmware_version"] = FIRMWARE;
  status["uptime_s"] = (unsigned long)(esp_timer_get_time() / 1000000);
  status["ip"] = WiFi.localIP().toString();
  status["rssi"] = WiFi.RSSI();
  status["buffered"] = count;
  String body;
  serializeJson(status, body);
  request("POST", "/device/status", body, nullptr, nullptr);
}

void printAddresses() {
  DeviceAddress address;
  int found = probes.getDeviceCount();
  Serial.printf("%d DS18B20 found:\n", found);
  for (int i = 0; i < found; i++) {
    if (!probes.getAddress(address, i)) continue;
    Serial.print("  {");
    for (int j = 0; j < 8; j++) Serial.printf("0x%02X%s", address[j], j < 7 ? ", " : "}\n");
  }
}

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.printf("WiFi connected: %s\n", WiFi.localIP().toString().c_str());

  // No readings until NTP has set the clock: before that it says 1970.
  configTime(0, 0, "pool.ntp.org", "time.google.com");
  while (time(nullptr) < 1700000000) delay(500);  // any moment after November 2023
  Serial.println("Clock synchronised");

  probes.begin();
  printAddresses();
  lastMeasure = millis() - MEASURE_MS;  // measure and report right away
  lastStatus = millis() - STATUS_MS;
}

void loop() {
  if (millis() - lastMeasure >= MEASURE_MS) {
    lastMeasure = millis();
    measure();
  }
  if (count > 0 && millis() - lastAttempt >= waitMs) {
    lastAttempt = millis();
    upload();
  }
  if (millis() - lastStatus >= STATUS_MS) {
    lastStatus = millis();
    sendStatus();
  }
  delay(100);
}

TLS: das Root-Zertifikat

Der Sketch prüft das Zertifikat des Servers mit setCACert, gegen das Root-Zertifikat, auf dem die Kette endet. Für boilerbuddy.eu ist das ISRG Root X1 von Let's Encrypt. Lade es als PEM von der Website von Let's Encrypt herunter und füge es in ROOT_CA ein, einschließlich der Zeilen BEGIN und END. Die Kette siehst du mit:

openssl s_client -connect boilerbuddy.eu:443 -servername boilerbuddy.eu -showcerts </dev/null
  • Nimm das Root-Zertifikat, nicht das des Servers selbst. Das Zertifikat des Servers wird alle paar Monate ersetzt; ein Root-Zertifikat hält jahrelang.
  • setInsecure() funktioniert auch, prüft aber nicht, mit wem der ESP32 spricht: Jeder dazwischen könnte sich als der Server ausgeben und deinen Token mitlesen. Nutze es höchstens für einen schnellen Test.
  • Schlägt die Verbindung nach Monaten plötzlich fehl, prüf die Kette noch einmal: Eine Zertifizierungsstelle kann das Root-Zertifikat wechseln.

Die Uhr

Ein ESP32 hat keine batteriegepufferte Uhr und startet bei 1970. Deshalb wartet setup(), bis NTP die Zeit gestellt hat, meist ein paar Sekunden, und jeder Zeitstempel, den der Sketch schickt, stimmt. Die Uhrkorrektur durch den Server (Puffern und die Uhr) lohnt den Aufwand hier nicht.

Der Puffer

Messwerte kommen zuerst in einen Ringpuffer im RAM:

  • 1440 Messwerte: 8 Stunden bei 3 Sensoren pro Minute, etwa 23 KB. Ist er voll, fällt der älteste Messwert weg.
  • Nach einem Neustart oder Stromausfall ist er leer, es sei denn, du schreibst in den Flash, zum Beispiel mit LittleFS.
  • Höchstens 100 Messwerte pro Upload; bei einem Rückstand ein Batch alle 10 Sekunden, deutlich innerhalb des Limits.
  • Nach 200 wird der Batch entfernt, und abgelehnte Messwerte und unbekannte Codes werden ausgegeben. Ein Batch mit 422 wird verworfen. Bei 401, 429, 5xx oder ohne Antwort versucht er es später mit derselben batch_uuid noch einmal und wartet jedes Mal länger, bis zu 15 Minuten und mindestens Retry-After.
  • retry: true kommt hier nicht vor: Der Sketch schickt nie clock_synced: false, und sein Puffer umfasst Stunden, nicht Tage. Änderst du eins davon, bewahre diese Messwerte auf.

Die Sensoren

  • Antwortet ein Sensor nicht, gibt DallasTemperature -127 zurück, und der Sketch schickt no_response ohne Wert.
  • Direkt nach dem Einschalten kann ein DS18B20 85 °C liefern: seinen Startwert, keine Messung.
  • Ein DS18B20 misst bis 125 °C, und ein Sonnenkollektor kann bei Stillstand heißer werden.
  • Regelkreis 201 hat auch R2, die Pumpe, die der Sketch nicht misst. Deshalb folgt die Meldung Sensor liefert keinen Messwert. Schalte diese Regel beim Gerät unter Meldungen aus, oder schick die Pumpe mit: 0 (aus) oder 10 (ein).

Was dieser Sketch nicht macht

  • Mit einem Code koppeln: Du trägst den API-Schlüssel in den Sketch ein.
  • Die Konfiguration aus der API lesen: Die Codes stehen fest in PROBES, und er misst alle 60 Sekunden.
  • Geheimnisse aus dem Code heraushalten: WLAN-Zugangsdaten und Schlüssel stehen darin, teile den ausgefüllten Sketch also nicht.
  • Verbindungen wiederverwenden: Jede Anfrage macht einen vollständigen TLS-Handshake. Bei einem Upload pro Minute ist das kein Problem.

Kommst du nicht weiter?

Stell deine Frage über das Kontaktformular. Wir helfen dir weiter und verbessern diese Seite.

Kontakt aufnehmen