Skip to content

Phase 3: Timed Motor Control (v1.2.0)#5

Merged
hrabbach merged 8 commits into
mainfrom
ship/phase-03-timed-motor-control
Jun 26, 2026
Merged

Phase 3: Timed Motor Control (v1.2.0)#5
hrabbach merged 8 commits into
mainfrom
ship/phase-03-timed-motor-control

Conversation

@hrabbach

Copy link
Copy Markdown
Owner

Summary

Phase 3: Timed Motor Control · v1.2.0
Goal: Non-bidirectional (timed) motors respond immediately to open/close/stop commands, estimate position from elapsed time, and survive restarts with position intact.
Status: Verified ✓ (13/13 must-haves) · threat-secure · Nyquist-compliant

Adds first-class support for timed (non-bidirectional) roller-shutter motors that never report status back. Open/close/stop now dispatch immediately (no waiting for a motor confirmation that never arrives); a mid-travel Stop freezes the time-based position estimate and a full run resets to the endstop; an uncalibrated motor's set-position is an inert-but-visible no-op until Phase 4 calibration; and a timed motor's position survives a Home Assistant restart. Existing bidirectional motors are byte-for-byte unaffected.

Changes

03-01 — Immediate control (CTRL-01/02)

Characterization tests pinning immediate CMD_UP/CMD_DOWN dispatch, stop-freezes-estimate, and full-run reset to 100%/0% for timed motors. Tests only — no cover.py change.

03-02 — Calibrated gate (CTRL-02)

_is_calibrated flag (computed from value-presence of persisted open/close times, not key-presence), a timed-only calibrated state attribute (suppressed from HA history via _unrecorded_attributes), and a no-op gate so set-position on an uncalibrated timed motor is silently ignored while the slider stays visible.

03-03 — Restart survival (CTRL-04)

async_added_to_hass restores timed motors after a restart: mid-move snaps to the destination endstop (opening→100, closing→0); idle restore preserves the recorded position including a genuine 0%, falling back to initial_position then 100% — never collapsing missing data to 0%. Restore logic is extracted into a shared _restore_position_from_last_state helper used by both the bidirectional and timed paths, and _handle_event early-returns for timed motors.

03-04 — Zero regression (CTRL-05)

Behavior-assertion tests proving the bidirectional path (open/close/stop, ungated set-position, restore, no calibrated attribute, legacy no-flag default) is unaffected by every Phase 3 change.

Key files: custom_components/schellenberg_usb/cover.py, tests/test_cover.py, custom_components/schellenberg_usb/manifest.json (1.1.2 → 1.2.0).

Requirements Addressed

  • CTRL-01 — timed open/close drives the shutter immediately, no motor confirmation wait
  • CTRL-02 — Stop freezes the estimate (D-03 reinterpretation); a full run resets to 100%/0%; calibration status surfaced
  • CTRL-04 — timed position survives an HA restart; slider does not jump to 0%
  • CTRL-05 — existing bidirectional motors continue unchanged (no regression)

Verification

  • Automated verification: passed (13/13 must-haves, goal-backward)
  • Full suite: 186 passed in WSL; ruff + ​mypy clean
  • Security: 03-SECURITY.md — 9 threats, threats_open: 0 (all low, accepted/mitigated)
  • Nyquist validation: all 4 requirements covered by dedicated green tests, 0 gaps
  • Code review applied: 1 blocker (CR-01 stale _target_position) + 4 warnings fixed, with a regression test
  • Hardware / live-HA UAT — post-deploy (does not gate this release)

Key Decisions

  • D-03 — for timed motors, Stop freezes the position estimate (no endstop snap); only a full run to completion resets to 100%/0%.
  • D-05/06/07 — calibrated gate: value-presence flag, timed-only calibrated attribute (unrecorded), inert-but-visible set-position until calibrated.
  • D-08/09 — restart: mid-move endstop snap; idle restore preserves real 0%; missing-data fallback is initial_position then 100%, never 0%.
  • REVIEW-02 — shared restore helper (no duplication) so bidirectional restore can't silently drift.
  • CR-01 fix — a full Open/Close now clears a stale set-position target so it runs to the endstop instead of stopping at a leftover partial target.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QwPMgtiypLgJ5nkR3mUGfL

hrabbach added 8 commits June 26, 2026 15:33
…TRL-01)

- Add test_timed_open_sends_command_immediately: asserts CMD_UP awaited once
  on async_open_cover for CONF_BIDIRECTIONAL=False cover, no event needed
- Add test_timed_close_sends_command_immediately: asserts CMD_DOWN awaited once
  on async_close_cover for timed motor
- Add CMD_UP, CMD_DOWN, CMD_STOP to const import block for use in tests
…-07)

- __init__: compute _is_calibrated from VALUE-presence of CONF_OPEN_TIME and
  CONF_CLOSE_TIME (is not None) — key-present-but-None stays uncalibrated (REVIEW-01)
- _unrecorded_attributes: add 'calibrated' alongside 'mode' (D-07, T-03-04)
- extra_state_attributes: expose calibrated key for timed motors only (D-07)
- _handle_calibration_completed: set _is_calibrated=True before async_write_ha_state (REVIEW-05)
- tests: 4 failing-first tests (RED confirmed) then GREEN for D-06/D-07 behaviours
- async_set_cover_position: early-return guard at method top under
  `if not self._is_bidirectional and not self._is_calibrated:` with a
  single debug log; SET_POSITION feature retained (slider stays visible)
- test: failing-first (RED confirmed) then GREEN for D-05 behaviour
…DD GREEN)

- Extract _restore_position_from_last_state helper (REVIEW-02): the generic
  recorded-position restore logic now lives in one place, called from both
  the bidirectional and timed-idle branches — no duplication, no drift risk.
- Restructure async_added_to_hass: timed branch (not _is_bidirectional)
  snaps opening→100%% and closing→0%% on restart (D-08); bidirectional
  branch calls the shared helper unchanged (D-10).
- Add if not self._is_bidirectional: return guard at the top of _handle_event
  (D-11/REVIEW-04) — stray inbound events on timed motors are a structural
  no-op, never mutating state or writing HA state.
- Tests: test_timed_restart_opening_snaps_to_100, test_timed_restart_closing_snaps_to_0,
  test_timed_handle_event_ignored (all GREEN); bidirectional restore canary passes.
…tors (TDD GREEN)

- Timed-idle restart branch calls _restore_position_from_last_state (shared
  helper) — a real recorded 0%% is preserved via 'is None' sentinel, never
  replaced by initial_position (Pitfall 1 / REVIEW-02 — no inline duplication).
- No-prior-state fallback for timed motors is now 100%% (assume open, D-09),
  NOT the existing bidirectional 0%% default; the existing else:0 is gated
  on self._is_bidirectional so bidirectional behavior is unchanged (D-10).
- Tests: test_timed_restart_idle_restores_position (real-0%% key guard),
  test_timed_restart_no_prior_state_uses_initial_position,
  test_timed_restart_no_prior_no_initial_defaults_to_100 (all GREEN).
@hrabbach hrabbach merged commit 76547fe into main Jun 26, 2026
1 check passed
@hrabbach hrabbach deleted the ship/phase-03-timed-motor-control branch June 26, 2026 13:38
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.

1 participant