From ee0cd73e93d00ce168e96eb49e7eadc5ffceada6 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:55:26 +0200 Subject: [PATCH 1/2] fix(cover): confirm end_stop on full-travel bus runs and scope run timeouts --- custom_components/myhome/cover.py | 13 ++++- custom_components/myhome/cover_scope.py | 17 ++++-- .../myhome/cover_strategies/base.py | 7 +-- tests/test_cover_strategy_contract.py | 54 +++++++++++++++++-- 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py index 520a5e65d..e6c379979 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -682,9 +682,18 @@ def _freeze_position(self, at: float) -> None: self._run_started_at = None self._motion_started_at = None self._refresh_travel_attributes() + end_stop = ( + not self._strategy.assumed_state + and run_started_at is not None + and (is_opening or is_closing) + and (travel := self._travel_for(is_opening)) > 0 + and at - run_started_at >= travel + ) curtain_at = max(self._move_start_time, at - lead) if self._move_start_time is not None else at if self._strategy.assumed_state: frozen: int | None = None + elif end_stop: + frozen = 100 if is_opening else 0 elif self._move_start_time is not None and (is_opening or is_closing): frozen_pos = self._strategy.position_after( self._start_position, @@ -712,7 +721,9 @@ def _freeze_position(self, at: float) -> None: self._move_start_time = None self._turn_slats_for_run(at, run_started_at, is_opening, is_closing) if run_started_at is not None and (is_opening or is_closing): - self._strategy.on_run_ended(self, "stop", frozen, at) + self._strategy.on_run_ended(self, "end_stop" if end_stop else "stop", frozen, at) + if end_stop and frozen is not None: + self._strategy.on_position_confirmed(self, frozen, "end_stop") self._attr_is_opening = False self._attr_is_closing = False self._run_start_position = None diff --git a/custom_components/myhome/cover_scope.py b/custom_components/myhome/cover_scope.py index 1d533c260..78f41f8a9 100644 --- a/custom_components/myhome/cover_scope.py +++ b/custom_components/myhome/cover_scope.py @@ -357,10 +357,21 @@ def _end_run(self, _now: Any) -> None: opening = self._attr_is_opening if direction != ("open" if opening else "close" if self._attr_is_closing else None): return # stopped or reversed meanwhile - self._freeze_position(time.monotonic()) + end = 100 if opening else 0 + at = time.monotonic() + if self._run_started_at is not None: + at = max(at, self._run_started_at + self._travel_for(opening)) + confirmed = ( + not self._strategy.assumed_state + and self._run_started_at is not None + and self._travel_for(opening) > 0 + ) + self._freeze_position(at) # A full run by the model; the anchor may trail the timer by the queue delay. - self._attr_current_cover_position = self._start_position = 100 if opening else 0 + self._attr_current_cover_position = self._start_position = end + if not confirmed: + self._strategy.on_position_confirmed(self, end, "end_stop") if self._slat_tilt: - self._attr_current_cover_tilt_position = 100 if opening else 0 + self._attr_current_cover_tilt_position = end self._attr_is_closed = not opening self._publish_state() diff --git a/custom_components/myhome/cover_strategies/base.py b/custom_components/myhome/cover_strategies/base.py index 8dcf3687e..3fcf10814 100644 --- a/custom_components/myhome/cover_strategies/base.py +++ b/custom_components/myhome/cover_strategies/base.py @@ -161,9 +161,10 @@ def restore_state(self, state: dict[str, Any]) -> None: """Restore the state ``export_state`` returned before a restart.""" -#: Why a curtain run ended: a stop frame or stop status, the motor reaching its -#: limit switch (settled on a timer), a new command taking the motor over, the -#: opposite direction seen on the bus, or a command that never reached the bus. +#: Why a curtain run ended: a stop before the end of travel, the motor reaching +#: its limit switch (a full-travel run or end-of-run timer), a new command +#: taking the motor over, the opposite direction seen on the bus, or a command +#: that never reached the bus. RUN_END_REASONS = ("stop", "end_stop", "takeover", "reversal", "abort") diff --git a/tests/test_cover_strategy_contract.py b/tests/test_cover_strategy_contract.py index 07032c4a7..456d6265b 100644 --- a/tests/test_cover_strategy_contract.py +++ b/tests/test_cover_strategy_contract.py @@ -7,15 +7,18 @@ """ from __future__ import annotations +from datetime import timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.cover import CoverEntityFeature from homeassistant.helpers.restore_state import RestoredExtraData +from homeassistant.util import dt as dt_util from OWNd.message import OWNEvent +from pytest_homeassistant_custom_component.common import async_fire_time_changed -from custom_components.myhome.cover import MyHOMECover +from custom_components.myhome.cover import CoverScope, MyHOMECover, MyHOMEScopeCover from custom_components.myhome.cover_motion import END_SETTLE_MARGIN from custom_components.myhome.cover_strategies import ( RUN_END_REASONS, @@ -111,9 +114,11 @@ def _cover(hass, strategy: Any = None, **kwargs: Any) -> MyHOMECover: "travel_time": 13, **kwargs, } + cls = MyHOMEScopeCover if "scope" in args else MyHOMECover with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None): - cover = MyHOMECover(**args) + cover = cls(**args) cover.entity_id = "cover.cover_21" + cover.hass = hass cover.async_write_ha_state = MagicMock() cover.async_schedule_update_ha_state = MagicMock() if strategy is not None: @@ -206,12 +211,15 @@ async def test_piecewise_strategy_drives_cover_without_cover_py_changes(hass) -> assert ended == [ ("stop", 0), ("stop", 20), - ("stop", 0), + ("end_stop", 0), ("stop", 20), ("stop", 36), # frozen at the stop write; the relayed echo then re-anchors to 41 ("end_stop", 100), ] - assert strategy.events[-1] == ("confirmed", 100, "end_stop") + assert [e for e in strategy.events if e[0] == "confirmed"] == [ + ("confirmed", 0, "end_stop"), + ("confirmed", 100, "end_stop"), + ] @pytest.mark.asyncio @@ -257,7 +265,22 @@ async def test_position_unknown_until_a_full_run(hass) -> None: clock.now += 6.0 _event(cover, "*2*0*21##") assert cover.current_cover_position is None + assert strategy.known is False + + # A keypad run that runs for the full travel time hits the limit switch + # and confirms the end stop even though the bus only sends *2*0*WHERE##. + clock.now += 10.0 + _event(cover, "*2*2*21##") + clock.now += 13.0 + _event(cover, "*2*0*21##") + assert strategy.known is True + assert cover.current_cover_position == 0 + assert ("ended", "end_stop", 0, clock.now) in strategy.events + assert ("confirmed", 0, "end_stop") in strategy.events + strategy.known = False + cover._attr_current_cover_position = None + clock.now += 10.0 await cover.async_set_cover_position(position=100) _event(cover, "*2*1*21##") await cover._stop_task @@ -266,6 +289,29 @@ async def test_position_unknown_until_a_full_run(hass) -> None: assert cover.current_cover_position == 100 assert ("confirmed", 100, "end_stop") in strategy.events + # A memberless scope cover's run timeout also confirms the end stop. + scope_strategy = PiecewiseGeometryStrategy(known=False) + scope_cover = _cover(hass, scope_strategy, device_id="#7", where="#7", scope=CoverScope.of("#7")) + scope_cover._attr_current_cover_position = None + _event(scope_cover, "*2*1*#7##") + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=14)) + await hass.async_block_till_done() + assert scope_strategy.known is True + assert scope_cover.current_cover_position == 100 + assert [e[:3] for e in scope_strategy.events if e[0] in ("ended", "confirmed")] == [ + ("ended", "end_stop", 100), + ("confirmed", 100, "end_stop"), + ] + + # And when the timeout fires before a write anchor is set. + scope_strategy.known = False + scope_cover._attr_is_closing = True + scope_cover._run_timeout_direction = "close" + scope_cover._end_run(None) + assert scope_strategy.known is True + assert scope_cover.current_cover_position == 0 + assert scope_strategy.events[-1] == ("confirmed", 0, "end_stop") + @pytest.mark.asyncio async def test_strategy_state_survives_a_restart(hass) -> None: From 0a7b2342e49661f16e30c45e6a3e57dd5f233914 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:00:27 +0200 Subject: [PATCH 2/2] fix(cover): coerce _attr_is_opening to bool in MyHOMEScopeCover._end_run for mypy --- custom_components/myhome/cover_scope.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/custom_components/myhome/cover_scope.py b/custom_components/myhome/cover_scope.py index 78f41f8a9..bd18ee003 100644 --- a/custom_components/myhome/cover_scope.py +++ b/custom_components/myhome/cover_scope.py @@ -354,17 +354,18 @@ def _end_run(self, _now: Any) -> None: direction = self._run_timeout_direction self._run_timeout = None self._run_timeout_direction = None - opening = self._attr_is_opening + opening = bool(self._attr_is_opening) if direction != ("open" if opening else "close" if self._attr_is_closing else None): return # stopped or reversed meanwhile end = 100 if opening else 0 + travel = self._travel_for(opening) at = time.monotonic() if self._run_started_at is not None: - at = max(at, self._run_started_at + self._travel_for(opening)) + at = max(at, self._run_started_at + travel) confirmed = ( not self._strategy.assumed_state and self._run_started_at is not None - and self._travel_for(opening) > 0 + and travel > 0 ) self._freeze_position(at) # A full run by the model; the anchor may trail the timer by the queue delay.