Motor on a roller blind - ESPHome version?

The secrets.yaml file contains the actual values for everything marked as !secret in the master yaml file. You don’t need it if you just replace all the !secret marks with their appropriate values. As for mqtt, if you’re not using one then I think you can not have those lines and rely on HA instead

So i got your setup build and now testing, calibration mode seems to be very hard to get to

esphome:
  name: gardin_kontor
  platform: ESP8266
  board: nodemcuv2
  esp8266_restore_from_flash: true
  on_boot:
    then:
      - if:
          condition:
#           shade thinks it's at 0 but endstop is not triggered
            lambda: "if ((id(stepper_position) == 0) && (id(endstop).state == 0)) { return 1; } else { return 0; }"
          then:
#             open to blind to calibrate the endstop, remember the steps to do so, put the blind back to where it was
            - globals.set:
                id: stepper_state
                value: '1'
#             set the assumed stepper position to stepper_steps + 4000 and have it travel to 0
            - stepper.report_position:
                id: my_stepper
                position: !lambda "return id(stepper_steps) + 4000;"
            - stepper.set_target:
                id: my_stepper
                target: 0
#             wait until the stepper is no longer moving
            - wait_until:
                - lambda: "if (id(my_stepper).current_position == id(my_stepper).target_position) { return 1; } else { return 0; }"
#             now move the blind back to however many steps it took to reach the endstop
            - stepper.set_target:
                id: my_stepper
                target: !lambda "return id(stepper_steps) + 4000 - id(stepper_prevpos);"
            - globals.set:
                id: stepper_state
                value: '1'
            - delay: !lambda "return ((id(my_stepper).target_position - 834) * 2) + 3334;"
            - globals.set:
                id: stepper_position
                value: !lambda "return id(my_stepper).target_position;"
            - globals.set:
                id: stepper_state
                value: '0'

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
 
  ap:
    ssid: !secret ap_ssid
    password: !secret ap_password
    
# Enable Home Assistant API
api:

captive_portal:

web_server:
  port: 80

# Enable logging
logger:

ota:

globals:
  - id: stepper_steps
    type: int
    initial_value: '0'
    restore_value: true
  - id: stepper_prevpos
    type: float
    initial_value: '0'
  - id: stepper_position
    type: float
    initial_value: '0'
  - id: stepper_state
    type: int
    initial_value: '0'
  - id: calib_state
    type: int
    initial_value: '0'

sensor:
  - platform: uptime
    name: "Node uptime"

switch:
  - platform: restart
    name: "Restart"

binary_sensor:
  - platform: gpio
    pin:
      number: D5
      mode: INPUT_PULLUP
    name: "Open"
    filters:
        - invert:
        - delayed_on: 10ms
    on_click:
    - min_length: 50ms
      max_length: 1000ms
      then:
        - lambda: |
            if ((id(stepper_state) == 0) && (id(endstop).state == 0)) {
              //shade is stopped and not already open at endstop
              id(blind).open();
            } else {
              //shade is moving
              id(blind).stop();
            }

  - platform: gpio
    pin:
      number: D6
      mode: INPUT_PULLUP
    name: "Close"
    filters:
        - invert:
        - delayed_on: 10ms
    on_click:
    - min_length: 50ms
      max_length: 1000ms
      then:
        - lambda: |
            if ((id(stepper_state) == 0) && (id(stepper_position) < id(stepper_steps))) {
              //shade is stopped and not already closed
              id(blind).close();
            } else {
              //shade is moving
              id(blind).stop();
            }
#     a 10s press on the down button will enter calibration mode
#     shutter goes all the way up and then travels down until a button is pressed
#     where shutter is stopped is the new stepper_steps value
    - min_length: 5000ms
      max_length: 15000ms
      then:
        - lambda: |
            id(calib_state) = 1;
            if ((id(stepper_state) == 0) && (id(endstop).state == 0)) {
              //shade is stopped and not already open at endstop
              id(blind).open();
            } else if ((id(stepper_state) == 0) && (id(endstop).state == 1)) {
              //shade is stopped and already open at endstop
              id(blind).close();
            }

  - platform: gpio
    pin:
      number: D7
      mode: INPUT_PULLUP
    name: "Endstop"
    id: endstop
    filters:
        - invert:
    on_press:
      then:
#         hit the endstop, store the current steps value and reset stepper position to 0
        - globals.set:
            id: stepper_prevpos
            value: !lambda "return id(my_stepper).current_position;"
        - stepper.report_position:
            id: my_stepper
            position: 0
#         just in case, also set the target to 0
        - stepper.set_target:
            id: my_stepper
            target: 0
        - lambda: |
            if (id(stepper_state) == 1) {
              //shade is moving
              id(blind).stop();
            }

cover:
  - platform: template
    name: "Gardin Kontor"
    id: blind
    open_action:
      - globals.set:
          id: stepper_state
          value: '1'
#       overshoot past the endstop to make sure it's hit (endstop will stop blind)
      - stepper.set_target:
          id: my_stepper
          target: -4000
#       wait until the stepper is no longer moving
      - wait_until:
          - lambda: "if (id(my_stepper).current_position == id(my_stepper).target_position) { return 1; } else { return 0; }"
      - globals.set:
          id: stepper_position
          value: !lambda "return id(my_stepper).target_position;"
      - globals.set:
          id: stepper_state
          value: '0'
      - if:
          condition:
#           reached past the 'top' but still haven't triggered the endstop yet
            lambda: "if(id(stepper_position) == -4000 && id(endstop).state == 0) { return 1; } else { return 0; }"
          then:
#           reset stepper position to 0
          - stepper.report_position:
              id: my_stepper
              position: 0
#           just in case, also set the target to 0
          - stepper.set_target:
              id: my_stepper
              target: 0
    close_action:
      - globals.set:
          id: stepper_state
          value: '1'
      - stepper.set_target:
          id: my_stepper
#         for calibration the number of steps is doubled
          target: !lambda "if(id(calib_state) == 0) { return id(stepper_steps); } else { return id(stepper_steps) * 2; }"
#       wait until the stepper is no longer moving
      - wait_until:
          - lambda: "if (id(my_stepper).current_position == id(my_stepper).target_position) { return 1; } else { return 0; }"
      - globals.set:
          id: stepper_position
          value: !lambda "return id(my_stepper).target_position;"
      - globals.set:
          id: stepper_state
          value: '0'
      - lambda: |
          if (id(calib_state) == 1) {
            //reached double the current stepper distance
            id(stepper_steps) = id(stepper_position);
            //continue with calibration
            id(blind).close();
          }
    stop_action:
      - stepper.set_target:
          id: my_stepper
          target: !lambda "return id(my_stepper).current_position;" 
      - globals.set:
          id: stepper_position
          value: !lambda "return id(my_stepper).current_position;"
      - globals.set:
          id: stepper_state
          value: '0'
      - lambda: |
          if (id(calib_state) == 1 && id(endstop).state == 0) {
            //stopping at something other than the endstop
            id(calib_state) = 0;
            id(stepper_steps) = id(stepper_position);
          }
      - cover.template.publish:
          id: blind
          position: !lambda "return 1 - (id(stepper_position) / id(stepper_steps));"
    position_action:
      - stepper.set_target:
          id: my_stepper
          target: !lambda "return id(stepper_steps) * (1 - pos);"
    optimistic: true
    assumed_state: true
    has_position: true
    
stepper:
  - platform: a4988
    id: my_stepper
    dir_pin: D3
    step_pin: D2
    max_speed: 500 steps/s

    # Optional:
    sleep_pin: D1
    acceleration: 300
    deceleration: 300

1 Like

the switch’s do register correct in the small web portal ie up/down and endstop

but holding the down for 10 sec with the endstop activated does nothing

If anyone is interested, I’ve had this setup working reliably for two months or so.

esphome:
  name: projector_vs_screen
  platform: ESP32
  board: nodemcu-32s

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_pass
  # manual_ip:
  #   static_ip: 192.168.107.22
  #   gateway: 192.168.107.1
  #   subnet: 255.255.255.0
  ap:
    ssid: "Projector Screen Basement (VS)"
    password: "XXXX"

captive_portal:

api:
  reboot_timeout: 60s
  
ota:
logger:

output:
  - platform: gpio
    id: down
    pin: GPIO12
  - platform: gpio
    id: up
    pin: GPIO14
  - platform: ledc
    id: pwm
    pin: GPIO27
    
switch: #drive screen up or down at full speed, when so desired
  - platform: output
    name: "Lower"
    id: down_1
    output: down
    on_turn_on:
      then:
        - output.turn_on: pwm
  - platform: output
    name: "Raise"
    id: up_1
    output: up
    on_turn_on:
      then:
        - output.turn_on: pwm
  - platform: restart
    name: "Reboot Projector Node"

sensor: #encoder
  - platform: rotary_encoder
    name: "projector encoder"
    pin_a: 
      number: GPIO5
      mode: INPUT_PULLDOWN
    pin_b: 
      number: GPIO17
      mode: INPUT_PULLDOWN
    id: step_counter
    internal: true #having it not show up in the HA GUI seems to help with capture accuracy
    resolution: 1

cover: #screen motor control
  - platform: template
    name: "Projector"
    id: projector_screen
    optimistic: true
    open_action:
      - output.turn_off: down
      - output.turn_on: pwm
      - output.set_level:
          id: pwm
          level: "80%"
      - output.turn_on: up
      - wait_until:
          lambda: 'return id(step_counter).state >= 0;' #rolled up
      - switch.turn_off: up_1
      - delay: 1s
      - sensor.rotary_encoder.set_value:
          id: step_counter
          value: 0
    close_action:
      - output.turn_off: up
      - output.turn_on: pwm
      - output.set_level:
          id: pwm
          level: "50%"
      - output.turn_on: down
      - wait_until:
          lambda: 'return id(step_counter).state <= -8000;'
      - switch.turn_off: down_1
      - delay: 1s
      - sensor.rotary_encoder.set_value:
          id: step_counter
          value: -8000 #fully unrolled
    stop_action:
      - output.turn_off: pwm
      - output.turn_off: down
      - output.turn_off: up

It’s driving a 100inch (diagonal) projector screen, which is clearly a lot heavier than your typical shade, so your motor wouldn’t need the level of gearing mine has.

Basically, I have a 24v DC motor with a worm gear box and shaft encoder connected to one end of the projector. Using an esp32 and a VNH5019 motor driver (which is more powerful than I need, but it was the only 24v capable driver I had on hand), I setup a cover to open/close (i.e.,roll down/roll up) the screen. In my case, 0 is ‘rolled up’ and -8000 is rolled down. The encoder on the shaft tracks these numbers (my cover does reset the encoder’s count to be exactly the 0 or -8000, cause it does over/undershoot by a few steps, but not to the degree where it is visibly noticeable).

LEDC is a PWM implementation specific to the esp32: I don’t believe you can use it with an 8266, but esphome has a comparable PWM component for 8266. LEDC basically let’s be manipulate the PWM frequency and/or duty cycle. In my case, you can see me changing the duty cycle in the cover actions (e.g., level: “50%” is a 50% duty cycle). I found that, by reducing the duty cycle (which you can kind of think of like reducing the “power”), I get a more accurate reading from the encoder. Obviously I give a high duty cycle when the motor is trying to roll the project screen back up vs. lowering it (80% vs. 50%).

Like I said, it works consistently. I’m still experimenting with getting a proper PID in place, but in the meantime…

Items:

2 Likes
    - lambda: |
        id(calib_state) = 1;
        if ((id(stepper_state) == 0) && (id(endstop).state == 0)) {
          //shade is stopped and not already open at endstop
          id(blind).open();
        } else if ((id(stepper_state) == 0) && (id(endstop).state == 1)) {
          //shade is stopped and already open at endstop
          id(blind).close();
        }

when i start calibration with endstop active nothing happens
with the endstop not active it does go up but it never goes down to do the actual calibration

Calibration Mode: With the blind at full open, press and hold the Down (close) button for 10s. The blind will start going Down (close) until a button is pressed.

that is not the case, at full open the endstop would be active, and if it is and i enter calibration mode it will not go anywhere

I’m having the same issue, did you manage to find any resolution?

i found this github repro: https://github.com/RoadkillUK/Motor-on-a-Roller-Blind-for-ESPHOME

i combined the tesblind.yaml and common_blind.yaml to one file and came up with this:


# 1) Press button for > 1 second to enter setup mode
# 2) Press button again to start the blind closing
# 3) Press button again when closed and blind starts to open (actually resets the stepper position to 0)
# 4) Press button again when blind is fully open
# 5) Job Done

# Button is also used to open/close the blind (must be fully open/closed first)

esphome:
  name: gardin_kontor
  platform: ESP8266
  board: nodemcuv2
  esp8266_restore_from_flash: True
  on_boot:
    - priority: -200.0
      then:
      - stepper.report_position: # Set stepper to global variable
          id: my_stepper
          position: !lambda return id(my_stepper_global);
      - stepper.set_target: # Set stepper to global variable
          id: my_stepper
          target: !lambda return id(my_stepper_global);
      - if: # If blind is Closed
          condition:
            - lambda: 'return id(my_stepper_global) == 0;'
          then: # Publish state etc.
            - cover.template.publish:
                id: blinded
                state: CLOSED
                current_operation: IDLE
      - if: # If blind is Open
          condition:
            - lambda: 'return id(my_stepper_global) == id(endstop);'
          then: # Publish state etc.
            - cover.template.publish:
                id: blinded
                state: OPEN
                current_operation: IDLE
      - if: # If blind is Neither
          condition:
            - lambda: 'return (id(my_stepper_global) != 0) && (id(my_stepper_global) != id(endstop));'
          then: #  # Publish state etc.
            - cover.template.publish:
                id: blinded
                position: !lambda 'return (float(float(id(my_stepper).current_position) / float(id(endstop))));' 
                current_operation: IDLE

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  ap:
    ssid: Roller Blind
    password: !secret ap_password

web_server:
  port: 80

logger:

api:
  

ota:
  

captive_portal:

stepper:
  - platform: a4988
    id: my_stepper
    dir_pin:
      number: D3
      inverted: True
    step_pin: D2
    sleep_pin: D1
    max_speed: 500 steps/s # Set the speed of the motor
    acceleration: 300
    deceleration: 300

globals:
  - id: my_stepper_global # Integer for storing the stepper position in case of reboot
    type: int
    restore_value: True
    initial_value: '0'

  - id: openclosed # Boolean to store OPEN/CLOSED state
    type: bool
    restore_value: True
    initial_value: '0'

  - id: endstop # Variable for storing ENDSTOP (how far to move stepper)
    type: int
    restore_value: True
    initial_value: '1000'

  - id: settingmode # Integer for Setup Mode
    type: int
    restore_value: no
    initial_value: '0'

binary_sensor:
  - platform: gpio
    pin:
      number: D6 # Connect Button to D6 and GND
      mode: INPUT_PULLUP
      inverted: True
    name: Button
    internal: True
    on_click:
    - min_length: 50ms
      max_length: 500ms
      then: # Short press to OPEN/CLOSE blinds and also for setting up
        - if: # If settings variable is on
            condition:
              - lambda: 'return id(settingmode) != 0;'
            then: # Enter Setting Mode
              - script.execute: setupbutton
            else:
              - if: # If blind is closed
                  condition:
                    - lambda: 'return id(openclosed) == 0;'
                  then: # Open blind
                    - cover.open: blinded
                  else: # Close blind
                    - cover.close: blinded
    - min_length: 1000ms
      max_length: 3000ms
      then: # Long press to Enter Setting Mode
        - logger.log: "Entered Settings Mode"
        - globals.set:
            id: settingmode
            value:  '1'

switch:
  - platform: template
    name: Roller Blind Setup Switch # Switch to enter Setup Mode
    id: setupswitch
    lambda: |-
      if (id(settingmode) != 0) {
        return true;
      } else {
        return false;
      }
    turn_on_action:
      then:
        - logger.log: "Entered Settings Mode"
        - globals.set:
            id: settingmode
            value:  '1'
    turn_off_action:
      then:
        - logger.log: "Exiting Settings Mode"
        - globals.set:
            id: settingmode
            value:  '0'
  - platform: template
    name: Roller Blind Setup Button # Switch to replicate the Physical Button
    id: hasetup
    turn_on_action:
      - if: # If settings variable is on
          condition:
            - lambda: 'return id(settingmode) != 0;'
          then: # Enter Setting Mode
            - script.execute: setupbutton
            - switch.turn_off: hasetup

cover:
  - platform: template
    name: "Gardin Kontor"
    id: blinded
    open_action:
      then:
        - logger.log: "Opening"
        - stepper.set_target: # Send stepper to endstop
            id: my_stepper
            target: !lambda return id(endstop);
        - while:
            condition:
              lambda: 'return id(my_stepper).current_position != id(endstop);'
            then:
              - cover.template.publish:
                  id: blinded
                  position: !lambda 'return (float(float(id(my_stepper).current_position) / float(id(endstop))));' 
                  current_operation: OPENING
              - delay: 1000 ms
        - globals.set: # Set global to current position
            id: my_stepper_global
            value: !lambda return id(my_stepper).current_position; 
        - globals.set: # Set toggle to OPEN (No need for 'optimistic mode')
            id: openclosed
            value: '1'
        - cover.template.publish:
            id: blinded
            state: OPEN 
            current_operation: IDLE
    close_action:
      then:
        - logger.log: "Closing"
        - stepper.set_target: # Send stepper to 0
            id: my_stepper
            target: '0'
        - while:
            condition:
              lambda: 'return id(my_stepper).current_position != 0;'
            then:
              - cover.template.publish:
                  id: blinded
                  position: !lambda 'return (float(float(id(my_stepper).current_position) / float(id(endstop))));' 
                  current_operation: CLOSING
              - delay: 1000 ms
        - globals.set: # Set global to current position
            id: my_stepper_global
            value: !lambda return id(my_stepper).current_position; 
        - globals.set: # Set toggle to CLOSED (No need for 'optimistic mode')
            id: openclosed
            value: '0'
        - cover.template.publish:
            id: blinded
            state: CLOSED
            current_operation: IDLE
    position_action:
      then:
        - stepper.set_target:
            id: my_stepper
            target: !lambda return int(id(endstop) * pos);
        - while:
            condition:
              lambda: 'return id(my_stepper).current_position != int(id(endstop) * pos);'
            then:
              - cover.template.publish:
                  id: blinded
                  position: !lambda 'return (float(float(id(my_stepper).current_position) / float(id(endstop))));' 
              - delay: 1000 ms
        - globals.set: # Set global to current position
            id: my_stepper_global
            value: !lambda return id(my_stepper).current_position; 
        - cover.template.publish:
            id: blinded
            position: !lambda 'return (float(float(id(my_stepper).current_position) / float(id(endstop))));' 
            current_operation: IDLE
    stop_action:
      then:
        - stepper.set_target:
            id: my_stepper
            target: !lambda return id(my_stepper).current_position;
        - globals.set: # Set global to current position
            id: my_stepper_global
            value: !lambda return id(my_stepper).current_position;
        - cover.template.publish:
            id: blinded
            position: !lambda 'return (float(float(id(my_stepper).current_position) / float(id(endstop))));' 
            current_operation: IDLE
    has_position: true
    device_class: blind

script:
  - id: setupbutton
    then:
      - if:
          condition:
            - lambda: 'return (id(settingmode) == 3);'
          then:
            - logger.log: "Pressed Setup Button: Mode 3"
            - stepper.set_target: # Set Stepper position
                id: my_stepper
                target: !lambda return id(my_stepper).current_position;
            - globals.set: # Set Endstop Variable
                id: endstop
                value: !lambda return id(my_stepper).current_position;
            - globals.set: # Set Global stepper position
                id: my_stepper_global
                value: !lambda return id(my_stepper).current_position;
            - globals.set: # Reset Setting Mode
                id: settingmode
                value:  '0'
            - globals.set: # Set toggle to Open
                id: openclosed
                value: '1'
            - cover.template.publish:
                id: blinded
                state: OPEN 
                current_operation: IDLE
            - logger.log: "Exiting Setting Mode"
      - if:
          condition:
            - lambda: 'return (id(settingmode) == 2);'
          then:
            - logger.log: "Pressed Setup Button: Mode 2"
            - stepper.report_position: # Reset Stepper position to 0
                id: my_stepper
                position: '0'
            - stepper.set_target: # Reset Stepper position to 0
                id: my_stepper
                target: '0'
            - globals.set: # Move stepper to 0 (doesn't move it's already there!)
                id: my_stepper_global
                value: '0'
            - stepper.set_target: # Reset Stepper position to 72000
                id: my_stepper
                target: '72000'
            - globals.set: # Advance setup to next mode
                id: settingmode
                value:  '3'
      - if:
          condition:
            - lambda: 'return (id(settingmode) == 1);'
          then:
            - logger.log: "Pressed Setup Button: Mode 1"
            - stepper.report_position: # Set Stepper position to 72000, makes it move to 0 (Closed)
                id: my_stepper
                position: '72000'
            - globals.set: # Advance setup to next mode
                id: settingmode
                value:  '2'

the thing that is lost is that it goes up to endstop on boot, but the calibration works
and there is only one hardware button. So in the end not a great stepback considered i have tested it

the only thing to test is whatever one or 2 stepper motors are needed.

the printed parts i found on thingiverse but cant remember what thing.

EDIT: https://www.thingiverse.com/thing:2065722 was the one, i dont use the box thou.
and the part that goes in to the roller blind has to be adjusted to fit. if only one stepper is needed i also made a “dummy” version that just has a 608 bearing in it. in that way its the same in both ends. looks symetric. i then designed a bracket that can holde the stepper. the bracket is 2 parts where one parts slides in sideways. that makes it easy to the the whole blind down when cleaning

2 Likes

Glad I could help :wink:

yeah, i’m no good at programming but i managed to melt the 2 files together

i thought for a long time that it would be a shame to loose the endstop but not when you think long enough. and it makes the wiring more simple

If you put the 2 files in esphome directory, you only have to change/copy the testblinds.yaml to add more blinds (seperate esp’s)

I’m not the best programmer, I forget more than I learn :slight_smile:

I don’t actually use any physical buttons on the blind itself, I just use the webpage instead. It actually works quite well as I use it on 4 blinds and one of them got stuck last week and lost it’s calibration, sorted in no time at all.

The repo you found is actually further up in this post :slight_smile:

Im using this print I put together from others I found. I use it to pull my heave blinds.

Is everyone having the same jittering when using stepper motors? like it stops for a split second after every turn?

What sketch/repo are you using?

I think you’ll find it’s this line (and similar ones further down) that cause the delay as the blind reports it’s position, I’m not sure there’s a way around it (or I’ve not really looked that hard :slight_smile:)

I’ve ordered all my supplies to start building my own blinds now. Cant wait to get started. I have windows side by side at each location which require 2 blinds. Does anyone know if i can use a single esp board to control them individually or will i need to have 2?

What have you ordered for motor and steering?

I’ve ordered 12v 28BYJ-48 and a ULN2003

And that together with the thingiverse? Or?

Let us know the result!