Skip to content

Phase 4: Timed Calibration (v1.3.0)#6

Merged
hrabbach merged 10 commits into
mainfrom
gsd/phase-04-timed-calibration-pr
Jun 26, 2026
Merged

Phase 4: Timed Calibration (v1.3.0)#6
hrabbach merged 10 commits into
mainfrom
gsd/phase-04-timed-calibration-pr

Conversation

@hrabbach

Copy link
Copy Markdown
Owner

Summary

Phase 4: Timed CalibrationStatus: Verified ✓ (manifest → v1.3.0)

Goal: Let a user calibrate a non-bidirectional ("timed") roller-shutter motor — one that never reports movement back — by timing its close and open runs with button presses, then drive it to any position. This is the final phase of milestone v1.0.

Adds an event-free calibration flow (TimedCalibrationFlowHandler): the legacy flow waits on motor events and hangs for timed motors — this flow blocks only on Home Assistant form rendering, never on a protocol message, so it cannot hang by construction. It times the close run (CMD_DOWN) then the open run (CMD_UP) using time.monotonic(), never sends a stop command on the end-press (the motor coasts to its physical endstop), guards runs to a 2–120 s window, and emits SIGNAL_CALIBRATION_COMPLETED with final_position=100. The cover handler is made end-state aware so the timed flow lands at 100 % open while the legacy bidirectional flow still ends at 0 % closed (no regression), and the previously-deferred CTRL-03 drive-to-percentage is unlocked once real calibration times exist.

Changes

Plan 04-01 — Event-free timed calibration flow

  • New options_flow_timed_calibration.pyTimedCalibrationFlowHandler (precondition → close → open → confirm steps; 2 s/120 s guards that re-show without re-driving; redo path; final_position=100 signal). No asyncio.Event/wait_for; no CMD_STOP; time.monotonic() throughout.
  • const.pyCAL_MIN_TRAVEL_TIME=2, CAL_MAX_TRAVEL_TIME=120.
  • config_flow.py — reconfigure routes timed motors into the new flow (bidirectional still routes to the legacy event-based flow — CTRL-05 unchanged) + 4 delegate steps.
  • strings.json + translations/{en,de,es,fr}.json — 4 step keys + 2 error keys (with repositioning guidance), plus device_not_found / timed_cal_incomplete abort strings.
  • New tests/test_timed_calibration_flow.py (11 tests) + tests/test_timed_cal_handler_structure.py.

Plan 04-02 — Cover end-state awareness + CTRL-03 unlock

  • cover.py_handle_calibration_completed gains final_position: int = 0; end-state driven by it (100 % timed / 0 % legacy via the default arg — backward compatible with the 3-arg legacy sender).
  • options_flow_calibration.py — legacy sender passes an explicit 0 (hygiene).
  • tests/test_cover.py — D-14 end-states, CTRL-03 set-position unlock + drive-to-50 % midpoint stop, SC#4 restart survival.

Requirements Addressed

  • CAL-01 — Non-bidirectional motors calibrate separate up (open) and down (close) travel times.
  • CTRL-03 — Estimate position from elapsed time and drive a timed motor to a target percentage (deferred from Phase 3, now unlocked).

Verification

  • Phase goal verification: passed, 9/9 must-haves against the codebase.
  • Full test suite: 207 passed (+21 vs the 186 baseline) · ruff clean · mypy clean.
  • Code review (deep, 13 files): 0 critical; 2 warnings found and fixed (missing device_not_found translation; misleading abort reason → new timed_cal_incomplete).
  • Security: threats_open: 0 (5 threats, all closed/accepted).
  • Nyquist validation: compliant — every automatable requirement test-covered.
  • Hardware UAT (post-deploy): on a real timed motor, confirm measured times match observed travel and a drive-to-50 % stops near the midpoint. Per project policy this runs after release and does not gate the merge.

Key Decisions

  • Event-free by construction (no asyncio.Event/wait_for) — the fix for the core "flow hangs on timed motors" bug (SC#1).
  • End-press is record-only: no CMD_STOP is ever sent; the motor coasts to its physical endstop (D-06). Guard re-shows do not re-drive (motor at unknown position).
  • time.monotonic() throughout (not time.time()) — immune to NTP jumps mid-run (D-07).
  • final_position default 0 keeps the 3-arg legacy bidirectional sender working unchanged (D-14 / CTRL-05 safe).

🤖 Generated with Claude Code

hrabbach added 10 commits June 26, 2026 20:00
…cture

- Verify module importability and class existence
- Verify CAL_MAX_TRAVEL_TIME=120 and CAL_MIN_TRAVEL_TIME=2 constants
- Verify all required async_step_* methods present
- Verify no CMD_STOP in module (D-06)
- Verify time.monotonic() used, not time.time() (D-07)
- const.py: add CAL_MAX_TRAVEL_TIME=120 and CAL_MIN_TRAVEL_TIME=2 (D-08/D-09)
- options_flow_timed_calibration.py: TimedCalibrationFlowHandler with:
  - async_step_timed_cal_precondition: instruction form, no drive command (D-05)
  - async_step_timed_cal_close: CMD_DOWN + monotonic timing + 2s/120s guards (D-04/D-07)
  - async_step_timed_cal_open: CMD_UP + monotonic timing + same guards (D-04/D-07)
  - async_step_timed_cal_confirm: show measured times, redo path (D-10)
  - _emit_calibration_signal: SIGNAL_CALIBRATION_COMPLETED with final_position=100 (D-14)
  - No CMD_STOP (D-06), no asyncio.Event/wait_for (SC#1 by construction)
  - start time recorded BEFORE await control_blind (Phase 3 locked rule)
- Fix test assertions to check import namespace rather than source text
- ruff clean, mypy clean
config_flow.py:
- Import TimedCalibrationFlowHandler
- Add self.timed_cal_handler and _get_timed_cal_handler() lazy getter
- async_step_reconfigure: route timed motors to TimedCalibrationFlowHandler
  (inverted from abort) while bidirectional path unchanged (CTRL-05)
- Add 4 delegate steps: async_step_timed_cal_{precondition,close,open,confirm}
  so HA does not raise UnknownStep when form step_ids are rendered

strings.json + translations/en.json:
- 4 new step keys: timed_cal_{precondition,close,open,confirm}
- 2 new error keys: timed_cal_too_short, timed_cal_too_long
  (both include repositioning guidance per REVIEW-2)
- confirm step redo label includes reopening instruction

translations/de.json, es.json, fr.json:
- English placeholder strings for the 4 new step keys and 2 error keys

ruff clean, mypy clean, all 5 JSON files parse
tests/test_timed_calibration_flow.py:
- test_timed_cal_precondition_shows_form: routing + precondition form (D-01/D-05)
- test_timed_cal_close_sends_cmd_down_records_start: CMD_DOWN + start time (D-04/D-07)
- test_timed_cal_close_too_short: 2s floor guard (D-09)
- test_timed_cal_close_too_long: 120s cap guard (D-08)
- test_timed_cal_guard_reshow_does_not_redrive: no re-drive on guard (REVIEW-3)
- test_timed_cal_no_stop_on_end_press: CMD_STOP never sent (D-06)
- test_timed_cal_happy_path_reaches_confirm: full close+open -> confirm (D-04/D-10)
- test_timed_cal_confirm_emits_signal_with_100: signal with final_position=100 (D-14)
- test_timed_cal_redo_returns_to_precondition: redo path resets and loops (D-10)
- test_reconfigure_bidirectional_routes_to_legacy: CTRL-05 not regressed
- test_timed_cal_abort_emits_no_signal: mid-flow abort emits no signal (D-11)

tests/test_config_flow.py:
- Rename test_reconfigure_timed_motor_aborts -> test_reconfigure_timed_motor_enters_timed_flow
- Assert timed reconfigure enters timed_cal_precondition (not abort) per REVIEW-4
- Add final_position: int = 0 param to _handle_calibration_completed
- Branch end-state on final_position (timed -> 100% open; legacy -> 0% closed)
- _is_calibrated = True still set before async_write_ha_state (REVIEW-05)
- Update info log to report actual final_position (no more hardcoded 0%)
- options_flow_calibration.py: legacy sender now passes explicit 0 as 4th
  positional arg (REVIEW-1 hygiene; behavior unchanged)
…ock, SC#4

- test_calibration_completed_timed_ends_100pct: 4-arg call with 100, asserts 100%
- test_calibration_completed_legacy_ends_0pct: explicit 4-arg 0, asserts 0%
- test_set_position_noop_until_calibrated: D-05 gate, no control_blind
- test_set_position_unlocked_after_calibration_drives_to_midpoint: CTRL-03 /
  SC#3 midpoint stop via backdated _move_start_time, asserts CMD_UP + CMD_STOP
- test_timed_calibration_survives_restart: SC#4 / D-13 Store round-trip and
  _is_calibrated=True on cover built from persisted data
- Also imports _get_cal_store and _save_calibration for the restart test
Fix stale code from edit insertion point that caused NameError in
test_timed_calibration_survives_restart; all 54 test_cover.py tests pass:
- test_calibration_completed_timed_ends_100pct: position 100 / is_closed False
- test_calibration_completed_legacy_ends_0pct: position 0 / is_closed True
- test_set_position_noop_until_calibrated: uncalibrated D-05 gate holds
- test_set_position_unlocked_after_calibration_drives_to_midpoint: CMD_UP
  then CMD_STOP at position 50 (CTRL-03 / SC#3)
- test_timed_calibration_survives_restart: Store round-trip + _is_calibrated
  True on cover built from persisted data (SC#4 / D-13)
@hrabbach hrabbach merged commit 4138bdd into main Jun 26, 2026
1 check passed
@hrabbach hrabbach deleted the gsd/phase-04-timed-calibration-pr branch June 26, 2026 18:06
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