Sump Pump Monitor

Problem

Spring 2023 Update See below

A couple of years ago, the sump pump in my basement backed up after some heavy rain, and the basement flooded with several inches of water. Needless to say, this was not an experience I wanted to re-live, so I installed a backup pump and replaced the partially clogged drain pipe on the outside of the house. However, these measures were not enough to keep me from becoming very anxious anytime it rained heavily - you might say that it left me in a sump pump slump.

I started thinking about ways that I could monitor the water level in the sump pit, perhaps even track it over time, to see if the data could allow me to enjoy the sound of rain on the roof again. Thinking back to my college experience with ultrasonic sensors, I figured I could mount a sensor in the pit, write a little code, and send the data to an InfluxDB database in my homelab. I custom-designed and 3d printed a chassis for the sensor, mounted it, got the data pushed to the DB and… I kept getting inconsistent readings. My theory is that the sound waves got deflected off the other objects in the pit too regularly and would confuse the sensor. I tried compensating by slowing the polling rate and averaging readings, but to no avail. The data was good enough to see when graphed, but was consistently inconsistent enough to prevent other analytics.

It was some time later that I discovered a pack of cheap float sensors on Amazon and inspiration struck me again. I realized that I could mount the switches at different “intervals” and thus monitor the water levels that way. I designed and printed another mounting system (.stl file here), and wired up the switches to the raspberry pi using some ethernet cable and interfacing with a couple of extra keystone jacks I had lying around.

After installing the new sensor setup in the pit, I updated the code to account for the new sensors.

With the float switches, I was able to poll at a very high rate, trading water level precision for timing precision, accuracy and reliability. The data also turned out to be very consistent, and I was able to easily distinguish when the sump pump ran, and determine other information from that. I figure if the discharge pipe ever gets clogged, I’ll start to see an increase in the time that it takes to empty all of the water from the pit.

The python code is not fancy, but it works. I’ll leave it below in case someone wants to attempt a similar project of their own.

Click to show code
import time
import logging
import math
import RPi.GPIO as GPIO
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from influxdb import InfluxDBClient

def determineLevel(s1,s2,s3,s4,s5,s6):
    return s1 + s2 + s3 + s4 + s5 + s6

def calculateRatePerHour(dt_now, last_event):
    time_delta = dt_now - last_event
    return 3600/time_delta.seconds

# Determine if pump event occured
def determinePumpEvent(current, previous):
    if (current == 0 and
        previous[0] == 1 and
        previous[1] == 2 and
        previous[2] == 3 and
        previous[3] == 4):
        return 1
    else:
        return 0

# Time Setup
tz_offset=-5 # UTC-6
tzinfo = timezone(timedelta(hours=tz_offset))

# Logging
logging.basicConfig(filename='sump_pump.log', level=logging.INFO)

# Startup
dt = datetime.now(tzinfo)
logging.info('Starting Service %s', dt)

# Polling Frequency (seconds / poll)
frequency = .1

# DB Connection
influx_db_server_ip = ""
influx_db_server_port = 123
account = ""
password = ""
database_name = ""
influx_level_measurement = "level"
influx_pump_event_measurement = "pumpEvent"
influx_pump_rate_measurement = "pumpRate"
influx_discharge_time_measurement = "dischargeTime"
influx_emergency_measurement = "emergencyFlag"

influxdb_client = InfluxDBClient(influx_db_server_ip, influx_db_server_port, account, password, database_name)
if database_name not in influxdb_client.get_list_database():
        influxdb_client.create_database(database_name)

# Data Setup
datapoints = [{},{}]

# Switches
# Ground (orange wire) to pin 6
switch1 = 18 # yellow
switch2 = 11 # white
switch3 = 13 # brown
switch4 = 16 # red
switch5 = 15 # green
switch6 = 22 # black

# GPIO
GPIO.setmode(GPIO.BOARD)
GPIO.setup(switch1,GPIO.IN, pull_up_down=GPIO.PUD_UP)
GPIO.setup(switch2,GPIO.IN, pull_up_down=GPIO.PUD_UP)
GPIO.setup(switch3,GPIO.IN, pull_up_down=GPIO.PUD_UP)
GPIO.setup(switch4,GPIO.IN, pull_up_down=GPIO.PUD_UP)
GPIO.setup(switch5,GPIO.IN, pull_up_down=GPIO.PUD_UP)
GPIO.setup(switch6,GPIO.IN, pull_up_down=GPIO.PUD_UP)

# Loop Setup
last_input1 = False
last_input2 = False
last_input3 = False
last_input4 = False
last_input5 = False
last_input6 = False
currentLevel = 0
pump_event = 0
last_pump_time = None
rate = 0
previousLevels = [0,0,0,0,0,0]
previousLevelDTimes = [None,None,None,None,None,None]

# Loop
while True:

    input1 = GPIO.input(switch1)
    input2 = GPIO.input(switch2)
    input3 = GPIO.input(switch3)
    input4 = GPIO.input(switch4)
    input5 = GPIO.input(switch5)
    input6 = GPIO.input(switch6)

    dt = datetime.now(tzinfo)
    iso_dt = dt.isoformat()

    # Status Changed
    if (input1 != last_input1 or 
        input2 != last_input2 or
        input3 != last_input3 or
        input4 != last_input4 or
        input5 != last_input5 or
        input6 != last_input6):

        currentLevel = determineLevel(input1,input2,input3,input4,input5,input6)
        datapoints[0] = { "measurement" : influx_level_measurement, "time": iso_dt, "fields": {"level" : currentLevel} }
        
        # If level 6, problem
        if input6 == 1:
            datapoints.append({"measurement" : influx_emergency_measurement, "time": iso_dt, "fields": {"emergency" : 1 } })
        else:
            datapoints.append({"measurement" : influx_emergency_measurement, "time": iso_dt, "fields": {"emergency" : 0 } })

        # Detect Pump Event
        pump_event = determinePumpEvent(currentLevel, previousLevels)
        datapoints[1] = { "measurement" : influx_pump_event_measurement, "time": iso_dt, "fields": {"pump_event" : pump_event } }
        if pump_event == 1:
            # Pump Event Rate
            if last_pump_time != None:
                rate = calculateRatePerHour(dt, last_pump_time)
                datapoints.append({"measurement" : influx_pump_rate_measurement, "time": iso_dt, "fields": {"pumpEventRatePerHour" : round(rate,2) } })
            
            last_pump_time = dt

            # Pump Discharge Time
            if previousLevelDTimes[2] != None:
                timeToDischarge = dt - previousLevelDTimes[2]
                milliseconds = round((timeToDischarge.microseconds / 1000),3)
                datapoints.append({"measurement" : influx_discharge_time_measurement, "time": iso_dt, "fields": {"pumpDischargeTime" : milliseconds } })

        # Send Data to DB
        response = influxdb_client.write_points(datapoints)
        if response is False:
            logging.warn('Bad DB response: ', response)
        
        # Loop Cleanup
        last_input1 = input1
        last_input2 = input2
        last_input3 = input3
        last_input4 = input4
        last_input5 = input5
        last_input6 = input6
        last_distance = currentLevel
        datapoints = [{},{}]
        previousLevels = [currentLevel, previousLevels[0], previousLevels[1],previousLevels[2],previousLevels[3],previousLevels[4]]
        previousLevelDTimes = [dt, previousLevelDTimes[0], previousLevelDTimes[1],previousLevelDTimes[2],previousLevelDTimes[3],previousLevelDTimes[4]]

    # Loop Frequency
    time.sleep(frequency)

Spring 2023 Update

Since writing this, my primary sump pump failed again, and so I put in a name brand pump as a replacement, and the float switch assembly no longer fit in the pit with how the new pump was set up. I stumbled upon this writeup and have since implemented it, though I’ve already had to replace the depth sense already. I think the issue was with the water getting grimy - chlorine tablets seem to help. Because the controller uses an esp32 chip, it was easy to modify the configuration to include a few extra calculations that then automatically sync back up to Home Assistant (without having to go through an intermediate InfluxDB).

Show ESPHome Configuration
substitutions:
  # Device Naming
  devicename: sump-pump-monitor
  friendly_name: Sump Pump Monitor
  device_description: Sump Pump Level Sensor, Counter, and Alarm

  # Pumpout levels
  # Backup switch on: 0.275m
  # Backup switch off: 0.164m
  # Primary switch on: 0.145m
  # Primary switch off: 0.066m
  # Diameter of pit: 0.4572 m
  # Area of pit: 0.164 m2
  # 1 m3 = 264.172 ga

  pit_area: '0.164' #square meters; the area of the sump pit
  gallons_per_cube_meter: '264.172' #gallons

  # Limits for pump levels and alarm levels
  primary_pumpout_below_height: '0.080' #meters; 0.068 the primary pump will empty the sump to below this level
  primary_pumpout_reset_height: '0.150' #meters; 0.140 primary pump counter is reset when water rises above this level (enabling it to be triggered again)
  primary_pumpout_duration_limit: '60.0' #seconds; maximum duration for the primary pump to drop the level from 'reset_height' to 'below_height' (otherwise the sump level dropped by some other means e.g. evaporation)
  backup_pumpout_below_height: '0.172' #meters; 0.160 the backup pump will empty the sump to below this level
  backup_pumpout_reset_height: '0.282' #meters; 0.270 backup pump counter is reset when water rises above this level (enabling it to be triggered again)
  backup_pumpout_duration_limit: '60.0' #seconds; maximum duration for the backup pump to drop the level from 'reset_height' to 'below_height' (otherwise the sump level dropped by some other means e.g. evaporation)
  primary_height_limit: '0.212' #meters; 0.200 primary pump isn't working when water is above this level (alarm will sound)
  backup_height_limit: '0.332' #meters; 0.320 backup pump isn't working when water is above this level (alarm will sound)
  battery_not_charging_voltage: '11.9' #12.1 volts; when 12V battery drops below this voltage it means it is not charging (30 sec average) (alarm will sound)
  battery_critical_low_voltage: '11.0' #11.0 volts; 12V battery is getting ready to die at this voltage (30 sec average) (Barracuda pump stops working at 10.8V) (alarm will sound)
  batt_volt_charge_max: '14.2' #volts; maximum voltage expected to be seen when battery is connected to the trickle charger
  batt_volt_charge_min: '12.0' #volts; minimum voltage expected to be seen when battery is connected to the trickle charger
  batt_volt_report: '12.7' #volts; voltage to report if the measured voltage is between the previous two limits
  alarm_snooze_duration: '4.0' #hours to snooze the siren if it is going off. Snoozing only applies to 'warning' alarms (primary pump failing or the battery not charging). Snoozing 'critical' alarms is not possible.
   
  # Configuration values for the sensors connected to the ESP32
  level_sensor_res_val: '290.0' #ohms; resistance in series with the level sensor
  batt_volt_high_res_val: '20520.0' #ohms; resistor between 12V and battery voltage measurement point
  batt_volt_low_res_val: '9830.0' #ohms; resistor between battery voltage measurement point and ground
  level_sensor_dist_offset: '0.007' #meters; depth water must be before sensor reports nonzero values (default 0.007)
  level_sensor_max_depth: '1.0' #meters; maximum water depth reading per the sensor spec
  level_sensor_min_current: '0.004' #amps; the current sourced by the depth sensor when reading zero (default: 0.004)
  level_sensor_max_current: '0.020' #amps; the current sourced by the depth sensor when reading max depth (default: 0.020)

esphome:
  name: $devicename
  comment: ${device_description}
  on_boot:
    priority: 250.0 # 250=after sensors are set up; when wifi is initializing
    then:
      
      # Publish the friendly-formatted snooze time based on the value of the snooze timestamp
      - lambda: |-
          id(primary_pumpout_counter).publish_state(id(primary_pumpout_counter_var));
          id(backup_pumpout_counter).publish_state(id(backup_pumpout_counter_var));
          id(primary_pumpout_gallon_counter).publish_state(id(primary_pumpout_gallon_counter_var));
          id(primary_pumpout_gallon_per_hour).publish_state(id(primary_pumpout_gallon_per_hour_var));
          id(primary_pumpout_interval).publish_state(id(primary_pumpout_interval_var));
          id(backup_pumpout_interval).publish_state(id(backup_pumpout_interval_var));
          time_t snoozeExpTime = id(snooze_expiration_timestamp_var);
          time_t boot_time = id(homeassistant_time).now().timestamp;
          id(boot_time_var) = boot_time;
          // If snooze time is in the past, then snooze has expired
          if (snoozeExpTime < boot_time) {
            id(snooze_expiration_timestamp_var) = 0;
            id(snooze_expiration_text_sensor_friendly).publish_state("N/A");
          // Snooze time is later or equal to now, so snooze is active
          } else {
            // Create a friendly-formatted version of the timestamp
            char snoozeExpTimeTextFriendly[23];
            strftime(snoozeExpTimeTextFriendly, sizeof(snoozeExpTimeTextFriendly), "%Y-%m-%d %I:%M:%S %p", localtime(&snoozeExpTime));
            // Publish the friendly-formatted timestamp
            id(snooze_expiration_text_sensor_friendly).publish_state(snoozeExpTimeTextFriendly);
          }

esp32:
  board: esp32dev
  framework:
    type: arduino

# Enable logging
logger:
  level: wARN
  logs:
    ads1115: WARN

# Enable Home Assistant API
api:
  encryption:
    key: ""

ota:
  safe_mode: true
  password: ""

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  power_save_mode: none # default is LIGHT for ESP32

  manual_ip:
    static_ip: 
    gateway: 
    subnet: 

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: $devicename
    password: ""

captive_portal:

# Enable Web server.
web_server:
  port: 80

# Sync time with Home Assistant.
time:
  - platform: homeassistant
    id: homeassistant_time
    on_time:
      #Every day at 12:00am, reset sump pump counter
      - seconds: 0
        minutes: 0
        hours: 0
        then:
          - lambda: |-
              id(primary_pumpout_counter_var) = 0;
              id(primary_pumpout_counter).publish_state(id(primary_pumpout_counter_var));
              id(backup_pumpout_counter_var) = 0;
              id(backup_pumpout_counter).publish_state(id(backup_pumpout_counter_var));
              id(primary_pumpout_gallon_counter_var) = 0.0;
              id(primary_pumpout_gallon_counter).publish_state(id(primary_pumpout_gallon_counter_var));
              id(primary_pumpout_gallon_per_hour_var) = 0.0;
              id(primary_pumpout_gallon_per_hour).publish_state(id(primary_pumpout_gallon_per_hour_var));
              float primary_pumpout_interval_now = (id(homeassistant_time).now().timestamp - id(primary_pumpout_begin_var))/60.0;
              //If primary pumpout interval has passed the limit
              if ((primary_pumpout_interval_now > 720.0) && (id(primary_pumpout_interval_var) > 0.0)) {
                id(primary_pumpout_interval_var) = 0.0;
                id(primary_pumpout_interval).publish_state(0.0);
              }
              float backup_pumpout_interval_now = (id(homeassistant_time).now().timestamp - id(backup_pumpout_begin_var))/60.0;
              //If backup pumpout interval has passed the limit
              if ((backup_pumpout_interval_now > 720.0) && (id(backup_pumpout_interval_var) > 0.0)) {
                id(backup_pumpout_interval_var) = 0.0;
                id(backup_pumpout_interval).publish_state(0.0);
              }

# I2C bus setup for ADC chip
i2c:
  - id: bus_a
    sda: 32
    scl: 33
    scan: true

# ADC chip setup
ads1115:
  - address: 0x48
    continuous_mode: false

# Global Variables
globals:
  - id: primary_pumpout_begin_var
    type: time_t
    initial_value: '0'
    restore_value: true
  - id: primary_pumpout_counter_var
    type: int
    restore_value: true
    initial_value: '0'
  - id: primary_pumpout_gallon_counter_var
    type: float
    restore_value: true
    initial_value: '0.0'
  - id: primary_pumpout_gallon_per_hour_var
    type: float
    restore_value: true
    initial_value: '0.0'
  - id: primary_pumpout_last_timestamp_var
    type: time_t
    initial_value: '0'
    restore_value: true
  - id: primary_pumpout_interval_var
    type: float
    initial_value: '0.0'
    restore_value: true

  - id: backup_pumpout_begin_var
    type: time_t
    initial_value: '0'
    restore_value: true
  - id: backup_pumpout_counter_var
    type: int
    restore_value: true
    initial_value: '0'
  - id: backup_pumpout_last_timestamp_var
    type: time_t
    initial_value: '0'
    restore_value: true
  - id: backup_pumpout_interval_var
    type: float
    initial_value: '0.0'
    restore_value: true

  - id: snooze_expiration_timestamp_var
    type: time_t
    initial_value: '0'
    restore_value: true
  - id: alarm_snooze_loops_var
    type: int
    initial_value: '0'
    restore_value: false
  - id: boot_time_var
    type: time_t
    restore_value: false

################################################################################
# Binary Sensors
################################################################################
binary_sensor:
  # Physical alarm snooze button
  - platform: gpio
    pin:
      number: 23
      mode:
        input: true
        pullup: true
      inverted: true
    name: "${friendly_name} Alarm Snooze Physical Button"
    id: physical_snooze_button
    internal: true
    on_press:
      # Set snooze by pressing the virtual snooze button
      - button.press: virtual_snooze_button
      # If the physical snooze is held, cancel the snooze after 3 sec & chirp the alarm
      - while:
          condition: 
            binary_sensor.is_on: physical_snooze_button
          then:
          - lambda: |-
              if (id(alarm_snooze_loops_var) == 3) {
                // button held for 3s, so cancel the snooze
                // set to 1.0 so that the alarm_snoozed template sensor will take care of the publishing details and will then set to 0.
                id(snooze_expiration_timestamp_var) = 1.0;
                // Chirp the alarm so you know the snooze is reset
                id(alarm_chirp).execute();
              }
              id(alarm_snooze_loops_var) += 1;
          - delay: 1s
    on_release:
      - lambda: id(alarm_snooze_loops_var) = 0;

  # Hysteresis sensor to trip when sump pump is emptied by primary pump. When tripped, execute the 'primary_pumpout_increment' script
  - platform: template
    name: "${friendly_name} Primary Pumpout Event Enable"
    id: primary_pumpout_event_enable
    internal: false
    lambda: |-
      if (id(level_actual).state < $primary_pumpout_below_height) {
        return true;
      } else if (id(level_actual).state > $primary_pumpout_reset_height) {
        return false;
      } else {
        return {};
      }
    filters:
      - delayed_on: 5s #debounce after falling below_height
      - delayed_off: 10s #debounce after rising above reset_height
    on_press:
      # when state transisions from false to true, a pumpout event occurred. 
      lambda: |-
        time_t pumpout_duration = id(homeassistant_time).now().timestamp - id(primary_pumpout_begin_var);
        ESP_LOGW("primary_hyst", "duration is %li", pumpout_duration);
        ESP_LOGW("primary_hyst", "timestamp is %li", id(homeassistant_time).now().timestamp);
        ESP_LOGW("primary_hyst", "primary_begin is %li", id(primary_pumpout_begin_var));
        //If pumpout was quicker than the duration limit
        if (pumpout_duration < $primary_pumpout_duration_limit) {
          id(primary_pumpout_increment).execute();
        }

  # Hysteresis sensor to trip when sump pump is emptied by backup pump. When tripped, execute the 'backup_pumpout_increment' script
  - platform: template
    name: "${friendly_name} Backup Pumpout Event Enable"
    id: backup_pumpout_event_enable
    internal: false
    lambda: |-
      if (id(level_actual).state < $backup_pumpout_below_height) {
        return true;
      } else if (id(level_actual).state > $backup_pumpout_reset_height) {
        return false;
      } else {
        return {};
      }
    filters:
      - delayed_on: 5s #debounce after falling below_height
      - delayed_off: 10s #debounce after rising above reset_height
    on_press:
      # when state transisions from false to true, a pumpout event occurred. 
      lambda: |-
        time_t pumpout_duration = id(homeassistant_time).now().timestamp - id(backup_pumpout_begin_var);
        ESP_LOGD("backup_hyst", "duration is %li", pumpout_duration);
        //If pumpout was quicker than the duration limit
        if (pumpout_duration < $backup_pumpout_duration_limit) {
          id(backup_pumpout_increment).execute();
        }
  
  # Sensor to determine if the Alarm Snooze is Active
  - platform: template
    name: "${friendly_name} Alarm Snoozed"
    id: alarm_snoozed
    internal: false
    lambda: |-
      // If snooze time is zero, then snooze is not active
      if (id(snooze_expiration_timestamp_var) == 0) {
        return false;
      // If snooze time is in the past, then snooze has expired
      } else if (id(snooze_expiration_timestamp_var) < id(homeassistant_time).now().timestamp) {
        id(snooze_expiration_timestamp_var) = 0;
        id(snooze_expiration_text_sensor_friendly).publish_state("N/A");
        return false;
      // Snooze time is later or equal to now, so snooze is active
      } else {
        return true;
      }
      
  # Primary pump not working    
  - platform: template
    name: "${friendly_name} Fault: Primary Pump Not Operating"
    id: primary_pump_alarm
    internal: false
    lambda: |-
      if ( id(level_actual).state > $primary_height_limit ) {
        return true;
      } else {
        return false;
      }
    filters:
      - delayed_on: 10s
      - delayed_off: 2s
      
 # Backup pump not working    
  - platform: template
    name: "${friendly_name} Fault: Backup Pump Not Operating"
    id: backup_pump_alarm
    internal: false
    lambda: |-
      if ( id(level_actual).state > $backup_height_limit ) {
        return true;
      } else {
        return false;
      }
    filters:
      - delayed_on: 10s
      - delayed_off: 2s 
      
 # Battery not charging    
  - platform: template
    name: "${friendly_name} Fault: Battery Not Charging"
    id: not_charging_alarm
    internal: false
    lambda: |-
      if ( id(batt_voltage).state < $battery_not_charging_voltage ) {
        return true;
      } else {
        return false;
      }
    filters:
      #- delayed_on: 10s
      #- delayed_off: 10s

 # Low Battery     
  - platform: template
    name: "${friendly_name} Fault: Critically Low Battery"
    id: low_battery_alarm
    internal: false
    lambda: |-
      if ( id(batt_voltage).state < $battery_critical_low_voltage ) {
        return true;
      } else {
        return false;
      }
    filters:
      #- delayed_on: 10s
      #- delayed_off: 10s

  # Arbitration to determime if alarm should be sounding
  - platform: template
    name: "${friendly_name} Alarm Sounding"
    id: alarm_sounding
    internal: false
    lambda: |-
      if ( ( !id(alarm_snoozed).state && 
      
      // put stuff on the next line that will only sound the alarm if it's not snoozed
      ( id(not_charging_alarm).state || id(primary_pump_alarm).state )
      ) ||
      
      // put stuff on the next line that will sound the alarm regardless of snooze state
      ( id(low_battery_alarm).state || id(backup_pump_alarm).state )
      
      ) {
        return true;
      } else {
        return false;
      }
    on_press:
      lambda: |-
        id(sump_pump_alarm).turn_on();
    on_release:
      lambda: |-
        id(sump_pump_alarm).turn_off();

################################################################################
# Buttons.
################################################################################
button:
  # Virtual Button to restart the sump_pump_sensor.  
  - platform: restart
    name: "${friendly_name} Restart"
  # Virtual Snooze Button (shown on the ESP's webpage and in HA)
  - platform: template
    name: "${friendly_name} Alarm Snooze Button"
    id: virtual_snooze_button
    on_press:
      lambda: |-
        // Calculate snooze expiration time and set as a variable
        time_t snoozeExpTime = id(homeassistant_time).now().timestamp+(60.0*60.0*$alarm_snooze_duration);

        // Save the snooze expiration timestamp to a global variable
        id(snooze_expiration_timestamp_var) = snoozeExpTime;

        // Create a friendly-formatted version of the timestamp
        char snoozeExpTimeTextFriendly[23];
        strftime(snoozeExpTimeTextFriendly, sizeof(snoozeExpTimeTextFriendly), "%Y-%m-%d %I:%M:%S %p", localtime(&snoozeExpTime));
        
        // Publish the friendly-formatted timestamp
        id(snooze_expiration_text_sensor_friendly).publish_state(snoozeExpTimeTextFriendly);

################################################################################
# Outputs
################################################################################
output:
  # Output for sump pump physical alarm
  - platform: gpio
    pin: 25 #yellow wire
    inverted: true #false means 3.2V when ON
    id: sump_pump_alarm

################################################################################
#Scripts
################################################################################
script:
  - id: alarm_chirp
    mode: single
    then:
      - if:
          condition:
            lambda: return id(alarm_sounding).state == true;
          then:
            - lambda: |-
                id(sump_pump_alarm).turn_off();
            - delay: 250ms
            - lambda: |-
                id(sump_pump_alarm).turn_on();
            - delay: 50ms
            - lambda: |-
                id(sump_pump_alarm).turn_off();
            - delay: 250ms
            - lambda: |-
                id(sump_pump_alarm).turn_on();
          else:
            - lambda: |-
                id(sump_pump_alarm).turn_on();
            - delay: 50ms
            - lambda: |-
                id(sump_pump_alarm).turn_off();
  - id: primary_pumpout_increment
    mode: single
    then:
      lambda: |-
        // Save the timestamp as a local variable
        time_t execution_timestamp = id(homeassistant_time).now().timestamp;
        
        // Only exectute if at least 5 sec after bootup
        if (execution_timestamp - id(boot_time_var) > 5.0) {
        
          // Save the timestamp as a local variable
          time_t execution_timestamp = id(homeassistant_time).now().timestamp;

          // Add one to the global integer
          id(primary_pumpout_counter_var) += 1;

          // Force the sensor to publish a new state
          id(primary_pumpout_counter).publish_state(id(primary_pumpout_counter_var));

          // Add gallons to the global counter
          float cubed_meters_pumped = $pit_area * ($primary_pumpout_reset_height - $primary_pumpout_below_height);
          float gallons_pumped = cubed_meters_pumped * $gallons_per_cube_meter;
          id(primary_pumpout_gallon_counter_var) += gallons_pumped;

          // Force the sensor to publish a new state
          id(primary_pumpout_gallon_counter).publish_state(id(primary_pumpout_gallon_counter_var));

          // Calculate the minutes since last pumpout
          id(primary_pumpout_interval_var) = difftime(execution_timestamp,id(primary_pumpout_last_timestamp_var))/60.0;

          // If over 12 hours, simply report zero since I don't care at that point and don't want my nice plots to have outrageous y-axis scaling
          if (id(primary_pumpout_interval_var) > 720.0) {
            id(primary_pumpout_interval_var) = 0.0;
          }

          // Force the interval sensor to publish a new state
          id(primary_pumpout_interval).publish_state(id(primary_pumpout_interval_var));

          // Calculate the gallons per hour
          float gallons_per_hour = gallons_pumped / id(primary_pumpout_interval_var) * 60;
          id(primary_pumpout_gallon_per_hour_var) = gallons_per_hour;

          // Force the interval sensor to publish a new state
          id(primary_pumpout_gallon_per_hour).publish_state(id(primary_pumpout_gallon_per_hour_var));

          // Store the pumpout timestamp to a global variable
          id(primary_pumpout_last_timestamp_var) = execution_timestamp;
        }
  - id: backup_pumpout_increment
    mode: single
    then:
      lambda: |-
        // Save the timestamp as a local variable
        time_t execution_timestamp = id(homeassistant_time).now().timestamp;
        
        // Only exectute if at least 5 sec after bootup
        if (execution_timestamp - id(boot_time_var) > 5.0) {

          // Add one to the global integer
          id(backup_pumpout_counter_var) += 1;

          // Force the sensor to publish a new state
          id(backup_pumpout_counter).publish_state(id(backup_pumpout_counter_var));

          // Calculate the minutes since last pumpout
          id(backup_pumpout_interval_var) = difftime(execution_timestamp,id(backup_pumpout_last_timestamp_var))/60.0;

          // If over 12 hours, simply report zero since I don't care at that point and don't want my nice plots to have outrageous y-axis scaling
          if (id(backup_pumpout_interval_var) > 720.0) {
            id(backup_pumpout_interval_var) = 0.0;
          }

          // Force the interval sensor to publish a new state
          id(backup_pumpout_interval).publish_state(id(backup_pumpout_interval_var));

          // Store the pumpout timestamp to a global variable
          id(backup_pumpout_last_timestamp_var) = execution_timestamp;
        }
################################################################################
#Sensors
################################################################################
sensor:
  # Uptime sensor.
  - platform: uptime
    name: "${friendly_name} Uptime"
    id: uptime_sensor
    update_interval: 5min

  # WiFi Signal sensor.
  - platform: wifi_signal
    name: "${friendly_name} WiFi Signal"
    update_interval: 5min

  # Primary Pumpout Counter to count number of times it has turned on
  - platform: template
    name: "${friendly_name} Primary Pumpout Daily Counter"
    id: primary_pumpout_counter
    update_interval: never
    accuracy_decimals: 0
    state_class: total_increasing
    lambda: return id(primary_pumpout_counter_var);

  # Primary Pumpout Interval
  - platform: template
    name: "${friendly_name} Primary Pumpout Interval"
    id: primary_pumpout_interval
    unit_of_measurement: min
    update_interval: never
    accuracy_decimals: 2
    lambda: return id(primary_pumpout_interval_var);

  # Primary Pumpout Daily Gallons
  - platform: template
    name: "${friendly_name} Primary Pumpout Daily Gallons"
    id: primary_pumpout_gallon_counter
    unit_of_measurement: gallons
    update_interval: never
    accuracy_decimals: 2
    lambda: return id(primary_pumpout_gallon_counter_var);

  # Primary Pumpout Gallons Per Hour
  - platform: template
    name: "${friendly_name} Primary Pumpout Gallons Per Hour"
    id: primary_pumpout_gallon_per_hour
    unit_of_measurement: "gallons / hour"
    update_interval: never
    accuracy_decimals: 2
    lambda: return id(primary_pumpout_gallon_per_hour_var);

  # Backup Pumpout Counter to count number of times it has turned on
  - platform: template
    name: "${friendly_name} Backup Pumpout Daily Counter"
    id: backup_pumpout_counter
    update_interval: never
    accuracy_decimals: 0
    state_class: total_increasing
    lambda: return id(backup_pumpout_counter_var);

  # Backup Pumpout Interval 
  - platform: template
    name: "${friendly_name} Backup Pumpout Interval"
    id: backup_pumpout_interval
    unit_of_measurement: min
    update_interval: never
    accuracy_decimals: 1
    lambda: return id(backup_pumpout_interval_var);

  # ADC: 5V Supply Voltage Measurement
  - platform: ads1115
    multiplexer: 'A0_GND'
    gain: 6.144
    name: "${friendly_name} 5V Supply Voltage"
    update_interval: 1s
    accuracy_decimals: 1
    internal: false
    state_class: measurement
    device_class: voltage
    unit_of_measurement: V
    filters:
      - sliding_window_moving_average:
          window_size: 10
          send_every: 10

  # ADC: 12V Battery Voltage Measurement
  - platform: ads1115
    multiplexer: 'A2_A3'
    gain: 6.144
    name: "${friendly_name} 12V Battery Voltage"
    id: batt_voltage
    update_interval: 5s
    accuracy_decimals: 3
    internal: false
    state_class: measurement
    device_class: voltage
    unit_of_measurement: V
    filters:
      - filter_out: nan
      - sliding_window_moving_average:
          window_size: 6
          send_every: 6 # 30s
      - lambda: |-
          // Calculate the battery voltage based off the resistor divider circuit
          float batt_voltage = x*($batt_volt_high_res_val/$batt_volt_low_res_val+1.0);
          
          // Filter out the intermittent voltage spikes due to the battery trickle charger
          if ( batt_voltage < $batt_volt_charge_max && batt_voltage > $batt_volt_charge_min ) {
            batt_voltage = $batt_volt_report;
          }
          return batt_voltage;

  # ADC: Sump level sensor voltage measurement
  - platform: ads1115
    multiplexer: 'A1_A3' #A1_A3
    gain: 6.144
    name: "${friendly_name} Voltage"
    id: level_voltage
    update_interval: 100ms
    accuracy_decimals: 4
    internal: false
    state_class: measurement
    device_class: voltage
    unit_of_measurement: V
    filters:
      - filter_out: nan
      - sliding_window_moving_average:
          window_size: 10
          send_every: 10 # every 1s

  # Calculation of sensor current based on measured voltage
  - platform: template
    name: "${friendly_name} Level Sensor Current"
    id: level_current
    update_interval: 1s
    accuracy_decimals: 4
    internal: false
    state_class: measurement
    device_class: current
    unit_of_measurement: A
    lambda: return (id(level_voltage).state / $level_sensor_res_val);

  # Calculation of sump water level based on sensor current 
  - platform: template
    name: "${friendly_name} Level"
    id: level_actual
    update_interval: 1s
    accuracy_decimals: 3
    internal: false
    state_class: measurement
    device_class: ""
    unit_of_measurement: m
    lambda: |-
      // Calculate raw level from current
      float raw_level = ((id(level_current).state-$level_sensor_min_current)/($level_sensor_max_current-$level_sensor_min_current)*$level_sensor_max_depth + $level_sensor_dist_offset);
      if (raw_level < $level_sensor_dist_offset) {
        return 0.0;
      } else if (raw_level > $level_sensor_max_depth+$level_sensor_dist_offset) {
        return $level_sensor_max_depth + $level_sensor_dist_offset;
      } else {
        return (raw_level);
      }
    filters:
      - sliding_window_moving_average:
          window_size: 2
          send_every: 2 #every 2 seconds
      - delta: 0.002
      - lambda: |-
          if (x > 0) return x;
          else return 0;
    on_value_range:
      - below: $primary_pumpout_reset_height
        then:
          - lambda: |-
              //Store the timestamp for the start of pumpout in a global variable
              id(primary_pumpout_begin_var) = id(homeassistant_time).now().timestamp;
              ESP_LOGD("level", "primary_begin is %li", id(primary_pumpout_begin_var));
      - below: $backup_pumpout_reset_height
        then:
          - lambda: |-
              //Store the timestamp for the start of pumpout in a global variable
              id(backup_pumpout_begin_var) = id(homeassistant_time).now().timestamp;
              ESP_LOGD("level", "backup_begin is %li", id(backup_pumpout_begin_var));


################################################################################
# Text sensors
################################################################################
text_sensor:
  # Expose ESPHome version as sensor.
  - platform: version
    name: "${friendly_name} ESPHome Version"

  # Expose WiFi information as sensors.
  - platform: wifi_info
    ip_address:
      name: "${friendly_name} IP"
    ssid:
      name: "${friendly_name} SSID"
    bssid:
      name: "${friendly_name} BSSID"  
  
  # Snooze Expiration Friendly Time
  - platform: template
    name: "${friendly_name} Snooze Expiration"
    id: snooze_expiration_text_sensor_friendly
    update_interval: never