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

@@ -3,16 +3,13 @@ import {
MAIN_MENU_BAR,
MenuContribution,
MenuModelRegistry,
SelectionService,
ILogger,
DisposableCollection,
} from '@theia/core';
import {
ContextMenuRenderer,
FrontendApplication,
FrontendApplicationContribution,
LocalStorageService,
OpenerService,
StatusBar,
StatusBarAlignment,
} from '@theia/core/lib/browser';
@@ -35,7 +32,6 @@ import {
EditorManager,
EditorOpenerOptions,
} from '@theia/editor/lib/browser';
import { FileDialogService } from '@theia/filesystem/lib/browser/file-dialog';
import { ProblemContribution } from '@theia/markers/lib/browser/problem/problem-contribution';
import { MonacoMenus } from '@theia/monaco/lib/browser/monaco-menu';
import { FileNavigatorContribution } from '@theia/navigator/lib/browser/navigator-contribution';
@@ -47,33 +43,25 @@ import { TerminalMenus } from '@theia/terminal/lib/browser/terminal-frontend-con
import { inject, injectable, postConstruct } from 'inversify';
import * as React from 'react';
import { remote } from 'electron';
import { MainMenuManager } from '../common/main-menu-manager';
import {
BoardsService,
CoreService,
Port,
SketchesService,
ExecutableService,
Sketch,
} from '../common/protocol';
import { ArduinoDaemon } from '../common/protocol/arduino-daemon';
import { ConfigService } from '../common/protocol/config-service';
import { FileSystemExt } from '../common/protocol/filesystem-ext';
import { ArduinoCommands } from './arduino-commands';
import { BoardsConfig } from './boards/boards-config';
import { BoardsConfigDialog } from './boards/boards-config-dialog';
import { BoardsDataStore } from './boards/boards-data-store';
import { BoardsServiceProvider } from './boards/boards-service-provider';
import { BoardsToolBarItem } from './boards/boards-toolbar-item';
import { EditorMode } from './editor-mode';
import { ArduinoMenus } from './menu/arduino-menus';
import { MonitorConnection } from './monitor/monitor-connection';
import { MonitorViewContribution } from './monitor/monitor-view-contribution';
import { WorkspaceService } from './theia/workspace/workspace-service';
import { MonitorViewContribution } from './serial/monitor/monitor-view-contribution';
import { ArduinoToolbar } from './toolbar/arduino-toolbar';
import { HostedPluginSupport } from '@theia/plugin-ext/lib/hosted/browser/hosted-plugin';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { ResponseService } from '../common/protocol/response-service';
import { ArduinoPreferences } from './arduino-preferences';
import { SketchesServiceClientImpl } from '../common/protocol/sketches-service-client-impl';
import { SaveAsSketch } from './contributions/save-as-sketch';
@@ -101,24 +89,12 @@ export class ArduinoFrontendContribution
@inject(BoardsService)
protected readonly boardsService: BoardsService;
@inject(CoreService)
protected readonly coreService: CoreService;
@inject(BoardsServiceProvider)
protected readonly boardsServiceClientImpl: BoardsServiceProvider;
@inject(SelectionService)
protected readonly selectionService: SelectionService;
@inject(EditorManager)
protected readonly editorManager: EditorManager;
@inject(ContextMenuRenderer)
protected readonly contextMenuRenderer: ContextMenuRenderer;
@inject(FileDialogService)
protected readonly fileDialogService: FileDialogService;
@inject(FileService)
protected readonly fileService: FileService;
@@ -128,21 +104,12 @@ export class ArduinoFrontendContribution
@inject(BoardsConfigDialog)
protected readonly boardsConfigDialog: BoardsConfigDialog;
@inject(MenuModelRegistry)
protected readonly menuRegistry: MenuModelRegistry;
@inject(CommandRegistry)
protected readonly commandRegistry: CommandRegistry;
@inject(StatusBar)
protected readonly statusBar: StatusBar;
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(MonitorConnection)
protected readonly monitorConnection: MonitorConnection;
@inject(FileNavigatorContribution)
protected readonly fileNavigatorContributions: FileNavigatorContribution;
@@ -167,40 +134,21 @@ export class ArduinoFrontendContribution
@inject(EditorMode)
protected readonly editorMode: EditorMode;
@inject(ArduinoDaemon)
protected readonly daemon: ArduinoDaemon;
@inject(OpenerService)
protected readonly openerService: OpenerService;
@inject(ConfigService)
protected readonly configService: ConfigService;
@inject(BoardsDataStore)
protected readonly boardsDataStore: BoardsDataStore;
@inject(MainMenuManager)
protected readonly mainMenuManager: MainMenuManager;
@inject(FileSystemExt)
protected readonly fileSystemExt: FileSystemExt;
@inject(HostedPluginSupport)
protected hostedPluginSupport: HostedPluginSupport;
@inject(ExecutableService)
protected executableService: ExecutableService;
@inject(ResponseService)
protected readonly responseService: ResponseService;
@inject(ArduinoPreferences)
protected readonly arduinoPreferences: ArduinoPreferences;
@inject(SketchesServiceClientImpl)
protected readonly sketchServiceClient: SketchesServiceClientImpl;
@inject(FrontendApplicationStateService)
protected readonly appStateService: FrontendApplicationStateService;
@inject(LocalStorageService)

View File

@@ -69,20 +69,20 @@ import { ScmContribution } from './theia/scm/scm-contribution';
import { SearchInWorkspaceFrontendContribution as TheiaSearchInWorkspaceFrontendContribution } from '@theia/search-in-workspace/lib/browser/search-in-workspace-frontend-contribution';
import { SearchInWorkspaceFrontendContribution } from './theia/search-in-workspace/search-in-workspace-frontend-contribution';
import { LibraryListWidgetFrontendContribution } from './library/library-widget-frontend-contribution';
import { MonitorServiceClientImpl } from './monitor/monitor-service-client-impl';
import { SerialServiceClientImpl } from './serial/serial-service-client-impl';
import {
MonitorServicePath,
MonitorService,
MonitorServiceClient,
} from '../common/protocol/monitor-service';
SerialServicePath,
SerialService,
SerialServiceClient,
} from '../common/protocol/serial-service';
import {
ConfigService,
ConfigServicePath,
} from '../common/protocol/config-service';
import { MonitorWidget } from './monitor/monitor-widget';
import { MonitorViewContribution } from './monitor/monitor-view-contribution';
import { MonitorConnection } from './monitor/monitor-connection';
import { MonitorModel } from './monitor/monitor-model';
import { MonitorWidget } from './serial/monitor/monitor-widget';
import { MonitorViewContribution } from './serial/monitor/monitor-view-contribution';
import { SerialConnectionManager } from './serial/serial-connection-manager';
import { SerialModel } from './serial/serial-model';
import { TabBarDecoratorService as TheiaTabBarDecoratorService } from '@theia/core/lib/browser/shell/tab-bar-decorator';
import { TabBarDecoratorService } from './theia/core/tab-bar-decorator';
import { ProblemManager as TheiaProblemManager } from '@theia/markers/lib/browser';
@@ -253,6 +253,7 @@ import {
UploadCertificateDialogProps,
UploadCertificateDialogWidget,
} from './dialogs/certificate-uploader/certificate-uploader-dialog';
import { PlotterFrontendContribution } from './serial/plotter/plotter-frontend-contribution';
import { nls } from '@theia/core/lib/browser/nls';
const ElementQueries = require('css-element-queries/src/ElementQueries');
@@ -386,8 +387,8 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
.inSingletonScope();
// Serial monitor
bind(MonitorModel).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(MonitorModel);
bind(SerialModel).toSelf().inSingletonScope();
bind(FrontendApplicationContribution).toService(SerialModel);
bind(MonitorWidget).toSelf();
bindViewContribution(bind, MonitorViewContribution);
bind(TabBarToolbarContribution).toService(MonitorViewContribution);
@@ -395,18 +396,19 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
id: MonitorWidget.ID,
createWidget: () => context.container.get(MonitorWidget),
}));
// Frontend binding for the serial monitor service
bind(MonitorService)
// Frontend binding for the serial service
bind(SerialService)
.toDynamicValue((context) => {
const connection = context.container.get(WebSocketConnectionProvider);
const client =
context.container.get<MonitorServiceClient>(MonitorServiceClient);
return connection.createProxy(MonitorServicePath, client);
context.container.get<SerialServiceClient>(SerialServiceClient);
return connection.createProxy(SerialServicePath, client);
})
.inSingletonScope();
bind(MonitorConnection).toSelf().inSingletonScope();
// Serial monitor service client to receive and delegate notifications from the backend.
bind(MonitorServiceClient).to(MonitorServiceClientImpl).inSingletonScope();
bind(SerialConnectionManager).toSelf().inSingletonScope();
// Serial service client to receive and delegate notifications from the backend.
bind(SerialServiceClient).to(SerialServiceClientImpl).inSingletonScope();
bind(WorkspaceService).toSelf().inSingletonScope();
rebind(TheiaWorkspaceService).toService(WorkspaceService);
@@ -597,6 +599,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
Contribution.configure(bind, AddFile);
Contribution.configure(bind, ArchiveSketch);
Contribution.configure(bind, AddZipLibrary);
Contribution.configure(bind, PlotterFrontendContribution);
bind(ResponseServiceImpl)
.toSelf()

View File

@@ -64,7 +64,7 @@ export class BoardsServiceProvider implements FrontendApplicationContribution {
* This even also fires, when the boards package was not available for the currently selected board,
* and the user installs the board package. Note: installing a board package will set the `fqbn` of the
* currently selected board.\
* This even also emitted when the board package for the currently selected board was uninstalled.
* This event is also emitted when the board package for the currently selected board was uninstalled.
*/
readonly onBoardsConfigChanged = this.onBoardsConfigChangedEmitter.event;
readonly onAvailableBoardsChanged =

View File

@@ -138,7 +138,11 @@ PID: ${PID}`;
// The board specific items, and the rest, have order with `z`. We needed something between `0` and `z` with natural-order.
this.menuModelRegistry.registerSubmenu(
boardsSubmenuPath,
nls.localize('arduino/board/board', 'Board{0}', !!boardsSubmenuLabel ? `: "${boardsSubmenuLabel}"` : ''),
nls.localize(
'arduino/board/board',
'Board{0}',
!!boardsSubmenuLabel ? `: "${boardsSubmenuLabel}"` : ''
),
{ order: '100' }
);
this.toDisposeBeforeMenuRebuild.push(
@@ -155,7 +159,11 @@ PID: ${PID}`;
const portsSubmenuLabel = config.selectedPort?.address;
this.menuModelRegistry.registerSubmenu(
portsSubmenuPath,
nls.localize('arduino/board/port', 'Port{0}', portsSubmenuLabel ? `: "${portsSubmenuLabel}"` : ''),
nls.localize(
'arduino/board/port',
'Port{0}',
portsSubmenuLabel ? `: "${portsSubmenuLabel}"` : ''
),
{ order: '101' }
);
this.toDisposeBeforeMenuRebuild.push(
@@ -193,9 +201,10 @@ PID: ${PID}`;
const packageLabel =
packageName +
`${manuallyInstalled
? nls.localize('arduino/board/inSketchbook', ' (in Sketchbook)')
: ''
`${
manuallyInstalled
? nls.localize('arduino/board/inSketchbook', ' (in Sketchbook)')
: ''
}`;
// Platform submenu
const platformMenuPath = [...boardsPackagesGroup, packageId];
@@ -268,8 +277,9 @@ PID: ${PID}`;
});
}
for (const { name, fqbn } of boards) {
const id = `arduino-select-port--${address}${fqbn ? `--${fqbn}` : ''
}`;
const id = `arduino-select-port--${address}${
fqbn ? `--${fqbn}` : ''
}`;
const command = { id };
const handler = {
execute: () => {

View File

@@ -3,7 +3,7 @@ import { OutputChannelManager } from '@theia/output/lib/common/output-channel';
import { CoreService } from '../../common/protocol';
import { ArduinoMenus } from '../menu/arduino-menus';
import { BoardsDataStore } from '../boards/boards-data-store';
import { MonitorConnection } from '../monitor/monitor-connection';
import { SerialConnectionManager } from '../serial/serial-connection-manager';
import { BoardsServiceProvider } from '../boards/boards-service-provider';
import {
SketchContribution,
@@ -18,8 +18,8 @@ export class BurnBootloader extends SketchContribution {
@inject(CoreService)
protected readonly coreService: CoreService;
@inject(MonitorConnection)
protected readonly monitorConnection: MonitorConnection;
@inject(SerialConnectionManager)
protected readonly serialConnection: SerialConnectionManager;
@inject(BoardsDataStore)
protected readonly boardsDataStore: BoardsDataStore;
@@ -48,10 +48,7 @@ export class BurnBootloader extends SketchContribution {
}
async burnBootloader(): Promise<void> {
const monitorConfig = this.monitorConnection.monitorConfig;
if (monitorConfig) {
await this.monitorConnection.disconnect();
}
await this.serialConnection.disconnect();
try {
const { boardsConfig } = this.boardsServiceClientImpl;
const port = boardsConfig.selectedPort;
@@ -84,8 +81,8 @@ export class BurnBootloader extends SketchContribution {
} catch (e) {
this.messageService.error(e.toString());
} finally {
if (monitorConfig) {
await this.monitorConnection.connect(monitorConfig);
if (this.serialConnection.isSerialOpen()) {
await this.serialConnection.connect();
}
}
}

View File

@@ -4,7 +4,7 @@ import { CoreService } from '../../common/protocol';
import { ArduinoMenus } from '../menu/arduino-menus';
import { ArduinoToolbar } from '../toolbar/arduino-toolbar';
import { BoardsDataStore } from '../boards/boards-data-store';
import { MonitorConnection } from '../monitor/monitor-connection';
import { SerialConnectionManager } from '../serial/serial-connection-manager';
import { BoardsServiceProvider } from '../boards/boards-service-provider';
import {
SketchContribution,
@@ -21,8 +21,8 @@ export class UploadSketch extends SketchContribution {
@inject(CoreService)
protected readonly coreService: CoreService;
@inject(MonitorConnection)
protected readonly monitorConnection: MonitorConnection;
@inject(SerialConnectionManager)
protected readonly serialConnection: SerialConnectionManager;
@inject(BoardsDataStore)
protected readonly boardsDataStore: BoardsDataStore;
@@ -108,15 +108,7 @@ export class UploadSketch extends SketchContribution {
if (!sketch) {
return;
}
let shouldAutoConnect = false;
const monitorConfig = this.monitorConnection.monitorConfig;
if (monitorConfig) {
await this.monitorConnection.disconnect();
if (this.monitorConnection.autoConnect) {
shouldAutoConnect = true;
}
this.monitorConnection.autoConnect = false;
}
await this.serialConnection.disconnect();
try {
const { boardsConfig } = this.boardsServiceClientImpl;
const [fqbn, { selectedProgrammer }, verify, verbose, sourceOverride] =
@@ -175,24 +167,22 @@ export class UploadSketch extends SketchContribution {
this.uploadInProgress = false;
this.onDidChangeEmitter.fire();
if (monitorConfig) {
const { board, port } = monitorConfig;
if (
this.serialConnection.isSerialOpen() &&
this.serialConnection.serialConfig
) {
const { board, port } = this.serialConnection.serialConfig;
try {
await this.boardsServiceClientImpl.waitUntilAvailable(
Object.assign(board, { port }),
10_000
);
if (shouldAutoConnect) {
// Enabling auto-connect will trigger a connect.
this.monitorConnection.autoConnect = true;
} else {
await this.monitorConnection.connect(monitorConfig);
}
await this.serialConnection.connect();
} catch (waitError) {
this.messageService.error(
nls.localize(
'arduino/sketch/couldNotConnectToMonitor',
'Could not reconnect to serial monitor. {0}',
'arduino/sketch/couldNotConnectToSerial',
'Could not reconnect to serial port. {0}',
waitError.toString()
)
);

View File

@@ -86,7 +86,7 @@ export namespace ArduinoMenus {
// -- Tools
export const TOOLS = [...MAIN_MENU_BAR, '4_tools'];
// `Auto Format`, `Archive Sketch`, `Manage Libraries...`, `Serial Monitor`
// `Auto Format`, `Archive Sketch`, `Manage Libraries...`, `Serial Monitor`, Serial Plotter
export const TOOLS__MAIN_GROUP = [...TOOLS, '0_main'];
// `WiFi101 / WiFiNINA Firmware Updater`
export const TOOLS__FIRMWARE_UPLOADER_GROUP = [

View File

@@ -1,381 +0,0 @@
import { injectable, inject, postConstruct } from 'inversify';
import { deepClone } from '@theia/core/lib/common/objects';
import { Emitter, Event } from '@theia/core/lib/common/event';
import { MessageService } from '@theia/core/lib/common/message-service';
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
import {
MonitorService,
MonitorConfig,
MonitorError,
Status,
MonitorServiceClient,
} from '../../common/protocol/monitor-service';
import { BoardsServiceProvider } from '../boards/boards-service-provider';
import {
Port,
Board,
BoardsService,
AttachedBoardsChangeEvent,
} from '../../common/protocol/boards-service';
import { BoardsConfig } from '../boards/boards-config';
import { MonitorModel } from './monitor-model';
import { NotificationCenter } from '../notification-center';
import { nls } from '@theia/core/lib/browser/nls';
@injectable()
export class MonitorConnection {
@inject(MonitorModel)
protected readonly monitorModel: MonitorModel;
@inject(MonitorService)
protected readonly monitorService: MonitorService;
@inject(MonitorServiceClient)
protected readonly monitorServiceClient: MonitorServiceClient;
@inject(BoardsService)
protected readonly boardsService: BoardsService;
@inject(BoardsServiceProvider)
protected readonly boardsServiceProvider: BoardsServiceProvider;
@inject(NotificationCenter)
protected readonly notificationCenter: NotificationCenter;
@inject(MessageService)
protected messageService: MessageService;
@inject(FrontendApplicationStateService)
protected readonly applicationState: FrontendApplicationStateService;
protected state: MonitorConnection.State | undefined;
/**
* Note: The idea is to toggle this property from the UI (`Monitor` view)
* and the boards config and the boards attachment/detachment logic can be at on place, here.
*/
protected _autoConnect = false;
protected readonly onConnectionChangedEmitter = new Emitter<
MonitorConnection.State | undefined
>();
/**
* This emitter forwards all read events **iff** the connection is established.
*/
protected readonly onReadEmitter = new Emitter<{ messages: string[] }>();
/**
* Array for storing previous monitor errors received from the server, and based on the number of elements in this array,
* we adjust the reconnection delay.
* Super naive way: we wait `array.length * 1000` ms. Once we hit 10 errors, we do not try to reconnect and clean the array.
*/
protected monitorErrors: MonitorError[] = [];
protected reconnectTimeout?: number;
@postConstruct()
protected init(): void {
this.monitorServiceClient.onMessage(this.handleMessage.bind(this));
this.monitorServiceClient.onError(this.handleError.bind(this));
this.boardsServiceProvider.onBoardsConfigChanged(
this.handleBoardConfigChange.bind(this)
);
this.notificationCenter.onAttachedBoardsChanged(
this.handleAttachedBoardsChanged.bind(this)
);
// Handles the `baudRate` changes by reconnecting if required.
this.monitorModel.onChange(({ property }) => {
if (property === 'baudRate' && this.autoConnect && this.connected) {
const { boardsConfig } = this.boardsServiceProvider;
this.handleBoardConfigChange(boardsConfig);
}
});
}
async handleMessage(port: string): Promise<void> {
const w = new WebSocket(`ws://localhost:${port}`);
w.onmessage = (res) => {
const messages = JSON.parse(res.data);
this.onReadEmitter.fire({ messages });
};
}
get connected(): boolean {
return !!this.state;
}
get monitorConfig(): MonitorConfig | undefined {
return this.state ? this.state.config : undefined;
}
get autoConnect(): boolean {
return this._autoConnect;
}
set autoConnect(value: boolean) {
const oldValue = this._autoConnect;
this._autoConnect = value;
// When we enable the auto-connect, we have to connect
if (!oldValue && value) {
// We have to make sure the previous boards config has been restored.
// Otherwise, we might start the auto-connection without configured boards.
this.applicationState.reachedState('started_contributions').then(() => {
const { boardsConfig } = this.boardsServiceProvider;
this.handleBoardConfigChange(boardsConfig);
});
} else if (oldValue && !value) {
if (this.reconnectTimeout !== undefined) {
window.clearTimeout(this.reconnectTimeout);
this.monitorErrors.length = 0;
}
}
}
handleError(error: MonitorError): void {
let shouldReconnect = false;
if (this.state) {
const { code, config } = error;
const { board, port } = config;
const options = { timeout: 3000 };
switch (code) {
case MonitorError.ErrorCodes.CLIENT_CANCEL: {
console.debug(
`Connection was canceled by client: ${MonitorConnection.State.toString(
this.state
)}.`
);
break;
}
case MonitorError.ErrorCodes.DEVICE_BUSY: {
this.messageService.warn(
nls.localize(
'arduino/monitor/connectionBusy',
'Connection failed. Serial port is busy: {0}',
Port.toString(port)
),
options
);
shouldReconnect = this.autoConnect;
this.monitorErrors.push(error);
break;
}
case MonitorError.ErrorCodes.DEVICE_NOT_CONFIGURED: {
this.messageService.info(
nls.localize(
'arduino/monitor/disconnected',
'Disconnected {0} from {1}.',
Board.toString(board, {
useFqbn: false,
}),
Port.toString(port)
),
options
);
break;
}
case undefined: {
this.messageService.error(
nls.localize(
'arduino/monitor/unexpectedError',
'Unexpected error. Reconnecting {0} on port {1}.',
Board.toString(board),
Port.toString(port)
),
options
);
console.error(JSON.stringify(error));
shouldReconnect = this.connected && this.autoConnect;
break;
}
}
const oldState = this.state;
this.state = undefined;
this.onConnectionChangedEmitter.fire(this.state);
if (shouldReconnect) {
if (this.monitorErrors.length >= 10) {
this.messageService.warn(
nls.localize(
'arduino/monitor/failedReconnect',
'Failed to reconnect {0} to the the serial-monitor after 10 consecutive attempts. The {1} serial port is busy.',
Board.toString(board, {
useFqbn: false,
}),
Port.toString(port)
)
);
this.monitorErrors.length = 0;
} else {
const attempts = this.monitorErrors.length || 1;
if (this.reconnectTimeout !== undefined) {
// Clear the previous timer.
window.clearTimeout(this.reconnectTimeout);
}
const timeout = attempts * 1000;
this.messageService.warn(
nls.localize(
'arduino/monitor/reconnect',
'Reconnecting {0} to {1} in {2] seconds...',
Board.toString(board, {
useFqbn: false,
}),
Port.toString(port),
attempts.toString()
),
{ timeout }
);
this.reconnectTimeout = window.setTimeout(
() => this.connect(oldState.config),
timeout
);
}
}
}
}
handleAttachedBoardsChanged(event: AttachedBoardsChangeEvent): void {
if (this.autoConnect && this.connected) {
const { boardsConfig } = this.boardsServiceProvider;
if (
this.boardsServiceProvider.canUploadTo(boardsConfig, {
silent: false,
})
) {
const { attached } = AttachedBoardsChangeEvent.diff(event);
if (
attached.boards.some(
(board) =>
!!board.port && BoardsConfig.Config.sameAs(boardsConfig, board)
)
) {
const { selectedBoard: board, selectedPort: port } = boardsConfig;
const { baudRate } = this.monitorModel;
this.disconnect().then(() => this.connect({ board, port, baudRate }));
}
}
}
}
async connect(config: MonitorConfig): Promise<Status> {
if (this.connected) {
const disconnectStatus = await this.disconnect();
if (!Status.isOK(disconnectStatus)) {
return disconnectStatus;
}
}
console.info(
`>>> Creating serial monitor connection for ${Board.toString(
config.board
)} on port ${Port.toString(config.port)}...`
);
const connectStatus = await this.monitorService.connect(config);
if (Status.isOK(connectStatus)) {
this.state = { config };
console.info(
`<<< Serial monitor connection created for ${Board.toString(
config.board,
{ useFqbn: false }
)} on port ${Port.toString(config.port)}.`
);
}
this.onConnectionChangedEmitter.fire(this.state);
return Status.isOK(connectStatus);
}
async disconnect(): Promise<Status> {
if (!this.connected) {
return Status.OK;
}
const stateCopy = deepClone(this.state);
if (!stateCopy) {
return Status.OK;
}
console.log('>>> Disposing existing monitor connection...');
const status = await this.monitorService.disconnect();
if (Status.isOK(status)) {
console.log(
`<<< Disposed connection. Was: ${MonitorConnection.State.toString(
stateCopy
)}`
);
} else {
console.warn(
`<<< Could not dispose connection. Activate connection: ${MonitorConnection.State.toString(
stateCopy
)}`
);
}
this.state = undefined;
this.onConnectionChangedEmitter.fire(this.state);
return status;
}
/**
* Sends the data to the connected serial monitor.
* The desired EOL is appended to `data`, you do not have to add it.
* It is a NOOP if connected.
*/
async send(data: string): Promise<Status> {
if (!this.connected) {
return Status.NOT_CONNECTED;
}
return new Promise<Status>((resolve) => {
this.monitorService
.send(data + this.monitorModel.lineEnding)
.then(() => resolve(Status.OK));
});
}
get onConnectionChanged(): Event<MonitorConnection.State | undefined> {
return this.onConnectionChangedEmitter.event;
}
get onRead(): Event<{ messages: string[] }> {
return this.onReadEmitter.event;
}
protected async handleBoardConfigChange(
boardsConfig: BoardsConfig.Config
): Promise<void> {
if (this.autoConnect) {
if (
this.boardsServiceProvider.canUploadTo(boardsConfig, {
silent: false,
})
) {
// Instead of calling `getAttachedBoards` and filtering for `AttachedSerialBoard` we have to check the available ports.
// The connected board might be unknown. See: https://github.com/arduino/arduino-pro-ide/issues/127#issuecomment-563251881
this.boardsService.getAvailablePorts().then((ports) => {
if (
ports.some((port) => Port.equals(port, boardsConfig.selectedPort))
) {
new Promise<void>((resolve) => {
// First, disconnect if connected.
if (this.connected) {
this.disconnect().then(() => resolve());
return;
}
resolve();
}).then(() => {
// Then (re-)connect.
const { selectedBoard: board, selectedPort: port } = boardsConfig;
const { baudRate } = this.monitorModel;
this.connect({ board, port, baudRate });
});
}
});
}
}
}
}
export namespace MonitorConnection {
export interface State {
readonly config: MonitorConfig;
}
export namespace State {
export function toString(state: State): string {
const { config } = state;
const { board, port } = config;
return `${Board.toString(board)} ${Port.toString(port)}`;
}
}
}

View File

@@ -1,23 +0,0 @@
import { injectable } from 'inversify';
import { Emitter } from '@theia/core/lib/common/event';
import {
MonitorServiceClient,
MonitorError,
} from '../../common/protocol/monitor-service';
@injectable()
export class MonitorServiceClientImpl implements MonitorServiceClient {
protected readonly onErrorEmitter = new Emitter<MonitorError>();
readonly onError = this.onErrorEmitter.event;
protected readonly onMessageEmitter = new Emitter<string>();
readonly onMessage = this.onMessageEmitter.event;
notifyError(error: MonitorError): void {
this.onErrorEmitter.fire(error);
}
notifyMessage(message: string): void {
this.onMessageEmitter.fire(message);
}
}

View File

@@ -9,6 +9,7 @@ export function messagesToLines(
const linesToAdd: Line[] = prevLines.length
? [prevLines[prevLines.length - 1]]
: [{ message: '', lineLen: 0 }];
if (!(Symbol.iterator in Object(messages))) return [prevLines, charCount];
for (const message of messages) {
const messageLen = message.length;

View File

@@ -7,9 +7,9 @@ import {
TabBarToolbarContribution,
TabBarToolbarRegistry,
} from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { ArduinoToolbar } from '../toolbar/arduino-toolbar';
import { MonitorModel } from './monitor-model';
import { ArduinoMenus } from '../menu/arduino-menus';
import { ArduinoToolbar } from '../../toolbar/arduino-toolbar';
import { SerialModel } from '../serial-model';
import { ArduinoMenus } from '../../menu/arduino-menus';
import { nls } from '@theia/core/lib/browser/nls';
export namespace SerialMonitor {
@@ -19,14 +19,14 @@ export namespace SerialMonitor {
id: 'serial-monitor-autoscroll',
label: 'Autoscroll',
},
'arduino/monitor/autoscroll'
'arduino/serial/autoscroll'
);
export const TIMESTAMP = Command.toLocalizedCommand(
{
id: 'serial-monitor-timestamp',
label: 'Timestamp',
},
'arduino/monitor/timestamp'
'arduino/serial/timestamp'
);
export const CLEAR_OUTPUT = Command.toLocalizedCommand(
{
@@ -48,7 +48,7 @@ export class MonitorViewContribution
static readonly TOGGLE_SERIAL_MONITOR_TOOLBAR =
MonitorWidget.ID + ':toggle-toolbar';
@inject(MonitorModel) protected readonly model: MonitorModel;
@inject(SerialModel) protected readonly model: SerialModel;
constructor() {
super({
@@ -156,7 +156,7 @@ export class MonitorViewContribution
<React.Fragment key="line-ending-toolbar-item">
<div
title={nls.localize(
'arduino/monitor/toggleTimestamp',
'arduino/serial/toggleTimestamp',
'Toggle Timestamp'
)}
className={`item enabled fa fa-clock-o arduino-monitor ${

View File

@@ -9,27 +9,31 @@ import {
Widget,
MessageLoop,
} from '@theia/core/lib/browser/widgets';
import { MonitorConfig } from '../../common/protocol/monitor-service';
import { ArduinoSelect } from '../widgets/arduino-select';
import { MonitorModel } from './monitor-model';
import { MonitorConnection } from './monitor-connection';
import { SerialConfig } from '../../../common/protocol/serial-service';
import { ArduinoSelect } from '../../widgets/arduino-select';
import { SerialModel } from '../serial-model';
import { Serial, SerialConnectionManager } from '../serial-connection-manager';
import { SerialMonitorSendInput } from './serial-monitor-send-input';
import { SerialMonitorOutput } from './serial-monitor-send-output';
import { nls } from '@theia/core/lib/browser/nls';
import { BoardsServiceProvider } from '../../boards/boards-service-provider';
@injectable()
export class MonitorWidget extends ReactWidget {
static readonly LABEL = nls.localize(
'arduino/monitor/title',
'arduino/common/serialMonitor',
'Serial Monitor'
);
static readonly ID = 'serial-monitor';
@inject(MonitorModel)
protected readonly monitorModel: MonitorModel;
@inject(SerialModel)
protected readonly serialModel: SerialModel;
@inject(MonitorConnection)
protected readonly monitorConnection: MonitorConnection;
@inject(SerialConnectionManager)
protected readonly serialConnection: SerialConnectionManager;
@inject(BoardsServiceProvider)
protected readonly boardsServiceProvider: BoardsServiceProvider;
protected widgetHeight: number;
@@ -53,12 +57,9 @@ export class MonitorWidget extends ReactWidget {
this.scrollOptions = undefined;
this.toDispose.push(this.clearOutputEmitter);
this.toDispose.push(
Disposable.create(() => {
this.monitorConnection.autoConnect = false;
if (this.monitorConnection.connected) {
this.monitorConnection.disconnect();
}
})
Disposable.create(() =>
this.serialConnection.closeSerial(Serial.Type.Monitor)
)
);
}
@@ -66,8 +67,9 @@ export class MonitorWidget extends ReactWidget {
protected init(): void {
this.update();
this.toDispose.push(
this.monitorConnection.onConnectionChanged(() => this.clearConsole())
this.serialConnection.onConnectionChanged(() => this.clearConsole())
);
this.toDispose.push(this.serialModel.onChange(() => this.update()));
}
clearConsole(): void {
@@ -81,7 +83,7 @@ export class MonitorWidget extends ReactWidget {
protected onAfterAttach(msg: Message): void {
super.onAfterAttach(msg);
this.monitorConnection.autoConnect = true;
this.serialConnection.openSerial(Serial.Type.Monitor);
}
onCloseRequest(msg: Message): void {
@@ -119,27 +121,24 @@ export class MonitorWidget extends ReactWidget {
};
protected get lineEndings(): OptionsType<
SerialMonitorOutput.SelectOption<MonitorModel.EOL>
SerialMonitorOutput.SelectOption<SerialModel.EOL>
> {
return [
{
label: nls.localize('arduino/monitor/noLineEndings', 'No Line Ending'),
label: nls.localize('arduino/serial/noLineEndings', 'No Line Ending'),
value: '',
},
{
label: nls.localize('arduino/monitor/newLine', 'New Line'),
label: nls.localize('arduino/serial/newLine', 'New Line'),
value: '\n',
},
{
label: nls.localize(
'arduino/monitor/carriageReturn',
'Carriage Return'
),
label: nls.localize('arduino/serial/carriageReturn', 'Carriage Return'),
value: '\r',
},
{
label: nls.localize(
'arduino/monitor/newLineCarriageReturn',
'arduino/serial/newLineCarriageReturn',
'Both NL & CR'
),
value: '\r\n',
@@ -148,9 +147,9 @@ export class MonitorWidget extends ReactWidget {
}
protected get baudRates(): OptionsType<
SerialMonitorOutput.SelectOption<MonitorConfig.BaudRate>
SerialMonitorOutput.SelectOption<SerialConfig.BaudRate>
> {
const baudRates: Array<MonitorConfig.BaudRate> = [
const baudRates: Array<SerialConfig.BaudRate> = [
300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200,
];
return baudRates.map((baudRate) => ({
@@ -162,17 +161,17 @@ export class MonitorWidget extends ReactWidget {
protected render(): React.ReactNode {
const { baudRates, lineEndings } = this;
const lineEnding =
lineEndings.find((item) => item.value === this.monitorModel.lineEnding) ||
lineEndings.find((item) => item.value === this.serialModel.lineEnding) ||
lineEndings[1]; // Defaults to `\n`.
const baudRate =
baudRates.find((item) => item.value === this.monitorModel.baudRate) ||
baudRates.find((item) => item.value === this.serialModel.baudRate) ||
baudRates[4]; // Defaults to `9600`.
return (
<div className="serial-monitor">
<div className="head">
<div className="send">
<SerialMonitorSendInput
monitorConfig={this.monitorConnection.monitorConfig}
serialConfig={this.serialConnection.serialConfig}
resolveFocus={this.onFocusResolved}
onSend={this.onSend}
/>
@@ -182,7 +181,7 @@ export class MonitorWidget extends ReactWidget {
<ArduinoSelect
maxMenuHeight={this.widgetHeight - 40}
options={lineEndings}
defaultValue={lineEnding}
value={lineEnding}
onChange={this.onChangeLineEnding}
/>
</div>
@@ -191,7 +190,7 @@ export class MonitorWidget extends ReactWidget {
className="select"
maxMenuHeight={this.widgetHeight - 40}
options={baudRates}
defaultValue={baudRate}
value={baudRate}
onChange={this.onChangeBaudRate}
/>
</div>
@@ -199,8 +198,8 @@ export class MonitorWidget extends ReactWidget {
</div>
<div className="body">
<SerialMonitorOutput
monitorModel={this.monitorModel}
monitorConnection={this.monitorConnection}
serialModel={this.serialModel}
serialConnection={this.serialConnection}
clearConsoleEvent={this.clearOutputEmitter.event}
height={Math.floor(this.widgetHeight - 50)}
/>
@@ -211,18 +210,18 @@ export class MonitorWidget extends ReactWidget {
protected readonly onSend = (value: string) => this.doSend(value);
protected async doSend(value: string): Promise<void> {
this.monitorConnection.send(value);
this.serialConnection.send(value);
}
protected readonly onChangeLineEnding = (
option: SerialMonitorOutput.SelectOption<MonitorModel.EOL>
option: SerialMonitorOutput.SelectOption<SerialModel.EOL>
) => {
this.monitorModel.lineEnding = option.value;
this.serialModel.lineEnding = option.value;
};
protected readonly onChangeBaudRate = (
option: SerialMonitorOutput.SelectOption<MonitorConfig.BaudRate>
option: SerialMonitorOutput.SelectOption<SerialConfig.BaudRate>
) => {
this.monitorModel.baudRate = option.value;
this.serialModel.baudRate = option.value;
};
}

View File

@@ -1,13 +1,13 @@
import * as React from 'react';
import { Key, KeyCode } from '@theia/core/lib/browser/keys';
import { Board, Port } from '../../common/protocol/boards-service';
import { MonitorConfig } from '../../common/protocol/monitor-service';
import { Board, Port } from '../../../common/protocol/boards-service';
import { SerialConfig } from '../../../common/protocol/serial-service';
import { isOSX } from '@theia/core/lib/common/os';
import { nls } from '@theia/core/lib/browser/nls';
export namespace SerialMonitorSendInput {
export interface Props {
readonly monitorConfig?: MonitorConfig;
readonly serialConfig?: SerialConfig;
readonly onSend: (text: string) => void;
readonly resolveFocus: (element: HTMLElement | undefined) => void;
}
@@ -33,7 +33,7 @@ export class SerialMonitorSendInput extends React.Component<
<input
ref={this.setRef}
type="text"
className={`theia-input ${this.props.monitorConfig ? '' : 'warning'}`}
className={`theia-input ${this.props.serialConfig ? '' : 'warning'}`}
placeholder={this.placeholder}
value={this.state.text}
onChange={this.onChange}
@@ -43,16 +43,16 @@ export class SerialMonitorSendInput extends React.Component<
}
protected get placeholder(): string {
const { monitorConfig } = this.props;
if (!monitorConfig) {
const { serialConfig } = this.props;
if (!serialConfig) {
return nls.localize(
'arduino/monitor/notConnected',
'arduino/serial/notConnected',
'Not connected. Select a board and a port to connect automatically.'
);
}
const { board, port } = monitorConfig;
const { board, port } = serialConfig;
return nls.localize(
'arduino/monitor/message',
'arduino/serial/message',
"Message ({0} + Enter to send message to '{1}' on '{2}'",
isOSX ? '⌘' : nls.localize('vscode/keybindingLabels/ctrlKey', 'Ctrl'),
Board.toString(board, {

View File

@@ -2,8 +2,8 @@ import * as React from 'react';
import { Event } from '@theia/core/lib/common/event';
import { DisposableCollection } from '@theia/core/lib/common/disposable';
import { areEqual, FixedSizeList as List } from 'react-window';
import { MonitorModel } from './monitor-model';
import { MonitorConnection } from './monitor-connection';
import { SerialModel } from '../serial-model';
import { SerialConnectionManager } from '../serial-connection-manager';
import dateFormat = require('dateformat');
import { messagesToLines, truncateLines } from './monitor-utils';
@@ -24,7 +24,7 @@ export class SerialMonitorOutput extends React.Component<
this.listRef = React.createRef();
this.state = {
lines: [],
timestamp: this.props.monitorModel.timestamp,
timestamp: this.props.serialModel.timestamp,
charCount: 0,
};
}
@@ -57,7 +57,7 @@ export class SerialMonitorOutput extends React.Component<
componentDidMount(): void {
this.scrollToBottom();
this.toDisposeBeforeUnmount.pushAll([
this.props.monitorConnection.onRead(({ messages }) => {
this.props.serialConnection.onRead(({ messages }) => {
const [newLines, totalCharCount] = messagesToLines(
messages,
this.state.lines,
@@ -74,9 +74,9 @@ export class SerialMonitorOutput extends React.Component<
this.props.clearConsoleEvent(() =>
this.setState({ lines: [], charCount: 0 })
),
this.props.monitorModel.onChange(({ property }) => {
this.props.serialModel.onChange(({ property }) => {
if (property === 'timestamp') {
const { timestamp } = this.props.monitorModel;
const { timestamp } = this.props.serialModel;
this.setState({ timestamp });
}
if (property === 'autoscroll') {
@@ -92,7 +92,7 @@ export class SerialMonitorOutput extends React.Component<
}
scrollToBottom = ((): void => {
if (this.listRef.current && this.props.monitorModel.autoscroll) {
if (this.listRef.current && this.props.serialModel.autoscroll) {
this.listRef.current.scrollToItem(this.state.lines.length, 'end');
}
}).bind(this);
@@ -125,8 +125,8 @@ const Row = React.memo(_Row, areEqual);
export namespace SerialMonitorOutput {
export interface Props {
readonly monitorModel: MonitorModel;
readonly monitorConnection: MonitorConnection;
readonly serialModel: SerialModel;
readonly serialConnection: SerialConnectionManager;
readonly clearConsoleEvent: Event<void>;
readonly height: number;
}

View File

@@ -0,0 +1,110 @@
import { ThemeService } from '@theia/core/lib/browser/theming';
import { injectable, inject } from 'inversify';
import {
Command,
CommandRegistry,
MaybePromise,
MenuModelRegistry,
} from '@theia/core';
import { SerialModel } from '../serial-model';
import { ArduinoMenus } from '../../menu/arduino-menus';
import { Contribution } from '../../contributions/contribution';
import { Endpoint, FrontendApplication } from '@theia/core/lib/browser';
import { ipcRenderer } from '@theia/core/shared/electron';
import { SerialConfig, Status } from '../../../common/protocol';
import { Serial, SerialConnectionManager } from '../serial-connection-manager';
import { SerialPlotter } from './protocol';
import { BoardsServiceProvider } from '../../boards/boards-service-provider';
const queryString = require('query-string');
export namespace SerialPlotterContribution {
export namespace Commands {
export const OPEN: Command = {
id: 'serial-plotter-open',
label: 'Serial Plotter',
category: 'Arduino',
};
}
}
@injectable()
export class PlotterFrontendContribution extends Contribution {
protected window: Window | null;
protected url: string;
protected wsPort: number;
@inject(SerialModel)
protected readonly model: SerialModel;
@inject(ThemeService)
protected readonly themeService: ThemeService;
@inject(SerialConnectionManager)
protected readonly serialConnection: SerialConnectionManager;
@inject(BoardsServiceProvider)
protected readonly boardsServiceProvider: BoardsServiceProvider;
onStart(app: FrontendApplication): MaybePromise<void> {
this.url = new Endpoint({ path: '/plotter' }).getRestUrl().toString();
ipcRenderer.on('CLOSE_CHILD_WINDOW', async () => {
if (!!this.window) {
this.window = null;
await this.serialConnection.closeSerial(Serial.Type.Plotter);
}
});
return super.onStart(app);
}
registerCommands(registry: CommandRegistry): void {
registry.registerCommand(SerialPlotterContribution.Commands.OPEN, {
execute: this.connect.bind(this),
});
}
registerMenus(menus: MenuModelRegistry): void {
menus.registerMenuAction(ArduinoMenus.TOOLS__MAIN_GROUP, {
commandId: SerialPlotterContribution.Commands.OPEN.id,
label: SerialPlotterContribution.Commands.OPEN.label,
order: '7',
});
}
async connect(): Promise<void> {
if (!!this.window) {
this.window.focus();
return;
}
const status = await this.serialConnection.openSerial(Serial.Type.Plotter);
const wsPort = this.serialConnection.getWsPort();
if (Status.isOK(status) && wsPort) {
this.open(wsPort);
} else {
this.serialConnection.closeSerial(Serial.Type.Plotter);
this.messageService.error(`Couldn't open serial plotter`);
}
}
protected open(wsPort: number): void {
const initConfig: Partial<SerialPlotter.Config> = {
baudrates: SerialConfig.BaudRates.map((b) => b),
currentBaudrate: this.model.baudRate,
currentLineEnding: this.model.lineEnding,
darkTheme: this.themeService.getCurrentTheme().type === 'dark',
wsPort,
interpolate: this.model.interpolate,
connected: this.serialConnection.connected,
serialPort: this.boardsServiceProvider.boardsConfig.selectedPort?.address,
};
const urlWithParams = queryString.stringifyUrl(
{
url: this.url,
query: initConfig,
},
{ arrayFormat: 'comma' }
);
this.window = window.open(urlWithParams, 'serialPlotter');
}
}

View File

@@ -0,0 +1,26 @@
export namespace SerialPlotter {
export type Config = {
currentBaudrate: number;
baudrates: number[];
currentLineEnding: string;
darkTheme: boolean;
wsPort: number;
interpolate: boolean;
serialPort: string;
connected: boolean;
generate?: boolean;
};
export namespace Protocol {
export enum Command {
PLOTTER_SET_BAUDRATE = 'PLOTTER_SET_BAUDRATE',
PLOTTER_SET_LINE_ENDING = 'PLOTTER_SET_LINE_ENDING',
PLOTTER_SET_INTERPOLATE = 'PLOTTER_SET_INTERPOLATE',
PLOTTER_SEND_MESSAGE = 'PLOTTER_SEND_MESSAGE',
MIDDLEWARE_CONFIG_CHANGED = 'MIDDLEWARE_CONFIG_CHANGED',
}
export type Message = {
command: SerialPlotter.Protocol.Command;
data?: any;
};
}
}

View File

@@ -0,0 +1,479 @@
import { injectable, inject } from 'inversify';
import { deepClone } from '@theia/core/lib/common/objects';
import { Emitter, Event } from '@theia/core/lib/common/event';
import { MessageService } from '@theia/core/lib/common/message-service';
import {
SerialService,
SerialConfig,
SerialError,
Status,
SerialServiceClient,
} from '../../common/protocol/serial-service';
import { BoardsServiceProvider } from '../boards/boards-service-provider';
import {
Port,
Board,
BoardsService,
} from '../../common/protocol/boards-service';
import { BoardsConfig } from '../boards/boards-config';
import { SerialModel } from './serial-model';
import { ThemeService } from '@theia/core/lib/browser/theming';
import { nls } from '@theia/core/lib/browser/nls';
import { CoreService } from '../../common/protocol';
@injectable()
export class SerialConnectionManager {
protected _state: Serial.State = [];
protected _connected = false;
protected config: Partial<SerialConfig> = {
board: undefined,
port: undefined,
baudRate: undefined,
};
protected readonly onConnectionChangedEmitter = new Emitter<boolean>();
/**
* This emitter forwards all read events **if** the connection is established.
*/
protected readonly onReadEmitter = new Emitter<{ messages: string[] }>();
/**
* Array for storing previous serial errors received from the server, and based on the number of elements in this array,
* we adjust the reconnection delay.
* Super naive way: we wait `array.length * 1000` ms. Once we hit 10 errors, we do not try to reconnect and clean the array.
*/
protected serialErrors: SerialError[] = [];
protected reconnectTimeout?: number;
/**
* When the websocket server is up on the backend, we save the port here, so that the client knows how to connect to it
* */
protected wsPort?: number;
protected webSocket?: WebSocket;
constructor(
@inject(SerialModel) protected readonly serialModel: SerialModel,
@inject(SerialService) protected readonly serialService: SerialService,
@inject(SerialServiceClient)
protected readonly serialServiceClient: SerialServiceClient,
@inject(BoardsService) protected readonly boardsService: BoardsService,
@inject(BoardsServiceProvider)
protected readonly boardsServiceProvider: BoardsServiceProvider,
@inject(MessageService) protected messageService: MessageService,
@inject(ThemeService) protected readonly themeService: ThemeService,
@inject(CoreService) protected readonly core: CoreService
) {
this.serialServiceClient.onWebSocketChanged(
this.handleWebSocketChanged.bind(this)
);
this.serialServiceClient.onBaudRateChanged((baudRate) => {
if (this.serialModel.baudRate !== baudRate) {
this.serialModel.baudRate = baudRate;
}
});
this.serialServiceClient.onLineEndingChanged((lineending) => {
if (this.serialModel.lineEnding !== lineending) {
this.serialModel.lineEnding = lineending;
}
});
this.serialServiceClient.onInterpolateChanged((interpolate) => {
if (this.serialModel.interpolate !== interpolate) {
this.serialModel.interpolate = interpolate;
}
});
this.serialServiceClient.onError(this.handleError.bind(this));
this.boardsServiceProvider.onBoardsConfigChanged(
this.handleBoardConfigChange.bind(this)
);
// Handles the `baudRate` changes by reconnecting if required.
this.serialModel.onChange(({ property }) => {
if (property === 'baudRate' && this.connected) {
const { boardsConfig } = this.boardsServiceProvider;
this.handleBoardConfigChange(boardsConfig);
}
// update the current values in the backend and propagate to websocket clients
this.serialService.updateWsConfigParam({
...(property === 'lineEnding' && {
currentLineEnding: this.serialModel.lineEnding,
}),
...(property === 'interpolate' && {
interpolate: this.serialModel.interpolate,
}),
});
});
this.themeService.onDidColorThemeChange((theme) => {
this.serialService.updateWsConfigParam({
darkTheme: theme.newTheme.type === 'dark',
});
});
}
/**
* Set the config passing only the properties that has changed. If some has changed and the serial is open,
* we try to reconnect
*
* @param newConfig the porperties of the config that has changed
*/
async setConfig(newConfig: Partial<SerialConfig>): Promise<void> {
let configHasChanged = false;
Object.keys(this.config).forEach((key: keyof SerialConfig) => {
if (newConfig[key] !== this.config[key]) {
configHasChanged = true;
this.config = { ...this.config, [key]: newConfig[key] };
}
});
if (
configHasChanged &&
this.isSerialOpen() &&
!(await this.core.isUploading())
) {
this.serialService.updateWsConfigParam({
currentBaudrate: this.config.baudRate,
serialPort: this.config.port?.address,
});
await this.disconnect();
await this.connect();
}
}
getConfig(): Partial<SerialConfig> {
return this.config;
}
getWsPort(): number | undefined {
return this.wsPort;
}
isWebSocketConnected(): boolean {
return !!this.webSocket?.url;
}
protected handleWebSocketChanged(wsPort: number): void {
this.wsPort = wsPort;
}
/**
* When the serial is open and the frontend is connected to the serial, we create the websocket here
*/
protected createWsConnection(): boolean {
if (this.wsPort) {
try {
this.webSocket = new WebSocket(`ws://localhost:${this.wsPort}`);
this.webSocket.onmessage = (res) => {
const messages = JSON.parse(res.data);
this.onReadEmitter.fire({ messages });
};
return true;
} catch {
return false;
}
}
return false;
}
/**
* Sets the types of connections needed by the client.
*
* @param newState The array containing the list of desired connections.
* If the previuos state was empty and 'newState' is not, it tries to reconnect to the serial service
* If the provios state was NOT empty and now it is, it disconnects to the serial service
* @returns The status of the operation
*/
protected async setState(newState: Serial.State): Promise<Status> {
const oldState = deepClone(this._state);
let status = Status.OK;
if (this.isSerialOpen(oldState) && !this.isSerialOpen(newState)) {
status = await this.disconnect();
} else if (!this.isSerialOpen(oldState) && this.isSerialOpen(newState)) {
if (await this.core.isUploading()) {
this.messageService.error(`Cannot open serial port when uploading`);
return Status.NOT_CONNECTED;
}
status = await this.connect();
}
this._state = newState;
return status;
}
protected get state(): Serial.State {
return this._state;
}
isSerialOpen(state?: Serial.State): boolean {
return (state ? state : this._state).length > 0;
}
get serialConfig(): SerialConfig | undefined {
return isSerialConfig(this.config)
? (this.config as SerialConfig)
: undefined;
}
get connected(): boolean {
return this._connected;
}
set connected(c: boolean) {
this._connected = c;
this.serialService.updateWsConfigParam({ connected: c });
this.onConnectionChangedEmitter.fire(this._connected);
}
/**
* Called when a client opens the serial from the GUI
*
* @param type could be either 'Monitor' or 'Plotter'. If it's 'Monitor' we also connect to the websocket and
* listen to the message events
* @returns the status of the operation
*/
async openSerial(type: Serial.Type): Promise<Status> {
if (!isSerialConfig(this.config)) {
this.messageService.error(
`Please select a board and a port to open the serial connection.`
);
return Status.NOT_CONNECTED;
}
if (this.state.includes(type)) return Status.OK;
const newState = deepClone(this.state);
newState.push(type);
const status = await this.setState(newState);
if (Status.isOK(status) && type === Serial.Type.Monitor)
this.createWsConnection();
return status;
}
/**
* Called when a client closes the serial from the GUI
*
* @param type could be either 'Monitor' or 'Plotter'. If it's 'Monitor' we close the websocket connection
* @returns the status of the operation
*/
async closeSerial(type: Serial.Type): Promise<Status> {
const index = this.state.indexOf(type);
let status = Status.OK;
if (index >= 0) {
const newState = deepClone(this.state);
newState.splice(index, 1);
status = await this.setState(newState);
if (
Status.isOK(status) &&
type === Serial.Type.Monitor &&
this.webSocket
) {
this.webSocket.close();
this.webSocket = undefined;
}
}
return status;
}
/**
* Handles error on the SerialServiceClient and try to reconnect, eventually
*/
handleError(error: SerialError): void {
if (!this.connected) return;
const { code, config } = error;
const { board, port } = config;
const options = { timeout: 3000 };
switch (code) {
case SerialError.ErrorCodes.CLIENT_CANCEL: {
console.debug(
`Serial connection was canceled by client: ${Serial.Config.toString(
this.config
)}.`
);
break;
}
case SerialError.ErrorCodes.DEVICE_BUSY: {
this.messageService.warn(
nls.localize(
'arduino/serial/connectionBusy',
'Connection failed. Serial port is busy: {0}',
Port.toString(port)
),
options
);
this.serialErrors.push(error);
break;
}
case SerialError.ErrorCodes.DEVICE_NOT_CONFIGURED: {
this.messageService.info(
nls.localize(
'arduino/serial/disconnected',
'Disconnected {0} from {1}.',
Board.toString(board, {
useFqbn: false,
}),
Port.toString(port)
),
options
);
break;
}
case undefined: {
this.messageService.error(
nls.localize(
'arduino/serial/unexpectedError',
'Unexpected error. Reconnecting {0} on port {1}.',
Board.toString(board),
Port.toString(port)
),
options
);
console.error(JSON.stringify(error));
break;
}
}
this.connected = false;
if (this.isSerialOpen()) {
if (this.serialErrors.length >= 10) {
this.messageService.warn(
nls.localize(
'arduino/serial/failedReconnect',
'Failed to reconnect {0} to serial port after 10 consecutive attempts. The {1} serial port is busy.',
Board.toString(board, {
useFqbn: false,
}),
Port.toString(port)
)
);
this.serialErrors.length = 0;
} else {
const attempts = this.serialErrors.length || 1;
if (this.reconnectTimeout !== undefined) {
// Clear the previous timer.
window.clearTimeout(this.reconnectTimeout);
}
const timeout = attempts * 1000;
this.messageService.warn(
nls.localize(
'arduino/serial/reconnect',
'Reconnecting {0} to {1} in {2] seconds...',
Board.toString(board, {
useFqbn: false,
}),
Port.toString(port),
attempts.toString()
)
);
this.reconnectTimeout = window.setTimeout(
() => this.connect(),
timeout
);
}
}
}
async connect(): Promise<Status> {
if (this.connected) return Status.ALREADY_CONNECTED;
if (!isSerialConfig(this.config)) return Status.NOT_CONNECTED;
console.info(
`>>> Creating serial connection for ${Board.toString(
this.config.board
)} on port ${Port.toString(this.config.port)}...`
);
const connectStatus = await this.serialService.connect(this.config);
if (Status.isOK(connectStatus)) {
this.connected = true;
console.info(
`<<< Serial connection created for ${Board.toString(this.config.board, {
useFqbn: false,
})} on port ${Port.toString(this.config.port)}.`
);
}
return Status.isOK(connectStatus);
}
async disconnect(): Promise<Status> {
if (!this.connected) {
return Status.OK;
}
console.log('>>> Disposing existing serial connection...');
const status = await this.serialService.disconnect();
if (Status.isOK(status)) {
this.connected = false;
console.log(
`<<< Disposed serial connection. Was: ${Serial.Config.toString(
this.config
)}`
);
this.wsPort = undefined;
} else {
console.warn(
`<<< Could not dispose serial connection. Activate connection: ${Serial.Config.toString(
this.config
)}`
);
}
return status;
}
/**
* Sends the data to the connected serial port.
* The desired EOL is appended to `data`, you do not have to add it.
* It is a NOOP if connected.
*/
async send(data: string): Promise<Status> {
if (!this.connected) {
return Status.NOT_CONNECTED;
}
return new Promise<Status>((resolve) => {
this.serialService
.sendMessageToSerial(data + this.serialModel.lineEnding)
.then(() => resolve(Status.OK));
});
}
get onConnectionChanged(): Event<boolean> {
return this.onConnectionChangedEmitter.event;
}
get onRead(): Event<{ messages: string[] }> {
return this.onReadEmitter.event;
}
protected async handleBoardConfigChange(
boardsConfig: BoardsConfig.Config
): Promise<void> {
const { selectedBoard: board, selectedPort: port } = boardsConfig;
const { baudRate } = this.serialModel;
const newConfig: Partial<SerialConfig> = { board, port, baudRate };
this.setConfig(newConfig);
}
}
export namespace Serial {
export enum Type {
Monitor = 'Monitor',
Plotter = 'Plotter',
}
/**
* The state represents which types of connections are needed by the client, and it should match whether the Serial Monitor
* or the Serial Plotter are open or not in the GUI. It's an array cause it's possible to have both, none or only one of
* them open
*/
export type State = Serial.Type[];
export namespace Config {
export function toString(config: Partial<SerialConfig>): string {
if (!isSerialConfig(config)) return '';
const { board, port } = config;
return `${Board.toString(board)} ${Port.toString(port)}`;
}
}
}
function isSerialConfig(config: Partial<SerialConfig>): config is SerialConfig {
return !!config.board && !!config.baudRate && !!config.port;
}

View File

@@ -1,6 +1,6 @@
import { injectable, inject } from 'inversify';
import { Emitter, Event } from '@theia/core/lib/common/event';
import { MonitorConfig } from '../../common/protocol/monitor-service';
import { SerialConfig } from '../../common/protocol';
import {
FrontendApplicationContribution,
LocalStorageService,
@@ -8,8 +8,8 @@ import {
import { BoardsServiceProvider } from '../boards/boards-service-provider';
@injectable()
export class MonitorModel implements FrontendApplicationContribution {
protected static STORAGE_ID = 'arduino-monitor-model';
export class SerialModel implements FrontendApplicationContribution {
protected static STORAGE_ID = 'arduino-serial-model';
@inject(LocalStorageService)
protected readonly localStorageService: LocalStorageService;
@@ -18,26 +18,28 @@ export class MonitorModel implements FrontendApplicationContribution {
protected readonly boardsServiceClient: BoardsServiceProvider;
protected readonly onChangeEmitter: Emitter<
MonitorModel.State.Change<keyof MonitorModel.State>
SerialModel.State.Change<keyof SerialModel.State>
>;
protected _autoscroll: boolean;
protected _timestamp: boolean;
protected _baudRate: MonitorConfig.BaudRate;
protected _lineEnding: MonitorModel.EOL;
protected _baudRate: SerialConfig.BaudRate;
protected _lineEnding: SerialModel.EOL;
protected _interpolate: boolean;
constructor() {
this._autoscroll = true;
this._timestamp = false;
this._baudRate = MonitorConfig.BaudRate.DEFAULT;
this._lineEnding = MonitorModel.EOL.DEFAULT;
this._baudRate = SerialConfig.BaudRate.DEFAULT;
this._lineEnding = SerialModel.EOL.DEFAULT;
this._interpolate = false;
this.onChangeEmitter = new Emitter<
MonitorModel.State.Change<keyof MonitorModel.State>
SerialModel.State.Change<keyof SerialModel.State>
>();
}
onStart(): void {
this.localStorageService
.getData<MonitorModel.State>(MonitorModel.STORAGE_ID)
.getData<SerialModel.State>(SerialModel.STORAGE_ID)
.then((state) => {
if (state) {
this.restoreState(state);
@@ -45,7 +47,7 @@ export class MonitorModel implements FrontendApplicationContribution {
});
}
get onChange(): Event<MonitorModel.State.Change<keyof MonitorModel.State>> {
get onChange(): Event<SerialModel.State.Change<keyof SerialModel.State>> {
return this.onChangeEmitter.event;
}
@@ -78,11 +80,11 @@ export class MonitorModel implements FrontendApplicationContribution {
);
}
get baudRate(): MonitorConfig.BaudRate {
get baudRate(): SerialConfig.BaudRate {
return this._baudRate;
}
set baudRate(baudRate: MonitorConfig.BaudRate) {
set baudRate(baudRate: SerialConfig.BaudRate) {
this._baudRate = baudRate;
this.storeState().then(() =>
this.onChangeEmitter.fire({
@@ -92,11 +94,11 @@ export class MonitorModel implements FrontendApplicationContribution {
);
}
get lineEnding(): MonitorModel.EOL {
get lineEnding(): SerialModel.EOL {
return this._lineEnding;
}
set lineEnding(lineEnding: MonitorModel.EOL) {
set lineEnding(lineEnding: SerialModel.EOL) {
this._lineEnding = lineEnding;
this.storeState().then(() =>
this.onChangeEmitter.fire({
@@ -106,29 +108,46 @@ export class MonitorModel implements FrontendApplicationContribution {
);
}
protected restoreState(state: MonitorModel.State): void {
get interpolate(): boolean {
return this._interpolate;
}
set interpolate(i: boolean) {
this._interpolate = i;
this.storeState().then(() =>
this.onChangeEmitter.fire({
property: 'interpolate',
value: this._interpolate,
})
);
}
protected restoreState(state: SerialModel.State): void {
this._autoscroll = state.autoscroll;
this._timestamp = state.timestamp;
this._baudRate = state.baudRate;
this._lineEnding = state.lineEnding;
this._interpolate = state.interpolate;
}
protected async storeState(): Promise<void> {
return this.localStorageService.setData(MonitorModel.STORAGE_ID, {
return this.localStorageService.setData(SerialModel.STORAGE_ID, {
autoscroll: this._autoscroll,
timestamp: this._timestamp,
baudRate: this._baudRate,
lineEnding: this._lineEnding,
interpolate: this._interpolate,
});
}
}
export namespace MonitorModel {
export namespace SerialModel {
export interface State {
autoscroll: boolean;
timestamp: boolean;
baudRate: MonitorConfig.BaudRate;
baudRate: SerialConfig.BaudRate;
lineEnding: EOL;
interpolate: boolean;
}
export namespace State {
export interface Change<K extends keyof State> {

View File

@@ -0,0 +1,48 @@
import { injectable } from 'inversify';
import { Emitter } from '@theia/core/lib/common/event';
import {
SerialServiceClient,
SerialError,
SerialConfig,
} from '../../common/protocol/serial-service';
import { SerialModel } from './serial-model';
@injectable()
export class SerialServiceClientImpl implements SerialServiceClient {
protected readonly onErrorEmitter = new Emitter<SerialError>();
readonly onError = this.onErrorEmitter.event;
protected readonly onWebSocketChangedEmitter = new Emitter<number>();
readonly onWebSocketChanged = this.onWebSocketChangedEmitter.event;
protected readonly onBaudRateChangedEmitter =
new Emitter<SerialConfig.BaudRate>();
readonly onBaudRateChanged = this.onBaudRateChangedEmitter.event;
protected readonly onLineEndingChangedEmitter =
new Emitter<SerialModel.EOL>();
readonly onLineEndingChanged = this.onLineEndingChangedEmitter.event;
protected readonly onInterpolateChangedEmitter = new Emitter<boolean>();
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);
}
}