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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/release-notes/2.0.0b16.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 14 additions & 2 deletions custom_components/myhome/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
38 changes: 30 additions & 8 deletions custom_components/myhome/cover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
43 changes: 40 additions & 3 deletions custom_components/myhome/cover_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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():
Expand All @@ -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():
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration/climate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*<WHERE>*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*<WHERE>##` 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 = <zone>#4#<interface>`) is not polled on the F450, F454, F459 and MH202: their firmware drops `*#4*<WHERE>*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).
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration/covers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*<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.
* **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, 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.
Expand Down
Loading
Loading