Skip to content

Commit 1be9ccd

Browse files
fix: send stop for inline tilt at a travel endpoint in self-stopping modes (#142) (#143)
In inline tilt mode the slats are articulated by the main travel motor, so a tilt move drives the travel relay. When the cover is parked at a travel endpoint (fully open/closed) — the usual case when adjusting slats — the tilt move drives the motor *off* its limit switch, so it will NOT self-stop there. The self-stopping relay modes (toggle, pulse, wrapped — _self_stops_at_endpoints() True) took the endpoint self-stop branch in auto_stop_if_necessary, which skips the relay stop on the assumption the motor is seated against its physical limit. For a tilt move that assumption is wrong, so the stop was never sent and the cover ran on to the full endpoint (e.g. tilting open while fully closed rolled the blind all the way up). #125 fixed the analogous overshoot for switch mode by excluding tilt moves from the adjacent run-on branch (not self._moving_tilt). Hoist that exclusion into a shared `endpoint_applies = _at_endpoint(...) and not _moving_tilt` predicate and gate both endpoint branches on it, so a tilt move at an endpoint always falls through to the explicit stop. Real travel moves to an endpoint still skip the redundant stop (toggle re-pulse would restart the motor — #105). Add tests covering inline tilt at the closed and open endpoints in a self-stopping mode, plus a regression guard that a travel move to an endpoint still skips the stop.
1 parent 3ce556e commit 1be9ccd

3 files changed

Lines changed: 99 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## Unreleased
2+
3+
### Fixes
4+
5+
- **Inline tilt at a parked endpoint no longer rolls the cover fully open/closed** ([#142](https://github.com/Sese-Schneider/ha-cover-time-based/issues/142)): the companion to [#125](https://github.com/Sese-Schneider/ha-cover-time-based/issues/125). In **inline** tilt mode the slats are articulated by the main travel motor, so a tilt move adjusting the slats while the cover is parked at a travel endpoint (fully open or fully closed) drives the motor *off* its limit switch. #125 fixed the run-on overshoot for **Switch** mode, but the self-stopping modes (**Toggle**, **Pulse** and **wrapped** covers) took a different branch: at an endpoint they skip the stop entirely, assuming the motor is still seated against its physical limit and self-stops there. For a tilt move that assumption is wrong — the motor has just been driven off the limit — so the stop was never sent and the cover ran on to the full endpoint (e.g. tilting the slats open while fully closed rolled the blind all the way up). The stop is now sent for tilt moves at an endpoint in these modes (mirroring the run-on exclusion #125 added), so the motor stops at the requested tilt; real *travel* moves to an endpoint still skip the redundant stop as before.
6+
17
## 4.7.0 (2026-06-29)
28

39
### Features

custom_components/cover_time_based/cover_base.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1447,6 +1447,16 @@ async def auto_stop_if_necessary(self):
14471447
return
14481448

14491449
current_travel = self.travel_calc.current_position()
1450+
# Endpoint handling (the self-stop skip and the run-on below) is a
1451+
# *travel* concept: it only applies when a travel move reaches a
1452+
# physical limit. A tilt move that merely finishes while the cover is
1453+
# parked at a travel endpoint has driven the motor *off* that limit
1454+
# to articulate the slats, so the motor will NOT self-stop there and
1455+
# must always be stopped explicitly — never given the self-stop skip
1456+
# (issue #142) or the run-on (issue #125).
1457+
endpoint_applies = (
1458+
self._at_endpoint(current_travel) and not self._moving_tilt
1459+
)
14501460
if self._motor_stops_itself():
14511461
# The device drives to the target and holds there on its own
14521462
# (e.g. a wrapped cover commanded via set_cover_position). Any
@@ -1461,7 +1471,7 @@ async def auto_stop_if_necessary(self):
14611471
# travel stop below and re-pulse the travel relay off a stale
14621472
# _last_command.
14631473
await self._tilt_settle()
1464-
elif self._at_endpoint(current_travel) and self._self_stops_at_endpoints():
1474+
elif endpoint_applies and self._self_stops_at_endpoints():
14651475
# The motor self-stops at its physical limit switch. Sending a
14661476
# stop here is redundant (and for toggle re-pulses → restart),
14671477
# so skip the relay stop and any run-on; just settle the
@@ -1472,8 +1482,7 @@ async def auto_stop_if_necessary(self):
14721482
current_travel,
14731483
)
14741484
elif (
1475-
self._at_endpoint(current_travel)
1476-
and not self._moving_tilt
1485+
endpoint_applies
14771486
and self._endpoint_runon_time is not None
14781487
and self._endpoint_runon_time > 0
14791488
):

tests/test_endpoint_self_stop.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -845,3 +845,84 @@ async def test_stop_during_tilt_then_travel_still_runs_on(make_cover):
845845
"travel run-on must fire after a tilt move was committed then stopped"
846846
)
847847
await _cancel_tasks(cover)
848+
849+
850+
# ===================================================================
851+
# Inline tilt at a travel endpoint, self-stopping modes (issue #142)
852+
#
853+
# #125 (above) fixed inline tilt at an endpoint for the *latched* modes
854+
# (switch), whose _self_stops_at_endpoints() is False — those hit the
855+
# run-on branch. The *self-stopping* modes (toggle/pulse/wrapped), whose
856+
# _self_stops_at_endpoints() is True, hit the earlier self-stop branch,
857+
# which skipped the stop on the assumption the motor sits against its
858+
# limit. But an inline tilt move drives the motor *off* the limit to
859+
# articulate the slats, so it does NOT self-stop — skipping the stop
860+
# lets it run on to the full endpoint (the cover rolls all the way
861+
# up/down instead of just tilting).
862+
# ===================================================================
863+
864+
865+
@pytest.mark.asyncio
866+
async def test_inline_tilt_at_closed_endpoint_self_stopping_mode_stops(make_cover):
867+
"""Toggle + inline: tilting open while parked fully closed drives the motor
868+
off the bottom limit, so it won't self-stop — the stop must be sent (a
869+
toggle re-pulse) or the cover rolls all the way up (issue #142)."""
870+
cover = _make_inline(make_cover, CONTROL_MODE_TOGGLE)
871+
cover.travel_calc.set_position(0) # parked fully closed (a travel endpoint)
872+
cover.tilt_calc.set_position(0)
873+
874+
with patch.object(cover, "async_write_ha_state"):
875+
await cover.set_tilt_position(50) # tilt open, off the closed endpoint
876+
cover.hass.services.async_call.reset_mock()
877+
cover.tilt_calc.set_position(50) # tilt reaches its 50% target
878+
await cover.auto_stop_if_necessary()
879+
880+
on_calls = [c for c in _ha_calls(cover) if c.args[1] == "turn_on"]
881+
assert on_calls, (
882+
"inline tilt at a travel endpoint must stop the motor — it was driven "
883+
"off the limit and won't self-stop (issue #142)"
884+
)
885+
await _cancel_tasks(cover)
886+
887+
888+
@pytest.mark.asyncio
889+
async def test_inline_tilt_at_open_endpoint_self_stopping_mode_stops(make_cover):
890+
"""The same bug bites at the open endpoint: tilting closed while parked
891+
fully open drives the motor off the top limit and must be stopped, or the
892+
cover rolls all the way down (issue #142)."""
893+
cover = _make_inline(make_cover, CONTROL_MODE_TOGGLE)
894+
cover.travel_calc.set_position(100) # parked fully open (a travel endpoint)
895+
cover.tilt_calc.set_position(100)
896+
897+
with patch.object(cover, "async_write_ha_state"):
898+
await cover.set_tilt_position(50) # tilt closed, off the open endpoint
899+
cover.hass.services.async_call.reset_mock()
900+
cover.tilt_calc.set_position(50)
901+
await cover.auto_stop_if_necessary()
902+
903+
on_calls = [c for c in _ha_calls(cover) if c.args[1] == "turn_on"]
904+
assert on_calls, "inline tilt at the open endpoint must stop the motor (issue #142)"
905+
await _cancel_tasks(cover)
906+
907+
908+
@pytest.mark.asyncio
909+
async def test_inline_travel_to_endpoint_self_stopping_mode_skips_stop(make_cover):
910+
"""The fix is tilt-move only: a real *travel* move to an endpoint in a
911+
self-stopping mode must still skip the stop (the motor self-stops at its
912+
limit; a toggle re-pulse would restart it — issue #105)."""
913+
cover = _make_inline(make_cover, CONTROL_MODE_TOGGLE)
914+
cover.travel_calc.set_position(50)
915+
cover.tilt_calc.set_position(0) # already at the closing tilt endpoint
916+
917+
with patch.object(cover, "async_write_ha_state"):
918+
await cover.set_position(0) # pure travel to fully closed
919+
cover.hass.services.async_call.reset_mock()
920+
cover.travel_calc.set_position(0)
921+
cover.tilt_calc.set_position(0)
922+
await cover.auto_stop_if_necessary()
923+
924+
assert _ha_calls(cover) == [], (
925+
"a travel move to an endpoint must still self-stop (no re-pulse)"
926+
)
927+
assert cover._delay_task is None
928+
await _cancel_tasks(cover)

0 commit comments

Comments
 (0)