From d0ff76cc502852bcd05a7f490cbd114e8128d942 Mon Sep 17 00:00:00 2001 From: Jamey Huffnagle Date: Fri, 22 Nov 2024 15:15:27 -0500 Subject: [PATCH] refactor(app): Use `currentState` for tip detection (#16904) Closes EXEC-1027 After completing a run or during Error Recovery, the app runs tip detection logic to determine whether or not to pop the drop tip wizard. It does so by iterating through protocol analysis, keeping track of attached pipettes, and looking at specific tip exchange commands to determine whether a not a pipette has tips attached. While this approach works, it's both brittle (any changes to tip exchange commands breaks the logic) and also a command scanning operation. Now that we have tip status on our new /runs/:runId/currentState endpoint, we have a more robust option for determining tip status. There's one caveat: the robot and this util do have ideas of when a tip should be considered attached. On the app, because we want to show drop tip wizard when there might be a tip attached, such as during a failed pick up tip command, we assume there is a tip attached. In this instance however, the robot server does not think a tip is attached. Unfortunately, there's no way around manually accounting for these differences, but luckily they are few, and it's still a significant improvement over tracking every tip exchange command. There's another optimization we can make too: we don't actually need to poll /instruments for pipette data, because now, the only place we use that data is during the tip detection util. Instead, we can fetch it along with the other necessary resources when determineTipStatus is invoked. In short, these changes give us: * Much easier to reason about tip detection logic. * Significantly less brittle. * No command scanning. * No /instruments polling. --- .../hooks/useRunHeaderDropTip.ts | 15 +- .../modals/ProtocolDropTipModal.tsx | 2 +- .../DropTipWizardFlows/TipsAttachedModal.tsx | 2 +- .../__tests__/TipsAttachedModal.test.tsx | 2 +- .../__tests__/useTipAttachmentStatus.test.tsx | 119 -------- .../DropTipWizardFlows/hooks/index.ts | 1 - .../getPipettesWithTipAttached.test.ts | 239 --------------- .../getPipettesWithTipAttached.ts | 157 ---------- .../hooks/useTipAttachmentStatus/index.ts | 118 -------- app/src/organisms/DropTipWizardFlows/index.ts | 2 - .../RecoveryOptions/ManageTips.tsx | 6 +- .../RecoveryOptions/SelectRecoveryOption.tsx | 2 +- .../hooks/useRecoveryTipStatus.ts | 8 +- app/src/pages/ODD/RunSummary/index.tsx | 9 +- .../__tests__/useTipAttachmentStatus.test.ts | 277 ++++++++++++++++++ app/src/resources/instruments/index.ts | 1 + .../instruments/useTipAttachmentStatus.ts | 235 +++++++++++++++ 17 files changed, 529 insertions(+), 666 deletions(-) delete mode 100644 app/src/organisms/DropTipWizardFlows/hooks/__tests__/useTipAttachmentStatus.test.tsx delete mode 100644 app/src/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/__tests__/getPipettesWithTipAttached.test.ts delete mode 100644 app/src/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/getPipettesWithTipAttached.ts delete mode 100644 app/src/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/index.ts create mode 100644 app/src/resources/instruments/__tests__/useTipAttachmentStatus.test.ts create mode 100644 app/src/resources/instruments/useTipAttachmentStatus.ts diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts index 48887d4ac17..687b0d404c5 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/hooks/useRunHeaderDropTip.ts @@ -1,13 +1,9 @@ import { useEffect } from 'react' -import { useHost } from '@opentrons/react-api-client' import { RUN_STATUS_IDLE, RUN_STATUS_STOPPED } from '@opentrons/api-client' import { FLEX_ROBOT_TYPE, OT2_ROBOT_TYPE } from '@opentrons/shared-data' -import { - useDropTipWizardFlows, - useTipAttachmentStatus, -} from '/app/organisms/DropTipWizardFlows' +import { useDropTipWizardFlows } from '/app/organisms/DropTipWizardFlows' import { useProtocolDropTipModal } from '../modals' import { useCloseCurrentRun, @@ -16,13 +12,12 @@ import { } from '/app/resources/runs' import { isTerminalRunStatus } from '../../utils' import { lastRunCommandPromptedErrorRecovery } from '/app/local-resources/commands' +import { useTipAttachmentStatus } from '/app/resources/instruments' import type { RobotType } from '@opentrons/shared-data' import type { Run, RunStatus } from '@opentrons/api-client' -import type { - DropTipWizardFlowsProps, - PipetteWithTip, -} from '/app/organisms/DropTipWizardFlows' +import type { PipetteWithTip } from '/app/resources/instruments' +import type { DropTipWizardFlowsProps } from '/app/organisms/DropTipWizardFlows' import type { UseProtocolDropTipModalResult } from '../modals' import type { PipetteDetails } from '/app/resources/maintenance_runs' @@ -49,7 +44,6 @@ export function useRunHeaderDropTip({ robotType, runStatus, }: UseRunHeaderDropTipParams): UseRunHeaderDropTipResult { - const host = useHost() const isRunCurrent = useIsRunCurrent(runId) const enteredER = runRecord?.data.hasEverEnteredErrorRecovery ?? false @@ -66,7 +60,6 @@ export function useRunHeaderDropTip({ } = useTipAttachmentStatus({ runId, runRecord: runRecord ?? null, - host, }) const dropTipModalUtils = useProtocolDropTipModal({ diff --git a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx index e1f1be57d22..b9f30de446f 100644 --- a/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx +++ b/app/src/organisms/Desktop/Devices/ProtocolRun/ProtocolRunHeader/RunHeaderModalContainer/modals/ProtocolDropTipModal.tsx @@ -21,7 +21,7 @@ import { useHomePipettes } from '/app/local-resources/instruments' import type { PipetteData } from '@opentrons/api-client' import type { IconProps } from '@opentrons/components' import type { UseHomePipettesProps } from '/app/local-resources/instruments' -import type { TipAttachmentStatusResult } from '/app/organisms/DropTipWizardFlows' +import type { TipAttachmentStatusResult } from '/app/resources/instruments' type UseProtocolDropTipModalProps = Pick< UseHomePipettesProps, diff --git a/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx b/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx index 7198d8bb5ea..86778afe97b 100644 --- a/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx +++ b/app/src/organisms/DropTipWizardFlows/TipsAttachedModal.tsx @@ -19,8 +19,8 @@ import { useHomePipettes } from '/app/local-resources/instruments' import type { HostConfig } from '@opentrons/api-client' import type { OddModalHeaderBaseProps } from '/app/molecules/OddModal/types' import type { UseHomePipettesProps } from '/app/local-resources/instruments' -import type { PipetteWithTip } from './hooks' import type { PipetteDetails } from '/app/resources/maintenance_runs' +import type { PipetteWithTip } from '/app/resources/instruments' type TipsAttachedModalProps = Pick & { aPipetteWithTip: PipetteWithTip diff --git a/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx b/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx index 917c770c10e..2a71920c4fc 100644 --- a/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx +++ b/app/src/organisms/DropTipWizardFlows/__tests__/TipsAttachedModal.test.tsx @@ -14,7 +14,7 @@ import { useDropTipWizardFlows } from '..' import type { Mock } from 'vitest' import type { PipetteModelSpecs } from '@opentrons/shared-data' import type { HostConfig } from '@opentrons/api-client' -import type { PipetteWithTip } from '../hooks' +import type { PipetteWithTip } from '/app/resources/instruments' vi.mock('/app/resources/runs/useCloseCurrentRun') vi.mock('..') diff --git a/app/src/organisms/DropTipWizardFlows/hooks/__tests__/useTipAttachmentStatus.test.tsx b/app/src/organisms/DropTipWizardFlows/hooks/__tests__/useTipAttachmentStatus.test.tsx deleted file mode 100644 index 6d9d25719d2..00000000000 --- a/app/src/organisms/DropTipWizardFlows/hooks/__tests__/useTipAttachmentStatus.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, renderHook } from '@testing-library/react' - -import { getPipetteModelSpecs } from '@opentrons/shared-data' -import { useInstrumentsQuery } from '@opentrons/react-api-client' - -import { mockPipetteInfo } from '/app/redux/pipettes/__fixtures__' -import { getPipettesWithTipAttached } from '../useTipAttachmentStatus/getPipettesWithTipAttached' -import { DropTipWizard } from '../../DropTipWizard' -import { useTipAttachmentStatus } from '../useTipAttachmentStatus' - -import type { Mock } from 'vitest' -import type { PipetteModelSpecs } from '@opentrons/shared-data' -import type { PipetteWithTip } from '../useTipAttachmentStatus' - -vi.mock('@opentrons/shared-data', async importOriginal => { - const actual = await importOriginal() - return { - ...actual, - getPipetteModelSpecs: vi.fn(), - } -}) -vi.mock('@opentrons/react-api-client') -vi.mock('../useTipAttachmentStatus/getPipettesWithTipAttached') -vi.mock('../../DropTipWizard') - -const MOCK_ACTUAL_PIPETTE = { - ...mockPipetteInfo.pipetteSpecs, - model: 'model', - tipLength: { - value: 20, - }, -} as PipetteModelSpecs - -const mockPipetteWithTip: PipetteWithTip = { - mount: 'left', - specs: MOCK_ACTUAL_PIPETTE, -} - -const mockSecondPipetteWithTip: PipetteWithTip = { - mount: 'right', - specs: MOCK_ACTUAL_PIPETTE, -} - -const mockPipettesWithTip: PipetteWithTip[] = [ - mockPipetteWithTip, - mockSecondPipetteWithTip, -] - -describe('useTipAttachmentStatus', () => { - let mockGetPipettesWithTipAttached: Mock - - beforeEach(() => { - mockGetPipettesWithTipAttached = vi.mocked(getPipettesWithTipAttached) - vi.mocked(getPipetteModelSpecs).mockReturnValue(MOCK_ACTUAL_PIPETTE) - vi.mocked(DropTipWizard).mockReturnValue(
MOCK DROP TIP WIZ
) - mockGetPipettesWithTipAttached.mockResolvedValue(mockPipettesWithTip) - vi.mocked(useInstrumentsQuery).mockReturnValue({ data: {} } as any) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('should return the correct initial state', () => { - const { result } = renderHook(() => useTipAttachmentStatus({} as any)) - - expect(result.current.areTipsAttached).toBe(false) - expect(result.current.aPipetteWithTip).toEqual(null) - }) - - it('should determine tip status and update state accordingly', async () => { - const { result } = renderHook(() => useTipAttachmentStatus({} as any)) - - await act(async () => { - await result.current.determineTipStatus() - }) - - expect(result.current.areTipsAttached).toBe(true) - expect(result.current.aPipetteWithTip).toEqual(mockPipetteWithTip) - }) - - it('should reset tip status', async () => { - const { result } = renderHook(() => useTipAttachmentStatus({} as any)) - - await act(async () => { - await result.current.determineTipStatus() - result.current.resetTipStatus() - }) - - expect(result.current.areTipsAttached).toBe(false) - expect(result.current.aPipetteWithTip).toEqual(null) - }) - - it('should set tip status resolved and update state', async () => { - const { result } = renderHook(() => useTipAttachmentStatus({} as any)) - - await act(async () => { - await result.current.determineTipStatus() - result.current.setTipStatusResolved() - }) - - expect(result.current.aPipetteWithTip).toEqual(mockSecondPipetteWithTip) - }) - - it('should call onEmptyCache callback when cache becomes empty', async () => { - mockGetPipettesWithTipAttached.mockResolvedValueOnce([mockPipetteWithTip]) - - const onEmptyCacheMock = vi.fn() - const { result } = renderHook(() => useTipAttachmentStatus({} as any)) - - await act(async () => { - await result.current.determineTipStatus() - result.current.setTipStatusResolved(onEmptyCacheMock) - }) - - expect(onEmptyCacheMock).toHaveBeenCalled() - }) -}) diff --git a/app/src/organisms/DropTipWizardFlows/hooks/index.ts b/app/src/organisms/DropTipWizardFlows/hooks/index.ts index 3f3f531a9d8..f3145d7d083 100644 --- a/app/src/organisms/DropTipWizardFlows/hooks/index.ts +++ b/app/src/organisms/DropTipWizardFlows/hooks/index.ts @@ -1,6 +1,5 @@ export * from './errors' export * from './useDropTipWithType' -export * from './useTipAttachmentStatus' export * from './useDropTipLocations' export { useDropTipRouting } from './useDropTipRouting' export { useDropTipWithType } from './useDropTipWithType' diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/__tests__/getPipettesWithTipAttached.test.ts b/app/src/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/__tests__/getPipettesWithTipAttached.test.ts deleted file mode 100644 index bdaeff2dee0..00000000000 --- a/app/src/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/__tests__/getPipettesWithTipAttached.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { describe, it, beforeEach, expect, vi } from 'vitest' -import { getCommands } from '@opentrons/api-client' - -import { getPipettesWithTipAttached } from '../getPipettesWithTipAttached' -import { LEFT, RIGHT } from '@opentrons/shared-data' - -import type { GetPipettesWithTipAttached } from '../getPipettesWithTipAttached' - -vi.mock('@opentrons/api-client') - -const HOST_NAME = 'localhost' -const RUN_ID = 'testRunId' -const LEFT_PIPETTE_ID = 'testId1' -const RIGHT_PIPETTE_ID = 'testId2' -const LEFT_PIPETTE_NAME = 'testLeftName' -const RIGHT_PIPETTE_NAME = 'testRightName' -const PICK_UP_TIP = 'pickUpTip' -const DROP_TIP = 'dropTip' -const DROP_TIP_IN_PLACE = 'dropTipInPlace' -const LOAD_PIPETTE = 'loadPipette' -const FIXIT_INTENT = 'fixit' - -const LEFT_PIPETTE = { - mount: LEFT, - state: { tipDetected: true }, - instrumentType: 'pipette', - ok: true, -} -const RIGHT_PIPETTE = { - mount: RIGHT, - state: { tipDetected: true }, - instrumentType: 'pipette', - ok: true, -} - -const mockAttachedInstruments = { - data: [LEFT_PIPETTE, RIGHT_PIPETTE], - meta: { cursor: 0, totalLength: 2 }, -} - -const createMockCommand = ( - type: string, - id: string, - pipetteId: string, - status = 'succeeded' -) => ({ - id, - key: `${id}-key`, - commandType: type, - status, - params: { pipetteId }, -}) - -const mockCommands = { - data: [ - createMockCommand(LOAD_PIPETTE, 'load-left', LEFT_PIPETTE_ID), - createMockCommand(LOAD_PIPETTE, 'load-right', RIGHT_PIPETTE_ID), - createMockCommand(PICK_UP_TIP, 'pickup-left', LEFT_PIPETTE_ID), - createMockCommand(DROP_TIP, 'drop-left', LEFT_PIPETTE_ID, 'succeeded'), - ], - meta: { cursor: 0, totalLength: 4 }, -} - -const mockRunRecord = { - data: { - pipettes: [ - { id: LEFT_PIPETTE_ID, pipetteName: LEFT_PIPETTE_NAME, mount: LEFT }, - { id: RIGHT_PIPETTE_ID, pipetteName: RIGHT_PIPETTE_NAME, mount: RIGHT }, - ], - }, -} - -describe('getPipettesWithTipAttached', () => { - let DEFAULT_PARAMS: GetPipettesWithTipAttached - - beforeEach(() => { - DEFAULT_PARAMS = { - host: { hostname: HOST_NAME }, - runId: RUN_ID, - attachedInstruments: mockAttachedInstruments as any, - runRecord: mockRunRecord as any, - } - - vi.mocked(getCommands).mockResolvedValue({ - data: mockCommands, - } as any) - }) - - it('returns an empty array if attachedInstruments is null', async () => { - const params = { ...DEFAULT_PARAMS, attachedInstruments: null } - const result = await getPipettesWithTipAttached(params) - expect(result).toEqual([]) - }) - - it('returns an empty array if runRecord is null', async () => { - const params = { ...DEFAULT_PARAMS, runRecord: null } - const result = await getPipettesWithTipAttached(params) - expect(result).toEqual([]) - }) - - it('returns an empty array when no tips are attached according to protocol', async () => { - const mockCommandsWithoutAttachedTips = { - ...mockCommands, - data: [ - createMockCommand(LOAD_PIPETTE, 'load-left', LEFT_PIPETTE_ID), - createMockCommand(LOAD_PIPETTE, 'load-right', RIGHT_PIPETTE_ID), - createMockCommand(PICK_UP_TIP, 'pickup-left', LEFT_PIPETTE_ID), - createMockCommand(DROP_TIP, 'drop-left', LEFT_PIPETTE_ID, 'succeeded'), - ], - } - - vi.mocked(getCommands).mockResolvedValue({ - data: mockCommandsWithoutAttachedTips, - } as any) - - const result = await getPipettesWithTipAttached(DEFAULT_PARAMS) - expect(result).toEqual([]) - }) - - it('returns pipettes with protocol detected tip attachment', async () => { - const mockCommandsWithPickUpTip = { - ...mockCommands, - data: [ - ...mockCommands.data, - createMockCommand(PICK_UP_TIP, 'pickup-left-2', LEFT_PIPETTE_ID), - createMockCommand(PICK_UP_TIP, 'pickup-right', RIGHT_PIPETTE_ID), - ], - } - - vi.mocked(getCommands).mockResolvedValue({ - data: mockCommandsWithPickUpTip, - } as any) - - const result = await getPipettesWithTipAttached(DEFAULT_PARAMS) - expect(result).toEqual(mockAttachedInstruments.data) - }) - - it('always returns the left mount before the right mount if both pipettes have tips attached', async () => { - const mockCommandsWithPickUpTip = { - ...mockCommands, - data: [ - ...mockCommands.data, - createMockCommand(PICK_UP_TIP, 'pickup-right', RIGHT_PIPETTE_ID), - createMockCommand(PICK_UP_TIP, 'pickup-left-2', LEFT_PIPETTE_ID), - ], - } - - vi.mocked(getCommands).mockResolvedValue({ - data: mockCommandsWithPickUpTip, - } as any) - - const result = await getPipettesWithTipAttached(DEFAULT_PARAMS) - expect(result.length).toBe(2) - expect(result[0].mount).toEqual(LEFT) - expect(result[1].mount).toEqual(RIGHT) - }) - - it('does not return otherwise legitimate failed tip exchange commands if fixit intent tip commands are present and successful', async () => { - const mockCommandsWithFixit = { - ...mockCommands, - data: [ - ...mockCommands.data, - { - ...createMockCommand( - DROP_TIP_IN_PLACE, - 'fixit-drop', - LEFT_PIPETTE_ID - ), - intent: FIXIT_INTENT, - }, - ], - } - - vi.mocked(getCommands).mockResolvedValue({ - data: mockCommandsWithFixit, - } as any) - - const result = await getPipettesWithTipAttached(DEFAULT_PARAMS) - expect(result).toEqual([]) - }) - - it('considers a tip attached only if the last tip exchange command was pickUpTip', async () => { - const mockCommandsWithPickUpTip = { - ...mockCommands, - data: [ - ...mockCommands.data, - createMockCommand(PICK_UP_TIP, 'pickup-left-2', LEFT_PIPETTE_ID), - ], - } - - vi.mocked(getCommands).mockResolvedValue({ - data: mockCommandsWithPickUpTip, - } as any) - - const result = await getPipettesWithTipAttached(DEFAULT_PARAMS) - expect(result).toEqual([mockAttachedInstruments.data[0]]) - }) - - it('returns all valid attached pipettes when an error occurs', async () => { - vi.mocked(getCommands).mockRejectedValueOnce( - new Error('Example network error') - ) - - const result = await getPipettesWithTipAttached(DEFAULT_PARAMS) - - expect(result).toEqual([LEFT_PIPETTE, RIGHT_PIPETTE]) - }) - - it('filters out not ok pipettes', async () => { - vi.mocked(getCommands).mockRejectedValueOnce(new Error('Network error')) - - const mockInvalidPipettes = { - data: [ - LEFT_PIPETTE, - { - ...RIGHT_PIPETTE, - ok: false, - }, - ], - meta: { cursor: 0, totalLength: 2 }, - } - - const params = { - ...DEFAULT_PARAMS, - attachedInstruments: mockInvalidPipettes as any, - } - - const result = await getPipettesWithTipAttached(params) - - expect(result).toEqual([ - { - mount: LEFT, - state: { tipDetected: true }, - instrumentType: 'pipette', - ok: true, - }, - ]) - }) -}) diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/getPipettesWithTipAttached.ts b/app/src/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/getPipettesWithTipAttached.ts deleted file mode 100644 index b710ba5a810..00000000000 --- a/app/src/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/getPipettesWithTipAttached.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { getCommands } from '@opentrons/api-client' -import { LEFT } from '@opentrons/shared-data' - -import type { - HostConfig, - PipetteData, - Run, - CommandsData, - RunCommandSummary, - Instruments, -} from '@opentrons/api-client' -import type { - LoadedPipette, - PipettingRunTimeCommand, -} from '@opentrons/shared-data' - -export interface GetPipettesWithTipAttached { - host: HostConfig | null - runId: string - attachedInstruments: Instruments | null - runRecord: Run | null -} - -export function getPipettesWithTipAttached({ - host, - runId, - attachedInstruments, - runRecord, -}: GetPipettesWithTipAttached): Promise { - if (attachedInstruments == null || runRecord == null) { - return Promise.resolve([]) - } - - return ( - getCommandsExecutedDuringRun(host as HostConfig, runId) - .then(executedCmdData => - checkPipettesForAttachedTips( - executedCmdData.data, - runRecord.data.pipettes, - attachedInstruments.data as PipetteData[] - ) - ) - // If any network error occurs, return all attached pipettes as having tips attached for safety reasons. - .catch(() => Promise.resolve(getPipettesDataFrom(attachedInstruments))) - ) -} - -function getCommandsExecutedDuringRun( - host: HostConfig, - runId: string -): Promise { - return getCommands(host, runId, { - cursor: null, - pageLength: 0, - includeFixitCommands: true, - }).then(response => { - const { totalLength } = response.data.meta - return getCommands(host, runId, { - cursor: 0, - pageLength: totalLength, - includeFixitCommands: null, - }).then(response => response.data) - }) -} - -const TIP_EXCHANGE_COMMAND_TYPES = [ - 'dropTip', - 'dropTipInPlace', - 'pickUpTip', - 'moveToAddressableAreaForDropTip', -] - -function checkPipettesForAttachedTips( - commands: RunCommandSummary[], - pipettesUsedInRun: LoadedPipette[], - attachedPipettes: PipetteData[] -): PipetteData[] { - let pipettesWithUnknownTipStatus = pipettesUsedInRun - const mountsWithTipAttached: Array = [] - - // Iterate backwards through commands, finding first tip exchange command for each pipette. - // If there's a chance the tip is still attached, flag the pipette. - for (let i = commands.length - 1; i >= 0; i--) { - if (pipettesWithUnknownTipStatus.length === 0) { - break - } - - const commandType = commands[i].commandType - const pipetteUsedInCommand = (commands[i] as PipettingRunTimeCommand).params - .pipetteId - const isTipExchangeCommand = TIP_EXCHANGE_COMMAND_TYPES.includes( - commandType - ) - const pipetteUsedInCommandWithUnknownTipStatus = - pipettesWithUnknownTipStatus.find( - pipette => pipette.id === pipetteUsedInCommand - ) ?? null - - // If the currently iterated command is a fixit command, we can safely assume the user - // had the option to fix pipettes with tips in this command and all commands - // earlier in the run, during Error Recovery flows. - if ( - commands[i].intent === 'fixit' && - isTipExchangeCommand && - commands[i].status === 'succeeded' - ) { - break - } - - if ( - isTipExchangeCommand && - pipetteUsedInCommandWithUnknownTipStatus != null - ) { - const tipPossiblyAttached = - commands[i].status !== 'succeeded' || commandType === 'pickUpTip' - - if (tipPossiblyAttached) { - mountsWithTipAttached.push( - pipetteUsedInCommandWithUnknownTipStatus.mount - ) - } - pipettesWithUnknownTipStatus = pipettesWithUnknownTipStatus.filter( - pipette => pipette.id !== pipetteUsedInCommand - ) - } - } - - // Convert the array of mounts with attached tips to PipetteData with attached tips. - const pipettesWithTipAttached = attachedPipettes.filter( - attachedPipette => - mountsWithTipAttached.includes(attachedPipette.mount) && - attachedPipette.ok - ) - - // Preferentially assign the left mount as the first element. - if ( - pipettesWithTipAttached.length === 2 && - pipettesWithTipAttached[1].mount === LEFT - ) { - ;[pipettesWithTipAttached[0], pipettesWithTipAttached[1]] = [ - pipettesWithTipAttached[1], - pipettesWithTipAttached[0], - ] - } - - return pipettesWithTipAttached -} - -function getPipettesDataFrom( - attachedInstruments: Instruments | null -): PipetteData[] { - return attachedInstruments != null - ? (attachedInstruments.data.filter( - instrument => instrument.instrumentType === 'pipette' && instrument.ok - ) as PipetteData[]) - : [] -} diff --git a/app/src/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/index.ts b/app/src/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/index.ts deleted file mode 100644 index 99d4ea9abd8..00000000000 --- a/app/src/organisms/DropTipWizardFlows/hooks/useTipAttachmentStatus/index.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { useState, useCallback } from 'react' -import head from 'lodash/head' - -import { useInstrumentsQuery } from '@opentrons/react-api-client' -import { getPipetteModelSpecs } from '@opentrons/shared-data' - -import { getPipettesWithTipAttached } from './getPipettesWithTipAttached' - -import type { Mount } from '@opentrons/api-client' -import type { PipetteModelSpecs } from '@opentrons/shared-data' -import type { GetPipettesWithTipAttached } from './getPipettesWithTipAttached' - -const INSTRUMENTS_POLL_MS = 5000 - -export interface PipetteWithTip { - mount: Mount - specs: PipetteModelSpecs -} - -export interface TipAttachmentStatusResult { - /** Updates the pipettes with tip cache. Determine whether tips are likely attached on one or more pipettes. - * - * NOTE: Use responsibly! This function can potentially (but not likely) iterate over the entire length of a protocol run. - * */ - determineTipStatus: () => Promise - /* Whether tips are likely attached on *any* pipette. Typically called after determineTipStatus() */ - areTipsAttached: boolean - /* Resets the cached pipettes with tip statuses to null. */ - resetTipStatus: () => void - /** Removes the first element from the tip attached cache if present. - * @param {Function} onEmptyCache After removing the pipette from the cache, if the attached tip cache is empty, invoke this callback. - * @param {Function} onTipsDetected After removing the pipette from the cache, if the attached tip cache is not empty, invoke this callback. - * */ - setTipStatusResolved: ( - onEmptyCache?: () => void, - onTipsDetected?: () => void - ) => Promise - /* Relevant pipette information for a pipette with a tip attached. If both pipettes have tips attached, return the left pipette. */ - aPipetteWithTip: PipetteWithTip | null - /* The initial number of pipettes with tips. Null if there has been no tip check yet. */ - initialPipettesWithTipsCount: number | null -} - -// Returns various utilities for interacting with the cache of pipettes with tips attached. -export function useTipAttachmentStatus( - params: Omit -): TipAttachmentStatusResult { - const [pipettesWithTip, setPipettesWithTip] = useState([]) - const [initialPipettesCount, setInitialPipettesCount] = useState< - number | null - >(null) - const { data: attachedInstruments } = useInstrumentsQuery({ - refetchInterval: INSTRUMENTS_POLL_MS, - }) - - const aPipetteWithTip = head(pipettesWithTip) ?? null - const areTipsAttached = - pipettesWithTip.length > 0 && head(pipettesWithTip)?.specs != null - - const determineTipStatus = useCallback((): Promise => { - return getPipettesWithTipAttached({ - ...params, - attachedInstruments: attachedInstruments ?? null, - }).then(pipettesWithTip => { - const pipettesWithTipsData = pipettesWithTip.map(pipette => { - const specs = getPipetteModelSpecs(pipette.instrumentModel) - return { - specs, - mount: pipette.mount, - } - }) - const pipettesWithTipAndSpecs = pipettesWithTipsData.filter( - pipette => pipette.specs != null - ) as PipetteWithTip[] - - setPipettesWithTip(pipettesWithTipAndSpecs) - // Set only once. - if (initialPipettesCount === null) { - setInitialPipettesCount(pipettesWithTipAndSpecs.length) - } - - return Promise.resolve(pipettesWithTipAndSpecs) - }) - }, [params]) - - const resetTipStatus = (): void => { - setPipettesWithTip([]) - setInitialPipettesCount(null) - } - - const setTipStatusResolved = ( - onEmptyCache?: () => void, - onTipsDetected?: () => void - ): Promise => { - return new Promise(resolve => { - setPipettesWithTip(prevPipettesWithTip => { - const newState = [...prevPipettesWithTip.slice(1)] - if (newState.length === 0) { - onEmptyCache?.() - } else { - onTipsDetected?.() - } - - resolve(newState[0]) - return newState - }) - }) - } - - return { - areTipsAttached, - determineTipStatus, - resetTipStatus, - aPipetteWithTip, - setTipStatusResolved, - initialPipettesWithTipsCount: initialPipettesCount, - } -} diff --git a/app/src/organisms/DropTipWizardFlows/index.ts b/app/src/organisms/DropTipWizardFlows/index.ts index 1b53f36e5c8..05a16f92e49 100644 --- a/app/src/organisms/DropTipWizardFlows/index.ts +++ b/app/src/organisms/DropTipWizardFlows/index.ts @@ -1,6 +1,4 @@ export * from './DropTipWizardFlows' -export { useTipAttachmentStatus } from './hooks' export * from './TipsAttachedModal' -export type { TipAttachmentStatusResult, PipetteWithTip } from './hooks' export type { FixitCommandTypeUtils } from './types' diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx index 57eef74d2d6..1609acfa0ca 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/ManageTips.tsx @@ -24,10 +24,8 @@ import { DT_ROUTES } from '/app/organisms/DropTipWizardFlows/constants' import { SelectRecoveryOption } from './SelectRecoveryOption' import type { RecoveryContentProps, RecoveryRoute, RouteStep } from '../types' -import type { - FixitCommandTypeUtils, - PipetteWithTip, -} from '/app/organisms/DropTipWizardFlows' +import type { FixitCommandTypeUtils } from '/app/organisms/DropTipWizardFlows' +import type { PipetteWithTip } from '/app/resources/instruments' // The Drop Tip flow entry point. Includes entry from SelectRecoveryOption and CancelRun. export function ManageTips(props: RecoveryContentProps): JSX.Element { diff --git a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx index c44252e2da9..59888c39c42 100644 --- a/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx +++ b/app/src/organisms/ErrorRecoveryFlows/RecoveryOptions/SelectRecoveryOption.tsx @@ -20,7 +20,7 @@ import { import { RecoverySingleColumnContentWrapper } from '../shared' import type { ErrorKind, RecoveryContentProps, RecoveryRoute } from '../types' -import type { PipetteWithTip } from '/app/organisms/DropTipWizardFlows' +import type { PipetteWithTip } from '/app/resources/instruments' // The "home" route within Error Recovery. When a user completes a non-terminal flow or presses "Go back" enough // to escape the boundaries of any route, they will be redirected here. diff --git a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryTipStatus.ts b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryTipStatus.ts index 0a12b59d089..8db4af030ea 100644 --- a/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryTipStatus.ts +++ b/app/src/organisms/ErrorRecoveryFlows/hooks/useRecoveryTipStatus.ts @@ -1,9 +1,9 @@ import { useState } from 'react' import head from 'lodash/head' -import { useHost, useRunCurrentState } from '@opentrons/react-api-client' +import { useRunCurrentState } from '@opentrons/react-api-client' import { getPipetteModelSpecs } from '@opentrons/shared-data' -import { useTipAttachmentStatus } from '/app/organisms/DropTipWizardFlows' +import { useTipAttachmentStatus } from '/app/resources/instruments' import { ERROR_KINDS } from '/app/organisms/ErrorRecoveryFlows/constants' import { getErrorKind } from '/app/organisms/ErrorRecoveryFlows/utils' @@ -11,7 +11,7 @@ import type { Run, Instruments, PipetteData } from '@opentrons/api-client' import type { PipetteWithTip, TipAttachmentStatusResult, -} from '/app/organisms/DropTipWizardFlows' +} from '/app/resources/instruments' import type { ERUtilsProps } from '/app/organisms/ErrorRecoveryFlows/hooks/useERUtils' interface UseRecoveryTipStatusProps { @@ -38,11 +38,9 @@ export function useRecoveryTipStatus( failedCommandPipette, setFailedCommandPipette, ] = useState(null) - const host = useHost() const tipAttachmentStatusUtils = useTipAttachmentStatus({ ...props, - host, runRecord: props.runRecord ?? null, }) diff --git a/app/src/pages/ODD/RunSummary/index.tsx b/app/src/pages/ODD/RunSummary/index.tsx index ce7fd3c0ef6..98a53f591d4 100644 --- a/app/src/pages/ODD/RunSummary/index.tsx +++ b/app/src/pages/ODD/RunSummary/index.tsx @@ -67,15 +67,13 @@ import { EMPTY_TIMESTAMP, useCurrentRunCommands, } from '/app/resources/runs' -import { - useTipAttachmentStatus, - handleTipsAttachedModal, -} from '/app/organisms/DropTipWizardFlows' +import { handleTipsAttachedModal } from '/app/organisms/DropTipWizardFlows' import { lastRunCommandPromptedErrorRecovery } from '/app/local-resources/commands' +import { useTipAttachmentStatus } from '/app/resources/instruments' import type { IconName } from '@opentrons/components' import type { OnDeviceRouteParams } from '/app/App/types' -import type { PipetteWithTip } from '/app/organisms/DropTipWizardFlows' +import type { PipetteWithTip } from '/app/resources/instruments' export function RunSummary(): JSX.Element { const { runId } = useParams< @@ -236,7 +234,6 @@ export function RunSummary(): JSX.Element { } = useTipAttachmentStatus({ runId, runRecord: runRecord ?? null, - host, }) // Determine tip status on initial render only. Error Recovery always handles tip status, so don't show it twice. diff --git a/app/src/resources/instruments/__tests__/useTipAttachmentStatus.test.ts b/app/src/resources/instruments/__tests__/useTipAttachmentStatus.test.ts new file mode 100644 index 00000000000..6d0de5c6d05 --- /dev/null +++ b/app/src/resources/instruments/__tests__/useTipAttachmentStatus.test.ts @@ -0,0 +1,277 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { act, renderHook, waitFor } from '@testing-library/react' + +import { + getCommands, + getInstruments, + getRunCurrentState, +} from '@opentrons/api-client' +import { getPipetteModelSpecs } from '@opentrons/shared-data' +import { useHost } from '@opentrons/react-api-client' + +import { mockPipetteInfo } from '/app/redux/pipettes/__fixtures__' +import { useTipAttachmentStatus } from '../useTipAttachmentStatus' + +import type { PipetteModelSpecs } from '@opentrons/shared-data' +import type { PipetteData } from '@opentrons/api-client' + +vi.mock('@opentrons/shared-data', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + getPipetteModelSpecs: vi.fn(), + } +}) +vi.mock('@opentrons/api-client') +vi.mock('@opentrons/react-api-client') + +const MOCK_HOST = { ip: '1.2.3.4', port: 31950 } as any +const MOCK_RUN_ID = 'run-123' + +const MOCK_ACTUAL_PIPETTE = { + ...mockPipetteInfo.pipetteSpecs, + model: 'model', + tipLength: { + value: 20, + }, +} as PipetteModelSpecs + +const mockPipetteData: PipetteData = { + mount: 'left', + instrumentType: 'pipette', + instrumentModel: 'p1000_single_v3.6', + ok: true, +} as any + +const mockSecondPipetteData: PipetteData = { + ...mockPipetteData, + mount: 'right', +} + +const mockRunRecord = { + data: { + pipettes: [ + { id: 'pipette-1', mount: 'left' }, + { id: 'pipette-2', mount: 'right' }, + ], + }, +} as any + +const mockTipStates = { + 'pipette-1': { hasTip: true }, + 'pipette-2': { hasTip: true }, +} + +describe('useTipAttachmentStatus', () => { + beforeEach(() => { + vi.mocked(useHost).mockReturnValue(MOCK_HOST) + vi.mocked(getPipetteModelSpecs).mockReturnValue(MOCK_ACTUAL_PIPETTE) + + vi.mocked(getInstruments).mockResolvedValue({ + data: { data: [mockPipetteData, mockSecondPipetteData] }, + } as any) + + vi.mocked(getRunCurrentState).mockResolvedValue({ + data: { data: { tipStates: mockTipStates } }, + } as any) + + vi.mocked(getCommands).mockResolvedValue({ + data: { data: [{ commandType: 'mockType' }] }, + } as any) + }) + + const renderTipAttachmentStatus = () => { + return renderHook(() => + useTipAttachmentStatus({ + runId: MOCK_RUN_ID, + runRecord: mockRunRecord, + }) + ) + } + + it('should return the correct initial state', async () => { + const { result } = renderTipAttachmentStatus() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + expect(result.current.areTipsAttached).toBe(false) + expect(result.current.aPipetteWithTip).toEqual(null) + expect(result.current.initialPipettesWithTipsCount).toEqual(null) + }) + + it('should determine tip status and update state accordingly', async () => { + const { result } = renderTipAttachmentStatus() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + await act(async () => { + await result.current.determineTipStatus() + }) + + expect(result.current.areTipsAttached).toBe(true) + expect(result.current.aPipetteWithTip).toMatchObject({ + mount: 'left', + specs: MOCK_ACTUAL_PIPETTE, + }) + expect(result.current.initialPipettesWithTipsCount).toBe(2) + }) + + it('should handle network errors', async () => { + vi.mocked(getInstruments).mockRejectedValueOnce(new Error('Error')) + const { result } = renderTipAttachmentStatus() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + await act(async () => { + await result.current.determineTipStatus() + }) + + expect(result.current.areTipsAttached).toBe(false) + expect(result.current.aPipetteWithTip).toBeNull() + }) + + it('should reset tip status', async () => { + const { result } = renderTipAttachmentStatus() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + await act(async () => { + await result.current.determineTipStatus() + }) + + act(() => { + result.current.resetTipStatus() + }) + + expect(result.current.areTipsAttached).toBe(false) + expect(result.current.aPipetteWithTip).toEqual(null) + expect(result.current.initialPipettesWithTipsCount).toEqual(null) + }) + + it('should set tip status resolved and a state', async () => { + const { result } = renderTipAttachmentStatus() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + await act(async () => { + await result.current.determineTipStatus() + }) + + expect(result.current.aPipetteWithTip).toMatchObject({ + mount: 'left', + specs: MOCK_ACTUAL_PIPETTE, + }) + + act(() => { + result.current.setTipStatusResolved() + }) + + await waitFor(() => + expect(result.current.aPipetteWithTip?.mount).toBe('right') + ) + }) + + it('should call onEmptyCache callback when cache becomes empty', async () => { + vi.mocked(getRunCurrentState).mockResolvedValueOnce({ + data: { + data: { + tipStates: { + 'pipette-1': { hasTip: true }, + 'pipette-2': { hasTip: false }, + }, + }, + }, + } as any) + + const onEmptyCacheMock = vi.fn() + const { result } = renderTipAttachmentStatus() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + await act(async () => { + await result.current.determineTipStatus() + }) + + expect(result.current.aPipetteWithTip).toMatchObject({ + mount: 'left', + specs: MOCK_ACTUAL_PIPETTE, + }) + + act(() => { + result.current.setTipStatusResolved(onEmptyCacheMock) + }) + + await waitFor(() => { + expect(onEmptyCacheMock).toHaveBeenCalled() + }) + }) + + it('should handle tipPhysicallyMissing error by assuming tip is attached', async () => { + vi.mocked(getCommands).mockResolvedValueOnce({ + data: { + data: [ + { + error: { + errorType: 'tipPhysicallyMissing', + }, + }, + ], + }, + } as any) + + const { result } = renderTipAttachmentStatus() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + await act(async () => { + await result.current.determineTipStatus() + }) + + expect(result.current.areTipsAttached).toBe(true) + }) + + it('should call onTipsDetected callback when tips remain after resolution', async () => { + const onTipsDetectedMock = vi.fn() + const { result } = renderTipAttachmentStatus() + + await waitFor(() => { + expect(result.current).toBeDefined() + }) + + await act(async () => { + await result.current.determineTipStatus() + }) + + expect(result.current.aPipetteWithTip).toMatchObject({ + mount: 'left', + specs: MOCK_ACTUAL_PIPETTE, + }) + + act(() => { + result.current.setTipStatusResolved(undefined, onTipsDetectedMock) + }) + + await waitFor(() => { + expect(onTipsDetectedMock).toHaveBeenCalled() + }) + + expect(result.current.aPipetteWithTip).toMatchObject({ + mount: 'right', + specs: MOCK_ACTUAL_PIPETTE, + }) + }) +}) diff --git a/app/src/resources/instruments/index.ts b/app/src/resources/instruments/index.ts index 16fae1ecad8..d88a2c7215f 100644 --- a/app/src/resources/instruments/index.ts +++ b/app/src/resources/instruments/index.ts @@ -1,3 +1,4 @@ export * from './useAttachedPipettes' export * from './useAttachedPipetteCalibrations' export * from './useAttachedPipettesFromInstrumentsQuery' +export * from './useTipAttachmentStatus' diff --git a/app/src/resources/instruments/useTipAttachmentStatus.ts b/app/src/resources/instruments/useTipAttachmentStatus.ts new file mode 100644 index 00000000000..ee0d6449ea6 --- /dev/null +++ b/app/src/resources/instruments/useTipAttachmentStatus.ts @@ -0,0 +1,235 @@ +import { useState, useCallback } from 'react' +import head from 'lodash/head' + +import { useHost } from '@opentrons/react-api-client' +import { + getCommands, + getInstruments, + getRunCurrentState, +} from '@opentrons/api-client' +import { getPipetteModelSpecs } from '@opentrons/shared-data' + +import type { + HostConfig, + Mount, + PipetteData, + Run, + RunCommandSummary, +} from '@opentrons/api-client' +import type { PipetteModelSpecs } from '@opentrons/shared-data' + +export interface PipetteWithTip { + mount: Mount + specs: PipetteModelSpecs +} + +export interface PipetteTipState { + specs: PipetteModelSpecs | null + mount: Mount + hasTip: boolean +} + +export interface TipAttachmentStatusParams { + runId: string + runRecord: Run | null +} + +export interface TipAttachmentStatusResult { + /** Updates the pipettes with tip cache. Determine whether tips are likely attached on one or more pipettes, assuming + * tips are attached when there's uncertainty. + * + * NOTE: This function makes a few network requests on each invocation! + * */ + determineTipStatus: () => Promise + /* Whether tips are likely attached on *any* pipette. Typically called after determineTipStatus() */ + areTipsAttached: boolean + /* Resets the cached pipettes with tip statuses to null. */ + resetTipStatus: () => void + /** Removes the first element from the tip attached cache if present. + * @param {Function} onEmptyCache After removing the pipette from the cache, if the attached tip cache is empty, invoke this callback. + * @param {Function} onTipsDetected After removing the pipette from the cache, if the attached tip cache is not empty, invoke this callback. + * */ + setTipStatusResolved: ( + onEmptyCache?: () => void, + onTipsDetected?: () => void + ) => Promise + /* Relevant pipette information for a pipette with a tip attached. If both pipettes have tips attached, return the left pipette. */ + aPipetteWithTip: PipetteWithTip | null + /* The initial number of pipettes with tips. Null if there has been no tip check yet. */ + initialPipettesWithTipsCount: number | null +} + +// Returns various utilities for interacting with the cache of pipettes with tips attached. +export function useTipAttachmentStatus( + params: TipAttachmentStatusParams +): TipAttachmentStatusResult { + const { runId, runRecord } = params + const host = useHost() + const [pipettesWithTip, setPipettesWithTip] = useState([]) + const [initialPipettesCount, setInitialPipettesCount] = useState< + number | null + >(null) + + const aPipetteWithTip = head(pipettesWithTip) ?? null + const areTipsAttached = + pipettesWithTip.length > 0 && head(pipettesWithTip)?.specs != null + + const determineTipStatus = useCallback((): Promise => { + return Promise.all([ + getInstruments(host as HostConfig), + getRunCurrentState(host as HostConfig, runId), + getCommands(host as HostConfig, runId, { + includeFixitCommands: false, + pageLength: 1, + cursor: null, + }), + ]) + .then(([attachedInstruments, currentState, commandsData]) => { + const { tipStates } = currentState.data.data + + const pipetteInfo = validatePipetteInfo( + attachedInstruments?.data.data as PipetteData[] + ) + + const pipetteInfoById = createPipetteInfoById(runRecord, pipetteInfo) + const pipettesWithTipsData = getPipettesWithTipsData( + // eslint-disable-next-line + tipStates, + pipetteInfoById, + commandsData.data.data as RunCommandSummary[] + ) + const pipettesWithTipAndSpecs = filterPipettesWithTips( + pipettesWithTipsData + ) + + setPipettesWithTip(pipettesWithTipAndSpecs) + + if (initialPipettesCount === null) { + setInitialPipettesCount(pipettesWithTipAndSpecs.length) + } + + return Promise.resolve(pipettesWithTipAndSpecs) + }) + .catch(e => { + console.error(`Error during tip status check: ${e.message}`) + return Promise.resolve([]) + }) + }, [host, initialPipettesCount, runId, runRecord]) + + const resetTipStatus = (): void => { + setPipettesWithTip([]) + setInitialPipettesCount(null) + } + + const setTipStatusResolved = ( + onEmptyCache?: () => void, + onTipsDetected?: () => void + ): Promise => { + return new Promise(resolve => { + setPipettesWithTip(prevPipettesWithTip => { + const newState = [...prevPipettesWithTip.slice(1)] + if (newState.length === 0) { + onEmptyCache?.() + } else { + onTipsDetected?.() + } + + resolve(newState[0]) + return newState + }) + }) + } + + return { + areTipsAttached, + determineTipStatus, + resetTipStatus, + aPipetteWithTip, + setTipStatusResolved, + initialPipettesWithTipsCount: initialPipettesCount, + } +} + +// Return good pipettes from instrument data. +const validatePipetteInfo = ( + attachedInstruments: PipetteData[] | null +): PipetteData[] => { + const goodPipetteInfo = + attachedInstruments?.filter( + instr => instr.instrumentType === 'pipette' && instr.ok + ) ?? null + + if (goodPipetteInfo == null) { + throw new Error( + 'Attached instrument pipettes differ from current state pipettes.' + ) + } + + return goodPipetteInfo +} + +// Associate pipette info with a pipette id. +const createPipetteInfoById = ( + runRecord: Run | null, + pipetteInfo: PipetteData[] +): Record => { + const pipetteInfoById: Record = {} + + runRecord?.data.pipettes.forEach(p => { + const pipetteInfoForThisPipette = pipetteInfo.find( + goodPipette => p.mount === goodPipette.mount + ) + if (pipetteInfoForThisPipette != null) { + pipetteInfoById[p.id] = pipetteInfoForThisPipette + } + }) + + return pipetteInfoById +} + +const getPipettesWithTipsData = ( + tipStates: Record, + pipetteInfoById: Record, + commands: RunCommandSummary[] +): PipetteTipState[] => { + return Object.entries(tipStates).map(([pipetteId, tipInfo]) => { + const pipetteInfo = pipetteInfoById[pipetteId] + const specs = getPipetteModelSpecs(pipetteInfo.instrumentModel) + return { + specs, + mount: pipetteInfo.mount, + hasTip: getMightHaveTipGivenCommands(Boolean(tipInfo.hasTip), commands), + } + }) +} + +const PICK_UP_TIP_COMMAND_TYPES: Array = [ + 'pickUpTip', +] as const + +// Sometimes, the robot and the tip status util have different ideas of when tips are attached. +// For example, if a pickUpTip command fails, the robot does not think a tip is attached. However, we want to be +// conservative and prompt drop tip wizard in case there are tips attached unexpectedly. +const getMightHaveTipGivenCommands = ( + hasTip: boolean, + commands: RunCommandSummary[] +): boolean => { + const lastRunProtocolCommand = commands[commands.length - 1] + + if ( + PICK_UP_TIP_COMMAND_TYPES.includes(lastRunProtocolCommand.commandType) || + lastRunProtocolCommand?.error?.errorType === 'tipPhysicallyMissing' + ) { + return true + } else { + return hasTip + } +} + +const filterPipettesWithTips = ( + pipettesWithTipsData: PipetteTipState[] +): PipetteWithTip[] => { + return pipettesWithTipsData.filter( + pipette => pipette.specs != null && pipette.hasTip + ) as PipetteWithTip[] +}