Skip to content

Commit e42be0d

Browse files
Merge pull request #6 from Valar-Systems/feature/stallguard-autotune
Feature/stallguard autotune
2 parents f9df89e + c82d6eb commit e42be0d

3 files changed

Lines changed: 207 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,24 @@ sensor). Local/branch builds report `dev`.
99
This project adheres to [Semantic Versioning](https://semver.org/) and the
1010
format is based on [Keep a Changelog](https://keepachangelog.com/).
1111

12-
## [Unreleased]
12+
## [2.1.0] - 2026-07-23
13+
14+
### Added
15+
- **Speed presets — Slow / Medium / Fast** (Motion Tuning): 1200 / 2600 / 5500
16+
steps/s, chosen from an accelerometer resonance sweep of this mechanism. The
17+
drivetrain has a violent resonance at ~2900–3300 steps/s (worst 3000–3100);
18+
all three presets stay clear of it (ramps only cross it transiently, which is
19+
harmless). The max-speed cap was raised 1200 → 5500 — the motor tracks cleanly
20+
well beyond, but above ~6000 the free-run vibration starts false-tripping the
21+
stall detector, so 5500 is the ceiling. Fast is ~4.5× the previous top speed.
22+
The raw Speed slider moves to the advanced group.
23+
- **StallGuard Auto-Tune** switch (advanced group, opt-in, default OFF): a
24+
self-healing SGTHRS that cross-checks every StallGuard trip against the
25+
accelerometer. It raises the threshold when vibration catches a stall StallGuard
26+
missed, and lowers it after a run of quiet mid-travel trips that look like false
27+
positives. Adjustments are one register step at a time, clamped to a safe band,
28+
logged, and counted via a new "Auto-Tune Adjustments" diagnostic. A fresh
29+
Auto-Calibrate always overrides whatever it has drifted to.
1330

1431
### Changed
1532
- **Calibration button gesture: double-click → 7 quick presses** (GPIO21).
@@ -95,6 +112,7 @@ auto-calibration sequence) live in the product layer.
95112
- Wi-Fi provisioning with no hardcoded credentials (fallback hotspot + captive
96113
portal, Improv over USB serial).
97114

115+
[2.1.0]: https://github.com/Valar-Systems/Glasscalibur/releases/tag/v2.1.0
98116
[2.0.0]: https://github.com/Valar-Systems/Glasscalibur/releases/tag/v2.0.0
99117
[1.2.0]: https://github.com/Valar-Systems/Glasscalibur/releases/tag/v1.2.0
100118
[1.1.0]: https://github.com/Valar-Systems/Glasscalibur/releases/tag/v1.1.0

‎firmware/esphome/Glasscalibur-esphome.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ substitutions:
3939
stepper_rsense: "220 mOhm"
4040
stepper_vsense: "false"
4141
stepper_deceleration: "inf" # immediate stop
42-
stepper_max_speed: "1200 steps/s"
42+
stepper_max_speed: "5500 steps/s"
4343
stepper_acceleration: "100 steps/s^2"
4444
stepper_config_dump: "false"
4545
motor_direction_default: "COUNTERCLOCKWISE"

‎firmware/esphome/glasscalibur-product.yaml‎

Lines changed: 187 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
12171286
select:
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

Comments
 (0)