Naar de inhoud
BoilerBuddy

Voorbeelden

Voorbeeld: ESP32

Een Arduino-sketch voor de ESP32 die DS18B20-sensoren leest en elke minuut een batch naar BoilerBuddy stuurt, met uitleg over TLS, de klok en de buffer.

Met een ESP32 en een paar DS18B20-temperatuursensoren meet je je zonneboiler voor weinig geld. Deze sketch leest de sensoren elke minuut en stuurt de metingen naar BoilerBuddy. Het is een voorbeeld om aan te passen, geen afgewerkte firmware.

Wat je nodig hebt

  • Een ESP32-bord, de Arduino IDE met de ESP32-core 3.x, en de bibliotheken ArduinoJson (versie 7), OneWire en DallasTemperature.
  • DS18B20-sensoren, met 4,7 kΩ tussen de datalijn en 3,3 V. De datalijn gaat hier naar GPIO 4.
  • Een apparaat in het portaal met regelkring 201 (T1 collector, T2 onderzijde vat, T3 bovenzijde vat) en een API-sleutel. Zie Je eigen BoilerBuddy bouwen.

Instellen

  1. Vul bovenaan de sketch je wifi, de API-sleutel en het rootcertificaat in (zie TLS hieronder).
  2. Upload de sketch en open de seriële monitor op 115200 baud. Bij het opstarten print hij de adressen van de sensoren.
  3. Zet die adressen in PROBES, elk bij de juiste code. Op adres en niet op volgorde, zodat T1 de collector blijft als je een sensor vervangt.
  4. Upload opnieuw en kijk of Laatst gezien verandert en de metingen op het Dashboard verschijnen.

De 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: het rootcertificaat

De sketch controleert het certificaat van de server met setCACert, tegen het rootcertificaat waar de keten eindigt. Voor boilerbuddy.eu is dat ISRG Root X1 van Let's Encrypt. Download die als PEM van de site van Let's Encrypt en plak hem in ROOT_CA, met de regels BEGIN en END. De keten zie je met:

openssl s_client -connect boilerbuddy.eu:443 -servername boilerbuddy.eu -showcerts </dev/null
  • Neem het rootcertificaat, niet dat van de server. Dat van de server wordt elke paar maanden vervangen; een rootcertificaat gaat jaren mee.
  • setInsecure() werkt ook, maar controleert niet met wie de ESP32 praat: wie ertussen zit, kan zich voordoen als de server en je token lezen. Hooguit voor een snelle test.
  • Faalt de verbinding na maanden ineens, controleer dan de keten opnieuw: een certificaatuitgever kan van root wisselen.

De klok

Een ESP32 heeft geen klok met batterij en start op 1970. Daarom wacht setup() tot NTP de tijd heeft gezet, meestal een paar seconden, zodat elke tijd die de sketch stuurt klopt. Klokcorrectie door de server (Bufferen en de klok) is hier meer werk dan het oplevert.

De buffer

De metingen gaan eerst in een ringbuffer in het RAM:

  • 1440 metingen: 8 uur bij 3 sensoren per minuut, ongeveer 23 KB. Is hij vol, dan gaat de oudste meting eruit.
  • Na een herstart of stroomstoring is hij leeg, tenzij je naar flash schrijft, bijvoorbeeld met LittleFS.
  • Hooguit 100 metingen per upload; bij een achterstand om de 10 seconden een batch, ruim binnen de limiet.
  • Na 200 gaat de batch eruit en print hij afwijzingen en onbekende codes. Een 422 gooit hij weg. Bij 401, 429, 5xx of geen antwoord probeert hij het later opnieuw met dezelfde batch_uuid, steeds wat later, tot hooguit 15 minuten en minstens Retry-After.
  • retry: true komt hier niet voor: de sketch stuurt nooit clock_synced: false en zijn buffer beslaat uren, geen dagen. Verander je een van beide, bewaar die metingen dan.

De sensoren

  • Antwoordt een sensor niet, dan geeft DallasTemperature -127 en stuurt de sketch no_response zonder waarde.
  • Vlak na het aanzetten kan een DS18B20 85 °C geven: zijn beginwaarde, geen meting.
  • Een DS18B20 meet tot 125 °C, en een zonnecollector kan bij stilstand heter worden.
  • Regelkring 201 heeft ook R2, de pomp, die de sketch niet meet. Dan volgt Sensor geeft geen meting. Zet die regel uit bij Meldingen van het apparaat, of stuur de pomp ook: 0 (uit) of 10 (aan).

Wat deze sketch niet doet

  • Koppelen met een code: je zet de API-sleutel in de sketch.
  • De configuratie uit de API lezen: de codes staan vast in PROBES, en hij meet elke 60 seconden.
  • Geheimen buiten de code houden: wifi-gegevens en sleutel staan erin, dus deel de sketch niet ingevuld.
  • Verbindingen hergebruiken: elk verzoek doet een volledige TLS-handshake. Bij één upload per minuut is dat geen probleem.

Kom je er niet uit?

Stel je vraag via het contactformulier. We helpen je verder en maken deze pagina beter.

Neem contact op