diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aedf17b8b3f..e4460fecda9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -380,10 +380,10 @@ ssh-keygen # note the path you save the key to make -C robot-server install-key br_ssh_pubkey=/path/to/pubkey host=${some_other_ip_address} ``` -and subsequently, when you do `make term`, add the `br_ssh_key=/path/to/key` option: +and subsequently, when you do `make term`, add the `ssh_key=/path/to/key` option: ```shell -make term br_ssh_key=/path/to/privkey +make term ssh_key=/path/to/privkey ``` If you create the key as `~/.ssh/robot_key` and `~/.ssh/robot_key.pub` then `make term` and `make install-key` will work without arguments. diff --git a/api-client/src/instruments/types.ts b/api-client/src/instruments/types.ts index ae92cab333f..ec74c873045 100644 --- a/api-client/src/instruments/types.ts +++ b/api-client/src/instruments/types.ts @@ -1,4 +1,11 @@ export type InstrumentData = PipetteData | GripperData | BadPipette | BadGripper + +// pipettes module already exports type `Mount` +type Mount = 'left' | 'right' | 'extension' + +export interface SharedInstrumentData { + mount: Mount +} export interface GripperData { data: { jawState: string @@ -11,7 +18,7 @@ export interface GripperData { firmwareVersion?: string instrumentModel: string instrumentType: 'gripper' - mount: string + mount: 'extension' serialNumber: string subsystem: 'gripper' ok: true @@ -31,7 +38,7 @@ export interface PipetteData { instrumentName: string instrumentModel: string instrumentType: 'pipette' - mount: string + mount: 'left' | 'right' serialNumber: string subsystem: 'pipette_left' | 'pipette_right' ok: true diff --git a/api-client/src/protocols/getProtocolAnalysisAsDocument.ts b/api-client/src/protocols/getProtocolAnalysisAsDocument.ts new file mode 100644 index 00000000000..bdfc78aa2cd --- /dev/null +++ b/api-client/src/protocols/getProtocolAnalysisAsDocument.ts @@ -0,0 +1,18 @@ +import { GET, request } from '../request' + +import type { ResponsePromise } from '../request' +import type { HostConfig } from '../types' +import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' + +export function getProtocolAnalysisAsDocument( + config: HostConfig, + protocolId: string, + analysisId: string +): ResponsePromise { + return request( + GET, + `/protocols/${protocolId}/analyses/${analysisId}/asDocument`, + null, + config + ) +} diff --git a/api-client/src/protocols/index.ts b/api-client/src/protocols/index.ts index 58b790eadab..6febd0795cf 100644 --- a/api-client/src/protocols/index.ts +++ b/api-client/src/protocols/index.ts @@ -1,5 +1,6 @@ export { getProtocol } from './getProtocol' export { getProtocolAnalyses } from './getProtocolAnalyses' +export { getProtocolAnalysisAsDocument } from './getProtocolAnalysisAsDocument' export { deleteProtocol } from './deleteProtocol' export { createProtocol } from './createProtocol' export { getProtocols } from './getProtocols' diff --git a/api/Makefile b/api/Makefile index c850ff11b67..770a5026743 100755 --- a/api/Makefile +++ b/api/Makefile @@ -49,10 +49,12 @@ test_opts ?= pypi_username ?= pypi_password ?= -# Host key location for buildroot robot -br_ssh_key ?= $(default_ssh_key) -# Other SSH args for buildroot robots +# Host key location for robot +ssh_key ?= $(default_ssh_key) +# Other SSH args for robot ssh_opts ?= $(default_ssh_opts) +# Helper to safely bundle ssh options +ssh_helper = $(if $(ssh_key),-i $(ssh_key)) $(ssh_opts) twine_auth_args := --username $(pypi_username) --password $(pypi_password) twine_repository_url ?= $(pypi_test_upload_url) @@ -171,24 +173,23 @@ local-shell: .PHONY: push-no-restart push-no-restart: wheel - $(call push-python-package,$(host),$(br_ssh_key),$(ssh_opts),$(wheel_file)) + $(call push-python-package,$(host),$(ssh_key),$(ssh_opts),$(wheel_file)) .PHONY: push push: push-no-restart - $(call restart-service,$(host),$(br_ssh_key),$(ssh_opts),"jupyter-notebook opentrons-robot-server") + $(call restart-service,$(host),$(ssh_key),$(ssh_opts),"jupyter-notebook opentrons-robot-server") .PHONY: push-no-restart-ot3 push-no-restart-ot3: sdist echo $(sdist_file) - $(call push-python-sdist,$(host),,$(ssh_opts),$(sdist_file),/opt/opentrons-robot-server,opentrons,src,,$(version_file)) - ssh $(ssh_opts) root@$(host) "mount -o remount,rw / && mkdir -p /usr/local/bin" - scp $(ssh_opts) ./src/opentrons/hardware_control/scripts/ot3repl root@$(host):/usr/local/bin/ot3repl - scp $(ssh_opts) ./src/opentrons/hardware_control/scripts/ot3gripper root@$(host):/usr/local/bin/ot3gripper - ssh $(ssh_opts) root@$(host) "mount -o remount,ro /" + $(call push-python-sdist,$(host),$(ssh_key),$(ssh_opts),$(sdist_file),/opt/opentrons-robot-server,opentrons,src,,$(version_file)) + ssh $(ssh_helper) root@$(host) "mount -o remount,rw / && mkdir -p /usr/local/bin" + scp $(ssh_helper) ./src/opentrons/hardware_control/scripts/{ot3repl,ot3gripper} root@$(host):/usr/local/bin/ + ssh $(ssh_helper) root@$(host) "mount -o remount,ro /" .PHONY: push-ot3 push-ot3: push-no-restart-ot3 - $(call restart-server,$(host),,$(ssh_opts),"opentrons-robot-server") + $(call restart-server,$(host),$(host),$(ssh_opts),"opentrons-robot-server") .PHONY: simulate simulate: @@ -206,7 +207,7 @@ deploy: wheel # User must currently specify host, e.g.: `make term host=169.254.202.176` .PHONY: term term: - ssh -i $(br_ssh_key) $(ssh_opts) root@$(host) + ssh $(ssh_helper) root@$(host) .PHONY: plot-session plot-session: diff --git a/api/mypy.ini b/api/mypy.ini index bd85666af02..56b8435855c 100644 --- a/api/mypy.ini +++ b/api/mypy.ini @@ -14,8 +14,8 @@ warn_untyped_fields = True # TODO(mc, 2021-09-08): fix and remove any / all of the # overrides below whenever able -# ~125 errors -[mypy-opentrons.protocols.advanced_control.*,opentrons.protocols.api_support.*,opentrons.protocols.duration.*,opentrons.protocols.execution.*,opentrons.protocols.geometry.*,opentrons.protocols.models.*,opentrons.protocols.labware.*] +# ~115 errors +[mypy-opentrons.protocols.advanced_control.*,opentrons.protocols.api_support.*,opentrons.protocols.duration.*,opentrons.protocols.execution.dev_types,opentrons.protocols.execution.execute_json_v4,opentrons.protocols.execution.execute_python,opentrons.protocols.geometry.*,opentrons.protocols.models.*,opentrons.protocols.labware.*] disallow_any_generics = False disallow_untyped_defs = False disallow_untyped_calls = False diff --git a/api/release-notes.md b/api/release-notes.md index 9c55063f61b..e893c583edb 100644 --- a/api/release-notes.md +++ b/api/release-notes.md @@ -10,6 +10,8 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr Welcome to the v7.0.0 release of the Opentrons robot software! This release adds support for the Opentrons Flex robot, instruments, modules, and labware. +This update may take longer than usual. Allow **approximately 15 minutes** for your robot to restart. This delay will only happen once. + ### New Features - Flex touchscreen diff --git a/api/src/opentrons/calibration_storage/ot2/pipette_offset.py b/api/src/opentrons/calibration_storage/ot2/pipette_offset.py index 83616a3737a..ac09a736b4e 100644 --- a/api/src/opentrons/calibration_storage/ot2/pipette_offset.py +++ b/api/src/opentrons/calibration_storage/ot2/pipette_offset.py @@ -88,12 +88,13 @@ def get_pipette_offset( **io.read_cal_file(pipette_calibration_filepath) ) except FileNotFoundError: - log.warning(f"Calibrations for {pipette_id} on {mount} does not exist.") + log.debug(f"Calibrations for {pipette_id} on {mount} does not exist.") return None except (json.JSONDecodeError, ValidationError): log.warning( f"Malformed calibrations for {pipette_id} on {mount}. Please factory reset your calibrations." ) + # TODO: Delete the bad calibration here maybe? return None diff --git a/api/src/opentrons/calibration_storage/ot2/tip_length.py b/api/src/opentrons/calibration_storage/ot2/tip_length.py index d2be974b12b..eca8f723f09 100644 --- a/api/src/opentrons/calibration_storage/ot2/tip_length.py +++ b/api/src/opentrons/calibration_storage/ot2/tip_length.py @@ -50,7 +50,7 @@ def tip_lengths_for_pipette( pass return tip_lengths except FileNotFoundError: - log.warning(f"Tip length calibrations not found for {pipette_id}") + log.debug(f"Tip length calibrations not found for {pipette_id}") return tip_lengths diff --git a/api/src/opentrons/calibration_storage/ot3/pipette_offset.py b/api/src/opentrons/calibration_storage/ot3/pipette_offset.py index f7f25ccc384..fcd53bbbf3e 100644 --- a/api/src/opentrons/calibration_storage/ot3/pipette_offset.py +++ b/api/src/opentrons/calibration_storage/ot3/pipette_offset.py @@ -85,7 +85,7 @@ def get_pipette_offset( **io.read_cal_file(pipette_calibration_filepath) ) except FileNotFoundError: - log.warning(f"Calibrations for {pipette_id} on {mount} does not exist.") + log.debug(f"Calibrations for {pipette_id} on {mount} does not exist.") return None except (json.JSONDecodeError, ValidationError): log.warning( diff --git a/api/src/opentrons/calibration_storage/ot3/tip_length.py b/api/src/opentrons/calibration_storage/ot3/tip_length.py index 9525aafd86e..638560851aa 100644 --- a/api/src/opentrons/calibration_storage/ot3/tip_length.py +++ b/api/src/opentrons/calibration_storage/ot3/tip_length.py @@ -37,19 +37,22 @@ def tip_lengths_for_pipette( ) -> typing.Dict[str, v1.TipLengthModel]: tip_lengths = {} try: + # While you technically could drop some data in for tip length calibration on the flex, + # it is not necessary and there is no UI frontend for it, so this code will mostly be + # taking the FileNotFoundError path. tip_length_filepath = config.get_tip_length_cal_path() / f"{pipette_id}.json" all_tip_lengths_for_pipette = io.read_cal_file(tip_length_filepath) for tiprack, data in all_tip_lengths_for_pipette.items(): try: tip_lengths[tiprack] = v1.TipLengthModel(**data) except (json.JSONDecodeError, ValidationError): - log.warning( + log.debug( f"Tip length calibration is malformed for {tiprack} on {pipette_id}" ) pass return tip_lengths except FileNotFoundError: - log.warning(f"Tip length calibrations not found for {pipette_id}") + # this is the overwhelmingly common case return tip_lengths diff --git a/api/src/opentrons/config/advanced_settings.py b/api/src/opentrons/config/advanced_settings.py index d0e121239a4..97629fcd2e9 100644 --- a/api/src/opentrons/config/advanced_settings.py +++ b/api/src/opentrons/config/advanced_settings.py @@ -51,6 +51,7 @@ def __init__( restart_required: bool = False, show_if: Optional[Tuple[str, bool]] = None, internal_only: bool = False, + default_true_on_robot_types: Optional[List[RobotTypeEnum]] = None, ): self.id = _id #: The id of the setting for programmatic access through @@ -70,6 +71,8 @@ def __init__( #: A list of RobotTypeEnums that are compatible with this feature flag. self.internal_only = internal_only #: A flag determining whether this setting is user-facing. + self.default_true_on_robot_types = default_true_on_robot_types or [] + #: Robot types for which null/unset means the setting is activated def __repr__(self) -> str: return "{}: {}".format(self.__class__, self.id) @@ -152,7 +155,7 @@ class Setting(NamedTuple): old_id="disable-home-on-boot", title="Disable home on boot", description="Prevent robot from homing motors on boot", - robot_type=[RobotTypeEnum.OT2], + robot_type=[RobotTypeEnum.OT2, RobotTypeEnum.FLEX], ), SettingDefinition( _id="useOldAspirationFunctions", @@ -171,6 +174,7 @@ class Setting(NamedTuple): "pause your robot only after it has completed its " "current motion.", robot_type=[RobotTypeEnum.OT2], + default_true_on_robot_types=[RobotTypeEnum.FLEX], ), SettingDefinition( _id="disableFastProtocolUpload", @@ -253,6 +257,14 @@ class Setting(NamedTuple): } +def get_setting_definition(setting_id: str) -> Optional[SettingDefinition]: + clean = _clean_id(setting_id) + for setting in settings: + if setting.id == clean: + return setting + return None + + def get_adv_setting(setting: str, robot_type: RobotTypeEnum) -> Optional[Setting]: setting = _clean_id(setting) s = get_all_adv_settings(robot_type, include_internal=True) @@ -749,11 +761,18 @@ def _ensure(data: Mapping[str, Any]) -> SettingsMap: def get_setting_with_env_overload(setting_name: str, robot_type: RobotTypeEnum) -> bool: env_name = "OT_API_FF_" + setting_name + defn = get_setting_definition(setting_name) if env_name in os.environ: return os.environ[env_name].lower() in {"1", "true", "on"} else: s = get_adv_setting(setting_name, robot_type) - return s.value is True if s is not None else False + if s is not None: + return s.value is True + if defn is None: + return False + if robot_type in defn.default_true_on_robot_types: + return True + return False _SETTINGS_RESTART_REQUIRED = False diff --git a/api/src/opentrons/execute.py b/api/src/opentrons/execute.py index 75add87a4d6..38a8db44734 100644 --- a/api/src/opentrons/execute.py +++ b/api/src/opentrons/execute.py @@ -41,6 +41,7 @@ ThreadManagedHardware, ThreadManager, ) +from opentrons.protocol_engine.types import PostRunHardwareState from opentrons.protocols import parse from opentrons.protocols.api_support.deck_type import ( @@ -572,7 +573,8 @@ def _create_live_context_pe( create_protocol_engine_in_thread( hardware_api=hardware_api.wrapped(), config=_get_protocol_engine_config(), - drop_tips_and_home_after=False, + drop_tips_after_run=False, + post_run_hardware_state=PostRunHardwareState.STAY_ENGAGED_IN_PLACE, ) ) diff --git a/api/src/opentrons/hardware_control/api.py b/api/src/opentrons/hardware_control/api.py index e612195f831..d1ee331e486 100644 --- a/api/src/opentrons/hardware_control/api.py +++ b/api/src/opentrons/hardware_control/api.py @@ -490,14 +490,15 @@ async def _chained_calls() -> None: asyncio.run_coroutine_threadsafe(_chained_calls(), self._loop) - async def halt(self) -> None: + async def halt(self, disengage_before_stopping: bool = False) -> None: """Immediately stop motion, cancel execution manager and cancel running tasks. After this call, the smoothie will be in a bad state until a call to :py:meth:`stop`. """ - await self._backend.hard_halt() - await self._execution_manager.cancel() + if disengage_before_stopping: + await self._backend.hard_halt() + await self._backend.halt() async def stop(self, home_after: bool = True) -> None: """ @@ -508,6 +509,7 @@ async def stop(self, home_after: bool = True) -> None: robot. After this call, no further recovery is necessary. """ await self._backend.halt() # calls smoothie_driver.kill() + await self._execution_manager.cancel() self._log.info("Recovering from halt") await self.reset() await self.cache_instruments() @@ -988,6 +990,7 @@ async def dispense( mount: top_types.Mount, volume: Optional[float] = None, rate: float = 1.0, + push_out: Optional[float] = None, ) -> None: """ Dispense a volume of liquid in microliters(uL) using this pipette. diff --git a/api/src/opentrons/hardware_control/backends/ot3controller.py b/api/src/opentrons/hardware_control/backends/ot3controller.py index 8a82c2ad9a8..b2a597ce340 100644 --- a/api/src/opentrons/hardware_control/backends/ot3controller.py +++ b/api/src/opentrons/hardware_control/backends/ot3controller.py @@ -45,6 +45,7 @@ LIMIT_SWITCH_OVERTRAVEL_DISTANCE, map_pipette_type_to_sensor_id, moving_axes_in_move_group, + gripper_jaw_state_from_fw, ) try: @@ -127,6 +128,7 @@ TipStateType, FailedTipStateCheck, EstopState, + GripperJawState, ) from opentrons.hardware_control.errors import ( InvalidPipetteName, @@ -152,6 +154,9 @@ set_deck_light, get_deck_light_state, ) +from opentrons_hardware.hardware_control.gripper_settings import ( + get_gripper_jaw_state, +) from opentrons_hardware.drivers.gpio import OT3GPIO, RemoteOT3GPIO from opentrons_shared_data.pipette.dev_types import PipetteName @@ -732,6 +737,10 @@ async def gripper_home_jaw(self, duty_cycle: float) -> None: positions = await runner.run(can_messenger=self._messenger) self._handle_motor_status_response(positions) + async def get_jaw_state(self) -> GripperJawState: + res = await get_gripper_jaw_state(self._messenger) + return gripper_jaw_state_from_fw(res) + @staticmethod def _lookup_serial_key(pipette_name: FirmwarePipetteName) -> str: lookup_name = { diff --git a/api/src/opentrons/hardware_control/backends/ot3simulator.py b/api/src/opentrons/hardware_control/backends/ot3simulator.py index 15886c15040..093342952c4 100644 --- a/api/src/opentrons/hardware_control/backends/ot3simulator.py +++ b/api/src/opentrons/hardware_control/backends/ot3simulator.py @@ -59,6 +59,7 @@ SubSystem, SubSystemState, TipStateType, + GripperJawState, ) from opentrons_hardware.hardware_control.motion import MoveStopCondition from opentrons_hardware.hardware_control import status_bar @@ -187,6 +188,7 @@ def _sanitize_attached_instrument( nodes.add(NodeId.gripper) self._present_nodes = nodes self._current_settings: Optional[OT3AxisMap[CurrentConfig]] = None + self._sim_jaw_state = GripperJawState.HOMED_READY @property def initialized(self) -> bool: @@ -368,12 +370,14 @@ async def gripper_grip_jaw( ) -> None: """Move gripper inward.""" _ = create_gripper_jaw_grip_group(duty_cycle, stop_condition, stay_engaged) + self._sim_jaw_state = GripperJawState.GRIPPING @ensure_yield async def gripper_home_jaw(self, duty_cycle: float) -> None: """Move gripper outward.""" _ = create_gripper_jaw_home_group(duty_cycle) self._motor_status[NodeId.gripper_g] = MotorStatus(True, True) + self._sim_jaw_state = GripperJawState.HOMED_READY @ensure_yield async def gripper_hold_jaw( @@ -382,6 +386,7 @@ async def gripper_hold_jaw( ) -> None: _ = create_gripper_jaw_hold_group(encoder_position_um) self._encoder_position[NodeId.gripper_g] = encoder_position_um / 1000.0 + self._sim_jaw_state = GripperJawState.HOLDING async def get_tip_present(self, mount: OT3Mount, tip_state: TipStateType) -> None: """Raise an error if the given state doesn't match the physical state.""" @@ -391,6 +396,10 @@ async def get_tip_present_state(self, mount: OT3Mount) -> int: """Get the state of the tip ejector flag for a given mount.""" pass + async def get_jaw_state(self) -> GripperJawState: + """Get the state of the gripper jaw.""" + return self._sim_jaw_state + async def tip_action( self, moves: Optional[List[Move[Axis]]] = None, diff --git a/api/src/opentrons/hardware_control/backends/ot3utils.py b/api/src/opentrons/hardware_control/backends/ot3utils.py index f7e6bd3a960..61f2699ccb5 100644 --- a/api/src/opentrons/hardware_control/backends/ot3utils.py +++ b/api/src/opentrons/hardware_control/backends/ot3utils.py @@ -15,6 +15,7 @@ PipetteSubType, UpdateState, UpdateStatus, + GripperJawState, ) import numpy as np @@ -25,6 +26,7 @@ SensorId, PipetteTipActionType, USBTarget, + GripperJawState as FirmwareGripperjawState, ) from opentrons_hardware.firmware_update.types import FirmwareUpdateStatus, StatusElement from opentrons_hardware.hardware_control import network @@ -631,3 +633,15 @@ def update( progress = int(progress * 100) self._tracker[target] = UpdateStatus(subsystem, state, progress) return set(self._tracker.values()) + + +_gripper_jaw_state_lookup: Dict[FirmwareGripperjawState, GripperJawState] = { + FirmwareGripperjawState.unhomed: GripperJawState.UNHOMED, + FirmwareGripperjawState.force_controlling_home: GripperJawState.HOMED_READY, + FirmwareGripperjawState.force_controlling: GripperJawState.GRIPPING, + FirmwareGripperjawState.position_controlling: GripperJawState.HOLDING, +} + + +def gripper_jaw_state_from_fw(state: FirmwareGripperjawState) -> GripperJawState: + return _gripper_jaw_state_lookup[state] diff --git a/api/src/opentrons/hardware_control/backends/subsystem_manager.py b/api/src/opentrons/hardware_control/backends/subsystem_manager.py index ea3c3d15b83..130f265b0f9 100644 --- a/api/src/opentrons/hardware_control/backends/subsystem_manager.py +++ b/api/src/opentrons/hardware_control/backends/subsystem_manager.py @@ -265,7 +265,7 @@ async def update_firmware( can_messenger=self._can_messenger, usb_messenger=self._usb_messenger, update_details=update_details, - retry_count=60, + retry_count=3, timeout_seconds=5, erase=True, ) diff --git a/api/src/opentrons/hardware_control/dev_types.py b/api/src/opentrons/hardware_control/dev_types.py index b37d680c565..a7330675f0c 100644 --- a/api/src/opentrons/hardware_control/dev_types.py +++ b/api/src/opentrons/hardware_control/dev_types.py @@ -93,6 +93,7 @@ class PipetteDict(InstrumentDict): ready_to_aspirate: bool has_tip: bool default_blow_out_volume: float + default_push_out_volume: Optional[float] supported_tips: Dict[PipetteTipType, SupportedTipsDefinition] diff --git a/api/src/opentrons/hardware_control/instruments/ot2/pipette.py b/api/src/opentrons/hardware_control/instruments/ot2/pipette.py index 144558fdacc..b56b15ef978 100644 --- a/api/src/opentrons/hardware_control/instruments/ot2/pipette.py +++ b/api/src/opentrons/hardware_control/instruments/ot2/pipette.py @@ -125,15 +125,7 @@ def __init__( pip_types.PipetteTipType(self._liquid_class.max_volume) ] self._fallback_tip_length = self._active_tip_settings.default_tip_length - self._aspirate_flow_rates_lookup = ( - self._active_tip_settings.default_aspirate_flowrate.values_by_api_level - ) - self._dispense_flow_rates_lookup = ( - self._active_tip_settings.default_dispense_flowrate.values_by_api_level - ) - self._blowout_flow_rates_lookup = ( - self._active_tip_settings.default_blowout_flowrate.values_by_api_level - ) + self._aspirate_flow_rate = ( self._active_tip_settings.default_aspirate_flowrate.default ) @@ -426,15 +418,15 @@ def blow_out_flow_rate(self, new_flow_rate: float) -> None: @property def aspirate_flow_rates_lookup(self) -> Dict[str, float]: - return self._aspirate_flow_rates_lookup + return self._active_tip_settings.default_aspirate_flowrate.values_by_api_level @property def dispense_flow_rates_lookup(self) -> Dict[str, float]: - return self._dispense_flow_rates_lookup + return self._active_tip_settings.default_dispense_flowrate.values_by_api_level @property def blow_out_flow_rates_lookup(self) -> Dict[str, float]: - return self._blowout_flow_rates_lookup + return self.active_tip_settings.default_blowout_flowrate.values_by_api_level @property def working_volume(self) -> float: @@ -545,9 +537,9 @@ def as_dict(self) -> "Pipette.DictType": "aspirate_flow_rate": self.aspirate_flow_rate, "dispense_flow_rate": self.dispense_flow_rate, "blow_out_flow_rate": self.blow_out_flow_rate, - "default_aspirate_flow_rates": self.active_tip_settings.default_aspirate_flowrate.values_by_api_level, - "default_blow_out_flow_rates": self.active_tip_settings.default_blowout_flowrate.values_by_api_level, - "default_dispense_flow_rates": self.active_tip_settings.default_dispense_flowrate.values_by_api_level, + "default_aspirate_flow_rates": self.aspirate_flow_rates_lookup, + "default_blow_out_flow_rates": self.blow_out_flow_rates_lookup, + "default_dispense_flow_rates": self.dispense_flow_rates_lookup, "tip_length": self.current_tip_length, "return_tip_height": self.active_tip_settings.default_return_tip_height, "tip_overlap": self.tip_overlap, diff --git a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py index dc29a8ccc4c..ca085629b3b 100644 --- a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py @@ -698,7 +698,6 @@ def plan_check_pick_up_tip( # type: ignore[no-untyped-def] presses, increment, ): - # Prechecks: ready for pickup tip and press/increment are valid instrument = self.get_pipette(mount) if instrument.has_tip: diff --git a/api/src/opentrons/hardware_control/instruments/ot3/gripper.py b/api/src/opentrons/hardware_control/instruments/ot3/gripper.py index a59a60daa31..1737f5860ca 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/gripper.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/gripper.py @@ -121,6 +121,10 @@ def current_jaw_displacement(self, mm: float) -> None: def default_grip_force(self) -> float: return self.grip_force_profile.default_grip_force + @property + def default_idle_force(self) -> float: + return self.grip_force_profile.default_idle_force + @property def default_home_force(self) -> float: return self.grip_force_profile.default_home_force diff --git a/api/src/opentrons/hardware_control/instruments/ot3/gripper_handler.py b/api/src/opentrons/hardware_control/instruments/ot3/gripper_handler.py index b6dca3318eb..e7235c93d76 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/gripper_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/gripper_handler.py @@ -1,5 +1,6 @@ from typing import Optional import logging +import math from opentrons.types import Point from .instrument_calibration import ( @@ -137,6 +138,24 @@ def check_ready_for_jaw_move(self) -> None: if gripper.state == GripperJawState.UNHOMED: raise GripError("Gripper jaw must be homed before moving") + def is_ready_for_idle(self) -> bool: + """Raise an exception if it is not currently valid to idle the jaw.""" + gripper = self.get_gripper() + if gripper.state == GripperJawState.UNHOMED: + self._log.warning( + "Gripper jaw is not homed and cannot move to idle position." + ) + return gripper.state == GripperJawState.GRIPPING + + def is_ready_for_jaw_home(self) -> bool: + """Raise an exception if it is not currently valid to home the jaw.""" + gripper = self.get_gripper() + if gripper.state == GripperJawState.GRIPPING and not math.isclose( + gripper.jaw_width, gripper.geometry.jaw_width["min"], abs_tol=5.0 + ): + return False + return True + def set_jaw_state(self, state: GripperJawState) -> None: self.get_gripper().state = state diff --git a/api/src/opentrons/hardware_control/instruments/ot3/instrument_calibration.py b/api/src/opentrons/hardware_control/instruments/ot3/instrument_calibration.py index d7f6926ea80..c5fe989c2dd 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/instrument_calibration.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/instrument_calibration.py @@ -10,10 +10,12 @@ from opentrons.types import Point, Mount # TODO change this when imports are fixed -from opentrons.calibration_storage.ot3.pipette_offset import save_pipette_calibration +from opentrons.calibration_storage.ot3.pipette_offset import ( + save_pipette_calibration, + get_pipette_offset, +) from opentrons.calibration_storage import ( types as cal_top_types, - get_pipette_offset, ot3_gripper_offset, ) from opentrons.hardware_control.types import OT3Mount diff --git a/api/src/opentrons/hardware_control/instruments/ot3/pipette.py b/api/src/opentrons/hardware_control/instruments/ot3/pipette.py index 62ceb29ebd2..7e1b0518527 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/pipette.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/pipette.py @@ -111,16 +111,6 @@ def __init__( ] self._fallback_tip_length = self._active_tip_settings.default_tip_length - self._aspirate_flow_rates_lookup = ( - self._active_tip_settings.default_aspirate_flowrate.values_by_api_level - ) - self._dispense_flow_rates_lookup = ( - self._active_tip_settings.default_dispense_flowrate.values_by_api_level - ) - self._blowout_flow_rates_lookup = ( - self._active_tip_settings.default_blowout_flowrate.values_by_api_level - ) - self._aspirate_flow_rate = ( self._active_tip_settings.default_aspirate_flowrate.default ) @@ -131,6 +121,7 @@ def __init__( self._active_tip_settings.default_blowout_flowrate.default ) self._flow_acceleration = self._active_tip_settings.default_flow_acceleration + self._push_out_volume = self._active_tip_settings.default_push_out_volume self._tip_overlap_lookup = self._liquid_class.tip_overlap_dictionary @@ -240,6 +231,7 @@ def reset_state(self) -> None: self._active_tip_settings.default_blowout_flowrate.default ) self._flow_acceleration = self._active_tip_settings.default_flow_acceleration + self._push_out_volume = self._active_tip_settings.default_push_out_volume self._tip_overlap_lookup = self.liquid_class.tip_overlap_dictionary @@ -424,15 +416,15 @@ def flow_acceleration(self, new_flow_acceleration: float) -> None: @property def aspirate_flow_rates_lookup(self) -> Dict[str, float]: - return self._aspirate_flow_rates_lookup + return self._active_tip_settings.default_aspirate_flowrate.values_by_api_level @property def dispense_flow_rates_lookup(self) -> Dict[str, float]: - return self._dispense_flow_rates_lookup + return self._active_tip_settings.default_dispense_flowrate.values_by_api_level @property def blow_out_flow_rates_lookup(self) -> Dict[str, float]: - return self._blowout_flow_rates_lookup + return self._active_tip_settings.default_blowout_flowrate.values_by_api_level @property def working_volume(self) -> float: @@ -548,9 +540,9 @@ def as_dict(self) -> "Pipette.DictType": "dispense_flow_rate": self.dispense_flow_rate, "blow_out_flow_rate": self.blow_out_flow_rate, "flow_acceleration": self.flow_acceleration, - "default_aspirate_flow_rates": self.active_tip_settings.default_aspirate_flowrate.values_by_api_level, - "default_blow_out_flow_rates": self.active_tip_settings.default_blowout_flowrate.values_by_api_level, - "default_dispense_flow_rates": self.active_tip_settings.default_dispense_flowrate.values_by_api_level, + "default_aspirate_flow_rates": self.aspirate_flow_rates_lookup, + "default_blow_out_flow_rates": self.blow_out_flow_rates_lookup, + "default_dispense_flow_rates": self.dispense_flow_rates_lookup, "default_flow_acceleration": self.active_tip_settings.default_flow_acceleration, "tip_length": self.current_tip_length, "return_tip_height": self.active_tip_settings.default_return_tip_height, diff --git a/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py index ec55bdd671b..28b51ca2c0d 100644 --- a/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py @@ -269,6 +269,9 @@ def get_attached_instrument(self, mount: OT3Mount) -> PipetteDict: result[ "default_blow_out_volume" ] = instr.active_tip_settings.default_blowout_volume + result[ + "default_push_out_volume" + ] = instr.active_tip_settings.default_push_out_volume return cast(PipetteDict, result) @property @@ -463,13 +466,13 @@ def plunger_position( def plunger_speed( self, instr: Pipette, ul_per_s: float, action: "UlPerMmAction" ) -> float: - mm_per_s = ul_per_s / instr.ul_per_mm(instr.liquid_class.max_volume, action) + mm_per_s = ul_per_s / instr.ul_per_mm(instr.working_volume, action) return round(mm_per_s, 6) def plunger_flowrate( self, instr: Pipette, mm_per_s: float, action: "UlPerMmAction" ) -> float: - ul_per_s = mm_per_s * instr.ul_per_mm(instr.liquid_class.max_volume, action) + ul_per_s = mm_per_s * instr.ul_per_mm(instr.working_volume, action) return round(ul_per_s, 6) def plunger_acceleration(self, instr: Pipette, ul_per_s_per_s: float) -> float: diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 9456fdcf7b7..897b5788c8e 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -32,8 +32,8 @@ from opentrons_shared_data.pipette import ( pipette_load_name_conversions as pipette_load_name, ) -from opentrons_shared_data.gripper.constants import IDLE_STATE_GRIP_FORCE from opentrons_shared_data.robot.dev_types import RobotType +from opentrons_shared_data.errors.exceptions import StallOrCollisionDetectedError from opentrons import types as top_types from opentrons.config import robot_configs, feature_flags as ff @@ -91,7 +91,6 @@ OT3AxisKind, OT3Mount, OT3AxisMap, - GripperJawState, InstrumentProbeType, GripperProbe, UpdateStatus, @@ -606,10 +605,8 @@ async def cache_instruments( and set up hardware controller internal state if necessary. """ skip_configure = await self._cache_instruments(require) - self._log.info( - f"Instrument model cache updated, skip configure: {skip_configure}" - ) if not skip_configure: + self._log.info("Instrument model cache updated, reconfiguring") await self._configure_instruments() async def _cache_instruments( # noqa: C901 @@ -743,23 +740,28 @@ async def _cancel_execution_and_running_tasks(self) -> None: """Cancel execution manager and all running (hardware module) tasks.""" await self._execution_manager.cancel() - async def halt(self) -> None: + async def halt(self, disengage_before_stopping: bool = False) -> None: """Immediately disengage all present motors and clear motor and module tasks.""" - # TODO (spp, 2023-08-22): check if disengaging motors is really required - await self.disengage_axes( - [ax for ax in Axis if self._backend.axis_is_present(ax)] - ) + if disengage_before_stopping: + await self.disengage_axes( + [ax for ax in Axis if self._backend.axis_is_present(ax) if ax != Axis.G] + ) await self._stop_motors() - await self._cancel_execution_and_running_tasks() async def stop(self, home_after: bool = True) -> None: """Stop motion as soon as possible, reset, and optionally home.""" await self._stop_motors() - + await self._cancel_execution_and_running_tasks() self._log.info("Resetting OT3API") await self.reset() if home_after: - await self.home() + skip = [] + if ( + self._gripper_handler.has_gripper() + and not self._gripper_handler.is_ready_for_jaw_home() + ): + skip.append(Axis.G) + await self.home(skip=skip) async def reset(self) -> None: """Reset the stored state of the system.""" @@ -792,11 +794,11 @@ async def home_gripper_jaw(self) -> None: try: gripper = self._gripper_handler.get_gripper() self._log.info("Homing gripper jaw.") + dc = self._gripper_handler.get_duty_cycle_by_grip_force( gripper.default_home_force ) await self._ungrip(duty_cycle=dc) - gripper.state = GripperJawState.HOMED_READY except GripperNotAttachedError: pass @@ -862,6 +864,14 @@ async def refresh_positions(self) -> None: await self._backend.update_motor_status() await self._cache_current_position() await self._cache_encoder_position() + await self._refresh_jaw_state() + + async def _refresh_jaw_state(self) -> None: + try: + gripper = self._gripper_handler.get_gripper() + gripper.state = await self._backend.get_jaw_state() + except GripperNotAttachedError: + pass async def _cache_current_position(self) -> Dict[Axis, float]: """Cache current position from backend and return in absolute deck coords.""" @@ -1023,7 +1033,6 @@ async def move_to( checked_max = None await self._cache_and_maybe_retract_mount(realmount) - await self._move_gripper_to_idle_position(realmount) await self._move( target_position, speed=speed, @@ -1124,7 +1133,6 @@ async def move_rel( else: checked_max = None await self._cache_and_maybe_retract_mount(realmount) - await self._move_gripper_to_idle_position(realmount) await self._move( target_position, speed=speed, @@ -1143,28 +1151,21 @@ async def _cache_and_maybe_retract_mount(self, mount: OT3Mount) -> None: """ if mount != self._last_moved_mount and self._last_moved_mount: await self.retract(self._last_moved_mount, 10) + if mount != OT3Mount.GRIPPER: + await self.idle_gripper() self._last_moved_mount = mount - async def _move_gripper_to_idle_position(self, mount_in_use: OT3Mount) -> None: - """Move gripper to its idle, gripped position. - - If the gripper is not currently in use, puts its jaws in a low-current, - gripped position. Experimental behavior in order to prevent gripper jaws - from colliding into thermocycler lid & lid latch clips. - """ - # TODO: see https://opentrons.atlassian.net/browse/RLAB-214 - if ( - self._gripper_handler.gripper - and mount_in_use != OT3Mount.GRIPPER - and self._gripper_handler.gripper.state != GripperJawState.GRIPPING - ): - if self._gripper_handler.gripper.state == GripperJawState.UNHOMED: - self._log.warning( - "Gripper jaw is not homed. Can't be moved to idle position" + async def idle_gripper(self) -> None: + """Move gripper to its idle, gripped position.""" + try: + gripper = self._gripper_handler.get_gripper() + if self._gripper_handler.is_ready_for_idle(): + await self.grip( + force_newtons=gripper.default_idle_force, + stay_engaged=False, ) - else: - # allows for safer gantry movement at minimum force - await self.grip(force_newtons=IDLE_STATE_GRIP_FORCE) + except GripperNotAttachedError: + pass def _build_moves( self, @@ -1288,11 +1289,17 @@ async def _retrieve_home_position() -> Tuple[ if origin[axis] - target_pos[axis] > axis_home_dist: target_pos[axis] += axis_home_dist moves = self._build_moves(origin, target_pos) - await self._backend.move( - origin, - moves[0], - MoveStopCondition.none, - ) + try: + await self._backend.move( + origin, + moves[0], + MoveStopCondition.none, + ) + except StallOrCollisionDetectedError: + self._log.warning( + f"Stall on {axis} during fast home, encoder may have missed an overflow" + ) + await self._backend.home([axis], self.gantry_load) else: # both stepper and encoder positions are invalid, must home @@ -1321,7 +1328,11 @@ async def _home(self, axes: Sequence[Axis]) -> None: await self._cache_encoder_position() @ExecutionManagerProvider.wait_for_running - async def home(self, axes: Optional[List[Axis]] = None) -> None: + async def home( + self, + axes: Optional[List[Axis]] = None, + skip: Optional[List[Axis]] = None, + ) -> None: """ Worker function to home the robot by axis or list of desired axes. @@ -1335,6 +1346,9 @@ async def home(self, axes: Optional[List[Axis]] = None) -> None: checked_axes = [ax for ax in Axis if ax != Axis.Q] if self.gantry_load == GantryLoad.HIGH_THROUGHPUT: checked_axes.append(Axis.Q) + + if skip: + checked_axes = [ax for ax in checked_axes if ax not in skip] self._log.info(f"Homing {axes}") home_seq = [ @@ -1445,6 +1459,7 @@ async def _grip(self, duty_cycle: float, stay_engaged: bool = True) -> None: duty_cycle=duty_cycle, stay_engaged=stay_engaged ) await self._cache_encoder_position() + self._gripper_handler.set_jaw_state(await self._backend.get_jaw_state()) except Exception: self._log.exception( f"Gripper grip failed, encoder pos: {self._encoder_position[Axis.G]}" @@ -1457,6 +1472,7 @@ async def _ungrip(self, duty_cycle: float) -> None: try: await self._backend.gripper_home_jaw(duty_cycle=duty_cycle) await self._cache_encoder_position() + self._gripper_handler.set_jaw_state(await self._backend.get_jaw_state()) except Exception: self._log.exception("Gripper home failed") raise @@ -1472,6 +1488,7 @@ async def _hold_jaw_width(self, jaw_width_mm: float) -> None: jaw_displacement_mm = (width_max - jaw_width_mm) / 2.0 await self._backend.gripper_hold_jaw(int(1000 * jaw_displacement_mm)) await self._cache_encoder_position() + self._gripper_handler.set_jaw_state(await self._backend.get_jaw_state()) except Exception: self._log.exception("Gripper set width failed") raise @@ -1484,21 +1501,24 @@ async def grip( force_newtons or self._gripper_handler.get_gripper().default_grip_force ) await self._grip(duty_cycle=dc, stay_engaged=stay_engaged) - self._gripper_handler.set_jaw_state(GripperJawState.GRIPPING) async def ungrip(self, force_newtons: Optional[float] = None) -> None: + """ + Release gripped object. + + To simply open the jaw, use `home_gripper_jaw` instead. + """ # get default grip force for release if not provided self._gripper_handler.check_ready_for_jaw_move() + # TODO: check jaw width to make sure it is actually gripping something dc = self._gripper_handler.get_duty_cycle_by_grip_force( force_newtons or self._gripper_handler.get_gripper().default_home_force ) await self._ungrip(duty_cycle=dc) - self._gripper_handler.set_jaw_state(GripperJawState.HOMED_READY) async def hold_jaw_width(self, jaw_width_mm: int) -> None: self._gripper_handler.check_ready_for_jaw_move() await self._hold_jaw_width(jaw_width_mm) - self._gripper_handler.set_jaw_state(GripperJawState.HOLDING_CLOSED) async def _move_to_plunger_bottom( self, mount: OT3Mount, rate: float, acquire_lock: bool = True @@ -1631,6 +1651,8 @@ async def dispense( mount: Union[top_types.Mount, OT3Mount], volume: Optional[float] = None, rate: float = 1.0, + # TODO (tz, 8-24-24): add implementation https://opentrons.atlassian.net/browse/RET-1373 + push_out: Optional[float] = None, ) -> None: """ Dispense a volume of liquid in microliters(uL) using this pipette.""" @@ -1815,6 +1837,10 @@ async def pick_up_tip( for rel_point, speed in spec.shake_off_list: await self.move_rel(realmount, rel_point, speed=speed) + # fixme: really only need this during labware position check so user + # can verify if a tip is properly attached + await self.move_rel(realmount, top_types.Point(z=spec.retract_target)) + # TODO: implement tip-detection sequence during pick-up-tip for 96ch, # but not with DVT pipettes because those can only detect drops diff --git a/api/src/opentrons/hardware_control/protocols/liquid_handler.py b/api/src/opentrons/hardware_control/protocols/liquid_handler.py index 8322af5282d..292f0ace0ac 100644 --- a/api/src/opentrons/hardware_control/protocols/liquid_handler.py +++ b/api/src/opentrons/hardware_control/protocols/liquid_handler.py @@ -68,6 +68,7 @@ async def dispense( mount: Mount, volume: Optional[float] = None, rate: float = 1.0, + push_out: Optional[float] = None, ) -> None: """ Dispense a volume of liquid in microliters(uL) using this pipette diff --git a/api/src/opentrons/hardware_control/protocols/motion_controller.py b/api/src/opentrons/hardware_control/protocols/motion_controller.py index 0fffb94d75a..8fbae1b9a1b 100644 --- a/api/src/opentrons/hardware_control/protocols/motion_controller.py +++ b/api/src/opentrons/hardware_control/protocols/motion_controller.py @@ -8,7 +8,7 @@ class MotionController(Protocol): """Protocol specifying fundamental motion controls.""" - async def halt(self) -> None: + async def halt(self, disengage_before_stopping: bool = False) -> None: """Immediately stop motion. Calls to stop through the synch adapter while other calls @@ -17,8 +17,9 @@ async def halt(self) -> None: smoothie. To provide actual immediate halting, call this method which does not require use of the loop. - After this call, the hardware will be in a bad state until a call to - stop + If disengage_before_stopping is True, the motors will disengage first and then + stop in place. Disengaging creates a smoother halt but requires homing after + in order to resume movement. """ ... diff --git a/api/src/opentrons/hardware_control/types.py b/api/src/opentrons/hardware_control/types.py index edd914e91f6..94e156b9802 100644 --- a/api/src/opentrons/hardware_control/types.py +++ b/api/src/opentrons/hardware_control/types.py @@ -575,11 +575,8 @@ class GripperJawState(enum.Enum): #: the gripper has been homed and is at its fully-open homed position GRIPPING = enum.auto() #: the gripper is actively force-control gripping something - HOLDING_CLOSED = enum.auto() - #: the gripper is in position-control mode somewhere other than its - #: open position and probably should be opened before gripping something - HOLDING_OPENED = enum.auto() - #: the gripper is holding itself open but not quite at its homed position + HOLDING = enum.auto() + #: the gripper is in position-control mode class InstrumentProbeType(enum.Enum): diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 4b17d24765e..4d9d31bfac6 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -146,6 +146,7 @@ def dispense( rate: float, flow_rate: float, in_place: bool, + push_out: Optional[float], ) -> None: """Dispense a given volume of liquid into the specified location. Args: @@ -155,6 +156,7 @@ def dispense( rate: Not used in this core. flow_rate: The flow rate in µL/s to dispense at. in_place: whether this is a in-place command. + push_out: The amount to push the plunger below bottom position. """ if well_core is None: if not in_place: @@ -169,7 +171,10 @@ def dispense( ) self._engine_client.dispense_in_place( - pipette_id=self._pipette_id, volume=volume, flow_rate=flow_rate + pipette_id=self._pipette_id, + volume=volume, + flow_rate=flow_rate, + push_out=push_out, ) else: well_name = well_core.get_name() @@ -190,6 +195,7 @@ def dispense( well_location=well_location, volume=volume, flow_rate=flow_rate, + push_out=push_out, ) self._protocol_core.set_last_location(location=location, mount=self.get_mount()) diff --git a/api/src/opentrons/protocol_api/core/instrument.py b/api/src/opentrons/protocol_api/core/instrument.py index 60555c90be0..c221470e70d 100644 --- a/api/src/opentrons/protocol_api/core/instrument.py +++ b/api/src/opentrons/protocol_api/core/instrument.py @@ -51,6 +51,7 @@ def dispense( rate: float, flow_rate: float, in_place: bool, + push_out: Optional[float], ) -> None: """Dispense a given volume of liquid into the specified location. Args: @@ -60,6 +61,7 @@ def dispense( rate: The rate for how quickly to dispense. flow_rate: The flow rate in µL/s to dispense at. in_place: Whether this is in-place. + push_out: The amount to push the plunger below bottom position. """ ... diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py index c6646d1532c..56b5f9dc49b 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py @@ -116,6 +116,7 @@ def dispense( rate: float, flow_rate: float, in_place: bool, + push_out: Optional[float], ) -> None: """Dispense a given volume of liquid into the specified location. Args: @@ -125,7 +126,10 @@ def dispense( rate: The rate in µL/s to dispense at. flow_rate: Not used in this core. in_place: Whether we should move_to location. + push_out: The amount to push the plunger below bottom position. """ + if push_out: + raise APIVersionError("push_out is not supported in this API version.") if not in_place: self.move_to(location=location) diff --git a/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py b/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py index e1fa257922c..060fe477f54 100644 --- a/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +++ b/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py @@ -122,6 +122,7 @@ def dispense( rate: float, flow_rate: float, in_place: bool, + push_out: Optional[float], ) -> None: if not in_place: self.move_to(location=location, well_core=well_core) diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 7514cfd4496..7daf2986061 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -249,6 +249,7 @@ def dispense( volume: Optional[float] = None, location: Optional[Union[types.Location, labware.Well]] = None, rate: float = 1.0, + push_out: Optional[float] = None, ) -> InstrumentContext: """ Dispense a volume of liquid (in microliters/uL) using this pipette @@ -278,6 +279,9 @@ def dispense( `rate` * :py:attr:`flow_rate.dispense `. If not specified, defaults to 1.0. :type rate: float + :param push_out: Continue past the plunger bottom to guarantee all liquid + leaves the tip. Specified in microliters. By default, this value is None. + :type push_out: float :returns: This instance. @@ -290,6 +294,10 @@ def dispense( ``instr.dispense(location=wellplate['A1'])`` """ + if self.api_version < APIVersion(2, 15) and push_out: + raise APIVersionError( + "Unsupported parameter push_out. Change your API version to 2.15 or above to use this parameter." + ) _log.debug( "dispense {} from {} at {}".format( volume, location if location else "current position", rate @@ -351,6 +359,7 @@ def dispense( well_core=well._core if well is not None else None, flow_rate=flow_rate, in_place=target.in_place, + push_out=push_out, ) return self @@ -853,8 +862,9 @@ def drop_tip( If no location is passed, the Pipette will drop the tip into its :py:attr:`trash_container`, which if not specified defaults to - the fixed trash in slot 12. From API version 2.15 on, the API will default to - alternating between two different drop tip locations within the trash container + the fixed trash in slot 12. From API version 2.15 on, if the trash container is + the default fixed trash in A3 (slot 12), the API will default to + dropping tips in different points within the trash container in order to prevent tips from piling up in a single location in the trash. The location in which to drop the tip can be manually specified with diff --git a/api/src/opentrons/protocol_api/protocol_context.py b/api/src/opentrons/protocol_api/protocol_context.py index ae7fa918b2f..c500722006f 100644 --- a/api/src/opentrons/protocol_api/protocol_context.py +++ b/api/src/opentrons/protocol_api/protocol_context.py @@ -14,6 +14,7 @@ ) from opentrons_shared_data.labware.dev_types import LabwareDefinition +from opentrons_shared_data.pipette.dev_types import PipetteNameType from opentrons.types import Mount, Location, DeckLocation, DeckSlotName from opentrons.broker import Broker @@ -41,7 +42,6 @@ AbstractMagneticBlockCore, ) from .core.engine import ENGINE_CORE_API_VERSION -from .core.engine.protocol import ProtocolCore as ProtocolEngineCore from .core.legacy.legacy_protocol_core import LegacyProtocolCore from . import validation @@ -756,14 +756,12 @@ def load_instrument( `mount` (if such an instrument exists) should be replaced by `instrument_name`. """ + # TODO (spp: 2023-08-30): disallow loading Flex pipettes on OT-2 by checking robotType instrument_name = validation.ensure_lowercase_name(instrument_name) - is_96_channel = instrument_name in ("p1000_96", "flex_96channel_1000") - if is_96_channel and isinstance(self._core, ProtocolEngineCore): - checked_instrument_name = instrument_name - checked_mount = Mount.LEFT - else: - checked_instrument_name = validation.ensure_pipette_name(instrument_name) - checked_mount = validation.ensure_mount(mount) + checked_instrument_name = validation.ensure_pipette_name(instrument_name) + is_96_channel = checked_instrument_name == PipetteNameType.P1000_96 + + checked_mount = Mount.LEFT if is_96_channel else validation.ensure_mount(mount) tip_racks = tip_racks or [] @@ -782,7 +780,7 @@ def load_instrument( # TODO (tz, 11-22-22): was added to support 96 channel pipette. # Should remove when working on https://opentrons.atlassian.net/browse/RLIQ-255 instrument_core = self._core.load_instrument( - instrument_name=checked_instrument_name, # type: ignore[arg-type] + instrument_name=checked_instrument_name, mount=checked_mount, ) diff --git a/api/src/opentrons/protocol_engine/clients/sync_client.py b/api/src/opentrons/protocol_engine/clients/sync_client.py index f29344da32c..1b4e3b6a66a 100644 --- a/api/src/opentrons/protocol_engine/clients/sync_client.py +++ b/api/src/opentrons/protocol_engine/clients/sync_client.py @@ -310,6 +310,7 @@ def dispense( well_location: WellLocation, volume: float, flow_rate: float, + push_out: Optional[float], ) -> commands.DispenseResult: """Execute a ``Dispense`` command and return the result.""" request = commands.DispenseCreate( @@ -320,6 +321,7 @@ def dispense( wellLocation=well_location, volume=volume, flowRate=flow_rate, + pushOut=push_out, ) ) result = self._transport.execute_command(request=request) @@ -330,6 +332,7 @@ def dispense_in_place( pipette_id: str, volume: float, flow_rate: float, + push_out: Optional[float], ) -> commands.DispenseInPlaceResult: """Execute a ``DispenseInPlace`` command and return the result.""" request = commands.DispenseInPlaceCreate( @@ -337,6 +340,7 @@ def dispense_in_place( pipetteId=pipette_id, volume=volume, flowRate=flow_rate, + pushOut=push_out, ) ) result = self._transport.execute_command(request=request) diff --git a/api/src/opentrons/protocol_engine/commands/dispense.py b/api/src/opentrons/protocol_engine/commands/dispense.py index 4aab9567f18..361b6d2cdda 100644 --- a/api/src/opentrons/protocol_engine/commands/dispense.py +++ b/api/src/opentrons/protocol_engine/commands/dispense.py @@ -3,6 +3,8 @@ from typing import TYPE_CHECKING, Optional, Type from typing_extensions import Literal +from pydantic import Field + from ..types import DeckPoint from .pipetting_common import ( PipetteIdMixin, @@ -24,7 +26,10 @@ class DispenseParams(PipetteIdMixin, VolumeMixin, FlowRateMixin, WellLocationMixin): """Payload required to dispense to a specific well.""" - pass + pushOut: Optional[float] = Field( + None, + description="push the plunger a small amount farther than necessary for accurate low-volume dispensing", + ) class DispenseResult(BaseLiquidHandlingResult, DestinationPositionResult): @@ -54,6 +59,7 @@ async def execute(self, params: DispenseParams) -> DispenseResult: pipette_id=params.pipetteId, volume=params.volume, flow_rate=params.flowRate, + push_out=params.pushOut, ) return DispenseResult( diff --git a/api/src/opentrons/protocol_engine/commands/dispense_in_place.py b/api/src/opentrons/protocol_engine/commands/dispense_in_place.py index babc3b94f3c..bda6a953f45 100644 --- a/api/src/opentrons/protocol_engine/commands/dispense_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/dispense_in_place.py @@ -1,10 +1,10 @@ """Dispense-in-place command request, result, and implementation models.""" - - from __future__ import annotations from typing import TYPE_CHECKING, Optional, Type from typing_extensions import Literal +from pydantic import Field + from .pipetting_common import ( PipetteIdMixin, VolumeMixin, @@ -23,7 +23,10 @@ class DispenseInPlaceParams(PipetteIdMixin, VolumeMixin, FlowRateMixin): """Payload required to dispense in place.""" - pass + pushOut: Optional[float] = Field( + None, + description="push the plunger a small amount farther than necessary for accurate low-volume dispensing", + ) class DispenseInPlaceResult(BaseLiquidHandlingResult): @@ -46,6 +49,7 @@ async def execute(self, params: DispenseInPlaceParams) -> DispenseInPlaceResult: pipette_id=params.pipetteId, volume=params.volume, flow_rate=params.flowRate, + push_out=params.pushOut, ) return DispenseInPlaceResult(volume=volume) diff --git a/api/src/opentrons/protocol_engine/commands/move_labware.py b/api/src/opentrons/protocol_engine/commands/move_labware.py index 025ba86dbf1..682f2a58a22 100644 --- a/api/src/opentrons/protocol_engine/commands/move_labware.py +++ b/api/src/opentrons/protocol_engine/commands/move_labware.py @@ -92,6 +92,11 @@ async def execute(self, params: MoveLabwareParams) -> MoveLabwareResult: ) definition_uri = current_labware.definitionUri + if self._state_view.labware.is_fixed_trash(params.labwareId): + raise LabwareMovementNotAllowedError( + f"Cannot move fixed trash labware '{current_labware_definition.parameters.loadName}'." + ) + available_new_location = self._state_view.geometry.ensure_location_not_occupied( location=params.newLocation ) @@ -125,11 +130,19 @@ async def execute(self, params: MoveLabwareParams) -> MoveLabwareResult: message="Labware movement using a gripper is not supported on the OT-2", details={"strategy": params.strategy}, ) + if not labware_validation.validate_gripper_compatible( + current_labware_definition + ): + raise LabwareMovementNotAllowedError( + f"Cannot move labware '{current_labware_definition.parameters.loadName}' with gripper." + f" If trying to move a labware on an adapter, load the adapter separately to allow" + f" gripper movement." + ) if labware_validation.validate_definition_is_adapter( current_labware_definition ): raise LabwareMovementNotAllowedError( - f"Cannot move adapter {params.labwareId} with gripper." + f"Cannot move adapter '{current_labware_definition.parameters.loadName}' with gripper." ) validated_current_loc = ( diff --git a/api/src/opentrons/protocol_engine/create_protocol_engine.py b/api/src/opentrons/protocol_engine/create_protocol_engine.py index f4e70afc4e7..5139b9b5eba 100644 --- a/api/src/opentrons/protocol_engine/create_protocol_engine.py +++ b/api/src/opentrons/protocol_engine/create_protocol_engine.py @@ -10,6 +10,7 @@ from .protocol_engine import ProtocolEngine from .resources import DeckDataProvider, ModuleDataProvider from .state import Config, StateStore +from .types import PostRunHardwareState # TODO(mm, 2023-06-16): Arguably, this not being a context manager makes us prone to forgetting to @@ -44,7 +45,8 @@ async def create_protocol_engine( def create_protocol_engine_in_thread( hardware_api: HardwareControlAPI, config: Config, - drop_tips_and_home_after: bool, + drop_tips_after_run: bool, + post_run_hardware_state: PostRunHardwareState, ) -> typing.Generator[ typing.Tuple[ProtocolEngine, asyncio.AbstractEventLoop], None, None ]: @@ -66,7 +68,9 @@ def create_protocol_engine_in_thread( 3. Joins the thread. """ with async_context_manager_in_thread( - _protocol_engine(hardware_api, config, drop_tips_and_home_after) + _protocol_engine( + hardware_api, config, drop_tips_after_run, post_run_hardware_state + ) ) as ( protocol_engine, loop, @@ -78,7 +82,8 @@ def create_protocol_engine_in_thread( async def _protocol_engine( hardware_api: HardwareControlAPI, config: Config, - drop_tips_and_home_after: bool, + drop_tips_after_run: bool, + post_run_hardware_state: PostRunHardwareState, ) -> typing.AsyncGenerator[ProtocolEngine, None]: protocol_engine = await create_protocol_engine( hardware_api=hardware_api, @@ -88,4 +93,7 @@ async def _protocol_engine( protocol_engine.play() yield protocol_engine finally: - await protocol_engine.finish(drop_tips_and_home=drop_tips_and_home_after) + await protocol_engine.finish( + drop_tips_after_run=drop_tips_after_run, + post_run_hardware_state=post_run_hardware_state, + ) diff --git a/api/src/opentrons/protocol_engine/errors/__init__.py b/api/src/opentrons/protocol_engine/errors/__init__.py index f60e4900a98..4e8b6524a0a 100644 --- a/api/src/opentrons/protocol_engine/errors/__init__.py +++ b/api/src/opentrons/protocol_engine/errors/__init__.py @@ -48,6 +48,7 @@ CannotPerformModuleAction, PauseNotAllowedError, GripperNotAttachedError, + CannotPerformGripperAction, HardwareNotSupportedError, LabwareMovementNotAllowedError, LocationIsOccupiedError, @@ -107,6 +108,7 @@ "PauseNotAllowedError", "ProtocolCommandFailedError", "GripperNotAttachedError", + "CannotPerformGripperAction", "HardwareNotSupportedError", "LabwareMovementNotAllowedError", "LocationIsOccupiedError", diff --git a/api/src/opentrons/protocol_engine/errors/error_occurrence.py b/api/src/opentrons/protocol_engine/errors/error_occurrence.py index 630413481d1..12f1289f4f0 100644 --- a/api/src/opentrons/protocol_engine/errors/error_occurrence.py +++ b/api/src/opentrons/protocol_engine/errors/error_occurrence.py @@ -37,17 +37,55 @@ def from_failed( ) id: str = Field(..., description="Unique identifier of this error occurrence.") - errorType: str = Field(..., description="Specific error type that occurred.") createdAt: datetime = Field(..., description="When the error occurred.") - detail: str = Field(..., description="A human-readable message about the error.") + errorCode: str = Field( default=ErrorCodes.GENERAL_ERROR.value.code, - description="An enumerated error code for the error type.", + description=( + "An enumerated error code for the error type." + " This is intended to be shown to the robot operator to direct them to the" + " correct rough area for troubleshooting." + ), + ) + + # TODO(mm, 2023-09-07): + # The Opentrons App and Flex ODD use `errorType` in the title of error modals, but it's unclear + # if they should. Is this field redundant now that we have `errorCode` and `detail`? + # + # In practice, this is often the source code name of our Python exception type. + # Should we derive this from `errorCode` instead? Like how HTTP code 404 is always "not found." + errorType: str = Field( + ..., + description=( + "A short name for the error type that occurred, like `PipetteOverpressure`." + " This can be a bit more specific than `errorCode`." + ), ) + + detail: str = Field( + ..., + description=( + "A short human-readable message about the error." + "\n\n" + "This is intended to provide the robot operator with more specific details than" + " `errorCode` alone. It should be no longer than a couple of sentences," + " and it should not contain internal newlines or indentation." + "\n\n" + " It should not internally repeat `errorCode`, but it may internally repeat `errorType`" + " if it helps the message make sense when it's displayed in its own separate block." + ), + ) + errorInfo: Dict[str, str] = Field( default={}, - description="Specific details about the error that may be useful for determining cause.", + description=( + "Specific details about the error that may be useful for determining cause." + " This might contain the same information as `detail`, but in a more structured form." + " It might also contain additional information that was too verbose or technical" + " to put in `detail`." + ), ) + wrappedErrors: List["ErrorOccurrence"] = Field( default=[], description="Errors that may have caused this one." ) diff --git a/api/src/opentrons/protocol_engine/errors/exceptions.py b/api/src/opentrons/protocol_engine/errors/exceptions.py index 13cd5944d41..4ae0fae33ff 100644 --- a/api/src/opentrons/protocol_engine/errors/exceptions.py +++ b/api/src/opentrons/protocol_engine/errors/exceptions.py @@ -676,6 +676,19 @@ def __init__( super().__init__(ErrorCodes.GRIPPER_NOT_PRESENT, message, details, wrapping) +class CannotPerformGripperAction(ProtocolEngineError): + """Raised when trying to perform an illegal gripper action.""" + + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a CannotPerformGripperAction.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + + class LabwareMovementNotAllowedError(ProtocolEngineError): """Raised when attempting an illegal labware movement.""" @@ -741,6 +754,19 @@ def __init__( super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) +class InvalidPushOutVolumeError(ProtocolEngineError): + """Raised when attempting to use an invalid volume for dispense push_out.""" + + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a InvalidPushOutVolumeError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + + class InvalidAxisForRobotType(ProtocolEngineError): """Raised when attempting to use an axis that is not present on the given type of robot.""" diff --git a/api/src/opentrons/protocol_engine/execution/hardware_stopper.py b/api/src/opentrons/protocol_engine/execution/hardware_stopper.py index 367182657b7..11f753b0ee4 100644 --- a/api/src/opentrons/protocol_engine/execution/hardware_stopper.py +++ b/api/src/opentrons/protocol_engine/execution/hardware_stopper.py @@ -7,7 +7,7 @@ from ..resources.ot3_validation import ensure_ot3_hardware from ..state import StateStore -from ..types import MotorAxis +from ..types import MotorAxis, PostRunHardwareState from ..errors import HardwareNotSupportedError from .movement import MovementHandler @@ -87,17 +87,27 @@ async def _drop_tip(self) -> None: # should not happen during an actual run log.debug(f"Pipette ID {pipette_id} no longer attached.") - async def do_halt(self) -> None: + async def do_halt(self, disengage_before_stopping: bool = False) -> None: """Issue a halt signal to the hardware API. After issuing a halt, you must call do_stop_and_recover after anything using the HardwareAPI has settled. """ - await self._hardware_api.halt() + await self._hardware_api.halt( + disengage_before_stopping=disengage_before_stopping + ) - async def do_stop_and_recover(self, drop_tips_and_home: bool = False) -> None: + async def do_stop_and_recover( + self, + post_run_hardware_state: PostRunHardwareState, + drop_tips_after_run: bool = False, + ) -> None: """Stop and reset the HardwareAPI, optionally dropping tips and homing.""" - if drop_tips_and_home: + if drop_tips_after_run: await self._drop_tip() - await self._hardware_api.stop(home_after=drop_tips_and_home) + home_after_stop = post_run_hardware_state in ( + PostRunHardwareState.HOME_AND_STAY_ENGAGED, + PostRunHardwareState.HOME_THEN_DISENGAGE, + ) + await self._hardware_api.stop(home_after=home_after_stop) diff --git a/api/src/opentrons/protocol_engine/execution/labware_movement.py b/api/src/opentrons/protocol_engine/execution/labware_movement.py index 9682d7159c5..d3c4fb3619c 100644 --- a/api/src/opentrons/protocol_engine/execution/labware_movement.py +++ b/api/src/opentrons/protocol_engine/execution/labware_movement.py @@ -2,7 +2,6 @@ from __future__ import annotations from typing import Optional, TYPE_CHECKING -from opentrons_shared_data.gripper.constants import IDLE_STATE_GRIP_FORCE from opentrons.hardware_control import HardwareControlAPI from opentrons.hardware_control.types import OT3Mount, Axis @@ -20,6 +19,7 @@ LabwareMovementNotAllowedError, ThermocyclerNotOpenError, HeaterShakerLabwareLatchNotOpenError, + CannotPerformGripperAction, ) from ..types import ( @@ -97,6 +97,10 @@ async def move_labware_with_gripper( raise GripperNotAttachedError( "No gripper found for performing labware movements." ) + if not ot3api._gripper_handler.is_ready_for_jaw_home(): + raise CannotPerformGripperAction( + "Cannot pick up labware when gripper is already gripping." + ) gripper_mount = OT3Mount.GRIPPER @@ -137,9 +141,8 @@ async def move_labware_with_gripper( mount=gripper_mount, abs_position=waypoint_data.position ) - # Keep the gripper in idly gripped position to avoid colliding with - # things like the thermocycler latches - await ot3api.grip(force_newtons=IDLE_STATE_GRIP_FORCE, stay_engaged=False) + # this makes sure gripper jaw is closed between two move labware calls + await ot3api.idle_gripper() async def ensure_movement_not_obstructed_by_module( self, labware_id: str, new_location: LabwareLocation diff --git a/api/src/opentrons/protocol_engine/execution/pipetting.py b/api/src/opentrons/protocol_engine/execution/pipetting.py index fd2f3588ccd..5cd59bcde46 100644 --- a/api/src/opentrons/protocol_engine/execution/pipetting.py +++ b/api/src/opentrons/protocol_engine/execution/pipetting.py @@ -6,7 +6,11 @@ from opentrons.hardware_control import HardwareControlAPI from ..state import StateView, HardwarePipette -from ..errors.exceptions import TipNotAttachedError, InvalidPipettingVolumeError +from ..errors.exceptions import ( + TipNotAttachedError, + InvalidPipettingVolumeError, + InvalidPushOutVolumeError, +) class PipettingHandler(TypingProtocol): @@ -31,6 +35,7 @@ async def dispense_in_place( pipette_id: str, volume: float, flow_rate: float, + push_out: Optional[float], ) -> float: """Set flow-rate and dispense.""" @@ -88,15 +93,22 @@ async def dispense_in_place( pipette_id: str, volume: float, flow_rate: float, + push_out: Optional[float], ) -> float: """Dispense liquid without moving the pipette.""" hw_pipette = self._state_view.pipettes.get_hardware_pipette( pipette_id=pipette_id, attached_pipettes=self._hardware_api.attached_instruments, ) - + # TODO (tz, 8-23-23): add a check for push_out not larger that the max volume allowed when working on this https://opentrons.atlassian.net/browse/RSS-329 + if push_out and push_out < 0: + raise InvalidPushOutVolumeError( + "push out value cannot have a negative value." + ) with self._set_flow_rate(pipette=hw_pipette, dispense_flow_rate=flow_rate): - await self._hardware_api.dispense(mount=hw_pipette.mount, volume=volume) + await self._hardware_api.dispense( + mount=hw_pipette.mount, volume=volume, push_out=push_out + ) return volume @@ -195,8 +207,14 @@ async def dispense_in_place( pipette_id: str, volume: float, flow_rate: float, + push_out: Optional[float], ) -> float: """Virtually dispense (no-op).""" + # TODO (tz, 8-23-23): add a check for push_out not larger that the max volume allowed when working on this https://opentrons.atlassian.net/browse/RSS-329 + if push_out and push_out < 0: + raise InvalidPushOutVolumeError( + "push out value cannot have a negative value." + ) self._validate_tip_attached(pipette_id=pipette_id, command_name="dispense") return volume diff --git a/api/src/opentrons/protocol_engine/protocol_engine.py b/api/src/opentrons/protocol_engine/protocol_engine.py index c50eb714c82..fa851ec29e7 100644 --- a/api/src/opentrons/protocol_engine/protocol_engine.py +++ b/api/src/opentrons/protocol_engine/protocol_engine.py @@ -7,7 +7,6 @@ from opentrons.hardware_control import HardwareControlAPI from opentrons.hardware_control.modules import AbstractModule as HardwareModuleAPI from opentrons.hardware_control.types import PauseType as HardwarePauseType - from opentrons_shared_data.errors import ( ErrorCodes, EnumeratedError, @@ -24,6 +23,7 @@ ModuleModel, Liquid, HexColor, + PostRunHardwareState, ) from .execution import ( QueueWorker, @@ -306,8 +306,9 @@ async def wait_until_complete(self) -> None: async def finish( self, error: Optional[Exception] = None, - drop_tips_and_home: bool = True, + drop_tips_after_run: bool = True, set_run_status: bool = True, + post_run_hardware_state: PostRunHardwareState = PostRunHardwareState.HOME_AND_STAY_ENGAGED, ) -> None: """Gracefully finish using the ProtocolEngine, waiting for it to become idle. @@ -321,19 +322,38 @@ async def finish( Arguments: error: An error that caused the stop, if applicable. - drop_tips_and_home: Whether to home and drop tips as part of cleanup. + drop_tips_after_run: Whether to drop tips as part of cleanup. set_run_status: Whether to calculate a `success` or `failure` run status. If `False`, will set status to `stopped`. + post_run_hardware_state: The state in which to leave the gantry and motors in + after the run is over. """ if self._state_store.commands.state.stopped_by_estop: - drop_tips_and_home = False + # This handles the case where the E-stop was pressed while we were *not* in the middle + # of some hardware interaction that would raise it as an exception. For example, imagine + # we were paused between two commands, or imagine we were executing a very long run of + # comment commands. + drop_tips_after_run = False + post_run_hardware_state = PostRunHardwareState.DISENGAGE_IN_PLACE if error is None: error = EStopActivatedError(message="Estop was activated during a run") + if error: - if isinstance(error, EnumeratedError) and self._code_in_exception_stack( - error=error, code=ErrorCodes.E_STOP_ACTIVATED + # If the run had an error, check if that error indicates an E-stop. + # This handles the case where the run was in the middle of some hardware control + # method and the hardware controller raised an E-stop error from it. + # + # To do this, we need to scan all the way through the error tree. + # By the time E-stop error has gotten to us, it may have been wrapped in other errors, + # so we need to unwrap them to uncover the E-stop error's inner beauty. + # + # We don't use self._hardware_api.get_estop_state() because the E-stop may have been + # released by the time we get here. + if isinstance(error, EnumeratedError) and self._code_in_error_tree( + root_error=error, code=ErrorCodes.E_STOP_ACTIVATED ): - drop_tips_and_home = False + drop_tips_after_run = False + post_run_hardware_state = PostRunHardwareState.DISENGAGE_IN_PLACE error_details: Optional[FinishErrorDetails] = FinishErrorDetails( error_id=self._model_utils.generate_id(), @@ -357,13 +377,21 @@ async def finish( exit_stack.push_async_callback( # Cleanup after hardware halt and reset the hardware controller self._hardware_stopper.do_stop_and_recover, - drop_tips_and_home=drop_tips_and_home, + post_run_hardware_state=post_run_hardware_state, + drop_tips_after_run=drop_tips_after_run, ) exit_stack.callback(self._door_watcher.stop) - # Halt any movements immediately. Requires a hardware stop & reset after, in order to - # recover from halt and resume hardware operations. - exit_stack.push_async_callback(self._hardware_stopper.do_halt) + disengage_before_stopping = ( + False + if post_run_hardware_state == PostRunHardwareState.STAY_ENGAGED_IN_PLACE + else True + ) + # Halt any movements immediately + exit_stack.push_async_callback( + self._hardware_stopper.do_halt, + disengage_before_stopping=disengage_before_stopping, + ) exit_stack.push_async_callback(self._queue_worker.join) # First step. try: @@ -481,34 +509,34 @@ async def use_attached_modules( # TODO(tz, 7-12-23): move this to shared data when we dont relay on ErrorOccurrence @staticmethod - def _code_in_exception_stack( - error: Union[EnumeratedError, ErrorOccurrence], code: ErrorCodes + def _code_in_error_tree( + root_error: Union[EnumeratedError, ErrorOccurrence], code: ErrorCodes ) -> bool: - if isinstance(error, ErrorOccurrence): + if isinstance(root_error, ErrorOccurrence): # ErrorOccurrence is not the same as the enumerated error exceptions. Check the # code by a string value. - if error.errorCode == code.value.code: + if root_error.errorCode == code.value.code: return True return any( - ProtocolEngine._code_in_exception_stack(wrapped, code) - for wrapped in error.wrappedErrors + ProtocolEngine._code_in_error_tree(wrapped, code) + for wrapped in root_error.wrappedErrors ) # From here we have an exception, can just check the code + recurse to wrapped errors. - if error.code == code: + if root_error.code == code: return True if ( - isinstance(error, ProtocolCommandFailedError) - and error.original_error is not None + isinstance(root_error, ProtocolCommandFailedError) + and root_error.original_error is not None ): # For this specific EnumeratedError child, we recurse on the original_error field # in favor of the general error.wrapping field. - return ProtocolEngine._code_in_exception_stack(error.original_error, code) + return ProtocolEngine._code_in_error_tree(root_error.original_error, code) - if len(error.wrapping) == 0: + if len(root_error.wrapping) == 0: return False return any( - ProtocolEngine._code_in_exception_stack(wrapped_error, code) - for wrapped_error in error.wrapping + ProtocolEngine._code_in_error_tree(wrapped_error, code) + for wrapped_error in root_error.wrapping ) diff --git a/api/src/opentrons/protocol_engine/resources/labware_data_provider.py b/api/src/opentrons/protocol_engine/resources/labware_data_provider.py index 0123c85ad97..0b08720d4e9 100644 --- a/api/src/opentrons/protocol_engine/resources/labware_data_provider.py +++ b/api/src/opentrons/protocol_engine/resources/labware_data_provider.py @@ -82,5 +82,5 @@ def _get_calibrated_tip_length_sync( f"No calibrated tip length found for {pipette_serial}," f" using nominal fallback value of {nominal_fallback}" ) - log.warning(message, exc_info=e) + log.debug(message, exc_info=e) return nominal_fallback diff --git a/api/src/opentrons/protocol_engine/resources/labware_validation.py b/api/src/opentrons/protocol_engine/resources/labware_validation.py index 89fc5b47d7e..33f74247125 100644 --- a/api/src/opentrons/protocol_engine/resources/labware_validation.py +++ b/api/src/opentrons/protocol_engine/resources/labware_validation.py @@ -22,3 +22,11 @@ def validate_labware_can_be_stacked( ) -> bool: """Validate that the labware being loaded onto is in the above labware's stackingOffsetWithLabware definition.""" return below_labware_load_name in top_labware_definition.stackingOffsetWithLabware + + +def validate_gripper_compatible(definition: LabwareDefinition) -> bool: + """Validate that the labware definition does not have a quirk disallowing movement with gripper.""" + return ( + definition.parameters.quirks is None + or "gripperIncompatible" not in definition.parameters.quirks + ) diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index cabe52ce258..6c90a9c670d 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -379,15 +379,15 @@ def get_checked_tip_drop_location( offset=well_location.offset, ) - # return to top if labware is fixed trash - if self._labware.get_has_quirk(labware_id=labware_id, quirk="fixedTrash"): - z_offset = well_location.offset.z - else: + if self._labware.get_definition(labware_id).parameters.isTiprack: z_offset = self._labware.get_tip_drop_z_offset( labware_id=labware_id, length_scale=self._pipettes.get_return_tip_scale(pipette_id), additional_offset=well_location.offset.z, ) + else: + # return to top if labware is not tip rack + z_offset = well_location.offset.z return WellLocation( origin=WellOrigin.TOP, @@ -552,6 +552,8 @@ def get_next_tip_drop_location( # In order to avoid the complexity of finding tip drop locations for # variety of labware with different well configs, we will allow # location cycling only for fixed trash labware right now. + # TODO (spp, 2023-09-12): update this to possibly a labware-width based check, + # or a 'trash' quirk check, once movable trash is implemented. return DropTipWellLocation( origin=DropTipWellOrigin.DEFAULT, offset=WellOffset(x=0, y=0, z=0), diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index 80d2c2b6725..4ce477cb4cb 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -750,9 +750,12 @@ def get_labware_gripper_offsets( ) -> Optional[LabwareMovementOffsetData]: """Get the labware's gripper offsets of the specified type.""" parsed_offsets = self.get_definition(labware_id).gripperOffsets - offset_key = slot_name.name if slot_name else "default" - return ( - LabwareMovementOffsetData( + offset_key = slot_name.id if slot_name else "default" + + if parsed_offsets is None or offset_key not in parsed_offsets: + return None + else: + return LabwareMovementOffsetData( pickUpOffset=cast( LabwareOffsetVector, parsed_offsets[offset_key].pickUpOffset ), @@ -760,9 +763,6 @@ def get_labware_gripper_offsets( LabwareOffsetVector, parsed_offsets[offset_key].dropOffset ), ) - if parsed_offsets - else None - ) def get_grip_force(self, labware_id: str) -> float: """Get the recommended grip force for gripping labware using gripper.""" diff --git a/api/src/opentrons/protocol_engine/types.py b/api/src/opentrons/protocol_engine/types.py index 9287696049a..288e644fb16 100644 --- a/api/src/opentrons/protocol_engine/types.py +++ b/api/src/opentrons/protocol_engine/types.py @@ -629,3 +629,30 @@ class LabwareMovementStrategy(str, Enum): USING_GRIPPER = "usingGripper" MANUAL_MOVE_WITH_PAUSE = "manualMoveWithPause" MANUAL_MOVE_WITHOUT_PAUSE = "manualMoveWithoutPause" + + +class PostRunHardwareState(Enum): + """State of robot gantry & motors after a stop is performed and the hardware API is reset. + + HOME_AND_STAY_ENGAGED: home the gantry and keep all motors engaged. This allows the + robot to continue performing movement actions without re-homing + HOME_THEN_DISENGAGE: home the gantry and then disengage motors. + Reduces current consumption of the motors and prevents coil heating. + Re-homing is required to re-engage the motors and resume robot movement. + STAY_ENGAGED_IN_PLACE: do not home after the stop and keep the motors engaged. + Keeps gantry in the same position as prior to `stop()` execution + and allows the robot to execute movement commands without requiring to re-home first. + DISENGAGE_IN_PLACE: disengage motors and do not home the robot + Probable states for pipette: + - for 1- or 8-channel: + - HOME_AND_STAY_ENGAGED after protocol runs + - STAY_ENGAGED_IN_PLACE after maintenance runs + - for 96-channel: + - HOME_THEN_DISENGAGE after protocol runs + - DISENGAGE_IN_PLACE after maintenance runs + """ + + HOME_AND_STAY_ENGAGED = "homeAndStayEngaged" + HOME_THEN_DISENGAGE = "homeThenDisengage" + STAY_ENGAGED_IN_PLACE = "stayEngagedInPlace" + DISENGAGE_IN_PLACE = "disengageInPlace" diff --git a/api/src/opentrons/protocol_runner/protocol_runner.py b/api/src/opentrons/protocol_runner/protocol_runner.py index 9af858275d3..f5b317bf1ee 100644 --- a/api/src/opentrons/protocol_runner/protocol_runner.py +++ b/api/src/opentrons/protocol_runner/protocol_runner.py @@ -35,6 +35,7 @@ LegacyExecutor, LegacyLoadInfo, ) +from ..protocol_engine.types import PostRunHardwareState class RunResult(NamedTuple): @@ -80,8 +81,9 @@ async def stop(self) -> None: await self._protocol_engine.stop() else: await self._protocol_engine.finish( - drop_tips_and_home=False, + drop_tips_after_run=False, set_run_status=False, + post_run_hardware_state=PostRunHardwareState.STAY_ENGAGED_IN_PLACE, ) @abstractmethod @@ -153,6 +155,7 @@ async def load( initial_home_command = pe_commands.HomeCreate( params=pe_commands.HomeParams(axes=None) ) + # this command homes all axes, including pipette plugner and gripper jaw self._protocol_engine.add_command(request=initial_home_command) self._task_queue.set_run_func( @@ -246,6 +249,7 @@ async def load(self, protocol_source: ProtocolSource) -> None: initial_home_command = pe_commands.HomeCreate( params=pe_commands.HomeParams(axes=None) ) + # this command homes all axes, including pipette plugner and gripper jaw self._protocol_engine.add_command(request=initial_home_command) for command in commands: diff --git a/api/src/opentrons/protocols/execution/errors.py b/api/src/opentrons/protocols/execution/errors.py index 84d28d84643..aa77070a289 100644 --- a/api/src/opentrons/protocols/execution/errors.py +++ b/api/src/opentrons/protocols/execution/errors.py @@ -1,3 +1,6 @@ +from types import TracebackType +from typing import Optional + from opentrons_shared_data.errors.exceptions import GeneralError @@ -7,19 +10,31 @@ class ExceptionInProtocolError(GeneralError): we can properly figure out formatting """ - def __init__(self, original_exc, original_tb, message, line): + def __init__( + self, + original_exc: Exception, + original_tb: Optional[TracebackType], + message: str, + line: Optional[int], + ) -> None: self.original_exc = original_exc self.original_tb = original_tb - self.message = message self.line = line super().__init__( wrapping=[original_exc], - message="{}{}: {}".format( - self.original_exc.__class__.__name__, - " [line {}]".format(self.line) if self.line else "", - self.message, + message=_build_message( + exception_class_name=self.original_exc.__class__.__name__, + line_number=self.line, + message=message, ), ) - def __str__(self): + def __str__(self) -> str: return self.message + + +def _build_message( + exception_class_name: str, line_number: Optional[int], message: str +) -> str: + line_number_part = f" [line {line_number}]" if line_number is not None else "" + return f"{exception_class_name}{line_number_part}: {message}" diff --git a/api/src/opentrons/protocols/execution/execute.py b/api/src/opentrons/protocols/execution/execute.py index e5a062a89ae..ea8ef6163e9 100644 --- a/api/src/opentrons/protocols/execution/execute.py +++ b/api/src/opentrons/protocols/execution/execute.py @@ -16,7 +16,7 @@ MODULE_LOG = logging.getLogger(__name__) -def run_protocol(protocol: Protocol, context: ProtocolContext): +def run_protocol(protocol: Protocol, context: ProtocolContext) -> None: """Run a protocol. :param protocol: The :py:class:`.protocols.types.Protocol` to execute diff --git a/api/src/opentrons/protocols/execution/execute_python.py b/api/src/opentrons/protocols/execution/execute_python.py index 940767fdb03..1d01a7120cd 100644 --- a/api/src/opentrons/protocols/execution/execute_python.py +++ b/api/src/opentrons/protocols/execution/execute_python.py @@ -10,7 +10,6 @@ from opentrons.protocols.execution.errors import ExceptionInProtocolError from opentrons.protocols.types import PythonProtocol, MalformedPythonProtocolError from opentrons.hardware_control import ExecutionCancelledError -from opentrons.protocol_engine.errors import ProtocolCommandFailedError MODULE_LOG = logging.getLogger(__name__) @@ -63,7 +62,6 @@ def run_python(proto: PythonProtocol, context: ProtocolContext): SmoothieAlarm, asyncio.CancelledError, ExecutionCancelledError, - ProtocolCommandFailedError, ): # this is a protocol cancel and shouldn't have special logging raise diff --git a/api/src/opentrons/protocols/execution/json_dispatchers.py b/api/src/opentrons/protocols/execution/json_dispatchers.py index a48bba1f97a..398aee14a0e 100644 --- a/api/src/opentrons/protocols/execution/json_dispatchers.py +++ b/api/src/opentrons/protocols/execution/json_dispatchers.py @@ -66,7 +66,7 @@ } -def tc_do_nothing(module: ThermocyclerContext, params) -> None: +def tc_do_nothing(module: ThermocyclerContext, params: object) -> None: pass diff --git a/api/src/opentrons/system/nmcli.py b/api/src/opentrons/system/nmcli.py index 7fa9359e009..2607a768292 100644 --- a/api/src/opentrons/system/nmcli.py +++ b/api/src/opentrons/system/nmcli.py @@ -318,17 +318,20 @@ def _add_security_type_to_scan(scan_out: Dict[str, Any]) -> Dict[str, Any]: return scan_out -async def available_ssids() -> List[Dict[str, Any]]: +async def available_ssids(rescan: Optional[bool] = False) -> List[Dict[str, Any]]: """List the visible (broadcasting SSID) wireless networks. + rescan: Forces a rescan instead of using the cached results. + Returns a list of the SSIDs. They may contain spaces and should be escaped if later passed to a shell. """ - # Force nmcli to actually scan rather than reuse cached results. We ignore - # errors here because NetworkManager yells at you if you do it twice in a + # Force nmcli to actually scan if rescan is true rather than reuse cached results. + # We ignore errors here because NetworkManager yells at you if you do it twice in a # row without another operation in between - cmd = ["device", "wifi", "rescan"] - _1, _2 = await _call(cmd, suppress_err=True) + if rescan: + cmd = ["device", "wifi", "rescan"] + _1, _2 = await _call(cmd, suppress_err=True) fields = ["ssid", "signal", "active", "security"] cmd = ["--terse", "--fields", ",".join(fields), "device", "wifi", "list"] diff --git a/api/tests/opentrons/cli/test_cli.py b/api/tests/opentrons/cli/test_cli.py index 51ab62d6ae8..eae5aa31ccc 100644 --- a/api/tests/opentrons/cli/test_cli.py +++ b/api/tests/opentrons/cli/test_cli.py @@ -180,3 +180,65 @@ def run(protocol): "You may only put apiLevel in the metadata dict or the requirements dict" ) assert expected_message in result.stdout_stderr + + +@pytest.mark.parametrize( + ("python_protocol_source", "expected_detail"), + [ + ( + textwrap.dedent( + # Raises an exception from outside of Opentrons code, + # in between two PAPI functions. + """\ + requirements = {"apiLevel": "2.14"} # line 1 + # line 2 + def run(protocol): # line 3 + protocol.comment(":^)") # line 4 + raise RuntimeError(">:(") # line 5 + protocol.comment(":D") # line 6 + """ + ), + "RuntimeError [line 5]: >:(", + ), + ( + textwrap.dedent( + # Raises an exception from inside a Protocol Engine command. + # https://opentrons.atlassian.net/browse/RSS-317 + """\ + requirements = {"apiLevel": "2.14"} # line 1 + # line 2 + def run(protocol): # line 3 + tip_rack = protocol.load_labware( # line 4 + "opentrons_96_tiprack_300ul", 1 # line 5 + ) # line 6 + pipette = protocol.load_instrument( # line 7 + "p300_single", "left" # line 8 + ) # line 9 + pipette.pick_up_tip(tip_rack["A1"]) # line 10 + pipette.pick_up_tip(tip_rack["A2"]) # line 11 + """ + ), + ( + # TODO(mm, 2023-09-12): This is an overly verbose concatenative Frankenstein + # message. We should simplify our error propagation to trim out the noise. + "ProtocolCommandFailedError [line 11]:" + " Error 4000 GENERAL_ERROR (ProtocolCommandFailedError):" + " TipAttachedError: Pipette should not have a tip attached, but does." + ), + ), + # TODO: PAPIv<2.15? + ], +) +def test_python_error_line_numbers( + tmp_path: Path, python_protocol_source: str, expected_detail: str +) -> None: + """Test that error messages from Python protocols have line numbers.""" + protocol_source_file = tmp_path / "protocol.py" + protocol_source_file.write_text(python_protocol_source, encoding="utf-8") + + result = _get_analysis_result([protocol_source_file]) + + assert result.exit_code == 0 + assert result.json_output is not None + [error] = result.json_output["errors"] + assert error["detail"] == expected_detail diff --git a/api/tests/opentrons/config/test_advanced_settings.py b/api/tests/opentrons/config/test_advanced_settings.py index 36dcc67ba03..21140b0f3d7 100644 --- a/api/tests/opentrons/config/test_advanced_settings.py +++ b/api/tests/opentrons/config/test_advanced_settings.py @@ -34,6 +34,11 @@ def mock_settings_values_flex() -> Dict[str, Optional[bool]]: } +@pytest.fixture +def mock_settings_values_empty() -> Dict[str, Optional[bool]]: + return {s.id: None for s in advanced_settings.settings} + + @pytest.fixture def mock_settings_version() -> int: return 1 @@ -76,6 +81,19 @@ def mock_read_settings_file_flex( yield p +@pytest.fixture +def mock_read_settings_file_empty( + mock_settings_values_empty: Dict[str, Optional[bool]], + mock_settings_version: int, +) -> Generator[MagicMock, None, None]: + with patch("opentrons.config.advanced_settings._read_settings_file") as p: + p.return_value = advanced_settings.SettingsData( + settings_map=mock_settings_values_empty, + version=mock_settings_version, + ) + yield p + + @pytest.fixture def mock_write_settings_file() -> Generator[MagicMock, None, None]: with patch("opentrons.config.advanced_settings._write_settings_file") as p: @@ -274,3 +292,13 @@ async def set_syslog_level(level: Any) -> Tuple[int, str, str]: s = advanced_settings.DisableLogIntegrationSettingDefinition() with pytest.raises(advanced_settings.SettingException): await s.on_change(True) + + +def test_per_robot_true_defaults(mock_read_settings_file_empty: MagicMock) -> None: + with patch.object(advanced_settings, "settings_by_id", new={}): + assert ( + advanced_settings.get_setting_with_env_overload( + "enableDoorSafetySwitch", RobotTypeEnum.FLEX + ) + is True + ) diff --git a/api/tests/opentrons/conftest.py b/api/tests/opentrons/conftest.py index c2b520ad727..b25eb9049f7 100755 --- a/api/tests/opentrons/conftest.py +++ b/api/tests/opentrons/conftest.py @@ -25,6 +25,8 @@ import pytest from decoy import Decoy +from opentrons.protocol_engine.types import PostRunHardwareState + try: import aionotify # type: ignore[import] except (OSError, ModuleNotFoundError): @@ -296,7 +298,8 @@ def _make_ot3_pe_ctx( use_virtual_gripper=True, block_on_door_open=False, ), - drop_tips_and_home_after=False, + drop_tips_after_run=False, + post_run_hardware_state=PostRunHardwareState.STAY_ENGAGED_IN_PLACE, ) as ( engine, loop, diff --git a/api/tests/opentrons/hardware_control/test_instruments.py b/api/tests/opentrons/hardware_control/test_instruments.py index f622aadcdea..fb1ff27e96c 100644 --- a/api/tests/opentrons/hardware_control/test_instruments.py +++ b/api/tests/opentrons/hardware_control/test_instruments.py @@ -32,7 +32,7 @@ def dummy_instruments_attached(): "id": None, "name": None, }, - } + }, 10 @pytest.fixture @@ -49,7 +49,7 @@ def dummy_instruments_attached_ot3(): }, types.Mount.RIGHT: {"model": None, "id": None, "name": None}, OT3Mount.GRIPPER: None, - } + }, 200 @pytest.fixture @@ -123,7 +123,7 @@ def get_plunger_speed(api): async def test_cache_instruments(sim_and_instr): - sim_builder, dummy_instruments = sim_and_instr + sim_builder, (dummy_instruments, _) = sim_and_instr hw_api = await sim_builder( attached_instruments=dummy_instruments, loop=asyncio.get_running_loop() ) @@ -137,7 +137,7 @@ async def test_cache_instruments(sim_and_instr): async def test_mismatch_fails(sim_and_instr): - sim_builder, dummy_instruments = sim_and_instr + sim_builder, (dummy_instruments, _) = sim_and_instr hw_api = await sim_builder( attached_instruments=dummy_instruments, loop=asyncio.get_running_loop() ) @@ -149,10 +149,8 @@ async def test_mismatch_fails(sim_and_instr): await hw_api.cache_instruments(requested_instr) -@pytest.mark.ot2_only -async def test_backwards_compatibility(dummy_backwards_compatibility, sim_and_instr): - sim_builder, _ = sim_and_instr - hw_api = await sim_builder( +async def test_backwards_compatibility(dummy_backwards_compatibility): + hw_api = await API.build_hardware_simulator( attached_instruments=dummy_backwards_compatibility, loop=asyncio.get_running_loop(), ) @@ -217,7 +215,7 @@ async def mock_driver_id(mount): @pytest.mark.ot2_only async def test_cache_instruments_sim(sim_and_instr): - sim_builder, dummy_instruments = sim_and_instr + sim_builder, (dummy_instruments, _) = sim_and_instr def fake_func1(value): return value @@ -305,7 +303,7 @@ def fake_func2(mount, value): async def test_prep_aspirate(sim_and_instr): - sim_builder, dummy_instruments = sim_and_instr + sim_builder, (dummy_instruments, dummy_tip_vol) = sim_and_instr hw_api = await sim_builder( attached_instruments=dummy_instruments, loop=asyncio.get_running_loop() ) @@ -314,6 +312,7 @@ async def test_prep_aspirate(sim_and_instr): mount = types.Mount.LEFT await hw_api.pick_up_tip(mount, 20.0) + hw_api.set_working_volume(mount, dummy_tip_vol) # If we just picked up a new tip, we should be fine await hw_api.aspirate(mount, 1) @@ -330,19 +329,20 @@ async def test_prep_aspirate(sim_and_instr): # If we don't prep_after, we should still be fine await hw_api.drop_tip(mount) await hw_api.pick_up_tip(mount, 20.0, prep_after=False) + hw_api.set_working_volume(mount, dummy_tip_vol) await hw_api.aspirate(mount, 1, 1.0) async def test_aspirate_new(dummy_instruments): hw_api = await API.build_hardware_simulator( - attached_instruments=dummy_instruments, loop=asyncio.get_running_loop() + attached_instruments=dummy_instruments[0], loop=asyncio.get_running_loop() ) await hw_api.home() await hw_api.cache_instruments() mount = types.Mount.LEFT await hw_api.pick_up_tip(mount, 20.0) - + hw_api.set_working_volume(mount, 10) aspirate_ul = 3.0 aspirate_rate = 2 await hw_api.prepare_for_aspirate(mount) @@ -356,14 +356,14 @@ async def test_aspirate_old(decoy: Decoy, mock_feature_flags: None, dummy_instru decoy.when(config.feature_flags.use_old_aspiration_functions()).then_return(True) hw_api = await API.build_hardware_simulator( - attached_instruments=dummy_instruments, loop=asyncio.get_running_loop() + attached_instruments=dummy_instruments[0], loop=asyncio.get_running_loop() ) await hw_api.home() await hw_api.cache_instruments() mount = types.Mount.LEFT await hw_api.pick_up_tip(mount, 20.0) - + hw_api.set_working_volume(mount, 10) aspirate_ul = 3.0 aspirate_rate = 2 await hw_api.prepare_for_aspirate(mount) @@ -375,26 +375,26 @@ async def test_aspirate_old(decoy: Decoy, mock_feature_flags: None, dummy_instru async def test_aspirate_ot3(dummy_instruments_ot3, ot3_api_obj): hw_api = await ot3_api_obj( - attached_instruments=dummy_instruments_ot3, loop=asyncio.get_running_loop() + attached_instruments=dummy_instruments_ot3[0], loop=asyncio.get_running_loop() ) await hw_api.home() await hw_api.cache_instruments() mount = types.Mount.LEFT await hw_api.pick_up_tip(mount, 20.0) - + hw_api.set_working_volume(mount, 50) aspirate_ul = 3.0 aspirate_rate = 2 await hw_api.prepare_for_aspirate(mount) await hw_api.aspirate(mount, aspirate_ul, aspirate_rate) - new_plunger_pos = 71.212208 + new_plunger_pos = 71.1968 pos = await hw_api.current_position(mount) assert pos[Axis.B] == pytest.approx(new_plunger_pos) async def test_dispense_ot2(dummy_instruments): hw_api = await API.build_hardware_simulator( - attached_instruments=dummy_instruments, loop=asyncio.get_running_loop() + attached_instruments=dummy_instruments[0], loop=asyncio.get_running_loop() ) await hw_api.home() @@ -402,6 +402,7 @@ async def test_dispense_ot2(dummy_instruments): mount = types.Mount.LEFT await hw_api.pick_up_tip(mount, 20.0) + hw_api.set_working_volume(mount, 10) aspirate_ul = 10.0 aspirate_rate = 2 @@ -420,7 +421,7 @@ async def test_dispense_ot2(dummy_instruments): async def test_dispense_ot3(dummy_instruments_ot3, ot3_api_obj): hw_api = await ot3_api_obj( - attached_instruments=dummy_instruments_ot3, loop=asyncio.get_running_loop() + attached_instruments=dummy_instruments_ot3[0], loop=asyncio.get_running_loop() ) await hw_api.home() @@ -428,7 +429,7 @@ async def test_dispense_ot3(dummy_instruments_ot3, ot3_api_obj): mount = types.Mount.LEFT await hw_api.pick_up_tip(mount, 20.0) - + hw_api.set_working_volume(mount, 50) aspirate_ul = 10.0 aspirate_rate = 2 await hw_api.prepare_for_aspirate(mount) @@ -436,7 +437,7 @@ async def test_dispense_ot3(dummy_instruments_ot3, ot3_api_obj): dispense_1 = 3.0 await hw_api.dispense(mount, dispense_1) - plunger_pos_1 = 70.92099 + plunger_pos_1 = 70.938414 assert (await hw_api.current_position(mount))[Axis.B] == pytest.approx( plunger_pos_1 ) @@ -449,7 +450,7 @@ async def test_dispense_ot3(dummy_instruments_ot3, ot3_api_obj): async def test_no_pipette(sim_and_instr): - sim_builder, dummy_instruments = sim_and_instr + sim_builder, (dummy_instruments, _) = sim_and_instr hw_api = await sim_builder( attached_instruments=dummy_instruments, loop=asyncio.get_running_loop() ) @@ -462,7 +463,7 @@ async def test_no_pipette(sim_and_instr): async def test_pick_up_tip(is_robot, sim_and_instr): - sim_builder, dummy_instruments = sim_and_instr + sim_builder, (dummy_instruments, _) = sim_and_instr hw_api = await sim_builder( attached_instruments=dummy_instruments, loop=asyncio.get_running_loop() ) @@ -483,7 +484,7 @@ async def test_pick_up_tip(is_robot, sim_and_instr): async def test_pick_up_tip_pos_ot2(is_robot, dummy_instruments): hw_api = await API.build_hardware_simulator( - attached_instruments=dummy_instruments, loop=asyncio.get_running_loop() + attached_instruments=dummy_instruments[0], loop=asyncio.get_running_loop() ) mount = types.Mount.LEFT await hw_api.home() @@ -520,7 +521,7 @@ def assert_move_called(mock_move, speed, lock=None): async def test_aspirate_flow_rate(sim_and_instr): - sim_builder, dummy_instruments = sim_and_instr + sim_builder, (dummy_instruments, tip_vol) = sim_and_instr hw_api = await sim_builder( attached_instruments=dummy_instruments, loop=asyncio.get_running_loop() ) @@ -529,6 +530,7 @@ async def test_aspirate_flow_rate(sim_and_instr): await hw_api.cache_instruments() await hw_api.pick_up_tip(mount, 20.0) + hw_api.set_working_volume(mount, tip_vol) pip = hw_api.hardware_instruments[mount] with mock.patch.object(hw_api, "_move") as mock_move: @@ -577,7 +579,7 @@ async def test_aspirate_flow_rate(sim_and_instr): async def test_dispense_flow_rate(sim_and_instr): - sim_builder, dummy_instruments = sim_and_instr + sim_builder, (dummy_instruments, tip_vol) = sim_and_instr hw_api = await sim_builder( attached_instruments=dummy_instruments, loop=asyncio.get_running_loop() ) @@ -586,6 +588,7 @@ async def test_dispense_flow_rate(sim_and_instr): await hw_api.cache_instruments() await hw_api.pick_up_tip(mount, 20.0) + hw_api.set_working_volume(mount, tip_vol) await hw_api.prepare_for_aspirate(types.Mount.LEFT) await hw_api.aspirate(mount, 10) @@ -632,7 +635,7 @@ async def test_dispense_flow_rate(sim_and_instr): async def test_blowout_flow_rate(sim_and_instr): - sim_builder, dummy_instruments = sim_and_instr + sim_builder, (dummy_instruments, tip_vol) = sim_and_instr hw_api = await sim_builder( attached_instruments=dummy_instruments, loop=asyncio.get_running_loop() ) @@ -641,6 +644,7 @@ async def test_blowout_flow_rate(sim_and_instr): await hw_api.cache_instruments() await hw_api.pick_up_tip(mount, 20.0) + hw_api.set_working_volume(mount, tip_vol) pip = hw_api.hardware_instruments[mount] diff --git a/api/tests/opentrons/hardware_control/test_ot3_api.py b/api/tests/opentrons/hardware_control/test_ot3_api.py index 0dc40ee0d5c..314d0db85ce 100644 --- a/api/tests/opentrons/hardware_control/test_ot3_api.py +++ b/api/tests/opentrons/hardware_control/test_ot3_api.py @@ -274,6 +274,16 @@ async def mock_refresh(ot3_hardware: ThreadManager[OT3API]) -> Iterator[AsyncMoc yield mock_refresh +@pytest.fixture +async def mock_reset(ot3_hardware: ThreadManager[OT3API]) -> Iterator[AsyncMock]: + with patch.object( + ot3_hardware.managed_obj, + "reset", + AsyncMock(), + ) as mock_reset: + yield mock_reset + + @pytest.fixture async def mock_instrument_handlers( ot3_hardware: ThreadManager[OT3API], @@ -1744,3 +1754,29 @@ async def test_estop_event_deactivate_module( ) else: assert len(futures) == 0 + + +@pytest.mark.parametrize( + "jaw_state", + [ + GripperJawState.UNHOMED, + GripperJawState.HOMED_READY, + GripperJawState.GRIPPING, + GripperJawState.HOLDING, + ], +) +async def test_stop_only_home_necessary_axes( + ot3_hardware: ThreadManager[OT3API], + mock_home: AsyncMock, + mock_reset: AsyncMock, + jaw_state: GripperJawState, +): + gripper_config = gc.load(GripperModel.v1) + instr_data = AttachedGripper(config=gripper_config, id="test") + await ot3_hardware.cache_gripper(instr_data) + ot3_hardware._gripper_handler.get_gripper().current_jaw_displacement = 0 + ot3_hardware._gripper_handler.get_gripper().state = jaw_state + + await ot3_hardware.stop(home_after=True) + if jaw_state == GripperJawState.GRIPPING: + mock_home.assert_called_once_with(skip=[Axis.G]) diff --git a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py index 3b5dca26e82..b53fa674ef1 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py @@ -533,6 +533,7 @@ def test_dispense_to_well( rate=5.6, flow_rate=6.0, in_place=False, + push_out=7, ) decoy.verify( @@ -545,6 +546,7 @@ def test_dispense_to_well( ), volume=12.34, flow_rate=6.0, + push_out=7, ), mock_protocol_core.set_last_location(location=location, mount=Mount.LEFT), ) @@ -565,13 +567,12 @@ def test_dispense_in_place( well_core=None, location=location, in_place=True, + push_out=None, ) decoy.verify( mock_engine_client.dispense_in_place( - pipette_id="abc123", - volume=12.34, - flow_rate=7.8, + pipette_id="abc123", volume=12.34, flow_rate=7.8, push_out=None ), ) @@ -591,6 +592,7 @@ def test_dispense_to_coordinates( well_core=None, location=location, in_place=False, + push_out=None, ) decoy.verify( @@ -602,9 +604,7 @@ def test_dispense_to_coordinates( speed=None, ), mock_engine_client.dispense_in_place( - pipette_id="abc123", - volume=12.34, - flow_rate=7.8, + pipette_id="abc123", volume=12.34, flow_rate=7.8, push_out=None ), ) diff --git a/api/tests/opentrons/protocol_api/test_instrument_context.py b/api/tests/opentrons/protocol_api/test_instrument_context.py index 2a848151c75..3e201fad41e 100644 --- a/api/tests/opentrons/protocol_api/test_instrument_context.py +++ b/api/tests/opentrons/protocol_api/test_instrument_context.py @@ -717,6 +717,7 @@ def test_dispense_with_location( volume=42.0, rate=1.23, flow_rate=5.67, + push_out=None, ), times=1, ) @@ -744,7 +745,7 @@ def test_dispense_with_well_location( ).then_return(WellTarget(well=mock_well, location=input_location, in_place=False)) decoy.when(mock_instrument_core.get_dispense_flow_rate(1.23)).then_return(3.0) - subject.dispense(volume=42.0, location=input_location, rate=1.23) + subject.dispense(volume=42.0, location=input_location, rate=1.23, push_out=7) decoy.verify( mock_instrument_core.dispense( @@ -754,6 +755,7 @@ def test_dispense_with_well_location( volume=42.0, rate=1.23, flow_rate=3.0, + push_out=7, ), times=1, ) @@ -783,7 +785,7 @@ def test_dispense_with_well( decoy.when(mock_well.bottom(z=1.0)).then_return(bottom_location) decoy.when(mock_instrument_core.get_dispense_flow_rate(1.23)).then_return(5.67) - subject.dispense(volume=42.0, location=input_location, rate=1.23) + subject.dispense(volume=42.0, location=input_location, rate=1.23, push_out=None) decoy.verify( mock_instrument_core.dispense( @@ -793,6 +795,7 @@ def test_dispense_with_well( volume=42.0, rate=1.23, flow_rate=5.67, + push_out=None, ), times=1, ) @@ -815,6 +818,24 @@ def test_dispense_raises_no_location( subject.dispense(location=None) +@pytest.mark.parametrize("api_version", [APIVersion(2, 14)]) +def test_dispense_push_out_on_not_allowed_version( + decoy: Decoy, + mock_instrument_core: InstrumentCore, + subject: InstrumentContext, + mock_protocol_core: ProtocolCore, +) -> None: + """Should raise a APIVersionError.""" + decoy.when(mock_instrument_core.get_mount()).then_return(Mount.RIGHT) + decoy.when(mock_protocol_core.get_last_location(Mount.RIGHT)).then_return(None) + + decoy.when( + mock_validation.validate_location(location=None, last_location=None) + ).then_raise(mock_validation.NoLocationError()) + with pytest.raises(APIVersionError): + subject.dispense(push_out=3) + + def test_touch_tip( decoy: Decoy, mock_instrument_core: InstrumentCore, diff --git a/api/tests/opentrons/protocol_api/test_protocol_context.py b/api/tests/opentrons/protocol_api/test_protocol_context.py index b57a9aa2a7c..ca28d795a39 100644 --- a/api/tests/opentrons/protocol_api/test_protocol_context.py +++ b/api/tests/opentrons/protocol_api/test_protocol_context.py @@ -131,7 +131,7 @@ def test_load_instrument( mock_core: ProtocolCore, subject: ProtocolContext, ) -> None: - """It should create a instrument using its execution core.""" + """It should create an instrument using its execution core.""" mock_instrument_core = decoy.mock(cls=InstrumentCore) mock_tip_racks = [decoy.mock(cls=Labware), decoy.mock(cls=Labware)] @@ -206,6 +206,33 @@ def test_load_instrument_replace( subject.load_instrument(instrument_name="ada", mount=Mount.RIGHT) +def test_96_channel_pipette_always_loads_on_the_left_mount( + decoy: Decoy, + mock_core: ProtocolCore, + subject: ProtocolContext, +) -> None: + """It should always load a 96-channel pipette on left mount, regardless of the mount arg specified.""" + mock_instrument_core = decoy.mock(cls=InstrumentCore) + + decoy.when(mock_validation.ensure_lowercase_name("A 96 Channel Name")).then_return( + "a 96 channel name" + ) + decoy.when(mock_validation.ensure_pipette_name("a 96 channel name")).then_return( + PipetteNameType.P1000_96 + ) + decoy.when( + mock_core.load_instrument( + instrument_name=PipetteNameType.P1000_96, + mount=Mount.LEFT, + ) + ).then_return(mock_instrument_core) + + result = subject.load_instrument( + instrument_name="A 96 Channel Name", mount="shadowfax" + ) + assert result == subject.loaded_instruments["left"] + + def test_load_labware( decoy: Decoy, mock_core: ProtocolCore, diff --git a/api/tests/opentrons/protocol_api_old/core/simulator/test_instrument_context.py b/api/tests/opentrons/protocol_api_old/core/simulator/test_instrument_context.py index fcadaac81a2..35a152839c8 100644 --- a/api/tests/opentrons/protocol_api_old/core/simulator/test_instrument_context.py +++ b/api/tests/opentrons/protocol_api_old/core/simulator/test_instrument_context.py @@ -56,6 +56,7 @@ def test_dispense_no_tip(subject: InstrumentCore) -> None: location=location, well_core=None, in_place=False, + push_out=None, ) @@ -129,6 +130,7 @@ def test_pick_up_tip_prep_after( location=Location(point=Point(2, 2, 3), labware=None), well_core=labware.get_well_core("A2"), in_place=False, + push_out=None, ) subject.drop_tip(location=None, well_core=tip_core, home_after=True) @@ -156,6 +158,7 @@ def test_pick_up_tip_prep_after( location=Location(point=Point(2, 2, 3), labware=None), well_core=labware.get_well_core("A2"), in_place=False, + push_out=None, ) subject.drop_tip(location=None, well_core=tip_core, home_after=True) @@ -258,6 +261,7 @@ def _aspirate_dispense(i: InstrumentCore, labware: LabwareCore) -> None: location=Location(point=Point(2, 2, 3), labware=None), well_core=labware.get_well_core("A2"), in_place=False, + push_out=None, ) diff --git a/api/tests/opentrons/protocol_engine/clients/test_sync_client.py b/api/tests/opentrons/protocol_engine/clients/test_sync_client.py index 149689fd8cf..f172b93cab1 100644 --- a/api/tests/opentrons/protocol_engine/clients/test_sync_client.py +++ b/api/tests/opentrons/protocol_engine/clients/test_sync_client.py @@ -442,6 +442,7 @@ def test_dispense( ), volume=10, flowRate=2.0, + pushOut=None, ) ) @@ -458,6 +459,7 @@ def test_dispense( ), volume=10, flow_rate=2.0, + push_out=None, ) assert result == response @@ -471,9 +473,7 @@ def test_dispense_in_place( """It should execute a DispenceInPlace command.""" request = commands.DispenseInPlaceCreate( params=commands.DispenseInPlaceParams( - pipetteId="123", - volume=10, - flowRate=2.0, + pipetteId="123", volume=10, flowRate=2.0, pushOut=None ) ) @@ -482,9 +482,7 @@ def test_dispense_in_place( decoy.when(transport.execute_command(request=request)).then_return(response) result = subject.dispense_in_place( - pipette_id="123", - volume=10, - flow_rate=2.0, + pipette_id="123", volume=10, flow_rate=2.0, push_out=None ) assert result == response diff --git a/api/tests/opentrons/protocol_engine/commands/test_dispense.py b/api/tests/opentrons/protocol_engine/commands/test_dispense.py index a91b509ffd3..cb6737f535f 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_dispense.py +++ b/api/tests/opentrons/protocol_engine/commands/test_dispense.py @@ -44,9 +44,7 @@ async def test_dispense_implementation( decoy.when( await pipetting.dispense_in_place( - pipette_id="pipette-id-abc123", - volume=50, - flow_rate=1.23, + pipette_id="pipette-id-abc123", volume=50, flow_rate=1.23, push_out=None ) ).then_return(42) diff --git a/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py b/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py index cc1d150c67d..025d863d45b 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py @@ -25,9 +25,7 @@ async def test_dispense_in_place_implementation( decoy.when( await pipetting.dispense_in_place( - pipette_id="pipette-id-abc", - volume=123, - flow_rate=456, + pipette_id="pipette-id-abc", volume=123, flow_rate=456, push_out=None ) ).then_return(42) diff --git a/api/tests/opentrons/protocol_engine/commands/test_move_labware.py b/api/tests/opentrons/protocol_engine/commands/test_move_labware.py index 6e444d9cf17..0b76e8a2b56 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_move_labware.py +++ b/api/tests/opentrons/protocol_engine/commands/test_move_labware.py @@ -3,6 +3,8 @@ import pytest from decoy import Decoy +from opentrons_shared_data.labware.labware_definition import Parameters + from opentrons.types import DeckSlotName from opentrons.protocols.models import LabwareDefinition from opentrons.protocol_engine import errors, Config @@ -188,6 +190,11 @@ async def test_gripper_move_labware_implementation( dropOffset=None, ) + decoy.when( + state_view.labware.get_definition(labware_id="my-cool-labware-id") + ).then_return( + LabwareDefinition.construct(namespace="my-cool-namespace") # type: ignore[call-arg] + ) decoy.when(state_view.labware.get(labware_id="my-cool-labware-id")).then_return( LoadedLabware( id="my-cool-labware-id", @@ -217,6 +224,11 @@ async def test_gripper_move_labware_implementation( decoy.when( state_view.geometry.ensure_valid_gripper_location(new_location) ).then_return(validated_new_location) + decoy.when( + labware_validation.validate_gripper_compatible( + LabwareDefinition.construct(namespace="my-cool-namespace") # type: ignore[call-arg] + ) + ).then_return(True) result = await subject.execute(data) decoy.verify( @@ -403,6 +415,10 @@ async def test_move_labware_raises_when_moving_adapter_with_gripper( strategy=LabwareMovementStrategy.USING_GRIPPER, ) + definition = LabwareDefinition.construct( # type: ignore[call-arg] + parameters=Parameters.construct(loadName="My cool adapter"), # type: ignore[call-arg] + ) + decoy.when(state_view.labware.get(labware_id="my-cool-labware-id")).then_return( LoadedLabware( id="my-cool-labware-id", @@ -414,16 +430,65 @@ async def test_move_labware_raises_when_moving_adapter_with_gripper( ) decoy.when( state_view.labware.get_definition(labware_id="my-cool-labware-id") - ).then_return( - LabwareDefinition.construct(namespace="spacename") # type: ignore[call-arg] + ).then_return(definition) + decoy.when(labware_validation.validate_gripper_compatible(definition)).then_return( + True ) decoy.when( - labware_validation.validate_definition_is_adapter( - LabwareDefinition.construct(namespace="spacename") # type: ignore[call-arg] - ) + labware_validation.validate_definition_is_adapter(definition) ).then_return(True) - with pytest.raises(errors.LabwareMovementNotAllowedError, match="gripper"): + with pytest.raises( + errors.LabwareMovementNotAllowedError, match="move adapter 'My cool adapter'" + ): + await subject.execute(data) + + +async def test_move_labware_raises_when_moving_labware_with_gripper_incompatible_quirk( + decoy: Decoy, + equipment: EquipmentHandler, + labware_movement: LabwareMovementHandler, + state_view: StateView, + run_control: RunControlHandler, +) -> None: + """It should raise an error when trying to move an adapter with a gripper.""" + subject = MoveLabwareImplementation( + state_view=state_view, + equipment=equipment, + labware_movement=labware_movement, + run_control=run_control, + ) + + data = MoveLabwareParams( + labwareId="my-cool-labware-id", + newLocation=DeckSlotLocation(slotName=DeckSlotName.SLOT_4), + strategy=LabwareMovementStrategy.USING_GRIPPER, + ) + + definition = LabwareDefinition.construct( # type: ignore[call-arg] + parameters=Parameters.construct(loadName="My cool labware"), # type: ignore[call-arg] + ) + + decoy.when(state_view.labware.get(labware_id="my-cool-labware-id")).then_return( + LoadedLabware( + id="my-cool-labware-id", + loadName="load-name", + definitionUri="opentrons-test/load-name/1", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_4), + offsetId=None, + ) + ) + decoy.when( + state_view.labware.get_definition(labware_id="my-cool-labware-id") + ).then_return(definition) + decoy.when(labware_validation.validate_gripper_compatible(definition)).then_return( + False + ) + + with pytest.raises( + errors.LabwareMovementNotAllowedError, + match="Cannot move labware 'My cool labware' with gripper", + ): await subject.execute(data) @@ -466,3 +531,52 @@ async def test_move_labware_with_gripper_raises_on_ot2( ) with pytest.raises(errors.NotSupportedOnRobotType): await subject.execute(data) + + +async def test_move_labware_raises_when_moving_fixed_trash_labware( + decoy: Decoy, + equipment: EquipmentHandler, + labware_movement: LabwareMovementHandler, + state_view: StateView, + run_control: RunControlHandler, +) -> None: + """It should raise an error when trying to move a fixed trash.""" + subject = MoveLabwareImplementation( + state_view=state_view, + equipment=equipment, + labware_movement=labware_movement, + run_control=run_control, + ) + + data = MoveLabwareParams( + labwareId="my-cool-labware-id", + newLocation=DeckSlotLocation(slotName=DeckSlotName.FIXED_TRASH), + strategy=LabwareMovementStrategy.USING_GRIPPER, + ) + + definition = LabwareDefinition.construct( # type: ignore[call-arg] + parameters=Parameters.construct(loadName="My cool labware", quirks=["fixedTrash"]), # type: ignore[call-arg] + ) + + decoy.when(state_view.labware.get(labware_id="my-cool-labware-id")).then_return( + LoadedLabware( + id="my-cool-labware-id", + loadName="load-name", + definitionUri="opentrons-test/load-name/1", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_4), + offsetId=None, + ) + ) + decoy.when( + state_view.labware.get_definition(labware_id="my-cool-labware-id") + ).then_return(definition) + + decoy.when(state_view.labware.is_fixed_trash("my-cool-labware-id")).then_return( + True + ) + + with pytest.raises( + errors.LabwareMovementNotAllowedError, + match="Cannot move fixed trash labware 'My cool labware'.", + ): + await subject.execute(data) diff --git a/api/tests/opentrons/protocol_engine/conftest.py b/api/tests/opentrons/protocol_engine/conftest.py index b6a379db890..6431d70457f 100644 --- a/api/tests/opentrons/protocol_engine/conftest.py +++ b/api/tests/opentrons/protocol_engine/conftest.py @@ -224,4 +224,5 @@ def supported_tip_fixture() -> pipette_definition.SupportedTipsDefinition: aspirate=pipette_definition.ulPerMMDefinition(default={"1": [(0, 0, 0)]}), dispense=pipette_definition.ulPerMMDefinition(default={"1": [(0, 0, 0)]}), defaultBlowoutVolume=5, + defaultPushOutVolume=3, ) diff --git a/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py b/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py index 5030763c3cc..96e0cc3ea88 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py +++ b/api/tests/opentrons/protocol_engine/execution/test_hardware_stopper.py @@ -15,7 +15,7 @@ TipHandler, HardwareStopper, ) -from opentrons.protocol_engine.types import MotorAxis, TipGeometry +from opentrons.protocol_engine.types import MotorAxis, TipGeometry, PostRunHardwareState if TYPE_CHECKING: from opentrons.hardware_control.ot3api import OT3API @@ -69,9 +69,18 @@ async def test_hardware_halt( """It should halt the hardware API.""" await subject.do_halt() - decoy.verify(await hardware_api.halt()) + decoy.verify(await hardware_api.halt(disengage_before_stopping=False)) +@pytest.mark.parametrize( + argnames=["post_run_hardware_state", "expected_home_after"], + argvalues=[ + (PostRunHardwareState.STAY_ENGAGED_IN_PLACE, False), + (PostRunHardwareState.DISENGAGE_IN_PLACE, False), + (PostRunHardwareState.HOME_AND_STAY_ENGAGED, True), + (PostRunHardwareState.HOME_THEN_DISENGAGE, True), + ], +) async def test_hardware_stopping_sequence( decoy: Decoy, state_store: StateStore, @@ -79,6 +88,8 @@ async def test_hardware_stopping_sequence( movement: MovementHandler, mock_tip_handler: TipHandler, subject: HardwareStopper, + post_run_hardware_state: PostRunHardwareState, + expected_home_after: bool, ) -> None: """It should stop the hardware, home the robot and perform drop tip if required.""" decoy.when(state_store.pipettes.get_all_attached_tips()).then_return( @@ -87,7 +98,9 @@ async def test_hardware_stopping_sequence( ] ) - await subject.do_stop_and_recover(drop_tips_and_home=True) + await subject.do_stop_and_recover( + drop_tips_after_run=True, post_run_hardware_state=post_run_hardware_state + ) decoy.verify( await hardware_api.stop(home_after=False), @@ -104,7 +117,7 @@ async def test_hardware_stopping_sequence( well_name="A1", ), await mock_tip_handler.drop_tip(pipette_id="pipette-id", home_after=False), - await hardware_api.stop(home_after=True), + await hardware_api.stop(home_after=expected_home_after), ) @@ -117,14 +130,17 @@ async def test_hardware_stopping_sequence_without_pipette_tips( """Don't drop tip when there aren't any tips attached to pipettes.""" decoy.when(state_store.pipettes.get_all_attached_tips()).then_return([]) - await subject.do_stop_and_recover(drop_tips_and_home=True) + await subject.do_stop_and_recover( + drop_tips_after_run=True, + post_run_hardware_state=PostRunHardwareState.HOME_AND_STAY_ENGAGED, + ) decoy.verify( await hardware_api.stop(home_after=True), ) -async def test_hardware_stopping_sequence_no_home( +async def test_hardware_stopping_sequence_no_tip_drop( decoy: Decoy, state_store: StateStore, hardware_api: HardwareAPI, @@ -138,7 +154,10 @@ async def test_hardware_stopping_sequence_no_home( ] ) - await subject.do_stop_and_recover(drop_tips_and_home=False) + await subject.do_stop_and_recover( + drop_tips_after_run=False, + post_run_hardware_state=PostRunHardwareState.DISENGAGE_IN_PLACE, + ) decoy.verify(await hardware_api.stop(home_after=False), times=1) @@ -172,7 +191,10 @@ async def test_hardware_stopping_sequence_no_pipette( ), ).then_raise(HwPipetteNotAttachedError("oh no")) - await subject.do_stop_and_recover(drop_tips_and_home=True) + await subject.do_stop_and_recover( + drop_tips_after_run=True, + post_run_hardware_state=PostRunHardwareState.HOME_AND_STAY_ENGAGED, + ) decoy.verify( await hardware_api.stop(home_after=True), @@ -202,7 +224,10 @@ async def test_hardware_stopping_sequence_with_gripper( ) decoy.when(state_store.config.use_virtual_gripper).then_return(False) decoy.when(ot3_hardware_api.has_gripper()).then_return(True) - await subject.do_stop_and_recover(drop_tips_and_home=True) + await subject.do_stop_and_recover( + drop_tips_after_run=True, + post_run_hardware_state=PostRunHardwareState.HOME_AND_STAY_ENGAGED, + ) decoy.verify( await ot3_hardware_api.stop(home_after=False), diff --git a/api/tests/opentrons/protocol_engine/execution/test_labware_movement_handler.py b/api/tests/opentrons/protocol_engine/execution/test_labware_movement_handler.py index 70682e814db..0934b6d1c10 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_labware_movement_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_labware_movement_handler.py @@ -8,7 +8,6 @@ from typing import TYPE_CHECKING, Union from opentrons.protocol_engine.execution import EquipmentHandler, MovementHandler -from opentrons_shared_data.gripper.constants import IDLE_STATE_GRIP_FORCE from opentrons.hardware_control import HardwareControlAPI from opentrons.types import DeckSlotName, Point @@ -167,6 +166,9 @@ async def test_move_labware_with_gripper( decoy.when(state_store.config.use_virtual_gripper).then_return(False) decoy.when(ot3_hardware_api.has_gripper()).then_return(True) + decoy.when(ot3_hardware_api._gripper_handler.is_ready_for_jaw_home()).then_return( + True + ) decoy.when( await ot3_hardware_api.gantry_position(mount=OT3Mount.GRIPPER) @@ -257,9 +259,7 @@ async def test_move_labware_with_gripper( await ot3_hardware_api.move_to( mount=gripper, abs_position=expected_waypoints[5] ), - await ot3_hardware_api.grip( - force_newtons=IDLE_STATE_GRIP_FORCE, stay_engaged=False - ), + await ot3_hardware_api.idle_gripper(), ) diff --git a/api/tests/opentrons/protocol_engine/execution/test_pipetting_handler.py b/api/tests/opentrons/protocol_engine/execution/test_pipetting_handler.py index 28fa1004331..63a9a052450 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_pipetting_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_pipetting_handler.py @@ -18,6 +18,7 @@ from opentrons.protocol_engine.errors.exceptions import ( TipNotAttachedError, InvalidPipettingVolumeError, + InvalidPushOutVolumeError, ) @@ -153,9 +154,7 @@ async def test_dispense_in_place( ) result = await hardware_subject.dispense_in_place( - pipette_id="pipette-id", - volume=25, - flow_rate=2.5, + pipette_id="pipette-id", volume=25, flow_rate=2.5, push_out=None ) assert result == 25 @@ -164,13 +163,46 @@ async def test_dispense_in_place( mock_hardware_api.set_flow_rate( mount=Mount.RIGHT, aspirate=None, dispense=2.5, blow_out=None ), - await mock_hardware_api.dispense(mount=Mount.RIGHT, volume=25), + await mock_hardware_api.dispense(mount=Mount.RIGHT, volume=25, push_out=None), mock_hardware_api.set_flow_rate( mount=Mount.RIGHT, aspirate=1.23, dispense=4.56, blow_out=7.89 ), ) +async def test_dispense_in_place_raises_invalid_push_out( + decoy: Decoy, + mock_state_view: StateView, + mock_hardware_api: HardwareAPI, + hardware_subject: HardwarePipettingHandler, +) -> None: + """It should raise an InvalidPushOutVolumeError.""" + decoy.when(mock_hardware_api.attached_instruments).then_return({}) + decoy.when( + mock_state_view.pipettes.get_hardware_pipette( + pipette_id="pipette-id", + attached_pipettes={}, + ) + ).then_return( + HardwarePipette( + mount=Mount.RIGHT, + config=cast( + PipetteDict, + { + "aspirate_flow_rate": 1.23, + "dispense_flow_rate": 4.56, + "blow_out_flow_rate": 7.89, + }, + ), + ) + ) + + with pytest.raises(InvalidPushOutVolumeError): + await hardware_subject.dispense_in_place( + pipette_id="pipette-id", volume=25, flow_rate=2.5, push_out=-7 + ) + + async def test_aspirate_in_place( decoy: Decoy, mock_state_view: StateView, @@ -331,11 +363,27 @@ async def test_dispense_in_place_virtual( ) result = await subject.dispense_in_place( - pipette_id="pipette-id", volume=3, flow_rate=5 + pipette_id="pipette-id", volume=3, flow_rate=5, push_out=None ) assert result == 3 +async def test_dispense_in_place_virtual_raises_invalid_push_out( + decoy: Decoy, mock_state_view: StateView +) -> None: + """Should raise an InvalidPushOutVolumeError.""" + subject = VirtualPipettingHandler(state_view=mock_state_view) + + decoy.when(mock_state_view.pipettes.get_attached_tip("pipette-id")).then_return( + TipGeometry(length=1, diameter=2, volume=3) + ) + + with pytest.raises(InvalidPushOutVolumeError): + await subject.dispense_in_place( + pipette_id="pipette-id", volume=3, flow_rate=5, push_out=-7 + ) + + async def test_validate_tip_attached_in_blow_out( mock_state_view: StateView, decoy: Decoy ) -> None: @@ -379,4 +427,6 @@ async def test_validate_tip_attached_in_dispense( with pytest.raises( TipNotAttachedError, match="Cannot perform dispense without a tip attached" ): - await subject.dispense_in_place("pipette-id", volume=20, flow_rate=1) + await subject.dispense_in_place( + "pipette-id", volume=20, flow_rate=1, push_out=None + ) diff --git a/api/tests/opentrons/protocol_engine/resources/test_labware_validation.py b/api/tests/opentrons/protocol_engine/resources/test_labware_validation.py index 27341855da5..663aec7337f 100644 --- a/api/tests/opentrons/protocol_engine/resources/test_labware_validation.py +++ b/api/tests/opentrons/protocol_engine/resources/test_labware_validation.py @@ -1,7 +1,11 @@ """Test labware validation.""" import pytest -from opentrons_shared_data.labware.labware_definition import LabwareRole, OverlapOffset +from opentrons_shared_data.labware.labware_definition import ( + LabwareRole, + OverlapOffset, + Parameters, +) from opentrons.protocols.models import LabwareDefinition from opentrons.protocol_engine.resources import labware_validation as subject @@ -53,3 +57,18 @@ def test_validate_labware_can_be_stacked( subject.validate_labware_can_be_stacked(definition, "labware123") == expected_result ) + + +@pytest.mark.parametrize( + ("definition", "expected_result"), + [ + (LabwareDefinition.construct(parameters=Parameters.construct(quirks=None)), True), # type: ignore[call-arg] + (LabwareDefinition.construct(parameters=Parameters.construct(quirks=["foo"])), True), # type: ignore[call-arg] + (LabwareDefinition.construct(parameters=Parameters.construct(quirks=["gripperIncompatible"])), False), # type: ignore[call-arg] + ], +) +def test_validate_gripper_compatible( + definition: LabwareDefinition, expected_result: bool +) -> None: + """It should validate if definition is defined as an adapter.""" + assert subject.validate_gripper_compatible(definition) == expected_result diff --git a/api/tests/opentrons/protocol_engine/resources/test_pipette_data_provider.py b/api/tests/opentrons/protocol_engine/resources/test_pipette_data_provider.py index 280bdbcc321..4dffa6ed6f9 100644 --- a/api/tests/opentrons/protocol_engine/resources/test_pipette_data_provider.py +++ b/api/tests/opentrons/protocol_engine/resources/test_pipette_data_provider.py @@ -81,6 +81,7 @@ def test_get_pipette_static_config( "default_dispense_speeds": {"2.0": 5.021202, "2.6": 10.042404}, "default_aspirate_speeds": {"2.0": 5.021202, "2.6": 10.042404}, "default_blow_out_volume": 10, + "default_push_out_volume": 3, "supported_tips": {pip_types.PipetteTipType.t300: supported_tip_fixture}, } diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index b6245f5f1bc..b6740482d04 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -944,8 +944,11 @@ def test_get_tip_drop_location( labware_view: LabwareView, mock_pipette_view: PipetteView, subject: GeometryView, + tip_rack_def: LabwareDefinition, ) -> None: """It should get relative drop tip location for a pipette/labware combo.""" + decoy.when(labware_view.get_definition("tip-rack-id")).then_return(tip_rack_def) + decoy.when(mock_pipette_view.get_return_tip_scale("pipette-id")).then_return(0.5) decoy.when( @@ -966,15 +969,14 @@ def test_get_tip_drop_location( assert location == WellLocation(offset=WellOffset(x=1, y=2, z=1337)) -def test_get_tip_drop_location_with_trash( +def test_get_tip_drop_location_with_non_tiprack( decoy: Decoy, labware_view: LabwareView, subject: GeometryView, + reservoir_def: LabwareDefinition, ) -> None: - """It should get relative drop tip location for a the fixed trash.""" - decoy.when( - labware_view.get_has_quirk(labware_id="labware-id", quirk="fixedTrash") - ).then_return(True) + """It should get relative drop tip location for a labware that is not a tiprack.""" + decoy.when(labware_view.get_definition("labware-id")).then_return(reservoir_def) location = subject.get_checked_tip_drop_location( pipette_id="pipette-id", diff --git a/api/tests/opentrons/protocol_engine/state/test_labware_view.py b/api/tests/opentrons/protocol_engine/state/test_labware_view.py index 535c2c68694..7d277d93b5a 100644 --- a/api/tests/opentrons/protocol_engine/state/test_labware_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_labware_view.py @@ -10,6 +10,8 @@ Parameters, LabwareRole, OverlapOffset as SharedDataOverlapOffset, + GripperOffsets, + OffsetVector, ) from opentrons.protocols.models import LabwareDefinition from opentrons.types import DeckSlotName, Point, MountType @@ -1346,6 +1348,49 @@ def test_get_labware_gripper_offsets( ) +def test_get_labware_gripper_offsets_default_no_slots( + well_plate_def: LabwareDefinition, + adapter_plate_def: LabwareDefinition, +) -> None: + """It should get the labware's gripper offsets with only a default gripper offset entry.""" + subject = get_labware_view( + labware_by_id={ + "labware-id": LoadedLabware( + id="labware-id", + loadName="labware-load-name", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_1), + definitionUri="some-labware-uri", + offsetId=None, + displayName="Fancy Labware Name", + ) + }, + definitions_by_uri={ + "some-labware-uri": LabwareDefinition.construct( # type: ignore[call-arg] + gripperOffsets={ + "default": GripperOffsets( + pickUpOffset=OffsetVector(x=1, y=2, z=3), + dropOffset=OffsetVector(x=4, y=5, z=6), + ) + } + ), + }, + ) + + assert ( + subject.get_labware_gripper_offsets( + labware_id="labware-id", slot_name=DeckSlotName.SLOT_D1 + ) + is None + ) + + assert subject.get_labware_gripper_offsets( + labware_id="labware-id", slot_name=None + ) == LabwareMovementOffsetData( + pickUpOffset=LabwareOffsetVector(x=1, y=2, z=3), + dropOffset=LabwareOffsetVector(x=4, y=5, z=6), + ) + + def test_get_grip_force( flex_50uL_tiprack: LabwareDefinition, reservoir_def: LabwareDefinition, diff --git a/api/tests/opentrons/protocol_engine/test_protocol_engine.py b/api/tests/opentrons/protocol_engine/test_protocol_engine.py index 09709be4046..8ae068e7480 100644 --- a/api/tests/opentrons/protocol_engine/test_protocol_engine.py +++ b/api/tests/opentrons/protocol_engine/test_protocol_engine.py @@ -27,6 +27,7 @@ ModuleDefinition, ModuleModel, Liquid, + PostRunHardwareState, ) from opentrons.protocol_engine.execution import ( QueueWorker, @@ -408,8 +409,17 @@ def test_pause( ) -@pytest.mark.parametrize("drop_tips_and_home", [True, False]) +@pytest.mark.parametrize("drop_tips_after_run", [True, False]) @pytest.mark.parametrize("set_run_status", [True, False]) +@pytest.mark.parametrize( + argnames=["post_run_hardware_state", "expected_halt_disengage"], + argvalues=[ + (PostRunHardwareState.HOME_AND_STAY_ENGAGED, True), + (PostRunHardwareState.HOME_THEN_DISENGAGE, True), + (PostRunHardwareState.STAY_ENGAGED_IN_PLACE, False), + (PostRunHardwareState.DISENGAGE_IN_PLACE, True), + ], +) async def test_finish( decoy: Decoy, action_dispatcher: ActionDispatcher, @@ -417,8 +427,10 @@ async def test_finish( queue_worker: QueueWorker, subject: ProtocolEngine, hardware_stopper: HardwareStopper, - drop_tips_and_home: bool, + drop_tips_after_run: bool, set_run_status: bool, + post_run_hardware_state: PostRunHardwareState, + expected_halt_disengage: bool, model_utils: ModelUtils, state_store: StateStore, door_watcher: DoorWatcher, @@ -430,16 +442,21 @@ async def test_finish( decoy.when(state_store.commands.state.stopped_by_estop).then_return(False) await subject.finish( - drop_tips_and_home=drop_tips_and_home, + drop_tips_after_run=drop_tips_after_run, set_run_status=set_run_status, + post_run_hardware_state=post_run_hardware_state, ) decoy.verify( action_dispatcher.dispatch(FinishAction(set_run_status=set_run_status)), await queue_worker.join(), + await hardware_stopper.do_halt( + disengage_before_stopping=expected_halt_disengage + ), door_watcher.stop(), await hardware_stopper.do_stop_and_recover( - drop_tips_and_home=drop_tips_and_home + drop_tips_after_run=drop_tips_after_run, + post_run_hardware_state=post_run_hardware_state, ), await plugin_starter.stop(), action_dispatcher.dispatch( @@ -461,11 +478,21 @@ async def test_finish_with_defaults( decoy.verify( action_dispatcher.dispatch(FinishAction(set_run_status=True)), - await hardware_stopper.do_stop_and_recover(drop_tips_and_home=True), + await hardware_stopper.do_halt(disengage_before_stopping=True), + await hardware_stopper.do_stop_and_recover( + drop_tips_after_run=True, + post_run_hardware_state=PostRunHardwareState.HOME_AND_STAY_ENGAGED, + ), ) -@pytest.mark.parametrize("stopped_by_estop", [True, False]) +@pytest.mark.parametrize( + argnames=["stopped_by_estop", "expected_drop_tips", "expected_end_state"], + argvalues=[ + (True, False, PostRunHardwareState.DISENGAGE_IN_PLACE), + (False, True, PostRunHardwareState.HOME_AND_STAY_ENGAGED), + ], +) async def test_finish_with_error( decoy: Decoy, action_dispatcher: ActionDispatcher, @@ -477,6 +504,8 @@ async def test_finish_with_error( door_watcher: DoorWatcher, state_store: StateStore, stopped_by_estop: bool, + expected_drop_tips: bool, + expected_end_state: PostRunHardwareState, ) -> None: """It should be able to tell the engine it's finished because of an error.""" error = RuntimeError("oh no") @@ -501,9 +530,11 @@ async def test_finish_with_error( FinishAction(error_details=expected_error_details, set_run_status=True) ), await queue_worker.join(), + await hardware_stopper.do_halt(disengage_before_stopping=True), door_watcher.stop(), await hardware_stopper.do_stop_and_recover( - drop_tips_and_home=not stopped_by_estop + drop_tips_after_run=expected_drop_tips, + post_run_hardware_state=expected_end_state, ), await plugin_starter.stop(), action_dispatcher.dispatch( @@ -551,8 +582,12 @@ async def test_finish_with_estop_error_will_not_drop_tip_and_home( FinishAction(error_details=expected_error_details, set_run_status=True) ), await queue_worker.join(), + await hardware_stopper.do_halt(disengage_before_stopping=True), door_watcher.stop(), - await hardware_stopper.do_stop_and_recover(drop_tips_and_home=False), + await hardware_stopper.do_stop_and_recover( + drop_tips_after_run=False, + post_run_hardware_state=PostRunHardwareState.DISENGAGE_IN_PLACE, + ), await plugin_starter.stop(), action_dispatcher.dispatch( HardwareStoppedAction( @@ -594,9 +629,12 @@ async def test_finish_stops_hardware_if_queue_worker_join_fails( action_dispatcher.dispatch(FinishAction()), # await queue_worker.join() should be called, and should raise, here. # We can't verify that step in the sequence here because of a Decoy limitation. - await hardware_stopper.do_halt(), + await hardware_stopper.do_halt(disengage_before_stopping=True), door_watcher.stop(), - await hardware_stopper.do_stop_and_recover(drop_tips_and_home=True), + await hardware_stopper.do_stop_and_recover( + drop_tips_after_run=True, + post_run_hardware_state=PostRunHardwareState.HOME_AND_STAY_ENGAGED, + ), await plugin_starter.stop(), action_dispatcher.dispatch( HardwareStoppedAction( diff --git a/api/tests/opentrons/protocol_runner/test_protocol_runner.py b/api/tests/opentrons/protocol_runner/test_protocol_runner.py index 42f57270d1a..aa9e36d5740 100644 --- a/api/tests/opentrons/protocol_runner/test_protocol_runner.py +++ b/api/tests/opentrons/protocol_runner/test_protocol_runner.py @@ -11,6 +11,7 @@ from opentrons.broker import Broker from opentrons.equipment_broker import EquipmentBroker from opentrons.hardware_control import API as HardwareAPI +from opentrons.protocol_engine.types import PostRunHardwareState from opentrons.protocols.api_support.types import APIVersion from opentrons.protocols.parse import PythonParseMode from opentrons_shared_data.protocol.models import ProtocolSchemaV6, ProtocolSchemaV7 @@ -275,7 +276,11 @@ async def test_stop_when_run_never_started( await subject.stop() decoy.verify( - await protocol_engine.finish(drop_tips_and_home=False, set_run_status=False), + await protocol_engine.finish( + drop_tips_after_run=False, + set_run_status=False, + post_run_hardware_state=PostRunHardwareState.STAY_ENGAGED_IN_PLACE, + ), times=1, ) diff --git a/api/tests/opentrons/system/test_nmcli.py b/api/tests/opentrons/system/test_nmcli.py index d587dcdbd2d..2ef07b7403e 100644 --- a/api/tests/opentrons/system/test_nmcli.py +++ b/api/tests/opentrons/system/test_nmcli.py @@ -133,7 +133,7 @@ async def mock_call(cmd, suppress_err=False): return mock_nmcli_output, "" monkeypatch.setattr(nmcli, "_call", mock_call) - result = await nmcli.available_ssids() + result = await nmcli.available_ssids(True) assert result == expected diff --git a/app-shell-odd/Makefile b/app-shell-odd/Makefile index 5d67fef6db7..5dd7ca92736 100644 --- a/app-shell-odd/Makefile +++ b/app-shell-odd/Makefile @@ -72,9 +72,9 @@ dist-ot3: package-deps .PHONY: push-ot3 push-ot3: dist-ot3 tar -zcvf opentrons-robot-app.tar.gz -C ./dist/linux-arm64-unpacked/ ./ - scp -r $(ssh_opts) ./opentrons-robot-app.tar.gz root@$(host): - ssh $(ssh_opts) root@$(host) "mount -o remount,rw / && systemctl stop opentrons-robot-app && rm -rf /opt/opentrons-app && mkdir -p /opt/opentrons-app" - ssh $(ssh_opts) root@$(host) "tar -xvf opentrons-robot-app.tar.gz -C /opt/opentrons-app/ && mount -o remount,ro / && systemctl start opentrons-robot-app && rm -rf opentrons-robot-app.tar.gz" + scp $(if $(ssh_key),-i $(ssh_key)) $(ssh_opts) -r ./opentrons-robot-app.tar.gz root@$(host): + ssh $(if $(ssh_key),-i $(ssh_key)) $(ssh_opts) root@$(host) "mount -o remount,rw / && systemctl stop opentrons-robot-app && rm -rf /opt/opentrons-app && mkdir -p /opt/opentrons-app" + ssh $(if $(ssh_key),-i $(ssh_key)) $(ssh_opts) root@$(host) "tar -xvf opentrons-robot-app.tar.gz -C /opt/opentrons-app/ && mount -o remount,ro / && systemctl start opentrons-robot-app && rm -rf opentrons-robot-app.tar.gz" rm -rf opentrons-robot-app.tar.gz # development diff --git a/app-shell-odd/package.json b/app-shell-odd/package.json index 2647af12ce8..bf0191b8c48 100644 --- a/app-shell-odd/package.json +++ b/app-shell-odd/package.json @@ -44,7 +44,6 @@ "dateformat": "3.0.3", "electron-debug": "3.0.1", "electron-devtools-installer": "3.2.0", - "electron-dl": "1.14.0", "electron-store": "5.1.1", "electron-updater": "4.1.2", "execa": "4.0.0", diff --git a/app-shell-odd/src/config/__fixtures__/index.ts b/app-shell-odd/src/config/__fixtures__/index.ts index 485d9494a6f..b6f6d211253 100644 --- a/app-shell-odd/src/config/__fixtures__/index.ts +++ b/app-shell-odd/src/config/__fixtures__/index.ts @@ -6,6 +6,7 @@ import type { ConfigV16, ConfigV17, ConfigV18, + ConfigV19, } from '@opentrons/app/src/redux/config/types' export const MOCK_CONFIG_V12: ConfigV12 = { @@ -108,3 +109,12 @@ export const MOCK_CONFIG_V18: ConfigV18 = { })(), version: 18, } + +export const MOCK_CONFIG_V19: ConfigV19 = { + ...MOCK_CONFIG_V18, + version: 19, + update: { + ...MOCK_CONFIG_V18.update, + hasJustUpdated: false, + }, +} diff --git a/app-shell-odd/src/config/__tests__/migrate.test.ts b/app-shell-odd/src/config/__tests__/migrate.test.ts index c9605aa4eae..bb197986621 100644 --- a/app-shell-odd/src/config/__tests__/migrate.test.ts +++ b/app-shell-odd/src/config/__tests__/migrate.test.ts @@ -7,63 +7,74 @@ import { MOCK_CONFIG_V16, MOCK_CONFIG_V17, MOCK_CONFIG_V18, + MOCK_CONFIG_V19, } from '../__fixtures__' import { migrate } from '../migrate' +const NEWEST_VERSION = 19 + describe('config migration', () => { it('should migrate version 12 to latest', () => { const v12Config = MOCK_CONFIG_V12 const result = migrate(v12Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 13 to latest', () => { const v13Config = MOCK_CONFIG_V13 const result = migrate(v13Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 14 to latest', () => { const v14Config = MOCK_CONFIG_V14 const result = migrate(v14Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 15 to latest', () => { const v15Config = MOCK_CONFIG_V15 const result = migrate(v15Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 16 to latest', () => { const v16Config = MOCK_CONFIG_V16 const result = migrate(v16Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 17 to latest', () => { const v17Config = MOCK_CONFIG_V17 const result = migrate(v17Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should keep version 18', () => { const v18Config = MOCK_CONFIG_V18 const result = migrate(v18Config) - expect(result.version).toBe(18) - expect(result).toEqual(v18Config) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) + }) + + it('should keep version 19', () => { + const v19Config = MOCK_CONFIG_V19 + const result = migrate(v19Config) + + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(v19Config) }) }) diff --git a/app-shell-odd/src/config/migrate.ts b/app-shell-odd/src/config/migrate.ts index c89d71f3371..48d45f5cc3c 100644 --- a/app-shell-odd/src/config/migrate.ts +++ b/app-shell-odd/src/config/migrate.ts @@ -12,6 +12,7 @@ import type { ConfigV16, ConfigV17, ConfigV18, + ConfigV19, } from '@opentrons/app/src/redux/config/types' // format // base config v12 defaults @@ -143,13 +144,26 @@ const toVersion18 = (prevConfig: ConfigV17): ConfigV18 => { } } +const toVersion19 = (prevConfig: ConfigV18): ConfigV19 => { + const nextConfig = { + ...prevConfig, + version: 19 as const, + update: { + ...prevConfig.update, + hasJustUpdated: false, + }, + } + return nextConfig +} + const MIGRATIONS: [ (prevConfig: ConfigV12) => ConfigV13, (prevConfig: ConfigV13) => ConfigV14, (prevConfig: ConfigV14) => ConfigV15, (prevConfig: ConfigV15) => ConfigV16, (prevConfig: ConfigV16) => ConfigV17, - (prevConfig: ConfigV17) => ConfigV18 + (prevConfig: ConfigV17) => ConfigV18, + (prevConfig: ConfigV18) => ConfigV19 ] = [ toVersion13, toVersion14, @@ -157,6 +171,7 @@ const MIGRATIONS: [ toVersion16, toVersion17, toVersion18, + toVersion19, ] export const DEFAULTS: Config = migrate(DEFAULTS_V12) @@ -170,6 +185,7 @@ export function migrate( | ConfigV16 | ConfigV17 | ConfigV18 + | ConfigV19 ): Config { let result = prevConfig // loop through the migrations, skipping any migrations that are unnecessary diff --git a/app-shell-odd/src/discovery.ts b/app-shell-odd/src/discovery.ts index 8e44554a3d6..bbe84cc14a9 100644 --- a/app-shell-odd/src/discovery.ts +++ b/app-shell-odd/src/discovery.ts @@ -28,7 +28,7 @@ import type { } from '@opentrons/discovery-client' import type { Action, Dispatch } from './types' -import type { Config } from './config' +import type { ConfigV1 } from '@opentrons/app/src/redux/config/schema-types' const log = createLogger('discovery') @@ -42,7 +42,7 @@ interface DiscoveryStore { services?: LegacyService[] } -let config: Config['discovery'] +let config: ConfigV1['discovery'] let store: Store let client: DiscoveryClient diff --git a/app-shell-odd/src/main.ts b/app-shell-odd/src/main.ts index 55019ceb0d6..63c218b56ef 100644 --- a/app-shell-odd/src/main.ts +++ b/app-shell-odd/src/main.ts @@ -5,7 +5,6 @@ import path from 'path' import { createUi } from './ui' import { createLogger } from './log' import { registerDiscovery } from './discovery' -import { registerRobotLogs } from './robot-logs' import { registerUpdate, updateLatestVersion, @@ -91,7 +90,6 @@ function startUp(): void { const actionHandlers: Dispatch[] = [ registerConfig(dispatch), registerDiscovery(dispatch), - registerRobotLogs(dispatch, mainWindow), registerUpdate(dispatch), registerRobotSystemUpdate(dispatch), registerAppRestart(), diff --git a/app-shell-odd/src/robot-logs.ts b/app-shell-odd/src/robot-logs.ts deleted file mode 100644 index e8ae69dce39..00000000000 --- a/app-shell-odd/src/robot-logs.ts +++ /dev/null @@ -1,49 +0,0 @@ -// download robot logs manager -import { download } from 'electron-dl' -import { createLogger } from './log' - -import type { BrowserWindow } from 'electron' -import type { Action, Dispatch } from './types' -import systemd from './systemd' - -const log = createLogger('robot-logs') - -export function registerRobotLogs( - dispatch: Dispatch, - mainWindow: BrowserWindow -): (action: Action) => unknown { - return function handleIncomingAction(action: Action): void { - switch (action.type) { - case 'shell:DOWNLOAD_LOGS': - const { logUrls } = action.payload as { logUrls: string[] } - - log.debug('Downloading robot logs', { logUrls }) - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - logUrls - .reduce>((result, url, index) => { - return result.then(() => { - return download(mainWindow, url, { - saveAs: true, - openFolderWhenDone: index === logUrls.length - 1, - }) - }) - }, Promise.resolve()) - .catch((error: unknown) => { - log.error('Error downloading robot logs', { error }) - }) - .then(() => dispatch({ type: 'shell:DOWNLOAD_LOGS_DONE' })) - break - case 'shell:SEND_LOG': - systemd - .sendStatus(action.payload.message) - .catch((e: Error) => - console.error(`error sending status to systemd ${e.message}`) - ) - console.log( - `shell message from browser layer: ${action.payload.message}` - ) - break - } - } -} diff --git a/app-shell/Makefile b/app-shell/Makefile index 7afc2ec6d0a..c8bd9be4e14 100644 --- a/app-shell/Makefile +++ b/app-shell/Makefile @@ -23,7 +23,7 @@ dist_files = $(if $(filter $(1),robot-stack),"dist/**/Opentrons-*","dist/**/Open update_files := "dist/@(alpha|beta|latest)*.@(yml|json)" publish_dir := dist/publish -# Other SSH args for buildroot robots +# Other SSH args for robot ssh_opts ?= $(default_ssh_opts) # TODO(mc, 2018-03-27): move all this to some sort of envfile diff --git a/app-shell/build/release-notes.md b/app-shell/build/release-notes.md index 649805cad53..ec1da4bbf28 100644 --- a/app-shell/build/release-notes.md +++ b/app-shell/build/release-notes.md @@ -21,6 +21,10 @@ Welcome to the v7.0.0 release of the Opentrons App! This release adds support fo - Manually move labware around the deck during protocols. The app shows animated instructions for which labware to move, and lets you resume the protocol when movement is complete. - See when your protocol will pause. During a run, marks on the protocol timeline show all pauses that require user attention, including labware movement. +### Improved Features + +- The app loads various pages faster. + --- ## Opentrons App Changes in 6.3.1 diff --git a/app-shell/package.json b/app-shell/package.json index 31c93d5e990..abe24f5da46 100644 --- a/app-shell/package.json +++ b/app-shell/package.json @@ -48,7 +48,6 @@ "electron-context-menu": "^3.5.0", "electron-debug": "3.0.1", "electron-devtools-installer": "3.2.0", - "electron-dl": "1.14.0", "electron-store": "5.1.1", "electron-updater": "4.1.2", "execa": "4.0.0", diff --git a/app-shell/src/__tests__/update.test.ts b/app-shell/src/__tests__/update.test.ts index 250aec8ae42..123a5e70c24 100644 --- a/app-shell/src/__tests__/update.test.ts +++ b/app-shell/src/__tests__/update.test.ts @@ -1,5 +1,6 @@ // app-shell self-update tests import * as ElectronUpdater from 'electron-updater' +import { UPDATE_VALUE } from '@opentrons/app/src/redux/config' import { registerUpdate } from '../update' import * as Cfg from '../config' @@ -67,20 +68,44 @@ describe('update', () => { }) it('handles shell:DOWNLOAD_UPDATE', () => { - handleAction({ type: 'shell:DOWNLOAD_UPDATE', meta: { shell: true } }) + handleAction({ + type: 'shell:DOWNLOAD_UPDATE', + meta: { shell: true }, + }) expect(autoUpdater.downloadUpdate).toHaveBeenCalledTimes(1) + const progress = { + percent: 20, + } + + autoUpdater.emit('download-progress', progress) + + expect(dispatch).toHaveBeenCalledWith({ + type: 'shell:DOWNLOAD_PERCENTAGE', + payload: { + percent: 20, + }, + }) + autoUpdater.emit('update-downloaded', { version: '1.0.0' }) expect(dispatch).toHaveBeenCalledWith({ type: 'shell:DOWNLOAD_UPDATE_RESULT', payload: {}, }) + expect(dispatch).toHaveBeenCalledWith({ + type: UPDATE_VALUE, + payload: { path: 'update.hasJustUpdated', value: true }, + meta: { shell: true }, + }) }) it('handles shell:DOWNLOAD_UPDATE with error', () => { - handleAction({ type: 'shell:DOWNLOAD_UPDATE', meta: { shell: true } }) + handleAction({ + type: 'shell:DOWNLOAD_UPDATE', + meta: { shell: true }, + }) autoUpdater.emit('error', new Error('AH')) expect(dispatch).toHaveBeenCalledWith({ diff --git a/app-shell/src/config/__fixtures__/index.ts b/app-shell/src/config/__fixtures__/index.ts index c88bbcc14c4..5225d825c74 100644 --- a/app-shell/src/config/__fixtures__/index.ts +++ b/app-shell/src/config/__fixtures__/index.ts @@ -18,6 +18,7 @@ import type { ConfigV16, ConfigV17, ConfigV18, + ConfigV19, } from '@opentrons/app/src/redux/config/types' export const MOCK_CONFIG_V0: ConfigV0 = { @@ -240,3 +241,12 @@ export const MOCK_CONFIG_V18: ConfigV18 = { })(), version: 18, } + +export const MOCK_CONFIG_V19: ConfigV19 = { + ...MOCK_CONFIG_V18, + version: 19, + update: { + ...MOCK_CONFIG_V18.update, + hasJustUpdated: false, + }, +} diff --git a/app-shell/src/config/__tests__/migrate.test.ts b/app-shell/src/config/__tests__/migrate.test.ts index 51625eec489..0287fbb9e20 100644 --- a/app-shell/src/config/__tests__/migrate.test.ts +++ b/app-shell/src/config/__tests__/migrate.test.ts @@ -19,158 +19,168 @@ import { MOCK_CONFIG_V16, MOCK_CONFIG_V17, MOCK_CONFIG_V18, + MOCK_CONFIG_V19, } from '../__fixtures__' import { migrate } from '../migrate' +const NEWEST_VERSION = 19 + describe('config migration', () => { it('should migrate version 0 to latest', () => { const v0Config = MOCK_CONFIG_V0 const result = migrate(v0Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 1 to latest', () => { const v1Config = MOCK_CONFIG_V1 const result = migrate(v1Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 2 to latest', () => { const v2Config = MOCK_CONFIG_V2 const result = migrate(v2Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 3 to latest', () => { const v3Config = MOCK_CONFIG_V3 const result = migrate(v3Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 4 to latest', () => { const v4Config = MOCK_CONFIG_V4 const result = migrate(v4Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 5 to latest', () => { const v5Config = MOCK_CONFIG_V5 const result = migrate(v5Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 6 to latest', () => { const v6Config = MOCK_CONFIG_V6 const result = migrate(v6Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 7 to latest', () => { const v7Config = MOCK_CONFIG_V7 const result = migrate(v7Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 8 to latest', () => { const v8Config = MOCK_CONFIG_V8 const result = migrate(v8Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 9 to latest', () => { const v9Config = MOCK_CONFIG_V9 const result = migrate(v9Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 10 to latest', () => { const v10Config = MOCK_CONFIG_V10 const result = migrate(v10Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 11 to latest', () => { const v11Config = MOCK_CONFIG_V11 const result = migrate(v11Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 12 to latest', () => { const v12Config = MOCK_CONFIG_V12 const result = migrate(v12Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 13 to latest', () => { const v13Config = MOCK_CONFIG_V13 const result = migrate(v13Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 14 to latest', () => { const v14Config = MOCK_CONFIG_V14 const result = migrate(v14Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 15 to latest', () => { const v15Config = MOCK_CONFIG_V15 const result = migrate(v15Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 16 to latest', () => { const v16Config = MOCK_CONFIG_V16 const result = migrate(v16Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) it('should migrate version 17 to latest', () => { const v17Config = MOCK_CONFIG_V17 const result = migrate(v17Config) - expect(result.version).toBe(18) - expect(result).toEqual(MOCK_CONFIG_V18) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) }) - it('should keep version 18', () => { + it('should migrate version 18 to latest', () => { const v18Config = MOCK_CONFIG_V18 const result = migrate(v18Config) - expect(result.version).toBe(18) - expect(result).toEqual(v18Config) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(MOCK_CONFIG_V19) + }) + it('should keep version 19', () => { + const v19Config = MOCK_CONFIG_V19 + const result = migrate(v19Config) + + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(v19Config) }) }) diff --git a/app-shell/src/config/migrate.ts b/app-shell/src/config/migrate.ts index b6e1252234c..0a4616e7b14 100644 --- a/app-shell/src/config/migrate.ts +++ b/app-shell/src/config/migrate.ts @@ -24,6 +24,7 @@ import type { ConfigV16, ConfigV17, ConfigV18, + ConfigV19, } from '@opentrons/app/src/redux/config/types' // format // base config v0 defaults @@ -340,6 +341,19 @@ const toVersion18 = (prevConfig: ConfigV17): ConfigV18 => { return { ...prevConfigFields, version: 18 as const } } +// config version 19 migration and defaults +const toVersion19 = (prevConfig: ConfigV18): ConfigV19 => { + const nextConfig = { + ...prevConfig, + version: 19 as const, + update: { + ...prevConfig.update, + hasJustUpdated: false, + }, + } + return nextConfig +} + const MIGRATIONS: [ (prevConfig: ConfigV0) => ConfigV1, (prevConfig: ConfigV1) => ConfigV2, @@ -358,7 +372,8 @@ const MIGRATIONS: [ (prevConfig: ConfigV14) => ConfigV15, (prevConfig: ConfigV15) => ConfigV16, (prevConfig: ConfigV16) => ConfigV17, - (prevConfig: ConfigV17) => ConfigV18 + (prevConfig: ConfigV17) => ConfigV18, + (prevConfig: ConfigV18) => ConfigV19 ] = [ toVersion1, toVersion2, @@ -378,6 +393,7 @@ const MIGRATIONS: [ toVersion16, toVersion17, toVersion18, + toVersion19, ] export const DEFAULTS: Config = migrate(DEFAULTS_V0) @@ -403,6 +419,7 @@ export function migrate( | ConfigV16 | ConfigV17 | ConfigV18 + | ConfigV19 ): Config { const prevVersion = prevConfig.version let result = prevConfig diff --git a/app-shell/src/discovery.ts b/app-shell/src/discovery.ts index e7e23eb3c84..ed562fdd069 100644 --- a/app-shell/src/discovery.ts +++ b/app-shell/src/discovery.ts @@ -33,7 +33,7 @@ import type { } from '@opentrons/discovery-client' import type { Action, Dispatch } from './types' -import type { Config } from './config' +import type { ConfigV1 } from '@opentrons/app/src/redux/config/schema-types' const log = createLogger('discovery') @@ -47,7 +47,7 @@ interface DiscoveryStore { services?: LegacyService[] } -let config: Config['discovery'] +let config: ConfigV1['discovery'] let store: Store let client: DiscoveryClient diff --git a/app-shell/src/log.ts b/app-shell/src/log.ts index e67bf625b37..f18e0c0ea52 100644 --- a/app-shell/src/log.ts +++ b/app-shell/src/log.ts @@ -9,7 +9,7 @@ import winston from 'winston' import { getConfig } from './config' import type Transport from 'winston-transport' -import type { Config } from './config' +import type { ConfigV0 } from '@opentrons/app/src/redux/config/schema-types' export const LOG_DIR = path.join(app.getPath('userData'), 'logs') const ERROR_LOG = path.join(LOG_DIR, 'error.log') @@ -25,7 +25,7 @@ const FILE_OPTIONS = { tailable: true, } -let config: Config['log'] +let config: ConfigV0['log'] let transports: Transport[] let log: winston.Logger diff --git a/app-shell/src/main.ts b/app-shell/src/main.ts index 2a3d774178f..b1ef492b949 100644 --- a/app-shell/src/main.ts +++ b/app-shell/src/main.ts @@ -8,7 +8,6 @@ import { createLogger } from './log' import { registerProtocolAnalysis } from './protocol-analysis' import { registerDiscovery } from './discovery' import { registerLabware } from './labware' -import { registerRobotLogs } from './robot-logs' import { registerUpdate } from './update' import { registerRobotUpdate } from './robot-update' import { registerSystemInfo } from './system-info' @@ -83,7 +82,6 @@ function startUp(): void { registerConfig(dispatch), registerDiscovery(dispatch), registerProtocolAnalysis(dispatch, mainWindow), - registerRobotLogs(dispatch, mainWindow), registerUpdate(dispatch), registerRobotUpdate(dispatch), registerLabware(dispatch, mainWindow), diff --git a/app-shell/src/protocol-storage/file-system.ts b/app-shell/src/protocol-storage/file-system.ts index fac01d9aa24..cf3c36f3c5f 100644 --- a/app-shell/src/protocol-storage/file-system.ts +++ b/app-shell/src/protocol-storage/file-system.ts @@ -24,8 +24,13 @@ import { analyzeProtocolSource } from '../protocol-analysis' * │ ├─ analysis/ * │ │ ├─ 1646303906.json */ - -export const PROTOCOLS_DIRECTORY_NAME = 'protocols' +// TODO(jh, 2023-09-11): remove OLD_PROTOCOLS_DIRECTORY_PATH after +// OT-2 parity work is completed and move all protocols back to "protocols" directory. +export const OLD_PROTOCOLS_DIRECTORY_PATH = path.join( + app.getPath('userData'), + 'protocols' +) +export const PROTOCOLS_DIRECTORY_NAME = 'protocols_v7.0-supported' export const PROTOCOLS_DIRECTORY_PATH = path.join( app.getPath('userData'), PROTOCOLS_DIRECTORY_NAME diff --git a/app-shell/src/protocol-storage/index.ts b/app-shell/src/protocol-storage/index.ts index a7c1a71e492..2a03d82ce47 100644 --- a/app-shell/src/protocol-storage/index.ts +++ b/app-shell/src/protocol-storage/index.ts @@ -42,11 +42,65 @@ export const getProtocolSrcFilePaths = ( }) } +// TODO(jh, 2023-09-11): remove migrateProtocolsToNewDirectory after +// OT-2 parity work is completed. +const migrateProtocols = migrateProtocolsToNewDirectory() +function migrateProtocolsToNewDirectory(): () => Promise { + let hasCheckedForMigration = false + return function (): Promise { + return new Promise((resolve, reject) => { + if (hasCheckedForMigration) resolve() + hasCheckedForMigration = true + console.log( + `Performing protocol migration to ${FileSystem.PROTOCOLS_DIRECTORY_NAME}...` + ) + copyProtocols( + FileSystem.OLD_PROTOCOLS_DIRECTORY_PATH, + FileSystem.PROTOCOLS_DIRECTORY_PATH + ) + .then(() => { + console.log('Protocol migration complete.') + resolve() + }) + .catch(e => { + console.log( + `Error migrating protocols to ${FileSystem.PROTOCOLS_DIRECTORY_NAME}: ${e}` + ) + resolve() + }) + }) + } + + function copyProtocols(src: string, dest: string): Promise { + return fse + .stat(src) + .then(doesSrcExist => { + if (!doesSrcExist.isDirectory()) return Promise.resolve() + + return fse.readdir(src).then(items => { + const protocols = items.map(item => { + const srcItem = path.join(src, item) + const destItem = path.join(dest, item) + + return fse.copy(srcItem, destItem, { + overwrite: false, + }) + }) + return Promise.all(protocols).then(() => Promise.resolve()) + }) + }) + .catch(e => { + return Promise.reject(e) + }) + } +} + export const fetchProtocols = ( dispatch: Dispatch, source: ListSource ): Promise => { return ensureDir(FileSystem.PROTOCOLS_DIRECTORY_PATH) + .then(() => migrateProtocols()) .then(() => FileSystem.readDirectoriesWithinDirectory( FileSystem.PROTOCOLS_DIRECTORY_PATH diff --git a/app-shell/src/robot-logs.ts b/app-shell/src/robot-logs.ts deleted file mode 100644 index 60c9cfacea2..00000000000 --- a/app-shell/src/robot-logs.ts +++ /dev/null @@ -1,37 +0,0 @@ -// download robot logs manager -import { download } from 'electron-dl' -import { createLogger } from './log' - -import type { BrowserWindow } from 'electron' -import type { Action, Dispatch } from './types' - -const log = createLogger('robot-logs') - -export function registerRobotLogs( - dispatch: Dispatch, - mainWindow: BrowserWindow -): (action: Action) => unknown { - return function handleIncomingAction(action: Action): void { - if (action.type === 'shell:DOWNLOAD_LOGS') { - const { logUrls } = action.payload as { logUrls: string[] } - - log.debug('Downloading robot logs', { logUrls }) - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - logUrls - .reduce>((result, url, index) => { - return result.then(() => { - return download(mainWindow, url, { - saveAs: true, - openFolderWhenDone: index === logUrls.length - 1, - onCancel: () => dispatch({ type: 'shell:DOWNLOAD_LOGS_DONE' }), - }) - }) - }, Promise.resolve()) - .catch((error: unknown) => { - log.error('Error downloading robot logs', { error }) - }) - .then(() => dispatch({ type: 'shell:DOWNLOAD_LOGS_DONE' })) - } - } -} diff --git a/app-shell/src/update.ts b/app-shell/src/update.ts index e30e23f3734..d28e420abfa 100644 --- a/app-shell/src/update.ts +++ b/app-shell/src/update.ts @@ -4,6 +4,7 @@ import { autoUpdater as updater } from 'electron-updater' import { UI_INITIALIZED } from '@opentrons/app/src/redux/shell/actions' import { createLogger } from './log' import { getConfig } from './config' +import { UPDATE_VALUE } from '@opentrons/app/src/redux/config' import type { UpdateInfo } from '@opentrons/app/src/redux/shell/types' import type { Action, Dispatch, PlainError } from './types' @@ -63,20 +64,45 @@ function checkUpdate(dispatch: Dispatch): void { } } +interface ProgressInfo { + total: number + delta: number + transferred: number + percent: number + bytesPerSecond: number +} +interface DownloadingPayload { + progress: ProgressInfo + bytesPerSecond: number + percent: number + total: number + transferred: number +} + function downloadUpdate(dispatch: Dispatch): void { + const onDownloading = (payload: DownloadingPayload): void => + dispatch({ type: 'shell:DOWNLOAD_PERCENTAGE', payload }) const onDownloaded = (): void => done({}) const onError = (error: Error): void => { done({ error: PlainObjectError(error) }) } + updater.on('download-progress', onDownloading) updater.once('update-downloaded', onDownloaded) updater.once('error', onError) // eslint-disable-next-line @typescript-eslint/no-floating-promises updater.downloadUpdate() function done(payload: { error?: PlainError }): void { + updater.removeListener('download-progress', onDownloading) updater.removeListener('update-downloaded', onDownloaded) updater.removeListener('error', onError) + if (payload.error == null) + dispatch({ + type: UPDATE_VALUE, + payload: { path: 'update.hasJustUpdated', value: true }, + meta: { shell: true }, + }) dispatch({ type: 'shell:DOWNLOAD_UPDATE_RESULT', payload }) } } diff --git a/app/package.json b/app/package.json index 8417e7fd196..d2f8dd09ff1 100644 --- a/app/package.json +++ b/app/package.json @@ -37,6 +37,7 @@ "history": "4.7.2", "i18next": "^19.8.3", "is-ip": "3.1.0", + "jszip": "3.2.2", "lodash": "4.17.21", "mixpanel-browser": "2.22.1", "netmask": "2.0.2", diff --git a/app/src/App/hooks.ts b/app/src/App/hooks.ts index 3b54951798e..429f5a14283 100644 --- a/app/src/App/hooks.ts +++ b/app/src/App/hooks.ts @@ -2,13 +2,16 @@ import * as React from 'react' import difference from 'lodash/difference' import { useTranslation } from 'react-i18next' import { useQueryClient } from 'react-query' +import { useRouteMatch } from 'react-router-dom' import { useDispatch } from 'react-redux' + import { useInterval } from '@opentrons/components' import { useAllProtocolIdsQuery, useAllRunsQuery, useHost, useRunQuery, + useCreateLiveCommandMutation, } from '@opentrons/react-api-client' import { getProtocol, @@ -19,9 +22,11 @@ import { RUN_STATUS_FAILED, RUN_STATUS_SUCCEEDED, } from '@opentrons/api-client' + import { checkShellUpdate } from '../redux/shell' import { useToaster } from '../organisms/ToasterOven' +import type { SetStatusBarCreateCommand } from '@opentrons/shared-data/protocol/types/schemaV7/command/incidental' import type { Dispatch } from '../redux/types' const CURRENT_RUN_POLL = 5000 @@ -50,6 +55,11 @@ export function useProtocolReceiptToast(): void { const protocolIds = protocolIdsQuery.data?.data ?? [] const protocolIdsRef = React.useRef(protocolIds) const hasRefetched = React.useRef(true) + const { createLiveCommand } = useCreateLiveCommandMutation() + const animationCommand: SetStatusBarCreateCommand = { + commandType: 'setStatusBar', + params: { animation: 'confirm' }, + } if (protocolIdsQuery.isRefetching) { hasRefetched.current = false @@ -98,6 +108,13 @@ export function useProtocolReceiptToast(): void { console.error(`error invalidating protocols query: ${e.message}`) ) }) + .then(() => { + createLiveCommand({ + command: animationCommand, + }).catch((e: Error) => + console.warn(`cannot run status bar animation: ${e.message}`) + ) + }) .catch((e: Error) => { console.error(e) }) @@ -122,40 +139,38 @@ export function useCurrentRunRoute(): string | null { run => run.id === currentRunLink.href.replace('/runs/', '') ) // trim link path down to only runId : null - const currentRunId = currentRun?.id ?? null - const { data: runRecord } = useRunQuery(currentRunId, { staleTime: Infinity, enabled: currentRunId != null, }) + const isRunSetupRoute = useRouteMatch('/runs/:runId/setup') + if (isRunSetupRoute != null && runRecord == null) return '/protocols' + const runStatus = runRecord?.data.status const runActions = runRecord?.data.actions - if (runRecord == null || runStatus == null || runActions == null) return null // grabbing run id off of the run query to have all routing info come from one source of truth const runId = runRecord.data.id - - const hasBeenStarted = runActions?.some( + const hasRunStarted = runActions?.some( action => action.actionType === RUN_ACTION_TYPE_PLAY ) if ( runStatus === RUN_STATUS_SUCCEEDED || - // don't want to route to the run summary page if the run has been cancelled before starting - (runStatus === RUN_STATUS_STOPPED && hasBeenStarted) || + (runStatus === RUN_STATUS_STOPPED && hasRunStarted) || runStatus === RUN_STATUS_FAILED ) { return `/runs/${runId}/summary` } else if ( runStatus === RUN_STATUS_IDLE || - (!hasBeenStarted && runStatus === RUN_STATUS_BLOCKED_BY_OPEN_DOOR) + (!hasRunStarted && runStatus === RUN_STATUS_BLOCKED_BY_OPEN_DOOR) ) { return `/runs/${runId}/setup` - // don't want to route to the run page if the run hasn't started - } else if (hasBeenStarted) { + } else if (hasRunStarted) { return `/runs/${runId}/run` } else { + // includes runs cancelled before starting and runs not yet started return null } } diff --git a/app/src/assets/images/heater_shaker-key-parts.png b/app/src/assets/images/heater_shaker-key-parts.png deleted file mode 100644 index 06b7f9ced1c..00000000000 Binary files a/app/src/assets/images/heater_shaker-key-parts.png and /dev/null differ diff --git a/app/src/assets/images/heater_shaker_adapter_alignment.png b/app/src/assets/images/heater_shaker_adapter_alignment.png deleted file mode 100644 index 1d5e8dc1398..00000000000 Binary files a/app/src/assets/images/heater_shaker_adapter_alignment.png and /dev/null differ diff --git a/app/src/assets/images/heater_shaker_adapter_screwdriver.png b/app/src/assets/images/heater_shaker_adapter_screwdriver.png deleted file mode 100644 index 948988f6a9d..00000000000 Binary files a/app/src/assets/images/heater_shaker_adapter_screwdriver.png and /dev/null differ diff --git a/app/src/assets/images/heater_shaker_empty.png b/app/src/assets/images/heater_shaker_empty.png deleted file mode 100644 index 9540b6d75f5..00000000000 Binary files a/app/src/assets/images/heater_shaker_empty.png and /dev/null differ diff --git a/app/src/assets/images/heater_shaker_module_diagram.png b/app/src/assets/images/heater_shaker_module_diagram.png deleted file mode 100644 index 9ba8b76fa11..00000000000 Binary files a/app/src/assets/images/heater_shaker_module_diagram.png and /dev/null differ diff --git a/app/src/assets/images/heatershaker_calibration_adapter.png b/app/src/assets/images/heatershaker_calibration_adapter.png new file mode 100644 index 00000000000..64fa8094bc7 Binary files /dev/null and b/app/src/assets/images/heatershaker_calibration_adapter.png differ diff --git a/app/src/assets/images/module_instruction_code.png b/app/src/assets/images/module_instruction_code.png new file mode 100644 index 00000000000..95e83abea0e Binary files /dev/null and b/app/src/assets/images/module_instruction_code.png differ diff --git a/app/src/assets/images/temperature_module_calibration_adapter.png b/app/src/assets/images/temperature_module_calibration_adapter.png new file mode 100644 index 00000000000..ae3e3253014 Binary files /dev/null and b/app/src/assets/images/temperature_module_calibration_adapter.png differ diff --git a/app/src/assets/images/thermocycler_calibration_adapter.png b/app/src/assets/images/thermocycler_calibration_adapter.png new file mode 100644 index 00000000000..5e54e892db6 Binary files /dev/null and b/app/src/assets/images/thermocycler_calibration_adapter.png differ diff --git a/app/src/assets/localization/en/app_settings.json b/app/src/assets/localization/en/app_settings.json index 735851d0fc0..c77e2e4012d 100644 --- a/app/src/assets/localization/en/app_settings.json +++ b/app/src/assets/localization/en/app_settings.json @@ -1,6 +1,5 @@ { "__dev_internal__enableExtendedHardware": "Enable Extended Hardware", - "__dev_internal__enableModuleCalibration": "Enable Module Calibration", "__dev_internal__lpcWithProbe": "Golden Tip LPC", "add_folder_button": "Add labware source folder", "add_ip_button": "Add", @@ -11,8 +10,8 @@ "additional_folder_location": "Additional Source Folder", "additional_labware_folder_title": "Additional Custom Labware Source Folder", "advanced": "Advanced", - "allow_sending_all_protocols_to_ot3_description": "Enable the \"Send to Opentrons Flex\" menu item for each imported protocol, even if protocol analysis fails or does not recognize it as designed for the Opentrons Flex.", "allow_sending_all_protocols_to_ot3": "Allow Sending All Protocols to Opentrons Flex", + "allow_sending_all_protocols_to_ot3_description": "Enable the \"Send to Opentrons Flex\" menu item for each imported protocol, even if protocol analysis fails or does not recognize it as designed for the Opentrons Flex.", "analytics_description": "Help Opentrons improve its products and services by automatically sending anonymous diagnostics and usage data.", "app_changes": "App Changes in ", "app_settings": "App Settings", @@ -20,6 +19,7 @@ "cal_block": "Always use calibration block to calibrate", "change_folder_button": "Change labware source folder", "channel": "Channel", + "choose_what_data_to_share": "Choose what data to share with Opentrons.", "clear_confirm": "Clear unavailable robots", "clear_robots_button": "Clear unavailable robots list", "clear_robots_description": "Clear the list of unavailable robots on the Devices page. This action cannot be undone.", @@ -27,13 +27,13 @@ "clear_unavailable_robots": "Clear unavailable robots?", "clearing_cannot_be_undone": "Clearing the list of unavailable robots on the Devices page cannot be undone.", "close": "Close", + "connect_ip": "Connect to a Robot via IP Address", "connect_ip_button": "Done", "connect_ip_link": "Learn more about connecting a robot manually", - "connect_ip": "Connect to a Robot via IP Address", "discovery_timeout": "Discovery timed out.", - "download_update": "Download app update", - "enable_dev_tools_description": "Enabling this setting opens Developer Tools on app launch, enables additional logging and gives access to feature flags.", + "download_update": "Downloading update...", "enable_dev_tools": "Developer Tools", + "enable_dev_tools_description": "Enabling this setting opens Developer Tools on app launch, enables additional logging and gives access to feature flags.", "error_boundary_description": "You need to restart the touchscreen. Then download the robot logs from the Opentrons App and send them to support@opentrons.com for assistance.", "error_boundary_title": "An unknown error has occurred", "feature_flags": "Feature Flags", @@ -41,6 +41,7 @@ "heater_shaker_attach_description": "Display a reminder to attach the Heater-Shaker properly before running a test shake or using it in a protocol.", "heater_shaker_attach_visible": "Confirm Heater-Shaker Module Attachment", "how_to_restore": "How to Restore a Previous Software Version", + "installing_update": "Installing update...", "ip_available": "Available", "ip_description_first": "Enter an IP address or hostname to connect to a robot.", "ip_description_second": "Opentrons recommends working with your network administrator to assign a static IP address to the robot.", @@ -51,12 +52,20 @@ "no_specified_folder": "No path specified", "no_unavail_robots_to_clear": "No unavailable robots to clear", "not_found": "Not Found", + "opentrons_app_successfully_updated": "The Opentrons App was successfully updated.", + "opentrons_app_update": "Opentrons app update", + "opentrons_app_update_available": "Opentrons App Update Available", + "opentrons_app_update_available_variation": "An Opentrons App update is available.", "opentrons_app_will_use_interpreter": "If specified, the Opentrons App will use the Python interpreter at this path instead of the default bundled Python interpreter.", + "opentrons_cares_about_privacy": "Opentrons cares about your privacy. We anonymize all data and only use it to improve our products.", + "opt_in_description": "Automatically send us anonymous diagnostics and usage data. We only use this information to improve our products.", + "opt_in": "Opt in", + "opt_out": "Opt out", "ot2_advanced_settings": "OT-2 Advanced Settings", - "override_path_to_python": "Override Path to Python", "override_path": "override path", - "prevent_robot_caching_description": "The app will immediately clear unavailable robots and will not remember unavailable robots while this is enabled. On networks with many robots, preventing caching may improve network performance at the expense of slower and less reliable robot discovery on app launch.", + "override_path_to_python": "Override Path to Python", "prevent_robot_caching": "Prevent Robot Caching", + "prevent_robot_caching_description": "The app will immediately clear unavailable robots and will not remember unavailable robots while this is enabled. On networks with many robots, preventing caching may improve network performance at the expense of slower and less reliable robot discovery on app launch.", "previous_releases": "View previous Opentrons releases", "privacy": "Privacy", "prompt": "Always show the prompt to choose calibration block or trash bin", @@ -64,13 +73,19 @@ "remind_later": "Remind me later", "reset_to_default": "Reset to default", "restart_touchscreen": "Restart touchscreen", + "restarting_app": "Download complete, restarting the app...", "restore_description": "Opentrons does not recommend reverting to previous software versions, but you can access previous releases below. For best results, uninstall the existing app and remove its configuration files before installing the previous version.", "restore_previous": "See how to restore a previous software version", "searching": "Searching for 30s", "setup_connection": "Set up connection", - "share_analytics": "Share Robot and App Analytics with Opentrons", - "show_labware_offset_snippets_description": "Only for users who need to apply Labware Offset data outside of the Opentrons App. When enabled, code snippets for Jupyter Notebook and SSH are available during protocol setup.", + "share_app_analytics": "Share App Analytics with Opentrons", + "share_app_analytics_description": "Help Opentrons improve its products and services by automatically sending anonymous diagnostics and usage data.", + "share_display_usage_description": "Data on how you interact with the touchscreen on Flex.", + "share_display_usage": "Share display usage", + "share_robot_logs_description": "Data on actions the robot does, like running protocols.", + "share_robot_logs": "Share robot logs", "show_labware_offset_snippets": "Show Labware Offset data code snippets", + "show_labware_offset_snippets_description": "Only for users who need to apply Labware Offset data outside of the Opentrons App. When enabled, code snippets for Jupyter Notebook and SSH are available during protocol setup.", "software_update_available": "Software Update Available", "software_version": "App Software Version", "successfully_deleted_unavail_robots": "Successfully deleted unavailable robots", @@ -79,13 +94,15 @@ "turn_off_updates": "Turn off software update notifications in App Settings.", "up_to_date": "Up to date", "update_alerts": "Software Update Alerts", - "update_available": "update available", + "update_app_now": "Update app now", + "update_available": "Update available", "update_channel": "Update Channel", "update_description": "Stable receives the latest stable releases. Beta allows you to try out new in-progress features before they launch in Stable channel, but they have not completed testing yet.", + "update_requires_restarting": "Updating requires restarting the Opentrons App.", "usb_to_ethernet_adapter_description": "Description", "usb_to_ethernet_adapter_driver_version": "Driver Version", - "usb_to_ethernet_adapter_info_description": "Some OT-2s have an internal USB-to-Ethernet adapter. If your OT-2 uses this adapter, it will be added to your computer’s device list when you make a wired connection. If you have a Realtek adapter, it is essential that the driver is up to date.", "usb_to_ethernet_adapter_info": "USB-to-Ethernet Adapter Information", + "usb_to_ethernet_adapter_info_description": "Some OT-2s have an internal USB-to-Ethernet adapter. If your OT-2 uses this adapter, it will be added to your computer’s device list when you make a wired connection. If you have a Realtek adapter, it is essential that the driver is up to date.", "usb_to_ethernet_adapter_link": "go to Realtek.com", "usb_to_ethernet_adapter_manufacturer": "Manufacturer", "usb_to_ethernet_adapter_no_driver_version": "Unknown", @@ -96,5 +113,6 @@ "view_issue_tracker": "View Opentrons issue tracker", "view_release_notes": "View full Opentrons release notes", "view_software_update": "View software update", - "view_update": "View Update" + "view_update": "View Update", + "want_to_help_out": "Want to help out Opentrons?" } diff --git a/app/src/assets/localization/en/device_details.json b/app/src/assets/localization/en/device_details.json index 5c6dfe536a8..10acca1a98b 100644 --- a/app/src/assets/localization/en/device_details.json +++ b/app/src/assets/localization/en/device_details.json @@ -42,7 +42,7 @@ "firmware_update_available_now": "Firmware update available. Update now", "firmware_update_failed": "Failed to update module firmware", "firmware_update_installation_successful": "Installation successful", - "got_it": "Got it", + "firmware_update_occurring": "Firmware update in progress...", "have_not_run": "No recent runs", "have_not_run_description": "After you run some protocols, they will appear here.", "heater": "Heater", @@ -118,6 +118,7 @@ "run_again": "Run again", "run_duration": "Run duration", "serial_number": "Serial Number", + "robot_initializing": "Initializing...", "set_block_temp": "Set temperature", "set_block_temperature": "Set block temperature", "set_engage_height": "Set Engage Height", diff --git a/app/src/assets/localization/en/device_settings.json b/app/src/assets/localization/en/device_settings.json index 4fbb82ebbdd..fc81d5ea3ea 100644 --- a/app/src/assets/localization/en/device_settings.json +++ b/app/src/assets/localization/en/device_settings.json @@ -1,12 +1,12 @@ { "about_advanced": "About", - "about_calibration_description_ot3": "For the robot to move accurately and precisely, you need to calibrate it. Pipette and gripper calibration is an automated process that uses a calibration probe or pin.After calibration is complete, you can save the calibration data to your computer as a JSON file.", "about_calibration_description": "For the robot to move accurately and precisely, you need to calibrate it. Positional calibration happens in three parts: deck calibration, pipette offset calibration and tip length calibration.", + "about_calibration_description_ot3": "For the robot to move accurately and precisely, you need to calibrate it. Pipette and gripper calibration is an automated process that uses a calibration probe or pin.After calibration is complete, you can save the calibration data to your computer as a JSON file.", "about_calibration_title": "About Calibration", "advanced": "Advanced", "alpha_description": "Warning: alpha releases are feature-complete but may contain significant bugs.", - "alternative_security_types_description": "The Opentrons App supports connecting Flex to various enterprise access points. Connect via USB and finish setup in the app.", "alternative_security_types": "Alternative security types", + "alternative_security_types_description": "The Opentrons App supports connecting Flex to various enterprise access points. Connect via USB and finish setup in the app.", "app_change_in": "App Changes in {{version}}", "apply_historic_offsets": "Apply Labware Offsets", "are_you_sure_you_want_to_disconnect": "Are you sure you want to disconnect from {{ssid}}?", @@ -14,49 +14,52 @@ "boot_scripts": "Boot scripts", "browse_file_system": "Browse file system", "bug_fixes": "Bug Fixes", + "calibrate_deck": "Calibrate deck", "calibrate_deck_description": "For pre-2019 robots that do not have crosses etched on the deck.", "calibrate_deck_to_dots": "Calibrate deck to dots", - "calibrate_deck": "Calibrate deck", "calibrate_gripper": "Calibrate gripper", + "calibrate_module": "Calibrate module", "calibrate_now": "Calibrate now", "calibrate_pipette": "Calibrate Pipette Offset", + "calibration": "Calibration", "calibration_health_check_description": "Check the accuracy of key calibration points without recalibrating the robot.", "calibration_health_check_title": "Calibration Health Check", - "calibration": "Calibration", "change_network": "Change network", "characters_max": "17 characters max", "check_for_updates": "Check for updates", "checking_for_updates": "Checking for updates", - "choose_reset_settings": "Choose reset settings", "choose": "Choose...", + "choose_network_type": "Choose network type", + "choose_reset_settings": "Choose reset settings", "clear_all_data": "Clear all data", - "clear_all_stored_data_description": "Resets all settings. You’ll have to redo initial setup before using the robot again.", "clear_all_stored_data": "Clear all stored data", + "clear_all_stored_data_description": "Resets all settings. You’ll have to redo initial setup before using the robot again.", + "clear_calibration_data": "Clear calibration data", "clear_data_and_restart_robot": "Clear data and restart robot", "clear_individual_data": "Clear individual data", - "clear_option_boot_scripts_description": "Clears scripts that modify the robot's behavior when powered on.", "clear_option_boot_scripts": "Clear custom boot scripts", + "clear_option_boot_scripts_description": "Clears scripts that modify the robot's behavior when powered on.", "clear_option_deck_calibration": "Clear deck calibration", "clear_option_gripper_calibration": "Clear gripper calibration", "clear_option_gripper_offset_calibrations": "Clear gripper calibration", - "clear_option_pipette_calibrations": "Clear pipette calibration(s)", + "clear_option_module_calibrations": "Clear module calibrations", + "clear_option_pipette_calibrations": "Clear pipette calibration", "clear_option_pipette_offset_calibrations": "Clear pipette offset calibrations", - "clear_option_runs_history_subtext": "Clears information about past runs of all protocols.", "clear_option_runs_history": "Clear protocol run history", + "clear_option_runs_history_subtext": "Clears information about past runs of all protocols.", "clear_option_tip_length_calibrations": "Clear tip length calibrations", + "confirm_device_reset_description": "Are you sure you want to reset your device?", + "connect": "Connect", "connect_the_estop_to_continue": "Connect the E-stop to continue", - "connect_to_a_network": "Connect to a network", "connect_to_wifi_network": "Connect to Wi-Fi network", - "connect_to": "Connect to {{ssid}}", + "connect_via": "Connect via {{type}}", "connect_via_usb_description_1": "1. Connect the USB A-to-B cable to the robot’s USB-B port.", "connect_via_usb_description_2": "2. Connect the cable to an open USB port on your computer.", "connect_via_usb_description_3": "3. Launch the Opentrons App on the computer to continue.", - "connect_via": "Connect via {{type}}", - "connect": "Connect", + "connected": "Connected", "connected_network": "Connected Network", "connected_to_ssid": "Connected to {{ssid}}", "connected_via": "Connected via {{networkInterface}}", - "connected": "Connected", "connecting_to": "Connecting to {{ssid}}...", "connection_description_ethernet": "Connect to your lab's wired network.", "connection_description_usb": "Connect directly to a computer (running the Opentrons App).", @@ -64,57 +67,58 @@ "connection_lost_description": "The Opentrons App is unable to communicate with this robot right now. Double check the USB or Wifi connection to the robot, then try to reconnect.", "connection_to_robot_lost": "Connection to robot lost", "deck_calibration_description": "Calibrating the deck is required for new robots or after you relocate your robot. Recalibrating the deck will require you to also recalibrate pipette offsets.", - "deck_calibration_missing_no_pipette": "Deck calibration missing. Attach a pipette to perform deck calibration.", "deck_calibration_missing": "Deck calibration missing", + "deck_calibration_missing_no_pipette": "Deck calibration missing. Attach a pipette to perform deck calibration.", "deck_calibration_modal_description": "Calibrating pipette offset before deck calibration when both are needed isn’t suggested. Calibrating the deck clears all other calibration data. ", "deck_calibration_modal_pipette_description": "Would you like to continue with pipette offset calibration?", "deck_calibration_modal_title": "Are you sure you want to calibrate?", "deck_calibration_recommended": "Deck calibration recommended", "deck_calibration_title": "Deck Calibration", "dev_tools_description": "Access additional logging and feature flags.", + "device_reset": "Device Reset", "device_reset_description": "Reset labware calibration, boot scripts, and/or robot calibration to factory settings.", "device_reset_slideout_description": "Select individual settings to only clear specific data types.", - "device_reset": "Device Reset", "device_resets_cannot_be_undone": "Resets cannot be undone", "directly_connected_to_this_computer": "Directly connected to this computer.", - "disable_homing_description": "Prevent robot from homing motors when the robot restarts.", - "disable_homing": "Disable homing the gantry when restarting robot", + "disconnect": "Disconnect", "disconnect_from_ssid": "Disconnect from {{ssid}}", + "disconnect_from_wifi": "Disconnect from Wi-Fi", "disconnect_from_wifi_network_failure": "Your robot was unable to disconnect from Wi-Fi network {{ssid}}.", "disconnect_from_wifi_network_success": "Your robot has successfully disconnected from the Wi-Fi network.", - "disconnect_from_wifi": "Disconnect from Wi-Fi", - "disconnect": "Disconnect", "disconnected_from_wifi": "Disconnected from Wi-Fi", "disconnecting_from_wifi_network": "Disconnecting from Wi-Fi network {{ssid}}", "disengaged": "Disengaged", "display_brightness": "Display Brightness", - "display_led_lights_description": "Control the strip of color lights on the front of the robot.", "display_led_lights": "Status LEDs", + "display_led_lights_description": "Control the strip of color lights on the front of the robot.", "display_sleep_settings": "Display Sleep Settings", + "do_not_turn_off": "Do not turn off the robot while updating", "done": "Done", + "download": "Download", "download_calibration_data": "Download calibration data", "download_error": "Download error", "download_logs": "Download logs", - "download": "Download", "downloading_logs": "Downloading logs...", "downloading_software": "Downloading software...", + "downloading_update": "Downloading update...", "e_stop_connected": "E-stop successfully connected", "e_stop_not_connected": "Connect the E-stop to an auxiliary port on the back of the robot.", "engaged": "Engaged", "enter_network_name": "Enter network name", "enter_password": "Enter password", + "estop": "E-stop", "estop_disengaged": "E-stop Disengaged", "estop_engaged": "E-stop Engaged", - "estop_missing_description": "Your E-stop could be damaged or detached. {{robotName}} lost its connection to the E-stop, so it canceled the protocol. Connect a functioning E-stop to continue.", "estop_missing": "E-stop missing", - "estop_pressed_description": "First, safely clear the deck of any labware or spills. Then, twist the E-stop button counterclockwise. Finally, have Flex move the gantry to its home position.", + "estop_missing_description": "Your E-stop could be damaged or detached. {{robotName}} lost its connection to the E-stop, so it canceled the protocol. Connect a functioning E-stop to continue.", "estop_pressed": "E-stop pressed", - "estop": "E-stop", - "ethernet_connection_description": "Connect an Ethernet cable to the back of the robot and a network switch or hub.", + "estop_pressed_description": "First, safely clear the deck of any labware or spills. Then, twist the E-stop button clockwise. Finally, have Flex move the gantry to its home position.", "ethernet": "Ethernet", + "ethernet_connection_description": "Connect an Ethernet cable to the back of the robot and a network switch or hub.", + "exit": "exit", + "factory_reset": "Factory Reset", "factory_reset_description": "Resets all settings. You’ll have to redo initial setup before using the robot again.", "factory_reset_modal_description": "This data cannot be retrieved later.", - "factory_reset": "Factory Reset", "factory_resets_cannot_be_undone": "Factory resets cannot be undone.", "failed_to_connect_to_ssid": "Failed to connect to {{ssid}}", "feature_flags": "Feature Flags", @@ -122,6 +126,8 @@ "finish_setup": "Finish setup", "firmware_version": "Firmware Version", "fully_calibrate_before_checking_health": "Fully calibrate your robot before checking calibration health", + "gantry_homing": "Home Gantry on Restart", + "gantry_homing_description": "Homes the gantry along the z-axis.", "go_to_advanced_settings": "Go to Advanced App Settings", "gripper_calibration_description": "Gripper calibration uses a metal pin to determine the gripper's exact position relative to precision-cut squares on deck slots.", "gripper_calibration_title": "Gripper Calibration", @@ -129,33 +135,37 @@ "health_check": "Check health", "hide": "Hide", "historic_offsets_description": "Use stored data when setting up a protocol.", - "home_gantry_on_restart": "Home gantry on restart", - "home_gantry_subtext": "By default, this setting is turned on.", "incorrect_password_for_ssid": "Oops! Incorrect password for {{ssid}}", "install_e_stop": "Install the E-stop", "installing_software": "Installing software...", + "installing_update": "Installing update...", "ip_address": "IP Address", - "join_other_network_error_message": "Must be 2–32 characters long", "join_other_network": "Join other network", + "join_other_network_error_message": "Must be 2–32 characters long", + "jupyter_notebook": "Jupyter Notebook", "jupyter_notebook_description": "Open the Jupyter Notebook running on this robot in the web browser. This is an experimental feature.", "jupyter_notebook_link": "Learn more about using Jupyter notebook", - "jupyter_notebook": "Jupyter Notebook", - "last_calibrated_label": "Last Calibrated", "last_calibrated": "Last calibrated: {{date}}", + "last_calibrated_label": "Last Calibrated", "launch_jupyter_notebook": "Launch Jupyter Notebook", "legacy_settings": "Legacy Settings", "mac_address": "MAC Address", "minutes": "{{minute}} minutes", "missing_calibration": "Missing calibration", "model_and_serial": "Pipette Model and Serial", + "module": "Module", + "module_calibration": "Module Calibration", + "module_calibration_confirm_modal_body": "This will immediately delete calibration data for this module on this robot.", + "module_calibration_confirm_modal_title": "Are you sure you want to clear module calibration data?", + "module_calibration_description": "Module calibration uses a pipette and attached probe to determine the module's exact position relative to the deck.", "mount": "Mount", "name_love_it": "{{name}}, love it!", "name_rule_description": "Enter up to 17 characters (letters and numbers only)", "name_rule_error_exist": "Oops! Name is already in use. Choose a different name.", "name_rule_error_name_length": "Oops! Robot name must follow the character count and limitations", "name_rule_error_too_short": "Oops! Too short. Robot name must be at least 1 character.", - "name_your_robot_description": "Don’t worry, you can always change this in your settings.", "name_your_robot": "Name your robot", + "name_your_robot_description": "Don’t worry, you can always change this in your settings.", "need_another_security_type": "Need another security type?", "network_name": "Network Name", "network_settings": "Network Settings", @@ -166,28 +176,29 @@ "next_step": "Next step", "no_connection_found": "No connection found", "no_gripper_attached": "No gripper attached", + "no_modules_attached": "No modules attached", "no_network_found": "No network found", "no_pipette_attached": "No pipette attached", "none_description": "Not recommended", - "not_calibrated_short": "Not calibrated", "not_calibrated": "Not calibrated yet", + "not_calibrated_short": "Not calibrated", + "not_connected": "Not connected", "not_connected_via_ethernet": "Not connected via Ethernet", "not_connected_via_usb": "Not connected via USB", "not_connected_via_wifi": "Not connected via Wi-Fi", "not_connected_via_wired_usb": "Not connected via wired USB", - "not_connected": "Not connected", "not_now": "Not now", "one_hour": "1 hour", "other_networks": "Other Networks", - "password_error_message": "Must be at least 8 characters", "password": "Password", - "pause_protocol_description": "When enabled, opening the robot door during a run will pause the robot after it has completed its current motion.", + "password_error_message": "Must be at least 8 characters", "pause_protocol": "Pause protocol when robot door opens", + "pause_protocol_description": "When enabled, opening the robot door during a run will pause the robot after it has completed its current motion.", "pipette_calibrations_description": "Pipette calibration uses a metal probe to determine the pipette's exact position relative to precision-cut divots on deck slots.", "pipette_calibrations_title": "Pipette Calibrations", + "pipette_offset_calibration": "pipette offset calibration", "pipette_offset_calibration_missing": "Pipette Offset calibration missing", "pipette_offset_calibration_recommended": "Pipette Offset calibration recommended", - "pipette_offset_calibration": "pipette offset calibration", "pipette_offset_calibrations_history": "See all Pipette Offset Calibration history", "pipette_offset_calibrations_title": "Pipette Offset Calibrations", "privacy": "Privacy", @@ -195,88 +206,98 @@ "protocol_run_history": "Protocol run History", "recalibrate_deck": "Recalibrate deck", "recalibrate_gripper": "Recalibrate gripper", + "recalibrate_module": "Recalibrate module", "recalibrate_now": "Recalibrate now", "recalibrate_pipette": "Recalibrate Pipette Offset", "recalibrate_tip_and_pipette": "Recalibrate Tip Length and Pipette Offset", "recalibration_recommended": "Recalibration recommended", "reinstall": "reinstall", "remind_me_later": "Remind me later", + "rename_robot": "Rename robot", "rename_robot_input_error": "Oops! Robot name must follow the character count and limitations.", "rename_robot_input_limitation_detail": "Please enter 17 characters max using valid inputs: letters and numbers.", "rename_robot_prefer_usb_connection": "To ensure reliable renaming of your robot, please connect to it via USB.", "rename_robot_title": "Rename Robot", - "rename_robot": "Rename robot", "requires_restarting_the_robot": "Updating the robot’s software requires restarting the robot", "reset_to_factory_settings": "Reset to factory settings?", "resets_cannot_be_undone": "Resets cannot be undone", "restart_now": "Restart now?", "restart_robot_confirmation_description": "It will take a few minutes for {{robotName}} to restart.", - "restarting_robot": "Restarting robot...", + "restarting_robot": "Install complete, robot restarting...", "resume_robot_operations": "Resume robot operations", "returns_your_device_to_new_state": "This returns your device to a new state.", + "robot_busy_protocol": "This robot cannot be updated while a protocol is running on it", "robot_calibration_data": "Robot Calibration Data", "robot_name": "Robot Name", + "robot_operating_update_available": "Robot Operating System Update Available", "robot_serial_number": "Robot Serial Number", - "robot_server_version_ot3_description": "The Opentrons Flex software includes the robot server and the touchscreen display interface.", "robot_server_version": "Robot Server Version", - "robot_settings_advanced_unknown": "Unknown", + "robot_server_version_ot3_description": "The Opentrons Flex software includes the robot server and the touchscreen display interface.", "robot_settings": "Robot Settings", + "robot_settings_advanced_unknown": "Unknown", "robot_software_update_required": "A robot software update is required to run protocols with this version of the Opentrons App.", "robot_successfully_connected": "Robot successfully connected to {{networkName}}.", - "robot_system_version_available": "Robot System Version {{releaseVersion}} available", "robot_system_version": "Robot System Version", + "robot_system_version_available": "Robot System Version {{releaseVersion}} available", "robot_update_available": "Robot Update Available", + "robot_update_success": "Robot software successfully updated", "search_again": "Search again", - "searching_for_networks": "Searching for networks...", "searching": "Searching", + "searching_for_networks": "Searching for networks...", "security_type": "Security Type", "select_a_network": "Select a network", "select_a_security_type": "Select a security type", "select_all_settings": "Select all settings", "select_authentication_method": "Select authentication method for your selected network.", "sending_software": "Sending software...", - "share_logs_with_opentrons_description": "Help Opentrons improve its products and services by automatically sending anonymous robot logs. Opentrons uses these logs to troubleshoot robot issues and spot error trends.", + "serial": "Serial", "share_logs_with_opentrons": "Share Robot logs with Opentrons", - "short_trash_bin_description": "For pre-2019 robots with trash bins that are 55mm tall (instead of 77mm default)", + "share_logs_with_opentrons_description": "Help Opentrons improve its products and services by automatically sending anonymous robot logs. Opentrons uses these logs to troubleshoot robot issues and spot error trends.", + "share_logs_with_opentrons_description_short": "Share anonymous robot logs with Opentrons.", + "share_logs_with_opentrons_short": "Share Robot logs", "short_trash_bin": "Short trash bin", - "show_password": "Show Password", + "short_trash_bin_description": "For pre-2019 robots with trash bins that are 55mm tall (instead of 77mm default)", "show": "Show", + "show_password": "Show Password", "sign_into_wifi": "Sign into Wi-Fi", "software_is_up_to_date": "Your software is already up to date!", "software_update_error": "Software update error", "some_robot_controls_are_not_available": "Some robot controls are not available when run is in progress", "subnet_mask": "Subnet Mask", - "successfully_connected_to_network": "Successfully connected to {{ssid}}!", "successfully_connected": "Successfully connected!", + "successfully_connected_to_network": "Successfully connected to {{ssid}}!", "supported_protocol_api_versions": "Supported Protocol API Versions", "switch_to_usb_description": "If your network uses a different authentication method, connect to the Opentrons App and finish Wi-Fi setup there.", - "text_size_description": "Text on all screens will adjust to the size you choose below.", "text_size": "Text Size", + "text_size_description": "Text on all screens will adjust to the size you choose below.", "tip_length_calibrations_history": "See all Tip Length Calibration history", "tip_length_calibrations_title": "Tip Length Calibrations", "tiprack": "Tip Rack", "touchscreen_brightness": "Touchscreen Brightness", "touchscreen_sleep": "Touchscreen Sleep", "troubleshooting": "Troubleshooting", + "try_again": "Try again", "up_to_date": "up to date", + "update_available": "Update Available", "update_channel_description": "Stable receives the latest stable releases. Beta allows you to try out new in-progress features before they launch in Stable channel, but they have not completed testing yet.", "update_complete": "Update complete!", "update_found": "Update found!", "update_robot_now": "Update robot now", + "update_robot_software": "Update robot software manually with a local file (.zip)", "update_robot_software_description": "Bypass the Opentrons App auto-update process and update the robot software manually.", "update_robot_software_link": "Launch Opentrons software update page", - "update_robot_software": "Update robot software manually with a local file (.zip)", - "updating_robot_system": "Updating the robot system requires a restart", + "updating": "Updating", + "updating_robot_system": "Updating the robot software requires restarting the robot", "usage_settings": "Usage Settings", - "usb_to_ethernet_description": "Looking for USB-to-Ethernet Adapter info?", "usb": "USB", - "use_older_aspirate_description": "Aspirate with the less accurate volumetric calibrations that were used before version 3.7.0. Use this if you need consistency with pre-v3.7.0 results. This only affects GEN1 P10S, P10M, P50M, and P300S pipettes.", + "usb_to_ethernet_description": "Looking for USB-to-Ethernet Adapter info?", "use_older_aspirate": "Use older aspirate behavior", - "use_older_protocol_analysis_method_description": "Use an older, slower method of analyzing uploaded protocols. This changes how the OT-2 validates your protocol during the upload step, but does not affect how your protocol actually runs. Opentrons Support might ask you to change this setting if you encounter problems with the newer, faster protocol analysis method.", + "use_older_aspirate_description": "Aspirate with the less accurate volumetric calibrations that were used before version 3.7.0. Use this if you need consistency with pre-v3.7.0 results. This only affects GEN1 P10S, P10M, P50M, and P300S pipettes.", "use_older_protocol_analysis_method": "Use older protocol analysis method", + "use_older_protocol_analysis_method_description": "Use an older, slower method of analyzing uploaded protocols. This changes how the OT-2 validates your protocol during the upload step, but does not affect how your protocol actually runs. Opentrons Support might ask you to change this setting if you encounter problems with the newer, faster protocol analysis method.", "validating_software": "Validating software...", "view_details": "View details", - "view_latest_release_notes_at": "View latest release notes at ", + "view_latest_release_notes_at": "View latest release notes at {{url}}", "view_network_details": "View network details", "view_opentrons_issue_tracker": "View Opentrons issue tracker", "view_opentrons_release_notes": "View full Opentrons release notes", @@ -288,13 +309,13 @@ "wired_ip": "Wired IP", "wired_mac_address": "Wired MAC Address", "wired_subnet_mask": "Wired Subnet Mask", - "wired_usb_description": "Learn about connecting to a robot via USB", "wired_usb": "Wired USB", + "wired_usb_description": "Learn about connecting to a robot via USB", "wireless_ip": "Wireless IP", "wireless_mac_address": "Wireless MAC Address", "wireless_subnet_mask": "Wireless Subnet Mask", - "wpa2_personal_description": "Most labs use this method", "wpa2_personal": "WPA2 Personal", + "wpa2_personal_description": "Most labs use this method", "yes_clear_data_and_restart_robot": "Yes, clear data and restart robot", "your_mac_address_is": "Your MAC Address is {{macAddress}}", "your_robot_is_ready_to_go": "Your robot is ready to go." diff --git a/app/src/assets/localization/en/firmware_update.json b/app/src/assets/localization/en/firmware_update.json index 64c11d59190..a9ade820a34 100644 --- a/app/src/assets/localization/en/firmware_update.json +++ b/app/src/assets/localization/en/firmware_update.json @@ -1,10 +1,17 @@ { "download_logs": "Download the robot logs from the Opentrons App and send them to support@opentrons.com for assistance.", "firmware_out_of_date": "The firmware for {{mount}} {{instrument}} is out of date. You need to update it before running protocols that use this instrument.", + "gantry_x": "Gantry X", + "gantry_y": "Gantry Y", + "gripper": "Gripper", + "head": "Head", + "pipette_left": "Left Pipette", + "pipette_right": "Right Pipette", "ready_to_use": "Your {{instrument}} is ready to use!", + "rear_panel": "Rear Panel", "successful_update": "Successful update!", "update_failed": "Update failed", "update_firmware": "Update firmware", "update_needed": "Instrument firmware update needed", - "updating_firmware": "Updating firmware..." + "updating_firmware": "Updating {{subsystem}} firmware..." } diff --git a/app/src/assets/localization/en/heater_shaker.json b/app/src/assets/localization/en/heater_shaker.json index 7c1aed4b300..532f7ee125d 100644 --- a/app/src/assets/localization/en/heater_shaker.json +++ b/app/src/assets/localization/en/heater_shaker.json @@ -1,32 +1,7 @@ { - "1a": "1a", - "1b": "1b", - "1c": "1c", - "3a": "3a", - "3b": "3b", - "3c": "3c", - "a_properly_attached_adapter": "A properly attached adapter will sit evenly on the module.", - "about_screwdriver": "Provided with module. Note: using another screwdriver size can strip the module’s screws.", - "adapter_name_and_screw": "{{adapter}} + Screw", - "attach_adapter_to_module": "Attach your adapter to the module.", - "attach_heater_shaker_to_deck": "Attach {{name}} to deck before proceeding to run", - "attach_module_anchor_not_extended": "Before placing the module on the deck, make sure the anchors are not extended and are level with the module’s base.", - "attach_module_check_attachment": "Check attachment by gently pulling up and rocking the module.", - "attach_module_extend_anchors": "Hold the module flat against the deck and turn screws clockwise to extend the anchors.", - "attach_module_turn_screws": "Turn screws counterclockwise to retract the anchors. The screws should not come out of the module.", - "attach_screwdriver_and_screw_explanation": "Using a different screwdriver can strip the screws. Using a different screw than the one provided can damage the module", - "attach_screwdriver_and_screw": "Please use T10 Torx Screwdriver and provided screw ", - "attach_to_deck_to_prevent_shaking": "Attachment prevents the module from shaking out of a deck slot.", "back": "Back", - "btn_begin_attachment": "Begin attachment", - "btn_continue_attachment_guide": "Continue to attachment guide", - "btn_power_module": "Continue to power on module", - "btn_test_shake": "Continue to test shake", - "btn_thermal_adapter": "Continue to attach thermal adapter", "cannot_open_latch": "Cannot open labware latch while module is shaking.", "cannot_shake": "Cannot shake when labware latch is open", - "check_alignment_instructions": "Check attachment by rocking the adapter back and forth.", - "check_alignment": "Check alignment.", "close_labware_latch": "Close Labware Latch", "close_latch": "Close latch", "closed_and_locked": "Closed and Locked", @@ -39,53 +14,24 @@ "deactivate_heater": "Deactivate heater", "deactivate_shaker": "Deactivate shaker", "deactivate": "Deactivate", - "go_to_step_1": "Go to Step 1", - "go_to_step_3": "Go to Step 3", - "heater_shaker_anchor_description": "The 2 Anchors keep the module attached to the deck while it is shaking. To extend and retract each anchor, turn the screw above it. See animation below. Extending the anchors increases the module’s footprint, which more firmly attaches it to the slot.", "heater_shaker_in_slot": "Attach {{moduleName}} in Slot {{slotName}} before proceeding", "heater_shaker_is_shaking": "Heater-Shaker Module is currently shaking", - "heater_shaker_key_parts": "Key Heater-Shaker parts and terminology", - "heater_shaker_latch_description": "The Labware Latch keeps labware secure while the module is shaking. It can be opened or closed manually and with software but is closed and locked while the module is shaking.", - "heater_shaker_orient_module": "Orient the module so its power ports face away from you.", - "heater_shaker_setup_description": "{{name}} - Attach Heater-Shaker Module", - "how_to_attach_module": "See how to attach module to the deck", - "how_to_attach_to_deck": "See how to attach to deck", - "improperly_fastened_description": "An improperly fastened Heater-Shaker module can shake itself out of a deck slot.", - "intro_adapter_body": "Screw may already be in the center of the module.", - "intro_adapter_known": "{{adapter}} + Screw", - "intro_adapter_unknown": "Thermal Adapter + Screw", - "intro_heater_shaker_mod": "Heater-Shaker Module", - "intro_labware": "Labware", - "intro_screwdriver_body": "Provided with module. Note: using another screwdriver size can strip the module’s screws.", - "intro_screwdriver": "T10 Torx Screwdriver", - "intro_subtitle": "You will need:", - "intro_title": "Use this guide to attach the Heater-Shaker Module to your robot’s deck for secure shaking.", "keep_shaking_start_run": "Keep shaking and start run", "labware_latch": "Labware Latch", "labware": "Labware", "min_max_rpm": "{{min}} - {{max}} rpm", "module_anchors_extended": "Before the run begins, module should have both anchors fully extended for a firm attachment to the deck.", "module_in_slot": "{{moduleName}} in Slot {{slotName}}", - "module_is_not_connected": "Module is not connected", "module_should_have_anchors": "Module should have both anchors fully extended for a firm attachment to the deck.", "open_labware_latch": "Open Labware Latch", "open_latch": "Open latch", "open": "Open", "opening": "Opening...", - "orient_heater_shaker_module": "Orient your module such that the power and USB ports are facing outward.", - "place_the_module_slot_number": "Place the module in Slot {{slot}}.", - "place_the_module_slot": "Place the module in a Slot.", "proceed_to_run": "Proceed to run", - "screw_may_be_in_module": "Screw may already be in the center of the module.", "set_shake_speed": "Set shake speed", "set_temperature": "Set module temperature", "shake_speed": "Shake speed", "show_attachment_instructions": "Show attachment instructions", - "start_shaking": "Start Shaking", - "step_1_of_4_attach_module": "Step 1 of 4: Attach module to deck", - "step_2_power_on": "Step 2 of 4: Power on the moduleConnect your module to the robot and and power it on.", - "step_3_of_4_attach_adapter": "Step 3 of 4: Attach Thermal Adapter", - "step_4_of_4": "Step 4 of 4: Test shake", "stop_shaking_start_run": "Stop shaking and start run", "stop_shaking": "Stop Shaking", "t10_torx_screwdriver": "{{name}} Screwdriver", @@ -97,9 +43,5 @@ "thermal_adapter_attached_to_module": "The thermal adapter should be attached to the module.", "troubleshoot_step_1": "Return to Step 1 to see instructions for securing the module to the deck.", "troubleshoot_step_3": "Return to Step 3 to see instructions for securing the thermal adapter to the module.", - "troubleshooting": "Troubleshooting", - "unknown_adapter_and_screw": "Thermal Adapter + Screw", - "use_this_heater_shaker_guide": "Use this guide to attach the Heater-Shaker Module to your robot’s deck for secure shaking.", - "view_instructions": "View instructions", - "you_will_need": "You will need:" + "troubleshooting": "Troubleshooting" } diff --git a/app/src/assets/localization/en/labware_position_check.json b/app/src/assets/localization/en/labware_position_check.json index 9a5b05ce7d2..7fded35aeae 100644 --- a/app/src/assets/localization/en/labware_position_check.json +++ b/app/src/assets/localization/en/labware_position_check.json @@ -101,6 +101,7 @@ "stored_offsets_for_this_protocol": "Stored Labware Offset data that applies to this protocol", "table_view": "Table View", "tip_rack": "tip rack", + "view_current_offsets": "view current offsets", "view_data": "View data", "what_is_labware_offset_data": "What is labware offset data?" } diff --git a/app/src/assets/localization/en/module_wizard_flows.json b/app/src/assets/localization/en/module_wizard_flows.json index d52440e6167..76b5b96ac84 100644 --- a/app/src/assets/localization/en/module_wizard_flows.json +++ b/app/src/assets/localization/en/module_wizard_flows.json @@ -4,22 +4,37 @@ "calibration_probe": "Take the calibration probe from its storage location. Ensure its collar is unlocked. Push the pipette ejector up and press the probe firmly onto the pipette nozzle. Twist the collar to lock the probe. Test that the probe is secure by gently pulling it back and forth.", "calibrate": "Calibrate", "calibration": "{{module}} Calibration", - "cal_adapter": "Calibration Adapter", + "calibration_probe_touching": "The calibration probe will touch the sides of the calibration square in {{module}} in slot {{slotNumber}} to determine its exact position", + "calibration_probe_touching_thermocycler": "The calibration probe will touch the sides of the calibration square in Thermocycler to determine its exact position", + "calibration_adapter_heatershaker": "Calibration Adapter", + "calibration_adapter_temperature": "Calibration Adapter", + "calibration_adapter_thermocycler": "Calibration Adapter", "checking_firmware": "Checking {{module}} firmware", + "complete_calibration": "Complete calibration", "confirm_location": "Confirm location", "confirm_placement": "Confirm placement", + "detach_probe": "Remove pipette probe", + "detach_probe_description": "Unlock the pipette calibration probe, remove it from the nozzle, and return it to its storage location.", + "error_during_calibration": "Error during calibration", "exit": "Exit", "firmware_updated": "{{module}} firmware updated!", "get_started": "To get started, remove labware from the deck and clean up the working area to make the calibration easier. Also gather the needed equipment shown to the right.The calibration adapter came with your module. The pipette probe came with your Flex pipette.", + "install_probe_8_channel": "Take the calibration probe from its storage location. Ensure its collar is unlocked. Push the pipette ejector up and press the probe firmly onto the backmost pipette nozzle. Twist the collar to lock the probe. Test that the probe is secure by gently pulling it back and forth.", + "install_probe_96_channel": "Take the calibration probe from its storage location. Ensure its collar is unlocked. Push the pipette ejector up and press the probe firmly onto the A1 (back left corner) pipette nozzle. Twist the collar to lock the probe. Test that the probe is secure by gently pulling it back and forth.", + "install_probe": "Take the calibration probe from its storage location. Ensure its collar is unlocked. Push the pipette ejector up and press the probe firmly onto the pipette nozzle. Twist the collar to lock the probe. Test that the probe is secure by gently pulling it back and forth.", "module_calibration": "Module calibration", + "module_calibrating": "Stand back, {{moduleName}} is calibrating", + "module_calibration_failed": "The module calibration could not be completed. Contact customer support for assistance.", "module_secured": "The module must be fully secured in its caddy and secured in the deck slot.", "move_gantry_to_front": "Move gantry to front", "next": "Next", "pipette_probe": "Pipette Probe", "place_adapter": "Place calibration adapter in {{module}}", "place_flush": "Place the adapter flush on top of the module.", + "recalibrate": "Recalibrate", "stand_back": "Stand back, calibration in progress", "successfully_calibrated": "{{module}} successfully calibrated", "select_location": "Select module location", - "select_the_slot": "Select the slot where you installed the {{module}} on the deck map to the right. The location must be correct for successful calibration." + "select_the_slot": "Select the slot where you installed the {{module}} on the deck map to the right. The location must be correct for successful calibration.", + "start_setup": "Start setup" } diff --git a/app/src/assets/localization/en/protocol_command_text.json b/app/src/assets/localization/en/protocol_command_text.json index f026cd6e865..e7f50460e79 100644 --- a/app/src/assets/localization/en/protocol_command_text.json +++ b/app/src/assets/localization/en/protocol_command_text.json @@ -1,4 +1,6 @@ { + "adapter_in_mod_in_slot": "{{adapter}} on {{module}} in {{slot}}", + "adapter_in_slot": "{{adapter}} in {{slot}}", "aspirate": "Aspirating {{volume}} µL from well {{well_name}} of {{labware}} in {{labware_location}} at {{flow_rate}} µL/sec", "blowout": "Blowing out at well {{well_name}} of {{labware}} in {{labware_location}} at {{flow_rate}} µL/sec", "closing_tc_lid": "Closing Thermocycler lid", @@ -34,6 +36,7 @@ "pause_on": "Pause on {{robot_name}}", "pause": "Pause", "pickup_tip": "Picking up tip from {{well_name}} of {{labware}} in {{labware_location}}", + "return_tip": "Returning tip to {{well_name}} of {{labware}} in {{labware_location}}", "save_position": "Saving position", "set_and_await_hs_shake": "Setting Heater-Shaker to shake at {{rpm}} rpm and waiting until reached", "setting_hs_temp": "Setting Target Temperature of Heater-Shaker to {{temp}}", diff --git a/app/src/assets/localization/en/protocol_info.json b/app/src/assets/localization/en/protocol_info.json index 48f07abdc00..0fc225e6f65 100644 --- a/app/src/assets/localization/en/protocol_info.json +++ b/app/src/assets/localization/en/protocol_info.json @@ -74,6 +74,7 @@ "rerunning_protocol_modal_link": "Learn more about Labware Offset Data", "rerunning_protocol_modal_title": "See How Rerunning a Protocol Works", "robot_name_last_run": "{{robot_name}}’s last run", + "robot_type_first": "{{robotType}} protocols first", "run_again": "Run again", "run_protocol": "Run protocol", "run_timestamp_title": "Run timestamp", diff --git a/app/src/assets/localization/en/protocol_list.json b/app/src/assets/localization/en/protocol_list.json index 667e3007b17..1b54fc7885c 100644 --- a/app/src/assets/localization/en/protocol_list.json +++ b/app/src/assets/localization/en/protocol_list.json @@ -10,7 +10,8 @@ "reanalyze_or_view_error": "Reanalyze protocol or view error details", "right_mount": "right mount", "robot": "robot", - "send_to_ot3": "Send to Opentrons Flex", + "send_to_ot3_overflow": "Send to {{robot_display_name}}", + "send_to_ot3": "Send protocol to {{robot_display_name}}", "should_delete_this_protocol": "Delete this protocol?", "show_in_folder": "Show in folder", "start_setup": "Start setup", diff --git a/app/src/assets/localization/en/protocol_setup.json b/app/src/assets/localization/en/protocol_setup.json index a4b82e42404..0af5a041b86 100644 --- a/app/src/assets/localization/en/protocol_setup.json +++ b/app/src/assets/localization/en/protocol_setup.json @@ -6,6 +6,7 @@ "additional_off_deck_labware": "Additional Off-Deck Labware", "attach_gripper_failure_reason": "Attach the required gripper to continue", "attach_gripper": "attach gripper", + "attach_pipette_before_module_calibration": "Attach a pipette before running module calibration", "attach_pipette_calibration": "Attach pipette to see calibration information", "attach_pipette_cta": "Attach Pipette", "attach_pipette_failure_reason": "Attach the required pipette(s) to continue", @@ -18,6 +19,7 @@ "calibrate_deck_to_proceed_to_tip_length_calibration": "Calibrate your deck in order to proceed to tip length calibration", "calibrate_gripper_failure_reason": "Calibrate the required gripper to continue", "calibrate_now": "Calibrate now", + "calibrate_pipette_before_module_calibration": "Calibrate pipette before running module calibration", "calibrate_pipette_failure_reason": "Calibrate the required pipette(s) to continue", "calibrate_tiprack_failure_reason": "Calibrate the required tip lengths to continue", "calibrate": "calibrate", @@ -25,6 +27,9 @@ "calibration_data_not_available": "Calibration data not available once run has started", "calibration_needed": "Calibration needed", "calibration_ready": "Calibration ready", + "calibration_required_attach_pipette_first": "Calibration required Attach pipette first", + "calibration_required_calibrate_pipette_first": "Calibration required Calibrate pipette first", + "calibration_required": "Calibration required", "calibration_status": "calibration status", "calibration": "Calibration", "closing": "Closing...", @@ -97,8 +102,11 @@ "magnetic_module_extra_attention": "Opentrons recommends securing labware with the module’s bracket", "map_view": "Map View", "missing": "Missing", + "modal_instructions_title": "{{moduleName}} Setup Instructions", + "modal_instructions": "For step-by-step instructions on setting up your module, consult the Quickstart Guide that came in its box. You can also click the link below or scan the QR code to visit the modules section of the Opentrons Help Center.", "module_connected": "Connected", "module_disconnected": "Disconnected", + "module_instructions_link": "{{moduleName}} setup instructions", "module_mismatch_body": "Check that the modules connected to this robot are of the right type and generation", "module_name": "Module Name", "module_not_connected": "Not connected", @@ -123,7 +131,7 @@ "must_have_labware_and_pip": "Protocol must load labware and a pipette", "n_a": "N/A", "no_data": "no data", - "no_labware_offset_data": "No Labware Offset Data yet", + "no_labware_offset_data": "no labware offset data yet", "no_modules_specified": "no modules are specified for this protocol.", "no_modules_used_in_this_protocol": "No modules used in this protocol", "no_tiprack_loaded": "Protocol must load a tip rack", @@ -132,10 +140,13 @@ "no_usb_port_yet": "No USB Port Yet", "no_usb_required": "No USB required", "not_calibrated": "Not calibrated yet", + "off_deck": "Off deck", "offset_data": "Offset Data", "offsets_applied_plural": "{{count}} offsets applied", "offsets_applied": "{{count}} offset applied", + "on_adapter_in_mod": "on {{adapterName}} in {{moduleName}}", "on_adapter": "on {{adapterName}}", + "on_deck": "On deck", "on-deck_labware": "{{count}} on-deck labware", "opening": "Opening...", "pipette_mismatch": "Pipette generation mismatch.", @@ -145,6 +156,7 @@ "pipette_offset_cal_description_bullet_3": "Redo Pipette Offset Calibration after performing Tip Length Calibration for the tip you used to calibrate the pipette.", "pipette_offset_cal_description": "This measures a pipette’s X, Y and Z values in relation to the pipette mount and the deck. Pipette Offset Calibration relies on Deck Calibration and Tip Length Calibration. ", "pipette_offset_cal": "Pipette Offset Calibration", + "placement": "Placement", "plug_in_required_module_plural": "Plug in and power up the required modules to continue", "plug_in_required_module": "Plug in and power up the required module to continue", "prepare_to_run": "Prepare to run", diff --git a/app/src/assets/localization/en/robot_controls.json b/app/src/assets/localization/en/robot_controls.json index c4565c575dd..0526077ab5f 100644 --- a/app/src/assets/localization/en/robot_controls.json +++ b/app/src/assets/localization/en/robot_controls.json @@ -1,4 +1,5 @@ { + "confirm_location": "confirm location", "home_button": "home", "home_description": "Return robot to starting position.", "home_label": "home all axes", diff --git a/app/src/assets/localization/en/run_details.json b/app/src/assets/localization/en/run_details.json index 4e4baeaeab1..b6f74676a97 100644 --- a/app/src/assets/localization/en/run_details.json +++ b/app/src/assets/localization/en/run_details.json @@ -114,7 +114,7 @@ "start_time": "Start Time", "start": "Start", "status_blocked-by-open-door": "Paused - door open", - "status_failed": "Completed", + "status_failed": "Failed", "status_finishing": "Finishing", "status_idle": "Not started", "status_pause-requested": "Pause requested", diff --git a/app/src/assets/localization/en/shared.json b/app/src/assets/localization/en/shared.json index 903f311f855..20883f99012 100644 --- a/app/src/assets/localization/en/shared.json +++ b/app/src/assets/localization/en/shared.json @@ -5,6 +5,7 @@ "before_you_begin": "Before you begin", "browse": "browse", "cancel": "cancel", + "clear_data": "clear data", "close": "close", "computer_in_app_is_controlling_robot": "A computer with the Opentrons App is currently controlling this robot.", "confirm_placement": "Confirm placement", diff --git a/app/src/assets/videos/module_wizard_flows/HeaterShaker_PlaceAdapter_L.webm b/app/src/assets/videos/module_wizard_flows/HeaterShaker_PlaceAdapter_L.webm new file mode 100644 index 00000000000..a3281a8befc Binary files /dev/null and b/app/src/assets/videos/module_wizard_flows/HeaterShaker_PlaceAdapter_L.webm differ diff --git a/app/src/assets/videos/module_wizard_flows/HeaterShaker_PlaceAdapter_R.webm b/app/src/assets/videos/module_wizard_flows/HeaterShaker_PlaceAdapter_R.webm new file mode 100644 index 00000000000..e29ce967e70 Binary files /dev/null and b/app/src/assets/videos/module_wizard_flows/HeaterShaker_PlaceAdapter_R.webm differ diff --git a/app/src/assets/videos/module_wizard_flows/TempModule_PlaceAdapter_L.webm b/app/src/assets/videos/module_wizard_flows/TempModule_PlaceAdapter_L.webm new file mode 100644 index 00000000000..46e73223e2d Binary files /dev/null and b/app/src/assets/videos/module_wizard_flows/TempModule_PlaceAdapter_L.webm differ diff --git a/app/src/assets/videos/module_wizard_flows/TempModule_PlaceAdapter_R.webm b/app/src/assets/videos/module_wizard_flows/TempModule_PlaceAdapter_R.webm new file mode 100644 index 00000000000..fbf663c610c Binary files /dev/null and b/app/src/assets/videos/module_wizard_flows/TempModule_PlaceAdapter_R.webm differ diff --git a/app/src/assets/videos/module_wizard_flows/Thermocycler_PlaceAdapter.webm b/app/src/assets/videos/module_wizard_flows/Thermocycler_PlaceAdapter.webm new file mode 100644 index 00000000000..3293a102799 Binary files /dev/null and b/app/src/assets/videos/module_wizard_flows/Thermocycler_PlaceAdapter.webm differ diff --git a/app/src/assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_1.webm b/app/src/assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_1.webm index eff891c50cb..83b453b507d 100644 Binary files a/app/src/assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_1.webm and b/app/src/assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_1.webm differ diff --git a/app/src/assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_8.webm b/app/src/assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_8.webm index 504c581d751..2bb87fa02c2 100644 Binary files a/app/src/assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_8.webm and b/app/src/assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_8.webm differ diff --git a/app/src/assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_96.webm b/app/src/assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_96.webm new file mode 100644 index 00000000000..30594aa9fdc Binary files /dev/null and b/app/src/assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_96.webm differ diff --git a/app/src/assets/videos/pipette-wizard-flows/Pipette_Detach_Probe_1.webm b/app/src/assets/videos/pipette-wizard-flows/Pipette_Detach_Probe_1.webm index c581db32235..6f6f3fac5a6 100644 Binary files a/app/src/assets/videos/pipette-wizard-flows/Pipette_Detach_Probe_1.webm and b/app/src/assets/videos/pipette-wizard-flows/Pipette_Detach_Probe_1.webm differ diff --git a/app/src/assets/videos/pipette-wizard-flows/Pipette_Detach_Probe_8.webm b/app/src/assets/videos/pipette-wizard-flows/Pipette_Detach_Probe_8.webm index 4412e2a5f6a..1e2fd0b7950 100644 Binary files a/app/src/assets/videos/pipette-wizard-flows/Pipette_Detach_Probe_8.webm and b/app/src/assets/videos/pipette-wizard-flows/Pipette_Detach_Probe_8.webm differ diff --git a/app/src/assets/videos/pipette-wizard-flows/Pipette_Probing_1.webm b/app/src/assets/videos/pipette-wizard-flows/Pipette_Probing_1.webm index fcc4cc4d9c1..c5312a8732a 100644 Binary files a/app/src/assets/videos/pipette-wizard-flows/Pipette_Probing_1.webm and b/app/src/assets/videos/pipette-wizard-flows/Pipette_Probing_1.webm differ diff --git a/app/src/assets/videos/pipette-wizard-flows/Pipette_Probing_8.webm b/app/src/assets/videos/pipette-wizard-flows/Pipette_Probing_8.webm index a076ecce809..d38357f7822 100644 Binary files a/app/src/assets/videos/pipette-wizard-flows/Pipette_Probing_8.webm and b/app/src/assets/videos/pipette-wizard-flows/Pipette_Probing_8.webm differ diff --git a/app/src/atoms/InputField/index.tsx b/app/src/atoms/InputField/index.tsx index 80868fecd5d..58eb8945db3 100644 --- a/app/src/atoms/InputField/index.tsx +++ b/app/src/atoms/InputField/index.tsx @@ -93,10 +93,6 @@ function Input(props: InputFieldProps): JSX.Element { ${error ? COLORS.errorEnabled : COLORS.medGreyEnabled}; font-size: ${TYPOGRAPHY.fontSizeP}; - @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - padding: 0; - } - &:active { border: 1px ${BORDERS.styleSolid} ${COLORS.darkGreyEnabled}; } @@ -130,6 +126,13 @@ function Input(props: InputFieldProps): JSX.Element { } ` + const FORM_BOTTOM_SPACE_STYLE = css` + padding-bottom: ${SPACING.spacing4}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + padding-bottom: 0; + } + ` + const ERROR_TEXT_STYLE = css` color: ${COLORS.errorEnabled}; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { @@ -165,9 +168,9 @@ function Input(props: InputFieldProps): JSX.Element { paddingTop={SPACING.spacing4} flexDirection={DIRECTION_COLUMN} > - {props.caption} + {props.caption} {props.secondaryCaption != null ? ( - {props.secondaryCaption} + {props.secondaryCaption} ) : null} {props.error} diff --git a/app/src/atoms/SoftwareKeyboard/CustomKeyboard.stories.tsx b/app/src/atoms/SoftwareKeyboard/CustomKeyboard/CustomKeyboard.stories.tsx similarity index 86% rename from app/src/atoms/SoftwareKeyboard/CustomKeyboard.stories.tsx rename to app/src/atoms/SoftwareKeyboard/CustomKeyboard/CustomKeyboard.stories.tsx index 17d48593cb9..87cbbcb1da9 100644 --- a/app/src/atoms/SoftwareKeyboard/CustomKeyboard.stories.tsx +++ b/app/src/atoms/SoftwareKeyboard/CustomKeyboard/CustomKeyboard.stories.tsx @@ -5,13 +5,14 @@ import { POSITION_ABSOLUTE, SPACING, } from '@opentrons/components' -import { InputField } from '../InputField' -import { CustomKeyboard } from './' +import { InputField } from '../../InputField' +import { CustomKeyboard } from '.' +import '../../../styles.global.css' import type { Story, Meta } from '@storybook/react' export default { - title: 'Odd/Atoms/SoftwareKeyboard/CustomKeyboard', + title: 'ODD/Atoms/SoftwareKeyboard/CustomKeyboard', component: CustomKeyboard, } as Meta diff --git a/app/src/atoms/SoftwareKeyboard/__tests__/CustomKeyboard.test.tsx b/app/src/atoms/SoftwareKeyboard/CustomKeyboard/__tests__/CustomKeyboard.test.tsx similarity index 99% rename from app/src/atoms/SoftwareKeyboard/__tests__/CustomKeyboard.test.tsx rename to app/src/atoms/SoftwareKeyboard/CustomKeyboard/__tests__/CustomKeyboard.test.tsx index fc8e8952506..245a091b3aa 100644 --- a/app/src/atoms/SoftwareKeyboard/__tests__/CustomKeyboard.test.tsx +++ b/app/src/atoms/SoftwareKeyboard/CustomKeyboard/__tests__/CustomKeyboard.test.tsx @@ -84,7 +84,7 @@ describe('CustomKeyboard', () => { 'J', 'K', 'L', - 'SHIFT', + 'abc', 'Z', 'X', 'C', diff --git a/app/src/atoms/SoftwareKeyboard/CustomKeyboard/index.css b/app/src/atoms/SoftwareKeyboard/CustomKeyboard/index.css new file mode 100644 index 00000000000..1a8090ed878 --- /dev/null +++ b/app/src/atoms/SoftwareKeyboard/CustomKeyboard/index.css @@ -0,0 +1,33 @@ +/* stylelint-disable */ + +.simple-keyboard.oddTheme1.hg-theme-default { + width: 100%; + height: 100%; + background-color: #d0d0d0; + font-family: 'Public Sans', sans-serif; + padding: 8px; + font-size: 28px; +} + +.simple-keyboard.oddTheme1 + .hg-row:not(:last-child) + .hg-button:not(:last-child) { + margin-right: 8px; + margin-bottom: 3px; +} + +.simple-keyboard.simple-keyboard.oddTheme1 .hg-button { + height: 48px; +} + +.simple-keyboard .hg-button:active { + color: #16212d; + background-color: #e3e3e3; +} + +/* Numeric keyboard in custom keyboard */ +.hg-layout-numbers button.hg-button.hg-button-backspace, +.hg-layout-numbers button.hg-button.hg-button-abc, +.hg-layout-numbers button.hg-button.hg-standardBtn { + flex: 1; +} diff --git a/app/src/atoms/SoftwareKeyboard/CustomKeyboard.tsx b/app/src/atoms/SoftwareKeyboard/CustomKeyboard/index.tsx similarity index 94% rename from app/src/atoms/SoftwareKeyboard/CustomKeyboard.tsx rename to app/src/atoms/SoftwareKeyboard/CustomKeyboard/index.tsx index 4e1885b6e11..ddf9215a874 100644 --- a/app/src/atoms/SoftwareKeyboard/CustomKeyboard.tsx +++ b/app/src/atoms/SoftwareKeyboard/CustomKeyboard/index.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import Keyboard from 'react-simple-keyboard' -import { customDisplay } from './constants' +import { customDisplay } from '../constants' interface CustomKeyboardProps { onChange: (input: string) => void @@ -17,7 +17,7 @@ const customLayout = { shift: [ 'Q W E R T Y U I O P', 'A S D F G H J K L', - '{shift} Z X C V B N M {backspace}', + '{abc} Z X C V B N M {backspace}', '{numbers}', ], numbers: ['1 2 3', '4 5 6', '7 8 9', '{abc} 0 {backspace}'], diff --git a/app/src/atoms/SoftwareKeyboard/NormalKeyboard.stories.tsx b/app/src/atoms/SoftwareKeyboard/NormalKeyboard/NormalKeyboard.stories.tsx similarity index 86% rename from app/src/atoms/SoftwareKeyboard/NormalKeyboard.stories.tsx rename to app/src/atoms/SoftwareKeyboard/NormalKeyboard/NormalKeyboard.stories.tsx index f2bb1a08a05..d7d62d87d7c 100644 --- a/app/src/atoms/SoftwareKeyboard/NormalKeyboard.stories.tsx +++ b/app/src/atoms/SoftwareKeyboard/NormalKeyboard/NormalKeyboard.stories.tsx @@ -5,14 +5,14 @@ import { POSITION_ABSOLUTE, SPACING, } from '@opentrons/components' -import { InputField } from '../InputField' -import { NormalKeyboard } from './' -import '../../styles.global.css' +import { InputField } from '../../InputField' +import { NormalKeyboard } from '.' +import '../../../styles.global.css' import type { Story, Meta } from '@storybook/react' export default { - title: 'Odd/Atoms/SoftwareKeyboard/NormalKeyboard', + title: 'ODD/Atoms/SoftwareKeyboard/NormalKeyboard', component: NormalKeyboard, } as Meta diff --git a/app/src/atoms/SoftwareKeyboard/__tests__/NormalKeyboard.test.tsx b/app/src/atoms/SoftwareKeyboard/NormalKeyboard/__tests__/NormalKeyboard.test.tsx similarity index 99% rename from app/src/atoms/SoftwareKeyboard/__tests__/NormalKeyboard.test.tsx rename to app/src/atoms/SoftwareKeyboard/NormalKeyboard/__tests__/NormalKeyboard.test.tsx index 346d861b240..41cac3e2056 100644 --- a/app/src/atoms/SoftwareKeyboard/__tests__/NormalKeyboard.test.tsx +++ b/app/src/atoms/SoftwareKeyboard/NormalKeyboard/__tests__/NormalKeyboard.test.tsx @@ -87,7 +87,7 @@ describe('SoftwareKeyboard', () => { 'J', 'K', 'L', - 'SHIFT', + 'abc', 'Z', 'X', 'C', diff --git a/app/src/atoms/SoftwareKeyboard/index.css b/app/src/atoms/SoftwareKeyboard/NormalKeyboard/index.css similarity index 88% rename from app/src/atoms/SoftwareKeyboard/index.css rename to app/src/atoms/SoftwareKeyboard/NormalKeyboard/index.css index 1dc0972b582..4b5c8c256e5 100644 --- a/app/src/atoms/SoftwareKeyboard/index.css +++ b/app/src/atoms/SoftwareKeyboard/NormalKeyboard/index.css @@ -13,14 +13,14 @@ .hg-row:not(:last-child) .hg-button:not(:last-child) { margin-right: 8px; - margin-bottom: 8px; + margin-bottom: 3px; } .simple-keyboard.simple-keyboard.oddTheme1 .hg-button { - height: 58px; + height: 48px; } .simple-keyboard .hg-button:active { - color: white; + color: #16212d; background-color: #e3e3e3; } diff --git a/app/src/atoms/SoftwareKeyboard/NormalKeyboard.tsx b/app/src/atoms/SoftwareKeyboard/NormalKeyboard/index.tsx similarity index 95% rename from app/src/atoms/SoftwareKeyboard/NormalKeyboard.tsx rename to app/src/atoms/SoftwareKeyboard/NormalKeyboard/index.tsx index 76b79e99756..dcb02503f00 100644 --- a/app/src/atoms/SoftwareKeyboard/NormalKeyboard.tsx +++ b/app/src/atoms/SoftwareKeyboard/NormalKeyboard/index.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import Keyboard from 'react-simple-keyboard' -import { customDisplay } from './constants' +import { customDisplay } from '../constants' interface NormalKeyboardProps { onChange: (input: string) => void @@ -20,7 +20,7 @@ const customLayout = { shift: [ 'Q W E R T Y U I O P', '{numbers} A S D F G H J K L', - '{shift} Z X C V B N M {backspace}', + '{abc} Z X C V B N M {backspace}', '{space}', ], symbols: [ diff --git a/app/src/atoms/SoftwareKeyboard/Numpad.stories.tsx b/app/src/atoms/SoftwareKeyboard/Numpad/Numpad.stories.tsx similarity index 87% rename from app/src/atoms/SoftwareKeyboard/Numpad.stories.tsx rename to app/src/atoms/SoftwareKeyboard/Numpad/Numpad.stories.tsx index 2e1cd7b094b..576df0a66e4 100644 --- a/app/src/atoms/SoftwareKeyboard/Numpad.stories.tsx +++ b/app/src/atoms/SoftwareKeyboard/Numpad/Numpad.stories.tsx @@ -5,13 +5,14 @@ import { POSITION_ABSOLUTE, SPACING, } from '@opentrons/components' -import { InputField } from '../InputField' -import { Numpad } from './' +import { InputField } from '../../InputField' +import { Numpad } from '.' +import '../../../styles.global.css' import type { Story, Meta } from '@storybook/react' export default { - title: 'Odd/Atoms/SoftwareKeyboard/Numpad', + title: 'ODD/Atoms/SoftwareKeyboard/Numpad', component: Numpad, } as Meta diff --git a/app/src/atoms/SoftwareKeyboard/__tests__/Numpad.test.tsx b/app/src/atoms/SoftwareKeyboard/Numpad/__tests__/Numpad.test.tsx similarity index 97% rename from app/src/atoms/SoftwareKeyboard/__tests__/Numpad.test.tsx rename to app/src/atoms/SoftwareKeyboard/Numpad/__tests__/Numpad.test.tsx index 577ec7ae587..c9c19def25c 100644 --- a/app/src/atoms/SoftwareKeyboard/__tests__/Numpad.test.tsx +++ b/app/src/atoms/SoftwareKeyboard/Numpad/__tests__/Numpad.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { fireEvent } from '@testing-library/react' import { renderHook } from '@testing-library/react-hooks' import { renderWithProviders } from '@opentrons/components' -import { Numpad } from '../' +import { Numpad } from '..' const render = (props: React.ComponentProps) => { return renderWithProviders()[0] diff --git a/app/src/atoms/SoftwareKeyboard/Numpad/index.css b/app/src/atoms/SoftwareKeyboard/Numpad/index.css new file mode 100644 index 00000000000..7d832afeb2f --- /dev/null +++ b/app/src/atoms/SoftwareKeyboard/Numpad/index.css @@ -0,0 +1,7 @@ +/* stylelint-disable */ + +.numpad button.hg-button.hg-button-backspace, +.numpad button.hg-button.hg-button-abc, +.numpad button.hg-button.hg-standardBtn { + flex: 1; +} diff --git a/app/src/atoms/SoftwareKeyboard/Numpad.tsx b/app/src/atoms/SoftwareKeyboard/Numpad/index.tsx similarity index 94% rename from app/src/atoms/SoftwareKeyboard/Numpad.tsx rename to app/src/atoms/SoftwareKeyboard/Numpad/index.tsx index 64ed306f1b3..445ace45dba 100644 --- a/app/src/atoms/SoftwareKeyboard/Numpad.tsx +++ b/app/src/atoms/SoftwareKeyboard/Numpad/index.tsx @@ -22,7 +22,7 @@ export function Numpad({ onChange, keyboardRef }: NumpadProps): JSX.Element { */ (keyboardRef.current = r)} - theme={'hg-theme-default oddTheme1'} + theme={'hg-theme-default oddTheme1 numpad'} onChange={onChange} layoutName="default" display={customDisplay} diff --git a/app/src/atoms/SoftwareKeyboard/index.ts b/app/src/atoms/SoftwareKeyboard/index.ts index dc7b999db2d..93ae28749ac 100644 --- a/app/src/atoms/SoftwareKeyboard/index.ts +++ b/app/src/atoms/SoftwareKeyboard/index.ts @@ -1,3 +1,3 @@ +export { CustomKeyboard } from './CustomKeyboard' export { NormalKeyboard } from './NormalKeyboard' export { Numpad } from './Numpad' -export { CustomKeyboard } from './CustomKeyboard' diff --git a/app/src/atoms/buttons/__tests__/FloatingActionButton.test.tsx b/app/src/atoms/buttons/__tests__/FloatingActionButton.test.tsx index 9975d9ba03b..0c14cec1f55 100644 --- a/app/src/atoms/buttons/__tests__/FloatingActionButton.test.tsx +++ b/app/src/atoms/buttons/__tests__/FloatingActionButton.test.tsx @@ -8,9 +8,12 @@ import { } from '@opentrons/components' import { FloatingActionButton } from '..' +import { i18n } from '../../../i18n' const render = (props: React.ComponentProps) => { - return renderWithProviders()[0] + return renderWithProviders(, { + i18nInstance: i18n, + })[0] } describe('FloatingActionButton', () => { diff --git a/app/src/molecules/GenericWizardTile/index.tsx b/app/src/molecules/GenericWizardTile/index.tsx index 9de8a0e2a42..9e6d15074bd 100644 --- a/app/src/molecules/GenericWizardTile/index.tsx +++ b/app/src/molecules/GenericWizardTile/index.tsx @@ -158,7 +158,11 @@ export function GenericWizardTile(props: GenericWizardTileProps): JSX.Element { {getHelp != null ? : null} {proceed != null && proceedButton == null ? ( isOnDevice ? ( - + ) : ( { @@ -26,6 +27,7 @@ export const LegacyModal = (props: LegacyModalProps): JSX.Element => { title, childrenPadding = `${SPACING.spacing16} ${SPACING.spacing24} ${SPACING.spacing24}`, children, + footer, ...styleProps } = props @@ -67,6 +69,7 @@ export const LegacyModal = (props: LegacyModalProps): JSX.Element => { // center within viewport aside from nav marginLeft="7.125rem" {...props} + footer={footer} > {children} diff --git a/app/src/molecules/Modal/SmallModalChildren.tsx b/app/src/molecules/Modal/SmallModalChildren.tsx index 3ef94f59922..39ca1daaee1 100644 --- a/app/src/molecules/Modal/SmallModalChildren.tsx +++ b/app/src/molecules/Modal/SmallModalChildren.tsx @@ -28,6 +28,7 @@ export function SmallModalChildren( flexDirection={DIRECTION_COLUMN} gridGap={SPACING.spacing8} width="100%" + whiteSpace="break-spaces" > const render = (props: React.ComponentProps) => { return renderWithProviders(, { i18nInstance: i18n, + initialState: { robotsByName: 'test' }, })[0] } @@ -15,11 +21,13 @@ describe('Module Update Banner', () => { beforeEach(() => { props = { + robotName: 'testRobot', updateType: 'calibration', setShowBanner: jest.fn(), handleUpdateClick: jest.fn(), serialNumber: 'test_number', } + when(mockUseIsOT3).calledWith(props.robotName).mockReturnValue(true) }) it('enables the updateType and serialNumber to be used as the test ID', () => { const { getByTestId } = render(props) @@ -94,4 +102,9 @@ describe('Module Update Banner', () => { const { queryByText } = render(props) expect(queryByText('Update now')).toBeInTheDocument() }) + it('should not render a calibrate update link if the robot is an OT-2', () => { + when(mockUseIsOT3).calledWith(props.robotName).mockReturnValue(false) + const { queryByText } = render(props) + expect(queryByText('Calibrate now')).not.toBeInTheDocument() + }) }) diff --git a/app/src/molecules/UpdateBanner/index.tsx b/app/src/molecules/UpdateBanner/index.tsx index a759d24136e..bfc34ddda0c 100644 --- a/app/src/molecules/UpdateBanner/index.tsx +++ b/app/src/molecules/UpdateBanner/index.tsx @@ -11,8 +11,10 @@ import { } from '@opentrons/components' import { Banner } from '../../atoms/Banner' +import { useIsOT3 } from '../../organisms/Devices/hooks' interface UpdateBannerProps { + robotName: string updateType: 'calibration' | 'firmware' | 'firmware_important' setShowBanner: (arg0: boolean) => void handleUpdateClick: () => void @@ -22,13 +24,14 @@ interface UpdateBannerProps { } export const UpdateBanner = ({ + robotName, updateType, serialNumber, setShowBanner, handleUpdateClick, attachPipetteRequired, updatePipetteFWRequired, -}: UpdateBannerProps): JSX.Element => { +}: UpdateBannerProps): JSX.Element | null => { const { t } = useTranslation('device_details') let bannerType: 'error' | 'warning' @@ -55,6 +58,9 @@ export const UpdateBanner = ({ hyperlinkText = t('update_now') } + const isOT3 = useIsOT3(robotName) + if (!isOT3 && updateType === 'calibration') return null + return ( void) | null totalSteps?: number currentStep?: number | null exitDisabled?: boolean diff --git a/app/src/molecules/WizardRequiredEquipmentList/equipmentImages.ts b/app/src/molecules/WizardRequiredEquipmentList/equipmentImages.ts index 12628a80222..34106f47255 100644 --- a/app/src/molecules/WizardRequiredEquipmentList/equipmentImages.ts +++ b/app/src/molecules/WizardRequiredEquipmentList/equipmentImages.ts @@ -3,6 +3,9 @@ export const equipmentImages = { calibration_pin: require('../../assets/images/gripper_cal_pin.png'), calibration_probe: require('../../assets/images/change-pip/calibration_probe.png'), + calibration_adapter_heatershaker: require('../../assets/images/heatershaker_calibration_adapter.png'), + calibration_adapter_temperature: require('../../assets/images/temperature_module_calibration_adapter.png'), + calibration_adapter_thermocycler: require('../../assets/images/thermocycler_calibration_adapter.png'), t10_torx_screwdriver: require('../../assets/images/t10_torx_screwdriver.png'), hex_screwdriver: require('../../assets/images/change-pip/hex_screwdriver.png'), flex_pipette: require('../../assets/images/change-pip/single_mount_pipettes.png'), diff --git a/app/src/molecules/WizardRequiredEquipmentList/index.tsx b/app/src/molecules/WizardRequiredEquipmentList/index.tsx index 467fd749b66..35b87750b4b 100644 --- a/app/src/molecules/WizardRequiredEquipmentList/index.tsx +++ b/app/src/molecules/WizardRequiredEquipmentList/index.tsx @@ -49,7 +49,6 @@ export function WizardRequiredEquipmentList( > {t('you_will_need')} - ) : ( <> - + {t('you_will_need')} - - {equipmentList.map(requiredEquipmentProps => ( + {equipmentList.length > 1 ? : null} + {equipmentList.map((requiredEquipmentProps, index) => ( ))} {footer != null ? ( @@ -106,12 +110,13 @@ interface RequiredEquipmentCardProps { loadName: string displayName: string subtitle?: string + bottomDivider?: boolean } function RequiredEquipmentCard(props: RequiredEquipmentCardProps): JSX.Element { - const { loadName, displayName, subtitle } = props + const { loadName, displayName, subtitle, bottomDivider = true } = props - let imageSrc: string = labwareImages.generic_custom_tiprack + let imageSrc: string | null = null if (loadName in labwareImages) { imageSrc = labwareImages[loadName as keyof typeof labwareImages] } else if (loadName in equipmentImages) { @@ -125,24 +130,26 @@ function RequiredEquipmentCard(props: RequiredEquipmentCardProps): JSX.Element { alignItems={ALIGN_CENTER} width="100%" > - - {displayName} - + {imageSrc != null ? ( + + {displayName} + + ) : null} - + {bottomDivider ? : null} ) } diff --git a/app/src/organisms/Alerts/__tests__/Alerts.test.tsx b/app/src/organisms/Alerts/__tests__/Alerts.test.tsx index 1a3d378ef99..64241dd347c 100644 --- a/app/src/organisms/Alerts/__tests__/Alerts.test.tsx +++ b/app/src/organisms/Alerts/__tests__/Alerts.test.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { mountWithStore } from '@opentrons/components' import * as AppAlerts from '../../../redux/alerts' +import { TOAST_ANIMATION_DURATION } from '../../../atoms/Toast' import { Alerts } from '..' import { AnalyticsSettingsModal } from '../../AnalyticsSettingsModal' import { U2EDriverOutdatedAlert } from '../U2EDriverOutdatedAlert' @@ -73,19 +74,51 @@ describe('app-wide Alerts component', () => { AppAlerts.alertDismissed(AppAlerts.ALERT_U2E_DRIVER_OUTDATED, true) ) }) + it('should render a software update toast if a software update is available that is dismissed when clicked', () => { + const { wrapper, refresh } = render() + expect(wrapper.exists(UpdateAppModal)).toBe(false) + + stubActiveAlerts([AppAlerts.ALERT_APP_UPDATE_AVAILABLE]) + refresh() - it('should render an UpdateAppModal if appUpdateAvailable alert is triggered', () => { + setTimeout(() => { + expect(wrapper.contains('View Update')).toBe(true) + wrapper.findWhere(node => node.text() === 'View Update').simulate('click') + setTimeout( + () => expect(wrapper.contains('View Update')).toBe(false), + TOAST_ANIMATION_DURATION + ) + }, TOAST_ANIMATION_DURATION) + }) + it('should render an UpdateAppModal if the app update toast is clicked', () => { const { wrapper, store, refresh } = render() expect(wrapper.exists(UpdateAppModal)).toBe(false) stubActiveAlerts([AppAlerts.ALERT_APP_UPDATE_AVAILABLE]) refresh() - expect(wrapper.exists(UpdateAppModal)).toBe(true) - wrapper.find(UpdateAppModal).invoke('dismissAlert')?.(true) + setTimeout(() => { + expect(wrapper.contains('View Update')).toBe(true) + wrapper.findWhere(node => node.text() === 'View Update').simulate('click') - expect(store.dispatch).toHaveBeenCalledWith( - AppAlerts.alertDismissed(AppAlerts.ALERT_APP_UPDATE_AVAILABLE, true) - ) + expect(wrapper.exists(UpdateAppModal)).toBe(true) + + wrapper.find(UpdateAppModal).invoke('closeModal')?.(true) + + expect(store.dispatch).toHaveBeenCalledWith( + AppAlerts.alertDismissed(AppAlerts.ALERT_APP_UPDATE_AVAILABLE, true) + ) + }, TOAST_ANIMATION_DURATION) + }) + it('should render a success toast if the software update was successful', () => { + const { wrapper } = render() + const updatedState = { + hasJustUpdated: true, + } + + wrapper.setProps({ initialState: updatedState }) + setTimeout(() => { + expect(wrapper.contains('successfully updated')).toBe(true) + }, TOAST_ANIMATION_DURATION) }) }) diff --git a/app/src/organisms/Alerts/index.tsx b/app/src/organisms/Alerts/index.tsx index 30c1f252295..7cc8101f8cc 100644 --- a/app/src/organisms/Alerts/index.tsx +++ b/app/src/organisms/Alerts/index.tsx @@ -1,8 +1,12 @@ import * as React from 'react' import { useSelector, useDispatch } from 'react-redux' +import { useTranslation } from 'react-i18next' import head from 'lodash/head' import * as AppAlerts from '../../redux/alerts' +import { getHasJustUpdated, toggleConfigValue } from '../../redux/config' +import { SUCCESS_TOAST, WARNING_TOAST } from '../../atoms/Toast' +import { useToaster } from '../ToasterOven' import { AnalyticsSettingsModal } from '../AnalyticsSettingsModal' import { UpdateAppModal } from '../UpdateAppModal' import { U2EDriverOutdatedAlert } from './U2EDriverOutdatedAlert' @@ -12,17 +16,56 @@ import type { AlertId } from '../../redux/alerts/types' export function Alerts(): JSX.Element { const dispatch = useDispatch() + const [showUpdateModal, setShowUpdateModal] = React.useState(false) + const { t } = useTranslation('app_settings') + const { makeToast, eatToast } = useToaster() + const toastRef = React.useRef(null) // TODO(mc, 2020-05-07): move head logic to selector with alert priorities - const activeAlert: AlertId | null = useSelector((state: State) => { + const activeAlertId: AlertId | null = useSelector((state: State) => { return head(AppAlerts.getActiveAlerts(state)) ?? null }) const dismissAlert = (remember?: boolean): void => { - if (activeAlert != null) { - dispatch(AppAlerts.alertDismissed(activeAlert, remember)) + if (activeAlertId != null) { + dispatch(AppAlerts.alertDismissed(activeAlertId, remember)) } } + const isAppUpdateIgnored = useSelector((state: State) => { + return AppAlerts.getAlertIsPermanentlyIgnored( + state, + AppAlerts.ALERT_APP_UPDATE_AVAILABLE + ) + }) + + const hasJustUpdated = useSelector(getHasJustUpdated) + + // Only run this hook on app startup + React.useEffect(() => { + if (hasJustUpdated) { + makeToast(t('opentrons_app_successfully_updated'), SUCCESS_TOAST, { + closeButton: true, + disableTimeout: true, + }) + dispatch(toggleConfigValue('update.hasJustUpdated')) + } + }, []) + + React.useEffect(() => { + if (activeAlertId === AppAlerts.ALERT_APP_UPDATE_AVAILABLE) + toastRef.current = makeToast( + t('opentrons_app_update_available_variation'), + WARNING_TOAST, + { + closeButton: true, + disableTimeout: true, + linkText: t('view_update'), + onLinkClick: () => setShowUpdateModal(true), + } + ) + if (isAppUpdateIgnored && toastRef.current != null) + eatToast(toastRef.current) + }, [activeAlertId]) return ( <> @@ -30,10 +73,11 @@ export function Alerts(): JSX.Element { own render; move its logic into `state.alerts` */} - {activeAlert === AppAlerts.ALERT_U2E_DRIVER_OUTDATED ? ( + {activeAlertId === AppAlerts.ALERT_U2E_DRIVER_OUTDATED ? ( - ) : activeAlert === AppAlerts.ALERT_APP_UPDATE_AVAILABLE ? ( - + ) : null} + {showUpdateModal ? ( + setShowUpdateModal(false)} /> ) : null} ) diff --git a/app/src/organisms/ChooseProtocolSlideout/index.tsx b/app/src/organisms/ChooseProtocolSlideout/index.tsx index dd39fcc664a..3e7b7b1fdfe 100644 --- a/app/src/organisms/ChooseProtocolSlideout/index.tsx +++ b/app/src/organisms/ChooseProtocolSlideout/index.tsx @@ -205,56 +205,61 @@ function StoredProtocolList(props: StoredProtocolListProps): JSX.Element { selectedProtocol != null && storedProtocol.protocolKey === selectedProtocol.protocolKey return ( - - handleSelectProtocol(storedProtocol)} + <> + - - - + handleSelectProtocol(storedProtocol)} + > + + + + + + {storedProtocol.mostRecentAnalysis?.metadata + ?.protocolName ?? + first(storedProtocol.srcFileNames) ?? + storedProtocol.protocolKey} + - - {storedProtocol.mostRecentAnalysis?.metadata?.protocolName ?? - first(storedProtocol.srcFileNames) ?? - storedProtocol.protocolKey} - - - {runCreationError != null && isSelected ? ( - <> - - - - ) : null} - + {runCreationError != null && isSelected ? ( + <> + + + + ) : null} + + {runCreationError != null && isSelected ? ( {runCreationErrorCode === 409 ? ( @@ -278,7 +283,7 @@ function StoredProtocolList(props: StoredProtocolListProps): JSX.Element { )} ) : null} - + ) })} @@ -290,7 +295,12 @@ function StoredProtocolList(props: StoredProtocolListProps): JSX.Element { width="100%" minHeight="11rem" padding={SPACING.spacing16} - css={BORDERS.cardOutlineBorder} + css={css` + ${BORDERS.cardOutlineBorder} + &:hover { + border-color: ${COLORS.medGreyEnabled}; + } + `} > {!isScanning && healthyReachableRobots.length === 0 ? ( - { - if (!isCreatingRun) { - resetCreateRun?.() - setSelectedRobot(robot) + <> + + { + if (!isCreatingRun) { + resetCreateRun?.() + setSelectedRobot(robot) + } + }} + isError={runCreationError != null} + isSelected={isSelected} + isOnDifferentSoftwareVersion={ + isSelectedRobotOnWrongVersionOfSoftware } - }} - isError={runCreationError != null} - isSelected={isSelected} - isOnDifferentSoftwareVersion={ - isSelectedRobotOnWrongVersionOfSoftware - } - showIdleOnly={showIdleOnly} - registerRobotBusyStatus={registerRobotBusyStatus} - /> + showIdleOnly={showIdleOnly} + registerRobotBusyStatus={registerRobotBusyStatus} + /> + {runCreationError != null && isSelected && ( {runCreationErrorCode === 409 ? ( @@ -266,7 +273,7 @@ export function ChooseRobotSlideout( )} )} - + ) }) )} diff --git a/app/src/organisms/CommandText/MoveLabwareCommandText.tsx b/app/src/organisms/CommandText/MoveLabwareCommandText.tsx index a39016f0f50..8f20c8ef1c1 100644 --- a/app/src/organisms/CommandText/MoveLabwareCommandText.tsx +++ b/app/src/organisms/CommandText/MoveLabwareCommandText.tsx @@ -4,8 +4,8 @@ import type { MoveLabwareRunTimeCommand, } from '@opentrons/shared-data/' import { getLabwareName } from './utils' -import { getLoadedLabware } from './utils/accessors' import { getLabwareDisplayLocation } from './utils/getLabwareDisplayLocation' +import { getFinalLabwareLocation } from './utils/getFinalLabwareLocation' interface MoveLabwareCommandTextProps { command: MoveLabwareRunTimeCommand @@ -17,7 +17,12 @@ export function MoveLabwareCommandText( const { t } = useTranslation('protocol_command_text') const { command, robotSideAnalysis } = props const { labwareId, newLocation, strategy } = command.params - const oldLocation = getLoadedLabware(robotSideAnalysis, labwareId)?.location + + const allPreviousCommands = robotSideAnalysis.commands.slice( + 0, + robotSideAnalysis.commands.findIndex(c => c.id === command.id) + ) + const oldLocation = getFinalLabwareLocation(labwareId, allPreviousCommands) const newDisplayLocation = getLabwareDisplayLocation( robotSideAnalysis, newLocation, diff --git a/app/src/organisms/CommandText/PipettingCommandText.tsx b/app/src/organisms/CommandText/PipettingCommandText.tsx index 5249386dfe5..64c55b23f66 100644 --- a/app/src/organisms/CommandText/PipettingCommandText.tsx +++ b/app/src/organisms/CommandText/PipettingCommandText.tsx @@ -1,7 +1,5 @@ import { useTranslation } from 'react-i18next' -import { getLoadedLabware } from './utils/accessors' -import { getLabwareName, getLabwareDisplayLocation } from './utils' -import type { +import { CompletedProtocolAnalysis, AspirateRunTimeCommand, DispenseRunTimeCommand, @@ -9,7 +7,11 @@ import type { MoveToWellRunTimeCommand, DropTipRunTimeCommand, PickUpTipRunTimeCommand, + getLabwareDefURI, } from '@opentrons/shared-data' +import { getLabwareDefinitionsFromCommands } from '../LabwarePositionCheck/utils/labware' +import { getLoadedLabware } from './utils/accessors' +import { getLabwareName, getLabwareDisplayLocation } from './utils' type PipettingRunTimeCommmand = | AspirateRunTimeCommand @@ -32,6 +34,7 @@ export const PipettingCommandText = ({ const { labwareId, wellName } = command.params const labwareLocation = getLoadedLabware(robotSideAnalysis, labwareId) ?.location + const displayLocation = labwareLocation != null ? getLabwareDisplayLocation(robotSideAnalysis, labwareLocation, t) @@ -74,11 +77,23 @@ export const PipettingCommandText = ({ }) } case 'dropTip': { - return t('drop_tip', { - well_name: wellName, - labware: getLabwareName(robotSideAnalysis, labwareId), - labware_location: displayLocation, - }) + const loadedLabware = getLoadedLabware(robotSideAnalysis, labwareId) + const labwareDefinitions = getLabwareDefinitionsFromCommands( + robotSideAnalysis.commands + ) + const labwareDef = labwareDefinitions.find( + lw => getLabwareDefURI(lw) === loadedLabware?.definitionUri + ) + return labwareDef?.parameters.isTiprack + ? t('return_tip', { + well_name: wellName, + labware: getLabwareName(robotSideAnalysis, labwareId), + labware_location: displayLocation, + }) + : t('drop_tip', { + well_name: wellName, + labware: getLabwareName(robotSideAnalysis, labwareId), + }) } case 'pickUpTip': { return t('pickup_tip', { diff --git a/app/src/organisms/CommandText/__tests__/CommandText.test.tsx b/app/src/organisms/CommandText/__tests__/CommandText.test.tsx index 42455079ebb..19473a26dda 100644 --- a/app/src/organisms/CommandText/__tests__/CommandText.test.tsx +++ b/app/src/organisms/CommandText/__tests__/CommandText.test.tsx @@ -10,7 +10,11 @@ import type { LoadLabwareRunTimeCommand, LoadLiquidRunTimeCommand, } from '@opentrons/shared-data/protocol/types/schemaV7/command/setup' -import { LabwareDefinition2, RunTimeCommand } from '@opentrons/shared-data' +import { + LabwareDefinition2, + RunTimeCommand, + DropTipRunTimeCommand, +} from '@opentrons/shared-data' describe('CommandText', () => { it('renders correct text for aspirate', () => { @@ -107,6 +111,26 @@ describe('CommandText', () => { getByText('Dropping tip in A1 of Fixed Trash') } }) + it('renders correct text for dropTip into a labware', () => { + const { getByText } = renderWithProviders( + , + { i18nInstance: i18n } + )[0] + getByText('Returning tip to A1 of Opentrons 96 Tip Rack 300 µL in Slot 9') + }) it('renders correct text for pickUpTip', () => { const command = mockRobotSideAnalysis.commands.find( c => c.commandType === 'pickUpTip' @@ -875,7 +899,7 @@ describe('CommandText', () => { params: { strategy: 'manualMoveWithPause', labwareId: mockRobotSideAnalysis.labware[3].id, - newLocation: { moduleId: mockRobotSideAnalysis.modules[0].id }, + newLocation: { slotName: 'A3' }, }, id: 'def456', result: { offsetId: 'fake_offset_id' }, @@ -892,7 +916,7 @@ describe('CommandText', () => { } )[0] getByText( - 'Manually move NEST 96 Well Plate 100 µL PCR Full Skirt (1) from Magnetic Module GEN2 in Slot 1 to Magnetic Module GEN2 in Slot 1' + 'Manually move NEST 96 Well Plate 100 µL PCR Full Skirt (1) from Magnetic Module GEN2 in Slot 1 to Slot A3' ) }) it('renders correct text for move labware with gripper off deck', () => { diff --git a/app/src/organisms/CommandText/index.tsx b/app/src/organisms/CommandText/index.tsx index 522e794c12e..6ec6ac4bbd7 100644 --- a/app/src/organisms/CommandText/index.tsx +++ b/app/src/organisms/CommandText/index.tsx @@ -85,18 +85,18 @@ export function CommandText(props: Props): JSX.Element | null { ) return ( - + {t('tc_starting_profile', { repetitions: Object.keys(steps).length, })} - +
    {steps.map((step: string, index: number) => (
  • {step}
  • ))}
-
+
) } diff --git a/app/src/organisms/CommandText/utils/__tests__/getFinalLabwareLocation.test.ts b/app/src/organisms/CommandText/utils/__tests__/getFinalLabwareLocation.test.ts new file mode 100644 index 00000000000..b592c14263a --- /dev/null +++ b/app/src/organisms/CommandText/utils/__tests__/getFinalLabwareLocation.test.ts @@ -0,0 +1,129 @@ +import fixture_tiprack_10_ul from '@opentrons/shared-data/labware/fixtures/2/fixture_tiprack_10_ul.json' +import { getFinalLabwareLocation } from '../getFinalLabwareLocation' +import type { LabwareDefinition2 } from '@opentrons/shared-data' + +describe('getFinalLabwareLocation', () => { + it('calculates labware location after only load_labware', () => { + const labwareId = 'fakeLabwareId' + const location = { slotName: 'C3' } + expect( + getFinalLabwareLocation(labwareId, [ + { + id: 'fakeId1', + commandType: 'loadLabware', + params: { + location, + loadName: 'fakeLoadname', + namespace: 'opentrons', + version: 1, + }, + result: { + labwareId, + definition: fixture_tiprack_10_ul as LabwareDefinition2, + offset: { x: 1, y: 2, z: 3 }, + }, + status: 'succeeded', + createdAt: 'fake_timestamp', + startedAt: 'fake_timestamp', + completedAt: 'fake_timestamp', + }, + ]) + ).toBe(location) + }) + it('calculates labware location after only load_labware and move_labware', () => { + const labwareId = 'fakeLabwareId' + const initialLocation = { slotName: 'C3' } + const finalLocation = { slotName: 'D1' } + expect( + getFinalLabwareLocation(labwareId, [ + { + id: 'fakeId1', + commandType: 'loadLabware', + params: { + location: initialLocation, + loadName: 'fakeLoadname', + namespace: 'opentrons', + version: 1, + }, + result: { + labwareId, + definition: fixture_tiprack_10_ul as LabwareDefinition2, + offset: { x: 1, y: 2, z: 3 }, + }, + status: 'succeeded', + createdAt: 'fake_timestamp', + startedAt: 'fake_timestamp', + completedAt: 'fake_timestamp', + }, + { + id: 'fakeId2', + commandType: 'moveLabware', + params: { + labwareId, + newLocation: finalLocation, + strategy: 'usingGripper', + }, + status: 'succeeded', + createdAt: 'fake_timestamp', + startedAt: 'fake_timestamp', + completedAt: 'fake_timestamp', + }, + ]) + ).toBe(finalLocation) + }) + it('calculates labware location after multiple moves', () => { + const labwareId = 'fakeLabwareId' + const initialLocation = { slotName: 'C3' } + const secondLocation = { slotName: 'D1' } + const finalLocation = { slotName: 'A2' } + expect( + getFinalLabwareLocation(labwareId, [ + { + id: 'fakeId1', + commandType: 'loadLabware', + params: { + location: initialLocation, + loadName: 'fakeLoadname', + namespace: 'opentrons', + version: 1, + }, + result: { + labwareId, + definition: fixture_tiprack_10_ul as LabwareDefinition2, + offset: { x: 1, y: 2, z: 3 }, + }, + status: 'succeeded', + createdAt: 'fake_timestamp', + startedAt: 'fake_timestamp', + completedAt: 'fake_timestamp', + }, + { + id: 'fakeId2', + commandType: 'moveLabware', + params: { + labwareId, + newLocation: secondLocation, + strategy: 'usingGripper', + }, + status: 'succeeded', + createdAt: 'fake_timestamp', + startedAt: 'fake_timestamp', + completedAt: 'fake_timestamp', + }, + { + id: 'fakeId3', + commandType: 'moveLabware', + params: { + labwareId, + newLocation: finalLocation, + strategy: 'usingGripper', + }, + status: 'succeeded', + createdAt: 'fake_timestamp', + startedAt: 'fake_timestamp', + completedAt: 'fake_timestamp', + }, + ]) + ).toBe(finalLocation) + }) +}) diff --git a/app/src/organisms/CommandText/utils/getFinalLabwareLocation.ts b/app/src/organisms/CommandText/utils/getFinalLabwareLocation.ts new file mode 100644 index 00000000000..80cd4e26a4e --- /dev/null +++ b/app/src/organisms/CommandText/utils/getFinalLabwareLocation.ts @@ -0,0 +1,25 @@ +import type { LabwareLocation, RunTimeCommand } from '@opentrons/shared-data' + +/** + * given a list of commands and a labwareId, calculate the resulting location + * of the corresponding labware after all given commands are executed + * @param labwareId target labware + * @param commands list of commands to search within + * @returns LabwareLocation object of the resulting location of the target labware after all commands execute + */ +export function getFinalLabwareLocation( + labwareId: string, + commands: RunTimeCommand[] +): LabwareLocation | null { + for (const c of commands.reverse()) { + if (c.commandType === 'loadLabware' && c.result?.labwareId === labwareId) { + return c.params.location + } else if ( + c.commandType === 'moveLabware' && + c.params.labwareId === labwareId + ) { + return c.params.newLocation + } + } + return null +} diff --git a/app/src/organisms/CommandText/utils/getLabwareDisplayLocation.ts b/app/src/organisms/CommandText/utils/getLabwareDisplayLocation.ts index d2a894487f1..3852d4655ca 100644 --- a/app/src/organisms/CommandText/utils/getLabwareDisplayLocation.ts +++ b/app/src/organisms/CommandText/utils/getLabwareDisplayLocation.ts @@ -1,4 +1,6 @@ import { + getLabwareDefURI, + getLabwareDisplayName, getModuleDisplayName, getModuleType, getOccludedSlotCountForModule, @@ -7,6 +9,7 @@ import { } from '@opentrons/shared-data' import { getModuleDisplayLocation } from './getModuleDisplayLocation' import { getModuleModel } from './getModuleModel' +import { getLabwareDefinitionsFromCommands } from '../../LabwarePositionCheck/utils/labware' import type { CompletedProtocolAnalysis } from '@opentrons/shared-data/' import type { TFunction } from 'react-i18next' @@ -33,7 +36,7 @@ export function getLabwareDisplayLocation( location.moduleId ) return isOnDevice - ? `${location.moduleId}, ${slotName}` + ? `${getModuleDisplayName(moduleModel)}, ${slotName}` : t('module_in_slot', { count: getOccludedSlotCountForModule( getModuleType(moduleModel), @@ -43,6 +46,58 @@ export function getLabwareDisplayLocation( slot_name: slotName, }) } + } else if ('labwareId' in location) { + const adapter = robotSideAnalysis.labware.find( + lw => lw.id === location.labwareId + ) + const allDefs = getLabwareDefinitionsFromCommands( + robotSideAnalysis.commands + ) + const adapterDef = allDefs.find( + def => getLabwareDefURI(def) === adapter?.definitionUri + ) + const adapterDisplayName = + adapterDef != null ? getLabwareDisplayName(adapterDef) : '' + + if (adapter == null) { + console.warn('labware is located on an unknown adapter') + return '' + } else if (adapter.location === 'offDeck') { + return t('off_deck') + } else if ('slotName' in adapter.location) { + return t('adapter_in_slot', { + adapter: adapterDisplayName, + slot: adapter.location.slotName, + }) + } else if ('moduleId' in adapter.location) { + const moduleIdUnderAdapter = adapter.location.moduleId + const moduleModel = robotSideAnalysis.modules.find( + module => module.id === moduleIdUnderAdapter + )?.model + if (moduleModel == null) { + console.warn('labware is located on an adapter on an unknown module') + return '' + } + const slotName = getModuleDisplayLocation( + robotSideAnalysis, + adapter.location.moduleId + ) + return t('adapter_in_mod_in_slot', { + count: getOccludedSlotCountForModule( + getModuleType(moduleModel), + robotSideAnalysis.robotType ?? OT2_STANDARD_MODEL + ), + module: getModuleDisplayName(moduleModel), + adapter: adapterDisplayName, + slot: slotName, + }) + } else { + console.warn( + 'display location on adapter could not be established: ', + location + ) + return '' + } } else { console.warn('display location could not be established: ', location) return '' diff --git a/app/src/organisms/Devices/HeaterShakerWizard/AttachAdapter.tsx b/app/src/organisms/Devices/HeaterShakerWizard/AttachAdapter.tsx deleted file mode 100644 index 212cc2a43e8..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/AttachAdapter.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import React from 'react' -import { useTranslation } from 'react-i18next' -import { - COLORS, - Flex, - DIRECTION_COLUMN, - DIRECTION_ROW, - Icon, - TYPOGRAPHY, - SPACING, - SIZE_AUTO, - useHoverTooltip, -} from '@opentrons/components' - -import screwInAdapter from '../../../assets/images/heater_shaker_adapter_screwdriver.png' -import heaterShakerAdapterAlignment from '../../../assets/images/heater_shaker_adapter_alignment.png' -import { TertiaryButton } from '../../../atoms/buttons' -import { Tooltip } from '../../../atoms/Tooltip' -import { StyledText } from '../../../atoms/text' -import { useLatchControls } from '../../ModuleCard/hooks' - -import type { HeaterShakerModule } from '../../../redux/modules/types' - -interface AttachAdapterProps { - module: HeaterShakerModule -} -export function AttachAdapter(props: AttachAdapterProps): JSX.Element { - const { module } = props - const { t } = useTranslation('heater_shaker') - const { toggleLatch, isLatchClosed } = useLatchControls(module) - const [targetProps, tooltipProps] = useHoverTooltip() - const isShaking = module.data.speedStatus !== 'idle' - - return ( - - {t('step_3_of_4_attach_adapter')} - - - {t('3a')} - - - - screw_in_adapter - - - - {t('attach_adapter_to_module')} - - - - - - - - {t('attach_screwdriver_and_screw')} - - - {t('attach_screwdriver_and_screw_explanation')} - - - - - {isLatchClosed - ? t('open_labware_latch') - : t('close_labware_latch')} - - {isShaking ? ( - - {t('cannot_open_latch')} - - ) : null} - - - - - - {t('3b')} - - - - heater_shaker_adapter_alignment - - - {t('check_alignment')} - - {t('a_properly_attached_adapter')} - - - - - - - {t('3c')} - - - {t('check_alignment_instructions')} - - - - ) -} diff --git a/app/src/organisms/Devices/HeaterShakerWizard/AttachModule.tsx b/app/src/organisms/Devices/HeaterShakerWizard/AttachModule.tsx deleted file mode 100644 index 3c1c2dd2f97..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/AttachModule.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import React from 'react' -import { Trans, useTranslation } from 'react-i18next' -import { - COLORS, - Flex, - DIRECTION_COLUMN, - DIRECTION_ROW, - Icon, - TYPOGRAPHY, - SPACING, - Box, - RobotWorkSpace, - Module, -} from '@opentrons/components' -import { - getModuleDef2, - inferModuleOrientationFromXCoordinate, -} from '@opentrons/shared-data' -import { StyledText } from '../../../atoms/text' -import attachHeaterShakerModule from '../../../assets/images/heater_shaker_module_diagram.png' -import standardDeckDef from '@opentrons/shared-data/deck/definitions/3/ot2_standard.json' -import screwdriverOrientedLeft from '../../../assets/images/screwdriver_oriented_left.png' -import { ProtocolModuleInfo } from '../../Devices/ProtocolRun/utils/getProtocolModulesInfo' -interface AttachModuleProps { - moduleFromProtocol?: ProtocolModuleInfo -} - -export function AttachModule(props: AttachModuleProps): JSX.Element { - const { moduleFromProtocol } = props - const { t } = useTranslation('heater_shaker') - - const moduleDef = getModuleDef2('heaterShakerModuleV1') - const DECK_MAP_VIEWBOX = '-80 -20 550 460' - const DECK_LAYER_BLOCKLIST = [ - 'calibrationMarkings', - 'fixedBase', - 'doorStops', - 'metalFrame', - 'removalHandle', - 'removableDeckOutline', - 'screwHoles', - ] - - // ToDo kj 10/10/2022 the current hardcoded sizes will be removed - // when we make this wizard responsible - return ( - - - {t('step_1_of_4_attach_module')} - - - - Attach Module to Deck - - screwdriver_1a - - - - , - block: ( - - ), - }} - /> - , - block: ( - - ), - icon: , - }} - /> - - - - - - - {moduleFromProtocol != null ? ( - - {() => ( - - - - )} - - ) : ( - - )} - - - screwdriver_1b - - - , - block: ( - - ), - }} - /> - , - block: ( - - ), - }} - /> - , - block: ( - - ), - icon: , - }} - /> - - - - - - {t('attach_module_check_attachment')} - - - - ) -} - -interface AttachedModuleItemProps { - step: string - children?: React.ReactNode -} - -function AttachedModuleItem(props: AttachedModuleItemProps): JSX.Element { - const { step } = props - return ( - - - {step} - - - {props.children} - - - ) -} diff --git a/app/src/organisms/Devices/HeaterShakerWizard/Introduction.tsx b/app/src/organisms/Devices/HeaterShakerWizard/Introduction.tsx deleted file mode 100644 index 79bd302324e..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/Introduction.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import React from 'react' -import { useTranslation } from 'react-i18next' -import { css } from 'styled-components' -import { - Flex, - DIRECTION_COLUMN, - TYPOGRAPHY, - COLORS, - JUSTIFY_CENTER, - DIRECTION_ROW, - LabwareRender, - SPACING, - ALIGN_CENTER, - BORDERS, - RobotWorkSpace, -} from '@opentrons/components' -import { getModuleDisplayName } from '@opentrons/shared-data' - -import heaterShaker from '../../../assets/images/heater_shaker_empty.png' -import flatBottom from '../../../assets/images/flatbottom_thermal_adapter.png' -import deepwell from '../../../assets/images/deepwell_thermal_adapter.png' -import pcr from '../../../assets/images/pcr_thermal_adapter.png' -import universal from '../../../assets/images/universal_thermal_adapter.png' -import screwdriver from '../../../assets/images/t10_torx_screwdriver.png' -import { StyledText } from '../../../atoms/text' - -import type { - LabwareDefinition2, - ModuleModel, - ThermalAdapterName, -} from '@opentrons/shared-data' - -const VIEW_BOX = '-20 -10 160 100' - -interface IntroContainerProps { - text: string - image?: JSX.Element - subtext?: string -} - -const IntroItem = (props: IntroContainerProps): JSX.Element => { - let multiText: JSX.Element =
- const leftPadding = props.image != null ? SPACING.spacing24 : SPACING.spacing8 - - if (props.subtext != null) { - multiText = ( - - - {props.text} - - - {props.subtext} - - - ) - } else { - multiText = ( - - {props.text} - - ) - } - return ( - - {props.image != null ? ( - <> - - {props.image} - - {multiText} - - ) : ( - {multiText} - )} - - ) -} -interface IntroductionProps { - labwareDefinition: LabwareDefinition2 | null - thermalAdapterName: ThermalAdapterName | null - moduleModel: ModuleModel -} - -const THERMAL_ADAPTER_TRANSFORM = css` - transform: scale(1.4); - transform-origin: 90% 50%; -` - -export function Introduction(props: IntroductionProps): JSX.Element { - const { labwareDefinition, thermalAdapterName } = props - const { t } = useTranslation('heater_shaker') - - let adapterImage: string = '' - switch (thermalAdapterName) { - case 'PCR Adapter': - adapterImage = pcr - break - case 'Universal Flat Adapter': - adapterImage = universal - break - case 'Deep Well Adapter': - adapterImage = deepwell - break - case '96 Flat Bottom Adapter': - adapterImage = flatBottom - break - } - - return ( - - - {t('use_this_heater_shaker_guide')} - - - - - {t('you_will_need')} - - - - - {`${String(thermalAdapterName)}`} - - ) : undefined - } - /> - - - - - {() => { - return ( - - - - ) - }} - - - ) : undefined - } - /> - - - } - text={getModuleDisplayName(props.moduleModel)} - /> - - - } - text={t('t10_torx_screwdriver', { name: 'T10 Torx' })} - subtext={t('about_screwdriver')} - /> - - - - ) -} diff --git a/app/src/organisms/Devices/HeaterShakerWizard/KeyParts.tsx b/app/src/organisms/Devices/HeaterShakerWizard/KeyParts.tsx deleted file mode 100644 index 295448710b3..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/KeyParts.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import React from 'react' -import { Trans, useTranslation } from 'react-i18next' -import { css } from 'styled-components' -import { - ALIGN_FLEX_START, - DIRECTION_COLUMN, - DIRECTION_ROW, - Flex, - JUSTIFY_SPACE_BETWEEN, - SPACING, - TYPOGRAPHY, -} from '@opentrons/components' -import { StyledText } from '../../../atoms/text' -import HeaterShakerKeyParts from '../../../assets/images/heater_shaker-key-parts.png' -import HeaterShakerDeckLock from '../../../assets/videos/heater-shaker-setup/HS_Deck_Lock_Anim.webm' - -export function KeyParts(): JSX.Element { - const { t } = useTranslation('heater_shaker') - return ( - <> - - {t('heater_shaker_key_parts')} - - - , - }} - /> - - - Heater Shaker Key Parts - - - , - block: ( - - ), - }} - /> - , - block: ( - - ), - }} - /> - - - - - ) -} diff --git a/app/src/organisms/Devices/HeaterShakerWizard/PowerOn.tsx b/app/src/organisms/Devices/HeaterShakerWizard/PowerOn.tsx deleted file mode 100644 index f0637a14e2b..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/PowerOn.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from 'react' -import { Trans, useTranslation } from 'react-i18next' -import { - getModuleDef2, - inferModuleOrientationFromXCoordinate, -} from '@opentrons/shared-data' -import { - DIRECTION_COLUMN, - Flex, - Module, - RobotWorkSpace, - SPACING, - TYPOGRAPHY, -} from '@opentrons/components' -import { StyledText } from '../../../atoms/text' -import { ModuleInfo } from '../ModuleInfo' - -import type { HeaterShakerModule } from '../../../redux/modules/types' - -const VIEW_BOX = '-150 -38 440 128' -interface PowerOnProps { - attachedModule: HeaterShakerModule | null -} - -export function PowerOn(props: PowerOnProps): JSX.Element { - const { t } = useTranslation('heater_shaker') - const moduleDef = getModuleDef2('heaterShakerModuleV1') - - return ( - <> - - - ), - block: , - }} - /> - - - {() => ( - - - - - - )} - - - ) -} diff --git a/app/src/organisms/Devices/HeaterShakerWizard/TestShake.tsx b/app/src/organisms/Devices/HeaterShakerWizard/TestShake.tsx deleted file mode 100644 index 90f0096a9c6..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/TestShake.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import React from 'react' -import { Trans, useTranslation } from 'react-i18next' -import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' -import { - ALIGN_CENTER, - ALIGN_FLEX_START, - COLORS, - DIRECTION_COLUMN, - DIRECTION_ROW, - Flex, - Icon, - SIZE_AUTO, - SPACING, - TYPOGRAPHY, - useHoverTooltip, -} from '@opentrons/components' -import { - RPM, - HS_RPM_MAX, - HS_RPM_MIN, - CreateCommand, -} from '@opentrons/shared-data' -import { TertiaryButton } from '../../../atoms/buttons' -import { Tooltip } from '../../../atoms/Tooltip' -import { StyledText } from '../../../atoms/text' -import { Divider } from '../../../atoms/structure' -import { InputField } from '../../../atoms/InputField' -import { Collapsible } from '../../ModuleCard/Collapsible' -import { useLatchControls } from '../../ModuleCard/hooks' -import { HeaterShakerModuleCard } from './HeaterShakerModuleCard' - -import type { HeaterShakerModule } from '../../../redux/modules/types' -import type { - HeaterShakerSetAndWaitForShakeSpeedCreateCommand, - HeaterShakerDeactivateShakerCreateCommand, - HeaterShakerCloseLatchCreateCommand, -} from '@opentrons/shared-data/protocol/types/schemaV7/command/module' -import type { ProtocolModuleInfo } from '../../Devices/ProtocolRun/utils/getProtocolModulesInfo' - -interface TestShakeProps { - module: HeaterShakerModule - setCurrentPage: React.Dispatch> - moduleFromProtocol?: ProtocolModuleInfo -} - -export function TestShake(props: TestShakeProps): JSX.Element { - const { module, setCurrentPage, moduleFromProtocol } = props - const { t } = useTranslation(['heater_shaker', 'device_details']) - const { createLiveCommand } = useCreateLiveCommandMutation() - const [isExpanded, setExpanded] = React.useState(false) - const [shakeValue, setShakeValue] = React.useState(null) - const [targetProps, tooltipProps] = useHoverTooltip() - const { toggleLatch, isLatchClosed } = useLatchControls(module) - const isShaking = module.data.speedStatus !== 'idle' - - const closeLatchCommand: HeaterShakerCloseLatchCreateCommand = { - commandType: 'heaterShaker/closeLabwareLatch', - params: { - moduleId: module.id, - }, - } - - const setShakeCommand: HeaterShakerSetAndWaitForShakeSpeedCreateCommand = { - commandType: 'heaterShaker/setAndWaitForShakeSpeed', - params: { - moduleId: module.id, - rpm: shakeValue !== null ? shakeValue : 0, - }, - } - - const stopShakeCommand: HeaterShakerDeactivateShakerCreateCommand = { - commandType: 'heaterShaker/deactivateShaker', - params: { - moduleId: module.id, - }, - } - - const sendCommands = async (): Promise => { - const commands: CreateCommand[] = isShaking - ? [stopShakeCommand] - : [closeLatchCommand, setShakeCommand] - - for (const command of commands) { - // await each promise to make sure the server receives requests in the right order - await createLiveCommand({ - command, - }).catch((e: Error) => { - console.error( - `error setting module status with command type ${String( - command.commandType - )}: ${e.message}` - ) - }) - } - - setShakeValue(null) - } - - const errorMessage = - shakeValue != null && (shakeValue < HS_RPM_MIN || shakeValue > HS_RPM_MAX) - ? t('device_details:input_out_of_range') - : null - - return ( - - - {t('step_4_of_4')} - - - - - - - - , - block: ( - - ), - }} - /> - - - - - - - {isLatchClosed ? t('open_labware_latch') : t('close_labware_latch')} - - - - - - {t('set_shake_speed')} - - setShakeValue(e.target.valueAsNumber)} - type="number" - caption={t('min_max_rpm', { - min: HS_RPM_MIN, - max: HS_RPM_MAX, - })} - error={errorMessage} - disabled={isShaking} - /> - - - {isShaking ? t('stop_shaking') : t('start_shaking')} - - {!isLatchClosed ? ( - {t('cannot_shake')} - ) : null} - - - - setExpanded(!isExpanded)} - > - - {t('troubleshoot_step_1')} - setCurrentPage(2)} - > - {t('go_to_step_1')} - - - - {t('troubleshoot_step_3')} - setCurrentPage(4)} - > - {t('go_to_step_3')} - - - - - - ) -} diff --git a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/AttachAdapter.test.tsx b/app/src/organisms/Devices/HeaterShakerWizard/__tests__/AttachAdapter.test.tsx deleted file mode 100644 index cfb717edd31..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/AttachAdapter.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import * as React from 'react' -import { fireEvent } from '@testing-library/react' -import { renderWithProviders } from '@opentrons/components' -import { i18n } from '../../../../i18n' -import { mockHeaterShaker } from '../../../../redux/modules/__fixtures__' -import { AttachAdapter } from '../AttachAdapter' -import { useLatchControls } from '../../../ModuleCard/hooks' -import type { HeaterShakerModule } from '../../../../redux/modules/types' - -jest.mock('../../../ModuleCard/hooks') - -const mockUseLatchControls = useLatchControls as jest.MockedFunction< - typeof useLatchControls -> - -const render = (props: React.ComponentProps) => { - return renderWithProviders(, { - i18nInstance: i18n, - })[0] -} - -const mockHeaterShakeShaking: HeaterShakerModule = { - id: 'heatershaker_id', - moduleModel: 'heaterShakerModuleV1', - moduleType: 'heaterShakerModuleType', - serialNumber: 'jkl123', - hardwareRevision: 'heatershaker_v4.0', - firmwareVersion: 'v2.0.0', - hasAvailableUpdate: true, - data: { - labwareLatchStatus: 'idle_closed', - speedStatus: 'speeding up', - temperatureStatus: 'idle', - currentSpeed: 300, - currentTemperature: null, - targetSpeed: 800, - targetTemperature: null, - errorDetails: null, - status: 'idle', - }, - usbPort: { - path: '/dev/ot_module_heatershaker0', - port: 1, - hub: false, - portGroup: 'unknown', - }, -} - -describe('AttachAdapter', () => { - let props: React.ComponentProps - const mockToggleLatch = jest.fn() - beforeEach(() => { - props = { - module: mockHeaterShaker, - } - mockUseLatchControls.mockReturnValue({ - toggleLatch: mockToggleLatch, - isLatchClosed: true, - }) - }) - afterEach(() => { - jest.resetAllMocks() - }) - it('renders all the Attach adapter component text and images', () => { - const { getByText, getByAltText, getByLabelText } = render(props) - - getByText('Step 3 of 4: Attach Thermal Adapter') - getByText('Attach your adapter to the module.') - getByText('Please use T10 Torx Screwdriver and provided screw') - getByText( - 'Using a different screwdriver can strip the screws. Using a different screw than the one provided can damage the module' - ) - getByText('Check alignment.') - getByText('A properly attached adapter will sit evenly on the module.') - getByText('3a') - getByText('Check attachment by rocking the adapter back and forth.') - getByText('3b') - getByText('3c') - getByAltText('heater_shaker_adapter_alignment') - getByAltText('screw_in_adapter') - getByLabelText('information') - }) - it('renders button and clicking on it sends latch command to open', () => { - const { getByRole } = render(props) - const btn = getByRole('button', { name: 'Open Labware Latch' }) - fireEvent.click(btn) - expect(mockToggleLatch).toHaveBeenCalled() - }) - it('renders button and clicking on it sends latch command to close', () => { - mockUseLatchControls.mockReturnValue({ - toggleLatch: mockToggleLatch, - isLatchClosed: false, - }) - const { getByRole } = render(props) - const btn = getByRole('button', { name: 'Close Labware Latch' }) - fireEvent.click(btn) - expect(mockToggleLatch).toHaveBeenCalled() - }) - it('renders button and it is disabled when heater-shaker is shaking', () => { - props = { - module: mockHeaterShakeShaking, - } - const { getByRole } = render(props) - const btn = getByRole('button', { name: 'Open Labware Latch' }) - expect(btn).toBeDisabled() - }) -}) diff --git a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/AttachModule.test.tsx b/app/src/organisms/Devices/HeaterShakerWizard/__tests__/AttachModule.test.tsx deleted file mode 100644 index 7295a436d1c..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/AttachModule.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import * as React from 'react' -import { nestedTextMatcher, renderWithProviders } from '@opentrons/components' -import { i18n } from '../../../../i18n' -import { AttachModule } from '../AttachModule' -import { mockHeaterShaker } from '../../../../redux/modules/__fixtures__' -import heaterShakerCommands from '@opentrons/shared-data/protocol/fixtures/6/heaterShakerCommands.json' -import { ProtocolModuleInfo } from '../../../Devices/ProtocolRun/utils/getProtocolModulesInfo' - -const HEATER_SHAKER_PROTOCOL_MODULE_INFO = { - moduleId: 'heater_shaker_id', - x: 0, - y: 0, - z: 0, - moduleDef: mockHeaterShaker as any, - nestedLabwareDef: heaterShakerCommands.labwareDefinitions['example/plate/1'], - nestedLabwareDisplayName: 'Source Plate', - nestedLabwareId: null, - protocolLoadOrder: 1, - slotName: '1', -} as ProtocolModuleInfo - -const render = (props: React.ComponentProps) => { - return renderWithProviders(, { - i18nInstance: i18n, - })[0] -} - -describe('AttachModule', () => { - let props: React.ComponentProps - beforeEach(() => { - props = { - moduleFromProtocol: HEATER_SHAKER_PROTOCOL_MODULE_INFO, - } - }) - it('renders the correct title', () => { - const { getByText } = render(props) - - getByText('Step 1 of 4: Attach module to deck') - }) - - it('renders the content and images correctly when page is not launched from a protocol', () => { - props = { - moduleFromProtocol: undefined, - } - const { getByText, getByAltText, getByTestId } = render(props) - - getByText( - nestedTextMatcher( - 'Before placing the module on the deck, make sure the anchors are not extended.' - ) - ) - getByText( - nestedTextMatcher( - 'Turn screws counterclockwise to retract the anchors. The screws should not come out of the module.' - ) - ) - getByText( - nestedTextMatcher( - 'Orient your module such that the power and USB ports are facing outward.' - ) - ) - getByText( - nestedTextMatcher( - 'Hold the module flat against the deck and turn screws clockwise to extend the anchors.' - ) - ) - getByText( - nestedTextMatcher( - 'Check attachment by gently pulling up and rocking the module.' - ) - ) - getByText('Place the module in a Slot.') - getByText('1a') - getByText('1b') - getByText('1c') - getByAltText('Attach Module to Deck') - getByAltText('screwdriver_1a') - getByAltText('screwdriver_1b') - getByTestId('HeaterShakerWizard_deckMap') - }) - - it('renders the correct slot number when a protocol with a heater shaker is provided', () => { - const { getByText } = render(props) - - getByText(nestedTextMatcher('Place the module in Slot 1.')) - }) -}) diff --git a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/HeaterShakerWizard.test.tsx b/app/src/organisms/Devices/HeaterShakerWizard/__tests__/HeaterShakerWizard.test.tsx deleted file mode 100644 index 8ad2220c517..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/HeaterShakerWizard.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import * as React from 'react' -import { fireEvent } from '@testing-library/react' -import { renderWithProviders } from '@opentrons/components' -import { MemoryRouter } from 'react-router-dom' -import { i18n } from '../../../../i18n' -import { mockHeaterShaker } from '../../../../redux/modules/__fixtures__' -import heaterShakerCommands from '@opentrons/shared-data/protocol/fixtures/6/heaterShakerCommands.json' -import { HeaterShakerWizard } from '..' -import { Introduction } from '../Introduction' -import { KeyParts } from '../KeyParts' -import { AttachModule } from '../AttachModule' -import { AttachAdapter } from '../AttachAdapter' -import { PowerOn } from '../PowerOn' -import { TestShake } from '../TestShake' - -import type { ProtocolModuleInfo } from '../../../Devices/ProtocolRun/utils/getProtocolModulesInfo' - -jest.mock('../Introduction') -jest.mock('../KeyParts') -jest.mock('../AttachModule') -jest.mock('../AttachAdapter') -jest.mock('../PowerOn') -jest.mock('../TestShake') - -const mockIntroduction = Introduction as jest.MockedFunction< - typeof Introduction -> -const mockKeyParts = KeyParts as jest.MockedFunction -const mockAttachModule = AttachModule as jest.MockedFunction< - typeof AttachModule -> -const mockAttachAdapter = AttachAdapter as jest.MockedFunction< - typeof AttachAdapter -> -const mockPowerOn = PowerOn as jest.MockedFunction -const mockTestShake = TestShake as jest.MockedFunction - -const render = ( - props: React.ComponentProps, - path = '/' -) => { - return renderWithProviders( - - - , - { - i18nInstance: i18n, - } - )[0] -} - -describe('HeaterShakerWizard', () => { - let props: React.ComponentProps - beforeEach(() => { - props = { - onCloseClick: jest.fn(), - attachedModule: mockHeaterShaker, - } - mockIntroduction.mockReturnValue(
Mock Introduction
) - mockKeyParts.mockReturnValue(
Mock Key Parts
) - mockAttachModule.mockReturnValue(
Mock Attach Module
) - mockAttachAdapter.mockReturnValue(
Mock Attach Adapter
) - mockPowerOn.mockReturnValue(
Mock Power On
) - mockTestShake.mockReturnValue(
Mock Test Shake
) - }) - - it('renders the main modal component of the wizard and exit button is clickable', () => { - const { getByText, getByLabelText } = render(props) - getByText(/Attach Heater-Shaker Module/i) - getByText('Mock Introduction') - const close = getByLabelText('close') - fireEvent.click(close) - expect(props.onCloseClick).toHaveBeenCalled() - }) - - it('renders wizard and returns the correct pages when the buttons are clicked', () => { - const { getByText, getByRole } = render(props) - - let button = getByRole('button', { name: 'Continue to attachment guide' }) - fireEvent.click(button) - getByText('Mock Key Parts') - - button = getByRole('button', { name: 'Begin attachment' }) - fireEvent.click(button) - getByText('Mock Attach Module') - - button = getByRole('button', { name: 'Continue to power on module' }) - fireEvent.click(button) - getByText('Mock Power On') - - button = getByRole('button', { name: 'Continue to attach thermal adapter' }) - fireEvent.click(button) - getByText('Mock Attach Adapter') - - button = getByRole('button', { name: 'Continue to test shake' }) - fireEvent.click(button) - getByText('Mock Test Shake') - - getByRole('button', { name: 'Complete' }) - }) - - it('renders wizard and returns the correct pages when the buttons are clicked and protocol is known', () => { - props = { - onCloseClick: jest.fn(), - moduleFromProtocol: { - moduleId: 'heater_shaker_id', - x: 0, - y: 0, - z: 0, - moduleDef: mockHeaterShaker as any, - nestedLabwareDef: - heaterShakerCommands.labwareDefinitions['example/plate/1'], - nestedLabwareDisplayName: null, - nestedLabwareId: null, - protocolLoadOrder: 1, - slotName: '1', - } as ProtocolModuleInfo, - attachedModule: mockHeaterShaker, - } - const { getByText, getByRole } = render(props) - - let button = getByRole('button', { name: 'Continue to attachment guide' }) - fireEvent.click(button) - getByText('Mock Key Parts') - - button = getByRole('button', { name: 'Begin attachment' }) - fireEvent.click(button) - getByText('Mock Attach Module') - - button = getByRole('button', { name: 'Continue to power on module' }) - fireEvent.click(button) - getByText('Mock Power On') - - button = getByRole('button', { name: 'Continue to attach thermal adapter' }) - fireEvent.click(button) - getByText('Mock Attach Adapter') - - button = getByRole('button', { name: 'Continue to test shake' }) - fireEvent.click(button) - getByText('Mock Test Shake') - - getByRole('button', { name: 'Complete' }) - }) - - it('renders power on component and the test shake button is disabled', () => { - props = { - ...props, - attachedModule: null, - } - const { getByText, getByRole } = render(props) - - let button = getByRole('button', { name: 'Continue to attachment guide' }) - fireEvent.click(button) - getByText('Mock Key Parts') - - button = getByRole('button', { name: 'Begin attachment' }) - fireEvent.click(button) - getByText('Mock Attach Module') - - button = getByRole('button', { name: 'Continue to power on module' }) - fireEvent.click(button) - getByText('Mock Power On') - - button = getByRole('button', { name: 'Continue to attach thermal adapter' }) - expect(button).toBeDisabled() - }) -}) diff --git a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/Introduction.test.tsx b/app/src/organisms/Devices/HeaterShakerWizard/__tests__/Introduction.test.tsx deleted file mode 100644 index c9c797b31d7..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/Introduction.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import * as React from 'react' -import { renderWithProviders } from '@opentrons/components' -import { i18n } from '../../../../i18n' -import { mockDefinition } from '../../../../redux/custom-labware/__fixtures__' -import { Introduction } from '../Introduction' -import type { ThermalAdapterName } from '@opentrons/shared-data' - -const render = (props: React.ComponentProps) => { - return renderWithProviders(, { - i18nInstance: i18n, - })[0] -} - -describe('Introduction', () => { - let props: React.ComponentProps - beforeEach(() => { - props = { - labwareDefinition: null, - thermalAdapterName: null, - moduleModel: 'heaterShakerModuleV1', - } - }) - afterEach(() => { - jest.resetAllMocks() - }) - - it('renders correct title and body when protocol has not been uploaded', () => { - const { getByText, getByAltText } = render(props) - - getByText( - 'Use this guide to attach the Heater-Shaker Module to your robot’s deck for secure shaking.' - ) - getByText('You will need:') - getByText('Thermal Adapter + Screw') - getByText('Screw may already be in the center of the module.') - getByText('Labware') - getByText('Heater-Shaker Module GEN1') - getByText('T10 Torx Screwdriver') - getByText( - 'Provided with module. Note: using another screwdriver size can strip the module’s screws.' - ) - getByAltText('heater_shaker_image') - getByAltText('screwdriver_image') - }) - it('renders the correct body when protocol has been uploaded with PCR adapter', () => { - props = { - labwareDefinition: mockDefinition, - thermalAdapterName: 'PCR Adapter' as ThermalAdapterName, - moduleModel: 'heaterShakerModuleV1', - } - - const { getByText, getByAltText } = render(props) - getByText('Mock Definition') - getByText('PCR Adapter + Screw') - getByAltText('PCR Adapter') - }) - it('renders the correct thermal adapter info when name is Universal Flat Adapter', () => { - props = { - labwareDefinition: null, - thermalAdapterName: 'Universal Flat Adapter', - moduleModel: 'heaterShakerModuleV1', - } - - const { getByText, getByAltText } = render(props) - getByText('Universal Flat Adapter + Screw') - getByAltText('Universal Flat Adapter') - }) - it('renders the correct thermal adapter info when name is Deep Well Adapter', () => { - props = { - labwareDefinition: null, - thermalAdapterName: 'Deep Well Adapter', - moduleModel: 'heaterShakerModuleV1', - } - - const { getByText, getByAltText } = render(props) - getByText('Deep Well Adapter + Screw') - getByAltText('Deep Well Adapter') - }) - it('renders the correct thermal adapter info when name is 96 Flat Bottom Adapter', () => { - props = { - labwareDefinition: null, - thermalAdapterName: '96 Flat Bottom Adapter', - moduleModel: 'heaterShakerModuleV1', - } - - const { getByText, getByAltText } = render(props) - getByText('96 Flat Bottom Adapter + Screw') - getByAltText('96 Flat Bottom Adapter') - }) -}) diff --git a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/KeyParts.test.tsx b/app/src/organisms/Devices/HeaterShakerWizard/__tests__/KeyParts.test.tsx deleted file mode 100644 index 459b1f88229..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/KeyParts.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import * as React from 'react' -import { nestedTextMatcher, renderWithProviders } from '@opentrons/components' -import { i18n } from '../../../../i18n' -import { KeyParts } from '../KeyParts' - -const render = () => { - return renderWithProviders(, { - i18nInstance: i18n, - })[0] -} - -describe('KeyParts', () => { - it('renders correct title, image and body', () => { - const { getByText, getByAltText, getByTestId } = render() - - getByText('Key Heater-Shaker parts and terminology') - getByText( - nestedTextMatcher( - 'Orient the module so its power ports face away from you.' - ) - ) - getByText( - nestedTextMatcher( - 'The Labware Latch keeps labware secure while the module is shaking.' - ) - ) - getByText( - 'It can be opened or closed manually and with software but is closed and locked while the module is shaking.' - ) - getByText( - nestedTextMatcher( - 'The 2 Anchors keep the module attached to the deck while it is shaking.' - ) - ) - getByText( - 'To extend and retract each anchor, turn the screw above it. See animation below.' - ) - getByText( - 'Extending the anchors increases the module’s footprint, which more firmly attaches it to the slot.' - ) - getByAltText('Heater Shaker Key Parts') - - getByTestId('heater_shaker_deck_lock') - }) -}) diff --git a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/PowerOn.test.tsx b/app/src/organisms/Devices/HeaterShakerWizard/__tests__/PowerOn.test.tsx deleted file mode 100644 index c1d57620ba2..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/PowerOn.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import * as React from 'react' -import { renderWithProviders } from '@opentrons/components' -import { i18n } from '../../../../i18n' -import { mockHeaterShaker } from '../../../../redux/modules/__fixtures__' -import { PowerOn } from '../PowerOn' - -const render = (props: React.ComponentProps) => { - return renderWithProviders(, { - i18nInstance: i18n, - })[0] -} - -describe('PowerOn', () => { - let props: React.ComponentProps - - beforeEach(() => { - props = { - attachedModule: mockHeaterShaker, - } - }) - afterEach(() => { - jest.resetAllMocks() - }) - - it('renders correct title and body when protocol has not been uploaded', () => { - const { getByText } = render(props) - - getByText('Step 2 of 4: Power on the module') - getByText('Connect your module to the robot and and power it on.') - }) - - it('renders heater shaker SVG with info with module connected', () => { - if (props.attachedModule != null && props.attachedModule.usbPort != null) { - props.attachedModule.usbPort.port = 1 - } - const { getByText } = render(props) - getByText('Connected') - getByText('Heater-Shaker Module GEN1') - getByText('USB Port 1') - }) - - it('renders heater shaker SVG with info with module not connected', () => { - props = { - attachedModule: null, - } - const { getByText } = render(props) - getByText('Not connected') - getByText('Heater-Shaker Module GEN1') - getByText('No USB Port Yet') - }) -}) diff --git a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/TestShake.test.tsx b/app/src/organisms/Devices/HeaterShakerWizard/__tests__/TestShake.test.tsx deleted file mode 100644 index 52fd08d7724..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/__tests__/TestShake.test.tsx +++ /dev/null @@ -1,408 +0,0 @@ -import * as React from 'react' -import { nestedTextMatcher, renderWithProviders } from '@opentrons/components' -import { fireEvent, waitFor } from '@testing-library/react' -import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' -import { i18n } from '../../../../i18n' -import { useLatchControls } from '../../../ModuleCard/hooks' -import heaterShakerCommands from '@opentrons/shared-data/protocol/fixtures/6/heaterShakerCommands.json' -import { mockHeaterShaker } from '../../../../redux/modules/__fixtures__' -import { useRunStatuses } from '../../hooks' -import { TestShake } from '../TestShake' -import { HeaterShakerModuleCard } from '../HeaterShakerModuleCard' - -import type { ProtocolModuleInfo } from '../../../Devices/ProtocolRun/utils/getProtocolModulesInfo' - -jest.mock('@opentrons/react-api-client') -jest.mock('../HeaterShakerModuleCard') -jest.mock('../../../ModuleCard/hooks') -jest.mock('../../hooks') - -const mockUseLiveCommandMutation = useCreateLiveCommandMutation as jest.MockedFunction< - typeof useCreateLiveCommandMutation -> -const mockUseLatchControls = useLatchControls as jest.MockedFunction< - typeof useLatchControls -> -const mockHeaterShakerModuleCard = HeaterShakerModuleCard as jest.MockedFunction< - typeof HeaterShakerModuleCard -> -const mockUseRunStatuses = useRunStatuses as jest.MockedFunction< - typeof useRunStatuses -> - -const render = (props: React.ComponentProps) => { - return renderWithProviders(, { - i18nInstance: i18n, - })[0] -} - -const HEATER_SHAKER_PROTOCOL_MODULE_INFO = { - moduleId: 'heater_shaker_id', - x: 0, - y: 0, - z: 0, - moduleDef: mockHeaterShaker as any, - nestedLabwareDef: heaterShakerCommands.labwareDefinitions['example/plate/1'], - nestedLabwareDisplayName: 'Source Plate', - nestedLabwareId: null, - protocolLoadOrder: 1, - slotName: '1', -} as ProtocolModuleInfo - -const mockOpenLatchHeaterShaker = { - id: 'heatershaker_id', - moduleModel: 'heaterShakerModuleV1', - moduleType: 'heaterShakerModuleType', - serialNumber: 'jkl123', - hardwareRevision: 'heatershaker_v4.0', - firmwareVersion: 'v2.0.0', - hasAvailableUpdate: true, - data: { - labwareLatchStatus: 'idle_open', - speedStatus: 'idle', - temperatureStatus: 'idle', - currentSpeed: null, - currentTemperature: null, - targetSpeed: null, - targetTemp: null, - errorDetails: null, - status: 'idle', - }, - usbPort: { path: '/dev/ot_module_heatershaker0', port: 1 }, -} as any - -const mockCloseLatchHeaterShaker = { - id: 'heatershaker_id', - moduleModel: 'heaterShakerModuleV1', - moduleType: 'heaterShakerModuleType', - serialNumber: 'jkl123', - hardwareRevision: 'heatershaker_v4.0', - firmwareVersion: 'v2.0.0', - hasAvailableUpdate: true, - data: { - labwareLatchStatus: 'idle_closed', - speedStatus: 'idle', - temperatureStatus: 'idle', - currentSpeed: null, - currentTemperature: null, - targetSpeed: null, - targetTemp: null, - errorDetails: null, - status: 'idle', - }, - usbPort: { path: '/dev/ot_module_heatershaker0', port: 1, hub: null }, -} as any - -const mockMovingHeaterShaker = { - id: 'heatershaker_id', - moduleModel: 'heaterShakerModuleV1', - moduleType: 'heaterShakerModuleType', - serialNumber: 'jkl123', - hardwareRevision: 'heatershaker_v4.0', - firmwareVersion: 'v2.0.0', - hasAvailableUpdate: true, - data: { - labwareLatchStatus: 'idle_closed', - speedStatus: 'speeding up', - temperatureStatus: 'idle', - currentSpeed: null, - currentTemperature: null, - targetSpeed: null, - targetTemp: null, - errorDetails: null, - status: 'idle', - }, - usbPort: { path: '/dev/ot_module_heatershaker0', port: 1 }, -} as any - -describe('TestShake', () => { - let props: React.ComponentProps - let mockCreateLiveCommand = jest.fn() - const mockToggleLatch = jest.fn() - beforeEach(() => { - props = { - setCurrentPage: jest.fn(), - module: mockHeaterShaker, - moduleFromProtocol: undefined, - } - mockCreateLiveCommand = jest.fn() - mockCreateLiveCommand.mockResolvedValue(null) - mockUseLiveCommandMutation.mockReturnValue({ - createLiveCommand: mockCreateLiveCommand, - } as any) - mockHeaterShakerModuleCard.mockReturnValue( -
Mock Heater Shaker Module Card
- ) - mockUseLatchControls.mockReturnValue({ - toggleLatch: jest.fn(), - isLatchClosed: true, - } as any) - mockUseRunStatuses.mockReturnValue({ - isRunRunning: false, - isRunStill: false, - isRunTerminal: false, - isRunIdle: false, - }) - }) - it('renders the correct title', () => { - const { getByText } = render(props) - getByText('Step 4 of 4: Test shake') - }) - - it('renders the information banner icon and description', () => { - const { getByText, getByLabelText } = render(props) - getByLabelText('information') - getByText( - 'If you want to add labware to the module before doing a test shake, you can use the labware latch controls to hold the latches open.' - ) - }) - - it('renders labware name in the banner description when there is a protocol', () => { - props = { - setCurrentPage: jest.fn(), - module: mockHeaterShaker, - moduleFromProtocol: HEATER_SHAKER_PROTOCOL_MODULE_INFO, - } - const { getByText } = render(props) - getByText( - nestedTextMatcher( - 'If you want to add the Source Plate to the module before doing a test shake, you can use the labware latch controls.' - ) - ) - }) - - it('renders a heater shaker module card', () => { - const { getByText } = render(props) - - getByText('Mock Heater Shaker Module Card') - }) - - it('renders the close labware latch button and is enabled when latch status is open', () => { - props = { - module: mockHeaterShaker, - setCurrentPage: jest.fn(), - moduleFromProtocol: undefined, - } - - mockUseLatchControls.mockReturnValue({ - toggleLatch: mockToggleLatch, - isLatchClosed: false, - }) - - const { getByRole } = render(props) - const button = getByRole('button', { name: /Close Labware Latch/i }) - expect(button).toBeEnabled() - }) - - it('renders the start shaking button and is disabled', () => { - props = { - module: mockCloseLatchHeaterShaker, - setCurrentPage: jest.fn(), - moduleFromProtocol: undefined, - } - - const { getByRole } = render(props) - const button = getByRole('button', { name: /Start Shaking/i }) - expect(button).toBeDisabled() - }) - - it('renders an input field for speed setting', () => { - const { getByText, getByRole } = render(props) - - getByText('Set shake speed') - getByRole('spinbutton') - }) - - it('renders troubleshooting accordion and contents', () => { - const { getByText, getByRole } = render(props) - - const troubleshooting = getByText('Troubleshooting') - fireEvent.click(troubleshooting) - - getByText( - 'Return to Step 1 to see instructions for securing the module to the deck.' - ) - const buttonStep1 = getByRole('button', { name: /Go to Step 1/i }) - expect(buttonStep1).toBeEnabled() - - getByText( - 'Return to Step 3 to see instructions for securing the thermal adapter to the module.' - ) - const buttonStep2 = getByRole('button', { name: /Go to Step 3/i }) - expect(buttonStep2).toBeEnabled() - }) - - it('start shake button should be disabled if the labware latch is open', () => { - props = { - module: mockOpenLatchHeaterShaker, - setCurrentPage: jest.fn(), - moduleFromProtocol: undefined, - } - - mockUseLatchControls.mockReturnValue({ - toggleLatch: mockToggleLatch, - isLatchClosed: false, - }) - - const { getByRole } = render(props) - const button = getByRole('button', { name: /Start/i }) - expect(button).toBeDisabled() - }) - - it('start shake button should be disabled if the input is out of range', () => { - props = { - module: mockOpenLatchHeaterShaker, - setCurrentPage: jest.fn(), - moduleFromProtocol: undefined, - } - - mockUseLatchControls.mockReturnValue({ - toggleLatch: mockToggleLatch, - isLatchClosed: false, - }) - - const { getByRole } = render(props) - const input = getByRole('spinbutton') - fireEvent.change(input, { target: { value: '0' } }) - const button = getByRole('button', { name: /Start/i }) - expect(button).toBeDisabled() - }) - - it('clicking the open latch button should open the heater shaker latch', () => { - props = { - module: mockCloseLatchHeaterShaker, - setCurrentPage: jest.fn(), - moduleFromProtocol: undefined, - } - - mockUseLatchControls.mockReturnValue({ - toggleLatch: mockToggleLatch, - isLatchClosed: true, - }) - - const { getByRole } = render(props) - const button = getByRole('button', { name: /Open Labware Latch/i }) - fireEvent.click(button) - expect(mockToggleLatch).toHaveBeenCalled() - }) - - it('clicking the close latch button should close the heater shaker latch', () => { - props = { - module: mockOpenLatchHeaterShaker, - setCurrentPage: jest.fn(), - moduleFromProtocol: undefined, - } - - mockUseLatchControls.mockReturnValue({ - toggleLatch: mockToggleLatch, - isLatchClosed: false, - }) - - const { getByRole } = render(props) - const button = getByRole('button', { name: /Close Labware Latch/i }) - fireEvent.click(button) - expect(mockToggleLatch).toHaveBeenCalled() - }) - - it('entering an input for shake speed and clicking start should begin shaking', async () => { - props = { - module: mockCloseLatchHeaterShaker, - setCurrentPage: jest.fn(), - moduleFromProtocol: undefined, - } - - const { getByRole } = render(props) - const button = getByRole('button', { name: /Start Shaking/i }) - const input = getByRole('spinbutton') - fireEvent.change(input, { target: { value: '300' } }) - fireEvent.click(button) - - await waitFor(() => { - expect(mockCreateLiveCommand).toHaveBeenCalledWith({ - command: { - commandType: 'heaterShaker/closeLabwareLatch', - params: { - moduleId: 'heatershaker_id', - }, - }, - }) - - expect(mockCreateLiveCommand).toHaveBeenCalledWith({ - command: { - commandType: 'heaterShaker/setAndWaitForShakeSpeed', - params: { - moduleId: 'heatershaker_id', - rpm: 300, - }, - }, - }) - }) - }) - - it('when the heater shaker is shaking clicking stop should deactivate the shaking', () => { - props = { - module: mockMovingHeaterShaker, - setCurrentPage: jest.fn(), - moduleFromProtocol: undefined, - } - - const { getByRole } = render(props) - const input = getByRole('spinbutton') - expect(input).toBeDisabled() - const button = getByRole('button', { name: /Stop Shaking/i }) - fireEvent.change(input, { target: { value: '200' } }) - fireEvent.click(button) - - expect(mockCreateLiveCommand).toHaveBeenCalledWith({ - command: { - commandType: 'heaterShaker/deactivateShaker', - params: { - moduleId: mockHeaterShaker.id, - }, - }, - }) - }) - - // next test is sending module commands when run is terminal and through module controls - it('entering an input for shake speed and clicking start should close the latch and begin shaking when run is terminal', async () => { - mockUseRunStatuses.mockReturnValue({ - isRunRunning: false, - isRunStill: false, - isRunTerminal: true, - isRunIdle: false, - }) - - props = { - module: mockHeaterShaker, - setCurrentPage: jest.fn(), - moduleFromProtocol: HEATER_SHAKER_PROTOCOL_MODULE_INFO, - } - - const { getByRole } = render(props) - const button = getByRole('button', { name: /Start Shaking/i }) - const input = getByRole('spinbutton') - fireEvent.change(input, { target: { value: '300' } }) - fireEvent.click(button) - - await waitFor(() => { - expect(mockCreateLiveCommand).toHaveBeenCalledWith({ - command: { - commandType: 'heaterShaker/closeLabwareLatch', - params: { - moduleId: 'heatershaker_id', - }, - }, - }) - - expect(mockCreateLiveCommand).toHaveBeenCalledWith({ - command: { - commandType: 'heaterShaker/setAndWaitForShakeSpeed', - params: { - moduleId: 'heatershaker_id', - rpm: 300, - }, - }, - }) - }) - }) -}) diff --git a/app/src/organisms/Devices/HeaterShakerWizard/index.tsx b/app/src/organisms/Devices/HeaterShakerWizard/index.tsx deleted file mode 100644 index 1c82eb30e05..00000000000 --- a/app/src/organisms/Devices/HeaterShakerWizard/index.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import * as React from 'react' -import { useParams } from 'react-router-dom' -import { useTranslation } from 'react-i18next' -import { getAdapterName } from '@opentrons/shared-data' -import { Portal } from '../../../App/portal' -import { Interstitial } from '../../../atoms/Interstitial/Interstitial' -import { Tooltip } from '../../../atoms/Tooltip' -import { Introduction } from './Introduction' -import { KeyParts } from './KeyParts' -import { AttachModule } from './AttachModule' -import { AttachAdapter } from './AttachAdapter' -import { PowerOn } from './PowerOn' -import { TestShake } from './TestShake' -import { - DIRECTION_ROW, - Flex, - JUSTIFY_SPACE_BETWEEN, - JUSTIFY_FLEX_END, - useHoverTooltip, - PrimaryButton, - SecondaryButton, -} from '@opentrons/components' - -import type { ModuleModel } from '@opentrons/shared-data' -import type { DesktopRouteParams } from '../../../App/types' -import type { HeaterShakerModule } from '../../../redux/modules/types' -import type { ProtocolModuleInfo } from '../../Devices/ProtocolRun/utils/getProtocolModulesInfo' - -interface HeaterShakerWizardProps { - onCloseClick: () => unknown - moduleFromProtocol?: ProtocolModuleInfo - attachedModule: HeaterShakerModule | null -} - -export const HeaterShakerWizard = ( - props: HeaterShakerWizardProps -): JSX.Element | null => { - const { onCloseClick, moduleFromProtocol, attachedModule } = props - const { t } = useTranslation(['heater_shaker', 'shared']) - const [currentPage, setCurrentPage] = React.useState(0) - const { robotName } = useParams() - const [targetProps, tooltipProps] = useHoverTooltip() - - let isPrimaryCTAEnabled: boolean = true - if (currentPage === 3) { - isPrimaryCTAEnabled = Boolean(attachedModule) - } - const labwareDef = - moduleFromProtocol != null ? moduleFromProtocol.nestedLabwareDef : null - - let heaterShakerModel: ModuleModel - if (attachedModule != null) { - heaterShakerModel = attachedModule.moduleModel - } else if (moduleFromProtocol != null) { - heaterShakerModel = moduleFromProtocol.moduleDef.model - } - - let buttonContent = null - const getWizardDisplayPage = (): JSX.Element | null => { - switch (currentPage) { - case 0: - buttonContent = t('btn_continue_attachment_guide') - return ( - - ) - case 1: - buttonContent = t('btn_begin_attachment') - return - case 2: - buttonContent = t('btn_power_module') - return - case 3: - buttonContent = t('btn_thermal_adapter') - return - case 4: - buttonContent = t('btn_test_shake') - return ( - // attachedModule should never be null because isPrimaryCTAEnabled would be disabled otherwise - attachedModule != null ? ( - - ) : null - ) - case 5: - buttonContent = t('complete') - return attachedModule != null ? ( - - ) : null - default: - return null - } - } - - return ( - - onCloseClick(), - title: t('shared:exit'), - children: t('shared:exit'), - }, - }} - > - {getWizardDisplayPage()} - - {currentPage > 0 ? ( - setCurrentPage(currentPage => currentPage - 1)} - > - {t('back')} - - ) : null} - {currentPage <= 5 ? ( - onCloseClick() - : () => setCurrentPage(currentPage => currentPage + 1) - } - > - {buttonContent} - {!isPrimaryCTAEnabled ? ( - - {t('module_is_not_connected')} - - ) : null} - - ) : null} - - - - ) -} diff --git a/app/src/organisms/Devices/InstrumentsAndModules.tsx b/app/src/organisms/Devices/InstrumentsAndModules.tsx index 66d30742691..8b05036d1c2 100644 --- a/app/src/organisms/Devices/InstrumentsAndModules.tsx +++ b/app/src/organisms/Devices/InstrumentsAndModules.tsx @@ -23,8 +23,6 @@ import { import { StyledText } from '../../atoms/text' import { Banner } from '../../atoms/Banner' -import { UpdateBanner } from '../../molecules/UpdateBanner' -import { InstrumentCard } from '../../molecules/InstrumentCard' import { useCurrentRunId } from '../ProtocolUpload/hooks' import { ModuleCard } from '../ModuleCard' import { FirmwareUpdateModal } from '../FirmwareUpdateModal' @@ -74,11 +72,7 @@ export function InstrumentsAndModules({ const attachedGripper = (attachedInstruments?.data ?? []).find( - (i): i is GripperData => i.instrumentType === 'gripper' && i.ok - ) ?? null - const badGripper = - (attachedInstruments?.data ?? []).find( - (i): i is BadGripper => i.subsystem === 'gripper' && !i.ok + (i): i is GripperData | BadGripper => i.subsystem === 'gripper' ) ?? null const attachedLeftPipette = attachedInstruments?.data?.find( @@ -193,66 +187,32 @@ export function InstrumentsAndModules({ flexDirection={DIRECTION_COLUMN} gridGap={SPACING.spacing8} > - {badLeftPipette == null ? ( - - ) : ( - null} - updateType="firmware_important" - handleUpdateClick={() => - setSubsystemToUpdate('pipette_left') - } - /> - } - /> - )} - {isOT3 && badGripper == null && ( + setSubsystemToUpdate('pipette_left')} + /> + {isOT3 && ( - )} - {isOT3 && badGripper != null && ( - null} - updateType="firmware" - handleUpdateClick={() => setSubsystemToUpdate('gripper')} - /> + isCalibrated={ + attachedGripper?.ok === true && + attachedGripper?.data?.calibratedOffset != null } + setSubsystemToUpdate={setSubsystemToUpdate} /> )} {leftColumnModules.map((module, index) => ( @@ -273,41 +233,25 @@ export function InstrumentsAndModules({ flexDirection={DIRECTION_COLUMN} gridGap={SPACING.spacing8} > - {!Boolean(is96ChannelAttached) && badRightPipette == null && ( + {!Boolean(is96ChannelAttached) && ( - )} - {badRightPipette != null && ( - null} - updateType="firmware_important" - handleUpdateClick={() => setSubsystemToUpdate('gripper')} - /> - } + pipetteIs96Channel={false} + pipetteIsBad={badRightPipette != null} + updatePipette={() => setSubsystemToUpdate('pipette_right')} /> )} {rightColumnModules.map((module, index) => ( diff --git a/app/src/organisms/Devices/PipetteCard/__tests__/PipetteCard.test.tsx b/app/src/organisms/Devices/PipetteCard/__tests__/PipetteCard.test.tsx index 6040287c854..9bcdeac0e1d 100644 --- a/app/src/organisms/Devices/PipetteCard/__tests__/PipetteCard.test.tsx +++ b/app/src/organisms/Devices/PipetteCard/__tests__/PipetteCard.test.tsx @@ -3,6 +3,7 @@ import { resetAllWhenMocks, when } from 'jest-when' import { fireEvent } from '@testing-library/react' import { renderWithProviders } from '@opentrons/components' import { LEFT, RIGHT } from '@opentrons/shared-data' +import { useCurrentSubsystemUpdateQuery } from '@opentrons/react-api-client' import { i18n } from '../../../../i18n' import { getHasCalibrationBlock } from '../../../../redux/config' import { useDispatchApiRequest } from '../../../../redux/robot-api' @@ -28,6 +29,7 @@ jest.mock('../../../CalibrateTipLength') jest.mock('../../hooks') jest.mock('../AboutPipetteSlideout') jest.mock('../../../../redux/robot-api') +jest.mock('@opentrons/react-api-client') const mockPipetteOverflowMenu = PipetteOverflowMenu as jest.MockedFunction< typeof PipetteOverflowMenu @@ -51,6 +53,9 @@ const mockUseDispatchApiRequest = useDispatchApiRequest as jest.MockedFunction< typeof useDispatchApiRequest > const mockUseIsOT3 = useIsOT3 as jest.MockedFunction +const mockUseCurrentSubsystemUpdateQuery = useCurrentSubsystemUpdateQuery as jest.MockedFunction< + typeof useCurrentSubsystemUpdateQuery +> const render = (props: React.ComponentProps) => { return renderWithProviders(, { @@ -62,10 +67,21 @@ const mockRobotName = 'mockRobotName' describe('PipetteCard', () => { let startWizard: any let dispatchApiRequest: DispatchApiRequestType + let props: React.ComponentProps beforeEach(() => { startWizard = jest.fn() dispatchApiRequest = jest.fn() + props = { + pipetteModelSpecs: mockLeftSpecs, + mount: LEFT, + robotName: mockRobotName, + pipetteId: 'id', + pipetteIs96Channel: false, + isPipetteCalibrated: false, + pipetteIsBad: false, + updatePipette: jest.fn(), + } when(mockUseIsOT3).calledWith(mockRobotName).mockReturnValue(false) when(mockAboutPipettesSlideout).mockReturnValue(
mock about slideout
@@ -86,6 +102,9 @@ describe('PipetteCard', () => { dispatchApiRequest, ['id'], ]) + mockUseCurrentSubsystemUpdateQuery.mockReturnValue({ + data: undefined, + } as any) }) afterEach(() => { jest.resetAllMocks() @@ -93,26 +112,32 @@ describe('PipetteCard', () => { }) it('renders information for a left pipette', () => { - const { getByText } = render({ - pipetteInfo: mockLeftSpecs, + props = { + pipetteModelSpecs: mockLeftSpecs, mount: LEFT, robotName: mockRobotName, pipetteId: 'id', - is96ChannelAttached: false, + pipetteIs96Channel: false, isPipetteCalibrated: false, - }) + pipetteIsBad: false, + updatePipette: jest.fn(), + } + const { getByText } = render(props) getByText('left Mount') getByText('Left Pipette') }) it('renders information for a 96 channel pipette with overflow menu button not disabled', () => { - const { getByText, getByRole } = render({ - pipetteInfo: mockLeftSpecs, + props = { + pipetteModelSpecs: mockLeftSpecs, mount: LEFT, robotName: mockRobotName, pipetteId: 'id', - is96ChannelAttached: true, + pipetteIs96Channel: true, isPipetteCalibrated: false, - }) + pipetteIsBad: false, + updatePipette: jest.fn(), + } + const { getByText, getByRole } = render(props) getByText('Both Mounts') const overflowButton = getByRole('button', { name: /overflow/i, @@ -122,69 +147,87 @@ describe('PipetteCard', () => { getByText('mock pipette overflow menu') }) it('renders information for a right pipette', () => { - const { getByText } = render({ - pipetteInfo: mockRightSpecs, + props = { + pipetteModelSpecs: mockRightSpecs, mount: RIGHT, robotName: mockRobotName, pipetteId: 'id', - is96ChannelAttached: false, + pipetteIs96Channel: false, isPipetteCalibrated: false, - }) + pipetteIsBad: false, + updatePipette: jest.fn(), + } + const { getByText } = render(props) getByText('right Mount') getByText('Right Pipette') }) it('renders information for no pipette on right Mount', () => { - const { getByText } = render({ - pipetteInfo: null, + props = { + pipetteModelSpecs: null, mount: RIGHT, robotName: mockRobotName, - is96ChannelAttached: false, + pipetteIs96Channel: false, isPipetteCalibrated: false, - }) + pipetteIsBad: false, + updatePipette: jest.fn(), + } + const { getByText } = render(props) getByText('right Mount') getByText('Empty') }) it('renders information for no pipette on left Mount', () => { - const { getByText } = render({ - pipetteInfo: null, + props = { + pipetteModelSpecs: null, mount: LEFT, robotName: mockRobotName, - is96ChannelAttached: false, + pipetteIs96Channel: false, isPipetteCalibrated: false, - }) + pipetteIsBad: false, + updatePipette: jest.fn(), + } + const { getByText } = render(props) getByText('left Mount') getByText('Empty') }) it('does not render banner to calibrate for ot2 pipette if not calibrated', () => { when(mockUseIsOT3).calledWith(mockRobotName).mockReturnValue(false) - const { queryByText } = render({ - pipetteInfo: mockLeftSpecs, + props = { + pipetteModelSpecs: mockLeftSpecs, mount: LEFT, robotName: mockRobotName, - is96ChannelAttached: false, + pipetteIs96Channel: false, isPipetteCalibrated: false, - }) + pipetteIsBad: false, + updatePipette: jest.fn(), + } + const { queryByText } = render(props) expect(queryByText('Calibrate now')).toBeNull() }) it('renders banner to calibrate for ot3 pipette if not calibrated', () => { when(mockUseIsOT3).calledWith(mockRobotName).mockReturnValue(true) - const { getByText } = render({ - pipetteInfo: { ...mockLeftSpecs, name: 'p300_single_flex' }, + props = { + pipetteModelSpecs: { ...mockLeftSpecs, name: 'p300_single_flex' }, mount: LEFT, robotName: mockRobotName, - is96ChannelAttached: false, + pipetteIs96Channel: false, isPipetteCalibrated: false, - }) + pipetteIsBad: false, + updatePipette: jest.fn(), + } + const { getByText } = render(props) getByText('Calibrate now') }) it('renders kebab icon, opens and closes overflow menu on click', () => { - const { getByRole, getByText, queryByText } = render({ - pipetteInfo: mockRightSpecs, + props = { + pipetteModelSpecs: mockRightSpecs, mount: RIGHT, robotName: mockRobotName, - is96ChannelAttached: false, + pipetteIs96Channel: false, isPipetteCalibrated: false, - }) + pipetteIsBad: false, + updatePipette: jest.fn(), + } + const { getByRole, getByText, queryByText } = render(props) const overflowButton = getByRole('button', { name: /overflow/i, @@ -196,4 +239,39 @@ describe('PipetteCard', () => { overflowMenu.click() expect(queryByText('mock pipette overflow menu')).toBeNull() }) + it('renders firmware update needed state if pipette is bad', () => { + props = { + pipetteModelSpecs: mockRightSpecs, + mount: RIGHT, + robotName: mockRobotName, + pipetteIs96Channel: false, + isPipetteCalibrated: false, + pipetteIsBad: true, + updatePipette: jest.fn(), + } + const { getByText } = render(props) + getByText('Right mount') + getByText('Instrument attached') + getByText('Firmware update available.') + getByText('Update now').click() + expect(props.updatePipette).toHaveBeenCalled() + }) + it('renders firmware update in progress state if pipette is bad and update in progress', () => { + when(mockUseCurrentSubsystemUpdateQuery).mockReturnValue({ + data: { data: { updateProgress: 50 } as any }, + } as any) + props = { + pipetteModelSpecs: mockRightSpecs, + mount: RIGHT, + robotName: mockRobotName, + pipetteIs96Channel: false, + isPipetteCalibrated: false, + pipetteIsBad: true, + updatePipette: jest.fn(), + } + const { getByText } = render(props) + getByText('Right mount') + getByText('Instrument attached') + getByText('Firmware update in progress...') + }) }) diff --git a/app/src/organisms/Devices/PipetteCard/index.tsx b/app/src/organisms/Devices/PipetteCard/index.tsx index d6294b7ab7f..6ab3e887df3 100644 --- a/app/src/organisms/Devices/PipetteCard/index.tsx +++ b/app/src/organisms/Devices/PipetteCard/index.tsx @@ -19,10 +19,13 @@ import { NINETY_SIX_CHANNEL, SINGLE_MOUNT_PIPETTES, } from '@opentrons/shared-data' +import { useCurrentSubsystemUpdateQuery } from '@opentrons/react-api-client' import { LEFT } from '../../../redux/pipettes' import { OverflowBtn } from '../../../atoms/MenuList/OverflowBtn' import { StyledText } from '../../../atoms/text' +import { Banner } from '../../../atoms/Banner' import { useMenuHandleClickOutside } from '../../../atoms/MenuList/hooks' +import { InstrumentCard } from '../../../molecules/InstrumentCard' import { ChangePipette } from '../../ChangePipette' import { FLOWS } from '../../PipetteWizardFlows/constants' import { PipetteWizardFlows } from '../../PipetteWizardFlows' @@ -41,26 +44,35 @@ import type { PipetteWizardFlow, SelectablePipettes, } from '../../PipetteWizardFlows/types' -import { Banner } from '../../../atoms/Banner' interface PipetteCardProps { - pipetteInfo: PipetteModelSpecs | null + pipetteModelSpecs: PipetteModelSpecs | null pipetteId?: AttachedPipette['id'] | null isPipetteCalibrated: boolean mount: Mount robotName: string - is96ChannelAttached: boolean + pipetteIs96Channel: boolean + pipetteIsBad: boolean + updatePipette: () => void } +const BANNER_LINK_CSS = css` + text-decoration: underline; + cursor: pointer; + margin-left: ${SPACING.spacing8}; +` +const SUBSYSTEM_UPDATE_POLL_MS = 3000 export const PipetteCard = (props: PipetteCardProps): JSX.Element => { - const { t } = useTranslation(['device_details', 'protocol_setup']) + const { t, i18n } = useTranslation(['device_details', 'protocol_setup']) const { - pipetteInfo, + pipetteModelSpecs, isPipetteCalibrated, mount, robotName, pipetteId, - is96ChannelAttached, + pipetteIs96Channel, + pipetteIsBad, + updatePipette, } = props const { menuOverlay, @@ -69,9 +81,9 @@ export const PipetteCard = (props: PipetteCardProps): JSX.Element => { setShowOverflowMenu, } = useMenuHandleClickOutside() const isOt3 = useIsOT3(robotName) - const pipetteName = pipetteInfo?.name + const pipetteName = pipetteModelSpecs?.name const isOT3PipetteAttached = isOT3Pipette(pipetteName as PipetteName) - const pipetteDisplayName = pipetteInfo?.displayName + const pipetteDisplayName = pipetteModelSpecs?.displayName const pipetteOverflowWrapperRef = useOnClickOutside({ onClickOutside: () => setShowOverflowMenu(false), }) @@ -83,6 +95,14 @@ export const PipetteCard = (props: PipetteCardProps): JSX.Element => { ] = React.useState(null) const [showAttachPipette, setShowAttachPipette] = React.useState(false) const [showAboutSlideout, setShowAboutSlideout] = React.useState(false) + const subsystem = mount === LEFT ? 'pipette_left' : 'pipette_right' + const { data: subsystemUpdateData } = useCurrentSubsystemUpdateQuery( + subsystem, + { + enabled: pipetteIsBad, + refetchInterval: SUBSYSTEM_UPDATE_POLL_MS, + } + ) const [ selectedPipette, @@ -153,98 +173,139 @@ export const PipetteCard = (props: PipetteCardProps): JSX.Element => { closeModal={() => setChangePipette(false)} /> )} - {showSlideout && pipetteInfo != null && pipetteId != null && ( + {showSlideout && pipetteModelSpecs != null && pipetteId != null && ( setShowSlideout(false)} isExpanded={true} pipetteId={pipetteId} /> )} - {showAboutSlideout && pipetteInfo != null && pipetteId != null && ( + {showAboutSlideout && pipetteModelSpecs != null && pipetteId != null && ( setShowAboutSlideout(false)} isExpanded={true} /> )} - - - - {pipetteInfo === null ? null : ( - - )} - - + - {isOT3PipetteAttached && !isPipetteCalibrated ? ( - - - ), - }} - /> - - ) : null} - - {is96ChannelAttached - ? t('both_mounts') - : t('mount', { - side: mount === LEFT ? t('left') : t('right'), - })} - - - - {pipetteDisplayName ?? t('empty')} - + + + {pipetteModelSpecs !== null ? ( + + ) : null} + + + {isOT3PipetteAttached && !isPipetteCalibrated ? ( + + + ), + }} + /> + + ) : null} + + {pipetteIs96Channel + ? t('both_mounts') + : t('mount', { + side: mount === LEFT ? t('left') : t('right'), + })} + + + + {pipetteDisplayName ?? t('empty')} + + + - - - - - - + + + + + + )} + {pipetteIsBad && ( + + + ), + }} + /> + + } + /> + )} {showOverflowMenu && ( <> { onClick={() => setShowOverflowMenu(false)} > { > @@ -75,7 +75,6 @@ const LabwareInfo = (props: LabwareInfoProps): JSX.Element | null => { <> diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx index 7bfeed69f28..014841be972 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunHeader.tsx @@ -16,11 +16,7 @@ import { RUN_STATUS_BLOCKED_BY_OPEN_DOOR, RunStatus, } from '@opentrons/api-client' -import { - useRunQuery, - useModulesQuery, - useEstopQuery, -} from '@opentrons/react-api-client' +import { useRunQuery, useModulesQuery } from '@opentrons/react-api-client' import { HEATERSHAKER_MODULE_TYPE } from '@opentrons/shared-data' import { Box, @@ -83,14 +79,12 @@ import { useIsRobotViewable, useTrackProtocolRunEvent, useRobotAnalyticsData, - useIsOT3, } from '../hooks' import { formatTimestamp } from '../utils' import { RunTimer } from './RunTimer' import { EMPTY_TIMESTAMP } from '../constants' import { getHighestPriorityError } from '../../OnDeviceDisplay/RunningProtocol' import { RunFailedModal } from './RunFailedModal' -import { DISENGAGED } from '../../EmergencyStop' import type { Run, RunError } from '@opentrons/api-client' import type { State } from '../../../redux/types' @@ -98,7 +92,6 @@ import type { HeaterShakerModule } from '../../../redux/modules/types' import { RunProgressMeter } from '../../RunProgressMeter' const EQUIPMENT_POLL_MS = 5000 -const ESTOP_POLL_MS = 5000 const CANCELLABLE_STATUSES = [ RUN_STATUS_RUNNING, RUN_STATUS_PAUSED, @@ -142,21 +135,7 @@ export function ProtocolRunHeader({ const highestPriorityError = runRecord?.data.errors?.[0] != null ? getHighestPriorityError(runRecord?.data?.errors) - : undefined - const { data: estopStatus, error: estopError } = useEstopQuery({ - refetchInterval: ESTOP_POLL_MS, - }) - const [ - showEmergencyStopRunBanner, - setShowEmergencyStopRunBanner, - ] = React.useState(false) - const isOT3 = useIsOT3(robotName) - - React.useEffect(() => { - if (estopStatus?.data.status !== DISENGAGED && estopError == null) { - setShowEmergencyStopRunBanner(true) - } - }, [estopStatus?.data.status]) + : null React.useEffect(() => { if (protocolData != null && !isRobotViewable) { @@ -290,14 +269,6 @@ export function ProtocolRunHeader({ }} /> ) : null} - {estopStatus?.data.status !== DISENGAGED && - estopError == null && - isOT3 && - showEmergencyStopRunBanner ? ( - - ) : null} void isClosingCurrentRun: boolean setShowRunFailedModal: (showRunFailedModal: boolean) => void - highestPriorityError?: RunError + highestPriorityError?: RunError | null } function TerminalRunBanner(props: TerminalRunProps): JSX.Element | null { const { @@ -699,23 +670,3 @@ function TerminalRunBanner(props: TerminalRunProps): JSX.Element | null { } return null } - -interface EmergencyStopRunPropsBanner { - setShowEmergencyStopRunBanner: (showEmergencyStopRunBanner: boolean) => void -} - -function EmergencyStopRunBanner({ - setShowEmergencyStopRunBanner, -}: EmergencyStopRunPropsBanner): JSX.Element { - const { t } = useTranslation('run_details') - return ( - setShowEmergencyStopRunBanner(false)} - > - - {t('run_failed')} - - - ) -} diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunModuleControls.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunModuleControls.tsx index b8d03d21d8f..8e2d67fd238 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunModuleControls.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunModuleControls.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' +import { useInstrumentsQuery } from '@opentrons/react-api-client' import { COLORS, Flex, @@ -10,6 +11,50 @@ import { import { StyledText } from '../../../atoms/text' import { ModuleCard } from '../../ModuleCard' import { useModuleRenderInfoForProtocolById } from '../hooks' +import type { BadPipette, PipetteData } from '@opentrons/api-client' + +interface PipetteStatus { + attachPipetteRequired: boolean + updatePipetteFWRequired: boolean +} + +const usePipetteIsReady = (): PipetteStatus => { + const EQUIPMENT_POLL_MS = 5000 + + const { data: attachedInstruments } = useInstrumentsQuery({ + refetchInterval: EQUIPMENT_POLL_MS, + }) + const attachedLeftPipette = + attachedInstruments?.data?.find( + (i): i is PipetteData => + i.instrumentType === 'pipette' && i.ok && i.mount === 'left' + ) ?? null + const leftPipetteRequiresFWUpdate = + attachedInstruments?.data?.find( + (i): i is BadPipette => + i.instrumentType === 'pipette' && + !i.ok && + i.subsystem === 'pipette_left' + ) ?? null + const attachedRightPipette = + attachedInstruments?.data?.find( + (i): i is PipetteData => + i.instrumentType === 'pipette' && i.ok && i.mount === 'right' + ) ?? null + const rightPipetteFWRequired = + attachedInstruments?.data?.find( + (i): i is BadPipette => + i.instrumentType === 'pipette' && + !i.ok && + i.subsystem === 'pipette_right' + ) ?? null + + const attachPipetteRequired = + attachedLeftPipette == null && attachedRightPipette == null + const updatePipetteFWRequired = + leftPipetteRequiresFWUpdate != null || rightPipetteFWRequired != null + return { attachPipetteRequired, updatePipetteFWRequired } +} interface ProtocolRunModuleControlsProps { robotName: string @@ -21,6 +66,9 @@ export const ProtocolRunModuleControls = ({ runId, }: ProtocolRunModuleControlsProps): JSX.Element => { const { t } = useTranslation('protocol_details') + + const { attachPipetteRequired, updatePipetteFWRequired } = usePipetteIsReady() + const moduleRenderInfoForProtocolById = useModuleRenderInfoForProtocolById( robotName, runId @@ -67,6 +115,8 @@ export const ProtocolRunModuleControls = ({ module={module.attachedModuleMatch} slotName={module.slotName} isLoadedInRun={true} + attachPipetteRequired={attachPipetteRequired} + updatePipetteFWRequired={updatePipetteFWRequired} /> ) : null )} @@ -85,6 +135,8 @@ export const ProtocolRunModuleControls = ({ module={module.attachedModuleMatch} slotName={module.slotName} isLoadedInRun={true} + attachPipetteRequired={attachPipetteRequired} + updatePipetteFWRequired={updatePipetteFWRequired} /> ) : null )} diff --git a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx index 951bcd88be0..36f14a0a7ff 100644 --- a/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx +++ b/app/src/organisms/Devices/ProtocolRun/ProtocolRunSetup.tsx @@ -8,6 +8,11 @@ import { COLORS, DIRECTION_COLUMN, SPACING, + Icon, + SIZE_1, + DIRECTION_ROW, + TYPOGRAPHY, + Link, } from '@opentrons/components' import { Line } from '../../../atoms/structure' @@ -20,6 +25,7 @@ import { useRunHasStarted, useProtocolAnalysisErrors, useStoredProtocolAnalysis, + ProtocolCalibrationStatus, } from '../hooks' import { useMostRecentCompletedAnalysis } from '../../LabwarePositionCheck/useMostRecentCompletedAnalysis' import { SetupLabware } from './SetupLabware' @@ -29,6 +35,7 @@ import { SetupModules } from './SetupModules' import { SetupStep } from './SetupStep' import { SetupLiquids } from './SetupLiquids' import { EmptySetupStep } from './EmptySetupStep' +import { HowLPCWorksModal } from './SetupLabwarePositionCheck/HowLPCWorksModal' const ROBOT_CALIBRATION_STEP_KEY = 'robot_calibration_step' as const const MODULE_SETUP_KEY = 'module_setup_step' as const @@ -207,10 +214,10 @@ export function ProtocolRunSetup({ ? setExpandedStepKey(null) : setExpandedStepKey(stepKey) } - calibrationStatusComplete={ - stepKey === ROBOT_CALIBRATION_STEP_KEY && !runHasStarted - ? calibrationStatus.complete - : null + rightElement={ + } > {StepDetailMap[stepKey].stepInternals} @@ -231,3 +238,70 @@ export function ProtocolRunSetup({ ) } + +interface StepRightElementProps { + stepKey: StepKey + calibrationStatus: ProtocolCalibrationStatus + runHasStarted: boolean +} +function StepRightElement(props: StepRightElementProps): JSX.Element | null { + const { stepKey, calibrationStatus, runHasStarted } = props + const { t } = useTranslation('protocol_setup') + + if (stepKey === ROBOT_CALIBRATION_STEP_KEY && !runHasStarted) { + return ( + + + + {calibrationStatus.complete + ? t('calibration_ready') + : t('calibration_needed')} + + + ) + } else if (stepKey === LPC_KEY) { + return + } else { + return null + } +} + +function LearnAboutLPC(): JSX.Element { + const { t } = useTranslation('protocol_setup') + const [showLPCHelpModal, setShowLPCHelpModal] = React.useState(false) + return ( + <> + { + // clicking link shouldn't toggle step expanded state + e.preventDefault() + e.stopPropagation() + setShowLPCHelpModal(true) + }} + > + {t('learn_how_it_works')} + + {showLPCHelpModal ? ( + setShowLPCHelpModal(false)} /> + ) : null} + + ) +} diff --git a/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx b/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx index ba7c7b19f81..8a3416e5e51 100644 --- a/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/RunFailedModal.tsx @@ -1,5 +1,4 @@ import * as React from 'react' -import isEmpty from 'lodash/isEmpty' import { useTranslation } from 'react-i18next' import { ALIGN_CENTER, @@ -40,7 +39,7 @@ interface RunFailedModalProps { robotName: string runId: string setShowRunFailedModal: (showRunFailedModal: boolean) => void - highestPriorityError?: RunError + highestPriorityError?: RunError | null } export function RunFailedModal({ @@ -95,11 +94,6 @@ export function RunFailedModal({ {highestPriorityError.detail} - {!isEmpty(highestPriorityError.errorInfo) && ( - - {JSON.stringify(highestPriorityError.errorInfo)} - - )} {t('run_failed_modal_description_desktop')} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupFlexPipetteCalibrationItem.tsx b/app/src/organisms/Devices/ProtocolRun/SetupFlexPipetteCalibrationItem.tsx new file mode 100644 index 00000000000..862d65104d5 --- /dev/null +++ b/app/src/organisms/Devices/ProtocolRun/SetupFlexPipetteCalibrationItem.tsx @@ -0,0 +1,132 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { + Flex, + ALIGN_CENTER, + DIRECTION_ROW, + SPACING, + JUSTIFY_FLEX_END, + WRAP, +} from '@opentrons/components' +import { + getPipetteNameSpecs, + NINETY_SIX_CHANNEL, + SINGLE_MOUNT_PIPETTES, +} from '@opentrons/shared-data' +import { useInstrumentsQuery } from '@opentrons/react-api-client' +import { TertiaryButton } from '../../../atoms/buttons' +import { useMostRecentCompletedAnalysis } from '../../LabwarePositionCheck/useMostRecentCompletedAnalysis' +import { PipetteWizardFlows } from '../../PipetteWizardFlows' +import { FLOWS } from '../../PipetteWizardFlows/constants' +import { SetupCalibrationItem } from './SetupCalibrationItem' +import type { PipetteData } from '@opentrons/api-client' +import type { LoadPipetteRunTimeCommand } from '@opentrons/shared-data/protocol/types/schemaV7/command/setup' +import type { Mount } from '../../../redux/pipettes/types' + +interface SetupInstrumentCalibrationItemProps { + mount: Mount + runId: string + instrumentsRefetch?: () => void +} + +export function SetupFlexPipetteCalibrationItem({ + mount, + runId, + instrumentsRefetch, +}: SetupInstrumentCalibrationItemProps): JSX.Element | null { + const { t } = useTranslation(['protocol_setup', 'devices_landing']) + const [showFlexPipetteFlow, setShowFlexPipetteFlow] = React.useState( + false + ) + const { data: attachedInstruments } = useInstrumentsQuery() + const mostRecentAnalysis = useMostRecentCompletedAnalysis(runId) + const loadPipetteCommand = mostRecentAnalysis?.commands.find( + (command): command is LoadPipetteRunTimeCommand => + command.commandType === 'loadPipette' && command.params.mount === mount + ) + const requestedPipette = mostRecentAnalysis?.pipettes?.find( + pipette => pipette.id === loadPipetteCommand?.result?.pipetteId + ) + + if (requestedPipette == null) return null + const requestedPipetteSpecs = getPipetteNameSpecs( + requestedPipette.pipetteName + ) + let button: JSX.Element | undefined + let subText + + const attachedPipetteOnMount = attachedInstruments?.data?.find( + (instrument): instrument is PipetteData => + instrument.ok && instrument.mount === mount + ) + const requestedPipetteMatch = + requestedPipette.pipetteName === attachedPipetteOnMount?.instrumentName + const pipetteCalDate = requestedPipetteMatch + ? attachedPipetteOnMount?.data.calibratedOffset?.last_modified ?? null + : null + let flowType = '' + if (pipetteCalDate != null && requestedPipetteMatch) { + button = undefined + } else if (!requestedPipetteMatch) { + subText = t('attach_pipette_calibration') + flowType = FLOWS.ATTACH + button = ( + + setShowFlexPipetteFlow(true)} + > + {t('attach_pipette_cta')} + + + ) + } else { + flowType = FLOWS.CALIBRATE + button = ( + <> + + setShowFlexPipetteFlow(true)} + > + {t('calibrate_now')} + + + + ) + } + + return ( + <> + {showFlexPipetteFlow && ( + setShowFlexPipetteFlow(false)} + selectedPipette={ + requestedPipetteSpecs?.channels === 96 + ? NINETY_SIX_CHANNEL + : SINGLE_MOUNT_PIPETTES + } + pipetteInfo={mostRecentAnalysis?.pipettes} + onComplete={instrumentsRefetch} + /> + )} + + + ) +} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupInstrumentCalibration.tsx b/app/src/organisms/Devices/ProtocolRun/SetupInstrumentCalibration.tsx index 974067f6cd1..fe6a936ad57 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupInstrumentCalibration.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupInstrumentCalibration.tsx @@ -11,8 +11,13 @@ import { import { StyledText } from '../../../atoms/text' import * as PipetteConstants from '../../../redux/pipettes/constants' -import { useRunPipetteInfoByMount, useStoredProtocolAnalysis } from '../hooks' +import { + useRunPipetteInfoByMount, + useStoredProtocolAnalysis, + useIsOT3, +} from '../hooks' import { SetupPipetteCalibrationItem } from './SetupPipetteCalibrationItem' +import { SetupFlexPipetteCalibrationItem } from './SetupFlexPipetteCalibrationItem' import { SetupGripperCalibrationItem } from './SetupGripperCalibrationItem' import { useMostRecentCompletedAnalysis } from '../../LabwarePositionCheck/useMostRecentCompletedAnalysis' import { useInstrumentsQuery } from '@opentrons/react-api-client' @@ -33,8 +38,10 @@ export function SetupInstrumentCalibration({ }: SetupInstrumentCalibrationProps): JSX.Element { const { t } = useTranslation('protocol_setup') const runPipetteInfoByMount = useRunPipetteInfoByMount(runId) + const isOT3 = useIsOT3(robotName) const { data: instrumentsQueryData, refetch } = useInstrumentsQuery({ + enabled: isOT3, refetchInterval: EQUIPMENT_POLL_MS, }) const mostRecentAnalysis = useMostRecentCompletedAnalysis(runId) @@ -58,16 +65,29 @@ export function SetupInstrumentCalibration({ {PipetteConstants.PIPETTE_MOUNTS.map((mount, index) => { const pipetteInfo = runPipetteInfoByMount[mount] - return pipetteInfo != null ? ( - - ) : null + if (pipetteInfo != null && !isOT3) { + return ( + + ) + } else if (isOT3) { + return ( + + ) + } else { + return null + } })} {usesGripper ? ( (false) const [isLatchClosed, setIsLatchClosed] = React.useState(false) - let slotInfo: JSX.Element | null = - initialLocation === 'offDeck' - ? null - : t('slot_location', { - slotName: Object.values(initialLocation), - }) + let slotInfo: string | null = + initialLocation !== 'offDeck' && 'slotName' in initialLocation + ? initialLocation.slotName + : null + let moduleDisplayName: string | null = null let extraAttentionText: JSX.Element | null = null let isCorrectHeaterShakerAttached: boolean = false let isHeaterShakerInProtocol: boolean = false @@ -113,10 +115,7 @@ export function LabwareListItem( if (loadedAdapterLocation != null && loadedAdapterLocation !== 'offDeck') { if ('slotName' in loadedAdapterLocation) { - slotInfo = t('adapter_slot_location', { - slotName: loadedAdapterLocation.slotName, - adapterName: loadedAdapter?.result?.definition.metadata.displayName, - }) + slotInfo = loadedAdapterLocation.slotName } else if ('moduleId' in loadedAdapterLocation) { const module = commands.find( (command): command is LoadModuleRunTimeCommand => @@ -124,11 +123,8 @@ export function LabwareListItem( command.result?.moduleId === loadedAdapterLocation.moduleId ) if (module != null) { - slotInfo = t('adapter_slot_location_module', { - slotName: module.params.location.slotName, - adapterName: loadedAdapter?.result?.definition.metadata.displayName, - moduleName: getModuleDisplayName(module.params.model), - }) + slotInfo = module.params.location.slotName + moduleDisplayName = getModuleDisplayName(module.params.model) } } } @@ -148,10 +144,8 @@ export function LabwareListItem( if (moduleType === THERMOCYCLER_MODULE_TYPE) { moduleSlotName = isOt3 ? TC_MODULE_LOCATION_OT3 : TC_MODULE_LOCATION_OT2 } - slotInfo = t('module_slot_location', { - slotName: moduleSlotName, - moduleName: moduleName, - }) + slotInfo = moduleSlotName + moduleDisplayName = moduleName switch (moduleTypeNeedsAttention) { case MAGNETIC_MODULE_TYPE: case THERMOCYCLER_MODULE_TYPE: @@ -188,11 +182,7 @@ export function LabwareListItem( case HEATERSHAKER_MODULE_TYPE: isHeaterShakerInProtocol = true extraAttentionText = ( - + {t('heater_shaker_labware_list_view')} ) @@ -253,23 +243,54 @@ export function LabwareListItem( ) { hsLatchText = t('opening') } + return ( - - - - - {labwareDisplayName} - - - {nickName} - + + + {slotInfo} + + + + + + + + {labwareDisplayName} + + + {nickName} + + + {nestedLabwareInfo != null && + nestedLabwareInfo?.sharedSlotId === slotInfo ? ( + + {nestedLabwareInfo.nestedLabwareDefinition != null ? ( + + ) : null} + + + {nestedLabwareInfo.nestedLabwareDisplayName} + + + {nestedLabwareInfo.nestedLabwareNickName} + + + + ) : null} - {slotInfo} + + {moduleDisplayName != null + ? moduleDisplayName + : t(initialLocation === 'offDeck' ? 'off_deck' : 'on_deck')} + {extraAttentionText != null ? extraAttentionText : null} + {isHeaterShakerInProtocol ? ( diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/OffDeckLabwareList.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/OffDeckLabwareList.tsx index 5e97e7ce1d3..6ef9247cad4 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/OffDeckLabwareList.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/OffDeckLabwareList.tsx @@ -35,6 +35,7 @@ export function OffDeckLabwareList( {...labwareItem} isOt3={isOt3} commands={commands} + nestedLabwareInfo={null} /> ))} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareList.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareList.tsx index f8730a529fa..ebe6275191c 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareList.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/SetupLabwareList.tsx @@ -12,13 +12,14 @@ import { StyledText } from '../../../../atoms/text' import { getLabwareSetupItemGroups } from '../../../../pages/Protocols/utils' import { LabwareListItem } from './LabwareListItem' import { OffDeckLabwareList } from './OffDeckLabwareList' +import { getNestedLabwareInfo } from './getNestedLabwareInfo' import type { ModuleRenderInfoForProtocol } from '../../hooks' import type { ModuleTypesThatRequireExtraAttention } from '../utils/getModuleTypesThatRequireExtraAttention' const HeaderRow = styled.div` display: grid; - grid-template-columns: 6fr 5fr; + grid-template-columns: 1fr 5.2fr 5.3fr; grip-gap: ${SPACING.spacing8}; padding: ${SPACING.spacing8}; ` @@ -42,23 +43,35 @@ export function SetupLabwareList( marginBottom={SPACING.spacing16} > + + {t('location')} + {t('labware_name')} - {t('initial_location')} + {t('placement')} - {onDeckItems.map((labwareItem, index) => ( - - ))} + {onDeckItems.map((labwareItem, index) => { + const labwareOnAdapter = onDeckItems.find( + item => + labwareItem.initialLocation !== 'offDeck' && + 'labwareId' in labwareItem.initialLocation && + item.labwareId === labwareItem.initialLocation.labwareId + ) + return labwareOnAdapter != null ? null : ( + + ) + })} { } as any) as ModuleRenderInfoForProtocol, }, isOt3: false, + nestedLabwareInfo: null, }) getByText('Mock Labware Definition') - getByText('Slot 7,8,10,11, Thermocycler Module GEN1') + getByText('nickName') + getByText('Thermocycler Module GEN1') + getByText('7,8,10,11') const button = getByText('Secure labware instructions') fireEvent.click(button) getByText('mock secure labware modal') @@ -136,13 +139,15 @@ describe('LabwareListItem', () => { } as any) as ModuleRenderInfoForProtocol, }, isOt3: true, + nestedLabwareInfo: null, }) getByText('Mock Labware Definition') - getByText('Slot A1+B1, Thermocycler Module GEN1') + getByText('A1+B1') + getByText('Thermocycler Module GEN1') }) it('renders the correct info for a labware on top of a magnetic module', () => { - const { getByText } = render({ + const { getByText, getByTestId } = render({ commands: [], nickName: mockNickName, definition: mockLabwareDef, @@ -165,9 +170,11 @@ describe('LabwareListItem', () => { } as any) as ModuleRenderInfoForProtocol, }, isOt3: false, + nestedLabwareInfo: null, }) getByText('Mock Labware Definition') - getByText('Slot 7, Magnetic Module GEN1') + getByTestId('slot_info_7') + getByText('Magnetic Module GEN1') const button = getByText('Secure labware instructions') fireEvent.click(button) getByText('mock secure labware modal') @@ -175,7 +182,7 @@ describe('LabwareListItem', () => { }) it('renders the correct info for a labware on top of a temperature module', () => { - const { getByText } = render({ + const { getByText, getByTestId } = render({ commands: [], nickName: mockNickName, definition: mockLabwareDef, @@ -197,9 +204,11 @@ describe('LabwareListItem', () => { } as any) as ModuleRenderInfoForProtocol, }, isOt3: false, + nestedLabwareInfo: null, }) getByText('Mock Labware Definition') - getByText('Slot 7, Temperature Module GEN1') + getByTestId('slot_info_7') + getByText('Temperature Module GEN1') getByText('nickName') }) @@ -223,7 +232,7 @@ describe('LabwareListItem', () => { commandType: 'loadModule', params: { moduleId: mockModuleId, - location: { slotName: 7 }, + location: { slotName: '7' }, model: 'temperatureModuleV2', }, result: { @@ -231,7 +240,7 @@ describe('LabwareListItem', () => { }, } as any - const { getByText } = render({ + const { getByText, getAllByText } = render({ commands: [mockAdapterLoadCommand, mockModuleLoadCommand], nickName: mockNickName, definition: mockLabwareDef, @@ -253,9 +262,18 @@ describe('LabwareListItem', () => { } as any) as ModuleRenderInfoForProtocol, }, isOt3: false, + nestedLabwareInfo: { + nestedLabwareDisplayName: 'mock nested display name', + sharedSlotId: '7', + nestedLabwareNickName: 'nestedLabwareNickName', + nestedLabwareDefinition: mockLabwareDef, + }, }) getByText('Mock Labware Definition') - getByText('Slot 7, Opentrons 96 PCR Adapter on Temperature Module GEN2') + getAllByText('7') + getByText('Temperature Module GEN2') + getByText('mock nested display name') + getByText('nestedLabwareNickName') getByText('nickName') }) @@ -286,14 +304,23 @@ describe('LabwareListItem', () => { extraAttentionModules: [], attachedModuleInfo: {}, isOt3: false, + nestedLabwareInfo: { + nestedLabwareDisplayName: 'mock nested display name', + sharedSlotId: 'A2', + nestedLabwareNickName: 'nestedLabwareNickName', + nestedLabwareDefinition: mockLabwareDef, + }, }) getByText('Mock Labware Definition') - getByText('Slot A2, Opentrons 96 PCR Adapter') + getByText('A2') + getByText('mock nested display name') + getByText('nestedLabwareNickName') getByText('nickName') + getByText('On deck') }) it('renders the correct info for a labware on top of a heater shaker', () => { - const { getByText, getByLabelText } = render({ + const { getByText, getByLabelText, getByTestId } = render({ nickName: mockNickName, commands: [], definition: mockLabwareDef, @@ -315,9 +342,11 @@ describe('LabwareListItem', () => { } as any) as ModuleRenderInfoForProtocol, }, isOt3: false, + nestedLabwareInfo: null, }) getByText('Mock Labware Definition') - getByText('Slot 7, Heater-Shaker Module GEN1') + getByTestId('slot_info_7') + getByText('Heater-Shaker Module GEN1') getByText('nickName') getByText('To add labware, use the toggle to control the latch') getByText('Labware Latch') @@ -345,7 +374,9 @@ describe('LabwareListItem', () => { extraAttentionModules: [], attachedModuleInfo: {}, isOt3: false, + nestedLabwareInfo: null, }) getByText('Mock Labware Definition') + getByText('Off deck') }) }) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabwareList.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabwareList.test.tsx index f0fbe326367..c4a14f0585e 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabwareList.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/SetupLabwareList.test.tsx @@ -183,7 +183,8 @@ describe('SetupLabwareList', () => { getAllByText('mock labware list item') getByText('Labware Name') - getByText('Initial Location') + getByText('Location') + getByText('Placement') }) it('renders null for the offdeck labware list when there are none', () => { const { queryByText } = render({ diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/getNestedLabwareInfo.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/getNestedLabwareInfo.test.tsx new file mode 100644 index 00000000000..0ba0fffdcf5 --- /dev/null +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/__tests__/getNestedLabwareInfo.test.tsx @@ -0,0 +1,117 @@ +import { mockDefinition } from '../../../../../redux/custom-labware/__fixtures__' +import { getNestedLabwareInfo } from '../getNestedLabwareInfo' +import type { RunTimeCommand } from '@opentrons/shared-data' +import type { LabwareSetupItem } from '../../../../../pages/Protocols/utils' + +const MOCK_LABWARE_ID = 'mockLabwareId' +const MOCK_OTHER_LABWARE_ID = 'mockOtherLabwareId' +const MOCK_NICK_NAME = 'mockNickName' +const MOCK_MODULE_ID = 'mockModId' +const SLOT_NAME = 'A1' +describe('getNestedLabwareInfo', () => { + it('should return null if the nestedLabware is null', () => { + const mockLabwareSetupItem = { + definition: mockDefinition, + nickName: null, + initialLocation: { slotName: SLOT_NAME }, + moduleModel: null, + labwareId: MOCK_LABWARE_ID, + } as LabwareSetupItem + const mockCommands = [ + { + id: '0abc3', + commandType: 'loadLabware', + params: { + labwareId: MOCK_OTHER_LABWARE_ID, + location: { + slotName: '1', + }, + }, + result: { + labwareId: MOCK_OTHER_LABWARE_ID, + definition: mockDefinition, + }, + }, + ] as RunTimeCommand[] + + expect(getNestedLabwareInfo(mockLabwareSetupItem, mockCommands)).toBe(null) + }) + + it('should return nested labware info if nested labware is not null and adapter is on deck', () => { + const mockLabwareSetupItem = { + definition: mockDefinition, + nickName: null, + initialLocation: { slotName: SLOT_NAME }, + moduleModel: null, + labwareId: MOCK_LABWARE_ID, + } as LabwareSetupItem + const mockCommands = [ + { + id: '0abc3', + commandType: 'loadLabware', + params: { + labwareId: MOCK_OTHER_LABWARE_ID, + location: { + labwareId: MOCK_LABWARE_ID, + }, + displayName: MOCK_NICK_NAME, + }, + result: { + labwareId: MOCK_OTHER_LABWARE_ID, + definition: mockDefinition, + }, + }, + ] as RunTimeCommand[] + + expect(getNestedLabwareInfo(mockLabwareSetupItem, mockCommands)).toEqual({ + nestedLabwareDefinition: mockDefinition, + nestedLabwareDisplayName: 'Mock Definition', + nestedLabwareNickName: MOCK_NICK_NAME, + sharedSlotId: SLOT_NAME, + }) + }) + + it('should return nested labware info if nested labware is not null and adapter is on a module', () => { + const mockLabwareSetupItem = { + definition: mockDefinition, + nickName: null, + initialLocation: { moduleId: MOCK_MODULE_ID }, + moduleModel: null, + labwareId: MOCK_LABWARE_ID, + } as LabwareSetupItem + const mockCommands = [ + { + id: '0abc3', + commandType: 'loadLabware', + params: { + labwareId: MOCK_OTHER_LABWARE_ID, + location: { + labwareId: MOCK_LABWARE_ID, + }, + displayName: MOCK_NICK_NAME, + }, + result: { + labwareId: MOCK_OTHER_LABWARE_ID, + definition: mockDefinition, + }, + }, + { + id: '123', + commandType: 'loadModule', + params: { + location: { slotName: SLOT_NAME }, + }, + result: { + moduleId: MOCK_MODULE_ID, + }, + }, + ] as RunTimeCommand[] + + expect(getNestedLabwareInfo(mockLabwareSetupItem, mockCommands)).toEqual({ + nestedLabwareDefinition: mockDefinition, + nestedLabwareDisplayName: 'Mock Definition', + nestedLabwareNickName: MOCK_NICK_NAME, + sharedSlotId: SLOT_NAME, + }) + }) +}) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabware/getNestedLabwareInfo.ts b/app/src/organisms/Devices/ProtocolRun/SetupLabware/getNestedLabwareInfo.ts new file mode 100644 index 00000000000..46f9d643123 --- /dev/null +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabware/getNestedLabwareInfo.ts @@ -0,0 +1,55 @@ +import type { + LabwareDefinition2, + LoadLabwareRunTimeCommand, + LoadModuleRunTimeCommand, + RunTimeCommand, +} from '@opentrons/shared-data' +import type { LabwareSetupItem } from '../../../../pages/Protocols/utils' + +export interface NestedLabwareInfo { + nestedLabwareDisplayName: string + // shared location between labware and adapter + sharedSlotId: string + nestedLabwareDefinition?: LabwareDefinition2 + nestedLabwareNickName?: string +} +export function getNestedLabwareInfo( + labwareSetupItem: LabwareSetupItem, + commands: RunTimeCommand[] +): NestedLabwareInfo | null { + const nestedLabware = commands.find( + (command): command is LoadLabwareRunTimeCommand => + command.commandType === 'loadLabware' && + command.params.location !== 'offDeck' && + 'labwareId' in command.params.location && + command.params.location.labwareId === labwareSetupItem.labwareId + ) + if (nestedLabware == null) return null + + let sharedSlotId: string = '' + if (labwareSetupItem.initialLocation !== 'offDeck') { + const adapterLocation = labwareSetupItem.initialLocation + if ('slotName' in adapterLocation) { + sharedSlotId = adapterLocation.slotName + } else if ('moduleId' in adapterLocation) { + const moduleLocationUnderAdapter = commands.find( + (command): command is LoadModuleRunTimeCommand => + command.commandType === 'loadModule' && + command.result?.moduleId === adapterLocation.moduleId + ) + sharedSlotId = moduleLocationUnderAdapter?.params.location.slotName ?? '' + } + } + return { + nestedLabwareDefinition: nestedLabware.result?.definition ?? undefined, + nestedLabwareDisplayName: + nestedLabware.result?.definition.metadata.displayName ?? '', + nestedLabwareNickName: + // only display nickName if the user defined it + nestedLabware.params.displayName !== + nestedLabware.result?.definition.metadata.displayName + ? nestedLabware.params.displayName + : undefined, + sharedSlotId, + } +} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx index 038db0c26c7..65ce62c90e1 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLabwarePositionCheck/index.tsx @@ -7,11 +7,11 @@ import { DIRECTION_COLUMN, ALIGN_CENTER, TYPOGRAPHY, - Link, TOOLTIP_LEFT, useHoverTooltip, SecondaryButton, PrimaryButton, + COLORS, } from '@opentrons/components' import { useRunQuery } from '@opentrons/react-api-client' import { useMostRecentCompletedAnalysis } from '../../../LabwarePositionCheck/useMostRecentCompletedAnalysis' @@ -20,7 +20,6 @@ import { Tooltip } from '../../../../atoms/Tooltip' import { useLPCDisabledReason, useStoredProtocolAnalysis } from '../../hooks' import { CurrentOffsetsTable } from './CurrentOffsetsTable' import { useLaunchLPC } from '../../../LabwarePositionCheck/useLaunchLPC' -import { HowLPCWorksModal } from './HowLPCWorksModal' import { StyledText } from '../../../../atoms/text' interface SetupLabwarePositionCheckProps { @@ -33,7 +32,7 @@ export function SetupLabwarePositionCheck( props: SetupLabwarePositionCheckProps ): JSX.Element { const { robotName, runId, expandLabwareStep } = props - const { t } = useTranslation('protocol_setup') + const { t, i18n } = useTranslation('protocol_setup') const { data: runRecord } = useRunQuery(runId, { staleTime: Infinity }) const currentOffsets = runRecord?.data?.labwareOffsets ?? [] @@ -41,7 +40,6 @@ export function SetupLabwarePositionCheck( const robotProtocolAnalysis = useMostRecentCompletedAnalysis(runId) const storedProtocolAnalysis = useStoredProtocolAnalysis(runId) const protocolData = robotProtocolAnalysis ?? storedProtocolAnalysis - const [showHelpModal, setShowHelpModal] = React.useState(false) const [targetProps, tooltipProps] = useHoverTooltip({ placement: TOOLTIP_LEFT, }) @@ -56,18 +54,27 @@ export function SetupLabwarePositionCheck( marginTop={SPACING.spacing16} gridGap={SPACING.spacing16} > - - setShowHelpModal(true)} + {currentOffsets.length > 0 ? ( + + ) : ( + - {t('learn_how_it_works')} - + + {i18n.format(t('no_labware_offset_data'), 'capitalize')} + + + )} + {lpcDisabledReason} ) : null} - - {currentOffsets.length > 0 ? ( - - ) : ( - - {t('no_labware_offset_data')} - - )} - - {LPCWizard} - {showHelpModal ? ( - setShowHelpModal(false)} /> - ) : null} ) } diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/LiquidsLabwareDetailsModal.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/LiquidsLabwareDetailsModal.tsx index caffe79fac1..b53936add28 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/LiquidsLabwareDetailsModal.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/LiquidsLabwareDetailsModal.tsx @@ -21,7 +21,7 @@ import { getIsOnDevice } from '../../../../redux/config' import { useMostRecentCompletedAnalysis } from '../../../LabwarePositionCheck/useMostRecentCompletedAnalysis' import { LegacyModal } from '../../../../molecules/LegacyModal' import { StyledText } from '../../../../atoms/text' -import { getSlotLabwareName } from '../utils/getSlotLabwareName' +import { getLocationInfoNames } from '../utils/getLocationInfoNames' import { getSlotLabwareDefinition } from '../utils/getSlotLabwareDefinition' import { LiquidDetailCard } from './LiquidDetailCard' import { @@ -58,7 +58,7 @@ export const LiquidsLabwareDetailsModal = ( labwareByLiquidId ) const labwareInfo = getLiquidsByIdForLabware(labwareId, labwareByLiquidId) - const { slotName, labwareName } = getSlotLabwareName(labwareId, commands) + const { slotName, labwareName } = getLocationInfoNames(labwareId, commands) const loadLabwareCommand = commands ?.filter(command => command.commandType === 'loadLabware') ?.find(command => command.result?.labwareId === labwareId) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/SetupLiquidsList.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/SetupLiquidsList.tsx index e41ff9259db..8538ec7ae10 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/SetupLiquidsList.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/SetupLiquidsList.tsx @@ -22,7 +22,7 @@ import { Box, JUSTIFY_FLEX_START, } from '@opentrons/components' -import { MICRO_LITERS } from '@opentrons/shared-data' +import { getModuleDisplayName, MICRO_LITERS } from '@opentrons/shared-data' import { useTrackEvent, ANALYTICS_EXPAND_LIQUID_SETUP_ROW, @@ -30,7 +30,7 @@ import { } from '../../../../redux/analytics' import { useMostRecentCompletedAnalysis } from '../../../LabwarePositionCheck/useMostRecentCompletedAnalysis' import { StyledText } from '../../../../atoms/text' -import { getSlotLabwareName } from '../utils/getSlotLabwareName' +import { getLocationInfoNames } from '../utils/getLocationInfoNames' import { LiquidsLabwareDetailsModal } from './LiquidsLabwareDetailsModal' import { getTotalVolumePerLiquidId, @@ -185,11 +185,12 @@ export function LiquidsListItem(props: LiquidsListItemProps): JSX.Element { {labwareByLiquidId[liquidId].map((labware, index) => { - // TODO: (jr, 8/16/23): show adapter and module name here - const { slotName, labwareName } = getSlotLabwareName( - labware.labwareId, - commands - ) + const { + slotName, + labwareName, + adapterName, + moduleModel, + } = getLocationInfoNames(labware.labwareId, commands) const handleLiquidDetailsLabwareId = (): void => { setLiquidDetailsLabwareId(labware.labwareId) trackEvent({ @@ -213,23 +214,46 @@ export function LiquidsListItem(props: LiquidsListItemProps): JSX.Element { justifyContent={JUSTIFY_FLEX_START} gridGap={SPACING.spacing16} > - - {t('slot_location', { - slotName: slotName, - })} - - - {labwareName} - + + + {slotName} + + + + + {labwareName} + + {adapterName != null ? ( + + {moduleModel != null + ? t('on_adapter_in_mod', { + adapterName: adapterName, + moduleName: getModuleDisplayName(moduleModel), + }) + : t('on_adapter', { + adapterName: adapterName, + })} + + ) : null} + {getTotalVolumePerLiquidLabwarePair( liquidId, diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/LiquidsLabwareDetailsModal.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/LiquidsLabwareDetailsModal.test.tsx index 36abc4bc154..ecd308f2970 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/LiquidsLabwareDetailsModal.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/LiquidsLabwareDetailsModal.test.tsx @@ -12,7 +12,7 @@ import { getIsOnDevice } from '../../../../../redux/config' import { useLabwareRenderInfoForRunById } from '../../../../Devices/hooks' import { useMostRecentCompletedAnalysis } from '../../../../LabwarePositionCheck/useMostRecentCompletedAnalysis' import { mockDefinition } from '../../../../../redux/custom-labware/__fixtures__' -import { getSlotLabwareName } from '../../utils/getSlotLabwareName' +import { getLocationInfoNames } from '../../utils/getLocationInfoNames' import { getSlotLabwareDefinition } from '../../utils/getSlotLabwareDefinition' import { getLiquidsByIdForLabware, getWellFillFromLabwareId } from '../utils' import { LiquidsLabwareDetailsModal } from '../LiquidsLabwareDetailsModal' @@ -31,7 +31,7 @@ jest.mock('@opentrons/api-client') jest.mock('../../../../../redux/config') jest.mock('../../../../LabwarePositionCheck/useMostRecentCompletedAnalysis') jest.mock('../../../../Devices/hooks') -jest.mock('../../utils/getSlotLabwareName') +jest.mock('../../utils/getLocationInfoNames') jest.mock('../../utils/getSlotLabwareDefinition') jest.mock('../utils') jest.mock('../LiquidDetailCard') @@ -39,8 +39,8 @@ jest.mock('../LiquidDetailCard') const mockLiquidDetailCard = LiquidDetailCard as jest.MockedFunction< typeof LiquidDetailCard > -const mockGetSlotLabwareName = getSlotLabwareName as jest.MockedFunction< - typeof getSlotLabwareName +const mockGetLocationInfoNames = getLocationInfoNames as jest.MockedFunction< + typeof getLocationInfoNames > const mockGetSlotLabwareDefinition = getSlotLabwareDefinition as jest.MockedFunction< typeof getSlotLabwareDefinition @@ -84,7 +84,7 @@ describe('LiquidsLabwareDetailsModal', () => { runId: '456', closeModal: jest.fn(), } - mockGetSlotLabwareName.mockReturnValue({ + mockGetLocationInfoNames.mockReturnValue({ labwareName: 'mock labware name', slotName: '5', }) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquidsList.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquidsList.test.tsx index d1fda805370..bdefb155b5d 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquidsList.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupLiquids/__tests__/SetupLiquidsList.test.tsx @@ -16,7 +16,7 @@ import { ANALYTICS_EXPAND_LIQUID_SETUP_ROW, ANALYTICS_OPEN_LIQUID_LABWARE_DETAIL_MODAL, } from '../../../../../redux/analytics' -import { getSlotLabwareName } from '../../utils/getSlotLabwareName' +import { getLocationInfoNames } from '../../utils/getLocationInfoNames' import { SetupLiquidsList } from '../SetupLiquidsList' import { getTotalVolumePerLiquidId, @@ -52,7 +52,7 @@ const MOCK_LABWARE_INFO_BY_LIQUID_ID = { } jest.mock('../utils') -jest.mock('../../utils/getSlotLabwareName') +jest.mock('../../utils/getLocationInfoNames') jest.mock('../LiquidsLabwareDetailsModal') jest.mock('@opentrons/api-client') jest.mock('../../../../../redux/analytics') @@ -66,8 +66,8 @@ const mockGetTotalVolumePerLiquidId = getTotalVolumePerLiquidId as jest.MockedFu const mockGetTotalVolumePerLiquidLabwarePair = getTotalVolumePerLiquidLabwarePair as jest.MockedFunction< typeof getTotalVolumePerLiquidLabwarePair > -const mockGetSlotLabwareName = getSlotLabwareName as jest.MockedFunction< - typeof getSlotLabwareName +const mockGetLocationInfoNames = getLocationInfoNames as jest.MockedFunction< + typeof getLocationInfoNames > const mockParseLiquidsInLoadOrder = parseLiquidsInLoadOrder as jest.MockedFunction< typeof parseLiquidsInLoadOrder @@ -92,7 +92,7 @@ describe('SetupLiquidsList', () => { props = { runId: '123' } mockGetTotalVolumePerLiquidId.mockReturnValue(400) mockGetTotalVolumePerLiquidLabwarePair.mockReturnValue(200) - mockGetSlotLabwareName.mockReturnValue({ + mockGetLocationInfoNames.mockReturnValue({ labwareName: 'mock labware name', slotName: '4', }) @@ -130,7 +130,7 @@ describe('SetupLiquidsList', () => { getByText('Labware Name') getByText('Volume') getAllByText(nestedTextMatcher('200 µL')) - getByText('Slot 4') + getByText('4') getByText('mock labware name') }) diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModules/SetupModulesList.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModules/SetupModulesList.tsx index a4e5ab03ee5..81e4412c001 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModules/SetupModulesList.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModules/SetupModulesList.tsx @@ -15,6 +15,8 @@ import { JUSTIFY_SPACE_BETWEEN, SPACING, TYPOGRAPHY, + useHoverTooltip, + TOOLTIP_LEFT, } from '@opentrons/components' import { getModuleType, @@ -28,6 +30,7 @@ import { Banner } from '../../../../atoms/Banner' import { StyledText } from '../../../../atoms/text' import { StatusLabel } from '../../../../atoms/StatusLabel' import { TertiaryButton } from '../../../../atoms/buttons' +import { Tooltip } from '../../../../atoms/Tooltip' import { UnMatchedModuleWarning } from './UnMatchedModuleWarning' import { MultipleModulesModal } from './MultipleModulesModal' import { @@ -35,13 +38,15 @@ import { useIsOT3, useModuleRenderInfoForProtocolById, useUnmatchedModulesForProtocol, + useRunCalibrationStatus, } from '../../hooks' -import { HeaterShakerWizard } from '../../HeaterShakerWizard' +import { ModuleSetupModal } from '../../../ModuleCard/ModuleSetupModal' import { ModuleWizardFlows } from '../../../ModuleWizardFlows' import { getModuleImage } from './utils' import type { ModuleModel } from '@opentrons/shared-data' import type { AttachedModule } from '../../../../redux/modules/types' +import type { ProtocolCalibrationStatus } from '../../hooks' interface SetupModulesListProps { robotName: string @@ -62,6 +67,8 @@ export const SetupModulesList = (props: SetupModulesListProps): JSX.Element => { const isOt3 = useIsOT3(robotName) + const calibrationStatus = useRunCalibrationStatus(robotName, runId) + const [ showMultipleModulesModal, setShowMultipleModulesModal, @@ -122,14 +129,12 @@ export const SetupModulesList = (props: SetupModulesListProps): JSX.Element => { {t('module_name')} @@ -137,7 +142,6 @@ export const SetupModulesList = (props: SetupModulesListProps): JSX.Element => { @@ -148,7 +152,6 @@ export const SetupModulesList = (props: SetupModulesListProps): JSX.Element => { flexDirection={DIRECTION_COLUMN} width="100%" overflowY="auto" - data-testid="SetupModulesList_ListView" gridGap={SPACING.spacing4} marginBottom={SPACING.spacing24} > @@ -162,7 +165,7 @@ export const SetupModulesList = (props: SetupModulesListProps): JSX.Element => { )}_slot_${slotName}`} moduleModel={moduleDef.model} displayName={moduleDef.displayName} - location={slotName} + slotName={slotName} attachedModuleMatch={attachedModuleMatch} heaterShakerModuleFromProtocol={ moduleRenderInfoForProtocolById[moduleId].moduleDef @@ -171,6 +174,7 @@ export const SetupModulesList = (props: SetupModulesListProps): JSX.Element => { : null } isOt3={isOt3} + calibrationStatus={calibrationStatus} /> ) } @@ -183,19 +187,21 @@ export const SetupModulesList = (props: SetupModulesListProps): JSX.Element => { interface ModulesListItemProps { moduleModel: ModuleModel displayName: string - location: string + slotName: string attachedModuleMatch: AttachedModule | null heaterShakerModuleFromProtocol: ModuleRenderInfoForProtocol | null isOt3: boolean + calibrationStatus: ProtocolCalibrationStatus } export function ModulesListItem({ moduleModel, displayName, - location, + slotName, attachedModuleMatch, heaterShakerModuleFromProtocol, isOt3, + calibrationStatus, }: ModulesListItemProps): JSX.Element { const { t } = useTranslation('protocol_setup') const moduleConnectionStatus = @@ -203,15 +209,14 @@ export function ModulesListItem({ ? t('module_connected') : t('module_not_connected') const [ - showHeaterShakerFlow, - setShowHeaterShakerFlow, + showModuleSetupModal, + setShowModuleSetupModal, ] = React.useState(false) - const heaterShakerAttachedModule = - attachedModuleMatch != null && - attachedModuleMatch.moduleType === HEATERSHAKER_MODULE_TYPE - ? attachedModuleMatch - : null const [showModuleWizard, setShowModuleWizard] = React.useState(false) + const [targetProps, tooltipProps] = useHoverTooltip({ + placement: TOOLTIP_LEFT, + }) + let subText: JSX.Element | null = null if (moduleModel === HEATERSHAKER_MODULE_V1) { subText = ( @@ -225,7 +230,7 @@ export function ModulesListItem({ } `} marginTop={SPACING.spacing4} - onClick={() => setShowHeaterShakerFlow(true)} + onClick={() => setShowModuleSetupModal(true)} > { - const handleCalibrate = (): void => { - setShowModuleWizard(true) - } - if (attachedModuleMatch == null) { - return ( - - ) - } else if (attachedModuleMatch.moduleOffset?.last_modified != null) { - return ( - - ) - } else { - return ( - - {t('calibrate_now')} - - ) - } + let renderModuleStatus: JSX.Element = ( + <> + setShowModuleWizard(true)} + disabled={!calibrationStatus?.complete} + > + {t('calibrate_now')} + + {!calibrationStatus?.complete && calibrationStatus?.reason != null ? ( + + {calibrationStatus.reason === 'attach_pipette_failure_reason' + ? t('attach_pipette_before_module_calibration') + : t('calibrate_pipette_before_module_calibration')} + + ) : null} + + ) + + if (attachedModuleMatch == null) { + renderModuleStatus = ( + + ) + } else if (attachedModuleMatch.moduleOffset?.last_modified != null) { + renderModuleStatus = ( + + ) } return ( @@ -294,6 +305,7 @@ export function ModulesListItem({ setShowModuleWizard(false)} + initialSlotName={slotName} /> ) : null} - {showHeaterShakerFlow && heaterShakerModuleFromProtocol != null ? ( - setShowHeaterShakerFlow(false)} - moduleFromProtocol={heaterShakerModuleFromProtocol} - attachedModule={heaterShakerAttachedModule} + {showModuleSetupModal && heaterShakerModuleFromProtocol != null ? ( + setShowModuleSetupModal(false)} + moduleDisplayName={ + heaterShakerModuleFromProtocol.moduleDef.displayName + } /> ) : null} {moduleModel === MAGNETIC_BLOCK_V1 ? ( {t('n_a')} ) : ( - + renderModuleStatus )} diff --git a/app/src/organisms/Devices/ProtocolRun/SetupModules/__tests__/SetupModulesList.test.tsx b/app/src/organisms/Devices/ProtocolRun/SetupModules/__tests__/SetupModulesList.test.tsx index c193952d4bc..e450af4d2ea 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupModules/__tests__/SetupModulesList.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupModules/__tests__/SetupModulesList.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { when, resetAllWhenMocks } from 'jest-when' import { fireEvent } from '@testing-library/react' -import { COLORS, renderWithProviders } from '@opentrons/components' +import { renderWithProviders } from '@opentrons/components' import { i18n } from '../../../../../i18n' import { mockMagneticModule as mockMagneticModuleFixture, @@ -19,8 +19,9 @@ import { useModuleRenderInfoForProtocolById, useRunHasStarted, useUnmatchedModulesForProtocol, + useRunCalibrationStatus, } from '../../../hooks' -import { HeaterShakerWizard } from '../../../HeaterShakerWizard' +import { ModuleSetupModal } from '../../../../ModuleCard/ModuleSetupModal' import { ModuleWizardFlows } from '../../../../ModuleWizardFlows' import { SetupModulesList } from '../SetupModulesList' @@ -28,7 +29,7 @@ import type { ModuleModel, ModuleType } from '@opentrons/shared-data' jest.mock('../../../hooks') jest.mock('../UnMatchedModuleWarning') -jest.mock('../../../HeaterShakerWizard') +jest.mock('../../../../ModuleCard/ModuleSetupModal') jest.mock('../../../../ModuleWizardFlows') jest.mock('../MultipleModulesModal') @@ -39,8 +40,8 @@ const mockUseModuleRenderInfoForProtocolById = useModuleRenderInfoForProtocolByI const mockUnMatchedModuleWarning = UnMatchedModuleWarning as jest.MockedFunction< typeof UnMatchedModuleWarning > -const mockHeaterShakerWizard = HeaterShakerWizard as jest.MockedFunction< - typeof HeaterShakerWizard +const mockModuleSetupModal = ModuleSetupModal as jest.MockedFunction< + typeof ModuleSetupModal > const mockUseUnmatchedModulesForProtocol = useUnmatchedModulesForProtocol as jest.MockedFunction< typeof useUnmatchedModulesForProtocol @@ -54,6 +55,9 @@ const mockMultipleModulesModal = MultipleModulesModal as jest.MockedFunction< const mockModuleWizardFlows = ModuleWizardFlows as jest.MockedFunction< typeof ModuleWizardFlows > +const mockUseRunCalibrationStatus = useRunCalibrationStatus as jest.MockedFunction< + typeof useRunCalibrationStatus +> const ROBOT_NAME = 'otie' const RUN_ID = '1' const MOCK_MAGNETIC_MODULE_COORDS = [10, 20, 0] @@ -104,9 +108,7 @@ describe('SetupModulesList', () => { robotName: ROBOT_NAME, runId: RUN_ID, } - when(mockHeaterShakerWizard).mockReturnValue( -
mockHeaterShakerWizard
- ) + when(mockModuleSetupModal).mockReturnValue(
mockModuleSetupModal
) when(mockUnMatchedModuleWarning).mockReturnValue(
mock unmatched module Banner
) @@ -116,6 +118,11 @@ describe('SetupModulesList', () => { missingModuleIds: [], remainingAttachedModules: [], }) + when(mockUseRunCalibrationStatus) + .calledWith(ROBOT_NAME, RUN_ID) + .mockReturnValue({ + complete: true, + }) mockModuleWizardFlows.mockReturnValue(
mock ModuleWizardFlows
) }) afterEach(() => resetAllWhenMocks()) @@ -150,13 +157,10 @@ describe('SetupModulesList', () => { }, } as any) - const { getByText, getByTestId } = render(props) + const { getByText } = render(props) getByText('Magnetic Module') getByText('Slot 1') getByText('Connected') - expect(getByTestId('status_label_Connected_1')).toHaveStyle({ - backgroundColor: COLORS.successBackgroundLight, - }) }) it('should render a magnetic module that is NOT connected', () => { @@ -175,13 +179,10 @@ describe('SetupModulesList', () => { }, } as any) - const { getByText, getByTestId } = render(props) + const { getByText } = render(props) getByText('Magnetic Module') getByText('Slot 1') getByText('Not connected') - expect(getByTestId('status_label_Not connected_1')).toHaveStyle({ - backgroundColor: COLORS.warningBackgroundLight, - }) }) it('should render a thermocycler module that is connected, OT2', () => { @@ -239,13 +240,46 @@ describe('SetupModulesList', () => { } as any) mockUseIsOt3.mockReturnValue(true) - const { getByText } = render(props) + const { getByText, getByRole } = render(props) getByText('Thermocycler Module') getByText('Slot A1+B1') - getByText('Calibrate now').click() + getByRole('button', { name: 'Calibrate now' }).click() getByText('mock ModuleWizardFlows') }) + it('should render disabled button when pipette and module are not calibrated', () => { + when(mockUseUnmatchedModulesForProtocol) + .calledWith(ROBOT_NAME, RUN_ID) + .mockReturnValue({ + missingModuleIds: [], + remainingAttachedModules: [], + }) + when(mockUseRunCalibrationStatus) + .calledWith(ROBOT_NAME, RUN_ID) + .mockReturnValue({ + complete: false, + reason: 'calibrate_pipette_failure_reason', + }) + mockUseModuleRenderInfoForProtocolById.mockReturnValue({ + [mockTCModule.moduleId]: { + moduleId: mockTCModule.moduleId, + x: MOCK_TC_COORDS[0], + y: MOCK_TC_COORDS[1], + z: MOCK_TC_COORDS[2], + moduleDef: mockTCModule as any, + nestedLabwareDef: null, + nestedLabwareId: null, + protocolLoadOrder: 0, + slotName: '7', + attachedModuleMatch: mockThermocycler, + }, + } as any) + mockUseIsOt3.mockReturnValue(true) + + const { getByRole } = render(props) + expect(getByRole('button', { name: 'Calibrate now' })).toBeDisabled() + }) + it('should render a thermocycler module that is connected, OT3', () => { when(mockUseUnmatchedModulesForProtocol) .calledWith(ROBOT_NAME, RUN_ID) @@ -382,7 +416,7 @@ describe('SetupModulesList', () => { const { getByText } = render(props) const moduleSetup = getByText('View module setup instructions') fireEvent.click(moduleSetup) - getByText('mockHeaterShakerWizard') + getByText('mockModuleSetupModal') }) it('shoulde render a magnetic block', () => { mockUseModuleRenderInfoForProtocolById.mockReturnValue({ diff --git a/app/src/organisms/Devices/ProtocolRun/SetupPipetteCalibrationItem.tsx b/app/src/organisms/Devices/ProtocolRun/SetupPipetteCalibrationItem.tsx index 2f5cefab170..aa9a9383029 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupPipetteCalibrationItem.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupPipetteCalibrationItem.tsx @@ -18,21 +18,10 @@ import { JUSTIFY_FLEX_END, WRAP, } from '@opentrons/components' -import { - NINETY_SIX_CHANNEL, - SINGLE_MOUNT_PIPETTES, -} from '@opentrons/shared-data' import { TertiaryButton } from '../../../atoms/buttons' import { Banner } from '../../../atoms/Banner' import * as PipetteConstants from '../../../redux/pipettes/constants' -import { useMostRecentCompletedAnalysis } from '../../LabwarePositionCheck/useMostRecentCompletedAnalysis' -import { PipetteWizardFlows } from '../../PipetteWizardFlows' -import { FLOWS } from '../../PipetteWizardFlows/constants' -import { - useDeckCalibrationData, - useAttachedPipettesFromInstrumentsQuery, - useIsOT3, -} from '../hooks' +import { useDeckCalibrationData } from '../hooks' import { SetupCalibrationItem } from './SetupCalibrationItem' import type { Mount } from '../../../redux/pipettes/types' @@ -53,18 +42,10 @@ export function SetupPipetteCalibrationItem({ mount, robotName, runId, - instrumentsRefetch, }: SetupInstrumentCalibrationItemProps): JSX.Element | null { const { t } = useTranslation(['protocol_setup', 'devices_landing']) const deviceDetailsUrl = `/devices/${robotName}` - const [showFlexPipetteFlow, setShowFlexPipetteFlow] = React.useState( - false - ) const { isDeckCalibrated } = useDeckCalibrationData(robotName) - const attachedPipettesForFlex = useAttachedPipettesFromInstrumentsQuery() - const mostRecentAnalysis = useMostRecentCompletedAnalysis(runId) - - const isOT3 = useIsOT3(robotName) const [targetProps, tooltipProps] = useHoverTooltip({ placement: TOOLTIP_LEFT, @@ -75,9 +56,7 @@ export function SetupPipetteCalibrationItem({ let pipetteMismatchInfo if (pipetteInfo == null) return null - const pipetteCalDate = isOT3 - ? attachedPipettesForFlex[mount]?.data?.calibratedOffset?.last_modified - : pipetteInfo.pipetteCalDate + const pipetteCalDate = pipetteInfo.pipetteCalDate const attached = pipetteInfo.requestedPipetteMatch === PipetteConstants.INEXACT_MATCH || @@ -106,38 +85,22 @@ export function SetupPipetteCalibrationItem({ ) } - let flowType = '' if (pipetteCalDate != null && attached) { button = pipetteMismatchInfo } else if (!attached) { subText = t('attach_pipette_calibration') - if (isOT3) { - flowType = FLOWS.ATTACH - button = ( - - setShowFlexPipetteFlow(true)} - > - {t('attach_pipette_cta')} - - - ) - } else { - button = ( - - - {t('attach_pipette_cta')} - - - ) - } + button = ( + + + {t('attach_pipette_cta')} + + + ) } else { - flowType = FLOWS.CALIBRATE button = ( <> {pipetteMismatchInfo} - {isOT3 ? ( + setShowFlexPipetteFlow(true)} > {t('calibrate_now')} - ) : ( - - - {t('calibrate_now')} - - - )} + {!isDeckCalibrated ? ( @@ -184,30 +137,14 @@ export function SetupPipetteCalibrationItem({ const attachedCalibratedDate = pipetteCalDate ?? null return ( - <> - {showFlexPipetteFlow && ( - setShowFlexPipetteFlow(false)} - selectedPipette={ - pipetteInfo.pipetteSpecs.channels === 96 - ? NINETY_SIX_CHANNEL - : SINGLE_MOUNT_PIPETTES - } - pipetteInfo={mostRecentAnalysis?.pipettes} - onComplete={instrumentsRefetch} - /> - )} - - + ) } diff --git a/app/src/organisms/Devices/ProtocolRun/SetupStep.tsx b/app/src/organisms/Devices/ProtocolRun/SetupStep.tsx index c7808bb373b..dbf0d910c36 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupStep.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupStep.tsx @@ -1,5 +1,4 @@ import * as React from 'react' -import { useTranslation } from 'react-i18next' import { css } from 'styled-components' import { @@ -11,7 +10,6 @@ import { DIRECTION_COLUMN, DIRECTION_ROW, JUSTIFY_SPACE_BETWEEN, - SIZE_1, COLORS, SPACING, TYPOGRAPHY, @@ -20,13 +18,20 @@ import { import { StyledText } from '../../../atoms/text' interface SetupStepProps { + /** whether or not to show the full contents of the step */ expanded: boolean + /** always shown text name of the step */ title: React.ReactNode + /** always shown text that provides a one sentence explanation of the contents */ description: string + /** always shown text that sits above title of step (used for step number) */ label: string + /** callback that should toggle the expanded state (managed by parent) */ toggleExpanded: () => void + /** contents to be shown only when expanded */ children: React.ReactNode - calibrationStatusComplete: boolean | null + /** element to be shown (right aligned) regardless of expanded state */ + rightElement: React.ReactNode } const EXPANDED_STYLE = css` @@ -57,10 +62,8 @@ export function SetupStep({ label, toggleExpanded, children, - calibrationStatusComplete, + rightElement, }: SetupStepProps): JSX.Element { - const { t } = useTranslation('protocol_setup') - return ( @@ -100,34 +103,7 @@ export function SetupStep({
- {calibrationStatusComplete !== null ? ( - - - - {calibrationStatusComplete - ? t('calibration_ready') - : t('calibration_needed')} - - - ) : null} + {rightElement} { const [{ getByText }] = render() const button = getByText('Run again') - getByText('Completed') + getByText('Failed') getByText(formatTimestamp(COMPLETED_AT)) fireEvent.click(button) expect(mockTrackProtocolRunEvent).toBeCalledWith({ @@ -843,31 +837,4 @@ describe('ProtocolRunHeader', () => { getByText('Run completed.') getByLabelText('ot-spinner') }) - - it('renders banner when estop pressed - physicallyEngaged', () => { - mockEstopStatus.data.status = PHYSICALLY_ENGAGED - mockEstopStatus.data.leftEstopPhysicalStatus = ENGAGED - - mockUseEstopQuery({ data: mockEstopStatus } as any) - const [{ getByText }] = render() - getByText('Run failed.') - }) - - it('renders banner when estop pressed - logicallyEngaged', () => { - mockEstopStatus.data.status = LOGICALLY_ENGAGED - mockEstopStatus.data.leftEstopPhysicalStatus = ENGAGED - - mockUseEstopQuery({ data: mockEstopStatus } as any) - const [{ getByText }] = render() - getByText('Run failed.') - }) - - it('renders banner when estop pressed - notPresent', () => { - mockEstopStatus.data.status = NOT_PRESENT - mockEstopStatus.data.leftEstopPhysicalStatus = NOT_PRESENT - - mockUseEstopQuery({ data: mockEstopStatus } as any) - const [{ getByText }] = render() - getByText('Run failed.') - }) }) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunModuleControls.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunModuleControls.test.tsx index f9385fa76e6..b4d2be4862f 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunModuleControls.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/ProtocolRunModuleControls.test.tsx @@ -1,15 +1,13 @@ import * as React from 'react' import { resetAllWhenMocks, when } from 'jest-when' import { i18n } from '../../../../i18n' -import { - componentPropsMatcher, - renderWithProviders, -} from '@opentrons/components' +import { renderWithProviders } from '@opentrons/components' import { CompletedProtocolAnalysis, ModuleModel, ModuleType, } from '@opentrons/shared-data' +import { useInstrumentsQuery } from '@opentrons/react-api-client' import { ProtocolRunModuleControls } from '../ProtocolRunModuleControls' import { ModuleCard } from '../../../ModuleCard' import { @@ -24,6 +22,7 @@ import { } from '../../../../redux/modules/__fixtures__' import fixtureAnalysis from '../../../../organisms/RunDetails/__fixtures__/analysis.json' +jest.mock('@opentrons/react-api-client') jest.mock('../../../ModuleCard') jest.mock('../../hooks') @@ -34,6 +33,9 @@ const mockUseModuleRenderInfoForProtocolById = useModuleRenderInfoForProtocolByI const mockUseProtocolDetailsForRun = useProtocolDetailsForRun as jest.MockedFunction< typeof useProtocolDetailsForRun > +const mockUseInstrumentsQuery = useInstrumentsQuery as jest.MockedFunction< + typeof useInstrumentsQuery +> const _fixtureAnalysis = (fixtureAnalysis as unknown) as CompletedProtocolAnalysis @@ -83,6 +85,9 @@ describe('ProtocolRunModuleControls', () => { protocolKey: 'fakeProtocolKey', robotType: 'OT-2 Standard', }) + when(mockUseInstrumentsQuery).mockReturnValue({ + data: { data: [] }, + } as any) }) afterEach(() => { @@ -106,16 +111,7 @@ describe('ProtocolRunModuleControls', () => { attachedModuleMatch: mockMagneticModuleGen2, }, } as any) - when(mockModuleCard) - .calledWith( - componentPropsMatcher({ - robotName: 'otie', - module: mockMagneticModuleGen2, - runId: 'test123', - isLoadedInRun: true, // this can never be false in this component since isModuleControl true is hardcoded in - }) - ) - .mockReturnValue(
mock Magnetic Module Card
) + when(mockModuleCard).mockReturnValue(
mock Magnetic Module Card
) const { getByText } = render({ robotName: 'otie', runId: 'test123', @@ -140,16 +136,9 @@ describe('ProtocolRunModuleControls', () => { attachedModuleMatch: mockTemperatureModuleGen2, }, } as any) - when(mockModuleCard) - .calledWith( - componentPropsMatcher({ - robotName: 'otie', - module: mockTemperatureModuleGen2, - runId: 'test123', - isLoadedInRun: true, - }) - ) - .mockReturnValue(
mock Temperature Module Card
) + when(mockModuleCard).mockReturnValue( +
mock Temperature Module Card
+ ) const { getByText } = render({ robotName: 'otie', runId: 'test123', @@ -175,16 +164,9 @@ describe('ProtocolRunModuleControls', () => { }, } as any) - when(mockModuleCard) - .calledWith( - componentPropsMatcher({ - robotName: 'otie', - module: mockThermocycler, - runId: 'test123', - isLoadedInRun: true, - }) - ) - .mockReturnValue(
mock Thermocycler Module Card
) + when(mockModuleCard).mockReturnValue( +
mock Thermocycler Module Card
+ ) const { getByText } = render({ robotName: 'otie', @@ -210,16 +192,9 @@ describe('ProtocolRunModuleControls', () => { attachedModuleMatch: mockHeaterShaker, }, } as any) - when(mockModuleCard) - .calledWith( - componentPropsMatcher({ - robotName: 'otie', - module: mockHeaterShaker, - runId: 'test123', - isLoadedInRun: true, - }) - ) - .mockReturnValue(
mock Heater-Shaker Module Card
) + when(mockModuleCard).mockReturnValue( +
mock Heater-Shaker Module Card
+ ) const { getByText } = render({ robotName: 'otie', diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/SetupFlexPipetteCalibrationItem.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/SetupFlexPipetteCalibrationItem.test.tsx new file mode 100644 index 00000000000..a59e8622b74 --- /dev/null +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/SetupFlexPipetteCalibrationItem.test.tsx @@ -0,0 +1,135 @@ +import * as React from 'react' +import { resetAllWhenMocks } from 'jest-when' +import { MemoryRouter } from 'react-router-dom' +import { fireEvent } from '@testing-library/dom' + +import { renderWithProviders } from '@opentrons/components' +import { useInstrumentsQuery } from '@opentrons/react-api-client' + +import { i18n } from '../../../../i18n' +import { useMostRecentCompletedAnalysis } from '../../../LabwarePositionCheck/useMostRecentCompletedAnalysis' +import { PipetteWizardFlows } from '../../../PipetteWizardFlows' +import { SetupFlexPipetteCalibrationItem } from '../SetupFlexPipetteCalibrationItem' +import _uncastedModifiedSimpleV6Protocol from '../../hooks/__fixtures__/modifiedSimpleV6.json' +import { CompletedProtocolAnalysis } from '@opentrons/shared-data' + +jest.mock('@opentrons/react-api-client') +jest.mock('../../../PipetteWizardFlows') +jest.mock('../../../LabwarePositionCheck/useMostRecentCompletedAnalysis') +jest.mock('../../hooks') + +const mockUseInstrumentsQuery = useInstrumentsQuery as jest.MockedFunction< + typeof useInstrumentsQuery +> +const mockUseMostRecentCompletedAnalysis = useMostRecentCompletedAnalysis as jest.MockedFunction< + typeof useMostRecentCompletedAnalysis +> +const mockPipetteWizardFlows = PipetteWizardFlows as jest.MockedFunction< + typeof PipetteWizardFlows +> + +const RUN_ID = '1' +const modifiedSimpleV6Protocol = ({ + ..._uncastedModifiedSimpleV6Protocol, + pipettes: [ + { + id: 'pipetteId', + pipetteName: 'p10_single', + }, + ], +} as any) as CompletedProtocolAnalysis + +describe('SetupFlexPipetteCalibrationItem', () => { + const render = ({ + mount = 'left', + runId = RUN_ID, + }: Partial< + React.ComponentProps + > = {}) => { + return renderWithProviders( + + + , + { i18nInstance: i18n } + )[0] + } + + beforeEach(() => { + mockPipetteWizardFlows.mockReturnValue(
pipette wizard flows
) + mockUseMostRecentCompletedAnalysis.mockReturnValue(modifiedSimpleV6Protocol) + mockUseInstrumentsQuery.mockReturnValue({ + data: { + data: [], + }, + } as any) + }) + afterEach(() => { + resetAllWhenMocks() + }) + + it('renders the mount and pipette name', () => { + const { getByText } = render() + getByText('Left Mount') + getByText('P10 Single-Channel GEN1') + }) + + it('renders an attach button if on a Flex and pipette is not attached', () => { + const { getByText, getByRole } = render() + getByText('Left Mount') + getByText('P10 Single-Channel GEN1') + const attach = getByRole('button', { name: 'Attach Pipette' }) + fireEvent.click(attach) + getByText('pipette wizard flows') + }) + it('renders a calibrate button if on a Flex and pipette is not calibrated', () => { + mockUseInstrumentsQuery.mockReturnValue({ + data: { + data: [ + { + instrumentType: 'pipette', + mount: 'left', + ok: true, + firmwareVersion: 12, + instrumentName: 'p10_single', + data: {}, + } as any, + ], + }, + } as any) + const { getByText, getByRole } = render() + getByText('Left Mount') + getByText('P10 Single-Channel GEN1') + const attach = getByRole('button', { name: 'Calibrate now' }) + fireEvent.click(attach) + getByText('pipette wizard flows') + }) + it('renders calibrated text if on a Flex and pipette is calibrated', () => { + mockUseInstrumentsQuery.mockReturnValue({ + data: { + data: [ + { + instrumentType: 'pipette', + mount: 'left', + ok: true, + firmwareVersion: 12, + instrumentName: 'p10_single', + data: { + calibratedOffset: { + last_modified: 'today', + }, + }, + } as any, + ], + }, + } as any) + const { getByText } = render() + getByText('Left Mount') + getByText('P10 Single-Channel GEN1') + getByText('Last calibrated: today') + }) +}) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/SetupPipetteCalibrationItem.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/SetupPipetteCalibrationItem.test.tsx index b0e8fbb7838..5c9463d54da 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/SetupPipetteCalibrationItem.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/SetupPipetteCalibrationItem.test.tsx @@ -6,29 +6,15 @@ import { renderWithProviders } from '@opentrons/components' import { i18n } from '../../../../i18n' import { mockDeckCalData } from '../../../../redux/calibration/__fixtures__' import { mockPipetteInfo } from '../../../../redux/pipettes/__fixtures__' -import { - useDeckCalibrationData, - useAttachedPipettesFromInstrumentsQuery, - useIsOT3, -} from '../../hooks' -import { PipetteWizardFlows } from '../../../PipetteWizardFlows' +import { useDeckCalibrationData } from '../../hooks' import { SetupPipetteCalibrationItem } from '../SetupPipetteCalibrationItem' import { MemoryRouter } from 'react-router-dom' -import { fireEvent } from '@testing-library/dom' jest.mock('../../hooks') -jest.mock('../../../PipetteWizardFlows') const mockUseDeckCalibrationData = useDeckCalibrationData as jest.MockedFunction< typeof useDeckCalibrationData > -const mockUseIsOT3 = useIsOT3 as jest.MockedFunction -const mockUseAttachedPipettesFromInstrumentsQuery = useAttachedPipettesFromInstrumentsQuery as jest.MockedFunction< - typeof useAttachedPipettesFromInstrumentsQuery -> -const mockPipetteWizardFlows = PipetteWizardFlows as jest.MockedFunction< - typeof PipetteWizardFlows -> const ROBOT_NAME = 'otie' const RUN_ID = '1' @@ -57,7 +43,6 @@ describe('SetupPipetteCalibrationItem', () => { } beforeEach(() => { - mockPipetteWizardFlows.mockReturnValue(
pipette wizard flows
) when(mockUseDeckCalibrationData).calledWith(ROBOT_NAME).mockReturnValue({ deckCalibrationData: mockDeckCalData, isDeckCalibrated: true, @@ -102,74 +87,4 @@ describe('SetupPipetteCalibrationItem', () => { getByRole('link', { name: 'Learn more' }) getByText('Pipette generation mismatch.') }) - it('renders an attach button if on a Flex and pipette is not attached', () => { - mockUseIsOT3.mockReturnValue(true) - mockUseAttachedPipettesFromInstrumentsQuery.mockReturnValue({ - left: null, - right: null, - }) - const { getByText, getByRole } = render({ - pipetteInfo: { - ...mockPipetteInfo, - tipRacksForPipette: [], - requestedPipetteMatch: 'incompatible', - pipetteCalDate: null, - }, - }) - getByText('Left Mount') - getByText(mockPipetteInfo.pipetteSpecs.displayName) - const attach = getByRole('button', { name: 'Attach Pipette' }) - fireEvent.click(attach) - getByText('pipette wizard flows') - }) - it('renders a calibrate button if on a Flex and pipette is not calibrated', () => { - mockUseIsOT3.mockReturnValue(true) - mockUseAttachedPipettesFromInstrumentsQuery.mockReturnValue({ - left: { - data: { - calibratedOffset: { - last_modified: undefined, - }, - }, - } as any, - right: null, - }) - const { getByText, getByRole } = render({ - pipetteInfo: { - ...mockPipetteInfo, - tipRacksForPipette: [], - requestedPipetteMatch: 'match', - pipetteCalDate: null, - }, - }) - getByText('Left Mount') - getByText(mockPipetteInfo.pipetteSpecs.displayName) - const attach = getByRole('button', { name: 'Calibrate now' }) - fireEvent.click(attach) - getByText('pipette wizard flows') - }) - it('renders calibrated text if on a Flex and pipette is calibrated', () => { - mockUseIsOT3.mockReturnValue(true) - mockUseAttachedPipettesFromInstrumentsQuery.mockReturnValue({ - left: { - data: { - calibratedOffset: { - last_modified: 'today', - }, - }, - } as any, - right: null, - }) - const { getByText } = render({ - pipetteInfo: { - ...mockPipetteInfo, - tipRacksForPipette: [], - requestedPipetteMatch: 'match', - pipetteCalDate: null, - }, - }) - getByText('Left Mount') - getByText(mockPipetteInfo.pipetteSpecs.displayName) - getByText('Last calibrated: today') - }) }) diff --git a/app/src/organisms/Devices/ProtocolRun/__tests__/SetupStep.test.tsx b/app/src/organisms/Devices/ProtocolRun/__tests__/SetupStep.test.tsx index a8978c71b32..eaecc41a8ff 100644 --- a/app/src/organisms/Devices/ProtocolRun/__tests__/SetupStep.test.tsx +++ b/app/src/organisms/Devices/ProtocolRun/__tests__/SetupStep.test.tsx @@ -12,9 +12,9 @@ describe('SetupStep', () => { title = 'stub title', description = 'stub description', label = 'stub label', - calibrationStatusComplete = null, toggleExpanded = toggleExpandedMock, children = , + rightElement =
right element
, }: Partial> = {}) => { return renderWithProviders( { label, toggleExpanded, children, - calibrationStatusComplete, + rightElement, }} />, { i18nInstance: i18n } @@ -55,5 +55,6 @@ describe('SetupStep', () => { getByText('stub label') getByText('stub title') queryAllByText('stub description') + queryAllByText('right element') }) }) diff --git a/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLocationInfoNames.test.ts b/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLocationInfoNames.test.ts new file mode 100644 index 00000000000..1a83edf9bd1 --- /dev/null +++ b/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getLocationInfoNames.test.ts @@ -0,0 +1,170 @@ +import { getLabwareDisplayName, ModuleModel } from '@opentrons/shared-data' +import { getLocationInfoNames } from '../getLocationInfoNames' + +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 MOCK_MODEL = 'heaterShakerModuleV1' as ModuleModel +const ADAPTER_ID = + 'd9a85adf-d272-4edd-9aae-426ef5756fef:opentrons/opentrons_96_flat_bottom_adapter/1' +const LABWARE_ID = + '60e8b050-3412-11eb-ad93-ed232a2337cf:opentrons/corning_24_wellplate_3.4ml_flat/1' +const MOCK_LOAD_LABWARE_COMMANDS = [ + { + commandType: 'loadLabware', + params: { + location: { + slotName: SLOT, + }, + }, + result: { + labwareId: LABWARE_ID, + definition: {}, + }, + }, +] +const MOCK_MOD_COMMANDS = [ + { + commandType: 'loadLabware', + params: { + location: { + moduleId: '12345', + }, + }, + result: { + labwareId: LABWARE_ID, + definition: {}, + }, + }, + { + commandType: 'loadModule', + params: { + location: { + slotName: SLOT, + }, + model: MOCK_MODEL, + }, + result: { + moduleId: '12345', + }, + }, +] +const MOCK_ADAPTER_MOD_COMMANDS = [ + { + commandType: 'loadLabware', + params: { + location: { + moduleId: '12345', + }, + }, + result: { + labwareId: ADAPTER_ID, + definition: { + metadata: { displayName: ADAPTER_DISPLAY_NAME }, + }, + }, + }, + { + commandType: 'loadLabware', + params: { + location: { + labwareId: ADAPTER_ID, + }, + }, + result: { + labwareId: LABWARE_ID, + definition: {}, + }, + }, + { + commandType: 'loadModule', + params: { + location: { + slotName: SLOT, + }, + model: MOCK_MODEL, + }, + result: { + moduleId: '12345', + }, + }, +] +const MOCK_ADAPTER_COMMANDS = [ + { + commandType: 'loadLabware', + params: { + location: { + slotName: SLOT, + }, + }, + result: { + labwareId: ADAPTER_ID, + definition: { + metadata: { displayName: ADAPTER_DISPLAY_NAME }, + }, + }, + }, + { + commandType: 'loadLabware', + params: { + location: { + labwareId: ADAPTER_ID, + }, + }, + result: { + labwareId: LABWARE_ID, + definition: {}, + }, + }, +] + +jest.mock('@opentrons/shared-data') +const mockGetLabwareDisplayName = getLabwareDisplayName as jest.MockedFunction< + typeof getLabwareDisplayName +> + +describe('getLocationInfoNames', () => { + beforeEach(() => { + mockGetLabwareDisplayName.mockReturnValue(LABWARE_DISPLAY_NAME) + }) + it('returns labware name and slot number for labware id on the deck', () => { + const expected = { + slotName: SLOT, + labwareName: LABWARE_DISPLAY_NAME, + } + expect( + getLocationInfoNames(LABWARE_ID, MOCK_LOAD_LABWARE_COMMANDS as any) + ).toEqual(expected) + }) + it('returns the module slot number if the labware is on a module', () => { + const expected = { + slotName: SLOT, + labwareName: LABWARE_DISPLAY_NAME, + moduleModel: MOCK_MODEL, + } + expect(getLocationInfoNames(LABWARE_ID, MOCK_MOD_COMMANDS as any)).toEqual( + expected + ) + }) + it('returns the adapter, module, slot number if the labware is on an adapter on a module', () => { + const expected = { + slotName: SLOT, + labwareName: LABWARE_DISPLAY_NAME, + moduleModel: MOCK_MODEL, + adapterName: ADAPTER_DISPLAY_NAME, + } + expect( + getLocationInfoNames(LABWARE_ID, MOCK_ADAPTER_MOD_COMMANDS as any) + ).toEqual(expected) + }) + it('returns the adapter, slot number if the labware is on an adapter on the deck', () => { + const expected = { + slotName: SLOT, + labwareName: LABWARE_DISPLAY_NAME, + adapterName: ADAPTER_DISPLAY_NAME, + } + expect( + getLocationInfoNames(LABWARE_ID, MOCK_ADAPTER_COMMANDS as any) + ).toEqual(expected) + }) +}) diff --git a/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getSlotLabwareName.test.ts b/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getSlotLabwareName.test.ts deleted file mode 100644 index 1117cec31bf..00000000000 --- a/app/src/organisms/Devices/ProtocolRun/utils/__tests__/getSlotLabwareName.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { getLabwareDisplayName } from '@opentrons/shared-data' -import { getSlotLabwareName } from '../getSlotLabwareName' - -const LABWARE_ID = - '60e8b050-3412-11eb-ad93-ed232a2337cf:opentrons/corning_24_wellplate_3.4ml_flat/1' -const MOCK_LOAD_LABWARE_COMMANDS = [ - { - commandType: 'loadLabware', - params: { - location: { - slotName: '5', - }, - }, - result: { - labwareId: - '60e8b050-3412-11eb-ad93-ed232a2337cf:opentrons/corning_24_wellplate_3.4ml_flat/1', - definition: {}, - }, - }, -] -const MOCK_COMMANDS = [ - { - commandType: 'loadLabware', - params: { - location: { - moduleId: '12345', - }, - }, - result: { - labwareId: - '60e8b050-3412-11eb-ad93-ed232a2337cf:opentrons/corning_24_wellplate_3.4ml_flat/1', - definition: {}, - }, - }, - { - commandType: 'loadModule', - params: { - location: { - slotName: '4', - }, - }, - result: { - moduleId: '12345', - }, - }, -] - -jest.mock('@opentrons/shared-data') -const mockGetLabwareDisplayName = getLabwareDisplayName as jest.MockedFunction< - typeof getLabwareDisplayName -> - -describe('getSlotLabwareName', () => { - beforeEach(() => { - mockGetLabwareDisplayName.mockReturnValue( - 'Corning 24 Well Plate 3.4 mL Flat' - ) - }) - it('returns labware name and slot number for labware id', () => { - const expected = { - slotName: '5', - labwareName: 'Corning 24 Well Plate 3.4 mL Flat', - } - expect( - getSlotLabwareName(LABWARE_ID, MOCK_LOAD_LABWARE_COMMANDS as any) - ).toEqual(expected) - }) - it('returns the module slot number if the labware is on a module', () => { - const expected = { - slotName: '4', - labwareName: 'Corning 24 Well Plate 3.4 mL Flat', - } - expect(getSlotLabwareName(LABWARE_ID, MOCK_COMMANDS as any)).toEqual( - expected - ) - }) - it.todo('TODO(jr, 8/16/23): add test cases for adapter and module name') -}) diff --git a/app/src/organisms/Devices/ProtocolRun/utils/getSlotLabwareName.ts b/app/src/organisms/Devices/ProtocolRun/utils/getLocationInfoNames.ts similarity index 85% rename from app/src/organisms/Devices/ProtocolRun/utils/getSlotLabwareName.ts rename to app/src/organisms/Devices/ProtocolRun/utils/getLocationInfoNames.ts index 83ae1288667..ee49db3573e 100644 --- a/app/src/organisms/Devices/ProtocolRun/utils/getSlotLabwareName.ts +++ b/app/src/organisms/Devices/ProtocolRun/utils/getLocationInfoNames.ts @@ -1,24 +1,22 @@ import { getLabwareDisplayName, - getModuleDisplayName, LoadLabwareRunTimeCommand, RunTimeCommand, LoadModuleRunTimeCommand, + ModuleModel, } from '@opentrons/shared-data' -export interface SlotInfo { +export interface LocationInfoNames { slotName: string labwareName: string adapterName?: string - moduleName?: string + moduleModel?: ModuleModel } -// TODO: (jr, 8/16/23): probably need to call this a new name and should slotName and labwareName be optional? -// also TODO: refactor all instances of getSlotLabwareName to display the module and adapter name if applicable -export function getSlotLabwareName( +export function getLocationInfoNames( labwareId: string, commands?: RunTimeCommand[] -): SlotInfo { +): LocationInfoNames { const loadLabwareCommands = commands?.filter( (command): command is LoadLabwareRunTimeCommand => command.commandType === 'loadLabware' @@ -58,9 +56,7 @@ export function getSlotLabwareName( slotName: loadModuleCommandUnderLabware?.params.location.slotName ?? '', labwareName, - moduleName: getModuleDisplayName( - loadModuleCommandUnderLabware?.params.model - ), + moduleModel: loadModuleCommandUnderLabware?.params.model, } : { slotName: '', labwareName: '' } } else { @@ -100,9 +96,7 @@ export function getSlotLabwareName( labwareName, adapterName: loadedAdapterCommand.result?.definition.metadata.displayName, - moduleName: getModuleDisplayName( - loadModuleCommandUnderAdapter.params.model - ), + moduleModel: loadModuleCommandUnderAdapter.params.model, } : { slotName: '', labwareName } } else { diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/DeviceResetSlideout.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/DeviceResetSlideout.tsx index 163e0446475..46b3b188360 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/DeviceResetSlideout.tsx +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/DeviceResetSlideout.tsx @@ -21,10 +21,10 @@ import { } from '@opentrons/components' import { useAllRunsQuery } from '@opentrons/react-api-client' -import { UNREACHABLE } from '../../../../../redux/discovery' import { Slideout } from '../../../../../atoms/Slideout' import { StyledText } from '../../../../../atoms/text' import { Divider } from '../../../../../atoms/structure' +import { UNREACHABLE } from '../../../../../redux/discovery' import { getResetConfigOptions, fetchResetConfigOptions, @@ -51,6 +51,7 @@ interface DeviceResetSlideoutProps { updateResetStatus: (connected: boolean, rOptions?: ResetConfigRequest) => void } +// Note (kk:08/30/2023) lines that are related to module calibration will be activated when the be is ready export function DeviceResetSlideout({ isExpanded, onCloseClick, @@ -81,6 +82,8 @@ export function DeviceResetSlideout({ opt => opt.id === 'pipetteOffsetCalibrations' || opt.id === 'gripperOffsetCalibrations' + // || + // opt.id === 'moduleCalibrations' ) : [] diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/__tests__/DeviceResetSlideout.test.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/__tests__/DeviceResetSlideout.test.tsx index bb4891a0733..19fcf560533 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/__tests__/DeviceResetSlideout.test.tsx +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/AdvancedTabSlideouts/__tests__/DeviceResetSlideout.test.tsx @@ -111,10 +111,10 @@ describe('RobotSettings DeviceResetSlideout', () => { 'Resets all settings. You’ll have to redo initial setup before using the robot again.' ) expect(queryByText('Clear deck calibration')).toBeNull() - getByText('Clear pipette calibration(s)') + getByText('Clear pipette calibration') expect(queryByText('Clear tip length calibrations')).toBeNull() getByText('Clear gripper calibration') - getByRole('checkbox', { name: 'Clear pipette calibration(s)' }) + getByRole('checkbox', { name: 'Clear pipette calibration' }) getByRole('checkbox', { name: 'Clear gripper calibration' }) expect( queryByRole('checkbox', { name: 'Clear deck calibration' }) diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/DisableHoming.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/GantryHoming.tsx similarity index 79% rename from app/src/organisms/Devices/RobotSettings/AdvancedTab/DisableHoming.tsx rename to app/src/organisms/Devices/RobotSettings/AdvancedTab/GantryHoming.tsx index c80a0f606c7..01a669bcccd 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/DisableHoming.tsx +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/GantryHoming.tsx @@ -18,17 +18,17 @@ import { updateSetting } from '../../../../redux/robot-settings' import type { Dispatch } from '../../../../redux/types' import type { RobotSettingsField } from '../../../../redux/robot-settings/types' -interface DisableHomingProps { +interface GantryHomingProps { settings: RobotSettingsField | undefined robotName: string isRobotBusy: boolean } -export function DisableHoming({ +export function GantryHoming({ settings, robotName, isRobotBusy, -}: DisableHomingProps): JSX.Element { +}: GantryHomingProps): JSX.Element { const { t } = useTranslation('device_settings') const dispatch = useDispatch() const value = settings?.value ? settings.value : false @@ -46,17 +46,17 @@ export function DisableHoming({ - {t('disable_homing')} + {t('gantry_homing')} - {t('disable_homing_description')} + {t('gantry_homing_description')}
diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/Troubleshooting.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/Troubleshooting.tsx index 780e44cc573..526c6e30624 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/Troubleshooting.tsx +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/Troubleshooting.tsx @@ -1,7 +1,10 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import { useSelector, useDispatch } from 'react-redux' +import { saveAs } from 'file-saver' +import JSZip from 'jszip' +import last from 'lodash/last' +import { GET, request } from '@opentrons/api-client' import { Flex, ALIGN_CENTER, @@ -11,18 +14,16 @@ import { SPACING_AUTO, TYPOGRAPHY, } from '@opentrons/components' +import { useHost } from '@opentrons/react-api-client' import { StyledText } from '../../../../atoms/text' import { TertiaryButton } from '../../../../atoms/buttons' -import { INFO_TOAST } from '../../../../atoms/Toast' +import { ERROR_TOAST, INFO_TOAST } from '../../../../atoms/Toast' import { useToaster } from '../../../../organisms/ToasterOven' -import { downloadLogs } from '../../../../redux/shell/robot-logs/actions' -import { getRobotLogsDownloading } from '../../../../redux/shell/robot-logs/selectors' import { CONNECTABLE } from '../../../../redux/discovery' import { useRobot } from '../../hooks' import type { IconProps } from '@opentrons/components' -import type { Dispatch } from '../../../../redux/types' interface TroubleshootingProps { robotName: string @@ -32,33 +33,76 @@ export function Troubleshooting({ robotName, }: TroubleshootingProps): JSX.Element { const { t } = useTranslation('device_settings') - const dispatch = useDispatch() const robot = useRobot(robotName) const controlDisabled = robot?.status !== CONNECTABLE - const logsAvailable = robot?.health != null && robot.health.logs - const robotLogsDownloading = useSelector(getRobotLogsDownloading) - const [toastId, setToastId] = React.useState(null) + const logsAvailable = robot?.health != null && robot.health.logs != null + const [ + isDownloadingRobotLogs, + setIsDownloadingRobotLogs, + ] = React.useState(false) const { makeToast, eatToast } = useToaster() const toastIcon: IconProps = { name: 'ot-spinner', spin: true } + const host = useHost() + const handleClick: React.MouseEventHandler = () => { - const newToastId = makeToast(t('downloading_logs'), INFO_TOAST, { + setIsDownloadingRobotLogs(true) + const toastId = makeToast(t('downloading_logs'), INFO_TOAST, { + disableTimeout: true, icon: toastIcon, }) - setToastId(newToastId) - if (!controlDisabled && robot?.status === CONNECTABLE) - dispatch(downloadLogs(robot)) - } - React.useEffect(() => { - if (!robotLogsDownloading && toastId != null) { - eatToast(toastId) - setToastId(null) + if ( + !controlDisabled && + robot?.status === CONNECTABLE && + robot.health.logs != null && + host != null + ) { + const zip = new JSZip() + + Promise.all( + robot.health.logs.map(log => { + const logFileName: string = last(log.split('/')) ?? 'opentrons.log' + return request(GET, log, null, host) + .then(res => { + zip.file(logFileName, res.data) + }) + .catch((e: Error) => + makeToast(e?.message, ERROR_TOAST, { closeButton: true }) + ) + }) + ) + .then(() => { + zip + .generateAsync({ type: 'blob' }) + .then(blob => { + saveAs(blob, `${robotName}_logs.zip`) + }) + .catch((e: Error) => { + eatToast(toastId) + makeToast(e?.message, ERROR_TOAST, { closeButton: true }) + // avoid no-op on unmount + if (mounted.current != null) setIsDownloadingRobotLogs(false) + }) + }) + .then(() => { + eatToast(toastId) + if (mounted.current != null) setIsDownloadingRobotLogs(false) + }) + .catch((e: Error) => { + eatToast(toastId) + makeToast(e?.message, ERROR_TOAST, { closeButton: true }) + if (mounted.current != null) setIsDownloadingRobotLogs(false) + }) } - }, [robotLogsDownloading, eatToast, toastId]) + } + + // set ref on component to check if component is mounted https://react.dev/reference/react/useRef#manipulating-the-dom-with-a-ref + const mounted = React.useRef(null) return ( { return renderWithProviders( - + , { i18nInstance: i18n } ) @@ -44,10 +44,10 @@ describe('RobotSettings DisableHoming', () => { it('should render title, description and toggle button', () => { const [{ getByText, getByRole }] = render() - getByText('Disable homing the gantry when restarting robot') - getByText('Prevent robot from homing motors when the robot restarts.') - const toggleButton = getByRole('switch', { name: 'disable_homing' }) - expect(toggleButton.getAttribute('aria-checked')).toBe('true') + getByText('Home Gantry on Restart') + getByText('Homes the gantry along the z-axis.') + const toggleButton = getByRole('switch', { name: 'gantry_homing' }) + expect(toggleButton.getAttribute('aria-checked')).toBe('false') }) it('should change the value when a user clicks a toggle button', () => { @@ -58,16 +58,16 @@ describe('RobotSettings DisableHoming', () => { mockGetRobotSettings.mockReturnValue([tempMockSettings]) const [{ getByRole }] = render() const toggleButton = getByRole('switch', { - name: 'disable_homing', + name: 'gantry_homing', }) fireEvent.click(toggleButton) - expect(toggleButton.getAttribute('aria-checked')).toBe('true') + expect(toggleButton.getAttribute('aria-checked')).toBe('false') }) it('should call update robot status if a robot is busy', () => { const [{ getByRole }] = render(true) const toggleButton = getByRole('switch', { - name: 'disable_homing', + name: 'gantry_homing', }) expect(toggleButton).toBeDisabled() }) diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/__tests__/Troubleshooting.test.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/__tests__/Troubleshooting.test.tsx index 188077f194e..700ae868ee7 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/__tests__/Troubleshooting.test.tsx +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/__tests__/Troubleshooting.test.tsx @@ -1,29 +1,36 @@ import * as React from 'react' import { MemoryRouter } from 'react-router-dom' -import { fireEvent } from '@testing-library/react' +import { act, cleanup, waitFor } from '@testing-library/react' import { resetAllWhenMocks, when } from 'jest-when' + import { renderWithProviders } from '@opentrons/components' +import { useHost } from '@opentrons/react-api-client' + import { i18n } from '../../../../../i18n' +import { useToaster } from '../../../../../organisms/ToasterOven' import { mockConnectableRobot, mockUnreachableRobot, } from '../../../../../redux/discovery/__fixtures__' -import { downloadLogs } from '../../../../../redux/shell/robot-logs/actions' import { useRobot } from '../../../hooks' - import { Troubleshooting } from '../Troubleshooting' -jest.mock('../../../../../redux/shell/robot-logs/actions') -jest.mock('../../../../../redux/shell/robot-logs/selectors') +import type { HostConfig } from '@opentrons/api-client' +import { ToasterContextType } from '../../../../ToasterOven/ToasterContext' + +jest.mock('@opentrons/react-api-client') +jest.mock('../../../../../organisms/ToasterOven') jest.mock('../../../../../redux/discovery/selectors') jest.mock('../../../hooks') -const mockDownloadLogs = downloadLogs as jest.MockedFunction< - typeof downloadLogs -> +const mockUseHost = useHost as jest.MockedFunction const mockUseRobot = useRobot as jest.MockedFunction +const mockUseToaster = useToaster as jest.MockedFunction const ROBOT_NAME = 'otie' +const HOST_CONFIG: HostConfig = { hostname: 'localhost' } +const MOCK_MAKE_TOAST = jest.fn() +const MOCK_EAT_TOAST = jest.fn() const render = (robotName = ROBOT_NAME) => { return renderWithProviders( @@ -37,10 +44,18 @@ const render = (robotName = ROBOT_NAME) => { describe('RobotSettings Troubleshooting', () => { beforeEach(() => { when(mockUseRobot).calledWith('otie').mockReturnValue(mockConnectableRobot) + when(mockUseHost).calledWith().mockReturnValue(HOST_CONFIG) + when(mockUseToaster) + .calledWith() + .mockReturnValue(({ + makeToast: MOCK_MAKE_TOAST, + eatToast: MOCK_EAT_TOAST, + } as unknown) as ToasterContextType) }) afterEach(() => { jest.resetAllMocks() resetAllWhenMocks() + cleanup() }) it('should render title, description, and button', () => { const [{ getByText, getByRole, getByTestId }] = render() @@ -56,10 +71,22 @@ describe('RobotSettings Troubleshooting', () => { expect(downloadLogsButton).toBeDisabled() }) - it('should call downloadLogs when clicking Download logs button', () => { - const [{ getByRole }] = render() + it('should initiate log download when clicking Download logs button', async () => { + const [{ getByRole, queryByText }] = render() const downloadLogsButton = getByRole('button', { name: 'Download logs' }) - fireEvent.click(downloadLogsButton) - expect(mockDownloadLogs).toHaveBeenCalled() + act(() => { + downloadLogsButton.click() + }) + expect(downloadLogsButton).toBeDisabled() + expect(MOCK_MAKE_TOAST).toBeCalledWith('Downloading logs...', 'info', { + disableTimeout: true, + icon: { name: 'ot-spinner', spin: true }, + }) + await waitFor(() => { + expect(queryByText('Downloading logs...')).toBeNull() + }) + await waitFor(() => { + expect(downloadLogsButton).not.toBeDisabled() + }) }) }) diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/__tests__/UsageSettings.test.tsx b/app/src/organisms/Devices/RobotSettings/AdvancedTab/__tests__/UsageSettings.test.tsx index 345d5e9ab9b..f7b2cb88d27 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/__tests__/UsageSettings.test.tsx +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/__tests__/UsageSettings.test.tsx @@ -33,7 +33,7 @@ const render = (isRobotBusy = false) => { ) } -describe('RobotSettings DisableHoming', () => { +describe('RobotSettings GantryHoming', () => { beforeEach(() => { mockGetRobotSettings.mockReturnValue([mockSettings]) }) diff --git a/app/src/organisms/Devices/RobotSettings/AdvancedTab/index.ts b/app/src/organisms/Devices/RobotSettings/AdvancedTab/index.ts index cc5b347ff2a..9e7ada15c5c 100644 --- a/app/src/organisms/Devices/RobotSettings/AdvancedTab/index.ts +++ b/app/src/organisms/Devices/RobotSettings/AdvancedTab/index.ts @@ -1,4 +1,4 @@ -export * from './DisableHoming' +export * from './GantryHoming' export * from './DisplayRobotName' export * from './DeviceReset' export * from './LegacySettings' diff --git a/app/src/organisms/Devices/RobotSettings/RobotSettingsAdvanced.tsx b/app/src/organisms/Devices/RobotSettings/RobotSettingsAdvanced.tsx index 03179be47f8..d54e195784b 100644 --- a/app/src/organisms/Devices/RobotSettings/RobotSettingsAdvanced.tsx +++ b/app/src/organisms/Devices/RobotSettings/RobotSettingsAdvanced.tsx @@ -16,7 +16,7 @@ import { ToggleButton } from '../../../atoms/buttons' import { useIsOT3, useIsRobotBusy, useRobot } from '../hooks' import { UsageSettings } from './AdvancedTab/UsageSettings' import { - DisableHoming, + GantryHoming, DisplayRobotName, DeviceReset, LegacySettings, @@ -166,14 +166,18 @@ export function RobotSettingsAdvanced({ + {isOT3 ? null : ( + <> + + + + )} - - - -
- {error !== null ? ( - <> -

- There was an error downloading robot update files: -

-

{error}

-

- To download this update you must be connected to the internet. -

- - ) : ( - <> -

- Robot update download in progress... -

- -

- Please keep app connected to the internet until the download is - complete. -

- - )} -
- - ) -} diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/InstallModal.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/InstallModal.tsx deleted file mode 100644 index eb981246ff5..00000000000 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/InstallModal.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import * as React from 'react' - -import { AlertModal } from '@opentrons/components' -import { InstallModalContents } from './InstallModalContents' - -import type { ViewableRobot } from '../../../../redux/discovery/types' -import type { - RobotUpdateSession, - RobotSystemType, -} from '../../../../redux/robot-update/types' -import { OT2_BALENA } from '../../../../redux/robot-update' - -export interface InstallModalProps { - robot: ViewableRobot - robotSystemType: RobotSystemType | null - session: RobotUpdateSession - close: () => unknown -} - -export function InstallModal(props: InstallModalProps): JSX.Element { - const { session, close, robotSystemType } = props - const buttons = [] - - if (session.step === 'finished' || session.error !== null) { - buttons.push({ children: 'close', onClick: close }) - } - - let heading: string - // let heading: string = '' - if (robotSystemType === OT2_BALENA) { - if ( - session.step === 'premigration' || - session.step === 'premigrationRestart' - ) { - heading = 'Robot Update: Step 1 of 2' - } else { - heading = 'Robot Update: Step 2 of 2' - } - } else { - heading = 'Robot Update' - } - - return ( - - - - ) -} diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/InstallModalContents.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/InstallModalContents.tsx deleted file mode 100644 index 2479332cbe2..00000000000 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/InstallModalContents.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import * as React from 'react' - -import { usePrevious } from '@opentrons/components' -import { ProgressSpinner, ProgressBar } from './progress' -import styles from './styles.css' - -import type { - RobotUpdateSession, - RobotSystemType, -} from '../../../../redux/robot-update/types' -import { OT2_BALENA } from '../../../../redux/robot-update' - -export interface InstallModalContentsProps { - robotSystemType: RobotSystemType | null - session: RobotUpdateSession -} - -export function InstallModalContents( - props: InstallModalContentsProps -): JSX.Element { - const { robotSystemType, session } = props - const { step: updateStep, progress, error } = session - const prevStep = usePrevious(updateStep) - const step = updateStep || prevStep - - let title: string - let restartMessage: string - let updateMessage: string | null = null - - if (error !== null) { - return ( -
-

- An error occurred while updating your robot: -

-

{error}

-
- ) - } - - if (step === 'premigration') { - title = 'Robot server update in progress…' - restartMessage = - 'Your OT-2 will reboot once robot server update is complete.' - } else if (step === 'premigrationRestart') { - title = 'Robot is restarting...' - restartMessage = - 'Robot update process will continue once robot restart is complete.' - } else if (step === 'restart' || step === 'restarting') { - title = 'Robot is restarting...' - restartMessage = 'Waiting for robot to restart to complete update' - } else { - title = `Robot ${ - robotSystemType === OT2_BALENA ? 'system ' : '' - } update in progress…` - restartMessage = 'Your robot will restart once the update is complete.' - } - - if ( - step === 'premigration' || - step === 'premigrationRestart' || - step === 'restart' || - step === 'restarting' - ) { - updateMessage = 'Hang tight! This may take up to 3 minutes.' - } else if (step === 'getToken' || step === 'uploadFile') { - updateMessage = 'Sending update file to robot' - } else if ( - step === 'processFile' && - (session.stage === 'awaiting-file' || session.stage === 'validating') - ) { - updateMessage = 'Validating update file' - } else if (step === 'processFile' || step === 'commitUpdate') { - updateMessage = 'Applying update to robot' - } - - const progressComponent = - step === 'processFile' || step === 'commitUpdate' ? ( - - ) : ( - - ) - - return ( -
- {step === 'finished' ? ( -

Your robot is now successfully updated.

- ) : ( - <> -

{title}

- {progressComponent} -

{updateMessage}

-

{restartMessage}

- - )} -
- ) -} diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/MigrationWarningModal.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/MigrationWarningModal.tsx index 4f7de34062e..fb15cc9afc3 100644 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/MigrationWarningModal.tsx +++ b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/MigrationWarningModal.tsx @@ -1,8 +1,9 @@ import * as React from 'react' +import { css } from 'styled-components' +import { useTranslation } from 'react-i18next' import { AlertModal } from '@opentrons/components' import { UPGRADE } from '../../../../redux/robot-update' -import styles from './styles.css' import type { ButtonProps } from '@opentrons/components' import type { RobotUpdateType } from '../../../../redux/robot-update/types' @@ -15,31 +16,45 @@ export interface MigrationWarningModalProps { type MaybeButtonProps = ButtonProps | null | undefined -const HEADING = 'Robot Operating System Update Available' +const VIEW_UPDATE_BUTTON_STYLE = css` + width: auto; + min-width: 10rem; + padding: 0.5rem 1.5rem; +` +const SYSTEM_UPDATE_MODAL_STYLE = css` + padding: 0 1rem; + & > p { + margin-bottom: 1rem; + } +` +const SYSTEM_UPDATE_WARNING_STYLE = css` + font-weight: var(--fw-semibold); +` export function MigrationWarningModal( props: MigrationWarningModalProps ): JSX.Element { + const { t } = useTranslation('device_settings') const { notNowButton, updateType, proceed } = props const buttons: MaybeButtonProps[] = [ notNowButton, { children: updateType === UPGRADE ? 'view robot update' : 'update robot', - className: styles.view_update_button, + css: VIEW_UPDATE_BUTTON_STYLE, onClick: proceed, }, ] return ( -
-

+

+

This update is a little different than previous updates.

diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/ReleaseNotesModal.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/ReleaseNotesModal.tsx deleted file mode 100644 index 316dca88544..00000000000 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/ReleaseNotesModal.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import * as React from 'react' -import { useDispatch } from 'react-redux' - -import { - robotUpdateChangelogSeen, - OT2_BALENA, -} from '../../../../redux/robot-update' -import { ScrollableAlertModal } from '../../../../molecules/modals' -import { ReleaseNotes } from '../../../../molecules/ReleaseNotes' -import { useIsRobotBusy } from '../../hooks' -import styles from './styles.css' -import { RobotIsBusyModal } from './RobotIsBusyModal' - -import { ButtonProps, useConditionalConfirm } from '@opentrons/components' -import type { Dispatch } from '../../../../redux/types' -import type { RobotSystemType } from '../../../../redux/robot-update/types' - -export interface ReleaseNotesModalProps { - robotName: string - notNowButton: ButtonProps - releaseNotes: string - systemType: RobotSystemType | null - proceed: () => unknown -} - -export function ReleaseNotesModal(props: ReleaseNotesModalProps): JSX.Element { - const { robotName, notNowButton, releaseNotes, systemType, proceed } = props - const dispatch = useDispatch() - const isRobotBusy = useIsRobotBusy() - - React.useEffect(() => { - dispatch(robotUpdateChangelogSeen(robotName)) - }, [dispatch, robotName]) - - const { - confirm: confirmProceed, - showConfirmation: showRobotIsBusyModal, - cancel: cancelExit, - } = useConditionalConfirm(proceed, isRobotBusy) - - const heading = - systemType !== OT2_BALENA ? 'Robot Update' : 'Robot Operating System Update' - - const buttons = [ - notNowButton, - { - onClick: confirmProceed, - children: 'update robot', - className: styles.view_update_button, - }, - ] - - return showRobotIsBusyModal ? ( - - ) : ( - - - - ) -} diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/RobotIsBusyModal.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/RobotIsBusyModal.tsx deleted file mode 100644 index 0cfc3d72139..00000000000 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/RobotIsBusyModal.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import * as React from 'react' -import { Trans, useTranslation } from 'react-i18next' - -import { - Flex, - DIRECTION_COLUMN, - JUSTIFY_FLEX_END, - SPACING, - TYPOGRAPHY, - ALIGN_CENTER, - Btn, - PrimaryButton, -} from '@opentrons/components' -import { LegacyModal } from '../../../../molecules/LegacyModal' -import { StyledText } from '../../../../atoms/text' - -interface RobotIsBusyModalProps { - closeModal: () => void - proceed: () => void -} - -export function RobotIsBusyModal({ - closeModal, - proceed, -}: RobotIsBusyModalProps): JSX.Element { - const { t } = useTranslation(['device_details', 'shared']) - - return ( - - - , - }} - /> - - - {t('shared:cancel')} - - {t('yes_update_now')} - - - - ) -} diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/RobotUpdateProgressModal.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/RobotUpdateProgressModal.tsx new file mode 100644 index 00000000000..b0d8f7efe5d --- /dev/null +++ b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/RobotUpdateProgressModal.tsx @@ -0,0 +1,248 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { css } from 'styled-components' + +import { + Flex, + Icon, + NewPrimaryBtn, + NewSecondaryBtn, + JUSTIFY_FLEX_END, + ALIGN_CENTER, + COLORS, + DIRECTION_COLUMN, + SPACING, + BORDERS, +} from '@opentrons/components' +import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' + +import { StyledText } from '../../../../atoms/text' +import { LegacyModal } from '../../../../molecules/LegacyModal' +import { ProgressBar } from '../../../../atoms/ProgressBar' +import { FOOTER_BUTTON_STYLE } from './UpdateRobotModal' +import { + clearRobotUpdateSession, + startRobotUpdate, +} from '../../../../redux/robot-update' +import successIcon from '../../../../assets/images/icon_success.png' + +import type { SetStatusBarCreateCommand } from '@opentrons/shared-data/protocol/types/schemaV7/command/incidental' +import type { Dispatch } from '../../../../redux/types' +import type { UpdateStep } from '.' +import type { RobotUpdateAction } from '../../../../redux/robot-update/types' + +interface SuccessOrErrorProps { + errorMessage?: string | null +} + +function SuccessOrError({ errorMessage }: SuccessOrErrorProps): JSX.Element { + const { t } = useTranslation('device_settings') + const IMAGE_ALT = 'Welcome screen background image' + let renderedImg: JSX.Element + if (!errorMessage) + renderedImg = ( + {IMAGE_ALT} + ) + else + renderedImg = ( + + ) + + return ( + <> + {renderedImg} + + {!errorMessage ? t('robot_update_success') : errorMessage} + + + ) +} + +interface RobotUpdateProgressFooterProps { + robotName: string + errorMessage?: string | null + closeUpdateBuildroot?: () => void +} + +function RobotUpdateProgressFooter({ + robotName, + errorMessage, + closeUpdateBuildroot, +}: RobotUpdateProgressFooterProps): JSX.Element { + const { t } = useTranslation('device_settings') + const dispatch = useDispatch() + // TODO(jh, 08-30-2023: add reinstall logic for zip file installation) + const installUpdate = React.useCallback(() => { + dispatch(clearRobotUpdateSession()) + dispatch(startRobotUpdate(robotName)) + }, [robotName]) + + const { createLiveCommand } = useCreateLiveCommandMutation() + const idleCommand: SetStatusBarCreateCommand = { + commandType: 'setStatusBar', + params: { animation: 'idle' }, + } + + // Called if the update fails + const startIdleAnimationIfFailed = (): void => { + if (errorMessage) { + createLiveCommand({ + command: idleCommand, + waitUntilComplete: false, + }).catch((e: Error) => + console.warn(`cannot run status bar animation: ${e.message}`) + ) + } + } + + React.useEffect(startIdleAnimationIfFailed, []) + + return ( + + {errorMessage && ( + + {t('try_again')} + + )} + + {t('exit')} + + + ) +} + +interface RobotUpdateProgressModalProps { + robotName: string + updateStep: UpdateStep + stepProgress?: number | null + error?: string | null + closeUpdateBuildroot?: () => void +} + +export function RobotUpdateProgressModal({ + robotName, + updateStep, + stepProgress, + error, + closeUpdateBuildroot, +}: RobotUpdateProgressModalProps): JSX.Element { + const { t } = useTranslation('device_settings') + const dispatch = useDispatch() + const progressPercent = React.useRef(0) + const [previousUpdateStep, setPreviousUpdateStep] = React.useState< + string | null + >(null) + const completeRobotUpdateHandler = (): RobotUpdateAction => { + if (closeUpdateBuildroot != null) closeUpdateBuildroot() + return dispatch(clearRobotUpdateSession()) + } + + const { createLiveCommand } = useCreateLiveCommandMutation() + const updatingCommand: SetStatusBarCreateCommand = { + commandType: 'setStatusBar', + params: { animation: 'updating' }, + } + + // Called when the first step of the update begins + const startUpdatingAnimation = (): void => { + createLiveCommand({ + command: updatingCommand, + waitUntilComplete: false, + }).catch((e: Error) => + console.warn(`cannot run status bar animation: ${e.message}`) + ) + } + + let modalBodyText = t('downloading_update') + if (updateStep === 'install') { + modalBodyText = t('installing_update') + } else if (updateStep === 'restart') { + modalBodyText = t('restarting_robot') + } + + // Make sure to start the animation when this modal first pops up + React.useEffect(startUpdatingAnimation, []) + + // Account for update methods that do not require download & decreasing percent oddities. + React.useEffect(() => { + const explicitStepProgress = stepProgress || 0 + if (previousUpdateStep === null) { + if (updateStep === 'install') + progressPercent.current = Math.max( + progressPercent.current, + explicitStepProgress + ) + else if (updateStep === 'download') { + progressPercent.current = Math.max( + progressPercent.current, + Math.floor(explicitStepProgress / 2) + ) + if (progressPercent.current === 50) setPreviousUpdateStep('download') + } else progressPercent.current = 100 + } else { + progressPercent.current = Math.max( + progressPercent.current, + 50 + Math.floor(explicitStepProgress / 2) + ) + } + }, [updateStep, stepProgress, previousUpdateStep]) + + const completedUpdating = error || updateStep === 'finished' + + const UPDATE_PROGRESS_BAR_STYLE = css` + margin-top: ${SPACING.spacing24}; + margin-bottom: ${SPACING.spacing24}; + border-radius: ${BORDERS.borderRadiusSize3}; + background: ${COLORS.medGreyEnabled}; + ` + const dontTurnOffMessage = css` + color: ${COLORS.darkGreyEnabled}; + ` + + return ( + + ) : null + } + > + + {completedUpdating ? ( + + ) : ( + <> + {modalBodyText} + + + {t('do_not_turn_off')} + + + )} + + + ) +} diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/SkipAppUpdateMessage.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/SkipAppUpdateMessage.tsx deleted file mode 100644 index 4611e5c462f..00000000000 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/SkipAppUpdateMessage.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import * as React from 'react' - -import { - C_BLUE, - FONT_SIZE_INHERIT, - SPACING_3, - Btn, - Text, -} from '@opentrons/components' - -interface SkipAppUpdateMessageProps { - onClick: React.MouseEventHandler -} - -const SKIP_APP_MESSAGE = - 'If you wish to skip this app update and only sync your robot server with your current app version, please ' -const CLICK_HERE = 'click here' - -export function SkipAppUpdateMessage( - props: SkipAppUpdateMessageProps -): JSX.Element { - return ( - - {SKIP_APP_MESSAGE} - - {CLICK_HERE} - - . - - ) -} diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/SyncRobotMessage.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/SyncRobotMessage.tsx deleted file mode 100644 index 7ce80104ed5..00000000000 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/SyncRobotMessage.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import * as React from 'react' -import styles from './styles.css' -import { UPGRADE, DOWNGRADE, REINSTALL } from '../../../../redux/robot-update' -import type { RobotUpdateType } from '../../../../redux/robot-update/types' - -export interface SyncRobotMessageProps { - updateType: RobotUpdateType - version: string -} - -export function SyncRobotMessage( - props: SyncRobotMessageProps -): JSX.Element | null { - const { updateType, version } = props - - if (updateType === REINSTALL) return null - - return ( -

- - Your robot software version and app version are out of sync.
-
- {updateType === UPGRADE && ( - <> - For an optimal experience, we recommend you upgrade your robot - software to {version} to match your app. - - )} - {updateType === DOWNGRADE && ( - <> - You may wish to downgrade to robot software version {version} to - ensure compatibility. - - )} -

- ) -} diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/UpdateAppMessage.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/UpdateAppMessage.tsx deleted file mode 100644 index fc89d3addd7..00000000000 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/UpdateAppMessage.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import * as React from 'react' -import semver from 'semver' -import styles from './styles.css' -import type { VersionProps } from './types' - -const NEWER_VERSION = ( - A newer version of the robot server is available. -) -const RECOMMEND_UPDATE_APP_FIRST = ( - <> - We recommend you update your app first before updating your robot - server to ensure you have received all the latest improvements. - -) -const UPDATE_APP = ( - <> - Please update your app to receive all the latest improvements and robot - server update. - -) - -export function UpdateAppMessage(props: VersionProps): JSX.Element { - const { appVersion, availableUpdate } = props - const versionsMatch: boolean = - semver.valid(appVersion) && semver.valid(availableUpdate) - ? semver.eq(appVersion, availableUpdate) - : false - - return ( -

- {NEWER_VERSION} -
- {!versionsMatch ? RECOMMEND_UPDATE_APP_FIRST : UPDATE_APP} -

- ) -} diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/UpdateRobotModal.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/UpdateRobotModal.tsx new file mode 100644 index 00000000000..390a4b0ff85 --- /dev/null +++ b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/UpdateRobotModal.tsx @@ -0,0 +1,128 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' +import styled, { css } from 'styled-components' + +import { + useHoverTooltip, + ALIGN_CENTER, + DIRECTION_COLUMN, + JUSTIFY_FLEX_END, + SPACING, + Flex, + NewPrimaryBtn, + NewSecondaryBtn, + BORDERS, +} from '@opentrons/components' + +import { + getRobotUpdateDisplayInfo, + robotUpdateChangelogSeen, + startRobotUpdate, + OT2_BALENA, +} from '../../../../redux/robot-update' +import { ReleaseNotes } from '../../../../molecules/ReleaseNotes' +import { useIsRobotBusy } from '../../hooks' +import { Tooltip } from '../../../../atoms/Tooltip' +import { LegacyModal } from '../../../../molecules/LegacyModal' +import { Banner } from '../../../../atoms/Banner' + +import type { State, Dispatch } from '../../../../redux/types' +import type { RobotSystemType } from '../../../../redux/robot-update/types' + +const UpdateAppBanner = styled(Banner)` + border: none; +` +export const FOOTER_BUTTON_STYLE = css` + text-transform: lowercase; + padding-left: ${SPACING.spacing16}; + padding-right: ${SPACING.spacing16}; + border-radius: ${BORDERS.borderRadiusSize1}; + margin-top: ${SPACING.spacing16}; + margin-bottom: ${SPACING.spacing16}; + + &:first-letter { + text-transform: uppercase; + } +` + +export interface UpdateRobotModalProps { + robotName: string + releaseNotes: string + systemType: RobotSystemType | null + closeModal: () => void +} + +export function UpdateRobotModal({ + robotName, + releaseNotes, + systemType, + closeModal, +}: UpdateRobotModalProps): JSX.Element { + const dispatch = useDispatch() + const { t } = useTranslation('device_settings') + const [updateButtonProps, updateButtonTooltipProps] = useHoverTooltip() + // TODO(jh 08-29-2023): revisit reasons that are/are not captured by this selector. + const { updateFromFileDisabledReason } = useSelector((state: State) => { + return getRobotUpdateDisplayInfo(state, robotName) + }) + const isRobotBusy = useIsRobotBusy() + const updateDisabled = updateFromFileDisabledReason !== null || isRobotBusy + + let disabledReason: string = '' + if (updateFromFileDisabledReason) + disabledReason = updateFromFileDisabledReason + else if (isRobotBusy) disabledReason = t('robot_busy_protocol') + + React.useEffect(() => { + dispatch(robotUpdateChangelogSeen(robotName)) + }, [robotName]) + + const heading = + systemType === OT2_BALENA + ? 'Robot Operating System Update' + : `${robotName} ${t('update_available')}` + + const robotUpdateFooter = ( + + + {t('remind_me_later')} + + dispatch(startRobotUpdate(robotName))} + marginRight={SPACING.spacing12} + css={FOOTER_BUTTON_STYLE} + disabled={updateDisabled} + {...updateButtonProps} + > + {t('update_robot_now')} + + {updateDisabled && ( + + {disabledReason} + + )} + + ) + + return ( + + + + {t('updating_robot_system')} + + + + + ) +} diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/VersionInfoModal.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/VersionInfoModal.tsx deleted file mode 100644 index e15706e24b2..00000000000 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/VersionInfoModal.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import * as React from 'react' -import { useSelector } from 'react-redux' - -import { getRobotApiVersion } from '../../../../redux/discovery' -import { - CURRENT_VERSION, - getAvailableShellUpdate, -} from '../../../../redux/shell' - -import { AlertModal } from '@opentrons/components' -import { Portal } from '../../../../App/portal' -import { UpdateAppModal } from '../../../../organisms/UpdateAppModal' -import { UpdateAppMessage } from './UpdateAppMessage' -import { VersionList } from './VersionList' -import { SkipAppUpdateMessage } from './SkipAppUpdateMessage' -import { SyncRobotMessage } from './SyncRobotMessage' -import styles from './styles.css' - -import type { ButtonProps } from '@opentrons/components' -import type { RobotUpdateType } from '../../../../redux/robot-update/types' -import type { ViewableRobot } from '../../../../redux/discovery/types' - -export interface VersionInfoModalProps { - robot: ViewableRobot - robotUpdateType: RobotUpdateType | null - close: () => unknown - goToViewUpdate: () => unknown - installUpdate: () => unknown -} - -const REINSTALL_HEADING = 'Robot is up to date' -const REINSTALL_MESSAGE = - "It looks like your robot is already up to date, but if you're experiencing issues you can re-apply the latest update." - -export function VersionInfoModal(props: VersionInfoModalProps): JSX.Element { - const { robot, robotUpdateType, close, installUpdate, goToViewUpdate } = props - const [showUpdateAppModal, setShowUpdateAppModal] = React.useState(false) - const availableAppUpdateVersion = useSelector(getAvailableShellUpdate) - const robotVersion = getRobotApiVersion(robot) - - if (showUpdateAppModal) - return ( - - - - ) - - const versionProps = { - robotVersion: robotVersion != null ? robotVersion : null, - appVersion: CURRENT_VERSION, - availableUpdate: availableAppUpdateVersion ?? CURRENT_VERSION, - } - - let heading = '' - let primaryButton: ButtonProps = { className: styles.view_update_button } - let message = null - let secondaryMessage = null - - if (availableAppUpdateVersion !== null) { - heading = `App Version ${availableAppUpdateVersion} Available` - message = - secondaryMessage = - primaryButton = { - ...primaryButton, - children: 'View App Update', - onClick: () => setShowUpdateAppModal(true), - } - } else if (robotUpdateType === 'upgrade' || robotUpdateType === 'downgrade') { - heading = 'Robot Update Available' - message = ( - - ) - primaryButton = { - ...primaryButton, - onClick: robotUpdateType === 'upgrade' ? goToViewUpdate : installUpdate, - children: - robotUpdateType === 'upgrade' ? 'View Robot Update' : 'Downgrade Robot', - } - } else { - heading = REINSTALL_HEADING - message =

{REINSTALL_MESSAGE}

- primaryButton = { - ...primaryButton, - onClick: installUpdate, - children: 'Reinstall', - } - } - - return ( - - {message} - - {secondaryMessage} - - ) -} diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/VersionList.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/VersionList.tsx deleted file mode 100644 index b3aaafa8ff2..00000000000 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/VersionList.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import * as React from 'react' -import styles from './styles.css' -import type { VersionProps } from './types' - -export function VersionList(props: VersionProps): JSX.Element { - return ( -
    -
  1. Your current app version: {props.appVersion}
  2. -
  3. - Your current robot server version: {props.robotVersion || 'Unknown'} -
  4. -
  5. Latest available version: {props.availableUpdate}
  6. -
- ) -} diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/ViewUpdateModal.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/ViewUpdateModal.tsx index 7db4607829d..65a78c7bf66 100644 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/ViewUpdateModal.tsx +++ b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/ViewUpdateModal.tsx @@ -8,30 +8,31 @@ import { getRobotUpdateDownloadProgress, getRobotUpdateDownloadError, } from '../../../../redux/robot-update' - +import { getAvailableShellUpdate } from '../../../../redux/shell' +import { Portal } from '../../../../App/portal' +import { UpdateAppModal } from '../../../../organisms/UpdateAppModal' import { MigrationWarningModal } from './MigrationWarningModal' -import { DownloadUpdateModal } from './DownloadUpdateModal' -import { ReleaseNotesModal } from './ReleaseNotesModal' +import { RobotUpdateProgressModal } from './RobotUpdateProgressModal' +import { UpdateRobotModal } from './UpdateRobotModal' import type { RobotUpdateType, RobotSystemType, } from '../../../../redux/robot-update/types' - import type { State } from '../../../../redux/types' export interface ViewUpdateModalProps { robotName: string robotUpdateType: RobotUpdateType | null robotSystemType: RobotSystemType | null - close: () => unknown - proceed: () => unknown + closeModal: () => void } export function ViewUpdateModal( props: ViewUpdateModalProps ): JSX.Element | null { - const { robotName, robotUpdateType, robotSystemType, close, proceed } = props + const { robotName, robotUpdateType, robotSystemType, closeModal } = props + const updateInfo = useSelector((state: State) => getRobotUpdateInfo(state, robotName) ) @@ -41,6 +42,7 @@ export function ViewUpdateModal( const downloadError = useSelector((state: State) => getRobotUpdateDownloadError(state, robotName) ) + const availableAppUpdateVersion = useSelector(getAvailableShellUpdate) const [ showMigrationWarning, @@ -48,10 +50,20 @@ export function ViewUpdateModal( ] = React.useState(robotSystemType === OT2_BALENA) const notNowButton = { - onClick: close, + onClick: closeModal, children: downloadError !== null ? 'close' : 'not now', } + const showReleaseNotes = robotUpdateType === UPGRADE + let releaseNotes = '' + if (updateInfo?.releaseNotes) releaseNotes = updateInfo.releaseNotes + + if (availableAppUpdateVersion) + return ( + + + + ) if (showMigrationWarning) { return ( @@ -63,27 +75,25 @@ export function ViewUpdateModal( ) } - if (updateInfo === null) { + if (updateInfo === null) return ( - ) - } - if (showReleaseNotes) { + if (showReleaseNotes) return ( - ) - } return null } diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/DownloadUpdateModal.test.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/DownloadUpdateModal.test.tsx deleted file mode 100644 index 6160b462bc9..00000000000 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/DownloadUpdateModal.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import * as React from 'react' -import { mount } from 'enzyme' - -import { AlertModal } from '@opentrons/components' -import { ProgressBar } from '../progress' -import { DownloadUpdateModal } from '../DownloadUpdateModal' - -describe('DownloadUpdateModal', () => { - const handleClose = jest.fn() - - const render = (error: string | null = null) => { - return mount( - - ) - } - - afterEach(() => { - jest.resetAllMocks() - }) - - it('should render an AlertModal with a heading', () => { - const wrapper = render() - const modal = wrapper.find(AlertModal) - - expect(modal.prop('heading')).toBe('Downloading Update') - }) - - it('should render a close button', () => { - const wrapper = render() - const button = wrapper.find('button') - - expect(button.text()).toMatch(/not now/) - expect(handleClose).not.toHaveBeenCalled() - button.invoke('onClick')?.({} as React.MouseEvent) - expect(handleClose).toHaveBeenCalled() - }) - - it('should render a progress bar', () => { - const wrapper = render() - const progress = wrapper.find(ProgressBar) - - expect(progress.prop('progress')).toBe(50) - }) - - it('should render a different heading, an error message, and no progress bar on error', () => { - const wrapper = render('oh no!') - const modal = wrapper.find(AlertModal) - - expect(modal.prop('heading')).toBe('Download Error') - expect(modal.html()).toContain('oh no!') - expect(wrapper.exists(ProgressBar)).toBe(false) - }) -}) diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/RobotIsBusyModal.test.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/RobotIsBusyModal.test.tsx deleted file mode 100644 index 9302d8ebc89..00000000000 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/RobotIsBusyModal.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import * as React from 'react' -import { fireEvent } from '@testing-library/react' -import { renderWithProviders } from '@opentrons/components' -import { i18n } from '../../../../../i18n' -import { RobotIsBusyModal } from '../RobotIsBusyModal' - -const render = (props: React.ComponentProps) => { - return renderWithProviders(, { - i18nInstance: i18n, - })[0] -} - -describe('RobotIsBusyModal', () => { - let props: React.ComponentProps - beforeEach(() => { - props = { - closeModal: jest.fn(), - proceed: jest.fn(), - } - }) - - afterEach(() => { - jest.resetAllMocks() - }) - - it('should render title, description, and buttons', () => { - const { getByText, getByRole } = render(props) - - getByText('Robot is busy') - getByText( - 'This robot has to restart to update its software. Restarting will immediately stop the current run or calibration.' - ) - getByText('Do you want to update now anyway?') - getByRole('button', { name: 'cancel' }) - getByRole('button', { name: 'Yes, update now' }) - }) - - it('should render buttons and they should be clickable', () => { - const { getByRole } = render(props) - const cancelBtn = getByRole('button', { name: 'cancel' }) - const proceedBtn = getByRole('button', { name: 'Yes, update now' }) - fireEvent.click(cancelBtn) - expect(props.closeModal).toHaveBeenCalled() - fireEvent.click(proceedBtn) - expect(props.proceed).toHaveBeenCalled() - }) -}) diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/RobotUpdateProgressModal.test.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/RobotUpdateProgressModal.test.tsx new file mode 100644 index 00000000000..6e246527ab3 --- /dev/null +++ b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/RobotUpdateProgressModal.test.tsx @@ -0,0 +1,152 @@ +import * as React from 'react' +import { i18n } from '../../../../../i18n' +import { fireEvent } from '@testing-library/react' +import { renderWithProviders } from '@opentrons/components' +import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' + +import type { SetStatusBarCreateCommand } from '@opentrons/shared-data/protocol/types/schemaV7/command/incidental' + +import { RobotUpdateProgressModal } from '../RobotUpdateProgressModal' + +jest.mock('@opentrons/react-api-client') +jest.mock('@opentrons/shared-data/protocol/types/schemaV7/command/incidental') + +const mockUseCreateLiveCommandMutation = useCreateLiveCommandMutation as jest.MockedFunction< + typeof useCreateLiveCommandMutation +> + +const render = ( + props: React.ComponentProps +) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('DownloadUpdateModal', () => { + let props: React.ComponentProps + let mockCreateLiveCommand = jest.fn() + + beforeEach(() => { + mockCreateLiveCommand = jest.fn() + mockCreateLiveCommand.mockResolvedValue(null) + props = { + robotName: 'testRobot', + updateStep: 'download', + error: null, + stepProgress: 50, + closeUpdateBuildroot: jest.fn(), + } + mockUseCreateLiveCommandMutation.mockReturnValue({ + createLiveCommand: mockCreateLiveCommand, + } as any) + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('renders the robot name as a part of the header', () => { + const [{ getByText }] = render(props) + + expect(getByText('Updating testRobot')).toBeInTheDocument() + }) + + it('activates the Update animation when first rendered', () => { + render(props) + const updatingCommand: SetStatusBarCreateCommand = { + commandType: 'setStatusBar', + params: { animation: 'updating' }, + } + + expect(mockUseCreateLiveCommandMutation).toBeCalledWith() + expect(mockCreateLiveCommand).toBeCalledWith({ + command: updatingCommand, + waitUntilComplete: false, + }) + }) + + it('renders the correct text when downloading the robot update with no close button', () => { + const [{ queryByRole, getByText }] = render(props) + + expect(getByText('Downloading update...')).toBeInTheDocument() + expect( + getByText('Do not turn off the robot while updating') + ).toBeInTheDocument() + expect(queryByRole('button')).not.toBeInTheDocument() + }) + + it('renders the correct text when installing the robot update with no close button', () => { + props = { + ...props, + updateStep: 'install', + } + + const [{ queryByRole, getByText }] = render(props) + + expect(getByText('Installing update...')).toBeInTheDocument() + expect( + getByText('Do not turn off the robot while updating') + ).toBeInTheDocument() + expect(queryByRole('button')).not.toBeInTheDocument() + }) + + it('renders the correct text when finalizing the robot update with no close button', () => { + props = { + ...props, + updateStep: 'restart', + } + + const [{ queryByRole, getByText }] = render(props) + + expect( + getByText('Install complete, robot restarting...') + ).toBeInTheDocument() + expect( + getByText('Do not turn off the robot while updating') + ).toBeInTheDocument() + expect(queryByRole('button')).not.toBeInTheDocument() + }) + + it('renders a success modal and exit button upon finishing the update process', () => { + props = { + ...props, + updateStep: 'finished', + } + const [{ getByText }] = render(props) + + const exitButton = getByText('exit') + + expect(getByText('Robot software successfully updated')).toBeInTheDocument() + expect(exitButton).toBeInTheDocument() + expect(mockCreateLiveCommand).toBeCalledTimes(1) + fireEvent.click(exitButton) + expect(props.closeUpdateBuildroot).toHaveBeenCalled() + }) + + it('renders an error modal and exit button if an error occurs', () => { + const idleCommand: SetStatusBarCreateCommand = { + commandType: 'setStatusBar', + params: { animation: 'idle' }, + } + props = { + ...props, + error: 'test error', + } + const [{ getByText }] = render(props) + + const exitButton = getByText('exit') + + expect(getByText('test error')).toBeInTheDocument() + fireEvent.click(exitButton) + expect(getByText('Try again')).toBeInTheDocument() + expect(props.closeUpdateBuildroot).toHaveBeenCalled() + + expect(mockUseCreateLiveCommandMutation).toBeCalledWith() + expect(mockCreateLiveCommand).toBeCalledTimes(2) + expect(mockCreateLiveCommand).toBeCalledWith({ + command: idleCommand, + waitUntilComplete: false, + }) + }) +}) diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/UpdateBuildroot.test.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/UpdateBuildroot.test.tsx index 1f38f9cd47e..98ac277b92d 100644 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/UpdateBuildroot.test.tsx +++ b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/UpdateBuildroot.test.tsx @@ -1,24 +1,23 @@ -import * as React from 'react' +import React from 'react' -import { mountWithStore, WrapperWithStore } from '@opentrons/components' import { mockConnectableRobot as mockRobot } from '../../../../../redux/discovery/__fixtures__' import * as RobotUpdate from '../../../../../redux/robot-update' -import { VersionInfoModal } from '../VersionInfoModal' -import { ViewUpdateModal } from '../ViewUpdateModal' -import { InstallModal } from '../InstallModal' + +import { mountWithStore, WrapperWithStore } from '@opentrons/components' import { UpdateBuildroot } from '..' +import { ViewUpdateModal } from '../ViewUpdateModal' +import { RobotUpdateProgressModal } from '../RobotUpdateProgressModal' import type { State } from '../../../../../redux/types' -// shallow render connected children -jest.mock('../VersionInfoModal', () => ({ - VersionInfoModal: () => <>, -})) - jest.mock('../ViewUpdateModal', () => ({ ViewUpdateModal: () => <>, })) +jest.mock('../RobotUpdateProgressModal', () => ({ + RobotUpdateProgressModal: () => <>, +})) + jest.mock('../../../../../redux/robot-update/selectors') const getRobotUpdateAvailable = RobotUpdate.getRobotUpdateAvailable as jest.MockedFunction< @@ -33,7 +32,7 @@ const getRobotSystemType = RobotUpdate.getRobotSystemType as jest.MockedFunction const MOCK_STATE: State = { mockState: true } as any -describe('UpdateBuildroot wizard', () => { +describe('UpdateBuildroot', () => { const closeModal = jest.fn() const render = (): WrapperWithStore< React.ComponentProps @@ -61,47 +60,21 @@ describe('UpdateBuildroot wizard', () => { ) }) - it('should render a VersionInfoModal first', () => { + it('renders a ViewUpdateModal if no session exists', () => { const { wrapper } = render() - const versionInfo = wrapper.find(VersionInfoModal) + const viewUpdate = wrapper.find(ViewUpdateModal) - expect(versionInfo.prop('robot')).toBe(mockRobot) - expect(versionInfo.prop('robotUpdateType')).toBe(RobotUpdate.UPGRADE) + expect(viewUpdate.prop('robotName')).toBe(mockRobot.name) + expect(viewUpdate.prop('robotUpdateType')).toBe(RobotUpdate.UPGRADE) expect(getRobotUpdateAvailable).toHaveBeenCalledWith(MOCK_STATE, mockRobot) expect(closeModal).not.toHaveBeenCalled() - versionInfo.invoke('close')?.() + viewUpdate.invoke('closeModal')?.() expect(closeModal).toHaveBeenCalled() }) - it('should proceed from the VersionInfoModal to a ViewUpdateModal', () => { - const { wrapper } = render() - const versionInfo = wrapper.find(VersionInfoModal) - - expect(wrapper.exists(ViewUpdateModal)).toBe(false) - versionInfo.invoke('goToViewUpdate')?.() - - const viewUpdate = wrapper.find(ViewUpdateModal) - expect(viewUpdate.prop('robotName')).toBe(mockRobot.name) - expect(viewUpdate.prop('robotUpdateType')).toBe(RobotUpdate.UPGRADE) - expect(viewUpdate.prop('robotSystemType')).toBe(RobotUpdate.OT2_BUILDROOT) - expect(getRobotSystemType).toHaveBeenCalledWith(mockRobot) - - viewUpdate.invoke('close')?.() - expect(closeModal).toHaveBeenCalled() - }) - - it('should proceed from the VersionInfoModal to an install', () => { - const { wrapper, store } = render() - wrapper.find(VersionInfoModal).invoke('installUpdate')?.() - - expect(store.dispatch).toHaveBeenCalledWith( - RobotUpdate.startRobotUpdate(mockRobot.name) - ) - }) - - it('should display an InstallModal if a session is in progress', () => { + it('renders RobotUpdateProgressModal if session exists', () => { const mockSession = { robotName: mockRobot.name, fileInfo: null, @@ -109,65 +82,17 @@ describe('UpdateBuildroot wizard', () => { pathPrefix: null, step: null, stage: null, - progress: null, + progress: 50, error: null, } - getRobotUpdateSession.mockReturnValue(mockSession) const { wrapper } = render() - const installModal = wrapper.find(InstallModal) - - expect(installModal.prop('robot')).toBe(mockRobot) - expect(installModal.prop('robotSystemType')).toBe(RobotUpdate.OT2_BUILDROOT) - expect(installModal.prop('session')).toBe(mockSession) - - expect(closeModal).not.toHaveBeenCalled() - installModal.invoke('close')?.() - expect(closeModal).toHaveBeenCalled() - }) - - it('should clear a finished session un unmount', () => { - const mockSession = { - robotName: mockRobot.name, - fileInfo: null, - token: null, - pathPrefix: null, - step: RobotUpdate.FINISHED, - stage: null, - progress: null, - error: null, - } - - getRobotUpdateSession.mockReturnValue(mockSession) - - const { wrapper, store } = render() + const progressModal = wrapper.find(RobotUpdateProgressModal) - wrapper.unmount() - expect(store.dispatch).toHaveBeenCalledWith( - RobotUpdate.clearRobotUpdateSession() - ) - }) - - it('should not clear an unfinished session un unmount', () => { - const mockSession = { - robotName: mockRobot.name, - fileInfo: null, - token: null, - pathPrefix: null, - step: RobotUpdate.RESTART, - stage: null, - progress: null, - error: null, - } - - getRobotUpdateSession.mockReturnValue(mockSession) - - const { wrapper, store } = render() - - wrapper.unmount() - expect(store.dispatch).not.toHaveBeenCalledWith( - RobotUpdate.clearRobotUpdateSession() - ) + expect(progressModal.prop('robotName')).toBe(mockRobot.name) + expect(progressModal.prop('updateStep')).toBe('download') + expect(progressModal.prop('error')).toBe(mockSession.error) + expect(progressModal.prop('stepProgress')).toBe(mockSession.progress) }) }) diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/UpdateRobotModal.test.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/UpdateRobotModal.test.tsx new file mode 100644 index 00000000000..0c6bde081e6 --- /dev/null +++ b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/UpdateRobotModal.test.tsx @@ -0,0 +1,94 @@ +import * as React from 'react' +import { createStore } from 'redux' +import { when } from 'jest-when' +import { fireEvent } from '@testing-library/react' + +import { renderWithProviders } from '@opentrons/components' + +import { i18n } from '../../../../../i18n' +import { getRobotUpdateDisplayInfo } from '../../../../../redux/robot-update' +import { getDiscoverableRobotByName } from '../../../../../redux/discovery' +import { UpdateRobotModal } from '../UpdateRobotModal' +import type { Store } from 'redux' + +import type { State } from '../../../../../redux/types' + +jest.mock('../../../../../redux/robot-update') +jest.mock('../../../../../redux/discovery') +jest.mock('../../../../UpdateAppModal', () => ({ + UpdateAppModal: () => null, +})) + +const mockGetRobotUpdateDisplayInfo = getRobotUpdateDisplayInfo as jest.MockedFunction< + typeof getRobotUpdateDisplayInfo +> +const mockGetDiscoverableRobotByName = getDiscoverableRobotByName as jest.MockedFunction< + typeof getDiscoverableRobotByName +> + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('UpdateRobotModal', () => { + let props: React.ComponentProps + let store: Store + beforeEach(() => { + store = createStore(jest.fn(), {}) + store.dispatch = jest.fn() + props = { + robotName: 'test robot', + releaseNotes: 'test notes', + systemType: 'flex', + closeModal: jest.fn(), + } + when(mockGetRobotUpdateDisplayInfo).mockReturnValue({ + autoUpdateAction: 'upgrade', + autoUpdateDisabledReason: null, + updateFromFileDisabledReason: 'test', + }) + when(mockGetDiscoverableRobotByName).mockReturnValue(null) + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('renders an update available header if the type is not Balena', () => { + const [{ getByText }] = render(props) + getByText('test robot Update Available') + }) + + it('renders a special update header if the type is Balena', () => { + props = { + ...props, + systemType: 'ot2-balena', + } + const [{ getByText }] = render(props) + getByText('Robot Operating System Update') + }) + + it('renders release notes and a modal header close icon', () => { + const [{ getByText, getByTestId }] = render(props) + getByText('test notes') + + const exitIcon = getByTestId( + 'ModalHeader_icon_close_test robot Update Available' + ) + fireEvent.click(exitIcon) + expect(props.closeModal).toHaveBeenCalled() + }) + + it('renders remind me later and and disabled update robot now buttons', () => { + const [{ getByText }] = render(props) + getByText('test notes') + + const remindMeLater = getByText('Remind me later') + const updateNow = getByText('Update robot now') + expect(updateNow).toBeDisabled() + fireEvent.click(remindMeLater) + expect(props.closeModal).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/VersionInfoModal.test.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/VersionInfoModal.test.tsx deleted file mode 100644 index 6254700718b..00000000000 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/VersionInfoModal.test.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import * as React from 'react' - -import { - mountWithStore, - AlertModal, - OutlineButton, -} from '@opentrons/components' -import { mockReachableRobot } from '../../../../../redux/discovery/__fixtures__' -import { - UPGRADE, - DOWNGRADE, - REINSTALL, -} from '../../../../../redux/robot-update' -import * as Shell from '../../../../../redux/shell' -import { Portal } from '../../../../../App/portal' -import { UpdateAppModal } from '../../../../UpdateAppModal' -import { VersionList } from '../VersionList' -import { SyncRobotMessage } from '../SyncRobotMessage' -import { SkipAppUpdateMessage } from '../SkipAppUpdateMessage' -import { VersionInfoModal } from '../VersionInfoModal' - -import type { State, Action } from '../../../../../redux/types' - -jest.mock('../../../../../redux/shell/update') -jest.mock('../../../../UpdateAppModal', () => ({ - UpdateAppModal: () => null, -})) - -const MOCK_STATE: State = {} as any - -const getAvailableShellUpdate = Shell.getAvailableShellUpdate as jest.MockedFunction< - typeof Shell.getAvailableShellUpdate -> - -describe('VersionInfoModal', () => { - const handleClose = jest.fn() - const mockGoToViewUpdate = jest.fn() - const mockInstallUpdate = jest.fn() - - const render = ( - robotUpdateType: React.ComponentProps< - typeof VersionInfoModal - >['robotUpdateType'] = UPGRADE - ) => { - return mountWithStore< - React.ComponentProps, - State, - Action - >( - , - { initialState: MOCK_STATE } - ) - } - - beforeEach(() => { - getAvailableShellUpdate.mockImplementation(state => { - expect(state).toBe(MOCK_STATE) - return null - }) - }) - - afterEach(() => { - jest.resetAllMocks() - }) - - it('should render an AlertModal with the proper children for an upgrade', () => { - const { wrapper } = render(UPGRADE) - const alert = wrapper.find(AlertModal) - const versionList = alert.find(VersionList) - const syncRobot = alert.find(SyncRobotMessage) - const closeButton = alert.find(OutlineButton).at(0) - const primaryButton = alert.find(OutlineButton).at(1) - - expect(alert.prop('heading')).toBe('Robot Update Available') - expect(versionList.props()).toEqual({ - robotVersion: '0.0.0-mock', - appVersion: Shell.CURRENT_VERSION, - availableUpdate: Shell.CURRENT_VERSION, - }) - expect(syncRobot.props()).toEqual({ - updateType: UPGRADE, - version: Shell.CURRENT_VERSION, - }) - - expect(closeButton.text()).toMatch(/not now/i) - expect(primaryButton.text()).toMatch(/view robot update/i) - - expect(handleClose).not.toHaveBeenCalled() - closeButton.invoke('onClick')?.({} as React.MouseEvent) - expect(handleClose).toHaveBeenCalled() - expect(mockInstallUpdate).not.toHaveBeenCalled() - primaryButton.invoke('onClick')?.({} as React.MouseEvent) - expect(mockGoToViewUpdate).toHaveBeenCalled() - }) - - it('should render an AlertModal with the proper children for a downgrade', () => { - const { wrapper } = render(DOWNGRADE) - const alert = wrapper.find(AlertModal) - const versionList = alert.find(VersionList) - const syncRobot = alert.find(SyncRobotMessage) - const closeButton = alert.find(OutlineButton).at(0) - const primaryButton = alert.find(OutlineButton).at(1) - - expect(alert.prop('heading')).toBe('Robot Update Available') - expect(versionList.props()).toEqual({ - robotVersion: '0.0.0-mock', - appVersion: Shell.CURRENT_VERSION, - availableUpdate: Shell.CURRENT_VERSION, - }) - expect(syncRobot.props()).toEqual({ - updateType: DOWNGRADE, - version: Shell.CURRENT_VERSION, - }) - - expect(closeButton.text()).toMatch(/not now/i) - expect(primaryButton.text()).toMatch(/downgrade/i) - - expect(handleClose).not.toHaveBeenCalled() - closeButton.invoke('onClick')?.({} as React.MouseEvent) - expect(handleClose).toHaveBeenCalled() - expect(mockInstallUpdate).not.toHaveBeenCalled() - primaryButton.invoke('onClick')?.({} as React.MouseEvent) - expect(mockInstallUpdate).toHaveBeenCalled() - }) - - it('should render an AlertModal with the proper children for a reinstall', () => { - const { wrapper } = render(REINSTALL) - const alert = wrapper.find(AlertModal) - const versionList = alert.find(VersionList) - const syncRobot = alert.find(SyncRobotMessage) - const closeButton = alert.find(OutlineButton).at(0) - const primaryButton = alert.find(OutlineButton).at(1) - - expect(alert.prop('heading')).toBe('Robot is up to date') - expect(versionList.props()).toEqual({ - robotVersion: '0.0.0-mock', - appVersion: Shell.CURRENT_VERSION, - availableUpdate: Shell.CURRENT_VERSION, - }) - expect(syncRobot.exists()).toBe(false) - - expect(closeButton.text()).toMatch(/not now/i) - expect(primaryButton.text()).toMatch(/reinstall/i) - - expect(handleClose).not.toHaveBeenCalled() - closeButton.invoke('onClick')?.({} as React.MouseEvent) - expect(handleClose).toHaveBeenCalled() - expect(mockInstallUpdate).not.toHaveBeenCalled() - primaryButton.invoke('onClick')?.({} as React.MouseEvent) - expect(mockInstallUpdate).toHaveBeenCalled() - }) - - describe('with an app update available', () => { - beforeEach(() => { - getAvailableShellUpdate.mockReturnValue('1.2.3') - }) - - it('should render an AlertModal saying an app update is available', () => { - const { wrapper } = render() - const alert = wrapper.find(AlertModal) - const versionList = alert.find(VersionList) - - expect(alert.prop('heading')).toBe('App Version 1.2.3 Available') - expect(versionList.props()).toEqual({ - robotVersion: '0.0.0-mock', - appVersion: Shell.CURRENT_VERSION, - availableUpdate: '1.2.3', - }) - }) - - it('should have a "View Update" button that opens an UpdateAppModal', () => { - const { wrapper } = render() - const alert = wrapper.find(AlertModal) - const viewUpdateButton = alert - .find('button') - .filterWhere(b => /view app update/i.test(b.text())) - - expect(wrapper.exists(UpdateAppModal)).toBe(false) - - viewUpdateButton.invoke('onClick')?.({} as React.MouseEvent) - - expect(wrapper.find(Portal).exists(UpdateAppModal)).toBe(true) - }) - - it('should have a SkipAppUpdateMessage that runs the robot update', () => { - const { wrapper } = render() - const skipAppUpdate = wrapper.find(SkipAppUpdateMessage) - - expect(mockInstallUpdate).not.toHaveBeenCalled() - skipAppUpdate.invoke('onClick')?.({} as React.MouseEvent) - expect(mockInstallUpdate).toHaveBeenCalled() - }) - - it('should call props.close when the UpdateAppModal is closed', () => { - const { wrapper } = render() - const alert = wrapper.find(AlertModal) - const viewUpdateButton = alert - .find('button') - .filterWhere(b => /view app update/i.test(b.text())) - - viewUpdateButton.invoke('onClick')?.({} as React.MouseEvent) - - expect(handleClose).not.toHaveBeenCalled() - - wrapper.find(UpdateAppModal).invoke('closeModal')?.() - - expect(handleClose).toHaveBeenCalled() - }) - }) -}) diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/ViewUpdateModal.test.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/ViewUpdateModal.test.tsx index 0c0825ed1db..53717fb6685 100644 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/ViewUpdateModal.test.tsx +++ b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/__tests__/ViewUpdateModal.test.tsx @@ -1,16 +1,19 @@ import * as React from 'react' import { QueryClient, QueryClientProvider } from 'react-query' import { mountWithStore } from '@opentrons/components' +import { useIsRobotBusy } from '../../../hooks' import * as RobotUpdate from '../../../../../redux/robot-update' -import { DownloadUpdateModal } from '../DownloadUpdateModal' -import { ReleaseNotesModal } from '../ReleaseNotesModal' +import { RobotUpdateProgressModal } from '../RobotUpdateProgressModal' +import { UpdateRobotModal } from '../UpdateRobotModal' import { MigrationWarningModal } from '../MigrationWarningModal' import { ViewUpdateModal } from '../ViewUpdateModal' import type { State } from '../../../../../redux/types' jest.mock('../../../../../redux/robot-update') +jest.mock('../../../../../redux/shell') +jest.mock('../../../hooks') const getRobotUpdateInfo = RobotUpdate.getRobotUpdateInfo as jest.MockedFunction< typeof RobotUpdate.getRobotUpdateInfo @@ -21,6 +24,13 @@ const getRobotUpdateDownloadProgress = RobotUpdate.getRobotUpdateDownloadProgres const getRobotUpdateDownloadError = RobotUpdate.getRobotUpdateDownloadError as jest.MockedFunction< typeof RobotUpdate.getRobotUpdateDownloadError > +const getRobotUpdateDisplayInfo = RobotUpdate.getRobotUpdateDisplayInfo as jest.MockedFunction< + typeof RobotUpdate.getRobotUpdateDisplayInfo +> + +const mockUseIsRobotBusy = useIsRobotBusy as jest.MockedFunction< + typeof useIsRobotBusy +> const MOCK_STATE: State = { mockState: true } as any const MOCK_ROBOT_NAME = 'robot-name' @@ -28,7 +38,6 @@ const queryClient = new QueryClient() describe('ViewUpdateModal', () => { const handleClose = jest.fn() - const handleProceed = jest.fn() const render = ( robotUpdateType: React.ComponentProps< @@ -44,8 +53,7 @@ describe('ViewUpdateModal', () => { robotName={MOCK_ROBOT_NAME} robotUpdateType={robotUpdateType} robotSystemType={robotSystemType} - close={handleClose} - proceed={handleProceed} + closeModal={handleClose} /> , { initialState: MOCK_STATE } @@ -55,52 +63,44 @@ describe('ViewUpdateModal', () => { getRobotUpdateInfo.mockReturnValue(null) getRobotUpdateDownloadProgress.mockReturnValue(50) getRobotUpdateDownloadError.mockReturnValue(null) + getRobotUpdateDisplayInfo.mockReturnValue({ + autoUpdateAction: 'upgrade', + autoUpdateDisabledReason: null, + updateFromFileDisabledReason: null, + }) + mockUseIsRobotBusy.mockReturnValue(false) }) afterEach(() => { jest.resetAllMocks() }) - it('should show a DownloadUpdateModal if the update has not been downloaded yet', () => { + it('renders a RobotUpdateProgressModal if the update has not completed downloading', () => { const { wrapper } = render() - const downloadUpdateModal = wrapper.find(DownloadUpdateModal) + const robotUpdateProgressModal = wrapper.find(RobotUpdateProgressModal) - expect(downloadUpdateModal.prop('error')).toEqual(null) - expect(downloadUpdateModal.prop('progress')).toEqual(50) + expect(robotUpdateProgressModal.prop('error')).toEqual(null) + expect(robotUpdateProgressModal.prop('stepProgress')).toEqual(50) expect(getRobotUpdateDownloadProgress).toHaveBeenCalledWith( MOCK_STATE, 'robot-name' ) - - const closeButtonProps = downloadUpdateModal.prop('notNowButton') - - expect(closeButtonProps.children).toMatch(/not now/i) - expect(handleClose).not.toHaveBeenCalled() - closeButtonProps.onClick?.({} as React.MouseEvent) - expect(handleClose).toHaveBeenCalled() }) - it('should show a DownloadUpdateModal if the update download errored out', () => { + it('should show a RobotUpdateProgressModal if the update download errored out', () => { getRobotUpdateDownloadError.mockReturnValue('oh no!') const { wrapper } = render() - const downloadUpdateModal = wrapper.find(DownloadUpdateModal) + const robotUpdateProgressModal = wrapper.find(RobotUpdateProgressModal) - expect(downloadUpdateModal.prop('error')).toEqual('oh no!') + expect(robotUpdateProgressModal.prop('error')).toEqual('oh no!') expect(getRobotUpdateDownloadError).toHaveBeenCalledWith( MOCK_STATE, 'robot-name' ) - - const closeButtonProps = downloadUpdateModal.prop('notNowButton') - - expect(closeButtonProps.children).toMatch(/close/i) - expect(handleClose).not.toHaveBeenCalled() - closeButtonProps.onClick?.({} as React.MouseEvent) - expect(handleClose).toHaveBeenCalled() }) - it('should show a ReleaseNotesModal if the update is an upgrade', () => { + it('should show a UpdateRobotModal if the update is an upgrade', () => { getRobotUpdateInfo.mockReturnValue({ version: '1.0.0', target: 'ot2', @@ -108,23 +108,12 @@ describe('ViewUpdateModal', () => { }) const { wrapper } = render() - const releaseNotesModal = wrapper.find(ReleaseNotesModal) + const updateRobotModal = wrapper.find(UpdateRobotModal) - expect(releaseNotesModal.prop('robotName')).toBe(MOCK_ROBOT_NAME) - expect(releaseNotesModal.prop('releaseNotes')).toBe('hey look a release') - expect(releaseNotesModal.prop('systemType')).toBe(RobotUpdate.OT2_BUILDROOT) + expect(updateRobotModal.prop('robotName')).toBe(MOCK_ROBOT_NAME) + expect(updateRobotModal.prop('releaseNotes')).toBe('hey look a release') + expect(updateRobotModal.prop('systemType')).toBe(RobotUpdate.OT2_BUILDROOT) expect(getRobotUpdateInfo).toHaveBeenCalledWith(MOCK_STATE, 'robot-name') - - const closeButtonProps = releaseNotesModal.prop('notNowButton') - - expect(closeButtonProps.children).toMatch(/not now/i) - expect(handleClose).not.toHaveBeenCalled() - closeButtonProps.onClick?.({} as React.MouseEvent) - expect(handleClose).toHaveBeenCalled() - - expect(handleProceed).not.toHaveBeenCalled() - releaseNotesModal.invoke('proceed')?.() - expect(handleProceed).toHaveBeenCalled() }) it('should show a MigrationWarningModal if the robot is on Balena', () => { @@ -153,16 +142,16 @@ describe('ViewUpdateModal', () => { migrationWarning.invoke('proceed')?.() - expect(wrapper.find(ReleaseNotesModal).prop('systemType')).toBe( + expect(wrapper.find(UpdateRobotModal).prop('systemType')).toBe( RobotUpdate.OT2_BALENA ) }) - it('should proceed from MigrationWarningModal to DownloadUpdateModal if still downloading', () => { + it('should proceed from MigrationWarningModal to RobotUpdateProgressModal if still downloading', () => { const { wrapper } = render(RobotUpdate.UPGRADE, RobotUpdate.OT2_BALENA) const migrationWarning = wrapper.find(MigrationWarningModal) migrationWarning.invoke('proceed')?.() - expect(wrapper.exists(DownloadUpdateModal)).toBe(true) + expect(wrapper.exists(RobotUpdateProgressModal)).toBe(true) }) }) diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/index.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/index.tsx index 3269d355398..6596ab8d2ae 100644 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/index.tsx +++ b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/index.tsx @@ -1,105 +1,70 @@ import * as React from 'react' import { useSelector, useDispatch } from 'react-redux' - -import { VersionInfoModal } from './VersionInfoModal' -import { ViewUpdateModal } from './ViewUpdateModal' -import { InstallModal } from './InstallModal' import { - startRobotUpdate, setRobotUpdateSeen, robotUpdateIgnored, getRobotUpdateSession, - clearRobotUpdateSession, getRobotSystemType, getRobotUpdateAvailable, } from '../../../../redux/robot-update' +import { ViewUpdateModal } from './ViewUpdateModal' +import { RobotUpdateProgressModal } from './RobotUpdateProgressModal' import type { State, Dispatch } from '../../../../redux/types' import type { ViewableRobot } from '../../../../redux/discovery/types' +export type UpdateStep = 'download' | 'install' | 'restart' | 'finished' + export interface UpdateBuildrootProps { robot: ViewableRobot - close: () => unknown + close: () => void } export function UpdateBuildroot(props: UpdateBuildrootProps): JSX.Element { const { robot, close } = props const robotName = robot.name - const [viewUpdateInfo, setViewUpdateInfo] = React.useState(false) const session = useSelector(getRobotUpdateSession) const robotUpdateType = useSelector((state: State) => getRobotUpdateAvailable(state, robot) ) const dispatch = useDispatch() - const { step, error } = session || { step: null, error: null } + const { step, error: installError } = session || { + step: null, + installError: null, + } // set update seen on component mount React.useEffect(() => { dispatch(setRobotUpdateSeen(robotName)) - }, [dispatch, robotName]) - - // TODO(bc, 2022-07-05): We are currently ignoring the 'finished' session state, but - // when new SW Update flow is made, delete this implicit dismissal that - // clears buildroot state if session finished when initially mounted - React.useEffect(() => { - if (step === 'finished') { - dispatch(clearRobotUpdateSession()) - } - }, [dispatch, step]) - - // clear buildroot state on component dismount if done - React.useEffect(() => { - if (step === 'finished' || error !== null) { - return () => { - dispatch(clearRobotUpdateSession()) - } - } - }, [dispatch, step, error]) - - const goToViewUpdate = React.useCallback(() => setViewUpdateInfo(true), []) + }, [robotName]) const ignoreUpdate = React.useCallback(() => { dispatch(robotUpdateIgnored(robotName)) close() - }, [dispatch, robotName, close]) - - const installUpdate = React.useCallback( - () => dispatch(startRobotUpdate(robotName)), - [dispatch, robotName] - ) + }, [robotName, close]) const robotSystemType = getRobotSystemType(robot) - if (session) { - return ( - - ) - } - - if (!viewUpdateInfo) { - return ( - - ) - } + let updateStep: UpdateStep + if (step == null) updateStep = 'download' + else if (step === 'finished') updateStep = 'finished' + else if (step === 'restart' || step === 'restarting') updateStep = 'restart' + else updateStep = 'install' - return ( + return session ? ( + + ) : ( ) } diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/progress.tsx b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/progress.tsx deleted file mode 100644 index c4de00e5da4..00000000000 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/progress.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import * as React from 'react' -import styles from './styles.css' - -export function ProgressSpinner(): JSX.Element { - return ( -
- - - - - - - - -
- ) -} - -export interface ProgressBarProps { - progress: number | null -} - -export function ProgressBar(props: ProgressBarProps): JSX.Element { - const progress = props.progress || 0 - const width = `${progress}%` - - return ( -
- {progress}% -
-
- ) -} diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/styles.css b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/styles.css deleted file mode 100644 index f24ec858439..00000000000 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/styles.css +++ /dev/null @@ -1,131 +0,0 @@ -@import '@opentrons/components'; - -.system_update_modal { - padding: 0 1rem; - - & > p { - margin-bottom: 1rem; - } -} - -.system_update_warning { - font-weight: var(--fw-semibold); -} - -.view_update_button { - width: auto; - min-width: 10rem; - padding: 0.5rem 1.5rem; -} - -.version_list { - @apply --font-body-1-dark; - - margin: 0 0 1.5rem 2rem; -} - -.sync_message { - margin-left: 1rem; - padding-bottom: 1rem; -} - -.reinstall_message { - margin-left: 1rem; -} - -.download_message, -.update_title { - font-weight: var(--fw-semibold); -} - -.download_error, -.update_message { - font-style: italic; -} - -.progress_bar_container { - position: relative; - height: 1.75rem; - margin-bottom: 1rem; - border-radius: 4px; - border: solid 2px var(--c-dark-gray); - text-align: right; -} - -.progress_bar { - position: absolute; - top: 0; - height: 100%; - border: solid 2px var(--c-white); - border-radius: 3px; - background-color: #006cfa; - transition: width ease-in-out 0.5s; -} - -.progress_text { - line-height: 1.75; - padding: 0.5rem 0.25rem; - position: relative; - z-index: 99; - color: var(--c-med-gray); -} - -.progress_spinner { - height: 2rem; - margin-bottom: -0.5rem; -} - -.progress_spinner span { - transition: all 500ms ease; - background: #006cfa; - height: 0.5rem; - width: 0.5rem; - display: inline-block; - border-radius: 0.5rem; - animation: wave 2s ease infinite; - margin-right: 0.5rem; -} - -.progress_spinner span:nth-child(1) { - animation-delay: 0; -} - -.progress_spinner span:nth-child(2) { - animation-delay: 100ms; -} - -.progress_spinner span:nth-child(3) { - animation-delay: 200ms; -} - -.progress_spinner span:nth-child(4) { - animation-delay: 300ms; -} - -.progress_spinner span:nth-child(5) { - animation-delay: 400ms; -} - -.progress_spinner span:nth-child(6) { - animation-delay: 500ms; -} - -.progress_spinner span:nth-child(7) { - animation-delay: 600ms; -} - -.progress_spinner span:nth-child(8) { - animation-delay: 700ms; -} - -@keyframes wave { - 0%, - 40%, - 100% { - transform: translate(0, 0); - } - - 10% { - transform: translate(0, -1rem); - } -} diff --git a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/types.ts b/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/types.ts deleted file mode 100644 index 5e32b2360d7..00000000000 --- a/app/src/organisms/Devices/RobotSettings/UpdateBuildroot/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface VersionProps { - appVersion: string - robotVersion: string | null - availableUpdate: string -} diff --git a/app/src/organisms/Devices/RobotSettings/__tests__/RobotSettingsAdvanced.test.tsx b/app/src/organisms/Devices/RobotSettings/__tests__/RobotSettingsAdvanced.test.tsx index 9a174d9935b..8048be4f284 100644 --- a/app/src/organisms/Devices/RobotSettings/__tests__/RobotSettingsAdvanced.test.tsx +++ b/app/src/organisms/Devices/RobotSettings/__tests__/RobotSettingsAdvanced.test.tsx @@ -8,7 +8,7 @@ import { i18n } from '../../../../i18n' import { getShellUpdateState } from '../../../../redux/shell' import { useIsOT3 } from '../../hooks' import { - DisableHoming, + GantryHoming, DisplayRobotName, DeviceReset, LegacySettings, @@ -34,7 +34,7 @@ jest.mock('../../../../redux/shell/update', () => ({ })) jest.mock('../../hooks/useIsOT3') jest.mock('../AdvancedTab/DisplayRobotName') -jest.mock('../AdvancedTab/DisableHoming') +jest.mock('../AdvancedTab/GantryHoming') jest.mock('../AdvancedTab/DeviceReset') jest.mock('../AdvancedTab/LegacySettings') jest.mock('../AdvancedTab/OpenJupyterControl') @@ -54,8 +54,8 @@ const mockGetShellUpdateState = getShellUpdateState as jest.MockedFunction< const mockAboutRobotName = DisplayRobotName as jest.MockedFunction< typeof DisplayRobotName > -const mockDisableHoming = DisableHoming as jest.MockedFunction< - typeof DisableHoming +const mockGantryHoming = GantryHoming as jest.MockedFunction< + typeof GantryHoming > const mockDeviceReset = DeviceReset as jest.MockedFunction const mockLegacySettings = LegacySettings as jest.MockedFunction< @@ -112,7 +112,7 @@ describe('RobotSettings Advanced tab', () => { downloading: true, } as ShellUpdateState) mockAboutRobotName.mockReturnValue(
Mock AboutRobotName Section
) - mockDisableHoming.mockReturnValue(
Mock DisableHoming Section
) + mockGantryHoming.mockReturnValue(
Mock GantryHoming Section
) mockDeviceReset.mockReturnValue(
Mock DeviceReset Section
) mockLegacySettings.mockReturnValue(
Mock LegacySettings Section
) mockOpenJupyterControl.mockReturnValue( @@ -149,9 +149,9 @@ describe('RobotSettings Advanced tab', () => { getByText('Mock AboutRobotName Section') }) - it('should render DisableHoming section', () => { + it('should render GantryHoming section', () => { const [{ getByText }] = render() - getByText('Mock DisableHoming Section') + getByText('Mock GantryHoming Section') }) it('should render DeviceReset section', () => { @@ -211,6 +211,12 @@ describe('RobotSettings Advanced tab', () => { getByText('Mock UsageSettings Section') }) + it('should not render UsageSettings for OT-3', () => { + when(mockUseIsOT3).calledWith('otie').mockReturnValue(true) + const [{ queryByText }] = render() + expect(queryByText('Mock UsageSettings Section')).toBeNull() + }) + it('should render UseOlderAspirateBehavior section for OT-2', () => { const [{ getByText }] = render() getByText('Mock UseOlderAspirateBehavior Section') diff --git a/app/src/organisms/Devices/hooks/__tests__/useIsRobotBusy.test.ts b/app/src/organisms/Devices/hooks/__tests__/useIsRobotBusy.test.ts index 47b849d815d..458e9fc97a1 100644 --- a/app/src/organisms/Devices/hooks/__tests__/useIsRobotBusy.test.ts +++ b/app/src/organisms/Devices/hooks/__tests__/useIsRobotBusy.test.ts @@ -8,15 +8,16 @@ import { DISENGAGED, NOT_PRESENT, PHYSICALLY_ENGAGED, - ENGAGED, } from '../../../EmergencyStop' import { useIsRobotBusy } from '../useIsRobotBusy' +import { useIsOT3 } from '../useIsOT3' import type { Sessions, Runs } from '@opentrons/api-client' jest.mock('@opentrons/react-api-client') jest.mock('../../../ProtocolUpload/hooks') +jest.mock('../useIsOT3') const mockEstopStatus = { data: { @@ -35,6 +36,7 @@ const mockUseAllRunsQuery = useAllRunsQuery as jest.MockedFunction< const mockUseEstopQuery = useEstopQuery as jest.MockedFunction< typeof useEstopQuery > +const mockUseIsOT3 = useIsOT3 as jest.MockedFunction describe('useIsRobotBusy', () => { beforeEach(() => { @@ -49,6 +51,7 @@ describe('useIsRobotBusy', () => { }, } as UseQueryResult) mockUseEstopQuery.mockReturnValue({ data: mockEstopStatus } as any) + mockUseIsOT3.mockReturnValue(false) }) afterEach(() => { @@ -113,7 +116,8 @@ describe('useIsRobotBusy', () => { expect(result).toBe(false) }) - it('returns true when Estop status is not disengaged', () => { + it('returns true when robot is a Flex and Estop status is engaged', () => { + mockUseIsOT3.mockReturnValue(true) mockUseAllRunsQuery.mockReturnValue({ data: { links: { @@ -134,9 +138,41 @@ describe('useIsRobotBusy', () => { links: {}, } as unknown) as UseQueryResult) const mockEngagedStatus = { - ...mockEstopStatus, - status: PHYSICALLY_ENGAGED, - leftEstopPhysicalStatus: ENGAGED, + data: { + ...mockEstopStatus.data, + status: PHYSICALLY_ENGAGED, + }, + } + mockUseEstopQuery.mockReturnValue({ data: mockEngagedStatus } as any) + const result = useIsRobotBusy() + expect(result).toBe(true) + }) + it('returns false when robot is NOT a Flex and Estop status is engaged', () => { + mockUseIsOT3.mockReturnValue(false) + mockUseAllRunsQuery.mockReturnValue({ + data: { + links: { + current: null, + }, + }, + } as any) + mockUseAllSessionsQuery.mockReturnValue(({ + data: [ + { + id: 'test', + createdAt: '2019-08-24T14:15:22Z', + details: {}, + sessionType: 'calibrationCheck', + createParams: {}, + }, + ], + links: {}, + } as unknown) as UseQueryResult) + const mockEngagedStatus = { + data: { + ...mockEstopStatus.data, + status: PHYSICALLY_ENGAGED, + }, } mockUseEstopQuery.mockReturnValue({ data: mockEngagedStatus } as any) const result = useIsRobotBusy() diff --git a/app/src/organisms/Devices/hooks/__tests__/useProtocolAnalysisErrors.test.tsx b/app/src/organisms/Devices/hooks/__tests__/useProtocolAnalysisErrors.test.tsx index 18f38231814..86f4d6c1180 100644 --- a/app/src/organisms/Devices/hooks/__tests__/useProtocolAnalysisErrors.test.tsx +++ b/app/src/organisms/Devices/hooks/__tests__/useProtocolAnalysisErrors.test.tsx @@ -3,7 +3,8 @@ import { UseQueryResult } from 'react-query' import { renderHook } from '@testing-library/react-hooks' import { - useProtocolAnalysesQuery, + useProtocolAnalysisAsDocumentQuery, + useProtocolQuery, useRunQuery, } from '@opentrons/react-api-client' @@ -11,7 +12,7 @@ import { useProtocolAnalysisErrors } from '..' import { RUN_ID_2 } from '../../../../organisms/RunTimeControl/__fixtures__' -import type { Run, ProtocolAnalyses } from '@opentrons/api-client' +import type { Run, Protocol } from '@opentrons/api-client' import type { CompletedProtocolAnalysis, PendingProtocolAnalysis, @@ -20,8 +21,12 @@ import type { jest.mock('@opentrons/react-api-client') const mockUseRunQuery = useRunQuery as jest.MockedFunction -const mockUseProtocolAnalysesQuery = useProtocolAnalysesQuery as jest.MockedFunction< - typeof useProtocolAnalysesQuery + +const mockUseProtocolQuery = useProtocolQuery as jest.MockedFunction< + typeof useProtocolQuery +> +const mockUseProtocolAnalysisAsDocumentQuery = useProtocolAnalysisAsDocumentQuery as jest.MockedFunction< + typeof useProtocolAnalysisAsDocumentQuery > describe('useProtocolAnalysisErrors hook', () => { @@ -29,11 +34,14 @@ describe('useProtocolAnalysisErrors hook', () => { when(mockUseRunQuery) .calledWith(null, { staleTime: Infinity }) .mockReturnValue({} as UseQueryResult) - when(mockUseProtocolAnalysesQuery) - .calledWith(null, { staleTime: Infinity }) + when(mockUseProtocolQuery) + .calledWith(null) + .mockReturnValue({} as UseQueryResult) + when(mockUseProtocolAnalysisAsDocumentQuery) + .calledWith(null, null, { enabled: false }) .mockReturnValue({ - data: { data: [] } as any, - } as UseQueryResult) + data: null, + } as UseQueryResult) }) afterEach(() => { @@ -63,12 +71,18 @@ describe('useProtocolAnalysisErrors hook', () => { .mockReturnValue({ data: { data: { protocolId: PROTOCOL_ID } } as any, } as UseQueryResult) - when(mockUseProtocolAnalysesQuery) - .calledWith(PROTOCOL_ID, { staleTime: Infinity }) + when(mockUseProtocolQuery) + .calledWith(PROTOCOL_ID) .mockReturnValue({ - data: { data: [PROTOCOL_ANALYSIS as any] }, - } as UseQueryResult) - + data: { + data: { analysisSummaries: [{ id: PROTOCOL_ANALYSIS.id }] }, + } as any, + } as UseQueryResult) + when(mockUseProtocolAnalysisAsDocumentQuery) + .calledWith(PROTOCOL_ID, PROTOCOL_ANALYSIS.id, { enabled: true }) + .mockReturnValue({ + data: PROTOCOL_ANALYSIS, + } as UseQueryResult) const { result } = renderHook(() => useProtocolAnalysisErrors(RUN_ID_2)) expect(result.current).toStrictEqual({ analysisErrors: null, @@ -86,12 +100,18 @@ describe('useProtocolAnalysisErrors hook', () => { .mockReturnValue({ data: { data: { protocolId: PROTOCOL_ID } } as any, } as UseQueryResult) - when(mockUseProtocolAnalysesQuery) - .calledWith(PROTOCOL_ID, { staleTime: Infinity }) + when(mockUseProtocolQuery) + .calledWith(PROTOCOL_ID) .mockReturnValue({ - data: { data: [PROTOCOL_ANALYSIS as any] }, - } as UseQueryResult) - + data: { + data: { analysisSummaries: [{ id: PROTOCOL_ANALYSIS.id }] }, + } as any, + } as UseQueryResult) + when(mockUseProtocolAnalysisAsDocumentQuery) + .calledWith(PROTOCOL_ID, PROTOCOL_ANALYSIS.id, { enabled: true }) + .mockReturnValue({ + data: PROTOCOL_ANALYSIS, + } as UseQueryResult) const { result } = renderHook(() => useProtocolAnalysisErrors(RUN_ID_2)) expect(result.current).toStrictEqual({ analysisErrors: null, @@ -110,12 +130,22 @@ describe('useProtocolAnalysisErrors hook', () => { .mockReturnValue({ data: { data: { protocolId: PROTOCOL_ID } } as any, } as UseQueryResult) - when(mockUseProtocolAnalysesQuery) - .calledWith(PROTOCOL_ID, { staleTime: Infinity }) + when(mockUseProtocolQuery) + .calledWith(PROTOCOL_ID) .mockReturnValue({ - data: { data: [PROTOCOL_ANALYSIS_WITH_ERRORS as any] }, - } as UseQueryResult) - + data: { + data: { + analysisSummaries: [{ id: PROTOCOL_ANALYSIS_WITH_ERRORS.id }], + }, + } as any, + } as UseQueryResult) + when(mockUseProtocolAnalysisAsDocumentQuery) + .calledWith(PROTOCOL_ID, PROTOCOL_ANALYSIS_WITH_ERRORS.id, { + enabled: true, + }) + .mockReturnValue({ + data: PROTOCOL_ANALYSIS_WITH_ERRORS, + } as UseQueryResult) const { result } = renderHook(() => useProtocolAnalysisErrors(RUN_ID_2)) expect(result.current).toStrictEqual({ analysisErrors: [{ detail: 'fake error' }], diff --git a/app/src/organisms/Devices/hooks/__tests__/useProtocolDetailsForRun.test.tsx b/app/src/organisms/Devices/hooks/__tests__/useProtocolDetailsForRun.test.tsx index 0d3ed0db887..2c94afeedf1 100644 --- a/app/src/organisms/Devices/hooks/__tests__/useProtocolDetailsForRun.test.tsx +++ b/app/src/organisms/Devices/hooks/__tests__/useProtocolDetailsForRun.test.tsx @@ -3,7 +3,7 @@ import { UseQueryResult } from 'react-query' import { renderHook } from '@testing-library/react-hooks' import { - useProtocolAnalysesQuery, + useProtocolAnalysisAsDocumentQuery, useProtocolQuery, useRunQuery, } from '@opentrons/react-api-client' @@ -12,25 +12,32 @@ import { useProtocolDetailsForRun } from '..' import { RUN_ID_2 } from '../../../../organisms/RunTimeControl/__fixtures__' -import type { Protocol, Run, ProtocolAnalyses } from '@opentrons/api-client' +import type { Protocol, Run } from '@opentrons/api-client' +import { CompletedProtocolAnalysis } from '@opentrons/shared-data' jest.mock('@opentrons/react-api-client') const mockUseProtocolQuery = useProtocolQuery as jest.MockedFunction< typeof useProtocolQuery > -const mockUseProtocolAnalysesQuery = useProtocolAnalysesQuery as jest.MockedFunction< - typeof useProtocolAnalysesQuery +const mockUseProtocolAnalysisAsDocumentQuery = useProtocolAnalysisAsDocumentQuery as jest.MockedFunction< + typeof useProtocolAnalysisAsDocumentQuery > const mockUseRunQuery = useRunQuery as jest.MockedFunction +const PROTOCOL_ID = 'fake_protocol_id' +const PROTOCOL_ANALYSIS = { + id: 'fake analysis', + status: 'completed', + labware: [], +} as any const PROTOCOL_RESPONSE = { data: { protocolType: 'json', createdAt: 'now', - id: '1', + id: PROTOCOL_ID, metadata: { protocolName: 'fake protocol' }, - analysisSummaries: [{ id: 'fake analysis', status: 'completed' }], + analysisSummaries: [{ id: PROTOCOL_ANALYSIS.id, status: 'completed' }], key: 'fakeProtocolKey', }, } as Protocol @@ -43,11 +50,11 @@ describe('useProtocolDetailsForRun hook', () => { when(mockUseProtocolQuery) .calledWith(null, { staleTime: Infinity }) .mockReturnValue({} as UseQueryResult) - when(mockUseProtocolAnalysesQuery) - .calledWith(null, { staleTime: Infinity }, true) + when(mockUseProtocolAnalysisAsDocumentQuery) + .calledWith(null, null, { enabled: false, refetchInterval: 5000 }) .mockReturnValue({ - data: { data: [] } as any, - } as UseQueryResult) + data: null, + } as UseQueryResult) }) afterEach(() => { @@ -66,12 +73,6 @@ describe('useProtocolDetailsForRun hook', () => { }) it('returns the protocol file when given a run id', async () => { - const PROTOCOL_ID = 'fake_protocol_id' - const PROTOCOL_ANALYSIS = { - id: 'fake analysis', - status: 'completed', - labware: [], - } as any when(mockUseRunQuery) .calledWith(RUN_ID_2, { staleTime: Infinity }) .mockReturnValue({ @@ -80,11 +81,22 @@ describe('useProtocolDetailsForRun hook', () => { when(mockUseProtocolQuery) .calledWith(PROTOCOL_ID, { staleTime: Infinity }) .mockReturnValue({ data: PROTOCOL_RESPONSE } as UseQueryResult) - when(mockUseProtocolAnalysesQuery) - .calledWith(PROTOCOL_ID, { staleTime: Infinity }, expect.any(Boolean)) + when(mockUseProtocolAnalysisAsDocumentQuery) + .calledWith(PROTOCOL_ID, 'fake analysis', { + enabled: true, + refetchInterval: 5000, + }) .mockReturnValue({ - data: { data: [PROTOCOL_ANALYSIS as any] }, - } as UseQueryResult) + data: PROTOCOL_ANALYSIS, + } as UseQueryResult) + when(mockUseProtocolAnalysisAsDocumentQuery) + .calledWith(PROTOCOL_ID, 'fake analysis', { + enabled: false, + refetchInterval: 5000, + }) + .mockReturnValue({ + data: PROTOCOL_ANALYSIS, + } as UseQueryResult) const { result } = renderHook(() => useProtocolDetailsForRun(RUN_ID_2)) diff --git a/app/src/organisms/Devices/hooks/useIsRobotBusy.ts b/app/src/organisms/Devices/hooks/useIsRobotBusy.ts index db3f675aeee..7271e21f3f6 100644 --- a/app/src/organisms/Devices/hooks/useIsRobotBusy.ts +++ b/app/src/organisms/Devices/hooks/useIsRobotBusy.ts @@ -2,8 +2,10 @@ import { useAllSessionsQuery, useAllRunsQuery, useEstopQuery, + useHost, } from '@opentrons/react-api-client' import { DISENGAGED } from '../../EmergencyStop' +import { useIsOT3 } from './useIsOT3' const ROBOT_STATUS_POLL_MS = 30000 @@ -18,12 +20,18 @@ export function useIsRobotBusy( const robotHasCurrentRun = useAllRunsQuery({}, queryOptions)?.data?.links?.current != null const allSessionsQueryResponse = useAllSessionsQuery(queryOptions) - const { data: estopStatus, error: estopError } = useEstopQuery(queryOptions) + const host = useHost() + const robotName = host?.robotName + const isOT3 = useIsOT3(robotName ?? '') + const { data: estopStatus, error: estopError } = useEstopQuery({ + ...queryOptions, + enabled: isOT3, + }) return ( robotHasCurrentRun || (allSessionsQueryResponse?.data?.data != null && allSessionsQueryResponse?.data?.data?.length !== 0) || - (estopStatus?.data.status !== DISENGAGED && estopError == null) + (isOT3 && estopStatus?.data.status !== DISENGAGED && estopError == null) ) } diff --git a/app/src/organisms/Devices/hooks/useProtocolAnalysisErrors.ts b/app/src/organisms/Devices/hooks/useProtocolAnalysisErrors.ts index 7e43fa65f85..8e50c6b153c 100644 --- a/app/src/organisms/Devices/hooks/useProtocolAnalysisErrors.ts +++ b/app/src/organisms/Devices/hooks/useProtocolAnalysisErrors.ts @@ -1,6 +1,7 @@ import last from 'lodash/last' import { - useProtocolAnalysesQuery, + useProtocolAnalysisAsDocumentQuery, + useProtocolQuery, useRunQuery, } from '@opentrons/react-api-client' @@ -15,16 +16,19 @@ export function useProtocolAnalysisErrors( ): ProtocolAnalysisErrors { const { data: runRecord } = useRunQuery(runId, { staleTime: Infinity }) const protocolId = runRecord?.data?.protocolId ?? null - const { data: protocolAnalyses } = useProtocolAnalysesQuery(protocolId, { - staleTime: Infinity, - }) + const { data: protocolData } = useProtocolQuery(protocolId) + const { + data: mostRecentAnalysis, + } = useProtocolAnalysisAsDocumentQuery( + protocolId, + last(protocolData?.data.analysisSummaries)?.id ?? null, + { enabled: protocolData != null } + ) if (protocolId === null || runRecord?.data?.current === false) { return { analysisErrors: null } } - const mostRecentAnalysis = last(protocolAnalyses?.data ?? []) ?? null - if (mostRecentAnalysis?.status !== 'completed') { return { analysisErrors: null } } diff --git a/app/src/organisms/Devices/hooks/useProtocolDetailsForRun.ts b/app/src/organisms/Devices/hooks/useProtocolDetailsForRun.ts index f9834364345..ec61b88967f 100644 --- a/app/src/organisms/Devices/hooks/useProtocolDetailsForRun.ts +++ b/app/src/organisms/Devices/hooks/useProtocolDetailsForRun.ts @@ -3,8 +3,8 @@ import last from 'lodash/last' import { getRobotTypeFromLoadedLabware } from '@opentrons/shared-data' import { useProtocolQuery, - useProtocolAnalysesQuery, useRunQuery, + useProtocolAnalysisAsDocumentQuery, } from '@opentrons/react-api-client' import type { @@ -13,6 +13,7 @@ import type { PendingProtocolAnalysis, } from '@opentrons/shared-data' +const ANALYSIS_POLL_MS = 5000 export interface ProtocolDetails { displayName: string | null protocolData: CompletedProtocolAnalysis | PendingProtocolAnalysis | null @@ -34,17 +35,15 @@ export function useProtocolDetailsForRun( const { data: protocolRecord } = useProtocolQuery(protocolId, { staleTime: Infinity, }) - - const { data: protocolAnalyses } = useProtocolAnalysesQuery( + const { data: mostRecentAnalysis } = useProtocolAnalysisAsDocumentQuery( protocolId, + last(protocolRecord?.data.analysisSummaries)?.id ?? null, { - staleTime: Infinity, - }, - isPollingProtocolAnalyses + enabled: protocolRecord != null && isPollingProtocolAnalyses, + refetchInterval: ANALYSIS_POLL_MS, + } ) - const mostRecentAnalysis = last(protocolAnalyses?.data ?? []) ?? null - React.useEffect(() => { if (mostRecentAnalysis?.status === 'completed') { setIsPollingProtocolAnalyses(false) @@ -61,8 +60,7 @@ export function useProtocolDetailsForRun( displayName: displayName ?? null, protocolData: mostRecentAnalysis ?? null, protocolKey: protocolRecord?.data.key ?? null, - isProtocolAnalyzing: - mostRecentAnalysis != null && mostRecentAnalysis?.status === 'pending', + isProtocolAnalyzing: protocolRecord != null && mostRecentAnalysis == null, // this should be deleted as soon as analysis tells us intended robot type robotType: mostRecentAnalysis?.status === 'completed' diff --git a/app/src/organisms/EmergencyStop/__tests__/EsoptPressedModal.test.tsx b/app/src/organisms/EmergencyStop/__tests__/EsoptPressedModal.test.tsx index 6278fad223e..46fea0ce683 100644 --- a/app/src/organisms/EmergencyStop/__tests__/EsoptPressedModal.test.tsx +++ b/app/src/organisms/EmergencyStop/__tests__/EsoptPressedModal.test.tsx @@ -43,7 +43,7 @@ describe('EstopPressedModal - Touchscreen', () => { getByText('E-stop') getByText('Engaged') getByText( - 'First, safely clear the deck of any labware or spills. Then, twist the E-stop button counterclockwise. Finally, have Flex move the gantry to its home position.' + 'First, safely clear the deck of any labware or spills. Then, twist the E-stop button clockwise. Finally, have Flex move the gantry to its home position.' ) getByText('Resume robot operations') expect(getByTestId('Estop_pressed_button')).toBeDisabled() @@ -85,7 +85,7 @@ describe('EstopPressedModal - Desktop', () => { getByText('E-stop pressed') getByText('E-stop Engaged') getByText( - 'First, safely clear the deck of any labware or spills. Then, twist the E-stop button counterclockwise. Finally, have Flex move the gantry to its home position.' + 'First, safely clear the deck of any labware or spills. Then, twist the E-stop button clockwise. Finally, have Flex move the gantry to its home position.' ) expect( getByRole('button', { name: 'Resume robot operations' }) diff --git a/app/src/organisms/FirmwareUpdateModal/FirmwareUpdateTakeover.tsx b/app/src/organisms/FirmwareUpdateModal/FirmwareUpdateTakeover.tsx index 6f6eb01000f..a6205c702da 100644 --- a/app/src/organisms/FirmwareUpdateModal/FirmwareUpdateTakeover.tsx +++ b/app/src/organisms/FirmwareUpdateModal/FirmwareUpdateTakeover.tsx @@ -3,43 +3,91 @@ import * as React from 'react' import { useInstrumentsQuery, useCurrentMaintenanceRun, + useCurrentAllSubsystemUpdatesQuery, + useSubsystemUpdateQuery, } from '@opentrons/react-api-client' - +import { Portal } from '../../App/portal' import { useIsUnboxingFlowOngoing } from '../RobotSettingsDashboard/NetworkSettings/hooks' +import { UpdateInProgressModal } from './UpdateInProgressModal' import { UpdateNeededModal } from './UpdateNeededModal' +import type { Subsystem } from '@opentrons/api-client' -const INSTRUMENT_POLL_INTERVAL = 5000 +const POLL_INTERVAL_MS = 5000 export function FirmwareUpdateTakeover(): JSX.Element { const [ showUpdateNeededModal, setShowUpdateNeededModal, ] = React.useState(false) + const [ + initiatedSubsystemUpdate, + setInitiatedSubsystemUpdate, + ] = React.useState(null) + const instrumentsData = useInstrumentsQuery({ - refetchInterval: INSTRUMENT_POLL_INTERVAL, + refetchInterval: POLL_INTERVAL_MS, }).data?.data - const { data: maintenanceRunData } = useCurrentMaintenanceRun() const subsystemUpdateInstrument = instrumentsData?.find( instrument => instrument.ok === false ) + + const { data: maintenanceRunData } = useCurrentMaintenanceRun({ + refetchInterval: POLL_INTERVAL_MS, + }) const isUnboxingFlowOngoing = useIsUnboxingFlowOngoing() + const { + data: currentSubsystemsUpdatesData, + } = useCurrentAllSubsystemUpdatesQuery({ + refetchInterval: POLL_INTERVAL_MS, + }) + const externalSubsystemUpdate = currentSubsystemsUpdatesData?.data.find( + update => + (update.updateStatus === 'queued' || + update.updateStatus === 'updating') && + update.subsystem !== initiatedSubsystemUpdate + ) + const { data: externalsubsystemUpdateData } = useSubsystemUpdateQuery( + externalSubsystemUpdate?.id ?? null + ) + React.useEffect(() => { - if (subsystemUpdateInstrument != null && maintenanceRunData == null) { + if ( + subsystemUpdateInstrument != null && + maintenanceRunData == null && + !isUnboxingFlowOngoing && + externalSubsystemUpdate == null + ) { setShowUpdateNeededModal(true) } - }, [subsystemUpdateInstrument, maintenanceRunData]) + }, [ + subsystemUpdateInstrument, + maintenanceRunData, + isUnboxingFlowOngoing, + externalSubsystemUpdate, + ]) + const memoizedSubsystem = React.useMemo( + () => subsystemUpdateInstrument?.subsystem, + [] + ) return ( <> - {subsystemUpdateInstrument != null && - showUpdateNeededModal && - !isUnboxingFlowOngoing ? ( + {memoizedSubsystem != null && showUpdateNeededModal ? ( ) : null} + {externalsubsystemUpdateData != null ? ( + + + + ) : null} ) } diff --git a/app/src/organisms/FirmwareUpdateModal/UpdateInProgressModal.tsx b/app/src/organisms/FirmwareUpdateModal/UpdateInProgressModal.tsx index 9c66a775df1..c0cfee1e3ca 100644 --- a/app/src/organisms/FirmwareUpdateModal/UpdateInProgressModal.tsx +++ b/app/src/organisms/FirmwareUpdateModal/UpdateInProgressModal.tsx @@ -13,9 +13,11 @@ import { import { ProgressBar } from '../../atoms/ProgressBar' import { StyledText } from '../../atoms/text' import { Modal } from '../../molecules/Modal' +import { Subsystem } from '@opentrons/api-client' interface UpdateInProgressModalProps { percentComplete: number + subsystem: Subsystem } const OUTER_STYLES = css` @@ -26,8 +28,8 @@ const OUTER_STYLES = css` export function UpdateInProgressModal( props: UpdateInProgressModalProps ): JSX.Element { - const { percentComplete } = props - const { i18n, t } = useTranslation('firmware_update') + const { percentComplete, subsystem } = props + const { t } = useTranslation('firmware_update') return ( @@ -47,7 +49,7 @@ export function UpdateInProgressModal( marginBottom={SPACING.spacing4} fontWeight={TYPOGRAPHY.fontWeightBold} > - {i18n.format(t('updating_firmware'), 'capitalize')} + {t('updating_firmware', { subsystem: t(subsystem) })} > subsystem: Subsystem + setInitiatedSubsystemUpdate: (subsystem: Subsystem | null) => void } export function UpdateNeededModal(props: UpdateNeededModalProps): JSX.Element { - const { setShowUpdateModal, subsystem } = props + const { setShowUpdateModal, subsystem, setInitiatedSubsystemUpdate } = props const { t } = useTranslation('firmware_update') const [updateId, setUpdateId] = React.useState('') const { @@ -43,6 +44,12 @@ export function UpdateNeededModal(props: UpdateNeededModalProps): JSX.Element { const { data: updateData } = useSubsystemUpdateQuery(updateId) const status = updateData?.data.updateStatus + React.useEffect(() => { + if (status === 'done') { + setInitiatedSubsystemUpdate(null) + } + }, [status, setInitiatedSubsystemUpdate]) + const percentComplete = updateData?.data.updateProgress ?? 0 const updateError = updateData?.data.updateError const instrumentType = subsystem === 'gripper' ? 'gripper' : 'pipette' @@ -73,7 +80,10 @@ export function UpdateNeededModal(props: UpdateNeededModalProps): JSX.Element { /> updateSubsystem(subsystem)} + onClick={() => { + setInitiatedSubsystemUpdate(subsystem) + updateSubsystem(subsystem) + }} buttonText={t('update_firmware')} width="100%" /> @@ -81,7 +91,12 @@ export function UpdateNeededModal(props: UpdateNeededModalProps): JSX.Element { ) if (status === 'updating' || status === 'queued') { - modalContent = + modalContent = ( + + ) } else if (status === 'done' || instrument?.ok) { modalContent = ( +const mockUseCurrentAllSubsystemUpdateQuery = useCurrentAllSubsystemUpdatesQuery as jest.MockedFunction< + typeof useCurrentAllSubsystemUpdatesQuery +> +const mockUseSubsystemUpdateQuery = useSubsystemUpdateQuery as jest.MockedFunction< + typeof useSubsystemUpdateQuery +> +const mockUpdateInProgressModal = UpdateInProgressModal as jest.MockedFunction< + typeof UpdateInProgressModal +> const render = () => { return renderWithProviders(, { @@ -48,6 +61,15 @@ describe('FirmwareUpdateTakeover', () => { mockUpdateNeededModal.mockReturnValue(<>Mock Update Needed Modal) mockUseCurrentMaintenanceRun.mockReturnValue({ data: undefined } as any) mockUseIsUnboxingFlowOngoing.mockReturnValue(false) + mockUseCurrentAllSubsystemUpdateQuery.mockReturnValue({ + data: undefined, + } as any) + mockUseSubsystemUpdateQuery.mockReturnValue({ + data: undefined, + } as any) + mockUpdateInProgressModal.mockReturnValue( + <>Mock Update In Progress Modal + ) }) it('renders update needed modal when an instrument is not ok', () => { @@ -67,7 +89,7 @@ describe('FirmwareUpdateTakeover', () => { }, } as any) const { queryByText } = render() - expect(queryByText('Mock Update In Progress Modal')).not.toBeInTheDocument() + expect(queryByText('Mock Update Needed Modal')).not.toBeInTheDocument() }) it('does not render modal when a maintenance run is active', () => { @@ -77,12 +99,39 @@ describe('FirmwareUpdateTakeover', () => { }, } as any) const { queryByText } = render() - expect(queryByText('Mock Update In Progress Modal')).not.toBeInTheDocument() + expect(queryByText('Mock Update Needed Modal')).not.toBeInTheDocument() }) - it('should not render modal when unboxing flow is not done', () => { + it('does not not render modal when unboxing flow is not done', () => { mockUseIsUnboxingFlowOngoing.mockReturnValue(true) const { queryByText } = render() - expect(queryByText('Mock Update In Progress Modal')).not.toBeInTheDocument() + expect(queryByText('Mock Update Needed Modal')).not.toBeInTheDocument() + }) + + it('does not render modal when another update is in progress', () => { + mockUseCurrentAllSubsystemUpdateQuery.mockReturnValue({ + data: { + data: [ + { + id: '123', + createdAt: 'today', + subsystem: 'pipette_right', + updateStatus: 'updating', + }, + ], + }, + } as any) + mockUseSubsystemUpdateQuery.mockReturnValue({ + data: { + data: { + subsystem: 'pipette_right', + updateStatus: 20, + } as any, + }, + } as any) + + const { queryByText, getByText } = render() + expect(queryByText('Mock Update Needed Modal')).not.toBeInTheDocument() + getByText('Mock Update In Progress Modal') }) }) diff --git a/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateInProgressModal.test.tsx b/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateInProgressModal.test.tsx index 3fc096e69bb..d5d06fd0a25 100644 --- a/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateInProgressModal.test.tsx +++ b/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateInProgressModal.test.tsx @@ -19,12 +19,13 @@ describe('UpdateInProgressModal', () => { beforeEach(() => { props = { percentComplete: 12, + subsystem: 'pipette_right', } mockProgressBar.mockReturnValue('12' as any) }) it('renders test and progress bar', () => { const { getByText } = render(props) - getByText('Updating firmware...') + getByText('Updating Right Pipette firmware...') getByText('12') }) }) diff --git a/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateNeededModal.test.tsx b/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateNeededModal.test.tsx index 178d237e31b..f9e26646295 100644 --- a/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateNeededModal.test.tsx +++ b/app/src/organisms/FirmwareUpdateModal/__tests__/UpdateNeededModal.test.tsx @@ -48,6 +48,7 @@ describe('UpdateNeededModal', () => { props = { setShowUpdateModal: jest.fn(), subsystem: 'pipette_left', + setInitiatedSubsystemUpdate: jest.fn(), } mockUseInstrumentQuery.mockReturnValue({ data: { diff --git a/app/src/organisms/GripperCard/__tests__/GripperCard.test.tsx b/app/src/organisms/GripperCard/__tests__/GripperCard.test.tsx index 1774bc0b68b..76ab0d39601 100644 --- a/app/src/organisms/GripperCard/__tests__/GripperCard.test.tsx +++ b/app/src/organisms/GripperCard/__tests__/GripperCard.test.tsx @@ -1,25 +1,27 @@ import * as React from 'react' import { resetAllWhenMocks } from 'jest-when' import { renderWithProviders } from '@opentrons/components' +import { useCurrentSubsystemUpdateQuery } from '@opentrons/react-api-client' import { fireEvent } from '@testing-library/react' import { i18n } from '../../../i18n' -import { Banner } from '../../../atoms/Banner' import { GripperWizardFlows } from '../../GripperWizardFlows' import { AboutGripperSlideout } from '../AboutGripperSlideout' import { GripperCard } from '../' import type { GripperData } from '@opentrons/api-client' -jest.mock('../../../atoms/Banner') jest.mock('../../GripperWizardFlows') jest.mock('../AboutGripperSlideout') +jest.mock('@opentrons/react-api-client') -const mockBanner = Banner as jest.MockedFunction const mockGripperWizardFlows = GripperWizardFlows as jest.MockedFunction< typeof GripperWizardFlows > const mockAboutGripperSlideout = AboutGripperSlideout as jest.MockedFunction< typeof AboutGripperSlideout > +const mockUseCurrentSubsystemUpdateQuery = useCurrentSubsystemUpdateQuery as jest.MockedFunction< + typeof useCurrentSubsystemUpdateQuery +> const render = (props: React.ComponentProps) => { return renderWithProviders(, { @@ -35,6 +37,7 @@ describe('GripperCard', () => { instrumentModel: 'gripperV1.1', serialNumber: '123', firmwareVersion: '12', + ok: true, data: { calibratedOffset: { last_modified: '12/2/4', @@ -42,10 +45,13 @@ describe('GripperCard', () => { }, } as GripperData, isCalibrated: true, + setSubsystemToUpdate: jest.fn(), } - mockBanner.mockReturnValue(<>calibration needed) mockGripperWizardFlows.mockReturnValue(<>wizard flow launched) mockAboutGripperSlideout.mockReturnValue(<>about gripper) + mockUseCurrentSubsystemUpdateQuery.mockReturnValue({ + data: undefined, + } as any) }) afterEach(() => { jest.resetAllMocks() @@ -72,6 +78,7 @@ describe('GripperCard', () => { instrumentModel: 'gripperV1.1', serialNumber: '123', firmwareVersion: '12', + ok: true, data: { calibratedOffset: { last_modified: undefined, @@ -79,10 +86,11 @@ describe('GripperCard', () => { }, } as GripperData, isCalibrated: false, + setSubsystemToUpdate: jest.fn(), } const { getByText } = render(props) - getByText('calibration needed') + getByText('Calibration needed.') }) it('opens the about gripper slideout when button is pressed', () => { const { getByText, getByRole } = render(props) @@ -118,6 +126,7 @@ describe('GripperCard', () => { props = { attachedGripper: null, isCalibrated: false, + setSubsystemToUpdate: jest.fn(), } const { getByText, getByRole } = render(props) const overflowButton = getByRole('button', { @@ -128,4 +137,35 @@ describe('GripperCard', () => { attachGripperButton.click() getByText('wizard flow launched') }) + it('renders firmware update needed state if gripper is bad', () => { + props = { + attachedGripper: { + ok: false, + } as any, + isCalibrated: false, + setSubsystemToUpdate: jest.fn(), + } + const { getByText } = render(props) + getByText('Extension mount') + getByText('Instrument attached') + getByText('Firmware update available.') + getByText('Update now').click() + expect(props.setSubsystemToUpdate).toHaveBeenCalledWith('gripper') + }) + it('renders firmware update in progress state if gripper is bad and update in progress', () => { + mockUseCurrentSubsystemUpdateQuery.mockReturnValue({ + data: { data: { updateProgress: 50 } as any }, + } as any) + props = { + attachedGripper: { + ok: false, + } as any, + isCalibrated: true, + setSubsystemToUpdate: jest.fn(), + } + const { getByText } = render(props) + getByText('Extension mount') + getByText('Instrument attached') + getByText('Firmware update in progress...') + }) }) diff --git a/app/src/organisms/GripperCard/index.tsx b/app/src/organisms/GripperCard/index.tsx index d0b1ca936cb..363fbaa116e 100644 --- a/app/src/organisms/GripperCard/index.tsx +++ b/app/src/organisms/GripperCard/index.tsx @@ -1,27 +1,36 @@ import * as React from 'react' import { Trans, useTranslation } from 'react-i18next' import { css } from 'styled-components' -import { GripperData } from '@opentrons/api-client' import { SPACING } from '@opentrons/components' import { getGripperDisplayName, GripperModel } from '@opentrons/shared-data' +import { useCurrentSubsystemUpdateQuery } from '@opentrons/react-api-client' import { Banner } from '../../atoms/Banner' import { StyledText } from '../../atoms/text' import { InstrumentCard } from '../../molecules/InstrumentCard' import { GripperWizardFlows } from '../GripperWizardFlows' import { AboutGripperSlideout } from './AboutGripperSlideout' import { GRIPPER_FLOW_TYPES } from '../GripperWizardFlows/constants' +import type { BadGripper, GripperData, Subsystem } from '@opentrons/api-client' import type { GripperWizardFlowType } from '../GripperWizardFlows/types' interface GripperCardProps { - attachedGripper: GripperData | null + attachedGripper: GripperData | BadGripper | null isCalibrated: boolean + setSubsystemToUpdate: (subsystem: Subsystem | null) => void } +const BANNER_LINK_CSS = css` + text-decoration: underline; + cursor: pointer; + margin-left: ${SPACING.spacing8}; +` +const SUBSYSTEM_UPDATE_POLL_MS = 3000 export function GripperCard({ attachedGripper, isCalibrated, + setSubsystemToUpdate, }: GripperCardProps): JSX.Element { - const { t } = useTranslation(['device_details', 'shared']) + const { t, i18n } = useTranslation(['device_details', 'shared']) const [ openWizardFlowType, setOpenWizardFlowType, @@ -42,9 +51,15 @@ export function GripperCard({ const handleCalibrate: React.MouseEventHandler = () => { setOpenWizardFlowType(GRIPPER_FLOW_TYPES.RECALIBRATE) } - + const { data: subsystemUpdateData } = useCurrentSubsystemUpdateQuery( + 'gripper', + { + enabled: attachedGripper != null && !attachedGripper.ok, + refetchInterval: SUBSYSTEM_UPDATE_POLL_MS, + } + ) const menuOverlayItems = - attachedGripper == null + attachedGripper == null || !attachedGripper.ok ? [ { label: t('attach_gripper'), @@ -74,41 +89,69 @@ export function GripperCard({ ] return ( <> - + {attachedGripper == null || attachedGripper.ok ? ( + + + ), + }} + /> + + ) : null + } + isGripperAttached={attachedGripper != null} + label={t('shared:extension_mount')} + menuOverlayItems={menuOverlayItems} + /> + ) : null} + {attachedGripper?.ok === false ? ( + setSubsystemToUpdate('gripper')} /> ), }} /> - ) : null - } - isGripperAttached={attachedGripper != null} - label={t('shared:extension_mount')} - menuOverlayItems={menuOverlayItems} - /> + } + /> + ) : null} {openWizardFlowType != null ? ( setOpenWizardFlowType(null)} /> ) : null} - {attachedGripper != null && showAboutGripperSlideout && ( + {attachedGripper?.ok && showAboutGripperSlideout && ( isCreateLoading: boolean + createdMaintenanceRunId: string | null } export const BeforeBeginning = ( @@ -70,10 +71,13 @@ export const BeforeBeginning = ( errorMessage, maintenanceRunId, setErrorMessage, + createdMaintenanceRunId, } = props const { t } = useTranslation(['gripper_wizard_flows', 'shared']) React.useEffect(() => { - createMaintenanceRun({}) + if (createdMaintenanceRunId == null) { + createMaintenanceRun({}) + } }, []) const commandsOnProceed: CreateCommand[] = [ diff --git a/app/src/organisms/GripperWizardFlows/__tests__/BeforeBeginning.test.tsx b/app/src/organisms/GripperWizardFlows/__tests__/BeforeBeginning.test.tsx index 3fb757d205b..6b645d6cec1 100644 --- a/app/src/organisms/GripperWizardFlows/__tests__/BeforeBeginning.test.tsx +++ b/app/src/organisms/GripperWizardFlows/__tests__/BeforeBeginning.test.tsx @@ -39,6 +39,7 @@ describe('BeforeBeginning', () => { isRobotMoving: false, setErrorMessage: jest.fn(), errorMessage: null, + createdMaintenanceRunId: null, } // mockNeedHelpLink.mockReturnValue(
mock need help link
) mockInProgressModal.mockReturnValue(
mock in progress
) diff --git a/app/src/organisms/GripperWizardFlows/index.tsx b/app/src/organisms/GripperWizardFlows/index.tsx index 65a8d3f02ab..3910cda685c 100644 --- a/app/src/organisms/GripperWizardFlows/index.tsx +++ b/app/src/organisms/GripperWizardFlows/index.tsx @@ -12,7 +12,6 @@ import { } from '@opentrons/components' import { useCreateMaintenanceCommandMutation, - useCreateMaintenanceRunMutation, useDeleteMaintenanceRunMutation, useCurrentMaintenanceRun, } from '@opentrons/react-api-client' @@ -21,7 +20,10 @@ import { Portal } from '../../App/portal' import { WizardHeader } from '../../molecules/WizardHeader' import { FirmwareUpdateModal } from '../FirmwareUpdateModal' import { getIsOnDevice } from '../../redux/config' -import { useChainMaintenanceCommands } from '../../resources/runs/hooks' +import { + useChainMaintenanceCommands, + useCreateTargetedMaintenanceRunMutation, +} from '../../resources/runs/hooks' import { getGripperWizardSteps } from './getGripperWizardSteps' import { GRIPPER_FLOW_TYPES, SECTIONS } from './constants' import { BeforeBeginning } from './BeforeBeginning' @@ -74,9 +76,9 @@ export function GripperWizardFlows( ] = React.useState(false) const { - createMaintenanceRun, + createTargetedMaintenanceRun, isLoading: isCreateLoading, - } = useCreateMaintenanceRunMutation({ + } = useCreateTargetedMaintenanceRunMutation({ onSuccess: response => { setCreatedMaintenanceRunId(response.data.id) }, @@ -144,9 +146,10 @@ export function GripperWizardFlows( return ( ) } else if (currentStep.section === SECTIONS.MOVE_PIN) { diff --git a/app/src/organisms/InstrumentInfo/index.tsx b/app/src/organisms/InstrumentInfo/index.tsx index bc7b5fb2c31..206a38f10ea 100644 --- a/app/src/organisms/InstrumentInfo/index.tsx +++ b/app/src/organisms/InstrumentInfo/index.tsx @@ -20,7 +20,6 @@ import { GripperWizardFlows } from '../GripperWizardFlows' import { StyledText } from '../../atoms/text' import { MediumButton } from '../../atoms/buttons' import { FLOWS } from '../PipetteWizardFlows/constants' -import { useMaintenanceRunTakeover } from '../TakeoverModal' import { GRIPPER_FLOW_TYPES } from '../GripperWizardFlows/constants' import { formatTimeWithUtcLabel } from '../../resources/runs/utils' @@ -35,7 +34,6 @@ interface InstrumentInfoProps { } export const InstrumentInfo = (props: InstrumentInfoProps): JSX.Element => { const { t, i18n } = useTranslation('instruments_dashboard') - const { setODDMaintenanceFlowInProgress } = useMaintenanceRunTakeover() const { instrument } = props const history = useHistory() const [wizardProps, setWizardProps] = React.useState< @@ -57,11 +55,9 @@ export const InstrumentInfo = (props: InstrumentInfoProps): JSX.Element => { instrument != null && instrument.ok && instrument.mount !== 'extension' && - // @ts-expect-error the mount acts as a type narrower here instrument.data?.channels === 96 const handleDetach: React.MouseEventHandler = () => { - setODDMaintenanceFlowInProgress() if (instrument != null && instrument.ok) { setWizardProps( instrument.mount === 'extension' @@ -89,7 +85,6 @@ export const InstrumentInfo = (props: InstrumentInfoProps): JSX.Element => { } } const handleRecalibrate: React.MouseEventHandler = () => { - setODDMaintenanceFlowInProgress() if (instrument != null && instrument.ok) { setWizardProps( instrument.mount === 'extension' diff --git a/app/src/organisms/InstrumentMountItem/AttachedInstrumentMountItem.tsx b/app/src/organisms/InstrumentMountItem/AttachedInstrumentMountItem.tsx index f90966695ee..e620da0ce56 100644 --- a/app/src/organisms/InstrumentMountItem/AttachedInstrumentMountItem.tsx +++ b/app/src/organisms/InstrumentMountItem/AttachedInstrumentMountItem.tsx @@ -15,7 +15,6 @@ import { FLOWS } from '../PipetteWizardFlows/constants' import { PipetteWizardFlows } from '../PipetteWizardFlows' import { GripperWizardFlows } from '../GripperWizardFlows' import { GRIPPER_FLOW_TYPES } from '../GripperWizardFlows/constants' -import { useMaintenanceRunTakeover } from '../TakeoverModal' import { LabeledMount } from './LabeledMount' import type { InstrumentData } from '@opentrons/api-client' import type { Mount } from '../../redux/pipettes/types' @@ -45,7 +44,6 @@ export function AttachedInstrumentMountItem( selectedPipette, setSelectedPipette, ] = React.useState(SINGLE_MOUNT_PIPETTES) - const { setODDMaintenanceFlowInProgress } = useMaintenanceRunTakeover() const handleClick: React.MouseEventHandler = () => { if (attachedInstrument == null && mount !== 'extension') { @@ -61,7 +59,6 @@ export function AttachedInstrumentMountItem( }, closeFlow: () => setWizardProps(null), }) - setODDMaintenanceFlowInProgress() } else { history.push(`/instruments/${mount}`) } @@ -107,7 +104,6 @@ export function AttachedInstrumentMountItem( ) }, }) - setODDMaintenanceFlowInProgress() setShowChoosePipetteModal(false) }} setSelectedPipette={setSelectedPipette} diff --git a/app/src/organisms/InstrumentMountItem/ProtocolInstrumentMountItem.tsx b/app/src/organisms/InstrumentMountItem/ProtocolInstrumentMountItem.tsx index 0befebe43fb..9e30d1a7518 100644 --- a/app/src/organisms/InstrumentMountItem/ProtocolInstrumentMountItem.tsx +++ b/app/src/organisms/InstrumentMountItem/ProtocolInstrumentMountItem.tsx @@ -23,7 +23,6 @@ import { } from '@opentrons/shared-data' import { SmallButton } from '../../atoms/buttons' -import { useMaintenanceRunTakeover } from '../TakeoverModal' import { FLOWS } from '../PipetteWizardFlows/constants' import { PipetteWizardFlows } from '../PipetteWizardFlows' import { GripperWizardFlows } from '../GripperWizardFlows' @@ -66,7 +65,6 @@ export function ProtocolInstrumentMountItem( ): JSX.Element { const { i18n, t } = useTranslation('protocol_setup') const { mount, attachedInstrument, speccedName, mostRecentAnalysis } = props - const { setODDMaintenanceFlowInProgress } = useMaintenanceRunTakeover() const [ showPipetteWizardFlow, setShowPipetteWizardFlow, @@ -87,7 +85,6 @@ export function ProtocolInstrumentMountItem( speccedName === 'p1000_96' ? NINETY_SIX_CHANNEL : SINGLE_MOUNT_PIPETTES const handleCalibrate: React.MouseEventHandler = () => { - setODDMaintenanceFlowInProgress() setFlowType(FLOWS.CALIBRATE) if (mount === 'extension') { setShowGripperWizardFlow(true) @@ -96,7 +93,6 @@ export function ProtocolInstrumentMountItem( } } const handleAttach: React.MouseEventHandler = () => { - setODDMaintenanceFlowInProgress() setFlowType(FLOWS.ATTACH) if (mount === 'extension') { setShowGripperWizardFlow(true) diff --git a/app/src/organisms/InstrumentMountItem/__tests__/ProtocolInstrumentMountItem.test.tsx b/app/src/organisms/InstrumentMountItem/__tests__/ProtocolInstrumentMountItem.test.tsx index e7cdb2aaa74..6a98f6c5d1b 100644 --- a/app/src/organisms/InstrumentMountItem/__tests__/ProtocolInstrumentMountItem.test.tsx +++ b/app/src/organisms/InstrumentMountItem/__tests__/ProtocolInstrumentMountItem.test.tsx @@ -4,7 +4,6 @@ import { fireEvent } from '@testing-library/react' import { i18n } from '../../../i18n' import { PipetteWizardFlows } from '../../PipetteWizardFlows' import { GripperWizardFlows } from '../../GripperWizardFlows' -import { useMaintenanceRunTakeover } from '../../TakeoverModal' import { ProtocolInstrumentMountItem } from '..' jest.mock('../../PipetteWizardFlows') @@ -17,9 +16,6 @@ const mockPipetteWizardFlows = PipetteWizardFlows as jest.MockedFunction< const mockGripperWizardFlows = GripperWizardFlows as jest.MockedFunction< typeof GripperWizardFlows > -const mockUseMaintenanceRunTakeover = useMaintenanceRunTakeover as jest.MockedFunction< - typeof useMaintenanceRunTakeover -> const mockGripperData = { instrumentModel: 'gripper_v1', @@ -70,7 +66,6 @@ const render = ( describe('ProtocolInstrumentMountItem', () => { let props: React.ComponentProps - const mockSetODDMaintenanceFlowInProgress = jest.fn() beforeEach(() => { props = { mount: LEFT, @@ -80,9 +75,6 @@ describe('ProtocolInstrumentMountItem', () => { } mockPipetteWizardFlows.mockReturnValue(
pipette wizard flow
) mockGripperWizardFlows.mockReturnValue(
gripper wizard flow
) - mockUseMaintenanceRunTakeover.mockReturnValue({ - setODDMaintenanceFlowInProgress: mockSetODDMaintenanceFlowInProgress, - }) }) it('renders the correct information when there is no pipette attached', () => { @@ -116,7 +108,7 @@ describe('ProtocolInstrumentMountItem', () => { getByText('Calibrated') getByText('Flex 8-Channel 1000 μL') }) - it('renders the pipette with no cal data and the calibration button and clicking on it launches the correct flow ', () => { + it('renders the pipette with no cal data and the calibration button and clicking on it launches the correct flow', () => { props = { ...props, mount: LEFT, @@ -134,9 +126,8 @@ describe('ProtocolInstrumentMountItem', () => { const button = getByText('Calibrate') fireEvent.click(button) getByText('pipette wizard flow') - expect(mockSetODDMaintenanceFlowInProgress).toHaveBeenCalled() }) - it('renders the attach button and clicking on it launches the correct flow ', () => { + it('renders the attach button and clicking on it launches the correct flow', () => { props = { ...props, mount: LEFT, @@ -148,7 +139,6 @@ describe('ProtocolInstrumentMountItem', () => { const button = getByText('Attach') fireEvent.click(button) getByText('pipette wizard flow') - expect(mockSetODDMaintenanceFlowInProgress).toHaveBeenCalled() }) it('renders the correct information when gripper needs to be atached', () => { props = { @@ -163,7 +153,6 @@ describe('ProtocolInstrumentMountItem', () => { const button = getByText('Attach') fireEvent.click(button) getByText('gripper wizard flow') - expect(mockSetODDMaintenanceFlowInProgress).toHaveBeenCalled() }) it('renders the correct information when gripper is attached but not calibrated', () => { props = { @@ -178,6 +167,5 @@ describe('ProtocolInstrumentMountItem', () => { const button = getByText('Calibrate') fireEvent.click(button) getByText('gripper wizard flow') - expect(mockSetODDMaintenanceFlowInProgress).toHaveBeenCalled() }) }) diff --git a/app/src/organisms/InterventionModal/MoveLabwareInterventionContent.tsx b/app/src/organisms/InterventionModal/MoveLabwareInterventionContent.tsx index f9bdd46642d..81c667ba048 100644 --- a/app/src/organisms/InterventionModal/MoveLabwareInterventionContent.tsx +++ b/app/src/organisms/InterventionModal/MoveLabwareInterventionContent.tsx @@ -20,31 +20,35 @@ import { RESPONSIVENESS, TEXT_TRANSFORM_UPPERCASE, } from '@opentrons/components' - -import { - getRunLabwareRenderInfo, - getRunModuleRenderInfo, - getLabwareNameFromRunData, - getModuleModelFromRunData, - getModuleDisplayLocationFromRunData, -} from './utils' -import { StyledText } from '../../atoms/text' -import { Divider } from '../../atoms/structure' - import { CompletedProtocolAnalysis, + LabwareDefinitionsByUri, LabwareLocation, MoveLabwareRunTimeCommand, + OT2_ROBOT_TYPE, RobotType, getDeckDefFromRobotType, + getLabwareDisplayName, getLoadedLabwareDefinitionsByUri, getModuleDisplayName, getModuleType, getOccludedSlotCountForModule, getRobotTypeFromLoadedLabware, } from '@opentrons/shared-data' +import { + getRunLabwareRenderInfo, + getRunModuleRenderInfo, + getLabwareNameFromRunData, + getModuleModelFromRunData, + getModuleDisplayLocationFromRunData, +} from './utils' +import { StyledText } from '../../atoms/text' +import { Divider } from '../../atoms/structure' +import { + getLoadedLabware, + getLoadedModule, +} from '../CommandText/utils/accessors' import type { RunData } from '@opentrons/api-client' -import { getLoadedLabware } from '../CommandText/utils/accessors' const LABWARE_DESCRIPTION_STYLE = css` flex-direction: ${DIRECTION_COLUMN}; @@ -169,6 +173,7 @@ export function MoveLabwareInterventionContent({ protocolData={run} location={oldLabwareLocation} robotType={robotType} + labwareDefsByUri={labwareDefsByUri} /> @@ -176,6 +181,7 @@ export function MoveLabwareInterventionContent({ protocolData={run} location={command.params.newLocation} robotType={robotType} + labwareDefsByUri={labwareDefsByUri} /> @@ -225,17 +231,17 @@ interface LabwareDisplayLocationProps { protocolData: RunData location: LabwareLocation robotType: RobotType + labwareDefsByUri: LabwareDefinitionsByUri } function LabwareDisplayLocation( props: LabwareDisplayLocationProps ): JSX.Element { const { t } = useTranslation('protocol_command_text') - const { protocolData, location, robotType } = props + const { protocolData, location, robotType, labwareDefsByUri } = props let displayLocation: React.ReactNode = '' if (location === 'offDeck') { - // typecheck thinks t() can return undefined - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - displayLocation = + // TODO(BC, 08/28/23): remove this string cast after update i18next to >23 (see https://www.i18next.com/overview/typescript#argument-of-type-defaulttfuncreturn-is-not-assignable-to-parameter-of-type-xyz) + displayLocation = } else if ('slotName' in location) { displayLocation = } else if ('moduleId' in location) { @@ -258,8 +264,48 @@ function LabwareDisplayLocation( ), }) } - } else { - console.warn('display location could not be established: ', location) + } else if ('labwareId' in location) { + const adapter = protocolData.labware.find( + lw => lw.id === location.labwareId + ) + const adapterDef = + adapter != null ? labwareDefsByUri[adapter.definitionUri] : null + const adapterDisplayName = + adapterDef != null ? getLabwareDisplayName(adapterDef) : '' + + if (adapter == null) { + console.warn('labware is located on an unknown adapter') + } else if (adapter.location === 'offDeck') { + displayLocation = t('off_deck') + } else if ('slotName' in adapter.location) { + displayLocation = t('adapter_in_slot', { + adapter: adapterDisplayName, + slot_name: adapter.location.slotName, + }) + } else if ('moduleId' in adapter.location) { + const moduleIdUnderAdapter = adapter.location.moduleId + const moduleModel = protocolData.modules.find( + module => module.id === moduleIdUnderAdapter + )?.model + if (moduleModel == null) { + console.warn('labware is located on an adapter on an unknown module') + } else { + const slotName = + getLoadedModule(protocolData, adapter.location.moduleId)?.location + ?.slotName ?? '' + displayLocation = t('adapter_in_module_in_slot', { + count: getOccludedSlotCountForModule( + getModuleType(moduleModel), + robotType ?? OT2_ROBOT_TYPE + ), + module: getModuleDisplayName(moduleModel), + adapter: adapterDisplayName, + slot_name: slotName, + }) + } + } else { + console.warn('display location could not be established: ', location) + } } return <>{displayLocation} } diff --git a/app/src/organisms/InterventionModal/__tests__/InterventionModal.test.tsx b/app/src/organisms/InterventionModal/__tests__/InterventionModal.test.tsx index 2f1317fca63..2c251e1603d 100644 --- a/app/src/organisms/InterventionModal/__tests__/InterventionModal.test.tsx +++ b/app/src/organisms/InterventionModal/__tests__/InterventionModal.test.tsx @@ -45,10 +45,10 @@ describe('InterventionModal', () => { } }) - it('renders an InterventionModal with the robot name in the header, learn more link, and confirm button', () => { + it('renders an InterventionModal with the robot name in the header and confirm button', () => { const { getByText, getByRole } = render(props) getByText('Pause on Otie') - getByText('Learn more about manual steps') + // getByText('Learn more about manual steps') getByRole('button', { name: 'Confirm and resume' }) }) diff --git a/app/src/organisms/InterventionModal/__tests__/utils.test.ts b/app/src/organisms/InterventionModal/__tests__/utils.test.ts index 97a208d202e..8aa5b57b6bb 100644 --- a/app/src/organisms/InterventionModal/__tests__/utils.test.ts +++ b/app/src/organisms/InterventionModal/__tests__/utils.test.ts @@ -14,7 +14,6 @@ import { import { getRunLabwareRenderInfo, getRunModuleRenderInfo, - getLabwareDisplayLocationFromRunData, getLabwareNameFromRunData, getModuleDisplayLocationFromRunData, getModuleModelFromRunData, @@ -32,58 +31,6 @@ const mockGetSlotHasMatingSurfaceUnitVector = getSlotHasMatingSurfaceUnitVector typeof getSlotHasMatingSurfaceUnitVector > -describe('getLabwareDisplayLocationFromRunData', () => { - const mockTranslator = jest.fn() - - it('uses off_deck copy when labware location is off deck', () => { - getLabwareDisplayLocationFromRunData( - mockRunData, - 'offDeck', - mockTranslator, - 'OT-2 Standard' - ) - expect(mockTranslator).toHaveBeenLastCalledWith('off_deck') - }) - - it('uses slot copy and slot name when labware location is a slot', () => { - getLabwareDisplayLocationFromRunData( - mockRunData, - { - slotName: '3', - }, - mockTranslator, - 'OT-2 Standard' - ) - expect(mockTranslator).toHaveBeenLastCalledWith('slot', { slot_name: '3' }) - }) - - it('returns an empty string if the location is a module that cannot be found in protocol data', () => { - const res = getLabwareDisplayLocationFromRunData( - mockRunData, - { moduleId: 'badID' }, - mockTranslator, - 'OT-2 Standard' - ) - - expect(res).toEqual('') - }) - - it('uses module in slot copy when location is a module in the protocol data', () => { - getLabwareDisplayLocationFromRunData( - mockRunData, - { moduleId: 'mockModuleID' }, - mockTranslator, - 'OT-2 Standard' - ) - - expect(mockTranslator).toHaveBeenLastCalledWith('module_in_slot', { - count: 1, - module: 'Heater-Shaker Module GEN1', - slot_name: '3', - }) - }) -}) - describe('getLabwareNameFromRunData', () => { it('returns an empty string if it cannot find matching loaded labware', () => { const res = getLabwareNameFromRunData(mockRunData, 'a bad ID', []) diff --git a/app/src/organisms/InterventionModal/index.tsx b/app/src/organisms/InterventionModal/index.tsx index a51223f3aee..88ffd990132 100644 --- a/app/src/organisms/InterventionModal/index.tsx +++ b/app/src/organisms/InterventionModal/index.tsx @@ -18,11 +18,9 @@ import { DISPLAY_FLEX, DIRECTION_COLUMN, ALIGN_FLEX_START, - TYPOGRAPHY, - JUSTIFY_SPACE_BETWEEN, - Link, Icon, PrimaryButton, + JUSTIFY_FLEX_END, } from '@opentrons/components' import { SmallButton } from '../../atoms/buttons' @@ -84,7 +82,7 @@ const FOOTER_STYLE = { display: DISPLAY_FLEX, width: '100%', alignItems: ALIGN_CENTER, - justifyContent: JUSTIFY_SPACE_BETWEEN, + justifyContent: JUSTIFY_FLEX_END, } as const export interface InterventionModalProps { @@ -204,6 +202,8 @@ export function InterventionModal({ {childContent} + {/* + TODO(BC, 08/31/23): reintroduce this link and justify space between when support article is written {t('protocol_info:manual_steps_learn_more')} + */} {t('confirm_and_resume')} diff --git a/app/src/organisms/InterventionModal/utils/getLabwareDisplayLocationFromRunData.ts b/app/src/organisms/InterventionModal/utils/getLabwareDisplayLocationFromRunData.ts deleted file mode 100644 index b6e4438f49b..00000000000 --- a/app/src/organisms/InterventionModal/utils/getLabwareDisplayLocationFromRunData.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - getModuleDisplayName, - getModuleType, - getOccludedSlotCountForModule, - LabwareLocation, - RobotType, -} from '@opentrons/shared-data' - -import { getModuleDisplayLocationFromRunData } from './getModuleDisplayLocationFromRunData' -import { getModuleModelFromRunData } from './getModuleModelFromRunData' - -import type { TFunction } from 'react-i18next' -import type { RunData } from '@opentrons/api-client' - -export function getLabwareDisplayLocationFromRunData( - protocolData: RunData, - location: LabwareLocation, - t: TFunction<'protocol_command_text'>, - robotType: RobotType -): string { - if (location === 'offDeck') { - return t('off_deck') - } else if ('slotName' in location) { - return t('slot', { slot_name: location.slotName }) - } else if ('moduleId' in location) { - const moduleModel = getModuleModelFromRunData( - protocolData, - location.moduleId - ) - if (moduleModel == null) { - console.warn('labware is located on an unknown module model') - return '' - } else { - return t('module_in_slot', { - count: getOccludedSlotCountForModule( - getModuleType(moduleModel), - robotType - ), - module: getModuleDisplayName(moduleModel), - slot_name: getModuleDisplayLocationFromRunData( - protocolData, - location.moduleId - ), - }) - } - } else { - console.warn('display location could not be established: ', location) - return '' - } -} diff --git a/app/src/organisms/InterventionModal/utils/index.ts b/app/src/organisms/InterventionModal/utils/index.ts index 41dde6e2170..5b72cc9fac7 100644 --- a/app/src/organisms/InterventionModal/utils/index.ts +++ b/app/src/organisms/InterventionModal/utils/index.ts @@ -1,6 +1,5 @@ export * from './getRunLabwareRenderInfo' export * from './getRunModuleRenderInfo' -export * from './getLabwareDisplayLocationFromRunData' export * from './getLabwareNameFromRunData' export * from './getModuleDisplayLocationFromRunData' export * from './getModuleModelFromRunData' diff --git a/app/src/organisms/LabwarePositionCheck/IntroScreen/index.tsx b/app/src/organisms/LabwarePositionCheck/IntroScreen/index.tsx index c2fb104b14b..8bba4649b69 100644 --- a/app/src/organisms/LabwarePositionCheck/IntroScreen/index.tsx +++ b/app/src/organisms/LabwarePositionCheck/IntroScreen/index.tsx @@ -1,6 +1,9 @@ import * as React from 'react' import { Trans, useTranslation } from 'react-i18next' -import { CompletedProtocolAnalysis } from '@opentrons/shared-data' +import { + CompletedProtocolAnalysis, + LabwareDefinition2, +} from '@opentrons/shared-data' import { StyledText } from '../../../atoms/text' import { RobotMotionLoader } from '../RobotMotionLoader' import { getPrepCommands } from './getPrepCommands' @@ -8,11 +11,34 @@ import { useChainRunCommands } from '../../../resources/runs/hooks' import type { RegisterPositionAction } from '../types' import type { Jog } from '../../../molecules/JogControls' import { WizardRequiredEquipmentList } from '../../../molecules/WizardRequiredEquipmentList' -import { GenericWizardTile } from '../../../molecules/GenericWizardTile' import { getIsOnDevice } from '../../../redux/config' +import { NeedHelpLink } from '../../CalibrationPanels' import { useSelector } from 'react-redux' +import { TwoUpTileLayout } from '../TwoUpTileLayout' +import { + ALIGN_CENTER, + Box, + Btn, + COLORS, + DIRECTION_COLUMN, + Flex, + Icon, + JUSTIFY_SPACE_BETWEEN, + PrimaryButton, + SPACING, + TYPOGRAPHY, +} from '@opentrons/components' +import { LabwareOffset } from '@opentrons/api-client' +import { css } from 'styled-components' +import { Portal } from '../../../App/portal' +import { LegacyModalShell } from '../../../molecules/LegacyModal' +import { SmallButton } from '../../../atoms/buttons' +import { TerseOffsetTable } from '../ResultsSummary' +import { getLabwareDefinitionsFromCommands } from '../utils/labware' export const INTERVAL_MS = 3000 + +// TODO(BC, 09/01/23): replace updated support article link for LPC on OT-2/Flex const SUPPORT_PAGE_URL = 'https://support.opentrons.com/s/ot2-calibration' export const IntroScreen = (props: { @@ -23,6 +49,7 @@ export const IntroScreen = (props: { handleJog: Jog setFatalError: (errorMessage: string) => void isRobotMoving: boolean + existingOffsets: LabwareOffset[] }): JSX.Element | null => { const { proceed, @@ -30,9 +57,10 @@ export const IntroScreen = (props: { chainRunCommands, isRobotMoving, setFatalError, + existingOffsets, } = props const isOnDevice = useSelector(getIsOnDevice) - const { t } = useTranslation(['labware_position_check', 'shared']) + const { t, i18n } = useTranslation(['labware_position_check', 'shared']) const handleClickStartLPC = (): void => { const prepCommands = getPrepCommands(protocolData) chainRunCommands(prepCommands, false) @@ -50,17 +78,16 @@ export const IntroScreen = (props: { ) } return ( - }} /> } - rightHandBody={ + rightElement={ } - proceedButtonText={t('shared:get_started')} - proceed={handleClickStartLPC} + footer={ + + {isOnDevice ? ( + + ) : ( + + )} + {isOnDevice ? ( + + ) : ( + + {i18n.format(t('shared:get_started'), 'capitalize')} + + )} + + } /> ) } + +const VIEW_OFFSETS_BUTTON_STYLE = css` + ${TYPOGRAPHY.pSemiBold}; + color: ${COLORS.darkBlackEnabled}; + font-size: ${TYPOGRAPHY.fontSize22}; + &:hover { + opacity: 100%; + } + &:active { + opacity: 70%; + } +` +interface ViewOffsetsProps { + existingOffsets: LabwareOffset[] + labwareDefinitions: LabwareDefinition2[] +} +function ViewOffsets(props: ViewOffsetsProps): JSX.Element { + const { existingOffsets, labwareDefinitions } = props + const { t, i18n } = useTranslation('labware_position_check') + const [showOffsetsTable, setShowOffsetsModal] = React.useState(false) + return existingOffsets.length > 0 ? ( + <> + setShowOffsetsModal(true)} + css={VIEW_OFFSETS_BUTTON_STYLE} + aria-label="show labware offsets" + > + + + {i18n.format(t('view_current_offsets'), 'capitalize')} + + + {showOffsetsTable ? ( + + + {i18n.format(t('labware_offset_data'), 'capitalize')} + + } + footer={ + setShowOffsetsModal(false)} + /> + } + > + + + + + + ) : null} + + ) : ( + + ) +} diff --git a/app/src/organisms/LabwarePositionCheck/LabwarePositionCheckComponent.tsx b/app/src/organisms/LabwarePositionCheck/LabwarePositionCheckComponent.tsx index 9bcdfb1644f..6ca827aae9d 100644 --- a/app/src/organisms/LabwarePositionCheck/LabwarePositionCheckComponent.tsx +++ b/app/src/organisms/LabwarePositionCheck/LabwarePositionCheckComponent.tsx @@ -263,7 +263,9 @@ export const LabwarePositionCheckComponent = ( /> ) } else if (currentStep.section === 'BEFORE_BEGINNING') { - modalContent = + modalContent = ( + + ) } else if ( currentStep.section === 'CHECK_POSITIONS' || currentStep.section === 'CHECK_TIP_RACKS' || diff --git a/app/src/organisms/LabwarePositionCheck/TwoUpTileLayout.tsx b/app/src/organisms/LabwarePositionCheck/TwoUpTileLayout.tsx new file mode 100644 index 00000000000..44ee775f25a --- /dev/null +++ b/app/src/organisms/LabwarePositionCheck/TwoUpTileLayout.tsx @@ -0,0 +1,66 @@ +import * as React from 'react' +import styled, { css } from 'styled-components' +import { + DIRECTION_COLUMN, + Flex, + SPACING, + JUSTIFY_SPACE_BETWEEN, + DIRECTION_ROW, + TYPOGRAPHY, + JUSTIFY_CENTER, + RESPONSIVENESS, + DISPLAY_INLINE_BLOCK, +} from '@opentrons/components' + +const Title = styled.h1` + ${TYPOGRAPHY.h1Default}; + margin-bottom: ${SPACING.spacing8}; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + ${TYPOGRAPHY.level4HeaderSemiBold}; + margin-bottom: 0; + height: ${SPACING.spacing40}; + display: ${DISPLAY_INLINE_BLOCK}; + } +` + +const TILE_CONTAINER_STYLE = css` + flex-direction: ${DIRECTION_COLUMN}; + justify-content: ${JUSTIFY_SPACE_BETWEEN}; + padding: ${SPACING.spacing32}; + height: 24.625rem; + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + height: 29.5rem; + } +` +export interface TwoUpTileLayoutProps { + /** main header text on left half */ + title: string + /** paragraph text below title on left half */ + body: React.ReactNode + /** entire contents of the right half */ + rightElement: React.ReactNode + /** footer underneath both halves of content */ + footer: React.ReactNode +} + +export function TwoUpTileLayout(props: TwoUpTileLayoutProps): JSX.Element { + const { title, body, rightElement, footer } = props + return ( + + + + {title} + {body} + + + {rightElement} + + + {footer} + + ) +} diff --git a/app/src/organisms/LabwarePositionCheck/__tests__/useLaunchLPC.test.tsx b/app/src/organisms/LabwarePositionCheck/__tests__/useLaunchLPC.test.tsx index 7832aa58bc8..2c0b0eb5820 100644 --- a/app/src/organisms/LabwarePositionCheck/__tests__/useLaunchLPC.test.tsx +++ b/app/src/organisms/LabwarePositionCheck/__tests__/useLaunchLPC.test.tsx @@ -1,14 +1,16 @@ import * as React from 'react' +import { Provider } from 'react-redux' +import configureStore from 'redux-mock-store' import { when, resetAllWhenMocks } from 'jest-when' import { renderHook } from '@testing-library/react-hooks' import { renderWithProviders } from '@opentrons/components' import { QueryClient, QueryClientProvider } from 'react-query' import { - useCreateMaintenanceRunMutation, useCreateMaintenanceRunLabwareDefinitionMutation, useDeleteMaintenanceRunMutation, useRunQuery, } from '@opentrons/react-api-client' +import { useCreateTargetedMaintenanceRunMutation } from '../../../resources/runs/hooks' import fixture_tiprack_300_ul from '@opentrons/shared-data/labware/fixtures/2/fixture_tiprack_300_ul.json' import { useMostRecentCompletedAnalysis } from '../useMostRecentCompletedAnalysis' @@ -20,10 +22,11 @@ import { LabwareDefinition2 } from '@opentrons/shared-data' jest.mock('../') jest.mock('@opentrons/react-api-client') +jest.mock('../../../resources/runs/hooks') jest.mock('../useMostRecentCompletedAnalysis') -const mockUseCreateMaintenanceRunMutation = useCreateMaintenanceRunMutation as jest.MockedFunction< - typeof useCreateMaintenanceRunMutation +const mockUseCreateTargetedMaintenanceRunMutation = useCreateTargetedMaintenanceRunMutation as jest.MockedFunction< + typeof useCreateTargetedMaintenanceRunMutation > const mockUseCreateMaintenanceRunLabwareDefinitionMutation = useCreateMaintenanceRunLabwareDefinitionMutation as jest.MockedFunction< typeof useCreateMaintenanceRunLabwareDefinitionMutation @@ -64,6 +67,7 @@ describe('useLaunchLPC hook', () => { let mockCreateMaintenanceRun: jest.Mock let mockCreateLabwareDefinition: jest.Mock let mockDeleteMaintenanceRun: jest.Mock + const mockStore = configureStore() beforeEach(() => { const queryClient = new QueryClient() @@ -78,9 +82,13 @@ describe('useLaunchLPC hook', () => { mockDeleteMaintenanceRun = jest.fn((_data, opts) => { opts?.onSuccess() }) - + const store = mockStore({ isOnDevice: false }) wrapper = ({ children }) => ( - {children} + + + {children} + + ) mockLabwarePositionCheck.mockImplementation(({ onCloseClick }) => (
{ }, }, } as any) - when(mockUseCreateMaintenanceRunMutation) + when(mockUseCreateTargetedMaintenanceRunMutation) .calledWith() .mockReturnValue({ - createMaintenanceRun: mockCreateMaintenanceRun, + createTargetedMaintenanceRun: mockCreateMaintenanceRun, } as any) when(mockUseCreateMaintenanceRunLabwareDefinitionMutation) .calledWith() diff --git a/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx b/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx index 74a09f32262..fe329f80f06 100644 --- a/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx +++ b/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx @@ -1,10 +1,10 @@ import * as React from 'react' import { useCreateMaintenanceRunLabwareDefinitionMutation, - useCreateMaintenanceRunMutation, useDeleteMaintenanceRunMutation, useRunQuery, } from '@opentrons/react-api-client' +import { useCreateTargetedMaintenanceRunMutation } from '../../resources/runs/hooks' import { LabwarePositionCheck } from '.' import { useMostRecentCompletedAnalysis } from './useMostRecentCompletedAnalysis' import { getLabwareDefinitionsFromCommands } from './utils/labware' @@ -13,7 +13,9 @@ export function useLaunchLPC( runId: string ): { launchLPC: () => void; LPCWizard: JSX.Element | null } { const { data: runRecord } = useRunQuery(runId, { staleTime: Infinity }) - const { createMaintenanceRun } = useCreateMaintenanceRunMutation() + const { + createTargetedMaintenanceRun, + } = useCreateTargetedMaintenanceRunMutation() const { deleteMaintenanceRun } = useDeleteMaintenanceRunMutation() const mostRecentAnalysis = useMostRecentCompletedAnalysis(runId) const [maintenanceRunId, setMaintenanceRunId] = React.useState( @@ -35,7 +37,7 @@ export function useLaunchLPC( } return { launchLPC: () => - createMaintenanceRun({ + createTargetedMaintenanceRun({ labwareOffsets: currentOffsets.map( ({ vector, location, definitionUri }) => ({ vector, diff --git a/app/src/organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis.ts b/app/src/organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis.ts index 21ee417f638..4d0ece68dc0 100644 --- a/app/src/organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis.ts +++ b/app/src/organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis.ts @@ -1,5 +1,7 @@ +import last from 'lodash/last' import { - useProtocolAnalysesQuery, + useProtocolAnalysisAsDocumentQuery, + useProtocolQuery, useRunQuery, } from '@opentrons/react-api-client' import { CompletedProtocolAnalysis } from '@opentrons/shared-data' @@ -9,14 +11,14 @@ export function useMostRecentCompletedAnalysis( ): CompletedProtocolAnalysis | null { const { data: runRecord } = useRunQuery(runId) const protocolId = runRecord?.data?.protocolId ?? null - const { data: protocolAnalyses } = useProtocolAnalysesQuery(protocolId) - - return ( - (protocolAnalyses?.data ?? []) - .reverse() - .find( - (analysis): analysis is CompletedProtocolAnalysis => - analysis.status === 'completed' - ) ?? null + const { data: protocolData } = useProtocolQuery(protocolId, { + enabled: protocolId != null, + }) + const { data: analysis } = useProtocolAnalysisAsDocumentQuery( + protocolId, + last(protocolData?.data.analysisSummaries)?.id ?? null, + { enabled: protocolData != null } ) + + return analysis ?? null } diff --git a/app/src/organisms/ModuleCard/ModuleOverflowMenu.tsx b/app/src/organisms/ModuleCard/ModuleOverflowMenu.tsx index 457807d56e5..3b6ef0207a8 100644 --- a/app/src/organisms/ModuleCard/ModuleOverflowMenu.tsx +++ b/app/src/organisms/ModuleCard/ModuleOverflowMenu.tsx @@ -5,7 +5,6 @@ import { Flex, POSITION_RELATIVE } from '@opentrons/components' import { MenuList } from '../../atoms/MenuList' import { MenuItem } from '../../atoms/MenuList/MenuItem' -import { useFeatureFlag } from '../../redux/config' import { useCurrentRunId } from '../ProtocolUpload/hooks' import { useIsOT3, @@ -24,6 +23,7 @@ interface ModuleOverflowMenuProps { handleInstructionsClick: () => void handleCalibrateClick: () => void isLoadedInRun: boolean + isPipetteReady: boolean robotName: string runId?: string } @@ -41,9 +41,10 @@ export const ModuleOverflowMenu = ( handleInstructionsClick, handleCalibrateClick, isLoadedInRun, + isPipetteReady, } = props - const { t } = useTranslation('module_wizard_flows') + const { t, i18n } = useTranslation('module_wizard_flows') const currentRunId = useCurrentRunId() const { isRunTerminal, isRunStill } = useRunStatuses() @@ -52,8 +53,6 @@ export const ModuleOverflowMenu = ( const isIncompatibleWithOT3 = isOT3 && module.moduleModel === 'thermocyclerModuleV1' - const enableModuleCalibration = useFeatureFlag('enableModuleCalibration') - let isDisabled: boolean = false if (runId != null && isLoadedInRun) { isDisabled = !isRunStill @@ -78,8 +77,15 @@ export const ModuleOverflowMenu = ( return ( - {enableModuleCalibration ? ( - {t('calibrate')} + {isOT3 ? ( + + {i18n.format( + module.moduleOffset?.last_modified != null + ? t('recalibrate') + : t('calibrate'), + 'capitalize' + )} + ) : null} {menuOverflowItemsByModuleType[module.moduleType].map( (item: any, index: number) => { diff --git a/app/src/organisms/ModuleCard/ModuleSetupModal.tsx b/app/src/organisms/ModuleCard/ModuleSetupModal.tsx new file mode 100644 index 00000000000..d6c3dceda2d --- /dev/null +++ b/app/src/organisms/ModuleCard/ModuleSetupModal.tsx @@ -0,0 +1,74 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { StyledText } from '../../atoms/text' +import code from '../../assets/images/module_instruction_code.png' +import { + ALIGN_FLEX_END, + Flex, + DIRECTION_COLUMN, + TYPOGRAPHY, + SPACING, + PrimaryButton, + Icon, + DIRECTION_ROW, + Link, +} from '@opentrons/components' +import { LegacyModal } from '../../molecules/LegacyModal' +import { Portal } from '../../App/portal' + +const MODULE_SETUP_URL = 'https://support.opentrons.com/s/modules' + +interface ModuleSetupModalProps { + close: () => void + moduleDisplayName: string +} + +export const ModuleSetupModal = (props: ModuleSetupModalProps): JSX.Element => { + const { moduleDisplayName } = props + const { t, i18n } = useTranslation(['protocol_setup', 'shared']) + + return ( + + + + + + + {t('modal_instructions')} + + + {t('module_instructions_link', { + moduleName: moduleDisplayName, + })} + + + + + + + {i18n.format(t('shared:close'), 'capitalize')} + + + + + ) +} diff --git a/app/src/organisms/ModuleCard/TestShakeSlideout.tsx b/app/src/organisms/ModuleCard/TestShakeSlideout.tsx index 1724d8d948e..02ed2417fcb 100644 --- a/app/src/organisms/ModuleCard/TestShakeSlideout.tsx +++ b/app/src/organisms/ModuleCard/TestShakeSlideout.tsx @@ -34,9 +34,9 @@ import { Divider } from '../../atoms/structure' import { InputField } from '../../atoms/InputField' import { Tooltip } from '../../atoms/Tooltip' import { StyledText } from '../../atoms/text' -import { HeaterShakerWizard } from '../Devices/HeaterShakerWizard' import { ConfirmAttachmentModal } from './ConfirmAttachmentModal' import { useLatchControls } from './hooks' +import { ModuleSetupModal } from './ModuleSetupModal' import type { HeaterShakerModule, LatchStatus } from '../../redux/modules/types' import type { @@ -64,7 +64,10 @@ export const TestShakeSlideout = ( const { toggleLatch, isLatchClosed } = useLatchControls(module) const configHasHeaterShakerAttached = useSelector(getIsHeaterShakerAttached) const [shakeValue, setShakeValue] = React.useState(null) - const [showWizard, setShowWizard] = React.useState(false) + const [ + showModuleSetupModal, + setShowModuleSetupModal, + ] = React.useState(false) const isShaking = module.data.speedStatus !== 'idle' const setShakeCommand: HeaterShakerSetAndWaitForShakeSpeedCreateCommand = { @@ -286,10 +289,10 @@ export const TestShakeSlideout = ( ) : null} - {showWizard && ( - setShowWizard(false)} - attachedModule={module} + {showModuleSetupModal && ( + setShowModuleSetupModal(false)} + moduleDisplayName={getModuleDisplayName(module.moduleModel)} /> )} setShowWizard(true)} + onClick={() => setShowModuleSetupModal(true)} > {t('show_attachment_instructions')} diff --git a/app/src/organisms/ModuleCard/__tests__/ModuleCard.test.tsx b/app/src/organisms/ModuleCard/__tests__/ModuleCard.test.tsx index 74a10a1385d..04334542bac 100644 --- a/app/src/organisms/ModuleCard/__tests__/ModuleCard.test.tsx +++ b/app/src/organisms/ModuleCard/__tests__/ModuleCard.test.tsx @@ -11,6 +11,7 @@ import { import { useCurrentRunStatus } from '../../RunTimeControl/hooks' import * as RobotApi from '../../../redux/robot-api' import { useToaster } from '../../ToasterOven' +import { useIsOT3 } from '../../Devices/hooks' import { MagneticModuleData } from '../MagneticModuleData' import { TemperatureModuleData } from '../TemperatureModuleData' import { ThermocyclerModuleData } from '../ThermocyclerModuleData' @@ -52,6 +53,7 @@ jest.mock('react-router-dom', () => { useHistory: () => ({ push: jest.fn() } as any), } }) +jest.mock('../../../organisms/Devices/hooks') const mockMagneticModuleData = MagneticModuleData as jest.MockedFunction< typeof MagneticModuleData @@ -203,6 +205,7 @@ const mockHotThermo = { portGroup: 'unknown', }, } as ThermocyclerModule +const mockUseIsOT3 = useIsOT3 as jest.MockedFunction const mockMakeSnackbar = jest.fn() const mockMakeToast = jest.fn() @@ -250,6 +253,7 @@ describe('ModuleCard', () => { when(mockUseCurrentRunStatus) .calledWith(expect.any(Object)) .mockReturnValue(RUN_STATUS_IDLE) + when(mockUseIsOT3).calledWith(props.robotName).mockReturnValue(true) }) afterEach(() => { jest.resetAllMocks() diff --git a/app/src/organisms/ModuleCard/__tests__/ModuleOverflowMenu.test.tsx b/app/src/organisms/ModuleCard/__tests__/ModuleOverflowMenu.test.tsx index 5e8cfb1c6de..200a0f7bf17 100644 --- a/app/src/organisms/ModuleCard/__tests__/ModuleOverflowMenu.test.tsx +++ b/app/src/organisms/ModuleCard/__tests__/ModuleOverflowMenu.test.tsx @@ -190,6 +190,7 @@ describe('ModuleOverflowMenu', () => { handleInstructionsClick: jest.fn(), handleCalibrateClick: jest.fn(), isLoadedInRun: false, + isPipetteReady: true, } }) @@ -205,14 +206,8 @@ describe('ModuleOverflowMenu', () => { it('renders the correct temperature module menu', () => { props = { - robotName: 'otie', + ...props, module: mockTemperatureModuleGen2, - handleSlideoutClick: jest.fn(), - handleAboutClick: jest.fn(), - handleTestShakeClick: jest.fn(), - handleInstructionsClick: jest.fn(), - handleCalibrateClick: jest.fn(), - isLoadedInRun: false, } const { getByRole } = render(props) const buttonSetting = getByRole('button', { @@ -226,14 +221,8 @@ describe('ModuleOverflowMenu', () => { }) it('renders the correct TC module menu', () => { props = { - robotName: 'otie', + ...props, module: mockThermocycler, - handleSlideoutClick: jest.fn(), - handleAboutClick: jest.fn(), - handleTestShakeClick: jest.fn(), - handleInstructionsClick: jest.fn(), - handleCalibrateClick: jest.fn(), - isLoadedInRun: false, } const { getByRole } = render(props) const buttonSettingLid = getByRole('button', { @@ -253,14 +242,8 @@ describe('ModuleOverflowMenu', () => { }) it('renders the correct Heater Shaker module menu', () => { props = { - robotName: 'otie', + ...props, module: mockHeaterShaker, - handleSlideoutClick: jest.fn(), - handleAboutClick: jest.fn(), - handleTestShakeClick: jest.fn(), - handleInstructionsClick: jest.fn(), - handleCalibrateClick: jest.fn(), - isLoadedInRun: false, } const { getByRole } = render(props) getByRole('button', { @@ -279,14 +262,8 @@ describe('ModuleOverflowMenu', () => { }) it('renders heater shaker show attachment instructions button and when clicked, launches hs wizard', () => { props = { - robotName: 'otie', + ...props, module: mockHeaterShaker, - handleSlideoutClick: jest.fn(), - handleAboutClick: jest.fn(), - handleTestShakeClick: jest.fn(), - handleInstructionsClick: jest.fn(), - handleCalibrateClick: jest.fn(), - isLoadedInRun: false, } const { getByRole } = render(props) const btn = getByRole('button', { name: 'Show attachment instructions' }) @@ -296,14 +273,8 @@ describe('ModuleOverflowMenu', () => { it('renders heater shaker labware latch button and is disabled when status is not idle', () => { props = { - robotName: 'otie', + ...props, module: mockMovingHeaterShaker, - handleSlideoutClick: jest.fn(), - handleAboutClick: jest.fn(), - handleTestShakeClick: jest.fn(), - handleInstructionsClick: jest.fn(), - handleCalibrateClick: jest.fn(), - isLoadedInRun: false, } const { getByRole } = render(props) expect( @@ -315,14 +286,8 @@ describe('ModuleOverflowMenu', () => { it('renders heater shaker labware latch button and when clicked, moves labware latch open', () => { props = { - robotName: 'otie', + ...props, module: mockCloseLatchHeaterShaker, - handleSlideoutClick: jest.fn(), - handleAboutClick: jest.fn(), - handleTestShakeClick: jest.fn(), - handleInstructionsClick: jest.fn(), - handleCalibrateClick: jest.fn(), - isLoadedInRun: false, } const { getByRole } = render(props) @@ -336,14 +301,8 @@ describe('ModuleOverflowMenu', () => { it('renders heater shaker labware latch button and when clicked, moves labware latch close', () => { props = { - robotName: 'otie', + ...props, module: mockHeaterShaker, - handleSlideoutClick: jest.fn(), - handleAboutClick: jest.fn(), - handleTestShakeClick: jest.fn(), - handleInstructionsClick: jest.fn(), - handleCalibrateClick: jest.fn(), - isLoadedInRun: false, } const { getByRole } = render(props) @@ -356,14 +315,8 @@ describe('ModuleOverflowMenu', () => { it('renders heater shaker overflow menu and deactivates heater when status changes', () => { props = { - robotName: 'otie', + ...props, module: mockDeactivateHeatHeaterShaker, - handleSlideoutClick: jest.fn(), - handleAboutClick: jest.fn(), - handleTestShakeClick: jest.fn(), - handleInstructionsClick: jest.fn(), - handleCalibrateClick: jest.fn(), - isLoadedInRun: false, } const { getByRole } = render(props) @@ -377,14 +330,8 @@ describe('ModuleOverflowMenu', () => { it('renders temperature module overflow menu and deactivates heat when status changes', () => { props = { - robotName: 'otie', + ...props, module: mockTemperatureModuleHeating, - handleSlideoutClick: jest.fn(), - handleAboutClick: jest.fn(), - handleTestShakeClick: jest.fn(), - handleInstructionsClick: jest.fn(), - handleCalibrateClick: jest.fn(), - isLoadedInRun: false, } const { getByRole } = render(props) @@ -398,14 +345,8 @@ describe('ModuleOverflowMenu', () => { it('renders magnetic module overflow menu and disengages when status changes', () => { props = { - robotName: 'otie', + ...props, module: mockMagDeckEngaged, - handleSlideoutClick: jest.fn(), - handleAboutClick: jest.fn(), - handleTestShakeClick: jest.fn(), - handleInstructionsClick: jest.fn(), - handleCalibrateClick: jest.fn(), - isLoadedInRun: false, } const { getByRole } = render(props) @@ -419,14 +360,8 @@ describe('ModuleOverflowMenu', () => { it('renders thermocycler overflow menu and deactivates block when status changes', () => { props = { - robotName: 'otie', + ...props, module: mockTCBlockHeating, - handleSlideoutClick: jest.fn(), - handleAboutClick: jest.fn(), - handleTestShakeClick: jest.fn(), - handleInstructionsClick: jest.fn(), - handleCalibrateClick: jest.fn(), - isLoadedInRun: false, } const { getByRole } = render(props) @@ -447,13 +382,8 @@ describe('ModuleOverflowMenu', () => { isRunIdle: true, }) props = { - robotName: 'otie', + ...props, module: mockTCBlockHeating, - handleSlideoutClick: jest.fn(), - handleAboutClick: jest.fn(), - handleTestShakeClick: jest.fn(), - handleInstructionsClick: jest.fn(), - handleCalibrateClick: jest.fn(), isLoadedInRun: true, runId: 'id', } @@ -469,13 +399,8 @@ describe('ModuleOverflowMenu', () => { it('should disable overflow menu buttons for thermocycler gen 1 when the robot is an OT-3', () => { props = { - robotName: 'otie', + ...props, module: mockTCBlockHeating, - handleSlideoutClick: jest.fn(), - handleAboutClick: jest.fn(), - handleTestShakeClick: jest.fn(), - handleInstructionsClick: jest.fn(), - handleCalibrateClick: jest.fn(), isLoadedInRun: true, runId: 'id', } @@ -506,14 +431,8 @@ describe('ModuleOverflowMenu', () => { it('renders the correct Thermocycler gen 2 menu', () => { props = { - robotName: 'otie', + ...props, module: mockThermocyclerGen2, - handleSlideoutClick: jest.fn(), - handleAboutClick: jest.fn(), - handleTestShakeClick: jest.fn(), - handleInstructionsClick: jest.fn(), - handleCalibrateClick: jest.fn(), - isLoadedInRun: false, } const { getByRole } = render(props) const setLid = getByRole('button', { @@ -534,14 +453,8 @@ describe('ModuleOverflowMenu', () => { it('renders the correct Thermocycler gen 2 menu with the lid closed', () => { props = { - robotName: 'otie', + ...props, module: mockThermocyclerGen2LidClosed, - handleSlideoutClick: jest.fn(), - handleAboutClick: jest.fn(), - handleTestShakeClick: jest.fn(), - handleInstructionsClick: jest.fn(), - handleCalibrateClick: jest.fn(), - isLoadedInRun: false, } const { getByRole } = render(props) const setLid = getByRole('button', { @@ -570,14 +483,8 @@ describe('ModuleOverflowMenu', () => { }) props = { - robotName: 'otie', + ...props, module: mockThermocyclerGen2LidClosed, - handleSlideoutClick: jest.fn(), - handleAboutClick: jest.fn(), - handleTestShakeClick: jest.fn(), - handleInstructionsClick: jest.fn(), - handleCalibrateClick: jest.fn(), - isLoadedInRun: false, } const { getByRole } = render(props) const setLid = getByRole('button', { @@ -593,4 +500,39 @@ describe('ModuleOverflowMenu', () => { expect(setBlock).toBeDisabled() expect(about).not.toBeDisabled() }) + + it('not render calibrate button when a robot is OT-2', () => { + props = { + ...props, + isPipetteReady: false, + } + const { queryByRole } = render(props) + + const calibrate = queryByRole('button', { name: 'Calibrate' }) + expect(calibrate).not.toBeInTheDocument() + }) + + it('renders a disabled calibrate button if the pipettes are not attached or need a firmware update', () => { + mockUseIsOT3.mockReturnValue(true) + props = { + ...props, + isPipetteReady: false, + } + const { getByRole } = render(props) + + const calibrate = getByRole('button', { name: 'Calibrate' }) + expect(calibrate).toBeDisabled() + }) + + it('a mock function should be called when clicking Calibrate if pipette is ready', () => { + mockUseIsOT3.mockReturnValue(true) + props = { + ...props, + isPipetteReady: true, + } + const { getByRole } = render(props) + + getByRole('button', { name: 'Calibrate' }).click() + expect(props.handleCalibrateClick).toHaveBeenCalled() + }) }) diff --git a/app/src/organisms/ModuleCard/__tests__/ModuleSetupModal.test.tsx b/app/src/organisms/ModuleCard/__tests__/ModuleSetupModal.test.tsx new file mode 100644 index 00000000000..cfc407e2fd1 --- /dev/null +++ b/app/src/organisms/ModuleCard/__tests__/ModuleSetupModal.test.tsx @@ -0,0 +1,44 @@ +import * as React from 'react' +import { fireEvent } from '@testing-library/react' +import { renderWithProviders } from '@opentrons/components' +import { i18n } from '../../../i18n' +import { ModuleSetupModal } from '../ModuleSetupModal' + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + })[0] +} + +describe('ModuleSetupModal', () => { + let props: React.ComponentProps + beforeEach(() => { + props = { close: jest.fn(), moduleDisplayName: 'mockModuleDisplayName' } + }) + + it('should render the correct header', () => { + const { getByRole } = render(props) + getByRole('heading', { name: 'mockModuleDisplayName Setup Instructions' }) + }) + it('should render the correct body', () => { + const { getByText } = render(props) + getByText( + 'For step-by-step instructions on setting up your module, consult the Quickstart Guide that came in its box. You can also click the link below or scan the QR code to visit the modules section of the Opentrons Help Center.' + ) + }) + it('should render a link to the learn more page', () => { + const { getByRole } = render(props) + expect( + getByRole('link', { + name: 'mockModuleDisplayName setup instructions', + }).getAttribute('href') + ).toBe('https://support.opentrons.com/s/modules') + }) + it('should call close when the close button is pressed', () => { + const { getByRole } = render(props) + expect(props.close).not.toHaveBeenCalled() + const closeButton = getByRole('button', { name: 'Close' }) + fireEvent.click(closeButton) + expect(props.close).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/ModuleCard/__tests__/TestShakeSlideout.test.tsx b/app/src/organisms/ModuleCard/__tests__/TestShakeSlideout.test.tsx index fbe49b90023..b3a8de07e6a 100644 --- a/app/src/organisms/ModuleCard/__tests__/TestShakeSlideout.test.tsx +++ b/app/src/organisms/ModuleCard/__tests__/TestShakeSlideout.test.tsx @@ -5,14 +5,14 @@ import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' import { renderWithProviders } from '@opentrons/components' import { getIsHeaterShakerAttached } from '../../../redux/config' import { mockHeaterShaker } from '../../../redux/modules/__fixtures__' -import { HeaterShakerWizard } from '../../Devices/HeaterShakerWizard' import { useLatchControls } from '../hooks' import { TestShakeSlideout } from '../TestShakeSlideout' +import { ModuleSetupModal } from '../ModuleSetupModal' jest.mock('../../../redux/config') jest.mock('@opentrons/react-api-client') jest.mock('../hooks') -jest.mock('../../Devices/HeaterShakerWizard') +jest.mock('../ModuleSetupModal') const mockGetIsHeaterShakerAttached = getIsHeaterShakerAttached as jest.MockedFunction< typeof getIsHeaterShakerAttached @@ -23,8 +23,8 @@ const mockUseLiveCommandMutation = useCreateLiveCommandMutation as jest.MockedFu const mockUseLatchControls = useLatchControls as jest.MockedFunction< typeof useLatchControls > -const mockHeaterShakerWizard = HeaterShakerWizard as jest.MockedFunction< - typeof HeaterShakerWizard +const mockModuleSetupModal = ModuleSetupModal as jest.MockedFunction< + typeof ModuleSetupModal > const render = (props: React.ComponentProps) => { @@ -151,12 +151,12 @@ describe('TestShakeSlideout', () => { }) it('renders show attachment instructions link', () => { - mockHeaterShakerWizard.mockReturnValue(
mock HeaterShakerWizard
) + mockModuleSetupModal.mockReturnValue(
mockModuleSetupModal
) const { getByText } = render(props) const button = getByText('Show attachment instructions') fireEvent.click(button) - getByText('mock HeaterShakerWizard') + getByText('mockModuleSetupModal') }) it('start shake button should be disabled if the labware latch is open', () => { diff --git a/app/src/organisms/ModuleCard/index.tsx b/app/src/organisms/ModuleCard/index.tsx index 166dcac4f68..7aa47823156 100644 --- a/app/src/organisms/ModuleCard/index.tsx +++ b/app/src/organisms/ModuleCard/index.tsx @@ -2,6 +2,8 @@ import * as React from 'react' import { Trans, useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' import last from 'lodash/last' +import { useHistory } from 'react-router-dom' + import { Box, Flex, @@ -26,7 +28,8 @@ import { THERMOCYCLER_MODULE_TYPE, } from '@opentrons/shared-data' import { RUN_STATUS_FINISHING, RUN_STATUS_RUNNING } from '@opentrons/api-client' -import { useHistory } from 'react-router-dom' +import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' + import { OverflowBtn } from '../../atoms/MenuList/OverflowBtn' import { updateModule } from '../../redux/modules' import { @@ -45,7 +48,6 @@ import { useMenuHandleClickOutside } from '../../atoms/MenuList/hooks' import { Tooltip } from '../../atoms/Tooltip' import { StyledText } from '../../atoms/text' import { useCurrentRunStatus } from '../RunTimeControl/hooks' -import { HeaterShakerWizard } from '../Devices/HeaterShakerWizard' import { useToaster } from '../ToasterOven' import { MagneticModuleData } from './MagneticModuleData' import { TemperatureModuleData } from './TemperatureModuleData' @@ -61,21 +63,27 @@ import { TestShakeSlideout } from './TestShakeSlideout' import { ModuleWizardFlows } from '../ModuleWizardFlows' import { getModuleCardImage } from './utils' import { FirmwareUpdateFailedModal } from './FirmwareUpdateFailedModal' +import { useLatchControls } from './hooks' import { ErrorInfo } from './ErrorInfo' +import type { + HeaterShakerDeactivateShakerCreateCommand, + TCOpenLidCreateCommand, +} from '@opentrons/shared-data/protocol/types/schemaV7/command/module' import type { AttachedModule, HeaterShakerModule, } from '../../redux/modules/types' import type { State, Dispatch } from '../../redux/types' import type { RequestState } from '../../redux/robot-api/types' +import { ModuleSetupModal } from './ModuleSetupModal' interface ModuleCardProps { module: AttachedModule robotName: string isLoadedInRun: boolean - attachPipetteRequired?: boolean - updatePipetteFWRequired?: boolean + attachPipetteRequired: boolean + updatePipetteFWRequired: boolean runId?: string slotName?: string } @@ -106,8 +114,8 @@ export const ModuleCard = (props: ModuleCardProps): JSX.Element | null => { const [showAboutModule, setShowAboutModule] = React.useState(false) const [showTestShake, setShowTestShake] = React.useState(false) const [showHSWizard, setShowHSWizard] = React.useState(false) - const [showFWBanner, setshowFWBanner] = React.useState(true) - const [showCalModal, setshowCalModal] = React.useState(false) + const [showFWBanner, setShowFWBanner] = React.useState(true) + const [showCalModal, setShowCalModal] = React.useState(false) const [targetProps, tooltipProps] = useHoverTooltip() const history = useHistory() @@ -120,10 +128,14 @@ export const ModuleCard = (props: ModuleCardProps): JSX.Element | null => { }, }) const requireModuleCalibration = module.moduleOffset == null + const isPipetteReady = + (!attachPipetteRequired ?? false) && (!updatePipetteFWRequired ?? false) const latestRequestId = last(requestIds) const latestRequest = useSelector(state => latestRequestId ? getRequestById(state, latestRequestId) : null ) + const { createLiveCommand } = useCreateLiveCommandMutation() + const handleCloseErrorModal = (): void => { if (latestRequestId != null) { dispatch(dismissRequest(latestRequestId)) @@ -162,6 +174,8 @@ export const ModuleCard = (props: ModuleCardProps): JSX.Element | null => { const isTooHot = heaterShakerTooHot || ThermoTooHot + const { toggleLatch, isLatchClosed } = useLatchControls(module) + let moduleData: JSX.Element =
switch (module.moduleType) { case 'magneticModuleType': { @@ -223,7 +237,47 @@ export const ModuleCard = (props: ModuleCardProps): JSX.Element | null => { } const handleCalibrateClick = (): void => { - setshowCalModal(true) + if ( + module.moduleType === HEATERSHAKER_MODULE_TYPE && + module.data.currentSpeed != null && + module?.data?.currentSpeed > 0 + ) { + const stopShakeCommand: HeaterShakerDeactivateShakerCreateCommand = { + commandType: 'heaterShaker/deactivateShaker', + params: { + moduleId: module.id, + }, + } + createLiveCommand({ + command: stopShakeCommand, + }).catch((e: Error) => { + console.error( + `error setting module status with command type ${stopShakeCommand.commandType}: ${e.message}` + ) + }) + } + if (module.moduleType === HEATERSHAKER_MODULE_TYPE && !isLatchClosed) { + toggleLatch() + } + if ( + module.moduleType === THERMOCYCLER_MODULE_TYPE && + module.data.lidStatus !== 'open' + ) { + const lidCommand: TCOpenLidCreateCommand = { + commandType: 'thermocycler/openLid', + params: { + moduleId: module.id, + }, + } + createLiveCommand({ + command: lidCommand, + }).catch((e: Error) => { + console.error( + `error setting thermocycler module status with command type ${lidCommand.commandType}: ${e.message}` + ) + }) + } + setShowCalModal(true) } return ( @@ -236,13 +290,13 @@ export const ModuleCard = (props: ModuleCardProps): JSX.Element | null => { {showCalModal ? ( setshowCalModal(false)} + closeFlow={() => setShowCalModal(false)} /> ) : null} {showHSWizard && module.moduleType === HEATERSHAKER_MODULE_TYPE && ( - setShowHSWizard(false)} - attachedModule={module} + setShowHSWizard(false)} + moduleDisplayName={getModuleDisplayName(module.moduleModel)} /> )} {showSlideout && ( @@ -296,10 +350,11 @@ export const ModuleCard = (props: ModuleCardProps): JSX.Element | null => { requireModuleCalibration && !isPending ? ( null} - handleUpdateClick={() => setshowCalModal(true)} + handleUpdateClick={() => setShowCalModal(true)} attachPipetteRequired={attachPipetteRequired} updatePipetteFWRequired={updatePipetteFWRequired} /> @@ -310,9 +365,10 @@ export const ModuleCard = (props: ModuleCardProps): JSX.Element | null => { showFWBanner && !isPending ? ( ) : null} @@ -430,6 +486,7 @@ export const ModuleCard = (props: ModuleCardProps): JSX.Element | null => { robotName={robotName} runId={runId} isLoadedInRun={isLoadedInRun} + isPipetteReady={isPipetteReady} handleSlideoutClick={handleMenuItemClick} handleTestShakeClick={handleTestShakeClick} handleInstructionsClick={handleInstructionsClick} diff --git a/app/src/organisms/ModuleWizardFlows/AttachProbe.tsx b/app/src/organisms/ModuleWizardFlows/AttachProbe.tsx index b98984730d3..99e90b52799 100644 --- a/app/src/organisms/ModuleWizardFlows/AttachProbe.tsx +++ b/app/src/organisms/ModuleWizardFlows/AttachProbe.tsx @@ -1,7 +1,15 @@ import * as React from 'react' -import { useTranslation } from 'react-i18next' import { css } from 'styled-components' - +import attachProbe1 from '../../assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_1.webm' +import attachProbe8 from '../../assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_8.webm' +import attachProbe96 from '../../assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_96.webm' +import { Trans, useTranslation } from 'react-i18next' +import { + LEFT, + THERMOCYCLER_MODULE_MODELS, +} from '@opentrons/shared-data/js/constants' +import { getModuleDisplayName } from '@opentrons/shared-data/js/modules' +import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' import { Flex, RESPONSIVENESS, @@ -11,11 +19,24 @@ import { import { StyledText } from '../../atoms/text' import { GenericWizardTile } from '../../molecules/GenericWizardTile' -import pipetteProbe1 from '../../assets/videos/pipette-wizard-flows/Pipette_Attach_Probe_1.webm' import type { ModuleCalibrationWizardStepProps } from './types' +interface AttachProbeProps extends ModuleCalibrationWizardStepProps { + isExiting: boolean + adapterId: string | null +} + +const IN_PROGRESS_STYLE = css` + ${TYPOGRAPHY.pRegular}; + text-align: ${TYPOGRAPHY.textAlignCenter}; -export const BODY_STYLE = css` + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + font-size: ${TYPOGRAPHY.fontSize28}; + line-height: 1.625rem; + margin-top: ${SPACING.spacing4}; + } +` +const BODY_STYLE = css` ${TYPOGRAPHY.pRegular}; @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { @@ -24,18 +45,42 @@ export const BODY_STYLE = css` } ` -export const AttachProbe = ( - props: ModuleCalibrationWizardStepProps -): JSX.Element | null => { - const { proceed, goBack } = props - const { t } = useTranslation('module_wizard_flows') +export const AttachProbe = (props: AttachProbeProps): JSX.Element | null => { + const { + proceed, + goBack, + chainRunCommands, + setErrorMessage, + adapterId, + isRobotMoving, + attachedModule, + attachedPipette, + isExiting, + isOnDevice, + slotName, + } = props + const { t, i18n } = useTranslation('module_wizard_flows') - const handleOnClick = (): void => { - // TODO: send calibration/calibrateModule command here - proceed() + const moduleDisplayName = getModuleDisplayName(attachedModule.moduleModel) + + const attachedPipetteChannels = attachedPipette.data.channels + let pipetteAttachProbeVideoSource, i18nKey + switch (attachedPipetteChannels) { + case 1: + pipetteAttachProbeVideoSource = attachProbe1 + i18nKey = 'install_probe' + break + case 8: + pipetteAttachProbeVideoSource = attachProbe8 + i18nKey = 'install_probe_8_channel' + break + case 96: + pipetteAttachProbeVideoSource = attachProbe96 + i18nKey = 'install_probe_96_channel' + break } - const pipetteProbeVid = ( + const pipetteAttachProbeVid = ( ) - const bodyText = ( - {t('calibration_probe')} - ) + let moduleCalibratingDisplay + if ( + THERMOCYCLER_MODULE_MODELS.some( + model => model === attachedModule.moduleModel + ) + ) { + moduleCalibratingDisplay = t('calibration_probe_touching', { + module: moduleDisplayName, + slotName: slotName, + }) + } else { + moduleCalibratingDisplay = t('calibration_probe_touching', { + module: moduleDisplayName, + }) + } + + if (isRobotMoving) + return ( + + {isExiting ? undefined : ( + + + {moduleCalibratingDisplay} + + + )} + + ) + + const bodyText = + attachedPipetteChannels === 8 || attachedPipetteChannels === 96 ? ( + , + block: , + }} + /> + ) : ( + {t('install_probe')} + ) + + const handleBeginCalibration = (): void => { + if (adapterId == null) { + setErrorMessage('calibration adapter has not been loaded yet') + return + } + chainRunCommands?.( + [ + { + commandType: 'home' as const, + params: { + axes: attachedPipette.mount === LEFT ? ['leftZ'] : ['rightZ'], + }, + }, + { + commandType: 'calibration/calibrateModule', + params: { + moduleId: attachedModule.id, + labwareId: adapterId, + mount: attachedPipette.mount, + }, + }, + { + commandType: 'calibration/moveToMaintenancePosition' as const, + params: { + mount: attachedPipette.mount, + }, + }, + ], + false + ) + .then(() => proceed()) + .catch((e: Error) => + setErrorMessage(`error starting module calibration: ${e.message}`) + ) + } // TODO: add calibration loading screen and error screen return ( ) diff --git a/app/src/organisms/ModuleWizardFlows/BeforeBeginning.tsx b/app/src/organisms/ModuleWizardFlows/BeforeBeginning.tsx index 2c80c56424d..a8a360740f8 100644 --- a/app/src/organisms/ModuleWizardFlows/BeforeBeginning.tsx +++ b/app/src/organisms/ModuleWizardFlows/BeforeBeginning.tsx @@ -1,6 +1,11 @@ import * as React from 'react' import { UseMutateFunction } from 'react-query' import { Trans, useTranslation } from 'react-i18next' +import { + HEATERSHAKER_MODULE_MODELS, + TEMPERATURE_MODULE_MODELS, + THERMOCYCLER_MODULE_MODELS, +} from '@opentrons/shared-data/js/constants' import { getModuleDisplayName } from '@opentrons/shared-data' import { StyledText } from '../../atoms/text' import { GenericWizardTile } from '../../molecules/GenericWizardTile' @@ -20,6 +25,7 @@ interface BeforeBeginningProps extends ModuleCalibrationWizardStepProps { unknown > isCreateLoading: boolean + createdMaintenanceRunId: string | null } export const BeforeBeginning = ( @@ -31,16 +37,49 @@ export const BeforeBeginning = ( isCreateLoading, attachedModule, maintenanceRunId, + createdMaintenanceRunId, } = props const { t } = useTranslation(['module_wizard_flows', 'shared']) React.useEffect(() => { - createMaintenanceRun({}) + if (createdMaintenanceRunId == null) { + createMaintenanceRun({}) + } }, []) const moduleDisplayName = getModuleDisplayName(attachedModule.moduleModel) - // TODO: get the image for calibration adapter + + let adapterLoadname + let adapterDisplaynameKey + if ( + THERMOCYCLER_MODULE_MODELS.some( + model => model === attachedModule.moduleModel + ) + ) { + adapterLoadname = 'calibration_adapter_thermocycler' + adapterDisplaynameKey = 'calibration_adapter_thermocycler' + } else if ( + HEATERSHAKER_MODULE_MODELS.some( + model => model === attachedModule.moduleModel + ) + ) { + adapterLoadname = 'calibration_adapter_heatershaker' + adapterDisplaynameKey = 'calibration_adapter_heatershaker' + } else if ( + TEMPERATURE_MODULE_MODELS.some( + model => model === attachedModule.moduleModel + ) + ) { + adapterLoadname = 'calibration_adapter_temperature' + adapterDisplaynameKey = 'calibration_adapter_temperature' + } else { + adapterLoadname = '' + console.error( + `Invalid module type for calibration: ${attachedModule.moduleModel}` + ) + return null + } const equipmentList = [ { loadName: 'calibration_probe', displayName: t('pipette_probe') }, - { loadName: 'calibration_adapter', displayName: t('cal_adapter') }, + { loadName: adapterLoadname, displayName: t(adapterDisplaynameKey) }, ] return ( @@ -57,7 +96,7 @@ export const BeforeBeginning = ( components={{ block: }} /> } - proceedButtonText={t('move_gantry_to_front')} + proceedButtonText={t('start_setup')} proceedIsDisabled={isCreateLoading || maintenanceRunId == null} proceed={proceed} /> diff --git a/app/src/organisms/ModuleWizardFlows/DetachProbe.tsx b/app/src/organisms/ModuleWizardFlows/DetachProbe.tsx new file mode 100644 index 00000000000..c5bc623692a --- /dev/null +++ b/app/src/organisms/ModuleWizardFlows/DetachProbe.tsx @@ -0,0 +1,78 @@ +import * as React from 'react' +import { css } from 'styled-components' +import detachProbe1 from '../../assets/videos/pipette-wizard-flows/Pipette_Detach_Probe_1.webm' +import detachProbe8 from '../../assets/videos/pipette-wizard-flows/Pipette_Detach_Probe_8.webm' +import detachProbe96 from '../../assets/videos/pipette-wizard-flows/Pipette_Detach_96.webm' +import { useTranslation } from 'react-i18next' +import { + Flex, + RESPONSIVENESS, + SPACING, + TYPOGRAPHY, +} from '@opentrons/components' + +import { StyledText } from '../../atoms/text' +import { GenericWizardTile } from '../../molecules/GenericWizardTile' + +import type { ModuleCalibrationWizardStepProps } from './types' + +const BODY_STYLE = css` + ${TYPOGRAPHY.pRegular}; + + @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { + font-size: 1.275rem; + line-height: 1.75rem; + } +` + +export const DetachProbe = ( + props: ModuleCalibrationWizardStepProps +): JSX.Element | null => { + const { attachedPipette, proceed, goBack } = props + const { t, i18n } = useTranslation('module_wizard_flows') + + const pipetteChannels = attachedPipette.data.channels + let pipetteDetachProbeVideoSource + switch (pipetteChannels) { + case 1: + pipetteDetachProbeVideoSource = detachProbe1 + break + case 8: + pipetteDetachProbeVideoSource = detachProbe8 + break + case 96: + pipetteDetachProbeVideoSource = detachProbe96 + break + } + + const pipetteDetachProbeVid = ( + + + + ) + + const bodyText = ( + {t('detach_probe_description')} + ) + + return ( + + ) +} diff --git a/app/src/organisms/ModuleWizardFlows/FirmwareUpdate.tsx b/app/src/organisms/ModuleWizardFlows/FirmwareUpdate.tsx deleted file mode 100644 index 704c55ed095..00000000000 --- a/app/src/organisms/ModuleWizardFlows/FirmwareUpdate.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import * as React from 'react' -import { useTranslation } from 'react-i18next' -import { css } from 'styled-components' - -import { - COLORS, - PrimaryButton, - RESPONSIVENESS, - TYPOGRAPHY, -} from '@opentrons/components' - -import { SimpleWizardBody } from '../../molecules/SimpleWizardBody' - -import type { ModuleCalibrationWizardStepProps } from './types' - -export const BODY_STYLE = css` - ${TYPOGRAPHY.pRegular}; - - @media ${RESPONSIVENESS.touchscreenMediaQuerySpecs} { - font-size: 1.275rem; - line-height: 1.75rem; - } -` - -export const FirmwareUpdate = ( - props: ModuleCalibrationWizardStepProps -): JSX.Element | null => { - const { proceed } = props - const { t } = useTranslation('module_wizard_flows') - const handleOnClick = (): void => { - proceed() - } - - return ( - - {t('next')} - - ) -} diff --git a/app/src/organisms/ModuleWizardFlows/PlaceAdapter.tsx b/app/src/organisms/ModuleWizardFlows/PlaceAdapter.tsx index 81b5b2b66b1..152ada6678f 100644 --- a/app/src/organisms/ModuleWizardFlows/PlaceAdapter.tsx +++ b/app/src/organisms/ModuleWizardFlows/PlaceAdapter.tsx @@ -1,14 +1,41 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { css } from 'styled-components' +import { v4 as uuidv4 } from 'uuid' +import HeaterShaker_PlaceAdapter_L from '@opentrons/app/src/assets/videos/module_wizard_flows/HeaterShaker_PlaceAdapter_L.webm' +import HeaterShaker_PlaceAdapter_R from '@opentrons/app/src/assets/videos/module_wizard_flows/HeaterShaker_PlaceAdapter_R.webm' +import TempModule_PlaceAdapter_L from '@opentrons/app/src/assets/videos/module_wizard_flows/TempModule_PlaceAdapter_L.webm' +import TempModule_PlaceAdapter_R from '@opentrons/app/src/assets/videos/module_wizard_flows/TempModule_PlaceAdapter_R.webm' +import Thermocycler_PlaceAdapter from '@opentrons/app/src/assets/videos/module_wizard_flows/Thermocycler_PlaceAdapter.webm' -import { RESPONSIVENESS, TYPOGRAPHY } from '@opentrons/components' -import { getModuleDisplayName } from '@opentrons/shared-data' +import { + Flex, + TYPOGRAPHY, + SPACING, + RESPONSIVENESS, +} from '@opentrons/components' +import { + CreateCommand, + getCalibrationAdapterLoadName, + getModuleDisplayName, +} from '@opentrons/shared-data' import { StyledText } from '../../atoms/text' import { GenericWizardTile } from '../../molecules/GenericWizardTile' +import { + HEATERSHAKER_MODULE_MODELS, + TEMPERATURE_MODULE_MODELS, + THERMOCYCLER_MODULE_MODELS, +} from '@opentrons/shared-data/js/constants' +import { LEFT_SLOTS } from './constants' import type { ModuleCalibrationWizardStepProps } from './types' +import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' + +interface PlaceAdapterProps extends ModuleCalibrationWizardStepProps { + slotName: string + setCreatedAdapterId: (adapterId: string) => void +} export const BODY_STYLE = css` ${TYPOGRAPHY.pRegular}; @@ -19,25 +46,134 @@ export const BODY_STYLE = css` } ` -export const PlaceAdapter = ( - props: ModuleCalibrationWizardStepProps -): JSX.Element | null => { - const { proceed, goBack, attachedModule } = props +export const PlaceAdapter = (props: PlaceAdapterProps): JSX.Element | null => { + const { + proceed, + goBack, + attachedModule, + slotName, + chainRunCommands, + setErrorMessage, + setCreatedAdapterId, + attachedPipette, + isRobotMoving, + } = props const { t } = useTranslation('module_wizard_flows') const moduleName = getModuleDisplayName(attachedModule.moduleModel) + const mount = attachedPipette.mount const handleOnClick = (): void => { - // TODO: send calibration/moveToMaintenance command here for the pipette - // that will be used in calibration - proceed() + const calibrationAdapterLoadName = getCalibrationAdapterLoadName( + attachedModule.moduleModel + ) + if (calibrationAdapterLoadName == null) { + console.error( + `could not get calibration adapter load name for ${attachedModule.moduleModel}` + ) + return + } + + const calibrationAdapterId = uuidv4() + const commands: CreateCommand[] = [ + { + commandType: 'loadModule', + params: { + location: { + slotName: slotName, + }, + model: attachedModule.moduleModel, + moduleId: attachedModule.id, + }, + }, + { + commandType: 'loadLabware', + params: { + labwareId: calibrationAdapterId, + location: { moduleId: attachedModule.id }, + version: 1, + namespace: 'opentrons', + loadName: calibrationAdapterLoadName, + }, + }, + { commandType: 'home' as const, params: {} }, + { + commandType: 'calibration/moveToMaintenancePosition', + params: { + mount: mount, + maintenancePosition: 'attachInstrument', + }, + }, + ] + chainRunCommands?.(commands, false) + .then(() => setCreatedAdapterId(calibrationAdapterId)) + .then(() => proceed()) + .catch((e: Error) => setErrorMessage(e.message)) } - const bodyText = {t('place_flush')} + const bodyText = ( + {t('place_flush', { moduleName })} + ) + + const moduleDisplayName = getModuleDisplayName(attachedModule.moduleModel) + const isInLeftSlot = LEFT_SLOTS.some(slot => slot === slotName) + + let attachAdapterVideoSrc + if ( + THERMOCYCLER_MODULE_MODELS.some( + model => model === attachedModule.moduleModel + ) + ) { + attachAdapterVideoSrc = Thermocycler_PlaceAdapter + } else if ( + HEATERSHAKER_MODULE_MODELS.some( + model => model === attachedModule.moduleModel + ) + ) { + attachAdapterVideoSrc = isInLeftSlot + ? HeaterShaker_PlaceAdapter_L + : HeaterShaker_PlaceAdapter_R + } else if ( + TEMPERATURE_MODULE_MODELS.some( + model => model === attachedModule.moduleModel + ) + ) { + attachAdapterVideoSrc = isInLeftSlot + ? TempModule_PlaceAdapter_L + : TempModule_PlaceAdapter_R + } else { + attachAdapterVideoSrc = null + console.error( + `Invalid module type for calibration: ${attachedModule.moduleModel}` + ) + return null + } + + const placeAdapterVid = ( + + + + ) + if (isRobotMoving) + return ( + + ) return ( TODO: place adapter contents

} + rightHandBody={placeAdapterVid} bodyText={bodyText} proceedButtonText={t('confirm_placement')} proceed={handleOnClick} diff --git a/app/src/organisms/ModuleWizardFlows/SelectLocation.tsx b/app/src/organisms/ModuleWizardFlows/SelectLocation.tsx index e557be1771f..6cd18622e6d 100644 --- a/app/src/organisms/ModuleWizardFlows/SelectLocation.tsx +++ b/app/src/organisms/ModuleWizardFlows/SelectLocation.tsx @@ -1,13 +1,18 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { css } from 'styled-components' -import { getModuleDisplayName } from '@opentrons/shared-data' +import { + FLEX_ROBOT_TYPE, + ModuleLocation, + getDeckDefFromRobotType, + getModuleDisplayName, +} from '@opentrons/shared-data' import { RESPONSIVENESS, TYPOGRAPHY, SPACING, SIZE_1, - Flex, + DeckLocationSelect, } from '@opentrons/components' import { Banner } from '../../atoms/Banner' import { StyledText } from '../../atoms/text' @@ -22,18 +27,26 @@ export const BODY_STYLE = css` line-height: 1.75rem; } ` - +interface SelectLocationProps extends ModuleCalibrationWizardStepProps { + setSlotName: React.Dispatch> + availableSlotNames: string[] +} export const SelectLocation = ( - props: ModuleCalibrationWizardStepProps + props: SelectLocationProps ): JSX.Element | null => { - const { proceed, attachedModule } = props + const { + proceed, + attachedModule, + slotName, + setSlotName, + availableSlotNames, + } = props const { t } = useTranslation('module_wizard_flows') const moduleName = getModuleDisplayName(attachedModule.moduleModel) - // TODO: keep track of the selected slot in a state variable that can be - // used in calibration step const handleOnClick = (): void => { proceed() } + const deckDef = getDeckDefFromRobotType(FLEX_ROBOT_TYPE) const bodyText = ( <> @@ -47,9 +60,20 @@ export const SelectLocation = ( return ( TODO: DECK LOCATION SELECT} + rightHandBody={ + setSlotName(loc.slotName)} + disabledLocations={deckDef.locations.orderedSlots.reduce< + ModuleLocation[] + >((acc, slot) => { + if (availableSlotNames.some(slotName => slotName === slot.id)) + return acc + return [...acc, { slotName: slot.id }] + }, [])} + /> + } bodyText={bodyText} proceedButtonText={t('confirm_location')} proceed={handleOnClick} diff --git a/app/src/organisms/ModuleWizardFlows/constants.ts b/app/src/organisms/ModuleWizardFlows/constants.ts index 96949094633..72cbe4fdfef 100644 --- a/app/src/organisms/ModuleWizardFlows/constants.ts +++ b/app/src/organisms/ModuleWizardFlows/constants.ts @@ -1,11 +1,32 @@ +import { + HEATERSHAKER_MODULE_TYPE, + ModuleType, + TEMPERATURE_MODULE_TYPE, + THERMOCYCLER_MODULE_TYPE, +} from '@opentrons/shared-data' + export const SECTIONS = { BEFORE_BEGINNING: 'BEFORE_BEGINNING', FIRMWARE_UPDATE: 'FIRMWARE_UPDATE', SELECT_LOCATION: 'SELECT_LOCATION', PLACE_ADAPTER: 'PLACE_ADAPTER', ATTACH_PROBE: 'ATTACH_PROBE', + DETACH_PROBE: 'DETACH_PROBE', SUCCESS: 'SUCCESS', } as const +export const FLOWS = { + CALIBRATE: 'CALIBRATE', +} + export const CAL_PIN_LOADNAME = 'calibration_pin' as const export const SCREWDRIVER_LOADNAME = 'hex_screwdriver' as const + +export const FLEX_SLOT_NAMES_BY_MOD_TYPE: { + [moduleType in ModuleType]?: string[] +} = { + [HEATERSHAKER_MODULE_TYPE]: ['D1', 'C1', 'B1', 'A1', 'D3', 'C3', 'B3'], + [TEMPERATURE_MODULE_TYPE]: ['D1', 'C1', 'B1', 'A1', 'D3', 'C3', 'B3'], + [THERMOCYCLER_MODULE_TYPE]: ['B1'], +} +export const LEFT_SLOTS: string[] = ['A1', 'B1', 'C1', 'D1'] diff --git a/app/src/organisms/ModuleWizardFlows/getModuleCalibrationSteps.ts b/app/src/organisms/ModuleWizardFlows/getModuleCalibrationSteps.ts index 1d989524d9b..f57c8683771 100644 --- a/app/src/organisms/ModuleWizardFlows/getModuleCalibrationSteps.ts +++ b/app/src/organisms/ModuleWizardFlows/getModuleCalibrationSteps.ts @@ -8,6 +8,7 @@ export const getModuleCalibrationSteps = (): ModuleCalibrationWizardStep[] => { { section: SECTIONS.SELECT_LOCATION }, { section: SECTIONS.PLACE_ADAPTER }, { section: SECTIONS.ATTACH_PROBE }, + { section: SECTIONS.DETACH_PROBE }, { section: SECTIONS.SUCCESS }, ] } diff --git a/app/src/organisms/ModuleWizardFlows/index.tsx b/app/src/organisms/ModuleWizardFlows/index.tsx index 3af896213e0..6be825f2914 100644 --- a/app/src/organisms/ModuleWizardFlows/index.tsx +++ b/app/src/organisms/ModuleWizardFlows/index.tsx @@ -2,20 +2,24 @@ import * as React from 'react' import { useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' import { - useCreateMaintenanceRunMutation, useDeleteMaintenanceRunMutation, useCurrentMaintenanceRun, } from '@opentrons/react-api-client' - +import { COLORS } from '@opentrons/components' +import { CreateCommand, LEFT, getModuleType } from '@opentrons/shared-data' import { LegacyModalShell } from '../../molecules/LegacyModal' import { Portal } from '../../App/portal' import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' import { WizardHeader } from '../../molecules/WizardHeader' import { useAttachedPipettesFromInstrumentsQuery } from '../../organisms/Devices/hooks' -import { useChainMaintenanceCommands } from '../../resources/runs/hooks' +import { + useChainMaintenanceCommands, + useCreateTargetedMaintenanceRunMutation, +} from '../../resources/runs/hooks' import { getIsOnDevice } from '../../redux/config' +import { SimpleWizardBody } from '../../molecules/SimpleWizardBody' import { getModuleCalibrationSteps } from './getModuleCalibrationSteps' -import { SECTIONS } from './constants' +import { FLEX_SLOT_NAMES_BY_MOD_TYPE, SECTIONS } from './constants' import { BeforeBeginning } from './BeforeBeginning' import { AttachProbe } from './AttachProbe' import { PlaceAdapter } from './PlaceAdapter' @@ -23,13 +27,13 @@ import { SelectLocation } from './SelectLocation' import { Success } from './Success' import type { AttachedModule, CommandData } from '@opentrons/api-client' -import type { CreateCommand } from '@opentrons/shared-data' -import { FirmwareUpdate } from './FirmwareUpdate' +import { DetachProbe } from './DetachProbe' +import { FirmwareUpdateModal } from '../FirmwareUpdateModal' interface ModuleWizardFlowsProps { attachedModule: AttachedModule closeFlow: () => void - slotName?: string + initialSlotName?: string onComplete?: () => void } @@ -38,25 +42,34 @@ const RUN_REFETCH_INTERVAL = 5000 export const ModuleWizardFlows = ( props: ModuleWizardFlowsProps ): JSX.Element | null => { - const { attachedModule, slotName, closeFlow, onComplete } = props + const { attachedModule, initialSlotName, closeFlow, onComplete } = props const isOnDevice = useSelector(getIsOnDevice) const { t } = useTranslation('module_wizard_flows') const attachedPipettes = useAttachedPipettesFromInstrumentsQuery() - + const attachedPipette = attachedPipettes.left ?? attachedPipettes.right const moduleCalibrationSteps = getModuleCalibrationSteps() + + const availableSlotNames = + FLEX_SLOT_NAMES_BY_MOD_TYPE[getModuleType(attachedModule.moduleModel)] ?? [] + const [slotName, setSlotName] = React.useState( + initialSlotName != null ? initialSlotName : availableSlotNames?.[0] ?? 'D1' + ) const [currentStepIndex, setCurrentStepIndex] = React.useState(0) const totalStepCount = moduleCalibrationSteps.length - 1 const currentStep = moduleCalibrationSteps?.[currentStepIndex] const goBack = (): void => { setCurrentStepIndex( - currentStepIndex !== totalStepCount ? 0 : currentStepIndex + currentStepIndex === 0 ? currentStepIndex : currentStepIndex - 1 ) } const [createdMaintenanceRunId, setCreatedMaintenanceRunId] = React.useState< string | null >(null) + const [createdAdapterId, setCreatedAdapterId] = React.useState( + null + ) // we should start checking for run deletion only after the maintenance run is created // and the useCurrentRun poll has returned that created id const [ @@ -74,9 +87,9 @@ export const ModuleWizardFlows = ( } = useChainMaintenanceCommands() const { - createMaintenanceRun, + createTargetedMaintenanceRun, isLoading: isCreateLoading, - } = useCreateMaintenanceRunMutation({ + } = useCreateTargetedMaintenanceRunMutation({ onSuccess: response => { setCreatedMaintenanceRunId(response.data.id) }, @@ -168,42 +181,91 @@ export const ModuleWizardFlows = ( continuePastCommandFailure ) } + if (currentStep == null || attachedPipette == null) return null + const maintenanceRunId = + maintenanceRunData?.data.id != null && + maintenanceRunData?.data.id === createdMaintenanceRunId + ? createdMaintenanceRunId + : undefined const calibrateBaseProps = { - attachedPipettes, + attachedPipette, chainRunCommands: chainMaintenanceRunCommands, isRobotMoving, proceed, - maintenanceRunId: maintenanceRunData?.data.id, + maintenanceRunId, goBack, setErrorMessage, errorMessage, isOnDevice, attachedModule, slotName, + isExiting, } - if (currentStep == null) return null let modalContent: JSX.Element =
UNASSIGNED STEP
- if (isExiting) { + if (errorMessage != null) { + modalContent = ( + + {t('module_calibration_failed')} + {errorMessage} + + } + /> + ) + } else if (isExiting) { modalContent = - } - if (currentStep.section === SECTIONS.BEFORE_BEGINNING) { + } else if (currentStep.section === SECTIONS.BEFORE_BEGINNING) { modalContent = ( ) } else if (currentStep.section === SECTIONS.FIRMWARE_UPDATE) { - modalContent = + modalContent = ( + + ) } else if (currentStep.section === SECTIONS.SELECT_LOCATION) { - modalContent = + modalContent = ( + + ) } else if (currentStep.section === SECTIONS.PLACE_ADAPTER) { - modalContent = + modalContent = ( + + ) } else if (currentStep.section === SECTIONS.ATTACH_PROBE) { - modalContent = + modalContent = ( + + ) + } else if (currentStep.section === SECTIONS.DETACH_PROBE) { + modalContent = } else if (currentStep.section === SECTIONS.SUCCESS) { modalContent = ( void + slotName: string + isOnDevice: boolean | null } +export type ModuleWizardFlow = typeof FLOWS.CALIBRATE + export interface BeforeBeginningStep { section: typeof SECTIONS.BEFORE_BEGINNING } @@ -39,6 +46,9 @@ export interface PlaceAdapterStep { export interface AttachProbeStep { section: typeof SECTIONS.ATTACH_PROBE } +export interface DetachProbeStep { + section: typeof SECTIONS.DETACH_PROBE +} export interface SuccessStep { section: typeof SECTIONS.SUCCESS } diff --git a/app/src/organisms/Navigation/NavigationMenu.tsx b/app/src/organisms/Navigation/NavigationMenu.tsx index 7ffffd63756..d5c1df03ebf 100644 --- a/app/src/organisms/Navigation/NavigationMenu.tsx +++ b/app/src/organisms/Navigation/NavigationMenu.tsx @@ -22,10 +22,11 @@ import type { Dispatch } from '../../redux/types' interface NavigationMenuProps { onClick: React.MouseEventHandler robotName: string + setShowNavMenu: (showNavMenu: boolean) => void } export function NavigationMenu(props: NavigationMenuProps): JSX.Element { - const { onClick, robotName } = props + const { onClick, robotName, setShowNavMenu } = props const { t, i18n } = useTranslation(['devices_landing', 'robot_controls']) const { lightsOn, toggleLights } = useLights() const dispatch = useDispatch() @@ -38,6 +39,11 @@ export function NavigationMenu(props: NavigationMenuProps): JSX.Element { setShowRestartRobotConfirmationModal(true) } + const handleHomeGantry = (): void => { + dispatch(home(robotName, ROBOT)) + setShowNavMenu(false) + } + return ( <> {showRestartRobotConfirmationModal ? ( @@ -49,10 +55,7 @@ export function NavigationMenu(props: NavigationMenuProps): JSX.Element { /> ) : null} - dispatch(home(robotName, ROBOT))} - > + { props = { onClick: jest.fn(), robotName: 'otie', + setShowNavMenu: jest.fn(), } mockUseLights.mockReturnValue({ lightsOn: false, @@ -42,14 +43,14 @@ describe('NavigationMenu', () => {
mock RestartRobotConfirmationModal
) }) - it('should render the home menu item and clicking home robot arm, dispatches home', () => { + it('should render the home menu item and clicking home gantry, dispatches home and call a mock function', () => { const { getByText, getByLabelText } = render(props) getByLabelText('BackgroundOverlay_ModalShell').click() expect(props.onClick).toHaveBeenCalled() - const home = getByText('Home gantry') getByLabelText('home-gantry_icon') - home.click() + getByText('Home gantry').click() expect(mockHome).toHaveBeenCalled() + expect(props.setShowNavMenu).toHaveBeenCalled() }) it('should render the restart robot menu item and clicking it, dispatches restart robot', () => { diff --git a/app/src/organisms/Navigation/index.tsx b/app/src/organisms/Navigation/index.tsx index 55d6babd871..ec2dea8d8a3 100644 --- a/app/src/organisms/Navigation/index.tsx +++ b/app/src/organisms/Navigation/index.tsx @@ -131,6 +131,7 @@ export function Navigation(props: NavigationProps): JSX.Element { handleMenu(false)} robotName={robotName} + setShowNavMenu={setShowNavMenu} /> )} diff --git a/app/src/organisms/NetworkSettings/SelectAuthenticationType.tsx b/app/src/organisms/NetworkSettings/SelectAuthenticationType.tsx index 78b3464ff80..c3f35a15eee 100644 --- a/app/src/organisms/NetworkSettings/SelectAuthenticationType.tsx +++ b/app/src/organisms/NetworkSettings/SelectAuthenticationType.tsx @@ -18,6 +18,7 @@ import { StyledText } from '../../atoms/text' import { RadioButton } from '../../atoms/buttons' import { getLocalRobot } from '../../redux/discovery' import { getNetworkInterfaces, fetchStatus } from '../../redux/networking' +import { useIsUnboxingFlowOngoing } from '../RobotSettingsDashboard/NetworkSettings/hooks' import { AlternativeSecurityTypeModal } from './AlternativeSecurityTypeModal' import type { WifiSecurityType } from '@opentrons/api-client' @@ -36,6 +37,7 @@ export function SelectAuthenticationType({ const dispatch = useDispatch() const localRobot = useSelector(getLocalRobot) const robotName = localRobot?.name != null ? localRobot.name : 'no name' + const isUnboxingFlowOngoing = useIsUnboxingFlowOngoing() const { wifi } = useSelector((state: State) => getNetworkInterfaces(state, robotName) ) @@ -78,7 +80,11 @@ export function SelectAuthenticationType({ flexDirection={DIRECTION_COLUMN} padding={`${SPACING.spacing32} ${SPACING.spacing40} ${SPACING.spacing40}`} > - + (false) + const isUnboxingFlowOngoing = useIsUnboxingFlowOngoing() return ( <> - + {t('enter_password')} - - + + @@ -54,6 +56,7 @@ export function SetWifiSsid({ flexDirection={DIRECTION_COLUMN} paddingX="6.34375rem" gridGap={SPACING.spacing8} + marginTop={isUnboxingFlowOngoing ? undefined : '7.75rem'} > history.push(`runs/${createRunResponse.data.id}/setup`) const { cloneRun } = useCloneRun(runData.id, onResetSuccess) @@ -121,7 +121,8 @@ export function ProtocolWithLastRun({ name: 'proceedToRun', properties: { sourceLocation: 'RecentRunProtocolCard' }, }) - trackProtocolRunEvent({ name: 'runAgain' }) + // TODO(BC, 08/29/23): reintroduce this analytics event when we refactor the hook to fetch data lazily (performance concern) + // trackProtocolRunEvent({ name: 'runAgain' }) } const terminationTypeMap: { [runStatus in RunStatus]?: string } = { diff --git a/app/src/organisms/OnDeviceDisplay/RobotDashboard/ServerInitializing.tsx b/app/src/organisms/OnDeviceDisplay/RobotDashboard/ServerInitializing.tsx new file mode 100644 index 00000000000..dc38804d42b --- /dev/null +++ b/app/src/organisms/OnDeviceDisplay/RobotDashboard/ServerInitializing.tsx @@ -0,0 +1,40 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' + +import { + Flex, + DIRECTION_COLUMN, + ALIGN_CENTER, + COLORS, + JUSTIFY_CENTER, + TYPOGRAPHY, + BORDERS, + Icon, + SPACING, +} from '@opentrons/components' + +import { StyledText } from '../../../atoms/text' + +export function ServerInitializing(): JSX.Element { + const { t } = useTranslation('device_details') + return ( + + + + {t('robot_initializing')} + + + ) +} diff --git a/app/src/organisms/OnDeviceDisplay/RobotDashboard/__tests__/RecentRunProtocolCard.test.tsx b/app/src/organisms/OnDeviceDisplay/RobotDashboard/__tests__/RecentRunProtocolCard.test.tsx index f7955a89194..b20cb8fddc4 100644 --- a/app/src/organisms/OnDeviceDisplay/RobotDashboard/__tests__/RecentRunProtocolCard.test.tsx +++ b/app/src/organisms/OnDeviceDisplay/RobotDashboard/__tests__/RecentRunProtocolCard.test.tsx @@ -200,7 +200,8 @@ describe('RecentRunProtocolCard', () => { name: 'proceedToRun', properties: { sourceLocation: 'RecentRunProtocolCard' }, }) - expect(mockTrackProtocolRunEvent).toBeCalledWith({ name: 'runAgain' }) + // TODO(BC, 08/30/23): reintroduce check for tracking when tracking is reintroduced lazily + // expect(mockTrackProtocolRunEvent).toBeCalledWith({ name: 'runAgain' }) getByLabelText('icon_ot-spinner') expect(button).toHaveStyle(`background-color: ${COLORS.green3Pressed}`) }) diff --git a/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx b/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx index e1246d836e8..95ce2efcd28 100644 --- a/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx +++ b/app/src/organisms/OnDeviceDisplay/RunningProtocol/RunFailedModal.tsx @@ -1,7 +1,6 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' import { useHistory } from 'react-router-dom' -import isEmpty from 'lodash/isEmpty' import { css } from 'styled-components' import { @@ -98,11 +97,6 @@ export function RunFailedModal({ {highestPriorityError.detail} - {!isEmpty(highestPriorityError.errorInfo) && ( - - {JSON.stringify(highestPriorityError.errorInfo)} - - )} diff --git a/app/src/organisms/PipetteWizardFlows/BeforeBeginning.tsx b/app/src/organisms/PipetteWizardFlows/BeforeBeginning.tsx index 7fa3eb2879f..639a49e0a38 100644 --- a/app/src/organisms/PipetteWizardFlows/BeforeBeginning.tsx +++ b/app/src/organisms/PipetteWizardFlows/BeforeBeginning.tsx @@ -48,6 +48,7 @@ interface BeforeBeginningProps extends PipetteWizardStepProps { unknown > isCreateLoading: boolean + createdMaintenanceRunId: string | null requiredPipette?: LoadedPipette } export const BeforeBeginning = ( @@ -67,10 +68,14 @@ export const BeforeBeginning = ( selectedPipette, isOnDevice, requiredPipette, + maintenanceRunId, + createdMaintenanceRunId, } = props const { t } = useTranslation(['pipette_wizard_flows', 'shared']) React.useEffect(() => { - createMaintenanceRun({}) + if (createdMaintenanceRunId == null) { + createMaintenanceRun({}) + } }, []) const pipetteId = attachedPipettes[mount]?.serialNumber const isGantryEmpty = getIsGantryEmpty(attachedPipettes) @@ -256,7 +261,7 @@ export const BeforeBeginning = ( } proceedButtonText={proceedButtonText} - proceedIsDisabled={isCreateLoading} + proceedIsDisabled={isCreateLoading || maintenanceRunId == null} proceed={ isGantryEmptyFor96ChannelAttachment || (flowType === FLOWS.ATTACH && selectedPipette === SINGLE_MOUNT_PIPETTES) diff --git a/app/src/organisms/PipetteWizardFlows/Results.tsx b/app/src/organisms/PipetteWizardFlows/Results.tsx index 88f35cd56ed..fc9c905d29d 100644 --- a/app/src/organisms/PipetteWizardFlows/Results.tsx +++ b/app/src/organisms/PipetteWizardFlows/Results.tsx @@ -166,28 +166,6 @@ export const Results = (props: ResultsProps): JSX.Element => { .catch(error => { setShowErrorMessage(error.message) }) - } else if ( - isSuccess && - flowType === FLOWS.DETACH && - currentStepIndex !== totalStepCount - ) { - chainRunCommands?.( - [ - { - commandType: 'calibration/moveToMaintenancePosition' as const, - params: { - mount: mount, - }, - }, - ], - false - ) - .then(() => { - proceed() - }) - .catch(error => { - setShowErrorMessage(error.message) - }) } else { proceed() } diff --git a/app/src/organisms/PipetteWizardFlows/__tests__/BeforeBeginning.test.tsx b/app/src/organisms/PipetteWizardFlows/__tests__/BeforeBeginning.test.tsx index 75047182753..d6d75f881fd 100644 --- a/app/src/organisms/PipetteWizardFlows/__tests__/BeforeBeginning.test.tsx +++ b/app/src/organisms/PipetteWizardFlows/__tests__/BeforeBeginning.test.tsx @@ -59,6 +59,7 @@ describe('BeforeBeginning', () => { isRobotMoving: false, isOnDevice: false, requiredPipette: undefined, + createdMaintenanceRunId: null, } // mockNeedHelpLink.mockReturnValue(
mock need help link
) mockInProgressModal.mockReturnValue(
mock in progress
) diff --git a/app/src/organisms/PipetteWizardFlows/__tests__/PipetteWizardFlows.test.tsx b/app/src/organisms/PipetteWizardFlows/__tests__/PipetteWizardFlows.test.tsx deleted file mode 100644 index 1a3280387d6..00000000000 --- a/app/src/organisms/PipetteWizardFlows/__tests__/PipetteWizardFlows.test.tsx +++ /dev/null @@ -1,683 +0,0 @@ -import * as React from 'react' -import { fireEvent, waitFor } from '@testing-library/react' -import { renderWithProviders } from '@opentrons/components' -import { - LEFT, - NINETY_SIX_CHANNEL, - RIGHT, - SINGLE_MOUNT_PIPETTES, -} from '@opentrons/shared-data' -import { - useCreateMaintenanceRunMutation, - useDeleteMaintenanceRunMutation, - useCurrentMaintenanceRun, -} from '@opentrons/react-api-client' -import { i18n } from '../../../i18n' -import { useChainMaintenanceCommands } from '../../../resources/runs/hooks' -import { - mock96ChannelAttachedPipetteInformation, - mockAttachedPipetteInformation, -} from '../../../redux/pipettes/__fixtures__' -import * as RobotApi from '../../../redux/robot-api' -import { getIsOnDevice } from '../../../redux/config' -import { useRunStatus } from '../../RunTimeControl/hooks' -import { useAttachedPipettesFromInstrumentsQuery } from '../../Devices/hooks/useAttachedPipettesFromInstrumentsQuery' -import { getPipetteWizardSteps } from '../getPipetteWizardSteps' -import { usePipetteFlowWizardHeaderText } from '../hooks' -import { ExitModal } from '../ExitModal' -import { FLOWS, SECTIONS } from '../constants' -import { UnskippableModal } from '../UnskippableModal' -import { PipetteWizardFlows } from '..' - -jest.mock('../../../redux/pipettes') -jest.mock('../getPipetteWizardSteps') -jest.mock('../../../resources/runs/hooks') -jest.mock('@opentrons/react-api-client') -jest.mock('../ExitModal') -jest.mock('../../ProtocolUpload/hooks') -jest.mock('../../RunTimeControl/hooks') -jest.mock('../../../redux/robot-api') -jest.mock('../UnskippableModal') -jest.mock('../../../redux/config') -jest.mock('../../Devices/hooks/useAttachedPipettesFromInstrumentsQuery') -jest.mock('../hooks') - -const mockUseAttachedPipettesFromInstrumentsQuery = useAttachedPipettesFromInstrumentsQuery as jest.MockedFunction< - typeof useAttachedPipettesFromInstrumentsQuery -> -const mockGetRequestById = RobotApi.getRequestById as jest.MockedFunction< - typeof RobotApi.getRequestById -> -const mockGetPipetteWizardSteps = getPipetteWizardSteps as jest.MockedFunction< - typeof getPipetteWizardSteps -> -const mockUseChainMaintenanceCommands = useChainMaintenanceCommands as jest.MockedFunction< - typeof useChainMaintenanceCommands -> -const mockUseCreateMaintenanceRunMutation = useCreateMaintenanceRunMutation as jest.MockedFunction< - typeof useCreateMaintenanceRunMutation -> -const mockUseDeleteMaintenanceRunMutation = useDeleteMaintenanceRunMutation as jest.MockedFunction< - typeof useDeleteMaintenanceRunMutation -> -const mockUseCurrentMaintenanceRun = useCurrentMaintenanceRun as jest.MockedFunction< - typeof useCurrentMaintenanceRun -> -const mockUseRunStatus = useRunStatus as jest.MockedFunction< - typeof useRunStatus -> -const mockExitModal = ExitModal as jest.MockedFunction -const mockUnskippableModal = UnskippableModal as jest.MockedFunction< - typeof UnskippableModal -> -const mockGetIsOnDevice = getIsOnDevice as jest.MockedFunction< - typeof getIsOnDevice -> -const mockUsePipetteFlowWizardHeaderText = usePipetteFlowWizardHeaderText as jest.MockedFunction< - typeof usePipetteFlowWizardHeaderText -> - -const render = (props: React.ComponentProps) => { - return renderWithProviders(, { - i18nInstance: i18n, - })[0] -} - -describe('PipetteWizardFlows', () => { - let props: React.ComponentProps - let mockCreateMaintenanceRun: jest.Mock - let mockDeleteMaintenanceRun: jest.Mock - let mockChainRunCommands: jest.Mock - beforeEach(() => { - props = { - selectedPipette: SINGLE_MOUNT_PIPETTES, - flowType: FLOWS.CALIBRATE, - mount: LEFT, - closeFlow: jest.fn(), - onComplete: jest.fn(), - } - mockCreateMaintenanceRun = jest.fn() - mockDeleteMaintenanceRun = jest.fn() - mockChainRunCommands = jest.fn().mockImplementation(() => Promise.resolve()) - mockUseAttachedPipettesFromInstrumentsQuery.mockReturnValue({ - left: mockAttachedPipetteInformation, - right: null, - }) - mockUseDeleteMaintenanceRunMutation.mockReturnValue({ - deleteMaintenanceRun: mockDeleteMaintenanceRun, - } as any) - mockExitModal.mockReturnValue(
mock exit modal
) - mockUseCreateMaintenanceRunMutation.mockReturnValue({ - createMaintenanceRun: mockCreateMaintenanceRun, - } as any) - mockUseChainMaintenanceCommands.mockReturnValue({ - chainRunCommands: mockChainRunCommands, - isCommandMutationLoading: false, - }) - mockUseRunStatus.mockReturnValue('idle') - mockGetPipetteWizardSteps.mockReturnValue([ - { - section: SECTIONS.BEFORE_BEGINNING, - mount: LEFT, - flowType: FLOWS.CALIBRATE, - }, - { - section: SECTIONS.ATTACH_PROBE, - mount: LEFT, - flowType: FLOWS.CALIBRATE, - }, - { - section: SECTIONS.DETACH_PROBE, - mount: LEFT, - flowType: FLOWS.CALIBRATE, - }, - { - section: SECTIONS.RESULTS, - mount: LEFT, - flowType: FLOWS.CALIBRATE, - }, - ]) - mockGetRequestById.mockReturnValue(null) - mockUnskippableModal.mockReturnValue(
mock unskippable modal
) - mockGetIsOnDevice.mockReturnValue(false) - mockUsePipetteFlowWizardHeaderText.mockReturnValue( - 'mock wizard header text' - ) - mockUseCurrentMaintenanceRun.mockReturnValue({ - data: { - data: { - id: 'mockRunId', - } as any, - }, - } as any) - }) - it('renders the correct information, calling the correct commands for the calibration flow', async () => { - const { getByText, getByRole } = render(props) - // first page - getByText('mock wizard header text') - getByText('Before you begin') - getByText( - 'To get started, remove labware from the deck and clean up the working area to make calibration easier. Also gather the needed equipment shown to the right.' - ) - getByText( - 'The calibration probe is included with the robot and should be stored on the front pillar of the robot.' - ) - const getStarted = getByRole('button', { name: 'Move gantry to front' }) - fireEvent.click(getStarted) - await waitFor(() => { - expect(mockChainRunCommands).toHaveBeenCalledWith( - 'mockRunId', - [ - { - commandType: 'loadPipette', - params: { - mount: LEFT, - pipetteId: 'abc', - pipetteName: 'p1000_single_flex', - }, - }, - { commandType: 'home' as const, params: {} }, - { - commandType: 'calibration/moveToMaintenancePosition', - params: { mount: LEFT }, - }, - ], - false - ) - expect(mockCreateMaintenanceRun).toHaveBeenCalled() - }) - // second page - getByText('Step 1 / 3') - getByText('Attach calibration probe') - const initiate = getByRole('button', { name: 'Begin calibration' }) - fireEvent.click(initiate) - await waitFor(() => { - expect(mockChainRunCommands).toHaveBeenCalledWith( - 'mockRunId', - [ - { - commandType: 'home', - params: { axes: ['leftZ'] }, - }, - { - commandType: 'calibration/calibratePipette', - params: { mount: LEFT }, - }, - { - commandType: 'calibration/moveToMaintenancePosition', - params: { mount: LEFT }, - }, - ], - false - ) - }) - // third page - getByText('Step 2 / 3') - getByText('Remove calibration probe') - const complete = getByRole('button', { name: 'Complete calibration' }) - fireEvent.click(complete) - await waitFor(() => { - // TODO(sb, 3/21/23): rewire this when home issue is sorted - // expect(mockChainRunCommands).toHaveBeenCalledWith( - // [ - // { - // commandType: 'home', - // params: {}, - // }, - // ], - // false - // ) - // TODO(jr, 11/2/22): wire this up when stop run logic is figured out - }) - // last page - // TODO(jr, 11/2/22): wire this up when stop run logic is figured out - // getByText('Step 3 / 3') - // getByText('Pipette Successfully Calibrated') - // const exitButton = getByLabelText('Results_exit') - // fireEvent.click(exitButton) - // await waitFor(() => { - // expect(props.closeFlow).toHaveBeenCalled() - // }) - }) - it('renders the correct first page for calibrating single mount when rendering from on device display', () => { - mockGetIsOnDevice.mockReturnValue(true) - const { getByText } = render(props) - getByText('mock wizard header text') - getByText('Before you begin') - getByText( - 'To get started, remove labware from the deck and clean up the working area to make calibration easier. Also gather the needed equipment shown to the right.' - ) - getByText( - 'The calibration probe is included with the robot and should be stored on the front pillar of the robot.' - ) - }) - it('renders 3rd page and clicking back button redirects to the first page', async () => { - const { getByText, getByRole } = render(props) - // first page - getByText('Before you begin') - getByRole('button', { name: 'Move gantry to front' }).click() - await waitFor(() => { - expect(mockChainRunCommands).toHaveBeenCalled() - expect(mockCreateMaintenanceRun).toHaveBeenCalled() - }) - // second page - getByText('Attach calibration probe') - getByRole('button', { name: 'Begin calibration' }).click() - await waitFor(() => { - expect(mockChainRunCommands).toHaveBeenCalled() - }) - // third page - getByText('Remove calibration probe') - getByRole('button', { name: 'back' }).click() - // first page - getByText('Before you begin') - }) - - it('renders the correct information, calling the correct commands for the detach flow', () => { - props = { - ...props, - flowType: FLOWS.DETACH, - } - mockGetPipetteWizardSteps.mockReturnValue([ - { - section: SECTIONS.BEFORE_BEGINNING, - mount: LEFT, - flowType: FLOWS.DETACH, - }, - { - section: SECTIONS.DETACH_PIPETTE, - mount: LEFT, - flowType: FLOWS.DETACH, - }, - { - section: SECTIONS.RESULTS, - mount: LEFT, - flowType: FLOWS.DETACH, - }, - ]) - const { getByText } = render(props) - getByText('mock wizard header text') - // TODO(jr 11/11/22): finish the rest of the test - }) - - it('renders the correct information, calling the correct commands for the attach flow', async () => { - mockUseAttachedPipettesFromInstrumentsQuery.mockReturnValue({ - left: null, - right: null, - }) - props = { - ...props, - flowType: FLOWS.ATTACH, - } - mockGetPipetteWizardSteps.mockReturnValue([ - { - section: SECTIONS.BEFORE_BEGINNING, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - { - section: SECTIONS.MOUNT_PIPETTE, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - { - section: SECTIONS.RESULTS, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - { - section: SECTIONS.ATTACH_PROBE, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - { - section: SECTIONS.DETACH_PROBE, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - { - section: SECTIONS.RESULTS, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - ]) - const { getByText, getByRole } = render(props) - getByText('mock wizard header text') - getByText('Before you begin') - // page 1 - const getStarted = getByRole('button', { name: 'Move gantry to front' }) - fireEvent.click(getStarted) - await waitFor(() => { - expect(mockChainRunCommands).toHaveBeenCalledWith( - 'mockRunId', - [ - { commandType: 'home' as const, params: {} }, - { - commandType: 'calibration/moveToMaintenancePosition', - params: { mount: LEFT }, - }, - ], - false - ) - expect(mockCreateMaintenanceRun).toHaveBeenCalled() - }) - }) - it('renders the correct information, calling the correct commands for the attach flow 96 channel', async () => { - mockUseAttachedPipettesFromInstrumentsQuery.mockReturnValue({ - left: null, - right: null, - }) - props = { - ...props, - flowType: FLOWS.ATTACH, - selectedPipette: NINETY_SIX_CHANNEL, - } - mockGetPipetteWizardSteps.mockReturnValue([ - { - section: SECTIONS.BEFORE_BEGINNING, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - { - section: SECTIONS.CARRIAGE, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - { - section: SECTIONS.MOUNTING_PLATE, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - { - section: SECTIONS.MOUNT_PIPETTE, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - { - section: SECTIONS.RESULTS, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - ]) - const { getByText, getByRole } = render(props) - getByText('mock wizard header text') - getByText('Before you begin') - // page 1 - const getStarted = getByRole('button', { name: 'Move gantry to front' }) - fireEvent.click(getStarted) - await waitFor(() => { - expect(mockChainRunCommands).toHaveBeenCalledWith( - 'mockRunId', - [ - { commandType: 'home' as const, params: {} }, - { - commandType: 'calibration/moveToMaintenancePosition' as const, - params: { maintenancePosition: 'attachPlate', mount: RIGHT }, - }, - ], - false - ) - expect(mockCreateMaintenanceRun).toHaveBeenCalled() - }) - // page 2 - getByText('Unscrew z-axis carriage') - // TODO wait until commands are wired up to write out more of this test! - }) - it('renders the correct information, calling the correct commands for the detach flow 96 channel', async () => { - mockUseAttachedPipettesFromInstrumentsQuery.mockReturnValue({ - left: mock96ChannelAttachedPipetteInformation, - right: null, - }) - props = { - ...props, - flowType: FLOWS.DETACH, - selectedPipette: NINETY_SIX_CHANNEL, - } - mockGetPipetteWizardSteps.mockReturnValue([ - { - section: SECTIONS.BEFORE_BEGINNING, - mount: LEFT, - flowType: FLOWS.DETACH, - }, - { - section: SECTIONS.DETACH_PIPETTE, - mount: LEFT, - flowType: FLOWS.DETACH, - }, - { - section: SECTIONS.MOUNTING_PLATE, - mount: LEFT, - flowType: FLOWS.DETACH, - }, - { - section: SECTIONS.CARRIAGE, - mount: LEFT, - flowType: FLOWS.DETACH, - }, - { - section: SECTIONS.RESULTS, - mount: LEFT, - flowType: FLOWS.DETACH, - }, - ]) - const { getByText, getByRole } = render(props) - getByText('mock wizard header text') - getByText('Before you begin') - // page 1 - const getStarted = getByRole('button', { name: 'Move gantry to front' }) - fireEvent.click(getStarted) - await waitFor(() => { - expect(mockChainRunCommands).toHaveBeenCalledWith( - 'mockRunId', - [ - { - commandType: 'loadPipette', - params: { - mount: LEFT, - pipetteId: 'cba', - pipetteName: 'p1000_96', - }, - }, - { commandType: 'home' as const, params: {} }, - { - commandType: 'calibration/moveToMaintenancePosition', - params: { mount: LEFT }, - }, - ], - false - ) - expect(mockCreateMaintenanceRun).toHaveBeenCalled() - }) - }) - it('renders the correct information, calling the correct commands for the attach flow 96 channel with gantry not empty', async () => { - mockUseAttachedPipettesFromInstrumentsQuery.mockReturnValue({ - left: null, - right: mockAttachedPipetteInformation, - }) - props = { - ...props, - flowType: FLOWS.ATTACH, - selectedPipette: NINETY_SIX_CHANNEL, - } - mockGetPipetteWizardSteps.mockReturnValue([ - { - section: SECTIONS.BEFORE_BEGINNING, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - { - section: SECTIONS.DETACH_PIPETTE, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - { section: SECTIONS.RESULTS, mount: LEFT, flowType: FLOWS.ATTACH }, - { - section: SECTIONS.CARRIAGE, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - { - section: SECTIONS.MOUNTING_PLATE, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - { - section: SECTIONS.MOUNT_PIPETTE, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - { - section: SECTIONS.RESULTS, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - ]) - const { getByText, getByRole } = render(props) - getByText('mock wizard header text') - getByText('Before you begin') - // page 1 - const getStarted = getByRole('button', { name: 'Move gantry to front' }) - fireEvent.click(getStarted) - await waitFor(() => { - expect(mockChainRunCommands).toHaveBeenCalledWith( - 'mockRunId', - [ - { commandType: 'home' as const, params: {} }, - { - commandType: 'calibration/moveToMaintenancePosition', - params: { mount: LEFT }, - }, - ], - false - ) - expect(mockCreateMaintenanceRun).toHaveBeenCalled() - }) - }) - it('renders the correct information, calling the correct commands for the 96-channel calibration flow', async () => { - mockUseAttachedPipettesFromInstrumentsQuery.mockReturnValue({ - left: mock96ChannelAttachedPipetteInformation, - right: null, - }) - props = { - ...props, - flowType: FLOWS.CALIBRATE, - selectedPipette: NINETY_SIX_CHANNEL, - } - const { getByText, getByRole } = render(props) - // first page - getByText('mock wizard header text') - getByText('Before you begin') - getByRole('button', { name: 'Move gantry to front' }).click() - await waitFor(() => { - expect(mockChainRunCommands).toHaveBeenCalledWith( - 'mockRunId', - [ - { - commandType: 'loadPipette', - params: { - mount: LEFT, - pipetteId: 'cba', - pipetteName: 'p1000_96', - }, - }, - { commandType: 'home' as const, params: {} }, - { - commandType: 'calibration/moveToMaintenancePosition', - params: { mount: LEFT }, - }, - ], - false - ) - expect(mockCreateMaintenanceRun).toHaveBeenCalled() - }) - // second page - getByText('Step 1 / 3') - getByText('Attach calibration probe') - getByRole('button', { name: 'Begin calibration' }).click() - await waitFor(() => { - expect(mockChainRunCommands).toHaveBeenCalledWith( - 'mockRunId', - [ - { - commandType: 'home', - params: { axes: ['leftZ'] }, - }, - { - commandType: 'calibration/calibratePipette', - params: { mount: LEFT }, - }, - { - commandType: 'calibration/moveToMaintenancePosition', - params: { mount: LEFT }, - }, - ], - false - ) - }) - // third page - getByText('Step 2 / 3') - getByText('Remove calibration probe') - getByRole('button', { name: 'Complete calibration' }).click() - // TODO(sb, 3/21/23): rewire this when home issue is sorted - // await waitFor(() => { - // expect(mockChainRunCommands).toHaveBeenCalledWith( - // [ - // { - // commandType: 'home', - // params: {}, - // }, - // ], - // false - // ) - // }) - }) - it('renders the 96 channel attach flow carriage unskippable step page', async () => { - mockUseAttachedPipettesFromInstrumentsQuery.mockReturnValue({ - left: null, - right: null, - }) - props = { - ...props, - flowType: FLOWS.ATTACH, - selectedPipette: NINETY_SIX_CHANNEL, - } - mockGetPipetteWizardSteps.mockReturnValue([ - { - section: SECTIONS.BEFORE_BEGINNING, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - { - section: SECTIONS.CARRIAGE, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - { - section: SECTIONS.MOUNTING_PLATE, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - { - section: SECTIONS.MOUNT_PIPETTE, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - { - section: SECTIONS.RESULTS, - mount: LEFT, - flowType: FLOWS.ATTACH, - }, - ]) - const { getByText, getByRole, getByLabelText } = render(props) - // page 1 - getByRole('button', { name: 'Move gantry to front' }).click() - await waitFor(() => { - expect(mockChainRunCommands).toHaveBeenCalled() - expect(mockCreateMaintenanceRun).toHaveBeenCalled() - }) - // page 2 - getByText('Unscrew z-axis carriage') - getByLabelText('Exit').click() - getByText('mock unskippable modal') - }) -}) diff --git a/app/src/organisms/PipetteWizardFlows/__tests__/Results.test.tsx b/app/src/organisms/PipetteWizardFlows/__tests__/Results.test.tsx index 92a4165b806..56af823d796 100644 --- a/app/src/organisms/PipetteWizardFlows/__tests__/Results.test.tsx +++ b/app/src/organisms/PipetteWizardFlows/__tests__/Results.test.tsx @@ -232,17 +232,7 @@ describe('Results', () => { getByText('attach pipette') const exit = getByRole('button', { name: 'Results_exit' }) fireEvent.click(exit) - expect(props.chainRunCommands).toHaveBeenCalledWith( - [ - { - commandType: 'calibration/moveToMaintenancePosition' as const, - params: { - mount: 'left', - }, - }, - ], - false - ) + expect(props.proceed).toHaveBeenCalled() }) it('renders the correct information when pipette wizard succeeds to calibrate in attach flow 96-channel', () => { props = { diff --git a/app/src/organisms/PipetteWizardFlows/index.tsx b/app/src/organisms/PipetteWizardFlows/index.tsx index 5a5d5afdea7..1a0dff58068 100644 --- a/app/src/organisms/PipetteWizardFlows/index.tsx +++ b/app/src/organisms/PipetteWizardFlows/index.tsx @@ -11,17 +11,19 @@ import { } from '@opentrons/shared-data' import { useHost, - useCreateMaintenanceRunMutation, useDeleteMaintenanceRunMutation, useCurrentMaintenanceRun, } from '@opentrons/react-api-client' +import { + useCreateTargetedMaintenanceRunMutation, + useChainMaintenanceCommands, +} from '../../resources/runs/hooks' import { LegacyModalShell } from '../../molecules/LegacyModal' import { Portal } from '../../App/portal' import { InProgressModal } from '../../molecules/InProgressModal/InProgressModal' import { WizardHeader } from '../../molecules/WizardHeader' import { FirmwareUpdateModal } from '../FirmwareUpdateModal' -import { useChainMaintenanceCommands } from '../../resources/runs/hooks' import { getIsOnDevice } from '../../redux/config' import { useAttachedPipettesFromInstrumentsQuery } from '../Devices/hooks' import { usePipetteFlowWizardHeaderText } from './hooks' @@ -124,9 +126,9 @@ export const PipetteWizardFlows = ( } = useChainMaintenanceCommands() const { - createMaintenanceRun, + createTargetedMaintenanceRun, isLoading: isCreateLoading, - } = useCreateMaintenanceRunMutation( + } = useCreateTargetedMaintenanceRunMutation( { onSuccess: response => { setCreatedMaintenanceRunId(response.data.id) @@ -228,11 +230,16 @@ export const PipetteWizardFlows = ( ) } + const maintenanceRunId = + maintenanceRunData?.data.id != null && + maintenanceRunData?.data.id === createdMaintenanceRunId + ? createdMaintenanceRunId + : undefined const calibrateBaseProps = { chainRunCommands: chainMaintenanceRunCommands, isRobotMoving, proceed, - maintenanceRunId: maintenanceRunData?.data.id, + maintenanceRunId, goBack, attachedPipettes, setShowErrorMessage, @@ -271,7 +278,8 @@ export const PipetteWizardFlows = ( diff --git a/app/src/organisms/ProtocolSetupLabware/index.tsx b/app/src/organisms/ProtocolSetupLabware/index.tsx index 6cff8cf808d..dcc69a22223 100644 --- a/app/src/organisms/ProtocolSetupLabware/index.tsx +++ b/app/src/organisms/ProtocolSetupLabware/index.tsx @@ -7,6 +7,7 @@ import { ALIGN_FLEX_START, ALIGN_STRETCH, BORDERS, + Box, COLORS, DIRECTION_COLUMN, DIRECTION_ROW, @@ -51,6 +52,10 @@ import { getLabwareSetupItemGroups } from '../../pages/Protocols/utils' import { getProtocolModulesInfo } from '../Devices/ProtocolRun/utils/getProtocolModulesInfo' import { getAttachedProtocolModuleMatches } from '../ProtocolSetupModules/utils' import { getLabwareRenderInfo } from '../Devices/ProtocolRun/utils/getLabwareRenderInfo' +import { + getNestedLabwareInfo, + NestedLabwareInfo, +} from '../Devices/ProtocolRun/SetupLabware/getNestedLabwareInfo' import type { UseQueryResult } from 'react-query' import type { @@ -184,7 +189,6 @@ export function ProtocolSetupLabware({ typeof selectedLabware.location === 'object' && 'labwareId' in selectedLabware?.location ) { - // TODO(jr, 8/14/23): add adapter location icon when we have one const adapterId = selectedLabware.location.labwareId const adapterLocation = mostRecentAnalysis?.commands.find( (command): command is LoadLabwareRunTimeCommand => @@ -396,13 +400,23 @@ export function ProtocolSetupLabware({
{[...onDeckItems, ...offDeckItems].map((labware, i) => { - return mostRecentAnalysis != null ? ( + const labwareOnAdapter = onDeckItems.find( + item => + labware.initialLocation !== 'offDeck' && + 'labwareId' in labware.initialLocation && + item.labwareId === labware.initialLocation.labwareId + ) + return mostRecentAnalysis != null && labwareOnAdapter == null ? ( ) : null })} @@ -515,6 +529,8 @@ function LabwareLatch({ ? `${COLORS.darkBlack100}${COLORS.opacity60HexCode}` : COLORS.darkBlackEnabled } + height="6.5rem" + alignSelf={ALIGN_CENTER} flexDirection={DIRECTION_COLUMN} fontSize={TYPOGRAPHY.fontSize22} gridGap={SPACING.spacing8} @@ -557,6 +573,7 @@ interface RowLabwareProps { labware: LabwareSetupItem attachedProtocolModules: AttachedProtocolModuleMatch[] refetchModules: UseQueryResult['refetch'] + nestedLabwareInfo: NestedLabwareInfo | null commands?: RunTimeCommand[] } @@ -564,11 +581,11 @@ function RowLabware({ labware, attachedProtocolModules, refetchModules, + nestedLabwareInfo, commands, }: RowLabwareProps): JSX.Element | null { const { definition, initialLocation, nickName } = labware - const { t: commandTextTranslator } = useTranslation('protocol_command_text') - const { t: setupTextTranslator } = useTranslation('protocol_setup') + const { t } = useTranslation('protocol_command_text') const matchedModule = initialLocation !== 'offDeck' && @@ -584,20 +601,17 @@ function RowLabware({ ? matchedModule.attachedModuleMatch : null - const moduleInstructions = ( - - {setupTextTranslator('labware_latch_instructions')} - - ) - const matchedModuleType = matchedModule?.attachedModuleMatch?.moduleType + let slotName: string = '' let location: JSX.Element | string | null = null if (initialLocation === 'offDeck') { - location = commandTextTranslator('off_deck') + location = t('off_deck') } else if ('slotName' in initialLocation) { + slotName = initialLocation.slotName location = } else if (matchedModuleType != null && matchedModule?.slotName != null) { + slotName = matchedModule.slotName location = ( <> @@ -605,7 +619,6 @@ function RowLabware({ ) } else if ('labwareId' in initialLocation) { - // TODO(jr, 8/14/23): add adapter location icon when we have one const adapterId = initialLocation.labwareId const adapterLocation = commands?.find( (command): command is LoadLabwareRunTimeCommand => @@ -615,12 +628,14 @@ function RowLabware({ if (adapterLocation != null && adapterLocation !== 'offDeck') { if ('slotName' in adapterLocation) { + slotName = adapterLocation.slotName location = } else if ('moduleId' in adapterLocation) { const moduleUnderAdapter = attachedProtocolModules.find( module => module.moduleId === adapterLocation.moduleId ) if (moduleUnderAdapter != null) { + slotName = moduleUnderAdapter.slotName location = ( <> @@ -637,7 +652,6 @@ function RowLabware({ } } } - return ( - - - {getLabwareDisplayName(definition)} - - - {nickName} - - {matchingHeaterShaker != null ? moduleInstructions : null} + + + + {getLabwareDisplayName(definition)} + + + {nickName} + + + {nestedLabwareInfo != null ? ( + + ) : null} + {nestedLabwareInfo != null && + nestedLabwareInfo?.sharedSlotId === slotName ? ( + + + {nestedLabwareInfo.nestedLabwareDisplayName} + + + {nestedLabwareInfo.nestedLabwareNickName} + + + ) : null} {matchingHeaterShaker != null ? ( {labwareByLiquidId[liquid.id].map(labware => { - const { slotName, labwareName } = getSlotLabwareName( + const { slotName, labwareName } = getLocationInfoNames( labware.labwareId, commands ) diff --git a/app/src/organisms/ProtocolSetupLiquids/__tests__/LiquidDetails.test.tsx b/app/src/organisms/ProtocolSetupLiquids/__tests__/LiquidDetails.test.tsx index 24d521bfcac..1c41ce98f38 100644 --- a/app/src/organisms/ProtocolSetupLiquids/__tests__/LiquidDetails.test.tsx +++ b/app/src/organisms/ProtocolSetupLiquids/__tests__/LiquidDetails.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { renderWithProviders } from '@opentrons/components' import { i18n } from '../../../i18n' import { RUN_ID_1 } from '../../RunTimeControl/__fixtures__' -import { getSlotLabwareName } from '../../Devices/ProtocolRun/utils/getSlotLabwareName' +import { getLocationInfoNames } from '../../Devices/ProtocolRun/utils/getLocationInfoNames' import { getTotalVolumePerLiquidId } from '../../Devices/ProtocolRun/SetupLiquids/utils' import { LiquidDetails } from '../LiquidDetails' import { LiquidsLabwareDetailsModal } from '../../Devices/ProtocolRun/SetupLiquids/LiquidsLabwareDetailsModal' @@ -13,11 +13,11 @@ import { import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' jest.mock('../../Devices/ProtocolRun/SetupLiquids/utils') -jest.mock('../../Devices/ProtocolRun/utils/getSlotLabwareName') +jest.mock('../../Devices/ProtocolRun/utils/getLocationInfoNames') jest.mock('../../Devices/ProtocolRun/SetupLiquids/LiquidsLabwareDetailsModal') -const mockGetSlotLabwareNames = getSlotLabwareName as jest.MockedFunction< - typeof getSlotLabwareName +const mockGetLocationInfoNames = getLocationInfoNames as jest.MockedFunction< + typeof getLocationInfoNames > const mockgetTotalVolumePerLiquidId = getTotalVolumePerLiquidId as jest.MockedFunction< typeof getTotalVolumePerLiquidId @@ -46,7 +46,7 @@ describe('LiquidDetails', () => { }, } mockgetTotalVolumePerLiquidId.mockReturnValue(50) - mockGetSlotLabwareNames.mockReturnValue({ + mockGetLocationInfoNames.mockReturnValue({ slotName: '4', labwareName: 'mock labware name', }) diff --git a/app/src/organisms/ProtocolSetupModules/__tests__/ProtocolSetupModules.test.tsx b/app/src/organisms/ProtocolSetupModules/__tests__/ProtocolSetupModules.test.tsx index 1b46ba64ba8..486fbab0b65 100644 --- a/app/src/organisms/ProtocolSetupModules/__tests__/ProtocolSetupModules.test.tsx +++ b/app/src/organisms/ProtocolSetupModules/__tests__/ProtocolSetupModules.test.tsx @@ -8,17 +8,26 @@ import ot3StandardDeckDef from '@opentrons/shared-data/deck/definitions/3/ot3_st import { i18n } from '../../../i18n' import { mockRobotSideAnalysis } from '../../../organisms/CommandText/__fixtures__' -import { useAttachedModules } from '../../../organisms/Devices/hooks' +import { + useAttachedModules, + useRunCalibrationStatus, +} from '../../../organisms/Devices/hooks' import { useMostRecentCompletedAnalysis } from '../../../organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis' import { getProtocolModulesInfo } from '../../../organisms/Devices/ProtocolRun/utils/getProtocolModulesInfo' +import { mockApiHeaterShaker } from '../../../redux/modules/__fixtures__' +import { mockProtocolModuleInfo } from '../../ProtocolSetupInstruments/__fixtures__' +import { getLocalRobot } from '../../../redux/discovery' +import { mockConnectedRobot } from '../../../redux/discovery/__fixtures__' import { getAttachedProtocolModuleMatches, getUnmatchedModulesForProtocol, } from '../utils' import { SetupInstructionsModal } from '../SetupInstructionsModal' +import { ModuleWizardFlows } from '../../ModuleWizardFlows' import { ProtocolSetupModules } from '..' jest.mock('@opentrons/shared-data/js/helpers') +jest.mock('../../../redux/discovery') jest.mock('../../../organisms/Devices/hooks') jest.mock( '../../../organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis' @@ -26,6 +35,7 @@ jest.mock( jest.mock('../../../organisms/Devices/ProtocolRun/utils/getProtocolModulesInfo') jest.mock('../utils') jest.mock('../SetupInstructionsModal') +jest.mock('../../ModuleWizardFlows') const mockGetDeckDefFromRobotType = getDeckDefFromRobotType as jest.MockedFunction< typeof getDeckDefFromRobotType @@ -48,10 +58,33 @@ const mockUseMostRecentCompletedAnalysis = useMostRecentCompletedAnalysis as jes const mockSetupInstructionsModal = SetupInstructionsModal as jest.MockedFunction< typeof SetupInstructionsModal > +const mockGetLocalRobot = getLocalRobot as jest.MockedFunction< + typeof getLocalRobot +> +const mockUseRunCalibrationStatus = useRunCalibrationStatus as jest.MockedFunction< + typeof useRunCalibrationStatus +> +const mockModuleWizardFlows = ModuleWizardFlows as jest.MockedFunction< + typeof ModuleWizardFlows +> -const RUN_ID = "otie's run" +const ROBOT_NAME = 'otie' +const RUN_ID = '1' const mockSetSetupScreen = jest.fn() +const calibratedMockApiHeaterShaker = { + ...mockApiHeaterShaker, + moduleOffset: { + offset: { + x: 0.1640625, + y: -1.2421875, + z: -1.759999999999991, + }, + slot: '7', + last_modified: '2023-06-01T14:42:20.131798+00:00', + }, +} + const render = () => { return renderWithProviders( @@ -87,6 +120,16 @@ describe('ProtocolSetupModules', () => { mockSetupInstructionsModal.mockReturnValue(
mock SetupInstructionsModal
) + mockGetLocalRobot.mockReturnValue({ + ...mockConnectedRobot, + name: ROBOT_NAME, + }) + when(mockUseRunCalibrationStatus) + .calledWith(ROBOT_NAME, RUN_ID) + .mockReturnValue({ + complete: true, + }) + mockModuleWizardFlows.mockReturnValue(
mock ModuleWizardFlows
) }) afterEach(() => { @@ -115,4 +158,108 @@ describe('ProtocolSetupModules', () => { getByText('Setup Instructions').click() getByText('mock SetupInstructionsModal') }) + + it('should render module information when a protocol has module - connected', () => { + when(mockGetUnmatchedModulesForProtocol) + .calledWith(calibratedMockApiHeaterShaker as any, mockProtocolModuleInfo) + .mockReturnValue({ + missingModuleIds: [], + remainingAttachedModules: mockApiHeaterShaker as any, + }) + mockGetAttachedProtocolModuleMatches.mockReturnValue([ + { + ...mockProtocolModuleInfo[0], + attachedModuleMatch: calibratedMockApiHeaterShaker, + }, + ]) + const [{ getByText }] = render() + getByText('Heater-Shaker Module GEN1') + getByText('Connected') + }) + + it('should render module information when a protocol has module - disconnected', () => { + when(mockGetUnmatchedModulesForProtocol) + .calledWith(mockApiHeaterShaker as any, mockProtocolModuleInfo) + .mockReturnValue({ + missingModuleIds: [], + remainingAttachedModules: mockApiHeaterShaker as any, + }) + mockGetAttachedProtocolModuleMatches.mockReturnValue([ + { + ...mockProtocolModuleInfo[0], + }, + ]) + const [{ getByText }] = render() + getByText('Heater-Shaker Module GEN1') + getByText('Disconnected') + }) + + it('should render module information with calibrate button when a protocol has module', () => { + when(mockGetUnmatchedModulesForProtocol) + .calledWith(mockApiHeaterShaker as any, mockProtocolModuleInfo) + .mockReturnValue({ + missingModuleIds: [], + remainingAttachedModules: mockApiHeaterShaker as any, + }) + mockGetAttachedProtocolModuleMatches.mockReturnValue([ + { + ...mockProtocolModuleInfo[0], + attachedModuleMatch: mockApiHeaterShaker, + }, + ]) + const [{ getByText }] = render() + getByText('Heater-Shaker Module GEN1') + getByText('Calibrate').click() + getByText('mock ModuleWizardFlows') + }) + + it('should render module information with text button when a protocol has module - attach pipette first', () => { + const ATTACH_FIRST = { + complete: false, + reason: 'attach_pipette_failure_reason', + } + when(mockUseRunCalibrationStatus) + .calledWith(ROBOT_NAME, RUN_ID) + .mockReturnValue(ATTACH_FIRST as any) + when(mockGetUnmatchedModulesForProtocol) + .calledWith(mockApiHeaterShaker as any, mockProtocolModuleInfo) + .mockReturnValue({ + missingModuleIds: [], + remainingAttachedModules: mockApiHeaterShaker as any, + }) + mockGetAttachedProtocolModuleMatches.mockReturnValue([ + { + ...mockProtocolModuleInfo[0], + attachedModuleMatch: mockApiHeaterShaker, + }, + ]) + const [{ getByText }] = render() + getByText('Heater-Shaker Module GEN1') + getByText('Calibration required Attach pipette first') + }) + + it('should render module information with text button when a protocol has module - calibrate pipette first', () => { + const CALIBRATE_FIRST = { + complete: false, + reason: 'calibrate_pipette_failure_reason', + } + when(mockUseRunCalibrationStatus) + .calledWith(ROBOT_NAME, RUN_ID) + .mockReturnValue(CALIBRATE_FIRST as any) + when(mockGetUnmatchedModulesForProtocol) + .calledWith(mockApiHeaterShaker as any, mockProtocolModuleInfo) + .mockReturnValue({ + missingModuleIds: [], + remainingAttachedModules: mockApiHeaterShaker as any, + }) + mockGetAttachedProtocolModuleMatches.mockReturnValue([ + { + ...mockProtocolModuleInfo[0], + attachedModuleMatch: mockApiHeaterShaker, + }, + ]) + const [{ getByText }] = render() + getByText('Heater-Shaker Module GEN1') + getByText('Calibration required Calibrate pipette first') + }) }) diff --git a/app/src/organisms/ProtocolSetupModules/index.tsx b/app/src/organisms/ProtocolSetupModules/index.tsx index edd65ea08c3..66c3b7ca210 100644 --- a/app/src/organisms/ProtocolSetupModules/index.tsx +++ b/app/src/organisms/ProtocolSetupModules/index.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' import { ALIGN_CENTER, @@ -23,10 +24,12 @@ import { getModuleDisplayName, getModuleType, inferModuleOrientationFromXCoordinate, + HEATERSHAKER_MODULE_TYPE, NON_CONNECTING_MODULE_TYPES, TC_MODULE_LOCATION_OT3, THERMOCYCLER_MODULE_TYPE, } from '@opentrons/shared-data' +import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' import { Portal } from '../../App/portal' import { FloatingActionButton, SmallButton } from '../../atoms/buttons' @@ -35,21 +38,31 @@ import { InlineNotification } from '../../atoms/InlineNotification' import { Modal } from '../../molecules/Modal' import { StyledText } from '../../atoms/text' import { ODDBackButton } from '../../molecules/ODDBackButton' -import { useAttachedModules } from '../../organisms/Devices/hooks' +import { + useAttachedModules, + useRunCalibrationStatus, +} from '../../organisms/Devices/hooks' import { ModuleInfo } from '../../organisms/Devices/ModuleInfo' import { MultipleModulesModal } from '../../organisms/Devices/ProtocolRun/SetupModules/MultipleModulesModal' import { getProtocolModulesInfo } from '../../organisms/Devices/ProtocolRun/utils/getProtocolModulesInfo' import { useMostRecentCompletedAnalysis } from '../../organisms/LabwarePositionCheck/useMostRecentCompletedAnalysis' -import { ROBOT_MODEL_OT3 } from '../../redux/discovery' +import { ROBOT_MODEL_OT3, getLocalRobot } from '../../redux/discovery' import { getAttachedProtocolModuleMatches, getUnmatchedModulesForProtocol, } from './utils' import { SetupInstructionsModal } from './SetupInstructionsModal' +import { ModuleWizardFlows } from '../ModuleWizardFlows' +import type { + HeaterShakerDeactivateShakerCreateCommand, + HeaterShakerCloseLatchCreateCommand, + TCOpenLidCreateCommand, +} from '@opentrons/shared-data/protocol/types/schemaV7/command/module' import type { SetupScreens } from '../../pages/OnDeviceDisplay/ProtocolSetup' import type { AttachedProtocolModuleMatch } from './utils' import type { ModalHeaderBaseProps } from '../../molecules/Modal/types' +import type { ProtocolCalibrationStatus } from '../../organisms/Devices/hooks' const OT3_STANDARD_DECK_VIEW_LAYER_BLOCK_LIST: string[] = [ 'DECK_BASE', @@ -59,16 +72,150 @@ const OT3_STANDARD_DECK_VIEW_LAYER_BLOCK_LIST: string[] = [ 'CALIBRATION_CUTOUTS', ] +interface RenderModuleStatusProps { + isModuleReady: boolean + isDuplicateModuleModel: boolean + module: AttachedProtocolModuleMatch + calibrationStatus: ProtocolCalibrationStatus + setShowModuleWizard: (showModuleWizard: boolean) => void +} + +function RenderModuleStatus({ + isModuleReady, + isDuplicateModuleModel, + module, + calibrationStatus, + setShowModuleWizard, +}: RenderModuleStatusProps): JSX.Element { + const { i18n, t } = useTranslation('protocol_setup') + const { createLiveCommand } = useCreateLiveCommandMutation() + + const handleCalibrate = (): void => { + if ( + module.attachedModuleMatch?.moduleType === HEATERSHAKER_MODULE_TYPE && + module.attachedModuleMatch.data.currentSpeed != null && + module.attachedModuleMatch.data.currentSpeed > 0 + ) { + const stopShakeCommand: HeaterShakerDeactivateShakerCreateCommand = { + commandType: 'heaterShaker/deactivateShaker', + params: { + moduleId: module.attachedModuleMatch.id, + }, + } + createLiveCommand({ + command: stopShakeCommand, + }).catch((e: Error) => { + console.error( + `error setting module status with command type ${stopShakeCommand.commandType}: ${e.message}` + ) + }) + } + if ( + module.attachedModuleMatch?.moduleType === HEATERSHAKER_MODULE_TYPE && + module.attachedModuleMatch.data.labwareLatchStatus !== 'idle_closed' && + module.attachedModuleMatch.data.labwareLatchStatus !== 'closing' + ) { + const latchCommand: HeaterShakerCloseLatchCreateCommand = { + commandType: 'heaterShaker/closeLabwareLatch', + params: { + moduleId: module.attachedModuleMatch.id, + }, + } + createLiveCommand({ + command: latchCommand, + }).catch((e: Error) => { + console.error( + `error setting module status with command type ${latchCommand.commandType}: ${e.message}` + ) + }) + } + if ( + module.attachedModuleMatch?.moduleType === THERMOCYCLER_MODULE_TYPE && + module.attachedModuleMatch.data.lidStatus !== 'open' + ) { + const lidCommand: TCOpenLidCreateCommand = { + commandType: 'thermocycler/openLid', + params: { + moduleId: module.attachedModuleMatch.id, + }, + } + createLiveCommand({ + command: lidCommand, + }).catch((e: Error) => { + console.error( + `error setting thermocycler module status with command type ${lidCommand.commandType}: ${e.message}` + ) + }) + } + setShowModuleWizard(true) + } + + let moduleStatus: JSX.Element = ( + <> + + {isDuplicateModuleModel ? : null} + + ) + + if ( + isModuleReady && + calibrationStatus.complete && + module.attachedModuleMatch?.moduleOffset?.last_modified != null + ) { + moduleStatus = ( + <> + + {isDuplicateModuleModel ? ( + + ) : null} + + ) + } else if ( + isModuleReady && + calibrationStatus.complete && + module.attachedModuleMatch?.moduleOffset?.last_modified == null + ) { + moduleStatus = ( + + ) + } else if (!calibrationStatus?.complete) { + moduleStatus = ( + + {calibrationStatus?.reason === 'attach_pipette_failure_reason' + ? t('calibration_required_attach_pipette_first') + : t('calibration_required_calibrate_pipette_first')} + + ) + } + return moduleStatus +} + interface RowModuleProps { isDuplicateModuleModel: boolean module: AttachedProtocolModuleMatch setShowMultipleModulesModal: (showMultipleModulesModal: boolean) => void + calibrationStatus: ProtocolCalibrationStatus } function RowModule({ isDuplicateModuleModel, module, setShowMultipleModulesModal, + calibrationStatus, }: RowModuleProps): JSX.Element { const { t } = useTranslation('protocol_setup') const isNonConnectingModule = NON_CONNECTING_MODULE_TYPES.includes( @@ -76,62 +223,75 @@ function RowModule({ ) const isModuleReady = isNonConnectingModule || module.attachedModuleMatch != null + + const [showModuleWizard, setShowModuleWizard] = React.useState(false) + return ( - - isDuplicateModuleModel ? setShowMultipleModulesModal(true) : null - } - > - - - {getModuleDisplayName(module.moduleDef.model)} - - - - + {showModuleWizard && module.attachedModuleMatch != null ? ( + setShowModuleWizard(false)} + initialSlotName={module.slotName} /> - - {isNonConnectingModule ? ( - + ) : null} + + isDuplicateModuleModel ? setShowMultipleModulesModal(true) : null + } + > + - {t('n_a')} + {getModuleDisplayName(module.moduleDef.model)} - ) : ( - - + - {isDuplicateModuleModel ? ( - - ) : null} - )} - + {isNonConnectingModule ? ( + + + {t('n_a')} + + + ) : ( + + + + )} + + ) } @@ -168,6 +328,10 @@ export function ProtocolSetupModules({ const attachedModules = useAttachedModules() + const localRobot = useSelector(getLocalRobot) + const robotName = localRobot?.name != null ? localRobot.name : '' + const calibrationStatus = useRunCalibrationStatus(robotName, runId) + const protocolModulesInfo = mostRecentAnalysis != null ? getProtocolModulesInfo(mostRecentAnalysis, deckDef) @@ -310,6 +474,7 @@ export function ProtocolSetupModules({ module={module} isDuplicateModuleModel={isDuplicateModuleModel} setShowMultipleModulesModal={setShowMultipleModulesModal} + calibrationStatus={calibrationStatus} /> ) })} diff --git a/app/src/organisms/ProtocolsLanding/ProtocolCard.tsx b/app/src/organisms/ProtocolsLanding/ProtocolCard.tsx index 7a5866c2e5a..0b94bf6fbc2 100644 --- a/app/src/organisms/ProtocolsLanding/ProtocolCard.tsx +++ b/app/src/organisms/ProtocolsLanding/ProtocolCard.tsx @@ -3,6 +3,7 @@ import { format } from 'date-fns' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' import { useHistory } from 'react-router-dom' +import { ErrorBoundary } from 'react-error-boundary' import { getModuleType, @@ -55,7 +56,6 @@ interface ProtocolCardProps { handleSendProtocolToOT3: (storedProtocolData: StoredProtocolData) => void storedProtocolData: StoredProtocolData } - export function ProtocolCard(props: ProtocolCardProps): JSX.Element | null { const history = useHistory() const { @@ -78,6 +78,16 @@ export function ProtocolCard(props: ProtocolCardProps): JSX.Element | null { mostRecentAnalysis ) + const UNKNOWN_ATTACHMENT_ERROR = `${protocolDisplayName} protocol uses + instruments or modules from a future version of the app. Please update + the app to the most recent version to run this protocol.` + + const UnknownAttachmentError = ( + + {UNKNOWN_ATTACHMENT_ERROR} + + ) + return ( history.push(`/protocols/${protocolKey}`)} css={BORDERS.cardOutlineBorder} > - + + + { @@ -126,11 +133,13 @@ export function ProtocolList(props: ProtocolListProps): JSX.Element | null { {selectedProtocol != null ? ( <> setShowChooseRobotToRunProtocolSlideout(false)} showSlideout={showChooseRobotToRunProtocolSlideout} storedProtocolData={selectedProtocol} /> setShowSendProtocolToOT3Slideout(false)} storedProtocolData={selectedProtocol} @@ -196,15 +205,21 @@ export function ProtocolList(props: ProtocolListProps): JSX.Element | null { handleProtocolsSortKey('alphabetical')}> {t('shared:alphabetical')} - handleProtocolsSortKey('recent')}> - {t('most_recent_updates')} - handleProtocolsSortKey('reverse')}> {t('shared:reverse')} + handleProtocolsSortKey('recent')}> + {t('most_recent_updates')} + handleProtocolsSortKey('oldest')}> {t('oldest_updates')} + handleProtocolsSortKey('flex')}> + {t('robot_type_first', { robotType: FLEX })} + + handleProtocolsSortKey('ot2')}> + {t('robot_type_first', { robotType: OT2 })} + )} {showSortByMenu ? ( diff --git a/app/src/organisms/ProtocolsLanding/ProtocolOverflowMenu.tsx b/app/src/organisms/ProtocolsLanding/ProtocolOverflowMenu.tsx index 2aca16f3375..f98b5ef0b28 100644 --- a/app/src/organisms/ProtocolsLanding/ProtocolOverflowMenu.tsx +++ b/app/src/organisms/ProtocolsLanding/ProtocolOverflowMenu.tsx @@ -11,6 +11,7 @@ import { ALIGN_FLEX_END, useConditionalConfirm, } from '@opentrons/components' +import { FLEX_DISPLAY_NAME } from '@opentrons/shared-data' import { Portal } from '../../App/portal' import { OverflowBtn } from '../../atoms/MenuList/OverflowBtn' @@ -145,7 +146,9 @@ export function ProtocolOverflowMenu( onClick={handleClickSendToOT3} data-testid="ProtocolOverflowMenu_sendToOT3" > - {t('send_to_ot3')} + {t('protocol_list:send_to_ot3_overflow', { + robot_display_name: FLEX_DISPLAY_NAME, + })}
) : null} { const { getByText } = render(props) getByText('Oldest updates') }) + + it('renders Flex as the sort key when flex was selected last time', () => { + when(mockUseSortedProtocols) + .calledWith('flex', [storedProtocolData, storedProtocolDataTwo]) + .mockReturnValue([storedProtocolData, storedProtocolDataTwo]) + when(mockGetProtocolsDesktopSortKey).mockReturnValue('flex') + const { getByText } = render(props) + getByText('Flex protocols first') + }) + + it('renders ot2 as the sort key when ot2 was selected last time', () => { + when(mockUseSortedProtocols) + .calledWith('ot2', [storedProtocolData, storedProtocolDataTwo]) + .mockReturnValue([storedProtocolData, storedProtocolDataTwo]) + when(mockGetProtocolsDesktopSortKey).mockReturnValue('ot2') + const { getByText } = render(props) + getByText('OT-2 protocols first') + }) }) diff --git a/app/src/organisms/ProtocolsLanding/__tests__/hooks.test.tsx b/app/src/organisms/ProtocolsLanding/__tests__/hooks.test.tsx index 9cf9310ecf1..41c60f2de40 100644 --- a/app/src/organisms/ProtocolsLanding/__tests__/hooks.test.tsx +++ b/app/src/organisms/ProtocolsLanding/__tests__/hooks.test.tsx @@ -2,13 +2,14 @@ import * as React from 'react' import { Provider } from 'react-redux' import { createStore } from 'redux' import { renderHook } from '@testing-library/react-hooks' +import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' import { useSortedProtocols } from '../hooks' import { StoredProtocolData } from '../../../redux/protocol-storage' import type { Store } from 'redux' -import type { State } from '../../../redux/types' import type { ProtocolAnalysisOutput } from '@opentrons/shared-data' +import type { State } from '../../../redux/types' const mockStoredProtocolData = [ { @@ -17,6 +18,7 @@ const mockStoredProtocolData = [ srcFileNames: ['secondProtocol.json'], srcFiles: [], mostRecentAnalysis: { + robotType: FLEX_ROBOT_TYPE, createdAt: '2022-05-03T21:36:12.494778+00:00', files: [ { @@ -102,6 +104,7 @@ const mockStoredProtocolData = [ srcFileNames: ['testProtocol.json'], srcFiles: [], mostRecentAnalysis: { + robotType: OT2_ROBOT_TYPE, createdAt: '2022-05-10T17:04:43.132768+00:00', files: [ { @@ -377,4 +380,51 @@ describe('useSortedProtocols', () => { '3dc99ffa-f85e-4c01-ab0a-edecff432dac' ) }) + + it('should return an object with protocols sorted by flex then ot-2', () => { + const wrapper: React.FunctionComponent<{}> = ({ children }) => ( + {children} + ) + + const { result } = renderHook( + () => useSortedProtocols('flex', mockStoredProtocolData), + { wrapper } + ) + const firstProtocol = result.current[0] + const secondProtocol = result.current[1] + const thirdProtocol = result.current[2] + + expect(firstProtocol.protocolKey).toBe( + '26ed5a82-502f-4074-8981-57cdda1d066d' + ) + expect(secondProtocol.protocolKey).toBe( + '3dc99ffa-f85e-4c01-ab0a-edecff432dac' + ) + expect(thirdProtocol.protocolKey).toBe( + 'f130337e-68ad-4b5d-a6d2-cbc20515b1f7' + ) + }) + it('should return an object with protocols sorted by ot-2 then flex', () => { + const wrapper: React.FunctionComponent<{}> = ({ children }) => ( + {children} + ) + + const { result } = renderHook( + () => useSortedProtocols('ot2', mockStoredProtocolData), + { wrapper } + ) + const firstProtocol = result.current[0] + const secondProtocol = result.current[1] + const thirdProtocol = result.current[2] + + expect(firstProtocol.protocolKey).toBe( + '3dc99ffa-f85e-4c01-ab0a-edecff432dac' + ) + expect(secondProtocol.protocolKey).toBe( + 'f130337e-68ad-4b5d-a6d2-cbc20515b1f7' + ) + expect(thirdProtocol.protocolKey).toBe( + '26ed5a82-502f-4074-8981-57cdda1d066d' + ) + }) }) diff --git a/app/src/organisms/ProtocolsLanding/hooks.tsx b/app/src/organisms/ProtocolsLanding/hooks.tsx index 17eeff5ffb3..dcdc9c528c9 100644 --- a/app/src/organisms/ProtocolsLanding/hooks.tsx +++ b/app/src/organisms/ProtocolsLanding/hooks.tsx @@ -1,7 +1,14 @@ +import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data' import { StoredProtocolData } from '../../redux/protocol-storage' import { getProtocolDisplayName } from './utils' -export type ProtocolSort = 'alphabetical' | 'reverse' | 'recent' | 'oldest' +export type ProtocolSort = + | 'alphabetical' + | 'reverse' + | 'recent' + | 'oldest' + | 'flex' + | 'ot2' export function useSortedProtocols( sortBy: ProtocolSort, @@ -18,6 +25,8 @@ export function useSortedProtocols( b.srcFileNames, b?.mostRecentAnalysis ) + const protocolRobotTypeA = a?.mostRecentAnalysis?.robotType + const protocolRobotTypeB = b?.mostRecentAnalysis?.robotType if (sortBy === 'alphabetical') { if (protocolNameA.toLowerCase() === protocolNameB.toLowerCase()) { @@ -30,6 +39,34 @@ export function useSortedProtocols( return b.modified - a.modified } else if (sortBy === 'oldest') { return a.modified - b.modified + } else if (sortBy === 'flex') { + if ( + protocolRobotTypeA === FLEX_ROBOT_TYPE && + protocolRobotTypeB !== FLEX_ROBOT_TYPE + ) { + return -1 + } + if ( + protocolRobotTypeA !== FLEX_ROBOT_TYPE && + protocolRobotTypeB === FLEX_ROBOT_TYPE + ) { + return 1 + } + return b.modified - a.modified + } else if (sortBy === 'ot2') { + if ( + protocolRobotTypeA !== FLEX_ROBOT_TYPE && + protocolRobotTypeB === FLEX_ROBOT_TYPE + ) { + return -1 + } + if ( + protocolRobotTypeA === FLEX_ROBOT_TYPE && + protocolRobotTypeB !== FLEX_ROBOT_TYPE + ) { + return 1 + } + return b.modified - a.modified } return 0 }) diff --git a/app/src/organisms/RobotSettingsCalibration/CalibrationDataDownload.tsx b/app/src/organisms/RobotSettingsCalibration/CalibrationDataDownload.tsx index fee5399d0dd..40fc4528570 100644 --- a/app/src/organisms/RobotSettingsCalibration/CalibrationDataDownload.tsx +++ b/app/src/organisms/RobotSettingsCalibration/CalibrationDataDownload.tsx @@ -12,7 +12,10 @@ import { TYPOGRAPHY, DIRECTION_COLUMN, } from '@opentrons/components' -import { useInstrumentsQuery } from '@opentrons/react-api-client' +import { + useInstrumentsQuery, + useModulesQuery, +} from '@opentrons/react-api-client' import { TertiaryButton } from '../../atoms/buttons' import { StyledText } from '../../atoms/text' import { @@ -55,21 +58,15 @@ export function CalibrationDataDownload({ const pipetteOffsetCalibrations = usePipetteOffsetCalibrations() const tipLengthCalibrations = useTipLengthCalibrations() const { data: attachedInstruments } = useInstrumentsQuery({ enabled: isOT3 }) + const { data: attachedModules } = useModulesQuery({ enabled: isOT3 }) - const downloadIsPossible = + const ot2DownloadIsPossible = deckCalibrationData.isDeckCalibrated && pipetteOffsetCalibrations != null && pipetteOffsetCalibrations.length > 0 && tipLengthCalibrations != null && tipLengthCalibrations.length > 0 - const ot3DownloadIsPossible = - isOT3 && - attachedInstruments?.data.some( - instrument => - instrument.ok && instrument.data.calibratedOffset?.last_modified != null - ) - const onClickSaveAs: React.MouseEventHandler = e => { e.preventDefault() doTrackEvent({ @@ -81,6 +78,7 @@ export function CalibrationDataDownload({ isOT3 ? JSON.stringify({ instrumentData: attachedInstruments, + moduleData: attachedModules, }) : JSON.stringify({ deck: deckCalibrationData, @@ -124,7 +122,7 @@ export function CalibrationDataDownload({ ) : ( {t( - downloadIsPossible + ot2DownloadIsPossible ? 'robot_calibration:download_calibration_data_available' : 'robot_calibration:download_calibration_data_unavailable' )} @@ -133,7 +131,7 @@ export function CalibrationDataDownload({ diff --git a/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/ModuleCalibrationItems.tsx b/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/ModuleCalibrationItems.tsx new file mode 100644 index 00000000000..a25fc72ae73 --- /dev/null +++ b/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/ModuleCalibrationItems.tsx @@ -0,0 +1,100 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' + +import { BORDERS, COLORS, SPACING, TYPOGRAPHY } from '@opentrons/components' +import { getModuleDisplayName } from '@opentrons/shared-data/js/modules' + +import { StyledText } from '../../../atoms/text' +import { formatLastCalibrated } from './utils' +import { ModuleCalibrationOverflowMenu } from './ModuleCalibrationOverflowMenu' + +import type { AttachedModule } from '@opentrons/api-client' +import type { FormattedPipetteOffsetCalibration } from '..' + +interface ModuleCalibrationItemsProps { + attachedModules: AttachedModule[] + updateRobotStatus: (isRobotBusy: boolean) => void + formattedPipetteOffsetCalibrations: FormattedPipetteOffsetCalibration[] +} + +export function ModuleCalibrationItems({ + attachedModules, + updateRobotStatus, + formattedPipetteOffsetCalibrations, +}: ModuleCalibrationItemsProps): JSX.Element { + const { t } = useTranslation('device_settings') + + return ( + + + + {t('module')} + {t('serial')} + {t('last_calibrated_label')} + + + + {attachedModules.map(attachedModule => ( + + + + {getModuleDisplayName(attachedModule.moduleModel)} + + + + {attachedModule.serialNumber} + + + + {attachedModule.moduleOffset?.last_modified != null + ? formatLastCalibrated( + attachedModule.moduleOffset?.last_modified + ) + : t('not_calibrated_short')} + + + + + + + ))} + + + ) +} + +const StyledTable = styled.table` + width: 100%; + border-collapse: collapse; + text-align: left; +` + +const StyledTableHeader = styled.th` + ${TYPOGRAPHY.labelSemiBold} + padding: ${SPACING.spacing8}; +` + +const StyledTableRow = styled.tr` + padding: ${SPACING.spacing8}; + border-bottom: ${BORDERS.lineBorder}; +` + +const StyledTableCell = styled.td` + padding: ${SPACING.spacing8}; + text-overflow: wrap; +` + +const BODY_STYLE = css` + box-shadow: 0 0 0 1px ${COLORS.medGreyEnabled}; + border-radius: 3px; +` diff --git a/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/ModuleCalibrationOverflowMenu.tsx b/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/ModuleCalibrationOverflowMenu.tsx new file mode 100644 index 00000000000..64360f30019 --- /dev/null +++ b/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/ModuleCalibrationOverflowMenu.tsx @@ -0,0 +1,178 @@ +import * as React from 'react' + +import { useTranslation } from 'react-i18next' + +import { + Flex, + COLORS, + POSITION_ABSOLUTE, + DIRECTION_COLUMN, + POSITION_RELATIVE, + ALIGN_FLEX_END, + useOnClickOutside, +} from '@opentrons/components' +import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' +import { + HEATERSHAKER_MODULE_TYPE, + THERMOCYCLER_MODULE_TYPE, +} from '@opentrons/shared-data' + +import { Divider } from '../../../atoms/structure' +import { OverflowBtn } from '../../../atoms/MenuList/OverflowBtn' +import { MenuItem } from '../../../atoms/MenuList/MenuItem' +import { useMenuHandleClickOutside } from '../../../atoms/MenuList/hooks' +import { useRunStatuses } from '../../Devices/hooks' +import { useLatchControls } from '../../ModuleCard/hooks' +import { ModuleWizardFlows } from '../../ModuleWizardFlows' + +import type { + HeaterShakerDeactivateShakerCreateCommand, + TCOpenLidCreateCommand, +} from '@opentrons/shared-data/protocol/types/schemaV7/command/module' +import type { AttachedModule } from '../../../redux/modules/types' +import type { FormattedPipetteOffsetCalibration } from '../' +interface ModuleCalibrationOverflowMenuProps { + isCalibrated: boolean + attachedModule: AttachedModule + updateRobotStatus: (isRobotBusy: boolean) => void + formattedPipetteOffsetCalibrations: FormattedPipetteOffsetCalibration[] +} + +export function ModuleCalibrationOverflowMenu({ + isCalibrated, + attachedModule, + updateRobotStatus, + formattedPipetteOffsetCalibrations, +}: ModuleCalibrationOverflowMenuProps): JSX.Element { + const { t } = useTranslation(['device_settings', 'robot_calibration']) + + const { + menuOverlay, + handleOverflowClick, + showOverflowMenu, + setShowOverflowMenu, + } = useMenuHandleClickOutside() + + const [showModuleWizard, setShowModuleWizard] = React.useState(false) + const { isRunRunning: isRunning } = useRunStatuses() + + const OverflowMenuRef = useOnClickOutside({ + onClickOutside: () => setShowOverflowMenu(false), + }) + const { createLiveCommand } = useCreateLiveCommandMutation() + + const requiredAttachOrCalibratePipette = + formattedPipetteOffsetCalibrations.length === 0 || + (formattedPipetteOffsetCalibrations[0].lastCalibrated == null && + formattedPipetteOffsetCalibrations[1].lastCalibrated == null) + + const { toggleLatch, isLatchClosed } = useLatchControls(attachedModule) + + const handleCalibration = (): void => { + if ( + attachedModule.moduleType === HEATERSHAKER_MODULE_TYPE && + attachedModule.data.currentSpeed != null && + attachedModule.data.currentSpeed > 0 + ) { + const stopShakeCommand: HeaterShakerDeactivateShakerCreateCommand = { + commandType: 'heaterShaker/deactivateShaker', + params: { + moduleId: attachedModule.id, + }, + } + createLiveCommand({ + command: stopShakeCommand, + }).catch((e: Error) => { + console.error( + `error setting module status with command type ${stopShakeCommand.commandType}: ${e.message}` + ) + }) + } + if ( + attachedModule.moduleType === HEATERSHAKER_MODULE_TYPE && + !isLatchClosed + ) { + toggleLatch() + } + + if ( + attachedModule.moduleType === THERMOCYCLER_MODULE_TYPE && + attachedModule.data.lidStatus !== 'open' + ) { + const lidCommand: TCOpenLidCreateCommand = { + commandType: 'thermocycler/openLid', + params: { + moduleId: attachedModule.id, + }, + } + createLiveCommand({ + command: lidCommand, + }).catch((e: Error) => { + console.error( + `error setting thermocycler module status with command type ${lidCommand.commandType}: ${e.message}` + ) + }) + } + setShowOverflowMenu(false) + setShowModuleWizard(true) + } + + const handleDeleteCalibration = (): void => { + // ToDo (kk:08/23/2023) + // call a custom hook to delete calibration data + } + + React.useEffect(() => { + if (isRunning) { + updateRobotStatus(true) + } + }, [isRunning, updateRobotStatus]) + + return ( + + + {showModuleWizard ? ( + { + setShowModuleWizard(false) + }} + /> + ) : null} + {showOverflowMenu ? ( + + + {isCalibrated ? t('recalibrate_module') : t('calibrate_module')} + + {isCalibrated ? ( + <> + + + {t('clear_calibration_data')} + + + ) : null} + + ) : null} + {menuOverlay} + + ) +} diff --git a/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__tests__/ModuleCalibrationItems.test.tsx b/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__tests__/ModuleCalibrationItems.test.tsx new file mode 100644 index 00000000000..48599a112b5 --- /dev/null +++ b/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__tests__/ModuleCalibrationItems.test.tsx @@ -0,0 +1,103 @@ +import * as React from 'react' + +import { renderWithProviders } from '@opentrons/components' + +import { i18n } from '../../../../i18n' +import { mockFetchModulesSuccessActionPayloadModules } from '../../../../redux/modules/__fixtures__' +import { ModuleCalibrationOverflowMenu } from '../ModuleCalibrationOverflowMenu' +import { formatLastCalibrated } from '../utils' +import { ModuleCalibrationItems } from '../ModuleCalibrationItems' + +import type { AttachedModule } from '@opentrons/api-client' + +jest.mock('../ModuleCalibrationOverflowMenu') + +const mockModuleCalibrationOverflowMenu = ModuleCalibrationOverflowMenu as jest.MockedFunction< + typeof ModuleCalibrationOverflowMenu +> + +const mockCalibratedModule = { + id: '1436cd6085f18e5c315d65bd835d899a631cc2ba', + serialNumber: 'TC2PVT2023040702', + firmwareVersion: 'v1.0.4', + hardwareRevision: 'Opentrons-thermocycler-gen2', + hasAvailableUpdate: false, + moduleType: 'thermocyclerModuleType', + moduleModel: 'thermocyclerModuleV2', + moduleOffset: { + offset: { + x: 0.1640625, + y: -1.2421875, + z: -1.759999999999991, + }, + slot: '7', + last_modified: '2023-06-01T14:42:20.131798+00:00', + }, + data: { + status: 'holding at target', + currentTemperature: 10, + targetTemperature: 10, + lidStatus: 'open', + lidTemperatureStatus: 'holding at target', + lidTemperature: 100, + lidTargetTemperature: 100, + holdTime: 0, + currentCycleIndex: 1, + totalCycleCount: 1, + currentStepIndex: 1, + totalStepCount: 1, + }, + usbPort: { + port: 3, + portGroup: 'left', + hub: false, + path: '1.0/tty/ttyACM3/dev', + }, +} + +const render = ( + props: React.ComponentProps +): ReturnType => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('ModuleCalibrationItems', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + attachedModules: mockFetchModulesSuccessActionPayloadModules, + updateRobotStatus: jest.fn(), + formattedPipetteOffsetCalibrations: [], + } + mockModuleCalibrationOverflowMenu.mockReturnValue( +
mock ModuleCalibrationOverflowMenu
+ ) + }) + + it('should render module information and overflow menu', () => { + const [{ getByText, getAllByText }] = render(props) + getByText('Module') + getByText('Serial') + getByText('Last Calibrated') + getByText('Magnetic Module GEN1') + getByText('def456') + getByText('Temperature Module GEN1') + getByText('abc123') + getByText('Thermocycler Module GEN1') + getByText('ghi789') + expect(getAllByText('Not calibrated').length).toBe(3) + expect(getAllByText('mock ModuleCalibrationOverflowMenu').length).toBe(3) + }) + + it('should display last calibrated time if a module is calibrated', () => { + props = { + ...props, + attachedModules: [mockCalibratedModule] as AttachedModule[], + } + const [{ getByText }] = render(props) + getByText(formatLastCalibrated('2023-06-01T14:42:20.131798+00:00')) + }) +}) diff --git a/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__tests__/ModuleCalibrationOverflowMenu.test.tsx b/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__tests__/ModuleCalibrationOverflowMenu.test.tsx new file mode 100644 index 00000000000..e910a141e14 --- /dev/null +++ b/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__tests__/ModuleCalibrationOverflowMenu.test.tsx @@ -0,0 +1,144 @@ +import * as React from 'react' +import { when, resetAllWhenMocks } from 'jest-when' +import { renderWithProviders } from '@opentrons/components' +import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' + +import { i18n } from '../../../../i18n' +import { ModuleWizardFlows } from '../../../ModuleWizardFlows' +import { mockHeaterShaker } from '../../../../redux/modules/__fixtures__' +import { useRunStatuses } from '../../../Devices/hooks' +import { useLatchControls } from '../../../ModuleCard/hooks' +import { ModuleCalibrationOverflowMenu } from '../ModuleCalibrationOverflowMenu' + +import type { Mount } from '@opentrons/components' + +jest.mock('@opentrons/react-api-client') +jest.mock('../../../ModuleCard/hooks') +jest.mock('../../../ModuleWizardFlows') +jest.mock('../../../Devices/hooks') + +const mockPipetteOffsetCalibrations = [ + { + modelName: 'mockPipetteModelLeft', + serialNumber: '1234567', + mount: 'left' as Mount, + tiprack: 'mockTiprackLeft', + lastCalibrated: '2022-11-10T18:14:01', + markedBad: false, + }, + { + modelName: 'mockPipetteModelRight', + serialNumber: '01234567', + mount: 'right' as Mount, + tiprack: 'mockTiprackRight', + lastCalibrated: '2022-11-10T18:15:02', + markedBad: false, + }, +] + +const mockCreateLiveCommand = jest.fn() +const mockToggleLatch = jest.fn() + +const mockModuleWizardFlows = ModuleWizardFlows as jest.MockedFunction< + typeof ModuleWizardFlows +> +const mockUseRunStatuses = useRunStatuses as jest.MockedFunction< + typeof useRunStatuses +> +const mockUseCreateLiveCommandMutation = useCreateLiveCommandMutation as jest.MockedFunction< + typeof useCreateLiveCommandMutation +> +const mockUseLatchControls = useLatchControls as jest.MockedFunction< + typeof useLatchControls +> + +const render = ( + props: React.ComponentProps +) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('ModuleCalibrationOverflowMenu', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + isCalibrated: false, + attachedModule: mockHeaterShaker, + updateRobotStatus: jest.fn(), + formattedPipetteOffsetCalibrations: mockPipetteOffsetCalibrations, + } + mockModuleWizardFlows.mockReturnValue(
module wizard flows
) + mockUseRunStatuses.mockReturnValue({ + isRunRunning: false, + isRunStill: false, + isRunIdle: false, + isRunTerminal: false, + }) + mockUseCreateLiveCommandMutation.mockReturnValue({ + createLiveCommand: mockCreateLiveCommand, + } as any) + when(mockUseLatchControls) + .calledWith(mockHeaterShaker) + .mockReturnValue({ + toggleLatch: mockToggleLatch, + isLatchClosed: true, + } as any) + }) + + afterEach(() => { + jest.clearAllMocks() + resetAllWhenMocks() + }) + + it('should render overflow menu buttons - not calibrated', () => { + const [{ getByText, queryByText, getByLabelText }] = render(props) + getByLabelText('ModuleCalibrationOverflowMenu').click() + getByText('Calibrate module') + expect(queryByText('Clear calibration data')).not.toBeInTheDocument() + }) + + it('should render overflow menu buttons - calibrated', () => { + props = { ...props, isCalibrated: true } + const [{ getByText, getByLabelText }] = render(props) + getByLabelText('ModuleCalibrationOverflowMenu').click() + getByText('Recalibrate module') + getByText('Clear calibration data') + }) + + it('should call a mock function when clicking calibrate button', () => { + const [{ getByText, getByLabelText }] = render(props) + getByLabelText('ModuleCalibrationOverflowMenu').click() + getByText('Calibrate module').click() + getByText('module wizard flows') + }) + + it('should be disabled when not calibrated module and pipette is not attached', () => { + props.formattedPipetteOffsetCalibrations = [] as any + const [{ getByText, getByLabelText }] = render(props) + getByLabelText('ModuleCalibrationOverflowMenu').click() + expect(getByText('Calibrate module')).toBeDisabled() + }) + + it('should be disabled when not calibrated module and pipette is not calibrated', () => { + props.formattedPipetteOffsetCalibrations[0].lastCalibrated = undefined + props.formattedPipetteOffsetCalibrations[1].lastCalibrated = undefined + const [{ getByText, getByLabelText }] = render(props) + getByLabelText('ModuleCalibrationOverflowMenu').click() + expect(getByText('Calibrate module')).toBeDisabled() + }) + + it('should be disabled when running', () => { + mockUseRunStatuses.mockReturnValue({ + isRunRunning: true, + isRunStill: false, + isRunIdle: false, + isRunTerminal: false, + }) + const [{ getByText, getByLabelText }] = render(props) + getByLabelText('ModuleCalibrationOverflowMenu').click() + expect(getByText('Calibrate module')).toBeDisabled() + }) +}) diff --git a/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__test__/OverflowMenu.test.tsx b/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__tests__/OverflowMenu.test.tsx similarity index 99% rename from app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__test__/OverflowMenu.test.tsx rename to app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__tests__/OverflowMenu.test.tsx index a71888119c5..18919b6c1c2 100644 --- a/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__test__/OverflowMenu.test.tsx +++ b/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__tests__/OverflowMenu.test.tsx @@ -17,7 +17,7 @@ import { useDeckCalibrationData, useRunStatuses, useAttachedPipettesFromInstrumentsQuery, -} from '../../../../organisms/Devices/hooks' +} from '../../../Devices/hooks' import { mockAttachedPipetteInformation } from '../../../../redux/pipettes/__fixtures__' import { OverflowMenu } from '../OverflowMenu' diff --git a/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__test__/PipetteOffsetCalibrationItems.test.tsx b/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__tests__/PipetteOffsetCalibrationItems.test.tsx similarity index 97% rename from app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__test__/PipetteOffsetCalibrationItems.test.tsx rename to app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__tests__/PipetteOffsetCalibrationItems.test.tsx index 683a5f6880f..9a90419d2e5 100644 --- a/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__test__/PipetteOffsetCalibrationItems.test.tsx +++ b/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__tests__/PipetteOffsetCalibrationItems.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { when, resetAllWhenMocks } from 'jest-when' -import { renderWithProviders, Mount } from '@opentrons/components' +import { renderWithProviders } from '@opentrons/components' import { i18n } from '../../../../i18n' import { @@ -12,11 +12,12 @@ import { useAttachedPipettes, useIsOT3, useAttachedPipettesFromInstrumentsQuery, -} from '../../../../organisms/Devices/hooks' +} from '../../../Devices/hooks' import { PipetteOffsetCalibrationItems } from '../PipetteOffsetCalibrationItems' import { OverflowMenu } from '../OverflowMenu' import { formatLastCalibrated } from '../utils' +import type { Mount } from '@opentrons/components' import type { AttachedPipettesByMount } from '../../../../redux/pipettes/types' const render = ( diff --git a/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__test__/TipLengthCalibrationItems.test.tsx b/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__tests__/TipLengthCalibrationItems.test.tsx similarity index 100% rename from app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__test__/TipLengthCalibrationItems.test.tsx rename to app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__tests__/TipLengthCalibrationItems.test.tsx diff --git a/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__test__/utils.test.ts b/app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__tests__/utils.test.ts similarity index 100% rename from app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__test__/utils.test.ts rename to app/src/organisms/RobotSettingsCalibration/CalibrationDetails/__tests__/utils.test.ts diff --git a/app/src/organisms/RobotSettingsCalibration/ModuleCalibrationConfirmModal.stories.tsx b/app/src/organisms/RobotSettingsCalibration/ModuleCalibrationConfirmModal.stories.tsx new file mode 100644 index 00000000000..03a0acbf8d3 --- /dev/null +++ b/app/src/organisms/RobotSettingsCalibration/ModuleCalibrationConfirmModal.stories.tsx @@ -0,0 +1,20 @@ +import * as React from 'react' + +import { ModuleCalibrationConfirmModal } from './ModuleCalibrationConfirmModal' + +import type { Story, Meta } from '@storybook/react' + +export default { + title: 'App/Organisms/ModuleCalibrationConfirmModal', + component: ModuleCalibrationConfirmModal, +} as Meta + +const Template: Story< + React.ComponentProps +> = args => + +export const Primary = Template.bind({}) +Primary.args = { + confirm: () => {}, + cancel: () => {}, +} diff --git a/app/src/organisms/RobotSettingsCalibration/ModuleCalibrationConfirmModal.tsx b/app/src/organisms/RobotSettingsCalibration/ModuleCalibrationConfirmModal.tsx new file mode 100644 index 00000000000..99b1045aa05 --- /dev/null +++ b/app/src/organisms/RobotSettingsCalibration/ModuleCalibrationConfirmModal.tsx @@ -0,0 +1,52 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { + Flex, + SPACING, + AlertPrimaryButton, + SecondaryButton, + DIRECTION_COLUMN, + DIRECTION_ROW, + JUSTIFY_FLEX_END, +} from '@opentrons/components' + +import { LegacyModal } from '../../molecules/LegacyModal' +import { StyledText } from '../../atoms/text' + +interface ModuleCalibrationConfirmModalProps { + confirm: () => unknown + cancel: () => unknown +} + +export function ModuleCalibrationConfirmModal({ + confirm, + cancel, +}: ModuleCalibrationConfirmModalProps): JSX.Element { + const { i18n, t } = useTranslation(['device_settings', 'shared']) + + return ( + + + + {t('module_calibration_confirm_modal_body')} + + + + {i18n.format(t('shared:cancel'), 'capitalize')} + + + {i18n.format(t('shared:clear_data'), 'capitalize')} + + + + + ) +} diff --git a/app/src/organisms/RobotSettingsCalibration/RobotSettingsModuleCalibration.tsx b/app/src/organisms/RobotSettingsCalibration/RobotSettingsModuleCalibration.tsx new file mode 100644 index 00000000000..8492803fa9f --- /dev/null +++ b/app/src/organisms/RobotSettingsCalibration/RobotSettingsModuleCalibration.tsx @@ -0,0 +1,55 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' + +import { + DIRECTION_COLUMN, + Flex, + SPACING, + TYPOGRAPHY, +} from '@opentrons/components' + +import { StyledText } from '../../atoms/text' +import { ModuleCalibrationItems } from './CalibrationDetails/ModuleCalibrationItems' + +import type { AttachedModule } from '@opentrons/api-client' +import type { FormattedPipetteOffsetCalibration } from '.' + +interface RobotSettingsModuleCalibrationProps { + attachedModules: AttachedModule[] + updateRobotStatus: (isRobotBusy: boolean) => void + formattedPipetteOffsetCalibrations: FormattedPipetteOffsetCalibration[] +} + +export function RobotSettingsModuleCalibration({ + attachedModules, + updateRobotStatus, + formattedPipetteOffsetCalibrations, +}: RobotSettingsModuleCalibrationProps): JSX.Element { + const { t } = useTranslation('device_settings') + + return ( + + + {t('module_calibration')} + + {t('module_calibration_description')} + {attachedModules.length > 0 ? ( + + ) : ( + + {t('no_modules_attached')} + + )} + + ) +} diff --git a/app/src/organisms/RobotSettingsCalibration/__tests__/CalibrationDataDownload.test.tsx b/app/src/organisms/RobotSettingsCalibration/__tests__/CalibrationDataDownload.test.tsx index 07a30016a7a..13979539b77 100644 --- a/app/src/organisms/RobotSettingsCalibration/__tests__/CalibrationDataDownload.test.tsx +++ b/app/src/organisms/RobotSettingsCalibration/__tests__/CalibrationDataDownload.test.tsx @@ -3,7 +3,10 @@ import { saveAs } from 'file-saver' import { when, resetAllWhenMocks } from 'jest-when' import { renderWithProviders } from '@opentrons/components' -import { useInstrumentsQuery } from '@opentrons/react-api-client' +import { + useInstrumentsQuery, + useModulesQuery, +} from '@opentrons/react-api-client' import { instrumentsResponseFixture } from '@opentrons/api-client' import { i18n } from '../../../i18n' @@ -55,6 +58,9 @@ const mockUseIsOT3 = useIsOT3 as jest.MockedFunction const mockUseInstrumentsQuery = useInstrumentsQuery as jest.MockedFunction< typeof useInstrumentsQuery > +const mockUseModulesQuery = useModulesQuery as jest.MockedFunction< + typeof useModulesQuery +> let mockTrackEvent: jest.Mock const mockSetShowHowCalibrationWorksModal = jest.fn() @@ -113,6 +119,9 @@ describe('CalibrationDataDownload', () => { mockUseInstrumentsQuery.mockReturnValue({ data: { data: [] }, } as any) + mockUseModulesQuery.mockReturnValue({ + data: { data: [] }, + } as any) }) afterEach(() => { @@ -221,7 +230,7 @@ describe('CalibrationDataDownload', () => { const downloadButton = getByRole('button', { name: 'Download calibration data', }) - expect(downloadButton).toBeDisabled() + expect(downloadButton).toBeEnabled() // allow download for empty cal data }) it('renders disabled button when tip lengths are not calibrated', () => { diff --git a/app/src/organisms/RobotSettingsCalibration/__tests__/ModuleCalibrationConfirmModal.test.tsx b/app/src/organisms/RobotSettingsCalibration/__tests__/ModuleCalibrationConfirmModal.test.tsx new file mode 100644 index 00000000000..2a192641254 --- /dev/null +++ b/app/src/organisms/RobotSettingsCalibration/__tests__/ModuleCalibrationConfirmModal.test.tsx @@ -0,0 +1,47 @@ +import * as React from 'react' + +import { renderWithProviders } from '@opentrons/components' + +import { i18n } from '../../../i18n' +import { ModuleCalibrationConfirmModal } from '../ModuleCalibrationConfirmModal' + +const render = ( + props: React.ComponentProps +) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('ModuleCalibrationConfirmModal', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + confirm: jest.fn(), + cancel: jest.fn(), + } + }) + + it('should render text and buttons', () => { + const [{ getByText, getByRole }] = render(props) + getByText('Are you sure you want to clear module calibration data?') + getByText( + 'This will immediately delete calibration data for this module on this robot.' + ) + getByRole('button', { name: 'Cancel' }) + getByRole('button', { name: 'Clear data' }) + }) + + it('should call a mock function when clicking cancel', () => { + const [{ getByRole }] = render(props) + getByRole('button', { name: 'Cancel' }).click() + expect(props.cancel).toHaveBeenCalled() + }) + + it('should call a mock function when clicking confirm', () => { + const [{ getByRole }] = render(props) + getByRole('button', { name: 'Clear data' }).click() + expect(props.confirm).toHaveBeenCalled() + }) +}) diff --git a/app/src/organisms/RobotSettingsCalibration/__tests__/RobotSettingsCalibration.test.tsx b/app/src/organisms/RobotSettingsCalibration/__tests__/RobotSettingsCalibration.test.tsx index 09d296a5958..6ea0f374478 100644 --- a/app/src/organisms/RobotSettingsCalibration/__tests__/RobotSettingsCalibration.test.tsx +++ b/app/src/organisms/RobotSettingsCalibration/__tests__/RobotSettingsCalibration.test.tsx @@ -32,6 +32,7 @@ import { RobotSettingsDeckCalibration } from '../RobotSettingsDeckCalibration' import { RobotSettingsGripperCalibration } from '../RobotSettingsGripperCalibration' import { RobotSettingsPipetteOffsetCalibration } from '../RobotSettingsPipetteOffsetCalibration' import { RobotSettingsTipLengthCalibration } from '../RobotSettingsTipLengthCalibration' +import { RobotSettingsModuleCalibration } from '../RobotSettingsModuleCalibration' import { RobotSettingsCalibration } from '..' import type { AttachedPipettesByMount } from '../../../redux/pipettes/types' @@ -48,6 +49,7 @@ jest.mock('../RobotSettingsDeckCalibration') jest.mock('../RobotSettingsGripperCalibration') jest.mock('../RobotSettingsPipetteOffsetCalibration') jest.mock('../RobotSettingsTipLengthCalibration') +jest.mock('../RobotSettingsModuleCalibration') const mockAttachedPipettes: AttachedPipettesByMount = { left: mockAttachedPipette, @@ -97,6 +99,9 @@ const mockUseAttachedPipettesFromInstrumentsQuery = useAttachedPipettesFromInstr typeof useAttachedPipettesFromInstrumentsQuery > const mockUseIsOT3 = useIsOT3 as jest.MockedFunction +const mockRobotSettingsModuleCalibration = RobotSettingsModuleCalibration as jest.MockedFunction< + typeof RobotSettingsModuleCalibration +> const RUN_STATUSES = { isRunRunning: false, @@ -167,6 +172,9 @@ describe('RobotSettingsCalibration', () => { mockRobotSettingsTipLengthCalibration.mockReturnValue(
Mock RobotSettingsTipLengthCalibration
) + mockRobotSettingsModuleCalibration.mockReturnValue( +
Mock RobotSettingsModuleCalibration
+ ) }) afterEach(() => { @@ -261,4 +269,11 @@ describe('RobotSettingsCalibration', () => { const [{ getByText }] = render() getByText('Mock RobotSettingsPipetteOffsetCalibration') }) + + it('render a Module Calibration component for an OT-3 and module calibration feature flag is on', () => { + mockUseFeatureFlag.mockReturnValue(true) + when(mockUseIsOT3).calledWith('otie').mockReturnValue(true) + const [{ getByText }] = render() + getByText('Mock RobotSettingsModuleCalibration') + }) }) diff --git a/app/src/organisms/RobotSettingsCalibration/__tests__/RobotSettingsModuleCalibration.test.tsx b/app/src/organisms/RobotSettingsCalibration/__tests__/RobotSettingsModuleCalibration.test.tsx new file mode 100644 index 00000000000..88ac44042cf --- /dev/null +++ b/app/src/organisms/RobotSettingsCalibration/__tests__/RobotSettingsModuleCalibration.test.tsx @@ -0,0 +1,55 @@ +import * as React from 'react' + +import { renderWithProviders } from '@opentrons/components' +import { i18n } from '../../../i18n' +import { ModuleCalibrationItems } from '../CalibrationDetails/ModuleCalibrationItems' +import { mockFetchModulesSuccessActionPayloadModules } from '../../../redux/modules/__fixtures__' +import { RobotSettingsModuleCalibration } from '../RobotSettingsModuleCalibration' + +jest.mock('../CalibrationDetails/ModuleCalibrationItems') + +const mockModuleCalibrationItems = ModuleCalibrationItems as jest.MockedFunction< + typeof ModuleCalibrationItems +> + +const render = ( + props: React.ComponentProps +) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('RobotSettingsModuleCalibration', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + attachedModules: mockFetchModulesSuccessActionPayloadModules, + updateRobotStatus: jest.fn(), + formattedPipetteOffsetCalibrations: [], + } + mockModuleCalibrationItems.mockReturnValue( +
mock ModuleCalibrationItems
+ ) + }) + + it('should render text and ModuleCalibrationItems when a module is attached', () => { + const [{ getByText }] = render(props) + getByText('Module Calibration') + getByText( + "Module calibration uses a pipette and attached probe to determine the module's exact position relative to the deck." + ) + getByText('mock ModuleCalibrationItems') + }) + + it('should render no modules attached when there is no module', () => { + props = { ...props, attachedModules: [] } + const [{ getByText }] = render(props) + getByText('Module Calibration') + getByText( + "Module calibration uses a pipette and attached probe to determine the module's exact position relative to the deck." + ) + getByText('No modules attached') + }) +}) diff --git a/app/src/organisms/RobotSettingsCalibration/index.tsx b/app/src/organisms/RobotSettingsCalibration/index.tsx index ba9f9829341..21ddec58891 100644 --- a/app/src/organisms/RobotSettingsCalibration/index.tsx +++ b/app/src/organisms/RobotSettingsCalibration/index.tsx @@ -7,6 +7,7 @@ import { useAllTipLengthCalibrationsQuery, useCalibrationStatusQuery, useInstrumentsQuery, + useModulesQuery, } from '@opentrons/react-api-client' import { Portal } from '../../App/portal' @@ -32,6 +33,7 @@ import { RobotSettingsDeckCalibration } from './RobotSettingsDeckCalibration' import { RobotSettingsGripperCalibration } from './RobotSettingsGripperCalibration' import { RobotSettingsPipetteOffsetCalibration } from './RobotSettingsPipetteOffsetCalibration' import { RobotSettingsTipLengthCalibration } from './RobotSettingsTipLengthCalibration' +import { RobotSettingsModuleCalibration } from './RobotSettingsModuleCalibration' import type { GripperData } from '@opentrons/api-client' import type { Mount } from '@opentrons/components' @@ -119,6 +121,12 @@ export function RobotSettingsCalibration({ } ) + // Modules Calibration + const attachedModules = + useModulesQuery({ + refetchInterval: CALS_FETCH_MS, + })?.data?.data ?? [] + // Note: following fetch need to reflect the latest state of calibrations // when a user does calibration or rename a robot. useCalibrationStatusQuery({ refetchInterval: CALS_FETCH_MS }) @@ -317,6 +325,14 @@ export function RobotSettingsCalibration({ /> + + ) : ( <> diff --git a/app/src/organisms/RobotSettingsDashboard/DeviceReset.tsx b/app/src/organisms/RobotSettingsDashboard/DeviceReset.tsx index 90d3bbf729c..e655f0d0a7c 100644 --- a/app/src/organisms/RobotSettingsDashboard/DeviceReset.tsx +++ b/app/src/organisms/RobotSettingsDashboard/DeviceReset.tsx @@ -10,10 +10,13 @@ import { COLORS, SPACING, BORDERS, + useConditionalConfirm, + DIRECTION_ROW, } from '@opentrons/components' import { StyledText } from '../../atoms/text' -import { MediumButton } from '../../atoms/buttons' +import { MediumButton, SmallButton } from '../../atoms/buttons' +import { Modal } from '../../molecules/Modal' import { ChildNavigation } from '../../organisms/ChildNavigation' import { getResetConfigOptions, @@ -25,6 +28,7 @@ import { useDispatchApiRequest } from '../../redux/robot-api' import type { Dispatch, State } from '../../redux/types' import type { ResetConfigRequest } from '../../redux/robot-admin/types' import type { SetSettingOption } from '../../pages/OnDeviceDisplay/RobotSettingsDashboard' +import type { ModalHeaderBaseProps } from '../../molecules/Modal/types' interface LabelProps { isSelected?: boolean @@ -48,11 +52,13 @@ interface DeviceResetProps { setCurrentOption: SetSettingOption } +// ToDo (kk:08/30/2023) lines that are related to module calibration will be activated when the be is ready. +// The tests for that will be added. export function DeviceReset({ robotName, setCurrentOption, }: DeviceResetProps): JSX.Element { - const { t } = useTranslation(['device_settings']) + const { t } = useTranslation('device_settings') const [resetOptions, setResetOptions] = React.useState({}) const options = useSelector((state: State) => getResetConfigOptions(state, robotName) @@ -62,12 +68,13 @@ export function DeviceReset({ const targetOptionsOrder = [ 'pipetteOffsetCalibrations', 'gripperOffsetCalibrations', + // 'moduleCalibrations', 'runsHistory', - 'bootScripts', ] const availableOptions = options // filtering out ODD setting because this gets implicitly cleared if all settings are selected - .filter(o => o.id !== 'onDeviceDisplay') + // filtering out boot scripts since product doesn't want this exposed to ODD users + .filter(({ id }) => !['onDeviceDisplay', 'bootScripts'].includes(id)) .sort( (a, b) => targetOptionsOrder.indexOf(a.id) - targetOptionsOrder.indexOf(b.id) @@ -76,27 +83,27 @@ export function DeviceReset({ const handleClick = (): void => { if (resetOptions != null) { - const totalOptionsSelected = Object.values(resetOptions).filter( - selected => selected === true - ).length - - const isEveryOptionSelected = - totalOptionsSelected > 0 && - totalOptionsSelected === availableOptions.length - - if (isEveryOptionSelected) { + // remove clearAllStoredData since its not a setting on the backend + const { clearAllStoredData, ...serverResetOptions } = resetOptions + if (Boolean(clearAllStoredData)) { dispatchRequest( resetConfig(robotName, { - ...resetOptions, + ...serverResetOptions, onDeviceDisplay: true, }) ) } else { - dispatchRequest(resetConfig(robotName, resetOptions)) + dispatchRequest(resetConfig(robotName, serverResetOptions)) } } } + const { + confirm: confirmClearData, + showConfirmation: showConfirmationModal, + cancel: cancelClearData, + } = useConditionalConfirm(handleClick, true) + const renderText = ( optionId: string ): { optionText: string; subText?: string } => { @@ -109,16 +116,14 @@ export function DeviceReset({ case 'gripperOffsetCalibrations': optionText = t('clear_option_gripper_calibration') break + // case 'moduleCalibrations': + // optionText = t('clear_option_module_calibrations') + // break case 'runsHistory': optionText = t('clear_option_runs_history') subText = t('clear_option_runs_history_subtext') break - case 'bootScripts': - optionText = t('clear_option_boot_scripts') - subText = t('clear_option_boot_scripts_description') - break - case 'factoryReset': optionText = t('factory_reset') subText = t('factory_reset_description') @@ -137,6 +142,12 @@ export function DeviceReset({ return ( + {showConfirmationModal && ( + + )} ) })} + + { + setResetOptions( + Boolean(resetOptions.clearAllStoredData) + ? {} + : availableOptions.reduce( + (acc, val) => { + return { + ...acc, + [val.id]: true, + } + }, + { clearAllStoredData: true } + ) + ) + }} + /> + + + + {t('clear_all_stored_data')} + + + {t('clear_all_stored_data_description')} + + +
) } + +interface ConfirmClearDataModalProps { + cancelClearData: () => void + confirmClearData: () => void +} + +export const ConfirmClearDataModal = ({ + cancelClearData, + confirmClearData, +}: ConfirmClearDataModalProps): JSX.Element => { + const { t } = useTranslation(['device_settings', 'shared']) + const modalHeader: ModalHeaderBaseProps = { + title: t('confirm_device_reset_heading'), + hasExitIcon: false, + iconName: 'ot-alert', + iconColor: COLORS.yellow2, + } + return ( + + + + + {t('confirm_device_reset_description')} + + + + + + + + + ) +} diff --git a/app/src/organisms/RobotSettingsDashboard/Privacy.tsx b/app/src/organisms/RobotSettingsDashboard/Privacy.tsx new file mode 100644 index 00000000000..a232cd77b00 --- /dev/null +++ b/app/src/organisms/RobotSettingsDashboard/Privacy.tsx @@ -0,0 +1,94 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' + +import { + Flex, + SPACING, + DIRECTION_COLUMN, + TYPOGRAPHY, +} from '@opentrons/components' + +import { StyledText } from '../../atoms/text' +import { ChildNavigation } from '../../organisms/ChildNavigation' +import { ROBOT_ANALYTICS_SETTING_ID } from '../../pages/OnDeviceDisplay/RobotDashboard/AnalyticsOptInModal' +import { RobotSettingButton } from '../../pages/OnDeviceDisplay/RobotSettingsDashboard/RobotSettingButton' +import { OnOffToggle } from '../../pages/OnDeviceDisplay/RobotSettingsDashboard/RobotSettingsList' +import { + getAnalyticsOptedIn, + toggleAnalyticsOptedIn, +} from '../../redux/analytics' +import { getRobotSettings, updateSetting } from '../../redux/robot-settings' + +import type { Dispatch, State } from '../../redux/types' +import type { SetSettingOption } from '../../pages/OnDeviceDisplay/RobotSettingsDashboard' + +interface PrivacyProps { + robotName: string + setCurrentOption: SetSettingOption +} + +export function Privacy({ + robotName, + setCurrentOption, +}: PrivacyProps): JSX.Element { + const { t } = useTranslation('app_settings') + const dispatch = useDispatch() + + const allRobotSettings = useSelector((state: State) => + getRobotSettings(state, robotName) + ) + + const appAnalyticsOptedIn = useSelector(getAnalyticsOptedIn) + + const isRobotAnalyticsDisabled = + allRobotSettings.find(({ id }) => id === ROBOT_ANALYTICS_SETTING_ID) + ?.value ?? false + + return ( + + setCurrentOption(null)} + /> + + + {t('opentrons_cares_about_privacy')} + + + } + onClick={() => + dispatch( + updateSetting( + robotName, + ROBOT_ANALYTICS_SETTING_ID, + !isRobotAnalyticsDisabled + ) + ) + } + /> + } + onClick={() => dispatch(toggleAnalyticsOptedIn())} + /> + + + + ) +} diff --git a/app/src/organisms/RobotSettingsDashboard/RobotSystemVersion.tsx b/app/src/organisms/RobotSettingsDashboard/RobotSystemVersion.tsx index 088b41b42eb..98982d320ba 100644 --- a/app/src/organisms/RobotSettingsDashboard/RobotSystemVersion.tsx +++ b/app/src/organisms/RobotSettingsDashboard/RobotSystemVersion.tsx @@ -21,7 +21,7 @@ import { getShellUpdateState } from '../../redux/shell' import type { SetSettingOption } from '../../pages/OnDeviceDisplay/RobotSettingsDashboard' -const GITHUB_URL = 'https://github.com/Opentrons/opentrons' +const GITHUB_URL = 'https://github.com/Opentrons/opentrons/releases' interface RobotSystemVersionProps { currentVersion: string @@ -77,9 +77,9 @@ export function RobotSystemVersion({ marginTop="7.75rem" > - {`${t( - 'view_latest_release_notes_at' - )} ${GITHUB_URL}`} + + {t('view_latest_release_notes_at', { url: GITHUB_URL })} + { it('should render text and button', () => { const [{ getByText, getByTestId }] = render(props) - getByText('Clear pipette calibration(s)') + getByText('Clear pipette calibration') getByText('Clear gripper calibration') getByText('Clear protocol run history') getByText('Clears information about past runs of all protocols.') - getByText('Clear custom boot scripts') - getByText( - "Clears scripts that modify the robot's behavior when powered on." - ) expect(getByTestId('DeviceReset_clear_data_button')).toBeDisabled() }) it('when tapping a option button, the clear button is enabled', () => { const [{ getByText, getByTestId }] = render(props) - fireEvent.click(getByText('Clear pipette calibration(s)')) + fireEvent.click(getByText('Clear pipette calibration')) expect(getByTestId('DeviceReset_clear_data_button')).not.toBeDisabled() }) @@ -95,10 +91,12 @@ describe('DeviceReset', () => { runsHistory: true, } const [{ getByText }] = render(props) - fireEvent.click(getByText('Clear pipette calibration(s)')) + fireEvent.click(getByText('Clear pipette calibration')) fireEvent.click(getByText('Clear protocol run history')) const clearButton = getByText('Clear data and restart robot') fireEvent.click(clearButton) + getByText('Are you sure you want to reset your device?') + fireEvent.click(getByText('Confirm')) expect(dispatchApiRequest).toBeCalledWith( mockResetConfig('mockRobot', clearMockResetOptions) ) diff --git a/app/src/organisms/RobotSettingsDashboard/__tests__/Privacy.test.tsx b/app/src/organisms/RobotSettingsDashboard/__tests__/Privacy.test.tsx new file mode 100644 index 00000000000..ec0120e8c25 --- /dev/null +++ b/app/src/organisms/RobotSettingsDashboard/__tests__/Privacy.test.tsx @@ -0,0 +1,73 @@ +import * as React from 'react' + +import { renderWithProviders } from '@opentrons/components' + +import { i18n } from '../../../i18n' +import { toggleAnalyticsOptedIn } from '../../../redux/analytics' +import { getRobotSettings, updateSetting } from '../../../redux/robot-settings' + +import { Privacy } from '../Privacy' + +jest.mock('../../../redux/analytics') +jest.mock('../../../redux/robot-settings') + +const mockGetRobotSettings = getRobotSettings as jest.MockedFunction< + typeof getRobotSettings +> +const mockUpdateSetting = updateSetting as jest.MockedFunction< + typeof updateSetting +> +const mockToggleAnalyticsOptedIn = toggleAnalyticsOptedIn as jest.MockedFunction< + typeof toggleAnalyticsOptedIn +> + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('Privacy', () => { + let props: React.ComponentProps + beforeEach(() => { + props = { + robotName: 'Otie', + setCurrentOption: jest.fn(), + } + mockGetRobotSettings.mockReturnValue([]) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should render text and buttons', () => { + const [{ getByText }] = render(props) + getByText('Privacy') + getByText( + 'Opentrons cares about your privacy. We anonymize all data and only use it to improve our products.' + ) + getByText('Share robot logs') + getByText('Data on actions the robot does, like running protocols.') + getByText('Share display usage') + getByText('Data on how you interact with the touchscreen on Flex.') + }) + + it('should toggle display usage sharing on click', () => { + const [{ getByText }] = render(props) + + getByText('Share display usage').click() + expect(mockToggleAnalyticsOptedIn).toBeCalled() + }) + + it('should toggle robot logs sharing on click', () => { + const [{ getByText }] = render(props) + + getByText('Share robot logs').click() + expect(mockUpdateSetting).toBeCalledWith( + 'Otie', + 'disableLogAggregation', + true + ) + }) +}) diff --git a/app/src/organisms/RobotSettingsDashboard/__tests__/RobotSystemVersion.test.tsx b/app/src/organisms/RobotSettingsDashboard/__tests__/RobotSystemVersion.test.tsx index 486a684e8a0..fe5c6c9eb7e 100644 --- a/app/src/organisms/RobotSettingsDashboard/__tests__/RobotSystemVersion.test.tsx +++ b/app/src/organisms/RobotSettingsDashboard/__tests__/RobotSystemVersion.test.tsx @@ -48,7 +48,7 @@ describe('RobotSystemVersion', () => { const [{ getByText }] = render(props) getByText('Robot System Version') getByText( - 'View latest release notes at https://github.com/Opentrons/opentrons' + 'View latest release notes at https://github.com/Opentrons/opentrons/releases' ) getByText('Current Version') getByText('mock7.0.0') diff --git a/app/src/organisms/RobotSettingsDashboard/__tests__/RobotSystemVersionModal.test.tsx b/app/src/organisms/RobotSettingsDashboard/__tests__/RobotSystemVersionModal.test.tsx index 68f73a0119e..21a7c359ef4 100644 --- a/app/src/organisms/RobotSettingsDashboard/__tests__/RobotSystemVersionModal.test.tsx +++ b/app/src/organisms/RobotSettingsDashboard/__tests__/RobotSystemVersionModal.test.tsx @@ -40,7 +40,7 @@ describe('RobotSystemVersionModal', () => { it('should render text and buttons', () => { const [{ getByText }] = render(props) getByText('Robot System Version mockVersion available') - getByText('Updating the robot system requires a restart') + getByText('Updating the robot software requires restarting the robot') getByText('mockReleaseNote') getByText('Not now') getByText('Update') diff --git a/app/src/organisms/RobotSettingsDashboard/index.ts b/app/src/organisms/RobotSettingsDashboard/index.ts index 8dc444ff75c..ba05950ff24 100644 --- a/app/src/organisms/RobotSettingsDashboard/index.ts +++ b/app/src/organisms/RobotSettingsDashboard/index.ts @@ -5,6 +5,7 @@ export * from './NetworkSettings/RobotSettingsSetWifiCred' export * from './NetworkSettings/RobotSettingsWifi' export * from './NetworkSettings/RobotSettingsWifiConnect' export * from './NetworkSettings' +export * from './Privacy' export * from './RobotName' export * from './RobotSystemVersion' export * from './TextSize' diff --git a/app/src/organisms/RunProgressMeter/__tests__/RunProgressMeter.test.tsx b/app/src/organisms/RunProgressMeter/__tests__/RunProgressMeter.test.tsx index 91d280a5d43..d0f36f5405c 100644 --- a/app/src/organisms/RunProgressMeter/__tests__/RunProgressMeter.test.tsx +++ b/app/src/organisms/RunProgressMeter/__tests__/RunProgressMeter.test.tsx @@ -106,11 +106,13 @@ describe('RunProgressMeter', () => { }) it('should show only the total count of commands in run and not show the meter when protocol is non-deterministic', () => { + mockUseCommandQuery.mockReturnValue({ data: null } as any) const { getByText, queryByText } = render(props) expect(getByText('Current Step 42/?')).toBeTruthy() expect(queryByText('MOCK PROGRESS BAR')).toBeFalsy() }) it('should give the correct info when run status is idle', () => { + mockUseCommandQuery.mockReturnValue({ data: null } as any) mockUseRunStatus.mockReturnValue(RUN_STATUS_IDLE) const { getByText } = render(props) getByText('Current Step:') diff --git a/app/src/organisms/RunProgressMeter/index.tsx b/app/src/organisms/RunProgressMeter/index.tsx index 7c901a8b141..84b7cb33370 100644 --- a/app/src/organisms/RunProgressMeter/index.tsx +++ b/app/src/organisms/RunProgressMeter/index.tsx @@ -100,7 +100,7 @@ export function RunProgressMeter(props: RunProgressMeterProps): JSX.Element { analysisCommands.findIndex(c => c.key === lastRunCommand?.key) ?? 0 const { data: runCommandDetails } = useCommandQuery( runId, - lastRunCommand?.key ?? null + lastRunCommand?.id ?? null ) let countOfTotalText = '' if ( diff --git a/app/src/organisms/SendProtocolToOT3Slideout/__tests__/SendProtocolToOT3Slideout.test.tsx b/app/src/organisms/SendProtocolToOT3Slideout/__tests__/SendProtocolToOT3Slideout.test.tsx index 1e9ec1ae947..9eaf3cc8024 100644 --- a/app/src/organisms/SendProtocolToOT3Slideout/__tests__/SendProtocolToOT3Slideout.test.tsx +++ b/app/src/organisms/SendProtocolToOT3Slideout/__tests__/SendProtocolToOT3Slideout.test.tsx @@ -165,8 +165,8 @@ describe('SendProtocolToOT3Slideout', () => { onCloseClick: jest.fn(), isExpanded: true, }) - getByText('Choose Robot to Run fakeSrcFileName') - getByRole('button', { name: 'Proceed to setup' }) + getByText('Send protocol to Opentrons Flex') + getByRole('button', { name: 'Send' }) }) it('renders an available robot option for every connectable OT-3, and link for other robots', () => { @@ -246,7 +246,7 @@ describe('SendProtocolToOT3Slideout', () => { onCloseClick: jest.fn(), isExpanded: true, }) - const proceedButton = getByRole('button', { name: 'Proceed to setup' }) + const proceedButton = getByRole('button', { name: 'Send' }) expect(proceedButton).not.toBeDisabled() const otherRobot = getByText('otherRobot') otherRobot.click() // unselect default robot @@ -273,7 +273,7 @@ describe('SendProtocolToOT3Slideout', () => { onCloseClick: jest.fn(), isExpanded: true, }) - const proceedButton = getByRole('button', { name: 'Proceed to setup' }) + const proceedButton = getByRole('button', { name: 'Send' }) expect(proceedButton).toBeDisabled() expect( getByText( diff --git a/app/src/organisms/SendProtocolToOT3Slideout/index.tsx b/app/src/organisms/SendProtocolToOT3Slideout/index.tsx index a3fda2f0040..dbea4776eae 100644 --- a/app/src/organisms/SendProtocolToOT3Slideout/index.tsx +++ b/app/src/organisms/SendProtocolToOT3Slideout/index.tsx @@ -5,6 +5,8 @@ import { useSelector } from 'react-redux' import { useCreateProtocolMutation } from '@opentrons/react-api-client' +import { FLEX_DISPLAY_NAME } from '@opentrons/shared-data' + import { PrimaryButton, IconProps, StyleProps } from '@opentrons/components' import { ERROR_TOAST, INFO_TOAST, SUCCESS_TOAST } from '../../atoms/Toast' import { ChooseRobotSlideout } from '../../organisms/ChooseRobotSlideout' @@ -38,7 +40,7 @@ export function SendProtocolToOT3Slideout( srcFiles, mostRecentAnalysis, } = storedProtocolData - const { t } = useTranslation(['protocol_details', 'shared']) + const { t } = useTranslation(['protocol_details', 'protocol_list']) const [selectedRobot, setSelectedRobot] = React.useState(null) @@ -141,8 +143,8 @@ export function SendProtocolToOT3Slideout( - {t('shared:proceed_to_setup')} + {t('protocol_details:send')} } selectedRobot={selectedRobot} diff --git a/app/src/organisms/TakeoverModal/MaintenanceRunStatusProvider.tsx b/app/src/organisms/TakeoverModal/MaintenanceRunStatusProvider.tsx new file mode 100644 index 00000000000..06df9e56058 --- /dev/null +++ b/app/src/organisms/TakeoverModal/MaintenanceRunStatusProvider.tsx @@ -0,0 +1,55 @@ +import * as React from 'react' +import { useCurrentMaintenanceRun } from '@opentrons/react-api-client' + +interface MaintenanceRunIds { + currentRunId: string | null + oddRunId: string | null +} + +export interface MaintenanceRunStatus { + getRunIds: () => MaintenanceRunIds + setOddRunIds: (state: MaintenanceRunIds) => void +} + +export const MaintenanceRunContext = React.createContext({ + getRunIds: () => ({ currentRunId: null, oddRunId: null }), + setOddRunIds: () => {}, +}) + +interface MaintenanceRunProviderProps { + children?: React.ReactNode +} + +export function MaintenanceRunStatusProvider( + props: MaintenanceRunProviderProps +): JSX.Element { + const [oddRunIds, setOddRunIds] = React.useState({ + currentRunId: null, + oddRunId: null, + }) + + const currentRunIdQueryResult = useCurrentMaintenanceRun({ + refetchInterval: 5000, + }).data?.data.id + + React.useEffect(() => { + setOddRunIds(prevState => ({ + ...prevState, + currentRunId: currentRunIdQueryResult ?? null, + })) + }, [currentRunIdQueryResult]) + + const maintenanceRunStatus = React.useMemo( + () => ({ + getRunIds: () => oddRunIds, + setOddRunIds, + }), + [oddRunIds] + ) + + return ( + + {props.children} + + ) +} diff --git a/app/src/organisms/TakeoverModal/MaintenanceRunTakeover.tsx b/app/src/organisms/TakeoverModal/MaintenanceRunTakeover.tsx index 9a83c2be7d3..54b6e3d5d2b 100644 --- a/app/src/organisms/TakeoverModal/MaintenanceRunTakeover.tsx +++ b/app/src/organisms/TakeoverModal/MaintenanceRunTakeover.tsx @@ -1,61 +1,62 @@ import * as React from 'react' -import { - useCurrentMaintenanceRun, - useDeleteMaintenanceRunMutation, -} from '@opentrons/react-api-client' +import { useDeleteMaintenanceRunMutation } from '@opentrons/react-api-client' import { TakeoverModal } from './TakeoverModal' -import { TakeoverModalContext } from './TakeoverModalContext' +import { MaintenanceRunStatusProvider } from './MaintenanceRunStatusProvider' +import { useMaintenanceRunTakeover } from './useMaintenanceRunTakeover' interface MaintenanceRunTakeoverProps { children: React.ReactNode } -export function MaintenanceRunTakeover( - props: MaintenanceRunTakeoverProps -): JSX.Element { - const [ - isODDMaintenanceInProgress, - setIsODDMaintenanceInProgress, - ] = React.useState(false) - const maintenanceRunId = useCurrentMaintenanceRun({ - refetchInterval: 5000, - }).data?.data.id - const isMaintenanceRunCurrent = maintenanceRunId != null +export function MaintenanceRunTakeover({ + children, +}: MaintenanceRunTakeoverProps): JSX.Element { + return ( + + {children} + + ) +} + +interface MaintenanceRunTakeoverModalProps { + children: React.ReactNode +} + +export function MaintenanceRunTakeoverModal( + props: MaintenanceRunTakeoverModalProps +): JSX.Element { + const [isLoading, setIsLoading] = React.useState(false) const [ showConfirmTerminateModal, setShowConfirmTerminateModal, ] = React.useState(false) - const { - deleteMaintenanceRun, - status, - reset, - } = useDeleteMaintenanceRunMutation() - const [isLoading, setIsLoading] = React.useState(false) + const { oddRunId, currentRunId } = useMaintenanceRunTakeover().getRunIds() + const isMaintenanceRunCurrent = currentRunId != null + + const desktopMaintenanceRunInProgress = + isMaintenanceRunCurrent && oddRunId !== currentRunId + + const { deleteMaintenanceRun, reset } = useDeleteMaintenanceRunMutation() const handleCloseAndTerminate = (): void => { - if (maintenanceRunId != null) { + if (currentRunId != null) { setIsLoading(true) - deleteMaintenanceRun(maintenanceRunId) + deleteMaintenanceRun(currentRunId) } } React.useEffect(() => { - if (maintenanceRunId == null && status === 'success') { + if (currentRunId == null) { setIsLoading(false) setShowConfirmTerminateModal(false) reset() } - }, [maintenanceRunId, status]) + }, [currentRunId]) return ( - - setIsODDMaintenanceInProgress(true), - }} - > - {!isODDMaintenanceInProgress && isMaintenanceRunCurrent && ( + <> + {desktopMaintenanceRunInProgress && ( )} {props.children} - + ) } diff --git a/app/src/organisms/TakeoverModal/TakeoverModalContext.ts b/app/src/organisms/TakeoverModal/TakeoverModalContext.ts deleted file mode 100644 index 01a44d77e3e..00000000000 --- a/app/src/organisms/TakeoverModal/TakeoverModalContext.ts +++ /dev/null @@ -1,11 +0,0 @@ -import * as React from 'react' - -export interface TakeoverModalContextType { - setODDMaintenanceFlowInProgress: () => void -} - -export const TakeoverModalContext = React.createContext( - { - setODDMaintenanceFlowInProgress: () => {}, - } -) diff --git a/app/src/organisms/TakeoverModal/__tests__/MaintenanceRunTakeover.test.tsx b/app/src/organisms/TakeoverModal/__tests__/MaintenanceRunTakeover.test.tsx new file mode 100644 index 00000000000..a084925d254 --- /dev/null +++ b/app/src/organisms/TakeoverModal/__tests__/MaintenanceRunTakeover.test.tsx @@ -0,0 +1,84 @@ +import * as React from 'react' +import { i18n } from '../../../i18n' +import { when } from 'jest-when' +import { renderWithProviders } from '@opentrons/components' +import { useMaintenanceRunTakeover as mockUseMaintenanceRunTakeover } from '../useMaintenanceRunTakeover' +import { MaintenanceRunTakeover } from '../MaintenanceRunTakeover' +import type { MaintenanceRunStatus } from '../MaintenanceRunStatusProvider' + +jest.mock('../useMaintenanceRunTakeover') + +const MOCK_MAINTENANCE_RUN: MaintenanceRunStatus = { + getRunIds: () => ({ + currentRunId: null, + oddRunId: null, + }), + setOddRunIds: () => null, +} + +const useMaintenanceRunTakeover = mockUseMaintenanceRunTakeover as jest.MockedFunction< + typeof mockUseMaintenanceRunTakeover +> + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('MaintenanceRunTakeover', () => { + let props: React.ComponentProps + const testComponent =
{'Test Component'}
+ + beforeEach(() => { + props = { children: [testComponent] } + when(useMaintenanceRunTakeover) + .calledWith() + .mockReturnValue(MOCK_MAINTENANCE_RUN) + }) + + it('renders child components successfuly', () => { + const [{ getByText }] = render(props) + getByText('Test Component') + }) + + it('does not render a takeover modal if no maintenance run has been initiated', () => { + const [{ queryByText }] = render(props) + + expect(queryByText('Robot is busy')).not.toBeInTheDocument() + }) + + it('does not render a takeover modal if a maintenance run has been initiated by the ODD', () => { + const MOCK_ODD_RUN = { + ...MOCK_MAINTENANCE_RUN, + getRunIds: () => ({ + currentRunId: 'testODD', + oddRunId: 'testODD', + }), + } + + when(useMaintenanceRunTakeover).calledWith().mockReturnValue(MOCK_ODD_RUN) + + const [{ queryByText }] = render(props) + + expect(queryByText('Robot is busy')).not.toBeInTheDocument() + }) + + it('renders a takeover modal if a maintenance run has been initiated by the desktop', () => { + const MOCK_DESKTOP_RUN = { + ...MOCK_MAINTENANCE_RUN, + getRunIds: () => ({ + currentRunId: 'testRunDesktop', + oddRunId: null, + }), + } + + when(useMaintenanceRunTakeover) + .calledWith() + .mockReturnValue(MOCK_DESKTOP_RUN) + + const [{ queryByText }] = render(props) + + expect(queryByText('Robot is busy')).toBeInTheDocument() + }) +}) diff --git a/app/src/organisms/TakeoverModal/index.ts b/app/src/organisms/TakeoverModal/index.ts index 03fe9415102..fd3ab6f8070 100644 --- a/app/src/organisms/TakeoverModal/index.ts +++ b/app/src/organisms/TakeoverModal/index.ts @@ -1,3 +1,2 @@ -export * from './TakeoverModalContext' export * from './MaintenanceRunTakeover' export * from './useMaintenanceRunTakeover' diff --git a/app/src/organisms/TakeoverModal/useMaintenanceRunTakeover.ts b/app/src/organisms/TakeoverModal/useMaintenanceRunTakeover.ts index d44f6cea436..3c6373dee07 100644 --- a/app/src/organisms/TakeoverModal/useMaintenanceRunTakeover.ts +++ b/app/src/organisms/TakeoverModal/useMaintenanceRunTakeover.ts @@ -1,15 +1,7 @@ import * as React from 'react' -import { - TakeoverModalContextType, - TakeoverModalContext, -} from './TakeoverModalContext' +import { MaintenanceRunContext } from './MaintenanceRunStatusProvider' +import type { MaintenanceRunStatus } from './MaintenanceRunStatusProvider' -export function useMaintenanceRunTakeover(): TakeoverModalContextType { - const { setODDMaintenanceFlowInProgress } = React.useContext( - TakeoverModalContext - ) - - return { - setODDMaintenanceFlowInProgress, - } +export function useMaintenanceRunTakeover(): MaintenanceRunStatus { + return React.useContext(MaintenanceRunContext) } diff --git a/app/src/organisms/UpdateAppModal/__tests__/UpdateAppModal.test.tsx b/app/src/organisms/UpdateAppModal/__tests__/UpdateAppModal.test.tsx index f7675c7b4ca..2bcc5ffa162 100644 --- a/app/src/organisms/UpdateAppModal/__tests__/UpdateAppModal.test.tsx +++ b/app/src/organisms/UpdateAppModal/__tests__/UpdateAppModal.test.tsx @@ -1,48 +1,49 @@ import * as React from 'react' -import { Link as InternalLink } from 'react-router-dom' -import { mountWithStore, BaseModal, Flex, Icon } from '@opentrons/components' +import { i18n } from '../../../i18n' +import { fireEvent } from '@testing-library/react' import * as Shell from '../../../redux/shell' -import { ErrorModal } from '../../../molecules/modals' -import { ReleaseNotes } from '../../../molecules/ReleaseNotes' -import { UpdateAppModal } from '..' +import { renderWithProviders } from '@opentrons/components' +import { UpdateAppModal, UpdateAppModalProps } from '..' -import type { State, Action } from '../../../redux/types' +import type { State } from '../../../redux/types' import type { ShellUpdateState } from '../../../redux/shell/types' -import type { UpdateAppModalProps } from '..' -import type { HTMLAttributes, ReactWrapper } from 'enzyme' -// TODO(mc, 2020-10-06): this is a partial mock because shell/update -// needs some reorg to split actions and selectors jest.mock('../../../redux/shell/update', () => ({ ...jest.requireActual<{}>('../../../redux/shell/update'), getShellUpdateState: jest.fn(), })) -jest.mock('react-router-dom', () => ({ Link: () => <> })) +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: jest.fn(), + }), +})) const getShellUpdateState = Shell.getShellUpdateState as jest.MockedFunction< typeof Shell.getShellUpdateState > -const MOCK_STATE: State = { mockState: true } as any +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} describe('UpdateAppModal', () => { - const closeModal = jest.fn() - const dismissAlert = jest.fn() - - const render = (props: UpdateAppModalProps) => { - return mountWithStore( - , - { - initialState: MOCK_STATE, - } - ) - } + let props: React.ComponentProps beforeEach(() => { + props = { + closeModal: jest.fn(), + } as UpdateAppModalProps getShellUpdateState.mockImplementation((state: State) => { - expect(state).toBe(MOCK_STATE) return { + downloading: false, + available: true, + downloaded: false, + downloadPercentage: 0, + error: null, info: { version: '1.2.3', releaseNotes: 'this is a release', @@ -55,275 +56,52 @@ describe('UpdateAppModal', () => { jest.resetAllMocks() }) - it('should render an BaseModal using available version from state', () => { - const { wrapper } = render({ closeModal }) - const modal = wrapper.find(BaseModal) - const title = modal.find('h2') - const titleIcon = title.closest(Flex).find(Icon) - - expect(title.text()).toBe('App Version 1.2.3 Available') - expect(titleIcon.prop('name')).toBe('alert') - }) - - it('should render a component with the release notes', () => { - const { wrapper } = render({ closeModal }) - const releaseNotes = wrapper.find(ReleaseNotes) - - expect(releaseNotes.prop('source')).toBe('this is a release') + it('renders update available title and release notes when update is available', () => { + const [{ getByText }] = render(props) + expect(getByText('Opentrons App Update Available')).toBeInTheDocument() + expect(getByText('this is a release')).toBeInTheDocument() }) - - it('should render a "Not Now" button that closes the modal', () => { - const { wrapper } = render({ closeModal }) - const notNowButton = wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /not now/i.test(b.text()) - ) - - expect(closeModal).not.toHaveBeenCalled() - notNowButton.invoke('onClick')?.({} as React.MouseEvent) + it('closes modal when "remind me later" button is clicked', () => { + const closeModal = jest.fn() + const [{ getByText }] = render({ ...props, closeModal }) + fireEvent.click(getByText('Remind me later')) expect(closeModal).toHaveBeenCalled() }) - - it('should render a "Download" button that starts the update', () => { - const { wrapper, store } = render({ closeModal }) - const downloadButton = wrapper - .find('button') - .filterWhere((b: ReactWrapper) => /download/i.test(b.text())) - - downloadButton.invoke('onClick')?.({} as React.MouseEvent) - - expect(store.dispatch).toHaveBeenCalledWith(Shell.downloadShellUpdate()) - }) - - it('should render a spinner if update is downloading', () => { - getShellUpdateState.mockReturnValue({ - downloading: true, - } as ShellUpdateState) - const { wrapper } = render({ closeModal }) - const spinner = wrapper - .find(Icon) - .filterWhere( - (i: ReactWrapper>) => - i.prop('name') === 'ot-spinner' - ) - const spinnerParent = spinner.closest(Flex) - - expect(spinnerParent.text()).toMatch(/download in progress/i) - }) - - it('should render a instructional copy instead of release notes if update is downloaded', () => { - getShellUpdateState.mockReturnValue({ - downloaded: true, - info: { - version: '1.2.3', - releaseNotes: 'this is a release', - }, - } as ShellUpdateState) - - const { wrapper } = render({ closeModal }) - const title = wrapper.find('h2') - - expect(title.text()).toBe('App Version 1.2.3 Downloaded') - expect(wrapper.exists(ReleaseNotes)).toBe(false) - expect(wrapper.text()).toMatch(/Restart your app to complete the update/i) - }) - - it('should render a "Restart App" button if update is downloaded', () => { - getShellUpdateState.mockReturnValue({ - downloaded: true, - } as ShellUpdateState) - const { wrapper, store } = render({ closeModal }) - const restartButton = wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /restart/i.test(b.text()) - ) - - restartButton.invoke('onClick')?.({} as React.MouseEvent) - expect(store.dispatch).toHaveBeenCalledWith(Shell.applyShellUpdate()) - }) - - it('should render a "Not Now" button if update is downloaded', () => { - getShellUpdateState.mockReturnValue({ - downloaded: true, - } as ShellUpdateState) - const { wrapper } = render({ closeModal }) - const notNowButton = wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /not now/i.test(b.text()) - ) - - notNowButton.invoke('onClick')?.({} as React.MouseEvent) - expect(closeModal).toHaveBeenCalled() - }) - - it('should render an ErrorModal if the update errors', () => { + it('shows error modal on error', () => { getShellUpdateState.mockReturnValue({ error: { message: 'Could not get code signature for running application', name: 'Error', }, } as ShellUpdateState) - - const { wrapper } = render({ closeModal }) - const errorModal = wrapper.find(ErrorModal) - - expect(errorModal.prop('heading')).toBe('Update Error') - expect(errorModal.prop('description')).toBe( - 'Something went wrong while updating your app' - ) - expect(errorModal.prop('error')).toEqual({ - message: 'Could not get code signature for running application', - name: 'Error', - }) - - errorModal.invoke('close')?.() - - expect(closeModal).toHaveBeenCalled() - }) - - it('should call props.dismissAlert via the "Not Now" button', () => { - const { wrapper } = render({ dismissAlert }) - const notNowButton = wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /not now/i.test(b.text()) - ) - - expect(dismissAlert).not.toHaveBeenCalled() - notNowButton.invoke('onClick')?.({} as React.MouseEvent) - expect(dismissAlert).toHaveBeenCalledWith(false) + const [{ getByText }] = render(props) + expect(getByText('Update Error')).toBeInTheDocument() }) - - it('should call props.dismissAlert via the Error modal "close" button', () => { + it('shows a download progress bar when downloading', () => { getShellUpdateState.mockReturnValue({ - error: { - message: 'Could not get code signature for running application', - name: 'Error', - }, + downloading: true, + downloadPercentage: 50, } as ShellUpdateState) - - const { wrapper } = render({ dismissAlert }) - const errorModal = wrapper.find(ErrorModal) - - errorModal.invoke('close')?.() - - expect(dismissAlert).toHaveBeenCalledWith(false) + const [{ getByText, getByRole }] = render(props) + expect(getByText('Downloading update...')).toBeInTheDocument() + expect(getByRole('progressbar')).toBeInTheDocument() }) - - it('should have a button to allow the user to dismiss alerts permanently', () => { - const { wrapper } = render({ dismissAlert }) - const ignoreButton = wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /turn off update notifications/i.test(b.text()) - ) - - ignoreButton.invoke('onClick')?.({} as React.MouseEvent) - - const title = wrapper.find('h2') - - expect(wrapper.exists(ReleaseNotes)).toBe(false) - expect(title.text()).toMatch(/turned off update notifications/i) - expect(wrapper.text()).toMatch( - /You've chosen to not be notified when an app update is available/ - ) - }) - - it('should not show the "ignore" button if modal was not alert triggered', () => { - const { wrapper } = render({ closeModal }) - const ignoreButton = wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /turn off update notifications/i.test(b.text()) - ) - - expect(ignoreButton.exists()).toBe(false) - }) - - it('should not show the "ignore" button if the user has proceeded with the update', () => { + it('renders download complete text when download is finished', () => { getShellUpdateState.mockReturnValue({ + downloading: false, downloaded: true, } as ShellUpdateState) - - const { wrapper } = render({ dismissAlert }) - const ignoreButton = wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /turn off update notifications/i.test(b.text()) - ) - - expect(ignoreButton.exists()).toBe(false) - }) - - it('should dismiss the alert permanently once the user clicks "OK"', () => { - const { wrapper } = render({ dismissAlert }) - - wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /turn off update notifications/i.test(b.text()) - ) - .invoke('onClick')?.({} as React.MouseEvent) - - wrapper - .find('button') - .filterWhere((b: ReactWrapper) => /ok/i.test(b.text())) - .invoke('onClick')?.({} as React.MouseEvent) - - expect(dismissAlert).toHaveBeenCalledWith(true) - }) - - it('should dismiss the alert permanently if the component unmounts, for safety', () => { - const { wrapper } = render({ dismissAlert }) - - wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /turn off update notifications/i.test(b.text()) - ) - .invoke('onClick')?.({} as React.MouseEvent) - - wrapper.unmount() - - expect(dismissAlert).toHaveBeenCalledWith(true) - }) - - it('should have a link to /more/app that also dismisses alert permanently', () => { - const { wrapper } = render({ dismissAlert }) - - wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /turn off update notifications/i.test(b.text()) - ) - .invoke('onClick')?.({} as React.MouseEvent) - - wrapper - .find(InternalLink) - .filterWhere( - (b: ReactWrapper>) => - b.prop('to') === '/more/app' - ) - .invoke('onClick')?.({} as React.MouseEvent) - - expect(dismissAlert).toHaveBeenCalledWith(true) + const [{ getByText, getByRole }] = render(props) + expect( + getByText('Download complete, restarting the app...') + ).toBeInTheDocument() + expect(getByRole('progressbar')).toBeInTheDocument() }) - - it('should not send dismissal via unmount if button is close button clicked', () => { - const { wrapper } = render({ dismissAlert }) - const notNowButton = wrapper - .find('button') - .filterWhere((b: ReactWrapper) => - /not now/i.test(b.text()) - ) - - notNowButton.invoke('onClick')?.({} as React.MouseEvent) - wrapper.unmount() - - expect(dismissAlert).toHaveBeenCalledTimes(1) - expect(dismissAlert).toHaveBeenCalledWith(false) + it('renders an error message when an error occurs', () => { + getShellUpdateState.mockReturnValue({ + error: { name: 'Update Error' }, + } as ShellUpdateState) + const [{ getByTitle }] = render(props) + expect(getByTitle('Update Error')).toBeInTheDocument() }) }) diff --git a/app/src/organisms/UpdateAppModal/index.tsx b/app/src/organisms/UpdateAppModal/index.tsx index 573a58f0404..43a46c8e63b 100644 --- a/app/src/organisms/UpdateAppModal/index.tsx +++ b/app/src/organisms/UpdateAppModal/index.tsx @@ -1,31 +1,19 @@ import * as React from 'react' import { useSelector, useDispatch } from 'react-redux' -import { Link as InternalLink } from 'react-router-dom' +import styled, { css } from 'styled-components' +import { useHistory } from 'react-router-dom' +import { useTranslation } from 'react-i18next' import { ALIGN_CENTER, - C_BLUE, - C_TRANSPARENT, - C_WHITE, + COLORS, DIRECTION_COLUMN, - DISPLAY_FLEX, - FONT_SIZE_BODY_1, - FONT_SIZE_HEADER, - FONT_STYLE_ITALIC, - FONT_WEIGHT_REGULAR, JUSTIFY_FLEX_END, - SIZE_4, - SIZE_6, SPACING, - useMountEffect, - BaseModal, - Btn, - Box, Flex, - Icon, - SecondaryBtn, - Text, - TYPOGRAPHY, + NewPrimaryBtn, + NewSecondaryBtn, + BORDERS, } from '@opentrons/components' import { @@ -34,194 +22,148 @@ import { applyShellUpdate, } from '../../redux/shell' -import { ErrorModal } from '../../molecules/modals' import { ReleaseNotes } from '../../molecules/ReleaseNotes' +import { LegacyModal } from '../../molecules/LegacyModal' +import { Banner } from '../../atoms/Banner' +import { ProgressBar } from '../../atoms/ProgressBar' import type { Dispatch } from '../../redux/types' +import { StyledText } from '../../atoms/text' -export interface UpdateAppModalProps { - dismissAlert?: (remember: boolean) => unknown - closeModal?: () => unknown +interface PlaceHolderErrorProps { + errorMessage?: string } -// TODO(mc, 2020-10-06): i18n -const APP_VERSION = 'App Version' -const AVAILABLE = 'Available' -const DOWNLOADED = 'Downloaded' -const DOWNLOAD_IN_PROGRESS = 'Download in progress' -const DOWNLOAD = 'Download' -const RESTART_APP = 'Restart App' -const NOT_NOW = 'Not Now' -const OK = 'OK' -const UPDATE_ERROR = 'Update Error' -const SOMETHING_WENT_WRONG = 'Something went wrong while updating your app' -const TURN_OFF_UPDATE_NOTIFICATIONS = 'Turn off update notifications' -const YOUVE_TURNED_OFF_NOTIFICATIONS = "You've Turned Off Update Notifications" -const VIEW_APP_SOFTWARE_SETTINGS = 'View App Software Settings' -const NOTIFICATIONS_DISABLED_DESCRIPTION = ( - <> - You{"'"}ve chosen to not be notified when an app update is available. You - can change this setting under More {'>'} App {'>'}{' '} - App Software Settings. - -) +// TODO(jh, 2023-08-25): refactor default error handling into LegacyModal +const PlaceholderError = ({ + errorMessage, +}: PlaceHolderErrorProps): JSX.Element => { + const SOMETHING_WENT_WRONG = 'Something went wrong while updating your app.' + const AN_UNKNOWN_ERROR_OCCURRED = 'An unknown error occurred.' + const FALLBACK_ERROR_MESSAGE = `If you keep getting this message, try restarting your app and/or + robot. If this does not resolve the issue please contact Opentrons + Support.` -const FINISH_UPDATE_INSTRUCTIONS = ( - <> - - Restart your app to complete the update. Please note the following: - - -
  • - - After updating the Opentrons App, update your robot{' '} - to ensure the app and robot software is in sync. - -
  • -
  • - - You should update the Opentrons App on all computers{' '} - that you use with your robot. - -
  • -
    - -) + return ( + <> + {SOMETHING_WENT_WRONG} +
    +
    + {errorMessage ?? AN_UNKNOWN_ERROR_OCCURRED} +
    +
    + {FALLBACK_ERROR_MESSAGE} + + ) +} -const SPINNER = ( - - - - {DOWNLOAD_IN_PROGRESS} - - -) +const UPDATE_ERROR = 'Update Error' +const FOOTER_BUTTON_STYLE = css` + text-transform: lowercase; + padding-left: ${SPACING.spacing16}; + padding-right: ${SPACING.spacing16}; + border-radius: ${BORDERS.borderRadiusSize1}; + margin-top: ${SPACING.spacing16}; + margin-bottom: ${SPACING.spacing16}; + + &:first-letter { + text-transform: uppercase; + } +` +const UpdateAppBanner = styled(Banner)` + border: none; +` +const UPDATE_PROGRESS_BAR_STYLE = css` + margin-top: 1.5rem; + border-radius: ${BORDERS.borderRadiusSize3}; + background: ${COLORS.medGreyEnabled}; +` + +export interface UpdateAppModalProps { + closeModal: (arg0: boolean) => void +} export function UpdateAppModal(props: UpdateAppModalProps): JSX.Element { - const { dismissAlert, closeModal } = props - const [updatesIgnored, setUpdatesIgnored] = React.useState(false) + const { closeModal } = props const dispatch = useDispatch() const updateState = useSelector(getShellUpdateState) - const { downloaded, downloading, error, info: updateInfo } = updateState - const version = updateInfo?.version ?? '' + const { + downloaded, + downloading, + downloadPercentage, + error, + info: updateInfo, + } = updateState const releaseNotes = updateInfo?.releaseNotes + const { t } = useTranslation('app_settings') + const history = useHistory() - const handleUpdateClick = (): void => { - dispatch(downloaded ? applyShellUpdate() : downloadShellUpdate()) - } - - // ensure close handlers are called on close button click or on component - // unmount (for safety), but not both - const latestHandleClose = React.useRef<(() => void) | null>(null) + if (downloaded) setTimeout(() => dispatch(applyShellUpdate()), 5000) - React.useEffect(() => { - latestHandleClose.current = () => { - if (typeof dismissAlert === 'function') dismissAlert(updatesIgnored) - if (typeof closeModal === 'function') closeModal() - latestHandleClose.current = null - } - }) - - const handleCloseClick = (): void => { - latestHandleClose.current && latestHandleClose.current() + const handleRemindMeLaterClick = (): void => { + history.push('/app-settings/general') + closeModal(true) } - useMountEffect(() => { - return () => { - latestHandleClose.current && latestHandleClose.current() - } - }) - - if (error) { - return ( - - ) - } - - if (downloading) return SPINNER + const appUpdateFooter = ( + + + {t('remind_later')} + + dispatch(downloadShellUpdate())} + marginRight={SPACING.spacing12} + css={FOOTER_BUTTON_STYLE} + > + {t('update_app_now')} + + + ) - // TODO(mc, 2020-10-08): refactor most of this back into a new AlertModal - // component built with BaseModal return ( - + {error != null ? ( + closeModal(true)}> + + + ) : null} + {(downloading || downloaded) && error == null ? ( + + + + {downloading ? t('download_update') : t('restarting_app')} + + + + + ) : null} + {!downloading && !downloaded && error == null ? ( + closeModal(true)} + closeOnOutsideClick={true} + footer={appUpdateFooter} + maxHeight="80%" > - - {updatesIgnored - ? YOUVE_TURNED_OFF_NOTIFICATIONS - : `${APP_VERSION} ${version} ${ - downloaded ? DOWNLOADED : AVAILABLE - }`} - - } - footer={ - - {updatesIgnored ? ( - <> - - {VIEW_APP_SOFTWARE_SETTINGS} - - {OK} - - ) : ( - <> - {dismissAlert != null && !downloaded ? ( - setUpdatesIgnored(true)} - > - {TURN_OFF_UPDATE_NOTIFICATIONS} - - ) : null} - - {NOT_NOW} - - - {downloaded ? RESTART_APP : DOWNLOAD} - - - )} - - } - > - - {updatesIgnored ? ( - NOTIFICATIONS_DISABLED_DESCRIPTION - ) : downloaded ? ( - FINISH_UPDATE_INSTRUCTIONS - ) : ( - - )} - - + + + {t('update_requires_restarting')} + + + + + ) : null} + ) } diff --git a/app/src/organisms/UpdateRobotSoftware/__tests__/CompleteUpdateSoftware.test.tsx b/app/src/organisms/UpdateRobotSoftware/__tests__/CompleteUpdateSoftware.test.tsx index fe2f05aac3e..86dfe9778c4 100644 --- a/app/src/organisms/UpdateRobotSoftware/__tests__/CompleteUpdateSoftware.test.tsx +++ b/app/src/organisms/UpdateRobotSoftware/__tests__/CompleteUpdateSoftware.test.tsx @@ -23,7 +23,7 @@ describe('CompleteUpdateSoftware', () => { it('should render text, progress bar and button', () => { const [{ getByText, getByTestId }] = render(props) getByText('Update complete!') - getByText('Restarting robot...') + getByText('Install complete, robot restarting...') const bar = getByTestId('ProgressBar_Bar') expect(bar).toHaveStyle('width: 100%') }) diff --git a/app/src/pages/AppSettings/GeneralSettings.tsx b/app/src/pages/AppSettings/GeneralSettings.tsx index ae88539c16f..046226a9af5 100644 --- a/app/src/pages/AppSettings/GeneralSettings.tsx +++ b/app/src/pages/AppSettings/GeneralSettings.tsx @@ -113,7 +113,7 @@ export function GeneralSettings(): JSX.Element { type="warning" onCloseClick={() => setShowUpdateBanner(false)} > - {t('update_available')} + {t('opentrons_app_update_available_variation')} - {t('share_analytics')} + {t('share_app_analytics')}
    - {t('analytics_description')} + {t('share_app_analytics_description')} { getByText('Additional Custom Labware Source Folder') getByText('Prevent Robot Caching') getByText('Clear Unavailable Robots') - getByText('Developer Tools') + getByText('Enable Developer Tools') getByText('OT-2 Advanced Settings') getByText('Tip Length Calibration Method') getByText('USB-to-Ethernet Adapter Information') diff --git a/app/src/pages/AppSettings/__test__/PrivacySettings.test.tsx b/app/src/pages/AppSettings/__test__/PrivacySettings.test.tsx index a01f41a3f5a..662212c1b02 100644 --- a/app/src/pages/AppSettings/__test__/PrivacySettings.test.tsx +++ b/app/src/pages/AppSettings/__test__/PrivacySettings.test.tsx @@ -23,7 +23,7 @@ const render = (): ReturnType => { describe('PrivacySettings', () => { it('renders correct title, body text, and toggle', () => { const [{ getByText, getByRole }] = render() - getByText('Share Robot and App Analytics with Opentrons') + getByText('Share App Analytics with Opentrons') getByText( 'Help Opentrons improve its products and services by automatically sending anonymous diagnostics and usage data.' ) diff --git a/app/src/pages/Devices/DeviceDetails/DeviceDetailsComponent.tsx b/app/src/pages/Devices/DeviceDetails/DeviceDetailsComponent.tsx index 7f435eb1ae6..a500ca8bd5e 100644 --- a/app/src/pages/Devices/DeviceDetails/DeviceDetailsComponent.tsx +++ b/app/src/pages/Devices/DeviceDetails/DeviceDetailsComponent.tsx @@ -23,9 +23,11 @@ interface DeviceDetailsComponentProps { export function DeviceDetailsComponent({ robotName, }: DeviceDetailsComponentProps): JSX.Element { - const { data: estopStatus, error: estopError } = useEstopQuery() - const { isEmergencyStopModalDismissed } = useEstopContext() const isOT3 = useIsOT3(robotName) + const { data: estopStatus, error: estopError } = useEstopQuery({ + enabled: isOT3, + }) + const { isEmergencyStopModalDismissed } = useEstopContext() return ( - {estopStatus?.data.status !== DISENGAGED && + {isOT3 && + estopStatus?.data.status !== DISENGAGED && estopError == null && - isOT3 && isEmergencyStopModalDismissed ? ( diff --git a/app/src/pages/OnDeviceDisplay/NameRobot.tsx b/app/src/pages/OnDeviceDisplay/NameRobot.tsx index c1674a6d599..fea706a90d0 100644 --- a/app/src/pages/OnDeviceDisplay/NameRobot.tsx +++ b/app/src/pages/OnDeviceDisplay/NameRobot.tsx @@ -21,8 +21,6 @@ import { Icon, Btn, } from '@opentrons/components' -import { getOnDeviceDisplaySettings } from '../../redux/config' - import { useUpdateRobotNameMutation } from '@opentrons/react-api-client' import { @@ -38,17 +36,15 @@ import { InputField } from '../../atoms/InputField' import { CustomKeyboard } from '../../atoms/SoftwareKeyboard' import { SmallButton } from '../../atoms/buttons' import { StepMeter } from '../../atoms/StepMeter' +import { useIsUnboxingFlowOngoing } from '../../organisms/RobotSettingsDashboard/NetworkSettings/hooks' import { ConfirmRobotName } from '../../organisms/OnDeviceDisplay/NameRobot/ConfirmRobotName' import type { UpdatedRobotName } from '@opentrons/api-client' import type { State, Dispatch } from '../../redux/types' -// Note: kj 12/15/2022 the current input field is optimized for the desktop -// Need to update the InputField for the ODD app -// That will be done in another PR const INPUT_FIELD_ODD_STYLE = css` - padding-top: ${SPACING.spacing40}; - padding-bottom: ${SPACING.spacing40}; + padding-top: ${SPACING.spacing32}; + padding-bottom: ${SPACING.spacing32}; font-size: 2.5rem; line-height: 3.25rem; text-align: center; @@ -72,12 +68,8 @@ export function NameRobot(): JSX.Element { ] = React.useState(false) const keyboardRef = React.useRef(null) const dispatch = useDispatch() - const { unfinishedUnboxingFlowRoute } = useSelector( - getOnDeviceDisplaySettings - ) - const isInitialSetup = unfinishedUnboxingFlowRoute !== null + const isUnboxingFlowOngoing = useIsUnboxingFlowOngoing() - // check for robot name const connectableRobots = useSelector((state: State) => getConnectableRobots(state) ) @@ -126,7 +118,7 @@ export function NameRobot(): JSX.Element { onSuccess: (data: UpdatedRobotName) => { if (data.name != null) { setNewName(data.name) - if (!isInitialSetup) { + if (!isUnboxingFlowOngoing) { history.push('/robot-settings') } else { setIsShowConfirmRobotName(true) @@ -143,7 +135,6 @@ export function NameRobot(): JSX.Element { const handleConfirm = (): void => { // check robot name in the same network - // ToDo (kj:04/09/2023) need to specify for odd trackEvent({ name: ANALYTICS_RENAME_ROBOT, properties: { @@ -156,30 +147,31 @@ export function NameRobot(): JSX.Element { return ( <> - {isShowConfirmRobotName && isInitialSetup ? ( + {isShowConfirmRobotName && isUnboxingFlowOngoing ? ( ) : ( <> - {isInitialSetup ? : null} + {isUnboxingFlowOngoing ? ( + + ) : null} { - if (isInitialSetup) { + if (isUnboxingFlowOngoing) { history.push('/emergency-stop') } else { history.push('/robot-settings') @@ -189,9 +181,11 @@ export function NameRobot(): JSX.Element { - + - {isInitialSetup ? t('name_your_robot') : t('rename_robot')} + {isUnboxingFlowOngoing + ? t('name_your_robot') + : t('rename_robot')} @@ -211,63 +205,64 @@ export function NameRobot(): JSX.Element { )} + + - {isInitialSetup ? ( + {isUnboxingFlowOngoing ? ( {t('name_your_robot_description')} ) : null} - - - + + + + {t('name_rule_description')} + + {formik.errors.newRobotName && ( - {t('name_rule_description')} + {formik.errors.newRobotName} - {formik.errors.newRobotName && ( - - {formik.errors.newRobotName} - - )} - + )} + - - e != null && setName(e)} - keyboardRef={keyboardRef} - /> - + + e != null && setName(e)} + keyboardRef={keyboardRef} + /> )} diff --git a/app/src/pages/OnDeviceDisplay/NetworkSetupMenu.tsx b/app/src/pages/OnDeviceDisplay/NetworkSetupMenu.tsx index ac20b750ef9..38f126305b4 100644 --- a/app/src/pages/OnDeviceDisplay/NetworkSetupMenu.tsx +++ b/app/src/pages/OnDeviceDisplay/NetworkSetupMenu.tsx @@ -1,6 +1,5 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import { Link } from 'react-router-dom' import { Flex, @@ -10,11 +9,9 @@ import { JUSTIFY_CENTER, ALIGN_CENTER, DIRECTION_ROW, - ALIGN_FLEX_END, TYPOGRAPHY, } from '@opentrons/components' -import { TertiaryButton } from '../../atoms/buttons' import { StyledText } from '../../atoms/text' import { StepMeter } from '../../atoms/StepMeter' import { CardButton } from '../../molecules/CardButton' @@ -62,7 +59,7 @@ export function NetworkSetupMenu(): JSX.Element { fontWeight={TYPOGRAPHY.fontWeightBold} color={COLORS.black} > - {t('connect_to_a_network')} + {t('choose_network_type')}
    ))} - - - To ODD Menu - - ) diff --git a/app/src/pages/OnDeviceDisplay/ProtocolDetails/Deck.tsx b/app/src/pages/OnDeviceDisplay/ProtocolDetails/Deck.tsx index 988b5c53ec1..286213fb915 100644 --- a/app/src/pages/OnDeviceDisplay/ProtocolDetails/Deck.tsx +++ b/app/src/pages/OnDeviceDisplay/ProtocolDetails/Deck.tsx @@ -1,19 +1,23 @@ import * as React from 'react' import last from 'lodash/last' -import { useProtocolAnalysesQuery } from '@opentrons/react-api-client' +import { + useProtocolAnalysisAsDocumentQuery, + useProtocolQuery, +} from '@opentrons/react-api-client' import { DeckThumbnail } from '../../../molecules/DeckThumbnail' import type { CompletedProtocolAnalysis } from '@opentrons/shared-data' export const Deck = (props: { protocolId: string }): JSX.Element => { - const { data: protocolAnalyses } = useProtocolAnalysesQuery( + const { data: protocolData } = useProtocolQuery(props.protocolId) + const { + data: mostRecentAnalysis, + } = useProtocolAnalysisAsDocumentQuery( props.protocolId, - { - staleTime: Infinity, - } + last(protocolData?.data.analysisSummaries)?.id ?? null, + { enabled: protocolData != null } ) - const mostRecentAnalysis = last(protocolAnalyses?.data ?? []) ?? null return ( { const { protocolId } = props - const { data: protocolAnalyses } = useProtocolAnalysesQuery(protocolId, { - staleTime: Infinity, - }) - const mostRecentAnalysis = last(protocolAnalyses?.data ?? []) ?? [] + const { data: protocolData } = useProtocolQuery(protocolId) + const { + data: mostRecentAnalysis, + } = useProtocolAnalysisAsDocumentQuery( + protocolId, + last(protocolData?.data.analysisSummaries)?.id ?? null, + { enabled: protocolData != null } + ) const liquidsInOrder = parseLiquidsInLoadOrder( (mostRecentAnalysis as CompletedProtocolAnalysis).liquids ?? [], (mostRecentAnalysis as CompletedProtocolAnalysis).commands ?? [] diff --git a/app/src/pages/OnDeviceDisplay/ProtocolDetails/__tests__/Liquids.test.tsx b/app/src/pages/OnDeviceDisplay/ProtocolDetails/__tests__/Liquids.test.tsx index c430267a5f0..a6c8f434fc0 100644 --- a/app/src/pages/OnDeviceDisplay/ProtocolDetails/__tests__/Liquids.test.tsx +++ b/app/src/pages/OnDeviceDisplay/ProtocolDetails/__tests__/Liquids.test.tsx @@ -1,21 +1,28 @@ import * as React from 'react' import { UseQueryResult } from 'react-query' import { when } from 'jest-when' -import { useProtocolAnalysesQuery } from '@opentrons/react-api-client' +import { + useProtocolAnalysisAsDocumentQuery, + useProtocolQuery, +} from '@opentrons/react-api-client' import { parseLabwareInfoByLiquidId, parseLiquidsInLoadOrder, - ProtocolAnalyses, + Protocol, } from '@opentrons/api-client' import { renderWithProviders } from '@opentrons/components' import { i18n } from '../../../../i18n' import { Liquids } from '../Liquids' +import { CompletedProtocolAnalysis } from '@opentrons/shared-data' jest.mock('@opentrons/api-client') jest.mock('@opentrons/react-api-client') -const mockUseProtocolAnalysesQuery = useProtocolAnalysesQuery as jest.MockedFunction< - typeof useProtocolAnalysesQuery +const mockUseProtocolQuery = useProtocolQuery as jest.MockedFunction< + typeof useProtocolQuery +> +const mockUseProtocolAnalysisAsDocumentQuery = useProtocolAnalysisAsDocumentQuery as jest.MockedFunction< + typeof useProtocolAnalysisAsDocumentQuery > const mockParseLiquidsInLoadOrder = parseLiquidsInLoadOrder as jest.MockedFunction< typeof parseLiquidsInLoadOrder @@ -25,6 +32,7 @@ const mockParseLabwareInfoByLiquidId = parseLabwareInfoByLiquidId as jest.Mocked > const MOCK_PROTOCOL_ID = 'mockProtocolId' const MOCK_PROTOCOL_ANALYSIS = { + id: 'fake_protocol_analysis', commands: [ { id: '97ba49a5-04f6-4f91-986a-04a0eb632882', @@ -188,11 +196,20 @@ describe('Liquids', () => { mockParseLabwareInfoByLiquidId.mockReturnValue( MOCK_LABWARE_INFO_BY_LIQUID_ID ) - when(mockUseProtocolAnalysesQuery) - .calledWith(MOCK_PROTOCOL_ID, { staleTime: Infinity }) + when(mockUseProtocolQuery) + .calledWith(MOCK_PROTOCOL_ID) + .mockReturnValue({ + data: { + data: { analysisSummaries: [{ id: MOCK_PROTOCOL_ANALYSIS.id }] }, + } as any, + } as UseQueryResult) + when(mockUseProtocolAnalysisAsDocumentQuery) + .calledWith(MOCK_PROTOCOL_ID, MOCK_PROTOCOL_ANALYSIS.id, { + enabled: true, + }) .mockReturnValue({ - data: { data: [MOCK_PROTOCOL_ANALYSIS] } as any, - } as UseQueryResult) + data: MOCK_PROTOCOL_ANALYSIS as any, + } as UseQueryResult) }) it('should render the correct headers and liquids', () => { const { getByRole, getByText, getByLabelText } = render(props)[0] diff --git a/app/src/pages/OnDeviceDisplay/ProtocolDetails/__tests__/ProtocolDetails.test.tsx b/app/src/pages/OnDeviceDisplay/ProtocolDetails/__tests__/ProtocolDetails.test.tsx index e05db746405..309dc783b72 100644 --- a/app/src/pages/OnDeviceDisplay/ProtocolDetails/__tests__/ProtocolDetails.test.tsx +++ b/app/src/pages/OnDeviceDisplay/ProtocolDetails/__tests__/ProtocolDetails.test.tsx @@ -14,7 +14,7 @@ import { useCreateRunMutation, useHost, useProtocolQuery, - useProtocolAnalysesQuery, + useProtocolAnalysisAsDocumentQuery, } from '@opentrons/react-api-client' import { i18n } from '../../../../i18n' import { useMissingHardwareText } from '../../../../organisms/OnDeviceDisplay/RobotDashboard/hooks' @@ -67,8 +67,8 @@ const mockDeleteRun = deleteRun as jest.MockedFunction const mockUseProtocolQuery = useProtocolQuery as jest.MockedFunction< typeof useProtocolQuery > -const mockUseProtocolAnalysesQuery = useProtocolAnalysesQuery as jest.MockedFunction< - typeof useProtocolAnalysesQuery +const mockUseProtocolAnalysisAsDocumentQuery = useProtocolAnalysisAsDocumentQuery as jest.MockedFunction< + typeof useProtocolAnalysisAsDocumentQuery > const mockUseMissingProtocolHardware = useMissingProtocolHardware as jest.MockedFunction< typeof useMissingProtocolHardware @@ -130,14 +130,10 @@ describe('ODDProtocolDetails', () => { data: MOCK_DATA, isLoading: false, } as any) - mockUseProtocolAnalysesQuery.mockReturnValue({ + mockUseProtocolAnalysisAsDocumentQuery.mockReturnValue({ data: { - data: [ - { - id: 'mockAnalysisId', - status: 'completed', - }, - ], + id: 'mockAnalysisId', + status: 'completed', }, } as any) when(mockuseHost).calledWith().mockReturnValue(MOCK_HOST_CONFIG) diff --git a/app/src/pages/OnDeviceDisplay/ProtocolDetails/index.tsx b/app/src/pages/OnDeviceDisplay/ProtocolDetails/index.tsx index 89cb0d2fc59..259f57a49f9 100644 --- a/app/src/pages/OnDeviceDisplay/ProtocolDetails/index.tsx +++ b/app/src/pages/OnDeviceDisplay/ProtocolDetails/index.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import last from 'lodash/last' import { useTranslation } from 'react-i18next' import { useQueryClient } from 'react-query' import { deleteProtocol, deleteRun, getProtocol } from '@opentrons/api-client' @@ -22,10 +23,9 @@ import { import { useCreateRunMutation, useHost, - useProtocolAnalysesQuery, + useProtocolAnalysisAsDocumentQuery, useProtocolQuery, } from '@opentrons/react-api-client' -import { CompletedProtocolAnalysis } from '@opentrons/shared-data' import { MAXIMUM_PINNED_PROTOCOLS } from '../../../App/constants' import { MediumButton, SmallButton, TabbedButton } from '../../../atoms/buttons' import { Chip } from '../../../atoms/Chip' @@ -308,20 +308,21 @@ export function ProtocolDetails(): JSX.Element | null { let pinnedProtocolIds = useSelector(getPinnedProtocolIds) ?? [] const pinned = pinnedProtocolIds.includes(protocolId) - const { data: protocolAnalyses } = useProtocolAnalysesQuery(protocolId) - const mostRecentAnalysis = - (protocolAnalyses?.data ?? []) - .reverse() - .find( - (analysis): analysis is CompletedProtocolAnalysis => - analysis.status === 'completed' - ) ?? null + const { data: protocolData } = useProtocolQuery(protocolId) + const { + data: mostRecentAnalysis, + } = useProtocolAnalysisAsDocumentQuery( + protocolId, + last(protocolData?.data.analysisSummaries)?.id ?? null, + { enabled: protocolData != null } + ) + const shouldApplyOffsets = useSelector(getApplyHistoricOffsets) // I'd love to skip scraping altogether if we aren't applying // conditional offsets, but React won't let us use hooks conditionally. // So, we'll scrape regardless and just toss them if we don't need them. const scrapedLabwareOffsets = useOffsetCandidatesForAnalysis( - mostRecentAnalysis + mostRecentAnalysis ?? null ).map(({ vector, location, definitionUri }) => ({ vector, location, diff --git a/app/src/pages/OnDeviceDisplay/ProtocolSetup/index.tsx b/app/src/pages/OnDeviceDisplay/ProtocolSetup/index.tsx index 83223118589..ae0be8c4ae1 100644 --- a/app/src/pages/OnDeviceDisplay/ProtocolSetup/index.tsx +++ b/app/src/pages/OnDeviceDisplay/ProtocolSetup/index.tsx @@ -41,7 +41,6 @@ import { ProtocolSetupStepSkeleton, } from '../../../organisms/OnDeviceDisplay/ProtocolSetup' import { ODD_FOCUS_VISIBLE } from '../../../atoms/buttons/constants' -import { useMaintenanceRunTakeover } from '../../../organisms/TakeoverModal' import { useAttachedModules, useLPCDisabledReason, @@ -175,13 +174,15 @@ export function ProtocolSetupStep({ {subDetail}
    - + {disabled ? null : ( + + )} ) @@ -341,7 +342,6 @@ function PrepareToRun({ '' const mostRecentAnalysis = useMostRecentCompletedAnalysis(runId) const { launchLPC, LPCWizard } = useLaunchLPC(runId) - const { setODDMaintenanceFlowInProgress } = useMaintenanceRunTakeover() const onConfirmCancelClose = (): void => { setShowConfirmCancelModal(false) @@ -378,6 +378,9 @@ function PrepareToRun({ hasMissingModulesForOdd: isMissingModules, hasMissingCalForOdd: !areInstrumentsReady, }) + const requiredCalibration = attachedModules.some( + module => module.moduleOffset?.last_modified == null + ) const [ showConfirmCancelModal, @@ -401,7 +404,8 @@ function PrepareToRun({ }) const instrumentsStatus = areInstrumentsReady ? 'ready' : 'not ready' - const modulesStatus = isMissingModules ? 'not ready' : 'ready' + const modulesStatus = + isMissingModules || requiredCalibration ? 'not ready' : 'ready' const isReadyToRun = areInstrumentsReady && !isMissingModules @@ -445,9 +449,15 @@ function PrepareToRun({ ? `${t('missing')} ${firstMissingModuleDisplayName}` : t('multiple_modules_missing') - const modulesDetail = isMissingModules - ? missingModulesText - : connectedModulesText + const modulesDetail = (): string => { + if (isMissingModules) { + return missingModulesText + } else if (requiredCalibration) { + return t('calibration_required') + } else { + return connectedModulesText + } + } // Labware information const { offDeckItems, onDeckItems } = getLabwareSetupItemGroups( @@ -546,13 +556,12 @@ function PrepareToRun({ setSetupScreen('modules')} title={t('modules')} - detail={modulesDetail} + detail={modulesDetail()} status={modulesStatus} disabled={protocolModulesInfo.length === 0} /> { - setODDMaintenanceFlowInProgress() launchLPC() }} title={t('labware_position_check')} diff --git a/app/src/pages/OnDeviceDisplay/RobotDashboard/AnalyticsOptInModal.tsx b/app/src/pages/OnDeviceDisplay/RobotDashboard/AnalyticsOptInModal.tsx new file mode 100644 index 00000000000..b66f201a7e5 --- /dev/null +++ b/app/src/pages/OnDeviceDisplay/RobotDashboard/AnalyticsOptInModal.tsx @@ -0,0 +1,91 @@ +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch, useSelector } from 'react-redux' + +import { + Flex, + COLORS, + DIRECTION_COLUMN, + DIRECTION_ROW, + SPACING, +} from '@opentrons/components' + +import { SmallButton } from '../../../atoms/buttons' +import { StyledText } from '../../../atoms/text' +import { Modal } from '../../../molecules/Modal' +import { updateConfigValue } from '../../../redux/config' +import { getLocalRobot } from '../../../redux/discovery' +import { updateSetting } from '../../../redux/robot-settings' + +import type { Dispatch } from '../../../redux/types' + +export const ROBOT_ANALYTICS_SETTING_ID = 'disableLogAggregation' + +interface AnalyticsOptInModalProps { + setShowAnalyticsOptInModal: (showAnalyticsOptInModal: boolean) => void +} + +export function AnalyticsOptInModal({ + setShowAnalyticsOptInModal, +}: AnalyticsOptInModalProps): JSX.Element { + const { t } = useTranslation(['app_settings', 'shared']) + const dispatch = useDispatch() + + const localRobot = useSelector(getLocalRobot) + const robotName = localRobot?.name != null ? localRobot.name : 'no name' + + const handleCloseModal = (): void => { + dispatch( + updateConfigValue( + 'onDeviceDisplaySettings.unfinishedUnboxingFlowRoute', + null + ) + ) + setShowAnalyticsOptInModal(false) + } + + const handleOptIn = (): void => { + dispatch(updateSetting(robotName, ROBOT_ANALYTICS_SETTING_ID, false)) + dispatch(updateConfigValue('analytics.optedIn', true)) + handleCloseModal() + } + + const handleOptOut = (): void => { + dispatch(updateSetting(robotName, ROBOT_ANALYTICS_SETTING_ID, true)) + dispatch(updateConfigValue('analytics.optedIn', false)) + handleCloseModal() + } + + return ( + + + + + {t('opt_in_description')} + + + + + + + + + ) +} diff --git a/app/src/pages/OnDeviceDisplay/RobotDashboard/WelcomeModal.tsx b/app/src/pages/OnDeviceDisplay/RobotDashboard/WelcomeModal.tsx index a7a62059988..70e38354d5d 100644 --- a/app/src/pages/OnDeviceDisplay/RobotDashboard/WelcomeModal.tsx +++ b/app/src/pages/OnDeviceDisplay/RobotDashboard/WelcomeModal.tsx @@ -1,6 +1,5 @@ import * as React from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' import { Flex, @@ -10,36 +9,49 @@ import { DIRECTION_COLUMN, JUSTIFY_CENTER, } from '@opentrons/components' +import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' import { StyledText } from '../../../atoms/text' import { SmallButton } from '../../../atoms/buttons' import { Modal } from '../../../molecules/Modal' -import { updateConfigValue } from '../../../redux/config' import welcomeModalImage from '../../../assets/images/on-device-display/welcome_dashboard_modal.png' -import type { Dispatch } from '../../../redux/types' +import type { SetStatusBarCreateCommand } from '@opentrons/shared-data/protocol/types/schemaV7/command/incidental' interface WelcomeModalProps { + setShowAnalyticsOptInModal: (showAnalyticsOptInModal: boolean) => void setShowWelcomeModal: (showWelcomeModal: boolean) => void } -export function WelcomedModal({ +export function WelcomeModal({ + setShowAnalyticsOptInModal, setShowWelcomeModal, }: WelcomeModalProps): JSX.Element { - const { t } = useTranslation('device_details') - const dispatch = useDispatch() + const { t } = useTranslation(['device_details', 'shared']) - const handleCloseModal = (): void => { - dispatch( - updateConfigValue( - 'onDeviceDisplaySettings.unfinishedUnboxingFlowRoute', - null - ) + const { createLiveCommand } = useCreateLiveCommandMutation() + const animationCommand: SetStatusBarCreateCommand = { + commandType: 'setStatusBar', + params: { animation: 'disco' }, + } + + const startDiscoAnimation = (): void => { + createLiveCommand({ + command: animationCommand, + waitUntilComplete: false, + }).catch((e: Error) => + console.warn(`cannot run status bar animation: ${e.message}`) ) + } + + const handleCloseModal = (): void => { setShowWelcomeModal(false) + setShowAnalyticsOptInModal(true) } + React.useEffect(startDiscoAnimation, []) + return ( - + ) diff --git a/app/src/pages/OnDeviceDisplay/RobotDashboard/__tests__/AnalyticsOptInModal.test.tsx b/app/src/pages/OnDeviceDisplay/RobotDashboard/__tests__/AnalyticsOptInModal.test.tsx new file mode 100644 index 00000000000..ad074575bd0 --- /dev/null +++ b/app/src/pages/OnDeviceDisplay/RobotDashboard/__tests__/AnalyticsOptInModal.test.tsx @@ -0,0 +1,85 @@ +import * as React from 'react' + +import { renderWithProviders } from '@opentrons/components' + +import { i18n } from '../../../../i18n' +import { updateConfigValue } from '../../../../redux/config' +import { getLocalRobot } from '../../../../redux/discovery' +import { updateSetting } from '../../../../redux/robot-settings' +import { AnalyticsOptInModal } from '../AnalyticsOptInModal' + +import type { DiscoveredRobot } from '../../../../redux/discovery/types' + +jest.mock('../../../../redux/config') +jest.mock('../../../../redux/discovery') +jest.mock('../../../../redux/robot-settings') + +const mockUpdateConfigValue = updateConfigValue as jest.MockedFunction< + typeof updateConfigValue +> +const mockGetLocalRobot = getLocalRobot as jest.MockedFunction< + typeof getLocalRobot +> +const mockUpdateSetting = updateSetting as jest.MockedFunction< + typeof updateSetting +> + +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { + i18nInstance: i18n, + }) +} + +describe('AnalyticsOptInModal', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + setShowAnalyticsOptInModal: jest.fn(), + } + mockGetLocalRobot.mockReturnValue({ name: 'Otie' } as DiscoveredRobot) + }) + + it('should render text and button', () => { + const [{ getByText }] = render(props) + + getByText('Want to help out Opentrons?') + getByText( + 'Automatically send us anonymous diagnostics and usage data. We only use this information to improve our products.' + ) + getByText('Opt out') + getByText('Opt in') + }) + + it('should call a mock function when tapping opt out button', () => { + const [{ getByText }] = render(props) + getByText('Opt out').click() + + expect(mockUpdateConfigValue).toHaveBeenCalledWith( + 'analytics.optedIn', + false + ) + expect(mockUpdateSetting).toHaveBeenCalledWith( + 'Otie', + 'disableLogAggregation', + true + ) + expect(props.setShowAnalyticsOptInModal).toHaveBeenCalled() + }) + + it('should call a mock function when tapping out in button', () => { + const [{ getByText }] = render(props) + getByText('Opt in').click() + + expect(mockUpdateConfigValue).toHaveBeenCalledWith( + 'analytics.optedIn', + true + ) + expect(mockUpdateSetting).toHaveBeenCalledWith( + 'Otie', + 'disableLogAggregation', + true + ) + expect(props.setShowAnalyticsOptInModal).toHaveBeenCalled() + }) +}) diff --git a/app/src/pages/OnDeviceDisplay/RobotDashboard/__tests__/RobotDashboard.test.tsx b/app/src/pages/OnDeviceDisplay/RobotDashboard/__tests__/RobotDashboard.test.tsx index 40b66bb8eeb..7f584f87530 100644 --- a/app/src/pages/OnDeviceDisplay/RobotDashboard/__tests__/RobotDashboard.test.tsx +++ b/app/src/pages/OnDeviceDisplay/RobotDashboard/__tests__/RobotDashboard.test.tsx @@ -13,7 +13,7 @@ import { RecentRunProtocolCarousel } from '../../../../organisms/OnDeviceDisplay import { Navigation } from '../../../../organisms/Navigation' import { useMissingProtocolHardware } from '../../../Protocols/hooks' import { getOnDeviceDisplaySettings } from '../../../../redux/config' -import { WelcomedModal } from '../WelcomeModal' +import { WelcomeModal } from '../WelcomeModal' import { RobotDashboard } from '../../RobotDashboard' import type { ProtocolResource } from '@opentrons/shared-data' @@ -56,8 +56,8 @@ const mockRecentRunProtocolCarousel = RecentRunProtocolCarousel as jest.MockedFu const mockGetOnDeviceDisplaySettings = getOnDeviceDisplaySettings as jest.MockedFunction< typeof getOnDeviceDisplaySettings > -const mockWelcomeModal = WelcomedModal as jest.MockedFunction< - typeof WelcomedModal +const mockWelcomeModal = WelcomeModal as jest.MockedFunction< + typeof WelcomeModal > const render = () => { diff --git a/app/src/pages/OnDeviceDisplay/RobotDashboard/__tests__/WelcomeModal.test.tsx b/app/src/pages/OnDeviceDisplay/RobotDashboard/__tests__/WelcomeModal.test.tsx index 793acb54d9d..81287a3987c 100644 --- a/app/src/pages/OnDeviceDisplay/RobotDashboard/__tests__/WelcomeModal.test.tsx +++ b/app/src/pages/OnDeviceDisplay/RobotDashboard/__tests__/WelcomeModal.test.tsx @@ -3,49 +3,69 @@ import * as React from 'react' import { renderWithProviders } from '@opentrons/components' import { i18n } from '../../../../i18n' -import { updateConfigValue } from '../../../../redux/config' -import { WelcomedModal } from '../WelcomeModal' +import { WelcomeModal } from '../WelcomeModal' +import { useCreateLiveCommandMutation } from '@opentrons/react-api-client' + +import type { SetStatusBarCreateCommand } from '@opentrons/shared-data/protocol/types/schemaV7/command/incidental' jest.mock('../../../../redux/config') +jest.mock('@opentrons/react-api-client') +jest.mock('@opentrons/shared-data/protocol/types/schemaV7/command/incidental') + +const mockUseCreateLiveCommandMutation = useCreateLiveCommandMutation as jest.MockedFunction< + typeof useCreateLiveCommandMutation +> const mockFunc = jest.fn() const WELCOME_MODAL_IMAGE_NAME = 'welcome_dashboard_modal.png' -const mockUpdateConfigValue = updateConfigValue as jest.MockedFunction< - typeof updateConfigValue -> - -const render = (props: React.ComponentProps) => { - return renderWithProviders(, { +const render = (props: React.ComponentProps) => { + return renderWithProviders(, { i18nInstance: i18n, }) } describe('WelcomeModal', () => { - let props: React.ComponentProps + let props: React.ComponentProps + let mockCreateLiveCommand = jest.fn() beforeEach(() => { + mockCreateLiveCommand = jest.fn() + mockCreateLiveCommand.mockResolvedValue(null) props = { + setShowAnalyticsOptInModal: jest.fn(), setShowWelcomeModal: mockFunc, } + mockUseCreateLiveCommandMutation.mockReturnValue({ + createLiveCommand: mockCreateLiveCommand, + } as any) }) it('should render text and button', () => { const [{ getByText, getByRole }] = render(props) const image = getByRole('img') + const animationCommand: SetStatusBarCreateCommand = { + commandType: 'setStatusBar', + params: { animation: 'disco' }, + } expect(image.getAttribute('src')).toEqual(WELCOME_MODAL_IMAGE_NAME) getByText('Welcome to your dashboard!') getByText( 'A place to run protocols, manage your instruments, and view robot status.' ) - getByText('Got it') + getByText('Next') + expect(mockUseCreateLiveCommandMutation).toBeCalledWith() + expect(mockCreateLiveCommand).toBeCalledWith({ + command: animationCommand, + waitUntilComplete: false, + }) }) - it('should call a mock function when tapping got it button', () => { + it('should call a mock function when tapping next button', () => { const [{ getByText }] = render(props) - getByText('Got it').click() - expect(mockUpdateConfigValue).toHaveBeenCalled() + getByText('Next').click() expect(props.setShowWelcomeModal).toHaveBeenCalled() + expect(props.setShowAnalyticsOptInModal).toHaveBeenCalled() }) }) diff --git a/app/src/pages/OnDeviceDisplay/RobotDashboard/index.tsx b/app/src/pages/OnDeviceDisplay/RobotDashboard/index.tsx index 71c1ed4f8c1..f2d861b6a13 100644 --- a/app/src/pages/OnDeviceDisplay/RobotDashboard/index.tsx +++ b/app/src/pages/OnDeviceDisplay/RobotDashboard/index.tsx @@ -19,14 +19,16 @@ import { RecentRunProtocolCarousel, } from '../../../organisms/OnDeviceDisplay/RobotDashboard' import { getOnDeviceDisplaySettings } from '../../../redux/config' -import { WelcomedModal } from './WelcomeModal' +import { AnalyticsOptInModal } from './AnalyticsOptInModal' +import { WelcomeModal } from './WelcomeModal' import { RunData } from '@opentrons/api-client' +import { ServerInitializing } from '../../../organisms/OnDeviceDisplay/RobotDashboard/ServerInitializing' export const MAXIMUM_RECENT_RUN_PROTOCOLS = 8 export function RobotDashboard(): JSX.Element { const { t } = useTranslation('device_details') - const allRuns = useAllRunsQuery().data?.data ?? [] + const { data: allRunsQueryData, error: allRunsQueryError } = useAllRunsQuery() const { unfinishedUnboxingFlowRoute } = useSelector( getOnDeviceDisplaySettings @@ -34,8 +36,12 @@ export function RobotDashboard(): JSX.Element { const [showWelcomeModal, setShowWelcomeModal] = React.useState( unfinishedUnboxingFlowRoute !== null ) + const [ + showAnalyticsOptInModal, + setShowAnalyticsOptInModal, + ] = React.useState(false) - const recentRunsOfUniqueProtocols = allRuns + const recentRunsOfUniqueProtocols = (allRunsQueryData?.data ?? []) .reverse() // newest runs first .reduce((acc, run) => { if ( @@ -48,6 +54,29 @@ export function RobotDashboard(): JSX.Element { }, []) .slice(0, MAXIMUM_RECENT_RUN_PROTOCOLS) + let contents: JSX.Element = + // GET runs query will error with 503 if database is initializing + // this should be momentary, and the type of error to come from this endpoint + // so, all errors will be mapped to an initializing spinner + if (allRunsQueryError != null) { + contents = + } else if (recentRunsOfUniqueProtocols.length > 0) { + contents = ( + <> + + {t('run_again')} + + + + ) + } + return ( @@ -57,24 +86,17 @@ export function RobotDashboard(): JSX.Element { gridGap={SPACING.spacing16} > {showWelcomeModal ? ( - + + ) : null} + {showAnalyticsOptInModal ? ( + ) : null} - {recentRunsOfUniqueProtocols.length === 0 ? ( - - ) : ( - <> - - {t('run_again')} - - - - )} + {contents} ) diff --git a/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/RobotSettingButton.tsx b/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/RobotSettingButton.tsx index 8df12a2532b..eb56cd1128f 100644 --- a/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/RobotSettingButton.tsx +++ b/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/RobotSettingButton.tsx @@ -1,7 +1,5 @@ import * as React from 'react' -import { useTranslation } from 'react-i18next' import { css } from 'styled-components' -import { useDispatch } from 'react-redux' import { ALIGN_CENTER, @@ -21,14 +19,8 @@ import { } from '@opentrons/components' import { StyledText } from '../../../atoms/text' -import { InlineNotification } from '../../../atoms/InlineNotification' -import { toggleDevtools, toggleHistoricOffsets } from '../../../redux/config' -import { updateSetting } from '../../../redux/robot-settings' import type { IconName } from '@opentrons/components' -import type { Dispatch } from '../../../redux/types' -import type { RobotSettingsField } from '../../../redux/robot-settings/types' -import type { SettingOption, SetSettingOption } from '../RobotSettingsDashboard' const SETTING_BUTTON_STYLE = css` width: 100%; @@ -44,72 +36,30 @@ const SETTING_BUTTON_STYLE = css` interface RobotSettingButtonProps { settingName: string - iconName: IconName + onClick: React.MouseEventHandler + iconName?: IconName settingInfo?: string - currentOption?: SettingOption - setCurrentOption?: SetSettingOption - robotName?: string - isUpdateAvailable?: boolean - enabledDevTools?: boolean - enabledHistoricOffsets?: boolean - devToolsOn?: boolean - historicOffsetsOn?: boolean - ledLights?: boolean - lightsOn?: boolean - toggleLights?: () => void - enabledHomeGantry?: boolean - homeGantrySettings?: RobotSettingsField + rightElement?: React.ReactNode + dataTestId?: string } export function RobotSettingButton({ settingName, - settingInfo, - currentOption, - setCurrentOption, - robotName, - isUpdateAvailable, iconName, - enabledDevTools, - enabledHistoricOffsets, - devToolsOn, - historicOffsetsOn, - ledLights, - lightsOn, - toggleLights, - enabledHomeGantry, - homeGantrySettings, + onClick, + settingInfo, + rightElement, + dataTestId, }: RobotSettingButtonProps): JSX.Element { - const { t, i18n } = useTranslation(['app_settings', 'shared']) - const dispatch = useDispatch() - const settingValue = homeGantrySettings?.value - ? homeGantrySettings.value - : false - const settingId = homeGantrySettings?.id - ? homeGantrySettings.id - : 'disableHomeOnBoot' - - const handleClick = (): void => { - if (currentOption != null && setCurrentOption != null) { - setCurrentOption(currentOption) - } else if (Boolean(enabledDevTools)) { - dispatch(toggleDevtools()) - } else if (Boolean(enabledHistoricOffsets)) { - dispatch(toggleHistoricOffsets()) - } else if (Boolean(ledLights)) { - if (toggleLights != null) toggleLights() - } else if (Boolean(enabledHomeGantry) && robotName != null) { - dispatch(updateSetting(robotName, settingId, !settingValue)) - } - } - return ( - + {iconName != null ? ( + + ) : null} - {enabledDevTools != null ? ( - - - {Boolean(devToolsOn) ? t('shared:on') : t('shared:off')} - - - ) : null} - {enabledHistoricOffsets != null ? ( - - - {Boolean(historicOffsetsOn) ? t('shared:on') : t('shared:off')} - - - ) : null} - {ledLights != null ? ( - - - {Boolean(lightsOn) ? t('shared:on') : t('shared:off')} - - - ) : null} - - {isUpdateAvailable ?? false ? ( - - ) : null} - {enabledHomeGantry != null ? ( - - - {Boolean(settingValue) ? t('shared:on') : t('shared:off')} - - - ) : null} - {enabledDevTools == null && - enabledHistoricOffsets == null && - ledLights == null && - enabledHomeGantry == null ? ( + {rightElement != null ? ( + rightElement + ) : ( + - ) : null} - + + )} ) } diff --git a/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/RobotSettingsList.tsx b/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/RobotSettingsList.tsx index 12976568797..9c5606d9091 100644 --- a/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/RobotSettingsList.tsx +++ b/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/RobotSettingsList.tsx @@ -20,7 +20,6 @@ import { JUSTIFY_CENTER, } from '@opentrons/components' -import { StyledText } from '../../../atoms/text' import { getLocalRobot, getRobotApiVersion } from '../../../redux/discovery' import { getRobotUpdateAvailable } from '../../../redux/robot-update' import { @@ -29,26 +28,31 @@ import { getDevtoolsEnabled, getFeatureFlags, toggleDevInternalFlag, + toggleDevtools, + toggleHistoricOffsets, } from '../../../redux/config' +import { StyledText } from '../../../atoms/text' +import { InlineNotification } from '../../../atoms/InlineNotification' +import { getRobotSettings, updateSetting } from '../../../redux/robot-settings' import { UNREACHABLE } from '../../../redux/discovery/constants' import { Navigation } from '../../../organisms/Navigation' import { useLEDLights } from '../../../organisms/Devices/hooks' import { onDeviceDisplayRoutes } from '../../../App/OnDeviceDisplayApp' import { useNetworkConnection } from '../hooks' -import { getRobotSettings } from '../../../redux/robot-settings' import { RobotSettingButton } from './RobotSettingButton' import type { Dispatch, State } from '../../../redux/types' -import type { RobotSettings } from '../../../redux/robot-settings/types' import type { SetSettingOption } from './' +const HOME_GANTRY_SETTING_ID = 'disableHomeOnBoot' interface RobotSettingsListProps { setCurrentOption: SetSettingOption } export function RobotSettingsList(props: RobotSettingsListProps): JSX.Element { const { setCurrentOption } = props - const { t } = useTranslation(['device_settings', 'app_settings']) + const { t, i18n } = useTranslation(['device_settings', 'app_settings']) + const dispatch = useDispatch() const localRobot = useSelector(getLocalRobot) const robotName = localRobot?.name != null ? localRobot.name : 'no name' const networkConnection = useNetworkConnection(robotName) @@ -56,6 +60,14 @@ export function RobotSettingsList(props: RobotSettingsListProps): JSX.Element { const robotServerVersion = localRobot?.status != null ? getRobotApiVersion(localRobot) : null + const allRobotSettings = useSelector((state: State) => + getRobotSettings(state, robotName) + ) + + const isHomeGantryOn = + allRobotSettings.find(({ id }) => id === HOME_GANTRY_SETTING_ID)?.value ?? + false + const robotUpdateType = useSelector((state: State) => { return localRobot != null && localRobot.status !== UNREACHABLE ? getRobotUpdateAvailable(state, localRobot) @@ -65,97 +77,117 @@ export function RobotSettingsList(props: RobotSettingsListProps): JSX.Element { const devToolsOn = useSelector(getDevtoolsEnabled) const historicOffsetsOn = useSelector(getApplyHistoricOffsets) const { lightsEnabled, toggleLights } = useLEDLights(robotName) - const settings = useSelector((state: State) => - getRobotSettings(state, robotName) - ) - const homeGantrySettings = settings?.find(s => s.id === 'disableHomeOnBoot') - return ( setCurrentOption('NetworkSettings')} iconName="wifi" /> setCurrentOption('RobotName')} iconName="flex-robot" /> setCurrentOption('RobotSystemVersion')} iconName="update" + rightElement={ + + {isUpdateAvailable ? ( + + ) : null} + + + } /> } + onClick={toggleLights} /> setCurrentOption('TouchscreenSleep')} iconName="sleep" /> setCurrentOption('TouchscreenBrightness')} iconName="brightness" /> + setCurrentOption('Privacy')} + iconName="privacy" + /> } + onClick={() => dispatch(toggleHistoricOffsets())} /> setCurrentOption('DeviceReset')} iconName="reset" /> } + onClick={() => + dispatch( + updateSetting(robotName, HOME_GANTRY_SETTING_ID, !isHomeGantryOn) + ) + } /> setCurrentOption('UpdateChannel')} iconName="update-channel" /> } + onClick={() => dispatch(toggleDevtools())} /> {devToolsOn ? : null} @@ -183,7 +215,6 @@ function FeatureFlags(): JSX.Element { justifyContent={JUSTIFY_SPACE_BETWEEN} alignItems={ALIGN_CENTER} onClick={() => { - console.log('CLICKED TOGGLE flag', flag) dispatch(toggleDevInternalFlag(flag)) }} > @@ -205,22 +236,27 @@ function FeatureFlags(): JSX.Element { - - - {Boolean(devInternalFlags?.[flag]) - ? t('shared:on') - : t('shared:off')} - - + ))} ) } + +export function OnOffToggle(props: { isOn: boolean }): JSX.Element { + const { t } = useTranslation('shared') + return ( + + + {props.isOn ? t('on') : t('off')} + + + ) +} diff --git a/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/__tests__/RobotSettingsDashboard.test.tsx b/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/__tests__/RobotSettingsDashboard.test.tsx index a0683128adf..1a8e36829a1 100644 --- a/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/__tests__/RobotSettingsDashboard.test.tsx +++ b/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/__tests__/RobotSettingsDashboard.test.tsx @@ -15,6 +15,7 @@ import { TouchScreenSleep, TouchscreenBrightness, NetworkSettings, + Privacy, RobotSystemVersion, UpdateChannel, } from '../../../../organisms/RobotSettingsDashboard' @@ -33,6 +34,7 @@ jest.mock('../../../../organisms/Navigation') jest.mock('../../../../organisms/RobotSettingsDashboard/TouchScreenSleep') jest.mock('../../../../organisms/RobotSettingsDashboard/NetworkSettings') jest.mock('../../../../organisms/RobotSettingsDashboard/DeviceReset') +jest.mock('../../../../organisms/RobotSettingsDashboard/Privacy') jest.mock('../../../../organisms/RobotSettingsDashboard/RobotSystemVersion') jest.mock('../../../../organisms/RobotSettingsDashboard/TouchscreenBrightness') jest.mock('../../../../organisms/RobotSettingsDashboard/UpdateChannel') @@ -60,6 +62,7 @@ const mockNetworkSettings = NetworkSettings as jest.MockedFunction< typeof NetworkSettings > const mockDeviceReset = DeviceReset as jest.MockedFunction +const mockPrivacy = Privacy as jest.MockedFunction const mockRobotSystemVersion = RobotSystemVersion as jest.MockedFunction< typeof RobotSystemVersion > @@ -98,6 +101,7 @@ describe('RobotSettingsDashboard', () => { mockTouchScreenSleep.mockReturnValue(
    Mock Touchscreen Sleep
    ) mockNetworkSettings.mockReturnValue(
    Mock Network Settings
    ) mockDeviceReset.mockReturnValue(
    Mock Device Reset
    ) + mockPrivacy.mockReturnValue(
    Mock Privacy
    ) mockRobotSystemVersion.mockReturnValue(
    Mock Robot System Version
    ) mockGetRobotSettings.mockReturnValue([ { @@ -125,7 +129,7 @@ describe('RobotSettingsDashboard', () => { }) it('should render setting buttons', () => { - const [{ getByText, getAllByText }] = render() + const [{ getByText }] = render() getByText('Robot Name') getByText('opentrons-robot-name') getByText('Robot System Version') @@ -134,16 +138,16 @@ describe('RobotSettingsDashboard', () => { getByText('Control the strip of color lights on the front of the robot.') getByText('Touchscreen Sleep') getByText('Touchscreen Brightness') + getByText('Privacy') + getByText('Choose what data to share with Opentrons.') getByText('Device Reset') getByText('Update Channel') getByText('Apply Labware Offsets') getByText('Use stored data when setting up a protocol.') - getByText('Developer Tools') + getByText('Enable Developer Tools') getByText('Access additional logging and feature flags.') - expect(getAllByText('Off').length).toBe(3) // LED & DEV tools & historic offsets }) - // Note(kj: 02/03/2023) This case will be changed in a following PR it('should render component when tapping robot name button', () => { const [{ getByText }] = render() const button = getByText('Robot Name') @@ -171,7 +175,9 @@ describe('RobotSettingsDashboard', () => { toggleLights: mockToggleLights, }) const [{ getByTestId }] = render() - expect(getByTestId('RobotSettingButton_LED_Lights')).toHaveTextContent('On') + expect( + getByTestId('RobotSettingButton_display_led_lights') + ).toHaveTextContent('On') }) it('should render component when tapping network settings', () => { @@ -195,6 +201,13 @@ describe('RobotSettingsDashboard', () => { getByText('Mock Touchscreen Brightness') }) + it('should render component when tapping privacy', () => { + const [{ getByText }] = render() + const button = getByText('Privacy') + fireEvent.click(button) + getByText('Mock Privacy') + }) + it('should render component when tapping device rest', () => { const [{ getByText }] = render() const button = getByText('Device Reset') @@ -209,16 +222,7 @@ describe('RobotSettingsDashboard', () => { getByText('Mock Update Channel') }) - it('should call a mock function when tapping home gantry on restart', () => { - const [{ getByText, getByTestId }] = render() - getByText('Home gantry on restart') - getByText('By default, this setting is turned on.') - expect(getByTestId('RobotSettingButton_Home_Gantry')).toHaveTextContent( - 'On' - ) - }) - - it('should render text with home gantry off', () => { + it('should render text with home gantry off', () => { mockGetRobotSettings.mockReturnValue([ { id: 'disableHomeOnBoot', @@ -229,9 +233,9 @@ describe('RobotSettingsDashboard', () => { }, ]) const [{ getByTestId }] = render() - expect(getByTestId('RobotSettingButton_LED_Lights')).toHaveTextContent( - 'Off' - ) + expect( + getByTestId('RobotSettingButton_home_gantry_on_restart') + ).toHaveTextContent('On') }) it('should call a mock function when tapping enable historic offset', () => { @@ -243,7 +247,7 @@ describe('RobotSettingsDashboard', () => { it('should call a mock function when tapping enable dev tools', () => { const [{ getByText }] = render() - const button = getByText('Developer Tools') + const button = getByText('Enable Developer Tools') fireEvent.click(button) expect(mockToggleDevtools).toHaveBeenCalled() }) @@ -253,12 +257,4 @@ describe('RobotSettingsDashboard', () => { const [{ getByText }] = render() getByText('Update available') }) - - // The following cases will be activate when RobotSettings PRs are ready - it.todo('should render connection status - only wifi') - it.todo('should render connection status - wifi + ethernet') - it.todo('should render connection status - wifi + usb') - it.todo('should render connection status - ethernet + usb') - it.todo('should render connection status - all connected') - it.todo('should render connection status - all not connected') }) diff --git a/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/index.tsx b/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/index.tsx index 03b0fb7714b..91f7840eb68 100644 --- a/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/index.tsx +++ b/app/src/pages/OnDeviceDisplay/RobotSettingsDashboard/index.tsx @@ -9,6 +9,7 @@ import { TouchscreenBrightness, TouchScreenSleep, NetworkSettings, + Privacy, RobotName, RobotSettingsJoinOtherNetwork, RobotSettingsSelectAuthenticationType, @@ -43,6 +44,7 @@ export type SettingOption = | 'TouchscreenSleep' | 'TouchscreenBrightness' | 'TextSize' + | 'Privacy' | 'DeviceReset' | 'UpdateChannel' | 'EthernetConnectionDetails' @@ -139,6 +141,10 @@ export function RobotSettingsDashboard(): JSX.Element { return case 'TouchscreenBrightness': return + case 'Privacy': + return ( + + ) // TODO(bh, 2023-6-9): TextSize does not appear to be active in the app yet // case 'TextSize': // return diff --git a/app/src/pages/OnDeviceDisplay/RunSummary.tsx b/app/src/pages/OnDeviceDisplay/RunSummary.tsx index a98162a868a..c2fc4f960bb 100644 --- a/app/src/pages/OnDeviceDisplay/RunSummary.tsx +++ b/app/src/pages/OnDeviceDisplay/RunSummary.tsx @@ -223,16 +223,10 @@ export function RunSummary(): JSX.Element { /> - {`${t('start')}: ${formatTimeWithUtcLabel( - startedAtTimestamp, - true - )}`} + {`${t('start')}: ${formatTimeWithUtcLabel(startedAtTimestamp)}`} - {`${t('end')}: ${formatTimeWithUtcLabel( - completedAtTimestamp, - true - )}`} + {`${t('end')}: ${formatTimeWithUtcLabel(completedAtTimestamp)}`} @@ -276,7 +270,6 @@ export function RunSummary(): JSX.Element { const SplashHeader = styled.h1` font-weight: ${TYPOGRAPHY.fontWeightBold}; text-align: ${TYPOGRAPHY.textAlignLeft}; - text-transform: ${TYPOGRAPHY.textTransformCapitalize}; font-size: 80px; line-height: 94px; color: ${COLORS.white}; diff --git a/app/src/pages/OnDeviceDisplay/RunningProtocol.tsx b/app/src/pages/OnDeviceDisplay/RunningProtocol.tsx index 2f6e3069214..d00bc29de40 100644 --- a/app/src/pages/OnDeviceDisplay/RunningProtocol.tsx +++ b/app/src/pages/OnDeviceDisplay/RunningProtocol.tsx @@ -16,6 +16,7 @@ import { useSwipe, } from '@opentrons/components' import { + useAllCommandsQuery, useProtocolQuery, useRunQuery, useRunActionMutations, @@ -44,7 +45,6 @@ import { CancelingRunModal } from '../../organisms/OnDeviceDisplay/RunningProtoc import { ConfirmCancelRunModal } from '../../organisms/OnDeviceDisplay/RunningProtocol/ConfirmCancelRunModal' import { getLocalRobot } from '../../redux/discovery' -import type { RunTimeCommand } from '@opentrons/shared-data' import type { OnDeviceRouteParams } from '../../App/types' const RUN_STATUS_REFETCH_INTERVAL = 5000 @@ -122,26 +122,28 @@ export function RunningProtocol(): JSX.Element { } }, [currentOption, swipe, swipe.setSwipeType]) - const currentCommand = robotSideAnalysis?.commands.find( - (c: RunTimeCommand, index: number) => index === currentRunCommandIndex - ) + const { data: allCommandsQueryData } = useAllCommandsQuery(runId, { + cursor: null, + pageLength: 1, + }) + const lastRunCommand = allCommandsQueryData?.data[0] ?? null React.useEffect(() => { if ( - currentCommand != null && + lastRunCommand != null && interventionModalCommandKey != null && - currentCommand.key !== interventionModalCommandKey + lastRunCommand.key !== interventionModalCommandKey ) { // set intervention modal command key to null if different from current command key setInterventionModalCommandKey(null) } else if ( - currentCommand?.key != null && - isInterventionCommand(currentCommand) && + lastRunCommand?.key != null && + isInterventionCommand(lastRunCommand) && interventionModalCommandKey === null ) { - setInterventionModalCommandKey(currentCommand.key) + setInterventionModalCommandKey(lastRunCommand.key) } - }, [currentCommand, interventionModalCommandKey]) + }, [lastRunCommand, interventionModalCommandKey]) return ( <> @@ -170,10 +172,11 @@ export function RunningProtocol(): JSX.Element { ) : null} {interventionModalCommandKey != null && runRecord?.data != null && - currentCommand != null ? ( + lastRunCommand != null && + isInterventionCommand(lastRunCommand) ? ( { } }) -const mockSettings = { - sleepMs: 0, - brightness: 1, - textSize: 1, - unfinishedUnboxingFlowRoute: '/robot-settings/rename-robot', -} as OnDeviceDisplaySettings - const mockGetConnectableRobots = getConnectableRobots as jest.MockedFunction< typeof getConnectableRobots > @@ -49,8 +41,8 @@ const mockGetReachableRobots = getReachableRobots as jest.MockedFunction< const mockUseTrackEvent = useTrackEvent as jest.MockedFunction< typeof useTrackEvent > -const mockGetOnDeviceDisplaySettings = getOnDeviceDisplaySettings as jest.MockedFunction< - typeof getOnDeviceDisplaySettings +const mockuseIsUnboxingFlowOngoing = useIsUnboxingFlowOngoing as jest.MockedFunction< + typeof useIsUnboxingFlowOngoing > let mockTrackEvent: jest.Mock @@ -71,7 +63,7 @@ describe('NameRobot', () => { mockReachableRobot.name = 'reachableOtie' mockGetConnectableRobots.mockReturnValue([mockConnectableRobot]) mockGetReachableRobots.mockReturnValue([mockReachableRobot]) - mockGetOnDeviceDisplaySettings.mockReturnValue(mockSettings) + mockuseIsUnboxingFlowOngoing.mockReturnValue(true) }) it('should render text, button and keyboard', () => { @@ -145,8 +137,7 @@ describe('NameRobot', () => { }) it('should render text and button when coming from robot settings', () => { - mockSettings.unfinishedUnboxingFlowRoute = null - mockGetOnDeviceDisplaySettings.mockReturnValue(mockSettings) + mockuseIsUnboxingFlowOngoing.mockReturnValue(false) const [{ getByText, queryByText }] = render() getByText('Rename robot') expect( @@ -157,7 +148,7 @@ describe('NameRobot', () => { }) it('should call a mock function when tapping back button', () => { - mockSettings.unfinishedUnboxingFlowRoute = null + mockuseIsUnboxingFlowOngoing.mockReturnValue(false) const [{ getByTestId }] = render() getByTestId('name_back_button').click() expect(mockPush).toHaveBeenCalledWith('/robot-settings') diff --git a/app/src/pages/OnDeviceDisplay/__tests__/NetworkSetupMenu.test.tsx b/app/src/pages/OnDeviceDisplay/__tests__/NetworkSetupMenu.test.tsx index 0462f494d5c..bc444767f27 100644 --- a/app/src/pages/OnDeviceDisplay/__tests__/NetworkSetupMenu.test.tsx +++ b/app/src/pages/OnDeviceDisplay/__tests__/NetworkSetupMenu.test.tsx @@ -31,7 +31,7 @@ describe('NetworkSetupMenu', () => { it('should render text and button, and step meter', () => { const [{ getByText }] = render() - getByText('Connect to a network') + getByText('Choose network type') getByText( 'You’ll use this connection to run software updates and load protocols onto your Opentrons Flex.' ) diff --git a/app/src/pages/OnDeviceDisplay/__tests__/RunningProtocol.test.tsx b/app/src/pages/OnDeviceDisplay/__tests__/RunningProtocol.test.tsx index 841a7f450cc..79972477a6b 100644 --- a/app/src/pages/OnDeviceDisplay/__tests__/RunningProtocol.test.tsx +++ b/app/src/pages/OnDeviceDisplay/__tests__/RunningProtocol.test.tsx @@ -10,6 +10,7 @@ import { } from '@opentrons/api-client' import { renderWithProviders } from '@opentrons/components' import { + useAllCommandsQuery, useProtocolAnalysesQuery, useProtocolQuery, useRunQuery, @@ -22,6 +23,7 @@ import { RunningProtocolCommandList, RunningProtocolSkeleton, } from '../../../organisms/OnDeviceDisplay/RunningProtocol' +import { mockUseAllCommandsResponseNonDeterministic } from '../../../organisms/RunProgressMeter/__fixtures__' import { useRunStatus, useRunTimestamps, @@ -81,6 +83,9 @@ const mockRunningProtocolSkeleton = RunningProtocolSkeleton as jest.MockedFuncti const mockCancelingRunModal = CancelingRunModal as jest.MockedFunction< typeof CancelingRunModal > +const mockUseAllCommandsQuery = useAllCommandsQuery as jest.MockedFunction< + typeof useAllCommandsQuery +> const RUN_ID = 'run_id' const PROTOCOL_ID = 'protocol_id' @@ -165,6 +170,9 @@ describe('RunningProtocol', () => {
    mock RunningProtocolSkeleton
    ) mockCancelingRunModal.mockReturnValue(
    mock CancelingRunModal
    ) + when(mockUseAllCommandsQuery) + .calledWith(RUN_ID, { cursor: null, pageLength: 1 }) + .mockReturnValue(mockUseAllCommandsResponseNonDeterministic) }) afterEach(() => { diff --git a/app/src/pages/ProtocolDashboard/ProtocolCard.tsx b/app/src/pages/ProtocolDashboard/ProtocolCard.tsx index f69da042383..cb628d59e23 100644 --- a/app/src/pages/ProtocolDashboard/ProtocolCard.tsx +++ b/app/src/pages/ProtocolDashboard/ProtocolCard.tsx @@ -19,7 +19,10 @@ import { TYPOGRAPHY, useLongPress, } from '@opentrons/components' -import { useHost, useProtocolAnalysesQuery } from '@opentrons/react-api-client' +import { + useHost, + useProtocolAnalysisAsDocumentQuery, +} from '@opentrons/react-api-client' import { deleteProtocol, deleteRun, getProtocol } from '@opentrons/api-client' import { StyledText } from '../../atoms/text' @@ -57,10 +60,15 @@ export function ProtocolCard(props: { const longpress = useLongPress() const queryClient = useQueryClient() const host = useHost() - const { data: protocolAnalyses } = useProtocolAnalysesQuery(protocol.id, { - staleTime: Infinity, - }) - const mostRecentAnalysis = last(protocolAnalyses?.data ?? []) ?? null + + const { + data: mostRecentAnalysis, + } = useProtocolAnalysisAsDocumentQuery( + protocol.id, + last(protocol.analysisSummaries)?.id ?? null, + { enabled: protocol != null } + ) + const isFailedAnalysis = (mostRecentAnalysis != null && 'result' in mostRecentAnalysis && @@ -209,6 +217,7 @@ export function ProtocolCard(props: { flexDirection={DIRECTION_COLUMN} gridGap={SPACING.spacing8} maxWidth="100%" + whiteSpace="break-spaces" > { }) jest.mock('@opentrons/react-api-client') -const mockUseProtocolAnalysesQuery = useProtocolAnalysesQuery as jest.MockedFunction< - typeof useProtocolAnalysesQuery +const mockUseProtocolAnalysisAsDocumentQuery = useProtocolAnalysisAsDocumentQuery as jest.MockedFunction< + typeof useProtocolAnalysisAsDocumentQuery > const mockProtocol: ProtocolResource = { @@ -65,9 +67,9 @@ describe('ProtocolCard', () => { jest.useFakeTimers() beforeEach(() => { - mockUseProtocolAnalysesQuery.mockReturnValue({ - data: { data: [{ result: 'ok' }] } as any, - } as UseQueryResult) + mockUseProtocolAnalysisAsDocumentQuery.mockReturnValue({ + data: { result: 'ok' } as any, + } as UseQueryResult) }) it('should redirect to protocol details after short click', () => { const [{ getByText }] = render() @@ -77,9 +79,9 @@ describe('ProtocolCard', () => { }) it('should display the analysis failed error modal when clicking on the protocol', () => { - mockUseProtocolAnalysesQuery.mockReturnValue({ - data: { data: [{ result: 'error' }] } as any, - } as UseQueryResult) + mockUseProtocolAnalysisAsDocumentQuery.mockReturnValue({ + data: { result: 'error' } as any, + } as UseQueryResult) const [{ getByText, getByLabelText, queryByText }] = render() getByLabelText('failedAnalysis_icon') getByText('Failed analysis') @@ -105,9 +107,9 @@ describe('ProtocolCard', () => { }) it('should display the analysis failed error modal when clicking on the protocol when doing a long pressing', async () => { - mockUseProtocolAnalysesQuery.mockReturnValue({ - data: { data: [{ result: 'error' }] } as any, - } as UseQueryResult) + mockUseProtocolAnalysisAsDocumentQuery.mockReturnValue({ + data: { result: 'error' } as any, + } as UseQueryResult) const [{ getByText, getByLabelText }] = render() const name = getByText('yay mock protocol') fireEvent.mouseDown(name) diff --git a/app/src/pages/Protocols/hooks/__tests__/hooks.test.tsx b/app/src/pages/Protocols/hooks/__tests__/hooks.test.tsx index c11ef452557..f0cbf0aa9dd 100644 --- a/app/src/pages/Protocols/hooks/__tests__/hooks.test.tsx +++ b/app/src/pages/Protocols/hooks/__tests__/hooks.test.tsx @@ -4,24 +4,31 @@ import { renderHook } from '@testing-library/react-hooks' import { when, resetAllWhenMocks } from 'jest-when' import { - useProtocolAnalysesQuery, + useProtocolQuery, useInstrumentsQuery, useModulesQuery, + useProtocolAnalysisAsDocumentQuery, } from '@opentrons/react-api-client' import { mockHeaterShaker } from '../../../../redux/modules/__fixtures__' import { useRequiredProtocolLabware, useMissingProtocolHardware } from '..' import fixture_tiprack_300_ul from '@opentrons/shared-data/labware/fixtures/2/fixture_tiprack_300_ul.json' -import type { ProtocolAnalyses } from '@opentrons/api-client' -import type { LabwareDefinition2 } from '@opentrons/shared-data' +import type { Protocol } from '@opentrons/api-client' +import type { + CompletedProtocolAnalysis, + LabwareDefinition2, +} from '@opentrons/shared-data' jest.mock('@opentrons/react-api-client') jest.mock('../../../../organisms/Devices/hooks') const PROTOCOL_ID = 'fake_protocol_id' -const mockUseProtocolAnalysesQuery = useProtocolAnalysesQuery as jest.MockedFunction< - typeof useProtocolAnalysesQuery +const mockUseProtocolQuery = useProtocolQuery as jest.MockedFunction< + typeof useProtocolQuery +> +const mockUseProtocolAnalysisAsDocumentQuery = useProtocolAnalysisAsDocumentQuery as jest.MockedFunction< + typeof useProtocolAnalysisAsDocumentQuery > const mockUseModulesQuery = useModulesQuery as jest.MockedFunction< typeof useModulesQuery @@ -87,16 +94,29 @@ const NULL_COMMAND = { } const NULL_PROTOCOL_ANALYSIS = { ...PROTOCOL_ANALYSIS, + id: 'null_analysis', commands: [NULL_COMMAND], } as any describe('useRequiredProtocolLabware', () => { beforeEach(() => { - when(mockUseProtocolAnalysesQuery) - .calledWith(PROTOCOL_ID, { staleTime: Infinity }) + when(mockUseProtocolQuery) + .calledWith(PROTOCOL_ID) + .mockReturnValue({ + data: { + data: { analysisSummaries: [{ id: PROTOCOL_ANALYSIS.id } as any] }, + }, + } as UseQueryResult) + when(mockUseProtocolAnalysisAsDocumentQuery) + .calledWith(PROTOCOL_ID, PROTOCOL_ANALYSIS.id, { enabled: true }) + .mockReturnValue({ + data: PROTOCOL_ANALYSIS, + } as UseQueryResult) + when(mockUseProtocolAnalysisAsDocumentQuery) + .calledWith(PROTOCOL_ID, NULL_PROTOCOL_ANALYSIS.id, { enabled: true }) .mockReturnValue({ - data: { data: [PROTOCOL_ANALYSIS as any] }, - } as UseQueryResult) + data: NULL_PROTOCOL_ANALYSIS, + } as UseQueryResult) }) afterEach(() => { @@ -114,11 +134,15 @@ describe('useRequiredProtocolLabware', () => { }) it('should return empty array when there is no match with protocol id', () => { - when(mockUseProtocolAnalysesQuery) - .calledWith(PROTOCOL_ID, { staleTime: Infinity }) + when(mockUseProtocolQuery) + .calledWith(PROTOCOL_ID) .mockReturnValue({ - data: { data: [NULL_PROTOCOL_ANALYSIS as any] }, - } as UseQueryResult) + data: { + data: { + analysisSummaries: [{ id: NULL_PROTOCOL_ANALYSIS.id } as any], + }, + }, + } as UseQueryResult) const { result } = renderHook(() => useRequiredProtocolLabware(PROTOCOL_ID)) expect(result.current.length).toBe(0) }) @@ -135,9 +159,14 @@ describe('useMissingProtocolHardware', () => { data: { data: [] }, isLoading: false, } as any) - mockUseProtocolAnalysesQuery.mockReturnValue({ - data: { data: [PROTOCOL_ANALYSIS as any] }, - } as UseQueryResult) + mockUseProtocolQuery.mockReturnValue({ + data: { + data: { analysisSummaries: [{ id: PROTOCOL_ANALYSIS.id } as any] }, + }, + } as UseQueryResult) + mockUseProtocolAnalysisAsDocumentQuery.mockReturnValue({ + data: PROTOCOL_ANALYSIS, + } as UseQueryResult) }) afterEach(() => { diff --git a/app/src/pages/Protocols/hooks/index.ts b/app/src/pages/Protocols/hooks/index.ts index 24fc92c1548..96a5164ed19 100644 --- a/app/src/pages/Protocols/hooks/index.ts +++ b/app/src/pages/Protocols/hooks/index.ts @@ -2,7 +2,8 @@ import last from 'lodash/last' import { useInstrumentsQuery, useModulesQuery, - useProtocolAnalysesQuery, + useProtocolAnalysisAsDocumentQuery, + useProtocolQuery, } from '@opentrons/react-api-client' import { getLabwareSetupItemGroups } from '../utils' @@ -50,10 +51,12 @@ export type ProtocolHardware = export const useRequiredProtocolHardware = ( protocolId: string ): { requiredProtocolHardware: ProtocolHardware[]; isLoading: boolean } => { - const { data: protocolAnalyses } = useProtocolAnalysesQuery(protocolId, { - staleTime: Infinity, - }) - const mostRecentAnalysis = last(protocolAnalyses?.data ?? []) ?? null + const { data: protocolData } = useProtocolQuery(protocolId) + const { data: analysis } = useProtocolAnalysisAsDocumentQuery( + protocolId, + last(protocolData?.data.analysisSummaries)?.id ?? null, + { enabled: protocolData != null } + ) const { data: attachedModulesData, @@ -67,16 +70,11 @@ export const useRequiredProtocolHardware = ( } = useInstrumentsQuery() const attachedInstruments = attachedInstrumentsData?.data ?? [] - if ( - mostRecentAnalysis == null || - mostRecentAnalysis?.status !== 'completed' - ) { + if (analysis == null || analysis?.status !== 'completed') { return { requiredProtocolHardware: [], isLoading: true } } - const requiredGripper: ProtocolGripper[] = getProtocolUsesGripper( - mostRecentAnalysis - ) + const requiredGripper: ProtocolGripper[] = getProtocolUsesGripper(analysis) ? [ { hardwareType: 'gripper', @@ -87,7 +85,7 @@ export const useRequiredProtocolHardware = ( ] : [] - const requiredModules: ProtocolModule[] = mostRecentAnalysis.modules.map( + const requiredModules: ProtocolModule[] = analysis.modules.map( ({ location, model }) => { return { hardwareType: 'module', @@ -99,7 +97,7 @@ export const useRequiredProtocolHardware = ( } ) - const requiredPipettes: ProtocolPipette[] = mostRecentAnalysis.pipettes.map( + const requiredPipettes: ProtocolPipette[] = analysis.pipettes.map( ({ mount, pipetteName }) => ({ hardwareType: 'pipette', pipetteName: pipetteName, @@ -134,10 +132,14 @@ export const useRequiredProtocolHardware = ( export const useRequiredProtocolLabware = ( protocolId: string ): LabwareSetupItem[] => { - const { data: protocolAnalyses } = useProtocolAnalysesQuery(protocolId, { - staleTime: Infinity, - }) - const mostRecentAnalysis = last(protocolAnalyses?.data ?? []) ?? null + const { data: protocolData } = useProtocolQuery(protocolId) + const { + data: mostRecentAnalysis, + } = useProtocolAnalysisAsDocumentQuery( + protocolId, + last(protocolData?.data.analysisSummaries)?.id ?? null, + { enabled: protocolData != null } + ) const commands = (mostRecentAnalysis as CompletedProtocolAnalysis)?.commands ?? [] const { onDeckItems, offDeckItems } = getLabwareSetupItemGroups(commands) diff --git a/app/src/pages/Protocols/utils/index.ts b/app/src/pages/Protocols/utils/index.ts index 592d7fc24c4..56f54ccd36c 100644 --- a/app/src/pages/Protocols/utils/index.ts +++ b/app/src/pages/Protocols/utils/index.ts @@ -18,6 +18,7 @@ export interface LabwareSetupItem { initialLocation: LabwareLocation moduleModel: ModuleModel | null moduleLocation: ModuleLocation | null + labwareId?: string } export interface GroupedLabwareSetupItems { @@ -88,6 +89,7 @@ export function getLabwareSetupItemGroups( moduleModel, moduleLocation, nickName, + labwareId: c.result?.labwareId, }, ] } else if ( diff --git a/app/src/redux/analytics/mixpanel.ts b/app/src/redux/analytics/mixpanel.ts index ce60e8be24e..d29d9c0c362 100644 --- a/app/src/redux/analytics/mixpanel.ts +++ b/app/src/redux/analytics/mixpanel.ts @@ -20,14 +20,14 @@ const MIXPANEL_OPTS = { track_pageview: false, } +const initMixpanelInstanceOnce = initializeMixpanelInstanceOnce(MIXPANEL_ID) + export function initializeMixpanel( config: AnalyticsConfig, isOnDevice: boolean | null ): void { if (MIXPANEL_ID) { - log.debug('Initializing Mixpanel', { config }) - - mixpanel.init(MIXPANEL_ID, MIXPANEL_OPTS) + initMixpanelInstanceOnce(config) setMixpanelTracking(config, isOnDevice) trackEvent({ name: 'appOpen', properties: {} }, config) } else { @@ -54,6 +54,7 @@ export function setMixpanelTracking( isOnDevice: boolean | null ): void { if (MIXPANEL_ID) { + initMixpanelInstanceOnce(config) if (config.optedIn) { log.debug('User has opted into analytics; tracking with Mixpanel') mixpanel.identify(config.appId) @@ -65,11 +66,22 @@ export function setMixpanelTracking( }) } else { log.debug('User has opted out of analytics; stopping tracking') - const config = mixpanel?.get_config?.() - if (config != null) { - mixpanel.opt_out_tracking?.() - mixpanel.reset?.() - } + mixpanel.opt_out_tracking?.() + mixpanel.reset?.() + } + } +} + +function initializeMixpanelInstanceOnce( + MIXPANEL_ID?: string +): (config: AnalyticsConfig) => undefined { + let hasBeenInitialized = false + + return function (config: AnalyticsConfig): undefined { + if (!hasBeenInitialized && MIXPANEL_ID) { + hasBeenInitialized = true + log.debug('Initializing Mixpanel', { config }) + return mixpanel.init(MIXPANEL_ID, MIXPANEL_OPTS) } } } diff --git a/app/src/redux/analytics/types.ts b/app/src/redux/analytics/types.ts index 8bd8c3fb208..d5b96a2dd8c 100644 --- a/app/src/redux/analytics/types.ts +++ b/app/src/redux/analytics/types.ts @@ -1,4 +1,3 @@ -import type { Config } from '../config/types' import { ANALYTICS_PIPETTE_OFFSET_STARTED, ANALYTICS_TIP_LENGTH_STARTED, @@ -7,8 +6,9 @@ import { import type { CalibrationCheckComparisonsPerCalibration } from '../sessions/types' import type { DeckCalibrationStatus } from '../calibration/types' import type { Mount } from '@opentrons/components' +import type { ConfigV0 } from '../config/types' -export type AnalyticsConfig = Config['analytics'] +export type AnalyticsConfig = ConfigV0['analytics'] export interface ProtocolAnalyticsData { protocolType: string diff --git a/app/src/redux/config/__tests__/selectors.test.ts b/app/src/redux/config/__tests__/selectors.test.ts index f752098f4c4..0c79c83a316 100644 --- a/app/src/redux/config/__tests__/selectors.test.ts +++ b/app/src/redux/config/__tests__/selectors.test.ts @@ -43,6 +43,20 @@ describe('shell selectors', () => { }) }) + describe('getHasJustUpdated', () => { + it('should return false if config is unknown', () => { + const state: State = { config: null } as any + expect(Selectors.getHasJustUpdated(state)).toEqual(false) + }) + + it('should return config.update.hasJustUpdated if config is known', () => { + const state: State = { + config: { update: { hasJustUpdated: false } }, + } as any + expect(Selectors.getHasJustUpdated(state)).toEqual(false) + }) + }) + describe('getUpdateChannelOptions', () => { it('should return "latest" and "beta" options if config is unknown', () => { const state: State = { config: null } as any diff --git a/app/src/redux/config/constants.ts b/app/src/redux/config/constants.ts index 02852534fed..36b28bf8387 100644 --- a/app/src/redux/config/constants.ts +++ b/app/src/redux/config/constants.ts @@ -5,7 +5,6 @@ export const CONFIG_VERSION_LATEST: 1 = 1 export const DEV_INTERNAL_FLAGS: DevInternalFlag[] = [ 'enableExtendedHardware', 'lpcWithProbe', - 'enableModuleCalibration', ] // action type constants diff --git a/app/src/redux/config/schema-types.ts b/app/src/redux/config/schema-types.ts index 339b159477b..fe2230a5fcc 100644 --- a/app/src/redux/config/schema-types.ts +++ b/app/src/redux/config/schema-types.ts @@ -7,10 +7,7 @@ export type UpdateChannel = 'latest' | 'beta' | 'alpha' export type DiscoveryCandidates = string[] -export type DevInternalFlag = - | 'enableExtendedHardware' - | 'lpcWithProbe' - | 'enableModuleCalibration' +export type DevInternalFlag = 'enableExtendedHardware' | 'lpcWithProbe' export type FeatureFlags = Partial> @@ -99,7 +96,7 @@ export interface ConfigV0 { devInternal?: FeatureFlags } -export interface ConfigV1 extends Omit { +export type ConfigV1 = Omit & { version: 1 discovery: { candidates: DiscoveryCandidates @@ -107,15 +104,14 @@ export interface ConfigV1 extends Omit { } } -export interface ConfigV2 extends Omit { +export type ConfigV2 = Omit & { version: 2 calibration: { useTrashSurfaceForTipCal: boolean | null } } -// v3 config changes default values but does not change schema -export interface ConfigV3 extends Omit { +export type ConfigV3 = Omit & { version: 3 support: ConfigV2['support'] & { name: string | null @@ -123,77 +119,77 @@ export interface ConfigV3 extends Omit { } } -export interface ConfigV4 extends Omit { +export type ConfigV4 = Omit & { version: 4 labware: ConfigV3['labware'] & { showLabwareOffsetCodeSnippets: boolean } } -export interface ConfigV5 extends Omit { +export type ConfigV5 = Omit & { version: 5 python: { pathToPythonOverride: string | null } } -export interface ConfigV6 extends Omit { +export type ConfigV6 = Omit & { version: 6 modules: { heaterShaker: { isAttached: boolean } } } -export interface ConfigV7 extends Omit { +export type ConfigV7 = Omit & { version: 7 ui: ConfigV6['ui'] & { minWidth: number } } -export interface ConfigV8 extends Omit { +export type ConfigV8 = Omit & { version: 8 } -export interface ConfigV9 extends Omit { +export type ConfigV9 = Omit & { version: 9 isOnDevice: boolean } -export interface ConfigV10 extends Omit { +export type ConfigV10 = Omit & { version: 10 protocols: { sendAllProtocolsToOT3: boolean } } -export interface ConfigV11 extends Omit { +export type ConfigV11 = Omit & { version: 11 protocols: ConfigV10['protocols'] & { protocolsStoredSortKey: ProtocolSort | null } } -export interface ConfigV12 extends Omit { +export type ConfigV12 = Omit & { version: 12 robotSystemUpdate: { manifestUrls: { OT2: string; OT3: string } } } -export interface ConfigV13 extends Omit { +export type ConfigV13 = Omit & { version: 13 protocols: ConfigV12['protocols'] & { protocolsOnDeviceSortKey: ProtocolsOnDeviceSortKey | null } } -export interface ConfigV14 extends Omit { +export type ConfigV14 = Omit & { version: 14 protocols: ConfigV13['protocols'] & { pinnedProtocolIds: string[] } } -export interface ConfigV15 extends Omit { +export type ConfigV15 = Omit & { version: 15 onDeviceDisplaySettings: { sleepMs: number @@ -202,23 +198,32 @@ export interface ConfigV15 extends Omit { } } -export interface ConfigV16 extends Omit { +export type ConfigV16 = Omit & { version: 16 onDeviceDisplaySettings: ConfigV15['onDeviceDisplaySettings'] & { unfinishedUnboxingFlowRoute: string | null } } -export interface ConfigV17 extends Omit { +export type ConfigV17 = Omit & { version: 17 protocols: ConfigV15['protocols'] & { applyHistoricOffsets: boolean } } -export interface ConfigV18 - extends Omit { +export type ConfigV18 = Omit & { version: 18 } -export type Config = ConfigV18 +export type ConfigV19 = Omit & { + version: 19 + devtools: boolean + reinstallDevtools: boolean + update: { + channel: UpdateChannel + hasJustUpdated: boolean + } +} + +export type Config = ConfigV19 diff --git a/app/src/redux/config/selectors.ts b/app/src/redux/config/selectors.ts index f42e2952f20..007d474f7d1 100644 --- a/app/src/redux/config/selectors.ts +++ b/app/src/redux/config/selectors.ts @@ -32,6 +32,10 @@ export const getUpdateChannel = (state: State): UpdateChannel => { return state.config?.update.channel ?? 'latest' } +export const getHasJustUpdated = (state: State): boolean => { + return state.config?.update.hasJustUpdated ?? false +} + export const getUseTrashSurfaceForTipCal = (state: State): boolean | null => { return state.config?.calibration.useTrashSurfaceForTipCal ?? null } diff --git a/app/src/redux/shell/__tests__/update.test.ts b/app/src/redux/shell/__tests__/update.test.ts index 1d7916830b5..080be9cb79b 100644 --- a/app/src/redux/shell/__tests__/update.test.ts +++ b/app/src/redux/shell/__tests__/update.test.ts @@ -23,7 +23,10 @@ describe('shell/update', () => { name: 'shell:DOWNLOAD_UPDATE', creator: ShellUpdate.downloadShellUpdate, args: [], - expected: { type: 'shell:DOWNLOAD_UPDATE', meta: { shell: true } }, + expected: { + type: 'shell:DOWNLOAD_UPDATE', + meta: { shell: true }, + }, }, { name: 'shell:APPLY_UPDATE', diff --git a/app/src/redux/shell/index.ts b/app/src/redux/shell/index.ts index 1d2795a8dbc..5a918f75eb3 100644 --- a/app/src/redux/shell/index.ts +++ b/app/src/redux/shell/index.ts @@ -2,8 +2,6 @@ export * from './actions' export * from './update' -export * from './robot-logs/actions' -export * from './robot-logs/selectors' export * from './is-ready/actions' export * from './is-ready/selectors' diff --git a/app/src/redux/shell/reducer.ts b/app/src/redux/shell/reducer.ts index ad3a49983cf..79ead262f46 100644 --- a/app/src/redux/shell/reducer.ts +++ b/app/src/redux/shell/reducer.ts @@ -1,6 +1,5 @@ import { combineReducers } from 'redux' -import { robotLogsReducer } from './robot-logs/reducer' import { robotSystemReducer } from './is-ready/reducer' import type { Action } from '../types' @@ -11,6 +10,7 @@ const INITIAL_STATE: ShellUpdateState = { downloading: false, available: false, downloaded: false, + downloadPercentage: 0, info: null, error: null, } @@ -38,7 +38,13 @@ export function shellUpdateReducer( ...state, downloading: false, error: action.payload.error || null, - downloaded: !action.payload.error, + downloaded: action.payload.error == null, + } + } + case 'shell:DOWNLOAD_PERCENTAGE': { + return { + ...state, + downloadPercentage: action.payload.percent, } } } @@ -48,6 +54,5 @@ export function shellUpdateReducer( // TODO: (sa 2021-15-18: remove any typed state in combineReducers) export const shellReducer = combineReducers({ update: shellUpdateReducer, - robotLogs: robotLogsReducer, isReady: robotSystemReducer, }) diff --git a/app/src/redux/shell/robot-logs/actions.ts b/app/src/redux/shell/robot-logs/actions.ts deleted file mode 100644 index 12f5ea47959..00000000000 --- a/app/src/redux/shell/robot-logs/actions.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ThunkAction } from '../../types' -import type { ViewableRobot } from '../../discovery/types' - -export function downloadLogs(robot: ViewableRobot): ThunkAction { - return (dispatch, getState) => { - const logPaths = robot.health && robot.health.logs - - if (logPaths) { - const logUrls = logPaths.map(p => `http://${robot.ip}:${robot.port}${p}`) - - dispatch({ - type: 'shell:DOWNLOAD_LOGS', - payload: { logUrls }, - meta: { shell: true }, - }) - } - } -} diff --git a/app/src/redux/shell/robot-logs/reducer.ts b/app/src/redux/shell/robot-logs/reducer.ts deleted file mode 100644 index c5a5294fd2f..00000000000 --- a/app/src/redux/shell/robot-logs/reducer.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Action } from '../../types' -import type { RobotLogsState } from './types' - -const INITIAL_STATE: RobotLogsState = { downloading: false } - -export function robotLogsReducer( - state: RobotLogsState = INITIAL_STATE, - action: Action -): RobotLogsState { - switch (action.type) { - case 'shell:DOWNLOAD_LOGS': { - return { ...state, downloading: true } - } - - case 'shell:DOWNLOAD_LOGS_DONE': { - return { ...state, downloading: false } - } - } - - return state -} diff --git a/app/src/redux/shell/robot-logs/selectors.ts b/app/src/redux/shell/robot-logs/selectors.ts deleted file mode 100644 index 3ce85180c0e..00000000000 --- a/app/src/redux/shell/robot-logs/selectors.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { State } from '../../types' - -export function getRobotLogsDownloading(state: State): boolean { - return state.shell.robotLogs.downloading -} diff --git a/app/src/redux/shell/robot-logs/types.ts b/app/src/redux/shell/robot-logs/types.ts deleted file mode 100644 index c17bbaa91ab..00000000000 --- a/app/src/redux/shell/robot-logs/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type RobotLogsState = Readonly<{ - downloading: boolean -}> - -export type RobotLogsAction = - | { - type: 'shell:DOWNLOAD_LOGS' - payload: { logUrls: string[] } - meta: { shell: true } - } - | { type: 'shell:DOWNLOAD_LOGS_DONE' } diff --git a/app/src/redux/shell/types.ts b/app/src/redux/shell/types.ts index e561b6eedd7..f9833a8aedc 100644 --- a/app/src/redux/shell/types.ts +++ b/app/src/redux/shell/types.ts @@ -1,5 +1,4 @@ import type { Error } from '../types' -import type { RobotLogsState, RobotLogsAction } from './robot-logs/types' import type { RobotSystemAction } from './is-ready/types' export interface Remote { @@ -26,6 +25,7 @@ export interface ShellUpdateState { downloading: boolean available: boolean downloaded: boolean + downloadPercentage: number error: Error | null | undefined info: UpdateInfo | null | undefined } @@ -39,10 +39,10 @@ export type ShellUpdateAction = | { type: 'shell:DOWNLOAD_UPDATE'; meta: { shell: true } } | { type: 'shell:DOWNLOAD_UPDATE_RESULT'; payload: { error?: Error } } | { type: 'shell:APPLY_UPDATE'; meta: { shell: true } } + | { type: 'shell:DOWNLOAD_PERCENTAGE'; payload: { percent: number } } export interface ShellState { update: ShellUpdateState - robotLogs: RobotLogsState isReady: boolean } @@ -82,7 +82,6 @@ export interface UpdateBrightnessAction { export type ShellAction = | UiInitializedAction | ShellUpdateAction - | RobotLogsAction | RobotSystemAction | UsbRequestsAction | AppRestartAction diff --git a/app/src/resources/runs/__tests__/util.test.ts b/app/src/resources/runs/__tests__/util.test.ts index e3ea85a558b..14afde92a49 100644 --- a/app/src/resources/runs/__tests__/util.test.ts +++ b/app/src/resources/runs/__tests__/util.test.ts @@ -11,7 +11,12 @@ describe('formatTimeWithUtc', () => { }) it('return formatted time with UTC only hh:mm', () => { - const result = formatTimeWithUtcLabel('21:35:04', true) + const result = formatTimeWithUtcLabel('21:35:04') expect(result).toEqual('21:35:04 UTC') }) + + it('return unknown if time is null', () => { + const result = formatTimeWithUtcLabel(null) + expect(result).toEqual('unknown') + }) }) diff --git a/app/src/resources/runs/hooks.ts b/app/src/resources/runs/hooks.ts index c2079f9112b..b52f185a620 100644 --- a/app/src/resources/runs/hooks.ts +++ b/app/src/resources/runs/hooks.ts @@ -1,13 +1,23 @@ import * as React from 'react' +import { useSelector } from 'react-redux' +import type { CreateCommand } from '@opentrons/shared-data' +import type { HostConfig } from '@opentrons/api-client' import { useCreateCommandMutation, useCreateMaintenanceCommandMutation, + useCreateMaintenanceRunMutation, } from '@opentrons/react-api-client' import { chainRunCommandsRecursive, chainMaintenanceCommandsRecursive, } from './utils' -import type { CreateCommand } from '@opentrons/shared-data' +import { getIsOnDevice } from '../../redux/config' +import { useMaintenanceRunTakeover } from '../../organisms/TakeoverModal' +import type { + UseCreateMaintenanceRunMutationOptions, + UseCreateMaintenanceRunMutationResult, + CreateMaintenanceRunType, +} from '@opentrons/react-api-client' export type CreateCommandMutate = ReturnType< typeof useCreateCommandMutation @@ -89,3 +99,32 @@ export function useChainMaintenanceCommands(): { isCommandMutationLoading: isLoading, } } + +type CreateTargetedMaintenanceRunMutation = UseCreateMaintenanceRunMutationResult & { + createTargetedMaintenanceRun: CreateMaintenanceRunType +} + +export function useCreateTargetedMaintenanceRunMutation( + options: UseCreateMaintenanceRunMutationOptions = {}, + hostOverride?: HostConfig | null +): CreateTargetedMaintenanceRunMutation { + const createMaintenanceRunMutation = useCreateMaintenanceRunMutation( + options, + hostOverride + ) + const isOnDevice = useSelector(getIsOnDevice) + const { setOddRunIds } = useMaintenanceRunTakeover() + + return { + ...createMaintenanceRunMutation, + createTargetedMaintenanceRun: (variables, ...options) => + createMaintenanceRunMutation + .createMaintenanceRun(variables, ...options) + .then(res => { + if (isOnDevice) + setOddRunIds({ currentRunId: res.data.id, oddRunId: res.data.id }) + return res + }) + .catch(error => error), + } +} diff --git a/app/src/resources/runs/utils.ts b/app/src/resources/runs/utils.ts index 7ab3aa5444c..8bf5061d40f 100644 --- a/app/src/resources/runs/utils.ts +++ b/app/src/resources/runs/utils.ts @@ -87,14 +87,14 @@ export const chainMaintenanceCommandsRecursive = ( }) } -export const formatTimeWithUtcLabel = ( - time: string, - noFormat?: boolean -): string => { - const UTC_LABEL = 'UTC' - const TIME_FORMAT = 'M/d/yy HH:mm' +const dateIsValid = (date: string): boolean => { + return !isNaN(new Date(date).getTime()) +} - return noFormat - ? `${time} ${UTC_LABEL}` - : `${format(new Date(time), TIME_FORMAT)} ${UTC_LABEL}` +export const formatTimeWithUtcLabel = (time: string | null): string => { + const UTC_LABEL = 'UTC' + if (time == null) return 'unknown' + return typeof time === 'string' && dateIsValid(time) + ? `${format(new Date(time), 'M/d/yy HH:mm')} ${UTC_LABEL}` + : `${time} ${UTC_LABEL}` } diff --git a/app/src/styles.global.css b/app/src/styles.global.css index 9701c68fa12..9cdcb703387 100644 --- a/app/src/styles.global.css +++ b/app/src/styles.global.css @@ -3,4 +3,6 @@ */ @import '../../node_modules/react-simple-keyboard/build/css/index.css'; -@import './atoms/SoftwareKeyboard/index.css'; +@import './atoms/SoftwareKeyboard/CustomKeyboard/index.css'; +@import './atoms/SoftwareKeyboard/NormalKeyboard/index.css'; +@import './atoms/SoftwareKeyboard/Numpad/index.css'; diff --git a/components/src/__tests__/__snapshots__/icons.test.tsx.snap b/components/src/__tests__/__snapshots__/icons.test.tsx.snap index 81f103ebe18..e61d81cc8a5 100644 --- a/components/src/__tests__/__snapshots__/icons.test.tsx.snap +++ b/components/src/__tests__/__snapshots__/icons.test.tsx.snap @@ -2925,6 +2925,30 @@ exports[`icons plus renders correctly 1`] = ` `; +exports[`icons privacy renders correctly 1`] = ` +.c0.spin { + -webkit-animation: GLFYz 0.8s steps(8) infinite; + animation: GLFYz 0.8s steps(8) infinite; + -webkit-transform-origin: center; + -ms-transform-origin: center; + transform-origin: center; +} + + +`; + exports[`icons question-mark-circle renders correctly 1`] = ` .c0.spin { -webkit-animation: GLFYz 0.8s steps(8) infinite; diff --git a/components/src/hardware-sim/DeckSlotLocation/index.tsx b/components/src/hardware-sim/DeckSlotLocation/index.tsx index 68302703427..c098128210d 100644 --- a/components/src/hardware-sim/DeckSlotLocation/index.tsx +++ b/components/src/hardware-sim/DeckSlotLocation/index.tsx @@ -1,18 +1,28 @@ import * as React from 'react' import { DeckDefinition, DeckSlot, ModuleType } from '@opentrons/shared-data' +// TODO(BC, 8/2/2023): as soon as the deck definitions have a concept of base locations, use their +// identity to key this mapping instead of slotNames interface DeckSlotLocationProps extends React.SVGProps { slotName: DeckSlot['id'] deckDefinition: DeckDefinition moduleType?: ModuleType slotBaseColor?: React.SVGProps['fill'] slotClipColor?: React.SVGProps['stroke'] + showExtensions?: boolean } export function DeckSlotLocation( props: DeckSlotLocationProps ): JSX.Element | null { - const { slotName, deckDefinition, slotBaseColor, slotClipColor } = props + const { + slotName, + deckDefinition, + slotBaseColor, + slotClipColor, + showExtensions = false, + ...restProps + } = props const slotDef = deckDefinition?.locations.orderedSlots.find( s => s.id === slotName @@ -27,10 +37,12 @@ export function DeckSlotLocation( const contentsBySlotName: { [slotName: string]: JSX.Element } = { A1: ( <> - + {showExtensions ? ( + + ) : null} , @@ -56,7 +68,7 @@ export function DeckSlotLocation( A3: ( <> , @@ -68,7 +80,7 @@ export function DeckSlotLocation( B1: ( <> , @@ -80,7 +92,7 @@ export function DeckSlotLocation( B2: ( <> , @@ -92,7 +104,7 @@ export function DeckSlotLocation( B3: ( <> , @@ -104,7 +116,7 @@ export function DeckSlotLocation( C1: ( <> @@ -116,7 +128,7 @@ export function DeckSlotLocation( C2: ( <> , @@ -128,7 +140,7 @@ export function DeckSlotLocation( C3: ( <> , @@ -141,7 +153,7 @@ export function DeckSlotLocation( <> @@ -153,7 +165,7 @@ export function DeckSlotLocation( <> @@ -164,7 +176,7 @@ export function DeckSlotLocation( D3: ( <> @@ -175,7 +187,7 @@ export function DeckSlotLocation( ), } - return {contentsBySlotName[slotName]} + return {contentsBySlotName[slotName]} } function SlotBase(props: React.SVGProps): JSX.Element { diff --git a/components/src/hooks/index.ts b/components/src/hooks/index.ts index a56ef462c82..8808efac6b5 100644 --- a/components/src/hooks/index.ts +++ b/components/src/hooks/index.ts @@ -8,6 +8,7 @@ export * from './useLongPress' export * from './useMountEffect' export * from './usePrevious' export * from './useScrolling' +export * from './useSelectDeckLocation' export * from './useSwipe' export * from './useTimeout' export * from './useToggle' diff --git a/components/src/hooks/useSelectDeckLocation/SelectDeckLocation.stories.tsx b/components/src/hooks/useSelectDeckLocation/SelectDeckLocation.stories.tsx new file mode 100644 index 00000000000..e93640e3f5b --- /dev/null +++ b/components/src/hooks/useSelectDeckLocation/SelectDeckLocation.stories.tsx @@ -0,0 +1,39 @@ +import * as React from 'react' +import { DeckLocationSelect as DeckLocationSelectComponent } from './' +import { + FLEX_ROBOT_TYPE, + getDeckDefFromRobotType, +} from '@opentrons/shared-data' + +import type { Meta, StoryObj } from '@storybook/react' + +const meta: Meta> = { + component: DeckLocationSelectComponent, + title: 'Library/Molecules/Simulation/SelectDeckLocation', +} as Meta + +export default meta +type Story = StoryObj<{ disabledSlotNames: string[] }> + +export const DeckLocationSelect: Story = { + render: args => { + return + }, + args: { + disabledSlotNames: ['A2'], + }, +} + +function Wrapper(props: { disabledSlotNames: string[] }): JSX.Element { + const [selectedLocation, setSelectedLocation] = React.useState({ + slotName: 'A1', + }) + + const deckDef = getDeckDefFromRobotType(FLEX_ROBOT_TYPE) + return ( + ({ slotName: s }))} + /> + ) +} diff --git a/components/src/hooks/useSelectDeckLocation/index.tsx b/components/src/hooks/useSelectDeckLocation/index.tsx new file mode 100644 index 00000000000..dd89082aa90 --- /dev/null +++ b/components/src/hooks/useSelectDeckLocation/index.tsx @@ -0,0 +1,102 @@ +import * as React from 'react' +import isEqual from 'lodash/isEqual' +import { DeckDefinition, getDeckDefFromRobotType } from '@opentrons/shared-data' +import { RobotCoordinateSpace } from '../../hardware-sim/RobotCoordinateSpace' + +import type { ModuleLocation, RobotType } from '@opentrons/shared-data' +import { COLORS, SPACING } from '../../ui-style-constants' +import { RobotCoordsForeignDiv, SlotLabels } from '../../hardware-sim' +import { Icon } from '../../icons' +import { Text } from '../../primitives' +import { ALIGN_CENTER, JUSTIFY_CENTER } from '../../styles' +import { DeckSlotLocation } from '../../hardware-sim/DeckSlotLocation' + +const X_CROP_MM = 60 +export function useDeckLocationSelect( + robotType: RobotType +): { DeckLocationSelect: JSX.Element; selectedLocation: ModuleLocation } { + const deckDef = getDeckDefFromRobotType(robotType) + const [ + selectedLocation, + setSelectedLocation, + ] = React.useState({ + slotName: deckDef.locations.orderedSlots[0].id, + }) + return { + DeckLocationSelect: ( + + ), + selectedLocation, + } +} + +interface DeckLocationSelectProps { + deckDef: DeckDefinition + selectedLocation: ModuleLocation + setSelectedLocation: (loc: ModuleLocation) => void + disabledLocations?: ModuleLocation[] +} +export function DeckLocationSelect({ + deckDef, + selectedLocation, + setSelectedLocation, + disabledLocations = [], +}: DeckLocationSelectProps): JSX.Element { + return ( + + {deckDef.locations.orderedSlots.map(slot => { + const slotLocation = { slotName: slot.id } + const isDisabled = disabledLocations.some( + l => + typeof l === 'object' && 'slotName' in l && l.slotName === slot.id + ) + const isSelected = isEqual(selectedLocation, slotLocation) + let fill = COLORS.highlightPurple2 + if (isSelected) fill = COLORS.highlightPurple1 + if (isDisabled) fill = COLORS.darkGreyDisabled + return ( + + !isDisabled && setSelectedLocation(slotLocation)} + cursor={isDisabled || isSelected ? 'default' : 'pointer'} + deckDefinition={deckDef} + /> + {isSelected ? ( + + + + Selected + + + ) : null} + + ) + })} + + + ) +} diff --git a/components/src/icons/icon-data.ts b/components/src/icons/icon-data.ts index 06038eed638..17786af0323 100644 --- a/components/src/icons/icon-data.ts +++ b/components/src/icons/icon-data.ts @@ -582,6 +582,11 @@ export const ICON_DATA_BY_NAME = { path: 'M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z', viewBox: '0 0 24 24', }, + privacy: { + path: + 'M14.5 30.8002H17.5V17.9502H14.5V30.8002ZM16 14.6502C16.4817 14.6502 16.8854 14.4873 17.2113 14.1614C17.5371 13.8356 17.7 13.4319 17.7 12.9502C17.7 12.4685 17.5371 12.0648 17.2113 11.7389C16.8854 11.4131 16.4817 11.2502 16 11.2502C15.5183 11.2502 15.1146 11.4131 14.7887 11.7389C14.4629 12.0648 14.3 12.4685 14.3 12.9502C14.3 13.4319 14.4629 13.8356 14.7887 14.1614C15.1146 14.4873 15.5183 14.6502 16 14.6502ZM16 40.9502C11.3333 39.7835 7.5 37.0752 4.5 32.8252C1.5 28.5752 0 23.9169 0 18.8502V6.9502L16 0.950195L32 6.9502V18.8502C32 23.9169 30.5 28.5752 27.5 32.8252C24.5 37.0752 20.6667 39.7835 16 40.9502ZM16 37.8502C19.8333 36.5835 22.9583 34.1919 25.375 30.6752C27.7917 27.1585 29 23.2169 29 18.8502V9.0502L16 4.1502L3 9.0502V18.8502C3 23.2169 4.20833 27.1585 6.625 30.6752C9.04167 34.1919 12.1667 36.5835 16 37.8502Z', + viewBox: '0 0 32 41', + }, 'question-mark-circle': { path: 'M20 10C20 15.5228 15.5228 20 10 20C4.47715 20 0 15.5228 0 10C0 4.47715 4.47715 0 10 0C15.5228 0 20 4.47715 20 10ZM10 12.6923C10.6212 12.6923 11.125 13.209 11.125 13.8461C11.125 14.4833 10.6212 15 10 15C9.37879 15 8.875 14.4833 8.875 13.8461C8.875 13.209 9.37879 12.6923 10 12.6923ZM7.87898 5.89468C7.34803 6.43507 7.0365 7.15355 7.00316 7.91734L7.00357 7.91741L7.00016 7.99833C6.99832 8.04296 7.0125 8.08204 7.04263 8.11458C7.07248 8.14713 7.10998 8.16393 7.15381 8.16483L8.76193 8.19294C8.84503 8.19433 8.91449 8.12971 8.92063 8.04532L8.92608 7.97112C8.94522 7.70905 9.056 7.46447 9.23848 7.27867C9.44128 7.07227 9.71348 6.95761 10.0003 6.95761C10.2872 6.95761 10.5591 7.07219 10.7621 7.27867C10.9649 7.48515 11.0775 7.76227 11.0775 8.05433C11.0775 8.2529 11.0277 8.4473 10.9274 8.61797C10.827 8.78891 10.6826 8.93028 10.5102 9.02419C10.0639 9.2676 9.69055 9.6194 9.43022 10.0623C9.17009 10.5043 9.03901 11.0044 9.03901 11.5197V11.7636C9.03901 11.8515 9.10935 11.9231 9.19545 11.9231H10.8049C10.891 11.9231 10.9614 11.8515 10.9614 11.7636V11.5197C10.9614 11.3609 10.9952 11.2021 11.076 11.0649C11.1566 10.9274 11.275 10.8209 11.4129 10.7456C11.8917 10.4845 12.2939 10.095 12.5729 9.62072C12.8522 9.1462 13 8.6077 13 8.05433C13 7.24162 12.6856 6.469 12.1213 5.89468C11.5569 5.32017 10.7983 5 10.0001 5C9.20191 5 8.44327 5.32015 7.87898 5.89468Z', diff --git a/discovery-client/src/discovery-client.ts b/discovery-client/src/discovery-client.ts index 204bf14b760..d13f32dfd14 100644 --- a/discovery-client/src/discovery-client.ts +++ b/discovery-client/src/discovery-client.ts @@ -1,3 +1,5 @@ +import isEqual from 'lodash/isEqual' + import { DEFAULT_PORT } from './constants' import { createHealthPoller } from './health-poller' import { createMdnsBrowser } from './mdns-browser' @@ -53,7 +55,7 @@ export function createDiscoveryClient( const addrs = getAddresses() const robots = getRobots() - if (addrs !== prevAddrs) healthPoller.start({ list: addrs }) + if (!isEqual(addrs, prevAddrs)) healthPoller.start({ list: addrs }) if (robots !== prevRobots) onListChange(robots) prevAddrs = addrs diff --git a/hardware-testing/Makefile b/hardware-testing/Makefile index e25db7bf9fb..376261fe9e1 100755 --- a/hardware-testing/Makefile +++ b/hardware-testing/Makefile @@ -25,10 +25,12 @@ tests ?= tests cov_opts ?= --cov=hardware_testing --cov-report term-missing:skip-covered --cov-report xml:coverage.xml test_opts ?= -# Host key location for buildroot robot -br_ssh_key ?= $(default_ssh_key) -# Other SSH args for buildroot robots +# Host key location for robot +ssh_key ?= $(default_ssh_key) +# Other SSH args for robot ssh_opts ?= $(default_ssh_opts) +# Helper to safely bundle ssh options +ssh_helper = $(if $(ssh_key),-i $(ssh_key)) $(ssh_opts) # Source discovery # For the python sources @@ -170,32 +172,32 @@ endef .PHONY: push-plot-webpage-ot3 push-plot-webpage-ot3: - scp -r hardware_testing/tools/plot root@$(host):/data - $(call move-plot-webpage-ot3,$(host),$(br_ssh_key),$(ssh_opts)) + scp $(ssh_helper) -r hardware_testing/tools/plot root@$(host):/data + $(call move-plot-webpage-ot3,$(host),$(ssh_key),$(ssh_opts)) .PHONY: push-description-ot3 push-description-ot3: $(python) -c "from hardware_testing.data import create_git_description_file; create_git_description_file()" - scp ./.hardware-testing-description root@$(host):/data/.hardware-testing-description + scp $(ssh_helper) ./.hardware-testing-description root@$(host):/data/ .PHONY: restart restart: - $(call restart-service,$(host),$(br_ssh_key),$(ssh_opts),"opentrons-robot-server") + $(call restart-service,$(host),$(ssh_key),$(ssh_opts),"opentrons-robot-server") .PHONY: push-no-restart push-no-restart: wheel - $(call push-python-package,$(host),$(br_ssh_key),$(ssh_opts),$(wheel_file)) + $(call push-python-package,$(host),$(ssh_key),$(ssh_opts),$(wheel_file)) .PHONY: push push: push-no-restart restart .PHONY: restart-ot3 restart-ot3: - $(call restart-server,$(host),,$(ssh_opts),"opentrons-robot-server") + $(call restart-server,$(host),$(ssh_key),$(ssh_opts),"opentrons-robot-server") .PHONY: push-no-restart-ot3 push-no-restart-ot3: sdist Pipfile.lock - $(call push-python-sdist,$(host),,$(ssh_opts),$(sdist_file),/opt/opentrons-robot-server,"hardware_testing",,,$(version_file)) + $(call push-python-sdist,$(host),$(ssh_key),$(ssh_opts),$(sdist_file),/opt/opentrons-robot-server,"hardware_testing",,,$(version_file)) .PHONY: push-ot3 push-ot3: push-no-restart-ot3 push-plot-webpage-ot3 push-description-ot3 @@ -205,7 +207,7 @@ push-all: clean wheel push-no-restart push-plot-webpage .PHONY: term term: - ssh -i $(br_ssh_key) $(ssh_opts) root@$(host) + ssh $(ssh_helper) root@$(host) .PHONY: list-ports list-ports: @@ -221,7 +223,7 @@ push-all-and-term: push-all term .PHONY: pull-data-ot3 pull-data-ot3: mkdir -p "./.pulled-data" - scp -r "root@$(host):/data/testing_data/$(test)" "./.pulled-data" + scp $(ssh_helper) -r "root@$(host):/data/testing_data/$(test)" "./.pulled-data" define delete-test-data-cmd ssh -i $(2) $(3) root@$(1) \ @@ -230,14 +232,14 @@ endef .PHONY: delete-data-ot3 delete-data-ot3: - $(call delete-test-data-cmd,$(host),$(br_ssh_key),$(ssh_opts),$(test)) + $(call delete-test-data-cmd,$(host),$(ssh_key),$(ssh_opts),$(test)) define push-and-update-fw -scp $(2) root@$(1):/tmp -ssh root@$(1) \ -"function cleanup () { (rm -rf /tmp/$(2) || true) && mount -o remount,ro / ; } ;\ +scp -i $(2) $(3) $(4) root@$(1):/tmp/ +ssh -i $(2) $(3) root@$(1) \ +"function cleanup () { (rm -rf /tmp/$(4) || true) && mount -o remount,ro / ; } ;\ mount -o remount,rw / &&\ -(unzip -o /tmp/$(2) -d /usr/lib/firmware || cleanup) &&\ +(unzip -o /tmp/$(4) -d /usr/lib/firmware || cleanup) &&\ python3 -m json.tool /usr/lib/firmware/opentrons-firmware.json &&\ cleanup" endef @@ -248,7 +250,7 @@ sync-sw-ot3: push-ot3 .PHONY: sync-fw-ot3 sync-fw-ot3: - $(call push-and-update-fw,$(host),$(zip)) + $(call push-and-update-fw,$(host),$(ssh_key),$(ssh_opts),$(zip)) .PHONY: sync-ot3 sync-ot3: sync-sw-ot3 sync-fw-ot3 @@ -258,10 +260,10 @@ push-ot3-gravimetric: $(MAKE) apply-patches-gravimetric -$(MAKE) sync-sw-ot3 $(MAKE) remove-patches-gravimetric - scp -r -O hardware_testing/labware/opentrons_flex_96_tiprack_50ul_adp/ root@$(host):/data/labware/v2/custom_definitions/custom_beta/ - scp -r -O hardware_testing/labware/opentrons_flex_96_tiprack_200ul_adp/ root@$(host):/data/labware/v2/custom_definitions/custom_beta/ - scp -r -O hardware_testing/labware/opentrons_flex_96_tiprack_1000ul_adp/ root@$(host):/data/labware/v2/custom_definitions/custom_beta/ - scp -r -O hardware_testing/labware/radwag_pipette_calibration_vial/ root@$(host):/data/labware/v2/custom_definitions/custom_beta/ + scp $(ssh_helper) -r -O hardware_testing/labware/opentrons_flex_96_tiprack_50ul_adp/ root@$(host):/data/labware/v2/custom_definitions/custom_beta/ + scp $(ssh_helper) -r -O hardware_testing/labware/opentrons_flex_96_tiprack_200ul_adp/ root@$(host):/data/labware/v2/custom_definitions/custom_beta/ + scp $(ssh_helper) -r -O hardware_testing/labware/opentrons_flex_96_tiprack_1000ul_adp/ root@$(host):/data/labware/v2/custom_definitions/custom_beta/ + scp $(ssh_helper) -r -O hardware_testing/labware/radwag_pipette_calibration_vial/ root@$(host):/data/labware/v2/custom_definitions/custom_beta/ .PHONY: apply-patches-gravimetric apply-patches-gravimetric: @@ -283,7 +285,7 @@ update-patches-gravimetric: .PHONY: push-photometric-ot2 push-photometric-ot2: - scp -r -O photometric-ot2/photometric_ot2 root@$(host):/data/user_storage + scp $(ssh_helper) -r -O photometric-ot2/photometric_ot2 root@$(host):/data/user_storage .PHONY: get-latest-tag get-latest-tag: diff --git a/hardware-testing/hardware_testing/opentrons_api/http_api.py b/hardware-testing/hardware_testing/opentrons_api/http_api.py index a97883a6cec..1d7296ac600 100644 --- a/hardware-testing/hardware_testing/opentrons_api/http_api.py +++ b/hardware-testing/hardware_testing/opentrons_api/http_api.py @@ -9,8 +9,6 @@ CALIBRATION_ADAPTER = { "temperatureModuleV1": "opentrons_calibration_adapter_temperature_module", "temperatureModuleV2": "opentrons_calibration_adapter_temperature_module", - "magneticModuleV1": "opentrons_calibration_adapter_magnetic_module", - "magneticModuleV2": "opentrons_calibration_adapter_magnetic_module", "thermocyclerModuleV1": "opentrons_calibration_adapter_thermocycler_module", "thermocyclerModuleV2": "opentrons_calibration_adapter_thermocycler_module", "heaterShakerModuleV1": "opentrons_calibration_adapter_heatershaker_module", diff --git a/hardware-testing/hardware_testing/scripts/module_calibration.py b/hardware-testing/hardware_testing/scripts/module_calibration.py index 786f5a5af25..b87c1de4dbb 100644 --- a/hardware-testing/hardware_testing/scripts/module_calibration.py +++ b/hardware-testing/hardware_testing/scripts/module_calibration.py @@ -12,8 +12,6 @@ MODELS = [ "temperatureModuleV1", "temperatureModuleV2", - "magneticModuleV1", - "magneticModuleV2", "thermocyclerModuleV1", "thermocyclerModuleV2", "heaterShakerModuleV1", diff --git a/hardware/Makefile b/hardware/Makefile index baca8db7004..757beb564f1 100755 --- a/hardware/Makefile +++ b/hardware/Makefile @@ -36,10 +36,12 @@ test_opts ?= pypi_username ?= pypi_password ?= -# Host key location for buildroot robot -br_ssh_key ?= $(default_ssh_key) -# Other SSH args for buildroot robots +# Host key location for robot +ssh_key ?= $(default_ssh_key) +# Other SSH args for robot ssh_opts ?= $(default_ssh_opts) +# Helper to safely bundle ssh options +ssh_helper = $(if $(ssh_key),-i $(ssh_key)) $(ssh_opts) twine_auth_args := --username $(pypi_username) --password $(pypi_password) twine_repository_url ?= $(pypi_test_upload_url) @@ -121,19 +123,19 @@ dev: .PHONY: push-no-restart push-no-restart: wheel - $(call push-python-package,$(host),$(br_ssh_key),$(ssh_opts),$(wheel_file)) + $(call push-python-package,$(host),$(ssh_key),$(ssh_opts),$(wheel_file)) .PHONY: push push: push-no-restart - $(call restart-service,$(host),$(br_ssh_key),$(ssh_opts),"jupyter-notebook opentrons-robot-server") + $(call restart-service,$(host),$(ssh_key),$(ssh_opts),"jupyter-notebook opentrons-robot-server") .PHONY: push-no-restart-ot3 push-no-restart-ot3: sdist Pipfile.lock - $(call push-python-sdist,$(host),,$(ssh_opts),$(sdist_file),/opt/opentrons-robot-server,"opentrons_hardware",,,$(version_file)) + $(call push-python-sdist,$(host),$(ssh_key),$(ssh_opts),$(sdist_file),/opt/opentrons-robot-server,"opentrons_hardware",,,$(version_file)) .PHONY: push-ot3 push-ot3: push-no-restart-ot3 - $(call restart-server,$(host),,$(ssh_opts),"opentrons-robot-server") + $(call restart-server,$(host),$(ssh_key),$(ssh_opts),"opentrons-robot-server") # Launch the emulator application. diff --git a/hardware/opentrons_hardware/firmware_bindings/messages/message_definitions.py b/hardware/opentrons_hardware/firmware_bindings/messages/message_definitions.py index 95cfb464869..070842001f4 100644 --- a/hardware/opentrons_hardware/firmware_bindings/messages/message_definitions.py +++ b/hardware/opentrons_hardware/firmware_bindings/messages/message_definitions.py @@ -631,7 +631,7 @@ class GripperJawStateRequest(EmptyPayloadMessage): # noqa: D101 @dataclass class GripperJawStateResponse(BaseMessage): # noqa: D101 - payload: payloads.GripperMoveRequestPayload + payload: payloads.GripperJawStatePayload payload_type: Type[ payloads.GripperJawStatePayload ] = payloads.GripperJawStatePayload diff --git a/hardware/opentrons_hardware/firmware_bindings/messages/messages.py b/hardware/opentrons_hardware/firmware_bindings/messages/messages.py index b8224558139..633825ed861 100644 --- a/hardware/opentrons_hardware/firmware_bindings/messages/messages.py +++ b/hardware/opentrons_hardware/firmware_bindings/messages/messages.py @@ -94,6 +94,8 @@ defs.TipStatusQueryRequest, defs.GetMotorUsageRequest, defs.GetMotorUsageResponse, + defs.GripperJawStateRequest, + defs.GripperJawStateResponse, ] diff --git a/hardware/opentrons_hardware/firmware_update/downloader.py b/hardware/opentrons_hardware/firmware_update/downloader.py index e4b6d2a750a..41c4783ef1d 100644 --- a/hardware/opentrons_hardware/firmware_update/downloader.py +++ b/hardware/opentrons_hardware/firmware_update/downloader.py @@ -35,6 +35,7 @@ async def run( node_id: NodeId, hex_processor: HexRecordProcessor, ack_wait_seconds: float, + retries: int = 3, ) -> AsyncIterator[float]: """Download hex record chunks to node. @@ -42,6 +43,7 @@ async def run( node_id: The target node id. hex_processor: The producer of hex chunks. ack_wait_seconds: Number of seconds to wait for an ACK. + retries: Number of attempts when sending a chunk. Returns: None @@ -52,23 +54,34 @@ async def run( num_messages = 0 crc32 = 0 for chunk in chunks: - logger.debug( - f"Sending chunk {num_messages} to address {chunk.address:x}." - ) - # Create and send message from this chunk - data = bytes(chunk.data) - data_message = message_definitions.FirmwareUpdateData( - payload=payloads.FirmwareUpdateData.create( - address=chunk.address, data=data + passed = False + for retry in range(retries): + logger.debug( + f"Sending chunk {num_messages} to address {chunk.address:x} retry: {retry}." ) - ) - await self._messenger.send(node_id=node_id, message=data_message) - try: - # Wait for ack. - await asyncio.wait_for( - self._wait_data_message_ack(node_id, reader), ack_wait_seconds + # Create and send message from this chunk + data = bytes(chunk.data) + data_message = message_definitions.FirmwareUpdateData( + payload=payloads.FirmwareUpdateData.create( + address=chunk.address, data=data + ) ) - except asyncio.TimeoutError: + await self._messenger.send(node_id=node_id, message=data_message) + try: + # Wait for ack. + await asyncio.wait_for( + self._wait_data_message_ack(node_id, reader), + ack_wait_seconds, + ) + passed = True + break + except asyncio.TimeoutError: + logger.warning( + f"Firmware update data ack timed out for chunk {num_messages}" + ) + + # message was not successful + if not passed: raise TimeoutResponse(data_message, node_id) crc32 = binascii.crc32(data, crc32) diff --git a/hardware/opentrons_hardware/firmware_update/run.py b/hardware/opentrons_hardware/firmware_update/run.py index 2e896d77023..a7b1a771598 100644 --- a/hardware/opentrons_hardware/firmware_update/run.py +++ b/hardware/opentrons_hardware/firmware_update/run.py @@ -372,6 +372,7 @@ async def _run_can_update( node_id=target.bootloader_node, hex_processor=hex_processor, ack_wait_seconds=timeout_seconds, + retries=retry_count, ): await self._status_queue.put( ( diff --git a/hardware/opentrons_hardware/hardware_control/gripper_settings.py b/hardware/opentrons_hardware/hardware_control/gripper_settings.py index 9635ed2ef13..6012e12da49 100644 --- a/hardware/opentrons_hardware/hardware_control/gripper_settings.py +++ b/hardware/opentrons_hardware/hardware_control/gripper_settings.py @@ -9,6 +9,7 @@ from opentrons_hardware.firmware_bindings.arbitration_id import ArbitrationId from opentrons_hardware.firmware_bindings.messages import payloads +from opentrons_hardware.firmware_bindings.messages.messages import MessageDefinition from opentrons_hardware.firmware_bindings.messages.message_definitions import ( SetBrushedMotorVrefRequest, SetBrushedMotorPwmRequest, @@ -18,13 +19,20 @@ AddBrushedLinearMoveRequest, BrushedMotorConfRequest, BrushedMotorConfResponse, + GripperJawStateRequest, + GripperJawStateResponse, ) from opentrons_hardware.firmware_bindings.utils import ( UInt8Field, UInt32Field, Int32Field, ) -from opentrons_hardware.firmware_bindings.constants import NodeId, ErrorCode +from opentrons_hardware.firmware_bindings.constants import ( + MessageId, + NodeId, + ErrorCode, + GripperJawState, +) from .constants import brushed_motor_interrupts_per_sec log = logging.getLogger(__name__) @@ -192,3 +200,33 @@ async def move( ) ), ) + + +async def get_gripper_jaw_state( + can_messenger: CanMessenger, +) -> GripperJawState: + """Get gripper jaw state.""" + jaw_state = GripperJawState.unhomed + + event = asyncio.Event() + + def _listener(message: MessageDefinition, arb_id: ArbitrationId) -> None: + nonlocal jaw_state + if isinstance(message, GripperJawStateResponse): + event.set() + jaw_state = GripperJawState(message.payload.state.value) + + def _filter(arb_id: ArbitrationId) -> bool: + return (NodeId(arb_id.parts.originating_node_id) == NodeId.gripper_g) and ( + MessageId(arb_id.parts.message_id) == MessageId.gripper_jaw_state_response + ) + + can_messenger.add_listener(_listener, _filter) + await can_messenger.send(node_id=NodeId.gripper_g, message=GripperJawStateRequest()) + try: + await asyncio.wait_for(event.wait(), 1.0) + except asyncio.TimeoutError: + log.warning("gripper jaw state request timed out") + finally: + can_messenger.remove_listener(_listener) + return jaw_state diff --git a/hardware/opentrons_hardware/hardware_control/motion_planning/move_utils.py b/hardware/opentrons_hardware/hardware_control/motion_planning/move_utils.py index 318deea5221..4c2a664c601 100644 --- a/hardware/opentrons_hardware/hardware_control/motion_planning/move_utils.py +++ b/hardware/opentrons_hardware/hardware_control/motion_planning/move_utils.py @@ -26,6 +26,9 @@ MINIMUM_DISPLACEMENT = 0.05 +# Minimum vector component of 0.1% +MINIMUM_VECTOR_COMPONENT = np.float64(0.001) + def apply_constraint(constraint: np.float64, input: np.float64) -> np.float64: """Keep the sign of the input but cap the numeric value at the constraint value.""" @@ -57,6 +60,79 @@ def get_unit_vector( return unit_vector, distance +def split_unit_vector( + initial_vector: Coordinates[AxisKey, np.float64], + initial_distance: np.float64, + to_remove: AxisKey, +) -> Tuple[ + Tuple[Coordinates[AxisKey, np.float64], np.float64], + Tuple[Coordinates[AxisKey, np.float64], np.float64], +]: + """Split a unit vector into two sequential vectors. + + Exactly one axis should be specified to be removed. This function will return a tuple + of two vectors: the first will only contain the axis requested for removal, and the + second will contain the rest of the movement. + """ + origin = {ax: np.float64(0) for ax in initial_vector.keys()} + + displacement_first = origin.copy() + + displacement_second = { + ax: val * initial_distance for ax, val in initial_vector.items() + } + displacement_first[to_remove] = displacement_second[to_remove] + displacement_second[to_remove] = np.float64(0) + + return ( + get_unit_vector(origin, displacement_first), + get_unit_vector(origin, displacement_second), + ) + + +def de_diagonalize_unit_vector( + initial_vector: Coordinates[AxisKey, np.float64], + initial_distance: np.float64, + min_unit_vector: np.float64, +) -> List[Tuple[Coordinates[AxisKey, np.float64], np.float64]]: + """Split a unit vector into consecutive movements if any component is too small. + + If the component of the unit vector for certain movement axis is extremely + small, the resulting speed for that axis may be low enough to cause erroneous + stepping behavior on that motor. To deal with this, we "de-diagonalize" those + movements by splitting the very small part of the movement into its own + dedicated movement. + + For example, if a movement goes 100mm in X but 0.1mm in Y, we split it into + two separate movements that will run in sequence. + + Returns a list of resultant unit vectors once the original vector was (maybe) split. + """ + vectors: List[Tuple[Coordinates[AxisKey, np.float64], np.float64]] = [ + (initial_vector, initial_distance) + ] + while True: + vector, distance = vectors[-1] + # Check for any component under the min + to_split = [ + ax + for ax in vector.keys() + if vector[ax] != np.float64(0) and abs(vector[ax]) < min_unit_vector + ] + if len(to_split) == 0: + # Everything is good and we can return the list as-is. + return vectors + # We need to split this vector into multiple sequential vectors + vectors.pop() + for ax in to_split: + first, second = split_unit_vector(vector, distance, ax) + vectors.append(first) + vector, distance = second + # Always put the larger vector in LAST so, when we re-check, we are looking + # at the movement that may have more than one axis. + vectors.append((vector, distance)) + + def limit_max_speed( unit_vector: Coordinates[AxisKey, np.float64], max_linear_speed: np.float64, @@ -83,6 +159,38 @@ def limit_max_speed( return max_linear_speed * scale +def _unit_vector_to_move( + unit_vector: Coordinates[AxisKey, np.float64], + distance: np.float64, + max_speed: np.float64, + constraints: SystemConstraints[AxisKey], +) -> Move[AxisKey]: + speed = limit_max_speed(unit_vector, max_speed, constraints) + third_distance = np.float64(distance / 3) + return Move( + unit_vector=unit_vector, + distance=distance, + max_speed=speed, + blocks=( + Block( + distance=third_distance, + initial_speed=speed, + acceleration=np.float64(0), + ), + Block( + distance=third_distance, + initial_speed=speed, + acceleration=np.float64(0), + ), + Block( + distance=third_distance, + initial_speed=speed, + acceleration=np.float64(0), + ), + ), + ) + + def targets_to_moves( initial: Coordinates[AxisKey, CoordinateValue], targets: List[MoveTarget[AxisKey]], @@ -96,33 +204,18 @@ def targets_to_moves( initial_checked = {k: np.float64(initial.get(k, 0)) for k in all_axes} for target in targets: position = {k: np.float64(target.position.get(k, 0)) for k in all_axes} - unit_vector, distance = get_unit_vector(initial_checked, position) - speed = limit_max_speed(unit_vector, target.max_speed, constraints) - third_distance = np.float64(distance / 3) - m = Move( - unit_vector=unit_vector, - distance=distance, - max_speed=speed, - blocks=( - Block( - distance=third_distance, - initial_speed=speed, - acceleration=np.float64(0), - ), - Block( - distance=third_distance, - initial_speed=speed, - acceleration=np.float64(0), - ), - Block( - distance=third_distance, - initial_speed=speed, - acceleration=np.float64(0), - ), - ), + initial_unit_vector, initial_distance = get_unit_vector( + initial_checked, position ) - log.debug(f"Built move from {initial} to {target} as {m}") - yield m + de_diagonalized_vectors = de_diagonalize_unit_vector( + initial_unit_vector, initial_distance, MINIMUM_VECTOR_COMPONENT + ) + for unit_vector, distance in de_diagonalized_vectors: + m = _unit_vector_to_move( + unit_vector, distance, target.max_speed, constraints + ) + log.debug(f"Built move from {initial} to {target} as {m}") + yield m initial_checked = position diff --git a/hardware/opentrons_hardware/hardware_control/move_group_runner.py b/hardware/opentrons_hardware/hardware_control/move_group_runner.py index c812e344c34..a7965df2d47 100644 --- a/hardware/opentrons_hardware/hardware_control/move_group_runner.py +++ b/hardware/opentrons_hardware/hardware_control/move_group_runner.py @@ -10,6 +10,7 @@ GeneralError, MoveConditionNotMetError, EnumeratedError, + EStopActivatedError, MotionFailedError, PythonException, ) @@ -551,6 +552,18 @@ def _get_nodes_in_move_group(self, group_id: int) -> List[NodeId]: nodes.append(NodeId(node_id)) return nodes + def _filtered_errors(self) -> List[EnumeratedError]: + """If multiple errors occurred, filter which ones we raise. + + This function primarily handles the case when an Estop is pressed during a run. + Multiple kinds of error messages may arise, but the only one that is important + to raise is the message about the Estop. + """ + for err in self._errors: + if isinstance(err, EStopActivatedError): + return [err] + return self._errors + async def _send_stop_if_necessary( self, can_messenger: CanMessenger, group_id: int ) -> None: @@ -563,12 +576,13 @@ async def _send_stop_if_necessary( if err != ErrorCode.stop_requested: log.warning("Stop request failed") if self._errors: - if len(self._errors) > 1: + errors_to_show = self._filtered_errors() + if len(errors_to_show) > 1: raise MotionFailedError( - "Motion failed with multiple errors", wrapping=self._errors + "Motion failed with multiple errors", wrapping=errors_to_show ) else: - raise self._errors[0] + raise errors_to_show[0] else: # This happens when the move completed without stop condition raise MoveConditionNotMetError(detail={"group-id": str(group_id)}) diff --git a/hardware/tests/opentrons_hardware/firmware_update/test_run.py b/hardware/tests/opentrons_hardware/firmware_update/test_run.py index b73b577d1ce..c9fb975e536 100644 --- a/hardware/tests/opentrons_hardware/firmware_update/test_run.py +++ b/hardware/tests/opentrons_hardware/firmware_update/test_run.py @@ -116,6 +116,7 @@ async def test_run_update( node_id=target.bootloader_node, hex_processor=mock_hex_record_processor, ack_wait_seconds=11, + retries=12, ) mock_can_messenger.send.assert_called_once_with( node_id=target.bootloader_node, message=FirmwareUpdateStartApp() @@ -148,17 +149,18 @@ async def test_run_updates( can_messenger=mock_can_messenger, usb_messenger=mock_usb_messenger, update_details=update_details, - retry_count=12, - timeout_seconds=11, + retry_count=3, + timeout_seconds=5, erase=should_erase, + erase_timeout_seconds=60, ) async for progress in updater.run_updates(): pass mock_initiator_run.assert_has_calls( [ - mock.call(target=target_1, retry_count=12, ready_wait_time_sec=11), - mock.call(target=target_2, retry_count=12, ready_wait_time_sec=11), + mock.call(target=target_1, retry_count=3, ready_wait_time_sec=5), + mock.call(target=target_2, retry_count=3, ready_wait_time_sec=5), ] ) @@ -177,13 +179,15 @@ async def test_run_updates( mock.call( node_id=target_1.bootloader_node, hex_processor=mock_hex_record_processor, - ack_wait_seconds=11, + ack_wait_seconds=5, + retries=3, ), mock.call().__aiter__(), mock.call( node_id=target_2.bootloader_node, hex_processor=mock_hex_record_processor, - ack_wait_seconds=11, + ack_wait_seconds=5, + retries=3, ), mock.call().__aiter__(), ] diff --git a/hardware/tests/opentrons_hardware/hardware_control/test_move_group_runner.py b/hardware/tests/opentrons_hardware/hardware_control/test_move_group_runner.py index 281accb908d..a81d091bc40 100644 --- a/hardware/tests/opentrons_hardware/hardware_control/test_move_group_runner.py +++ b/hardware/tests/opentrons_hardware/hardware_control/test_move_group_runner.py @@ -7,6 +7,7 @@ MoveConditionNotMetError, EnumeratedError, MotionFailedError, + EStopActivatedError, ) from opentrons_hardware.firmware_bindings import ArbitrationId, ArbitrationIdParts @@ -1231,11 +1232,13 @@ def __init__( move_groups: MoveGroups, listener: MessageListenerCallback, start_at_index: int = 0, + estop_errors_to_send: int = 0, ) -> None: """Constructor.""" self._move_groups = move_groups self._listener = listener self._start_at_index = start_at_index + self._estop_errors_to_send = estop_errors_to_send self.call_count = 0 @property @@ -1263,9 +1266,13 @@ async def mock_send( ): for node, move in moves.items(): assert isinstance(move, MoveGroupSingleAxisStep) + code = ErrorCode.collision_detected + if self._estop_errors_to_send > 0: + self._estop_errors_to_send -= 1 + code = ErrorCode.estop_detected payload = ErrorMessagePayload( severity=ErrorSeverityField(ErrorSeverity.unrecoverable), - error_code=ErrorCodeField(ErrorCode.collision_detected), + error_code=ErrorCodeField(code), ) payload.message_index = message.payload.message_index arbitration_id = ArbitrationId( @@ -1338,6 +1345,21 @@ async def test_multiple_move_error( assert mock_sender.call_count == 2 +async def test_multiple_move_error_estop_filtering( + mock_can_messenger: AsyncMock, move_group_multiple_axes: MoveGroups +) -> None: + """It should receive all of the errors but only report the Estop one.""" + subject = MoveScheduler(move_groups=move_group_multiple_axes) + mock_sender = MockSendMoveErrorCompleter( + move_group_multiple_axes, subject, estop_errors_to_send=1 + ) + mock_can_messenger.ensure_send.side_effect = mock_sender.mock_ensure_send + mock_can_messenger.send.side_effect = mock_sender.mock_send + with pytest.raises(EStopActivatedError): + await subject.run(can_messenger=mock_can_messenger) + assert mock_sender.call_count == 2 + + @pytest.fixture def move_group_with_stall() -> MoveGroups: """Move group with a stop-on-stall.""" diff --git a/hardware/tests/opentrons_hardware/hardware_control/test_move_utils.py b/hardware/tests/opentrons_hardware/hardware_control/test_move_utils.py index b106c4782f0..68d88b6185f 100644 --- a/hardware/tests/opentrons_hardware/hardware_control/test_move_utils.py +++ b/hardware/tests/opentrons_hardware/hardware_control/test_move_utils.py @@ -13,6 +13,9 @@ get_unit_vector, FLOAT_THRESHOLD, limit_max_speed, + split_unit_vector, + de_diagonalize_unit_vector, + MINIMUM_VECTOR_COMPONENT, ) from opentrons_hardware.hardware_control.motion_planning.types import ( AxisConstraints, @@ -362,6 +365,157 @@ def test_convert_targets_to_moves() -> None: ) +def test_convert_targets_to_moves_de_diagonilization() -> None: + """Test that conversion will split out problematic movements.""" + targets = [ + MoveTarget.build( + { + "X": np.float64(200), + "Y": np.float64(0.1), + "Z": np.float64(0), + "A": np.float64(0), + }, + np.float64(1000), + ), + MoveTarget.build( + { + "X": np.float64(200), + "Y": np.float64(0), + "Z": np.float64(200), + "A": np.float64(0), + }, + np.float64(1000), + ), + ] + + expected = [ + Move.build( + unit_vector={ + "X": np.float64(0), + "Y": np.float64(1), + "Z": np.float64(0), + "A": np.float64(0), + }, + distance=np.float64(0.1), + max_speed=np.float64(500), + blocks=( + Block( + distance=np.float64(0.1 / 3), + initial_speed=np.float64(np.float64(500)), + acceleration=np.float64(0), + ), + Block( + distance=np.float64(0.1 / 3), + initial_speed=np.float64(np.float64(500)), + acceleration=np.float64(0), + ), + Block( + distance=np.float64(0.1 / 3), + initial_speed=np.float64(np.float64(500)), + acceleration=np.float64(0), + ), + ), + ), + Move.build( + unit_vector={ + "X": np.float64(1), + "Y": np.float64(0), + "Z": np.float64(0), + "A": np.float64(0), + }, + distance=np.float64(200), + max_speed=np.float64(500), + blocks=( + Block( + distance=np.float64(200 / 3), + initial_speed=np.float64(np.float64(500)), + acceleration=np.float64(0), + ), + Block( + distance=np.float64(200 / 3), + initial_speed=np.float64(np.float64(500)), + acceleration=np.float64(0), + ), + Block( + distance=np.float64(200 / 3), + initial_speed=np.float64(np.float64(500)), + acceleration=np.float64(0), + ), + ), + ), + Move.build( + unit_vector={ + "X": np.float64(0), + "Y": np.float64(-1), + "Z": np.float64(0), + "A": np.float64(0), + }, + distance=np.float64(0.1), + max_speed=np.float64(500), + blocks=( + Block( + distance=np.float64(0.1 / 3), + initial_speed=np.float64(np.float64(500)), + acceleration=np.float64(0), + ), + Block( + distance=np.float64(0.1 / 3), + initial_speed=np.float64(np.float64(500)), + acceleration=np.float64(0), + ), + Block( + distance=np.float64(0.1 / 3), + initial_speed=np.float64(np.float64(500)), + acceleration=np.float64(0), + ), + ), + ), + Move.build( + unit_vector={ + "X": np.float64(0), + "Y": np.float64(0), + "Z": np.float64(1), + "A": np.float64(0), + }, + distance=np.float64(200), + max_speed=np.float64(500), + blocks=( + Block( + distance=np.float64(200 / 3), + initial_speed=np.float64(np.float64(500)), + acceleration=np.float64(0), + ), + Block( + distance=np.float64(200 / 3), + initial_speed=np.float64(np.float64(500)), + acceleration=np.float64(0), + ), + Block( + distance=np.float64(200 / 3), + initial_speed=np.float64(np.float64(500)), + acceleration=np.float64(0), + ), + ), + ), + ] + + assert ( + list( + targets_to_moves( + { + "X": np.float64(0), + "Y": np.float64(0), + "Z": np.float64(0), + "A": np.float64(0), + }, + targets, + CONSTRAINTS, + ) + ) + == expected + ) + + @pytest.mark.parametrize( argnames=["prev_move", "unit_vector", "max_speed", "expected"], argvalues=[ @@ -551,6 +705,75 @@ def test_get_unit_vector(x: List[float], y: List[float]) -> None: assert is_unit_vector(unit_v) +def test_split_unit_vector() -> None: + """Given a unit vector, be able to split it.""" + coord_0 = { + "X": 0, + "Y": 0, + "Z": 0, + "W": 0, + } + coord_1 = { + "X": 100, + "Y": -100, + "Z": 25, + "W": 0, + } + unit_v, dist = get_unit_vector(coord_0, coord_1) + + split_1, split_2 = split_unit_vector(unit_v, dist, "X") + unit_1, dist_1 = split_1 + unit_2, dist_2 = split_2 + assert is_unit_vector(unit_1) + assert is_unit_vector(unit_2) + assert unit_1["X"] != np.float64(0) + assert unit_2["X"] == np.float64(0) + assert dist_1 == pytest.approx(np.float64(100)) + assert dist_1 != dist_2 + + +def test_de_diagonalize_vectors() -> None: + """Given a unit vector, split out small distances.""" + coord_0 = { + "X": 0, + "Y": 0, + "Z": 0, + "A": 0, + } + coord_1 = { + "X": 100, + "Y": -0.05, + "Z": 25, + "A": 0.07, + } + unit_v, dist = get_unit_vector(coord_0, coord_1) + + split = de_diagonalize_unit_vector(unit_v, dist, MINIMUM_VECTOR_COMPONENT) + assert len(split) == 3 + + y_v, y_dist = split[0] + a_v, a_dist = split[1] + long_v, long_dist = split[2] + + assert is_unit_vector(y_v) + assert is_unit_vector(a_v) + assert is_unit_vector(long_v) + assert y_v["Y"] == pytest.approx(np.float64(-1)) + assert y_dist == pytest.approx(np.float64(0.05)) + assert a_dist == pytest.approx(np.float64(0.07)) + assert long_dist > y_dist + assert y_v["Y"] == pytest.approx(np.float64(-1)) + assert a_v["A"] == pytest.approx(np.float64(1)) + assert long_v["Y"] == np.float64(0) + assert long_v["A"] == np.float64(0) + + split_again = de_diagonalize_unit_vector( + long_v, long_dist, MINIMUM_VECTOR_COMPONENT + ) + assert len(split_again) == 1 + assert split_again[0] == (long_v, long_dist) + + def test_triangle_matching() -> None: """Test that equal-endpoint triangle moves work. diff --git a/notify-server/Makefile b/notify-server/Makefile index a3000fa0361..30e79a994a8 100755 --- a/notify-server/Makefile +++ b/notify-server/Makefile @@ -41,11 +41,11 @@ tests ?= tests cov_opts ?= --cov=$(SRC_PATH) --cov-report term-missing:skip-covered --cov-report xml:coverage.xml test_opts ?= -# Host key location for buildroot robot -br_ssh_key ?= $(default_ssh_key) +# Host key location for robot +ssh_key ?= $(default_ssh_key) # Pubkey location for buildroot robot to install with install-key -br_ssh_pubkey ?= $(br_ssh_key).pub -# Other SSH args for buildroot robots +br_ssh_pubkey ?= $(ssh_key).pub +# Other SSH args for robot ssh_opts ?= $(default_ssh_opts) # Source discovery @@ -128,19 +128,19 @@ local-shell: .PHONY: push push: wheel - $(call push-python-package,$(host),$(br_ssh_key),$(ssh_opts),$(wheel_file)) - $(call push-systemd-unit,$(host),$(br_ssh_key),$(ssh_opts),./opentrons-notify-server.service) - $(call restart-service,$(host),$(br_ssh_key),$(ssh_opts),opentrons-notify-server) + $(call push-python-package,$(host),$(ssh_key),$(ssh_opts),$(wheel_file)) + $(call push-systemd-unit,$(host),$(ssh_key),$(ssh_opts),./opentrons-notify-server.service) + $(call restart-service,$(host),$(ssh_key),$(ssh_opts),opentrons-notify-server) .PHONY: push-no-restart-ot3 push-no-restart-ot3: sdist - $(call push-python-sdist,$(host),,$(ssh_opts),$(sdist_file),"/opt/opentrons-robot-server","notify_server",,,$(version_file)) + $(call push-python-sdist,$(host),$(ssh_key),$(ssh_opts),$(sdist_file),"/opt/opentrons-robot-server","notify_server",,,$(version_file)) .PHONY: push-ot3 push-ot3: push-no-restart-ot3 - $(call restart-server,$(host),,$(ssh_opts),"opentrons-notify-server") + $(call restart-server,$(host),$(ssh_key),$(ssh_opts),"opentrons-notify-server") .PHONY: install-key @@ -152,4 +152,4 @@ install-key: # User must currently specify host, e.g.: `make term host=169.254.202.176` .PHONY: term term: - ssh -i $(br_ssh_key) $(ssh_opts) root@$(host) + ssh $(if $(ssh_key),-i $(ssh_key)) $(ssh_opts) root@$(host) diff --git a/protocol-designer/fixtures/protocol/7/doItAllV7.json b/protocol-designer/fixtures/protocol/7/doItAllV7.json index eb2e3dce738..dbde26caf46 100644 --- a/protocol-designer/fixtures/protocol/7/doItAllV7.json +++ b/protocol-designer/fixtures/protocol/7/doItAllV7.json @@ -2704,6 +2704,7 @@ ], "parameters": { "format": "irregular", + "quirks": ["gripperIncompatible"], "isTiprack": false, "isMagneticModuleCompatible": false, "loadName": "opentrons_24_aluminumblock_nest_1.5ml_snapcap" diff --git a/protocol-designer/src/step-forms/reducers/index.ts b/protocol-designer/src/step-forms/reducers/index.ts index ffc0635246d..e457f805667 100644 --- a/protocol-designer/src/step-forms/reducers/index.ts +++ b/protocol-designer/src/step-forms/reducers/index.ts @@ -1227,7 +1227,13 @@ export const moduleInvariantProperties: Reducer< ) const modules = loadModuleCommands.reduce( (acc: ModuleEntities, command: LoadModuleCreateCommand) => { - const { moduleId, model } = command.params + const { moduleId, model, location } = command.params + if (moduleId == null) { + console.error( + `expected module ${model} in location ${location.slotName} to have an id, but id does not` + ) + return acc + } return { ...acc, [moduleId]: { diff --git a/react-api-client/src/maintenance_runs/index.ts b/react-api-client/src/maintenance_runs/index.ts index 1011b2dcde1..4d6d89d9c33 100644 --- a/react-api-client/src/maintenance_runs/index.ts +++ b/react-api-client/src/maintenance_runs/index.ts @@ -1,4 +1,4 @@ -export { useCreateMaintenanceRunMutation } from './useCreateMaintenanceRunMutation' +export * from './useCreateMaintenanceRunMutation' export { useMaintenanceRunQuery } from './useMaintenanceRunQuery' export { useCreateMaintenanceCommandMutation } from './useCreateMaintenanceCommandMutation' export { useCreateMaintenanceRunLabwareDefinitionMutation } from './useCreateMaintenanceRunLabwareDefinitionMutation' diff --git a/react-api-client/src/maintenance_runs/useCreateMaintenanceRunMutation.ts b/react-api-client/src/maintenance_runs/useCreateMaintenanceRunMutation.ts index 068aaed5acd..3768bfcb544 100644 --- a/react-api-client/src/maintenance_runs/useCreateMaintenanceRunMutation.ts +++ b/react-api-client/src/maintenance_runs/useCreateMaintenanceRunMutation.ts @@ -13,16 +13,18 @@ import { import { useHost } from '../api' import type { AxiosError } from 'axios' +export type CreateMaintenanceRunType = UseMutateAsyncFunction< + MaintenanceRun, + AxiosError, + CreateMaintenanceRunData +> + export type UseCreateMaintenanceRunMutationResult = UseMutationResult< MaintenanceRun, AxiosError, CreateMaintenanceRunData > & { - createMaintenanceRun: UseMutateAsyncFunction< - MaintenanceRun, - AxiosError, - CreateMaintenanceRunData - > + createMaintenanceRun: CreateMaintenanceRunType } export type UseCreateMaintenanceRunMutationOptions = UseMutationOptions< diff --git a/react-api-client/src/protocols/index.ts b/react-api-client/src/protocols/index.ts index 13a4b94a9b0..ddf7c3eeaac 100644 --- a/react-api-client/src/protocols/index.ts +++ b/react-api-client/src/protocols/index.ts @@ -2,5 +2,6 @@ export { useAllProtocolsQuery } from './useAllProtocolsQuery' export { useAllProtocolIdsQuery } from './useAllProtocolIdsQuery' export { useProtocolQuery } from './useProtocolQuery' export { useProtocolAnalysesQuery } from './useProtocolAnalysesQuery' +export { useProtocolAnalysisAsDocumentQuery } from './useProtocolAnalysisAsDocumentQuery' export { useCreateProtocolMutation } from './useCreateProtocolMutation' export { useDeleteProtocolMutation } from './useDeleteProtocolMutation' diff --git a/react-api-client/src/protocols/useProtocolAnalysisAsDocumentQuery.ts b/react-api-client/src/protocols/useProtocolAnalysisAsDocumentQuery.ts new file mode 100644 index 00000000000..6cfebeb2c47 --- /dev/null +++ b/react-api-client/src/protocols/useProtocolAnalysisAsDocumentQuery.ts @@ -0,0 +1,26 @@ +import { UseQueryResult, useQuery } from 'react-query' +import { getProtocolAnalysisAsDocument } from '@opentrons/api-client' +import { useHost } from '../api' +import type { HostConfig } from '@opentrons/api-client' +import type { UseQueryOptions } from 'react-query' +import { CompletedProtocolAnalysis } from '@opentrons/shared-data' + +export function useProtocolAnalysisAsDocumentQuery( + protocolId: string | null, + analysisId: string | null, + options?: UseQueryOptions +): UseQueryResult { + const host = useHost() + const query = useQuery( + [host, 'protocols', protocolId, 'analyses', analysisId], + () => + getProtocolAnalysisAsDocument( + host as HostConfig, + protocolId as string, + analysisId as string + ).then(response => response.data), + options + ) + + return query +} diff --git a/robot-server/Makefile b/robot-server/Makefile index 2cfcf90c30d..c91b4176280 100755 --- a/robot-server/Makefile +++ b/robot-server/Makefile @@ -36,11 +36,11 @@ tests ?= tests cov_opts ?= --cov=$(SRC_PATH) --cov-report term-missing:skip-covered --cov-report xml:coverage.xml test_opts ?= -# Host key location for buildroot robot -br_ssh_key ?= $(default_ssh_key) -# Pubkey location for buildroot robot to install with install-key -br_ssh_pubkey ?= $(br_ssh_key).pub -# Other SSH args for buildroot robots +# Host key location for robot +ssh_key ?= $(default_ssh_key) +# Pubkey location for robot to install with install-key +ssh_pubkey ?= $(ssh_key).pub +# Other SSH args for robot ssh_opts ?= $(default_ssh_opts) # Source discovery @@ -150,19 +150,19 @@ local-shell: .PHONY: push push: wheel - $(call push-python-package,$(host),$(br_ssh_key),$(ssh_opts),$(wheel_file)) - $(call push-systemd-unit,$(host),$(br_ssh_key),$(ssh_opts),./opentrons-robot-server.service) - $(call restart-service,$(host),$(br_ssh_key),$(ssh_opts),opentrons-robot-server) + $(call push-python-package,$(host),$(ssh_key),$(ssh_opts),$(wheel_file)) + $(call push-systemd-unit,$(host),$(ssh_key),$(ssh_opts),./opentrons-robot-server.service) + $(call restart-service,$(host),$(ssh_key),$(ssh_opts),opentrons-robot-server) .PHONY: push-ot3 push-ot3: sdist - $(call push-python-sdist,$(host),,$(ssh_opts),$(sdist_file),"/opt/opentrons-robot-server","robot_server",,,$(version_file)) + $(call push-python-sdist,$(host),$(ssh_key),$(ssh_opts),$(sdist_file),"/opt/opentrons-robot-server","robot_server",,,$(version_file)) .PHONY: install-key install-key: curl -X POST $(host):31950/server/ssh_keys\ -H "Content-Type: application/json"\ - -d "{\"key\":\"$(shell cat $(br_ssh_pubkey))\"}" + -d "{\"key\":\"$(shell cat $(ssh_pubkey))\"}" .PHONY: restart restart: @@ -194,7 +194,7 @@ change-left: # User must currently specify host, e.g.: `make term host=169.254.202.176` .PHONY: term term: - ssh -i $(br_ssh_key) $(ssh_opts) root@$(host) + ssh -i $(ssh_key) $(ssh_opts) root@$(host) .PHONY: docs diff --git a/robot-server/robot_server/hardware.py b/robot-server/robot_server/hardware.py index 4308df9f354..5c0deb148be 100644 --- a/robot-server/robot_server/hardware.py +++ b/robot-server/robot_server/hardware.py @@ -26,7 +26,7 @@ feature_flags as ff, ) from opentrons.util.helpers import utc_now -from opentrons.hardware_control import ThreadManagedHardware, HardwareControlAPI +from opentrons.hardware_control import ThreadManagedHardware, HardwareControlAPI, API from opentrons.hardware_control.simulator_setup import load_simulator_thread_manager from opentrons.hardware_control.types import ( HardwareEvent, @@ -50,6 +50,7 @@ ) from .errors.robot_errors import ( NotSupportedOnOT2, + NotSupportedOnFlex, HardwareNotYetInitialized, HardwareFailedToInitialize, ) @@ -226,6 +227,17 @@ def get_ot3_hardware( return cast(OT3API, thread_manager.wrapped()) +def get_ot2_hardware( + thread_manager: ThreadManagedHardware = Depends(get_thread_manager), +) -> "API": + """Get an OT2 hardware controller.""" + if not thread_manager.wraps_instance(API): + raise NotSupportedOnFlex( + detail="This route is only available on an OT-2." + ).as_error(status.HTTP_403_FORBIDDEN) + return cast(API, thread_manager.wrapped()) + + async def get_firmware_update_manager( app_state: AppState = Depends(get_app_state), thread_manager: ThreadManagedHardware = Depends(get_thread_manager), @@ -500,6 +512,12 @@ async def _initialize_hardware_api( if callback[1]: await callback[0](app_state, hardware.wrapped()) + # This ties systemd notification to hardware initialization. We might want to move + # systemd notification so it also waits for DB migration+initialization. + # If we do that, we need to be careful: + # - systemd timeouts might need to be increased to allow for DB migration time + # - There might be UI implications for loading states on the Flex's on-device display, + # because it polls for the server's systemd status. _systemd_notify(systemd_available) if should_use_ot3(): diff --git a/robot-server/robot_server/maintenance_runs/maintenance_engine_store.py b/robot-server/robot_server/maintenance_runs/maintenance_engine_store.py index bae17259f73..42081dbf459 100644 --- a/robot-server/robot_server/maintenance_runs/maintenance_engine_store.py +++ b/robot-server/robot_server/maintenance_runs/maintenance_engine_store.py @@ -2,6 +2,7 @@ from datetime import datetime from typing import List, NamedTuple, Optional +from opentrons.protocol_engine.types import PostRunHardwareState from opentrons_shared_data.robot.dev_types import RobotType from opentrons.config import feature_flags @@ -171,7 +172,11 @@ async def clear(self) -> RunResult: state_view = engine.state_view if state_view.commands.get_is_okay_to_clear(): - await engine.finish(drop_tips_and_home=False, set_run_status=False) + await engine.finish( + drop_tips_after_run=False, + set_run_status=False, + post_run_hardware_state=PostRunHardwareState.STAY_ENGAGED_IN_PLACE, + ) else: raise EngineConflictError("Current run is not idle or stopped.") diff --git a/robot-server/robot_server/persistence/_database.py b/robot-server/robot_server/persistence/_database.py index f38d4f22f8e..3055898dd36 100644 --- a/robot-server/robot_server/persistence/_database.py +++ b/robot-server/robot_server/persistence/_database.py @@ -3,6 +3,8 @@ import sqlalchemy +from server_utils import sql_utils + from ._tables import add_tables_to_db from ._migrations import migrate @@ -29,35 +31,16 @@ def create_sql_engine(path: Path) -> sqlalchemy.engine.Engine: Migrations can take several minutes. If calling this from an async function, offload this to a thread to avoid blocking the event loop. """ - sql_engine = _open_db_no_cleanup(db_file_path=path) + sql_engine = sqlalchemy.create_engine(sql_utils.get_connection_url(path)) try: + sql_utils.enable_foreign_key_constraints(sql_engine) + sql_utils.fix_transactions(sql_engine) add_tables_to_db(sql_engine) migrate(sql_engine) + except Exception: sql_engine.dispose() raise return sql_engine - - -def _open_db_no_cleanup(db_file_path: Path) -> sqlalchemy.engine.Engine: - """Create a database engine for performing transactions.""" - engine = sqlalchemy.create_engine( - # sqlite:/// - # where is empty. - f"sqlite:///{db_file_path}", - ) - - # Enable foreign key support in sqlite - # https://docs.sqlalchemy.org/en/14/dialects/sqlite.html#foreign-key-support - @sqlalchemy.event.listens_for(engine, "connect") # type: ignore[misc] - def _set_sqlite_pragma( - dbapi_connection: sqlalchemy.engine.CursorResult, - connection_record: sqlalchemy.engine.CursorResult, - ) -> None: - cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA foreign_keys=ON;") - cursor.close() - - return engine diff --git a/robot-server/robot_server/persistence/_migrations.py b/robot-server/robot_server/persistence/_migrations.py index 8329cb4ba37..295f86ad1ee 100644 --- a/robot-server/robot_server/persistence/_migrations.py +++ b/robot-server/robot_server/persistence/_migrations.py @@ -18,11 +18,14 @@ - Version 0 - Initial schema version + - Version 1 - - `run_table.state_summary` column added - - `run_table.commands` column added - - `run_table.engine_status` column added - - `run_table._updated_at` column added + This migration adds the following nullable columns to the run table: + + - Column("state_summary, sqlalchemy.PickleType, nullable=True) + - Column("commands", sqlalchemy.PickleType, nullable=True) + - Column("engine_status", sqlalchemy.String, nullable=True) + - Column("_updated_at", sqlalchemy.DateTime, nullable=True) """ import logging from datetime import datetime, timezone @@ -31,9 +34,11 @@ import sqlalchemy -from ._tables import migration_table, run_table +from ._tables import analysis_table, migration_table, run_table +from . import legacy_pickle + -_LATEST_SCHEMA_VERSION: Final = 1 +_LATEST_SCHEMA_VERSION: Final = 2 _log = logging.getLogger(__name__) @@ -48,26 +53,52 @@ def migrate(sql_engine: sqlalchemy.engine.Engine) -> None: NOTE: added columns should be nullable. """ with sql_engine.begin() as transaction: - version = _get_schema_version(transaction) + starting_version = _get_schema_version(transaction) - if version is not None: - if version < 1: - _migrate_0_to_1(transaction) + if starting_version is None: + _log.info( + f"Marking fresh database as schema version {_LATEST_SCHEMA_VERSION}." + ) + _stamp_schema_version(transaction) + elif starting_version == _LATEST_SCHEMA_VERSION: _log.info( - f"Migrated database from schema {version}" - f" to version {_LATEST_SCHEMA_VERSION}" + f"Database has schema version {_LATEST_SCHEMA_VERSION}." + " no migrations needed." ) + else: _log.info( - f"Marking fresh database as schema version {_LATEST_SCHEMA_VERSION}" + f"Database has schema version {starting_version}." + f" Migrating to {_LATEST_SCHEMA_VERSION}..." ) - if version != _LATEST_SCHEMA_VERSION: - _insert_migration(transaction) - - -def _insert_migration(transaction: sqlalchemy.engine.Connection) -> None: + if starting_version < 1: + _log.info("Migrating database schema from 0 to 1...") + _migrate_schema_0_to_1(transaction) + if starting_version < 2: + _log.info("Migrating database schema from 1 to 2...") + _migrate_schema_1_to_2(transaction) + + _log.info("Database schema migrations complete.") + _stamp_schema_version(transaction) + + # We migrate data 1->2 unconditionally, even when we haven't just performed a 1->2 schema + # migration. This is to solve the following edge case: + # + # 1) Start on schema 1. + # 2) Update robot software, triggering a migration to schema 2. + # 3) Roll back to older robot software that doesn't understand schema 2. + # Now we've got old software working in a schema 2 database, causing rows to be added + # where schema 2's `completed_analysis_as_document` column is NULL. + # 4) Update robot software again. This won't trigger a schema migration, because the + # database was already migrated to schema 2 once. But we want to get rid of those NULL + # `completed_analysis_as_document` values. + _migrate_data_1_to_2(transaction) + + +def _stamp_schema_version(transaction: sqlalchemy.engine.Connection) -> None: + """Mark the database as having the latest schema version.""" transaction.execute( sqlalchemy.insert(migration_table).values( created_at=datetime.now(tz=timezone.utc), @@ -77,7 +108,7 @@ def _insert_migration(transaction: sqlalchemy.engine.Connection) -> None: def _get_schema_version(transaction: sqlalchemy.engine.Connection) -> Optional[int]: - """Get the starting version of the database. + """Get the current schema version of the given database. Returns: The version found, or None if this is a fresh database that @@ -86,6 +117,12 @@ def _get_schema_version(transaction: sqlalchemy.engine.Connection) -> Optional[i if _is_version_0(transaction): return 0 + # It's important that this takes the highest schema version that we've seen, + # not the most recent one to be added. If you downgrade robot software across + # a schema boundary, the old software will leave the database at its newer schema, + # but stamp it as having "migrated" to the old one. We need to see it as having the newer + # schema, to avoid incorrectly doing a redundant migration when the software is upgraded + # again later. select_latest_version = sqlalchemy.select(migration_table).order_by( sqlalchemy.desc(migration_table.c.version) ) @@ -111,16 +148,8 @@ def _is_version_0(transaction: sqlalchemy.engine.Connection) -> bool: return True -def _migrate_0_to_1(transaction: sqlalchemy.engine.Connection) -> None: - """Migrate to schema version 1. - - This migration adds the following nullable columns to the run table: - - - Column("state_summary, sqlalchemy.PickleType, nullable=True) - - Column("commands", sqlalchemy.PickleType, nullable=True) - - Column("engine_status", sqlalchemy.String, nullable=True) - - Column("_updated_at", sqlalchemy.DateTime, nullable=True) - """ +def _migrate_schema_0_to_1(transaction: sqlalchemy.engine.Connection) -> None: + """Migrate the database from schema 0 to schema 1.""" add_summary_column = sqlalchemy.text("ALTER TABLE run ADD state_summary BLOB") add_commands_column = sqlalchemy.text("ALTER TABLE run ADD commands BLOB") # NOTE: The column type of `STRING` here is mistaken. SQLite won't recognize it, @@ -135,3 +164,66 @@ def _migrate_0_to_1(transaction: sqlalchemy.engine.Connection) -> None: transaction.execute(add_commands_column) transaction.execute(add_status_column) transaction.execute(add_updated_at_column) + + +def _migrate_schema_1_to_2(transaction: sqlalchemy.engine.Connection) -> None: + """Migrate the database from schema 1 to schema 2.""" + add_completed_analysis_as_document_column = sqlalchemy.text( + "ALTER TABLE analysis ADD completed_analysis_as_document VARCHAR" + ) + transaction.execute(add_completed_analysis_as_document_column) + + +def _migrate_data_1_to_2(transaction: sqlalchemy.engine.Connection) -> None: + """Migrate the data that the database contains to take advantage of schema 2. + + Find rows where the `completed_analysis_as_document` column, introduced in schema 2, + is NULL. Populate them with values computed from schema 1's `completed_analysis` column. + + The database is expected to already be at schema 2. This is safe to run again on a database + whose data has already been migrated by this function. + """ + # Local import to work around a circular dependency: + # 1) This module is part of robot_server.persistence + # 2) We're trying to import something from robot_server.protocols + # 3) ...which re-exports stuff from robot_server.protocols.protocol_store + # 4) ...which depends on robot_server.persistence + from robot_server.protocols.analysis_models import CompletedAnalysis + + rows_needing_migration = transaction.execute( + sqlalchemy.select( + analysis_table.c.id, analysis_table.c.completed_analysis + ).where(analysis_table.c.completed_analysis_as_document.is_(None)) + ).all() + + if rows_needing_migration: + _log.info( + f"Migrating {len(rows_needing_migration)} analysis documents." + f" This may take a while..." + ) + + for index, row in enumerate(rows_needing_migration): + _log.info( + f"Migrating analysis {index+1}/{len(rows_needing_migration)}, {row.id}..." + ) + + v1_completed_analysis = CompletedAnalysis.parse_obj( + legacy_pickle.loads(row.completed_analysis) + ) + + v2_completed_analysis_as_document = v1_completed_analysis.json( + # by_alias and exclude_none should match how + # FastAPI + Pydantic + our customizations serialize these objects + # over the `GET /protocols/:id/analyses/:id` endpoint. + by_alias=True, + exclude_none=True, + ) + + transaction.execute( + sqlalchemy.update(analysis_table) + .where(analysis_table.c.id == row.id) + .values(completed_analysis_as_document=v2_completed_analysis_as_document) + ) + + if rows_needing_migration: + _log.info("Done migrating analysis documents.") diff --git a/robot-server/robot_server/persistence/_tables.py b/robot-server/robot_server/persistence/_tables.py index edb10200588..7de9fa60465 100644 --- a/robot-server/robot_server/persistence/_tables.py +++ b/robot-server/robot_server/persistence/_tables.py @@ -2,6 +2,7 @@ import sqlalchemy from . import legacy_pickle +from .pickle_protocol_version import PICKLE_PROTOCOL_VERSION from ._utc_datetime import UTCDateTime _metadata = sqlalchemy.MetaData() @@ -56,9 +57,19 @@ ), sqlalchemy.Column( "completed_analysis", + # Stores a pickled dict. See CompletedAnalysisStore. + # TODO(mm, 2023-08-30): Remove this. See https://opentrons.atlassian.net/browse/RSS-98. sqlalchemy.LargeBinary, nullable=False, ), + sqlalchemy.Column( + "completed_analysis_as_document", + # Stores the same data as completed_analysis, but serialized as a JSON string. + sqlalchemy.String, + # This column should never be NULL in practice. + # It needs to be nullable=True because of limitations in SQLite and our migration code. + nullable=True, + ), ) @@ -84,13 +95,13 @@ # column added in schema v1 sqlalchemy.Column( "state_summary", - sqlalchemy.PickleType(pickler=legacy_pickle), + sqlalchemy.PickleType(pickler=legacy_pickle, protocol=PICKLE_PROTOCOL_VERSION), nullable=True, ), # column added in schema v1 sqlalchemy.Column( "commands", - sqlalchemy.PickleType(pickler=legacy_pickle), + sqlalchemy.PickleType(pickler=legacy_pickle, protocol=PICKLE_PROTOCOL_VERSION), nullable=True, ), # column added in schema v1 diff --git a/robot-server/robot_server/persistence/legacy_pickle.py b/robot-server/robot_server/persistence/legacy_pickle.py index 29119f9b3f9..0ad36054cbc 100644 --- a/robot-server/robot_server/persistence/legacy_pickle.py +++ b/robot-server/robot_server/persistence/legacy_pickle.py @@ -158,6 +158,11 @@ def _get_legacy_ot_types() -> List[_LegacyTypeInfo]: _LegacyTypeInfo(original_name="LabwareMovementStrategy", current_type=LabwareMovementStrategy) ) + from opentrons_shared_data.labware.labware_definition import LabwareRole + _legacy_ot_types.append( + _LegacyTypeInfo(original_name="LabwareRole", current_type=LabwareRole) + ) + from opentrons.protocol_engine import ModuleModel _legacy_ot_types.append( _LegacyTypeInfo(original_name="ModuleModel", current_type=ModuleModel) diff --git a/robot-server/robot_server/persistence/pickle_protocol_version.py b/robot-server/robot_server/persistence/pickle_protocol_version.py new file mode 100644 index 00000000000..a4d9702bf07 --- /dev/null +++ b/robot-server/robot_server/persistence/pickle_protocol_version.py @@ -0,0 +1,23 @@ +# noqa: D100 + + +from typing_extensions import Final + + +PICKLE_PROTOCOL_VERSION: Final = 4 +"""The version of Python's pickle protocol that we should use for serializing new objects. + +We set this to v4 because it's the least common denominator between all of our environments. +At the time of writing (2023-09-05): + +* Flex: Python 3.8, pickle protocol v5 by default +* OT-2: Python 3.7, pickle protocol v4 by default +* Typical local dev environments: Python 3.7, pickle protocol v4 by default + +For troubleshooting, we want our dev environments be able to read pickles created by any robot. +""" + + +# TODO(mm, 2023-09-05): Delete this when robot-server stops pickling new objects +# (https://opentrons.atlassian.net/browse/RSS-98), or when we upgrade the Python version +# in our dev environments. diff --git a/robot-server/robot_server/protocols/analysis_store.py b/robot-server/robot_server/protocols/analysis_store.py index 4bb9d53846a..29ddb0d82ec 100644 --- a/robot-server/robot_server/protocols/analysis_store.py +++ b/robot-server/robot_server/protocols/analysis_store.py @@ -179,7 +179,7 @@ async def get(self, analysis_id: str) -> ProtocolAnalysis: """Get a single protocol analysis by its ID. Raises: - AnalysisNotFoundError + AnalysisNotFoundError: If there is no analysis with the given ID. """ pending_analysis = self._pending_store.get(analysis_id=analysis_id) completed_analysis_resource = await self._completed_store.get_by_id( @@ -193,6 +193,21 @@ async def get(self, analysis_id: str) -> ProtocolAnalysis: else: raise AnalysisNotFoundError(analysis_id=analysis_id) + async def get_as_document(self, analysis_id: str) -> str: + """Get a single completed protocol analysis by its ID, as a pre-serialized JSON document. + + Raises: + AnalysisNotFoundError: If there is no completed analysis with the given ID. + Unlike `get()`, this is raised if the analysis exists, but is pending. + """ + completed_analysis_document = await self._completed_store.get_by_id_as_document( + analysis_id=analysis_id + ) + if completed_analysis_document is not None: + return completed_analysis_document + else: + raise AnalysisNotFoundError(analysis_id=analysis_id) + def get_summaries_by_protocol(self, protocol_id: str) -> List[AnalysisSummary]: """Get summaries of all analyses for a protocol, in order from oldest first. diff --git a/robot-server/robot_server/protocols/completed_analysis_store.py b/robot-server/robot_server/protocols/completed_analysis_store.py index 749c288783d..29ee571d08c 100644 --- a/robot-server/robot_server/protocols/completed_analysis_store.py +++ b/robot-server/robot_server/protocols/completed_analysis_store.py @@ -1,14 +1,17 @@ """Completed analysis storage and access.""" from __future__ import annotations -from typing import Dict, List, Optional +import asyncio +from typing import Dict, List, Optional, Tuple from logging import getLogger from dataclasses import dataclass + import sqlalchemy import anyio from robot_server.persistence import analysis_table, sqlite_rowid from robot_server.persistence import legacy_pickle +from robot_server.persistence.pickle_protocol_version import PICKLE_PROTOCOL_VERSION from .analysis_models import CompletedAnalysis from .analysis_memcache import MemoryCache @@ -40,10 +43,16 @@ async def to_sql_values(self) -> Dict[str, object]: Avoid calling this from inside a SQL transaction, since it might be slow. """ - def serialize_completed_analysis() -> bytes: - return legacy_pickle.dumps(self.completed_analysis.dict()) + def serialize_completed_analysis() -> Tuple[bytes, str]: + serialized_pickle = _serialize_completed_analysis_to_pickle( + self.completed_analysis + ) + serialized_json = _serialize_completed_analysis_to_json( + self.completed_analysis + ) + return serialized_pickle, serialized_json - serialized_completed_analysis = await anyio.to_thread.run_sync( + serialized_pickle, serialized_json = await anyio.to_thread.run_sync( serialize_completed_analysis, # Cancellation may orphan the worker thread, # but that should be harmless in this case. @@ -54,7 +63,8 @@ def serialize_completed_analysis() -> bytes: "id": self.id, "protocol_id": self.protocol_id, "analyzer_version": self.analyzer_version, - "completed_analysis": serialized_completed_analysis, + "completed_analysis": serialized_pickle, + "completed_analysis_as_document": serialized_json, } @classmethod @@ -110,10 +120,26 @@ class CompletedAnalysisStore: cache because the access methods are async, and lru_cache doesn't work with those. """ - _memcache: MemoryCache[str, CompletedAnalysisResource] _sql_engine: sqlalchemy.engine.Engine _current_analyzer_version: str + # Parsing and validating blobs from the database into CompletedAnalysisResources + # is a major compute bottleneck. It can take minutes for long protocols. + # Caching it can speed up the overall HTTP response time by ~10x (after the first request). + _memcache: MemoryCache[str, CompletedAnalysisResource] + + # This is a lock for performance, not correctness. + # + # If multiple clients request the same resources all at once, we want to handle the requests + # serially to take the most advantage of _memcache. Otherwise, two concurrent requests for the + # same uncached CompletedAnalysisResource would each do their own work to parse it, which would + # be redundant and waste compute time. + # + # Handling requests serially does not harm overall throughput because even if we handled them + # concurrently, we'd be bottlenecked by Python's GIL. It will, however, increase latency for + # a small request if it gets blocked behind a big request. + _memcache_lock: asyncio.Lock + def __init__( self, sql_engine: sqlalchemy.engine.Engine, @@ -121,28 +147,56 @@ def __init__( current_analyzer_version: str, ) -> None: self._sql_engine = sql_engine - self._memcache = memory_cache self._current_analyzer_version = current_analyzer_version + self._memcache = memory_cache + self._memcache_lock = asyncio.Lock() async def get_by_id(self, analysis_id: str) -> Optional[CompletedAnalysisResource]: """Return the analysis with the given ID, if it exists.""" - try: - return self._memcache.get(analysis_id) - except KeyError: - pass - statement = sqlalchemy.select(analysis_table).where( - analysis_table.c.id == analysis_id - ) + async with self._memcache_lock: + try: + return self._memcache.get(analysis_id) + except KeyError: + pass + + statement = sqlalchemy.select(analysis_table).where( + analysis_table.c.id == analysis_id + ) + with self._sql_engine.begin() as transaction: + try: + result = transaction.execute(statement).one() + except sqlalchemy.exc.NoResultFound: + return None + + resource = await CompletedAnalysisResource.from_sql_row( + result, self._current_analyzer_version + ) + self._memcache.insert(resource.id, resource) + + return resource + + async def get_by_id_as_document(self, analysis_id: str) -> Optional[str]: + """Return the analysis with the given ID, if it exists. + + This is like `get_by_id()`, except it returns the analysis as a pre-serialized JSON + document. + """ + statement = sqlalchemy.select( + analysis_table.c.completed_analysis_as_document + ).where(analysis_table.c.id == analysis_id) + with self._sql_engine.begin() as transaction: try: - result = transaction.execute(statement).one() + document: Optional[str] = transaction.execute(statement).scalar_one() except sqlalchemy.exc.NoResultFound: + # No analysis with this ID. return None - resource = await CompletedAnalysisResource.from_sql_row( - result, self._current_analyzer_version - ) - self._memcache.insert(resource.id, resource) - return resource + + # Although the completed_analysis_as_document column is nullable, + # our migration code is supposed to ensure that it's never NULL in practice. + assert document is not None + + return document async def get_by_protocol( self, protocol_id: str @@ -152,55 +206,57 @@ async def get_by_protocol( If protocol_id doesn't point to a valid protocol, returns an empty list; doesn't raise an error. """ - id_statement = ( - sqlalchemy.select(analysis_table.c.id) - .where(analysis_table.c.protocol_id == protocol_id) - .order_by(sqlite_rowid) - ) - with self._sql_engine.begin() as transaction: - ordered_analyses_for_protocol = [ - row.id for row in transaction.execute(id_statement).all() - ] - - analysis_set = set(ordered_analyses_for_protocol) - cached_analyses = { - analysis_id - for analysis_id in ordered_analyses_for_protocol - if self._memcache.contains(analysis_id) - } - uncached_analyses = analysis_set - cached_analyses - - # Because we'll be loading whatever resources are not currently cached from sql - # using an async method, if this method is called reentrantly then inserting those - # newly-fetched resources into the memcache could race and eject resources we just - # added and were about to return. To prevent this, we'll make a second memcache just - # for this coroutine - since we don't care about size limitations we can just use a - # dict. - local_memcache: Dict[str, CompletedAnalysisResource] = {} - - for key in cached_analyses: - local_memcache[key] = self._memcache.get(key) - - if uncached_analyses: - statement = ( - sqlalchemy.select(analysis_table) - .where(analysis_table.c.id.in_(uncached_analyses)) + async with self._memcache_lock: + id_statement = ( + sqlalchemy.select(analysis_table.c.id) + .where(analysis_table.c.protocol_id == protocol_id) .order_by(sqlite_rowid) ) with self._sql_engine.begin() as transaction: - results = transaction.execute(statement).all() - for r in results: - resource = await CompletedAnalysisResource.from_sql_row( - r, self._current_analyzer_version + ordered_analyses_for_protocol = [ + row.id for row in transaction.execute(id_statement).all() + ] + + analysis_set = set(ordered_analyses_for_protocol) + cached_analyses = { + analysis_id + for analysis_id in ordered_analyses_for_protocol + if self._memcache.contains(analysis_id) + } + uncached_analyses = analysis_set - cached_analyses + + # Because we'll be loading whatever resources are not currently cached from sql + # using an async method, if this method is called reentrantly then inserting those + # newly-fetched resources into the memcache could race and eject resources we just + # added and were about to return. To prevent this, we'll make a second memcache just + # for this coroutine - since we don't care about size limitations we can just use a + # dict. + local_memcache: Dict[str, CompletedAnalysisResource] = {} + + for key in cached_analyses: + local_memcache[key] = self._memcache.get(key) + + if uncached_analyses: + statement = ( + sqlalchemy.select(analysis_table) + .where(analysis_table.c.id.in_(uncached_analyses)) + .order_by(sqlite_rowid) ) - local_memcache[resource.id] = resource - self._memcache.insert(resource.id, resource) - - # note: we want to iterate through ordered_analyseS_for_protocol rather than - # just the local_memcache dict to preserve total ordering - return [ - local_memcache[analysis_id] for analysis_id in ordered_analyses_for_protocol - ] + with self._sql_engine.begin() as transaction: + results = transaction.execute(statement).all() + for r in results: + resource = await CompletedAnalysisResource.from_sql_row( + r, self._current_analyzer_version + ) + local_memcache[resource.id] = resource + self._memcache.insert(resource.id, resource) + + # note: we want to iterate through ordered_analyseS_for_protocol rather than + # just the local_memcache dict to preserve total ordering + return [ + local_memcache[analysis_id] + for analysis_id in ordered_analyses_for_protocol + ] def get_ids_by_protocol(self, protocol_id: str) -> List[str]: """Like `get_by_protocol()`, but return only the ID of each analysis.""" @@ -229,3 +285,21 @@ async def add(self, completed_analysis_resource: CompletedAnalysisResource) -> N self._memcache.insert( completed_analysis_resource.id, completed_analysis_resource ) + + +def _serialize_completed_analysis_to_pickle( + completed_analysis: CompletedAnalysis, +) -> bytes: + return legacy_pickle.dumps( + completed_analysis.dict(), protocol=PICKLE_PROTOCOL_VERSION + ) + + +def _serialize_completed_analysis_to_json(completed_analysis: CompletedAnalysis) -> str: + return completed_analysis.json( + # by_alias and exclude_none should match how + # FastAPI + Pydantic + our customizations serialize these objects + # over the `GET /protocols/:id/analyses/:id` endpoint. + by_alias=True, + exclude_none=True, + ) diff --git a/robot-server/robot_server/protocols/router.py b/robot-server/robot_server/protocols/router.py index 6bad69afb33..1ce0337469b 100644 --- a/robot-server/robot_server/protocols/router.py +++ b/robot-server/robot_server/protocols/router.py @@ -3,11 +3,12 @@ from textwrap import dedent from datetime import datetime from pathlib import Path +from typing import List, Optional, Union +from typing_extensions import Literal from fastapi import APIRouter, Depends, File, UploadFile, status, Form +from fastapi.responses import PlainTextResponse from pydantic import BaseModel, Field -from typing import List, Optional, Union -from typing_extensions import Literal from opentrons.protocol_reader import ( ProtocolReader, @@ -307,7 +308,7 @@ async def get_protocols( @protocols_router.get( path="/protocols/ids", - summary="[Internal] Get uploaded protocol ids", + summary="[Internal] Get uploaded protocol IDs", description=( "Get the IDs of all protocols stored on the server." "\n\n" @@ -501,3 +502,58 @@ async def get_protocol_analysis_by_id( ) from error return await PydanticResponse.create(content=SimpleBody.construct(data=analysis)) + + +@protocols_router.get( + path="/protocols/{protocolId}/analyses/{analysisId}/asDocument", + summary="[Experimental] Get one of a protocol's analyses as a raw document", + description=( + "**Warning:** This endpoint is experimental. We may change or remove it without warning." + "\n\n" + "This is a faster alternative to `GET /protocols/{protocolId}/analyses`" + " and `GET /protocols/{protocolId}/analyses/{analysisId}`." + " For large analyses (10k+ commands), those other endpoints can take minutes to respond," + " whereas this one should only take a few seconds." + "\n\n" + "For a completed analysis, this returns the same JSON data as the" + " `GET /protocols/:id/analyses/:id` endpoint, except that it's not wrapped in a top-level" + " `data` object." + "\n\n" + "For a *pending* analysis, this returns a 404 response, unlike those other" + ' endpoints, which return a 200 response with `"status": "pending"`.' + ), + responses={ + status.HTTP_404_NOT_FOUND: { + "model": ErrorBody[Union[ProtocolNotFound, AnalysisNotFound]] + }, + }, +) +async def get_protocol_analysis_as_document( + protocolId: str, + analysisId: str, + protocol_store: ProtocolStore = Depends(get_protocol_store), + analysis_store: AnalysisStore = Depends(get_analysis_store), +) -> PlainTextResponse: + """Get a protocol analysis by analysis ID. + + Arguments: + protocolId: The ID of the protocol, pulled from the URL. + analysisId: The ID of the analysis, pulled from the URL. + protocol_store: Protocol resource storage. + analysis_store: Analysis resource storage. + """ + if not protocol_store.has(protocolId): + raise ProtocolNotFound(detail=f"Protocol {protocolId} not found").as_error( + status.HTTP_404_NOT_FOUND + ) + + try: + # TODO(mm, 2022-04-28): This will erroneously return an analysis even if + # this analysis isn't owned by this protocol. This should be an error. + analysis = await analysis_store.get_as_document(analysisId) + except AnalysisNotFoundError as error: + raise AnalysisNotFound(detail=str(error)).as_error( + status.HTTP_404_NOT_FOUND + ) from error + + return PlainTextResponse(content=analysis, media_type="application/json") diff --git a/robot-server/robot_server/runs/engine_store.py b/robot-server/robot_server/runs/engine_store.py index 497e1e91a2f..40fd5d343ca 100644 --- a/robot-server/robot_server/runs/engine_store.py +++ b/robot-server/robot_server/runs/engine_store.py @@ -1,6 +1,7 @@ """In-memory storage of ProtocolEngine instances.""" from typing import List, NamedTuple, Optional +from opentrons.protocol_engine.types import PostRunHardwareState from opentrons_shared_data.robot.dev_types import RobotType from opentrons.config import feature_flags @@ -219,7 +220,11 @@ async def clear(self) -> RunResult: state_view = engine.state_view if state_view.commands.get_is_okay_to_clear(): - await engine.finish(drop_tips_and_home=False, set_run_status=False) + await engine.finish( + drop_tips_after_run=False, + set_run_status=False, + post_run_hardware_state=PostRunHardwareState.STAY_ENGAGED_IN_PLACE, + ) else: raise EngineConflictError("Current run is not idle or stopped.") diff --git a/robot-server/robot_server/service/legacy/routers/networking.py b/robot-server/robot_server/service/legacy/routers/networking.py index 8a1786c3686..91a6b219a4b 100644 --- a/robot-server/robot_server/service/legacy/routers/networking.py +++ b/robot-server/robot_server/service/legacy/routers/networking.py @@ -4,10 +4,11 @@ from starlette import status from starlette.responses import JSONResponse -from fastapi import APIRouter, HTTPException, File, Path, UploadFile +from typing import Optional +from fastapi import APIRouter, HTTPException, File, Path, UploadFile, Query + from opentrons_shared_data.errors import ErrorCodes from opentrons.system import nmcli, wifi - from robot_server.errors import LegacyErrorResponse from robot_server.service.legacy.models import V1BasicResponse from robot_server.service.legacy.models.networking import ( @@ -36,7 +37,7 @@ @router.get( "/networking/status", summary="Query the current network connectivity state", - description="Gets information about the OT-2's network interfaces " + description="Gets information about the robot's network interfaces " "including their connectivity, their " "addresses, and their networking info", response_model=NetworkingStatus, @@ -60,19 +61,30 @@ async def get_networking_status() -> NetworkingStatus: @router.get( "/wifi/list", summary="Scan for visible Wi-Fi networks", - description="Scans for beaconing WiFi networks and returns the " - "list of visible ones along with some data about " - "their security and strength", + description="Returns the list of the visible wifi networks " + "along with some data about their security and strength. " + "Only use rescan=True based on the user needs like clicking on" + "the scan network button and not to just poll.", response_model=WifiNetworks, ) -async def get_wifi_networks() -> WifiNetworks: - networks = await nmcli.available_ssids() +async def get_wifi_networks( + rescan: Optional[bool] = Query( + default=False, + description=( + "If `true` it forces a rescan for beaconing WiFi networks, " + "this is an expensive operation which can take ~10 seconds." + "If `false` it returns the cached wifi networks, " + "letting the system decide when to do a rescan." + ), + ) +) -> WifiNetworks: + networks = await nmcli.available_ssids(rescan) return WifiNetworks(list=[WifiNetworkFull(**n) for n in networks]) @router.post( path="/wifi/configure", - summary="Configure the OT-2's Wi-Fi", + summary="Configure the robot's Wi-Fi", description=( "Configures the wireless network interface to " "connect to a network" ), @@ -134,7 +146,7 @@ async def get_wifi_keys(): @router.post( "/wifi/keys", - description="Send a new key file to the OT-2", + description="Send a new key file to the robot", responses={status.HTTP_200_OK: {"model": AddWifiKeyFileResponse}}, response_model=AddWifiKeyFileResponse, status_code=status.HTTP_201_CREATED, @@ -158,7 +170,7 @@ async def post_wifi_key(key: UploadFile = File(...)): @router.delete( path="/wifi/keys/{key_uuid}", - description="Delete a key file from the OT-2", + description="Delete a key file from the robot", response_model=V1BasicResponse, responses={ status.HTTP_404_NOT_FOUND: {"model": LegacyErrorResponse}, @@ -211,7 +223,7 @@ async def get_eap_options() -> EapOptions: @router.post( "/wifi/disconnect", - summary="Disconnect the OT-2 from Wi-Fi", + summary="Disconnect the robot from Wi-Fi", description="Deactivates the Wi-Fi connection and removes it " "from known connections", response_model=V1BasicResponse, diff --git a/robot-server/robot_server/service/pipette_offset/router.py b/robot-server/robot_server/service/pipette_offset/router.py index c6e04dc2a45..e3229a0c3e7 100644 --- a/robot-server/robot_server/service/pipette_offset/router.py +++ b/robot-server/robot_server/service/pipette_offset/router.py @@ -1,15 +1,18 @@ from starlette import status -from fastapi import APIRouter +from fastapi import APIRouter, Depends from typing import Optional from opentrons import types as ot_types from opentrons.calibration_storage.ot2 import pipette_offset, models +from robot_server.hardware import get_ot2_hardware from robot_server.errors import ErrorBody from robot_server.service.pipette_offset import models as pip_models from robot_server.service.errors import RobotServerError, CommonErrorDef from robot_server.service.shared_models import calibration as cal_model +from opentrons.hardware_control import API + router = APIRouter() @@ -45,7 +48,9 @@ def _format_calibration( response_model=pip_models.MultipleCalibrationsResponse, ) async def get_all_pipette_offset_calibrations( - pipette_id: Optional[str] = None, mount: Optional[pip_models.MountType] = None + pipette_id: Optional[str] = None, + mount: Optional[pip_models.MountType] = None, + _: API = Depends(get_ot2_hardware), ) -> pip_models.MultipleCalibrationsResponse: all_calibrations = pipette_offset.get_all_pipette_offset_calibrations() @@ -75,7 +80,7 @@ async def get_all_pipette_offset_calibrations( responses={status.HTTP_404_NOT_FOUND: {"model": ErrorBody}}, ) async def delete_specific_pipette_offset_calibration( - pipette_id: str, mount: pip_models.MountType + pipette_id: str, mount: pip_models.MountType, _: API = Depends(get_ot2_hardware) ): try: pipette_offset.delete_pipette_offset_file( diff --git a/robot-server/robot_server/service/tip_length/router.py b/robot-server/robot_server/service/tip_length/router.py index d79973039d6..2d6461e0b7f 100644 --- a/robot-server/robot_server/service/tip_length/router.py +++ b/robot-server/robot_server/service/tip_length/router.py @@ -1,15 +1,18 @@ from starlette import status -from fastapi import APIRouter +from fastapi import APIRouter, Depends from typing import Optional from opentrons.calibration_storage import types as cal_types from opentrons.calibration_storage.ot2 import tip_length, models +from robot_server.hardware import get_ot2_hardware from robot_server.errors import ErrorBody from robot_server.service.tip_length import models as tl_models from robot_server.service.errors import RobotServerError, CommonErrorDef from robot_server.service.shared_models import calibration as cal_model +from opentrons.hardware_control import API + router = APIRouter() @@ -48,6 +51,7 @@ async def get_all_tip_length_calibrations( tiprack_hash: Optional[str] = None, pipette_id: Optional[str] = None, tiprack_uri: Optional[str] = None, + _: API = Depends(get_ot2_hardware), ) -> tl_models.MultipleCalibrationsResponse: all_calibrations = tip_length.get_all_tip_length_calibrations() if not all_calibrations: @@ -79,7 +83,9 @@ async def get_all_tip_length_calibrations( "serial and tiprack hash", responses={status.HTTP_404_NOT_FOUND: {"model": ErrorBody}}, ) -async def delete_specific_tip_length_calibration(tiprack_hash: str, pipette_id: str): +async def delete_specific_tip_length_calibration( + tiprack_hash: str, pipette_id: str, _: API = Depends(get_ot2_hardware) +): try: tip_length.delete_tip_length_calibration(tiprack_hash, pipette_id) except cal_types.TipLengthCalNotFound: diff --git a/robot-server/tests/conftest.py b/robot-server/tests/conftest.py index 1e30fb7c4a6..a414541f45f 100644 --- a/robot-server/tests/conftest.py +++ b/robot-server/tests/conftest.py @@ -30,7 +30,7 @@ from opentrons.types import Point, Mount from robot_server import app -from robot_server.hardware import get_hardware +from robot_server.hardware import get_hardware, get_ot2_hardware from robot_server.versioning import API_VERSION_HEADER, LATEST_API_VERSION_HEADER_VALUE from robot_server.service.session.manager import SessionManager from robot_server.persistence import get_sql_engine, create_sql_engine @@ -139,11 +139,23 @@ async def get_version_override() -> ComponentVersions: del app.dependency_overrides[get_versions] +@pytest.fixture +def _override_ot2_hardware_with_mock(hardware: MagicMock) -> Iterator[None]: + async def get_ot2_hardware_override() -> API: + """Override for the get_ot2_hardware FastAPI dependency.""" + return MagicMock(spec=API) + + app.dependency_overrides[get_ot2_hardware] = get_ot2_hardware_override + yield + del app.dependency_overrides[get_ot2_hardware] + + @pytest.fixture def api_client( _override_hardware_with_mock: None, _override_sql_engine_with_mock: None, _override_version_with_mock: None, + _override_ot2_hardware_with_mock: None, ) -> TestClient: client = TestClient(app) client.headers.update({API_VERSION_HEADER: LATEST_API_VERSION_HEADER_VALUE}) diff --git a/robot-server/tests/integration/http_api/persistence/test_compatibility.py b/robot-server/tests/integration/http_api/persistence/test_compatibility.py index ebdf26e9037..6df9a36602f 100644 --- a/robot-server/tests/integration/http_api/persistence/test_compatibility.py +++ b/robot-server/tests/integration/http_api/persistence/test_compatibility.py @@ -13,6 +13,8 @@ from .persistence_snapshots_dir import PERSISTENCE_SNAPSHOTS_DIR +# Allow plenty of time for database migrations, which can take a while in our CI runners. +_STARTUP_TIMEOUT = 60 _POLL_INTERVAL = 0.1 _RUN_TIMEOUT = 5 @@ -93,8 +95,8 @@ async def test_protocols_analyses_and_runs_available_from_older_persistence_dir( ), "Dev Robot is running and must not be." with DevServer(port=_PORT, persistence_directory=snapshot.get_copy()) as server: server.start() - assert ( - await robot_client.wait_until_alive() + assert await robot_client.wait_until_alive( + _STARTUP_TIMEOUT ), "Dev Robot never became available." all_protocols = (await robot_client.get_protocols()).json()["data"] @@ -121,6 +123,12 @@ async def test_protocols_analyses_and_runs_available_from_older_persistence_dir( == analysis_ids_from_all_analyses_endpoint ) + for analysis_id in analysis_ids_from_all_protocols_endpoint: + # Make sure this doesn't 404. + await robot_client.get_analysis_as_document( + protocol_id=protocol_id, analysis_id=analysis_id + ) + number_of_analyses = len(analysis_ids_from_all_protocols_endpoint) if protocol_id in snapshot.protocols_with_no_analyses: assert number_of_analyses == 0 @@ -167,8 +175,9 @@ async def test_rerun_flex_dev_compat() -> None: ), "Dev Robot is running but it should not be." with DevServer(persistence_directory=snapshot.get_copy(), port=_PORT) as server: server.start() - await client.wait_until_alive() - assert await client.wait_until_alive(), "Dev Robot never became available." + assert await client.wait_until_alive( + _STARTUP_TIMEOUT + ), "Dev Robot never became available." [protocol] = (await client.get_protocols()).json()["data"] new_run = ( diff --git a/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml b/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml new file mode 100644 index 00000000000..a756ea10e1b --- /dev/null +++ b/robot-server/tests/integration/http_api/protocols/test_analyses.tavern.yaml @@ -0,0 +1,86 @@ +test_name: Test the protocol analysis endpoints + +marks: + - usefixtures: + - ot2_server_base_url + +stages: + - name: Upload a protocol + request: + url: '{ot2_server_base_url}/protocols' + method: POST + files: + files: 'tests/integration/protocols/basic_transfer_standalone.py' + response: + save: + json: + protocol_id: data.id + analysis_id: data.analysisSummaries[0].id + strict: + - json:off + status_code: 201 + json: + data: + analyses: [] + analysisSummaries: + - id: !anystr + status: pending + + - name: Check that the analysis summary is present in /protocols/:id; retry until it says it's completed + max_retries: 5 + delay_after: 1 + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}' + response: + status_code: 200 + json: + data: + analyses: [] + analysisSummaries: + - id: '{analysis_id}' + status: completed + id: !anything + protocolType: !anything + files: !anything + createdAt: !anything + robotType: !anything + metadata: !anything + links: !anything + + - name: Check that the analysis data is present in /protocols/:id/analyses/:id + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}/analyses/{analysis_id}' + response: + save: + json: + analysis_data: data + strict: + - json:off + json: + data: + id: '{analysis_id}' + commands: + # Check for this command's presence as a smoke test that the analysis isn't empty. + - commandType: loadPipette + + + - name: Check that the analysis data is present in /protocols/:id/analyses + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}/analyses' + response: + json: + data: + - !force_format_include '{analysis_data}' + meta: + cursor: 0 + totalLength: 1 + + - name: Check that the analysis data is present in /protocols/:id/analyses/:id/asDocument + request: + url: '{ot2_server_base_url}/protocols/{protocol_id}/analyses/{analysis_id}/asDocument' + response: + headers: + # This endpoint's steps outside our usual FastAPI implementation. + # We need to make sure we get the Content-Type right because FastAPI won't do it for us. + Content-Type: application/json + json: !force_format_include '{analysis_data}' diff --git a/robot-server/tests/integration/http_api/protocols/test_persistence.py b/robot-server/tests/integration/http_api/protocols/test_persistence.py index c5e375ba52f..2c6c62bf930 100644 --- a/robot-server/tests/integration/http_api/protocols/test_persistence.py +++ b/robot-server/tests/integration/http_api/protocols/test_persistence.py @@ -167,7 +167,13 @@ async def _get_all_analyses(robot_client: RobotClient) -> Dict[str, List[object] protocol_id=protocol_id, analysis_id=analysis_id, ) - analyses_on_this_protocol.append(analysis_response.json()["data"]) + analysis_as_document_response = await robot_client.get_analysis_as_document( + protocol_id=protocol_id, + analysis_id=analysis_id, + ) + analyses_on_this_protocol.append( + (analysis_response.json()["data"], analysis_as_document_response.json()) + ) analyses_by_protocol_id[protocol_id] = analyses_on_this_protocol diff --git a/robot-server/tests/integration/robot_client.py b/robot-server/tests/integration/robot_client.py index 23c13bca7c3..32cb87f4201 100644 --- a/robot-server/tests/integration/robot_client.py +++ b/robot-server/tests/integration/robot_client.py @@ -261,13 +261,23 @@ async def get_analyses(self, protocol_id: str) -> Response: return response async def get_analysis(self, protocol_id: str, analysis_id: str) -> Response: - """GET /protocols/{protocol_id}/{analysis_id}.""" + """GET /protocols/{protocol_id}/analyses/{analysis_id}.""" response = await self.httpx_client.get( url=f"{self.base_url}/protocols/{protocol_id}/analyses/{analysis_id}" ) response.raise_for_status() return response + async def get_analysis_as_document( + self, protocol_id: str, analysis_id: str + ) -> Response: + """GET /protocols/{protocol_id}/analyses/{analysis_id}/asDocument.""" + response = await self.httpx_client.get( + url=f"{self.base_url}/protocols/{protocol_id}/analyses/{analysis_id}/asDocument" + ) + response.raise_for_status() + return response + async def delete_run(self, run_id: str) -> Response: """DELETE /runs/{run_id}.""" response = await self.httpx_client.delete(f"{self.base_url}/runs/{run_id}") diff --git a/robot-server/tests/integration/test_pipette_offset_access.tavern.yaml b/robot-server/tests/integration/test_pipette_offset_access.tavern.yaml index 4145c682740..90495534bfc 100644 --- a/robot-server/tests/integration/test_pipette_offset_access.tavern.yaml +++ b/robot-server/tests/integration/test_pipette_offset_access.tavern.yaml @@ -124,3 +124,27 @@ stages: mount: 'right' response: status_code: 200 + +--- +test_name: Pipette calibrations inaccessible on flex +marks: + - ot3_only + - usefixtures: + - ot3_server_base_url + - set_up_pipette_offset_temp_directory +stages: + - name: GET request 403s + request: + url: "{ot3_server_base_url}/calibration/pipette_offset" + method: GET + response: + status_code: 403 + - name: DELETE request 403s + request: + url: "{ot3_server_base_url}/calibration/pipette_offset" + method: DELETE + params: + pipette_id: '321' + mount: 'right' + response: + status_code: 403 diff --git a/robot-server/tests/integration/test_tip_length_access.tavern.yaml b/robot-server/tests/integration/test_tip_length_access.tavern.yaml index d8d2f009485..9b181e0877a 100644 --- a/robot-server/tests/integration/test_tip_length_access.tavern.yaml +++ b/robot-server/tests/integration/test_tip_length_access.tavern.yaml @@ -159,3 +159,22 @@ stages: method: DELETE response: status_code: 404 +--- +test_name: Tip length inaccessible on flex +marks: + - ot3_only + - usefixtures: + - ot3_server_base_url +stages: + - name: GET request 403s + request: + url: "{ot3_server_base_url}/calibration/tip_length" + method: GET + response: + status_code: 403 + - name: DELETE request 403s + request: + url: "{ot3_server_base_url}/calibration/tip_length" + method: DELETE + response: + status_code: 403 diff --git a/robot-server/tests/persistence/test_migrations.py b/robot-server/tests/persistence/test_migrations.py deleted file mode 100644 index 3ae8a6eb3a5..00000000000 --- a/robot-server/tests/persistence/test_migrations.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Test SQL database migrations.""" -from pathlib import Path -from typing import Generator - -import pytest -import sqlalchemy -from pytest_lazyfixture import lazy_fixture # type: ignore[import] - -from robot_server.persistence import create_sql_engine -from robot_server.persistence import ( - migration_table, - run_table, - action_table, - protocol_table, - analysis_table, -) - - -TABLES = [run_table, action_table, protocol_table, analysis_table] - - -@pytest.fixture -def database_v0(tmp_path: Path) -> Path: - """Create a database matching schema version 0.""" - db_path = tmp_path / "migration-test-v0.db" - sql_engine = create_sql_engine(db_path) - sql_engine.execute("DROP TABLE migration") - sql_engine.execute("DROP TABLE run") - sql_engine.execute( - """ - CREATE TABLE run ( - id VARCHAR NOT NULL, - created_at DATETIME NOT NULL, - protocol_id VARCHAR, - PRIMARY KEY (id), - FOREIGN KEY(protocol_id) REFERENCES protocol (id) - ) - """ - ) - sql_engine.dispose() - return db_path - - -@pytest.fixture -def database_v1(tmp_path: Path) -> Path: - """Create a database matching schema version 1.""" - db_path = tmp_path / "migration-test-v1.db" - sql_engine = create_sql_engine(db_path) - sql_engine.dispose() - return db_path - - -@pytest.fixture -def subject(database_path: Path) -> Generator[sqlalchemy.engine.Engine, None, None]: - """Get a SQLEngine test subject. - - The tests in this suite will use this SQLEngine to test - that migrations happen properly. For other tests, the `sql_engine` - fixture in `conftest.py` should be used, instead. - """ - engine = create_sql_engine(database_path) - yield engine - engine.dispose() - - -@pytest.mark.parametrize( - "database_path", - [ - lazy_fixture("database_v0"), - lazy_fixture("database_v1"), - ], -) -def test_migration(subject: sqlalchemy.engine.Engine) -> None: - """It should migrate a table.""" - migrations = subject.execute(sqlalchemy.select(migration_table)).all() - - assert [m.version for m in migrations] == [1] - - # all table queries work without raising - for table in TABLES: - values = subject.execute(sqlalchemy.select(table)).all() - assert values == [] diff --git a/robot-server/tests/persistence/test_tables.py b/robot-server/tests/persistence/test_tables.py index 1f1ec177175..b50b06d5687 100644 --- a/robot-server/tests/persistence/test_tables.py +++ b/robot-server/tests/persistence/test_tables.py @@ -41,6 +41,7 @@ protocol_id VARCHAR NOT NULL, analyzer_version VARCHAR NOT NULL, completed_analysis BLOB NOT NULL, + completed_analysis_as_document VARCHAR, PRIMARY KEY (id), FOREIGN KEY(protocol_id) REFERENCES protocol (id) ) diff --git a/robot-server/tests/protocols/test_analysis_store.py b/robot-server/tests/protocols/test_analysis_store.py index be78b044a2d..058ec0497d9 100644 --- a/robot-server/tests/protocols/test_analysis_store.py +++ b/robot-server/tests/protocols/test_analysis_store.py @@ -1,10 +1,12 @@ """Tests for the AnalysisStore interface.""" -import pytest +import json from datetime import datetime, timezone from pathlib import Path from typing import List, NamedTuple +import pytest + from sqlalchemy.engine import Engine as SQLEngine from opentrons_shared_data.pipette.dev_types import PipetteNameType @@ -105,9 +107,13 @@ async def test_add_pending( result = subject.add_pending(protocol_id="protocol-id", analysis_id="analysis-id") assert result == expected_summary + assert await subject.get("analysis-id") == expected_analysis assert await subject.get_by_protocol("protocol-id") == [expected_analysis] assert subject.get_summaries_by_protocol("protocol-id") == [expected_summary] + with pytest.raises(AnalysisNotFoundError, match="analysis-id"): + # Unlike get(), get_as_document() should raise if the analysis is pending. + await subject.get_as_document("analysis-id") async def test_returned_in_order_added( @@ -178,6 +184,7 @@ async def test_update_adds_details_and_completes_analysis( ) result = await subject.get("analysis-id") + result_as_document = await subject.get_as_document("analysis-id") assert result == CompletedAnalysis( id="analysis-id", @@ -190,6 +197,26 @@ async def test_update_adds_details_and_completes_analysis( liquids=[], ) assert await subject.get_by_protocol("protocol-id") == [result] + assert json.loads(result_as_document) == { + "id": "analysis-id", + "result": "ok", + "status": "completed", + "labware": [ + { + "id": "labware-id", + "loadName": "load-name", + "definitionUri": "namespace/load-name/42", + "location": {"slotName": "1"}, + } + ], + "pipettes": [ + {"id": "pipette-id", "pipetteName": "p300_single", "mount": "left"} + ], + "commands": [], + "errors": [], + "liquids": [], + "modules": [], + } class AnalysisResultSpec(NamedTuple): diff --git a/robot-server/tests/protocols/test_completed_analysis_store.py b/robot-server/tests/protocols/test_completed_analysis_store.py index eb1a596974b..76a9d32adc6 100644 --- a/robot-server/tests/protocols/test_completed_analysis_store.py +++ b/robot-server/tests/protocols/test_completed_analysis_store.py @@ -1,4 +1,5 @@ """Test the CompletedAnalysisStore.""" +import json from datetime import datetime, timezone from pathlib import Path @@ -92,7 +93,6 @@ def _completed_analysis_resource( async def test_get_by_analysis_id_prefers_cache( subject: CompletedAnalysisStore, - sql_engine: Engine, memcache: MemoryCache[str, CompletedAnalysisResource], protocol_store: ProtocolStore, decoy: Decoy, @@ -106,9 +106,8 @@ async def test_get_by_analysis_id_prefers_cache( assert (await subject.get_by_id("analysis-id")) is resource -async def test_get_by_analysis_falls_back_to_sql( +async def test_get_by_analysis_id_falls_back_to_sql( subject: CompletedAnalysisStore, - sql_engine: Engine, memcache: MemoryCache[str, CompletedAnalysisResource], protocol_store: ProtocolStore, decoy: Decoy, @@ -124,9 +123,8 @@ async def test_get_by_analysis_falls_back_to_sql( assert analysis_from_sql == resource -async def test_get_by_analysis_id_caches_results( +async def test_get_by_analysis_id_stores_results_in_cache( subject: CompletedAnalysisStore, - sql_engine: Engine, memcache: MemoryCache[str, CompletedAnalysisResource], protocol_store: ProtocolStore, decoy: Decoy, @@ -142,8 +140,32 @@ async def test_get_by_analysis_id_caches_results( decoy.verify(memcache.insert("analysis-id", from_sql)) +async def test_get_by_analysis_id_as_document( + subject: CompletedAnalysisStore, + protocol_store: ProtocolStore, +) -> None: + """It should return the analysis serialized as a JSON string.""" + resource = _completed_analysis_resource("analysis-id", "protocol-id") + protocol_store.insert(make_dummy_protocol_resource("protocol-id")) + await subject.add(resource) + result = await subject.get_by_id_as_document("analysis-id") + assert result is not None + assert json.loads(result) == { + "id": "analysis-id", + "result": "ok", + "status": "completed", + "commands": [], + "errors": [], + "labware": [], + "liquids": [], + "modules": [], + "pipettes": [], + "result": "ok", + } + + async def test_get_ids_by_protocol( - subject: CompletedAnalysisStore, sql_engine: Engine, protocol_store: ProtocolStore + subject: CompletedAnalysisStore, protocol_store: ProtocolStore ) -> None: """It should return correct analysis id lists.""" resource_1 = _completed_analysis_resource("analysis-id-1", "protocol-id-1") @@ -154,14 +176,14 @@ async def test_get_ids_by_protocol( await subject.add(resource_1) await subject.add(resource_2) await subject.add(resource_3) - assert sorted(subject.get_ids_by_protocol("protocol-id-1")) == sorted( - ["analysis-id-1", "analysis-id-2"] - ) + assert subject.get_ids_by_protocol("protocol-id-1") == [ + "analysis-id-1", + "analysis-id-2", + ] async def test_get_by_protocol( subject: CompletedAnalysisStore, - sql_engine: Engine, memcache: MemoryCache[str, CompletedAnalysisResource], protocol_store: ProtocolStore, decoy: Decoy, diff --git a/robot-server/tests/protocols/test_protocols_router.py b/robot-server/tests/protocols/test_protocols_router.py index 1887d989435..56027ff5970 100644 --- a/robot-server/tests/protocols/test_protocols_router.py +++ b/robot-server/tests/protocols/test_protocols_router.py @@ -56,6 +56,7 @@ delete_protocol_by_id, get_protocol_analyses, get_protocol_analysis_by_id, + get_protocol_analysis_as_document, ) @@ -647,7 +648,7 @@ async def test_get_protocol_analysis_by_id_analysis_not_found( protocol_store: ProtocolStore, analysis_store: AnalysisStore, ) -> None: - """It should get a single full analysis by ID.""" + """It should 404 if the analysis does not exist.""" decoy.when(protocol_store.has("protocol-id")).then_return(True) decoy.when(await analysis_store.get("analysis-id")).then_raise( AnalysisNotFoundError("oh no") @@ -663,3 +664,70 @@ async def test_get_protocol_analysis_by_id_analysis_not_found( assert exc_info.value.status_code == 404 assert exc_info.value.content["errors"][0]["id"] == "AnalysisNotFound" + + +async def test_get_protocol_analysis_as_document( + decoy: Decoy, + protocol_store: ProtocolStore, + analysis_store: AnalysisStore, +) -> None: + """It should get a single full analysis by ID.""" + analysis = "foo" + + decoy.when(protocol_store.has("protocol-id")).then_return(True) + decoy.when(await analysis_store.get_as_document("analysis-id")).then_return( + analysis + ) + + result = await get_protocol_analysis_as_document( + protocolId="protocol-id", + analysisId="analysis-id", + protocol_store=protocol_store, + analysis_store=analysis_store, + ) + + assert result.status_code == 200 + assert result.body.decode(result.charset) == analysis + + +async def test_get_protocol_analysis_as_document_protocol_not_found( + decoy: Decoy, + protocol_store: ProtocolStore, + analysis_store: AnalysisStore, +) -> None: + """It should 404 if the protocol does not exist.""" + decoy.when(protocol_store.has("protocol-id")).then_return(False) + + with pytest.raises(ApiError) as exc_info: + await get_protocol_analysis_as_document( + protocolId="protocol-id", + analysisId="analysis-id", + protocol_store=protocol_store, + analysis_store=analysis_store, + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.content["errors"][0]["id"] == "ProtocolNotFound" + + +async def test_get_protocol_analysis_as_document_analysis_not_found( + decoy: Decoy, + protocol_store: ProtocolStore, + analysis_store: AnalysisStore, +) -> None: + """It should 404 if the analysis document does not exist.""" + decoy.when(protocol_store.has("protocol-id")).then_return(True) + decoy.when(await analysis_store.get_as_document("analysis-id")).then_raise( + AnalysisNotFoundError("oh no") + ) + + with pytest.raises(ApiError) as exc_info: + await get_protocol_analysis_as_document( + protocolId="protocol-id", + analysisId="analysis-id", + protocol_store=protocol_store, + analysis_store=analysis_store, + ) + + assert exc_info.value.status_code == 404 + assert exc_info.value.content["errors"][0]["id"] == "AnalysisNotFound" diff --git a/robot-server/tests/service/legacy/routers/test_networking.py b/robot-server/tests/service/legacy/routers/test_networking.py index 799c04c0479..a6185c66d7e 100755 --- a/robot-server/tests/service/legacy/routers/test_networking.py +++ b/robot-server/tests/service/legacy/routers/test_networking.py @@ -5,6 +5,7 @@ import pytest from opentrons.system import nmcli, wifi +from typing import Optional def test_networking_status(api_client, monkeypatch): @@ -84,7 +85,7 @@ def test_wifi_list(api_client, monkeypatch): }, ] - async def mock_available(): + async def mock_available(rescan: Optional[bool] = False): return expected_res monkeypatch.setattr(nmcli, "available_ssids", mock_available) diff --git a/scripts/push.mk b/scripts/push.mk index 4f114daa64e..d1fcf08cf83 100644 --- a/scripts/push.mk +++ b/scripts/push.mk @@ -1,7 +1,7 @@ # utilities for pushing things to robots in a reusable fashion find_robot=$(shell yarn run -s discovery find -i 169.254) -default_ssh_key := ~/.ssh/robot_key +default_ssh_key := default_ssh_opts := -o stricthostkeychecking=no -o userknownhostsfile=/dev/null version_dict=$(shell ssh $(call id-file-arg,$(2)) $(3) root@$(1) cat /etc/VERSION.json) is-ot3=$(findstring OT-3, $(version_dict)) diff --git a/server-utils/Makefile b/server-utils/Makefile index 896b1a26916..12c61573049 100755 --- a/server-utils/Makefile +++ b/server-utils/Makefile @@ -35,11 +35,11 @@ tests ?= tests cov_opts ?= --cov=$(SRC_PATH) --cov-report term-missing:skip-covered --cov-report xml:coverage.xml test_opts ?= -# Host key location for buildroot robot -br_ssh_key ?= $(default_ssh_key) +# Host key location for robot +ssh_key ?= $(default_ssh_key) # Pubkey location for buildroot robot to install with install-key -br_ssh_pubkey ?= $(br_ssh_key).pub -# Other SSH args for buildroot robots +br_ssh_pubkey ?= $(ssh_key).pub +# Other SSH args for robot ssh_opts ?= $(default_ssh_opts) # Source discovery @@ -111,9 +111,9 @@ local-shell: .PHONY: push push: wheel - $(call push-python-package,$(host),$(br_ssh_key),$(ssh_opts),$(wheel_file)) + $(call push-python-package,$(host),$(ssh_key),$(ssh_opts),$(wheel_file)) .PHONY: push-ot3 push-ot3: sdist - $(call push-python-sdist,$(host),,$(ssh_opts),$(sdist_file),"/opt/opentrons-system-server","server_utils",,,$(version_file)) - $(call push-python-sdist,$(host),,$(ssh_opts),$(sdist_file),"/opt/opentrons-robot-server","server_utils") + $(call push-python-sdist,$(host),$(ssh_key),$(ssh_opts),$(sdist_file),"/opt/opentrons-system-server","server_utils",,,$(version_file)) + $(call push-python-sdist,$(host),$(ssh_key),$(ssh_opts),$(sdist_file),"/opt/opentrons-robot-server","server_utils") diff --git a/server-utils/server_utils/sql_utils.py b/server-utils/server_utils/sql_utils.py new file mode 100644 index 00000000000..4033ead0c5b --- /dev/null +++ b/server-utils/server_utils/sql_utils.py @@ -0,0 +1,76 @@ +"""Utilities for working with SQLite databases through SQLAlchemy.""" + +from pathlib import Path +from typing import Any + +import sqlalchemy + + +def get_connection_url(db_file_path: Path) -> str: + """Return a connection URL to pass to `sqlalchemy.create_engine()`. + + Params: + db_file_path: The path to the SQLite database file to open. + (This file often has an extension like .db, .sqlite, or .sqlite3.) + """ + # sqlite:/// + # where is empty. + return f"sqlite:///{db_file_path}" + + +def enable_foreign_key_constraints(engine: sqlalchemy.engine.Engine) -> None: + """Enable SQLite's enforcement of foreign key constraints. + + SQLite does not enforce foreign key constraints by default, for backwards compatibility. + + This should be called once per SQLAlchemy engine, shortly after creating it, + before doing anything substantial with it. + + Params: + engine: A SQLAlchemy engine connected to a SQLite database. + """ + # Copied from: + # https://docs.sqlalchemy.org/en/14/dialects/sqlite.html#foreign-key-support + + @sqlalchemy.event.listens_for(engine, "connect") # type: ignore[misc] + def on_connect( + # TODO(mm, 2023-08-29): Improve these type annotations when we have SQLAlchemy 2.0. + dbapi_connection: Any, + connection_record: object, + ) -> None: + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON;") + cursor.close() + + +def fix_transactions(engine: sqlalchemy.engine.Engine) -> None: + """Make SQLite transactions behave sanely. + + This works around various misbehaviors in Python's `sqlite3` driver (aka `pysqlite`), + which is a middle layer between SQLAlchemy and the underlying SQLite library. + These misbehaviors can make transactions not actually behave transactionally. See: + https://docs.sqlalchemy.org/en/14/dialects/sqlite.html#serializable-isolation-savepoints-transactional-ddl + + This should be called once per SQLAlchemy engine, shortly after creating it, + before doing anything substantial with it. + + Params: + engine: A SQLAlchemy engine connected to a SQLite database. + """ + # Copied from: + # https://docs.sqlalchemy.org/en/14/dialects/sqlite.html#serializable-isolation-savepoints-transactional-ddl. + + @sqlalchemy.event.listens_for(engine, "connect") # type: ignore[misc] + def on_connect( + # TODO(mm, 2023-08-29): Improve these type annotations when we have SQLAlchemy 2.0. + dbapi_connection: Any, + connection_record: object, + ) -> None: + # disable pysqlite's emitting of the BEGIN statement entirely. + # also stops it from emitting COMMIT before any DDL. + dbapi_connection.isolation_level = None + + @sqlalchemy.event.listens_for(engine, "begin") # type: ignore[misc] + def on_begin(conn: sqlalchemy.engine.Connection) -> None: + # emit our own BEGIN + conn.exec_driver_sql("BEGIN") diff --git a/shared-data/command/schemas/7.json b/shared-data/command/schemas/7.json index 5df32c9321a..aa321da0c3c 100644 --- a/shared-data/command/schemas/7.json +++ b/shared-data/command/schemas/7.json @@ -460,6 +460,11 @@ "title": "Pipetteid", "description": "Identifier of pipette to use for liquid handling.", "type": "string" + }, + "pushOut": { + "title": "Pushout", + "description": "push the plunger a small amount farther than necessary for accurate low-volume dispensing", + "type": "number" } }, "required": ["labwareId", "wellName", "flowRate", "volume", "pipetteId"] @@ -515,6 +520,11 @@ "title": "Pipetteid", "description": "Identifier of pipette to use for liquid handling.", "type": "string" + }, + "pushOut": { + "title": "Pushout", + "description": "push the plunger a small amount farther than necessary for accurate low-volume dispensing", + "type": "number" } }, "required": ["flowRate", "volume", "pipetteId"] diff --git a/shared-data/gripper/definitions/1/gripperV1.1.json b/shared-data/gripper/definitions/1/gripperV1.1.json index 6f68872e837..b5587c9e78a 100644 --- a/shared-data/gripper/definitions/1/gripperV1.1.json +++ b/shared-data/gripper/definitions/1/gripperV1.1.json @@ -10,6 +10,7 @@ [2, 0.0135956] ], "defaultGripForce": 15.0, + "defaultIdleForce": 10.0, "defaultHomeForce": 12.0, "min": 2.0, "max": 30.0 diff --git a/shared-data/gripper/definitions/1/gripperV1.2.json b/shared-data/gripper/definitions/1/gripperV1.2.json index 7c30bcc4910..5cb3c045bc6 100644 --- a/shared-data/gripper/definitions/1/gripperV1.2.json +++ b/shared-data/gripper/definitions/1/gripperV1.2.json @@ -5,11 +5,12 @@ "displayName": "Flex Gripper", "gripForceProfile": { "polynomial": [ - [0, 4.1194669], - [1, 1.4181001], - [2, 0.0135956] + [0, 3.8361203], + [1, 1.3505968], + [2, 0.0221189] ], "defaultGripForce": 15.0, + "defaultIdleForce": 10.0, "defaultHomeForce": 12.0, "min": 2.0, "max": 30.0 diff --git a/shared-data/gripper/definitions/1/gripperV1.json b/shared-data/gripper/definitions/1/gripperV1.json index a1d5f8bd528..093ed42f745 100644 --- a/shared-data/gripper/definitions/1/gripperV1.json +++ b/shared-data/gripper/definitions/1/gripperV1.json @@ -9,6 +9,7 @@ [1, 2.09] ], "defaultGripForce": 15.0, + "defaultIdleForce": 10.0, "defaultHomeForce": 12.0, "min": 5.0, "max": 20.0 diff --git a/shared-data/gripper/schemas/1.json b/shared-data/gripper/schemas/1.json index dd8114726a8..1ecc38087e9 100644 --- a/shared-data/gripper/schemas/1.json +++ b/shared-data/gripper/schemas/1.json @@ -35,7 +35,7 @@ "GripperModel": { "title": "GripperModel", "description": "Gripper models.", - "enum": ["gripperV1"], + "enum": ["gripperV1", "gripperV1.1", "gripperV1.2"], "type": "string" }, "Geometry": { @@ -147,6 +147,11 @@ "minimum": 0.0, "type": "number" }, + "defaultIdleForce": { + "title": "Defaultidleforce", + "minimum": 0.0, + "type": "number" + }, "defaultHomeForce": { "title": "Defaulthomeforce", "minimum": 0.0, @@ -166,6 +171,7 @@ "required": [ "polynomial", "defaultGripForce", + "defaultIdleForce", "defaultHomeForce", "min", "max" diff --git a/shared-data/js/__tests__/labwareDefQuirks.test.ts b/shared-data/js/__tests__/labwareDefQuirks.test.ts index 86f14550ac6..80f3ecd003b 100644 --- a/shared-data/js/__tests__/labwareDefQuirks.test.ts +++ b/shared-data/js/__tests__/labwareDefQuirks.test.ts @@ -10,6 +10,7 @@ const EXPECTED_VALID_QUIRKS = [ 'centerMultichannelOnWells', 'touchTipDisabled', 'fixedTrash', + 'gripperIncompatible', ] describe('check quirks for all labware defs', () => { diff --git a/shared-data/js/constants.ts b/shared-data/js/constants.ts index 5f97caa0493..b4e556d6c8f 100644 --- a/shared-data/js/constants.ts +++ b/shared-data/js/constants.ts @@ -48,6 +48,9 @@ export const GRIPPER_V1_1: 'gripperV1.1' = 'gripperV1.1' export const GRIPPER_V1_2: 'gripperV1.2' = 'gripperV1.2' export const GRIPPER_MODELS = [GRIPPER_V1, GRIPPER_V1_1, GRIPPER_V1_2] +// robot display name +export const FLEX_DISPLAY_NAME: 'Opentrons Flex' = 'Opentrons Flex' + // pipette display categories export const FLEX: 'FLEX' = 'FLEX' export const GEN2: 'GEN2' = 'GEN2' diff --git a/shared-data/js/getLabware.ts b/shared-data/js/getLabware.ts index 9dd5c84cfae..0c2f5dc0c2c 100644 --- a/shared-data/js/getLabware.ts +++ b/shared-data/js/getLabware.ts @@ -44,7 +44,6 @@ export const LABWAREV2_DO_NOT_LIST = [ 'opentrons_calibrationblock_short_side_left', 'opentrons_calibrationblock_short_side_right', 'opentrons_calibration_adapter_heatershaker_module', - 'opentrons_calibration_adapter_magnetic_module', 'opentrons_calibration_adapter_temperature_module', 'opentrons_calibration_adapter_thermocycler_module', // TODO(lc 8-24-2022) We are temporarily filtering diff --git a/shared-data/js/helpers/index.ts b/shared-data/js/helpers/index.ts index a0b56ec1ac5..2bfb8f4b0d2 100644 --- a/shared-data/js/helpers/index.ts +++ b/shared-data/js/helpers/index.ts @@ -4,8 +4,14 @@ import uniq from 'lodash/uniq' import { OPENTRONS_LABWARE_NAMESPACE } from '../constants' import standardDeckDefOt2 from '../../deck/definitions/3/ot2_standard.json' import standardDeckDefOt3 from '../../deck/definitions/3/ot3_standard.json' -import type { DeckDefinition, LabwareDefinition2 } from '../types' -import type { LoadedLabware, RobotType, ThermalAdapterName } from '..' +import type { + DeckDefinition, + LabwareDefinition2, + LoadedLabware, + ModuleModel, + RobotType, + ThermalAdapterName, +} from '../types' export { getWellNamePerMultiTip } from './getWellNamePerMultiTip' export { getWellTotalVolume } from './getWellTotalVolume' @@ -310,6 +316,24 @@ export const getAdapterName = (labwareLoadname: string): ThermalAdapterName => { return adapterName } +export const getCalibrationAdapterLoadName = ( + moduleModel: ModuleModel +): string | null => { + switch (moduleModel) { + case 'heaterShakerModuleV1': + return 'opentrons_calibration_adapter_heatershaker_module' + case 'temperatureModuleV2': + return 'opentrons_calibration_adapter_temperature_module' + case 'thermocyclerModuleV2': + return 'opentrons_calibration_adapter_thermocycler_module' + default: + console.error( + `${moduleModel} does not have an associated calibration adapter` + ) + return null + } +} + export const getRobotTypeFromLoadedLabware = ( labware: LoadedLabware[] ): RobotType => { diff --git a/shared-data/js/types.ts b/shared-data/js/types.ts index e887f600a3a..75b5f5f2956 100644 --- a/shared-data/js/types.ts +++ b/shared-data/js/types.ts @@ -499,6 +499,7 @@ export interface GripperDefinition { polynomial: [[number, number], [number, number]] defaultGripForce: number defaultHomeForce: number + defaultIdleForce: number min: number max: number } @@ -510,3 +511,12 @@ export interface GripperDefinition { jawWidth: { min: number; max: number } } } + +export type StatusBarAnimation = + | 'idle' + | 'confirm' + | 'updating' + | 'disco' + | 'off' + +export type StatusBarAnimations = StatusBarAnimation[] diff --git a/shared-data/labware/definitions/2/opentrons_24_aluminumblock_generic_2ml_screwcap/2.json b/shared-data/labware/definitions/2/opentrons_24_aluminumblock_generic_2ml_screwcap/2.json index f116a5acfc2..72ae1e73c8d 100644 --- a/shared-data/labware/definitions/2/opentrons_24_aluminumblock_generic_2ml_screwcap/2.json +++ b/shared-data/labware/definitions/2/opentrons_24_aluminumblock_generic_2ml_screwcap/2.json @@ -23,6 +23,7 @@ }, "parameters": { "format": "irregular", + "quirks": ["gripperIncompatible"], "isTiprack": false, "isMagneticModuleCompatible": false, "loadName": "opentrons_24_aluminumblock_generic_2ml_screwcap" diff --git a/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_0.5ml_screwcap/1.json b/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_0.5ml_screwcap/1.json index 19884ff6829..cf4124b627b 100644 --- a/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_0.5ml_screwcap/1.json +++ b/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_0.5ml_screwcap/1.json @@ -283,6 +283,7 @@ ], "parameters": { "format": "irregular", + "quirks": ["gripperIncompatible"], "isTiprack": false, "isMagneticModuleCompatible": false, "loadName": "opentrons_24_aluminumblock_nest_0.5ml_screwcap" diff --git a/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_1.5ml_screwcap/1.json b/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_1.5ml_screwcap/1.json index f47d04609d9..2b5eab92ca4 100644 --- a/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_1.5ml_screwcap/1.json +++ b/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_1.5ml_screwcap/1.json @@ -283,6 +283,7 @@ ], "parameters": { "format": "irregular", + "quirks": ["gripperIncompatible"], "isTiprack": false, "isMagneticModuleCompatible": false, "loadName": "opentrons_24_aluminumblock_nest_1.5ml_screwcap" diff --git a/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1.json b/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1.json index b81e44330c9..c8b41ab3986 100644 --- a/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1.json +++ b/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_1.5ml_snapcap/1.json @@ -285,6 +285,7 @@ ], "parameters": { "format": "irregular", + "quirks": ["gripperIncompatible"], "isTiprack": false, "isMagneticModuleCompatible": false, "loadName": "opentrons_24_aluminumblock_nest_1.5ml_snapcap" diff --git a/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_2ml_screwcap/1.json b/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_2ml_screwcap/1.json index 9421158566e..e98453e9d36 100644 --- a/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_2ml_screwcap/1.json +++ b/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_2ml_screwcap/1.json @@ -283,6 +283,7 @@ ], "parameters": { "format": "irregular", + "quirks": ["gripperIncompatible"], "isTiprack": false, "isMagneticModuleCompatible": false, "loadName": "opentrons_24_aluminumblock_nest_2ml_screwcap" diff --git a/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_2ml_snapcap/1.json b/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_2ml_snapcap/1.json index e2c8fd54393..1cca56ee216 100644 --- a/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_2ml_snapcap/1.json +++ b/shared-data/labware/definitions/2/opentrons_24_aluminumblock_nest_2ml_snapcap/1.json @@ -285,6 +285,7 @@ ], "parameters": { "format": "irregular", + "quirks": ["gripperIncompatible"], "isTiprack": false, "isMagneticModuleCompatible": false, "loadName": "opentrons_24_aluminumblock_nest_2ml_snapcap" diff --git a/shared-data/labware/definitions/2/opentrons_40_aluminumblock_eppendorf_24x2ml_safelock_snapcap_generic_16x0.2ml_pcr_strip/1.json b/shared-data/labware/definitions/2/opentrons_40_aluminumblock_eppendorf_24x2ml_safelock_snapcap_generic_16x0.2ml_pcr_strip/1.json index acdaf09c2dd..56b4dda5031 100644 --- a/shared-data/labware/definitions/2/opentrons_40_aluminumblock_eppendorf_24x2ml_safelock_snapcap_generic_16x0.2ml_pcr_strip/1.json +++ b/shared-data/labware/definitions/2/opentrons_40_aluminumblock_eppendorf_24x2ml_safelock_snapcap_generic_16x0.2ml_pcr_strip/1.json @@ -381,6 +381,7 @@ "version": 1, "parameters": { "format": "irregular", + "quirks": ["gripperIncompatible"], "isTiprack": false, "isMagneticModuleCompatible": false, "loadName": "opentrons_40_aluminumblock_eppendorf_24x2ml_safelock_snapcap_generic_16x0.2ml_pcr_strip" diff --git a/shared-data/labware/definitions/2/opentrons_96_aluminumblock_biorad_wellplate_200ul/1.json b/shared-data/labware/definitions/2/opentrons_96_aluminumblock_biorad_wellplate_200ul/1.json index 3042cc12a89..b84497a4879 100644 --- a/shared-data/labware/definitions/2/opentrons_96_aluminumblock_biorad_wellplate_200ul/1.json +++ b/shared-data/labware/definitions/2/opentrons_96_aluminumblock_biorad_wellplate_200ul/1.json @@ -29,6 +29,7 @@ }, "parameters": { "format": "96Standard", + "quirks": ["gripperIncompatible"], "isTiprack": false, "isMagneticModuleCompatible": false, "loadName": "opentrons_96_aluminumblock_biorad_wellplate_200ul" diff --git a/shared-data/labware/definitions/2/opentrons_96_aluminumblock_generic_pcr_strip_200ul/2.json b/shared-data/labware/definitions/2/opentrons_96_aluminumblock_generic_pcr_strip_200ul/2.json index 7d836ac2ad2..5003bdb53bb 100644 --- a/shared-data/labware/definitions/2/opentrons_96_aluminumblock_generic_pcr_strip_200ul/2.json +++ b/shared-data/labware/definitions/2/opentrons_96_aluminumblock_generic_pcr_strip_200ul/2.json @@ -29,6 +29,7 @@ }, "parameters": { "format": "96Standard", + "quirks": ["gripperIncompatible"], "isTiprack": false, "isMagneticModuleCompatible": false, "loadName": "opentrons_96_aluminumblock_generic_pcr_strip_200ul" diff --git a/shared-data/labware/definitions/2/opentrons_96_aluminumblock_nest_wellplate_100ul/1.json b/shared-data/labware/definitions/2/opentrons_96_aluminumblock_nest_wellplate_100ul/1.json index ef47317e3b6..875ff19df17 100644 --- a/shared-data/labware/definitions/2/opentrons_96_aluminumblock_nest_wellplate_100ul/1.json +++ b/shared-data/labware/definitions/2/opentrons_96_aluminumblock_nest_wellplate_100ul/1.json @@ -1011,6 +1011,7 @@ ], "parameters": { "format": "96Standard", + "quirks": ["gripperIncompatible"], "isTiprack": false, "isMagneticModuleCompatible": false, "loadName": "opentrons_96_aluminumblock_nest_wellplate_100ul" diff --git a/shared-data/labware/definitions/2/opentrons_96_deep_well_adapter_nest_wellplate_2ml_deep/1.json b/shared-data/labware/definitions/2/opentrons_96_deep_well_adapter_nest_wellplate_2ml_deep/1.json index a53993be113..6dfcc6d21f6 100644 --- a/shared-data/labware/definitions/2/opentrons_96_deep_well_adapter_nest_wellplate_2ml_deep/1.json +++ b/shared-data/labware/definitions/2/opentrons_96_deep_well_adapter_nest_wellplate_2ml_deep/1.json @@ -1105,7 +1105,7 @@ ], "parameters": { "format": "96Standard", - "quirks": [], + "quirks": ["gripperIncompatible"], "isTiprack": false, "isMagneticModuleCompatible": false, "loadName": "opentrons_96_deep_well_adapter_nest_wellplate_2ml_deep" diff --git a/shared-data/labware/definitions/2/opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat/1.json b/shared-data/labware/definitions/2/opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat/1.json index 26d23b2b94f..46dc0a5204d 100644 --- a/shared-data/labware/definitions/2/opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat/1.json +++ b/shared-data/labware/definitions/2/opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat/1.json @@ -1011,7 +1011,7 @@ ], "parameters": { "format": "96Standard", - "quirks": [], + "quirks": ["gripperIncompatible"], "isTiprack": false, "isMagneticModuleCompatible": false, "loadName": "opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat" diff --git a/shared-data/labware/definitions/2/opentrons_96_pcr_adapter_armadillo_wellplate_200ul/1.json b/shared-data/labware/definitions/2/opentrons_96_pcr_adapter_armadillo_wellplate_200ul/1.json index ac61a095bcb..83cdd56d675 100644 --- a/shared-data/labware/definitions/2/opentrons_96_pcr_adapter_armadillo_wellplate_200ul/1.json +++ b/shared-data/labware/definitions/2/opentrons_96_pcr_adapter_armadillo_wellplate_200ul/1.json @@ -4,6 +4,7 @@ "schemaVersion": 2, "parameters": { "loadName": "opentrons_96_pcr_adapter_armadillo_wellplate_200ul", + "quirks": ["gripperIncompatible"], "format": "96Standard", "isTiprack": false, "isMagneticModuleCompatible": false diff --git a/shared-data/labware/definitions/2/opentrons_96_pcr_adapter_nest_wellplate_100ul_pcr_full_skirt/1.json b/shared-data/labware/definitions/2/opentrons_96_pcr_adapter_nest_wellplate_100ul_pcr_full_skirt/1.json index 52b864f03aa..27d7b6ac6ca 100644 --- a/shared-data/labware/definitions/2/opentrons_96_pcr_adapter_nest_wellplate_100ul_pcr_full_skirt/1.json +++ b/shared-data/labware/definitions/2/opentrons_96_pcr_adapter_nest_wellplate_100ul_pcr_full_skirt/1.json @@ -1009,7 +1009,7 @@ ], "parameters": { "format": "96Standard", - "quirks": [], + "quirks": ["gripperIncompatible"], "isTiprack": false, "isMagneticModuleCompatible": false, "loadName": "opentrons_96_pcr_adapter_nest_wellplate_100ul_pcr_full_skirt" diff --git a/shared-data/labware/definitions/2/opentrons_calibration_adapter_magnetic_module/1.json b/shared-data/labware/definitions/2/opentrons_calibration_adapter_magnetic_module/1.json deleted file mode 100644 index f7eede95f14..00000000000 --- a/shared-data/labware/definitions/2/opentrons_calibration_adapter_magnetic_module/1.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "ordering": [["A1", "B1"]], - "brand": { - "brand": "Opentrons", - "brandId": [] - }, - "metadata": { - "displayName": "Opentrons Calibration Adapter - Magnetic Module", - "displayCategory": "other", - "displayVolumeUnits": "mL", - "tags": [] - }, - "dimensions": { - "xDimension": 113.95, - "yDimension": 77.95, - "zDimension": 15.5 - }, - "wells": { - "A1": { - "depth": 4.75, - "totalLiquidVolume": 0, - "shape": "rectangular", - "xDimension": 20, - "yDimension": 20, - "x": 56.97, - "y": 38.97, - "z": 10.75 - }, - "B1": { - "depth": 0, - "totalLiquidVolume": 0, - "shape": "circular", - "diameter": 1, - "x": 69.97, - "y": 51.97, - "z": 15.5 - } - }, - "groups": [ - { - "metadata": { - "wellBottomShape": "flat" - }, - "wells": ["A1", "B1"] - } - ], - "parameters": { - "format": "irregular", - "quirks": [], - "isTiprack": false, - "isMagneticModuleCompatible": true, - "loadName": "opentrons_calibration_adapter_magnetic_module" - }, - "namespace": "opentrons", - "version": 1, - "schemaVersion": 2, - "cornerOffsetFromSlot": { - "x": 7.03, - "y": 4.5, - "z": 0 - } -} diff --git a/shared-data/labware/definitions/2/opentrons_universal_flat_adapter/1.json b/shared-data/labware/definitions/2/opentrons_universal_flat_adapter/1.json index 653162d97a5..600cba23047 100644 --- a/shared-data/labware/definitions/2/opentrons_universal_flat_adapter/1.json +++ b/shared-data/labware/definitions/2/opentrons_universal_flat_adapter/1.json @@ -51,7 +51,7 @@ "z": 0 } }, - "SLOT_A1": { + "A1": { "pickUpOffset": { "x": 0, "y": 0, @@ -63,7 +63,7 @@ "z": 0 } }, - "SLOT_B1": { + "B1": { "pickUpOffset": { "x": 0, "y": 0, @@ -75,7 +75,7 @@ "z": 0 } }, - "SLOT_C1": { + "C1": { "pickUpOffset": { "x": 0, "y": 0, @@ -87,7 +87,7 @@ "z": 0 } }, - "SLOT_D1": { + "D1": { "pickUpOffset": { "x": 0, "y": 0, @@ -99,7 +99,7 @@ "z": 0 } }, - "SLOT_A3": { + "A3": { "pickUpOffset": { "x": 0, "y": 0, @@ -111,7 +111,7 @@ "z": 0 } }, - "SLOT_B3": { + "B3": { "pickUpOffset": { "x": 0, "y": 0, @@ -123,7 +123,7 @@ "z": 0 } }, - "SLOT_C3": { + "C3": { "pickUpOffset": { "x": 0, "y": 0, @@ -135,7 +135,7 @@ "z": 0 } }, - "SLOT_D3": { + "D3": { "pickUpOffset": { "x": 0, "y": 0, diff --git a/shared-data/labware/definitions/2/opentrons_universal_flat_adapter_corning_384_wellplate_112ul_flat/1.json b/shared-data/labware/definitions/2/opentrons_universal_flat_adapter_corning_384_wellplate_112ul_flat/1.json index 89f13c2da55..2b617214e79 100644 --- a/shared-data/labware/definitions/2/opentrons_universal_flat_adapter_corning_384_wellplate_112ul_flat/1.json +++ b/shared-data/labware/definitions/2/opentrons_universal_flat_adapter_corning_384_wellplate_112ul_flat/1.json @@ -4694,6 +4694,7 @@ ], "parameters": { "format": "384Standard", + "quirks": ["gripperIncompatible"], "isTiprack": false, "isMagneticModuleCompatible": false, "loadName": "opentrons_universal_flat_adapter_corning_384_wellplate_112ul_flat" diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_0.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_0.json index b4c3abed957..25aaf9341ce 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_0.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_0.json @@ -38,7 +38,8 @@ "2": [[12.74444519, 0, 0.8058688085]] } }, - "defaultBlowoutVolume": 2.356 + "defaultBlowoutVolume": 2.356, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_3.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_3.json index b4c3abed957..25aaf9341ce 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_3.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_3.json @@ -38,7 +38,8 @@ "2": [[12.74444519, 0, 0.8058688085]] } }, - "defaultBlowoutVolume": 2.356 + "defaultBlowoutVolume": 2.356, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_4.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_4.json index b4c3abed957..25aaf9341ce 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_4.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_4.json @@ -38,7 +38,8 @@ "2": [[12.74444519, 0, 0.8058688085]] } }, - "defaultBlowoutVolume": 2.356 + "defaultBlowoutVolume": 2.356, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_5.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_5.json index db4618720ae..a458fa7b22d 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_5.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_5.json @@ -29,7 +29,8 @@ "dispense": { "default": { "1": [[12.74444519, 0.0, 0.8058688085]] } }, - "defaultBlowoutVolume": 2.356 + "defaultBlowoutVolume": 2.356, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_6.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_6.json index c5581d55101..d7339497fbe 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_6.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p10/default/1_6.json @@ -37,7 +37,8 @@ "dispense": { "default": { "1": [[12.74444519, 0.0, 0.8058688085]] } }, - "defaultBlowoutVolume": 2.356 + "defaultBlowoutVolume": 2.356, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/1_0.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/1_0.json index 3e538dfa998..acda3bef927 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/1_0.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/1_0.json @@ -119,7 +119,8 @@ ] } }, - "defaultBlowoutVolume": -318.086 + "defaultBlowoutVolume": -318.086, + "defaultPushOutVolume": 7 }, "t200": { "defaultAspirateFlowRate": { @@ -237,7 +238,8 @@ ] } }, - "defaultBlowoutVolume": -318.086 + "defaultBlowoutVolume": -318.086, + "defaultPushOutVolume": 5 }, "t1000": { "defaultAspirateFlowRate": { @@ -355,7 +357,8 @@ ] } }, - "defaultBlowoutVolume": -318.086 + "defaultBlowoutVolume": -318.086, + "defaultPushOutVolume": 20 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_0.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_0.json index 0f64394baa3..82cdc862902 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_0.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_0.json @@ -119,7 +119,8 @@ ] } }, - "defaultBlowoutVolume": 3.2 + "defaultBlowoutVolume": 3.2, + "defaultPushOutVolume": 7 }, "t200": { "defaultAspirateFlowRate": { @@ -237,7 +238,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 5 }, "t1000": { "defaultAspirateFlowRate": { @@ -355,7 +357,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 20 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_3.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_3.json index 0f64394baa3..82cdc862902 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_3.json @@ -119,7 +119,8 @@ ] } }, - "defaultBlowoutVolume": 3.2 + "defaultBlowoutVolume": 3.2, + "defaultPushOutVolume": 7 }, "t200": { "defaultAspirateFlowRate": { @@ -237,7 +238,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 5 }, "t1000": { "defaultAspirateFlowRate": { @@ -355,7 +357,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 20 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_4.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_4.json index 76172c67e56..988a438e2da 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_4.json @@ -119,7 +119,8 @@ ] } }, - "defaultBlowoutVolume": 3.2 + "defaultBlowoutVolume": 3.2, + "defaultPushOutVolume": 7 }, "t200": { "defaultAspirateFlowRate": { @@ -237,7 +238,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 5 }, "t1000": { "defaultAspirateFlowRate": { @@ -355,7 +357,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 20 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_5.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_5.json index 76172c67e56..988a438e2da 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p1000/default/3_5.json @@ -119,7 +119,8 @@ ] } }, - "defaultBlowoutVolume": 3.2 + "defaultBlowoutVolume": 3.2, + "defaultPushOutVolume": 7 }, "t200": { "defaultAspirateFlowRate": { @@ -237,7 +238,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 5 }, "t1000": { "defaultAspirateFlowRate": { @@ -355,7 +357,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 20 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p20/default/2_0.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p20/default/2_0.json index f24cbe3b351..5ab10e72142 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p20/default/2_0.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p20/default/2_0.json @@ -209,7 +209,8 @@ ] } }, - "defaultBlowoutVolume": 3.534 + "defaultBlowoutVolume": 3.534, + "defaultPushOutVolume": 0 }, "t10": { "defaultAspirateFlowRate": { @@ -299,7 +300,8 @@ ] } }, - "defaultBlowoutVolume": 3.534 + "defaultBlowoutVolume": 3.534, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p20/default/2_1.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p20/default/2_1.json index 2d1d8e16d34..937aa4b572b 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p20/default/2_1.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p20/default/2_1.json @@ -89,7 +89,8 @@ ] } }, - "defaultBlowoutVolume": 3.534 + "defaultBlowoutVolume": 3.534, + "defaultPushOutVolume": 0 }, "t10": { "defaultAspirateFlowRate": { @@ -179,7 +180,8 @@ ] } }, - "defaultBlowoutVolume": 3.534 + "defaultBlowoutVolume": 3.534, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/1_0.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/1_0.json index 34673f4a80a..1016e711992 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/1_0.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/1_0.json @@ -28,7 +28,8 @@ "1": [[309.2612689, 0.0, 19.29389273]] } }, - "defaultBlowoutVolume": 39.27 + "defaultBlowoutVolume": 39.27, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -57,7 +58,8 @@ "1": [[309.2612689, 0.0, 19.29389273]] } }, - "defaultBlowoutVolume": 39.27 + "defaultBlowoutVolume": 39.27, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/1_3.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/1_3.json index eccaa851eb2..a452064068e 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/1_3.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/1_3.json @@ -28,7 +28,8 @@ "1": [[309.2612689, 0.0, 19.29389273]] } }, - "defaultBlowoutVolume": 39.27 + "defaultBlowoutVolume": 39.27, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -57,7 +58,8 @@ "1": [[309.2612689, 0.0, 19.29389273]] } }, - "defaultBlowoutVolume": 39.27 + "defaultBlowoutVolume": 39.27, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/1_4.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/1_4.json index 34673f4a80a..1016e711992 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/1_4.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/1_4.json @@ -28,7 +28,8 @@ "1": [[309.2612689, 0.0, 19.29389273]] } }, - "defaultBlowoutVolume": 39.27 + "defaultBlowoutVolume": 39.27, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -57,7 +58,8 @@ "1": [[309.2612689, 0.0, 19.29389273]] } }, - "defaultBlowoutVolume": 39.27 + "defaultBlowoutVolume": 39.27, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/1_5.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/1_5.json index eccaa851eb2..a452064068e 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/1_5.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/1_5.json @@ -28,7 +28,8 @@ "1": [[309.2612689, 0.0, 19.29389273]] } }, - "defaultBlowoutVolume": 39.27 + "defaultBlowoutVolume": 39.27, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -57,7 +58,8 @@ "1": [[309.2612689, 0.0, 19.29389273]] } }, - "defaultBlowoutVolume": 39.27 + "defaultBlowoutVolume": 39.27, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/2_0.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/2_0.json index 13374cc484e..41add126d46 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/2_0.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/2_0.json @@ -92,7 +92,8 @@ ] } }, - "defaultBlowoutVolume": 43.295 + "defaultBlowoutVolume": 43.295, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -185,7 +186,8 @@ ] } }, - "defaultBlowoutVolume": 43.295 + "defaultBlowoutVolume": 43.295, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/2_1.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/2_1.json index 7eea925b93f..8429bbcb65a 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/2_1.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p300/default/2_1.json @@ -92,7 +92,8 @@ ] } }, - "defaultBlowoutVolume": 43.295 + "defaultBlowoutVolume": 43.295, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -185,7 +186,8 @@ ] } }, - "defaultBlowoutVolume": 43.295 + "defaultBlowoutVolume": 43.295, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/1_0.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/1_0.json index b24e1d5f9d1..0d0c335b9f3 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/1_0.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/1_0.json @@ -36,7 +36,8 @@ "2": [[50, 0, 3.06368702]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 2 }, "t200": { "defaultAspirateFlowRate": { @@ -73,7 +74,8 @@ "2": [[50, 0, 3.06368702]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -110,7 +112,8 @@ "2": [[50, 0, 3.06368702]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/1_3.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/1_3.json index b24e1d5f9d1..0d0c335b9f3 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/1_3.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/1_3.json @@ -36,7 +36,8 @@ "2": [[50, 0, 3.06368702]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 2 }, "t200": { "defaultAspirateFlowRate": { @@ -73,7 +74,8 @@ "2": [[50, 0, 3.06368702]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -110,7 +112,8 @@ "2": [[50, 0, 3.06368702]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/1_4.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/1_4.json index 5554187f45c..16a609ada5b 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/1_4.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/1_4.json @@ -36,7 +36,8 @@ "2": [[50, 0, 3.06368702]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 2 }, "t200": { "defaultAspirateFlowRate": { @@ -73,7 +74,8 @@ "2": [[50, 0, 3.06368702]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -110,7 +112,8 @@ "2": [[50, 0, 3.06368702]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/1_5.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/1_5.json index d1037751e6f..c914fcfde8b 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/1_5.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/1_5.json @@ -36,7 +36,8 @@ "2": [[50, 0, 3.06368702]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 2 }, "t200": { "defaultAspirateFlowRate": { @@ -68,7 +69,8 @@ "1": [[50.0, 0.0, 3.06368702]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -100,7 +102,8 @@ "1": [[50.0, 0.0, 3.06368702]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_0.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_0.json index 8630a2eff29..3516fce5ea0 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_0.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_0.json @@ -85,7 +85,8 @@ ] } }, - "defaultBlowoutVolume": 1.5 + "defaultBlowoutVolume": 1.5, + "defaultPushOutVolume": 2 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_3.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_3.json index 8630a2eff29..3516fce5ea0 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_3.json @@ -85,7 +85,8 @@ ] } }, - "defaultBlowoutVolume": 1.5 + "defaultBlowoutVolume": 1.5, + "defaultPushOutVolume": 2 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_4.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_4.json index c1080cf67b2..cbe6e0693d9 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_4.json @@ -85,7 +85,8 @@ ] } }, - "defaultBlowoutVolume": 1.5 + "defaultBlowoutVolume": 1.5, + "defaultPushOutVolume": 2 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_5.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_5.json index c1080cf67b2..4ff8fd9e081 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/default/3_5.json @@ -20,72 +20,65 @@ "aspirate": { "default": { "1": [ - [0.6464, 0.4817, 0.0427], - [1.0889, 0.2539, 0.1591], - [1.5136, 0.1624, 0.2587], - [1.9108, 0.1042, 0.3467], - [2.2941, 0.0719, 0.4085], - [2.9978, 0.037, 0.4886], - [3.7731, 0.0378, 0.4863], - [4.7575, 0.0516, 0.4342], - [5.5024, 0.011, 0.6275], - [6.2686, 0.0114, 0.6253], - [7.005, 0.0054, 0.6625], - [8.5207, 0.0063, 0.6563], - [10.0034, 0.003, 0.6844], - [11.5075, 0.0031, 0.6833], - [13.0327, 0.0032, 0.6829], - [14.5356, 0.0018, 0.7003], - [17.5447, 0.0014, 0.7063], - [20.5576, 0.0011, 0.7126], - [23.5624, 0.0007, 0.7197], - [26.5785, 0.0007, 0.721], - [29.593, 0.0005, 0.7248], - [32.6109, 0.0004, 0.7268], - [35.6384, 0.0004, 0.727], - [38.6439, 0.0002, 0.7343], - [41.6815, 0.0004, 0.7284], - [44.6895, 0.0002, 0.7372], - [47.6926, 0.0001, 0.7393], - [51.4567, 0.0001, 0.7382] + [0.375, 0.6521, 0.05], + [0.67, 0.4286, 0.1338], + [0.9867, 0.2293, 0.2673], + [1.3683, 0.1434, 0.3521], + [1.8383, 0.0872, 0.4289], + [2.4133, 0.0524, 0.4931], + [2.625, 0.0252, 0.5587], + [2.8667, 0.0027, 0.6176], + [2.9917, -0.0954, 0.8989], + [3.12, -0.0068, 0.6339], + [3.4133, 0.0369, 0.4973], + [3.7417, 0.0469, 0.4634], + [3.9667, 0.0513, 0.4467], + [4.555, 0.0418, 0.4846], + [5.23, 0.0159, 0.6025], + [6.6483, 0.008, 0.6437], + [8.425, 0.0051, 0.6633], + [10.665, 0.0038, 0.6742], + [17.0017, 0.0021, 0.6922], + [21.3983, 0.0011, 0.7093], + [26.9283, 0.0008, 0.7155], + [33.8483, 0.0005, 0.7238], + [42.5067, 0.0004, 0.7288], + [53.2317, 0.0001, 0.7403] ] } }, "dispense": { "default": { "1": [ - [0.6464, 0.4817, 0.0427], - [1.0889, 0.2539, 0.1591], - [1.5136, 0.1624, 0.2587], - [1.9108, 0.1042, 0.3467], - [2.2941, 0.0719, 0.4085], - [2.9978, 0.037, 0.4886], - [3.7731, 0.0378, 0.4863], - [4.7575, 0.0516, 0.4342], - [5.5024, 0.011, 0.6275], - [6.2686, 0.0114, 0.6253], - [7.005, 0.0054, 0.6625], - [8.5207, 0.0063, 0.6563], - [10.0034, 0.003, 0.6844], - [11.5075, 0.0031, 0.6833], - [13.0327, 0.0032, 0.6829], - [14.5356, 0.0018, 0.7003], - [17.5447, 0.0014, 0.7063], - [20.5576, 0.0011, 0.7126], - [23.5624, 0.0007, 0.7197], - [26.5785, 0.0007, 0.721], - [29.593, 0.0005, 0.7248], - [32.6109, 0.0004, 0.7268], - [35.6384, 0.0004, 0.727], - [38.6439, 0.0002, 0.7343], - [41.6815, 0.0004, 0.7284], - [44.6895, 0.0002, 0.7372], - [47.6926, 0.0001, 0.7393], - [51.4567, 0.0001, 0.7382] + [0.375, 0.6521, 0.05], + [0.67, 0.4286, 0.1338], + [0.9867, 0.2293, 0.2673], + [1.3683, 0.1434, 0.3521], + [1.8383, 0.0872, 0.4289], + [2.4133, 0.0524, 0.4931], + [2.625, 0.0252, 0.5587], + [2.8667, 0.0027, 0.6176], + [2.9917, -0.0954, 0.8989], + [3.12, -0.0068, 0.6339], + [3.4133, 0.0369, 0.4973], + [3.7417, 0.0469, 0.4634], + [3.9667, 0.0513, 0.4467], + [4.555, 0.0418, 0.4846], + [5.23, 0.0159, 0.6025], + [6.6483, 0.008, 0.6437], + [8.425, 0.0051, 0.6633], + [10.665, 0.0038, 0.6742], + [17.0017, 0.0021, 0.6922], + [21.3983, 0.0011, 0.7093], + [26.9283, 0.0008, 0.7155], + [33.8483, 0.0005, 0.7238], + [42.5067, 0.0004, 0.7288], + [53.2317, 0.0001, 0.7403] ] } }, - "defaultBlowoutVolume": 1.5 + "defaultBlowoutVolume": 1.5, + "defaultPushOutVolume": 2 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_0.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_0.json index 1c8a7caf11b..54152b6d0b8 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_0.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_0.json @@ -85,7 +85,8 @@ ] } }, - "defaultBlowoutVolume": 1.5 + "defaultBlowoutVolume": 1.5, + "defaultPushOutVolume": 7 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_3.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_3.json index 1c8a7caf11b..54152b6d0b8 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_3.json @@ -85,7 +85,8 @@ ] } }, - "defaultBlowoutVolume": 1.5 + "defaultBlowoutVolume": 1.5, + "defaultPushOutVolume": 7 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_4.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_4.json index e889473054e..85ed5123325 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_4.json @@ -85,7 +85,8 @@ ] } }, - "defaultBlowoutVolume": 1.5 + "defaultBlowoutVolume": 1.5, + "defaultPushOutVolume": 7 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_5.json b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_5.json index a88a92a92ff..121fe81eedc 100644 --- a/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/eight_channel/p50/lowVolumeDefault/3_5.json @@ -20,72 +20,65 @@ "aspirate": { "default": { "1": [ - [0.6464, 0.4817, 0.0427], - [1.0889, 0.2539, 0.1591], - [1.5136, 0.1624, 0.2587], - [1.9108, 0.1042, 0.3467], - [2.2941, 0.0719, 0.4085], - [2.9978, 0.037, 0.4886], - [3.7731, 0.0378, 0.4863], - [4.7575, 0.0516, 0.4342], - [5.5024, 0.011, 0.6275], - [6.2686, 0.0114, 0.6253], - [7.005, 0.0054, 0.6625], - [8.5207, 0.0063, 0.6563], - [10.0034, 0.003, 0.6844], - [11.5075, 0.0031, 0.6833], - [13.0327, 0.0032, 0.6829], - [14.5356, 0.0018, 0.7003], - [17.5447, 0.0014, 0.7063], - [20.5576, 0.0011, 0.7126], - [23.5624, 0.0007, 0.7197], - [26.5785, 0.0007, 0.721], - [29.593, 0.0005, 0.7248], - [32.6109, 0.0004, 0.7268], - [35.6384, 0.0004, 0.727], - [38.6439, 0.0002, 0.7343], - [41.6815, 0.0004, 0.7284], - [44.6895, 0.0002, 0.7372], - [47.6926, 0.0001, 0.7393], - [51.4567, 0.0001, 0.7382] + [0.375, 0.6521, 0.05], + [0.67, 0.4286, 0.1338], + [0.9867, 0.2293, 0.2673], + [1.3683, 0.1434, 0.3521], + [1.8383, 0.0872, 0.4289], + [2.4133, 0.0524, 0.4931], + [2.625, 0.0252, 0.5587], + [2.8667, 0.0027, 0.6176], + [2.9917, -0.0954, 0.8989], + [3.12, -0.0068, 0.6339], + [3.4133, 0.0369, 0.4973], + [3.7417, 0.0469, 0.4634], + [3.9667, 0.0513, 0.4467], + [4.555, 0.0418, 0.4846], + [5.23, 0.0159, 0.6025], + [6.6483, 0.008, 0.6437], + [8.425, 0.0051, 0.6633], + [10.665, 0.0038, 0.6742], + [17.0017, 0.0021, 0.6922], + [21.3983, 0.0011, 0.7093], + [26.9283, 0.0008, 0.7155], + [33.8483, 0.0005, 0.7238], + [42.5067, 0.0004, 0.7288], + [53.2317, 0.0001, 0.7403] ] } }, "dispense": { "default": { "1": [ - [0.6464, 0.4817, 0.0427], - [1.0889, 0.2539, 0.1591], - [1.5136, 0.1624, 0.2587], - [1.9108, 0.1042, 0.3467], - [2.2941, 0.0719, 0.4085], - [2.9978, 0.037, 0.4886], - [3.7731, 0.0378, 0.4863], - [4.7575, 0.0516, 0.4342], - [5.5024, 0.011, 0.6275], - [6.2686, 0.0114, 0.6253], - [7.005, 0.0054, 0.6625], - [8.5207, 0.0063, 0.6563], - [10.0034, 0.003, 0.6844], - [11.5075, 0.0031, 0.6833], - [13.0327, 0.0032, 0.6829], - [14.5356, 0.0018, 0.7003], - [17.5447, 0.0014, 0.7063], - [20.5576, 0.0011, 0.7126], - [23.5624, 0.0007, 0.7197], - [26.5785, 0.0007, 0.721], - [29.593, 0.0005, 0.7248], - [32.6109, 0.0004, 0.7268], - [35.6384, 0.0004, 0.727], - [38.6439, 0.0002, 0.7343], - [41.6815, 0.0004, 0.7284], - [44.6895, 0.0002, 0.7372], - [47.6926, 0.0001, 0.7393], - [51.4567, 0.0001, 0.7382] + [0.375, 0.6521, 0.05], + [0.67, 0.4286, 0.1338], + [0.9867, 0.2293, 0.2673], + [1.3683, 0.1434, 0.3521], + [1.8383, 0.0872, 0.4289], + [2.4133, 0.0524, 0.4931], + [2.625, 0.0252, 0.5587], + [2.8667, 0.0027, 0.6176], + [2.9917, -0.0954, 0.8989], + [3.12, -0.0068, 0.6339], + [3.4133, 0.0369, 0.4973], + [3.7417, 0.0469, 0.4634], + [3.9667, 0.0513, 0.4467], + [4.555, 0.0418, 0.4846], + [5.23, 0.0159, 0.6025], + [6.6483, 0.008, 0.6437], + [8.425, 0.0051, 0.6633], + [10.665, 0.0038, 0.6742], + [17.0017, 0.0021, 0.6922], + [21.3983, 0.0011, 0.7093], + [26.9283, 0.0008, 0.7155], + [33.8483, 0.0005, 0.7238], + [42.5067, 0.0004, 0.7288], + [53.2317, 0.0001, 0.7403] ] } }, - "defaultBlowoutVolume": 1.5 + "defaultBlowoutVolume": 1.5, + "defaultPushOutVolume": 7 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/1_0.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/1_0.json index 27b31bb65cc..382dfffd62a 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/1_0.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/1_0.json @@ -119,7 +119,8 @@ ] } }, - "defaultBlowoutVolume": -318.086 + "defaultBlowoutVolume": -318.086, + "defaultPushOutVolume": 7 }, "t200": { "defaultAspirateFlowRate": { @@ -237,7 +238,8 @@ ] } }, - "defaultBlowoutVolume": -318.086 + "defaultBlowoutVolume": -318.086, + "defaultPushOutVolume": 5 }, "t1000": { "defaultAspirateFlowRate": { @@ -355,7 +357,8 @@ ] } }, - "defaultBlowoutVolume": -318.086 + "defaultBlowoutVolume": -318.086, + "defaultPushOutVolume": 20 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_0.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_0.json index 26d091d548b..784d2bca84c 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_0.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_0.json @@ -119,7 +119,8 @@ ] } }, - "defaultBlowoutVolume": 3.2 + "defaultBlowoutVolume": 3.2, + "defaultPushOutVolume": 7 }, "t200": { "defaultAspirateFlowRate": { @@ -237,7 +238,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 5 }, "t1000": { "defaultAspirateFlowRate": { @@ -355,7 +357,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 20 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_3.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_3.json index 26d091d548b..784d2bca84c 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_3.json @@ -119,7 +119,8 @@ ] } }, - "defaultBlowoutVolume": 3.2 + "defaultBlowoutVolume": 3.2, + "defaultPushOutVolume": 7 }, "t200": { "defaultAspirateFlowRate": { @@ -237,7 +238,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 5 }, "t1000": { "defaultAspirateFlowRate": { @@ -355,7 +357,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 20 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_4.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_4.json index 63aa174d314..2261b958bd7 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_4.json @@ -119,7 +119,8 @@ [51.4512, 0.0064, 13.9651] ] } - } + }, + "defaultPushOutVolume": 7 }, "t200": { "defaultAspirateFlowRate": { @@ -237,7 +238,8 @@ [214.0264, 0.0008, 15.123] ] } - } + }, + "defaultPushOutVolume": 5 }, "t1000": { "defaultAspirateFlowRate": { @@ -355,7 +357,8 @@ [1051.4648, 0.0001, 15.391] ] } - } + }, + "defaultPushOutVolume": 20 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_5.json b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_5.json index 63aa174d314..2261b958bd7 100644 --- a/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/ninety_six_channel/p1000/default/3_5.json @@ -119,7 +119,8 @@ [51.4512, 0.0064, 13.9651] ] } - } + }, + "defaultPushOutVolume": 7 }, "t200": { "defaultAspirateFlowRate": { @@ -237,7 +238,8 @@ [214.0264, 0.0008, 15.123] ] } - } + }, + "defaultPushOutVolume": 5 }, "t1000": { "defaultAspirateFlowRate": { @@ -355,7 +357,8 @@ [1051.4648, 0.0001, 15.391] ] } - } + }, + "defaultPushOutVolume": 20 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p10/default/1_0.json b/shared-data/pipette/definitions/2/liquid/single_channel/p10/default/1_0.json index 50e489c8de8..6ffee508e62 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p10/default/1_0.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p10/default/1_0.json @@ -39,7 +39,8 @@ "2": [[12.5135, 0, 0.7945]] } }, - "defaultBlowoutVolume": 2.356 + "defaultBlowoutVolume": 2.356, + "defaultPushOutVolume": 0 } }, "maxVolume": 10, diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p10/default/1_3.json b/shared-data/pipette/definitions/2/liquid/single_channel/p10/default/1_3.json index 81d69757d94..65c98f7ecb9 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p10/default/1_3.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p10/default/1_3.json @@ -39,7 +39,8 @@ "2": [[12.5135, 0, 0.7945]] } }, - "defaultBlowoutVolume": 2.356 + "defaultBlowoutVolume": 2.356, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p10/default/1_4.json b/shared-data/pipette/definitions/2/liquid/single_channel/p10/default/1_4.json index ae135bbb601..a76a4005db4 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p10/default/1_4.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p10/default/1_4.json @@ -39,7 +39,8 @@ "2": [[12.5135, 0, 0.7945]] } }, - "defaultBlowoutVolume": 2.356 + "defaultBlowoutVolume": 2.356, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p10/default/1_5.json b/shared-data/pipette/definitions/2/liquid/single_channel/p10/default/1_5.json index e2bdebf2be0..6ad59bfad92 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p10/default/1_5.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p10/default/1_5.json @@ -38,7 +38,8 @@ "opentrons/geb_96_tiprack_10ul/1": 6.2, "opentrons/eppendorf_96_tiprack_10ul_eptips/1": 1.0 }, - "defaultBlowoutVolume": 2.356 + "defaultBlowoutVolume": 2.356, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/1_0.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/1_0.json index 234f72f14c9..5864ddc0ffd 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/1_0.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/1_0.json @@ -30,7 +30,8 @@ "dispense": { "default": { "1": [[1000.0, 0.0, 61.3275]] } }, - "defaultBlowoutVolume": 127.235 + "defaultBlowoutVolume": 127.235, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/1_3.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/1_3.json index 33074474dff..6a0651ee6e3 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/1_3.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/1_3.json @@ -32,7 +32,8 @@ "1": [[1000.0, 0.0, 61.3275]] } }, - "defaultBlowoutVolume": 127.235 + "defaultBlowoutVolume": 127.235, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/1_4.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/1_4.json index 33074474dff..6a0651ee6e3 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/1_4.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/1_4.json @@ -32,7 +32,8 @@ "1": [[1000.0, 0.0, 61.3275]] } }, - "defaultBlowoutVolume": 127.235 + "defaultBlowoutVolume": 127.235, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/1_5.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/1_5.json index 234f72f14c9..5864ddc0ffd 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/1_5.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/1_5.json @@ -30,7 +30,8 @@ "dispense": { "default": { "1": [[1000.0, 0.0, 61.3275]] } }, - "defaultBlowoutVolume": 127.235 + "defaultBlowoutVolume": 127.235, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_0.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_0.json index df91fb61891..6dd39522077 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_0.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_0.json @@ -95,7 +95,8 @@ ] } }, - "defaultBlowoutVolume": 127.235 + "defaultBlowoutVolume": 127.235, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_1.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_1.json index b6b0494e42b..8890cd3d2e0 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_1.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_1.json @@ -97,7 +97,8 @@ ] } }, - "defaultBlowoutVolume": 127.235 + "defaultBlowoutVolume": 127.235, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_2.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_2.json index 53e5880e3b8..4cacb7531a3 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_2.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/2_2.json @@ -97,7 +97,8 @@ ] } }, - "defaultBlowoutVolume": 127.235 + "defaultBlowoutVolume": 127.235, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_0.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_0.json index 594f045d072..e61496d9c90 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_0.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_0.json @@ -119,7 +119,8 @@ ] } }, - "defaultBlowoutVolume": 3.2 + "defaultBlowoutVolume": 3.2, + "defaultPushOutVolume": 7 }, "t200": { "defaultAspirateFlowRate": { @@ -237,7 +238,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 5 }, "t1000": { "defaultAspirateFlowRate": { @@ -355,7 +357,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 20 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_3.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_3.json index 594f045d072..e61496d9c90 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_3.json @@ -119,7 +119,8 @@ ] } }, - "defaultBlowoutVolume": 3.2 + "defaultBlowoutVolume": 3.2, + "defaultPushOutVolume": 7 }, "t200": { "defaultAspirateFlowRate": { @@ -237,7 +238,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 5 }, "t1000": { "defaultAspirateFlowRate": { @@ -355,7 +357,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 20 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_4.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_4.json index bd31c1ec15a..b90d35ee790 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_4.json @@ -93,7 +93,8 @@ ] } }, - "defaultBlowoutVolume": 3.2 + "defaultBlowoutVolume": 3.2, + "defaultPushOutVolume": 7 }, "t200": { "defaultAspirateFlowRate": { @@ -187,7 +188,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 5 }, "t1000": { "defaultAspirateFlowRate": { @@ -281,7 +283,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 20 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_5.json b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_5.json index 0077411c3f6..031c5536ddc 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p1000/default/3_5.json @@ -26,54 +26,55 @@ "aspirate": { "default": { "1": [ - [0.598667, 5.492916, 0.238012], - [1.008667, 4.441331, 0.867561], - [1.703333, 3.134903, 2.185311], - [2.148, 2.093612, 3.958978], - [2.630667, 1.531735, 5.16589], - [2.968667, 0.130993, 8.850773], - [3.040667, -6.20796, 27.669014], - [3.212, 0.534702, 7.166826], - [3.542, 1.528965, 3.973251], - [4.394, 1.677278, 3.447926], - [5.366, 0.947649, 6.653918], - [6.161333, 0.32074, 10.01791], - [8.794667, 0.274041, 10.305637], - [10.542667, 0.161462, 11.295736], - [13.850667, 0.104161, 11.899836], - [22.955333, 0.053443, 12.602318], - [29.402, 0.027894, 13.188802], - [47.971333, 0.015576, 13.550976], - [54.092, 0.006549, 13.984019] + [0.7033, 4.9393, 0.669], + [1.0911, 4.233, 1.1657], + [1.7256, 2.8983, 2.6221], + [2.1344, 1.9062, 4.334], + [2.57, 1.3331, 5.5573], + [2.9178, 0.2816, 8.2595], + [3.0222, -3.2737, 18.6333], + [3.2233, 0.8766, 6.09], + [3.5033, 1.324, 4.648], + [4.4567, 1.7683, 3.0913], + [5.3878, 0.8747, 7.0738], + [6.1956, 0.3393, 9.9586], + [8.7911, 0.2504, 10.5095], + [10.5322, 0.1577, 11.3246], + [13.8389, 0.1047, 11.8827], + [23.01, 0.0579, 12.5301], + [29.4811, 0.0285, 13.2057], + [48.0456, 0.0147, 13.612], + [54.06, 0.0016, 14.2446] ] } }, "dispense": { "default": { "1": [ - [0.598667, 5.492916, 0.238012], - [1.008667, 4.441331, 0.867561], - [1.703333, 3.134903, 2.185311], - [2.148, 2.093612, 3.958978], - [2.630667, 1.531735, 5.16589], - [2.968667, 0.130993, 8.850773], - [3.040667, -6.20796, 27.669014], - [3.212, 0.534702, 7.166826], - [3.542, 1.528965, 3.973251], - [4.394, 1.677278, 3.447926], - [5.366, 0.947649, 6.653918], - [6.161333, 0.32074, 10.01791], - [8.794667, 0.274041, 10.305637], - [10.542667, 0.161462, 11.295736], - [13.850667, 0.104161, 11.899836], - [22.955333, 0.053443, 12.602318], - [29.402, 0.027894, 13.188802], - [47.971333, 0.015576, 13.550976], - [54.092, 0.006549, 13.984019] + [0.7033, 4.9393, 0.669], + [1.0911, 4.233, 1.1657], + [1.7256, 2.8983, 2.6221], + [2.1344, 1.9062, 4.334], + [2.57, 1.3331, 5.5573], + [2.9178, 0.2816, 8.2595], + [3.0222, -3.2737, 18.6333], + [3.2233, 0.8766, 6.09], + [3.5033, 1.324, 4.648], + [4.4567, 1.7683, 3.0913], + [5.3878, 0.8747, 7.0738], + [6.1956, 0.3393, 9.9586], + [8.7911, 0.2504, 10.5095], + [10.5322, 0.1577, 11.3246], + [13.8389, 0.1047, 11.8827], + [23.01, 0.0579, 12.5301], + [29.4811, 0.0285, 13.2057], + [48.0456, 0.0147, 13.612], + [54.06, 0.0016, 14.2446] ] } }, - "defaultBlowoutVolume": 3.2 + "defaultBlowoutVolume": 3.2, + "defaultPushOutVolume": 7 }, "t200": { "defaultAspirateFlowRate": { @@ -167,7 +168,8 @@ ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 5 }, "t1000": { "defaultAspirateFlowRate": { @@ -194,46 +196,47 @@ "aspirate": { "default": { "1": [ - [1.915833, 1.927128, 3.925446], - [2.815833, 1.488084, 4.766582], - [4.740833, 0.734841, 6.887589], - [9.56, 0.312748, 8.88866], - [11.899167, 0.185659, 10.103631], - [14.8525, 0.147322, 10.559816], - [51.128333, 0.042193, 12.121236], - [92.410833, 0.010715, 13.730653], - [112.169167, 0.004993, 14.259441], - [243.099167, 0.002853, 14.499515], - [355.853333, 0.000919, 14.969688], - [430.2725, 0.000569, 15.094064], - [627.886667, 0.000284, 15.216604], - [999.469167, 0.000101, 15.331563], - [1103.450833, 0.0, 15.433145] + [2.1588, 1.8801, 4.5248], + [3.125, 1.4042, 5.552], + [5.0288, 0.5573, 8.1985], + [9.83, 0.2526, 9.7309], + [12.1563, 0.1568, 10.6724], + [15.0438, 0.1154, 11.176], + [51.2713, 0.0388, 12.3281], + [92.4825, 0.01, 13.8035], + [112.2463, 0.0049, 14.2764], + [242.9288, 0.0027, 14.5268], + [355.5763, 0.0009, 14.9617], + [429.8313, 0.0005, 15.1002], + [627.3463, 0.0003, 15.1955], + [998.4438, 0.0001, 15.3227], + [1102.5675, 0.0, 15.3839] ] } }, "dispense": { "default": { "1": [ - [1.915833, 1.927128, 3.925446], - [2.815833, 1.488084, 4.766582], - [4.740833, 0.734841, 6.887589], - [9.56, 0.312748, 8.88866], - [11.899167, 0.185659, 10.103631], - [14.8525, 0.147322, 10.559816], - [51.128333, 0.042193, 12.121236], - [92.410833, 0.010715, 13.730653], - [112.169167, 0.004993, 14.259441], - [243.099167, 0.002853, 14.499515], - [355.853333, 0.000919, 14.969688], - [430.2725, 0.000569, 15.094064], - [627.886667, 0.000284, 15.216604], - [999.469167, 0.000101, 15.331563], - [1103.450833, 0.0, 15.433145] + [2.1588, 1.8801, 4.5248], + [3.125, 1.4042, 5.552], + [5.0288, 0.5573, 8.1985], + [9.83, 0.2526, 9.7309], + [12.1563, 0.1568, 10.6724], + [15.0438, 0.1154, 11.176], + [51.2713, 0.0388, 12.3281], + [92.4825, 0.01, 13.8035], + [112.2463, 0.0049, 14.2764], + [242.9288, 0.0027, 14.5268], + [355.5763, 0.0009, 14.9617], + [429.8313, 0.0005, 15.1002], + [627.3463, 0.0003, 15.1955], + [998.4438, 0.0001, 15.3227], + [1102.5675, 0.0, 15.3839] ] } }, - "defaultBlowoutVolume": 16 + "defaultBlowoutVolume": 16, + "defaultPushOutVolume": 20 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_0.json b/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_0.json index 1703bcda8cb..39c1882567f 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_0.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_0.json @@ -212,7 +212,8 @@ ] } }, - "defaultBlowoutVolume": 3.534 + "defaultBlowoutVolume": 3.534, + "defaultPushOutVolume": 0 }, "t10": { "defaultAspirateFlowRate": { @@ -425,7 +426,8 @@ ] } }, - "defaultBlowoutVolume": 3.534 + "defaultBlowoutVolume": 3.534, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_1.json b/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_1.json index 1c0bb6f15b7..8f10c9406f6 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_1.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_1.json @@ -152,7 +152,8 @@ ] } }, - "defaultBlowoutVolume": 3.534 + "defaultBlowoutVolume": 3.534, + "defaultPushOutVolume": 0 }, "t10": { "defaultAspirateFlowRate": { @@ -245,7 +246,8 @@ ] } }, - "defaultBlowoutVolume": 3.534 + "defaultBlowoutVolume": 3.534, + "defaultPushOutVolume": 0 } }, "maxVolume": 20, diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_2.json b/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_2.json index dbb07223a32..3708e3dbe4d 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_2.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p20/default/2_2.json @@ -152,7 +152,8 @@ ] } }, - "defaultBlowoutVolume": 3.534 + "defaultBlowoutVolume": 3.534, + "defaultPushOutVolume": 0 }, "t10": { "defaultAspirateFlowRate": { @@ -305,7 +306,8 @@ ] } }, - "defaultBlowoutVolume": 3.534 + "defaultBlowoutVolume": 3.534, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/1_0.json b/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/1_0.json index 4fb6fbd1950..e6f1c2dbaea 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/1_0.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/1_0.json @@ -40,7 +40,8 @@ "2": [[302.3895337, 0, 18.83156277]] } }, - "defaultBlowoutVolume": 58.905 + "defaultBlowoutVolume": 58.905, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -81,7 +82,8 @@ "2": [[302.3895337, 0, 18.83156277]] } }, - "defaultBlowoutVolume": 58.905 + "defaultBlowoutVolume": 58.905, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/1_3.json b/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/1_3.json index 4fb6fbd1950..e6f1c2dbaea 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/1_3.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/1_3.json @@ -40,7 +40,8 @@ "2": [[302.3895337, 0, 18.83156277]] } }, - "defaultBlowoutVolume": 58.905 + "defaultBlowoutVolume": 58.905, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -81,7 +82,8 @@ "2": [[302.3895337, 0, 18.83156277]] } }, - "defaultBlowoutVolume": 58.905 + "defaultBlowoutVolume": 58.905, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/1_4.json b/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/1_4.json index 4fb6fbd1950..e6f1c2dbaea 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/1_4.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/1_4.json @@ -40,7 +40,8 @@ "2": [[302.3895337, 0, 18.83156277]] } }, - "defaultBlowoutVolume": 58.905 + "defaultBlowoutVolume": 58.905, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -81,7 +82,8 @@ "2": [[302.3895337, 0, 18.83156277]] } }, - "defaultBlowoutVolume": 58.905 + "defaultBlowoutVolume": 58.905, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/1_5.json b/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/1_5.json index 24904d5a496..66d6ece893e 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/1_5.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/1_5.json @@ -32,7 +32,8 @@ "1": [[302.3895337, 0.0, 18.83156277]] } }, - "defaultBlowoutVolume": 58.905 + "defaultBlowoutVolume": 58.905, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -65,7 +66,8 @@ "1": [[302.3895337, 0.0, 18.83156277]] } }, - "defaultBlowoutVolume": 58.905 + "defaultBlowoutVolume": 58.905, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/2_0.json b/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/2_0.json index 9dc7403a5c1..ea5c1c9b367 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/2_0.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/2_0.json @@ -94,7 +94,8 @@ ] } }, - "defaultBlowoutVolume": 43.295 + "defaultBlowoutVolume": 43.295, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -189,7 +190,8 @@ ] } }, - "defaultBlowoutVolume": 43.295 + "defaultBlowoutVolume": 43.295, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/2_1.json b/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/2_1.json index fd654b1c870..b73308f74e5 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/2_1.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p300/default/2_1.json @@ -94,7 +94,8 @@ ] } }, - "defaultBlowoutVolume": 43.295 + "defaultBlowoutVolume": 43.295, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -189,7 +190,8 @@ ] } }, - "defaultBlowoutVolume": 43.295 + "defaultBlowoutVolume": 43.295, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/1_0.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/1_0.json index c40edfdc1fe..888ff33469a 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/1_0.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/1_0.json @@ -36,7 +36,8 @@ "2": [[50, 0, 2.931601299]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 }, "t200": { "defaultAspirateFlowRate": { @@ -73,7 +74,8 @@ "2": [[50, 0, 2.931601299]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -110,7 +112,8 @@ "2": [[50, 0, 2.931601299]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/1_3.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/1_3.json index 0252b304dd5..fa6979ef006 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/1_3.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/1_3.json @@ -36,7 +36,8 @@ "2": [[50, 0, 2.931601299]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 }, "t200": { "defaultAspirateFlowRate": { @@ -73,7 +74,8 @@ "2": [[50, 0, 2.931601299]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -110,7 +112,8 @@ "2": [[50, 0, 2.931601299]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/1_4.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/1_4.json index 0252b304dd5..fa6979ef006 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/1_4.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/1_4.json @@ -36,7 +36,8 @@ "2": [[50, 0, 2.931601299]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 }, "t200": { "defaultAspirateFlowRate": { @@ -73,7 +74,8 @@ "2": [[50, 0, 2.931601299]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -110,7 +112,8 @@ "2": [[50, 0, 2.931601299]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/1_5.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/1_5.json index 479aa28b1e5..a3304a80bc9 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/1_5.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/1_5.json @@ -32,7 +32,8 @@ "1": [[50.0, 0.0, 2.931601299]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 }, "t200": { "defaultAspirateFlowRate": { @@ -65,7 +66,8 @@ "1": [[50.0, 0.0, 2.931601299]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 }, "t300": { "defaultAspirateFlowRate": { @@ -98,7 +100,8 @@ "1": [[50.0, 0.0, 2.931601299]] } }, - "defaultBlowoutVolume": 4.712 + "defaultBlowoutVolume": 4.712, + "defaultPushOutVolume": 0 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_0.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_0.json index 8630a2eff29..3516fce5ea0 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_0.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_0.json @@ -85,7 +85,8 @@ ] } }, - "defaultBlowoutVolume": 1.5 + "defaultBlowoutVolume": 1.5, + "defaultPushOutVolume": 2 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_3.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_3.json index 8630a2eff29..3516fce5ea0 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_3.json @@ -85,7 +85,8 @@ ] } }, - "defaultBlowoutVolume": 1.5 + "defaultBlowoutVolume": 1.5, + "defaultPushOutVolume": 2 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_4.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_4.json index c2b6a5564bb..1c484b29a32 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_4.json @@ -87,7 +87,8 @@ ] } }, - "defaultBlowoutVolume": 1.5 + "defaultBlowoutVolume": 1.5, + "defaultPushOutVolume": 2 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_5.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_5.json index c2b6a5564bb..7ae6b1de95a 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/default/3_5.json @@ -20,74 +20,55 @@ "aspirate": { "default": { "1": [ - [0.45, 0.4702, 0.0464], - [0.6717, 0.3617, 0.0952], - [0.9133, 0.259, 0.1642], - [1.1783, 0.1997, 0.2184], - [1.46, 0.1366, 0.2927], - [1.8183, 0.1249, 0.3098], - [2.1783, 0.0719, 0.4061], - [2.615, 0.0666, 0.4176], - [3.015, 0.0152, 0.552], - [3.4433, 0.0008, 0.5956], - [4.3033, 0.0659, 0.3713], - [5.0933, 0.0306, 0.5234], - [5.915, 0.0135, 0.6102], - [6.8233, 0.0083, 0.6414], - [7.85, 0.0051, 0.6631], - [9.005, 0.0025, 0.6838], - [10.3517, 0.0036, 0.6735], - [11.9, 0.0032, 0.6775], - [13.6617, 0.0023, 0.6886], - [15.6383, 0.001, 0.7058], - [17.95, 0.0015, 0.6976], - [20.58, 0.0012, 0.7033], - [23.5483, 0.0005, 0.7183], - [26.9983, 0.0008, 0.7105], - [30.88, 0.0003, 0.7233], - [35.3167, 0.0003, 0.725], - [40.4283, 0.0004, 0.7224], - [46.255, 0.0003, 0.7271], - [52.8383, 0.0, 0.7369] + [0.462, 0.5646, 0.0415], + [0.648, 0.3716, 0.1307], + [1.032, 0.2742, 0.1938], + [1.37, 0.1499, 0.3221], + [2.014, 0.1044, 0.3845], + [2.772, 0.0432, 0.5076], + [3.05, -0.0809, 0.8517], + [3.4, 0.0256, 0.5268], + [3.962, 0.0612, 0.4057], + [4.438, 0.0572, 0.4217], + [5.164, 0.018, 0.5955], + [5.966, 0.0095, 0.6393], + [7.38, 0.0075, 0.6514], + [9.128, 0.0049, 0.6705], + [10.16, 0.0033, 0.6854], + [13.812, 0.0024, 0.6948], + [27.204, 0.0008, 0.7165], + [50.614, 0.0002, 0.7328], + [53.046, -0.0005, 0.7676] ] } }, "dispense": { "default": { "1": [ - [0.45, 0.4702, 0.0464], - [0.6717, 0.3617, 0.0952], - [0.9133, 0.259, 0.1642], - [1.1783, 0.1997, 0.2184], - [1.46, 0.1366, 0.2927], - [1.8183, 0.1249, 0.3098], - [2.1783, 0.0719, 0.4061], - [2.615, 0.0666, 0.4176], - [3.015, 0.0152, 0.552], - [3.4433, 0.0008, 0.5956], - [4.3033, 0.0659, 0.3713], - [5.0933, 0.0306, 0.5234], - [5.915, 0.0135, 0.6102], - [6.8233, 0.0083, 0.6414], - [7.85, 0.0051, 0.6631], - [9.005, 0.0025, 0.6838], - [10.3517, 0.0036, 0.6735], - [11.9, 0.0032, 0.6775], - [13.6617, 0.0023, 0.6886], - [15.6383, 0.001, 0.7058], - [17.95, 0.0015, 0.6976], - [20.58, 0.0012, 0.7033], - [23.5483, 0.0005, 0.7183], - [26.9983, 0.0008, 0.7105], - [30.88, 0.0003, 0.7233], - [35.3167, 0.0003, 0.725], - [40.4283, 0.0004, 0.7224], - [46.255, 0.0003, 0.7271], - [52.8383, 0.0, 0.7369] + [0.462, 0.5646, 0.0415], + [0.648, 0.3716, 0.1307], + [1.032, 0.2742, 0.1938], + [1.37, 0.1499, 0.3221], + [2.014, 0.1044, 0.3845], + [2.772, 0.0432, 0.5076], + [3.05, -0.0809, 0.8517], + [3.4, 0.0256, 0.5268], + [3.962, 0.0612, 0.4057], + [4.438, 0.0572, 0.4217], + [5.164, 0.018, 0.5955], + [5.966, 0.0095, 0.6393], + [7.38, 0.0075, 0.6514], + [9.128, 0.0049, 0.6705], + [10.16, 0.0033, 0.6854], + [13.812, 0.0024, 0.6948], + [27.204, 0.0008, 0.7165], + [50.614, 0.0002, 0.7328], + [53.046, -0.0005, 0.7676] ] } }, - "defaultBlowoutVolume": 1.5 + "defaultBlowoutVolume": 1.5, + "defaultPushOutVolume": 2 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_0.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_0.json index 1c8a7caf11b..54152b6d0b8 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_0.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_0.json @@ -85,7 +85,8 @@ ] } }, - "defaultBlowoutVolume": 1.5 + "defaultBlowoutVolume": 1.5, + "defaultPushOutVolume": 7 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_3.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_3.json index 1c8a7caf11b..54152b6d0b8 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_3.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_3.json @@ -85,7 +85,8 @@ ] } }, - "defaultBlowoutVolume": 1.5 + "defaultBlowoutVolume": 1.5, + "defaultPushOutVolume": 7 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_4.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_4.json index 5352442665a..ca07944e66c 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_4.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_4.json @@ -87,7 +87,8 @@ ] } }, - "defaultBlowoutVolume": 1.5 + "defaultBlowoutVolume": 1.5, + "defaultPushOutVolume": 7 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_5.json b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_5.json index 5352442665a..84ed39b2d08 100644 --- a/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_5.json +++ b/shared-data/pipette/definitions/2/liquid/single_channel/p50/lowVolumeDefault/3_5.json @@ -20,74 +20,55 @@ "aspirate": { "default": { "1": [ - [0.45, 0.4702, 0.0464], - [0.6717, 0.3617, 0.0952], - [0.9133, 0.259, 0.1642], - [1.1783, 0.1997, 0.2184], - [1.46, 0.1366, 0.2927], - [1.8183, 0.1249, 0.3098], - [2.1783, 0.0719, 0.4061], - [2.615, 0.0666, 0.4176], - [3.015, 0.0152, 0.552], - [3.4433, 0.0008, 0.5956], - [4.3033, 0.0659, 0.3713], - [5.0933, 0.0306, 0.5234], - [5.915, 0.0135, 0.6102], - [6.8233, 0.0083, 0.6414], - [7.85, 0.0051, 0.6631], - [9.005, 0.0025, 0.6838], - [10.3517, 0.0036, 0.6735], - [11.9, 0.0032, 0.6775], - [13.6617, 0.0023, 0.6886], - [15.6383, 0.001, 0.7058], - [17.95, 0.0015, 0.6976], - [20.58, 0.0012, 0.7033], - [23.5483, 0.0005, 0.7183], - [26.9983, 0.0008, 0.7105], - [30.88, 0.0003, 0.7233], - [35.3167, 0.0003, 0.725], - [40.4283, 0.0004, 0.7224], - [46.255, 0.0003, 0.7271], - [52.8383, 0.0, 0.7369] + [0.462, 0.5646, 0.0415], + [0.648, 0.3716, 0.1307], + [1.032, 0.2742, 0.1938], + [1.37, 0.1499, 0.3221], + [2.014, 0.1044, 0.3845], + [2.772, 0.0432, 0.5076], + [3.05, -0.0809, 0.8517], + [3.4, 0.0256, 0.5268], + [3.962, 0.0612, 0.4057], + [4.438, 0.0572, 0.4217], + [5.164, 0.018, 0.5955], + [5.966, 0.0095, 0.6393], + [7.38, 0.0075, 0.6514], + [9.128, 0.0049, 0.6705], + [10.16, 0.0033, 0.6854], + [13.812, 0.0024, 0.6948], + [27.204, 0.0008, 0.7165], + [50.614, 0.0002, 0.7328], + [53.046, -0.0005, 0.7676] ] } }, "dispense": { "default": { "1": [ - [0.45, 0.4702, 0.0464], - [0.6717, 0.3617, 0.0952], - [0.9133, 0.259, 0.1642], - [1.1783, 0.1997, 0.2184], - [1.46, 0.1366, 0.2927], - [1.8183, 0.1249, 0.3098], - [2.1783, 0.0719, 0.4061], - [2.615, 0.0666, 0.4176], - [3.015, 0.0152, 0.552], - [3.4433, 0.0008, 0.5956], - [4.3033, 0.0659, 0.3713], - [5.0933, 0.0306, 0.5234], - [5.915, 0.0135, 0.6102], - [6.8233, 0.0083, 0.6414], - [7.85, 0.0051, 0.6631], - [9.005, 0.0025, 0.6838], - [10.3517, 0.0036, 0.6735], - [11.9, 0.0032, 0.6775], - [13.6617, 0.0023, 0.6886], - [15.6383, 0.001, 0.7058], - [17.95, 0.0015, 0.6976], - [20.58, 0.0012, 0.7033], - [23.5483, 0.0005, 0.7183], - [26.9983, 0.0008, 0.7105], - [30.88, 0.0003, 0.7233], - [35.3167, 0.0003, 0.725], - [40.4283, 0.0004, 0.7224], - [46.255, 0.0003, 0.7271], - [52.8383, 0.0, 0.7369] + [0.462, 0.5646, 0.0415], + [0.648, 0.3716, 0.1307], + [1.032, 0.2742, 0.1938], + [1.37, 0.1499, 0.3221], + [2.014, 0.1044, 0.3845], + [2.772, 0.0432, 0.5076], + [3.05, -0.0809, 0.8517], + [3.4, 0.0256, 0.5268], + [3.962, 0.0612, 0.4057], + [4.438, 0.0572, 0.4217], + [5.164, 0.018, 0.5955], + [5.966, 0.0095, 0.6393], + [7.38, 0.0075, 0.6514], + [9.128, 0.0049, 0.6705], + [10.16, 0.0033, 0.6854], + [13.812, 0.0024, 0.6948], + [27.204, 0.0008, 0.7165], + [50.614, 0.0002, 0.7328], + [53.046, -0.0005, 0.7676] ] } }, - "defaultBlowoutVolume": 1.5 + "defaultBlowoutVolume": 1.5, + "defaultPushOutVolume": 7 } }, "defaultTipOverlapDictionary": { diff --git a/shared-data/protocol/types/schemaV7/command/incidental.ts b/shared-data/protocol/types/schemaV7/command/incidental.ts new file mode 100644 index 00000000000..aa3c6857d9b --- /dev/null +++ b/shared-data/protocol/types/schemaV7/command/incidental.ts @@ -0,0 +1,21 @@ +import type { CommonCommandRunTimeInfo, CommonCommandCreateInfo } from '.' +import type { StatusBarAnimation } from '../../../../js/types' + +export type IncidentalCreateCommand = SetStatusBarCreateCommand + +export type IncidentalRunTimeCommand = SetStatusBarRunTimeCommand + +export interface SetStatusBarCreateCommand extends CommonCommandCreateInfo { + commandType: 'setStatusBar' + params: SetStatusBarParams +} + +export interface SetStatusBarRunTimeCommand + extends CommonCommandRunTimeInfo, + SetStatusBarCreateCommand { + result?: any +} + +interface SetStatusBarParams { + animation: StatusBarAnimation +} diff --git a/shared-data/protocol/types/schemaV7/command/index.ts b/shared-data/protocol/types/schemaV7/command/index.ts index a9dec62350f..ae4c39ad209 100644 --- a/shared-data/protocol/types/schemaV7/command/index.ts +++ b/shared-data/protocol/types/schemaV7/command/index.ts @@ -6,6 +6,10 @@ import type { GantryRunTimeCommand, GantryCreateCommand } from './gantry' import type { ModuleRunTimeCommand, ModuleCreateCommand } from './module' import type { SetupRunTimeCommand, SetupCreateCommand } from './setup' import type { TimingRunTimeCommand, TimingCreateCommand } from './timing' +import type { + IncidentalCreateCommand, + IncidentalRunTimeCommand, +} from './incidental' import type { AnnotationRunTimeCommand, AnnotationCreateCommand, @@ -51,6 +55,7 @@ export type CreateCommand = | TimingCreateCommand // effecting the timing of command execution | CalibrationCreateCommand // for automatic pipette calibration | AnnotationCreateCommand // annotating command execution + | IncidentalCreateCommand // command with only incidental effects (status bar animations) // commands will be required to have a key, but will not be created with one export type RunTimeCommand = @@ -61,6 +66,7 @@ export type RunTimeCommand = | TimingRunTimeCommand // effecting the timing of command execution | CalibrationRunTimeCommand // for automatic pipette calibration | AnnotationRunTimeCommand // annotating command execution + | IncidentalRunTimeCommand // command with only incidental effects (status bar animations) interface RunCommandError { id: string diff --git a/shared-data/protocol/types/schemaV7/command/setup.ts b/shared-data/protocol/types/schemaV7/command/setup.ts index b157522e229..84f17313f25 100644 --- a/shared-data/protocol/types/schemaV7/command/setup.ts +++ b/shared-data/protocol/types/schemaV7/command/setup.ts @@ -123,7 +123,7 @@ interface MoveLabwareResult { offsetId: string } interface LoadModuleParams { - moduleId: string + moduleId?: string location: ModuleLocation model: ModuleModel } diff --git a/shared-data/python/Makefile b/shared-data/python/Makefile index d3376bd89e9..5b8778e09ef 100644 --- a/shared-data/python/Makefile +++ b/shared-data/python/Makefile @@ -3,9 +3,9 @@ include ../../scripts/python.mk include ../../scripts/push.mk -# Host key location for buildroot robot -br_ssh_key ?= $(default_ssh_key) -# Other SSH args for buildroot robots +# Host key location for robot +ssh_key ?= $(default_ssh_key) +# Other SSH args for robot ssh_opts ?= $(default_ssh_opts) # using bash instead of /bin/bash in SHELL prevents macOS optimizing away our PATH update @@ -103,19 +103,19 @@ format: .PHONY: push-no-restart push-no-restart: wheel - $(call push-python-package,$(host),$(br_ssh_key),$(ssh_opts),$(wheel_file)) + $(call push-python-package,$(host),$(ssh_key),$(ssh_opts),$(wheel_file)) .PHONY: push push: push-no-restart - $(call restart-service,$(host),$(br_ssh_key),$(ssh_opts),opentrons-robot-server) + $(call restart-service,$(host),$(ssh_key),$(ssh_opts),opentrons-robot-server) .PHONY: push-no-restart-ot3 push-no-restart-ot3: sdist - $(call push-python-sdist,$(host),,$(ssh_opts),$(sdist_file),/opt/opentrons-robot-server,opentrons_shared_data) + $(call push-python-sdist,$(host),$(ssh_key),$(ssh_opts),$(sdist_file),/opt/opentrons-robot-server,opentrons_shared_data) .PHONY: push-ot3 push-ot3: push-no-restart-ot3 - $(call restart-server,$(host),,$(ssh_opts),opentrons-robot-server) + $(call restart-server,$(host),$(ssh_key),$(ssh_opts),opentrons-robot-server) .PHONY: deploy deploy: wheel diff --git a/shared-data/python/opentrons_shared_data/gripper/constants.py b/shared-data/python/opentrons_shared_data/gripper/constants.py index cd3ba033d7c..3273b85e99f 100644 --- a/shared-data/python/opentrons_shared_data/gripper/constants.py +++ b/shared-data/python/opentrons_shared_data/gripper/constants.py @@ -1,6 +1,5 @@ """Gripper constants and default values.""" LABWARE_GRIP_FORCE = 15.0 # Newtons -IDLE_STATE_GRIP_FORCE = 10.0 # Newtons GRIPPER_DECK_DROP_OFFSET = [-0.5, -0.5, -1.5] GRIPPER_MODULE_DROP_OFFSET = [0.0, 0.0, 1.5] diff --git a/shared-data/python/opentrons_shared_data/gripper/gripper_definition.py b/shared-data/python/opentrons_shared_data/gripper/gripper_definition.py index 91f892fafc8..70ca41f54ce 100644 --- a/shared-data/python/opentrons_shared_data/gripper/gripper_definition.py +++ b/shared-data/python/opentrons_shared_data/gripper/gripper_definition.py @@ -83,6 +83,7 @@ class GripForceProfile(GripperBaseModel): min_items=1, ) default_grip_force: _StrictNonNegativeFloat + default_idle_force: _StrictNonNegativeFloat default_home_force: _StrictNonNegativeFloat min: _StrictNonNegativeFloat max: _StrictNonNegativeFloat diff --git a/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py b/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py index 027a2e688b9..743915c90ae 100644 --- a/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py +++ b/shared-data/python/opentrons_shared_data/pipette/pipette_definition.py @@ -109,6 +109,11 @@ class SupportedTipsDefinition(BaseModel): description="The default volume for a blowout command with this tip type.", alias="defaultBlowoutVolume", ) + default_push_out_volume: Optional[float] = Field( + ..., + description="The default volume for a push-out during dispense.", + alias="defaultPushOutVolume", + ) class MotorConfigurations(BaseModel): diff --git a/shared-data/python/opentrons_shared_data/pipette/scripts/update_configuration_files.py b/shared-data/python/opentrons_shared_data/pipette/scripts/update_configuration_files.py index 7d43adc41d2..eaf2665af9b 100644 --- a/shared-data/python/opentrons_shared_data/pipette/scripts/update_configuration_files.py +++ b/shared-data/python/opentrons_shared_data/pipette/scripts/update_configuration_files.py @@ -27,6 +27,7 @@ PipetteTipType, PipetteModelMajorVersion, PipetteModelMinorVersion, + LiquidClasses, ) from ..load_data import _geometry, _physical, _liquid from ..pipette_load_name_conversions import convert_pipette_model @@ -144,6 +145,7 @@ def update( Recursively update the given dictionary to ensure no data is lost when updating. """ next_key = next(iter_of_configs, None) + breakpoint() if next_key and isinstance(dict_to_update[next_key], dict): dict_to_update[next_key] = update( dict_to_update.get(next_key, {}), iter_of_configs, value_to_update @@ -250,26 +252,60 @@ def load_and_update_file_from_config( f"{model_to_update.pipette_version.major}_{model_to_update.pipette_version.minor}", physical, ) - elif config_to_update[0] in PipetteLiquidPropertiesDefinition.__fields__: + elif config_to_update[0] == "liquid_properties": + next(camel_list_to_update) liquid = _liquid( model_to_update.pipette_channels, model_to_update.pipette_type, model_to_update.pipette_version, ) - liquid = update(physical, camel_list_to_update, value_to_update) - PipetteLiquidPropertiesDefinition.parse_obj(liquid) - filepath = ( - ROOT - / "liquid" - / model_to_update.pipette_channels.name.lower() - / model_to_update.pipette_type.value - ) - save_data_to_file( - filepath, - f"{model_to_update.pipette_version.major}_{model_to_update.pipette_version.minor}", - liquid, + print( + "Please select what liquid class you wish to update.\n If you want to update all liquid classes then type 'all'.\n" ) + + print(f"choose {LiquidClasses.__name__} or type 'all':") + for row in list_available_enum(LiquidClasses): + print(f"\t{row}") + liquid_classes = input("liquid class: ") + if liquid_classes == "all": + for c in LiquidClasses: + liquid = update( + liquid[c.name.lower()], camel_list_to_update, value_to_update + ) + + PipetteLiquidPropertiesDefinition.parse_obj(liquid) + filepath = ( + ROOT + / "liquid" + / model_to_update.pipette_channels.name.lower() + / model_to_update.pipette_type.value + / c.name.lower() + ) + save_data_to_file( + filepath, + f"{model_to_update.pipette_version.major}_{model_to_update.pipette_version.minor}", + liquid, + ) + else: + lc = list(LiquidClasses)[int(liquid_classes)] + liquid = update( + liquid[lc.name.lower()], camel_list_to_update, value_to_update + ) + PipetteLiquidPropertiesDefinition.parse_obj(liquid) + + filepath = ( + ROOT + / "liquid" + / model_to_update.pipette_channels.name.lower() + / model_to_update.pipette_type.value + / lc.name.lower() + ) + save_data_to_file( + filepath, + f"{model_to_update.pipette_version.major}_{model_to_update.pipette_version.minor}", + liquid, + ) else: raise KeyError( f"{config_to_update} is not saved to a file. Check `pipette_definition.py` for more information." @@ -304,7 +340,7 @@ def _update_single_model(configuration_to_update: List[str]) -> None: value_to_update = json.loads( input( - f"Please select what you would like to update {configuration_to_update} to for {built_model}\n" + f"Please select what you would like to update {configuration_to_update[-1]} to for {built_model}\n" ) ) diff --git a/shared-data/python/opentrons_shared_data/pipette/types.py b/shared-data/python/opentrons_shared_data/pipette/types.py index dba0b62c8ac..be86999c4ac 100644 --- a/shared-data/python/opentrons_shared_data/pipette/types.py +++ b/shared-data/python/opentrons_shared_data/pipette/types.py @@ -140,6 +140,8 @@ def build( max: float, name: str, ) -> "MutableConfig": + if units == "mm/sec": + units = "mm/s" return cls( value=value, default=default, diff --git a/shared-data/python/tests/pipette/test_mutable_configurations.py b/shared-data/python/tests/pipette/test_mutable_configurations.py index 0ab38dd9fe5..e70520fb05f 100644 --- a/shared-data/python/tests/pipette/test_mutable_configurations.py +++ b/shared-data/python/tests/pipette/test_mutable_configurations.py @@ -249,3 +249,18 @@ def test_load_with_overrides( assert updated_configurations_dict == dict_loaded_configs else: assert updated_configurations == loaded_base_configurations + + +def test_build_mutable_config_using_old_units() -> None: + """Test that MutableConfigs can build with old units.""" + old_units_config = { + "value": 5, + "default": 5.0, + "units": "mm/s", + "type": "float", + "min": 0.01, + "max": 30, + } + assert ( + types.MutableConfig.build(**old_units_config, name="dropTipSpeed") is not None # type: ignore + ) diff --git a/system-server/Makefile b/system-server/Makefile index 7de75cafc83..56130abf7a5 100644 --- a/system-server/Makefile +++ b/system-server/Makefile @@ -35,10 +35,10 @@ version_file = $(call python_get_git_version,system-server,$(project_ot3_default # specified test tests ?= tests test_opts ?= -# Host key location for buildroot robot -br_ssh_key ?= $(default_ssh_key) -# Other SSH args for buildroot robots -br_ssh_opts ?= $(default_ssh_opts) +# Host key location for robot +ssh_key ?= $(default_ssh_key) +# Other SSH args for robot +ssh_opts ?= $(default_ssh_opts) .PHONY: setup setup: @@ -91,14 +91,14 @@ sdist: clean .PHONY: push push: wheel - $(call push-python-package,$(host),$(br_ssh_key),$(ssh_opts),$(wheel_file)) - $(call push-systemd-unit,$(host),$(br_ssh_key),$(ssh_opts),./opentrons-system-server.service) - $(call restart-service,$(host),$(br_ssh_key),$(ssh_opts),opentrons-system-server) + $(call push-python-package,$(host),$(ssh_key),$(ssh_opts),$(wheel_file)) + $(call push-systemd-unit,$(host),$(ssh_key),$(ssh_opts),./opentrons-system-server.service) + $(call restart-service,$(host),$(ssh_key),$(ssh_opts),opentrons-system-server) .PHONY: push-ot3 push-ot3: sdist - $(call push-python-sdist,$(host),,$(br_ssh_opts),dist/$(sdist_file),/opt/opentrons-system-server,system_server,,,$(version_file)) - $(call restart-service,$(host),,$(br_ssh_opts),opentrons-system-server) + $(call push-python-sdist,$(host),$(ssh_key),$(ssh_opts),dist/$(sdist_file),/opt/opentrons-system-server,system_server,,,$(version_file)) + $(call restart-service,$(host),$(ssh_key),$(ssh_opts),opentrons-system-server) .PHONY: dev dev: export OT_SYSTEM_SERVER_DOT_ENV_PATH ?= dev.env diff --git a/system-server/system_server/persistence/database.py b/system-server/system_server/persistence/database.py index 28de145489f..2f74cd6da7e 100644 --- a/system-server/system_server/persistence/database.py +++ b/system-server/system_server/persistence/database.py @@ -2,6 +2,9 @@ from pathlib import Path import sqlalchemy + +from server_utils import sql_utils + from .tables import add_tables_to_db from .migrations import migrate @@ -23,35 +26,16 @@ def create_sql_engine(path: Path) -> sqlalchemy.engine.Engine: """Create a SQL engine with tables and migrations.""" - sql_engine = _open_db(db_file_path=path) + sql_engine = sqlalchemy.create_engine(sql_utils.get_connection_url(path)) try: + sql_utils.enable_foreign_key_constraints(sql_engine) + sql_utils.fix_transactions(sql_engine) add_tables_to_db(sql_engine) migrate(sql_engine) + except Exception: sql_engine.dispose() raise return sql_engine - - -def _open_db(db_file_path: Path) -> sqlalchemy.engine.Engine: - """Create a database engine for performing transactions.""" - engine = sqlalchemy.create_engine( - # sqlite:/// - # where is empty. - f"sqlite:///{db_file_path}", - ) - - # Enable foreign key support in sqlite - # https://docs.sqlalchemy.org/en/14/dialects/sqlite.html#foreign-key-support - @sqlalchemy.event.listens_for(engine, "connect") # type: ignore[misc] - def _set_sqlite_pragma( - dbapi_connection: sqlalchemy.engine.CursorResult, - connection_record: sqlalchemy.engine.CursorResult, - ) -> None: - cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA foreign_keys=ON;") - cursor.close() - - return engine diff --git a/update-server/Makefile b/update-server/Makefile index 0782d7a9fc2..24946ac09cd 100644 --- a/update-server/Makefile +++ b/update-server/Makefile @@ -21,10 +21,10 @@ wheel_file = $(call python_get_wheelname,update-server,$(project_rs_default),otu sdist_file = $(call python_get_sdistname,update-server,$(project_ot3_default),otupdate) # Find the branch, sha, version that will be used to update the VERSION.json file version_file = $(call python_get_git_version,update-server,$(project_ot3_default),update-server) -# Host key location for buildroot robot -br_ssh_key ?= $(default_ssh_key) -# Other SSH args for buildroot robots -br_ssh_opts ?= $(default_ssh_opts) +# Host key location for robot +ssh_key ?= $(default_ssh_key) +# Other SSH args for robot +ssh_opts ?= $(default_ssh_opts) .PHONY: setup @@ -93,12 +93,12 @@ restart: .PHONY: push push: wheel - $(call push-python-package,$(host),$(br_ssh_key),$(br_ssh_opts),dist/$(wheel_file)) - $(call push-systemd-unit,$(host),$(br_ssh_key),$(br_ssh_opts),./opentrons-update-server.service) - $(call restart-service,$(host),$(br_ssh_key),$(br_ssh_opts),opentrons-update-server) + $(call push-python-package,$(host),$(ssh_key),$(ssh_opts),dist/$(wheel_file)) + $(call push-systemd-unit,$(host),$(ssh_key),$(ssh_opts),./opentrons-update-server.service) + $(call restart-service,$(host),$(ssh_key),$(ssh_opts),opentrons-update-server) .PHONY: push-ot3 push-ot3: sdist - $(call push-python-sdist,$(host),,$(br_ssh_opts),dist/$(sdist_file),/opt/opentrons-update-server,"otupdate",,,$(version_file)) - $(call restart-service,$(host),,$(br_ssh_opts),opentrons-update-server) + $(call push-python-sdist,$(host),$(ssh_key),$(ssh_opts),dist/$(sdist_file),/opt/opentrons-update-server,"otupdate",,,$(version_file)) + $(call restart-service,$(host),$(ssh_key),$(ssh_opts),opentrons-update-server) diff --git a/update-server/otupdate/common/ssh_key_management.py b/update-server/otupdate/common/ssh_key_management.py index 64ad03210de..8129322b80d 100644 --- a/update-server/otupdate/common/ssh_key_management.py +++ b/update-server/otupdate/common/ssh_key_management.py @@ -7,20 +7,16 @@ import ipaddress import logging import os -from typing import ( - Any, - Generator, - IO, - List, - Tuple, -) - from aiohttp import web +from pathlib import Path +from typing import Any, Generator, IO, List, Tuple from .handler_type import Handler LOG = logging.getLogger(__name__) +SSH_DIR = Path(os.path.expanduser("~/.ssh")) +AUTHORIZED_KEYS = SSH_DIR / "authorized_keys" def require_linklocal(handler: Handler) -> Handler: @@ -68,7 +64,7 @@ def authorized_keys(mode: str = "r") -> Generator[IO[Any], None, None]: :param mode: As :py:meth:`open` """ - path = "/var/home/.ssh/authorized_keys" + path = os.path.expanduser("~/.ssh/authorized_keys") if not os.path.exists(path): os.makedirs(os.path.dirname(path)) open(path, "w").close() @@ -109,6 +105,12 @@ def key_present(hashval: str) -> bool: return hashval in [keyhash for keyhash, _ in get_keys()] +def key_error(error: str, message: str, status: int = 400) -> web.Response: + return web.json_response( # type: ignore[no-untyped-call,no-any-return] + data={"error": error, "message": message}, status=status + ) + + @require_linklocal async def list_keys(request: web.Request) -> web.Response: """List keys in the authorized_keys file. @@ -138,11 +140,6 @@ async def add(request: web.Request) -> web.Response: If the key string doesn't look like an openssh public key, rejects with 400 """ - def key_error(error: str, message: str) -> web.Response: - return web.json_response( # type: ignore[no-untyped-call,no-any-return] - data={"error": error, "message": message}, status=400 - ) - body = await request.json() if "key" not in body or not isinstance(body["key"], str): @@ -238,3 +235,48 @@ async def remove(request: web.Request) -> web.Response: }, status=200, ) + + +async def add_from_local(request: web.Request) -> web.Response: + """Add a public keys from usb device to the authorized_keys file. + + POST /server/ssh_keys/from_local + -> 201 Created + -> 404 Not Found otherwise + + """ + + LOG.info("Searching for public keys in /media") + pub_keys = [ + Path(root, file) + for root, _, files in os.walk("/media") + for file in files + if file.endswith(".pub") + ] + if not pub_keys: + LOG.warning("No keys found") + return key_error("no-key", "No valid keys found", 404) + + # Create the .ssh folder if it does not exist + if not os.path.exists(SSH_DIR): + os.mkdir(SSH_DIR, mode=0o700) + + # Update the existing keys if the ssh public key is valid + new_keys = list() + with open(AUTHORIZED_KEYS, "a") as fh: + for key in pub_keys: + with open(key, "r") as gh: + ssh_key = gh.read() + if "ssh-rsa" not in ssh_key: + LOG.warning(f"Invalid ssh public key: {key}") + continue + key_hash = hashlib.new("md5", ssh_key.encode()).hexdigest() + if not key_present(key_hash): + fh.write(f"{ssh_key}\n") + LOG.info(f"Added new rsa key: {key}") + new_keys.append(key_hash) + + return web.json_response( # type: ignore[no-untyped-call,no-any-return] + data={"message": f"Added {len(new_keys)} new keys", "key_md5": new_keys}, + status=201, + ) diff --git a/update-server/otupdate/openembedded/__init__.py b/update-server/otupdate/openembedded/__init__.py index bf74fee0468..5f081be63e4 100644 --- a/update-server/otupdate/openembedded/__init__.py +++ b/update-server/otupdate/openembedded/__init__.py @@ -80,6 +80,7 @@ async def get_app( web.post("/server/restart", control.restart), web.get("/server/ssh_keys", ssh_key_management.list_keys), web.post("/server/ssh_keys", ssh_key_management.add), + web.post("/server/ssh_keys/from_local", ssh_key_management.add_from_local), web.delete("/server/ssh_keys", ssh_key_management.clear), web.delete("/server/ssh_keys/{key_md5}", ssh_key_management.remove), web.post("/server/name", name_management.set_name_endpoint), diff --git a/update-server/otupdate/openembedded/update_actions.py b/update-server/otupdate/openembedded/update_actions.py index c2f34ceb21f..45dff930cdc 100644 --- a/update-server/otupdate/openembedded/update_actions.py +++ b/update-server/otupdate/openembedded/update_actions.py @@ -127,7 +127,7 @@ def write_update( # check that the uncompressed size is greater than the partition size partition_size = PartitionManager.get_partition_size(part.path) - if total_size >= partition_size: + if total_size > partition_size: msg = f"Write failed, update size ({total_size}) is larger than partition size {part.path} ({partition_size})." LOG.error(msg) return False, msg diff --git a/usb-bridge/Makefile b/usb-bridge/Makefile index d715389f3c0..930e10c78a5 100644 --- a/usb-bridge/Makefile +++ b/usb-bridge/Makefile @@ -26,10 +26,10 @@ sdist_file = $(call python_get_sdistname,usb-bridge,$(project_ot3_default),ot3us # Find the branch, sha, version that will be used to update the VERSION.json file version_file = $(call python_get_git_version,usb-bridge,$(project_ot3_default),ot3usb) -# Host key location for buildroot robot -br_ssh_key ?= $(default_ssh_key) -# Other SSH args for buildroot robots -br_ssh_opts ?= $(default_ssh_opts) +# Host key location for robot +ssh_key ?= $(default_ssh_key) +# Other SSH args for robot +ssh_opts ?= $(default_ssh_opts) .PHONY: setup setup: @@ -74,5 +74,5 @@ sdist: clean .PHONY: push-ot3 push-ot3: sdist - $(call push-python-sdist,$(host),,$(br_ssh_opts),dist/$(sdist_file),/opt/ot3usb,ot3usb,,,$(version_file)) - $(call restart-service,$(host),,$(br_ssh_opts),opentrons-usb-bridge) + $(call push-python-sdist,$(host),$(ssh_key),$(ssh_opts),dist/$(sdist_file),/opt/ot3usb,ot3usb,,,$(version_file)) + $(call restart-service,$(host),$(ssh_key),$(ssh_opts),opentrons-usb-bridge) diff --git a/usb-bridge/node-client/src/usb-agent.ts b/usb-bridge/node-client/src/usb-agent.ts index 2c95fb53278..7d6074cd336 100644 --- a/usb-bridge/node-client/src/usb-agent.ts +++ b/usb-bridge/node-client/src/usb-agent.ts @@ -2,7 +2,7 @@ import * as http from 'http' import agent from 'agent-base' import type { Duplex } from 'stream' -import { ReadlineParser, SerialPort } from 'serialport' +import { SerialPort } from 'serialport' import type { AgentOptions } from 'http' import type { Socket } from 'net' @@ -256,13 +256,6 @@ function installListeners( } s.on('timeout', onTimeout) - function onLineParserData(line: string): void { - agent.log('info', line) - } - // TODO(bh, 2023-05-05): determine delimiter for end of response body or use different parser - const parser = s.pipe(new ReadlineParser()) - parser.on('data', onLineParserData) - function onFinish(): void { agent.log('info', 'socket finishing: closing serialport') s.close() @@ -283,7 +276,6 @@ function installListeners( s.removeListener('close', onClose) s.removeListener('free', onFree) s.removeListener('timeout', onTimeout) - parser.removeListener('data', onLineParserData) s.removeListener('finish', onFinish) s.removeListener('agentRemove', onRemove) if (agent[kOnKeylog] != null) { diff --git a/usb-bridge/ot3usb/__main__.py b/usb-bridge/ot3usb/__main__.py index 9a709e0027b..a0ba62bbad2 100644 --- a/usb-bridge/ot3usb/__main__.py +++ b/usb-bridge/ot3usb/__main__.py @@ -4,7 +4,10 @@ import time from typing import NoReturn -from . import cli, usb_config, default_config, usb_monitor, tcp_conn, listener +from . import cli, usb_config, usb_monitor, tcp_conn, listener +from .default_config import get_gadget_config, PHY_NAME + +from .serial_thread import create_worker_thread LOG = logging.getLogger(__name__) @@ -20,7 +23,7 @@ async def main() -> NoReturn: LOG.info("Starting USB-TCP bridge") config = usb_config.SerialGadget( - driver=usb_config.OSDriver(), config=default_config.default_gadget + driver=usb_config.OSDriver(), config=get_gadget_config() ) try: @@ -43,7 +46,7 @@ async def main() -> NoReturn: ser = None monitor = usb_monitor.USBConnectionMonitorFactory.create( - phy_udev_name=default_config.PHY_NAME, udc_folder=config.udc_folder() + phy_udev_name=PHY_NAME, udc_folder=config.udc_folder() ) # Create a tcp connection that will be managed by `listen` @@ -54,12 +57,16 @@ async def main() -> NoReturn: monitor.begin() + thread, queue = create_worker_thread() + + thread.start() + if monitor.host_connected(): LOG.debug("USB connected on startup") ser = listener.update_ser_handle(config, ser, True, tcp) while True: - ser = listener.listen(monitor, config, ser, tcp) + ser = listener.listen(monitor, config, ser, tcp, queue) if __name__ == "__main__": diff --git a/usb-bridge/ot3usb/default_config.py b/usb-bridge/ot3usb/default_config.py index 07650a7faa7..e4ed63d9f04 100644 --- a/usb-bridge/ot3usb/default_config.py +++ b/usb-bridge/ot3usb/default_config.py @@ -5,23 +5,38 @@ DEFAULT_VID = "0x1b67" DEFAULT_PID = "0x4037" DEFAULT_BCDEVICE = "0x0010" -DEFAULT_SERIAL = "01121997" +DEFAULT_SERIAL = "FLX00000000000000" DEFAULT_MANUFACTURER = "Opentrons" -DEFAULT_PRODUCT = "OT3" +DEFAULT_PRODUCT = "Flex" DEFAULT_CONFIGURATION = "ACM Device" DEFAULT_MAX_POWER = 150 -default_gadget = SerialGadgetConfig( - name=DEFAULT_NAME, - vid=DEFAULT_VID, - pid=DEFAULT_PID, - bcdDevice=DEFAULT_BCDEVICE, - serial_number=DEFAULT_SERIAL, - manufacturer=DEFAULT_MANUFACTURER, - product_desc=DEFAULT_PRODUCT, - configuration_desc=DEFAULT_CONFIGURATION, - max_power=DEFAULT_MAX_POWER, -) +SERIAL_NUMBER_FILE = "/var/serial" + + +def _get_serial_number() -> str: + """Try to read the serial number from the filesystem.""" + try: + with open(SERIAL_NUMBER_FILE, "r") as serial_file: + return serial_file.read() + except OSError: + return DEFAULT_SERIAL + + +def get_gadget_config() -> SerialGadgetConfig: + """Get the default gadget configuration.""" + return SerialGadgetConfig( + name=DEFAULT_NAME, + vid=DEFAULT_VID, + pid=DEFAULT_PID, + bcdDevice=DEFAULT_BCDEVICE, + serial_number=_get_serial_number(), + manufacturer=DEFAULT_MANUFACTURER, + product_desc=DEFAULT_PRODUCT, + configuration_desc=DEFAULT_CONFIGURATION, + max_power=DEFAULT_MAX_POWER, + ) + # The name of the PHY in sysfs for the OT3 PHY_NAME = "usbphynop1" diff --git a/usb-bridge/ot3usb/listener.py b/usb-bridge/ot3usb/listener.py index 82062cc0a1d..7e8b18621ca 100644 --- a/usb-bridge/ot3usb/listener.py +++ b/usb-bridge/ot3usb/listener.py @@ -5,7 +5,10 @@ from typing import Optional, List, Any import serial # type: ignore[import] -from . import usb_config, default_config, usb_monitor, tcp_conn +from . import usb_config, usb_monitor, tcp_conn + +from .default_config import DEFAULT_IP, DEFAULT_PORT +from .serial_thread import QUEUE_TYPE, QUEUE_MAX_ITEMS LOG = logging.getLogger(__name__) @@ -39,7 +42,7 @@ def update_ser_handle( elif connected and not ser: LOG.debug("New USB host connected") ser = config.get_handle() - tcp.connect(default_config.DEFAULT_IP, default_config.DEFAULT_PORT) + tcp.connect(DEFAULT_IP, DEFAULT_PORT) return ser @@ -62,6 +65,7 @@ def listen( config: usb_config.SerialGadget, ser: Optional[serial.Serial], tcp: tcp_conn.TCPConnection, + worker_queue: QUEUE_TYPE, ) -> Optional[serial.Serial]: """Process any available incoming data. @@ -79,6 +83,8 @@ def listen( ser: Handle for the serial port tcp: Handle for the socket connection to the internal server + + worker_queue: Handle for queue to the serial worker thread """ rlist: List[Any] = [monitor] if ser is not None: @@ -105,6 +111,7 @@ def listen( if ser and tcp in ready: # Ready TCP data to echo to serial data = tcp.read() - if len(data) > 0: - ser.write(data) + worker_queue.put((ser, data)) + if worker_queue.qsize() >= QUEUE_MAX_ITEMS: + LOG.warning("Worker queue appears full") return ser diff --git a/usb-bridge/ot3usb/serial_thread.py b/usb-bridge/ot3usb/serial_thread.py new file mode 100644 index 00000000000..6913b3a61e1 --- /dev/null +++ b/usb-bridge/ot3usb/serial_thread.py @@ -0,0 +1,47 @@ +"""Worker thread to write serial data.""" +from typing import Tuple +from typing_extensions import TypeAlias +import serial # type: ignore[import] +import threading +from queue import Queue +import time + +QUEUE_WRITE_ITEM: TypeAlias = Tuple[serial.Serial, bytes] + +QUEUE_TYPE: TypeAlias = "Queue[QUEUE_WRITE_ITEM]" + +QUEUE_MAX_ITEMS = 100 + + +def _try_write_all_data(serial: serial.Serial, data: bytes) -> None: + sent = 0 + tries = 0 + if len(data) == 0: + return + while sent < len(data): + try: + sent += serial.write(data[sent:]) + except Exception as e: + # Any exception means we need to quit + print(f"Failed to write: {e}") + return + if sent < len(data): + tries += 1 + # Extremely short sleep to try to avoid battering the CPU + time.sleep(0.01) + + +def _worker(queue: QUEUE_TYPE) -> None: + while True: + ser, data = queue.get() + _try_write_all_data(ser, data) + + +def create_worker_thread() -> Tuple[threading.Thread, QUEUE_TYPE]: + """Create a serial worker thread. Returns the comms queue.""" + queue: QUEUE_TYPE = Queue(QUEUE_MAX_ITEMS) + thread = threading.Thread( + target=_worker, name="serial worker", kwargs={"queue": queue} + ) + + return (thread, queue) diff --git a/usb-bridge/ot3usb/usb_config.py b/usb-bridge/ot3usb/usb_config.py index b9bd2f01534..0ea109ecff5 100644 --- a/usb-bridge/ot3usb/usb_config.py +++ b/usb-bridge/ot3usb/usb_config.py @@ -219,5 +219,6 @@ def get_handle(self) -> serial.Serial: path = self._get_handle_path() ser = serial.Serial(port=path) # To support select() + ser.write_timeout = 0 ser.nonblocking() return ser diff --git a/usb-bridge/tests/test_listener.py b/usb-bridge/tests/test_listener.py index 67fb527583d..09575baf85d 100644 --- a/usb-bridge/tests/test_listener.py +++ b/usb-bridge/tests/test_listener.py @@ -5,8 +5,10 @@ import select import serial # type: ignore[import] +from queue import Queue from ot3usb import usb_config, tcp_conn, usb_monitor, listener +from ot3usb.serial_thread import QUEUE_TYPE FAKE_HANDLE = "Handle Placeholder" @@ -27,6 +29,11 @@ def serial_mock() -> mock.MagicMock: return mock.MagicMock(serial.Serial) +@pytest.fixture +def worker_queue() -> QUEUE_TYPE: + return Queue(0) + + def test_update_ser_handle() -> None: config = config_mock() tcp = tcp_mock() @@ -75,13 +82,14 @@ def test_check_monitor() -> None: TCP_DATA = b"efgh" -def test_listen(monkeypatch: pytest.MonkeyPatch) -> None: +def test_listen(monkeypatch: pytest.MonkeyPatch, worker_queue: QUEUE_TYPE) -> None: monitor = monitor_mock() config = config_mock() tcp = tcp_mock() ser = serial_mock() ser.read_all.return_value = SER_DATA + ser.write.return_value = len(TCP_DATA) tcp.read.return_value = TCP_DATA tcp.connected.return_value = False @@ -94,7 +102,7 @@ def test_listen(monkeypatch: pytest.MonkeyPatch) -> None: # No message ready, monitor disconnected monitor.host_connected.return_value = False - assert listener.listen(monitor, config, None, tcp) is None + assert listener.listen(monitor, config, None, tcp, worker_queue) is None monitor.update_state.assert_called_once() select_mock.assert_called_with([monitor], [], [], TIMEOUT) select_mock.reset_mock() @@ -103,7 +111,7 @@ def test_listen(monkeypatch: pytest.MonkeyPatch) -> None: # Monitor has a message and is connected monitor.host_connected.return_value = True select_mock.return_value = ([monitor], None, None) - assert listener.listen(monitor, config, None, tcp) is not None + assert listener.listen(monitor, config, None, tcp, worker_queue) is not None # Monitor should be manually updated monitor.update_state.assert_not_called() select_mock.assert_called_with([monitor], [], [], TIMEOUT) @@ -116,7 +124,7 @@ def test_listen(monkeypatch: pytest.MonkeyPatch) -> None: select_mock.return_value = ([], None, None) tcp.connected.return_value = True monitor.host_connected.return_value = True - assert listener.listen(monitor, config, ser, tcp) == ser + assert listener.listen(monitor, config, ser, tcp, worker_queue) == ser select_mock.assert_called_with([monitor, ser, tcp], [], [], TIMEOUT) select_mock.reset_mock() monitor.reset_mock() @@ -125,12 +133,13 @@ def test_listen(monkeypatch: pytest.MonkeyPatch) -> None: select_mock.return_value = ([ser, tcp], None, None) tcp.connected.return_value = True monitor.host_connected.return_value = True - assert listener.listen(monitor, config, ser, tcp) == ser + assert listener.listen(monitor, config, ser, tcp, worker_queue) == ser select_mock.assert_called_with([monitor, ser, tcp], [], [], TIMEOUT) ser.read_all.assert_called_once() tcp.send.assert_called_with(SER_DATA) tcp.read.assert_called_once() - ser.write.assert_called_with(TCP_DATA) + assert not worker_queue.empty() + assert worker_queue.get() == (ser, TCP_DATA) ser.reset_mock() select_mock.reset_mock() diff --git a/usb-bridge/tests/test_usb_config.py b/usb-bridge/tests/test_usb_config.py index 2e17ed4e092..238df66a183 100644 --- a/usb-bridge/tests/test_usb_config.py +++ b/usb-bridge/tests/test_usb_config.py @@ -22,7 +22,7 @@ def os_driver() -> mock.Mock: @pytest.fixture def subject(os_driver: mock.Mock) -> usb_config.SerialGadget: return usb_config.SerialGadget( - driver=os_driver, config=default_config.default_gadget + driver=os_driver, config=default_config.get_gadget_config() ) @@ -200,3 +200,21 @@ def test_get_udc_folder(subject: usb_config.SerialGadget) -> None: subject._udc_name = "fake_name" expected = usb_config.UDC_HANDLE_FOLDER + "fake_name" assert subject.udc_folder() == expected + + +def test_get_gadget_serial_number(tmpdir: Path) -> None: + """Test that the gadget serial number is read.""" + serial = Path(tmpdir) / "serial" + TEST_SERIAL = "fake_serial_number_123" + serial.write_text(TEST_SERIAL) + default_config.SERIAL_NUMBER_FILE = str(serial.absolute()) + + assert default_config.get_gadget_config().serial_number == TEST_SERIAL + + # Make sure the default serial number is used if there's no serial file + default_config.SERIAL_NUMBER_FILE = "/fake/path/that/does/not/exist.txt" + + assert ( + default_config.get_gadget_config().serial_number + == default_config.DEFAULT_SERIAL + ) diff --git a/yarn.lock b/yarn.lock index a6454d15de0..5de2106ae93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2197,6 +2197,7 @@ history "4.7.2" i18next "^19.8.3" is-ip "3.1.0" + jszip "3.2.2" lodash "4.17.21" mixpanel-browser "2.22.1" netmask "2.0.2" @@ -8621,15 +8622,6 @@ electron-devtools-installer@3.2.0: tslib "^2.1.0" unzip-crx-3 "^0.2.0" -electron-dl@1.14.0: - version "1.14.0" - resolved "https://registry.yarnpkg.com/electron-dl/-/electron-dl-1.14.0.tgz#1466f1b945664ca3d784268307c2b935728177bf" - integrity sha512-4okyei42a1mLsvLK7hLrIfd20EQzB18nIlLTwBV992aMSmTGLUEFRTmO1MfSslGNrzD8nuPuy1l/VxO8so4lig== - dependencies: - ext-name "^5.0.0" - pupa "^1.0.0" - unused-filename "^1.0.0" - electron-dl@^3.2.1: version "3.3.1" resolved "https://registry.yarnpkg.com/electron-dl/-/electron-dl-3.3.1.tgz#14164595bebcc636c671eb791b2a3265003f76c4" @@ -17145,11 +17137,6 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -pupa@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/pupa/-/pupa-1.0.0.tgz#9a9568a5af7e657b8462a6e9d5328743560ceff6" - integrity sha512-WTQm0CKSL1kn+DQCuu970eBPGmhIcfDyDBa9cbgR/grZ2jLrQmLDHoqqAPWLTRlOHFUrBBmL7FQJBZALA+llQg== - pupa@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.1.1.tgz#f5e8fd4afc2c5d97828faa523549ed8744a20d62" @@ -21172,14 +21159,6 @@ untildify@^4.0.0: resolved "https://registry.yarnpkg.com/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b" integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw== -unused-filename@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unused-filename/-/unused-filename-1.0.0.tgz#d340880f71ae2115ebaa1325bef05cc6684469c6" - integrity sha512-CzxEtvTNfsydlKb30IeExGVcRAQv9CLgzoYmnQskceQTV/EZY4jTOrtUcUBlWnAfZdi1UmX1JO0hMKQTDcwCVw== - dependencies: - modify-filename "^1.1.0" - path-exists "^3.0.0" - unused-filename@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/unused-filename/-/unused-filename-2.1.0.tgz#33719c4e8d9644f32d2dec1bc8525c6aaeb4ba51"