diff --git a/app-shell/src/usb.ts b/app-shell/src/usb.ts index b09d1157fa1..816f06defa2 100644 --- a/app-shell/src/usb.ts +++ b/app-shell/src/usb.ts @@ -29,6 +29,7 @@ import type { Action, Dispatch } from './types' let usbHttpAgent: SerialPortHttpAgent | undefined const usbLog = createLogger('usb') +let usbFetchInterval: NodeJS.Timeout export function getSerialPortHttpAgent(): SerialPortHttpAgent | undefined { return usbHttpAgent @@ -43,6 +44,7 @@ export function createSerialPortHttpAgent(path: string): void { keepAliveMsecs: 10000, path, logger: usbLog, + timeout: 100000, }) usbHttpAgent = serialPortHttpAgent @@ -110,6 +112,48 @@ async function usbListener( } } +function pollSerialPortAndCreateAgent(dispatch: Dispatch): void { + // usb poll already initialized + if (usbFetchInterval != null) { + return + } + usbFetchInterval = setInterval(() => { + // already connected to an Opentrons robot via USB + if (getSerialPortHttpAgent() != null) { + return + } + usbLog.debug('fetching serialport list') + fetchSerialPortList() + .then((list: PortInfo[]) => { + const ot3UsbSerialPort = list.find( + port => + port.productId?.localeCompare(DEFAULT_PRODUCT_ID, 'en-US', { + sensitivity: 'base', + }) === 0 && + port.vendorId?.localeCompare(DEFAULT_VENDOR_ID, 'en-US', { + sensitivity: 'base', + }) === 0 + ) + + if (ot3UsbSerialPort == null) { + usbLog.debug('no OT-3 serial port found') + return + } + + createSerialPortHttpAgent(ot3UsbSerialPort.path) + // remove any existing handler + ipcMain.removeHandler('usb:request') + ipcMain.handle('usb:request', usbListener) + + dispatch(usbRequestsStart()) + }) + .catch(e => + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + usbLog.debug(`fetchSerialPortList error ${e?.message ?? 'unknown'}`) + ) + }, 10000) +} + function startUsbHttpRequests(dispatch: Dispatch): void { fetchSerialPortList() .then((list: PortInfo[]) => { @@ -150,6 +194,7 @@ export function registerUsb(dispatch: Dispatch): (action: Action) => unknown { if (action.payload.usbDevices.find(isUsbDeviceOt3) != null) { startUsbHttpRequests(dispatch) } + pollSerialPortAndCreateAgent(dispatch) break case USB_DEVICE_ADDED: if (isUsbDeviceOt3(action.payload.usbDevice)) { diff --git a/app/src/redux/robot-api/http.ts b/app/src/redux/robot-api/http.ts index 098493913c6..71840066a9f 100644 --- a/app/src/redux/robot-api/http.ts +++ b/app/src/redux/robot-api/http.ts @@ -4,6 +4,7 @@ import { map, switchMap, catchError } from 'rxjs/operators' import mapValues from 'lodash/mapValues' import toString from 'lodash/toString' import omitBy from 'lodash/omitBy' +import inRange from 'lodash/inRange' import { OPENTRONS_USB } from '../../redux/discovery' import { appShellRequestor } from '../../redux/shell/remote' @@ -68,7 +69,7 @@ export function fetchRobotApi( body: response?.data, status: response?.status, // appShellRequestor eventually calls axios.request, which doesn't provide an ok boolean in the response - ok: response?.statusText === 'OK', + ok: inRange(response?.status, 200, 300), })) ) : from(fetch(url, options)).pipe( diff --git a/app/src/redux/robot-update/__tests__/epic.test.ts b/app/src/redux/robot-update/__tests__/epic.test.ts index d6ad7d46652..92172ec3852 100644 --- a/app/src/redux/robot-update/__tests__/epic.test.ts +++ b/app/src/redux/robot-update/__tests__/epic.test.ts @@ -286,23 +286,78 @@ describe('robot update epics', () => { }) }) - it('issues error if begin request fails without 409', () => { + it('sends request to cancel URL if a non 409 occurs and reissues CREATE_SESSION', () => { testScheduler.run(({ hot, cold, expectObservable, flush }) => { const action = actions.createSession(robot, '/server/update/begin') - mockFetchRobotApi.mockReturnValueOnce( - cold('r', { r: Fixtures.mockUpdateBeginFailure }) - ) + mockFetchRobotApi + .mockReturnValueOnce( + cold('r', { r: Fixtures.mockUpdateBeginFailure }) + ) + .mockReturnValueOnce( + cold('r', { r: Fixtures.mockUpdateCancelSuccess }) + ) const action$ = hot('-a', { a: action }) const state$ = hot('a-', { a: state } as any) const output$ = epics.createSessionEpic(action$, state$) - expectObservable(output$).toBe('-e', { - e: actions.unexpectedRobotUpdateError( + expectObservable(output$).toBe('-a', { a: action }) + flush() + expect(mockFetchRobotApi).toHaveBeenCalledWith(robot, { + method: 'POST', + path: '/server/update/cancel', + }) + }) + }) + + it('Issues an error if cancelling a session fails after a 409 error occurs', () => { + testScheduler.run(({ hot, cold, expectObservable, flush }) => { + const action = actions.createSession(robot, '/server/update/begin') + + mockFetchRobotApi + .mockReturnValueOnce( + cold('r', { r: Fixtures.mockUpdateBeginConflict }) + ) + .mockReturnValueOnce( + cold('r', { r: Fixtures.mockUpdateCancelFailure }) + ) + + const action$ = hot('-a', { a: action }) + const state$ = hot('a-', { a: state } as any) + const output$ = epics.createSessionEpic(action$, state$) + + expectObservable(output$).toBe('-a', { + a: actions.unexpectedRobotUpdateError( + 'Unable to cancel in-progress update session' + ), + }) + flush() + }) + }) + + it('Issues an error if cancelling a session fails after a non 409 error occurs', () => { + testScheduler.run(({ hot, cold, expectObservable, flush }) => { + const action = actions.createSession(robot, '/server/update/begin') + + mockFetchRobotApi + .mockReturnValueOnce( + cold('r', { r: Fixtures.mockUpdateBeginFailure }) + ) + .mockReturnValueOnce( + cold('r', { r: Fixtures.mockUpdateCancelFailure }) + ) + + const action$ = hot('-a', { a: action }) + const state$ = hot('a-', { a: state } as any) + const output$ = epics.createSessionEpic(action$, state$) + + expectObservable(output$).toBe('-a', { + a: actions.unexpectedRobotUpdateError( 'Unable to start update session' ), }) + flush() }) }) diff --git a/app/src/redux/robot-update/epic.ts b/app/src/redux/robot-update/epic.ts index e53cea99c84..07c22e03555 100644 --- a/app/src/redux/robot-update/epic.ts +++ b/app/src/redux/robot-update/epic.ts @@ -219,7 +219,16 @@ export const createSessionEpic: Epic = action$ => { ) } - return of(unexpectedRobotUpdateError(UNABLE_TO_START_UPDATE_SESSION)) + return fetchRobotApi(host, { + method: POST, + path: `${pathPrefix}/cancel`, + }).pipe( + map(cancelResp => { + return cancelResp.ok + ? createSession(host, path) + : unexpectedRobotUpdateError(UNABLE_TO_START_UPDATE_SESSION) + }) + ) }) ) } diff --git a/usb-bridge/node-client/src/usb-agent.ts b/usb-bridge/node-client/src/usb-agent.ts index 7d6074cd336..c6dfdeaf38a 100644 --- a/usb-bridge/node-client/src/usb-agent.ts +++ b/usb-bridge/node-client/src/usb-agent.ts @@ -108,6 +108,7 @@ export function createSerialPortListMonitor( return { start, stop } } +const SOCKET_OPEN_RETRY_TIME = 10000 class SerialPortSocket extends SerialPort { // allow node socket destroy destroy(): void {} @@ -195,10 +196,18 @@ class SerialPortHttpAgent extends http.Agent { const socket = new SerialPortSocket({ path: this.options.path, - baudRate: 115200, + baudRate: 1152000, }) if (!socket.isOpen && !socket.opening) { - socket.open() + socket.open(error => { + this.log( + 'error', + `could not open serialport socket: ${error?.message}. Retrying in ${SOCKET_OPEN_RETRY_TIME} ms` + ) + setTimeout(() => { + socket.open() + }, SOCKET_OPEN_RETRY_TIME) + }) } if (socket != null) oncreate(null, socket) } @@ -230,6 +239,11 @@ function installListeners( } s.on('free', onFree) + function onError(err: Error): void { + agent.log('error', `CLIENT socket onError: ${err?.message}`) + } + s.on('error', onError) + function onClose(): void { agent.log('debug', 'CLIENT socket onClose') // This is the only place where sockets get removed from the Agent. @@ -241,18 +255,15 @@ function installListeners( s.on('close', onClose) function onTimeout(): void { - agent.log('debug', 'CLIENT socket onTimeout') - - // Destroy if in free list. - // TODO(ronag): Always destroy, even if not in free list. - const sockets = agent.freeSockets - if ( - Object.keys(sockets).some(name => - sockets[name]?.includes((s as unknown) as Socket) - ) - ) { - return s.destroy() - } + agent.log( + 'debug', + 'CLIENT socket onTimeout, closing and reopening the socket' + ) + + s.close() + setTimeout(() => { + s.open() + }, 3000) } s.on('timeout', onTimeout)