mirror of
https://github.com/arduino/arduino-ide.git
synced 2025-11-12 19:59:27 +00:00
Serial Plotter implementation (#597)
* spawn new window where to instantiate serial plotter app * initialize serial monito web app * connect serial plotter app with websocket * use npm serial-plotter package * refactor monitor connection and fix some connection issues * fix clearConsole + refactor monitor connection * add serial unit tests * refactoring and cleaning code
This commit is contained in:
committed by
GitHub
parent
9863dc2f90
commit
20f7712129
@@ -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];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
26
arduino-ide-extension/src/browser/serial/plotter/protocol.ts
Normal file
26
arduino-ide-extension/src/browser/serial/plotter/protocol.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
163
arduino-ide-extension/src/browser/serial/serial-model.ts
Normal file
163
arduino-ide-extension/src/browser/serial/serial-model.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { injectable, inject } from 'inversify';
|
||||
import { Emitter, Event } from '@theia/core/lib/common/event';
|
||||
import { SerialConfig } from '../../common/protocol';
|
||||
import {
|
||||
FrontendApplicationContribution,
|
||||
LocalStorageService,
|
||||
} from '@theia/core/lib/browser';
|
||||
import { BoardsServiceProvider } from '../boards/boards-service-provider';
|
||||
|
||||
@injectable()
|
||||
export class SerialModel implements FrontendApplicationContribution {
|
||||
protected static STORAGE_ID = 'arduino-serial-model';
|
||||
|
||||
@inject(LocalStorageService)
|
||||
protected readonly localStorageService: LocalStorageService;
|
||||
|
||||
@inject(BoardsServiceProvider)
|
||||
protected readonly boardsServiceClient: BoardsServiceProvider;
|
||||
|
||||
protected readonly onChangeEmitter: Emitter<
|
||||
SerialModel.State.Change<keyof SerialModel.State>
|
||||
>;
|
||||
protected _autoscroll: boolean;
|
||||
protected _timestamp: boolean;
|
||||
protected _baudRate: SerialConfig.BaudRate;
|
||||
protected _lineEnding: SerialModel.EOL;
|
||||
protected _interpolate: boolean;
|
||||
|
||||
constructor() {
|
||||
this._autoscroll = true;
|
||||
this._timestamp = false;
|
||||
this._baudRate = SerialConfig.BaudRate.DEFAULT;
|
||||
this._lineEnding = SerialModel.EOL.DEFAULT;
|
||||
this._interpolate = false;
|
||||
this.onChangeEmitter = new Emitter<
|
||||
SerialModel.State.Change<keyof SerialModel.State>
|
||||
>();
|
||||
}
|
||||
|
||||
onStart(): void {
|
||||
this.localStorageService
|
||||
.getData<SerialModel.State>(SerialModel.STORAGE_ID)
|
||||
.then((state) => {
|
||||
if (state) {
|
||||
this.restoreState(state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get onChange(): Event<SerialModel.State.Change<keyof SerialModel.State>> {
|
||||
return this.onChangeEmitter.event;
|
||||
}
|
||||
|
||||
get autoscroll(): boolean {
|
||||
return this._autoscroll;
|
||||
}
|
||||
|
||||
toggleAutoscroll(): void {
|
||||
this._autoscroll = !this._autoscroll;
|
||||
this.storeState();
|
||||
this.storeState().then(() =>
|
||||
this.onChangeEmitter.fire({
|
||||
property: 'autoscroll',
|
||||
value: this._autoscroll,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
get timestamp(): boolean {
|
||||
return this._timestamp;
|
||||
}
|
||||
|
||||
toggleTimestamp(): void {
|
||||
this._timestamp = !this._timestamp;
|
||||
this.storeState().then(() =>
|
||||
this.onChangeEmitter.fire({
|
||||
property: 'timestamp',
|
||||
value: this._timestamp,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
get baudRate(): SerialConfig.BaudRate {
|
||||
return this._baudRate;
|
||||
}
|
||||
|
||||
set baudRate(baudRate: SerialConfig.BaudRate) {
|
||||
this._baudRate = baudRate;
|
||||
this.storeState().then(() =>
|
||||
this.onChangeEmitter.fire({
|
||||
property: 'baudRate',
|
||||
value: this._baudRate,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
get lineEnding(): SerialModel.EOL {
|
||||
return this._lineEnding;
|
||||
}
|
||||
|
||||
set lineEnding(lineEnding: SerialModel.EOL) {
|
||||
this._lineEnding = lineEnding;
|
||||
this.storeState().then(() =>
|
||||
this.onChangeEmitter.fire({
|
||||
property: 'lineEnding',
|
||||
value: this._lineEnding,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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(SerialModel.STORAGE_ID, {
|
||||
autoscroll: this._autoscroll,
|
||||
timestamp: this._timestamp,
|
||||
baudRate: this._baudRate,
|
||||
lineEnding: this._lineEnding,
|
||||
interpolate: this._interpolate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export namespace SerialModel {
|
||||
export interface State {
|
||||
autoscroll: boolean;
|
||||
timestamp: boolean;
|
||||
baudRate: SerialConfig.BaudRate;
|
||||
lineEnding: EOL;
|
||||
interpolate: 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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user