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], },