#################################################################### # 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(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;