diff --git a/api-client/src/modules/__fixtures__/index.ts b/api-client/src/modules/__fixtures__/index.ts
index abba8731561..52bb9dc57f0 100644
--- a/api-client/src/modules/__fixtures__/index.ts
+++ b/api-client/src/modules/__fixtures__/index.ts
@@ -7,6 +7,7 @@ export const mockModulesResponse = [
hasAvailableUpdate: false,
moduleType: 'thermocyclerModuleType',
moduleModel: 'thermocyclerModuleV1',
+ compatibleWithRobot: true,
data: {
status: 'holding at target',
currentTemperature: 3.0,
@@ -31,6 +32,7 @@ export const mockModulesResponse = [
hasAvailableUpdate: false,
moduleType: 'heaterShakerModuleType',
moduleModel: 'heaterShakerModuleV1',
+ compatibleWithRobot: true,
data: {
status: 'idle',
labwareLatchStatus: 'idle_unknown',
@@ -55,6 +57,7 @@ export const mockModulesResponse = [
hasAvailableUpdate: false,
moduleType: 'temperatureModuleType',
moduleModel: 'temperatureModuleV1',
+ compatibleWithRobot: true,
data: {
status: 'holding at target',
currentTemperature: 3.0,
@@ -75,6 +78,7 @@ export const mockModulesResponse = [
hasAvailableUpdate: false,
moduleType: 'magneticModuleType',
moduleModel: 'magneticModuleV1',
+ compatibleWithRobot: true,
data: {
status: 'engaged',
engaged: true,
diff --git a/api-client/src/modules/api-types.ts b/api-client/src/modules/api-types.ts
index 68c4f3f26e7..60203dba15e 100644
--- a/api-client/src/modules/api-types.ts
+++ b/api-client/src/modules/api-types.ts
@@ -36,6 +36,7 @@ export interface ApiBaseModule {
firmwareVersion: string
hasAvailableUpdate: boolean
usbPort: PhysicalPort
+ compatibleWithRobot?: boolean
moduleOffset?: ModuleOffset
}
diff --git a/app/src/App/DesktopApp.tsx b/app/src/App/DesktopApp.tsx
index ffa50727da1..c4b8aa6d6cb 100644
--- a/app/src/App/DesktopApp.tsx
+++ b/app/src/App/DesktopApp.tsx
@@ -28,6 +28,7 @@ import { Labware } from '../pages/Labware'
import { useSoftwareUpdatePoll } from './hooks'
import { Navbar } from './Navbar'
import { EstopTakeover, EmergencyStopContext } from '../organisms/EmergencyStop'
+import { IncompatibleModuleTakeover } from '../organisms/IncompatibleModule'
import { OPENTRONS_USB } from '../redux/discovery'
import { appShellRequestor } from '../redux/shell/remote'
import { useRobot, useIsFlex } from '../organisms/Devices/hooks'
@@ -153,10 +154,6 @@ function RobotControlTakeover(): JSX.Element | null {
const params = deviceRouteMatch?.params as DesktopRouteParams
const robotName = params?.robotName
const robot = useRobot(robotName)
- const isFlex = useIsFlex(robotName)
-
- // E-stop is not supported on OT2
- if (!isFlex) return null
if (deviceRouteMatch == null || robot == null || robotName == null)
return null
@@ -167,7 +164,29 @@ function RobotControlTakeover(): JSX.Element | null {
hostname={robot.ip ?? null}
requestor={robot?.ip === OPENTRONS_USB ? appShellRequestor : undefined}
>
-
+
+
)
}
+
+interface TakeoverProps {
+ robotName: string
+}
+
+function AllRobotsRobotControlTakeover({
+ robotName,
+}: TakeoverProps): JSX.Element | null {
+ return
+}
+
+function FlexOnlyRobotControlTakeover({
+ robotName,
+}: TakeoverProps): JSX.Element | null {
+ // E-stop is not supported on OT2
+ const isFlex = useIsFlex(robotName)
+ if (!isFlex) {
+ return null
+ }
+ return
+}
diff --git a/app/src/App/OnDeviceDisplayApp.tsx b/app/src/App/OnDeviceDisplayApp.tsx
index 1459ff5071f..43f93bd425b 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/IncompatibleModule'
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/App/portal.tsx b/app/src/App/portal.tsx
index 62f5d79fcf2..346d5842d81 100644
--- a/app/src/App/portal.tsx
+++ b/app/src/App/portal.tsx
@@ -1,8 +1,8 @@
import * as React from 'react'
import { Box } from '@opentrons/components'
-const TOP_PORTAL_ID = '__otAppTopPortalRoot'
-const MODAL_PORTAL_ID = '__otAppModalPortalRoot'
+export const TOP_PORTAL_ID = '__otAppTopPortalRoot'
+export const MODAL_PORTAL_ID = '__otAppModalPortalRoot'
export function getTopPortalEl(): HTMLElement {
return global.document.getElementById(TOP_PORTAL_ID) ?? global.document.body
}
@@ -11,9 +11,9 @@ export function getModalPortalEl(): HTMLElement {
}
export function PortalRoot(): JSX.Element {
- return
+ return
}
export function TopPortalRoot(): JSX.Element {
- return
+ return
}
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 00000000000..d9b1a231f0c
--- /dev/null
+++ b/app/src/assets/localization/en/incompatible_modules.json
@@ -0,0 +1,7 @@
+{
+ "incompatible_modules_attached": "incompatible module detected",
+ "remove_before_running_protocol": "Remove the following hardware before running a protocol:",
+ "needs_your_assistance": "{{robot_name}} needs your assistance",
+ "remove_before_using": "You must remove incompatible modules before using this robot.",
+ "is_not_compatible": "{{module_name}} is not compatible with the {{robot_type}}"
+}
diff --git a/app/src/assets/localization/en/index.ts b/app/src/assets/localization/en/index.ts
index 51acf92db53..aef6c301f3e 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/IncompatibleModule/IncompatibleModuleDesktopModalBody.tsx b/app/src/organisms/IncompatibleModule/IncompatibleModuleDesktopModalBody.tsx
new file mode 100644
index 00000000000..ac4c8c5993d
--- /dev/null
+++ b/app/src/organisms/IncompatibleModule/IncompatibleModuleDesktopModalBody.tsx
@@ -0,0 +1,92 @@
+import * as React from 'react'
+import { useTranslation, Trans } from 'react-i18next'
+import {
+ DIRECTION_COLUMN,
+ DIRECTION_ROW,
+ ALIGN_CENTER,
+ JUSTIFY_FLEX_START,
+ Flex,
+ SPACING,
+ StyledText,
+ TYPOGRAPHY,
+ OVERFLOW_SCROLL,
+ Icon,
+ COLORS,
+} from '@opentrons/components'
+import { getModuleDisplayName } from '@opentrons/shared-data'
+import type { AttachedModule } from '@opentrons/api-client'
+import { useIsFlex } from '../Devices/hooks'
+import { InterventionModal } from '../../molecules/InterventionModal'
+export interface IncompatibleModuleDesktopModalBodyProps {
+ modules: AttachedModule[]
+ robotName: string
+}
+
+export function IncompatibleModuleDesktopModalBody({
+ modules,
+ robotName,
+}: IncompatibleModuleDesktopModalBodyProps): JSX.Element {
+ const { t } = useTranslation('incompatible_modules')
+ const isFlex = useIsFlex(robotName)
+ const displayName = isFlex ? 'Flex' : 'OT-2'
+ return (
+
+ }
+ type="error"
+ >
+
+
+ {modules.map(module => (
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+ )
+}
diff --git a/app/src/organisms/IncompatibleModule/IncompatibleModuleODDModalBody.tsx b/app/src/organisms/IncompatibleModule/IncompatibleModuleODDModalBody.tsx
new file mode 100644
index 00000000000..fb4981c0c71
--- /dev/null
+++ b/app/src/organisms/IncompatibleModule/IncompatibleModuleODDModalBody.tsx
@@ -0,0 +1,55 @@
+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 IncompatibleModuleODDModalBodyProps {
+ modules: AttachedModule[]
+}
+
+export function IncompatibleModuleODDModalBody({
+ modules,
+}: IncompatibleModuleODDModalBodyProps): JSX.Element {
+ const { t } = useTranslation('incompatible_modules')
+ const incompatibleModuleHeader: ModalHeaderBaseProps = {
+ title: capitalize(t('incompatible_modules_attached')),
+ }
+ return (
+
+
+
+
+
+
+ {modules.map(module => (
+
+
+ {getModuleDisplayName(module.moduleModel)}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/app/src/organisms/IncompatibleModule/IncompatibleModuleTakeover.tsx b/app/src/organisms/IncompatibleModule/IncompatibleModuleTakeover.tsx
new file mode 100644
index 00000000000..3be98ad14fc
--- /dev/null
+++ b/app/src/organisms/IncompatibleModule/IncompatibleModuleTakeover.tsx
@@ -0,0 +1,39 @@
+import * as React from 'react'
+import { createPortal } from 'react-dom'
+import { IncompatibleModuleODDModalBody } from './IncompatibleModuleODDModalBody'
+import { IncompatibleModuleDesktopModalBody } from './IncompatibleModuleDesktopModalBody'
+import { getTopPortalEl, getModalPortalEl } from '../../App/portal'
+import { useIncompatibleModulesAttached } from './hooks'
+
+const POLL_INTERVAL_MS = 5000
+
+export interface IncompatibleModuleTakeoverProps {
+ isOnDevice: boolean
+ robotName?: string
+}
+
+export function IncompatibleModuleTakeover({
+ isOnDevice,
+ robotName,
+}: IncompatibleModuleTakeoverProps): JSX.Element | null {
+ const incompatibleModules = useIncompatibleModulesAttached({
+ refetchInterval: POLL_INTERVAL_MS,
+ })
+ if (incompatibleModules.length === 0) {
+ return null
+ }
+ if (isOnDevice) {
+ return createPortal(
+ ,
+ getTopPortalEl()
+ )
+ } else {
+ return createPortal(
+ ,
+ getModalPortalEl()
+ )
+ }
+}
diff --git a/app/src/organisms/IncompatibleModule/__fixtures__/index.ts b/app/src/organisms/IncompatibleModule/__fixtures__/index.ts
new file mode 100644
index 00000000000..35399d22450
--- /dev/null
+++ b/app/src/organisms/IncompatibleModule/__fixtures__/index.ts
@@ -0,0 +1,154 @@
+export const oneIncompatibleModule = [
+ {
+ 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',
+ },
+ },
+]
+export const manyIncompatibleModules = [
+ {
+ 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: 'aojfhkalshdaoahosifhoaisdada',
+ 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: 'asojhfaohsoihfjaoisodaalallala',
+ 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: 'sfaoisdfolasda09sd09aaaaaaaaaa',
+ 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: 'oasihfa980109109dm011',
+ 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',
+ },
+ },
+]
diff --git a/app/src/organisms/IncompatibleModule/__tests__/IncompatibleModuleDesktopModalBody.test.tsx b/app/src/organisms/IncompatibleModule/__tests__/IncompatibleModuleDesktopModalBody.test.tsx
new file mode 100644
index 00000000000..b3d7fc7bbf3
--- /dev/null
+++ b/app/src/organisms/IncompatibleModule/__tests__/IncompatibleModuleDesktopModalBody.test.tsx
@@ -0,0 +1,54 @@
+import React from 'react'
+import { screen } from '@testing-library/react'
+import { describe, it, beforeEach, vi } from 'vitest'
+import { when } from 'vitest-when'
+import '@testing-library/jest-dom/vitest'
+import { renderWithProviders } from '../../../__testing-utils__'
+import { i18n } from '../../../i18n'
+import { IncompatibleModuleDesktopModalBody } from '../IncompatibleModuleDesktopModalBody'
+import { useIsFlex } from '../../Devices/hooks'
+import * as Fixtures from '../__fixtures__'
+
+vi.mock('../../Devices/hooks')
+
+const getRenderer = (isFlex: boolean) => {
+ when(useIsFlex).calledWith('otie').thenReturn(isFlex)
+ return (
+ props: React.ComponentProps
+ ) => {
+ return renderWithProviders(
+ ,
+ {
+ i18nInstance: i18n,
+ }
+ )[0]
+ }
+}
+
+describe('IncompatibleModuleDesktopModalBody', () => {
+ let props: React.ComponentProps
+ beforeEach(() => {
+ props = {
+ modules: [],
+ robotName: 'otie',
+ }
+ })
+
+ it('should render i18nd footer text', () => {
+ props = { ...props, modules: Fixtures.oneIncompatibleModule as any }
+ getRenderer(true)(props)
+ screen.getByText(
+ 'You must remove incompatible modules before using this robot.'
+ )
+ screen.getByText('otie needs your assistance')
+ })
+ ;['Flex', 'OT-2'].forEach(robotKind =>
+ it(`should render a module card that says ${robotKind}`, () => {
+ props = { ...props, modules: Fixtures.oneIncompatibleModule as any }
+ getRenderer(robotKind === 'Flex')(props)
+ screen.getByText(
+ `Thermocycler Module GEN1 is not compatible with the ${robotKind}`
+ )
+ })
+ )
+})
diff --git a/app/src/organisms/IncompatibleModule/__tests__/IncompatibleModuleODDModalBody.test.tsx b/app/src/organisms/IncompatibleModule/__tests__/IncompatibleModuleODDModalBody.test.tsx
new file mode 100644
index 00000000000..ce63b26ed88
--- /dev/null
+++ b/app/src/organisms/IncompatibleModule/__tests__/IncompatibleModuleODDModalBody.test.tsx
@@ -0,0 +1,45 @@
+import React from 'react'
+import { screen } from '@testing-library/react'
+import { describe, it, beforeEach, expect } from 'vitest'
+import '@testing-library/jest-dom/vitest'
+import { renderWithProviders } from '../../../__testing-utils__'
+import { i18n } from '../../../i18n'
+import { IncompatibleModuleODDModalBody } from '../IncompatibleModuleODDModalBody'
+import * as Fixtures from '../__fixtures__'
+
+const render = (
+ props: React.ComponentProps
+) => {
+ return renderWithProviders(, {
+ i18nInstance: i18n,
+ })[0]
+}
+
+describe('IncompatibleModuleODDModalBody', () => {
+ let props: React.ComponentProps
+ beforeEach(() => {
+ props = {
+ modules: [],
+ }
+ })
+
+ it('should render i18nd header text', () => {
+ props = { ...props, modules: Fixtures.oneIncompatibleModule as any }
+ render(props)
+ screen.getByText('Incompatible module detected')
+ screen.getByText('Remove the following hardware before running a protocol:')
+ })
+
+ it('should render a module card', () => {
+ props = { ...props, modules: Fixtures.oneIncompatibleModule as any }
+ render(props)
+ screen.getByText('Thermocycler Module GEN1')
+ })
+
+ it('should overflow via scroll', () => {
+ props = { ...props, modules: Fixtures.manyIncompatibleModules as any }
+ render(props)
+ const labels = screen.getAllByText('Thermocycler Module GEN1')
+ expect(labels).toHaveLength(Fixtures.manyIncompatibleModules.length)
+ })
+})
diff --git a/app/src/organisms/IncompatibleModule/__tests__/IncompatibleModuleTakeover.test.tsx b/app/src/organisms/IncompatibleModule/__tests__/IncompatibleModuleTakeover.test.tsx
new file mode 100644
index 00000000000..d5c31e5cf3a
--- /dev/null
+++ b/app/src/organisms/IncompatibleModule/__tests__/IncompatibleModuleTakeover.test.tsx
@@ -0,0 +1,94 @@
+import React from 'react'
+import { screen, findByText } from '@testing-library/react'
+import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'
+import { when } from 'vitest-when'
+import '@testing-library/jest-dom/vitest'
+import { renderWithProviders } from '../../../__testing-utils__'
+import { i18n } from '../../../i18n'
+import { IncompatibleModuleTakeover } from '../IncompatibleModuleTakeover'
+import { IncompatibleModuleODDModalBody } from '../IncompatibleModuleODDModalBody'
+import { IncompatibleModuleDesktopModalBody } from '../IncompatibleModuleDesktopModalBody'
+import { useIncompatibleModulesAttached } from '../hooks'
+import type { AttachedModule } from '@opentrons/api-client'
+import {
+ PortalRoot,
+ TopPortalRoot,
+ MODAL_PORTAL_ID,
+ TOP_PORTAL_ID,
+} from '../../../App/portal'
+import * as Fixtures from '../__fixtures__'
+
+vi.mock('../hooks')
+vi.mock('../IncompatibleModuleODDModalBody')
+vi.mock('../IncompatibleModuleDesktopModalBody')
+
+const getRenderer = (incompatibleModules: AttachedModule[]) => {
+ when(useIncompatibleModulesAttached)
+ .calledWith(expect.anything())
+ .thenReturn(incompatibleModules)
+ vi.mocked(IncompatibleModuleODDModalBody).mockReturnValue(
+ TEST ELEMENT ODD
+ )
+ vi.mocked(IncompatibleModuleDesktopModalBody).mockReturnValue(
+ TEST ELEMENT DESKTOP
+ )
+ return (props: React.ComponentProps) => {
+ const [rendered] = renderWithProviders(
+ <>
+
+
+
+ >,
+ {
+ i18nInstance: i18n,
+ }
+ )
+ rendered.rerender(
+ <>
+
+
+
+ >
+ )
+ return rendered
+ }
+}
+
+describe('IncompatibleModuleTakeover', () => {
+ let props: React.ComponentProps
+ beforeEach(() => {
+ props = { isOnDevice: true }
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+ ;['desktop', 'odd'].forEach(target => {
+ it(`should render nothing on ${target} when no incompatible modules are attached`, () => {
+ getRenderer([])({ ...props, isOnDevice: target === 'odd' })
+ expect(screen.findByTestId(TOP_PORTAL_ID)).resolves.toBeEmptyDOMElement()
+ expect(
+ screen.findByTestId(MODAL_PORTAL_ID)
+ ).resolves.toBeEmptyDOMElement()
+ expect(screen.queryByText(/TEST ELEMENT/)).toBeNull()
+ })
+ })
+
+ it('should render the modal body on odd when incompatible modules are attached', async () => {
+ getRenderer(Fixtures.oneIncompatibleModule as any)({
+ ...props,
+ isOnDevice: true,
+ })
+ const container = await screen.findByTestId(TOP_PORTAL_ID)
+ await findByText(container, 'TEST ELEMENT ODD')
+ })
+
+ it('should render the modal body on desktop when incompatible modules are attached', async () => {
+ getRenderer(Fixtures.oneIncompatibleModule as any)({
+ ...props,
+ isOnDevice: false,
+ })
+ const container = await screen.findByTestId(MODAL_PORTAL_ID)
+ await findByText(container, 'TEST ELEMENT DESKTOP')
+ })
+})
diff --git a/app/src/organisms/IncompatibleModule/hooks/__fixtures__/incompatibleModuleFixtures.ts b/app/src/organisms/IncompatibleModule/hooks/__fixtures__/incompatibleModuleFixtures.ts
new file mode 100644
index 00000000000..fbe7141b4ce
--- /dev/null
+++ b/app/src/organisms/IncompatibleModule/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/IncompatibleModule/hooks/__fixtures__/index.ts b/app/src/organisms/IncompatibleModule/hooks/__fixtures__/index.ts
new file mode 100644
index 00000000000..fc83332dba7
--- /dev/null
+++ b/app/src/organisms/IncompatibleModule/hooks/__fixtures__/index.ts
@@ -0,0 +1 @@
+export * from './incompatibleModuleFixtures'
diff --git a/app/src/organisms/IncompatibleModule/hooks/__tests__/useIncompatibleModulesAttached.test.tsx b/app/src/organisms/IncompatibleModule/hooks/__tests__/useIncompatibleModulesAttached.test.tsx
new file mode 100644
index 00000000000..970805c95c6
--- /dev/null
+++ b/app/src/organisms/IncompatibleModule/hooks/__tests__/useIncompatibleModulesAttached.test.tsx
@@ -0,0 +1,72 @@
+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 { Modules } from '@opentrons/api-client'
+import type { UseQueryResult } from 'react-query'
+vi.mock('@opentrons/react-api-client')
+
+describe('useIncompatibleModulesAttached', () => {
+ let wrapper: React.FunctionComponent<{ children: React.ReactNode }>
+ 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: {
+ data: Fixtures.v2MockModulesResponse,
+ meta: {},
+ },
+ error: null,
+ } as any) as UseQueryResult)
+ const { result } = renderHook(useIncompatibleModulesAttached, { wrapper })
+ expect(result.current).toHaveLength(0)
+ })
+ it('pulls incompatible modules out of endpoint responses', () => {
+ vi.mocked(useModulesQuery).mockReturnValue(({
+ data: {
+ data: Fixtures.mockModulesWithOneIncompatibleResponse,
+ meta: {},
+ },
+ error: null,
+ } as any) as UseQueryResult)
+ 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: {
+ data: Fixtures.mockModulesAllNotImplementedResponse,
+ meta: {},
+ },
+ error: null,
+ } as any) as UseQueryResult)
+ const { result } = renderHook(useIncompatibleModulesAttached, { wrapper })
+ expect(result.current).toHaveLength(0)
+ })
+ it('passes all compatible modules', () => {
+ vi.mocked(useModulesQuery).mockReturnValue(({
+ data: {
+ data: Fixtures.mockModulesAllCompatibleResponse,
+ meta: {},
+ },
+ } as any) as UseQueryResult)
+ const { result } = renderHook(useIncompatibleModulesAttached, { wrapper })
+ expect(result.current).toHaveLength(0)
+ })
+})
diff --git a/app/src/organisms/IncompatibleModule/hooks/index.ts b/app/src/organisms/IncompatibleModule/hooks/index.ts
new file mode 100644
index 00000000000..9a5e1978747
--- /dev/null
+++ b/app/src/organisms/IncompatibleModule/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useIncompatibleModulesAttached'
diff --git a/app/src/organisms/IncompatibleModule/hooks/useIncompatibleModulesAttached.ts b/app/src/organisms/IncompatibleModule/hooks/useIncompatibleModulesAttached.ts
new file mode 100644
index 00000000000..f57ba37d69c
--- /dev/null
+++ b/app/src/organisms/IncompatibleModule/hooks/useIncompatibleModulesAttached.ts
@@ -0,0 +1,17 @@
+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 { data, error } = useModulesQuery({
+ ...options,
+ })
+ return error == null
+ ? data?.data.filter(
+ attachedModule => attachedModule?.compatibleWithRobot === false
+ ) || []
+ : []
+}
diff --git a/app/src/organisms/IncompatibleModule/index.tsx b/app/src/organisms/IncompatibleModule/index.tsx
new file mode 100644
index 00000000000..e9866b2689c
--- /dev/null
+++ b/app/src/organisms/IncompatibleModule/index.tsx
@@ -0,0 +1 @@
+export * from './IncompatibleModuleTakeover'
diff --git a/robot-server/robot_server/modules/module_data_mapper.py b/robot-server/robot_server/modules/module_data_mapper.py
index 4a501f2b32d..dafcf4e3fce 100644
--- a/robot-server/robot_server/modules/module_data_mapper.py
+++ b/robot-server/robot_server/modules/module_data_mapper.py
@@ -1,5 +1,8 @@
"""Module identification and response data mapping."""
from typing import Type, cast, Optional
+from fastapi import Depends
+
+from opentrons_shared_data.module import load_definition
from opentrons.hardware_control.modules import (
LiveData,
@@ -16,7 +19,7 @@
)
from opentrons.drivers.rpi_drivers.types import USBPort as HardwareUSBPort
-from opentrons.protocol_engine import ModuleModel
+from opentrons.protocol_engine import ModuleModel, DeckType
from .module_identifier import ModuleIdentity
from .module_models import (
@@ -34,10 +37,15 @@
UsbPort,
)
+from robot_server.hardware import get_deck_type
+
class ModuleDataMapper:
"""Map hardware control modules to module response."""
+ def __init__(self, deck_type: DeckType = Depends(get_deck_type)) -> None:
+ self.deck_type = deck_type
+
def map_data(
self,
model: str,
@@ -53,6 +61,7 @@ def map_data(
module_cls: Type[AttachedModule]
module_data: AttachedModuleData
+ module_definition = load_definition(model_or_loadname=model, version="3")
# rely on Pydantic to check/coerce data fields from dicts at run time
if module_type == ModuleType.MAGNETIC:
@@ -131,6 +140,9 @@ def map_data(
firmwareVersion=module_identity.firmware_version,
hardwareRevision=module_identity.hardware_revision,
hasAvailableUpdate=has_available_update,
+ compatibleWithRobot=(
+ not (self.deck_type.value in module_definition["incompatibleWithDecks"])
+ ),
usbPort=UsbPort(
port=usb_port.port_number,
portGroup=usb_port.port_group,
diff --git a/robot-server/robot_server/modules/module_models.py b/robot-server/robot_server/modules/module_models.py
index a82e941fbb6..f05e2ff0a99 100644
--- a/robot-server/robot_server/modules/module_models.py
+++ b/robot-server/robot_server/modules/module_models.py
@@ -90,6 +90,9 @@ class _GenericModule(GenericModel, Generic[ModuleT, ModuleModelT, ModuleDataT]):
moduleOffset: Optional[ModuleCalibrationData] = Field(
None, description="The calibrated module offset."
)
+ compatibleWithRobot: bool = Field(
+ ..., description="Whether the detected module is compatible with this robot."
+ )
data: ModuleDataT
usbPort: UsbPort
diff --git a/robot-server/tests/integration/test_modules.tavern.yaml b/robot-server/tests/integration/test_modules.tavern.yaml
index 815b736acf7..d08eabe1234 100644
--- a/robot-server/tests/integration/test_modules.tavern.yaml
+++ b/robot-server/tests/integration/test_modules.tavern.yaml
@@ -1,5 +1,5 @@
---
-test_name: Get modules
+test_name: Get modules OT2
marks:
- usefixtures:
- ot2_server_base_url
@@ -113,6 +113,7 @@ stages:
firmwareVersion: !anystr
hardwareRevision: !anystr
hasAvailableUpdate: !anybool
+ compatibleWithRobot: true
moduleType: thermocyclerModuleType
moduleModel: thermocyclerModuleV1
usbPort:
@@ -134,6 +135,7 @@ stages:
firmwareVersion: !anystr
hardwareRevision: !anystr
hasAvailableUpdate: !anybool
+ compatibleWithRobot: true
moduleType: heaterShakerModuleType
moduleModel: heaterShakerModuleV1
usbPort:
@@ -153,6 +155,7 @@ stages:
firmwareVersion: !anystr
hardwareRevision: !anystr
hasAvailableUpdate: !anybool
+ compatibleWithRobot: true
moduleType: temperatureModuleType
moduleModel: temperatureModuleV1
usbPort:
@@ -169,6 +172,7 @@ stages:
firmwareVersion: !anystr
hardwareRevision: !anystr
hasAvailableUpdate: !anybool
+ compatibleWithRobot: true
moduleType: magneticModuleType
moduleModel: magneticModuleV2
usbPort:
@@ -185,6 +189,7 @@ stages:
firmwareVersion: !anystr
hardwareRevision: !anystr
hasAvailableUpdate: !anybool
+ compatibleWithRobot: true
moduleType: magneticModuleType
moduleModel: magneticModuleV1
usbPort:
@@ -196,3 +201,95 @@ stages:
status: !anystr
height: !anyfloat
engaged: !anybool
+
+---
+test_name: Get modules on Flex
+marks:
+ - usefixtures:
+ - ot3_server_base_url
+stages:
+ - name: Get all the modules
+ request:
+ url: '{ot3_server_base_url}/modules'
+ method: GET
+ response:
+ status_code: 200
+ json:
+ meta: !anydict
+ data:
+ - id: !anystr
+ serialNumber: !anystr
+ firmwareVersion: !anystr
+ hardwareRevision: !anystr
+ hasAvailableUpdate: !anybool
+ compatibleWithRobot: true
+ moduleType: thermocyclerModuleType
+ moduleModel: thermocyclerModuleV2
+ usbPort:
+ port: !anyint
+ hub: !anybool
+ portGroup: !anystr
+ path: !anystr
+ data:
+ status: !anystr
+ lidStatus: !anystr
+ lidTemperatureStatus: !anystr
+ lidTargetTemperature: !anyfloat
+ lidTemperature: !anyfloat
+ currentTemperature: !anyfloat
+ targetTemperature: !anyfloat
+ holdTime: !anyfloat
+ - id: !anystr
+ serialNumber: !anystr
+ firmwareVersion: !anystr
+ hardwareRevision: !anystr
+ hasAvailableUpdate: !anybool
+ compatibleWithRobot: true
+ moduleType: heaterShakerModuleType
+ moduleModel: heaterShakerModuleV1
+ usbPort:
+ port: !anyint
+ hub: !anybool
+ portGroup: !anystr
+ path: !anystr
+ data:
+ status: !anystr
+ labwareLatchStatus: !anystr
+ speedStatus: !anystr
+ temperatureStatus: !anystr
+ currentSpeed: !anyint
+ currentTemperature: !anyfloat
+ - id: !anystr
+ serialNumber: !anystr
+ firmwareVersion: !anystr
+ hardwareRevision: !anystr
+ hasAvailableUpdate: !anybool
+ compatibleWithRobot: true
+ moduleType: temperatureModuleType
+ moduleModel: temperatureModuleV2
+ usbPort:
+ port: !anyint
+ hub: !anybool
+ portGroup: !anystr
+ path: !anystr
+ data:
+ status: !anystr
+ currentTemperature: !anyfloat
+ targetTemperature: !anyfloat
+ - id: !anystr
+ serialNumber: !anystr
+ firmwareVersion: !anystr
+ hardwareRevision: !anystr
+ hasAvailableUpdate: !anybool
+ compatibleWithRobot: true
+ moduleType: temperatureModuleType
+ moduleModel: temperatureModuleV2
+ usbPort:
+ port: !anyint
+ hub: !anybool
+ portGroup: !anystr
+ path: !anystr
+ data:
+ status: !anystr
+ currentTemperature: !anyfloat
+ targetTemperature: !anyfloat
diff --git a/robot-server/tests/modules/test_module_data_mapper.py b/robot-server/tests/modules/test_module_data_mapper.py
index 7eb50854428..62fa54e9a49 100644
--- a/robot-server/tests/modules/test_module_data_mapper.py
+++ b/robot-server/tests/modules/test_module_data_mapper.py
@@ -1,7 +1,7 @@
"""Tests for robot_server.modules.module_data_mapper."""
import pytest
-from opentrons.protocol_engine import ModuleModel
+from opentrons.protocol_engine import ModuleModel, DeckType
from opentrons.protocol_engine.types import Vec3f
from opentrons.drivers.rpi_drivers.types import USBPort as HardwareUSBPort, PortGroup
from opentrons.hardware_control.modules import (
@@ -31,50 +31,88 @@
@pytest.mark.parametrize(
- ("input_model", "input_data", "expected_output_data"),
+ (
+ "input_model",
+ "deck_type",
+ "input_data",
+ "expected_output_data",
+ "expected_compatible",
+ ),
[
(
"magneticModuleV1",
+ DeckType("ot2_standard"),
{"status": "disengaged", "data": {"engaged": False, "height": 0.0}},
MagneticModuleData(
status=MagneticStatus.DISENGAGED,
engaged=False,
height=-2.5,
),
+ True,
),
(
"magneticModuleV1",
+ DeckType("ot3_standard"),
+ {"status": "disengaged", "data": {"engaged": False, "height": 0.0}},
+ MagneticModuleData(
+ status=MagneticStatus.DISENGAGED,
+ engaged=False,
+ height=-2.5,
+ ),
+ False,
+ ),
+ (
+ "magneticModuleV1",
+ DeckType("ot2_standard"),
{"status": "engaged", "data": {"engaged": True, "height": 42}},
MagneticModuleData(
status=MagneticStatus.ENGAGED,
engaged=True,
height=18.5,
),
+ True,
),
(
"magneticModuleV2",
+ DeckType("ot2_standard"),
{"status": "disengaged", "data": {"engaged": False, "height": 0.0}},
MagneticModuleData(
status=MagneticStatus.DISENGAGED,
engaged=False,
height=-2.5,
),
+ True,
),
(
"magneticModuleV2",
+ DeckType("ot3_standard"),
+ {"status": "disengaged", "data": {"engaged": False, "height": 0.0}},
+ MagneticModuleData(
+ status=MagneticStatus.DISENGAGED,
+ engaged=False,
+ height=-2.5,
+ ),
+ False,
+ ),
+ (
+ "magneticModuleV2",
+ DeckType("ot2_standard"),
{"status": "engaged", "data": {"engaged": True, "height": 42}},
MagneticModuleData(
status=MagneticStatus.ENGAGED,
engaged=True,
height=39.5,
),
+ True,
),
],
)
def test_maps_magnetic_module_data(
input_model: str,
+ deck_type: DeckType,
input_data: LiveData,
expected_output_data: MagneticModuleData,
+ expected_compatible: bool,
) -> None:
"""It should map hardware data to a magnetic module."""
module_identity = ModuleIdentity(
@@ -93,7 +131,7 @@ def test_maps_magnetic_module_data(
device_path="/dev/null",
)
- subject = ModuleDataMapper()
+ subject = ModuleDataMapper(deck_type=deck_type)
result = subject.map_data(
model=input_model,
module_identity=module_identity,
@@ -113,6 +151,7 @@ def test_maps_magnetic_module_data(
hasAvailableUpdate=True,
moduleType=ModuleType.MAGNETIC,
moduleModel=ModuleModel(input_model), # type: ignore[arg-type]
+ compatibleWithRobot=expected_compatible,
usbPort=UsbPort(
port=101,
portGroup=PortGroup.UNKNOWN,
@@ -126,8 +165,13 @@ def test_maps_magnetic_module_data(
@pytest.mark.parametrize(
- "input_model",
- ["temperatureModuleV1", "temperatureModuleV2"],
+ "input_model,deck_type,expected_compatible",
+ [
+ ("temperatureModuleV1", DeckType("ot2_standard"), True),
+ ("temperatureModuleV1", DeckType("ot3_standard"), False),
+ ("temperatureModuleV2", DeckType("ot2_standard"), True),
+ ("temperatureModuleV2", DeckType("ot3_standard"), True),
+ ],
)
@pytest.mark.parametrize(
"input_data",
@@ -139,7 +183,12 @@ def test_maps_magnetic_module_data(
},
],
)
-def test_maps_temperature_module_data(input_model: str, input_data: LiveData) -> None:
+def test_maps_temperature_module_data(
+ input_model: str,
+ deck_type: DeckType,
+ expected_compatible: bool,
+ input_data: LiveData,
+) -> None:
"""It should map hardware data to a magnetic module."""
module_identity = ModuleIdentity(
module_id="module-id",
@@ -157,7 +206,7 @@ def test_maps_temperature_module_data(input_model: str, input_data: LiveData) ->
device_path="/dev/null",
)
- subject = ModuleDataMapper()
+ subject = ModuleDataMapper(deck_type=deck_type)
result = subject.map_data(
model=input_model,
module_identity=module_identity,
@@ -176,6 +225,7 @@ def test_maps_temperature_module_data(input_model: str, input_data: LiveData) ->
hardwareRevision="4.5.6",
hasAvailableUpdate=True,
moduleType=ModuleType.TEMPERATURE,
+ compatibleWithRobot=expected_compatible,
moduleModel=ModuleModel(input_model), # type: ignore[arg-type]
usbPort=UsbPort(
port=101,
@@ -194,8 +244,13 @@ def test_maps_temperature_module_data(input_model: str, input_data: LiveData) ->
@pytest.mark.parametrize(
- "input_model",
- ["thermocyclerModuleV1"],
+ "input_model,deck_type,expected_compatible",
+ [
+ ("thermocyclerModuleV1", DeckType("ot2_standard"), True),
+ ("thermocyclerModuleV1", DeckType("ot3_standard"), False),
+ ("thermocyclerModuleV2", DeckType("ot2_standard"), True),
+ ("thermocyclerModuleV2", DeckType("ot3_standard"), True),
+ ],
)
@pytest.mark.parametrize(
"input_data",
@@ -236,7 +291,12 @@ def test_maps_temperature_module_data(input_model: str, input_data: LiveData) ->
},
],
)
-def test_maps_thermocycler_module_data(input_model: str, input_data: LiveData) -> None:
+def test_maps_thermocycler_module_data(
+ input_model: str,
+ deck_type: DeckType,
+ expected_compatible: bool,
+ input_data: LiveData,
+) -> None:
"""It should map hardware data to a magnetic module."""
module_identity = ModuleIdentity(
module_id="module-id",
@@ -254,7 +314,7 @@ def test_maps_thermocycler_module_data(input_model: str, input_data: LiveData) -
device_path="/dev/null",
)
- subject = ModuleDataMapper()
+ subject = ModuleDataMapper(deck_type=deck_type)
result = subject.map_data(
model=input_model,
module_identity=module_identity,
@@ -273,6 +333,7 @@ def test_maps_thermocycler_module_data(input_model: str, input_data: LiveData) -
hardwareRevision="4.5.6",
hasAvailableUpdate=True,
moduleType=ModuleType.THERMOCYCLER,
+ compatibleWithRobot=expected_compatible,
moduleModel=ModuleModel(input_model), # type: ignore[arg-type]
usbPort=UsbPort(
port=101,
@@ -301,8 +362,11 @@ def test_maps_thermocycler_module_data(input_model: str, input_data: LiveData) -
@pytest.mark.parametrize(
- "input_model",
- ["heaterShakerModuleV1"],
+ "input_model,deck_type",
+ [
+ ("heaterShakerModuleV1", DeckType("ot2_standard")),
+ ("heaterShakerModuleV1", DeckType("ot3_standard")),
+ ],
)
@pytest.mark.parametrize(
"input_data",
@@ -335,7 +399,9 @@ def test_maps_thermocycler_module_data(input_model: str, input_data: LiveData) -
},
],
)
-def test_maps_heater_shaker_module_data(input_model: str, input_data: LiveData) -> None:
+def test_maps_heater_shaker_module_data(
+ input_model: str, deck_type: DeckType, input_data: LiveData
+) -> None:
"""It should map hardware data to a magnetic module."""
module_identity = ModuleIdentity(
module_id="module-id",
@@ -353,7 +419,7 @@ def test_maps_heater_shaker_module_data(input_model: str, input_data: LiveData)
device_path="/dev/null",
)
- subject = ModuleDataMapper()
+ subject = ModuleDataMapper(deck_type=deck_type)
result = subject.map_data(
model=input_model,
module_identity=module_identity,
@@ -372,6 +438,7 @@ def test_maps_heater_shaker_module_data(input_model: str, input_data: LiveData)
hardwareRevision="4.5.6",
hasAvailableUpdate=True,
moduleType=ModuleType.HEATER_SHAKER,
+ compatibleWithRobot=True,
moduleModel=ModuleModel(input_model), # type: ignore[arg-type]
usbPort=UsbPort(
port=101,
diff --git a/robot-server/tests/modules/test_router.py b/robot-server/tests/modules/test_router.py
index 6aa92b3fee7..f2392672109 100644
--- a/robot-server/tests/modules/test_router.py
+++ b/robot-server/tests/modules/test_router.py
@@ -49,7 +49,9 @@ def module_identifier(decoy: Decoy) -> ModuleIdentifier:
@pytest.fixture()
-def module_data_mapper(decoy: Decoy) -> ModuleDataMapper:
+def module_data_mapper(
+ decoy: Decoy,
+) -> ModuleDataMapper:
"""Get a mock module data mapper."""
return decoy.mock(cls=ModuleDataMapper)
@@ -87,6 +89,7 @@ async def test_get_modules_maps_data_and_id(
hasAvailableUpdate=True,
moduleType=ModuleType.MAGNETIC,
moduleModel=ModuleModel.MAGNETIC_MODULE_V1,
+ compatibleWithRobot=True,
usbPort=UsbPort(
port=42,
hub=False,
diff --git a/shared-data/module/definitions/3/absorbanceReaderV1.json b/shared-data/module/definitions/3/absorbanceReaderV1.json
index 7b3172c377d..bed2c21302e 100644
--- a/shared-data/module/definitions/3/absorbanceReaderV1.json
+++ b/shared-data/module/definitions/3/absorbanceReaderV1.json
@@ -69,6 +69,7 @@
}
},
"compatibleWith": [],
+ "incompatibleWithDecks": ["ot2_standard"],
"twoDimensionalRendering": {
"name": "svg",
"type": "element",
diff --git a/shared-data/module/definitions/3/heaterShakerModuleV1.json b/shared-data/module/definitions/3/heaterShakerModuleV1.json
index fac8fa81e8d..46385cf1bbf 100644
--- a/shared-data/module/definitions/3/heaterShakerModuleV1.json
+++ b/shared-data/module/definitions/3/heaterShakerModuleV1.json
@@ -170,6 +170,7 @@
}
},
"compatibleWith": [],
+ "incompatibleWithDecks": [],
"twoDimensionalRendering": {
"name": "svg",
"type": "element",
diff --git a/shared-data/module/definitions/3/magneticBlockV1.json b/shared-data/module/definitions/3/magneticBlockV1.json
index a1fd0a39248..0ce8ca3ffa6 100644
--- a/shared-data/module/definitions/3/magneticBlockV1.json
+++ b/shared-data/module/definitions/3/magneticBlockV1.json
@@ -50,6 +50,7 @@
"ot3_standard": {}
},
"compatibleWith": [],
+ "incompatibleWithDecks": [],
"twoDimensionalRendering": {
"name": "svg",
"type": "element",
diff --git a/shared-data/module/definitions/3/magneticModuleV1.json b/shared-data/module/definitions/3/magneticModuleV1.json
index ab46b018ae5..eb31487876b 100644
--- a/shared-data/module/definitions/3/magneticModuleV1.json
+++ b/shared-data/module/definitions/3/magneticModuleV1.json
@@ -34,6 +34,7 @@
"quirks": [],
"slotTransforms": {},
"compatibleWith": [],
+ "incompatibleWithDecks": ["ot3_standard"],
"twoDimensionalRendering": {
"name": "svg",
"type": "element",
diff --git a/shared-data/module/definitions/3/magneticModuleV2.json b/shared-data/module/definitions/3/magneticModuleV2.json
index 5344627ce35..9bc35e5c2dd 100644
--- a/shared-data/module/definitions/3/magneticModuleV2.json
+++ b/shared-data/module/definitions/3/magneticModuleV2.json
@@ -87,6 +87,7 @@
}
},
"compatibleWith": [],
+ "incompatibleWithDecks": ["ot3_standard"],
"twoDimensionalRendering": {
"name": "svg",
"type": "element",
diff --git a/shared-data/module/definitions/3/temperatureModuleV1.json b/shared-data/module/definitions/3/temperatureModuleV1.json
index 9b7b703ba52..7f5d824e1b5 100644
--- a/shared-data/module/definitions/3/temperatureModuleV1.json
+++ b/shared-data/module/definitions/3/temperatureModuleV1.json
@@ -35,6 +35,7 @@
"quirks": [],
"slotTransforms": {},
"compatibleWith": ["temperatureModuleV2"],
+ "incompatibleWithDecks": ["ot3_standard"],
"twoDimensionalRendering": {
"name": "svg",
"type": "element",
diff --git a/shared-data/module/definitions/3/temperatureModuleV2.json b/shared-data/module/definitions/3/temperatureModuleV2.json
index 07bdfe03c47..ecd353eb0bd 100644
--- a/shared-data/module/definitions/3/temperatureModuleV2.json
+++ b/shared-data/module/definitions/3/temperatureModuleV2.json
@@ -168,6 +168,7 @@
}
},
"compatibleWith": ["temperatureModuleV1"],
+ "incompatibleWithDecks": [],
"twoDimensionalRendering": {
"name": "svg",
"type": "element",
diff --git a/shared-data/module/definitions/3/thermocyclerModuleV1.json b/shared-data/module/definitions/3/thermocyclerModuleV1.json
index 09dd6828c9b..a6933bb3db5 100644
--- a/shared-data/module/definitions/3/thermocyclerModuleV1.json
+++ b/shared-data/module/definitions/3/thermocyclerModuleV1.json
@@ -38,6 +38,7 @@
"quirks": [],
"slotTransforms": {},
"compatibleWith": ["thermocyclerModuleV1"],
+ "incompatibleWithDecks": ["ot3_standard"],
"twoDimensionalRendering": {
"name": "svg",
"type": "element",
diff --git a/shared-data/module/definitions/3/thermocyclerModuleV2.json b/shared-data/module/definitions/3/thermocyclerModuleV2.json
index b5d8b1fbd9e..f051d81078b 100644
--- a/shared-data/module/definitions/3/thermocyclerModuleV2.json
+++ b/shared-data/module/definitions/3/thermocyclerModuleV2.json
@@ -69,6 +69,7 @@
}
},
"compatibleWith": [],
+ "incompatibleWithDecks": [],
"twoDimensionalRendering": {
"name": "svg",
"type": "element",
diff --git a/shared-data/module/schemas/3.json b/shared-data/module/schemas/3.json
index fdafba4c8a9..c422645a67a 100644
--- a/shared-data/module/schemas/3.json
+++ b/shared-data/module/schemas/3.json
@@ -67,7 +67,8 @@
"quirks",
"slotTransforms",
"compatibleWith",
- "twoDimensionalRendering"
+ "twoDimensionalRendering",
+ "incompatibleWithDecks"
],
"additionalProperties": false,
"properties": {
@@ -198,6 +199,13 @@
"description": "A compatible module model (e.g. temperatureModuleV1)"
}
},
+ "incompatibleWithDecks": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "description": "A list of robot decks (by their definition name) not compatible with this module."
+ }
+ },
"twoDimensionalRendering": {
"type": "object",
"description": "SVG rendering of the module represented as svgson",
diff --git a/shared-data/python/opentrons_shared_data/module/dev_types.py b/shared-data/python/opentrons_shared_data/module/dev_types.py
index c6dbbc4acad..827905d8a31 100644
--- a/shared-data/python/opentrons_shared_data/module/dev_types.py
+++ b/shared-data/python/opentrons_shared_data/module/dev_types.py
@@ -109,6 +109,7 @@ class GripperOffsets(TypedDict):
"quirks": List[str],
"slotTransforms": Dict[str, Dict[str, Dict[str, List[List[float]]]]],
"compatibleWith": List[ModuleModel],
+ "incompatibleWithDecks": List[str],
"twoDimensionalRendering": Dict[str, Any],
"gripperOffsets": Dict[str, GripperOffsets],
},