Any advice on connecting a SDI-12 sensor to ESPHome via ESP32?

I have a single Stevens HydraProbe sensor that I would like to connect to HA using an ESP32 and ESPHome, but I am struggling to figure out how actually to get it to work.

The primary issue I seem to be having is finding a way to communicate with the SDI-12 protocol.

Apparently, it can be done via half-duplex UART but I haven’t found a 1:1 example of a basic configuration.

Something like this looks like it has the potential to do what I am attempting to do, but my programming skills are not good enough to know how to do something like that in ESPHome

HarveyBates/ESP32-SDI12: SDI-12 library for ESP32 microcontrollers. (github.com)

#include <esp32-sdi12.h>

#define SDI12_DATA_PIN D0 // Change based on the pin you are using
#define DEVICE_ADDRESS 1 // SDI-12 Address of device

// Create a SDI12 object, passing in your chosen pin
ESP32_SDI12 sdi12(SDI12_DATA_PIN);

// Buffer to hold values from a measurement
float values[10];

void setup() {
    Serial.begin(115200);

    // Initialise SDI-12 pin definition
    sdi12.begin();
}

void loop() {
    // Measure (will populate values array with data)
    ESP32_SDI12::Status res = sdi12.measure(DEVICE_ADDRESS, values, sizeof(values));
    
    // Error handling
    if(res != ESP32_SDI12::SDI12_OK){
        // Some error occured, handle it here
        Serial.printf("Error: %d\n", res);
    }
    
    // Do what you want with values here

    delay(15000); // Do this measurement every 15 seconds
}

This is as far as I have been able to get with my efforts

uart:
  id: uart_bus
  rx_pin: GPIO38
  baud_rate: 1200

custom_component:
- id: sdi12_component
  lambda: |-
      auto sdi12_component = new SDI12Component(id(uart_bus));
      App.register_component(sdi12_component);
      return {sdi12_component};

# Define the custom sensors without update_interval
sensor:
  - platform: custom
    lambda: |-
      auto sdi12_sensor_1 = new SDI12Sensor(id(sdi12_component), "0", "M");
      App.register_sensor(sdi12_sensor_1);
      id(sdi12_sensor_1) = sdi12_sensor_1;
      return {sdi12_sensor_1};
    sensors:
      - name: "SDI-12 Measurement Set 1"
        filters:
        - lambda: return x[0];  # Soil Moisture (F)
        - lambda: return x[1];  # Bulk EC (Temperature Corrected) (I)
        - lambda: return x[2];  # Soil Temperature (C) (G)
        - lambda: return x[3];  # Soil Temperature (F) (H)
        - lambda: return x[4];  # Bulk EC (J)
        - lambda: return x[5];  # Real Dielectric Permittivity (L)
        - lambda: return x[6];  # Imaginary Dielectric Permittivity (M)
        - lambda: return x[7];  # Pore Water EC (K)
        - lambda: return x[8];  # Dielectric Loss Tangent (O)

  - platform: custom
    lambda: |-
      auto sdi12_sensor_2 = new SDI12Sensor(id(sdi12_component), "0", "M1");
      App.register_sensor(sdi12_sensor_2);
      id(sdi12_sensor_2) = sdi12_sensor_2;
      return {sdi12_sensor_2};
    sensors:
      - name: "SDI-12 Measurement Set 2"
        filters:
        - lambda: return x[0];  # Real Dielectric Permittivity (L)
        - lambda: return x[1];  # Imaginary Dielectric Permittivity (M)
        - lambda: return x[2];  # Imaginary Dielectric Permittivity (Temperature Corrected) (N)
        - lambda: return x[3];  # Dielectric Loss Tangent (O)
        - lambda: return x[4];  # Diode Temperature (P)

# Define a binary sensor to trigger the script
binary_sensor:
  - platform: gpio
    pin: GPIO1  # Change to the appropriate GPIO pin
    name: "Measurement Button"
    on_press:
      then:
        - script.execute: trigger_measurement

# Define a script to trigger the measurement
script:
  - id: trigger_measurement
    then:
      - lambda: |-
          id(sdi12_sensor_1).update();
          id(sdi12_sensor_2).update();

So you have created an ESPHome custom component from the code you mentioned?
You might want to show its code.

And what are the current results?

Yes, I tried to create a local external component but despite some help from ChatGPT I haven’t been able to get it to function properly. I was able to find another project that had some aspects of what I am trying to do albeit with custom hardware.

I find it weird that some basic SDI-12 functionality isn’t more common in ESPHome. So far I haven’t really seen a single implementation of one that didn’t rely on custom hardware which makes troubleshooting a lot harder.

There are almost certainly many things I have done wrong here, but hopefully, if I can get it working, there will be at least one reference like this for anyone else looking to do something similar.

Any suggestions you can offer would be much appreciated.

Current status of code functionality

Component file structure
image

soil-moisture.yaml
(The sensor filters are a remnant of my previous efforts and most likely will need to be modified)

esphome:
  name: soil-moisture
  friendly_name: soil-moisture
  # libraries:
  #   - plerup/espsoftwareserial

esp32:
  board: um_feathers3
  variant: esp32s3
  framework:
    type: arduino

# Enable logging
logger:
  level: DEBUG

# Enable Home Assistant API
api:
  encryption:
    key: !secret soil-moisture_api_key

ota:
  - platform: esphome
    password: !secret soil-moisture_ota_password

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  manual_ip:
    static_ip: 192.168.1.88
    gateway: 192.168.1.1
    subnet: 255.255.255.0
  ap:
    ssid: !secret soil-moisture_ap_ssid
    password: !secret soil-moisture_ap_password

captive_portal:

external_components:
  - source:
      type: local
      path: my_components/esp32_sdi12
    components: [ esp32_sdi12 ]

uart:
  id: uart_bus
  rx_pin: GPIO38
  baud_rate: 1200

esp32_sdi12:
  id: esp32_sdi12_component

# custom_component:
#   - id: esp32_sdi12_component
#     lambda: |-
#       auto esp32_sdi12_component = new ESP32SDI12Component(id(uart_bus));
#       App.register_component(esp32_sdi12_component);
#       return {esp32_sdi12_component};

sensor:
  - platform: esp32_sdi12
    id: sdi12_sensor_1
    name: "SDI-12 Measurement Set 1"
    address: 0
    command: "M"
    filters:
          - lambda: return x[0];  # Soil Moisture (F)
          - lambda: return x[1];  # Bulk EC (Temperature Corrected) (I)
          - lambda: return x[2];  # Soil Temperature (C) (G)
          - lambda: return x[3];  # Soil Temperature (F) (H)
          - lambda: return x[4];  # Bulk EC (J)
          - lambda: return x[5];  # Real Dielectric Permittivity (L)
          - lambda: return x[6];  # Imaginary Dielectric Permittivity (M)
          - lambda: return x[7];  # Pore Water EC (K)
          - lambda: return x[8];  # Dielectric Loss Tangent (O)

  - platform: esp32_sdi12
    id: sdi12_sensor_2
    name: "SDI-12 Measurement Set 2"
    address: "0"
    command: "M1"
    filters:
          - lambda: return x[0];  # Real Dielectric Permittivity (L)
          - lambda: return x[1];  # Imaginary Dielectric Permittivity (M)
          - lambda: return x[2];  # Imaginary Dielectric Permittivity (Temperature Corrected) (N)

platformio.ini

; PlatformIO Project Configuration File
;
;   Build options: build flags, source filter
;   Upload options: custom upload port, speed and extra flags
;   Library options: dependencies, extra library storages
;   Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html

[env:um_feathers3]
platform = espressif32
board = um_feathers3
framework = arduino
lib_deps =
    plerup/espsoftwareserial@^6.15.0

sensor.py

from esphome.const import CONF_ID, CONF_NAME, CONF_FILTERS
from esphome.components import uart
from esphome.core import coroutine
from esphome import automation
# Assuming cv and cg are required and part of the esphome framework
from esphome.helpers import cv, cg  # This is an assumed import for illustration

CONF_ADDRESS = "address"
CONF_COMMAND = "command"

CONFIG_SCHEMA = cv.Schema({
    cv.GenerateID(): cv.declare_id(ESP32SDI12Component),
    cv.Optional(CONF_NAME): cv.string,
    cv.Optional(CONF_ADDRESS): cv.int_range(min=0, max=255),
    cv.Optional(CONF_COMMAND): cv.string,
    cv.Optional(CONF_FILTERS): automation.validate_automation(single=True),
}).extend(cv.COMPONENT_SCHEMA).extend(uart.UART_DEVICE_SCHEMA)

@coroutine
async def to_code(config):
    var = cg.new_Pvariable(config[CONF_ID])
    await cg.register_component(var, config)
    await uart.register_uart_device(var, config)

    if CONF_NAME in config:
        cg.add(var.set_name(config[CONF_NAME]))
    if CONF_ADDRESS in config:
        cg.add(var.set_address(config[CONF_ADDRESS]))
    if CONF_COMMAND in config:
        cg.add(var.set_command(config[CONF_COMMAND]))
    if CONF_FILTERS in config:
        filters = config[CONF_FILTERS]
        if not isinstance(filters, list):
            filters = [filters]  # Ensure filters is always a list
        for conf in filters:
            await automation.build_automation(var, [(float, "x")], conf)

esp-sdi12.h

#ifndef ESP32_SDI12_H
#define ESP32_SDI12_H

#include "SoftwareSerial.h"

// Optional define to display SDI-12 serial debug output
//#define SDI12_SERIAL_DEBUG

class ESP32_SDI12 {
private:
    // Software Serial instance (initialised in `begin()`)
    SoftwareSerial uart;

    // Maximum number of SDI-12 devices on bus
    static constexpr int8_t MAX_N_DEVICES       =       9;

    /* Information command structure begin */
    // Length of vendor
    static constexpr int8_t LEN_VENDOR          =       8;
    // Length of sensor model identifier
    static constexpr int8_t LEN_MODEL           =       6;
    // Length of sensor version
    static constexpr int8_t LEN_SENSOR_VERSION  =       3;
    // Length of additional sensor information
    static constexpr int8_t LEN_INFO            =       13;
    /* Information command structure end */

    // Baud rate of SDI-12 TX/RX
    static constexpr int16_t SDI12_BAUD         =       1200;
    // Max size of any SDI-12 command (in bytes)
    static constexpr int8_t MAX_CMD_SIZE        =       8;
    // Max size of response (in bytes)
    static constexpr int8_t MAX_RES_SIZE        =       75;

    // Message buffer for the command
    char sensor_cmd[MAX_CMD_SIZE+1] = {0};
    // Message buffer for response
    char msg_buf[MAX_RES_SIZE+1] = {0};

    // List of available SDI-12 commands
    enum Commands {
        // aM! (Measure)
        SDI12_Measure           =       0x00,
        // aMC! (CRC Measure)
        SDI12_CRC_Measure       =       0x01,
        // aMd! (Additional Measure)
        SDI12_AD_Measure        =       0x02,
        // aMCd! (Additional CRC Measure)
        SDI12_AD_CRC_Measure    =       0x03,
        // aDp! (Get data)
        SDI12_Data              =       0x04,
        // a! (Attention)
        SDI12_Attention         =       0x05,
        // aI! (Information)
        SDI12_Information       =       0x06,
        // aC! (Concurrent Measure)
        SDI12_CNC_Measure       =       0x07,
        // aCC! (Concurrent CRC Measure)
        SDI12_CND_CRC_Measure   =       0x08,
        // aV! (Verify)
        SDI12_Verify            =       0x09,
        // aAb! (Change Address)
        SDI12_Change_Address    =       0x0A
    };

    // SDI-12 measure response structure
    struct Measure {
        uint8_t address;
        uint16_t delay_time;
        uint8_t n_values;
    };

public:
    // Status information for SDI-12 requests and data parsing in the
    // ESP32-SDI12 library
    enum Status {
        // All ok, no errors detected
        SDI12_OK                =       0x00,
        // A generic error was detected
        SDI12_ERR               =       0x01,
        // Sensor not found when querying the bus
        SDI12_SENSOR_NOT_FOUND  =       0x02,
        // Invalid SDI-12 address was supplied
        SDI12_INVALID_ADDR      =       0x03,
        // SDI-12 address already in-use by another sensor
        SDI12_ADDR_IN_USE       =       0x04,
        // SDI-12 response timed out
        SDI12_TIMEOUT           =       0x05,
        // Supplied buffer was too small to hold the returned data
        SDI12_BUF_OVERFLOW      =       0x06
    };

    // SDI-12 sensor information obtained from a `SDI12_Information`
    // request
    struct Sensor {
        // Sensor address on the bus (0 - 9)
        uint8_t address;
        // SDI-12 major version identifier
        uint8_t sdi_version_major;
        // SDI-12 minor version identifier
        uint8_t sdi_version_minor;
        // SDI-12 vendor identifier
        uint8_t vendor[LEN_VENDOR];
        // SDI-12 sensor model identifier
        uint8_t model[LEN_MODEL];
        // SDI-12 sensor version information
        uint8_t sensor_version[LEN_SENSOR_VERSION];
        // Any additional information from sensor
        uint8_t aux_information[LEN_INFO];
    };

    // Structure to hold up to 10 sensors (and all their information)
    struct Sensors {
        // Number of sensors detected on bus
        uint8_t count;
        // Array of sensor information
        Sensor sensor[MAX_N_DEVICES];
    };

    explicit ESP32_SDI12(int8_t pin);
    ~ESP32_SDI12() = default;

    void begin();
    Status ackActive(uint8_t address);
    Status sensorInfo(uint8_t address, Sensor* sensor);
    Status sensorInfo(uint8_t address);
    Status sensorsOnBus(Sensors* sensors);
    Status changeAddress(uint8_t address,
                         uint8_t newAddress);
    Status measure(uint8_t address, float* values, size_t max_values,
                   uint8_t* num_returned_values = nullptr);

    // Default SDI-12 pin (should error if uninitialized as
    // `SDI12_INVALID_ADDR`)
    int8_t sdi12_pin = -1;

private:
    Status waitForResponse(uint32_t timeout = 1000);
    Status querySensor(uint8_t address, Commands cmd, uint8_t position = 0,
                       uint8_t newAddress = 0);
    static Status validAddress(uint8_t address);
    Status requestMeasure(uint8_t address, Measure* measure);
    Status requestData(uint8_t address, uint8_t position);

    size_t readUntilCRLF();

#ifdef SDI12_SERIAL_DEBUG
    // Debug message that outputs sensor information over a serial connection
    void sensor_debug(Sensor* sensor);
#endif // SDI12_SERIAL_DEBUG

};

#endif //ESP32_SDI12_H

esp32-sdi12.cpp

#include <Arduino.h>
#include "esp32-sdi12.h"

/**
 * @brief Constructor for ESP32_SDI12 object.
 *
 * Takes a single argument referencing a pin that is attached to the SDI-12
 * data line. This should be called before the setup() function under normal
 * operation.
 *
 * @param pin SDI-12 data pin (single pin all SDI-12 devices are connected to.
 */
ESP32_SDI12::ESP32_SDI12(int8_t pin){
    // Set the SDI12 pin definition to a variable for later use.
    ESP32_SDI12::sdi12_pin = pin;
}

/**
 * @brief Start SDI-12 service.
 *
 * @details This method initialises and instance of the ESPSoftwareSerial library with
 * the required settings for SDI-12 communications. This could be done in the
 * constructor but this gives further flexibility to the user.
 *
 * @see https://github.com/plerup/espsoftwareserial
 */
void ESP32_SDI12::begin() {
    // Pass baud rate (1200 as per the SDI-12 specsheet) and specified SDI-12
    // pin to software serial service.
    // 'SWSERIAL_7E1' specifies the SDI-12 bit frame format i.e.
    // 1 start bit, 7 data bits (7), 1 even parity bit (E) and 1 data bit (1).
    // TRUE required as the SDI-12 signal is normally low.
    // Valid GPIO pin will be checked by SoftwareSerial
    uart.begin(SDI12_BAUD, SWSERIAL_7E1, sdi12_pin, sdi12_pin, true);

    // If the pull-up is enabled during the command/response cycle, some
    // SDI-12 sensors do not work. We found the Meter Atmos41 failed in
    // this configuration.
    uart.enableRxGPIOPullUp(false);

    // As the SDI-12 communication operates in half-duplex mode (there is no
    // separate RX and TX lines) the interrupt on TX is turned off.
    uart.enableIntTx(false);

    // Placing the pin in a low state seems to help stability on startup.
    digitalWrite(sdi12_pin, LOW);
}

/**
 *
 * @brief Waits for a response from a SDI-12 device.
 *
 * Wait for a character to appear over UART before continuing. Timeout will
 * depend on a sensors response to a measure command. For example, if a
 * measure command is send, the preceding response should be ttt where ttt
 * represents the time in seconds until the device will be ready to send data.
 *
 * @see SDI-12 specification page 19.
 *
 * @param timeout the number of milliseconds to wait before timing out.
 * @return SDI12_OK if a character appears before the timeout,
 * otherwise SDI12_TIMEOUT.
 */
ESP32_SDI12::Status ESP32_SDI12::waitForResponse(uint32_t timeout) {

    uint32_t start = millis();
    while (millis() - start < timeout && !uart.available()) {
        delay(1);
    }

    #ifdef SDI12_SERIAL_DEBUG
        uint32_t end = millis();
        Serial.printf("Time elapsed: %u ms\n", end - start);
        if(!uart.available()){
            Serial.printf("No response was found before timeout\n");
        }
    #endif

    return uart.available() > 0 ? SDI12_OK : SDI12_TIMEOUT;
}

/**
 * @brief Passes a response into msg_buf.
 *
 * This method assumes data is available on the UART.
 *
 * @return The length of the message. If 0 there was no response.
 */
size_t ESP32_SDI12::readUntilCRLF() {
    // Reset the message buffer
    memset(msg_buf, 0, sizeof(msg_buf));

    // Read response from device
    uart.readBytesUntil('\n', msg_buf, MAX_RES_SIZE);

    #ifdef SDI12_SERIAL_DEBUG
        Serial.printf("Response length: %zu\n", strlen(msg_buf));
    #endif

    return strlen(msg_buf);
}

/**
 * @brief Main function to deal with SDI12 commands and responses.
 *
 * @details A sensor command is created based on the passed command request. Optionally,
 * the user can pass in a position or newAddress for commands that requires
 * these additional variables.
 *
 * @note SDI-12 structure is as follows.
 * Break (12 ms)
 *       │                          380 - 810 ms
 *       ▼      Command              Response
 *     ┌───┐  ┌─┐ ┌─┐ ┌─┐           ┌─┐ ┌─┐ ┌─┐
 *     │   │  │ │ │ │ │ │           │ │ │ │ │ │
 * ────┘   └──┘ └─┘ └─┘ └───────────┘ └─┘ └─┘ └─
 *           ▲               Max
 *           │          ◄───15 ms───►
 *       Marking (8.3 ms)
 *
 * @param address The sensor SDI-12 address.
 * @param cmd Command found in Commands enum.
 * @param position As a maximum of 75 bytes can be transmitted in a single
 * response, the position gives the ability to send multiple identical requests
 * while adjusting the position 0 - 9 to get all data.
 * @param newAddress If changing address this will be the sensors new address.
 * @return Status code (ESP32_SDI12::Status).
 */
ESP32_SDI12::Status ESP32_SDI12::querySensor(uint8_t address,
                                             Commands cmd,
                                             uint8_t position,
                                             uint8_t newAddress) {
    memset(sensor_cmd, 0, sizeof(sensor_cmd));

    bool response = false; // Is a response needed from this command?

    switch(cmd){
        case SDI12_Measure:
            response = true;
            snprintf(sensor_cmd, sizeof(sensor_cmd), "%dM!", address);
            break;
        case SDI12_CRC_Measure:
            response = true;
            snprintf(sensor_cmd, sizeof(sensor_cmd), "%dMC!", address);
            break;
        case SDI12_AD_Measure:
            response = true;
            snprintf(sensor_cmd, sizeof(sensor_cmd), "%dM%d!", address,
                     position);
            break;
        case SDI12_AD_CRC_Measure:
            response = true;
            snprintf(sensor_cmd, sizeof(sensor_cmd), "%dM%dC!", address,
                     position);
            break;
        case SDI12_Data:
            response = true;
            snprintf(sensor_cmd, sizeof(sensor_cmd), "%dD%d!", address,
                     position);
            break;
        case SDI12_Attention:
            snprintf(sensor_cmd, sizeof(sensor_cmd), "%d!", address);
            break;
        case SDI12_Information:
            response = true;
            snprintf(sensor_cmd, sizeof(sensor_cmd), "%dI!", address);
            break;
        case SDI12_CNC_Measure:
            response = true;
            snprintf(sensor_cmd, sizeof(sensor_cmd), "%dC!", address);
            break;
        case SDI12_CND_CRC_Measure:
            response = true;
            snprintf(sensor_cmd, sizeof(sensor_cmd), "%dCC!", address);
            break;
        case SDI12_Verify:
            response = true;
            snprintf(sensor_cmd, sizeof(sensor_cmd), "%dV!", address);
            break;
        case SDI12_Change_Address:
            snprintf(sensor_cmd, sizeof(sensor_cmd), "%dA%d!", address,
                     newAddress);
            break;
    }

    #ifdef SDI12_SERIAL_DEBUG
        Serial.printf("Issuing command: %s\n", sensor_cmd);
    #endif // SDI12_SERIAL_DEBUG

    // Reset the message buffer
    memset(msg_buf, '\0', sizeof(msg_buf));

    uart.enableRx(false); // Disable interrupts on RX

    pinMode(sdi12_pin, OUTPUT); // Set pin to OUTPUT mode before break and marking

    // Break (at least 12 ms) put signal HIGH
    digitalWrite(sdi12_pin, HIGH);
    delay(13);

    // Marking (at least 8.3 ms) pull signal LOW
    digitalWrite(sdi12_pin, LOW);
    delay(9);

    // Send command after break and marking to sensor
    uart.write(sensor_cmd);

    // Receive message from sensor
    uart.enableTx(false); // Enables RX mode

    Status res = waitForResponse();
    if (res != SDI12_OK) {
        return res;
    }

    if (response) {
        // readUntilCRLF leaves the string read from the UART in msg_buf with
        // trailing whitespace removed.
        readUntilCRLF();
    }

    digitalWrite(sdi12_pin, LOW);

    return SDI12_OK;
}


/**
 * @brief Executes the SDI-12 attention command to see if a sensor is available
 * on a particular address.
 *
 * @warning Not all sensors respond to this command. Use information command
 * if unsure.
 *
 * @param address SDI-12 sensor address
 * @return Status code (ESP32_SDI12::Status).
 */
ESP32_SDI12::Status ESP32_SDI12::ackActive(uint8_t address) {

    if(validAddress(address) != SDI12_OK){
        return SDI12_INVALID_ADDR;
    }

    return querySensor(address, SDI12_Attention);
}

/**
 * @brief Get SDI-12 sensor information.

 * Pass in a reference to a Sensor struct to be populated (when successful).
 *
 * @param address SDI-12 sensor address.
 * @param sensor SDI-12 Sensor struct to be populated.
 * @return Status code (ESP32_SDI12::Status).
 */
ESP32_SDI12::Status ESP32_SDI12::sensorInfo(uint8_t address,
                                            Sensor* sensor) {

    if(validAddress(address) != SDI12_OK){
        return SDI12_INVALID_ADDR;
    }

    ESP32_SDI12::Status res = querySensor(address, SDI12_Information);
    if(res != SDI12_OK) {
        return res;
    }

    // Parse response held in msg_buf into a SDI12 sensor object
    memcpy(sensor, msg_buf, strlen(msg_buf));

    #ifdef SDI12_SERIAL_DEBUG
        sensor_debug(sensor);
    #endif // SDI12_SERIAL_DEBUG

    return SDI12_OK;
}

/**
 * @breif Get SDI-12 sensor information.
 *
 * Used when the ackActive() command is not supported by the sensor. This
 * method will return SDI12_OK if a device is found but will not populate
 * a Sensor struct.
 *
 * @param address SDI12 sensor address.
 * @return Status code (ESP32_SDI12::Status).
 */
ESP32_SDI12::Status ESP32_SDI12::sensorInfo(uint8_t address) {

    if(validAddress(address) != SDI12_OK){
        return SDI12_INVALID_ADDR;
    }

    ESP32_SDI12::Status res = querySensor(address, SDI12_Information);
    if(res != SDI12_OK) {
        return res;
    }

    return SDI12_OK;
}

/**
 * @brief Checks if the supplied address is a valid SDI-12 address.
 *
 * @param address SDI-12 sensor address.
 * @return Status code (ESP32_SDI12::Status).
 */
ESP32_SDI12::Status ESP32_SDI12::validAddress(uint8_t address) {
    return (address <= MAX_N_DEVICES) ? SDI12_OK : SDI12_INVALID_ADDR;
}

/**
 * @brief Obtains an overview of sensors on the SDI-12 bus.
 *
 * Populates a Sensors struct with sensors as they are found on the SDI-12 bus.
 *
 * @param sensors Sensors struct to be populated.
 * @return Status code (ESP32_SDI12::Status).
 */
ESP32_SDI12::Status ESP32_SDI12::sensorsOnBus(Sensors* sensors) {

    sensors->count = 0;
    for (uint8_t address = 0; address <= 9; address++) {
        if (querySensor(address, SDI12_Information) == SDI12_OK) {
            // Assign sensor to provided struct
            memcpy(&sensors->sensor[sensors->count], msg_buf, strlen(msg_buf));

            // Doing a memcpy above results in the address becoming its decimal
            // representation e.g. 0 becomes 48. To solve this, the sensors
            // address is reassigned with its actual address.
            sensors->sensor[sensors->count].address = address;

            #ifdef SDI12_SERIAL_DEBUG
                sensor_debug(&sensors->sensor[sensors->count]);
            #endif // SDI12_SERIAL_DEBUG

            sensors->count++;

        } else {
            #ifdef SDI12_SERIAL_DEBUG
                Serial.printf("No sensor found on address: %d\n", address);
            #endif // SDI12_SERIAL_DEBUG
        }
    }

    if (sensors->count == 0) {
        return SDI12_SENSOR_NOT_FOUND;
    }

    #ifdef SDI12_SERIAL_DEBUG
        Serial.printf("=== Sensor scan complete ===\n");
        Serial.printf("\t%d SDI12 sensor(s) found.\n", sensors->count);
    #endif // SDI12_SERIAL_DEBUG

    return SDI12_OK;
}

/**
 * @brief Change a devices SDI-12 address.
 *
 * @param address Sensors current SDI-12 address.
 * @param newAddress Proposed SDI-12 sensor address.
 * @return Status code (ESP32_SDI12::Status).
 */
ESP32_SDI12::Status ESP32_SDI12::changeAddress(uint8_t address,
                                               uint8_t newAddress) {
    #ifdef SDI12_SERIAL_DEBUG
        Serial.printf("Request sensor change address from: %d to: %d\n",
                      address, newAddress);
    #endif // SDI12_SERIAL_DEBUG

    // Check if addresses are valid
    if(validAddress(address) != SDI12_OK &&
       validAddress(newAddress) != SDI12_OK){
        return SDI12_INVALID_ADDR;
    }

    // Check if devices exist on each address (should on the first, shouldn't
    // on the new address). Using the information command here as not all
    // sensors support the verify command.
    Status res = sensorInfo(address);
    if(res != SDI12_OK){
        #ifdef SDI12_SERIAL_DEBUG
            Serial.printf("Error, no sensor found on address %d\n", address);
        #endif // SDI12_SERIAL_DEBUG
        return SDI12_SENSOR_NOT_FOUND;
    }

    res = sensorInfo(newAddress);
    if(res == SDI12_OK){
        #ifdef SDI12_SERIAL_DEBUG
            Serial.printf("Error, address %d already inuse\n", newAddress);
        #endif // SDI12_SERIAL_DEBUG
        return SDI12_ADDR_IN_USE;
    }

    // Change address from address to new address
    res = querySensor(address, SDI12_Change_Address, 0, newAddress);

    #ifdef SDI12_SERIAL_DEBUG
        if (res == SDI12_OK) {
            Serial.printf("Address changed from %d to %d\n", address, newAddress);
        }
    #endif // SDI12_SERIAL_DEBUG

    return res;
}

/**
 * @brief Request a SDI-12 sensor to take a measurement.
 *
 * If successful the Measure struct will contain all variables needed
 * to submit a subsequent data command (as required by the SDI-12 spec).
 *
 * @note This method does not return sensor data, rather information regarding
 * how long it will take for the sensor to have data ready to send and how many
 * values can be expected.
 *
 * @param address Sensor SDI-12 address.
 * @param measure Measure struct to be populated (if successful)
 * @return Status code (ESP32_SDI12::Status).
 */
ESP32_SDI12::Status ESP32_SDI12::requestMeasure(uint8_t address,
                                                Measure* measure) {
    Status res = querySensor(address, SDI12_Measure, 0, 0);

    #ifdef SDI12_SERIAL_DEBUG
        if(res != SDI12_OK){
            Serial.printf("Error requesting sensor measurement (Error = %d)\n", res);
            return res;
        } else {
            Serial.printf("Successfully queued measurement on address: %d\n", address);
        }
    #endif // SDI12_SERIAL_DEBUG

    // Device address that requestMeasure command was issued on
    measure->address = msg_buf[0] - '0';

    // Delay time before data is ready in seconds
    char delay_time_buf[4];
    for(uint8_t i = 1; i < 4; i++){
       delay_time_buf[i-1] = msg_buf[i];
       delay_time_buf[i] = 0;
    }
    measure->delay_time = strtol(delay_time_buf, nullptr, 10);

    // Expected number of values in response
    measure->n_values = msg_buf[4] - '0';

    // A sensor *should* send a service request string when it has asked for a delay of > 0
    // seconds. We have at least one sensor that does not always do this so don't assume
    // it's coming, but read and discard it if it does.
    if (measure->delay_time > 0) {
        waitForResponse(measure->delay_time * 1000);
        if (uart.available() > 0) {
            // This should be the service request, because no data commands
            // have been issued yet.
            readUntilCRLF();
        }
    }

    return SDI12_OK;
}

/**
 * Request data from SDI-12 sensor (after measure command).
 *
 * @breif Used to request data from a sensor after a sensor measure command has
 * been executed.
 *
 * @note Multiples of this request may be needed to get all values from a sensor
 * this can be handled by incrementing the position variable from 0 through to
 * 9 with each subsequent request.
 *
 * @param address Sensor SDI-12 address.
 * @param position Position in data command interactions.
 * @return Status code (ESP32_SDI12::Status).
 */
ESP32_SDI12::Status ESP32_SDI12::requestData(uint8_t address,
                                             uint8_t position) {
    Status res = querySensor(address, SDI12_Data, position, 0);
    #ifdef SDI12_SERIAL_DEBUG
        if(res != SDI12_OK){
            Serial.printf("Error requesting sensor data (Error = %d)\n", res);
        } else {
            Serial.printf("Successfully obtained measurement "
                          "data on address: %d\n", address);
        }
    #endif // SDI12_SERIAL_DEBUG

    return res;
}

/**
 * Ask SDI-12 sensor to take a measurement and return all values.
 *
 * @breif Combines the requestMeasure() and requestData() commands to ask a
 * sensor to take any measurements and wait for a response then return all
 * these data as floats to the user. This will handle multiple data commands
 * and only return when all values have been received.
 *
 * @code
 * static float values[10];
 * static uint8_t n_received;
 * ESP32_SDI12::Status res = sdi12.measure(address, values,
 *                                          sizeof(values), &n_received);
 *
 * @param address Sensor SDI-12 address.
 * @param values User supplied buffer to hold returned measurement values.
 * @param max_values Number of values in user supplied buffer.
 * @param num_returned_values The number of values read back from the sensor.
 * @return Status code (ESP32_SDI12::Status).
 */
ESP32_SDI12::Status ESP32_SDI12::measure(uint8_t address,
                                         float* values,
                                         size_t max_values,
                                         uint8_t* num_returned_values) {

    Measure measure = {};

    // Request a new measurement. This call parses the response to the measure
    // command, delays as necessary and reads the attention sequence. Upon
    // return the data commands can be issued immediately.
    Status res = requestMeasure(address, &measure);
    if (res != SDI12_OK) {
        return res;
    }

    // Tell the caller they did not provide enough space for the measurements.
    if (measure.n_values >= max_values) {
        return SDI12_BUF_OVERFLOW;
    }

    uint8_t parsed_values = 0; // Number of values successfully parsed
    // Position in the data command request (multiple calls may be needed to
    // get all values from a sensor).
    uint8_t position = 0;
    while (parsed_values < measure.n_values) {
        // Request data as it should be ready to be read now
        res = requestData(address, position);
        if (res != SDI12_OK) {
            return res;
        }

        char* msg_ptr;
        // Extracts the device address and stores a ptr to the rest of the
        // message buffer for use below (to extract values only)
        strtof(msg_buf, &msg_ptr);
        char* next_msg_ptr;
        float value;
        // Extract the values from the message buffer and put into user
        // supplied buffer
        for (size_t i = 0; i < max_values; i++) {
            value = strtof(msg_ptr, &next_msg_ptr);
            if(msg_ptr == next_msg_ptr){
                break;
            }
            #ifdef SDI12_SERIAL_DEBUG
                Serial.printf("Value: %f\n", value);
            #endif // SDI12_SERIAL_DEBUG
            values[parsed_values++] = value;
            msg_ptr = next_msg_ptr;
        }

        // Increment the position in the data command to get more measurements
        // until all values hav been received
        position++;
    }

    // Assign the number for returned values to the user provided variable
    // Skip of num_returned_values is a nullptr
    if (num_returned_values) {
        *num_returned_values = measure.n_values;
    }

    return res;
}

/* DEBUG methods below */
#ifdef SDI12_SERIAL_DEBUG
/**
 * Helper function to display sensor information as Serial output.
 *
 * @param sensor Sensor struct to display.
 */
void ESP32_SDI12::sensor_debug(Sensor* sensor){

    Serial.printf("=== SDI12 sensor ===\n");
    Serial.printf("\tAddress: %c\n", (char)sensor->address);
    Serial.printf("\tSDI12 Version: v%c.%c\n",(char)sensor->sdi_version_major,
              (char)sensor->sdi_version_minor);

    Serial.printf("\tVendor: ");
    for(unsigned char i : sensor->vendor){
        char c = (char)i;
        if(c == '\0') break;
        Serial.printf("%c", c);
    }

    Serial.printf("\n\tModel: ");
    for(unsigned char i : sensor->model){
        char c = (char)i;
        if(c == '\0') break;
        Serial.printf("%c", c);
    }

    Serial.printf("\n\tSensor Version: ");
    for(unsigned char i : sensor->sensor_version){
        char c = (char)i;
        if(c == '\0') break;
        Serial.printf("%c", c);
    }

    Serial.printf("\n\tAux Info: ");
    for(unsigned char i : sensor->aux_information){
        char c = (char)i;
        if(c == '\0') break;
        Serial.printf("%c", c);
    }

    Serial.printf("\nRaw sensor response: %s\n", msg_buf);
}
#endif // SDI12_SERIAL_DEBUG

And what does chatgpt have to say about your error? :joy:
Seriously, you have to tell that you used AI when posting. It gives a an idea (somewhat negative, I must say) on the quality of the code.

The (very) first hurdle here is your python error (compilation of C++ didn’t even start).
Use

import esphome.config_validation as cv