From 5d97429470b5dfb62be37ae41c683cef2f7a3be2 Mon Sep 17 00:00:00 2001 From: gdluck Date: Mon, 26 Oct 2026 19:38:48 +0200 Subject: [PATCH 1/2] fix(climate): restore the saved setpoint with its knob offset, not as the nominal (#791 follow-up) The saved `temperature` is what the slider showed, which always carries the knob offset: the setpoint in force (dimension 12, offset included) once the zone has reported it, else the commanded setpoint plus the offset. The restore path put that value into _target_temperature, the nominal slot, with the offset still 0, so the first dimension 13 after a restart added the offset a second time: 23.5 restored with offset +2, `*#4*2*13*02##` -> 25.5 shown until the next dimension 12 report (the live path showed 23.5). The offset and the local target were saved as attributes but never read back. The restore now follows the live rule: the saved `local_offset` is restored, the nominal is the shown value less that offset and the shown value goes into the local-target slot. The flag stays False, so a dimension 14 before the next dimension 12 shows the commanded setpoint plus the offset, as a fresh entity does. A state saved without a usable offset is read as before. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/climate.py | 16 ++++++- docs/configuration/climate.md | 2 +- tests/test_climate_setpoint_in_force.py | 61 +++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/custom_components/myhome/climate.py b/custom_components/myhome/climate.py index 35b037a4d..c66ce736e 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/docs/configuration/climate.md b/docs/configuration/climate.md index 263c165c9..16ee17f5e 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/tests/test_climate_setpoint_in_force.py b/tests/test_climate_setpoint_in_force.py index 61e60a792..0193741b7 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 From bf1d50ad850f9238e35f02668dd46077d8f5ab29 Mon Sep 17 00:00:00 2001 From: gdluck Date: Mon, 26 Oct 2026 19:38:48 +0200 Subject: [PATCH 2/2] fix(cover): members of a scope cover settle a run from unknown on their travel timer too (#795 follow-up) On the main bus (and for a general frame, which crosses an F422 natively) an area / group cover sends one scope frame; the gateway relays it to the members, which took it as an external run. A member with no position then froze to no position when its F411/2 stopped itself before `travel_time`, and stayed unknown until another event anchored it, while the same member commanded directly settles on travel_time + 2 s. The class comment "the members settle their own runs" held only for the F422 fan-out path, and the release note and docs promised the settle for the open / close buttons of every timed cover. The state setup of _async_move becomes _begin_run and the settle of _async_run_to_end becomes _settle_run_to_end. A scope open / close / set_cover_position 0 / 100 without fan-out opens the run of every member that _runs_to_end_from_unknown before the frame is queued, tracks the write on each (a lost frame aborts their runs as an own command's would) and settles them on their travel timer; the relayed scope frame is their motor-start echo. Members with a known position keep timing the run from it, as for a direct command. A scope stop cancels the members' settles first, as a direct stop does, so the relayed stop ends their run with the position still unknown instead of being taken for the actuator's own early stop. Docs and release note say so; the stale "(default 50)" comment in the restore test says "(preset to 50)". Tests: early actuator stop after a scope open, a known member next to an unknown one on a scope close, a scope frame refused at once and one lost after queueing, set_cover_position 0 on the scope, a scope stop during the settle. Co-Authored-By: Claude Fable 5.1 --- .github/release-notes/2.0.0b16.md | 2 +- custom_components/myhome/cover.py | 38 ++++- custom_components/myhome/cover_scope.py | 43 ++++- docs/configuration/covers.md | 2 +- tests/test_component_cover.py | 2 +- tests/test_cover_unknown_start.py | 203 +++++++++++++++++++++++- tests/test_issue_433_cover_scope.py | 4 + 7 files changed, 278 insertions(+), 16 deletions(-) diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index 41d995796..889c7c692 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/cover.py b/custom_components/myhome/cover.py index 5fc30d784..92c4616c8 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 c06803ed1..2dbdd25b0 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/covers.md b/docs/configuration/covers.md index 6e6345441..c4bac849a 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_component_cover.py b/tests/test_component_cover.py index 370908630..9003e7183 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 b91bf07b6..8af2c5f78 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 158c6504b..65cc29eea 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: