From 3f88a790a5ae2517d51e35ee26edbe6d0ac6bd7c Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 15:55:56 +0300 Subject: [PATCH 1/9] fix(cover): a timed cover with no restored state starts unknown A WHO 2 timed cover with no restored state was created as `open` with `current_position 50`. The actuator cannot report a position (an F411/2 on a physical F461 2.0.11 answers `*#2*05*10##` with an ACK and no data), so the 50 was invented, and a `set_cover_position` before the first full run was timed from it. The position, `is_closed` and the run's start position now begin as None (HA renders `unknown`). A run from an unknown position stays unknown unless it reaches an end stop (a run of at least the travel time), which anchors 0 / 100 as before. With an unknown position, `set_cover_position` 0 / 100 run to the end stop; any other target is refused with the translated `cover_position_unknown` error and sends no frame. The restored position from the last HA run is unchanged. The stop echo of a run from an unknown position still records `last_run_seconds` but re-anchors no position. Verified on the F461 plant: fresh entity `unknown`, position 40 refused with no frame on the bus, `open_cover` -> `open 100` at the actuator's stop, then 40 -> 39 % and `close_cover` -> `closed 0`. Co-Authored-By: Claude Fable 5.1 --- .github/release-notes/2.0.0b16.md | 1 + custom_components/myhome/cover.py | 65 ++++-- .../myhome/cover_strategies/base.py | 5 +- .../myhome/cover_strategies/timed.py | 12 +- custom_components/myhome/strings.json | 3 + custom_components/myhome/translations/en.json | 3 + docs/configuration/covers.md | 1 + tests/test_component_cover.py | 2 + tests/test_cover_unknown_start.py | 186 ++++++++++++++++++ 9 files changed, 255 insertions(+), 23 deletions(-) create mode 100644 tests/test_cover_unknown_start.py diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index 196af1a42..d2683a971 100644 --- a/.github/release-notes/2.0.0b16.md +++ b/.github/release-notes/2.0.0b16.md @@ -12,6 +12,7 @@ - **Timed covers: `set_cover_position: 0` / `100` sends no stop frame and settles on a timer.** The motor stops on its limit switch. The entity reports 0 % / 100 % once the stored travel time plus 2 s has passed, instead of waiting for the actuator's own stop status (about 60 s late on a MyHOMEServer1, never on a gateway that does not relay it). Automations waiting for `open` / `closed` see the change at that point, which can be a few seconds before a slow curtain physically arrives. The open and close buttons are unchanged. - **Small moves are widened or stretched, so they are no longer exact.** A timed cover runs at least its `min_motor_pulse` (default 0.4 s, set per cover in YAML or with `myhome.set_cover_travel_time`; `0` turns it off), and the estimate books that longer run (50 % → 51 % on a 5 s cover ends at 58 % with the default). An advanced cover is sent at least a 2 % level change (50 % → 51 % is sent as 52 %). A move whose run reaches an end becomes a full run to that end. Sending the cover to 0 % or 100 % re-syncs it (#708). - **No widening while an advanced cover is moving.** While the actuator runs it reports the level it started from, so a retarget goes out as asked (40 → 80, then `set_cover_position: 40` sends the level frame). +- **A timed cover with no restored state starts `unknown`, not `open` at 50 %.** The actuator cannot report its position, so none is invented. It becomes known at the first end stop (open / close buttons, `set_cover_position: 0` / `100`, or a keypad run that lasts the full travel time); until then `set_cover_position` with another target is refused with a translated error and sends nothing. The position restored from the last run of Home Assistant is kept. - **Timed runs end at the actuator's stop status.** When the gateway relays `*2*0*WHERE##` after a stop, the position and `last_run_seconds` are taken from that status instead of the moment the stop was written, which was about 0.5 s short per run on a MyHOMEServer1 (#707). A keypad run that lasts the full travel time is now reported as reaching the end (#760). See the cover docs for the details and the known limits. diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py index fac7e4630..57cbbe0b8 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -513,16 +513,18 @@ def __init__( # type: ignore elif isinstance(self._strategy, NativePositionStrategy): self._attr_extra_state_attributes["min_position_delta"] = self._strategy.min_position_delta - self._attr_current_cover_position = None if self._strategy.assumed_state else 50 + # Unknown until a restored state, an actuator report or a full run to an end + # stop says otherwise: a timed actuator (F411/2) cannot report its position. + self._attr_current_cover_position = None self._attr_is_opening = False self._attr_is_closing = False - self._attr_is_closed = False + self._attr_is_closed = None self._move_start_time: float | None = None # Anchor of the current run (motor start), untouched by the set_position # re-anchor: the last completed run is measured from it (stopwatch). self._run_started_at: float | None = None - self._start_position = 50 + self._start_position: int | None = None self._stop_task: asyncio.Task[None] | None = None # Echo model state (see ECHO_WINDOW): which command of ours is in @@ -750,23 +752,30 @@ def _freeze_position(self, at: float) -> None: 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 + # None: the run started at an unknown position, which only an end stop resolves. + start_position = self._start_position 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, - max(0.0, at - self._move_start_time), - "open" if is_opening else "close", - entity=self, - lead=lead, - ) - # None: the strategy does not know the position (yet). - frozen = frozen_pos + if start_position is None: + # A run from an unknown position that did not reach an end stop. + frozen = None + else: + # None: the strategy does not know the position (yet). + frozen = self._strategy.position_after( + start_position, + max(0.0, at - self._move_start_time), + "open" if is_opening else "close", + entity=self, + lead=lead, + ) + elif start_position is None: + frozen = self._attr_current_cover_position else: frozen = compute_freeze_position( - self._start_position, + start_position, self._move_start_time, curtain_at, self._curtain_travel_for(is_opening), @@ -796,6 +805,9 @@ def _freeze_position(self, at: float) -> None: # No position to derive it from: an assumed-state cover is closed after # a closing run and open after an opening one, as HA's optimistic covers. self._attr_is_closed = is_closing + elif not self._advanced and (is_opening or is_closing): + # A positioned cover whose position is still unknown: so is its state. + self._attr_is_closed = None def _abort_motion(self) -> None: """Abort motion state when a command frame failed to reach the bus.""" @@ -1500,7 +1512,8 @@ async def _async_move(self, direction: str, is_tilting: bool = False) -> asyncio self._attr_is_closing = direction == "close" elif not self._advanced: self._end_curtain_for_takeover(time.monotonic()) - self._start_position = self.current_cover_position if self.current_cover_position is not None else (0 if direction == "open" else 100) + # None while the position is unknown: only an end stop anchors that run. + self._start_position = self.current_cover_position self._run_start_position = self._start_position self._run_start_tilt = None self._run_lead = self._move_lead = 0.0 @@ -1649,7 +1662,22 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: translation_placeholders={"entity_id": str(self.entity_id)}, ) - curr_pos = self.current_cover_position if self.current_cover_position is not None else 50 + curr_pos = self.current_cover_position + if curr_pos is None: + # Nothing to time a partial run from: 0% and 100% are full runs that + # anchor the position on the end stop, anything else waits for one. + if target_position >= 100: + await self._async_run_to_end(True) + return + if target_position <= 0: + await self._async_run_to_end(False) + return + raise ServiceValidationError( + f"{self._display_name}: the position is unknown until the cover has run fully open or closed once", + translation_domain=DOMAIN, + translation_key="cover_position_unknown", + translation_placeholders={"name": self._display_name}, + ) diff = target_position - curr_pos if diff == 0 and self._is_tilting: # Already at this level while a slat pulse runs: stop the pulse here. @@ -2159,7 +2187,8 @@ def _handle_echo(self, message: OWNMessage, now: float) -> bool: self._auto_stop_duration = None self._last_stop_at = now self._stopped_event.set() - if ctx and ctx.get("run_started_at") is not None and ctx.get("run_start_position") is not None: + if ctx and ctx.get("run_started_at") is not None: + # run_start_position is None for a run from an unknown position. self._strategy.reanchor_on_stop_echo(self, ctx, now) return True # Opposite or external command during stop echo window: end window and handle externally. @@ -2289,7 +2318,7 @@ def handle_event(self, message: OWNMessage) -> None: self._cancel_stop_task() self._run_generation += 1 if not self._advanced and not self._attr_is_opening: - self._start_position = self.current_cover_position if self.current_cover_position is not None else 0 + self._start_position = self.current_cover_position if was_closing: self._turn_slats_for_run(now, self._run_started_at, False, True) self._end_run_on_reversal(now) @@ -2319,7 +2348,7 @@ def handle_event(self, message: OWNMessage) -> None: self._cancel_stop_task() self._run_generation += 1 if not self._advanced and not self._attr_is_closing: - self._start_position = self.current_cover_position if self.current_cover_position is not None else 100 + self._start_position = self.current_cover_position if was_opening: self._turn_slats_for_run(now, self._run_started_at, True, False) self._end_run_on_reversal(now) diff --git a/custom_components/myhome/cover_strategies/base.py b/custom_components/myhome/cover_strategies/base.py index 3fcf10814..5a16a9547 100644 --- a/custom_components/myhome/cover_strategies/base.py +++ b/custom_components/myhome/cover_strategies/base.py @@ -131,7 +131,10 @@ def reanchor_on_stop_echo( ctx: dict[str, Any], now: float, ) -> int | None: - """Re-anchor position (and slat tilt if applicable) on a relayed stop status echo (#705).""" + """Re-anchor position (and slat tilt if applicable) on a relayed stop status echo (#705). + + ``ctx["run_start_position"]`` is ``None`` for a run that started at an unknown position. + """ def current_position(self, entity: Any, now: float) -> int | None: """Return current cover position (0..100) or None if position is unknown.""" diff --git a/custom_components/myhome/cover_strategies/timed.py b/custom_components/myhome/cover_strategies/timed.py index 9115f26ed..59586a2a1 100644 --- a/custom_components/myhome/cover_strategies/timed.py +++ b/custom_components/myhome/cover_strategies/timed.py @@ -127,11 +127,12 @@ def reanchor_on_stop_echo( now: float, ) -> int | None: """Re-anchor cover position and travel telemetry on a relayed stop status echo (#705, #707).""" - if not ctx or ctx.get("run_started_at") is None or ctx.get("run_start_position") is None: + if not ctx or ctx.get("run_started_at") is None: pos: int | None = getattr(entity, "_attr_current_cover_position", None) return pos start_anchor = float(ctx["run_started_at"]) - run_start_pos = int(ctx["run_start_position"]) + # None: the run started at an unknown position; only the run is measured. + run_start_pos = int(ctx["run_start_position"]) if ctx.get("run_start_position") is not None else None is_opening = bool(ctx.get("is_opening")) is_closing = bool(ctx.get("is_closing")) auto_dur = ctx.get("auto_stop_duration") @@ -141,7 +142,7 @@ def reanchor_on_stop_echo( direction = "open" if is_opening else "close" if is_closing else "" travel_time = self.curtain_travel_for(is_opening) reanchored: int | None = getattr(entity, "_attr_current_cover_position", None) - if travel_time > 0 and (is_opening or is_closing): + if travel_time > 0 and (is_opening or is_closing) and run_start_pos is not None: reanchored = self.position_after( run_start_pos, elapsed, direction, entity=entity, lead=lead ) @@ -176,12 +177,15 @@ def current_position(self, entity: Any, now: float) -> int | None: and (is_opening or is_closing) and self.curtain_travel_for(is_opening) > 0 ): + start = getattr(entity, "_start_position", None) + if start is None: + return None # a run from an unknown position: known again at the end stop direction = "open" if is_opening else "close" elapsed = max(0.0, now - float(move_start)) # The lead still ahead of this segment, as the entity anchored it. move_lead = getattr(entity, "_move_lead", None) return self.position_after( - int(getattr(entity, "_start_position", 50)), + int(start), elapsed, direction, entity=entity, diff --git a/custom_components/myhome/strings.json b/custom_components/myhome/strings.json index 13c79d4e3..212903722 100644 --- a/custom_components/myhome/strings.json +++ b/custom_components/myhome/strings.json @@ -569,6 +569,9 @@ "cover_busy_calibrating": { "message": "{entity_id} is being calibrated; try again when it has finished" }, + "cover_position_unknown": { + "message": "{name}: the position is unknown until the cover has run fully open or closed once" + }, "cover_busy_moving": { "message": "{name} is moving; stop it before tilting the slats" }, diff --git a/custom_components/myhome/translations/en.json b/custom_components/myhome/translations/en.json index 13c79d4e3..212903722 100644 --- a/custom_components/myhome/translations/en.json +++ b/custom_components/myhome/translations/en.json @@ -569,6 +569,9 @@ "cover_busy_calibrating": { "message": "{entity_id} is being calibrated; try again when it has finished" }, + "cover_position_unknown": { + "message": "{name}: the position is unknown until the cover has run fully open or closed once" + }, "cover_busy_moving": { "message": "{name} is moving; stop it before tilting the slats" }, diff --git a/docs/configuration/covers.md b/docs/configuration/covers.md index bec4ac474..8eb073489 100644 --- a/docs/configuration/covers.md +++ b/docs/configuration/covers.md @@ -64,6 +64,7 @@ Covers set up in the UI (without `myhome.yaml`) change their device class in Hom For standard covers without hardware position feedback, the integration provides a software estimator enabling full `open_cover`, `close_cover`, `stop_cover`, and slider-based `set_cover_position` support: +* **Unknown Until the First Full Run**: A timed actuator cannot report where the curtain is (an F411/2 answers `*#2**10##` with an ACK and no data), so a cover with no restored state starts as `unknown`, with no position, instead of an invented `open` at 50 %. The restored position from the last run of Home Assistant is kept. The position becomes known at an end stop: `open_cover`, `close_cover`, `set_cover_position: 0` / `100` or a keypad run that lasts the full travel time; a keypad stop before that leaves it unknown. While it is unknown, `set_cover_position` with any other target is refused (*"… the position is unknown until the cover has run fully open or closed once"*) and sends nothing, because there is no position to time the run from. * **Direction-Aware Travel Times**: Because gravity and motor friction cause shutters to fall faster than they rise, the v2 engine tracks separate `travel_time_down` and `travel_time_up` durations (default: `25.0s`). * **Frame Anchor Timing**: The run timer starts when the direction frame is **written to the gateway**, not when Home Assistant queues it. * **Echo Suppression**: The gateway relays a momentary stop status (~0.1 s) followed by translation and the actual motor start (~0.55 s). The v2 engine recognizes these as command echoes, re-anchoring the timer to the true motor start rather than falsely treating them as manual stop commands. diff --git a/tests/test_component_cover.py b/tests/test_component_cover.py index b6c2ee70d..16f707f0f 100644 --- a/tests/test_component_cover.py +++ b/tests/test_component_cover.py @@ -761,6 +761,8 @@ def test_compute_run_duration(self): assert compute_run_duration(1, 20.0, min_pulse=0.5) == 0.5 def test_handle_event(self, basic_cover): + # A known start: a timed cover with no history has no position (F411/2). + basic_cover._attr_current_cover_position = 50 # Opening event msg_opening = OWNEvent.parse("*2*1*21##") basic_cover.handle_event(msg_opening) diff --git a/tests/test_cover_unknown_start.py b/tests/test_cover_unknown_start.py new file mode 100644 index 000000000..0ad5f1f64 --- /dev/null +++ b/tests/test_cover_unknown_start.py @@ -0,0 +1,186 @@ +"""A timed cover with no restored state starts unknown, not "open 50 %" (F411/2 on a physical F461 2.0.11).""" +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from homeassistant.components.cover import ATTR_CURRENT_POSITION +from homeassistant.core import State +from homeassistant.exceptions import ServiceValidationError +from OWNd.message import OWNEvent + +from custom_components.myhome.cover import MyHOMECover + + +class Clock: + def __init__(self): + self.now = 0.0 + + +@pytest.fixture +def clock(): + return Clock() + + +@pytest.fixture +def fake_time(clock): + with patch("custom_components.myhome.cover.time") as mock_time: + mock_time.monotonic.side_effect = lambda: clock.now + yield mock_time + + +@pytest.fixture +def gateway(): + gw = MagicMock() + gw.mac = "00:03:50:00:00:05" + gw.log_id = "[F461 gateway - test]" + gw.availability_signal = "myhome_avail" + gw.available = True + gw.device_registry_id = None + gw.send_status_request = AsyncMock() + gw.config_entry = MagicMock() + gw.config_entry.options = {} + gw.deliveries = [] + + async def _send(message): + fut = asyncio.get_running_loop().create_future() + gw.deliveries.append((str(message), fut)) + return fut + + gw.send = AsyncMock(side_effect=_send) + return gw + + +async def _yield(n=4): + for _ in range(n): + await asyncio.sleep(0) + + +def _make_cover(hass, gateway, **kwargs): + """A timed WHO 2 cover as the F461 plant has it: F411/2 at WHERE 05, yaml travel_time 6.""" + kwargs.setdefault("name", "Shutter 05") + kwargs.setdefault("entity_name", None) + kwargs.setdefault("device_id", "05") + kwargs.setdefault("who", "2") + kwargs.setdefault("where", "05") + kwargs.setdefault("interface", None) + kwargs.setdefault("advanced", False) + kwargs.setdefault("manufacturer", "BTicino") + kwargs.setdefault("model", "F411/2") + kwargs.setdefault("travel_time", 6) + cover = MyHOMECover(hass=hass, gateway=gateway, **kwargs) + cover.hass = hass + cover.entity_id = "cover.shutter_05" + cover.async_write_ha_state = MagicMock() + cover.async_schedule_update_ha_state = MagicMock() + return cover + + +async def _close_from_unknown(cover, gateway, clock): + """close_cover on a cover with no position, with the F461's direction echo 0.12 s after the write.""" + await cover.async_close_cover() + raw, written = gateway.deliveries[-1] + assert raw == "*2*2*05##" + written.set_result(clock.now) + await _yield() + clock.now += 0.12 + cover.handle_event(OWNEvent.parse("*2*2*05##")) # direction echo (F461: 0.10-0.15 s) + assert cover.is_closing is True + assert cover.current_cover_position is None # still unknown while it runs + + +async def test_timed_cover_without_history_is_unknown(hass, gateway) -> None: + """No restored state: no invented 50 %, and HA shows the state as unknown.""" + cover = _make_cover(hass, gateway) + cover.async_get_last_state = AsyncMock(return_value=None) + await cover.async_added_to_hass() + assert cover.current_cover_position is None + assert cover.is_closed is None + assert cover.state is None # rendered "unknown" + + +async def test_close_from_unknown_reports_closed_after_the_end_stop(hass, gateway, clock, fake_time) -> None: + """close_cover from an unknown position, the actuator's stop after a full travel: 0 % (closed).""" + cover = _make_cover(hass, gateway) + await _close_from_unknown(cover, gateway, clock) + clock.now += 6.5 + cover.handle_event(OWNEvent.parse("*2*0*05##")) # the actuator stops at its end stop + assert cover.current_cover_position == 0 + assert cover.is_closed is True + + +async def test_stop_from_ha_during_a_run_from_unknown_stays_unknown(hass, gateway, clock, fake_time) -> None: + """stop_cover before the end stop: the run is measured, but no position is invented from it.""" + cover = _make_cover(hass, gateway) + await _close_from_unknown(cover, gateway, clock) + clock.now += 2.0 + await cover.async_stop_cover() + raw, stop_written = gateway.deliveries[-1] + assert raw == "*2*0*05##" + stop_written.set_result(clock.now) + await _yield() + assert cover.current_cover_position is None + assert cover.is_closed is None + clock.now += 0.05 + cover.handle_event(OWNEvent.parse("*2*0*05##")) # the relayed stop status of our own stop + assert cover.current_cover_position is None + assert cover.is_closed is None + assert cover.extra_state_attributes["last_run_seconds"] == pytest.approx(2.05) + assert cover.extra_state_attributes["last_run_direction"] == "close" + + +async def test_partial_run_from_unknown_stays_unknown(hass, gateway, clock, fake_time) -> None: + """A keypad stop before a full travel leaves nothing to anchor: still unknown, not a guess.""" + cover = _make_cover(hass, gateway) + cover.handle_event(OWNEvent.parse("*2*1*05##")) # opened from a wall switch + clock.now += 2.0 + cover.handle_event(OWNEvent.parse("*2*0*05##")) + assert cover.current_cover_position is None + assert cover.is_closed is None + # A full run anchors it. + cover.handle_event(OWNEvent.parse("*2*1*05##")) + clock.now += 7.0 + cover.handle_event(OWNEvent.parse("*2*0*05##")) + assert cover.current_cover_position == 100 + assert cover.is_closed is False + + +async def test_set_position_before_the_position_is_known(hass, gateway, clock, fake_time) -> None: + """A partial target is refused without a frame; 0 % and 100 % run to the end stop.""" + cover = _make_cover(hass, gateway) + with pytest.raises(ServiceValidationError) as err: + await cover.async_set_cover_position(position=40) + assert err.value.translation_key == "cover_position_unknown" + assert gateway.deliveries == [] + + await cover.async_set_cover_position(position=0) + assert [raw for raw, _ in gateway.deliveries] == ["*2*2*05##"] + cover._cancel_stop_task() + await cover.async_set_cover_position(position=100) + assert [raw for raw, _ in gateway.deliveries] == ["*2*2*05##", "*2*1*05##"] + cover._cancel_stop_task() + + +async def test_restored_position_is_kept(hass, gateway) -> None: + """Restore from the last state still wins over unknown.""" + cover = _make_cover(hass, gateway) + cover.async_get_last_state = AsyncMock(return_value=State("cover.x", "open", {ATTR_CURRENT_POSITION: 30})) + await cover.async_added_to_hass() + assert cover.current_cover_position == 30 + assert cover.is_closed is False + + +async def test_partial_run_from_a_known_position_keeps_a_known_position(hass, gateway, clock, fake_time) -> None: + """Regression guard: a known start (50 %) is still timed; only an unknown start stays unknown.""" + cover = _make_cover(hass, gateway) + cover._attr_current_cover_position = 50 + cover._attr_is_closed = False + cover.handle_event(OWNEvent.parse("*2*1*05##")) # opened from a wall switch + assert cover.is_opening is True + assert cover.current_cover_position is not None # timed from the known 50 while it runs + clock.now += 2.0 + cover.handle_event(OWNEvent.parse("*2*0*05##")) # stopped before the end stop + assert cover.current_cover_position is not None + assert 50 < cover.current_cover_position < 100 + assert cover.is_closed is False From 18ffe58da7aaf81330130c148e97ccfc71a71620 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 22:54:23 +0300 Subject: [PATCH 2/9] fix(cover): a lost direction frame leaves an unknown cover unknown `_async_move` sets `is_closed` to False before the frame is written, and `_abort_motion` only put it back when a position existed. A cover with no position (unknown, or an assumed-state cover) whose frame failed, was cancelled or timed out therefore flipped from `unknown` (or its restored `closed`) to `open` although nothing reached the bus. `_async_move` now keeps the state it found, and `_abort_motion` restores it when there is no position to derive the state from. Review 5639677952 on #795, finding 1. Tests: delivery failure and write timeout from unknown keep `is_closed None` and the state unknown with one frame queued and none more; a simple cover restored as `closed` goes back to `closed`. The three fail before the fix (`False is None`, `False is True`). Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/cover.py | 9 ++++++ tests/test_cover_unknown_start.py | 49 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py index 57cbbe0b8..6ac1ebed0 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -525,6 +525,9 @@ def __init__( # type: ignore # re-anchor: the last completed run is measured from it (stopwatch). self._run_started_at: float | None = None self._start_position: int | None = None + # is_closed before the last direction command: restored when its frame + # never reaches the bus and there is no position to derive it from. + self._is_closed_before_move: bool | None = None self._stop_task: asyncio.Task[None] | None = None # Echo model state (see ECHO_WINDOW): which command of ours is in @@ -829,6 +832,11 @@ def _abort_motion(self) -> None: if self._attr_current_cover_position is not None: self._start_position = self._attr_current_cover_position self._attr_is_closed = (self._attr_current_cover_position == 0) + else: + # No position to derive the state from (unknown, or an assumed-state + # cover): nothing moved, so the state is what it was before the command. + self._start_position = None + self._attr_is_closed = self._is_closed_before_move self._refresh_travel_attributes() self._publish_state() @@ -1527,6 +1535,7 @@ async def _async_move(self, direction: str, is_tilting: bool = False) -> asyncio self._refresh_travel_attributes() self._attr_is_opening = direction == "open" self._attr_is_closing = direction == "close" + self._is_closed_before_move = self._attr_is_closed self._attr_is_closed = False self._begin_command(direction) written = await self._gateway_handler.send(command) diff --git a/tests/test_cover_unknown_start.py b/tests/test_cover_unknown_start.py index 0ad5f1f64..4855720fe 100644 --- a/tests/test_cover_unknown_start.py +++ b/tests/test_cover_unknown_start.py @@ -184,3 +184,52 @@ async def test_partial_run_from_a_known_position_keeps_a_known_position(hass, ga assert cover.current_cover_position is not None assert 50 < cover.current_cover_position < 100 assert cover.is_closed is False + + +async def test_delivery_failure_from_unknown_keeps_the_state_unknown(hass, gateway) -> None: + """A close whose frame never reached the bus leaves the cover unknown, not open (review 5639677952).""" + cover = _make_cover(hass, gateway) + await cover.async_close_cover() + raw, written = gateway.deliveries[-1] + assert raw == "*2*2*05##" + assert cover.is_closing is True + written.set_exception(ConnectionError("gateway connection lost")) + await _yield() + assert cover.is_closing is False + assert cover.is_closed is None + assert cover.current_cover_position is None + assert cover.state is None # rendered "unknown" + assert len(gateway.deliveries) == 1 # nothing else was sent + + +async def test_delivery_timeout_from_unknown_keeps_the_state_unknown(hass, gateway) -> None: + """set_cover_position 0 whose frame is never written: the settle task aborts to unknown.""" + cover = _make_cover(hass, gateway) + with patch("custom_components.myhome.cover.WRITE_TIMEOUT", 0.01): + await cover.async_set_cover_position(position=0) + raw, written = gateway.deliveries[-1] + assert raw == "*2*2*05##" + assert cover.is_closing is True + await asyncio.wait_for(cover._stop_task, 1.0) # the never-written frame times out + assert cover.is_closing is False + assert cover.is_closed is None + assert cover.current_cover_position is None + assert cover.state is None + assert len(gateway.deliveries) == 1 + written.cancel() + + +async def test_delivery_failure_from_a_restored_closed_state_restores_it(hass, gateway) -> None: + """A simple cover restored as closed goes back to closed when its open frame is lost.""" + cover = _make_cover(hass, gateway, travel_time=0) + cover.async_get_last_state = AsyncMock(return_value=State("cover.x", "closed", {})) + await cover.async_added_to_hass() + assert cover.is_closed is True + await cover.async_open_cover() + raw, written = gateway.deliveries[-1] + assert raw == "*2*1*05##" + assert cover.is_closed is False # optimistic while the frame is queued + written.set_exception(ConnectionError("gateway connection lost")) + await _yield() + assert cover.is_opening is False + assert cover.is_closed is True From ceb275a36942a977f01c09fcc213470758823937 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 22:56:17 +0300 Subject: [PATCH 3/9] fix(cover): a scope cover is unknown while its members are `MyHOMEScopeCover.is_closed` took `all(m.is_closed for m in members)`, so an area / general / group cover whose timed members all start unknown reported `open`. It now returns `bool | None` as HA Core's CoverGroup does: None when no member has a known state, otherwise closed when every member with a known state is closed. `async_set_cover_position` behind an F422 fanned the target out member by member and raised `cover_position_unknown` mid-loop when one member was known and another unknown, after the first had been started. The refusal moved to `MyHOMECover._check_position_target`, which the scope runs on every member before the first frame; the point cover uses the same check. Review 5639677952 on #795, finding 2. Tests: fresh members give `area.is_closed None` and state unknown, one known member decides; a mixed fan-out at 40 % raises before any frame (`*2*2*11#4#02##` was sent before the fix) and 0 % reaches every member. Two fan-out tests now set a known 50 % on their members, as they test the pacing, not the refusal. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/cover.py | 43 ++++++++++++++-------- custom_components/myhome/cover_scope.py | 20 +++++++++-- tests/test_issue_433_cover_scope.py | 48 ++++++++++++++++++++++++- 3 files changed, 92 insertions(+), 19 deletions(-) diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py index 6ac1ebed0..2505a8314 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -952,11 +952,11 @@ def is_closing(self) -> bool: return self._attr_is_closing # type: ignore @property - def is_closed(self) -> bool: - """Return if the cover is closed.""" + def is_closed(self) -> bool | None: + """Return if the cover is closed (None while that is unknown).""" if self.current_cover_position is not None: return self.current_cover_position == 0 - return self._attr_is_closed # type: ignore + return self._attr_is_closed async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -1612,6 +1612,28 @@ async def _settle() -> None: self._stop_task = asyncio.create_task(_settle()) + def _check_position_target(self, target_position: float) -> None: + """Refuse a partial target while a timed cover's position is unknown. + + 0 % and 100 % are full runs to an end stop and always go through; any + other target has no position to be timed from. A scope cover checks + every member with this before it dispatches, so that a fan-out does + not start some members and then refuse another. + """ + if ( + self._advanced + or self._strategy.assumed_state + or self.current_cover_position is not None + or not 0 < target_position < 100 + ): + return + raise ServiceValidationError( + f"{self._display_name}: the position is unknown until the cover has run fully open or closed once", + translation_domain=DOMAIN, + translation_key="cover_position_unknown", + translation_placeholders={"name": self._display_name}, + ) + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" if ATTR_POSITION not in kwargs: @@ -1675,18 +1697,9 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: if curr_pos is None: # Nothing to time a partial run from: 0% and 100% are full runs that # anchor the position on the end stop, anything else waits for one. - if target_position >= 100: - await self._async_run_to_end(True) - return - if target_position <= 0: - await self._async_run_to_end(False) - return - raise ServiceValidationError( - f"{self._display_name}: the position is unknown until the cover has run fully open or closed once", - translation_domain=DOMAIN, - translation_key="cover_position_unknown", - translation_placeholders={"name": self._display_name}, - ) + self._check_position_target(target_position) + await self._async_run_to_end(target_position >= 100) + return diff = target_position - curr_pos if diff == 0 and self._is_tilting: # Already at this level while a slat pulse runs: stop the pulse here. diff --git a/custom_components/myhome/cover_scope.py b/custom_components/myhome/cover_scope.py index 3dbe56491..9dc2e7968 100644 --- a/custom_components/myhome/cover_scope.py +++ b/custom_components/myhome/cover_scope.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Iterable -from homeassistant.components.cover import ATTR_TILT_POSITION +from homeassistant.components.cover import ATTR_POSITION, ATTR_TILT_POSITION from homeassistant.core import CALLBACK_TYPE, Event, EventStateChangedData, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.event import async_call_later, async_track_state_change_event @@ -166,10 +166,19 @@ def is_closing(self) -> bool: return any(m.is_closing for m in members) @property - def is_closed(self) -> bool: + def is_closed(self) -> bool | None: + """Closed when every member with a known state is; unknown when none has one. + + As HA Core's CoverGroup: a member whose state is unknown (a timed cover + before its first full run) does not count, so the scope is not reported + ``open`` on the strength of members that have no state at all. + """ if not (members := self._members()): return super().is_closed - return all(m.is_closed for m in members) + known = [closed for m in members if (closed := m.is_closed) is not None] + if not known: + return None + return all(known) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -221,6 +230,11 @@ async def async_stop_cover(self, **kwargs: Any) -> None: async def async_set_cover_position(self, **kwargs: Any) -> None: if members := self._fan_out_position_members(): + if ATTR_POSITION in kwargs: + # Refuse before the first member moves: a member with an unknown + # position cannot be timed to a partial target. + for member in members: + member._check_position_target(kwargs[ATTR_POSITION]) for i, member in enumerate(members): if i > 0 and PACED_FANOUT_DELAY > 0: await asyncio.sleep(PACED_FANOUT_DELAY) diff --git a/tests/test_issue_433_cover_scope.py b/tests/test_issue_433_cover_scope.py index 689b2e803..ff26ee199 100644 --- a/tests/test_issue_433_cover_scope.py +++ b/tests/test_issue_433_cover_scope.py @@ -35,7 +35,7 @@ CONF_PORT, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util import dt as dt_util from OWNd.message import OWNEvent, OWNMessage @@ -396,6 +396,7 @@ async def test_scope_cover_behind_an_interface_positions_each_member(gateway) -> area = _cover(gateway, "1", "02", scope=CoverScope.of("1", "02")) for c in (*points, area): family.add(c) + c._attr_current_cover_position = 50 # known: a partial target can be timed with patch.object(MyHOMECover, "async_set_cover_position", AsyncMock()) as timed: await area.async_set_cover_position(position=40) assert timed.await_count == 2 # one timed run per member, none for the scope @@ -456,6 +457,7 @@ async def test_general_scope_cover_behind_an_interface_paces_member_positioning( general = _cover(gateway, "0", "02", scope=CoverScope.of("0", "02")) for c in (*points, general): family.add(c) + c._attr_current_cover_position = 50 # known: a partial target can be timed with ( patch.object(MyHOMECover, "async_set_cover_position", AsyncMock()) as member_pos, @@ -692,3 +694,47 @@ async def test_member_command_during_a_scope_pulse_takes_its_motor_over(gateway) assert p2._tilt_follows is area # the other blind still follows await area._stop_task assert p2.current_cover_tilt_position == 100 + + +def test_scope_cover_state_follows_its_members_like_a_cover_group(gateway) -> None: + """Fresh timed members are unknown, so is the area; one known member decides (review 5639677952 on #795).""" + family = CoverFamily() + points = [_cover(gateway, "11"), _cover(gateway, "12")] + area = _cover(gateway, "1", scope=CoverScope.of("1", None)) + for c in (*points, area): + family.add(c) + assert all(m.is_closed is None for m in points) + assert area.is_closed is None + assert area.state is None # rendered "unknown", not "open" + assert area.current_cover_position is None + + points[0]._attr_current_cover_position = 50 # one member known and open + assert area.is_closed is False + assert area.state == "open" + + points[0]._attr_current_cover_position = 0 # the known member is closed, the other still unknown + assert area.is_closed is True # as HA Core's CoverGroup: unknown members do not count + assert area.state == "closed" + + points[1]._attr_current_cover_position = 100 + assert area.is_closed is False + + +async def test_scope_position_behind_an_interface_is_refused_before_any_member_moves(gateway) -> None: + """A mixed known / unknown fan-out raises before a frame, not after the first member started.""" + family = CoverFamily() + points = [_cover(gateway, "11", "02"), _cover(gateway, "12", "02")] + area = _cover(gateway, "1", "02", scope=CoverScope.of("1", "02")) + for c in (*points, area): + family.add(c) + points[0]._attr_current_cover_position = 50 # known + assert points[1].current_cover_position is None # unknown + with pytest.raises(ServiceValidationError) as err: + await area.async_set_cover_position(position=40) + assert err.value.translation_key == "cover_position_unknown" + assert _sent(gateway) == [] # member 11 was not started + assert points[0].is_opening is False and points[0].is_closing is False + # The end positions are full runs for every member and go through. + with patch.object(MyHOMECover, "async_set_cover_position", AsyncMock()) as timed: + await area.async_set_cover_position(position=0) + assert timed.await_count == 2 From 5e33c8c20f0dbf7c0e7fa11a38edbcfc1f23b456 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 22:57:27 +0300 Subject: [PATCH 4/9] fix(cover): promoting a simple cover to timed forgets its optimistic state A `travel_time: 0` cover (simple strategy, assumed state) sets `is_closed` from the last direction pulse. `_promote_to_timed_strategy` (`myhome.set_cover_travel_time`, calibration) carried that over while the position became None, so the timed cover reported `closed` / `open` with no position and refused `set_cover_position` 1..99 as unknown. When the promotion leaves no position, `is_closed` is now None too. Review 5639677952 on #795, finding 3. Test: a simple cover pulsed closed for 0.5 s, then `async_set_travel_time(6.0, 6.0)`: timed, position None, `is_closed None`, state unknown, 40 % refused with no frame. Fails before the fix (`True is None`). Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/cover.py | 4 ++++ tests/test_cover_unknown_start.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py index 2505a8314..1650c63e2 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -1228,6 +1228,10 @@ def _promote_to_timed_strategy(self) -> None: ) if self._attr_current_cover_position is None: self._attr_current_cover_position = self._start_position + if self._attr_current_cover_position is None: + # The simple strategy's open / closed was optimistic (the last + # direction); a timed cover without a position is unknown. + self._attr_is_closed = None def _demote_to_simple_strategy(self) -> None: """Let the strategy pick its successor when the calibration is reset to no travel time. diff --git a/tests/test_cover_unknown_start.py b/tests/test_cover_unknown_start.py index 4855720fe..8906b26d6 100644 --- a/tests/test_cover_unknown_start.py +++ b/tests/test_cover_unknown_start.py @@ -233,3 +233,25 @@ async def test_delivery_failure_from_a_restored_closed_state_restores_it(hass, g await _yield() assert cover.is_opening is False assert cover.is_closed is True + + +async def test_promotion_from_simple_forgets_the_optimistic_state(hass, gateway, clock, fake_time) -> None: + """travel_time 0 pulsed closed, then given a travel time: timed and unknown, not closed with no position.""" + cover = _make_cover(hass, gateway, travel_time=0) + assert cover.strategy.name == "simple" + cover.handle_event(OWNEvent.parse("*2*2*05##")) # a brief keypad pulse down + clock.now += 0.5 + cover.handle_event(OWNEvent.parse("*2*0*05##")) + assert cover.is_closed is True # the optimistic state of an assumed-state cover + assert cover.current_cover_position is None + cover._persist_calibration = MagicMock() + await cover.async_set_travel_time(6.0, 6.0) + assert cover.strategy.name == "timed" + assert cover.assumed_state is False + assert cover.current_cover_position is None + assert cover.is_closed is None # nothing says where the 0.5 s pulse left it + assert cover.state is None + with pytest.raises(ServiceValidationError) as err: + await cover.async_set_cover_position(position=40) + assert err.value.translation_key == "cover_position_unknown" + assert gateway.deliveries == [] From d4071f0405292197070d593b60442715ec0b2348 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 22:58:51 +0300 Subject: [PATCH 5/9] fix(cover): a stop echo past the travel time anchors a run from unknown `TimedCurtainStrategy.reanchor_on_stop_echo` skipped anchoring when the run started at an unknown position, also when the relayed stop status arrived at or after the travel time: a stop written at 5.85 s of a 6.0 s travel whose status came at 6.05 s recorded `last_run_seconds` 6.05 and left the position None, while `_freeze_position` anchors the same run at its end stop. The echo now anchors 100 / 0 in that case and reports it to the strategy as `end_stop`. The debug line no longer prints `re-anchored to None%` for a partial run from unknown. Review 5639677952 on #795, finding 4. Tests: the stop-just-before, status-just-after case anchors 0 (`None == 0` before the fix); the partial case logs "position still unknown" and never "None%". Co-Authored-By: Claude Fable 5.1 --- .../myhome/cover_strategies/timed.py | 31 +++++++++++----- tests/test_cover_unknown_start.py | 37 +++++++++++++++++++ 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/custom_components/myhome/cover_strategies/timed.py b/custom_components/myhome/cover_strategies/timed.py index 59586a2a1..024b90f58 100644 --- a/custom_components/myhome/cover_strategies/timed.py +++ b/custom_components/myhome/cover_strategies/timed.py @@ -142,13 +142,25 @@ def reanchor_on_stop_echo( direction = "open" if is_opening else "close" if is_closing else "" travel_time = self.curtain_travel_for(is_opening) reanchored: int | None = getattr(entity, "_attr_current_cover_position", None) - if travel_time > 0 and (is_opening or is_closing) and run_start_pos is not None: - reanchored = self.position_after( - run_start_pos, elapsed, direction, entity=entity, lead=lead - ) - entity._attr_current_cover_position = reanchored - entity._start_position = reanchored - entity._attr_is_closed = reanchored == 0 + end_stop_from_unknown = False + if travel_time > 0 and (is_opening or is_closing): + if run_start_pos is not None: + reanchored = self.position_after( + run_start_pos, elapsed, direction, entity=entity, lead=lead + ) + elif elapsed >= self.travel_for_direction(is_opening): + # A run from an unknown position that lasted the full travel + # reached its end stop, as _freeze_position anchors it: the + # stop was written just before the travel time and the relayed + # status came just after. + reanchored = 100 if is_opening else 0 + end_stop_from_unknown = True + if run_start_pos is not None or end_stop_from_unknown: + entity._attr_current_cover_position = reanchored + entity._start_position = reanchored + entity._attr_is_closed = reanchored == 0 + if end_stop_from_unknown and reanchored is not None: + self.on_position_confirmed(entity, reanchored, "end_stop") self._reanchor_slats_on_stop_echo(entity, ctx, elapsed, is_opening, is_closing) entity._last_run = { "seconds": round(elapsed, 2), @@ -158,11 +170,12 @@ def reanchor_on_stop_echo( if hasattr(entity, "_refresh_travel_attributes"): entity._refresh_travel_attributes() log_id = getattr(getattr(entity, "_gateway_handler", None), "log_id", "") + position = entity._attr_current_cover_position LOGGER.debug( - "%s Stop status echo for %s; re-anchored to %s%% (elapsed %.2f s).", + "%s Stop status echo for %s; %s (elapsed %.2f s).", log_id, getattr(entity, "_full_where", ""), - entity._attr_current_cover_position, + f"re-anchored to {position}%" if position is not None else "position still unknown", elapsed, ) return reanchored diff --git a/tests/test_cover_unknown_start.py b/tests/test_cover_unknown_start.py index 8906b26d6..217eb4f92 100644 --- a/tests/test_cover_unknown_start.py +++ b/tests/test_cover_unknown_start.py @@ -255,3 +255,40 @@ async def test_promotion_from_simple_forgets_the_optimistic_state(hass, gateway, await cover.async_set_cover_position(position=40) assert err.value.translation_key == "cover_position_unknown" assert gateway.deliveries == [] + + +async def test_stop_echo_after_the_travel_time_anchors_the_end_stop(hass, gateway, clock, fake_time, caplog) -> None: + """stop_cover written just before the travel time, its status relayed just after: the end stop anchors 0.""" + caplog.set_level("DEBUG", logger="custom_components.myhome") + cover = _make_cover(hass, gateway) + await _close_from_unknown(cover, gateway, clock) + clock.now += 5.85 # 5.85 s into a 6.0 s travel: the provisional freeze knows no position + await cover.async_stop_cover() + raw, stop_written = gateway.deliveries[-1] + assert raw == "*2*0*05##" + stop_written.set_result(clock.now) + await _yield() + assert cover.current_cover_position is None + clock.now += 0.2 # the relayed stop status lands at 6.05 s, past the travel time + cover.handle_event(OWNEvent.parse("*2*0*05##")) + assert cover.current_cover_position == 0 + assert cover.is_closed is True + assert cover.extra_state_attributes["last_run_seconds"] == pytest.approx(6.05) + assert "re-anchored to 0%" in caplog.text + assert "None%" not in caplog.text + + +async def test_stop_echo_of_a_partial_run_from_unknown_logs_no_position(hass, gateway, clock, fake_time, caplog) -> None: + """The debug line of a stop echo that anchors nothing says so instead of printing None%.""" + caplog.set_level("DEBUG", logger="custom_components.myhome") + cover = _make_cover(hass, gateway) + await _close_from_unknown(cover, gateway, clock) + clock.now += 2.0 + await cover.async_stop_cover() + gateway.deliveries[-1][1].set_result(clock.now) + await _yield() + clock.now += 0.05 + cover.handle_event(OWNEvent.parse("*2*0*05##")) + assert cover.current_cover_position is None + assert "position still unknown" in caplog.text + assert "None%" not in caplog.text From 82d89cc39601da148b6031a2807f286d8dac71e7 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 23:05:19 +0300 Subject: [PATCH 6/9] fix(cover): a Venetian blind's slat angle is unknown until a full rotation `async_set_cover_tilt_position` and a scope pulse's followers invented a slat angle (0 when closed, else 50) when `current_cover_tilt_position` was None, so on a fresh blind `set_cover_tilt_position: 50` was a silent 0-frame no-op and `20` sent a 0.6 s pulse timed from nowhere. The slat angle now follows the rule of the curtain position: 0 % and 100 % run a full `slat_time` against the slat stop and anchor the angle there (the strategy counts the rotation from the far end); any other target is refused with the translated `cover_tilt_position_unknown` error and sends nothing. A pulse from an unknown angle that is stopped before the slat stop leaves the angle unknown, and a follower whose angle is unknown is anchored by its leader's pulse only at a slat stop. A scope blind already took its members' mean angle; it is unknown while none of them knows theirs. Review 5639677952 on #795, finding 5. Tests: a fresh blind refuses 50 % and 20 % with no frame, 100 % sends `*2*1*05##` with a 2.0 s pulse and reads 100 at the stop while the curtain position stays unknown; a pulse to 0 % stopped at 1.0 s stays unknown, the next full pulse anchors 0; a scope blind with unknown members refuses 50 % and follows the members' mean once they know. Before the fix the refusals did not raise and the half pulse invented 0. One delivery-failure test sets a known angle, as it tests the failure path, not the fallback. Docs: covers.md slat tilt section; strings.json + translations/en.json (sync-en). Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/cover.py | 45 ++++++++---- custom_components/myhome/strings.json | 3 + custom_components/myhome/translations/en.json | 3 + docs/configuration/covers.md | 1 + tests/test_component_cover.py | 1 + tests/test_cover_unknown_start.py | 68 +++++++++++++++++++ tests/test_issue_433_cover_scope.py | 19 ++++++ 7 files changed, 128 insertions(+), 12 deletions(-) diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py index 1650c63e2..d7717fab8 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -415,7 +415,8 @@ def __init__( # type: ignore raw_slat_time = _clamp_slat_time(slat_time) self._is_tilting: bool = False self._tilt_start_time: float | None = None - self._tilt_initial_position: int = 0 + # None: the running pulse started from an unknown angle, known again at a slat stop. + self._tilt_initial_position: int | None = 0 self._tilt_target_position: int = 0 self._tilt_duration: float = 0.0 self._tilt_direction: str | None = None @@ -1864,11 +1865,13 @@ def _interpolated_tilt(self, at: float) -> int | None: if self._tilt_start_time is None or self._slat_time <= 0: return self.current_cover_tilt_position direction = "open" if self._tilt_step() > 0 else "close" - return self._strategy.turn_slats_after( - self._tilt_initial_position, - max(0.0, at - self._tilt_start_time), - direction, - ) + elapsed = max(0.0, at - self._tilt_start_time) + if self._tilt_initial_position is None and elapsed < self._slat_time: + # A pulse from an unknown angle: nothing to count it from until the + # slats reach their stop (a full rotation); then the strategy + # computes it from the far end, which is where a full rotation ends. + return None + return self._strategy.turn_slats_after(self._tilt_initial_position, elapsed, direction) def _tilt_step(self) -> int: """+1 while the running pulse opens the slats, -1 while it closes them.""" @@ -1884,9 +1887,13 @@ def _carry_overshoot_into_curtain(self, at: float) -> None: return direction = "open" if self._tilt_step() > 0 else "close" elapsed = max(0.0, at - self._tilt_start_time) + # A pulse from an unknown angle ran a full rotation from the far end. + start_tilt = self._tilt_initial_position + if start_tilt is None: + start_tilt = 0 if direction == "open" else 100 position = self._strategy.carry_overshoot_into_curtain( self._attr_current_cover_position, - self._tilt_initial_position, + start_tilt, elapsed, direction, ) @@ -1939,8 +1946,8 @@ def _follow_pulse(self, leader: MyHOMECover, direction: str) -> None: """ self._cancel_stop_task() self._end_pulse_for_takeover() - current = self.current_cover_tilt_position - self._tilt_initial_position = current if current is not None else (0 if self.is_closed else 50) + # None: this blind's angle is unknown; the leader's pulse anchors it only at a slat stop. + self._tilt_initial_position = self.current_cover_tilt_position self._tilt_target_position = 100 if direction == "open" else 0 self._tilt_direction = direction self._tilt_start_time = None @@ -2030,8 +2037,20 @@ async def _async_start_tilt(self, target_tilt: int) -> None: translation_placeholders={"name": self._display_name}, ) curr_tilt = self._interpolated_tilt(time.monotonic()) if was_tilting else self.current_cover_tilt_position + # None: the angle is unknown (a fresh blind, or a pulse from an unknown + # angle still running). As with the curtain position, 0 % and 100 % run + # a full rotation against the slat stop and anchor the angle there; + # anything else has no angle to be timed from. + start_tilt = curr_tilt if curr_tilt is None: - curr_tilt = 0 if self.is_closed else 50 + if 0 < target_tilt < 100: + raise ServiceValidationError( + f"{self._display_name}: the slat angle is unknown until the slats have been tilted fully open or closed once", + translation_domain=DOMAIN, + translation_key="cover_tilt_position_unknown", + translation_placeholders={"name": self._display_name}, + ) + curr_tilt = 100 - target_tilt # the far end: a full rotation to the stop diff = target_tilt - curr_tilt run_duration = self._strategy.tilt_pulse_duration_for(curr_tilt, target_tilt) if run_duration <= 0: @@ -2057,14 +2076,16 @@ async def _async_start_tilt(self, target_tilt: int) -> None: self._cancel_stop_task() self._tilt_target_position = target_tilt self._tilt_duration = self._strategy.tilt_pulse_duration_for( - self._tilt_initial_position, target_tilt, enforce_min_pulse=False + self._tilt_initial_position if self._tilt_initial_position is not None else 100 - target_tilt, + target_tilt, + enforce_min_pulse=False, ) self._start_pulse_auto_stop(self._pulse_written, self._run_generation, self._motor_started) if self.hass is not None: self.async_write_ha_state() return - self._tilt_initial_position = curr_tilt + self._tilt_initial_position = start_tilt # None: known again at the slat stop self._tilt_target_position = target_tilt self._tilt_duration = run_duration self._tilt_direction = direction diff --git a/custom_components/myhome/strings.json b/custom_components/myhome/strings.json index 212903722..26c234d69 100644 --- a/custom_components/myhome/strings.json +++ b/custom_components/myhome/strings.json @@ -572,6 +572,9 @@ "cover_position_unknown": { "message": "{name}: the position is unknown until the cover has run fully open or closed once" }, + "cover_tilt_position_unknown": { + "message": "{name}: the slat angle is unknown until the slats have been tilted fully open or closed once" + }, "cover_busy_moving": { "message": "{name} is moving; stop it before tilting the slats" }, diff --git a/custom_components/myhome/translations/en.json b/custom_components/myhome/translations/en.json index 212903722..26c234d69 100644 --- a/custom_components/myhome/translations/en.json +++ b/custom_components/myhome/translations/en.json @@ -572,6 +572,9 @@ "cover_position_unknown": { "message": "{name}: the position is unknown until the cover has run fully open or closed once" }, + "cover_tilt_position_unknown": { + "message": "{name}: the slat angle is unknown until the slats have been tilted fully open or closed once" + }, "cover_busy_moving": { "message": "{name} is moving; stop it before tilting the slats" }, diff --git a/docs/configuration/covers.md b/docs/configuration/covers.md index 8eb073489..f072a5fab 100644 --- a/docs/configuration/covers.md +++ b/docs/configuration/covers.md @@ -185,6 +185,7 @@ The integration models this behavior with timed travel: * A new target in the **same direction** while the pulse still runs moves the pulse's stop and sends nothing; only a change of direction sends a new direction frame. * Like any command, a pulse opens an echo window: the stop status the gateway relays right after our direction frame is ignored, so it cannot cancel the pulse's stop and leave the motor running. * Steps shorter than the cover's `min_motor_pulse` (default **0.4 s**, or 20 % at `slat_time: 2.0`) are skipped: the motor would barely answer while the angle was recorded as changed. On the 3.75 m F401 shutter with tilting slats ([discussion #770](https://github.com/orgs/OpenWebNet-HA/discussions/770#discussioncomment-19144521), `slat_time: 1.4`, `min_motor_pulse: 0.2`), pulses of 0.26–0.39 s (`15 %`–`25 %`) all produced the same visual slat opening while 0.74 s (`50 %`) reached horizontal and 1.44 s (`100 %`) fully opened the slats before the curtain started to lift — matching the default **0.4 s** floor (`Dimension 10` stayed at `0 %`–`1 %` throughout). +* **Unknown until the first full rotation**: a blind with no restored slat angle has none (`current_cover_tilt_position` is not set), as the actuator cannot report it. `set_cover_tilt_position: 0` / `100` (and `open_cover_tilt` / `close_cover_tilt`) run a full `slat_time` against the slat stop and anchor the angle there; any other target is refused until then (*"… the slat angle is unknown until the slats have been tilted fully open or closed once"*) and sends nothing, because there is no angle to time the pulse from. A pulse stopped before the stop leaves the angle unknown. An area / group / general blind takes its members' mean angle and is unknown while none of them knows theirs. * A tilt command while the curtain itself is running is refused (*"… is moving; stop it before tilting the slats"*). The run turns the slats anyway, and a pulse would leave the curtain's travel estimate behind. Opening, closing or setting the position during a pulse ends the pulse at the angle it reached; the new command takes the motor over, and the pulse's own stop is dropped. Setting the position the blind is already at, while a pulse or a run is going, stops the motor there. * During a curtain run the slats turn first at one full rotation per `slat_time` (towards 100 % when opening, towards 0 % when closing), and only the remaining motor time moves the curtain estimate over `travel_time - slat_time`. A stop status with no run behind it (such as the relay of a pulse's own stop) leaves the angle alone, so slats tilted open on a lowered blind stay open. diff --git a/tests/test_component_cover.py b/tests/test_component_cover.py index 16f707f0f..1d7aa445a 100644 --- a/tests/test_component_cover.py +++ b/tests/test_component_cover.py @@ -1180,6 +1180,7 @@ async def test_cover_slat_tilt_delivery_failure(self, tilt_cover, mock_gateway): fut = asyncio.Future() fut.set_exception(Exception("Bus write error")) mock_gateway.send.return_value = fut + tilt_cover._attr_current_cover_tilt_position = 0 # known: a partial target can be timed with pytest.raises(HomeAssistantError, match="direction command delivery failed"): await tilt_cover.async_set_cover_tilt_position(tilt_position=30) diff --git a/tests/test_cover_unknown_start.py b/tests/test_cover_unknown_start.py index 217eb4f92..80af3f121 100644 --- a/tests/test_cover_unknown_start.py +++ b/tests/test_cover_unknown_start.py @@ -292,3 +292,71 @@ async def test_stop_echo_of_a_partial_run_from_unknown_logs_no_position(hass, ga assert cover.current_cover_position is None assert "position still unknown" in caplog.text assert "None%" not in caplog.text + + +def _make_blind(hass, gateway): + """The same actuator as a Venetian blind: slat_tilt with the default 2.0 s slat time.""" + return _make_cover(hass, gateway, name="Blind 05", slat_tilt=True, slat_time=2.0) + + +async def test_fresh_blind_refuses_a_partial_tilt_and_runs_an_end_to_the_slat_stop(hass, gateway, clock, fake_time) -> None: + """No slat angle to time a pulse from: 50 % / 20 % are refused with no frame, 100 % runs the full slat_time.""" + cover = _make_blind(hass, gateway) + assert cover.current_cover_tilt_position is None + for target in (50, 20): + with pytest.raises(ServiceValidationError) as err: + await cover.async_set_cover_tilt_position(tilt_position=target) + assert err.value.translation_key == "cover_tilt_position_unknown" + assert gateway.deliveries == [] # 50 % was not a silent 0-frame no-op either: it was refused + assert cover.current_cover_tilt_position is None + + with patch("asyncio.sleep", new=AsyncMock()): + await cover.async_set_cover_tilt_position(tilt_position=100) + raw, written = gateway.deliveries[-1] + assert raw == "*2*1*05##" + assert cover._tilt_duration == pytest.approx(2.0) # a full rotation to the slat stop + assert cover.current_cover_tilt_position is None # unknown until the stop + written.set_result(clock.now) + await _yield() + cover.handle_event(OWNEvent.parse("*2*1*05##")) # motor start echo + await cover._stop_task + raw, stop_written = gateway.deliveries[-1] + assert raw == "*2*0*05##" + clock.now += 2.0 + stop_written.set_result(clock.now) + await _yield() + assert cover.current_cover_tilt_position == 100 + assert cover.current_cover_position is None # the slat pulse says nothing about the curtain + + +async def test_fresh_blind_pulse_stopped_before_the_slat_stop_stays_unknown(hass, gateway, clock, fake_time) -> None: + """A full pulse towards 0 % stopped halfway anchors nothing; the next full pulse does.""" + cover = _make_blind(hass, gateway) + await cover.async_set_cover_tilt_position(tilt_position=0) + raw, written = gateway.deliveries[-1] + assert raw == "*2*2*05##" + written.set_result(clock.now) + await _yield() + cover.handle_event(OWNEvent.parse("*2*2*05##")) + clock.now += 1.0 + await cover.async_stop_cover_tilt() + raw, stop_written = gateway.deliveries[-1] + assert raw == "*2*0*05##" + stop_written.set_result(clock.now) + await _yield() + assert cover._is_tilting is False + assert cover.current_cover_tilt_position is None # 1.0 s of a 2.0 s rotation from nowhere + with pytest.raises(ServiceValidationError): + await cover.async_set_cover_tilt_position(tilt_position=50) + + await cover.async_set_cover_tilt_position(tilt_position=0) + raw, written = gateway.deliveries[-1] + assert raw == "*2*2*05##" + written.set_result(clock.now) + await _yield() + cover.handle_event(OWNEvent.parse("*2*2*05##")) + clock.now += 2.0 + await cover.async_stop_cover_tilt() + gateway.deliveries[-1][1].set_result(clock.now) + await _yield() + assert cover.current_cover_tilt_position == 0 diff --git a/tests/test_issue_433_cover_scope.py b/tests/test_issue_433_cover_scope.py index ff26ee199..09214b5eb 100644 --- a/tests/test_issue_433_cover_scope.py +++ b/tests/test_issue_433_cover_scope.py @@ -738,3 +738,22 @@ async def test_scope_position_behind_an_interface_is_refused_before_any_member_m with patch.object(MyHOMECover, "async_set_cover_position", AsyncMock()) as timed: await area.async_set_cover_position(position=0) assert timed.await_count == 2 + + +async def test_scope_tilt_follows_its_members_and_is_unknown_until_they_are(gateway) -> None: + """The scope's slat angle is its members' mean, None while no member knows its angle.""" + family = CoverFamily() + p1 = _cover(gateway, "11", slat_tilt=True) + p2 = _cover(gateway, "12", slat_tilt=True) + area = _cover(gateway, "1", scope=CoverScope.of("1"), slat_tilt=True) + for c in (p1, p2, area): + family.add(c) + assert area.current_cover_tilt_position is None + with pytest.raises(ServiceValidationError) as err: + await area.async_set_cover_tilt_position(tilt_position=50) + assert err.value.translation_key == "cover_tilt_position_unknown" + assert _sent(gateway) == [] + p1._attr_current_cover_tilt_position = 0 + assert area.current_cover_tilt_position == 0 # the one known member + p2._attr_current_cover_tilt_position = 100 + assert area.current_cover_tilt_position == 50 From 4fd412ce4492f8ecd07147c5a572913afe3d1245 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 23:05:20 +0300 Subject: [PATCH 7/9] docs(cover): the gateway, not the F411/2, answers the dimension 10 read On the SCS bus an F411/2 emits no OpenWebNet ACK; the gateway answers `*#2**10##` (`*#*1##` on an F461 2.0.11, `*#*0##` on an MH200N / F453) and relays no position frame because the actuator does not answer dimension 10. The covers.md sentence says so now, as suggested in review 5639677952 on #795. The release-notes delta covers the review's follow-ups (lost frame, scope state, slat angle). Co-Authored-By: Claude Fable 5.1 --- .github/release-notes/2.0.0b16.md | 2 +- docs/configuration/covers.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index d2683a971..cc8353f18 100644 --- a/.github/release-notes/2.0.0b16.md +++ b/.github/release-notes/2.0.0b16.md @@ -12,7 +12,7 @@ - **Timed covers: `set_cover_position: 0` / `100` sends no stop frame and settles on a timer.** The motor stops on its limit switch. The entity reports 0 % / 100 % once the stored travel time plus 2 s has passed, instead of waiting for the actuator's own stop status (about 60 s late on a MyHOMEServer1, never on a gateway that does not relay it). Automations waiting for `open` / `closed` see the change at that point, which can be a few seconds before a slow curtain physically arrives. The open and close buttons are unchanged. - **Small moves are widened or stretched, so they are no longer exact.** A timed cover runs at least its `min_motor_pulse` (default 0.4 s, set per cover in YAML or with `myhome.set_cover_travel_time`; `0` turns it off), and the estimate books that longer run (50 % → 51 % on a 5 s cover ends at 58 % with the default). An advanced cover is sent at least a 2 % level change (50 % → 51 % is sent as 52 %). A move whose run reaches an end becomes a full run to that end. Sending the cover to 0 % or 100 % re-syncs it (#708). - **No widening while an advanced cover is moving.** While the actuator runs it reports the level it started from, so a retarget goes out as asked (40 → 80, then `set_cover_position: 40` sends the level frame). -- **A timed cover with no restored state starts `unknown`, not `open` at 50 %.** The actuator cannot report its position, so none is invented. It becomes known at the first end stop (open / close buttons, `set_cover_position: 0` / `100`, or a keypad run that lasts the full travel time); until then `set_cover_position` with another target is refused with a translated error and sends nothing. The position restored from the last run of Home Assistant is kept. +- **A timed cover with no restored state starts `unknown`, not `open` at 50 %.** The actuator cannot report its position, so none is invented. It becomes known at the first end stop (open / close buttons, `set_cover_position: 0` / `100`, or a keypad run that lasts the full travel time); until then `set_cover_position` with another target is refused with a translated error and sends nothing. The position restored from the last run of Home Assistant is kept. A direction frame that never reaches the bus leaves the cover as it was. An area / group / general cover is `unknown` while none of its members has a known state, and a `set_cover_position` behind an F422 is refused before any member moves when one of them is unknown. A Venetian blind's slat angle follows the same rule: `set_cover_tilt_position: 0` / `100` run a full rotation to the slat stop, any other angle is refused until then. - **Timed runs end at the actuator's stop status.** When the gateway relays `*2*0*WHERE##` after a stop, the position and `last_run_seconds` are taken from that status instead of the moment the stop was written, which was about 0.5 s short per run on a MyHOMEServer1 (#707). A keypad run that lasts the full travel time is now reported as reaching the end (#760). See the cover docs for the details and the known limits. diff --git a/docs/configuration/covers.md b/docs/configuration/covers.md index f072a5fab..770438c62 100644 --- a/docs/configuration/covers.md +++ b/docs/configuration/covers.md @@ -64,7 +64,7 @@ Covers set up in the UI (without `myhome.yaml`) change their device class in Hom For standard covers without hardware position feedback, the integration provides a software estimator enabling full `open_cover`, `close_cover`, `stop_cover`, and slider-based `set_cover_position` support: -* **Unknown Until the First Full Run**: A timed actuator cannot report where the curtain is (an F411/2 answers `*#2**10##` with an ACK and no data), so a cover with no restored state starts as `unknown`, with no position, instead of an invented `open` at 50 %. The restored position from the last run of Home Assistant is kept. The position becomes known at an end stop: `open_cover`, `close_cover`, `set_cover_position: 0` / `100` or a keypad run that lasts the full travel time; a keypad stop before that leaves it unknown. While it is unknown, `set_cover_position` with any other target is refused (*"… the position is unknown until the cover has run fully open or closed once"*) and sends nothing, because there is no position to time the run from. +* **Unknown Until the First Full Run**: A timed actuator cannot report where the curtain is (for an F411/2, the gateway answers `*#2**10##` with no position frame), so a cover with no restored state starts as `unknown`, with no position, instead of an invented `open` at 50 %. The restored position from the last run of Home Assistant is kept. The position becomes known at an end stop: `open_cover`, `close_cover`, `set_cover_position: 0` / `100` or a keypad run that lasts the full travel time; a keypad stop before that leaves it unknown. While it is unknown, `set_cover_position` with any other target is refused (*"… the position is unknown until the cover has run fully open or closed once"*) and sends nothing, because there is no position to time the run from. * **Direction-Aware Travel Times**: Because gravity and motor friction cause shutters to fall faster than they rise, the v2 engine tracks separate `travel_time_down` and `travel_time_up` durations (default: `25.0s`). * **Frame Anchor Timing**: The run timer starts when the direction frame is **written to the gateway**, not when Home Assistant queues it. * **Echo Suppression**: The gateway relays a momentary stop status (~0.1 s) followed by translation and the actual motor start (~0.55 s). The v2 engine recognizes these as command echoes, re-anchoring the timer to the true motor start rather than falsely treating them as manual stop commands. From 85c44012f72daa6646fdcb54c9f7b296719656a2 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 23:16:59 +0300 Subject: [PATCH 8/9] fix(cover): measure an end stop from unknown on the real run time only The stop-echo end-stop inference for a run from an unknown position compared the booked elapsed time, which is raised to the scheduled auto-stop duration, with the travel time. The scheduled duration is not evidence that the motor ran that long; only the measured run is. The comparison now uses the raw elapsed time (second-opinion review point on the review 5639677952 follow-up). `last_run_seconds` is unchanged. Also covers the Venetian overshoot branch for a blind whose position is known but whose angle is not: a full tilt from unknown that runs 1 s past the rotation lifts a 50 % curtain to 75 %. Co-Authored-By: Claude Fable 5.1 --- .../myhome/cover_strategies/timed.py | 6 ++- tests/test_cover_unknown_start.py | 45 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/custom_components/myhome/cover_strategies/timed.py b/custom_components/myhome/cover_strategies/timed.py index 024b90f58..e1d961146 100644 --- a/custom_components/myhome/cover_strategies/timed.py +++ b/custom_components/myhome/cover_strategies/timed.py @@ -148,11 +148,13 @@ def reanchor_on_stop_echo( reanchored = self.position_after( run_start_pos, elapsed, direction, entity=entity, lead=lead ) - elif elapsed >= self.travel_for_direction(is_opening): + elif raw_elapsed >= self.travel_for_direction(is_opening): # A run from an unknown position that lasted the full travel # reached its end stop, as _freeze_position anchors it: the # stop was written just before the travel time and the relayed - # status came just after. + # status came just after. Measured on the real motor time, not + # the scheduled auto-stop duration: only the run itself is + # evidence that the end stop was reached. reanchored = 100 if is_opening else 0 end_stop_from_unknown = True if run_start_pos is not None or end_stop_from_unknown: diff --git a/tests/test_cover_unknown_start.py b/tests/test_cover_unknown_start.py index 80af3f121..471587706 100644 --- a/tests/test_cover_unknown_start.py +++ b/tests/test_cover_unknown_start.py @@ -360,3 +360,48 @@ async def test_fresh_blind_pulse_stopped_before_the_slat_stop_stays_unknown(hass gateway.deliveries[-1][1].set_result(clock.now) await _yield() assert cover.current_cover_tilt_position == 0 + + +def test_stop_echo_end_stop_from_unknown_is_measured_on_the_real_run() -> None: + """A scheduled auto-stop duration is no evidence that the motor ran that long: no end stop is fabricated.""" + from custom_components.myhome.cover_strategies.timed import TimedCurtainStrategy + + strategy = TimedCurtainStrategy(travel_time=6.0, travel_time_up=6.0, travel_time_down=6.0) + entity = MagicMock() + entity._attr_current_cover_position = None + entity._gateway_handler.log_id = "[test]" + ctx = { + "run_started_at": 100.0, + "run_start_position": None, + "is_opening": False, + "is_closing": True, + "auto_stop_duration": 6.0, # the stop that was scheduled + "lead": 0.0, + } + # Stopped from the bus 2 s in: the status echo arrives at 102.05 s. + assert strategy.reanchor_on_stop_echo(entity, dict(ctx), 102.05) is None + assert entity._attr_current_cover_position is None + assert entity._last_run["seconds"] == pytest.approx(6.0) # the booked run, as before + # The motor really ran the travel time: the end stop anchors 0. + assert strategy.reanchor_on_stop_echo(entity, dict(ctx), 106.05) == 0 + assert entity._attr_current_cover_position == 0 + assert entity._attr_is_closed is True + + +async def test_full_tilt_from_unknown_that_runs_long_moves_a_known_curtain(hass, gateway, clock, fake_time) -> None: + """A blind whose position is known but whose angle is not: a late stop past the rotation lifts the curtain.""" + cover = _make_blind(hass, gateway) + cover._attr_current_cover_position = cover._start_position = 50 # restored position, no restored angle + assert cover.current_cover_tilt_position is None + await cover.async_set_cover_tilt_position(tilt_position=100) + raw, written = gateway.deliveries[-1] + assert raw == "*2*1*05##" + written.set_result(clock.now) + await _yield() + cover.handle_event(OWNEvent.parse("*2*1*05##")) + clock.now += 3.0 # the 2.0 s rotation, then 1.0 s of the 4.0 s curtain travel (travel_time 6 - slat_time 2) + await cover.async_stop_cover_tilt() + gateway.deliveries[-1][1].set_result(clock.now) + await _yield() + assert cover.current_cover_tilt_position == 100 + assert cover.current_cover_position == 75 From ea7067ffe714f87f1b4145228a925a612ef77a83 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 25 Oct 2026 11:23:56 +0200 Subject: [PATCH 9/9] fix(cover): settle open / close from unknown on the travel timer; restore "open" without a position as unknown Review of #795 (5642333408), both points: 1. open_cover / close_cover from an unknown position run to the end stop through _async_run_to_end, settling on travel_time + END_SETTLE_MARGIN (#702) like set_cover_position 0 / 100. An actuator stop status before the travel time (an F411/2 timed shorter than travel_time, 5 s on the F461 plant against the default 25 s) no longer ends that run: nothing can be frozen from it, so the settle timer anchors 100 / 0; the motor time is still recorded as last_run_seconds. A stop at or after the travel time anchors the end stop at once, as before. A cancelled settle (reversal, stop_cover, a new command) clears the flag so the next stop status ends the run normally. Scope covers keep their own run timeout; advanced and assumed-state covers are untouched. 2. A restored last state "open" with no position stays open with an unknown position instead of 100 %: "open" says nothing about the height. A restored "closed" is still 0 %. Docs and release notes say so; tests: early actuator stop settles, open without any stop status settles, known start still freezes the estimate, reversal during the settle, restored open / closed without a position. Co-Authored-By: Claude Fable 5.1 --- .github/release-notes/2.0.0b16.md | 2 +- custom_components/myhome/cover.py | 78 ++++++++++++++- custom_components/myhome/cover_scope.py | 2 + docs/configuration/covers.md | 2 +- tests/test_component_cover.py | 12 +-- tests/test_component_logic_more.py | 2 + tests/test_cover_calibration.py | 1 + tests/test_cover_unknown_start.py | 121 ++++++++++++++++++++++++ tests/test_issue_433_cover_scope.py | 2 + 9 files changed, 212 insertions(+), 10 deletions(-) diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index cc8353f18..6632155f8 100644 --- a/.github/release-notes/2.0.0b16.md +++ b/.github/release-notes/2.0.0b16.md @@ -12,7 +12,7 @@ - **Timed covers: `set_cover_position: 0` / `100` sends no stop frame and settles on a timer.** The motor stops on its limit switch. The entity reports 0 % / 100 % once the stored travel time plus 2 s has passed, instead of waiting for the actuator's own stop status (about 60 s late on a MyHOMEServer1, never on a gateway that does not relay it). Automations waiting for `open` / `closed` see the change at that point, which can be a few seconds before a slow curtain physically arrives. The open and close buttons are unchanged. - **Small moves are widened or stretched, so they are no longer exact.** A timed cover runs at least its `min_motor_pulse` (default 0.4 s, set per cover in YAML or with `myhome.set_cover_travel_time`; `0` turns it off), and the estimate books that longer run (50 % → 51 % on a 5 s cover ends at 58 % with the default). An advanced cover is sent at least a 2 % level change (50 % → 51 % is sent as 52 %). A move whose run reaches an end becomes a full run to that end. Sending the cover to 0 % or 100 % re-syncs it (#708). - **No widening while an advanced cover is moving.** While the actuator runs it reports the level it started from, so a retarget goes out as asked (40 → 80, then `set_cover_position: 40` sends the level frame). -- **A timed cover with no restored state starts `unknown`, not `open` at 50 %.** The actuator cannot report its position, so none is invented. It becomes known at the first end stop (open / close buttons, `set_cover_position: 0` / `100`, or a keypad run that lasts the full travel time); until then `set_cover_position` with another target is refused with a translated error and sends nothing. The position restored from the last run of Home Assistant is kept. A direction frame that never reaches the bus leaves the cover as it was. An area / group / general cover is `unknown` while none of its members has a known state, and a `set_cover_position` behind an F422 is refused before any member moves when one of them is unknown. A Venetian blind's slat angle follows the same rule: `set_cover_tilt_position: 0` / `100` run a full rotation to the slat stop, any other angle is refused until then. +- **A timed cover with no restored state starts `unknown`, not `open` at 50 %.** The actuator cannot report its position, so none is invented. It becomes known at the first end stop: the open / close buttons and `set_cover_position: 0` / `100` run to the limit switch and settle on the travel time plus 2 s even when the actuator's own stop status comes earlier (an F411/2 timed shorter than `travel_time`), a keypad run that lasts the full travel time anchors it too; until then `set_cover_position` with another target is refused with a translated error and sends nothing. The position restored from the last run of Home Assistant is kept; a restored `open` without a position stays open with an unknown position, it is no longer turned into 100 %. A direction frame that never reaches the bus leaves the cover as it was. An area / group / general cover is `unknown` while none of its members has a known state, and a `set_cover_position` behind an F422 is refused before any member moves when one of them is unknown. A Venetian blind's slat angle follows the same rule: `set_cover_tilt_position: 0` / `100` run a full rotation to the slat stop, any other angle is refused until then. - **Timed runs end at the actuator's stop status.** When the gateway relays `*2*0*WHERE##` after a stop, the position and `last_run_seconds` are taken from that status instead of the moment the stop was written, which was about 0.5 s short per run on a MyHOMEServer1 (#707). A keypad run that lasts the full travel time is now reported as reaching the end (#760). See the cover docs for the details and the known limits. diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py index d7717fab8..5fc30d784 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -363,6 +363,9 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> class MyHOMECover(MyHOMEEntity, CoverEntity): + # open / close from an unknown position settle on the travel timer (point + # covers); a scope cover follows its members and has its own run timeout. + _settles_open_close_from_unknown = True _attr_device_class = CoverDeviceClass.SHUTTER def __init__( # type: ignore @@ -530,6 +533,11 @@ def __init__( # type: ignore # never reaches the bus and there is no position to derive it from. self._is_closed_before_move: bool | None = None self._stop_task: asyncio.Task[None] | None = None + # True while a run HA commanded to an end stop from an unknown position + # settles on its timer: an actuator stop before the travel time does not + # end it (review of #795: the F411/2's own timer is often shorter than + # the configured travel time, and only the end stop can anchor the position). + self._end_run_from_unknown = False # Echo model state (see ECHO_WINDOW): which command of ours is in # flight, until when relayed frames count as its echo, when the motor @@ -824,6 +832,7 @@ def _abort_motion(self) -> None: self._run_lead = self._move_lead = 0.0 self._pending_stop_context = None self._auto_stop_duration = None + self._end_run_from_unknown = False self._move_start_time = None self._run_started_at = None self._motion_started_at = None @@ -904,6 +913,9 @@ async def _await_motion_anchor(self, written) -> float: # type: ignore def _cancel_stop_task(self) -> None: """Cancel any running scheduled auto-stop task.""" + # A cancelled settle can no longer anchor a run from unknown: the next + # stop status must end the run normally (a reversal or an HA stop). + self._end_run_from_unknown = False if self._stop_task is not None: try: current = asyncio.current_task() @@ -994,8 +1006,10 @@ async def async_restore_last_state(self, last_state: Any) -> None: self._start_position = 0 self._attr_is_closed = True elif last_state.state in (STATE_OPEN, "open"): - self._attr_current_cover_position = 100 - self._start_position = 100 + # "open" says nothing about the height: open, position unknown + # until an end stop (review of #795). "closed" is a height. + self._attr_current_cover_position = None + self._start_position = None self._attr_is_closed = False if self._slat_tilt: @@ -1499,15 +1513,38 @@ async def async_update(self) -> None: async def async_open_cover(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Open the cover.""" + if self._runs_to_end_from_unknown(True): + await self._async_run_to_end(True) + return await self._async_move("open") async def async_close_cover(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Close cover.""" + if self._runs_to_end_from_unknown(False): + await self._async_run_to_end(False) + return await self._async_move("close") + def _runs_to_end_from_unknown(self, opening: bool) -> bool: + """True when open / close must settle on the travel timer: a timed cover with no position. + + Nothing but an end stop can anchor the position, and the actuator's own + stop status may come before the configured travel time (an F411/2 timed + shorter than `travel_time`), so the run is settled like + `set_cover_position: 0` / `100` (#702) instead of waiting for that status. + """ + return ( + self._settles_open_close_from_unknown + and not self._advanced + and not self._strategy.assumed_state + and self.current_cover_position is None + and self._travel_for(opening) > 0 + ) + async def _async_move(self, direction: str, is_tilting: bool = False) -> asyncio.Future[Any] | None: """Queue a direction command and return its delivery future.""" self._cancel_stop_task() + self._end_run_from_unknown = False if not is_tilting or self._tilt_follows is not None: # A curtain command, or a blind's own pulse, takes the motor over. self._end_pulse_for_takeover() @@ -1582,6 +1619,8 @@ async def _async_run_to_end(self, opening: bool) -> None: travel_time = self._travel_for(opening) written = await self._async_move("open" if opening else "close") generation = self._run_generation + # _async_move anchored _start_position: None means the run starts unknown. + self._end_run_from_unknown = self._start_position is None and not self._strategy.assumed_state async def _settle() -> None: try: @@ -1608,9 +1647,11 @@ async def _settle() -> None: self._run_lead = self._move_lead = 0.0 self._pending_stop_context = None self._auto_stop_duration = None + self._end_run_from_unknown = False self._refresh_travel_attributes() self._publish_state() except HomeAssistantError as err: + self._end_run_from_unknown = False LOGGER.error("%s End-of-run settle aborted for %s: %s", self._gateway_handler.log_id, self._full_where, err) except asyncio.CancelledError: pass @@ -2413,6 +2454,39 @@ def handle_event(self, message: OWNMessage) -> None: was_tilting = self._is_tilting was_opening = bool(self._attr_is_opening) was_closing = bool(self._attr_is_closing) + if ( + not was_tilting + and (was_opening or was_closing) + and self._end_run_from_unknown + and self._stop_task is not None + and not self._stop_task.done() + and self._run_started_at is not None + and now < self._run_started_at + self._travel_for(was_opening) + ): + # A run HA commanded to an end stop from an unknown position, and + # the actuator stopped itself before the configured travel time + # (an F411/2 timed shorter than `travel_time`): there is no + # position to freeze, so the end position settles on the travel + # timer (#702). A keypad stop in that window is indistinguishable + # from the actuator's own and settles the same way (documented). + # The motor time is still measured: it is what calibration needs. + self._last_stop_at = now + self._stopped_event.set() + self._last_run = { + "seconds": round(now - self._run_started_at, 2), + "direction": "open" if was_opening else "close", + "ended_at": dt_util.utcnow().isoformat(timespec="milliseconds"), + } + self._refresh_travel_attributes() + LOGGER.debug( + "%s Stop status for %s %.2f s into a run from an unknown position; the end " + "position settles on the travel timer.", + self._gateway_handler.log_id, + self._full_where, + now - self._run_started_at, + ) + self._publish_state() + return self._is_tilting = False self._cancel_stop_task() self._run_generation += 1 diff --git a/custom_components/myhome/cover_scope.py b/custom_components/myhome/cover_scope.py index 9dc2e7968..c06803ed1 100644 --- a/custom_components/myhome/cover_scope.py +++ b/custom_components/myhome/cover_scope.py @@ -112,6 +112,8 @@ class MyHOMEScopeCover(MyHOMECover): A scope with no known members ends a run after its travel time. """ + _settles_open_close_from_unknown = False # the members settle their own runs + def __init__(self, *args: Any, scope: CoverScope, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.scope = scope diff --git a/docs/configuration/covers.md b/docs/configuration/covers.md index 770438c62..62aedcc06 100644 --- a/docs/configuration/covers.md +++ b/docs/configuration/covers.md @@ -64,7 +64,7 @@ Covers set up in the UI (without `myhome.yaml`) change their device class in Hom For standard covers without hardware position feedback, the integration provides a software estimator enabling full `open_cover`, `close_cover`, `stop_cover`, and slider-based `set_cover_position` support: -* **Unknown Until the First Full Run**: A timed actuator cannot report where the curtain is (for an F411/2, the gateway answers `*#2**10##` with no position frame), so a cover with no restored state starts as `unknown`, with no position, instead of an invented `open` at 50 %. The restored position from the last run of Home Assistant is kept. The position becomes known at an end stop: `open_cover`, `close_cover`, `set_cover_position: 0` / `100` or a keypad run that lasts the full travel time; a keypad stop before that leaves it unknown. While it is unknown, `set_cover_position` with any other target is refused (*"… the position is unknown until the cover has run fully open or closed once"*) and sends nothing, because there is no position to time the run from. +* **Unknown Until the First Full Run**: A timed actuator cannot report where the curtain is (for an F411/2, the gateway answers `*#2**10##` with no position frame), so a cover with no restored state starts as `unknown`, with no position, instead of an invented `open` at 50 %. The restored position from the last run of Home Assistant is kept. The position becomes known at an end stop. `open_cover`, `close_cover` and `set_cover_position: 0` / `100` run to the limit switch and settle the end position on the travel time plus 2 s, like the full runs of a known cover: the actuator's own stop status may come earlier (an F411/2 whose timer is shorter than `travel_time`), later or not at all; a stop status before the travel time does not end that run, one at or after it anchors the end position at once. A stop sent from Home Assistant during such a run leaves the position unknown; a keypad stop in that window cannot be told apart from the actuator's own stop and settles at the end position too. A keypad run that lasts the full travel time anchors the position as well; a keypad stop before that leaves it unknown. The state restored from the last run of Home Assistant is kept: a restored `closed` is 0 %, a restored `open` without a position stays open with an unknown position until an end stop. While it is unknown, `set_cover_position` with any other target is refused (*"… the position is unknown until the cover has run fully open or closed once"*) and sends nothing, because there is no position to time the run from. * **Direction-Aware Travel Times**: Because gravity and motor friction cause shutters to fall faster than they rise, the v2 engine tracks separate `travel_time_down` and `travel_time_up` durations (default: `25.0s`). * **Frame Anchor Timing**: The run timer starts when the direction frame is **written to the gateway**, not when Home Assistant queues it. * **Echo Suppression**: The gateway relays a momentary stop status (~0.1 s) followed by translation and the actual motor start (~0.55 s). The v2 engine recognizes these as command echoes, re-anchoring the timer to the true motor start rather than falsely treating them as manual stop commands. diff --git a/tests/test_component_cover.py b/tests/test_component_cover.py index 1d7aa445a..370908630 100644 --- a/tests/test_component_cover.py +++ b/tests/test_component_cover.py @@ -981,14 +981,14 @@ async def test_restore_entity_position(self, basic_cover): assert basic_cover._start_position == 43 assert basic_cover.is_closed is False - # 5. Invalid position attribute with state 'open' -> falls back to 100% + # 5. Invalid position attribute with state 'open' -> open, position unknown basic_cover._attr_current_cover_position = 50 basic_cover.async_get_last_state = AsyncMock( return_value=State("cover.basic_shutter", "open", {ATTR_CURRENT_POSITION: "invalid"}) ) await basic_cover.async_added_to_hass() - assert basic_cover.current_cover_position == 100 - assert basic_cover._start_position == 100 + assert basic_cover.current_cover_position is None + assert basic_cover._start_position is None assert basic_cover.is_closed is False # 6. Invalid position attribute with state 'closed' -> falls back to 0% @@ -1022,13 +1022,13 @@ async def test_restore_entity_fallback_from_state(self, basic_cover): assert basic_cover._start_position == 0 assert basic_cover.is_closed is True - # 2. State 'open' -> position 100%, is_closed False + # 2. State 'open' without a position -> open, position unknown (no height is implied) basic_cover.async_get_last_state = AsyncMock( return_value=State("cover.basic_shutter", "open", {}) ) await basic_cover.async_added_to_hass() - assert basic_cover.current_cover_position == 100 - assert basic_cover._start_position == 100 + assert basic_cover.current_cover_position is None + assert basic_cover._start_position is None assert basic_cover.is_closed is False # 3. State 'unknown' -> position remains unchanged (default 50) diff --git a/tests/test_component_logic_more.py b/tests/test_component_logic_more.py index 9420adb87..2fdc9f135 100644 --- a/tests/test_component_logic_more.py +++ b/tests/test_component_logic_more.py @@ -354,11 +354,13 @@ def test_handle_event_stop(self, cover): async def test_async_open_cover(self, cover): await cover.async_open_cover() cover._gateway_handler.send.assert_called_once() + cover._cancel_stop_task() # a run from unknown settles on the travel timer @pytest.mark.asyncio async def test_async_close_cover(self, cover): await cover.async_close_cover() cover._gateway_handler.send.assert_called_once() + cover._cancel_stop_task() # a run from unknown settles on the travel timer @pytest.mark.asyncio async def test_async_stop_cover(self, cover): diff --git a/tests/test_cover_calibration.py b/tests/test_cover_calibration.py index 2989b512d..78c0b0b05 100644 --- a/tests/test_cover_calibration.py +++ b/tests/test_cover_calibration.py @@ -972,6 +972,7 @@ async def test_actuator_stop_status_ends_the_measured_run(hass, gateway, clock, cover.handle_event(OWNEvent.parse("*2*0*21##")) attrs = cover.extra_state_attributes assert attrs["last_run_seconds"] == pytest.approx(18.4) and attrs["last_run_direction"] == "close" + cover._cancel_stop_task() # a run from unknown settles its end position on the travel timer async def test_set_position_re_anchor_does_not_shorten_the_measured_run(hass, gateway, clock, fake_time, sleeps): diff --git a/tests/test_cover_unknown_start.py b/tests/test_cover_unknown_start.py index 471587706..b91bf07b6 100644 --- a/tests/test_cover_unknown_start.py +++ b/tests/test_cover_unknown_start.py @@ -405,3 +405,124 @@ async def test_full_tilt_from_unknown_that_runs_long_moves_a_known_curtain(hass, await _yield() assert cover.current_cover_tilt_position == 100 assert cover.current_cover_position == 75 + + +def _controlled_sleep(release: asyncio.Event): + """asyncio.sleep stand-in: zero-length sleeps yield, longer ones wait for ``release``.""" + real_sleep = asyncio.sleep + + async def fake_sleep(duration, *args, **kwargs): + if duration <= 0: + await real_sleep(0) + return + await release.wait() + + return fake_sleep + + +async def test_close_from_unknown_settles_when_the_actuator_stops_early(hass, gateway, clock, fake_time) -> None: + """The F411/2 stops itself at 5 s while travel_time is 6: the end position settles on the timer (review 5642333408).""" + cover = _make_cover(hass, gateway) + release = asyncio.Event() + with patch("custom_components.myhome.cover.asyncio.sleep", side_effect=_controlled_sleep(release)): + try: + await _close_from_unknown(cover, gateway, clock) + clock.now += 5.0 + cover.handle_event(OWNEvent.parse("*2*0*05##")) # the actuator's own timer, before travel_time + assert cover.is_closing is True # the run is still settling + assert cover.current_cover_position is None + assert cover.extra_state_attributes["last_run_seconds"] == pytest.approx(5.0) # measured anyway + clock.now += 3.1 # travel_time + END_SETTLE_MARGIN passed + finally: + release.set() + await asyncio.wait_for(cover._stop_task, 1.0) + assert cover.is_closing is False + assert cover.current_cover_position == 0 + assert cover.is_closed is True + assert [raw for raw, _ in gateway.deliveries] == ["*2*2*05##"] # no stop frame was sent + + +async def test_open_from_unknown_settles_on_the_timer_without_a_stop_status(hass, gateway, clock, fake_time) -> None: + """open_cover from unknown on a gateway that relays no stop status: 100 % after travel_time + 2 s.""" + cover = _make_cover(hass, gateway) + release = asyncio.Event() + with patch("custom_components.myhome.cover.asyncio.sleep", side_effect=_controlled_sleep(release)): + try: + await cover.async_open_cover() + raw, written = gateway.deliveries[-1] + assert raw == "*2*1*05##" + written.set_result(clock.now) + await _yield() + clock.now += 0.12 + cover.handle_event(OWNEvent.parse("*2*1*05##")) # direction echo, then silence + await _yield() + assert cover.is_opening is True + assert cover.current_cover_position is None + clock.now += 8.1 + finally: + release.set() + await asyncio.wait_for(cover._stop_task, 1.0) + assert cover.current_cover_position == 100 + assert cover.is_closed is False + assert cover.is_opening is False + + +async def test_early_actuator_stop_of_a_known_run_still_freezes_the_estimate(hass, gateway, clock, fake_time) -> None: + """Regression guard: from a known position the early stop still ends the run at the timed estimate.""" + cover = _make_cover(hass, gateway) + cover._attr_current_cover_position = 100 + cover._start_position = 100 + cover._attr_is_closed = False + await cover.async_close_cover() + raw, written = gateway.deliveries[-1] + assert raw == "*2*2*05##" + written.set_result(clock.now) + await _yield() + clock.now += 0.12 + cover.handle_event(OWNEvent.parse("*2*2*05##")) + clock.now += 3.0 + cover.handle_event(OWNEvent.parse("*2*0*05##")) # stopped halfway + assert cover.is_closing is False + assert cover.current_cover_position == 50 + + +async def test_restored_open_without_a_position_stays_open_and_unknown(hass, gateway) -> None: + """A last state "open" with no position says nothing about the height: open, position unknown (review 5642333408).""" + cover = _make_cover(hass, gateway) + cover.async_get_last_state = AsyncMock(return_value=State("cover.x", "open", {})) + await cover.async_added_to_hass() + assert cover.current_cover_position is None + assert cover.is_closed is False + assert cover.state == "open" + with pytest.raises(ServiceValidationError) as err: + await cover.async_set_cover_position(position=50) + assert err.value.translation_key == "cover_position_unknown" + assert gateway.deliveries == [] + + +async def test_restored_closed_without_a_position_is_still_closed(hass, gateway) -> None: + """"closed" is a height: a restored closed state without a position is 0 %.""" + cover = _make_cover(hass, gateway) + cover.async_get_last_state = AsyncMock(return_value=State("cover.x", "closed", {})) + await cover.async_added_to_hass() + assert cover.current_cover_position == 0 + assert cover.is_closed is True + + +async def test_reversal_during_a_settling_run_from_unknown_ends_at_the_next_stop(hass, gateway, clock, fake_time) -> None: + """A keypad reversal cancels the settle; the stop that follows ends the reversed run (no stuck closing).""" + cover = _make_cover(hass, gateway) + release = asyncio.Event() + with patch("custom_components.myhome.cover.asyncio.sleep", side_effect=_controlled_sleep(release)): + try: + await _close_from_unknown(cover, gateway, clock) + clock.now += 2.0 + cover.handle_event(OWNEvent.parse("*2*1*05##")) # somebody reversed it from the keypad + assert cover.is_opening is True + clock.now += 1.0 + cover.handle_event(OWNEvent.parse("*2*0*05##")) # and stopped it + finally: + release.set() + assert cover.is_opening is False + assert cover.is_closing is False + assert cover.current_cover_position is None # still unknown: no end stop was reached diff --git a/tests/test_issue_433_cover_scope.py b/tests/test_issue_433_cover_scope.py index 09214b5eb..158c6504b 100644 --- a/tests/test_issue_433_cover_scope.py +++ b/tests/test_issue_433_cover_scope.py @@ -358,6 +358,8 @@ async def test_scope_cover_behind_an_interface_commands_each_member(gateway) -> gateway.send.reset_mock() await area.async_close_cover() assert sorted(_sent(gateway)) == ["*2*2*11#4#02##", "*2*2*12#4#02##"] + for p in points: + p._cancel_stop_task() # the members' runs from unknown settle on their travel timer async def test_scope_cover_on_the_main_bus_sends_one_scope_frame(gateway) -> None: