{
hasSlotConflict: false,
connected: false,
},
+ {
+ hardwareType: 'module',
+ moduleModel: 'thermocyclerModuleV2',
+ slot: 'B1',
+ hasSlotConflict: false,
+ connected: false,
+ },
{
hardwareType: 'fixture',
cutoutFixtureId: WASTE_CHUTE_RIGHT_ADAPTER_NO_COVER_FIXTURE,
@@ -92,6 +99,7 @@ describe('Hardware', () => {
})
screen.getByRole('row', { name: '1 Heater-Shaker Module GEN1' })
screen.getByRole('row', { name: '3 Temperature Module GEN2' })
+ screen.getByRole('row', { name: 'A1+B1 Thermocycler Module GEN2' })
screen.getByRole('row', { name: 'D3 Waste chute only' })
screen.getByRole('row', { name: 'B3 Staging area slot' })
})
diff --git a/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx b/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx
index 5479f4693bd6..04f3c2d8e770 100644
--- a/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx
+++ b/app/src/pages/ProtocolSetup/__tests__/ProtocolSetup.test.tsx
@@ -478,8 +478,58 @@ describe('ProtocolSetup', () => {
it('should render a confirmation modal when heater-shaker is in a protocol and it is not shaking', () => {
vi.mocked(useIsHeaterShakerInProtocol).mockReturnValue(true)
+ vi.mocked(useProtocolAnalysisAsDocumentQuery).mockReturnValue({
+ data: { ...mockRobotSideAnalysis, liquids: mockLiquids },
+ } as any)
+ when(vi.mocked(getProtocolModulesInfo))
+ .calledWith(
+ { ...mockRobotSideAnalysis, liquids: mockLiquids },
+ flexDeckDefV5 as any
+ )
+ .thenReturn(mockProtocolModuleInfo)
+ when(vi.mocked(getUnmatchedModulesForProtocol))
+ .calledWith([], mockProtocolModuleInfo)
+ .thenReturn({ missingModuleIds: [], remainingAttachedModules: [] })
+ vi.mocked(getIncompleteInstrumentCount).mockReturnValue(0)
+ MockProtocolSetupLiquids.mockImplementation(
+ vi.fn(({ setIsConfirmed, setSetupScreen }) => {
+ setIsConfirmed(true)
+ setSetupScreen('prepare to run')
+ return Mock ProtocolSetupLiquids
+ })
+ )
+ MockProtocolSetupLabware.mockImplementation(
+ vi.fn(({ setIsConfirmed, setSetupScreen }) => {
+ setIsConfirmed(true)
+ setSetupScreen('prepare to run')
+ return Mock ProtocolSetupLabware
+ })
+ )
+ MockProtocolSetupOffsets.mockImplementation(
+ vi.fn(({ setIsConfirmed, setSetupScreen }) => {
+ setIsConfirmed(true)
+ setSetupScreen('prepare to run')
+ return Mock ProtocolSetupOffsets
+ })
+ )
+ render(`/runs/${RUN_ID}/setup/`)
+ fireEvent.click(screen.getByText('Labware Position Check'))
+ fireEvent.click(screen.getByText('Labware'))
+ fireEvent.click(screen.getByText('Liquids'))
+ fireEvent.click(screen.getByRole('button', { name: 'play' }))
+ expect(vi.mocked(ConfirmAttachedModal)).toHaveBeenCalled()
+ })
+ it('should go from skip steps to heater-shaker modal', () => {
+ vi.mocked(useIsHeaterShakerInProtocol).mockReturnValue(true)
+ MockConfirmSetupStepsCompleteModal.mockImplementation(
+ ({ onConfirmClick }) => {
+ onConfirmClick()
+ return Mock ConfirmSetupStepsCompleteModal
+ }
+ )
render(`/runs/${RUN_ID}/setup/`)
fireEvent.click(screen.getByRole('button', { name: 'play' }))
+ expect(MockConfirmSetupStepsCompleteModal).toHaveBeenCalled()
expect(vi.mocked(ConfirmAttachedModal)).toHaveBeenCalled()
})
it('should render a loading skeleton while awaiting a response from the server', () => {
diff --git a/app/src/pages/ProtocolSetup/index.tsx b/app/src/pages/ProtocolSetup/index.tsx
index f152b0cc44a9..5a50c52c19cb 100644
--- a/app/src/pages/ProtocolSetup/index.tsx
+++ b/app/src/pages/ProtocolSetup/index.tsx
@@ -513,6 +513,8 @@ function PrepareToRun({
areModulesReady && areFixturesReady && !isLocationConflict
? 'ready'
: 'not ready'
+ // Liquids information
+ const liquidsInProtocol = mostRecentAnalysis?.liquids ?? []
const isReadyToRun =
incompleteInstrumentCount === 0 && areModulesReady && areFixturesReady
@@ -521,13 +523,17 @@ function PrepareToRun({
makeSnackbar(t('shared:close_robot_door') as string)
} else {
if (isReadyToRun) {
- if (runStatus === RUN_STATUS_IDLE && isHeaterShakerInProtocol) {
- confirmAttachment()
- } else if (
+ if (
runStatus === RUN_STATUS_IDLE &&
- !(labwareConfirmed && offsetsConfirmed && liquidsConfirmed)
+ !(
+ labwareConfirmed &&
+ offsetsConfirmed &&
+ (liquidsConfirmed || liquidsInProtocol.length === 0)
+ )
) {
confirmStepsComplete()
+ } else if (runStatus === RUN_STATUS_IDLE && isHeaterShakerInProtocol) {
+ confirmAttachment()
} else {
play()
trackProtocolRunEvent({
@@ -654,9 +660,6 @@ function PrepareToRun({
runRecord?.data?.labwareOffsets ?? []
)
- // Liquids information
- const liquidsInProtocol = mostRecentAnalysis?.liquids ?? []
-
const { data: doorStatus } = useDoorQuery({
refetchInterval: FETCH_DURATION_MS,
})
@@ -757,7 +760,7 @@ function PrepareToRun({
detail={modulesDetail}
subDetail={modulesSubDetail}
status={modulesStatus}
- disabled={
+ interactionDisabled={
protocolModulesInfo.length === 0 && !protocolHasFixtures
}
/>
@@ -799,7 +802,11 @@ function PrepareToRun({
setSetupScreen('liquids')
}}
title={i18n.format(t('liquids'), 'capitalize')}
- status={liquidsConfirmed ? 'ready' : 'general'}
+ status={
+ liquidsConfirmed || liquidsInProtocol.length === 0
+ ? 'ready'
+ : 'general'
+ }
detail={
liquidsInProtocol.length > 0
? t('initial_liquids_num', {
@@ -807,7 +814,7 @@ function PrepareToRun({
})
: t('liquids_not_in_setup')
}
- disabled={liquidsInProtocol.length === 0}
+ interactionDisabled={liquidsInProtocol.length === 0}
/>
>
) : (
@@ -957,6 +964,8 @@ export function ProtocolSetup(): JSX.Element {
handleProceedToRunClick,
!(labwareConfirmed && liquidsConfirmed && offsetsConfirmed)
)
+ const runStatus = useRunStatus(runId)
+ const isHeaterShakerInProtocol = useIsHeaterShakerInProtocol()
// orchestrate setup subpages/components
const [setupScreen, setSetupScreen] = React.useState(
@@ -1027,7 +1036,6 @@ export function ProtocolSetup(): JSX.Element {
),
}
-
return (
<>
{showAnalysisFailedModal &&
@@ -1043,7 +1051,11 @@ export function ProtocolSetup(): JSX.Element {
{
+ runStatus === RUN_STATUS_IDLE && isHeaterShakerInProtocol
+ ? confirmAttachment()
+ : handleProceedToRunClick()
+ }}
/>
) : null}
{showHSConfirmationModal ? (
diff --git a/app/src/pages/RunSummary/index.tsx b/app/src/pages/RunSummary/index.tsx
index 188f5e606ee4..c7f9cdaba972 100644
--- a/app/src/pages/RunSummary/index.tsx
+++ b/app/src/pages/RunSummary/index.tsx
@@ -38,10 +38,10 @@ import {
import {
useHost,
useProtocolQuery,
- useInstrumentsQuery,
useDeleteRunMutation,
useRunCommandErrors,
} from '@opentrons/react-api-client'
+import { FLEX_ROBOT_TYPE } from '@opentrons/shared-data'
import {
useRunTimestamps,
@@ -65,13 +65,14 @@ import { getLocalRobot } from '../../redux/discovery'
import { RunFailedModal } from '../../organisms/OnDeviceDisplay/RunningProtocol'
import { formatTimeWithUtcLabel, useNotifyRunQuery } from '../../resources/runs'
import { handleTipsAttachedModal } from '../../organisms/DropTipWizardFlows/TipsAttachedModal'
-import { useMostRecentRunId } from '../../organisms/ProtocolUpload/hooks/useMostRecentRunId'
import { useTipAttachmentStatus } from '../../organisms/DropTipWizardFlows'
import { useRecoveryAnalytics } from '../../organisms/ErrorRecoveryFlows/hooks'
import type { OnDeviceRouteParams } from '../../App/types'
import type { PipetteWithTip } from '../../organisms/DropTipWizardFlows'
+const CURRENT_RUN_POLL_MS = 5000
+
export function RunSummary(): JSX.Element {
const { runId } = useParams<
keyof OnDeviceRouteParams
@@ -80,9 +81,10 @@ export function RunSummary(): JSX.Element {
const navigate = useNavigate()
const host = useHost()
const { data: runRecord } = useNotifyRunQuery(runId, { staleTime: Infinity })
- const isRunCurrent = Boolean(runRecord?.data?.current)
- const mostRecentRunId = useMostRecentRunId()
- const { data: attachedInstruments } = useInstrumentsQuery()
+ const isRunCurrent = Boolean(
+ useNotifyRunQuery(runId, { refetchInterval: CURRENT_RUN_POLL_MS })?.data
+ ?.data?.current
+ )
const { deleteRun } = useDeleteRunMutation()
const runStatus = runRecord?.data.status ?? null
const didRunSucceed = runStatus === RUN_STATUS_SUCCEEDED
@@ -155,7 +157,10 @@ export function RunSummary(): JSX.Element {
isRunCurrent,
})
- let headerText = t('run_complete_splash')
+ let headerText =
+ commandErrorList != null && commandErrorList.data.length > 0
+ ? t('run_completed_with_warnings')
+ : t('run_completed_splash')
if (runStatus === RUN_STATUS_FAILED) {
headerText = t('run_failed_splash')
} else if (runStatus === RUN_STATUS_STOPPED) {
@@ -168,10 +173,8 @@ export function RunSummary(): JSX.Element {
aPipetteWithTip,
} = useTipAttachmentStatus({
runId,
- runRecord,
- attachedInstruments,
+ runRecord: runRecord ?? null,
host,
- isFlex: true,
})
// Determine tip status on initial render only. Error Recovery always handles tip status, so don't show it twice.
@@ -185,7 +188,8 @@ export function RunSummary(): JSX.Element {
const queryClient = useQueryClient()
const returnToDash = (): void => {
closeCurrentRun()
- // Eagerly clear the query cache to prevent top level redirecting back to this page.
+ // Eagerly clear the query caches to prevent top level redirecting back to this page.
+ queryClient.setQueryData([host, 'runs', 'details'], () => undefined)
queryClient.setQueryData([host, 'runs', runId, 'details'], () => undefined)
navigate('/')
}
@@ -225,25 +229,39 @@ export function RunSummary(): JSX.Element {
}
const handleReturnToDash = (aPipetteWithTip: PipetteWithTip | null): void => {
- if (mostRecentRunId === runId && aPipetteWithTip != null) {
+ if (isRunCurrent && aPipetteWithTip != null) {
void handleTipsAttachedModal({
setTipStatusResolved: setTipStatusResolvedAndRoute(handleReturnToDash),
host,
aPipetteWithTip,
+ instrumentModelSpecs: aPipetteWithTip.specs,
+ mount: aPipetteWithTip.mount,
+ robotType: FLEX_ROBOT_TYPE,
+ onClose: () => {
+ closeCurrentRun()
+ returnToDash()
+ },
})
} else if (isQuickTransfer) {
returnToQuickTransfer()
} else {
+ closeCurrentRun()
returnToDash()
}
}
const handleRunAgain = (aPipetteWithTip: PipetteWithTip | null): void => {
- if (mostRecentRunId === runId && aPipetteWithTip != null) {
+ if (isRunCurrent && aPipetteWithTip != null) {
void handleTipsAttachedModal({
setTipStatusResolved: setTipStatusResolvedAndRoute(handleRunAgain),
host,
aPipetteWithTip,
+ instrumentModelSpecs: aPipetteWithTip.specs,
+ mount: aPipetteWithTip.mount,
+ robotType: FLEX_ROBOT_TYPE,
+ onClose: () => {
+ runAgain()
+ },
})
} else {
if (!isResetRunLoading) {
@@ -332,6 +350,7 @@ export function RunSummary(): JSX.Element {
setShowRunFailedModal={setShowRunFailedModal}
errors={runRecord?.data.errors}
commandErrorList={commandErrorList}
+ runStatus={runStatus}
/>
) : null}
- {!didRunSucceed ? (
+ {(commandErrorList != null && commandErrorList?.data.length > 0) ||
+ !didRunSucceed ? (
) : null}
diff --git a/app/src/pages/RunningProtocol/index.tsx b/app/src/pages/RunningProtocol/index.tsx
index f1ca179167da..de6cfc58994a 100644
--- a/app/src/pages/RunningProtocol/index.tsx
+++ b/app/src/pages/RunningProtocol/index.tsx
@@ -156,7 +156,7 @@ export function RunningProtocol(): JSX.Element {
) : null}
diff --git a/components/src/hardware-sim/Labware/LabwareAdapter/index.tsx b/components/src/hardware-sim/Labware/LabwareAdapter/index.tsx
index 978fb7cbea37..fc05d8b5621d 100644
--- a/components/src/hardware-sim/Labware/LabwareAdapter/index.tsx
+++ b/components/src/hardware-sim/Labware/LabwareAdapter/index.tsx
@@ -4,6 +4,9 @@ import { Opentrons96FlatBottomAdapter } from './Opentrons96FlatBottomAdapter'
import { OpentronsUniversalFlatAdapter } from './OpentronsUniversalFlatAdapter'
import { OpentronsAluminumFlatBottomPlate } from './OpentronsAluminumFlatBottomPlate'
import { OpentronsFlex96TiprackAdapter } from './OpentronsFlex96TiprackAdapter'
+import { COLORS } from '../../../helix-design-system'
+import { LabwareOutline } from '../labwareInternals'
+import type { LabwareDefinition2 } from '@opentrons/shared-data'
const LABWARE_ADAPTER_LOADNAME_PATHS = {
opentrons_96_deep_well_adapter: Opentrons96DeepWellAdapter,
@@ -20,13 +23,28 @@ export const labwareAdapterLoadNames = Object.keys(
export interface LabwareAdapterProps {
labwareLoadName: LabwareAdapterLoadName
+ definition?: LabwareDefinition2
+ highlight?: boolean
}
export const LabwareAdapter = (
props: LabwareAdapterProps
): JSX.Element | null => {
- const { labwareLoadName } = props
+ const { labwareLoadName, definition, highlight = false } = props
+ const highlightOutline =
+ highlight && definition != null ? (
+
+ ) : null
const SVGElement = LABWARE_ADAPTER_LOADNAME_PATHS[labwareLoadName]
- return
+ return (
+
+
+ {highlightOutline}
+
+ )
}
diff --git a/components/src/hardware-sim/Labware/LabwareRender.tsx b/components/src/hardware-sim/Labware/LabwareRender.tsx
index 41c2537a7d95..9137a2d2f15e 100644
--- a/components/src/hardware-sim/Labware/LabwareRender.tsx
+++ b/components/src/hardware-sim/Labware/LabwareRender.tsx
@@ -88,6 +88,8 @@ export const LabwareRender = (props: LabwareRenderProps): JSX.Element => {
>
diff --git a/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx b/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx
index 7478c6711140..743743bd6c04 100644
--- a/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx
+++ b/components/src/hardware-sim/Labware/labwareInternals/LabwareOutline.tsx
@@ -65,6 +65,7 @@ export function LabwareOutline(props: LabwareOutlineProps): JSX.Element {
rx="8"
ry="8"
showRadius={showRadius}
+ fill={backgroundFill}
/>
None:
if run_id == self._run_orchestrator_store.current_run_id:
await self._run_orchestrator_store.clear()
- await self._maintenance_runs_publisher.publish_current_maintenance_run()
+ await self._maintenance_runs_publisher.publish_current_maintenance_run_async()
else:
raise MaintenanceRunNotFoundError(run_id=run_id)
diff --git a/robot-server/robot_server/protocols/router.py b/robot-server/robot_server/protocols/router.py
index 563b8c21d6f0..ff6521b70d6e 100644
--- a/robot-server/robot_server/protocols/router.py
+++ b/robot-server/robot_server/protocols/router.py
@@ -195,9 +195,10 @@ class ProtocolLinks(BaseModel):
responses={
status.HTTP_200_OK: {"model": SimpleBody[Protocol]},
status.HTTP_201_CREATED: {"model": SimpleBody[Protocol]},
- status.HTTP_400_BAD_REQUEST: {"model": ErrorBody[FileIdNotFound]},
status.HTTP_422_UNPROCESSABLE_ENTITY: {
- "model": ErrorBody[Union[ProtocolFilesInvalid, ProtocolRobotTypeMismatch]]
+ "model": ErrorBody[
+ Union[ProtocolFilesInvalid, ProtocolRobotTypeMismatch, FileIdNotFound]
+ ]
},
status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorBody[LastAnalysisPending]},
},
@@ -331,7 +332,9 @@ async def create_protocol( # noqa: C901
for name, file_id in parsed_rtp_files.items()
}
except FileIdNotFoundError as e:
- raise FileIdNotFound(detail=str(e)).as_error(status.HTTP_400_BAD_REQUEST)
+ raise FileIdNotFound(detail=str(e)).as_error(
+ status.HTTP_422_UNPROCESSABLE_ENTITY
+ )
content_hash = await file_hasher.hash(buffered_files)
cached_protocol_id = protocol_store.get_id_by_hash(content_hash)
@@ -705,8 +708,8 @@ async def delete_protocol_by_id(
responses={
status.HTTP_200_OK: {"model": SimpleMultiBody[AnalysisSummary]},
status.HTTP_201_CREATED: {"model": SimpleMultiBody[AnalysisSummary]},
- status.HTTP_400_BAD_REQUEST: {"model": ErrorBody[FileIdNotFound]},
status.HTTP_404_NOT_FOUND: {"model": ErrorBody[ProtocolNotFound]},
+ status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ErrorBody[FileIdNotFound]},
status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorBody[LastAnalysisPending]},
},
)
@@ -746,7 +749,9 @@ async def create_protocol_analysis(
for name, file_id in rtp_files.items()
}
except FileIdNotFoundError as e:
- raise FileIdNotFound(detail=str(e)).as_error(status.HTTP_400_BAD_REQUEST)
+ raise FileIdNotFound(detail=str(e)).as_error(
+ status.HTTP_422_UNPROCESSABLE_ENTITY
+ )
try:
(
diff --git a/robot-server/robot_server/runs/light_control_task.py b/robot-server/robot_server/runs/light_control_task.py
index 8a43af93321e..ed59f5cfaa3d 100644
--- a/robot-server/robot_server/runs/light_control_task.py
+++ b/robot-server/robot_server/runs/light_control_task.py
@@ -37,14 +37,14 @@ def _engine_status_to_status_bar(
return StatusBarState.IDLE if initialization_done else StatusBarState.OFF
case EngineStatus.RUNNING:
return StatusBarState.RUNNING
+ case EngineStatus.PAUSED | EngineStatus.BLOCKED_BY_OPEN_DOOR:
+ return StatusBarState.PAUSED
case (
- EngineStatus.PAUSED
- | EngineStatus.BLOCKED_BY_OPEN_DOOR
- | EngineStatus.AWAITING_RECOVERY
+ EngineStatus.AWAITING_RECOVERY
| EngineStatus.AWAITING_RECOVERY_PAUSED
| EngineStatus.AWAITING_RECOVERY_BLOCKED_BY_OPEN_DOOR
):
- return StatusBarState.PAUSED
+ return StatusBarState.ERROR_RECOVERY
case EngineStatus.STOP_REQUESTED | EngineStatus.FINISHING:
return StatusBarState.UPDATING
case EngineStatus.STOPPED:
diff --git a/robot-server/robot_server/runs/router/actions_router.py b/robot-server/robot_server/runs/router/actions_router.py
index 6ceb6eadef64..f80d37e2319e 100644
--- a/robot-server/robot_server/runs/router/actions_router.py
+++ b/robot-server/robot_server/runs/router/actions_router.py
@@ -29,7 +29,12 @@
from robot_server.maintenance_runs.dependencies import (
get_maintenance_run_orchestrator_store,
)
-from robot_server.service.notifications import get_runs_publisher, RunsPublisher
+from robot_server.service.notifications import (
+ get_runs_publisher,
+ get_maintenance_runs_publisher,
+ RunsPublisher,
+ MaintenanceRunsPublisher,
+)
log = logging.getLogger(__name__)
actions_router = APIRouter()
@@ -50,6 +55,7 @@ async def get_run_controller(
],
run_store: Annotated[RunStore, Depends(get_run_store)],
runs_publisher: Annotated[RunsPublisher, Depends(get_runs_publisher)],
+ maintenance_runs_publisher: Annotated[MaintenanceRunsPublisher, Depends(get_maintenance_runs_publisher)],
) -> RunController:
"""Get a RunController for the current run.
@@ -73,6 +79,7 @@ async def get_run_controller(
run_orchestrator_store=run_orchestrator_store,
run_store=run_store,
runs_publisher=runs_publisher,
+ maintenance_runs_publisher=maintenance_runs_publisher,
)
diff --git a/robot-server/robot_server/runs/router/base_router.py b/robot-server/robot_server/runs/router/base_router.py
index fa4f1947a4fe..429097c24ab1 100644
--- a/robot-server/robot_server/runs/router/base_router.py
+++ b/robot-server/robot_server/runs/router/base_router.py
@@ -148,8 +148,8 @@ async def get_run_data_from_url(
status_code=status.HTTP_201_CREATED,
responses={
status.HTTP_201_CREATED: {"model": SimpleBody[Run]},
- status.HTTP_400_BAD_REQUEST: {"model": ErrorBody[FileIdNotFound]},
status.HTTP_404_NOT_FOUND: {"model": ErrorBody[ProtocolNotFound]},
+ status.HTTP_422_UNPROCESSABLE_ENTITY: {"model": ErrorBody[FileIdNotFound]},
status.HTTP_409_CONFLICT: {"model": ErrorBody[RunAlreadyActive]},
},
)
@@ -208,7 +208,9 @@ async def create_run( # noqa: C901
for name, file_id in rtp_files.items()
}
except FileIdNotFoundError as e:
- raise FileIdNotFound(detail=str(e)).as_error(status.HTTP_400_BAD_REQUEST)
+ raise FileIdNotFound(detail=str(e)).as_error(
+ status.HTTP_422_UNPROCESSABLE_ENTITY
+ )
protocol_resource = None
diff --git a/robot-server/robot_server/runs/run_controller.py b/robot-server/robot_server/runs/run_controller.py
index 84299c34dedf..7c8c0fe8b766 100644
--- a/robot-server/robot_server/runs/run_controller.py
+++ b/robot-server/robot_server/runs/run_controller.py
@@ -13,7 +13,7 @@
from opentrons.protocol_engine.types import DeckConfigurationType
-from robot_server.service.notifications import RunsPublisher
+from robot_server.service.notifications import RunsPublisher, MaintenanceRunsPublisher
log = logging.getLogger(__name__)
@@ -32,12 +32,14 @@ def __init__(
run_orchestrator_store: RunOrchestratorStore,
run_store: RunStore,
runs_publisher: RunsPublisher,
+ maintenance_runs_publisher: MaintenanceRunsPublisher,
) -> None:
self._run_id = run_id
self._task_runner = task_runner
self._run_orchestrator_store = run_orchestrator_store
self._run_store = run_store
self._runs_publisher = runs_publisher
+ self._maintenance_runs_publisher = maintenance_runs_publisher
def create_action(
self,
@@ -80,6 +82,8 @@ def create_action(
func=self._run_protocol_and_insert_result,
deck_configuration=action_payload,
)
+ # Playing a protocol run terminates an existing maintenance run.
+ self._maintenance_runs_publisher.publish_current_maintenance_run()
elif action_type == RunActionType.PAUSE:
log.info(f'Pausing run "{self._run_id}".')
diff --git a/robot-server/robot_server/service/notifications/publishers/maintenance_runs_publisher.py b/robot-server/robot_server/service/notifications/publishers/maintenance_runs_publisher.py
index b1b7e44675c8..babfd9542345 100644
--- a/robot-server/robot_server/service/notifications/publishers/maintenance_runs_publisher.py
+++ b/robot-server/robot_server/service/notifications/publishers/maintenance_runs_publisher.py
@@ -18,7 +18,7 @@ def __init__(self, client: NotificationClient) -> None:
"""Returns a configured Maintenance Runs Publisher."""
self._client = client
- async def publish_current_maintenance_run(
+ async def publish_current_maintenance_run_async(
self,
) -> None:
"""Publishes the equivalent of GET /maintenance_run/current_run"""
@@ -26,6 +26,12 @@ async def publish_current_maintenance_run(
topic=topics.MAINTENANCE_RUNS_CURRENT_RUN
)
+ def publish_current_maintenance_run(
+ self,
+ ) -> None:
+ """Publishes the equivalent of GET /maintenance_run/current_run"""
+ self._client.publish_advise_refetch(topic=topics.MAINTENANCE_RUNS_CURRENT_RUN)
+
_maintenance_runs_publisher_accessor: AppStateAccessor[
MaintenanceRunsPublisher
diff --git a/robot-server/tests/runs/test_run_controller.py b/robot-server/tests/runs/test_run_controller.py
index a901c9881680..89aa79743a69 100644
--- a/robot-server/tests/runs/test_run_controller.py
+++ b/robot-server/tests/runs/test_run_controller.py
@@ -14,7 +14,7 @@
from opentrons.protocol_engine.types import RunTimeParameter, BooleanParameter
from opentrons.protocol_runner import RunResult
-from robot_server.service.notifications import RunsPublisher
+from robot_server.service.notifications import RunsPublisher, MaintenanceRunsPublisher
from robot_server.service.task_runner import TaskRunner
from robot_server.runs.action_models import RunAction, RunActionType
from robot_server.runs.run_orchestrator_store import RunOrchestratorStore
@@ -48,6 +48,12 @@ def mock_runs_publisher(decoy: Decoy) -> RunsPublisher:
return decoy.mock(cls=RunsPublisher)
+@pytest.fixture()
+def mock_maintenance_runs_publisher(decoy: Decoy) -> MaintenanceRunsPublisher:
+ """Get a mock RunsPublisher."""
+ return decoy.mock(cls=MaintenanceRunsPublisher)
+
+
@pytest.fixture
def run_id() -> str:
"""A run identifier value."""
@@ -99,6 +105,7 @@ def subject(
mock_run_store: RunStore,
mock_task_runner: TaskRunner,
mock_runs_publisher: RunsPublisher,
+ mock_maintenance_runs_publisher: MaintenanceRunsPublisher,
) -> RunController:
"""Get a RunController test subject."""
return RunController(
@@ -107,6 +114,7 @@ def subject(
run_store=mock_run_store,
task_runner=mock_task_runner,
runs_publisher=mock_runs_publisher,
+ maintenance_runs_publisher=mock_maintenance_runs_publisher,
)
@@ -144,6 +152,7 @@ async def test_create_play_action_to_start(
mock_run_store: RunStore,
mock_task_runner: TaskRunner,
mock_runs_publisher: RunsPublisher,
+ mock_maintenance_runs_publisher: MaintenanceRunsPublisher,
engine_state_summary: StateSummary,
run_time_parameters: List[RunTimeParameter],
protocol_commands: List[pe_commands.Command],
@@ -194,6 +203,12 @@ async def test_create_play_action_to_start(
times=1,
)
+ # Verify maintenance run publication after background task execution
+ decoy.verify(
+ mock_maintenance_runs_publisher.publish_current_maintenance_run(),
+ times=1,
+ )
+
def test_create_pause_action(
decoy: Decoy,
diff --git a/robot-server/tests/service/notifications/publishers/test_maintenance_runs_publisher.py b/robot-server/tests/service/notifications/publishers/test_maintenance_runs_publisher.py
index fcc4cac5aac2..bfdbbd263121 100644
--- a/robot-server/tests/service/notifications/publishers/test_maintenance_runs_publisher.py
+++ b/robot-server/tests/service/notifications/publishers/test_maintenance_runs_publisher.py
@@ -24,7 +24,7 @@ async def test_publish_current_maintenance_run(
notification_client: AsyncMock, maintenance_runs_publisher: MaintenanceRunsPublisher
) -> None:
"""It should publish a notify flag for maintenance runs."""
- await maintenance_runs_publisher.publish_current_maintenance_run()
+ await maintenance_runs_publisher.publish_current_maintenance_run_async()
notification_client.publish_advise_refetch_async.assert_awaited_once_with(
topic=topics.MAINTENANCE_RUNS_CURRENT_RUN
)