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
77 changes: 68 additions & 9 deletions custom_components/schellenberg_usb/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
)
from .options_flow import SchellenbergOptionsFlowHandler
from .options_flow_calibration import CalibrationFlowHandler
from .options_flow_timed_calibration import TimedCalibrationFlowHandler

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -203,6 +204,7 @@ def __init__(self) -> None:
"""Initialize the subentry flow."""
super().__init__()
self.calibration_handler: CalibrationFlowHandler | None = None
self.timed_cal_handler: TimedCalibrationFlowHandler | None = None
self._pending_device_id: str | None = None
self._pending_device_enum: str | None = None
self._pending_device_name: str | None = None
Expand All @@ -214,6 +216,12 @@ def _get_calibration_handler(self) -> CalibrationFlowHandler:
self.calibration_handler = CalibrationFlowHandler(self)
return self.calibration_handler

def _get_timed_cal_handler(self) -> TimedCalibrationFlowHandler:
"""Return (and lazily create) the timed calibration flow handler."""
if self.timed_cal_handler is None:
self.timed_cal_handler = TimedCalibrationFlowHandler(self)
return self.timed_cal_handler

async def _await_subentry_result(
self,
step_coro: Awaitable[ConfigFlowResult | SubentryFlowResult],
Expand Down Expand Up @@ -470,21 +478,33 @@ async def async_step_reconfigure(
if not device_id:
return self.async_abort(reason="device_not_found")

# Guard: timed motors cannot calibrate via the event-waiting CalibrationFlowHandler
# (they never send EVENT_STARTED_MOVING_*/EVENT_STOPPED, so calibration hangs).
# Use the same missing-key default as cover.py (True = bidirectional) so legacy
# flag-less subentries are still treated as bidirectional here (REVIEW-2, T-02-04).
# Timed-motor calibration is deferred to Phase 4 / CAL-01.
# Route by motor type (CTRL-05 zero-regression requirement):
# - bidirectional motors → legacy event-based CalibrationFlowHandler
# - timed (non-bidirectional) motors → new TimedCalibrationFlowHandler
# Use the same missing-key default as cover.py (True = bidirectional)
# so legacy flag-less subentries are treated as bidirectional.
is_bidirectional = bool(subentry.data.get(CONF_BIDIRECTIONAL, True))
device_name = subentry.title or f"Blind {device_id}"

if not is_bidirectional:
# CAL-01 / D-01: route timed motor to the event-free timed flow.
_LOGGER.debug(
"Reconfigure blocked for timed motor %s: calibration not yet supported",
"Reconfigure: routing timed motor %s to TimedCalibrationFlowHandler",
device_id,
)
return self.async_abort(reason="timed_calibration_unavailable")
handler_tc = self._get_timed_cal_handler()
handler_tc.set_selected_device(
{
"id": device_id,
"name": device_name,
"enum": device_enum,
}
)
return await self._await_subentry_result(
handler_tc.async_step_timed_cal_precondition(user_input)
)

# Build a minimal device record; calibration handler will enrich after timing
device_name = subentry.title or f"Blind {device_id}"
# Bidirectional motor: use event-based CalibrationFlowHandler (CTRL-05).
handler.set_selected_device(
{
"id": device_id,
Expand Down Expand Up @@ -535,3 +555,42 @@ async def async_step_calibration_complete(
return await self._await_subentry_result(
handler.async_step_calibration_complete(user_input)
)

# Delegate all timed-calibration steps to TimedCalibrationFlowHandler.
# Each delegate is required so HA can route form step_ids without raising
# UnknownStep (Pitfall 5 from RESEARCH.md).
async def async_step_timed_cal_precondition(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Delegate to timed calibration handler."""
handler = self._get_timed_cal_handler()
return await self._await_subentry_result(
handler.async_step_timed_cal_precondition(user_input)
)

async def async_step_timed_cal_close(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Delegate to timed calibration handler."""
handler = self._get_timed_cal_handler()
return await self._await_subentry_result(
handler.async_step_timed_cal_close(user_input)
)

async def async_step_timed_cal_open(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Delegate to timed calibration handler."""
handler = self._get_timed_cal_handler()
return await self._await_subentry_result(
handler.async_step_timed_cal_open(user_input)
)

async def async_step_timed_cal_confirm(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Delegate to timed calibration handler."""
handler = self._get_timed_cal_handler()
return await self._await_subentry_result(
handler.async_step_timed_cal_confirm(user_input)
)
4 changes: 4 additions & 0 deletions custom_components/schellenberg_usb/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@
CONF_CLOSE_TIME = "close_time" # Time it takes to close (down) in seconds
CONF_DEVICE_ID = "device_id" # Device ID for calibration

# Timed calibration guards (D-08 / D-09)
CAL_MAX_TRAVEL_TIME = 120 # seconds — reject "walked away" runs (D-08)
CAL_MIN_TRAVEL_TIME = 2 # seconds — reject double-press/misfire (D-09)

# Manual-add device mode flag (stored in subentry.data)
CONF_BIDIRECTIONAL = "bidirectional" # bool; False = timed/non-bidirectional
CONF_INITIAL_POSITION = "initial_position" # int 0-100; timed motors only
25 changes: 18 additions & 7 deletions custom_components/schellenberg_usb/cover.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,9 +489,18 @@ def _handle_status_update(self) -> None:

@callback
def _handle_calibration_completed(
self, device_id: str, open_time: float, close_time: float
self,
device_id: str,
open_time: float,
close_time: float,
final_position: int = 0,
) -> None:
"""Handle calibration completion for this device."""
"""Handle calibration completion for this device.

final_position: timed flow passes 100 (ends open); legacy
bidirectional flow passes 0 (ends closed). Default 0 keeps the
3-arg legacy dispatcher dispatch backward-compatible (D-14).
"""
if device_id != self._device_id:
return

Expand All @@ -510,20 +519,22 @@ def _handle_calibration_completed(
)
)

# After calibration the device is fully closed
self._attr_current_cover_position = 0
self._attr_is_closed = True
# End-state depends on which flow completed:
# timed flow ends open (final_position=100), legacy ends closed (0).
self._attr_current_cover_position = final_position
self._attr_is_closed = final_position == 0

# Flip calibrated flag so the attribute reflects live state (REVIEW-05).
# Must run BEFORE async_write_ha_state() so the pushed state is correct.
self._is_calibrated = True

_LOGGER.info(
"Device %s calibration updated: open_time=%.2fs, close_time=%.2fs. "
"Cover position set to fully closed (0%%)",
"Device %s calibration updated: open_time=%.2fs, close_time=%.2fs."
" Cover position set to %d%%",
self._attr_name,
open_time,
close_time,
final_position,
)

self.async_write_ha_state()
Expand Down
2 changes: 1 addition & 1 deletion custom_components/schellenberg_usb/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@
"manufacturer": "van ooijen"
}
],
"version": "1.2.0"
"version": "1.3.0"
}
Original file line number Diff line number Diff line change
Expand Up @@ -488,14 +488,18 @@ async def _save_calibration_data(self, open_time: float, close_time: float) -> N

await storage.async_save(stored_data)

# Send signal to notify entities that calibration has been completed
# Send signal to notify entities that calibration has been completed.
# Explicit final_position=0: legacy flow ends on a close run (D-14 /
# REVIEW-1). The handler default already covers the 3-arg case, but
# passing 0 explicitly documents intent and guards future refactors.
if self._selected_device is not None:
async_dispatcher_send(
self.flow.hass,
SIGNAL_CALIBRATION_COMPLETED,
self._selected_device["id"],
round(open_time, 2),
round(close_time, 2),
0,
)

def enable_subentry_creation(
Expand Down
Loading