diff --git a/app/src/components/CalibratePipetteOffset/__tests__/useCalibratePipetteOffset.test.js b/app/src/components/CalibratePipetteOffset/__tests__/useCalibratePipetteOffset.test.js new file mode 100644 index 00000000000..a455f1c2fad --- /dev/null +++ b/app/src/components/CalibratePipetteOffset/__tests__/useCalibratePipetteOffset.test.js @@ -0,0 +1,130 @@ +// @flow +import * as React from 'react' +import uniqueId from 'lodash/uniqueId' +import { mountWithStore } from '@opentrons/components/__utils__' +import { act } from 'react-dom/test-utils' + +import * as RobotApi from '../../../robot-api' +import * as Sessions from '../../../sessions' +import { mockPipetteOffsetCalibrationSessionAttributes } from '../../../sessions/__fixtures__' + +import { useCalibratePipetteOffset } from '../useCalibratePipetteOffset' + +import type { State } from '../../../types' +import type { SessionType } from '../../../sessions' + +jest.mock('../../../sessions/selectors') +jest.mock('../../../robot-api/selectors') +jest.mock('lodash/uniqueId') + +const mockUniqueId: JestMockFn<[string | void], string> = uniqueId +const mockGetRobotSessionOfType: JestMockFn< + [State, string, SessionType], + $Call +> = Sessions.getRobotSessionOfType +const mockGetRequestById: JestMockFn< + [State, string], + $Call +> = RobotApi.getRequestById + +describe('useCalibratePipetteOffset hook', () => { + let startCalibration + let CalWizardComponent + const robotName = 'robotName' + const mountString = 'left' + const onComplete = jest.fn() + + const TestUseCalibratePipetteOffset = () => { + const [_startCalibration, _CalWizardComponent] = useCalibratePipetteOffset( + robotName, + { + mount: mountString, + shouldRecalibrateTipLength: false, + hasCalibrationBlock: false, + tipRackDefinition: null, + }, + onComplete + ) + React.useEffect(() => { + startCalibration = _startCalibration + CalWizardComponent = _CalWizardComponent + }) + return <>{CalWizardComponent} + } + + beforeEach(() => { + let mockIdCounter = 0 + mockUniqueId.mockImplementation(() => `mockId_${mockIdCounter++}`) + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('returns start callback, and no wizard if session not present', () => { + const { store } = mountWithStore(, { + initialState: { robotApi: {}, sessions: {} }, + }) + expect(typeof startCalibration).toBe('function') + expect(CalWizardComponent).toBe(null) + + act(() => startCalibration()) + + expect(store.dispatch).toHaveBeenCalledWith({ + ...Sessions.ensureSession( + robotName, + Sessions.SESSION_TYPE_PIPETTE_OFFSET_CALIBRATION, + { + mount: mountString, + shouldRecalibrateTipLength: false, + hasCalibrationBlock: false, + tipRackDefinition: null, + } + ), + meta: { requestId: expect.any(String) }, + }) + }) + + it('wizard should appear after create request succeeds with session and close on closeWizard', () => { + const seshId = 'fake-session-id' + const mockPipOffsetCalSession = { + id: seshId, + ...mockPipetteOffsetCalibrationSessionAttributes, + details: { + ...mockPipetteOffsetCalibrationSessionAttributes.details, + currentStep: Sessions.PIP_OFFSET_STEP_CALIBRATION_COMPLETE, + }, + } + const { store, wrapper } = mountWithStore( + , + { + initialState: { robotApi: {} }, + } + ) + mockGetRobotSessionOfType.mockReturnValue(mockPipOffsetCalSession) + mockGetRequestById.mockReturnValue({ + status: RobotApi.SUCCESS, + response: { + method: 'POST', + ok: true, + path: '/', + status: 200, + }, + }) + act(() => startCalibration()) + wrapper.setProps({}) + expect(CalWizardComponent).not.toBe(null) + + wrapper + .find('button[title="Return tip to tip rack and exit"]') + .invoke('onClick')() + wrapper.setProps({}) + expect(store.dispatch).toHaveBeenCalledWith({ + ...Sessions.deleteSession(robotName, seshId), + meta: { requestId: expect.any(String) }, + }) + wrapper.setProps({}) // update so delete request can be handled on success + expect(CalWizardComponent).toBe(null) + expect(onComplete).toHaveBeenCalled() + }) +}) diff --git a/app/src/components/CalibratePipetteOffset/index.js b/app/src/components/CalibratePipetteOffset/index.js index a87d1eba5a3..5c0f9c7004e 100644 --- a/app/src/components/CalibratePipetteOffset/index.js +++ b/app/src/components/CalibratePipetteOffset/index.js @@ -97,14 +97,7 @@ const PANEL_STYLE_PROPS_BY_STEP: { export function CalibratePipetteOffset( props: CalibratePipetteOffsetParentProps ): React.Node { - const { - session, - robotName, - closeWizard, - dispatchRequests, - showSpinner, - hasBlock, - } = props + const { session, robotName, dispatchRequests, showSpinner, hasBlock } = props const { currentStep, instrument, labware } = session?.details || {} const { @@ -147,7 +140,6 @@ export function CalibratePipetteOffset( Sessions.deleteSession(robotName, session.id) ) } - closeWizard() } if (!session || !tipRack) { diff --git a/app/src/components/InstrumentSettings/PipetteOffsetCalibrationControl.js b/app/src/components/CalibratePipetteOffset/useCalibratePipetteOffset.js similarity index 75% rename from app/src/components/InstrumentSettings/PipetteOffsetCalibrationControl.js rename to app/src/components/CalibratePipetteOffset/useCalibratePipetteOffset.js index 1abae87a2d2..70ab696e3e6 100644 --- a/app/src/components/InstrumentSettings/PipetteOffsetCalibrationControl.js +++ b/app/src/components/CalibratePipetteOffset/useCalibratePipetteOffset.js @@ -1,7 +1,6 @@ // @flow import * as React from 'react' import { useSelector } from 'react-redux' -import { SecondaryBtn, SPACING_2 } from '@opentrons/components' import * as RobotApi from '../../robot-api' import * as Sessions from '../../sessions' @@ -10,34 +9,44 @@ import type { State } from '../../types' import type { SessionCommandString, PipetteOffsetCalibrationSession, + PipetteOffsetCalibrationSessionParams, } from '../../sessions/types' -import type { Mount } from '../../pipettes/types' import type { RequestState } from '../../robot-api/types' import { Portal } from '../portal' import { CalibratePipetteOffset } from '../CalibratePipetteOffset' -type Props = {| - robotName: string, - mount: Mount, -|} - // pipette calibration commands for which the full page spinner should not appear const spinnerCommandBlockList: Array = [ Sessions.sharedCalCommands.JOG, ] -const BUTTON_TEXT = 'Calibrate offset' - -export function PipetteOffsetCalibrationControl(props: Props): React.Node { - const { robotName, mount } = props - +export function useCalibratePipetteOffset( + robotName: string, + sessionParams: $Shape, + onComplete: (() => mixed) | null = null +): [() => void, React.Node | null] { const [showWizard, setShowWizard] = React.useState(false) const trackedRequestId = React.useRef(null) const deleteRequestId = React.useRef(null) const createRequestId = React.useRef(null) + const pipOffsetCalSession = useSelector< + State, + PipetteOffsetCalibrationSession | null + >((state: State) => { + const session: Sessions.Session | null = Sessions.getRobotSessionOfType( + state, + robotName, + Sessions.SESSION_TYPE_PIPETTE_OFFSET_CALIBRATION + ) + return session && + session.sessionType === Sessions.SESSION_TYPE_PIPETTE_OFFSET_CALIBRATION + ? session + : null + }) + const [dispatchRequests] = RobotApi.useDispatchApiRequests( dispatchedAction => { if (dispatchedAction.type === Sessions.ENSURE_SESSION) { @@ -79,20 +88,28 @@ export function PipetteOffsetCalibrationControl(props: Props): React.Node { : null )?.status === RobotApi.SUCCESS + const closeWizard = React.useCallback(() => { + onComplete && onComplete() + setShowWizard(false) + }, [onComplete]) + React.useEffect(() => { if (shouldOpen) { setShowWizard(true) createRequestId.current = null } if (shouldClose) { - setShowWizard(false) + closeWizard() deleteRequestId.current = null } - }, [shouldOpen, shouldClose]) - - const hasCalibrationBlock = false - const shouldRecalibrateTipLength = false - const tipRackDefinition = null + }, [shouldOpen, shouldClose, closeWizard]) + + const { + mount, + shouldRecalibrateTipLength = false, + hasCalibrationBlock = false, + tipRackDefinition = null, + } = sessionParams const handleStartPipOffsetCalSession = () => { dispatchRequests( Sessions.ensureSession( @@ -108,42 +125,18 @@ export function PipetteOffsetCalibrationControl(props: Props): React.Node { ) } - const pipOffsetCalSession = useSelector< - State, - PipetteOffsetCalibrationSession | null - >((state: State) => { - const session: Sessions.Session | null = Sessions.getRobotSessionOfType( - state, - robotName, - Sessions.SESSION_TYPE_PIPETTE_OFFSET_CALIBRATION - ) - return session && - session.sessionType === Sessions.SESSION_TYPE_PIPETTE_OFFSET_CALIBRATION - ? session - : null - }) - - return ( - <> - - {BUTTON_TEXT} - - {showWizard && ( - - setShowWizard(false)} - showSpinner={showSpinner} - dispatchRequests={dispatchRequests} - /> - - )} - - ) + return [ + handleStartPipOffsetCalSession, + showWizard ? ( + + + + ) : null, + ] } diff --git a/app/src/components/ChangePipette/ConfirmPipette.js b/app/src/components/ChangePipette/ConfirmPipette.js index 2677e9e7290..20aded8e843 100644 --- a/app/src/components/ChangePipette/ConfirmPipette.js +++ b/app/src/components/ChangePipette/ConfirmPipette.js @@ -1,11 +1,13 @@ // @flow import * as React from 'react' import cx from 'classnames' +import { useSelector } from 'react-redux' -import { Icon, PrimaryButton, ModalPage } from '@opentrons/components' +import { Icon, PrimaryBtn, ModalPage, SPACING_2 } from '@opentrons/components' import { getDiagramsSrc } from './InstructionStep' import { CheckPipettesButton } from './CheckPipettesButton' import styles from './styles.css' +import { getFeatureFlags } from '../../config' import type { PipetteNameSpecs, @@ -13,9 +15,12 @@ import type { PipetteDisplayCategory, } from '@opentrons/shared-data' import type { Mount } from '../../pipettes/types' +import type { PipetteOffsetCalibration } from '../../calibration/types' const EXIT_BUTTON_MESSAGE = 'exit pipette setup' const EXIT_BUTTON_MESSAGE_WRONG = 'keep pipette and exit setup' +const EXIT_WITHOUT_CAL = 'exit without calibrating' +const CONTINUE_TO_PIP_OFFSET = 'continue to pipette offset calibration' type Props = {| robotName: string, @@ -26,15 +31,27 @@ type Props = {| attachedWrong: boolean, wantedPipette: PipetteNameSpecs | null, actualPipette: PipetteModelSpecs | null, + actualPipetteOffset: PipetteOffsetCalibration | null, displayName: string, displayCategory: PipetteDisplayCategory | null, tryAgain: () => mixed, back: () => mixed, exit: () => mixed, + startPipetteOffsetCalibration: () => void, |} export function ConfirmPipette(props: Props): React.Node { - const { title, subtitle, success, attachedWrong, actualPipette, back } = props + const { + title, + subtitle, + success, + attachedWrong, + actualPipette, + actualPipetteOffset, + back, + } = props + + const ff = useSelector(getFeatureFlags) return ( {!success && } {success && !actualPipette && } + {ff.enableCalibrationOverhaul && + success && + actualPipette && + !actualPipetteOffset && } ) @@ -137,9 +158,21 @@ function StatusDetails(props: Props) { function AttachAnotherButton(props: Props) { return ( - + attach another pipette - + + ) +} + +function CalibratePipetteOffsetButton(props: Props) { + return ( + + {CONTINUE_TO_PIP_OFFSET} + ) } @@ -154,9 +187,9 @@ function TryAgainButton(props: Props) { if (wantedPipette && attachedWrong) { return ( - + detach and try again - + ) } @@ -173,14 +206,14 @@ function TryAgainButton(props: Props) { } function ExitButton(props: Props) { - const { exit, attachedWrong } = props - const children = attachedWrong - ? EXIT_BUTTON_MESSAGE_WRONG - : EXIT_BUTTON_MESSAGE + const { exit, attachedWrong, actualPipetteOffset } = props + let buttonText = EXIT_BUTTON_MESSAGE + if (attachedWrong) buttonText = EXIT_BUTTON_MESSAGE_WRONG + else if (!actualPipetteOffset) buttonText = EXIT_WITHOUT_CAL return ( - - {children} - + + {buttonText} + ) } diff --git a/app/src/components/ChangePipette/LevelPipette.js b/app/src/components/ChangePipette/LevelPipette.js index f533e6d2890..a95c055ab75 100644 --- a/app/src/components/ChangePipette/LevelPipette.js +++ b/app/src/components/ChangePipette/LevelPipette.js @@ -2,9 +2,17 @@ import * as React from 'react' import cx from 'classnames' +import { useSelector } from 'react-redux' -import { Icon, ModalPage, PrimaryButton } from '@opentrons/components' +import { + Icon, + ModalPage, + PrimaryBtn, + SecondaryBtn, + SPACING_2, +} from '@opentrons/components' import styles from './styles.css' +import { getFeatureFlags } from '../../config' import type { PipetteNameSpecs, @@ -13,9 +21,12 @@ import type { } from '@opentrons/shared-data' import type { Mount } from '../../pipettes/types' +import type { PipetteOffsetCalibration } from '../../calibration/types' // TODO: i18n const EXIT_BUTTON_MESSAGE = 'confirm pipette is leveled' +const EXIT_WITHOUT_CAL = 'exit without calibrating' +const CONTINUE_TO_PIP_OFFSET = 'continue to pipette offset calibration' const LEVEL_MESSAGE = (displayName: string) => `Next, level the ${displayName}` const CONNECTED_MESSAGE = (displayName: string) => `${displayName} connected` @@ -26,11 +37,13 @@ type Props = {| subtitle: string, wantedPipette: PipetteNameSpecs | null, actualPipette: PipetteModelSpecs | null, + actualPipetteOffset: PipetteOffsetCalibration | null, displayName: string, displayCategory: PipetteDisplayCategory | null, pipetteModelName: string, back: () => mixed, exit: () => mixed, + startPipetteOffsetCalibration: () => void, |} function Status(props: { displayName: string }) { @@ -48,14 +61,6 @@ function Status(props: { displayName: string }) { ) } -function ExitButton(props: { exit: () => mixed }) { - return ( - - {EXIT_BUTTON_MESSAGE} - - ) -} - function LevelingInstruction(props: { displayName: string }) { return (
@@ -88,10 +93,15 @@ export function LevelPipette(props: Props): React.Node { subtitle, pipetteModelName, displayName, + actualPipetteOffset, mount, back, exit, + startPipetteOffsetCalibration, } = props + + const ff = useSelector(getFeatureFlags) + return ( - + {ff.enableCalibrationOverhaul && !actualPipetteOffset && ( + + {CONTINUE_TO_PIP_OFFSET} + + )} + + {actualPipetteOffset ? EXIT_BUTTON_MESSAGE : EXIT_WITHOUT_CAL} + ) } diff --git a/app/src/components/ChangePipette/RequestInProgressModal.js b/app/src/components/ChangePipette/RequestInProgressModal.js index 8a7a4f841d7..55f4635f117 100644 --- a/app/src/components/ChangePipette/RequestInProgressModal.js +++ b/app/src/components/ChangePipette/RequestInProgressModal.js @@ -14,22 +14,28 @@ const CARRIAGE_MOVING = 'carriage moving' const TO_FRONT_AND_LEFT = 'to front and left' const TO_FRONT_AND_RIGHT = 'to front and right' const UP = 'up' +const ROBOT_IS_HOMING = 'Robot is homing' type Props = {| title: string, subtitle: string, mount: Mount, movementStatus: MovementStatus, + isPipetteHoming: boolean, |} export function RequestInProgressModal(props: Props): React.Node { - const { title, subtitle, mount, movementStatus } = props + const { title, subtitle, mount, movementStatus, isPipetteHoming } = props let message = `${mount === RIGHT ? RIGHT_PIP : LEFT_PIP} ${CARRIAGE_MOVING}` if (movementStatus === MOVING) { message += ` ${mount === RIGHT ? TO_FRONT_AND_LEFT : TO_FRONT_AND_RIGHT}.` } else if (movementStatus === HOMING) { - message += ` ${UP}.` + if (isPipetteHoming) { + message += ` ${UP}.` + } else { + message = ROBOT_IS_HOMING + } } return ( diff --git a/app/src/components/ChangePipette/constants.js b/app/src/components/ChangePipette/constants.js index a7966d8c382..f8fa8f1ceee 100644 --- a/app/src/components/ChangePipette/constants.js +++ b/app/src/components/ChangePipette/constants.js @@ -6,3 +6,4 @@ export const DETACH: 'detach' = 'detach' export const CLEAR_DECK: 'clearDeck' = 'clearDeck' export const INSTRUCTIONS: 'instructions' = 'instructions' export const CONFIRM: 'confirm' = 'confirm' +export const CALIBRATE_PIPETTE: 'calibratePipette' = 'calibratePipette' diff --git a/app/src/components/ChangePipette/index.js b/app/src/components/ChangePipette/index.js index e81fdb806bc..d3b780adc80 100644 --- a/app/src/components/ChangePipette/index.js +++ b/app/src/components/ChangePipette/index.js @@ -1,10 +1,15 @@ // @flow import * as React from 'react' import { useSelector, useDispatch } from 'react-redux' -import last from 'lodash/last' import { getPipetteNameSpecs, shouldLevel } from '@opentrons/shared-data' -import { useDispatchApiRequest, getRequestById, PENDING } from '../../robot-api' +import { + useDispatchApiRequests, + getRequestById, + SUCCESS, + PENDING, +} from '../../robot-api' +import { getCalibrationForPipette } from '../../calibration' import { getAttachedPipettes } from '../../pipettes' import { home, @@ -12,10 +17,13 @@ import { getMovementStatus, HOMING, MOVING, + ROBOT, PIPETTE, CHANGE_PIPETTE, + HOME, } from '../../robot-controls' +import { useCalibratePipetteOffset } from '../CalibratePipetteOffset/useCalibratePipetteOffset' import { ClearDeckAlertModal } from '../ClearDeckAlertModal' import { ExitAlertModal } from './ExitAlertModal' import { Instructions } from './Instructions' @@ -23,7 +31,14 @@ import { ConfirmPipette } from './ConfirmPipette' import { RequestInProgressModal } from './RequestInProgressModal' import { LevelPipette } from './LevelPipette' -import { ATTACH, DETACH, CLEAR_DECK, INSTRUCTIONS, CONFIRM } from './constants' +import { + ATTACH, + DETACH, + CLEAR_DECK, + INSTRUCTIONS, + CONFIRM, + CALIBRATE_PIPETTE, +} from './constants' import type { State, Dispatch } from '../../types' import type { Mount } from '../../robot/types' @@ -44,34 +59,56 @@ const MOUNT = 'mount' export function ChangePipette(props: Props): React.Node { const { robotName, mount, closeModal } = props const dispatch = useDispatch() - const [dispatchApiRequest, requestIds] = useDispatchApiRequest() + const homePipRequestId = React.useRef(null) + const [dispatchApiRequests] = useDispatchApiRequests(dispatchedAction => { + if ( + dispatchedAction.type === HOME && + dispatchedAction.payload.target === PIPETTE + ) { + // track final home pipette request, its success closes modal + homePipRequestId.current = dispatchedAction.meta.requestId + } + }) const [wizardStep, setWizardStep] = React.useState(CLEAR_DECK) const [wantedName, setWantedName] = React.useState(null) const [confirmExit, setConfirmExit] = React.useState(false) const wantedPipette = wantedName ? getPipetteNameSpecs(wantedName) : null - const actualPipette = useSelector((state: State) => { - return getAttachedPipettes(state, robotName)[mount]?.modelSpecs || null - }) + const attachedPipette = useSelector( + (state: State) => getAttachedPipettes(state, robotName)[mount] + ) + const actualPipette = attachedPipette?.modelSpecs || null + const actualPipetteOffset = useSelector((state: State) => + attachedPipette?.id + ? getCalibrationForPipette(state, robotName, attachedPipette.id) + : null + ) const movementStatus = useSelector((state: State) => { return getMovementStatus(state, robotName) }) - const homeRequest = useSelector((state: State) => { - return getRequestById(state, last(requestIds)) + const homePipStatus = useSelector((state: State) => { + return homePipRequestId.current + ? getRequestById(state, homePipRequestId.current) + : null })?.status React.useEffect(() => { - if (homeRequest && homeRequest !== PENDING) { + if (homePipStatus === SUCCESS) { closeModal() } - }, [homeRequest, closeModal]) + }, [homePipStatus, closeModal]) - const homeAndExit = React.useCallback( - () => dispatchApiRequest(home(robotName, PIPETTE, mount)), - [dispatchApiRequest, robotName, mount] + const homePipAndExit = React.useCallback( + () => dispatchApiRequests(home(robotName, PIPETTE, mount)), + [dispatchApiRequests, robotName, mount] ) + const [ + startPipetteOffsetCalibration, + PipetteOffsetCalibrationWizard, + ] = useCalibratePipetteOffset(robotName, { mount }, closeModal) + const baseProps = { title: PIPETTE_SETUP, subtitle: `${mount} ${MOUNT}`, @@ -83,7 +120,11 @@ export function ChangePipette(props: Props): React.Node { (movementStatus === HOMING || movementStatus === MOVING) ) { return ( - + ) } @@ -119,7 +160,7 @@ export function ChangePipette(props: Props): React.Node { {confirmExit && ( setConfirmExit(false)} - exit={homeAndExit} + exit={homePipAndExit} /> )} { + // home before cal flow to account for skips when attaching pipette + setWizardStep(CALIBRATE_PIPETTE) + dispatchApiRequests(home(robotName, ROBOT)) + startPipetteOffsetCalibration() + } + if (success && wantedPipette && shouldLevel(wantedPipette)) { return ( setWizardStep(INSTRUCTIONS), - exit: homeAndExit, + exit: homePipAndExit, + actualPipetteOffset: actualPipetteOffset, + startPipetteOffsetCalibration: launchPOC, }} /> ) @@ -167,13 +217,19 @@ export function ChangePipette(props: Props): React.Node { setWizardStep(INSTRUCTIONS) }, back: () => setWizardStep(INSTRUCTIONS), - exit: homeAndExit, + exit: homePipAndExit, + actualPipetteOffset: actualPipetteOffset, + startPipetteOffsetCalibration: launchPOC, }} /> ) } } + if (wizardStep === CALIBRATE_PIPETTE) { + return PipetteOffsetCalibrationWizard + } + // this will never be reached return null } diff --git a/app/src/components/ChangePipette/types.js b/app/src/components/ChangePipette/types.js index a7e68a0529f..29ddcc19cb8 100644 --- a/app/src/components/ChangePipette/types.js +++ b/app/src/components/ChangePipette/types.js @@ -2,4 +2,8 @@ export type Direction = 'attach' | 'detach' -export type WizardStep = 'clearDeck' | 'instructions' | 'confirm' +export type WizardStep = + | 'clearDeck' + | 'instructions' + | 'confirm' + | 'calibratePipette' diff --git a/app/src/components/InstrumentSettings/PipetteInfo.js b/app/src/components/InstrumentSettings/PipetteInfo.js index 5859bbeaec9..eb39f5a38c9 100644 --- a/app/src/components/InstrumentSettings/PipetteInfo.js +++ b/app/src/components/InstrumentSettings/PipetteInfo.js @@ -11,6 +11,7 @@ import { Box, Flex, Text, + SecondaryBtn, DIRECTION_COLUMN, SPACING_1, SPACING_2, @@ -26,7 +27,7 @@ import { JUSTIFY_START, } from '@opentrons/components' import styles from './styles.css' -import { PipetteOffsetCalibrationControl } from './PipetteOffsetCalibrationControl' +import { useCalibratePipetteOffset } from '../CalibratePipetteOffset/useCalibratePipetteOffset' import type { State } from '../../types' import { getCalibrationForPipette } from '../../calibration' @@ -49,6 +50,7 @@ const LABEL_BY_MOUNT = { const SERIAL_NUMBER = 'Serial number' const PIPETTE_OFFSET_MISSING = 'Pipette offset calibration missing.' const CALIBRATE_NOW = 'Please calibrate offset now.' +const CALIBRATE_OFFSET = 'Calibrate offset' export function PipetteInfo(props: PipetteInfoProps): React.Node { const { robotName, mount, pipette, changeUrl, settingsUrl } = props @@ -62,6 +64,12 @@ export function PipetteInfo(props: PipetteInfoProps): React.Node { ? getCalibrationForPipette(state, robotName, serialNumber) : null ) + + const [ + startPipetteOffsetCalibration, + PipetteOffsetCalibrationWizard, + ] = useCalibratePipetteOffset(robotName, { mount }) + const pipImage = ( )} {serialNumber && ( - + <> + + {CALIBRATE_OFFSET} + + {PipetteOffsetCalibrationWizard} + )} {serialNumber && !pipetteOffsetCalibration && (