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

@@ -0,0 +1,69 @@
import { Line, SerialMonitorOutput } from './serial-monitor-send-output';
export function messagesToLines(
messages: string[],
prevLines: Line[] = [],
charCount = 0,
separator = '\n'
): [Line[], number] {
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;
charCount += messageLen;
const lastLine = linesToAdd[linesToAdd.length - 1];
// if the previous messages ends with "separator" add a new line
if (lastLine.message.charAt(lastLine.message.length - 1) === separator) {
linesToAdd.push({
message,
timestamp: new Date(),
lineLen: messageLen,
});
} else {
// concatenate to the last line
linesToAdd[linesToAdd.length - 1].message += message;
linesToAdd[linesToAdd.length - 1].lineLen += messageLen;
if (!linesToAdd[linesToAdd.length - 1].timestamp) {
linesToAdd[linesToAdd.length - 1].timestamp = new Date();
}
}
}
prevLines.splice(prevLines.length - 1, 1, ...linesToAdd);
return [prevLines, charCount];
}
export function truncateLines(
lines: Line[],
charCount: number,
maxCharacters: number = SerialMonitorOutput.MAX_CHARACTERS
): [Line[], number] {
let charsToDelete = charCount - maxCharacters;
let lineIndex = 0;
while (charsToDelete > 0 || lineIndex > 0) {
const firstLineLength = lines[lineIndex]?.lineLen;
if (charsToDelete >= firstLineLength) {
// every time a full line to delete is found, move the index.
lineIndex++;
charsToDelete -= firstLineLength;
charCount -= firstLineLength;
continue;
}
// delete all previous lines
lines.splice(0, lineIndex);
lineIndex = 0;
const newFirstLine = lines[0]?.message?.substring(charsToDelete);
const deletedCharsCount = firstLineLength - newFirstLine.length;
charCount -= deletedCharsCount;
charsToDelete -= deletedCharsCount;
lines[0].message = newFirstLine;
}
return [lines, charCount];
}

View File

@@ -0,0 +1,175 @@
import * as React from 'react';
import { injectable, inject } from 'inversify';
import { AbstractViewContribution } from '@theia/core/lib/browser';
import { MonitorWidget } from './monitor-widget';
import { MenuModelRegistry, Command, CommandRegistry } from '@theia/core';
import {
TabBarToolbarContribution,
TabBarToolbarRegistry,
} from '@theia/core/lib/browser/shell/tab-bar-toolbar';
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 {
export namespace Commands {
export const AUTOSCROLL = Command.toLocalizedCommand(
{
id: 'serial-monitor-autoscroll',
label: 'Autoscroll',
},
'arduino/serial/autoscroll'
);
export const TIMESTAMP = Command.toLocalizedCommand(
{
id: 'serial-monitor-timestamp',
label: 'Timestamp',
},
'arduino/serial/timestamp'
);
export const CLEAR_OUTPUT = Command.toLocalizedCommand(
{
id: 'serial-monitor-clear-output',
label: 'Clear Output',
iconClass: 'clear-all',
},
'vscode/output.contribution/clearOutput.label'
);
}
}
@injectable()
export class MonitorViewContribution
extends AbstractViewContribution<MonitorWidget>
implements TabBarToolbarContribution
{
static readonly TOGGLE_SERIAL_MONITOR = MonitorWidget.ID + ':toggle';
static readonly TOGGLE_SERIAL_MONITOR_TOOLBAR =
MonitorWidget.ID + ':toggle-toolbar';
@inject(SerialModel) protected readonly model: SerialModel;
constructor() {
super({
widgetId: MonitorWidget.ID,
widgetName: MonitorWidget.LABEL,
defaultWidgetOptions: {
area: 'bottom',
},
toggleCommandId: MonitorViewContribution.TOGGLE_SERIAL_MONITOR,
toggleKeybinding: 'CtrlCmd+Shift+M',
});
}
registerMenus(menus: MenuModelRegistry): void {
if (this.toggleCommand) {
menus.registerMenuAction(ArduinoMenus.TOOLS__MAIN_GROUP, {
commandId: this.toggleCommand.id,
label: MonitorWidget.LABEL,
order: '5',
});
}
}
registerToolbarItems(registry: TabBarToolbarRegistry): void {
registry.registerItem({
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/
});
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/
});
registry.registerItem({
id: SerialMonitor.Commands.CLEAR_OUTPUT.id,
command: SerialMonitor.Commands.CLEAR_OUTPUT.id,
tooltip: nls.localize(
'vscode/output.contribution/clearOutput.label',
'Clear Output'
),
});
}
registerCommands(commands: CommandRegistry): void {
commands.registerCommand(SerialMonitor.Commands.CLEAR_OUTPUT, {
isEnabled: (widget) => widget instanceof MonitorWidget,
isVisible: (widget) => widget instanceof MonitorWidget,
execute: (widget) => {
if (widget instanceof MonitorWidget) {
widget.clearConsole();
}
},
});
if (this.toggleCommand) {
commands.registerCommand(this.toggleCommand, {
execute: () => this.toggle(),
});
commands.registerCommand(
{ id: MonitorViewContribution.TOGGLE_SERIAL_MONITOR_TOOLBAR },
{
isVisible: (widget) =>
ArduinoToolbar.is(widget) && widget.side === 'right',
execute: () => this.toggle(),
}
);
}
}
protected async toggle(): Promise<void> {
const widget = this.tryGetWidget();
if (widget) {
widget.dispose();
} else {
await this.openView({ activate: true, reveal: true });
}
}
protected renderAutoScrollButton(): React.ReactNode {
return (
<React.Fragment key="autoscroll-toolbar-item">
<div
title={nls.localize(
'vscode/output.contribution/toggleAutoScroll',
'Toggle Autoscroll'
)}
className={`item enabled fa fa-angle-double-down arduino-monitor ${
this.model.autoscroll ? 'toggled' : ''
}`}
onClick={this.toggleAutoScroll}
></div>
</React.Fragment>
);
}
protected readonly toggleAutoScroll = () => this.doToggleAutoScroll();
protected async doToggleAutoScroll(): Promise<void> {
this.model.toggleAutoscroll();
}
protected renderTimestampButton(): React.ReactNode {
return (
<React.Fragment key="line-ending-toolbar-item">
<div
title={nls.localize(
'arduino/serial/toggleTimestamp',
'Toggle Timestamp'
)}
className={`item enabled fa fa-clock-o arduino-monitor ${
this.model.timestamp ? 'toggled' : ''
}`}
onClick={this.toggleTimestamp}
></div>
</React.Fragment>
);
}
protected readonly toggleTimestamp = () => this.doToggleTimestamp();
protected async doToggleTimestamp(): Promise<void> {
this.model.toggleTimestamp();
}
}

View File

@@ -0,0 +1,227 @@
import * as React from 'react';
import { postConstruct, injectable, inject } from 'inversify';
import { OptionsType } from 'react-select/src/types';
import { Emitter } from '@theia/core/lib/common/event';
import { Disposable } from '@theia/core/lib/common/disposable';
import {
ReactWidget,
Message,
Widget,
MessageLoop,
} from '@theia/core/lib/browser/widgets';
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/common/serialMonitor',
'Serial Monitor'
);
static readonly ID = 'serial-monitor';
@inject(SerialModel)
protected readonly serialModel: SerialModel;
@inject(SerialConnectionManager)
protected readonly serialConnection: SerialConnectionManager;
@inject(BoardsServiceProvider)
protected readonly boardsServiceProvider: BoardsServiceProvider;
protected widgetHeight: number;
/**
* Do not touch or use it. It is for setting the focus on the `input` after the widget activation.
*/
protected focusNode: HTMLElement | undefined;
/**
* Guard against re-rendering the view after the close was requested.
* See: https://github.com/eclipse-theia/theia/issues/6704
*/
protected closing = false;
protected readonly clearOutputEmitter = new Emitter<void>();
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.toDispose.push(this.clearOutputEmitter);
this.toDispose.push(
Disposable.create(() =>
this.serialConnection.closeSerial(Serial.Type.Monitor)
)
);
}
@postConstruct()
protected init(): void {
this.update();
this.toDispose.push(
this.serialConnection.onConnectionChanged(() => this.clearConsole())
);
this.toDispose.push(this.serialModel.onChange(() => this.update()));
}
clearConsole(): void {
this.clearOutputEmitter.fire(undefined);
this.update();
}
dispose(): void {
super.dispose();
}
protected onAfterAttach(msg: Message): void {
super.onAfterAttach(msg);
this.serialConnection.openSerial(Serial.Type.Monitor);
}
onCloseRequest(msg: Message): void {
this.closing = true;
super.onCloseRequest(msg);
}
protected onUpdateRequest(msg: Message): void {
// TODO: `this.isAttached`
// See: https://github.com/eclipse-theia/theia/issues/6704#issuecomment-562574713
if (!this.closing && this.isAttached) {
super.onUpdateRequest(msg);
}
}
protected onResize(msg: Widget.ResizeMessage): void {
super.onResize(msg);
this.widgetHeight = msg.height;
this.update();
}
protected onActivateRequest(msg: Message): void {
super.onActivateRequest(msg);
(this.focusNode || this.node).focus();
}
protected onFocusResolved = (element: HTMLElement | undefined) => {
if (this.closing || !this.isAttached) {
return;
}
this.focusNode = element;
requestAnimationFrame(() =>
MessageLoop.sendMessage(this, Widget.Msg.ActivateRequest)
);
};
protected get lineEndings(): OptionsType<
SerialMonitorOutput.SelectOption<SerialModel.EOL>
> {
return [
{
label: nls.localize('arduino/serial/noLineEndings', 'No Line Ending'),
value: '',
},
{
label: nls.localize('arduino/serial/newLine', 'New Line'),
value: '\n',
},
{
label: nls.localize('arduino/serial/carriageReturn', 'Carriage Return'),
value: '\r',
},
{
label: nls.localize(
'arduino/serial/newLineCarriageReturn',
'Both NL & CR'
),
value: '\r\n',
},
];
}
protected get baudRates(): OptionsType<
SerialMonitorOutput.SelectOption<SerialConfig.BaudRate>
> {
const baudRates: Array<SerialConfig.BaudRate> = [
300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200,
];
return baudRates.map((baudRate) => ({
label: baudRate + ' baud',
value: baudRate,
}));
}
protected render(): React.ReactNode {
const { baudRates, lineEndings } = this;
const lineEnding =
lineEndings.find((item) => item.value === this.serialModel.lineEnding) ||
lineEndings[1]; // Defaults to `\n`.
const 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
serialConfig={this.serialConnection.serialConfig}
resolveFocus={this.onFocusResolved}
onSend={this.onSend}
/>
</div>
<div className="config">
<div className="select">
<ArduinoSelect
maxMenuHeight={this.widgetHeight - 40}
options={lineEndings}
value={lineEnding}
onChange={this.onChangeLineEnding}
/>
</div>
<div className="select">
<ArduinoSelect
className="select"
maxMenuHeight={this.widgetHeight - 40}
options={baudRates}
value={baudRate}
onChange={this.onChangeBaudRate}
/>
</div>
</div>
</div>
<div className="body">
<SerialMonitorOutput
serialModel={this.serialModel}
serialConnection={this.serialConnection}
clearConsoleEvent={this.clearOutputEmitter.event}
height={Math.floor(this.widgetHeight - 50)}
/>
</div>
</div>
);
}
protected readonly onSend = (value: string) => this.doSend(value);
protected async doSend(value: string): Promise<void> {
this.serialConnection.send(value);
}
protected readonly onChangeLineEnding = (
option: SerialMonitorOutput.SelectOption<SerialModel.EOL>
) => {
this.serialModel.lineEnding = option.value;
};
protected readonly onChangeBaudRate = (
option: SerialMonitorOutput.SelectOption<SerialConfig.BaudRate>
) => {
this.serialModel.baudRate = option.value;
};
}

View File

@@ -0,0 +1,89 @@
import * as React from 'react';
import { Key, KeyCode } from '@theia/core/lib/browser/keys';
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 serialConfig?: SerialConfig;
readonly onSend: (text: string) => void;
readonly resolveFocus: (element: HTMLElement | undefined) => void;
}
export interface State {
text: string;
}
}
export class SerialMonitorSendInput extends React.Component<
SerialMonitorSendInput.Props,
SerialMonitorSendInput.State
> {
constructor(props: Readonly<SerialMonitorSendInput.Props>) {
super(props);
this.state = { text: '' };
this.onChange = this.onChange.bind(this);
this.onSend = this.onSend.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
}
render(): React.ReactNode {
return (
<input
ref={this.setRef}
type="text"
className={`theia-input ${this.props.serialConfig ? '' : 'warning'}`}
placeholder={this.placeholder}
value={this.state.text}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
/>
);
}
protected get placeholder(): string {
const { serialConfig } = this.props;
if (!serialConfig) {
return nls.localize(
'arduino/serial/notConnected',
'Not connected. Select a board and a port to connect automatically.'
);
}
const { board, port } = serialConfig;
return nls.localize(
'arduino/serial/message',
"Message ({0} + Enter to send message to '{1}' on '{2}'",
isOSX ? '⌘' : nls.localize('vscode/keybindingLabels/ctrlKey', 'Ctrl'),
Board.toString(board, {
useFqbn: false,
}),
Port.toString(port)
);
}
protected setRef = (element: HTMLElement | null) => {
if (this.props.resolveFocus) {
this.props.resolveFocus(element || undefined);
}
};
protected onChange(event: React.ChangeEvent<HTMLInputElement>): void {
this.setState({ text: event.target.value });
}
protected onSend(): void {
this.props.onSend(this.state.text);
this.setState({ text: '' });
}
protected onKeyDown(event: React.KeyboardEvent<HTMLInputElement>): void {
const keyCode = KeyCode.createKeyCode(event.nativeEvent);
if (keyCode) {
const { key, meta, ctrl } = keyCode;
if (key === Key.ENTER && ((isOSX && meta) || (!isOSX && ctrl))) {
this.onSend();
}
}
}
}

View File

@@ -0,0 +1,146 @@
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 { SerialModel } from '../serial-model';
import { SerialConnectionManager } from '../serial-connection-manager';
import dateFormat = require('dateformat');
import { messagesToLines, truncateLines } from './monitor-utils';
export type Line = { message: string; timestamp?: Date; lineLen: number };
export class SerialMonitorOutput extends React.Component<
SerialMonitorOutput.Props,
SerialMonitorOutput.State
> {
/**
* Do not touch it. It is used to be able to "follow" the serial monitor log.
*/
protected toDisposeBeforeUnmount = new DisposableCollection();
private listRef: React.RefObject<any>;
constructor(props: Readonly<SerialMonitorOutput.Props>) {
super(props);
this.listRef = React.createRef();
this.state = {
lines: [],
timestamp: this.props.serialModel.timestamp,
charCount: 0,
};
}
render(): React.ReactNode {
return (
<List
className="serial-monitor-messages"
height={this.props.height}
itemData={
{
lines: this.state.lines,
timestamp: this.state.timestamp,
} as any
}
itemCount={this.state.lines.length}
itemSize={18}
width={'100%'}
ref={this.listRef}
>
{Row}
</List>
);
}
shouldComponentUpdate(): boolean {
return true;
}
componentDidMount(): void {
this.scrollToBottom();
this.toDisposeBeforeUnmount.pushAll([
this.props.serialConnection.onRead(({ messages }) => {
const [newLines, totalCharCount] = messagesToLines(
messages,
this.state.lines,
this.state.charCount
);
const [lines, charCount] = truncateLines(newLines, totalCharCount);
this.setState({
lines,
charCount,
});
this.scrollToBottom();
}),
this.props.clearConsoleEvent(() =>
this.setState({ lines: [], charCount: 0 })
),
this.props.serialModel.onChange(({ property }) => {
if (property === 'timestamp') {
const { timestamp } = this.props.serialModel;
this.setState({ timestamp });
}
if (property === 'autoscroll') {
this.scrollToBottom();
}
}),
]);
}
componentWillUnmount(): void {
// TODO: "Your preferred browser's local storage is almost full." Discard `content` before saving layout?
this.toDisposeBeforeUnmount.dispose();
}
scrollToBottom = ((): void => {
if (this.listRef.current && this.props.serialModel.autoscroll) {
this.listRef.current.scrollToItem(this.state.lines.length, 'end');
}
}).bind(this);
}
const _Row = ({
index,
style,
data,
}: {
index: number;
style: any;
data: { lines: Line[]; timestamp: boolean };
}) => {
const timestamp =
(data.timestamp &&
`${dateFormat(data.lines[index].timestamp, 'H:M:ss.l')} -> `) ||
'';
return (
(data.lines[index].lineLen && (
<div style={style}>
{timestamp}
{data.lines[index].message}
</div>
)) ||
null
);
};
const Row = React.memo(_Row, areEqual);
export namespace SerialMonitorOutput {
export interface Props {
readonly serialModel: SerialModel;
readonly serialConnection: SerialConnectionManager;
readonly clearConsoleEvent: Event<void>;
readonly height: number;
}
export interface State {
lines: Line[];
timestamp: boolean;
charCount: number;
}
export interface SelectOption<T> {
readonly label: string;
readonly value: T;
}
export const MAX_CHARACTERS = 1_000_000;
}