@@ -388,6 +388,35 @@ globals:
388388 restore_value : no
389389 initial_value : ' false'
390390
391+ # --- StallGuard auto-tune (self-healing SGTHRS) state ---
392+ # Peak vibration (g) seen during the current travel; reset at motion start,
393+ # maxed in the vibration interval. It is the cross-check for whether a StallGuard
394+ # trip was a real (shaky) stall or a quiet false trip.
395+ - id : vib_run_peak
396+ type : float
397+ restore_value : no
398+ initial_value : ' 0.0'
399+ # Set true when the StallGuard handler fires during the current travel; reset at
400+ # motion start. Lets the vibration detector tell "StallGuard also caught this"
401+ # from "StallGuard missed a real stall".
402+ - id : sg_fired_this_move
403+ type : bool
404+ restore_value : no
405+ initial_value : ' false'
406+ # Consecutive quiet mid-travel StallGuard trips. A single quiet trip is weak
407+ # evidence (the accelerometer can miss a fast stall), so auto-tune only lowers
408+ # SGTHRS after a run of them. Reset by vibration agreement or a clean full
409+ # travel. Not persisted — a fresh boot re-earns the evidence.
410+ - id : sg_quiet_streak
411+ type : int
412+ restore_value : no
413+ initial_value : ' 0'
414+ # Lifetime count of auto-tune adjustments (persisted, surfaced as a diagnostic).
415+ - id : sg_autotune_count
416+ type : int
417+ restore_value : yes
418+ initial_value : ' 0'
419+
391420# -----------------------------------------------------------------------------
392421# Product-layer overrides of shared infrastructure
393422# -----------------------------------------------------------------------------
@@ -556,6 +585,10 @@ stepper:
556585 - globals.set :
557586 id : global_current_position
558587 value : !lambda "return id(driver)->current_position;"
588+ # Record the fire and, if auto-tune is on, cross-check vibration to
589+ # decide whether this was a real stall or a false trip (see the
590+ # sg_autotune_eval script). Runs last so global_state/position are set.
591+ - script.execute : sg_autotune_eval
559592
560593# -----------------------------------------------------------------------------
561594# Physical buttons + end-stop limit switches
@@ -750,6 +783,9 @@ binary_sensor:
750783 target : ${max_distance_steps}
751784 - cover.stop : glasscalibur_cover
752785 - logger.log : " LIMIT 1 PRESSED"
786+ # A full travel reached its end stop -> the StallGuard tune is fine;
787+ # clear any accumulated quiet-trip evidence (auto-tune).
788+ - lambda : ' id(sg_quiet_streak) = 0;'
753789 else :
754790 - logger.log : " LIMIT 1 blip while closing - ignored"
755791
@@ -789,6 +825,7 @@ binary_sensor:
789825 target : 0
790826 - cover.stop : glasscalibur_cover
791827 - logger.log : " LIMIT 2 PRESSED"
828+ - lambda : ' id(sg_quiet_streak) = 0;' # clean full travel -> tune OK (auto-tune)
792829 else :
793830 - logger.log : " LIMIT 2 blip while opening - ignored"
794831
@@ -903,7 +940,10 @@ cover:
903940
904941 open_action :
905942 - logger.log : " COVER open_action"
906- - lambda : ' id(motion_start_ms) = millis();'
943+ - lambda : |-
944+ id(motion_start_ms) = millis();
945+ id(vib_run_peak) = 0.0f; // reset per-travel stall cross-check state
946+ id(sg_fired_this_move) = false;
907947 - globals.set :
908948 id : global_state
909949 value : " 1"
@@ -916,7 +956,10 @@ cover:
916956
917957 close_action :
918958 - logger.log : " COVER close_action"
919- - lambda : ' id(motion_start_ms) = millis();'
959+ - lambda : |-
960+ id(motion_start_ms) = millis();
961+ id(vib_run_peak) = 0.0f; // reset per-travel stall cross-check state
962+ id(sg_fired_this_move) = false;
920963 - globals.set :
921964 id : global_state
922965 value : " 2"
@@ -929,7 +972,10 @@ cover:
929972
930973 position_action :
931974 - logger.log : " COVER position_action"
932- - lambda : ' id(motion_start_ms) = millis();'
975+ - lambda : |-
976+ id(motion_start_ms) = millis();
977+ id(vib_run_peak) = 0.0f; // reset per-travel stall cross-check state
978+ id(sg_fired_this_move) = false;
933979 # Direction is inferred by comparing the new target to current_position.
934980 - if :
935981 condition :
@@ -974,10 +1020,11 @@ number:
9741020 # core numbers into the reorganized groups, tightens the current cap, and adds
9751021 # its own tamper/vibration tuning numbers.
9761022
977- # Move core's Speed + Acceleration into the product Motion Tuning group (above
978- # Schedule). Core's own group_motion is left empty and does not render.
1023+ # Acceleration lives in Motion Tuning. The raw Speed slider is driven by the
1024+ # friendly Slow/Medium/Fast preset select (below) and moves to the advanced
1025+ # group for power users; core's own group_motion is left empty and doesn't render.
9791026 - id : !extend num_speed
980- web_server : { sorting_weight: 1 , sorting_group_id: group_tuning }
1027+ web_server : { sorting_weight: 8 , sorting_group_id: group_sg_adv }
9811028 - id : !extend num_accel
9821029 web_server : { sorting_weight: 2, sorting_group_id: group_tuning }
9831030
@@ -1208,13 +1255,69 @@ switch:
12081255 turn_off_action :
12091256 - logger.log : " StallGuard handler disabled"
12101257
1258+ # Self-healing StallGuard tune. When ON (and StallGuard + Vibration Stall
1259+ # Detection are both on), the firmware cross-checks every StallGuard trip against
1260+ # the accelerometer: it RAISES SGTHRS when vibration catches a stall StallGuard
1261+ # missed, and LOWERS it after a run of quiet mid-travel trips that look like false
1262+ # positives. Adjustments are ±1 register step at a time and clamped to a safe
1263+ # band; each is logged and counted. Advanced/opt-in (default OFF) — it slowly
1264+ # drifts a safety threshold, so enable it only once a baseline calibration is
1265+ # trusted. A fresh Auto-Calibrate always overrides whatever it has drifted to.
1266+ - platform : template
1267+ name : " StallGuard Auto-Tune"
1268+ id : sg_autotune
1269+ entity_category : CONFIG
1270+ web_server :
1271+ sorting_weight : 7
1272+ sorting_group_id : group_sg_adv
1273+ optimistic : true
1274+ restore_mode : RESTORE_DEFAULT_OFF
1275+ turn_on_action :
1276+ - logger.log : " StallGuard auto-tune enabled"
1277+ turn_off_action :
1278+ - logger.log : " StallGuard auto-tune disabled"
1279+
12111280# -----------------------------------------------------------------------------
12121281# Removed core entity
12131282# -----------------------------------------------------------------------------
12141283# Glasscalibur's motor direction is fixed counter-clockwise (set via the board's
12151284# motor_direction_default substitution) and there is no runtime direction
12161285# control, so drop core's "Motor Direction" select.
12171286select :
1287+ # Friendly speed presets. Values were chosen from an accelerometer resonance
1288+ # sweep of this mechanism: there is a violent drivetrain resonance at ~2900-3300
1289+ # (worst 3000-3100) that all three presets stay clear of. Slow 1200 is silent;
1290+ # Medium 2600 sits just below the band; Fast 5500 is well above it (ramps only
1291+ # cross the band transiently, which is harmless). Like Motor Direction on other
1292+ # boards this holds no state of its own — it reads/writes global_speed (the
1293+ # single source of truth, restored on boot). The raw Speed slider lives in the
1294+ # advanced group. Changing speed re-flags the StallGuard calibration as stale
1295+ # automatically (SG_RESULT is speed-dependent; see Calibration Status).
1296+ - platform : template
1297+ name : " Speed"
1298+ id : sel_speed
1299+ icon : " mdi:speedometer"
1300+ entity_category : CONFIG
1301+ web_server : { sorting_weight: 1, sorting_group_id: group_tuning }
1302+ update_interval : 1s
1303+ options : ["Slow", "Medium", "Fast"]
1304+ lambda : |-
1305+ const int sp = id(global_speed);
1306+ if (sp <= 1900) return std::string("Slow"); // 1200
1307+ if (sp <= 4050) return std::string("Medium"); // 2600
1308+ return std::string("Fast"); // 5500
1309+ set_action :
1310+ - lambda : |-
1311+ int s = 5500; // Fast
1312+ if (x.find("Slow") != std::string::npos) s = 1200;
1313+ else if (x.find("Medium") != std::string::npos) s = 2600;
1314+ id(global_speed) = s;
1315+ - stepper.set_speed : { id: driver, speed: !lambda "return id(global_speed);" }
1316+ - lambda : |-
1317+ // Persist immediately so the choice survives an unplug seconds later.
1318+ global_speed->update();
1319+ global_preferences->sync();
1320+
12181321 - id : !remove sel_direction
12191322
12201323# -----------------------------------------------------------------------------
@@ -1468,6 +1571,15 @@ sensor:
14681571 web_server :
14691572 sorting_weight : 13
14701573 sorting_group_id : group_diagnostics
1574+ - platform : template
1575+ name : " Auto-Tune Adjustments"
1576+ lambda : ' return id(sg_autotune_count);'
1577+ update_interval : 30s
1578+ entity_category : DIAGNOSTIC
1579+ accuracy_decimals : 0
1580+ web_server :
1581+ sorting_weight : 6
1582+ sorting_group_id : group_sg_adv
14711583
14721584# -----------------------------------------------------------------------------
14731585# State exposure (HA)
@@ -2063,6 +2175,52 @@ script:
20632175 id : global_current_position
20642176 value : !lambda 'return id(driver)->current_position;'
20652177
2178+ # StallGuard auto-tune — lower-direction cross-check, run from the on_stall
2179+ # handler. Records that StallGuard fired this move, then (only when the
2180+ # "StallGuard Auto-Tune" switch is on and vibration detection is available)
2181+ # decides whether the trip was real:
2182+ # - vibration agreed (peak >= half the Vibration Threshold) -> real stall,
2183+ # the tune is fine; clear the quiet streak.
2184+ # - vibration stayed at free-run levels AND the trip was mid-travel (not near
2185+ # an end stop, where a decel/limit trip is expected) -> a probable false
2186+ # trip; bump the quiet streak. Only after THREE consecutive such trips —
2187+ # strong evidence of chronic over-sensitivity, since the accelerometer can
2188+ # miss a single fast stall — lower SGTHRS one step (harder to trip) and
2189+ # reset. Clamped to a safe floor. The raise direction lives in the vibration
2190+ # interval (it has the high-confidence signal). Small, bounded, opt-in, and
2191+ # every change is logged and counted.
2192+ - id : sg_autotune_eval
2193+ then :
2194+ - lambda : |-
2195+ id(sg_fired_this_move) = true;
2196+ if (!id(sg_autotune).state || !id(vib_enabled).state) return;
2197+ // Ignore trips near either end stop — a StallGuard trip as the sash
2198+ // seats against a limit is expected, not a false positive.
2199+ int32_t pos = id(driver)->current_position;
2200+ float frac = (float)pos / (float)${max_distance_steps};
2201+ if (frac < 0.05f || frac > 0.95f) return;
2202+ float bar = 0.5f * id(vib_threshold).state;
2203+ if (id(vib_run_peak) >= bar) { // vibration agreed -> real stall
2204+ id(sg_quiet_streak) = 0;
2205+ return;
2206+ }
2207+ // Quiet mid-travel trip -> weak evidence of over-sensitivity.
2208+ id(sg_quiet_streak) += 1;
2209+ ESP_LOGI("sg_autotune", "quiet mid-travel StallGuard trip (%d/3), peak=%.3fg bar=%.3fg",
2210+ id(sg_quiet_streak), id(vib_run_peak), bar);
2211+ if (id(sg_quiet_streak) >= 3) {
2212+ int v = id(global_sgthrs) - 2; // lower = less sensitive
2213+ if (v < 2) v = 2; // safe floor
2214+ if (v != id(global_sgthrs)) {
2215+ id(global_sgthrs) = v;
2216+ global_sgthrs->update(); // persist (restore_value global)
2217+ id(driver)->write_register(SGTHRS, v);
2218+ id(sg_autotune_count) += 1;
2219+ ESP_LOGW("sg_autotune", "3 quiet trips -> lowering SGTHRS to %d (less sensitive)", v);
2220+ }
2221+ id(sg_quiet_streak) = 0;
2222+ }
2223+
20662224# -----------------------------------------------------------------------------
20672225# Motion-completion poll
20682226# -----------------------------------------------------------------------------
@@ -2190,6 +2348,9 @@ interval:
21902348 constexpr float G_MS2 = 9.80665f;
21912349 float mag_g = sqrtf(x*x + y*y + z*z) / G_MS2;
21922350 float vibration = fabsf(mag_g - 1.0f);
2351+ // Track the peak vibration this travel for the StallGuard
2352+ // auto-tune cross-check (see sg_autotune_eval).
2353+ if (vibration > id(vib_run_peak)) id(vib_run_peak) = vibration;
21932354 // Asymmetric leaky counter: +2 on loud, -1 on quiet
21942355 // (floored at 0). A skipping stall's judder alternates
21952356 // violent and quiet readings at this 500 ms sample cadence;
@@ -2213,6 +2374,26 @@ interval:
22132374 args : ['id(vib_loud_count)']
22142375 - stepper.stop : driver
22152376 - lambda : ' id(vib_stall_count) += 1;'
2377+ # StallGuard auto-tune — RAISE direction (high confidence). The
2378+ # vibration detector needed sustained judder to trip, so this is
2379+ # a real stall. If StallGuard was armed but did NOT fire during
2380+ # this travel, it was too insensitive and missed it -> raise
2381+ # SGTHRS one step (easier to trip), clamped to a safe ceiling.
2382+ - if :
2383+ condition :
2384+ lambda : ' return id(sg_autotune).state && id(stallguard_enabled).state && !id(sg_fired_this_move);'
2385+ then :
2386+ - lambda : |-
2387+ int v = id(global_sgthrs) + 2; // higher = more sensitive
2388+ if (v > 60) v = 60; // safe ceiling for this device
2389+ if (v != id(global_sgthrs)) {
2390+ id(global_sgthrs) = v;
2391+ global_sgthrs->update();
2392+ id(driver)->write_register(SGTHRS, v);
2393+ id(sg_autotune_count) += 1;
2394+ id(sg_quiet_streak) = 0;
2395+ ESP_LOGW("sg_autotune", "vibration caught a stall StallGuard missed -> raising SGTHRS to %d", v);
2396+ }
22162397 # Stall auto-release — same behavior as the StallGuard
22172398 # on_stall path (the leadscrew holds clamp force until
22182399 # reversed). Runs BEFORE global_state is zeroed: the stall
0 commit comments