From 0a3f1a801ffc483c63e1c2680b69050598652b0b Mon Sep 17 00:00:00 2001 From: Nick Diehl <47604184+ncdiehl11@users.noreply.github.com> Date: Wed, 9 Oct 2024 16:01:27 -0400 Subject: [PATCH 1/4] chore(shared-data): create liquid class schema v1 and fixture (#16267) # Overview This PR introduces the first version of an Opentrons liquid class schema. Single liquid class definitions will adhere to this schema and be keyed by pipette and tip type. Closes AUTH-832 ## Test Plan and Hands on Testing - Look through schema and ensure it adheres to our finalized liquid class testing matrix. Any feedback on schema structure/style is appreciated. - Try out some test data in a JSON validator. I created a base fixture that passes schema validation. We can modify that for testing. ## Changelog - create liquid class schema v1 - add fixture ## Review requests See test plan. Should we leave keys for `patternProperties` pipette and tip type less restrictive strings? Perhaps any `safeString` rather than following more stringent regex? ## Risk assessment low --- .../fixtures/fixture_glycerol50.json | 241 +++++++++ shared-data/liquid-class/schemas/1.json | 480 ++++++++++++++++++ 2 files changed, 721 insertions(+) create mode 100644 shared-data/liquid-class/fixtures/fixture_glycerol50.json create mode 100644 shared-data/liquid-class/schemas/1.json diff --git a/shared-data/liquid-class/fixtures/fixture_glycerol50.json b/shared-data/liquid-class/fixtures/fixture_glycerol50.json new file mode 100644 index 00000000000..fd655c66a61 --- /dev/null +++ b/shared-data/liquid-class/fixtures/fixture_glycerol50.json @@ -0,0 +1,241 @@ +{ + "liquidName": "Glycerol 50%", + "schemaVersion": 1, + "namespace": "opentrons", + "byPipette": [ + { + "pipetteModel": "p20_single_gen2", + "byTipType": [ + { + "tipType": "p20_tip", + "aspirate": { + "submerge": { + "positionReference": "liquid-meniscus", + "offset": { + "x": 0, + "y": 0, + "z": -5 + }, + "speed": 100, + "delay": { + "enable": true, + "params": { + "duration": 1.5 + } + } + }, + "retract": { + "positionReference": "well-top", + "offset": { + "x": 0, + "y": 0, + "z": 5 + }, + "speed": 100, + "airGapByVolume": { + "default": 2, + "5": 3, + "10": 4 + }, + "touchTip": { + "enable": true, + "params": { + "zOffset": 2, + "mmToEdge": 1, + "speed": 50 + } + }, + "delay": { + "enable": true, + "params": { + "duration": 1 + } + } + }, + "positionReference": "well-bottom", + "offset": { + "x": 0, + "y": 0, + "z": -5 + }, + "flowRateByVolume": { + "default": 50, + "10": 40, + "20": 30 + }, + "preWet": true, + "mix": { + "enable": true, + "params": { + "repetitions": 3, + "volume": 15 + } + }, + "delay": { + "enable": true, + "params": { + "duration": 2 + } + } + }, + "singleDispense": { + "submerge": { + "positionReference": "liquid-meniscus", + "offset": { + "x": 0, + "y": 0, + "z": -5 + }, + "speed": 100, + "delay": { + "enable": true, + "params": { + "duration": 1.5 + } + } + }, + "retract": { + "positionReference": "well-top", + "offset": { + "x": 0, + "y": 0, + "z": 5 + }, + "speed": 100, + "airGapByVolume": { + "default": 2, + "5": 3, + "10": 4 + }, + "blowout": { + "enable": true, + "params": { + "location": "trash", + "flowRate": 100 + } + }, + "touchTip": { + "enable": true, + "params": { + "zOffset": 2, + "mmToEdge": 1, + "speed": 50 + } + }, + "delay": { + "enable": true, + "params": { + "duration": 1 + } + } + }, + "positionReference": "well-bottom", + "offset": { + "x": 0, + "y": 0, + "z": -5 + }, + "flowRateByVolume": { + "default": 50, + "10": 40, + "20": 30 + }, + "mix": { + "enable": true, + "params": { + "repetitions": 3, + "volume": 15 + } + }, + "pushOutByVolume": { + "default": 5, + "10": 7, + "20": 10 + }, + "delay": 1 + }, + "multiDispense": { + "submerge": { + "positionReference": "liquid-meniscus", + "offset": { + "x": 0, + "y": 0, + "z": -5 + }, + "speed": 100, + "delay": { + "enable": true, + "params": { + "duration": 1.5 + } + } + }, + "retract": { + "positionReference": "well-top", + "offset": { + "x": 0, + "y": 0, + "z": 5 + }, + "speed": 100, + "airGapByVolume": { + "default": 2, + "5": 3, + "10": 4 + }, + "touchTip": { + "enable": true, + "params": { + "zOffset": 2, + "mmToEdge": 1, + "speed": 50 + } + }, + "delay": { + "enable": true, + "params": { + "duration": 1 + } + }, + "blowout": { + "enable": false + } + }, + "positionReference": "well-bottom", + "offset": { + "x": 0, + "y": 0, + "z": -5 + }, + "flowRateByVolume": { + "default": 50, + "10": 40, + "20": 30 + }, + "mix": { + "enable": true, + "params": { + "repetitions": 3, + "volume": 15 + } + }, + "conditioningByVolume": { + "default": 10, + "5": 5 + }, + "disposalByVolume": { + "default": 2, + "5": 3 + }, + "delay": { + "enable": true, + "params": { + "duration": 1 + } + } + } + } + ] + } + ] +} diff --git a/shared-data/liquid-class/schemas/1.json b/shared-data/liquid-class/schemas/1.json new file mode 100644 index 00000000000..6af0ff9babe --- /dev/null +++ b/shared-data/liquid-class/schemas/1.json @@ -0,0 +1,480 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "opentronsLiquidClassSchemaV1", + "title": "Liquid Class Schema", + "description": "Schema for defining a single liquid class's properties for liquid handling functions.", + "type": "object", + "definitions": { + "positiveNumber": { + "type": "number", + "minimum": 0 + }, + "safeString": { + "description": "A string safe to use for namespace. Lowercase-only.", + "type": "string", + "pattern": "^[a-z0-9._]+$" + }, + "delay": { + "type": "object", + "properties": { + "enable": { + "type": "boolean", + "description": "Whether delay is enabled." + }, + "params": { + "type": "object", + "properties": { + "duration": { + "#ref": "#/definitions/positiveNumber", + "description": "Duration of delay, in seconds." + } + }, + "required": ["duration"], + "additionalProperties": false + } + }, + "required": ["enable"], + "additionalProperties": false + }, + "positionReference": { + "type": "string", + "description": "Reference point for positioning.", + "enum": ["well-bottom", "well-top", "well-center", "liquid-meniscus"] + }, + "coordinate": { + "type": "object", + "description": "3-dimensional coordinate.", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + }, + "required": ["x", "y", "z"], + "additionalProperties": false + }, + "touchTip": { + "type": "object", + "description": "Shared properties for the touch-tip function.", + "properties": { + "enable": { + "type": "boolean", + "description": "Whether touch-tip is enabled." + }, + "params": { + "type": "object", + "properties": { + "zOffset": { + "type": "number", + "description": "Offset from the top of the well for touch-tip, in millimeters." + }, + "mmToEdge": { + "type": "number", + "description": "Offset away from the the well edge, in millimeters." + }, + "speed": { + "$ref": "#/definitions/positiveNumber", + "description": "Touch-tip speed, in millimeters per second." + } + }, + "required": ["zOffset", "mmToEdge", "speed"], + "additionalProperties": false + } + }, + "required": ["enable"], + "additionalProperties": false + }, + "airGapByVolume": { + "type": "object", + "description": "Settings for air gap keyed by target aspiration volume.", + "properties": { + "default": { "$ref": "#/definitions/positiveNumber" } + }, + "patternProperties": { + "d+": { "$ref": "#/definitions/positiveNumber" } + }, + "required": ["default"] + }, + "flowRateByVolume": { + "type": "object", + "description": "Settings for flow rate keyed by target aspiration/dispense volume.", + "properties": { + "default": { "$ref": "#/definitions/positiveNumber" } + }, + "patternProperties": { + "d+": { "$ref": "#/definitions/positiveNumber" } + }, + "required": ["default"] + }, + "pushOutByVolume": { + "type": "object", + "description": "Settings for pushout keyed by target aspiration volume.", + "properties": { + "default": { "$ref": "#/definitions/positiveNumber" } + }, + "patternProperties": { + "d+": { "$ref": "#/definitions/positiveNumber" } + }, + "required": ["default"] + }, + "disposalByVolume": { + "type": "object", + "description": "Settings for disposal volume keyed by target dispense volume.", + "properties": { + "default": { "$ref": "#/definitions/positiveNumber" } + }, + "patternProperties": { + "d+": { "$ref": "#/definitions/positiveNumber" } + }, + "required": ["default"] + }, + "conditioningByVolume": { + "type": "object", + "description": "Settings for conditioning volume keyed by target dispense volume.", + "properties": { + "default": { "$ref": "#/definitions/positiveNumber" } + }, + "patternProperties": { + "d+": { "$ref": "#/definitions/positiveNumber" } + }, + "required": ["default"] + }, + "mix": { + "type": "object", + "description": "Mixing properties.", + "properties": { + "enable": { + "type": "boolean", + "description": "Whether mix is enabled." + }, + "params": { + "type": "object", + "properties": { + "repetitions": { + "type": "integer", + "description": "Number of mixing repetitions.", + "minimum": 0 + }, + "volume": { + "$ref": "#/definitions/positiveNumber", + "description": "Volume used for mixing, in microliters." + } + }, + "required": ["repetitions", "volume"], + "additionalProperties": false + } + }, + "required": ["enable"], + "additionalProperties": false + }, + "blowout": { + "type": "object", + "description": "Blowout properties.", + "properties": { + "enable": { + "type": "boolean", + "description": "Whether blow-out is enabled." + }, + "params": { + "type": "object", + "properties": { + "location": { + "type": "string", + "enum": ["source", "destination", "trash"], + "description": "Location well or trash entity for blow out." + }, + "flowRate": { + "$ref": "#/definitions/positiveNumber", + "description": "Flow rate for blow out, in microliters per second." + } + }, + "required": ["location", "flowRate"] + } + }, + "required": ["enable"], + "additionalProperties": false + }, + "submerge": { + "type": "object", + "description": "Shared properties for the submerge function before aspiration or dispense.", + "properties": { + "positionReference": { + "$ref": "#/definitions/positionReference" + }, + "offset": { + "$ref": "#/definitions/coordinate" + }, + "speed": { + "$ref": "#/definitions/positiveNumber", + "description": "Speed of submerging, in millimeters per second." + }, + "delay": { + "$ref": "#/definitions/delay" + } + }, + "required": ["positionReference", "offset", "speed", "delay"], + "additionalProperties": false + }, + "retractAspirate": { + "type": "object", + "description": "Shared properties for the retract function after aspiration or dispense.", + "properties": { + "positionReference": { + "$ref": "#/definitions/positionReference" + }, + "offset": { + "$ref": "#/definitions/coordinate" + }, + "speed": { + "$ref": "#/definitions/positiveNumber", + "description": "Speed of retraction, in millimeters per second." + }, + "airGapByVolume": { + "$ref": "#/definitions/airGapByVolume" + }, + "touchTip": { + "$ref": "#/definitions/touchTip" + }, + "delay": { + "$ref": "#/definitions/delay" + } + }, + "required": [ + "positionReference", + "offset", + "speed", + "airGapByVolume", + "delay" + ], + "additionalProperties": false + }, + "retractDispense": { + "type": "object", + "description": "Shared properties for the retract function after aspiration or dispense.", + "properties": { + "positionReference": { + "$ref": "#/definitions/positionReference" + }, + "offset": { + "$ref": "#/definitions/coordinate" + }, + "speed": { + "$ref": "#/definitions/positiveNumber", + "description": "Speed of retraction, in millimeters per second." + }, + "airGapByVolume": { + "$ref": "#/definitions/airGapByVolume" + }, + "blowout": { + "$ref": "#/definitions/blowout" + }, + "touchTip": { + "$ref": "#/definitions/touchTip" + }, + "delay": { + "$ref": "#/definitions/delay" + } + }, + "required": [ + "positionReference", + "offset", + "speed", + "airGapByVolume", + "blowout", + "touchTip", + "delay" + ], + "additionalProperties": false + }, + "aspirateParams": { + "type": "object", + "description": "Parameters specific to the aspirate function.", + "properties": { + "submerge": { + "$ref": "#/definitions/submerge" + }, + "retract": { + "$ref": "#/definitions/retractAspirate" + }, + "positionReference": { + "$ref": "#/definitions/positionReference" + }, + "offset": { + "$ref": "#/definitions/coordinate" + }, + "flowRateByVolume": { + "$ref": "#/definitions/flowRateByVolume" + }, + "preWet": { + "type": "boolean", + "description": "Whether to perform a pre-wet action." + }, + "mix": { + "$ref": "#/definitions/mix" + }, + "delay": { + "$ref": "#/definitions/delay" + } + }, + "required": [ + "submerge", + "retract", + "positionReference", + "offset", + "flowRateByVolume", + "preWet", + "mix", + "delay" + ], + "additionalProperties": false + }, + "singleDispenseParams": { + "type": "object", + "description": "Parameters specific to the single-dispense function.", + "properties": { + "submerge": { + "$ref": "#/definitions/submerge" + }, + "retract": { + "$ref": "#/definitions/retractDispense" + }, + "positionReference": { + "$ref": "#/definitions/positionReference" + }, + "offset": { + "$ref": "#/definitions/coordinate" + }, + "flowRateByVolume": { + "$ref": "#/definitions/flowRateByVolume" + }, + "mix": { + "$ref": "#/definitions/mix" + }, + "pushOutByVolume": { + "$ref": "#/definitions/pushOutByVolume" + }, + "delay": { + "$ref": "#/definitions/positiveNumber", + "description": "Delay after dispense, in seconds." + } + }, + "required": [ + "submerge", + "retract", + "positionReference", + "offset", + "flowRateByVolume", + "mix", + "pushOutByVolume", + "delay" + ], + "additionalProperties": false + }, + "multiDispenseParams": { + "type": "object", + "description": "Parameters specific to the multi-dispense function.", + "properties": { + "submerge": { + "$ref": "#/definitions/submerge" + }, + "retract": { + "$ref": "#/definitions/retractDispense" + }, + "positionReference": { + "$ref": "#/definitions/positionReference" + }, + "offset": { + "$ref": "#/definitions/coordinate" + }, + "flowRateByVolume": { + "$ref": "#/definitions/flowRateByVolume" + }, + "mix": { + "$ref": "#/definitions/mix" + }, + "conditioningByVolume": { + "$ref": "#/definitions/conditioningByVolume" + }, + "disposalByVolume": { + "$ref": "#/definitions/disposalByVolume" + }, + "delay": { + "$ref": "#/definitions/delay" + } + }, + "required": [ + "submerge", + "retract", + "positionReference", + "offset", + "flowRateByVolume", + "mix", + "conditioningByVolume", + "disposalByVolume", + "delay" + ], + "additionalProperties": false + } + }, + "properties": { + "liquidName": { + "type": "string", + "description": "The name of the liquid (e.g., water, ethanol, serum)." + }, + "schemaVersion": { + "description": "Which schema version a liquid class is using", + "type": "number", + "enum": [1] + }, + "namespace": { + "$ref": "#/definitions/safeString" + }, + "byPipette": { + "type": "array", + "description": "Liquid class settings by each pipette compatible with this liquid class.", + "items": { + "type": "object", + "description": "The settings for a specific kind of pipette when interacting with this liquid class", + "properties": { + "pipetteModel": { + "type": "string", + "description": "The pipette model this applies to" + }, + "byTipType": { + "type": "array", + "description": "Settings for each kind of tip this pipette can use", + "items": { + "type": "object", + "properties": { + "tipType": { + "type": "string", + "description": "The tip type whose properties will be used when handling this specific liquid class with this pipette" + }, + "aspirate": { + "$ref": "#/definitions/aspirateParams" + }, + "singleDispense": { + "$ref": "#/definitions/singleDispenseParams" + }, + "multiDispense": { + "$ref": "#/definitions/multiDispenseParams" + } + }, + "required": ["tipType", "aspirate", "singleDispense"], + "additionalProperties": false + } + } + }, + "required": ["pipetteModel", "byTipType"], + "additionalProperties": false + } + } + }, + "required": ["liquidName", "schemaVersion", "namespace", "byPipette"], + "additionalProperties": false +} From 601229742624494f82639dfa2903b035fec8490d Mon Sep 17 00:00:00 2001 From: Max Marrone Date: Thu, 10 Oct 2024 10:33:42 -0400 Subject: [PATCH 2/4] refactor(api): Remove redundant tip length state (#16450) `PipetteState` and `TipState` were both storing the length of the pipette's currently-attached tip. I think that duplication was accidental. The `TipState` one was only used in one place, so this deletes that in favor of the `PipetteState` one. --- .../protocol_engine/commands/pick_up_tip.py | 8 +- .../protocol_engine/execution/gantry_mover.py | 4 +- .../opentrons/protocol_engine/state/tips.py | 10 +-- .../execution/test_gantry_mover.py | 15 +++- .../protocol_engine/state/test_tip_state.py | 87 ------------------- 5 files changed, 18 insertions(+), 106 deletions(-) diff --git a/api/src/opentrons/protocol_engine/commands/pick_up_tip.py b/api/src/opentrons/protocol_engine/commands/pick_up_tip.py index 86d64d3034e..465ede2f86f 100644 --- a/api/src/opentrons/protocol_engine/commands/pick_up_tip.py +++ b/api/src/opentrons/protocol_engine/commands/pick_up_tip.py @@ -9,7 +9,7 @@ from ..errors import ErrorOccurrence, TipNotAttachedError from ..resources import ModelUtils from ..state import update_types -from ..types import DeckPoint, TipGeometry +from ..types import DeckPoint from .pipetting_common import ( PipetteIdMixin, WellLocationMixin, @@ -132,11 +132,7 @@ async def execute( ) state_update.update_tip_state( pipette_id=pipette_id, - tip_geometry=TipGeometry( - volume=tip_geometry.volume, - length=tip_geometry.length, - diameter=tip_geometry.diameter, - ), + tip_geometry=tip_geometry, ) except TipNotAttachedError as e: return DefinedErrorData( diff --git a/api/src/opentrons/protocol_engine/execution/gantry_mover.py b/api/src/opentrons/protocol_engine/execution/gantry_mover.py index 98a3d19b8d5..8b33e43f437 100644 --- a/api/src/opentrons/protocol_engine/execution/gantry_mover.py +++ b/api/src/opentrons/protocol_engine/execution/gantry_mover.py @@ -273,7 +273,9 @@ def get_max_travel_z(self, pipette_id: str) -> float: ) else: instrument_height = VIRTUAL_MAX_OT3_HEIGHT - tip_length = self._state_view.tips.get_tip_length(pipette_id) + + tip = self._state_view.pipettes.get_attached_tip(pipette_id=pipette_id) + tip_length = tip.length if tip is not None else 0 return instrument_height - tip_length async def move_to( diff --git a/api/src/opentrons/protocol_engine/state/tips.py b/api/src/opentrons/protocol_engine/state/tips.py index 7b50b291f4d..1e14843114c 100644 --- a/api/src/opentrons/protocol_engine/state/tips.py +++ b/api/src/opentrons/protocol_engine/state/tips.py @@ -44,8 +44,8 @@ class TipState: tips_by_labware_id: Dict[str, TipRackStateByWellName] column_by_labware_id: Dict[str, List[List[str]]] + channels_by_pipette_id: Dict[str, int] - length_by_pipette_id: Dict[str, float] active_channels_by_pipette_id: Dict[str, int] nozzle_map_by_pipette_id: Dict[str, NozzleMap] @@ -61,7 +61,6 @@ def __init__(self) -> None: tips_by_labware_id={}, column_by_labware_id={}, channels_by_pipette_id={}, - length_by_pipette_id={}, active_channels_by_pipette_id={}, nozzle_map_by_pipette_id={}, ) @@ -121,18 +120,15 @@ def _handle_succeeded_command(self, command: Command) -> None: labware_id = command.params.labwareId well_name = command.params.wellName pipette_id = command.params.pipetteId - length = command.result.tipLength self._set_used_tips( pipette_id=pipette_id, well_name=well_name, labware_id=labware_id ) - self._state.length_by_pipette_id[pipette_id] = length elif isinstance( command.result, (DropTipResult, DropTipInPlaceResult, unsafe.UnsafeDropTipInPlaceResult), ): pipette_id = command.params.pipetteId - self._state.length_by_pipette_id.pop(pipette_id, None) def _handle_failed_command( self, @@ -506,10 +502,6 @@ def has_clean_tip(self, labware_id: str, well_name: str) -> bool: return well_state == TipRackWellState.CLEAN - def get_tip_length(self, pipette_id: str) -> float: - """Return the given pipette's tip length.""" - return self._state.length_by_pipette_id.get(pipette_id, 0) - def _drop_wells_before_starting_tip( wells: TipRackStateByWellName, starting_tip_name: str diff --git a/api/tests/opentrons/protocol_engine/execution/test_gantry_mover.py b/api/tests/opentrons/protocol_engine/execution/test_gantry_mover.py index 6f6d3274532..b9dbd798ff2 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_gantry_mover.py +++ b/api/tests/opentrons/protocol_engine/execution/test_gantry_mover.py @@ -17,7 +17,12 @@ from opentrons.protocol_engine.state.state import StateView from opentrons.protocol_engine.state.motion import PipetteLocationData -from opentrons.protocol_engine.types import MotorAxis, DeckPoint, CurrentWell +from opentrons.protocol_engine.types import ( + MotorAxis, + DeckPoint, + CurrentWell, + TipGeometry, +) from opentrons.protocol_engine.errors import MustHomeError, InvalidAxisForRobotType from opentrons.protocol_engine.execution.gantry_mover import ( @@ -499,7 +504,9 @@ def test_virtual_get_max_travel_z_ot2( decoy.when( mock_state_view.pipettes.get_instrument_max_height_ot2("pipette-id") ).then_return(42) - decoy.when(mock_state_view.tips.get_tip_length("pipette-id")).then_return(20) + decoy.when(mock_state_view.pipettes.get_attached_tip("pipette-id")).then_return( + TipGeometry(length=20, diameter=0, volume=0) + ) result = virtual_subject.get_max_travel_z("pipette-id") @@ -513,7 +520,9 @@ def test_virtual_get_max_travel_z_ot3( ) -> None: """It should get the max travel z height with the state store.""" decoy.when(mock_state_view.config.robot_type).then_return("OT-3 Standard") - decoy.when(mock_state_view.tips.get_tip_length("pipette-id")).then_return(48) + decoy.when(mock_state_view.pipettes.get_attached_tip("pipette-id")).then_return( + TipGeometry(length=48, diameter=0, volume=0) + ) result = virtual_subject.get_max_travel_z("pipette-id") diff --git a/api/tests/opentrons/protocol_engine/state/test_tip_state.py b/api/tests/opentrons/protocol_engine/state/test_tip_state.py index da570c940cd..bdc5cc639f4 100644 --- a/api/tests/opentrons/protocol_engine/state/test_tip_state.py +++ b/api/tests/opentrons/protocol_engine/state/test_tip_state.py @@ -908,93 +908,6 @@ def test_has_tip_tip_rack( assert result is True -def test_drop_tip( - subject: TipStore, - load_labware_command: commands.LoadLabware, - pick_up_tip_command: commands.PickUpTip, - drop_tip_command: commands.DropTip, - drop_tip_in_place_command: commands.DropTipInPlace, - unsafe_drop_tip_in_place_command: commands.unsafe.UnsafeDropTipInPlace, - supported_tip_fixture: pipette_definition.SupportedTipsDefinition, -) -> None: - """It should be clear tip length when a tip is dropped.""" - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=load_labware_command) - ) - load_pipette_command = commands.LoadPipette.construct( # type: ignore[call-arg] - result=commands.LoadPipetteResult(pipetteId="pipette-id") - ) - load_pipette_private_result = commands.LoadPipettePrivateResult( - pipette_id="pipette-id", - serial_number="pipette-serial", - config=LoadedStaticPipetteData( - channels=8, - max_volume=15, - min_volume=3, - model="gen a", - display_name="display name", - flow_rates=FlowRates( - default_aspirate={}, - default_dispense={}, - default_blow_out={}, - ), - tip_configuration_lookup_table={15: supported_tip_fixture}, - nominal_tip_overlap={}, - nozzle_offset_z=1.23, - home_position=4.56, - nozzle_map=get_default_nozzle_map(PipetteNameType.P300_SINGLE_GEN2), - back_left_corner_offset=Point(x=1, y=2, z=3), - front_right_corner_offset=Point(x=4, y=5, z=6), - pipette_lld_settings={}, - ), - ) - subject.handle_action( - actions.SucceedCommandAction( - private_result=load_pipette_private_result, command=load_pipette_command - ) - ) - - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=pick_up_tip_command) - ) - result = TipView(subject.state).get_tip_length("pipette-id") - assert result == 1.23 - - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=drop_tip_command) - ) - result = TipView(subject.state).get_tip_length("pipette-id") - assert result == 0 - - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=pick_up_tip_command) - ) - result = TipView(subject.state).get_tip_length("pipette-id") - assert result == 1.23 - - subject.handle_action( - actions.SucceedCommandAction( - private_result=None, command=drop_tip_in_place_command - ) - ) - result = TipView(subject.state).get_tip_length("pipette-id") - assert result == 0 - - subject.handle_action( - actions.SucceedCommandAction(private_result=None, command=pick_up_tip_command) - ) - result = TipView(subject.state).get_tip_length("pipette-id") - assert result == 1.23 - - subject.handle_action( - actions.SucceedCommandAction( - private_result=None, command=unsafe_drop_tip_in_place_command - ) - ) - result = TipView(subject.state).get_tip_length("pipette-id") - assert result == 0 - - @pytest.mark.parametrize( argnames=["nozzle_map", "expected_channels"], argvalues=[ From eeb8972ab63e5c53df8f03b4193cc0748f1043b9 Mon Sep 17 00:00:00 2001 From: CaseyBatten Date: Thu, 10 Oct 2024 11:07:25 -0400 Subject: [PATCH 3/4] feat(api, shared-data): Add support for labware lids and publish tc lid seal labware (#16345) Covers PLAT-356, PLAT-264, PLAT-540 Publicizes "opentrons_tough_pcr_auto_sealing_lid" labware. Implements multilabware stacks, new labware allowedRole "lid" for labware, alongside "lidOffsets" subcategory of the gripper offsets. --- .../execution/labware_movement.py | 2 + .../resources/labware_validation.py | 5 + .../protocol_engine/state/geometry.py | 83 ++++++- .../protocol_engine/state/labware.py | 66 +++++- .../test_labware_movement_handler.py | 10 +- .../state/test_geometry_view.py | 31 +++ .../state/test_labware_view.py | 93 +++++++- .../LabwarePositionCheck/useLaunchLPC.tsx | 15 +- .../protocols/tc_auto_seal_lid/__init__.py | 1 + .../tc_auto_seal_lid/tc_lid_evap_test.py | 208 ++++++++++++++++++ .../tc_auto_seal_lid/tc_lid_movement.py | 73 ++++++ .../js/__tests__/labwareDefQuirks.test.ts | 1 + .../1.json | 93 ++++++++ shared-data/labware/schemas/2.json | 2 +- shared-data/labware/schemas/3.json | 2 +- .../labware/labware_definition.py | 1 + .../opentrons_shared_data/labware/types.py | 1 + 17 files changed, 664 insertions(+), 23 deletions(-) create mode 100644 hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/__init__.py create mode 100644 hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/tc_lid_evap_test.py create mode 100644 hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/tc_lid_movement.py create mode 100644 shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/1.json diff --git a/api/src/opentrons/protocol_engine/execution/labware_movement.py b/api/src/opentrons/protocol_engine/execution/labware_movement.py index 30feb6517ff..8ede6f6085b 100644 --- a/api/src/opentrons/protocol_engine/execution/labware_movement.py +++ b/api/src/opentrons/protocol_engine/execution/labware_movement.py @@ -126,6 +126,7 @@ async def move_labware_with_gripper( current_location=current_location, ) + current_labware = self._state_store.labware.get_definition(labware_id) async with self._thermocycler_plate_lifter.lift_plate_for_labware_movement( labware_location=current_location ): @@ -134,6 +135,7 @@ async def move_labware_with_gripper( from_location=current_location, to_location=new_location, additional_offset_vector=user_offset_data, + current_labware=current_labware, ) ) from_labware_center = self._state_store.geometry.get_labware_grip_point( diff --git a/api/src/opentrons/protocol_engine/resources/labware_validation.py b/api/src/opentrons/protocol_engine/resources/labware_validation.py index 3b4ed14166c..090723ffb7e 100644 --- a/api/src/opentrons/protocol_engine/resources/labware_validation.py +++ b/api/src/opentrons/protocol_engine/resources/labware_validation.py @@ -27,6 +27,11 @@ def validate_definition_is_adapter(definition: LabwareDefinition) -> bool: return LabwareRole.adapter in definition.allowedRoles +def validate_definition_is_lid(definition: LabwareDefinition) -> bool: + """Validate that one of the definition's allowed roles is `lid`.""" + return LabwareRole.lid in definition.allowedRoles + + def validate_labware_can_be_stacked( top_labware_definition: LabwareDefinition, below_labware_load_name: str ) -> bool: diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 502f0d4d8eb..e37a460d226 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -12,6 +12,7 @@ from opentrons_shared_data.deck.types import CutoutFixture from opentrons_shared_data.pipette import PIPETTE_X_SPAN from opentrons_shared_data.pipette.types import ChannelCount +from opentrons.protocols.models import LabwareDefinition from .. import errors from ..errors import ( @@ -20,7 +21,7 @@ LabwareMovementNotAllowedError, InvalidWellDefinitionError, ) -from ..resources import fixture_validation +from ..resources import fixture_validation, labware_validation from ..types import ( OFF_DECK_LOCATION, LoadedLabware, @@ -46,6 +47,7 @@ AddressableOffsetVector, StagingSlotLocation, LabwareOffsetLocation, + ModuleModel, ) from .config import Config from .labware import LabwareView @@ -997,17 +999,22 @@ def get_final_labware_movement_offset_vectors( from_location: OnDeckLabwareLocation, to_location: OnDeckLabwareLocation, additional_offset_vector: LabwareMovementOffsetData, + current_labware: LabwareDefinition, ) -> LabwareMovementOffsetData: """Calculate the final labware offset vector to use in labware movement.""" pick_up_offset = ( self.get_total_nominal_gripper_offset_for_move_type( - location=from_location, move_type=_GripperMoveType.PICK_UP_LABWARE + location=from_location, + move_type=_GripperMoveType.PICK_UP_LABWARE, + current_labware=current_labware, ) + additional_offset_vector.pickUpOffset ) drop_offset = ( self.get_total_nominal_gripper_offset_for_move_type( - location=to_location, move_type=_GripperMoveType.DROP_LABWARE + location=to_location, + move_type=_GripperMoveType.DROP_LABWARE, + current_labware=current_labware, ) + additional_offset_vector.dropOffset ) @@ -1038,7 +1045,10 @@ def ensure_valid_gripper_location( return location def get_total_nominal_gripper_offset_for_move_type( - self, location: OnDeckLabwareLocation, move_type: _GripperMoveType + self, + location: OnDeckLabwareLocation, + move_type: _GripperMoveType, + current_labware: LabwareDefinition, ) -> LabwareOffsetVector: """Get the total of the offsets to be used to pick up labware in its current location.""" if move_type == _GripperMoveType.PICK_UP_LABWARE: @@ -1054,14 +1064,39 @@ def get_total_nominal_gripper_offset_for_move_type( location ) ancestor = self._labware.get_parent_location(location.labwareId) + extra_offset = LabwareOffsetVector(x=0, y=0, z=0) + if ( + isinstance(ancestor, ModuleLocation) + and self._modules._state.requested_model_by_id[ancestor.moduleId] + == ModuleModel.THERMOCYCLER_MODULE_V2 + and labware_validation.validate_definition_is_lid(current_labware) + ): + if "lidOffsets" in current_labware.gripperOffsets.keys(): + extra_offset = LabwareOffsetVector( + x=current_labware.gripperOffsets[ + "lidOffsets" + ].pickUpOffset.x, + y=current_labware.gripperOffsets[ + "lidOffsets" + ].pickUpOffset.y, + z=current_labware.gripperOffsets[ + "lidOffsets" + ].pickUpOffset.z, + ) + else: + raise errors.LabwareOffsetDoesNotExistError( + f"Labware Definition {current_labware.parameters.loadName} does not contain required field 'lidOffsets' of 'gripperOffsets'." + ) + assert isinstance( - ancestor, (DeckSlotLocation, ModuleLocation) + ancestor, (DeckSlotLocation, ModuleLocation, OnLabwareLocation) ), "No gripper offsets for off-deck labware" return ( direct_parent_offset.pickUpOffset + self._nominal_gripper_offsets_for_location( location=ancestor ).pickUpOffset + + extra_offset ) else: if isinstance( @@ -1076,14 +1111,39 @@ def get_total_nominal_gripper_offset_for_move_type( location ) ancestor = self._labware.get_parent_location(location.labwareId) + extra_offset = LabwareOffsetVector(x=0, y=0, z=0) + if ( + isinstance(ancestor, ModuleLocation) + and self._modules._state.requested_model_by_id[ancestor.moduleId] + == ModuleModel.THERMOCYCLER_MODULE_V2 + and labware_validation.validate_definition_is_lid(current_labware) + ): + if "lidOffsets" in current_labware.gripperOffsets.keys(): + extra_offset = LabwareOffsetVector( + x=current_labware.gripperOffsets[ + "lidOffsets" + ].pickUpOffset.x, + y=current_labware.gripperOffsets[ + "lidOffsets" + ].pickUpOffset.y, + z=current_labware.gripperOffsets[ + "lidOffsets" + ].pickUpOffset.z, + ) + else: + raise errors.LabwareOffsetDoesNotExistError( + f"Labware Definition {current_labware.parameters.loadName} does not contain required field 'lidOffsets' of 'gripperOffsets'." + ) + assert isinstance( - ancestor, (DeckSlotLocation, ModuleLocation) + ancestor, (DeckSlotLocation, ModuleLocation, OnLabwareLocation) ), "No gripper offsets for off-deck labware" return ( direct_parent_offset.dropOffset + self._nominal_gripper_offsets_for_location( location=ancestor ).dropOffset + + extra_offset ) def check_gripper_labware_tip_collision( @@ -1147,11 +1207,20 @@ def _labware_gripper_offsets( """ parent_location = self._labware.get_parent_location(labware_id) assert isinstance( - parent_location, (DeckSlotLocation, ModuleLocation) + parent_location, + ( + DeckSlotLocation, + ModuleLocation, + AddressableAreaLocation, + ), ), "No gripper offsets for off-deck labware" if isinstance(parent_location, DeckSlotLocation): slot_name = parent_location.slotName + elif isinstance(parent_location, AddressableAreaLocation): + slot_name = self._addressable_areas.get_addressable_area_base_slot( + parent_location.addressableAreaName + ) else: module_loc = self._modules.get_location(parent_location.moduleId) slot_name = module_loc.slotName diff --git a/api/src/opentrons/protocol_engine/state/labware.py b/api/src/opentrons/protocol_engine/state/labware.py index 0db6b310e1e..78f2124bdb4 100644 --- a/api/src/opentrons/protocol_engine/state/labware.py +++ b/api/src/opentrons/protocol_engine/state/labware.py @@ -405,6 +405,16 @@ def get_parent_location(self, labware_id: str) -> NonStackedLocation: return self.get_parent_location(parent.labwareId) return parent + def get_labware_stack( + self, labware_stack: List[LoadedLabware] + ) -> List[LoadedLabware]: + """Get the a stack of labware starting from a given labware or existing stack.""" + parent = self.get_location(labware_stack[-1].id) + if isinstance(parent, OnLabwareLocation): + labware_stack.append(self.get(parent.labwareId)) + return self.get_labware_stack(labware_stack) + return labware_stack + def get_all(self) -> List[LoadedLabware]: """Get a list of all labware entries in state.""" return list(self._state.labware_by_id.values()) @@ -429,6 +439,27 @@ def get_should_center_column_on_target_well(self, labware_id: str) -> bool: and len(self.get_definition(labware_id).wells) < 96 ) + def get_labware_stacking_maximum(self, labware: LabwareDefinition) -> int: + """Returns the maximum number of labware allowed in a stack for a given labware definition. + + If not defined within a labware, defaults to one. + """ + stacking_quirks = { + "stackingMaxFive": 5, + "stackingMaxFour": 4, + "stackingMaxThree": 3, + "stackingMaxTwo": 2, + "stackingMaxOne": 1, + "stackingMaxZero": 0, + } + for quirk in stacking_quirks.keys(): + if ( + labware.parameters.quirks is not None + and quirk in labware.parameters.quirks + ): + return stacking_quirks[quirk] + return 1 + def get_should_center_pipette_on_target_well(self, labware_id: str) -> bool: """True if a pipette moving to a well of this labware should center its body on the target. @@ -596,9 +627,14 @@ def get_labware_overlap_offsets( ) -> OverlapOffset: """Get the labware's overlap with requested labware's load name.""" definition = self.get_definition(labware_id) - stacking_overlap = definition.stackingOffsetWithLabware.get( - below_labware_name, OverlapOffset(x=0, y=0, z=0) - ) + if below_labware_name in definition.stackingOffsetWithLabware.keys(): + stacking_overlap = definition.stackingOffsetWithLabware.get( + below_labware_name, OverlapOffset(x=0, y=0, z=0) + ) + else: + stacking_overlap = definition.stackingOffsetWithLabware.get( + "default", OverlapOffset(x=0, y=0, z=0) + ) return OverlapOffset( x=stacking_overlap.x, y=stacking_overlap.y, z=stacking_overlap.z ) @@ -767,7 +803,7 @@ def raise_if_labware_in_location( f"Labware {labware.loadName} is already present at {location}." ) - def raise_if_labware_cannot_be_stacked( + def raise_if_labware_cannot_be_stacked( # noqa: C901 self, top_labware_definition: LabwareDefinition, bottom_labware_id: str ) -> None: """Raise if the specified labware definition cannot be placed on top of the bottom labware.""" @@ -786,17 +822,37 @@ def raise_if_labware_cannot_be_stacked( ) elif isinstance(below_labware.location, ModuleLocation): below_definition = self.get_definition(labware_id=below_labware.id) - if not labware_validation.validate_definition_is_adapter(below_definition): + if not labware_validation.validate_definition_is_adapter( + below_definition + ) and not labware_validation.validate_definition_is_lid( + top_labware_definition + ): raise errors.LabwareCannotBeStackedError( f"Labware {top_labware_definition.parameters.loadName} cannot be loaded" f" onto a labware on top of a module" ) elif isinstance(below_labware.location, OnLabwareLocation): + labware_stack = self.get_labware_stack([below_labware]) + stack_without_adapters = [] + for lw in labware_stack: + if not labware_validation.validate_definition_is_adapter( + self.get_definition(lw.id) + ): + stack_without_adapters.append(lw) + if len(stack_without_adapters) >= self.get_labware_stacking_maximum( + top_labware_definition + ): + raise errors.LabwareCannotBeStackedError( + f"Labware {top_labware_definition.parameters.loadName} cannot be loaded to stack of more than {self.get_labware_stacking_maximum(top_labware_definition)} labware." + ) + further_below_definition = self.get_definition( labware_id=below_labware.location.labwareId ) if labware_validation.validate_definition_is_adapter( further_below_definition + ) and not labware_validation.validate_definition_is_lid( + top_labware_definition ): raise errors.LabwareCannotBeStackedError( f"Labware {top_labware_definition.parameters.loadName} cannot be loaded" 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 c434995ee52..6032bad81b8 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 @@ -202,11 +202,16 @@ async def test_raise_error_if_gripper_pickup_failed( ) ).then_return(mock_tc_context_manager) + current_labware = state_store.labware.get_definition( + labware_id="my-teleporting-labware" + ) + decoy.when( state_store.geometry.get_final_labware_movement_offset_vectors( from_location=starting_location, to_location=to_location, additional_offset_vector=user_offset_data, + current_labware=current_labware, ) ).then_return(final_offset_data) @@ -316,12 +321,15 @@ async def test_move_labware_with_gripper( await set_up_decoy_hardware_gripper(decoy, ot3_hardware_api, state_store) user_offset_data, final_offset_data = hardware_gripper_offset_data - + current_labware = state_store.labware.get_definition( + labware_id="my-teleporting-labware" + ) decoy.when( state_store.geometry.get_final_labware_movement_offset_vectors( from_location=from_location, to_location=to_location, additional_offset_vector=user_offset_data, + current_labware=current_labware, ) ).then_return(final_offset_data) 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 427dececa7b..3f824da7193 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -2233,6 +2233,7 @@ def test_get_final_labware_movement_offset_vectors( mock_module_view: ModuleView, mock_labware_view: LabwareView, subject: GeometryView, + well_plate_def: LabwareDefinition, ) -> None: """It should provide the final labware movement offset data based on locations.""" decoy.when(mock_labware_view.get_deck_default_gripper_offsets()).then_return( @@ -2248,6 +2249,10 @@ def test_get_final_labware_movement_offset_vectors( ) ) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + well_plate_def + ) + final_offsets = subject.get_final_labware_movement_offset_vectors( from_location=DeckSlotLocation(slotName=DeckSlotName("D2")), to_location=ModuleLocation(moduleId="module-id"), @@ -2255,6 +2260,7 @@ def test_get_final_labware_movement_offset_vectors( pickUpOffset=LabwareOffsetVector(x=100, y=200, z=300), dropOffset=LabwareOffsetVector(x=400, y=500, z=600), ), + current_labware=mock_labware_view.get_definition("labware-id"), ) assert final_offsets == LabwareMovementOffsetData( pickUpOffset=LabwareOffsetVector(x=101, y=202, z=303), @@ -2285,6 +2291,7 @@ def test_get_total_nominal_gripper_offset( mock_labware_view: LabwareView, mock_module_view: ModuleView, subject: GeometryView, + well_plate_def: LabwareDefinition, ) -> None: """It should calculate the correct gripper offsets given the location and move type..""" decoy.when(mock_labware_view.get_deck_default_gripper_offsets()).then_return( @@ -2301,10 +2308,15 @@ def test_get_total_nominal_gripper_offset( ) ) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + well_plate_def + ) + # Case 1: labware on deck result1 = subject.get_total_nominal_gripper_offset_for_move_type( location=DeckSlotLocation(slotName=DeckSlotName.SLOT_3), move_type=_GripperMoveType.PICK_UP_LABWARE, + current_labware=mock_labware_view.get_definition("labware-d"), ) assert result1 == LabwareOffsetVector(x=1, y=2, z=3) @@ -2312,6 +2324,7 @@ def test_get_total_nominal_gripper_offset( result2 = subject.get_total_nominal_gripper_offset_for_move_type( location=ModuleLocation(moduleId="module-id"), move_type=_GripperMoveType.DROP_LABWARE, + current_labware=mock_labware_view.get_definition("labware-id"), ) assert result2 == LabwareOffsetVector(x=33, y=22, z=11) @@ -2321,6 +2334,7 @@ def test_get_stacked_labware_total_nominal_offset_slot_specific( mock_labware_view: LabwareView, mock_module_view: ModuleView, subject: GeometryView, + well_plate_def: LabwareDefinition, ) -> None: """Get nominal offset for stacked labware.""" # Case: labware on adapter on module, adapter has slot-specific offsets @@ -2346,15 +2360,23 @@ def test_get_stacked_labware_total_nominal_offset_slot_specific( decoy.when(mock_labware_view.get_parent_location("adapter-id")).then_return( ModuleLocation(moduleId="module-id") ) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + well_plate_def + ) + decoy.when(mock_module_view._state.requested_model_by_id).then_return( + {"module-id": ModuleModel.HEATER_SHAKER_MODULE_V1} + ) result1 = subject.get_total_nominal_gripper_offset_for_move_type( location=OnLabwareLocation(labwareId="adapter-id"), move_type=_GripperMoveType.PICK_UP_LABWARE, + current_labware=mock_labware_view.get_definition("labware-id"), ) assert result1 == LabwareOffsetVector(x=111, y=222, z=333) result2 = subject.get_total_nominal_gripper_offset_for_move_type( location=OnLabwareLocation(labwareId="adapter-id"), move_type=_GripperMoveType.DROP_LABWARE, + current_labware=mock_labware_view.get_definition("labware-id"), ) assert result2 == LabwareOffsetVector(x=333, y=222, z=111) @@ -2364,6 +2386,7 @@ def test_get_stacked_labware_total_nominal_offset_default( mock_labware_view: LabwareView, mock_module_view: ModuleView, subject: GeometryView, + well_plate_def: LabwareDefinition, ) -> None: """Get nominal offset for stacked labware.""" # Case: labware on adapter on module, adapter has only default offsets @@ -2394,15 +2417,23 @@ def test_get_stacked_labware_total_nominal_offset_default( decoy.when(mock_labware_view.get_parent_location("adapter-id")).then_return( ModuleLocation(moduleId="module-id") ) + decoy.when(mock_labware_view.get_definition("labware-id")).then_return( + well_plate_def + ) + decoy.when(mock_module_view._state.requested_model_by_id).then_return( + {"module-id": ModuleModel.HEATER_SHAKER_MODULE_V1} + ) result1 = subject.get_total_nominal_gripper_offset_for_move_type( location=OnLabwareLocation(labwareId="adapter-id"), move_type=_GripperMoveType.PICK_UP_LABWARE, + current_labware=mock_labware_view.get_definition("labware-id"), ) assert result1 == LabwareOffsetVector(x=111, y=222, z=333) result2 = subject.get_total_nominal_gripper_offset_for_move_type( location=OnLabwareLocation(labwareId="adapter-id"), move_type=_GripperMoveType.DROP_LABWARE, + current_labware=mock_labware_view.get_definition("labware-id"), ) assert result2 == LabwareOffsetVector(x=333, y=222, z=111) 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 ab2f49cfb29..d461ddda4e6 100644 --- a/api/tests/opentrons/protocol_engine/state/test_labware_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_labware_view.py @@ -1386,13 +1386,18 @@ def test_raise_if_labware_cannot_be_stacked_on_labware_on_adapter() -> None: ), }, definitions_by_uri={ + "def-uri-1": LabwareDefinition.construct( # type: ignore[call-arg] + allowedRoles=[LabwareRole.labware] + ), "def-uri-2": LabwareDefinition.construct( # type: ignore[call-arg] allowedRoles=[LabwareRole.adapter] - ) + ), }, ) - with pytest.raises(errors.LabwareCannotBeStackedError, match="on top of adapter"): + with pytest.raises( + errors.LabwareCannotBeStackedError, match="cannot be loaded to stack" + ): subject.raise_if_labware_cannot_be_stacked( top_labware_definition=LabwareDefinition.construct( # type: ignore[call-arg] parameters=Parameters.construct( # type: ignore[call-arg] @@ -1406,6 +1411,90 @@ def test_raise_if_labware_cannot_be_stacked_on_labware_on_adapter() -> None: ) +@pytest.mark.parametrize( + argnames=[ + "allowed_roles", + "stacking_quirks", + "exception", + ], + argvalues=[ + [ + [LabwareRole.labware], + [], + pytest.raises(errors.LabwareCannotBeStackedError), + ], + [ + [LabwareRole.lid], + ["stackingMaxFive"], + does_not_raise(), + ], + ], +) +def test_labware_stacking_height_passes_or_raises( + allowed_roles: List[LabwareRole], + stacking_quirks: List[str], + exception: ContextManager[None], +) -> None: + """It should raise if the labware is stacked too high, and pass if the labware definition allowed this.""" + subject = get_labware_view( + labware_by_id={ + "labware-id4": LoadedLabware( + id="labware-id4", + loadName="test", + definitionUri="def-uri-1", + location=OnLabwareLocation(labwareId="labware-id3"), + ), + "labware-id3": LoadedLabware( + id="labware-id3", + loadName="test", + definitionUri="def-uri-1", + location=OnLabwareLocation(labwareId="labware-id2"), + ), + "labware-id2": LoadedLabware( + id="labware-id2", + loadName="test", + definitionUri="def-uri-1", + location=OnLabwareLocation(labwareId="labware-id1"), + ), + "labware-id1": LoadedLabware( + id="labware-id1", + loadName="test", + definitionUri="def-uri-1", + location=DeckSlotLocation(slotName=DeckSlotName.SLOT_1), + ), + }, + definitions_by_uri={ + "def-uri-1": LabwareDefinition.construct( # type: ignore[call-arg] + allowedRoles=allowed_roles, + parameters=Parameters.construct( + format="irregular", + quirks=stacking_quirks, + isTiprack=False, + loadName="name", + isMagneticModuleCompatible=False, + ), + ) + }, + ) + + with exception: + subject.raise_if_labware_cannot_be_stacked( + top_labware_definition=LabwareDefinition.construct( # type: ignore[call-arg] + parameters=Parameters.construct( + format="irregular", + quirks=stacking_quirks, + isTiprack=False, + loadName="name", + isMagneticModuleCompatible=False, + ), + stackingOffsetWithLabware={ + "test": SharedDataOverlapOffset(x=0, y=0, z=0) + }, + ), + bottom_labware_id="labware-id4", + ) + + def test_get_deck_gripper_offsets(ot3_standard_deck_def: DeckDefinitionV5) -> None: """It should get the deck's gripper offsets.""" subject = get_labware_view(deck_definition=ot3_standard_deck_def) diff --git a/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx b/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx index c3262af8225..0ad5ea06a50 100644 --- a/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx +++ b/app/src/organisms/LabwarePositionCheck/useLaunchLPC.tsx @@ -15,6 +15,8 @@ import { getLabwareDefinitionsFromCommands } from '/app/molecules/Command/utils/ import type { RobotType } from '@opentrons/shared-data' +const filteredLabware = ['opentrons_tough_pcr_auto_sealing_lid'] + export function useLaunchLPC( runId: string, robotType: RobotType, @@ -61,12 +63,13 @@ export function useLaunchLPC( Promise.all( getLabwareDefinitionsFromCommands( mostRecentAnalysis?.commands ?? [] - ).map(def => - createLabwareDefinition({ - maintenanceRunId: maintenanceRun?.data?.id, - labwareDef: def, - }) - ) + ).map(def => { + if (!filteredLabware.includes(def.parameters.loadName)) + createLabwareDefinition({ + maintenanceRunId: maintenanceRun?.data?.id, + labwareDef: def, + }) + }) ).then(() => { setMaintenanceRunId(maintenanceRun.data.id) }) diff --git a/hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/__init__.py b/hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/__init__.py new file mode 100644 index 00000000000..848fb967ae2 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/__init__.py @@ -0,0 +1 @@ +"""Tough Auto Sealing Lid Tests.""" diff --git a/hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/tc_lid_evap_test.py b/hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/tc_lid_evap_test.py new file mode 100644 index 00000000000..5a02624f08f --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/tc_lid_evap_test.py @@ -0,0 +1,208 @@ +"""Protocol to Test Evaporation % of the Tough Auto Seal Lid.""" +from typing import List +from opentrons.hardware_control.modules.types import ThermocyclerStep +from opentrons.protocol_api import ( + ParameterContext, + ProtocolContext, + Labware, + InstrumentContext, + Well, +) +from opentrons.protocol_api.module_contexts import ThermocyclerContext +from opentrons.protocol_api.disposal_locations import WasteChute + +metadata = {"protocolName": "Tough Auto Seal Lid Evaporation Test"} +requirements = {"robotType": "Flex", "apiLevel": "2.20"} + + +def _long_hold_test(thermocycler: ThermocyclerContext, tc_lid_temp: float) -> None: + """Holds TC lid in Thermocycler for 5 min at high temp before evap test.""" + thermocycler.set_block_temperature(4, hold_time_minutes=5) + thermocycler.set_lid_temperature(tc_lid_temp) + thermocycler.set_block_temperature(98, hold_time_minutes=5) + thermocycler.set_block_temperature(4, hold_time_minutes=5) + thermocycler.open_lid() + + +def _fill_with_liquid_and_measure( + protocol: ProtocolContext, + pipette: InstrumentContext, + reservoir: Labware, + plate_in_cycler: Labware, +) -> None: + """Fill plate with 10 ul per well.""" + locations: List[Well] = [ + plate_in_cycler["A1"], + plate_in_cycler["A2"], + plate_in_cycler["A3"], + plate_in_cycler["A4"], + plate_in_cycler["A5"], + plate_in_cycler["A6"], + plate_in_cycler["A7"], + plate_in_cycler["A8"], + plate_in_cycler["A9"], + plate_in_cycler["A10"], + plate_in_cycler["A11"], + plate_in_cycler["A12"], + ] + volumes = [10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10] + protocol.pause("Weight Armadillo Plate, place on thermocycler") + # pipette 10uL into Armadillo wells + source_well: Well = reservoir["A1"] + pipette.distribute( + volume=volumes, + source=source_well, + dest=locations, + return_tips=True, + blow_out=False, + ) + protocol.pause("Weight Armadillo Plate, place on thermocycler, put on lid") + + +def _pcr_cycle(thermocycler: ThermocyclerContext) -> None: + """30x cycles of: 70° for 30s 72° for 30s 95° for 10s.""" + profile_TAG2: List[ThermocyclerStep] = [ + {"temperature": 70, "hold_time_seconds": 30}, + {"temperature": 72, "hold_time_seconds": 30}, + {"temperature": 95, "hold_time_seconds": 10}, + ] + thermocycler.execute_profile( + steps=profile_TAG2, repetitions=30, block_max_volume=50 + ) + + +def _move_lid( + thermocycler: ThermocyclerContext, + protocol: ProtocolContext, + top_lid: Labware, + bottom_lid: Labware, + wasteChute: WasteChute, +) -> None: + """Move lid from tc to deck.""" + # Move lid from thermocycler to deck to stack to waste chute + thermocycler.open_lid() + # Move Lid to Deck + protocol.move_labware(top_lid, "B2", use_gripper=True) + # Move Lid to Stack + protocol.move_labware(top_lid, bottom_lid, use_gripper=True) + # Move Lid to Waste Chute + protocol.move_labware(top_lid, wasteChute, use_gripper=True) + + +def add_parameters(parameters: ParameterContext) -> None: + """Add parameters.""" + parameters.add_str( + variable_name="mount_pos", + display_name="Mount Position", + description="What mount to use", + choices=[ + {"display_name": "left_mount", "value": "left"}, + {"display_name": "right_mount", "value": "right"}, + ], + default="left", + ) + parameters.add_str( + variable_name="pipette_type", + display_name="Pipette Type", + description="What pipette to use", + choices=[ + {"display_name": "8ch 50 uL", "value": "flex_8channel_50"}, + {"display_name": "8ch 1000 uL", "value": "flex_8channel_1000"}, + ], + default="flex_8channel_50", + ) + parameters.add_float( + variable_name="tc_lid_temp", + display_name="TC Lid Temp", + description="Max temp of TC Lid", + default=105, + choices=[ + {"display_name": "105", "value": 105}, + {"display_name": "107", "value": 107}, + {"display_name": "110", "value": 110}, + ], + ) + parameters.add_str( + variable_name="test_type", + display_name="Test Type", + description="Type of test to run", + default="evap_test", + choices=[ + {"display_name": "Evaporation Test", "value": "evap_test"}, + {"display_name": "Long Hold Test", "value": "long_hold_test"}, + ], + ) + + +def run(protocol: ProtocolContext) -> None: + """Run protocol.""" + # LOAD PARAMETERS + pipette_type = protocol.params.pipette_type # type: ignore[attr-defined] + mount_position = protocol.params.mount_pos # type: ignore[attr-defined] + tc_lid_temp = protocol.params.tc_lid_temp # type: ignore[attr-defined] + test_type = protocol.params.test_type # type: ignore[attr-defined] + # SETUP + # Thermocycler + thermocycler: ThermocyclerContext = protocol.load_module( + "thermocyclerModuleV2" + ) # type: ignore[assignment] + + plate_in_cycler = thermocycler.load_labware( + "armadillo_96_wellplate_200ul_pcr_full_skirt" + ) + thermocycler.open_lid() + # Labware + tiprack_50_1 = protocol.load_labware("opentrons_flex_96_tiprack_50ul", "C3") + reservoir = protocol.load_labware("nest_12_reservoir_15ml", "A2") + lids: List[Labware] = [ + protocol.load_labware("opentrons_tough_pcr_auto_sealing_lid", "D2") + ] + for i in range(4): + lids.append(lids[-1].load_labware("opentrons_tough_pcr_auto_sealing_lid")) + lids.reverse() + top_lid = lids[0] + bottom_lid = lids[1] + # Pipette + pipette = protocol.load_instrument( + pipette_type, mount_position, tip_racks=[tiprack_50_1] + ) + # Waste Chute + wasteChute = protocol.load_waste_chute() + + # DEFINE TESTS # + thermocycler.set_block_temperature(4) + thermocycler.set_lid_temperature(105) + + # hold at 95° for 3 minutes + profile_TAG: List[ThermocyclerStep] = [{"temperature": 95, "hold_time_minutes": 3}] + # hold at 72° for 5min + profile_TAG3: List[ThermocyclerStep] = [{"temperature": 72, "hold_time_minutes": 5}] + + if test_type == "long_hold_test": + protocol.move_labware(top_lid, plate_in_cycler, use_gripper=True) + _long_hold_test(thermocycler, tc_lid_temp) + protocol.move_labware(top_lid, "B2", use_gripper=True) + _long_hold_test(thermocycler, tc_lid_temp) + _fill_with_liquid_and_measure(protocol, pipette, reservoir, plate_in_cycler) + thermocycler.close_lid() + _pcr_cycle(thermocycler) + + # Go through PCR cycle + if test_type == "evap_test": + _fill_with_liquid_and_measure(protocol, pipette, reservoir, plate_in_cycler) + protocol.move_labware(top_lid, plate_in_cycler, use_gripper=True) + thermocycler.close_lid() + thermocycler.execute_profile( + steps=profile_TAG, repetitions=1, block_max_volume=50 + ) + _pcr_cycle(thermocycler) + thermocycler.execute_profile( + steps=profile_TAG3, repetitions=1, block_max_volume=50 + ) + # # # Cool to 4° + thermocycler.set_block_temperature(4) + thermocycler.set_lid_temperature(tc_lid_temp) + # Open lid + thermocycler.open_lid() + _move_lid(thermocycler, protocol, top_lid, bottom_lid, wasteChute) + protocol.pause("Weigh armadillo plate.") diff --git a/hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/tc_lid_movement.py b/hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/tc_lid_movement.py new file mode 100644 index 00000000000..475c84e6516 --- /dev/null +++ b/hardware-testing/hardware_testing/protocols/tc_auto_seal_lid/tc_lid_movement.py @@ -0,0 +1,73 @@ +"""Protocol to Test the Stacking and Movement of Tough Auto Seal Lid.""" +from typing import List, Union +from opentrons.protocol_api import ( + ParameterContext, + ProtocolContext, + Labware, +) +from opentrons.protocol_api.module_contexts import ThermocyclerContext + + +metadata = {"protocolName": "Tough Auto Seal Lid Stacking Test"} +requirements = {"robotType": "Flex", "apiLevel": "2.20"} + + +def add_parameters(parameters: ParameterContext) -> None: + """Add parameters.""" + parameters.add_int( + variable_name="num_of_stack_ups", + display_name="Number of Stack Ups", + choices=[ + {"display_name": "1", "value": 1}, + {"display_name": "10", "value": 10}, + {"display_name": "20", "value": 20}, + {"display_name": "30", "value": 30}, + {"display_name": "40", "value": 40}, + ], + default=20, + ) + + +def run(protocol: ProtocolContext) -> None: + """Runs protocol that moves lids and stacks them.""" + # Load Parameters + iterations = protocol.params.num_of_stack_ups # type: ignore[attr-defined] + # Thermocycler + thermocycler: ThermocyclerContext = protocol.load_module( + "thermocyclerModuleV2" + ) # type: ignore[assignment] + plate_in_cycler = thermocycler.load_labware( + "armadillo_96_wellplate_200ul_pcr_full_skirt" + ) + thermocycler.open_lid() + + lids: List[Labware] = [ + protocol.load_labware("opentrons_tough_pcr_auto_sealing_lid", "D2") + ] + for i in range(4): + lids.append(lids[-1].load_labware("opentrons_tough_pcr_auto_sealing_lid")) + lids.reverse() + stack_locations = ["C2", "D2"] + slot = 0 + for iteration in range(iterations - 1): + protocol.comment(f"Stack up {iteration}") + locations_for_lid = ["D1", "C1", "C3", "B2", "B3"] + loc = 0 + for lid in lids: + # move lid to plate in thermocycler + protocol.move_labware(lid, plate_in_cycler, use_gripper=True) + # move lid to deck slot + location_to_move: Union[int, str] = locations_for_lid[loc] + protocol.move_labware(lid, location_to_move, use_gripper=True) + # move lid to lid stack + if loc == 0: + protocol.move_labware(lid, stack_locations[slot], use_gripper=True) + prev_moved_lid: Labware = lid + else: + protocol.move_labware(lid, prev_moved_lid, use_gripper=True) + prev_moved_lid = lid + loc += 1 + slot = (slot + 1) % 2 # Switch between 0 and 1 to rotate stack locations + + # reverse lid list to restart stacking exercise + lids.reverse() diff --git a/shared-data/js/__tests__/labwareDefQuirks.test.ts b/shared-data/js/__tests__/labwareDefQuirks.test.ts index 6ebc39f9f17..6251c894647 100644 --- a/shared-data/js/__tests__/labwareDefQuirks.test.ts +++ b/shared-data/js/__tests__/labwareDefQuirks.test.ts @@ -13,6 +13,7 @@ const EXPECTED_VALID_QUIRKS = [ 'fixedTrash', 'gripperIncompatible', 'tiprackAdapterFor96Channel', + 'stackingMaxFive', ] describe('check quirks for all labware defs', () => { diff --git a/shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/1.json b/shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/1.json new file mode 100644 index 00000000000..ca39c122b47 --- /dev/null +++ b/shared-data/labware/definitions/2/opentrons_tough_pcr_auto_sealing_lid/1.json @@ -0,0 +1,93 @@ +{ + "allowedRoles": ["labware", "lid"], + "ordering": [], + "brand": { + "brand": "Opentrons", + "brandId": [] + }, + "metadata": { + "displayName": "Opentrons Tough PCR Auto-Sealing Lid", + "displayCategory": "other", + "displayVolumeUnits": "\u00b5L", + "tags": [] + }, + "dimensions": { + "xDimension": 127.7, + "yDimension": 85.48, + "zDimension": 12.8 + }, + "wells": {}, + "groups": [], + "cornerOffsetFromSlot": { + "x": 0, + "y": 0, + "z": -0.71 + }, + "parameters": { + "format": "irregular", + "quirks": ["stackingMaxFive"], + "isTiprack": false, + "isMagneticModuleCompatible": false, + "loadName": "opentrons_tough_pcr_auto_sealing_lid" + }, + "namespace": "opentrons", + "version": 1, + "schemaVersion": 2, + "stackingOffsetWithModule": { + "thermocyclerModuleV2": { + "x": 0, + "y": 0, + "z": 0 + } + }, + "stackingOffsetWithLabware": { + "default": { + "x": 0, + "y": 0, + "z": 8.193 + }, + "opentrons_tough_pcr_auto_sealing_lid": { + "x": 0, + "y": 0, + "z": 6.492 + }, + "armadillo_96_wellplate_200ul_pcr_full_skirt": { + "x": 0, + "y": 0, + "z": 8.193 + }, + "opentrons_96_wellplate_200ul_pcr_full_skirt": { + "x": 0, + "y": 0, + "z": 8.193 + } + }, + "gripForce": 15, + "gripHeightFromLabwareBottom": 7.91, + "gripperOffsets": { + "default": { + "pickUpOffset": { + "x": 0, + "y": 0, + "z": 1.5 + }, + "dropOffset": { + "x": 0, + "y": 0.52, + "z": -6 + } + }, + "lidOffsets": { + "pickUpOffset": { + "x": 0.5, + "y": 0, + "z": -5 + }, + "dropOffset": { + "x": 0.5, + "y": 0, + "z": -1 + } + } + } +} diff --git a/shared-data/labware/schemas/2.json b/shared-data/labware/schemas/2.json index 01931d2c2a1..203009be9f5 100644 --- a/shared-data/labware/schemas/2.json +++ b/shared-data/labware/schemas/2.json @@ -323,7 +323,7 @@ "description": "Allowed behaviors and usage of a labware in a protocol.", "items": { "type": "string", - "enum": ["labware", "adapter", "fixture", "maintenance"] + "enum": ["labware", "adapter", "fixture", "maintenance", "lid"] } }, "stackingOffsetWithLabware": { diff --git a/shared-data/labware/schemas/3.json b/shared-data/labware/schemas/3.json index ecd285c554a..e38c070919a 100644 --- a/shared-data/labware/schemas/3.json +++ b/shared-data/labware/schemas/3.json @@ -519,7 +519,7 @@ "description": "Allowed behaviors and usage of a labware in a protocol.", "items": { "type": "string", - "enum": ["labware", "adapter", "fixture", "maintenance"] + "enum": ["labware", "adapter", "fixture", "maintenance", "lid"] } }, "stackingOffsetWithLabware": { diff --git a/shared-data/python/opentrons_shared_data/labware/labware_definition.py b/shared-data/python/opentrons_shared_data/labware/labware_definition.py index a818afc106a..be4c1a17d01 100644 --- a/shared-data/python/opentrons_shared_data/labware/labware_definition.py +++ b/shared-data/python/opentrons_shared_data/labware/labware_definition.py @@ -119,6 +119,7 @@ class LabwareRole(str, Enum): fixture = "fixture" adapter = "adapter" maintenance = "maintenance" + lid = "lid" class Metadata(BaseModel): diff --git a/shared-data/python/opentrons_shared_data/labware/types.py b/shared-data/python/opentrons_shared_data/labware/types.py index d3f6599848c..5a6aebf4ff7 100644 --- a/shared-data/python/opentrons_shared_data/labware/types.py +++ b/shared-data/python/opentrons_shared_data/labware/types.py @@ -37,6 +37,7 @@ Literal["fixture"], Literal["adapter"], Literal["maintenance"], + Literal["lid"], ] From e33a2471818a8204881a772dbb3645245cf76238 Mon Sep 17 00:00:00 2001 From: Brent Hagen Date: Thu, 10 Oct 2024 12:45:02 -0400 Subject: [PATCH 4/4] feat(app,app-shell,app-shell-odd): detect user system language, add language setting to app config (#16393) adds a config app language value to desktop/ODD and initializes i18n language to the stored app language config value. detects the user's system language in desktop app-shell and transmits to renderer via IPC/redux. adds a system language config value. closes PLAT-504, PLAT-497 --- .../src/config/__fixtures__/index.ts | 10 +++++++ .../src/config/__tests__/migrate.test.ts | 14 +++++++-- app-shell-odd/src/config/migrate.ts | 20 +++++++++++-- app-shell/src/__fixtures__/config.ts | 10 +++++++ .../src/config/__tests__/migrate.test.ts | 14 +++++++-- app-shell/src/config/migrate.ts | 20 +++++++++++-- app-shell/src/main.ts | 3 +- app-shell/src/ui.ts | 24 +++++++++++++-- app/src/App/DesktopApp.tsx | 7 ++--- app/src/App/OnDeviceDisplayApp.tsx | 6 ++-- app/src/App/__tests__/DesktopApp.test.tsx | 9 ++++++ .../App/__tests__/OnDeviceDisplayApp.test.tsx | 12 ++++---- app/src/LocalizationProvider.tsx | 29 ++++++++++++++----- .../Desktop/AppSettings/AdvancedSettings.tsx | 18 ++++++++++-- .../RobotSettingsList.tsx | 8 +++-- app/src/redux/config/schema-types.ts | 12 +++++++- app/src/redux/config/selectors.ts | 13 +++++++++ app/src/redux/shell/index.ts | 1 + app/src/redux/shell/reducer.ts | 12 ++++++++ app/src/redux/shell/selectors.ts | 5 ++++ app/src/redux/shell/types.ts | 10 +++++++ 21 files changed, 217 insertions(+), 40 deletions(-) create mode 100644 app/src/redux/shell/selectors.ts diff --git a/app-shell-odd/src/config/__fixtures__/index.ts b/app-shell-odd/src/config/__fixtures__/index.ts index d670234ebbc..7f9a48dc02c 100644 --- a/app-shell-odd/src/config/__fixtures__/index.ts +++ b/app-shell-odd/src/config/__fixtures__/index.ts @@ -12,6 +12,7 @@ import type { ConfigV22, ConfigV23, ConfigV24, + ConfigV25, } from '@opentrons/app/src/redux/config/types' const PKG_VERSION: string = _PKG_VERSION_ @@ -171,3 +172,12 @@ export const MOCK_CONFIG_V24: ConfigV24 = { userId: 'MOCK_UUIDv4', }, } + +export const MOCK_CONFIG_V25: ConfigV25 = { + ...MOCK_CONFIG_V24, + version: 25, + language: { + appLanguage: null, + systemLanguage: null, + }, +} diff --git a/app-shell-odd/src/config/__tests__/migrate.test.ts b/app-shell-odd/src/config/__tests__/migrate.test.ts index dcc8eb03708..7ea91ee8d53 100644 --- a/app-shell-odd/src/config/__tests__/migrate.test.ts +++ b/app-shell-odd/src/config/__tests__/migrate.test.ts @@ -16,13 +16,14 @@ import { MOCK_CONFIG_V22, MOCK_CONFIG_V23, MOCK_CONFIG_V24, + MOCK_CONFIG_V25, } from '../__fixtures__' import { migrate } from '../migrate' vi.mock('uuid/v4') -const NEWEST_VERSION = 24 -const NEWEST_MOCK_CONFIG = MOCK_CONFIG_V24 +const NEWEST_VERSION = 25 +const NEWEST_MOCK_CONFIG = MOCK_CONFIG_V25 describe('config migration', () => { beforeEach(() => { @@ -121,10 +122,17 @@ describe('config migration', () => { expect(result.version).toBe(NEWEST_VERSION) expect(result).toEqual(NEWEST_MOCK_CONFIG) }) - it('should keep version 24', () => { + it('should migrate version 24 to latest', () => { const v24Config = MOCK_CONFIG_V24 const result = migrate(v24Config) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(NEWEST_MOCK_CONFIG) + }) + it('should keep version 25', () => { + const v25Config = MOCK_CONFIG_V25 + const result = migrate(v25Config) + expect(result.version).toBe(NEWEST_VERSION) expect(result).toEqual(NEWEST_MOCK_CONFIG) }) diff --git a/app-shell-odd/src/config/migrate.ts b/app-shell-odd/src/config/migrate.ts index d1e9103d430..b6977fbf489 100644 --- a/app-shell-odd/src/config/migrate.ts +++ b/app-shell-odd/src/config/migrate.ts @@ -17,13 +17,14 @@ import type { ConfigV22, ConfigV23, ConfigV24, + ConfigV25, } from '@opentrons/app/src/redux/config/types' // format // base config v12 defaults // any default values for later config versions are specified in the migration // functions for those version below -const CONFIG_VERSION_LATEST = 23 // update this after each config version bump +const CONFIG_VERSION_LATEST = 25 // update this after each config version bump const PKG_VERSION: string = _PKG_VERSION_ export const DEFAULTS_V12: ConfigV12 = { @@ -226,6 +227,18 @@ const toVersion24 = (prevConfig: ConfigV23): ConfigV24 => { } } +const toVersion25 = (prevConfig: ConfigV24): ConfigV25 => { + const nextConfig = { + ...prevConfig, + version: 25 as const, + language: { + appLanguage: null, + systemLanguage: null, + }, + } + return nextConfig +} + const MIGRATIONS: [ (prevConfig: ConfigV12) => ConfigV13, (prevConfig: ConfigV13) => ConfigV14, @@ -238,7 +251,8 @@ const MIGRATIONS: [ (prevConfig: ConfigV20) => ConfigV21, (prevConfig: ConfigV21) => ConfigV22, (prevConfig: ConfigV22) => ConfigV23, - (prevConfig: ConfigV23) => ConfigV24 + (prevConfig: ConfigV23) => ConfigV24, + (prevConfig: ConfigV24) => ConfigV25 ] = [ toVersion13, toVersion14, @@ -252,6 +266,7 @@ const MIGRATIONS: [ toVersion22, toVersion23, toVersion24, + toVersion25, ] export const DEFAULTS: Config = migrate(DEFAULTS_V12) @@ -271,6 +286,7 @@ export function migrate( | ConfigV22 | ConfigV23 | ConfigV24 + | ConfigV25 ): Config { let result = prevConfig // loop through the migrations, skipping any migrations that are unnecessary diff --git a/app-shell/src/__fixtures__/config.ts b/app-shell/src/__fixtures__/config.ts index 23ef4f56f90..dd344c78532 100644 --- a/app-shell/src/__fixtures__/config.ts +++ b/app-shell/src/__fixtures__/config.ts @@ -24,6 +24,7 @@ import type { ConfigV22, ConfigV23, ConfigV24, + ConfigV25, } from '@opentrons/app/src/redux/config/types' export const MOCK_CONFIG_V0: ConfigV0 = { @@ -302,3 +303,12 @@ export const MOCK_CONFIG_V24: ConfigV24 = { userId: 'MOCK_UUIDv4', }, } + +export const MOCK_CONFIG_V25: ConfigV25 = { + ...MOCK_CONFIG_V24, + version: 25, + language: { + appLanguage: null, + systemLanguage: null, + }, +} diff --git a/app-shell/src/config/__tests__/migrate.test.ts b/app-shell/src/config/__tests__/migrate.test.ts index dee16e0dae4..ddc151fc2cf 100644 --- a/app-shell/src/config/__tests__/migrate.test.ts +++ b/app-shell/src/config/__tests__/migrate.test.ts @@ -28,13 +28,14 @@ import { MOCK_CONFIG_V22, MOCK_CONFIG_V23, MOCK_CONFIG_V24, + MOCK_CONFIG_V25, } from '../../__fixtures__' import { migrate } from '../migrate' vi.mock('uuid/v4') -const NEWEST_VERSION = 24 -const NEWEST_MOCK_CONFIG = MOCK_CONFIG_V24 +const NEWEST_VERSION = 25 +const NEWEST_MOCK_CONFIG = MOCK_CONFIG_V25 describe('config migration', () => { beforeEach(() => { @@ -226,10 +227,17 @@ describe('config migration', () => { expect(result.version).toBe(NEWEST_VERSION) expect(result).toEqual(NEWEST_MOCK_CONFIG) }) - it('should keep version 24', () => { + it('should migrate version 24 to latest', () => { const v24Config = MOCK_CONFIG_V24 const result = migrate(v24Config) + expect(result.version).toBe(NEWEST_VERSION) + expect(result).toEqual(NEWEST_MOCK_CONFIG) + }) + it('should keep version 25', () => { + const v25Config = MOCK_CONFIG_V25 + const result = migrate(v25Config) + expect(result.version).toBe(NEWEST_VERSION) expect(result).toEqual(NEWEST_MOCK_CONFIG) }) diff --git a/app-shell/src/config/migrate.ts b/app-shell/src/config/migrate.ts index fa9ed4a91dd..69c53ab2e72 100644 --- a/app-shell/src/config/migrate.ts +++ b/app-shell/src/config/migrate.ts @@ -28,13 +28,14 @@ import type { ConfigV22, ConfigV23, ConfigV24, + ConfigV25, } from '@opentrons/app/src/redux/config/types' // format // base config v0 defaults // any default values for later config versions are specified in the migration // functions for those version below -const CONFIG_VERSION_LATEST = 23 +const CONFIG_VERSION_LATEST = 25 export const DEFAULTS_V0: ConfigV0 = { version: 0, @@ -430,6 +431,18 @@ const toVersion24 = (prevConfig: ConfigV23): ConfigV24 => { } } +const toVersion25 = (prevConfig: ConfigV24): ConfigV25 => { + const nextConfig = { + ...prevConfig, + version: 25 as const, + language: { + appLanguage: null, + systemLanguage: null, + }, + } + return nextConfig +} + const MIGRATIONS: [ (prevConfig: ConfigV0) => ConfigV1, (prevConfig: ConfigV1) => ConfigV2, @@ -454,7 +467,8 @@ const MIGRATIONS: [ (prevConfig: ConfigV20) => ConfigV21, (prevConfig: ConfigV21) => ConfigV22, (prevConfig: ConfigV22) => ConfigV23, - (prevConfig: ConfigV23) => ConfigV24 + (prevConfig: ConfigV23) => ConfigV24, + (prevConfig: ConfigV24) => ConfigV25 ] = [ toVersion1, toVersion2, @@ -480,6 +494,7 @@ const MIGRATIONS: [ toVersion22, toVersion23, toVersion24, + toVersion25, ] export const DEFAULTS: Config = migrate(DEFAULTS_V0) @@ -511,6 +526,7 @@ export function migrate( | ConfigV22 | ConfigV23 | ConfigV24 + | ConfigV25 ): Config { const prevVersion = prevConfig.version let result = prevConfig diff --git a/app-shell/src/main.ts b/app-shell/src/main.ts index 10d3f02cda0..ef422a455cc 100644 --- a/app-shell/src/main.ts +++ b/app-shell/src/main.ts @@ -5,7 +5,7 @@ import dns from 'dns' import contextMenu from 'electron-context-menu' import * as electronDevtoolsInstaller from 'electron-devtools-installer' -import { createUi, registerReloadUi } from './ui' +import { createUi, registerReloadUi, registerSystemLanguage } from './ui' import { initializeMenu } from './menu' import { createLogger } from './log' import { registerProtocolAnalysis } from './protocol-analysis' @@ -110,6 +110,7 @@ function startUp(): void { registerUsb(dispatch), registerNotify(dispatch, mainWindow), registerReloadUi(mainWindow), + registerSystemLanguage(dispatch), ] ipcMain.on('dispatch', (_, action) => { diff --git a/app-shell/src/ui.ts b/app-shell/src/ui.ts index 6f7a2a360fd..25dcf133ad4 100644 --- a/app-shell/src/ui.ts +++ b/app-shell/src/ui.ts @@ -3,10 +3,10 @@ import { app, shell, BrowserWindow } from 'electron' import path from 'path' import { getConfig } from './config' -import { RELOAD_UI } from './constants' +import { RELOAD_UI, UI_INITIALIZED } from './constants' import { createLogger } from './log' -import type { Action } from './types' +import type { Action, Dispatch } from './types' const config = getConfig('ui') const log = createLogger('ui') @@ -78,3 +78,23 @@ export function registerReloadUi( } } } + +export function registerSystemLanguage( + dispatch: Dispatch +): (action: Action) => unknown { + return function handleAction(action: Action) { + switch (action.type) { + case UI_INITIALIZED: { + const systemLanguage = app.getPreferredSystemLanguages() + + dispatch({ + type: 'shell:SYSTEM_LANGUAGE', + payload: { systemLanguage }, + meta: { shell: true }, + }) + + break + } + } + } +} diff --git a/app/src/App/DesktopApp.tsx b/app/src/App/DesktopApp.tsx index 3c9dc3ae253..27d7fd4f238 100644 --- a/app/src/App/DesktopApp.tsx +++ b/app/src/App/DesktopApp.tsx @@ -1,7 +1,6 @@ import { useState, Fragment } from 'react' import { Navigate, Route, Routes, useMatch } from 'react-router-dom' import { ErrorBoundary } from 'react-error-boundary' -import { I18nextProvider } from 'react-i18next' import { Box, @@ -12,7 +11,7 @@ import { import { ApiHostProvider } from '@opentrons/react-api-client' import NiceModal from '@ebay/nice-modal-react' -import { i18n } from '/app/i18n' +import { LocalizationProvider } from '/app/LocalizationProvider' import { Alerts } from '/app/organisms/Desktop/Alerts' import { Breadcrumbs } from '/app/organisms/Desktop/Breadcrumbs' import { ToasterOven } from '/app/organisms/ToasterOven' @@ -106,7 +105,7 @@ export const DesktopApp = (): JSX.Element => { return ( - + @@ -155,7 +154,7 @@ export const DesktopApp = (): JSX.Element => { - + ) } diff --git a/app/src/App/OnDeviceDisplayApp.tsx b/app/src/App/OnDeviceDisplayApp.tsx index 46fb91b21f4..42335754432 100644 --- a/app/src/App/OnDeviceDisplayApp.tsx +++ b/app/src/App/OnDeviceDisplayApp.tsx @@ -16,7 +16,7 @@ import { ApiHostProvider } from '@opentrons/react-api-client' import NiceModal from '@ebay/nice-modal-react' import { SleepScreen } from '/app/atoms/SleepScreen' -import { OnDeviceLocalizationProvider } from '../LocalizationProvider' +import { LocalizationProvider } from '../LocalizationProvider' import { ToasterOven } from '/app/organisms/ToasterOven' import { MaintenanceRunTakeover } from '/app/organisms/TakeoverModal' import { FirmwareUpdateTakeover } from '/app/organisms/FirmwareUpdateModal/FirmwareUpdateTakeover' @@ -180,7 +180,7 @@ export const OnDeviceDisplayApp = (): JSX.Element => { return ( - + {isIdle ? ( @@ -203,7 +203,7 @@ export const OnDeviceDisplayApp = (): JSX.Element => { - + ) diff --git a/app/src/App/__tests__/DesktopApp.test.tsx b/app/src/App/__tests__/DesktopApp.test.tsx index cd769ec0a1b..6510dd49e31 100644 --- a/app/src/App/__tests__/DesktopApp.test.tsx +++ b/app/src/App/__tests__/DesktopApp.test.tsx @@ -5,6 +5,7 @@ import { vi, describe, beforeEach, afterEach, expect, it } from 'vitest' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' +import { LocalizationProvider } from '/app/LocalizationProvider' import { Breadcrumbs } from '/app/organisms/Desktop/Breadcrumbs' import { CalibrationDashboard } from '/app/pages/Desktop/Devices/CalibrationDashboard' import { DeviceDetails } from '/app/pages/Desktop/Devices/DeviceDetails' @@ -20,6 +21,9 @@ import { ProtocolTimeline } from '/app/pages/Desktop/Protocols/ProtocolDetails/P import { useSoftwareUpdatePoll } from '../hooks' import { DesktopApp } from '../DesktopApp' +import type { LocalizationProviderProps } from '/app/LocalizationProvider' + +vi.mock('/app/LocalizationProvider') vi.mock('/app/organisms/Desktop/Breadcrumbs') vi.mock('/app/pages/Desktop/AppSettings/GeneralSettings') vi.mock('/app/pages/Desktop/Devices/CalibrationDashboard') @@ -67,6 +71,11 @@ describe('DesktopApp', () => { vi.mocked(Breadcrumbs).mockReturnValue(
Mock Breadcrumbs
) vi.mocked(AlertsModal).mockReturnValue(<>) vi.mocked(useIsFlex).mockReturnValue(true) + vi.mocked( + LocalizationProvider + ).mockImplementation((props: LocalizationProviderProps) => ( + <>{props.children} + )) }) afterEach(() => { vi.resetAllMocks() diff --git a/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx b/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx index fae54eb2fed..662b2523436 100644 --- a/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx +++ b/app/src/App/__tests__/OnDeviceDisplayApp.test.tsx @@ -4,7 +4,7 @@ import { MemoryRouter } from 'react-router-dom' import { renderWithProviders } from '/app/__testing-utils__' import { i18n } from '/app/i18n' -import { OnDeviceLocalizationProvider } from '../../LocalizationProvider' +import { LocalizationProvider } from '../../LocalizationProvider' import { ConnectViaEthernet } from '/app/pages/ODD/ConnectViaEthernet' import { ConnectViaUSB } from '/app/pages/ODD/ConnectViaUSB' import { ConnectViaWifi } from '/app/pages/ODD/ConnectViaWifi' @@ -32,7 +32,7 @@ import { ODDTopLevelRedirects } from '../ODDTopLevelRedirects' import type { UseQueryResult } from 'react-query' import type { RobotSettingsResponse } from '@opentrons/api-client' -import type { OnDeviceLocalizationProviderProps } from '../../LocalizationProvider' +import type { LocalizationProviderProps } from '../../LocalizationProvider' import type { OnDeviceDisplaySettings } from '/app/redux/config/schema-types' vi.mock('@opentrons/react-api-client', async () => { @@ -100,8 +100,8 @@ describe('OnDeviceDisplayApp', () => { } as any) // TODO(bh, 2024-03-27): implement testing of branded and anonymous i18n, but for now pass through vi.mocked( - OnDeviceLocalizationProvider - ).mockImplementation((props: OnDeviceLocalizationProviderProps) => ( + LocalizationProvider + ).mockImplementation((props: LocalizationProviderProps) => ( <>{props.children} )) }) @@ -163,14 +163,14 @@ describe('OnDeviceDisplayApp', () => { }) it('renders the localization provider and not the loading screen when app-shell is ready', () => { render('/') - expect(vi.mocked(OnDeviceLocalizationProvider)).toHaveBeenCalled() + expect(vi.mocked(LocalizationProvider)).toHaveBeenCalled() expect(screen.queryByLabelText('loading indicator')).toBeNull() }) it('renders the loading screen when app-shell is not ready', () => { vi.mocked(getIsShellReady).mockReturnValue(false) render('/') screen.getByLabelText('loading indicator') - expect(vi.mocked(OnDeviceLocalizationProvider)).not.toHaveBeenCalled() + expect(vi.mocked(LocalizationProvider)).not.toHaveBeenCalled() }) it('renders EmergencyStop component from /emergency-stop', () => { render('/emergency-stop') diff --git a/app/src/LocalizationProvider.tsx b/app/src/LocalizationProvider.tsx index 1cd676d2095..b8fc0149673 100644 --- a/app/src/LocalizationProvider.tsx +++ b/app/src/LocalizationProvider.tsx @@ -1,24 +1,38 @@ import type * as React from 'react' import { I18nextProvider } from 'react-i18next' +import { useSelector } from 'react-redux' import reduce from 'lodash/reduce' -import { resources } from './assets/localization' -import { useIsOEMMode } from './resources/robot-settings/hooks' -import { i18n, i18nCb, i18nConfig } from './i18n' +import { resources } from '/app/assets/localization' +import { i18n, i18nCb, i18nConfig } from '/app/i18n' +import { getAppLanguage, getStoredSystemLanguage } from '/app/redux/config' +import { getSystemLanguage } from '/app/redux/shell' +import { useIsOEMMode } from '/app/resources/robot-settings/hooks' -export interface OnDeviceLocalizationProviderProps { +export interface LocalizationProviderProps { children?: React.ReactNode } export const BRANDED_RESOURCE = 'branded' export const ANONYMOUS_RESOURCE = 'anonymous' -// TODO(bh, 2024-03-26): anonymization limited to ODD for now, may change in future OEM phases -export function OnDeviceLocalizationProvider( - props: OnDeviceLocalizationProviderProps +export function LocalizationProvider( + props: LocalizationProviderProps ): JSX.Element | null { const isOEMMode = useIsOEMMode() + const language = useSelector(getAppLanguage) + const systemLanguage = useSelector(getSystemLanguage) + const storedSystemLanguage = useSelector(getStoredSystemLanguage) + + // TODO(bh, 2024-10-09): desktop app, check for current system language vs stored config system language value, launch modal + console.log( + 'redux systemLanguage', + systemLanguage, + 'storedSystemLanguage', + storedSystemLanguage + ) + // iterate through language resources, nested files, substitute anonymous file for branded file for OEM mode const anonResources = reduce( resources, @@ -44,6 +58,7 @@ export function OnDeviceLocalizationProvider( const anonI18n = i18n.createInstance( { ...i18nConfig, + lng: language ?? 'en', resources: anonResources, }, i18nCb diff --git a/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx b/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx index 59439a6a088..4eda66f68e1 100644 --- a/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx +++ b/app/src/pages/Desktop/AppSettings/AdvancedSettings.tsx @@ -1,3 +1,6 @@ +import { useContext } from 'react' +import { I18nContext } from 'react-i18next' +import { useDispatch } from 'react-redux' import { css } from 'styled-components' import { @@ -10,7 +13,6 @@ import { } from '@opentrons/components' import { Divider } from '/app/atoms/structure' -import { i18n } from '/app/i18n' import { ClearUnavailableRobots, EnableDevTools, @@ -23,7 +25,9 @@ import { UpdatedChannel, AdditionalCustomLabwareSourceFolder, } from '/app/organisms/Desktop/AdvancedSettings' -import { useFeatureFlag } from '/app/redux/config' +import { updateConfigValue, useFeatureFlag } from '/app/redux/config' + +import type { Dispatch } from '/app/redux/types' export function AdvancedSettings(): JSX.Element { return ( @@ -57,6 +61,9 @@ export function AdvancedSettings(): JSX.Element { function LocalizationSetting(): JSX.Element | null { const enableLocalization = useFeatureFlag('enableLocalization') + const dispatch = useDispatch() + + const { i18n } = useContext(I18nContext) return enableLocalization ? ( <> @@ -70,7 +77,12 @@ function LocalizationSetting(): JSX.Element | null { `} value={i18n.language} onChange={(event: React.ChangeEvent) => { - void i18n.changeLanguage(event.currentTarget.value) + dispatch( + updateConfigValue( + 'language.appLanguage', + event.currentTarget.value + ) + ) }} options={[ { name: 'EN', value: 'en' }, diff --git a/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx b/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx index 5983e957419..61e1d9d3314 100644 --- a/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx +++ b/app/src/pages/ODD/RobotSettingsDashboard/RobotSettingsList.tsx @@ -31,6 +31,7 @@ import { toggleDevInternalFlag, toggleDevtools, toggleHistoricOffsets, + updateConfigValue, useFeatureFlag, } from '/app/redux/config' import { InlineNotification } from '/app/atoms/InlineNotification' @@ -273,6 +274,7 @@ function FeatureFlags(): JSX.Element { function LanguageToggle(): JSX.Element | null { const enableLocalization = useFeatureFlag('enableLocalization') + const dispatch = useDispatch() const { i18n } = useContext(I18nContext) @@ -280,9 +282,9 @@ function LanguageToggle(): JSX.Element | null { { - void (i18n.language === 'en' - ? i18n.changeLanguage('zh') - : i18n.changeLanguage('en')) + i18n.language === 'en' + ? dispatch(updateConfigValue('language.appLanguage', 'zh')) + : dispatch(updateConfigValue('language.appLanguage', 'en')) }} rightElement={<>} /> diff --git a/app/src/redux/config/schema-types.ts b/app/src/redux/config/schema-types.ts index 842fb8c3b80..17d4a1ca211 100644 --- a/app/src/redux/config/schema-types.ts +++ b/app/src/redux/config/schema-types.ts @@ -31,6 +31,8 @@ export type QuickTransfersOnDeviceSortKey = | 'recentCreated' | 'oldCreated' +export type Language = 'en' | 'zh' + export interface OnDeviceDisplaySettings { sleepMs: number brightness: number @@ -274,4 +276,12 @@ export type ConfigV24 = Omit & { } } -export type Config = ConfigV24 +export type ConfigV25 = Omit & { + version: 25 + language: { + appLanguage: Language | null + systemLanguage: string | null + } +} + +export type Config = ConfigV25 diff --git a/app/src/redux/config/selectors.ts b/app/src/redux/config/selectors.ts index 53ba2c9d10f..59647b61410 100644 --- a/app/src/redux/config/selectors.ts +++ b/app/src/redux/config/selectors.ts @@ -8,6 +8,7 @@ import type { ProtocolsOnDeviceSortKey, QuickTransfersOnDeviceSortKey, OnDeviceDisplaySettings, + Language, } from './types' import type { ProtocolSort } from '/app/redux/protocol-storage' @@ -155,3 +156,15 @@ export const getUserId: (state: State) => string = createSelector( getConfig, config => config?.userInfo.userId ?? '' ) + +export const getAppLanguage: (state: State) => Language | null = createSelector( + getConfig, + config => config?.language.appLanguage ?? 'en' +) + +export const getStoredSystemLanguage: ( + state: State +) => string | null = createSelector( + getConfig, + config => config?.language.systemLanguage ?? null +) diff --git a/app/src/redux/shell/index.ts b/app/src/redux/shell/index.ts index 5a918f75eb3..d4f88d9a8c9 100644 --- a/app/src/redux/shell/index.ts +++ b/app/src/redux/shell/index.ts @@ -1,6 +1,7 @@ // desktop shell module export * from './actions' +export * from './selectors' export * from './update' 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 5d30a960236..8a325ca8c3b 100644 --- a/app/src/redux/shell/reducer.ts +++ b/app/src/redux/shell/reducer.ts @@ -63,9 +63,21 @@ export function massStorageReducer( return state } +export function systemLanguageReducer( + state: string[] | null = null, + action: Action +): string[] | null { + switch (action.type) { + case 'shell:SYSTEM_LANGUAGE': + return action.payload.systemLanguage + } + return state +} + // TODO: (sa 2021-15-18: remove any typed state in combineReducers) export const shellReducer = combineReducers({ update: shellUpdateReducer, isReady: robotSystemReducer, filePaths: massStorageReducer, + systemLanguage: systemLanguageReducer, }) diff --git a/app/src/redux/shell/selectors.ts b/app/src/redux/shell/selectors.ts new file mode 100644 index 00000000000..6b16e84a4be --- /dev/null +++ b/app/src/redux/shell/selectors.ts @@ -0,0 +1,5 @@ +import type { State } from '../types' + +export function getSystemLanguage(state: State): string | null { + return state.shell.systemLanguage?.[0] ?? null +} diff --git a/app/src/redux/shell/types.ts b/app/src/redux/shell/types.ts index aeee1fe72c6..a3f887b4108 100644 --- a/app/src/redux/shell/types.ts +++ b/app/src/redux/shell/types.ts @@ -68,6 +68,7 @@ export interface ShellState { update: ShellUpdateState isReady: boolean filePaths: string[] + systemLanguage: string[] | null } export interface UiInitializedAction { @@ -95,6 +96,14 @@ export interface ReloadUiAction { meta: { shell: true } } +export interface SystemLanguageAction { + type: 'shell:SYSTEM_LANGUAGE' + payload: { + systemLanguage: string[] + } + meta: { shell: true } +} + export interface SendLogAction { type: 'shell:SEND_LOG' payload: { @@ -177,6 +186,7 @@ export type ShellAction = | RobotMassStorageDeviceRemoved | NotifySubscribeAction | SendFilePathsAction + | SystemLanguageAction export type IPCSafeFormDataEntry = | {