Examples
Example: ESP32
An Arduino sketch for the ESP32 that reads DS18B20 sensors and uploads a batch to BoilerBuddy every minute, with notes on TLS, the clock and the buffer.
With an ESP32 and a few DS18B20 temperature sensors you can measure your solar water heater for very little money. This sketch reads the sensors every minute and sends the readings to BoilerBuddy. It is an example to adapt, not finished firmware.
What you need
- An ESP32 board, the Arduino IDE with the ESP32 core 3.x, and the libraries ArduinoJson (version 7), OneWire and DallasTemperature.
- DS18B20 sensors, with 4.7 kΩ between the data line and 3.3 V. Here the data line goes to GPIO 4.
- A device in the portal with circuit 201 (T1 collector, T2 tank bottom, T3 tank top) and an API key. See Building your own BoilerBuddy.
Setting it up
- Fill in your Wi-Fi, the API key and the root certificate at the top of the sketch (see TLS below).
- Upload the sketch and open the serial monitor at 115200 baud. At startup it prints the addresses of the sensors.
- Put those addresses in
PROBES, each next to the right code. By address rather than by order, so T1 stays the collector when you replace a sensor. - Upload again and check that Last seen changes and the readings appear on the Dashboard.
The 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: the root certificate
The sketch checks the server's certificate with setCACert, against the root certificate the chain ends at. For boilerbuddy.eu that is ISRG Root X1 from Let's Encrypt. Download it as PEM from the Let's Encrypt website and paste it into ROOT_CA, including the BEGIN and END lines. You can see the chain with:
openssl s_client -connect boilerbuddy.eu:443 -servername boilerbuddy.eu -showcerts </dev/null
- Use the root certificate, not the server's own. The server's certificate is replaced every few months; a root certificate lasts for years.
setInsecure()also works, but does not check who the ESP32 is talking to: anyone in between could pose as the server and read your token. Use it for a quick test at most.- If the connection suddenly fails after months, check the chain again: a certificate authority can switch roots.
The clock
An ESP32 has no battery-backed clock and starts at 1970. So setup() waits until NTP has set the time, usually a few seconds, and every timestamp the sketch sends is right. Clock correction by the server (Buffering and the clock) is more work than it is worth here.
The buffer
Readings go into a ring buffer in RAM first:
- 1440 readings: 8 hours at 3 sensors per minute, about 23 KB. When it is full, the oldest reading goes.
- After a restart or power cut it is empty, unless you write to flash, for example with LittleFS.
- At most 100 readings per upload; with a backlog, a batch every 10 seconds, well within the limit.
- After
200the batch is removed and it prints rejections and unknown codes. A422is dropped. On401,429,5xxor no answer it tries again later with the samebatch_uuid, waiting longer each time, up to 15 minutes and at leastRetry-After. retry: truedoes not occur here: the sketch never sendsclock_synced: false, and its buffer covers hours, not days. If you change either, keep those readings.
The sensors
- If a sensor does not answer, DallasTemperature returns -127 and the sketch sends
no_responsewithout a value. - Right after power-up a DS18B20 can return 85 °C: its start value, not a reading.
- A DS18B20 measures up to 125 °C, and a solar collector can get hotter when it stagnates.
- Circuit 201 also has R2, the pump, which the sketch does not measure. So the Sensor sends no reading alert follows. Switch that rule off under Alerts for the device, or send the pump too: 0 (off) or 10 (on).
What this sketch does not do
- Pair with a code: you put the API key in the sketch.
- Read the configuration from the API: the codes are fixed in
PROBES, and it measures every 60 seconds. - Keep secrets out of the code: Wi-Fi credentials and key are in it, so don't share the sketch filled in.
- Reuse connections: every request does a full TLS handshake. With one upload per minute that is not a problem.