Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion custom_components/myhome/cover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions custom_components/myhome/cover_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,13 +354,25 @@ 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
self._freeze_position(time.monotonic())
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 + travel)
confirmed = (
not self._strategy.assumed_state
and self._run_started_at is not None
and travel > 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()
7 changes: 4 additions & 3 deletions custom_components/myhome/cover_strategies/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
54 changes: 50 additions & 4 deletions tests/test_cover_strategy_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
Loading