diff --git a/api/src/opentrons/api/dev_types.py b/api/src/opentrons/api/dev_types.py new file mode 100644 index 00000000000..c22cca08a9a --- /dev/null +++ b/api/src/opentrons/api/dev_types.py @@ -0,0 +1,17 @@ +from typing_extensions import Literal, TypedDict + +State = Literal[ + 'loaded', 'running', 'finished', 'stopped', 'paused', 'error', None] + + +class StateInfo(TypedDict, total=False): + message: str + #: A message associated with the state change by the system for display + changedAt: float + #: The time at which the state changed, relative to the startTime + estimatedDuration: float + #: If relevant for the state (e.g. 'paused' caused by a delay() call) the + #: duration estimated for the state + userMessage: str + #: If provided by the mechanism that changed the state, a message from the + #: user diff --git a/api/src/opentrons/api/session.py b/api/src/opentrons/api/session.py index f235e768819..f7b2115e123 100755 --- a/api/src/opentrons/api/session.py +++ b/api/src/opentrons/api/session.py @@ -4,7 +4,7 @@ from functools import reduce, wraps import logging from time import time, sleep -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any, Optional, Set, TYPE_CHECKING from uuid import uuid4 from opentrons.drivers.smoothie_drivers.driver_3_0 import SmoothieAlarm from opentrons.drivers.rpi_drivers.gpio_simulator import SimulatingGPIOCharDev @@ -27,9 +27,13 @@ from opentrons.legacy_api.containers import get_container, location_to_list +if TYPE_CHECKING: + from .dev_types import State, StateInfo + log = logging.getLogger(__name__) -VALID_STATES = {'loaded', 'running', 'finished', 'stopped', 'paused', 'error'} +VALID_STATES: Set['State'] = { + 'loaded', 'running', 'finished', 'stopped', 'paused', 'error'} def _motion_lock(func): @@ -241,7 +245,10 @@ def __init__(self, name, protocol, hardware, loop, broker, motion_lock): self._simulating_ctx = ProtocolContext.build_using( self._protocol, loop=self._loop, broker=self._broker) - self.state = None + self.state: 'State' = None + #: The current state + self.stateInfo: 'StateInfo' = {} + #: A message associated with the current state self.commands = [] self.command_log = {} self.errors = [] @@ -256,7 +263,7 @@ def __init__(self, name, protocol, hardware, loop, broker, motion_lock): self.modules = None self.protocol_text = protocol.text - self.startTime = None + self.startTime: Optional[float] = None self._motion_lock = motion_lock def _hw_iface(self): @@ -448,7 +455,10 @@ def stop(self): self.set_state('stopped') return self - def pause(self): + def pause(self, + reason: str = None, + user_message: str = None, + duration: float = None): if self._use_v2: self._hardware.pause() # robot.pause in the legacy API will publish commands to the broker @@ -456,7 +466,9 @@ def pause(self): else: robot.execute_pause() - self.set_state('paused') + self.set_state( + 'paused', reason=reason, + user_message=user_message, duration=duration) return self def resume(self): @@ -476,7 +488,9 @@ def on_command(message): if message['$'] == 'before': self.log_append() if message['name'] == command_types.PAUSE: - self.set_state('paused') + self.set_state('paused', + reason='The protocol paused execution', + user_message=message['payload']['userMessage']) if message['name'] == command_types.RESUME: self.set_state('running') @@ -553,13 +567,31 @@ def run(self): self._broker.set_logger(self._default_logger) return self - def set_state(self, state): - log.debug("State set to {}".format(state)) + def set_state(self, state: 'State', + reason: str = None, + user_message: str = None, + duration: float = None): if state not in VALID_STATES: raise ValueError( 'Invalid state: {0}. Valid states are: {1}' .format(state, VALID_STATES)) self.state = state + if user_message: + self.stateInfo['userMessage'] = user_message + else: + self.stateInfo.pop('userMessage', None) + if reason: + self.stateInfo['message'] = reason + else: + self.stateInfo.pop('message', None) + if duration: + self.stateInfo['estimatedDuration'] = duration + else: + self.stateInfo.pop('estimatedDuration', None) + if self.startTime: + self.stateInfo['changedAt'] = now()-self.startTime + else: + self.stateInfo.pop('changedAt', None) self._on_state_changed() def log_append(self): @@ -593,6 +625,7 @@ def _snapshot(self): payload = { 'state': self.state, + 'stateInfo': self.stateInfo, 'startTime': self.startTime, 'errors': self.errors, 'lastCommand': last_command diff --git a/api/src/opentrons/commands/commands.py b/api/src/opentrons/commands/commands.py index 8ce03f8cbac..180152b22a0 100755 --- a/api/src/opentrons/commands/commands.py +++ b/api/src/opentrons/commands/commands.py @@ -533,7 +533,8 @@ def pause(msg): return make_command( name=command_types.PAUSE, payload={ - 'text': text + 'text': text, + 'userMessage': msg, } ) diff --git a/api/tests/opentrons/api/test_session.py b/api/tests/opentrons/api/test_session.py index 0d7892efbe0..b86ea0bca92 100755 --- a/api/tests/opentrons/api/test_session.py +++ b/api/tests/opentrons/api/test_session.py @@ -3,6 +3,7 @@ import pytest import base64 +from opentrons.api import session from opentrons.api.session import ( _accumulate, _dedupe) from tests.opentrons.conftest import state @@ -132,6 +133,21 @@ def test_set_state(run_session): run_session.set_state('impossible-state') +def test_set_state_info(run_session, monkeypatch): + assert run_session.stateInfo == {} + run_session.set_state('paused', + reason='test1', + user_message='cool message', + duration=10) + assert run_session.stateInfo == {'message': 'test1', + 'userMessage': 'cool message', + 'estimatedDuration': 10} + run_session.startTime = 300 + monkeypatch.setattr(session, 'now', lambda: 350) + run_session.set_state('running') + assert run_session.stateInfo == {'changedAt': 50} + + def test_error_append(run_session): foo = Exception('Foo') bar = Exception('Bar') diff --git a/app/src/components/RunLog/CommandList.js b/app/src/components/RunLog/CommandList.js index aed4f38190d..b9a7a71a10a 100644 --- a/app/src/components/RunLog/CommandList.js +++ b/app/src/components/RunLog/CommandList.js @@ -7,11 +7,12 @@ import { SessionAlert } from './SessionAlert' import { Portal } from '../portal' import styles from './styles.css' -import type { SessionStatus } from '../../robot' +import type { SessionStatus, SessionStatusInfo } from '../../robot' export type CommandListProps = {| commands: Array, sessionStatus: SessionStatus, + sessionStatusInfo: SessionStatusInfo, showSpinner: boolean, onResetClick: () => mixed, |} @@ -23,7 +24,13 @@ export class CommandList extends React.Component { } render() { - const { commands, sessionStatus, showSpinner, onResetClick } = this.props + const { + commands, + sessionStatus, + sessionStatusInfo, + showSpinner, + onResetClick, + } = this.props const makeCommandToTemplateMapper = depth => command => { const { id, @@ -88,6 +95,7 @@ export class CommandList extends React.Component { {!showSpinner && ( diff --git a/app/src/components/RunLog/SessionAlert.js b/app/src/components/RunLog/SessionAlert.js index 2188dd9f974..f69d9e28117 100644 --- a/app/src/components/RunLog/SessionAlert.js +++ b/app/src/components/RunLog/SessionAlert.js @@ -3,16 +3,27 @@ import * as React from 'react' import { useSelector } from 'react-redux' import { AlertItem } from '@opentrons/components' import { getSessionError } from '../../robot/selectors' -import type { SessionStatus } from '../../robot' +import type { SessionStatus, SessionStatusInfo } from '../../robot' +import styles from './styles.css' + +const buildPauseMessage = (message: ?string): string => + message ? `: ${message}` : '' + +const buildPause = (message: ?string): string => + `Run paused${buildPauseMessage(message)}` + +const buildPauseUserMessage = (message: ?string) => + message &&
{message}
export type SessionAlertProps = {| sessionStatus: SessionStatus, + sessionStatusInfo: SessionStatusInfo, className?: string, onResetClick: () => mixed, |} export function SessionAlert(props: SessionAlertProps) { - const { sessionStatus, className, onResetClick } = props + const { sessionStatus, sessionStatusInfo, className, onResetClick } = props const sessionError = useSelector(getSessionError) switch (sessionStatus) { @@ -36,8 +47,10 @@ export function SessionAlert(props: SessionAlertProps) { className={className} type="info" icon={{ name: 'pause-circle' }} - title="Run paused" - /> + title={buildPause(sessionStatusInfo.message)} + > + {buildPauseUserMessage(sessionStatusInfo.userMessage)} + ) case 'stopped': diff --git a/app/src/components/RunLog/index.js b/app/src/components/RunLog/index.js index 927e3a78bd0..6cfe59257f4 100644 --- a/app/src/components/RunLog/index.js +++ b/app/src/components/RunLog/index.js @@ -9,7 +9,7 @@ import { import { CommandList } from './CommandList' import type { State, Dispatch } from '../../types' -import type { SessionStatus } from '../../robot' +import type { SessionStatus, SessionStatusInfo } from '../../robot' import type { CommandListProps } from './CommandList' export { ConfirmCancelModal } from './ConfirmCancelModal' @@ -17,6 +17,7 @@ export { ConfirmCancelModal } from './ConfirmCancelModal' type SP = {| commands: Array, sessionStatus: SessionStatus, + sessionStatusInfo: SessionStatusInfo, showSpinner: boolean, |} @@ -33,6 +34,7 @@ function mapStateToProps(state: State): SP { return { commands: robotSelectors.getCommands(state), sessionStatus: robotSelectors.getSessionStatus(state), + sessionStatusInfo: robotSelectors.getSessionStatusInfo(state), showSpinner: robotSelectors.getCancelInProgress(state) || robotSelectors.getSessionLoadInProgress(state), diff --git a/app/src/components/RunLog/styles.css b/app/src/components/RunLog/styles.css index 1a117b83cf5..7230f2b9098 100644 --- a/app/src/components/RunLog/styles.css +++ b/app/src/components/RunLog/styles.css @@ -2,6 +2,14 @@ /* stylelint-disable selector-class-pattern, font-family-no-missing-generic-family-keyword */ @import '@opentrons/components'; +.pause_user_message { + margin-left: 0.5rem; + margin-right: 0.5rem; + font-style: italic; + overflow-y: scroll; + max-height: 4rem; +} + .run_page { position: relative; } diff --git a/app/src/robot/api-client/client.js b/app/src/robot/api-client/client.js index 31e8ddd0060..35954ac0b88 100755 --- a/app/src/robot/api-client/client.js +++ b/app/src/robot/api-client/client.js @@ -453,6 +453,13 @@ export function client(dispatch) { clearRunTimerInterval() } + update.statusInfo = { + message: apiSession.stateInfo?.message ?? null, + userMessage: apiSession.stateInfo?.userMessage ?? null, + changedAt: apiSession.stateInfo?.changedAt ?? null, + estimatedDuration: apiSession.stateInfo?.estimatedDuration ?? null, + } + // both light and full updates may have the errors list if (apiSession.errors) { update.errors = apiSession.errors.map(e => ({ diff --git a/app/src/robot/reducer/session.js b/app/src/robot/reducer/session.js index d803f7ee860..0b5ac9c5beb 100644 --- a/app/src/robot/reducer/session.js +++ b/app/src/robot/reducer/session.js @@ -12,6 +12,7 @@ import type { Mount, Slot, SessionStatus, + SessionStatusInfo, } from '../types' import type { InvalidProtocolFileAction } from '../../protocol/types' @@ -25,6 +26,7 @@ type Request = { export type SessionState = { sessionRequest: Request, state: SessionStatus, + statusInfo: SessionStatusInfo, errors: Array<{| timestamp: number, line: number, @@ -72,6 +74,12 @@ const INITIAL_STATE: SessionState = { // loading a protocol sessionRequest: { inProgress: false, error: null }, state: '', + statusInfo: { + message: null, + changedAt: null, + estimatedDuration: null, + userMessage: null, + }, errors: [], protocolCommands: [], protocolCommandsById: {}, @@ -196,6 +204,7 @@ function handleSessionUpdate( return { ...state, state: sessionState, + statusInfo: action.payload.statusInfo, remoteTimeCompensation, startTime, protocolCommandsById, diff --git a/app/src/robot/selectors.js b/app/src/robot/selectors.js index e270f1852d7..fd0c38595b7 100644 --- a/app/src/robot/selectors.js +++ b/app/src/robot/selectors.js @@ -22,6 +22,7 @@ import type { LabwareCalibrationStatus, LabwareType, SessionStatus, + SessionStatusInfo, SessionModule, TiprackByMountMap, } from './types' @@ -83,6 +84,10 @@ export function getSessionStatus(state: State): SessionStatus { return session(state).state } +export function getSessionStatusInfo(state: State): SessionStatusInfo { + return session(state).statusInfo +} + export function getSessionIsLoaded(state: State): boolean { return getSessionStatus(state) !== ('': SessionStatus) } diff --git a/app/src/robot/test/__fixtures__/session.js b/app/src/robot/test/__fixtures__/session.js index 7009bfd8abc..5a20be76551 100644 --- a/app/src/robot/test/__fixtures__/session.js +++ b/app/src/robot/test/__fixtures__/session.js @@ -7,6 +7,7 @@ export function MockSession() { commands: [], command_log: {}, state: 'loaded', + stateInfo: {}, instruments: [], containers: [], @@ -20,3 +21,23 @@ export function MockSession() { refresh: jest.fn(), } } + +export function MockSessionNoStateInfo() { + return { + name: 'MOCK SESSION', + protocol_text: '# mock protocol text', + commands: [], + command_log: {}, + state: 'loaded', + instruments: [], + containers: [], + + modules: [], + + run: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + stop: jest.fn(), + refresh: jest.fn(), + } +} diff --git a/app/src/robot/test/api-client.test.js b/app/src/robot/test/api-client.test.js index 743e47228ac..9fdfbe92e53 100755 --- a/app/src/robot/test/api-client.test.js +++ b/app/src/robot/test/api-client.test.js @@ -8,7 +8,7 @@ import { Client as RpcClient } from '../../rpc/client' import { NAME, actions, constants } from '../' import * as AdminActions from '../../robot-admin/actions' -import { MockSession } from './__fixtures__/session' +import { MockSession, MockSessionNoStateInfo } from './__fixtures__/session' import { MockCalibrationManager } from './__fixtures__/calibration-manager' import { getLabwareDefBySlot } from '../../protocol/selectors' @@ -353,6 +353,12 @@ describe('api client', () => { { name: session.name, state: session.state, + statusInfo: { + message: null, + userMessage: null, + changedAt: null, + estimatedDuration: null, + }, protocolText: session.protocol_text, protocolCommands: [], protocolCommandsById: {}, @@ -380,6 +386,49 @@ describe('api client', () => { .then(() => expect(dispatch).toHaveBeenCalledWith(expectedInitial)) }) + it('handles sessionResponses without status info', () => { + session = MockSessionNoStateInfo() + sessionManager.session = session + return sendConnect() + .then(() => { + dispatch.mockClear() + sendNotification('session', session) + }) + .then(() => expect(dispatch).toHaveBeenCalledWith(expectedInitial)) + }) + + it('handles sessionResponses with some status info set', () => { + session.stateInfo.changedAt = 2 + session.stateInfo.message = 'test message' + const expected = actions.sessionResponse( + null, + { + name: session.name, + state: session.state, + statusInfo: { + message: 'test message', + userMessage: null, + changedAt: 2, + estimatedDuration: null, + }, + protocolText: session.protocol_text, + protocolCommands: [], + protocolCommandsById: {}, + pipettesByMount: {}, + labwareBySlot: {}, + modulesBySlot: {}, + apiLevel: [1, 0], + }, + false + ) + return sendConnect() + .then(() => { + dispatch.mockClear() + sendNotification('session', session) + }) + .then(() => expect(dispatch).toHaveBeenCalledWith(expected)) + }) + it('handles connnect without session', () => { const notExpected = actions.sessionResponse( null, @@ -686,8 +735,78 @@ describe('api client', () => { }) it('sends SESSION_UPDATE if session notification has lastCommand', () => { - const update = { state: 'running', startTime: 1, lastCommand: null } - const expected = actions.sessionUpdate(update, expect.any(Number)) + const update = { + state: 'running', + startTime: 1, + lastCommand: null, + stateInfo: {}, + } + + const actionInput = { + state: 'running', + startTime: 1, + lastCommand: null, + statusInfo: { + message: null, + userMessage: null, + changedAt: null, + estimatedDuration: null, + }, + } + const expected = actions.sessionUpdate(actionInput, expect.any(Number)) + + return sendConnect() + .then(() => sendNotification('session', update)) + .then(() => expect(dispatch).toHaveBeenCalledWith(expected)) + }) + + it('handles SESSION_UPDATEs with no stateInfo', () => { + const update = { + state: 'running', + startTime: 2, + lastCommand: null, + } + + const actionInput = { + ...update, + statusInfo: { + message: null, + userMessage: null, + changedAt: null, + estimatedDuration: null, + }, + } + const expected = actions.sessionUpdate(actionInput, expect.any(Number)) + + return sendConnect() + .then(() => sendNotification('session', update)) + .then(() => expect(dispatch).toHaveBeenCalledWith(expected)) + }) + + it('handles SESSION_UPDATEs with values in stateInfo', () => { + const update = { + state: 'running', + startTime: 2, + lastCommand: null, + stateInfo: { + message: 'hi and hello football fans', + userMessage: 'whos ready for some FOOTBALL', + changedAt: 2, + }, + } + + const actionInput = { + state: 'running', + startTime: 2, + lastCommand: null, + statusInfo: { + message: 'hi and hello football fans', + userMessage: 'whos ready for some FOOTBALL', + changedAt: 2, + estimatedDuration: null, + }, + } + const expected = actions.sessionUpdate(actionInput, expect.any(Number)) return sendConnect() .then(() => sendNotification('session', update)) diff --git a/app/src/robot/test/session-reducer.test.js b/app/src/robot/test/session-reducer.test.js index def8b808089..0deefd084bb 100644 --- a/app/src/robot/test/session-reducer.test.js +++ b/app/src/robot/test/session-reducer.test.js @@ -16,6 +16,12 @@ describe('robot reducer - session', () => { // loading a protocol sessionRequest: { inProgress: false, error: null }, state: '', + statusInfo: { + message: null, + userMessage: null, + changedAt: null, + estimatedDuration: null, + }, errors: [], protocolCommands: [], protocolCommandsById: {}, diff --git a/app/src/robot/types.js b/app/src/robot/types.js index 88f060241dc..9b5103984d5 100644 --- a/app/src/robot/types.js +++ b/app/src/robot/types.js @@ -157,8 +157,16 @@ export type ConnectionStatus = | CONNECTED | DISCONNECTING +export type SessionStatusInfo = {| + message: string | null, + changedAt: number | null, + estimatedDuration: number | null, + userMessage: string | null, +|} + export type SessionUpdate = {| state: SessionStatus, + statusInfo: SessionStatusInfo, startTime: ?number, lastCommand: ?{| id: number,