diff --git a/api/release-notes.md b/api/release-notes.md index c680cef73ca..ff193247459 100644 --- a/api/release-notes.md +++ b/api/release-notes.md @@ -6,6 +6,19 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr --- +## Opentrons Robot Software Changes in 7.2.1 + +Welcome to the v7.2.1 release of the Opentrons robot software! + +### Bug Fixes + +- Fixed an issue where OT-2 tip length calibrations created before v4.1.0 would cause a "missing calibration data" error that you could only resolve by resetting calibration. +- Fixed collision prediction being too conservative in certain conditions on Flex, leading to errors even when collisions wouldn't take place. +- Flex now properly homes after an instrument collision. +- `opentrons_simulate` now outputs entries for commands that drop tips in the default trash container in protocols that specify Python API version 2.16 or newer. + +--- + ## Opentrons Robot Software Changes in 7.2.0 Welcome to the v7.2.0 release of the Opentrons robot software! diff --git a/api/src/opentrons/calibration_storage/ot2/deck_attitude.py b/api/src/opentrons/calibration_storage/ot2/deck_attitude.py index 3f85ad25c17..8edd2e52662 100644 --- a/api/src/opentrons/calibration_storage/ot2/deck_attitude.py +++ b/api/src/opentrons/calibration_storage/ot2/deck_attitude.py @@ -79,7 +79,7 @@ def get_robot_deck_attitude() -> Optional[v1.DeckCalibrationModel]: pass except (json.JSONDecodeError, ValidationError): log.warning( - "Deck calibration is malformed. Please factory reset your calibrations." + "Deck calibration is malformed. Please factory reset your calibrations.", + exc_info=True, ) - pass return None diff --git a/api/src/opentrons/calibration_storage/ot2/pipette_offset.py b/api/src/opentrons/calibration_storage/ot2/pipette_offset.py index ac09a736b4e..a4175b90545 100644 --- a/api/src/opentrons/calibration_storage/ot2/pipette_offset.py +++ b/api/src/opentrons/calibration_storage/ot2/pipette_offset.py @@ -92,7 +92,8 @@ def get_pipette_offset( return None except (json.JSONDecodeError, ValidationError): log.warning( - f"Malformed calibrations for {pipette_id} on {mount}. Please factory reset your calibrations." + f"Malformed calibrations for {pipette_id} on {mount}. Please factory reset your calibrations.", + exc_info=True, ) # 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 7aff6ec9515..8b5e5369805 100644 --- a/api/src/opentrons/calibration_storage/ot2/tip_length.py +++ b/api/src/opentrons/calibration_storage/ot2/tip_length.py @@ -37,28 +37,43 @@ def _convert_tip_length_model_to_dict( def tip_lengths_for_pipette( pipette_id: str, ) -> typing.Dict[LabwareUri, v1.TipLengthModel]: - tip_lengths = {} try: 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_identifier, data in all_tip_lengths_for_pipette.items(): - # We normally key these calibrations by their tip rack URI, - # but older software had them keyed by their tip rack hash. - # Migrate from the old format, if necessary. - if "/" not in tiprack_identifier: - data["definitionHash"] = tiprack_identifier - tiprack_identifier = data.pop("uri") - try: - tip_lengths[LabwareUri(tiprack_identifier)] = v1.TipLengthModel(**data) - except (json.JSONDecodeError, ValidationError): - log.warning( - f"Tip length calibration is malformed for {tiprack_identifier} on {pipette_id}" - ) - pass - return tip_lengths except FileNotFoundError: log.debug(f"Tip length calibrations not found for {pipette_id}") - return tip_lengths + return {} + except json.JSONDecodeError: + log.warning( + f"Tip length calibration is malformed for {pipette_id}", exc_info=True + ) + return {} + + tip_lengths: typing.Dict[LabwareUri, v1.TipLengthModel] = {} + + for tiprack_identifier, data in all_tip_lengths_for_pipette.items(): + # We normally key these calibrations by their tip rack URI, + # but older software had them keyed by their tip rack hash. + # Migrate from the old format, if necessary. + tiprack_identifier_is_uri = "/" in tiprack_identifier + if not tiprack_identifier_is_uri: + data["definitionHash"] = tiprack_identifier + uri = data.pop("uri", None) + if uri is None: + # We don't have a way to migrate old records without a URI, + # so skip over them. + continue + else: + tiprack_identifier = uri + + try: + tip_lengths[LabwareUri(tiprack_identifier)] = v1.TipLengthModel(**data) + except ValidationError: + log.warning( + f"Tip length calibration is malformed for {tiprack_identifier} on {pipette_id}", + exc_info=True, + ) + return tip_lengths def load_tip_length_calibration( diff --git a/api/src/opentrons/calibration_storage/ot3/deck_attitude.py b/api/src/opentrons/calibration_storage/ot3/deck_attitude.py index 8f779e4338a..6187459d461 100644 --- a/api/src/opentrons/calibration_storage/ot3/deck_attitude.py +++ b/api/src/opentrons/calibration_storage/ot3/deck_attitude.py @@ -77,7 +77,7 @@ def get_robot_belt_attitude() -> Optional[v1.BeltCalibrationModel]: pass except (json.JSONDecodeError, ValidationError): log.warning( - "Belt calibration is malformed. Please factory reset your calibrations." + "Belt calibration is malformed. Please factory reset your calibrations.", + exc_info=True, ) - pass return None diff --git a/api/src/opentrons/calibration_storage/ot3/module_offset.py b/api/src/opentrons/calibration_storage/ot3/module_offset.py index 800ab8380e6..b9a030d1208 100644 --- a/api/src/opentrons/calibration_storage/ot3/module_offset.py +++ b/api/src/opentrons/calibration_storage/ot3/module_offset.py @@ -108,7 +108,8 @@ def get_module_offset( return None except (json.JSONDecodeError, ValidationError): log.warning( - f"Malformed calibrations for {module_id} on slot {slot}. Please factory reset your calibrations." + f"Malformed calibrations for {module_id} on slot {slot}. Please factory reset your calibrations.", + exc_info=True, ) return None @@ -130,7 +131,8 @@ def load_all_module_offsets() -> List[v1.ModuleOffsetModel]: ) except (json.JSONDecodeError, ValidationError): log.warning( - f"Malformed module calibrations for {file}. Please factory reset your calibrations." + f"Malformed module calibrations for {file}. Please factory reset your calibrations.", + exc_info=True, ) continue return calibrations diff --git a/api/src/opentrons/calibration_storage/ot3/pipette_offset.py b/api/src/opentrons/calibration_storage/ot3/pipette_offset.py index fcd53bbbf3e..a1e6e1090db 100644 --- a/api/src/opentrons/calibration_storage/ot3/pipette_offset.py +++ b/api/src/opentrons/calibration_storage/ot3/pipette_offset.py @@ -89,6 +89,7 @@ def get_pipette_offset( return None except (json.JSONDecodeError, ValidationError): log.warning( - f"Malformed calibrations for {pipette_id} on {mount}. Please factory reset your calibrations." + f"Malformed calibrations for {pipette_id} on {mount}. Please factory reset your calibrations.", + exc_info=True, ) return None diff --git a/api/src/opentrons/config/robot_configs.py b/api/src/opentrons/config/robot_configs.py index d30109dc697..bcb6d6076da 100755 --- a/api/src/opentrons/config/robot_configs.py +++ b/api/src/opentrons/config/robot_configs.py @@ -148,7 +148,7 @@ def _load_json(filename: Union[str, Path]) -> Dict[str, Any]: log.warning("{0} not found. Loading defaults".format(filename)) res = {} except json.decoder.JSONDecodeError: - log.warning("{0} is corrupt. Loading defaults".format(filename)) + log.warning("{0} is corrupt. Loading defaults".format(filename), exc_info=True) res = {} return cast(Dict[str, Any], res) diff --git a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py index d3f66bad076..2a50964e757 100644 --- a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py +++ b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py @@ -78,9 +78,6 @@ def __init__(self, message: str) -> None: x=A12_column_back_right_bound.x - _NOZZLE_PITCH * 11, y=506.2 ) -# Arbitrary safety margin in z-direction -Z_SAFETY_MARGIN = 10 - _FLEX_TC_LID_BACK_LEFT_PT = Point( x=FLEX_TC_LID_COLLISION_ZONE["back_left"]["x"], y=FLEX_TC_LID_COLLISION_ZONE["back_left"]["y"], @@ -332,7 +329,7 @@ def _slot_has_potential_colliding_object( slot_highest_z = engine_state.geometry.get_highest_z_in_slot( StagingSlotLocation(slotName=surrounding_slot) ) - return slot_highest_z + Z_SAFETY_MARGIN > pipette_bounds[0].z + return slot_highest_z >= pipette_bounds[0].z return False diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 9754def8e5b..1b58bcfc524 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -1149,6 +1149,7 @@ def home_plunger(self) -> InstrumentContext: self._core.home_plunger() return self + # TODO (spp, 2024-03-08): verify if ok to & change source & dest types to AdvancedLiquidHandling @publisher.publish(command=cmds.distribute) @requires_version(2, 0) def distribute( @@ -1188,6 +1189,7 @@ def distribute( return self.transfer(volume, source, dest, **kwargs) + # TODO (spp, 2024-03-08): verify if ok to & change source & dest types to AdvancedLiquidHandling @publisher.publish(command=cmds.consolidate) @requires_version(2, 0) def consolidate( diff --git a/api/tests/opentrons/calibration_storage/test_tip_length_ot2.py b/api/tests/opentrons/calibration_storage/test_tip_length_ot2.py index 4b63b52d3fc..2d593bda67e 100644 --- a/api/tests/opentrons/calibration_storage/test_tip_length_ot2.py +++ b/api/tests/opentrons/calibration_storage/test_tip_length_ot2.py @@ -113,3 +113,31 @@ def test_delete_all_tip_calibration(starting_calibration_data: Any) -> None: clear_tip_length_calibration() assert tip_lengths_for_pipette("pip1") == {} assert tip_lengths_for_pipette("pip2") == {} + + +def test_uriless_calibrations_are_dropped(ot_config_tempdir: object) -> None: + """Legacy records without a `uri` field should be silently ignored.""" + + data = { + "ed323db6ca1ddf197aeb20667c1a7a91c89cfb2f931f45079d483928da056812": { + "tipLength": 123, + "lastModified": "2021-01-11T00:34:29.291073+00:00", + "source": "user", + "status": {"markedBad": False}, + }, + "130e17bb7b2f0c0472dcc01c1ff6f600ca1a6f9f86a90982df56c4bf43776824": { + "tipLength": 456, + "lastModified": "2021-05-12T22:16:14.249567+00:00", + "source": "user", + "status": {"markedBad": False}, + "uri": "opentrons/opentrons_96_filtertiprack_200ul/1", + }, + } + + io.save_to_file(config.get_tip_length_cal_path(), "pipette1234", data) + result = tip_lengths_for_pipette("pipette1234") + assert len(result) == 1 + assert ( + result[LabwareUri("opentrons/opentrons_96_filtertiprack_200ul/1")].tipLength + == 456 + ) diff --git a/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py b/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py index 2ad7b63615a..33e92086edb 100644 --- a/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py +++ b/api/tests/opentrons/protocol_api_integration/test_pipette_movement_deck_conflicts.py @@ -99,6 +99,36 @@ def test_deck_conflicts_for_96_ch_a12_column_configuration() -> None: instrument.dispense(50, accessible_plate.wells_by_name()["A1"]) +@pytest.mark.ot3_only +def test_close_shave_deck_conflicts_for_96_ch_a12_column_configuration() -> None: + """Shouldn't raise errors for "almost collision"s.""" + protocol_context = simulate.get_protocol_api(version="2.16", robot_type="Flex") + res12 = protocol_context.load_labware("nest_12_reservoir_15ml", "C3") + + # Mag block and tiprack adapter are very close to the destination reservoir labware + protocol_context.load_module("magneticBlockV1", "D2") + protocol_context.load_labware( + "opentrons_flex_96_tiprack_200ul", + "B3", + adapter="opentrons_flex_96_tiprack_adapter", + ) + tiprack_8 = protocol_context.load_labware("opentrons_flex_96_tiprack_200ul", "B2") + hs = protocol_context.load_module("heaterShakerModuleV1", "D1") + hs_adapter = hs.load_adapter("opentrons_96_deep_well_adapter") + deepwell = hs_adapter.load_labware("nest_96_wellplate_2ml_deep") + protocol_context.load_trash_bin("A3") + p1000_96 = protocol_context.load_instrument("flex_96channel_1000") + p1000_96.configure_nozzle_layout(style=COLUMN, start="A12", tip_racks=[tiprack_8]) + + hs.close_labware_latch() # type: ignore[union-attr] + p1000_96.distribute( + 15, + res12.wells()[0], + deepwell.rows()[0], + disposal_vol=0, + ) + + @pytest.mark.ot3_only def test_deck_conflicts_for_96_ch_a1_column_configuration() -> None: """It should raise errors for expected deck conflicts.""" diff --git a/app-shell/build/release-notes.md b/app-shell/build/release-notes.md index 410c27a58a4..97fa5f01b81 100644 --- a/app-shell/build/release-notes.md +++ b/app-shell/build/release-notes.md @@ -6,6 +6,16 @@ log][]. For a list of currently known issues, please see the [Opentrons issue tr --- +## Opentrons App Changes in 7.2.1 + +Welcome to the v7.2.1 release of the Opentrons App! + +### Bug Fixes + +- Fixed a memory leak that could cause the app to crash. + +--- + ## Opentrons App Changes in 7.2.0 Welcome to the v7.2.0 release of the Opentrons App! diff --git a/app/src/organisms/Devices/ProtocolRun/SetupGripperCalibrationItem.tsx b/app/src/organisms/Devices/ProtocolRun/SetupGripperCalibrationItem.tsx index 431ecbf5529..255a69f467c 100644 --- a/app/src/organisms/Devices/ProtocolRun/SetupGripperCalibrationItem.tsx +++ b/app/src/organisms/Devices/ProtocolRun/SetupGripperCalibrationItem.tsx @@ -26,7 +26,7 @@ export function SetupGripperCalibrationItem({ gripperData, runId, }: SetupGripperCalibrationItemProps): JSX.Element | null { - const { t } = useTranslation('protocol_setup') + const { t, i18n } = useTranslation('protocol_setup') const [ openWizardFlowType, setOpenWizardFlowType, @@ -47,7 +47,7 @@ export function SetupGripperCalibrationItem({ setOpenWizardFlowType(GRIPPER_FLOW_TYPES.ATTACH) }} > - {t('attach_gripper')} + {i18n.format(t('attach_gripper'), 'capitalize')} ) diff --git a/app/src/redux/shell/remote.ts b/app/src/redux/shell/remote.ts index c6e9a984f13..18508789ada 100644 --- a/app/src/redux/shell/remote.ts +++ b/app/src/redux/shell/remote.ts @@ -34,20 +34,50 @@ export function appShellRequestor( return remote.ipcRenderer.invoke('usb:request', configProxy) } -export function appShellListener( - hostname: string | null, - topic: NotifyTopic, +interface CallbackStore { + [hostname: string]: { + [topic in NotifyTopic]: Array<(data: NotifyResponseData) => void> + } +} +const callbackStore: CallbackStore = {} + +interface AppShellListener { + hostname: string + topic: NotifyTopic callback: (data: NotifyResponseData) => void -): void { - remote.ipcRenderer.on( - 'notify', - (_, shellHostname, shellTopic, shellMessage) => { - if ( - hostname === shellHostname && - (topic === shellTopic || shellTopic === 'ALL_TOPICS') - ) { - callback(shellMessage) + isDismounting?: boolean +} +export function appShellListener({ + hostname, + topic, + callback, + isDismounting = false, +}: AppShellListener): CallbackStore { + if (isDismounting) { + const callbacks = callbackStore[hostname]?.[topic] + if (callbacks != null) { + callbackStore[hostname][topic] = callbacks.filter(cb => cb !== callback) + if (!callbackStore[hostname][topic].length) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete callbackStore[hostname][topic] + if (!Object.keys(callbackStore[hostname]).length) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete callbackStore[hostname] + } } } - ) + } else { + callbackStore[hostname] = callbackStore[hostname] ?? {} + callbackStore[hostname][topic] ??= [] + callbackStore[hostname][topic].push(callback) + } + return callbackStore } + +// Instantiate the notify listener at runtime. +remote.ipcRenderer.on( + 'notify', + (_, shellHostname, shellTopic, shellMessage) => { + callbackStore[shellHostname]?.[shellTopic]?.forEach(cb => cb(shellMessage)) + } +) diff --git a/app/src/redux/shell/types.ts b/app/src/redux/shell/types.ts index 6502f92c439..df36124e7c1 100644 --- a/app/src/redux/shell/types.ts +++ b/app/src/redux/shell/types.ts @@ -6,19 +6,19 @@ export interface Remote { ipcRenderer: { invoke: (channel: string, ...args: unknown[]) => Promise send: (channel: string, ...args: unknown[]) => void - on: ( - channel: string, - listener: ( - event: IpcMainEvent, - hostname: string, - topic: NotifyTopic, - message: NotifyResponseData | NotifyNetworkError, - ...args: unknown[] - ) => void - ) => void + on: (channel: string, listener: IpcListener) => void + off: (channel: string, listener: IpcListener) => void } } +export type IpcListener = ( + event: IpcMainEvent, + hostname: string, + topic: NotifyTopic, + message: NotifyResponseData | NotifyNetworkError, + ...args: unknown[] +) => void + export interface NotifyRefetchData { refetchUsingHTTP: boolean } diff --git a/app/src/resources/__tests__/useNotifyService.test.ts b/app/src/resources/__tests__/useNotifyService.test.ts index 0b4ce2fd1b0..ad8628e3e87 100644 --- a/app/src/resources/__tests__/useNotifyService.test.ts +++ b/app/src/resources/__tests__/useNotifyService.test.ts @@ -138,11 +138,12 @@ describe('useNotifyService', () => { }) it('should return set HTTP refetch to always and fire an analytics reporting event if the connection was refused', () => { - vi.mocked(appShellListener).mockImplementation( - (_: any, __: any, mockCb: any) => { - mockCb('ECONNREFUSED') - } - ) + vi.mocked(appShellListener).mockImplementation(function ({ + callback, + }): any { + // eslint-disable-next-line n/no-callback-literal + callback('ECONNREFUSED') + }) const { rerender } = renderHook(() => useNotifyService({ topic: MOCK_TOPIC, @@ -156,11 +157,12 @@ describe('useNotifyService', () => { }) it('should trigger a single HTTP refetch if the refetch flag was returned', () => { - vi.mocked(appShellListener).mockImplementation( - (_: any, __: any, mockCb: any) => { - mockCb({ refetchUsingHTTP: true }) - } - ) + vi.mocked(appShellListener).mockImplementation(function ({ + callback, + }): any { + // eslint-disable-next-line n/no-callback-literal + callback({ refetchUsingHTTP: true }) + }) const { rerender } = renderHook(() => useNotifyService({ topic: MOCK_TOPIC, @@ -173,11 +175,12 @@ describe('useNotifyService', () => { }) it('should trigger a single HTTP refetch if the unsubscribe flag was returned', () => { - vi.mocked(appShellListener).mockImplementation( - (_: any, __: any, mockCb: any) => { - mockCb({ unsubscribe: true }) - } - ) + vi.mocked(appShellListener).mockImplementation(function ({ + callback, + }): any { + // eslint-disable-next-line n/no-callback-literal + callback({ unsubscribe: true }) + }) const { rerender } = renderHook(() => useNotifyService({ topic: MOCK_TOPIC, @@ -188,4 +191,16 @@ describe('useNotifyService', () => { rerender() expect(mockHTTPRefetch).toHaveBeenCalledWith('once') }) + + it('should clean up the listener on dismount', () => { + const { unmount } = renderHook(() => + useNotifyService({ + topic: MOCK_TOPIC, + setRefetchUsingHTTP: mockHTTPRefetch, + options: MOCK_OPTIONS, + }) + ) + unmount() + expect(appShellListener).toHaveBeenCalled() + }) }) diff --git a/app/src/resources/runs/useNotifyRunQuery.ts b/app/src/resources/runs/useNotifyRunQuery.ts index d36110c37f1..dde7bc84448 100644 --- a/app/src/resources/runs/useNotifyRunQuery.ts +++ b/app/src/resources/runs/useNotifyRunQuery.ts @@ -21,6 +21,8 @@ export function useNotifyRunQuery( setRefetchUsingHTTP, ] = React.useState(null) + const isEnabled = options.enabled !== false && runId != null + useNotifyService({ topic: `robot-server/runs/${runId}` as NotifyTopic, setRefetchUsingHTTP, @@ -29,7 +31,7 @@ export function useNotifyRunQuery( const httpResponse = useRunQuery(runId, { ...options, - enabled: options?.enabled !== false && refetchUsingHTTP != null, + enabled: isEnabled && refetchUsingHTTP != null, onSettled: refetchUsingHTTP === 'once' ? () => setRefetchUsingHTTP(null) diff --git a/app/src/resources/useNotifyService.ts b/app/src/resources/useNotifyService.ts index 8fcfd852575..63c887fb9b5 100644 --- a/app/src/resources/useNotifyService.ts +++ b/app/src/resources/useNotifyService.ts @@ -55,14 +55,26 @@ export function useNotifyService({ if (shouldUseNotifications) { // Always fetch on initial mount. setRefetchUsingHTTP('once') - appShellListener(hostname, topic, onDataEvent) + appShellListener({ + hostname, + topic, + callback: onDataEvent, + }) dispatch(notifySubscribeAction(hostname, topic)) hasUsedNotifyService.current = true } else setRefetchUsingHTTP('always') return () => { - if (hasUsedNotifyService.current && hostname != null) { - dispatch(notifyUnsubscribeAction(hostname, topic)) + if (hasUsedNotifyService.current) { + if (hostname != null) { + dispatch(notifyUnsubscribeAction(hostname, topic)) + } + appShellListener({ + hostname: hostname as string, + topic, + callback: onDataEvent, + isDismounting: true, + }) } } }, [topic, host, shouldUseNotifications]) diff --git a/setup-vitest.ts b/setup-vitest.ts index c4baaaf3bb6..07bd135137d 100644 --- a/setup-vitest.ts +++ b/setup-vitest.ts @@ -6,6 +6,7 @@ vi.mock('protocol-designer/src/labware-defs/utils') vi.mock('electron-store') vi.mock('electron-updater') vi.mock('electron') +vi.mock('./app/src/redux/shell/remote') process.env.OT_PD_VERSION = 'fake_PD_version' global._PKG_VERSION_ = 'test environment'