From c9a4601a0e5cc3a47b648a566d4868b738b01735 Mon Sep 17 00:00:00 2001 From: Daniel Frenkel Date: Wed, 22 Jul 2026 10:30:07 -0700 Subject: [PATCH 1/3] feat(firmware): self-healing StallGuard auto-tune (opt-in) Continuously cross-check StallGuard against the accelerometer and nudge SGTHRS so the tune self-corrects between calibrations. New "StallGuard Auto-Tune" switch (advanced group, default OFF) gates it; requires StallGuard + Vibration Stall Detection both on. Two directions, both small (+/-1 register step), clamped, logged, and counted via a new "Auto-Tune Adjustments" diagnostic: - RAISE (high confidence): when the vibration detector trips (its sustained-judder threshold = a real stall) but StallGuard did NOT fire this travel, StallGuard was too insensitive -> SGTHRS += step (ceiling 60). - LOWER (conservative): a StallGuard trip is cross-checked in sg_autotune_eval. If peak vibration this travel stayed below half the Vibration Threshold AND the trip was mid-travel (not near an end stop), it is counted as a probable false trip. Only after THREE consecutive such quiet trips -> SGTHRS -= step (floor 2). A vibration-agreeing trip or a clean full travel (limit reached) resets the streak, so a single missed-judder stall can't desensitize StallGuard. State: vib_run_peak + sg_fired_this_move reset at motion start, tracked in the vibration interval / on_stall; sg_quiet_streak and a persisted sg_autotune_count. A fresh Auto-Calibrate always overrides whatever auto-tune has drifted to. Config valid; full compile OK. Default OFF so behaviour is unchanged until enabled. --- firmware/esphome/glasscalibur-product.yaml | 152 ++++++++++++++++++++- 1 file changed, 149 insertions(+), 3 deletions(-) diff --git a/firmware/esphome/glasscalibur-product.yaml b/firmware/esphome/glasscalibur-product.yaml index f403d6b..34dc94e 100644 --- a/firmware/esphome/glasscalibur-product.yaml +++ b/firmware/esphome/glasscalibur-product.yaml @@ -388,6 +388,35 @@ globals: restore_value: no initial_value: 'false' + # --- StallGuard auto-tune (self-healing SGTHRS) state --- + # Peak vibration (g) seen during the current travel; reset at motion start, + # maxed in the vibration interval. It is the cross-check for whether a StallGuard + # trip was a real (shaky) stall or a quiet false trip. + - id: vib_run_peak + type: float + restore_value: no + initial_value: '0.0' + # Set true when the StallGuard handler fires during the current travel; reset at + # motion start. Lets the vibration detector tell "StallGuard also caught this" + # from "StallGuard missed a real stall". + - id: sg_fired_this_move + type: bool + restore_value: no + initial_value: 'false' + # Consecutive quiet mid-travel StallGuard trips. A single quiet trip is weak + # evidence (the accelerometer can miss a fast stall), so auto-tune only lowers + # SGTHRS after a run of them. Reset by vibration agreement or a clean full + # travel. Not persisted — a fresh boot re-earns the evidence. + - id: sg_quiet_streak + type: int + restore_value: no + initial_value: '0' + # Lifetime count of auto-tune adjustments (persisted, surfaced as a diagnostic). + - id: sg_autotune_count + type: int + restore_value: yes + initial_value: '0' + # ----------------------------------------------------------------------------- # Product-layer overrides of shared infrastructure # ----------------------------------------------------------------------------- @@ -556,6 +585,10 @@ stepper: - globals.set: id: global_current_position value: !lambda "return id(driver)->current_position;" + # Record the fire and, if auto-tune is on, cross-check vibration to + # decide whether this was a real stall or a false trip (see the + # sg_autotune_eval script). Runs last so global_state/position are set. + - script.execute: sg_autotune_eval # ----------------------------------------------------------------------------- # Physical buttons + end-stop limit switches @@ -750,6 +783,9 @@ binary_sensor: target: ${max_distance_steps} - cover.stop: glasscalibur_cover - logger.log: "LIMIT 1 PRESSED" + # A full travel reached its end stop -> the StallGuard tune is fine; + # clear any accumulated quiet-trip evidence (auto-tune). + - lambda: 'id(sg_quiet_streak) = 0;' else: - logger.log: "LIMIT 1 blip while closing - ignored" @@ -789,6 +825,7 @@ binary_sensor: target: 0 - cover.stop: glasscalibur_cover - logger.log: "LIMIT 2 PRESSED" + - lambda: 'id(sg_quiet_streak) = 0;' # clean full travel -> tune OK (auto-tune) else: - logger.log: "LIMIT 2 blip while opening - ignored" @@ -903,7 +940,10 @@ cover: open_action: - logger.log: "COVER open_action" - - lambda: 'id(motion_start_ms) = millis();' + - lambda: |- + id(motion_start_ms) = millis(); + id(vib_run_peak) = 0.0f; // reset per-travel stall cross-check state + id(sg_fired_this_move) = false; - globals.set: id: global_state value: "1" @@ -916,7 +956,10 @@ cover: close_action: - logger.log: "COVER close_action" - - lambda: 'id(motion_start_ms) = millis();' + - lambda: |- + id(motion_start_ms) = millis(); + id(vib_run_peak) = 0.0f; // reset per-travel stall cross-check state + id(sg_fired_this_move) = false; - globals.set: id: global_state value: "2" @@ -929,7 +972,10 @@ cover: position_action: - logger.log: "COVER position_action" - - lambda: 'id(motion_start_ms) = millis();' + - lambda: |- + id(motion_start_ms) = millis(); + id(vib_run_peak) = 0.0f; // reset per-travel stall cross-check state + id(sg_fired_this_move) = false; # Direction is inferred by comparing the new target to current_position. - if: condition: @@ -1208,6 +1254,28 @@ switch: turn_off_action: - logger.log: "StallGuard handler disabled" + # Self-healing StallGuard tune. When ON (and StallGuard + Vibration Stall + # Detection are both on), the firmware cross-checks every StallGuard trip against + # the accelerometer: it RAISES SGTHRS when vibration catches a stall StallGuard + # missed, and LOWERS it after a run of quiet mid-travel trips that look like false + # positives. Adjustments are ±1 register step at a time and clamped to a safe + # band; each is logged and counted. Advanced/opt-in (default OFF) — it slowly + # drifts a safety threshold, so enable it only once a baseline calibration is + # trusted. A fresh Auto-Calibrate always overrides whatever it has drifted to. + - platform: template + name: "StallGuard Auto-Tune" + id: sg_autotune + entity_category: CONFIG + web_server: + sorting_weight: 7 + sorting_group_id: group_sg_adv + optimistic: true + restore_mode: RESTORE_DEFAULT_OFF + turn_on_action: + - logger.log: "StallGuard auto-tune enabled" + turn_off_action: + - logger.log: "StallGuard auto-tune disabled" + # ----------------------------------------------------------------------------- # Removed core entity # ----------------------------------------------------------------------------- @@ -1468,6 +1536,15 @@ sensor: web_server: sorting_weight: 13 sorting_group_id: group_diagnostics + - platform: template + name: "Auto-Tune Adjustments" + lambda: 'return id(sg_autotune_count);' + update_interval: 30s + entity_category: DIAGNOSTIC + accuracy_decimals: 0 + web_server: + sorting_weight: 6 + sorting_group_id: group_sg_adv # ----------------------------------------------------------------------------- # State exposure (HA) @@ -2063,6 +2140,52 @@ script: id: global_current_position value: !lambda 'return id(driver)->current_position;' + # StallGuard auto-tune — lower-direction cross-check, run from the on_stall + # handler. Records that StallGuard fired this move, then (only when the + # "StallGuard Auto-Tune" switch is on and vibration detection is available) + # decides whether the trip was real: + # - vibration agreed (peak >= half the Vibration Threshold) -> real stall, + # the tune is fine; clear the quiet streak. + # - vibration stayed at free-run levels AND the trip was mid-travel (not near + # an end stop, where a decel/limit trip is expected) -> a probable false + # trip; bump the quiet streak. Only after THREE consecutive such trips — + # strong evidence of chronic over-sensitivity, since the accelerometer can + # miss a single fast stall — lower SGTHRS one step (harder to trip) and + # reset. Clamped to a safe floor. The raise direction lives in the vibration + # interval (it has the high-confidence signal). Small, bounded, opt-in, and + # every change is logged and counted. + - id: sg_autotune_eval + then: + - lambda: |- + id(sg_fired_this_move) = true; + if (!id(sg_autotune).state || !id(vib_enabled).state) return; + // Ignore trips near either end stop — a StallGuard trip as the sash + // seats against a limit is expected, not a false positive. + int32_t pos = id(driver)->current_position; + float frac = (float)pos / (float)${max_distance_steps}; + if (frac < 0.05f || frac > 0.95f) return; + float bar = 0.5f * id(vib_threshold).state; + if (id(vib_run_peak) >= bar) { // vibration agreed -> real stall + id(sg_quiet_streak) = 0; + return; + } + // Quiet mid-travel trip -> weak evidence of over-sensitivity. + id(sg_quiet_streak) += 1; + ESP_LOGI("sg_autotune", "quiet mid-travel StallGuard trip (%d/3), peak=%.3fg bar=%.3fg", + id(sg_quiet_streak), id(vib_run_peak), bar); + if (id(sg_quiet_streak) >= 3) { + int v = id(global_sgthrs) - 2; // lower = less sensitive + if (v < 2) v = 2; // safe floor + if (v != id(global_sgthrs)) { + id(global_sgthrs) = v; + global_sgthrs->update(); // persist (restore_value global) + id(driver)->write_register(SGTHRS, v); + id(sg_autotune_count) += 1; + ESP_LOGW("sg_autotune", "3 quiet trips -> lowering SGTHRS to %d (less sensitive)", v); + } + id(sg_quiet_streak) = 0; + } + # ----------------------------------------------------------------------------- # Motion-completion poll # ----------------------------------------------------------------------------- @@ -2190,6 +2313,9 @@ interval: constexpr float G_MS2 = 9.80665f; float mag_g = sqrtf(x*x + y*y + z*z) / G_MS2; float vibration = fabsf(mag_g - 1.0f); + // Track the peak vibration this travel for the StallGuard + // auto-tune cross-check (see sg_autotune_eval). + if (vibration > id(vib_run_peak)) id(vib_run_peak) = vibration; // Asymmetric leaky counter: +2 on loud, -1 on quiet // (floored at 0). A skipping stall's judder alternates // violent and quiet readings at this 500 ms sample cadence; @@ -2213,6 +2339,26 @@ interval: args: ['id(vib_loud_count)'] - stepper.stop: driver - lambda: 'id(vib_stall_count) += 1;' + # StallGuard auto-tune — RAISE direction (high confidence). The + # vibration detector needed sustained judder to trip, so this is + # a real stall. If StallGuard was armed but did NOT fire during + # this travel, it was too insensitive and missed it -> raise + # SGTHRS one step (easier to trip), clamped to a safe ceiling. + - if: + condition: + lambda: 'return id(sg_autotune).state && id(stallguard_enabled).state && !id(sg_fired_this_move);' + then: + - lambda: |- + int v = id(global_sgthrs) + 2; // higher = more sensitive + if (v > 60) v = 60; // safe ceiling for this device + if (v != id(global_sgthrs)) { + id(global_sgthrs) = v; + global_sgthrs->update(); + id(driver)->write_register(SGTHRS, v); + id(sg_autotune_count) += 1; + id(sg_quiet_streak) = 0; + ESP_LOGW("sg_autotune", "vibration caught a stall StallGuard missed -> raising SGTHRS to %d", v); + } # Stall auto-release — same behavior as the StallGuard # on_stall path (the leadscrew holds clamp force until # reversed). Runs BEFORE global_state is zeroed: the stall From a083c75ca3a8f7f6f140bb47eff12aa3b079900c Mon Sep 17 00:00:00 2001 From: Daniel Frenkel Date: Thu, 23 Jul 2026 17:04:03 -0700 Subject: [PATCH 2/3] feat(firmware): friendly Slow/Medium/Fast speed presets (data-chosen), raise cap Add a "Speed" preset select (Motion Tuning) mapping Slow/Medium/Fast to 1200 / 2600 / 5500 steps/s, and demote the raw Speed slider to the advanced group (mirrors the Ropener pattern). Raise stepper_max_speed 1200 -> 5500 so Fast is reachable. Values chosen from an accelerometer resonance sweep of this mechanism (external measurement tool, not shipped): vibration is flat and quiet across the range EXCEPT a violent drivetrain resonance at ~2900-3300 (worst 3000-3100, peak ~1 g). All three presets avoid parking in that band; ramps cross it only transiently, which measured harmless (clean position tracking). The motor holds torque with no step loss all the way to 9000, but 6000+ free-run vibration exceeds the vib-stall threshold, so the product cap sits at 5500 (Fast) to stay clear of false trips. The select reads/writes global_speed (single source of truth, restored on boot); changing speed auto-flags the StallGuard calibration stale (SG_RESULT is speed-dependent). Verified on hardware: Slow->1200, Medium->2600, Fast->5500. --- firmware/esphome/Glasscalibur-esphome.yml | 2 +- firmware/esphome/glasscalibur-product.yaml | 41 ++++++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/firmware/esphome/Glasscalibur-esphome.yml b/firmware/esphome/Glasscalibur-esphome.yml index f7763f7..e0769e5 100644 --- a/firmware/esphome/Glasscalibur-esphome.yml +++ b/firmware/esphome/Glasscalibur-esphome.yml @@ -39,7 +39,7 @@ substitutions: stepper_rsense: "220 mOhm" stepper_vsense: "false" stepper_deceleration: "inf" # immediate stop - stepper_max_speed: "1200 steps/s" + stepper_max_speed: "5500 steps/s" stepper_acceleration: "100 steps/s^2" stepper_config_dump: "false" motor_direction_default: "COUNTERCLOCKWISE" diff --git a/firmware/esphome/glasscalibur-product.yaml b/firmware/esphome/glasscalibur-product.yaml index 34dc94e..9e5a62b 100644 --- a/firmware/esphome/glasscalibur-product.yaml +++ b/firmware/esphome/glasscalibur-product.yaml @@ -1020,10 +1020,11 @@ number: # core numbers into the reorganized groups, tightens the current cap, and adds # its own tamper/vibration tuning numbers. - # Move core's Speed + Acceleration into the product Motion Tuning group (above - # Schedule). Core's own group_motion is left empty and does not render. + # Acceleration lives in Motion Tuning. The raw Speed slider is driven by the + # friendly Slow/Medium/Fast preset select (below) and moves to the advanced + # group for power users; core's own group_motion is left empty and doesn't render. - id: !extend num_speed - web_server: { sorting_weight: 1, sorting_group_id: group_tuning } + web_server: { sorting_weight: 8, sorting_group_id: group_sg_adv } - id: !extend num_accel web_server: { sorting_weight: 2, sorting_group_id: group_tuning } @@ -1283,6 +1284,40 @@ switch: # motor_direction_default substitution) and there is no runtime direction # control, so drop core's "Motor Direction" select. select: + # Friendly speed presets. Values were chosen from an accelerometer resonance + # sweep of this mechanism: there is a violent drivetrain resonance at ~2900-3300 + # (worst 3000-3100) that all three presets stay clear of. Slow 1200 is silent; + # Medium 2600 sits just below the band; Fast 5500 is well above it (ramps only + # cross the band transiently, which is harmless). Like Motor Direction on other + # boards this holds no state of its own — it reads/writes global_speed (the + # single source of truth, restored on boot). The raw Speed slider lives in the + # advanced group. Changing speed re-flags the StallGuard calibration as stale + # automatically (SG_RESULT is speed-dependent; see Calibration Status). + - platform: template + name: "Speed" + id: sel_speed + icon: "mdi:speedometer" + entity_category: CONFIG + web_server: { sorting_weight: 1, sorting_group_id: group_tuning } + update_interval: 1s + options: ["Slow", "Medium", "Fast"] + lambda: |- + const int sp = id(global_speed); + if (sp <= 1900) return std::string("Slow"); // 1200 + if (sp <= 4050) return std::string("Medium"); // 2600 + return std::string("Fast"); // 5500 + set_action: + - lambda: |- + int s = 5500; // Fast + if (x.find("Slow") != std::string::npos) s = 1200; + else if (x.find("Medium") != std::string::npos) s = 2600; + id(global_speed) = s; + - stepper.set_speed: { id: driver, speed: !lambda "return id(global_speed);" } + - lambda: |- + // Persist immediately so the choice survives an unplug seconds later. + global_speed->update(); + global_preferences->sync(); + - id: !remove sel_direction # ----------------------------------------------------------------------------- From c82d6eb544f163e3fc9c07ff6e657c287f4633dc Mon Sep 17 00:00:00 2001 From: Daniel Frenkel Date: Fri, 24 Jul 2026 12:51:38 -0700 Subject: [PATCH 3/3] docs: v2.1.0 changelog (auto-tune, speed presets, gesture change) --- CHANGELOG.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01050f0..36f676f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,24 @@ sensor). Local/branch builds report `dev`. This project adheres to [Semantic Versioning](https://semver.org/) and the format is based on [Keep a Changelog](https://keepachangelog.com/). -## [Unreleased] +## [2.1.0] - 2026-07-23 + +### Added +- **Speed presets — Slow / Medium / Fast** (Motion Tuning): 1200 / 2600 / 5500 + steps/s, chosen from an accelerometer resonance sweep of this mechanism. The + drivetrain has a violent resonance at ~2900–3300 steps/s (worst 3000–3100); + all three presets stay clear of it (ramps only cross it transiently, which is + harmless). The max-speed cap was raised 1200 → 5500 — the motor tracks cleanly + well beyond, but above ~6000 the free-run vibration starts false-tripping the + stall detector, so 5500 is the ceiling. Fast is ~4.5× the previous top speed. + The raw Speed slider moves to the advanced group. +- **StallGuard Auto-Tune** switch (advanced group, opt-in, default OFF): a + self-healing SGTHRS that cross-checks every StallGuard trip against the + accelerometer. It raises the threshold when vibration catches a stall StallGuard + missed, and lowers it after a run of quiet mid-travel trips that look like false + positives. Adjustments are one register step at a time, clamped to a safe band, + logged, and counted via a new "Auto-Tune Adjustments" diagnostic. A fresh + Auto-Calibrate always overrides whatever it has drifted to. ### Changed - **Calibration button gesture: double-click → 7 quick presses** (GPIO21). @@ -95,6 +112,7 @@ auto-calibration sequence) live in the product layer. - Wi-Fi provisioning with no hardcoded credentials (fallback hotspot + captive portal, Improv over USB serial). +[2.1.0]: https://github.com/Valar-Systems/Glasscalibur/releases/tag/v2.1.0 [2.0.0]: https://github.com/Valar-Systems/Glasscalibur/releases/tag/v2.0.0 [1.2.0]: https://github.com/Valar-Systems/Glasscalibur/releases/tag/v1.2.0 [1.1.0]: https://github.com/Valar-Systems/Glasscalibur/releases/tag/v1.1.0