diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 0000000..ee5a37d --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,54 @@ +# Build the Glasscalibur firmware and, on a tag, attach the factory + OTA +# binaries (and the .ota.bin.md5 the on-device "Update Firmware (GitHub)" button +# needs) to the GitHub release. Field units self-update by asset name, so keep +# the "Glasscalibur" asset name stable. +name: Build firmware + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install ESPHome + run: pip install esphome + + # On a tag build, stamp the release version (strip a leading "v") so the + # on-device "Firmware Version" matches the release the unit is running. + - name: Compile Glasscalibur + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + V="${{ github.ref_name }}"; V="${V#v}" + esphome -s fw_version "$V" compile firmware/esphome/Glasscalibur-esphome.yml + else + esphome compile firmware/esphome/Glasscalibur-esphome.yml + fi + + - name: Collect binaries + md5 + run: | + mkdir -p dist + F=$(find . -path "*/.pioenvs/*/firmware.factory.bin" | head -1) + O=$(find . -path "*/.pioenvs/*/firmware.ota.bin" | head -1) + cp "$F" "dist/Glasscalibur.factory.bin" + cp "$O" "dist/Glasscalibur.ota.bin" + md5sum "dist/Glasscalibur.ota.bin" | cut -d' ' -f1 > "dist/Glasscalibur.ota.bin.md5" + + - uses: actions/upload-artifact@v4 + with: + name: Glasscalibur + path: dist/* + + - name: Attach to release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: dist/* diff --git a/CHANGELOG.md b/CHANGELOG.md index c72bed7..2ad01f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,46 @@ # Changelog -All notable changes to the Glasscalibur firmware are documented here. The -version number is the `firmware_version` substitution in -[`firmware/esphome/Glasscalibur-esphome.yml`](firmware/esphome/Glasscalibur-esphome.yml), -which feeds `project.version` (reported to Home Assistant / the ESPHome -dashboard) and the "Firmware Version" diagnostic sensor on the web UI. +All notable changes to the Glasscalibur firmware are documented here. As of +v2.0.0 the version number comes from the release git tag: the build workflow +stamps it into the `fw_version` substitution (feeding `project.version` reported +to Home Assistant / the ESPHome dashboard and the "Firmware Version" diagnostic +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/). +## [2.0.0] - 2026-07-22 + +Major architectural change: the firmware is now built on the shared +**valar-core** + **scheduling** packages (from `Valar-Systems/valar-motion`, +pinned at `v0.3.0`) rather than a single monolithic config. Board infrastructure +(Wi-Fi, API, OTA, web UI, diagnostics, the TMC2209 driver, and the daily/sun +schedule) comes from core; only Glasscalibur's product hardware and logic (cover, +limit switches, LIS2DH12 tamper/vibration, TMP1075, buzzer, child lock, and the +auto-calibration sequence) live in the product layer. + +### Added +- **Auto-calibration** (double-click the Wi-Fi/Calibrate button, or the + "Auto-Calibrate" entity): a 3-phase sequence that homes on the limit switches, + searches for the minimum viable motor current, and tunes StallGuard (SGTHRS / + TCOOLTHRS) and the vibration threshold from the measured travel. Result is + shown in "Calibration Status" and persisted. Includes a stall auto-release + back-off and a thermal guard. +- **Board** diagnostic sensor (from core), reporting the board identity. + +### Changed +- **BREAKING — tuning entity names** now match the shared core across the Valar + product line. Home Assistant `entity_id`s change accordingly: + `max_speed` → **Speed**, `acceleration` → **Acceleration**, + `irun` → **IRUN (motor current)**, `SGTHRS` → **SGTHRS (stall threshold)**, + and the `TSTEP Sensor` / `SG_RESULT Sensor` diagnostics → **TSTEP** / **SG_RESULT**. + Motor-current entries are still capped at IRUN 19 (rated 0.6 A). +- Firmware version now comes from the release tag (see note above) instead of a + hardcoded `firmware_version` substitution. +- The GitHub-OTA "Update Firmware" button, the Restart button, and the + Firmware Version / network-info diagnostics are now provided by core (the OTA + asset URL — `Glasscalibur.ota.bin` from the latest release — is unchanged). + ## [1.2.0] - 2026-06-30 ### Added @@ -54,6 +86,7 @@ format is based on [Keep a Changelog](https://keepachangelog.com/). - Wi-Fi provisioning with no hardcoded credentials (fallback hotspot + captive portal, Improv over USB serial). +[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 [1.0.0]: https://github.com/Valar-Systems/Glasscalibur/releases/tag/v1.0.0 diff --git a/firmware/esphome/Glasscalibur-esphome.yml b/firmware/esphome/Glasscalibur-esphome.yml index 0ddc536..f7763f7 100644 --- a/firmware/esphome/Glasscalibur-esphome.yml +++ b/firmware/esphome/Glasscalibur-esphome.yml @@ -1,2940 +1,71 @@ # ============================================================================= -# Glasscalibur — ESPHome firmware +# Glasscalibur — board wrapper (ESP32-C6) # ============================================================================= -# -# A Wi-Fi connected window-cover controller that drives a NEMA stepper motor -# via a TMC2209 driver, with physical end-stop limit switches at both ends of -# travel for absolute position calibration. Control it via a browser at -# http://glasscalibur-XXXXXX.local (where XXXXXX is the last 3 bytes of the -# device's MAC, printed on the serial log at boot). No Home Assistant -# required. Can also pair with Home Assistant over the native ESPHome API. -# -# ----------------------------------------------------------------------------- -# Hardware -# ----------------------------------------------------------------------------- -# MCU : ESP32-C6-MINI-1 (esp-idf framework, USB-Serial-JTAG console) -# Driver : TMC2209 stepper driver, single-wire UART control (PDN_UART) -# Motor : NEMA stepper, 8x microstepping = 1600 steps/rev -# End stops : two physical limit switches (one at each end of travel) -# Sensors : TMP1075 motor temperature (I2C 0x49) — auto-stops at >80 °C -# LIS2DH12 accelerometer (I2C 0x19) — anti-tamper + vib stall -# Buzzer : passive piezo on a ledc PWM output (tamper alarm) -# -# ----------------------------------------------------------------------------- -# GPIO wiring -# ----------------------------------------------------------------------------- -# GPIO0 : UART RX <- TMC2209 PDN_UART -# GPIO1 : UART TX -> TMC2209 PDN_UART -# GPIO3 : Close-end limit switch (active low, polled) -# GPIO5 : Buzzer (ledc output) -# GPIO6 : INDEX (TMC2209 step-counter output, informational) -# GPIO8 : ENN (TMC2209 enable, active low) -# GPIO14 : DIAG (TMC2209 StallGuard / fault output -> on_stall) -# GPIO15 : Open-end limit switch (active low, polled) -# GPIO21 : Calibration / Wi-Fi Reset button (active low; double-click = -# auto-calibrate, hold 3-10 s to clear Wi-Fi creds — the timings -# must never overlap) -# GPIO19 : I2C SDA -# GPIO20 : I2C SCL -# GPIO23 : Button 1 (active low; single/double click) -# -# ----------------------------------------------------------------------------- -# Position / motion model -# ----------------------------------------------------------------------------- -# Steps are the canonical position unit. The cover entity reports position -# as a 0..1 ratio of current_position / max_distance_steps: -# -# position = current_position / max_distance_steps (0.0 .. 1.0) -# -# max_distance_steps is a compile-time substitution (see `substitutions:` -# below) that defines the full travel between limits. Hitting a limit -# switch re-calibrates the stepper to a known absolute position (0 at the -# close end, max_distance_steps at the open end), correcting any drift. -# Position is persisted to flash at every IDLE transition so the cover -# survives reboots without re-homing. -# -# ----------------------------------------------------------------------------- -# State machine (global_state, mirrored to the "State" text_sensor) -# ----------------------------------------------------------------------------- -# 0 = IDLE no motion in progress -# 1 = OPENING driving toward max_distance_steps -# 2 = CLOSING driving toward 0 -# 3 = TAMPERED tamper detected; alarm active or pending acknowledgement -# -# OPENING/CLOSING return to IDLE via the 200 ms motion-completion poll, -# any cover stop_action (limit switch, temperature cutoff, manual stop), -# or the TMC2209 on_stall handler. TAMPERED is a *soft indicator* — -# subsequent motion commands override it; it only returns to IDLE on -# explicit acknowledgement (Button 1 single-click, "Clear Tamper Alert", -# or "Set Tamper Baseline"). -# -# ----------------------------------------------------------------------------- -# Buttons (physical, on the device) -# ----------------------------------------------------------------------------- -# Button 1 (GPIO23): -# - Single click : CALIBRATING -> abort calibration (works even with -# Child Lock on — stopping an -# autonomous sequence is a safety -# control, never lockable) -# TAMPERED -> silence alarm + return to IDLE -# OPENING/CLOSING -> stop motor -# IDLE -> close the cover -# - Double click : open the cover (ignored while calibrating) -# Calibration / WiFi Reset (GPIO21): -# - Double click : start auto-calibration (see the auto_calibrate script) -# - Hold 3-10 s : clear saved Wi-Fi credentials and reboot into setup -# -# ----------------------------------------------------------------------------- -# Anti-tamper (accelerometer-based) -# ----------------------------------------------------------------------------- -# The LIS2DH12 establishes a "rest orientation" baseline (stored in g; -# live m/s² readings are normalized before comparing). While IDLE, a -# 1 s interval compares live x/y/z to the baseline; if Euclidean distance -# exceeds the Tamper Threshold, the device enters TAMPERED state, latches -# "Tamper Detected", and pulses the buzzer for 60 s. The alarm dismisses -# via Button 1 single-click, "Clear Tamper Alert", or "Set Tamper Baseline" -# (which also captures a new baseline at the current position). -# -# Off by default. Turning the "Tamper Detection" switch ON auto-captures -# the current accel reading as the baseline — no separate Set Baseline -# press needed. -# -# ----------------------------------------------------------------------------- -# Vibration-based stall (secondary to TMC2209 StallGuard) -# ----------------------------------------------------------------------------- -# While OPENING/CLOSING, a 200 ms interval samples -# vibration = |sqrt(x² + y² + z²)/9.80665 - 1| [g] -# from the accelerometer — an orientation-independent measure of non-gravity -# acceleration. UNITS MATTER: the LIS2DH12 sensors publish m/s² (ESPHome -# convention) while every threshold in this config is in g — each consumer -# divides by 9.80665 before comparing. If vibration exceeds Vibration -# Threshold for Vibration Stall Samples distinct sensor samples, the same -# stop+IDLE+save-position path runs as on_stall. Auto-calibration tunes the -# threshold; or tune by watching the Accel X/Y/Z sensors during a clean -# move vs. a deliberately stalled one. -# -# ----------------------------------------------------------------------------- -# Daily schedule (browser-editable, no Home Assistant required) -# ----------------------------------------------------------------------------- -# Web UI fields: -# - "Open Time" / "Close Time" : daily times to open / close -# - "Schedule Enabled" : master on/off (default OFF) -# - "Timezone" : POSIX TZ string (default UTC0) -# With the schedule enabled, the cover opens at Open Time and closes at -# Close Time every day. All four settings persist across reboots. Requires -# a network connection for SNTP time. -# -# ----------------------------------------------------------------------------- -# Wi-Fi provisioning (no secrets required) -# ----------------------------------------------------------------------------- -# This firmware ships without hardcoded Wi-Fi credentials. On a fresh flash -# (or after a Wi-Fi reset via the GPIO21 button), the device broadcasts a -# "glasscalibur-XXXXXX" hotspot and the captive portal prompts for your -# network. Improv over USB-Serial-JTAG also works (e.g. web.esphome.io). -# Credentials are saved to flash and survive reboots and OTA updates. -# -# secrets.yaml is only needed if you enable the optional native-API -# encryption key — see the Home Assistant pairing note below. -# -# Non-HA users: open http://glasscalibur-XXXXXX.local in a browser to -# control the device (replace XXXXXX with the MAC suffix from the boot log). -# -# ----------------------------------------------------------------------------- -# Home Assistant pairing note -# ----------------------------------------------------------------------------- -# The native API runs without encryption by default. Home Assistant will -# show a warning when you first add the device ("Communication not -# encrypted"). The device works normally despite this warning. To silence -# it, generate a key and add it to secrets.yaml, then uncomment the -# encryption block in the `api:` section below: -# -# api: -# encryption: -# key: !secret api_key # 32-byte base64 string -# -# Generate a key with: -# python3 -c "import secrets,base64; print(base64.b64encode(secrets.token_bytes(32)).decode())" +# Sets this board's chip, GPIO map, OTA identity, and hardware constants, then +# pulls the shared valar-core + scheduling from Valar-Systems/valar-motion as +# packages and layers the Glasscalibur product (window cover, limit switches, +# accelerometer tamper/stall, buzzer, auto-calibration) on top. +# +# Glasscalibur differs from the VAL30xx reference boards in hardware: a 220 mOhm +# sense resistor, immediate-stop deceleration, and a fixed counter-clockwise +# motor direction with no runtime direction select. Those are expressed through +# the stepper_* / motor_direction_default substitutions that core v0.3.0 added. # ============================================================================= - -# External components. -# - slimcdk: TMC2209 driver + custom stepper component with on_stall trigger, -# StallGuard configuration, register-level UART access. -# - valar-systems: Valar fork of latonita's draft LIS2DH12 PR (esphome#14788) -# with four fixes — stale-sample guard, throttled loop() reads, optional -# data-ready interrupt pin, and on-chip high-pass filter support. -external_components: - - source: github://slimcdk/esphome-custom-components - components: [tmc2209_hub, tmc2209, stepper] - - - source: github://Valar-Systems/esphome-lis2dh12 - components: [lis2dh12_base, lis2dh12_i2c] - -globals: - # irun ≤ 19 everywhere: 19 = rated 0.6 A on this rsense/vsense math - # (I_RMS = (CS+1)/32 × 0.9575 A). Higher values overdrive the 60 °C-limited - # motor (25 = 130 %, 31 = 160 %). on_boot clamps restored values from older - # firmware that allowed up to 31. - - id: global_irun - type: int - restore_value: yes - initial_value: '19' - - id: global_max_speed - type: int - restore_value: yes - initial_value: '1500' - - id: global_acceleration - type: int - restore_value: yes - initial_value: '1500' - - id: global_sgthrs - type: int - restore_value: yes - initial_value: '100' - - id: global_tcoolthrs - type: int - restore_value: yes - initial_value: '100' - - # Cover state machine. Not persisted — every reboot starts IDLE, since the - # motor is not actually moving at that point. - # 0 = IDLE no motion in progress - # 1 = OPENING driving toward max_distance_steps - # 2 = CLOSING driving toward 0 - # 3 = TAMPERED tamper detected; cleared by "Clear Tamper Alert" or - # "Set Tamper Baseline" buttons. Tracks tamper_detected. - # Mirrored to HA via the "State" text_sensor. Transitions back to IDLE - # happen on stop_action (which the limit switches and the temperature cutoff - # both fan into) and on the stepper's on_stall handler. - - id: global_state - type: int - restore_value: no - initial_value: '0' - - # Last known stepper position in steps. Persisted so the cover entity - # survives reboots without re-homing. Saved at IDLE-transition points only - # (stop_action, on_stall, natural target arrival) to minimize flash writes — - # not in the 200 ms poll, despite Ropener doing so, since we don't need a - # per-tick checkpoint. - - id: global_current_position - type: int - restore_value: yes - initial_value: '0' - - # --- Anti-tamper baseline --- - # Captured by the "Set Tamper Baseline" button when the device is at rest in - # its mounted orientation. The interval-driven check compares live accel to - # these and flags tamper if Euclidean distance > tamper_threshold. - - id: tamper_baseline_x - type: float - restore_value: yes - initial_value: '0.0' - - id: tamper_baseline_y - type: float - restore_value: yes - initial_value: '0.0' - - id: tamper_baseline_z - type: float - restore_value: yes - initial_value: '-1.0' # accelerometer is on the bottom of the PCB facing - # down, so at rest Z reads -1g (gravity). Overwritten - # by "Set Tamper Baseline" on first calibration. - # Latched tamper state. Cleared only by the "Clear Tamper Alert" button so a - # bumped-then-replaced device doesn't silently un-flag itself. - - id: tamper_detected - type: bool - restore_value: no - initial_value: 'false' - - # --- Vibration-based stall (secondary to StallGuard) --- - # A stalled stepper shakes the device hard. While the motor is commanded to - # move, we run a leaky counter on vibration-above-threshold samples; crossing - # vib_stall_samples declares a stall. Leaky (rather than consecutive-reset) - # so a single quiet sample mid-stall doesn't restart the count from zero. - - id: vib_loud_count - type: int - restore_value: no - initial_value: '0' - - # Timestamp (millis()) at which the current motion command started. Used by - # the vib-stall check to skip its first ~500 ms after launch — initial motor - # torque can spike the accelerometer hard enough to false-trip before the - # mechanism reaches steady-state. Set in open_action / close_action / - # position_action; not persisted (only meaningful while motion is active). - - id: motion_start_ms - type: uint32_t - restore_value: no - initial_value: '0' - - # --- Lifetime diagnostic counters (persisted) --- - # Incremented each time the respective event fires. Useful for spotting a - # unit that's stalling more than its peers, or detecting tamper attempts - # that happened while the owner wasn't watching HA. Written to flash via - # NVS batching so they're not free-running flash writers. - - id: stallguard_count - type: int - restore_value: yes - initial_value: '0' - - id: vib_stall_count - type: int - restore_value: yes - initial_value: '0' - - id: tamper_count - type: int - restore_value: yes - initial_value: '0' - - # --- Sunrise/sunset schedule (persisted) --- - # Geographic location for the optional sun schedule, in decimal degrees - # (north and east positive). Persisted and browser-editable via the - # "Latitude"/"Longitude" numbers; pushed into the sun component on change and - # on boot. Default 0,0 — set your real coordinates for the sun schedule to be - # meaningful. - - id: global_latitude - type: float - restore_value: yes - initial_value: '0.0' - - id: global_longitude - type: float - restore_value: yes - initial_value: '0.0' - - # Minute offsets applied to the sun events: the cover opens at - # sunrise + open_offset and closes at sunset + close_offset. Signed, so a - # negative value fires before the event (e.g. "close 30 min before sunset" is - # a close offset of -30). Persisted and browser-editable. - - id: global_sun_open_offset - type: int - restore_value: yes - initial_value: '0' - - id: global_sun_close_offset - type: int - restore_value: yes - initial_value: '0' - - # Last daylight state the sun scheduler drove the cover to (1 = open/day, - # 0 = closed/night, -1 = not yet evaluated). The scheduler only issues a move - # when this changes, so it acts once per sunrise/sunset transition (and - # reconciles to the correct state on boot) without repeatedly fighting manual - # control. Reset to -1 whenever the schedule inputs change. Not persisted: - # every boot re-evaluates from the current time. - - id: global_sun_last_desired - type: int - restore_value: no - initial_value: '-1' - - # --- Auto-calibration + stall-release state (v1.3.0) --- - # Live calibration state (not persisted — a reboot mid-cal starts clean). - - id: cal_active - type: bool - restore_value: no - initial_value: 'false' - - id: cal_sg_min - type: int - restore_value: no - initial_value: '510' - - id: cal_vib_max - type: float - restore_value: no - initial_value: '0.0' - - id: cal_irun_index - type: int - restore_value: no - initial_value: '0' - - id: cal_done - type: bool - restore_value: no - initial_value: 'false' - - id: cal_travel_ok - type: bool - restore_value: no - initial_value: 'false' - # Per-travel deadline: armed immediately before each calibration travel from - # the LIVE speed (max_distance_steps / global_max_speed × 1.5 + 10 s). A - # fixed timeout cannot work here — travel time ranges from ~34 s (speed - # 10000) to ~57 min (speed 100) — and the wait_until conditions below always - # exit on software target arrival anyway (open-loop stepper: the 200 ms - # completion poll drops global_state to 0), so the deadline is a backstop. - - id: cal_t0_ms - type: uint32_t - restore_value: no - initial_value: '0' - - id: cal_budget_ms - type: uint32_t - restore_value: no - initial_value: '0' - # The user's Vibration Threshold as it was when calibration started, so a - # failed/aborted run can put it back after probing with the raised - # ${cal_vib_backstop}. Volatile: if the device reboots mid-calibration the - # raised backstop persists in the number entity — still a working (just - # laxer) detector; recalibrate to retune. - - id: cal_vib_prev - type: float - restore_value: no - initial_value: '0.30' - # Escalation handshake between the 100 ms calibration sampler and cal_leg: - # sustained SG_RESULT below ${cal_sg_floor} (torque margin starving) sets - # cal_escalate so the leg bumps to the next current BEFORE a violent stall - # develops. Reset at the start of every attempt. - - id: cal_escalate - type: bool - restore_value: no - initial_value: 'false' - - id: cal_sg_low_count - type: int - restore_value: no - initial_value: '0' - # Largest (= slowest) TSTEP seen at cruise during the validation leg. - # Feeds TCOOLTHRS = 1.5 × cruise TSTEP on success: the TMC2209 only - # asserts stalls while TSTEP < TCOOLTHRS, and the shipped default (100) - # sat BELOW the ~150 cruise TSTEP at speed 2500 — StallGuard was - # velocity-gated inert in normal operation until calibration derives - # this properly. - - id: cal_tstep_max - type: int - restore_value: no - initial_value: '0' - - # Which calibration phase is running, so the status text can tell a - # bystander that the homing close is deliberate (field 2026-07-10: a user - # watching the window close right after starting calibration read it as a - # malfunction and hit the abort button). 0 = none/generic, 1 = homing - # close, 2 = open leg, 3 = close leg. Display-only; gated on cal_active. - - id: cal_phase - type: int - restore_value: no - initial_value: '0' - - # Persisted results (survive reboot, feed the "Calibration Status" sensor). - # cal_result: 0 = never run, 1 = OK, 2 = OK w/ sticky-window warning, - # 3 = FAILED (travel never reached both limits), 4 = thermal - # abort (motor exceeded 55 °C mid-run — see cal_thermal_guard), - # 5 = aborted by user (Button 1) - - id: cal_result - type: int - restore_value: yes - initial_value: '0' - - id: cal_irun_chosen - type: int - restore_value: yes - initial_value: '0' - - id: cal_sgthrs_chosen - type: int - restore_value: yes - initial_value: '0' - - id: cal_sgmin_seen - type: int - restore_value: yes - initial_value: '0' - - id: cal_vibmax_x100 - type: int - restore_value: yes - initial_value: '0' - - id: cal_speed_at - type: int - restore_value: yes - initial_value: '0' - - # Stall auto-release handshake. One back-off per stall event: set when a - # release starts, cleared by stall_release_clear 5 s later. A second stall - # while the flag is set just stops (jammed solid — don't oscillate). - - id: stall_release_active - type: bool - restore_value: no - initial_value: 'false' - -# ----------------------------------------------------------------------------- -# Mechanical constants -# ----------------------------------------------------------------------------- -# Compile-time substitutions — change them only if the mechanism or -# microstepping changes; a new flash is required. max_distance_steps is the -# absolute step count between the close-end limit (position 0) and the open- -# end limit (position max_distance_steps). It is the "100%" target the cover -# entity uses to map position 1.0 -> step count and is what the limit switches -# re-calibrate against when they fire. substitutions: - steps_per_revolution: "1600" # 200 full steps × 8 microsteps - max_distance_revs: "213.3669" # informational only; = max_distance_steps / steps_per_revolution - max_distance_steps: "341387" - - # --- Auto-calibration (v1.3.0) constants --- - # Minimum acceptable SG_RESULT during a clean travel (0-510 scale). Below - # this, torque margin is too thin and the next irun candidate is tried. - # NOTE: there is deliberately NO fixed travel timeout — a full travel is - # max_distance_steps / global_max_speed seconds (≈228 s at the default - # 1500 steps/s, and speed is user-tunable 100..10000), so any constant is - # either too short (fails healthy probes mid-travel) or uselessly long. - # Each calibration travel instead computes its own deadline from the live - # speed (see cal_t0_ms / cal_budget_ms in globals). - cal_sg_floor: "80" - # Vibration backstop used WHILE calibrating: the vibration detector stays - # live during probes (it is the only stall protection while the StallGuard - # reaction is gated off), but the user's tuned threshold may be wrong — - # that threshold is one of the things calibration exists to set. Probes run - # with the threshold raised to at least this value. 0.45 (= the tuned - # threshold's clamp ceiling): field stall judder sampled 0.6-1.2 g on the - # violent phases but alternated with quieter readings — 0.60 missed too - # many of them to accumulate. Free-run vibration on a healthy travel is - # ~0.02-0.06 g, so 0.45 keeps ~8x margin against false trips. Restored - # (fail/abort) or replaced by the tuned value (success) at the end. - cal_vib_backstop: "0.45" - # Stall auto-release distance in steps. Target ≈8 mm of sash travel. - # Kinematics measured from the CAD (2026-07-10): Tr6×1 leadscrew driven - # through a 12T:23T reduction -> 1600 × 23/12 = 3066.7 steps per screw - # rev = 3066.7 steps/mm at the 1 mm lead. 8 mm ≈ 24 533 steps. Stroke is - # 111.3 mm total (341387 steps), so this back-off is ~7 % of travel and - # takes ~10 s at the default speed — slow, but it is the leadscrew's - # 0.8 mm/s, not a firmware choice. (Closes protocol item OPEN-2.) - stall_release_steps: "24500" - - # Firmware version — single source of truth. Feeds project.version (so Home - # Assistant / the ESPHome dashboard report the running build) and the - # "Firmware Version" diagnostic sensor shown on the web UI. Bump this on every - # released change, then publish a matching GitHub release (see ota_asset). - firmware_version: "1.3.0" - - # Base name of the OTA asset uploaded to each GitHub release. The "Update - # Firmware (GitHub)" button downloads - # https://github.com/Valar-Systems/Glasscalibur/releases/latest/download/${ota_asset}.ota.bin - # (and the matching .ota.bin.md5). Keep the uploaded asset names stable - # (version-less) so this "latest" URL never changes between releases. + device_name: "glasscalibur" + friendly_name: "Glasscalibur" + board_id: "Glasscalibur (ESP32-C6)" + esp_board: "esp32-c6-devkitc-1" + log_uart: "USB_SERIAL_JTAG" + ota_repo: "Valar-Systems/Glasscalibur" # field units self-update from Glasscalibur's own releases ota_asset: "Glasscalibur" + # "dev" for local/branch builds; the release workflow overrides it with the + # git tag (-s fw_version) so shipped units report the version they run. + fw_version: "dev" + + # --- GPIO map ------------------------------------------------------------- + pin_uart_tx: "1" + pin_uart_rx: "0" + pin_enn: "8" + pin_index: "6" + pin_diag: "14" + pin_btn1: "23" # Button 1 + pin_btn2: "2" # UNUSED — core's Button 2 is !removed in the product + # layer (Glasscalibur has no second button); this only + # exists so the substitution resolves before removal. + pin_btn_wifi: "21" # Wi-Fi / Calibrate button + + # --- Stepper hardware (overrides core v0.3.0 defaults) -------------------- + stepper_rsense: "220 mOhm" + stepper_vsense: "false" + stepper_deceleration: "inf" # immediate stop + stepper_max_speed: "1200 steps/s" + stepper_acceleration: "100 steps/s^2" + stepper_config_dump: "false" + motor_direction_default: "COUNTERCLOCKWISE" + + # --- Mechanical constants (product) --------------------------------------- + steps_per_revolution: "1600" # 200 full steps × 8 microsteps + max_distance_revs: "213.3669" # informational; = max_distance_steps / steps_per_revolution + max_distance_steps: "341387" # close-end limit (pos 0) → open-end limit + cal_sg_floor: "80" # min acceptable SG_RESULT during a clean calibration travel + cal_vib_backstop: "0.45" # vibration threshold floor held during calibration probes + stall_release_steps: "24500" # stall auto-release back-off (~8 mm sash travel) -# Native ESPHome API — works without Home Assistant. Encryption removed so the -# build doesn't require secrets.yaml. To pair with HA without the "Communication -# not encrypted" warning, add: -# encryption: -# key: !secret api_key -api: - -# Over-the-air firmware updates. The `esphome` platform handles uploads from the -# ESPHome dashboard / CLI; the `http_request` platform powers the "Update -# Firmware (GitHub)" button, which pulls the latest released binary from GitHub. -ota: - - platform: esphome - - platform: http_request - -# HTTP client used by the GitHub OTA button. verify_ssl is off so we don't have -# to bundle and refresh a CA certificate; the tradeoff is no certificate -# verification on the download. The timeout is generous (the image is ~1 MB). -# The buffers are enlarged from the 512-byte default: GitHub 302-redirects the -# download to a very long signed CDN URL, and the request/response don't fit in -# 512 bytes (the esp_http_client reports "Out of buffer" and the OTA fails). -http_request: - verify_ssl: false - timeout: 10min - buffer_size_rx: 4096 - buffer_size_tx: 4096 - -# Logger console comes through the ESP32-C6's built-in USB-Serial-JTAG -# controller (USB-C data lines), independent of GPIO16/17. INFO for deployed -# units; bump to DEBUG temporarily when tuning StallGuard or vib thresholds. -logger: - level: DEBUG - logs: - # Silence the LIS2DH12 status-poll chatter (every accelerometer sample - # produces ~50 i2c.idf lines while it waits for DRDY). Bump these back - # to DEBUG if you actually need to debug I2C. - i2c.idf: INFO - lis2dh12: INFO - -# Browser-based control panel — open http://glasscalibur-XXXXXX.local in -# any browser on the same network (where XXXXXX is the last 3 bytes of the -# device's MAC, printed on the serial log at boot). Exposes all entities -# (cover, buttons, sliders, switches, sensors) without requiring Home Assistant. -# -# version: 3 enables the grouped UI: instead of one alphabetical list, entities -# are placed into the labeled, collapsible groups defined below. Each entity -# sets `web_server: { sorting_weight, sorting_group_id }` to choose its group -# and position within it; the groups themselves are ordered by their own -# sorting_weight (lower = higher on the page). -web_server: - port: 80 - version: 3 - sorting_groups: - - id: group_control - name: "Control" - sorting_weight: 10 - - id: group_motion - name: "Motion Tuning" - sorting_weight: 20 - - id: group_stallguard - name: "StallGuard / Stall Detection" - sorting_weight: 30 - - id: group_security - name: "Security / Tamper" - sorting_weight: 40 - - id: group_schedule - name: "Schedule" - sorting_weight: 50 - - id: group_diagnostics - name: "Diagnostics" - sorting_weight: 60 - -# ----------------------------------------------------------------------------- -# Board / framework -# ----------------------------------------------------------------------------- -esp32: - variant: esp32c6 - framework: - type: esp-idf - -# ----------------------------------------------------------------------------- -# Wi-Fi (provisioned at first boot — no hardcoded credentials) -# ----------------------------------------------------------------------------- -# This firmware ships WITHOUT hardcoded credentials so each unit can be -# provisioned to its owner's network — nothing is baked into the binary. On a -# fresh flash (or after credentials are cleared) the device comes up in setup -# mode: it broadcasts a "glasscalibur-XXXXXX" hotspot (same name as its -# mDNS hostname, where XXXXXX is the last 3 bytes of the MAC) and the captive -# portal lets you enter your network. Improv over USB serial (e.g. -# web.esphome.io) also works. Credentials are saved to flash and survive -# reboots and OTA updates. -wifi: - # No STA `ssid:`/`password:` here on purpose: credentials come from the - # captive portal or Improv and are stored in flash. ESPHome keeps - # flash-saved credentials as long as none are set in the config file. - # - # No `ssid:` under `ap:` on purpose either: ESPHome defaults the fallback - # hotspot SSID to App.get_name() — with name_add_mac_suffix that's - # "glasscalibur-XXXXXX", unique per device and matched to .local. - ap: - -captive_portal: - -# Improv Wi-Fi provisioning over USB serial — lets tools like web.esphome.io -# push Wi-Fi credentials without editing secrets.yaml or using the hotspot. -improv_serial: - -# ----------------------------------------------------------------------------- -# Time source (drives the open/close schedule) -# ----------------------------------------------------------------------------- -# SNTP keeps the clock synced over the network. SNTP time is UTC; the local -# time the schedule fires on is derived from the timezone. We pin a deterministic -# "UTC0" default here so every shipped unit behaves identically — each user -# sets their own zone at runtime via the "Timezone" text entity, no reflash. -# Setting `timezone:` also enables USE_TIME_TIMEZONE so set_timezone() compiles. -time: - - platform: sntp - id: sntp_time - timezone: "UTC0" - -# ----------------------------------------------------------------------------- -# Sun position (drives the optional sunrise/sunset schedule) -# ----------------------------------------------------------------------------- -# Computes local sunrise/sunset from the device's latitude/longitude and the -# SNTP clock above. The lat/long literals here are placeholders (0,0 — "null -# island"); the real location is pushed in at runtime from the browser-editable -# "Latitude"/"Longitude" numbers (and re-applied in on_boot), so no reflash is -# needed to set your location. Consumed by the sun-schedule interval further -# down when "Sun Schedule" is enabled. The time source is auto-resolved to the -# single `time:` component above. -sun: - id: sun_sun - latitude: 0° - longitude: 0° - -# ----------------------------------------------------------------------------- -# Device identity + boot-time motor configuration -# ----------------------------------------------------------------------------- -# on_boot: -# 1. Restore last saved position so HA cover state is accurate. -# 2. Override with limit-switch ground truth if either is currently pressed. -# 3. Apply persisted speed / acceleration to the stepper. -# 4. Configure TMC2209: direction, microsteps, TCOOLTHRS, StallGuard, currents. -# 5. Re-apply the user's saved timezone (restore doesn't re-fire set_action). esphome: - name: glasscalibur - name_add_mac_suffix: true # every unit comes up unique, e.g. glasscalibur-a1b2c3 - friendly_name: "Glasscalibur" - # Project identity + firmware version. Surfaced in Home Assistant device - # info and the ESPHome dashboard, and exposed over the native API so HA can - # tell which build is running. Bump `version` on every released change. project: name: "valar-systems.glasscalibur" - version: "${firmware_version}" - on_boot: - # - priority: -100 - # then: - # Restore last saved position so HA shows the right cover state on boot. - # A pressed limit switch (below) overrides this with the known limit value. - - stepper.report_position: - id: driver - position: !lambda "return id(global_current_position);" - - stepper.set_target: - id: driver - target: !lambda "return id(global_current_position);" - - logger.log: "CHECKING LIMIT SWITCHES" - - if: # NEED TO CHECK IF EITHER LIMIT SWITCH IS PRESSED AND SET POSITION BASED ON THAT - condition: - binary_sensor.is_on: limit_switch_1 # Check if switch is pressed - then: - - logger.log: "SETTING LIMIT 1" - - stepper.report_position: - id: driver - position: ${max_distance_steps} - - stepper.set_target: - id: driver - target: ${max_distance_steps} - - if: - condition: - binary_sensor.is_on: limit_switch_2 # Check if switch is pressed - then: - - logger.log: "SETTING LIMIT 2" - - stepper.report_position: - id: driver - position: 0 - - stepper.set_target: - id: driver - target: 0 - - - stepper.set_speed: - id: driver - speed: !lambda "return id(global_max_speed);" - - stepper.set_acceleration: - id: driver - acceleration: !lambda "return id(global_acceleration);" - - - tmc2209.configure: - direction: ccw - microsteps: 8 - interpolation: true - tcool_threshold: !lambda "return id(global_tcoolthrs);" - - tmc2209.stallguard: - # SGTHRS stays at the user's chosen value regardless of the - # StallGuard Enabled switch — the chip always detects stalls; - # the on_stall handler below is what's gated by the switch. - # We avoid writing SGTHRS=0 because the stall condition - # SG_RESULT ≤ SGTHRS*2 can latch immediately at motion start - # (SG_RESULT is briefly 0 before it stabilizes), which would - # stop the motor before it ever gets going. - threshold: !lambda "return id(global_sgthrs);" - - lambda: |- - // irun ≤ 19 invariant: units upgraded from firmware that allowed - // irun up to 31 restore the old value from flash — clamp it before - // it reaches the driver (19 = rated 0.6 A; the motor's 60 °C case - // limit leaves no headroom above that). - if (id(global_irun) > 19) id(global_irun) = 19; - - tmc2209.currents: - standstill_mode: freewheeling - irun: !lambda "return id(global_irun);" #global variable - ihold: 0 - tpowerdown: 0 - iholddelay: 0 - - - lambda: |- - // Re-apply the timezone the user picked in the browser. The Timezone - // text entity restores its saved value during setup, but restoring a - // value does not re-fire its set_action, so push it into the clock - // here on boot. - id(sntp_time)->set_timezone(id(tz_text)->state); - - lambda: |- - // Same story for the saved location: the Latitude/Longitude numbers - // restore their values but don't re-fire their set_action, so push - // them into the sun component here so sunrise/sunset are computed for - // the right place after a reboot. - id(sun_sun)->set_latitude(id(global_latitude)); - id(sun_sun)->set_longitude(id(global_longitude)); - - text_sensor.template.publish: # surface the firmware version on the web UI / HA - id: firmware_version_text - state: "${firmware_version}" - -# ----------------------------------------------------------------------------- -# Buzzer (passive piezo on GPIO5, driven by ledc PWM) -# ----------------------------------------------------------------------------- -# Used by the tamper_alarm script. Frequency and duty are set per-pulse from -# the script (2700 Hz / 50 % gives a clear, audible square wave). -output: - - platform: ledc - pin: GPIO5 - id: buzzer - -# ----------------------------------------------------------------------------- -# UART to TMC2209 (PDN_UART, single-wire) -# ----------------------------------------------------------------------------- -# 500 kbaud is well within the TMC2209's tolerance and gives quick register -# round-trips for the 1 s polled diagnostic sensors. Separate from UART0 — the -# logger uses USB-Serial-JTAG, leaving this UART free for the driver. -uart: - tx_pin: 1 - rx_pin: 0 - baud_rate: 500000 - -# ----------------------------------------------------------------------------- -# Stepper driver -# ----------------------------------------------------------------------------- -# rsense 220 mOhm + vsense=false matches the Glasscalibur board. Adjust rsense -# if your board uses a different sense resistor (lower rsense = higher current -# at the same IRUN setting). -# max_speed / acceleration here are initial values; both are overwritten in -# on_boot from the persisted Speed / Acceleration number entities, so this -# block's settings really only matter for the brief window before on_boot runs. -# on_stall fires on a StallGuard event; we stop, drop to IDLE, save the -# position, and let the user/cover handle recovery. -stepper: - - platform: tmc2209 - id: driver - max_speed: 1200 steps/s # Faster speed is louder #2000 good. #1500 great - acceleration: 100 steps/s^2 - deceleration: inf # Stop immediately - config_dump_include_registers: false - rsense: 220 mOhm - vsense: False - enn_pin: 8 - index_pin: 6 - diag_pin: 14 - on_stall: - # Software gate on the "StallGuard Enabled" switch — the chip itself - # always flags stalls; we only react when the user has opted in. When - # the switch is off, the trigger fires harmlessly with no effect. - # (During auto-calibration the switch is off, so calibration probes - # never enter this handler — the script does its own recovery.) - - if: - condition: - lambda: 'return id(stallguard_enabled).state;' - then: - - stepper.stop: driver - - lambda: 'id(stallguard_count) += 1;' - - logger.log: "MOTOR STALLED - backing off" - # Stall auto-release: the leadscrew is not backdrivable, so a - # stall against an obstruction HOLDS whatever clamp force it - # reached until the motor reverses. Back off ~8 mm so "stops - # while still clamping" becomes genuine auto-reverse. Must run - # BEFORE global_state is zeroed below — the stall direction is - # what decides which way to back off. One release per event: - # a second stall while stall_release_active is set (jammed - # solid) just stops. - - if: - condition: - lambda: 'return !id(stall_release_active);' - then: - - globals.set: - id: stall_release_active - value: 'true' - - lambda: |- - int32_t pos = id(driver)->current_position; - int32_t rel = ${stall_release_steps}; - // Stalled while CLOSING (state 2): back off toward open. - // Stalled while OPENING: back off toward close. - int32_t tgt = (id(global_state) == 2) ? pos + rel : pos - rel; - if (tgt < 0) tgt = 0; - id(driver)->set_target(tgt); - - script.execute: stall_release_clear - - globals.set: - id: global_state - value: "0" - - cover.template.publish: - id: glasscalibur_cover - current_operation: IDLE - - globals.set: - id: global_current_position - value: !lambda "return id(driver)->current_position;" - -# ----------------------------------------------------------------------------- -# Physical buttons + end-stop limit switches -# ----------------------------------------------------------------------------- -# Button 1 (GPIO23): single click cycles between dismiss-alarm / stop-motor -# / close-cover based on global_state; double click opens the cover. -# Limit switches (GPIO15 open, GPIO3 close): when triggered, report the known -# absolute position to the stepper (correcting drift), stop the cover, and -# inherit IDLE-state cleanup through stop_action. Presses are direction- -# gated: a switch acts only when travel is toward it (or idle) — a press -# while moving AWAY is a mechanical blip and is logged then ignored, never -# allowed to stop motion or rewrite position. Both are polled rather -# than interrupt-driven because polling proved more reliable in testing. -# WiFi Reset (GPIO21): long press wipes saved credentials and reboots into -# captive-portal setup mode. -# Tamper / accel-interrupt sensors live at the end of this block. -binary_sensor: - - platform: gpio - name: Button 1 - id: btn1 - web_server: - sorting_weight: 3 - sorting_group_id: group_control - pin: - number: GPIO23 - mode: INPUT - inverted: true - filters: - - delayed_on: 10ms - on_multi_click: - - # --- Single Click --- - # CALIBRATING -> abort the calibration sequence. This check - # deliberately PRECEDES Child Lock: stopping - # an autonomous multi-travel sequence is a - # safety control and must never be lockable. - # TAMPERED -> silence the alarm + return to IDLE. - # OPENING or CLOSING -> stop (stop_action sets state to IDLE). - # Otherwise (IDLE) -> close (close_action sets state to CLOSING). - - timing: - - ON for at most 1.0s - - OFF for at least 0.5s - then: - - if: - condition: - lambda: 'return id(cal_active);' - then: - - script.stop: auto_calibrate - - script.stop: cal_leg - - cover.stop: glasscalibur_cover - - switch.turn_on: stallguard_enabled - - switch.turn_on: vib_enabled - # Put back the vibration threshold the user had before the - # script raised it to the calibration backstop. - - number.set: - id: vib_threshold - value: !lambda 'return id(cal_vib_prev);' - - globals.set: - id: cal_active - value: 'false' - - globals.set: - id: cal_result - value: '5' - # The aborted probe may have left a mid-search candidate - # (e.g. 12) in the driver — restore the rated current. - - globals.set: - id: global_irun - value: '19' - - tmc2209.currents: - irun: !lambda 'return id(global_irun);' - - logger.log: "CAL: aborted by user" - - script.execute: cal_beep_fail - - component.update: cal_status - # Swallow the click — don't fall through to stop/close. - else: - - if: - condition: - lambda: 'return id(child_lock).state;' - then: - - logger.log: "Child lock on — single click ignored" - else: - - if: - condition: - lambda: 'return id(global_state) == 3;' - then: - - script.stop: tamper_alarm - - output.turn_off: buzzer - - globals.set: - id: tamper_detected - value: "false" - - globals.set: - id: global_state - value: "0" - - logger.log: "Tamper alarm dismissed via button" - else: - - if: - condition: - lambda: 'return id(global_state) == 1 || id(global_state) == 2;' - then: - - cover.stop: glasscalibur_cover - else: - - cover.close: glasscalibur_cover - # - if: - # condition: ## If moving, then stop - # lambda: return (id(glasscalibur_cover).current_operation != COVER_OPERATION_IDLE); # Should evaluate to TRUE if moving - # then: - # - cover.stop: glasscalibur_cover - # else: ## If not moving, then close - # - cover.close: glasscalibur_cover - - # - output.turn_on: buzzer - # - output.ledc.set_frequency: - # id: buzzer - # frequency: "2700Hz" - # - output.set_level: - # id: buzzer - # level: "50%" - # - delay: 1s - # - output.turn_off: buzzer - - # --- Double Click --- - # Ignored while calibrating: launching a new motion mid-sequence would - # corrupt the probe in progress (single-click aborts instead). - - timing: - - ON for at most 1.0s - - OFF for at most 0.3s - - ON for at most 1.0s - - OFF for at least 0.5s - then: - - if: - condition: - lambda: 'return id(cal_active);' - then: - - logger.log: "Calibrating — double click ignored (single click aborts)" - else: - - if: - condition: - lambda: 'return id(child_lock).state;' - then: - - logger.log: "Child lock on — double click ignored" - else: - - cover.open: glasscalibur_cover - - # - output.turn_on: buzzer - # - output.ledc.set_frequency: - # id: buzzer - # frequency: "2700Hz" - # - output.set_level: - # id: buzzer - # level: "50%" - # - delay: 1s - # - output.turn_off: buzzer - - - platform: gpio - name: Open Limit Switch - id: limit_switch_1 - web_server: - sorting_weight: 4 - sorting_group_id: group_diagnostics - pin: - number: GPIO15 - mode: INPUT - inverted: true - # Interrupts are not reliable, they constatnly miss. Use polling - use_interrupt: false - #interrupt_type: FALLING - filters: - - delayed_on: 10ms - # Debounce the RELEASE edge too: calibration re-reads the switch - # right after its wait exits, and a micro-bounce (seen on the bench - # with the tube loose) read "released" 9 ms after the press and - # false-failed homing. - - delayed_off: 30ms - on_press: - then: - # Direction-gated: act only when travel is toward this switch - # (opening) or idle (sash hand-moved onto the stop -> re-sync). - # While CLOSING away from the fully-open stop, drive-force - # build-up can rack the sash and re-flick this switch for - # ~150 ms (field 2026-07-10: three calibration close legs in a - # row killed ~1 s in, each blip also re-claiming position as - # fully open mid-close). A 150 ms contact is real, so debounce - # can't filter it; ignoring wrong-direction presses can. - - if: - condition: - lambda: 'return id(global_state) != 2;' - then: - - stepper.report_position: - id: driver - position: ${max_distance_steps} - - stepper.set_target: - id: driver - target: ${max_distance_steps} - - cover.stop: glasscalibur_cover - - logger.log: "LIMIT 1 PRESSED" - else: - - logger.log: "LIMIT 1 blip while closing - ignored" - - - - platform: gpio - name: Close Limit Switch - id: limit_switch_2 - web_server: - sorting_weight: 5 - sorting_group_id: group_diagnostics - pin: - number: GPIO3 - mode: INPUT - inverted: true - # Interrupts are not reliable, they constatnly miss. Use polling - use_interrupt: false - #interrupt_type: FALLING - # Optional: filters to prevent noise from triggering the sensor - filters: - - delayed_on: 10ms - # See the open-limit note: release-edge debounce for the calibration - # homing re-check. - - delayed_off: 30ms - on_press: - then: - # Direction-gated like the open switch: ignore presses while - # OPENING away from the closed stop (see limit_switch_1 note). - - if: - condition: - lambda: 'return id(global_state) != 1;' - then: - - stepper.report_position: - id: driver - position: 0 - - stepper.set_target: - id: driver - target: 0 - - cover.stop: glasscalibur_cover - - logger.log: "LIMIT 2 PRESSED" - else: - - logger.log: "LIMIT 2 blip while opening - ignored" - - # Calibration / WiFi Reset (GPIO21) — two gestures, non-overlapping timings: - # * Double-click (presses ≤ 0.6 s each) -> start auto-calibration. The - # script announces itself (3 beeps + 2 s) before any motion and refuses - # if the device is busy, tampered, or the motor is hot. - # * Hold 3-10 s -> erase saved Wi-Fi credentials and reboot into setup - # mode. A long hold is required (not a quick tap) so the device can't - # be knocked offline by an accidental press. On reboot the device - # re-runs Wi-Fi setup: with no credentials saved (and none compiled - # into the firmware), it broadcasts the "glasscalibur-XXXXXX" hotspot / - # captive portal so a new network can be entered (Improv over USB - # serial works too). - # A double-click press can never satisfy the 3 s minimum hold, so the two - # gestures cannot both fire from one input. - - platform: gpio - name: WiFi Reset - id: btn_wifi_reset - web_server: - sorting_weight: 6 - sorting_group_id: group_diagnostics - pin: - number: GPIO21 - mode: INPUT - inverted: true - filters: - - delayed_on: 10ms - on_multi_click: - - timing: - - ON for at most 0.6s - - OFF for at most 0.4s - - ON for at most 0.6s - - OFF for at least 0.3s - then: - - logger.log: "CAL: requested via calibration button double-click" - - script.execute: auto_calibrate - on_click: - - min_length: 3000ms - max_length: 10000ms - then: - - logger.log: "Clearing saved Wi-Fi credentials and rebooting into setup" - - lambda: |- - // Overwrite the persisted credentials in flash with empty values, - // then drop the in-memory copy so nothing is re-saved on teardown. - esphome::wifi::global_wifi_component->save_wifi_sta("", ""); - esphome::wifi::global_wifi_component->clear_sta(); - - delay: 1s # let the log flush before we restart - - lambda: "App.safe_reboot();" - - # Anti-tamper status. Set by the 1 s tamper-check interval; latched until - # the "Clear Tamper Alert" button is pressed. - - platform: template - name: "Tamper Detected" - id: tamper_sensor - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 5 - sorting_group_id: group_security - lambda: 'return id(tamper_detected);' - -# ----------------------------------------------------------------------------- -# The cover entity (Home Assistant) -# ----------------------------------------------------------------------------- -# Template cover backed by the TMC2209 stepper. Position is reported live as a -# 0..1 ratio of current_position / max_distance_steps. -# - open_action : drive to the configured travel limit (position 1.0) -# - close_action : drive to step 0 (position 0.0) -# - position_action : drive to an arbitrary fraction set from HA — direction -# is inferred by comparing the new target to current_position -# - stop_action : halt + flip state to IDLE + persist position -# -# Each motion action also updates global_state so the "State" text_sensor and -# automations follow along. The motion-completion poll (interval block at the -# bottom of this file) flips current_operation back to IDLE once the stepper -# reaches its target. -cover: - - platform: template - id: glasscalibur_cover - name: Glasscalibur - web_server: - sorting_weight: 1 - sorting_group_id: group_control - has_position: true - lambda: "return (float(id(driver)->current_position) / float(${max_distance_steps}));" - - stop_action: - - logger.log: "COVER stop_action" - - stepper.stop: driver - - globals.set: - id: global_state - value: "0" - - cover.template.publish: - id: glasscalibur_cover - current_operation: IDLE - - globals.set: - id: global_current_position - value: !lambda "return id(driver)->current_position;" - - open_action: - - logger.log: "COVER open_action" - - lambda: 'id(motion_start_ms) = millis();' - - globals.set: - id: global_state - value: "1" - - stepper.set_target: - id: driver - target: ${max_distance_steps} - - cover.template.publish: - id: glasscalibur_cover - current_operation: OPENING - - close_action: - - logger.log: "COVER close_action" - - lambda: 'id(motion_start_ms) = millis();' - - globals.set: - id: global_state - value: "2" - - stepper.set_target: - id: driver - target: 0 - - cover.template.publish: - id: glasscalibur_cover - current_operation: CLOSING - - position_action: - - logger.log: "COVER position_action" - - lambda: 'id(motion_start_ms) = millis();' - # Direction is inferred by comparing the new target to current_position. - - if: - condition: - lambda: 'return (pos * ${max_distance_steps}) >= id(driver)->current_position;' - then: - - globals.set: - id: global_state - value: "1" - - cover.template.publish: - id: glasscalibur_cover - current_operation: OPENING - else: - - globals.set: - id: global_state - value: "2" - - cover.template.publish: - id: glasscalibur_cover - current_operation: CLOSING - - stepper.set_target: - id: driver - target: !lambda return pos * ${max_distance_steps}; - - # NOTE: on_opening / on_opened / on_closing / on_closed used to be wired - # here, each re-publishing the same current_operation they fired on. That - # made the trigger recurse into itself indefinitely and stack-overflow - # within milliseconds of a cover.open / cover.close. The open_action / - # close_action / position_action / stop_action above already publish - # OPENING / CLOSING / IDLE, and the 200 ms motion-completion poll flips - # back to IDLE on target arrival, so these handlers are not needed at all. - -# ----------------------------------------------------------------------------- -# Tunable number entities -# ----------------------------------------------------------------------------- -# Each entity below is bound to a global (or directly to a chip register) and -# pushes its value into the driver / system when the user changes it from the -# web UI or Home Assistant. The `lambda` reads the value back live (every -# update_interval) so the UI reflects the actual state, not just the last -# value we wrote. -number: -# SET acceleration - - platform: template - name: acceleration - web_server: - sorting_weight: 2 - sorting_group_id: group_motion - step: 100 - min_value: 100 - max_value: 10000 - mode: SLIDER - entity_category: CONFIG - lambda: return id(global_acceleration); - update_interval: 1s - set_action: - then: - - globals.set: - id: global_acceleration - value: !lambda "return x;" - - stepper.set_acceleration: - id: driver - acceleration: !lambda "return id(global_acceleration);" - -# SET max_speed - - platform: template - name: max_speed - web_server: - sorting_weight: 1 - sorting_group_id: group_motion - step: 100 - min_value: 100 - max_value: 10000 - mode: SLIDER - entity_category: CONFIG - lambda: return id(global_max_speed); - update_interval: 1s - set_action: - then: - - globals.set: - id: global_max_speed - value: !lambda "return x;" - - stepper.set_speed: - id: driver - speed: !lambda "return id(global_max_speed);" - -# SET irun -# max_value 19 = rated 0.6 A for this motor on this board's sense-resistor -# math (I_RMS = (CS+1)/32 × 0.9575 A). Values above 19 overdrive the motor -# toward its 60 °C case limit (25 = 130 %, 31 = 160 %) — never raise this. - - platform: template - name: irun - web_server: - sorting_weight: 3 - sorting_group_id: group_motion - step: 1 - min_value: 1 - max_value: 19 - mode: BOX - entity_category: CONFIG - lambda: return id(driver)->read_field(IRUN_FIELD); - update_interval: 1s - set_action: - then: - - globals.set: - id: global_irun - value: !lambda "return x;" - - tmc2209.currents: - irun: !lambda "return id(global_irun);" - -# SET TCOOL - - platform: template - name: TCOOLTHRS - web_server: - sorting_weight: 3 - sorting_group_id: group_stallguard - step: 1 - min_value: 0 - max_value: 1048575 - mode: BOX - entity_category: CONFIG - lambda: return id(driver)->read_register(TCOOLTHRS); - update_interval: 1s - set_action: - then: - - globals.set: - id: global_tcoolthrs - value: !lambda "return x;" - - lambda: id(driver)->write_register(TCOOLTHRS, x); - - -# --- Anti-tamper threshold (g) --- -# Euclidean distance between current accel and baseline that triggers a -# tamper alert. 0.30g ≈ 17° tilt or a moderate bump. Tune downward if you -# want more sensitivity, upward if you see false positives. - - platform: template - name: "Tamper Threshold" - id: tamper_threshold - web_server: - sorting_weight: 2 - sorting_group_id: group_security - step: 0.05 - min_value: 0.05 - max_value: 2.0 - mode: BOX - entity_category: CONFIG - unit_of_measurement: "g" - optimistic: true - restore_value: true - initial_value: 0.30 - -# --- Vibration stall threshold (g) --- -# |magnitude - 1g| above which a sample is counted as "loud". A stalled stepper -# shakes the device hard (well above smooth running). Tune by watching the -# accel sensors during a normal move vs. a deliberately stalled one and pick -# a value comfortably between the two. - - platform: template - name: "Vibration Threshold" - id: vib_threshold - web_server: - sorting_weight: 5 - sorting_group_id: group_stallguard - step: 0.05 - min_value: 0.05 - max_value: 4.0 - mode: BOX - entity_category: CONFIG - unit_of_measurement: "g" - optimistic: true - restore_value: true - initial_value: 0.30 - -# --- Vibration stall debounce (distinct accelerometer samples) --- -# Number of net "loud" samples before declaring a stall. The detector counts -# each fresh LIS2DH12 reading once (the sensor publishes every 500 ms), so -# 5 samples ≈ 2.5 s of sustained strong vibration. A single jolt or one noisy -# bump doesn't trip; sustained motor judder does. - - platform: template - name: "Vibration Stall Samples" - id: vib_stall_samples - web_server: - sorting_weight: 6 - sorting_group_id: group_stallguard - step: 1 - min_value: 2 - max_value: 60 - mode: BOX - entity_category: CONFIG - optimistic: true - restore_value: true - initial_value: 5 - -# SET SGTHRS — always written to the chip. The "StallGuard Enabled" switch -# gates only the software on_stall handler, not the chip's SGTHRS register, -# so SG_RESULT and the DIAG output stay live for tuning even when the handler -# is muted. - - platform: template - name: SGTHRS - step: 1 - min_value: 0 - max_value: 255 - mode: SLIDER - entity_category: CONFIG - web_server: - sorting_weight: 2 - sorting_group_id: group_stallguard - lambda: return id(global_sgthrs); - update_interval: 1s - set_action: - then: - - globals.set: - id: global_sgthrs - value: !lambda "return x;" - - lambda: id(driver)->write_register(SGTHRS, x); - - # ---- Sun-schedule location & offsets ---------------------------------------- - # Latitude / Longitude in decimal degrees (north & east positive). Used only - # by the sun schedule. Pushed into the sun component on change (and on boot); - # changing either also resets the scheduler so it re-evaluates immediately. - # Set these to your location for sunrise/sunset to be accurate. - - platform: template - name: "Latitude" - web_server: - sorting_weight: 7 - sorting_group_id: group_schedule - step: 0.0001 - min_value: -90 - max_value: 90 - mode: BOX - entity_category: CONFIG - lambda: return id(global_latitude); - update_interval: 30s - set_action: - then: - - globals.set: - id: global_latitude - value: !lambda "return x;" - - lambda: id(sun_sun)->set_latitude(x); - - globals.set: - id: global_sun_last_desired - value: "-1" - - - platform: template - name: "Longitude" - web_server: - sorting_weight: 8 - sorting_group_id: group_schedule - step: 0.0001 - min_value: -180 - max_value: 180 - mode: BOX - entity_category: CONFIG - lambda: return id(global_longitude); - update_interval: 30s - set_action: - then: - - globals.set: - id: global_longitude - value: !lambda "return x;" - - lambda: id(sun_sun)->set_longitude(x); - - globals.set: - id: global_sun_last_desired - value: "-1" - - # Minute offsets for the sun schedule. Open fires at sunrise + open offset, - # close at sunset + close offset. Negative = before the event (e.g. set the - # close offset to -30 to close 30 minutes before sunset). - - platform: template - name: "Sun Open Offset" - web_server: - sorting_weight: 5 - sorting_group_id: group_schedule - unit_of_measurement: "min" - step: 1 - min_value: -180 - max_value: 180 - mode: BOX - entity_category: CONFIG - lambda: return id(global_sun_open_offset); - update_interval: 30s - set_action: - then: - - globals.set: - id: global_sun_open_offset - value: !lambda "return x;" - - globals.set: - id: global_sun_last_desired - value: "-1" - - - platform: template - name: "Sun Close Offset" - web_server: - sorting_weight: 6 - sorting_group_id: group_schedule - unit_of_measurement: "min" - step: 1 - min_value: -180 - max_value: 180 - mode: BOX - entity_category: CONFIG - lambda: return id(global_sun_close_offset); - update_interval: 30s - set_action: - then: - - globals.set: - id: global_sun_close_offset - value: !lambda "return x;" - - globals.set: - id: global_sun_last_desired - value: "-1" - -# ----------------------------------------------------------------------------- -# Child lock -# ----------------------------------------------------------------------------- -# When ON, the physical Button 1 (single + double click) is ignored so the -# cover can't be opened, closed, or stopped from the on-device button — e.g. to -# keep a child from operating it. Remote control is intentionally unaffected: -# the web UI, Home Assistant, and the open/close schedule all keep working, so -# you can still operate the cover and toggle this lock from your phone. The -# WiFi-reset button (GPIO21) is also left active so the device can always be -# recovered. Persists across reboots; default OFF so a fresh unit is never -# locked. -switch: - - platform: template - name: "Child Lock" - id: child_lock - entity_category: CONFIG - web_server: - sorting_weight: 4 - sorting_group_id: group_control - optimistic: true - restore_mode: RESTORE_DEFAULT_OFF - turn_on_action: - - logger.log: "Child lock enabled — physical button disabled" - turn_off_action: - - logger.log: "Child lock disabled — physical button active" - -# ----------------------------------------------------------------------------- -# Schedule controls -# ----------------------------------------------------------------------------- -# Master on/off for the daily open/close schedule below. When OFF, the Open -# Time / Close Time triggers do nothing. `optimistic` stores state locally; -# `restore_mode` brings it back after a reboot (default OFF so a fresh unit -# never moves the cover unattended). - - platform: template - name: "Schedule Enabled" - id: schedule_enabled - entity_category: CONFIG - web_server: - sorting_weight: 1 - sorting_group_id: group_schedule - optimistic: true - restore_mode: RESTORE_DEFAULT_OFF - - # Sun Schedule: picks WHICH schedule the master "Schedule Enabled" runs. - # OFF -> fixed "Open Time"/"Close Time" pickers (default) - # ON -> sunrise/sunset: open at sunrise + "Sun Open Offset" and close at - # sunset + "Sun Close Offset" (see those numbers and the sun-schedule - # interval). The fixed on_time triggers are suppressed while this is - # ON, so only one schedule is ever active at a time. - # Master "Schedule Enabled" must still be ON for either schedule to run. - # Resets the scheduler's cached daylight state on enable so it reconciles the - # cover to the current sun position immediately rather than at the next event. - - platform: template - name: "Sun Schedule" - id: sun_schedule_enabled - entity_category: CONFIG - web_server: - sorting_weight: 2 - sorting_group_id: group_schedule - optimistic: true - restore_mode: RESTORE_DEFAULT_OFF - turn_on_action: - - globals.set: - id: global_sun_last_desired - value: "-1" - - # Master enable for tamper detection. When OFF the 1 s tamper-check interval - # is skipped entirely. Turning OFF also silences any active alarm and clears - # the TAMPERED state so the device doesn't keep beeping with detection - # "disabled". Turning ON auto-captures the current accel as the new baseline - # so the first check after enabling can't false-trip against a stale baseline - # taken from a different mounting position. Default OFF — user opts in. - - platform: template - name: "Tamper Detection" - id: tamper_enabled - entity_category: CONFIG - web_server: - sorting_weight: 1 - sorting_group_id: group_security - optimistic: true - restore_mode: RESTORE_DEFAULT_OFF - turn_on_action: - # Baselines are stored in g (sensor states are m/s² — ESPHome - # convention) so they compare directly against Tamper Threshold. - - globals.set: - id: tamper_baseline_x - value: !lambda "return id(accel_x).state / 9.80665f;" - - globals.set: - id: tamper_baseline_y - value: !lambda "return id(accel_y).state / 9.80665f;" - - globals.set: - id: tamper_baseline_z - value: !lambda "return id(accel_z).state / 9.80665f;" - - globals.set: - id: tamper_detected - value: "false" - - logger.log: - format: "Tamper detection enabled — baseline captured: x=%.3fg y=%.3fg z=%.3fg" - args: ['id(accel_x).state / 9.80665f', 'id(accel_y).state / 9.80665f', 'id(accel_z).state / 9.80665f'] - turn_off_action: - - script.stop: tamper_alarm - - output.turn_off: buzzer - - globals.set: - id: tamper_detected - value: "false" - - if: - condition: - lambda: 'return id(global_state) == 3;' - then: - - globals.set: - id: global_state - value: "0" - - logger.log: "Tamper detection disabled" - - # Master enable for the accelerometer-based vibration stall check. When OFF - # the 200 ms vibration interval short-circuits — only TMC2209 StallGuard - # remains as a stall safeguard. Defaults OFF because the thresholds need - # tuning per installation and a misconfigured detector trips spurious stops. - # Turning OFF resets the loud-sample counter so a future re-enable starts - # fresh. - - platform: template - name: "Vibration Stall Detection" - id: vib_enabled - entity_category: CONFIG - web_server: - sorting_weight: 4 - sorting_group_id: group_stallguard - optimistic: true - restore_mode: RESTORE_DEFAULT_OFF - turn_off_action: - - globals.set: - id: vib_loud_count - value: "0" - - logger.log: "Vibration stall detection disabled" - - # Master enable for the TMC2209 StallGuard *handler*. The chip's SGTHRS is - # always set to the user's chosen value (via the SGTHRS number entity), so - # the chip detects stalls regardless of this switch. The on_stall handler - # on the stepper is what's gated by this switch — when OFF, stall events - # fire but are ignored, so a misconfigured threshold can't spuriously stop - # the motor. Defaults OFF because SGTHRS needs per-installation tuning - # before stall response can be trusted. - - platform: template - name: "StallGuard Enabled" - id: stallguard_enabled - entity_category: CONFIG - web_server: - sorting_weight: 1 - sorting_group_id: group_stallguard - optimistic: true - restore_mode: RESTORE_DEFAULT_OFF - turn_on_action: - - logger.log: "StallGuard handler enabled" - turn_off_action: - - logger.log: "StallGuard handler disabled" - -# ----------------------------------------------------------------------------- -# Anti-tamper calibration / acknowledgement -# ----------------------------------------------------------------------------- -# Press "Set Tamper Baseline" once the device is mounted in its final position -# and at rest — this captures the current X/Y/Z reading as the "untampered" -# reference. Any subsequent orientation change beyond Tamper Threshold (g) -# while IDLE flags Tamper Detected. The alert is latched so a bumped-then- -# replaced device doesn't silently un-flag; press "Clear Tamper Alert" to -# acknowledge. -button: - - platform: template - name: "Set Tamper Baseline" - entity_category: CONFIG - web_server: - sorting_weight: 3 - sorting_group_id: group_security - on_press: - # Stored in g — see the note on the Tamper Detection switch. - - globals.set: - id: tamper_baseline_x - value: !lambda "return id(accel_x).state / 9.80665f;" - - globals.set: - id: tamper_baseline_y - value: !lambda "return id(accel_y).state / 9.80665f;" - - globals.set: - id: tamper_baseline_z - value: !lambda "return id(accel_z).state / 9.80665f;" - - script.stop: tamper_alarm - - output.turn_off: buzzer - - globals.set: - id: tamper_detected - value: "false" - - if: - condition: - lambda: 'return id(global_state) == 3;' - then: - - globals.set: - id: global_state - value: "0" - - logger.log: - format: "Tamper baseline captured: x=%.3fg y=%.3fg z=%.3fg" - args: ['id(accel_x).state / 9.80665f', 'id(accel_y).state / 9.80665f', 'id(accel_z).state / 9.80665f'] - - - platform: template - name: "Clear Tamper Alert" - entity_category: CONFIG - web_server: - sorting_weight: 4 - sorting_group_id: group_security - on_press: - - script.stop: tamper_alarm - - output.turn_off: buzzer - - globals.set: - id: tamper_detected - value: "false" - - if: - condition: - lambda: 'return id(global_state) == 3;' - then: - - globals.set: - id: global_state - value: "0" - - logger.log: "Tamper alert cleared" - - # --- Lifetime counter resets --- - - platform: template - name: "Reset StallGuard Trips" - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 14 - sorting_group_id: group_diagnostics - on_press: - - globals.set: - id: stallguard_count - value: "0" - - logger.log: "StallGuard trip counter reset" - - - platform: template - name: "Reset Vibration Stall Trips" - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 15 - sorting_group_id: group_diagnostics - on_press: - - globals.set: - id: vib_stall_count - value: "0" - - logger.log: "Vibration stall trip counter reset" - - - platform: template - name: "Reset Tamper Trips" - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 16 - sorting_group_id: group_diagnostics - on_press: - - globals.set: - id: tamper_count - value: "0" - - logger.log: "Tamper trip counter reset" - - # Pull the latest released firmware straight from GitHub and flash it over the - # air. Points at the version-less "latest" asset so the URL never changes - # between releases; the device downloads, flashes, and reboots. Requires an - # internet connection AND a published GitHub release that includes - # ${ota_asset}.ota.bin and its .md5 (see the release-publishing note in the - # firmware README). Until that release exists the button will log a download - # error and leave the running firmware untouched. - - platform: template - name: "Update Firmware (GitHub)" - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 2 - sorting_group_id: group_diagnostics - on_press: - - logger.log: "Checking GitHub for the latest firmware..." - - ota.http_request.flash: - # Version-less "latest" assets (published with every release): the OTA - # image plus a text file holding its MD5, which the flash verifies. - md5_url: https://github.com/Valar-Systems/Glasscalibur/releases/latest/download/${ota_asset}.ota.bin.md5 - url: https://github.com/Valar-Systems/Glasscalibur/releases/latest/download/${ota_asset}.ota.bin - - # Reboot the device from the web UI / Home Assistant — handy after changing a - # setting that only applies at boot, or to recover from a wedged state without - # pulling power. Uses ESPHome's built-in safe restart (flushes pending writes). - - platform: restart - name: "Restart" - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 3 - sorting_group_id: group_diagnostics - - # Start auto-calibration from the web UI / Home Assistant. Same entry point - # as the GPIO21 double-click; all preconditions live in the script. - - platform: template - name: "Auto-Calibrate" - entity_category: CONFIG - web_server: - sorting_weight: 1 - sorting_group_id: group_stallguard - on_press: - - script.execute: auto_calibrate - -# ----------------------------------------------------------------------------- -# Daily open/close schedule -# ----------------------------------------------------------------------------- -# Two browser-editable time pickers. Each fires once a day at the set local -# time; the action runs only when "Schedule Enabled" is on. Times persist -# across reboots (restore_value). Local time depends on the Timezone below. -datetime: - - platform: template - name: "Open Time" - id: open_time - type: time - entity_category: CONFIG - web_server: - sorting_weight: 3 - sorting_group_id: group_schedule - optimistic: true - restore_value: true - initial_value: "07:00:00" - on_time: - - if: - condition: - # !cal_active: a scheduled move landing mid-calibration would - # re-target the stepper and corrupt the probe in progress. - lambda: 'return id(schedule_enabled).state && !id(sun_schedule_enabled).state && !id(cal_active);' - then: - - logger.log: "Schedule: opening cover" - - cover.open: glasscalibur_cover - - - platform: template - name: "Close Time" - id: close_time - type: time - entity_category: CONFIG - web_server: - sorting_weight: 4 - sorting_group_id: group_schedule - optimistic: true - restore_value: true - initial_value: "21:00:00" - on_time: - - if: - condition: - # !cal_active: see the Open Time note above. - lambda: 'return id(schedule_enabled).state && !id(sun_schedule_enabled).state && !id(cal_active);' - then: - - logger.log: "Schedule: closing cover" - - cover.close: glasscalibur_cover - -# ----------------------------------------------------------------------------- -# Timezone (browser-editable) -# ----------------------------------------------------------------------------- -# POSIX TZ string for the schedule's local time. Format (note the sign is -# inverted vs. UTC offset): -# "UTC0" UTC (default) -# "EST5EDT,M3.2.0,M11.1.0" US Eastern with DST -# "PST8PDT,M3.2.0,M11.1.0" US Pacific with DST -# "CET-1CEST,M3.5.0,M10.5.0/3" Central Europe with DST -# Changing it calls set_timezone() immediately; on_boot re-applies the saved -# value (see esphome.on_boot above). -text: - - platform: template - name: "Timezone" - id: tz_text - entity_category: CONFIG - web_server: - sorting_weight: 9 - sorting_group_id: group_schedule - mode: text - optimistic: true - restore_value: true - initial_value: "UTC0" - min_length: 0 - max_length: 64 - set_action: - - lambda: 'id(sntp_time)->set_timezone(x);' - -# ----------------------------------------------------------------------------- -# I2C bus (shared by TMP1075 temperature sensor + LIS2DH12 accelerometer) -# ----------------------------------------------------------------------------- -# scan: true prints all detected device addresses on boot — useful if the -# LIS2DH12 ends up at 0x18 instead of 0x19 on a particular board, or if the -# accel block fails to initialize. -i2c: - sda: GPIO19 - scl: GPIO20 - scan: true - id: i2c_bus - -# ----------------------------------------------------------------------------- -# LIS2DH12 accelerometer -# ----------------------------------------------------------------------------- -# 3-axis MEMS accelerometer on the PCB. Used for two things: -# 1. Anti-tamper detection (checks orientation while IDLE). -# 2. Vibration-based stall detection (backstops StallGuard). -# SA0 is grounded on this board, so the I2C address is 0x18 (would be 0x19 -# if SA0 were pulled to VDD). -lis2dh12_i2c: - id: accelerometer - i2c_id: i2c_bus - address: 0x18 - range: 4G # ±4g — plenty for orientation + motor vibration - resolution: high # 12-bit reads - output_data_rate: 100Hz # chip samples 100x/sec - # 500 ms keeps the draft PR's status-poll cost manageable (its sync DRDY - # loop is what triggers "took a long time" warnings). The 1 s tamper check - # and 200 ms vib-stall check read the cached value, so vib detection latency - # is bounded by this — ~2.5 s worst case at the default 5-sample threshold, - # acceptable for a window cover. Tighten if you want snappier stall reaction. - update_interval: 500ms - -# ----------------------------------------------------------------------------- -# Diagnostic / live sensors -# ----------------------------------------------------------------------------- -# Accelerometer x/y/z feed the anti-tamper and vibration-stall logic in the -# intervals at the bottom of the file. TSTEP / SG_RESULT are useful for tuning -# StallGuard sensitivity (drive the motor under load, watch SG_RESULT, set -# SGTHRS to about half of it). Motor temperature is hard-cut at 80 °C. -sensor: - - platform: lis2dh12_base - lis2dh12_id: accelerometer - acceleration_x: - name: "Accel X" - id: accel_x - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 20 - sorting_group_id: group_diagnostics - acceleration_y: - name: "Accel Y" - id: accel_y - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 21 - sorting_group_id: group_diagnostics - acceleration_z: - name: "Accel Z" - id: accel_z - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 22 - sorting_group_id: group_diagnostics - - - platform: template - name: "TSTEP Sensor" # SHOW TSTEP - lambda: return id(driver)->read_register(TSTEP); - update_interval: 5s - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 7 - sorting_group_id: group_stallguard - - platform: template # SHOW SG_RESULT - name: "SG_RESULT Sensor" - lambda: return id(driver)->read_register(SG_RESULT); - update_interval: 1s - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 8 - sorting_group_id: group_stallguard - - - platform: tmp1075 - name: "Motor Temperature" - id: motor_temp # referenced by the auto_calibrate temperature gate - update_interval: 3s - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 7 - sorting_group_id: group_diagnostics - i2c_id: i2c_bus - address: 0x49 - accuracy_decimals: 2 - # --- ACTION ON TEMPERATURE EXAMPLE --- - on_value_range: - - above: 80 - then: - - cover.stop: glasscalibur_cover - - logger.log: "TOO HOT!" - - # USE ALERT PIN IN FUTURE instead of polling - - # --- Standard network / system diagnostics --- - - platform: wifi_signal - name: "WiFi Signal" - id: wifi_signal_db - update_interval: 60s - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 8 - sorting_group_id: group_diagnostics - - platform: uptime - name: "Uptime" - update_interval: 60s - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 9 - sorting_group_id: group_diagnostics - - platform: internal_temperature - name: "ESP Internal Temperature" - update_interval: 60s - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 10 - sorting_group_id: group_diagnostics - - # --- Lifetime event counters (persisted in flash) --- - # Bump on each respective event; survive reboots. To zero them, you'd need - # to add reset buttons or temporarily flip restore_value: no and reflash. - - platform: template - name: "StallGuard Trips" - lambda: 'return id(stallguard_count);' - update_interval: 30s - entity_category: DIAGNOSTIC - accuracy_decimals: 0 - web_server: - sorting_weight: 11 - sorting_group_id: group_diagnostics - - platform: template - name: "Vibration Stall Trips" - lambda: 'return id(vib_stall_count);' - update_interval: 30s - entity_category: DIAGNOSTIC - accuracy_decimals: 0 - web_server: - sorting_weight: 12 - sorting_group_id: group_diagnostics - - platform: template - name: "Tamper Trips" - lambda: 'return id(tamper_count);' - update_interval: 30s - entity_category: DIAGNOSTIC - accuracy_decimals: 0 - web_server: - sorting_weight: 13 - sorting_group_id: group_diagnostics - -# ----------------------------------------------------------------------------- -# State exposure (HA) -# ----------------------------------------------------------------------------- -# Mirrors global_state to Home Assistant as a string. Polled rather than -# event-driven because transitions happen in many places; one poll is simpler -# than wiring publish() into every site. -# ----------------------------------------------------------------------------- -# State exposure (HA / web UI) -# ----------------------------------------------------------------------------- -# Mirrors global_state to a string. Polled at 500 ms rather than event-driven -# because state transitions happen in many places; one poll is simpler than -# wiring publish() into every transition site. -text_sensor: - - platform: template - name: "State" - id: cover_state - web_server: - sorting_weight: 2 - sorting_group_id: group_control - update_interval: 500ms - lambda: |- - switch (id(global_state)) { - case 1: return {"OPENING"}; - case 2: return {"CLOSING"}; - case 3: return {"TAMPERED"}; - default: return {"IDLE"}; - } - - # Firmware version string, shown in the Diagnostics group of the web UI and in - # Home Assistant device info. Static value published once from esphome.on_boot; - # `update_interval: never` so it isn't re-polled. - - platform: template - name: "Firmware Version" - id: firmware_version_text - icon: "mdi:chip" - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 1 - sorting_group_id: group_diagnostics - update_interval: never - - # --- Network info (handy when troubleshooting connectivity) --- - - platform: wifi_info - ip_address: - name: "IP Address" - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 17 - sorting_group_id: group_diagnostics - ssid: - name: "Connected SSID" - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 18 - sorting_group_id: group_diagnostics - mac_address: - name: "MAC Address" - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 19 - sorting_group_id: group_diagnostics - - # Calibration result, persisted across reboots. Appends STALE if Speed has - # changed since calibration — SG_RESULT is speed-dependent, so a speed - # change invalidates the StallGuard tune. - - platform: template - name: "Calibration Status" - id: cal_status - entity_category: DIAGNOSTIC - web_server: - sorting_weight: 2 - sorting_group_id: group_stallguard - update_interval: 30s - lambda: |- - if (id(cal_active)) { - switch (id(cal_phase)) { - case 1: return {"Calibrating 1/3: closing to home position (normal)"}; - case 2: return {"Calibrating 2/3: opening - finding minimum current"}; - case 3: return {"Calibrating 3/3: closing - verifying"}; - default: return {"Calibrating - keep the window path clear"}; - } - } - char buf[120]; - switch (id(cal_result)) { - case 1: - snprintf(buf, sizeof(buf), "OK: irun=%d SGTHRS=%d (SGmin=%d, vib=%.2fg)", - id(cal_irun_chosen), id(cal_sgthrs_chosen), - id(cal_sgmin_seen), id(cal_vibmax_x100) / 100.0f); - break; - case 2: - snprintf(buf, sizeof(buf), "OK (STICKY WINDOW): irun=%d SGTHRS=%d (SGmin=%d)", - id(cal_irun_chosen), id(cal_sgthrs_chosen), id(cal_sgmin_seen)); - break; - case 3: - return {"FAILED - window did not reach limits. Check track."}; - case 4: - return {"FAILED - motor overheated mid-run. Cool 15 min, re-run."}; - case 5: - return {"Aborted by user - run calibration again"}; - default: - return {"Not calibrated"}; - } - std::string s = buf; - if (id(cal_speed_at) != 0 && id(cal_speed_at) != id(global_max_speed)) { - s += " | STALE - speed changed, recalibrate"; - } - return {s}; - -# ----------------------------------------------------------------------------- -# Tamper alarm -# ----------------------------------------------------------------------------- -# Pulses the buzzer for 60 seconds when tamper is first detected. 500 ms on / -# 500 ms off at 2700 Hz — more attention-grabbing than a steady tone. Stopped -# early by a Button 1 single-press, "Clear Tamper Alert", or "Set Tamper -# Baseline". mode: restart means a fresh tamper trip during an active alarm -# restarts the 60 s timer. -script: - - id: tamper_alarm - mode: restart - then: - - repeat: - count: 60 - then: - - output.turn_on: buzzer - - output.ledc.set_frequency: - id: buzzer - frequency: "2700Hz" - - output.set_level: - id: buzzer - level: "50%" - - delay: 500ms - - output.turn_off: buzzer - - delay: 500ms - - output.turn_off: buzzer # belt-and-suspenders if loop exits early - - # =========================================================================== - # Auto-calibration (v1.3.0) - # =========================================================================== - # Finds the lowest reliable motor current and tunes both stall detectors to - # this specific window, using the physical limit switches as ground truth. - # - # 1. Refuse when busy, tampered, already calibrating, or the motor is hot - # (>50 °C) or its temperature is NAN — sensor absent means thermal - # protection absent; refuse, never ignore. - # 2. Announce: 3 beeps + 2 s stand-clear pause before ANY motion. - # 3. Detector posture: StallGuard REACTION off (SGTHRS is the thing being - # tuned — a stale threshold would false-abort every probe). The - # vibration detector is forced ON as the backstop — it defaults OFF - # from the factory, so without the explicit turn-on a fresh unit would - # probe with no stall protection at all — and its threshold is raised - # to at least ${cal_vib_backstop} g for the duration (the user's value - # may itself be mistuned; it is one of calibration's outputs). A truly - # stalled probe still shakes far harder than that and fails in - # seconds instead of riding out the travel deadline. - # 4. Home to the close limit at irun 19 (rated 0.6 A). The saved position - # can lie (skipped steps, stale flash), so homing first claims - # position = full-open: the close command then always covers the whole - # travel and the limit switch re-syncs position to 0 on arrival. - # 5. Single-pass search (see cal_leg): ONE open travel escalating in - # place up the ladder {12, 15, 17, 19} — on vibration stall, SG - # margin starvation, silent step loss, or deadline, bump to the next - # current and resume from where it stopped — then ONE close travel - # validating the settled current over the full distance (escalating - # further if friction is direction-dependent). Success oracle = the - # LIMIT SWITCHES, never the step counter: an open-loop stepper that - # skipped steps still "arrives" at its software target, so every - # attempt re-claims a full travel of commanded distance. Each - # attempt gets a deadline computed from the LIVE speed - # (max_distance_steps / global_max_speed × 1.5 + 10 s); a fixed - # timeout cannot work — a full travel is ≈228 s at the default - # 1500 steps/s and speed is user-tunable 100..10000. A spot that - # still stalls at 19 exhausts the ladder and fails the run: never - # muscle through what might be an obstruction. - # 6. Outcome: SGTHRS = SGmin/4 — trip level (2×SGTHRS) = half the worst - # legitimate reading. NEVER SGmin/2: that puts the trip level AT the - # worst legitimate load and false-stalls instantly (the original - # tuning bug). Vibration Threshold = 3 × free-run max, clamped - # [0.15, 0.45] g. - # 7. EVERY exit path (success, fail, homing bail-out, user abort in - # Button 1) re-enables BOTH detectors — ship-safe posture. - # - # Abort: Button 1 single-click (checked BEFORE Child Lock — stopping an - # autonomous multi-travel sequence is a safety control, never lockable). - # =========================================================================== - - id: auto_calibrate - mode: single - then: - # ---- 1. Preconditions ------------------------------------------------ - - if: - condition: - lambda: 'return id(global_state) != 0 || id(tamper_detected) || id(cal_active);' - then: - - logger.log: "CAL: refused - busy, tampered, or already calibrating" - - script.stop: auto_calibrate - - if: - condition: - lambda: 'return isnan(id(motor_temp).state) || id(motor_temp).state > 50.0f;' - then: - - logger.log: - level: WARN - format: "CAL: refused - motor temp %.1f degC (limit 50, NAN = sensor absent)" - args: ['id(motor_temp).state'] - - script.stop: auto_calibrate - - - logger.log: "CAL: starting - keep the window path clear" - - globals.set: - id: cal_active - value: 'true' - - globals.set: - id: cal_done - value: 'false' - - globals.set: - id: cal_irun_index - value: '0' - - globals.set: - id: cal_result - value: '0' - - globals.set: - id: cal_phase - value: '0' - - component.update: cal_status - - # ---- 2. Announce: 3 beeps + 2 s stand-clear, before any motion ------- - - repeat: - count: 3 - then: - - output.ledc.set_frequency: - id: buzzer - frequency: "2700Hz" - - output.set_level: - id: buzzer - level: "50%" - - delay: 120ms - - output.turn_off: buzzer - - delay: 120ms - - delay: 2s - - # ---- 3. Detector posture --------------------------------------------- - - switch.turn_off: stallguard_enabled - - switch.turn_on: vib_enabled - - lambda: 'id(cal_vib_prev) = id(vib_threshold).state;' - - number.set: - id: vib_threshold - value: !lambda |- - float v = id(vib_threshold).state; - float b = ${cal_vib_backstop}; - return (v > b) ? v : b; - - # ---- 4. Home to the close limit at full allowed current -------------- - - globals.set: - id: cal_phase - value: '1' - - component.update: cal_status - - globals.set: - id: global_irun - value: '19' - - tmc2209.currents: - irun: !lambda 'return id(global_irun);' - - if: - condition: - binary_sensor.is_off: limit_switch_2 - then: - - stepper.report_position: - id: driver - position: ${max_distance_steps} - - lambda: |- - int spd = id(global_max_speed); - if (spd < 100) spd = 100; - id(cal_budget_ms) = (uint32_t)(1000.0f * ${max_distance_steps} / (float)spd * 1.5f) + 10000; - id(cal_t0_ms) = millis(); - - cover.close: glasscalibur_cover - - wait_until: - condition: - lambda: |- - return id(limit_switch_2).state || id(global_state) == 0 || - (millis() - id(cal_t0_ms)) > id(cal_budget_ms); - - if: - condition: - binary_sensor.is_off: limit_switch_2 - then: - # Could not even home — bail out safe. - - cover.stop: glasscalibur_cover - - globals.set: - id: cal_result - value: '3' - - number.set: - id: vib_threshold - value: !lambda 'return id(cal_vib_prev);' - - switch.turn_on: stallguard_enabled - - switch.turn_on: vib_enabled - - globals.set: - id: cal_active - value: 'false' - - logger.log: - level: WARN - format: "CAL: FAILED - could not reach the close limit" - - script.execute: cal_beep_fail - - component.update: cal_status - - script.stop: auto_calibrate - - # ---- 5. Single-pass search: escalate in place up {12, 15, 17, 19} --- - # One open travel + one close travel total. The open leg starts at the - # lowest candidate and, on trouble (vibration stall, SG margin - # starvation, silent step loss, deadline), bumps to the next current - # and RESUMES from where it stopped — the limit switches remain the - # only success oracle. The close leg then validates the settled - # current over a full travel (friction can be direction-dependent), - # escalating further if it must. Replaces the 4-probe × full-travel - # search: ~2 travels instead of up to 11, which matters on an 80 g - # motor that reached 61.5 °C mid-search in field testing. - - globals.set: - id: cal_irun_index - value: '0' - - script.execute: - id: cal_leg - opening: true - - script.wait: cal_leg - - if: - condition: - lambda: 'return id(cal_travel_ok);' - then: - # cal_irun_index deliberately carries over: the close leg starts - # at the current the open leg settled on. - - script.execute: - id: cal_leg - opening: false - - script.wait: cal_leg - - lambda: |- - // cal_travel_ok now reflects the close leg (or stayed false if - // the open leg exhausted the ladder). SG starvation escalates - // DURING a leg, so completing at a sub-cap current implies - // margin by construction; only a run that needed the full 19 - // with a thin observed minimum is flagged sticky (nothing higher - // is allowed: 19 = rated 0.6 A against a 60 °C case limit). - if (id(cal_travel_ok)) { - id(cal_done) = true; - id(cal_result) = - (id(global_irun) == 19 && id(cal_sg_min) < ${cal_sg_floor}) ? 2 : 1; - } - - # ---- 6. Outcome ------------------------------------------------------- - - if: - condition: - lambda: 'return id(cal_done);' - then: - - lambda: |- - // SGTHRS = SGmin/4 -> trip level (2*SGTHRS) = half the worst - // legitimate reading. NEVER SGmin/2 (trip level AT the worst - // legitimate load = instant false stalls). Clamp to a sane - // register range. SGmin==510 means the leg somehow gathered - // no samples — keep the previous threshold rather than - // tuning from an empty bucket (belt-and-braces; leg-level - // accumulators make this near-impossible). - int sgthrs = id(global_sgthrs); - if (id(cal_sg_min) < 510) { - sgthrs = id(cal_sg_min) / 4; - if (sgthrs < 4) sgthrs = 4; - if (sgthrs > 120) sgthrs = 120; - } - id(global_sgthrs) = sgthrs; - id(cal_sgthrs_chosen) = sgthrs; - id(cal_irun_chosen) = id(global_irun); - id(cal_sgmin_seen) = id(cal_sg_min); - id(cal_vibmax_x100) = (int)(id(cal_vib_max) * 100.0f); - id(cal_speed_at) = id(global_max_speed); - // Arm StallGuard at the operating speed: the chip only - // asserts stalls while TSTEP < TCOOLTHRS, so derive the - // threshold from measured cruise TSTEP (×1.5 keeps the - // acceleration ramp gated off, where SG readings false- - // trip). The old fixed default (100) was BELOW cruise - // TSTEP — StallGuard never fired in normal operation. - if (id(cal_tstep_max) > 0) { - int tc = (id(cal_tstep_max) * 3) / 2; - if (tc > 1048575) tc = 1048575; - id(global_tcoolthrs) = tc; - } - - lambda: 'id(driver)->write_register(TCOOLTHRS, id(global_tcoolthrs));' - - tmc2209.stallguard: - threshold: !lambda 'return id(global_sgthrs);' - # Vibration threshold = 3x the free-run maximum seen during the - # winning probe, clamped [0.15, 0.45] g. Replaces the raised - # calibration backstop AND whatever the user had before. - - number.set: - id: vib_threshold - value: !lambda |- - float v = 3.0f * id(cal_vib_max); - if (v < 0.15f) v = 0.15f; - if (v > 0.45f) v = 0.45f; - return v; - - logger.log: - format: "CAL: done. irun=%d SGTHRS=%d SGmin=%d vib_free=%.2fg TCOOLTHRS=%d" - args: ['id(cal_irun_chosen)', 'id(cal_sgthrs_chosen)', - 'id(cal_sgmin_seen)', 'id(cal_vib_max)', 'id(global_tcoolthrs)'] - - script.execute: cal_beep_ok - else: - - globals.set: - id: cal_result - value: '3' - # Failed: put the user's vibration threshold back (a successful - # run replaces it with the tuned value instead). - - number.set: - id: vib_threshold - value: !lambda 'return id(cal_vib_prev);' - - logger.log: - level: WARN - format: "CAL: FAILED - window never reached both limits" - - script.execute: cal_beep_fail - - # ---- 7. Always: ship-safe posture ------------------------------------- - - switch.turn_on: stallguard_enabled - - switch.turn_on: vib_enabled - - globals.set: - id: cal_active - value: 'false' - - component.update: cal_status - - - id: cal_beep_ok - mode: single - then: - - output.ledc.set_frequency: - id: buzzer - frequency: "2700Hz" - - output.set_level: - id: buzzer - level: "50%" - - delay: 120ms - - output.turn_off: buzzer - - delay: 120ms - - output.set_level: - id: buzzer - level: "50%" - - delay: 120ms - - output.turn_off: buzzer - - - id: cal_beep_fail - mode: single - then: - - repeat: - count: 3 - then: - - output.ledc.set_frequency: - id: buzzer - frequency: "1400Hz" - - output.set_level: - id: buzzer - level: "50%" - - delay: 400ms - - output.turn_off: buzzer - - delay: 150ms - - # Mid-run thermal guard for auto_calibrate. The 50 °C precondition only - # runs once at start; a full search is up to ~11 travels and field testing - # showed 49 °C at start → 55.9 °C three probes in. Called before every - # calibration travel: above 55 °C (or NAN — no sensor means no protection) - # it stops the whole sequence with the same ship-safe cleanup as every - # other exit path. 55 = FW-2's derate level: 5 °C of margin under the - # motor's 60 °C datasheet case limit. - - id: cal_thermal_guard - mode: single - then: - - if: - condition: - lambda: |- - return id(cal_active) && - (isnan(id(motor_temp).state) || id(motor_temp).state > 55.0f); - then: - - logger.log: - level: WARN - format: "CAL: thermal stop - motor %.1f degC (limit 55). Cool down, then re-run." - args: ['id(motor_temp).state'] - - cover.stop: glasscalibur_cover - - globals.set: - id: cal_result - value: '4' - - number.set: - id: vib_threshold - value: !lambda 'return id(cal_vib_prev);' - - switch.turn_on: stallguard_enabled - - switch.turn_on: vib_enabled - - globals.set: - id: cal_active - value: 'false' - - script.execute: cal_beep_fail - - component.update: cal_status - # Must be last: kill the running leg, then the main sequence - # (auto_calibrate is parked in script.wait: cal_leg). - - script.stop: cal_leg - - script.stop: auto_calibrate - - # One calibration travel ("leg") with in-place current escalation. - # opening=true drives close→open (success oracle: limit_switch_1); - # opening=false drives open→close (oracle: limit_switch_2). Each attempt - # runs at CANDIDATES[cal_irun_index] and the leg climbs the ladder when: - # * the vibration detector declares a stall (it stops the motor and - # drops global_state to 0; its auto-release also backs the mechanism - # off ~8 mm, giving the next attempt a run-up at the sticky spot), - # * the sampler reports SG margin starvation (cal_escalate — escalates - # while still moving, before a violent stall develops), - # * the software step count "arrives" without the switch (silent step - # loss — vibration alone misses this), or - # * the per-attempt deadline expires. - # Every attempt re-claims a FULL travel of commanded distance, so a lying - # step counter can never end a leg — only the physical switch can. - # Exhausting the ladder leaves cal_travel_ok false; the caller decides - # what that means. Only ever executed from auto_calibrate. - - id: cal_leg - mode: single - parameters: - opening: bool - then: - - globals.set: - id: cal_travel_ok - value: 'false' - - globals.set: - id: cal_phase - value: !lambda 'return opening ? 2 : 3;' - - component.update: cal_status - - lambda: |- - // LEG-level tune accumulators. SGTHRS / vibration threshold / - // TCOOLTHRS are tuned from the close (validation) leg's stats, - // which resets them here and owns them for its whole travel. - // They must NOT reset per attempt: a leg that escalates moments - // before the switch leaves zero unmasked samples at the final - // current — that shipped SGmin=510 -> SGTHRS=120 (trip level - // above normal cruise readings) in field testing. Sub-final- - // current samples in the mix only bias SGTHRS low = fewer false - // trips, never more. - id(cal_sg_min) = 510; - id(cal_vib_max) = 0.0f; - id(cal_tstep_max) = 0; - - while: - condition: - lambda: |- - bool sw = opening ? id(limit_switch_1).state : id(limit_switch_2).state; - return !sw && id(cal_irun_index) < 4; - then: - - script.execute: cal_thermal_guard - - lambda: |- - static const int CANDIDATES[4] = {12, 15, 17, 19}; - id(global_irun) = CANDIDATES[id(cal_irun_index)]; - id(cal_escalate) = false; - id(cal_sg_low_count) = 0; - - tmc2209.currents: - irun: !lambda 'return id(global_irun);' - - logger.log: - format: "CAL: %s leg at irun=%d" - args: ['opening ? "open" : "close"', 'id(global_irun)'] - # Re-claim a full travel of commanded distance (the step counter - # lies after a stall) and arm the speed-derived deadline. - - lambda: |- - id(driver)->report_position(opening ? 0 : ${max_distance_steps}); - int spd = id(global_max_speed); - if (spd < 100) spd = 100; - id(cal_budget_ms) = (uint32_t)(1000.0f * ${max_distance_steps} / (float)spd * 1.5f) + 10000; - id(cal_t0_ms) = millis(); - - if: - condition: - lambda: 'return opening;' - then: - - cover.open: glasscalibur_cover - else: - - cover.close: glasscalibur_cover - - wait_until: - condition: - lambda: |- - bool sw = opening ? id(limit_switch_1).state : id(limit_switch_2).state; - return sw || id(global_state) == 0 || id(cal_escalate) || - (millis() - id(cal_t0_ms)) > id(cal_budget_ms); - - if: - condition: - lambda: 'return !(opening ? id(limit_switch_1).state : id(limit_switch_2).state);' - then: - # No switch: stalled, starved, silently lost steps, or out - # of time. Stop (a starvation exit arrives here still - # moving) and climb to the next current. At the top of the - # ladder the while-condition ends the leg instead — a spot - # that still stalls at 19 is an obstruction or jam, never - # something to muscle through. - - cover.stop: glasscalibur_cover - - lambda: 'id(cal_irun_index) += 1;' - - delay: 300ms - - lambda: |- - id(cal_travel_ok) = - opening ? id(limit_switch_1).state : id(limit_switch_2).state; - - # Second half of the stall auto-release handshake (see on_stall and the - # vibration-stall interval). mode: restart so a second stall re-arms the - # 5 s window instead of leaving the flag set forever: if this logic lived - # inline in on_stall, a re-trigger during the delay would cancel the - # pending clear and stall_release_active would stay latched until reboot, - # silently disabling every future release. - - id: stall_release_clear - mode: restart - then: - - delay: 5s - - globals.set: - id: stall_release_active - value: 'false' - - globals.set: - id: global_current_position - value: !lambda 'return id(driver)->current_position;' - -# ----------------------------------------------------------------------------- -# Motion-completion poll -# ----------------------------------------------------------------------------- -# The stepper has no "target reached" event, so we poll: if we're in -# OPENING/CLOSING and current_position has caught up to target_position, flip -# back to IDLE, tell the cover entity, and persist the final position. The -# limit-switch and on_stall paths already save inside their own handlers — this -# block only covers natural mid-travel arrivals (e.g. HA position-slider drags). -# ----------------------------------------------------------------------------- -# Periodic intervals — motion completion, tamper check, vibration stall -# ----------------------------------------------------------------------------- -# Three interval blocks run independently: -# 1. 200 ms — detects natural OPENING/CLOSING -> IDLE transitions (target -# reached) and persists the final position to flash. -# 2. 1 s — anti-tamper check; only runs while IDLE and tamper detection -# is enabled. Latches tamper_detected, flips state to TAMPERED, -# and fires the buzzer alarm script. -# 3. 200 ms — vibration-based stall check; only runs while OPENING/CLOSING. -# Counts consecutive "loud" samples and runs the same stop+IDLE -# path as on_stall if the threshold is crossed. -interval: - - interval: 200ms - then: - - if: - condition: - lambda: |- - int s = id(global_state); - return (s == 1 || s == 2) && - id(driver)->current_position == id(driver)->target_position; - then: - - globals.set: - id: global_state - value: "0" - - cover.template.publish: - id: glasscalibur_cover - current_operation: IDLE - - globals.set: - id: global_current_position - value: !lambda "return id(driver)->current_position;" - - # --- Anti-tamper check (only runs while IDLE so motion vibration can't - # trip it). Latches tamper_detected = true on first trip; the binary - # sensor follows the global; the "Clear Tamper Alert" button clears. - - interval: 1s - then: - - if: - condition: - lambda: |- - if (!id(tamper_enabled).state) return false; - if (id(global_state) != 0) return false; - // Calibration shakes the device between travels (state is - // briefly 0 at each limit) — don't count that as tampering. - if (id(cal_active)) return false; - // Sensor states are m/s²; baselines and Tamper Threshold are - // in g — normalize the live readings before comparing. - constexpr float G_MS2 = 9.80665f; - float dx = id(accel_x).state / G_MS2 - id(tamper_baseline_x); - float dy = id(accel_y).state / G_MS2 - id(tamper_baseline_y); - float dz = id(accel_z).state / G_MS2 - id(tamper_baseline_z); - float dist = sqrtf(dx*dx + dy*dy + dz*dz); - return dist > id(tamper_threshold).state; - then: - - globals.set: - id: tamper_detected - value: "true" - - globals.set: - id: global_state - value: "3" - - lambda: 'id(tamper_count) += 1;' - - script.execute: tamper_alarm - - logger.log: - level: WARN - format: "TAMPER DETECTED (deviation > %.2fg)" - args: ['id(tamper_threshold).state'] - - # --- Vibration-based stall check. Only active while OPENING/CLOSING. - # A stalled stepper shakes the device hard. We measure - # |sqrt(x^2+y^2+z^2) - 1g| (orientation-independent vibration amplitude - # — gravity always has magnitude 1g regardless of how the device is - # mounted, so subtracting 1g leaves only the non-gravity component). - # If that exceeds vib_threshold for vib_stall_samples consecutive ticks, - # run the same stop+IDLE path as the TMC2209 on_stall handler. - - interval: 200ms - then: - - if: - condition: - lambda: |- - // Launch mask: the acceleration ramp shakes the device hard — - // steppers pass through their resonance band at low step rates, - // and the leadscrew takes up slack — so vibration there reads - // like a stall on a perfectly healthy travel. A fixed 500 ms - // was shorter than the ramp itself (speed/accel = 1 s at the - // 1500/1500 defaults), which false-tripped ~2 s into every - // move. Mask 500 ms + the time to reach cruise speed, capped - // at 3 s so extreme speed/accel settings can't blind the - // detector for a whole travel. - if (!id(vib_enabled).state) return false; - if (id(global_state) != 1 && id(global_state) != 2) return false; - int acc = id(global_acceleration); - if (acc < 1) acc = 1; - uint32_t mask_ms = 500 + (uint32_t)(1000.0f * id(global_max_speed) / (float)acc); - if (mask_ms > 3000) mask_ms = 3000; - return (millis() - id(motion_start_ms)) >= mask_ms; - then: - - lambda: |- - // The LIS2DH12 publishes every 500 ms but this interval ticks - // every 200 ms, so the same cached sample used to be counted - // 2-3 times — "5 consecutive samples" tripped on only ~2 - // distinct readings (~1 s), not the ~2.5 s of sustained - // judder the debounce was designed for. Count each fresh - // sensor sample exactly once; an unchanged x/y/z triple is - // the same cached reading, not new evidence. - static float last_x = 1e9f, last_y = 1e9f, last_z = 1e9f; - float x = id(accel_x).state; - float y = id(accel_y).state; - float z = id(accel_z).state; - if (x == last_x && y == last_y && z == last_z) return; - last_x = x; last_y = y; last_z = z; - // UNITS: the LIS2DH12 publishes m/s² (ESPHome convention); - // every threshold in this config is in g. Normalize BEFORE - // subtracting the 1 g gravity magnitude — without the - // division this computes ≈9 "g" of vibration at rest, every - // sample counts as loud, and any move is killed as soon as - // the launch mask expires (this false-failed calibration). - 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); - // 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; - // the old symmetric ±1 dithered near zero through a real - // multi-second field stall and never tripped. With +2/-1, - // loud-dominant sampling reaches the 5-sample default in - // ~2 s while an isolated jolt (+2, then decay) still - // cannot trip it. - if (vibration > id(vib_threshold).state) { - id(vib_loud_count) += 2; - } else if (id(vib_loud_count) > 0) { - id(vib_loud_count) -= 1; - } - - if: - condition: - lambda: 'return id(vib_loud_count) >= (int)id(vib_stall_samples).state;' - then: - - logger.log: - level: WARN - format: "VIBRATION STALL — strong vibration for %d samples" - args: ['id(vib_loud_count)'] - - stepper.stop: driver - - lambda: 'id(vib_stall_count) += 1;' - # 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 - # direction decides which way to back off. One release per - # event via stall_release_active. This path stays live - # during calibration (it is the calibration backstop); the - # cal script's own cover.stop may truncate the back-off, - # which is fine — the probe has already failed safe. - - if: - condition: - lambda: 'return !id(stall_release_active);' - then: - - globals.set: - id: stall_release_active - value: 'true' - - lambda: |- - int32_t pos = id(driver)->current_position; - int32_t rel = ${stall_release_steps}; - int32_t tgt = (id(global_state) == 2) ? pos + rel : pos - rel; - if (tgt < 0) tgt = 0; - id(driver)->set_target(tgt); - - script.execute: stall_release_clear - - globals.set: - id: global_state - value: "0" - - cover.template.publish: - id: glasscalibur_cover - current_operation: IDLE - - globals.set: - id: global_current_position - value: !lambda "return id(driver)->current_position;" - - globals.set: - id: vib_loud_count - value: "0" - else: - # Reset the counter whenever we're not commanded to move so the - # next move starts fresh. - - globals.set: - id: vib_loud_count - value: "0" - - # --------------------------------------------------------------------------- - # Sun schedule poll - # --------------------------------------------------------------------------- - # When the master "Schedule Enabled" and "Sun Schedule" are both on, drive the - # cover to match daylight: open during the day, closed at night, using local - # sunrise/sunset (from the sun component) plus the minute offsets. We compare - # the desired daylight state to the last one we acted on and only issue a move - # on a transition — so it fires once at sunrise and once at sunset, reconciles - # to the right state on boot, and never repeatedly fights manual control. - # A 60 s tick is plenty for minute-resolution events. - - interval: 60s - then: - - if: - condition: - # !cal_active: a sun-driven move landing mid-calibration would - # re-target the stepper and corrupt the probe; the reconciler - # fires on the next 60 s tick after calibration ends. - lambda: 'return id(schedule_enabled).state && id(sun_schedule_enabled).state && !id(cal_active);' - then: - - lambda: |- - auto now = id(sntp_time)->now(); - if (!now.is_valid()) - return; // clock not synced yet - auto sr = id(sun_sun)->sunrise(-0.833); // standard sunrise/sunset elevation - auto ss = id(sun_sun)->sunset(-0.833); - if (!sr.has_value() || !ss.has_value()) - return; // no event today (polar day/night) or no location - int now_min = now.hour * 60 + now.minute; - int open_min = sr->hour * 60 + sr->minute + id(global_sun_open_offset); - int close_min = ss->hour * 60 + ss->minute + id(global_sun_close_offset); - int desired = (now_min >= open_min && now_min < close_min) ? 1 : 0; - if (desired == id(global_sun_last_desired)) - return; // already in the right state - id(global_sun_last_desired) = desired; - ESP_LOGI("sun_schedule", "%s cover (now %02d:%02d, open %02d:%02d, close %02d:%02d)", - desired ? "opening" : "closing", - now.hour, now.minute, - (open_min / 60) % 24, ((open_min % 60) + 60) % 60, - (close_min / 60) % 24, ((close_min % 60) + 60) % 60); - auto call = id(glasscalibur_cover)->make_call(); - if (desired == 1) - call.set_command_open(); - else - call.set_command_close(); - call.perform(); - - # --------------------------------------------------------------------------- - # Auto-calibration sampler - # --------------------------------------------------------------------------- - # Fast SG_RESULT / vibration sampler feeding the auto_calibrate script. Runs - # only while calibrating, only while a travel is in progress, and only after - # the launch mask (same ramp-aware mask as the vibration-stall detector, - # +100 ms so the free-run baseline never includes ramp shake — a polluted - # baseline would inflate the tuned Vibration Threshold toward its clamp). - # cal_vib_max takes a running max, so the accelerometer's 500 ms cadence - # only limits how often it can rise, not its correctness. - - interval: 100ms - then: - - if: - condition: - lambda: |- - if (!id(cal_active)) return false; - if (id(global_state) != 1 && id(global_state) != 2) return false; - // Only while actually driving toward the target. At software - // arrival there is a ≤200 ms window before the completion - // poll zeroes global_state, in which the motor has already - // stopped: TSTEP decays toward its 1048575 idle ceiling and - // SG_RESULT reads near zero. One tick inside that window - // shipped TCOOLTHRS=37098 (mid-decay TSTEP × 1.5) and a fake - // SGmin in field testing. - if (id(driver)->current_position == id(driver)->target_position) return false; - int acc = id(global_acceleration); - if (acc < 1) acc = 1; - uint32_t mask_ms = 600 + (uint32_t)(1000.0f * id(global_max_speed) / (float)acc); - if (mask_ms > 3100) mask_ms = 3100; - return (millis() - id(motion_start_ms)) > mask_ms; - then: - - lambda: |- - // Cruise TSTEP (slowest sample survives) -> TCOOLTHRS on - // success. 1048575 is the register's "stopped" value. - int ts = id(driver)->read_register(TSTEP); - if (ts > 0 && ts < 1048575 && ts > id(cal_tstep_max)) { - id(cal_tstep_max) = ts; - } - int sg = id(driver)->read_register(SG_RESULT); - if (sg >= 0 && sg <= 510) { - if (sg < id(cal_sg_min)) id(cal_sg_min) = sg; - // Margin starvation -> escalate: net 8 low ticks below the - // floor asks cal_leg for the next current while the motor - // is still moving — earlier and quieter than a full - // vibration stall, and it bakes the margin requirement - // into the search itself. LEAKY counter (+1 low / -1 - // high), NOT reset-on-good: a skipping stall oscillates - // SG_RESULT wildly sample-to-sample, and consecutive - // counting never accumulated during a real field stall — - // the motor ground at the jam until the step counter ran - // out instead. - if (sg < ${cal_sg_floor}) { - id(cal_sg_low_count) += 1; - if (id(cal_sg_low_count) >= 8) id(cal_escalate) = true; - } else if (id(cal_sg_low_count) > 0) { - id(cal_sg_low_count) -= 1; - } - } - float x = id(accel_x).state; - float y = id(accel_y).state; - float z = id(accel_z).state; - if (!isnan(x) && !isnan(y) && !isnan(z)) { - // Sensor publishes m/s²; cal math (and the Vibration - // Threshold this feeds) is in g — normalize first. - constexpr float G_MS2 = 9.80665f; - float vib = fabsf(sqrtf(x*x + y*y + z*z) / G_MS2 - 1.0f); - if (vib > id(cal_vib_max)) id(cal_vib_max) = vib; - } \ No newline at end of file + version: "${fw_version}" + +packages: + valar_core: + url: https://github.com/Valar-Systems/valar-motion + ref: v0.3.0 + files: [common/valar-core.yaml] + refresh: 1d + scheduling: + url: https://github.com/Valar-Systems/valar-motion + ref: v0.3.0 + files: [common/scheduling.yaml] + refresh: 1d + product: !include ./glasscalibur-product.yaml diff --git a/firmware/esphome/glasscalibur-product.yaml b/firmware/esphome/glasscalibur-product.yaml new file mode 100644 index 0000000..247615a --- /dev/null +++ b/firmware/esphome/glasscalibur-product.yaml @@ -0,0 +1,2314 @@ +# ============================================================================= +# Glasscalibur — ESPHome firmware +# ============================================================================= +# +# A Wi-Fi connected window-cover controller that drives a NEMA stepper motor +# via a TMC2209 driver, with physical end-stop limit switches at both ends of +# travel for absolute position calibration. Control it via a browser at +# http://glasscalibur-XXXXXX.local (where XXXXXX is the last 3 bytes of the +# device's MAC, printed on the serial log at boot). No Home Assistant +# required. Can also pair with Home Assistant over the native ESPHome API. +# +# ----------------------------------------------------------------------------- +# Hardware +# ----------------------------------------------------------------------------- +# MCU : ESP32-C6-MINI-1 (esp-idf framework, USB-Serial-JTAG console) +# Driver : TMC2209 stepper driver, single-wire UART control (PDN_UART) +# Motor : NEMA stepper, 8x microstepping = 1600 steps/rev +# End stops : two physical limit switches (one at each end of travel) +# Sensors : TMP1075 motor temperature (I2C 0x49) — auto-stops at >80 °C +# LIS2DH12 accelerometer (I2C 0x19) — anti-tamper + vib stall +# Buzzer : passive piezo on a ledc PWM output (tamper alarm) +# +# ----------------------------------------------------------------------------- +# GPIO wiring +# ----------------------------------------------------------------------------- +# GPIO0 : UART RX <- TMC2209 PDN_UART +# GPIO1 : UART TX -> TMC2209 PDN_UART +# GPIO3 : Close-end limit switch (active low, polled) +# GPIO5 : Buzzer (ledc output) +# GPIO6 : INDEX (TMC2209 step-counter output, informational) +# GPIO8 : ENN (TMC2209 enable, active low) +# GPIO14 : DIAG (TMC2209 StallGuard / fault output -> on_stall) +# GPIO15 : Open-end limit switch (active low, polled) +# GPIO21 : Calibration / Wi-Fi Reset button (active low; double-click = +# auto-calibrate, hold 3-10 s to clear Wi-Fi creds — the timings +# must never overlap) +# GPIO19 : I2C SDA +# GPIO20 : I2C SCL +# GPIO23 : Button 1 (active low; single/double click) +# +# ----------------------------------------------------------------------------- +# Position / motion model +# ----------------------------------------------------------------------------- +# Steps are the canonical position unit. The cover entity reports position +# as a 0..1 ratio of current_position / max_distance_steps: +# +# position = current_position / max_distance_steps (0.0 .. 1.0) +# +# max_distance_steps is a compile-time substitution (see `substitutions:` +# below) that defines the full travel between limits. Hitting a limit +# switch re-calibrates the stepper to a known absolute position (0 at the +# close end, max_distance_steps at the open end), correcting any drift. +# Position is persisted to flash at every IDLE transition so the cover +# survives reboots without re-homing. +# +# ----------------------------------------------------------------------------- +# State machine (global_state, mirrored to the "State" text_sensor) +# ----------------------------------------------------------------------------- +# 0 = IDLE no motion in progress +# 1 = OPENING driving toward max_distance_steps +# 2 = CLOSING driving toward 0 +# 3 = TAMPERED tamper detected; alarm active or pending acknowledgement +# +# OPENING/CLOSING return to IDLE via the 200 ms motion-completion poll, +# any cover stop_action (limit switch, temperature cutoff, manual stop), +# or the TMC2209 on_stall handler. TAMPERED is a *soft indicator* — +# subsequent motion commands override it; it only returns to IDLE on +# explicit acknowledgement (Button 1 single-click, "Clear Tamper Alert", +# or "Set Tamper Baseline"). +# +# ----------------------------------------------------------------------------- +# Buttons (physical, on the device) +# ----------------------------------------------------------------------------- +# Button 1 (GPIO23): +# - Single click : CALIBRATING -> abort calibration (works even with +# Child Lock on — stopping an +# autonomous sequence is a safety +# control, never lockable) +# TAMPERED -> silence alarm + return to IDLE +# OPENING/CLOSING -> stop motor +# IDLE -> close the cover +# - Double click : open the cover (ignored while calibrating) +# Calibration / WiFi Reset (GPIO21): +# - Double click : start auto-calibration (see the auto_calibrate script) +# - Hold 3-10 s : clear saved Wi-Fi credentials and reboot into setup +# +# ----------------------------------------------------------------------------- +# Anti-tamper (accelerometer-based) +# ----------------------------------------------------------------------------- +# The LIS2DH12 establishes a "rest orientation" baseline (stored in g; +# live m/s² readings are normalized before comparing). While IDLE, a +# 1 s interval compares live x/y/z to the baseline; if Euclidean distance +# exceeds the Tamper Threshold, the device enters TAMPERED state, latches +# "Tamper Detected", and pulses the buzzer for 60 s. The alarm dismisses +# via Button 1 single-click, "Clear Tamper Alert", or "Set Tamper Baseline" +# (which also captures a new baseline at the current position). +# +# Off by default. Turning the "Tamper Detection" switch ON auto-captures +# the current accel reading as the baseline — no separate Set Baseline +# press needed. +# +# ----------------------------------------------------------------------------- +# Vibration-based stall (secondary to TMC2209 StallGuard) +# ----------------------------------------------------------------------------- +# While OPENING/CLOSING, a 200 ms interval samples +# vibration = |sqrt(x² + y² + z²)/9.80665 - 1| [g] +# from the accelerometer — an orientation-independent measure of non-gravity +# acceleration. UNITS MATTER: the LIS2DH12 sensors publish m/s² (ESPHome +# convention) while every threshold in this config is in g — each consumer +# divides by 9.80665 before comparing. If vibration exceeds Vibration +# Threshold for Vibration Stall Samples distinct sensor samples, the same +# stop+IDLE+save-position path runs as on_stall. Auto-calibration tunes the +# threshold; or tune by watching the Accel X/Y/Z sensors during a clean +# move vs. a deliberately stalled one. +# +# ----------------------------------------------------------------------------- +# Daily schedule (browser-editable, no Home Assistant required) +# ----------------------------------------------------------------------------- +# Web UI fields: +# - "Open Time" / "Close Time" : daily times to open / close +# - "Schedule Enabled" : master on/off (default OFF) +# - "Timezone" : POSIX TZ string (default UTC0) +# With the schedule enabled, the cover opens at Open Time and closes at +# Close Time every day. All four settings persist across reboots. Requires +# a network connection for SNTP time. +# +# ----------------------------------------------------------------------------- +# Wi-Fi provisioning (no secrets required) +# ----------------------------------------------------------------------------- +# This firmware ships without hardcoded Wi-Fi credentials. On a fresh flash +# (or after a Wi-Fi reset via the GPIO21 button), the device broadcasts a +# "glasscalibur-XXXXXX" hotspot and the captive portal prompts for your +# network. Improv over USB-Serial-JTAG also works (e.g. web.esphome.io). +# Credentials are saved to flash and survive reboots and OTA updates. +# +# secrets.yaml is only needed if you enable the optional native-API +# encryption key — see the Home Assistant pairing note below. +# +# Non-HA users: open http://glasscalibur-XXXXXX.local in a browser to +# control the device (replace XXXXXX with the MAC suffix from the boot log). +# +# ----------------------------------------------------------------------------- +# Home Assistant pairing note +# ----------------------------------------------------------------------------- +# The native API runs without encryption by default. Home Assistant will +# show a warning when you first add the device ("Communication not +# encrypted"). The device works normally despite this warning. To silence +# it, generate a key and add it to secrets.yaml, then uncomment the +# encryption block in the `api:` section below: +# +# api: +# encryption: +# key: !secret api_key # 32-byte base64 string +# +# Generate a key with: +# python3 -c "import secrets,base64; print(base64.b64encode(secrets.token_bytes(32)).decode())" +# ============================================================================= + +# External components. +# - slimcdk: TMC2209 driver + custom stepper component with on_stall trigger, +# StallGuard configuration, register-level UART access. +# - valar-systems: Valar fork of latonita's draft LIS2DH12 PR (esphome#14788) +# with four fixes — stale-sample guard, throttled loop() reads, optional +# data-ready interrupt pin, and on-chip high-pass filter support. +external_components: + - source: github://slimcdk/esphome-custom-components + components: [tmc2209_hub, tmc2209, stepper] + + - source: github://Valar-Systems/esphome-lis2dh12 + components: [lis2dh12_base, lis2dh12_i2c] + +globals: + # The tuning globals (global_speed, global_acceleration, global_irun, + # global_sgthrs, global_tcoolthrs) now come from valar-core, and the + # sun-schedule globals (global_latitude, global_longitude, global_sun_*) from + # the scheduling mixin — both are defined there, not here. This product's + # former global_max_speed is now core's global_speed (renamed throughout). + # irun stays ≤ 19 via num_irun's lowered max_value and the on_boot clamp. + + # Cover state machine. Not persisted — every reboot starts IDLE, since the + # motor is not actually moving at that point. + # 0 = IDLE no motion in progress + # 1 = OPENING driving toward max_distance_steps + # 2 = CLOSING driving toward 0 + # 3 = TAMPERED tamper detected; cleared by "Clear Tamper Alert" or + # "Set Tamper Baseline" buttons. Tracks tamper_detected. + # Mirrored to HA via the "State" text_sensor. Transitions back to IDLE + # happen on stop_action (which the limit switches and the temperature cutoff + # both fan into) and on the stepper's on_stall handler. + - id: global_state + type: int + restore_value: no + initial_value: '0' + + # Last known stepper position in steps. Persisted so the cover entity + # survives reboots without re-homing. Saved at IDLE-transition points only + # (stop_action, on_stall, natural target arrival) to minimize flash writes — + # not in the 200 ms poll, despite Ropener doing so, since we don't need a + # per-tick checkpoint. + - id: global_current_position + type: int + restore_value: yes + initial_value: '0' + + # --- Anti-tamper baseline --- + # Captured by the "Set Tamper Baseline" button when the device is at rest in + # its mounted orientation. The interval-driven check compares live accel to + # these and flags tamper if Euclidean distance > tamper_threshold. + - id: tamper_baseline_x + type: float + restore_value: yes + initial_value: '0.0' + - id: tamper_baseline_y + type: float + restore_value: yes + initial_value: '0.0' + - id: tamper_baseline_z + type: float + restore_value: yes + initial_value: '-1.0' # accelerometer is on the bottom of the PCB facing + # down, so at rest Z reads -1g (gravity). Overwritten + # by "Set Tamper Baseline" on first calibration. + # Latched tamper state. Cleared only by the "Clear Tamper Alert" button so a + # bumped-then-replaced device doesn't silently un-flag itself. + - id: tamper_detected + type: bool + restore_value: no + initial_value: 'false' + + # --- Vibration-based stall (secondary to StallGuard) --- + # A stalled stepper shakes the device hard. While the motor is commanded to + # move, we run a leaky counter on vibration-above-threshold samples; crossing + # vib_stall_samples declares a stall. Leaky (rather than consecutive-reset) + # so a single quiet sample mid-stall doesn't restart the count from zero. + - id: vib_loud_count + type: int + restore_value: no + initial_value: '0' + + # Timestamp (millis()) at which the current motion command started. Used by + # the vib-stall check to skip its first ~500 ms after launch — initial motor + # torque can spike the accelerometer hard enough to false-trip before the + # mechanism reaches steady-state. Set in open_action / close_action / + # position_action; not persisted (only meaningful while motion is active). + - id: motion_start_ms + type: uint32_t + restore_value: no + initial_value: '0' + + # --- Lifetime diagnostic counters (persisted) --- + # Incremented each time the respective event fires. Useful for spotting a + # unit that's stalling more than its peers, or detecting tamper attempts + # that happened while the owner wasn't watching HA. Written to flash via + # NVS batching so they're not free-running flash writers. + - id: stallguard_count + type: int + restore_value: yes + initial_value: '0' + - id: vib_stall_count + type: int + restore_value: yes + initial_value: '0' + - id: tamper_count + type: int + restore_value: yes + initial_value: '0' + + # (Sun-schedule globals — latitude/longitude/offsets/last-desired — are now + # provided by the scheduling mixin.) + + # --- Auto-calibration + stall-release state (v1.3.0) --- + # Live calibration state (not persisted — a reboot mid-cal starts clean). + - id: cal_active + type: bool + restore_value: no + initial_value: 'false' + - id: cal_sg_min + type: int + restore_value: no + initial_value: '510' + - id: cal_vib_max + type: float + restore_value: no + initial_value: '0.0' + - id: cal_irun_index + type: int + restore_value: no + initial_value: '0' + - id: cal_done + type: bool + restore_value: no + initial_value: 'false' + - id: cal_travel_ok + type: bool + restore_value: no + initial_value: 'false' + # Per-travel deadline: armed immediately before each calibration travel from + # the LIVE speed (max_distance_steps / global_speed × 1.5 + 10 s). A + # fixed timeout cannot work here — travel time ranges from ~34 s (speed + # 10000) to ~57 min (speed 100) — and the wait_until conditions below always + # exit on software target arrival anyway (open-loop stepper: the 200 ms + # completion poll drops global_state to 0), so the deadline is a backstop. + - id: cal_t0_ms + type: uint32_t + restore_value: no + initial_value: '0' + - id: cal_budget_ms + type: uint32_t + restore_value: no + initial_value: '0' + # The user's Vibration Threshold as it was when calibration started, so a + # failed/aborted run can put it back after probing with the raised + # ${cal_vib_backstop}. Volatile: if the device reboots mid-calibration the + # raised backstop persists in the number entity — still a working (just + # laxer) detector; recalibrate to retune. + - id: cal_vib_prev + type: float + restore_value: no + initial_value: '0.30' + # Escalation handshake between the 100 ms calibration sampler and cal_leg: + # sustained SG_RESULT below ${cal_sg_floor} (torque margin starving) sets + # cal_escalate so the leg bumps to the next current BEFORE a violent stall + # develops. Reset at the start of every attempt. + - id: cal_escalate + type: bool + restore_value: no + initial_value: 'false' + - id: cal_sg_low_count + type: int + restore_value: no + initial_value: '0' + # Largest (= slowest) TSTEP seen at cruise during the validation leg. + # Feeds TCOOLTHRS = 1.5 × cruise TSTEP on success: the TMC2209 only + # asserts stalls while TSTEP < TCOOLTHRS, and the shipped default (100) + # sat BELOW the ~150 cruise TSTEP at speed 2500 — StallGuard was + # velocity-gated inert in normal operation until calibration derives + # this properly. + - id: cal_tstep_max + type: int + restore_value: no + initial_value: '0' + + # Which calibration phase is running, so the status text can tell a + # bystander that the homing close is deliberate (field 2026-07-10: a user + # watching the window close right after starting calibration read it as a + # malfunction and hit the abort button). 0 = none/generic, 1 = homing + # close, 2 = open leg, 3 = close leg. Display-only; gated on cal_active. + - id: cal_phase + type: int + restore_value: no + initial_value: '0' + + # Persisted results (survive reboot, feed the "Calibration Status" sensor). + # cal_result: 0 = never run, 1 = OK, 2 = OK w/ sticky-window warning, + # 3 = FAILED (travel never reached both limits), 4 = thermal + # abort (motor exceeded 55 °C mid-run — see cal_thermal_guard), + # 5 = aborted by user (Button 1) + - id: cal_result + type: int + restore_value: yes + initial_value: '0' + - id: cal_irun_chosen + type: int + restore_value: yes + initial_value: '0' + - id: cal_sgthrs_chosen + type: int + restore_value: yes + initial_value: '0' + - id: cal_sgmin_seen + type: int + restore_value: yes + initial_value: '0' + - id: cal_vibmax_x100 + type: int + restore_value: yes + initial_value: '0' + - id: cal_speed_at + type: int + restore_value: yes + initial_value: '0' + + # Stall auto-release handshake. One back-off per stall event: set when a + # release starts, cleared by stall_release_clear 5 s later. A second stall + # while the flag is set just stops (jammed solid — don't oscillate). + - id: stall_release_active + type: bool + restore_value: no + initial_value: 'false' + +# ----------------------------------------------------------------------------- +# Product-layer overrides of shared infrastructure +# ----------------------------------------------------------------------------- +# Substitutions (mechanical + OTA identity) moved to the board wrapper. api, ota, +# http_request, safe_mode, wifi, captive_portal, improv_serial, time and sun all +# come from valar-core / the scheduling mixin. Only the product-specific slivers +# of logger and web_server remain here; package merge folds them into core's. + +# Add the LIS2DH12 i2c-poll log silencing to core's logger. Package merge folds +# this logs map into core's (core owns the console uart + base level). +logger: + logs: + # Silence the LIS2DH12 status-poll chatter (~50 i2c.idf lines per sample). + i2c.idf: INFO + lis2dh12: INFO + +# Web UI groups. Control / Schedule / Diagnostics come from core + the scheduling +# mixin. Everything else is defined here so the page reads top-to-bottom as: +# Control, Motion Tuning, Calibration, Stall Detection, Schedule, Security, +# Diagnostics, StallGuard (advanced). +# +# A product can't re-weight a core group (redefining its id errors), so — as on +# the Ropener — we define NEW groups and move the relevant entities into them, +# leaving core's group_motion / group_stallguard / group_setup empty (empty groups +# don't render). Motion Tuning sits above Schedule (25); the accelerometer-based +# Stall Detection is its own first-class group (it is NOT a StallGuard feature), +# while the TMC2209 StallGuard registers drop to an advanced group at the bottom. +web_server: + sorting_groups: + - { id: group_tuning, name: "Motion Tuning", sorting_weight: 12 } + - { id: group_cal, name: "Calibration", sorting_weight: 22 } + - { id: group_stall, name: "Stall Detection", sorting_weight: 24 } + - { id: group_security, name: "Security / Tamper", sorting_weight: 45 } + - { id: group_sg_adv, name: "StallGuard (advanced)", sorting_weight: 70 } + +# ----------------------------------------------------------------------------- +# Device identity + boot-time motor configuration +# ----------------------------------------------------------------------------- +# on_boot: +# 1. Restore last saved position so HA cover state is accurate. +# 2. Override with limit-switch ground truth if either is currently pressed. +# 3. Apply persisted speed / acceleration to the stepper. +# 4. Configure TMC2209: direction, microsteps, TCOOLTHRS, StallGuard, currents. +# 5. Re-apply the user's saved timezone (restore doesn't re-fire set_action). +esphome: + # name / friendly_name / project come from the board wrapper and core. The + # motor configure/stallguard/currents, speed/accel, and firmware-version publish + # are all done by core's priority-600 on_boot (direction = COUNTERCLOCKWISE via + # the board's motor_direction_default). Timezone / lat-long re-apply is done by + # the scheduling mixin's priority -100 on_boot. This product adds only what core + # can't know about: the current clamp and limit-switch homing. + on_boot: + # BEFORE core configures the driver (core boots at priority 600): clamp the + # restored motor current. Units upgraded from firmware that allowed irun up to + # 31 restore the old value from flash; core's currents would otherwise push it + # straight to the driver. 19 = rated 0.6 A; the motor's 60 °C case limit leaves + # no headroom above that. num_irun's lowered max_value caps new entries too. + - priority: 650 + then: + - lambda: 'if (id(global_irun) > 19) id(global_irun) = 19;' + # AFTER core has configured the driver and applied speed/accel (priority 600): + # establish the true position. Restore the last saved position, then let a + # currently-pressed limit switch override it with mechanical ground truth. + # (Position counter vs. driver registers are independent, so running this + # after core's configure is equivalent to the pre-rewire single-trigger boot.) + - priority: 500 + then: + - stepper.report_position: + id: driver + position: !lambda "return id(global_current_position);" + - stepper.set_target: + id: driver + target: !lambda "return id(global_current_position);" + - logger.log: "CHECKING LIMIT SWITCHES" + - if: + condition: + binary_sensor.is_on: limit_switch_1 + then: + - logger.log: "SETTING LIMIT 1" + - stepper.report_position: + id: driver + position: ${max_distance_steps} + - stepper.set_target: + id: driver + target: ${max_distance_steps} + - if: + condition: + binary_sensor.is_on: limit_switch_2 + then: + - logger.log: "SETTING LIMIT 2" + - stepper.report_position: + id: driver + position: 0 + - stepper.set_target: + id: driver + target: 0 + +# ----------------------------------------------------------------------------- +# Buzzer (passive piezo on GPIO5, driven by ledc PWM) +# ----------------------------------------------------------------------------- +# Used by the tamper_alarm script. Frequency and duty are set per-pulse from +# the script (2700 Hz / 50 % gives a clear, audible square wave). +output: + - platform: ledc + pin: GPIO5 + id: buzzer + +# ----------------------------------------------------------------------------- +# Stepper driver — product on_stall override +# ----------------------------------------------------------------------------- +# The driver (id: driver), its UART, and all hardware params (rsense 220 mOhm, +# vsense off, deceleration inf, config_dump off, enn/index/diag pins) come from +# core, fed by the board wrapper's stepper_* substitutions. Only the stall +# response is product-specific. Replace core's default on_stall: because package +# !extend APPENDS to action lists (it does not replace), first !remove core's +# handler — otherwise core's stop + report_position(0) would still run and zero +# the position, which limit-switch homing (not stalls) owns here — then re-add +# the Glasscalibur handler. +stepper: + - id: !extend driver + on_stall: !remove + - id: !extend driver + on_stall: + # Software gate on the "StallGuard Enabled" switch — the chip itself + # always flags stalls; we only react when the user has opted in. When + # the switch is off, the trigger fires harmlessly with no effect. + # (During auto-calibration the switch is off, so calibration probes + # never enter this handler — the script does its own recovery.) + - if: + condition: + lambda: 'return id(stallguard_enabled).state;' + then: + - stepper.stop: driver + - lambda: 'id(stallguard_count) += 1;' + - logger.log: "MOTOR STALLED - backing off" + # Stall auto-release: the leadscrew is not backdrivable, so a + # stall against an obstruction HOLDS whatever clamp force it + # reached until the motor reverses. Back off ~8 mm so "stops + # while still clamping" becomes genuine auto-reverse. Must run + # BEFORE global_state is zeroed below — the stall direction is + # what decides which way to back off. One release per event: + # a second stall while stall_release_active is set (jammed + # solid) just stops. + - if: + condition: + lambda: 'return !id(stall_release_active);' + then: + - globals.set: + id: stall_release_active + value: 'true' + - lambda: |- + int32_t pos = id(driver)->current_position; + int32_t rel = ${stall_release_steps}; + // Stalled while CLOSING (state 2): back off toward open. + // Stalled while OPENING: back off toward close. + int32_t tgt = (id(global_state) == 2) ? pos + rel : pos - rel; + if (tgt < 0) tgt = 0; + id(driver)->set_target(tgt); + - script.execute: stall_release_clear + - globals.set: + id: global_state + value: "0" + - cover.template.publish: + id: glasscalibur_cover + current_operation: IDLE + - globals.set: + id: global_current_position + value: !lambda "return id(driver)->current_position;" + +# ----------------------------------------------------------------------------- +# Physical buttons + end-stop limit switches +# ----------------------------------------------------------------------------- +# Button 1 (GPIO23): single click cycles between dismiss-alarm / stop-motor +# / close-cover based on global_state; double click opens the cover. +# Limit switches (GPIO15 open, GPIO3 close): when triggered, report the known +# absolute position to the stepper (correcting drift), stop the cover, and +# inherit IDLE-state cleanup through stop_action. Presses are direction- +# gated: a switch acts only when travel is toward it (or idle) — a press +# while moving AWAY is a mechanical blip and is logged then ignored, never +# allowed to stop motion or rewrite position. Both are polled rather +# than interrupt-driven because polling proved more reliable in testing. +# WiFi Reset (GPIO21): long press wipes saved credentials and reboots into +# captive-portal setup mode. +# Tamper / accel-interrupt sensors live at the end of this block. +binary_sensor: + # Core defines btn1/btn2/btn3 as bare inputs. Glasscalibur has only two + # buttons, so drop core's phantom Button 2 and its Wi-Fi button — this product + # keeps its own "WiFi Reset" entity (btn_wifi_reset, below). btn1 keeps core's + # pin/name/filters (identical: GPIO ${pin_btn1}, INPUT, inverted, delayed_on + # 10ms) and just gains the product's click behaviour. + - id: !remove btn2 + - id: !remove btn3 + - id: !extend btn1 + web_server: + sorting_weight: 3 + sorting_group_id: group_control + on_multi_click: + + # --- Single Click --- + # CALIBRATING -> abort the calibration sequence. This check + # deliberately PRECEDES Child Lock: stopping + # an autonomous multi-travel sequence is a + # safety control and must never be lockable. + # TAMPERED -> silence the alarm + return to IDLE. + # OPENING or CLOSING -> stop (stop_action sets state to IDLE). + # Otherwise (IDLE) -> close (close_action sets state to CLOSING). + - timing: + - ON for at most 1.0s + - OFF for at least 0.5s + then: + - if: + condition: + lambda: 'return id(cal_active);' + then: + - script.stop: auto_calibrate + - script.stop: cal_leg + - cover.stop: glasscalibur_cover + - switch.turn_on: stallguard_enabled + - switch.turn_on: vib_enabled + # Put back the vibration threshold the user had before the + # script raised it to the calibration backstop. + - number.set: + id: vib_threshold + value: !lambda 'return id(cal_vib_prev);' + - globals.set: + id: cal_active + value: 'false' + - globals.set: + id: cal_result + value: '5' + # The aborted probe may have left a mid-search candidate + # (e.g. 12) in the driver — restore the rated current. + - globals.set: + id: global_irun + value: '19' + - tmc2209.currents: + irun: !lambda 'return id(global_irun);' + - logger.log: "CAL: aborted by user" + - script.execute: cal_beep_fail + - component.update: cal_status + # Swallow the click — don't fall through to stop/close. + else: + - if: + condition: + lambda: 'return id(child_lock).state;' + then: + - logger.log: "Child lock on — single click ignored" + else: + - if: + condition: + lambda: 'return id(global_state) == 3;' + then: + - script.stop: tamper_alarm + - output.turn_off: buzzer + - globals.set: + id: tamper_detected + value: "false" + - globals.set: + id: global_state + value: "0" + - logger.log: "Tamper alarm dismissed via button" + else: + - if: + condition: + lambda: 'return id(global_state) == 1 || id(global_state) == 2;' + then: + - cover.stop: glasscalibur_cover + else: + - cover.close: glasscalibur_cover + # - if: + # condition: ## If moving, then stop + # lambda: return (id(glasscalibur_cover).current_operation != COVER_OPERATION_IDLE); # Should evaluate to TRUE if moving + # then: + # - cover.stop: glasscalibur_cover + # else: ## If not moving, then close + # - cover.close: glasscalibur_cover + + # - output.turn_on: buzzer + # - output.ledc.set_frequency: + # id: buzzer + # frequency: "2700Hz" + # - output.set_level: + # id: buzzer + # level: "50%" + # - delay: 1s + # - output.turn_off: buzzer + + # --- Double Click --- + # Ignored while calibrating: launching a new motion mid-sequence would + # corrupt the probe in progress (single-click aborts instead). + - timing: + - ON for at most 1.0s + - OFF for at most 0.3s + - ON for at most 1.0s + - OFF for at least 0.5s + then: + - if: + condition: + lambda: 'return id(cal_active);' + then: + - logger.log: "Calibrating — double click ignored (single click aborts)" + else: + - if: + condition: + lambda: 'return id(child_lock).state;' + then: + - logger.log: "Child lock on — double click ignored" + else: + - cover.open: glasscalibur_cover + + # - output.turn_on: buzzer + # - output.ledc.set_frequency: + # id: buzzer + # frequency: "2700Hz" + # - output.set_level: + # id: buzzer + # level: "50%" + # - delay: 1s + # - output.turn_off: buzzer + + - platform: gpio + name: Open Limit Switch + id: limit_switch_1 + web_server: + sorting_weight: 4 + sorting_group_id: group_diagnostics + pin: + number: GPIO15 + mode: INPUT + inverted: true + # Interrupts are not reliable, they constatnly miss. Use polling + use_interrupt: false + #interrupt_type: FALLING + filters: + - delayed_on: 10ms + # Debounce the RELEASE edge too: calibration re-reads the switch + # right after its wait exits, and a micro-bounce (seen on the bench + # with the tube loose) read "released" 9 ms after the press and + # false-failed homing. + - delayed_off: 30ms + on_press: + then: + # Direction-gated: act only when travel is toward this switch + # (opening) or idle (sash hand-moved onto the stop -> re-sync). + # While CLOSING away from the fully-open stop, drive-force + # build-up can rack the sash and re-flick this switch for + # ~150 ms (field 2026-07-10: three calibration close legs in a + # row killed ~1 s in, each blip also re-claiming position as + # fully open mid-close). A 150 ms contact is real, so debounce + # can't filter it; ignoring wrong-direction presses can. + - if: + condition: + lambda: 'return id(global_state) != 2;' + then: + - stepper.report_position: + id: driver + position: ${max_distance_steps} + - stepper.set_target: + id: driver + target: ${max_distance_steps} + - cover.stop: glasscalibur_cover + - logger.log: "LIMIT 1 PRESSED" + else: + - logger.log: "LIMIT 1 blip while closing - ignored" + + + - platform: gpio + name: Close Limit Switch + id: limit_switch_2 + web_server: + sorting_weight: 5 + sorting_group_id: group_diagnostics + pin: + number: GPIO3 + mode: INPUT + inverted: true + # Interrupts are not reliable, they constatnly miss. Use polling + use_interrupt: false + #interrupt_type: FALLING + # Optional: filters to prevent noise from triggering the sensor + filters: + - delayed_on: 10ms + # See the open-limit note: release-edge debounce for the calibration + # homing re-check. + - delayed_off: 30ms + on_press: + then: + # Direction-gated like the open switch: ignore presses while + # OPENING away from the closed stop (see limit_switch_1 note). + - if: + condition: + lambda: 'return id(global_state) != 1;' + then: + - stepper.report_position: + id: driver + position: 0 + - stepper.set_target: + id: driver + target: 0 + - cover.stop: glasscalibur_cover + - logger.log: "LIMIT 2 PRESSED" + else: + - logger.log: "LIMIT 2 blip while opening - ignored" + + # Calibration / WiFi Reset (GPIO21) — two gestures, non-overlapping timings: + # * Double-click (presses ≤ 0.6 s each) -> start auto-calibration. The + # script announces itself (3 beeps + 2 s) before any motion and refuses + # if the device is busy, tampered, or the motor is hot. + # * Hold 3-10 s -> erase saved Wi-Fi credentials and reboot into setup + # mode. A long hold is required (not a quick tap) so the device can't + # be knocked offline by an accidental press. On reboot the device + # re-runs Wi-Fi setup: with no credentials saved (and none compiled + # into the firmware), it broadcasts the "glasscalibur-XXXXXX" hotspot / + # captive portal so a new network can be entered (Improv over USB + # serial works too). + # A double-click press can never satisfy the 3 s minimum hold, so the two + # gestures cannot both fire from one input. + - platform: gpio + name: WiFi Reset + id: btn_wifi_reset + web_server: + sorting_weight: 6 + sorting_group_id: group_diagnostics + pin: + number: GPIO21 + mode: INPUT + inverted: true + filters: + - delayed_on: 10ms + on_multi_click: + - timing: + - ON for at most 0.6s + - OFF for at most 0.4s + - ON for at most 0.6s + - OFF for at least 0.3s + then: + - logger.log: "CAL: requested via calibration button double-click" + - script.execute: auto_calibrate + on_click: + - min_length: 3000ms + max_length: 10000ms + then: + - logger.log: "Clearing saved Wi-Fi credentials and rebooting into setup" + - lambda: |- + // Overwrite the persisted credentials in flash with empty values, + // then drop the in-memory copy so nothing is re-saved on teardown. + esphome::wifi::global_wifi_component->save_wifi_sta("", ""); + esphome::wifi::global_wifi_component->clear_sta(); + - delay: 1s # let the log flush before we restart + - lambda: "App.safe_reboot();" + + # Anti-tamper status. Set by the 1 s tamper-check interval; latched until + # the "Clear Tamper Alert" button is pressed. + - platform: template + name: "Tamper Detected" + id: tamper_sensor + entity_category: DIAGNOSTIC + web_server: + sorting_weight: 5 + sorting_group_id: group_security + lambda: 'return id(tamper_detected);' + +# ----------------------------------------------------------------------------- +# The cover entity (Home Assistant) +# ----------------------------------------------------------------------------- +# Template cover backed by the TMC2209 stepper. Position is reported live as a +# 0..1 ratio of current_position / max_distance_steps. +# - open_action : drive to the configured travel limit (position 1.0) +# - close_action : drive to step 0 (position 0.0) +# - position_action : drive to an arbitrary fraction set from HA — direction +# is inferred by comparing the new target to current_position +# - stop_action : halt + flip state to IDLE + persist position +# +# Each motion action also updates global_state so the "State" text_sensor and +# automations follow along. The motion-completion poll (interval block at the +# bottom of this file) flips current_operation back to IDLE once the stepper +# reaches its target. +cover: + - platform: template + id: glasscalibur_cover + name: Glasscalibur + web_server: + sorting_weight: 1 + sorting_group_id: group_control + has_position: true + lambda: "return (float(id(driver)->current_position) / float(${max_distance_steps}));" + + stop_action: + - logger.log: "COVER stop_action" + - stepper.stop: driver + - globals.set: + id: global_state + value: "0" + - cover.template.publish: + id: glasscalibur_cover + current_operation: IDLE + - globals.set: + id: global_current_position + value: !lambda "return id(driver)->current_position;" + + open_action: + - logger.log: "COVER open_action" + - lambda: 'id(motion_start_ms) = millis();' + - globals.set: + id: global_state + value: "1" + - stepper.set_target: + id: driver + target: ${max_distance_steps} + - cover.template.publish: + id: glasscalibur_cover + current_operation: OPENING + + close_action: + - logger.log: "COVER close_action" + - lambda: 'id(motion_start_ms) = millis();' + - globals.set: + id: global_state + value: "2" + - stepper.set_target: + id: driver + target: 0 + - cover.template.publish: + id: glasscalibur_cover + current_operation: CLOSING + + position_action: + - logger.log: "COVER position_action" + - lambda: 'id(motion_start_ms) = millis();' + # Direction is inferred by comparing the new target to current_position. + - if: + condition: + lambda: 'return (pos * ${max_distance_steps}) >= id(driver)->current_position;' + then: + - globals.set: + id: global_state + value: "1" + - cover.template.publish: + id: glasscalibur_cover + current_operation: OPENING + else: + - globals.set: + id: global_state + value: "2" + - cover.template.publish: + id: glasscalibur_cover + current_operation: CLOSING + - stepper.set_target: + id: driver + target: !lambda return pos * ${max_distance_steps}; + + # NOTE: on_opening / on_opened / on_closing / on_closed used to be wired + # here, each re-publishing the same current_operation they fired on. That + # made the trigger recurse into itself indefinitely and stack-overflow + # within milliseconds of a cover.open / cover.close. The open_action / + # close_action / position_action / stop_action above already publish + # OPENING / CLOSING / IDLE, and the 200 ms motion-completion poll flips + # back to IDLE on target arrival, so these handlers are not needed at all. + +# ----------------------------------------------------------------------------- +# Tunable number entities +# ----------------------------------------------------------------------------- +# Each entity below is bound to a global (or directly to a chip register) and +# pushes its value into the driver / system when the user changes it from the +# web UI or Home Assistant. The `lambda` reads the value back live (every +# update_interval) so the UI reflects the actual state, not just the last +# value we wrote. +number: + # Speed, Acceleration, TCOOLTHRS and SGTHRS come from core; Latitude/Longitude + # and the sun offsets from the scheduling mixin. This product retargets those + # 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. + - id: !extend num_speed + web_server: { sorting_weight: 1, sorting_group_id: group_tuning } + - id: !extend num_accel + web_server: { sorting_weight: 2, sorting_group_id: group_tuning } + + # Friendly motor current in milliamps (Motion Tuning). The driver knows this + # board's Rsense (220 mOhm) and Vsense, so read_run_current_mA / write_run_current_mA + # do the register conversion — no formula here. IRUN 19 (this motor's thermal + # ceiling, rated 0.6 A) is ~600 mA on this board, so max_value caps there: the + # motor's 60 °C case limit leaves no headroom above it. Values snap to the + # nearest of the 32 register steps (~30 mA). Keeps global_irun (persisted, + # reapplied on boot) in sync with the register the driver just derived. + - platform: template + name: "Motor Current" + id: num_motor_current + unit_of_measurement: "mA" + icon: "mdi:current-dc" + web_server: { sorting_weight: 3, sorting_group_id: group_tuning } + min_value: 100 + max_value: 600 + step: 10 + mode: BOX + entity_category: CONFIG + lambda: "return id(driver)->read_run_current_mA();" + update_interval: 5s + set_action: + - lambda: "id(driver)->write_run_current_mA((uint16_t) x);" + - globals.set: + id: global_irun + value: !lambda "return id(driver)->read_field(IRUN_FIELD);" + + # Raw IRUN register (1-19) is meaningless to most users — the friendly Motor + # Current above drives it in mA. Keep the raw register in the advanced group for + # power users, still capped at 19. + - id: !extend num_irun + name: "IRUN value" + max_value: 19 + web_server: + sorting_weight: 6 + sorting_group_id: group_sg_adv + + # TCOOLTHRS + SGTHRS are TMC2209 StallGuard registers -> the advanced group at + # the bottom of the page (they are tuning knobs for StallGuard, not everyday + # controls). Core defines them in its group_stallguard, which is left empty. + - id: !extend num_tcoolthrs + web_server: { sorting_weight: 3, sorting_group_id: group_sg_adv } + - id: !extend num_sgthrs + web_server: { sorting_weight: 2, sorting_group_id: group_sg_adv } + + +# --- Anti-tamper threshold (g) --- +# Euclidean distance between current accel and baseline that triggers a +# tamper alert. 0.30g ≈ 17° tilt or a moderate bump. Tune downward if you +# want more sensitivity, upward if you see false positives. + - platform: template + name: "Tamper Threshold" + id: tamper_threshold + web_server: + sorting_weight: 2 + sorting_group_id: group_security + step: 0.05 + min_value: 0.05 + max_value: 2.0 + mode: BOX + entity_category: CONFIG + unit_of_measurement: "g" + optimistic: true + restore_value: true + initial_value: 0.30 + +# --- Vibration stall threshold (g) --- +# |magnitude - 1g| above which a sample is counted as "loud". A stalled stepper +# shakes the device hard (well above smooth running). Tune by watching the +# accel sensors during a normal move vs. a deliberately stalled one and pick +# a value comfortably between the two. + - platform: template + name: "Vibration Threshold" + id: vib_threshold + web_server: + sorting_weight: 2 + sorting_group_id: group_stall + step: 0.05 + min_value: 0.05 + max_value: 4.0 + mode: BOX + entity_category: CONFIG + unit_of_measurement: "g" + optimistic: true + restore_value: true + initial_value: 0.30 + +# --- Vibration stall debounce (distinct accelerometer samples) --- +# Number of net "loud" samples before declaring a stall. The detector counts +# each fresh LIS2DH12 reading once (the sensor publishes every 500 ms), so +# 5 samples ≈ 2.5 s of sustained strong vibration. A single jolt or one noisy +# bump doesn't trip; sustained motor judder does. + - platform: template + name: "Vibration Stall Samples" + id: vib_stall_samples + web_server: + sorting_weight: 3 + sorting_group_id: group_stall + step: 1 + min_value: 2 + max_value: 60 + mode: BOX + entity_category: CONFIG + optimistic: true + restore_value: true + initial_value: 5 + + # (Latitude / Longitude / Sun Open Offset / Sun Close Offset come from the + # scheduling mixin.) + +# ----------------------------------------------------------------------------- +# Child lock +# ----------------------------------------------------------------------------- +# When ON, the physical Button 1 (single + double click) is ignored so the +# cover can't be opened, closed, or stopped from the on-device button — e.g. to +# keep a child from operating it. Remote control is intentionally unaffected: +# the web UI, Home Assistant, and the open/close schedule all keep working, so +# you can still operate the cover and toggle this lock from your phone. The +# WiFi-reset button (GPIO21) is also left active so the device can always be +# recovered. Persists across reboots; default OFF so a fresh unit is never +# locked. +switch: + - platform: template + name: "Child Lock" + id: child_lock + entity_category: CONFIG + web_server: + sorting_weight: 4 + sorting_group_id: group_control + optimistic: true + restore_mode: RESTORE_DEFAULT_OFF + turn_on_action: + - logger.log: "Child lock enabled — physical button disabled" + turn_off_action: + - logger.log: "Child lock disabled — physical button active" + + # (Schedule Enabled + Sun Schedule switches come from the scheduling mixin.) + + # Master enable for tamper detection. When OFF the 1 s tamper-check interval + # is skipped entirely. Turning OFF also silences any active alarm and clears + # the TAMPERED state so the device doesn't keep beeping with detection + # "disabled". Turning ON auto-captures the current accel as the new baseline + # so the first check after enabling can't false-trip against a stale baseline + # taken from a different mounting position. Default OFF — user opts in. + - platform: template + name: "Tamper Detection" + id: tamper_enabled + entity_category: CONFIG + web_server: + sorting_weight: 1 + sorting_group_id: group_security + optimistic: true + restore_mode: RESTORE_DEFAULT_OFF + turn_on_action: + # Baselines are stored in g (sensor states are m/s² — ESPHome + # convention) so they compare directly against Tamper Threshold. + - globals.set: + id: tamper_baseline_x + value: !lambda "return id(accel_x).state / 9.80665f;" + - globals.set: + id: tamper_baseline_y + value: !lambda "return id(accel_y).state / 9.80665f;" + - globals.set: + id: tamper_baseline_z + value: !lambda "return id(accel_z).state / 9.80665f;" + - globals.set: + id: tamper_detected + value: "false" + - logger.log: + format: "Tamper detection enabled — baseline captured: x=%.3fg y=%.3fg z=%.3fg" + args: ['id(accel_x).state / 9.80665f', 'id(accel_y).state / 9.80665f', 'id(accel_z).state / 9.80665f'] + turn_off_action: + - script.stop: tamper_alarm + - output.turn_off: buzzer + - globals.set: + id: tamper_detected + value: "false" + - if: + condition: + lambda: 'return id(global_state) == 3;' + then: + - globals.set: + id: global_state + value: "0" + - logger.log: "Tamper detection disabled" + + # Master enable for the accelerometer-based vibration stall check. When OFF + # the 200 ms vibration interval short-circuits — only TMC2209 StallGuard + # remains as a stall safeguard. Defaults OFF because the thresholds need + # tuning per installation and a misconfigured detector trips spurious stops. + # Turning OFF resets the loud-sample counter so a future re-enable starts + # fresh. + - platform: template + name: "Vibration Stall Detection" + id: vib_enabled + entity_category: CONFIG + web_server: + sorting_weight: 1 + sorting_group_id: group_stall + optimistic: true + restore_mode: RESTORE_DEFAULT_OFF + turn_off_action: + - globals.set: + id: vib_loud_count + value: "0" + - logger.log: "Vibration stall detection disabled" + + # Master enable for the TMC2209 StallGuard *handler*. The chip's SGTHRS is + # always set to the user's chosen value (via the SGTHRS number entity), so + # the chip detects stalls regardless of this switch. The on_stall handler + # on the stepper is what's gated by this switch — when OFF, stall events + # fire but are ignored, so a misconfigured threshold can't spuriously stop + # the motor. Defaults OFF because SGTHRS needs per-installation tuning + # before stall response can be trusted. + - platform: template + name: "StallGuard Enabled" + id: stallguard_enabled + entity_category: CONFIG + web_server: + sorting_weight: 1 + sorting_group_id: group_sg_adv + optimistic: true + restore_mode: RESTORE_DEFAULT_OFF + turn_on_action: + - logger.log: "StallGuard handler enabled" + turn_off_action: + - logger.log: "StallGuard handler 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: + - id: !remove sel_direction + +# ----------------------------------------------------------------------------- +# Anti-tamper calibration / acknowledgement +# ----------------------------------------------------------------------------- +# Press "Set Tamper Baseline" once the device is mounted in its final position +# and at rest — this captures the current X/Y/Z reading as the "untampered" +# reference. Any subsequent orientation change beyond Tamper Threshold (g) +# while IDLE flags Tamper Detected. The alert is latched so a bumped-then- +# replaced device doesn't silently un-flag; press "Clear Tamper Alert" to +# acknowledge. +button: + - platform: template + name: "Set Tamper Baseline" + entity_category: CONFIG + web_server: + sorting_weight: 3 + sorting_group_id: group_security + on_press: + # Stored in g — see the note on the Tamper Detection switch. + - globals.set: + id: tamper_baseline_x + value: !lambda "return id(accel_x).state / 9.80665f;" + - globals.set: + id: tamper_baseline_y + value: !lambda "return id(accel_y).state / 9.80665f;" + - globals.set: + id: tamper_baseline_z + value: !lambda "return id(accel_z).state / 9.80665f;" + - script.stop: tamper_alarm + - output.turn_off: buzzer + - globals.set: + id: tamper_detected + value: "false" + - if: + condition: + lambda: 'return id(global_state) == 3;' + then: + - globals.set: + id: global_state + value: "0" + - logger.log: + format: "Tamper baseline captured: x=%.3fg y=%.3fg z=%.3fg" + args: ['id(accel_x).state / 9.80665f', 'id(accel_y).state / 9.80665f', 'id(accel_z).state / 9.80665f'] + + - platform: template + name: "Clear Tamper Alert" + entity_category: CONFIG + web_server: + sorting_weight: 4 + sorting_group_id: group_security + on_press: + - script.stop: tamper_alarm + - output.turn_off: buzzer + - globals.set: + id: tamper_detected + value: "false" + - if: + condition: + lambda: 'return id(global_state) == 3;' + then: + - globals.set: + id: global_state + value: "0" + - logger.log: "Tamper alert cleared" + + # --- Lifetime counter resets --- + - platform: template + name: "Reset StallGuard Trips" + entity_category: DIAGNOSTIC + web_server: + sorting_weight: 14 + sorting_group_id: group_diagnostics + on_press: + - globals.set: + id: stallguard_count + value: "0" + - logger.log: "StallGuard trip counter reset" + + - platform: template + name: "Reset Vibration Stall Trips" + entity_category: DIAGNOSTIC + web_server: + sorting_weight: 15 + sorting_group_id: group_diagnostics + on_press: + - globals.set: + id: vib_stall_count + value: "0" + - logger.log: "Vibration stall trip counter reset" + + - platform: template + name: "Reset Tamper Trips" + entity_category: DIAGNOSTIC + web_server: + sorting_weight: 16 + sorting_group_id: group_diagnostics + on_press: + - globals.set: + id: tamper_count + value: "0" + - logger.log: "Tamper trip counter reset" + + # ("Update Firmware (GitHub)" and "Restart" come from core — core's GitHub-OTA + # button already targets ${ota_repo}/${ota_asset} = Valar-Systems/Glasscalibur, + # so the field OTA URL is byte-for-byte identical to what shipped.) + + # Start auto-calibration from the web UI / Home Assistant. Same entry point + # as the GPIO21 double-click; all preconditions live in the script. + - platform: template + name: "Auto-Calibrate" + entity_category: CONFIG + web_server: + sorting_weight: 1 + sorting_group_id: group_cal + on_press: + - script.execute: auto_calibrate + +# (Open Time / Close Time datetime pickers and the Timezone text entity come from +# the scheduling mixin. The mixin's fixed-time and sun triggers call this product's +# schedule_open / schedule_close scripts, which carry the !cal_active guard — see +# the script block below.) + +# ----------------------------------------------------------------------------- +# I2C bus (shared by TMP1075 temperature sensor + LIS2DH12 accelerometer) +# ----------------------------------------------------------------------------- +# scan: true prints all detected device addresses on boot — useful if the +# LIS2DH12 ends up at 0x18 instead of 0x19 on a particular board, or if the +# accel block fails to initialize. +i2c: + sda: GPIO19 + scl: GPIO20 + scan: true + id: i2c_bus + +# ----------------------------------------------------------------------------- +# LIS2DH12 accelerometer +# ----------------------------------------------------------------------------- +# 3-axis MEMS accelerometer on the PCB. Used for two things: +# 1. Anti-tamper detection (checks orientation while IDLE). +# 2. Vibration-based stall detection (backstops StallGuard). +# SA0 is grounded on this board, so the I2C address is 0x18 (would be 0x19 +# if SA0 were pulled to VDD). +lis2dh12_i2c: + id: accelerometer + i2c_id: i2c_bus + address: 0x18 + range: 4G # ±4g — plenty for orientation + motor vibration + resolution: high # 12-bit reads + output_data_rate: 100Hz # chip samples 100x/sec + # 500 ms keeps the draft PR's status-poll cost manageable (its sync DRDY + # loop is what triggers "took a long time" warnings). The 1 s tamper check + # and 200 ms vib-stall check read the cached value, so vib detection latency + # is bounded by this — ~2.5 s worst case at the default 5-sample threshold, + # acceptable for a window cover. Tighten if you want snappier stall reaction. + update_interval: 500ms + +# ----------------------------------------------------------------------------- +# Diagnostic / live sensors +# ----------------------------------------------------------------------------- +# Accelerometer x/y/z feed the anti-tamper and vibration-stall logic in the +# intervals at the bottom of the file. TSTEP / SG_RESULT are useful for tuning +# StallGuard sensitivity (drive the motor under load, watch SG_RESULT, set +# SGTHRS to about half of it). Motor temperature is hard-cut at 80 °C. +sensor: + # Core's SG_RESULT / TSTEP readouts are StallGuard-tuning diagnostics; move them + # from core's Diagnostics group down into the advanced StallGuard group with the + # SGTHRS/TCOOLTHRS registers they help tune. + - id: !extend sen_sgresult + web_server: { sorting_weight: 4, sorting_group_id: group_sg_adv } + - id: !extend sen_tstep + web_server: { sorting_weight: 5, sorting_group_id: group_sg_adv } + + - platform: lis2dh12_base + lis2dh12_id: accelerometer + acceleration_x: + name: "Accel X" + id: accel_x + entity_category: DIAGNOSTIC + web_server: + sorting_weight: 20 + sorting_group_id: group_diagnostics + acceleration_y: + name: "Accel Y" + id: accel_y + entity_category: DIAGNOSTIC + web_server: + sorting_weight: 21 + sorting_group_id: group_diagnostics + acceleration_z: + name: "Accel Z" + id: accel_z + entity_category: DIAGNOSTIC + web_server: + sorting_weight: 22 + sorting_group_id: group_diagnostics + + # (SG_RESULT and TSTEP diagnostic sensors come from core — sen_sgresult / + # sen_tstep. Scripts read the registers directly, so nothing here depends on + # the product's former "… Sensor" copies.) + + - platform: tmp1075 + name: "Motor Temperature" + id: motor_temp # referenced by the auto_calibrate temperature gate + update_interval: 3s + entity_category: DIAGNOSTIC + web_server: + sorting_weight: 7 + sorting_group_id: group_diagnostics + i2c_id: i2c_bus + address: 0x49 + accuracy_decimals: 2 + # --- ACTION ON TEMPERATURE EXAMPLE --- + on_value_range: + - above: 80 + then: + - cover.stop: glasscalibur_cover + - logger.log: "TOO HOT!" + + # USE ALERT PIN IN FUTURE instead of polling + + # (WiFi Signal, Uptime and ESP Internal Temperature come from core.) + + # --- Lifetime event counters (persisted in flash) --- + # Bump on each respective event; survive reboots. To zero them, you'd need + # to add reset buttons or temporarily flip restore_value: no and reflash. + - platform: template + name: "StallGuard Trips" + lambda: 'return id(stallguard_count);' + update_interval: 30s + entity_category: DIAGNOSTIC + accuracy_decimals: 0 + web_server: + sorting_weight: 11 + sorting_group_id: group_diagnostics + - platform: template + name: "Vibration Stall Trips" + lambda: 'return id(vib_stall_count);' + update_interval: 30s + entity_category: DIAGNOSTIC + accuracy_decimals: 0 + web_server: + sorting_weight: 12 + sorting_group_id: group_diagnostics + - platform: template + name: "Tamper Trips" + lambda: 'return id(tamper_count);' + update_interval: 30s + entity_category: DIAGNOSTIC + accuracy_decimals: 0 + web_server: + sorting_weight: 13 + sorting_group_id: group_diagnostics + +# ----------------------------------------------------------------------------- +# State exposure (HA) +# ----------------------------------------------------------------------------- +# Mirrors global_state to Home Assistant as a string. Polled rather than +# event-driven because transitions happen in many places; one poll is simpler +# than wiring publish() into every site. +# ----------------------------------------------------------------------------- +# State exposure (HA / web UI) +# ----------------------------------------------------------------------------- +# Mirrors global_state to a string. Polled at 500 ms rather than event-driven +# because state transitions happen in many places; one poll is simpler than +# wiring publish() into every transition site. +text_sensor: + - platform: template + name: "State" + id: cover_state + web_server: + sorting_weight: 2 + sorting_group_id: group_control + update_interval: 500ms + lambda: |- + switch (id(global_state)) { + case 1: return {"OPENING"}; + case 2: return {"CLOSING"}; + case 3: return {"TAMPERED"}; + default: return {"IDLE"}; + } + + # (Firmware Version, Board, and the IP/SSID/MAC wifi_info sensors come from + # core — core owns firmware_version_text and publishes it in on_boot.) + + # Calibration result, persisted across reboots. Appends STALE if Speed has + # changed since calibration — SG_RESULT is speed-dependent, so a speed + # change invalidates the StallGuard tune. + - platform: template + name: "Calibration Status" + id: cal_status + entity_category: DIAGNOSTIC + web_server: + sorting_weight: 2 + sorting_group_id: group_cal + update_interval: 30s + lambda: |- + if (id(cal_active)) { + switch (id(cal_phase)) { + case 1: return {"Calibrating 1/3: closing to home position (normal)"}; + case 2: return {"Calibrating 2/3: opening - finding minimum current"}; + case 3: return {"Calibrating 3/3: closing - verifying"}; + default: return {"Calibrating - keep the window path clear"}; + } + } + char buf[120]; + switch (id(cal_result)) { + case 1: + snprintf(buf, sizeof(buf), "OK: irun=%d SGTHRS=%d (SGmin=%d, vib=%.2fg)", + id(cal_irun_chosen), id(cal_sgthrs_chosen), + id(cal_sgmin_seen), id(cal_vibmax_x100) / 100.0f); + break; + case 2: + snprintf(buf, sizeof(buf), "OK (STICKY WINDOW): irun=%d SGTHRS=%d (SGmin=%d)", + id(cal_irun_chosen), id(cal_sgthrs_chosen), id(cal_sgmin_seen)); + break; + case 3: + return {"FAILED - window did not reach limits. Check track."}; + case 4: + return {"FAILED - motor overheated mid-run. Cool 15 min, re-run."}; + case 5: + return {"Aborted by user - run calibration again"}; + default: + return {"Not calibrated"}; + } + std::string s = buf; + if (id(cal_speed_at) != 0 && id(cal_speed_at) != id(global_speed)) { + s += " | STALE - speed changed, recalibrate"; + } + return {s}; + +# ----------------------------------------------------------------------------- +# Tamper alarm +# ----------------------------------------------------------------------------- +# Pulses the buzzer for 60 seconds when tamper is first detected. 500 ms on / +# 500 ms off at 2700 Hz — more attention-grabbing than a steady tone. Stopped +# early by a Button 1 single-press, "Clear Tamper Alert", or "Set Tamper +# Baseline". mode: restart means a fresh tamper trip during an active alarm +# restarts the 60 s timer. +script: + # Scheduling-mixin contract: the shared scheduling.yaml calls these on the + # fixed Open/Close times and on sun transitions. It has already checked + # "Schedule Enabled" (and sun mode); this product adds only its own guard — + # never drive the cover into a calibration probe in progress (a scheduled move + # mid-cal would re-target the stepper and corrupt it; it reconciles on the next + # tick after calibration ends). + - id: schedule_open + then: + - if: + condition: + lambda: 'return !id(cal_active);' + then: + - logger.log: "Schedule: opening cover" + - cover.open: glasscalibur_cover + - id: schedule_close + then: + - if: + condition: + lambda: 'return !id(cal_active);' + then: + - logger.log: "Schedule: closing cover" + - cover.close: glasscalibur_cover + + - id: tamper_alarm + mode: restart + then: + - repeat: + count: 60 + then: + - output.turn_on: buzzer + - output.ledc.set_frequency: + id: buzzer + frequency: "2700Hz" + - output.set_level: + id: buzzer + level: "50%" + - delay: 500ms + - output.turn_off: buzzer + - delay: 500ms + - output.turn_off: buzzer # belt-and-suspenders if loop exits early + + # =========================================================================== + # Auto-calibration (v1.3.0) + # =========================================================================== + # Finds the lowest reliable motor current and tunes both stall detectors to + # this specific window, using the physical limit switches as ground truth. + # + # 1. Refuse when busy, tampered, already calibrating, or the motor is hot + # (>50 °C) or its temperature is NAN — sensor absent means thermal + # protection absent; refuse, never ignore. + # 2. Announce: 3 beeps + 2 s stand-clear pause before ANY motion. + # 3. Detector posture: StallGuard REACTION off (SGTHRS is the thing being + # tuned — a stale threshold would false-abort every probe). The + # vibration detector is forced ON as the backstop — it defaults OFF + # from the factory, so without the explicit turn-on a fresh unit would + # probe with no stall protection at all — and its threshold is raised + # to at least ${cal_vib_backstop} g for the duration (the user's value + # may itself be mistuned; it is one of calibration's outputs). A truly + # stalled probe still shakes far harder than that and fails in + # seconds instead of riding out the travel deadline. + # 4. Home to the close limit at irun 19 (rated 0.6 A). The saved position + # can lie (skipped steps, stale flash), so homing first claims + # position = full-open: the close command then always covers the whole + # travel and the limit switch re-syncs position to 0 on arrival. + # 5. Single-pass search (see cal_leg): ONE open travel escalating in + # place up the ladder {12, 15, 17, 19} — on vibration stall, SG + # margin starvation, silent step loss, or deadline, bump to the next + # current and resume from where it stopped — then ONE close travel + # validating the settled current over the full distance (escalating + # further if friction is direction-dependent). Success oracle = the + # LIMIT SWITCHES, never the step counter: an open-loop stepper that + # skipped steps still "arrives" at its software target, so every + # attempt re-claims a full travel of commanded distance. Each + # attempt gets a deadline computed from the LIVE speed + # (max_distance_steps / global_speed × 1.5 + 10 s); a fixed + # timeout cannot work — a full travel is ≈228 s at the default + # 1500 steps/s and speed is user-tunable 100..10000. A spot that + # still stalls at 19 exhausts the ladder and fails the run: never + # muscle through what might be an obstruction. + # 6. Outcome: SGTHRS = SGmin/4 — trip level (2×SGTHRS) = half the worst + # legitimate reading. NEVER SGmin/2: that puts the trip level AT the + # worst legitimate load and false-stalls instantly (the original + # tuning bug). Vibration Threshold = 3 × free-run max, clamped + # [0.15, 0.45] g. + # 7. EVERY exit path (success, fail, homing bail-out, user abort in + # Button 1) re-enables BOTH detectors — ship-safe posture. + # + # Abort: Button 1 single-click (checked BEFORE Child Lock — stopping an + # autonomous multi-travel sequence is a safety control, never lockable). + # =========================================================================== + - id: auto_calibrate + mode: single + then: + # ---- 1. Preconditions ------------------------------------------------ + - if: + condition: + lambda: 'return id(global_state) != 0 || id(tamper_detected) || id(cal_active);' + then: + - logger.log: "CAL: refused - busy, tampered, or already calibrating" + - script.stop: auto_calibrate + - if: + condition: + lambda: 'return isnan(id(motor_temp).state) || id(motor_temp).state > 50.0f;' + then: + - logger.log: + level: WARN + format: "CAL: refused - motor temp %.1f degC (limit 50, NAN = sensor absent)" + args: ['id(motor_temp).state'] + - script.stop: auto_calibrate + + - logger.log: "CAL: starting - keep the window path clear" + - globals.set: + id: cal_active + value: 'true' + - globals.set: + id: cal_done + value: 'false' + - globals.set: + id: cal_irun_index + value: '0' + - globals.set: + id: cal_result + value: '0' + - globals.set: + id: cal_phase + value: '0' + - component.update: cal_status + + # ---- 2. Announce: 3 beeps + 2 s stand-clear, before any motion ------- + - repeat: + count: 3 + then: + - output.ledc.set_frequency: + id: buzzer + frequency: "2700Hz" + - output.set_level: + id: buzzer + level: "50%" + - delay: 120ms + - output.turn_off: buzzer + - delay: 120ms + - delay: 2s + + # ---- 3. Detector posture --------------------------------------------- + - switch.turn_off: stallguard_enabled + - switch.turn_on: vib_enabled + - lambda: 'id(cal_vib_prev) = id(vib_threshold).state;' + - number.set: + id: vib_threshold + value: !lambda |- + float v = id(vib_threshold).state; + float b = ${cal_vib_backstop}; + return (v > b) ? v : b; + + # ---- 4. Home to the close limit at full allowed current -------------- + - globals.set: + id: cal_phase + value: '1' + - component.update: cal_status + - globals.set: + id: global_irun + value: '19' + - tmc2209.currents: + irun: !lambda 'return id(global_irun);' + - if: + condition: + binary_sensor.is_off: limit_switch_2 + then: + - stepper.report_position: + id: driver + position: ${max_distance_steps} + - lambda: |- + int spd = id(global_speed); + if (spd < 100) spd = 100; + id(cal_budget_ms) = (uint32_t)(1000.0f * ${max_distance_steps} / (float)spd * 1.5f) + 10000; + id(cal_t0_ms) = millis(); + - cover.close: glasscalibur_cover + - wait_until: + condition: + lambda: |- + return id(limit_switch_2).state || id(global_state) == 0 || + (millis() - id(cal_t0_ms)) > id(cal_budget_ms); + - if: + condition: + binary_sensor.is_off: limit_switch_2 + then: + # Could not even home — bail out safe. + - cover.stop: glasscalibur_cover + - globals.set: + id: cal_result + value: '3' + - number.set: + id: vib_threshold + value: !lambda 'return id(cal_vib_prev);' + - switch.turn_on: stallguard_enabled + - switch.turn_on: vib_enabled + - globals.set: + id: cal_active + value: 'false' + - logger.log: + level: WARN + format: "CAL: FAILED - could not reach the close limit" + - script.execute: cal_beep_fail + - component.update: cal_status + - script.stop: auto_calibrate + + # ---- 5. Single-pass search: escalate in place up {12, 15, 17, 19} --- + # One open travel + one close travel total. The open leg starts at the + # lowest candidate and, on trouble (vibration stall, SG margin + # starvation, silent step loss, deadline), bumps to the next current + # and RESUMES from where it stopped — the limit switches remain the + # only success oracle. The close leg then validates the settled + # current over a full travel (friction can be direction-dependent), + # escalating further if it must. Replaces the 4-probe × full-travel + # search: ~2 travels instead of up to 11, which matters on an 80 g + # motor that reached 61.5 °C mid-search in field testing. + - globals.set: + id: cal_irun_index + value: '0' + - script.execute: + id: cal_leg + opening: true + - script.wait: cal_leg + - if: + condition: + lambda: 'return id(cal_travel_ok);' + then: + # cal_irun_index deliberately carries over: the close leg starts + # at the current the open leg settled on. + - script.execute: + id: cal_leg + opening: false + - script.wait: cal_leg + - lambda: |- + // cal_travel_ok now reflects the close leg (or stayed false if + // the open leg exhausted the ladder). SG starvation escalates + // DURING a leg, so completing at a sub-cap current implies + // margin by construction; only a run that needed the full 19 + // with a thin observed minimum is flagged sticky (nothing higher + // is allowed: 19 = rated 0.6 A against a 60 °C case limit). + if (id(cal_travel_ok)) { + id(cal_done) = true; + id(cal_result) = + (id(global_irun) == 19 && id(cal_sg_min) < ${cal_sg_floor}) ? 2 : 1; + } + + # ---- 6. Outcome ------------------------------------------------------- + - if: + condition: + lambda: 'return id(cal_done);' + then: + - lambda: |- + // SGTHRS = SGmin/4 -> trip level (2*SGTHRS) = half the worst + // legitimate reading. NEVER SGmin/2 (trip level AT the worst + // legitimate load = instant false stalls). Clamp to a sane + // register range. SGmin==510 means the leg somehow gathered + // no samples — keep the previous threshold rather than + // tuning from an empty bucket (belt-and-braces; leg-level + // accumulators make this near-impossible). + int sgthrs = id(global_sgthrs); + if (id(cal_sg_min) < 510) { + sgthrs = id(cal_sg_min) / 4; + if (sgthrs < 4) sgthrs = 4; + if (sgthrs > 120) sgthrs = 120; + } + id(global_sgthrs) = sgthrs; + id(cal_sgthrs_chosen) = sgthrs; + id(cal_irun_chosen) = id(global_irun); + id(cal_sgmin_seen) = id(cal_sg_min); + id(cal_vibmax_x100) = (int)(id(cal_vib_max) * 100.0f); + id(cal_speed_at) = id(global_speed); + // Arm StallGuard at the operating speed: the chip only + // asserts stalls while TSTEP < TCOOLTHRS, so derive the + // threshold from measured cruise TSTEP (×1.5 keeps the + // acceleration ramp gated off, where SG readings false- + // trip). The old fixed default (100) was BELOW cruise + // TSTEP — StallGuard never fired in normal operation. + if (id(cal_tstep_max) > 0) { + int tc = (id(cal_tstep_max) * 3) / 2; + if (tc > 1048575) tc = 1048575; + id(global_tcoolthrs) = tc; + } + - lambda: 'id(driver)->write_register(TCOOLTHRS, id(global_tcoolthrs));' + - tmc2209.stallguard: + threshold: !lambda 'return id(global_sgthrs);' + # Vibration threshold = 3x the free-run maximum seen during the + # winning probe, clamped [0.15, 0.45] g. Replaces the raised + # calibration backstop AND whatever the user had before. + - number.set: + id: vib_threshold + value: !lambda |- + float v = 3.0f * id(cal_vib_max); + if (v < 0.15f) v = 0.15f; + if (v > 0.45f) v = 0.45f; + return v; + - logger.log: + format: "CAL: done. irun=%d SGTHRS=%d SGmin=%d vib_free=%.2fg TCOOLTHRS=%d" + args: ['id(cal_irun_chosen)', 'id(cal_sgthrs_chosen)', + 'id(cal_sgmin_seen)', 'id(cal_vib_max)', 'id(global_tcoolthrs)'] + - script.execute: cal_beep_ok + else: + - globals.set: + id: cal_result + value: '3' + # Failed: put the user's vibration threshold back (a successful + # run replaces it with the tuned value instead). + - number.set: + id: vib_threshold + value: !lambda 'return id(cal_vib_prev);' + - logger.log: + level: WARN + format: "CAL: FAILED - window never reached both limits" + - script.execute: cal_beep_fail + + # ---- 7. Always: ship-safe posture ------------------------------------- + - switch.turn_on: stallguard_enabled + - switch.turn_on: vib_enabled + - globals.set: + id: cal_active + value: 'false' + - component.update: cal_status + + - id: cal_beep_ok + mode: single + then: + - output.ledc.set_frequency: + id: buzzer + frequency: "2700Hz" + - output.set_level: + id: buzzer + level: "50%" + - delay: 120ms + - output.turn_off: buzzer + - delay: 120ms + - output.set_level: + id: buzzer + level: "50%" + - delay: 120ms + - output.turn_off: buzzer + + - id: cal_beep_fail + mode: single + then: + - repeat: + count: 3 + then: + - output.ledc.set_frequency: + id: buzzer + frequency: "1400Hz" + - output.set_level: + id: buzzer + level: "50%" + - delay: 400ms + - output.turn_off: buzzer + - delay: 150ms + + # Mid-run thermal guard for auto_calibrate. The 50 °C precondition only + # runs once at start; a full search is up to ~11 travels and field testing + # showed 49 °C at start → 55.9 °C three probes in. Called before every + # calibration travel: above 55 °C (or NAN — no sensor means no protection) + # it stops the whole sequence with the same ship-safe cleanup as every + # other exit path. 55 = FW-2's derate level: 5 °C of margin under the + # motor's 60 °C datasheet case limit. + - id: cal_thermal_guard + mode: single + then: + - if: + condition: + lambda: |- + return id(cal_active) && + (isnan(id(motor_temp).state) || id(motor_temp).state > 55.0f); + then: + - logger.log: + level: WARN + format: "CAL: thermal stop - motor %.1f degC (limit 55). Cool down, then re-run." + args: ['id(motor_temp).state'] + - cover.stop: glasscalibur_cover + - globals.set: + id: cal_result + value: '4' + - number.set: + id: vib_threshold + value: !lambda 'return id(cal_vib_prev);' + - switch.turn_on: stallguard_enabled + - switch.turn_on: vib_enabled + - globals.set: + id: cal_active + value: 'false' + - script.execute: cal_beep_fail + - component.update: cal_status + # Must be last: kill the running leg, then the main sequence + # (auto_calibrate is parked in script.wait: cal_leg). + - script.stop: cal_leg + - script.stop: auto_calibrate + + # One calibration travel ("leg") with in-place current escalation. + # opening=true drives close→open (success oracle: limit_switch_1); + # opening=false drives open→close (oracle: limit_switch_2). Each attempt + # runs at CANDIDATES[cal_irun_index] and the leg climbs the ladder when: + # * the vibration detector declares a stall (it stops the motor and + # drops global_state to 0; its auto-release also backs the mechanism + # off ~8 mm, giving the next attempt a run-up at the sticky spot), + # * the sampler reports SG margin starvation (cal_escalate — escalates + # while still moving, before a violent stall develops), + # * the software step count "arrives" without the switch (silent step + # loss — vibration alone misses this), or + # * the per-attempt deadline expires. + # Every attempt re-claims a FULL travel of commanded distance, so a lying + # step counter can never end a leg — only the physical switch can. + # Exhausting the ladder leaves cal_travel_ok false; the caller decides + # what that means. Only ever executed from auto_calibrate. + - id: cal_leg + mode: single + parameters: + opening: bool + then: + - globals.set: + id: cal_travel_ok + value: 'false' + - globals.set: + id: cal_phase + value: !lambda 'return opening ? 2 : 3;' + - component.update: cal_status + - lambda: |- + // LEG-level tune accumulators. SGTHRS / vibration threshold / + // TCOOLTHRS are tuned from the close (validation) leg's stats, + // which resets them here and owns them for its whole travel. + // They must NOT reset per attempt: a leg that escalates moments + // before the switch leaves zero unmasked samples at the final + // current — that shipped SGmin=510 -> SGTHRS=120 (trip level + // above normal cruise readings) in field testing. Sub-final- + // current samples in the mix only bias SGTHRS low = fewer false + // trips, never more. + id(cal_sg_min) = 510; + id(cal_vib_max) = 0.0f; + id(cal_tstep_max) = 0; + - while: + condition: + lambda: |- + bool sw = opening ? id(limit_switch_1).state : id(limit_switch_2).state; + return !sw && id(cal_irun_index) < 4; + then: + - script.execute: cal_thermal_guard + - lambda: |- + static const int CANDIDATES[4] = {12, 15, 17, 19}; + id(global_irun) = CANDIDATES[id(cal_irun_index)]; + id(cal_escalate) = false; + id(cal_sg_low_count) = 0; + - tmc2209.currents: + irun: !lambda 'return id(global_irun);' + - logger.log: + format: "CAL: %s leg at irun=%d" + args: ['opening ? "open" : "close"', 'id(global_irun)'] + # Re-claim a full travel of commanded distance (the step counter + # lies after a stall) and arm the speed-derived deadline. + - lambda: |- + id(driver)->report_position(opening ? 0 : ${max_distance_steps}); + int spd = id(global_speed); + if (spd < 100) spd = 100; + id(cal_budget_ms) = (uint32_t)(1000.0f * ${max_distance_steps} / (float)spd * 1.5f) + 10000; + id(cal_t0_ms) = millis(); + - if: + condition: + lambda: 'return opening;' + then: + - cover.open: glasscalibur_cover + else: + - cover.close: glasscalibur_cover + - wait_until: + condition: + lambda: |- + bool sw = opening ? id(limit_switch_1).state : id(limit_switch_2).state; + return sw || id(global_state) == 0 || id(cal_escalate) || + (millis() - id(cal_t0_ms)) > id(cal_budget_ms); + - if: + condition: + lambda: 'return !(opening ? id(limit_switch_1).state : id(limit_switch_2).state);' + then: + # No switch: stalled, starved, silently lost steps, or out + # of time. Stop (a starvation exit arrives here still + # moving) and climb to the next current. At the top of the + # ladder the while-condition ends the leg instead — a spot + # that still stalls at 19 is an obstruction or jam, never + # something to muscle through. + - cover.stop: glasscalibur_cover + - lambda: 'id(cal_irun_index) += 1;' + - delay: 300ms + - lambda: |- + id(cal_travel_ok) = + opening ? id(limit_switch_1).state : id(limit_switch_2).state; + + # Second half of the stall auto-release handshake (see on_stall and the + # vibration-stall interval). mode: restart so a second stall re-arms the + # 5 s window instead of leaving the flag set forever: if this logic lived + # inline in on_stall, a re-trigger during the delay would cancel the + # pending clear and stall_release_active would stay latched until reboot, + # silently disabling every future release. + - id: stall_release_clear + mode: restart + then: + - delay: 5s + - globals.set: + id: stall_release_active + value: 'false' + - globals.set: + id: global_current_position + value: !lambda 'return id(driver)->current_position;' + +# ----------------------------------------------------------------------------- +# Motion-completion poll +# ----------------------------------------------------------------------------- +# The stepper has no "target reached" event, so we poll: if we're in +# OPENING/CLOSING and current_position has caught up to target_position, flip +# back to IDLE, tell the cover entity, and persist the final position. The +# limit-switch and on_stall paths already save inside their own handlers — this +# block only covers natural mid-travel arrivals (e.g. HA position-slider drags). +# ----------------------------------------------------------------------------- +# Periodic intervals — motion completion, tamper check, vibration stall +# ----------------------------------------------------------------------------- +# Three interval blocks run independently: +# 1. 200 ms — detects natural OPENING/CLOSING -> IDLE transitions (target +# reached) and persists the final position to flash. +# 2. 1 s — anti-tamper check; only runs while IDLE and tamper detection +# is enabled. Latches tamper_detected, flips state to TAMPERED, +# and fires the buzzer alarm script. +# 3. 200 ms — vibration-based stall check; only runs while OPENING/CLOSING. +# Counts consecutive "loud" samples and runs the same stop+IDLE +# path as on_stall if the threshold is crossed. +interval: + - interval: 200ms + then: + - if: + condition: + lambda: |- + int s = id(global_state); + return (s == 1 || s == 2) && + id(driver)->current_position == id(driver)->target_position; + then: + - globals.set: + id: global_state + value: "0" + - cover.template.publish: + id: glasscalibur_cover + current_operation: IDLE + - globals.set: + id: global_current_position + value: !lambda "return id(driver)->current_position;" + + # --- Anti-tamper check (only runs while IDLE so motion vibration can't + # trip it). Latches tamper_detected = true on first trip; the binary + # sensor follows the global; the "Clear Tamper Alert" button clears. + - interval: 1s + then: + - if: + condition: + lambda: |- + if (!id(tamper_enabled).state) return false; + if (id(global_state) != 0) return false; + // Calibration shakes the device between travels (state is + // briefly 0 at each limit) — don't count that as tampering. + if (id(cal_active)) return false; + // Sensor states are m/s²; baselines and Tamper Threshold are + // in g — normalize the live readings before comparing. + constexpr float G_MS2 = 9.80665f; + float dx = id(accel_x).state / G_MS2 - id(tamper_baseline_x); + float dy = id(accel_y).state / G_MS2 - id(tamper_baseline_y); + float dz = id(accel_z).state / G_MS2 - id(tamper_baseline_z); + float dist = sqrtf(dx*dx + dy*dy + dz*dz); + return dist > id(tamper_threshold).state; + then: + - globals.set: + id: tamper_detected + value: "true" + - globals.set: + id: global_state + value: "3" + - lambda: 'id(tamper_count) += 1;' + - script.execute: tamper_alarm + - logger.log: + level: WARN + format: "TAMPER DETECTED (deviation > %.2fg)" + args: ['id(tamper_threshold).state'] + + # --- Vibration-based stall check. Only active while OPENING/CLOSING. + # A stalled stepper shakes the device hard. We measure + # |sqrt(x^2+y^2+z^2) - 1g| (orientation-independent vibration amplitude + # — gravity always has magnitude 1g regardless of how the device is + # mounted, so subtracting 1g leaves only the non-gravity component). + # If that exceeds vib_threshold for vib_stall_samples consecutive ticks, + # run the same stop+IDLE path as the TMC2209 on_stall handler. + - interval: 200ms + then: + - if: + condition: + lambda: |- + // Launch mask: the acceleration ramp shakes the device hard — + // steppers pass through their resonance band at low step rates, + // and the leadscrew takes up slack — so vibration there reads + // like a stall on a perfectly healthy travel. A fixed 500 ms + // was shorter than the ramp itself (speed/accel = 1 s at the + // 1500/1500 defaults), which false-tripped ~2 s into every + // move. Mask 500 ms + the time to reach cruise speed, capped + // at 3 s so extreme speed/accel settings can't blind the + // detector for a whole travel. + if (!id(vib_enabled).state) return false; + if (id(global_state) != 1 && id(global_state) != 2) return false; + int acc = id(global_acceleration); + if (acc < 1) acc = 1; + uint32_t mask_ms = 500 + (uint32_t)(1000.0f * id(global_speed) / (float)acc); + if (mask_ms > 3000) mask_ms = 3000; + return (millis() - id(motion_start_ms)) >= mask_ms; + then: + - lambda: |- + // The LIS2DH12 publishes every 500 ms but this interval ticks + // every 200 ms, so the same cached sample used to be counted + // 2-3 times — "5 consecutive samples" tripped on only ~2 + // distinct readings (~1 s), not the ~2.5 s of sustained + // judder the debounce was designed for. Count each fresh + // sensor sample exactly once; an unchanged x/y/z triple is + // the same cached reading, not new evidence. + static float last_x = 1e9f, last_y = 1e9f, last_z = 1e9f; + float x = id(accel_x).state; + float y = id(accel_y).state; + float z = id(accel_z).state; + if (x == last_x && y == last_y && z == last_z) return; + last_x = x; last_y = y; last_z = z; + // UNITS: the LIS2DH12 publishes m/s² (ESPHome convention); + // every threshold in this config is in g. Normalize BEFORE + // subtracting the 1 g gravity magnitude — without the + // division this computes ≈9 "g" of vibration at rest, every + // sample counts as loud, and any move is killed as soon as + // the launch mask expires (this false-failed calibration). + 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); + // 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; + // the old symmetric ±1 dithered near zero through a real + // multi-second field stall and never tripped. With +2/-1, + // loud-dominant sampling reaches the 5-sample default in + // ~2 s while an isolated jolt (+2, then decay) still + // cannot trip it. + if (vibration > id(vib_threshold).state) { + id(vib_loud_count) += 2; + } else if (id(vib_loud_count) > 0) { + id(vib_loud_count) -= 1; + } + - if: + condition: + lambda: 'return id(vib_loud_count) >= (int)id(vib_stall_samples).state;' + then: + - logger.log: + level: WARN + format: "VIBRATION STALL — strong vibration for %d samples" + args: ['id(vib_loud_count)'] + - stepper.stop: driver + - lambda: 'id(vib_stall_count) += 1;' + # 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 + # direction decides which way to back off. One release per + # event via stall_release_active. This path stays live + # during calibration (it is the calibration backstop); the + # cal script's own cover.stop may truncate the back-off, + # which is fine — the probe has already failed safe. + - if: + condition: + lambda: 'return !id(stall_release_active);' + then: + - globals.set: + id: stall_release_active + value: 'true' + - lambda: |- + int32_t pos = id(driver)->current_position; + int32_t rel = ${stall_release_steps}; + int32_t tgt = (id(global_state) == 2) ? pos + rel : pos - rel; + if (tgt < 0) tgt = 0; + id(driver)->set_target(tgt); + - script.execute: stall_release_clear + - globals.set: + id: global_state + value: "0" + - cover.template.publish: + id: glasscalibur_cover + current_operation: IDLE + - globals.set: + id: global_current_position + value: !lambda "return id(driver)->current_position;" + - globals.set: + id: vib_loud_count + value: "0" + else: + # Reset the counter whenever we're not commanded to move so the + # next move starts fresh. + - globals.set: + id: vib_loud_count + value: "0" + + # (The sun-schedule poll now lives in the scheduling mixin's own 60 s interval, + # which calls this product's schedule_open / schedule_close scripts.) + + # --------------------------------------------------------------------------- + # Auto-calibration sampler + # --------------------------------------------------------------------------- + # Fast SG_RESULT / vibration sampler feeding the auto_calibrate script. Runs + # only while calibrating, only while a travel is in progress, and only after + # the launch mask (same ramp-aware mask as the vibration-stall detector, + # +100 ms so the free-run baseline never includes ramp shake — a polluted + # baseline would inflate the tuned Vibration Threshold toward its clamp). + # cal_vib_max takes a running max, so the accelerometer's 500 ms cadence + # only limits how often it can rise, not its correctness. + - interval: 100ms + then: + - if: + condition: + lambda: |- + if (!id(cal_active)) return false; + if (id(global_state) != 1 && id(global_state) != 2) return false; + // Only while actually driving toward the target. At software + // arrival there is a ≤200 ms window before the completion + // poll zeroes global_state, in which the motor has already + // stopped: TSTEP decays toward its 1048575 idle ceiling and + // SG_RESULT reads near zero. One tick inside that window + // shipped TCOOLTHRS=37098 (mid-decay TSTEP × 1.5) and a fake + // SGmin in field testing. + if (id(driver)->current_position == id(driver)->target_position) return false; + int acc = id(global_acceleration); + if (acc < 1) acc = 1; + uint32_t mask_ms = 600 + (uint32_t)(1000.0f * id(global_speed) / (float)acc); + if (mask_ms > 3100) mask_ms = 3100; + return (millis() - id(motion_start_ms)) > mask_ms; + then: + - lambda: |- + // Cruise TSTEP (slowest sample survives) -> TCOOLTHRS on + // success. 1048575 is the register's "stopped" value. + int ts = id(driver)->read_register(TSTEP); + if (ts > 0 && ts < 1048575 && ts > id(cal_tstep_max)) { + id(cal_tstep_max) = ts; + } + int sg = id(driver)->read_register(SG_RESULT); + if (sg >= 0 && sg <= 510) { + if (sg < id(cal_sg_min)) id(cal_sg_min) = sg; + // Margin starvation -> escalate: net 8 low ticks below the + // floor asks cal_leg for the next current while the motor + // is still moving — earlier and quieter than a full + // vibration stall, and it bakes the margin requirement + // into the search itself. LEAKY counter (+1 low / -1 + // high), NOT reset-on-good: a skipping stall oscillates + // SG_RESULT wildly sample-to-sample, and consecutive + // counting never accumulated during a real field stall — + // the motor ground at the jam until the step counter ran + // out instead. + if (sg < ${cal_sg_floor}) { + id(cal_sg_low_count) += 1; + if (id(cal_sg_low_count) >= 8) id(cal_escalate) = true; + } else if (id(cal_sg_low_count) > 0) { + id(cal_sg_low_count) -= 1; + } + } + float x = id(accel_x).state; + float y = id(accel_y).state; + float z = id(accel_z).state; + if (!isnan(x) && !isnan(y) && !isnan(z)) { + // Sensor publishes m/s²; cal math (and the Vibration + // Threshold this feeds) is in g — normalize first. + constexpr float G_MS2 = 9.80665f; + float vib = fabsf(sqrtf(x*x + y*y + z*z) / G_MS2 - 1.0f); + if (vib > id(cal_vib_max)) id(cal_vib_max) = vib; + } \ No newline at end of file diff --git a/tools/regression-gate/README.md b/tools/regression-gate/README.md new file mode 100644 index 0000000..dd1e31f --- /dev/null +++ b/tools/regression-gate/README.md @@ -0,0 +1,74 @@ +# Firmware regression gate + +Two diffs between the **old** and **new** resolved firmware config. Run both +before every release. A clean `esphome compile` is necessary but **not +sufficient** — it proves the YAML is valid, not that the firmware still behaves +the same. + +## Why the behaviour gate exists + +v2.7.0 rewired the Ropener onto `valar-core` as a remote package. The entity +gate passed perfectly: no entity dropped, renamed, or retyped. It shipped. + +But the product's `on_stall` handler had been silently replaced by +`valar-core`'s default. Two live bugs resulted: + +- the device wedged in `HOMING` forever after any home (killing the button + gestures and the schedule, both of which guard on `global_state`), and +- **every** stall zeroed the position, so a curtain snagging mid-travel + silently redefined that point as home. + +No entity name changed, so nothing caught it. The behaviour gate diffs the +*automation bodies* — `on_stall`, `on_press`/`on_click`/`on_release`, script +bodies, `on_boot`, intervals, `*_action`, lambdas — anchored to stable +identities rather than list positions. + +It also caught an unrelated change in the same release: the `Motor Direction` +option string `"Reversed ↺"` had lost its glyph, which would have broken any +Home Assistant automation selecting it by value. + +## Usage + +```sh +# 1. Resolve both configs (from a checkout of each version) +esphome config firmware/VAL3100/Ropener-VAL3100.yml > /tmp/old-3100.yaml +esphome config firmware/VAL3100/Ropener-VAL3100.yml > /tmp/new-3100.yaml + +# 2. Diff them +python tools/regression-gate/gate.py /tmp/old-3100.yaml /tmp/new-3100.yaml + +# 3. Inspect anything it flags +python tools/regression-gate/gate.py /tmp/old-3100.yaml /tmp/new-3100.yaml \ + --detail on_stall +``` + +Exit code `0` = both gates clean, `1` = something differs. **A difference is not +automatically a failure** — additions and refactors are often intended. The +gate's job is to guarantee nothing changes *unnoticed*. Every flagged line must +be explained in the release notes. + +Run it for **each board** (VAL3000 and VAL3100); they share a product layer but +resolve differently. + +Requires `pyyaml` — use the interpreter ESPHome is installed under. + +## Known-benign differences after the valar-core rewire + +Expected when diffing a pre-rewire release (≤ v2.6.4) against a rewired one: + +| Anchor | Why | +|---|---| +| 7 diagnostic entities added | `valar-core` shared diagnostics (Restart, Uptime, WiFi Signal, ESP Internal Temperature, IP/SSID/MAC) | +| `esphome:on_boot` split 600 / 400 / -100 | core / product / scheduling layers; relative order preserved | +| `script:schedule_open`, `script:schedule_close` | scheduling-mixin contract; the `global_state != 3` homing guard moved into these scripts | +| `datetime:Open Time`/`Close Time` `on_time`, `interval:60s` | now call the schedule scripts instead of inlining `cover.open`/`cover.close` | + +Anything **outside** this table needs justification before release. + +## Before Glasscalibur + +This tool should move to `valar-motion` so both products share one copy rather +than duplicating it — same rule as the firmware itself. Glasscalibur's overrides +(limit switches, TMP1075 thermal cutoff, LIS2DH12 tamper, buzzer) carry the same +silent-replacement risk as `on_stall` did here, and there a dropped thermal +cutoff or tamper handler is a safety regression, not a UX one. diff --git a/tools/regression-gate/gate.py b/tools/regression-gate/gate.py new file mode 100644 index 0000000..af9116c --- /dev/null +++ b/tools/regression-gate/gate.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Firmware regression gate: diff two `esphome config` dumps. + +Compiling proves the YAML is valid. It does not prove the firmware still DOES +what it did. This runs two independent diffs: + + ENTITY GATE -- every user-facing entity (domain, name, category). + Catches dropped/renamed/added entities, which break + Home Assistant entity_ids. + + BEHAVIOUR GATE -- the automation bodies attached to those entities + (on_stall, on_press, script bodies, on_boot, intervals, + *_action, lambdas). Catches an entity that survived by + name while its behaviour was silently replaced. + +The behaviour gate exists because of the v2.7.0 homing regression: the entity +gate passed clean while `on_stall` had been swapped for valar-core's default, +which zeroed the cover's position on every stall. Entity names alone cannot see +that class of bug. + +Usage: + python gate.py OLD.yaml NEW.yaml # human-readable report + python gate.py OLD.yaml NEW.yaml --detail ANCHOR_SUBSTRING + +Exit code is 0 if both gates are clean, 1 if either reports a difference. +Differences are not automatically failures -- intended changes must be reviewed +and called out. The gate's job is to make sure nothing changes UNNOTICED. +""" +import argparse, json, sys, yaml + +# --- shared loader ----------------------------------------------------------- + +DOMAINS = [ + "binary_sensor", "sensor", "text_sensor", "switch", "number", "select", + "button", "cover", "light", "datetime", "text", "fan", "lock", "climate", + "valve", "event", "update", "stepper", "output", "alarm_control_panel", + "media_player", +] + + +class Loader(yaml.SafeLoader): + """SafeLoader that tolerates ESPHome's custom tags (!lambda, !secret, ...).""" + + +def _passthrough(loader, tag_suffix, node): + if isinstance(node, yaml.ScalarNode): + return loader.construct_scalar(node) + if isinstance(node, yaml.SequenceNode): + return loader.construct_sequence(node) + return loader.construct_mapping(node) + + +Loader.add_multi_constructor("!", _passthrough) + + +def load(path): + with open(path, "r", encoding="utf-8") as fh: + return yaml.load(fh, Loader=Loader) + + +# --- entity gate ------------------------------------------------------------- + +def entities(cfg): + rows = set() + for domain in DOMAINS: + block = cfg.get(domain) + if not isinstance(block, list): + continue + for item in block: + if not isinstance(item, dict): + continue + if item.get("name") is not None: + rows.add(f"{domain}\t{item['name']}\t{item.get('entity_category') or ''}") + # Multi-entity platforms (wifi_info, ...) nest one sub-entity per key. + for sub in item.values(): + if isinstance(sub, dict) and "name" in sub: + rows.add(f"{domain}\t{sub['name']}\t{sub.get('entity_category') or ''}") + return rows + + +# --- behaviour gate ---------------------------------------------------------- + +def is_behaviour_key(key): + return key.startswith("on_") or key.endswith("_action") or key in ( + "lambda", "then", "condition") + + +def strip_comments(text): + """Drop whole-line C++ comments. Only lines that START with // are removed -- + a trailing-comment rule would truncate URLs like https://... in literals.""" + return "\n".join( + ln for ln in text.splitlines() if not ln.lstrip().startswith("//")) + + +def canon(obj): + """Whitespace- and comment-insensitive, so reformatting or a reworded + comment is not reported as a behaviour change.""" + if isinstance(obj, str): + return " ".join(strip_comments(obj).split()) + if isinstance(obj, list): + return [canon(v) for v in obj] + if isinstance(obj, dict): + return {k: canon(v) for k, v in sorted(obj.items())} + return obj + + +def behaviours(cfg): + """anchor -> body. Anchors are stable identities (entity name, script id, + boot priority) rather than list positions, so a reordered package merge + does not look like a change.""" + out = {} + + def put(anchor, key, body): + out[(anchor, key)] = json.dumps(canon(body), sort_keys=True) + + for boot in (cfg.get("esphome") or {}).get("on_boot") or []: + if isinstance(boot, dict): + put("esphome:on_boot", str(boot.get("priority", "?")), boot.get("then")) + + for scr in cfg.get("script") or []: + if isinstance(scr, dict): + put(f"script:{scr.get('id','?')}", "then", scr.get("then")) + + for iv in cfg.get("interval") or []: + if isinstance(iv, dict): + put(f"interval:{iv.get('interval','?')}", "then", iv.get("then")) + + for domain in DOMAINS: + block = cfg.get(domain) + if not isinstance(block, list): + continue + for item in block: + if not isinstance(item, dict): + continue + anchor = f"{domain}:{item.get('name') or item.get('id') or '?'}" + for key, val in sorted(item.items()): + if is_behaviour_key(key): + put(anchor, key, val) + elif isinstance(val, dict): + for k2, v2 in sorted(val.items()): + if is_behaviour_key(k2): + put(f"{anchor}.{key}", k2, v2) + return out + + +# --- reporting --------------------------------------------------------------- + +def report_entities(old, new): + removed, added = sorted(old - new), sorted(new - old) + print("=" * 72) + print("ENTITY GATE") + print("=" * 72) + if not removed and not added: + print(" clean - entity lists identical\n") + return True + for row in removed: + print(f" REMOVED {row}") + for row in added: + print(f" ADDED {row}") + print(f"\n {len(removed)} removed, {len(added)} added -- each must be intended and called out.\n") + return False + + +def report_behaviours(old, new): + changed = sorted(k for k in set(old) & set(new) if old[k] != new[k]) + dropped = sorted(set(old) - set(new)) + gained = sorted(set(new) - set(old)) + print("=" * 72) + print("BEHAVIOUR GATE") + print("=" * 72) + if not changed and not dropped and not gained: + print(" clean - all automation bodies identical\n") + return True + for k in changed: + print(f" CHANGED {k[0]} :: {k[1]}") + for k in dropped: + print(f" DROPPED {k[0]} :: {k[1]}") + for k in gained: + print(f" NEW {k[0]} :: {k[1]}") + print(f"\n {len(changed)} changed, {len(dropped)} dropped, {len(gained)} new.") + print(" Re-run with --detail to see the bodies side by side.\n") + return False + + +def detail(old, new, needle): + for key in sorted(set(old) | set(new)): + if needle.lower() not in f"{key[0]} {key[1]}".lower(): + continue + o, n = old.get(key), new.get(key) + if o == n: + continue + print("=" * 72) + print(f"{key[0]} :: {key[1]}") + print("-" * 30 + " OLD " + "-" * 30) + print(json.dumps(json.loads(o), indent=2) if o else "(absent)") + print("-" * 30 + " NEW " + "-" * 30) + print(json.dumps(json.loads(n), indent=2) if n else "(absent)") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("old") + ap.add_argument("new") + ap.add_argument("--detail", help="show bodies for anchors matching this substring") + args = ap.parse_args() + + old_cfg, new_cfg = load(args.old), load(args.new) + old_b, new_b = behaviours(old_cfg), behaviours(new_cfg) + + if args.detail: + detail(old_b, new_b, args.detail) + return 0 + + ok_e = report_entities(entities(old_cfg), entities(new_cfg)) + ok_b = report_behaviours(old_b, new_b) + return 0 if (ok_e and ok_b) else 1 + + +if __name__ == "__main__": + sys.exit(main())