Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion firmware/esphome/Glasscalibur-esphome.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
193 changes: 187 additions & 6 deletions firmware/esphome/glasscalibur-product.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
# -----------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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:
Expand Down Expand Up @@ -974,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 }

Expand Down Expand Up @@ -1208,13 +1255,69 @@ 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
# -----------------------------------------------------------------------------
# Glasscalibur's motor direction is fixed counter-clockwise (set via the board's
# 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

# -----------------------------------------------------------------------------
Expand Down Expand Up @@ -1468,6 +1571,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)
Expand Down Expand Up @@ -2063,6 +2175,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
# -----------------------------------------------------------------------------
Expand Down Expand Up @@ -2190,6 +2348,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;
Expand All @@ -2213,6 +2374,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
Expand Down
Loading