Skip to content
Merged
1 change: 1 addition & 0 deletions .github/release-notes/2.0.0b16.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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.
Expand Down
220 changes: 185 additions & 35 deletions custom_components/myhome/cover.py

Large diffs are not rendered by default.

22 changes: 19 additions & 3 deletions custom_components/myhome/cover_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -166,10 +168,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]:
Expand Down Expand Up @@ -221,6 +232,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)
Expand Down
5 changes: 4 additions & 1 deletion custom_components/myhome/cover_strategies/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
41 changes: 30 additions & 11 deletions custom_components/myhome/cover_strategies/timed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -141,13 +142,27 @@ 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)
end_stop_from_unknown = False
if travel_time > 0 and (is_opening or is_closing):
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
if run_start_pos is not None:
reanchored = self.position_after(
run_start_pos, elapsed, direction, entity=entity, lead=lead
)
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. 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:
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),
Expand All @@ -157,11 +172,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
Expand All @@ -176,12 +192,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,
Expand Down
6 changes: 6 additions & 0 deletions custom_components/myhome/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,12 @@
"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_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"
},
Expand Down
6 changes: 6 additions & 0 deletions custom_components/myhome/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,12 @@
"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_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"
},
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration/covers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (for an F411/2, the gateway answers `*#2*<WHERE>*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.
Expand Down Expand Up @@ -184,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.

Expand Down
15 changes: 9 additions & 6 deletions tests/test_component_cover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -979,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%
Expand Down Expand Up @@ -1020,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)
Expand Down Expand Up @@ -1178,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)
Expand Down
2 changes: 2 additions & 0 deletions tests/test_component_logic_more.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading