Skip to content

fix(cover): confirm end_stop on full-travel bus runs and scope run timeouts - #760

Merged
GreenGrassBlueOcean merged 2 commits into
v2-phase1-architecturefrom
fix/cover-strategy-end-stop
Oct 20, 2026
Merged

GreenGrassBlueOcean merged 2 commits into
v2-phase1-architecturefrom
fix/cover-strategy-end-stop

Conversation

@GreenGrassBlueOcean

@GreenGrassBlueOcean GreenGrassBlueOcean commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #739 addressing the two review items from @Interstellar0verdrive.


1. What is the problem?

  1. A keypad or bus run that hits the physical limit switch reported "stop" instead of "end_stop" (custom_components/myhome/cover.py):
    • On the OpenWebNet bus, when a standard timed shutter actuator (BT-F411/2, F411/4, LN4672M2) reaches its limit switch / hardware relay cutoff at the end of a keypad run (*2*1*WHERE## or *2*2*WHERE##), the actuator emits *2*0*WHERE## (WHAT=0, identical on the wire to a manual keypad stop).
    • Because MyHOMECover._freeze_position(at) unconditionally passed "stop" to self._strategy.on_run_ended(self, "stop", frozen, at) without checking whether the run had lasted at least the full directional travel time (at - run_started_at >= _travel_for(is_opening)), a full-travel keypad run reported "stop" instead of "end_stop" and never called self._strategy.on_position_confirmed(self, frozen, "end_stop").
    • For stateful strategies that leave position None until an endpoint is confirmed (such as PiecewiseGeometryStrategy(known=False) in tests/test_cover_strategy_contract.py and the calibrated geometry tracker in feat: add dedicated MyHOME administration panel #374), a full-travel keypad run left the position None (unknown) instead of confirming 100 or 0.
  2. Memberless scope cover run timeout set 100/0 without confirming the position (custom_components/myhome/cover_scope.py):
    • Under WHO_2.pdf §3.0.1, general (WHERE="0"), area (WHERE="1".."9", "00", "100"), and group (WHERE="#1"..) commands never receive a stop frame for the scope WHERE itself. When a MyHOMEScopeCover has no configured member entities, _watch_run() schedules _end_run after self._travel_for(moving == "open").
    • _end_run called self._freeze_position(time.monotonic()) and then overwrote self._attr_current_cover_position = self._start_position = 100 if opening else 0 without advancing the freeze timestamp to _run_started_at + travel or calling self._strategy.on_position_confirmed(self, end, "end_stop").

2. How is it solved?

  1. Detect full-travel runs in MyHOMECover._freeze_position(at) (custom_components/myhome/cover.py):
    • Computes end_stop when not self._strategy.assumed_state, run_started_at is not None, the cover is opening or closing, (travel := self._travel_for(is_opening)) > 0, and at - run_started_at >= travel.
    • When end_stop is True:
      • Sets frozen = 100 if is_opening else 0 (even when position_after would otherwise return None because the strategy had no prior known position).
      • Calls self._strategy.on_run_ended(self, "end_stop", frozen, at) followed by self._strategy.on_position_confirmed(self, frozen, "end_stop").
  2. Confirm the endpoint in MyHOMEScopeCover._end_run() (custom_components/myhome/cover_scope.py):
    • Advances the freeze timestamp at to max(time.monotonic(), self._run_started_at + travel) when _run_started_at is set (accounting for any queue delay between scheduling _watch_run and anchoring the motor write), so _freeze_position(at) reports ("end_stop", end) and confirms end (100 or 0).
    • If _end_run fires when no run anchor was set (not confirmed), explicitly invokes self._strategy.on_position_confirmed(self, end, "end_stop").
  3. Expand contract test coverage (tests/test_cover_strategy_contract.py):
    • Verifies that the 13 s closing keypad run in test_piecewise_strategy_drives_cover_without_cover_py_changes records ("end_stop", 0) and ("confirmed", 0, "end_stop").
    • Verifies in test_position_unknown_until_a_full_run that a full-travel keypad run (*2*2*21## $\rightarrow$ 13.0 s $\rightarrow$ *2*0*21##) and a memberless MyHOMEScopeCover run timeout (*2*1*#7## $\rightarrow$ _end_run) both transition PiecewiseGeometryStrategy(known=False) to known = True and confirm the endpoint.

3. State Diagram

stateDiagram-v2
    direction TB
    [*] --> UnknownPosition: Startup / Restart\n(strategy.known == False, position == None)

    state "Position Unknown (None)" as UnknownPosition
    state "Motor Running (Anchored at run_started_at)" as Running
    state "Partial Stop (elapsed < travel)" as PartialStop
    state "Confirmed End Stop (0% or 100%)" as ConfirmedEndStop
    state "Known Intermediate Position (0..100%)" as KnownPosition

    UnknownPosition --> Running: Keypad / Bus Move (*2*1*W## or *2*2*W##)\non_run_started(direction, start_position, at)
    KnownPosition --> Running: Move or Set Position\non_run_started(direction, start_position, at)

    Running --> PartialStop: *2*0*W## or async_stop_cover()\nwhere at - run_started_at < travel_for(opening)
    PartialStop --> UnknownPosition: strategy.known == False\non_run_ended("stop", None, at)
    PartialStop --> KnownPosition: strategy.known == True\non_run_ended("stop", frozen, at)

    Running --> ConfirmedEndStop: 1) *2*0*W## after at - run_started_at >= travel_for(opening)\n2) _async_run_to_end() settle timer (0% / 100%)\n3) MyHOMEScopeCover._end_run() timeout
    ConfirmedEndStop --> KnownPosition: on_run_ended("end_stop", 100|0, at)\non_position_confirmed(100|0, "end_stop") -> strategy.known = True
Loading

4. Where are the traces (real ones)?

Hardware Gateway Firmware / Profile Real Trace / Log File Observed Shutter End-of-Run Behavior
BTicino F461 Firmware 2.0.11 + BT-F411/2 (WHERE="05", 5 s hardware cutoff) tests/fixtures/traces/f461_live_2026-10-18/myhome_trace_F461_live_2026-10-18.json In run2_shutter_05_travel_and_reversal, after *2*2*05## starts a full closing run, the physical BT-F411/2 actuator emits a standard *2*0*05## stop frame on evt (dt = 5.0 s after motor start) when its hardware travel timer expires at the limit switch.
BTicino MyHOMEServer1 Firmware 2.38.9 (028206) + Timed Shutters (WHERE="21", "31") tests/fixtures/traces/myhomeserver1_timed_cover_echo_window.json & tests/test_issue_445_myhomeserver1_covers.py Relays *2*1000#0*W##, *2*0*W##, *2*1000#1*W##, *2*1*W## on motor start (~0.55 s) and emits *2*0*W## when the actuator's relay drops out at the end of the run (~60 s uncalibrated cutoff or calibrated travel time).
BTicino MH201 Firmware 1.0.x + Group/Area Scope Shutters (#433) tests/test_issue_433_cover_scope.py Scope commands (*2*1*0##, *2*1*1##, *2*1*#7##) never emit a stop frame for the scope WHERE (WHO_2.pdf §3.0.1); only individual actuators emit *2*0*<point>## at the end of travel, so a memberless scope cover settles via _end_run after _travel_for(opening).

5. What does the Firmware Oracle / Encyclopedia say?

  1. OpenWebNet Encyclopedia (functional/who-2-automation/what.md & WHO_2.pdf §3.0.1):
    • Standard WHO=2 basic movement defines only WHAT=0 (Stop), WHAT=1 (Up), and WHAT=2 (Down). There is no distinct WHAT code for a hardware limit-switch stop on non-advanced shutters: when a point-to-point actuator reaches the end of its run, it transmits *2*0*WHERE## (WHAT=0).
    • Under WHO_2.pdf §3.0.1, a broadcast Up or Down to GEN (0), A (area), or GR (#G) terminates with individual *2*0*<point>## frames from each physical automation object moved ("as many frames as automation objects"), and never with *2*0*0## / *2*0*A## / *2*0*#G## for the scope address itself.
  2. Empirical Firmware Oracle (openwebnet-mcp — lookup_firmware_verdict):
    • Across the emulated gateway fleet (F453, F453AV, F454, F459, F460, F461, H4684, MH200, MH200N, MH202, MyHomeServer1), *2*1*31## / *2*2*31## and *2*0*31## are the sole event-stream state transitions for standard WHO=2 shutters (F460, F461, and MyHomeServer1 additionally emit *2*1000#1*31## / *2*1000#0*31## translation frames before the actuator status frame). Distinguishing a manual mid-travel stop from a limit-switch end stop on a standard shutter is therefore a function of whether the motor ran for at least travel_for_direction(opening).

Verification

  • pytest tests/test_cover_strategy_contract.py tests/test_issue_433_cover_scope.py tests/test_component_cover.py tests/test_cover_strategies.py

  • mypy custom_components/myhome/cover.py custom_components/myhome/cover_scope.py custom_components/myhome/cover_strategies (Success: no issues found in 8 source files)

  • ruff check custom_components tests scripts

  • scripts/verify_ha_standards.py & scripts/sync_documentation.py --check

  • V2 Architecture: New entities implement handle_event() and do not manually subscribe via async_dispatcher_connect.

@GreenGrassBlueOcean
GreenGrassBlueOcean merged commit 8acea4f into v2-phase1-architecture Oct 20, 2026
17 checks passed
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@GreenGrassBlueOcean
GreenGrassBlueOcean deleted the fix/cover-strategy-end-stop branch October 20, 2026 14:10
GreenGrassBlueOcean added a commit that referenced this pull request Oct 21, 2026
- Count and summarize every pull request merged since 2.0.0b15, grouped by
  subsystem, with the expose engine (#750), slat tilt (#508) and impulse
  covers (#667) as the headline features.
- Covers: the start-up floor is the per-cover min_motor_pulse (#708), timed
  runs end at the stop status (#707) and full-travel keypad runs report the
  end (#760); drop the "lift only" and open follow-up lines, both shipped.
- New behaviour-change section: sync_time refuses half-hour time zones
  (#764) and advanced: true on an F411 now warns (#762).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants