diff --git a/arduino-ide-extension/README.md b/arduino-ide-extension/README.md index 1758bfc7a..ff25fd6eb 100644 --- a/arduino-ide-extension/README.md +++ b/arduino-ide-extension/README.md @@ -30,17 +30,20 @@ The Core Service is responsible for building your sketches and uploading them to - compiling a sketch for a selected board type - uploading a sketch to a connected board -#### Monitor Service +#### Serial Service -The Monitor Service allows getting information back from sketches running on your Arduino boards. +The Serial Service allows getting information back from sketches running on your Arduino boards. -- [src/common/protocol/monitor-service.ts](./src/common/protocol/monitor-service.ts) implements the common classes and interfaces -- [src/node/monitor/monitor-service-impl.ts](./src/node/monitor/monitor-service-impl.ts) implements the service backend: +- [src/common/protocol/serial-service.ts](./src/common/protocol/serial-service.ts) implements the common classes and interfaces +- [src/node/serial/serial-service-impl.ts](./src/node/serial/serial-service-impl.ts) implements the service backend: - connecting to / disconnecting from a board - receiving and sending data -- [src/browser/monitor/monitor-widget.tsx](./src/browser/monitor/monitor-widget.tsx) implements the serial monitor front-end: +- [src/browser/serial/serial-connection-manager.ts](./src/browser/serial/serial-connection-manager.ts) handles the serial connection in the frontend +- [src/browser/serial/monitor/monitor-widget.tsx](./src/browser/serial/monitor/monitor-widget.tsx) implements the serial monitor front-end: - viewing the output from a connected board - entering data to send to the board +- [src/browser/serial/plotter/plotter-frontend-contribution.ts](./src/browser/serial/plotter/plotter-frontend-contribution.ts) implements the serial plotter front-end: + - opening a new window running the [Serial Plotter Web App](https://github.com/arduino/arduino-serial-plotter-webapp) #### Config Service diff --git a/arduino-ide-extension/package.json b/arduino-ide-extension/package.json index 6bb9166b8..ecd248e00 100644 --- a/arduino-ide-extension/package.json +++ b/arduino-ide-extension/package.json @@ -4,10 +4,11 @@ "description": "An extension for Theia building the Arduino IDE", "license": "AGPL-3.0-or-later", "scripts": { - "prepare": "yarn download-cli && yarn download-fwuploader && yarn download-ls && yarn clean && yarn download-examples && yarn build && yarn test", + "prepare": "yarn download-cli && yarn download-fwuploader && yarn download-ls && yarn copy-serial-plotter && yarn clean && yarn download-examples && yarn build && yarn test", "clean": "rimraf lib", "download-cli": "node ./scripts/download-cli.js", "download-fwuploader": "node ./scripts/download-fwuploader.js", + "copy-serial-plotter": "npx ncp ../node_modules/arduino-serial-plotter-webapp ./build/arduino-serial-plotter-webapp", "download-ls": "node ./scripts/download-ls.js", "download-examples": "node ./scripts/download-examples.js", "generate-protocol": "node ./scripts/generate-protocol.js", @@ -18,11 +19,12 @@ "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.18.0", "@theia/core": "1.18.0", "@theia/editor": "1.18.0", - "@theia/editor-preview": "1.18.0", + "@theia/editor-preview": "1.18.0", "@theia/filesystem": "1.18.0", "@theia/git": "1.18.0", "@theia/keymaps": "1.18.0", @@ -77,6 +79,7 @@ "open": "^8.0.6", "p-queue": "^5.0.0", "ps-tree": "^1.2.0", + "query-string": "^7.0.1", "react-disable": "^0.1.0", "react-select": "^3.0.4", "react-tabs": "^3.1.2", diff --git a/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx b/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx index 8352f6dc1..f9ec9ccbf 100644 --- a/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx +++ b/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx @@ -3,16 +3,13 @@ import { MAIN_MENU_BAR, MenuContribution, MenuModelRegistry, - SelectionService, ILogger, DisposableCollection, } from '@theia/core'; import { - ContextMenuRenderer, FrontendApplication, FrontendApplicationContribution, LocalStorageService, - OpenerService, StatusBar, StatusBarAlignment, } from '@theia/core/lib/browser'; @@ -35,7 +32,6 @@ import { EditorManager, EditorOpenerOptions, } from '@theia/editor/lib/browser'; -import { FileDialogService } from '@theia/filesystem/lib/browser/file-dialog'; import { ProblemContribution } from '@theia/markers/lib/browser/problem/problem-contribution'; import { MonacoMenus } from '@theia/monaco/lib/browser/monaco-menu'; import { FileNavigatorContribution } from '@theia/navigator/lib/browser/navigator-contribution'; @@ -47,33 +43,25 @@ import { TerminalMenus } from '@theia/terminal/lib/browser/terminal-frontend-con import { inject, injectable, postConstruct } from 'inversify'; import * as React from 'react'; import { remote } from 'electron'; -import { MainMenuManager } from '../common/main-menu-manager'; import { BoardsService, - CoreService, Port, SketchesService, ExecutableService, Sketch, } from '../common/protocol'; -import { ArduinoDaemon } from '../common/protocol/arduino-daemon'; import { ConfigService } from '../common/protocol/config-service'; -import { FileSystemExt } from '../common/protocol/filesystem-ext'; import { ArduinoCommands } from './arduino-commands'; import { BoardsConfig } from './boards/boards-config'; import { BoardsConfigDialog } from './boards/boards-config-dialog'; -import { BoardsDataStore } from './boards/boards-data-store'; import { BoardsServiceProvider } from './boards/boards-service-provider'; import { BoardsToolBarItem } from './boards/boards-toolbar-item'; import { EditorMode } from './editor-mode'; import { ArduinoMenus } from './menu/arduino-menus'; -import { MonitorConnection } from './monitor/monitor-connection'; -import { MonitorViewContribution } from './monitor/monitor-view-contribution'; -import { WorkspaceService } from './theia/workspace/workspace-service'; +import { MonitorViewContribution } from './serial/monitor/monitor-view-contribution'; import { ArduinoToolbar } from './toolbar/arduino-toolbar'; import { HostedPluginSupport } from '@theia/plugin-ext/lib/hosted/browser/hosted-plugin'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; -import { ResponseService } from '../common/protocol/response-service'; import { ArduinoPreferences } from './arduino-preferences'; import { SketchesServiceClientImpl } from '../common/protocol/sketches-service-client-impl'; import { SaveAsSketch } from './contributions/save-as-sketch'; @@ -101,24 +89,12 @@ export class ArduinoFrontendContribution @inject(BoardsService) protected readonly boardsService: BoardsService; - @inject(CoreService) - protected readonly coreService: CoreService; - @inject(BoardsServiceProvider) protected readonly boardsServiceClientImpl: BoardsServiceProvider; - @inject(SelectionService) - protected readonly selectionService: SelectionService; - @inject(EditorManager) protected readonly editorManager: EditorManager; - @inject(ContextMenuRenderer) - protected readonly contextMenuRenderer: ContextMenuRenderer; - - @inject(FileDialogService) - protected readonly fileDialogService: FileDialogService; - @inject(FileService) protected readonly fileService: FileService; @@ -128,21 +104,12 @@ export class ArduinoFrontendContribution @inject(BoardsConfigDialog) protected readonly boardsConfigDialog: BoardsConfigDialog; - @inject(MenuModelRegistry) - protected readonly menuRegistry: MenuModelRegistry; - @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry; @inject(StatusBar) protected readonly statusBar: StatusBar; - @inject(WorkspaceService) - protected readonly workspaceService: WorkspaceService; - - @inject(MonitorConnection) - protected readonly monitorConnection: MonitorConnection; - @inject(FileNavigatorContribution) protected readonly fileNavigatorContributions: FileNavigatorContribution; @@ -167,40 +134,21 @@ export class ArduinoFrontendContribution @inject(EditorMode) protected readonly editorMode: EditorMode; - @inject(ArduinoDaemon) - protected readonly daemon: ArduinoDaemon; - - @inject(OpenerService) - protected readonly openerService: OpenerService; - @inject(ConfigService) protected readonly configService: ConfigService; - @inject(BoardsDataStore) - protected readonly boardsDataStore: BoardsDataStore; - - @inject(MainMenuManager) - protected readonly mainMenuManager: MainMenuManager; - - @inject(FileSystemExt) - protected readonly fileSystemExt: FileSystemExt; - @inject(HostedPluginSupport) protected hostedPluginSupport: HostedPluginSupport; @inject(ExecutableService) protected executableService: ExecutableService; - @inject(ResponseService) - protected readonly responseService: ResponseService; - @inject(ArduinoPreferences) protected readonly arduinoPreferences: ArduinoPreferences; @inject(SketchesServiceClientImpl) protected readonly sketchServiceClient: SketchesServiceClientImpl; - @inject(FrontendApplicationStateService) protected readonly appStateService: FrontendApplicationStateService; @inject(LocalStorageService) diff --git a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts index ec572776f..459e6be39 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -69,20 +69,20 @@ import { ScmContribution } from './theia/scm/scm-contribution'; import { SearchInWorkspaceFrontendContribution as TheiaSearchInWorkspaceFrontendContribution } from '@theia/search-in-workspace/lib/browser/search-in-workspace-frontend-contribution'; import { SearchInWorkspaceFrontendContribution } from './theia/search-in-workspace/search-in-workspace-frontend-contribution'; import { LibraryListWidgetFrontendContribution } from './library/library-widget-frontend-contribution'; -import { MonitorServiceClientImpl } from './monitor/monitor-service-client-impl'; +import { SerialServiceClientImpl } from './serial/serial-service-client-impl'; import { - MonitorServicePath, - MonitorService, - MonitorServiceClient, -} from '../common/protocol/monitor-service'; + SerialServicePath, + SerialService, + SerialServiceClient, +} from '../common/protocol/serial-service'; import { ConfigService, ConfigServicePath, } from '../common/protocol/config-service'; -import { MonitorWidget } from './monitor/monitor-widget'; -import { MonitorViewContribution } from './monitor/monitor-view-contribution'; -import { MonitorConnection } from './monitor/monitor-connection'; -import { MonitorModel } from './monitor/monitor-model'; +import { MonitorWidget } from './serial/monitor/monitor-widget'; +import { MonitorViewContribution } from './serial/monitor/monitor-view-contribution'; +import { SerialConnectionManager } from './serial/serial-connection-manager'; +import { SerialModel } from './serial/serial-model'; import { TabBarDecoratorService as TheiaTabBarDecoratorService } from '@theia/core/lib/browser/shell/tab-bar-decorator'; import { TabBarDecoratorService } from './theia/core/tab-bar-decorator'; import { ProblemManager as TheiaProblemManager } from '@theia/markers/lib/browser'; @@ -253,6 +253,7 @@ import { UploadCertificateDialogProps, UploadCertificateDialogWidget, } from './dialogs/certificate-uploader/certificate-uploader-dialog'; +import { PlotterFrontendContribution } from './serial/plotter/plotter-frontend-contribution'; import { nls } from '@theia/core/lib/browser/nls'; const ElementQueries = require('css-element-queries/src/ElementQueries'); @@ -386,8 +387,8 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { .inSingletonScope(); // Serial monitor - bind(MonitorModel).toSelf().inSingletonScope(); - bind(FrontendApplicationContribution).toService(MonitorModel); + bind(SerialModel).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService(SerialModel); bind(MonitorWidget).toSelf(); bindViewContribution(bind, MonitorViewContribution); bind(TabBarToolbarContribution).toService(MonitorViewContribution); @@ -395,18 +396,19 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { id: MonitorWidget.ID, createWidget: () => context.container.get(MonitorWidget), })); - // Frontend binding for the serial monitor service - bind(MonitorService) + // Frontend binding for the serial service + bind(SerialService) .toDynamicValue((context) => { const connection = context.container.get(WebSocketConnectionProvider); const client = - context.container.get(MonitorServiceClient); - return connection.createProxy(MonitorServicePath, client); + context.container.get(SerialServiceClient); + return connection.createProxy(SerialServicePath, client); }) .inSingletonScope(); - bind(MonitorConnection).toSelf().inSingletonScope(); - // Serial monitor service client to receive and delegate notifications from the backend. - bind(MonitorServiceClient).to(MonitorServiceClientImpl).inSingletonScope(); + bind(SerialConnectionManager).toSelf().inSingletonScope(); + + // Serial service client to receive and delegate notifications from the backend. + bind(SerialServiceClient).to(SerialServiceClientImpl).inSingletonScope(); bind(WorkspaceService).toSelf().inSingletonScope(); rebind(TheiaWorkspaceService).toService(WorkspaceService); @@ -597,6 +599,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { Contribution.configure(bind, AddFile); Contribution.configure(bind, ArchiveSketch); Contribution.configure(bind, AddZipLibrary); + Contribution.configure(bind, PlotterFrontendContribution); bind(ResponseServiceImpl) .toSelf() diff --git a/arduino-ide-extension/src/browser/boards/boards-service-provider.ts b/arduino-ide-extension/src/browser/boards/boards-service-provider.ts index 356589d4f..b9f5fd0e9 100644 --- a/arduino-ide-extension/src/browser/boards/boards-service-provider.ts +++ b/arduino-ide-extension/src/browser/boards/boards-service-provider.ts @@ -64,7 +64,7 @@ export class BoardsServiceProvider implements FrontendApplicationContribution { * This even also fires, when the boards package was not available for the currently selected board, * and the user installs the board package. Note: installing a board package will set the `fqbn` of the * currently selected board.\ - * This even also emitted when the board package for the currently selected board was uninstalled. + * This event is also emitted when the board package for the currently selected board was uninstalled. */ readonly onBoardsConfigChanged = this.onBoardsConfigChangedEmitter.event; readonly onAvailableBoardsChanged = diff --git a/arduino-ide-extension/src/browser/contributions/board-selection.ts b/arduino-ide-extension/src/browser/contributions/board-selection.ts index 11ea70ca5..a401164d5 100644 --- a/arduino-ide-extension/src/browser/contributions/board-selection.ts +++ b/arduino-ide-extension/src/browser/contributions/board-selection.ts @@ -138,7 +138,11 @@ PID: ${PID}`; // The board specific items, and the rest, have order with `z`. We needed something between `0` and `z` with natural-order. this.menuModelRegistry.registerSubmenu( boardsSubmenuPath, - nls.localize('arduino/board/board', 'Board{0}', !!boardsSubmenuLabel ? `: "${boardsSubmenuLabel}"` : ''), + nls.localize( + 'arduino/board/board', + 'Board{0}', + !!boardsSubmenuLabel ? `: "${boardsSubmenuLabel}"` : '' + ), { order: '100' } ); this.toDisposeBeforeMenuRebuild.push( @@ -155,7 +159,11 @@ PID: ${PID}`; const portsSubmenuLabel = config.selectedPort?.address; this.menuModelRegistry.registerSubmenu( portsSubmenuPath, - nls.localize('arduino/board/port', 'Port{0}', portsSubmenuLabel ? `: "${portsSubmenuLabel}"` : ''), + nls.localize( + 'arduino/board/port', + 'Port{0}', + portsSubmenuLabel ? `: "${portsSubmenuLabel}"` : '' + ), { order: '101' } ); this.toDisposeBeforeMenuRebuild.push( @@ -193,9 +201,10 @@ PID: ${PID}`; const packageLabel = packageName + - `${manuallyInstalled - ? nls.localize('arduino/board/inSketchbook', ' (in Sketchbook)') - : '' + `${ + manuallyInstalled + ? nls.localize('arduino/board/inSketchbook', ' (in Sketchbook)') + : '' }`; // Platform submenu const platformMenuPath = [...boardsPackagesGroup, packageId]; @@ -268,8 +277,9 @@ PID: ${PID}`; }); } for (const { name, fqbn } of boards) { - const id = `arduino-select-port--${address}${fqbn ? `--${fqbn}` : '' - }`; + const id = `arduino-select-port--${address}${ + fqbn ? `--${fqbn}` : '' + }`; const command = { id }; const handler = { execute: () => { diff --git a/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts b/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts index 3ea8602b1..0f24cc185 100644 --- a/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts +++ b/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts @@ -3,7 +3,7 @@ import { OutputChannelManager } from '@theia/output/lib/common/output-channel'; import { CoreService } from '../../common/protocol'; import { ArduinoMenus } from '../menu/arduino-menus'; import { BoardsDataStore } from '../boards/boards-data-store'; -import { MonitorConnection } from '../monitor/monitor-connection'; +import { SerialConnectionManager } from '../serial/serial-connection-manager'; import { BoardsServiceProvider } from '../boards/boards-service-provider'; import { SketchContribution, @@ -18,8 +18,8 @@ export class BurnBootloader extends SketchContribution { @inject(CoreService) protected readonly coreService: CoreService; - @inject(MonitorConnection) - protected readonly monitorConnection: MonitorConnection; + @inject(SerialConnectionManager) + protected readonly serialConnection: SerialConnectionManager; @inject(BoardsDataStore) protected readonly boardsDataStore: BoardsDataStore; @@ -48,10 +48,7 @@ export class BurnBootloader extends SketchContribution { } async burnBootloader(): Promise { - const monitorConfig = this.monitorConnection.monitorConfig; - if (monitorConfig) { - await this.monitorConnection.disconnect(); - } + await this.serialConnection.disconnect(); try { const { boardsConfig } = this.boardsServiceClientImpl; const port = boardsConfig.selectedPort; @@ -84,8 +81,8 @@ export class BurnBootloader extends SketchContribution { } catch (e) { this.messageService.error(e.toString()); } finally { - if (monitorConfig) { - await this.monitorConnection.connect(monitorConfig); + if (this.serialConnection.isSerialOpen()) { + await this.serialConnection.connect(); } } } diff --git a/arduino-ide-extension/src/browser/contributions/upload-sketch.ts b/arduino-ide-extension/src/browser/contributions/upload-sketch.ts index 4388f410b..c464ec811 100644 --- a/arduino-ide-extension/src/browser/contributions/upload-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/upload-sketch.ts @@ -4,7 +4,7 @@ import { CoreService } from '../../common/protocol'; import { ArduinoMenus } from '../menu/arduino-menus'; import { ArduinoToolbar } from '../toolbar/arduino-toolbar'; import { BoardsDataStore } from '../boards/boards-data-store'; -import { MonitorConnection } from '../monitor/monitor-connection'; +import { SerialConnectionManager } from '../serial/serial-connection-manager'; import { BoardsServiceProvider } from '../boards/boards-service-provider'; import { SketchContribution, @@ -21,8 +21,8 @@ export class UploadSketch extends SketchContribution { @inject(CoreService) protected readonly coreService: CoreService; - @inject(MonitorConnection) - protected readonly monitorConnection: MonitorConnection; + @inject(SerialConnectionManager) + protected readonly serialConnection: SerialConnectionManager; @inject(BoardsDataStore) protected readonly boardsDataStore: BoardsDataStore; @@ -108,15 +108,7 @@ export class UploadSketch extends SketchContribution { if (!sketch) { return; } - let shouldAutoConnect = false; - const monitorConfig = this.monitorConnection.monitorConfig; - if (monitorConfig) { - await this.monitorConnection.disconnect(); - if (this.monitorConnection.autoConnect) { - shouldAutoConnect = true; - } - this.monitorConnection.autoConnect = false; - } + await this.serialConnection.disconnect(); try { const { boardsConfig } = this.boardsServiceClientImpl; const [fqbn, { selectedProgrammer }, verify, verbose, sourceOverride] = @@ -175,24 +167,22 @@ export class UploadSketch extends SketchContribution { this.uploadInProgress = false; this.onDidChangeEmitter.fire(); - if (monitorConfig) { - const { board, port } = monitorConfig; + if ( + this.serialConnection.isSerialOpen() && + this.serialConnection.serialConfig + ) { + const { board, port } = this.serialConnection.serialConfig; try { await this.boardsServiceClientImpl.waitUntilAvailable( Object.assign(board, { port }), 10_000 ); - if (shouldAutoConnect) { - // Enabling auto-connect will trigger a connect. - this.monitorConnection.autoConnect = true; - } else { - await this.monitorConnection.connect(monitorConfig); - } + await this.serialConnection.connect(); } catch (waitError) { this.messageService.error( nls.localize( - 'arduino/sketch/couldNotConnectToMonitor', - 'Could not reconnect to serial monitor. {0}', + 'arduino/sketch/couldNotConnectToSerial', + 'Could not reconnect to serial port. {0}', waitError.toString() ) ); diff --git a/arduino-ide-extension/src/browser/menu/arduino-menus.ts b/arduino-ide-extension/src/browser/menu/arduino-menus.ts index 2055cbb61..500bdf124 100644 --- a/arduino-ide-extension/src/browser/menu/arduino-menus.ts +++ b/arduino-ide-extension/src/browser/menu/arduino-menus.ts @@ -86,7 +86,7 @@ export namespace ArduinoMenus { // -- Tools export const TOOLS = [...MAIN_MENU_BAR, '4_tools']; - // `Auto Format`, `Archive Sketch`, `Manage Libraries...`, `Serial Monitor` + // `Auto Format`, `Archive Sketch`, `Manage Libraries...`, `Serial Monitor`, Serial Plotter export const TOOLS__MAIN_GROUP = [...TOOLS, '0_main']; // `WiFi101 / WiFiNINA Firmware Updater` export const TOOLS__FIRMWARE_UPLOADER_GROUP = [ diff --git a/arduino-ide-extension/src/browser/monitor/monitor-connection.ts b/arduino-ide-extension/src/browser/monitor/monitor-connection.ts deleted file mode 100644 index 354f71ce3..000000000 --- a/arduino-ide-extension/src/browser/monitor/monitor-connection.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { injectable, inject, postConstruct } 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 { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; -import { - MonitorService, - MonitorConfig, - MonitorError, - Status, - MonitorServiceClient, -} from '../../common/protocol/monitor-service'; -import { BoardsServiceProvider } from '../boards/boards-service-provider'; -import { - Port, - Board, - BoardsService, - AttachedBoardsChangeEvent, -} from '../../common/protocol/boards-service'; -import { BoardsConfig } from '../boards/boards-config'; -import { MonitorModel } from './monitor-model'; -import { NotificationCenter } from '../notification-center'; -import { nls } from '@theia/core/lib/browser/nls'; - -@injectable() -export class MonitorConnection { - @inject(MonitorModel) - protected readonly monitorModel: MonitorModel; - - @inject(MonitorService) - protected readonly monitorService: MonitorService; - - @inject(MonitorServiceClient) - protected readonly monitorServiceClient: MonitorServiceClient; - - @inject(BoardsService) - protected readonly boardsService: BoardsService; - - @inject(BoardsServiceProvider) - protected readonly boardsServiceProvider: BoardsServiceProvider; - - @inject(NotificationCenter) - protected readonly notificationCenter: NotificationCenter; - - @inject(MessageService) - protected messageService: MessageService; - - @inject(FrontendApplicationStateService) - protected readonly applicationState: FrontendApplicationStateService; - - protected state: MonitorConnection.State | undefined; - /** - * Note: The idea is to toggle this property from the UI (`Monitor` view) - * and the boards config and the boards attachment/detachment logic can be at on place, here. - */ - protected _autoConnect = false; - protected readonly onConnectionChangedEmitter = new Emitter< - MonitorConnection.State | undefined - >(); - /** - * This emitter forwards all read events **iff** the connection is established. - */ - protected readonly onReadEmitter = new Emitter<{ messages: string[] }>(); - - /** - * Array for storing previous monitor errors received from the server, and based on the number of elements in this array, - * we adjust the reconnection delay. - * Super naive way: we wait `array.length * 1000` ms. Once we hit 10 errors, we do not try to reconnect and clean the array. - */ - protected monitorErrors: MonitorError[] = []; - protected reconnectTimeout?: number; - - @postConstruct() - protected init(): void { - this.monitorServiceClient.onMessage(this.handleMessage.bind(this)); - this.monitorServiceClient.onError(this.handleError.bind(this)); - this.boardsServiceProvider.onBoardsConfigChanged( - this.handleBoardConfigChange.bind(this) - ); - this.notificationCenter.onAttachedBoardsChanged( - this.handleAttachedBoardsChanged.bind(this) - ); - - // Handles the `baudRate` changes by reconnecting if required. - this.monitorModel.onChange(({ property }) => { - if (property === 'baudRate' && this.autoConnect && this.connected) { - const { boardsConfig } = this.boardsServiceProvider; - this.handleBoardConfigChange(boardsConfig); - } - }); - } - - async handleMessage(port: string): Promise { - const w = new WebSocket(`ws://localhost:${port}`); - w.onmessage = (res) => { - const messages = JSON.parse(res.data); - this.onReadEmitter.fire({ messages }); - }; - } - - get connected(): boolean { - return !!this.state; - } - - get monitorConfig(): MonitorConfig | undefined { - return this.state ? this.state.config : undefined; - } - - get autoConnect(): boolean { - return this._autoConnect; - } - - set autoConnect(value: boolean) { - const oldValue = this._autoConnect; - this._autoConnect = value; - // When we enable the auto-connect, we have to connect - if (!oldValue && value) { - // We have to make sure the previous boards config has been restored. - // Otherwise, we might start the auto-connection without configured boards. - this.applicationState.reachedState('started_contributions').then(() => { - const { boardsConfig } = this.boardsServiceProvider; - this.handleBoardConfigChange(boardsConfig); - }); - } else if (oldValue && !value) { - if (this.reconnectTimeout !== undefined) { - window.clearTimeout(this.reconnectTimeout); - this.monitorErrors.length = 0; - } - } - } - - handleError(error: MonitorError): void { - let shouldReconnect = false; - if (this.state) { - const { code, config } = error; - const { board, port } = config; - const options = { timeout: 3000 }; - switch (code) { - case MonitorError.ErrorCodes.CLIENT_CANCEL: { - console.debug( - `Connection was canceled by client: ${MonitorConnection.State.toString( - this.state - )}.` - ); - break; - } - case MonitorError.ErrorCodes.DEVICE_BUSY: { - this.messageService.warn( - nls.localize( - 'arduino/monitor/connectionBusy', - 'Connection failed. Serial port is busy: {0}', - Port.toString(port) - ), - options - ); - shouldReconnect = this.autoConnect; - this.monitorErrors.push(error); - break; - } - case MonitorError.ErrorCodes.DEVICE_NOT_CONFIGURED: { - this.messageService.info( - nls.localize( - 'arduino/monitor/disconnected', - 'Disconnected {0} from {1}.', - Board.toString(board, { - useFqbn: false, - }), - Port.toString(port) - ), - options - ); - break; - } - case undefined: { - this.messageService.error( - nls.localize( - 'arduino/monitor/unexpectedError', - 'Unexpected error. Reconnecting {0} on port {1}.', - Board.toString(board), - Port.toString(port) - ), - options - ); - console.error(JSON.stringify(error)); - shouldReconnect = this.connected && this.autoConnect; - break; - } - } - const oldState = this.state; - this.state = undefined; - this.onConnectionChangedEmitter.fire(this.state); - if (shouldReconnect) { - if (this.monitorErrors.length >= 10) { - this.messageService.warn( - nls.localize( - 'arduino/monitor/failedReconnect', - 'Failed to reconnect {0} to the the serial-monitor after 10 consecutive attempts. The {1} serial port is busy.', - Board.toString(board, { - useFqbn: false, - }), - Port.toString(port) - ) - ); - this.monitorErrors.length = 0; - } else { - const attempts = this.monitorErrors.length || 1; - if (this.reconnectTimeout !== undefined) { - // Clear the previous timer. - window.clearTimeout(this.reconnectTimeout); - } - const timeout = attempts * 1000; - this.messageService.warn( - nls.localize( - 'arduino/monitor/reconnect', - 'Reconnecting {0} to {1} in {2] seconds...', - Board.toString(board, { - useFqbn: false, - }), - Port.toString(port), - attempts.toString() - ), - { timeout } - ); - this.reconnectTimeout = window.setTimeout( - () => this.connect(oldState.config), - timeout - ); - } - } - } - } - - handleAttachedBoardsChanged(event: AttachedBoardsChangeEvent): void { - if (this.autoConnect && this.connected) { - const { boardsConfig } = this.boardsServiceProvider; - if ( - this.boardsServiceProvider.canUploadTo(boardsConfig, { - silent: false, - }) - ) { - const { attached } = AttachedBoardsChangeEvent.diff(event); - if ( - attached.boards.some( - (board) => - !!board.port && BoardsConfig.Config.sameAs(boardsConfig, board) - ) - ) { - const { selectedBoard: board, selectedPort: port } = boardsConfig; - const { baudRate } = this.monitorModel; - this.disconnect().then(() => this.connect({ board, port, baudRate })); - } - } - } - } - - async connect(config: MonitorConfig): Promise { - if (this.connected) { - const disconnectStatus = await this.disconnect(); - if (!Status.isOK(disconnectStatus)) { - return disconnectStatus; - } - } - console.info( - `>>> Creating serial monitor connection for ${Board.toString( - config.board - )} on port ${Port.toString(config.port)}...` - ); - const connectStatus = await this.monitorService.connect(config); - if (Status.isOK(connectStatus)) { - this.state = { config }; - console.info( - `<<< Serial monitor connection created for ${Board.toString( - config.board, - { useFqbn: false } - )} on port ${Port.toString(config.port)}.` - ); - } - this.onConnectionChangedEmitter.fire(this.state); - return Status.isOK(connectStatus); - } - - async disconnect(): Promise { - if (!this.connected) { - return Status.OK; - } - const stateCopy = deepClone(this.state); - if (!stateCopy) { - return Status.OK; - } - console.log('>>> Disposing existing monitor connection...'); - const status = await this.monitorService.disconnect(); - if (Status.isOK(status)) { - console.log( - `<<< Disposed connection. Was: ${MonitorConnection.State.toString( - stateCopy - )}` - ); - } else { - console.warn( - `<<< Could not dispose connection. Activate connection: ${MonitorConnection.State.toString( - stateCopy - )}` - ); - } - this.state = undefined; - this.onConnectionChangedEmitter.fire(this.state); - return status; - } - - /** - * Sends the data to the connected serial monitor. - * The desired EOL is appended to `data`, you do not have to add it. - * It is a NOOP if connected. - */ - async send(data: string): Promise { - if (!this.connected) { - return Status.NOT_CONNECTED; - } - return new Promise((resolve) => { - this.monitorService - .send(data + this.monitorModel.lineEnding) - .then(() => resolve(Status.OK)); - }); - } - - get onConnectionChanged(): Event { - return this.onConnectionChangedEmitter.event; - } - - get onRead(): Event<{ messages: string[] }> { - return this.onReadEmitter.event; - } - - protected async handleBoardConfigChange( - boardsConfig: BoardsConfig.Config - ): Promise { - if (this.autoConnect) { - if ( - this.boardsServiceProvider.canUploadTo(boardsConfig, { - silent: false, - }) - ) { - // Instead of calling `getAttachedBoards` and filtering for `AttachedSerialBoard` we have to check the available ports. - // The connected board might be unknown. See: https://github.com/arduino/arduino-pro-ide/issues/127#issuecomment-563251881 - this.boardsService.getAvailablePorts().then((ports) => { - if ( - ports.some((port) => Port.equals(port, boardsConfig.selectedPort)) - ) { - new Promise((resolve) => { - // First, disconnect if connected. - if (this.connected) { - this.disconnect().then(() => resolve()); - return; - } - resolve(); - }).then(() => { - // Then (re-)connect. - const { selectedBoard: board, selectedPort: port } = boardsConfig; - const { baudRate } = this.monitorModel; - this.connect({ board, port, baudRate }); - }); - } - }); - } - } - } -} - -export namespace MonitorConnection { - export interface State { - readonly config: MonitorConfig; - } - - export namespace State { - export function toString(state: State): string { - const { config } = state; - const { board, port } = config; - return `${Board.toString(board)} ${Port.toString(port)}`; - } - } -} diff --git a/arduino-ide-extension/src/browser/monitor/monitor-service-client-impl.ts b/arduino-ide-extension/src/browser/monitor/monitor-service-client-impl.ts deleted file mode 100644 index 9ab757ef4..000000000 --- a/arduino-ide-extension/src/browser/monitor/monitor-service-client-impl.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { injectable } from 'inversify'; -import { Emitter } from '@theia/core/lib/common/event'; -import { - MonitorServiceClient, - MonitorError, -} from '../../common/protocol/monitor-service'; - -@injectable() -export class MonitorServiceClientImpl implements MonitorServiceClient { - protected readonly onErrorEmitter = new Emitter(); - readonly onError = this.onErrorEmitter.event; - - protected readonly onMessageEmitter = new Emitter(); - readonly onMessage = this.onMessageEmitter.event; - - notifyError(error: MonitorError): void { - this.onErrorEmitter.fire(error); - } - - notifyMessage(message: string): void { - this.onMessageEmitter.fire(message); - } -} diff --git a/arduino-ide-extension/src/browser/monitor/monitor-utils.ts b/arduino-ide-extension/src/browser/serial/monitor/monitor-utils.ts similarity index 96% rename from arduino-ide-extension/src/browser/monitor/monitor-utils.ts rename to arduino-ide-extension/src/browser/serial/monitor/monitor-utils.ts index 586eea146..41cb4f450 100644 --- a/arduino-ide-extension/src/browser/monitor/monitor-utils.ts +++ b/arduino-ide-extension/src/browser/serial/monitor/monitor-utils.ts @@ -9,6 +9,7 @@ export function messagesToLines( const linesToAdd: Line[] = prevLines.length ? [prevLines[prevLines.length - 1]] : [{ message: '', lineLen: 0 }]; + if (!(Symbol.iterator in Object(messages))) return [prevLines, charCount]; for (const message of messages) { const messageLen = message.length; diff --git a/arduino-ide-extension/src/browser/monitor/monitor-view-contribution.tsx b/arduino-ide-extension/src/browser/serial/monitor/monitor-view-contribution.tsx similarity index 93% rename from arduino-ide-extension/src/browser/monitor/monitor-view-contribution.tsx rename to arduino-ide-extension/src/browser/serial/monitor/monitor-view-contribution.tsx index 3eb48c321..0f2d0257f 100644 --- a/arduino-ide-extension/src/browser/monitor/monitor-view-contribution.tsx +++ b/arduino-ide-extension/src/browser/serial/monitor/monitor-view-contribution.tsx @@ -7,9 +7,9 @@ import { TabBarToolbarContribution, TabBarToolbarRegistry, } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; -import { ArduinoToolbar } from '../toolbar/arduino-toolbar'; -import { MonitorModel } from './monitor-model'; -import { ArduinoMenus } from '../menu/arduino-menus'; +import { ArduinoToolbar } from '../../toolbar/arduino-toolbar'; +import { SerialModel } from '../serial-model'; +import { ArduinoMenus } from '../../menu/arduino-menus'; import { nls } from '@theia/core/lib/browser/nls'; export namespace SerialMonitor { @@ -19,14 +19,14 @@ export namespace SerialMonitor { id: 'serial-monitor-autoscroll', label: 'Autoscroll', }, - 'arduino/monitor/autoscroll' + 'arduino/serial/autoscroll' ); export const TIMESTAMP = Command.toLocalizedCommand( { id: 'serial-monitor-timestamp', label: 'Timestamp', }, - 'arduino/monitor/timestamp' + 'arduino/serial/timestamp' ); export const CLEAR_OUTPUT = Command.toLocalizedCommand( { @@ -48,7 +48,7 @@ export class MonitorViewContribution static readonly TOGGLE_SERIAL_MONITOR_TOOLBAR = MonitorWidget.ID + ':toggle-toolbar'; - @inject(MonitorModel) protected readonly model: MonitorModel; + @inject(SerialModel) protected readonly model: SerialModel; constructor() { super({ @@ -156,7 +156,7 @@ export class MonitorViewContribution
{ - this.monitorConnection.autoConnect = false; - if (this.monitorConnection.connected) { - this.monitorConnection.disconnect(); - } - }) + Disposable.create(() => + this.serialConnection.closeSerial(Serial.Type.Monitor) + ) ); } @@ -66,8 +67,9 @@ export class MonitorWidget extends ReactWidget { protected init(): void { this.update(); this.toDispose.push( - this.monitorConnection.onConnectionChanged(() => this.clearConsole()) + this.serialConnection.onConnectionChanged(() => this.clearConsole()) ); + this.toDispose.push(this.serialModel.onChange(() => this.update())); } clearConsole(): void { @@ -81,7 +83,7 @@ export class MonitorWidget extends ReactWidget { protected onAfterAttach(msg: Message): void { super.onAfterAttach(msg); - this.monitorConnection.autoConnect = true; + this.serialConnection.openSerial(Serial.Type.Monitor); } onCloseRequest(msg: Message): void { @@ -119,27 +121,24 @@ export class MonitorWidget extends ReactWidget { }; protected get lineEndings(): OptionsType< - SerialMonitorOutput.SelectOption + SerialMonitorOutput.SelectOption > { return [ { - label: nls.localize('arduino/monitor/noLineEndings', 'No Line Ending'), + label: nls.localize('arduino/serial/noLineEndings', 'No Line Ending'), value: '', }, { - label: nls.localize('arduino/monitor/newLine', 'New Line'), + label: nls.localize('arduino/serial/newLine', 'New Line'), value: '\n', }, { - label: nls.localize( - 'arduino/monitor/carriageReturn', - 'Carriage Return' - ), + label: nls.localize('arduino/serial/carriageReturn', 'Carriage Return'), value: '\r', }, { label: nls.localize( - 'arduino/monitor/newLineCarriageReturn', + 'arduino/serial/newLineCarriageReturn', 'Both NL & CR' ), value: '\r\n', @@ -148,9 +147,9 @@ export class MonitorWidget extends ReactWidget { } protected get baudRates(): OptionsType< - SerialMonitorOutput.SelectOption + SerialMonitorOutput.SelectOption > { - const baudRates: Array = [ + const baudRates: Array = [ 300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, ]; return baudRates.map((baudRate) => ({ @@ -162,17 +161,17 @@ export class MonitorWidget extends ReactWidget { protected render(): React.ReactNode { const { baudRates, lineEndings } = this; const lineEnding = - lineEndings.find((item) => item.value === this.monitorModel.lineEnding) || + lineEndings.find((item) => item.value === this.serialModel.lineEnding) || lineEndings[1]; // Defaults to `\n`. const baudRate = - baudRates.find((item) => item.value === this.monitorModel.baudRate) || + baudRates.find((item) => item.value === this.serialModel.baudRate) || baudRates[4]; // Defaults to `9600`. return (
@@ -182,7 +181,7 @@ export class MonitorWidget extends ReactWidget {
@@ -191,7 +190,7 @@ export class MonitorWidget extends ReactWidget { className="select" maxMenuHeight={this.widgetHeight - 40} options={baudRates} - defaultValue={baudRate} + value={baudRate} onChange={this.onChangeBaudRate} />
@@ -199,8 +198,8 @@ export class MonitorWidget extends ReactWidget {
@@ -211,18 +210,18 @@ export class MonitorWidget extends ReactWidget { protected readonly onSend = (value: string) => this.doSend(value); protected async doSend(value: string): Promise { - this.monitorConnection.send(value); + this.serialConnection.send(value); } protected readonly onChangeLineEnding = ( - option: SerialMonitorOutput.SelectOption + option: SerialMonitorOutput.SelectOption ) => { - this.monitorModel.lineEnding = option.value; + this.serialModel.lineEnding = option.value; }; protected readonly onChangeBaudRate = ( - option: SerialMonitorOutput.SelectOption + option: SerialMonitorOutput.SelectOption ) => { - this.monitorModel.baudRate = option.value; + this.serialModel.baudRate = option.value; }; } diff --git a/arduino-ide-extension/src/browser/monitor/serial-monitor-send-input.tsx b/arduino-ide-extension/src/browser/serial/monitor/serial-monitor-send-input.tsx similarity index 83% rename from arduino-ide-extension/src/browser/monitor/serial-monitor-send-input.tsx rename to arduino-ide-extension/src/browser/serial/monitor/serial-monitor-send-input.tsx index 2e197e33a..8e2335a48 100644 --- a/arduino-ide-extension/src/browser/monitor/serial-monitor-send-input.tsx +++ b/arduino-ide-extension/src/browser/serial/monitor/serial-monitor-send-input.tsx @@ -1,13 +1,13 @@ import * as React from 'react'; import { Key, KeyCode } from '@theia/core/lib/browser/keys'; -import { Board, Port } from '../../common/protocol/boards-service'; -import { MonitorConfig } from '../../common/protocol/monitor-service'; +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/browser/nls'; export namespace SerialMonitorSendInput { export interface Props { - readonly monitorConfig?: MonitorConfig; + readonly serialConfig?: SerialConfig; readonly onSend: (text: string) => void; readonly resolveFocus: (element: HTMLElement | undefined) => void; } @@ -33,7 +33,7 @@ export class SerialMonitorSendInput extends React.Component< { + this.props.serialConnection.onRead(({ messages }) => { const [newLines, totalCharCount] = messagesToLines( messages, this.state.lines, @@ -74,9 +74,9 @@ export class SerialMonitorOutput extends React.Component< this.props.clearConsoleEvent(() => this.setState({ lines: [], charCount: 0 }) ), - this.props.monitorModel.onChange(({ property }) => { + this.props.serialModel.onChange(({ property }) => { if (property === 'timestamp') { - const { timestamp } = this.props.monitorModel; + const { timestamp } = this.props.serialModel; this.setState({ timestamp }); } if (property === 'autoscroll') { @@ -92,7 +92,7 @@ export class SerialMonitorOutput extends React.Component< } scrollToBottom = ((): void => { - if (this.listRef.current && this.props.monitorModel.autoscroll) { + if (this.listRef.current && this.props.serialModel.autoscroll) { this.listRef.current.scrollToItem(this.state.lines.length, 'end'); } }).bind(this); @@ -125,8 +125,8 @@ const Row = React.memo(_Row, areEqual); export namespace SerialMonitorOutput { export interface Props { - readonly monitorModel: MonitorModel; - readonly monitorConnection: MonitorConnection; + readonly serialModel: SerialModel; + readonly serialConnection: SerialConnectionManager; readonly clearConsoleEvent: Event; readonly height: number; } diff --git a/arduino-ide-extension/src/browser/serial/plotter/plotter-frontend-contribution.ts b/arduino-ide-extension/src/browser/serial/plotter/plotter-frontend-contribution.ts new file mode 100644 index 000000000..bbc54b27e --- /dev/null +++ b/arduino-ide-extension/src/browser/serial/plotter/plotter-frontend-contribution.ts @@ -0,0 +1,110 @@ +import { ThemeService } from '@theia/core/lib/browser/theming'; +import { injectable, inject } from 'inversify'; +import { + Command, + CommandRegistry, + MaybePromise, + MenuModelRegistry, +} from '@theia/core'; +import { SerialModel } from '../serial-model'; +import { ArduinoMenus } from '../../menu/arduino-menus'; +import { Contribution } from '../../contributions/contribution'; +import { Endpoint, FrontendApplication } from '@theia/core/lib/browser'; +import { ipcRenderer } from '@theia/core/shared/electron'; +import { SerialConfig, Status } from '../../../common/protocol'; +import { Serial, SerialConnectionManager } from '../serial-connection-manager'; +import { SerialPlotter } from './protocol'; +import { BoardsServiceProvider } from '../../boards/boards-service-provider'; +const queryString = require('query-string'); + +export namespace SerialPlotterContribution { + export namespace Commands { + export const OPEN: Command = { + id: 'serial-plotter-open', + label: 'Serial Plotter', + category: 'Arduino', + }; + } +} + +@injectable() +export class PlotterFrontendContribution extends Contribution { + protected window: Window | null; + protected url: string; + protected wsPort: number; + + @inject(SerialModel) + protected readonly model: SerialModel; + + @inject(ThemeService) + protected readonly themeService: ThemeService; + + @inject(SerialConnectionManager) + protected readonly serialConnection: SerialConnectionManager; + + @inject(BoardsServiceProvider) + protected readonly boardsServiceProvider: BoardsServiceProvider; + + onStart(app: FrontendApplication): MaybePromise { + this.url = new Endpoint({ path: '/plotter' }).getRestUrl().toString(); + + ipcRenderer.on('CLOSE_CHILD_WINDOW', async () => { + if (!!this.window) { + this.window = null; + await this.serialConnection.closeSerial(Serial.Type.Plotter); + } + }); + + return super.onStart(app); + } + + registerCommands(registry: CommandRegistry): void { + registry.registerCommand(SerialPlotterContribution.Commands.OPEN, { + execute: this.connect.bind(this), + }); + } + + registerMenus(menus: MenuModelRegistry): void { + menus.registerMenuAction(ArduinoMenus.TOOLS__MAIN_GROUP, { + commandId: SerialPlotterContribution.Commands.OPEN.id, + label: SerialPlotterContribution.Commands.OPEN.label, + order: '7', + }); + } + + async connect(): Promise { + if (!!this.window) { + this.window.focus(); + return; + } + const status = await this.serialConnection.openSerial(Serial.Type.Plotter); + const wsPort = this.serialConnection.getWsPort(); + if (Status.isOK(status) && wsPort) { + this.open(wsPort); + } else { + this.serialConnection.closeSerial(Serial.Type.Plotter); + this.messageService.error(`Couldn't open serial plotter`); + } + } + + protected open(wsPort: number): void { + const initConfig: Partial = { + baudrates: SerialConfig.BaudRates.map((b) => b), + currentBaudrate: this.model.baudRate, + currentLineEnding: this.model.lineEnding, + darkTheme: this.themeService.getCurrentTheme().type === 'dark', + wsPort, + interpolate: this.model.interpolate, + connected: this.serialConnection.connected, + serialPort: this.boardsServiceProvider.boardsConfig.selectedPort?.address, + }; + const urlWithParams = queryString.stringifyUrl( + { + url: this.url, + query: initConfig, + }, + { arrayFormat: 'comma' } + ); + this.window = window.open(urlWithParams, 'serialPlotter'); + } +} diff --git a/arduino-ide-extension/src/browser/serial/plotter/protocol.ts b/arduino-ide-extension/src/browser/serial/plotter/protocol.ts new file mode 100644 index 000000000..c38c9fcb9 --- /dev/null +++ b/arduino-ide-extension/src/browser/serial/plotter/protocol.ts @@ -0,0 +1,26 @@ +export namespace SerialPlotter { + export type Config = { + currentBaudrate: number; + baudrates: number[]; + currentLineEnding: string; + darkTheme: boolean; + wsPort: number; + interpolate: boolean; + serialPort: string; + connected: boolean; + generate?: boolean; + }; + export namespace Protocol { + export enum Command { + PLOTTER_SET_BAUDRATE = 'PLOTTER_SET_BAUDRATE', + PLOTTER_SET_LINE_ENDING = 'PLOTTER_SET_LINE_ENDING', + PLOTTER_SET_INTERPOLATE = 'PLOTTER_SET_INTERPOLATE', + PLOTTER_SEND_MESSAGE = 'PLOTTER_SEND_MESSAGE', + MIDDLEWARE_CONFIG_CHANGED = 'MIDDLEWARE_CONFIG_CHANGED', + } + export type Message = { + command: SerialPlotter.Protocol.Command; + data?: any; + }; + } +} diff --git a/arduino-ide-extension/src/browser/serial/serial-connection-manager.ts b/arduino-ide-extension/src/browser/serial/serial-connection-manager.ts new file mode 100644 index 000000000..ca7ef1eb0 --- /dev/null +++ b/arduino-ide-extension/src/browser/serial/serial-connection-manager.ts @@ -0,0 +1,479 @@ +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 { + SerialService, + SerialConfig, + SerialError, + Status, + SerialServiceClient, +} from '../../common/protocol/serial-service'; +import { BoardsServiceProvider } from '../boards/boards-service-provider'; +import { + Port, + Board, + BoardsService, +} from '../../common/protocol/boards-service'; +import { BoardsConfig } from '../boards/boards-config'; +import { SerialModel } from './serial-model'; +import { ThemeService } from '@theia/core/lib/browser/theming'; +import { nls } from '@theia/core/lib/browser/nls'; +import { CoreService } from '../../common/protocol'; + +@injectable() +export class SerialConnectionManager { + protected _state: Serial.State = []; + protected _connected = false; + protected config: Partial = { + board: undefined, + port: undefined, + baudRate: undefined, + }; + + protected readonly onConnectionChangedEmitter = new Emitter(); + + /** + * This emitter forwards all read events **if** the connection is established. + */ + protected readonly onReadEmitter = new Emitter<{ messages: string[] }>(); + + /** + * Array for storing previous serial errors received from the server, and based on the number of elements in this array, + * we adjust the reconnection delay. + * Super naive way: we wait `array.length * 1000` ms. Once we hit 10 errors, we do not try to reconnect and clean the array. + */ + protected serialErrors: SerialError[] = []; + protected reconnectTimeout?: number; + + /** + * When the websocket server is up on the backend, we save the port here, so that the client knows how to connect to it + * */ + protected wsPort?: number; + protected webSocket?: WebSocket; + + constructor( + @inject(SerialModel) protected readonly serialModel: SerialModel, + @inject(SerialService) protected readonly serialService: SerialService, + @inject(SerialServiceClient) + protected readonly serialServiceClient: SerialServiceClient, + @inject(BoardsService) protected readonly boardsService: BoardsService, + @inject(BoardsServiceProvider) + protected readonly boardsServiceProvider: BoardsServiceProvider, + @inject(MessageService) protected messageService: MessageService, + @inject(ThemeService) protected readonly themeService: ThemeService, + @inject(CoreService) protected readonly core: CoreService + ) { + this.serialServiceClient.onWebSocketChanged( + this.handleWebSocketChanged.bind(this) + ); + this.serialServiceClient.onBaudRateChanged((baudRate) => { + if (this.serialModel.baudRate !== baudRate) { + this.serialModel.baudRate = baudRate; + } + }); + this.serialServiceClient.onLineEndingChanged((lineending) => { + if (this.serialModel.lineEnding !== lineending) { + this.serialModel.lineEnding = lineending; + } + }); + this.serialServiceClient.onInterpolateChanged((interpolate) => { + if (this.serialModel.interpolate !== interpolate) { + this.serialModel.interpolate = interpolate; + } + }); + + this.serialServiceClient.onError(this.handleError.bind(this)); + this.boardsServiceProvider.onBoardsConfigChanged( + this.handleBoardConfigChange.bind(this) + ); + + // Handles the `baudRate` changes by reconnecting if required. + this.serialModel.onChange(({ property }) => { + if (property === 'baudRate' && this.connected) { + const { boardsConfig } = this.boardsServiceProvider; + this.handleBoardConfigChange(boardsConfig); + } + + // update the current values in the backend and propagate to websocket clients + this.serialService.updateWsConfigParam({ + ...(property === 'lineEnding' && { + currentLineEnding: this.serialModel.lineEnding, + }), + ...(property === 'interpolate' && { + interpolate: this.serialModel.interpolate, + }), + }); + }); + + this.themeService.onDidColorThemeChange((theme) => { + this.serialService.updateWsConfigParam({ + darkTheme: theme.newTheme.type === 'dark', + }); + }); + } + + /** + * Set the config passing only the properties that has changed. If some has changed and the serial is open, + * we try to reconnect + * + * @param newConfig the porperties of the config that has changed + */ + async setConfig(newConfig: Partial): Promise { + let configHasChanged = false; + Object.keys(this.config).forEach((key: keyof SerialConfig) => { + if (newConfig[key] !== this.config[key]) { + configHasChanged = true; + this.config = { ...this.config, [key]: newConfig[key] }; + } + }); + if ( + configHasChanged && + this.isSerialOpen() && + !(await this.core.isUploading()) + ) { + this.serialService.updateWsConfigParam({ + currentBaudrate: this.config.baudRate, + serialPort: this.config.port?.address, + }); + await this.disconnect(); + await this.connect(); + } + } + + getConfig(): Partial { + return this.config; + } + + getWsPort(): number | undefined { + 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; + } + + 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 { + 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; + } + + /** + * 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 + ) { + this.webSocket.close(); + this.webSocket = undefined; + } + } + return status; + } + + /** + * Handles error on the SerialServiceClient and try to reconnect, eventually + */ + handleError(error: SerialError): void { + if (!this.connected) return; + const { code, config } = error; + const { board, port } = config; + const options = { timeout: 3000 }; + switch (code) { + case SerialError.ErrorCodes.CLIENT_CANCEL: { + console.debug( + `Serial connection was canceled by client: ${Serial.Config.toString( + this.config + )}.` + ); + break; + } + case SerialError.ErrorCodes.DEVICE_BUSY: { + this.messageService.warn( + nls.localize( + 'arduino/serial/connectionBusy', + 'Connection failed. Serial port is busy: {0}', + Port.toString(port) + ), + options + ); + this.serialErrors.push(error); + break; + } + case SerialError.ErrorCodes.DEVICE_NOT_CONFIGURED: { + this.messageService.info( + nls.localize( + 'arduino/serial/disconnected', + 'Disconnected {0} from {1}.', + Board.toString(board, { + useFqbn: false, + }), + Port.toString(port) + ), + options + ); + break; + } + case undefined: { + this.messageService.error( + nls.localize( + 'arduino/serial/unexpectedError', + 'Unexpected error. Reconnecting {0} on port {1}.', + Board.toString(board), + Port.toString(port) + ), + options + ); + console.error(JSON.stringify(error)); + break; + } + } + this.connected = false; + + if (this.isSerialOpen()) { + if (this.serialErrors.length >= 10) { + this.messageService.warn( + nls.localize( + 'arduino/serial/failedReconnect', + 'Failed to reconnect {0} to serial port after 10 consecutive attempts. The {1} serial port is busy.', + Board.toString(board, { + useFqbn: false, + }), + Port.toString(port) + ) + ); + this.serialErrors.length = 0; + } else { + const attempts = this.serialErrors.length || 1; + if (this.reconnectTimeout !== undefined) { + // Clear the previous timer. + window.clearTimeout(this.reconnectTimeout); + } + const timeout = attempts * 1000; + this.messageService.warn( + nls.localize( + 'arduino/serial/reconnect', + 'Reconnecting {0} to {1} in {2] seconds...', + Board.toString(board, { + useFqbn: false, + }), + Port.toString(port), + attempts.toString() + ) + ); + this.reconnectTimeout = window.setTimeout( + () => this.connect(), + 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 + )}` + ); + } + + return status; + } + + /** + * Sends the data to the connected serial port. + * The desired EOL is appended to `data`, you do not have to add it. + * It is a NOOP if connected. + */ + async send(data: string): Promise { + if (!this.connected) { + return Status.NOT_CONNECTED; + } + return new Promise((resolve) => { + this.serialService + .sendMessageToSerial(data + this.serialModel.lineEnding) + .then(() => resolve(Status.OK)); + }); + } + + get onConnectionChanged(): Event { + return this.onConnectionChangedEmitter.event; + } + + get onRead(): Event<{ messages: string[] }> { + return this.onReadEmitter.event; + } + + protected async handleBoardConfigChange( + boardsConfig: BoardsConfig.Config + ): Promise { + const { selectedBoard: board, selectedPort: port } = boardsConfig; + const { baudRate } = this.serialModel; + const newConfig: Partial = { board, port, baudRate }; + this.setConfig(newConfig); + } +} + +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 ''; + const { board, port } = config; + return `${Board.toString(board)} ${Port.toString(port)}`; + } + } +} + +function isSerialConfig(config: Partial): config is SerialConfig { + return !!config.board && !!config.baudRate && !!config.port; +} diff --git a/arduino-ide-extension/src/browser/monitor/monitor-model.ts b/arduino-ide-extension/src/browser/serial/serial-model.ts similarity index 65% rename from arduino-ide-extension/src/browser/monitor/monitor-model.ts rename to arduino-ide-extension/src/browser/serial/serial-model.ts index ba3860725..fc6e352ec 100644 --- a/arduino-ide-extension/src/browser/monitor/monitor-model.ts +++ b/arduino-ide-extension/src/browser/serial/serial-model.ts @@ -1,6 +1,6 @@ import { injectable, inject } from 'inversify'; import { Emitter, Event } from '@theia/core/lib/common/event'; -import { MonitorConfig } from '../../common/protocol/monitor-service'; +import { SerialConfig } from '../../common/protocol'; import { FrontendApplicationContribution, LocalStorageService, @@ -8,8 +8,8 @@ import { import { BoardsServiceProvider } from '../boards/boards-service-provider'; @injectable() -export class MonitorModel implements FrontendApplicationContribution { - protected static STORAGE_ID = 'arduino-monitor-model'; +export class SerialModel implements FrontendApplicationContribution { + protected static STORAGE_ID = 'arduino-serial-model'; @inject(LocalStorageService) protected readonly localStorageService: LocalStorageService; @@ -18,26 +18,28 @@ export class MonitorModel implements FrontendApplicationContribution { protected readonly boardsServiceClient: BoardsServiceProvider; protected readonly onChangeEmitter: Emitter< - MonitorModel.State.Change + SerialModel.State.Change >; protected _autoscroll: boolean; protected _timestamp: boolean; - protected _baudRate: MonitorConfig.BaudRate; - protected _lineEnding: MonitorModel.EOL; + protected _baudRate: SerialConfig.BaudRate; + protected _lineEnding: SerialModel.EOL; + protected _interpolate: boolean; constructor() { this._autoscroll = true; this._timestamp = false; - this._baudRate = MonitorConfig.BaudRate.DEFAULT; - this._lineEnding = MonitorModel.EOL.DEFAULT; + this._baudRate = SerialConfig.BaudRate.DEFAULT; + this._lineEnding = SerialModel.EOL.DEFAULT; + this._interpolate = false; this.onChangeEmitter = new Emitter< - MonitorModel.State.Change + SerialModel.State.Change >(); } onStart(): void { this.localStorageService - .getData(MonitorModel.STORAGE_ID) + .getData(SerialModel.STORAGE_ID) .then((state) => { if (state) { this.restoreState(state); @@ -45,7 +47,7 @@ export class MonitorModel implements FrontendApplicationContribution { }); } - get onChange(): Event> { + get onChange(): Event> { return this.onChangeEmitter.event; } @@ -78,11 +80,11 @@ export class MonitorModel implements FrontendApplicationContribution { ); } - get baudRate(): MonitorConfig.BaudRate { + get baudRate(): SerialConfig.BaudRate { return this._baudRate; } - set baudRate(baudRate: MonitorConfig.BaudRate) { + set baudRate(baudRate: SerialConfig.BaudRate) { this._baudRate = baudRate; this.storeState().then(() => this.onChangeEmitter.fire({ @@ -92,11 +94,11 @@ export class MonitorModel implements FrontendApplicationContribution { ); } - get lineEnding(): MonitorModel.EOL { + get lineEnding(): SerialModel.EOL { return this._lineEnding; } - set lineEnding(lineEnding: MonitorModel.EOL) { + set lineEnding(lineEnding: SerialModel.EOL) { this._lineEnding = lineEnding; this.storeState().then(() => this.onChangeEmitter.fire({ @@ -106,29 +108,46 @@ export class MonitorModel implements FrontendApplicationContribution { ); } - protected restoreState(state: MonitorModel.State): void { + get interpolate(): boolean { + return this._interpolate; + } + + set interpolate(i: boolean) { + this._interpolate = i; + this.storeState().then(() => + this.onChangeEmitter.fire({ + property: 'interpolate', + value: this._interpolate, + }) + ); + } + + protected restoreState(state: SerialModel.State): void { this._autoscroll = state.autoscroll; this._timestamp = state.timestamp; this._baudRate = state.baudRate; this._lineEnding = state.lineEnding; + this._interpolate = state.interpolate; } protected async storeState(): Promise { - return this.localStorageService.setData(MonitorModel.STORAGE_ID, { + return this.localStorageService.setData(SerialModel.STORAGE_ID, { autoscroll: this._autoscroll, timestamp: this._timestamp, baudRate: this._baudRate, lineEnding: this._lineEnding, + interpolate: this._interpolate, }); } } -export namespace MonitorModel { +export namespace SerialModel { export interface State { autoscroll: boolean; timestamp: boolean; - baudRate: MonitorConfig.BaudRate; + baudRate: SerialConfig.BaudRate; lineEnding: EOL; + interpolate: boolean; } export namespace State { export interface Change { diff --git a/arduino-ide-extension/src/browser/serial/serial-service-client-impl.ts b/arduino-ide-extension/src/browser/serial/serial-service-client-impl.ts new file mode 100644 index 000000000..5a025fcf5 --- /dev/null +++ b/arduino-ide-extension/src/browser/serial/serial-service-client-impl.ts @@ -0,0 +1,48 @@ +import { injectable } from 'inversify'; +import { Emitter } from '@theia/core/lib/common/event'; +import { + SerialServiceClient, + SerialError, + SerialConfig, +} from '../../common/protocol/serial-service'; +import { SerialModel } from './serial-model'; + +@injectable() +export class SerialServiceClientImpl implements SerialServiceClient { + protected readonly onErrorEmitter = new Emitter(); + readonly onError = this.onErrorEmitter.event; + + protected readonly onWebSocketChangedEmitter = new Emitter(); + readonly onWebSocketChanged = this.onWebSocketChangedEmitter.event; + + protected readonly onBaudRateChangedEmitter = + new Emitter(); + readonly onBaudRateChanged = this.onBaudRateChangedEmitter.event; + + protected readonly onLineEndingChangedEmitter = + new Emitter(); + readonly onLineEndingChanged = this.onLineEndingChangedEmitter.event; + + protected readonly onInterpolateChangedEmitter = new Emitter(); + readonly onInterpolateChanged = this.onInterpolateChangedEmitter.event; + + notifyError(error: SerialError): void { + this.onErrorEmitter.fire(error); + } + + notifyWebSocketChanged(message: number): void { + this.onWebSocketChangedEmitter.fire(message); + } + + notifyBaudRateChanged(message: SerialConfig.BaudRate): void { + this.onBaudRateChangedEmitter.fire(message); + } + + notifyLineEndingChanged(message: SerialModel.EOL): void { + this.onLineEndingChangedEmitter.fire(message); + } + + notifyInterpolateChanged(message: boolean): void { + this.onInterpolateChangedEmitter.fire(message); + } +} diff --git a/arduino-ide-extension/src/common/protocol/core-service.ts b/arduino-ide-extension/src/common/protocol/core-service.ts index 1454ffbd2..7e252bc2a 100644 --- a/arduino-ide-extension/src/common/protocol/core-service.ts +++ b/arduino-ide-extension/src/common/protocol/core-service.ts @@ -22,6 +22,7 @@ export interface CoreService { upload(options: CoreService.Upload.Options): Promise; uploadUsingProgrammer(options: CoreService.Upload.Options): Promise; burnBootloader(options: CoreService.Bootloader.Options): Promise; + isUploading(): Promise; } export namespace CoreService { diff --git a/arduino-ide-extension/src/common/protocol/index.ts b/arduino-ide-extension/src/common/protocol/index.ts index de17ea88b..101905752 100644 --- a/arduino-ide-extension/src/common/protocol/index.ts +++ b/arduino-ide-extension/src/common/protocol/index.ts @@ -6,7 +6,7 @@ export * from './core-service'; export * from './filesystem-ext'; export * from './installable'; export * from './library-service'; -export * from './monitor-service'; +export * from './serial-service'; export * from './searchable'; export * from './sketches-service'; export * from './examples-service'; diff --git a/arduino-ide-extension/src/common/protocol/monitor-service.ts b/arduino-ide-extension/src/common/protocol/monitor-service.ts deleted file mode 100644 index 01c8e1a95..000000000 --- a/arduino-ide-extension/src/common/protocol/monitor-service.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { JsonRpcServer } from '@theia/core/lib/common/messaging/proxy-factory'; -import { Board, Port } from './boards-service'; -import { Event } from '@theia/core/lib/common/event'; - -export interface Status {} -export type OK = Status; -export interface ErrorStatus extends Status { - readonly message: string; -} -export namespace Status { - export function isOK(status: Status & { message?: string }): status is OK { - return typeof status.message !== 'string'; - } - export const OK: OK = {}; - export const NOT_CONNECTED: ErrorStatus = { message: 'Not connected.' }; - export const ALREADY_CONNECTED: ErrorStatus = { - message: 'Already connected.', - }; -} - -export const MonitorServicePath = '/services/serial-monitor'; -export const MonitorService = Symbol('MonitorService'); -export interface MonitorService extends JsonRpcServer { - connect(config: MonitorConfig): Promise; - disconnect(): Promise; - send(message: string): Promise; -} - -export interface MonitorConfig { - readonly board: Board; - readonly port: Port; - /** - * Defaults to [`SERIAL`](MonitorConfig#ConnectionType#SERIAL). - */ - readonly type?: MonitorConfig.ConnectionType; - /** - * Defaults to `9600`. - */ - readonly baudRate?: MonitorConfig.BaudRate; -} -export namespace MonitorConfig { - export type BaudRate = - | 300 - | 1200 - | 2400 - | 4800 - | 9600 - | 19200 - | 38400 - | 57600 - | 115200; - export namespace BaudRate { - export const DEFAULT: BaudRate = 9600; - } - - export enum ConnectionType { - SERIAL = 0, - } -} - -export const MonitorServiceClient = Symbol('MonitorServiceClient'); -export interface MonitorServiceClient { - onError: Event; - onMessage: Event; - notifyError(event: MonitorError): void; - notifyMessage(message: string): void; -} - -export interface MonitorError { - readonly message: string; - /** - * If no `code` is available, clients must reestablish the serial-monitor connection. - */ - readonly code: number | undefined; - readonly config: MonitorConfig; -} -export namespace MonitorError { - export namespace ErrorCodes { - /** - * The frontend has refreshed the browser, for instance. - */ - export const CLIENT_CANCEL = 1; - /** - * When detaching a physical device when the duplex channel is still opened. - */ - export const DEVICE_NOT_CONFIGURED = 2; - /** - * Another serial monitor was opened on this port. For another electron-instance, Java IDE. - */ - export const DEVICE_BUSY = 3; - } -} diff --git a/arduino-ide-extension/src/common/protocol/serial-service.ts b/arduino-ide-extension/src/common/protocol/serial-service.ts new file mode 100644 index 000000000..0aa2793fa --- /dev/null +++ b/arduino-ide-extension/src/common/protocol/serial-service.ts @@ -0,0 +1,95 @@ +import { JsonRpcServer } from '@theia/core/lib/common/messaging/proxy-factory'; +import { Board, Port } from './boards-service'; +import { Event } from '@theia/core/lib/common/event'; +import { SerialPlotter } from '../../browser/serial/plotter/protocol'; +import { SerialModel } from '../../browser/serial/serial-model'; + +export interface Status {} +export type OK = Status; +export interface ErrorStatus extends Status { + readonly message: string; +} +export namespace Status { + export function isOK(status: Status & { message?: string }): status is OK { + return !!status && typeof status.message !== 'string'; + } + export const OK: OK = {}; + export const NOT_CONNECTED: ErrorStatus = { message: 'Not connected.' }; + export const ALREADY_CONNECTED: ErrorStatus = { + message: 'Already connected.', + }; +} + +export const SerialServicePath = '/services/serial'; +export const SerialService = Symbol('SerialService'); +export interface SerialService extends JsonRpcServer { + connect(config: SerialConfig): Promise; + disconnect(): Promise; + sendMessageToSerial(message: string): Promise; + updateWsConfigParam(config: Partial): Promise; +} + +export interface SerialConfig { + readonly board: Board; + readonly port: Port; + /** + * Defaults to [`SERIAL`](MonitorConfig#ConnectionType#SERIAL). + */ + readonly type?: SerialConfig.ConnectionType; + /** + * Defaults to `9600`. + */ + readonly baudRate?: SerialConfig.BaudRate; +} +export namespace SerialConfig { + export const BaudRates = [ + 300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, + ] as const; + export type BaudRate = typeof SerialConfig.BaudRates[number]; + export namespace BaudRate { + export const DEFAULT: BaudRate = 9600; + } + + export enum ConnectionType { + SERIAL = 0, + } +} + +export const SerialServiceClient = Symbol('SerialServiceClient'); +export interface SerialServiceClient { + onError: Event; + onWebSocketChanged: Event; + onLineEndingChanged: Event; + onBaudRateChanged: Event; + onInterpolateChanged: Event; + notifyError(event: SerialError): void; + notifyWebSocketChanged(message: number): void; + notifyLineEndingChanged(message: SerialModel.EOL): void; + notifyBaudRateChanged(message: SerialConfig.BaudRate): void; + notifyInterpolateChanged(message: boolean): void; +} + +export interface SerialError { + readonly message: string; + /** + * If no `code` is available, clients must reestablish the serial connection. + */ + readonly code: number | undefined; + readonly config: SerialConfig; +} +export namespace SerialError { + export namespace ErrorCodes { + /** + * The frontend has refreshed the browser, for instance. + */ + export const CLIENT_CANCEL = 1; + /** + * When detaching a physical device when the duplex channel is still opened. + */ + export const DEVICE_NOT_CONFIGURED = 2; + /** + * Another serial connection was opened on this port. For another electron-instance, Java IDE. + */ + export const DEVICE_BUSY = 3; + } +} 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 81e21b03b..80c531da0 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 @@ -18,6 +18,8 @@ import { } from '@theia/core/lib/electron-main/electron-main-application'; import { SplashServiceImpl } from '../splash/splash-service-impl'; +app.commandLine.appendSwitch('disable-http-cache'); + @injectable() export class ElectronMainApplication extends TheiaElectronMainApplication { protected _windows: BrowserWindow[] = []; @@ -88,6 +90,35 @@ export class ElectronMainApplication extends TheiaElectronMainApplication { this.splashService.onCloseRequested ); } + + electronWindow.webContents.on( + 'new-window', + (event, url, frameName, disposition, options, additionalFeatures) => { + if (frameName === 'serialPlotter') { + event.preventDefault(); + Object.assign(options, { + width: 800, + minWidth: 620, + height: 500, + minHeight: 320, + x: 100, + y: 100, + webPreferences: { + devTools: true, + nativeWindowOpen: true, + openerId: electronWindow?.webContents.id, + }, + }); + event.newGuest = new BrowserWindow(options); + event.newGuest.setMenu(null); + event.newGuest?.on('closed', (e: any) => { + electronWindow?.webContents.send('CLOSE_CHILD_WINDOW'); + }); + event.newGuest?.loadURL(url); + } + } + ); + this._windows.push(electronWindow); electronWindow.on('closed', () => { if (electronWindow) { diff --git a/arduino-ide-extension/src/node/arduino-ide-backend-module.ts b/arduino-ide-extension/src/node/arduino-ide-backend-module.ts index 642388bbc..a75dc6d78 100644 --- a/arduino-ide-extension/src/node/arduino-ide-backend-module.ts +++ b/arduino-ide-extension/src/node/arduino-ide-backend-module.ts @@ -40,13 +40,16 @@ import { ArduinoDaemon, ArduinoDaemonPath, } from '../common/protocol/arduino-daemon'; -import { MonitorServiceImpl } from './monitor/monitor-service-impl'; import { - MonitorService, - MonitorServicePath, - MonitorServiceClient, -} from '../common/protocol/monitor-service'; -import { MonitorClientProvider } from './monitor/monitor-client-provider'; + SerialServiceImpl, + SerialServiceName, +} from './serial/serial-service-impl'; +import { + SerialService, + SerialServicePath, + SerialServiceClient, +} from '../common/protocol/serial-service'; +import { MonitorClientProvider } from './serial/monitor-client-provider'; import { ConfigServiceImpl } from './config-service-impl'; import { EnvVariablesServer as TheiaEnvVariablesServer } from '@theia/core/lib/common/env-variables'; import { EnvVariablesServer } from './theia/env-variables/env-variables-server'; @@ -86,6 +89,9 @@ import { AuthenticationServicePath, } from '../common/protocol/authentication-service'; import { ArduinoFirmwareUploaderImpl } from './arduino-firmware-uploader-impl'; +import { PlotterBackendContribution } from './plotter/plotter-backend-contribution'; +import WebSocketServiceImpl from './web-socket/web-socket-service-impl'; +import { WebSocketService } from './web-socket/web-socket-service'; export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(BackendApplication).toSelf().inSingletonScope(); @@ -169,6 +175,9 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { }) ); + // Shared WebSocketService for the backend. This will manage all websocket conenctions + bind(WebSocketService).to(WebSocketServiceImpl).inSingletonScope(); + // Shared Arduino core client provider service for the backend. bind(CoreClientProvider).toSelf().inSingletonScope(); @@ -198,11 +207,11 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(ConnectionContainerModule).toConstantValue( ConnectionContainerModule.create(({ bind, bindBackendService }) => { bind(MonitorClientProvider).toSelf().inSingletonScope(); - bind(MonitorServiceImpl).toSelf().inSingletonScope(); - bind(MonitorService).toService(MonitorServiceImpl); - bindBackendService( - MonitorServicePath, - MonitorService, + bind(SerialServiceImpl).toSelf().inSingletonScope(); + bind(SerialService).toService(SerialServiceImpl); + bindBackendService( + SerialServicePath, + SerialService, (service, client) => { service.setClient(client); client.onDidCloseConnection(() => service.dispose()); @@ -299,14 +308,14 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { .inSingletonScope() .whenTargetNamed('config'); - // Logger for the monitor service. + // Logger for the serial service. bind(ILogger) .toDynamicValue((ctx) => { const parentLogger = ctx.container.get(ILogger); - return parentLogger.child('monitor-service'); + return parentLogger.child(SerialServiceName); }) .inSingletonScope() - .whenTargetNamed('monitor-service'); + .whenTargetNamed(SerialServiceName); bind(DefaultGitInit).toSelf(); rebind(GitInit).toService(DefaultGitInit); @@ -331,4 +340,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { ) ) .inSingletonScope(); + + bind(PlotterBackendContribution).toSelf().inSingletonScope(); + bind(BackendApplicationContribution).toService(PlotterBackendContribution); }); diff --git a/arduino-ide-extension/src/node/core-service-impl.ts b/arduino-ide-extension/src/node/core-service-impl.ts index e1a76eea1..f5ebe270c 100644 --- a/arduino-ide-extension/src/node/core-service-impl.ts +++ b/arduino-ide-extension/src/node/core-service-impl.ts @@ -32,6 +32,8 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { @inject(NotificationServiceServer) protected readonly notificationService: NotificationServiceServer; + protected uploading = false; + async compile( options: CoreService.Compile.Options & { exportBinaries?: boolean; @@ -110,6 +112,10 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { ); } + isUploading(): Promise { + return Promise.resolve(this.uploading); + } + protected async doUpload( options: CoreService.Upload.Options, requestProvider: () => UploadRequest | UploadUsingProgrammerRequest, @@ -120,6 +126,7 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { ) => ClientReadableStream, task = 'upload' ): Promise { + this.uploading = true; await this.compile(Object.assign(options, { exportBinaries: false })); const { sketchUri, fqbn, port, programmer } = options; const sketchPath = FileUri.fsPath(sketchUri); @@ -173,6 +180,8 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { severity: 'error', }); throw e; + } finally { + this.uploading = false; } } diff --git a/arduino-ide-extension/src/node/plotter/plotter-backend-contribution.ts b/arduino-ide-extension/src/node/plotter/plotter-backend-contribution.ts new file mode 100644 index 000000000..911303a64 --- /dev/null +++ b/arduino-ide-extension/src/node/plotter/plotter-backend-contribution.ts @@ -0,0 +1,29 @@ +import * as express from 'express'; +import { injectable } from 'inversify'; +import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application'; +import path = require('path'); + +@injectable() +export class PlotterBackendContribution + implements BackendApplicationContribution +{ + async initialize(): Promise {} + + configure(app: express.Application): void { + const relativePath = [ + '..', + '..', + '..', + 'build', + 'arduino-serial-plotter-webapp', + 'build', + ]; + app.use(express.static(path.join(__dirname, ...relativePath))); + app.get('/plotter', (req, res) => { + console.log( + `Serving serial plotter on http://${req.headers.host}${req.url}` + ); + res.sendFile(path.join(__dirname, ...relativePath, 'index.html')); + }); + } +} diff --git a/arduino-ide-extension/src/node/monitor/monitor-client-provider.ts b/arduino-ide-extension/src/node/serial/monitor-client-provider.ts similarity index 100% rename from arduino-ide-extension/src/node/monitor/monitor-client-provider.ts rename to arduino-ide-extension/src/node/serial/monitor-client-provider.ts diff --git a/arduino-ide-extension/src/node/monitor/monitor-service-impl.ts b/arduino-ide-extension/src/node/serial/serial-service-impl.ts similarity index 51% rename from arduino-ide-extension/src/node/monitor/monitor-service-impl.ts rename to arduino-ide-extension/src/node/serial/serial-service-impl.ts index c00404fe2..c60bca36a 100644 --- a/arduino-ide-extension/src/node/monitor/monitor-service-impl.ts +++ b/arduino-ide-extension/src/node/serial/serial-service-impl.ts @@ -2,15 +2,14 @@ import { ClientDuplexStream } from '@grpc/grpc-js'; import { TextEncoder } from 'util'; import { injectable, inject, named } from 'inversify'; import { Struct } from 'google-protobuf/google/protobuf/struct_pb'; -import { Emitter } from '@theia/core/lib/common/event'; import { ILogger } from '@theia/core/lib/common/logger'; import { - MonitorService, - MonitorServiceClient, - MonitorConfig, - MonitorError, + SerialService, + SerialServiceClient, + SerialConfig, + SerialError, Status, -} from '../../common/protocol/monitor-service'; +} from '../../common/protocol/serial-service'; import { StreamingOpenRequest, StreamingOpenResponse, @@ -18,16 +17,20 @@ import { } from '../cli-protocol/cc/arduino/cli/monitor/v1/monitor_pb'; import { MonitorClientProvider } from './monitor-client-provider'; import { Board, Port } from '../../common/protocol/boards-service'; -import * as WebSocket from 'ws'; +import { WebSocketService } from '../web-socket/web-socket-service'; +import { SerialPlotter } from '../../browser/serial/plotter/protocol'; +import { Disposable } from '@theia/core/shared/vscode-languageserver-protocol'; + +export const SerialServiceName = 'serial-service'; interface ErrorWithCode extends Error { readonly code: number; } namespace ErrorWithCode { - export function toMonitorError( + export function toSerialError( error: Error, - config: MonitorConfig - ): MonitorError { + config: SerialConfig + ): SerialError { const { message } = error; let code = undefined; if (is(error)) { @@ -35,15 +38,15 @@ namespace ErrorWithCode { const mapping = new Map(); mapping.set( '1 CANCELLED: Cancelled on client', - MonitorError.ErrorCodes.CLIENT_CANCEL + SerialError.ErrorCodes.CLIENT_CANCEL ); mapping.set( '2 UNKNOWN: device not configured', - MonitorError.ErrorCodes.DEVICE_NOT_CONFIGURED + SerialError.ErrorCodes.DEVICE_NOT_CONFIGURED ); mapping.set( - '2 UNKNOWN: error opening serial monitor: Serial port busy', - MonitorError.ErrorCodes.DEVICE_BUSY + '2 UNKNOWN: error opening serial connection: Serial port busy', + SerialError.ErrorCodes.DEVICE_BUSY ); code = mapping.get(message); } @@ -59,45 +62,59 @@ namespace ErrorWithCode { } @injectable() -export class MonitorServiceImpl implements MonitorService { +export class SerialServiceImpl implements SerialService { + @named(SerialServiceName) @inject(ILogger) - @named('monitor-service') protected readonly logger: ILogger; @inject(MonitorClientProvider) - protected readonly monitorClientProvider: MonitorClientProvider; + protected readonly serialClientProvider: MonitorClientProvider; + + @inject(WebSocketService) + protected readonly webSocketService: WebSocketService; - protected client?: MonitorServiceClient; - protected connection?: { + protected client?: SerialServiceClient; + protected serialConnection?: { duplex: ClientDuplexStream; - config: MonitorConfig; + config: SerialConfig; }; protected messages: string[] = []; - protected onMessageDidReadEmitter = new Emitter(); + protected onMessageReceived: Disposable | null; + protected flushMessagesInterval: NodeJS.Timeout | null; - setClient(client: MonitorServiceClient | undefined): void { + setClient(client: SerialServiceClient | undefined): void { this.client = client; } dispose(): void { - this.logger.info('>>> Disposing monitor service...'); - if (this.connection) { + this.logger.info('>>> Disposing serial service...'); + if (this.serialConnection) { this.disconnect(); } - this.logger.info('<<< Disposed monitor service.'); + this.logger.info('<<< Disposed serial service.'); this.client = undefined; } - async connect(config: MonitorConfig): Promise { + async updateWsConfigParam( + config: Partial + ): Promise { + const msg: SerialPlotter.Protocol.Message = { + command: SerialPlotter.Protocol.Command.MIDDLEWARE_CONFIG_CHANGED, + data: config, + }; + this.webSocketService.sendMessage(JSON.stringify(msg)); + } + + async connect(config: SerialConfig): Promise { this.logger.info( - `>>> Creating serial monitor connection for ${Board.toString( + `>>> Creating serial connection for ${Board.toString( config.board )} on port ${Port.toString(config.port)}...` ); - if (this.connection) { + if (this.serialConnection) { return Status.ALREADY_CONNECTED; } - const client = await this.monitorClientProvider.client(); + const client = await this.serialClientProvider.client(); if (!client) { return Status.NOT_CONNECTED; } @@ -105,17 +122,17 @@ export class MonitorServiceImpl implements MonitorService { return { message: client.message }; } const duplex = client.streamingOpen(); - this.connection = { duplex, config }; + this.serialConnection = { duplex, config }; duplex.on( 'error', ((error: Error) => { - const monitorError = ErrorWithCode.toMonitorError(error, config); - this.disconnect(monitorError).then(() => { + const serialError = ErrorWithCode.toSerialError(error, config); + this.disconnect(serialError).then(() => { if (this.client) { - this.client.notifyError(monitorError); + this.client.notifyError(serialError); } - if (monitorError.code === undefined) { + if (serialError.code === undefined) { // Log the original, unexpected error. this.logger.error(error); } @@ -123,23 +140,50 @@ export class MonitorServiceImpl implements MonitorService { }).bind(this) ); - const ws = new WebSocket.Server({ port: 0 }); - const address: any = ws.address(); - this.client?.notifyMessage(address.port); - let wsConn: WebSocket | null = null; - ws.on('connection', (ws) => { - wsConn = ws; - }); + this.client?.notifyWebSocketChanged( + this.webSocketService.getAddress().port + ); const flushMessagesToFrontend = () => { if (this.messages.length) { - wsConn?.send(JSON.stringify(this.messages)); + this.webSocketService.sendMessage(JSON.stringify(this.messages)); this.messages = []; } }; - // empty the queue every 16ms (~60fps) - setInterval(flushMessagesToFrontend, 32); + this.onMessageReceived = this.webSocketService.onMessageReceived( + (msg: string) => { + try { + const message: SerialPlotter.Protocol.Message = JSON.parse(msg); + + switch (message.command) { + case SerialPlotter.Protocol.Command.PLOTTER_SEND_MESSAGE: + this.sendMessageToSerial(message.data); + break; + + case SerialPlotter.Protocol.Command.PLOTTER_SET_BAUDRATE: + this.client?.notifyBaudRateChanged( + parseInt(message.data, 10) as SerialConfig.BaudRate + ); + break; + + case SerialPlotter.Protocol.Command.PLOTTER_SET_LINE_ENDING: + this.client?.notifyLineEndingChanged(message.data); + break; + + case SerialPlotter.Protocol.Command.PLOTTER_SET_INTERPOLATE: + this.client?.notifyInterpolateChanged(message.data); + break; + + default: + break; + } + } catch (error) {} + } + ); + + // 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') => { @@ -188,13 +232,12 @@ export class MonitorServiceImpl implements MonitorService { req.setConfig(monitorConfig); return new Promise((resolve) => { - if (this.connection) { - this.connection.duplex.write(req, () => { + if (this.serialConnection) { + this.serialConnection.duplex.write(req, () => { this.logger.info( - `<<< Serial monitor connection created for ${Board.toString( - config.board, - { useFqbn: false } - )} on port ${Port.toString(config.port)}.` + `<<< Serial connection created for ${Board.toString(config.board, { + useFqbn: false, + })} on port ${Port.toString(config.port)}.` ); resolve(Status.OK); }); @@ -204,43 +247,52 @@ export class MonitorServiceImpl implements MonitorService { }); } - async disconnect(reason?: MonitorError): Promise { + async disconnect(reason?: SerialError): Promise { try { + if (this.onMessageReceived) { + this.onMessageReceived.dispose(); + this.onMessageReceived = null; + } + if (this.flushMessagesInterval) { + clearInterval(this.flushMessagesInterval); + this.flushMessagesInterval = null; + } + if ( - !this.connection && + !this.serialConnection && reason && - reason.code === MonitorError.ErrorCodes.CLIENT_CANCEL + reason.code === SerialError.ErrorCodes.CLIENT_CANCEL ) { return Status.OK; } - this.logger.info('>>> Disposing monitor connection...'); - if (!this.connection) { + this.logger.info('>>> Disposing serial connection...'); + if (!this.serialConnection) { this.logger.warn('<<< Not connected. Nothing to dispose.'); return Status.NOT_CONNECTED; } - const { duplex, config } = this.connection; + const { duplex, config } = this.serialConnection; duplex.cancel(); this.logger.info( - `<<< Disposed monitor connection for ${Board.toString(config.board, { + `<<< Disposed serial connection for ${Board.toString(config.board, { useFqbn: false, })} on port ${Port.toString(config.port)}.` ); - this.connection = undefined; + this.serialConnection = undefined; return Status.OK; } finally { this.messages.length = 0; } } - async send(message: string): Promise { - if (!this.connection) { + async sendMessageToSerial(message: string): Promise { + if (!this.serialConnection) { return Status.NOT_CONNECTED; } const req = new StreamingOpenRequest(); req.setData(new TextEncoder().encode(message)); return new Promise((resolve) => { - if (this.connection) { - this.connection.duplex.write(req, () => { + if (this.serialConnection) { + this.serialConnection.duplex.write(req, () => { resolve(Status.OK); }); return; @@ -250,10 +302,10 @@ export class MonitorServiceImpl implements MonitorService { } protected mapType( - type?: MonitorConfig.ConnectionType + type?: SerialConfig.ConnectionType ): GrpcMonitorConfig.TargetType { switch (type) { - case MonitorConfig.ConnectionType.SERIAL: + case SerialConfig.ConnectionType.SERIAL: return GrpcMonitorConfig.TargetType.TARGET_TYPE_SERIAL; default: return GrpcMonitorConfig.TargetType.TARGET_TYPE_SERIAL; 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 new file mode 100644 index 000000000..0f05759aa --- /dev/null +++ b/arduino-ide-extension/src/node/web-socket/web-socket-service-impl.ts @@ -0,0 +1,46 @@ +import { Emitter } from '@theia/core'; +import { injectable } from 'inversify'; +import * as WebSocket from 'ws'; +import { WebSocketService } from './web-socket-service'; + +@injectable() +export default class WebSocketServiceImpl implements WebSocketService { + protected wsClients: WebSocket[]; + protected server: WebSocket.Server; + + protected readonly onMessage = new Emitter(); + public readonly onMessageReceived = this.onMessage.event; + + constructor() { + this.wsClients = []; + this.server = new WebSocket.Server({ port: 0 }); + + const addClient = this.addClient.bind(this); + this.server.on('connection', addClient); + } + + private addClient(ws: WebSocket): void { + this.wsClients.push(ws); + ws.onclose = () => { + this.wsClients.splice(this.wsClients.indexOf(ws), 1); + }; + + ws.onmessage = (res) => { + this.onMessage.fire(res.data.toString()); + }; + } + + getAddress(): WebSocket.AddressInfo { + return this.server.address() as WebSocket.AddressInfo; + } + + sendMessage(message: string): void { + this.wsClients.forEach((w) => { + try { + w.send(message); + } catch { + w.close(); + } + }); + } +} 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 new file mode 100644 index 000000000..5d612560d --- /dev/null +++ b/arduino-ide-extension/src/node/web-socket/web-socket-service.ts @@ -0,0 +1,9 @@ +import { Event } from '@theia/core/lib/common/event'; +import * as WebSocket from 'ws'; + +export const WebSocketService = Symbol('WebSocketService'); +export interface WebSocketService { + getAddress(): WebSocket.AddressInfo; + sendMessage(message: string): void; + onMessageReceived: Event; +} diff --git a/arduino-ide-extension/src/test/browser/boards-auto-installer.test.ts b/arduino-ide-extension/src/test/browser/boards-auto-installer.test.ts index efd75e943..030ae539b 100644 --- a/arduino-ide-extension/src/test/browser/boards-auto-installer.test.ts +++ b/arduino-ide-extension/src/test/browser/boards-auto-installer.test.ts @@ -11,52 +11,19 @@ import { MessageService } from '@theia/core'; import { BoardsServiceProvider } from '../../browser/boards/boards-service-provider'; import { BoardsListWidgetFrontendContribution } from '../../browser/boards/boards-widget-frontend-contribution'; import { - Board, BoardsPackage, BoardsService, - Port, ResponseServiceArduino, } from '../../common/protocol'; import { IMock, It, Mock, Times } from 'typemoq'; import { Container, ContainerModule } from 'inversify'; import { BoardsAutoInstaller } from '../../browser/boards/boards-auto-installer'; -import { BoardsConfig } from '../../browser/boards/boards-config'; import { tick } from '../utils'; import { ListWidget } from '../../browser/widgets/component-list/list-widget'; +import { aBoardConfig, anInstalledPackage, aPackage } from './fixtures/boards'; disableJSDOM(); -const aBoard: Board = { - fqbn: 'some:board:fqbn', - name: 'Some Arduino Board', - port: { address: '/lol/port1234', protocol: 'serial' }, -}; -const aPort: Port = { - address: aBoard.port!.address, - protocol: aBoard.port!.protocol, -}; -const aBoardConfig: BoardsConfig.Config = { - selectedBoard: aBoard, - selectedPort: aPort, -}; -const aPackage: BoardsPackage = { - author: 'someAuthor', - availableVersions: ['some.ver.sion', 'some.other.version'], - boards: [aBoard], - deprecated: false, - description: 'Some Arduino Board, Some Other Arduino Board', - id: 'some:arduinoCoreId', - installable: true, - moreInfoLink: 'http://www.some-url.lol/', - name: 'Some Arduino Package', - summary: 'Boards included in this package:', -}; - -const anInstalledPackage: BoardsPackage = { - ...aPackage, - installedVersion: 'some.ver.sion', -}; - describe('BoardsAutoInstaller', () => { let subject: BoardsAutoInstaller; let messageService: IMock; diff --git a/arduino-ide-extension/src/test/browser/fixtures/boards.ts b/arduino-ide-extension/src/test/browser/fixtures/boards.ts new file mode 100644 index 000000000..a9783f026 --- /dev/null +++ b/arduino-ide-extension/src/test/browser/fixtures/boards.ts @@ -0,0 +1,47 @@ +import { BoardsConfig } from '../../../browser/boards/boards-config'; +import { Board, BoardsPackage, Port } from '../../../common/protocol'; + +export const aBoard: Board = { + fqbn: 'some:board:fqbn', + name: 'Some Arduino Board', + port: { address: '/lol/port1234', protocol: 'serial' }, +}; +export const aPort: Port = { + address: aBoard.port!.address, + protocol: aBoard.port!.protocol, +}; +export const aBoardConfig: BoardsConfig.Config = { + selectedBoard: aBoard, + selectedPort: aPort, +}; +export const anotherBoard: Board = { + fqbn: 'another:board:fqbn', + name: 'Another Arduino Board', + port: { address: '/kek/port5678', protocol: 'serial' }, +}; +export const anotherPort: Port = { + address: anotherBoard.port!.address, + protocol: anotherBoard.port!.protocol, +}; +export const anotherBoardConfig: BoardsConfig.Config = { + selectedBoard: anotherBoard, + selectedPort: anotherPort, +}; + +export const aPackage: BoardsPackage = { + author: 'someAuthor', + availableVersions: ['some.ver.sion', 'some.other.version'], + boards: [aBoard], + deprecated: false, + description: 'Some Arduino Board, Some Other Arduino Board', + id: 'some:arduinoCoreId', + installable: true, + moreInfoLink: 'http://www.some-url.lol/', + name: 'Some Arduino Package', + summary: 'Boards included in this package:', +}; + +export const anInstalledPackage: BoardsPackage = { + ...aPackage, + installedVersion: 'some.ver.sion', +}; diff --git a/arduino-ide-extension/src/test/browser/fixtures/serial.ts b/arduino-ide-extension/src/test/browser/fixtures/serial.ts new file mode 100644 index 000000000..ab8b333a6 --- /dev/null +++ b/arduino-ide-extension/src/test/browser/fixtures/serial.ts @@ -0,0 +1,22 @@ +import { SerialConfig } from '../../../common/protocol/serial-service'; +import { aBoard, anotherBoard, anotherPort, aPort } from './boards'; + +export const aSerialConfig: SerialConfig = { + board: aBoard, + port: aPort, + baudRate: 9600, +}; + +export const anotherSerialConfig: SerialConfig = { + board: anotherBoard, + port: anotherPort, + baudRate: 9600, +}; + +export class WebSocketMock { + readonly url: string; + constructor(url: string) { + this.url = url; + } + close() {} +} diff --git a/arduino-ide-extension/src/test/browser/monitor-utils.test.ts b/arduino-ide-extension/src/test/browser/monitor-utils.test.ts index f3056da48..cf1025740 100644 --- a/arduino-ide-extension/src/test/browser/monitor-utils.test.ts +++ b/arduino-ide-extension/src/test/browser/monitor-utils.test.ts @@ -2,8 +2,8 @@ import { expect } from 'chai'; import { messagesToLines, truncateLines, -} from '../../browser/monitor/monitor-utils'; -import { Line } from '../../browser/monitor/serial-monitor-send-output'; +} from '../../browser/serial/monitor/monitor-utils'; +import { Line } from '../../browser/serial/monitor/serial-monitor-send-output'; import { set, reset } from 'mockdate'; type TestLine = { 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 new file mode 100644 index 000000000..6d9a96730 --- /dev/null +++ b/arduino-ide-extension/src/test/browser/serial-connection-manager.test.ts @@ -0,0 +1,375 @@ +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/i18n/en.json b/i18n/en.json index bbd23fd7f..706d7b1be 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -179,7 +179,7 @@ "upload": "Upload", "uploadUsingProgrammer": "Upload Using Programmer", "doneUploading": "Done uploading.", - "couldNotConnectToMonitor": "Could not reconnect to serial monitor. {0}", + "couldNotConnectToSerial": "Could not reconnect to serial port. {0}", "verifyOrCompile": "Verify/Compile", "exportBinary": "Export Compiled Binary", "verify": "Verify", @@ -253,22 +253,21 @@ "dialog": { "dontAskAgain": "Don't ask again" }, - "monitor": { - "connectionBusy": "Connection failed. Serial port is busy: {0}", - "disconnected": "Disconnected {0} from {1}.", - "unexpectedError": "Unexpected error. Reconnecting {0} on port {1}.", - "failedReconnect": "Failed to reconnect {0} to the the serial-monitor after 10 consecutive attempts. The {1} serial port is busy.", - "reconnect": "Reconnecting {0} to {1} in {2] seconds...", + "serial": { "toggleTimestamp": "Toggle Timestamp", "autoscroll": "Autoscroll", "timestamp": "Timestamp", - "title": "Serial Monitor", "noLineEndings": "No Line Ending", "newLine": "New Line", "carriageReturn": "Carriage Return", "newLineCarriageReturn": "Both NL & CR", "notConnected": "Not connected. Select a board and a port to connect automatically.", - "message": "Message ({0} + Enter to send message to '{1}' on '{2}'" + "message": "Message ({0} + Enter to send message to '{1}' on '{2}'", + "connectionBusy": "Connection failed. Serial port is busy: {0}", + "disconnected": "Disconnected {0} from {1}.", + "unexpectedError": "Unexpected error. Reconnecting {0} on port {1}.", + "failedReconnect": "Failed to reconnect {0} to serial port after 10 consecutive attempts. The {1} serial port is busy.", + "reconnect": "Reconnecting {0} to {1} in {2] seconds..." }, "component": { "uninstall": "Uninstall", diff --git a/yarn.lock b/yarn.lock index ee92004da..dee18a725 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4109,6 +4109,11 @@ archive-type@^4.0.0: dependencies: file-type "^4.2.0" +arduino-serial-plotter-webapp@0.0.13: + version "0.0.13" + resolved "https://registry.yarnpkg.com/arduino-serial-plotter-webapp/-/arduino-serial-plotter-webapp-0.0.13.tgz#b8d943a39f2c218bca36bb81bb6c5cabe4695ad7" + integrity sha512-Rn1shl6c1pUt1vtcdsAzhHIlHuHAmC829z0nR4JW4mYdYA+1MEY2VbbhfDf/tXiAFm8XAGfH63f//h1t99eGWQ== + are-we-there-yet@~1.1.2: version "1.1.5" resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" @@ -11945,6 +11950,16 @@ query-string@^6.13.8: split-on-first "^1.0.0" strict-uri-encode "^2.0.0" +query-string@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.0.1.tgz#45bd149cf586aaa582dffc7ec7a8ad97dd02f75d" + integrity sha512-uIw3iRvHnk9to1blJCG3BTc+Ro56CBowJXKmNNAm3RulvPBzWLRqKSiiDk+IplJhsydwtuNMHi8UGQFcCLVfkA== + dependencies: + decode-uri-component "^0.2.0" + filter-obj "^1.1.0" + split-on-first "^1.0.0" + strict-uri-encode "^2.0.0" + querystring@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620"