Serial Plotter implementation (#597)

* spawn new window where to instantiate serial plotter app

* initialize serial monito web app

* connect serial plotter app with websocket

* use npm serial-plotter package

* refactor monitor connection and fix some connection issues

* fix clearConsole + refactor monitor connection

* add serial unit tests

* refactoring and cleaning code
This commit is contained in:
Alberto Iannaccone
2021-11-23 18:18:20 +01:00
committed by GitHub
parent 9863dc2f90
commit 20f7712129
40 changed files with 1670 additions and 821 deletions

View File

@@ -40,13 +40,16 @@ import {
ArduinoDaemon,
ArduinoDaemonPath,
} from '../common/protocol/arduino-daemon';
import { MonitorServiceImpl } from './monitor/monitor-service-impl';
import {
MonitorService,
MonitorServicePath,
MonitorServiceClient,
} from '../common/protocol/monitor-service';
import { MonitorClientProvider } from './monitor/monitor-client-provider';
SerialServiceImpl,
SerialServiceName,
} from './serial/serial-service-impl';
import {
SerialService,
SerialServicePath,
SerialServiceClient,
} from '../common/protocol/serial-service';
import { MonitorClientProvider } from './serial/monitor-client-provider';
import { ConfigServiceImpl } from './config-service-impl';
import { EnvVariablesServer as TheiaEnvVariablesServer } from '@theia/core/lib/common/env-variables';
import { EnvVariablesServer } from './theia/env-variables/env-variables-server';
@@ -86,6 +89,9 @@ import {
AuthenticationServicePath,
} from '../common/protocol/authentication-service';
import { ArduinoFirmwareUploaderImpl } from './arduino-firmware-uploader-impl';
import { PlotterBackendContribution } from './plotter/plotter-backend-contribution';
import WebSocketServiceImpl from './web-socket/web-socket-service-impl';
import { WebSocketService } from './web-socket/web-socket-service';
export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(BackendApplication).toSelf().inSingletonScope();
@@ -169,6 +175,9 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
})
);
// Shared WebSocketService for the backend. This will manage all websocket conenctions
bind(WebSocketService).to(WebSocketServiceImpl).inSingletonScope();
// Shared Arduino core client provider service for the backend.
bind(CoreClientProvider).toSelf().inSingletonScope();
@@ -198,11 +207,11 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(ConnectionContainerModule).toConstantValue(
ConnectionContainerModule.create(({ bind, bindBackendService }) => {
bind(MonitorClientProvider).toSelf().inSingletonScope();
bind(MonitorServiceImpl).toSelf().inSingletonScope();
bind(MonitorService).toService(MonitorServiceImpl);
bindBackendService<MonitorService, MonitorServiceClient>(
MonitorServicePath,
MonitorService,
bind(SerialServiceImpl).toSelf().inSingletonScope();
bind(SerialService).toService(SerialServiceImpl);
bindBackendService<SerialService, SerialServiceClient>(
SerialServicePath,
SerialService,
(service, client) => {
service.setClient(client);
client.onDidCloseConnection(() => service.dispose());
@@ -299,14 +308,14 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
.inSingletonScope()
.whenTargetNamed('config');
// Logger for the monitor service.
// Logger for the serial service.
bind(ILogger)
.toDynamicValue((ctx) => {
const parentLogger = ctx.container.get<ILogger>(ILogger);
return parentLogger.child('monitor-service');
return parentLogger.child(SerialServiceName);
})
.inSingletonScope()
.whenTargetNamed('monitor-service');
.whenTargetNamed(SerialServiceName);
bind(DefaultGitInit).toSelf();
rebind(GitInit).toService(DefaultGitInit);
@@ -331,4 +340,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
)
)
.inSingletonScope();
bind(PlotterBackendContribution).toSelf().inSingletonScope();
bind(BackendApplicationContribution).toService(PlotterBackendContribution);
});

View File

@@ -32,6 +32,8 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService {
@inject(NotificationServiceServer)
protected readonly notificationService: NotificationServiceServer;
protected uploading = false;
async compile(
options: CoreService.Compile.Options & {
exportBinaries?: boolean;
@@ -110,6 +112,10 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService {
);
}
isUploading(): Promise<boolean> {
return Promise.resolve(this.uploading);
}
protected async doUpload(
options: CoreService.Upload.Options,
requestProvider: () => UploadRequest | UploadUsingProgrammerRequest,
@@ -120,6 +126,7 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService {
) => ClientReadableStream<UploadResponse | UploadUsingProgrammerResponse>,
task = 'upload'
): Promise<void> {
this.uploading = true;
await this.compile(Object.assign(options, { exportBinaries: false }));
const { sketchUri, fqbn, port, programmer } = options;
const sketchPath = FileUri.fsPath(sketchUri);
@@ -173,6 +180,8 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService {
severity: 'error',
});
throw e;
} finally {
this.uploading = false;
}
}

View File

@@ -0,0 +1,29 @@
import * as express from 'express';
import { injectable } from 'inversify';
import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application';
import path = require('path');
@injectable()
export class PlotterBackendContribution
implements BackendApplicationContribution
{
async initialize(): Promise<void> {}
configure(app: express.Application): void {
const relativePath = [
'..',
'..',
'..',
'build',
'arduino-serial-plotter-webapp',
'build',
];
app.use(express.static(path.join(__dirname, ...relativePath)));
app.get('/plotter', (req, res) => {
console.log(
`Serving serial plotter on http://${req.headers.host}${req.url}`
);
res.sendFile(path.join(__dirname, ...relativePath, 'index.html'));
});
}
}

View File

@@ -2,15 +2,14 @@ import { ClientDuplexStream } from '@grpc/grpc-js';
import { TextEncoder } from 'util';
import { injectable, inject, named } from 'inversify';
import { Struct } from 'google-protobuf/google/protobuf/struct_pb';
import { Emitter } from '@theia/core/lib/common/event';
import { ILogger } from '@theia/core/lib/common/logger';
import {
MonitorService,
MonitorServiceClient,
MonitorConfig,
MonitorError,
SerialService,
SerialServiceClient,
SerialConfig,
SerialError,
Status,
} from '../../common/protocol/monitor-service';
} from '../../common/protocol/serial-service';
import {
StreamingOpenRequest,
StreamingOpenResponse,
@@ -18,16 +17,20 @@ import {
} from '../cli-protocol/cc/arduino/cli/monitor/v1/monitor_pb';
import { MonitorClientProvider } from './monitor-client-provider';
import { Board, Port } from '../../common/protocol/boards-service';
import * as WebSocket from 'ws';
import { WebSocketService } from '../web-socket/web-socket-service';
import { SerialPlotter } from '../../browser/serial/plotter/protocol';
import { Disposable } from '@theia/core/shared/vscode-languageserver-protocol';
export const SerialServiceName = 'serial-service';
interface ErrorWithCode extends Error {
readonly code: number;
}
namespace ErrorWithCode {
export function toMonitorError(
export function toSerialError(
error: Error,
config: MonitorConfig
): MonitorError {
config: SerialConfig
): SerialError {
const { message } = error;
let code = undefined;
if (is(error)) {
@@ -35,15 +38,15 @@ namespace ErrorWithCode {
const mapping = new Map<string, number>();
mapping.set(
'1 CANCELLED: Cancelled on client',
MonitorError.ErrorCodes.CLIENT_CANCEL
SerialError.ErrorCodes.CLIENT_CANCEL
);
mapping.set(
'2 UNKNOWN: device not configured',
MonitorError.ErrorCodes.DEVICE_NOT_CONFIGURED
SerialError.ErrorCodes.DEVICE_NOT_CONFIGURED
);
mapping.set(
'2 UNKNOWN: error opening serial monitor: Serial port busy',
MonitorError.ErrorCodes.DEVICE_BUSY
'2 UNKNOWN: error opening serial connection: Serial port busy',
SerialError.ErrorCodes.DEVICE_BUSY
);
code = mapping.get(message);
}
@@ -59,45 +62,59 @@ namespace ErrorWithCode {
}
@injectable()
export class MonitorServiceImpl implements MonitorService {
export class SerialServiceImpl implements SerialService {
@named(SerialServiceName)
@inject(ILogger)
@named('monitor-service')
protected readonly logger: ILogger;
@inject(MonitorClientProvider)
protected readonly monitorClientProvider: MonitorClientProvider;
protected readonly serialClientProvider: MonitorClientProvider;
protected client?: MonitorServiceClient;
protected connection?: {
@inject(WebSocketService)
protected readonly webSocketService: WebSocketService;
protected client?: SerialServiceClient;
protected serialConnection?: {
duplex: ClientDuplexStream<StreamingOpenRequest, StreamingOpenResponse>;
config: MonitorConfig;
config: SerialConfig;
};
protected messages: string[] = [];
protected onMessageDidReadEmitter = new Emitter<void>();
protected onMessageReceived: Disposable | null;
protected flushMessagesInterval: NodeJS.Timeout | null;
setClient(client: MonitorServiceClient | undefined): void {
setClient(client: SerialServiceClient | undefined): void {
this.client = client;
}
dispose(): void {
this.logger.info('>>> Disposing monitor service...');
if (this.connection) {
this.logger.info('>>> Disposing serial service...');
if (this.serialConnection) {
this.disconnect();
}
this.logger.info('<<< Disposed monitor service.');
this.logger.info('<<< Disposed serial service.');
this.client = undefined;
}
async connect(config: MonitorConfig): Promise<Status> {
async updateWsConfigParam(
config: Partial<SerialPlotter.Config>
): Promise<void> {
const msg: SerialPlotter.Protocol.Message = {
command: SerialPlotter.Protocol.Command.MIDDLEWARE_CONFIG_CHANGED,
data: config,
};
this.webSocketService.sendMessage(JSON.stringify(msg));
}
async connect(config: SerialConfig): Promise<Status> {
this.logger.info(
`>>> Creating serial monitor connection for ${Board.toString(
`>>> Creating serial connection for ${Board.toString(
config.board
)} on port ${Port.toString(config.port)}...`
);
if (this.connection) {
if (this.serialConnection) {
return Status.ALREADY_CONNECTED;
}
const client = await this.monitorClientProvider.client();
const client = await this.serialClientProvider.client();
if (!client) {
return Status.NOT_CONNECTED;
}
@@ -105,17 +122,17 @@ export class MonitorServiceImpl implements MonitorService {
return { message: client.message };
}
const duplex = client.streamingOpen();
this.connection = { duplex, config };
this.serialConnection = { duplex, config };
duplex.on(
'error',
((error: Error) => {
const monitorError = ErrorWithCode.toMonitorError(error, config);
this.disconnect(monitorError).then(() => {
const serialError = ErrorWithCode.toSerialError(error, config);
this.disconnect(serialError).then(() => {
if (this.client) {
this.client.notifyError(monitorError);
this.client.notifyError(serialError);
}
if (monitorError.code === undefined) {
if (serialError.code === undefined) {
// Log the original, unexpected error.
this.logger.error(error);
}
@@ -123,23 +140,50 @@ export class MonitorServiceImpl implements MonitorService {
}).bind(this)
);
const ws = new WebSocket.Server({ port: 0 });
const address: any = ws.address();
this.client?.notifyMessage(address.port);
let wsConn: WebSocket | null = null;
ws.on('connection', (ws) => {
wsConn = ws;
});
this.client?.notifyWebSocketChanged(
this.webSocketService.getAddress().port
);
const flushMessagesToFrontend = () => {
if (this.messages.length) {
wsConn?.send(JSON.stringify(this.messages));
this.webSocketService.sendMessage(JSON.stringify(this.messages));
this.messages = [];
}
};
// empty the queue every 16ms (~60fps)
setInterval(flushMessagesToFrontend, 32);
this.onMessageReceived = this.webSocketService.onMessageReceived(
(msg: string) => {
try {
const message: SerialPlotter.Protocol.Message = JSON.parse(msg);
switch (message.command) {
case SerialPlotter.Protocol.Command.PLOTTER_SEND_MESSAGE:
this.sendMessageToSerial(message.data);
break;
case SerialPlotter.Protocol.Command.PLOTTER_SET_BAUDRATE:
this.client?.notifyBaudRateChanged(
parseInt(message.data, 10) as SerialConfig.BaudRate
);
break;
case SerialPlotter.Protocol.Command.PLOTTER_SET_LINE_ENDING:
this.client?.notifyLineEndingChanged(message.data);
break;
case SerialPlotter.Protocol.Command.PLOTTER_SET_INTERPOLATE:
this.client?.notifyInterpolateChanged(message.data);
break;
default:
break;
}
} catch (error) {}
}
);
// empty the queue every 32ms (~30fps)
this.flushMessagesInterval = setInterval(flushMessagesToFrontend, 32);
// converts 'ab\nc\nd' => [ab\n,c\n,d]
const stringToArray = (string: string, separator = '\n') => {
@@ -188,13 +232,12 @@ export class MonitorServiceImpl implements MonitorService {
req.setConfig(monitorConfig);
return new Promise<Status>((resolve) => {
if (this.connection) {
this.connection.duplex.write(req, () => {
if (this.serialConnection) {
this.serialConnection.duplex.write(req, () => {
this.logger.info(
`<<< Serial monitor connection created for ${Board.toString(
config.board,
{ useFqbn: false }
)} on port ${Port.toString(config.port)}.`
`<<< Serial connection created for ${Board.toString(config.board, {
useFqbn: false,
})} on port ${Port.toString(config.port)}.`
);
resolve(Status.OK);
});
@@ -204,43 +247,52 @@ export class MonitorServiceImpl implements MonitorService {
});
}
async disconnect(reason?: MonitorError): Promise<Status> {
async disconnect(reason?: SerialError): Promise<Status> {
try {
if (this.onMessageReceived) {
this.onMessageReceived.dispose();
this.onMessageReceived = null;
}
if (this.flushMessagesInterval) {
clearInterval(this.flushMessagesInterval);
this.flushMessagesInterval = null;
}
if (
!this.connection &&
!this.serialConnection &&
reason &&
reason.code === MonitorError.ErrorCodes.CLIENT_CANCEL
reason.code === SerialError.ErrorCodes.CLIENT_CANCEL
) {
return Status.OK;
}
this.logger.info('>>> Disposing monitor connection...');
if (!this.connection) {
this.logger.info('>>> Disposing serial connection...');
if (!this.serialConnection) {
this.logger.warn('<<< Not connected. Nothing to dispose.');
return Status.NOT_CONNECTED;
}
const { duplex, config } = this.connection;
const { duplex, config } = this.serialConnection;
duplex.cancel();
this.logger.info(
`<<< Disposed monitor connection for ${Board.toString(config.board, {
`<<< Disposed serial connection for ${Board.toString(config.board, {
useFqbn: false,
})} on port ${Port.toString(config.port)}.`
);
this.connection = undefined;
this.serialConnection = undefined;
return Status.OK;
} finally {
this.messages.length = 0;
}
}
async send(message: string): Promise<Status> {
if (!this.connection) {
async sendMessageToSerial(message: string): Promise<Status> {
if (!this.serialConnection) {
return Status.NOT_CONNECTED;
}
const req = new StreamingOpenRequest();
req.setData(new TextEncoder().encode(message));
return new Promise<Status>((resolve) => {
if (this.connection) {
this.connection.duplex.write(req, () => {
if (this.serialConnection) {
this.serialConnection.duplex.write(req, () => {
resolve(Status.OK);
});
return;
@@ -250,10 +302,10 @@ export class MonitorServiceImpl implements MonitorService {
}
protected mapType(
type?: MonitorConfig.ConnectionType
type?: SerialConfig.ConnectionType
): GrpcMonitorConfig.TargetType {
switch (type) {
case MonitorConfig.ConnectionType.SERIAL:
case SerialConfig.ConnectionType.SERIAL:
return GrpcMonitorConfig.TargetType.TARGET_TYPE_SERIAL;
default:
return GrpcMonitorConfig.TargetType.TARGET_TYPE_SERIAL;

View File

@@ -0,0 +1,46 @@
import { Emitter } from '@theia/core';
import { injectable } from 'inversify';
import * as WebSocket from 'ws';
import { WebSocketService } from './web-socket-service';
@injectable()
export default class WebSocketServiceImpl implements WebSocketService {
protected wsClients: WebSocket[];
protected server: WebSocket.Server;
protected readonly onMessage = new Emitter<string>();
public readonly onMessageReceived = this.onMessage.event;
constructor() {
this.wsClients = [];
this.server = new WebSocket.Server({ port: 0 });
const addClient = this.addClient.bind(this);
this.server.on('connection', addClient);
}
private addClient(ws: WebSocket): void {
this.wsClients.push(ws);
ws.onclose = () => {
this.wsClients.splice(this.wsClients.indexOf(ws), 1);
};
ws.onmessage = (res) => {
this.onMessage.fire(res.data.toString());
};
}
getAddress(): WebSocket.AddressInfo {
return this.server.address() as WebSocket.AddressInfo;
}
sendMessage(message: string): void {
this.wsClients.forEach((w) => {
try {
w.send(message);
} catch {
w.close();
}
});
}
}

View File

@@ -0,0 +1,9 @@
import { Event } from '@theia/core/lib/common/event';
import * as WebSocket from 'ws';
export const WebSocketService = Symbol('WebSocketService');
export interface WebSocketService {
getAddress(): WebSocket.AddressInfo;
sendMessage(message: string): void;
onMessageReceived: Event<string>;
}