ADC battery multiplier – calibrate from ON state or deep sleep?

I’m developing a soil moisture sensor project using LoRa communication with Heltec LoRa V3 boards.

I’m doing a single ADC read of the batteries and sending the value to the receiver, then to MQTT, and finally to Home Assistant.

My doubt is about using the multiply parameter.

We should divide the multimeter reading by the raw_adc value to get the multiply value to use in the sensor. But I’m not sure whether I should calculate it using the battery voltage measured while the board is in deep sleep or while it is awake (ON) .

I also had some trouble getting a stable ADC value without noise due to using update_interval: never . I had to use samples: 255 and sampling_mode: avg to improve it.

Here is my code below:

substitutions:
  sector: 3
  battery_low_level: 0.001
  send_interval: 2min

esphome:
  name: esp-kiwi-soil-sender
  friendly_name: "ESP Kiwi Soil - Sender"
  on_boot:
    #- priority: -100
      then:

        # 1. wait to stabilize
        - delay: 5s

        # 2. read sensors
        - script.execute: reading

        # 3. wait for readings
        - wait_until:
            not:
              script.is_running: reading

        # 4. --- WEAK BATTERY CHECK ---
        - if:
            condition:
              lambda: 'return id(battery_voltage).state <= ${battery_low_level};'
            then:
              - button.press: shutdown_button

        # 4. Envia pacote LoRa
        # Format: [sector(1B)] [volt_mV high(1B)] [volt_mV low(1B)] [pct(1B)] [temp_high(1B)] [temp_low(1B)]
        - sx126x.send_packet:
            data: !lambda |-
              uint8_t  sector     = ${sector};
              uint16_t volt_cV    = (uint16_t)(id(battery_voltage).state * 100);
              uint8_t  pct        = (uint8_t)(id(battery_percentage).state);
              int16_t  temp_dev   = (int16_t)(id(device_temperature).state * 10);

              std::vector<uint8_t> payload;
              payload.push_back(sector);
              payload.push_back((volt_cV >> 8) & 0xFF);
              payload.push_back(volt_cV & 0xFF);
              payload.push_back(pct);
              payload.push_back((temp_dev >> 8) & 0xFF);
              payload.push_back(temp_dev & 0xFF);

              ESP_LOGI("lora_tx", "Sent: Sector=%d Volt=%d cV Batt=%d%% DeviceTemp=%d", sector, volt_cV, pct, temp_dev);
              return payload;

        # 4. Put LoRa radio to sleep
        - sx126x.set_mode_sleep

        # 5. wait to stabilize
        - delay: 50ms

        # 6. disable all GPIOs
        - lambda: |-

            // === LORA SX1262 ===
            gpio_set_direction(GPIO_NUM_14, GPIO_MODE_DISABLE);  // DIO1
            gpio_set_direction(GPIO_NUM_8,  GPIO_MODE_DISABLE);  // NSS/CS
            gpio_set_direction(GPIO_NUM_12, GPIO_MODE_DISABLE);  // RESET
            gpio_set_direction(GPIO_NUM_13, GPIO_MODE_DISABLE);  // BUSY
            gpio_set_direction(GPIO_NUM_9,  GPIO_MODE_DISABLE);  // CLK
            gpio_set_direction(GPIO_NUM_11, GPIO_MODE_DISABLE);  // MISO
            gpio_set_direction(GPIO_NUM_10, GPIO_MODE_DISABLE);  // MOSI

            // === OLED SSD1306 ===
            gpio_set_direction(GPIO_NUM_17, GPIO_MODE_DISABLE);  // SDA
            gpio_set_direction(GPIO_NUM_18, GPIO_MODE_DISABLE);  // SCL
            gpio_set_direction(GPIO_NUM_21, GPIO_MODE_DISABLE);  // RST

            // === VEXT GPIO36 ===
            gpio_set_direction(GPIO_NUM_36, GPIO_MODE_DISABLE);

            // === LED GPIO35 ===
            gpio_set_direction(GPIO_NUM_35, GPIO_MODE_DISABLE);

            // === BATTERY ===
            gpio_set_direction(GPIO_NUM_37, GPIO_MODE_DISABLE);  // VBAT_CTRL
            gpio_set_direction(GPIO_NUM_1,  GPIO_MODE_DISABLE);  // VBAT_ADC

            // === SOIL SENSOR GPIO4 ===
            gpio_set_direction(GPIO_NUM_4, GPIO_MODE_DISABLE);

            // === POWER DOMAINS ===
            //esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_OFF);
            esp_sleep_pd_config(ESP_PD_DOMAIN_XTAL,    ESP_PD_OPTION_OFF);
            esp_sleep_pd_config(ESP_PD_DOMAIN_RC_FAST, ESP_PD_OPTION_OFF);

        # 7. wait to stabilize
        - delay: 50ms

        # 8. enter deep sleep mode
        - deep_sleep.enter: deep_sleep_1

esp32:
  variant: esp32s3
  flash_size: 8MB
  #cpu_frequency: 80MHZ
  #cpu_frequency: 160MHZ
  cpu_frequency: 240MHZ
  framework:
    type: esp-idf

logger:
  hardware_uart: UART0
  level: DEBUG
  logs:
    component: ERROR

deep_sleep:
  id: deep_sleep_1
  sleep_duration: ${send_interval}

button:
  - platform: shutdown
    id: shutdown_button

spi:
  clk_pin: GPIO9
  mosi_pin: GPIO10
  miso_pin: GPIO11

sx126x:
  # --- PINS / HARDWARE ---
  dio1_pin: GPIO14
  cs_pin: GPIO8
  busy_pin: GPIO13
  rst_pin: GPIO12
  hw_version: sx1262
  rf_switch: true

  # --- BASE CONFIG ---
  frequency: 433.0MHz
  modulation: LORA
  bandwidth: 125_0kHz
  crc_enable: true
  sync_value: [0x14, 0x24]
  preamble_size: 8
  rx_start: false

  # --- TCXO ---
  tcxo_voltage: 1_8V
  tcxo_delay: 5ms

  # --- TUNING ---
  pa_power: -2
  spreading_factor: 12
  coding_rate: CR_4_8

output:

  - platform: gpio
    pin: 37
    id: vbat_control
    inverted: true

  - platform: gpio
    pin: 36
    id: vext_control
    inverted: true

  - platform: gpio
    pin: 4
    id: moisture_power

sensor:

  - platform: adc
    pin: 1
    id: battery_voltage
    attenuation: 12db
    accuracy_decimals: 3
    samples: 255
    sampling_mode: avg
    update_interval: never
    filters:
      - round: 2
      #- multiply: 5.21
      #- round: 2

  - platform: copy
    id: battery_percentage
    source_id: battery_voltage
    unit_of_measurement: "%"
    filters:
      - calibrate_linear:
          - 3.30 -> 0
          - 4.10 -> 100
      - clamp:
          min_value: 0
          max_value: 100

  - platform: internal_temperature
    id: device_temperature
    accuracy_decimals: 1
    update_interval: never

script:
  - id: reading
    mode: single
    then:

      # BAT VOLT
      - output.turn_on: vbat_control
      - delay: 250ms
      - delay: 5s
      - component.update: battery_voltage
      - delay: 5s
      - output.turn_off: vbat_control

      # DEV TEMP
      - delay: 50ms
      - component.update: device_temperature

Thanks for any help!

Always better to read a voltage under load. I take you are using a voltage divider on the cell but then again you didn’t mention what cell you were using. Explain how you measure the voltage. ADC pin wouldn’t tolerate 4.1V for long before it would be distroyed. Does the Heltec V3 have a built in battery system?


Voltage doesn’t compare to how full cell is if it’s lithium. Percentage might be very misleading.

I’m not sure you can read the cell voltage when device is in deep sleep anyway.

Obviously you measure it at the same time/conditions that adc reads. If you have beefy battery the difference would be small though.

Yes, I’m using a Heltec WiFi LoRa 32 V3 board, and it already has built-in battery measurement hardware.

My battery is a 3.7V lithium-ion 18650 cell, fully charged at 4.20V. The battery is not connected directly to the ADC pin. On the Heltec V3, the battery voltage goes through the board’s internal battery sensing circuit (voltage divider + control MOSFET), so the ADC only sees a scaled voltage that is safe for the ESP32 ADC input.

To read the battery, I enable the board’s VBAT_CTRL pin, wait for the voltage to stabilize, then read the ADC. After that, I disable it again to save power before going back to deep sleep.

And yes, you’re right: during deep sleep the ESP32 ADC is not active, so the battery voltage has to be measured while the board is awake.

One thing I’m still trying to understand is how to get the most accurate battery voltage value. Right now I’m comparing the ADC reading with a multimeter.

In your opinion, does it make sense to use a calibrate_linear filter in ESPHome (based on multimeter reference points) to compensate for ADC and hardware tolerances, or is using a simple scaling factor enough for this type of battery monitoring?

Yes, that makes sense, and that’s exactly what I was trying to understand.

I know the correct reference is measuring under the same conditions as the ADC read, but I was wondering if it makes sense to apply some calibration or filtering in ESPHome to report a value slightly closer to the battery’s open-circuit voltage (when it’s not under load), or if that would just create a misleading value instead of the real operating voltage.

How bad a scatter are you getting with it currently?


This is one of my ESPHome devices with diy voltage divider connecting to ADC. It deep sleeps(5 minutes) as soon as it sends a reading ( withing 1-2 seconds of wifi connecting so device is awake at most 10 seconds awake) No filtering. It runs on wifi and power is turned down low. output_power: 12dB. Wifi is biggest current hog.

I have more of a scatter during the day when it’s solar charging at sametime. It was a cloudy day.

1 Like

I still don’t have data to understand the discharge behavior of the ‘18650’ battery.

I’m still trying to figure out the best scenario for me.

My project uses sending packets via the LoRa protocol, I don’t use WiFi. There are 8 devices where the 8 batteries can behave differently. I was thinking of reporting the battery state in RAW ADC and then doing the correction with a multiplication in Home Assistant. This way I avoid having to reinstall firmware on each board every time I change to a new battery.

Can you share your ADC code?

  - platform: adc
    pin: A0
    name: cell
    id: sw_cell
    filters:
      - multiply: 4.485

It shouldn’t affect the voltage multiplication factor if you change cells in general but a template sensor in HA so you can make adjustments would work out handy . As cell ages the internal resistance may change and so change the load resistance and throwing off the voltage divider values. If you are going to do this device with batteries and have it outside then I would advise a small solar panel otherwise you will be swapping cells every few days likely.