diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index 41d99579..889c7c69 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: 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. +- **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`), for a cover commanded directly as for the members of an area / group cover moved by one scope frame on the main bus; 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/climate.py b/custom_components/myhome/climate.py index 35b037a4..c66ce736 100644 --- a/custom_components/myhome/climate.py +++ b/custom_components/myhome/climate.py @@ -548,9 +548,21 @@ async def async_restore_last_state(self, last_state: State | None) -> None: target_temp = last_state.attributes.get("temperature") if target_temp is not None: try: - self._target_temperature = float(target_temp) + shown = float(target_temp) except (ValueError, TypeError): - pass + shown = None + if shown is not None: + # The saved value is what the slider showed, knob offset included (the + # setpoint in force, or the commanded one plus the offset), so the nominal + # is that less the saved offset. As on the live path, the first dimension + # 13 / 14 report after the restart then puts the offset on the nominal, + # not a second time on a value that already carries it (#791). + try: + self._local_offset = int(float(last_state.attributes.get("local_offset") or 0)) + except (ValueError, TypeError): + pass + self._target_temperature = shown - self._local_offset + self._local_target_temperature = shown if "program" in last_state.attributes: try: self._program = int(last_state.attributes["program"]) diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py index 5fc30d78..92c4616c 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -1541,18 +1541,16 @@ def _runs_to_end_from_unknown(self, opening: bool) -> bool: 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.""" + def _begin_run(self, direction: str, is_tilting: bool = False) -> None: + """Open the run of a direction frame about to be queued: this cover's own, or a scope + frame that moves it (the gateway relays that frame to every member, so it is the + member's motor-start echo too).""" 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() self._is_tilting = is_tilting - if direction == "open": - command = OWNAutomationCommand.raise_shutter(self._full_where) - else: - command = OWNAutomationCommand.lower_shutter(self._full_where) if is_tilting: # A slat pulse opens an echo window like any command (the gateway # relays a stop status before the direction status, which must not @@ -1580,6 +1578,14 @@ async def _async_move(self, direction: str, is_tilting: bool = False) -> asyncio self._is_closed_before_move = self._attr_is_closed self._attr_is_closed = False self._begin_command(direction) + + 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._begin_run(direction, is_tilting) + if direction == "open": + command = OWNAutomationCommand.raise_shutter(self._full_where) + else: + command = OWNAutomationCommand.lower_shutter(self._full_where) written = await self._gateway_handler.send(command) if self._calibrating or self.calibration_hub.is_calibrating: _record_calibration_frame(self._gateway_handler, "tx", str(command), direction_action=direction, entity_id=self.entity_id) @@ -1616,10 +1622,26 @@ async def _async_run_to_end(self, opening: bool) -> None: END_SETTLE_MARGIN has passed, publish the end position; the real stop status, if it comes, finds nothing left to change. """ - travel_time = self._travel_for(opening) written = await self._async_move("open" if opening else "close") + self._settle_run_to_end(opening, written) + + def _follow_scope_run_to_end(self, opening: bool, written: asyncio.Future[Any] | None) -> None: + """A scope frame just queued is this cover's direction frame as well. + + ``_begin_run`` opened the run before the frame was queued, as ``_async_move`` does + for an own command; the write is tracked and the run settles on the travel timer + like an own open / close from an unknown position, instead of freezing to no + position at the actuator's own stop (#795). + """ + self._track_write(written) + self._settle_run_to_end(opening, written) + self._publish_state() + + def _settle_run_to_end(self, opening: bool, written: asyncio.Future[Any] | None) -> None: + """Settle the run just queued on the travel timer (see ``_async_run_to_end``).""" + travel_time = self._travel_for(opening) generation = self._run_generation - # _async_move anchored _start_position: None means the run starts unknown. + # _begin_run 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: diff --git a/custom_components/myhome/cover_scope.py b/custom_components/myhome/cover_scope.py index c06803ed..2dbdd25b 100644 --- a/custom_components/myhome/cover_scope.py +++ b/custom_components/myhome/cover_scope.py @@ -112,7 +112,9 @@ 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 + # No position of its own to settle: the members with no position settle on their + # travel timer, armed by _async_run_members_to_end for a scope frame the gateway relays. + _settles_open_close_from_unknown = False def __init__(self, *args: Any, scope: CoverScope, **kwargs: Any) -> None: super().__init__(*args, **kwargs) @@ -210,7 +212,7 @@ async def async_open_cover(self, **kwargs: Any) -> None: await asyncio.sleep(PACED_FANOUT_DELAY) await member.async_open_cover(**kwargs) return - await super().async_open_cover(**kwargs) + await self._async_run_members_to_end(True) async def async_close_cover(self, **kwargs: Any) -> None: if members := self._fan_out(): @@ -219,7 +221,37 @@ async def async_close_cover(self, **kwargs: Any) -> None: await asyncio.sleep(PACED_FANOUT_DELAY) await member.async_close_cover(**kwargs) return - await super().async_close_cover(**kwargs) + await self._async_run_members_to_end(False) + + async def _async_run_members_to_end(self, opening: bool) -> asyncio.Future[Any] | None: + """One scope frame runs every member to its end stop (no F422 fan-out: the main bus, + or a general frame, which crosses the interface natively). + + The gateway relays the frame to the members, which took it as an external run + and froze at the actuator's own stop: a member with no position stayed unknown + when its F411/2 stopped before `travel_time`, while the same member commanded + directly settled on its travel timer (#795). Such members now open their run on + the scope's frame and settle like their own open / close; members with a known + position keep timing the run from it, as for a direct command. + """ + direction = "open" if opening else "close" + followers = [m for m in self._members() if m._runs_to_end_from_unknown(opening)] + for member in followers: + member._begin_run(direction) + try: + written = await self._async_move(direction) + except Exception: + for member in followers: + member._abort_motion() # the scope frame never reached the bus: nothing moved + raise + for member in followers: + member._follow_scope_run_to_end(opening, written) + return written + + async def _async_run_to_end(self, opening: bool) -> None: + """`set_cover_position: 0` / `100` with one scope frame: the members settle too.""" + written = await self._async_run_members_to_end(opening) + self._settle_run_to_end(opening, written) async def async_stop_cover(self, **kwargs: Any) -> None: if members := self._fan_out(): @@ -228,6 +260,11 @@ async def async_stop_cover(self, **kwargs: Any) -> None: await asyncio.sleep(PACED_FANOUT_DELAY) await member.async_stop_cover(**kwargs) return + for member in self._members(): + if member._end_run_from_unknown: + # The relayed stop must end the member's run from unknown (position still + # unknown), not be taken for the actuator's own early stop: as a direct stop. + member._cancel_stop_task() await super().async_stop_cover(**kwargs) async def async_set_cover_position(self, **kwargs: Any) -> None: diff --git a/docs/configuration/climate.md b/docs/configuration/climate.md index 263c165c..16ee17f5 100644 --- a/docs/configuration/climate.md +++ b/docs/configuration/climate.md @@ -54,7 +54,7 @@ MyHOME climate entities support standard Home Assistant climate controls: - `auto`: Following scheduled setpoint or central program. - `off`: System frost-protection or thermal shutdown. * **Target Temperature**: Adjustable setpoint slider in 0.5 °C increments. - The slider shows the setpoint the thermostat regulates to (dimension 12, knob offset included) whenever the zone has reported it; the commanded setpoint (dimension 14) is only shown until then. On an F461 the two can differ (21.0 commanded, 23.5 in force on a live capture), and the attribute `local_target_temperature` carries the same value. When the zone leaves `off`, the value reported while it was off is taken as the protection setpoint (#383), so the commanded setpoint is shown until the zone reports dimension 12 again; after a `set_temperature` call the next report, dimension 12 or 14, is shown. + The slider shows the setpoint the thermostat regulates to (dimension 12, knob offset included) whenever the zone has reported it; the commanded setpoint (dimension 14) is only shown until then. On an F461 the two can differ (21.0 commanded, 23.5 in force on a live capture), and the attribute `local_target_temperature` carries the same value. When the zone leaves `off`, the value reported while it was off is taken as the protection setpoint (#383), so the commanded setpoint is shown until the zone reports dimension 12 again; after a `set_temperature` call the next report, dimension 12 or 14, is shown. The value restored after a restart of Home Assistant already carries the saved `local_offset`, so the first dimension 13 report does not add it again. * **Current Temperature**: Real-time ambient reading reported by the probe. On SCS-only gateways whose firmware drops the general request `*#4*0##` (F450, F454, F459, H4684, MH200, MH201, MH202), each zone asks for its own temperature with `*#4**0##` after the startup sweep, so a standalone thermostat is not `unknown` until it next reports (#185). F460, F461 and MyHomeServer1 drop `*#4*0##` as well but answer the zone status request `*#4*##` themselves (the F460 and F461 expand it into the four dimension requests, the MyHomeServer1 replies from its cache), so their zones keep that poll. A zone behind an F422 bus interface (`WHERE = #4#`) is not polled on the F450, F454, F459 and MH202: their firmware drops `*#4**0##` for such a zone before it reaches the bus (own-firmware-oracle PR #60), so the zone shows its restored reading until it reports by itself; the H4684, MH200 and MH201 poll it. After a restart the last reading is shown with the attribute `current_temperature_restored: true` until the zone reports again. * **Current Humidity**: Ambient relative humidity percentage for zones equipped with humidity sensors (e.g. Living Now thermostats or humidity probes). diff --git a/docs/configuration/covers.md b/docs/configuration/covers.md index 6e634544..c4bac849 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` 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. +* **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, whether the cover is commanded directly or as a member of an area / group cover whose one scope frame on the main bus moves it: 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_climate_setpoint_in_force.py b/tests/test_climate_setpoint_in_force.py index 61e60a79..0193741b 100644 --- a/tests/test_climate_setpoint_in_force.py +++ b/tests/test_climate_setpoint_in_force.py @@ -11,6 +11,7 @@ import pytest from homeassistant.components.climate import HVACMode +from homeassistant.core import State from OWNd.message import OWNHeatingEvent from custom_components.myhome import climate as climate_module @@ -188,3 +189,63 @@ async def test_dimension_12_just_before_leaving_off_is_treated_as_protection(has _feed(zone, "*#4*2*12*0235*3##") assert zone.target_temperature == 23.5 assert zone._local_target_reported is True + + +# ------------------------------------------------------------------ after a restart + +async def test_restored_setpoint_in_force_does_not_get_the_offset_added_again(hass): + """The saved `temperature` already carries the knob offset: dimension 13 after a restart leaves it alone. + + Restoring 23.5 into the nominal slot made the first `*#4*2*13*02##` show 25.5 (23.5 + 2 on + top of a value that has the 2) until the next dimension 12 report; the live path showed 23.5. + """ + zone = _zone(hass) + _feed(zone, "*#4*2*12*0235*3##", "*4*1*2##", "*#4*2*14*0210*3##", "*#4*2*13*02##") + saved = State("climate.zone_2", "heat", {"temperature": zone.target_temperature, **zone.extra_state_attributes}) + assert saved.attributes["temperature"] == 23.5 and saved.attributes["local_offset"] == 2 + restored = _zone(hass) + await restored.async_restore_last_state(saved) + assert restored.target_temperature == 23.5 + assert restored.extra_state_attributes["local_target_temperature"] == 23.5 + _feed(restored, "*#4*2*13*02##") # the first report after the restart is the knob offset + assert restored.target_temperature == 23.5 + _feed(restored, *F461_POLL_REPLY[:4], "*#4*2*13*02##") # the next poll + assert restored.target_temperature == 23.5 + assert restored._target_temperature == 21.0 + + +async def test_restored_setpoint_follows_the_live_rule_before_dimension_12(hass): + """Until the zone reports dimension 12 again, a dimension 14 shows the commanded setpoint plus the offset.""" + restored = _zone(hass) + await restored.async_restore_last_state(State("climate.zone_2", "heat", {"temperature": 23.5, "local_offset": 2})) + assert restored._local_target_reported is False + _feed(restored, "*4*1*2##", "*#4*2*14*0210*3##") + assert restored.target_temperature == 23.0 # as a fresh entity would show it + _feed(restored, "*#4*2*12*0235*3##") + assert restored.target_temperature == 23.5 + + +@pytest.mark.parametrize( + ("attributes", "after_offset_report"), + [ + ({"temperature": 22.0}, 23.0), + ({"temperature": 22.0, "local_offset": "knob"}, 23.0), + ({"temperature": 22.0, "local_offset": None}, 23.0), + ({"temperature": 22.0, "local_offset": "1.0"}, 22.0), + ], + ids=["no-offset-saved", "unreadable-offset", "offset-none", "offset-as-text"], +) +async def test_restored_setpoint_without_a_usable_offset_is_taken_as_the_nominal(hass, attributes, after_offset_report): + """Without a saved offset nothing says the value carries one: it is the nominal, as before.""" + restored = _zone(hass) + await restored.async_restore_last_state(State("climate.zone_2", "heat", attributes)) + assert restored.target_temperature == 22.0 + _feed(restored, "*#4*2*13*01##") + assert restored.target_temperature == after_offset_report + + +async def test_unreadable_restored_setpoint_is_ignored(hass): + restored = _zone(hass) + await restored.async_restore_last_state(State("climate.zone_2", "heat", {"temperature": "warm", "local_offset": 2})) + assert restored.target_temperature is None + assert restored._local_offset == 0 diff --git a/tests/test_component_cover.py b/tests/test_component_cover.py index 37090863..9003e718 100644 --- a/tests/test_component_cover.py +++ b/tests/test_component_cover.py @@ -1031,7 +1031,7 @@ async def test_restore_entity_fallback_from_state(self, basic_cover): assert basic_cover._start_position is None assert basic_cover.is_closed is False - # 3. State 'unknown' -> position remains unchanged (default 50) + # 3. State 'unknown' -> position remains unchanged (preset to 50) basic_cover._attr_current_cover_position = 50 basic_cover._start_position = 50 basic_cover.async_get_last_state = AsyncMock( diff --git a/tests/test_cover_unknown_start.py b/tests/test_cover_unknown_start.py index b91bf07b..8af2c5f7 100644 --- a/tests/test_cover_unknown_start.py +++ b/tests/test_cover_unknown_start.py @@ -7,10 +7,10 @@ import pytest from homeassistant.components.cover import ATTR_CURRENT_POSITION from homeassistant.core import State -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from OWNd.message import OWNEvent -from custom_components.myhome.cover import MyHOMECover +from custom_components.myhome.cover import CoverFamily, CoverScope, MyHOMECover, MyHOMEScopeCover class Clock: @@ -526,3 +526,202 @@ async def test_reversal_during_a_settling_run_from_unknown_ends_at_the_next_stop 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 + + +# ------------------------------------------------------------------ members of a scope cover on the main bus + + +def _make_area(hass, gateway, members): + """Area 1 on the main bus (no F422): one scope frame moves every member.""" + family = CoverFamily() + area = MyHOMEScopeCover( + hass=hass, gateway=gateway, name="Area 1", entity_name=None, device_id="1", who="2", where="1", + interface=None, advanced=False, manufacturer="BTicino", model="Area", travel_time=6, + scope=CoverScope.of("1"), + ) + area.hass = hass + area.entity_id = "cover.area_1" + area.async_write_ha_state = MagicMock() + area.async_schedule_update_ha_state = MagicMock() + for cover in (*members, area): + family.add(cover) + return area + + +def _make_members(hass, gateway): + return [ + _make_cover(hass, gateway, name=f"Shutter {where}", device_id=where, where=where) + for where in ("11", "12") + ] + + +async def _open_area_from_unknown(area, members, gateway, clock): + """open_cover on the area: one scope frame, relayed by the gateway to the scope cover and its members.""" + await area.async_open_cover() + raw, written = gateway.deliveries[-1] + assert raw == "*2*1*1##" + assert all(m.is_opening and m._end_run_from_unknown for m in members) + written.set_result(clock.now) + await _yield() + clock.now += 0.12 + for cover in (area, *members): + cover.handle_event(OWNEvent.parse("*2*1*1##")) + assert area.is_opening is True + assert all(m.is_opening and m.current_cover_position is None for m in members) + + +async def test_scope_open_on_the_main_bus_settles_members_whose_actuator_stops_early(hass, gateway, clock, fake_time) -> None: + """The area's one frame moves members with no position: an F411/2 stop at 5 s settles them on the timer, as a direct open does.""" + members = _make_members(hass, gateway) + area = _make_area(hass, gateway, members) + release = asyncio.Event() + with patch("custom_components.myhome.cover.asyncio.sleep", side_effect=_controlled_sleep(release)): + try: + await _open_area_from_unknown(area, members, gateway, clock) + clock.now += 5.0 + for member in members: + member.handle_event(OWNEvent.parse(f"*2*0*{member._where}##")) # each actuator's own timer + assert all(m.is_opening and m.current_cover_position is None for m in members) # still settling + assert members[0].extra_state_attributes["last_run_seconds"] == pytest.approx(5.0) + clock.now += 3.1 # travel_time + END_SETTLE_MARGIN passed + finally: + release.set() + await asyncio.wait_for(asyncio.gather(*(m._stop_task for m in members)), 1.0) + assert [m.current_cover_position for m in members] == [100, 100] + assert all(m.is_closed is False and m.is_opening is False for m in members) + assert area.current_cover_position == 100 + assert area.is_closed is False + assert area.is_opening is False + assert [raw for raw, _ in gateway.deliveries] == ["*2*1*1##"] # no per-member frame, no stop frame + + +async def test_scope_close_on_the_main_bus_leaves_a_known_member_to_the_actuator_stop(hass, gateway, clock, fake_time) -> None: + """Only members with no position are settled on the timer; a known one is timed from its position, as for a direct close.""" + members = _make_members(hass, gateway) + known, unknown = members + known._attr_current_cover_position = known._start_position = 100 + known._attr_is_closed = False + area = _make_area(hass, gateway, members) + release = asyncio.Event() + with patch("custom_components.myhome.cover.asyncio.sleep", side_effect=_controlled_sleep(release)): + try: + await area.async_close_cover() + raw, written = gateway.deliveries[-1] + assert raw == "*2*2*1##" + assert known._end_run_from_unknown is False and known._stop_task is None + assert unknown._end_run_from_unknown is True + written.set_result(clock.now) + await _yield() + clock.now += 0.12 + for cover in (area, *members): + cover.handle_event(OWNEvent.parse("*2*2*1##")) + clock.now += 3.0 + for member in members: + member.handle_event(OWNEvent.parse(f"*2*0*{member._where}##")) + assert known.is_closing is False + assert known.current_cover_position == 50 # halfway down a 6 s travel from 100 + assert unknown.is_closing is True + assert unknown.current_cover_position is None + clock.now += 5.1 + finally: + release.set() + await asyncio.wait_for(unknown._stop_task, 1.0) + assert unknown.current_cover_position == 0 + assert unknown.is_closed is True + assert area.current_cover_position == 25 + assert area.is_closed is False + + +async def test_scope_frame_that_fails_at_once_leaves_the_members_as_they_were(hass, gateway, clock, fake_time) -> None: + """A scope frame refused on queueing: the members were not moved and go back to unknown, nothing settles.""" + members = _make_members(hass, gateway) + area = _make_area(hass, gateway, members) + + async def _refuse(message): + fut = asyncio.get_running_loop().create_future() + fut.set_exception(ConnectionError("gateway connection lost")) + gateway.deliveries.append((str(message), fut)) + return fut + + gateway.send = AsyncMock(side_effect=_refuse) + with pytest.raises(HomeAssistantError): + await area.async_open_cover() + assert [raw for raw, _ in gateway.deliveries] == ["*2*1*1##"] + for member in members: + assert member.is_opening is False + assert member.is_closed is None + assert member.current_cover_position is None + assert member._end_run_from_unknown is False + assert member._stop_task is None + assert area.is_opening is False + + +async def test_scope_frame_lost_after_queueing_aborts_the_members_runs(hass, gateway, clock, fake_time) -> None: + """The scope frame's delivery fails later: each member's tracked write aborts its run, as an own command would.""" + members = _make_members(hass, gateway) + area = _make_area(hass, gateway, members) + await area.async_open_cover() + raw, written = gateway.deliveries[-1] + assert raw == "*2*1*1##" + assert all(m.is_opening for m in members) + written.set_exception(ConnectionError("gateway connection lost")) + await _yield() + for member in members: + assert member.is_opening is False + assert member.is_closed is None + assert member.current_cover_position is None + assert member._end_run_from_unknown is False + assert area.is_opening is False + + +async def test_scope_set_position_0_on_the_main_bus_settles_the_members_too(hass, gateway, clock, fake_time) -> None: + """`set_cover_position: 0` on the area is a full run of the scope: its members with no position settle like its own run.""" + members = _make_members(hass, gateway) + area = _make_area(hass, gateway, members) + release = asyncio.Event() + with patch("custom_components.myhome.cover.asyncio.sleep", side_effect=_controlled_sleep(release)): + try: + await area.async_set_cover_position(position=0) + raw, written = gateway.deliveries[-1] + assert raw == "*2*2*1##" + assert all(m.is_closing and m._end_run_from_unknown for m in members) + assert area._stop_task is not None # the scope's own settle, as before + written.set_result(clock.now) + await _yield() + clock.now += 0.12 + for cover in (area, *members): + cover.handle_event(OWNEvent.parse("*2*2*1##")) + clock.now += 5.0 + for member in members: + member.handle_event(OWNEvent.parse(f"*2*0*{member._where}##")) + assert all(m.is_closing and m.current_cover_position is None for m in members) + clock.now += 3.1 + finally: + release.set() + await asyncio.wait_for(asyncio.gather(area._stop_task, *(m._stop_task for m in members)), 1.0) + assert [m.current_cover_position for m in members] == [0, 0] + assert area.current_cover_position == 0 + assert area.is_closed is True + + +async def test_scope_stop_on_the_main_bus_leaves_the_settling_members_unknown(hass, gateway, clock, fake_time) -> None: + """stop_cover on the area before the end stop: the relayed stop ends each member's run, no position is invented.""" + members = _make_members(hass, gateway) + area = _make_area(hass, gateway, members) + await _open_area_from_unknown(area, members, gateway, clock) + clock.now += 2.0 + await area.async_stop_cover() + raw, stop_written = gateway.deliveries[-1] + assert raw == "*2*0*1##" + assert all(m._end_run_from_unknown is False and m._stop_task is None for m in members) + stop_written.set_result(clock.now) + await _yield() + clock.now += 0.05 + for member in members: + member.handle_event(OWNEvent.parse(f"*2*0*{member._where}##")) # the relayed stop status + assert member.is_opening is False + assert member.current_cover_position is None + assert member.is_closed is None + assert member.extra_state_attributes["last_run_seconds"] == pytest.approx(2.05) + assert area.is_opening is False + assert area.current_cover_position is None diff --git a/tests/test_issue_433_cover_scope.py b/tests/test_issue_433_cover_scope.py index 158c6504..65cc29ee 100644 --- a/tests/test_issue_433_cover_scope.py +++ b/tests/test_issue_433_cover_scope.py @@ -370,6 +370,8 @@ async def test_scope_cover_on_the_main_bus_sends_one_scope_frame(gateway) -> Non family.add(c) await area.async_open_cover() assert _sent(gateway) == ["*2*1*1##"] + for c in family.members(area.scope): + c._cancel_stop_task() # the members' runs from unknown settle on their travel timer def test_general_scope_behind_an_interface_holds_that_interface_only(gateway) -> None: @@ -450,6 +452,8 @@ async def test_general_scope_cover_behind_an_interface_sends_native_broadcast(ga gateway.send.reset_mock() await general.async_close_cover() assert _sent(gateway) == ["*2*2*0#4#02##"] + for p in points: + p._cancel_stop_task() # the members' runs from unknown settle on their travel timer async def test_general_scope_cover_behind_an_interface_paces_member_positioning(gateway) -> None: