diff --git a/.vscode/launch.json b/.vscode/launch.json index c3f37149e..5c336c081 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,10 +8,6 @@ "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", "windows": { "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd", - "env": { - "NODE_ENV": "development", - "NODE_PRESERVE_SYMLINKS": "1" - } }, "cwd": "${workspaceFolder}/electron-app", "protocol": "inspector", diff --git a/arduino-ide-extension/package.json b/arduino-ide-extension/package.json index 9a46e057e..47b28fbf4 100644 --- a/arduino-ide-extension/package.json +++ b/arduino-ide-extension/package.json @@ -19,12 +19,11 @@ "test:watch": "mocha --watch --watch-files lib \"./lib/test/**/*.test.js\"" }, "dependencies": { - "arduino-serial-plotter-webapp": "0.0.15", "@grpc/grpc-js": "^1.3.7", "@theia/application-package": "1.19.0", "@theia/core": "1.19.0", "@theia/editor": "1.19.0", - "@theia/editor-preview": "1.19.0", + "@theia/editor-preview": "1.19.0", "@theia/filesystem": "1.19.0", "@theia/git": "1.19.0", "@theia/keymaps": "1.19.0", @@ -53,10 +52,10 @@ "@types/ps-tree": "^1.1.0", "@types/react-select": "^3.0.0", "@types/react-tabs": "^2.3.2", - "@types/sinon": "^7.5.2", "@types/temp": "^0.8.34", "@types/which": "^1.3.1", "ajv": "^6.5.3", + "arduino-serial-plotter-webapp": "0.0.15", "async-mutex": "^0.3.0", "atob": "^2.1.2", "auth0-js": "^9.14.0", @@ -97,6 +96,8 @@ "@types/chai-string": "^1.4.2", "@types/mocha": "^5.2.7", "@types/react-window": "^1.8.5", + "@types/sinon": "^10.0.6", + "@types/sinon-chai": "^3.2.6", "chai": "^4.2.0", "chai-string": "^1.5.0", "decompress": "^4.2.0", @@ -109,7 +110,8 @@ "moment": "^2.24.0", "protoc": "^1.0.4", "shelljs": "^0.8.3", - "sinon": "^9.0.1", + "sinon": "^12.0.1", + "sinon-chai": "^3.7.0", "typemoq": "^2.1.0", "uuid": "^3.2.1", "yargs": "^11.1.0" diff --git a/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts b/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts index 4ac79792b..75aaef8fa 100644 --- a/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts +++ b/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts @@ -48,7 +48,6 @@ export class BurnBootloader extends SketchContribution { } async burnBootloader(): Promise { - await this.serialConnection.disconnect(); try { const { boardsConfig } = this.boardsServiceClientImpl; const port = boardsConfig.selectedPort; @@ -87,9 +86,7 @@ export class BurnBootloader extends SketchContribution { } this.messageService.error(errorMessage); } finally { - if (this.serialConnection.isSerialOpen()) { - await this.serialConnection.connect(); - } + await this.serialConnection.reconnectAfterUpload(); } } } diff --git a/arduino-ide-extension/src/browser/contributions/upload-sketch.ts b/arduino-ide-extension/src/browser/contributions/upload-sketch.ts index e32d0793f..fd294ed28 100644 --- a/arduino-ide-extension/src/browser/contributions/upload-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/upload-sketch.ts @@ -210,7 +210,7 @@ export class UploadSketch extends SketchContribution { if (!sketch) { return; } - await this.serialConnection.disconnect(); + try { const { boardsConfig } = this.boardsServiceClientImpl; const [fqbn, { selectedProgrammer }, verify, verbose, sourceOverride] = @@ -288,27 +288,7 @@ export class UploadSketch extends SketchContribution { this.uploadInProgress = false; this.onDidChangeEmitter.fire(); - if ( - this.serialConnection.isSerialOpen() && - this.serialConnection.serialConfig - ) { - const { board, port } = this.serialConnection.serialConfig; - try { - await this.boardsServiceClientImpl.waitUntilAvailable( - Object.assign(board, { port }), - 10_000 - ); - await this.serialConnection.connect(); - } catch (waitError) { - this.messageService.error( - nls.localize( - 'arduino/sketch/couldNotConnectToSerial', - 'Could not reconnect to serial port. {0}', - waitError.toString() - ) - ); - } - } + setTimeout(() => this.serialConnection.reconnectAfterUpload(), 5000); } } } diff --git a/arduino-ide-extension/src/browser/serial/monitor/monitor-widget.tsx b/arduino-ide-extension/src/browser/serial/monitor/monitor-widget.tsx index b1ae21953..f5e68df54 100644 --- a/arduino-ide-extension/src/browser/serial/monitor/monitor-widget.tsx +++ b/arduino-ide-extension/src/browser/serial/monitor/monitor-widget.tsx @@ -12,7 +12,7 @@ import { import { SerialConfig } from '../../../common/protocol/serial-service'; import { ArduinoSelect } from '../../widgets/arduino-select'; import { SerialModel } from '../serial-model'; -import { Serial, SerialConnectionManager } from '../serial-connection-manager'; +import { SerialConnectionManager } from '../serial-connection-manager'; import { SerialMonitorSendInput } from './serial-monitor-send-input'; import { SerialMonitorOutput } from './serial-monitor-send-output'; import { BoardsServiceProvider } from '../../boards/boards-service-provider'; @@ -57,9 +57,7 @@ export class MonitorWidget extends ReactWidget { this.scrollOptions = undefined; this.toDispose.push(this.clearOutputEmitter); this.toDispose.push( - Disposable.create(() => - this.serialConnection.closeSerial(Serial.Type.Monitor) - ) + Disposable.create(() => this.serialConnection.closeWStoBE()) ); } @@ -83,7 +81,7 @@ export class MonitorWidget extends ReactWidget { protected onAfterAttach(msg: Message): void { super.onAfterAttach(msg); - this.serialConnection.openSerial(Serial.Type.Monitor); + this.serialConnection.openWSToBE(); } onCloseRequest(msg: Message): void { @@ -171,7 +169,7 @@ export class MonitorWidget extends ReactWidget {
diff --git a/arduino-ide-extension/src/browser/serial/monitor/serial-monitor-send-input.tsx b/arduino-ide-extension/src/browser/serial/monitor/serial-monitor-send-input.tsx index 6c06c2f32..5b730c17a 100644 --- a/arduino-ide-extension/src/browser/serial/monitor/serial-monitor-send-input.tsx +++ b/arduino-ide-extension/src/browser/serial/monitor/serial-monitor-send-input.tsx @@ -1,18 +1,20 @@ import * as React from 'react'; import { Key, KeyCode } from '@theia/core/lib/browser/keys'; import { Board, Port } from '../../../common/protocol/boards-service'; -import { SerialConfig } from '../../../common/protocol/serial-service'; import { isOSX } from '@theia/core/lib/common/os'; -import { nls } from '@theia/core/lib/common'; +import { DisposableCollection, nls } from '@theia/core/lib/common'; +import { SerialConnectionManager } from '../serial-connection-manager'; +import { SerialPlotter } from '../plotter/protocol'; export namespace SerialMonitorSendInput { export interface Props { - readonly serialConfig?: SerialConfig; + readonly serialConnection: SerialConnectionManager; readonly onSend: (text: string) => void; readonly resolveFocus: (element: HTMLElement | undefined) => void; } export interface State { text: string; + connected: boolean; } } @@ -20,20 +22,45 @@ export class SerialMonitorSendInput extends React.Component< SerialMonitorSendInput.Props, SerialMonitorSendInput.State > { + protected toDisposeBeforeUnmount = new DisposableCollection(); + constructor(props: Readonly) { super(props); - this.state = { text: '' }; + this.state = { text: '', connected: false }; this.onChange = this.onChange.bind(this); this.onSend = this.onSend.bind(this); this.onKeyDown = this.onKeyDown.bind(this); } + componentDidMount(): void { + this.props.serialConnection.isBESerialConnected().then((connected) => { + this.setState({ connected }); + }); + + this.toDisposeBeforeUnmount.pushAll([ + this.props.serialConnection.onRead(({ messages }) => { + if ( + messages.command === + SerialPlotter.Protocol.Command.MIDDLEWARE_CONFIG_CHANGED && + 'connected' in messages.data + ) { + this.setState({ connected: messages.data.connected }); + } + }), + ]); + } + + componentWillUnmount(): void { + // TODO: "Your preferred browser's local storage is almost full." Discard `content` before saving layout? + this.toDisposeBeforeUnmount.dispose(); + } + render(): React.ReactNode { return ( { if (!!this.window) { this.window = null; - await this.serialConnection.closeSerial(Serial.Type.Plotter); } }); - return super.onStart(app); } @@ -77,17 +75,15 @@ export class PlotterFrontendContribution extends Contribution { this.window.focus(); return; } - const status = await this.serialConnection.openSerial(Serial.Type.Plotter); const wsPort = this.serialConnection.getWsPort(); - if (Status.isOK(status) && wsPort) { + if (wsPort) { this.open(wsPort); } else { - this.serialConnection.closeSerial(Serial.Type.Plotter); this.messageService.error(`Couldn't open serial plotter`); } } - protected open(wsPort: number): void { + protected async open(wsPort: number): Promise { const initConfig: Partial = { baudrates: SerialConfig.BaudRates.map((b) => b), currentBaudrate: this.model.baudRate, @@ -95,7 +91,7 @@ export class PlotterFrontendContribution extends Contribution { darkTheme: this.themeService.getCurrentTheme().type === 'dark', wsPort, interpolate: this.model.interpolate, - connected: this.serialConnection.connected, + connected: await this.serialConnection.isBESerialConnected(), serialPort: this.boardsServiceProvider.boardsConfig.selectedPort?.address, }; const urlWithParams = queryString.stringifyUrl( diff --git a/arduino-ide-extension/src/browser/serial/serial-connection-manager.ts b/arduino-ide-extension/src/browser/serial/serial-connection-manager.ts index 06d8ed3c1..5c29cf566 100644 --- a/arduino-ide-extension/src/browser/serial/serial-connection-manager.ts +++ b/arduino-ide-extension/src/browser/serial/serial-connection-manager.ts @@ -1,5 +1,4 @@ import { injectable, inject } from 'inversify'; -import { deepClone } from '@theia/core/lib/common/objects'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { MessageService } from '@theia/core/lib/common/message-service'; import { @@ -23,8 +22,6 @@ import { nls } from '@theia/core/lib/common/nls'; @injectable() export class SerialConnectionManager { - protected _state: Serial.State = []; - protected _connected = false; protected config: Partial = { board: undefined, port: undefined, @@ -62,7 +59,9 @@ export class SerialConnectionManager { protected readonly boardsServiceProvider: BoardsServiceProvider, @inject(MessageService) protected messageService: MessageService, @inject(ThemeService) protected readonly themeService: ThemeService, - @inject(CoreService) protected readonly core: CoreService + @inject(CoreService) protected readonly core: CoreService, + @inject(BoardsServiceProvider) + protected readonly boardsServiceClientImpl: BoardsServiceProvider ) { this.serialServiceClient.onWebSocketChanged( this.handleWebSocketChanged.bind(this) @@ -89,8 +88,11 @@ export class SerialConnectionManager { ); // Handles the `baudRate` changes by reconnecting if required. - this.serialModel.onChange(({ property }) => { - if (property === 'baudRate' && this.connected) { + this.serialModel.onChange(async ({ property }) => { + if ( + property === 'baudRate' && + (await this.serialService.isSerialPortOpen()) + ) { const { boardsConfig } = this.boardsServiceProvider; this.handleBoardConfigChange(boardsConfig); } @@ -114,8 +116,8 @@ export class SerialConnectionManager { } /** - * Set the config passing only the properties that has changed. If some has changed and the serial is open, - * we try to reconnect + * Updated the config in the BE passing only the properties that has changed. + * BE will create a new connection if needed. * * @param newConfig the porperties of the config that has changed */ @@ -127,17 +129,16 @@ export class SerialConnectionManager { this.config = { ...this.config, [key]: newConfig[key] }; } }); - if ( - configHasChanged && - this.isSerialOpen() && - !(await this.core.isUploading()) - ) { + + if (configHasChanged) { this.serialService.updateWsConfigParam({ currentBaudrate: this.config.baudRate, serialPort: this.config.port?.address, }); - await this.disconnect(); - await this.connect(); + + if (isSerialConfig(this.config)) { + this.serialService.setSerialConfig(this.config); + } } } @@ -149,134 +150,56 @@ export class SerialConnectionManager { return this.wsPort; } - isWebSocketConnected(): boolean { - return !!this.webSocket?.url; - } - protected handleWebSocketChanged(wsPort: number): void { this.wsPort = wsPort; } - /** - * When the serial is open and the frontend is connected to the serial, we create the websocket here - */ - protected createWsConnection(): boolean { - if (this.wsPort) { - try { - this.webSocket = new WebSocket(`ws://localhost:${this.wsPort}`); - this.webSocket.onmessage = (res) => { - const messages = JSON.parse(res.data); - this.onReadEmitter.fire({ messages }); - }; - return true; - } catch { - return false; - } - } - return false; - } - - /** - * Sets the types of connections needed by the client. - * - * @param newState The array containing the list of desired connections. - * If the previuos state was empty and 'newState' is not, it tries to reconnect to the serial service - * If the provios state was NOT empty and now it is, it disconnects to the serial service - * @returns The status of the operation - */ - protected async setState(newState: Serial.State): Promise { - const oldState = deepClone(this._state); - let status = Status.OK; - - if (this.isSerialOpen(oldState) && !this.isSerialOpen(newState)) { - status = await this.disconnect(); - } else if (!this.isSerialOpen(oldState) && this.isSerialOpen(newState)) { - if (await this.core.isUploading()) { - this.messageService.error(`Cannot open serial port when uploading`); - return Status.NOT_CONNECTED; - } - status = await this.connect(); - } - this._state = newState; - return status; - } - - protected get state(): Serial.State { - return this._state; - } - - isSerialOpen(state?: Serial.State): boolean { - return (state ? state : this._state).length > 0; - } - get serialConfig(): SerialConfig | undefined { return isSerialConfig(this.config) ? (this.config as SerialConfig) : undefined; } - get connected(): boolean { - return this._connected; + async isBESerialConnected(): Promise { + return await this.serialService.isSerialPortOpen(); } - set connected(c: boolean) { - this._connected = c; - this.serialService.updateWsConfigParam({ connected: c }); - this.onConnectionChangedEmitter.fire(this._connected); - } - /** - * Called when a client opens the serial from the GUI - * - * @param type could be either 'Monitor' or 'Plotter'. If it's 'Monitor' we also connect to the websocket and - * listen to the message events - * @returns the status of the operation - */ - async openSerial(type: Serial.Type): Promise { + openWSToBE(): void { if (!isSerialConfig(this.config)) { this.messageService.error( `Please select a board and a port to open the serial connection.` ); - return Status.NOT_CONNECTED; } - if (this.state.includes(type)) return Status.OK; - const newState = deepClone(this.state); - newState.push(type); - const status = await this.setState(newState); - if (Status.isOK(status) && type === Serial.Type.Monitor) - this.createWsConnection(); - return status; + + if (!this.webSocket && this.wsPort) { + try { + this.webSocket = new WebSocket(`ws://localhost:${this.wsPort}`); + this.webSocket.onmessage = (res) => { + const messages = JSON.parse(res.data); + this.onReadEmitter.fire({ messages }); + }; + } catch { + this.messageService.error(`Unable to connect to websocket`); + } + } } - /** - * Called when a client closes the serial from the GUI - * - * @param type could be either 'Monitor' or 'Plotter'. If it's 'Monitor' we close the websocket connection - * @returns the status of the operation - */ - async closeSerial(type: Serial.Type): Promise { - const index = this.state.indexOf(type); - let status = Status.OK; - if (index >= 0) { - const newState = deepClone(this.state); - newState.splice(index, 1); - status = await this.setState(newState); - if ( - Status.isOK(status) && - type === Serial.Type.Monitor && - this.webSocket - ) { + closeWStoBE(): void { + if (this.webSocket) { + try { this.webSocket.close(); this.webSocket = undefined; + } catch { + this.messageService.error(`Unable to close websocket`); } } - return status; } /** * Handles error on the SerialServiceClient and try to reconnect, eventually */ - handleError(error: SerialError): void { - if (!this.connected) return; + async handleError(error: SerialError): Promise { + if (!(await this.serialService.isSerialPortOpen())) return; const { code, config } = error; const { board, port } = config; const options = { timeout: 3000 }; @@ -329,9 +252,8 @@ export class SerialConnectionManager { break; } } - this.connected = false; - if (this.isSerialOpen()) { + if ((await this.serialService.clientsAttached()) > 0) { if (this.serialErrors.length >= 10) { this.messageService.warn( nls.localize( @@ -363,59 +285,31 @@ export class SerialConnectionManager { ) ); this.reconnectTimeout = window.setTimeout( - () => this.connect(), + () => this.reconnectAfterUpload(), timeout ); } } } - async connect(): Promise { - if (this.connected) return Status.ALREADY_CONNECTED; - if (!isSerialConfig(this.config)) return Status.NOT_CONNECTED; - - console.info( - `>>> Creating serial connection for ${Board.toString( - this.config.board - )} on port ${Port.toString(this.config.port)}...` - ); - const connectStatus = await this.serialService.connect(this.config); - if (Status.isOK(connectStatus)) { - this.connected = true; - console.info( - `<<< Serial connection created for ${Board.toString(this.config.board, { - useFqbn: false, - })} on port ${Port.toString(this.config.port)}.` - ); - } - - return Status.isOK(connectStatus); - } - - async disconnect(): Promise { - if (!this.connected) { - return Status.OK; - } - - console.log('>>> Disposing existing serial connection...'); - const status = await this.serialService.disconnect(); - if (Status.isOK(status)) { - this.connected = false; - console.log( - `<<< Disposed serial connection. Was: ${Serial.Config.toString( - this.config - )}` - ); - this.wsPort = undefined; - } else { - console.warn( - `<<< Could not dispose serial connection. Activate connection: ${Serial.Config.toString( - this.config - )}` + async reconnectAfterUpload(): Promise { + try { + if (isSerialConfig(this.config)) { + await this.boardsServiceClientImpl.waitUntilAvailable( + Object.assign(this.config.board, { port: this.config.port }), + 10_000 + ); + this.serialService.connectSerialIfRequired(); + } + } catch (waitError) { + this.messageService.error( + nls.localize( + 'arduino/sketch/couldNotConnectToSerial', + 'Could not reconnect to serial port. {0}', + waitError.toString() + ) ); } - - return status; } /** @@ -424,7 +318,7 @@ export class SerialConnectionManager { * It is a NOOP if connected. */ async send(data: string): Promise { - if (!this.connected) { + if (!(await this.serialService.isSerialPortOpen())) { return Status.NOT_CONNECTED; } return new Promise((resolve) => { @@ -438,7 +332,7 @@ export class SerialConnectionManager { return this.onConnectionChangedEmitter.event; } - get onRead(): Event<{ messages: string[] }> { + get onRead(): Event<{ messages: any }> { return this.onReadEmitter.event; } @@ -453,18 +347,6 @@ export class SerialConnectionManager { } export namespace Serial { - export enum Type { - Monitor = 'Monitor', - Plotter = 'Plotter', - } - - /** - * The state represents which types of connections are needed by the client, and it should match whether the Serial Monitor - * or the Serial Plotter are open or not in the GUI. It's an array cause it's possible to have both, none or only one of - * them open - */ - export type State = Serial.Type[]; - export namespace Config { export function toString(config: Partial): string { if (!isSerialConfig(config)) return ''; diff --git a/arduino-ide-extension/src/common/protocol/serial-service.ts b/arduino-ide-extension/src/common/protocol/serial-service.ts index 0aa2793fa..0e77bb9cc 100644 --- a/arduino-ide-extension/src/common/protocol/serial-service.ts +++ b/arduino-ide-extension/src/common/protocol/serial-service.ts @@ -18,15 +18,22 @@ export namespace Status { export const ALREADY_CONNECTED: ErrorStatus = { message: 'Already connected.', }; + export const CONFIG_MISSING: ErrorStatus = { + message: 'Serial Config missing.', + }; } export const SerialServicePath = '/services/serial'; export const SerialService = Symbol('SerialService'); export interface SerialService extends JsonRpcServer { - connect(config: SerialConfig): Promise; - disconnect(): Promise; + clientsAttached(): Promise; + setSerialConfig(config: SerialConfig): Promise; sendMessageToSerial(message: string): Promise; updateWsConfigParam(config: Partial): Promise; + isSerialPortOpen(): Promise; + connectSerialIfRequired(): Promise; + disconnect(reason?: SerialError): Promise; + uploadInProgress: boolean; } export interface SerialConfig { diff --git a/arduino-ide-extension/src/electron-main/theia/electron-main-application.ts b/arduino-ide-extension/src/electron-main/theia/electron-main-application.ts index d2c602924..e625a8288 100644 --- a/arduino-ide-extension/src/electron-main/theia/electron-main-application.ts +++ b/arduino-ide-extension/src/electron-main/theia/electron-main-application.ts @@ -108,7 +108,7 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { electronWindow.webContents.on( 'new-window', - (event, url, frameName, disposition, options, additionalFeatures) => { + (event, url, frameName, disposition, options) => { if (frameName === 'serialPlotter') { event.preventDefault(); Object.assign(options, { diff --git a/arduino-ide-extension/src/node/core-service-impl.ts b/arduino-ide-extension/src/node/core-service-impl.ts index d3f1af59a..f0319f511 100644 --- a/arduino-ide-extension/src/node/core-service-impl.ts +++ b/arduino-ide-extension/src/node/core-service-impl.ts @@ -24,6 +24,7 @@ import { ArduinoCoreServiceClient } from './cli-protocol/cc/arduino/cli/commands import { firstToUpperCase, firstToLowerCase } from '../common/utils'; import { Port } from './cli-protocol/cc/arduino/cli/commands/v1/port_pb'; import { nls } from '@theia/core'; +import { SerialService } from './../common/protocol/serial-service'; @injectable() export class CoreServiceImpl extends CoreClientAware implements CoreService { @@ -33,6 +34,9 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { @inject(NotificationServiceServer) protected readonly notificationService: NotificationServiceServer; + @inject(SerialService) + protected readonly serialService: SerialService; + protected uploading = false; async compile( @@ -132,8 +136,13 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { ) => ClientReadableStream, task = 'upload' ): Promise { - this.uploading = true; await this.compile(Object.assign(options, { exportBinaries: false })); + + this.uploading = true; + this.serialService.uploadInProgress = true; + + await this.serialService.disconnect(); + const { sketchUri, fqbn, port, programmer } = options; const sketchPath = FileUri.fsPath(sketchUri); @@ -160,7 +169,7 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { req.setVerbose(options.verbose); req.setVerify(options.verify); - options.userFields.forEach(e => { + options.userFields.forEach((e) => { req.getUserFieldsMap().set(e.name, e.value); }); @@ -190,7 +199,7 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { 'arduino/upload/error', '{0} error: {1}', firstToUpperCase(task), - e.details, + e.details ); this.responseService.appendToOutput({ chunk: `${errorMessage}\n`, @@ -199,10 +208,15 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { throw new Error(errorMessage); } finally { this.uploading = false; + this.serialService.uploadInProgress = false; } } async burnBootloader(options: CoreService.Bootloader.Options): Promise { + this.uploading = true; + this.serialService.uploadInProgress = true; + await this.serialService.disconnect(); + await this.coreClientProvider.initialized; const coreClient = await this.coreClient(); const { client, instance } = coreClient; @@ -242,13 +256,16 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { const errorMessage = nls.localize( 'arduino/burnBootloader/error', 'Error while burning the bootloader: {0}', - e.details, + e.details ); this.responseService.appendToOutput({ chunk: `${errorMessage}\n`, severity: 'error', }); throw new Error(errorMessage); + } finally { + this.uploading = false; + this.serialService.uploadInProgress = false; } } diff --git a/arduino-ide-extension/src/node/serial/serial-service-impl.ts b/arduino-ide-extension/src/node/serial/serial-service-impl.ts index c60bca36a..7b288ac10 100644 --- a/arduino-ide-extension/src/node/serial/serial-service-impl.ts +++ b/arduino-ide-extension/src/node/serial/serial-service-impl.ts @@ -63,27 +63,61 @@ namespace ErrorWithCode { @injectable() export class SerialServiceImpl implements SerialService { - @named(SerialServiceName) - @inject(ILogger) - protected readonly logger: ILogger; + protected theiaFEClient?: SerialServiceClient; + protected serialConfig?: SerialConfig; - @inject(MonitorClientProvider) - protected readonly serialClientProvider: MonitorClientProvider; - - @inject(WebSocketService) - protected readonly webSocketService: WebSocketService; - - protected client?: SerialServiceClient; protected serialConnection?: { duplex: ClientDuplexStream; config: SerialConfig; }; protected messages: string[] = []; protected onMessageReceived: Disposable | null; + protected onWSClientsNumberChanged: Disposable | null; + protected flushMessagesInterval: NodeJS.Timeout | null; + uploadInProgress = false; + + constructor( + @inject(ILogger) + @named(SerialServiceName) + protected readonly logger: ILogger, + + @inject(MonitorClientProvider) + protected readonly serialClientProvider: MonitorClientProvider, + + @inject(WebSocketService) + protected readonly webSocketService: WebSocketService + ) {} + + async isSerialPortOpen(): Promise { + return !!this.serialConnection; + } + setClient(client: SerialServiceClient | undefined): void { - this.client = client; + this.theiaFEClient = client; + + this.theiaFEClient?.notifyWebSocketChanged( + this.webSocketService.getAddress().port + ); + + // listen for the number of websocket clients and create or dispose the serial connection + this.onWSClientsNumberChanged = + this.webSocketService.onClientsNumberChanged(async () => { + await this.connectSerialIfRequired(); + }); + } + + public async clientsAttached(): Promise { + return this.webSocketService.getConnectedClientsNumber.bind( + this.webSocketService + )(); + } + + public async connectSerialIfRequired(): Promise { + if (this.uploadInProgress) return; + const clients = await this.clientsAttached(); + clients > 0 ? await this.connect() : await this.disconnect(); } dispose(): void { @@ -92,7 +126,13 @@ export class SerialServiceImpl implements SerialService { this.disconnect(); } this.logger.info('<<< Disposed serial service.'); - this.client = undefined; + this.theiaFEClient = undefined; + } + + async setSerialConfig(config: SerialConfig): Promise { + this.serialConfig = config; + await this.disconnect(); + await this.connectSerialIfRequired(); } async updateWsConfigParam( @@ -105,12 +145,17 @@ export class SerialServiceImpl implements SerialService { this.webSocketService.sendMessage(JSON.stringify(msg)); } - async connect(config: SerialConfig): Promise { + private async connect(): Promise { + if (!this.serialConfig) { + return Status.CONFIG_MISSING; + } + this.logger.info( `>>> Creating serial connection for ${Board.toString( - config.board - )} on port ${Port.toString(config.port)}...` + this.serialConfig.board + )} on port ${Port.toString(this.serialConfig.port)}...` ); + if (this.serialConnection) { return Status.ALREADY_CONNECTED; } @@ -122,27 +167,29 @@ export class SerialServiceImpl implements SerialService { return { message: client.message }; } const duplex = client.streamingOpen(); - this.serialConnection = { duplex, config }; + this.serialConnection = { duplex, config: this.serialConfig }; + + const serialConfig = this.serialConfig; duplex.on( 'error', ((error: Error) => { - const serialError = ErrorWithCode.toSerialError(error, config); - this.disconnect(serialError).then(() => { - if (this.client) { - this.client.notifyError(serialError); - } - if (serialError.code === undefined) { - // Log the original, unexpected error. - this.logger.error(error); - } - }); + const serialError = ErrorWithCode.toSerialError(error, serialConfig); + if (serialError.code !== SerialError.ErrorCodes.CLIENT_CANCEL) { + this.disconnect(serialError).then(() => { + if (this.theiaFEClient) { + this.theiaFEClient.notifyError(serialError); + } + }); + } + if (serialError.code === undefined) { + // Log the original, unexpected error. + this.logger.error(error); + } }).bind(this) ); - this.client?.notifyWebSocketChanged( - this.webSocketService.getAddress().port - ); + this.updateWsConfigParam({ connected: !!this.serialConnection }); const flushMessagesToFrontend = () => { if (this.messages.length) { @@ -162,17 +209,17 @@ export class SerialServiceImpl implements SerialService { break; case SerialPlotter.Protocol.Command.PLOTTER_SET_BAUDRATE: - this.client?.notifyBaudRateChanged( + this.theiaFEClient?.notifyBaudRateChanged( parseInt(message.data, 10) as SerialConfig.BaudRate ); break; case SerialPlotter.Protocol.Command.PLOTTER_SET_LINE_ENDING: - this.client?.notifyLineEndingChanged(message.data); + this.theiaFEClient?.notifyLineEndingChanged(message.data); break; case SerialPlotter.Protocol.Command.PLOTTER_SET_INTERPOLATE: - this.client?.notifyInterpolateChanged(message.data); + this.theiaFEClient?.notifyInterpolateChanged(message.data); break; default: @@ -185,27 +232,6 @@ export class SerialServiceImpl implements SerialService { // empty the queue every 32ms (~30fps) this.flushMessagesInterval = setInterval(flushMessagesToFrontend, 32); - // converts 'ab\nc\nd' => [ab\n,c\n,d] - const stringToArray = (string: string, separator = '\n') => { - const retArray: string[] = []; - - let prevChar = separator; - - for (let i = 0; i < string.length; i++) { - const currChar = string[i]; - - if (prevChar === separator) { - retArray.push(currChar); - } else { - const lastWord = retArray[retArray.length - 1]; - retArray[retArray.length - 1] = lastWord + currChar; - } - - prevChar = currChar; - } - return retArray; - }; - duplex.on( 'data', ((resp: StreamingOpenResponse) => { @@ -219,69 +245,105 @@ export class SerialServiceImpl implements SerialService { }).bind(this) ); - const { type, port } = config; + const { type, port } = this.serialConfig; const req = new StreamingOpenRequest(); const monitorConfig = new GrpcMonitorConfig(); monitorConfig.setType(this.mapType(type)); monitorConfig.setTarget(port.address); - if (config.baudRate !== undefined) { + if (this.serialConfig.baudRate !== undefined) { monitorConfig.setAdditionalConfig( - Struct.fromJavaScript({ BaudRate: config.baudRate }) + Struct.fromJavaScript({ BaudRate: this.serialConfig.baudRate }) ); } req.setConfig(monitorConfig); - return new Promise((resolve) => { - if (this.serialConnection) { - this.serialConnection.duplex.write(req, () => { + if (!this.serialConnection) { + return await this.disconnect(); + } + + const writeTimeout = new Promise((resolve) => { + setTimeout(async () => { + resolve(Status.NOT_CONNECTED); + }, 1000); + }); + + const writePromise = (serialConnection: any) => { + return new Promise((resolve) => { + serialConnection.duplex.write(req, () => { + const boardName = this.serialConfig?.board + ? Board.toString(this.serialConfig.board, { + useFqbn: false, + }) + : 'unknown board'; + + const portName = this.serialConfig?.port + ? Port.toString(this.serialConfig.port) + : 'unknown port'; this.logger.info( - `<<< Serial connection created for ${Board.toString(config.board, { - useFqbn: false, - })} on port ${Port.toString(config.port)}.` + `<<< Serial connection created for ${boardName} on port ${portName}.` ); resolve(Status.OK); }); - return; - } - this.disconnect().then(() => resolve(Status.NOT_CONNECTED)); - }); + }); + }; + + const status = await Promise.race([ + writeTimeout, + writePromise(this.serialConnection), + ]); + + if (status === Status.NOT_CONNECTED) { + this.disconnect(); + } + + return status; } - async disconnect(reason?: SerialError): Promise { - try { - if (this.onMessageReceived) { - this.onMessageReceived.dispose(); - this.onMessageReceived = null; - } - if (this.flushMessagesInterval) { - clearInterval(this.flushMessagesInterval); - this.flushMessagesInterval = null; - } + public async disconnect(reason?: SerialError): Promise { + return new Promise((resolve) => { + try { + if (this.onMessageReceived) { + this.onMessageReceived.dispose(); + this.onMessageReceived = null; + } + if (this.flushMessagesInterval) { + clearInterval(this.flushMessagesInterval); + this.flushMessagesInterval = null; + } - if ( - !this.serialConnection && - reason && - reason.code === SerialError.ErrorCodes.CLIENT_CANCEL - ) { - return Status.OK; - } - this.logger.info('>>> Disposing serial connection...'); - if (!this.serialConnection) { - this.logger.warn('<<< Not connected. Nothing to dispose.'); - return Status.NOT_CONNECTED; + if ( + !this.serialConnection && + reason && + reason.code === SerialError.ErrorCodes.CLIENT_CANCEL + ) { + resolve(Status.OK); + return; + } + this.logger.info('>>> Disposing serial connection...'); + if (!this.serialConnection) { + this.logger.warn('<<< Not connected. Nothing to dispose.'); + resolve(Status.NOT_CONNECTED); + return; + } + const { duplex, config } = this.serialConnection; + + this.logger.info( + `<<< Disposed serial connection for ${Board.toString(config.board, { + useFqbn: false, + })} on port ${Port.toString(config.port)}.` + ); + + duplex.cancel(); + } finally { + this.serialConnection = undefined; + this.updateWsConfigParam({ connected: !!this.serialConnection }); + this.messages.length = 0; + + setTimeout(() => { + resolve(Status.OK); + }, 200); } - const { duplex, config } = this.serialConnection; - duplex.cancel(); - this.logger.info( - `<<< Disposed serial connection for ${Board.toString(config.board, { - useFqbn: false, - })} on port ${Port.toString(config.port)}.` - ); - this.serialConnection = undefined; - return Status.OK; - } finally { - this.messages.length = 0; - } + }); } async sendMessageToSerial(message: string): Promise { @@ -312,3 +374,24 @@ export class SerialServiceImpl implements SerialService { } } } + +// converts 'ab\nc\nd' => [ab\n,c\n,d] +function stringToArray(string: string, separator = '\n') { + const retArray: string[] = []; + + let prevChar = separator; + + for (let i = 0; i < string.length; i++) { + const currChar = string[i]; + + if (prevChar === separator) { + retArray.push(currChar); + } else { + const lastWord = retArray[retArray.length - 1]; + retArray[retArray.length - 1] = lastWord + currChar; + } + + prevChar = currChar; + } + return retArray; +} diff --git a/arduino-ide-extension/src/node/web-socket/web-socket-service-impl.ts b/arduino-ide-extension/src/node/web-socket/web-socket-service-impl.ts index 0f05759aa..869c2cf8d 100644 --- a/arduino-ide-extension/src/node/web-socket/web-socket-service-impl.ts +++ b/arduino-ide-extension/src/node/web-socket/web-socket-service-impl.ts @@ -11,6 +11,9 @@ export default class WebSocketServiceImpl implements WebSocketService { protected readonly onMessage = new Emitter(); public readonly onMessageReceived = this.onMessage.event; + protected readonly onConnectedClients = new Emitter(); + public readonly onClientsNumberChanged = this.onConnectedClients.event; + constructor() { this.wsClients = []; this.server = new WebSocket.Server({ port: 0 }); @@ -21,8 +24,11 @@ export default class WebSocketServiceImpl implements WebSocketService { private addClient(ws: WebSocket): void { this.wsClients.push(ws); + this.onConnectedClients.fire(this.wsClients.length); + ws.onclose = () => { this.wsClients.splice(this.wsClients.indexOf(ws), 1); + this.onConnectedClients.fire(this.wsClients.length); }; ws.onmessage = (res) => { @@ -30,6 +36,10 @@ export default class WebSocketServiceImpl implements WebSocketService { }; } + getConnectedClientsNumber(): number { + return this.wsClients.length; + } + getAddress(): WebSocket.AddressInfo { return this.server.address() as WebSocket.AddressInfo; } diff --git a/arduino-ide-extension/src/node/web-socket/web-socket-service.ts b/arduino-ide-extension/src/node/web-socket/web-socket-service.ts index 5d612560d..c793a07c4 100644 --- a/arduino-ide-extension/src/node/web-socket/web-socket-service.ts +++ b/arduino-ide-extension/src/node/web-socket/web-socket-service.ts @@ -6,4 +6,6 @@ export interface WebSocketService { getAddress(): WebSocket.AddressInfo; sendMessage(message: string): void; onMessageReceived: Event; + onClientsNumberChanged: Event; + getConnectedClientsNumber(): number; } diff --git a/arduino-ide-extension/src/test/browser/serial-connection-manager.test.ts b/arduino-ide-extension/src/test/browser/serial-connection-manager.test.ts deleted file mode 100644 index 6d9a96730..000000000 --- a/arduino-ide-extension/src/test/browser/serial-connection-manager.test.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; -const disableJSDOM = enableJSDOM(); - -import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; -import { ApplicationProps } from '@theia/application-package/lib/application-props'; -FrontendApplicationConfigProvider.set({ - ...ApplicationProps.DEFAULT.frontend.config, -}); - -import { MessageService } from '@theia/core'; -import { BoardsServiceProvider } from '../../browser/boards/boards-service-provider'; -import { - BoardsService, - CoreService, - SerialService, - SerialServiceClient, - Status, -} from '../../common/protocol'; -import { IMock, It, Mock, Times } from 'typemoq'; -import { - Serial, - SerialConnectionManager, -} from '../../browser/serial/serial-connection-manager'; -import { ThemeService } from '@theia/core/lib/browser/theming'; -import { SerialModel } from '../../browser/serial/serial-model'; -import { - aBoardConfig, - anotherBoardConfig, - anotherPort, - aPort, -} from './fixtures/boards'; -import { BoardsConfig } from '../../browser/boards/boards-config'; -import { - anotherSerialConfig, - aSerialConfig, - WebSocketMock, -} from './fixtures/serial'; -import { expect } from 'chai'; -import { tick } from '../utils'; - -disableJSDOM(); - -global.WebSocket = WebSocketMock as any; - -describe.only('SerialConnectionManager', () => { - let subject: SerialConnectionManager; - - let serialModel: IMock; - let serialService: IMock; - let serialServiceClient: IMock; - let boardsService: IMock; - let boardsServiceProvider: IMock; - let messageService: IMock; - let themeService: IMock; - let core: IMock; - - let handleBoardConfigChange: ( - boardsConfig: BoardsConfig.Config - ) => Promise; - let handleWebSocketChanged: (wsPort: number) => void; - const wsPort = 1234; - - beforeEach(() => { - serialModel = Mock.ofType(); - serialService = Mock.ofType(); - serialServiceClient = Mock.ofType(); - boardsService = Mock.ofType(); - boardsServiceProvider = Mock.ofType(); - messageService = Mock.ofType(); - themeService = Mock.ofType(); - core = Mock.ofType(); - - boardsServiceProvider - .setup((b) => b.boardsConfig) - .returns(() => aBoardConfig); - - boardsServiceProvider - .setup((b) => b.onBoardsConfigChanged(It.isAny())) - .returns((h) => { - handleBoardConfigChange = h; - return { dispose: () => {} }; - }); - - boardsServiceProvider - .setup((b) => b.canUploadTo(It.isAny(), It.isValue({ silent: false }))) - .returns(() => true); - - boardsService - .setup((b) => b.getAvailablePorts()) - .returns(() => Promise.resolve([aPort, anotherPort])); - - serialModel - .setup((m) => m.baudRate) - .returns(() => aSerialConfig.baudRate || 9600); - - serialServiceClient - .setup((m) => m.onWebSocketChanged(It.isAny())) - .returns((h) => { - handleWebSocketChanged = h; - return { dispose: () => {} }; - }); - - serialService - .setup((m) => m.disconnect()) - .returns(() => Promise.resolve(Status.OK)); - - core.setup((u) => u.isUploading()).returns(() => Promise.resolve(false)); - - subject = new SerialConnectionManager( - serialModel.object, - serialService.object, - serialServiceClient.object, - boardsService.object, - boardsServiceProvider.object, - messageService.object, - themeService.object, - core.object - ); - }); - - context('when no serial config is set', () => { - context('and the serial is NOT open', () => { - context('and it tries to open the serial plotter', () => { - it('should not try to connect and show an error', async () => { - await subject.openSerial(Serial.Type.Plotter); - messageService.verify((m) => m.error(It.isAnyString()), Times.once()); - serialService.verify((m) => m.disconnect(), Times.never()); - serialService.verify((m) => m.connect(It.isAny()), Times.never()); - }); - }); - context('and a serial config is set', () => { - it('should not try to reconnect', async () => { - await handleBoardConfigChange(aBoardConfig); - serialService.verify((m) => m.disconnect(), Times.never()); - serialService.verify((m) => m.connect(It.isAny()), Times.never()); - expect(subject.getConfig()).to.deep.equal(aSerialConfig); - }); - }); - }); - }); - context('when a serial config is set', () => { - beforeEach(() => { - subject.setConfig(aSerialConfig); - }); - context('and the serial is NOT open', () => { - context('and it tries to disconnect', () => { - it('should do nothing', async () => { - const status = await subject.disconnect(); - expect(status).to.be.ok; - expect(subject.connected).to.be.false; - }); - }); - context('and the config changes', () => { - beforeEach(() => { - subject.setConfig(anotherSerialConfig); - }); - it('should not try to reconnect', async () => { - await tick(); - messageService.verify( - (m) => m.error(It.isAnyString()), - Times.never() - ); - serialService.verify((m) => m.disconnect(), Times.never()); - serialService.verify((m) => m.connect(It.isAny()), Times.never()); - }); - }); - context( - 'and the connection to the serial succeeds with the config', - () => { - beforeEach(() => { - serialService - .setup((m) => m.connect(It.isValue(aSerialConfig))) - .returns(() => { - handleWebSocketChanged(wsPort); - return Promise.resolve(Status.OK); - }); - }); - context('and it tries to open the serial plotter', () => { - let status: Status; - beforeEach(async () => { - status = await subject.openSerial(Serial.Type.Plotter); - }); - it('should successfully connect to the serial', async () => { - messageService.verify( - (m) => m.error(It.isAnyString()), - Times.never() - ); - serialService.verify((m) => m.disconnect(), Times.never()); - serialService.verify((m) => m.connect(It.isAny()), Times.once()); - expect(status).to.be.ok; - expect(subject.connected).to.be.true; - expect(subject.getWsPort()).to.equal(wsPort); - expect(subject.isSerialOpen()).to.be.true; - expect(subject.isWebSocketConnected()).to.be.false; - }); - context('and it tries to open the serial monitor', () => { - let status: Status; - beforeEach(async () => { - status = await subject.openSerial(Serial.Type.Monitor); - }); - it('should open it using the same serial connection', () => { - messageService.verify( - (m) => m.error(It.isAnyString()), - Times.never() - ); - serialService.verify((m) => m.disconnect(), Times.never()); - serialService.verify( - (m) => m.connect(It.isAny()), - Times.once() - ); - expect(status).to.be.ok; - expect(subject.connected).to.be.true; - expect(subject.isSerialOpen()).to.be.true; - }); - it('should create a websocket connection', () => { - expect(subject.getWsPort()).to.equal(wsPort); - expect(subject.isWebSocketConnected()).to.be.true; - }); - context('and then it closes the serial plotter', () => { - beforeEach(async () => { - status = await subject.closeSerial(Serial.Type.Plotter); - }); - it('should close the plotter without disconnecting from the serial', () => { - messageService.verify( - (m) => m.error(It.isAnyString()), - Times.never() - ); - serialService.verify((m) => m.disconnect(), Times.never()); - serialService.verify( - (m) => m.connect(It.isAny()), - Times.once() - ); - expect(status).to.be.ok; - expect(subject.connected).to.be.true; - expect(subject.isSerialOpen()).to.be.true; - expect(subject.getWsPort()).to.equal(wsPort); - }); - it('should not close the websocket connection', () => { - expect(subject.isWebSocketConnected()).to.be.true; - }); - }); - context('and then it closes the serial monitor', () => { - beforeEach(async () => { - status = await subject.closeSerial(Serial.Type.Monitor); - }); - it('should close the monitor without disconnecting from the serial', () => { - messageService.verify( - (m) => m.error(It.isAnyString()), - Times.never() - ); - serialService.verify((m) => m.disconnect(), Times.never()); - serialService.verify( - (m) => m.connect(It.isAny()), - Times.once() - ); - expect(status).to.be.ok; - expect(subject.connected).to.be.true; - expect(subject.getWsPort()).to.equal(wsPort); - expect(subject.isSerialOpen()).to.be.true; - }); - it('should close the websocket connection', () => { - expect(subject.isWebSocketConnected()).to.be.false; - }); - }); - }); - context('and then it closes the serial plotter', () => { - beforeEach(async () => { - status = await subject.closeSerial(Serial.Type.Plotter); - }); - it('should successfully disconnect from the serial', () => { - messageService.verify( - (m) => m.error(It.isAnyString()), - Times.never() - ); - serialService.verify((m) => m.disconnect(), Times.once()); - serialService.verify( - (m) => m.connect(It.isAny()), - Times.once() - ); - expect(status).to.be.ok; - expect(subject.connected).to.be.false; - expect(subject.getWsPort()).to.be.undefined; - expect(subject.isSerialOpen()).to.be.false; - expect(subject.isWebSocketConnected()).to.be.false; - }); - }); - context('and the config changes', () => { - beforeEach(() => { - subject.setConfig(anotherSerialConfig); - }); - it('should try to reconnect', async () => { - await tick(); - messageService.verify( - (m) => m.error(It.isAnyString()), - Times.never() - ); - serialService.verify((m) => m.disconnect(), Times.once()); - serialService.verify( - (m) => m.connect(It.isAny()), - Times.exactly(2) - ); - }); - }); - }); - } - ); - context( - 'and the connection to the serial does NOT succeed with the config', - () => { - beforeEach(() => { - serialService - .setup((m) => m.connect(It.isValue(aSerialConfig))) - .returns(() => { - return Promise.resolve(Status.NOT_CONNECTED); - }); - serialService - .setup((m) => m.connect(It.isValue(anotherSerialConfig))) - .returns(() => { - handleWebSocketChanged(wsPort); - return Promise.resolve(Status.OK); - }); - }); - context('and it tries to open the serial plotter', () => { - let status: Status; - beforeEach(async () => { - status = await subject.openSerial(Serial.Type.Plotter); - }); - - it('should fail to connect to the serial', async () => { - messageService.verify( - (m) => m.error(It.isAnyString()), - Times.never() - ); - serialService.verify((m) => m.disconnect(), Times.never()); - serialService.verify( - (m) => m.connect(It.isValue(aSerialConfig)), - Times.once() - ); - expect(status).to.be.false; - expect(subject.connected).to.be.false; - expect(subject.getWsPort()).to.be.undefined; - expect(subject.isSerialOpen()).to.be.true; - }); - - context( - 'and the board config changes with an acceptable one', - () => { - beforeEach(async () => { - await handleBoardConfigChange(anotherBoardConfig); - }); - - it('should successfully connect to the serial', async () => { - await tick(); - messageService.verify( - (m) => m.error(It.isAnyString()), - Times.never() - ); - serialService.verify((m) => m.disconnect(), Times.never()); - serialService.verify( - (m) => m.connect(It.isValue(anotherSerialConfig)), - Times.once() - ); - expect(subject.connected).to.be.true; - expect(subject.getWsPort()).to.equal(wsPort); - expect(subject.isSerialOpen()).to.be.true; - expect(subject.isWebSocketConnected()).to.be.false; - }); - } - ); - }); - } - ); - }); - }); -}); diff --git a/arduino-ide-extension/src/test/node/serial-service-impl.test.ts b/arduino-ide-extension/src/test/node/serial-service-impl.test.ts new file mode 100644 index 000000000..a4ddcbe43 --- /dev/null +++ b/arduino-ide-extension/src/test/node/serial-service-impl.test.ts @@ -0,0 +1,167 @@ +import { SerialServiceImpl } from './../../node/serial/serial-service-impl'; +import { IMock, It, Mock } from 'typemoq'; +import { createSandbox } from 'sinon'; +import * as sinonChai from 'sinon-chai'; +import { expect, use } from 'chai'; +use(sinonChai); + +import { ILogger } from '@theia/core/lib/common/logger'; +import { MonitorClientProvider } from '../../node/serial/monitor-client-provider'; +import { WebSocketService } from '../../node/web-socket/web-socket-service'; +import { MonitorServiceClient } from '../../node/cli-protocol/cc/arduino/cli/monitor/v1/monitor_grpc_pb'; +import { Status } from '../../common/protocol'; + +describe('SerialServiceImpl', () => { + let subject: SerialServiceImpl; + + let logger: IMock; + let serialClientProvider: IMock; + let webSocketService: IMock; + + beforeEach(() => { + logger = Mock.ofType(); + logger.setup((b) => b.info(It.isAnyString())); + logger.setup((b) => b.warn(It.isAnyString())); + logger.setup((b) => b.error(It.isAnyString())); + + serialClientProvider = Mock.ofType(); + webSocketService = Mock.ofType(); + + subject = new SerialServiceImpl( + logger.object, + serialClientProvider.object, + webSocketService.object + ); + }); + + context('when a serial connection is requested', () => { + const sandbox = createSandbox(); + beforeEach(() => { + subject.uploadInProgress = false; + sandbox.spy(subject, 'disconnect'); + sandbox.spy(subject, 'updateWsConfigParam'); + }); + + afterEach(function () { + sandbox.restore(); + }); + + context('and an upload is in progress', () => { + beforeEach(async () => { + subject.uploadInProgress = true; + }); + + it('should not change the connection status', async () => { + await subject.connectSerialIfRequired(); + expect(subject.disconnect).to.have.callCount(0); + }); + }); + + context('and there is no upload in progress', () => { + beforeEach(async () => { + subject.uploadInProgress = false; + }); + + context('and there are 0 attached ws clients', () => { + it('should disconnect', async () => { + await subject.connectSerialIfRequired(); + expect(subject.disconnect).to.have.been.calledOnce; + }); + }); + + context('and there are > 0 attached ws clients', () => { + beforeEach(() => { + webSocketService + .setup((b) => b.getConnectedClientsNumber()) + .returns(() => 1); + }); + + it('should not call the disconenct', async () => { + await subject.connectSerialIfRequired(); + expect(subject.disconnect).to.have.callCount(0); + }); + }); + }); + }); + + context('when a disconnection is requested', () => { + const sandbox = createSandbox(); + beforeEach(() => {}); + + afterEach(function () { + sandbox.restore(); + }); + + context('and a serialConnection is not set', () => { + it('should return a NOT_CONNECTED status', async () => { + const status = await subject.disconnect(); + expect(status).to.be.equal(Status.NOT_CONNECTED); + }); + }); + + context('and a serialConnection is set', async () => { + beforeEach(async () => { + sandbox.spy(subject, 'updateWsConfigParam'); + await subject.disconnect(); + }); + + it('should dispose the serialConnection', async () => { + const serialConnectionOpen = await subject.isSerialPortOpen(); + expect(serialConnectionOpen).to.be.false; + }); + + it('should call updateWsConfigParam with disconnected status', async () => { + expect(subject.updateWsConfigParam).to.be.calledWith({ + connected: false, + }); + }); + }); + }); + + context('when a new config is passed in', () => { + const sandbox = createSandbox(); + beforeEach(async () => { + subject.uploadInProgress = false; + webSocketService + .setup((b) => b.getConnectedClientsNumber()) + .returns(() => 1); + + serialClientProvider + .setup((b) => b.client()) + .returns(async () => { + return { + streamingOpen: () => { + return { + on: (str: string, cb: any) => {}, + write: (chunk: any, cb: any) => { + cb(); + }, + cancel: () => {}, + }; + }, + } as MonitorServiceClient; + }); + + sandbox.spy(subject, 'disconnect'); + + await subject.setSerialConfig({ + board: { name: 'test' }, + port: { address: 'test', protocol: 'test' }, + }); + }); + + afterEach(function () { + sandbox.restore(); + subject.dispose(); + }); + + it('should disconnect from previous connection', async () => { + expect(subject.disconnect).to.be.called; + }); + + it('should create the serialConnection', async () => { + const serialConnectionOpen = await subject.isSerialPortOpen(); + expect(serialConnectionOpen).to.be.true; + }); + }); +}); diff --git a/i18n/en.json b/i18n/en.json index 4c1be82bf..459770646 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -181,12 +181,12 @@ "uploadUsingProgrammer": "Upload Using Programmer", "userFieldsNotFoundError": "Can't find user fields for connected board", "doneUploading": "Done uploading.", - "couldNotConnectToSerial": "Could not reconnect to serial port. {0}", "configureAndUpload": "Configure And Upload", "verifyOrCompile": "Verify/Compile", "exportBinary": "Export Compiled Binary", "verify": "Verify", "doneCompiling": "Done compiling.", + "couldNotConnectToSerial": "Could not reconnect to serial port. {0}", "openSketchInNewWindow": "Open Sketch in New Window", "openFolder": "Open Folder", "titleLocalSketchbook": "Local Sketchbook", diff --git a/package.json b/package.json index a890b3733..2fa8cfa49 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "test": "lerna run test", "download:plugins": "theia download:plugins", "update:version": "node ./scripts/update-version.js", - "i18n:generate": "theia nls-extract -e vscode -f '+(arduino-ide-extension|browser-app|electron|electron-app|plugins)/**/*.ts?(x)' -o ./i18n/en.json", + "i18n:generate": "theia nls-extract -e vscode -f \"+(arduino-ide-extension|browser-app|electron|electron-app|plugins)/**/*.ts?(x)\" -o ./i18n/en.json", "i18n:check": "yarn i18n:generate && git add -N ./i18n && git diff --exit-code ./i18n", "i18n:push": "node ./scripts/i18n/transifex-push.js ./i18n/en.json", "i18n:pull": "node ./scripts/i18n/transifex-pull.js ./i18n/" diff --git a/yarn.lock b/yarn.lock index b6cdcfa7f..fe560582c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2064,24 +2064,38 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd" integrity sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow== -"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.1": +"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0": version "1.8.2" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.2.tgz#858f5c4b48d80778fde4b9d541f27edc0d56488b" integrity sha512-sruwd86RJHdsVf/AtBoijDmUqJp3B6hF/DGC23C+JaegnDHaZyewCjoVGTdg3J0uz3Zs7NnIT05OBOmML72lQw== dependencies: type-detect "4.0.8" -"@sinonjs/fake-timers@^6.0.0", "@sinonjs/fake-timers@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz#293674fccb3262ac782c7aadfdeca86b10c75c40" - integrity sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA== +"@sinonjs/commons@^1.8.3": + version "1.8.3" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" + integrity sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^7.0.4", "@sinonjs/fake-timers@^7.1.0": + version "7.1.2" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz#2524eae70c4910edccf99b2f4e6efc5894aff7b5" + integrity sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg== dependencies: "@sinonjs/commons" "^1.7.0" -"@sinonjs/samsam@^5.3.1": - version "5.3.1" - resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-5.3.1.tgz#375a45fe6ed4e92fca2fb920e007c48232a6507f" - integrity sha512-1Hc0b1TtyfBu8ixF/tpfSHTVWKwCBLY4QJbkgnE7HcwyvT2xArDxb4K7dMgqRm3szI+LJbzmW/s4xxEhv6hwDg== +"@sinonjs/fake-timers@^8.1.0": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz#3fdc2b6cb58935b21bfb8d1625eb1300484316e7" + integrity sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg== + dependencies: + "@sinonjs/commons" "^1.7.0" + +"@sinonjs/samsam@^6.0.2": + version "6.0.2" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.0.2.tgz#a0117d823260f282c04bff5f8704bdc2ac6910bb" + integrity sha512-jxPRPp9n93ci7b8hMfJOFDPRLFYadN6FSpeROFTR4UNF4i5b+EK6m4QXPO46BDhFgRy1JuS87zAnFOzCUwMJcQ== dependencies: "@sinonjs/commons" "^1.6.0" lodash.get "^4.4.2" @@ -3246,16 +3260,26 @@ "@types/mime" "^1" "@types/node" "*" +"@types/sinon-chai@^3.2.6": + version "3.2.6" + resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.6.tgz#3504a744e2108646394766fb1339f52ea5d6bd0f" + integrity sha512-Z57LprQ+yOQNu9d6mWdHNvnmncPXzDWGSeLj+8L075/QahToapC4Q13zAFRVKV4clyBmdJ5gz4xBfVkOso5lXw== + dependencies: + "@types/chai" "*" + "@types/sinon" "*" + +"@types/sinon@*", "@types/sinon@^10.0.6": + version "10.0.6" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.6.tgz#bc3faff5154e6ecb69b797d311b7cf0c1b523a1d" + integrity sha512-6EF+wzMWvBNeGrfP3Nx60hhx+FfwSg1JJBLAAP/IdIUq0EYkqCYf70VT3PhuhPX9eLD+Dp+lNdpb/ZeHG8Yezg== + dependencies: + "@sinonjs/fake-timers" "^7.1.0" + "@types/sinon@^2.3.5": version "2.3.7" resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-2.3.7.tgz#e92c2fed3297eae078d78d1da032b26788b4af86" integrity sha512-w+LjztaZbgZWgt/y/VMP5BUAWLtSyoIJhXyW279hehLPyubDoBNwvhcj3WaSptcekuKYeTCVxrq60rdLc6ImJA== -"@types/sinon@^7.5.2": - version "7.5.2" - resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-7.5.2.tgz#5e2f1d120f07b9cda07e5dedd4f3bf8888fccdb9" - integrity sha512-T+m89VdXj/eidZyejvmoP9jivXgBDdkOSBVQjU9kF349NEx10QdPNGxHeZUaj1IlJ32/ewdyXJjnJxyxJroYwg== - "@types/tar-fs@^1.16.1": version "1.16.3" resolved "https://registry.yarnpkg.com/@types/tar-fs/-/tar-fs-1.16.3.tgz#425b2b817c405d13d051f36ec6ec6ebd25e31069" @@ -6075,10 +6099,10 @@ diff@3.5.0, diff@^3.4.0: resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== -diff@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" - integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +diff@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" + integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== dir-glob@^2.0.0, dir-glob@^2.2.2: version "2.2.2" @@ -10214,13 +10238,13 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== -nise@^4.0.4: - version "4.1.0" - resolved "https://registry.yarnpkg.com/nise/-/nise-4.1.0.tgz#8fb75a26e90b99202fa1e63f448f58efbcdedaf6" - integrity sha512-eQMEmGN/8arp0xsvGoQ+B1qvSkR73B1nWSCh7nOt5neMCtwcQVYQGdzQMhcNscktTsWB54xnlSQFzOAPJD8nXA== +nise@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.0.tgz#713ef3ed138252daef20ec035ab62b7a28be645c" + integrity sha512-W5WlHu+wvo3PaKLsJJkgPup2LrsXCcm7AWwyNZkUnn5rwPkuPBi3Iwk5SQtN0mv+K65k7nKKjwNQ30wg3wLAQQ== dependencies: "@sinonjs/commons" "^1.7.0" - "@sinonjs/fake-timers" "^6.0.0" + "@sinonjs/fake-timers" "^7.0.4" "@sinonjs/text-encoding" "^0.7.1" just-extend "^4.0.2" path-to-regexp "^1.7.0" @@ -12881,17 +12905,22 @@ simple-get@^3.0.3: once "^1.3.1" simple-concat "^1.0.0" -sinon@^9.0.1: - version "9.2.4" - resolved "https://registry.yarnpkg.com/sinon/-/sinon-9.2.4.tgz#e55af4d3b174a4443a8762fa8421c2976683752b" - integrity sha512-zljcULZQsJxVra28qIAL6ow1Z9tpattkCTEJR4RBP3TGc00FcttsP5pK284Nas5WjMZU5Yzy3kAIp3B3KRf5Yg== - dependencies: - "@sinonjs/commons" "^1.8.1" - "@sinonjs/fake-timers" "^6.0.1" - "@sinonjs/samsam" "^5.3.1" - diff "^4.0.2" - nise "^4.0.4" - supports-color "^7.1.0" +sinon-chai@^3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-3.7.0.tgz#cfb7dec1c50990ed18c153f1840721cf13139783" + integrity sha512-mf5NURdUaSdnatJx3uhoBOrY9dtL19fiOtAdT1Azxg3+lNJFiuN0uzaU3xX1LeAfL17kHQhTAJgpsfhbMJMY2g== + +sinon@^12.0.1: + version "12.0.1" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-12.0.1.tgz#331eef87298752e1b88a662b699f98e403c859e9" + integrity sha512-iGu29Xhym33ydkAT+aNQFBINakjq69kKO6ByPvTsm3yyIACfyQttRTP03aBP/I8GfhFmLzrnKwNNkr0ORb1udg== + dependencies: + "@sinonjs/commons" "^1.8.3" + "@sinonjs/fake-timers" "^8.1.0" + "@sinonjs/samsam" "^6.0.2" + diff "^5.0.0" + nise "^5.1.0" + supports-color "^7.2.0" slash@^1.0.0: version "1.0.0" @@ -13511,7 +13540,7 @@ supports-color@^5.3.0, supports-color@^5.4.0: dependencies: has-flag "^3.0.0" -supports-color@^7.1.0: +supports-color@^7.1.0, supports-color@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==