From 2e5cd52fd13746ae498e53a24c3f756854970c5c Mon Sep 17 00:00:00 2001 From: Daniel Frenkel Date: Tue, 21 Jul 2026 22:35:10 -0700 Subject: [PATCH 1/4] firmware: rewire onto valar-core v0.3.0 + scheduling packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the 2939-line monolith into a board wrapper + product layer that pull the shared valar-core (v0.3.0) and scheduling mixin as packages, keeping only Glasscalibur's product hardware and logic in glasscalibur-product.yaml. Board wrapper (Glasscalibur-esphome.yml): chip, GPIO map, OTA identity, and the stepper_* / motor_direction_default substitutions that express Glasscalibur's 220 mOhm / inf-decel / counter-clockwise hardware against core's defaults. Stripped (now from core/scheduling): api, ota, http_request, safe_mode, wifi, captive_portal, improv_serial, logger base, web_server base, uart, the tmc2209 driver + its on_boot config, Speed/Accel/IRUN/TCOOLTHRS/SGTHRS numbers, SG_RESULT/ TSTEP sensors, Restart/Update-Firmware buttons, Firmware Version + wifi_info, WiFi Signal/Uptime/ESP temp, and the entire sun/time schedule (time, sun, the Schedule/Sun switches, Lat/Long/offset numbers, Open/Close Time, Timezone, and the sun-poll interval). Kept (product): buzzer, i2c, LIS2DH12, TMP1075, cover, limit switches, tamper sensor, child lock, tamper/vibration switches+numbers, auto-calibration scripts, tamper/vibration/completion intervals, lifetime counters, State/Cal Status. Reconciled: on_stall replaced via !remove + re-add; num_irun !extended to cap at 19; sel_direction and phantom btn2/btn3 !removed (Glasscalibur has 2 buttons and fixed ccw direction); on_boot split into priority 650 (irun clamp, before core's currents) and 500 (restore + limit-switch homing, after core configures); global_max_speed renamed to core's global_speed; schedule_open/schedule_close scripts added for the scheduling mixin contract. Verification: esphome config valid; regression gate (entity + behaviour) shows only intended diffs — tuning entities adopt core's names, on_boot reorganized without losing an action, schedule moved to the mixin, plus the new "Board" diagnostic. OTA URL and device name preserved byte-for-byte. NOTE: board wrapper uses local includes for dev; swap to remote ref v0.3.0 once core v0.3.0 is tagged. A build-release CI workflow still needs adding. Co-Authored-By: Claude Opus 4.8 (1M context) --- firmware/esphome/Glasscalibur-esphome.yml | 2993 +------------------- firmware/esphome/glasscalibur-product.yaml | 2256 +++++++++++++++ tools/regression-gate/README.md | 74 + tools/regression-gate/gate.py | 220 ++ 4 files changed, 2612 insertions(+), 2931 deletions(-) create mode 100644 firmware/esphome/glasscalibur-product.yaml create mode 100644 tools/regression-gate/README.md create mode 100644 tools/regression-gate/gate.py diff --git a/firmware/esphome/Glasscalibur-esphome.yml b/firmware/esphome/Glasscalibur-esphome.yml index 0ddc536..89d1fc3 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: + # DEV: local includes for iteration against the unreleased core v0.3.0 branch. + # BEFORE SHIPPING, swap these two to remote packages pinned at the v0.3.0 tag: + # valar_core: + # url: https://github.com/Valar-Systems/valar-motion + # ref: v0.3.0 + # files: [common/valar-core.yaml] + # refresh: 1d + # scheduling: { ...same url/ref..., files: [common/scheduling.yaml] } + valar_core: !include C:/vm/v03/common/valar-core.yaml + scheduling: !include C:/vm/v03/common/scheduling.yaml + 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..463dd4e --- /dev/null +++ b/firmware/esphome/glasscalibur-product.yaml @@ -0,0 +1,2256 @@ +# ============================================================================= +# 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 + +# One product-only web UI group. Control / Setup / Motion Tuning / StallGuard / +# Diagnostics come from core, and Schedule from the scheduling mixin (package +# merge concatenates sorting_groups). group_security holds the tamper/anti-theft +# entities. Product entities that target core's groups (group_control, +# group_motion, group_stallguard, group_diagnostics) resolve against core's +# definitions; the empty group_setup simply doesn't render. +web_server: + sorting_groups: + - { id: group_security, name: "Security / Tamper", sorting_weight: 45 } + +# ----------------------------------------------------------------------------- +# 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 only tightens the + # current cap and adds its tamper/vibration tuning numbers. + + # Cap motor current at IRUN 19 (rated 0.6 A on this board's sense-resistor math, + # I_RMS = (CS+1)/32 × 0.9575 A). Core's num_irun allows up to 31; anything above + # 19 pushes this motor past its 60 °C case limit — never raise it. The on_boot + # clamp (priority 650) also fixes values restored from older firmware. Keep it in + # the Motion Tuning group where Glasscalibur has always shown it. + - id: !extend num_irun + max_value: 19 + web_server: + sorting_weight: 3 + sorting_group_id: group_motion + + +# --- 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 + + # (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: 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" + +# ----------------------------------------------------------------------------- +# 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_stallguard + 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: + - 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_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_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()) From f7cf355000b78fb2fa23e91cb3fe42135cd5349f Mon Sep 17 00:00:00 2001 From: Daniel Frenkel Date: Wed, 22 Jul 2026 09:54:44 -0700 Subject: [PATCH 2/4] firmware: pin core v0.3.0 remote packages, add build CI + v2.0.0 changelog - Board wrapper now pulls valar-core + scheduling from Valar-Systems/valar-motion at ref v0.3.0 (remote packages) instead of dev-time local includes. Config re-validated against the released tag; regression gate unchanged. - Add .github/workflows/build-release.yml (mirrors the Ropener pipeline, single board): compiles on push/PR, and on a v* tag stamps fw_version from the tag and attaches Glasscalibur.factory.bin / .ota.bin / .ota.bin.md5 to the release. The on-device GitHub-OTA button depends on that asset existing. - CHANGELOG: document v2.0.0 (valar-core rewrite, auto-calibration, breaking tuning-entity renames) and switch the version source note to the release tag. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build-release.yml | 54 +++++++++++++++++++++++ CHANGELOG.md | 43 +++++++++++++++--- firmware/esphome/Glasscalibur-esphome.yml | 20 ++++----- 3 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/build-release.yml 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 89d1fc3..f7763f7 100644 --- a/firmware/esphome/Glasscalibur-esphome.yml +++ b/firmware/esphome/Glasscalibur-esphome.yml @@ -58,14 +58,14 @@ esphome: version: "${fw_version}" packages: - # DEV: local includes for iteration against the unreleased core v0.3.0 branch. - # BEFORE SHIPPING, swap these two to remote packages pinned at the v0.3.0 tag: - # valar_core: - # url: https://github.com/Valar-Systems/valar-motion - # ref: v0.3.0 - # files: [common/valar-core.yaml] - # refresh: 1d - # scheduling: { ...same url/ref..., files: [common/scheduling.yaml] } - valar_core: !include C:/vm/v03/common/valar-core.yaml - scheduling: !include C:/vm/v03/common/scheduling.yaml + 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 From 289e124ee1a01aa17febec9ed061b7bda9060143 Mon Sep 17 00:00:00 2001 From: Daniel Frenkel Date: Wed, 22 Jul 2026 10:13:16 -0700 Subject: [PATCH 3/4] firmware: reorganize web UI groups (Ropener-style), split Stall Detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vibration-based stall detection is an accelerometer feature, not StallGuard, so give it its own "Stall Detection" group. Demote the TMC2209 StallGuard controls to an advanced group at the very bottom (as on the Ropener), and pull the SG_RESULT/TSTEP readouts down into it. Page order is now: Control, Motion Tuning (Speed/Accel/Motor Current), Calibration (Auto-Calibrate + status), Stall Detection (vibration), Schedule, Security/Tamper, Diagnostics, StallGuard (advanced) — SGTHRS/TCOOLTHRS/SG_RESULT/ TSTEP/enable. Uses product-local groups and !extend to move core entities, leaving core's group_motion/group_stallguard/group_setup empty (they don't render). Entity set and behaviour unchanged (regression gate identical; sorting is gate-blind). Co-Authored-By: Claude Opus 4.8 (1M context) --- firmware/esphome/glasscalibur-product.yaml | 75 ++++++++++++++++------ 1 file changed, 54 insertions(+), 21 deletions(-) diff --git a/firmware/esphome/glasscalibur-product.yaml b/firmware/esphome/glasscalibur-product.yaml index 463dd4e..3d4500a 100644 --- a/firmware/esphome/glasscalibur-product.yaml +++ b/firmware/esphome/glasscalibur-product.yaml @@ -404,15 +404,24 @@ logger: i2c.idf: INFO lis2dh12: INFO -# One product-only web UI group. Control / Setup / Motion Tuning / StallGuard / -# Diagnostics come from core, and Schedule from the scheduling mixin (package -# merge concatenates sorting_groups). group_security holds the tamper/anti-theft -# entities. Product entities that target core's groups (group_control, -# group_motion, group_stallguard, group_diagnostics) resolve against core's -# definitions; the empty group_setup simply doesn't render. +# 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_security, name: "Security / Tamper", sorting_weight: 45 } + - { id: group_tuning, name: "Motion Tuning", sorting_weight: 20 } + - { 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 @@ -948,19 +957,35 @@ cover: # value we wrote. number: # Speed, Acceleration, TCOOLTHRS and SGTHRS come from core; Latitude/Longitude - # and the sun offsets from the scheduling mixin. This product only tightens the - # current cap and adds its tamper/vibration tuning numbers. + # 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 } # Cap motor current at IRUN 19 (rated 0.6 A on this board's sense-resistor math, # I_RMS = (CS+1)/32 × 0.9575 A). Core's num_irun allows up to 31; anything above # 19 pushes this motor past its 60 °C case limit — never raise it. The on_boot - # clamp (priority 650) also fixes values restored from older firmware. Keep it in - # the Motion Tuning group where Glasscalibur has always shown it. + # clamp (priority 650) also fixes values restored from older firmware. Sits in + # Motion Tuning with Speed/Acceleration. - id: !extend num_irun max_value: 19 web_server: sorting_weight: 3 - sorting_group_id: group_motion + sorting_group_id: group_tuning + + # 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) --- @@ -992,8 +1017,8 @@ number: name: "Vibration Threshold" id: vib_threshold web_server: - sorting_weight: 5 - sorting_group_id: group_stallguard + sorting_weight: 2 + sorting_group_id: group_stall step: 0.05 min_value: 0.05 max_value: 4.0 @@ -1013,8 +1038,8 @@ number: name: "Vibration Stall Samples" id: vib_stall_samples web_server: - sorting_weight: 6 - sorting_group_id: group_stallguard + sorting_weight: 3 + sorting_group_id: group_stall step: 1 min_value: 2 max_value: 60 @@ -1114,8 +1139,8 @@ switch: id: vib_enabled entity_category: CONFIG web_server: - sorting_weight: 4 - sorting_group_id: group_stallguard + sorting_weight: 1 + sorting_group_id: group_stall optimistic: true restore_mode: RESTORE_DEFAULT_OFF turn_off_action: @@ -1137,7 +1162,7 @@ switch: entity_category: CONFIG web_server: sorting_weight: 1 - sorting_group_id: group_stallguard + sorting_group_id: group_sg_adv optimistic: true restore_mode: RESTORE_DEFAULT_OFF turn_on_action: @@ -1266,7 +1291,7 @@ button: entity_category: CONFIG web_server: sorting_weight: 1 - sorting_group_id: group_stallguard + sorting_group_id: group_cal on_press: - script.execute: auto_calibrate @@ -1317,6 +1342,14 @@ lis2dh12_i2c: # 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: @@ -1438,7 +1471,7 @@ text_sensor: entity_category: DIAGNOSTIC web_server: sorting_weight: 2 - sorting_group_id: group_stallguard + sorting_group_id: group_cal update_interval: 30s lambda: |- if (id(cal_active)) { From 24ac94c69aaabfc59921f2d2e4678345cbe64019 Mon Sep 17 00:00:00 2001 From: Daniel Frenkel Date: Wed, 22 Jul 2026 10:22:43 -0700 Subject: [PATCH 4/4] firmware: friendly Motor Current (mA) + Motion Tuning directly under Control - Add a "Motor Current" number in milliamps to Motion Tuning, driven by the driver's own read_run_current_mA / write_run_current_mA (no hand formula). Capped at 600 mA = IRUN 19, this motor's thermal ceiling. Keeps global_irun in sync. - Demote the raw IRUN register to "IRUN value" in the advanced StallGuard group for power users (still capped at 19). - Move the Motion Tuning group to weight 12 so it sits immediately under Control. Verified on hardware: Motor Current reads 538 mA at IRUN 17 (exact conversion). Co-Authored-By: Claude Opus 4.8 (1M context) --- firmware/esphome/glasscalibur-product.yaml | 41 +++++++++++++++++----- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/firmware/esphome/glasscalibur-product.yaml b/firmware/esphome/glasscalibur-product.yaml index 3d4500a..247615a 100644 --- a/firmware/esphome/glasscalibur-product.yaml +++ b/firmware/esphome/glasscalibur-product.yaml @@ -417,7 +417,7 @@ logger: # 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: 20 } + - { 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 } @@ -968,16 +968,41 @@ number: - id: !extend num_accel web_server: { sorting_weight: 2, sorting_group_id: group_tuning } - # Cap motor current at IRUN 19 (rated 0.6 A on this board's sense-resistor math, - # I_RMS = (CS+1)/32 × 0.9575 A). Core's num_irun allows up to 31; anything above - # 19 pushes this motor past its 60 °C case limit — never raise it. The on_boot - # clamp (priority 650) also fixes values restored from older firmware. Sits in - # Motion Tuning with Speed/Acceleration. + # 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: 3 - sorting_group_id: group_tuning + 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