Code for DFRobot EC Sensor with integrated Temperature probe

Couldn’t find this anywhere else…

This is a working ESPHome config for the DFRobot Gravity: EC Sensor.

The EC side is accurate according to my testing :+1:

The Temp sensor has a fixed offset of -2.87 but can be manually calibrated. I still don’t think its quite right, as I get more reliable readings from other probes, but it’s ‘good enough’ for a reef tank with stable temp. If anyone can improve my temp sensor then please feel free to share!

Hope this helps the next person as I struggled to get this to work!

edit: got given the wrong source code which didn’t help! - Temp sensor much better now!

edit, edit: added one point calibration from a 53,000 µS/cm reference solution. The 1413 µS/cm solution supplied with this probe just increases the error range for this application. This will automagically adjust the cal_slope to calibrate the probe, whilst leaving cal_offset at 0.

edit, edit, edit: Realised that I’d been calibrating against the value reported by Hanna which hadn’t been calibrated in a long time… added a sensor so I can get a reminder after sixty days to run the calibration. Everything now updates after 1 sec from a button press, but data isn’t reported for 5 sec to reduce network activity. I still think my other probes return a more consistent temperature reading so I’m using node-red to redo the salinity conversion, but the EC side seems solid!

# I2C configuration for ADS1115
i2c:
  sda: GPIO8   # Change to your preferred SDA pin
  scl: GPIO9   # Change to your preferred SCL pin
  scan: true
  frequency: 400kHz

# ADS1115 configuration
ads1115:
  - address: 0x48  # Default I2C address
    id: ads1115_hub

# Global variables for calibration
globals:
  - id: calibration_mode
    type: bool
    initial_value: 'false'
  - id: cal_voltage_low
    type: float
    initial_value: '0.0'
  - id: cal_voltage_high
    type: float
    initial_value: '0.0'
  - id: last_calibration_time      
    type: uint32_t                 
    initial_value: '0'             

# Persistent storage for calibration parameters - MUST be defined before sensors
number:
  - platform: template
    name: "EC Calibration Slope"
    id: cal_slope
    entity_category: config
    min_value: 0.1
    max_value: 100.0
    initial_value: 15.68
    step: 0.001
    optimistic: true
    restore_value: true

  - platform: template
    name: "EC Calibration Offset"
    id: cal_offset
    entity_category: config
    min_value: -1000.0
    max_value: 1000.0
    initial_value: 0
    step: 0.1
    optimistic: true
    restore_value: true

  - platform: template
    name: "Temperature Calibration Offset"
    id: temp_cal_offset
    entity_category: config
    min_value: -10.0
    max_value: 10.0
    initial_value: -1.97  # Based on your measurement
    step: 0.01
    optimistic: true
    restore_value: true
    unit_of_measurement: "°C"

  - platform: template
    name: "Last Calibration Timestamp"
    id: last_calibration_timestamp
    entity_category: config
    min_value: 0
    max_value: 2147483647  # Max uint32_t value
    initial_value: 0
    step: 1
    optimistic: true
    restore_value: true  # This is the key - persists across reboots
    internal: true  # Hide from HA UI

sensor:
  # EC probe's integrated PT1000 temperature sensor via ADS1115
  - platform: ads1115
    ads1115_id: ads1115_hub
    multiplexer: 'A1_GND'  # PT1000 temperature sensor on A1
    gain: 4.096  # ±4.096V range (good for PT1000 circuit)
    name: "Temperature Voltage"
    id: temp_voltage
    update_interval: 5s
    accuracy_decimals: 4
    internal: false  # Hide raw voltage reading
    unit_of_measurement: "mV"
    filters:
      - multiply: 1000  # Convert to mV for consistency

  - platform: template
    name: "Water Temperature"
    id: water_temp
    unit_of_measurement: "°C"
    device_class: "temperature"
    state_class: "measurement"
    accuracy_decimals: 2
    update_interval: 5s
    lambda: |-
      float voltage_mV = id(temp_voltage).state;
      
      // Convert mV to V for calculation (DFRobot formula expects volts)
      float voltage = voltage_mV / 1000.0;
      
      // DFRobot PT1000 conversion constants from source code
      const float GDIFF = 30.0/1.8;  // ~16.67
      const float VR0 = 0.223;
      const float G0 = 2.0;
      const float I = 1.24 / 10000.0;  // 0.000124
      
      // Calculate PT1000 resistance using DFRobot formula
      // Rpt1000 = (voltage/GDIFF + VR0) / I / G0
      float Rpt1000 = (voltage/GDIFF + VR0) / I / G0;
      
      // Convert resistance to temperature
      // temp = (Rpt1000 - 1000) / 3.85
      float temperature_raw = (Rpt1000 - 1000.0) / 3.85;
      
      // Apply calibration offset
      float temperature = temperature_raw + id(temp_cal_offset).state;
      
      // Bounds checking and logging
      if (temperature < 15.0 || temperature > 35.0) {
        ESP_LOGW("temp_sensor", "Temperature out of expected range: %.2f°C (raw: %.2f°C, %.2f mV, %.2f Ω)", 
                 temperature, temperature_raw, voltage_mV, Rpt1000);
      } else {
        ESP_LOGD("temp_sensor", "Temperature: %.2f°C (raw: %.2f°C, %.2f mV, %.2f Ω)", 
                 temperature, temperature_raw, voltage_mV, Rpt1000);
      }
      
      return temperature;

  # Voltage reading from EC sensor via ADS1115
  - platform: ads1115
    ads1115_id: ads1115_hub
    multiplexer: 'A0_GND'  # Single-ended input on A0
    gain: 6.144  # ±6.144V range (adjust based on your sensor's output range)
    name: "EC Voltage"
    unit_of_measurement: "mV"
    id: ec_voltage
    update_interval: 5s
    accuracy_decimals: 4
    filters:
      - multiply: 1000  # Convert to mV
      - exponential_moving_average:
          alpha: 0.0033
          send_every: 10 
    internal: false  # Hide from HA if you only want the final EC value
    
  # EC Value calculated sensor
  - platform: template
    name: "EC Value"
    id: ec_value
    unit_of_measurement: "µS/cm"
    device_class: "conductivity"
    state_class: "measurement"
    accuracy_decimals: 2
    update_interval: 5s
    filters:
      - exponential_moving_average:
          alpha: 0.0033
          send_every: 10 
    lambda: |-
      float voltage = id(ec_voltage).state;
      float temperature = id(water_temp).state;
      
      // Default temperature if sensor not available
      if (isnan(temperature)) {
        temperature = 24.5;
      }
      
      // EC calculation with temperature compensation using DFRobot algorithm
      float ec_value = 0;
      
      if (voltage > 0) {
        // DFRobot EC calculation - this needs calibration with known standards
        float k_value = id(cal_slope).state;  // Calibration factor (default 1.0)
        
        // Basic voltage to EC conversion
        float raw_ec = (voltage / 1000.0) * k_value * 1000.0;  // Convert mV to µS/cm
        
        // Temperature compensation (2% per degree C from 25C)
        float temp_coeff = 1.0 + 0.02 * (temperature - 25.0);
        ec_value = raw_ec / temp_coeff;
        
        // Apply calibrated offset if available
        ec_value += id(cal_offset).state;
      }
      
      return ec_value;

  # Salinity calculation from EC value - FIXED: Calculate directly from voltage
  - platform: template
    name: "Salinity (Instantaneous)"
    id: salinity_instant
    unit_of_measurement: "ppt"
    state_class: "measurement"
    accuracy_decimals: 2
    update_interval: 10s
    lambda: |-
      // Calculate EC value directly in this lambda instead of referencing ec_value sensor
      float voltage = id(ec_voltage).state;
      float temperature = id(water_temp).state;
      
      // Default temperature if sensor not available
      if (isnan(temperature)) {
        temperature = 25.0;
      }
      
      // EC calculation with temperature compensation
      float ec_value = 0;
      
      if (voltage > 0) {
        float k_value = id(cal_slope).state;
        float raw_ec = (voltage / 1000.0) * k_value * 1000.0;
        float temp_coeff = 1.0 + 0.02 * (temperature - 25.0);
        ec_value = raw_ec / temp_coeff;
        ec_value += id(cal_offset).state;
      }
      
      if (ec_value <= 0 || isnan(ec_value)) {
        return 0.0;
      }
      
      // Convert EC (µS/cm) to Salinity (ppt)
      // For seawater: Salinity (ppt) ≈ EC (µS/cm) / 1450-1550 depending on composition
      // For typical marine aquarium water: ~53,000 µS/cm = ~35 ppt
      float salinity_ppt = ec_value / 1514.3;  // Adjusted for 35ppt target
      
      return salinity_ppt;

  # Smoothed salinity reading (1-hour moving average) - FIXED: Reference salinity_instant properly
  - platform: template
    name: "Salinity (Smoothed)"
    id: salinity_smoothed
    unit_of_measurement: "ppt"
    state_class: "measurement"
    accuracy_decimals: 2
    update_interval: 60s  # Update every minute to reduce processing load
    lambda: |-
      static std::vector<float> readings;
      static const int MAX_READINGS = 60;  // 60 readings = 1 hour at 1-minute intervals
      
      // Check if salinity_instant sensor has a valid state
      if (!id(salinity_instant).has_state()) {
        return {};  // Return empty optional if no state yet
      }
      
      float current_salinity = id(salinity_instant).state;
      
      if (!isnan(current_salinity) && current_salinity > 0) {
        readings.push_back(current_salinity);
        
        // Keep only the last MAX_READINGS
        if (readings.size() > MAX_READINGS) {
          readings.erase(readings.begin());
        }
        
        // Calculate average
        float sum = 0;
        for (float reading : readings) {
          sum += reading;
        }
        
        return sum / readings.size();
      }
      
      return current_salinity;  // Return current reading if no history yet

  - platform: template
    name: "Days Since Last Calibration"
    id: days_since_calibration
    unit_of_measurement: "days"
    device_class: "duration"
    state_class: "measurement"
    accuracy_decimals: 1
    update_interval: 1h  # Update every hour
    lambda: |-
      uint32_t last_cal_timestamp = (uint32_t)id(last_calibration_timestamp).state;
      
      // If never calibrated, return a large number to indicate this
      if (last_cal_timestamp == 0) {
        return 999.9;  // Indicates "never calibrated"
      }
      
      // Get current time from Home Assistant
      auto time = id(homeassistant_time).now();
      if (!time.is_valid()) {
        ESP_LOGW("calibration", "Time not available from Home Assistant");
        return {};  // Return empty optional if time not available
      }
      
      uint32_t current_timestamp = time.timestamp;
      
      // Calculate time difference in seconds
      uint32_t time_diff = current_timestamp - last_cal_timestamp;
      
      // Convert to days (86400 seconds per day)
      float days = time_diff / 86400.0;
      
      return days;



# Add calibration to adjust cal_slope based on a reference sample
button:
  - platform: template
    name: "Calibrate EC to 53000 µS/cm"
    id: calibrate_ec_button
    entity_category: config
    on_press:
      - lambda: |-
          float current_voltage = id(ec_voltage).state;
          float current_temp = id(water_temp).state;
          
          if (isnan(current_temp)) {
            current_temp = 24.5;
          }
          
          const float target_ec = 53000.0;
          
          if (current_voltage > 0) {
            float current_slope = id(cal_slope).state;
            float current_offset = id(cal_offset).state;
            float temp_coeff = 1.0 + 0.02 * (current_temp - 25.0);
            float new_slope = ((target_ec - current_offset) * temp_coeff) / current_voltage;
            
            if (new_slope > 0.1 && new_slope < 100.0) {
              auto call = id(cal_slope).make_call();
              call.set_value(new_slope);
              call.perform();
              
              // Record calibration timestamp using Home Assistant time
              auto time = id(homeassistant_time).now();
              if (time.is_valid()) {
                auto timestamp_call = id(last_calibration_timestamp).make_call();
                timestamp_call.set_value((float)time.timestamp);
                timestamp_call.perform();
                
                ESP_LOGI("calibration", "Calibration timestamp saved: %u", time.timestamp);
              } else {
                ESP_LOGW("calibration", "Could not get current time for calibration timestamp");
              }
              
              // Force immediate update of the days sensor
              id(days_since_calibration).update();
              
              ESP_LOGI("calibration", "EC Calibration completed!");
              ESP_LOGI("calibration", "Voltage: %.2f mV, Temperature: %.2f°C", current_voltage, current_temp);
              ESP_LOGI("calibration", "New cal_slope: %.3f", new_slope);
              ESP_LOGI("calibration", "Target EC: %.0f µS/cm", target_ec);
            } else {
              ESP_LOGE("calibration", "Calculated slope out of bounds: %.3f", new_slope);
            }
          } else {
            ESP_LOGE("calibration", "Invalid voltage reading: %.2f mV", current_voltage);
          }
            
  - platform: restart
    name: "Salinity Controller Restart"
    icon: "mdi:restart"

# Text sensor to show calibration status
text_sensor:
  - platform: template
    name: "Calibration Status"
    id: cal_status
    lambda: |-
      if (id(calibration_mode)) {
        return {"Calibration Mode Active"};
      } else {
        return {"Normal Operation"};
      }

Link to code on GitHub with improved noise and spike filtering.

All sensors seem solid now with no sensor drift. :+1:

I’ll let it run for a week and then remove the smoothed salinity sensor as they are reporting identical readings now. Quite impressed with this probe!

Final working config! :raised_hands:

Took ages to get a reliable temperature voltage from the PT1000… tried a linear voltage regulator but my ESP32S3 only puts out 4.6V on the 5pin rail and as the regulator drops 1.5V it therefore fails to output 3.3V. I’m sure a seperate 5V supply is the key, but this is good enough and I don’t want to mess about any further adding more electronics.

I could get the PT1000 voltage readings stable for about six hours with my previous config and then they would drift like clockwork which suggests some compounding exponential error. Interestingly the DFRobot source code implies this is a known issue as their equation to convert to 'C seems to deliberately approximate these readings.

I’ve transcribed their source code into C++ to use as a lambda sensor and the nonlinear polynomial calculation looks correct now (to me at least!). No external libraries are required!

Have included an external reference temperature number sensor which is supplied by HA. EC and Salinity are calculated independently from both the internal temperature sensor and the reference source for sanity checking.

I have had no appreciable drift over a 12hours period which is by far the best I’ve been able to achieve. I can calibrate the internal temperature sensor, and therefore the internal EC using ‘temp_cal_slope’ to +/-10uS/cm of the reference - I suspect this is as good as I can get it…

I’ve had to excessively smooth the PT1000 data to achieve such stability, so it’s high sample but reduced polling rate. This may cause it to be ‘slower’ to spot significant swings in salinity. For normal day-to-day operations that’s not an issue. I lose about 10L through evaporation per day which roughly translates to an increase in salinity of about 0.3ppt. As i’m stupid enough to want this to actually control my RedSea ATO, so I’ve kept the second external temperature source for now. The RedSea ATO reports far more stable temperature readings, with much higher polling and seems suspiciously immune to erroneous data. I did decompile their source code to discover the unsecured http endpoints to allow direct control. If I get time, I may look to see what approach they used - it’s either a better probe with better voltage regulation, or they also just massively smooth the data so that you can’t notice. Suspect it’s the latter!

The external temperature is currently used for calibration to 53’000uS/cm as that made sense to me. I can simply calibrate my Hanna checker and then enter the displayed temp to ensure that the readings match.

I’ve spent bloody ages on different probes etc trying to get this to work. This probe is by far the best I’ve used!

Absolute nightmare of a project and there’s really not that much info online. Documented as best as I can to try and help the next guy!

Now to put my big boy pants on and give it full control of my RODI, Auto Water Change and ATO!

time:
  - platform: homeassistant
    id: homeassistant_time

# I2C configuration for ADS1115
i2c:
  sda: GPIO8   #
  scl: GPIO9   
  scan: true
  frequency: 400kHz

# ADS1115 configuration
ads1115:
  - address: 0x48  
    id: ads1115_hub

globals:
  - id: res2
    type: float
    initial_value: '820.0'
  - id: ecref
    type: float
    initial_value: '200.0'
  - id: gdiff
    type: float
    initial_value: '16.67'  
  - id: vr0
    type: float
    initial_value: '0.223'
  - id: g0
    type: float
    initial_value: '2.0'
  - id: current_i
    type: float
    initial_value: '0.000124'      

# Persistent storage for calibration parameters - MUST be defined before sensors
number:
  - platform: template
    name: "EC Calibration Slope"
    id: cal_slope
    entity_category: config
    min_value: 0.1
    max_value: 100.0
    initial_value: 21.227
    step: 0.001
    optimistic: true
    restore_value: true

  - platform: template
    name: "EC Calibration Offset"
    id: cal_offset
    entity_category: config
    min_value: -1000.0
    max_value: 1000.0
    initial_value: 0.0
    step: 0.1
    optimistic: true
    restore_value: true
  
  - platform: template
    name: "Reference Temperature"
    id: ref_temp
    entity_category: config 
    min_value: 20
    max_value: 30
    initial_value: 23.92
    step: 0.01
    optimistic: true
    restore_value: true

  - platform: template
    name: "Last Calibration Timestamp"
    id: last_calibration_timestamp
    entity_category: config
    min_value: 0
    max_value: 2147483647  # Max uint32_t value
    initial_value: 1758376812
    step: 1
    optimistic: true
    restore_value: true  
    internal: true  
  
  - platform: template
    name: "Temperature Calibration Offset"
    id: temp_cal_offset
    entity_category: config
    min_value: -20.0
    max_value: 20.0
    initial_value: 0 
    step: 0.01
    optimistic: true
    restore_value: true

  - platform: template
    name: "Temperature Calibration Slope"
    id: temp_cal_slope
    entity_category: config 
    min_value: 0.5
    max_value: 1.5
    initial_value: 0.7474
    step: 0.0001
    optimistic: true
    restore_value: true

sensor:
    # EC probe's integrated PT1000 temperature sensor via ADS1115
  - platform: ads1115
    ads1115_id: ads1115_hub
    multiplexer: 'A1_GND'  
    gain: 4.096  
    name: "Temperature Voltage"
    id: pt1000_voltage
    update_interval: 2s  
    accuracy_decimals: 8
    internal: false
    filters:
      - filter_out: nan
      - median:
          window_size: 5  
          send_every: 1   
      - sliding_window_moving_average:
          window_size: 10  
          send_every: 3   
      - exponential_moving_average:
          alpha: 0.1       
          send_every: 1
      - delta: 0.001      # Only send if voltage changes by more than 1mV
      - heartbeat: 30s    

  # Alternative lighter filtering approach (if the above is too aggressive)
  # - platform: ads1115
  #   ads1115_id: ads1115_hub
  #   multiplexer: 'A1_GND'
  #   gain: 4.096
  #   name: "Temperature Voltage"
  #   id: pt1000_voltage
  #   update_interval: 1s
  #   accuracy_decimals: 8
  #   internal: false
  #   filters:
  #     - filter_out: nan
  #     - median:
  #         window_size: 7
  #         send_every: 1
  #     - exponential_moving_average:
  #         alpha: 0.2
  #         send_every: 1
  #     - delta: 0.0005
  #     - heartbeat: 60s
    
  # Convert PT1000 voltage to temperature using DFRobot's conversion formula
  - platform: template
    id: pt1000_temperature
    name: "Water Temperature"
    unit_of_measurement: "°C"
    accuracy_decimals: 2
    internal: false  # Hide from HA if you only want the final EC value
    update_interval: 6s
    lambda: |-
      float voltage = id(pt1000_voltage).state;
      if (isnan(voltage)) return {};

      // PT1000 temperature calculation based on DFRobot code
      float rpt1000 = (voltage / id(gdiff) + id(vr0)) / id(current_i) / id(g0);
      float temp_raw = (rpt1000 - 1000.0) / 3.85;
      float temp = (temp_raw * id(temp_cal_slope).state) + id(temp_cal_offset).state;
      return temp;

  # Voltage reading from EC sensor via ADS1115
  - platform: ads1115
    ads1115_id: ads1115_hub
    multiplexer: 'A0_GND'  # Single-ended input on A0
    gain: 6.144  # ±6.144V range (adjust based on your sensor's output range)
    name: "EC Voltage"
    id: ec_voltage
    update_interval: 5s
    accuracy_decimals: 4
    internal: false  # Hide from HA if you only want the final EC value
    
  # EC Value -  using reference temperature
  - platform: template
    name: "EC Reference"
    id: ec_reference
    unit_of_measurement: "µS/cm"
    device_class: "conductivity"
    state_class: "measurement"
    update_interval: 5s
    accuracy_decimals: 0
    lambda: |-
      float voltage = id(ec_voltage).state * 1000;
      float temperature = id(ref_temp).state;
      float kvalue = id(cal_slope).state; 
      
      if (isnan(voltage) || isnan(temperature)) return {};
      
      // EC calculation with temperature compensation
      float ec_raw = 100000.0 * voltage / id(res2) / id(ecref) * kvalue;
      float ec_compensated = ec_raw / (1.0 + 0.02 * (temperature - 25.0));
      ec_compensated += id(cal_offset).state;
      
      return ec_compensated;

  - platform: template
    id: salinity
    name: "Salinity"
    unit_of_measurement: "ppt"
    accuracy_decimals: 2
    update_interval: 5s
    lambda: |-
      float voltage = id(ec_voltage).state * 1000;
      float temperature = id(ref_temp).state;
      float kvalue = id(cal_slope).state; 
      
      if (isnan(voltage) || isnan(temperature)) return {};
      
      // EC calculation with temperature compensation
      float ec_raw = 100000.0 * voltage / id(res2) / id(ecref) * kvalue;
      float ec_compensated = ec_raw / (1.0 + 0.02 * (temperature - 25.0));
      ec_compensated += id(cal_offset).state;
      
      return ec_compensated * 0.00066;

  # EC Value using internal temperature
  - platform: template
    name: "EC Internal"
    id: ec_internal
    unit_of_measurement: "µS/cm"
    device_class: "conductivity"
    state_class: "measurement"
    update_interval: 5s
    accuracy_decimals: 0
    lambda: |-
      float voltage = id(ec_voltage).state * 1000; 
      float temperature = id(pt1000_temperature).state;
      float kvalue = id(cal_slope).state; 
      
      if (isnan(voltage) || isnan(temperature)) return {};
      
      // EC calculation with temperature compensation
      float ec_raw = 100000.0 * voltage / id(res2) / id(ecref) * kvalue;
      float ec_compensated = ec_raw / (1.0 + 0.02 * (temperature - 25.0));
      ec_compensated += id(cal_offset).state;
      
      return ec_compensated;
   # Salinity using internal temperature 
  - platform: template
    id: salinity_internal
    name: "Salinity - Internal"
    unit_of_measurement: "ppt"
    accuracy_decimals: 2
    update_interval: 5s
    lambda: |-
      float voltage = id(ec_voltage).state * 1000;
      float temperature = id(pt1000_temperature).state;
      float kvalue = id(cal_slope).state; 
      
      if (isnan(voltage) || isnan(temperature)) return {};
      
      // EC calculation with temperature compensation
      float ec_raw = 100000.0 * voltage / id(res2) / id(ecref) * kvalue;
      float ec_compensated = ec_raw / (1.0 + 0.02 * (temperature - 25.0));
      ec_compensated += id(cal_offset).state;
      
      return ec_compensated * 0.00066;

  - platform: template
    name: "Days Since Last Calibration"
    id: days_since_calibration
    unit_of_measurement: "days"
    device_class: "duration"
    state_class: "measurement"
    entity_category: diagnostic
    accuracy_decimals: 1
    update_interval: 1h  # Update every hour
    lambda: |-
      uint32_t last_cal_timestamp = (uint32_t)id(last_calibration_timestamp).state;
      
      // If never calibrated, return a large number to indicate this
      if (last_cal_timestamp == 0) {
        return 999.9;  // Indicates "never calibrated"
      }
      
      // Get current time from Home Assistant
      auto time = id(homeassistant_time).now();
      if (!time.is_valid()) {
        ESP_LOGW("calibration", "Time not available from Home Assistant");
        return {};  // Return empty optional if time not available
      }
      
      uint32_t current_timestamp = time.timestamp;
      
      // Calculate time difference in seconds
      uint32_t time_diff = current_timestamp - last_cal_timestamp;
      
      // Convert to days (86400 seconds per day)
      float days = time_diff / 86400.0;
      
      return days;

button:
  - platform: template
    name: "Calibrate EC to 53000 µS/cm"
    id: calibrate_ec_button
    entity_category: diagnostic
    on_press:
      - lambda: |-
          // Use RAW voltage reading for calibration (not smoothed)
          float current_voltage = id(ec_voltage).state * 1000;
          float current_temp = id(ref_temp).state;  // Using the reference temperature number entity
          float kvalue = id(cal_slope).state; 
          
          if (isnan(current_temp)) {
            current_temp = 24.5;
          }
          
          const float target_ec = 53000.0;
          
          if (current_voltage > 0) {
            float current_slope = kvalue;
            float current_offset = id(cal_offset).state;
            float temp_coeff = 1.0 + 0.02 * (current_temp - 25.0);
            float new_slope = ((target_ec - current_offset) * temp_coeff) / current_voltage;
            
            if (new_slope > 0.1 && new_slope < 100.0) {
              auto call = id(cal_slope).make_call();
              call.set_value(new_slope);
              call.perform();
              
              // Record calibration timestamp using Home Assistant time
              auto time = id(homeassistant_time).now();
              if (time.is_valid()) {
                auto timestamp_call = id(last_calibration_timestamp).make_call();
                timestamp_call.set_value((float)time.timestamp);
                timestamp_call.perform();
                
                ESP_LOGI("calibration", "Calibration timestamp saved: %u", time.timestamp);
              } else {
                ESP_LOGW("calibration", "Could not get current time for calibration timestamp");
              }
              
              // Force immediate update of the days sensor
              id(days_since_calibration).update();
              
              ESP_LOGI("calibration", "EC Calibration completed!");
              ESP_LOGI("calibration", "Raw Voltage: %.2f mV, Temperature: %.2f°C", current_voltage, current_temp);
              ESP_LOGI("calibration", "New cal_slope: %.3f", new_slope);
              ESP_LOGI("calibration", "Target EC: %.0f µS/cm", target_ec);
            } else {
              ESP_LOGE("calibration", "Calculated slope out of bounds: %.3f", new_slope);
              ESP_LOGE("calibration", "Raw voltage: %.2f mV, check sensor readings", current_voltage);
            }
          } else {
            ESP_LOGE("calibration", "Invalid raw voltage reading: %.2f mV", current_voltage);
          }

  - platform: restart
    name: "Salinity Controller Restart"
    icon: "mdi:restart"
    entity_category: diagnostic
1 Like

Hello,

I am French, please excuse me if the translation is not perfect.
Anyway, congratulations on this work!

I have the same need but I am using an EZO Atlas temperature probe.
I’m also using an ADS1115.
My EC probe gives a value between 0 and 3.4V or a measurement between 0 and 20 mS/cm.

For the moment, the values returned are wrong.

I need to take measurements around 250 µS and I would like to calibrate with a buffer of 600 or 1200 µS.
Do you use 1 buffer value to calibrate the probe or can we use 2 to be more precise?

Could you give me some explanations on this part, please, so that I can make it work?

What is the function of res2 800 and ecref 200?
“EC Calibration Slope” — is this the k value of the EC probe?

EC Value - using reference temperature

- platform: template
    name: "EC Reference"
    id: ec_reference
    unit_of_measurement: "µS/cm"
    device_class: "conductivity"
    state_class: "measurement"
    update_interval: 5s
    accuracy_decimals: 0
    lambda: |-
      float voltage = id(ec_voltage).state * 1000;  **OK** 
      float temperature = id(ref_temp).state;  **OK** 
      float kvalue = id(cal_slope).state; **What is the function here and what value should be used?**
      
      if (isnan(voltage) || isnan(temperature)) return {}; **OK**
      
      // EC calculation with temperature compensation 
      float ec_raw = 100000.0 * voltage / id(res2) / id(ecref) * kvalue; **Can you explain this calculation? There is a code that looks like Arduino but the value is not x100000.0**

      float ec_compensated = ec_raw / (1.0 + 0.02 * (temperature - 25.0));  **OK** 
      ec_compensated += id(cal_offset).state; **What is the function here?**

Thank you for everything, I hope I can get it working.

Have a good day.
Cédric

Hi Cédric,

My French is rusty so I hope this is understandable.

It was very tricky to get this to work and my code is messy as a result. I have included multiple ways of essentially doing the same thing for cross-checking and, having re-read it all, am not surprised you are confused!

I’ve cleaned it all up and tried to be as clear as possible to help you (and anyone else!)

If you use this code instead, it should be easier to understand and you can then modify as needed.

You need to define the i2c bus and ads1115

i2c:
  sda: GPIO41 # Make sure these are correct - also check that the GPIO is safe to use!
  scl: GPIO42 # Make sure these are correct - also check that the GPIO is safe to use!
  scan: true  # This will show the ADS1115 address in the logs. 
  frequency: 400kHz

# ADS1115 configuration
ads1115:
  - address: 0x48  # Make sure the address is correct! 
    id: ads1115_hub

You then need to define the calibration offsets. Just use these numbers for now to ensure the code works.


# Persistent storage for calibration parameters - MUST be defined before sensors
number:
  - platform: template
    name: "EC Calibration Slope"
    id: ec_slope
    entity_category: config
    min_value: 0.1
    max_value: 100.0
    initial_value: 21.227
    step: 0.001
    optimistic: true
    restore_value: true

  - platform: template
    name: "EC Calibration Offset"
    id: ec_offset
    entity_category: config
    min_value: -1000.0
    max_value: 1000.0
    initial_value: 0.0
    step: 0.1
    optimistic: true
    restore_value: true
    
  - platform: template
    name: "Temperature Calibration Offset"
    id: temp_offset
    entity_category: config
    min_value: -20.0
    max_value: 20.0
    initial_value: 0 
    step: 0.01
    optimistic: true
    restore_value: true

  - platform: template
    name: "Temperature Calibration Slope"
    id: temp_slope
    entity_category: config 
    min_value: 0.5
    max_value: 1.5
    initial_value: 0.7474
    step: 0.0001
    optimistic: true
    restore_value: true
    

Then the first step is obtaining a temperature reading you can trust. If you are using the inbuilt PT1000 (not recommended!) then this section of code is what you need.

I’ve added comments to explain the steps and removed any filtering or bits you dont need. I’ve also put the constants directly into the code so they don’t need defining. The constants are essentially measurements of the inherent characteristics of the probe as specified in the datasheet - things like resistance. They shouldn’t change but won’t be perfect which is where the calibration comes in.

As I’m editing this - make sure you look up which gain to use for the ADS1115. It’s related to voltage. My PT1000 returns 0.9V and my EC sensor 3.8V

If you are not using an inbuilt PT1000, then simply replace this entire section with whatever you need.

sensor:
    # EC probe's integrated PT1000 temperature sensor via ADS1115
  - platform: ads1115
    ads1115_id: ads1115_hub
    multiplexer: 'A1_GND'  
    gain: 1.024                                                                     # This value depends on the voltage you are suppling to the ADS1115.
    name: "Temperature Voltage"
    id: pt1000_voltage
    update_interval: 2s  
    accuracy_decimals: 8
    internal: false


  # Convert PT1000 voltage to temperature using DFRobot's conversion formula
  - platform: template
    id: pt1000_temperature
    name: "Water Temperature"
    unit_of_measurement: "°C"
    accuracy_decimals: 2
    internal: false                                                                      # Hide from HA if you only want the final EC value
    update_interval: 2s                                                                  # This changes how often the sensor is reported to HA. Keep it a nice multiple of the voltage sensor or you may get issues
    lambda: |-
      float voltage = id(pt1000_voltage).state;
      if (isnan(voltage)) return {};

      // PT1000 temperature calculation based on DFRobot code
      float rpt1000 = (voltage / 16.67 + 0.223) / 0.000124 / 2.0;                        # These are constants for this specific probe and should not change  
      float temp_raw = (rpt1000 - 1000.0) / 3.85;                                        # This is the conversion from V to °C  
      float temp = (temp_raw * id(temp_slope).state) + id(temp_offset).state;            # These are calibration offsets which you need to measure
      return temp;

The probe then returns the voltage reading as ‘ec_voltage’ and converts that to ‘ec_compensated’ after compensating for temperature.

  # Voltage reading from EC sensor via ADS1115
  - platform: ads1115
    ads1115_id: ads1115_hub
    multiplexer: 'A0_GND'  
    gain: 4.096                                                                       # This value depends on the voltage you are suppling to the ADS1115. 
    name: "EC Voltage"
    id: ec_voltage
    update_interval: 2s
    accuracy_decimals: 4
    internal: false                                                                      # Hide from HA if you only want the final EC value
    
# EC Value using internal temperature
  - platform: template
    name: "EC Internal"
    id: ec_internal
    unit_of_measurement: "µS/cm"
    device_class: "conductivity"
    state_class: "measurement"
    update_interval: 2s                                                                  # This changes how often the sensor is reported to HA. Keep it a nice multiple of the voltage sensor or you may get issues
    accuracy_decimals: 0
    lambda: |-
      float voltage = id(ec_voltage).state * 1000;                                       # my ec_voltage is reported in mV so I need to multiply by 1000 here. 
      float temperature = id(pt1000_temperature).state;                                  # Replace 'pt1000_temperature' with your temperature sensor, or use a fixed value (25) for testing      
      if (isnan(voltage) || isnan(temperature)) return {};
      
      float ec_raw = 100000.0 * voltage / 820.0 / 200.0 * id(ec_slope).state;            # These are constants for this specific probe and should not change. id(ec_slope).state is referred to as kvalue in the DFRobot code. I've simplified it to avoid confusion
      float ec_compensated = ec_raw / (1.0 + 0.02 * (temperature - 25.0));               # This is the non-linear polynomial for converting V to µS. DFRobot has a less accurate linear conversion but either will work
      ec_compensated += id(ec_offset).state;                                             # This is the calibration offset which you need to measure
      
      return ec_compensated;

I’ve tested that the above will validate on ESPHome so this is all you need to get it working.

The next step is to calibrate so you know the slope and offset values for the conductivity probe and PT1000 if used.

You need to be confident that you have an accurate temperature reading before you calibrate the conductivity probe

To answer your specific questions:

You always want to calibrate around your expected measurements. As I expect 53000µS/cm, single point calibration with slope works best for me. As you are measuring something very different, I can’t advise there. If you are measuring a wide range, then you will need to calibrate at the high and low point of that range.

With the probe in a reference solution that is close to your expected measurement, adjust the slope until you get the expected reading. You can do this easily by changing the slope slider in the exposed HA entity. Just keep nudging it in the right direction. When you know the expected values, replace them in the number: section at the start of the code.

The slope and offset are just fudging the returned data by a fixed amount. So if your temperature probe always reads 2’C higher, then an offset of -2 would correct it. My maths is worse than my French, but I presume slope is exponential and offset is linear!

Failing that, place the probe into your 600µS/cm solution and write down the EC value it reports. Do the same for the 1200µS/cm solution and then give all the data to claude.ai / ChatGPT. They can both do the calculation easily.

My code includes a one button calibration to a reference solution at 53000µS/cm but you don’t want that as it will make your readings less accurate!

If you are measuring 250µS/cm then ideally you want to calibrate at that value or use the closest known solution. Calibrating at 1200µS/cm and 600µS/cm may be less accurate than calibrating at 600µS/cm alone!

My use case is indirectly measuring salinity in an aquarium using electrical conductivity. Despite having tightly controlled temperature and salinity values, it was difficult to get reliable data.

I had to use a dedicated power supply (non-USB), a LD1117 and a lot of filtering capacitors to supply the ADS1115 and probe breakout boards with a steady voltage. If you are powering it all from 3V3 on an ESP32 then you will get noisey data.

This is my sensor without any filtering at software level. Adding the following to the voltage sensors , those under -platform: ads1115, easily removes all the spikes. I’ve found that if you still get noisey data despite that, then filtering capacitors are key.

    filters:
      - median:
          window_size: 5
          send_every: 1

How much of an issue that will be will depend on your application.

I’d not worry about that for now as it will be clear if you need to do it after you get sensible readings from the probe.

Hope this helps and sorry for the confusion!

If I need to go over anything just shout!

Streamlined version of my code in full below:


time:
  - platform: homeassistant
    id: homeassistant_time

i2c:
  sda: GPIO41
  scl: GPIO42
  scan: true
  frequency: 400kHz

# ADS1115 configuration
ads1115:
  - address: 0x48 
    id: ads1115_hub

# Persistent storage for calibration parameters - MUST be defined before sensors
number: # tidy!
  - platform: template
    name: "EC Calibration Slope"
    id: ec_slope
    entity_category: config
    min_value: 0.1
    max_value: 100.0
    initial_value: 21.227
    step: 0.001
    optimistic: true
    restore_value: true

  - platform: template
    name: "EC Calibration Offset"
    id: ec_offset
    entity_category: config
    min_value: -1000.0
    max_value: 1000.0
    initial_value: 0.0
    step: 0.1
    optimistic: true
    restore_value: true
  
  - platform: template
    name: "Reference Temperature"
    id: ref_temp
    entity_category: config 
    min_value: 20
    max_value: 30
    initial_value: 23.92
    step: 0.01
    optimistic: true
    restore_value: true

  - platform: template
    name: "Last Calibration Timestamp"
    id: last_calibration_timestamp
    entity_category: config
    min_value: 0
    max_value: 2147483647
    initial_value: 1758376812
    step: 1
    optimistic: true
    restore_value: true  
    internal: true  
  
  - platform: template
    name: "Temperature Calibration Offset"
    id: temp_offset
    entity_category: config
    min_value: -20.0
    max_value: 20.0
    initial_value: 0 
    step: 0.01
    optimistic: true
    restore_value: true

  - platform: template
    name: "Temperature Calibration Slope"
    id: temp_slope
    entity_category: config 
    min_value: 0.5
    max_value: 1.5
    initial_value: 0.7474
    step: 0.0001
    optimistic: true
    restore_value: true
    
sensor:
    # EC probe's integrated PT1000 temperature sensor via ADS1115
  - platform: ads1115
    ads1115_id: ads1115_hub
    multiplexer: 'A1_GND'  
    gain: 1.024
    name: "Temperature Voltage"
    id: pt1000_voltage
    update_interval: 2s  
    accuracy_decimals: 8
    internal: false

  # Convert PT1000 voltage to temperature using DFRobot's conversion formula
  - platform: template
    id: pt1000_temperature
    name: "Water Temperature"
    unit_of_measurement: "°C"
    accuracy_decimals: 2
    internal: false
    update_interval: 2s
    filters:
      - median:
          window_size: 5
          send_every: 1
    lambda: |-
      float voltage = id(pt1000_voltage).state;
      if (isnan(voltage)) return {};

      // PT1000 temperature calculation based on DFRobot code
      float rpt1000 = (voltage / 16.67 + 0.223) / 0.000124 / 2.0;  
      float temp_raw = (rpt1000 - 1000.0) / 3.85;
      float temp = (temp_raw * id(temp_slope).state) + id(temp_offset).state;
      return temp;

  # Voltage reading from EC sensor via ADS1115
  - platform: ads1115
    ads1115_id: ads1115_hub
    multiplexer: 'A0_GND'  
    gain: 4.096
    name: "EC Voltage"
    id: ec_voltage
    update_interval: 2s
    accuracy_decimals: 4
    internal: false
    filters:
      - median:
          window_size: 5
          send_every: 1
    
# EC Value using internal temperature
  - platform: template
    name: "EC Internal"
    id: ec_internal
    unit_of_measurement: "µS/cm"
    device_class: "conductivity"
    state_class: "measurement"
    update_interval: 2s
    lambda: |-
      float voltage = id(ec_voltage).state * 1000; 
      float temperature = id(pt1000_temperature).state;
      if (isnan(voltage) || isnan(temperature)) return {};
      
      float ec_raw = 100000.0 * voltage / 820.0 / 200.0 * id(ec_slope).state; 
      float ec_compensated = ec_raw / (1.0 + 0.02 * (temperature - 25.0));
      ec_compensated += id(ec_offset).state;
      
      return ec_compensated;

  - platform: template
    id: salinity
    name: "Salinity"
    unit_of_measurement: "ppt"
    accuracy_decimals: 2
    update_interval: 2s
    lambda: |-
      float voltage = id(ec_voltage).state * 1000;
      float temperature = id(pt1000_temperature).state;
      float kvalue = id(ec_slope).state; 
      
      if (isnan(voltage) || isnan(temperature)) return {};
      
      // EC calculation with temperature compensation
      float ec_raw = 100000.0 * voltage / 820.0 / 200.0 * id(ec_slope).state;
      float ec_compensated = ec_raw / (1.0 + 0.02 * (temperature - 25.0));
      ec_compensated += id(ec_offset).state;
      
      return ec_compensated * 0.00066;


  - platform: template
    name: "Days Since Last Calibration"
    id: days_since_calibration
    unit_of_measurement: "days"
    device_class: "duration"
    state_class: "measurement"
    entity_category: diagnostic
    accuracy_decimals: 1
    update_interval: 1h
    lambda: |-
      uint32_t last_cal_timestamp = (uint32_t)id(last_calibration_timestamp).state;
      
      // If never calibrated, return a large number to indicate this
      if (last_cal_timestamp == 0) {
        return 999.9;  // Indicates "never calibrated"
      }
      
      // Get current time from Home Assistant
      auto time = id(homeassistant_time).now();
      if (!time.is_valid()) {
        ESP_LOGW("calibration", "Time not available from Home Assistant");
        return {};  // Return empty optional if time not available
      }
      
      uint32_t current_timestamp = time.timestamp;
      
      // Calculate time difference in seconds
      uint32_t time_diff = current_timestamp - last_cal_timestamp;
      
      // Convert to days (86400 seconds per day)
      float days = time_diff / 86400.0;
      
      return days;

button:
  - platform: template
    name: "Calibrate EC to 53000 µS/cm"
    id: calibrate_ec_button
    entity_category: diagnostic
    on_press:
      - lambda: |-
          float current_voltage = id(ec_voltage).state * 1000;
          float current_temp = id(ref_temp).state;
          
          if (isnan(current_temp)) {
            current_temp = 24.5;
          }
          
          const float target_ec = 53000.0;
          
          if (current_voltage > 0) {
            float current_offset = id(ec_offset).state;
            float temp_coeff = 1.0 + 0.02 * (current_temp - 25.0);
            
            // EC formula: ec = (100000 * voltage / 820 / 200 * slope / temp_coeff) + offset
            // Solving for slope: slope = (target_ec - offset) * temp_coeff / (100000 * voltage / 820 / 200)
            float voltage_factor = 100000.0 * current_voltage / 820.0 / 200.0;
            float new_slope = ((target_ec - current_offset) * temp_coeff) / voltage_factor;
            
            if (new_slope > 0.1 && new_slope < 100.0) {
              auto call = id(ec_slope).make_call();
              call.set_value(new_slope);
              call.perform();
              
              // Record calibration timestamp
              auto time = id(homeassistant_time).now();
              if (time.is_valid()) {
                auto timestamp_call = id(last_calibration_timestamp).make_call();
                timestamp_call.set_value((float)time.timestamp);
                timestamp_call.perform();
                
                ESP_LOGI("calibration", "Calibration timestamp saved: %u", time.timestamp);
              } else {
                ESP_LOGW("calibration", "Could not get current time for calibration timestamp");
              }
              
              id(days_since_calibration).update();
              
              ESP_LOGI("calibration", "EC Calibration completed!");
              ESP_LOGI("calibration", "Raw Voltage: %.2f mV, Temperature: %.2f°C", current_voltage, current_temp);
              ESP_LOGI("calibration", "New cal_slope: %.3f (old: %.3f)", new_slope, id(ec_slope).state);
              ESP_LOGI("calibration", "Voltage factor: %.2f", voltage_factor);
              ESP_LOGI("calibration", "Target EC: %.0f µS/cm", target_ec);
            } else {
              ESP_LOGE("calibration", "Calculated slope out of bounds: %.3f", new_slope);
              ESP_LOGE("calibration", "Raw voltage: %.2f mV, check sensor readings", current_voltage);
            }
          } else {
            ESP_LOGE("calibration", "Invalid raw voltage reading: %.2f mV", current_voltage);
          }
  - platform: restart
    name: "Salinity Controller Restart"
    icon: "mdi:restart"
    entity_category: diagnostic


My controller :+1:

Hello ffirenmatana,

Thank you, it seems to work with the simplified version, thanks for the feedback!

I’m having interference with my ORP sensor, so I ordered a galvanic isolation device to see if that helps.

However, I have a question, please.

I’m displaying the temperature probe value on a TM1637.
When I disconnect the probe, my voltage correctly displays 0.00002, but my TM1637 displays the last value.
I’d like to test the voltage value and display characters like “ERR” on the TM1637.

Do you think that’s possible?

My code snippet is

# affichage temperature 1
display:
  - platform: tm1637
    id: lcd_temp
    clk_pin: GPIO43
    dio_pin: GPIO38
    inverted: false
    length: 4
    lambda: |-
      it.printf("%0.2f", id(rtd_ezo).state);

mine

Thanks again, Cédric

If I understand your question, your temperature probe voltage correctly drops to zero when disconnecting the probe, but the temperature sensor doesn’t update?

Check the lambda section of my PT1000 temperature sensor. I guess you could add something like:

display:
  - platform: tm1637
    id: lcd_temp
    clk_pin: GPIO43
    dio_pin: GPIO38
    inverted: false
    length: 4
    lambda: |-
      float v = id(rtd_ezo).state; # I assume this is the voltage??
      if (v < 0.0001f) {
        it.print("ERR");
      } else {
        it.printf("%0.2f", 

My coding skills are just enough to get by though mate!

Nice project!

Good luck with the interference! Stable ref voltage, keep the analog and digital separate and filtering caps :+1:

For complteness!

I was really battling to get these probes to calibrate and be responsive. The above code, given in response to @cedrict, works fine for the use case DFRobot intends - i.e. hydroponics / freshwater.

In a salt water environment (53,000 μS/cm) this probe outputs ~3.9V on the A1_GND which results in the PT1000 on A0_GND exhibiting odd behaviour - essentially it takes a dramatic change in temperature to cause a change in voltage. Both probes are sluggish to respond which I missed as my temperature and salinity are very stable.

I’ve used a 10K resistor in series with the EC probe to A0_GND and then a 15K resistor from from A0_GND to GND. This drops the voltage to a more reasonable 2.1V and everything magically works. I see the tiny voltage voltage fluctuations on both probes now and as expected.

I’ve also realised that the 2% alpha centred at 25’C really adds up at 53,000uS/cm! In a reef tank, 1.9% centred over your target temp minimises this drift and is a better mathematical representation. I’ve added as much smoothing as I can get away with whilst keeping the probe responsive.

Unfortunately I think this is as good as it gets. It’s accurate to 0.01ppt now, but +/-0.1ppt is more than sufficient and keeps the data nice and clean. Sadly measuring salinity via conductivity is not the best approach and this is the reason why there are no good commercial products available.

That being said, this probe and code works well enough for me to let it autonomously control my RODI ATO and I’m more than happy to call this a day!

The code below now works for a marine environment for future viewers.

This code is for fresh water.

This salt water code has A0 and A1 reversed as I put the voltage divider on the wrong channel :rofl:

time:
  - platform: homeassistant
    id: homeassistant_time

i2c:
  sda: GPIO41
  scl: GPIO42
  scan: true
  frequency: 400kHz

# ADS1115 configuration
ads1115:
  - address: 0x48 
    id: ads1115_hub

# Persistent storage for calibration parameters - MUST be defined before sensors
number: 
  - platform: template
    name: "EC Calibration Slope"
    id: ec_slope
    entity_category: config
    min_value: 0
    max_value: 30
    initial_value: 24.5772
    step: 0.0001
    optimistic: true
    restore_value: true

  - platform: template
    name: "EC Calibration Offset"
    id: ec_offset
    entity_category: config
    min_value: -30.0
    max_value: 30.0
    initial_value: 0.0
    step: 0.1
    optimistic: true
    restore_value: true

  - platform: template
    name: "Last Calibration Timestamp"
    id: last_calibration_timestamp
    entity_category: config
    min_value: 0
    max_value: 2147483647
    initial_value: 1758376812
    step: 1
    optimistic: true
    restore_value: true  
    internal: true  
  
  - platform: template
    name: "Temperature Calibration Offset"
    id: temp_offset
    entity_category: config
    min_value: -20.0
    max_value: 20.0
    initial_value: 4.6
    step: 0.01
    optimistic: true
    restore_value: true

  - platform: template
    name: "Temperature Calibration Slope"
    id: temp_slope
    entity_category: config 
    min_value: -10
    max_value: 10
    initial_value: -7.65
    step: 0.0001
    optimistic: true
    restore_value: true
    
sensor:
  - platform: ads1115
    ads1115_id: ads1115_hub
    multiplexer: 'A0_GND'  
    gain: 1.024
    name: "Temperature Voltage"
    id: pt1000_voltage
    update_interval: 2s  
    accuracy_decimals: 4
    internal: true
    filters:
      - median:
          window_size: 3 
          send_every: 1

  - platform: template
    id: pt1000_temperature
    name: "Water Temperature"
    unit_of_measurement: "°C"
    accuracy_decimals: 2
    update_interval: 5s
    filters:
      - sliding_window_moving_average:
          window_size: 5
          send_every: 1
      - lambda: |-
          return round(x * 100.0) / 100.0;
    lambda: |-
      float voltage = id(pt1000_voltage).state;
      if (isnan(voltage)) return {};
      float rpt1000 = (voltage / 16.67 + 0.223) / 0.000124 / 2.0; 
      float temp_raw = (rpt1000 - 1000.0) / 3.85;
      float temp = (temp_raw * id(temp_slope).state) + id(temp_offset).state;
      return temp;
    
  - platform: ads1115
    ads1115_id: ads1115_hub
    multiplexer: 'A1_GND'  
    gain: 4.096
    name: "EC Voltage"
    id: ec_voltage
    update_interval: 2s
    unit_of_measurement: V
    accuracy_decimals: 4
    internal: true
    filters:
    - median:
        window_size: 3
        send_every: 1

  - platform: template
    name: "Electrical Conductivity"
    id: ec
    unit_of_measurement: "µS/cm"
    device_class: "conductivity"
    state_class: "measurement"
    update_interval: 5s
    filters:
      - sliding_window_moving_average:
          window_size: 15
          send_every: 1
    lambda: |-
      float measured = id(ec_voltage).state;         // V_adc (after divider)
      float voltage = (measured / 0.6) * 1000.0;     // true probe voltage in mV
      float temperature = id(pt1000_temperature).state;
      if (isnan(voltage) || isnan(temperature)) return {};

      // Raw EC from probe voltage (before temperature compensation)
      float ec_raw = 100000.0 * voltage / 820.0 / 200.0 * id(ec_slope).state;

      // Improved temperature compensation for seawater in at my expected temp range
      const float alpha = 0.019f;        // 1.9% per °C
      const float tref  = 27.0f;         // more realistic reference for your tank
      float temp_coeff  = 1.0f + alpha * (temperature - tref);

      float ec_compensated = ec_raw / temp_coeff;
      ec_compensated += id(ec_offset).state;
      return ec_compensated;

  - platform: template
    id: salinity
    name: "Salinity"
    unit_of_measurement: "ppt"
    accuracy_decimals: 2
    update_interval: 10s
    filters:
      - sliding_window_moving_average:
          window_size: 9
          send_every: 1
    lambda: |-
      float ec25 = id(ec).state;
      if (isnan(ec25)) return {};
      return ec25 * 0.00066;

  - platform: template
    name: "Days Since Last Calibration"
    id: days_since_calibration
    unit_of_measurement: "days"
    device_class: "duration"
    state_class: "measurement"
    entity_category: diagnostic
    accuracy_decimals: 1
    update_interval: 1h
    lambda: |-
      uint32_t last_cal_timestamp = (uint32_t)id(last_calibration_timestamp).state;
      
      // If never calibrated, return a large number to indicate this
      if (last_cal_timestamp == 0) {
        return 999.9;  // Indicates "never calibrated"
      }
      
      // Get current time from Home Assistant
      auto time = id(homeassistant_time).now();
      if (!time.is_valid()) {
        ESP_LOGW("calibration", "Time not available from Home Assistant");
        return {};  // Return empty optional if time not available
      }
      
      uint32_t current_timestamp = time.timestamp;
      
      // Calculate time difference in seconds
      uint32_t time_diff = current_timestamp - last_cal_timestamp;
      
      // Convert to days (86400 seconds per day)
      float days = time_diff / 86400.0;
      
      return days;

button:
  - platform: template
    name: "Calibrate EC to 53000 µS/cm"
    id: calibrate_ec_button
    entity_category: diagnostic
    on_press:
      - lambda: |-
          // Read divided ADC voltage
          float measured = id(ec_voltage).state;             // V_adc
          // Undo divider and convert to mV (true probe voltage)
          float current_voltage = (measured / 0.6) * 1000.0; // V_probe in mV

          float current_temp = id(pt1000_temperature).state;
          
          if (isnan(current_temp)) {
            current_temp = 24.5;
          }
          
          const float target_ec = 53000.0;
          
          if (current_voltage > 0) {
            float current_offset = id(ec_offset).state;
            const float alpha = 0.019f;
            const float tref  = 27.0f;
            float temp_coeff  = 1.0f + alpha * (current_temp - tref);
            
            // EC formula: ec = (100000 * voltage / 820 / 200 * slope / temp_coeff) + offset
            // Solving for slope: slope = (target_ec - offset) * temp_coeff / (100000 * voltage / 820 / 200)
            float voltage_factor = 100000.0 * current_voltage / 820.0 / 200.0;
            float new_slope = ((target_ec - current_offset) * temp_coeff) / voltage_factor;
            
              if (new_slope > 0.1 && new_slope < 100.0) {
              auto call = id(ec_slope).make_call();
              call.set_value(new_slope);
              call.perform();
              
              // Record calibration timestamp
              auto time = id(homeassistant_time).now();
              if (time.is_valid()) {
                auto timestamp_call = id(last_calibration_timestamp).make_call();
                timestamp_call.set_value((float)time.timestamp);
                timestamp_call.perform();
                
                ESP_LOGI("calibration", "Calibration timestamp saved: %u", time.timestamp);
              } else {
                ESP_LOGW("calibration", "Could not get current time for calibration timestamp");
              }
              
              id(days_since_calibration).update();
              
              ESP_LOGI("calibration", "EC Calibration completed!");
              ESP_LOGI("calibration", "Raw Voltage: %.2f mV, Temperature: %.2f°C", current_voltage, current_temp);
              ESP_LOGI("calibration", "New cal_slope: %.3f (old: %.3f)", new_slope, id(ec_slope).state);
              ESP_LOGI("calibration", "Voltage factor: %.2f", voltage_factor);
              ESP_LOGI("calibration", "Target EC: %.0f µS/cm", target_ec);
            } else {
              ESP_LOGE("calibration", "Calculated slope out of bounds: %.3f", new_slope);
              ESP_LOGE("calibration", "Raw voltage: %.2f mV, check sensor readings", current_voltage);
            }
          } else {
            ESP_LOGE("calibration", "Invalid raw voltage reading: %.2f mV", current_voltage);
          }
          
  - platform: restart
    name: "Salinity Controller Restart"
    icon: "mdi:restart"
    entity_category: diagnostic

Hello,

I can confirm that the code works well for freshwater as well.

I ordered calibration solutions, but at 600µF the values ​​are correct.

Regarding the time since the last calibration, I downloaded the integration time, but my value remains at 999.99.

# recuperation temps HA
time:
  - platform: homeassistant
    id: homeassistant_time

I must be doing something wrong. Could you guide me a little, please?
The code for handling the error code is great; it works perfectly.

Thank you.

I’m pleased it’s working for you.

I’m not sure what you mean by downloading the integration time. Everything is self contained - no external integrations are required. You don’t need any helpers or external sensors.

I don’t want to be that guy that says check the logs, but the calibration sensor does have a fair bit of logging in the code :stuck_out_tongue_winking_eye:

The logs should tell you if its a problem with the timestamp or the calibration itself :+1:

The number section is essentially the persistent storage so your settings survive reboots and OTA updates.

Given you are getting 999.9 days, then this bit from the ‘days_since_calibration’ sensor suggests that you have last_cal_timestamp set to 0 from the numbers section and the sensor code is working.

      if (last_cal_timestamp == 0) {
        return 999.9;  // Indicates "never calibrated"
      }

As you want to calibrate at 600 µS/cm and not 53,000 µS/cm, you will need to have changed the line below in the ‘calibrate_ec_button’ sensor to reflect your new target. Otherwise, the log will likely show you that your readings have been rejected as they are out of range. I’m guessing that’s the issue…

const float target_ec = 600.0 // Changed from 53000 to 600

If you just need something that you can copy and paste, then this should work but I can’t test it for you.

button:
  - platform: template
    name: "Calibrate EC to 600 µS/cm"
    id: calibrate_ec_button
    entity_category: diagnostic
    on_press:
      - lambda: |-
          float current_voltage = id(ec_voltage).state * 1000;
          float current_temp = id(ref_temp).state;
          
          if (isnan(current_temp)) {
            current_temp = 24.5;
          }
          
          const float target_ec = 600.0;
          
          if (current_voltage > 0) {
            float current_offset = id(ec_offset).state;
            float temp_coeff = 1.0 + 0.02 * (current_temp - 25.0);
            
            // EC formula: ec = (100000 * voltage / 820 / 200 * slope / temp_coeff) + offset
            // Solving for slope: slope = (target_ec - offset) * temp_coeff / (100000 * voltage / 820 / 200)
            float voltage_factor = 100000.0 * current_voltage / 820.0 / 200.0;
            float new_slope = ((target_ec - current_offset) * temp_coeff) / voltage_factor;
            
            if (new_slope > 0.1 && new_slope < 100.0) {
              auto call = id(ec_slope).make_call();
              call.set_value(new_slope);
              call.perform();
              
              // Record calibration timestamp
              auto time = id(homeassistant_time).now();
              if (time.is_valid()) {
                auto timestamp_call = id(last_calibration_timestamp).make_call();
                timestamp_call.set_value((float)time.timestamp);
                timestamp_call.perform();
                
                ESP_LOGI("calibration", "Calibration timestamp saved: %u", time.timestamp);
              } else {
                ESP_LOGW("calibration", "Could not get current time for calibration timestamp");
              }
              
              id(days_since_calibration).update();
              
              ESP_LOGI("calibration", "EC Calibration completed!");
              ESP_LOGI("calibration", "Raw Voltage: %.2f mV, Temperature: %.2f°C", current_voltage, current_temp);
              ESP_LOGI("calibration", "New cal_slope: %.3f (old: %.3f)", new_slope, id(ec_slope).state);
              ESP_LOGI("calibration", "Voltage factor: %.2f", voltage_factor);
              ESP_LOGI("calibration", "Target EC: %.0f µS/cm", target_ec);
            } else {
              ESP_LOGE("calibration", "Calculated slope out of bounds: %.3f", new_slope);
              ESP_LOGE("calibration", "Raw voltage: %.2f mV, check sensor readings", current_voltage);
            }
          } else {
            ESP_LOGE("calibration", "Invalid raw voltage reading: %.2f mV", current_voltage);

Hello
Thank you, you were absolutely right.

I changed the value and the days are now incrementing.
Thank you so much
Cédric

if (last_cal_timestamp == 0) {
        return 999.9;  // Indicates "never calibrated"
      }

I’m pleased it’s working for you. :+1:

So after a few days of running my streamlined code, it kind a bugs me that the conversion from EC to salinity isn’t as good as it could be.

It’s best exemplified in this high-resolution graph showing the relationship between temp and salinity. Don’t get me wrong, it’s all mathematically correct and it has little real world implications, but you get an inadvertent systematical error though the equation that is worsened with very high EC. The line should be straight but the maths say it isn’t!

  • note, all filtering and smoothing was disabled for this pic, so this is my raw data.

After analysing data from my setup, I was able to refine the equation to lessen the wobble. Essentially, by graphing the data in one hour blocks, I was able to derive that the the k vlaue (slope) is likely between 0.7 and 1.1

For each one hour block you can assume the salinity is constant and simply measure average peaks and troughs of the temp and salinity and then compute the salinity error per °C, which is our K value.

Δsal = 35.08 − 34.96 = 0.12 ppt
ΔT = 26.91 − 26.74 = 0.17 °C

k = ΔT / Δsal​ 
k = 0.7059 ppt/°C

That’s an error of 0.7ppt per change in °C which is massive thanks to the 53,000 µS/cm environment. Unfortunately, repeat testing shows it is not linear so a fixed approximation is just that.

This specific analysis is obviously unique to my setup so users with high EC environments should be cautious with simply copying this modified sensor.

  - platform: template
    id: salinity
    name: "Salinity"
    unit_of_measurement: "ppt"
    accuracy_decimals: 2
    update_interval: 20s
    lambda: |-
      float ec25 = id(ec).state;
      float temp = id(pt1000_temperature).state;
      if (isnan(ec25) || isnan(temp)) return {};
      float sal_raw = ec25 * 0.00066f;
      const float T_ref = 27.0f;
      const float k = 1.0f;
      float sal_corrected = sal_raw - k * (temp - T_ref);
      return sal_corrected;

Clearly, this is better, but fundamentally is not the correct approach unless you have very stable readings.

The correct path AFAIK is to perform rolling regression which I don’t know how to do in ESPHome, but I can do it in node-red! Now I could made node-red simply update the k value in ESPHome but I hate dependancies, so I may as well make node-red just expose the new salinity vaule directly.

This is another hour block similar to above, but the orange line is the new sensor with rolling regression. It takes 10-20 minutes to settle in, but it’s so much better now!

  • again, for transparency there is no smoothing on this. With minimal filtering this will be perfectly straight!

I’ve attached a node-red flow if anyone is keen - you just need to replace the sensors so they match yours :+1:

[{"id":"47ca207f0880989e","type":"server-state-changed","z":"8f06660af350a487","name":"PT1000","server":"666aa37e1d30361f","version":6,"outputs":1,"exposeAsEntityConfig":"","entities":{"entity":["sensor.salinity_water_temperature"],"substring":[],"regex":[]},"outputInitially":false,"stateType":"str","ifState":"","ifStateType":"num","ifStateOperator":"is","outputOnlyOnStateChange":true,"for":"0","forType":"num","forUnits":"minutes","ignorePrevStateNull":false,"ignorePrevStateUnknown":false,"ignorePrevStateUnavailable":false,"ignoreCurrentStateUnknown":false,"ignoreCurrentStateUnavailable":false,"outputProperties":[{"property":"payload","propertyType":"msg","value":"number","valueType":"entityState"},{"property":"topic","propertyType":"msg","value":"temp","valueType":"str"}],"x":90,"y":5060,"wires":[["e36397c2d7541093"]]},{"id":"356f521c6816ceee","type":"server-state-changed","z":"8f06660af350a487","name":"EC","server":"666aa37e1d30361f","version":6,"outputs":1,"exposeAsEntityConfig":"","entities":{"entity":["sensor.salinity_electrical_conductivity"],"substring":[],"regex":[]},"outputInitially":false,"stateType":"str","ifState":"","ifStateType":"num","ifStateOperator":"is","outputOnlyOnStateChange":true,"for":"0","forType":"num","forUnits":"minutes","ignorePrevStateNull":false,"ignorePrevStateUnknown":false,"ignorePrevStateUnavailable":false,"ignoreCurrentStateUnknown":false,"ignoreCurrentStateUnavailable":false,"outputProperties":[{"property":"payload","propertyType":"msg","value":"number","valueType":"entityState"},{"property":"topic","propertyType":"msg","value":"ec","valueType":"str"}],"x":90,"y":5120,"wires":[["e36397c2d7541093"]]},{"id":"e36397c2d7541093","type":"join","z":"8f06660af350a487","name":"","mode":"custom","build":"object","property":"payload","propertyType":"msg","key":"topic","joiner":"\\n","joinerType":"str","useparts":false,"accumulate":true,"timeout":"","count":"2","reduceRight":false,"reduceExp":"","reduceInit":"","reduceInitType":"","reduceFixup":"","x":230,"y":5100,"wires":[["aee14d33cb6f43a7"]]},{"id":"aee14d33cb6f43a7","type":"function","z":"8f06660af350a487","name":"Salinity Regressed","func":"// Rolling regression salinity corrector with auto-tuned k\n// Input: msg.payload = { temp: <°C>, ec: <µS/cm> }\n// Output: msg.payload = {\n//   salinity_corrected: <ppt>,\n//   salinity_raw: <ppt>,\n//   ec: <µS/cm>,\n//   temp: <°C>,\n//   k: <ppt per °C> (smoothed / auto-tuned),\n//   sample_count: <int>,\n//   temp_span: <°C>\n// }\n\nconst T_REF = 27.0;          // reference temp for \"flat\" salinity\nconst EC_TO_SAL = 0.00066;   // EC25 -> salinity conversion (ppt per µS/cm)\n\n// How much history to keep for regression\nconst MAX_SAMPLES = 240;     // ~20 min if you get a pair every 5s\n\n// Minimum conditions to trust a new regression\nconst MIN_SAMPLES_FOR_REG = 30;    // don't fit a new slope on tiny history\nconst MIN_TEMP_SPAN = 0.03;        // °C; if temp hardly moves, slope is meaningless\n\n// How fast k adapts to new estimates (0–1)\n// 0.1 ≈ slow and stable, 0.3 ≈ faster adaptation\nconst K_ALPHA = 0.15;\n\n// ---- 1. Read inputs ----\nconst temp = Number(msg.payload.temp);\nconst ec   = Number(msg.payload.ec);\n\nif (isNaN(temp) || isNaN(ec)) {\n    node.warn(`Invalid input: temp=${temp}, ec=${ec}`);\n    return null; // drop bad messages\n}\n\n// ---- 2. Compute raw salinity from EC ----\nconst salRaw = ec * EC_TO_SAL;\n\n// ---- 3. Maintain rolling history in node context ----\nlet samples = context.get('samples') || [];\nsamples.push({ t: temp, s: salRaw });\n\nif (samples.length > MAX_SAMPLES) {\n    samples = samples.slice(samples.length - MAX_SAMPLES);\n}\n\ncontext.set('samples', samples);\n\n// ---- 4. Try to compute a new regression slope k_new ----\nlet k_smoothed = context.get('k_smoothed');\nif (typeof k_smoothed !== 'number') k_smoothed = 0;  // default until learned\n\nlet k_new = null;\n\nif (samples.length >= MIN_SAMPLES_FOR_REG) {\n    let sumT = 0;\n    let sumS = 0;\n\n    for (const p of samples) {\n        sumT += p.t;\n        sumS += p.s;\n    }\n\n    const n = samples.length;\n    const meanT = sumT / n;\n    const meanS = sumS / n;\n\n    let num = 0;\n    let den = 0;\n    let tMin = Infinity;\n    let tMax = -Infinity;\n\n    for (const p of samples) {\n        const dt = p.t - meanT;\n        num += dt * (p.s - meanS);\n        den += dt * dt;\n\n        if (p.t < tMin) tMin = p.t;\n        if (p.t > tMax) tMax = p.t;\n    }\n\n    const tempSpan = tMax - tMin;\n\n    // Only trust regression if temperature has moved enough\n    if (den !== 0 && tempSpan >= MIN_TEMP_SPAN) {\n        k_new = num / den;   // ppt per °C from freshest history\n\n        // Auto-tune: exponential moving average on k\n        k_smoothed = k_smoothed + K_ALPHA * (k_new - k_smoothed);\n        context.set('k_smoothed', k_smoothed);\n\n        // For debugging, attach span\n        msg.temp_span = tempSpan;\n    } else {\n        // Keep previous k_smoothed, but record span for visibility\n        msg.temp_span = tempSpan;\n    }\n} else {\n    // Not enough data yet; keep initial k_smoothed (0 at first)\n    msg.temp_span = 0;\n}\n\n// ---- 5. Apply correction to flatten salinity vs temp ----\nlet salCorrected = salRaw - k_smoothed * (temp - T_REF);\n\n// Round both for nice display\nsalCorrected = Math.round(salCorrected * 100) / 100;\nconst salRawRounded = Math.round(salRaw * 100) / 100;\n\n// ---- 6. Build payload ----\nmsg.payload = {\n    salinity_corrected: salCorrected,\n    salinity_raw: salRawRounded,\n    ec: ec,\n    temp: temp,\n    k: k_smoothed,\n    sample_count: samples.length,\n    temp_span: msg.temp_span\n};\n\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":390,"y":5100,"wires":[["9da404b7a6a175c1"]]},{"id":"9da404b7a6a175c1","type":"ha-sensor","z":"8f06660af350a487","name":"Salinity - Regressed","entityConfig":"b1e9e45a8a7d9025","version":0,"state":"payload.salinity_corrected","stateType":"msg","attributes":[],"inputOverride":"allow","outputProperties":[],"x":600,"y":5100,"wires":[["738bca4aa744a49f"]]},{"id":"666aa37e1d30361f","type":"server","name":"Home Assistant","version":6,"addon":true,"rejectUnauthorizedCerts":true,"ha_boolean":["y","yes","true","on","home","open"],"connectionDelay":true,"cacheJson":true,"heartbeat":true,"heartbeatInterval":"30","areaSelector":"id","deviceSelector":"id","entitySelector":"id","statusSeparator":": ","statusYear":"hidden","statusMonth":"short","statusDay":"numeric","statusHourCycle":"default","statusTimeFormat":"h:m","enableGlobalContextStore":true},{"id":"b1e9e45a8a7d9025","type":"ha-entity-config","server":"666aa37e1d30361f","deviceConfig":"ebf69657f07ce1bb","name":"Salinity","version":6,"entityType":"sensor","haConfig":[{"property":"name","value":"Salinity"},{"property":"icon","value":""},{"property":"entity_picture","value":""},{"property":"entity_category","value":""},{"property":"device_class","value":""},{"property":"unit_of_measurement","value":"ppt"},{"property":"state_class","value":""}],"resend":false,"debugEnabled":false},{"id":"ebf69657f07ce1bb","type":"ha-device-config","name":"Apex","hwVersion":"","manufacturer":"Node-RED","model":"","swVersion":""},{"id":"b64192437a200e34","type":"global-config","env":[],"modules":{"node-red-contrib-home-assistant-websocket":"0.80.3"}}]

This is months of research and I’ve well and truly reached the limits of my understanding here. I don’t think I can get this any better unless someone with more skills knows how to perform this function in ESPHome directly.

As I said at the very start, there is a reason that no good commercial options exist for a reef tank. I’m confident that this is better than all of them though!

So glad to conclude this project! :raised_hands:

Hello ffirenmatana

I’m back with an update and some questions :slight_smile:

First, regarding the code, the calibration is set to 600. I made the change, and I think it’s OK.
The date since the last calibration is also working perfectly, thank you.

Today I have an EC sensor with a k value of 0.986.
There are sensors with k=1, 10, or 0.1.

Without using the automatic calibration program, I put the sensor in a 600µS solution and modified the slope to read 600µ.
I set the slope to 5.2 and got 0.1734V to see 600µ

And now it’s not working. next,
I put the sensor in a 1413µS solution and read 788µS and 0.2247V,
then in a 64µS solution and read 107µS and 0.0295V.

Do you have any ideas on how to improve the situation?

Thank you, Cédric.

number:
# Parametre d'etalonnage conductivite ec
  - platform: template
    name: "pente calibration ec"
    id: ec_slope
    entity_category: config
    min_value: 0.1
    max_value: 100.0
    initial_value: 21.227
    step: 0.001
    optimistic: true
    restore_value: true

  - platform: template
    name: "Offset calibration ec"
    id: ec_offset
    entity_category: config
    min_value: -1000.0
    max_value: 1000.0
    initial_value: 0.0
    step: 0.1
    optimistic: true
    restore_value: true

  - platform: template
    name: "date dernier etalonnage"
    id: last_calibration_timestamp
    entity_category: config
    min_value: 0
    max_value: 2147483647  # Max uint32_t value
    initial_value: 0
    step: 1
    optimistic: true
    restore_value: true  # C'est là le point clé : cela persiste même après un redémarrage.
    internal: true  # Masquer de l'interface utilisateur de HA
# Declaration capteur  EC
  - platform: ads1115
    multiplexer: 'A3_GND'
    state_class: measurement
    gain: 4.096
    name: "ec voltage"
    id: ec_voltage
    update_interval: 5s
    accuracy_decimals: 4

    # Valeur EC - en utilisant la température de référence
  - platform: template
    name: "EC Reference"
    id: ec_reference
    unit_of_measurement: "µS/cm"
    device_class: "conductivity"
    state_class: "measurement"
    update_interval: 5s
    accuracy_decimals: 0
    lambda: |-
      float voltage = id(ec_voltage).state * 1000;
      float temperature = id(rtd_ezo).state;
      if (isnan(voltage) || isnan(temperature)) return {};

      float ec_raw = 100000.0 * voltage / 820.0 / 200.0 * id(ec_slope).state; 
      float ec_compensated = ec_raw / (1.0 + 0.02 * (temperature - 25.0));     
      ec_compensated += id(ec_offset).state; 
      
      return ec_compensated;

  # EC - nb jours depuis derniere Calibration
  - platform: template
    name: "nb jours depuis derniere Calibration"
    id: days_since_calibration
    unit_of_measurement: "days"
    device_class: "duration"
    state_class: "measurement"
    entity_category: diagnostic
    accuracy_decimals: 1
    update_interval: 1h  # actualiser chaque heure
    lambda: |-
      uint32_t last_cal_timestamp = (uint32_t)id(last_calibration_timestamp).state;
      
      // If never calibrated, return a large number to indicate this
      if (last_cal_timestamp == 30) {
        return 999.9;  // Indicates "never calibrated"
      }
      
      // Get current time from Home Assistant
      auto time = id(homeassistant_time).now();
      if (!time.is_valid()) {
        ESP_LOGW("calibration", "Time not available from Home Assistant");
        return {};  // Return empty optional if time not available
      }
      
      uint32_t current_timestamp = time.timestamp;
      
      // Calculate time difference in seconds
      uint32_t time_diff = current_timestamp - last_cal_timestamp;
      
      // Convert to days (86400 seconds per day)
      float days = time_diff / 86400.0;
      
      return days;
 # EC Ajouter un étalonnage pour ajuster cal_slope en fonction d'un échantillon de référence

  - platform: template
    name: "Calibrate EC to 600 µS/cm"
    id: calibrate_ec_button
    entity_category: diagnostic
    on_press:
      - lambda: |-
          float current_voltage = id(ec_voltage).state * 1000;
          float current_temp = id(rtd_ezo).state;
          
          if (isnan(current_temp)) {
            current_temp = 24.5;
          }
          
          const float target_ec = 600.0;
          
          if (current_voltage > 0) {
            float current_offset = id(ec_offset).state;
            float temp_coeff = 1.0 + 0.02 * (current_temp - 25.0);
            
            // EC formula: ec = (100000 * voltage / 820 / 200 * slope / temp_coeff) + offset
            // Solving for slope: slope = (target_ec - offset) * temp_coeff / (100000 * voltage / 820 / 200)
            float voltage_factor = 100000.0 * current_voltage / 820.0 / 200.0;
            float new_slope = ((target_ec - current_offset) * temp_coeff) / voltage_factor;
            
            if (new_slope > 0.1 && new_slope < 100.0) {
              auto call = id(ec_slope).make_call();
              call.set_value(new_slope);
              call.perform();
              
              // Record calibration timestamp
              auto time = id(homeassistant_time).now();
              if (time.is_valid()) {
                auto timestamp_call = id(last_calibration_timestamp).make_call();
                timestamp_call.set_value((float)time.timestamp);
                timestamp_call.perform();
                
                ESP_LOGI("calibration", "Calibration timestamp saved: %u", time.timestamp);
              } else {
                ESP_LOGW("calibration", "Could not get current time for calibration timestamp");
              }
              
              id(days_since_calibration).update();
              
              ESP_LOGI("calibration", "EC Calibration completed!");
              ESP_LOGI("calibration", "Raw Voltage: %.2f mV, Temperature: %.2f°C", current_voltage, current_temp);
              ESP_LOGI("calibration", "New cal_slope: %.3f (old: %.3f)", new_slope, id(ec_slope).state);
              ESP_LOGI("calibration", "Voltage factor: %.2f", voltage_factor);
              ESP_LOGI("calibration", "Target EC: %.0f µS/cm", target_ec);
            } else {
              ESP_LOGE("calibration", "Calculated slope out of bounds: %.3f", new_slope);
              ESP_LOGE("calibration", "Raw voltage: %.2f mV, check sensor readings", current_voltage);
            }
          } else {
            ESP_LOGE("calibration", "Invalid raw voltage reading: %.2f mV", current_voltage);
            }

Just to clarify, my probe isn’t a DFRobot, it’s a Chinese one. I think they work the same way, right?

Here’s the link if I’m allowed:
https://fr.aliexpress.com/item/1005003479288815.html?spm=a2g0o.detail.pcDetailBottomMoreOtherSeller.15.345b70b4qGHBgT&gps-id=pcDetailBottomMoreOtherSeller&scm=1007.40050.354490.0&scm_id=1007.40050.354490.0&scm-url=1007.400

Hi Cedric.

So you are not using a voltage divider, your reference temp is around 25’C and your calibration solutions are 600uS/cm and 1413uS/cm?

I suspect your probe has a non-zero baseline…

This code should calculate the slope/offset using two point calibration. As the conversion to EC is non-linear, the slope will just make a best fit between those two points.

You will need to add these to the global section:

globals:
  - id: ec_cal_x_600
    type: float
    restore_value: true
    initial_value: "NAN"
  - id: ec_cal_x_1413
    type: float
    restore_value: true
    initial_value: "NAN"
  - id: ec_cal_temp_600
    type: float
    restore_value: true
    initial_value: "NAN"
  - id: ec_cal_temp_1413
    type: float
    restore_value: true
    initial_value: "NAN"

Then add these buttons:

button:
  - platform: template
    name: "EC Cal: Capture 600 µS/cm"
    on_press:
      - lambda: |-
          // If you have NO divider: DIV_RATIO = 1.0
          // If you DO have a divider (e.g. 10k/15k -> Vadc = 0.6*Vprobe): DIV_RATIO = 0.6 and we undo it by /DIV_RATIO
          const float DIV_RATIO = 1.0f;

          float measured_v = id(ec_voltage).state; // V at ADC pin
          float temp = id(rtd_ezo).state;          // °C
          if (isnan(measured_v) || isnan(temp)) {
            ESP_LOGE("ec_cal", "Capture 600 failed: voltage or temp is NaN");
            return;
          }

          float v_probe_mV = (measured_v / DIV_RATIO) * 1000.0f;

          // Keep Cédric's constants, just reorganized
          const float C = 100000.0f / 820.0f / 200.0f;
          const float alpha = 0.02f;
          const float tref = 25.0f;
          float temp_coeff = 1.0f + alpha * (temp - tref);

          // X = (C * Vprobe_mV) / temp_coeff
          id(ec_cal_x_600) = (C * v_probe_mV) / temp_coeff;
          id(ec_cal_temp_600) = temp;

          ESP_LOGI("ec_cal", "Captured 600: Vadc=%.4f V, Vprobe=%.2f mV, T=%.2f C, X=%.6f",
                   measured_v, v_probe_mV, temp, id(ec_cal_x_600));

  - platform: template
    name: "EC Cal: Capture 1413 µS/cm"
    on_press:
      - lambda: |-
          const float DIV_RATIO = 1.0f;

          float measured_v = id(ec_voltage).state;
          float temp = id(rtd_ezo).state;
          if (isnan(measured_v) || isnan(temp)) {
            ESP_LOGE("ec_cal", "Capture 1413 failed: voltage or temp is NaN");
            return;
          }

          float v_probe_mV = (measured_v / DIV_RATIO) * 1000.0f;

          const float C = 100000.0f / 820.0f / 200.0f;
          const float alpha = 0.02f;
          const float tref = 25.0f;
          float temp_coeff = 1.0f + alpha * (temp - tref);

          id(ec_cal_x_1413) = (C * v_probe_mV) / temp_coeff;
          id(ec_cal_temp_1413) = temp;

          ESP_LOGI("ec_cal", "Captured 1413: Vadc=%.4f V, Vprobe=%.2f mV, T=%.2f C, X=%.6f",
                   measured_v, v_probe_mV, temp, id(ec_cal_x_1413));

  - platform: template
    name: "EC Cal: Solve (600 + 1413)"
    on_press:
      - lambda: |-
          const float EC1 = 600.0f;
          const float EC2 = 1413.0f;

          float x1 = id(ec_cal_x_600);
          float x2 = id(ec_cal_x_1413);

          if (isnan(x1) || isnan(x2)) {
            ESP_LOGE("ec_cal", "Solve failed: missing capture(s). Capture 600 and 1413 first.");
            return;
          }

          float dx = x2 - x1;
          if (fabsf(dx) < 1e-6f) {
            ESP_LOGE("ec_cal", "Solve failed: dx too small (x1=%.6f, x2=%.6f).", x1, x2);
            return;
          }

          // Two-point linear fit:
          // EC = slope * X + offset
          float new_slope = (EC2 - EC1) / dx;
          float new_offset = EC1 - new_slope * x1;

          // Guard rails (adjust if you want)
          if (new_slope <= 0.0f || new_slope > 200.0f) {
            ESP_LOGE("ec_cal", "Slope out of bounds: %.6f", new_slope);
            return;
          }
          if (fabsf(new_offset) > 5000.0f) {
            ESP_LOGW("ec_cal", "Offset large: %.2f (still applying, but check captures)", new_offset);
          }

          // Apply to existing number entities
          auto s = id(ec_slope).make_call();
          s.set_value(new_slope);
          s.perform();

          auto o = id(ec_offset).make_call();
          o.set_value(new_offset);
          o.perform();

          ESP_LOGI("ec_cal", "2-point calibration applied:");
          ESP_LOGI("ec_cal", "  x600=%.6f (T=%.2fC), x1413=%.6f (T=%.2fC)",
                   x1, id(ec_cal_temp_600), x2, id(ec_cal_temp_1413));
          ESP_LOGI("ec_cal", "  slope=%.6f, offset=%.2f", new_slope, new_offset);

Hello ffirenmatana.

Thank you again for your time. I’ve kept this part of the code:

 - platform: template
    name: "EC Reference"
    id: ec_reference
    unit_of_measurement: "µS/cm"
    device_class: "conductivity"
    state_class: "measurement"
    update_interval: 5s
    accuracy_decimals: 0
    lambda: |-
      float voltage = id(ec_voltage).state * 1000;
      float temperature = id(rtd_ezo).state;
      if (isnan(voltage) || isnan(temperature)) return {};

      float ec_raw = 100000.0 * voltage / 820.0 / 200.0 * id(ec_slope).state; 
      float ec_compensated = ec_raw / (1.0 + 0.02 * (temperature - 25.0));     
      ec_compensated += id(ec_offset).state; 
      
      return ec_compensated;

I think it’s necessary.

I did several tests with a 600µs buffer solution. I get 0.1770V and press the 600 button.

I put the probe in the 1413µs buffer solution and get 0.2210V. I press the 1413 button.

From what I understand, I have to remove the probe and press the 600+1413 button. The code works correctly because I get a slope value of 26.79.

The problem is that the values ​​are wrong.
If I put the probe back in the 600µs solution, I read 3282µ, and in the 1413µs solution, I read 4084.

I don’t know if I did something wrong or if I need to modify my program a bit more. In any case, I’m trying to understand. It shouldn’t be anything major, but it’s complicated for me.

I’ll send you the complete new code with all your work.
Anyway, thank you so much! I don’t know how to thank you enough!

Cédric


globals:
  - id: current_i
    type: float
    initial_value: '0.000124' 
  - id: ec_cal_x_600
    type: float
    restore_value: true
    initial_value: "NAN"
  - id: ec_cal_x_1413
    type: float
    restore_value: true
    initial_value: "NAN"
  - id: ec_cal_temp_600
    type: float
    restore_value: true
    initial_value: "NAN"
  - id: ec_cal_temp_1413
    type: float
    restore_value: true
    initial_value: "NAN"


number:
# Parametre d'etalonnage conductivite ec
  - platform: template
    name: "pente calibration ec"
    id: ec_slope
    entity_category: config
    min_value: 0.1
    max_value: 100.0
    initial_value: 21.227
    step: 0.001
    optimistic: true
    restore_value: true

  - platform: template
    name: "Offset calibration ec"
    id: ec_offset
    entity_category: config
    min_value: -1000.0
    max_value: 1000.0
    initial_value: 0.0
    step: 0.1
    optimistic: true
    restore_value: true

  - platform: template
    name: "date dernier etalonnage"
    id: last_calibration_timestamp
    entity_category: config
    min_value: 0
    max_value: 2147483647  # Max uint32_t value
    initial_value: 0
    step: 1
    optimistic: true
    restore_value: true  # C'est là le point clé : cela persiste même après un redémarrage.
    internal: true  # Masquer de l'interface utilisateur de HA

sensor:
# Declaration capteur  EC
  - platform: ads1115
    multiplexer: 'A3_GND'
    state_class: measurement
    gain: 4.096
    name: "ec voltage"
    id: ec_voltage
    update_interval: 5s
    accuracy_decimals: 4

    # Valeur EC - en utilisant la température de référence
  - platform: template
    name: "EC Reference"
    id: ec_reference
    unit_of_measurement: "µS/cm"
    device_class: "conductivity"
    state_class: "measurement"
    update_interval: 5s
    accuracy_decimals: 0
    lambda: |-
      float voltage = id(ec_voltage).state * 1000;
      float temperature = id(rtd_ezo).state;
      if (isnan(voltage) || isnan(temperature)) return {};

      float ec_raw = 100000.0 * voltage / 820.0 / 200.0 * id(ec_slope).state; 
      float ec_compensated = ec_raw / (1.0 + 0.02 * (temperature - 25.0));     
      ec_compensated += id(ec_offset).state; 
      
      return ec_compensated;

  # EC - nb jours depuis derniere Calibration
  - platform: template
    name: "nb jours depuis derniere Calibration"
    id: days_since_calibration
    unit_of_measurement: "days"
    device_class: "duration"
    state_class: "measurement"
    entity_category: diagnostic
    accuracy_decimals: 1
    update_interval: 1h  # actualiser chaque heure
    lambda: |-
      uint32_t last_cal_timestamp = (uint32_t)id(last_calibration_timestamp).state;
      
      // If never calibrated, return a large number to indicate this
      if (last_cal_timestamp == 30) {
        return 999.9;  // Indicates "never calibrated"
      }
      
      // Get current time from Home Assistant
      auto time = id(homeassistant_time).now();
      if (!time.is_valid()) {
        ESP_LOGW("calibration", "Time not available from Home Assistant");
        return {};  // Return empty optional if time not available
      }
      
      uint32_t current_timestamp = time.timestamp;
      
      // Calculate time difference in seconds
      uint32_t time_diff = current_timestamp - last_cal_timestamp;
      
      // Convert to days (86400 seconds per day)
      float days = time_diff / 86400.0;
      
      return days;

# Declaration capteur temperature
  - platform: ezo
    id: rtd_ezo
    name: "Temperature Measurement"
    address: 102
    accuracy_decimals: 2
    unit_of_measurement: "°C"
    update_interval: 10s
    on_value:
      then:
        - lambda: |-
           float speed = (x - 20.0) / 10.0;
           id(sortie_pwm_temp).set_level(speed);


 # EC Ajouter un étalonnage pour ajuster cal_slope en fonction d'un échantillon de référence

  - platform: template
    name: "EC Cal: Capture 600 µS/cm"
    on_press:
      - lambda: |-
          // If you have NO divider: DIV_RATIO = 1.0 ******YES I DON'T HAVE DIVIDEUR
          // If you DO have a divider (e.g. 10k/15k -> Vadc = 0.6*Vprobe): DIV_RATIO = 0.6 and we undo it by /DIV_RATIO
          const float DIV_RATIO = 1.0f;

          float measured_v = id(ec_voltage).state; // V at ADC pin
          float temp = id(rtd_ezo).state;          // °C
          if (isnan(measured_v) || isnan(temp)) {
            ESP_LOGE("ec_cal", "Capture 600 failed: voltage or temp is NaN");
            return;
          }

          float v_probe_mV = (measured_v / DIV_RATIO) * 1000.0f;

          // Keep Cédric's constants, just reorganized
          const float C = 100000.0f / 820.0f / 200.0f;
          const float alpha = 0.02f;
          const float tref = 25.0f;
          float temp_coeff = 1.0f + alpha * (temp - tref);

          // X = (C * Vprobe_mV) / temp_coeff
          id(ec_cal_x_600) = (C * v_probe_mV) / temp_coeff;
          id(ec_cal_temp_600) = temp;

          ESP_LOGI("ec_cal", "Captured 600: Vadc=%.4f V, Vprobe=%.2f mV, T=%.2f C, X=%.6f",
                   measured_v, v_probe_mV, temp, id(ec_cal_x_600));

  - platform: template
    name: "EC Cal: Capture 1413 µS/cm"
    on_press:
      - lambda: |-
          const float DIV_RATIO = 1.0f;

          float measured_v = id(ec_voltage).state;
          float temp = id(rtd_ezo).state;
          if (isnan(measured_v) || isnan(temp)) {
            ESP_LOGE("ec_cal", "Capture 1413 failed: voltage or temp is NaN");
            return;
          }

          float v_probe_mV = (measured_v / DIV_RATIO) * 1000.0f;

          const float C = 100000.0f / 820.0f / 200.0f;
          const float alpha = 0.02f;
          const float tref = 25.0f;
          float temp_coeff = 1.0f + alpha * (temp - tref);

          id(ec_cal_x_1413) = (C * v_probe_mV) / temp_coeff;
          id(ec_cal_temp_1413) = temp;

          ESP_LOGI("ec_cal", "Captured 1413: Vadc=%.4f V, Vprobe=%.2f mV, T=%.2f C, X=%.6f",
                   measured_v, v_probe_mV, temp, id(ec_cal_x_1413));

  - platform: template
    name: "EC Cal: Solve (600 + 1413)"
    on_press:
      - lambda: |-
          const float EC1 = 600.0f;
          const float EC2 = 1413.0f;

          float x1 = id(ec_cal_x_600);
          float x2 = id(ec_cal_x_1413);

          if (isnan(x1) || isnan(x2)) {
            ESP_LOGE("ec_cal", "Solve failed: missing capture(s). Capture 600 and 1413 first.");
            return;
          }

          float dx = x2 - x1;
          if (fabsf(dx) < 1e-6f) {
            ESP_LOGE("ec_cal", "Solve failed: dx too small (x1=%.6f, x2=%.6f).", x1, x2);
            return;
          }

          // Two-point linear fit:
          // EC = slope * X + offset
          float new_slope = (EC2 - EC1) / dx;
          float new_offset = EC1 - new_slope * x1;

          // Guard rails (adjust if you want)
          if (new_slope <= 0.0f || new_slope > 200.0f) {
            ESP_LOGE("ec_cal", "Slope out of bounds: %.6f", new_slope);
            return;
          }
          if (fabsf(new_offset) > 5000.0f) {
            ESP_LOGW("ec_cal", "Offset large: %.2f (still applying, but check captures)", new_offset);
          }

          // Apply to existing number entities
          auto s = id(ec_slope).make_call();
          s.set_value(new_slope);
          s.perform();

          auto o = id(ec_offset).make_call();
          o.set_value(new_offset);
          o.perform();

          ESP_LOGI("ec_cal", "2-point calibration applied:");
          ESP_LOGI("ec_cal", "  x600=%.6f (T=%.2fC), x1413=%.6f (T=%.2fC)",
                   x1, id(ec_cal_temp_600), x2, id(ec_cal_temp_1413));
          ESP_LOGI("ec_cal", "  slope=%.6f, offset=%.2f", new_slope, new_offset);


Hi Cedric.

It’s difficult for me to troubleshoot as I’m using a voltage divider and am operating at different expected electrical conductivity. I also don’t know what sort of temperature ranges you are using.

If you want to easily calculate the slope and offset manually, your best bet is to document the temp, voltage and reported EC against a known solution - repeat for both calibration solutions and then give that data to the likes of ChatGPT. It can do the math for you quite comfortably.

I did need to include an additional filter to remove the temperature bias but I don’t think you will need to use that at 600uS/cm - 1400uS/cm.

Can you elaborate more about your setup?

Hello,

i will give you some detail yes :slight_smile:
I’m using this for a freshwater aquarium where I’ll be measuring temperature, EC, ORP, pH, and TDS once I’ve finished my project.

The whole system is filtered with analog isolation.
The temperature is provided by Atlas EZO RTD; it’s simple and reliable.
It’s around 25-27°C, but on my desktop for testing, it’s at 19°C.
When I calibrate, the temperature reading doesn’t change, so I don’t think the problem is there.

The EC module is from AliExpress; I’ve included the link above.
I’m connecting it via an ADS1115 to an ESP32 S3.

To answer the question, at 20°C and 600µs, the probe reads 0.1770V DC at the ESP32 input.

At 1413µs and 20°C, the probe reads 0.2210V DC at the ESP32 input.

I hope I’ve given enough detail.

Cédric

Yeah that helps.

I think your EC probe has a non-zero baseline output voltage so a slope only approach will force the line through 0 and break the rest of the range.

Essentially we just need to subtract your baseline voltage and it should work.

You could rewrite the voltage term as

float v_mV = (id(ec_voltage).state - 0.1445) * 1000;

Here’s the complete code.

lambda: |-
  float v = id(ec_voltage).state;        // volts at ADC/ESP32
  float temperature = id(rtd_ezo).state;
  if (isnan(v) || isnan(temperature)) return {};

  // Baseline removal for this specific EC module (from 2-point data)
  const float V0 = 0.1445f;              // volts
  float voltage_mV = (v - V0) * 1000.0f; // mV above baseline

  const float C = 100000.0f / 820.0f / 200.0f;
  const float alpha = 0.02f;
  const float tref = 25.0f;
  float temp_coeff = 1.0f + alpha * (temperature - tref);

  float ec_raw = C * voltage_mV * id(ec_slope).state;
  float ec_compensated = ec_raw / temp_coeff;

  ec_compensated += id(ec_offset).state; // you can keep this at 0
  return ec_compensated;`Preformatted text`

If you use a slope of 27.27 and an offset of 0 then the above makes your measurements correct.

The only other thing I can add is that your calibration solutions are likely referenced to 25’C so it’s important that you keep the temperature compensation or your readings will be out by 10% :+1:

Hello ffirenmatana,

Just to give you an update.

It’s now working well with a calibration using two levels: 64 and 1413.

The problem was that the 600µs solution was giving 1000µs, so the curve was inverted!

Thanks again for everything.
Cédric