diff --git a/api-client/src/runs/types.ts b/api-client/src/runs/types.ts index 9a34c2b4978..621443dce03 100644 --- a/api-client/src/runs/types.ts +++ b/api-client/src/runs/types.ts @@ -8,6 +8,7 @@ import type { RunTimeCommand, RunTimeParameter, NozzleLayoutConfig, + OnDeckLabwareLocation, } from '@opentrons/shared-data' import type { ResourceLink, ErrorDetails } from '../types' export * from './commands/types' @@ -117,7 +118,9 @@ export interface Runs { } export interface RunCurrentStateData { + estopEngaged: boolean activeNozzleLayouts: Record // keyed by pipetteId + placeLabwareState?: PlaceLabwareState } export const RUN_ACTION_TYPE_PLAY: 'play' = 'play' @@ -209,3 +212,9 @@ export interface NozzleLayoutValues { activeNozzles: string[] config: NozzleLayoutConfig } + +export interface PlaceLabwareState { + labwareId: string + location: OnDeckLabwareLocation + shouldPlaceDown: boolean +} diff --git a/api/src/opentrons/hardware_control/modules/absorbance_reader.py b/api/src/opentrons/hardware_control/modules/absorbance_reader.py index da7c4746086..ab6ce1bb22b 100644 --- a/api/src/opentrons/hardware_control/modules/absorbance_reader.py +++ b/api/src/opentrons/hardware_control/modules/absorbance_reader.py @@ -272,12 +272,8 @@ def usb_port(self) -> USBPort: return self._usb_port async def deactivate(self, must_be_running: bool = True) -> None: - """Deactivate the module. - - Contains an override to the `wait_for_is_running` step in cases where the - module must be deactivated regardless of context.""" - await self._poller.stop() - await self._driver.disconnect() + """Deactivate the module.""" + pass async def wait_for_is_running(self) -> None: if not self.is_simulated: @@ -336,7 +332,8 @@ async def cleanup(self) -> None: Clean up, i.e. stop pollers, disconnect serial, etc in preparation for object destruction. """ - await self.deactivate() + await self._poller.stop() + await self._driver.disconnect() async def set_sample_wavelength( self, diff --git a/api/src/opentrons/motion_planning/__init__.py b/api/src/opentrons/motion_planning/__init__.py index 570d4250ebe..2b304ecb74d 100644 --- a/api/src/opentrons/motion_planning/__init__.py +++ b/api/src/opentrons/motion_planning/__init__.py @@ -6,6 +6,7 @@ MINIMUM_Z_MARGIN, get_waypoints, get_gripper_labware_movement_waypoints, + get_gripper_labware_placement_waypoints, ) from .types import Waypoint, MoveType @@ -27,4 +28,5 @@ "ArcOutOfBoundsError", "get_waypoints", "get_gripper_labware_movement_waypoints", + "get_gripper_labware_placement_waypoints", ] diff --git a/api/src/opentrons/motion_planning/waypoints.py b/api/src/opentrons/motion_planning/waypoints.py index b9c62114215..bcc56ad7eda 100644 --- a/api/src/opentrons/motion_planning/waypoints.py +++ b/api/src/opentrons/motion_planning/waypoints.py @@ -181,3 +181,35 @@ def get_gripper_labware_movement_waypoints( ) ) return waypoints_with_jaw_status + + +def get_gripper_labware_placement_waypoints( + to_labware_center: Point, + gripper_home_z: float, + drop_offset: Optional[Point], +) -> List[GripperMovementWaypointsWithJawStatus]: + """Get waypoints for placing labware using a gripper.""" + drop_offset = drop_offset or Point() + + drop_location = to_labware_center + Point( + drop_offset.x, drop_offset.y, drop_offset.z + ) + + post_drop_home_pos = Point(drop_location.x, drop_location.y, gripper_home_z) + + return [ + GripperMovementWaypointsWithJawStatus( + position=Point(drop_location.x, drop_location.y, gripper_home_z), + jaw_open=False, + dropping=False, + ), + GripperMovementWaypointsWithJawStatus( + position=drop_location, jaw_open=False, dropping=False + ), + # Gripper ungrips here + GripperMovementWaypointsWithJawStatus( + position=post_drop_home_pos, + jaw_open=True, + dropping=True, + ), + ] diff --git a/api/src/opentrons/protocol_api/core/engine/module_core.py b/api/src/opentrons/protocol_api/core/engine/module_core.py index 1d800dee7ea..9f2785b7432 100644 --- a/api/src/opentrons/protocol_api/core/engine/module_core.py +++ b/api/src/opentrons/protocol_api/core/engine/module_core.py @@ -586,7 +586,7 @@ def initialize( ) self._initialized_value = wavelengths - def read(self, filename: Optional[str]) -> Dict[int, Dict[str, float]]: + def read(self, filename: Optional[str] = None) -> Dict[int, Dict[str, float]]: """Initiate a read on the Absorbance Reader, and return the results. During Analysis, this will return a measurement of zero for all wells.""" wavelengths = self._engine_client.state.modules.get_absorbance_reader_substate( self.module_id diff --git a/api/src/opentrons/protocol_api/core/engine/protocol.py b/api/src/opentrons/protocol_api/core/engine/protocol.py index 0ed5270320a..dac8bc44a5b 100644 --- a/api/src/opentrons/protocol_api/core/engine/protocol.py +++ b/api/src/opentrons/protocol_api/core/engine/protocol.py @@ -449,9 +449,10 @@ def load_module( # When the protocol engine is created, we add Module Lids as part of the deck fixed labware # If a valid module exists in the deck config. For analysis, we add the labware here since - # deck fixed labware is not created under the same conditions. - if self._engine_client.state.config.use_virtual_modules: - self._load_virtual_module_lid(module_core) + # deck fixed labware is not created under the same conditions. We also need to inject the Module + # lids when the module isnt already on the deck config, like when adding a new + # module during a protocol setup. + self._load_virtual_module_lid(module_core) self._module_cores_by_id[module_core.module_id] = module_core @@ -461,20 +462,24 @@ def _load_virtual_module_lid( self, module_core: Union[ModuleCore, NonConnectedModuleCore] ) -> None: if isinstance(module_core, AbsorbanceReaderCore): - lid = self._engine_client.execute_command_without_recovery( - cmd.LoadLabwareParams( - loadName="opentrons_flex_lid_absorbance_plate_reader_module", - location=ModuleLocation(moduleId=module_core.module_id), - namespace="opentrons", - version=1, - displayName="Absorbance Reader Lid", - ) + substate = self._engine_client.state.modules.get_absorbance_reader_substate( + module_core.module_id ) + if substate.lid_id is None: + lid = self._engine_client.execute_command_without_recovery( + cmd.LoadLabwareParams( + loadName="opentrons_flex_lid_absorbance_plate_reader_module", + location=ModuleLocation(moduleId=module_core.module_id), + namespace="opentrons", + version=1, + displayName="Absorbance Reader Lid", + ) + ) - self._engine_client.add_absorbance_reader_lid( - module_id=module_core.module_id, - lid_id=lid.labwareId, - ) + self._engine_client.add_absorbance_reader_lid( + module_id=module_core.module_id, + lid_id=lid.labwareId, + ) def _create_non_connected_module_core( self, load_module_result: LoadModuleResult diff --git a/api/src/opentrons/protocol_api/core/module.py b/api/src/opentrons/protocol_api/core/module.py index c93e8ce8de8..e24fbbc54b0 100644 --- a/api/src/opentrons/protocol_api/core/module.py +++ b/api/src/opentrons/protocol_api/core/module.py @@ -365,7 +365,7 @@ def initialize( """Initialize the Absorbance Reader by taking zero reading.""" @abstractmethod - def read(self, filename: Optional[str]) -> Dict[int, Dict[str, float]]: + def read(self, filename: Optional[str] = None) -> Dict[int, Dict[str, float]]: """Get an absorbance reading from the Absorbance Reader.""" @abstractmethod diff --git a/api/src/opentrons/protocol_api/module_contexts.py b/api/src/opentrons/protocol_api/module_contexts.py index f7541da1836..9ae550f8d3f 100644 --- a/api/src/opentrons/protocol_api/module_contexts.py +++ b/api/src/opentrons/protocol_api/module_contexts.py @@ -1035,7 +1035,9 @@ def initialize( ) @requires_version(2, 21) - def read(self, export_filename: Optional[str]) -> Dict[int, Dict[str, float]]: + def read( + self, export_filename: Optional[str] = None + ) -> Dict[int, Dict[str, float]]: """Initiate read on the Absorbance Reader. Returns a dictionary of wavelengths to dictionary of values ordered by well name. diff --git a/api/src/opentrons/protocol_engine/commands/command_unions.py b/api/src/opentrons/protocol_engine/commands/command_unions.py index 146b688253f..0e0a4cf3112 100644 --- a/api/src/opentrons/protocol_engine/commands/command_unions.py +++ b/api/src/opentrons/protocol_engine/commands/command_unions.py @@ -392,6 +392,7 @@ unsafe.UpdatePositionEstimators, unsafe.UnsafeEngageAxes, unsafe.UnsafeUngripLabware, + unsafe.UnsafePlaceLabware, ], Field(discriminator="commandType"), ] @@ -469,6 +470,7 @@ unsafe.UpdatePositionEstimatorsParams, unsafe.UnsafeEngageAxesParams, unsafe.UnsafeUngripLabwareParams, + unsafe.UnsafePlaceLabwareParams, ] CommandType = Union[ @@ -544,6 +546,7 @@ unsafe.UpdatePositionEstimatorsCommandType, unsafe.UnsafeEngageAxesCommandType, unsafe.UnsafeUngripLabwareCommandType, + unsafe.UnsafePlaceLabwareCommandType, ] CommandCreate = Annotated[ @@ -620,6 +623,7 @@ unsafe.UpdatePositionEstimatorsCreate, unsafe.UnsafeEngageAxesCreate, unsafe.UnsafeUngripLabwareCreate, + unsafe.UnsafePlaceLabwareCreate, ], Field(discriminator="commandType"), ] @@ -697,6 +701,7 @@ unsafe.UpdatePositionEstimatorsResult, unsafe.UnsafeEngageAxesResult, unsafe.UnsafeUngripLabwareResult, + unsafe.UnsafePlaceLabwareResult, ] diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py b/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py index 72698a3b0f2..eb138d89914 100644 --- a/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/unsafe/__init__.py @@ -40,6 +40,15 @@ ) +from .unsafe_place_labware import ( + UnsafePlaceLabwareCommandType, + UnsafePlaceLabwareParams, + UnsafePlaceLabwareResult, + UnsafePlaceLabware, + UnsafePlaceLabwareCreate, +) + + __all__ = [ # Unsafe blow-out-in-place command models "UnsafeBlowOutInPlaceCommandType", @@ -71,4 +80,10 @@ "UnsafeUngripLabwareResult", "UnsafeUngripLabware", "UnsafeUngripLabwareCreate", + # Unsafe place labware + "UnsafePlaceLabwareCommandType", + "UnsafePlaceLabwareParams", + "UnsafePlaceLabwareResult", + "UnsafePlaceLabware", + "UnsafePlaceLabwareCreate", ] diff --git a/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py new file mode 100644 index 00000000000..547b8416637 --- /dev/null +++ b/api/src/opentrons/protocol_engine/commands/unsafe/unsafe_place_labware.py @@ -0,0 +1,194 @@ +"""Place labware payload, result, and implementaiton.""" + +from __future__ import annotations +from pydantic import BaseModel, Field +from typing import TYPE_CHECKING, Optional, Type, cast +from typing_extensions import Literal + +from opentrons.hardware_control.types import Axis, OT3Mount +from opentrons.motion_planning.waypoints import get_gripper_labware_placement_waypoints +from opentrons.protocol_engine.errors.exceptions import ( + CannotPerformGripperAction, + GripperNotAttachedError, +) +from opentrons.types import Point + +from ...types import DeckSlotLocation, ModuleModel, OnDeckLabwareLocation +from ..command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData +from ...errors.error_occurrence import ErrorOccurrence +from ...resources import ensure_ot3_hardware +from ...state.update_types import StateUpdate + +from opentrons.hardware_control import HardwareControlAPI, OT3HardwareControlAPI + +if TYPE_CHECKING: + from ...state.state import StateView + from ...execution.equipment import EquipmentHandler + + +UnsafePlaceLabwareCommandType = Literal["unsafe/placeLabware"] + + +class UnsafePlaceLabwareParams(BaseModel): + """Payload required for an UnsafePlaceLabware command.""" + + labwareId: str = Field(..., description="The id of the labware to place.") + location: OnDeckLabwareLocation = Field( + ..., description="Where to place the labware." + ) + + +class UnsafePlaceLabwareResult(BaseModel): + """Result data from the execution of an UnsafePlaceLabware command.""" + + +class UnsafePlaceLabwareImplementation( + AbstractCommandImpl[ + UnsafePlaceLabwareParams, + SuccessData[UnsafePlaceLabwareResult], + ] +): + """The UnsafePlaceLabware command implementation.""" + + def __init__( + self, + hardware_api: HardwareControlAPI, + state_view: StateView, + equipment: EquipmentHandler, + **kwargs: object, + ) -> None: + self._hardware_api = hardware_api + self._state_view = state_view + self._equipment = equipment + + async def execute( + self, params: UnsafePlaceLabwareParams + ) -> SuccessData[UnsafePlaceLabwareResult]: + """Place Labware. + + This command is used only when the gripper is in the middle of moving + labware but is interrupted before completing the move. (i.e., the e-stop + is pressed, get into error recovery, etc). + + Unlike the `moveLabware` command, where you pick a source and destination + location, this command takes the labwareId to be moved and location to + move it to. + + """ + ot3api = ensure_ot3_hardware(self._hardware_api) + if not ot3api.has_gripper(): + raise GripperNotAttachedError("No gripper found to perform labware place.") + + if ot3api.gripper_jaw_can_home(): + raise CannotPerformGripperAction( + "Cannot place labware when gripper is not gripping." + ) + + # Allow propagation of LabwareNotLoadedError. + labware_id = params.labwareId + definition_uri = self._state_view.labware.get(labware_id).definitionUri + final_offsets = self._state_view.labware.get_labware_gripper_offsets( + labware_id, None + ) + drop_offset = cast(Point, final_offsets.dropOffset) if final_offsets else None + + if isinstance(params.location, DeckSlotLocation): + self._state_view.addressable_areas.raise_if_area_not_in_deck_configuration( + params.location.slotName.id + ) + + location = self._state_view.geometry.ensure_valid_gripper_location( + params.location, + ) + + # This is an absorbance reader, move the lid to its dock (staging area). + if isinstance(location, DeckSlotLocation): + module = self._state_view.modules.get_by_slot(location.slotName) + if module and module.model == ModuleModel.ABSORBANCE_READER_V1: + location = self._state_view.modules.absorbance_reader_dock_location( + module.id + ) + + new_offset_id = self._equipment.find_applicable_labware_offset_id( + labware_definition_uri=definition_uri, + labware_location=location, + ) + + # NOTE: When the estop is pressed, the gantry loses position, + # so the robot needs to home x, y to sync. + await ot3api.home(axes=[Axis.Z_L, Axis.Z_R, Axis.Z_G, Axis.X, Axis.Y]) + state_update = StateUpdate() + + # Place the labware down + await self._start_movement(ot3api, labware_id, location, drop_offset) + + state_update.set_labware_location( + labware_id=labware_id, + new_location=location, + new_offset_id=new_offset_id, + ) + return SuccessData(public=UnsafePlaceLabwareResult(), state_update=state_update) + + async def _start_movement( + self, + ot3api: OT3HardwareControlAPI, + labware_id: str, + location: OnDeckLabwareLocation, + drop_offset: Optional[Point], + ) -> None: + gripper_homed_position = await ot3api.gantry_position( + mount=OT3Mount.GRIPPER, + refresh=True, + ) + + to_labware_center = self._state_view.geometry.get_labware_grip_point( + labware_id=labware_id, location=location + ) + + movement_waypoints = get_gripper_labware_placement_waypoints( + to_labware_center=to_labware_center, + gripper_home_z=gripper_homed_position.z, + drop_offset=drop_offset, + ) + + # start movement + for waypoint_data in movement_waypoints: + if waypoint_data.jaw_open: + if waypoint_data.dropping: + # This `disengage_axes` step is important in order to engage + # the electronic brake on the Z axis of the gripper. The brake + # has a stronger holding force on the axis than the hold current, + # and prevents the axis from spuriously dropping when e.g. the notch + # on the side of a falling tiprack catches the jaw. + await ot3api.disengage_axes([Axis.Z_G]) + await ot3api.ungrip() + if waypoint_data.dropping: + # We lost the position estimation after disengaging the axis, so + # it is necessary to home it next + await ot3api.home_z(OT3Mount.GRIPPER) + await ot3api.move_to( + mount=OT3Mount.GRIPPER, abs_position=waypoint_data.position + ) + + +class UnsafePlaceLabware( + BaseCommand[UnsafePlaceLabwareParams, UnsafePlaceLabwareResult, ErrorOccurrence] +): + """UnsafePlaceLabware command model.""" + + commandType: UnsafePlaceLabwareCommandType = "unsafe/placeLabware" + params: UnsafePlaceLabwareParams + result: Optional[UnsafePlaceLabwareResult] + + _ImplementationCls: Type[ + UnsafePlaceLabwareImplementation + ] = UnsafePlaceLabwareImplementation + + +class UnsafePlaceLabwareCreate(BaseCommandCreate[UnsafePlaceLabwareParams]): + """UnsafePlaceLabware command request model.""" + + commandType: UnsafePlaceLabwareCommandType = "unsafe/placeLabware" + params: UnsafePlaceLabwareParams + + _CommandCls: Type[UnsafePlaceLabware] = UnsafePlaceLabware diff --git a/app/src/App/OnDeviceDisplayApp.tsx b/app/src/App/OnDeviceDisplayApp.tsx index 7be1070ca12..2e2217ce20e 100644 --- a/app/src/App/OnDeviceDisplayApp.tsx +++ b/app/src/App/OnDeviceDisplayApp.tsx @@ -191,9 +191,9 @@ export const OnDeviceDisplayApp = (): JSX.Element => { ) : ( <> - + diff --git a/app/src/organisms/EmergencyStop/EstopMissingModal.tsx b/app/src/organisms/EmergencyStop/EstopMissingModal.tsx index 3b862a94a9d..07fe453c932 100644 --- a/app/src/organisms/EmergencyStop/EstopMissingModal.tsx +++ b/app/src/organisms/EmergencyStop/EstopMissingModal.tsx @@ -42,7 +42,7 @@ export function EstopMissingModal({ ) : ( <> - {isDismissedModal === false ? ( + {!isDismissedModal ? ( - {t('estop_missing_description', { robotName: robotName })} + {t('estop_missing_description', { robotName })} @@ -121,7 +121,7 @@ function DesktopModal({ {t('connect_the_estop_to_continue')} - {t('estop_missing_description', { robotName: robotName })} + {t('estop_missing_description', { robotName })} diff --git a/app/src/organisms/EmergencyStop/EstopPressedModal.tsx b/app/src/organisms/EmergencyStop/EstopPressedModal.tsx index 8dc996c3374..7c78de6b8e2 100644 --- a/app/src/organisms/EmergencyStop/EstopPressedModal.tsx +++ b/app/src/organisms/EmergencyStop/EstopPressedModal.tsx @@ -24,6 +24,7 @@ import { import { useAcknowledgeEstopDisengageMutation } from '@opentrons/react-api-client' +import { usePlacePlateReaderLid } from '/app/resources/modules' import { getTopPortalEl } from '/app/App/portal' import { SmallButton } from '/app/atoms/buttons' import { OddModal } from '/app/molecules/OddModal' @@ -40,19 +41,15 @@ import type { ModalProps } from '@opentrons/components' interface EstopPressedModalProps { isEngaged: boolean closeModal: () => void - isDismissedModal?: boolean - setIsDismissedModal?: (isDismissedModal: boolean) => void - isWaitingForLogicalDisengage: boolean - setShouldSeeLogicalDisengage: () => void + isWaitingForResumeOperation: boolean + setIsWaitingForResumeOperation: () => void } export function EstopPressedModal({ isEngaged, closeModal, - isDismissedModal, - setIsDismissedModal, - isWaitingForLogicalDisengage, - setShouldSeeLogicalDisengage, + isWaitingForResumeOperation, + setIsWaitingForResumeOperation, }: EstopPressedModalProps): JSX.Element { const isOnDevice = useSelector(getIsOnDevice) return createPortal( @@ -60,20 +57,17 @@ export function EstopPressedModal({ ) : ( <> - {isDismissedModal === false ? ( - - ) : null} + ), getTopPortalEl() @@ -83,12 +77,19 @@ export function EstopPressedModal({ function TouchscreenModal({ isEngaged, closeModal, - isWaitingForLogicalDisengage, - setShouldSeeLogicalDisengage, + isWaitingForResumeOperation, + setIsWaitingForResumeOperation, }: EstopPressedModalProps): JSX.Element { const { t } = useTranslation(['device_settings', 'branded']) const [isResuming, setIsResuming] = React.useState(false) const { acknowledgeEstopDisengage } = useAcknowledgeEstopDisengageMutation() + + const { + handlePlaceReaderLid, + isValidPlateReaderMove, + } = usePlacePlateReaderLid({ + onSettled: closeModal, + }) const modalHeader: OddModalHeaderBaseProps = { title: t('estop_pressed'), iconName: 'ot-alert', @@ -100,9 +101,12 @@ function TouchscreenModal({ } const handleClick = (): void => { setIsResuming(true) + setIsWaitingForResumeOperation() acknowledgeEstopDisengage(null) - setShouldSeeLogicalDisengage() - closeModal() + handlePlaceReaderLid() + if (!isValidPlateReaderMove) { + closeModal() + } } return ( @@ -131,15 +135,13 @@ function TouchscreenModal({ data-testid="Estop_pressed_button" width="100%" iconName={ - isResuming || isWaitingForLogicalDisengage - ? 'ot-spinner' - : undefined + isResuming || isWaitingForResumeOperation ? 'ot-spinner' : undefined } iconPlacement={ - isResuming || isWaitingForLogicalDisengage ? 'startIcon' : undefined + isResuming || isWaitingForResumeOperation ? 'startIcon' : undefined } buttonText={t('resume_robot_operations')} - disabled={isEngaged || isResuming || isWaitingForLogicalDisengage} + disabled={isEngaged || isResuming || isWaitingForResumeOperation} onClick={handleClick} /> @@ -150,25 +152,23 @@ function TouchscreenModal({ function DesktopModal({ isEngaged, closeModal, - setIsDismissedModal, - isWaitingForLogicalDisengage, - setShouldSeeLogicalDisengage, + isWaitingForResumeOperation, + setIsWaitingForResumeOperation, }: EstopPressedModalProps): JSX.Element { const { t } = useTranslation('device_settings') const [isResuming, setIsResuming] = React.useState(false) const { acknowledgeEstopDisengage } = useAcknowledgeEstopDisengageMutation() - - const handleCloseModal = (): void => { - if (setIsDismissedModal != null) { - setIsDismissedModal(true) - } - closeModal() - } + const { + handlePlaceReaderLid, + isValidPlateReaderMove, + } = usePlacePlateReaderLid({ + onSettled: closeModal, + }) const modalProps: ModalProps = { type: 'error', title: t('estop_pressed'), - onClose: handleCloseModal, + onClose: closeModal, closeOnOutsideClick: false, childrenPadding: SPACING.spacing24, width: '47rem', @@ -177,19 +177,12 @@ function DesktopModal({ const handleClick: React.MouseEventHandler = (e): void => { e.preventDefault() setIsResuming(true) - acknowledgeEstopDisengage( - {}, - { - onSuccess: () => { - setShouldSeeLogicalDisengage() - closeModal() - }, - onError: (error: any) => { - setIsResuming(false) - console.error(error) - }, - } - ) + setIsWaitingForResumeOperation() + acknowledgeEstopDisengage(null) + handlePlaceReaderLid() + if (!isValidPlateReaderMove) { + closeModal() + } } return ( @@ -204,14 +197,14 @@ function DesktopModal({ - {isResuming || isWaitingForLogicalDisengage ? ( + {isResuming || isWaitingForResumeOperation ? ( ) : null} {t('resume_robot_operations')} diff --git a/app/src/organisms/EmergencyStop/EstopTakeover.tsx b/app/src/organisms/EmergencyStop/EstopTakeover.tsx index 5967edae75a..cbd9ba1a310 100644 --- a/app/src/organisms/EmergencyStop/EstopTakeover.tsx +++ b/app/src/organisms/EmergencyStop/EstopTakeover.tsx @@ -1,18 +1,13 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useSelector } from 'react-redux' import { useEstopQuery } from '@opentrons/react-api-client' import { EstopPressedModal } from './EstopPressedModal' import { EstopMissingModal } from './EstopMissingModal' -import { useEstopContext } from './hooks' import { useIsUnboxingFlowOngoing } from '/app/redux-resources/config' import { getLocalRobot } from '/app/redux/discovery' -import { - PHYSICALLY_ENGAGED, - LOGICALLY_ENGAGED, - NOT_PRESENT, - DISENGAGED, -} from './constants' +import { PHYSICALLY_ENGAGED, NOT_PRESENT, DISENGAGED } from './constants' +import type { EstopState } from '@opentrons/api-client' const ESTOP_CURRENTLY_DISENGAGED_REFETCH_INTERVAL_MS = 10000 const ESTOP_CURRENTLY_ENGAGED_REFETCH_INTERVAL_MS = 1000 @@ -22,71 +17,62 @@ interface EstopTakeoverProps { } export function EstopTakeover({ robotName }: EstopTakeoverProps): JSX.Element { - const [estopEngaged, setEstopEngaged] = useState(false) + const [isDismissedModal, setIsDismissedModal] = useState(false) const [ - isWaitingForLogicalDisengage, - setIsWaitingForLogicalDisengage, + isWaitingForResumeOperation, + setIsWatingForResumeOperation, ] = useState(false) + + const [estopState, setEstopState] = useState() + const [showEmergencyStopModal, setShowEmergencyStopModal] = useState( + false + ) + + // TODO: (ba, 2024-10-24): Use notifications instead of polling const { data: estopStatus } = useEstopQuery({ - refetchInterval: estopEngaged + refetchInterval: showEmergencyStopModal ? ESTOP_CURRENTLY_ENGAGED_REFETCH_INTERVAL_MS : ESTOP_CURRENTLY_DISENGAGED_REFETCH_INTERVAL_MS, - onSuccess: response => { - setEstopEngaged( - [PHYSICALLY_ENGAGED || LOGICALLY_ENGAGED].includes( - response?.data.status - ) - ) - setIsWaitingForLogicalDisengage(false) - }, }) + useEffect(() => { + if (estopStatus) { + setEstopState(estopStatus.data.status) + setShowEmergencyStopModal( + estopStatus.data.status !== DISENGAGED || isWaitingForResumeOperation + ) + } + }, [estopStatus]) - const { - isEmergencyStopModalDismissed, - setIsEmergencyStopModalDismissed, - } = useEstopContext() const isUnboxingFlowOngoing = useIsUnboxingFlowOngoing() const closeModal = (): void => { - if (estopStatus?.data.status === DISENGAGED) { - setIsEmergencyStopModalDismissed(false) - } + setIsWatingForResumeOperation(false) } const localRobot = useSelector(getLocalRobot) const localRobotName = localRobot?.name ?? 'no name' const TargetEstopModal = (): JSX.Element | null => { - switch (estopStatus?.data.status) { - case PHYSICALLY_ENGAGED: - case LOGICALLY_ENGAGED: - return ( - { - setIsWaitingForLogicalDisengage(true) - }} - /> - ) - case NOT_PRESENT: - return ( - - ) - default: - return null - } + return estopState === NOT_PRESENT ? ( + + ) : estopState !== DISENGAGED || isWaitingForResumeOperation ? ( + { + setIsWatingForResumeOperation(true) + }} + /> + ) : null } return ( <> - {estopStatus?.data.status !== DISENGAGED && !isUnboxingFlowOngoing ? ( + {showEmergencyStopModal && !isUnboxingFlowOngoing ? ( ) : null} diff --git a/app/src/organisms/EmergencyStop/__tests__/EstopPressedModal.test.tsx b/app/src/organisms/EmergencyStop/__tests__/EstopPressedModal.test.tsx index 124ea72b3ed..067211c4c06 100644 --- a/app/src/organisms/EmergencyStop/__tests__/EstopPressedModal.test.tsx +++ b/app/src/organisms/EmergencyStop/__tests__/EstopPressedModal.test.tsx @@ -8,9 +8,11 @@ import { useAcknowledgeEstopDisengageMutation } from '@opentrons/react-api-clien import { i18n } from '/app/i18n' import { getIsOnDevice } from '/app/redux/config' import { EstopPressedModal } from '../EstopPressedModal' +import { usePlacePlateReaderLid } from '/app/resources/modules' vi.mock('@opentrons/react-api-client') vi.mock('/app/redux/config') +vi.mock('/app/resources/modules') const render = (props: React.ComponentProps) => { return renderWithProviders(, { @@ -25,13 +27,19 @@ describe('EstopPressedModal - Touchscreen', () => { props = { isEngaged: true, closeModal: vi.fn(), - isWaitingForLogicalDisengage: false, - setShouldSeeLogicalDisengage: vi.fn(), + isWaitingForResumeOperation: false, + setIsWaitingForResumeOperation: vi.fn(), } vi.mocked(getIsOnDevice).mockReturnValue(true) vi.mocked(useAcknowledgeEstopDisengageMutation).mockReturnValue({ setEstopPhysicalStatus: vi.fn(), } as any) + + vi.mocked(usePlacePlateReaderLid).mockReturnValue({ + handlePlaceReaderLid: vi.fn(), + isValidPlateReaderMove: false, + isExecuting: false, + }) }) it('should render text and button', () => { @@ -59,6 +67,20 @@ describe('EstopPressedModal - Touchscreen', () => { render(props) fireEvent.click(screen.getByText('Resume robot operations')) expect(useAcknowledgeEstopDisengageMutation).toHaveBeenCalled() + expect(usePlacePlateReaderLid).toHaveBeenCalled() + }) + + it('should call a mock function to place the labware to a slot', () => { + vi.mocked(usePlacePlateReaderLid).mockReturnValue({ + handlePlaceReaderLid: vi.fn(), + isValidPlateReaderMove: true, + isExecuting: true, + }) + + render(props) + fireEvent.click(screen.getByText('Resume robot operations')) + expect(useAcknowledgeEstopDisengageMutation).toHaveBeenCalled() + expect(usePlacePlateReaderLid).toHaveBeenCalled() }) }) @@ -69,15 +91,19 @@ describe('EstopPressedModal - Desktop', () => { props = { isEngaged: true, closeModal: vi.fn(), - isDismissedModal: false, - setIsDismissedModal: vi.fn(), - isWaitingForLogicalDisengage: false, - setShouldSeeLogicalDisengage: vi.fn(), + isWaitingForResumeOperation: false, + setIsWaitingForResumeOperation: vi.fn(), } vi.mocked(getIsOnDevice).mockReturnValue(false) vi.mocked(useAcknowledgeEstopDisengageMutation).mockReturnValue({ setEstopPhysicalStatus: vi.fn(), } as any) + + vi.mocked(usePlacePlateReaderLid).mockReturnValue({ + handlePlaceReaderLid: vi.fn(), + isValidPlateReaderMove: false, + isExecuting: false, + }) }) it('should render text and button', () => { render(props) @@ -99,10 +125,18 @@ describe('EstopPressedModal - Desktop', () => { ).not.toBeDisabled() }) + it('should resume robot operation button is disabled when waiting for labware plate to finish', () => { + props.isEngaged = false + props.isWaitingForResumeOperation = true + render(props) + expect( + screen.getByRole('button', { name: 'Resume robot operations' }) + ).toBeDisabled() + }) + it('should call a mock function when clicking close icon', () => { render(props) fireEvent.click(screen.getByTestId('ModalHeader_icon_close_E-stop pressed')) - expect(props.setIsDismissedModal).toHaveBeenCalled() expect(props.closeModal).toHaveBeenCalled() }) diff --git a/app/src/resources/modules/hooks/index.ts b/app/src/resources/modules/hooks/index.ts index c38e5f46140..c26e43c8bfc 100644 --- a/app/src/resources/modules/hooks/index.ts +++ b/app/src/resources/modules/hooks/index.ts @@ -1 +1,2 @@ export * from './useAttachedModules' +export * from './usePlacePlateReaderLid' diff --git a/app/src/resources/modules/hooks/usePlacePlateReaderLid.ts b/app/src/resources/modules/hooks/usePlacePlateReaderLid.ts new file mode 100644 index 00000000000..0e4dabcb660 --- /dev/null +++ b/app/src/resources/modules/hooks/usePlacePlateReaderLid.ts @@ -0,0 +1,82 @@ +import { useRunCurrentState } from '@opentrons/react-api-client' +import { useCurrentRunId } from '../../runs' +import { useRobotControlCommands } from '/app/resources/maintenance_runs' + +import type { + CreateCommand, + OnDeckLabwareLocation, + ModuleLocation, +} from '@opentrons/shared-data' +import type { UseRobotControlCommandsProps } from '/app/resources/maintenance_runs' + +interface UsePlacePlateReaderLidResult { + handlePlaceReaderLid: () => Promise + isExecuting: boolean + isValidPlateReaderMove: boolean +} + +type UsePlacePlateReaderLidProps = Pick< + UseRobotControlCommandsProps, + 'onSettled' +> + +export function usePlacePlateReaderLid( + props: UsePlacePlateReaderLidProps +): UsePlacePlateReaderLidResult { + const runId = useCurrentRunId() + const { data: runCurrentState } = useRunCurrentState(runId) + + const placeLabware = runCurrentState?.data.placeLabwareState ?? null + const isValidPlateReaderMove = + placeLabware !== null && placeLabware.shouldPlaceDown + + // TODO eventually load module support for useRobotControlCommands + let commandsToExecute: CreateCommand[] = [] + if (isValidPlateReaderMove) { + const location = placeLabware.location + const loadModuleCommand = buildLoadModuleCommand(location as ModuleLocation) + const placeLabwareCommand = buildPlaceLabwareCommand( + placeLabware.labwareId as string, + location + ) + commandsToExecute = [loadModuleCommand, placeLabwareCommand] + } + + const { executeCommands, isExecuting } = useRobotControlCommands({ + ...props, + pipetteInfo: null, + commands: commandsToExecute, + continuePastCommandFailure: true, + }) + + const handlePlaceReaderLid = (): Promise => { + if (isValidPlateReaderMove) { + return executeCommands().then(() => Promise.resolve()) + } else { + return Promise.resolve() + } + } + + return { + handlePlaceReaderLid, + isExecuting, + isValidPlateReaderMove, + } +} + +const buildLoadModuleCommand = (location: ModuleLocation): CreateCommand => { + return { + commandType: 'loadModule' as const, + params: { model: 'absorbanceReaderV1', location }, + } +} + +const buildPlaceLabwareCommand = ( + labwareId: string, + location: OnDeckLabwareLocation +): CreateCommand => { + return { + commandType: 'unsafe/placeLabware' as const, + params: { labwareId, location }, + } +} diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index c108fa60a74..b7df09f8992 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -12,7 +12,13 @@ from pydantic import BaseModel, Field from opentrons_shared_data.errors import ErrorCodes -from opentrons.protocol_engine.types import CSVRuntimeParamPaths +from opentrons_shared_data.robot.types import RobotTypeEnum +from opentrons.hardware_control import HardwareControlAPI +from opentrons.hardware_control.modules.absorbance_reader import AbsorbanceReader +from opentrons.hardware_control.types import EstopState +from opentrons.protocol_engine.commands.absorbance_reader import CloseLid, OpenLid +from opentrons.protocol_engine.commands.move_labware import MoveLabware +from opentrons.protocol_engine.types import CSVRuntimeParamPaths, DeckSlotLocation from opentrons.protocol_engine import ( errors as pe_errors, ) @@ -27,6 +33,7 @@ from robot_server.protocols.protocol_models import ProtocolKind from robot_server.service.dependencies import get_current_time, get_unique_id from robot_server.robot.control.dependencies import require_estop_in_good_state +from robot_server.hardware import get_hardware, get_robot_type_enum from robot_server.service.json_api import ( RequestModel, @@ -48,6 +55,7 @@ from robot_server.protocols.router import ProtocolNotFound from ..run_models import ( + PlaceLabwareState, RunNotFoundError, ActiveNozzleLayout, RunCurrentState, @@ -567,17 +575,22 @@ async def get_run_commands_error( status.HTTP_409_CONFLICT: {"model": ErrorBody[RunStopped]}, }, ) -async def get_current_state( +async def get_current_state( # noqa: C901 runId: str, run_data_manager: Annotated[RunDataManager, Depends(get_run_data_manager)], + hardware: Annotated[HardwareControlAPI, Depends(get_hardware)], + robot_type: Annotated[RobotTypeEnum, Depends(get_robot_type_enum)], ) -> PydanticResponse[Body[RunCurrentState, CurrentStateLinks]]: """Get current state associated with a run if the run is current. Arguments: runId: Run ID pulled from URL. run_data_manager: Run data retrieval interface. + hardware: Hardware control interface. + robot_type: The type of robot. """ try: + run = run_data_manager.get(run_id=runId) active_nozzle_maps = run_data_manager.get_nozzle_maps(run_id=runId) nozzle_layouts = { @@ -589,6 +602,7 @@ async def get_current_state( for pipetteId, nozzle_map in active_nozzle_maps.items() } + current_command = run_data_manager.get_current_command(run_id=runId) last_completed_command = run_data_manager.get_last_completed_command( run_id=runId ) @@ -604,9 +618,60 @@ async def get_current_state( else None ) + estop_engaged = False + place_labware = None + if robot_type == RobotTypeEnum.FLEX: + estop_engaged = hardware.get_estop_state() in [ + EstopState.PHYSICALLY_ENGAGED, + EstopState.LOGICALLY_ENGAGED, + ] + + command = ( + run_data_manager.get_command(runId, current_command.command_id) + if current_command + else None + ) + + # Labware state when estop is engaged + if isinstance(command, MoveLabware): + location = command.params.newLocation + if isinstance(location, DeckSlotLocation): + place_labware = PlaceLabwareState( + location=location, + labwareId=command.params.labwareId, + shouldPlaceDown=False, + ) + # Handle absorbance reader lid + elif isinstance(command, (OpenLid, CloseLid)): + for mod in run.modules: + if ( + not isinstance(mod, AbsorbanceReader) + and mod.id != command.params.moduleId + ): + continue + for hw_mod in hardware.attached_modules: + if ( + mod.location is not None + and hw_mod.serial_number == mod.serialNumber + ): + location = mod.location + labware_id = f"{mod.model}Lid{location.slotName}" + place_labware = PlaceLabwareState( + location=location, + labwareId=labware_id, + shouldPlaceDown=estop_engaged, + ) + break + if place_labware: + break + return await PydanticResponse.create( content=Body.construct( - data=RunCurrentState.construct(activeNozzleLayouts=nozzle_layouts), + data=RunCurrentState.construct( + estopEngaged=estop_engaged, + activeNozzleLayouts=nozzle_layouts, + placeLabwareState=place_labware, + ), links=links, ), status_code=status.HTTP_200_OK, diff --git a/robot-server/robot_server/runs/run_models.py b/robot-server/robot_server/runs/run_models.py index bac7c4c740c..8baedb97a3b 100644 --- a/robot-server/robot_server/runs/run_models.py +++ b/robot-server/robot_server/runs/run_models.py @@ -21,6 +21,7 @@ CommandNote, ) from opentrons.protocol_engine.types import ( + OnDeckLabwareLocation, RunTimeParameter, PrimitiveRunTimeParamValuesType, CSVRunTimeParamFilesType, @@ -315,10 +316,24 @@ class ActiveNozzleLayout(BaseModel): ) +class PlaceLabwareState(BaseModel): + """Details the labware being placed by the gripper.""" + + labwareId: str = Field(..., description="The ID of the labware to place.") + location: OnDeckLabwareLocation = Field( + ..., description="The location the labware should be in." + ) + shouldPlaceDown: bool = Field( + ..., description="Whether the gripper should place down the labware." + ) + + class RunCurrentState(BaseModel): """Current details about a run.""" - activeNozzleLayouts: Dict[str, ActiveNozzleLayout] = Field(..., description="") + estopEngaged: bool = Field(..., description="") + activeNozzleLayouts: Dict[str, ActiveNozzleLayout] = Field(...) + placeLabwareState: Optional[PlaceLabwareState] = Field(None) class CommandLinkNoMeta(BaseModel): diff --git a/robot-server/robot_server/service/notifications/publisher_notifier.py b/robot-server/robot_server/service/notifications/publisher_notifier.py index 89a53e27b64..4701aa83718 100644 --- a/robot-server/robot_server/service/notifications/publisher_notifier.py +++ b/robot-server/robot_server/service/notifications/publisher_notifier.py @@ -51,6 +51,8 @@ async def _wait_for_event(self) -> None: LOG.exception( f'PublisherNotifier: exception in callback {getattr(callback, "__name__", "")}' ) + except asyncio.exceptions.CancelledError: + LOG.warning("PublisherNotifuer task cancelled.") except BaseException: LOG.exception("PublisherNotifer notify task failed") diff --git a/robot-server/tests/runs/router/conftest.py b/robot-server/tests/runs/router/conftest.py index 0ca0c5cc4f5..957f01c10dd 100644 --- a/robot-server/tests/runs/router/conftest.py +++ b/robot-server/tests/runs/router/conftest.py @@ -1,4 +1,5 @@ """Common test fixtures for runs route tests.""" +from opentrons.hardware_control import HardwareControlAPI, OT3HardwareControlAPI import pytest from decoy import Decoy @@ -80,3 +81,9 @@ def mock_file_provider( ) -> FileProvider: """Return a mock FileProvider.""" return decoy.mock(cls=FileProvider) + + +@pytest.fixture +def mock_hardware_api(decoy: Decoy) -> HardwareControlAPI: + """Get a mock HardwareControlAPI.""" + return decoy.mock(cls=OT3HardwareControlAPI) diff --git a/robot-server/tests/runs/router/test_base_router.py b/robot-server/tests/runs/router/test_base_router.py index 894950343e4..0a0266c0c65 100644 --- a/robot-server/tests/runs/router/test_base_router.py +++ b/robot-server/tests/runs/router/test_base_router.py @@ -1,6 +1,8 @@ """Tests for base /runs routes.""" from typing import Dict +from opentrons.hardware_control import HardwareControlAPI +from opentrons_shared_data.robot.types import RobotTypeEnum import pytest from datetime import datetime from decoy import Decoy @@ -868,44 +870,54 @@ async def test_get_run_commands_errors_defualt_cursor( async def test_get_current_state_success( decoy: Decoy, mock_run_data_manager: RunDataManager, + mock_hardware_api: HardwareControlAPI, mock_nozzle_maps: Dict[str, NozzleMap], ) -> None: - """It should return the active nozzle layout for a specific pipette.""" + """It should return different state from the current run. + + - the active nozzle layout for a specific pipette. + - place plate reader state for absorbance reader. + """ run_id = "test-run-id" decoy.when(mock_run_data_manager.get_nozzle_maps(run_id=run_id)).then_return( mock_nozzle_maps ) + command_pointer = CommandPointer( + command_id="command-id", + command_key="command-key", + created_at=datetime(year=2024, month=4, day=4), + index=101, + ) decoy.when( mock_run_data_manager.get_last_completed_command(run_id=run_id) - ).then_return( - CommandPointer( - command_id="last-command-id", - command_key="last-command-key", - created_at=datetime(year=2024, month=4, day=4), - index=101, - ) + ).then_return(command_pointer) + decoy.when(mock_run_data_manager.get_current_command(run_id=run_id)).then_return( + command_pointer ) result = await get_current_state( runId=run_id, run_data_manager=mock_run_data_manager, + hardware=mock_hardware_api, + robot_type=RobotTypeEnum.FLEX, ) assert result.status_code == 200 assert result.content.data == RunCurrentState.construct( + estopEngaged=False, activeNozzleLayouts={ "mock-pipette-id": ActiveNozzleLayout( startingNozzle="A1", activeNozzles=["A1"], config=NozzleLayoutConfig.FULL, ) - } + }, ) assert result.content.links == CurrentStateLinks( lastCompleted=CommandLinkNoMeta( - href="/runs/test-run-id/commands/last-command-id", - id="last-command-id", + href="/runs/test-run-id/commands/command-id", + id="command-id", ) ) @@ -913,6 +925,7 @@ async def test_get_current_state_success( async def test_get_current_state_run_not_current( decoy: Decoy, mock_run_data_manager: RunDataManager, + mock_hardware_api: HardwareControlAPI, ) -> None: """It should raise RunStopped when the run is not current.""" run_id = "non-current-run-id" @@ -925,6 +938,8 @@ async def test_get_current_state_run_not_current( await get_current_state( runId=run_id, run_data_manager=mock_run_data_manager, + hardware=mock_hardware_api, + robot_type=RobotTypeEnum.FLEX, ) assert exc_info.value.status_code == 409 diff --git a/shared-data/command/schemas/10.json b/shared-data/command/schemas/10.json index be8e870c5bb..93bc2387d63 100644 --- a/shared-data/command/schemas/10.json +++ b/shared-data/command/schemas/10.json @@ -76,7 +76,8 @@ "unsafe/dropTipInPlace": "#/definitions/UnsafeDropTipInPlaceCreate", "unsafe/updatePositionEstimators": "#/definitions/UpdatePositionEstimatorsCreate", "unsafe/engageAxes": "#/definitions/UnsafeEngageAxesCreate", - "unsafe/ungripLabware": "#/definitions/UnsafeUngripLabwareCreate" + "unsafe/ungripLabware": "#/definitions/UnsafeUngripLabwareCreate", + "unsafe/placeLabware": "#/definitions/UnsafePlaceLabwareCreate" } }, "oneOf": [ @@ -295,6 +296,9 @@ }, { "$ref": "#/definitions/UnsafeUngripLabwareCreate" + }, + { + "$ref": "#/definitions/UnsafePlaceLabwareCreate" } ], "definitions": { @@ -4739,6 +4743,67 @@ } }, "required": ["params"] + }, + "UnsafePlaceLabwareParams": { + "title": "UnsafePlaceLabwareParams", + "description": "Payload required for an UnsafePlaceLabware command.", + "type": "object", + "properties": { + "labwareId": { + "title": "Labwareid", + "description": "The id of the labware to place.", + "type": "string" + }, + "location": { + "title": "Location", + "description": "Where to place the labware.", + "anyOf": [ + { + "$ref": "#/definitions/DeckSlotLocation" + }, + { + "$ref": "#/definitions/ModuleLocation" + }, + { + "$ref": "#/definitions/OnLabwareLocation" + }, + { + "$ref": "#/definitions/AddressableAreaLocation" + } + ] + } + }, + "required": ["labwareId", "location"] + }, + "UnsafePlaceLabwareCreate": { + "title": "UnsafePlaceLabwareCreate", + "description": "UnsafePlaceLabware command request model.", + "type": "object", + "properties": { + "commandType": { + "title": "Commandtype", + "default": "unsafe/placeLabware", + "enum": ["unsafe/placeLabware"], + "type": "string" + }, + "params": { + "$ref": "#/definitions/UnsafePlaceLabwareParams" + }, + "intent": { + "description": "The reason the command was added. If not specified or `protocol`, the command will be treated as part of the protocol run itself, and added to the end of the existing command queue.\n\nIf `setup`, the command will be treated as part of run setup. A setup command may only be enqueued if the run has not started.\n\nUse setup commands for activities like pre-run calibration checks and module setup, like pre-heating.", + "allOf": [ + { + "$ref": "#/definitions/CommandIntent" + } + ] + }, + "key": { + "title": "Key", + "description": "A key value, unique in this run, that can be used to track the same logical command across multiple runs of the same protocol. If a value is not provided, one will be generated.", + "type": "string" + } + }, + "required": ["params"] } }, "$id": "opentronsCommandSchemaV10", diff --git a/shared-data/command/types/setup.ts b/shared-data/command/types/setup.ts index 0be40e6de13..13d29c682b4 100644 --- a/shared-data/command/types/setup.ts +++ b/shared-data/command/types/setup.ts @@ -106,6 +106,12 @@ export type LabwareLocation = | { labwareId: string } | { addressableAreaName: AddressableAreaName } +export type OnDeckLabwareLocation = + | { slotName: string } + | { moduleId: string } + | { labwareId: string } + | { addressableAreaName: AddressableAreaName } + export type NonStackedLocation = | 'offDeck' | { slotName: string } diff --git a/shared-data/command/types/unsafe.ts b/shared-data/command/types/unsafe.ts index d24a6f8e054..3875aaa3036 100644 --- a/shared-data/command/types/unsafe.ts +++ b/shared-data/command/types/unsafe.ts @@ -1,4 +1,8 @@ -import type { CommonCommandRunTimeInfo, CommonCommandCreateInfo } from '.' +import type { + CommonCommandRunTimeInfo, + CommonCommandCreateInfo, + OnDeckLabwareLocation, +} from '.' import type { MotorAxes } from '../../js/types' export type UnsafeRunTimeCommand = @@ -7,6 +11,7 @@ export type UnsafeRunTimeCommand = | UnsafeUpdatePositionEstimatorsRunTimeCommand | UnsafeEngageAxesRunTimeCommand | UnsafeUngripLabwareRunTimeCommand + | UnsafePlaceLabwareRunTimeCommand export type UnsafeCreateCommand = | UnsafeBlowoutInPlaceCreateCommand @@ -14,6 +19,7 @@ export type UnsafeCreateCommand = | UnsafeUpdatePositionEstimatorsCreateCommand | UnsafeEngageAxesCreateCommand | UnsafeUngripLabwareCreateCommand + | UnsafePlaceLabwareCreateCommand export interface UnsafeBlowoutInPlaceParams { pipetteId: string @@ -85,3 +91,17 @@ export interface UnsafeUngripLabwareRunTimeCommand UnsafeUngripLabwareCreateCommand { result?: any } +export interface UnsafePlaceLabwareParams { + labwareId: string + location: OnDeckLabwareLocation +} +export interface UnsafePlaceLabwareCreateCommand + extends CommonCommandCreateInfo { + commandType: 'unsafe/placeLabware' + params: UnsafePlaceLabwareParams +} +export interface UnsafePlaceLabwareRunTimeCommand + extends CommonCommandRunTimeInfo, + UnsafePlaceLabwareCreateCommand { + result?: any +}