Repository navigation
Expand file tree
/
Copy pathglasscalibur-product.yaml
More file actions
2508 lines (2412 loc) · 109 KB
/
Copy pathglasscalibur-product.yaml
File metadata and controls
2508 lines (2412 loc) · 109 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# =============================================================================
# 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; 7 quick presses =
# 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):
# - 7 quick clicks: 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'
# --- StallGuard auto-tune (self-healing SGTHRS) state ---
# Peak vibration (g) seen during the current travel; reset at motion start,
# maxed in the vibration interval. It is the cross-check for whether a StallGuard
# trip was a real (shaky) stall or a quiet false trip.
- id: vib_run_peak
type: float
restore_value: no
initial_value: '0.0'
# Set true when the StallGuard handler fires during the current travel; reset at
# motion start. Lets the vibration detector tell "StallGuard also caught this"
# from "StallGuard missed a real stall".
- id: sg_fired_this_move
type: bool
restore_value: no
initial_value: 'false'
# Consecutive quiet mid-travel StallGuard trips. A single quiet trip is weak
# evidence (the accelerometer can miss a fast stall), so auto-tune only lowers
# SGTHRS after a run of them. Reset by vibration agreement or a clean full
# travel. Not persisted — a fresh boot re-earns the evidence.
- id: sg_quiet_streak
type: int
restore_value: no
initial_value: '0'
# Lifetime count of auto-tune adjustments (persisted, surfaced as a diagnostic).
- id: sg_autotune_count
type: int
restore_value: yes
initial_value: '0'
# -----------------------------------------------------------------------------
# Product-layer overrides of shared infrastructure
# -----------------------------------------------------------------------------
# Substitutions (mechanical + OTA identity) moved to the board wrapper. api, ota,
# http_request, safe_mode, wifi, captive_portal, improv_serial, time and sun all
# come from valar-core / the scheduling mixin. Only the product-specific slivers
# of logger and web_server remain here; package merge folds them into core's.
# Add the LIS2DH12 i2c-poll log silencing to core's logger. Package merge folds
# this logs map into core's (core owns the console uart + base level).
logger:
logs:
# Silence the LIS2DH12 status-poll chatter (~50 i2c.idf lines per sample).
i2c.idf: INFO
lis2dh12: INFO
# Web UI groups. Control / Schedule / Diagnostics come from core + the scheduling
# mixin. Everything else is defined here so the page reads top-to-bottom as:
# Control, Motion Tuning, Calibration, Stall Detection, Schedule, Security,
# Diagnostics, StallGuard (advanced).
#
# A product can't re-weight a core group (redefining its id errors), so — as on
# the Ropener — we define NEW groups and move the relevant entities into them,
# leaving core's group_motion / group_stallguard / group_setup empty (empty groups
# don't render). Motion Tuning sits above Schedule (25); the accelerometer-based
# Stall Detection is its own first-class group (it is NOT a StallGuard feature),
# while the TMC2209 StallGuard registers drop to an advanced group at the bottom.
web_server:
sorting_groups:
- { id: group_tuning, name: "Motion Tuning", sorting_weight: 12 }
- { id: group_cal, name: "Calibration", sorting_weight: 22 }
- { id: group_stall, name: "Stall Detection", sorting_weight: 24 }
- { id: group_security, name: "Security / Tamper", sorting_weight: 45 }
- { id: group_sg_adv, name: "StallGuard (advanced)", sorting_weight: 70 }
# -----------------------------------------------------------------------------
# Device identity + boot-time motor configuration
# -----------------------------------------------------------------------------
# on_boot:
# 1. Restore last saved position so HA cover state is accurate.
# 2. Override with limit-switch ground truth if either is currently pressed.
# 3. Apply persisted speed / acceleration to the stepper.
# 4. Configure TMC2209: direction, microsteps, TCOOLTHRS, StallGuard, currents.
# 5. Re-apply the user's saved timezone (restore doesn't re-fire set_action).
esphome:
# name / friendly_name / project come from the board wrapper and core. The
# motor configure/stallguard/currents, speed/accel, and firmware-version publish
# are all done by core's priority-600 on_boot (direction = COUNTERCLOCKWISE via
# the board's motor_direction_default). Timezone / lat-long re-apply is done by
# the scheduling mixin's priority -100 on_boot. This product adds only what core
# can't know about: the current clamp and limit-switch homing.
on_boot:
# BEFORE core configures the driver (core boots at priority 600): clamp the
# restored motor current. Units upgraded from firmware that allowed irun up to
# 31 restore the old value from flash; core's currents would otherwise push it
# straight to the driver. 19 = rated 0.6 A; the motor's 60 °C case limit leaves
# no headroom above that. num_irun's lowered max_value caps new entries too.
- priority: 650
then:
- lambda: 'if (id(global_irun) > 19) id(global_irun) = 19;'
# AFTER core has configured the driver and applied speed/accel (priority 600):
# establish the true position. Restore the last saved position, then let a
# currently-pressed limit switch override it with mechanical ground truth.
# (Position counter vs. driver registers are independent, so running this
# after core's configure is equivalent to the pre-rewire single-trigger boot.)
- priority: 500
then:
- stepper.report_position:
id: driver
position: !lambda "return id(global_current_position);"
- stepper.set_target:
id: driver
target: !lambda "return id(global_current_position);"
- logger.log: "CHECKING LIMIT SWITCHES"
- if:
condition:
binary_sensor.is_on: limit_switch_1
then:
- logger.log: "SETTING LIMIT 1"
- stepper.report_position:
id: driver
position: ${max_distance_steps}
- stepper.set_target:
id: driver
target: ${max_distance_steps}
- if:
condition:
binary_sensor.is_on: limit_switch_2
then:
- logger.log: "SETTING LIMIT 2"
- stepper.report_position:
id: driver
position: 0
- stepper.set_target:
id: driver
target: 0
# -----------------------------------------------------------------------------
# Buzzer (passive piezo on GPIO5, driven by ledc PWM)
# -----------------------------------------------------------------------------
# Used by the tamper_alarm script. Frequency and duty are set per-pulse from
# the script (2700 Hz / 50 % gives a clear, audible square wave).
output:
- platform: ledc
pin: GPIO5
id: buzzer
# -----------------------------------------------------------------------------
# Stepper driver — product on_stall override
# -----------------------------------------------------------------------------
# The driver (id: driver), its UART, and all hardware params (rsense 220 mOhm,
# vsense off, deceleration inf, config_dump off, enn/index/diag pins) come from
# core, fed by the board wrapper's stepper_* substitutions. Only the stall
# response is product-specific. Replace core's default on_stall: because package
# !extend APPENDS to action lists (it does not replace), first !remove core's
# handler — otherwise core's stop + report_position(0) would still run and zero
# the position, which limit-switch homing (not stalls) owns here — then re-add
# the Glasscalibur handler.
stepper:
- id: !extend driver
on_stall: !remove
- id: !extend driver
on_stall:
# Software gate on the "StallGuard Enabled" switch — the chip itself
# always flags stalls; we only react when the user has opted in. When
# the switch is off, the trigger fires harmlessly with no effect.
# (During auto-calibration the switch is off, so calibration probes
# never enter this handler — the script does its own recovery.)
- if:
condition:
lambda: 'return id(stallguard_enabled).state;'
then:
- stepper.stop: driver
- lambda: 'id(stallguard_count) += 1;'
- logger.log: "MOTOR STALLED - backing off"
# Stall auto-release: the leadscrew is not backdrivable, so a
# stall against an obstruction HOLDS whatever clamp force it
# reached until the motor reverses. Back off ~8 mm so "stops
# while still clamping" becomes genuine auto-reverse. Must run
# BEFORE global_state is zeroed below — the stall direction is
# what decides which way to back off. One release per event:
# a second stall while stall_release_active is set (jammed
# solid) just stops.
- if:
condition:
lambda: 'return !id(stall_release_active);'
then:
- globals.set:
id: stall_release_active
value: 'true'
- lambda: |-
int32_t pos = id(driver)->current_position;
int32_t rel = ${stall_release_steps};
// Stalled while CLOSING (state 2): back off toward open.
// Stalled while OPENING: back off toward close.
int32_t tgt = (id(global_state) == 2) ? pos + rel : pos - rel;
if (tgt < 0) tgt = 0;
id(driver)->set_target(tgt);
- script.execute: stall_release_clear
- globals.set:
id: global_state
value: "0"
- cover.template.publish:
id: glasscalibur_cover
current_operation: IDLE
- globals.set:
id: global_current_position
value: !lambda "return id(driver)->current_position;"
# Record the fire and, if auto-tune is on, cross-check vibration to
# decide whether this was a real stall or a false trip (see the
# sg_autotune_eval script). Runs last so global_state/position are set.
- script.execute: sg_autotune_eval
# -----------------------------------------------------------------------------
# Physical buttons + end-stop limit switches
# -----------------------------------------------------------------------------
# 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"
# A full travel reached its end stop -> the StallGuard tune is fine;
# clear any accumulated quiet-trip evidence (auto-tune).
- lambda: 'id(sg_quiet_streak) = 0;'
else:
- logger.log: "LIMIT 1 blip while closing - ignored"
- 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"
- lambda: 'id(sg_quiet_streak) = 0;' # clean full travel -> tune OK (auto-tune)
else:
- logger.log: "LIMIT 2 blip while opening - ignored"
# Calibration / WiFi Reset (GPIO21) — two gestures, non-overlapping timings:
# * 7 quick presses (each ≤ 0.6 s) -> start auto-calibration. Seven is
# deliberate: calibration drives the window through its full travel, so
# the trigger must be impossible to hit accidentally (a stray
# double-tap used to be enough). The script still 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 calibration 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 most 0.4s
- ON for at most 0.6s
- OFF for at most 0.4s
- ON for at most 0.6s
- OFF for at most 0.4s
- ON for at most 0.6s
- OFF for at most 0.4s
- 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 (7 presses)"
- 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();
id(vib_run_peak) = 0.0f; // reset per-travel stall cross-check state
id(sg_fired_this_move) = false;
- globals.set:
id: global_state
value: "1"
- 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();
id(vib_run_peak) = 0.0f; // reset per-travel stall cross-check state
id(sg_fired_this_move) = false;
- globals.set:
id: global_state
value: "2"
- 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();
id(vib_run_peak) = 0.0f; // reset per-travel stall cross-check state
id(sg_fired_this_move) = false;
# Direction is inferred by comparing the new target to current_position.
- if:
condition:
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};