fix: propagate monitor errors to the frontend

- Handle when the board's platform is not installed (Closes #1974)
 - UX: Smoother monitor widget reset (Closes #1985)
 - Fixed monitor <input> readOnly state (Closes #1984)
 - Set monitor widget header color (Ref #682)

Closes #1508

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
This commit is contained in:
Akos Kitta 2023-03-17 15:41:34 +01:00 committed by Akos Kitta
parent ab5c63c4b7
commit 80d5b5afa7
16 changed files with 722 additions and 357 deletions

View File

@ -496,15 +496,7 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => {
bind(TabBarToolbarContribution).toService(MonitorViewContribution);
bind(WidgetFactory).toDynamicValue((context) => ({
id: MonitorWidget.ID,
createWidget: () => {
return new MonitorWidget(
context.container.get<MonitorModel>(MonitorModel),
context.container.get<MonitorManagerProxyClient>(
MonitorManagerProxyClient
),
context.container.get<BoardsServiceProvider>(BoardsServiceProvider)
);
},
createWidget: () => context.container.get(MonitorWidget),
}));
bind(MonitorManagerProxyFactory).toFactory(

View File

@ -1,11 +1,14 @@
import {
CommandRegistry,
ApplicationError,
Disposable,
Emitter,
MessageService,
nls,
} from '@theia/core';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { inject, injectable } from '@theia/core/shared/inversify';
import { NotificationManager } from '@theia/messages/lib/browser/notifications-manager';
import { MessageType } from '@theia/core/lib/common/message-service-protocol';
import { Board, Port } from '../common/protocol';
import {
Monitor,
@ -23,21 +26,31 @@ import { BoardsServiceProvider } from './boards/boards-service-provider';
export class MonitorManagerProxyClientImpl
implements MonitorManagerProxyClient
{
@inject(MessageService)
private readonly messageService: MessageService;
// This is necessary to call the backend methods from the frontend
@inject(MonitorManagerProxyFactory)
private readonly server: MonitorManagerProxyFactory;
@inject(BoardsServiceProvider)
private readonly boardsServiceProvider: BoardsServiceProvider;
@inject(NotificationManager)
private readonly notificationManager: NotificationManager;
// When pluggable monitor messages are received from the backend
// this event is triggered.
// Ideally a frontend component is connected to this event
// to update the UI.
protected readonly onMessagesReceivedEmitter = new Emitter<{
private readonly onMessagesReceivedEmitter = new Emitter<{
messages: string[];
}>();
readonly onMessagesReceived = this.onMessagesReceivedEmitter.event;
protected readonly onMonitorSettingsDidChangeEmitter =
private readonly onMonitorSettingsDidChangeEmitter =
new Emitter<MonitorSettings>();
readonly onMonitorSettingsDidChange =
this.onMonitorSettingsDidChangeEmitter.event;
protected readonly onMonitorShouldResetEmitter = new Emitter();
private readonly onMonitorShouldResetEmitter = new Emitter<void>();
readonly onMonitorShouldReset = this.onMonitorShouldResetEmitter.event;
// WebSocket used to handle pluggable monitor communication between
@ -51,29 +64,16 @@ export class MonitorManagerProxyClientImpl
return this.wsPort;
}
constructor(
@inject(MessageService)
protected messageService: MessageService,
// This is necessary to call the backend methods from the frontend
@inject(MonitorManagerProxyFactory)
protected server: MonitorManagerProxyFactory,
@inject(CommandRegistry)
protected readonly commandRegistry: CommandRegistry,
@inject(BoardsServiceProvider)
protected readonly boardsServiceProvider: BoardsServiceProvider
) {}
/**
* Connects a localhost WebSocket using the specified port.
* @param addressPort port of the WebSocket
*/
async connect(addressPort: number): Promise<void> {
if (!!this.webSocket) {
if (this.wsPort === addressPort) return;
else this.disconnect();
if (this.webSocket) {
if (this.wsPort === addressPort) {
return;
}
this.disconnect();
}
try {
this.webSocket = new WebSocket(`ws://localhost:${addressPort}`);
@ -87,6 +87,9 @@ export class MonitorManagerProxyClientImpl
return;
}
const opened = new Deferred<void>();
this.webSocket.onopen = () => opened.resolve();
this.webSocket.onerror = () => opened.reject();
this.webSocket.onmessage = (message) => {
const parsedMessage = JSON.parse(message.data);
if (Array.isArray(parsedMessage))
@ -99,19 +102,26 @@ export class MonitorManagerProxyClientImpl
}
};
this.wsPort = addressPort;
return opened.promise;
}
/**
* Disconnects the WebSocket if connected.
*/
disconnect(): void {
if (!this.webSocket) return;
if (!this.webSocket) {
return;
}
this.onBoardsConfigChanged?.dispose();
this.onBoardsConfigChanged = undefined;
try {
this.webSocket?.close();
this.webSocket.close();
this.webSocket = undefined;
} catch {
} catch (err) {
console.error(
'Could not close the websocket connection for the monitor.',
err
);
this.messageService.error(
nls.localize(
'arduino/monitor/unableToCloseWebSocket',
@ -126,6 +136,7 @@ export class MonitorManagerProxyClientImpl
}
async startMonitor(settings?: PluggableMonitorSettings): Promise<void> {
await this.boardsServiceProvider.reconciled;
this.lastConnectedBoard = {
selectedBoard: this.boardsServiceProvider.boardsConfig.selectedBoard,
selectedPort: this.boardsServiceProvider.boardsConfig.selectedPort,
@ -150,11 +161,11 @@ export class MonitorManagerProxyClientImpl
? Port.keyOf(this.lastConnectedBoard.selectedPort)
: undefined)
) {
this.onMonitorShouldResetEmitter.fire(null);
this.lastConnectedBoard = {
selectedBoard: selectedBoard,
selectedPort: selectedPort,
};
this.onMonitorShouldResetEmitter.fire();
} else {
// a board is plugged and it's the same as prev, rerun "this.startMonitor" to
// recreate the listener callback
@ -167,7 +178,14 @@ export class MonitorManagerProxyClientImpl
const { selectedBoard, selectedPort } =
this.boardsServiceProvider.boardsConfig;
if (!selectedBoard || !selectedBoard.fqbn || !selectedPort) return;
await this.server().startMonitor(selectedBoard, selectedPort, settings);
try {
this.clearVisibleNotification();
await this.server().startMonitor(selectedBoard, selectedPort, settings);
} catch (err) {
const message = ApplicationError.is(err) ? err.message : String(err);
this.previousNotificationId = this.notificationId(message);
this.messageService.error(message);
}
}
getCurrentSettings(board: Board, port: Port): Promise<MonitorSettings> {
@ -199,4 +217,24 @@ export class MonitorManagerProxyClientImpl
})
);
}
/**
* This is the internal (Theia) ID of the notification that is currently visible.
* It's stored here as a field to be able to close it before starting a new monitor connection. It's a hack.
*/
private previousNotificationId: string | undefined;
private clearVisibleNotification(): void {
if (this.previousNotificationId) {
this.notificationManager.clear(this.previousNotificationId);
this.previousNotificationId = undefined;
}
}
private notificationId(message: string, ...actions: string[]): string {
return this.notificationManager['getMessageId']({
text: message,
actions,
type: MessageType.Error,
});
}
}

View File

@ -4,7 +4,14 @@ import {
LocalStorageService,
} from '@theia/core/lib/browser';
import { inject, injectable } from '@theia/core/shared/inversify';
import { MonitorManagerProxyClient } from '../common/protocol';
import {
isMonitorConnected,
MonitorConnectionStatus,
monitorConnectionStatusEquals,
MonitorEOL,
MonitorManagerProxyClient,
MonitorState,
} from '../common/protocol';
import { isNullOrUndefined } from '../common/utils';
import { MonitorSettings } from '../node/monitor-settings/monitor-settings-provider';
@ -19,36 +26,36 @@ export class MonitorModel implements FrontendApplicationContribution {
protected readonly monitorManagerProxy: MonitorManagerProxyClient;
protected readonly onChangeEmitter: Emitter<
MonitorModel.State.Change<keyof MonitorModel.State>
MonitorState.Change<keyof MonitorState>
>;
protected _autoscroll: boolean;
protected _timestamp: boolean;
protected _lineEnding: MonitorModel.EOL;
protected _lineEnding: MonitorEOL;
protected _interpolate: boolean;
protected _darkTheme: boolean;
protected _wsPort: number;
protected _serialPort: string;
protected _connected: boolean;
protected _connectionStatus: MonitorConnectionStatus;
constructor() {
this._autoscroll = true;
this._timestamp = false;
this._interpolate = false;
this._lineEnding = MonitorModel.EOL.DEFAULT;
this._lineEnding = MonitorEOL.DEFAULT;
this._darkTheme = false;
this._wsPort = 0;
this._serialPort = '';
this._connected = true;
this._connectionStatus = 'not-connected';
this.onChangeEmitter = new Emitter<
MonitorModel.State.Change<keyof MonitorModel.State>
MonitorState.Change<keyof MonitorState>
>();
}
onStart(): void {
this.localStorageService
.getData<MonitorModel.State>(MonitorModel.STORAGE_ID)
.getData<MonitorState>(MonitorModel.STORAGE_ID)
.then(this.restoreState.bind(this));
this.monitorManagerProxy.onMonitorSettingsDidChange(
@ -56,11 +63,11 @@ export class MonitorModel implements FrontendApplicationContribution {
);
}
get onChange(): Event<MonitorModel.State.Change<keyof MonitorModel.State>> {
get onChange(): Event<MonitorState.Change<keyof MonitorState>> {
return this.onChangeEmitter.event;
}
protected restoreState(state: MonitorModel.State): void {
protected restoreState(state: MonitorState): void {
if (!state) {
return;
}
@ -125,11 +132,11 @@ export class MonitorModel implements FrontendApplicationContribution {
this.timestamp = !this._timestamp;
}
get lineEnding(): MonitorModel.EOL {
get lineEnding(): MonitorEOL {
return this._lineEnding;
}
set lineEnding(lineEnding: MonitorModel.EOL) {
set lineEnding(lineEnding: MonitorEOL) {
if (lineEnding === this._lineEnding) return;
this._lineEnding = lineEnding;
this.monitorManagerProxy.changeSettings({
@ -211,19 +218,26 @@ export class MonitorModel implements FrontendApplicationContribution {
);
}
get connected(): boolean {
return this._connected;
get connectionStatus(): MonitorConnectionStatus {
return this._connectionStatus;
}
set connected(connected: boolean) {
if (connected === this._connected) return;
this._connected = connected;
set connectionStatus(connectionStatus: MonitorConnectionStatus) {
if (
monitorConnectionStatusEquals(connectionStatus, this.connectionStatus)
) {
return;
}
this._connectionStatus = connectionStatus;
this.monitorManagerProxy.changeSettings({
monitorUISettings: { connected },
monitorUISettings: {
connectionStatus,
connected: isMonitorConnected(connectionStatus),
},
});
this.onChangeEmitter.fire({
property: 'connected',
value: this._connected,
property: 'connectionStatus',
value: this._connectionStatus,
});
}
@ -238,7 +252,7 @@ export class MonitorModel implements FrontendApplicationContribution {
darkTheme,
wsPort,
serialPort,
connected,
connectionStatus,
} = monitorUISettings;
if (!isNullOrUndefined(autoscroll)) this.autoscroll = autoscroll;
@ -248,31 +262,7 @@ export class MonitorModel implements FrontendApplicationContribution {
if (!isNullOrUndefined(darkTheme)) this.darkTheme = darkTheme;
if (!isNullOrUndefined(wsPort)) this.wsPort = wsPort;
if (!isNullOrUndefined(serialPort)) this.serialPort = serialPort;
if (!isNullOrUndefined(connected)) this.connected = connected;
if (!isNullOrUndefined(connectionStatus))
this.connectionStatus = connectionStatus;
};
}
// TODO: Move this to /common
export namespace MonitorModel {
export interface State {
autoscroll: boolean;
timestamp: boolean;
lineEnding: EOL;
interpolate: boolean;
darkTheme: boolean;
wsPort: number;
serialPort: string;
connected: boolean;
}
export namespace State {
export interface Change<K extends keyof State> {
readonly property: K;
readonly value: State[K];
}
}
export type EOL = '' | '\n' | '\r' | '\r\n';
export namespace EOL {
export const DEFAULT: EOL = '\n';
}
}

View File

@ -10,6 +10,7 @@ import {
import { ArduinoToolbar } from '../../toolbar/arduino-toolbar';
import { ArduinoMenus } from '../../menu/arduino-menus';
import { nls } from '@theia/core/lib/common';
import { Event } from '@theia/core/lib/common/event';
import { MonitorModel } from '../../monitor-model';
import { MonitorManagerProxyClient } from '../../../common/protocol';
@ -84,13 +85,13 @@ export class MonitorViewContribution
id: 'monitor-autoscroll',
render: () => this.renderAutoScrollButton(),
isVisible: (widget) => widget instanceof MonitorWidget,
onDidChange: this.model.onChange as any, // XXX: it's a hack. See: https://github.com/eclipse-theia/theia/pull/6696/
onDidChange: this.model.onChange as Event<unknown> as Event<void>,
});
registry.registerItem({
id: 'monitor-timestamp',
render: () => this.renderTimestampButton(),
isVisible: (widget) => widget instanceof MonitorWidget,
onDidChange: this.model.onChange as any, // XXX: it's a hack. See: https://github.com/eclipse-theia/theia/pull/6696/
onDidChange: this.model.onChange as Event<unknown> as Event<void>,
});
registry.registerItem({
id: SerialMonitor.Commands.CLEAR_OUTPUT.id,
@ -143,8 +144,7 @@ export class MonitorViewContribution
protected async reset(): Promise<void> {
const widget = this.tryGetWidget();
if (widget) {
widget.dispose();
await this.openView({ activate: true, reveal: true });
widget.reset();
}
}

View File

@ -1,7 +1,14 @@
import * as React from '@theia/core/shared/react';
import { injectable, inject } from '@theia/core/shared/inversify';
import {
injectable,
inject,
postConstruct,
} from '@theia/core/shared/inversify';
import { Emitter } from '@theia/core/lib/common/event';
import { Disposable } from '@theia/core/lib/common/disposable';
import {
Disposable,
DisposableCollection,
} from '@theia/core/lib/common/disposable';
import {
ReactWidget,
Message,
@ -13,9 +20,13 @@ import { SerialMonitorSendInput } from './serial-monitor-send-input';
import { SerialMonitorOutput } from './serial-monitor-send-output';
import { BoardsServiceProvider } from '../../boards/boards-service-provider';
import { nls } from '@theia/core/lib/common';
import { MonitorManagerProxyClient } from '../../../common/protocol';
import {
MonitorEOL,
MonitorManagerProxyClient,
} from '../../../common/protocol';
import { MonitorModel } from '../../monitor-model';
import { MonitorSettings } from '../../../node/monitor-settings/monitor-settings-provider';
import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
@injectable()
export class MonitorWidget extends ReactWidget {
@ -40,40 +51,46 @@ export class MonitorWidget extends ReactWidget {
protected closing = false;
protected readonly clearOutputEmitter = new Emitter<void>();
constructor(
@inject(MonitorModel)
protected readonly monitorModel: MonitorModel,
@inject(MonitorModel)
private readonly monitorModel: MonitorModel;
@inject(MonitorManagerProxyClient)
private readonly monitorManagerProxy: MonitorManagerProxyClient;
@inject(BoardsServiceProvider)
private readonly boardsServiceProvider: BoardsServiceProvider;
@inject(FrontendApplicationStateService)
private readonly appStateService: FrontendApplicationStateService;
@inject(MonitorManagerProxyClient)
protected readonly monitorManagerProxy: MonitorManagerProxyClient,
private readonly toDisposeOnReset: DisposableCollection;
@inject(BoardsServiceProvider)
protected readonly boardsServiceProvider: BoardsServiceProvider
) {
constructor() {
super();
this.id = MonitorWidget.ID;
this.title.label = MonitorWidget.LABEL;
this.title.iconClass = 'monitor-tab-icon';
this.title.closable = true;
this.scrollOptions = undefined;
this.toDisposeOnReset = new DisposableCollection();
this.toDispose.push(this.clearOutputEmitter);
this.toDispose.push(
Disposable.create(() => this.monitorManagerProxy.disconnect())
);
}
protected override onBeforeAttach(msg: Message): void {
this.update();
this.toDispose.push(this.monitorModel.onChange(() => this.update()));
this.getCurrentSettings().then(this.onMonitorSettingsDidChange.bind(this));
this.monitorManagerProxy.onMonitorSettingsDidChange(
this.onMonitorSettingsDidChange.bind(this)
);
this.monitorManagerProxy.startMonitor();
@postConstruct()
protected init(): void {
this.toDisposeOnReset.dispose();
this.toDisposeOnReset.pushAll([
Disposable.create(() => this.monitorManagerProxy.disconnect()),
this.monitorModel.onChange(() => this.update()),
this.monitorManagerProxy.onMonitorSettingsDidChange((event) =>
this.updateSettings(event)
),
]);
this.startMonitor();
}
onMonitorSettingsDidChange(settings: MonitorSettings): void {
reset(): void {
this.init();
}
private updateSettings(settings: MonitorSettings): void {
this.settings = {
...this.settings,
pluggableMonitorSettings: {
@ -90,6 +107,7 @@ export class MonitorWidget extends ReactWidget {
}
override dispose(): void {
this.toDisposeOnReset.dispose();
super.dispose();
}
@ -122,7 +140,7 @@ export class MonitorWidget extends ReactWidget {
this.update();
}
protected onFocusResolved = (element: HTMLElement | undefined) => {
protected onFocusResolved = (element: HTMLElement | undefined): void => {
if (this.closing || !this.isAttached) {
return;
}
@ -132,7 +150,7 @@ export class MonitorWidget extends ReactWidget {
);
};
protected get lineEndings(): SerialMonitorOutput.SelectOption<MonitorModel.EOL>[] {
protected get lineEndings(): SerialMonitorOutput.SelectOption<MonitorEOL>[] {
return [
{
label: nls.localize('arduino/serial/noLineEndings', 'No Line Ending'),
@ -156,11 +174,23 @@ export class MonitorWidget extends ReactWidget {
];
}
private getCurrentSettings(): Promise<MonitorSettings> {
private async startMonitor(): Promise<void> {
await this.appStateService.reachedState('ready');
await this.boardsServiceProvider.reconciled;
await this.syncSettings();
await this.monitorManagerProxy.startMonitor();
}
private async syncSettings(): Promise<void> {
const settings = await this.getCurrentSettings();
this.updateSettings(settings);
}
private async getCurrentSettings(): Promise<MonitorSettings> {
const board = this.boardsServiceProvider.boardsConfig.selectedBoard;
const port = this.boardsServiceProvider.boardsConfig.selectedPort;
if (!board || !port) {
return Promise.resolve(this.settings || {});
return this.settings || {};
}
return this.monitorManagerProxy.getCurrentSettings(board, port);
}
@ -171,7 +201,7 @@ export class MonitorWidget extends ReactWidget {
: undefined;
const baudrateOptions = baudrate?.values.map((b) => ({
label: b + ' baud',
label: nls.localize('arduino/monitor/baudRate', '{0} baud', b),
value: b,
}));
const baudrateSelectedOption = baudrateOptions?.find(
@ -181,7 +211,7 @@ export class MonitorWidget extends ReactWidget {
const lineEnding =
this.lineEndings.find(
(item) => item.value === this.monitorModel.lineEnding
) || this.lineEndings[1]; // Defaults to `\n`.
) || MonitorEOL.DEFAULT;
return (
<div className="serial-monitor">
@ -228,13 +258,13 @@ export class MonitorWidget extends ReactWidget {
);
}
protected readonly onSend = (value: string) => this.doSend(value);
protected async doSend(value: string): Promise<void> {
protected readonly onSend = (value: string): void => this.doSend(value);
protected doSend(value: string): void {
this.monitorManagerProxy.send(value);
}
protected readonly onChangeLineEnding = (
option: SerialMonitorOutput.SelectOption<MonitorModel.EOL>
option: SerialMonitorOutput.SelectOption<MonitorEOL>
): void => {
this.monitorModel.lineEnding = option.value;
};

View File

@ -5,6 +5,10 @@ import { DisposableCollection, nls } from '@theia/core/lib/common';
import { BoardsServiceProvider } from '../../boards/boards-service-provider';
import { MonitorModel } from '../../monitor-model';
import { Unknown } from '../../../common/nls';
import {
isMonitorConnectionError,
MonitorConnectionStatus,
} from '../../../common/protocol';
class HistoryList {
private readonly items: string[] = [];
@ -62,7 +66,7 @@ export namespace SerialMonitorSendInput {
}
export interface State {
text: string;
connected: boolean;
connectionStatus: MonitorConnectionStatus;
history: HistoryList;
}
}
@ -75,18 +79,27 @@ export class SerialMonitorSendInput extends React.Component<
constructor(props: Readonly<SerialMonitorSendInput.Props>) {
super(props);
this.state = { text: '', connected: true, history: new HistoryList() };
this.state = {
text: '',
connectionStatus: 'not-connected',
history: new HistoryList(),
};
this.onChange = this.onChange.bind(this);
this.onSend = this.onSend.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
}
override componentDidMount(): void {
this.setState({ connected: this.props.monitorModel.connected });
this.setState({
connectionStatus: this.props.monitorModel.connectionStatus,
});
this.toDisposeBeforeUnmount.push(
this.props.monitorModel.onChange(({ property }) => {
if (property === 'connected')
this.setState({ connected: this.props.monitorModel.connected });
if (property === 'connected' || property === 'connectionStatus') {
this.setState({
connectionStatus: this.props.monitorModel.connectionStatus,
});
}
})
);
}
@ -97,44 +110,83 @@ export class SerialMonitorSendInput extends React.Component<
}
override render(): React.ReactNode {
const status = this.state.connectionStatus;
const input = this.renderInput(status);
if (status !== 'connecting') {
return input;
}
return <label>{input}</label>;
}
private renderInput(status: MonitorConnectionStatus): React.ReactNode {
const inputClassName = this.inputClassName(status);
const placeholder = this.placeholder;
const readOnly = Boolean(inputClassName);
return (
<input
ref={this.setRef}
type="text"
className={`theia-input ${this.shouldShowWarning() ? 'warning' : ''}`}
placeholder={this.placeholder}
value={this.state.text}
className={`theia-input ${inputClassName}`}
readOnly={readOnly}
placeholder={placeholder}
title={placeholder}
value={readOnly ? '' : this.state.text} // always show the placeholder if cannot edit the <input>
onChange={this.onChange}
onKeyDown={this.onKeyDown}
/>
);
}
private inputClassName(
status: MonitorConnectionStatus
): 'error' | 'warning' | '' {
if (isMonitorConnectionError(status)) {
return 'error';
}
if (status === 'connected') {
return '';
}
return 'warning';
}
protected shouldShowWarning(): boolean {
const board = this.props.boardsServiceProvider.boardsConfig.selectedBoard;
const port = this.props.boardsServiceProvider.boardsConfig.selectedPort;
return !this.state.connected || !board || !port;
return !this.state.connectionStatus || !board || !port;
}
protected get placeholder(): string {
if (this.shouldShowWarning()) {
const status = this.state.connectionStatus;
if (isMonitorConnectionError(status)) {
return status.errorMessage;
}
if (status === 'not-connected') {
return nls.localize(
'arduino/serial/notConnected',
'Not connected. Select a board and a port to connect automatically.'
);
}
const board = this.props.boardsServiceProvider.boardsConfig.selectedBoard;
const port = this.props.boardsServiceProvider.boardsConfig.selectedPort;
const boardLabel = board
? Board.toString(board, {
useFqbn: false,
})
: Unknown;
const portLabel = port ? port.address : Unknown;
if (status === 'connecting') {
return nls.localize(
'arduino/serial/connecting',
"Connecting to '{0}' on '{1}'...",
boardLabel,
portLabel
);
}
return nls.localize(
'arduino/serial/message',
"Message (Enter to send message to '{0}' on '{1}')",
board
? Board.toString(board, {
useFqbn: false,
})
: Unknown,
port ? port.address : Unknown
boardLabel,
portLabel
);
}

View File

@ -29,9 +29,11 @@
/* https://github.com/arduino/arduino-ide/pull/1662#issuecomment-1324997134 */
body {
--theia-icon-loading: url(../icons/loading-light.svg);
--theia-icon-loading-warning: url(../icons/loading-dark.svg);
}
body.theia-dark {
--theia-icon-loading: url(../icons/loading-dark.svg);
--theia-icon-loading-warning: url(../icons/loading-light.svg);
}
.theia-input.warning:focus {
@ -48,22 +50,32 @@ body.theia-dark {
}
.theia-input.warning::placeholder {
/* Chrome, Firefox, Opera, Safari 10.1+ */
color: var(--theia-warningForeground);
background-color: var(--theia-warningBackground);
opacity: 1; /* Firefox */
}
.theia-input.warning:-ms-input-placeholder {
/* Internet Explorer 10-11 */
color: var(--theia-warningForeground);
background-color: var(--theia-warningBackground);
}
.theia-input.warning::-ms-input-placeholder {
/* Microsoft Edge */
color: var(--theia-warningForeground);
background-color: var(--theia-warningBackground);
.hc-black.hc-theia.theia-hc .theia-input.warning,
.hc-black.hc-theia.theia-hc .theia-input.warning::placeholder {
color: var(--theia-warningBackground);
background-color: var(--theia-warningForeground);
}
.theia-input.error:focus {
outline-width: 1px;
outline-style: solid;
outline-offset: -1px;
opacity: 1 !important;
color: var(--theia-errorForeground);
background-color: var(--theia-errorBackground);
}
.theia-input.error {
background-color: var(--theia-errorBackground);
}
.theia-input.error::placeholder {
color: var(--theia-errorForeground);
background-color: var(--theia-errorBackground);
}
/* Makes the sidepanel a bit wider when opening the widget */

View File

@ -20,22 +20,47 @@
.serial-monitor .head {
display: flex;
padding: 5px;
padding: 0px 5px 5px 0px;
height: 27px;
background-color: var(--theia-activityBar-background);
}
.serial-monitor .head .send {
display: flex;
flex: 1;
margin-right: 2px;
}
.serial-monitor .head .send > input {
.serial-monitor .head .send > label:before {
content: "";
position: absolute;
top: -1px;
background: var(--theia-icon-loading-warning) center center no-repeat;
animation: theia-spin 1.25s linear infinite;
width: 30px;
height: 30px;
}
.serial-monitor .head .send > label {
position: relative;
width: 100%;
display: flex;
align-self: baseline;
}
.serial-monitor .head .send > input,
.serial-monitor .head .send > label > input {
line-height: var(--theia-content-line-height);
height: 27px;
width: 100%;
}
.serial-monitor .head .send > input:focus {
.serial-monitor .head .send > label > input {
padding-left: 30px;
box-sizing: border-box;
}
.serial-monitor .head .send > input:focus,
.serial-monitor .head .send > label > input:focus {
border-color: var(--theia-focusBorder);
}

View File

@ -73,12 +73,12 @@ export namespace CoreError {
UploadUsingProgrammer: 4003,
BurnBootloader: 4004,
};
export const VerifyFailed = create(Codes.Verify);
export const UploadFailed = create(Codes.Upload);
export const UploadUsingProgrammerFailed = create(
export const VerifyFailed = declareCoreError(Codes.Verify);
export const UploadFailed = declareCoreError(Codes.Upload);
export const UploadUsingProgrammerFailed = declareCoreError(
Codes.UploadUsingProgrammer
);
export const BurnBootloaderFailed = create(Codes.BurnBootloader);
export const BurnBootloaderFailed = declareCoreError(Codes.BurnBootloader);
export function is(
error: unknown
): error is ApplicationError<number, ErrorLocation[]> {
@ -88,7 +88,7 @@ export namespace CoreError {
Object.values(Codes).includes(error.code)
);
}
function create(
function declareCoreError(
code: number
): ApplicationError.Constructor<number, ErrorLocation[]> {
return ApplicationError.declare(

View File

@ -1,4 +1,4 @@
import { Event, JsonRpcServer } from '@theia/core';
import { ApplicationError, Event, JsonRpcServer, nls } from '@theia/core';
import {
PluggableMonitorSettings,
MonitorSettings,
@ -31,7 +31,7 @@ export interface MonitorManagerProxyClient {
onMessagesReceived: Event<{ messages: string[] }>;
onMonitorSettingsDidChange: Event<MonitorSettings>;
onMonitorShouldReset: Event<void>;
connect(addressPort: number): void;
connect(addressPort: number): Promise<void>;
disconnect(): void;
getWebSocketPort(): number | undefined;
isWSConnected(): Promise<boolean>;
@ -46,7 +46,7 @@ export interface PluggableMonitorSetting {
readonly id: string;
// A human-readable label of the setting (to be displayed on the GUI)
readonly label: string;
// The setting type (at the moment only "enum" is avaiable)
// The setting type (at the moment only "enum" is available)
readonly type: string;
// The values allowed on "enum" types
readonly values: string[];
@ -72,24 +72,168 @@ export namespace Monitor {
};
}
export interface Status {}
export type OK = Status;
export interface ErrorStatus extends Status {
readonly message: string;
}
export namespace Status {
export function isOK(status: Status & { message?: string }): status is OK {
return !!status && typeof status.message !== 'string';
export const MonitorErrorCodes = {
ConnectionFailed: 6001,
NotConnected: 6002,
AlreadyConnected: 6003,
MissingConfiguration: 6004,
} as const;
export const ConnectionFailedError = declareMonitorError(
MonitorErrorCodes.ConnectionFailed
);
export const NotConnectedError = declareMonitorError(
MonitorErrorCodes.NotConnected
);
export const AlreadyConnectedError = declareMonitorError(
MonitorErrorCodes.AlreadyConnected
);
export const MissingConfigurationError = declareMonitorError(
MonitorErrorCodes.MissingConfiguration
);
export function createConnectionFailedError(
port: Port,
details?: string
): ApplicationError<number, PortDescriptor> {
const { protocol, address } = port;
let message;
if (details) {
const detailsWithPeriod = details.endsWith('.') ? details : `${details}.`;
message = nls.localize(
'arduino/monitor/connectionFailedErrorWithDetails',
'{0} Could not connect to {1} {2} port.',
detailsWithPeriod,
address,
protocol
);
} else {
message = nls.localize(
'arduino/monitor/connectionFailedError',
'Could not connect to {0} {1} port.',
address,
protocol
);
}
export const OK: OK = {};
export const NOT_CONNECTED: ErrorStatus = { message: 'Not connected.' };
export const ALREADY_CONNECTED: ErrorStatus = {
message: 'Already connected.',
};
export const CONFIG_MISSING: ErrorStatus = {
message: 'Serial Config missing.',
};
export const UPLOAD_IN_PROGRESS: ErrorStatus = {
message: 'Upload in progress.',
};
return ConnectionFailedError(message, { protocol, address });
}
export function createNotConnectedError(
port: Port
): ApplicationError<number, PortDescriptor> {
const { protocol, address } = port;
return NotConnectedError(
nls.localize(
'arduino/monitor/notConnectedError',
'Not connected to {0} {1} port.',
address,
protocol
),
{ protocol, address }
);
}
export function createAlreadyConnectedError(
port: Port
): ApplicationError<number, PortDescriptor> {
const { protocol, address } = port;
return AlreadyConnectedError(
nls.localize(
'arduino/monitor/alreadyConnectedError',
'Could not connect to {0} {1} port. Already connected.',
address,
protocol
),
{ protocol, address }
);
}
export function createMissingConfigurationError(
port: Port
): ApplicationError<number, PortDescriptor> {
const { protocol, address } = port;
return MissingConfigurationError(
nls.localize(
'arduino/monitor/missingConfigurationError',
'Could not connect to {0} {1} port. The monitor configuration is missing.',
address,
protocol
),
{ protocol, address }
);
}
/**
* Bare minimum representation of a port. Supports neither UI labels nor properties.
*/
interface PortDescriptor {
readonly protocol: string;
readonly address: string;
}
function declareMonitorError(
code: number
): ApplicationError.Constructor<number, PortDescriptor> {
return ApplicationError.declare(
code,
(message: string, data: PortDescriptor) => ({ data, message })
);
}
export interface MonitorConnectionError {
readonly errorMessage: string;
}
export type MonitorConnectionStatus =
| 'connecting'
| 'connected'
| 'not-connected'
| MonitorConnectionError;
export function monitorConnectionStatusEquals(
left: MonitorConnectionStatus,
right: MonitorConnectionStatus
): boolean {
if (typeof left === 'object' && typeof right === 'object') {
return left.errorMessage === right.errorMessage;
}
return left === right;
}
/**
* @deprecated see `MonitorState#connected`
*/
export function isMonitorConnected(
status: MonitorConnectionStatus
): status is 'connected' {
return status === 'connected';
}
export function isMonitorConnectionError(
status: MonitorConnectionStatus
): status is MonitorConnectionError {
return typeof status === 'object';
}
export interface MonitorState {
autoscroll: boolean;
timestamp: boolean;
lineEnding: MonitorEOL;
interpolate: boolean;
darkTheme: boolean;
wsPort: number;
serialPort: string;
connectionStatus: MonitorConnectionStatus;
/**
* @deprecated This property is never get by IDE2 only set. This value is present to be backward compatible with the plotter app.
* IDE2 uses `MonitorState#connectionStatus`.
*/
connected: boolean;
}
export namespace MonitorState {
export interface Change<K extends keyof MonitorState> {
readonly property: K;
readonly value: MonitorState[K];
}
}
export type MonitorEOL = '' | '\n' | '\r' | '\r\n';
export namespace MonitorEOL {
export const DEFAULT: MonitorEOL = '\n';
}

View File

@ -23,7 +23,7 @@ import {
UploadUsingProgrammerResponse,
} from './cli-protocol/cc/arduino/cli/commands/v1/upload_pb';
import { ResponseService } from '../common/protocol/response-service';
import { OutputMessage, Port, Status } from '../common/protocol';
import { OutputMessage, Port } from '../common/protocol';
import { ArduinoCoreServiceClient } from './cli-protocol/cc/arduino/cli/commands/v1/commands_grpc_pb';
import { Port as RpcPort } from './cli-protocol/cc/arduino/cli/commands/v1/port_pb';
import { ApplicationError, CommandService, Disposable, nls } from '@theia/core';
@ -392,7 +392,7 @@ export class CoreServiceImpl extends CoreClientAware implements CoreService {
}: {
fqbn?: string | undefined;
port?: Port | undefined;
}): Promise<Status> {
}): Promise<void> {
this.boardDiscovery.setUploadInProgress(false);
return this.monitorManager.notifyUploadFinished(fqbn, port);
}

View File

@ -2,7 +2,6 @@ import { inject, injectable } from '@theia/core/shared/inversify';
import {
MonitorManagerProxy,
MonitorManagerProxyClient,
Status,
} from '../common/protocol';
import { Board, Port } from '../common/protocol';
import { MonitorManager } from './monitor-manager';
@ -41,11 +40,16 @@ export class MonitorManagerProxyImpl implements MonitorManagerProxy {
await this.changeMonitorSettings(board, port, settings);
}
const connectToClient = (status: Status) => {
if (status === Status.ALREADY_CONNECTED || status === Status.OK) {
// Monitor started correctly, connect it with the frontend
this.client.connect(this.manager.getWebsocketAddressPort(board, port));
const connectToClient = async () => {
const address = this.manager.getWebsocketAddressPort(board, port);
if (!this.client) {
throw new Error(
`No client was connected to this monitor manager. Board: ${
board.fqbn ?? board.name
}, port: ${port.address}, address: ${address}`
);
}
await this.client.connect(address);
};
return this.manager.startMonitor(board, port, connectToClient);
}

View File

@ -1,6 +1,11 @@
import { ILogger } from '@theia/core';
import { inject, injectable, named } from '@theia/core/shared/inversify';
import { Board, BoardsService, Port, Status } from '../common/protocol';
import {
AlreadyConnectedError,
Board,
BoardsService,
Port,
} from '../common/protocol';
import { CoreClientAware } from './core-client-provider';
import { MonitorService } from './monitor-service';
import { MonitorServiceFactory } from './monitor-service-factory';
@ -36,7 +41,7 @@ export class MonitorManager extends CoreClientAware {
private monitorServiceStartQueue: {
monitorID: string;
serviceStartParams: [Board, Port];
connectToClient: (status: Status) => void;
connectToClient: () => Promise<void>;
}[] = [];
@inject(MonitorServiceFactory)
@ -104,7 +109,7 @@ export class MonitorManager extends CoreClientAware {
async startMonitor(
board: Board,
port: Port,
connectToClient: (status: Status) => void
connectToClient: () => Promise<void>
): Promise<void> {
const monitorID = this.monitorID(board.fqbn, port);
@ -127,8 +132,14 @@ export class MonitorManager extends CoreClientAware {
return;
}
const result = await monitor.start();
connectToClient(result);
try {
await connectToClient();
await monitor.start();
} catch (err) {
if (!AlreadyConnectedError.is(err)) {
throw err;
}
}
}
/**
@ -202,8 +213,7 @@ export class MonitorManager extends CoreClientAware {
async notifyUploadFinished(
fqbn?: string | undefined,
port?: Port
): Promise<Status> {
let status: Status = Status.NOT_CONNECTED;
): Promise<void> {
let portDidChangeOnUpload = false;
// We have no way of knowing which monitor
@ -214,7 +224,7 @@ export class MonitorManager extends CoreClientAware {
const monitor = this.monitorServices.get(monitorID);
if (monitor) {
status = await monitor.start();
await monitor.start();
}
// this monitorID will only be present in "disposedForUpload"
@ -232,7 +242,6 @@ export class MonitorManager extends CoreClientAware {
}
await this.startQueuedServices(portDidChangeOnUpload);
return status;
}
async startQueuedServices(portDidChangeOnUpload: boolean): Promise<void> {
@ -246,7 +255,7 @@ export class MonitorManager extends CoreClientAware {
for (const {
monitorID,
serviceStartParams: [_, port],
serviceStartParams: [, port],
connectToClient,
} of queued) {
const boardsState = await this.boardsService.getState();
@ -261,8 +270,8 @@ export class MonitorManager extends CoreClientAware {
const monitorService = this.monitorServices.get(monitorID);
if (monitorService) {
const result = await monitorService.start();
connectToClient(result);
await connectToClient();
await monitorService.start();
}
}
}

View File

@ -1,8 +1,23 @@
import { ClientDuplexStream } from '@grpc/grpc-js';
import { Disposable, Emitter, ILogger } from '@theia/core';
import { ClientDuplexStream, status } from '@grpc/grpc-js';
import {
ApplicationError,
Disposable,
Emitter,
ILogger,
nls,
} from '@theia/core';
import { inject, named, postConstruct } from '@theia/core/shared/inversify';
import { diff, Operation } from 'just-diff';
import { Board, Port, Status, Monitor } from '../common/protocol';
import {
Board,
Port,
Monitor,
createAlreadyConnectedError,
createMissingConfigurationError,
createNotConnectedError,
createConnectionFailedError,
isMonitorConnected,
} from '../common/protocol';
import {
EnumerateMonitorPortSettingsRequest,
EnumerateMonitorPortSettingsResponse,
@ -19,8 +34,13 @@ import {
PluggableMonitorSettings,
MonitorSettingsProvider,
} from './monitor-settings/monitor-settings-provider';
import { Deferred } from '@theia/core/lib/common/promise-util';
import {
Deferred,
retry,
timeoutReject,
} from '@theia/core/lib/common/promise-util';
import { MonitorServiceFactoryOptions } from './monitor-service-factory';
import { ServiceError } from './service-error';
export const MonitorServiceName = 'monitor-service';
type DuplexHandlerKeys =
@ -76,7 +96,7 @@ export class MonitorService extends CoreClientAware implements Disposable {
readonly onDispose = this.onDisposeEmitter.event;
private _initialized = new Deferred<void>();
private creating: Deferred<Status>;
private creating: Deferred<void>;
private readonly board: Board;
private readonly port: Port;
private readonly monitorID: string;
@ -114,7 +134,7 @@ export class MonitorService extends CoreClientAware implements Disposable {
this.updateClientsSettings(this.settings);
});
this.portMonitorSettings(this.port.protocol, this.board.fqbn!).then(
this.portMonitorSettings(this.port.protocol, this.board.fqbn!, true).then(
async (settings) => {
this.settings = {
...this.settings,
@ -154,74 +174,85 @@ export class MonitorService extends CoreClientAware implements Disposable {
/**
* Start and connects a monitor using currently set board and port.
* If a monitor is already started or board fqbn, port address and/or protocol
* are missing nothing happens.
* @returns a status to verify connection has been established.
* If a monitor is already started, the promise will reject with an `AlreadyConnectedError`.
* If the board fqbn, port address and/or protocol are missing, the promise rejects with a `MissingConfigurationError`.
*/
async start(): Promise<Status> {
async start(): Promise<void> {
if (this.creating?.state === 'unresolved') return this.creating.promise;
this.creating = new Deferred();
if (this.duplex) {
this.updateClientsSettings({
monitorUISettings: { connected: true, serialPort: this.port.address },
monitorUISettings: {
connectionStatus: 'connected',
connected: true, // TODO: should be removed when plotter app understand the `connectionStatus` message
serialPort: this.port.address,
},
});
this.creating.resolve(Status.ALREADY_CONNECTED);
this.creating.reject(createAlreadyConnectedError(this.port));
return this.creating.promise;
}
if (!this.board?.fqbn || !this.port?.address || !this.port?.protocol) {
this.updateClientsSettings({ monitorUISettings: { connected: false } });
this.updateClientsSettings({
monitorUISettings: {
connectionStatus: 'not-connected',
connected: false, // TODO: should be removed when plotter app understand the `connectionStatus` message
},
});
this.creating.resolve(Status.CONFIG_MISSING);
this.creating.reject(createMissingConfigurationError(this.port));
return this.creating.promise;
}
this.logger.info('starting monitor');
// get default monitor settings from the CLI
const defaultSettings = await this.portMonitorSettings(
this.port.protocol,
this.board.fqbn
);
// get actual settings from the settings provider
this.settings = {
...this.settings,
pluggableMonitorSettings: {
...this.settings.pluggableMonitorSettings,
...(await this.monitorSettingsProvider.getSettings(
this.monitorID,
defaultSettings
)),
},
};
try {
// get default monitor settings from the CLI
const defaultSettings = await this.portMonitorSettings(
this.port.protocol,
this.board.fqbn
);
const coreClient = await this.coreClient;
this.updateClientsSettings({
monitorUISettings: { connectionStatus: 'connecting' },
});
const { instance } = coreClient;
const monitorRequest = new MonitorRequest();
monitorRequest.setInstance(instance);
if (this.board?.fqbn) {
monitorRequest.setFqbn(this.board.fqbn);
}
if (this.port?.address && this.port?.protocol) {
const rpcPort = new RpcPort();
rpcPort.setAddress(this.port.address);
rpcPort.setProtocol(this.port.protocol);
monitorRequest.setPort(rpcPort);
}
const config = new MonitorPortConfiguration();
for (const id in this.settings.pluggableMonitorSettings) {
const s = new MonitorPortSetting();
s.setSettingId(id);
s.setValue(this.settings.pluggableMonitorSettings[id].selectedValue);
config.addSettings(s);
}
monitorRequest.setPortConfiguration(config);
// get actual settings from the settings provider
this.settings = {
...this.settings,
pluggableMonitorSettings: {
...this.settings.pluggableMonitorSettings,
...(await this.monitorSettingsProvider.getSettings(
this.monitorID,
defaultSettings
)),
},
};
const wroteToStreamSuccessfully = await this.pollWriteToStream(
monitorRequest
);
if (wroteToStreamSuccessfully) {
const coreClient = await this.coreClient;
const { instance } = coreClient;
const monitorRequest = new MonitorRequest();
monitorRequest.setInstance(instance);
if (this.board?.fqbn) {
monitorRequest.setFqbn(this.board.fqbn);
}
if (this.port?.address && this.port?.protocol) {
const rpcPort = new RpcPort();
rpcPort.setAddress(this.port.address);
rpcPort.setProtocol(this.port.protocol);
monitorRequest.setPort(rpcPort);
}
const config = new MonitorPortConfiguration();
for (const id in this.settings.pluggableMonitorSettings) {
const s = new MonitorPortSetting();
s.setSettingId(id);
s.setValue(this.settings.pluggableMonitorSettings[id].selectedValue);
config.addSettings(s);
}
monitorRequest.setPortConfiguration(config);
await this.pollWriteToStream(monitorRequest);
// Only store the config, if the monitor has successfully started.
this.currentPortConfigSnapshot = MonitorPortConfiguration.toObject(
false,
@ -237,15 +268,34 @@ export class MonitorService extends CoreClientAware implements Disposable {
`started monitor to ${this.port?.address} using ${this.port?.protocol}`
);
this.updateClientsSettings({
monitorUISettings: { connected: true, serialPort: this.port.address },
monitorUISettings: {
connectionStatus: 'connected',
connected: true, // TODO: should be removed when plotter app understand the `connectionStatus` message
serialPort: this.port.address,
},
});
this.creating.resolve(Status.OK);
this.creating.resolve();
return this.creating.promise;
} else {
} catch (err) {
this.logger.warn(
`failed starting monitor to ${this.port?.address} using ${this.port?.protocol}`
);
this.creating.resolve(Status.NOT_CONNECTED);
const appError = ApplicationError.is(err)
? err
: createConnectionFailedError(
this.port,
ServiceError.is(err)
? err.details
: err instanceof Error
? err.message
: String(err)
);
this.creating.reject(appError);
this.updateClientsSettings({
monitorUISettings: {
connectionStatus: { errorMessage: appError.message },
},
});
return this.creating.promise;
}
}
@ -264,19 +314,29 @@ export class MonitorService extends CoreClientAware implements Disposable {
// default handlers
duplex
.on('close', () => {
this.duplex = null;
this.updateClientsSettings({
monitorUISettings: { connected: false },
});
if (duplex === this.duplex) {
this.duplex = null;
this.updateClientsSettings({
monitorUISettings: {
connected: false, // TODO: should be removed when plotter app understand the `connectionStatus` message
connectionStatus: 'not-connected',
},
});
}
this.logger.info(
`monitor to ${this.port?.address} using ${this.port?.protocol} closed by client`
);
})
.on('end', () => {
this.duplex = null;
this.updateClientsSettings({
monitorUISettings: { connected: false },
});
if (duplex === this.duplex) {
this.duplex = null;
this.updateClientsSettings({
monitorUISettings: {
connected: false, // TODO: should be removed when plotter app understand the `connectionStatus` message
connectionStatus: 'not-connected',
},
});
}
this.logger.info(
`monitor to ${this.port?.address} using ${this.port?.protocol} closed by server`
);
@ -287,21 +347,17 @@ export class MonitorService extends CoreClientAware implements Disposable {
}
}
pollWriteToStream(request: MonitorRequest): Promise<boolean> {
let attemptsRemaining = MAX_WRITE_TO_STREAM_TRIES;
const writeTimeoutMs = WRITE_TO_STREAM_TIMEOUT_MS;
pollWriteToStream(request: MonitorRequest): Promise<void> {
const createWriteToStreamExecutor =
(duplex: ClientDuplexStream<MonitorRequest, MonitorResponse>) =>
(resolve: (value: boolean) => void, reject: () => void) => {
(resolve: () => void, reject: (reason?: unknown) => void) => {
const resolvingDuplexHandlers: DuplexHandler[] = [
{
key: 'error',
callback: async (err: Error) => {
this.logger.error(err);
resolve(false);
// TODO
// this.theiaFEClient?.notifyError()
const details = ServiceError.is(err) ? err.details : err.message;
reject(createConnectionFailedError(this.port, details));
},
},
{
@ -313,79 +369,47 @@ export class MonitorService extends CoreClientAware implements Disposable {
return;
}
if (monitorResponse.getSuccess()) {
resolve(true);
resolve();
return;
}
const data = monitorResponse.getRxData();
const message =
typeof data === 'string'
? data
: this.streamingTextDecoder.decode(data, {stream:true});
: this.streamingTextDecoder.decode(data, { stream: true });
this.messages.push(...splitLines(message));
},
},
];
this.setDuplexHandlers(duplex, resolvingDuplexHandlers);
setTimeout(() => {
reject();
}, writeTimeoutMs);
duplex.write(request);
};
const pollWriteToStream = new Promise<boolean>((resolve) => {
const startPolling = async () => {
// here we create a new duplex but we don't yet
// set "this.duplex", nor do we use "this.duplex" in our poll
// as duplex 'end' / 'close' events (which we do not "await")
// will set "this.duplex" to null
const createdDuplex = await this.createDuplex();
let pollingIsSuccessful;
// attempt a "writeToStream" and "await" CLI response: success (true) or error (false)
// if we get neither within WRITE_TO_STREAM_TIMEOUT_MS or an error we get undefined
try {
const writeToStream = createWriteToStreamExecutor(createdDuplex);
pollingIsSuccessful = await new Promise(writeToStream);
} catch (error) {
this.logger.error(error);
}
// CLI confirmed port opened successfully
if (pollingIsSuccessful) {
this.duplex = createdDuplex;
resolve(true);
return;
}
// if "pollingIsSuccessful" is false
// the CLI gave us an error, lets try again
// after waiting 2 seconds if we've not already
// reached MAX_WRITE_TO_STREAM_TRIES
if (pollingIsSuccessful === false) {
attemptsRemaining -= 1;
if (attemptsRemaining > 0) {
setTimeout(startPolling, 2000);
return;
} else {
resolve(false);
return;
return Promise.race([
retry(
async () => {
let createdDuplex = undefined;
try {
createdDuplex = await this.createDuplex();
await new Promise<void>(createWriteToStreamExecutor(createdDuplex));
this.duplex = createdDuplex;
} catch (err) {
createdDuplex?.end();
throw err;
}
}
// "pollingIsSuccessful" remains undefined:
// we got no response from the CLI within 30 seconds
// resolve to false and end the duplex connection
resolve(false);
createdDuplex.end();
return;
};
startPolling();
});
return pollWriteToStream;
},
2_000,
MAX_WRITE_TO_STREAM_TRIES
),
timeoutReject(
WRITE_TO_STREAM_TIMEOUT_MS,
nls.localize(
'arduino/monitor/connectionTimeout',
"Timeout. The IDE has not received the 'success' message from the monitor after successfully connecting to it"
)
),
]) as Promise<unknown> as Promise<void>;
}
/**
@ -429,9 +453,9 @@ export class MonitorService extends CoreClientAware implements Disposable {
* @param message string sent to running monitor
* @returns a status to verify message has been sent.
*/
async send(message: string): Promise<Status> {
async send(message: string): Promise<void> {
if (!this.duplex) {
return Status.NOT_CONNECTED;
throw createNotConnectedError(this.port);
}
const coreClient = await this.coreClient;
const { instance } = coreClient;
@ -439,14 +463,12 @@ export class MonitorService extends CoreClientAware implements Disposable {
const req = new MonitorRequest();
req.setInstance(instance);
req.setTxData(new TextEncoder().encode(message));
return new Promise<Status>((resolve) => {
return new Promise<void>((resolve, reject) => {
if (this.duplex) {
this.duplex?.write(req, () => {
resolve(Status.OK);
});
this.duplex?.write(req, resolve);
return;
}
this.stop().then(() => resolve(Status.NOT_CONNECTED));
this.stop().then(() => reject(createNotConnectedError(this.port)));
});
}
@ -469,7 +491,8 @@ export class MonitorService extends CoreClientAware implements Disposable {
*/
private async portMonitorSettings(
protocol: string,
fqbn: string
fqbn: string,
swallowsPlatformNotFoundError = false
): Promise<PluggableMonitorSettings> {
const coreClient = await this.coreClient;
const { client, instance } = coreClient;
@ -478,19 +501,33 @@ export class MonitorService extends CoreClientAware implements Disposable {
req.setPortProtocol(protocol);
req.setFqbn(fqbn);
const res = await new Promise<EnumerateMonitorPortSettingsResponse>(
(resolve, reject) => {
client.enumerateMonitorPortSettings(req, (err, resp) => {
if (!!err) {
reject(err);
const resp = await new Promise<
EnumerateMonitorPortSettingsResponse | undefined
>((resolve, reject) => {
client.enumerateMonitorPortSettings(req, async (err, resp) => {
if (err) {
// Check whether the platform is installed: https://github.com/arduino/arduino-ide/issues/1974.
// No error codes. Look for `Unknown FQBN: platform arduino:mbed_nano is not installed` message similarities: https://github.com/arduino/arduino-cli/issues/1762.
if (
swallowsPlatformNotFoundError &&
ServiceError.is(err) &&
err.code === status.NOT_FOUND &&
err.details.includes('FQBN') &&
err.details.includes(fqbn.split(':', 2).join(':')) // create a platform ID from the FQBN
) {
resolve(undefined);
}
resolve(resp);
});
}
);
reject(err);
}
resolve(resp);
});
});
const settings: PluggableMonitorSettings = {};
for (const iterator of res.getSettingsList()) {
if (!resp) {
return settings;
}
for (const iterator of resp.getSettingsList()) {
settings[iterator.getSettingId()] = {
id: iterator.getSettingId(),
label: iterator.getLabel(),
@ -510,7 +547,7 @@ export class MonitorService extends CoreClientAware implements Disposable {
* @param settings map of monitor settings to change
* @returns a status to verify settings have been sent.
*/
async changeSettings(settings: MonitorSettings): Promise<Status> {
async changeSettings(settings: MonitorSettings): Promise<void> {
const config = new MonitorPortConfiguration();
const { pluggableMonitorSettings } = settings;
const reconciledSettings = await this.monitorSettingsProvider.setSettings(
@ -527,17 +564,23 @@ export class MonitorService extends CoreClientAware implements Disposable {
}
}
const connectionStatus = Boolean(this.duplex)
? 'connected'
: 'not-connected';
this.updateClientsSettings({
monitorUISettings: {
...settings.monitorUISettings,
connected: !!this.duplex,
connectionStatus,
serialPort: this.port.address,
connected: isMonitorConnected(connectionStatus), // TODO: should be removed when plotter app understand the `connectionStatus` message
},
pluggableMonitorSettings: reconciledSettings,
});
if (!this.duplex) {
return Status.NOT_CONNECTED;
// instead of throwing an error, return silently like the original logic
// https://github.com/arduino/arduino-ide/blob/9b49712669b06c97bda68a1e5f04eee4664c13f8/arduino-ide-extension/src/node/monitor-service.ts#L540
return;
}
const diffConfig = this.maybeUpdatePortConfigSnapshot(config);
@ -545,7 +588,7 @@ export class MonitorService extends CoreClientAware implements Disposable {
this.logger.info(
`No port configuration changes have been detected. No need to send configure commands to the running monitor ${this.port.protocol}:${this.port.address}.`
);
return Status.OK;
return;
}
const coreClient = await this.coreClient;
@ -560,7 +603,6 @@ export class MonitorService extends CoreClientAware implements Disposable {
req.setInstance(instance);
req.setPortConfiguration(diffConfig);
this.duplex.write(req);
return Status.OK;
}
/**
@ -688,6 +730,26 @@ export class MonitorService extends CoreClientAware implements Disposable {
updateClientsSettings(settings: MonitorSettings): void {
this.settings = { ...this.settings, ...settings };
if (
settings.monitorUISettings?.connectionStatus &&
!('connected' in settings.monitorUISettings)
) {
// Make sure the deprecated `connected` prop is set.
settings.monitorUISettings.connected = isMonitorConnected(
settings.monitorUISettings.connectionStatus
);
}
if (
typeof settings.monitorUISettings?.connected === 'boolean' &&
!('connectionStatus' in settings.monitorUISettings)
) {
// Set the connectionStatus if the message was sent by the plotter which does not handle the new protocol. Assuming that the plotter can send anything.
// https://github.com/arduino/arduino-serial-plotter-webapp#monitor-settings
settings.monitorUISettings.connectionStatus = settings.monitorUISettings
.connected
? 'connected'
: 'not-connected';
}
const command: Monitor.Message = {
command: Monitor.MiddlewareCommand.ON_SETTINGS_DID_CHANGE,
data: settings,

View File

@ -1,10 +1,9 @@
import { MonitorModel } from '../../browser/monitor-model';
import { PluggableMonitorSetting } from '../../common/protocol';
import { MonitorState, PluggableMonitorSetting } from '../../common/protocol';
export type PluggableMonitorSettings = Record<string, PluggableMonitorSetting>;
export interface MonitorSettings {
pluggableMonitorSettings?: PluggableMonitorSettings;
monitorUISettings?: Partial<MonitorModel.State>;
monitorUISettings?: Partial<MonitorState>;
}
export const MonitorSettingsProvider = Symbol('MonitorSettingsProvider');

View File

@ -328,6 +328,13 @@
"tools": "Tools"
},
"monitor": {
"alreadyConnectedError": "Could not connect to {0} {1} port. Already connected.",
"baudRate": "{0} baud",
"connectionFailedError": "Could not connect to {0} {1} port.",
"connectionFailedErrorWithDetails": "{0} Could not connect to {1} {2} port.",
"connectionTimeout": "Timeout. The IDE has not received the 'success' message from the monitor after successfully connecting to it",
"missingConfigurationError": "Could not connect to {0} {1} port. The monitor configuration is missing.",
"notConnectedError": "Not connected to {0} {1} port.",
"unableToCloseWebSocket": "Unable to close websocket",
"unableToConnectToWebSocket": "Unable to connect to websocket"
},
@ -408,6 +415,7 @@
"serial": {
"autoscroll": "Autoscroll",
"carriageReturn": "Carriage Return",
"connecting": "Connecting to '{0}' on '{1}'...",
"message": "Message (Enter to send message to '{0}' on '{1}')",
"newLine": "New Line",
"newLineCarriageReturn": "Both NL & CR",