diff --git a/arduino-ide-extension/package.json b/arduino-ide-extension/package.json index 59060eb68..93b9889cf 100644 --- a/arduino-ide-extension/package.json +++ b/arduino-ide-extension/package.json @@ -57,7 +57,7 @@ "@types/temp": "^0.8.34", "@types/which": "^1.3.1", "ajv": "^6.5.3", - "arduino-serial-plotter-webapp": "0.0.17", + "arduino-serial-plotter-webapp": "0.1.0", "async-mutex": "^0.3.0", "atob": "^2.1.2", "auth0-js": "^9.14.0", 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 b5efac038..9289218b5 100644 --- a/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-ide-frontend-module.ts @@ -68,20 +68,12 @@ 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 { SerialServiceClientImpl } from './serial/serial-service-client-impl'; -import { - SerialServicePath, - SerialService, - SerialServiceClient, -} from '../common/protocol/serial-service'; import { ConfigService, ConfigServicePath, } from '../common/protocol/config-service'; 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'; @@ -158,7 +150,14 @@ import { OutputChannelRegistryMainImpl as TheiaOutputChannelRegistryMainImpl, OutputChannelRegistryMainImpl, } from './theia/plugin-ext/output-channel-registry-main'; -import { ExecutableService, ExecutableServicePath } from '../common/protocol'; +import { + ExecutableService, + ExecutableServicePath, + MonitorManagerProxy, + MonitorManagerProxyClient, + MonitorManagerProxyFactory, + MonitorManagerProxyPath, +} from '../common/protocol'; import { MonacoTextModelService as TheiaMonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service'; import { MonacoTextModelService } from './theia/monaco/monaco-text-model-service'; import { ResponseServiceImpl } from './response-service-impl'; @@ -273,6 +272,8 @@ import { IDEUpdaterDialogWidget, } from './dialogs/ide-updater/ide-updater-dialog'; import { ElectronIpcConnectionProvider } from '@theia/core/lib/electron-browser/messaging/electron-ipc-connection-provider'; +import { MonitorModel } from './monitor-model'; +import { MonitorManagerProxyClientImpl } from './monitor-manager-proxy-client-impl'; import { EditorManager as TheiaEditorManager } from '@theia/editor/lib/browser/editor-manager'; import { EditorManager } from './theia/editor/editor-manager'; import { HostedPluginEvents } from './hosted-plugin-events'; @@ -424,29 +425,44 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { .inSingletonScope(); // Serial monitor - bind(SerialModel).toSelf().inSingletonScope(); - bind(FrontendApplicationContribution).toService(SerialModel); bind(MonitorWidget).toSelf(); + bind(FrontendApplicationContribution).toService(MonitorModel); + bind(MonitorModel).toSelf().inSingletonScope(); bindViewContribution(bind, MonitorViewContribution); bind(TabBarToolbarContribution).toService(MonitorViewContribution); bind(WidgetFactory).toDynamicValue((context) => ({ id: MonitorWidget.ID, - createWidget: () => context.container.get(MonitorWidget), - })); - // Frontend binding for the serial service - bind(SerialService) - .toDynamicValue((context) => { - const connection = context.container.get(WebSocketConnectionProvider); - const client = context.container.get( - SerialServiceClient + createWidget: () => { + return new MonitorWidget( + context.container.get(MonitorModel), + context.container.get( + MonitorManagerProxyClient + ), + context.container.get(BoardsServiceProvider) ); - return connection.createProxy(SerialServicePath, client); - }) + }, + })); + + bind(MonitorManagerProxyFactory).toFactory( + (context) => () => + context.container.get(MonitorManagerProxy) + ); + + bind(MonitorManagerProxy) + .toDynamicValue((context) => + WebSocketConnectionProvider.createProxy( + context.container, + MonitorManagerProxyPath, + context.container.get(MonitorManagerProxyClient) + ) + ) .inSingletonScope(); - bind(SerialConnectionManager).toSelf().inSingletonScope(); - // Serial service client to receive and delegate notifications from the backend. - bind(SerialServiceClient).to(SerialServiceClientImpl).inSingletonScope(); + // Monitor manager proxy client to receive and delegate pluggable monitors + // notifications from the backend + bind(MonitorManagerProxyClient) + .to(MonitorManagerProxyClientImpl) + .inSingletonScope(); bind(WorkspaceService).toSelf().inSingletonScope(); rebind(TheiaWorkspaceService).toService(WorkspaceService); @@ -502,11 +518,12 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { .inSingletonScope(); rebind(TheiaEditorWidgetFactory).to(EditorWidgetFactory).inSingletonScope(); rebind(TabBarToolbarFactory).toFactory( - ({ container: parentContainer }) => () => { - const container = parentContainer.createChild(); - container.bind(TabBarToolbar).toSelf().inSingletonScope(); - return container.get(TabBarToolbar); - } + ({ container: parentContainer }) => + () => { + const container = parentContainer.createChild(); + container.bind(TabBarToolbar).toSelf().inSingletonScope(); + return container.get(TabBarToolbar); + } ); bind(OutputWidget).toSelf().inSingletonScope(); rebind(TheiaOutputWidget).toService(OutputWidget); @@ -523,7 +540,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(SearchInWorkspaceWidget).toSelf(); rebind(TheiaSearchInWorkspaceWidget).toService(SearchInWorkspaceWidget); - + rebind(TheiaEditorManager).to(EditorManager); // replace search icon @@ -560,9 +577,9 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(ProblemManager).toSelf().inSingletonScope(); rebind(TheiaProblemManager).toService(ProblemManager); - // Customized layout restorer that can restore the state in async way: https://github.com/eclipse-theia/theia/issues/6579 - bind(ShellLayoutRestorer).toSelf().inSingletonScope(); - rebind(TheiaShellLayoutRestorer).toService(ShellLayoutRestorer); + // Customized layout restorer that can restore the state in async way: https://github.com/eclipse-theia/theia/issues/6579 + bind(ShellLayoutRestorer).toSelf().inSingletonScope(); + rebind(TheiaShellLayoutRestorer).toService(ShellLayoutRestorer); // No dropdown for the _Output_ view. bind(OutputToolbarContribution).toSelf().inSingletonScope(); @@ -687,15 +704,13 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { // Enable the dirty indicator on uncloseable widgets. rebind(TabBarRendererFactory).toFactory((context) => () => { - const contextMenuRenderer = context.container.get( - ContextMenuRenderer - ); + const contextMenuRenderer = + context.container.get(ContextMenuRenderer); const decoratorService = context.container.get( TabBarDecoratorService ); - const iconThemeService = context.container.get( - IconThemeService - ); + const iconThemeService = + context.container.get(IconThemeService); return new TabBarRenderer( contextMenuRenderer, decoratorService, diff --git a/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts b/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts index 1acda7d15..17cf91f30 100644 --- a/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts +++ b/arduino-ide-extension/src/browser/contributions/burn-bootloader.ts @@ -3,7 +3,6 @@ import { OutputChannelManager } from '@theia/output/lib/browser/output-channel'; import { CoreService } from '../../common/protocol'; import { ArduinoMenus } from '../menu/arduino-menus'; import { BoardsDataStore } from '../boards/boards-data-store'; -import { SerialConnectionManager } from '../serial/serial-connection-manager'; import { BoardsServiceProvider } from '../boards/boards-service-provider'; import { SketchContribution, @@ -18,8 +17,6 @@ export class BurnBootloader extends SketchContribution { @inject(CoreService) protected readonly coreService: CoreService; - @inject(SerialConnectionManager) - protected readonly serialConnection: SerialConnectionManager; @inject(BoardsDataStore) protected readonly boardsDataStore: BoardsDataStore; @@ -60,9 +57,15 @@ export class BurnBootloader extends SketchContribution { this.preferences.get('arduino.upload.verify'), this.preferences.get('arduino.upload.verbose'), ]); + + const board = { + ...boardsConfig.selectedBoard, + name: boardsConfig.selectedBoard?.name || '', + fqbn, + } this.outputChannelManager.getChannel('Arduino').clear(); await this.coreService.burnBootloader({ - fqbn, + board, programmer, port, verify, @@ -85,8 +88,6 @@ export class BurnBootloader extends SketchContribution { errorMessage = e.toString(); } this.messageService.error(errorMessage); - } finally { - await this.serialConnection.reconnectAfterUpload(); } } } diff --git a/arduino-ide-extension/src/browser/contributions/upload-sketch.ts b/arduino-ide-extension/src/browser/contributions/upload-sketch.ts index f3478b1ea..76a8f4973 100644 --- a/arduino-ide-extension/src/browser/contributions/upload-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/upload-sketch.ts @@ -4,7 +4,6 @@ import { BoardUserField, CoreService } from '../../common/protocol'; import { ArduinoMenus, PlaceholderMenuNode } from '../menu/arduino-menus'; import { ArduinoToolbar } from '../toolbar/arduino-toolbar'; import { BoardsDataStore } from '../boards/boards-data-store'; -import { SerialConnectionManager } from '../serial/serial-connection-manager'; import { BoardsServiceProvider } from '../boards/boards-service-provider'; import { SketchContribution, @@ -23,9 +22,6 @@ export class UploadSketch extends SketchContribution { @inject(CoreService) protected readonly coreService: CoreService; - @inject(SerialConnectionManager) - protected readonly serialConnection: SerialConnectionManager; - @inject(MenuModelRegistry) protected readonly menuRegistry: MenuModelRegistry; @@ -227,6 +223,11 @@ export class UploadSketch extends SketchContribution { this.sourceOverride(), ]); + const board = { + ...boardsConfig.selectedBoard, + name: boardsConfig.selectedBoard?.name || '', + fqbn, + } let options: CoreService.Upload.Options | undefined = undefined; const sketchUri = sketch.uri; const optimizeForDebug = this.editorMode.compileForDebug; @@ -248,7 +249,7 @@ export class UploadSketch extends SketchContribution { const programmer = selectedProgrammer; options = { sketchUri, - fqbn, + board, optimizeForDebug, programmer, port, @@ -260,7 +261,7 @@ export class UploadSketch extends SketchContribution { } else { options = { sketchUri, - fqbn, + board, optimizeForDebug, port, verbose, @@ -290,8 +291,6 @@ export class UploadSketch extends SketchContribution { } finally { this.uploadInProgress = false; this.onDidChangeEmitter.fire(); - - setTimeout(() => this.serialConnection.reconnectAfterUpload(), 5000); } } } diff --git a/arduino-ide-extension/src/browser/contributions/verify-sketch.ts b/arduino-ide-extension/src/browser/contributions/verify-sketch.ts index b22f5998a..fdc5b504f 100644 --- a/arduino-ide-extension/src/browser/contributions/verify-sketch.ts +++ b/arduino-ide-extension/src/browser/contributions/verify-sketch.ts @@ -111,12 +111,17 @@ export class VerifySketch extends SketchContribution { ), this.sourceOverride(), ]); + const board = { + ...boardsConfig.selectedBoard, + name: boardsConfig.selectedBoard?.name || '', + fqbn, + } const verbose = this.preferences.get('arduino.compile.verbose'); const compilerWarnings = this.preferences.get('arduino.compile.warnings'); this.outputChannelManager.getChannel('Arduino').clear(); await this.coreService.compile({ sketchUri: sketch.uri, - fqbn, + board, optimizeForDebug: this.editorMode.compileForDebug, verbose, exportBinaries, diff --git a/arduino-ide-extension/src/browser/dialogs/firmware-uploader/firmware-uploader-component.tsx b/arduino-ide-extension/src/browser/dialogs/firmware-uploader/firmware-uploader-component.tsx index c42a3ef39..8dd12e681 100644 --- a/arduino-ide-extension/src/browser/dialogs/firmware-uploader/firmware-uploader-component.tsx +++ b/arduino-ide-extension/src/browser/dialogs/firmware-uploader/firmware-uploader-component.tsx @@ -1,5 +1,6 @@ import { nls } from '@theia/core/lib/common'; import * as React from '@theia/core/shared/react'; +import { Port } from '../../../common/protocol'; import { ArduinoFirmwareUploader, FirmwareInfo, @@ -20,7 +21,7 @@ export const FirmwareUploaderComponent = ({ availableBoards: AvailableBoard[]; firmwareUploader: ArduinoFirmwareUploader; updatableFqbns: string[]; - flashFirmware: (firmware: FirmwareInfo, port: string) => Promise; + flashFirmware: (firmware: FirmwareInfo, port: Port) => Promise; isOpen: any; }): React.ReactElement => { // boolean states for buttons @@ -81,7 +82,7 @@ export const FirmwareUploaderComponent = ({ const installStatus = !!firmwareToFlash && !!selectedBoard?.port && - (await flashFirmware(firmwareToFlash, selectedBoard?.port.address)); + (await flashFirmware(firmwareToFlash, selectedBoard?.port)); setInstallFeedback((installStatus && 'ok') || 'fail'); } catch { diff --git a/arduino-ide-extension/src/browser/dialogs/firmware-uploader/firmware-uploader-dialog.tsx b/arduino-ide-extension/src/browser/dialogs/firmware-uploader/firmware-uploader-dialog.tsx index dd966422e..ff65b71a9 100644 --- a/arduino-ide-extension/src/browser/dialogs/firmware-uploader/firmware-uploader-dialog.tsx +++ b/arduino-ide-extension/src/browser/dialogs/firmware-uploader/firmware-uploader-dialog.tsx @@ -1,5 +1,9 @@ import * as React from '@theia/core/shared/react'; -import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import { + inject, + injectable, + postConstruct, +} from '@theia/core/shared/inversify'; import { DialogProps } from '@theia/core/lib/browser/dialogs'; import { AbstractDialog } from '../../theia/dialogs/dialogs'; import { Widget } from '@theia/core/shared/@phosphor/widgets'; @@ -15,6 +19,7 @@ import { } from '../../../common/protocol/arduino-firmware-uploader'; import { FirmwareUploaderComponent } from './firmware-uploader-component'; import { UploadFirmware } from '../../contributions/upload-firmware'; +import { Port } from '../../../common/protocol'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; @injectable() @@ -54,7 +59,7 @@ export class UploadFirmwareDialogWidget extends ReactWidget { }); } - protected flashFirmware(firmware: FirmwareInfo, port: string): Promise { + protected flashFirmware(firmware: FirmwareInfo, port: Port): Promise { this.busyCallback(true); return this.arduinoFirmwareUploader .flash(firmware, port) diff --git a/arduino-ide-extension/src/browser/monitor-manager-proxy-client-impl.ts b/arduino-ide-extension/src/browser/monitor-manager-proxy-client-impl.ts new file mode 100644 index 000000000..5519057aa --- /dev/null +++ b/arduino-ide-extension/src/browser/monitor-manager-proxy-client-impl.ts @@ -0,0 +1,199 @@ +import { + CommandRegistry, + Disposable, + Emitter, + MessageService, + nls, +} from '@theia/core'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { Board, Port } from '../common/protocol'; +import { + Monitor, + MonitorManagerProxyClient, + MonitorManagerProxyFactory, +} from '../common/protocol/monitor-service'; +import { + PluggableMonitorSettings, + MonitorSettings, +} from '../node/monitor-settings/monitor-settings-provider'; +import { BoardsConfig } from './boards/boards-config'; +import { BoardsServiceProvider } from './boards/boards-service-provider'; + +@injectable() +export class MonitorManagerProxyClientImpl + implements MonitorManagerProxyClient +{ + // When pluggable monitor messages are received from the backend + // this event is triggered. + // Ideally a frontend component is connected to this event + // to update the UI. + protected readonly onMessagesReceivedEmitter = new Emitter<{ + messages: string[]; + }>(); + readonly onMessagesReceived = this.onMessagesReceivedEmitter.event; + + protected readonly onMonitorSettingsDidChangeEmitter = + new Emitter(); + readonly onMonitorSettingsDidChange = + this.onMonitorSettingsDidChangeEmitter.event; + + protected readonly onMonitorShouldResetEmitter = new Emitter(); + readonly onMonitorShouldReset = this.onMonitorShouldResetEmitter.event; + + // WebSocket used to handle pluggable monitor communication between + // frontend and backend. + private webSocket?: WebSocket; + private wsPort?: number; + private lastConnectedBoard: BoardsConfig.Config; + private onBoardsConfigChanged: Disposable | undefined; + + getWebSocketPort(): number | undefined { + return this.wsPort; + } + + constructor( + @inject(MessageService) + protected messageService: MessageService, + + // This is necessary to call the backend methods from the frontend + @inject(MonitorManagerProxyFactory) + protected server: MonitorManagerProxyFactory, + + @inject(CommandRegistry) + protected readonly commandRegistry: CommandRegistry, + + @inject(BoardsServiceProvider) + protected readonly boardsServiceProvider: BoardsServiceProvider + ) {} + + /** + * Connects a localhost WebSocket using the specified port. + * @param addressPort port of the WebSocket + */ + async connect(addressPort: number): Promise { + if (!!this.webSocket) { + if (this.wsPort === addressPort) return; + else this.disconnect(); + } + try { + this.webSocket = new WebSocket(`ws://localhost:${addressPort}`); + } catch { + this.messageService.error( + nls.localize( + 'arduino/monitor/unableToConnectToWebSocket', + 'Unable to connect to websocket' + ) + ); + return; + } + + this.webSocket.onmessage = (message) => { + const parsedMessage = JSON.parse(message.data); + if (Array.isArray(parsedMessage)) + this.onMessagesReceivedEmitter.fire({ messages: parsedMessage }); + else if ( + parsedMessage.command === + Monitor.MiddlewareCommand.ON_SETTINGS_DID_CHANGE + ) { + this.onMonitorSettingsDidChangeEmitter.fire(parsedMessage.data); + } + }; + this.wsPort = addressPort; + } + + /** + * Disconnects the WebSocket if connected. + */ + disconnect(): void { + if (!this.webSocket) return; + this.onBoardsConfigChanged?.dispose(); + this.onBoardsConfigChanged = undefined; + try { + this.webSocket?.close(); + this.webSocket = undefined; + } catch { + this.messageService.error( + nls.localize( + 'arduino/monitor/unableToCloseWebSocket', + 'Unable to close websocket' + ) + ); + } + } + + async isWSConnected(): Promise { + return !!this.webSocket; + } + + async startMonitor(settings?: PluggableMonitorSettings): Promise { + this.lastConnectedBoard = { + selectedBoard: this.boardsServiceProvider.boardsConfig.selectedBoard, + selectedPort: this.boardsServiceProvider.boardsConfig.selectedPort, + }; + + if (!this.onBoardsConfigChanged) { + this.onBoardsConfigChanged = + this.boardsServiceProvider.onBoardsConfigChanged( + async ({ selectedBoard, selectedPort }) => { + if ( + typeof selectedBoard === 'undefined' || + typeof selectedPort === 'undefined' + ) + return; + + // a board is plugged and it's different from the old connected board + if ( + selectedBoard?.fqbn !== + this.lastConnectedBoard?.selectedBoard?.fqbn || + selectedPort?.id !== this.lastConnectedBoard?.selectedPort?.id + ) { + this.onMonitorShouldResetEmitter.fire(null); + this.lastConnectedBoard = { + selectedBoard: selectedBoard, + selectedPort: selectedPort, + }; + } else { + // a board is plugged and it's the same as prev, rerun "this.startMonitor" to + // recreate the listener callback + this.startMonitor(); + } + } + ); + } + + const { selectedBoard, selectedPort } = + this.boardsServiceProvider.boardsConfig; + if (!selectedBoard || !selectedBoard.fqbn || !selectedPort) return; + await this.server().startMonitor(selectedBoard, selectedPort, settings); + } + + getCurrentSettings(board: Board, port: Port): Promise { + return this.server().getCurrentSettings(board, port); + } + + send(message: string): void { + if (!this.webSocket) { + return; + } + + this.webSocket.send( + JSON.stringify({ + command: Monitor.ClientCommand.SEND_MESSAGE, + data: message, + }) + ); + } + + changeSettings(settings: MonitorSettings): void { + if (!this.webSocket) { + return; + } + + this.webSocket.send( + JSON.stringify({ + command: Monitor.ClientCommand.CHANGE_SETTINGS, + data: settings, + }) + ); + } +} diff --git a/arduino-ide-extension/src/browser/monitor-model.ts b/arduino-ide-extension/src/browser/monitor-model.ts new file mode 100644 index 000000000..3ebea1819 --- /dev/null +++ b/arduino-ide-extension/src/browser/monitor-model.ts @@ -0,0 +1,278 @@ +import { Emitter, Event } from '@theia/core'; +import { + FrontendApplicationContribution, + LocalStorageService, +} from '@theia/core/lib/browser'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { MonitorManagerProxyClient } from '../common/protocol'; +import { isNullOrUndefined } from '../common/utils'; +import { MonitorSettings } from '../node/monitor-settings/monitor-settings-provider'; + +@injectable() +export class MonitorModel implements FrontendApplicationContribution { + protected static STORAGE_ID = 'arduino-monitor-model'; + + @inject(LocalStorageService) + protected readonly localStorageService: LocalStorageService; + + @inject(MonitorManagerProxyClient) + protected readonly monitorManagerProxy: MonitorManagerProxyClient; + + protected readonly onChangeEmitter: Emitter< + MonitorModel.State.Change + >; + + protected _autoscroll: boolean; + protected _timestamp: boolean; + protected _lineEnding: MonitorModel.EOL; + protected _interpolate: boolean; + protected _darkTheme: boolean; + protected _wsPort: number; + protected _serialPort: string; + protected _connected: boolean; + + constructor() { + this._autoscroll = true; + this._timestamp = false; + this._interpolate = false; + this._lineEnding = MonitorModel.EOL.DEFAULT; + this._darkTheme = false; + this._wsPort = 0; + this._serialPort = ''; + this._connected = true; + + this.onChangeEmitter = new Emitter< + MonitorModel.State.Change + >(); + } + + onStart(): void { + this.localStorageService + .getData(MonitorModel.STORAGE_ID) + .then(this.restoreState.bind(this)); + + this.monitorManagerProxy.onMonitorSettingsDidChange( + this.onMonitorSettingsDidChange.bind(this) + ); + } + + get onChange(): Event> { + return this.onChangeEmitter.event; + } + + protected restoreState(state: MonitorModel.State): void { + if (!state) { + return; + } + this._autoscroll = state.autoscroll; + this._timestamp = state.timestamp; + this._lineEnding = state.lineEnding; + this._interpolate = state.interpolate; + this._serialPort = state.serialPort; + } + + protected async storeState(): Promise { + return this.localStorageService.setData(MonitorModel.STORAGE_ID, { + autoscroll: this._autoscroll, + timestamp: this._timestamp, + lineEnding: this._lineEnding, + interpolate: this._interpolate, + serialPort: this._serialPort, + }); + } + + get autoscroll(): boolean { + return this._autoscroll; + } + + set autoscroll(autoscroll: boolean) { + if (autoscroll === this._autoscroll) return; + this._autoscroll = autoscroll; + this.monitorManagerProxy.changeSettings({ + monitorUISettings: { autoscroll }, + }); + this.storeState().then(() => { + this.onChangeEmitter.fire({ + property: 'autoscroll', + value: this._autoscroll, + }); + }); + } + + toggleAutoscroll(): void { + this.autoscroll = !this._autoscroll; + } + + get timestamp(): boolean { + return this._timestamp; + } + + set timestamp(timestamp: boolean) { + if (timestamp === this._timestamp) return; + this._timestamp = timestamp; + this.monitorManagerProxy.changeSettings({ + monitorUISettings: { timestamp }, + }); + this.storeState().then(() => + this.onChangeEmitter.fire({ + property: 'timestamp', + value: this._timestamp, + }) + ); + } + + toggleTimestamp(): void { + this.timestamp = !this._timestamp; + } + + get lineEnding(): MonitorModel.EOL { + return this._lineEnding; + } + + set lineEnding(lineEnding: MonitorModel.EOL) { + if (lineEnding === this._lineEnding) return; + this._lineEnding = lineEnding; + this.monitorManagerProxy.changeSettings({ + monitorUISettings: { lineEnding }, + }); + this.storeState().then(() => + this.onChangeEmitter.fire({ + property: 'lineEnding', + value: this._lineEnding, + }) + ); + } + + get interpolate(): boolean { + return this._interpolate; + } + + set interpolate(interpolate: boolean) { + if (interpolate === this._interpolate) return; + this._interpolate = interpolate; + this.monitorManagerProxy.changeSettings({ + monitorUISettings: { interpolate }, + }); + this.storeState().then(() => + this.onChangeEmitter.fire({ + property: 'interpolate', + value: this._interpolate, + }) + ); + } + + get darkTheme(): boolean { + return this._darkTheme; + } + + set darkTheme(darkTheme: boolean) { + if (darkTheme === this._darkTheme) return; + this._darkTheme = darkTheme; + this.monitorManagerProxy.changeSettings({ + monitorUISettings: { darkTheme }, + }); + this.onChangeEmitter.fire({ + property: 'darkTheme', + value: this._darkTheme, + }); + } + + get wsPort(): number { + return this._wsPort; + } + + set wsPort(wsPort: number) { + if (wsPort === this._wsPort) return; + this._wsPort = wsPort; + this.monitorManagerProxy.changeSettings({ + monitorUISettings: { wsPort }, + }); + this.onChangeEmitter.fire({ + property: 'wsPort', + value: this._wsPort, + }); + } + + get serialPort(): string { + return this._serialPort; + } + + set serialPort(serialPort: string) { + if (serialPort === this._serialPort) return; + this._serialPort = serialPort; + this.monitorManagerProxy.changeSettings({ + monitorUISettings: { serialPort }, + }); + this.storeState().then(() => + this.onChangeEmitter.fire({ + property: 'serialPort', + value: this._serialPort, + }) + ); + } + + get connected(): boolean { + return this._connected; + } + + set connected(connected: boolean) { + if (connected === this._connected) return; + this._connected = connected; + this.monitorManagerProxy.changeSettings({ + monitorUISettings: { connected }, + }); + this.onChangeEmitter.fire({ + property: 'connected', + value: this._connected, + }); + } + + protected onMonitorSettingsDidChange = (settings: MonitorSettings): void => { + const { monitorUISettings } = settings; + if (!monitorUISettings) return; + const { + autoscroll, + interpolate, + lineEnding, + timestamp, + darkTheme, + wsPort, + serialPort, + connected, + } = monitorUISettings; + + if (!isNullOrUndefined(autoscroll)) this.autoscroll = autoscroll; + if (!isNullOrUndefined(interpolate)) this.interpolate = interpolate; + if (!isNullOrUndefined(lineEnding)) this.lineEnding = lineEnding; + if (!isNullOrUndefined(timestamp)) this.timestamp = timestamp; + if (!isNullOrUndefined(darkTheme)) this.darkTheme = darkTheme; + if (!isNullOrUndefined(wsPort)) this.wsPort = wsPort; + if (!isNullOrUndefined(serialPort)) this.serialPort = serialPort; + if (!isNullOrUndefined(connected)) this.connected = connected; + }; +} + +// TODO: Move this to /common +export namespace MonitorModel { + export interface State { + autoscroll: boolean; + timestamp: boolean; + lineEnding: EOL; + interpolate: boolean; + darkTheme: boolean; + wsPort: number; + serialPort: string; + connected: boolean; + } + export namespace State { + export interface Change { + readonly property: K; + readonly value: State[K]; + } + } + + export type EOL = '' | '\n' | '\r' | '\r\n'; + export namespace EOL { + export const DEFAULT: EOL = '\n'; + } +} diff --git a/arduino-ide-extension/src/browser/serial/monitor/monitor-view-contribution.tsx b/arduino-ide-extension/src/browser/serial/monitor/monitor-view-contribution.tsx index c29f7ca3e..36f13c3b2 100644 --- a/arduino-ide-extension/src/browser/serial/monitor/monitor-view-contribution.tsx +++ b/arduino-ide-extension/src/browser/serial/monitor/monitor-view-contribution.tsx @@ -8,9 +8,10 @@ import { TabBarToolbarRegistry, } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { ArduinoToolbar } from '../../toolbar/arduino-toolbar'; -import { SerialModel } from '../serial-model'; import { ArduinoMenus } from '../../menu/arduino-menus'; import { nls } from '@theia/core/lib/common'; +import { MonitorModel } from '../../monitor-model'; +import { MonitorManagerProxyClient } from '../../../common/protocol'; export namespace SerialMonitor { export namespace Commands { @@ -47,10 +48,15 @@ export class MonitorViewContribution static readonly TOGGLE_SERIAL_MONITOR = MonitorWidget.ID + ':toggle'; static readonly TOGGLE_SERIAL_MONITOR_TOOLBAR = MonitorWidget.ID + ':toggle-toolbar'; + static readonly RESET_SERIAL_MONITOR = MonitorWidget.ID + ':reset'; - @inject(SerialModel) protected readonly model: SerialModel; + constructor( + @inject(MonitorModel) + protected readonly model: MonitorModel, - constructor() { + @inject(MonitorManagerProxyClient) + protected readonly monitorManagerProxy: MonitorManagerProxyClient + ) { super({ widgetId: MonitorWidget.ID, widgetName: MonitorWidget.LABEL, @@ -60,6 +66,7 @@ export class MonitorViewContribution toggleCommandId: MonitorViewContribution.TOGGLE_SERIAL_MONITOR, toggleKeybinding: 'CtrlCmd+Shift+M', }); + this.monitorManagerProxy.onMonitorShouldReset(() => this.reset()); } override registerMenus(menus: MenuModelRegistry): void { @@ -118,6 +125,10 @@ export class MonitorViewContribution } ); } + commands.registerCommand( + { id: MonitorViewContribution.RESET_SERIAL_MONITOR }, + { execute: () => this.reset() } + ); } protected async toggle(): Promise { @@ -129,6 +140,14 @@ export class MonitorViewContribution } } + protected async reset(): Promise { + const widget = this.tryGetWidget(); + if (widget) { + widget.dispose(); + await this.openView({ activate: true, reveal: true }); + } + } + protected renderAutoScrollButton(): React.ReactNode { return ( diff --git a/arduino-ide-extension/src/browser/serial/monitor/monitor-widget.tsx b/arduino-ide-extension/src/browser/serial/monitor/monitor-widget.tsx index 24d6449e7..f9aba5ed4 100644 --- a/arduino-ide-extension/src/browser/serial/monitor/monitor-widget.tsx +++ b/arduino-ide-extension/src/browser/serial/monitor/monitor-widget.tsx @@ -1,5 +1,5 @@ import * as React from '@theia/core/shared/react'; -import { postConstruct, injectable, inject } from '@theia/core/shared/inversify'; +import { injectable, inject } from '@theia/core/shared/inversify'; import { OptionsType } from 'react-select/src/types'; import { Emitter } from '@theia/core/lib/common/event'; import { Disposable } from '@theia/core/lib/common/disposable'; @@ -9,14 +9,14 @@ import { Widget, MessageLoop, } from '@theia/core/lib/browser/widgets'; -import { SerialConfig } from '../../../common/protocol/serial-service'; import { ArduinoSelect } from '../../widgets/arduino-select'; -import { SerialModel } from '../serial-model'; -import { SerialConnectionManager } from '../serial-connection-manager'; import { SerialMonitorSendInput } from './serial-monitor-send-input'; import { SerialMonitorOutput } from './serial-monitor-send-output'; import { BoardsServiceProvider } from '../../boards/boards-service-provider'; import { nls } from '@theia/core/lib/common'; +import { MonitorManagerProxyClient } from '../../../common/protocol'; +import { MonitorModel } from '../../monitor-model'; +import { MonitorSettings } from '../../../node/monitor-settings/monitor-settings-provider'; @injectable() export class MonitorWidget extends ReactWidget { @@ -26,14 +26,7 @@ export class MonitorWidget extends ReactWidget { ); static readonly ID = 'serial-monitor'; - @inject(SerialModel) - protected readonly serialModel: SerialModel; - - @inject(SerialConnectionManager) - protected readonly serialConnection: SerialConnectionManager; - - @inject(BoardsServiceProvider) - protected readonly boardsServiceProvider: BoardsServiceProvider; + protected settings: MonitorSettings = {}; protected widgetHeight: number; @@ -48,7 +41,16 @@ export class MonitorWidget extends ReactWidget { protected closing = false; protected readonly clearOutputEmitter = new Emitter(); - constructor() { + constructor( + @inject(MonitorModel) + protected readonly monitorModel: MonitorModel, + + @inject(MonitorManagerProxyClient) + protected readonly monitorManagerProxy: MonitorManagerProxyClient, + + @inject(BoardsServiceProvider) + protected readonly boardsServiceProvider: BoardsServiceProvider + ) { super(); this.id = MonitorWidget.ID; this.title.label = MonitorWidget.LABEL; @@ -57,17 +59,30 @@ export class MonitorWidget extends ReactWidget { this.scrollOptions = undefined; this.toDispose.push(this.clearOutputEmitter); this.toDispose.push( - Disposable.create(() => this.serialConnection.closeWStoBE()) + Disposable.create(() => this.monitorManagerProxy.disconnect()) ); } - @postConstruct() - protected init(): void { + protected override onBeforeAttach(msg: Message): void { this.update(); - this.toDispose.push( - this.serialConnection.onConnectionChanged(() => this.clearConsole()) + this.toDispose.push(this.monitorModel.onChange(() => this.update())); + this.getCurrentSettings().then(this.onMonitorSettingsDidChange.bind(this)); + this.monitorManagerProxy.onMonitorSettingsDidChange( + this.onMonitorSettingsDidChange.bind(this) ); - this.toDispose.push(this.serialModel.onChange(() => this.update())); + + this.monitorManagerProxy.startMonitor(); + } + + onMonitorSettingsDidChange(settings: MonitorSettings): void { + this.settings = { + ...this.settings, + pluggableMonitorSettings: { + ...this.settings.pluggableMonitorSettings, + ...settings.pluggableMonitorSettings, + }, + }; + this.update(); } clearConsole(): void { @@ -79,11 +94,6 @@ export class MonitorWidget extends ReactWidget { super.dispose(); } - protected override onAfterAttach(msg: Message): void { - super.onAfterAttach(msg); - this.serialConnection.openWSToBE(); - } - protected override onCloseRequest(msg: Message): void { this.closing = true; super.onCloseRequest(msg); @@ -119,7 +129,7 @@ export class MonitorWidget extends ReactWidget { }; protected get lineEndings(): OptionsType< - SerialMonitorOutput.SelectOption + SerialMonitorOutput.SelectOption > { return [ { @@ -144,32 +154,40 @@ export class MonitorWidget extends ReactWidget { ]; } - protected get baudRates(): OptionsType< - SerialMonitorOutput.SelectOption - > { - const baudRates: Array = [ - 300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, - ]; - return baudRates.map((baudRate) => ({ - label: baudRate + ' baud', - value: baudRate, - })); + private getCurrentSettings(): Promise { + const board = this.boardsServiceProvider.boardsConfig.selectedBoard; + const port = this.boardsServiceProvider.boardsConfig.selectedPort; + if (!board || !port) { + return Promise.resolve(this.settings || {}); + } + return this.monitorManagerProxy.getCurrentSettings(board, port); } protected render(): React.ReactNode { - const { baudRates, lineEndings } = this; + const baudrate = this.settings?.pluggableMonitorSettings + ? this.settings.pluggableMonitorSettings.baudrate + : undefined; + + const baudrateOptions = baudrate?.values.map((b) => ({ + label: b + ' baud', + value: b, + })); + const baudrateSelectedOption = baudrateOptions?.find( + (b) => b.value === baudrate?.selectedValue + ); + const lineEnding = - lineEndings.find((item) => item.value === this.serialModel.lineEnding) || - lineEndings[1]; // Defaults to `\n`. - const baudRate = - baudRates.find((item) => item.value === this.serialModel.baudRate) || - baudRates[4]; // Defaults to `9600`. + this.lineEndings.find( + (item) => item.value === this.monitorModel.lineEnding + ) || this.lineEndings[1]; // Defaults to `\n`. + return (
@@ -178,26 +196,28 @@ export class MonitorWidget extends ReactWidget {
-
- -
+ {baudrateOptions && baudrateSelectedOption && ( +
+ +
+ )}
@@ -208,18 +228,26 @@ export class MonitorWidget extends ReactWidget { protected readonly onSend = (value: string) => this.doSend(value); protected async doSend(value: string): Promise { - this.serialConnection.send(value); + this.monitorManagerProxy.send(value); } protected readonly onChangeLineEnding = ( - option: SerialMonitorOutput.SelectOption - ) => { - this.serialModel.lineEnding = option.value; + option: SerialMonitorOutput.SelectOption + ): void => { + this.monitorModel.lineEnding = option.value; }; - protected readonly onChangeBaudRate = ( - option: SerialMonitorOutput.SelectOption - ) => { - this.serialModel.baudRate = option.value; + protected readonly onChangeBaudRate = ({ + value, + }: { + value: string; + }): void => { + this.getCurrentSettings().then(({ pluggableMonitorSettings }) => { + if (!pluggableMonitorSettings || !pluggableMonitorSettings['baudrate']) + return; + const baudRateSettings = pluggableMonitorSettings['baudrate']; + baudRateSettings.selectedValue = value; + this.monitorManagerProxy.changeSettings({ pluggableMonitorSettings }); + }); }; } diff --git a/arduino-ide-extension/src/browser/serial/monitor/serial-monitor-send-input.tsx b/arduino-ide-extension/src/browser/serial/monitor/serial-monitor-send-input.tsx index 8000d9fe4..19f06eea1 100644 --- a/arduino-ide-extension/src/browser/serial/monitor/serial-monitor-send-input.tsx +++ b/arduino-ide-extension/src/browser/serial/monitor/serial-monitor-send-input.tsx @@ -3,12 +3,13 @@ import { Key, KeyCode } from '@theia/core/lib/browser/keys'; import { Board } from '../../../common/protocol/boards-service'; import { isOSX } from '@theia/core/lib/common/os'; import { DisposableCollection, nls } from '@theia/core/lib/common'; -import { SerialConnectionManager } from '../serial-connection-manager'; -import { SerialPlotter } from '../plotter/protocol'; +import { BoardsServiceProvider } from '../../boards/boards-service-provider'; +import { MonitorModel } from '../../monitor-model'; export namespace SerialMonitorSendInput { export interface Props { - readonly serialConnection: SerialConnectionManager; + readonly boardsServiceProvider: BoardsServiceProvider; + readonly monitorModel: MonitorModel; readonly onSend: (text: string) => void; readonly resolveFocus: (element: HTMLElement | undefined) => void; } @@ -26,28 +27,20 @@ export class SerialMonitorSendInput extends React.Component< constructor(props: Readonly) { super(props); - this.state = { text: '', connected: false }; + this.state = { text: '', connected: true }; this.onChange = this.onChange.bind(this); this.onSend = this.onSend.bind(this); this.onKeyDown = this.onKeyDown.bind(this); } override componentDidMount(): void { - this.props.serialConnection.isBESerialConnected().then((connected) => { - this.setState({ connected }); - }); - - this.toDisposeBeforeUnmount.pushAll([ - this.props.serialConnection.onRead(({ messages }) => { - if ( - messages.command === - SerialPlotter.Protocol.Command.MIDDLEWARE_CONFIG_CHANGED && - 'connected' in messages.data - ) { - this.setState({ connected: messages.data.connected }); - } - }), - ]); + this.setState({ connected: this.props.monitorModel.connected }); + this.toDisposeBeforeUnmount.push( + this.props.monitorModel.onChange(({ property }) => { + if (property === 'connected') + this.setState({ connected: this.props.monitorModel.connected }); + }) + ); } override componentWillUnmount(): void { @@ -60,7 +53,7 @@ export class SerialMonitorSendInput extends React.Component< { + this.props.monitorManagerProxy.onMessagesReceived(({ messages }) => { const [newLines, totalCharCount] = messagesToLines( messages, this.state.lines, this.state.charCount ); const [lines, charCount] = truncateLines(newLines, totalCharCount); - this.setState({ lines, charCount, @@ -75,9 +74,9 @@ export class SerialMonitorOutput extends React.Component< this.props.clearConsoleEvent(() => this.setState({ lines: [], charCount: 0 }) ), - this.props.serialModel.onChange(({ property }) => { + this.props.monitorModel.onChange(({ property }) => { if (property === 'timestamp') { - const { timestamp } = this.props.serialModel; + const { timestamp } = this.props.monitorModel; this.setState({ timestamp }); } if (property === 'autoscroll') { @@ -93,7 +92,7 @@ export class SerialMonitorOutput extends React.Component< } scrollToBottom = ((): void => { - if (this.listRef.current && this.props.serialModel.autoscroll) { + if (this.listRef.current && this.props.monitorModel.autoscroll) { this.listRef.current.scrollToItem(this.state.lines.length, 'end'); } }).bind(this); @@ -128,8 +127,8 @@ const Row = React.memo(_Row, areEqual); export namespace SerialMonitorOutput { export interface Props { - readonly serialModel: SerialModel; - readonly serialConnection: SerialConnectionManager; + readonly monitorModel: MonitorModel; + readonly monitorManagerProxy: MonitorManagerProxyClient; 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 index c2f23ae8f..0e9d6bbcc 100644 --- a/arduino-ide-extension/src/browser/serial/plotter/plotter-frontend-contribution.ts +++ b/arduino-ide-extension/src/browser/serial/plotter/plotter-frontend-contribution.ts @@ -6,15 +6,14 @@ import { 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/electron/shared/electron'; -import { SerialConfig } from '../../../common/protocol'; -import { SerialConnectionManager } from '../serial-connection-manager'; -import { SerialPlotter } from './protocol'; +import { MonitorManagerProxyClient } from '../../../common/protocol'; import { BoardsServiceProvider } from '../../boards/boards-service-provider'; +import { MonitorModel } from '../../monitor-model'; + const queryString = require('query-string'); export namespace SerialPlotterContribution { @@ -24,6 +23,11 @@ export namespace SerialPlotterContribution { label: 'Serial Plotter', category: 'Arduino', }; + export const RESET: Command = { + id: 'serial-plotter-reset', + label: 'Reset Serial Plotter', + category: 'Arduino', + }; } } @@ -33,14 +37,14 @@ export class PlotterFrontendContribution extends Contribution { protected url: string; protected wsPort: number; - @inject(SerialModel) - protected readonly model: SerialModel; + @inject(MonitorModel) + protected readonly model: MonitorModel; @inject(ThemeService) protected readonly themeService: ThemeService; - @inject(SerialConnectionManager) - protected readonly serialConnection: SerialConnectionManager; + @inject(MonitorManagerProxyClient) + protected readonly monitorManagerProxy: MonitorManagerProxyClient; @inject(BoardsServiceProvider) protected readonly boardsServiceProvider: BoardsServiceProvider; @@ -53,12 +57,17 @@ export class PlotterFrontendContribution extends Contribution { this.window = null; } }); + this.monitorManagerProxy.onMonitorShouldReset(() => this.reset()); + return super.onStart(app); } override registerCommands(registry: CommandRegistry): void { registry.registerCommand(SerialPlotterContribution.Commands.OPEN, { - execute: this.connect.bind(this), + execute: this.startPlotter.bind(this), + }); + registry.registerCommand(SerialPlotterContribution.Commands.RESET, { + execute: () => this.reset(), }); } @@ -70,12 +79,13 @@ export class PlotterFrontendContribution extends Contribution { }); } - async connect(): Promise { + async startPlotter(): Promise { + await this.monitorManagerProxy.startMonitor(); if (!!this.window) { this.window.focus(); return; } - const wsPort = this.serialConnection.getWsPort(); + const wsPort = this.monitorManagerProxy.getWebSocketPort(); if (wsPort) { this.open(wsPort); } else { @@ -84,15 +94,10 @@ export class PlotterFrontendContribution extends Contribution { } protected async open(wsPort: number): Promise { - const initConfig: Partial = { - baudrates: SerialConfig.BaudRates.map((b) => b), - currentBaudrate: this.model.baudRate, - currentLineEnding: this.model.lineEnding, + const initConfig = { darkTheme: this.themeService.getCurrentTheme().type === 'dark', wsPort, - interpolate: this.model.interpolate, - connected: await this.serialConnection.isBESerialConnected(), - serialPort: this.boardsServiceProvider.boardsConfig.selectedPort?.address, + serialPort: this.model.serialPort, }; const urlWithParams = queryString.stringifyUrl( { @@ -103,4 +108,11 @@ export class PlotterFrontendContribution extends Contribution { ); this.window = window.open(urlWithParams, 'serialPlotter'); } + + protected async reset(): Promise { + if (!!this.window) { + this.window.close(); + await this.startPlotter(); + } + } } diff --git a/arduino-ide-extension/src/browser/serial/plotter/protocol.ts b/arduino-ide-extension/src/browser/serial/plotter/protocol.ts deleted file mode 100644 index c38c9fcb9..000000000 --- a/arduino-ide-extension/src/browser/serial/plotter/protocol.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index b33db6447..000000000 --- a/arduino-ide-extension/src/browser/serial/serial-connection-manager.ts +++ /dev/null @@ -1,360 +0,0 @@ -import { injectable, inject } from '@theia/core/shared/inversify'; -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 { - 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 { CoreService } from '../../common/protocol'; -import { nls } from '@theia/core/lib/common/nls'; - -@injectable() -export class SerialConnectionManager { - 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, - @inject(BoardsServiceProvider) - protected readonly boardsServiceClientImpl: BoardsServiceProvider - ) { - 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(async ({ property }) => { - if ( - property === 'baudRate' && - (await this.serialService.isSerialPortOpen()) - ) { - 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', - }); - }); - } - - /** - * Updated the config in the BE passing only the properties that has changed. - * BE will create a new connection if needed. - * - * @param newConfig the porperties of the config that has changed - */ - 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.serialService.updateWsConfigParam({ - currentBaudrate: this.config.baudRate, - serialPort: this.config.port?.address, - }); - - if (isSerialConfig(this.config)) { - this.serialService.setSerialConfig(this.config); - } - } - } - - getConfig(): Partial { - return this.config; - } - - getWsPort(): number | undefined { - return this.wsPort; - } - - protected handleWebSocketChanged(wsPort: number): void { - this.wsPort = wsPort; - } - - get serialConfig(): SerialConfig | undefined { - return isSerialConfig(this.config) - ? (this.config as SerialConfig) - : undefined; - } - - async isBESerialConnected(): Promise { - return await this.serialService.isSerialPortOpen(); - } - - openWSToBE(): void { - if (!isSerialConfig(this.config)) { - this.messageService.error( - `Please select a board and a port to open the serial connection.` - ); - } - - if (!this.webSocket && this.wsPort) { - try { - this.webSocket = new WebSocket(`ws://localhost:${this.wsPort}`); - this.webSocket.onmessage = (res) => { - const messages = JSON.parse(res.data); - this.onReadEmitter.fire({ messages }); - }; - } catch { - this.messageService.error(`Unable to connect to websocket`); - } - } - } - - closeWStoBE(): void { - if (this.webSocket) { - try { - this.webSocket.close(); - this.webSocket = undefined; - } catch { - this.messageService.error(`Unable to close websocket`); - } - } - } - - /** - * Handles error on the SerialServiceClient and try to reconnect, eventually - */ - async handleError(error: SerialError): Promise { - if (!(await this.serialService.isSerialPortOpen())) 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.address - ), - 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.address - ), - options - ); - break; - } - case undefined: { - this.messageService.error( - nls.localize( - 'arduino/serial/unexpectedError', - 'Unexpected error. Reconnecting {0} on port {1}.', - Board.toString(board), - port.address - ), - options - ); - console.error(JSON.stringify(error)); - break; - } - } - - if ((await this.serialService.clientsAttached()) > 0) { - 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.address - ) - ); - 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.address, - attempts.toString() - ) - ); - this.reconnectTimeout = window.setTimeout( - () => this.reconnectAfterUpload(), - timeout - ); - } - } - } - - async reconnectAfterUpload(): Promise { - try { - if (isSerialConfig(this.config)) { - await this.boardsServiceClientImpl.waitUntilAvailable( - Object.assign(this.config.board, { port: this.config.port }), - 10_000 - ); - this.serialService.connectSerialIfRequired(); - } - } catch (waitError) { - this.messageService.error( - nls.localize( - 'arduino/sketch/couldNotConnectToSerial', - 'Could not reconnect to serial port. {0}', - waitError.toString() - ) - ); - } - } - - /** - * 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 (!(await this.serialService.isSerialPortOpen())) { - 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: any }> { - 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 namespace Config { - export function toString(config: Partial): string { - if (!isSerialConfig(config)) return ''; - const { board, port } = config; - return `${Board.toString(board)} ${port.address}`; - } - } -} - -function isSerialConfig(config: Partial): config is SerialConfig { - return !!config.board && !!config.baudRate && !!config.port; -} diff --git a/arduino-ide-extension/src/browser/serial/serial-model.ts b/arduino-ide-extension/src/browser/serial/serial-model.ts deleted file mode 100644 index 6f6dab16c..000000000 --- a/arduino-ide-extension/src/browser/serial/serial-model.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { injectable, inject } from '@theia/core/shared/inversify'; -import { Emitter, Event } from '@theia/core/lib/common/event'; -import { SerialConfig } from '../../common/protocol'; -import { - FrontendApplicationContribution, - LocalStorageService, -} from '@theia/core/lib/browser'; -import { BoardsServiceProvider } from '../boards/boards-service-provider'; - -@injectable() -export class SerialModel implements FrontendApplicationContribution { - protected static STORAGE_ID = 'arduino-serial-model'; - - @inject(LocalStorageService) - protected readonly localStorageService: LocalStorageService; - - @inject(BoardsServiceProvider) - protected readonly boardsServiceClient: BoardsServiceProvider; - - protected readonly onChangeEmitter: Emitter< - SerialModel.State.Change - >; - protected _autoscroll: boolean; - protected _timestamp: boolean; - protected _baudRate: SerialConfig.BaudRate; - protected _lineEnding: SerialModel.EOL; - protected _interpolate: boolean; - - constructor() { - this._autoscroll = true; - this._timestamp = false; - this._baudRate = SerialConfig.BaudRate.DEFAULT; - this._lineEnding = SerialModel.EOL.DEFAULT; - this._interpolate = false; - this.onChangeEmitter = new Emitter< - SerialModel.State.Change - >(); - } - - onStart(): void { - this.localStorageService - .getData(SerialModel.STORAGE_ID) - .then((state) => { - if (state) { - this.restoreState(state); - } - }); - } - - get onChange(): Event> { - return this.onChangeEmitter.event; - } - - get autoscroll(): boolean { - return this._autoscroll; - } - - toggleAutoscroll(): void { - this._autoscroll = !this._autoscroll; - this.storeState(); - this.storeState().then(() => - this.onChangeEmitter.fire({ - property: 'autoscroll', - value: this._autoscroll, - }) - ); - } - - get timestamp(): boolean { - return this._timestamp; - } - - toggleTimestamp(): void { - this._timestamp = !this._timestamp; - this.storeState().then(() => - this.onChangeEmitter.fire({ - property: 'timestamp', - value: this._timestamp, - }) - ); - } - - get baudRate(): SerialConfig.BaudRate { - return this._baudRate; - } - - set baudRate(baudRate: SerialConfig.BaudRate) { - this._baudRate = baudRate; - this.storeState().then(() => - this.onChangeEmitter.fire({ - property: 'baudRate', - value: this._baudRate, - }) - ); - } - - get lineEnding(): SerialModel.EOL { - return this._lineEnding; - } - - set lineEnding(lineEnding: SerialModel.EOL) { - this._lineEnding = lineEnding; - this.storeState().then(() => - this.onChangeEmitter.fire({ - property: 'lineEnding', - value: this._lineEnding, - }) - ); - } - - 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(SerialModel.STORAGE_ID, { - autoscroll: this._autoscroll, - timestamp: this._timestamp, - baudRate: this._baudRate, - lineEnding: this._lineEnding, - interpolate: this._interpolate, - }); - } -} - -export namespace SerialModel { - export interface State { - autoscroll: boolean; - timestamp: boolean; - baudRate: SerialConfig.BaudRate; - lineEnding: EOL; - interpolate: boolean; - } - export namespace State { - export interface Change { - readonly property: K; - readonly value: State[K]; - } - } - - export type EOL = '' | '\n' | '\r' | '\r\n'; - export namespace EOL { - export const DEFAULT: EOL = '\n'; - } -} 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 deleted file mode 100644 index e6eb40f7c..000000000 --- a/arduino-ide-extension/src/browser/serial/serial-service-client-impl.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { injectable } from '@theia/core/shared/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/arduino-firmware-uploader.ts b/arduino-ide-extension/src/common/protocol/arduino-firmware-uploader.ts index f1e2a439f..3cf9437d3 100644 --- a/arduino-ide-extension/src/common/protocol/arduino-firmware-uploader.ts +++ b/arduino-ide-extension/src/common/protocol/arduino-firmware-uploader.ts @@ -1,3 +1,5 @@ +import { Port } from "./boards-service"; + export const ArduinoFirmwareUploaderPath = '/services/arduino-firmware-uploader'; export const ArduinoFirmwareUploader = Symbol('ArduinoFirmwareUploader'); @@ -10,7 +12,7 @@ export type FirmwareInfo = { }; export interface ArduinoFirmwareUploader { list(fqbn?: string): Promise; - flash(firmware: FirmwareInfo, port: string): Promise; + flash(firmware: FirmwareInfo, port: Port): Promise; uploadCertificates(command: string): Promise; updatableBoards(): Promise; availableFirmwares(fqbn: string): Promise; diff --git a/arduino-ide-extension/src/common/protocol/core-service.ts b/arduino-ide-extension/src/common/protocol/core-service.ts index f8216f504..15aa85bb0 100644 --- a/arduino-ide-extension/src/common/protocol/core-service.ts +++ b/arduino-ide-extension/src/common/protocol/core-service.ts @@ -1,5 +1,5 @@ import { BoardUserField } from '.'; -import { Port } from '../../common/protocol/boards-service'; +import { Board, Port } from '../../common/protocol/boards-service'; import { Programmer } from './boards-service'; export const CompilerWarningLiterals = [ @@ -33,7 +33,7 @@ export namespace CoreService { * `file` URI to the sketch folder. */ readonly sketchUri: string; - readonly fqbn?: string | undefined; + readonly board?: Board; readonly optimizeForDebug: boolean; readonly verbose: boolean; readonly sourceOverride: Record; @@ -42,7 +42,7 @@ export namespace CoreService { export namespace Upload { export interface Options extends Compile.Options { - readonly port?: Port | undefined; + readonly port?: Port; readonly programmer?: Programmer | undefined; readonly verify: boolean; readonly userFields: BoardUserField[]; @@ -51,8 +51,8 @@ export namespace CoreService { export namespace Bootloader { export interface Options { - readonly fqbn?: string | undefined; - readonly port?: Port | undefined; + readonly board?: Board; + readonly port?: Port; readonly programmer?: Programmer | undefined; readonly verbose: boolean; readonly verify: boolean; diff --git a/arduino-ide-extension/src/common/protocol/index.ts b/arduino-ide-extension/src/common/protocol/index.ts index 101905752..4adf94223 100644 --- a/arduino-ide-extension/src/common/protocol/index.ts +++ b/arduino-ide-extension/src/common/protocol/index.ts @@ -6,10 +6,10 @@ export * from './core-service'; export * from './filesystem-ext'; export * from './installable'; export * from './library-service'; -export * from './serial-service'; export * from './searchable'; export * from './sketches-service'; export * from './examples-service'; export * from './executable-service'; export * from './response-service'; export * from './notification-service'; +export * from './monitor-service'; diff --git a/arduino-ide-extension/src/common/protocol/monitor-service.ts b/arduino-ide-extension/src/common/protocol/monitor-service.ts new file mode 100644 index 000000000..7374951db --- /dev/null +++ b/arduino-ide-extension/src/common/protocol/monitor-service.ts @@ -0,0 +1,95 @@ +import { Event, JsonRpcServer } from '@theia/core'; +import { + PluggableMonitorSettings, + MonitorSettings, +} from '../../node/monitor-settings/monitor-settings-provider'; +import { Board, Port } from './boards-service'; + +export const MonitorManagerProxyFactory = Symbol('MonitorManagerProxyFactory'); +export type MonitorManagerProxyFactory = () => MonitorManagerProxy; + +export const MonitorManagerProxyPath = '/services/monitor-manager-proxy'; +export const MonitorManagerProxy = Symbol('MonitorManagerProxy'); +export interface MonitorManagerProxy + extends JsonRpcServer { + startMonitor( + board: Board, + port: Port, + settings?: PluggableMonitorSettings + ): Promise; + changeMonitorSettings( + board: Board, + port: Port, + settings: MonitorSettings + ): Promise; + stopMonitor(board: Board, port: Port): Promise; + getCurrentSettings(board: Board, port: Port): Promise; +} + +export const MonitorManagerProxyClient = Symbol('MonitorManagerProxyClient'); +export interface MonitorManagerProxyClient { + onMessagesReceived: Event<{ messages: string[] }>; + onMonitorSettingsDidChange: Event; + onMonitorShouldReset: Event; + connect(addressPort: number): void; + disconnect(): void; + getWebSocketPort(): number | undefined; + isWSConnected(): Promise; + startMonitor(settings?: PluggableMonitorSettings): Promise; + getCurrentSettings(board: Board, port: Port): Promise; + send(message: string): void; + changeSettings(settings: MonitorSettings): void; +} + +export interface PluggableMonitorSetting { + // The setting identifier + readonly id: string; + // A human-readable label of the setting (to be displayed on the GUI) + readonly label: string; + // The setting type (at the moment only "enum" is avaiable) + readonly type: string; + // The values allowed on "enum" types + readonly values: string[]; + // The selected value + selectedValue: string; +} + +export namespace Monitor { + // Commands sent by the clients to the web socket server + export enum ClientCommand { + SEND_MESSAGE = 'SEND_MESSAGE', + CHANGE_SETTINGS = 'CHANGE_SETTINGS', + } + + // Commands sent by the backend to the clients + export enum MiddlewareCommand { + ON_SETTINGS_DID_CHANGE = 'ON_SETTINGS_DID_CHANGE', + } + + export type Message = { + command: Monitor.ClientCommand | Monitor.MiddlewareCommand; + data: string | MonitorSettings; + }; +} + +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 CONFIG_MISSING: ErrorStatus = { + message: 'Serial Config missing.', + }; + export const UPLOAD_IN_PROGRESS: ErrorStatus = { + message: 'Upload in progress.', + }; +} diff --git a/arduino-ide-extension/src/common/protocol/serial-service.ts b/arduino-ide-extension/src/common/protocol/serial-service.ts deleted file mode 100644 index 0e77bb9cc..000000000 --- a/arduino-ide-extension/src/common/protocol/serial-service.ts +++ /dev/null @@ -1,102 +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'; -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 CONFIG_MISSING: ErrorStatus = { - message: 'Serial Config missing.', - }; -} - -export const SerialServicePath = '/services/serial'; -export const SerialService = Symbol('SerialService'); -export interface SerialService extends JsonRpcServer { - clientsAttached(): Promise; - setSerialConfig(config: SerialConfig): Promise; - sendMessageToSerial(message: string): Promise; - updateWsConfigParam(config: Partial): Promise; - isSerialPortOpen(): Promise; - connectSerialIfRequired(): Promise; - disconnect(reason?: SerialError): Promise; - uploadInProgress: boolean; -} - -export interface SerialConfig { - 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/common/utils.ts b/arduino-ide-extension/src/common/utils.ts index 93dcdd332..8ffa1fd9a 100644 --- a/arduino-ide-extension/src/common/utils.ts +++ b/arduino-ide-extension/src/common/utils.ts @@ -12,3 +12,7 @@ export function firstToLowerCase(what: string): string { export function firstToUpperCase(what: string): string { return what.charAt(0).toUpperCase() + what.slice(1); } + +export function isNullOrUndefined(what: any): what is undefined | null { + return what === undefined || what === null; +} diff --git a/arduino-ide-extension/src/node/arduino-firmware-uploader-impl.ts b/arduino-ide-extension/src/node/arduino-firmware-uploader-impl.ts index 6ca8ad374..d95b1252a 100644 --- a/arduino-ide-extension/src/node/arduino-firmware-uploader-impl.ts +++ b/arduino-ide-extension/src/node/arduino-firmware-uploader-impl.ts @@ -3,10 +3,10 @@ import { FirmwareInfo, } from '../common/protocol/arduino-firmware-uploader'; import { injectable, inject, named } from '@theia/core/shared/inversify'; -import { ExecutableService } from '../common/protocol'; -import { SerialService } from '../common/protocol/serial-service'; +import { ExecutableService, Port } from '../common/protocol'; import { getExecPath, spawnCommand } from './exec-util'; import { ILogger } from '@theia/core/lib/common/logger'; +import { MonitorManager } from './monitor-manager'; @injectable() export class ArduinoFirmwareUploaderImpl implements ArduinoFirmwareUploader { @@ -19,8 +19,8 @@ export class ArduinoFirmwareUploaderImpl implements ArduinoFirmwareUploader { @named('fwuploader') protected readonly logger: ILogger; - @inject(SerialService) - protected readonly serialService: SerialService; + @inject(MonitorManager) + protected readonly monitorManager: MonitorManager; protected onError(error: any): void { this.logger.error(error); @@ -69,26 +69,28 @@ export class ArduinoFirmwareUploaderImpl implements ArduinoFirmwareUploader { return await this.list(fqbn); } - async flash(firmware: FirmwareInfo, port: string): Promise { + async flash(firmware: FirmwareInfo, port: Port): Promise { let output; + const board = { + name: firmware.board_name, + fqbn: firmware.board_fqbn, + }; try { - this.serialService.uploadInProgress = true; - await this.serialService.disconnect(); + this.monitorManager.notifyUploadStarted(board, port); output = await this.runCommand([ 'firmware', 'flash', '--fqbn', firmware.board_fqbn, '--address', - port, + port.address, '--module', `${firmware.module}@${firmware.firmware_version}`, ]); } catch (e) { throw e; } finally { - this.serialService.uploadInProgress = false; - this.serialService.connectSerialIfRequired(); + this.monitorManager.notifyUploadFinished(board, port); return output; } } 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 9124436ce..97d52c863 100644 --- a/arduino-ide-extension/src/node/arduino-ide-backend-module.ts +++ b/arduino-ide-extension/src/node/arduino-ide-backend-module.ts @@ -40,16 +40,7 @@ import { ArduinoDaemon, ArduinoDaemonPath, } from '../common/protocol/arduino-daemon'; -import { - 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'; @@ -91,10 +82,24 @@ import { } 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'; import { ArduinoLocalizationContribution } from './arduino-localization-contribution'; import { LocalizationContribution } from '@theia/core/lib/node/i18n/localization-contribution'; +import { MonitorManagerProxyImpl } from './monitor-manager-proxy-impl'; +import { MonitorManager, MonitorManagerName } from './monitor-manager'; +import { + MonitorManagerProxy, + MonitorManagerProxyClient, + MonitorManagerProxyPath, +} from '../common/protocol/monitor-service'; +import { MonitorService, MonitorServiceName } from './monitor-service'; +import { MonitorSettingsProvider } from './monitor-settings/monitor-settings-provider'; +import { MonitorSettingsProviderImpl } from './monitor-settings/monitor-settings-provider-impl'; +import { + MonitorServiceFactory, + MonitorServiceFactoryOptions, +} from './monitor-service-factory'; +import WebSocketProviderImpl from './web-socket/web-socket-provider-impl'; +import { WebSocketProvider } from './web-socket/web-socket-provider'; import { ClangFormatter } from './clang-formatter'; import { FormatterPath } from '../common/protocol/formatter'; @@ -193,9 +198,6 @@ 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(); @@ -221,19 +223,58 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { // #endregion Theia customizations + // a single MonitorManager is responsible for handling the actual connections to the pluggable monitors + bind(MonitorManager).toSelf().inSingletonScope(); + + // monitor service & factory bindings + bind(MonitorSettingsProviderImpl).toSelf().inSingletonScope(); + bind(MonitorSettingsProvider).toService(MonitorSettingsProviderImpl); + + bind(WebSocketProviderImpl).toSelf(); + bind(WebSocketProvider).toService(WebSocketProviderImpl); + + bind(MonitorServiceFactory).toFactory( + ({ container }) => + (options: MonitorServiceFactoryOptions) => { + const logger = container.get(ILogger); + + const monitorSettingsProvider = container.get( + MonitorSettingsProvider + ); + + const webSocketProvider = + container.get(WebSocketProvider); + + const { board, port, coreClientProvider, monitorID } = options; + + return new MonitorService( + logger, + monitorSettingsProvider, + webSocketProvider, + board, + port, + monitorID, + coreClientProvider + ); + } + ); + // Serial client provider per connected frontend. bind(ConnectionContainerModule).toConstantValue( ConnectionContainerModule.create(({ bind, bindBackendService }) => { - bind(MonitorClientProvider).toSelf().inSingletonScope(); - bind(SerialServiceImpl).toSelf().inSingletonScope(); - bind(SerialService).toService(SerialServiceImpl); - bindBackendService( - SerialServicePath, - SerialService, - (service, client) => { - service.setClient(client); - client.onDidCloseConnection(() => service.dispose()); - return service; + bind(MonitorManagerProxyImpl).toSelf().inSingletonScope(); + bind(MonitorManagerProxy).toService(MonitorManagerProxyImpl); + bindBackendService( + MonitorManagerProxyPath, + MonitorManagerProxy, + (monitorMgrProxy, client) => { + monitorMgrProxy.setClient(client); + // when the client close the connection, the proxy is disposed. + // when the MonitorManagerProxy is disposed, it informs the MonitorManager + // telling him that it does not need an address/board anymore. + // the MonitorManager will then dispose the actual connection if there are no proxies using it + client.onDidCloseConnection(() => monitorMgrProxy.dispose()); + return monitorMgrProxy; } ); }) @@ -323,14 +364,22 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { .inSingletonScope() .whenTargetNamed('config'); - // Logger for the serial service. + // Logger for the monitor manager and its services + bind(ILogger) + .toDynamicValue((ctx) => { + const parentLogger = ctx.container.get(ILogger); + return parentLogger.child(MonitorManagerName); + }) + .inSingletonScope() + .whenTargetNamed(MonitorManagerName); + bind(ILogger) .toDynamicValue((ctx) => { const parentLogger = ctx.container.get(ILogger); - return parentLogger.child(SerialServiceName); + return parentLogger.child(MonitorServiceName); }) .inSingletonScope() - .whenTargetNamed(SerialServiceName); + .whenTargetNamed(MonitorServiceName); // Remote sketchbook bindings bind(AuthenticationServiceImpl).toSelf().inSingletonScope(); diff --git a/arduino-ide-extension/src/node/core-service-impl.ts b/arduino-ide-extension/src/node/core-service-impl.ts index b3d9c5a6e..00d6ca5ed 100644 --- a/arduino-ide-extension/src/node/core-service-impl.ts +++ b/arduino-ide-extension/src/node/core-service-impl.ts @@ -24,7 +24,7 @@ import { ArduinoCoreServiceClient } from './cli-protocol/cc/arduino/cli/commands import { firstToUpperCase, firstToLowerCase } from '../common/utils'; import { Port } from './cli-protocol/cc/arduino/cli/commands/v1/port_pb'; import { nls } from '@theia/core'; -import { SerialService } from './../common/protocol/serial-service'; +import { MonitorManager } from './monitor-manager'; @injectable() export class CoreServiceImpl extends CoreClientAware implements CoreService { @@ -34,8 +34,8 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { @inject(NotificationServiceServer) protected readonly notificationService: NotificationServiceServer; - @inject(SerialService) - protected readonly serialService: SerialService; + @inject(MonitorManager) + protected readonly monitorManager: MonitorManager; protected uploading = false; @@ -45,7 +45,7 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { compilerWarnings?: CompilerWarnings; } ): Promise { - const { sketchUri, fqbn, compilerWarnings } = options; + const { sketchUri, board, compilerWarnings } = options; const sketchPath = FileUri.fsPath(sketchUri); await this.coreClientProvider.initialized; @@ -55,8 +55,8 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { const compileReq = new CompileRequest(); compileReq.setInstance(instance); compileReq.setSketchPath(sketchPath); - if (fqbn) { - compileReq.setFqbn(fqbn); + if (board?.fqbn) { + compileReq.setFqbn(board.fqbn); } if (compilerWarnings) { compileReq.setWarnings(compilerWarnings.toLowerCase()); @@ -139,11 +139,9 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { await this.compile(Object.assign(options, { exportBinaries: false })); this.uploading = true; - this.serialService.uploadInProgress = true; + const { sketchUri, board, port, programmer } = options; + await this.monitorManager.notifyUploadStarted(board, port); - await this.serialService.disconnect(); - - const { sketchUri, fqbn, port, programmer } = options; const sketchPath = FileUri.fsPath(sketchUri); await this.coreClientProvider.initialized; @@ -153,8 +151,8 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { const req = requestProvider(); req.setInstance(instance); req.setSketchPath(sketchPath); - if (fqbn) { - req.setFqbn(fqbn); + if (board?.fqbn) { + req.setFqbn(board.fqbn); } const p = new Port(); if (port) { @@ -209,23 +207,22 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { throw new Error(errorMessage); } finally { this.uploading = false; - this.serialService.uploadInProgress = false; + this.monitorManager.notifyUploadFinished(board, port); } } async burnBootloader(options: CoreService.Bootloader.Options): Promise { this.uploading = true; - this.serialService.uploadInProgress = true; - await this.serialService.disconnect(); + const { board, port, programmer } = options; + await this.monitorManager.notifyUploadStarted(board, port); await this.coreClientProvider.initialized; const coreClient = await this.coreClient(); const { client, instance } = coreClient; - const { fqbn, port, programmer } = options; const burnReq = new BurnBootloaderRequest(); burnReq.setInstance(instance); - if (fqbn) { - burnReq.setFqbn(fqbn); + if (board?.fqbn) { + burnReq.setFqbn(board.fqbn); } const p = new Port(); if (port) { @@ -267,7 +264,7 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService { throw new Error(errorMessage); } finally { this.uploading = false; - this.serialService.uploadInProgress = false; + await this.monitorManager.notifyUploadFinished(board, port); } } diff --git a/arduino-ide-extension/src/node/monitor-manager-proxy-impl.ts b/arduino-ide-extension/src/node/monitor-manager-proxy-impl.ts new file mode 100644 index 000000000..c4e9d59d5 --- /dev/null +++ b/arduino-ide-extension/src/node/monitor-manager-proxy-impl.ts @@ -0,0 +1,95 @@ +import { inject, injectable } from '@theia/core/shared/inversify'; +import { + MonitorManagerProxy, + MonitorManagerProxyClient, + Status, +} from '../common/protocol'; +import { Board, Port } from '../common/protocol'; +import { MonitorManager } from './monitor-manager'; +import { + MonitorSettings, + PluggableMonitorSettings, +} from './monitor-settings/monitor-settings-provider'; + +@injectable() +export class MonitorManagerProxyImpl implements MonitorManagerProxy { + protected client: MonitorManagerProxyClient; + + constructor( + @inject(MonitorManager) + protected readonly manager: MonitorManager + ) {} + + dispose(): void { + this.client?.disconnect(); + } + + /** + * Start a pluggable monitor and/or change its settings. + * If settings are defined they'll be set before starting the monitor, + * otherwise default ones will be used by the monitor. + * @param board board connected to port + * @param port port to monitor + * @param settings map of supported configuration by the monitor + */ + async startMonitor( + board: Board, + port: Port, + settings?: PluggableMonitorSettings + ): Promise { + if (settings) { + await this.changeMonitorSettings(board, port, settings); + } + const status = await this.manager.startMonitor(board, port); + if (status === Status.ALREADY_CONNECTED || status === Status.OK) { + // Monitor started correctly, connect it with the frontend + this.client.connect(this.manager.getWebsocketAddressPort(board, port)); + } + } + + /** + * Changes the settings of a running pluggable monitor, if that monitor is not + * started this function is a noop. + * @param board board connected to port + * @param port port monitored + * @param settings map of supported configuration by the monitor + */ + async changeMonitorSettings( + board: Board, + port: Port, + settings: PluggableMonitorSettings + ): Promise { + if (!this.manager.isStarted(board, port)) { + // Monitor is not running, no need to change settings + return; + } + return this.manager.changeMonitorSettings(board, port, settings); + } + + /** + * Stops a running pluggable monitor. + * @param board board connected to port + * @param port port monitored + */ + async stopMonitor(board: Board, port: Port): Promise { + return this.manager.stopMonitor(board, port); + } + + /** + * Returns the current settings by the pluggable monitor connected to specified + * by board/port combination. + * @param board board connected to port + * @param port port monitored + * @returns a map of MonitorSetting + */ + getCurrentSettings(board: Board, port: Port): Promise { + return this.manager.currentMonitorSettings(board, port); + } + + setClient(client: MonitorManagerProxyClient | undefined): void { + if (!client) { + return; + } + this.client = client; + } +} diff --git a/arduino-ide-extension/src/node/monitor-manager.ts b/arduino-ide-extension/src/node/monitor-manager.ts new file mode 100644 index 000000000..0f01fc958 --- /dev/null +++ b/arduino-ide-extension/src/node/monitor-manager.ts @@ -0,0 +1,220 @@ +import { ILogger } from '@theia/core'; +import { inject, injectable, named } from '@theia/core/shared/inversify'; +import { Board, Port, Status } from '../common/protocol'; +import { CoreClientAware } from './core-client-provider'; +import { MonitorService } from './monitor-service'; +import { MonitorServiceFactory } from './monitor-service-factory'; +import { + MonitorSettings, + PluggableMonitorSettings, +} from './monitor-settings/monitor-settings-provider'; + +type MonitorID = string; + +export const MonitorManagerName = 'monitor-manager'; + +@injectable() +export class MonitorManager extends CoreClientAware { + // Map of monitor services that manage the running pluggable monitors. + // Each service handles the lifetime of one, and only one, monitor. + // If either the board or port managed changes, a new service must + // be started. + private monitorServices = new Map(); + + @inject(MonitorServiceFactory) + private monitorServiceFactory: MonitorServiceFactory; + + constructor( + @inject(ILogger) + @named(MonitorManagerName) + protected readonly logger: ILogger + ) { + super(); + } + + /** + * Used to know if a monitor is started + * @param board board connected to port + * @param port port to monitor + * @returns true if the monitor is currently monitoring the board/port + * combination specifed, false in all other cases. + */ + isStarted(board: Board, port: Port): boolean { + const monitorID = this.monitorID(board, port); + const monitor = this.monitorServices.get(monitorID); + if (monitor) { + return monitor.isStarted(); + } + return false; + } + + /** + * Start a pluggable monitor that receives and sends messages + * to the specified board and port combination. + * @param board board connected to port + * @param port port to monitor + * @returns a Status object to know if the process has been + * started or if there have been errors. + */ + async startMonitor(board: Board, port: Port): Promise { + const monitorID = this.monitorID(board, port); + let monitor = this.monitorServices.get(monitorID); + if (!monitor) { + monitor = this.createMonitor(board, port); + } + return await monitor.start(); + } + + /** + * Stop a pluggable monitor connected to the specified board/port + * combination. It's a noop if monitor is not running. + * @param board board connected to port + * @param port port monitored + */ + async stopMonitor(board: Board, port: Port): Promise { + const monitorID = this.monitorID(board, port); + const monitor = this.monitorServices.get(monitorID); + if (!monitor) { + // There's no monitor to stop, bail + return; + } + return await monitor.stop(); + } + + /** + * Returns the port of the WebSocket used by the MonitorService + * that is handling the board/port combination + * @param board board connected to port + * @param port port to monitor + * @returns port of the MonitorService's WebSocket + */ + getWebsocketAddressPort(board: Board, port: Port): number { + const monitorID = this.monitorID(board, port); + const monitor = this.monitorServices.get(monitorID); + if (!monitor) { + return -1; + } + return monitor.getWebsocketAddressPort(); + } + + /** + * Notifies the monitor service of that board/port combination + * that an upload process started on that exact board/port combination. + * This must be done so that we can stop the monitor for the time being + * until the upload process finished. + * @param board board connected to port + * @param port port to monitor + */ + async notifyUploadStarted(board?: Board, port?: Port): Promise { + if (!board || !port) { + // We have no way of knowing which monitor + // to retrieve if we don't have this information. + return; + } + const monitorID = this.monitorID(board, port); + const monitor = this.monitorServices.get(monitorID); + if (!monitor) { + // There's no monitor running there, bail + return; + } + monitor.setUploadInProgress(true); + return await monitor.pause(); + } + + /** + * Notifies the monitor service of that board/port combination + * that an upload process started on that exact board/port combination. + * @param board board connected to port + * @param port port to monitor + * @returns a Status object to know if the process has been + * started or if there have been errors. + */ + async notifyUploadFinished(board?: Board, port?: Port): Promise { + if (!board || !port) { + // We have no way of knowing which monitor + // to retrieve if we don't have this information. + return Status.NOT_CONNECTED; + } + const monitorID = this.monitorID(board, port); + const monitor = this.monitorServices.get(monitorID); + if (!monitor) { + // There's no monitor running there, bail + return Status.NOT_CONNECTED; + } + monitor.setUploadInProgress(false); + return await monitor.start(); + } + + /** + * Changes the settings of a pluggable monitor even if it's running. + * If monitor is not running they're going to be used as soon as it's started. + * @param board board connected to port + * @param port port to monitor + * @param settings monitor settings to change + */ + changeMonitorSettings( + board: Board, + port: Port, + settings: PluggableMonitorSettings + ) { + const monitorID = this.monitorID(board, port); + let monitor = this.monitorServices.get(monitorID); + if (!monitor) { + monitor = this.createMonitor(board, port); + monitor.changeSettings(settings); + } + } + + /** + * Returns the settings currently used by the pluggable monitor + * that's communicating with the specified board/port combination. + * @param board board connected to port + * @param port port monitored + * @returns map of current monitor settings + */ + async currentMonitorSettings( + board: Board, + port: Port + ): Promise { + const monitorID = this.monitorID(board, port); + const monitor = this.monitorServices.get(monitorID); + if (!monitor) { + return {}; + } + return monitor.currentSettings(); + } + + /** + * Creates a MonitorService that handles the lifetime and the + * communication via WebSocket with the frontend. + * @param board board connected to specified port + * @param port port to monitor + * @returns a new instance of MonitorService ready to use. + */ + private createMonitor(board: Board, port: Port): MonitorService { + const monitorID = this.monitorID(board, port); + const monitor = this.monitorServiceFactory({ + board, + port, + monitorID, + coreClientProvider: this.coreClientProvider, + }); + this.monitorServices.set(monitorID, monitor); + monitor.onDispose( + (() => { + this.monitorServices.delete(monitorID); + }).bind(this) + ); + return monitor; + } + + /** + * Utility function to create a unique ID for a monitor service. + * @param board + * @param port + * @returns a unique monitor ID + */ + private monitorID(board: Board, port: Port): MonitorID { + return `${board.fqbn}-${port.address}-${port.protocol}`; + } +} diff --git a/arduino-ide-extension/src/node/monitor-service-factory.ts b/arduino-ide-extension/src/node/monitor-service-factory.ts new file mode 100644 index 000000000..6f88cdb0f --- /dev/null +++ b/arduino-ide-extension/src/node/monitor-service-factory.ts @@ -0,0 +1,20 @@ +import { Board, Port } from '../common/protocol'; +import { CoreClientProvider } from './core-client-provider'; +import { MonitorService } from './monitor-service'; + +export const MonitorServiceFactory = Symbol('MonitorServiceFactory'); +export interface MonitorServiceFactory { + (options: { + board: Board; + port: Port; + monitorID: string; + coreClientProvider: CoreClientProvider; + }): MonitorService; +} + +export interface MonitorServiceFactoryOptions { + board: Board; + port: Port; + monitorID: string; + coreClientProvider: CoreClientProvider; +} diff --git a/arduino-ide-extension/src/node/monitor-service.ts b/arduino-ide-extension/src/node/monitor-service.ts new file mode 100644 index 000000000..086e98de7 --- /dev/null +++ b/arduino-ide-extension/src/node/monitor-service.ts @@ -0,0 +1,606 @@ +import { ClientDuplexStream } from '@grpc/grpc-js'; +import { Disposable, Emitter, ILogger } from '@theia/core'; +import { inject, named } from '@theia/core/shared/inversify'; +import { Board, Port, Status, Monitor } from '../common/protocol'; +import { + EnumerateMonitorPortSettingsRequest, + EnumerateMonitorPortSettingsResponse, + MonitorPortConfiguration, + MonitorPortSetting, + MonitorRequest, + MonitorResponse, +} from './cli-protocol/cc/arduino/cli/commands/v1/monitor_pb'; +import { CoreClientAware, CoreClientProvider } from './core-client-provider'; +import { WebSocketProvider } from './web-socket/web-socket-provider'; +import { Port as gRPCPort } from 'arduino-ide-extension/src/node/cli-protocol/cc/arduino/cli/commands/v1/port_pb'; +import { + MonitorSettings, + PluggableMonitorSettings, + MonitorSettingsProvider, +} from './monitor-settings/monitor-settings-provider'; +import { Deferred } from '@theia/core/lib/common/promise-util'; + +export const MonitorServiceName = 'monitor-service'; +type DuplexHandlerKeys = + | 'close' + | 'end' + | 'error' + | 'data' + | 'status' + | 'metadata'; +interface DuplexHandler { + key: DuplexHandlerKeys; + callback: (...args: any) => void; +} + +export class MonitorService extends CoreClientAware implements Disposable { + // Bidirectional gRPC stream used to receive and send data from the running + // pluggable monitor managed by the Arduino CLI. + protected duplex: ClientDuplexStream | null; + + // Settings used by the currently running pluggable monitor. + // They can be freely modified while running. + protected settings: MonitorSettings = {}; + + // List of messages received from the running pluggable monitor. + // These are flushed from time to time to the frontend. + protected messages: string[] = []; + + // Handles messages received from the frontend via websocket. + protected onMessageReceived?: Disposable; + + // Sends messages to the frontend from time to time. + protected flushMessagesInterval?: NodeJS.Timeout; + + // Triggered each time the number of clients connected + // to the this service WebSocket changes. + protected onWSClientsNumberChanged?: Disposable; + + // Used to notify that the monitor is being disposed + protected readonly onDisposeEmitter = new Emitter(); + readonly onDispose = this.onDisposeEmitter.event; + + protected uploadInProgress = false; + protected _initialized = new Deferred(); + protected creating: Deferred; + + MAX_WRITE_TO_STREAM_TRIES = 10; + WRITE_TO_STREAM_TIMEOUT_MS = 30000; + + constructor( + @inject(ILogger) + @named(MonitorServiceName) + protected readonly logger: ILogger, + @inject(MonitorSettingsProvider) + protected readonly monitorSettingsProvider: MonitorSettingsProvider, + @inject(WebSocketProvider) + protected readonly webSocketProvider: WebSocketProvider, + + private readonly board: Board, + private readonly port: Port, + private readonly monitorID: string, + protected override readonly coreClientProvider: CoreClientProvider + ) { + super(); + + this.onWSClientsNumberChanged = + this.webSocketProvider.onClientsNumberChanged(async (clients: number) => { + if (clients === 0) { + // There are no more clients that want to receive + // data from this monitor, we can freely close + // and dispose it. + this.dispose(); + return; + } + this.updateClientsSettings(this.settings); + }); + + this.portMonitorSettings(port.protocol, board.fqbn!).then( + async (settings) => { + this.settings = { + ...this.settings, + pluggableMonitorSettings: + await this.monitorSettingsProvider.getSettings( + this.monitorID, + settings + ), + }; + this._initialized.resolve(); + } + ); + } + + get initialized(): Promise { + return this._initialized.promise; + } + + setUploadInProgress(status: boolean): void { + this.uploadInProgress = status; + } + + getWebsocketAddressPort(): number { + return this.webSocketProvider.getAddress().port; + } + + dispose(): void { + this.stop(); + this.onDisposeEmitter.fire(); + this.onWSClientsNumberChanged?.dispose(); + } + + /** + * isStarted is used to know if the currently running pluggable monitor is started. + * @returns true if pluggable monitor communication duplex is open, + * false in all other cases. + */ + isStarted(): boolean { + return !!this.duplex; + } + + /** + * Start and connects a monitor using currently set board and port. + * If a monitor is already started or board fqbn, port address and/or protocol + * are missing nothing happens. + * @returns a status to verify connection has been established. + */ + async start(): Promise { + if (this.creating?.state === 'unresolved') return this.creating.promise; + this.creating = new Deferred(); + if (this.duplex) { + this.updateClientsSettings({ + monitorUISettings: { connected: true, serialPort: this.port.address }, + }); + this.creating.resolve(Status.ALREADY_CONNECTED); + return this.creating.promise; + } + + if (!this.board?.fqbn || !this.port?.address || !this.port?.protocol) { + this.updateClientsSettings({ monitorUISettings: { connected: false } }); + + this.creating.resolve(Status.CONFIG_MISSING); + return this.creating.promise; + } + + if (this.uploadInProgress) { + this.updateClientsSettings({ + monitorUISettings: { connected: false, serialPort: this.port.address }, + }); + + this.creating.resolve(Status.UPLOAD_IN_PROGRESS); + return this.creating.promise; + } + + this.logger.info('starting monitor'); + + // get default monitor settings from the CLI + const defaultSettings = await this.portMonitorSettings( + this.port.protocol, + this.board.fqbn + ); + // get actual settings from the settings provider + this.settings = { + ...this.settings, + pluggableMonitorSettings: { + ...this.settings.pluggableMonitorSettings, + ...(await this.monitorSettingsProvider.getSettings( + this.monitorID, + defaultSettings + )), + }, + }; + + await this.coreClientProvider.initialized; + const coreClient = await this.coreClient(); + + const { instance } = coreClient; + const monitorRequest = new MonitorRequest(); + monitorRequest.setInstance(instance); + if (this.board?.fqbn) { + monitorRequest.setFqbn(this.board.fqbn); + } + if (this.port?.address && this.port?.protocol) { + const port = new gRPCPort(); + port.setAddress(this.port.address); + port.setProtocol(this.port.protocol); + monitorRequest.setPort(port); + } + const config = new MonitorPortConfiguration(); + for (const id in this.settings.pluggableMonitorSettings) { + const s = new MonitorPortSetting(); + s.setSettingId(id); + s.setValue(this.settings.pluggableMonitorSettings[id].selectedValue); + config.addSettings(s); + } + monitorRequest.setPortConfiguration(config); + + const wroteToStreamSuccessfully = await this.pollWriteToStream( + monitorRequest + ); + if (wroteToStreamSuccessfully) { + this.startMessagesHandlers(); + this.logger.info( + `started monitor to ${this.port?.address} using ${this.port?.protocol}` + ); + this.updateClientsSettings({ + monitorUISettings: { connected: true, serialPort: this.port.address }, + }); + this.creating.resolve(Status.OK); + return this.creating.promise; + } else { + this.logger.warn( + `failed starting monitor to ${this.port?.address} using ${this.port?.protocol}` + ); + this.creating.resolve(Status.NOT_CONNECTED); + return this.creating.promise; + } + } + + async createDuplex(): Promise< + ClientDuplexStream + > { + const coreClient = await this.coreClient(); + return coreClient.client.monitor(); + } + + setDuplexHandlers( + duplex: ClientDuplexStream, + additionalHandlers: DuplexHandler[] + ): void { + // default handlers + duplex + .on('close', () => { + this.duplex = null; + this.updateClientsSettings({ + monitorUISettings: { connected: false }, + }); + this.logger.info( + `monitor to ${this.port?.address} using ${this.port?.protocol} closed by client` + ); + }) + .on('end', () => { + this.duplex = null; + this.updateClientsSettings({ + monitorUISettings: { connected: false }, + }); + this.logger.info( + `monitor to ${this.port?.address} using ${this.port?.protocol} closed by server` + ); + }); + + for (const handler of additionalHandlers) { + duplex.on(handler.key, handler.callback); + } + } + + pollWriteToStream(request: MonitorRequest): Promise { + let attemptsRemaining = this.MAX_WRITE_TO_STREAM_TRIES; + const writeTimeoutMs = this.WRITE_TO_STREAM_TIMEOUT_MS; + + const createWriteToStreamExecutor = + (duplex: ClientDuplexStream) => + (resolve: (value: boolean) => void, reject: () => void) => { + const resolvingDuplexHandlers: DuplexHandler[] = [ + { + key: 'error', + callback: async (err: Error) => { + this.logger.error(err); + resolve(false); + // TODO + // this.theiaFEClient?.notifyError() + }, + }, + { + key: 'data', + callback: async (monitorResponse: MonitorResponse) => { + if (monitorResponse.getError()) { + // TODO: Maybe disconnect + this.logger.error(monitorResponse.getError()); + return; + } + if (monitorResponse.getSuccess()) { + resolve(true); + return; + } + const data = monitorResponse.getRxData(); + const message = + typeof data === 'string' + ? data + : new TextDecoder('utf8').decode(data); + this.messages.push(...splitLines(message)); + }, + }, + ]; + + this.setDuplexHandlers(duplex, resolvingDuplexHandlers); + + setTimeout(() => { + reject(); + }, writeTimeoutMs); + duplex.write(request); + }; + + const pollWriteToStream = new Promise((resolve) => { + const startPolling = async () => { + // here we create a new duplex but we don't yet + // set "this.duplex", nor do we use "this.duplex" in our poll + // as duplex 'end' / 'close' events (which we do not "await") + // will set "this.duplex" to null + const createdDuplex = await this.createDuplex(); + + let pollingIsSuccessful; + // attempt a "writeToStream" and "await" CLI response: success (true) or error (false) + // if we get neither within WRITE_TO_STREAM_TIMEOUT_MS or an error we get undefined + try { + const writeToStream = createWriteToStreamExecutor(createdDuplex); + pollingIsSuccessful = await new Promise(writeToStream); + } catch (error) { + this.logger.error(error); + } + + // CLI confirmed port opened successfully + if (pollingIsSuccessful) { + this.duplex = createdDuplex; + resolve(true); + return; + } + + // if "pollingIsSuccessful" is false + // the CLI gave us an error, lets try again + // after waiting 2 seconds if we've not already + // reached MAX_WRITE_TO_STREAM_TRIES + if (pollingIsSuccessful === false) { + attemptsRemaining -= 1; + if (attemptsRemaining > 0) { + setTimeout(startPolling, 2000); + return; + } else { + resolve(false); + return; + } + } + + // "pollingIsSuccessful" remains undefined: + // we got no response from the CLI within 30 seconds + // resolve to false and end the duplex connection + resolve(false); + createdDuplex.end(); + return; + }; + + startPolling(); + }); + + return pollWriteToStream; + } + + /** + * Pauses the currently running monitor, it still closes the gRPC connection + * with the underlying monitor process but it doesn't stop the message handlers + * currently running. + * This is mainly used to handle upload with the board/port combination + * the monitor is listening to. + * @returns + */ + async pause(): Promise { + return new Promise(async (resolve) => { + if (!this.duplex) { + this.logger.warn( + `monitor to ${this.port?.address} using ${this.port?.protocol} already stopped` + ); + return resolve(); + } + // It's enough to close the connection with the client + // to stop the monitor process + this.duplex.end(); + this.logger.info( + `stopped monitor to ${this.port?.address} using ${this.port?.protocol}` + ); + + this.duplex.on('end', resolve); + }); + } + + /** + * Stop the monitor currently running + */ + async stop(): Promise { + return this.pause().finally(this.stopMessagesHandlers.bind(this)); + } + + /** + * Send a message to the running monitor, a well behaved monitor + * will then send that message to the board. + * We MUST NEVER send a message that wasn't a user's input to the board. + * @param message string sent to running monitor + * @returns a status to verify message has been sent. + */ + async send(message: string): Promise { + if (!this.duplex) { + return Status.NOT_CONNECTED; + } + await this.coreClientProvider.initialized; + const coreClient = await this.coreClient(); + const { instance } = coreClient; + + const req = new MonitorRequest(); + req.setInstance(instance); + req.setTxData(new TextEncoder().encode(message)); + return new Promise((resolve) => { + if (this.duplex) { + this.duplex?.write(req, () => { + resolve(Status.OK); + }); + return; + } + this.stop().then(() => resolve(Status.NOT_CONNECTED)); + }); + } + + /** + * + * @returns map of current monitor settings + */ + async currentSettings(): Promise { + await this.initialized; + return this.settings; + } + + // TODO: move this into MonitoSettingsProvider + /** + * Returns the possible configurations used to connect a monitor + * to the board specified by fqbn using the specified protocol + * @param protocol the protocol of the monitor we want get settings for + * @param fqbn the fqbn of the board we want to monitor + * @returns a map of all the settings supported by the monitor + */ + private async portMonitorSettings( + protocol: string, + fqbn: string + ): Promise { + await this.coreClientProvider.initialized; + const coreClient = await this.coreClient(); + const { client, instance } = coreClient; + const req = new EnumerateMonitorPortSettingsRequest(); + req.setInstance(instance); + req.setPortProtocol(protocol); + req.setFqbn(fqbn); + + const res = await new Promise( + (resolve, reject) => { + client.enumerateMonitorPortSettings(req, (err, resp) => { + if (!!err) { + reject(err); + } + resolve(resp); + }); + } + ); + + const settings: PluggableMonitorSettings = {}; + for (const iterator of res.getSettingsList()) { + settings[iterator.getSettingId()] = { + id: iterator.getSettingId(), + label: iterator.getLabel(), + type: iterator.getType(), + values: iterator.getEnumValuesList(), + selectedValue: iterator.getValue(), + }; + } + return settings; + } + + /** + * Set monitor settings, if there is a running monitor they'll be sent + * to it, otherwise they'll be used when starting one. + * Only values in settings parameter will be change, other values won't + * be changed in any way. + * @param settings map of monitor settings to change + * @returns a status to verify settings have been sent. + */ + async changeSettings(settings: MonitorSettings): Promise { + const config = new MonitorPortConfiguration(); + const { pluggableMonitorSettings } = settings; + const reconciledSettings = await this.monitorSettingsProvider.setSettings( + this.monitorID, + pluggableMonitorSettings || {} + ); + + if (reconciledSettings) { + for (const id in reconciledSettings) { + const s = new MonitorPortSetting(); + s.setSettingId(id); + s.setValue(reconciledSettings[id].selectedValue); + config.addSettings(s); + } + } + + this.updateClientsSettings({ + monitorUISettings: { + ...settings.monitorUISettings, + connected: !!this.duplex, + serialPort: this.port.address, + }, + pluggableMonitorSettings: reconciledSettings, + }); + + if (!this.duplex) { + return Status.NOT_CONNECTED; + } + await this.coreClientProvider.initialized; + const coreClient = await this.coreClient(); + const { instance } = coreClient; + + const req = new MonitorRequest(); + req.setInstance(instance); + req.setPortConfiguration(config); + this.duplex.write(req); + return Status.OK; + } + + /** + * Starts the necessary handlers to send and receive + * messages to and from the frontend and the running monitor + */ + private startMessagesHandlers(): void { + if (!this.flushMessagesInterval) { + const flushMessagesToFrontend = () => { + if (this.messages.length) { + this.webSocketProvider.sendMessage(JSON.stringify(this.messages)); + this.messages = []; + } + }; + this.flushMessagesInterval = setInterval(flushMessagesToFrontend, 32); + } + + if (!this.onMessageReceived) { + this.onMessageReceived = this.webSocketProvider.onMessageReceived( + (msg: string) => { + const message: Monitor.Message = JSON.parse(msg); + + switch (message.command) { + case Monitor.ClientCommand.SEND_MESSAGE: + this.send(message.data as string); + break; + case Monitor.ClientCommand.CHANGE_SETTINGS: + this.changeSettings(message.data as MonitorSettings); + break; + } + } + ); + } + } + + updateClientsSettings(settings: MonitorSettings): void { + this.settings = { ...this.settings, ...settings }; + const command: Monitor.Message = { + command: Monitor.MiddlewareCommand.ON_SETTINGS_DID_CHANGE, + data: settings, + }; + + this.webSocketProvider.sendMessage(JSON.stringify(command)); + } + + /** + * Stops the necessary handlers to send and receive messages to + * and from the frontend and the running monitor + */ + private stopMessagesHandlers(): void { + if (this.flushMessagesInterval) { + clearInterval(this.flushMessagesInterval); + this.flushMessagesInterval = undefined; + } + if (this.onMessageReceived) { + this.onMessageReceived.dispose(); + this.onMessageReceived = undefined; + } + } +} + +/** + * Splits a string into an array without removing newline char. + * @param s string to split into lines + * @returns an lines array + */ +function splitLines(s: string): string[] { + return s.split(/(?<=\n)/); +} diff --git a/arduino-ide-extension/src/node/monitor-settings/monitor-settings-provider-impl.ts b/arduino-ide-extension/src/node/monitor-settings/monitor-settings-provider-impl.ts new file mode 100644 index 000000000..cde81cfd6 --- /dev/null +++ b/arduino-ide-extension/src/node/monitor-settings/monitor-settings-provider-impl.ts @@ -0,0 +1,130 @@ +import * as fs from 'fs'; +import { join } from 'path'; +import { injectable, inject, postConstruct } from 'inversify'; +import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; +import { FileUri } from '@theia/core/lib/node/file-uri'; +import { promisify } from 'util'; +import { + PluggableMonitorSettings, + MonitorSettingsProvider, +} from './monitor-settings-provider'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { + longestPrefixMatch, + reconcileSettings, +} from './monitor-settings-utils'; +import { ILogger } from '@theia/core'; + +const MONITOR_SETTINGS_FILE = 'pluggable-monitor-settings.json'; + +@injectable() +export class MonitorSettingsProviderImpl implements MonitorSettingsProvider { + @inject(EnvVariablesServer) + protected readonly envVariablesServer: EnvVariablesServer; + + @inject(ILogger) + protected logger: ILogger; + + // deferred used to guarantee file operations are performed after the service is initialized + protected ready = new Deferred(); + + // this contains actual values coming from the stored file and edited by the user + // this is a map with MonitorId as key and PluggableMonitorSetting as value + private monitorSettings: Record; + + // this is the path to the pluggable monitor settings file, set during init + private pluggableMonitorSettingsPath: string; + + @postConstruct() + protected async init(): Promise { + // get the monitor settings file path + const configDirUri = await this.envVariablesServer.getConfigDirUri(); + this.pluggableMonitorSettingsPath = join( + FileUri.fsPath(configDirUri), + MONITOR_SETTINGS_FILE + ); + + // read existing settings + await this.readSettingsFromFS(); + + // init is done, resolve the deferred and unblock any call that was waiting for it + this.ready.resolve(); + } + + async getSettings( + monitorId: string, + defaultSettings: PluggableMonitorSettings + ): Promise { + // wait for the service to complete the init + await this.ready.promise; + + const { matchingSettings } = this.longestPrefixMatch(monitorId); + + this.monitorSettings[monitorId] = this.reconcileSettings( + matchingSettings, + defaultSettings + ); + return this.monitorSettings[monitorId]; + } + + async setSettings( + monitorId: string, + settings: PluggableMonitorSettings + ): Promise { + // wait for the service to complete the init + await this.ready.promise; + + const newSettings = this.reconcileSettings( + settings, + this.monitorSettings[monitorId] || {} + ); + this.monitorSettings[monitorId] = newSettings; + + await this.writeSettingsToFS(); + return newSettings; + } + + private reconcileSettings( + newSettings: PluggableMonitorSettings, + defaultSettings: PluggableMonitorSettings + ): PluggableMonitorSettings { + return reconcileSettings(newSettings, defaultSettings); + } + + private async readSettingsFromFS(): Promise { + const rawJson = await promisify(fs.readFile)( + this.pluggableMonitorSettingsPath, + { + encoding: 'utf-8', + flag: 'a+', // a+ = append and read, creating the file if it doesn't exist + } + ); + + if (!rawJson) { + this.monitorSettings = {}; + } + + try { + this.monitorSettings = JSON.parse(rawJson); + } catch (error) { + this.logger.error( + 'Could not parse the pluggable monitor settings file. Using empty file.' + ); + this.monitorSettings = {}; + } + } + + private async writeSettingsToFS(): Promise { + await promisify(fs.writeFile)( + this.pluggableMonitorSettingsPath, + JSON.stringify(this.monitorSettings) + ); + } + + private longestPrefixMatch(id: string): { + matchingPrefix: string; + matchingSettings: PluggableMonitorSettings; + } { + return longestPrefixMatch(id, this.monitorSettings); + } +} diff --git a/arduino-ide-extension/src/node/monitor-settings/monitor-settings-provider.ts b/arduino-ide-extension/src/node/monitor-settings/monitor-settings-provider.ts new file mode 100644 index 000000000..e8949a60b --- /dev/null +++ b/arduino-ide-extension/src/node/monitor-settings/monitor-settings-provider.ts @@ -0,0 +1,20 @@ +import { MonitorModel } from '../../browser/monitor-model'; +import { PluggableMonitorSetting } from '../../common/protocol'; + +export type PluggableMonitorSettings = Record; +export interface MonitorSettings { + pluggableMonitorSettings?: PluggableMonitorSettings; + monitorUISettings?: Partial; +} + +export const MonitorSettingsProvider = Symbol('MonitorSettingsProvider'); +export interface MonitorSettingsProvider { + getSettings( + monitorId: string, + defaultSettings: PluggableMonitorSettings + ): Promise; + setSettings( + monitorId: string, + settings: PluggableMonitorSettings + ): Promise; +} diff --git a/arduino-ide-extension/src/node/monitor-settings/monitor-settings-utils.ts b/arduino-ide-extension/src/node/monitor-settings/monitor-settings-utils.ts new file mode 100644 index 000000000..3bcfc5775 --- /dev/null +++ b/arduino-ide-extension/src/node/monitor-settings/monitor-settings-utils.ts @@ -0,0 +1,81 @@ +import { PluggableMonitorSettings } from './monitor-settings-provider'; + +export function longestPrefixMatch( + id: string, + monitorSettings: Record +): { + matchingPrefix: string; + matchingSettings: PluggableMonitorSettings; +} { + const separator = '-'; + const idTokens = id.split(separator); + + let matchingPrefix = ''; + let matchingSettings: PluggableMonitorSettings = {}; + + const monitorSettingsKeys = Object.keys(monitorSettings); + + for (let i = idTokens.length - 1; i >= 0; i--) { + const prefix = idTokens.slice(0, i + 1).join(separator); + + for (let k = 0; k < monitorSettingsKeys.length; k++) { + if (monitorSettingsKeys[k].startsWith(prefix)) { + matchingPrefix = prefix; + matchingSettings = monitorSettings[monitorSettingsKeys[k]]; + break; + } + } + + if (matchingPrefix.length) { + break; + } + } + + return { matchingPrefix, matchingSettings }; +} + +export function reconcileSettings( + newSettings: PluggableMonitorSettings, + defaultSettings: PluggableMonitorSettings +): PluggableMonitorSettings { + // create a map with all the keys, merged together + const mergedSettingsKeys = Object.keys({ + ...defaultSettings, + ...newSettings, + }); + + // for every key in the settings, we need to check if it exist in the default + for (const key of mergedSettingsKeys) { + // remove from the newSettings if it was not found in the default + if (defaultSettings[key] === undefined) { + delete newSettings[key]; + } + // add to the newSettings if it was missing + else if (newSettings[key] === undefined) { + newSettings[key] = defaultSettings[key]; + } + // if the key is found in both, reconcile the settings + else { + // save the value set by the user + const value = newSettings[key].selectedValue; + + // settings needs to be overwritten with the defaults + newSettings[key] = defaultSettings[key]; + + // if there are no valid values defined, assume the one selected by the user is valid + // also use the value if it is a valid setting defined in the values + if ( + !Array.isArray(newSettings[key].values) || + newSettings[key].values.length === 0 || + newSettings[key].values.includes(value) + ) { + newSettings[key].selectedValue = value; + } else { + // if there are valid values but the user selected one that is not valid, fallback to the first valid one + newSettings[key].selectedValue = newSettings[key].values[0]; + } + } + } + + return newSettings; +} diff --git a/arduino-ide-extension/src/node/serial/monitor-client-provider.ts b/arduino-ide-extension/src/node/serial/monitor-client-provider.ts deleted file mode 100644 index 043548151..000000000 --- a/arduino-ide-extension/src/node/serial/monitor-client-provider.ts +++ /dev/null @@ -1,26 +0,0 @@ -import * as grpc from '@grpc/grpc-js'; -import { injectable } from '@theia/core/shared/inversify'; -import { MonitorServiceClient } from '../cli-protocol/cc/arduino/cli/monitor/v1/monitor_grpc_pb'; -import * as monitorGrpcPb from '../cli-protocol/cc/arduino/cli/monitor/v1/monitor_grpc_pb'; -import { GrpcClientProvider } from '../grpc-client-provider'; - -@injectable() -export class MonitorClientProvider extends GrpcClientProvider { - createClient(port: string | number): MonitorServiceClient { - // https://github.com/agreatfool/grpc_tools_node_protoc_ts/blob/master/doc/grpcjs_support.md#usage - const MonitorServiceClient = grpc.makeClientConstructor( - // @ts-expect-error: ignore - monitorGrpcPb['cc.arduino.cli.monitor.v1.MonitorService'], - 'MonitorServiceService' - ) as any; - return new MonitorServiceClient( - `localhost:${port}`, - grpc.credentials.createInsecure(), - this.channelOptions - ); - } - - close(client: MonitorServiceClient): void { - client.close(); - } -} diff --git a/arduino-ide-extension/src/node/serial/serial-service-impl.ts b/arduino-ide-extension/src/node/serial/serial-service-impl.ts deleted file mode 100644 index 1f5918a7c..000000000 --- a/arduino-ide-extension/src/node/serial/serial-service-impl.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { ClientDuplexStream } from '@grpc/grpc-js'; -import { TextEncoder } from 'util'; -import { injectable, inject, named } from '@theia/core/shared/inversify'; -import { Struct } from 'google-protobuf/google/protobuf/struct_pb'; -import { ILogger } from '@theia/core/lib/common/logger'; -import { - SerialService, - SerialServiceClient, - SerialConfig, - SerialError, - Status, -} from '../../common/protocol/serial-service'; -import { - StreamingOpenRequest, - StreamingOpenResponse, - MonitorConfig as GrpcMonitorConfig, -} from '../cli-protocol/cc/arduino/cli/monitor/v1/monitor_pb'; -import { MonitorClientProvider } from './monitor-client-provider'; -import { Board } from '../../common/protocol/boards-service'; -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 toSerialError( - error: Error, - config: SerialConfig - ): SerialError { - const { message } = error; - let code = undefined; - if (is(error)) { - // TODO: const `mapping`. Use regex for the `message`. - const mapping = new Map(); - mapping.set( - '1 CANCELLED: Cancelled on client', - SerialError.ErrorCodes.CLIENT_CANCEL - ); - mapping.set( - '2 UNKNOWN: device not configured', - SerialError.ErrorCodes.DEVICE_NOT_CONFIGURED - ); - mapping.set( - '2 UNKNOWN: error opening serial connection: Serial port busy', - SerialError.ErrorCodes.DEVICE_BUSY - ); - code = mapping.get(message); - } - return { - message, - code, - config, - }; - } - function is(error: Error & { code?: number }): error is ErrorWithCode { - return typeof error.code === 'number'; - } -} - -@injectable() -export class SerialServiceImpl implements SerialService { - protected theiaFEClient?: SerialServiceClient; - protected serialConfig?: SerialConfig; - - protected serialConnection?: { - duplex: ClientDuplexStream; - config: SerialConfig; - }; - protected messages: string[] = []; - protected onMessageReceived: Disposable | null; - protected onWSClientsNumberChanged: Disposable | null; - - protected flushMessagesInterval: NodeJS.Timeout | null; - - uploadInProgress = false; - - constructor( - @inject(ILogger) - @named(SerialServiceName) - protected readonly logger: ILogger, - - @inject(MonitorClientProvider) - protected readonly serialClientProvider: MonitorClientProvider, - - @inject(WebSocketService) - protected readonly webSocketService: WebSocketService - ) { } - - async isSerialPortOpen(): Promise { - return !!this.serialConnection; - } - - setClient(client: SerialServiceClient | undefined): void { - this.theiaFEClient = client; - - this.theiaFEClient?.notifyWebSocketChanged( - this.webSocketService.getAddress().port - ); - - // listen for the number of websocket clients and create or dispose the serial connection - this.onWSClientsNumberChanged = - this.webSocketService.onClientsNumberChanged(async () => { - await this.connectSerialIfRequired(); - }); - } - - public async clientsAttached(): Promise { - return this.webSocketService.getConnectedClientsNumber.bind( - this.webSocketService - )(); - } - - public async connectSerialIfRequired(): Promise { - if (this.uploadInProgress) return; - const clients = await this.clientsAttached(); - clients > 0 ? await this.connect() : await this.disconnect(); - } - - dispose(): void { - this.logger.info('>>> Disposing serial service...'); - if (this.serialConnection) { - this.disconnect(); - } - this.logger.info('<<< Disposed serial service.'); - this.theiaFEClient = undefined; - } - - async setSerialConfig(config: SerialConfig): Promise { - this.serialConfig = config; - await this.disconnect(); - await this.connectSerialIfRequired(); - } - - 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)); - } - - private async connect(): Promise { - if (!this.serialConfig) { - return Status.CONFIG_MISSING; - } - - this.logger.info( - `>>> Creating serial connection for ${Board.toString( - this.serialConfig.board - )} on port ${this.serialConfig.port.address}...` - ); - - if (this.serialConnection) { - return Status.ALREADY_CONNECTED; - } - const client = await this.serialClientProvider.client(); - if (!client) { - return Status.NOT_CONNECTED; - } - if (client instanceof Error) { - return { message: client.message }; - } - const duplex = client.streamingOpen(); - this.serialConnection = { duplex, config: this.serialConfig }; - - const serialConfig = this.serialConfig; - - duplex.on( - 'error', - ((error: Error) => { - const serialError = ErrorWithCode.toSerialError(error, serialConfig); - if (serialError.code !== SerialError.ErrorCodes.CLIENT_CANCEL) { - this.disconnect(serialError).then(() => { - if (this.theiaFEClient) { - this.theiaFEClient.notifyError(serialError); - } - }); - } - if (serialError.code === undefined) { - // Log the original, unexpected error. - this.logger.error(error); - } - }).bind(this) - ); - - this.updateWsConfigParam({ connected: !!this.serialConnection }); - - const flushMessagesToFrontend = () => { - if (this.messages.length) { - this.webSocketService.sendMessage(JSON.stringify(this.messages)); - this.messages = []; - } - }; - - 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.theiaFEClient?.notifyBaudRateChanged( - parseInt(message.data, 10) as SerialConfig.BaudRate - ); - break; - - case SerialPlotter.Protocol.Command.PLOTTER_SET_LINE_ENDING: - this.theiaFEClient?.notifyLineEndingChanged(message.data); - break; - - case SerialPlotter.Protocol.Command.PLOTTER_SET_INTERPOLATE: - this.theiaFEClient?.notifyInterpolateChanged(message.data); - break; - - default: - break; - } - } catch (error) { } - } - ); - - // empty the queue every 32ms (~30fps) - this.flushMessagesInterval = setInterval(flushMessagesToFrontend, 32); - - duplex.on( - 'data', - ((resp: StreamingOpenResponse) => { - const raw = resp.getData(); - const message = - typeof raw === 'string' ? raw : new TextDecoder('utf8').decode(raw); - - // split the message if it contains more lines - const messages = stringToArray(message); - this.messages.push(...messages); - }).bind(this) - ); - - const { type, port } = this.serialConfig; - const req = new StreamingOpenRequest(); - const monitorConfig = new GrpcMonitorConfig(); - monitorConfig.setType(this.mapType(type)); - monitorConfig.setTarget(port.address); - if (this.serialConfig.baudRate !== undefined) { - monitorConfig.setAdditionalConfig( - Struct.fromJavaScript({ BaudRate: this.serialConfig.baudRate }) - ); - } - req.setConfig(monitorConfig); - - if (!this.serialConnection) { - return await this.disconnect(); - } - - const writeTimeout = new Promise((resolve) => { - setTimeout(async () => { - resolve(Status.NOT_CONNECTED); - }, 1000); - }); - - const writePromise = (serialConnection: any) => { - return new Promise((resolve) => { - serialConnection.duplex.write(req, () => { - const boardName = this.serialConfig?.board - ? Board.toString(this.serialConfig.board, { - useFqbn: false, - }) - : 'unknown board'; - - const portName = this.serialConfig?.port - ? this.serialConfig.port.address - : 'unknown port'; - this.logger.info( - `<<< Serial connection created for ${boardName} on port ${portName}.` - ); - resolve(Status.OK); - }); - }); - }; - - const status = await Promise.race([ - writeTimeout, - writePromise(this.serialConnection), - ]); - - if (status === Status.NOT_CONNECTED) { - this.disconnect(); - } - - return status; - } - - public async disconnect(reason?: SerialError): Promise { - return new Promise((resolve) => { - try { - if (this.onMessageReceived) { - this.onMessageReceived.dispose(); - this.onMessageReceived = null; - } - if (this.flushMessagesInterval) { - clearInterval(this.flushMessagesInterval); - this.flushMessagesInterval = null; - } - - if ( - !this.serialConnection && - reason && - reason.code === SerialError.ErrorCodes.CLIENT_CANCEL - ) { - resolve(Status.OK); - return; - } - this.logger.info('>>> Disposing serial connection...'); - if (!this.serialConnection) { - this.logger.warn('<<< Not connected. Nothing to dispose.'); - resolve(Status.NOT_CONNECTED); - return; - } - const { duplex, config } = this.serialConnection; - - this.logger.info( - `<<< Disposed serial connection for ${Board.toString(config.board, { - useFqbn: false, - })} on port ${config.port.address}.` - ); - - duplex.cancel(); - } finally { - this.serialConnection = undefined; - this.updateWsConfigParam({ connected: !!this.serialConnection }); - this.messages.length = 0; - - setTimeout(() => { - resolve(Status.OK); - }, 200); - } - }); - } - - 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.serialConnection) { - this.serialConnection.duplex.write(req, () => { - resolve(Status.OK); - }); - return; - } - this.disconnect().then(() => resolve(Status.NOT_CONNECTED)); - }); - } - - protected mapType( - type?: SerialConfig.ConnectionType - ): GrpcMonitorConfig.TargetType { - switch (type) { - case SerialConfig.ConnectionType.SERIAL: - return GrpcMonitorConfig.TargetType.TARGET_TYPE_SERIAL; - default: - return GrpcMonitorConfig.TargetType.TARGET_TYPE_SERIAL; - } - } -} - -// converts 'ab\nc\nd' => [ab\n,c\n,d] -function stringToArray(string: string, separator = '\n') { - const retArray: string[] = []; - - let prevChar = separator; - - for (let i = 0; i < string.length; i++) { - const currChar = string[i]; - - if (prevChar === separator) { - retArray.push(currChar); - } else { - const lastWord = retArray[retArray.length - 1]; - retArray[retArray.length - 1] = lastWord + currChar; - } - - prevChar = currChar; - } - return retArray; -} diff --git a/arduino-ide-extension/src/node/web-socket/web-socket-service-impl.ts b/arduino-ide-extension/src/node/web-socket/web-socket-provider-impl.ts similarity index 91% rename from arduino-ide-extension/src/node/web-socket/web-socket-service-impl.ts rename to arduino-ide-extension/src/node/web-socket/web-socket-provider-impl.ts index b346c938d..463dadcf7 100644 --- a/arduino-ide-extension/src/node/web-socket/web-socket-service-impl.ts +++ b/arduino-ide-extension/src/node/web-socket/web-socket-provider-impl.ts @@ -1,10 +1,10 @@ import { Emitter } from '@theia/core'; import { injectable } from '@theia/core/shared/inversify'; import * as WebSocket from 'ws'; -import { WebSocketService } from './web-socket-service'; +import { WebSocketProvider } from './web-socket-provider'; @injectable() -export default class WebSocketServiceImpl implements WebSocketService { +export default class WebSocketProviderImpl implements WebSocketProvider { protected wsClients: WebSocket[]; protected server: WebSocket.Server; diff --git a/arduino-ide-extension/src/node/web-socket/web-socket-service.ts b/arduino-ide-extension/src/node/web-socket/web-socket-provider.ts similarity index 74% rename from arduino-ide-extension/src/node/web-socket/web-socket-service.ts rename to arduino-ide-extension/src/node/web-socket/web-socket-provider.ts index c793a07c4..6aa102040 100644 --- a/arduino-ide-extension/src/node/web-socket/web-socket-service.ts +++ b/arduino-ide-extension/src/node/web-socket/web-socket-provider.ts @@ -1,8 +1,8 @@ import { Event } from '@theia/core/lib/common/event'; import * as WebSocket from 'ws'; -export const WebSocketService = Symbol('WebSocketService'); -export interface WebSocketService { +export const WebSocketProvider = Symbol('WebSocketProvider'); +export interface WebSocketProvider { getAddress(): WebSocket.AddressInfo; sendMessage(message: string): void; onMessageReceived: Event; diff --git a/arduino-ide-extension/src/test/browser/fixtures/serial.ts b/arduino-ide-extension/src/test/browser/fixtures/serial.ts deleted file mode 100644 index ab8b333a6..000000000 --- a/arduino-ide-extension/src/test/browser/fixtures/serial.ts +++ /dev/null @@ -1,22 +0,0 @@ -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/node/monitor-settings-utils.test.ts b/arduino-ide-extension/src/test/node/monitor-settings-utils.test.ts new file mode 100644 index 000000000..dcaf1cbca --- /dev/null +++ b/arduino-ide-extension/src/test/node/monitor-settings-utils.test.ts @@ -0,0 +1,193 @@ +import { expect } from 'chai'; +import { + longestPrefixMatch, + reconcileSettings, +} from '../../node/monitor-settings/monitor-settings-utils'; +import { PluggableMonitorSettings } from '../../node/monitor-settings/monitor-settings-provider'; + +type DeepWriteable = { -readonly [P in keyof T]: DeepWriteable }; + +describe('longestPrefixMatch', () => { + const settings = { + 'arduino:avr:uno-port1-protocol1': { + name: 'Arduino Uno', + }, + 'arduino:avr:due-port1-protocol2': { + name: 'Arduino Due', + }, + }; + + it('should return the exact prefix when found', async () => { + const prefix = 'arduino:avr:uno-port1-protocol1'; + + const { matchingPrefix } = longestPrefixMatch( + prefix, + settings as unknown as Record + ); + + expect(matchingPrefix).to.equal(prefix); + }); + + it('should return the exact object when the prefix match', async () => { + const prefix = 'arduino:avr:uno-port1-protocol1'; + + const { matchingSettings } = longestPrefixMatch( + prefix, + settings as unknown as Record + ); + + expect(matchingSettings).to.have.property('name').to.equal('Arduino Uno'); + }); + + it('should return a partial matching prefix when a similar object is found', async () => { + const prefix = 'arduino:avr:due-port2-protocol2'; + + const { matchingPrefix } = longestPrefixMatch( + prefix, + settings as unknown as Record + ); + + expect(matchingPrefix).to.equal('arduino:avr:due'); + }); + + it('should return the closest object when the prefix partially match', async () => { + const prefix = 'arduino:avr:uno-port1-protocol2'; + + const { matchingSettings } = longestPrefixMatch( + prefix, + settings as unknown as Record + ); + + expect(matchingSettings).to.have.property('name').to.equal('Arduino Uno'); + }); + + it('should return an empty matching prefix when no similar object is found', async () => { + const prefix = 'arduino:avr:tre-port2-protocol2'; + + const { matchingPrefix } = longestPrefixMatch( + prefix, + settings as unknown as Record + ); + + expect(matchingPrefix).to.equal(''); + }); + + it('should return an empty object when no similar object is found', async () => { + const prefix = 'arduino:avr:tre-port1-protocol2'; + + const { matchingSettings } = longestPrefixMatch( + prefix, + settings as unknown as Record + ); + + expect(matchingSettings).to.be.empty; + }); +}); + +describe('reconcileSettings', () => { + const defaultSettings = { + setting1: { + id: 'setting1', + label: 'Label setting1', + type: 'enum', + values: ['a', 'b', 'c'], + selectedValue: 'b', + }, + setting2: { + id: 'setting2', + label: 'Label setting2', + type: 'enum', + values: ['a', 'b', 'c'], + selectedValue: 'b', + }, + setting3: { + id: 'setting3', + label: 'Label setting3', + type: 'enum', + values: ['a', 'b', 'c'], + selectedValue: 'b', + }, + }; + + it('should return default settings if new settings are missing', async () => { + const newSettings: PluggableMonitorSettings = {}; + + const reconciledSettings = reconcileSettings(newSettings, defaultSettings); + + expect(reconciledSettings).to.deep.equal(defaultSettings); + }); + + it('should add missing attributes copying it from the default settings', async () => { + const newSettings: PluggableMonitorSettings = JSON.parse( + JSON.stringify(defaultSettings) + ); + delete newSettings.setting2; + + const reconciledSettings = reconcileSettings(newSettings, defaultSettings); + + expect(reconciledSettings).to.have.property('setting2'); + }); + it('should remove wrong settings attributes using the default settings as a reference', async () => { + const newSettings: PluggableMonitorSettings = JSON.parse( + JSON.stringify(defaultSettings) + ); + newSettings['setting4'] = defaultSettings.setting3; + + const reconciledSettings = reconcileSettings(newSettings, defaultSettings); + + expect(reconciledSettings).not.to.have.property('setting4'); + }); + it('should reset non-value fields to those defiend in the default settings', async () => { + const newSettings: DeepWriteable = JSON.parse( + JSON.stringify(defaultSettings) + ); + newSettings['setting2'].id = 'fake id'; + + const reconciledSettings = reconcileSettings(newSettings, defaultSettings); + + expect(reconciledSettings.setting2) + .to.have.property('id') + .equal('setting2'); + }); + it('should accept a selectedValue if it is a valid one', async () => { + const newSettings: PluggableMonitorSettings = JSON.parse( + JSON.stringify(defaultSettings) + ); + newSettings.setting2.selectedValue = 'c'; + + const reconciledSettings = reconcileSettings(newSettings, defaultSettings); + + expect(reconciledSettings.setting2) + .to.have.property('selectedValue') + .to.equal('c'); + }); + it('should fall a back to the first valid setting when the selectedValue is not valid', async () => { + const newSettings: PluggableMonitorSettings = JSON.parse( + JSON.stringify(defaultSettings) + ); + newSettings.setting2.selectedValue = 'z'; + + const reconciledSettings = reconcileSettings(newSettings, defaultSettings); + + expect(reconciledSettings.setting2) + .to.have.property('selectedValue') + .to.equal('a'); + }); + it('should accept any value if default values are not set', async () => { + const wrongDefaults: DeepWriteable = JSON.parse( + JSON.stringify(defaultSettings) + ); + wrongDefaults.setting2.values = []; + + const newSettings: PluggableMonitorSettings = JSON.parse( + JSON.stringify(wrongDefaults) + ); + newSettings.setting2.selectedValue = 'z'; + + const reconciledSettings = reconcileSettings(newSettings, wrongDefaults); + + expect(reconciledSettings.setting2) + .to.have.property('selectedValue') + .to.equal('z'); + }); +}); diff --git a/arduino-ide-extension/src/test/node/serial-service-impl.test.ts b/arduino-ide-extension/src/test/node/serial-service-impl.test.ts deleted file mode 100644 index 141c240a3..000000000 --- a/arduino-ide-extension/src/test/node/serial-service-impl.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { SerialServiceImpl } from './../../node/serial/serial-service-impl'; -import { IMock, It, Mock } from 'typemoq'; -import { createSandbox } from 'sinon'; -import * as sinonChai from 'sinon-chai'; -import { expect, use } from 'chai'; -use(sinonChai); - -import { ILogger } from '@theia/core/lib/common/logger'; -import { MonitorClientProvider } from '../../node/serial/monitor-client-provider'; -import { WebSocketService } from '../../node/web-socket/web-socket-service'; -import { MonitorServiceClient } from '../../node/cli-protocol/cc/arduino/cli/monitor/v1/monitor_grpc_pb'; -import { Status } from '../../common/protocol'; - -describe('SerialServiceImpl', () => { - let subject: SerialServiceImpl; - - let logger: IMock; - let serialClientProvider: IMock; - let webSocketService: IMock; - - beforeEach(() => { - logger = Mock.ofType(); - logger.setup((b) => b.info(It.isAnyString())); - logger.setup((b) => b.warn(It.isAnyString())); - logger.setup((b) => b.error(It.isAnyString())); - - serialClientProvider = Mock.ofType(); - webSocketService = Mock.ofType(); - - subject = new SerialServiceImpl( - logger.object, - serialClientProvider.object, - webSocketService.object - ); - }); - - context('when a serial connection is requested', () => { - const sandbox = createSandbox(); - beforeEach(() => { - subject.uploadInProgress = false; - sandbox.spy(subject, 'disconnect'); - sandbox.spy(subject, 'updateWsConfigParam'); - }); - - afterEach(function () { - sandbox.restore(); - }); - - context('and an upload is in progress', () => { - beforeEach(async () => { - subject.uploadInProgress = true; - }); - - it('should not change the connection status', async () => { - await subject.connectSerialIfRequired(); - expect(subject.disconnect).to.have.callCount(0); - }); - }); - - context('and there is no upload in progress', () => { - beforeEach(async () => { - subject.uploadInProgress = false; - }); - - context('and there are 0 attached ws clients', () => { - it('should disconnect', async () => { - await subject.connectSerialIfRequired(); - expect(subject.disconnect).to.have.been.calledOnce; - }); - }); - - context('and there are > 0 attached ws clients', () => { - beforeEach(() => { - webSocketService - .setup((b) => b.getConnectedClientsNumber()) - .returns(() => 1); - }); - - it('should not call the disconenct', async () => { - await subject.connectSerialIfRequired(); - expect(subject.disconnect).to.have.callCount(0); - }); - }); - }); - }); - - context('when a disconnection is requested', () => { - const sandbox = createSandbox(); - beforeEach(() => { }); - - afterEach(function () { - sandbox.restore(); - }); - - context('and a serialConnection is not set', () => { - it('should return a NOT_CONNECTED status', async () => { - const status = await subject.disconnect(); - expect(status).to.be.equal(Status.NOT_CONNECTED); - }); - }); - - context('and a serialConnection is set', async () => { - beforeEach(async () => { - sandbox.spy(subject, 'updateWsConfigParam'); - await subject.disconnect(); - }); - - it('should dispose the serialConnection', async () => { - const serialConnectionOpen = await subject.isSerialPortOpen(); - expect(serialConnectionOpen).to.be.false; - }); - - it('should call updateWsConfigParam with disconnected status', async () => { - expect(subject.updateWsConfigParam).to.be.calledWith({ - connected: false, - }); - }); - }); - }); - - context('when a new config is passed in', () => { - const sandbox = createSandbox(); - beforeEach(async () => { - subject.uploadInProgress = false; - webSocketService - .setup((b) => b.getConnectedClientsNumber()) - .returns(() => 1); - - serialClientProvider - .setup((b) => b.client()) - .returns(async () => { - return { - streamingOpen: () => { - return { - on: (str: string, cb: any) => { }, - write: (chunk: any, cb: any) => { - cb(); - }, - cancel: () => { }, - }; - }, - } as MonitorServiceClient; - }); - - sandbox.spy(subject, 'disconnect'); - - await subject.setSerialConfig({ - board: { name: 'test' }, - port: { id: 'test|test', address: 'test', addressLabel: 'test', protocol: 'test', protocolLabel: 'test' }, - }); - }); - - afterEach(function () { - sandbox.restore(); - subject.dispose(); - }); - - it('should disconnect from previous connection', async () => { - expect(subject.disconnect).to.be.called; - }); - - it('should create the serialConnection', async () => { - const serialConnectionOpen = await subject.isSerialPortOpen(); - expect(serialConnectionOpen).to.be.true; - }); - }); -}); diff --git a/i18n/en.json b/i18n/en.json index 09659b6fb..959f0e989 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -215,6 +215,10 @@ "sketch": "Sketch", "tools": "Tools" }, + "monitor": { + "unableToCloseWebSocket": "Unable to close websocket", + "unableToConnectToWebSocket": "Unable to connect to websocket" + }, "preferences": { "additionalManagerURLs": "Additional Boards Manager URLs", "auth.audience": "The OAuth2 audience.", @@ -264,25 +268,19 @@ "serial": { "autoscroll": "Autoscroll", "carriageReturn": "Carriage Return", - "connectionBusy": "Connection failed. Serial port is busy: {0}", - "disconnected": "Disconnected {0} from {1}.", - "failedReconnect": "Failed to reconnect {0} to serial port after 10 consecutive attempts. The {1} serial port is busy.", "message": "Message ({0} + Enter to send message to '{1}' on '{2}')", "newLine": "New Line", "newLineCarriageReturn": "Both NL & CR", "noLineEndings": "No Line Ending", "notConnected": "Not connected. Select a board and a port to connect automatically.", - "reconnect": "Reconnecting {0} to {1} in {2} seconds...", "timestamp": "Timestamp", - "toggleTimestamp": "Toggle Timestamp", - "unexpectedError": "Unexpected error. Reconnecting {0} on port {1}." + "toggleTimestamp": "Toggle Timestamp" }, "sketch": { "archiveSketch": "Archive Sketch", "cantOpen": "A folder named \"{0}\" already exists. Can't open sketch.", "close": "Are you sure you want to close the sketch?", "configureAndUpload": "Configure And Upload", - "couldNotConnectToSerial": "Could not reconnect to serial port. {0}", "createdArchive": "Created archive '{0}'.", "doneCompiling": "Done compiling.", "doneUploading": "Done uploading.", diff --git a/yarn.lock b/yarn.lock index b53bf4248..28eca1e30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4271,10 +4271,10 @@ archive-type@^4.0.0: dependencies: file-type "^4.2.0" -arduino-serial-plotter-webapp@0.0.17: - version "0.0.17" - resolved "https://registry.yarnpkg.com/arduino-serial-plotter-webapp/-/arduino-serial-plotter-webapp-0.0.17.tgz#9a304df2a2fc95d9ec812b0d56288643292dd151" - integrity sha512-JGXFm2uJ+izzhk45ayq1ioXJOi5IZyK9De9fjCHCJKvc3BSGqBToZmRr3r1W5GPMfO88ySrGn9pfzZQtgI8Isg== +arduino-serial-plotter-webapp@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/arduino-serial-plotter-webapp/-/arduino-serial-plotter-webapp-0.1.0.tgz#fa631483a93a12acd89d7bbe0487a3c0e57fac9f" + integrity sha512-0gHDGDz6guIC7Y8JXHaUad0RoueG2A+ykKNY1yo59+hWGbkM37hdRy4GKLsOkn0NMqU1TjnWmQHaSmYJjD1cAQ== are-we-there-yet@^2.0.0: version "2.0.0"