Skip to content

Commit 8b4b9e7

Browse files
Merge pull request #36 from Valar-Systems/feat/stallguard-optional
v2.8.0 — manual homing, StallGuard opt-in, friendly Motion Tuning
2 parents b9275c2 + b97a64a commit 8b4b9e7

1 file changed

Lines changed: 258 additions & 29 deletions

File tree

‎firmware/common/ropener-product.yaml‎

Lines changed: 258 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -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

108122
script:
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 -------------
370494
number:
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

393563
sensor:
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 --------------------------------------------------------
400575
cover:
@@ -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

456655
text_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

Comments
 (0)