Need Help: Wireless Water Heater Temperature Sensor

Hello,

I am building a wireless temperature sensor to be inserted in my water heater's thermostat chamber in order to precisely adjust heating temperature.

I decided to use an ESP32-H2 (supermini) and Zigbee for power efficiency, a 18650 battery, and a Dallas DS18B20 for its small footprint. Here is the schematic of my project:

As you can see, I am using a voltage divider (2x10kΩ) to read battery voltage in order to be able to broadcast both temperature and battery level to my Home Assistant instance. I also added a 100nF capacitor as I read online it could smooth out the voltage readings.

Below is the code I uploaded to my board:

#ifndef ZIGBEE_MODE_ED
#error "Zigbee end device mode is not selected in Tools->Zigbee mode"
#endif

#include "Zigbee.h"
#include "OneWireNg_CurrentPlatform.h"
#include "drivers/DSTherm.h"
#include "utils/Placeholder.h"

/* Zigbee temperature sensor configuration */
#define TEMP_SENSOR_ENDPOINT_NUMBER 10
uint8_t button = BOOT_PIN;

static Placeholder<OneWireNg_CurrentPlatform> ow;

// Optional Time cluster variables
struct tm timeinfo;
struct tm *localTime;
int32_t timezone;

const int numReadings = 100;
int readings[numReadings];  // the readings from the analog input
int readIndex = 0;          // the index of the current reading

int inputPin = A3;

#define LOOPS_BETWEEN_TEMP_REPORTS 5
#define LOOPS_BETWEEN_BATTERY_REPORTS 10

int incrementTempLoopCounter = 0;
int incrementBatterySecondsCounter = 0;

ZigbeeTempSensor zbTempSensor = ZigbeeTempSensor(TEMP_SENSOR_ENDPOINT_NUMBER);

/* Calculate average from array excluding 10% extremes */
static float smoothedAverageVoltage(const int readings[], int numReadings){
  int copiedReadings[numReadings];
  // copying input array
  for (int i=0 ; i < numReadings; i++){
    copiedReadings[i] = readings[i];
  }

  // sorting copied array
  for (int i = 0; i < numReadings - 1; ++i) {
    for (int j = 0; j < numReadings - i - 1; ++j) {
        if (copiedReadings[j] > copiedReadings[j + 1]) {
            // Swap the elements
            int temp = copiedReadings[j];
            copiedReadings[j] = copiedReadings[j + 1];
            copiedReadings[j + 1] = temp;
        }
    }
  }

  // excludings 10% top and bottom
  int startIndex = 0.1 * numReadings;
  int endIndex = 0.9 * numReadings;

  
  int sum = 0;
  for (int i = startIndex; i < endIndex; i++){
    sum += copiedReadings[i];
  }

  // converting it to voltage (3.3v board, 2x10kOhm voltage divider, adjustment value)
  float voltage = (3.3 / 1095. * 2. * ((float)sum/(endIndex-startIndex))) * 0.8918;

  // rouding voltage 
  voltage = round(voltage*100) / 100;
  return voltage;
}

static int voltageToBatteryLevel(float voltage){
  float batteryLevel = 0;
  // smooth out bad values
  if (voltage > 4.19){
    batteryLevel = 100;
  }
  else if (voltage < 3.31){
    batteryLevel = 0;
  }
  // simple 3rd degree polynomial interpolation from voltage agains battery life chart
  else{
    batteryLevel = (-1.6123*voltage*voltage*voltage+17.945*voltage*voltage-65.164*voltage+77.579)*100;
  }

  // force bound result between 0 and 100
  if (batteryLevel < 0)
    batteryLevel = 0;
  else if (batteryLevel > 100){
      batteryLevel = 100;
  }
  
  batteryLevel = round(batteryLevel);

  return (int)batteryLevel;
}

// retrieve temperature from DS18B20
static float getTemp(const DSTherm::Scratchpad& scrpd)
{
    long temp = scrpd.getTemp2();
    return (float)temp/16;
}

/********************* Arduino functions **************************/
void setup() {

  new (&ow) OneWireNg_CurrentPlatform(1, false);
  DSTherm drv(ow);

  Serial.begin(115200);

  // initializing table of battery voltage readings
  for (int thisReading = 0; thisReading < numReadings; thisReading++) {
    readings[thisReading] = 0;
  }

  // Init button switch
  pinMode(button, INPUT_PULLUP);

  // Optional: set Zigbee device name and model
  zbTempSensor.setManufacturerAndModel("ClemCorp", "ZigbeeTempSensorBoiler");

  // Set minimum and maximum temperature measurement value (10-50°C is default range for chip temperature measurement)
  zbTempSensor.setMinMaxValue(-55, 125);

  // Optional: Set tolerance for temperature measurement in °C (lowest possible value is 0.01°C)
  zbTempSensor.setTolerance(0.5);

  // Optional: Time cluster configuration (default params, as this device will revieve time from coordinator)
  zbTempSensor.addTimeCluster();

  zbTempSensor.setPowerSource(ZB_POWER_SOURCE_BATTERY, 100);

  // Add endpoint to Zigbee Core
  Zigbee.addEndpoint(&zbTempSensor);

  Serial.println("Starting Zigbee...");
  // When all EPs are registered, start Zigbee in End Device mode
  if (!Zigbee.begin()) {
    Serial.println("Zigbee failed to start!");
    Serial.println("Rebooting...");
    ESP.restart();
  } else {
    Serial.println("Zigbee started successfully!");
  }
  Serial.println("Connecting to network");
  while (!Zigbee.connected()) {
    Serial.print(".");
    delay(100);
  }
  Serial.println();

  // Optional: If time cluster is added, time can be read from the coordinator
  timeinfo = zbTempSensor.getTime();
  timezone = zbTempSensor.getTimezone();

  Serial.println("UTC time:");
  Serial.println(&timeinfo, "%A, %B %d %Y %H:%M:%S");

  time_t local = mktime(&timeinfo) + timezone;
  localTime = localtime(&local);

  Serial.println("Local time with timezone:");
  Serial.println(localTime, "%A, %B %d %Y %H:%M:%S");

  // Start Temperature sensor reading task
  // xTaskCreate(temp_sensor_value_update, "temp_sensor_update", 2048, NULL, 10, NULL);

  // Set reporting interval for temperature measurement in seconds, must be called after Zigbee.begin()
  // min_interval and max_interval in seconds, delta (temp change in 0,1 °C)
  // if min = 1 and max = 0, reporting is sent only when temperature changes by delta
  // if min = 0 and max = 10, reporting is sent every 10 seconds or temperature changes by delta
  // if min = 0, max = 10 and delta = 0, reporting is sent every 10 seconds regardless of temperature change
  zbTempSensor.setReporting(0, 10, 0);
}

void loop() {
  // Checking button for factory reset
  if (digitalRead(button) == LOW) {  // Push button pressed
    // Key debounce handling
    delay(100);
    int startTime = millis();
    while (digitalRead(button) == LOW) {
      delay(50);
      if ((millis() - startTime) > 3000) {
        // If key pressed for more than 3secs, factory reset Zigbee and reboot
        Serial.println("Resetting Zigbee to factory and rebooting in 1s.");
        delay(1000);
        Zigbee.factoryReset();
      }
    }
    zbTempSensor.reportTemperature();
  }

  DSTherm drv(ow);

  /* convert temperature on all sensors connected... */
  drv.convertTempAll(DSTherm::MAX_CONV_TIME, true);

  static Placeholder<DSTherm::Scratchpad> scrpd;

  OneWireNg::ErrorCode ec = drv.readScratchpadSingle(scrpd);
  if (ec == OneWireNg::EC_SUCCESS) {
      float temp = getTemp(scrpd);
      zbTempSensor.setTemperature(temp);
      Serial.printf("Current Temperature is %.2f°C\r\n",temp);
  } else if (ec == OneWireNg::EC_CRC_ERROR)
      // Serial.println("  CRC error.");

  // read from the sensor:
  readings[readIndex] = analogRead(A3);

  // advance to the next position in the array:
  readIndex = readIndex + 1;

  // if we're at the end of the array...
  if (readIndex >= numReadings) {
    // ...wrap around to the beginning:
    readIndex = 0;
  }

  if (incrementTempLoopCounter < LOOPS_BETWEEN_TEMP_REPORTS){
    incrementTempLoopCounter++;
  }
  else{
    // Serial.println("Reporting temp");
    zbTempSensor.reportTemperature();
    incrementTempLoopCounter = 0;
  }

  float roundedVoltage=smoothedAverageVoltage(readings, numReadings);
  Serial.printf("Current voltage is : %.2f\r\n",roundedVoltage);
  int batteryLevel = voltageToBatteryLevel(roundedVoltage);
  Serial.printf("Current battery level is : %d%\r\n",batteryLevel);  
  zbTempSensor.setBatteryPercentage(batteryLevel);

  if (incrementBatterySecondsCounter < LOOPS_BETWEEN_BATTERY_REPORTS){
    incrementBatterySecondsCounter++;
  }
  else{
    // Serial.println("Reporting battery level");  
    zbTempSensor.reportBatteryPercentage();
    incrementBatterySecondsCounter = 0;
  }

  Serial.println("----------");
  
  delay(1000);
}

I based my code on: OneWireNG's DallasTemperature template, Arduino's Zigbee Temperature Sensor template, and Arduino's Analog > Smoothing Template.

I have issues reading the battery voltage. It starts going through the roof before slowly decreasing to eventually reach a null value (whereas 18650 voltage reading with a multimeter shows plenty of battery left). Here is the result I get in Home Assistant:

Did I miss an obvious error in my code? Is my assembly incorrect?

Below the chart I used for polynomial interpolation for conversion from voltage to battery percent:

PS: I am aware that my code is very calculation hungry, thus not very power efficient. I simply want to correct my battery level issue before simplifying it (and trying to use deep sleep).

Very strange project. Even if your water heater is fiberglass, the RF will all be absorbed by the surrounded water in the heater.

Your issues seem to be related to how you calculate the battery voltage, not reading the battery voltage.

Have you tried printing the raw voltage values read?

And the thermostat??
You can't just trust on your thermometer circuit to turn of the boiler, one day you find the boiler on your garden...

Hot water heaters have a safety release on the top.

Should have.

No actually, a water heater's thermostat (or at least mine) is not inside the water tank, is is slided in a dry empty tube from the bottom of the tank as shown on this picture for instance:


I attached DS18B20 sensor to very thin wires and slided it in on top of the thermostat's tip. The rest of the assembly lies underneath the water heater.

I also serial printed the raw value of the analogread and had the same issue.
General trend (going through the roof and then slowly decreasing) is the same between raw value and battery percent, the only difference being it is not on the same scale

That is probably a good design, but do you see how your previous post can easily be misinterpreted?

As said in my answer to Paul_KD7HB, I do not intend to remove the thermostat, simply adjust its setting with better accuracy (it only has a 1 to 5 adjustment screw).

Furthermore, temperature reading is working well I only have an issue with the voltage reading.

Please note that the only time the temperature reading makes sense is when the tank is fully heated. Otherwise, due to difference of density between cold and hot water and poor heat exchanges, cold water remains on the bottom and hot water on the top. Besides, this is very convenient to always draw the hottest water from the tank. The sensor being plunged in the bottom part, when the tank is not fully heated, it measures the temperature of the cold water

Yes, of course: that is why I attached a picture in my answer, sorry

Worse than that, the cold water coming into the tank actually is piped right to the bottom of the tank, so it won't cool the hot water at the top of the tank.

It's built like that on purpose. The water inlet has a cap inside that prevents the incoming water flowing upwards. And the outlet pipe is located at the very top of the heater.

I wonder how the thermostat can prevent the water from exceeding the hot water limits that keep children from being burned?

Not the thermostat's job. That is done by a tempering valve.

Hi, @feisty3864
Welcome to the forum.

Why precisely?
What do you aim to achieve?
What is wrong with the original?

Check that you have continuity between the two pins marked, if there is no continuity then the ESP32 gnd is not at the battery gnd, so your voltage readings will be skewed.

Tom.... :smiley: :+1: :coffee: :australia:
PS. I am surprised that there is not a GPO there for the heater controller.

I was wondering that myself.... Those dry thermostats shown in reply#7 have been around unchanged since the 1940s, and are accurate, reliable, and really all that is required. It all seems to be one of those dog-chasing-car things.

Hi,

I've just checked the continuity with a multimeter and indeed, there's no connection. I didn't even think of checking it on the charge board! I'll try to connect them and let you know how it goes.

As for why I'm doing it, like I mentioned earlier, the only adjustment available on the heater is a 1-5 dial, which means I have no idea what temperature level corresponds to each position. The thermostat is very reliable in that it always brings the water to the same temperature. What I'm trying to do is set the temperature high enough to prevent bacterial issues and not so high that I waste water or electricity (electricity costs are quite high here).

Thanks for your reply !