Outsourcing Herb Growing to My Smart Home

For years, I dreamed about growing our own herbs and veggies. But as a renter, every attempt turned into nothing more than pots filled with dirt and broken dreams.

Now that my wife Britt and I have bought our first home, complete with a gorgeous raised garden bed around an outdoor pergola, those dreams are back with a vengeance. We’re aiming to transform that garden into an automated haven of fresh herbs and chillis—with a bit of tech magic to make it as low-maintenance as possible.

The Dream

We want to stop buying expensive herb sleeves from the supermarket. Just using two sleeves a week racks up nearly $350 annually—and half the time they end up wilted in the bin. So our goal is a garden that provides fresh herbs and garnishes year-round.

But before we could plant anything, we needed to clear the old decorative plants and prep the bed. As we did, we realized buying all the seedlings from scratch was going to cost over $500. That’s when we remembered: seeds exist.

The Challenge: Growing Chillis from Seed

While herbs grow fairly easily from seed, chillis are another story. They need consistently warm soil—around 30°C—to germinate. In Victoria, Australia, that kind of consistency is hard to come by, even in summer. So we needed a way to keep the soil warm no matter the weather.

Enter the Smart Greenhouse.

Smart Greenhouse Goals

Our miniature greenhouse would serve as a nursery for our chilli seedlings. But I wasn’t content with just passive warmth—I wanted to log data, automate controls, and nerd out.

Here’s what we needed:

  • Keep soil temp near 30°C

  • Maintain at least 30% humidity

  • Automate heating, cooling, and misting

The Build

Hardware

  • Greenhouse: A standard mini greenhouse from Bunnings

  • Fan: 240V exhaust fan to vent heat and humidity

  • Heating: Waterproof 20W seedling heat mats

  • Humidity: Irrigation misters controlled via a solenoid valve

  • Controller: ESP32 board with 4 relays and mains power

  • Sensors:

    • DS18B20 temperature probes (for soil)

    • DHT22 humidity & temp sensor (for air)

Weatherproofing

All electronics went inside a sealed enclosure with cable glands. I mounted the relay board on a removable tray, soldered my sensors and wires, and made sure everything was sealed up tight.

Wiring

  • One relay for the fan

  • One for the solenoid valve (running off a 12V transformer)

  • Two for the heat mats

  • DS18B20 probes all wired to a single data pin with pull-up resistor

  • DHT22 wired separately and encased in epoxy for durability

Software & Automations

Everything is running via ESPHome and connected to Home Assistant. I created a dashboard showing live temperatures, humidity, and device status.

Controls

  • Soil Thermostat: Maintains heat mats at ~30°C

  • Air Thermostat: Kicks on fan if air temp exceeds 50°C

  • Humidity Logic: If humidity is >20% off target for 30 mins, the fan or misters activate to bring it back

  • Daily Watering: Misters run briefly each day to prevent dry soil

NOTE: I also added a function to run the fan periodically, even if no thresholds are hit, to ensure some fresh airflow inside the greenhouse throughout the day.

Everything can be configured via Home Assistant, including desired temps, humidity ranges, and mist durations.

Testing and Early Results

With just basil and coriander planted for testing, the soil temps held steady. Once we filled the greenhouse with pots and trays, the added thermal mass improved temp stability further.

After a few weeks of healthy growth, a storm hit. With insufficient weight in the base, the greenhouse was blown over, spilling the contents of all the pots. I salvaged what I could, repotted the survivors, and carried on. It seems most of what I saved has survived, but it definitely took the wind out of my otherwise very excited sails.

What’s Next

Despite setbacks, things are growing. As of now, our chillis are thriving in their automated, climate-controlled nursery.

Next up:

  • A Home Assistant-powered irrigation system with fertiliser dosing

  • A 3D-printed garden lighting setup

And if you’re not a fan of dirt and leaves, don’t worry—there are plenty more smart home and automation projects on the way.

Thanks for reading!

If you'd like to replicate this project, the YAML configuration and STL files are linked below.


Parts


YAML

esphome:
  name: "greenhouse"
  friendly_name: Greenhouse
  min_version: 2024.11.0
  name_add_mac_suffix: false

esp32:
  board: esp32dev
  framework:
    type: esp-idf

substitutions:
  update_interval: 60s
  log_level: DEBUG
  relay1_restore_mode: RESTORE_DEFAULT_OFF
  relay2_restore_mode: RESTORE_DEFAULT_OFF
  relay3_restore_mode: RESTORE_DEFAULT_OFF
  relay4_restore_mode: RESTORE_DEFAULT_OFF

logger:

api:

ota:
  - platform: esphome

wifi:
  ssid: Your_SSID
  password: Your_Password

time:
  - platform: sntp
    id: sntp_time
    servers:
      - 0.pool.ntp.org
      - 1.pool.ntp.org
      - 2.pool.ntp.org
    update_interval: 6h
    timezone: ""

one_wire:
  - platform: gpio
    pin:
      number: GPIO33
    id: x1

sensor:
  - platform: wifi_signal
    name: "WiFi Signal dB"
    id: wifi_signal_db
    update_interval: ${update_interval}
    entity_category: "diagnostic"

  - platform: copy
    source_id: wifi_signal_db
    name: "WiFi Signal Percent"
    filters:
      - lambda: return min(max(2 * (x + 100.0), 0.0), 100.0);
    unit_of_measurement: "Signal %"
    entity_category: "diagnostic"
    device_class: ""

  - platform: dallas_temp
    one_wire_id: x1
    address: 0xdc00000036a22728
    name: Probe_2
    id: probe_2
    update_interval: 30s

  - platform: dallas_temp
    one_wire_id: x1
    address: 0x7d00000033b6e328
    name: Probe_1
    id: probe_1
    update_interval: 30s

  - platform: template
    name: "Probe_Average"
    unit_of_measurement: "°C"
    update_interval: 30s
    lambda: |-
      if (isnan(id(probe_1).state) || isnan(id(probe_2).state)) {
        return NAN;
      }
      return (id(probe_1).state + id(probe_2).state) / 2.0;

  - platform: dht
    pin: GPIO4
    temperature:
      name: "Air Temp"
      id: air_temp
    humidity:
      name: "Humidity"
      id: humidity
    update_interval: 30s

switch:
  - platform: gpio
    name: "Heater_1"
    internal: true
    pin: GPIO27
    id: relay1
    restore_mode: ${relay1_restore_mode}

  - platform: gpio
    name: "Heater_2"
    internal: true
    pin: GPIO14
    id: relay2
    restore_mode: ${relay2_restore_mode}

  - platform: gpio
    name: "Fan"
    pin: GPIO12
    id: relay3
    restore_mode: ${relay3_restore_mode}

  - platform: gpio
    name: "Misters"
    pin: GPIO13
    id: relay4
    restore_mode: ${relay4_restore_mode}

light:
  - platform: status_led
    name: "Status LED"
    disabled_by_default: true
    pin: GPIO15

climate:
  - platform: thermostat
    id: heater_1
    name: "Heater_1"
    sensor: probe_1
    min_idle_time: 30s
    min_heating_off_time: 60s
    min_heating_run_time: 60s
    heat_deadband: 3
    heat_action:
      - switch.turn_on: relay1
    idle_action:
      - switch.turn_off: relay1
    visual:
      min_temperature: 15
      max_temperature: 50
      temperature_step: 1

  - platform: thermostat
    id: heater_2
    name: "Heater_2"
    sensor: probe_2
    min_idle_time: 30s
    min_heating_off_time: 60s
    min_heating_run_time: 60s
    heat_deadband: 3
    heat_action:
      - switch.turn_on: relay2
    idle_action:
      - switch.turn_off: relay2
    visual:
      min_temperature: 15
      max_temperature: 50
      temperature_step: 1

  - platform: thermostat
    id: air_temp_thermostat
    name: "Air_Temp"
    sensor: air_temp
    min_idle_time: 30s
    min_cooling_off_time: 60s
    min_cooling_run_time: 60s
    cool_deadband: 2
    cool_action:
      - switch.turn_on: relay3
    idle_action:
      - switch.turn_off: relay3
    preset:
      - name: Default
        default_target_temperature_high: 45
    visual:
      min_temperature: 20
      max_temperature: 60
      temperature_step: 1

binary_sensor:
  - platform: homeassistant
    name: "Disable Greenhouse Automation"
    entity_id: input_boolean.disable_greenhouse_automation
    id: disable_automation

  - platform: template
    name: "Fan Active"
    id: fan_active
    lambda: |-
      return id(relay3).state;

  - platform: template
    name: "Mister Active"
    id: mister_active
    lambda: |-
      return id(relay4).state;

  - platform: template
    name: "Heater_1 Active"
    id: heater_1_active
    lambda: |-
      return id(relay1).state;

  - platform: template
    name: "Heater_2 Active"
    id: heater_2_active
    lambda: |-
      return id(relay2).state;

number:
  - platform: template
    name: "Humidity Setpoint"
    id: humidity_setpoint
    optimistic: true
    restore_value: true
    min_value: 10
    max_value: 90
    step: 1
    unit_of_measurement: "%"

  - platform: template
    name: "Mister Duration"
    id: mister_duration
    optimistic: true
    restore_value: true
    min_value: 2
    max_value: 30
    step: 1
    unit_of_measurement: "s"

globals:
  - id: last_mister_time
    type: uint32_t
    restore_value: no
    initial_value: '0'

script:
  - id: humidity_fan_hold_script
    mode: restart
    then:
      - lambda: |-
          const uint32_t cooldown_ms = 10 * 60 * 1000;  // 10 minutes
          uint32_t time_since_misters = millis() - id(last_mister_time);

          if (time_since_misters < cooldown_ms) {
            ESP_LOGI("fan_hold", "Waiting for mister cooldown to finish (%d seconds remaining)", (cooldown_ms - time_since_misters) / 1000);
            return;
          }

      - if:
          condition:
            lambda: |-
              bool should_run = (
                id(humidity).state > id(humidity_setpoint).state + 20 &&
                abs(id(probe_1).state - id(heater_1).target_temperature) <= 10 &&
                abs(id(probe_2).state - id(heater_2).target_temperature) <= 10 &&
                !id(disable_automation).state
              );
              if (!should_run) {
                ESP_LOGI("fan_hold", "Fan hold skipped: humidity=%.1f, setpoint=%.1f, probe_1=%.1f, target_1=%.1f, probe_2=%.1f, target_2=%.1f, disabled=%d",
                  id(humidity).state,
                  id(humidity_setpoint).state,
                  id(probe_1).state,
                  id(heater_1).target_temperature,
                  id(probe_2).state,
                  id(heater_2).target_temperature,
                  id(disable_automation).state);
              }
              return should_run;
          then:
            - logger.log: "Humidity high, running fan for minimum 2 minutes"
            - switch.turn_on: relay3
            - delay: 2min
            - while:
                condition:
                  lambda: |-
                    return (
                      id(humidity).state > id(humidity_setpoint).state + 10 &&
                      !id(disable_automation).state
                    );
                then:
                  - logger.log: "Humidity still elevated, keeping fan ON"
                  - delay: 30s
            - lambda: |-
                if (id(air_temp_thermostat).action != CLIMATE_ACTION_COOLING) {
                  ESP_LOGI("fan_hold", "Humidity normalized and thermostat idle, turning fan OFF");
                  id(relay3).turn_off();
                } else {
                  ESP_LOGI("fan_hold", "Humidity normalized, but thermostat is cooling — leaving fan ON");
                }

interval:
  - interval: 30s
    then:
      - lambda: |-
          if (
            !id(disable_automation).state &&
            (millis() - id(last_mister_time) > 7200000) &&
            id(humidity).state < id(humidity_setpoint).state - 10
          ) {
            ESP_LOGI("mister", "Humidity low, triggering misters");
            id(last_mister_time) = millis();
            id(relay4).turn_on();
            delay(id(mister_duration).state * 1000);
            id(relay4).turn_off();
          }
Previous
Previous

The Best Smart Home Sensor Just Got Better

Next
Next

A Quick Project That’ll Save Me Hours Every Week