🌿 ESPlanty | Self-watering Solar Powered Plant | No plumbing & no powerpoints | # Irrigation , # Deep Sleep , # Battery

I’ll maintain my master config here. May not maintain it well though.

Edit: Fresh paste on 2024-01-13

#Todo: 
  #Determine night time battery loss.
  #More testing insitu
  #HA alerts for low water, low battery, data updates not working.
  #Check pump runs.
  #Monitor review wet/dry/battery thresholds.
  #Make debugging sensors internal.

substitutions:
  sleep_time: 60min
  auto_wake_time: 20s
  pump_run_time: 10s
  internal_mode: "true"
  long_deep_sleep_duration: 24h
  
  bh1750_i2c_scl_blue: GPIO27
  bh1750_i2c_sda_green: GPIO25
  
  vl53l0x_i2c_scl_blue: GPIO23
  vl53l0x_i2c_sda_green: GPIO05  
  
  power_peripheral_pin: GPIO26

  pump_switch_pin: GPIO18
  batt_voltage_pin: GPIO33
  soil_moisture_pin: GPIO34


# G                        # RST                            #1 tx         # G
# NC                       # VP                             #3 rx         #@ 27 bh1750 blue scl  ${bh1750_i2c_scl_blue}
# VN                       #@ 26 ${power_peripheral_pin}    #22           #@ 25 bh1750 green sda ${bh1750_i2c_sda_green}
# 35                       #@ 18 ${pump_switch_pin}         #21           #@ 32
#@ 33 ${batt_voltage_pin}  #@ 19                            #27           # TDI
# 34 ${soil_moisture_pin}  #@ 23 ${vl53l0x_i2c_scl_blue}    #25           #@ 4
# TMS                      #@ 5  ${vl53l0x_i2c_sda_green}   #G            # 0
# NC                       # 3v3                            #5v           # 2
# SD2                      # TCK                            #TDO          # SD1
# CMD                      # SD3                            #SDO          # CLK

esp32:
  board: ttgo-t7-v14-mini32 #pinout: https://ae01.alicdn.com/kf/Ha204b20d14d243faa0c1a8760de1b187r.jpg
esphome:
  name: "esplanty"
  friendly_name: ESPlanty
  comment: Balcony Solar Irrigation
  on_boot:    
    # #Try this if sensors don't boot. https://community.home-assistant.io/t/add-sensor-delay-upon-power-on/158567/6?u=mahko_mahko
    # https://community.home-assistant.io/t/i2c-bus-scan-after-deep-sleep-recovery/524925/7?u=mahko_mahko
    - priority: 900
      then:
      - lambda: |-
          Wire.begin();
          delay(500);
    - priority: -100
      then:
        #Reset sensor update counters. These are for debugging.
        - lambda: id(count_irrigation_lux).publish_state(0);
        - lambda: id(count_batt_voltage).publish_state(0);
        - lambda: id(count_irrigation_tof).publish_state(0);
        - delay: 1s     
        
        #Auto sensor updates are turned off and manually requested on boot. These are multi-sampled and then aggregated. 
        #The ESP then goes back to sleep when they're done (unless told to stay awake).
          #Request sensor updates        
        - logger.log: "....Starting sensor updates"
        - repeat:
            count: 5 #Update cycles
            then:
              - component.update: batt_voltage #Battery level
              - delay: 100ms
              - component.update: soil_moisture_voltage #Moisture level
              - delay: 100ms
              - component.update: tof #Water tank level
              - delay: 100ms
              - component.update: irrigation_lux #Light level
              - delay: 100ms
            #second pass for ADC based sensors which benefit from more samples.
              - component.update: batt_voltage
              - delay: 200ms
              - component.update: soil_moisture_voltage
              - delay: 200ms
              - component.update: tof #Water tank level
              - delay: 100ms

  on_shutdown: #Includes deep sleep
    priority: -100
    then:
      - script.wait: run_pump_for_pump_run_time #Wait until pump run is done.
      - if:
          condition:
            - binary_sensor.is_on: all_updates_recieved           
          then:
            - logger.log: "Data updates ok..."
          else:
            - logger.log: "A sensor might be down...missing updates" 
            - binary_sensor.template.publish:
                id: data_update_problem
                state: ON
            - delay: 1s
              
      - switch.turn_off: pump #Probably not required....
    #Turn off 5v peripheral power. It will retore as on when it wakes.
      - logger.log: "Turning off peripheral power..."
      - switch.turn_off: power_peripherals
    #Turn off the "Fresh data recieved sensors"
      - binary_sensor.template.publish:
          id: water_tank_level_recieved
          state: OFF
      - binary_sensor.template.publish:
          id: irrigation_lux_recieved
          state: OFF
      - binary_sensor.template.publish:
          id: batt_level_recieved
          state: OFF
      - binary_sensor.template.publish:
          id: solar_plant_moisture_level_recieved
          state: OFF
      - binary_sensor.template.publish:
          id: all_updates_recieved
          state: OFF
          
wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  fast_connect: true
  manual_ip:
      static_ip: 192.168.1.123
      gateway: 192.168.1.1
      subnet: 255.255.255.0

api:
ota:

logger:
  level: VERBOSE
  baud_rate: 0
  
i2c:
  - id: bh1750_i2c_bus
    sda: GPIO25 #data > green wire
    scl: GPIO27 #clock > blue wire
    scan: true
  - id: vl53l0x_i2c_bus
    sda: ${vl53l0x_i2c_sda_green} #data > green wire
    scl: ${vl53l0x_i2c_scl_blue} #clock > blue wire
    scan: true
    

deep_sleep: 
#Auto deep sleep shouldn't actually get used if data updates are happening properly (should sleep sooner).
#But if something is wrong with data updates this will kick-in to protect battery discharge.
  id: deep_sleep_1
  run_duration: ${auto_wake_time}
  sleep_duration: ${sleep_time}
  
time:
  - platform: homeassistant
    id: esptime
    on_time:
      #Deep sleep at 8pm and wake up at 6am.
      - hours: 20
        then:
          #Publish battery level to the end of day sensor since it will be our last update.
          - lambda: return id(end_of_day_battery_percent).publish_state(id(batt_level).state);
          #Reset culmulative light sensor reset
          - sensor.integration.reset: irrigation_cul_lux_hours
          - delay: 1s
          #Rest up
          - deep_sleep.enter:
              id: deep_sleep_1
              until: "06:00:00"
              time_id: esptime
              
text_sensor:
 # Reports the ESPHome Version with compile date
  - platform: version
    name: ESPHome Version
    
select:
#Watering Mode. 
  #Watering regime allows plant to become quite dry and then get a good watering,
  #as opposed to more frequent bang bang watering cycles. 
#TODO: More testing. 
  - platform: template
    name: "Watering Mode"
    id: watering_mode
    optimistic: true
    restore_value: true
    options:
      - Wetting Mode
      - Drying Mode
    initial_option: Drying Mode
    
number:
#Upper mositure target (once dry)
  - platform: template
    name: Moisture Upper Target
    id: moisture_upper_target
    icon: mdi:arrow-collapse-up
    optimistic: true
    unit_of_measurement: '%'
    min_value: 40.0
    max_value: 90.0
    step: 5.0
    restore_value: true
    initial_value: 80.0
    
#Lower Water threshold (triggers watering regime). Need to establish good values.
  - platform: template
    name: Moisture Lower Threshold
    id: moisture_lower_threshold  
    icon: mdi:arrow-collapse-down
    optimistic: true
    unit_of_measurement: '%'
    min_value: 5.0
    max_value: 60.0
    step: 5.0
    restore_value: true
    initial_value: 50.0
    
#Battery Saving Mode Threshold. Trigger longer sleep cycles if below this and hope for sunlight.
  - platform: template
    name: Battery Saving Threshold 
    id: battery_saving_mode_threshold
    icon: mdi:battery-alert
    optimistic: true
    unit_of_measurement: '%'
    min_value: 10.0
    max_value: 80.0
    step: 5.0
    restore_value: true
    initial_value: 20.0
    
#Low water tank threshold. Don't water if below this.
  - platform: template
    name: Low tank water threshold 
    id: low_tank_water_threshold
    icon: mdi:water-remove-outline
    optimistic: true
    unit_of_measurement: '%'
    min_value: 0.0
    max_value: 20.0
    step: 5.0
    restore_value: true
    initial_value: 10.0

button: 
#Deep sleep if "allowed".
  - platform: template
    name: Sleep If Allowed
    internal: ${internal_mode}
    id: sleep_if_allowed
    icon: "mdi:sleep"
    on_press:
      then:
         - if:
            condition:
              - binary_sensor.is_on: stay_awake_sensor
            then:
              - logger.log: "Sleep requested but STAY AWAKE mode is on. Skipping sleep."
              - deep_sleep.prevent: deep_sleep_1
            else:
              - logger.log: "Sleep requested and ALLOWED. Going to sleep..."     
              - deep_sleep.enter:
                  id: deep_sleep_1
                  sleep_duration: ${sleep_time}
                  
#Force Deep Sleep
  - platform: template
    name:  Force Sleep
    id: force_deep_sleep
    icon: "mdi:bell-sleep"
    internal: ${internal_mode}
    on_press:
      then:
        - logger.log: "FORCE SLEEP requested...Going to sleep"     
        - deep_sleep.enter:
            id: deep_sleep_1
            sleep_duration: ${sleep_time}

#Long Sleep (part of battery saving mode)
  - platform: template
    name:  Long Sleep
    id: long_deep_sleep
    internal: ${internal_mode}
    icon: "mdi:bell-sleep"
    on_press:
      then:
        - logger.log: "LONG SLEEP requested...Going to sleep"
        #Publish battery level to the end of day sensor since it will be our last update.
        - lambda: return id(end_of_day_battery_percent).publish_state(id(batt_level).state);
        - deep_sleep.enter:
            id: deep_sleep_1
            sleep_duration: ${long_deep_sleep_duration}
        #Reset culmulative light sesnor reset
        - sensor.integration.reset: irrigation_cul_lux_hours

binary_sensor:
#Low tank water alert
  - platform: template
    id: tank_water_level_is_ok
    name: Tank Level Ok
    icon: mdi:water-remove-outline
    device_class: moisture
    lambda: |-
      if (id(water_tank_level).state > id(low_tank_water_threshold).state) 
          {return true;}
        else if (id(water_tank_level).state <= id(low_tank_water_threshold).state) 
          {return false;}
        else
          return {};


    
#Data updates alert. I've had the lux/tof playing up. Seems to be when battery < ~ 40%
  - platform: template
    id: data_update_problem
    name: Sensor Problem
    icon: mdi:database-alert-outline
    device_class: problem
    entity_category: diagnostic

#Low Battery alert    
  - platform: template
    id: battery_level_is_low
    name: Battery Level Is Low 
    icon: mdi:battery-alert
    lambda: |-
      if (id(batt_level).state < id(battery_saving_mode_threshold).state) 
          {return true;}
        else if (id(batt_level).state >= id(battery_saving_mode_threshold).state) 
          {return false;}
        else
          return {};
    on_press:
      then:
        - button.press: long_deep_sleep #Long deep sleep if battery low.
        
#Pump Sensor.    
  - platform: template
    id: pump_is_on
    name: Pump is On 
    icon: mdi:water-pump
    device_class: running
    lambda: |-
      return id(pump).state;

##################################################
#Data Update Sensors. 
  #These were useful during dev. Might remove in prod.
  #All updated via other sensors.
##################################################
#Moisture
  - platform: template
    name: Moisture updated
    id: solar_plant_moisture_level_recieved
    internal: ${internal_mode}
#Battery Level
  - platform: template
    name: Battery level updated
    id: batt_level_recieved
    internal: ${internal_mode}
#Light
  - platform: template
    name: Lux updated
    id: irrigation_lux_recieved
    internal: ${internal_mode}
#Water level
  - platform: template
    name: water level updated
    id: water_tank_level_recieved
    internal: ${internal_mode}
#All
  - platform: template
    name: Data updates done
    id: all_updates_recieved
    icon: mdi:database-refresh-outline
    entity_category: diagnostic
    lambda: |-
      return
      id(solar_plant_moisture_level_recieved).state &&
      id(batt_level_recieved).state &&
      id(irrigation_lux_recieved).state &&
      id(water_tank_level_recieved).state
      ;
    on_press:
      then:
        - binary_sensor.template.publish:
            id: data_update_problem
            state: OFF
      #Log that updates are all done
        - logger.log:
            format: "All sensors updated after %.1f secs of uptime. Checking if watering is required"
            args: [ 'id(uptime_sec).state']
        - delay: 100ms
      #Water if required and allowed then go to sleep. Note watering will only happen if total system is healthy (by design).
        - if:
            condition:
              and:
                #Check if our watering regime wants a water dose
                - lambda: return (id(watering_mode).state == "Wetting Mode");
                - binary_sensor.is_off: battery_level_is_low # Not in battery saving mode.
                - binary_sensor.is_on: tank_water_level_is_ok #Tank Water is ok
                - binary_sensor.is_on: auto_water_sensor #Auto water is enabled
            then:
              - logger.log: "Watering required and allowed" 
              - switch.turn_on: pump
              - delay: ${pump_run_time}
              - delay: 1s
              - button.press: sleep_if_allowed
            else:
              - logger.log: "Watering not permitted!!.." #todo log reason? 
              - button.press: sleep_if_allowed
               
##################################################
#Deep sleep control
##################################################
#A lot of my logic is remashes of:
  #https://www.wirewd.com/make/blog/esphome_sleep_modes
  #https://tatham.blog/2021/02/06/esphome-batteries-deep-sleep-and-over-the-air-updates/

#Import HA Deep sleep control
  - platform: homeassistant
    name: "Stay Awake"
    internal: false #I want to know that everyone is talking to each other;)
    id: "stay_awake_sensor"
    entity_id: input_boolean.keep_esps_awake_switch_ha
    icon: "mdi:sleep-off"
    entity_category: diagnostic
    on_press:
      then:
        - logger.log: "STAY AWAKE requested from HA. Preventing deep sleep"
        - deep_sleep.prevent: deep_sleep_1
    on_release:
      then:
        - logger.log: "STAY AWAKE TURNED OFF. Going to sleep..."     
        - deep_sleep.enter:
            id: deep_sleep_1
            sleep_duration: ${sleep_time}


#Import Auto Water control
  - platform: homeassistant
    name: Auto Water
    internal: false
    id: "auto_water_sensor"
    entity_id: input_boolean.auto_water_planty
    icon: "mdi:auto-fix"
        

script:

##################################################
#Pump auto off timer
################################################## 
  - id: run_pump_for_pump_run_time
    then:
      - logger.log: "Running pump for ${pump_run_time}"
      - delay: ${pump_run_time}
      - switch.turn_off: pump

switch:
##################################################
#Pump
##################################################   
  - platform: gpio
    pin: ${pump_switch_pin}
    id: pump
    name: Pump 
    icon: mdi:water-pump
    internal: false
    restore_mode: ALWAYS_OFF
    on_turn_on: #Auto off timer. Flood protection.
      - script.stop: run_pump_for_pump_run_time # Stop any existing timers.
      - script.execute: run_pump_for_pump_run_time # Start new timer


##################################################
#Control peripheral power (on solar power manager)
##################################################
  - platform: gpio
    id: power_peripherals 
    name: Power Peripherals     
    pin: ${power_peripheral_pin}
    internal: true
    restore_mode: ALWAYS_ON #Power on when waking from sleep.

globals:
#For daily battery level change (below). i.e net energy production or loss.
#Globals survive deep sleep
  - id: previous_value
    type: float
    restore_value: yes
    initial_value: '0.0'

sensor:
#Track end of day battery
  - platform: template
    name:  End of Day Battery
    id: end_of_day_battery_percent
    update_interval: never
    icon: "mdi:solar-power"
    unit_of_measurement: '%'
    accuracy_decimals: 3
    on_value:
      then:
        #Calculate change in end of day battery and publish to sensor
        - lambda: id(change_in_end_of_day_battery_percent).publish_state(x - id(previous_value));
        #Set previous value as current value.
        - lambda: |-
            id(previous_value) = x;
                
#Track daily changes in battery (i.e net solar production)
  - platform: template
    name: Change in End of Day Battery   
    id: change_in_end_of_day_battery_percent
    icon: mdi:trending-up
    internal: false
    unit_of_measurement: '%'
    accuracy_decimals: 3

#Uptime sensor
  - platform: uptime
    id: uptime_sec
    name: Uptime Sensor 
    update_interval: 2s
    accuracy_decimals: 0
    unit_of_measurement: s

##################################################
#For counting data updates recieved for each wake cycle.
#Manually updated via publishing from other sensors.
##################################################
#Track Lux updates
  - platform: template
    name: "Count Lux Updates"
    id: count_irrigation_lux
    icon: "mdi:counter"
    unit_of_measurement: count
    entity_category: diagnostic
#Track ToF updates
  - platform: template
    name: "Count ToF Updates"
    id: count_irrigation_tof
    unit_of_measurement: count
    entity_category: diagnostic
#Track battery updates
  - platform: template
    name: "Count Batt V Updates"
    id: count_batt_voltage
    unit_of_measurement: count
    icon: "mdi:counter"
    entity_category: diagnostic
    
##########################################################################################
# Time of Flight sensor  - i2c
##########################################################################################
#Powered via 5v
  - platform: vl53l0x
    id: tof
    i2c_id: vl53l0x_i2c_bus
    # setup_priority: 300
    name:  ToF
    internal: false 
    address: 0x29
    timeout: 300ms
    update_interval: never
    entity_category: diagnostic
    #never
    # enable_pin: GPIO17 #Did not work: https://github.com/esphome/issues/issues/3644
    accuracy_decimals: 1
    unit_of_measurement: 'cm'
    filters:
      - multiply: 100 #Convert to cm
      - median: #Moving median to smooth noise.  Sample 5 points then push.
          window_size: 5
          send_every: 5
          send_first_at: 5
    on_raw_value:
      then:       #Push sensor update counter.
        - lambda: id(count_irrigation_tof).publish_state(id(count_irrigation_tof).state +1); 
      
#Convert the ToF distance to a water tank level (% full)
  - platform: copy
    source_id: tof
    id: water_tank_level
    internal: false
    # icon: "mdi:battery"
    name:  Water Tank Level
    unit_of_measurement: '%'
    accuracy_decimals: 1
    entity_category: ''
    filters:
      # Map from distance to % full. To calibrate.
      - calibrate_linear:
          - 3 -> 100 
          - 19.5 -> 0
      ##Overide values less than 0% and more than 100%. Round to 0.5%.
      - lambda: |
          if (x > 100) return 100; 
          else if (x < 0) return 0;
          else return ceil(x / 5) * 5;
    on_value:
      then:
       - binary_sensor.template.publish:
          id: water_tank_level_recieved
          state: ON

##########################################################################################
# bh1750 Lux/light sensor
##########################################################################################     
  - platform: bh1750
    id: irrigation_lux
    i2c_id: bh1750_i2c_bus
      # - id: bh1750_i2c_bus
    # sda: GPIO25 #data > green wire
    # scl: GPIO27 #clock > blue wire
    # scan: true
  # - id: vl53l0x_i2c_bus
    name: Lux
    address: 0x23
    update_interval: never
    filters:
      - median:  #Use moving median to smooth noise. Sample 5 points then send.
          window_size: 5
          send_every: 5
          send_first_at: 5
    on_value:
      then:
       - binary_sensor.template.publish:
          id: irrigation_lux_recieved
          state: ON
    on_raw_value:
      then:
      #Sensor update counter.
        - lambda: id(count_irrigation_lux).publish_state(id(count_irrigation_lux).state +1);

#Measure of total light per day. Poor persons DLI https://en.wikipedia.org/wiki/Daily_light_integral
  - platform: integration
    name: "Light per day"
    id: irrigation_cul_lux_hours
    sensor: irrigation_lux
    time_unit: s
    unit_of_measurement: lux*s
    restore: true
    accuracy_decimals: 0

#Notes:
#Voltage divider: Used 2 x 300K Ohm resistors
  - platform: adc
    id: batt_voltage
    name: Battery Voltage
    internal: true
    pin: ${batt_voltage_pin} #ADC1
    update_interval: never
    accuracy_decimals: 2
    attenuation: auto
    filters:
      # #Scale it back up from voltage divided value 2 x 300K > 2.1. 4.2/2.1 = 2.
      - multiply: 2
    on_raw_value:
      then:
      #Sensor update counter.
        - lambda: id(count_batt_voltage).publish_state(id(count_batt_voltage).state +1);
      
#Intermediate sensor. Might consolidate them later.
  - platform: copy
    source_id: batt_voltage
    id: batt_voltage_filtered
    icon: "mdi:battery"
    internal: false
    name:  Battery Voltage
    unit_of_measurement: V
    accuracy_decimals: 2
    filters:
      - median: #Use moving median to smooth noise.
          window_size: 10
          send_every: 10
          send_first_at: 10
       
#Convert the Voltage to a battery  level (%)
  - platform: copy
    source_id: batt_voltage_filtered
    id: batt_level
    internal: false 
    icon: "mdi:battery"
    name: Battery Percent
    unit_of_measurement: '%'
    accuracy_decimals: 0
    filters:
      # Map from voltage to Battery level
      - calibrate_linear:
          - 3.1 -> 0 #Set 3.0 to 0% even though it can go lower (2.4V), for life extention. There's not much capacity below this anyway.
          - 4.1 -> 100 #Set 4.05 to 100% even though it can go higher (~4.2V), for life extention.
       
      #Overide values less than 0% and more than 100%
      - lambda: |
          if (x < 0) return 0; 
          else if (x > 100) return 100;
          else return ceil(x / 5) * 5;
    on_value:
      then:
      #Publish that data is recieved
       - binary_sensor.template.publish:
          id: batt_level_recieved
          state: ON
          
#Capacitive soil moisture sensor: https://www.aliexpress.com/item/32832538686.html?spm=a2g0o.order_list.0.0.55771802WgNqEA
#Voltage of the Capacitive soil moisture sensor
  - platform: adc
    name: Soil Moisture Volts 
    id: soil_moisture_voltage    
    pin: ${soil_moisture_pin}
    internal: false 
    accuracy_decimals: 1
    update_interval: never
    # never
    attenuation: auto
    filters:
      - median:     #Use moving median to deal with noise. Sample 10, push 1.
          window_size: 10
          send_every: 10
          send_first_at: 10

#Convert the Voltage to a moisture  level (%)
  - platform: copy
    source_id: soil_moisture_voltage
    id: solar_plant_moisture_level
    name: Soil Moisture
    internal: false     
    icon: "mdi:water-percent"
    unit_of_measurement: '%'
    accuracy_decimals: 1
    filters:
      #max and min values taken from testing with glass of water. Prob need to do in-situ tests.
      - calibrate_linear:
          - 1.66 -> 100.0
          - 2.90 -> 0.0
      #Handle/cap boundaries
      - lambda: |
          if (x < 0) return 0; 
          else if (x > 100) return 100;
          else return (x);
    on_value:
      then:
      #Data update recieved.
       - binary_sensor.template.publish:
          id: solar_plant_moisture_level_recieved
          state: ON
      #Help manage the watering mode.
      #TODO: Monitor/test
       - lambda: |-
           if (x < id(moisture_lower_threshold).state) 
              { return id(watering_mode).publish_state("Wetting Mode");}
           else if (x > id(moisture_upper_target).state)
              { return id(watering_mode).publish_state("Drying Mode");}
    on_value_range:
      #Help manage the watering mode.
      - below: !lambda return id(moisture_lower_threshold).state;
        then:
          - lambda: return id(watering_mode).publish_state("Wetting Mode");      
      - above: !lambda return id(moisture_upper_target).state;
        then:
          - lambda: return id(watering_mode).publish_state("Drying Mode");  

3 Likes