From 692c3f6e3f0f47436468f886b64663aa92dcb4a9 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Thu, 1 Aug 2019 08:22:29 +0200 Subject: [PATCH] Implemented serial-monitoring for the backend. Signed-off-by: Akos Kitta --- .../src/browser/arduino-commands.ts | 10 ++ .../browser/arduino-frontend-contribution.tsx | 86 ++++++++- .../src/browser/arduino-frontend-module.ts | 16 ++ .../monitor/monitor-service-client-impl.ts | 23 +++ .../src/common/protocol/monitor-service.ts | 43 +++++ .../src/node/arduino-backend-module.ts | 24 +++ .../node/monitor/monitor-client-provider.ts | 20 +++ .../src/node/monitor/monitor-service-impl.ts | 164 ++++++++++++++++++ 8 files changed, 383 insertions(+), 3 deletions(-) create mode 100644 arduino-ide-extension/src/browser/monitor/monitor-service-client-impl.ts create mode 100644 arduino-ide-extension/src/common/protocol/monitor-service.ts create mode 100644 arduino-ide-extension/src/node/monitor/monitor-client-provider.ts create mode 100644 arduino-ide-extension/src/node/monitor/monitor-service-impl.ts diff --git a/arduino-ide-extension/src/browser/arduino-commands.ts b/arduino-ide-extension/src/browser/arduino-commands.ts index e56f4e2d..0629e024 100644 --- a/arduino-ide-extension/src/browser/arduino-commands.ts +++ b/arduino-ide-extension/src/browser/arduino-commands.ts @@ -43,4 +43,14 @@ export namespace ArduinoCommands { id: "arduino-toggle-pro-mode" } + export const CONNECT_TODO: Command = { + id: 'connect-to-attached-board', + label: 'Connect to Attached Board' + } + + export const SEND: Command = { + id: 'send', + label: 'Send a Message to the Connected Board' + } + } diff --git a/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx b/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx index 314956fb..e62c192e 100644 --- a/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx +++ b/arduino-ide-extension/src/browser/arduino-frontend-contribution.tsx @@ -5,7 +5,7 @@ import { EditorWidget } from '@theia/editor/lib/browser/editor-widget'; import { MessageService } from '@theia/core/lib/common/message-service'; import { CommandContribution, CommandRegistry, Command } from '@theia/core/lib/common/command'; import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; -import { BoardsService } from '../common/protocol/boards-service'; +import { BoardsService, AttachedSerialBoard } from '../common/protocol/boards-service'; import { ArduinoCommands } from './arduino-commands'; import { CoreService } from '../common/protocol/core-service'; import { WorkspaceServiceExt } from './workspace-service-ext'; @@ -19,7 +19,18 @@ import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service import { SketchFactory } from './sketch-factory'; import { ArduinoToolbar } from './toolbar/arduino-toolbar'; import { EditorManager, EditorMainMenu } from '@theia/editor/lib/browser'; -import { ContextMenuRenderer, OpenerService, Widget, StatusBar, ShellLayoutRestorer, StatusBarAlignment, LabelProvider } from '@theia/core/lib/browser'; +import { + ContextMenuRenderer, + OpenerService, + Widget, + StatusBar, + ShellLayoutRestorer, + StatusBarAlignment, + QuickOpenItem, + QuickOpenMode, + QuickOpenService, + LabelProvider +} from '@theia/core/lib/browser'; import { OpenFileDialogProps, FileDialogService } from '@theia/filesystem/lib/browser/file-dialog'; import { FileSystem, FileStat } from '@theia/filesystem/lib/common'; import { ArduinoToolbarContextMenu } from './arduino-file-menu'; @@ -34,6 +45,7 @@ import { MaybePromise } from '@theia/core/lib/common/types'; import { BoardsConfigDialog } from './boards/boards-config-dialog'; import { BoardsToolBarItem } from './boards/boards-toolbar-item'; import { BoardsConfig } from './boards/boards-config'; +import { MonitorService } from '../common/protocol/monitor-service'; export namespace ArduinoMenus { export const SKETCH = [...MAIN_MENU_BAR, '3_sketch']; @@ -56,6 +68,12 @@ export class ArduinoFrontendContribution implements TabBarToolbarContribution, C @inject(CoreService) protected readonly coreService: CoreService; + @inject(MonitorService) + protected readonly monitorService: MonitorService; + + // TODO: make this better! + protected connectionId: string | undefined; + @inject(WorkspaceServiceExt) protected readonly workspaceServiceExt: WorkspaceServiceExt; @@ -115,6 +133,9 @@ export class ArduinoFrontendContribution implements TabBarToolbarContribution, C @inject(LabelProvider) protected readonly labelProvider: LabelProvider; + + @inject(QuickOpenService) + protected readonly quickOpenService: QuickOpenService; protected boardsToolbarItem: BoardsToolBarItem | null; protected wsSketchCount: number = 0; @@ -293,7 +314,7 @@ export class ArduinoFrontendContribution implements TabBarToolbarContribution, C this.boardsServiceClient.boardsConfig = boardsConfig; } } - }) + }); registry.registerCommand(ArduinoCommands.TOGGLE_PRO_MODE, { execute: () => { const oldModeState = ARDUINO_PRO_MODE; @@ -301,6 +322,65 @@ export class ArduinoFrontendContribution implements TabBarToolbarContribution, C registry.executeCommand('reset.layout'); }, isToggled: () => ARDUINO_PRO_MODE + }); + registry.registerCommand(ArduinoCommands.CONNECT_TODO, { + execute: async () => { + const { boardsConfig } = this.boardsServiceClient; + const { selectedBoard, selectedPort } = boardsConfig; + if (!selectedBoard) { + this.messageService.warn('No boards selected.'); + return; + } + const { name } = selectedBoard; + if (!selectedPort) { + this.messageService.warn(`No ports selected for board: '${name}'.`); + return; + } + const attachedBoards = await this.boardsService.getAttachedBoards(); + const connectedBoard = attachedBoards.boards.filter(AttachedSerialBoard.is).find(board => BoardsConfig.Config.sameAs(boardsConfig, board)); + if (!connectedBoard) { + this.messageService.warn(`The selected '${name}' board is not connected on ${selectedPort}.`); + return; + } + if (this.connectionId) { + console.log('>>> Disposing existing monitor connection before establishing a new one...'); + const result = await this.monitorService.disconnect(this.connectionId); + if (!result) { + // TODO: better!!! + console.error(`Could not close connection: ${this.connectionId}. Check the backend logs.`); + } else { + console.log(`<<< Disposed ${this.connectionId} connection.`) + } + } + const { connectionId } = await this.monitorService.connect({ board: selectedBoard, port: selectedPort }); + this.connectionId = connectionId; + } + }); + registry.registerCommand(ArduinoCommands.SEND, { + isEnabled: () => !!this.connectionId, + execute: async () => { + const { monitorService, connectionId } = this; + const model = { + onType(lookFor: string, acceptor: (items: QuickOpenItem[]) => void): void { + acceptor([ + new QuickOpenItem({ + label: "Type your message and press 'Enter' to send it to the board. Escape to cancel.", + run: (mode: QuickOpenMode): boolean => { + if (mode !== QuickOpenMode.OPEN) { + return false; + } + monitorService.send(connectionId!, lookFor + '\n'); + return true; + } + }) + ]); + } + }; + const options = { + placeholder: "Your message. The message will be suffixed with a LF ['\\n'].", + }; + this.quickOpenService.open(model, options); + } }) } diff --git a/arduino-ide-extension/src/browser/arduino-frontend-module.ts b/arduino-ide-extension/src/browser/arduino-frontend-module.ts index 2ea69840..2cd6b476 100644 --- a/arduino-ide-extension/src/browser/arduino-frontend-module.ts +++ b/arduino-ide-extension/src/browser/arduino-frontend-module.ts @@ -54,6 +54,8 @@ import { SilentSearchInWorkspaceContribution } from './customization/silent-sear import { LibraryListWidgetFrontendContribution } from './library/library-widget-frontend-contribution'; import { LibraryItemRenderer } from './library/library-item-renderer'; import { BoardItemRenderer } from './boards/boards-item-renderer'; +import { MonitorServiceClientImpl } from './monitor/monitor-service-client-impl'; +import { MonitorServicePath, MonitorService, MonitorServiceClient } from '../common/protocol/monitor-service'; const ElementQueries = require('css-element-queries/src/ElementQueries'); if (!ARDUINO_PRO_MODE) { @@ -149,6 +151,20 @@ export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Un return workspaceServiceExt; }); + // Frontend binding for the monitor service. + bind(MonitorService).toDynamicValue(context => { + const connection = context.container.get(WebSocketConnectionProvider); + const client = context.container.get(MonitorServiceClientImpl); + return connection.createProxy(MonitorServicePath, client); + }).inSingletonScope(); + // Monitor service client to receive and delegate notifications from the backend. + bind(MonitorServiceClientImpl).toSelf().inSingletonScope(); + bind(MonitorServiceClient).toDynamicValue(context => { + const client = context.container.get(MonitorServiceClientImpl); + WebSocketConnectionProvider.createProxy(context.container, MonitorServicePath, client); + return client; + }).inSingletonScope(); + bind(AWorkspaceService).toSelf().inSingletonScope(); rebind(WorkspaceService).to(AWorkspaceService).inSingletonScope(); bind(SketchFactory).toSelf().inSingletonScope(); diff --git a/arduino-ide-extension/src/browser/monitor/monitor-service-client-impl.ts b/arduino-ide-extension/src/browser/monitor/monitor-service-client-impl.ts new file mode 100644 index 00000000..28ea0b2c --- /dev/null +++ b/arduino-ide-extension/src/browser/monitor/monitor-service-client-impl.ts @@ -0,0 +1,23 @@ +import { injectable } from 'inversify'; +import { Emitter } from '@theia/core/lib/common/event'; +import { MonitorServiceClient, MonitorReadEvent, MonitorError } from '../../common/protocol/monitor-service'; + +@injectable() +export class MonitorServiceClientImpl implements MonitorServiceClient { + + protected readonly onReadEmitter = new Emitter(); + protected readonly onErrorEmitter = new Emitter(); + readonly onRead = this.onReadEmitter.event; + readonly onError = this.onErrorEmitter.event; + + notifyRead(event: MonitorReadEvent): void { + this.onReadEmitter.fire(event); + const { connectionId, data } = event; + console.log(`Received data from ${connectionId}: ${data}`); + } + + notifyError(error: MonitorError): void { + this.onErrorEmitter.fire(error); + } + +} 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 00000000..62b2893a --- /dev/null +++ b/arduino-ide-extension/src/common/protocol/monitor-service.ts @@ -0,0 +1,43 @@ +import { JsonRpcServer } from '@theia/core'; +import { Board } from './boards-service'; + +export interface MonitorError { + readonly message: string; + readonly code: number +} + +export interface MonitorReadEvent { + readonly connectionId: string; + readonly data: string; +} + +export const MonitorServiceClient = Symbol('MonitorServiceClient'); +export interface MonitorServiceClient { + notifyRead(event: MonitorReadEvent): void; + notifyError(event: MonitorError): void; +} + +export const MonitorServicePath = '/services/serial-monitor'; +export const MonitorService = Symbol('MonitorService'); +export interface MonitorService extends JsonRpcServer { + connect(config: ConnectionConfig): Promise<{ connectionId: string }>; + disconnect(connectionId: string): Promise; + send(connectionId: string, data: string | Uint8Array): Promise; +} + +export interface ConnectionConfig { + readonly board: Board; + readonly port: string; + /** + * Defaults to [`SERIAL`](ConnectionType#SERIAL). + */ + readonly type?: ConnectionType; + /** + * Defaults to `9600`. + */ + readonly baudRate?: number; +} + +export enum ConnectionType { + SERIAL = 0 +} diff --git a/arduino-ide-extension/src/node/arduino-backend-module.ts b/arduino-ide-extension/src/node/arduino-backend-module.ts index 296835d8..3d50f866 100644 --- a/arduino-ide-extension/src/node/arduino-backend-module.ts +++ b/arduino-ide-extension/src/node/arduino-backend-module.ts @@ -19,6 +19,9 @@ import { DefaultWorkspaceServerExt } from './default-workspace-server-ext'; import { WorkspaceServer } from '@theia/workspace/lib/common'; import { SketchesServiceImpl } from './sketches-service-impl'; import { SketchesService, SketchesServicePath } from '../common/protocol/sketches-service'; +import { MonitorServiceImpl } from './monitor/monitor-service-impl'; +import { MonitorService, MonitorServicePath, MonitorServiceClient } from '../common/protocol/monitor-service'; +import { MonitorClientProvider } from './monitor/monitor-client-provider'; export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(ArduinoDaemon).toSelf().inSingletonScope(); @@ -104,4 +107,25 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { // If nothing was set previously. bind(DefaultWorkspaceServerExt).toSelf().inSingletonScope(); rebind(WorkspaceServer).toService(DefaultWorkspaceServerExt); + + // Shared monitor client provider service for the backend. + bind(MonitorClientProvider).toSelf().inSingletonScope(); + + // Connection scoped service for the serial monitor. + const monitorServiceConnectionModule = ConnectionContainerModule.create(({ bind, bindBackendService }) => { + bind(MonitorServiceImpl).toSelf().inSingletonScope(); + bind(MonitorService).toService(MonitorServiceImpl); + bindBackendService(MonitorServicePath, MonitorService, (service, client) => { + service.setClient(client); + client.onDidCloseConnection(() => service.dispose()); + return service; + }); + }); + bind(ConnectionContainerModule).toConstantValue(monitorServiceConnectionModule); + + // Logger for the monitor service. + bind(ILogger).toDynamicValue(ctx => { + const parentLogger = ctx.container.get(ILogger); + return parentLogger.child('monitor-service'); + }).inSingletonScope().whenTargetNamed('monitor-service'); }); diff --git a/arduino-ide-extension/src/node/monitor/monitor-client-provider.ts b/arduino-ide-extension/src/node/monitor/monitor-client-provider.ts new file mode 100644 index 00000000..609f0df9 --- /dev/null +++ b/arduino-ide-extension/src/node/monitor/monitor-client-provider.ts @@ -0,0 +1,20 @@ +import * as grpc from '@grpc/grpc-js'; +import { injectable, postConstruct } from 'inversify'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { MonitorClient } from '../cli-protocol/monitor/monitor_grpc_pb'; + +@injectable() +export class MonitorClientProvider { + + readonly deferred = new Deferred(); + + @postConstruct() + protected init(): void { + this.deferred.resolve(new MonitorClient('localhost:50051', grpc.credentials.createInsecure())); + } + + get client(): Promise { + return this.deferred.promise; + } + +} diff --git a/arduino-ide-extension/src/node/monitor/monitor-service-impl.ts b/arduino-ide-extension/src/node/monitor/monitor-service-impl.ts new file mode 100644 index 00000000..90eeeb0b --- /dev/null +++ b/arduino-ide-extension/src/node/monitor/monitor-service-impl.ts @@ -0,0 +1,164 @@ +import { v4 } from 'uuid'; +import * as grpc from '@grpc/grpc-js'; +import { TextDecoder, TextEncoder } from 'util'; +import { injectable, inject, named } from 'inversify'; +import { ILogger, Disposable, DisposableCollection } from '@theia/core'; +import { MonitorService, MonitorServiceClient, ConnectionConfig, ConnectionType } from '../../common/protocol/monitor-service'; +import { StreamingOpenReq, StreamingOpenResp, MonitorConfig } from '../cli-protocol/monitor/monitor_pb'; +import { MonitorClientProvider } from './monitor-client-provider'; + +export interface MonitorDuplex { + readonly toDispose: Disposable; + readonly duplex: grpc.ClientDuplexStream; +} + +type ErrorCode = { code: number }; +type MonitorError = Error & ErrorCode; +namespace MonitorError { + + export function is(error: Error & Partial): error is MonitorError { + return typeof error.code === 'number'; + } + + /** + * The frontend has refreshed the browser, for instance. + */ + export function isClientCancelledError(error: MonitorError): boolean { + return error.code === 1 && error.message === 'Cancelled on client'; + } + + /** + * When detaching a physical device when the duplex channel is still opened. + */ + export function isDeviceNotConfiguredError(error: MonitorError): boolean { + return error.code === 2 && error.message === 'device not configured'; + } + +} + +@injectable() +export class MonitorServiceImpl implements MonitorService { + + @inject(ILogger) + @named('monitor-service') + protected readonly logger: ILogger; + + @inject(MonitorClientProvider) + protected readonly monitorClientProvider: MonitorClientProvider; + + protected client?: MonitorServiceClient; + protected readonly connections = new Map(); + + setClient(client: MonitorServiceClient | undefined): void { + this.client = client; + } + + dispose(): void { + for (const [connectionId, duplex] of this.connections.entries()) { + this.doDisconnect(connectionId, duplex); + } + } + + async connect(config: ConnectionConfig): Promise<{ connectionId: string }> { + const client = await this.monitorClientProvider.client; + const duplex = client.streamingOpen(); + const connectionId = v4(); + const toDispose = new DisposableCollection( + Disposable.create(() => this.disconnect(connectionId)) + ); + + duplex.on('error', ((error: Error) => { + if (MonitorError.is(error) && ( + MonitorError.isClientCancelledError(error) + || MonitorError.isDeviceNotConfiguredError(error) + )) { + if (this.client) { + this.client.notifyError(error); + } + } + this.logger.error(`Error occurred for connection ${connectionId}.`, error); + toDispose.dispose(); + }).bind(this)); + + duplex.on('data', ((resp: StreamingOpenResp) => { + if (this.client) { + const raw = resp.getData(); + const data = typeof raw === 'string' ? raw : new TextDecoder('utf8').decode(raw); + this.client.notifyRead({ connectionId, data }); + } + }).bind(this)); + + const { type, port } = config; + const req = new StreamingOpenReq(); + const monitorConfig = new MonitorConfig(); + monitorConfig.setType(this.mapType(type)); + monitorConfig.setTarget(port); + if (config.baudRate !== undefined) { + monitorConfig.setAdditionalconfig({ 'BaudRate': config.baudRate }); + } + req.setMonitorconfig(monitorConfig); + + return new Promise<{ connectionId: string }>(resolve => { + duplex.write(req, () => { + this.connections.set(connectionId, { toDispose, duplex }); + resolve({ connectionId }); + }); + }); + } + + async disconnect(connectionId: string): Promise { + this.logger.info(`>>> Received disconnect request for connection: ${connectionId}`); + const disposable = this.connections.get(connectionId); + if (!disposable) { + this.logger.warn(`<<< No connection was found for ID: ${connectionId}`); + return false; + } + const result = await this.doDisconnect(connectionId, disposable); + if (result) { + this.logger.info(`<<< Successfully disconnected from ${connectionId}.`); + } else { + this.logger.info(`<<< Could not disconnected from ${connectionId}.`); + } + return result; + } + + protected async doDisconnect(connectionId: string, duplex: MonitorDuplex): Promise { + const { toDispose } = duplex; + this.logger.info(`>>> Disposing monitor connection: ${connectionId}...`); + try { + toDispose.dispose(); + this.logger.info(`<<< Connection disposed: ${connectionId}.`); + return true; + } catch (e) { + this.logger.error(`<<< Error occurred when disposing monitor connection: ${connectionId}. ${e}`); + return false; + } + } + + async send(connectionId: string, data: string): Promise { + const duplex = this.duplex(connectionId); + if (duplex) { + const req = new StreamingOpenReq(); + req.setData(new TextEncoder().encode(data)); + return new Promise(resolve => duplex.duplex.write(req, resolve)); + } else { + throw new Error(`No connection with ID: ${connectionId}.`); + } + } + + protected mapType(type?: ConnectionType): MonitorConfig.TargetType { + switch (type) { + case ConnectionType.SERIAL: return MonitorConfig.TargetType.SERIAL; + default: return MonitorConfig.TargetType.SERIAL; + } + } + + protected duplex(connectionId: string): MonitorDuplex | undefined { + const monitorClient = this.connections.get(connectionId); + if (!monitorClient) { + this.logger.warn(`Could not find monitor client for connection ID: ${connectionId}`); + } + return monitorClient; + } + +}