Skip to content

Commit 0c97dd7

Browse files
Restore Ropener stall handling lost in the valar-core rewire (v2.7.0 regression) (#34)
* fix(firmware): restore Ropener stall handling lost in the valar-core rewire v2.7.0 rewired onto valar-core as a remote package. The entity list came through clean, so it shipped -- but the product's on_stall handler had been silently replaced by core's default, which zeroes the stepper position on EVERY stall. Two live bugs resulted: * The device wedged in HOMING. start_homing sets global_state=3 and nothing cleared it, so after any home the State sensor read HOMING forever, the cover stayed CLOSING, every button gesture died (all guard on state==0) and the schedule refused to run (guards on state!=3). Only the manual Start-Stop Homing button recovered it. * Any stall corrupted the position reference. A curtain snagging mid-travel silently redefined that point as home, poisoning the cover percentage and the persisted position. !extend APPENDS to core's action list rather than replacing it, so extending alone would have left core's unconditional zeroing firing alongside the fix. Remove core's handler first, then install the product's -- verified against the resolved config: exactly one on_stall survives, byte-identical to v2.6.4 including the non-homing else branch. Also in this change: * Restore the "Reversed ↺" Motor Direction option. Core had dropped the glyph; the string is what Home Assistant automations select by, and existing units hold it as their persisted state. * fw_version defaults to "dev" instead of a hardcoded "2.7.0", so local and branch builds stop misreporting themselves. Release builds still get the real version injected by the workflow via -s fw_version. * Add tools/regression-gate -- an entity diff AND a behavioural diff of the resolved config (on_stall, on_press, script bodies, on_boot, intervals, *_action, lambdas). The entity gate alone passed v2.7.0 clean; the behavioural gate is what catches this class of regression, and it is what caught the Motor Direction glyph and a missing else branch in the first draft of this very fix. Verified on both boards: esphome config clean, entity gate shows only the 7 intended valar-core diagnostics, behavioural gate shows only the documented scheduling/on_boot refactors and nothing dropped. VAL3100 compiles to a full factory.bin. OTA asset names and the GitHub-OTA button URL are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(firmware): mark the cover as device_class curtain The cover had no device_class, so Home Assistant fell back to generic up/down arrow controls. A Ropener draws sideways; "curtain" gets the horizontal open/close controls, the right icon, and better voice-assistant phrasing. Note this does NOT change the device's own web UI: web_server v3 hardcodes the cover glyphs ("up", "stop", "down" in render_cover) and never reads device_class. Home Assistant only. Both gates clean -- entity list and all automation bodies identical; the resolved config differs by exactly this one line. Included in v2.7.1 because the bench-tested build had it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(firmware): stop rebooting every 15 min without a Home Assistant client The API reboot_timeout defaults to 15 minutes: with no *API* client connected the device reboots. It counts native-API clients only -- a browser sitting on the web UI is not one. The Ropener is sold as working without Home Assistant, so any customer driving it from the browser had a controller that silently rebooted every 15 minutes, stopping the curtain mid-travel if it happened to be moving at the time. Observed on the bench unit: "[E][api:127] No clients; rebooting", uptime resetting on a 15 minute cycle. Not a rewire regression -- v2.6.4 resolves the same 15min default. It is pre-existing in every Ropener release; the bench session just made it visible. Set at the product layer rather than in valar-core: core declares a bare `api:`, so the option merges in cleanly with no !remove needed, and this ships without a valar-motion retag. It should move into valar-core later, since the whole family is sold as HA-optional. Trade-off: this also removes the watchdog that recovers a wedged API connection. Acceptable -- the users it was rebooting are precisely the ones not using the API -- and safe_mode still covers boot loops. Both gates clean; the resolved config differs by exactly one line (reboot_timeout 15min -> 0s). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7616db7 commit 0c97dd7

5 files changed

Lines changed: 372 additions & 2 deletions

File tree

‎firmware/VAL3000/Ropener-VAL3000.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ substitutions:
1212
log_uart: "USB_SERIAL_JTAG"
1313
ota_repo: "Valar-Systems/Ropener"
1414
ota_asset: "Ropener-VAL3000"
15-
fw_version: "2.7.0"
15+
# "dev" for local/branch builds; the release workflow overrides it with the
16+
# git tag (-s fw_version) so shipped units report the version they run.
17+
fw_version: "dev"
1618
pin_uart_tx: "6"
1719
pin_uart_rx: "5"
1820
pin_enn: "8"

‎firmware/VAL3100/Ropener-VAL3100.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ substitutions:
1313
log_uart: "USB_SERIAL_JTAG"
1414
ota_repo: "Valar-Systems/Ropener" # field units self-update from Ropener's own releases
1515
ota_asset: "Ropener-VAL3100"
16-
fw_version: "2.7.0"
16+
# "dev" for local/branch builds; the release workflow overrides it with the
17+
# git tag (-s fw_version) so shipped units report the version they run.
18+
fw_version: "dev"
1719
pin_uart_tx: "0"
1820
pin_uart_rx: "1"
1921
pin_enn: "5"

‎firmware/common/ropener-product.yaml‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ substitutions:
2929
# stepper; here we resync the derived step count and restore the saved position
3030
# so the cover comes up accurate without a re-home. (scheduling re-applies
3131
# tz/location at priority -100, after this.)
32+
# The API's reboot_timeout defaults to 15 min: if no *API* client connects in
33+
# that window the device reboots. It counts native-API clients only — a browser
34+
# on the web UI is not one. The Ropener is sold as working without Home
35+
# Assistant, so a customer driving it purely from the browser got a controller
36+
# that rebooted every 15 minutes, stopping the curtain mid-travel if it happened
37+
# to be moving. 0 = never reboot for lack of a client.
38+
#
39+
# Trade-off: this also removes the watchdog that recovers a wedged API
40+
# connection. Acceptable here — the users it was rebooting are the ones not
41+
# using the API at all — and safe_mode still covers boot loops.
42+
api:
43+
reboot_timeout: 0s
44+
3245
esphome:
3346
on_boot:
3447
- priority: 400.0
@@ -58,6 +71,40 @@ globals:
5871
- { id: b1_tap_count, type: int, restore_value: no, initial_value: '0' }
5972
- { id: b1_last_tap_ms, type: uint32_t, restore_value: no, initial_value: '0' }
6073

74+
# --- Stall handling (homing) ------------------------------------------------
75+
# The Ropener homes sensorlessly: drive at the closed end until StallGuard fires,
76+
# and treat THAT stall as position 0. Core's default on_stall zeroes the position
77+
# unconditionally, which is wrong here — a stall mid-travel (curtain snags) would
78+
# silently redefine that spot as home and corrupt the cover's position reference.
79+
#
80+
# `!extend` APPENDS to core's action list rather than replacing it, so extending
81+
# alone would leave core's unconditional zeroing firing before ours. The two
82+
# entries below are deliberate and order-dependent: the first deletes core's
83+
# handler outright, the second installs the Ropener's conditional one. Verified
84+
# against the resolved config — exactly one on_stall survives. Keep both.
85+
stepper:
86+
- id: !extend driver
87+
on_stall: !remove
88+
- id: !extend driver
89+
on_stall:
90+
- stepper.stop: driver
91+
- if:
92+
condition:
93+
lambda: 'return id(global_state) == 3;'
94+
then:
95+
- stepper.report_position: { id: driver, position: 0 }
96+
- stepper.set_target: { id: driver, target: 0 }
97+
- globals.set: { id: global_state, value: "0" }
98+
- cover.template.publish: { id: ropener_cover, current_operation: IDLE }
99+
- logger.log: "Homed cover"
100+
# Stall outside homing (curtain snagged, or it hit the far end): stop
101+
# and settle the UI back to idle, but LEAVE the position alone — this
102+
# point is not home, and zeroing here is what corrupted v2.7.0.
103+
else:
104+
- globals.set: { id: global_state, value: "0" }
105+
- cover.template.publish: { id: ropener_cover, current_operation: IDLE }
106+
- logger.log: "Stall detected outside homing — motor stopped, position unchanged"
107+
61108
script:
62109
# Recompute the derived step cache from the persisted centimeters (one place).
63110
- id: recompute_distance
@@ -298,6 +345,27 @@ binary_sensor:
298345
- delay: 1s
299346
- lambda: "App.safe_reboot();"
300347

348+
# --- Preserve the shipped "Motor Direction" option strings --------------------
349+
# Field units (and any Home Assistant automation that selects a direction) know
350+
# this option as "Reversed ↺" — the glyph is part of the string, not decoration.
351+
# valar-core dropped it to a plain "Reversed", which would silently break those
352+
# automations and leave existing units holding an option that no longer exists.
353+
# `options` is a list, so !extend would APPEND and yield three choices; remove
354+
# then re-add instead. `lambda` is a scalar, so extending overwrites it cleanly.
355+
# Core's set_action matches on `x.find("Reversed")`, which still hits — no
356+
# override needed there.
357+
select:
358+
- id: !extend sel_direction
359+
options: !remove
360+
- id: !extend sel_direction
361+
options:
362+
- "Normal"
363+
- "Reversed ↺"
364+
lambda: |-
365+
if (id(global_stepper_direction) == esphome::tmc2209::ShaftDirection::COUNTERCLOCKWISE)
366+
return std::string("Reversed ↺");
367+
return std::string("Normal");
368+
301369
# --- Rename core tuning entities to Ropener's existing entity_ids -------------
302370
number:
303371
- id: !extend num_irun
@@ -333,6 +401,10 @@ cover:
333401
- platform: template
334402
id: ropener_cover
335403
name: Ropener
404+
# A curtain draws sideways, not up and down. With no device_class the UI
405+
# falls back to generic up/down arrows; "curtain" gets the horizontal
406+
# open/close controls (and the right icon in Home Assistant).
407+
device_class: curtain
336408
web_server: { sorting_weight: 1, sorting_group_id: group_control }
337409
has_position: true
338410
assumed_state: false

‎tools/regression-gate/README.md‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Firmware regression gate
2+
3+
Two diffs between the **old** and **new** resolved firmware config. Run both
4+
before every release. A clean `esphome compile` is necessary but **not
5+
sufficient** — it proves the YAML is valid, not that the firmware still behaves
6+
the same.
7+
8+
## Why the behaviour gate exists
9+
10+
v2.7.0 rewired the Ropener onto `valar-core` as a remote package. The entity
11+
gate passed perfectly: no entity dropped, renamed, or retyped. It shipped.
12+
13+
But the product's `on_stall` handler had been silently replaced by
14+
`valar-core`'s default. Two live bugs resulted:
15+
16+
- the device wedged in `HOMING` forever after any home (killing the button
17+
gestures and the schedule, both of which guard on `global_state`), and
18+
- **every** stall zeroed the position, so a curtain snagging mid-travel
19+
silently redefined that point as home.
20+
21+
No entity name changed, so nothing caught it. The behaviour gate diffs the
22+
*automation bodies* — `on_stall`, `on_press`/`on_click`/`on_release`, script
23+
bodies, `on_boot`, intervals, `*_action`, lambdas — anchored to stable
24+
identities rather than list positions.
25+
26+
It also caught an unrelated change in the same release: the `Motor Direction`
27+
option string `"Reversed ↺"` had lost its glyph, which would have broken any
28+
Home Assistant automation selecting it by value.
29+
30+
## Usage
31+
32+
```sh
33+
# 1. Resolve both configs (from a checkout of each version)
34+
esphome config firmware/VAL3100/Ropener-VAL3100.yml > /tmp/old-3100.yaml
35+
esphome config firmware/VAL3100/Ropener-VAL3100.yml > /tmp/new-3100.yaml
36+
37+
# 2. Diff them
38+
python tools/regression-gate/gate.py /tmp/old-3100.yaml /tmp/new-3100.yaml
39+
40+
# 3. Inspect anything it flags
41+
python tools/regression-gate/gate.py /tmp/old-3100.yaml /tmp/new-3100.yaml \
42+
--detail on_stall
43+
```
44+
45+
Exit code `0` = both gates clean, `1` = something differs. **A difference is not
46+
automatically a failure** — additions and refactors are often intended. The
47+
gate's job is to guarantee nothing changes *unnoticed*. Every flagged line must
48+
be explained in the release notes.
49+
50+
Run it for **each board** (VAL3000 and VAL3100); they share a product layer but
51+
resolve differently.
52+
53+
Requires `pyyaml` — use the interpreter ESPHome is installed under.
54+
55+
## Known-benign differences after the valar-core rewire
56+
57+
Expected when diffing a pre-rewire release (≤ v2.6.4) against a rewired one:
58+
59+
| Anchor | Why |
60+
|---|---|
61+
| 7 diagnostic entities added | `valar-core` shared diagnostics (Restart, Uptime, WiFi Signal, ESP Internal Temperature, IP/SSID/MAC) |
62+
| `esphome:on_boot` split 600 / 400 / -100 | core / product / scheduling layers; relative order preserved |
63+
| `script:schedule_open`, `script:schedule_close` | scheduling-mixin contract; the `global_state != 3` homing guard moved into these scripts |
64+
| `datetime:Open Time`/`Close Time` `on_time`, `interval:60s` | now call the schedule scripts instead of inlining `cover.open`/`cover.close` |
65+
66+
Anything **outside** this table needs justification before release.
67+
68+
## Before Glasscalibur
69+
70+
This tool should move to `valar-motion` so both products share one copy rather
71+
than duplicating it — same rule as the firmware itself. Glasscalibur's overrides
72+
(limit switches, TMP1075 thermal cutoff, LIS2DH12 tamper, buzzer) carry the same
73+
silent-replacement risk as `on_stall` did here, and there a dropped thermal
74+
cutoff or tamper handler is a safety regression, not a UX one.

0 commit comments

Comments
 (0)