Skip to content

Commit f789332

Browse files
fix(app, robot-server): notify on maintenance run status change (#16150)
# Overview closes [RQA-2941](https://opentrons.atlassian.net/browse/RQA-2941) and [RQA-2940](https://opentrons.atlassian.net/browse/RQA-2940). listen to run status change for maintenance runs and show error in app when failed calibration. ## Test Plan and Hands on Testing explanation in ticket. - [x] test gripper calibration with estop - [x] test pipette calibration with estop - [x] test module calibration with estop ## Changelog - added a callback to handle status change for a maintenance runs. - pass in status to gripper wizard flow. - check for status in module/pipette calibration and if failed show error ## Review requests changes make sense? ## Risk assessment low. [RQA-2941]: https://opentrons.atlassian.net/browse/RQA-2941?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [RQA-2940]: https://opentrons.atlassian.net/browse/RQA-2940?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Seth Foster <[email protected]>
1 parent f208e93 commit f789332

File tree

6 files changed

+112
-15
lines changed

6 files changed

+112
-15
lines changed

app/src/organisms/GripperWizardFlows/index.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ import type {
4242
InstrumentData,
4343
MaintenanceRun,
4444
CommandData,
45+
RunStatus,
4546
} from '@opentrons/api-client'
47+
import { RUN_STATUS_FAILED } from '@opentrons/api-client'
4648
import type { Coordinates, CreateCommand } from '@opentrons/shared-data'
4749

4850
const RUN_REFETCH_INTERVAL = 5000
@@ -108,6 +110,7 @@ export function GripperWizardFlows(
108110
}
109111
}, [
110112
maintenanceRunData?.data.id,
113+
maintenanceRunData?.data.status,
111114
createdMaintenanceRunId,
112115
monitorMaintenanceRunForDeletion,
113116
closeFlow,
@@ -160,6 +163,7 @@ export function GripperWizardFlows(
160163
flowType={flowType}
161164
createdMaintenanceRunId={createdMaintenanceRunId}
162165
maintenanceRunId={maintenanceRunData?.data.id}
166+
maintenanceRunStatus={maintenanceRunData?.data.status}
163167
attachedGripper={attachedGripper}
164168
createMaintenanceRun={createTargetedMaintenanceRun}
165169
isCreateLoading={isCreateLoading}
@@ -183,6 +187,7 @@ export function GripperWizardFlows(
183187
interface GripperWizardProps {
184188
flowType: GripperWizardFlowType
185189
maintenanceRunId?: string
190+
maintenanceRunStatus?: RunStatus
186191
createdMaintenanceRunId: string | null
187192
attachedGripper: InstrumentData | null
188193
createMaintenanceRun: UseMutateFunction<
@@ -212,6 +217,7 @@ export const GripperWizard = (
212217
const {
213218
flowType,
214219
maintenanceRunId,
220+
maintenanceRunStatus,
215221
createMaintenanceRun,
216222
handleCleanUpAndClose,
217223
handleClose,
@@ -266,6 +272,7 @@ export const GripperWizard = (
266272
}
267273

268274
const sharedProps = {
275+
maintenanceRunStatus,
269276
flowType,
270277
maintenanceRunId:
271278
maintenanceRunId != null && createdMaintenanceRunId === maintenanceRunId
@@ -283,7 +290,7 @@ export const GripperWizard = (
283290
let onExit
284291
if (currentStep == null) return null
285292
let modalContent: JSX.Element = <div>UNASSIGNED STEP</div>
286-
if (showConfirmExit) {
293+
if (showConfirmExit && maintenanceRunId !== null) {
287294
modalContent = (
288295
<ExitConfirmation
289296
handleGoBack={cancelExit}
@@ -292,14 +299,17 @@ export const GripperWizard = (
292299
isRobotMoving={isRobotMoving}
293300
/>
294301
)
295-
} else if (isExiting && errorMessage != null) {
302+
} else if (
303+
(isExiting && errorMessage != null) ||
304+
maintenanceRunStatus === RUN_STATUS_FAILED
305+
) {
296306
onExit = handleClose
297307
modalContent = (
298308
<SimpleWizardBody
299309
isSuccess={false}
300310
iconColor={COLORS.red50}
301311
header={t('shared:error_encountered')}
302-
subHeader={errorMessage}
312+
subHeader={errorMessage ?? undefined}
303313
/>
304314
)
305315
} else if (currentStep.section === SECTIONS.BEFORE_BEGINNING) {

app/src/organisms/ModuleWizardFlows/index.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { useNotifyDeckConfigurationQuery } from '../../resources/deck_configurat
3939
import { useNotifyCurrentMaintenanceRun } from '../../resources/maintenance_runs'
4040

4141
import type { AttachedModule, CommandData } from '@opentrons/api-client'
42+
import { RUN_STATUS_FAILED } from '@opentrons/api-client'
4243
import type {
4344
CreateCommand,
4445
CutoutConfig,
@@ -271,7 +272,11 @@ export const ModuleWizardFlows = (
271272
})}
272273
/>
273274
)
274-
} else if (prepCommandErrorMessage != null || errorMessage != null) {
275+
} else if (
276+
prepCommandErrorMessage != null ||
277+
errorMessage != null ||
278+
maintenanceRunData?.data.status === RUN_STATUS_FAILED
279+
) {
275280
modalContent = (
276281
<SimpleWizardBody
277282
isSuccess={false}

app/src/organisms/PipetteWizardFlows/index.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import type {
4545
PipetteMount,
4646
} from '@opentrons/shared-data'
4747
import type { CommandData, HostConfig } from '@opentrons/api-client'
48+
import { RUN_STATUS_FAILED } from '@opentrons/api-client'
4849
import type { PipetteWizardFlow, SelectablePipettes } from './types'
4950

5051
const RUN_REFETCH_INTERVAL = 5000
@@ -280,13 +281,16 @@ export const PipetteWizardFlows = (
280281
let onExit
281282
if (currentStep == null) return null
282283
let modalContent: JSX.Element = <div>UNASSIGNED STEP</div>
283-
if (isExiting && errorMessage != null) {
284+
if (
285+
(isExiting && errorMessage != null) ||
286+
maintenanceRunData?.data.status === RUN_STATUS_FAILED
287+
) {
284288
modalContent = (
285289
<SimpleWizardBody
286290
isSuccess={false}
287291
iconColor={COLORS.red50}
288292
header={t('shared:error_encountered')}
289-
subHeader={errorMessage}
293+
subHeader={errorMessage ?? undefined}
290294
/>
291295
)
292296
} else if (currentStep.section === SECTIONS.BEFORE_BEGINNING) {
@@ -395,7 +399,10 @@ export const PipetteWizardFlows = (
395399
let exitWizardButton = onExit
396400
if (isCommandMutationLoading || isDeleteLoading) {
397401
exitWizardButton = undefined
398-
} else if (errorMessage != null && isExiting) {
402+
} else if (
403+
(errorMessage != null && isExiting) ||
404+
maintenanceRunData?.data.status === RUN_STATUS_FAILED
405+
) {
399406
exitWizardButton = handleClose
400407
} else if (showConfirmExit) {
401408
exitWizardButton = handleCleanUpAndClose

robot-server/robot_server/maintenance_runs/maintenance_run_data_manager.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ async def create(
115115
state_summary=state_summary,
116116
)
117117

118-
await self._maintenance_runs_publisher.publish_current_maintenance_run_async()
118+
await self._maintenance_runs_publisher.start_publishing_for_maintenance_run(
119+
run_id=run_id, get_state_summary=self._get_state_summary
120+
)
119121

120122
return maintenance_run_data
121123

robot-server/robot_server/service/notifications/publishers/maintenance_runs_publisher.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,69 @@
1+
from dataclasses import dataclass
2+
from typing import Callable, Optional
13
from fastapi import Depends
24

5+
from opentrons.protocol_engine.state.state_summary import StateSummary
6+
from opentrons.protocol_engine.types import EngineStatus
37
from server_utils.fastapi_utils.app_state import (
48
AppState,
59
AppStateAccessor,
610
get_app_state,
711
)
812
from ..notification_client import NotificationClient, get_notification_client
13+
from ..publisher_notifier import PublisherNotifier, get_pe_publisher_notifier
914
from .. import topics
1015

1116

17+
@dataclass
18+
class _RunHooks:
19+
"""Generated during a protocol run. Utilized by MaintenanceRunsPublisher."""
20+
21+
run_id: str
22+
get_state_summary: Callable[[str], Optional[StateSummary]]
23+
24+
25+
@dataclass
26+
class _EngineStateSlice:
27+
"""Protocol Engine state relevant to MaintenanceRunsPublisher."""
28+
29+
state_summary_status: Optional[EngineStatus] = None
30+
31+
1232
class MaintenanceRunsPublisher:
1333
"""Publishes maintenance run topics."""
1434

15-
def __init__(self, client: NotificationClient) -> None:
35+
def __init__(
36+
self, client: NotificationClient, publisher_notifier: PublisherNotifier
37+
) -> None:
1638
"""Returns a configured Maintenance Runs Publisher."""
1739
self._client = client
40+
self._run_hooks: Optional[_RunHooks] = None
41+
self._engine_state_slice: Optional[_EngineStateSlice] = None
42+
43+
publisher_notifier.register_publish_callbacks(
44+
[
45+
self._handle_engine_status_change,
46+
]
47+
)
48+
49+
async def start_publishing_for_maintenance_run(
50+
self,
51+
run_id: str,
52+
get_state_summary: Callable[[str], Optional[StateSummary]],
53+
) -> None:
54+
"""Initialize RunsPublisher with necessary information derived from the current run.
55+
56+
Args:
57+
run_id: ID of the current run.
58+
get_state_summary: Callback to get the current run's state summary, if any.
59+
"""
60+
self._run_hooks = _RunHooks(
61+
run_id=run_id,
62+
get_state_summary=get_state_summary,
63+
)
64+
self._engine_state_slice = _EngineStateSlice()
65+
66+
await self.publish_current_maintenance_run_async()
1867

1968
async def publish_current_maintenance_run_async(
2069
self,
@@ -30,6 +79,21 @@ def publish_current_maintenance_run(
3079
"""Publishes the equivalent of GET /maintenance_run/current_run"""
3180
self._client.publish_advise_refetch(topic=topics.MAINTENANCE_RUNS_CURRENT_RUN)
3281

82+
async def _handle_engine_status_change(self) -> None:
83+
"""Publish a refetch flag if the engine status has changed."""
84+
if self._run_hooks is not None and self._engine_state_slice is not None:
85+
new_state_summary = self._run_hooks.get_state_summary(
86+
self._run_hooks.run_id
87+
)
88+
89+
if (
90+
new_state_summary is not None
91+
and self._engine_state_slice.state_summary_status
92+
!= new_state_summary.status
93+
):
94+
await self.publish_current_maintenance_run_async()
95+
self._engine_state_slice.state_summary_status = new_state_summary.status
96+
3397

3498
_maintenance_runs_publisher_accessor: AppStateAccessor[
3599
MaintenanceRunsPublisher
@@ -39,6 +103,7 @@ def publish_current_maintenance_run(
39103
async def get_maintenance_runs_publisher(
40104
app_state: AppState = Depends(get_app_state),
41105
notification_client: NotificationClient = Depends(get_notification_client),
106+
publisher_notifier: PublisherNotifier = Depends(get_pe_publisher_notifier),
42107
) -> MaintenanceRunsPublisher:
43108
"""Get a singleton MaintenanceRunsPublisher to publish maintenance run topics."""
44109
maintenance_runs_publisher = _maintenance_runs_publisher_accessor.get_from(
@@ -47,7 +112,7 @@ async def get_maintenance_runs_publisher(
47112

48113
if maintenance_runs_publisher is None:
49114
maintenance_runs_publisher = MaintenanceRunsPublisher(
50-
client=notification_client
115+
client=notification_client, publisher_notifier=publisher_notifier
51116
)
52117
_maintenance_runs_publisher_accessor.set_on(
53118
app_state, maintenance_runs_publisher

robot-server/tests/service/notifications/publishers/test_maintenance_runs_publisher.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,30 @@
11
"""Tests for the maintenance runs publisher."""
22
import pytest
3-
from unittest.mock import AsyncMock
3+
from unittest.mock import AsyncMock, Mock
44

55
from robot_server.service.notifications import MaintenanceRunsPublisher, topics
6+
from robot_server.service.notifications.notification_client import NotificationClient
7+
from robot_server.service.notifications.publisher_notifier import PublisherNotifier
68

79

810
@pytest.fixture
9-
def notification_client() -> AsyncMock:
11+
def notification_client() -> Mock:
1012
"""Mocked notification client."""
11-
return AsyncMock()
13+
return Mock(spec_set=NotificationClient)
14+
15+
16+
@pytest.fixture
17+
def publisher_notifier() -> Mock:
18+
"""Mocked publisher notifier."""
19+
return Mock(spec_set=PublisherNotifier)
1220

1321

1422
@pytest.fixture
1523
def maintenance_runs_publisher(
16-
notification_client: AsyncMock,
24+
notification_client: Mock, publisher_notifier: Mock
1725
) -> MaintenanceRunsPublisher:
1826
"""Instantiate MaintenanceRunsPublisher."""
19-
return MaintenanceRunsPublisher(notification_client)
27+
return MaintenanceRunsPublisher(notification_client, publisher_notifier)
2028

2129

2230
@pytest.mark.asyncio

0 commit comments

Comments
 (0)