Skip to content

Instantly share code, notes, and snippets.

@lumascet
Last active August 9, 2025 20:04
Show Gist options
  • Select an option

  • Save lumascet/abe936af3c08b853f5e80f2e82fbc3a8 to your computer and use it in GitHub Desktop.

Select an option

Save lumascet/abe936af3c08b853f5e80f2e82fbc3a8 to your computer and use it in GitHub Desktop.
Esphome Cover Controller (Height & Tilt)
# Esphome Cover Height & Tilt Controller by lumascet
# Stallguard only works for TCOOL >= TSTEP > TPWMTHRESHOLD
# TSTEP is the time between two step pulses, fclk/ pulse_freq so it is smaller for higher step frequencies
# TPWMTHRESHOLD is the threshold for the stealthChop2 algorithm to switch to spreadCycle mode, if speed is faster,
# (TSTEP < TPWMTHRESHOLD) then it will use spreadCycle, in this mode stallguard does not work.
# Use diagnostic sensors (TSTEP and stallguard_result) to determine the right settings for your motor.
substitutions:
cover_length: "1.4" # m
encoder_closed_steps: "1446525"
encoder_to_stepper_steps: "0.78125" # 200 * 16 steps/u / 4096 steps/u
encoder_to_cover_meters: "float(${cover_length})/${encoder_closed_steps}"
tilt_to_angle_k: "145" # Linear Fit: y = kx + d; -80 degree for closed (0), 65 degree for opened (1)
tilt_to_angle_d: "-80"
default_stepper_current: "1.5"
default_stepper_stallguard_threshold: "127"
default_tilt_steps_range: "70000"
default_backlash_steps_range: "18000"
default_cover_speed: "20"
max_cover_speed: "40" # encoder skipping steps past this
cover_speed_to_stepper_steps: "(float(${encoder_closed_steps}*${encoder_to_stepper_steps}))/${cover_length}"
stepper_direction: "ccw"
encoder_direction: "ccw"
esphome:
name: pd-stepper-1
friendly_name: PD-Stepper-1
platformio_options:
board_build.flash_mode: dio
upload_speed: 921600
on_boot:
- tmc2209.configure:
direction: ${stepper_direction}
microsteps: 16
interpolation: True
tcool_threshold: 0xFFFFF
enable_spreadcycle: !lambda return id(spreadcycle_enabled);
tpwm_threshold: 45
- tmc2209.stallguard:
threshold: !lambda return id(stepper_stallguard_threshold);
- tmc2209.currents:
ihold: 0
tpowerdown: 0
iholddelay: 0
run_current: !lambda return id(stepper_current);
standstill_mode: freewheeling
- output.turn_off: # CFG1: 5V
id: CFG1_pin
- output.turn_on: # CFG2: 15V
id: CFG2_pin
- output.turn_off: # CFG3: 15V
id: CFG3_pin
esp32:
board: esp32-s3-devkitc-1
framework:
type: esp-idf
preferences:
flash_write_interval: 1min
esp32_ble_tracker:
scan_parameters:
interval: 1100ms
window: 1100ms
active: true
bluetooth_proxy:
active: true
connection_slots: 3
# Reduce processing overhead, otherwise encoder position can loose steps
logger:
level: ERROR
api:
encryption:
key: "xxx"
ota:
- platform: esphome
password: "xxx"
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
ssid: "Pd-Stepper-1 Fallback Hotspot"
password: "xxx"
captive_portal:
external_components:
- source: github://slimcdk/esphome-custom-components
components: [tmc2209_hub, tmc2209, stepper]
globals:
- id: encoder_rotation
type: int32_t
restore_value: no
- id: encoder_steps
type: int32_t
restore_value: yes
- id: tilt_steps
type: int32_t
restore_value: yes
initial_value: "0"
- id: backlash_steps
type: int32_t
restore_value: yes
initial_value: "0"
- id: home_steps
type: int32_t
restore_value: yes
initial_value: "0"
- id: cover_steps
type: int32_t
restore_value: yes
initial_value: "0"
- id: tilt_steps_range
type: int32_t
restore_value: no
initial_value: "${default_tilt_steps_range}"
- id: backlash_range
type: int32_t
restore_value: no
initial_value: "${default_backlash_steps_range}"
- id: cover_speed
type: int32_t
initial_value: "${default_cover_speed}"
restore_value: yes
- id: stepper_current
type: float
initial_value: "${default_stepper_current}"
restore_value: yes
- id: stepper_stallguard_threshold
type: int
initial_value: "${default_stepper_stallguard_threshold}"
restore_value: yes
- id: spreadcycle_enabled
type: bool
initial_value: "false"
restore_value: yes
number:
- platform: template
name: "Cover Speed"
id: cover_speed_number
optimistic: true
restore_value: true
min_value: 1
max_value: "${max_cover_speed}"
unit_of_measurement: "mm/s"
step: 1
initial_value: "${default_cover_speed}"
entity_category: config
set_action:
- lambda: |-
id(cover_speed) = x;
- select.set:
id: operation_mode
option: "Custom"
- platform: template
name: "Stepper Current"
id: stepper_current_number
optimistic: true
restore_value: true
min_value: 0.1
max_value: 2.0
initial_value: "${default_stepper_current}"
entity_category: config
step: 0.1
set_action:
- lambda: |-
id(stepper_current) = x;
- tmc2209.currents:
run_current: !lambda return id(stepper_current);
- select.set:
id: operation_mode
option: "Custom"
- platform: template
name: "Stepper Stallguard Threshold"
optimistic: true
restore_value: true
min_value: 0
max_value: 255
initial_value: "${default_stepper_stallguard_threshold}"
entity_category: diagnostic
step: 1
set_action:
- lambda: |-
id(stepper_stallguard_threshold) = x;
- tmc2209.stallguard:
threshold: !lambda return x;
- platform: template
name: "Tilt Steps Range"
optimistic: true
restore_value: true
min_value: 100
max_value: 100000
step: 50
initial_value: "${default_tilt_steps_range}"
entity_category: diagnostic
set_action:
- lambda: |-
id(tilt_steps_range) = x;
- platform: template
name: "Backlash Steps Range"
min_value: 0
max_value: 50000
step: 10
initial_value: "${default_backlash_steps_range}"
entity_category: diagnostic
set_action:
- lambda: |-
id(backlash_range) = x;
- platform: template
name: "Cover Angle"
min_value: -80
max_value: 65
step: 1
update_interval: 1s
lambda: |-
double x = 1.0 - float(id(tilt_steps) + id(tilt_steps_range)/2) / (id(tilt_steps_range));
double y = ${tilt_to_angle_k} * x + ${tilt_to_angle_d};
return y;
set_action:
- lambda: |-
float angle = x; // target angle
// Inverse linear mapping to normalized value [0..1]
float normalized = (angle - ${tilt_to_angle_d}) / ${tilt_to_angle_k};
auto call = id(stepper_cover).make_call();
call.set_tilt(normalized);
call.perform();
switch:
- platform: template
name: "SpreadCycle Enabled"
optimistic: true
entity_category: config
restore_mode: RESTORE_DEFAULT_OFF
turn_on_action:
- lambda: id(spreadcycle_enabled) = true;
- tmc2209.configure:
enable_spreadcycle: True
turn_off_action:
- lambda: id(spreadcycle_enabled) = false;
- tmc2209.configure:
enable_spreadcycle: False
i2c:
sda: 8
scl: 9
scan: true
uart:
tx_pin: 17
rx_pin: 18
baud_rate: 712000
output:
- platform: ledc
pin: 10
id: led1_output
- platform: ledc
pin: 12
id: led2_output
- platform: gpio
pin: GPIO38
id: CFG1_pin
- platform: gpio
pin: GPIO48
id: CFG2_pin
- platform: gpio
pin: GPIO47
id: CFG3_pin
light:
- platform: monochromatic
output: led1_output
id: led1
name: LED 1
- platform: status_led
output: led2_output
id: led2
name: Status LED
stepper:
- platform: tmc2209
id: motor
max_speed: 50000
acceleration: 50000 steps/s^2
deceleration: 50000 steps/s^2
config_dump_include_registers: true
rsense: 100 mOhm
vsense: False
enn_pin: 21
diag_pin: 16
index_pin: 11
on_stall:
# Stallguard debounce
- delay: 50ms
- if:
condition:
lambda: 'return id(stallguard_result).state < id(stepper_stallguard_threshold);'
then:
- stepper.stop: motor
- logger.log: "Motor stalled!"
- light.turn_on:
id: led1
transition_length: 0s
- delay: 250ms
- light.turn_off:
id: led1
transition_length: 1s
button:
- platform: restart
name: Restart
- platform: template
name: Home
id: home
entity_category: diagnostic
on_press:
- stepper.set_speed:
id: motor
speed: !lambda return float(id(cover_speed))/1000*${cover_speed_to_stepper_steps};
- stepper.set_target:
id: motor
target: -2000000
- platform: template
name: Stop
entity_category: diagnostic
on_press:
- stepper.stop: motor
- platform: template
name: Zeroise Blinds
id: zeroise_blinds
entity_category: diagnostic
on_press:
- lambda: |-
//id(encoder_steps) = 0;
//id(cover_steps) = 0;
id(home_steps) = id(cover_steps);
ESP_LOGI("zeroise", "Encoder zeroised to %d", id(home_steps));
- platform: template
name: Zeroise Tilt
id: zeroise_tilt
entity_category: diagnostic
on_press:
- lambda: |-
id(tilt_steps) = -id(tilt_steps_range);
ESP_LOGI("tilt", "Tilt zeroised");
binary_sensor:
# - platform: gpio
# id: stall_guard_sensor
# name: StallGuard
# pin: 16
- platform: gpio
name: PD Power
pin:
number: 15
mode: INPUT
inverted: true
device_class: power
filters:
- delayed_on: 10ms
as5600:
slow_filter: 16x
text_sensor:
- platform: template
name: "Encoder Magnet Status"
icon: mdi:magnet
id: encoder_status_text
sensor:
- platform: template
name: Tstep
lambda: return id(motor)->read_register(TSTEP);
entity_category: diagnostic
update_interval: 250ms
disabled_by_default: True
- platform: tmc2209
type: stallguard_result
id: stallguard_result
name: Stallguard
update_interval: 250ms
icon: mdi:engine-off
disabled_by_default: True
- platform: tmc2209
type: motor_load
icon: mdi:percent
name: Motor load
update_interval: 250ms
- platform: tmc2209
type: actual_current
name: Actual current
icon: mdi:current
update_interval: 250ms
disabled_by_default: True
- platform: tmc2209
type: pwm_scale_sum
name: PWM Scale Sum
update_interval: 250ms
disabled_by_default: True
- platform: tmc2209
type: pwm_scale_auto
name: PWM Scale Auto
update_interval: 250ms
disabled_by_default: True
- platform: tmc2209
type: pwm_ofs_auto
name: PWM OFS Auto
update_interval: 250ms
disabled_by_default: True
- platform: tmc2209
type: pwm_grad_auto
name: PWM Grad Auto
update_interval: 250ms
disabled_by_default: True
- platform: as5600
id: encoder_sensor
update_interval: 60s
internal: True
status:
id: encoder_status
on_value:
lambda: |-
// Read magnet status from AS5600 register 0x0B
uint8_t status = x;
// Map to readable text
switch (status) {
case 2:
id(encoder_status_text).publish_state("No magnet");
break;
case 4:
id(encoder_status_text).publish_state("Good");
break;
case 5:
id(encoder_status_text).publish_state("Too strong");
break;
case 6:
id(encoder_status_text).publish_state("Too weak");
break;
default:
id(encoder_status_text).publish_state("Unknown");
break;
}
- platform: as5600
name: Encoder
id: encoder
update_interval: 0s # beware of the polling rate
internal: true # don't publish sensor data to Home Assistant or web server
filters:
- delta: 2 # throttle the high polling rate to only act on value changes
# compute absolute position from angle value
######## NORMAL DIRECTION ########
- lambda: |
const uint16_t curr = x; // current encoder value 0-4095
const uint16_t prev = id(encoder_rotation); // previous encoder value
int delta = 0;
if (curr > 3000 && prev < 1000) {
delta = (4095 - curr + prev); // crossed zero clockwise
} else if (curr < 1000 && prev > 3000) {
delta = -(4095 - prev + curr); // crossed zero counterclockwise
} else {
delta = prev - curr;
}
// in case encoder is counting backwards, invert
if (std::string("${encoder_direction}") == "cw") {
delta = -delta;
}
id(encoder_steps) += delta;
id(encoder_rotation) = curr;
// Update backlash and clamp
id(backlash_steps) += delta;
float backlash_half_range = id(backlash_range) / 2;
if (id(backlash_steps) >= backlash_half_range) {
id(backlash_steps) = backlash_half_range;
id(cover_steps) = id(encoder_steps);
id(tilt_steps) += delta;
}
if (id(backlash_steps) <= -backlash_half_range){
id(backlash_steps) = -backlash_half_range;
id(cover_steps) = id(encoder_steps);
id(tilt_steps) += delta;
}
float tilt_half_range = id(tilt_steps_range)/2;
if (id(tilt_steps) > tilt_half_range) id(tilt_steps) = tilt_half_range;
if (id(tilt_steps) < -tilt_half_range) id(tilt_steps) = -tilt_half_range;
return id(encoder_steps);
- throttle: 250ms # limit the amount of new sensor states from this component
accuracy_decimals: 0
state_class: measurement
- platform: template
name: "Blind Tilt Angle"
lambda: |-
double x = 1.0 - float(id(tilt_steps) + id(tilt_steps_range)/2) / (id(tilt_steps_range));
double y = ${tilt_to_angle_k} * x + ${tilt_to_angle_d};
return round(y * 10.0) / 10.0; // round to 1 decimal
update_interval: 1s
accuracy_decimals: 1
unit_of_measurement: °
icon: mdi:sun-angle
state_class: measurement
- platform: template
name: "Blind Distance Closed"
lambda: |-
return float(id(cover_steps)-id(home_steps)) * ${encoder_to_cover_meters};
update_interval: 1s
unit_of_measurement: m
accuracy_decimals: 3
icon: mdi:arrow-collapse-down
- platform: adc
pin: 4
name: VBUS Voltage
update_interval: 10s
attenuation: 12dB
filters:
- multiply: 8.47742
entity_category: diagnostic
select:
- platform: template
name: "Operation Mode"
id: operation_mode
icon: mdi:speedometer
optimistic: true
options:
- "Silent"
- "Slow"
- "Normal"
- "Fast"
- "Custom"
initial_option: "Normal"
set_action:
- if:
condition:
lambda: 'return x == "Silent";'
then:
- number.set:
id: stepper_current_number
value: 1.3
- number.set:
id: cover_speed_number
value: 4.0
- if:
condition:
lambda: 'return x == "Slow";'
then:
- number.set:
id: stepper_current_number
value: 1.5
- number.set:
id: cover_speed_number
value: 10.0
- if:
condition:
lambda: 'return x == "Normal";'
then:
- number.set:
id: stepper_current_number
value: 1.5
- number.set:
id: cover_speed_number
value: 20.0
- if:
condition:
lambda: 'return x == "Fast";'
then:
- number.set:
id: stepper_current_number
value: 1.8
- number.set:
id: cover_speed_number
value: 40.0
cover:
- platform: template
id: stepper_cover
name: Cover
has_position: true
assumed_state: false
lambda: "return 1.0 - float(id(cover_steps)-id(home_steps)) / ${encoder_closed_steps};"
tilt_lambda: "return 1.0 - float(id(tilt_steps) + id(tilt_steps_range)/2) / id(tilt_steps_range);"
tilt_action:
- stepper.set_speed:
id: motor
speed: !lambda return float(id(cover_speed))/1000*${cover_speed_to_stepper_steps};
- stepper.report_position:
id: motor
position: !lambda return (1.0f - id(stepper_cover)->position) * ${encoder_closed_steps} * ${encoder_to_stepper_steps};
- stepper.set_target:
id: motor
target: !lambda |-
// Map tilt (0.0 - 1.0) → mechanical tilt steps
// 0.0 = fully closed one way, 1.0 = fully closed the other way
float centered = (0.5 - tilt) * id(tilt_steps_range); // -range/2 .. +range/2
// Delta from current
int delta = centered - id(tilt_steps);
// Backlash compensation
int cover_mechanical_start = 0;
if (delta > 0) { // Moving down
cover_mechanical_start = id(backlash_range) / 2;
} else if (delta < 0) { // Moving up
cover_mechanical_start = -id(backlash_range) / 2;
}
int stepper_position = id(motor).current_position;
int backlash = id(backlash_steps);
//ESP_LOGI("tilt", "Target tilt: %f, delta: %d, backlash: %d, mech_start: %d", tilt, delta, backlash, cover_mechanical_start);
return stepper_position + (delta + (cover_mechanical_start - backlash)) * ${encoder_to_stepper_steps};
stop_action:
- stepper.stop: motor
open_action:
- stepper.set_speed:
id: motor
speed: !lambda return float(id(cover_speed))/1000*${cover_speed_to_stepper_steps};
- stepper.report_position:
id: motor
position: !lambda return (1.0 - id(stepper_cover)->position) * ${encoder_closed_steps} * ${encoder_to_stepper_steps};
- stepper.set_target:
id: motor
target: 0
close_action:
- stepper.set_speed:
id: motor
speed: !lambda return float(id(cover_speed))/1000*${cover_speed_to_stepper_steps};
- stepper.report_position:
id: motor
position: !lambda return (1.0 - id(stepper_cover)->position) * ${encoder_closed_steps} * ${encoder_to_stepper_steps};
- stepper.set_target:
id: motor
target: !lambda return ${encoder_closed_steps} * ${encoder_to_stepper_steps};
position_action:
- stepper.set_speed:
id: motor
speed: !lambda return float(id(cover_speed))/1000*${cover_speed_to_stepper_steps};
- stepper.report_position:
id: motor
position: !lambda return (1.0 - id(stepper_cover)->position) * ${encoder_closed_steps} * ${encoder_to_stepper_steps};
- stepper.set_target:
id: motor
target: !lambda return (1.0-pos) * ${encoder_closed_steps} * ${encoder_to_stepper_steps};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment