@@ -24,6 +24,12 @@ substitutions:
2424 hold_run_ms : " 700" # press longer than this = hold-to-run
2525 tap_window_ms : " 400" # rapid-tap chaining window
2626 combo_home_ms : " 3000" # both buttons held this long = set home
27+ home_speed : " 3000" # homing runs at this fixed speed (above the
28+ # ~2000 drivetrain resonance; gentle into the
29+ # stop; ~14 s for a full 1 m travel)
30+ home_timeout_s : " 60" # homing auto-aborts after this if not stopped
31+ # -- backstops an accidental press and a
32+ # mis-tuned StallGuard that never fires
2733
2834# Product boot steps. Core (priority 600) has already configured the TMC2209 and
2935# stepper; here we resync the derived step count and restore the saved position
@@ -47,6 +53,7 @@ esphome:
4753 - priority : 400.0
4854 then :
4955 - script.execute : recompute_distance
56+ - script.execute : recompute_tcoolthrs
5057 - stepper.report_position :
5158 id : driver
5259 position : !lambda "return id(global_current_position);"
@@ -86,49 +93,134 @@ stepper:
8693 - id : !extend driver
8794 on_stall : !remove
8895 - id : !extend driver
96+ # StallGuard is an OPT-IN advanced feature (sg_enabled, default OFF). When it
97+ # is off, this handler no-ops even if the chip flags a stall, so the default
98+ # product never reacts to StallGuard at all — homing is fully manual. `!extend`
99+ # APPENDS, so the two entries above are order-dependent: !remove deletes core's
100+ # unconditional zeroing, then this installs ours. Verified against the resolved
101+ # config — exactly one on_stall survives.
89102 on_stall :
90- - stepper.stop : driver
91103 - if :
92104 condition :
93- lambda : ' return id(global_state) == 3 ;'
105+ lambda : ' return id(sg_enabled).state ;'
94106 then :
95- - stepper.report_position : { id: driver, position: 0 }
96- - stepper.set_target : { id: driver, target: 0 }
97- - globals.set : { id: global_state, value: "0" }
98- - cover.template.publish : { id: ropener_cover, current_operation: IDLE }
99- - logger.log : " Homed cover"
100- # Stall outside homing (curtain snagged, or it hit the far end): stop
101- # and settle the UI back to idle, but LEAVE the position alone — this
102- # point is not home, and zeroing here is what corrupted v2.7.0.
103- else :
104- - globals.set : { id: global_state, value: "0" }
105- - cover.template.publish : { id: ropener_cover, current_operation: IDLE }
106- - logger.log : " Stall detected outside homing — motor stopped, position unchanged"
107+ - stepper.stop : driver
108+ - if :
109+ condition :
110+ lambda : ' return id(global_state) == 3;'
111+ then :
112+ # Genuine stall while homing = the end stop. Commit home.
113+ - script.execute : home_finish
114+ # Stall outside homing (snag, or hit the far end): stop and settle,
115+ # but LEAVE position alone — this point is not home, and zeroing
116+ # here is what corrupted v2.7.0.
117+ else :
118+ - globals.set : { id: global_state, value: "0" }
119+ - cover.template.publish : { id: ropener_cover, current_operation: IDLE }
120+ - logger.log : " Stall detected outside homing — motor stopped, position unchanged"
107121
108122script :
123+ # Derive TCOOLTHRS from the configured Speed.
124+ #
125+ # StallGuard only reports while TSTEP <= TCOOLTHRS, and TSTEP is inversely
126+ # proportional to velocity. Measured on VAL3000 hardware (microsteps=8),
127+ # within 1% from Speed 3000 to 9000 under real curtain load:
128+ #
129+ # TSTEP ~= 374000 / Speed
130+ #
131+ # So the shipped default of TCOOLTHRS=100 arms only above Speed ~3740. Below
132+ # that, StallGuard is switched off entirely -- no error, no log line, nothing
133+ # in the UI, and homing simply never completes. That is a setting a customer
134+ # can reach just by dragging the Speed slider down for a quieter curtain.
135+ #
136+ # 2x cruise TSTEP arms from half of cruise speed upward at any Speed, which
137+ # keeps the deliberate low-velocity cutoff (SG_RESULT is unreliable when slow,
138+ # which is what TCOOLTHRS is for) while guaranteeing it actually engages.
139+ - id : recompute_tcoolthrs
140+ then :
141+ - lambda : |-
142+ uint32_t sp = id(global_speed) > 0 ? (uint32_t) id(global_speed) : 1;
143+ uint32_t v = 748000UL / sp;
144+ if (v > 1048575) v = 1048575; // TCOOLTHRS is a 20-bit register
145+ id(global_tcoolthrs) = (int) v;
146+ id(driver)->write_register(TCOOLTHRS, v);
147+
109148 # Recompute the derived step cache from the persisted centimeters (one place).
110149 - id : recompute_distance
111150 then :
112151 - globals.set :
113152 id : global_distance_steps
114153 value : !lambda "return (float(id(global_distance_cm)) / ${gear_distance_cm}) * ${steps_per_revolution};"
115154
116- # Sensorless homing: drive toward the closed end until StallGuard fires.
117- - id : start_homing
155+ # --- Homing (manual by default; StallGuard only assists when sg_enabled) -----
156+ # Drive toward the closed end at a fixed gentle speed. It ends one of three
157+ # ways: the user presses Start-Stop again (home_finish), the timeout fires
158+ # (home_abort), or -- only if sg_enabled -- StallGuard trips (home_finish).
159+ #
160+ # Drives RELATIVE (current - 100000) and does NOT zero at start, so an aborted
161+ # home leaves the position honest instead of committing a false zero. Home is
162+ # committed to 0 only by a deliberate finish.
163+ - id : home_start
118164 then :
119165 - globals.set : { id: global_state, value: "3" }
120166 - cover.template.publish : { id: ropener_cover, current_operation: CLOSING }
167+ - stepper.set_speed :
168+ id : driver
169+ speed : ${home_speed}
170+ - stepper.set_target :
171+ id : driver
172+ target : !lambda "return id(driver)->current_position - 100000;"
173+ - script.execute : home_timeout
174+ - logger.log : " Homing started — press Start-Stop again at the closed end to set home"
175+
176+ # Reached the real closed end: commit position 0 as home, restore run speed.
177+ - id : home_finish
178+ then :
179+ - script.stop : home_timeout
180+ - stepper.stop : driver
121181 - stepper.report_position : { id: driver, position: 0 }
122- - stepper.set_target : { id: driver, target: -100000 }
123- - logger.log : " Starting homing"
182+ - stepper.set_target : { id: driver, target: 0 }
183+ - stepper.set_speed : { id: driver, speed: !lambda "return id(global_speed);" }
184+ - globals.set : { id: global_state, value: "0" }
185+ - globals.set : { id: global_current_position, value: "0" }
186+ - cover.template.publish : { id: ropener_cover, current_operation: IDLE }
187+ - logger.log : " Home set to 0"
188+
189+ # Timeout (or any abort): stop, restore run speed, and DO NOT set home. The
190+ # position is left exactly where it is -- committing a home on an accidental or
191+ # timed-out press is what corrupts the reference and inverts open/close.
192+ - id : home_abort
193+ then :
194+ - stepper.stop : driver
195+ - stepper.set_target :
196+ id : driver
197+ target : !lambda "return id(driver)->current_position;"
198+ - stepper.set_speed : { id: driver, speed: !lambda "return id(global_speed);" }
199+ - globals.set : { id: global_state, value: "0" }
200+ - cover.template.publish : { id: ropener_cover, current_operation: IDLE }
201+ - logger.log : " Homing timed out — aborted, home NOT set (position unchanged)"
202+
203+ # Unconditional backstop: if still homing after home_timeout_s, abort. Covers an
204+ # accidental Start-Stop press AND a mis-tuned StallGuard that never trips.
205+ - id : home_timeout
206+ mode : restart
207+ then :
208+ - delay : ${home_timeout_s}s
209+ - if :
210+ condition : { lambda: 'return id(global_state) == 3;' }
211+ then :
212+ - script.execute : home_abort
124213
125- # Declare current position as home (0).
214+ # Declare current position as home (0) — the two-button "set home here" combo .
126215 - id : set_home_zero
127216 then :
217+ - script.stop : home_timeout
128218 - stepper.stop : driver
129219 - stepper.report_position : { id: driver, position: 0 }
130220 - stepper.set_target : { id: driver, target: 0 }
221+ - stepper.set_speed : { id: driver, speed: !lambda "return id(global_speed);" }
131222 - globals.set : { id: global_state, value: "0" }
223+ - globals.set : { id: global_current_position, value: "0" }
132224 - cover.template.publish : { id: ropener_cover, current_operation: IDLE }
133225 - logger.log : " Home position set to 0"
134226
@@ -253,7 +345,7 @@ binary_sensor:
253345 - script.stop : b1_hold_check
254346 - globals.set : { id: b1_tap_count, value: "0" }
255347 - globals.set : { id: b1_consumed, value: "true" }
256- - script.execute : start_homing
348+ - script.execute : home_start
257349 - logger.log : " Button 1 x7: starting homing"
258350 else :
259351 - script.execute : b1_hold_check
@@ -366,14 +458,92 @@ select:
366458 return std::string("Reversed ↺");
367459 return std::string("Normal");
368460
461+ # Friendly Slow / Medium / Fast speed, chosen to straddle the ~2000 drivetrain
462+ # resonance (measured): Slow 1500 sits below it, Medium 5000 and Fast 9000 above.
463+ # Like Motor Direction, this holds NO state of its own -- it reads and writes
464+ # global_speed (the single source of truth, restored on boot) so it never fights
465+ # the restored value. The raw Speed slider lives in the advanced group.
466+ - platform : template
467+ name : " Speed"
468+ id : sel_speed
469+ icon : " mdi:speedometer"
470+ entity_category : CONFIG
471+ web_server : { sorting_weight: 1, sorting_group_id: group_tuning }
472+ update_interval : 1s
473+ options : ["Slow", "Medium", "Fast"]
474+ lambda : |-
475+ const int sp = id(global_speed);
476+ if (sp <= 2000) return std::string("Slow"); // <= resonance edge
477+ if (sp <= 6500) return std::string("Medium");
478+ return std::string("Fast");
479+ set_action :
480+ - lambda : |-
481+ int s = 9000; // Fast
482+ if (x.find("Slow") != std::string::npos) s = 1500;
483+ else if (x.find("Medium") != std::string::npos) s = 5000;
484+ id(global_speed) = s;
485+ - stepper.set_speed : { id: driver, speed: !lambda "return id(global_speed);" }
486+ - script.execute : recompute_tcoolthrs
487+ - lambda : |-
488+ // Persist immediately, like Motor Direction, so the choice survives an
489+ // unplug seconds later.
490+ global_speed->update();
491+ global_preferences->sync();
492+
369493# --- Rename core tuning entities to Ropener's existing entity_ids -------------
370494number :
495+ # Raw IRUN (register 1-31) is meaningless to users -- the friendly "Motor
496+ # Current" number below drives it in milliamps instead. Keep the raw register
497+ # in the advanced group for power users.
371498 - id : !extend num_irun
372499 name : " IRUN value"
500+ web_server : { sorting_weight: 7, sorting_group_id: group_sg }
501+ # Friendly motor current in mA. The driver knows Rsense (100 mOhm) and Vsense,
502+ # so write_run_current_mA / read_run_current_mA do the register conversion --
503+ # no formula here. Max is the register ceiling (~1915 mA at these settings);
504+ # the motor is rated 2 A continuous, so nothing the driver can produce is
505+ # thermally unsafe. Values snap to the nearest of 32 register steps (~60 mA).
506+ - platform : template
507+ name : " Motor Current"
508+ id : num_motor_current
509+ unit_of_measurement : " mA"
510+ icon : " mdi:current-dc"
511+ web_server : { sorting_weight: 3, sorting_group_id: group_tuning }
512+ min_value : 100
513+ max_value : 1900
514+ step : 10
515+ mode : BOX
516+ entity_category : CONFIG
517+ lambda : " return id(driver)->read_run_current_mA();"
518+ update_interval : 5s
519+ set_action :
520+ - lambda : " id(driver)->write_run_current_mA((uint16_t) x);"
521+ # Keep global_irun (persisted, reapplied in on_boot) in sync with the
522+ # register field the driver just derived.
523+ - globals.set :
524+ id : global_irun
525+ value : !lambda "return id(driver)->read_field(IRUN_FIELD);"
526+ # TCOOLTHRS and SGTHRS are StallGuard tuning -> move them to the bottom group_sg
527+ # (core puts them in group_stallguard, which we're retiring in favour of group_sg).
373528 - id : !extend num_tcoolthrs
374529 name : " TCOOLTHRS value"
530+ web_server : { sorting_weight: 4, sorting_group_id: group_sg }
375531 - id : !extend num_sgthrs
376532 name : " SGTHRS value"
533+ web_server : { sorting_weight: 3, sorting_group_id: group_sg }
534+ # Raw Speed (steps/s) is now driven by the friendly Slow/Medium/Fast preset
535+ # select below; keep the exact slider in the advanced group. !extend APPENDS to
536+ # core's set_action, so core still stores global_speed and pushes it to the
537+ # driver, then recompute_tcoolthrs runs.
538+ - id : !extend num_speed
539+ web_server : { sorting_weight: 8, sorting_group_id: group_sg }
540+ set_action :
541+ - script.execute : recompute_tcoolthrs
542+ # Acceleration is core's last remaining member of group_motion (Speed moved to the
543+ # advanced group above). Move it to group_tuning too so core's group_motion is fully
544+ # empty and does not render a duplicate "Motion Tuning" header.
545+ - id : !extend num_accel
546+ web_server : { sorting_weight: 2, sorting_group_id: group_tuning }
377547 # Product-only: travel distance in centimeters (drives the cover's 0..1 range).
378548 - platform : template
379549 name : " Centimeters"
@@ -391,10 +561,15 @@ number:
391561 - script.execute : recompute_distance
392562
393563sensor :
564+ # SG_RESULT and TSTEP are raw StallGuard tuning data -- meaningless with
565+ # StallGuard off -- so move them out of Diagnostics into the StallGuard group,
566+ # alongside SGTHRS/TCOOLTHRS and the enable switch.
394567 - id : !extend sen_sgresult
395568 name : " SG_RESULT Sensor"
569+ web_server : { sorting_weight: 5, sorting_group_id: group_sg }
396570 - id : !extend sen_tstep
397571 name : " TSTEP Sensor"
572+ web_server : { sorting_weight: 6, sorting_group_id: group_sg }
398573
399574# --- The curtain cover --------------------------------------------------------
400575cover :
@@ -442,18 +617,72 @@ button:
442617 - if :
443618 condition : { lambda: 'return id(global_state) == 3;' }
444619 then :
445- - stepper.stop : driver
446- - stepper.report_position : { id: driver, position: 0 }
447- - stepper.set_target : { id: driver, target: 0 }
448- - globals.set : { id: global_state, value: "0" }
449- - cover.template.publish : { id: ropener_cover, current_operation: IDLE }
620+ # Second press at the closed end: commit home.
621+ - script.execute : home_finish
450622 else :
451- - globals.set : { id: global_state, value: "3" }
452- - cover.template.publish : { id: ropener_cover, current_operation: CLOSING }
453- - stepper.report_position : { id: driver, position: 0 }
454- - stepper.set_target : { id: driver, target: -100000 }
623+ # First press: start homing (drives closed at ${home_speed}).
624+ - script.execute : home_start
625+
626+ # StallGuard is now advanced/experimental, so its controls sit at the very bottom.
627+ # Core's group_sg (weight 40) can't be re-weighted from here -- package
628+ # merge duplicates the id rather than overriding it -- so instead we define a NEW
629+ # group below Diagnostics (60) and reassign every StallGuard entity to it. Core's
630+ # original group is left with no members and does not render.
631+ web_server :
632+ sorting_groups :
633+ # Core puts Motion Tuning at weight 30 (below Schedule at 25). We can't re-weight
634+ # a core group from the product layer (the id would be redefined), so define a NEW
635+ # group at weight 22 -- directly under Setup (20), above Schedule (25) -- and move
636+ # every motion entity to it. Core's group_motion is left empty and does not render.
637+ - { id: group_tuning, name: "Motion Tuning", sorting_weight: 22 }
638+ - { id: group_sg, name: "StallGuard (experimental)", sorting_weight: 70 }
639+
640+ switch :
641+ # Master opt-in for StallGuard. Default OFF: the shipped product homes manually
642+ # (Start-Stop button) and never reacts to a chip stall. Turn this on only to
643+ # use sensorless auto-homing / obstruction detection, which needs SGTHRS tuned
644+ # to the installed load (see the StallGuard group). Mirrors Glasscalibur's
645+ # stallguard_enabled. on_stall no-ops while this is off.
646+ - platform : template
647+ name : " StallGuard Enabled"
648+ id : sg_enabled
649+ icon : " mdi:shield-search"
650+ entity_category : CONFIG
651+ web_server : { sorting_weight: 1, sorting_group_id: group_sg }
652+ optimistic : true
653+ restore_mode : RESTORE_DEFAULT_OFF
455654
456655text_sensor :
656+ # Surfaces StallGuard's arming state, which is otherwise invisible. The
657+ # failure it exists to catch: if TCOOLTHRS is below the cruise TSTEP for the
658+ # configured Speed, StallGuard NEVER reports, homing never completes, and
659+ # nothing anywhere says so. Diagnosed here from the configured Speed, so a
660+ # misconfiguration is visible while the curtain is standing still rather than
661+ # only discovered when homing silently fails.
662+ #
663+ # "Below threshold" during acceleration is normal and expected -- StallGuard
664+ # is deliberately disarmed at low velocity, which is what TCOOLTHRS is for.
665+ # A reading that never leaves it once at speed is the real fault.
666+ - platform : template
667+ name : " StallGuard"
668+ id : sg_status
669+ icon : " mdi:shield-search"
670+ entity_category : DIAGNOSTIC
671+ web_server : { sorting_weight: 2, sorting_group_id: group_sg }
672+ update_interval : 1s
673+ lambda : |-
674+ if (!id(sg_enabled).state) return {"Disabled"};
675+ const uint32_t tc = id(driver)->read_register(TCOOLTHRS);
676+ // Predicted cruise TSTEP for the configured Speed (measured constant).
677+ const uint32_t sp = id(global_speed) > 0 ? (uint32_t) id(global_speed) : 1;
678+ const uint32_t cruise = 374000UL / sp;
679+ if (cruise > tc)
680+ return {"Never arms at this Speed"};
681+ const uint32_t ts = id(driver)->read_register(TSTEP);
682+ if (ts >= 0xFFFFF) return {"Ready (stopped)"};
683+ if (ts <= tc) return {"Armed"};
684+ return {"Below threshold"};
685+
457686 - platform : template
458687 name : " State"
459688 id : cover_state
0 commit comments