diff --git a/api/src/opentrons/hardware_control/api.py b/api/src/opentrons/hardware_control/api.py index 2e2bbfef116..c52fae64131 100644 --- a/api/src/opentrons/hardware_control/api.py +++ b/api/src/opentrons/hardware_control/api.py @@ -921,6 +921,16 @@ def engaged_axes(self) -> Dict[Axis, bool]: async def disengage_axes(self, which: List[Axis]) -> None: await self._backend.disengage_axes([ot2_axis_to_string(ax) for ax in which]) + def axis_is_present(self, axis: Axis) -> bool: + is_ot2 = axis in Axis.ot2_axes() + if not is_ot2: + return False + if axis in Axis.pipette_axes(): + mount = Axis.to_ot2_mount(axis) + if self.attached_pipettes.get(mount) is None: + return False + return True + @ExecutionManagerProvider.wait_for_running async def _fast_home(self, axes: Sequence[str], margin: float) -> Dict[str, float]: converted_axes = "".join(axes) diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 1b3275f4c7e..6c4b4f291bc 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -1674,7 +1674,12 @@ async def disengage_axes(self, which: List[Axis]) -> None: await self._backend.disengage_axes(which) async def engage_axes(self, which: List[Axis]) -> None: - await self._backend.engage_axes(which) + await self._backend.engage_axes( + [axis for axis in which if self._backend.axis_is_present(axis)] + ) + + def axis_is_present(self, axis: Axis) -> bool: + return self._backend.axis_is_present(axis) async def get_limit_switches(self) -> Dict[Axis, bool]: res = await self._backend.get_limit_switches() diff --git a/api/src/opentrons/hardware_control/protocols/hardware_manager.py b/api/src/opentrons/hardware_control/protocols/hardware_manager.py index ee0228ae3b8..d2bfd94a06b 100644 --- a/api/src/opentrons/hardware_control/protocols/hardware_manager.py +++ b/api/src/opentrons/hardware_control/protocols/hardware_manager.py @@ -1,7 +1,7 @@ from typing import Dict, Optional from typing_extensions import Protocol -from ..types import SubSystem, SubSystemState +from ..types import SubSystem, SubSystemState, Axis class HardwareManager(Protocol): @@ -45,3 +45,7 @@ def attached_subsystems(self) -> Dict[SubSystem, SubSystemState]: async def get_serial_number(self) -> Optional[str]: """Get the robot serial number, if provisioned. If not provisioned, will be None.""" ... + + def axis_is_present(self, axis: Axis) -> bool: + """Get whether a motor axis is present on the machine.""" + ... diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_engage_axes.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_engage_axes.py index 02bc22b0396..4f80db24f42 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_engage_axes.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_engage_axes.py @@ -52,10 +52,7 @@ async def execute( """Enable exes.""" ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) await ot3_hardware_api.engage_axes( - [ - self._gantry_mover.motor_axis_to_hardware_axis(axis) - for axis in params.axes - ] + self._gantry_mover.motor_axes_to_present_hardware_axes(params.axes) ) return SuccessData( public=UnsafeEngageAxesResult(), diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py b/api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py index ff06b6c22ed..6b050d6472f 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/update_position_estimators.py @@ -58,10 +58,7 @@ async def execute( """Update axis position estimators from their encoders.""" ot3_hardware_api = ensure_ot3_hardware(self._hardware_api) await ot3_hardware_api.update_axis_position_estimations( - [ - self._gantry_mover.motor_axis_to_hardware_axis(axis) - for axis in params.axes - ] + self._gantry_mover.motor_axes_to_present_hardware_axes(params.axes) ) return SuccessData( public=UpdatePositionEstimatorsResult(), diff --git a/api/src/opentrons/protocol_engine/execution/gantry_mover.py b/api/src/opentrons/protocol_engine/execution/gantry_mover.py index c77a9e1bad2..5413de8741c 100644 --- a/api/src/opentrons/protocol_engine/execution/gantry_mover.py +++ b/api/src/opentrons/protocol_engine/execution/gantry_mover.py @@ -159,6 +159,12 @@ def pick_mount_from_axis_map(self, axis_map: Dict[MotorAxis, float]) -> Mount: """Find a mount axis in the axis_map if it exists otherwise default to left mount.""" ... + def motor_axes_to_present_hardware_axes( + self, motor_axes: List[MotorAxis] + ) -> List[HardwareAxis]: + """Transform a list of engine axes into a list of hardware axes, filtering out non-present axes.""" + ... + class HardwareGantryMover(GantryMover): """Hardware API based gantry movement handler.""" @@ -167,6 +173,18 @@ def __init__(self, hardware_api: HardwareControlAPI, state_view: StateView) -> N self._hardware_api = hardware_api self._state_view = state_view + def motor_axes_to_present_hardware_axes( + self, motor_axes: List[MotorAxis] + ) -> List[HardwareAxis]: + """Get hardware axes from engine axes while filtering out non-present axes.""" + return [ + self.motor_axis_to_hardware_axis(motor_axis) + for motor_axis in motor_axes + if self._hardware_api.axis_is_present( + self.motor_axis_to_hardware_axis(motor_axis) + ) + ] + def motor_axis_to_hardware_axis(self, motor_axis: MotorAxis) -> HardwareAxis: """Transform an engine motor axis into a hardware axis.""" return _MOTOR_AXIS_TO_HARDWARE_AXIS[motor_axis] @@ -643,6 +661,14 @@ async def prepare_for_mount_movement(self, mount: Mount) -> None: """Retract the 'idle' mount if necessary.""" pass + def motor_axes_to_present_hardware_axes( + self, motor_axes: List[MotorAxis] + ) -> List[HardwareAxis]: + """Get present hardware axes from a list of engine axes. In simulation, all axes are present.""" + return [ + self.motor_axis_to_hardware_axis(motor_axis) for motor_axis in motor_axes + ] + def create_gantry_mover( state_view: StateView, hardware_api: HardwareControlAPI diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_engage_axes.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_engage_axes.py index 72fb761ad23..1f40523e4e1 100644 --- a/api/tests/opentrons/protocol_engine/commands/unsafe/test_engage_axes.py +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_engage_axes.py @@ -22,21 +22,28 @@ async def test_engage_axes_implementation( ) data = UnsafeEngageAxesParams( - axes=[MotorAxis.LEFT_Z, MotorAxis.LEFT_PLUNGER, MotorAxis.X, MotorAxis.Y] - ) - - decoy.when(gantry_mover.motor_axis_to_hardware_axis(MotorAxis.LEFT_Z)).then_return( - Axis.Z_L + axes=[ + MotorAxis.LEFT_Z, + MotorAxis.LEFT_PLUNGER, + MotorAxis.X, + MotorAxis.Y, + MotorAxis.RIGHT_Z, + MotorAxis.RIGHT_PLUNGER, + ] ) decoy.when( - gantry_mover.motor_axis_to_hardware_axis(MotorAxis.LEFT_PLUNGER) - ).then_return(Axis.P_L) - decoy.when(gantry_mover.motor_axis_to_hardware_axis(MotorAxis.X)).then_return( - Axis.X - ) - decoy.when(gantry_mover.motor_axis_to_hardware_axis(MotorAxis.Y)).then_return( - Axis.Y - ) + gantry_mover.motor_axes_to_present_hardware_axes( + [ + MotorAxis.LEFT_Z, + MotorAxis.LEFT_PLUNGER, + MotorAxis.X, + MotorAxis.Y, + MotorAxis.RIGHT_Z, + MotorAxis.RIGHT_PLUNGER, + ] + ) + ).then_return([Axis.Z_L, Axis.P_L, Axis.X, Axis.Y]) + decoy.when( await ot3_hardware_api.update_axis_position_estimations( [Axis.Z_L, Axis.P_L, Axis.X, Axis.Y] diff --git a/api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py b/api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py index da381635ce3..e281502308c 100644 --- a/api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py +++ b/api/tests/opentrons/protocol_engine/commands/unsafe/test_update_position_estimators.py @@ -22,21 +22,27 @@ async def test_update_position_estimators_implementation( ) data = UpdatePositionEstimatorsParams( - axes=[MotorAxis.LEFT_Z, MotorAxis.LEFT_PLUNGER, MotorAxis.X, MotorAxis.Y] - ) - - decoy.when(gantry_mover.motor_axis_to_hardware_axis(MotorAxis.LEFT_Z)).then_return( - Axis.Z_L + axes=[ + MotorAxis.LEFT_Z, + MotorAxis.LEFT_PLUNGER, + MotorAxis.X, + MotorAxis.Y, + MotorAxis.RIGHT_Z, + MotorAxis.RIGHT_PLUNGER, + ] ) decoy.when( - gantry_mover.motor_axis_to_hardware_axis(MotorAxis.LEFT_PLUNGER) - ).then_return(Axis.P_L) - decoy.when(gantry_mover.motor_axis_to_hardware_axis(MotorAxis.X)).then_return( - Axis.X - ) - decoy.when(gantry_mover.motor_axis_to_hardware_axis(MotorAxis.Y)).then_return( - Axis.Y - ) + gantry_mover.motor_axes_to_present_hardware_axes( + [ + MotorAxis.LEFT_Z, + MotorAxis.LEFT_PLUNGER, + MotorAxis.X, + MotorAxis.Y, + MotorAxis.RIGHT_Z, + MotorAxis.RIGHT_PLUNGER, + ] + ) + ).then_return([Axis.Z_L, Axis.P_L, Axis.X, Axis.Y]) result = await subject.execute(data) diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json index b46e276c48b..e4e1b5164eb 100644 --- a/app/src/assets/localization/en/error_recovery.json +++ b/app/src/assets/localization/en/error_recovery.json @@ -8,6 +8,7 @@ "blowout_failed": "Blowout failed", "cancel_run": "Cancel run", "canceling_run": "Canceling run", + "carefully_move_labware": "Carefully move any misplaced labware and clean up any spilled liquid.Close the robot door before proceeding.", "change_location": "Change location", "change_tip_pickup_location": "Change tip pick-up location", "choose_a_recovery_action": "Choose a recovery action", @@ -32,6 +33,9 @@ "gripper_errors_occur_when": "Gripper errors occur when the gripper stalls or collides with another object on the deck and are usually caused by improperly placed labware or inaccurate labware offsets", "gripper_releasing_labware": "Gripper releasing labware", "gripper_will_release_in_s": "Gripper will release labware in {{seconds}} seconds", + "home_and_retry": "Home gantry and retry step", + "home_gantry": "Home gantry", + "home_now": "Home now", "homing_pipette_dangerous": "Homing the {{mount}} pipette with liquid in the tips may damage it. You must remove all tips before using the pipette again.", "if_issue_persists_gripper_error": " If the issue persists, cancel the run and rerun gripper calibration", "if_issue_persists_overpressure": " If the issue persists, cancel the run and make the necessary changes to the protocol", @@ -57,7 +61,9 @@ "overpressure_is_usually_caused": "Overpressure is usually caused by a tip contacting labware, a clog, or moving viscous liquid too quickly", "pick_up_tips": "Pick up tips", "pipette_overpressure": "Pipette overpressure", + "prepare_deck_for_homing": "Prepare deck for homing", "proceed_to_cancel": "Proceed to cancel", + "proceed_to_home": "Proceed to home", "proceed_to_tip_selection": "Proceed to tip selection", "recovery_action_failed": "{{action}} failed", "recovery_mode": "Recovery mode", @@ -96,6 +102,8 @@ "skip_to_next_step_same_tips": "Skip to next step with same tips", "skipping_to_step_succeeded": "Skipping to step {{step}} succeeded.", "skipping_to_step_succeeded_na": "Skipping to next step succeeded.", + "stall_or_collision_detected_when": "A stall or collision is detected when the robot's motors are blocked", + "stall_or_collision_error": "Stall or collision", "stand_back": "Stand back, robot is in motion", "stand_back_picking_up_tips": "Stand back, picking up tips", "stand_back_resuming": "Stand back, resuming current step", @@ -105,7 +113,9 @@ "take_necessary_actions": "First, take any necessary actions to prepare the robot to retry the failed step.Then, close the robot door before proceeding.", "take_necessary_actions_failed_pickup": "First, take any necessary actions to prepare the robot to retry the failed tip pickup.Then, close the robot door before proceeding.", "take_necessary_actions_failed_tip_drop": "First, take any necessary actions to prepare the robot to retry the failed tip drop.Then, close the robot door before proceeding.", + "take_necessary_actions_home": "Take any necessary actions to prepare the robot to move the gantry to its home position.Close the robot door before proceeding.", "terminate_remote_activity": "Terminate remote activity", + "the_robot_must_return_to_home_position": "The robot must return to its home position before proceeding", "tip_drop_failed": "Tip drop failed", "tip_not_detected": "Tip not detected", "tip_presence_errors_are_caused": "Tip presence errors are usually caused by improperly placed labware or inaccurate labware offsets", diff --git a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx index 2c6f047f80d..bd52195faf8 100644 --- a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx @@ -19,6 +19,7 @@ import { IgnoreErrorSkipStep, ManualMoveLwAndSkip, ManualReplaceLwAndRetry, + HomeAndRetry, } from './RecoveryOptions' import { useErrorDetailsModal, @@ -225,6 +226,10 @@ export function ErrorRecoveryContent(props: RecoveryContentProps): JSX.Element { return } + const buildHomeAndRetry = (): JSX.Element => { + return + } + switch (props.recoveryMap.route) { case RECOVERY_MAP.OPTION_SELECTION.ROUTE: return buildSelectRecoveryOption() @@ -264,6 +269,8 @@ export function ErrorRecoveryContent(props: RecoveryContentProps): JSX.Element { return buildRecoveryInProgress() case RECOVERY_MAP.ROBOT_DOOR_OPEN.ROUTE: return buildManuallyRouteToDoorOpen() + case RECOVERY_MAP.HOME_AND_RETRY.ROUTE: + return buildHomeAndRetry() default: return buildSelectRecoveryOption() } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/CancelRun.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/CancelRun.tsx index b3cdd5fe257..fa66d614011 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/CancelRun.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/CancelRun.tsx @@ -35,7 +35,9 @@ export function CancelRun(props: RecoveryContentProps): JSX.Element { case CANCEL_RUN.STEPS.CONFIRM_CANCEL: return default: - console.warn(`${step} in ${route} not explicitly handled. Rerouting.`) + console.warn( + `CancelRun: ${step} in ${route} not explicitly handled. Rerouting.` + ) return } } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/FillWellAndSkip.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/FillWellAndSkip.tsx index dc74ed7e529..d01ea7dfe4e 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/FillWellAndSkip.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/FillWellAndSkip.tsx @@ -34,7 +34,9 @@ export function FillWellAndSkip(props: RecoveryContentProps): JSX.Element { case CANCEL_RUN.STEPS.CONFIRM_CANCEL: return default: - console.warn(`${step} in ${route} not explicitly handled. Rerouting.`) + console.warn( + `FillWellAndSkip: ${step} in ${route} not explicitly handled. Rerouting.` + ) return } } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/HomeAndRetry.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/HomeAndRetry.tsx new file mode 100644 index 00000000000..00ebdfb35ee --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/HomeAndRetry.tsx @@ -0,0 +1,147 @@ +import { Trans, useTranslation } from 'react-i18next' +import { LegacyStyledText } from '@opentrons/components' +import { RECOVERY_MAP } from '../constants' +import { + TwoColTextAndFailedStepNextStep, + TwoColLwInfoAndDeck, + SelectTips, + RecoveryDoorOpenSpecial, + RetryStepInfo, +} from '../shared' +import { ManageTips } from './ManageTips' +import { SelectRecoveryOption } from './SelectRecoveryOption' + +import type { RecoveryContentProps } from '../types' + +const { HOME_AND_RETRY } = RECOVERY_MAP +export function HomeAndRetry(props: RecoveryContentProps): JSX.Element { + const { recoveryMap } = props + const { route, step } = recoveryMap + switch (step) { + case HOME_AND_RETRY.STEPS.PREPARE_DECK_FOR_HOME: { + return + } + case HOME_AND_RETRY.STEPS.REMOVE_TIPS_FROM_PIPETTE: { + // TODO: Make this work the same way as e.g. RetryNewTips by changing one of them. Or both of them. + return + } + case HOME_AND_RETRY.STEPS.REPLACE_TIPS: { + return + } + case HOME_AND_RETRY.STEPS.SELECT_TIPS: { + return + } + case HOME_AND_RETRY.STEPS.HOME_BEFORE_RETRY: { + return + } + case HOME_AND_RETRY.STEPS.CLOSE_DOOR_AND_HOME: { + return + } + case HOME_AND_RETRY.STEPS.CONFIRM_RETRY: { + return + } + default: + console.warn( + `HomeAndRetry: ${step} in ${route} not explicitly handled. Rerouting.}` + ) + return + } +} + +export function RetryAfterHome(props: RecoveryContentProps): JSX.Element { + const { recoveryMap, routeUpdateActions } = props + const { step, route } = recoveryMap + const { HOME_AND_RETRY } = RECOVERY_MAP + const { proceedToRouteAndStep } = routeUpdateActions + + const buildContent = (): JSX.Element => { + switch (step) { + case HOME_AND_RETRY.STEPS.CONFIRM_RETRY: + return ( + + proceedToRouteAndStep( + HOME_AND_RETRY.ROUTE, + HOME_AND_RETRY.STEPS.HOME_BEFORE_RETRY + ) + } + /> + ) + default: + console.warn( + `RetryStep: ${step} in ${route} not explicitly handled. Rerouting.` + ) + return + } + } + return buildContent() +} + +export function PrepareDeckForHome(props: RecoveryContentProps): JSX.Element { + const { t } = useTranslation('error_recovery') + const { routeUpdateActions, tipStatusUtils } = props + const { proceedToRouteAndStep } = routeUpdateActions + const primaryBtnOnClick = (): Promise => + proceedToRouteAndStep( + RECOVERY_MAP.HOME_AND_RETRY.ROUTE, + tipStatusUtils.areTipsAttached + ? RECOVERY_MAP.HOME_AND_RETRY.STEPS.REMOVE_TIPS_FROM_PIPETTE + : RECOVERY_MAP.HOME_AND_RETRY.STEPS.HOME_BEFORE_RETRY + ) + const buildBodyText = (): JSX.Element => ( + }} + /> + ) + return ( + + ) +} + +export function HomeGantryBeforeRetry( + props: RecoveryContentProps +): JSX.Element { + const { t } = useTranslation('error_recovery') + const { routeUpdateActions, tipStatusUtils } = props + const { proceedToRouteAndStep } = routeUpdateActions + const { HOME_AND_RETRY } = RECOVERY_MAP + const buildBodyText = (): JSX.Element => ( + }} + /> + ) + const secondaryBtnOnClick = (): Promise => + proceedToRouteAndStep( + RECOVERY_MAP.HOME_AND_RETRY.ROUTE, + tipStatusUtils.areTipsAttached + ? RECOVERY_MAP.HOME_AND_RETRY.STEPS.REMOVE_TIPS_FROM_PIPETTE + : RECOVERY_MAP.HOME_AND_RETRY.STEPS.PREPARE_DECK_FOR_HOME + ) + + const primaryBtnOnClick = (): Promise => + proceedToRouteAndStep( + HOME_AND_RETRY.ROUTE, + HOME_AND_RETRY.STEPS.CLOSE_DOOR_AND_HOME + ) + return ( + + ) +} diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/IgnoreErrorSkipStep.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/IgnoreErrorSkipStep.tsx index c17e947853b..16cb755d4da 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/IgnoreErrorSkipStep.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/IgnoreErrorSkipStep.tsx @@ -41,7 +41,9 @@ export function IgnoreErrorSkipStep(props: RecoveryContentProps): JSX.Element { case IGNORE_AND_SKIP.STEPS.SKIP_STEP: return default: - console.warn(`${step} in ${route} not explicitly handled. Rerouting.`) + console.warn( + `IgnoreErrorAndSkipStep: ${step} in ${route} not explicitly handled. Rerouting.` + ) return } } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx index 1609acfa0ca..9061ba9b638 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx @@ -34,7 +34,7 @@ export function ManageTips(props: RecoveryContentProps): JSX.Element { routeAlternativelyIfNoPipette(props) const buildContent = (): JSX.Element => { - const { DROP_TIP_FLOWS } = RECOVERY_MAP + const { DROP_TIP_FLOWS, HOME_AND_RETRY } = RECOVERY_MAP const { step, route } = recoveryMap switch (step) { @@ -44,8 +44,12 @@ export function ManageTips(props: RecoveryContentProps): JSX.Element { case DROP_TIP_FLOWS.STEPS.CHOOSE_BLOWOUT: case DROP_TIP_FLOWS.STEPS.CHOOSE_TIP_DROP: return + case HOME_AND_RETRY.STEPS.REMOVE_TIPS_FROM_PIPETTE: + return default: - console.warn(`${step} in ${route} not explicitly handled. Rerouting.`) + console.warn( + `ManageTips: ${step} in ${route} not explicitly handled. Rerouting.` + ) return } } @@ -68,11 +72,23 @@ export function BeginRemoval({ } = routeUpdateActions const { cancelRun } = recoveryCommands const { selectedRecoveryOption } = currentRecoveryOptionUtils - const { ROBOT_CANCELING, RETRY_NEW_TIPS } = RECOVERY_MAP + const { + ROBOT_CANCELING, + RETRY_NEW_TIPS, + HOME_AND_RETRY, + DROP_TIP_FLOWS, + } = RECOVERY_MAP const mount = aPipetteWithTip?.mount const primaryOnClick = (): void => { - void proceedNextStep() + if (selectedRecoveryOption === HOME_AND_RETRY.ROUTE) { + void proceedToRouteAndStep( + DROP_TIP_FLOWS.ROUTE, + DROP_TIP_FLOWS.STEPS.BEFORE_BEGINNING + ) + } else { + void proceedNextStep() + } } const secondaryOnClick = (): void => { @@ -81,6 +97,11 @@ export function BeginRemoval({ RETRY_NEW_TIPS.ROUTE, RETRY_NEW_TIPS.STEPS.REPLACE_TIPS ) + } else if (selectedRecoveryOption === HOME_AND_RETRY.ROUTE) { + void proceedToRouteAndStep( + HOME_AND_RETRY.ROUTE, + HOME_AND_RETRY.STEPS.HOME_BEFORE_RETRY + ) } else { void handleMotionRouting(true, ROBOT_CANCELING.ROUTE).then(() => { cancelRun() @@ -149,7 +170,12 @@ function DropTipFlowsContainer( recoveryCommands, currentRecoveryOptionUtils, } = props - const { DROP_TIP_FLOWS, ROBOT_CANCELING, RETRY_NEW_TIPS } = RECOVERY_MAP + const { + DROP_TIP_FLOWS, + ROBOT_CANCELING, + RETRY_NEW_TIPS, + HOME_AND_RETRY, + } = RECOVERY_MAP const { proceedToRouteAndStep, handleMotionRouting } = routeUpdateActions const { selectedRecoveryOption } = currentRecoveryOptionUtils const { setTipStatusResolved } = tipStatusUtils @@ -163,6 +189,11 @@ function DropTipFlowsContainer( RETRY_NEW_TIPS.ROUTE, RETRY_NEW_TIPS.STEPS.REPLACE_TIPS ) + } else if (selectedRecoveryOption === HOME_AND_RETRY.ROUTE) { + void proceedToRouteAndStep( + HOME_AND_RETRY.ROUTE, + HOME_AND_RETRY.STEPS.HOME_BEFORE_RETRY + ) } else { void setTipStatusResolved(onEmptyCache, onTipsDetected) } @@ -208,6 +239,7 @@ export function useDropTipFlowUtils({ SKIP_STEP_WITH_NEW_TIPS, ERROR_WHILE_RECOVERING, DROP_TIP_FLOWS, + HOME_AND_RETRY, } = RECOVERY_MAP const { runId, gripperErrorFirstPipetteWithTip } = tipStatusUtils const { step } = recoveryMap @@ -220,6 +252,7 @@ export function useDropTipFlowUtils({ switch (selectedRecoveryOption) { case RETRY_NEW_TIPS.ROUTE: case SKIP_STEP_WITH_NEW_TIPS.ROUTE: + case HOME_AND_RETRY.ROUTE: return t('proceed_to_tip_selection') default: return t('proceed_to_cancel') @@ -243,6 +276,10 @@ export function useDropTipFlowUtils({ SKIP_STEP_WITH_NEW_TIPS.STEPS.REPLACE_TIPS ) } + case HOME_AND_RETRY.ROUTE: + return () => { + routeTo(selectedRecoveryOption, HOME_AND_RETRY.STEPS.REPLACE_TIPS) + } default: return null } @@ -336,6 +373,7 @@ function routeAlternativelyIfNoPipette(props: RecoveryContentProps): void { RETRY_NEW_TIPS, SKIP_STEP_WITH_NEW_TIPS, OPTION_SELECTION, + HOME_AND_RETRY, } = RECOVERY_MAP if (tipStatusUtils.aPipetteWithTip == null) @@ -354,6 +392,13 @@ function routeAlternativelyIfNoPipette(props: RecoveryContentProps): void { ) break } + case HOME_AND_RETRY.ROUTE: { + proceedToRouteAndStep( + selectedRecoveryOption, + HOME_AND_RETRY.STEPS.HOME_BEFORE_RETRY + ) + break + } default: { proceedToRouteAndStep(OPTION_SELECTION.ROUTE) } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualMoveLwAndSkip.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualMoveLwAndSkip.tsx index 123493480f7..5cf8ef81a65 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualMoveLwAndSkip.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualMoveLwAndSkip.tsx @@ -28,7 +28,9 @@ export function ManualMoveLwAndSkip(props: RecoveryContentProps): JSX.Element { case MANUAL_MOVE_AND_SKIP.STEPS.SKIP: return default: - console.warn(`${step} in ${route} not explicitly handled. Rerouting.`) + console.warn( + `ManualMoveLwAndSkipStep: ${step} in ${route} not explicitly handled. Rerouting.` + ) return } } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualReplaceLwAndRetry.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualReplaceLwAndRetry.tsx index 11ffe783d42..313d3d1f086 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualReplaceLwAndRetry.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManualReplaceLwAndRetry.tsx @@ -30,7 +30,9 @@ export function ManualReplaceLwAndRetry( case MANUAL_REPLACE_AND_RETRY.STEPS.RETRY: return default: - console.warn(`${step} in ${route} not explicitly handled. Rerouting.`) + console.warn( + `ManualReplaceLwAndRetry: ${step} in ${route} not explicitly handled. Rerouting.` + ) return } } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/RetryNewTips.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/RetryNewTips.tsx index f6e86cd6923..003e776824d 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/RetryNewTips.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/RetryNewTips.tsx @@ -35,7 +35,9 @@ export function RetryNewTips(props: RecoveryContentProps): JSX.Element { case RETRY_NEW_TIPS.STEPS.RETRY: return default: - console.warn(`${step} in ${route} not explicitly handled. Rerouting.`) + console.warn( + `RetryNewTips: ${step} in ${route} not explicitly handled. Rerouting.` + ) return } } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/RetrySameTips.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/RetrySameTips.tsx index 93a0d84689d..0c28eb2a2da 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/RetrySameTips.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/RetrySameTips.tsx @@ -18,7 +18,9 @@ export function RetrySameTips(props: RecoveryContentProps): JSX.Element { case RETRY_SAME_TIPS.STEPS.RETRY: return default: - console.warn(`${step} in ${route} not explicitly handled. Rerouting.`) + console.warn( + `RetrySameTips: ${step} in ${route} not explicitly handled. Rerouting.` + ) return } } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/RetryStep.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/RetryStep.tsx index 9b1f4d2c85a..a30b68d4358 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/RetryStep.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/RetryStep.tsx @@ -14,7 +14,9 @@ export function RetryStep(props: RecoveryContentProps): JSX.Element { case RETRY_STEP.STEPS.CONFIRM_RETRY: return default: - console.warn(`${step} in ${route} not explicitly handled. Rerouting.`) + console.warn( + `RetryStep: ${step} in ${route} not explicitly handled. Rerouting.` + ) return } } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx index 59888c39c42..e271cc3be23 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx @@ -168,9 +168,16 @@ export function getRecoveryOptions(errorKind: ErrorKind): RecoveryRoute[] { return GRIPPER_ERROR_OPTIONS case ERROR_KINDS.GENERAL_ERROR: return GENERAL_ERROR_OPTIONS + case ERROR_KINDS.STALL_OR_COLLISION: + return STALL_OR_COLLISION_OPTIONS } } +export const STALL_OR_COLLISION_OPTIONS: RecoveryRoute[] = [ + RECOVERY_MAP.HOME_AND_RETRY.ROUTE, + RECOVERY_MAP.CANCEL_RUN.ROUTE, +] + export const NO_LIQUID_DETECTED_OPTIONS: RecoveryRoute[] = [ RECOVERY_MAP.MANUAL_FILL_AND_SKIP.ROUTE, RECOVERY_MAP.IGNORE_AND_SKIP.ROUTE, diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SkipStepNewTips.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SkipStepNewTips.tsx index 647bded71e1..b237afd82f0 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SkipStepNewTips.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SkipStepNewTips.tsx @@ -29,7 +29,9 @@ export function SkipStepNewTips( case SKIP_STEP_WITH_NEW_TIPS.STEPS.SKIP: return default: - console.warn(`${step} in ${route} not explicitly handled. Rerouting.`) + console.warn( + `SkipStepNewTips: ${step} in ${route} not explicitly handled. Rerouting.` + ) return } } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SkipStepSameTips.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SkipStepSameTips.tsx index 2b56012d5ab..9990d94171a 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SkipStepSameTips.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SkipStepSameTips.tsx @@ -14,7 +14,9 @@ export function SkipStepSameTips(props: RecoveryContentProps): JSX.Element { case SKIP_STEP_WITH_SAME_TIPS.STEPS.SKIP: return default: - console.warn(`${step} in ${route} not explicitly handled. Rerouting.`) + console.warn( + `SkipStepSameTips: ${step} in ${route} not explicitly handled. Rerouting.` + ) return } } diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/HomeAndRetry.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/HomeAndRetry.test.tsx new file mode 100644 index 00000000000..3286041b7fb --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/HomeAndRetry.test.tsx @@ -0,0 +1,154 @@ +import type * as React from 'react' +import { describe, it, vi, beforeEach, afterEach } from 'vitest' +import { screen } from '@testing-library/react' + +import { mockRecoveryContentProps } from '../../__fixtures__' +import { renderWithProviders } from '/app/__testing-utils__' +import { i18n } from '/app/i18n' +import { RECOVERY_MAP } from '../../constants' +import { SelectRecoveryOption } from '../SelectRecoveryOption' +import { HomeAndRetry } from '../HomeAndRetry' +import { TipSelection } from '../../shared/TipSelection' + +vi.mock('../SelectRecoveryOption') +vi.mock('../../shared/TipSelection') + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('HomeAndRetry', () => { + let props: React.ComponentProps + beforeEach(() => { + props = { + ...mockRecoveryContentProps, + currentRecoveryOptionUtils: { + ...mockRecoveryContentProps.currentRecoveryOptionUtils, + selectedRecoveryOption: RECOVERY_MAP.HOME_AND_RETRY.ROUTE, + }, + } + vi.mocked(SelectRecoveryOption).mockReturnValue( +
MOCK_SELECT_RECOVERY_OPTION
+ ) + vi.mocked(TipSelection).mockReturnValue(
WELL_SELECTION
) + }) + afterEach(() => { + vi.resetAllMocks() + }) + it(`renders PrepareDeckForHome when step is ${RECOVERY_MAP.HOME_AND_RETRY.STEPS.PREPARE_DECK_FOR_HOME}`, () => { + props = { + ...props, + recoveryMap: { + ...props.recoveryMap, + step: RECOVERY_MAP.HOME_AND_RETRY.STEPS.PREPARE_DECK_FOR_HOME, + }, + } + render(props) + screen.getByText('Prepare deck for homing') + }) + it(`renders ManageTips when step is ${RECOVERY_MAP.HOME_AND_RETRY.STEPS.REMOVE_TIPS_FROM_PIPETTE}`, () => { + props = { + ...props, + recoveryMap: { + ...props.recoveryMap, + step: RECOVERY_MAP.HOME_AND_RETRY.STEPS.REMOVE_TIPS_FROM_PIPETTE, + }, + tipStatusUtils: { + ...props.tipStatusUtils, + aPipetteWithTip: { + mount: 'left', + } as any, + }, + } + render(props) + screen.getByText('Remove any attached tips') + }) + it(`renders labware info when step is ${RECOVERY_MAP.HOME_AND_RETRY.STEPS.REPLACE_TIPS}`, () => { + props = { + ...props, + recoveryMap: { + ...props.recoveryMap, + step: RECOVERY_MAP.HOME_AND_RETRY.STEPS.REPLACE_TIPS, + }, + failedLabwareUtils: { + ...props.failedLabwareUtils, + relevantWellName: 'A2', + failedLabwareLocations: { + ...props.failedLabwareUtils.failedLabwareLocations, + displayNameCurrentLoc: 'B2', + }, + }, + } + + render(props) + screen.getByText('Replace used tips in rack location A2 in B2') + }) + it(`renders SelectTips when step is ${RECOVERY_MAP.HOME_AND_RETRY.STEPS.SELECT_TIPS}`, () => { + props = { + ...props, + recoveryMap: { + ...props.recoveryMap, + step: RECOVERY_MAP.HOME_AND_RETRY.STEPS.SELECT_TIPS, + }, + failedLabwareUtils: { + ...props.failedLabwareUtils, + failedLabwareLocations: { + ...props.failedLabwareUtils.failedLabwareLocations, + displayNameCurrentLoc: 'B2', + }, + }, + } + render(props) + screen.getByText('Select tip pick-up location') + }) + it(`renders HomeGantryBeforeRetry when step is ${RECOVERY_MAP.HOME_AND_RETRY.STEPS.HOME_BEFORE_RETRY}`, () => { + props = { + ...props, + recoveryMap: { + ...props.recoveryMap, + step: RECOVERY_MAP.HOME_AND_RETRY.STEPS.HOME_BEFORE_RETRY, + }, + } + render(props) + screen.getByText('Home gantry') + }) + it(`renders the special door open handler when step is ${RECOVERY_MAP.HOME_AND_RETRY.STEPS.CLOSE_DOOR_AND_HOME}`, () => { + props = { + ...props, + recoveryMap: { + ...props.recoveryMap, + step: RECOVERY_MAP.HOME_AND_RETRY.STEPS.CLOSE_DOOR_AND_HOME, + }, + doorStatusUtils: { + ...props.doorStatusUtils, + isDoorOpen: true, + }, + } + render(props) + screen.getByText('Close the robot door') + }) + it(`renders RetryAfterHome awhen step is ${RECOVERY_MAP.HOME_AND_RETRY.STEPS.CONFIRM_RETRY}`, () => { + props = { + ...props, + recoveryMap: { + ...props.recoveryMap, + step: RECOVERY_MAP.HOME_AND_RETRY.STEPS.CONFIRM_RETRY, + }, + } + render(props) + screen.getByText('Retry step') + }) + it(`renders SelectRecoveryOption as a fallback`, () => { + props = { + ...props, + recoveryMap: { + ...props.recoveryMap, + step: 'UNKNOWN_STEP' as any, + }, + } + render(props) + screen.getByText('MOCK_SELECT_RECOVERY_OPTION') + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx index a0dd0c778ca..62fe8eea3c8 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/SelectRecoveryOptions.test.tsx @@ -18,6 +18,7 @@ import { TIP_NOT_DETECTED_OPTIONS, TIP_DROP_FAILED_OPTIONS, GRIPPER_ERROR_OPTIONS, + STALL_OR_COLLISION_OPTIONS, } from '../SelectRecoveryOption' import { RECOVERY_MAP, ERROR_KINDS } from '../../constants' import { clickButtonLabeled } from '../../__tests__/util' @@ -95,6 +96,9 @@ describe('SelectRecoveryOption', () => { expect.any(String) ) .thenReturn('Skip to next step with same tips') + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.HOME_AND_RETRY.ROUTE, expect.any(String)) + .thenReturn('Home gantry and retry') }) it('sets the selected recovery option when clicking continue', () => { @@ -231,6 +235,22 @@ describe('SelectRecoveryOption', () => { RECOVERY_MAP.RETRY_STEP.ROUTE ) }) + it('renders appropriate "Stall or collision" copy and click behavior', () => { + props = { + ...props, + errorKind: ERROR_KINDS.STALL_OR_COLLISION, + } + renderSelectRecoveryOption(props) + screen.getByText('Choose a recovery action') + const homeGantryAndRetry = screen.getAllByRole('label', { + name: 'Home gantry and retry', + }) + fireEvent.click(homeGantryAndRetry[0]) + clickButtonLabeled('Continue') + expect(mockProceedToRouteAndStep).toHaveBeenCalledWith( + RECOVERY_MAP.HOME_AND_RETRY.ROUTE + ) + }) }) describe('RecoveryOptions', () => { let props: React.ComponentProps @@ -292,6 +312,9 @@ describe('RecoveryOptions', () => { expect.any(String) ) .thenReturn('Manually replace labware on deck and retry step') + when(mockGetRecoveryOptionCopy) + .calledWith(RECOVERY_MAP.HOME_AND_RETRY.ROUTE, expect.any(String)) + .thenReturn('Home gantry and retry') }) it('renders valid recovery options for a general error errorKind', () => { @@ -415,6 +438,17 @@ describe('RecoveryOptions', () => { }) screen.getByRole('label', { name: 'Cancel run' }) }) + it(`renders valid recovery options for a ${ERROR_KINDS.STALL_OR_COLLISION} errorKind`, () => { + props = { + ...props, + validRecoveryOptions: STALL_OR_COLLISION_OPTIONS, + } + renderRecoveryOptions(props) + screen.getByRole('label', { + name: 'Home gantry and retry', + }) + screen.getByRole('label', { name: 'Cancel run' }) + }) }) describe('getRecoveryOptions', () => { @@ -475,4 +509,11 @@ describe('getRecoveryOptions', () => { ) expect(overpressureWhileDispensingOptions).toBe(GRIPPER_ERROR_OPTIONS) }) + + it(`returns valid options when the errorKind is ${ERROR_KINDS.STALL_OR_COLLISION}`, () => { + const stallOrCollisionOptions = getRecoveryOptions( + ERROR_KINDS.STALL_OR_COLLISION + ) + expect(stallOrCollisionOptions).toBe(STALL_OR_COLLISION_OPTIONS) + }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/index.ts b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/index.ts index 0e50d054523..0ad8f530709 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/index.ts +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/index.ts @@ -10,3 +10,4 @@ export { SkipStepNewTips } from './SkipStepNewTips' export { IgnoreErrorSkipStep } from './IgnoreErrorSkipStep' export { ManualMoveLwAndSkip } from './ManualMoveLwAndSkip' export { ManualReplaceLwAndRetry } from './ManualReplaceLwAndRetry' +export { HomeAndRetry } from './HomeAndRetry' diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryWizard.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryWizard.test.tsx index dd915b72afb..d97072e45f3 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryWizard.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryWizard.test.tsx @@ -24,6 +24,7 @@ import { IgnoreErrorSkipStep, ManualReplaceLwAndRetry, ManualMoveLwAndSkip, + HomeAndRetry, } from '../RecoveryOptions' import { RecoveryInProgress } from '../RecoveryInProgress' import { RecoveryError } from '../RecoveryError' @@ -188,6 +189,7 @@ describe('ErrorRecoveryContent', () => { ROBOT_RELEASING_LABWARE, MANUAL_REPLACE_AND_RETRY, MANUAL_MOVE_AND_SKIP, + HOME_AND_RETRY, } = RECOVERY_MAP let props: React.ComponentProps @@ -225,6 +227,7 @@ describe('ErrorRecoveryContent', () => { vi.mocked(RecoveryDoorOpenSpecial).mockReturnValue(
MOCK_DOOR_OPEN_SPECIAL
) + vi.mocked(HomeAndRetry).mockReturnValue(
MOCK_HOME_AND_RETRY
) }) it(`returns SelectRecoveryOption when the route is ${OPTION_SELECTION.ROUTE}`, () => { @@ -505,4 +508,17 @@ describe('ErrorRecoveryContent', () => { screen.getByText('MOCK_DOOR_OPEN_SPECIAL') }) + + it(`returns HomeAndRetry when the route is ${HOME_AND_RETRY.ROUTE}`, () => { + props = { + ...props, + recoveryMap: { + ...props.recoveryMap, + route: HOME_AND_RETRY.ROUTE, + }, + } + renderRecoveryContent(props) + + screen.getByText('MOCK_HOME_AND_RETRY') + }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/constants.ts b/app/src/organisms/ErrorRecoveryFlows/constants.ts index 75835fd29f3..8be1b6adbe1 100644 --- a/app/src/organisms/ErrorRecoveryFlows/constants.ts +++ b/app/src/organisms/ErrorRecoveryFlows/constants.ts @@ -20,6 +20,7 @@ export const DEFINED_ERROR_TYPES = { TIP_PHYSICALLY_MISSING: 'tipPhysicallyMissing', TIP_PHYSICALLY_ATTACHED: 'tipPhysicallyAttached', GRIPPER_MOVEMENT: 'gripperMovement', + STALL_OR_COLLISION: 'stallOrCollision', } // Client-defined error-handling flows. @@ -32,6 +33,7 @@ export const ERROR_KINDS = { TIP_NOT_DETECTED: 'TIP_NOT_DETECTED', TIP_DROP_FAILED: 'TIP_DROP_FAILED', GRIPPER_ERROR: 'GRIPPER_ERROR', + STALL_OR_COLLISION: 'STALL_OR_COLLISION', } as const // TODO(jh, 06-14-24): Consolidate motion routes to a single route with several steps. @@ -55,6 +57,18 @@ export const RECOVERY_MAP = { DROP_TIP_GENERAL_ERROR: 'drop-tip-general-error', }, }, + HOME_AND_RETRY: { + ROUTE: 'home-and-retry', + STEPS: { + PREPARE_DECK_FOR_HOME: 'prepare-deck-for-home', + REMOVE_TIPS_FROM_PIPETTE: 'remove-tips-from-pipette', + REPLACE_TIPS: 'replace-tips', + SELECT_TIPS: 'select-tips', + HOME_BEFORE_RETRY: 'home-before-retry', + CLOSE_DOOR_AND_HOME: 'close-door-and-home', + CONFIRM_RETRY: 'confirm-retry', + }, + }, ROBOT_CANCELING: { ROUTE: 'robot-cancel-run', STEPS: { @@ -210,6 +224,7 @@ const { MANUAL_REPLACE_AND_RETRY, SKIP_STEP_WITH_NEW_TIPS, SKIP_STEP_WITH_SAME_TIPS, + HOME_AND_RETRY, } = RECOVERY_MAP // The deterministic ordering of steps for a given route. @@ -277,6 +292,15 @@ export const STEP_ORDER: StepOrder = { ERROR_WHILE_RECOVERING.STEPS.DROP_TIP_TIP_DROP_FAILED, ERROR_WHILE_RECOVERING.STEPS.DROP_TIP_BLOWOUT_FAILED, ], + [HOME_AND_RETRY.ROUTE]: [ + HOME_AND_RETRY.STEPS.PREPARE_DECK_FOR_HOME, + HOME_AND_RETRY.STEPS.REMOVE_TIPS_FROM_PIPETTE, + HOME_AND_RETRY.STEPS.REPLACE_TIPS, + HOME_AND_RETRY.STEPS.SELECT_TIPS, + HOME_AND_RETRY.STEPS.HOME_BEFORE_RETRY, + HOME_AND_RETRY.STEPS.CLOSE_DOOR_AND_HOME, + HOME_AND_RETRY.STEPS.CONFIRM_RETRY, + ], } // Contains metadata specific to all routes and/or steps. @@ -333,6 +357,15 @@ export const RECOVERY_MAP_METADATA: RecoveryRouteStepMetadata = { [ROBOT_DOOR_OPEN.ROUTE]: { [ROBOT_DOOR_OPEN.STEPS.DOOR_OPEN]: { allowDoorOpen: false }, }, + [HOME_AND_RETRY.ROUTE]: { + [HOME_AND_RETRY.STEPS.PREPARE_DECK_FOR_HOME]: { allowDoorOpen: true }, + [HOME_AND_RETRY.STEPS.REMOVE_TIPS_FROM_PIPETTE]: { allowDoorOpen: true }, + [HOME_AND_RETRY.STEPS.REPLACE_TIPS]: { allowDoorOpen: true }, + [HOME_AND_RETRY.STEPS.SELECT_TIPS]: { allowDoorOpen: true }, + [HOME_AND_RETRY.STEPS.HOME_BEFORE_RETRY]: { allowDoorOpen: true }, + [HOME_AND_RETRY.STEPS.CLOSE_DOOR_AND_HOME]: { allowDoorOpen: true }, + [HOME_AND_RETRY.STEPS.CONFIRM_RETRY]: { allowDoorOpen: true }, + }, [ROBOT_DOOR_OPEN_SPECIAL.ROUTE]: { [ROBOT_DOOR_OPEN_SPECIAL.STEPS.DOOR_OPEN]: { allowDoorOpen: true }, }, diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryOptionCopy.test.tsx b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryOptionCopy.test.tsx index 62a810cd96e..11e8a574246 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryOptionCopy.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryOptionCopy.test.tsx @@ -111,6 +111,11 @@ describe('useRecoveryOptionCopy', () => { screen.getByText('Manually replace labware on deck and retry step') }) + it(`renders the correct copy for ${RECOVERY_MAP.HOME_AND_RETRY.ROUTE}`, () => { + render({ route: RECOVERY_MAP.HOME_AND_RETRY.ROUTE }) + screen.getByText('Home gantry and retry step') + }) + it('renders "Unknown action" for an unknown recovery option', () => { render({ route: 'unknown_route' as RecoveryRoute }) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useErrorName.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useErrorName.ts index 6acd0df2f45..0279b8b675a 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useErrorName.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useErrorName.ts @@ -23,6 +23,8 @@ export function useErrorName(errorKind: ErrorKind): string { return t('tip_drop_failed') case ERROR_KINDS.GRIPPER_ERROR: return t('gripper_error') + case ERROR_KINDS.STALL_OR_COLLISION: + return t('stall_or_collision_error') default: return t('error') } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts index bc077d4c624..f1a57aa965f 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts @@ -148,6 +148,7 @@ export function getRelevantFailedLabwareCmdFrom({ case ERROR_KINDS.OVERPRESSURE_PREPARE_TO_ASPIRATE: case ERROR_KINDS.OVERPRESSURE_WHILE_ASPIRATING: case ERROR_KINDS.OVERPRESSURE_WHILE_DISPENSING: + case ERROR_KINDS.STALL_OR_COLLISION: return getRelevantPickUpTipCommand(failedCommandByRunRecord, runCommands) case ERROR_KINDS.GRIPPER_ERROR: return failedCommandByRunRecord as MoveLabwareRunTimeCommand @@ -155,7 +156,7 @@ export function getRelevantFailedLabwareCmdFrom({ return null default: console.error( - 'No labware associated with failed command. Handle case explicitly.' + `useFailedLabwareUtils: No labware associated with error kind ${errorKind}. Handle case explicitly.` ) return null } diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts index 3e4b20225c5..01f5c4a7c94 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts @@ -70,6 +70,8 @@ export interface UseRecoveryCommandsResult { homeExceptPlungers: () => Promise /* A non-terminal recovery command */ moveLabwareWithoutPause: () => Promise + /* A non-terminal recovery-command */ + homeAll: () => Promise } // TODO(jh, 07-24-24): Create tighter abstractions for terminal vs. non-terminal commands. @@ -307,6 +309,10 @@ export function useRecoveryCommands({ return chainRunRecoveryCommands([HOME_EXCEPT_PLUNGERS]) }, [chainRunRecoveryCommands]) + const homeAll = useCallback((): Promise => { + return chainRunRecoveryCommands([HOME_ALL]) + }, [chainRunRecoveryCommands]) + const moveLabwareWithoutPause = useCallback((): Promise => { const moveLabwareCmd = buildMoveLabwareWithoutPause( unvalidatedFailedCommand @@ -329,6 +335,7 @@ export function useRecoveryCommands({ moveLabwareWithoutPause, skipFailedCommand, ignoreErrorKindThisRun, + homeAll, } } @@ -371,6 +378,11 @@ export const HOME_EXCEPT_PLUNGERS: CreateCommand = { }, } +export const HOME_ALL: CreateCommand = { + commandType: 'home', + params: {}, +} + const buildMoveLabwareWithoutPause = ( failedCommand: FailedCommand | null ): CreateCommand | null => { diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryOptionCopy.tsx b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryOptionCopy.tsx index b364af7f9d5..6c7f2f8fc94 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryOptionCopy.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryOptionCopy.tsx @@ -26,6 +26,8 @@ export function useRecoveryOptionCopy(): ( } case RECOVERY_MAP.CANCEL_RUN.ROUTE: return t('cancel_run') + case RECOVERY_MAP.HOME_AND_RETRY.ROUTE: + return t('home_and_retry') case RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE: return t('retry_with_new_tips') case RECOVERY_MAP.RETRY_SAME_TIPS.ROUTE: diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts index 9fef84caca9..533b9877f72 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryToasts.ts @@ -171,6 +171,7 @@ function handleRecoveryOptionAction( case RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE: case RECOVERY_MAP.RETRY_STEP.ROUTE: case RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE: + case RECOVERY_MAP.HOME_AND_RETRY.ROUTE: return currentStepReturnVal default: { return null diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx index 7eb207a9fe7..603ac5af6c3 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx @@ -67,6 +67,7 @@ export function ErrorDetailsModal(props: ErrorDetailsModalProps): JSX.Element { case ERROR_KINDS.OVERPRESSURE_WHILE_DISPENSING: case ERROR_KINDS.TIP_NOT_DETECTED: case ERROR_KINDS.GRIPPER_ERROR: + case ERROR_KINDS.STALL_OR_COLLISION: return true default: return false @@ -213,6 +214,8 @@ export function NotificationBanner({ return case ERROR_KINDS.GRIPPER_ERROR: return + case ERROR_KINDS.STALL_OR_COLLISION: + return default: console.error('Handle error kind notification banners explicitly.') return
@@ -258,6 +261,18 @@ export function GripperErrorBanner(): JSX.Element { ) } +export function StallErrorBanner(): JSX.Element { + const { t } = useTranslation('error_recovery') + + return ( + + ) +} + // TODO(jh, 07-24-24): Using shared height/width constants for intervention modal sizing and the ErrorDetailsModal sizing // would be ideal. const DESKTOP_STEP_INFO_STYLE = css` diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryDoorOpenSpecial.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryDoorOpenSpecial.tsx index 98744985225..00b64839b90 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryDoorOpenSpecial.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/RecoveryDoorOpenSpecial.tsx @@ -52,6 +52,7 @@ export function RecoveryDoorOpenSpecial({ switch (selectedRecoveryOption) { case RECOVERY_MAP.MANUAL_REPLACE_AND_RETRY.ROUTE: case RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.ROUTE: + case RECOVERY_MAP.HOME_AND_RETRY.ROUTE: return t('door_open_robot_home') default: { console.error( @@ -62,6 +63,16 @@ export function RecoveryDoorOpenSpecial({ } } + const handleHomeAllAndRoute = ( + route: RecoveryRoute, + step?: RouteStep + ): void => { + void handleMotionRouting(true, RECOVERY_MAP.ROBOT_IN_MOTION.ROUTE) + .then(() => recoveryCommands.homeAll()) + .finally(() => handleMotionRouting(false)) + .then(() => proceedToRouteAndStep(route, step)) + } + const handleHomeExceptPlungersAndRoute = ( route: RecoveryRoute, step?: RouteStep @@ -87,6 +98,12 @@ export function RecoveryDoorOpenSpecial({ RECOVERY_MAP.MANUAL_MOVE_AND_SKIP.STEPS.MANUAL_MOVE ) break + case RECOVERY_MAP.HOME_AND_RETRY.ROUTE: + handleHomeAllAndRoute( + RECOVERY_MAP.HOME_AND_RETRY.ROUTE, + RECOVERY_MAP.HOME_AND_RETRY.STEPS.CONFIRM_RETRY + ) + break default: { console.error( `Unhandled special-cased door open on route ${selectedRecoveryOption}.` diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/RetryStepInfo.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/RetryStepInfo.tsx index c9f7567ee94..fd7a1dbaf5f 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/RetryStepInfo.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/RetryStepInfo.tsx @@ -7,7 +7,9 @@ import { TwoColTextAndFailedStepNextStep } from './TwoColTextAndFailedStepNextSt import type { RecoveryContentProps } from '../types' -export function RetryStepInfo(props: RecoveryContentProps): JSX.Element { +export function RetryStepInfo( + props: RecoveryContentProps & { secondaryBtnOnClickOverride?: () => void } +): JSX.Element { const { routeUpdateActions, recoveryCommands, errorKind } = props const { ROBOT_RETRYING_STEP } = RECOVERY_MAP const { t } = useTranslation('error_recovery') diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx index 00fa95072c1..9bf8f12bc22 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/TwoColLwInfoAndDeck.tsx @@ -34,6 +34,7 @@ export function TwoColLwInfoAndDeck( SKIP_STEP_WITH_NEW_TIPS, MANUAL_MOVE_AND_SKIP, MANUAL_REPLACE_AND_RETRY, + HOME_AND_RETRY, } = RECOVERY_MAP const { selectedRecoveryOption } = currentRecoveryOptionUtils const { relevantWellName, failedLabware } = failedLabwareUtils @@ -55,6 +56,7 @@ export function TwoColLwInfoAndDeck( return t('manually_move_lw_on_deck') case MANUAL_REPLACE_AND_RETRY.ROUTE: return t('manually_replace_lw_on_deck') + case HOME_AND_RETRY.ROUTE: case RETRY_NEW_TIPS.ROUTE: case SKIP_STEP_WITH_NEW_TIPS.ROUTE: { // Only special case the "full" 96-channel nozzle config. @@ -72,7 +74,7 @@ export function TwoColLwInfoAndDeck( } default: console.error( - 'Unexpected recovery option. Handle retry step copy explicitly.' + `TwoColLwInfoAndDeck: Unexpected recovery option: ${selectedRecoveryOption}. Handle retry step copy explicitly.` ) return 'UNEXPECTED RECOVERY OPTION' } @@ -84,14 +86,15 @@ export function TwoColLwInfoAndDeck( case MANUAL_REPLACE_AND_RETRY.ROUTE: return t('ensure_lw_is_accurately_placed') case RETRY_NEW_TIPS.ROUTE: - case SKIP_STEP_WITH_NEW_TIPS.ROUTE: { + case SKIP_STEP_WITH_NEW_TIPS.ROUTE: + case HOME_AND_RETRY.ROUTE: { return isPartialTipConfigValid ? t('replace_tips_and_select_loc_partial_tip') : t('replace_tips_and_select_location') } default: console.error( - 'Unexpected recovery option. Handle retry step copy explicitly.' + `TwoColLwInfoAndDeck:buildBannerText: Unexpected recovery option ${selectedRecoveryOption}. Handle retry step copy explicitly.` ) return 'UNEXPECTED RECOVERY OPTION' } diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/ErrorDetailsModal.test.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/ErrorDetailsModal.test.tsx index d759aaf3d78..ce754df9cfa 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/ErrorDetailsModal.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/__tests__/ErrorDetailsModal.test.tsx @@ -14,6 +14,7 @@ import { OverpressureBanner, TipNotDetectedBanner, GripperErrorBanner, + StallErrorBanner, } from '../ErrorDetailsModal' vi.mock('react-dom', () => ({ @@ -201,3 +202,25 @@ describe('GripperErrorBanner', () => { ) }) }) + +describe('StallErrorBanner', () => { + beforeEach(() => { + vi.mocked(InlineNotification).mockReturnValue( +
MOCK_INLINE_NOTIFICATION
+ ) + }) + it('renders the InlineNotification', () => { + renderWithProviders(, { + i18nInstance: i18n, + }) + expect(vi.mocked(InlineNotification)).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'alert', + heading: + "A stall or collision is detected when the robot's motors are blocked", + message: 'The robot must return to its home position before proceeding', + }), + {} + ) + }) +}) diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts b/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts index e9b5722ffa8..fb3637c0eb5 100644 --- a/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getErrorKind.test.ts @@ -68,6 +68,21 @@ describe('getErrorKind', () => { errorType: 'someHithertoUnknownDefinedErrorType', expectedError: ERROR_KINDS.GENERAL_ERROR, }, + ...([ + 'aspirate', + 'dispense', + 'blowOut', + 'moveToWell', + 'moveToAddressableArea', + 'dropTip', + 'pickUpTip', + 'prepareToAspirate', + ] as const).map(cmd => ({ + commandType: cmd, + errorType: DEFINED_ERROR_TYPES.STALL_OR_COLLISION, + expectedError: ERROR_KINDS.STALL_OR_COLLISION, + isDefined: true, + })), ])( 'returns $expectedError for $commandType with errorType $errorType', ({ commandType, errorType, expectedError, isDefined = true }) => { diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts b/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts index 1dc5e023a6c..73fe862eb3b 100644 --- a/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts +++ b/app/src/organisms/ErrorRecoveryFlows/utils/getErrorKind.ts @@ -54,6 +54,8 @@ export function getErrorKind( errorType === DEFINED_ERROR_TYPES.GRIPPER_MOVEMENT ) { return ERROR_KINDS.GRIPPER_ERROR + } else if (errorType === DEFINED_ERROR_TYPES.STALL_OR_COLLISION) { + return ERROR_KINDS.STALL_OR_COLLISION } }