Digital ORP sensor (i2C 16-bit) + espHome

Hello everyone. I am trying to connect a Digital ORP/pH sensor to an ESPHome. The sensor is like this. The sensor is digital, 16-bit, connected via I2C.

The seller provided me with separate executable files that work fine in the Arduino IDE (output data to the com port).
The main file of the executable program is as follows.

#include <Arduino.h>
#include <stdint.h>
#include <Wire.h>
#include "ADC.h"

//! I2C address of the pH/ORP module. 
//! Configured by tying the CA0, CA1 pins to H( High ) or L( Low ). Or to be left open for Float.

// I2C Slave Address               //  CA1       CA0    
// #define ADC_I2C_ADDRESS 0x14    //  Low       High       
// #define ADC_I2C_ADDRESS 0x15    //  Low       Float  
// #define ADC_I2C_ADDRESS 0x17    //  Float     High  
   #define ADC_I2C_ADDRESS 0x24    //  Float     Float(default)
// #define ADC_I2C_ADDRESS 0x26    //  High      High
// #define ADC_I2C_ADDRESS 0x27    //  High      Float

// Global variables
static int16_t  speed_mode = SLOW;                   //!< The ADC Speed Mode settings. Change it to "FAST" for higher data rate.
static float    adc_vref = 2.5;                      //!< The ADC reference voltage
static uint8_t  rejection_mode = ADC_R50;            //!< The ADC rejection mode
static uint8_t  i2c_address = ADC_I2C_ADDRESS;       //!< I2C address in 7 bit format for part
static uint16_t eoc_timeout = 300;                   //!< Timeout in ms

static float adc_offset = 0;                         //!< Add an offset value to the ADC output.

void setup()
{
  Wire.begin();                                      // wake up I2C bus
  Serial.begin(9600);                                // Initialize the serial port to the PC
}
  
void loop()
{
  uint8_t acknowledge = 0;

  acknowledge |= read_adc();
  
  if (acknowledge)
  Serial.println(F("***** I2C ERROR *****"));
  
  delay(1000);
}

//! @return 0 if successful, 1 is failure
int8_t read_adc()
{
  uint8_t adc_command;           
  int32_t adc_code = 0;           
  float   adc_voltage = 0;        
  uint8_t ack = 0;
    
  adc_command = rejection_mode| speed_mode;
          
  ack |= adc_read( i2c_address, adc_command, &adc_code, eoc_timeout );
  
  adc_voltage = adc_code_to_voltage( adc_code, adc_vref ) * 1000 + adc_offset;  //!< Convert the ADC code to mV voltage

  //!< Convert the mV signal to pH value. 
  //!< ......

  if ( !ack )
  {
    Serial.print( F("*************************\n" ) ); 
    Serial.print( "BNC Input Voltage: " );      
    Serial.print( adc_voltage, 1 );
    Serial.print( F( "mV\n" ) );
  }
  else
  {
    Serial.print( F( "Error in read" ) );
    return 1;
  }
      
  return( 0 );
}

The other two are:
ADC.h

#ifndef ADC_H
#define ADC_H


//! This union splits one int32_t (32-bit signed integer) or uint32_t (32-bit unsigned integer)
//! four uint8_t's (8-bit unsigned integers) and vice versa.
union LT_UNION_INT32_4BYTES
{
  int32_t  LT_INT32;       //!< 32-bit signed integer to be converted to four bytes
  uint32_t LT_UINT32;     //!< 32-bit unsigned integer to be converted to four bytes
  uint8_t  LT_BYTE[4];     //!< 4 bytes (unsigned 8-bit integers) to be converted to a 32-bit signed or unsigned integer
};


// Select rejection frequency - 50 and 60, 50, or 60Hz
#define ADC_R50         0b00000010
#define ADC_R60         0b00000100
#define ADC_R50_R60     0b00000000

// Speed settings is bit 7 in the 2nd byte
#define SLOW 0b00000000 // slow output rate with autozero
#define FAST 0b00000001 // fast output rate with no autozero


/*Commands
Construct a channel / resolution control word by bitwise ORing one choice from the channel configuration
and one choice from the Oversample ratio configuration. You can also enable 2Xmode, which will increase
sample rate by a factor of 2 but introduce an offset of up to 2mV. */

//! Reads from ADC.
//! @return  1 if no acknowledge, 0 if acknowledge
uint8_t adc_read(uint8_t i2c_address,   //!< I2C address (7-bit format) for part
                 uint8_t adc_command,   //!< High byte command written to ADC
                 int32_t *adc_code,     //!< 4 byte conversion code read from ADC
                 uint16_t timeout       //!< Timeout in ms
                    );

//! Calculates the voltage corresponding to an adc code, given the reference (in volts)
//! @return Returns voltage calculated from the ADC code.
float adc_code_to_voltage(int32_t adc_code,     //!< Code read from adc
                          float vref            //!< VRef (in volts)
                             );

#endif  // ADC_H

ADC.cpp

#include <stdint.h>
#include <Arduino.h>
#include <Wire.h>
#include "ADC.h"


// Write two command bytes, then receive a block of data
int8_t i2c_one_byte_command_read_block(uint8_t address, uint8_t command, uint8_t length, uint8_t *values)
{
  int8_t ret = 0;
  uint8_t i = (length-1);
  uint8_t readBack = 0;

  Wire.beginTransmission(address);
  Wire.write(byte(command));

  if (Wire.endTransmission(false)) // endTransmission(false) is a repeated start
  {
    // endTransmission returns zero on success
    Wire.endTransmission();
    return(1);
  }
  readBack = Wire.requestFrom((uint8_t)address, (uint8_t)length, (uint8_t)true);

  if (readBack == length)
  {
    while (Wire.available())
    {
      values[i] = Wire.read();
      if (i == 0)
        break;
      i--;
    }
    return (0);
  }
  else
  {
    return (1);
  }
}


//! Reads from the ADC that accepts a 8 bit configuration and returns a 24 bit result.
//! Returns the state of the acknowledge bit after the I2C address write. 0=acknowledge, 1=no acknowledge.
int8_t adc_i2c_8bit_command_24bit_data(uint8_t i2c_address,uint8_t adc_command,int32_t *adc_code,uint16_t eoc_timeout)
{
  int8_t ack;
  uint16_t  timer_count = 0;        // Timer count to wait for ACK
  LT_UNION_INT32_4BYTES data;       // ADC data
  
  while(1)
  {
    ack = i2c_one_byte_command_read_block(i2c_address, adc_command, 3, data.LT_BYTE);
    if(!ack) break; // !ack indicates success
    if (timer_count++>eoc_timeout)     // If timeout, return 1 (failure)
      return(1);
    else
      delay(1);
  }

  data.LT_BYTE[3] = data.LT_BYTE[2]; // Shift bytes up by one. We read out 24 bits,
  data.LT_BYTE[2] = data.LT_BYTE[1]; // which are loaded into bytes 2,1,0. Need to left-
  data.LT_BYTE[1] = data.LT_BYTE[0]; // justify.
  data.LT_BYTE[0] = 0x00;
  data.LT_UINT32 >>= 2;  // Shifts data 2 bits to the right; operating on unsigned member shifts in zeros.
  data.LT_BYTE[3] = data.LT_BYTE[3] & 0x3F; // Clear upper 2 bits JUST IN CASE. Now the data format matches the SPI parts.
  *adc_code = data.LT_INT32;
  return(ack); // Success
}

// Calculates the voltage corresponding to an adc code, given the reference voltage (in volts)
// This function handles all differential input parts, including the "single-ended" mode on multichannel
// differential parts. Data from I2C parts must be right-shifted by two bit positions such that the MSB
// is in bit 28 (the same as the SPI parts.)
float adc_diff_code_to_voltage(int32_t adc_code, float vref)
{
  float voltage;

  adc_code -= 0x20000000;             //! 1) Converts offset binary to binary
  voltage=(float) adc_code;
  voltage = voltage / 536870912.0;    //! 2) This calculates the input as a fraction of the reference voltage (dimensionless)
  voltage = voltage * vref;           //! 3) Multiply fraction by Vref to get the actual voltage at the input (in volts)
  return(voltage);
}

// Reads from the ADC.
uint8_t adc_read(uint8_t i2c_address, uint8_t adc_command, int32_t *adc_code, uint16_t timeout)
{
  return (adc_i2c_8bit_command_24bit_data(i2c_address, adc_command, adc_code, timeout));
}

// Calculates the voltage corresponding to an ADC code, given the reference (in volts)
float adc_code_to_voltage(int32_t adc_code, float vref)
{
  return (adc_diff_code_to_voltage(adc_code, vref));
}

But I can’t figure out how to integrate this code into ESP Home. I tried custom sensor component (Custom Sensor Component — ESPHome ), I get to step 3, and I don’t know what to do next. I’m still new to Home Assistant.

I would be grateful if someone could help me with this and guide me in the right direction.

Hello Alex, Did you ever get this thing to work with ESPHome? I am trying to do this exact same thing, so I am interested in the resullts and your experience. Bst gds, Pierre

Hello guys, I am trying to use this Ali express sensor as well and I could use some help. Did anyone succeed with the integration into ESPHome? Thanks for your help.

I am also curious if you had any luck getting these to work? I think I have the same sensors, and thus far, I have not had luck getting them to work via ESPhome as of yet.

Thank you,

Got the same PH/ORP i2c sensor… can anyone provide some guidance?

In case anyone else stumbles across this Aliexpress sensor, DO NOT BUY. Wasted HOURS on getting this working, it does not even show up in an i2c bus scan. Not sure what this is, but it does not work.

Hi, yes, I recently bought one of these and got it working without too much trouble.

The seller provided the same code as above, but with the important difference that the device no longer seems to require (nor accept) a write of a “command” byte (rejection mode and speed mode); it just responds to a read. This is also why the device doesn’t show up in a bus scan, because addresses are probed by issuing a write command, which the device ignores (resulting in a nack).

I translated the Arduino code to ESP-IDF to get things running on the bench, then the key bits to ESPHome as a template sensor:

i2c:
    sda: GPIO3
    scl: GPIO4

i2c_device:
  - id: i2c_adc
    address: 0x24

sensor:
  - platform: template
    id: orp
    name: "ORP"
    unit_of_measurement: "mV"
    device_class: "voltage"
    state_class: measurement
    lambda: |-
      if (auto result = id(i2c_adc).read_bytes_raw<3>()) {
          const auto& bytes = result.value();
          int32_t adc_value = (bytes[0] << 16) | (bytes[1] << 8) | bytes[2];
          static constexpr int32_t half_range = 1L << 23;
          adc_value -= half_range;
          float voltage = (float)adc_value * 2500 / half_range;
          return voltage;
        }
      return NAN;

Briefly, the ADC gives a 24-bit unsigned value i.e. a value in the range [0, 224). Half way (i.e. 223) corresponds to a zero voltage, maximum (224-1) corresponds to the device’s internal reference voltage (2.5V), and zero is -2.5V.

I hope that helps anybody else having trouble with this device.

I had these hanging around for more than a year now. Finally some code that actualy works. Thank you Ian. Do you have the conversion for ph also?

You’re welcome!

Yes, for pH it’s essentially the same but with a simplified application of the Nernst equation to convert the voltage reading to pH:

sensor:
  - platform: template
    id: ph
    name: "pH"
    device_class: "pH"
    state_class: measurement
    accuracy_decimals: 2
    lambda: |-
      if (auto result = id(i2c_adc).read_bytes_raw<3>()) {
          const auto& bytes = result.value();
          int32_t adc_value = (bytes[0] << 16) | (bytes[1] << 8) | bytes[2];
          static constexpr int32_t half_range = 1L << 23;
          adc_value -= half_range;
          auto voltage = (float)adc_value * 2500 / half_range;

          static constexpr float mv_per_ph = 0.5916;
          float ph = 7 - (voltage / mv_per_ph);
          return ph;
        }
      return NAN;

You can make the calculation a bit more complicated e.g. by taking temperature into account, but in practice it shouldn’t make much difference.

Also, if you calibrate your sensor using standard solutions you can add:

    filters:
      - calibrate_linear:
          method: exact # or least_squares
          datapoints:
            # measured -> actual
            - xxx -> 4.01
            - yyy -> 6.86
            - zzz -> 9.18

Last night, together with my friend Deepseek i figured out some code for the pH and to make things somehow easy to calibrate. i added webserver for easy GUI.


For calibration you put the sensor in the calibration fluid and when the value is stable you read the raw value and fil in this value in the number box. et voila.

of course the code.

web_server:
  port: 80
  version: 3
  log: True

i2c:
    sda: GPIO4
    scl: GPIO16
    scan: False

i2c_device:
  #! I2C address of the pH/ORP module. 
  #! Configured by tying the CA0, CA1 pins to H( High ) or L( Low ). Or to be left open for Float.

   # I2C Slave Address               //  CA1       CA0    
   #ADDRESS 0x14    //  Low       High       
   #ADDRESS 0x15    //  Low       Float  
   #AADDRESS 0x17    //  Float     High  
   #ADDRESS 0x24    //  Float     Float(default)
   #ADDRESS 0x26    //  High      High
   #ADDRESS 0x27    //  High      Float
  - id: i2c_orp
    address: 0x24  
  - id: i2c_ph
    address: 0x17   

number:
  - platform: template
    entity_category: "diagnostic"  
    name: "ORP Cal"
    min_value: -2000
    max_value: 2000
    step: 0.1
    optimistic: true
    initial_value: 478
    restore_value: true
    id: "orp_cal"
    mode: BOX
  - platform: template
    entity_category: "diagnostic"  
    name: "pH 4.65 Cal"
    min_value: -2000
    max_value: 2000
    step: 0.1
    optimistic: true
    initial_value: 512
    restore_value: true
    id: "ph_465"
    mode: BOX    
  - platform: template
    entity_category: "diagnostic"  
    name: "pH 7 Cal"
    min_value: -2000
    max_value: 2000
    step: 0.1
    optimistic: true
    initial_value: 0
    restore_value: true
    id: "ph_7"
    mode: BOX    



sensor:
  - platform: template
    id: orp_raw
    name: "ORP-raw"
    unit_of_measurement: "mV"
    device_class: "voltage"
    state_class: measurement
    update_interval: 2s
    lambda: |-
      if (auto result = id(i2c_orp).read_bytes_raw<3>()) {
          const auto& bytes = result.value();
          int32_t adc_value = (bytes[0] << 16) | (bytes[1] << 8) | bytes[2];
          static constexpr int32_t half_range = 1L << 23;
          adc_value -= half_range;
          float voltage = (float)adc_value * 2500 / half_range;
          return voltage;
        }
      return NAN;  

  - platform: template
    id: ph_raw
    name: "ph-raw"
    unit_of_measurement: "mV"
    device_class: "voltage"
    state_class: measurement
    update_interval: 2s
    lambda: |-
      if (auto result = id(i2c_ph).read_bytes_raw<3>()) {
          const auto& bytes = result.value();
          int32_t adc_value = (bytes[0] << 16) | (bytes[1] << 8) | bytes[2];
          static constexpr int32_t half_range = 1L << 23;
          adc_value -= half_range;
          float PH = (float)adc_value * 2500 / half_range;
          return PH;
        }
      return NAN;        

  - platform: template
    id: orp
    name: "ORP"
    unit_of_measurement: "mV"
    device_class: "voltage"
    state_class: measurement
    update_interval: 2s
    lambda: |
        auto orp_offset = 478-id(orp_cal).state;
          return id(orp_raw).state + orp_offset;
    icon: "mdi:test-tube"            


  - platform: template
    name: pH
    force_update: true
    lambda: | 
        // Get raw mV value
        auto raw_mv = id(ph_raw).state;
        // Get calibration values
        auto cal_ph4_65_mv = id(ph_465).state;
        auto cal_ph7_mv = id(ph_7).state;
        // Calculate slope and intercept for linear equation: pH = slope * mV + intercept
        auto slope = (7.0 - 4.65) / (cal_ph7_mv - cal_ph4_65_mv);
        auto intercept = 7.0 - (slope * cal_ph7_mv);
        // Calculate pH
        auto ph_value = (slope * raw_mv) + intercept;
        return ph_value;
    icon: "mdi:ph"
    unit_of_measurement: pH
    id: "ph"
    update_interval: 2s
    accuracy_decimals: 2 

Hello! Does anyone know if these are campatible with this code?

https://www.digikey.se/sv/products/detail/seeed-technology-co-ltd/110020370/18092926

https://www.digikey.se/sv/products/detail/dfrobot/SEN0161/6579368

And can I use them with a ESP32 wroom
I allready have one that controls the pool pump, reads temp och filter preasure.

These sensor kits have analog outputs, the topic and the code is about the I2C digital sensorboards.
https://nl.aliexpress.com/item/1005003273771206.html
and
https://nl.aliexpress.com/item/1005002879595477.html
The I2C bus sensors are available in an isolated version, I2C is easy and reliable to isolate, analog signals are not. But you can adapt the code to use the analog input values in stead of the values obtain from the I2C bus