diff --git a/api-client/src/runs/types.ts b/api-client/src/runs/types.ts index 19127b70bbae..5998259ae50d 100644 --- a/api-client/src/runs/types.ts +++ b/api-client/src/runs/types.ts @@ -54,11 +54,11 @@ export interface LegacyGoodRunData { modules: LoadedModule[] protocolId?: string labwareOffsets?: LabwareOffset[] - runTimeParameters: RunTimeParameter[] } export interface KnownGoodRunData extends LegacyGoodRunData { ok: true + runTimeParameters: RunTimeParameter[] } export interface KnownInvalidRunData extends LegacyGoodRunData { diff --git a/api/src/opentrons/hardware_control/backends/status_bar_state.py b/api/src/opentrons/hardware_control/backends/status_bar_state.py index 616fec2ff3a4..50d349acb328 100644 --- a/api/src/opentrons/hardware_control/backends/status_bar_state.py +++ b/api/src/opentrons/hardware_control/backends/status_bar_state.py @@ -48,6 +48,11 @@ async def _status_bar_software_error(self) -> None: if self._enabled: await self._controller.static_color(status_bar.YELLOW) + async def _status_bar_error_recovery(self) -> None: + self._status_bar_state = StatusBarState.ERROR_RECOVERY + if self._enabled: + await self._controller.pulse_color(status_bar.YELLOW) + async def _status_bar_confirm(self) -> None: # Confirm should revert to IDLE self._status_bar_state = StatusBarState.IDLE @@ -163,6 +168,7 @@ async def set_status_bar_state(self, state: StatusBarState) -> None: StatusBarState.PAUSED: self._status_bar_paused, StatusBarState.HARDWARE_ERROR: self._status_bar_hardware_error, StatusBarState.SOFTWARE_ERROR: self._status_bar_software_error, + StatusBarState.ERROR_RECOVERY: self._status_bar_error_recovery, StatusBarState.CONFIRMATION: self._status_bar_confirm, StatusBarState.RUN_COMPLETED: self._status_bar_run_complete, StatusBarState.UPDATING: self._status_bar_updating, diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index b4999aeb52a6..fc1216e8c5f2 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -2629,7 +2629,7 @@ async def _liquid_probe_pass( force_both_sensors: bool = False, ) -> float: plunger_direction = -1 if probe_settings.aspirate_while_sensing else 1 - await self._backend.liquid_probe( + end_z = await self._backend.liquid_probe( mount, p_travel, probe_settings.mount_speed, @@ -2641,8 +2641,17 @@ async def _liquid_probe_pass( probe=probe, force_both_sensors=force_both_sensors, ) - end_pos = await self.gantry_position(mount, refresh=True) - return end_pos.z + machine_pos = await self._backend.update_position() + machine_pos[Axis.by_mount(mount)] = end_z + deck_end_z = self._deck_from_machine(machine_pos)[Axis.by_mount(mount)] + offset = offset_for_mount( + mount, + top_types.Point(*self._config.left_mount_offset), + top_types.Point(*self._config.right_mount_offset), + top_types.Point(*self._config.gripper_mount_offset), + ) + cp = self.critical_point_for(mount, None) + return deck_end_z + offset.z + cp.z async def liquid_probe( self, diff --git a/api/src/opentrons/hardware_control/types.py b/api/src/opentrons/hardware_control/types.py index 3c14cf9e361c..62265afffcc9 100644 --- a/api/src/opentrons/hardware_control/types.py +++ b/api/src/opentrons/hardware_control/types.py @@ -572,17 +572,23 @@ class PauseType(enum.Enum): class StatusBarState(enum.Enum): - IDLE = 0 - RUNNING = 1 - PAUSED = 2 - HARDWARE_ERROR = 3 - SOFTWARE_ERROR = 4 - CONFIRMATION = 5 - RUN_COMPLETED = 6 - UPDATING = 7 - ACTIVATION = 8 - DISCO = 9 - OFF = 10 + """Semantic status bar states. + + These mostly correspond to cases listed out in the Flex manual. + """ + + IDLE = enum.auto() + RUNNING = enum.auto() + PAUSED = enum.auto() + HARDWARE_ERROR = enum.auto() + SOFTWARE_ERROR = enum.auto() + ERROR_RECOVERY = enum.auto() + CONFIRMATION = enum.auto() + RUN_COMPLETED = enum.auto() + UPDATING = enum.auto() + ACTIVATION = enum.auto() + DISCO = enum.auto() + OFF = enum.auto() def transient(self) -> bool: return self.value in { diff --git a/api/src/opentrons/protocol_api/_parameter_context.py b/api/src/opentrons/protocol_api/_parameter_context.py index 8ca2bdb2c2a4..2e0e0096f44d 100644 --- a/api/src/opentrons/protocol_api/_parameter_context.py +++ b/api/src/opentrons/protocol_api/_parameter_context.py @@ -15,6 +15,7 @@ from opentrons.protocols.parameters.exceptions import ( ParameterDefinitionError, ParameterValueError, + IncompatibleParameterError, ) from opentrons.protocol_engine.types import ( RunTimeParameter, @@ -183,6 +184,14 @@ def add_csv_file( variable_name: The variable name the CSV parameter will be referred to in the run context. description: A description of the parameter as it will show up on the frontend. """ + if any( + isinstance(parameter, csv_parameter_definition.CSVParameterDefinition) + for parameter in self._parameters.values() + ): + raise IncompatibleParameterError( + "Only one CSV File parameter can be defined per protocol." + ) + validation.validate_variable_name_unique(variable_name, set(self._parameters)) parameter = csv_parameter_definition.create_csv_parameter( display_name=display_name, @@ -246,8 +255,11 @@ def initialize_csv_files( file_id = file_path.parent.name file_name = file_path.name + with file_path.open("rb") as fh: + contents = fh.read() + parameter.file_info = FileInfo(id=file_id, name=file_name) - parameter.value = file_path + parameter.value = contents def export_parameters_for_analysis(self) -> List[RunTimeParameter]: """Exports all parameters into a protocol engine models for reporting in analysis. diff --git a/api/src/opentrons/protocols/execution/execute.py b/api/src/opentrons/protocols/execution/execute.py index ff52b3b1dc57..6dd74bcb7784 100644 --- a/api/src/opentrons/protocols/execution/execute.py +++ b/api/src/opentrons/protocols/execution/execute.py @@ -16,7 +16,6 @@ from opentrons.protocols.api_support.types import APIVersion from opentrons.protocols.parameters.csv_parameter_interface import CSVParameter -from opentrons.protocols.parameters.exceptions import RuntimeParameterRequired MODULE_LOG = logging.getLogger(__name__) @@ -51,13 +50,8 @@ def run_protocol( finally: if protocol.api_level >= APIVersion(2, 18): for parameter in context.params.get_all().values(): - if isinstance(parameter, CSVParameter): - try: - parameter.file.close() - # This will be raised if the csv file wasn't set, which means it was never opened, - # so we can safely skip this. - except RuntimeParameterRequired: - pass + if isinstance(parameter, CSVParameter) and parameter.file_opened: + parameter.file.close() else: if protocol.contents["schemaVersion"] == 3: ins = execute_json_v3.load_pipettes_from_json(context, protocol.contents) diff --git a/api/src/opentrons/protocols/parameters/csv_parameter_definition.py b/api/src/opentrons/protocols/parameters/csv_parameter_definition.py index d23b7d70f0b1..c48356d01d4e 100644 --- a/api/src/opentrons/protocols/parameters/csv_parameter_definition.py +++ b/api/src/opentrons/protocols/parameters/csv_parameter_definition.py @@ -1,5 +1,4 @@ """CSV Parameter definition and associated classes/functions.""" -from pathlib import Path from typing import Optional from opentrons.protocol_engine.types import ( @@ -14,7 +13,7 @@ from .csv_parameter_interface import CSVParameter -class CSVParameterDefinition(AbstractParameterDefinition[Optional[Path]]): +class CSVParameterDefinition(AbstractParameterDefinition[Optional[bytes]]): """The definition for a user defined CSV file parameter.""" def __init__( @@ -30,7 +29,7 @@ def __init__( self._display_name = validation.ensure_display_name(display_name) self._variable_name = validation.ensure_variable_name(variable_name) self._description = validation.ensure_description(description) - self._value: Optional[Path] = None + self._value: Optional[bytes] = None self._file_info: Optional[FileInfo] = None @property @@ -39,13 +38,13 @@ def variable_name(self) -> str: return self._variable_name @property - def value(self) -> Optional[Path]: + def value(self) -> Optional[bytes]: """The current set file for the CSV parameter. Defaults to None on definition creation.""" return self._value @value.setter - def value(self, new_path: Path) -> None: - self._value = new_path + def value(self, contents: bytes) -> None: + self._value = contents @property def file_info(self) -> Optional[FileInfo]: @@ -56,7 +55,7 @@ def file_info(self, file_info: FileInfo) -> None: self._file_info = file_info def as_csv_parameter_interface(self, api_version: APIVersion) -> CSVParameter: - return CSVParameter(csv_path=self._value, api_version=api_version) + return CSVParameter(contents=self._value, api_version=api_version) def as_protocol_engine_type(self) -> RunTimeParameter: """Returns CSV parameter as a Protocol Engine type to send to client.""" diff --git a/api/src/opentrons/protocols/parameters/csv_parameter_interface.py b/api/src/opentrons/protocols/parameters/csv_parameter_interface.py index 40a099558d41..20627322547a 100644 --- a/api/src/opentrons/protocols/parameters/csv_parameter_interface.py +++ b/api/src/opentrons/protocols/parameters/csv_parameter_interface.py @@ -1,35 +1,46 @@ import csv -from pathlib import Path +from tempfile import NamedTemporaryFile from typing import Optional, TextIO, Any, List from opentrons.protocols.api_support.types import APIVersion -from . import parameter_file_reader -from .exceptions import ParameterValueError +from .exceptions import ParameterValueError, RuntimeParameterRequired # TODO(jbl 2024-08-02) This is a public facing class and as such should be moved to the protocol_api folder class CSVParameter: - def __init__(self, csv_path: Optional[Path], api_version: APIVersion) -> None: - self._path = csv_path - self._file: Optional[TextIO] = None - self._contents: Optional[str] = None + def __init__(self, contents: Optional[bytes], api_version: APIVersion) -> None: + self._contents = contents self._api_version = api_version + self._file: Optional[TextIO] = None @property def file(self) -> TextIO: """Returns the file handler for the CSV file.""" if self._file is None: - self._file = parameter_file_reader.open_file_path(self._path) + text = self.contents + temporary_file = NamedTemporaryFile("r+") + temporary_file.write(text) + temporary_file.flush() + + # Open a new file handler for the temporary file with read-only permissions and close the other + self._file = open(temporary_file.name, "r") + temporary_file.close() return self._file + @property + def file_opened(self) -> bool: + """Return if a file handler has been opened for the CSV parameter.""" + return self._file is not None + @property def contents(self) -> str: """Returns the full contents of the CSV file as a single string.""" if self._contents is None: - self.file.seek(0) - self._contents = self.file.read() - return self._contents + raise RuntimeParameterRequired( + "CSV parameter needs to be set to a file for full analysis or run." + ) + return self._contents.decode("utf-8") def parse_as_csv( self, detect_dialect: bool = True, **kwargs: Any @@ -42,23 +53,20 @@ def parse_as_csv( rows: List[List[str]] = [] if detect_dialect: try: - self.file.seek(0) - dialect = csv.Sniffer().sniff(self.file.read(1024)) - self.file.seek(0) - reader = csv.reader(self.file, dialect, **kwargs) + dialect = csv.Sniffer().sniff(self.contents[:1024]) + reader = csv.reader(self.contents.split("\n"), dialect, **kwargs) except (UnicodeDecodeError, csv.Error): raise ParameterValueError( - "Cannot parse dialect or contents from provided CSV file." + "Cannot parse dialect or contents from provided CSV contents." ) else: try: - reader = csv.reader(self.file, **kwargs) + reader = csv.reader(self.contents.split("\n"), **kwargs) except (UnicodeDecodeError, csv.Error): - raise ParameterValueError("Cannot parse provided CSV file.") + raise ParameterValueError("Cannot parse provided CSV contents.") try: for row in reader: rows.append(row) except (UnicodeDecodeError, csv.Error): - raise ParameterValueError("Cannot parse provided CSV file.") - self.file.seek(0) + raise ParameterValueError("Cannot parse provided CSV contents.") return rows diff --git a/api/src/opentrons/protocols/parameters/exceptions.py b/api/src/opentrons/protocols/parameters/exceptions.py index 9f7bcee009cb..7f1b8a933cba 100644 --- a/api/src/opentrons/protocols/parameters/exceptions.py +++ b/api/src/opentrons/protocols/parameters/exceptions.py @@ -28,3 +28,7 @@ class ParameterDefinitionError(ValueError): class ParameterNameError(ValueError): """An error raised when a parameter name or description is not valid.""" + + +class IncompatibleParameterError(ValueError): + """An error raised when a parameter conflicts with another parameter.""" diff --git a/api/src/opentrons/protocols/parameters/parameter_file_reader.py b/api/src/opentrons/protocols/parameters/parameter_file_reader.py deleted file mode 100644 index 9a39c2fa0dcf..000000000000 --- a/api/src/opentrons/protocols/parameters/parameter_file_reader.py +++ /dev/null @@ -1,26 +0,0 @@ -from pathlib import Path -from tempfile import NamedTemporaryFile -from typing import Optional, TextIO - -from .exceptions import RuntimeParameterRequired - - -def open_file_path(file_path: Optional[Path]) -> TextIO: - """Ensure file path is set and open up the file in a safe read-only temporary file.""" - if file_path is None: - raise RuntimeParameterRequired( - "CSV parameter needs to be set to a file for full analysis or run." - ) - # Read the contents of the actual file - with file_path.open() as fh: - contents = fh.read() - - # Open a temporary file with write permissions and write contents to that - temporary_file = NamedTemporaryFile("r+") - temporary_file.write(contents) - temporary_file.flush() - - # Open a new file handler for the temporary file with read-only permissions and close the other - read_only_temp_file = open(temporary_file.name, "r") - temporary_file.close() - return read_only_temp_file diff --git a/api/src/opentrons/protocols/parameters/types.py b/api/src/opentrons/protocols/parameters/types.py index 631d686b7e75..0e248d8e1c09 100644 --- a/api/src/opentrons/protocols/parameters/types.py +++ b/api/src/opentrons/protocols/parameters/types.py @@ -1,11 +1,10 @@ -from pathlib import Path from typing import TypeVar, Union, TypedDict from .csv_parameter_interface import CSVParameter PrimitiveAllowedTypes = Union[str, int, float, bool] -AllAllowedTypes = Union[str, int, float, bool, Path, None] +AllAllowedTypes = Union[str, int, float, bool, bytes, None] UserFacingTypes = Union[str, int, float, bool, CSVParameter] ParamType = TypeVar("ParamType", bound=AllAllowedTypes) diff --git a/api/src/opentrons/protocols/parameters/validation.py b/api/src/opentrons/protocols/parameters/validation.py index 68a1d93ec97a..d111e1cc8a82 100644 --- a/api/src/opentrons/protocols/parameters/validation.py +++ b/api/src/opentrons/protocols/parameters/validation.py @@ -237,7 +237,7 @@ def validate_type(value: ParamType, parameter_type: type) -> None: """Validate parameter value is the correct type.""" if not isinstance(value, parameter_type): raise ParameterValueError( - f"Parameter value {value} has type '{type(value).__name__}'," + f"Parameter value {value!r} has type '{type(value).__name__}'," f" but must be of type '{parameter_type.__name__}'." ) @@ -252,7 +252,7 @@ def validate_options( """Validate default values and all possible constraints for a valid parameter definition.""" if not isinstance(default, parameter_type): raise ParameterValueError( - f"Parameter default {default} has type '{type(default).__name__}'," + f"Parameter default {default!r} has type '{type(default).__name__}'," f" but must be of type '{parameter_type.__name__}'." ) diff --git a/api/tests/opentrons/hardware_control/test_ot3_api.py b/api/tests/opentrons/hardware_control/test_ot3_api.py index 20a8f090374d..bb1352d31571 100644 --- a/api/tests/opentrons/hardware_control/test_ot3_api.py +++ b/api/tests/opentrons/hardware_control/test_ot3_api.py @@ -815,15 +815,9 @@ async def test_liquid_probe( with patch.object( hardware_backend, "liquid_probe", AsyncMock(spec=hardware_backend.liquid_probe) ) as mock_liquid_probe: - return_dict = { - head_node: 140, - NodeId.gantry_x: 0, - NodeId.gantry_y: 0, - pipette_node: 0, - } # make sure aspirate while sensing reverses direction - mock_liquid_probe.return_value = return_dict + mock_liquid_probe.return_value = 140 fake_settings_aspirate = LiquidProbeSettings( mount_speed=5, plunger_speed=20, @@ -849,8 +843,6 @@ async def test_liquid_probe( force_both_sensors=False, ) - return_dict[head_node], return_dict[pipette_node] = 142, 142 - mock_liquid_probe.return_value = return_dict await ot3_hardware.liquid_probe( mount, fake_max_z_dist, fake_liquid_settings ) # should raise no exceptions @@ -903,7 +895,7 @@ async def test_liquid_probe_plunger_moves( PipetteLiquidNotFoundError, PipetteLiquidNotFoundError, PipetteLiquidNotFoundError, - None, + 140, ] fake_max_z_dist = 75.0 @@ -1056,16 +1048,10 @@ async def test_multi_liquid_probe( with patch.object( hardware_backend, "liquid_probe", AsyncMock(spec=hardware_backend.liquid_probe) ) as mock_liquid_probe: - return_dict = { - NodeId.head_l: 140, - NodeId.gantry_x: 0, - NodeId.gantry_y: 0, - NodeId.pipette_left: 0, - } side_effects = [ PipetteLiquidNotFoundError(), PipetteLiquidNotFoundError(), - return_dict, + 140, ] # make sure aspirate while sensing reverses direction @@ -1103,9 +1089,6 @@ async def test_multi_liquid_probe( ) assert mock_liquid_probe.call_count == 3 - return_dict[NodeId.head_l], return_dict[NodeId.pipette_left] = 142, 142 - mock_liquid_probe.return_value = return_dict - async def test_liquid_not_found( ot3_hardware: ThreadManager[OT3API], diff --git a/api/tests/opentrons/protocol_api/test_parameter_context.py b/api/tests/opentrons/protocol_api/test_parameter_context.py index e2004a4b9b76..f59521c78a9c 100644 --- a/api/tests/opentrons/protocol_api/test_parameter_context.py +++ b/api/tests/opentrons/protocol_api/test_parameter_context.py @@ -13,7 +13,10 @@ parameter_definition as mock_parameter_definition, validation as mock_validation, ) -from opentrons.protocols.parameters.exceptions import ParameterDefinitionError +from opentrons.protocols.parameters.exceptions import ( + ParameterDefinitionError, + IncompatibleParameterError, +) from opentrons.protocol_engine.types import BooleanParameter from opentrons.protocol_api._parameter_context import ParameterContext @@ -196,7 +199,7 @@ def test_add_string(decoy: Decoy, subject: ParameterContext) -> None: def test_add_csv(decoy: Decoy, subject: ParameterContext) -> None: """It should create and add a CSV parameter definition.""" subject._parameters["other_param"] = decoy.mock( - cls=mock_csv_parameter_definition.CSVParameterDefinition + cls=mock_parameter_definition.ParameterDefinition ) param_def = decoy.mock(cls=mock_csv_parameter_definition.CSVParameterDefinition) decoy.when(param_def.variable_name).then_return("my potentially cool variable") @@ -220,6 +223,22 @@ def test_add_csv(decoy: Decoy, subject: ParameterContext) -> None: ) +def test_add_csv_raises_for_multiple_csvs( + decoy: Decoy, subject: ParameterContext +) -> None: + """It should raise with a IncompatibleParameterError if there's already a CSV parameter..""" + subject._parameters["other_param"] = decoy.mock( + cls=mock_csv_parameter_definition.CSVParameterDefinition + ) + + with pytest.raises(IncompatibleParameterError): + subject.add_csv_file( + display_name="jkl", + variable_name="qwerty", + description="fee foo fum", + ) + + def test_set_parameters(decoy: Decoy, subject: ParameterContext) -> None: """It should set the parameter values.""" param_def = decoy.mock(cls=mock_parameter_definition.ParameterDefinition) diff --git a/api/tests/opentrons/protocols/parameters/test_csv_parameter_definition.py b/api/tests/opentrons/protocols/parameters/test_csv_parameter_definition.py index 04dee0512b5f..ac8da823edb6 100644 --- a/api/tests/opentrons/protocols/parameters/test_csv_parameter_definition.py +++ b/api/tests/opentrons/protocols/parameters/test_csv_parameter_definition.py @@ -1,6 +1,5 @@ """Tests for the CSV Parameter Definitions.""" import inspect -from pathlib import Path import pytest from decoy import Decoy @@ -62,9 +61,9 @@ def test_create_csv_parameter(decoy: Decoy) -> None: def test_set_csv_value( decoy: Decoy, csv_parameter_subject: CSVParameterDefinition ) -> None: - """It should set the CSV parameter value to a path.""" - csv_parameter_subject.value = Path("123") - assert csv_parameter_subject.value == Path("123") + """It should set the CSV parameter value to a byte string.""" + csv_parameter_subject.value = b"123" + assert csv_parameter_subject.value == b"123" def test_csv_parameter_as_protocol_engine_type( @@ -79,13 +78,13 @@ def test_csv_parameter_as_protocol_engine_type( file=None, ) - csv_parameter_subject.file_info = FileInfo(id="123abc", name="") + csv_parameter_subject.file_info = FileInfo(id="123", name="abc") result = csv_parameter_subject.as_protocol_engine_type() assert result == CSVParameter( displayName="My cool CSV", variableName="my_cool_csv", description="Comma Separated Value", - file=FileInfo(id="123abc", name=""), + file=FileInfo(id="123", name="abc"), ) @@ -98,6 +97,6 @@ def test_csv_parameter_as_csv_parameter_interface( with pytest.raises(RuntimeParameterRequired): result.file - csv_parameter_subject.value = Path("abc") + csv_parameter_subject.value = b"abc" result = csv_parameter_subject.as_csv_parameter_interface(api_version) - assert result._path == Path("abc") + assert result.contents == "abc" diff --git a/api/tests/opentrons/protocols/parameters/test_csv_parameter_interface.py b/api/tests/opentrons/protocols/parameters/test_csv_parameter_interface.py index 4cd9e649b63f..81bffd0028ec 100644 --- a/api/tests/opentrons/protocols/parameters/test_csv_parameter_interface.py +++ b/api/tests/opentrons/protocols/parameters/test_csv_parameter_interface.py @@ -1,26 +1,13 @@ import pytest -import inspect +import platform from decoy import Decoy from pytest_lazyfixture import lazy_fixture # type: ignore[import-untyped] -import tempfile -from pathlib import Path -from typing import TextIO, Generator - from opentrons.protocols.api_support.types import APIVersion from opentrons.protocols.api_support.definitions import MAX_SUPPORTED_VERSION -from opentrons.protocols.parameters import ( - parameter_file_reader as mock_param_file_reader, -) from opentrons.protocols.parameters.csv_parameter_interface import CSVParameter -@pytest.fixture(autouse=True) -def _patch_parameter_file_reader(decoy: Decoy, monkeypatch: pytest.MonkeyPatch) -> None: - for name, func in inspect.getmembers(mock_param_file_reader, inspect.isfunction): - monkeypatch.setattr(mock_param_file_reader, name, decoy.mock(func=func)) - - @pytest.fixture def api_version() -> APIVersion: """The API version under test.""" @@ -28,70 +15,54 @@ def api_version() -> APIVersion: @pytest.fixture -def csv_file_basic() -> Generator[TextIO, None, None]: +def csv_file_basic() -> bytes: """A basic CSV file with quotes around strings.""" - with tempfile.TemporaryFile("r+") as temp_file: - contents = '"x","y","z"\n"a",1,2\n"b",3,4\n"c",5,6' - temp_file.write(contents) - temp_file.seek(0) - yield temp_file + return b'"x","y","z"\n"a",1,2\n"b",3,4\n"c",5,6' @pytest.fixture -def csv_file_no_quotes() -> Generator[TextIO, None, None]: +def csv_file_no_quotes() -> bytes: """A basic CSV file with no quotes around strings.""" - with tempfile.TemporaryFile("r+") as temp_file: - contents = "x,y,z\na,1,2\nb,3,4\nc,5,6" - temp_file.write(contents) - temp_file.seek(0) - yield temp_file + return b"x,y,z\na,1,2\nb,3,4\nc,5,6" @pytest.fixture -def csv_file_preceding_spaces() -> Generator[TextIO, None, None]: +def csv_file_preceding_spaces() -> bytes: """A basic CSV file with quotes around strings and spaces preceding non-initial columns.""" - with tempfile.TemporaryFile("r+") as temp_file: - contents = '"x", "y", "z"\n"a", 1, 2\n"b", 3, 4\n"c", 5, 6' - temp_file.write(contents) - temp_file.seek(0) - yield temp_file + return b'"x", "y", "z"\n"a", 1, 2\n"b", 3, 4\n"c", 5, 6' @pytest.fixture -def csv_file_mixed_quotes() -> Generator[TextIO, None, None]: +def csv_file_mixed_quotes() -> bytes: """A basic CSV file with both string quotes and escaped quotes.""" - with tempfile.TemporaryFile("r+") as temp_file: - contents = 'head,er\n"a,b,c",def\n"""ghi""","jkl"' - temp_file.write(contents) - temp_file.seek(0) - yield temp_file + return b'head,er\n"a,b,c",def\n"""ghi""","jkl"' @pytest.fixture -def csv_file_different_delimiter() -> Generator[TextIO, None, None]: +def csv_file_different_delimiter() -> bytes: """A basic CSV file with a non-comma delimiter.""" - with tempfile.TemporaryFile("r+") as temp_file: - contents = "x:y:z\na,:1,:2\nb,:3,:4\nc,:5,:6" - temp_file.write(contents) - temp_file.seek(0) - yield temp_file - - -@pytest.fixture -def subject(api_version: APIVersion) -> CSVParameter: - """Return a CSVParameter interface subject.""" - return CSVParameter(csv_path=Path("abc"), api_version=api_version) + return b"x:y:z\na,:1,:2\nb,:3,:4\nc,:5,:6" def test_csv_parameter( - decoy: Decoy, csv_file_basic: TextIO, subject: CSVParameter + decoy: Decoy, api_version: APIVersion, csv_file_basic: bytes ) -> None: """It should load the CSV parameter and provide access to the file, contents, and rows.""" - decoy.when(mock_param_file_reader.open_file_path(Path("abc"))).then_return( - csv_file_basic - ) - assert subject.file is csv_file_basic + subject = CSVParameter(csv_file_basic, api_version) + + # On Windows, you can't open a NamedTemporaryFile a second time, which breaks the code under test. + # Because of the way CSV analysis works this code will only ever be run on the actual OT-2/Flex hardware, + # so we skip testing and instead assert that we get a PermissionError on Windows (to ensure this + # test gets fixed in case we ever refactor the file opening.) + if platform.system() != "Windows": + assert subject.file.readable() + assert not subject.file.writable() + assert subject.file.read() == '"x","y","z"\n"a",1,2\n"b",3,4\n"c",5,6' + else: + with pytest.raises(PermissionError): + subject.file assert subject.contents == '"x","y","z"\n"a",1,2\n"b",3,4\n"c",5,6' + assert subject.parse_as_csv()[0] == ["x", "y", "z"] @pytest.mark.parametrize( @@ -103,22 +74,26 @@ def test_csv_parameter( ], ) def test_csv_parameter_rows( - decoy: Decoy, csv_file: TextIO, subject: CSVParameter + decoy: Decoy, + api_version: APIVersion, + csv_file: bytes, ) -> None: """It should load the rows as all strings even with no quotes or leading spaces.""" - decoy.when(mock_param_file_reader.open_file_path(Path("abc"))).then_return(csv_file) + subject = CSVParameter(csv_file, api_version) + assert len(subject.parse_as_csv()) == 4 assert subject.parse_as_csv()[0] == ["x", "y", "z"] assert subject.parse_as_csv()[1] == ["a", "1", "2"] def test_csv_parameter_mixed_quotes( - decoy: Decoy, csv_file_mixed_quotes: TextIO, subject: CSVParameter + decoy: Decoy, + api_version: APIVersion, + csv_file_mixed_quotes: bytes, ) -> None: """It should load the rows with no quotes, quotes and escaped quotes with double quotes.""" - decoy.when(mock_param_file_reader.open_file_path(Path("abc"))).then_return( - csv_file_mixed_quotes - ) + subject = CSVParameter(csv_file_mixed_quotes, api_version) + assert len(subject.parse_as_csv()) == 3 assert subject.parse_as_csv()[0] == ["head", "er"] assert subject.parse_as_csv()[1] == ["a,b,c", "def"] @@ -126,25 +101,27 @@ def test_csv_parameter_mixed_quotes( def test_csv_parameter_additional_kwargs( - decoy: Decoy, csv_file_different_delimiter: TextIO, subject: CSVParameter + decoy: Decoy, + api_version: APIVersion, + csv_file_different_delimiter: bytes, ) -> None: """It should load the rows with a different delimiter.""" - decoy.when(mock_param_file_reader.open_file_path(Path("abc"))).then_return( - csv_file_different_delimiter - ) + subject = CSVParameter(csv_file_different_delimiter, api_version) rows = subject.parse_as_csv(delimiter=":") + assert len(rows) == 4 assert rows[0] == ["x", "y", "z"] assert rows[1] == ["a,", "1,", "2"] def test_csv_parameter_dont_detect_dialect( - decoy: Decoy, csv_file_preceding_spaces: TextIO, subject: CSVParameter + decoy: Decoy, + api_version: APIVersion, + csv_file_preceding_spaces: bytes, ) -> None: """It should load the rows without trying to detect the dialect.""" - decoy.when(mock_param_file_reader.open_file_path(Path("abc"))).then_return( - csv_file_preceding_spaces - ) + subject = CSVParameter(csv_file_preceding_spaces, api_version) rows = subject.parse_as_csv(detect_dialect=False) + assert rows[0] == ["x", ' "y"', ' "z"'] assert rows[1] == ["a", " 1", " 2"] diff --git a/api/tests/opentrons/protocols/parameters/test_parameter_file_reader.py b/api/tests/opentrons/protocols/parameters/test_parameter_file_reader.py deleted file mode 100644 index d469c827d086..000000000000 --- a/api/tests/opentrons/protocols/parameters/test_parameter_file_reader.py +++ /dev/null @@ -1,34 +0,0 @@ -import pytest -import platform - -from opentrons_shared_data import get_shared_data_root, load_shared_data - -from opentrons.protocols.parameters.exceptions import RuntimeParameterRequired -from opentrons.protocols.parameters import parameter_file_reader as subject - - -def test_open_file_path() -> None: - """It should open a temporary file handler given a path.""" - contents = load_shared_data("protocol/fixtures/7/simpleV7.json") - shared_data_path = get_shared_data_root() / "protocol/fixtures/7/simpleV7.json" - - # On Windows, you can't open a NamedTemporaryFile a second time, which breaks the code under test. - # Because of the way CSV analysis works this code will only ever be run on the actual OT-2/Flex hardware, - # so we skip testing and instead assert that we get a PermissionError on Windows (to ensure this - # test gets fixed in case we ever refactor the file opening.) - if platform.system() != "Windows": - result = subject.open_file_path(shared_data_path) - - assert result.readable() - assert not result.writable() - assert result.read() == contents.decode("utf-8") - result.close() - else: - with pytest.raises(PermissionError): - subject.open_file_path(shared_data_path) - - -def test_open_file_path_raises() -> None: - """It should raise of no file path is provided.""" - with pytest.raises(RuntimeParameterRequired): - subject.open_file_path(None) diff --git a/app-shell-odd/src/system-update/index.ts b/app-shell-odd/src/system-update/index.ts index 2d0b53e35e62..7d8e62fb8acf 100644 --- a/app-shell-odd/src/system-update/index.ts +++ b/app-shell-odd/src/system-update/index.ts @@ -58,6 +58,11 @@ export function registerRobotSystemUpdate(dispatch: Dispatch): Dispatch { switch (action.type) { case UI_INITIALIZED: case 'shell:CHECK_UPDATE': + // short circuit early if we're already downloading the latest system files + if (isGettingLatestSystemFiles) { + log.info(`system update download already in progress`) + return + } updateLatestVersion() .then(() => { if (isUpdateAvailable() && !isGettingLatestSystemFiles) { diff --git a/app/src/assets/localization/en/drop_tip_wizard.json b/app/src/assets/localization/en/drop_tip_wizard.json index fe673268a504..e622fbce4cf8 100644 --- a/app/src/assets/localization/en/drop_tip_wizard.json +++ b/app/src/assets/localization/en/drop_tip_wizard.json @@ -13,7 +13,7 @@ "drop_tip_failed": "The drop tip could not be completed. Contact customer support for assistance.", "drop_tips": "drop tips", "error_dropping_tips": "Error dropping tips", - "exit_screen_title": "Exit before completing drop tip?", + "exit_and_home_pipette": "Exit and home pipette", "getting_ready": "Getting ready…", "go_back": "go back", "jog_too_far": "Jog too far?", @@ -34,6 +34,7 @@ "select_drop_tip_slot": "You can return tips to a tip rack or dispose of them.
Select the slot where you want to drop the tips on the deck map to the right. Once confirmed, the gantry will move to the chosen slot.", "select_drop_tip_slot_odd": "You can return tips to a tip rack or dispose of them.
After the gantry moves to the chosen slot, use the jog controls to move the pipette to the exact position for dropping tips.", "skip": "Skip", + "skip_and_home_pipette": "Skip and home pipette", "stand_back_blowing_out": "Stand back, robot is blowing out liquid", "stand_back_dropping_tips": "Stand back, robot is dropping tips", "stand_back_robot_in_motion": "Stand back, robot is in motion", diff --git a/app/src/assets/localization/en/error_recovery.json b/app/src/assets/localization/en/error_recovery.json index f0ee3b0f4e00..39671bbffd9c 100644 --- a/app/src/assets/localization/en/error_recovery.json +++ b/app/src/assets/localization/en/error_recovery.json @@ -1,8 +1,8 @@ { + "another_app_controlling_robot": "The robot’s touchscreen or another computer with the app is currently controlling this robot.", "are_you_sure_you_want_to_cancel": "Are you sure you want to cancel?", "at_step": "At step", "back_to_menu": "Back to menu", - "another_app_controlling_robot": "The robot’s touchscreen or another computer with the app is currently controlling this robot.", "before_you_begin": "Before you begin", "begin_removal": "Begin removal", "blowout_failed": "Blowout failed", @@ -64,7 +64,7 @@ "robot_will_retry_with_tips": "The robot will retry the failed step with new tips.", "run_paused": "Run paused", "select_tip_pickup_location": "Select tip pick-up location", - "skip": "Skip", + "skip_and_home_pipette": "Skip and home pipette", "skip_to_next_step": "Skip to next step", "skip_to_next_step_new_tips": "Skip to next step with new tips", "skip_to_next_step_same_tips": "Skip to next step with same tips", diff --git a/app/src/assets/localization/en/protocol_setup.json b/app/src/assets/localization/en/protocol_setup.json index 2a21ab64da18..5fee1c100906 100644 --- a/app/src/assets/localization/en/protocol_setup.json +++ b/app/src/assets/localization/en/protocol_setup.json @@ -51,6 +51,7 @@ "confirm_heater_shaker_module_modal_title": "Confirm Heater-Shaker Module is attached", "confirm_offsets": "Confirm offsets", "confirm_liquids": "Confirm liquids", + "confirm_locations_and_volumes": "Confirm locations and volumes", "confirm_placements": "Confirm placements", "confirm_selection": "Confirm selection", "confirm_values": "Confirm values", @@ -73,6 +74,7 @@ "deck_conflict_info": "Update the deck configuration by removing the {{currentFixture}} in location {{cutout}}. Either remove the fixture from the deck configuration or update the protocol.", "deck_conflict": "Deck location conflict", "deck_hardware": "Deck hardware", + "deck_hardware_ready": "Deck hardware ready", "deck_map": "Deck Map", "default_values": "Default values", "example": "Example", @@ -263,6 +265,7 @@ "run_disabled_modules_and_calibration_not_complete": "Make sure robot calibration is complete and all modules are connected before proceeding to run", "run_disabled_modules_not_connected": "Make sure all modules are connected before proceeding to run", "run_labware_position_check": "run labware position check", + "run_labware_position_check_to_get_offsets": "Run Labware Position Check to get your labware offset data.", "run_never_started": "Run was never started", "run": "Run", "secure_labware_instructions": "Secure labware instructions", diff --git a/app/src/assets/localization/en/run_details.json b/app/src/assets/localization/en/run_details.json index ab9a8114f5d0..9da392eeddf7 100644 --- a/app/src/assets/localization/en/run_details.json +++ b/app/src/assets/localization/en/run_details.json @@ -42,6 +42,9 @@ "end_step_time": "End", "error_info": "Error {{errorCode}}: {{errorType}}", "error_type": "Error: {{errorType}}", + "error_details": "Error details", + "no_of_error": "{{count}} error", + "no_of_errors": "{{count}} errors", "failed_step": "Failed step", "final_step": "Final Step", "ignore_stored_data": "Ignore stored data", @@ -98,6 +101,8 @@ "run_complete": "Run completed", "run_complete_splash": "Run completed", "run_completed": "Run completed.", + "run_completed_with_errors": "Run completed with errors.", + "run_completed_with_warnings": "Run completed with warnings.", "run_cta_disabled": "Complete required steps on Protocol tab before starting the run", "run_failed": "Run failed.", "run_failed_modal_body": "Error occurred when protocol was {{command}}", @@ -144,5 +149,8 @@ "view_analysis_error_details": "View error details", "view_current_step": "View current step", "view_error": "View error", - "view_error_details": "View error details" + "view_error_details": "View error details", + "warning_details": "Warning details", + "no_of_warning": "{{count}} warning", + "no_of_warnings": "{{count}} warnings" } diff --git a/app/src/molecules/Command/hooks/useCommandTextString/utils/getPipettingCommandText.ts b/app/src/molecules/Command/hooks/useCommandTextString/utils/getPipettingCommandText.ts index 00c9eba08dd6..02a957d5ae43 100644 --- a/app/src/molecules/Command/hooks/useCommandTextString/utils/getPipettingCommandText.ts +++ b/app/src/molecules/Command/hooks/useCommandTextString/utils/getPipettingCommandText.ts @@ -48,6 +48,7 @@ export const getPipettingCommandText = ({ robotType ) : '' + switch (command?.commandType) { case 'aspirate': { const { volume, flowRate } = command.params diff --git a/app/src/molecules/InterventionModal/CategorizedStepContent.tsx b/app/src/molecules/InterventionModal/CategorizedStepContent.tsx index 2b18ca83e87b..a9ec14b68760 100644 --- a/app/src/molecules/InterventionModal/CategorizedStepContent.tsx +++ b/app/src/molecules/InterventionModal/CategorizedStepContent.tsx @@ -111,35 +111,42 @@ export function CategorizedStepContent( justifyContent={JUSTIFY_FLEX_START} gap={SPACING.spacing4} > - - {props.bottomCategoryHeadline} - - {props.bottomCategoryCommands.map((command, idx) => ( - 0 ? HIDE_ON_TOUCHSCREEN_STYLE : undefined} + {props.bottomCategoryCommands[0] != null ? ( + - - - - ))} + {props.bottomCategoryHeadline} + + ) : null} + {props.bottomCategoryCommands.map((command, idx) => { + return command != null ? ( + 0 ? HIDE_ON_TOUCHSCREEN_STYLE : undefined} + > + + + + ) : null + })} ) diff --git a/app/src/molecules/OddModal/OddModalHeader.tsx b/app/src/molecules/OddModal/OddModalHeader.tsx index 38b798d95937..cd79d1a81f5f 100644 --- a/app/src/molecules/OddModal/OddModalHeader.tsx +++ b/app/src/molecules/OddModal/OddModalHeader.tsx @@ -43,7 +43,6 @@ export function OddModalHeader(props: OddModalHeaderBaseProps): JSX.Element { color={iconColor} size="2rem" alignSelf={ALIGN_CENTER} - marginRight={SPACING.spacing16} /> ) : null} parameter.type === 'csv_file' - ).length + const countRunDataFiles = + 'runTimeParameters' in run + ? run?.runTimeParameters.filter( + parameter => parameter.type === 'csv_file' + ).length + : 0 const runStatus = run.status const runDisplayName = formatTimestamp(run.createdAt) let duration = EMPTY_TIMESTAMP diff --git a/app/src/organisms/Devices/HistoricalProtocolRunDrawer.tsx b/app/src/organisms/Devices/HistoricalProtocolRunDrawer.tsx index cc74c46eaaea..8ccecf1746c7 100644 --- a/app/src/organisms/Devices/HistoricalProtocolRunDrawer.tsx +++ b/app/src/organisms/Devices/HistoricalProtocolRunDrawer.tsx @@ -46,15 +46,17 @@ export function HistoricalProtocolRunDrawer( const allLabwareOffsets = run.labwareOffsets?.sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ) - const runDataFileIds = run.runTimeParameters.reduce( - (acc, parameter) => { - if (parameter.type === 'csv_file') { - return parameter.file?.id != null ? [...acc, parameter.file?.id] : acc - } - return acc - }, - [] - ) + const runDataFileIds = + 'runTimeParameters' in run + ? run.runTimeParameters.reduce((acc, parameter) => { + if (parameter.type === 'csv_file') { + return parameter.file?.id != null + ? [...acc, parameter.file?.id] + : acc + } + return acc + }, []) + : [] const uniqueLabwareOffsets = allLabwareOffsets?.filter( (offset, index, array) => { return ( diff --git a/app/src/organisms/Devices/ProtocolRun/EmptySetupStep.tsx b/app/src/organisms/Devices/ProtocolRun/EmptySetupStep.tsx index 8a4d5a9c2bc2..eeca6dcf81a9 100644 --- a/app/src/organisms/Devices/ProtocolRun/EmptySetupStep.tsx +++ b/app/src/organisms/Devices/ProtocolRun/EmptySetupStep.tsx @@ -6,24 +6,30 @@ import { SPACING, LegacyStyledText, TYPOGRAPHY, + DIRECTION_ROW, + JUSTIFY_SPACE_BETWEEN, } from '@opentrons/components' interface EmptySetupStepProps { title: React.ReactNode description: string + rightElement?: React.ReactNode } export function EmptySetupStep(props: EmptySetupStepProps): JSX.Element { - const { title, description } = props + const { title, description, rightElement } = props return ( - - - {title} - - {description} + + + + {title} + + {description} + + {rightElement} ) } diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolDropTipBanner.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolDropTipBanner.tsx deleted file mode 100644 index 65458700d955..000000000000 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolDropTipBanner.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import * as React from 'react' -import { useTranslation } from 'react-i18next' - -import { - ALIGN_START, - Btn, - DIRECTION_COLUMN, - DIRECTION_ROW, - Flex, - SPACING, - LegacyStyledText, - TYPOGRAPHY, -} from '@opentrons/components' - -import { Banner } from '../../../atoms/Banner' - -export function ProtocolDropTipBanner(props: { - onLaunchWizardClick: (setShowWizard: true) => void - onCloseClick: () => void -}): JSX.Element { - const { t } = useTranslation('drop_tip_wizard') - const { onLaunchWizardClick, onCloseClick } = props - - return ( - - - - {t('remove_attached_tips')} - - - - - {t('liquid_damages_pipette')} - - { - onLaunchWizardClick(true) - }} - aria-label="remove-tips" - > - - {t('remove_tips')} - - - - - - ) -} diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolDropTipModal.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolDropTipModal.tsx index 4de86b54f098..6294355aada4 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolDropTipModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolDropTipModal.tsx @@ -16,54 +16,80 @@ import { } from '@opentrons/components' import { TextOnlyButton } from '../../../atoms/buttons' +import { useHomePipettes } from '../../DropTipWizardFlows/hooks' import type { PipetteData } from '@opentrons/api-client' import type { IconProps } from '@opentrons/components' +import type { UseHomePipettesProps } from '../../DropTipWizardFlows/hooks' import type { TipAttachmentStatusResult } from '../../DropTipWizardFlows' -interface UseProtocolDropTipModalProps { +type UseProtocolDropTipModalProps = Pick< + UseHomePipettesProps, + 'robotType' | 'instrumentModelSpecs' | 'mount' +> & { areTipsAttached: TipAttachmentStatusResult['areTipsAttached'] toggleDTWiz: () => void + currentRunId: string + onClose: () => void /* True if the most recent run is the current run */ - isMostRecentRunCurrent: boolean + isRunCurrent: boolean } interface UseProtocolDropTipModalResult { showDTModal: boolean onDTModalSkip: () => void onDTModalRemoval: () => void + isDisabled: boolean } // Wraps functionality required for rendering the related modal. export function useProtocolDropTipModal({ areTipsAttached, toggleDTWiz, - isMostRecentRunCurrent, + isRunCurrent, + onClose, + ...homePipetteProps }: UseProtocolDropTipModalProps): UseProtocolDropTipModalResult { const [showDTModal, setShowDTModal] = React.useState(areTipsAttached) + const { homePipettes, isHomingPipettes } = useHomePipettes({ + ...homePipetteProps, + onComplete: () => { + onClose() + setShowDTModal(false) + }, + }) + + // Close the modal if a different app closes the run context. React.useEffect(() => { - if (isMostRecentRunCurrent) { + if (isRunCurrent && !isHomingPipettes) { setShowDTModal(areTipsAttached) - } else { + } else if (!isRunCurrent) { setShowDTModal(false) } - }, [areTipsAttached, isMostRecentRunCurrent]) + }, [isRunCurrent, areTipsAttached, showDTModal]) // Continue to show the modal if a client dismisses the maintenance run on a different app. const onDTModalSkip = (): void => { - setShowDTModal(false) + homePipettes() } const onDTModalRemoval = (): void => { toggleDTWiz() + setShowDTModal(false) } - return { showDTModal, onDTModalSkip, onDTModalRemoval } + return { + showDTModal, + onDTModalSkip, + onDTModalRemoval, + isDisabled: isHomingPipettes, + } } interface ProtocolDropTipModalProps { onSkip: UseProtocolDropTipModalResult['onDTModalSkip'] onBeginRemoval: UseProtocolDropTipModalResult['onDTModalRemoval'] + isDisabled: UseProtocolDropTipModalResult['isDisabled'] mount?: PipetteData['mount'] } @@ -71,6 +97,7 @@ export function ProtocolDropTipModal({ onSkip, onBeginRemoval, mount, + isDisabled, }: ProtocolDropTipModalProps): JSX.Element { const { t } = useTranslation('drop_tip_wizard') @@ -115,8 +142,12 @@ export function ProtocolDropTipModal({ /> - - + + {t('begin_removal')} diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index d484dc5d1733..aa1f53ae7333 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -22,7 +22,6 @@ import { useModulesQuery, useDoorQuery, useHost, - useInstrumentsQuery, useRunCommandErrors, } from '@opentrons/react-api-client' import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' @@ -54,7 +53,6 @@ import { getRobotUpdateDisplayInfo } from '../../../redux/robot-update' import { getRobotSettings } from '../../../redux/robot-settings' import { getRobotSerialNumber } from '../../../redux/discovery' import { ProtocolAnalysisErrorBanner } from './ProtocolAnalysisErrorBanner' -import { ProtocolDropTipBanner } from './ProtocolDropTipBanner' import { DropTipWizardFlows, useDropTipWizardFlows, @@ -68,8 +66,8 @@ import { ANALYTICS_PROTOCOL_RUN_ACTION, } from '../../../redux/analytics' import { getIsHeaterShakerAttached } from '../../../redux/config' -import { useCloseCurrentRun } from '../../../organisms/ProtocolUpload/hooks' -import { ConfirmCancelModal } from '../../../organisms/RunDetails/ConfirmCancelModal' +import { useCloseCurrentRun } from '../../ProtocolUpload/hooks' +import { ConfirmCancelModal } from '../../RunDetails/ConfirmCancelModal' import { HeaterShakerIsRunningModal } from '../HeaterShakerIsRunningModal' import { useRunControls, @@ -124,6 +122,7 @@ import type { State } from '../../../redux/types' import type { HeaterShakerModule } from '../../../redux/modules/types' const EQUIPMENT_POLL_MS = 5000 +const CURRENT_RUN_POLL_MS = 5000 const CANCELLABLE_STATUSES = [ RUN_STATUS_RUNNING, RUN_STATUS_PAUSED, @@ -166,8 +165,10 @@ export function ProtocolRunHeader({ const isRobotViewable = useIsRobotViewable(robotName) const runStatus = useRunStatus(runId) const { analysisErrors } = useProtocolAnalysisErrors(runId) - const { data: attachedInstruments } = useInstrumentsQuery() - const isRunCurrent = Boolean(useNotifyRunQuery(runId)?.data?.data?.current) + const isRunCurrent = Boolean( + useNotifyRunQuery(runId, { refetchInterval: CURRENT_RUN_POLL_MS })?.data + ?.data?.current + ) const mostRecentRunId = useMostRecentRunId() const { closeCurrentRun, isClosingCurrentRun } = useCloseCurrentRun() const { startedAt, stoppedAt, completedAt } = useRunTimestamps(runId) @@ -179,7 +180,6 @@ export function ProtocolRunHeader({ RUN_STATUSES_TERMINAL.includes(runStatus) && isRunCurrent, }) - const [showDropTipBanner, setShowDropTipBanner] = React.useState(true) const isResetRunLoadingRef = React.useRef(false) const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) const highestPriorityError = @@ -224,19 +224,25 @@ export function ProtocolRunHeader({ aPipetteWithTip, } = useTipAttachmentStatus({ runId, - runRecord, - attachedInstruments, + runRecord: runRecord ?? null, host, - isFlex, }) const { showDTModal, onDTModalSkip, onDTModalRemoval, + isDisabled: areDTModalBtnsDisabled, } = useProtocolDropTipModal({ areTipsAttached, toggleDTWiz, - isMostRecentRunCurrent: mostRecentRunId === runId, + isRunCurrent, + currentRunId: runId, + instrumentModelSpecs: aPipetteWithTip?.specs, + mount: aPipetteWithTip?.mount, + robotType, + onClose: () => { + closeCurrentRun() + }, }) const enteredER = runRecord?.data.hasEverEnteredErrorRecovery @@ -244,7 +250,6 @@ export function ProtocolRunHeader({ React.useEffect(() => { if (isFlex) { if (runStatus === RUN_STATUS_IDLE) { - setShowDropTipBanner(true) resetTipStatus() } else if ( runStatus != null && @@ -334,7 +339,7 @@ export function ProtocolRunHeader({ ) : null} @@ -345,6 +350,7 @@ export function ProtocolRunHeader({ setShowRunFailedModal={setShowRunFailedModal} highestPriorityError={highestPriorityError} commandErrorList={commandErrorList} + runStatus={runStatus} /> ) : null} ) : null} - {mostRecentRunId === runId && showDropTipBanner && areTipsAttached ? ( - { - resetTipStatus() - setShowDropTipBanner(false) - closeCurrentRun() - }} - /> - ) : null} {showDTModal ? ( ) : null} @@ -518,7 +515,13 @@ export function ProtocolRunHeader({ robotType={isFlex ? FLEX_ROBOT_TYPE : OT2_ROBOT_TYPE} mount={aPipetteWithTip.mount} instrumentModelSpecs={aPipetteWithTip.specs} - closeFlow={() => setTipStatusResolved().then(toggleDTWiz)} + closeFlow={() => + setTipStatusResolved() + .then(toggleDTWiz) + .then(() => { + closeCurrentRun() + }) + } /> ) : null} @@ -749,6 +752,11 @@ function ActionButton(props: ActionButtonProps): JSX.Element { disableReason = t('close_door') } + const shouldShowHSConfirm = + isHeaterShakerInProtocol && + !isHeaterShakerShaking && + (runStatus === RUN_STATUS_IDLE || runStatus === RUN_STATUS_STOPPED) + if (isProtocolAnalyzing) { buttonIconName = 'ot-spinner' buttonText = t('analyzing_on_robot') @@ -777,11 +785,7 @@ function ActionButton(props: ActionButtonProps): JSX.Element { (runStatus === RUN_STATUS_IDLE || runStatus === RUN_STATUS_STOPPED) ) { confirmMissingSteps() - } else if ( - isHeaterShakerInProtocol && - !isHeaterShakerShaking && - (runStatus === RUN_STATUS_IDLE || runStatus === RUN_STATUS_STOPPED) - ) { + } else if (shouldShowHSConfirm) { confirmAttachment() } else { play() @@ -867,7 +871,11 @@ function ActionButton(props: ActionButtonProps): JSX.Element { {showMissingStepsConfirmationModal && ( { + shouldShowHSConfirm + ? confirmAttachment() + : handleProceedToRunClick() + }} missingSteps={missingSetupSteps} /> )} @@ -899,7 +907,8 @@ function TerminalRunBanner(props: TerminalRunProps): JSX.Element | null { isRunCurrent, } = props const { t } = useTranslation('run_details') - + const completedWithErrors = + commandErrorList?.data != null && commandErrorList.data.length > 0 const handleRunSuccessClick = (): void => { handleClearClick() } @@ -926,7 +935,10 @@ function TerminalRunBanner(props: TerminalRunProps): JSX.Element | null { const buildErrorBanner = (): JSX.Element => { return ( - + {highestPriorityError != null @@ -934,7 +946,11 @@ function TerminalRunBanner(props: TerminalRunProps): JSX.Element | null { errorType: highestPriorityError?.errorType, errorCode: highestPriorityError?.errorCode, }) - : 'Run completed with errors.'} + : `${ + runStatus === RUN_STATUS_SUCCEEDED + ? t('run_completed_with_warnings') + : t('run_completed_with_errors') + }`} 0 && - !isResetRunLoading) + (completedWithErrors && !isResetRunLoading) ) { return buildErrorBanner() } else { diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx index 5a4d7bb332f2..b1d52c037639 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunRunTimeParameters.tsx @@ -56,10 +56,15 @@ export function ProtocolRunRuntimeParameters({ // because the most recent analysis may not reflect the selected run (e.g. cloning a run // from a historical protocol run from the device details page) const run = useNotifyRunQuery(runId).data - const runTimeParameters = - (isRunTerminal + const runTimeParametersFromRun = + run?.data != null && 'runTimeParameters' in run?.data ? run?.data?.runTimeParameters - : mostRecentAnalysis?.runTimeParameters) ?? [] + : [] + const runTimeParametersFromAnalysis = + mostRecentAnalysis?.runTimeParameters ?? [] + const runTimeParameters = isRunTerminal + ? runTimeParametersFromRun + : runTimeParametersFromAnalysis const hasRunTimeParameters = runTimeParameters.length > 0 const hasCustomRunTimeParameterValues = runTimeParameters.some(parameter => parameter.type !== 'csv_file' ? parameter.value !== parameter.default : true diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx index 7ea1386768d9..ad2a02c61f94 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx @@ -163,17 +163,6 @@ export function ProtocolRunSetup({ return true }) - const [ - labwareSetupComplete, - setLabwareSetupComplete, - ] = React.useState(false) - const [liquidSetupComplete, setLiquidSetupComplete] = React.useState( - false - ) - const [lpcComplete, setLpcComplete] = React.useState(false) - - if (robot == null) return null - const liquids = protocolAnalysis?.liquids ?? [] const liquidsInLoadOrder = protocolAnalysis != null @@ -194,6 +183,18 @@ export function ProtocolRunSetup({ ? t('install_modules', { count: modules.length }) : t('no_deck_hardware_specified') + const [ + labwareSetupComplete, + setLabwareSetupComplete, + ] = React.useState(false) + const [liquidSetupComplete, setLiquidSetupComplete] = React.useState( + !hasLiquids + ) + if (!hasLiquids && missingSteps.includes('liquids')) { + setMissingSteps(missingSteps.filter(step => step !== 'liquids')) + } + const [lpcComplete, setLpcComplete] = React.useState(false) + if (robot == null) return null const StepDetailMap: Record< StepKey, { @@ -250,8 +251,14 @@ export function ProtocolRunSetup({ rightElProps: { stepKey: MODULE_SETUP_KEY, complete: - calibrationStatusRobot.complete && calibrationStatusModules.complete, - completeText: isFlex ? t('calibration_ready') : '', + calibrationStatusRobot.complete && + calibrationStatusModules.complete && + !isMissingModule && + !isFixtureMismatch, + completeText: + isFlex && hasModules + ? t('calibration_ready') + : t('deck_hardware_ready'), incompleteText: isFlex ? t('calibration_needed') : t('action_needed'), missingHardware: isMissingModule || isFixtureMismatch, missingHardwareText: t('action_needed'), @@ -372,6 +379,11 @@ export function ProtocolRunSetup({ + } /> ) : ( @@ -481,7 +492,6 @@ function StepRightElement(props: StepRightElementProps): JSX.Element | null { color={COLORS.black90} css={TYPOGRAPHY.pSemiBold} marginRight={SPACING.spacing16} - textTransform={TYPOGRAPHY.textTransformCapitalize} id={`RunSetupCard_${props.stepKey}_missingHardwareText`} whitespace="nowrap" > @@ -503,7 +513,6 @@ function StepRightElement(props: StepRightElementProps): JSX.Element | null { color={COLORS.black90} css={TYPOGRAPHY.pSemiBold} marginRight={SPACING.spacing16} - textTransform={TYPOGRAPHY.textTransformCapitalize} id={`RunSetupCard_${props.stepKey}_incompleteText`} whitespace="nowrap" > diff --git a/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx b/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx index 7d46206db0b8..33cfaf84ee5f 100644 --- a/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx @@ -23,8 +23,13 @@ import { import { useDownloadRunLog } from '../hooks' +import type { + RunError, + RunCommandErrors, + RunStatus, +} from '@opentrons/api-client' import type { ModalProps } from '@opentrons/components' -import type { RunError, RunCommandErrors } from '@opentrons/api-client' +import { RUN_STATUS_SUCCEEDED } from '@opentrons/api-client' import type { RunCommandError } from '@opentrons/shared-data' /** @@ -45,6 +50,7 @@ interface RunFailedModalProps { setShowRunFailedModal: (showRunFailedModal: boolean) => void highestPriorityError?: RunError | null commandErrorList?: RunCommandErrors | null + runStatus: RunStatus | null } export function RunFailedModal({ @@ -53,11 +59,17 @@ export function RunFailedModal({ setShowRunFailedModal, highestPriorityError, commandErrorList, + runStatus, }: RunFailedModalProps): JSX.Element | null { const { i18n, t } = useTranslation(['run_details', 'shared', 'branded']) const modalProps: ModalProps = { - type: 'error', - title: t('run_failed_modal_title'), + type: runStatus === RUN_STATUS_SUCCEEDED ? 'warning' : 'error', + title: + commandErrorList == null || commandErrorList?.data.length === 0 + ? t('run_failed_modal_title') + : runStatus === RUN_STATUS_SUCCEEDED + ? t('warning_details') + : t('error_details'), onClose: () => { setShowRunFailedModal(false) }, @@ -95,7 +107,13 @@ export function RunFailedModal({ errorType: errors[0].errorType, errorCode: errors[0].errorCode, }) - : `${errors.length} errors`} + : runStatus === RUN_STATUS_SUCCEEDED + ? t(errors.length > 1 ? 'no_of_warnings' : 'no_of_warning', { + count: errors.length, + }) + : t(errors.length > 1 ? 'no_of_errors' : 'no_of_error', { + count: errors.length, + })} {' '} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx index 1a19ed2f5a91..22140948381d 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/LabwareStackModal.tsx @@ -29,6 +29,7 @@ import { getModuleType, TC_MODULE_LOCATION_OT2, TC_MODULE_LOCATION_OT3, + THERMOCYCLER_MODULE_TYPE, } from '@opentrons/shared-data' import tiprackAdapter from '../../../../assets/images/labware/opentrons_flex_96_tiprack_adapter.png' @@ -72,7 +73,7 @@ export const LabwareStackModal = ( const isModuleThermocycler = moduleModel == null ? false - : getModuleType(moduleModel) === 'thermocyclerModuleType' + : getModuleType(moduleModel) === THERMOCYCLER_MODULE_TYPE const thermocyclerLocation = robotType === FLEX_ROBOT_TYPE ? TC_MODULE_LOCATION_OT3 @@ -210,14 +211,21 @@ export const LabwareStackModal = ( justifyContent={JUSTIFY_SPACE_BETWEEN} > - + {adapterDef.parameters.loadName === + 'opentrons_flex_96_tiprack_adapter' ? ( + tiprackAdapterImg + ) : ( + + )} - + {moduleModel != null ? ( + + ) : null} ) : null} {moduleModel != null ? ( diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx index 8a35d8d203e6..71609edb0ff1 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareMap.tsx @@ -137,20 +137,43 @@ export function SetupLabwareMap({ const topLabwareId = labwareInAdapter?.result?.labwareId ?? labwareId const topLabwareDisplayName = labwareInAdapter?.params.displayName ?? displayName + const isLabwareInStack = + topLabwareDefinition != null && + topLabwareId != null && + labwareInAdapter != null return { labwareLocation: { slotName }, definition: topLabwareDefinition, topLabwareId, topLabwareDisplayName, + highlight: isLabwareInStack && hoverLabwareId === topLabwareId, labwareChildren: ( - + { + if (isLabwareInStack) { + setLabwareStackDetailsLabwareId(topLabwareId) + } + }} + onMouseEnter={() => { + if (topLabwareDefinition != null && topLabwareId != null) { + setHoverLabwareId(() => topLabwareId) + } + }} + onMouseLeave={() => { + setHoverLabwareId(null) + }} + > + + ), + stacked: isLabwareInStack, } } ) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx index d649ef75f380..7189eeb4cc08 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx @@ -129,13 +129,18 @@ export function SetupLabwarePositionCheck( id="LPC_setOffsetsConfirmed" padding={`${SPACING.spacing8} ${SPACING.spacing16}`} {...confirmOffsetsTargetProps} - disabled={offsetsConfirmed || lpcDisabledReason !== null} + disabled={ + offsetsConfirmed || + lpcDisabledReason !== null || + nonIdentityOffsets.length === 0 + } > {t('confirm_offsets')} - {lpcDisabledReason !== null ? ( + {lpcDisabledReason != null || nonIdentityOffsets.length === 0 ? ( - {lpcDisabledReason} + {lpcDisabledReason ?? + t('run_labware_position_check_to_get_offsets')} ) : null} { render(props) screen.getByRole('button', { name: 'List View' }) screen.getByRole('button', { name: 'Map View' }) - screen.getByRole('button', { name: 'Confirm placements' }) + screen.getByRole('button', { name: 'Confirm locations and volumes' }) }) it('renders the map view when you press that toggle button', () => { render(props) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx index 243bfeb3ed65..6fc4446a0db5 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/index.tsx @@ -59,7 +59,7 @@ export function SetupLiquids({ }} disabled={isLiquidSetupConfirmed} > - {t('confirm_placements')} + {t('confirm_locations_and_volumes')} diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolDropTipBanner.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolDropTipBanner.test.tsx deleted file mode 100644 index 295a1bea3f60..000000000000 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolDropTipBanner.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import * as React from 'react' -import { fireEvent, screen } from '@testing-library/react' -import { describe, it, beforeEach, vi, expect } from 'vitest' - -import { renderWithProviders } from '../../../../__testing-utils__' -import { ProtocolDropTipBanner } from '../ProtocolDropTipBanner' -import { i18n } from '../../../../i18n' - -const render = (props: React.ComponentProps) => { - return renderWithProviders(, { - i18nInstance: i18n, - })[0] -} - -describe('Module Update Banner', () => { - let props: React.ComponentProps - - beforeEach(() => { - props = { - onCloseClick: vi.fn(), - onLaunchWizardClick: vi.fn(), - } - }) - - it('displays appropriate banner text', () => { - render(props) - screen.getByText('Remove any attached tips') - screen.queryByText( - /Homing the .* pipette with liquid in the tips may damage it\. You must remove all tips before using the pipette again\./ - ) - screen.getByText('Remove tips') - }) - - it('launches the drop tip wizard when clicking on the appropriate banner text', () => { - render(props) - expect(props.onLaunchWizardClick).not.toHaveBeenCalled() - fireEvent.click(screen.getByText('Remove tips')) - expect(props.onLaunchWizardClick).toHaveBeenCalled() - }) - - it('closes the banner when clicking the appropriate button', () => { - render(props) - expect(props.onCloseClick).not.toHaveBeenCalled() - fireEvent.click(screen.getByTestId('Banner_close-button')) - expect(props.onCloseClick).toHaveBeenCalled() - }) -}) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolDropTipModal.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolDropTipModal.test.tsx index 0e9ce19fc5f3..1eea0780f63f 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolDropTipModal.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolDropTipModal.test.tsx @@ -2,22 +2,42 @@ import * as React from 'react' import { describe, it, vi, expect, beforeEach } from 'vitest' import { renderHook, act, screen, fireEvent } from '@testing-library/react' +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' + import { useProtocolDropTipModal, ProtocolDropTipModal, } from '../ProtocolDropTipModal' import { renderWithProviders } from '../../../../__testing-utils__' import { i18n } from '../../../../i18n' +import { mockLeftSpecs } from '../../../../redux/pipettes/__fixtures__' +import { useHomePipettes } from '../../../DropTipWizardFlows/hooks' + +import type { Mock } from 'vitest' + +vi.mock('../../../DropTipWizardFlows/hooks') describe('useProtocolDropTipModal', () => { let props: Parameters[0] + let mockHomePipettes: Mock beforeEach(() => { props = { areTipsAttached: true, toggleDTWiz: vi.fn(), - isMostRecentRunCurrent: true, + isRunCurrent: true, + onClose: vi.fn(), + currentRunId: 'MOCK_ID', + mount: 'left', + instrumentModelSpecs: mockLeftSpecs, + robotType: FLEX_ROBOT_TYPE, } + mockHomePipettes = vi.fn() + + vi.mocked(useHomePipettes).mockReturnValue({ + homePipettes: mockHomePipettes, + isHomingPipettes: false, + }) }) it('should return initial values', () => { @@ -27,6 +47,7 @@ describe('useProtocolDropTipModal', () => { showDTModal: true, onDTModalSkip: expect.any(Function), onDTModalRemoval: expect.any(Function), + isDisabled: false, }) }) @@ -43,13 +64,23 @@ describe('useProtocolDropTipModal', () => { expect(result.current.showDTModal).toBe(false) }) - it('should not show modal when isMostRecentRunCurrent is false', () => { - props.isMostRecentRunCurrent = false + it('should not show modal when isRunCurrent is false', () => { + props.isRunCurrent = false const { result } = renderHook(() => useProtocolDropTipModal(props)) expect(result.current.showDTModal).toBe(false) }) + it('should call homePipettes when onDTModalSkip is called', () => { + const { result } = renderHook(() => useProtocolDropTipModal(props)) + + act(() => { + result.current.onDTModalSkip() + }) + + expect(mockHomePipettes).toHaveBeenCalled() + }) + it('should call toggleDTWiz when onDTModalRemoval is called', () => { const { result } = renderHook(() => useProtocolDropTipModal(props)) @@ -59,6 +90,17 @@ describe('useProtocolDropTipModal', () => { expect(props.toggleDTWiz).toHaveBeenCalled() }) + + it('should set isDisabled to true when isHomingPipettes is true', () => { + vi.mocked(useHomePipettes).mockReturnValue({ + homePipettes: mockHomePipettes, + isHomingPipettes: true, + }) + + const { result } = renderHook(() => useProtocolDropTipModal(props)) + + expect(result.current.isDisabled).toBe(true) + }) }) const render = (props: React.ComponentProps) => { @@ -75,6 +117,7 @@ describe('ProtocolDropTipModal', () => { onSkip: vi.fn(), onBeginRemoval: vi.fn(), mount: 'left', + isDisabled: false, } }) @@ -86,13 +129,13 @@ describe('ProtocolDropTipModal', () => { /Homing the .* pipette with liquid in the tips may damage it\. You must remove all tips before using the pipette again\./ ) screen.getByText('Begin removal') - screen.getByText('Skip') + screen.getByText('Skip and home pipette') }) it('calls onSkip when skip button is clicked', () => { render(props) - fireEvent.click(screen.getByText('Skip')) + fireEvent.click(screen.getByText('Skip and home pipette')) expect(props.onSkip).toHaveBeenCalled() }) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx index 45f84024b49f..b30505837a6a 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunHeader.test.tsx @@ -406,7 +406,8 @@ describe('ProtocolRunHeader', () => { onDTModalRemoval: vi.fn(), onDTModalSkip: vi.fn(), showDTModal: false, - } as any) + isDisabled: false, + }) vi.mocked(ProtocolDropTipModal).mockReturnValue(
MOCK_DROP_TIP_MODAL
) @@ -495,11 +496,9 @@ describe('ProtocolRunHeader', () => { when(vi.mocked(useRunStatus)) .calledWith(RUN_ID) .thenReturn(RUN_STATUS_STOPPED) - when(vi.mocked(useNotifyRunQuery)) - .calledWith(RUN_ID) - .thenReturn({ - data: { data: { ...mockIdleUnstartedRun, current: true } }, - } as UseQueryResult) + vi.mocked(useNotifyRunQuery).mockReturnValue({ + data: { data: { ...mockIdleUnstartedRun, current: true } }, + } as UseQueryResult) render() expect(mockTrackProtocolRunEvent).toBeCalled() expect(mockTrackProtocolRunEvent).toBeCalledWith({ @@ -848,30 +847,15 @@ describe('ProtocolRunHeader', () => { }) it('renders a clear protocol banner when run has succeeded', async () => { - when(vi.mocked(useNotifyRunQuery)) - .calledWith(RUN_ID) - .thenReturn({ - data: { data: mockSucceededRun }, - } as UseQueryResult) - when(vi.mocked(useRunStatus)) - .calledWith(RUN_ID) - .thenReturn(RUN_STATUS_SUCCEEDED) - render() - - screen.getByText('Run completed.') - }) - it('clicking close on a terminal run banner closes the run context', async () => { - when(vi.mocked(useNotifyRunQuery)) - .calledWith(RUN_ID) - .thenReturn({ - data: { data: mockSucceededRun }, - } as UseQueryResult) + vi.mocked(useNotifyRunQuery).mockReturnValue({ + data: { data: mockSucceededRun }, + } as UseQueryResult) when(vi.mocked(useRunStatus)) .calledWith(RUN_ID) .thenReturn(RUN_STATUS_SUCCEEDED) render() - fireEvent.click(screen.queryAllByTestId('Banner_close-button')[0]) + screen.getByText('Run completed with warnings.') }) it('does not display the "run successful" banner if the successful run is not current', async () => { @@ -986,24 +970,6 @@ describe('ProtocolRunHeader', () => { }) }) - it('renders banner with spinner if currently closing current run', async () => { - when(vi.mocked(useNotifyRunQuery)) - .calledWith(RUN_ID) - .thenReturn({ - data: { data: mockSucceededRun }, - } as UseQueryResult) - when(vi.mocked(useRunStatus)) - .calledWith(RUN_ID) - .thenReturn(RUN_STATUS_SUCCEEDED) - when(vi.mocked(useCloseCurrentRun)).calledWith().thenReturn({ - isClosingCurrentRun: true, - closeCurrentRun: mockCloseCurrentRun, - }) - render() - screen.getByText('Run completed.') - screen.getByLabelText('ot-spinner') - }) - it('renders door close banner when the robot door is open', () => { const mockOpenDoorStatus = { data: { status: 'open', doorRequiredClosedForProtocol: true }, @@ -1037,36 +1003,12 @@ describe('ProtocolRunHeader', () => { ).not.toBeInTheDocument() }) - it('renders the drop tip banner when the run is over and a pipette has a tip attached and is a flex', async () => { - when(vi.mocked(useNotifyRunQuery)) - .calledWith(RUN_ID) - .thenReturn({ - data: { - data: { - ...mockIdleUnstartedRun, - current: true, - status: RUN_STATUS_SUCCEEDED, - }, - }, - } as UseQueryResult) - when(vi.mocked(useRunStatus)) - .calledWith(RUN_ID) - .thenReturn(RUN_STATUS_SUCCEEDED) - - render() - await waitFor(() => { - screen.getByText('Remove any attached tips') - screen.getByText( - 'Homing the pipette with liquid in the tips may damage it. You must remove all tips before using the pipette again.' - ) - }) - }) - it('renders the drop tip modal initially when the run ends if tips are attached', () => { vi.mocked(useProtocolDropTipModal).mockReturnValue({ onDTModalRemoval: vi.fn(), onDTModalSkip: vi.fn(), showDTModal: true, + isDisabled: false, }) render() @@ -1074,7 +1016,7 @@ describe('ProtocolRunHeader', () => { screen.getByText('MOCK_DROP_TIP_MODAL') }) - it('does not render the drop tip banner when the run is not over', async () => { + it('does not render the drop tip modal when the run is not over', async () => { when(vi.mocked(useNotifyRunQuery)) .calledWith(RUN_ID) .thenReturn({ diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/RunFailedModal.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/RunFailedModal.test.tsx index 5e768e20359a..affc52d8d94d 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/RunFailedModal.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/RunFailedModal.test.tsx @@ -6,6 +6,7 @@ import { i18n } from '../../../../i18n' import { useDownloadRunLog } from '../../hooks' import { RunFailedModal } from '../RunFailedModal' +import { RUN_STATUS_FAILED } from '@opentrons/api-client' import type { RunError } from '@opentrons/api-client' import { fireEvent, screen } from '@testing-library/react' @@ -39,6 +40,7 @@ describe('RunFailedModal - DesktopApp', () => { runId: RUN_ID, setShowRunFailedModal: vi.fn(), highestPriorityError: mockError, + runStatus: RUN_STATUS_FAILED, } vi.mocked(useDownloadRunLog).mockReturnValue({ downloadRunLog: vi.fn(), diff --git a/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLocationInfoNames.test.ts b/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLocationInfoNames.test.ts index f917f64035f5..d0b3551972fa 100644 --- a/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLocationInfoNames.test.ts +++ b/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLocationInfoNames.test.ts @@ -6,6 +6,7 @@ import type { ModuleModel } from '@opentrons/shared-data' const ADAPTER_DISPLAY_NAME = 'Opentrons 96 Flat Bottom Adapter' const LABWARE_DISPLAY_NAME = 'Corning 24 Well Plate 3.4 mL Flat' const SLOT = '5' +const SLOT_EXTENSION = 'C4' const MOCK_MODEL = 'heaterShakerModuleV1' as ModuleModel const ADAPTER_ID = 'd9a85adf-d272-4edd-9aae-426ef5756fef:opentrons/opentrons_96_flat_bottom_adapter/1' @@ -119,6 +120,34 @@ const MOCK_ADAPTER_COMMANDS = [ }, }, ] +const MOCK_ADAPTER_EXTENSION_COMMANDS = [ + { + commandType: 'loadLabware', + params: { + location: { + addressableAreaName: SLOT_EXTENSION, + }, + }, + result: { + labwareId: ADAPTER_ID, + definition: { + metadata: { displayName: ADAPTER_DISPLAY_NAME }, + }, + }, + }, + { + commandType: 'loadLabware', + params: { + location: { + labwareId: ADAPTER_ID, + }, + }, + result: { + labwareId: LABWARE_ID, + definition: {}, + }, + }, +] vi.mock('@opentrons/shared-data') @@ -168,4 +197,15 @@ describe('getLocationInfoNames', () => { getLocationInfoNames(LABWARE_ID, MOCK_ADAPTER_COMMANDS as any) ).toEqual(expected) }) + it('returns the adapter, slot number if the labware is on an adapter on the deck extension slot', () => { + const expected = { + slotName: SLOT_EXTENSION, + labwareName: LABWARE_DISPLAY_NAME, + adapterName: ADAPTER_DISPLAY_NAME, + adapterId: ADAPTER_ID, + } + expect( + getLocationInfoNames(LABWARE_ID, MOCK_ADAPTER_EXTENSION_COMMANDS as any) + ).toEqual(expected) + }) }) diff --git a/app/src/organisms/Devices/ProtocolRun/utils/getLocationInfoNames.ts b/app/src/organisms/Devices/ProtocolRun/utils/getLocationInfoNames.ts index c3404945dcbf..26d618859f9f 100644 --- a/app/src/organisms/Devices/ProtocolRun/utils/getLocationInfoNames.ts +++ b/app/src/organisms/Devices/ProtocolRun/utils/getLocationInfoNames.ts @@ -87,6 +87,18 @@ export function getLocationInfoNames( loadedAdapterCommand?.result?.definition.metadata.displayName, adapterId: loadedAdapterCommand?.result?.labwareId, } + } else if ( + loadedAdapterCommand?.params.location !== 'offDeck' && + 'addressableAreaName' in loadedAdapterCommand?.params.location + ) { + return { + slotName: loadedAdapterCommand?.params.location.addressableAreaName, + labwareName, + labwareNickname, + adapterName: + loadedAdapterCommand?.result?.definition.metadata.displayName, + adapterId: loadedAdapterCommand?.result?.labwareId, + } } else if ( loadedAdapterCommand?.params.location !== 'offDeck' && 'moduleId' in loadedAdapterCommand?.params.location diff --git a/app/src/organisms/DropTipWizardFlows/DropTipWizardHeader.tsx b/app/src/organisms/DropTipWizardFlows/DropTipWizardHeader.tsx index b60275132606..7816c2fee4f5 100644 --- a/app/src/organisms/DropTipWizardFlows/DropTipWizardHeader.tsx +++ b/app/src/organisms/DropTipWizardFlows/DropTipWizardHeader.tsx @@ -11,6 +11,7 @@ type DropTipWizardHeaderProps = DropTipWizardProps & { isExitInitiated: boolean isFinalWizardStep: boolean confirmExit: () => void + showConfirmExit: boolean } export function DropTipWizardHeader({ @@ -22,6 +23,7 @@ export function DropTipWizardHeader({ isFinalWizardStep, errorDetails, dropTipCommands, + showConfirmExit, }: DropTipWizardHeaderProps): JSX.Element { const { handleCleanUpAndClose } = dropTipCommands const { t, i18n } = useTranslation('drop_tip_wizard') @@ -45,7 +47,7 @@ export function DropTipWizardHeader({ title={i18n.format(t('drop_tips'), 'capitalize')} currentStep={currentStepNumber} totalSteps={totalSteps} - onExit={wizardHeaderOnExit} + onExit={!showConfirmExit ? wizardHeaderOnExit : null} /> ) } diff --git a/app/src/organisms/DropTipWizardFlows/ExitConfirmation.tsx b/app/src/organisms/DropTipWizardFlows/ExitConfirmation.tsx index 8e299f918019..453bf527307e 100644 --- a/app/src/organisms/DropTipWizardFlows/ExitConfirmation.tsx +++ b/app/src/organisms/DropTipWizardFlows/ExitConfirmation.tsx @@ -1,14 +1,15 @@ import * as React from 'react' import { useSelector } from 'react-redux' -import { useTranslation } from 'react-i18next' +import { Trans, useTranslation } from 'react-i18next' import { Flex, COLORS, SPACING, AlertPrimaryButton, - SecondaryButton, JUSTIFY_FLEX_END, + StyledText, + PrimaryButton, } from '@opentrons/components' import { getIsOnDevice } from '../../redux/config' @@ -23,47 +24,65 @@ type ExitConfirmationProps = DropTipWizardContainerProps & { } export function ExitConfirmation(props: ExitConfirmationProps): JSX.Element { - const { handleGoBack, handleExit } = props - const { i18n, t } = useTranslation(['drop_tip_wizard', 'shared']) + const { handleGoBack, handleExit, mount } = props + const { t } = useTranslation(['drop_tip_wizard', 'shared']) - const flowTitle = t('drop_tips') const isOnDevice = useSelector(getIsOnDevice) return ( + , + }} + /> + + } + marginTop={isOnDevice ? '-2rem' : undefined} > {isOnDevice ? ( + - ) : ( - <> - + + {t('shared:go_back')} - + - {i18n.format(t('shared:exit'), 'capitalize')} + {t('exit_and_home_pipette')} - + )} ) diff --git a/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx b/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx index 2789d5892e1b..4cb8dc2c96bc 100644 --- a/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx +++ b/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx @@ -16,15 +16,21 @@ import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' import { SmallButton } from '../../atoms/buttons' import { OddModal } from '../../molecules/OddModal' import { DropTipWizardFlows, useDropTipWizardFlows } from '.' +import { useHomePipettes } from './hooks' import type { HostConfig } from '@opentrons/api-client' import type { OddModalHeaderBaseProps } from '../../molecules/OddModal/types' import type { PipetteWithTip } from '.' +import type { UseHomePipettesProps } from './hooks' -interface TipsAttachedModalProps { +type TipsAttachedModalProps = Pick< + UseHomePipettesProps, + 'robotType' | 'instrumentModelSpecs' | 'mount' +> & { aPipetteWithTip: PipetteWithTip host: HostConfig | null setTipStatusResolved: (onEmpty?: () => void) => Promise + onClose: () => void } export const handleTipsAttachedModal = ( @@ -37,12 +43,23 @@ export const handleTipsAttachedModal = ( const TipsAttachedModal = NiceModal.create( (props: TipsAttachedModalProps): JSX.Element => { - const { aPipetteWithTip, host, setTipStatusResolved } = props + const { + aPipetteWithTip, + host, + setTipStatusResolved, + ...homePipetteProps + } = props const { t } = useTranslation(['drop_tip_wizard']) const modal = useModal() const { mount, specs } = aPipetteWithTip const { showDTWiz, toggleDTWiz } = useDropTipWizardFlows() + const { homePipettes, isHomingPipettes } = useHomePipettes({ + ...homePipetteProps, + onComplete: () => { + cleanUpAndClose() + }, + }) const tipsAttachedHeader: OddModalHeaderBaseProps = { title: t('remove_any_attached_tips'), @@ -50,9 +67,13 @@ const TipsAttachedModal = NiceModal.create( iconColor: COLORS.red50, } + const onHomePipettes = (): void => { + homePipettes() + } + const cleanUpAndClose = (): void => { modal.remove() - setTipStatusResolved() + props.onClose() } const is96Channel = specs.channels === 96 @@ -80,13 +101,15 @@ const TipsAttachedModal = NiceModal.create( @@ -97,7 +120,6 @@ const TipsAttachedModal = NiceModal.create( mount={mount} robotType={FLEX_ROBOT_TYPE} closeFlow={() => { - toggleDTWiz() cleanUpAndClose() }} /> diff --git a/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardFlows.test.tsx b/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardFlows.test.tsx index bd1cc918ea55..99d08eaa579b 100644 --- a/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardFlows.test.tsx +++ b/app/src/organisms/DropTipWizardFlows/__tests__/DropTipWizardFlows.test.tsx @@ -13,6 +13,7 @@ import { import { getPipettesWithTipAttached } from '../getPipettesWithTipAttached' import { getPipetteModelSpecs } from '@opentrons/shared-data' import { DropTipWizard } from '../DropTipWizard' +import { useInstrumentsQuery } from '@opentrons/react-api-client' import type { Mock } from 'vitest' import type { PipetteModelSpecs } from '@opentrons/shared-data' @@ -28,6 +29,7 @@ vi.mock('@opentrons/shared-data', async importOriginal => { vi.mock('../DropTipWizard') vi.mock('../getPipettesWithTipAttached') vi.mock('../hooks') +vi.mock('@opentrons/react-api-client') const MOCK_ACTUAL_PIPETTE = { ...mockPipetteInfo.pipetteSpecs, @@ -60,6 +62,7 @@ describe('useTipAttachmentStatus', () => { vi.mocked(getPipetteModelSpecs).mockReturnValue(MOCK_ACTUAL_PIPETTE) vi.mocked(DropTipWizard).mockReturnValue(
MOCK DROP TIP WIZ
) mockGetPipettesWithTipAttached.mockResolvedValue(mockPipettesWithTip) + vi.mocked(useInstrumentsQuery).mockReturnValue({ data: {} } as any) }) afterEach(() => { diff --git a/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx b/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx index edd24d50e108..4c5dfaa9bb1f 100644 --- a/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx +++ b/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx @@ -7,7 +7,7 @@ import { renderWithProviders } from '../../../__testing-utils__' import { i18n } from '../../../i18n' import { handleTipsAttachedModal } from '../TipsAttachedModal' -import { LEFT } from '@opentrons/shared-data' +import { FLEX_ROBOT_TYPE, LEFT } from '@opentrons/shared-data' import { mockPipetteInfo } from '../../../redux/pipettes/__fixtures__' import { useCloseCurrentRun } from '../../ProtocolUpload/hooks' import { useDropTipWizardFlows } from '..' @@ -52,6 +52,10 @@ const render = (aPipetteWithTip: PipetteWithTip) => { host: MOCK_HOST, aPipetteWithTip, setTipStatusResolved: mockSetTipStatusResolved, + robotType: FLEX_ROBOT_TYPE, + mount: 'left', + instrumentModelSpecs: mockPipetteInfo.pipetteSpecs as any, + onClose: vi.fn(), }) } data-testid="testButton" @@ -93,9 +97,8 @@ describe('TipsAttachedModal', () => { const btn = screen.getByTestId('testButton') fireEvent.click(btn) - const skipBtn = screen.getByText('Skip') + const skipBtn = screen.getByText('Skip and home pipette') fireEvent.click(skipBtn) - expect(mockSetTipStatusResolved).toHaveBeenCalled() }) it('clicking the launch wizard button properly launches the wizard', () => { render(MOCK_A_PIPETTE_WITH_TIP) diff --git a/app/src/organisms/DropTipWizardFlows/__tests__/getPipettesWithTipAttached.test.ts b/app/src/organisms/DropTipWizardFlows/__tests__/getPipettesWithTipAttached.test.ts index b5e5c9bb5f69..eb969f46820f 100644 --- a/app/src/organisms/DropTipWizardFlows/__tests__/getPipettesWithTipAttached.test.ts +++ b/app/src/organisms/DropTipWizardFlows/__tests__/getPipettesWithTipAttached.test.ts @@ -8,135 +8,54 @@ import type { GetPipettesWithTipAttached } from '../getPipettesWithTipAttached' vi.mock('@opentrons/api-client') +const HOST_NAME = 'localhost' +const RUN_ID = 'testRunId' +const LEFT_PIPETTE_ID = 'testId1' +const RIGHT_PIPETTE_ID = 'testId2' +const LEFT_PIPETTE_NAME = 'testLeftName' +const RIGHT_PIPETTE_NAME = 'testRightName' +const PICK_UP_TIP = 'pickUpTip' +const DROP_TIP = 'dropTip' +const DROP_TIP_IN_PLACE = 'dropTipInPlace' +const LOAD_PIPETTE = 'loadPipette' +const FIXIT_INTENT = 'fixit' + const mockAttachedInstruments = { data: [ - { - mount: 'left', - state: { - tipDetected: true, - }, - }, - { - mount: 'right', - state: { - tipDetected: true, - }, - }, + { mount: LEFT, state: { tipDetected: true } }, + { mount: RIGHT, state: { tipDetected: true } }, ], - meta: { - cursor: 0, - totalLength: 2, - }, + meta: { cursor: 0, totalLength: 2 }, } +const createMockCommand = ( + type: string, + id: string, + pipetteId: string, + status = 'succeeded' +) => ({ + id, + key: `${id}-key`, + commandType: type, + status, + params: { pipetteId }, +}) + const mockCommands = { data: [ - { - id: '7bce590e-78bd-4e6c-9166-cbd3d39468bf', - key: 'b56a8e50-b08e-4792-ae97-70d175d2cf9a', - commandType: 'loadPipette', - createdAt: '2023-10-20T13:16:53.519743+00:00', - startedAt: '2023-10-20T13:18:06.494736+00:00', - completedAt: '2023-10-20T13:18:06.758755+00:00', - status: 'succeeded', - params: { - pipetteName: 'p1000_single_flex', - mount: 'left', - pipetteId: 'testId1', - }, - }, - { - id: 'e6ebdf69-f1f3-418c-9f25-2068180bfaa8', - key: 'b0a989d0-b651-4735-b3e8-e1f20ce5f53a', - commandType: 'loadLabware', - createdAt: '2023-10-20T13:16:53.536868+00:00', - startedAt: '2023-10-20T13:18:06.764154+00:00', - completedAt: '2023-10-20T13:18:06.765661+00:00', - status: 'succeeded', - params: { - location: { - slotName: 'A3', - }, - loadName: 'opentrons_1_trash_3200ml_fixed', - namespace: 'opentrons', - version: 1, - labwareId: - 'df371a43-1885-4590-8ca3-d38dc3096753:opentrons/opentrons_1_trash_3200ml_fixed/1', - displayName: 'Opentrons Fixed Trash', - }, - }, - { - id: '256f1bcf-ae9f-4190-be8a-5389f6b1a962', - key: '88c55e6a-4eb7-4863-a96e-de1de8ae27da', - commandType: 'pickUpTip', - createdAt: '2023-10-20T13:16:53.633713+00:00', - startedAt: '2023-10-20T13:18:06.830080+00:00', - completedAt: '2023-10-20T13:18:18.820189+00:00', - status: 'succeeded', - params: { - labwareId: - 'c4b5c4b1-b4f7-4ec6-a4b7-6c8155d7288b:opentrons/opentrons_flex_96_filtertiprack_200ul/1', - pipetteId: 'testId1', - }, - }, - { - id: '7f362b85-2005-4ea7-ab50-3aba27be79ca', - key: '1bd57042-2f0d-4c0c-afa6-b1a7dfbff769', - commandType: 'aspirate', - createdAt: '2023-10-20T13:16:53.635966+00:00', - startedAt: '2023-10-20T13:18:18.822130+00:00', - completedAt: '2023-10-20T13:18:23.424071+00:00', - status: 'succeeded', - params: { - labwareId: - 'd56511f1-8d02-4891-adba-d2710ae02279:opentrons/armadillo_96_wellplate_200ul_pcr_full_skirt/2', - wellName: 'A1', - wellLocation: { - origin: 'bottom', - offset: { - x: 0, - y: 0, - z: 1.0, - }, - }, - flowRate: 137.35, - volume: 5.0, - pipetteId: 'testId1', - }, - }, - { - id: '0220242c-4fe4-4d0c-92d8-71fcc45e944e', - key: 'a3e946a0-9b93-45d4-8d22-d08815bab0ce', - commandType: 'dropTip', - status: 'failed', - params: { - pipetteId: 'testId1', - }, - }, + createMockCommand(LOAD_PIPETTE, 'load-left', LEFT_PIPETTE_ID), + createMockCommand(LOAD_PIPETTE, 'load-right', RIGHT_PIPETTE_ID), + createMockCommand(PICK_UP_TIP, 'pickup-left', LEFT_PIPETTE_ID), + createMockCommand(DROP_TIP, 'drop-left', LEFT_PIPETTE_ID, 'succeeded'), ], - links: { - current: { - href: - '/runs/0d61a8ce-e5b8-4e09-9bf9-a65523094663/commands/0220242c-4fe4-4d0c-92d8-71fcc45e944e', - meta: { - runId: '0d61a8ce-e5b8-4e09-9bf9-a65523094663', - commandId: '0220242c-4fe4-4d0c-92d8-71fcc45e944e', - index: 10, - key: 'a3e946a0-9b93-45d4-8d22-d08815bab0ce', - createdAt: '2023-10-20T13:16:53.671711+00:00', - }, - }, - }, - meta: { - cursor: 0, - totalLength: 11, - }, + meta: { cursor: 0, totalLength: 4 }, } + const mockRunRecord = { data: { pipettes: [ - { id: 'testId1', pipetteName: 'testLeftName', mount: 'left' }, - { id: 'testId2', pipetteName: 'testRightName', mount: 'right' }, + { id: LEFT_PIPETTE_ID, pipetteName: LEFT_PIPETTE_NAME, mount: LEFT }, + { id: RIGHT_PIPETTE_ID, pipetteName: RIGHT_PIPETTE_NAME, mount: RIGHT }, ], }, } @@ -146,9 +65,8 @@ describe('getPipettesWithTipAttached', () => { beforeEach(() => { DEFAULT_PARAMS = { - host: { hostname: 'localhost' }, - isFlex: true, - runId: 'testRunId', + host: { hostname: HOST_NAME }, + runId: RUN_ID, attachedInstruments: mockAttachedInstruments as any, runRecord: mockRunRecord as any, } @@ -158,88 +76,113 @@ describe('getPipettesWithTipAttached', () => { } as any) }) - it('returns an empty array if attachedInstruments is undefined', () => { - const params = { - ...DEFAULT_PARAMS, - attachedInstruments: undefined, - } as GetPipettesWithTipAttached + it('returns an empty array if attachedInstruments is null', async () => { + const params = { ...DEFAULT_PARAMS, attachedInstruments: null } + const result = await getPipettesWithTipAttached(params) + expect(result).toEqual([]) + }) - const result = getPipettesWithTipAttached(params) - return expect(result).resolves.toEqual([]) + it('returns an empty array if runRecord is null', async () => { + const params = { ...DEFAULT_PARAMS, runRecord: null } + const result = await getPipettesWithTipAttached(params) + expect(result).toEqual([]) }) - it('returns an empty array if runRecord is undefined', () => { - const params = { - ...DEFAULT_PARAMS, - runRecord: undefined, - } as GetPipettesWithTipAttached + it('returns an empty array when no tips are attached according to protocol', async () => { + const mockCommandsWithoutAttachedTips = { + ...mockCommands, + data: [ + createMockCommand(LOAD_PIPETTE, 'load-left', LEFT_PIPETTE_ID), + createMockCommand(LOAD_PIPETTE, 'load-right', RIGHT_PIPETTE_ID), + createMockCommand(PICK_UP_TIP, 'pickup-left', LEFT_PIPETTE_ID), + createMockCommand(DROP_TIP, 'drop-left', LEFT_PIPETTE_ID, 'succeeded'), + ], + } - const result = getPipettesWithTipAttached(params) - return expect(result).resolves.toEqual([]) - }) + vi.mocked(getCommands).mockResolvedValue({ + data: mockCommandsWithoutAttachedTips, + } as any) - it('returns pipettes with sensor detected tip attachment if the robot is a Flex', () => { - const result = getPipettesWithTipAttached(DEFAULT_PARAMS) - return expect(result).resolves.toEqual(mockAttachedInstruments.data) + const result = await getPipettesWithTipAttached(DEFAULT_PARAMS) + expect(result).toEqual([]) }) - it('returns pipettes with protocol detected tip attachment if the sensor does not detect tip attachment', () => { - const noTipDetectedInstruments = { - ...mockAttachedInstruments, - data: mockAttachedInstruments.data.map(item => ({ - ...item, - state: { - ...item.state, - tipDetected: false, - }, - })), + it('returns pipettes with protocol detected tip attachment', async () => { + const mockCommandsWithPickUpTip = { + ...mockCommands, + data: [ + ...mockCommands.data, + createMockCommand(PICK_UP_TIP, 'pickup-left-2', LEFT_PIPETTE_ID), + createMockCommand(PICK_UP_TIP, 'pickup-right', RIGHT_PIPETTE_ID), + ], } - const params = { - ...DEFAULT_PARAMS, - attachedInstruments: noTipDetectedInstruments, - } as GetPipettesWithTipAttached + vi.mocked(getCommands).mockResolvedValue({ + data: mockCommandsWithPickUpTip, + } as any) - const result = getPipettesWithTipAttached(params) - return expect(result).resolves.toEqual([noTipDetectedInstruments.data[0]]) + const result = await getPipettesWithTipAttached(DEFAULT_PARAMS) + expect(result).toEqual(mockAttachedInstruments.data) }) - it('returns pipettes with protocol detected tip attachment if the robot is an OT-2', () => { - const params = { - ...DEFAULT_PARAMS, - isFlex: false, - } as GetPipettesWithTipAttached + it('always returns the left mount before the right mount if both pipettes have tips attached', async () => { + const mockCommandsWithPickUpTip = { + ...mockCommands, + data: [ + ...mockCommands.data, + createMockCommand(PICK_UP_TIP, 'pickup-right', RIGHT_PIPETTE_ID), + createMockCommand(PICK_UP_TIP, 'pickup-left-2', LEFT_PIPETTE_ID), + ], + } - const result = getPipettesWithTipAttached(params) - return expect(result).resolves.toEqual([mockAttachedInstruments.data[0]]) - }) + vi.mocked(getCommands).mockResolvedValue({ + data: mockCommandsWithPickUpTip, + } as any) - it('always returns the left mount before the right mount if both pipettes have tips attached', async () => { const result = await getPipettesWithTipAttached(DEFAULT_PARAMS) + expect(result.length).toBe(2) expect(result[0].mount).toEqual(LEFT) expect(result[1].mount).toEqual(RIGHT) }) it('does not return otherwise legitimate failed tip exchange commands if fixit intent tip commands are present and successful', async () => { - const mockCommandsWithFixit = mockCommands.data.push({ - id: '0220242c-4fe4-4d0c-92d8-71fcc45e944e', - key: 'a3e946a0-9b93-45d4-8d22-d08815bab0ce', - intent: 'fixit', - commandType: 'dropTipInPlace', - status: 'succeeded', - params: { - pipetteId: 'testId1', - }, - } as any) + const mockCommandsWithFixit = { + ...mockCommands, + data: [ + ...mockCommands.data, + { + ...createMockCommand( + DROP_TIP_IN_PLACE, + 'fixit-drop', + LEFT_PIPETTE_ID + ), + intent: FIXIT_INTENT, + }, + ], + } vi.mocked(getCommands).mockResolvedValue({ - data: { data: mockCommandsWithFixit, meta: { totalLength: 11 } }, + data: mockCommandsWithFixit, } as any) - const result = await getPipettesWithTipAttached({ - ...DEFAULT_PARAMS, - isFlex: false, - }) + const result = await getPipettesWithTipAttached(DEFAULT_PARAMS) expect(result).toEqual([]) }) + + it('considers a tip attached only if the last tip exchange command was pickUpTip', async () => { + const mockCommandsWithPickUpTip = { + ...mockCommands, + data: [ + ...mockCommands.data, + createMockCommand(PICK_UP_TIP, 'pickup-left-2', LEFT_PIPETTE_ID), + ], + } + + vi.mocked(getCommands).mockResolvedValue({ + data: mockCommandsWithPickUpTip, + } as any) + + const result = await getPipettesWithTipAttached(DEFAULT_PARAMS) + expect(result).toEqual([mockAttachedInstruments.data[0]]) + }) }) diff --git a/app/src/organisms/DropTipWizardFlows/getPipettesWithTipAttached.ts b/app/src/organisms/DropTipWizardFlows/getPipettesWithTipAttached.ts index 33db3cc5ada8..99bcd949093f 100644 --- a/app/src/organisms/DropTipWizardFlows/getPipettesWithTipAttached.ts +++ b/app/src/organisms/DropTipWizardFlows/getPipettesWithTipAttached.ts @@ -17,15 +17,13 @@ import type { export interface GetPipettesWithTipAttached { host: HostConfig | null runId: string - isFlex: boolean - attachedInstruments?: Instruments - runRecord?: Run + attachedInstruments: Instruments | null + runRecord: Run | null } export function getPipettesWithTipAttached({ host, runId, - isFlex, attachedInstruments, runRecord, }: GetPipettesWithTipAttached): Promise { @@ -39,7 +37,6 @@ export function getPipettesWithTipAttached({ ).then(executedCmdData => checkPipettesForAttachedTips( executedCmdData.data, - isFlex, runRecord.data.pipettes, attachedInstruments.data as PipetteData[] ) @@ -62,34 +59,15 @@ function getCommandsExecutedDuringRun( }) } +const TIP_EXCHANGE_COMMAND_TYPES = ['dropTip', 'dropTipInPlace', 'pickUpTip'] + function checkPipettesForAttachedTips( commands: RunCommandSummary[], - isFlex: boolean, pipettesUsedInRun: LoadedPipette[], attachedPipettes: PipetteData[] ): PipetteData[] { let pipettesWithUnknownTipStatus = pipettesUsedInRun - let mountsWithTipAttached: Array = [] - - // Check if the Flex detects a tip attached. - if (isFlex) { - mountsWithTipAttached = pipettesWithUnknownTipStatus - .filter(pipetteWithUnknownTipStatus => - attachedPipettes.some( - attachedInstrument => - attachedInstrument.mount === pipetteWithUnknownTipStatus.mount && - attachedInstrument.state?.tipDetected - ) - ) - .map(pipetteWithTipDetected => pipetteWithTipDetected.mount) - - pipettesWithUnknownTipStatus = pipettesWithUnknownTipStatus.filter( - pipetteWithUnkownStatus => - !mountsWithTipAttached.includes(pipetteWithUnkownStatus.mount) - ) - } - - const TIP_EXCHANGE_COMMAND_TYPES = ['dropTip', 'dropTipInPlace', 'pickUpTip'] + const mountsWithTipAttached: Array = [] // Iterate backwards through commands, finding first tip exchange command for each pipette. // If there's a chance the tip is still attached, flag the pipette. diff --git a/app/src/organisms/DropTipWizardFlows/hooks/index.ts b/app/src/organisms/DropTipWizardFlows/hooks/index.ts index 55cd3250bd9d..fdb5964eacea 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/index.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/index.ts @@ -1,4 +1,6 @@ export * from './errors' +export * from './useDropTipWithType' +export * from './useHomePipettes' export { useDropTipRouting } from './useDropTipRouting' export { useDropTipWithType } from './useDropTipWithType' diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCommands.ts b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts similarity index 86% rename from app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCommands.ts rename to app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts index c1360114e38f..c4036d04d1e8 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCommands.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCommands.ts @@ -2,9 +2,9 @@ import * as React from 'react' import { useDeleteMaintenanceRunMutation } from '@opentrons/react-api-client' -import { MANAGED_PIPETTE_ID, POSITION_AND_BLOWOUT } from '../../constants' -import { getAddressableAreaFromConfig } from '../../getAddressableAreaFromConfig' -import { useNotifyDeckConfigurationQuery } from '../../../../resources/deck_configuration' +import { MANAGED_PIPETTE_ID, POSITION_AND_BLOWOUT } from '../constants' +import { getAddressableAreaFromConfig } from '../getAddressableAreaFromConfig' +import { useNotifyDeckConfigurationQuery } from '../../../resources/deck_configuration' import type { CreateCommand, AddressableAreaName, @@ -14,18 +14,14 @@ import type { } from '@opentrons/shared-data' import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' import type { CommandData, PipetteData } from '@opentrons/api-client' -import type { - Axis, - Sign, - StepSize, -} from '../../../../molecules/JogControls/types' -import type { DropTipFlowsStep, FixitCommandTypeUtils } from '../../types' -import type { SetRobotErrorDetailsParams } from '../errors' -import type { UseDTWithTypeParams } from '..' +import type { Axis, Sign, StepSize } from '../../../molecules/JogControls/types' +import type { DropTipFlowsStep, FixitCommandTypeUtils } from '../types' +import type { SetRobotErrorDetailsParams, UseDTWithTypeParams } from '.' import type { RunCommandByCommandTypeParams } from './useDropTipCreateCommands' const JOG_COMMAND_TIMEOUT_MS = 10000 const MAXIMUM_BLOWOUT_FLOW_RATE_UL_PER_S = 50 +const MAX_QUEUED_JOGS = 3 type UseDropTipSetupCommandsParams = UseDTWithTypeParams & { activeMaintenanceRunId: string | null @@ -40,10 +36,9 @@ type UseDropTipSetupCommandsParams = UseDTWithTypeParams & { } export interface UseDropTipCommandsResult { - /* */ handleCleanUpAndClose: (homeOnExit?: boolean) => Promise moveToAddressableArea: (addressableArea: AddressableAreaName) => Promise - handleJog: (axis: Axis, dir: Sign, step: StepSize) => Promise + handleJog: (axis: Axis, dir: Sign, step: StepSize) => void blowoutOrDropTip: ( currentStep: DropTipFlowsStep, proceed: () => void @@ -51,7 +46,6 @@ export interface UseDropTipCommandsResult { handleMustHome: () => Promise } -// Returns setup commands used in Drop Tip Wizard. export function useDropTipCommands({ issuedCommandsType, toggleIsExiting, @@ -66,6 +60,8 @@ export function useDropTipCommands({ }: UseDropTipSetupCommandsParams): UseDropTipCommandsResult { const isFlex = robotType === FLEX_ROBOT_TYPE const [hasSeenClose, setHasSeenClose] = React.useState(false) + const [jogQueue, setJogQueue] = React.useState Promise>>([]) + const [isJogging, setIsJogging] = React.useState(false) const { deleteMaintenanceRun } = useDeleteMaintenanceRunMutation({ onSuccess: () => { @@ -154,7 +150,7 @@ export function useDropTipCommands({ }) } - const handleJog = (axis: Axis, dir: Sign, step: StepSize): Promise => { + const executeJog = (axis: Axis, dir: Sign, step: StepSize): Promise => { return new Promise((resolve, reject) => { return runCommand({ command: { @@ -180,6 +176,30 @@ export function useDropTipCommands({ }) } + const processJogQueue = (): void => { + if (jogQueue.length > 0 && !isJogging) { + setIsJogging(true) + const nextJog = jogQueue[0] + setJogQueue(prevQueue => prevQueue.slice(1)) + nextJog().finally(() => { + setIsJogging(false) + }) + } + } + + React.useEffect(() => { + processJogQueue() + }, [jogQueue.length, isJogging]) + + const handleJog = (axis: Axis, dir: Sign, step: StepSize): void => { + setJogQueue(prevQueue => { + if (prevQueue.length < MAX_QUEUED_JOGS) { + return [...prevQueue, () => executeJog(axis, dir, step)] + } + return prevQueue + }) + } + const blowoutOrDropTip = ( currentStep: DropTipFlowsStep, proceed: () => void @@ -338,13 +358,14 @@ const buildMoveToAACommand = ( } export const buildLoadPipetteCommand = ( + pipetteName: PipetteModelSpecs['name'], mount: PipetteData['mount'], - pipetteName: PipetteModelSpecs['name'] + pipetteId?: string | null ): CreateCommand => { return { commandType: 'loadPipette', params: { - pipetteId: MANAGED_PIPETTE_ID, + pipetteId: pipetteId ?? MANAGED_PIPETTE_ID, mount, pipetteName, }, diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCreateCommands.ts b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCreateCommands.ts similarity index 94% rename from app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCreateCommands.ts rename to app/src/organisms/DropTipWizardFlows/hooks/useDropTipCreateCommands.ts index 0f8e732ee669..10112ea740b8 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipCreateCommands.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipCreateCommands.ts @@ -4,13 +4,12 @@ import { useChainMaintenanceCommands, useChainRunCommands, useCreateRunCommandMutation, -} from '../../../../resources/runs' +} from '../../../resources/runs' import type { CreateCommand } from '@opentrons/shared-data' import type { CommandData } from '@opentrons/api-client' -import type { SetRobotErrorDetailsParams } from '../errors' -import type { UseDTWithTypeParams } from '.' -import type { FixitCommandTypeUtils } from '../../types' +import type { UseDTWithTypeParams, SetRobotErrorDetailsParams } from '.' +import type { FixitCommandTypeUtils } from '../types' export interface RunCommandByCommandTypeParams { command: CreateCommand diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipMaintenanceRun.tsx b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipMaintenanceRun.tsx similarity index 62% rename from app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipMaintenanceRun.tsx rename to app/src/organisms/DropTipWizardFlows/hooks/useDropTipMaintenanceRun.tsx index a73b8ee6fcff..cb7eb3461167 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/useDropTipMaintenanceRun.tsx +++ b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipMaintenanceRun.tsx @@ -1,23 +1,32 @@ import * as React from 'react' -import { useNotifyCurrentMaintenanceRun } from '../../../../resources/maintenance_runs' +import { useNotifyCurrentMaintenanceRun } from '../../../resources/maintenance_runs' import { useCreateTargetedMaintenanceRunMutation, useChainMaintenanceCommands, -} from '../../../../resources/runs' +} from '../../../resources/runs' import { buildLoadPipetteCommand } from './useDropTipCommands' import type { PipetteModelSpecs } from '@opentrons/shared-data' import type { PipetteData } from '@opentrons/api-client' -import type { SetRobotErrorDetailsParams } from '../errors' -import type { UseDTWithTypeParams } from '..' +import type { SetRobotErrorDetailsParams, UseDTWithTypeParams } from '.' const RUN_REFETCH_INTERVAL_MS = 5000 -type UseDropTipMaintenanceRunParams = UseDTWithTypeParams & { - setErrorDetails: (errorDetails: SetRobotErrorDetailsParams) => void +export type UseDropTipMaintenanceRunParams = Omit< + UseDTWithTypeParams, + 'instrumentModelSpecs' | 'mount' +> & { + setErrorDetails?: (errorDetails: SetRobotErrorDetailsParams) => void + instrumentModelSpecs?: PipetteModelSpecs + mount?: PipetteData['mount'] + /* Optionally control when a drop tip maintenance run is created. */ + enabled?: boolean } +// TODO(jh, 08-08-24): useDropTipMaintenanceRun is a bit overloaded now that we are using it create maintenance runs +// on-the-fly for one-off commands outside of a run. Consider refactoring. + // Manages the maintenance run state if the flow is utilizing "setup" type commands. export function useDropTipMaintenanceRun({ issuedCommandsType, @@ -25,6 +34,7 @@ export function useDropTipMaintenanceRun({ instrumentModelSpecs, setErrorDetails, closeFlow, + enabled, }: UseDropTipMaintenanceRunParams): string | null { const isMaintenanceRunType = issuedCommandsType === 'setup' @@ -34,7 +44,6 @@ export function useDropTipMaintenanceRun({ const { data: maintenanceRunData } = useNotifyCurrentMaintenanceRun({ refetchInterval: RUN_REFETCH_INTERVAL_MS, - enabled: createdMaintenanceRunId != null && isMaintenanceRunType, }) const activeMaintenanceRunId = maintenanceRunData?.data.id @@ -42,9 +51,10 @@ export function useDropTipMaintenanceRun({ useCreateDropTipMaintenanceRun({ issuedCommandsType, mount, - instrumentModelName: instrumentModelSpecs.name, + instrumentModelName: instrumentModelSpecs?.name, setErrorDetails, setCreatedMaintenanceRunId, + enabled, }) useMonitorMaintenanceRunForDeletion({ @@ -57,12 +67,12 @@ export function useDropTipMaintenanceRun({ return activeMaintenanceRunId ?? null } -interface UseCreateDropTipMaintenanceRunParams { - issuedCommandsType: UseDTWithTypeParams['issuedCommandsType'] - mount: PipetteData['mount'] - instrumentModelName: PipetteModelSpecs['name'] - setErrorDetails: (errorDetails: SetRobotErrorDetailsParams) => void +type UseCreateDropTipMaintenanceRunParams = Omit< + UseDropTipMaintenanceRunParams, + 'robotType' | 'closeFlow' +> & { setCreatedMaintenanceRunId: (id: string) => void + instrumentModelName?: PipetteModelSpecs['name'] } // Handles the creation of the maintenance run for "setup" command type drop tip flows, including the loading of the pipette. @@ -72,6 +82,7 @@ function useCreateDropTipMaintenanceRun({ instrumentModelName, setErrorDetails, setCreatedMaintenanceRunId, + enabled, }: UseCreateDropTipMaintenanceRunParams): void { const { chainRunCommands } = useChainMaintenanceCommands() @@ -79,9 +90,10 @@ function useCreateDropTipMaintenanceRun({ createTargetedMaintenanceRun, } = useCreateTargetedMaintenanceRunMutation({ onSuccess: response => { + // The type assertions here are safe, since we only use this command after asserting these const loadPipetteCommand = buildLoadPipetteCommand( - mount, - instrumentModelName + instrumentModelName as string, + mount as PipetteData['mount'] ) chainRunCommands(response.data.id, [loadPipetteCommand], false) @@ -91,19 +103,33 @@ function useCreateDropTipMaintenanceRun({ .catch((error: Error) => error) }, onError: (error: Error) => { - setErrorDetails({ message: error.message }) + if (setErrorDetails != null) { + setErrorDetails({ message: error.message }) + } }, }) + const isEnabled = enabled ?? true React.useEffect(() => { - if (issuedCommandsType === 'setup') { + if ( + issuedCommandsType === 'setup' && + mount != null && + instrumentModelName != null && + isEnabled + ) { createTargetedMaintenanceRun({}).catch((e: Error) => { - setErrorDetails({ - message: `Error creating maintenance run: ${e.message}`, - }) + if (setErrorDetails != null) { + setErrorDetails({ + message: `Error creating maintenance run: ${e.message}`, + }) + } }) + } else { + console.warn( + 'Could not create maintenance run due to missing pipette data.' + ) } - }, []) + }, [enabled, mount, instrumentModelName]) } interface UseMonitorMaintenanceRunForDeletionParams { @@ -125,9 +151,10 @@ function useMonitorMaintenanceRunForDeletion({ monitorMaintenanceRunForDeletion, setMonitorMaintenanceRunForDeletion, ] = React.useState(false) + const [closedOnce, setClosedOnce] = React.useState(false) React.useEffect(() => { - if (isMaintenanceRunType) { + if (isMaintenanceRunType && !closedOnce) { if ( createdMaintenanceRunId !== null && activeMaintenanceRunId === createdMaintenanceRunId @@ -139,12 +166,8 @@ function useMonitorMaintenanceRunForDeletion({ monitorMaintenanceRunForDeletion ) { closeFlow() + setClosedOnce(true) } } - }, [ - isMaintenanceRunType, - createdMaintenanceRunId, - activeMaintenanceRunId, - closeFlow, - ]) + }, [isMaintenanceRunType, createdMaintenanceRunId, activeMaintenanceRunId]) } diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/index.ts b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType.ts similarity index 90% rename from app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/index.ts rename to app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType.ts index 08337ad67d60..951b29c6266f 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType/index.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/useDropTipWithType.ts @@ -1,7 +1,7 @@ // This is the main unifying function for maintenanceRun and fixit type flows. import * as React from 'react' -import { useDropTipCommandErrors } from '../errors' +import { useDropTipCommandErrors } from '.' import { useDropTipMaintenanceRun } from './useDropTipMaintenanceRun' import { useDropTipCreateCommands } from './useDropTipCreateCommands' import { @@ -9,10 +9,10 @@ import { buildLoadPipetteCommand, } from './useDropTipCommands' -import type { SetRobotErrorDetailsParams } from '../errors' +import type { SetRobotErrorDetailsParams } from '.' import type { UseDropTipCommandsResult } from './useDropTipCommands' -import type { ErrorDetails, IssuedCommandsType } from '../../types' -import type { DropTipWizardFlowsProps } from '../..' +import type { ErrorDetails, IssuedCommandsType } from '../types' +import type { DropTipWizardFlowsProps } from '..' import type { UseDropTipCreateCommandsResult } from './useDropTipCreateCommands' export type UseDTWithTypeParams = DropTipWizardFlowsProps & { @@ -117,10 +117,15 @@ function useRegisterPipetteFixitType({ instrumentModelSpecs, issuedCommandsType, chainRunCommands, + fixitCommandTypeUtils, }: UseRegisterPipetteFixitType): void { React.useEffect(() => { if (issuedCommandsType === 'fixit') { - const command = buildLoadPipetteCommand(mount, instrumentModelSpecs.name) + const command = buildLoadPipetteCommand( + instrumentModelSpecs.name, + mount, + fixitCommandTypeUtils?.pipetteId + ) void chainRunCommands([command], true) } }, []) diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useHomePipettes.ts b/app/src/organisms/DropTipWizardFlows/hooks/useHomePipettes.ts new file mode 100644 index 000000000000..a42d3a5ee8c5 --- /dev/null +++ b/app/src/organisms/DropTipWizardFlows/hooks/useHomePipettes.ts @@ -0,0 +1,79 @@ +import * as React from 'react' + +import { + useCreateMaintenanceCommandMutation, + useDeleteMaintenanceRunMutation, +} from '@opentrons/react-api-client' + +import { useDropTipMaintenanceRun } from './useDropTipMaintenanceRun' + +import type { UseDropTipMaintenanceRunParams } from './useDropTipMaintenanceRun' +import type { CreateCommand } from '@opentrons/shared-data' + +export type UseHomePipettesProps = Omit< + UseDropTipMaintenanceRunParams, + 'issuedCommandsType' | 'closeFlow' +> & { + onComplete: () => void +} + +export function useHomePipettes( + props: UseHomePipettesProps +): { + homePipettes: () => void + isHomingPipettes: boolean +} { + const [isHomingPipettes, setIsHomingPipettes] = React.useState(false) + const { deleteMaintenanceRun } = useDeleteMaintenanceRunMutation() + + const createdMaintenanceRunId = useDropTipMaintenanceRun({ + ...props, + issuedCommandsType: 'setup', + enabled: isHomingPipettes, + closeFlow: props.onComplete, + }) + const isMaintenanceRunActive = createdMaintenanceRunId != null + + // Home the pipette after user click once a maintenance run has been created. + React.useEffect(() => { + if (isMaintenanceRunActive && isHomingPipettes) { + void homePipettesCmd().finally(() => { + props.onComplete() + deleteMaintenanceRun(createdMaintenanceRunId) + }) + } + }, [isMaintenanceRunActive, isHomingPipettes]) + + const { createMaintenanceCommand } = useCreateMaintenanceCommandMutation() + + const homePipettesCmd = React.useCallback(() => { + if (createdMaintenanceRunId != null) { + return createMaintenanceCommand( + { + maintenanceRunId: createdMaintenanceRunId, + command: HOME_EXCEPT_PLUNGERS, + waitUntilComplete: true, + }, + { onSettled: () => Promise.resolve() } + ) + } else { + return Promise.reject( + new Error( + "'Unable to create a maintenance run when attempting to home pipettes." + ) + ) + } + }, [createMaintenanceCommand, createdMaintenanceRunId]) + + return { + homePipettes: () => { + setIsHomingPipettes(true) + }, + isHomingPipettes, + } +} + +const HOME_EXCEPT_PLUNGERS: CreateCommand = { + commandType: 'home' as const, + params: { axes: ['leftZ', 'rightZ', 'x', 'y'] }, +} diff --git a/app/src/organisms/DropTipWizardFlows/index.tsx b/app/src/organisms/DropTipWizardFlows/index.tsx index 1565d3a17022..3d7f7489f861 100644 --- a/app/src/organisms/DropTipWizardFlows/index.tsx +++ b/app/src/organisms/DropTipWizardFlows/index.tsx @@ -11,6 +11,7 @@ import type { PipetteModelSpecs, RobotType } from '@opentrons/shared-data' import type { Mount, PipetteData } from '@opentrons/api-client' import type { FixitCommandTypeUtils, IssuedCommandsType } from './types' import type { GetPipettesWithTipAttached } from './getPipettesWithTipAttached' +import { useInstrumentsQuery } from '@opentrons/react-api-client' /** Provides the user toggle for rendering Drop Tip Wizard Flows. * @@ -64,6 +65,8 @@ export function DropTipWizardFlows( ) } +const INSTRUMENTS_POLL_MS = 5000 + export interface PipetteWithTip { mount: Mount specs: PipetteModelSpecs @@ -93,11 +96,14 @@ export interface TipAttachmentStatusResult { // Returns various utilities for interacting with the cache of pipettes with tips attached. export function useTipAttachmentStatus( - params: GetPipettesWithTipAttached + params: Omit ): TipAttachmentStatusResult { const [pipettesWithTip, setPipettesWithTip] = React.useState< PipetteWithTip[] >([]) + const { data: attachedInstruments } = useInstrumentsQuery({ + refetchInterval: INSTRUMENTS_POLL_MS, + }) const aPipetteWithTip = head(pipettesWithTip) ?? null @@ -107,7 +113,10 @@ export function useTipAttachmentStatus( const determineTipStatus = React.useCallback((): Promise< PipetteWithTip[] > => { - return getPipettesWithTipAttached(params).then(pipettesWithTip => { + return getPipettesWithTipAttached({ + ...params, + attachedInstruments: attachedInstruments ?? null, + }).then(pipettesWithTip => { const pipettesWithTipsData = pipettesWithTip.map(pipette => { const specs = getPipetteModelSpecs(pipette.instrumentModel) return { diff --git a/app/src/organisms/DropTipWizardFlows/types.ts b/app/src/organisms/DropTipWizardFlows/types.ts index 15a9e25cc9e9..4238d9ac8a0d 100644 --- a/app/src/organisms/DropTipWizardFlows/types.ts +++ b/app/src/organisms/DropTipWizardFlows/types.ts @@ -36,6 +36,7 @@ export interface DropTipWizardRouteOverride { export interface FixitCommandTypeUtils { runId: string failedCommandId: string + pipetteId: string | null copyOverrides: CopyOverrides errorOverrides: ErrorOverrides buttonOverrides: ButtonOverrides diff --git a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx index 96fcd2209ac5..7f982f5415e5 100644 --- a/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/ErrorRecoveryWizard.tsx @@ -29,7 +29,11 @@ import { RECOVERY_MAP } from './constants' import type { RobotType } from '@opentrons/shared-data' import type { RecoveryContentProps } from './types' -import type { ERUtilsResults, UseRecoveryAnalyticsResult } from './hooks' +import type { + ERUtilsResults, + UseRecoveryAnalyticsResult, + useRetainedFailedCommandBySource, +} from './hooks' import type { ErrorRecoveryFlowsProps } from '.' export interface UseERWizardResult { @@ -65,6 +69,7 @@ export type ErrorRecoveryWizardProps = ErrorRecoveryFlowsProps & isOnDevice: boolean isDoorOpen: boolean analytics: UseRecoveryAnalyticsResult + failedCommand: ReturnType } export function ErrorRecoveryWizard( @@ -76,7 +81,7 @@ export function ErrorRecoveryWizard( recoveryCommands, routeUpdateActions, } = props - const errorKind = getErrorKind(failedCommand) + const errorKind = getErrorKind(failedCommand?.byRunRecord ?? null) useInitialPipetteHome({ hasLaunchedRecovery, diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryError.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryError.tsx index 370b80174a62..4f2128b22535 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryError.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryError.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' +import { css } from 'styled-components' import { COLORS, @@ -10,13 +11,21 @@ import { SPACING, ALIGN_CENTER, JUSTIFY_END, + PrimaryButton, + JUSTIFY_CENTER, + RESPONSIVENESS, } from '@opentrons/components' -import { RECOVERY_MAP } from './constants' +import { SmallButton } from '../../atoms/buttons' import { RecoverySingleColumnContentWrapper } from './shared' +import { + DESKTOP_ONLY, + ICON_SIZE_ALERT_INFO_STYLE, + ODD_ONLY, + RECOVERY_MAP, +} from './constants' import type { RecoveryContentProps } from './types' -import { SmallButton } from '../../atoms/buttons' export function RecoveryError(props: RecoveryContentProps): JSX.Element { const { recoveryMap } = props @@ -155,7 +164,6 @@ export function RecoveryDropTipFlowErrors({ } export function ErrorContent({ - isOnDevice, title, subTitle, btnText, @@ -169,19 +177,12 @@ export function ErrorContent({ }): JSX.Element | null { return ( - + - + + + {btnText} + ) } + +const CONTAINER_STYLE = css` + padding: ${SPACING.spacing40}; + grid-gap: ${SPACING.spacing16}; + flex-direction: ${DIRECTION_COLUMN}; + align-items: ${ALIGN_CENTER}; + justify-content: ${JUSTIFY_CENTER}; + flex: 1; + + @media (${RESPONSIVENESS.touchscreenMediaQuerySpecs}) { + grid-gap: ${SPACING.spacing24}; + } +` diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx index 408c58345f0b..f79f0a7bc766 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx @@ -131,7 +131,7 @@ export function BeginRemoval({ primaryBtnOnClick={primaryOnClick} primaryBtnTextOverride={t('begin_removal')} secondaryBtnOnClick={secondaryOnClick} - secondaryBtnTextOverride={t('skip')} + secondaryBtnTextOverride={t('skip_and_home_pipette')} secondaryAsTertiary={true} /> @@ -200,6 +200,7 @@ export function useDropTipFlowUtils({ subMapUtils, routeUpdateActions, recoveryMap, + failedPipetteInfo, }: RecoveryContentProps): FixitCommandTypeUtils { const { t } = useTranslation('error_recovery') const { @@ -213,7 +214,7 @@ export function useDropTipFlowUtils({ const { selectedRecoveryOption } = currentRecoveryOptionUtils const { proceedToRouteAndStep } = routeUpdateActions const { updateSubMap, subMap } = subMapUtils - const failedCommandId = failedCommand?.id ?? '' // We should have a failed command here unless the run is not in AWAITING_RECOVERY. + const failedCommandId = failedCommand?.byRunRecord.id ?? '' // We should have a failed command here unless the run is not in AWAITING_RECOVERY. const buildTipDropCompleteBtn = (): string => { switch (selectedRecoveryOption) { @@ -301,9 +302,17 @@ export function useDropTipFlowUtils({ } } + const pipetteId = + failedCommand != null && + 'params' in failedCommand.byRunRecord && + 'pipetteId' in failedCommand.byRunRecord.params + ? failedCommand.byRunRecord.params.pipetteId + : null + return { runId, failedCommandId, + pipetteId, copyOverrides: buildCopyOverrides(), errorOverrides: buildErrorOverrides(), buttonOverrides: buildButtonOverrides(), diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx index 08ec338bfecb..8dc92a4205ee 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/__tests__/ManageTips.test.tsx @@ -96,14 +96,14 @@ describe('ManageTips', () => { /Homing the .* pipette with liquid in the tips may damage it\. You must remove all tips before using the pipette again\./ ) screen.queryAllByText('Begin removal') - screen.queryAllByText('Skip') + screen.queryAllByText('Skip and home pipette') }) it('routes correctly when continuing on BeginRemoval', () => { render(props) const beginRemovalBtn = screen.queryAllByText('Begin removal')[0] - const skipBtn = screen.queryAllByText('Skip')[0] + const skipBtn = screen.queryAllByText('Skip and home pipette')[0] fireEvent.click(beginRemovalBtn) clickButtonLabeled('Begin removal') @@ -111,7 +111,7 @@ describe('ManageTips', () => { expect(mockProceedNextStep).toHaveBeenCalled() fireEvent.click(skipBtn) - clickButtonLabeled('Skip') + clickButtonLabeled('Skip and home pipette') expect(mockSetRobotInMotion).toHaveBeenCalled() }) @@ -125,10 +125,10 @@ describe('ManageTips', () => { } render(props) - const skipBtn = screen.queryAllByText('Skip')[0] + const skipBtn = screen.queryAllByText('Skip and home pipette')[0] fireEvent.click(skipBtn) - clickButtonLabeled('Skip') + clickButtonLabeled('Skip and home pipette') expect(mockProceedToRouteAndStep).toHaveBeenCalledWith( RETRY_NEW_TIPS.ROUTE, @@ -210,7 +210,11 @@ describe('useDropTipFlowUtils', () => { const { result } = renderHook(() => useDropTipFlowUtils({ ...mockProps, - failedCommand: { id: 'MOCK_COMMAND_ID' }, + failedCommand: { + byRunRecord: { + id: 'MOCK_COMMAND_ID', + }, + }, } as any) ) diff --git a/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx b/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx index 5a1d22cb9e04..692646c4fbda 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RunPausedSplash.tsx @@ -38,6 +38,7 @@ import type { ERUtilsResults, UseRecoveryAnalyticsResult, UseRecoveryTakeoverResult, + useRetainedFailedCommandBySource, } from './hooks' export function useRunPausedSplash( @@ -55,7 +56,7 @@ export function useRunPausedSplash( type RunPausedSplashProps = ERUtilsResults & { isOnDevice: boolean - failedCommand: ErrorRecoveryFlowsProps['failedCommand'] + failedCommand: ReturnType protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] robotType: RobotType robotName: string @@ -74,7 +75,7 @@ export function RunPausedSplash( robotName, } = props const { t } = useTranslation('error_recovery') - const errorKind = getErrorKind(failedCommand) + const errorKind = getErrorKind(failedCommand?.byRunRecord ?? null) const title = useErrorName(errorKind) const { proceedToRouteAndStep } = routeUpdateActions diff --git a/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts b/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts index 919f45d9c425..cfc51ef036cd 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts +++ b/app/src/organisms/ErrorRecoveryFlows/__fixtures__/index.ts @@ -51,9 +51,13 @@ export const mockPickUpTipLabware: LoadedLabware = { displayName: 'MOCK_PickUpTipLabware_NAME', } -// TOME: Add the mock labware and pipette, etc. as you end up using it elsewhere to here. +// TODO: jh(08-07-24): update the "byAnalysis" mockFailedCommand. export const mockRecoveryContentProps: RecoveryContentProps = { - failedCommand: mockFailedCommand, + failedCommandByRunRecord: mockFailedCommand, + failedCommand: { + byRunRecord: mockFailedCommand, + byAnalysis: mockFailedCommand, + }, errorKind: 'GENERAL_ERROR', robotType: FLEX_ROBOT_TYPE, runId: 'MOCK_RUN_ID', diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx index 54462e62f222..3ab9e3688105 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/ErrorRecoveryFlows.test.tsx @@ -138,7 +138,7 @@ describe('ErrorRecoveryFlows', () => { beforeEach(() => { props = { runStatus: RUN_STATUS_AWAITING_RECOVERY, - failedCommand: mockFailedCommand, + failedCommandByRunRecord: mockFailedCommand, runId: 'MOCK_RUN_ID', protocolAnalysis: {} as any, } @@ -218,9 +218,11 @@ describe('ErrorRecoveryFlows', () => { const newProps = { ...props, - failedCommand: null, + failedCommandByRunRecord: null, } rerender() - expect(mockReportErrorEvent).toHaveBeenCalledWith(newProps.failedCommand) + expect(mockReportErrorEvent).toHaveBeenCalledWith( + newProps.failedCommandByRunRecord + ) }) }) diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryError.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryError.test.tsx index d3b0d4dd6293..e77e3e280aad 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryError.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/RecoveryError.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable testing-library/prefer-presence-queries */ import * as React from 'react' import { describe, it, vi, expect, beforeEach } from 'vitest' import { screen, fireEvent } from '@testing-library/react' @@ -48,13 +49,13 @@ describe('RecoveryError', () => { RECOVERY_MAP.ERROR_WHILE_RECOVERING.STEPS.RECOVERY_ACTION_FAILED render(props) - expect(screen.getByText('Retry step failed')).toBeInTheDocument() + expect(screen.queryAllByText('Retry step failed')[0]).toBeInTheDocument() expect( - screen.getByText( + screen.queryAllByText( 'Next, you can try another recovery action or cancel the run.' - ) + )[0] ).toBeInTheDocument() - expect(screen.getByText('Back to menu')).toBeInTheDocument() + expect(screen.queryAllByText('Back to menu')[0]).toBeInTheDocument() }) it(`renders RecoveryDropTipFlowErrors when step is ${ERROR_WHILE_RECOVERING.STEPS.DROP_TIP_GENERAL_ERROR}`, () => { @@ -62,13 +63,13 @@ describe('RecoveryError', () => { RECOVERY_MAP.ERROR_WHILE_RECOVERING.STEPS.DROP_TIP_GENERAL_ERROR render(props) - expect(screen.getByText('Retry step failed')).toBeInTheDocument() + expect(screen.queryAllByText('Retry step failed')[0]).toBeInTheDocument() expect( - screen.getByText( + screen.queryAllByText( 'Next, you can try another recovery action or cancel the run.' - ) + )[0] ).toBeInTheDocument() - expect(screen.getByText('Return to menu')).toBeInTheDocument() + expect(screen.queryAllByText('Return to menu')[0]).toBeInTheDocument() }) it(`renders RecoveryDropTipFlowErrors when step is ${ERROR_WHILE_RECOVERING.STEPS.DROP_TIP_BLOWOUT_FAILED}`, () => { @@ -76,13 +77,13 @@ describe('RecoveryError', () => { RECOVERY_MAP.ERROR_WHILE_RECOVERING.STEPS.DROP_TIP_BLOWOUT_FAILED render(props) - expect(screen.getByText('Blowout failed')).toBeInTheDocument() + expect(screen.queryAllByText('Blowout failed')[0]).toBeInTheDocument() expect( - screen.getByText( + screen.queryAllByText( 'You can still drop the attached tips before proceeding to tip selection.' - ) + )[0] ).toBeInTheDocument() - expect(screen.getByText('Continue to drop tip')).toBeInTheDocument() + expect(screen.queryAllByText('Continue to drop tip')[0]).toBeInTheDocument() }) it(`renders RecoveryDropTipFlowErrors when step is ${ERROR_WHILE_RECOVERING.STEPS.DROP_TIP_TIP_DROP_FAILED}`, () => { @@ -90,13 +91,13 @@ describe('RecoveryError', () => { RECOVERY_MAP.ERROR_WHILE_RECOVERING.STEPS.DROP_TIP_TIP_DROP_FAILED render(props) - expect(screen.getByText('Tip drop failed')).toBeInTheDocument() + expect(screen.queryAllByText('Tip drop failed')[0]).toBeInTheDocument() expect( - screen.getByText( + screen.queryAllByText( 'Next, you can try another recovery action or cancel the run.' - ) + )[0] ).toBeInTheDocument() - expect(screen.getByText('Return to menu')).toBeInTheDocument() + expect(screen.queryAllByText('Return to menu')[0]).toBeInTheDocument() }) it(`calls proceedToRouteAndStep with ${RECOVERY_MAP.OPTION_SELECTION.ROUTE} when the "Back to menu" button is clicked in ErrorRecoveryFlowError`, () => { @@ -104,7 +105,7 @@ describe('RecoveryError', () => { RECOVERY_MAP.ERROR_WHILE_RECOVERING.STEPS.RECOVERY_ACTION_FAILED render(props) - fireEvent.click(screen.getByText('Back to menu')) + fireEvent.click(screen.queryAllByText('Back to menu')[0]) expect(proceedToRouteAndStepMock).toHaveBeenCalledWith( RECOVERY_MAP.OPTION_SELECTION.ROUTE @@ -116,7 +117,7 @@ describe('RecoveryError', () => { RECOVERY_MAP.ERROR_WHILE_RECOVERING.STEPS.DROP_TIP_GENERAL_ERROR render(props) - fireEvent.click(screen.getByText('Return to menu')) + fireEvent.click(screen.queryAllByText('Return to menu')[0]) expect(proceedToRouteAndStepMock).toHaveBeenCalledWith( RECOVERY_MAP.OPTION_SELECTION.ROUTE @@ -128,7 +129,7 @@ describe('RecoveryError', () => { RECOVERY_MAP.ERROR_WHILE_RECOVERING.STEPS.DROP_TIP_TIP_DROP_FAILED render(props) - fireEvent.click(screen.getByText('Return to menu')) + fireEvent.click(screen.queryAllByText('Return to menu')[0]) expect(proceedToRouteAndStepMock).toHaveBeenCalledWith( RECOVERY_MAP.OPTION_SELECTION.ROUTE @@ -140,7 +141,7 @@ describe('RecoveryError', () => { RECOVERY_MAP.ERROR_WHILE_RECOVERING.STEPS.DROP_TIP_BLOWOUT_FAILED render(props) - fireEvent.click(screen.getByText('Continue to drop tip')) + fireEvent.click(screen.queryAllByText('Continue to drop tip')[0]) expect(proceedToRouteAndStepMock).toHaveBeenCalledWith( RECOVERY_MAP.DROP_TIP_FLOWS.ROUTE, diff --git a/app/src/organisms/ErrorRecoveryFlows/__tests__/RunPausedSplash.test.tsx b/app/src/organisms/ErrorRecoveryFlows/__tests__/RunPausedSplash.test.tsx index 9968012efebd..e654498a6a8e 100644 --- a/app/src/organisms/ErrorRecoveryFlows/__tests__/RunPausedSplash.test.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/__tests__/RunPausedSplash.test.tsx @@ -100,9 +100,11 @@ describe('RunPausedSplash', () => { props = { ...props, failedCommand: { - ...props.failedCommand, - commandType: 'aspirate', - error: { isDefined: true, errorType: 'overpressure' }, + byRunRecord: { + ...props.failedCommand?.byRunRecord, + commandType: 'aspirate', + error: { isDefined: true, errorType: 'overpressure' }, + }, } as any, } render(props) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts index fa6212218b7d..b20ab13a1cd8 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useFailedLabwareUtils.test.ts @@ -85,7 +85,7 @@ describe('getRelevantFailedLabwareCmdFrom', () => { }, } const result = getRelevantFailedLabwareCmdFrom({ - failedCommand: failedLiquidProbeCommand, + failedCommandByRunRecord: failedLiquidProbeCommand, }) expect(result).toEqual(failedLiquidProbeCommand) }) @@ -110,7 +110,7 @@ describe('getRelevantFailedLabwareCmdFrom', () => { overpressureErrorKinds.forEach(([commandType, errorType]) => { const result = getRelevantFailedLabwareCmdFrom({ - failedCommand: { + failedCommandByRunRecord: { ...failedCommand, commandType, error: { isDefined: true, errorType }, @@ -122,7 +122,7 @@ describe('getRelevantFailedLabwareCmdFrom', () => { }) it('should return null for GENERAL_ERROR error kind', () => { const result = getRelevantFailedLabwareCmdFrom({ - failedCommand: { + failedCommandByRunRecord: { ...failedCommand, error: { errorType: 'literally anything else' }, }, @@ -132,7 +132,7 @@ describe('getRelevantFailedLabwareCmdFrom', () => { it('should return null for unhandled error kinds', () => { const result = getRelevantFailedLabwareCmdFrom({ - failedCommand: { + failedCommandByRunRecord: { ...failedCommand, error: { errorType: 'SOME_UNHANDLED_ERROR' }, }, diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts index a55f3ef43f2a..1ca5733c1060 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts @@ -46,7 +46,7 @@ describe('useRecoveryCommands', () => { const props = { runId: mockRunId, - failedCommand: mockFailedCommand, + failedCommandByRunRecord: mockFailedCommand, failedLabwareUtils: mockFailedLabwareUtils, routeUpdateActions: mockRouteUpdateActions, recoveryToastUtils: { makeSuccessToast: mockMakeSuccessToast } as any, @@ -132,7 +132,7 @@ describe('useRecoveryCommands', () => { const { result } = renderHook(() => useRecoveryCommands({ runId: mockRunId, - failedCommand: { + failedCommandByRunRecord: { ...mockFailedCommand, commandType: inPlaceCommandType, params: { @@ -230,7 +230,7 @@ describe('useRecoveryCommands', () => { const testProps = { ...props, - failedCommand: mockFailedCmdWithPipetteId, + failedCommandByRunRecord: mockFailedCmdWithPipetteId, failedLabwareUtils: { ...mockFailedLabwareUtils, failedLabware: mockFailedLabware, @@ -271,7 +271,7 @@ describe('useRecoveryCommands', () => { const testProps = { ...props, - failedCommand: mockFailedCommandWithError, + failedCommandByRunRecord: mockFailedCommandWithError, } const { result } = renderHook(() => useRecoveryCommands(testProps)) diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/index.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/index.ts index 34da52e72245..923b84f22736 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/index.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/index.ts @@ -7,6 +7,7 @@ export { useRouteUpdateActions } from './useRouteUpdateActions' export { useERUtils } from './useERUtils' export { useRecoveryAnalytics } from './useRecoveryAnalytics' export { useRecoveryTakeover } from './useRecoveryTakeover' +export { useRetainedFailedCommandBySource } from './useRetainedFailedCommandBySource' export type { UseRouteUpdateActionsResult } from './useRouteUpdateActions' export type { UseRecoveryCommandsResult } from './useRecoveryCommands' @@ -15,3 +16,4 @@ export type { ERUtilsResults } from './useERUtils' export type { UseFailedLabwareUtilsResult } from './useFailedLabwareUtils' export type { UseRecoveryAnalyticsResult } from './useRecoveryAnalytics' export type { UseRecoveryTakeoverResult } from './useRecoveryTakeover' +export type { FailedCommandBySource } from './useRetainedFailedCommandBySource' diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useCurrentlyRecoveringFrom.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useCurrentlyRecoveringFrom.ts index df21a00a7600..0ca5de470d33 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useCurrentlyRecoveringFrom.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useCurrentlyRecoveringFrom.ts @@ -12,6 +12,13 @@ import type { FailedCommand } from '../types' const ALL_COMMANDS_POLL_MS = 5000 +// TODO(jh, 08-06-24): See EXEC-656. +const VALID_RECOVERY_FETCH_STATUSES = [ + RUN_STATUS_AWAITING_RECOVERY, + RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, + RUN_STATUS_AWAITING_RECOVERY_PAUSED, +] as Array + // Return the `currentlyRecoveringFrom` command returned by the server, if any. // Otherwise, returns null. export function useCurrentlyRecoveringFrom( @@ -20,11 +27,7 @@ export function useCurrentlyRecoveringFrom( ): FailedCommand | null { // There can only be a currentlyRecoveringFrom command when the run is in recovery mode. // In case we're falling back to polling, only enable queries when that is the case. - const isRunInRecoveryMode = ([ - RUN_STATUS_AWAITING_RECOVERY, - RUN_STATUS_AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR, - RUN_STATUS_AWAITING_RECOVERY_PAUSED, - ] as Array).includes(runStatus) + const isRunInRecoveryMode = VALID_RECOVERY_FETCH_STATUSES.includes(runStatus) const { data: allCommandsQueryData } = useNotifyAllCommandsQuery( runId, diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts index 10860cbacc57..5833a0a2dd87 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useERUtils.ts @@ -1,5 +1,4 @@ import { useInstrumentsQuery } from '@opentrons/react-api-client' -import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' import { useRouteUpdateActions } from './useRouteUpdateActions' import { useRecoveryCommands } from './useRecoveryCommands' @@ -34,13 +33,15 @@ import type { RecoveryActionMutationResult } from './useRecoveryActionMutation' import type { StepCounts } from '../../../resources/protocols/hooks' import type { UseRecoveryAnalyticsResult } from './useRecoveryAnalytics' import type { UseRecoveryTakeoverResult } from './useRecoveryTakeover' +import type { useRetainedFailedCommandBySource } from './useRetainedFailedCommandBySource' -type ERUtilsProps = ErrorRecoveryFlowsProps & { +export type ERUtilsProps = Omit & { toggleERWizAsActiveUser: UseRecoveryTakeoverResult['toggleERWizAsActiveUser'] hasLaunchedRecovery: boolean isOnDevice: boolean robotType: RobotType analytics: UseRecoveryAnalyticsResult + failedCommand: ReturnType } export interface ERUtilsResults { @@ -83,6 +84,7 @@ export function useERUtils({ cursor: 0, pageLength: 999, }) + const failedCommandByRunRecord = failedCommand?.byRunRecord ?? null const stepCounts = useRunningStepCounts(runId, runCommands) @@ -103,7 +105,6 @@ export function useERUtils({ const tipStatusUtils = useRecoveryTipStatus({ runId, - isFlex: robotType === FLEX_ROBOT_TYPE, runRecord, attachedInstruments, }) @@ -116,13 +117,13 @@ export function useERUtils({ }) const failedPipetteInfo = getFailedCommandPipetteInfo({ - failedCommand, + failedCommandByRunRecord, runRecord, attachedInstruments, }) const failedLabwareUtils = useFailedLabwareUtils({ - failedCommand, + failedCommandByRunRecord, protocolAnalysis, failedPipetteInfo, runRecord, @@ -131,7 +132,7 @@ export function useERUtils({ const recoveryCommands = useRecoveryCommands({ runId, - failedCommand, + failedCommandByRunRecord, failedLabwareUtils, routeUpdateActions, recoveryToastUtils, diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts index 927b867752b6..bee6eb0474ca 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useFailedLabwareUtils.ts @@ -9,6 +9,7 @@ import { import { ERROR_KINDS } from '../constants' import { getErrorKind } from '../utils' +import { getLoadedLabware } from '../../../molecules/Command/utils/accessors' import type { WellGroup } from '@opentrons/components' import type { CommandsData, PipetteData, Run } from '@opentrons/api-client' @@ -20,11 +21,11 @@ import type { DispenseRunTimeCommand, LiquidProbeRunTimeCommand, } from '@opentrons/shared-data' -import { getLoadedLabware } from '../../../molecules/Command/utils/accessors' import type { ErrorRecoveryFlowsProps } from '..' +import type { ERUtilsProps } from './useERUtils' interface UseFailedLabwareUtilsProps { - failedCommand: ErrorRecoveryFlowsProps['failedCommand'] + failedCommandByRunRecord: ERUtilsProps['failedCommandByRunRecord'] protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] failedPipetteInfo: PipetteData | null runCommands?: CommandsData @@ -49,15 +50,19 @@ export type UseFailedLabwareUtilsResult = UseTipSelectionUtilsResult & { * For no liquid detected errors, the relevant labware is the well in which no liquid was detected. */ export function useFailedLabwareUtils({ - failedCommand, + failedCommandByRunRecord, protocolAnalysis, failedPipetteInfo, runCommands, runRecord, }: UseFailedLabwareUtilsProps): UseFailedLabwareUtilsResult { const recentRelevantFailedLabwareCmd = React.useMemo( - () => getRelevantFailedLabwareCmdFrom({ failedCommand, runCommands }), - [failedCommand?.error?.errorType, runCommands] + () => + getRelevantFailedLabwareCmdFrom({ + failedCommandByRunRecord, + runCommands, + }), + [failedCommandByRunRecord?.key, runCommands?.meta.totalLength] ) const tipSelectionUtils = useTipSelectionUtils(recentRelevantFailedLabwareCmd) @@ -69,12 +74,12 @@ export function useFailedLabwareUtils({ recentRelevantFailedLabwareCmd, runRecord ), - [protocolAnalysis, recentRelevantFailedLabwareCmd, runRecord] + [protocolAnalysis?.id, recentRelevantFailedLabwareCmd?.key] ) const failedLabware = React.useMemo( () => getFailedLabware(recentRelevantFailedLabwareCmd, runRecord), - [recentRelevantFailedLabwareCmd, runRecord] + [recentRelevantFailedLabwareCmd?.key] ) const relevantWellName = getRelevantWellName( @@ -99,24 +104,24 @@ type FailedCommandRelevantLabware = | null interface RelevantFailedLabwareCmd { - failedCommand: ErrorRecoveryFlowsProps['failedCommand'] + failedCommandByRunRecord: ErrorRecoveryFlowsProps['failedCommandByRunRecord'] runCommands?: CommandsData } // Return the actual command that contains the info relating to the relevant labware. export function getRelevantFailedLabwareCmdFrom({ - failedCommand, + failedCommandByRunRecord, runCommands, }: RelevantFailedLabwareCmd): FailedCommandRelevantLabware { - const errorKind = getErrorKind(failedCommand) + const errorKind = getErrorKind(failedCommandByRunRecord) switch (errorKind) { case ERROR_KINDS.NO_LIQUID_DETECTED: - return failedCommand as LiquidProbeRunTimeCommand + return failedCommandByRunRecord as LiquidProbeRunTimeCommand case ERROR_KINDS.OVERPRESSURE_PREPARE_TO_ASPIRATE: case ERROR_KINDS.OVERPRESSURE_WHILE_ASPIRATING: case ERROR_KINDS.OVERPRESSURE_WHILE_DISPENSING: - return getRelevantPickUpTipCommand(failedCommand, runCommands) + return getRelevantPickUpTipCommand(failedCommandByRunRecord, runCommands) case ERROR_KINDS.GENERAL_ERROR: return null default: @@ -129,23 +134,23 @@ export function getRelevantFailedLabwareCmdFrom({ // Returns the most recent pickUpTip command for the pipette used in the failed command, if any. function getRelevantPickUpTipCommand( - failedCommand: ErrorRecoveryFlowsProps['failedCommand'], + failedCommandByRunRecord: ErrorRecoveryFlowsProps['failedCommandByRunRecord'], runCommands?: CommandsData ): Omit | null { if ( - failedCommand == null || + failedCommandByRunRecord == null || runCommands == null || - !('wellName' in failedCommand.params) || - !('pipetteId' in failedCommand.params) + !('wellName' in failedCommandByRunRecord.params) || + !('pipetteId' in failedCommandByRunRecord.params) ) { return null } - const failedCmdPipetteId = failedCommand.params.pipetteId + const failedCmdPipetteId = failedCommandByRunRecord.params.pipetteId // Reverse iteration is faster as long as # recovery commands < # run commands. const failedCommandIdx = runCommands.data.findLastIndex( - command => command.key === failedCommand.key + command => command.key === failedCommandByRunRecord.key ) const recentPickUpTipCmd = runCommands.data diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts index 803bdf18f6a8..ff636d2cbd3d 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryCommands.ts @@ -31,10 +31,11 @@ import type { UseRouteUpdateActionsResult } from './useRouteUpdateActions' import type { RecoveryToasts } from './useRecoveryToasts' import type { UseRecoveryAnalyticsResult } from './useRecoveryAnalytics' import type { CurrentRecoveryOptionUtils } from './useRecoveryRouting' +import type { ErrorRecoveryFlowsProps } from '../index' interface UseRecoveryCommandsParams { runId: string - failedCommand: FailedCommand | null + failedCommandByRunRecord: ErrorRecoveryFlowsProps['failedCommandByRunRecord'] failedLabwareUtils: UseFailedLabwareUtilsResult routeUpdateActions: UseRouteUpdateActionsResult recoveryToastUtils: RecoveryToasts @@ -62,7 +63,7 @@ export interface UseRecoveryCommandsResult { // Returns commands with a "fixit" intent. Commands may or may not terminate Error Recovery. See each command docstring for details. export function useRecoveryCommands({ runId, - failedCommand, + failedCommandByRunRecord, failedLabwareUtils, routeUpdateActions, recoveryToastUtils, @@ -70,7 +71,10 @@ export function useRecoveryCommands({ selectedRecoveryOption, }: UseRecoveryCommandsParams): UseRecoveryCommandsResult { const { proceedToRouteAndStep } = routeUpdateActions - const { chainRunCommands } = useChainRunCommands(runId, failedCommand?.id) + const { chainRunCommands } = useChainRunCommands( + runId, + failedCommandByRunRecord?.id + ) const { mutateAsync: resumeRunFromRecovery, } = useResumeRunFromRecoveryMutation() @@ -98,22 +102,23 @@ export function useRecoveryCommands({ IN_PLACE_COMMAND_TYPES.includes( (failedCommand as InPlaceCommand).commandType ) - return failedCommand != null - ? isInPlace(failedCommand) - ? failedCommand.error?.isDefined && - failedCommand.error?.errorType === 'overpressure' && + return failedCommandByRunRecord != null + ? isInPlace(failedCommandByRunRecord) + ? failedCommandByRunRecord.error?.isDefined && + failedCommandByRunRecord.error?.errorType === 'overpressure' && // Paranoia: this value comes from the wire and may be unevenly implemented - typeof failedCommand.error?.errorInfo?.retryLocation?.at(0) === - 'number' + typeof failedCommandByRunRecord.error?.errorInfo?.retryLocation?.at( + 0 + ) === 'number' ? { commandType: 'moveToCoordinates', intent: 'fixit', params: { - pipetteId: failedCommand.params?.pipetteId, + pipetteId: failedCommandByRunRecord.params?.pipetteId, coordinates: { - x: failedCommand.error.errorInfo.retryLocation[0], - y: failedCommand.error.errorInfo.retryLocation[1], - z: failedCommand.error.errorInfo.retryLocation[2], + x: failedCommandByRunRecord.error.errorInfo.retryLocation[0], + y: failedCommandByRunRecord.error.errorInfo.retryLocation[1], + z: failedCommandByRunRecord.error.errorInfo.retryLocation[2], }, }, } @@ -137,7 +142,7 @@ export function useRecoveryCommands({ ) const retryFailedCommand = React.useCallback((): Promise => { - const { commandType, params } = failedCommand as FailedCommand // Null case is handled before command could be issued. + const { commandType, params } = failedCommandByRunRecord as FailedCommand // Null case is handled before command could be issued. return chainRunRecoveryCommands( [ // move back to the location of the command if it is an in-place command @@ -145,7 +150,7 @@ export function useRecoveryCommands({ { commandType, params }, // retry the command that failed ].filter(c => c != null) as CreateCommand[] ) // the created command is the same command that failed - }, [chainRunRecoveryCommands, failedCommand]) + }, [chainRunRecoveryCommands, failedCommandByRunRecord?.key]) // Homes the Z-axis of all attached pipettes. const homePipetteZAxes = React.useCallback((): Promise => { @@ -158,7 +163,7 @@ export function useRecoveryCommands({ const pickUpTipCmd = buildPickUpTips( selectedTipLocations, - failedCommand, + failedCommandByRunRecord, failedLabware ) @@ -167,7 +172,7 @@ export function useRecoveryCommands({ } else { return chainRunRecoveryCommands([pickUpTipCmd]) } - }, [chainRunRecoveryCommands, failedCommand, failedLabwareUtils]) + }, [chainRunRecoveryCommands, failedCommandByRunRecord, failedLabwareUtils]) const resumeRun = React.useCallback((): void => { void resumeRunFromRecovery(runId).then(() => { @@ -189,10 +194,10 @@ export function useRecoveryCommands({ }, [runId, resumeRunFromRecovery, makeSuccessToast]) const ignoreErrorKindThisRun = React.useCallback((): Promise => { - if (failedCommand?.error != null) { + if (failedCommandByRunRecord?.error != null) { const ignorePolicyRules = buildIgnorePolicyRules( - failedCommand.commandType, - failedCommand.error.errorType + failedCommandByRunRecord.commandType, + failedCommandByRunRecord.error.errorType ) updateErrorRecoveryPolicy(ignorePolicyRules) @@ -202,7 +207,10 @@ export function useRecoveryCommands({ new Error('Could not execute command. No failed command.') ) } - }, [failedCommand?.error?.errorType, failedCommand?.commandType]) + }, [ + failedCommandByRunRecord?.error?.errorType, + failedCommandByRunRecord?.commandType, + ]) return { resumeRun, diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryTipStatus.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryTipStatus.ts index a9fc7c128e1d..1ac285481cda 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryTipStatus.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryTipStatus.ts @@ -12,7 +12,6 @@ import type { interface UseRecoveryTipStatusProps { runId: string - isFlex: boolean attachedInstruments?: Instruments runRecord?: Run } @@ -33,6 +32,7 @@ export function useRecoveryTipStatus( const tipAttachmentStatusUtils = useTipAttachmentStatus({ ...props, host, + runRecord: props.runRecord ?? null, }) const determineTipStatusWithLoading = (): Promise => { diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRetainedFailedCommandBySource.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRetainedFailedCommandBySource.ts new file mode 100644 index 000000000000..10231f5e0cc7 --- /dev/null +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRetainedFailedCommandBySource.ts @@ -0,0 +1,51 @@ +import * as React from 'react' + +import type { RunTimeCommand } from '@opentrons/shared-data' +import type { ErrorRecoveryFlowsProps } from '..' + +// TODO(jh, 08-06-24): Revisit this. Can the server reasonably supply the failed command via useCurrentlyRecoveringFrom +// during all states the app cares about? + +export interface FailedCommandBySource { + byAnalysis: RunTimeCommand + byRunRecord: RunTimeCommand +} + +/** + * Currently, Error Recovery needs the failedCommand from the run record and the failedCommand from protocol analysis. + * In order to reduce misuse, bundle the failedCommand into "run" and "analysis" versions. + */ +export function useRetainedFailedCommandBySource( + failedCommandByRunRecord: ErrorRecoveryFlowsProps['failedCommandByRunRecord'], + protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] +): FailedCommandBySource | null { + // In some cases, Error Recovery (by the app definition) persists when Error Recovery (by the server definition) does + // not persist. Retaining the failed command allows the app to show information related to the failed command while + // the robot is out of "awaiting-recovery" (by the server definition). + const [ + retainedFailedCommand, + setRetainedFailedCommand, + ] = React.useState(null) + + React.useEffect(() => { + if (failedCommandByRunRecord !== null) { + const failedCommandByAnalysis = + protocolAnalysis?.commands.find( + command => command.key === failedCommandByRunRecord?.key + ) ?? null + + if (failedCommandByAnalysis != null) { + setRetainedFailedCommand({ + byRunRecord: failedCommandByRunRecord, + byAnalysis: failedCommandByAnalysis, + }) + } + } + }, [ + failedCommandByRunRecord?.key, + failedCommandByRunRecord?.error?.errorType, + protocolAnalysis?.id, + ]) + + return retainedFailedCommand +} diff --git a/app/src/organisms/ErrorRecoveryFlows/index.tsx b/app/src/organisms/ErrorRecoveryFlows/index.tsx index 76b5d6b07bcf..4986197e9d05 100644 --- a/app/src/organisms/ErrorRecoveryFlows/index.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/index.tsx @@ -25,6 +25,7 @@ import { useERUtils, useRecoveryAnalytics, useRecoveryTakeover, + useRetainedFailedCommandBySource, useShowDoorInfo, } from './hooks' @@ -109,19 +110,24 @@ export function useErrorRecoveryFlows( export interface ErrorRecoveryFlowsProps { runId: string runStatus: RunStatus | null - failedCommand: FailedCommand | null + failedCommandByRunRecord: FailedCommand | null protocolAnalysis: CompletedProtocolAnalysis | null } export function ErrorRecoveryFlows( props: ErrorRecoveryFlowsProps ): JSX.Element | null { - const { protocolAnalysis, runStatus, failedCommand } = props + const { protocolAnalysis, runStatus, failedCommandByRunRecord } = props + + const failedCommandBySource = useRetainedFailedCommandBySource( + failedCommandByRunRecord, + protocolAnalysis + ) const analytics = useRecoveryAnalytics() React.useEffect(() => { - analytics.reportErrorEvent(failedCommand) - }, [failedCommand?.error?.detail]) + analytics.reportErrorEvent(failedCommandByRunRecord) + }, [failedCommandByRunRecord?.error?.detail]) const { hasLaunchedRecovery, toggleERWizard, showERWizard } = useERWizard() const isOnDevice = useSelector(getIsOnDevice) @@ -145,6 +151,7 @@ export function ErrorRecoveryFlows( isOnDevice, robotType, analytics, + failedCommand: failedCommandBySource, }) return ( @@ -165,6 +172,7 @@ export function ErrorRecoveryFlows( isOnDevice={isOnDevice} isDoorOpen={isDoorOpen} analytics={analytics} + failedCommand={failedCommandBySource} /> ) : null} {showSplash ? ( @@ -176,6 +184,7 @@ export function ErrorRecoveryFlows( isOnDevice={isOnDevice} toggleERWizAsActiveUser={toggleERWizAsActiveUser} analytics={analytics} + failedCommand={failedCommandBySource} /> ) : null} diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx index 0cf31872bcc4..e50e75c84445 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/ErrorDetailsModal.tsx @@ -25,7 +25,7 @@ import { getErrorKind } from '../utils' import type { RobotType } from '@opentrons/shared-data' import type { IconProps } from '@opentrons/components' import type { OddModalHeaderBaseProps } from '../../../molecules/OddModal/types' -import type { ERUtilsResults } from '../hooks' +import type { ERUtilsResults, useRetainedFailedCommandBySource } from '../hooks' import type { ErrorRecoveryFlowsProps } from '..' import type { DesktopSizeType } from '../types' @@ -42,17 +42,21 @@ export function useErrorDetailsModal(): { return { showModal, toggleModal } } -type ErrorDetailsModalProps = ErrorRecoveryFlowsProps & +type ErrorDetailsModalProps = Omit< + ErrorRecoveryFlowsProps, + 'failedCommandByRunRecord' +> & ERUtilsResults & { toggleModal: () => void isOnDevice: boolean robotType: RobotType desktopType: DesktopSizeType + failedCommand: ReturnType } export function ErrorDetailsModal(props: ErrorDetailsModalProps): JSX.Element { const { failedCommand, toggleModal, isOnDevice } = props - const errorKind = getErrorKind(failedCommand) + const errorKind = getErrorKind(failedCommand?.byRunRecord ?? null) const errorName = useErrorName(errorKind) const getIsOverpressureErrorKind = (): boolean => { diff --git a/app/src/organisms/ErrorRecoveryFlows/shared/FailedStepNextStep.tsx b/app/src/organisms/ErrorRecoveryFlows/shared/FailedStepNextStep.tsx index b29ade0d2eb0..f17a98bf3115 100644 --- a/app/src/organisms/ErrorRecoveryFlows/shared/FailedStepNextStep.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/shared/FailedStepNextStep.tsx @@ -18,12 +18,13 @@ export function FailedStepNextStep({ | 'robotType' >): JSX.Element { const { t } = useTranslation('error_recovery') + const failedCommandByAnalysis = failedCommand?.byAnalysis ?? null const nthStepAfter = (n: number): number | undefined => stepCounts.currentStepNumber == null ? undefined : stepCounts.currentStepNumber + n - const nthCommand = (n: number): typeof failedCommand => + const nthCommand = (n: number): typeof failedCommandByAnalysis => commandsAfterFailedCommand != null ? n < commandsAfterFailedCommand.length ? commandsAfterFailedCommand[n] @@ -40,6 +41,7 @@ export function FailedStepNextStep({ ? { command: commandsAfter[1], index: nthStepAfter(2) } : null, ] as const + return ( command.key === failedCommand?.key - ) - const currentCopy = currentStepNumber ?? '?' const totalCopy = totalStepCount ?? '?' @@ -49,9 +45,9 @@ export function StepInfo({ > {`${t('at_step')} ${currentCopy}/${totalCopy}: `} - {analysisCommand != null && protocolAnalysis != null ? ( + {failedCommand?.byAnalysis != null && protocolAnalysis != null ? ( { it('renders the OverpressureBanner when the error kind is an overpressure error', () => { props.failedCommand = { ...props.failedCommand, - commandType: 'aspirate', - error: { isDefined: true, errorType: 'overpressure' }, + byRunRecord: { + ...props.failedCommand?.byRunRecord, + commandType: 'aspirate', + error: { isDefined: true, errorType: 'overpressure' }, + }, } as any render({ ...props, isOnDevice }) diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getFailedCommandPipetteInfo.test.ts b/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getFailedCommandPipetteInfo.test.ts index 0c7091466f69..7031dedc5fc4 100644 --- a/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getFailedCommandPipetteInfo.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getFailedCommandPipetteInfo.test.ts @@ -31,7 +31,7 @@ describe('getFailedCommandPipetteInfo', () => { it('should return null if failedCommand is null', () => { const result = getFailedCommandPipetteInfo({ - failedCommand: null, + failedCommandByRunRecord: null, runRecord: undefined, attachedInstruments: undefined, }) @@ -40,7 +40,7 @@ describe('getFailedCommandPipetteInfo', () => { it('should return null if failedCommand does not have pipetteId in params', () => { const result = getFailedCommandPipetteInfo({ - failedCommand: { params: {} } as any, + failedCommandByRunRecord: failedCommand, runRecord: undefined, attachedInstruments: undefined, }) @@ -49,7 +49,7 @@ describe('getFailedCommandPipetteInfo', () => { it('should return null if no matching pipette is found in runRecord', () => { const result = getFailedCommandPipetteInfo({ - failedCommand, + failedCommandByRunRecord: failedCommand, runRecord: { data: { pipettes: [runRecordPipette2] } } as any, attachedInstruments: { data: [attachedInstrument1, attachedInstrument2], @@ -60,7 +60,7 @@ describe('getFailedCommandPipetteInfo', () => { it('should return null if no matching instrument is found in attachedInstruments', () => { const result = getFailedCommandPipetteInfo({ - failedCommand, + failedCommandByRunRecord: failedCommand, runRecord: { data: { pipettes: [runRecordPipette1] } } as any, attachedInstruments: { data: [attachedInstrument2] } as any, }) @@ -69,7 +69,7 @@ describe('getFailedCommandPipetteInfo', () => { it('should return the matching pipette data', () => { const result = getFailedCommandPipetteInfo({ - failedCommand, + failedCommandByRunRecord: failedCommand, runRecord: { data: { pipettes: [runRecordPipette1, runRecordPipette2] }, } as any, diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getNextStep.test.ts b/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getNextStep.test.ts index 236217521c5e..2071fe61e47c 100644 --- a/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getNextStep.test.ts +++ b/app/src/organisms/ErrorRecoveryFlows/utils/__tests__/getNextStep.test.ts @@ -1,15 +1,17 @@ import { describe, expect, it } from 'vitest' -import { mockFailedCommand } from '../../__fixtures__' +import { mockRecoveryContentProps } from '../../__fixtures__' import { getNextStep, getNextSteps } from '../getNextStep' import type { RunCommandSummary } from '@opentrons/api-client' +const mockFailedCommand = mockRecoveryContentProps.failedCommand + describe('getNextStep', () => { const mockProtocolAnalysis = { commands: [ { key: 'command1' }, - mockFailedCommand, + mockFailedCommand?.byAnalysis, { key: 'command3' }, ] as RunCommandSummary[], } as any @@ -25,7 +27,13 @@ describe('getNextStep', () => { key: 'command3', } - const result = getNextStep(lastFailedCommand, mockProtocolAnalysis) + const result = getNextStep( + { + byRunRecord: lastFailedCommand as any, + byAnalysis: lastFailedCommand as any, + }, + mockProtocolAnalysis + ) expect(result).toBeNull() }) @@ -35,7 +43,13 @@ describe('getNextStep', () => { key: 'non_existent_command_key', } - const result = getNextStep(nonExistentFailedCommand, mockProtocolAnalysis) + const result = getNextStep( + { + byRunRecord: nonExistentFailedCommand as any, + byAnalysis: nonExistentFailedCommand as any, + }, + mockProtocolAnalysis + ) expect(result).toBeNull() }) @@ -54,7 +68,7 @@ describe('getNextSteps', () => { const mockProtocolAnalysis = { commands: [ { key: 'command1' }, - mockFailedCommand, + mockFailedCommand?.byAnalysis, { key: 'command3' }, { key: 'command4' }, ] as RunCommandSummary[], @@ -71,7 +85,13 @@ describe('getNextSteps', () => { key: 'command4', } - const result = getNextStep(lastFailedCommand, mockProtocolAnalysis) + const result = getNextStep( + { + byRunRecord: lastFailedCommand as any, + byAnalysis: lastFailedCommand as any, + }, + mockProtocolAnalysis + ) expect(result).toBeNull() }) @@ -86,7 +106,13 @@ describe('getNextSteps', () => { key: 'non_existent_command_key', } - const result = getNextStep(nonExistentFailedCommand, mockProtocolAnalysis) + const result = getNextStep( + { + byRunRecord: nonExistentFailedCommand as any, + byAnalysis: nonExistentFailedCommand as any, + }, + mockProtocolAnalysis + ) expect(result).toBeNull() }) diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/getFailedCommandPipetteInfo.ts b/app/src/organisms/ErrorRecoveryFlows/utils/getFailedCommandPipetteInfo.ts index 849c8e5fe8a2..7d49b51f931a 100644 --- a/app/src/organisms/ErrorRecoveryFlows/utils/getFailedCommandPipetteInfo.ts +++ b/app/src/organisms/ErrorRecoveryFlows/utils/getFailedCommandPipetteInfo.ts @@ -2,21 +2,24 @@ import type { PipetteData, Instruments, Run } from '@opentrons/api-client' import type { ErrorRecoveryFlowsProps } from '..' interface UseFailedCommandPipetteInfoProps { - failedCommand: ErrorRecoveryFlowsProps['failedCommand'] + failedCommandByRunRecord: ErrorRecoveryFlowsProps['failedCommandByRunRecord'] runRecord?: Run attachedInstruments?: Instruments } // /instruments data for the pipette used in the failedCommand, if any. export function getFailedCommandPipetteInfo({ - failedCommand, + failedCommandByRunRecord, runRecord, attachedInstruments, }: UseFailedCommandPipetteInfoProps): PipetteData | null { - if (failedCommand == null || !('pipetteId' in failedCommand.params)) { + if ( + failedCommandByRunRecord == null || + !('pipetteId' in failedCommandByRunRecord.params) + ) { return null } else { - const failedPipetteId = failedCommand.params.pipetteId + const failedPipetteId = failedCommandByRunRecord.params.pipetteId const runRecordPipette = runRecord?.data.pipettes.find( pipette => pipette.id === failedPipetteId ) diff --git a/app/src/organisms/ErrorRecoveryFlows/utils/getNextStep.ts b/app/src/organisms/ErrorRecoveryFlows/utils/getNextStep.ts index 52617cc6aef0..d5e53d4921a4 100644 --- a/app/src/organisms/ErrorRecoveryFlows/utils/getNextStep.ts +++ b/app/src/organisms/ErrorRecoveryFlows/utils/getNextStep.ts @@ -1,22 +1,23 @@ import type { RunCommandSummary } from '@opentrons/api-client' import type { ErrorRecoveryFlowsProps } from '../' +import type { ERUtilsProps } from '../hooks/useERUtils' // Return the next protocol step given the failedCommand, if any. export const getNextStep = ( - failedCommand: ErrorRecoveryFlowsProps['failedCommand'], + failedCommand: ERUtilsProps['failedCommand'], protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'] ): RunCommandSummary | null => getNextSteps(failedCommand, protocolAnalysis, 1)?.at(0) ?? null export function getNextSteps( - failedCommand: ErrorRecoveryFlowsProps['failedCommand'], + failedCommand: ERUtilsProps['failedCommand'], protocolAnalysis: ErrorRecoveryFlowsProps['protocolAnalysis'], atMostFurther: number ): RunCommandSummary[] | null { if (protocolAnalysis != null && failedCommand != null) { const failedCommandAnalysisIdx = protocolAnalysis?.commands?.findIndex( - command => command.key === failedCommand.key + command => command.key === failedCommand.byAnalysis.key ) ?? -1 return failedCommandAnalysisIdx !== -1 && diff --git a/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx b/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx index e636bf42d0bd..6f9229089d67 100644 --- a/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx +++ b/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx @@ -18,9 +18,14 @@ import { useStopRunMutation } from '@opentrons/react-api-client' import { SmallButton } from '../../../atoms/buttons' import { OddModal } from '../../../molecules/OddModal' +import { RUN_STATUS_SUCCEEDED } from '@opentrons/api-client' import type { OddModalHeaderBaseProps } from '../../../molecules/OddModal/types' -import type { RunCommandErrors, RunError } from '@opentrons/api-client' +import type { + RunCommandErrors, + RunError, + RunStatus, +} from '@opentrons/api-client' import type { RunCommandError } from '@opentrons/shared-data' interface RunFailedModalProps { @@ -28,6 +33,7 @@ interface RunFailedModalProps { setShowRunFailedModal: (showRunFailedModal: boolean) => void errors?: RunError[] commandErrorList?: RunCommandErrors + runStatus: RunStatus | null } export function RunFailedModal({ @@ -35,6 +41,7 @@ export function RunFailedModal({ setShowRunFailedModal, errors, commandErrorList, + runStatus, }: RunFailedModalProps): JSX.Element | null { const { t, i18n } = useTranslation(['run_details', 'shared', 'branded']) const navigate = useNavigate() @@ -47,7 +54,12 @@ export function RunFailedModal({ ) return null const modalHeader: OddModalHeaderBaseProps = { - title: t('run_failed_modal_title'), + title: + commandErrorList == null || commandErrorList?.data.length === 0 + ? t('run_failed_modal_title') + : runStatus === RUN_STATUS_SUCCEEDED + ? t('warning_details') + : t('error_details'), } const highestPriorityError = getHighestPriorityError(errors ?? []) @@ -84,7 +96,13 @@ export function RunFailedModal({ errorType: errors[0].errorType, errorCode: errors[0].errorCode, }) - : `${errors.length} errors`} + : runStatus === RUN_STATUS_SUCCEEDED + ? t(errors.length > 1 ? 'no_of_warnings' : 'no_of_warning', { + count: errors.length, + }) + : t(errors.length > 1 ? 'no_of_errors' : 'no_of_error', { + count: errors.length, + })} { runId: RUN_ID, setShowRunFailedModal: mockFn, errors: mockErrors, + runStatus: RUN_STATUS_FAILED, } vi.mocked(useStopRunMutation).mockReturnValue({ diff --git a/app/src/organisms/ProtocolSetupLabware/LabwareMapView.tsx b/app/src/organisms/ProtocolSetupLabware/LabwareMapView.tsx index 1e00d2660083..49c6fd38d74d 100644 --- a/app/src/organisms/ProtocolSetupLabware/LabwareMapView.tsx +++ b/app/src/organisms/ProtocolSetupLabware/LabwareMapView.tsx @@ -85,6 +85,10 @@ export function LabwareMapView(props: LabwareMapViewProps): JSX.Element { const topLabwareDefinition = labwareInAdapter?.result?.definition ?? labwareDef const topLabwareId = labwareInAdapter?.result?.labwareId ?? labwareId + const isLabwareInStack = + topLabwareDefinition != null && + topLabwareId != null && + labwareInAdapter != null return { labwareLocation: { slotName }, @@ -95,6 +99,7 @@ export function LabwareMapView(props: LabwareMapViewProps): JSX.Element { }, labwareChildren: null, highlight: true, + stacked: isLabwareInStack, } } ) diff --git a/app/src/organisms/ProtocolSetupOffsets/index.tsx b/app/src/organisms/ProtocolSetupOffsets/index.tsx index a1c191c142fc..b0f8b6f78f6e 100644 --- a/app/src/organisms/ProtocolSetupOffsets/index.tsx +++ b/app/src/organisms/ProtocolSetupOffsets/index.tsx @@ -93,6 +93,7 @@ export function ProtocolSetupOffsets({ ) : ( { setIsConfirmed(true) setSetupScreen('prepare to run') diff --git a/app/src/organisms/ProtocolUpload/hooks/useCloneRun.ts b/app/src/organisms/ProtocolUpload/hooks/useCloneRun.ts index dc3b2e7902b9..0132adf7298d 100644 --- a/app/src/organisms/ProtocolUpload/hooks/useCloneRun.ts +++ b/app/src/organisms/ProtocolUpload/hooks/useCloneRun.ts @@ -48,7 +48,11 @@ export function useCloneRun( ) const cloneRun = (): void => { if (runRecord != null) { - const { protocolId, labwareOffsets, runTimeParameters } = runRecord.data + const { protocolId, labwareOffsets } = runRecord.data + const runTimeParameters = + 'runTimeParameters' in runRecord.data + ? runRecord.data.runTimeParameters + : [] const runTimeParameterValues = getRunTimeParameterValuesForRun( runTimeParameters ) diff --git a/app/src/pages/Devices/DevicesLanding/NewRobotSetupHelp.tsx b/app/src/pages/Devices/DevicesLanding/NewRobotSetupHelp.tsx index 05d2e5168ddc..729b4448af53 100644 --- a/app/src/pages/Devices/DevicesLanding/NewRobotSetupHelp.tsx +++ b/app/src/pages/Devices/DevicesLanding/NewRobotSetupHelp.tsx @@ -5,6 +5,7 @@ import { ALIGN_FLEX_END, DIRECTION_COLUMN, Flex, + FLEX_MAX_CONTENT, Link, PrimaryButton, SPACING, @@ -50,10 +51,16 @@ export function NewRobotSetupHelp(): JSX.Element { {t('new_robot_instructions')} - + {t('opentrons_flex_quickstart_guide')} - + {t('ot2_quickstart_guide')} ) if (hardware.hardwareType === 'module') { - location = + const slot = + getModuleType(hardware.moduleModel) === THERMOCYCLER_MODULE_TYPE + ? TC_MODULE_LOCATION_OT3 + : hardware.slot + location = } else if (hardware.hardwareType === 'fixture') { location = ( { hasSlotConflict: false, connected: false, }, + { + hardwareType: 'module', + moduleModel: 'thermocyclerModuleV2', + slot: 'B1', + hasSlotConflict: false, + connected: false, + }, { hardwareType: 'fixture', cutoutFixtureId: WASTE_CHUTE_RIGHT_ADAPTER_NO_COVER_FIXTURE, @@ -92,6 +99,7 @@ describe('Hardware', () => { }) screen.getByRole('row', { name: '1 Heater-Shaker Module GEN1' }) screen.getByRole('row', { name: '3 Temperature Module GEN2' }) + screen.getByRole('row', { name: 'A1+B1 Thermocycler Module GEN2' }) screen.getByRole('row', { name: 'D3 Waste chute only' }) screen.getByRole('row', { name: 'B3 Staging area slot' }) }) diff --git a/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx b/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx index 5479f4693bd6..04f3c2d8e770 100644 --- a/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx +++ b/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx @@ -478,8 +478,58 @@ describe('ProtocolSetup', () => { it('should render a confirmation modal when heater-shaker is in a protocol and it is not shaking', () => { vi.mocked(useIsHeaterShakerInProtocol).mockReturnValue(true) + vi.mocked(useProtocolAnalysisAsDocumentQuery).mockReturnValue({ + data: { ...mockRobotSideAnalysis, liquids: mockLiquids }, + } as any) + when(vi.mocked(getProtocolModulesInfo)) + .calledWith( + { ...mockRobotSideAnalysis, liquids: mockLiquids }, + flexDeckDefV5 as any + ) + .thenReturn(mockProtocolModuleInfo) + when(vi.mocked(getUnmatchedModulesForProtocol)) + .calledWith([], mockProtocolModuleInfo) + .thenReturn({ missingModuleIds: [], remainingAttachedModules: [] }) + vi.mocked(getIncompleteInstrumentCount).mockReturnValue(0) + MockProtocolSetupLiquids.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupLiquids
+ }) + ) + MockProtocolSetupLabware.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupLabware
+ }) + ) + MockProtocolSetupOffsets.mockImplementation( + vi.fn(({ setIsConfirmed, setSetupScreen }) => { + setIsConfirmed(true) + setSetupScreen('prepare to run') + return
Mock ProtocolSetupOffsets
+ }) + ) + render(`/runs/${RUN_ID}/setup/`) + fireEvent.click(screen.getByText('Labware Position Check')) + fireEvent.click(screen.getByText('Labware')) + fireEvent.click(screen.getByText('Liquids')) + fireEvent.click(screen.getByRole('button', { name: 'play' })) + expect(vi.mocked(ConfirmAttachedModal)).toHaveBeenCalled() + }) + it('should go from skip steps to heater-shaker modal', () => { + vi.mocked(useIsHeaterShakerInProtocol).mockReturnValue(true) + MockConfirmSetupStepsCompleteModal.mockImplementation( + ({ onConfirmClick }) => { + onConfirmClick() + return
Mock ConfirmSetupStepsCompleteModal
+ } + ) render(`/runs/${RUN_ID}/setup/`) fireEvent.click(screen.getByRole('button', { name: 'play' })) + expect(MockConfirmSetupStepsCompleteModal).toHaveBeenCalled() expect(vi.mocked(ConfirmAttachedModal)).toHaveBeenCalled() }) it('should render a loading skeleton while awaiting a response from the server', () => { diff --git a/app/src/pages/ProtocolSetup/index.tsx b/app/src/pages/ProtocolSetup/index.tsx index f152b0cc44a9..5a50c52c19cb 100644 --- a/app/src/pages/ProtocolSetup/index.tsx +++ b/app/src/pages/ProtocolSetup/index.tsx @@ -513,6 +513,8 @@ function PrepareToRun({ areModulesReady && areFixturesReady && !isLocationConflict ? 'ready' : 'not ready' + // Liquids information + const liquidsInProtocol = mostRecentAnalysis?.liquids ?? [] const isReadyToRun = incompleteInstrumentCount === 0 && areModulesReady && areFixturesReady @@ -521,13 +523,17 @@ function PrepareToRun({ makeSnackbar(t('shared:close_robot_door') as string) } else { if (isReadyToRun) { - if (runStatus === RUN_STATUS_IDLE && isHeaterShakerInProtocol) { - confirmAttachment() - } else if ( + if ( runStatus === RUN_STATUS_IDLE && - !(labwareConfirmed && offsetsConfirmed && liquidsConfirmed) + !( + labwareConfirmed && + offsetsConfirmed && + (liquidsConfirmed || liquidsInProtocol.length === 0) + ) ) { confirmStepsComplete() + } else if (runStatus === RUN_STATUS_IDLE && isHeaterShakerInProtocol) { + confirmAttachment() } else { play() trackProtocolRunEvent({ @@ -654,9 +660,6 @@ function PrepareToRun({ runRecord?.data?.labwareOffsets ?? [] ) - // Liquids information - const liquidsInProtocol = mostRecentAnalysis?.liquids ?? [] - const { data: doorStatus } = useDoorQuery({ refetchInterval: FETCH_DURATION_MS, }) @@ -757,7 +760,7 @@ function PrepareToRun({ detail={modulesDetail} subDetail={modulesSubDetail} status={modulesStatus} - disabled={ + interactionDisabled={ protocolModulesInfo.length === 0 && !protocolHasFixtures } /> @@ -799,7 +802,11 @@ function PrepareToRun({ setSetupScreen('liquids') }} title={i18n.format(t('liquids'), 'capitalize')} - status={liquidsConfirmed ? 'ready' : 'general'} + status={ + liquidsConfirmed || liquidsInProtocol.length === 0 + ? 'ready' + : 'general' + } detail={ liquidsInProtocol.length > 0 ? t('initial_liquids_num', { @@ -807,7 +814,7 @@ function PrepareToRun({ }) : t('liquids_not_in_setup') } - disabled={liquidsInProtocol.length === 0} + interactionDisabled={liquidsInProtocol.length === 0} /> ) : ( @@ -957,6 +964,8 @@ export function ProtocolSetup(): JSX.Element { handleProceedToRunClick, !(labwareConfirmed && liquidsConfirmed && offsetsConfirmed) ) + const runStatus = useRunStatus(runId) + const isHeaterShakerInProtocol = useIsHeaterShakerInProtocol() // orchestrate setup subpages/components const [setupScreen, setSetupScreen] = React.useState( @@ -1027,7 +1036,6 @@ export function ProtocolSetup(): JSX.Element { ), } - return ( <> {showAnalysisFailedModal && @@ -1043,7 +1051,11 @@ export function ProtocolSetup(): JSX.Element { { + runStatus === RUN_STATUS_IDLE && isHeaterShakerInProtocol + ? confirmAttachment() + : handleProceedToRunClick() + }} /> ) : null} {showHSConfirmationModal ? ( diff --git a/app/src/pages/RunSummary/index.tsx b/app/src/pages/RunSummary/index.tsx index 188f5e606ee4..c7f9cdaba972 100644 --- a/app/src/pages/RunSummary/index.tsx +++ b/app/src/pages/RunSummary/index.tsx @@ -38,10 +38,10 @@ import { import { useHost, useProtocolQuery, - useInstrumentsQuery, useDeleteRunMutation, useRunCommandErrors, } from '@opentrons/react-api-client' +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' import { useRunTimestamps, @@ -65,13 +65,14 @@ import { getLocalRobot } from '../../redux/discovery' import { RunFailedModal } from '../../organisms/OnDeviceDisplay/RunningProtocol' import { formatTimeWithUtcLabel, useNotifyRunQuery } from '../../resources/runs' import { handleTipsAttachedModal } from '../../organisms/DropTipWizardFlows/TipsAttachedModal' -import { useMostRecentRunId } from '../../organisms/ProtocolUpload/hooks/useMostRecentRunId' import { useTipAttachmentStatus } from '../../organisms/DropTipWizardFlows' import { useRecoveryAnalytics } from '../../organisms/ErrorRecoveryFlows/hooks' import type { OnDeviceRouteParams } from '../../App/types' import type { PipetteWithTip } from '../../organisms/DropTipWizardFlows' +const CURRENT_RUN_POLL_MS = 5000 + export function RunSummary(): JSX.Element { const { runId } = useParams< keyof OnDeviceRouteParams @@ -80,9 +81,10 @@ export function RunSummary(): JSX.Element { const navigate = useNavigate() const host = useHost() const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity }) - const isRunCurrent = Boolean(runRecord?.data?.current) - const mostRecentRunId = useMostRecentRunId() - const { data: attachedInstruments } = useInstrumentsQuery() + const isRunCurrent = Boolean( + useNotifyRunQuery(runId, { refetchInterval: CURRENT_RUN_POLL_MS })?.data + ?.data?.current + ) const { deleteRun } = useDeleteRunMutation() const runStatus = runRecord?.data.status ?? null const didRunSucceed = runStatus === RUN_STATUS_SUCCEEDED @@ -155,7 +157,10 @@ export function RunSummary(): JSX.Element { isRunCurrent, }) - let headerText = t('run_complete_splash') + let headerText = + commandErrorList != null && commandErrorList.data.length > 0 + ? t('run_completed_with_warnings') + : t('run_completed_splash') if (runStatus === RUN_STATUS_FAILED) { headerText = t('run_failed_splash') } else if (runStatus === RUN_STATUS_STOPPED) { @@ -168,10 +173,8 @@ export function RunSummary(): JSX.Element { aPipetteWithTip, } = useTipAttachmentStatus({ runId, - runRecord, - attachedInstruments, + runRecord: runRecord ?? null, host, - isFlex: true, }) // Determine tip status on initial render only. Error Recovery always handles tip status, so don't show it twice. @@ -185,7 +188,8 @@ export function RunSummary(): JSX.Element { const queryClient = useQueryClient() const returnToDash = (): void => { closeCurrentRun() - // Eagerly clear the query cache to prevent top level redirecting back to this page. + // Eagerly clear the query caches to prevent top level redirecting back to this page. + queryClient.setQueryData([host, 'runs', 'details'], () => undefined) queryClient.setQueryData([host, 'runs', runId, 'details'], () => undefined) navigate('/') } @@ -225,25 +229,39 @@ export function RunSummary(): JSX.Element { } const handleReturnToDash = (aPipetteWithTip: PipetteWithTip | null): void => { - if (mostRecentRunId === runId && aPipetteWithTip != null) { + if (isRunCurrent && aPipetteWithTip != null) { void handleTipsAttachedModal({ setTipStatusResolved: setTipStatusResolvedAndRoute(handleReturnToDash), host, aPipetteWithTip, + instrumentModelSpecs: aPipetteWithTip.specs, + mount: aPipetteWithTip.mount, + robotType: FLEX_ROBOT_TYPE, + onClose: () => { + closeCurrentRun() + returnToDash() + }, }) } else if (isQuickTransfer) { returnToQuickTransfer() } else { + closeCurrentRun() returnToDash() } } const handleRunAgain = (aPipetteWithTip: PipetteWithTip | null): void => { - if (mostRecentRunId === runId && aPipetteWithTip != null) { + if (isRunCurrent && aPipetteWithTip != null) { void handleTipsAttachedModal({ setTipStatusResolved: setTipStatusResolvedAndRoute(handleRunAgain), host, aPipetteWithTip, + instrumentModelSpecs: aPipetteWithTip.specs, + mount: aPipetteWithTip.mount, + robotType: FLEX_ROBOT_TYPE, + onClose: () => { + runAgain() + }, }) } else { if (!isResetRunLoading) { @@ -332,6 +350,7 @@ export function RunSummary(): JSX.Element { setShowRunFailedModal={setShowRunFailedModal} errors={runRecord?.data.errors} commandErrorList={commandErrorList} + runStatus={runStatus} /> ) : null} - {!didRunSucceed ? ( + {(commandErrorList != null && commandErrorList?.data.length > 0) || + !didRunSucceed ? ( ) : null} diff --git a/app/src/pages/RunningProtocol/index.tsx b/app/src/pages/RunningProtocol/index.tsx index f1ca179167da..de6cfc58994a 100644 --- a/app/src/pages/RunningProtocol/index.tsx +++ b/app/src/pages/RunningProtocol/index.tsx @@ -156,7 +156,7 @@ export function RunningProtocol(): JSX.Element { ) : null} diff --git a/components/src/hardware-sim/Labware/LabwareAdapter/index.tsx b/components/src/hardware-sim/Labware/LabwareAdapter/index.tsx index 978fb7cbea37..fc05d8b5621d 100644 --- a/components/src/hardware-sim/Labware/LabwareAdapter/index.tsx +++ b/components/src/hardware-sim/Labware/LabwareAdapter/index.tsx @@ -4,6 +4,9 @@ import { Opentrons96FlatBottomAdapter } from './Opentrons96FlatBottomAdapter' import { OpentronsUniversalFlatAdapter } from './OpentronsUniversalFlatAdapter' import { OpentronsAluminumFlatBottomPlate } from './OpentronsAluminumFlatBottomPlate' import { OpentronsFlex96TiprackAdapter } from './OpentronsFlex96TiprackAdapter' +import { COLORS } from '../../../helix-design-system' +import { LabwareOutline } from '../labwareInternals' +import type { LabwareDefinition2 } from '@opentrons/shared-data' const LABWARE_ADAPTER_LOADNAME_PATHS = { opentrons_96_deep_well_adapter: Opentrons96DeepWellAdapter, @@ -20,13 +23,28 @@ export const labwareAdapterLoadNames = Object.keys( export interface LabwareAdapterProps { labwareLoadName: LabwareAdapterLoadName + definition?: LabwareDefinition2 + highlight?: boolean } export const LabwareAdapter = ( props: LabwareAdapterProps ): JSX.Element | null => { - const { labwareLoadName } = props + const { labwareLoadName, definition, highlight = false } = props + const highlightOutline = + highlight && definition != null ? ( + + ) : null const SVGElement = LABWARE_ADAPTER_LOADNAME_PATHS[labwareLoadName] - return + return ( + + + {highlightOutline} + + ) } diff --git a/components/src/hardware-sim/Labware/LabwareRender.tsx b/components/src/hardware-sim/Labware/LabwareRender.tsx index 41c2537a7d95..9137a2d2f15e 100644 --- a/components/src/hardware-sim/Labware/LabwareRender.tsx +++ b/components/src/hardware-sim/Labware/LabwareRender.tsx @@ -88,6 +88,8 @@ export const LabwareRender = (props: LabwareRenderProps): JSX.Element => { > diff --git a/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx b/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx index 7478c6711140..743743bd6c04 100644 --- a/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx +++ b/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx @@ -65,6 +65,7 @@ export function LabwareOutline(props: LabwareOutlineProps): JSX.Element { rx="8" ry="8" showRadius={showRadius} + fill={backgroundFill} /> None: if run_id == self._run_orchestrator_store.current_run_id: await self._run_orchestrator_store.clear() - await self._maintenance_runs_publisher.publish_current_maintenance_run() + await self._maintenance_runs_publisher.publish_current_maintenance_run_async() else: raise MaintenanceRunNotFoundError(run_id=run_id) diff --git a/robot-server/robot_server/protocols/router.py b/robot-server/robot_server/protocols/router.py index 563b8c21d6f0..ff6521b70d6e 100644 --- a/robot-server/robot_server/protocols/router.py +++ b/robot-server/robot_server/protocols/router.py @@ -195,9 +195,10 @@ class ProtocolLinks(BaseModel): responses={ status.HTTP_200_OK: {"model": SimpleBody[Protocol]}, status.HTTP_201_CREATED: {"model": SimpleBody[Protocol]}, - status.HTTP_400_BAD_REQUEST: {"model": ErrorBody[FileIdNotFound]}, status.HTTP_422_UNPROCESSABLE_ENTITY: { - "model": ErrorBody[Union[ProtocolFilesInvalid, ProtocolRobotTypeMismatch]] + "model": ErrorBody[ + Union[ProtocolFilesInvalid, ProtocolRobotTypeMismatch, FileIdNotFound] + ] }, status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorBody[LastAnalysisPending]}, }, @@ -331,7 +332,9 @@ async def create_protocol( # noqa: C901 for name, file_id in parsed_rtp_files.items() } except FileIdNotFoundError as e: - raise FileIdNotFound(detail=str(e)).as_error(status.HTTP_400_BAD_REQUEST) + raise FileIdNotFound(detail=str(e)).as_error( + status.HTTP_422_UNPROCESSABLE_ENTITY + ) content_hash = await file_hasher.hash(buffered_files) cached_protocol_id = protocol_store.get_id_by_hash(content_hash) @@ -705,8 +708,8 @@ async def delete_protocol_by_id( responses={ status.HTTP_200_OK: {"model": SimpleMultiBody[AnalysisSummary]}, status.HTTP_201_CREATED: {"model": SimpleMultiBody[AnalysisSummary]}, - status.HTTP_400_BAD_REQUEST: {"model": ErrorBody[FileIdNotFound]}, status.HTTP_404_NOT_FOUND: {"model": ErrorBody[ProtocolNotFound]}, + status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ErrorBody[FileIdNotFound]}, status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorBody[LastAnalysisPending]}, }, ) @@ -746,7 +749,9 @@ async def create_protocol_analysis( for name, file_id in rtp_files.items() } except FileIdNotFoundError as e: - raise FileIdNotFound(detail=str(e)).as_error(status.HTTP_400_BAD_REQUEST) + raise FileIdNotFound(detail=str(e)).as_error( + status.HTTP_422_UNPROCESSABLE_ENTITY + ) try: ( diff --git a/robot-server/robot_server/runs/light_control_task.py b/robot-server/robot_server/runs/light_control_task.py index 8a43af93321e..ed59f5cfaa3d 100644 --- a/robot-server/robot_server/runs/light_control_task.py +++ b/robot-server/robot_server/runs/light_control_task.py @@ -37,14 +37,14 @@ def _engine_status_to_status_bar( return StatusBarState.IDLE if initialization_done else StatusBarState.OFF case EngineStatus.RUNNING: return StatusBarState.RUNNING + case EngineStatus.PAUSED | EngineStatus.BLOCKED_BY_OPEN_DOOR: + return StatusBarState.PAUSED case ( - EngineStatus.PAUSED - | EngineStatus.BLOCKED_BY_OPEN_DOOR - | EngineStatus.AWAITING_RECOVERY + EngineStatus.AWAITING_RECOVERY | EngineStatus.AWAITING_RECOVERY_PAUSED | EngineStatus.AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR ): - return StatusBarState.PAUSED + return StatusBarState.ERROR_RECOVERY case EngineStatus.STOP_REQUESTED | EngineStatus.FINISHING: return StatusBarState.UPDATING case EngineStatus.STOPPED: diff --git a/robot-server/robot_server/runs/router/actions_router.py b/robot-server/robot_server/runs/router/actions_router.py index 6ceb6eadef64..f80d37e2319e 100644 --- a/robot-server/robot_server/runs/router/actions_router.py +++ b/robot-server/robot_server/runs/router/actions_router.py @@ -29,7 +29,12 @@ from robot_server.maintenance_runs.dependencies import ( get_maintenance_run_orchestrator_store, ) -from robot_server.service.notifications import get_runs_publisher, RunsPublisher +from robot_server.service.notifications import ( + get_runs_publisher, + get_maintenance_runs_publisher, + RunsPublisher, + MaintenanceRunsPublisher, +) log = logging.getLogger(__name__) actions_router = APIRouter() @@ -50,6 +55,7 @@ async def get_run_controller( ], run_store: Annotated[RunStore, Depends(get_run_store)], runs_publisher: Annotated[RunsPublisher, Depends(get_runs_publisher)], + maintenance_runs_publisher: Annotated[MaintenanceRunsPublisher, Depends(get_maintenance_runs_publisher)], ) -> RunController: """Get a RunController for the current run. @@ -73,6 +79,7 @@ async def get_run_controller( run_orchestrator_store=run_orchestrator_store, run_store=run_store, runs_publisher=runs_publisher, + maintenance_runs_publisher=maintenance_runs_publisher, ) diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py index fa4f1947a4fe..429097c24ab1 100644 --- a/robot-server/robot_server/runs/router/base_router.py +++ b/robot-server/robot_server/runs/router/base_router.py @@ -148,8 +148,8 @@ async def get_run_data_from_url( status_code=status.HTTP_201_CREATED, responses={ status.HTTP_201_CREATED: {"model": SimpleBody[Run]}, - status.HTTP_400_BAD_REQUEST: {"model": ErrorBody[FileIdNotFound]}, status.HTTP_404_NOT_FOUND: {"model": ErrorBody[ProtocolNotFound]}, + status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ErrorBody[FileIdNotFound]}, status.HTTP_409_CONFLICT: {"model": ErrorBody[RunAlreadyActive]}, }, ) @@ -208,7 +208,9 @@ async def create_run( # noqa: C901 for name, file_id in rtp_files.items() } except FileIdNotFoundError as e: - raise FileIdNotFound(detail=str(e)).as_error(status.HTTP_400_BAD_REQUEST) + raise FileIdNotFound(detail=str(e)).as_error( + status.HTTP_422_UNPROCESSABLE_ENTITY + ) protocol_resource = None diff --git a/robot-server/robot_server/runs/run_controller.py b/robot-server/robot_server/runs/run_controller.py index 84299c34dedf..7c8c0fe8b766 100644 --- a/robot-server/robot_server/runs/run_controller.py +++ b/robot-server/robot_server/runs/run_controller.py @@ -13,7 +13,7 @@ from opentrons.protocol_engine.types import DeckConfigurationType -from robot_server.service.notifications import RunsPublisher +from robot_server.service.notifications import RunsPublisher, MaintenanceRunsPublisher log = logging.getLogger(__name__) @@ -32,12 +32,14 @@ def __init__( run_orchestrator_store: RunOrchestratorStore, run_store: RunStore, runs_publisher: RunsPublisher, + maintenance_runs_publisher: MaintenanceRunsPublisher, ) -> None: self._run_id = run_id self._task_runner = task_runner self._run_orchestrator_store = run_orchestrator_store self._run_store = run_store self._runs_publisher = runs_publisher + self._maintenance_runs_publisher = maintenance_runs_publisher def create_action( self, @@ -80,6 +82,8 @@ def create_action( func=self._run_protocol_and_insert_result, deck_configuration=action_payload, ) + # Playing a protocol run terminates an existing maintenance run. + self._maintenance_runs_publisher.publish_current_maintenance_run() elif action_type == RunActionType.PAUSE: log.info(f'Pausing run "{self._run_id}".') diff --git a/robot-server/robot_server/service/notifications/publishers/maintenance_runs_publisher.py b/robot-server/robot_server/service/notifications/publishers/maintenance_runs_publisher.py index b1b7e44675c8..babfd9542345 100644 --- a/robot-server/robot_server/service/notifications/publishers/maintenance_runs_publisher.py +++ b/robot-server/robot_server/service/notifications/publishers/maintenance_runs_publisher.py @@ -18,7 +18,7 @@ def __init__(self, client: NotificationClient) -> None: """Returns a configured Maintenance Runs Publisher.""" self._client = client - async def publish_current_maintenance_run( + async def publish_current_maintenance_run_async( self, ) -> None: """Publishes the equivalent of GET /maintenance_run/current_run""" @@ -26,6 +26,12 @@ async def publish_current_maintenance_run( topic=topics.MAINTENANCE_RUNS_CURRENT_RUN ) + def publish_current_maintenance_run( + self, + ) -> None: + """Publishes the equivalent of GET /maintenance_run/current_run""" + self._client.publish_advise_refetch(topic=topics.MAINTENANCE_RUNS_CURRENT_RUN) + _maintenance_runs_publisher_accessor: AppStateAccessor[ MaintenanceRunsPublisher diff --git a/robot-server/tests/runs/test_run_controller.py b/robot-server/tests/runs/test_run_controller.py index a901c9881680..89aa79743a69 100644 --- a/robot-server/tests/runs/test_run_controller.py +++ b/robot-server/tests/runs/test_run_controller.py @@ -14,7 +14,7 @@ from opentrons.protocol_engine.types import RunTimeParameter, BooleanParameter from opentrons.protocol_runner import RunResult -from robot_server.service.notifications import RunsPublisher +from robot_server.service.notifications import RunsPublisher, MaintenanceRunsPublisher from robot_server.service.task_runner import TaskRunner from robot_server.runs.action_models import RunAction, RunActionType from robot_server.runs.run_orchestrator_store import RunOrchestratorStore @@ -48,6 +48,12 @@ def mock_runs_publisher(decoy: Decoy) -> RunsPublisher: return decoy.mock(cls=RunsPublisher) +@pytest.fixture() +def mock_maintenance_runs_publisher(decoy: Decoy) -> MaintenanceRunsPublisher: + """Get a mock RunsPublisher.""" + return decoy.mock(cls=MaintenanceRunsPublisher) + + @pytest.fixture def run_id() -> str: """A run identifier value.""" @@ -99,6 +105,7 @@ def subject( mock_run_store: RunStore, mock_task_runner: TaskRunner, mock_runs_publisher: RunsPublisher, + mock_maintenance_runs_publisher: MaintenanceRunsPublisher, ) -> RunController: """Get a RunController test subject.""" return RunController( @@ -107,6 +114,7 @@ def subject( run_store=mock_run_store, task_runner=mock_task_runner, runs_publisher=mock_runs_publisher, + maintenance_runs_publisher=mock_maintenance_runs_publisher, ) @@ -144,6 +152,7 @@ async def test_create_play_action_to_start( mock_run_store: RunStore, mock_task_runner: TaskRunner, mock_runs_publisher: RunsPublisher, + mock_maintenance_runs_publisher: MaintenanceRunsPublisher, engine_state_summary: StateSummary, run_time_parameters: List[RunTimeParameter], protocol_commands: List[pe_commands.Command], @@ -194,6 +203,12 @@ async def test_create_play_action_to_start( times=1, ) + # Verify maintenance run publication after background task execution + decoy.verify( + mock_maintenance_runs_publisher.publish_current_maintenance_run(), + times=1, + ) + def test_create_pause_action( decoy: Decoy, diff --git a/robot-server/tests/service/notifications/publishers/test_maintenance_runs_publisher.py b/robot-server/tests/service/notifications/publishers/test_maintenance_runs_publisher.py index fcc4cac5aac2..bfdbbd263121 100644 --- a/robot-server/tests/service/notifications/publishers/test_maintenance_runs_publisher.py +++ b/robot-server/tests/service/notifications/publishers/test_maintenance_runs_publisher.py @@ -24,7 +24,7 @@ async def test_publish_current_maintenance_run( notification_client: AsyncMock, maintenance_runs_publisher: MaintenanceRunsPublisher ) -> None: """It should publish a notify flag for maintenance runs.""" - await maintenance_runs_publisher.publish_current_maintenance_run() + await maintenance_runs_publisher.publish_current_maintenance_run_async() notification_client.publish_advise_refetch_async.assert_awaited_once_with( topic=topics.MAINTENANCE_RUNS_CURRENT_RUN )