diff --git a/app/src/App/OnDeviceDisplayApp.tsx b/app/src/App/OnDeviceDisplayApp.tsx
index 1459ff5071f8..1439fc9de2c1 100644
--- a/app/src/App/OnDeviceDisplayApp.tsx
+++ b/app/src/App/OnDeviceDisplayApp.tsx
@@ -20,6 +20,7 @@ import { OnDeviceLocalizationProvider } from '../LocalizationProvider'
import { ToasterOven } from '../organisms/ToasterOven'
import { MaintenanceRunTakeover } from '../organisms/TakeoverModal'
import { FirmwareUpdateTakeover } from '../organisms/FirmwareUpdateModal/FirmwareUpdateTakeover'
+import { IncompatibleModuleTakeover } from '../organisms/IncompatibleModuleModal/IncompatibleModuleTakeover'
import { EstopTakeover } from '../organisms/EmergencyStop'
import { ConnectViaEthernet } from '../pages/ConnectViaEthernet'
import { ConnectViaUSB } from '../pages/ConnectViaUSB'
@@ -179,6 +180,7 @@ export const OnDeviceDisplayApp = (): JSX.Element => {
) : (
<>
+
diff --git a/app/src/assets/localization/en/incompatible_modules.json b/app/src/assets/localization/en/incompatible_modules.json
new file mode 100644
index 000000000000..547ac5eba587
--- /dev/null
+++ b/app/src/assets/localization/en/incompatible_modules.json
@@ -0,0 +1,4 @@
+{
+ "incompatible_modules_attached": "incompatible module detected",
+ "remove_before_running_protocol": "Remove the following hardware before running a protocol:"
+}
diff --git a/app/src/assets/localization/en/index.ts b/app/src/assets/localization/en/index.ts
index 51acf92db530..aef6c301f3ee 100644
--- a/app/src/assets/localization/en/index.ts
+++ b/app/src/assets/localization/en/index.ts
@@ -27,6 +27,7 @@ import robot_controls from './robot_controls.json'
import run_details from './run_details.json'
import top_navigation from './top_navigation.json'
import error_recovery from './error_recovery.json'
+import incompatible_modules from './incompatible_modules.json'
export const en = {
shared,
@@ -58,4 +59,5 @@ export const en = {
run_details,
top_navigation,
error_recovery,
+ incompatible_modules,
}
diff --git a/app/src/organisms/IncompatibleModuleModal/IncompatibleModuleModalBody.tsx b/app/src/organisms/IncompatibleModuleModal/IncompatibleModuleModalBody.tsx
new file mode 100644
index 000000000000..353abb7256e2
--- /dev/null
+++ b/app/src/organisms/IncompatibleModuleModal/IncompatibleModuleModalBody.tsx
@@ -0,0 +1,51 @@
+import * as React from 'react'
+import { useTranslation, Trans } from 'react-i18next'
+import capitalize from 'lodash/capitalize'
+import {
+ DIRECTION_COLUMN,
+ Flex,
+ SPACING,
+ StyledText,
+TYPOGRAPHY,
+ OVERFLOW_SCROLL,
+
+} from '@opentrons/components'
+import {
+ getModuleDisplayName
+} from '@opentrons/shared-data'
+import type { AttachedModule } from '@opentrons/api-client'
+import { Modal } from '../../molecules/Modal'
+import { ListItem } from '../../atoms/ListItem'
+import type { ModalHeaderBaseProps } from '../../molecules/Modal/types'
+
+export interface IncompatibleModuleModalBodyProps {
+ modules: AttachedModule[]
+}
+
+export function IncompatibleModuleModalBody(
+ props: IncompatibleModuleModalBodyProps
+): JSX.Element {
+ const { t } = useTranslation('incompatible_modules')
+ const incompatibleModuleHeader: ModalHeaderBaseProps = {
+ title: capitalize(t('incompatible_modules_attached')),
+ }
+ const { modules } = props
+ return (
+
+
+
+
+
+
+ {...modules.map(module => (
+
+
+ {getModuleDisplayName(module.moduleModel)}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/app/src/organisms/IncompatibleModuleModal/IncompatibleModuleTakeover.tsx b/app/src/organisms/IncompatibleModuleModal/IncompatibleModuleTakeover.tsx
new file mode 100644
index 000000000000..7faecaaa3d61
--- /dev/null
+++ b/app/src/organisms/IncompatibleModuleModal/IncompatibleModuleTakeover.tsx
@@ -0,0 +1,25 @@
+import * as React from 'react'
+import { createPortal } from 'react-dom'
+import { useTranslation, Trans } from 'react-i18next'
+import capitalize from 'lodash/capitalize'
+import { IncompatibleModuleModalBody } from './IncompatibleModuleModalBody'
+import { getTopPortalEl } from '../../App/portal'
+import { useIncompatibleModulesAttached } from './hooks'
+
+const POLL_INTERVAL_MS = 5000
+
+export function IncompatibleModuleTakeover(): JSX.Element {
+ const incompatibleModules = useIncompatibleModulesAttached({
+ refetchInterval: POLL_INTERVAL_MS,
+ })
+ return (
+ <>
+ {incompatibleModules.length !== 0
+ ? createPortal(
+ ,
+ getTopPortalEl()
+ )
+ : null}
+ >
+ )
+}
diff --git a/app/src/organisms/IncompatibleModuleModal/hooks/__fixtures__/incompatibleModuleFixtures.ts b/app/src/organisms/IncompatibleModuleModal/hooks/__fixtures__/incompatibleModuleFixtures.ts
new file mode 100644
index 000000000000..fbe7141b4cee
--- /dev/null
+++ b/app/src/organisms/IncompatibleModuleModal/hooks/__fixtures__/incompatibleModuleFixtures.ts
@@ -0,0 +1,386 @@
+export const mockModulesAllNotImplementedResponse = [
+ {
+ id: '3feb840a3fa2dac2409b977f1e330f54f50e6231',
+ serialNumber: 'dummySerialTC',
+ firmwareVersion: 'dummyVersionTC',
+ hardwareRevision: 'dummyModelTC',
+ hasAvailableUpdate: false,
+ moduleType: 'thermocyclerModuleType',
+ moduleModel: 'thermocyclerModuleV1',
+ data: {
+ status: 'holding at target',
+ currentTemperature: 3.0,
+ targetTemperature: 3.0,
+ lidStatus: 'open',
+ lidTemperature: 4.0,
+ lidTargetTemperature: 4.0,
+ holdTime: 121.0,
+ },
+ usbPort: {
+ port: 0,
+ path: '',
+ hub: false,
+ portGroup: 'unknown',
+ },
+ },
+ {
+ id: '8bcc37fdfcb4c2b5ab69963c589ceb1f9b1d1c4f',
+ serialNumber: 'dummySerialHS',
+ firmwareVersion: 'dummyVersionHS',
+ hardwareRevision: 'dummyModelHS',
+ hasAvailableUpdate: false,
+ moduleType: 'heaterShakerModuleType',
+ moduleModel: 'heaterShakerModuleV1',
+ data: {
+ status: 'idle',
+ labwareLatchStatus: 'idle_unknown',
+ speedStatus: 'idle',
+ currentSpeed: 0,
+ temperatureStatus: 'idle',
+ currentTemperature: 23.0,
+ targetSpeed: null,
+ },
+ usbPort: {
+ port: 0,
+ path: '',
+ hub: false,
+ portGroup: 'unknown',
+ },
+ },
+ {
+ id: '5fe40b412e39c6c079125b5dd4820ad8044e0962',
+ serialNumber: 'dummySerialTD',
+ firmwareVersion: 'dummyVersionTD',
+ hardwareRevision: 'temp_deck_v1.1',
+ hasAvailableUpdate: false,
+ moduleType: 'temperatureModuleType',
+ moduleModel: 'temperatureModuleV1',
+ data: {
+ status: 'holding at target',
+ currentTemperature: 3.0,
+ targetTemperature: 3.0,
+ },
+ usbPort: {
+ port: 0,
+ path: '',
+ hub: false,
+ portGroup: 'unknown',
+ },
+ },
+ {
+ id: '67a5b5118a952417b4aa47a62a96deccb13bed32',
+ serialNumber: 'dummySerialMD',
+ firmwareVersion: 'dummyVersionMD',
+ hardwareRevision: 'mag_deck_v1.1',
+ hasAvailableUpdate: false,
+ moduleType: 'magneticModuleType',
+ moduleModel: 'magneticModuleV1',
+ data: {
+ status: 'engaged',
+ engaged: true,
+ height: 4.0,
+ },
+ usbPort: {
+ port: 0,
+ path: '',
+ hub: false,
+ portGroup: 'unknown',
+ },
+ },
+]
+
+export const mockModulesAllCompatibleResponse = [
+ {
+ id: '3feb840a3fa2dac2409b977f1e330f54f50e6231',
+ serialNumber: 'dummySerialTC',
+ firmwareVersion: 'dummyVersionTC',
+ hardwareRevision: 'dummyModelTC',
+ hasAvailableUpdate: false,
+ moduleType: 'thermocyclerModuleType',
+ moduleModel: 'thermocyclerModuleV1',
+ compatibleWithRobot: true,
+ data: {
+ status: 'holding at target',
+ currentTemperature: 3.0,
+ targetTemperature: 3.0,
+ lidStatus: 'open',
+ lidTemperature: 4.0,
+ lidTargetTemperature: 4.0,
+ holdTime: 121.0,
+ },
+ usbPort: {
+ port: 0,
+ path: '',
+ hub: false,
+ portGroup: 'unknown',
+ },
+ },
+ {
+ id: '8bcc37fdfcb4c2b5ab69963c589ceb1f9b1d1c4f',
+ serialNumber: 'dummySerialHS',
+ firmwareVersion: 'dummyVersionHS',
+ hardwareRevision: 'dummyModelHS',
+ hasAvailableUpdate: false,
+ moduleType: 'heaterShakerModuleType',
+ moduleModel: 'heaterShakerModuleV1',
+ compatibleWithRobot: true,
+ data: {
+ status: 'idle',
+ labwareLatchStatus: 'idle_unknown',
+ speedStatus: 'idle',
+ currentSpeed: 0,
+ temperatureStatus: 'idle',
+ currentTemperature: 23.0,
+ targetSpeed: null,
+ },
+ usbPort: {
+ port: 0,
+ path: '',
+ hub: false,
+ portGroup: 'unknown',
+ },
+ },
+ {
+ id: '5fe40b412e39c6c079125b5dd4820ad8044e0962',
+ serialNumber: 'dummySerialTD',
+ firmwareVersion: 'dummyVersionTD',
+ hardwareRevision: 'temp_deck_v1.1',
+ hasAvailableUpdate: false,
+ moduleType: 'temperatureModuleType',
+ moduleModel: 'temperatureModuleV1',
+ compatibleWithRobot: true,
+ data: {
+ status: 'holding at target',
+ currentTemperature: 3.0,
+ targetTemperature: 3.0,
+ },
+ usbPort: {
+ port: 0,
+ path: '',
+ hub: false,
+ portGroup: 'unknown',
+ },
+ },
+ {
+ id: '67a5b5118a952417b4aa47a62a96deccb13bed32',
+ serialNumber: 'dummySerialMD',
+ firmwareVersion: 'dummyVersionMD',
+ hardwareRevision: 'mag_deck_v1.1',
+ hasAvailableUpdate: false,
+ moduleType: 'magneticModuleType',
+ moduleModel: 'magneticModuleV1',
+ compatibleWithRobot: true,
+ data: {
+ status: 'engaged',
+ engaged: true,
+ height: 4.0,
+ },
+ usbPort: {
+ port: 0,
+ path: '',
+ hub: false,
+ portGroup: 'unknown',
+ },
+ },
+]
+
+export const mockModulesWithOneIncompatibleResponse = [
+ {
+ id: '3feb840a3fa2dac2409b977f1e330f54f50e6231',
+ serialNumber: 'dummySerialTC',
+ firmwareVersion: 'dummyVersionTC',
+ hardwareRevision: 'dummyModelTC',
+ hasAvailableUpdate: false,
+ moduleType: 'thermocyclerModuleType',
+ moduleModel: 'thermocyclerModuleV1',
+ compatibleWithRobot: false,
+ data: {
+ status: 'holding at target',
+ currentTemperature: 3.0,
+ targetTemperature: 3.0,
+ lidStatus: 'open',
+ lidTemperature: 4.0,
+ lidTargetTemperature: 4.0,
+ holdTime: 121.0,
+ },
+ usbPort: {
+ port: 0,
+ path: '',
+ hub: false,
+ portGroup: 'unknown',
+ },
+ },
+ {
+ id: '8bcc37fdfcb4c2b5ab69963c589ceb1f9b1d1c4f',
+ serialNumber: 'dummySerialHS',
+ firmwareVersion: 'dummyVersionHS',
+ hardwareRevision: 'dummyModelHS',
+ hasAvailableUpdate: false,
+ moduleType: 'heaterShakerModuleType',
+ moduleModel: 'heaterShakerModuleV1',
+ compatibleWithRobot: true,
+ data: {
+ status: 'idle',
+ labwareLatchStatus: 'idle_unknown',
+ speedStatus: 'idle',
+ currentSpeed: 0,
+ temperatureStatus: 'idle',
+ currentTemperature: 23.0,
+ targetSpeed: null,
+ },
+ usbPort: {
+ port: 0,
+ path: '',
+ hub: false,
+ portGroup: 'unknown',
+ },
+ },
+ {
+ id: '5fe40b412e39c6c079125b5dd4820ad8044e0962',
+ serialNumber: 'dummySerialTD',
+ firmwareVersion: 'dummyVersionTD',
+ hardwareRevision: 'temp_deck_v1.1',
+ hasAvailableUpdate: false,
+ moduleType: 'temperatureModuleType',
+ moduleModel: 'temperatureModuleV1',
+ compatibleWithRobot: true,
+ data: {
+ status: 'holding at target',
+ currentTemperature: 3.0,
+ targetTemperature: 3.0,
+ },
+ usbPort: {
+ port: 0,
+ path: '',
+ hub: false,
+ portGroup: 'unknown',
+ },
+ },
+ {
+ id: '67a5b5118a952417b4aa47a62a96deccb13bed32',
+ serialNumber: 'dummySerialMD',
+ firmwareVersion: 'dummyVersionMD',
+ hardwareRevision: 'mag_deck_v1.1',
+ hasAvailableUpdate: false,
+ moduleType: 'magneticModuleType',
+ moduleModel: 'magneticModuleV1',
+ compatibleWithRobot: true,
+ data: {
+ status: 'engaged',
+ engaged: true,
+ height: 4.0,
+ },
+ usbPort: {
+ port: 0,
+ path: '',
+ hub: false,
+ portGroup: 'unknown',
+ },
+ },
+]
+
+export const v2MockModulesResponse = [
+ {
+ name: 'thermocycler',
+ displayName: 'thermocycler',
+ moduleModel: 'thermocyclerModuleV1',
+ port: '/dev/ot_module_sim_thermocycler0',
+ usbPort: {
+ port: 0,
+ hub: false,
+ portGroup: 'unknown',
+ path: '',
+ },
+ serial: 'dummySerialTC',
+ model: 'dummyModelTC',
+ revision: 'dummyModelTC',
+ fwVersion: 'dummyVersionTC',
+ hasAvailableUpdate: false,
+ status: 'holding at target',
+ data: {
+ lid: 'open',
+ lidTarget: 4.0,
+ lidTemp: 4.0,
+ currentTemp: 3.0,
+ targetTemp: 3.0,
+ holdTime: 121.0,
+ rampRate: null,
+ currentCycleIndex: null,
+ totalCycleCount: null,
+ currentStepIndex: null,
+ totalStepCount: null,
+ },
+ },
+ {
+ name: 'heatershaker',
+ displayName: 'heatershaker',
+ moduleModel: 'heaterShakerModuleV1',
+ port: '/dev/ot_module_sim_heatershaker1',
+ usbPort: {
+ hub: false,
+ portGroup: 'unknown',
+ path: '',
+ port: 0,
+ },
+ serial: 'dummySerialHS',
+ model: 'dummyModelHS',
+ revision: 'dummyModelHS',
+ fwVersion: 'dummyVersionHS',
+ hasAvailableUpdate: false,
+ status: 'idle',
+ data: {
+ labwareLatchStatus: 'idle_unknown',
+ speedStatus: 'idle',
+ temperatureStatus: 'idle',
+ currentSpeed: 0,
+ currentTemp: 23.0,
+ targetSpeed: null,
+ targetTemp: null,
+ errorDetails: null,
+ },
+ },
+ {
+ name: 'tempdeck',
+ displayName: 'tempdeck',
+ moduleModel: 'temperatureModuleV1',
+ port: '/dev/ot_module_sim_tempdeck2',
+ usbPort: {
+ hub: false,
+ portGroup: 'unknown',
+ path: '',
+ port: 0,
+ },
+ serial: 'dummySerialTD',
+ model: 'temp_deck_v1.1',
+ revision: 'temp_deck_v1.1',
+ fwVersion: 'dummyVersionTD',
+ hasAvailableUpdate: false,
+ status: 'holding at target',
+ data: {
+ currentTemp: 3.0,
+ targetTemp: 3.0,
+ },
+ },
+ {
+ name: 'magdeck',
+ displayName: 'magdeck',
+ moduleModel: 'magneticModuleV1',
+ port: '/dev/ot_module_sim_magdeck3',
+ usbPort: {
+ port: 0,
+ hub: false,
+ portGroup: 'unknown',
+ path: '',
+ },
+ serial: 'dummySerialMD',
+ model: 'mag_deck_v1.1',
+ revision: 'mag_deck_v1.1',
+ fwVersion: 'dummyVersionMD',
+ hasAvailableUpdate: false,
+ status: 'engaged',
+ data: {
+ engaged: true,
+ height: 4.0,
+ },
+ },
+]
diff --git a/app/src/organisms/IncompatibleModuleModal/hooks/__fixtures__/index.ts b/app/src/organisms/IncompatibleModuleModal/hooks/__fixtures__/index.ts
new file mode 100644
index 000000000000..fc83332dba79
--- /dev/null
+++ b/app/src/organisms/IncompatibleModuleModal/hooks/__fixtures__/index.ts
@@ -0,0 +1 @@
+export * from './incompatibleModuleFixtures'
diff --git a/app/src/organisms/IncompatibleModuleModal/hooks/__tests__/useIncompatibleModulesAttached.test.tsx b/app/src/organisms/IncompatibleModuleModal/hooks/__tests__/useIncompatibleModulesAttached.test.tsx
new file mode 100644
index 000000000000..f4c641c2a08e
--- /dev/null
+++ b/app/src/organisms/IncompatibleModuleModal/hooks/__tests__/useIncompatibleModulesAttached.test.tsx
@@ -0,0 +1,58 @@
+import * as React from 'react'
+import { QueryClient, QueryClientProvider } from 'react-query'
+
+import { vi, it, expect, describe, beforeEach } from 'vitest'
+import { renderHook } from '@testing-library/react'
+import { useModulesQuery } from '@opentrons/react-api-client'
+import { useIncompatibleModulesAttached } from '..'
+
+import * as Fixtures from '../__fixtures__'
+
+import type { HostConfig, Response, Modules } from '@opentrons/api-client'
+vi.mock('@opentrons/react-api-client')
+
+describe('useIncompatibleModulesAttached', () => {
+ let wrapper: React.FunctionComponent<
+ { children: React.ReactNode } & HostConfig
+ >
+ beforeEach(() => {
+ const queryClient = new QueryClient()
+ const clientProvider: React.FunctionComponent<{
+ children: React.ReactNode
+ }> = ({ children }) => (
+ {children}
+ )
+ wrapper = clientProvider
+ })
+ it('treats older endpoint responses as if the module were compatible', () => {
+ vi.mocked(useModulesQuery).mockReturnValue({
+ data: Fixtures.v2MockModulesResponse,
+ } as Response)
+ const { result } = renderHook(useIncompatibleModulesAttached, { wrapper })
+ expect(result.current).toHaveLength(0)
+ })
+ it('pulls incompatible modules out of endpoint responses', () => {
+ vi.mocked(useModulesQuery).mockReturnValue({
+ data: Fixtures.mockModulesWithOneIncompatibleResponse,
+ } as Response)
+ const { result } = renderHook(useIncompatibleModulesAttached, { wrapper })
+ expect(result.current).toHaveLength(1)
+ expect(result.current).toContain(
+ Fixtures.mockModulesWithOneIncompatibleResponse[0]
+ )
+ })
+ it('treats modules under new schema without compatibility as compatible', () => {
+ vi.mocked(useModulesQuery).mockReturnValue({
+ data: Fixtures.mockModulesAllNotImplementedResponse,
+ } as Response)
+ const { result } = renderHook(useIncompatibleModulesAttached, { wrapper })
+ expect(result.current).toHaveLength(0)
+ })
+ it('passes all compatible modules', () => {
+ vi.mocked(useModulesQuery).mockReturnValue({
+ data: Fixtures.mockModulesAllCompatibleResponse,
+ } as Response)
+ const { result } = renderHook(useIncompatibleModulesAttached, { wrapper })
+ expect(result.current).toHaveLength(0)
+ })
+})
diff --git a/app/src/organisms/IncompatibleModuleModal/hooks/index.ts b/app/src/organisms/IncompatibleModuleModal/hooks/index.ts
new file mode 100644
index 000000000000..9a5e19787476
--- /dev/null
+++ b/app/src/organisms/IncompatibleModuleModal/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useIncompatibleModulesAttached'
diff --git a/app/src/organisms/IncompatibleModuleModal/hooks/useIncompatibleModulesAttached.ts b/app/src/organisms/IncompatibleModuleModal/hooks/useIncompatibleModulesAttached.ts
new file mode 100644
index 000000000000..9fc0105aed0f
--- /dev/null
+++ b/app/src/organisms/IncompatibleModuleModal/hooks/useIncompatibleModulesAttached.ts
@@ -0,0 +1,15 @@
+import { useModulesQuery } from '@opentrons/react-api-client'
+import type { UseQueryOptions } from 'react-query'
+import type { AttachedModule, Modules, HostConfig } from '@opentrons/api-client'
+
+export function useIncompatibleModulesAttached(
+ options: UseQueryOptions = {},
+ hostOverride?: HostConfig | null
+): AttachedModule[] {
+ const attachedModulesResponse = useModulesQuery({ ...options })
+ return (
+ attachedModulesResponse.data?.data?.filter(
+ attachedModule => attachedModule?.compatibleWithRobot === false
+ ) || []
+ )
+}
diff --git a/app/src/organisms/IncompatibleModuleModal/index.tsx b/app/src/organisms/IncompatibleModuleModal/index.tsx
new file mode 100644
index 000000000000..e9866b2689cc
--- /dev/null
+++ b/app/src/organisms/IncompatibleModuleModal/index.tsx
@@ -0,0 +1 @@
+export * from './IncompatibleModuleTakeover'