mirror of
https://github.com/arduino/arduino-ide.git
synced 2025-11-10 02:48:33 +00:00
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:
committed by
GitHub
parent
9863dc2f90
commit
20f7712129
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user