####################################################################
# Sliding window opener  -  auto-calibrating version
# ESP32 + BTT TMC2209 v1.3 + NEMA17
# 2x MC-38:  D18 = closed end,  D19 = open end
#
# The device MEASURES its own travel instead of assuming it.
# On boot it homes to closed, then runs to open and records the
# step count. Microstepping mode no longer matters.
#
# Every time it reaches an end normally it quietly re-checks that
# number and corrects small drift, so belt skips self-heal.
#
# Rules the motion layer enforces:
#   1. Homing always CLEARS the switch before it seeks, so starting
#      parked on a limit never drives into the end stop.
#   2. A reversal mid-move STOPS first, then starts the other way.
#   3. A limit switch is an E-STOP, and the stop point is what
#      home is measured from.
#   4. Once home is known, traverses run fast and hand over to a
#      slow crawl near the switch WITHOUT stopping in between.
#   5. Position is PUBLISHED to HA, not assumed.
#
# One user-facing motion button: Calibrate. Homing and calibration
# are the same routine.
####################################################################

substitutions:
  name: window-opener
  friendly_name: "Bedroom Window"

  # Keep the cruise above ~450 full-steps/s x microstepping: 3000
  # steps/s sat in the stepper's mid-band resonance region and
  # jittered mid-span, where the belt is springiest. 4000 is the
  # tuned sweet spot - fast, smooth, quieter than 5000.
  run_speed: "2000"        # normal open / close
  cal_speed: "2000"        # calibration traverse - the measuring
                           # pass only, so normal travel stays gentle
  home_speed: "1000"       # careful approach to a limit
  creep_speed: "600"       # confirming pass (15 mm/s)
  end_crawl_speed: "200"   # very slow crawl onto a switch (5 mm/s)
  backoff_steps: "400"     # how far to reverse between passes.
                           # Must exceed the reed hysteresis, but
                           # back_off_clear escalates 400/800/1200/
                           # 1600 if it does not, so a low value
                           # costs a retry rather than a bad zero.
  decel: "500"

  # Acceleration is NOT constant. Breakaway force peaks at the two
  # extremes, so it eases off within accel_zone_mm of either end and
  # only reaches accel_max once clear of both. Applied continuously
  # from the current position - see the acceleration-shaping
  # interval near the bottom of the file.
  accel_min: "300"         # at the ends
  accel_max: "500"         # mid travel
  accel_zone_mm: "120"     # ramp distance at each end
  accel: "500"             # fallback before the numbers publish

  # How far 0% / 100% may crawl looking for the switch if the
  # stored endpoint fell short of it.
  end_overshoot_mm: "40"

  # Ramp used only for the back-off. This is the breakaway move -
  # peak torque demand, often from a standstill against the end
  # stop - so it gets a much gentler ramp than normal travel.
  # Lower this first if the belt still skips while homing.
  backoff_accel: "400"

  # Geometry, used only to work out where to stop dashing.
  # travel_mm is the mechanical measurement; the firmware never
  # trusts it for positioning, only for this ratio.
  travel_mm: "1050"        # measured panel travel
  # Measuring pass. Travel is a known ~1050 mm and does not really
  # change, so the pass runs at speed to cal_handover_mm and then
  # hands over to a slow crawl WITHOUT stopping.
  #
  # The handover is seamless because the target is aimed slightly
  # past the handover point - by exactly the distance the stepper's
  # own deceleration curve needs to fall to cal_slow_mm_s. Actual
  # speed therefore already equals the new max when it is applied,
  # so the clamp in calculate_speed_() has nothing to cut.
  cal_handover_mm: "1000"  # run fast up to here
  cal_slow_mm_s: "60"      # then crawl in at this, mm/s

  # How close to an end the panel must be for assist mode to notice
  # a hand-push, in steps. Assist only works near the ends - the reed
  # switches are the only back-drive detector we have.
  assist_window: "800"

  # Assist timing, in 50 ms ticks. Split into two separate jobs so
  # reacting fast does not mean reacting to noise:
  #   arm   - how long the axis must sit STILL before assist is even
  #           eligible. This is what stops the panel rocking on
  #           arrival from launching a traverse.
  #   dwell - once armed, how long the switch must read released
  #           before the motor takes over. Kept short: the panel is
  #           already moving under your hand, so the motor should
  #           pick it up while it still has momentum rather than
  #           waiting for it to stall and having to break away again.
  assist_arm_ticks: "15"   # x50 ms = 750 ms of stillness to arm
  assist_dwell_ticks: "1"  # x50 ms, noise rejection only - the
                           # deliberate wait is assist_delay_ms

  # Both exposed as HA numbers for tuning. Total time from the push
  # to the motor moving is (assist_dwell_ticks x 50 ms) + the delay.
  assist_delay_ms: "270"   # let the hand push carry it, then engage
  assist_speed: "4500"     # assist cruise, steps/s

  # Lockout after ANY arrival at a limit, in 50 ms ticks. The panel
  # bounces off the closed end, which releases the switch, and
  # without this that reads exactly like a hand push and assist
  # sends it straight back open.
  assist_lockout_ticks: "60"   # x50 ms = 3 s

  # An assisted CLOSE finishes with a token rehome: back off this
  # much and creep back on. Arriving is not the same as being
  # settled when the panel bounces, and this leaves the switch
  # solidly made and the zero honest.
  assist_rehome_mm: "5"

  # Assist engages with effectively no ramp - the push already has
  # the panel moving, so accelerating up from zero just leaves the
  # motor trailing something already in motion.
  assist_accel: "1000000"

  # Assist traverse profile. The fast segment is DERIVED, not fixed:
  #   fast = travel_mm - assist_slow_mm - assist_engage_mm
  # assist_engage_mm is the head start the hand push already gave the
  # panel before the motor took over. The step counter cannot see it
  # (the driver was disabled), so it is held back from the fast
  # segment - otherwise a hard push would put the panel into the far
  # end stop at cruise speed while the firmware still thought it
  # was well short. assist_slow_mm is the buffer that absorbs a
  # push longer than assist_engage_mm.
  assist_slow_mm: "200"    # careful final approach
  assist_engage_mm: "100"  # unseen head start from the push -
                           # measured, a push carries it ~10 cm

  # deceleration used by the e-stop. Deliberately far beyond what
  # the belt can follow - an e-stop is allowed to lose sync, that is
  # why an unexpected one is always followed by a recalibration.
  estop_decel: "1000000"

  # how long to let the debounced limit switches publish a real
  # state before any homing decision is made. Filters are 30 ms.
  settle_time: "500ms"

  # ---- StallGuard ----
  # sg_threshold: 0-255, HIGHER = more sensitive (stall flagged when
  # SG_RESULT < 2x this). 25 is deliberately conservative: a genuine
  # jam trips it, breakaway and acceleration load never should. No
  # tuning required; the HA "Stall sensitivity" box exists if wanted.
  sg_threshold: "25"
  # StallGuard is unreliable at low speed, so DIAG is gated to only
  # fire above this (steps/s at the driver's step input). 2000 keeps
  # every homing creep and end crawl below the gate while all normal
  # runs (4000+) are protected. Used to derive TCOOLTHRS assuming
  # 1/8 microstepping (x32 to 1/256 units) and the internal 12 MHz
  # clock.
  sg_min_speed: "2000"

  # only used before the first calibration completes
  fallback_steps: "42000"

esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  on_boot:
    priority: -100
    then:
      - logger.log: "Boot - configuring driver, then calibrating"
      - script.execute: tmc_init
      - script.wait: tmc_init
      - script.execute: calibrate

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

# INFO, not the default DEBUG: every DEBUG line blocks ~4 ms on the
# serial port, and steps are generated one per loop pass - each
# publish log line mid-traverse costs ~10 steps of timing at cruise,
# felt as jitter. Our own logger.log lines are INFO and still print.
logger:
  level: INFO
api:
ota:
  - platform: esphome

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  ap:
    ssid: "${name} fallback"

captive_portal:

####################################################################
# Globals
####################################################################

globals:
  # measured travel, persisted across reboots
  - id: travel_steps
    type: int
    restore_value: true
    initial_value: ${fallback_steps}

  # true once a full calibration has completed this session
  - id: is_calibrated
    type: bool
    restore_value: false
    initial_value: 'false'

  # suppresses limit-switch handling while calibrating
  - id: calibrating
    type: bool
    restore_value: false
    initial_value: 'false'

  # +1 or -1. Flipped by the Reverse direction switch.
  # Everything downstream uses signed positions, so travel_steps
  # simply comes out negative when reversed.
  - id: dir_sign
    type: int
    restore_value: true
    initial_value: '1'

  # cleared by seek_end if a homing pass fails. Nothing is zeroed
  # and no travel is recorded while this is false.
  - id: home_ok
    type: bool
    restore_value: false
    initial_value: 'true'

  # back-off retry counter, escalates the distance each attempt
  - id: backoff_tries
    type: int
    restore_value: false
    initial_value: '0'

  # set by a limit handler when the switch fired without us having
  # been on our way there - i.e. a genuine fault, not an arrival
  - id: unexpected_limit
    type: bool
    restore_value: false
    initial_value: 'false'

  # captured alongside unexpected_limit: the motor was IDLE when the
  # switch fired, so a human slid the panel there by hand. That gets
  # adopted (wait, settle, take the position) instead of treated as
  # a belt-slip fault - a fault needs the motor to have been moving.
  - id: manual_arrival
    type: bool
    restore_value: false
    initial_value: 'false'

  # the closed reed released by a HAND, outside any routine and
  # outside the arrival grace window. While set, the closed
  # watchdog leaves the panel alone - the human put it there.
  # Cleared on reed contact or any commanded move.
  - id: hand_opened
    type: bool
    restore_value: false
    initial_value: 'false'

  # where the approach run is aimed
  - id: dash_target
    type: int
    restore_value: false
    initial_value: '0'

  # where the approach run hands over to the slow crawl
  - id: dash_handover
    type: int
    restore_value: false
    initial_value: '0'

  # slow approach speed in steps/s, derived from cal_slow_mm_s and
  # the measured travel so it holds whatever the microstepping is
  - id: approach_slow
    type: float
    restore_value: false
    initial_value: '1200'

  # last position pushed to HA, so we only publish on real change
  - id: last_pub_pos
    type: int
    restore_value: false
    initial_value: '-2000000'

  - id: last_pub_moving
    type: bool
    restore_value: false
    initial_value: 'false'

  # assist mode: +1 wants to open, -1 wants to close, 0 idle
  - id: assist_want
    type: int
    restore_value: false
    initial_value: '0'

  - id: assist_dwell
    type: int
    restore_value: false
    initial_value: '0'

  # 50 ms ticks still counting as "an assist just happened".
  # assist_run clears `assisting` as it exits, but the limit handler
  # for the arrival can still be part-way through its own action
  # chain at that point (it yields on script.wait: estop). Without
  # this the arrival could be judged after the flag had dropped and
  # get treated as a fault, recalibrating after every assist.
  - id: assist_grace
    type: int
    restore_value: false
    initial_value: '0'

  # set when go_end's stored endpoint fell short and it had to
  # crawl - that arrival gets the confirming settle pass
  - id: end_crawled
    type: bool
    restore_value: false
    initial_value: 'false'

  # true while go_end / settle_end own the motor. These run with
  # calibrating false, so without this assist mode treats a
  # firmware-driven end seek as a hand push.
  - id: end_busy
    type: bool
    restore_value: false
    initial_value: 'false'

  # last acceleration written by the shaping interval, so it only
  # writes on a real change
  - id: applied_accel
    type: float
    restore_value: false
    initial_value: '500'

  # 50 ms ticks the axis has been standing still, used to arm assist
  - id: assist_idle
    type: int
    restore_value: false
    initial_value: '0'

  # set at the top of seek_end: we were already on the target
  # switch, so there is nothing to seek - confirm only
  - id: seek_at_end
    type: bool
    restore_value: false
    initial_value: 'false'

  # which end calibration homes first - the nearer one
  - id: home_first_open
    type: bool
    restore_value: false
    initial_value: 'false'

  # true while assist_run owns the motor. The step count is stale by
  # however far the hand push moved the panel, so nothing measured
  # during an assist run may be written back to travel_steps.
  - id: assisting
    type: bool
    restore_value: false
    initial_value: 'false'

####################################################################
# Driver UART - StallGuard configuration
#
# Write-only: registers are set with raw TMC datagrams (tmc_write),
# no ESPHome tmc2209 component involved, so nothing here depends on
# that component's shifting API. The driver keeps running plain
# STEP/DIR - UART is config only.
####################################################################

uart:
  id: tmc_uart
  tx_pin: GPIO17     # board label TX2  -> driver RX
  rx_pin: GPIO16     # board label RX2  -> driver TX
  baud_rate: 115200

####################################################################
# Stepper
#
# If it runs backwards, flip dir_pin inverted.
#
# Nothing should call stepper.set_target directly for a normal move
# any more - go through safe_move so reversals are stopped first.
####################################################################

stepper:
  # EN is deliberately NOT the stepper's sleep_pin any more. The
  # a4988 component cuts EN the instant target == position, which
  # released the belt's wound-up tension at EVERY intermediate
  # homing stop - the loud pop. driver_enable (below) now holds
  # torque through whole routines and only releases after real
  # idle, when assist arms.
  - platform: a4988
    id: win_stepper
    step_pin: GPIO22         # board label D22
    dir_pin:
      number: GPIO23         # board label D23
      inverted: true
    max_speed: ${run_speed} steps/s
    acceleration: ${accel} steps/s^2
    deceleration: ${decel} steps/s^2

####################################################################
# Limit switches
#
# Both ends are E-STOPS. On assertion the panel is brought up hard,
# and only then is the position fixed up. The two cases differ:
#
#   expected   - we were on our way to this end. Normal arrival.
#   unexpected - the switch fired while we were heading elsewhere or
#                sitting still. The belt has slipped or something is
#                obstructing, so a full recalibration follows: an
#                e-stop point is not repeatable enough to trust.
####################################################################

binary_sensor:
  # ---- CLOSED end ----
  - platform: gpio
    id: closed_limit
    name: "Closed limit"
    pin:
      number: GPIO18
      mode:
        input: true
        pullup: true
      inverted: true
    filters:
      # Press drives the e-stop and the unexpected-limit
      # recalibration, so it stays conservatively debounced.
      # Release only feeds assist and the back-off wait, where a
      # spurious read is cheap and latency is what you feel.
      - delayed_on: 30ms
      - delayed_off: 10ms
    on_press:
      then:
        - if:
            condition:
              lambda: 'return !id(calibrating);'
            then:
              - lambda: |-
                  // see the open-end handler for why "heading" is
                  // gone and why the tolerance is 10% of travel
                  long cur = id(win_stepper).current_position;
                  id(manual_arrival) =
                      cur == id(win_stepper).target_position;
                  id(hand_opened) = false;   // reed made - state is real again
                  long tol = labs(id(travel_steps)) / 10;
                  if (tol < ${backoff_steps}) tol = ${backoff_steps};
                  bool near = labs(cur) < tol;
                  bool assist = id(assisting) || id(assist_grace) > 0;
                  id(unexpected_limit) = !assist && !near;
                  // any arrival starts the assist lockout, so the
                  // panel bouncing off the end is not mistaken for
                  // a hand push
                  id(assist_grace) = ${assist_lockout_ticks};
              - script.execute: estop
              - script.wait: estop
              - lambda: 'id(win_stepper).report_position(0);'
              - stepper.set_target:
                  id: win_stepper
                  target: 0
              - script.execute: publish_position
              - logger.log: "Closed limit - stopped, position zeroed"
              - if:
                  condition:
                    lambda: 'return id(unexpected_limit);'
                  then:
                    - if:
                        condition:
                          lambda: 'return id(manual_arrival);'
                        then:
                          # Slid closed by hand while the motor was
                          # idle. Do NOT drive it anywhere - the
                          # human put it where they want it. Wait
                          # for them to let go, then settle onto
                          # the switch; the zero above already set
                          # the location.
                          - logger.log: "Pulled closed by hand - adopting the position"
                          - delay: 3s
                          - script.execute:
                              id: settle_end
                              to_open: false
                          - script.wait: settle_end
                          - script.execute: publish_position
                        else:
                          - logger.log:
                              level: WARN
                              format: "Closed limit hit unexpectedly mid-move - recalibrating"
                          - script.execute: calibrate
              # The panel can BOUNCE off the switch after the stop -
              # heavy glass on an elastic belt. The zero above was
              # taken at contact, but contact is not the same as
              # staying there. Give the bounce time to play out; if
              # the switch is no longer made we are not actually
              # closed, so settle back onto it. settle_end sets
              # end_busy, so the re-contact firing this handler
              # again cannot spawn a second settle, and the arrival
              # lockout is already armed so assist cannot read the
              # bounce as a hand push.
              - delay: 600ms
              - if:
                  condition:
                    and:
                      - binary_sensor.is_off: closed_limit
                      # assisting: assist_run does its own bounce
                      # check - both running settle_end at once had
                      # them restarting each other mid-move
                      - lambda: 'return !id(calibrating) && !id(end_busy) && !id(assisting) && !id(unexpected_limit);'
                  then:
                    - logger.log:
                        level: WARN
                        format: "Bounced off the closed limit - settling back on"
                    - script.execute:
                        id: settle_end
                        to_open: false
                    - script.wait: settle_end
                    - script.execute: publish_position
    # The switch releasing outside any routine means the panel left
    # the closed end. Inside the arrival grace window that is a
    # bounce and the watchdog may fix it; outside it, it was a hand
    # and the panel stays where the human put it.
    on_release:
      then:
        - if:
            condition:
              lambda: 'return !id(calibrating) && !id(end_busy) && !id(assisting);'
            then:
              - lambda: |-
                  if (id(assist_grace) == 0) id(hand_opened) = true;
              - script.execute: publish_position

  # ---- OPEN end ----
  - platform: gpio
    id: open_limit
    name: "Open limit"
    pin:
      number: GPIO19
      mode:
        input: true
        pullup: true
      inverted: true
    filters:
      # Press drives the e-stop and the unexpected-limit
      # recalibration, so it stays conservatively debounced.
      # Release only feeds assist and the back-off wait, where a
      # spurious read is cheap and latency is what you feel.
      - delayed_on: 30ms
      - delayed_off: 10ms
    on_press:
      then:
        - if:
            condition:
              lambda: 'return !id(calibrating);'
            then:
              - lambda: |-
                  // "Unexpected" must mean the switch fired somewhere
                  // it has no business firing - i.e. mid travel. It
                  // used to also require that we were still heading
                  // this way, but every arrival ends stopped (estop
                  // sees to that), so that term was false on normal
                  // arrivals too and the whole test hung on reading
                  // assisting at exactly the right instant.
                  //
                  // Tolerance is 10% of travel, not backoff_steps: an
                  // assist arrives early by however far the hand push
                  // moved the panel uncounted, which is far more than
                  // 10 mm.
                  long cur = id(win_stepper).current_position;
                  id(manual_arrival) =
                      cur == id(win_stepper).target_position;
                  long tol = labs(id(travel_steps)) / 10;
                  if (tol < ${backoff_steps}) tol = ${backoff_steps};
                  bool near = labs(cur - id(travel_steps)) < tol;
                  bool assist = id(assisting) || id(assist_grace) > 0;
                  id(unexpected_limit) = !assist && !near;
                  // any arrival starts the assist lockout, so the
                  // panel bouncing off the end is not mistaken for
                  // a hand push
                  id(assist_grace) = ${assist_lockout_ticks};
              - script.execute: estop
              - script.wait: estop
              # silent drift correction
              - lambda: |-
                  int measured = id(win_stepper).current_position;
                  int stored = id(travel_steps);
                  int diff = abs(measured - stored);
                  if (id(assisting)) {
                    // The push moved the panel with the driver off,
                    // so we arrived early by an unknown amount.
                    // Treating that as drift would shrink the stored
                    // travel a little more on every assisted open.
                    ESP_LOGI("assist", "Arrived open on assist at %d - travel left at %d",
                             measured, stored);
                  } else if (id(unexpected_limit)) {
                    ESP_LOGW("cal", "Open limit hit unexpectedly at %d - travel figure left alone", measured);
                  } else if (diff > 0 && diff < abs(stored) / 10) {
                    id(travel_steps) = measured;
                    ESP_LOGI("cal", "Drift corrected: %d -> %d", stored, measured);
                  } else if (diff >= abs(stored) / 10) {
                    ESP_LOGW("cal", "Large mismatch: expected %d got %d", stored, measured);
                  }
                  id(win_stepper).report_position(id(travel_steps));
              - stepper.set_target:
                  id: win_stepper
                  target: !lambda 'return id(travel_steps);'
              - script.execute: publish_position
              - logger.log: "Open limit - stopped"
              - if:
                  condition:
                    lambda: 'return id(unexpected_limit);'
                  then:
                    - if:
                        condition:
                          lambda: 'return id(manual_arrival);'
                        then:
                          # Slid open by hand while the motor was
                          # idle. The report_position above already
                          # set the location to travel_steps; wait
                          # for the hand to let go, then settle.
                          - logger.log: "Pulled open by hand - adopting the position"
                          - delay: 3s
                          - script.execute:
                              id: settle_end
                              to_open: true
                          - script.wait: settle_end
                          - script.execute: publish_position
                        else:
                          - logger.log:
                              level: WARN
                              format: "Open limit hit unexpectedly mid-move - recalibrating"
                          - script.execute: calibrate
    # mirror of the closed end: hand-nudged off the open switch
    on_release:
      then:
        - if:
            condition:
              lambda: 'return !id(calibrating) && !id(end_busy) && !id(assisting);'
            then:
              - script.execute: publish_position

  # ---- StallGuard ----
  - platform: gpio
    id: stall_detected
    name: "Stall detected"
    pin:
      number: GPIO4
      mode:
        input: true
        # DIAG is push-pull once the driver is up; the pulldown just
        # keeps the line defined during boot, before tmc_init runs.
        pulldown: true
    filters:
      # a real stall asserts continuously; the transient SG dips
      # during acceleration do not survive 60 ms
      - delayed_on: 60ms
    on_press:
      then:
        - if:
            condition:
              and:
                - switch.is_on: obstruction_stop
                - lambda: 'return !id(calibrating);'
            then:
              - script.execute: estop
              - logger.log: "STALL - e-stopped"

  # ---- Busy ----
  # calibrating swallows every move in safe_move. If this is stuck
  # on, that is why open/close/position all appear dead.
  - platform: template
    name: "Busy"
    id: busy
    entity_category: diagnostic
    lambda: 'return id(calibrating) || id(assisting) || id(end_busy);'

  # ---- Homing fault ----
  # Goes on when a homing pass gave up. Position is untrustworthy
  # until a Calibrate succeeds.
  - platform: template
    name: "Homing fault"
    id: homing_fault
    device_class: problem
    entity_category: diagnostic
    lambda: 'return !id(home_ok);'

####################################################################
# Live tuning from HA
####################################################################

number:
  - platform: template
    name: "Run speed"
    id: cfg_run_speed
    min_value: 100
    max_value: 15000
    step: 1
    initial_value: ${run_speed}
    optimistic: true
    mode: box
    entity_category: config
    on_value:
      - stepper.set_speed:
          id: win_stepper
          speed: !lambda 'return x;'

  # Separate from deceleration on purpose. A single entity setting
  # both would clobber ${decel} the moment it published, which is
  # what used to happen at boot: initial_value fires on_value, so
  # deceleration was silently overwritten with ${accel}.
  # Mid-travel acceleration. The shaping interval eases below this
  # near the ends, down to "Acceleration at ends".
  - platform: template
    name: "Acceleration"
    id: cfg_accel
    min_value: 100
    max_value: 10000
    step: 1
    initial_value: ${accel_max}
    optimistic: true
    mode: box
    entity_category: config

  - platform: template
    name: "Acceleration at ends"
    id: cfg_accel_ends
    min_value: 50
    max_value: 10000
    step: 1
    initial_value: ${accel_min}
    optimistic: true
    mode: box
    entity_category: config

  # ---- Assist tuning ----
  - platform: template
    name: "Assist delay"
    id: cfg_assist_delay
    min_value: 0
    max_value: 3000
    step: 1
    unit_of_measurement: "ms"
    initial_value: ${assist_delay_ms}
    optimistic: true
    mode: box
    entity_category: config

  - platform: template
    name: "Assist speed"
    id: cfg_assist_speed
    min_value: 100
    max_value: 15000
    step: 1
    unit_of_measurement: "steps/s"
    initial_value: ${assist_speed}
    optimistic: true
    mode: box
    entity_category: config

  # StallGuard sensitivity, written to the driver on every change.
  # Higher = trips earlier. Tune: raise until normal runs never trip,
  # then find the point where gripping the panel mid-travel stops it.
  - platform: template
    name: "Stall sensitivity"
    id: cfg_sgthrs
    min_value: 0
    max_value: 255
    step: 1
    initial_value: ${sg_threshold}
    optimistic: true
    mode: box
    entity_category: config
    on_value:
      - script.execute:
          id: tmc_write
          reg: 0x40
          value: !lambda 'return (int) x;'

  - platform: template
    name: "Deceleration"
    id: cfg_decel
    min_value: 100
    max_value: 10000
    step: 1
    initial_value: ${decel}
    optimistic: true
    mode: box
    entity_category: config
    on_value:
      - stepper.set_deceleration:
          id: win_stepper
          deceleration: !lambda 'return x;'

####################################################################
# Switches
####################################################################

switch:
  # Driver EN, active low. Turned on at the top of every motion
  # script; released by the 50 ms interval once the axis has been
  # truly idle for assist_arm_ticks - the same moment assist arms,
  # so a hand push never fights holding torque. Off at boot.
  - platform: gpio
    id: driver_enable
    pin:
      number: GPIO5          # board label D5 -> driver EN
      inverted: true         # EN is active LOW
    internal: true
    restore_mode: ALWAYS_OFF

  - platform: template
    id: obstruction_stop
    name: "Obstruction stop"
    optimistic: true
    restore_mode: RESTORE_DEFAULT_ON

  - platform: template
    id: assist_mode
    name: "Assist mode"
    optimistic: true
    restore_mode: RESTORE_DEFAULT_OFF

  # Flip if the window moves the wrong way.
  # Triggers a recalibration automatically.
  - platform: template
    id: reverse_direction
    name: "Reverse direction"
    optimistic: true
    restore_mode: RESTORE_DEFAULT_OFF
    entity_category: config
    turn_on_action:
      - lambda: 'id(dir_sign) = -1;'
      - logger.log: "Direction reversed - recalibrating"
      - script.execute: calibrate
    turn_off_action:
      - lambda: 'id(dir_sign) = 1;'
      - logger.log: "Direction normal - recalibrating"
      - script.execute: calibrate

####################################################################
# Cover
#
# optimistic: false means HA holds no state of its own - the device
# is the source of truth, so it has to actually publish. That is
# what publish_position does; without it the position slider has
# nothing to remember and snaps back.
####################################################################

cover:
  - platform: template
    name: "Window"
    id: window_cover
    device_class: window
    has_position: true
    optimistic: false

    # 0% and 100% go through go_end, which finishes the job with a
    # crawl onto the switch if the stored endpoint fell short.
    open_action:
      - script.execute:
          id: go_end
          to_open: true

    close_action:
      - script.execute:
          id: go_end
          to_open: false

    # A user-requested stop is a controlled stop, not an e-stop -
    # no reason to punish the belt. script.stop matters: without it a
    # safe_move that is mid-reversal would resume after the stop.
    # Stop must also kill calibration and its children. Leaving them
    # running used to only override the target: the motor halted, but
    # seek_end was still parked in a wait_until for a switch that now
    # never comes - and when that timed out (up to 180 s later) it
    # moved on to its next stage and STARTED THE MOTOR AGAIN, minutes
    # after the user pressed stop.
    stop_action:
      - script.stop: safe_move
      - script.stop: assist_run
      - script.stop: go_end
      - script.stop: settle_end
      - script.stop: calibrate
      - script.stop: seek_end
      - script.stop: back_off_clear
      - lambda: |-
          id(calibrating) = false;
          id(assisting) = false;
          id(end_busy) = false;
      - script.execute: restore_accel
      - stepper.set_target:
          id: win_stepper
          target: !lambda 'return id(win_stepper).current_position;'
      - script.execute: publish_position

    position_action:
      - logger.log:
          format: "Position requested: %.2f -> %d steps (travel %d)"
          args:
            - 'pos'
            - '(int)(pos * id(travel_steps))'
            - 'id(travel_steps)'
      - script.execute:
          id: safe_move
          tgt: !lambda 'return (int)(pos * id(travel_steps));'

####################################################################
# Diagnostics
####################################################################

sensor:
  # Human-readable location, alongside the raw step count.
  # The stepper generates AT MOST ONE STEP PER MAIN-LOOP PASS, so
  # every API publish stalls the motor for its duration - at speed
  # that is an audible surge. delta filters keep these sensors
  # silent unless the value genuinely changed, and the intervals
  # are the slowest HA can tolerate.
  - platform: template
    name: "Position"
    id: position_pct
    unit_of_measurement: "%"
    icon: "mdi:window-open-variant"
    accuracy_decimals: 0
    update_interval: 2s
    filters:
      - delta: 1.0
    lambda: |-
      // The switches are the authority for the endpoints: 0% is
      // only ever claimed with the closed reed made, 100% only
      // with the open reed made. Off the switch, the counter can
      // say what it likes - the display stays pinned to 1-99%.
      // Before the first calibration nothing is known at all.
      if (!id(is_calibrated)) return NAN;
      if (id(closed_limit).state) return 0.0f;
      if (id(open_limit).state) return 100.0f;
      if (id(travel_steps) == 0) return NAN;
      float p = 100.0f * (float) id(win_stepper).current_position
                       / (float) id(travel_steps);
      if (p < 1.0f) p = 1.0f;
      if (p > 99.0f) p = 99.0f;
      return p;

  - platform: template
    name: "Position steps"
    lambda: 'return id(win_stepper).current_position;'
    update_interval: 5s
    filters:
      - delta: 40
    entity_category: diagnostic

  - platform: template
    name: "Measured travel"
    lambda: 'return id(travel_steps);'
    update_interval: 10s
    filters:
      - delta: 1
    entity_category: diagnostic

  - platform: wifi_signal
    name: "WiFi signal"
    update_interval: 60s
    entity_category: diagnostic


####################################################################
# Motion primitives
#
# Sign convention, unchanged:
#   toward open   = position + (N * dir_sign)
#   toward closed = position - (N * dir_sign)
# so travel_steps simply comes out negative when reversed.
####################################################################

script:
  # ---- Write one TMC2209 register ----
  #
  # Raw UART write datagram: sync, slave 0 (MS1/MS2 float low),
  # register | 0x80, 32-bit value MSB-first, CRC8 (poly 0x07, LSB
  # first, per datasheet). Writes get no reply, so this is fire and
  # forget; queued mode keeps back-to-back writes in order.
  - id: tmc_write
    mode: queued
    parameters:
      reg: int
      value: int
    then:
      - uart.write:
          id: tmc_uart
          data: !lambda |-
            uint8_t d[8];
            d[0] = 0x05;
            d[1] = 0x00;
            d[2] = (uint8_t) (reg | 0x80);
            d[3] = (uint8_t) ((value >> 24) & 0xFF);
            d[4] = (uint8_t) ((value >> 16) & 0xFF);
            d[5] = (uint8_t) ((value >> 8) & 0xFF);
            d[6] = (uint8_t) (value & 0xFF);
            uint8_t crc = 0;
            for (int i = 0; i < 7; i++) {
              uint8_t b = d[i];
              for (int j = 0; j < 8; j++) {
                if ((crc >> 7) ^ (b & 0x01))
                  crc = (crc << 1) ^ 0x07;
                else
                  crc = crc << 1;
                b >>= 1;
              }
            }
            d[7] = crc;
            return std::vector<uint8_t>(d, d + 8);
      - delay: 10ms

  # ---- Configure StallGuard ----
  #
  # Registers are lost when the driver loses 24 V, so this runs at
  # boot and again at the top of every calibration as cheap
  # insurance. Three writes:
  #
  #   GCONF 0x141      keep I_scale_analog (Vref still sets the
  #                    current), pdn_disable (the PDN pin IS the
  #                    UART line), multistep_filt. mstep_reg_select
  #                    stays 0 - microstepping remains on the MS
  #                    pins, so the step units every stored value
  #                    is measured in CANNOT silently change.
  #   TCOOLTHRS        speed gate derived from sg_min_speed
  #   SGTHRS           sensitivity, from the HA box
  - id: tmc_init
    mode: restart
    then:
      - logger.log: "Writing TMC2209 StallGuard config"
      - script.execute:
          id: tmc_write
          reg: 0x00
          value: 0x141
      - script.execute:
          id: tmc_write
          reg: 0x14
          value: !lambda |-
            // TSTEP threshold in 1/256-microstep clock counts:
            // fclk 12 MHz, 1/8 mode = x32 to 1/256 rate
            return (int) (12000000L / ((long) ${sg_min_speed} * 32L));
      - script.execute:
          id: tmc_write
          reg: 0x40
          value: !lambda |-
            float v = id(cfg_sgthrs).state;
            return (int) (std::isnan(v) ? ${sg_threshold} : v);
      - script.wait: tmc_write

  # ---- Push the real position to HA ----
  #
  # Called from the reporting interval and at every point the
  # position changes discontinuously (limit hit, stop, calibration).
  - id: publish_position
    mode: restart
    then:
      - lambda: |-
          id(last_pub_pos) = id(win_stepper).current_position;
          id(last_pub_moving) =
              id(win_stepper).current_position != id(win_stepper).target_position;
      - cover.template.publish:
          id: window_cover
          # same endpoint rule as the Position sensor: fully closed
          # and fully open are only claimed with the switch made
          position: !lambda |-
            if (id(closed_limit).state) return 0.0f;
            if (id(open_limit).state) return 1.0f;
            if (id(travel_steps) == 0) return 0.5f;
            float p = (float) id(win_stepper).current_position
                    / (float) id(travel_steps);
            if (p < 0.01f) p = 0.01f;
            if (p > 0.99f) p = 0.99f;
            return p;
          current_operation: !lambda |-
            long cur = id(win_stepper).current_position;
            long tgt = id(win_stepper).target_position;
            if (cur == tgt)
              return esphome::cover::COVER_OPERATION_IDLE;
            return ((tgt - cur) * id(dir_sign) > 0)
                     ? esphome::cover::COVER_OPERATION_OPENING
                     : esphome::cover::COVER_OPERATION_CLOSING;

  # ---- Put the ramp back to the user's slider value ----
  #
  # back_off_clear lowers acceleration for the breakaway; this is
  # how it gets restored, so a gentle ramp never leaks into normal
  # travel. Synchronous - no delays inside.
  - id: restore_accel
    mode: restart
    then:
      - stepper.set_acceleration:
          id: win_stepper
          acceleration: !lambda |-
            float v = id(cfg_accel).state;
            return std::isnan(v) ? (float) ${accel} : v;
      - stepper.set_deceleration:
          id: win_stepper
          deceleration: !lambda |-
            float v = id(cfg_decel).state;
            return std::isnan(v) ? (float) ${decel} : v;

  # ---- Emergency stop ----
  #
  # Slams deceleration up, stops, waits for the axis to actually
  # settle, then puts deceleration back where the user had it.
  - id: estop
    mode: restart
    then:
      - stepper.set_deceleration:
          id: win_stepper
          deceleration: ${estop_decel} steps/s^2
      - stepper.set_target:
          id: win_stepper
          target: !lambda 'return id(win_stepper).current_position;'
      - wait_until:
          condition:
            lambda: 'return id(win_stepper).current_position == id(win_stepper).target_position;'
          timeout: 5s
      - stepper.set_deceleration:
          id: win_stepper
          deceleration: !lambda |-
            float v = id(cfg_decel).state;
            return std::isnan(v) ? (float) ${decel} : v;

  # ---- Every normal move goes through here ----
  #
  # If the request reverses the direction we are currently travelling
  # in, come to a complete stop first and only then set off the other
  # way. Handing the stepper a reversed target while it is under way
  # puts peak torque demand and a direction change in the same
  # instant, which is where the belt jumps teeth.
  - id: safe_move
    mode: restart
    parameters:
      tgt: int
    then:
      # An explicit command WINS. It used to be dropped whenever
      # calibrating was set, which meant any path that left that
      # flag on silently killed every button and the slider with no
      # way back except a reboot. Cancel whatever is running,
      # put the ramp and speed back where the user had them, move.
      - script.stop: calibrate
      - script.stop: assist_run
      - script.stop: seek_end
      - script.stop: back_off_clear
      - script.stop: settle_end
      - lambda: |-
          id(calibrating) = false;
          id(assisting) = false;
          id(end_busy) = false;
          id(hand_opened) = false;   // a commanded move supersedes
      - switch.turn_on: driver_enable
      - script.execute: restore_accel
      - stepper.set_speed:
          id: win_stepper
          speed: !lambda |-
            float v = id(cfg_run_speed).state;
            return std::isnan(v) ? (float) ${run_speed} : v;
      - if:
          condition:
            lambda: |-
              long cur = id(win_stepper).current_position;
              long now_tgt = id(win_stepper).target_position;
              if (cur == now_tgt) return false;   // already stopped
              if (tgt == cur) return false;       // asking for a stop
              int moving = (now_tgt > cur) ? 1 : -1;
              int wanted = (tgt > cur) ? 1 : -1;
              return moving != wanted;
          then:
            - logger.log: "Reversal mid-move - ramping to a stop first"
            # Ramped stop, not a chop: aim the braking distance
            # ahead in the direction we are already moving and let
            # the decel curve bring us to rest. A chop at speed
            # winds the belt up and the reversal then releases that
            # tension across the backlash - the pop. Clamped inside
            # the span; if there is no room to brake, chop anyway
            # and let the limit switch be the backstop.
            - stepper.set_target:
                id: win_stepper
                target: !lambda |-
                  long cur = id(win_stepper).current_position;
                  long tgt0 = id(win_stepper).target_position;
                  int dir = (tgt0 > cur) ? 1 : -1;
                  float v = id(cfg_run_speed).state;
                  if (std::isnan(v)) v = (float) ${run_speed};
                  float dec = id(cfg_decel).state;
                  if (std::isnan(dec) || dec <= 0) dec = (float) ${decel};
                  long brake = (long) ((v * v) / (2.0f * dec));
                  long stop = cur + dir * brake;
                  long a = 0, b = id(travel_steps);
                  long lo = ((a < b) ? a : b) + ${backoff_steps};
                  long hi = ((a > b) ? a : b) - ${backoff_steps};
                  if (stop < lo) stop = lo;
                  if (stop > hi) stop = hi;
                  // clamped to somewhere behind us? no room - chop
                  if ((stop - cur) * dir < 0) stop = cur;
                  return (int) stop;
            - wait_until:
                condition:
                  lambda: 'return id(win_stepper).current_position == id(win_stepper).target_position;'
                timeout: 30s
            - delay: 250ms
      - stepper.set_target:
          id: win_stepper
          target: !lambda 'return tgt;'
      - script.execute: publish_position

  # ---- Go fully to an end ----
  #
  # Move to the stored endpoint normally, then - only if the switch
  # has not asserted - CRAWL the rest of the way until it does.
  #
  # travel_steps only ever ratchets DOWN: a belt skip makes a
  # traverse come up short, and the drift correction accepts
  # anything inside 10%, so the stored endpoint creeps away from the
  # switch and 0% ends up stopping clear of it. Crawling the last
  # bit makes the switch the authority again, and because the limit
  # handler re-zeroes on contact, every full close repairs the
  # coordinate system instead of inheriting the error.
  - id: go_end
    mode: restart
    parameters:
      to_open: bool
    then:
      # end_busy is what stops assist mode barging in. go_end runs
      # with calibrating false (safe_move clears it), and its crawl
      # has plenty of stopped moments, so without an owner flag the
      # assist interval sees "idle, near an end, switch not made"
      # and reads a firmware-driven end seek as a hand push.
      - lambda: 'id(end_busy) = true; id(end_crawled) = false;'
      - switch.turn_on: driver_enable
      - script.execute:
          id: safe_move
          tgt: !lambda 'return to_open ? id(travel_steps) : 0;'
      - script.wait: safe_move
      - lambda: 'id(end_busy) = true;'
      - wait_until:
          condition:
            or:
              - lambda: 'return id(win_stepper).current_position == id(win_stepper).target_position;'
              - lambda: 'return to_open ? id(open_limit).state : id(closed_limit).state;'
          timeout: 180s

      # Stored endpoint reached but the switch is not made: crawl in
      # very slowly until it is.
      - if:
          condition:
            lambda: 'return !(to_open ? id(open_limit).state : id(closed_limit).state);'
          then:
            - logger.log:
                format: "Stored %s endpoint short of the switch - crawling in"
                args: ['to_open ? "open" : "closed"']
            - lambda: 'id(end_crawled) = true;'
            - stepper.set_acceleration:
                id: win_stepper
                acceleration: ${backoff_accel} steps/s^2
            - stepper.set_speed:
                id: win_stepper
                speed: ${end_crawl_speed} steps/s
            - stepper.set_target:
                id: win_stepper
                target: !lambda |-
                  long over = (long) (${end_overshoot_mm}
                                    * labs(id(travel_steps)) / ${travel_mm});
                  int toward = to_open ? 1 : -1;
                  return id(win_stepper).current_position
                       + (int) (toward * over * id(dir_sign));
            - wait_until:
                condition:
                  or:
                    - lambda: 'return to_open ? id(open_limit).state : id(closed_limit).state;'
                    - lambda: 'return id(win_stepper).current_position == id(win_stepper).target_position;'
                timeout: 60s
            - stepper.set_target:
                id: win_stepper
                target: !lambda 'return id(win_stepper).current_position;'
            - delay: 250ms
            - if:
                condition:
                  lambda: 'return !(to_open ? id(open_limit).state : id(closed_limit).state);'
                then:
                  - logger.log:
                      level: WARN
                      format: "Crawled the full ${end_overshoot_mm} mm without finding the switch - recalibrate"

      # Settle only when the arrival was dodgy: the stored endpoint
      # fell short (we had to crawl in), or the panel bounced back
      # off the switch inside the bounce window. A clean arrival
      # that is still on the switch needs nothing more.
      - delay: 600ms
      - if:
          condition:
            lambda: |-
              bool on_sw = to_open ? id(open_limit).state
                                   : id(closed_limit).state;
              return id(end_crawled) || !on_sw;
          then:
            - script.execute:
                id: settle_end
                to_open: !lambda 'return to_open;'
            - script.wait: settle_end

      - script.execute: restore_accel
      - stepper.set_speed:
          id: win_stepper
          speed: !lambda |-
            float v = id(cfg_run_speed).state;
            return std::isnan(v) ? (float) ${run_speed} : v;
      - lambda: 'id(end_busy) = false; id(assist_grace) = ${assist_lockout_ticks};'
      - script.execute: publish_position

  # ---- Settle onto a limit switch ----
  #
  # Back off ${assist_rehome_mm} and creep back on very slowly. Used
  # as the confirming second pass by go_end and at the end of an
  # assisted close. The limit handler does the zeroing on contact.
  - id: settle_end
    mode: restart
    parameters:
      to_open: bool
    then:
      - lambda: 'id(end_busy) = true;'
      - switch.turn_on: driver_enable
      - logger.log:
          format: "Settling onto the %s switch"
          args: ['to_open ? "open" : "closed"']
      - stepper.set_acceleration:
          id: win_stepper
          acceleration: ${backoff_accel} steps/s^2
      - stepper.set_speed:
          id: win_stepper
          speed: ${end_crawl_speed} steps/s
      # back off, away from the end
      - stepper.set_target:
          id: win_stepper
          target: !lambda |-
            long back = (long) (${assist_rehome_mm}
                              * labs(id(travel_steps)) / ${travel_mm});
            if (back < 1) back = 1;
            int away = to_open ? -1 : 1;
            return id(win_stepper).current_position
                 + (int) (away * back * id(dir_sign));
      - wait_until:
          condition:
            lambda: 'return id(win_stepper).current_position == id(win_stepper).target_position;'
          timeout: 20s
      - delay: 250ms
      # creep back on
      - stepper.set_target:
          id: win_stepper
          target: !lambda 'return (to_open ? 2000000 : -2000000) * id(dir_sign);'
      - wait_until:
          condition:
            lambda: 'return to_open ? id(open_limit).state : id(closed_limit).state;'
          timeout: 30s
      - stepper.set_target:
          id: win_stepper
          target: !lambda 'return id(win_stepper).current_position;'
      - delay: 250ms
      - lambda: 'id(end_busy) = false;'

  # ---- Assist traverse ----
  #
  # Runs to the far switch WITHOUT trusting the step count, and
  # WITHOUT writing anything back to travel_steps.
  #
  # A hand push moves the panel while the driver is disabled, so
  # current_position is stale by however far you pushed. Treating
  # the early arrival as drift would shrink the stored travel a
  # little more on every assisted open.
  #
  # Same one-continuous-move shape as the calibration approach, but
  # ${assist_engage_mm} is held back from the cruise to cover the
  # head start the push already gave the panel.
  - id: assist_run
    mode: restart
    parameters:
      to_open: bool
    then:
      - lambda: 'id(assisting) = true;'
      - switch.turn_on: driver_enable
      - logger.log:
          format: "Assist - running to the %s end"
          args: ['to_open ? "open" : "closed"']

      # Let the push carry the panel first, then take over. Tunable
      # live from HA. Cancelled cleanly if a command or a stop lands
      # during it, because this script is script.stop-ed by both.
      - delay: !lambda |-
          float v = id(cfg_assist_delay).state;
          return (uint32_t) (std::isnan(v) ? ${assist_delay_ms} : v);

      # Same seamless handover as the measuring pass, but the
      # handover point is pulled in by ${assist_engage_mm} to cover
      # the head start the push already gave the panel.
      - lambda: |-
          long travel = labs(id(travel_steps));
          double spmm = (double) travel / (double) ${travel_mm};
          int toward = to_open ? 1 : -1;
          long s = id(dir_sign);

          float dec = id(cfg_decel).state;
          if (std::isnan(dec) || dec <= 0) dec = (float) ${decel};

          id(approach_slow) = (float) (${cal_slow_mm_s} * spmm);
          // fast = travel - slow - engage. The slow segment is the
          // buffer that absorbs a push LONGER than assist_engage_mm:
          // with the handover pinned near the far end instead, a
          // strong push put the panel into the opposite end stop at
          // cruise speed.
          long handover = (long) ((${travel_mm} - ${assist_slow_mm} - ${assist_engage_mm}) * spmm);
          if (handover < 0) handover = 0;
          long coast = (long) ((id(approach_slow) * id(approach_slow))
                               / (2.0f * dec));

          id(dash_handover) = id(win_stepper).current_position
                            + (int) (toward * handover * s);
          id(dash_target)   = id(win_stepper).current_position
                            + (int) (toward * (handover + coast) * s);

      - if:
          condition:
            lambda: |-
              bool on_switch = to_open ? id(open_limit).state
                                       : id(closed_limit).state;
              return !on_switch
                  && id(dash_target) != id(win_stepper).current_position;
          then:
            # Engage at speed, no ramp. The push already has the
            # panel moving, so accelerating up from zero just leaves
            # the motor trailing something already in motion - and
            # because assist fires near an end, the shaping interval
            # would otherwise have left acceleration at its LOWEST
            # value, which is exactly wrong here.
            #
            # Only acceleration. Deceleration is deliberately left
            # alone so the handover coast distance computed above
            # stays valid.
            - stepper.set_acceleration:
                id: win_stepper
                acceleration: ${assist_accel} steps/s^2
            - stepper.set_speed:
                id: win_stepper
                speed: !lambda |-
                  float v = id(cfg_assist_speed).state;
                  return std::isnan(v) ? (float) ${assist_speed} : v;
            - stepper.set_target:
                id: win_stepper
                target: !lambda 'return id(dash_target);'
            - wait_until:
                condition:
                  or:
                    - lambda: |-
                        long cur = id(win_stepper).current_position;
                        int toward = to_open ? 1 : -1;
                        return ((cur - id(dash_handover))
                                * toward * id(dir_sign)) >= 0;
                    - lambda: 'return to_open ? id(open_limit).state : id(closed_limit).state;'
                    - lambda: 'return id(win_stepper).current_position == id(win_stepper).target_position;'
                timeout: 180s

      # crawl the rest of the way in, if the switch has not tripped
      - if:
          condition:
            lambda: 'return !(to_open ? id(open_limit).state : id(closed_limit).state);'
          then:
            - stepper.set_speed:
                id: win_stepper
                speed: !lambda 'return id(approach_slow);'
            - stepper.set_target:
                id: win_stepper
                target: !lambda 'return (to_open ? 2000000 : -2000000) * id(dir_sign);'
            - wait_until:
                condition:
                  lambda: 'return to_open ? id(open_limit).state : id(closed_limit).state;'
                timeout: 180s
            - if:
                condition:
                  lambda: 'return !(to_open ? id(open_limit).state : id(closed_limit).state);'
                then:
                  - logger.log:
                      level: WARN
                      format: "Assist run timed out before reaching the end"
      - stepper.set_target:
          id: win_stepper
          target: !lambda 'return id(win_stepper).current_position;'
      - delay: 300ms

      # --- bounce check on a close ---
      # Settle only if the panel bounced back off the switch. Runs
      # while assisting is still set, so the re-press is not judged
      # a fault.
      - if:
          condition:
            lambda: 'return !to_open;'
          then:
            - delay: 600ms
            - if:
                condition:
                  binary_sensor.is_off: closed_limit
                then:
                  - logger.log: "Assist close bounced off the switch - settling"
                  - script.execute:
                      id: settle_end
                      to_open: false
                  - script.wait: settle_end

      - script.execute: restore_accel
      # hold the grace window open past the flag drop
      - lambda: 'id(assisting) = false; id(assist_grace) = ${assist_lockout_ticks};'
      - stepper.set_speed:
          id: win_stepper
          speed: !lambda |-
            float v = id(cfg_run_speed).state;
            return std::isnan(v) ? (float) ${run_speed} : v;
      - script.execute: publish_position

  # ---- Reverse away from an end until its switch releases ----
  #
  # Escalates the distance on each retry, because a back-off that is
  # shorter than the magnet/reed hysteresis silently leaves the
  # switch asserted, and the creep pass that follows would then
  # "arrive" instantly and zero at completely the wrong place.
  #
  # First attempt runs at creep_speed: if the panel is jammed into
  # the end stop this is the breakaway move, and that is exactly
  # where torque demand peaks and the belt skips teeth.
  - id: back_off_clear
    mode: restart
    parameters:
      to_open: bool
    then:
      - lambda: 'id(backoff_tries) = 0;'
      - switch.turn_on: driver_enable
      # gentle ramp for the whole back-off, restored on the way out
      - stepper.set_acceleration:
          id: win_stepper
          acceleration: ${backoff_accel} steps/s^2
      - stepper.set_deceleration:
          id: win_stepper
          deceleration: ${backoff_accel} steps/s^2
      - while:
          condition:
            and:
              - lambda: 'return to_open ? id(open_limit).state : id(closed_limit).state;'
              - lambda: 'return id(backoff_tries) < 4;'
          then:
            - lambda: 'id(backoff_tries) += 1;'
            - logger.log:
                format: "Backing off clear of the %s limit (attempt %d)"
                args:
                  - 'to_open ? "open" : "closed"'
                  - 'id(backoff_tries)'
            - if:
                condition:
                  lambda: 'return id(backoff_tries) == 1;'
                then:
                  - stepper.set_speed:
                      id: win_stepper
                      speed: ${creep_speed} steps/s
                else:
                  - stepper.set_speed:
                      id: win_stepper
                      speed: ${home_speed} steps/s
            # move AWAY from the end we are homing to. The move is
            # bounded (backoff_steps x tries) and heads into open
            # space, so it is allowed to FINISH ON ITS OWN RAMP
            # rather than being chopped at the release point - a
            # chopped stop winds the belt up, and the very next
            # thing this sequence does is reverse, which released
            # that tension across the backlash as an audible pop.
            - stepper.set_target:
                id: win_stepper
                target: !lambda |-
                  int away = to_open ? -1 : 1;
                  return id(win_stepper).current_position
                         + (away * ${backoff_steps} * id(backoff_tries) * id(dir_sign));
            - wait_until:
                condition:
                  lambda: 'return id(win_stepper).current_position == id(win_stepper).target_position;'
                timeout: 60s
            - delay: 300ms
      - if:
          condition:
            lambda: 'return to_open ? id(open_limit).state : id(closed_limit).state;'
          then:
            - lambda: 'id(home_ok) = false;'
            - logger.log:
                level: ERROR
                format: "Limit will not release after %d back-offs - is the switch stuck on?"
                args: ['id(backoff_tries)']
      # put the normal ramp back
      - script.execute: restore_accel
      - script.wait: restore_accel

  # ---- Approach one end and stop on its switch ----
  #
  # 0. settle, skip the seek if already parked on the switch
  # 1. optional approach run at cal_speed, handing over to a slow
  #    crawl near the switch without stopping
  # 2. fast pass at home_speed
  # 3. back off clear
  # 4. creep pass at creep_speed
  #
  # Leaves the panel stopped on the switch with home_ok true, or
  # stopped somewhere safe with home_ok false. Never zeroes anything
  # itself - the caller decides what the position means.
  - id: seek_end
    mode: restart
    parameters:
      to_open: bool
      dash: bool
    then:
      - lambda: 'id(home_ok) = true;'
      - switch.turn_on: driver_enable

      # The debounce filters have not published a real state yet when
      # this runs from on_boot, and an unpublished binary_sensor reads
      # as off. Deciding "am I on the switch?" before this delay is
      # what used to send the panel into the end stop at full speed.
      - delay: ${settle_time}

      # --- 0. are we already at this end? ---
      #
      # If the switch is already on there is nothing to search for,
      # so the approach run and the seek are both skipped and we go
      # straight to back-off + creep. That pass is kept rather than
      # skipped outright: the switch asserts anywhere across the
      # magnet's range, so "the switch is on" locates the panel only
      # to within the reed hysteresis - under 30 mm, but the closed
      # end IS the zero, and that error would land in travel_steps
      # and in every position afterwards. The back-off and creep are
      # what make it repeatable, and they only cost a few seconds
      # because there is no traverse involved.
      - lambda: |-
          id(seek_at_end) = to_open ? id(open_limit).state
                                    : id(closed_limit).state;
      - if:
          condition:
            lambda: 'return id(seek_at_end);'
          then:
            - logger.log:
                format: "Already at the %s end - confirming without seeking"
                args: ['to_open ? "open" : "closed"']

      # --- 1. approach run ---
      #
      # Run to ${cal_handover_mm}, then continue into the switch at
      # ${cal_slow_mm_s} mm/s WITHOUT ever stopping.
      #
      # The handover is the whole trick. calculate_speed_() does
      #     current_speed = clamp(current_speed, 0, max_speed)
      # so simply lowering max speed mid-move drops the velocity in
      # one step - a jerk straight into the belt. Instead the target
      # is aimed PAST the handover point by v_slow^2 / 2*decel, the
      # exact distance the deceleration curve v = sqrt(2*decel*n)
      # needs to fall to the slow speed. Actual speed therefore
      # already equals the new max when it is applied, and the clamp
      # has nothing to cut.
      #
      # Everything is derived from travel_steps rather than assumed
      # steps/mm, so it stays correct whatever microstepping the
      # driver came up in. Aborts on the switch, so a stale
      # travel_steps cannot slam the end stop.
      - if:
          condition:
            lambda: 'return dash && id(home_ok) && !id(seek_at_end);'
          then:
            - lambda: |-
                long travel = labs(id(travel_steps));
                double spmm = (double) travel / (double) ${travel_mm};
                int toward = to_open ? 1 : -1;
                long s = id(dir_sign);

                float dec = id(cfg_decel).state;
                if (std::isnan(dec) || dec <= 0) dec = (float) ${decel};

                id(approach_slow) = (float) (${cal_slow_mm_s} * spmm);
                long handover = (long) (${cal_handover_mm} * spmm);
                long coast = (long) ((id(approach_slow) * id(approach_slow))
                                     / (2.0f * dec));

                id(dash_handover) = id(win_stepper).current_position
                                  + (int) (toward * handover * s);
                id(dash_target)   = id(win_stepper).current_position
                                  + (int) (toward * (handover + coast) * s);
            - if:
                condition:
                  lambda: |-
                    bool on_switch = to_open ? id(open_limit).state
                                             : id(closed_limit).state;
                    return !on_switch
                        && id(dash_target) != id(win_stepper).current_position;
                then:
                  - logger.log:
                      format: "Approach: fast to %d, then %.0f steps/s into the switch"
                      args:
                        - 'id(dash_handover)'
                        - 'id(approach_slow)'
                  - stepper.set_speed:
                      id: win_stepper
                      speed: ${cal_speed} steps/s
                  - stepper.set_target:
                      id: win_stepper
                      target: !lambda 'return id(dash_target);'
                  # ride the ramp down to the handover point
                  - wait_until:
                      condition:
                        or:
                          - lambda: |-
                              long cur = id(win_stepper).current_position;
                              int toward = to_open ? 1 : -1;
                              return ((cur - id(dash_handover))
                                      * toward * id(dir_sign)) >= 0;
                          - lambda: 'return to_open ? id(open_limit).state : id(closed_limit).state;'
                          - lambda: 'return id(win_stepper).current_position == id(win_stepper).target_position;'
                      timeout: 120s
                  # seamless: actual speed is already approach_slow
                  - if:
                      condition:
                        lambda: 'return !(to_open ? id(open_limit).state : id(closed_limit).state);'
                      then:
                        - stepper.set_speed:
                            id: win_stepper
                            speed: !lambda 'return id(approach_slow);'
                        - stepper.set_target:
                            id: win_stepper
                            target: !lambda 'return (to_open ? 2000000 : -2000000) * id(dir_sign);'
                        - wait_until:
                            condition:
                              lambda: 'return to_open ? id(open_limit).state : id(closed_limit).state;'
                            timeout: 120s
                  - stepper.set_target:
                      id: win_stepper
                      target: !lambda 'return id(win_stepper).current_position;'
                  - delay: 300ms

      # --- 2. fast approach ---
      # Skipped when we started on the switch - nothing to find.
      - if:
          condition:
            lambda: 'return id(home_ok) && !id(seek_at_end);'
          then:
            - logger.log:
                format: "Seeking the %s end"
                args: ['to_open ? "open" : "closed"']
            - stepper.set_speed:
                id: win_stepper
                speed: ${home_speed} steps/s
            - stepper.set_target:
                id: win_stepper
                target: !lambda 'return (to_open ? 2000000 : -2000000) * id(dir_sign);'
            - wait_until:
                condition:
                  lambda: 'return to_open ? id(open_limit).state : id(closed_limit).state;'
                timeout: 180s
            - stepper.set_target:
                id: win_stepper
                target: !lambda 'return id(win_stepper).current_position;'
            - delay: 400ms
            - if:
                condition:
                  lambda: 'return !(to_open ? id(open_limit).state : id(closed_limit).state);'
                then:
                  - lambda: 'id(home_ok) = false;'
                  - logger.log:
                      level: ERROR
                      format: "Timed out on the fast pass to the %s end"
                      args: ['to_open ? "open" : "closed"']

      # --- 3. back off clear of the switch ---
      - if:
          condition:
            lambda: 'return id(home_ok);'
          then:
            - script.execute:
                id: back_off_clear
                to_open: !lambda 'return to_open;'
            - script.wait: back_off_clear

      # --- 4. creep back on for the accurate reading ---
      - if:
          condition:
            lambda: 'return id(home_ok);'
          then:
            - logger.log: "Slow approach"
            - stepper.set_speed:
                id: win_stepper
                speed: ${creep_speed} steps/s
            - stepper.set_target:
                id: win_stepper
                target: !lambda 'return (to_open ? 2000000 : -2000000) * id(dir_sign);'
            - wait_until:
                condition:
                  lambda: 'return to_open ? id(open_limit).state : id(closed_limit).state;'
                timeout: 120s
            - stepper.set_target:
                id: win_stepper
                target: !lambda 'return id(win_stepper).current_position;'
            - delay: 400ms
            - if:
                condition:
                  lambda: 'return !(to_open ? id(open_limit).state : id(closed_limit).state);'
                then:
                  - lambda: 'id(home_ok) = false;'
                  - logger.log:
                      level: ERROR
                      format: "Creep pass never reached the %s end"
                      args: ['to_open ? "open" : "closed"']

      # whatever happened, do not leave the motor commanded somewhere
      - if:
          condition:
            lambda: 'return !id(home_ok);'
          then:
            - stepper.set_target:
                id: win_stepper
                target: !lambda 'return id(win_stepper).current_position;'

  ##################################################################
  # Calibration
  #
  # The only homing routine. Re-home and Recalibrate are this.
  #
  #  1. home to the NEARER end, no dash, and call it the origin
  #  2. run the full width to the far end, measuring   (dash)
  #  3. end up closed
  #
  # Homing the nearer end first is what stops a nearly-open window
  # crawling the whole 1050 mm at home_speed just to reach closed.
  # Which end that is comes from the switches when either is on,
  # and only falls back to the step count when neither is.
  ##################################################################

  - id: calibrate
    mode: restart
    then:
      - script.stop: safe_move
      - script.stop: assist_run
      - script.stop: go_end
      - script.stop: settle_end
      - lambda: |-
          id(calibrating) = true;
          id(assisting) = false;
          id(end_busy) = false;
      # defensive: an earlier run killed mid-back-off would have
      # left the gentle ramp in place
      - script.execute: restore_accel
      # rewrite driver registers - lost if 24 V ever dropped
      - script.execute: tmc_init

      - lambda: |-
          if (id(closed_limit).state) {
            id(home_first_open) = false;
          } else if (id(open_limit).state) {
            id(home_first_open) = true;
          } else {
            long travel = labs(id(travel_steps));
            long cur = labs(id(win_stepper).current_position);
            id(home_first_open) = (travel > 0) && (cur * 2 > travel);
          }
      - logger.log:
          format: "Calibrating - homing the %s end first"
          args: ['id(home_first_open) ? "open" : "closed"']

      # --- nearer end: establish the origin ---
      # No dash. Until this lands we do not know where the panel is,
      # so the whole pass runs at a speed the switch can catch.
      - script.execute:
          id: seek_end
          to_open: !lambda 'return id(home_first_open);'
          dash: false
      - script.wait: seek_end

      - if:
          condition:
            lambda: 'return !id(home_ok);'
          then:
            - logger.log:
                level: ERROR
                format: "Calibration abandoned at the near end - position not zeroed"
            - lambda: 'id(calibrating) = false;'
            - script.stop: calibrate

      # Temporary origin. If we homed the open end this is NOT the
      # real coordinate system yet - it gets fixed up below once the
      # width is known.
      - lambda: 'id(win_stepper).report_position(0);'
      - stepper.set_target:
          id: win_stepper
          target: 0
      - logger.log: "Near end found"

      # --- far end, counting ---
      - script.execute:
          id: seek_end
          to_open: !lambda 'return !id(home_first_open);'
          dash: true
      - script.wait: seek_end

      - if:
          condition:
            lambda: 'return id(home_ok);'
          then:
            - lambda: |-
                int measured = id(win_stepper).current_position;
                // Homed open first, so we ran open -> closed and the
                // count came out mirrored. Flip it.
                id(travel_steps) = id(home_first_open) ? -measured : measured;
                id(is_calibrated) = true;
                ESP_LOGI("cal", "Travel measured: %d steps", id(travel_steps));
            # Homed open first, so the temporary origin was at the
            # open end and we are now standing at closed. Restate
            # that as 0 - and move the TARGET with it, or the stepper
            # sees a full traverse of outstanding travel and drives
            # straight into the end stop.
            - if:
                condition:
                  lambda: 'return id(home_first_open);'
                then:
                  - lambda: 'id(win_stepper).report_position(0);'
                  - stepper.set_target:
                      id: win_stepper
                      target: 0
          else:
            - logger.log:
                level: ERROR
                format: "Far end not found - keeping the previous travel figure"

      # --- end up closed ---
      # Homing the open end first means the measuring pass already
      # finished at closed, so there is nothing to travel back.
      - if:
          condition:
            lambda: 'return !id(home_first_open);'
          then:
            - stepper.set_speed:
                id: win_stepper
                speed: !lambda |-
                  float v = id(cfg_run_speed).state;
                  return std::isnan(v) ? (float) ${run_speed} : v;
            - stepper.set_target:
                id: win_stepper
                target: 0
            - wait_until:
                condition:
                  binary_sensor.is_on: closed_limit
                timeout: 180s
            - delay: 300ms
      # The return above approaches the switch with the limit
      # handler suppressed (calibrating is set), so nothing checks
      # whether the panel STAYED on the switch after contact - a
      # bounced arrival used to end calibration claiming closed
      # while sitting off it. Clear the flag, arm the assist
      # lockout, give the bounce its window, and only settle if the
      # switch actually came off; the handler is live again by then
      # and re-zeroes on the final contact.
      - lambda: |-
          id(calibrating) = false;
          id(assist_grace) = ${assist_lockout_ticks};
      - if:
          condition:
            lambda: 'return !id(home_first_open) && id(home_ok);'
          then:
            - delay: 600ms
            - if:
                condition:
                  binary_sensor.is_off: closed_limit
                then:
                  - logger.log: "Calibration return bounced off the switch - settling"
                  - script.execute:
                      id: settle_end
                      to_open: false
                  - script.wait: settle_end
      - script.execute: publish_position
      - logger.log: "Calibration complete"

button:
  - platform: template
    name: "Calibrate"
    entity_category: config
    on_press:
      - script.execute: calibrate

####################################################################
# Acceleration shaping
#
# The stepper holds one acceleration value, so the profile is
# applied by rewriting it from the current position. Distance to the
# NEARER end sets it: accel_min right at an end, rising linearly to
# accel_max once ${accel_zone_mm} clear of both. Breakaway force
# peaks at the extremes, which is where the belt skips teeth.
#
# Suppressed while calibrating or assisting - those scripts set
# acceleration deliberately (the gentle back-off ramp especially)
# and must not have it overwritten underneath them.
####################################################################

interval:
  - interval: 100ms
    then:
      - if:
          condition:
            # end_busy included: go_end and settle_end set the gentle
            # crawl ramp deliberately, and this interval was quietly
            # overwriting it within 100 ms
            lambda: 'return !id(calibrating) && !id(assisting) && !id(end_busy);'
          then:
            - lambda: |-
                long travel = labs(id(travel_steps));
                if (travel <= 0) return;
                double spmm = (double) travel / (double) ${travel_mm};
                long zone = (long) (${accel_zone_mm} * spmm);
                if (zone < 1) zone = 1;

                long cur = labs(id(win_stepper).current_position);
                if (cur < 0) cur = 0;
                if (cur > travel) cur = travel;
                long d = (cur < travel - cur) ? cur : travel - cur;

                float amin = id(cfg_accel_ends).state;
                float amax = id(cfg_accel).state;
                if (std::isnan(amin)) amin = (float) ${accel_min};
                if (std::isnan(amax)) amax = (float) ${accel_max};

                float a = (d >= zone)
                        ? amax
                        : amin + (amax - amin) * ((float) d / (float) zone);

                if (fabsf(a - id(applied_accel)) > 5.0f) {
                  id(applied_accel) = a;
                  id(win_stepper).set_acceleration(a);
                }

####################################################################
# Reporting
#
# Publishes only on real change, so an idle window is silent on the
# API instead of pushing the same number several times a second.
####################################################################

  # 1 s / 2 % while moving: each cover publish stalls the step
  # generator (one step per loop pass), so mid-move publishes are
  # kept scarce. Discontinuities still publish immediately via the
  # explicit publish_position calls.
  - interval: 1s
    then:
      - if:
          condition:
            lambda: |-
              long cur = id(win_stepper).current_position;
              bool moving = cur != id(win_stepper).target_position;
              long thresh = labs(id(travel_steps)) / 50;    // 2 %
              if (thresh < 20) thresh = 20;
              return (moving != id(last_pub_moving))
                  || (labs(cur - id(last_pub_pos)) >= thresh);
          then:
            - script.execute: publish_position

####################################################################
# Closed watchdog
#
# "0% must mean the reed is made." If the firmware believes it is
# closed - idle within a switch-width of zero - but the reed is off
# (interrupted settle, missed bounce, boot oddity), creep in until
# it makes and rehome; go_end's crawl + settle is exactly that.
#
# hand_opened is the one exception: the reed releasing outside the
# arrival grace window was a human pulling the panel open, and the
# panel stays where the human put it. Reed contact or any commanded
# move clears the flag.
####################################################################

  - interval: 5s
    then:
      - if:
          condition:
            lambda: |-
              return id(is_calibrated) && id(home_ok)
                  && !id(calibrating) && !id(end_busy) && !id(assisting)
                  && id(assist_grace) == 0
                  && !id(hand_opened)
                  && id(win_stepper).current_position
                       == id(win_stepper).target_position
                  && labs(id(win_stepper).current_position) < ${backoff_steps}
                  && !id(closed_limit).state;
          then:
            - logger.log: "At 0% but the closed reed is off - creeping in to re-make it"
            - script.execute:
                id: go_end
                to_open: false

####################################################################
# Nudge to assist  -  both directions
#
# The reed switches are the only back-drive detector available, so
# this can only see a push that starts at an end: near closed the
# switch releasing means "pushed open", near open it means "pushed
# closed". Mid-travel pushes still need MSCNT polling over UART.
#
# Two separate guards, so this can react fast without reacting to
# noise. assist_idle must reach assist_arm_ticks - the axis has to
# have been genuinely still - before a switch release counts at all.
# That is what stops the panel rocking on arrival from launching a
# traverse. Only then does the short assist_dwell apply.
####################################################################

  - interval: 50ms
    then:
      - lambda: |-
          if (id(assist_grace) > 0) id(assist_grace) -= 1;
          int want = 0;                                  // +1 open, -1 close
          bool idle = id(win_stepper).current_position
                        == id(win_stepper).target_position;

          // how long have we been standing still?
          if (idle) {
            if (id(assist_idle) < 10000) id(assist_idle) += 1;
          } else {
            id(assist_idle) = 0;
          }

          // Driver EN. Motion scripts enable it at entry; this
          // releases it after real idle - the same tick assist
          // arms, so a push never fights holding torque - and acts
          // as a backstop enable if anything moves with it off.
          bool routine = id(calibrating) || id(end_busy) || id(assisting);
          if (!idle && !id(driver_enable).state) {
            id(driver_enable).turn_on();
          } else if (idle && !routine
                     && id(assist_idle) >= ${assist_arm_ticks}
                     && id(driver_enable).state) {
            id(driver_enable).turn_off();
          }

          bool armed = id(assist_mode).state && !id(calibrating)
                       && !id(assisting) && !id(end_busy)
                       && id(assist_grace) == 0
                       && id(home_ok) && idle
                       && id(assist_idle) >= ${assist_arm_ticks};
          if (armed) {
            long cur = id(win_stepper).current_position;
            bool near_closed = labs(cur) < ${assist_window};
            bool near_open   = labs(cur - id(travel_steps)) < ${assist_window};
            if (near_closed && !id(closed_limit).state)    want = 1;
            else if (near_open && !id(open_limit).state)   want = -1;
          }
          if (want != 0 && want == id(assist_want)) {
            id(assist_dwell) += 1;
          } else {
            id(assist_want) = want;
            id(assist_dwell) = 0;
          }
      - if:
          condition:
            lambda: 'return id(assist_want) != 0 && id(assist_dwell) >= ${assist_dwell_ticks};'
          then:
            - logger.log:
                format: "Nudge detected - assisting %s"
                args: ['id(assist_want) > 0 ? "open" : "closed"']
            - script.execute:
                id: assist_run
                to_open: !lambda 'return id(assist_want) > 0;'
            - lambda: |-
                id(assist_want) = 0;
                id(assist_dwell) = 0;
