diff --git a/README.md b/README.md index 88c23c3e..ac042328 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ This extension provides several commands in the Command Palette (F1 o - **Arduino: Initialize**: Scaffold a VS Code project with an Arduino sketch. - **Arduino: Library Manager**: Explore and manage libraries. - **Arduino: Open Serial Monitor**: Open the serial monitor in the integrated output window. +- **Arduino: Open Serial Plotter**: Open the serial plotter. - **Arduino: Select Serial Port**: Change the current serial port. - **Arduino: Send Text to Serial Port**: Send a line of text via the current serial port. - **Arduino: Upload**: Build sketch and upload to Arduino board. @@ -97,7 +98,8 @@ The following Visual Studio Code settings are available for the Arduino extensio "https://raw.githubusercontent.com/VSChina/azureiotdevkit_tools/master/package_azureboard_index.json", "http://arduino.esp8266.com/stable/package_esp8266com_index.json" ], - "arduino.defaultBaudRate": 115200 + "arduino.defaultBaudRate": 115200, + "arduino.plotterRegex": "^PLOT\\[(\\d+)\\]\\[(.+?)=(.+?)\\]$", } ``` *Note:* You only need to set `arduino.path` in Visual Studio Code settings, other options are not required. @@ -203,6 +205,45 @@ Steps to start debugging: > To learn more about how to debug Arduino code, visit our [team blog](https://blogs.msdn.microsoft.com/iotdev/2017/05/27/debug-your-arduino-code-with-visual-studio-code/). +## Using Serial Plotter + +You can start Serial Plotter by calling `Arduino: Open Serial Plotter` from Command Pallete. + +By default, it looks for lines of the following format in the serial input: `PLOT[time][variable=value]` + +For example, `PLOT[1234][cos=0.5]` means that we have variable named `cos` with it's value `0.5` at the time `1234`. + +You can use snippet below to print variables in such format. + +```c +void plot(String name, float value) +{ + String time = String(millis()); + Serial.println("PLOT[" + time + "][" + name + "=" + value + "]"); +} +``` + +### Throttling (refresh rate) + +This Plotter is not working in real time. It's built on top of web technologies +with [dygraphs](http://dygraphs.com/) library to create interactive chart, so it's pretty fast but not instant. + +Plotter accumulates data and flushes it to the chart with some periodicity that we will call `throttling` or `refresh rate`. By default it's 100ms, but you can change it as you want. This value was chosen empirically, lower values ​​can lead to noticable lags. + +### Time window + +Data is recorded for a specified period of time called `Time window`. By default it's 20 seconds and you can also change it in the way you need. The greater the value, the more resources are required to render chart, and the more lags we have. + +### Override log format + +You can override default regex to specify your own format, but the order will be remain the same: time, variable name, variable value. + +```json +{ + "arduino.plotterRegex": "^(\\d+):(.+?)=(.+?)$" +} +``` + ## Change Log See the [Change log](https://github.com/Microsoft/vscode-arduino/blob/master/CHANGELOG.md) for details about the changes in each version. diff --git a/package-lock.json b/package-lock.json index b4186ae0..2be35896 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5116,9 +5116,9 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "fsevents": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.12.tgz", - "integrity": "sha512-Ggd/Ktt7E7I8pxZRbGIs7vwqAPscSESMrCSkx2FtWeqmheJgCo2R74fTsZFCifr0VTPwqRpPv17+6b8Zp7th0Q==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", + "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", "dev": true, "optional": true, "requires": { @@ -7733,6 +7733,11 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=" + }, "lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", diff --git a/package.json b/package.json index 06d42122..0c275329 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "onCommand:arduino.showLibraryManager", "onCommand:arduino.showExamples", "onCommand:arduino.initialize", + "onCommand:arduino.openSerialPlotter", "onDebug" ], "main": "./out/src/extension", @@ -159,6 +160,10 @@ { "command": "arduino.showExamples", "title": "Arduino: Examples" + }, + { + "command": "arduino.openSerialPlotter", + "title": "Arduino: Open Serial Plotter" } ], "menus": { @@ -527,6 +532,10 @@ "type": "number", "default": 115200 }, + "arduino.plotterRegex": { + "type": "string", + "default": "^PLOT\\[(\\d+)\\]\\[(.+?)=(.+?)\\]$" + }, "arduino.disableIntelliSenseAutoGen": { "type": "boolean", "default": false, @@ -631,6 +640,7 @@ "extract-zip": "^2.0.1", "glob": "^7.1.1", "iconv-lite": "^0.4.18", + "lodash.throttle": "^4.1.1", "impor": "^0.1.1", "node-usb-native": "^0.0.20", "properties": "^1.2.1", diff --git a/src/arduino/arduinoContentProvider.ts b/src/arduino/arduinoContentProvider.ts index e3dcce7f..8f93d544 100644 --- a/src/arduino/arduinoContentProvider.ts +++ b/src/arduino/arduinoContentProvider.ts @@ -7,9 +7,11 @@ import * as vscode from "vscode"; import ArduinoActivator from "../arduinoActivator"; import ArduinoContext from "../arduinoContext"; import * as Constants from "../common/constants"; +import { SERIAL_PLOTTER_URI } from "../common/constants"; import * as JSONHelper from "../common/cycle"; import { DeviceContext } from "../deviceContext"; import * as Logger from "../logger/logger"; +import { SerialMonitor } from "../serialmonitor/serialMonitor"; import LocalWebServer from "./localWebServer"; export class ArduinoContentProvider implements vscode.TextDocumentContentProvider { @@ -47,7 +49,12 @@ export class ArduinoContentProvider implements vscode.TextDocumentContentProvide this.addHandlerWithLogger("load-examples", "/api/examples", async (req, res) => await this.getExamples(req, res)); this.addHandlerWithLogger("open-example", "/api/openexample", (req, res) => this.openExample(req, res), true); + // Arduino Serial Plotter + this.addHandlerWithLogger("show-serialplotter", "/serialplotter", (req, res) => this.getHtmlView(req, res)); + this.addHandlerWithLogger("updateplotrate", "/api/updateplotrate", (req, res) => this.updatePlotRefreshRate(req, res), true); + await this._webserver.start(); + } public async provideTextDocumentContent(uri: vscode.Uri): Promise { @@ -63,6 +70,8 @@ export class ArduinoContentProvider implements vscode.TextDocumentContentProvide type = "boardConfig"; } else if (uri.toString() === Constants.EXAMPLES_URI.toString()) { type = "examples"; + } else if (uri.toString() === Constants.SERIAL_PLOTTER_URI.toString()) { + type = "serialplotter"; } const timeNow = new Date().getTime(); @@ -81,7 +90,17 @@ export class ArduinoContentProvider implements vscode.TextDocumentContentProvide "theme=" + encodeURIComponent(theme.trim()) + "&backgroundcolor=" + encodeURIComponent(backgroundcolor.trim()) + "&color=" + encodeURIComponent(color.trim()); - document.getElementById('frame').src = url; + + var iframe = document.getElementById('frame'); + + iframe.onload = function() { + window.addEventListener('message', msg => { + var data = msg.data; + iframe.contentWindow.postMessage(data, url); + }) + } + + iframe.src = url; }; @@ -291,6 +310,24 @@ export class ArduinoContentProvider implements vscode.TextDocumentContentProvide } } + public updatePlotRefreshRate(req, res) { + if (!req.body.rate) { + return res.status(400).send("BAD Request! Missing parameters!"); + } else { + try { + const serialMonitor = SerialMonitor.getInstance(); + + serialMonitor.serialPlotter.setThrottling(req.body.rate); + + return res.json({ + status: "OK", + }); + } catch (error) { + return res.status(500).send(`Update plot refresh rate failed with message "code:${error.code}, err:${error.stderr}"`); + } + } + } + private addHandlerWithLogger(handlerName: string, url: string, handler: (req, res) => void, post: boolean = false): void { const wrappedHandler = async (req, res) => { const guid = Uuid().replace(/-/g, ""); diff --git a/src/arduino/vscodeSettings.ts b/src/arduino/vscodeSettings.ts index ce512189..3ff9b64c 100644 --- a/src/arduino/vscodeSettings.ts +++ b/src/arduino/vscodeSettings.ts @@ -16,8 +16,10 @@ const configKeys = { IGNORE_BOARDS: "arduino.ignoreBoards", SKIP_HEADER_PROVIDER: "arduino.skipHeaderProvider", DEFAULT_BAUD_RATE: "arduino.defaultBaudRate", + PLOTTER_REGEX: "arduino.plotterRegex", USE_ARDUINO_CLI: "arduino.useArduinoCli", DISABLE_INTELLISENSE_AUTO_GEN: "arduino.disableIntelliSenseAutoGen", + }; export interface IVscodeSettings { @@ -32,6 +34,7 @@ export interface IVscodeSettings { ignoreBoards: string[]; skipHeaderProvider: boolean; defaultBaudRate: number; + plotterRegex: string; useArduinoCli: boolean; disableIntelliSenseAutoGen: boolean; updateAdditionalUrls(urls: string | string[]): void; @@ -101,8 +104,13 @@ export class VscodeSettings implements IVscodeSettings { return this.getConfigValue(configKeys.SKIP_HEADER_PROVIDER); } + public get plotterRegex(): string { + return this.getConfigValue(configKeys.PLOTTER_REGEX); + } + public get disableIntelliSenseAutoGen(): boolean { return this.getConfigValue(configKeys.DISABLE_INTELLISENSE_AUTO_GEN); + } public async updateAdditionalUrls(value) { diff --git a/src/common/constants.ts b/src/common/constants.ts index 2fcfde72..ee0ea659 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -25,6 +25,7 @@ export const BOARD_MANAGER_URI = vscode.Uri.parse("arduino-manager://arduino/ard export const LIBRARY_MANAGER_URI = vscode.Uri.parse("arduino-manager://arduino/arduino-librariesmanager"); export const BOARD_CONFIG_URI = vscode.Uri.parse("arduino-manager://arduino/arduino-config"); export const EXAMPLES_URI = vscode.Uri.parse("arduino-manager://arduino/arduino-examples"); +export const SERIAL_PLOTTER_URI = vscode.Uri.parse("arduino-manager://arduino/arduino-serialplotter"); export const messages = { ARDUINO_FILE_ERROR: "The arduino.json file format is not correct.", @@ -43,6 +44,7 @@ export const statusBarPriority = { PORT: 20, OPEN_PORT: 30, BAUD_RATE: 40, + OPEN_SERIAL_PLOTTER: 50, BOARD: 60, ENDING: 70, SKETCH: 80, diff --git a/src/extension.ts b/src/extension.ts index 6c5b72ce..c4fbb69a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -16,7 +16,7 @@ const arduinoActivatorModule = impor("./arduinoActivator") as typeof import ("./ const arduinoContextModule = impor("./arduinoContext") as typeof import ("./arduinoContext"); import { ARDUINO_CONFIG_FILE, ARDUINO_MANAGER_PROTOCOL, ARDUINO_MODE, BOARD_CONFIG_URI, BOARD_MANAGER_URI, EXAMPLES_URI, - LIBRARY_MANAGER_URI, + LIBRARY_MANAGER_URI, SERIAL_PLOTTER_URI, } from "./common/constants"; import { validateArduinoPath } from "./common/platform"; import * as util from "./common/util"; @@ -29,8 +29,11 @@ const nsatModule = impor("./nsat") as typeof import ("./nsat"); import { BuildMode } from "./arduino/arduino"; import { SerialMonitor } from "./serialmonitor/serialMonitor"; +import { SerialPlotterPanel } from "./serialmonitor/serialPlotterPanel"; const usbDetectorModule = impor("./serialmonitor/usbDetector") as typeof import ("./serialmonitor/usbDetector"); +const status: any = {}; + export async function activate(context: vscode.ExtensionContext) { Logger.configure(context); const activeGuid = uuidModule().replace(/-/g, ""); @@ -129,7 +132,6 @@ export async function activate(context: vscode.ExtensionContext) { arduinoContextModule.default.boardManager.currentBoard.name, }; }); - registerArduinoCommand("arduino.upload", async () => { if (!arduinoContextModule.default.arduinoApp.building) { await vscode.window.withProgress({ @@ -281,8 +283,10 @@ export async function activate(context: vscode.ExtensionContext) { // serial monitor commands const serialMonitor = SerialMonitor.getInstance(); context.subscriptions.push(serialMonitor); + registerNonArduinoCommand("arduino.selectSerialPort", () => serialMonitor.selectSerialPort(null, null)); registerNonArduinoCommand("arduino.openSerialMonitor", () => serialMonitor.openSerialMonitor()); + registerNonArduinoCommand("arduino.openSerialPlotter", () => serialMonitor.openSerialPlotter()); registerNonArduinoCommand("arduino.changeBaudRate", () => serialMonitor.changeBaudRate()); registerNonArduinoCommand("arduino.sendMessageToSerialPort", () => serialMonitor.sendMessageToSerialPort()); registerNonArduinoCommand("arduino.closeSerialMonitor", (port, showWarning = true) => serialMonitor.closeSerialMonitor(port, showWarning)); @@ -406,6 +410,12 @@ export async function activate(context: vscode.ExtensionContext) { arduinoContextModule.default.boardManager.currentBoard.name, }; }); + registerArduinoCommand("arduino.showSerialPlotter", async () => { + const html = await arduinoManagerProvider.provideTextDocumentContent(SERIAL_PLOTTER_URI); + const serialPlotter = SerialMonitor.getInstance().serialPlotter; + + SerialPlotterPanel.createOrShow({serialPlotter, html}); + }); }, 100); setTimeout(() => { diff --git a/src/serialmonitor/serialMonitor.ts b/src/serialmonitor/serialMonitor.ts index b220fcd8..2534bc35 100644 --- a/src/serialmonitor/serialMonitor.ts +++ b/src/serialmonitor/serialMonitor.ts @@ -1,242 +1,282 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. - -import * as vscode from "vscode"; -import ArduinoContext from "../arduinoContext"; -import * as constants from "../common/constants"; -import { DeviceContext } from "../deviceContext"; -import * as Logger from "../logger/logger"; -import { SerialPortCtrl } from "./serialportctrl"; - -export interface ISerialPortDetail { - port: string; - desc: string; - hwid: string; - vendorId: string; - productId: string; -} - -export class SerialMonitor implements vscode.Disposable { - - public static SERIAL_MONITOR: string = "Serial Monitor"; - - public static DEFAULT_BAUD_RATE: number = 115200; - - public static listBaudRates(): number[] { - return [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 74880, 115200, 230400, 250000, 500000, 1000000, 2000000]; - } - - public static getInstance(): SerialMonitor { - if (SerialMonitor._serialMonitor === null) { - SerialMonitor._serialMonitor = new SerialMonitor(); - } - return SerialMonitor._serialMonitor; - } - - private static _serialMonitor: SerialMonitor = null; - - private _currentPort: string; - - private _currentBaudRate: number; - - private _portsStatusBar: vscode.StatusBarItem; - - private _openPortStatusBar: vscode.StatusBarItem; - - private _baudRateStatusBar: vscode.StatusBarItem; - - private _serialPortCtrl: SerialPortCtrl = null; - - private _outputChannel: vscode.OutputChannel; - - public initialize() { - let defaultBaudRate; - if (ArduinoContext.arduinoApp && ArduinoContext.arduinoApp.settings && ArduinoContext.arduinoApp.settings.defaultBaudRate) { - defaultBaudRate = ArduinoContext.arduinoApp.settings.defaultBaudRate; - } else { - defaultBaudRate = SerialMonitor.DEFAULT_BAUD_RATE; - } - this._outputChannel = vscode.window.createOutputChannel(SerialMonitor.SERIAL_MONITOR); - this._currentBaudRate = defaultBaudRate; - this._portsStatusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, constants.statusBarPriority.PORT); - this._portsStatusBar.command = "arduino.selectSerialPort"; - this._portsStatusBar.tooltip = "Select Serial Port"; - this._portsStatusBar.show(); - - this._openPortStatusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, constants.statusBarPriority.OPEN_PORT); - this._openPortStatusBar.command = "arduino.openSerialMonitor"; - this._openPortStatusBar.text = `$(plug)`; - this._openPortStatusBar.tooltip = "Open Serial Monitor"; - this._openPortStatusBar.show(); - - this._baudRateStatusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, constants.statusBarPriority.BAUD_RATE); - this._baudRateStatusBar.command = "arduino.changeBaudRate"; - this._baudRateStatusBar.tooltip = "Baud Rate"; - this._baudRateStatusBar.text = defaultBaudRate.toString(); - this.updatePortListStatus(); - - const dc = DeviceContext.getInstance(); - dc.onChangePort(() => { - this.updatePortListStatus(); - }); - } - public get initialized(): boolean { - return !!this._outputChannel; - } - - public dispose() { - if (this._serialPortCtrl && this._serialPortCtrl.isActive) { - return this._serialPortCtrl.stop(); - } - } - - public async selectSerialPort(vid: string, pid: string) { - const lists = await SerialPortCtrl.list(); - if (!lists.length) { - vscode.window.showInformationMessage("No serial port is available."); - return; - } - - if (vid && pid) { - const valueOfVid = parseInt(vid, 16); - const valueOfPid = parseInt(pid, 16); - const foundPort = lists.find((p) => { - // The pid and vid returned by SerialPortCtrl start with 0x prefix in Mac, but no 0x prefix in Win32. - // Should compare with decimal value to keep compatibility. - if (p.productId && p.vendorId) { - return parseInt(p.productId, 16) === valueOfPid && parseInt(p.vendorId, 16) === valueOfVid; - } - return false; - }); - if (foundPort && !(this._serialPortCtrl && this._serialPortCtrl.isActive)) { - this.updatePortListStatus(foundPort.port); - } - } else { - const chosen = await vscode.window.showQuickPick(lists.map((l: ISerialPortDetail): vscode.QuickPickItem => { - return { - description: l.desc, - label: l.port, - }; - }).sort((a, b): number => { - return a.label === b.label ? 0 : (a.label > b.label ? 1 : -1); - }), { placeHolder: "Select a serial port" }); - if (chosen && chosen.label) { - this.updatePortListStatus(chosen.label); - } - } - } - - public async openSerialMonitor() { - if (!this._currentPort) { - const ans = await vscode.window.showInformationMessage("No serial port was selected, please select a serial port first", "Yes", "No"); - if (ans === "Yes") { - await this.selectSerialPort(null, null); - } - if (!this._currentPort) { - return; - } - } - - if (this._serialPortCtrl) { - if (this._currentPort !== this._serialPortCtrl.currentPort) { - await this._serialPortCtrl.changePort(this._currentPort); - } else if (this._serialPortCtrl.isActive) { - vscode.window.showWarningMessage(`Serial monitor is already opened for ${this._currentPort}`); - return; - } - } else { - this._serialPortCtrl = new SerialPortCtrl(this._currentPort, this._currentBaudRate, this._outputChannel); - } - - if (!this._serialPortCtrl.currentPort) { - Logger.traceError("openSerialMonitorError", new Error(`Failed to open serial port ${this._currentPort}`)); - return; - } - - try { - await this._serialPortCtrl.open(); - this.updatePortStatus(true); - } catch (error) { - Logger.notifyUserWarning("openSerialMonitorError", error, - `Failed to open serial port ${this._currentPort} due to error: + ${error.toString()}`); - } - } - - public async sendMessageToSerialPort() { - if (this._serialPortCtrl && this._serialPortCtrl.isActive) { - const text = await vscode.window.showInputBox(); - try { - await this._serialPortCtrl.sendMessage(text); - } catch (error) { - Logger.notifyUserWarning("sendMessageToSerialPortError", error, constants.messages.FAILED_SEND_SERIALPORT); - } - } else { - Logger.notifyUserWarning("sendMessageToSerialPortError", new Error(constants.messages.SEND_BEFORE_OPEN_SERIALPORT)); - } - } - - public async changeBaudRate() { - const rates = SerialMonitor.listBaudRates(); - const chosen = await vscode.window.showQuickPick(rates.map((rate) => rate.toString())); - if (!chosen) { - Logger.warn("No baud rate selected, keeping previous baud rate."); - return; - } - if (!parseInt(chosen, 10)) { - Logger.warn("Invalid baud rate, keeping previous baud rate.", { value: chosen }); - return; - } - if (!this._serialPortCtrl) { - Logger.warn("Serial Monitor has not been started."); - return; - } - const selectedRate: number = parseInt(chosen, 10); - await this._serialPortCtrl.changeBaudRate(selectedRate); - this._currentBaudRate = selectedRate; - this._baudRateStatusBar.text = chosen; - } - - public async closeSerialMonitor(port: string, showWarning: boolean = true): Promise { - if (this._serialPortCtrl) { - if (port && port !== this._serialPortCtrl.currentPort) { - // Port is not opened - return false; - } - const result = await this._serialPortCtrl.stop(); - this.updatePortStatus(false); - return result; - } else if (!port && showWarning) { - Logger.notifyUserWarning("closeSerialMonitorError", new Error(constants.messages.SERIAL_PORT_NOT_STARTED)); - return false; - } - } - - private updatePortListStatus(port?: string) { - const dc = DeviceContext.getInstance(); - if (port) { - dc.port = port; - } - this._currentPort = dc.port; - - if (dc.port) { - this._portsStatusBar.text = dc.port; - } else { - this._portsStatusBar.text = ""; + } + } + + private updatePortStatus(isOpened: boolean) { + if (isOpened) { + this._openPortStatusBar.command = "arduino.closeSerialMonitor"; + this._openPortStatusBar.text = `$(x)`; + this._openPortStatusBar.tooltip = "Close Serial Monitor"; + this._baudRateStatusBar.show(); + } else { + this._openPortStatusBar.command = "arduino.openSerialMonitor"; + this._openPortStatusBar.text = `$(plug)`; + this._openPortStatusBar.tooltip = "Open Serial Monitor"; + this._baudRateStatusBar.hide(); + } + + } +} diff --git a/src/serialmonitor/serialPlotter.ts b/src/serialmonitor/serialPlotter.ts new file mode 100644 index 00000000..2af8674c --- /dev/null +++ b/src/serialmonitor/serialPlotter.ts @@ -0,0 +1,125 @@ +import * as throttle from "lodash.throttle"; +import * as vscode from "vscode"; + +import { VscodeSettings } from "../arduino/vscodeSettings"; +import { SerialPortCtrl } from "./serialportctrl"; + +enum MessageType { + Frame = "Frame", + Action = "Action", +} + +enum Action { + Reset = "Reset", +} + +interface IMessage { + type: MessageType; +} + +interface IMessageFrame extends IMessage { + type: typeof MessageType.Frame; + time?: number; + [field: string]: string | number; +} + +interface IMessageAction extends IMessage { + type: typeof MessageType.Action; + action: Action; +} + +type ISendMessage = (message: IMessage) => void; + +export class SerialPlotter implements vscode.Disposable { + public static DEFAULT_THROTTLING: number = 100; + + private _throttling: number = SerialPlotter.DEFAULT_THROTTLING; + private _frame: IMessageFrame = null; + private _sendMessage: ISendMessage = null; + + private disposableOnLineHandler: vscode.Disposable = null; + private sendFrame: () => void = null; + + constructor() { + this.setThrottling(SerialPlotter.DEFAULT_THROTTLING); + this.emptyFrame(); + } + + public open() { + vscode.commands.executeCommand("arduino.showSerialPlotter"); + } + + public reset() { + this.setThrottling(SerialPlotter.DEFAULT_THROTTLING); + this.sendMessage({type: MessageType.Action, action: Action.Reset} as IMessageAction); + + this.emptyFrame(); + } + + public dispose() { + this._sendMessage = undefined; + this._frame = undefined; + + if (this.disposableOnLineHandler) { + this.disposableOnLineHandler.dispose(); + this.disposableOnLineHandler = undefined; + } + } + + public setSendMessageFn(sendMessage: ISendMessage) { + this._sendMessage = sendMessage; + } + + public setSerialPortCtrl(serialPortCtrl: SerialPortCtrl) { + if (this.disposableOnLineHandler) { + this.disposableOnLineHandler.dispose(); + } + + this.disposableOnLineHandler = serialPortCtrl.onLine(this.handleSerialLine.bind(this)); + } + + public setThrottling(throttling: number): void { + this._throttling = throttling; + this.sendFrame = throttle(this._sendFrame, this._throttling, { leading: false }); + } + + private _sendFrame() { + if (!this._frame) { + return; + } + + this.sendMessage(this._frame); + this.emptyFrame(); + } + + private sendMessage(msg: IMessage) { + if (!this._sendMessage) { + return; + } + + this._sendMessage(msg); + } + + private handleSerialLine(line: string): void { + const match = line.match(new RegExp(VscodeSettings.getInstance().plotterRegex)); + + if (!match) { + return; + } + + const [, time, field, value] = match; + + this._frame = { + ...this._frame, + type: MessageType.Frame, + time: parseInt(time, 10), + [field]: parseFloat(value), + }; + + this.sendFrame(); + } + + private emptyFrame() { + this._frame = {type: MessageType.Frame}; + } +} diff --git a/src/serialmonitor/serialPlotterPanel.ts b/src/serialmonitor/serialPlotterPanel.ts new file mode 100644 index 00000000..759aab0e --- /dev/null +++ b/src/serialmonitor/serialPlotterPanel.ts @@ -0,0 +1,44 @@ +import * as vscode from "vscode"; +import { SerialPlotter } from "./serialPlotter"; + +export class SerialPlotterPanel { + public static currentPanel: SerialPlotterPanel | void = null; + public static serialPlotter: SerialPlotter | void = null; + + public static createOrShow({html, serialPlotter}: {html: string, serialPlotter: SerialPlotter}): void { + if (SerialPlotterPanel.currentPanel) { + SerialPlotterPanel.currentPanel._panel.reveal(); + return; + } + + const panel = vscode.window.createWebviewPanel("arduinoSerialPlotter", "Arduino Serial Plotter", vscode.ViewColumn.Two, { + enableScripts: true, + retainContextWhenHidden: true, + }); + + panel.webview.html = html; + + SerialPlotterPanel.serialPlotter = serialPlotter; + SerialPlotterPanel.currentPanel = new SerialPlotterPanel(panel); + } + + private readonly _panel: vscode.WebviewPanel = null; + + private constructor(panel: vscode.WebviewPanel) { + this._panel = panel; + + this._panel.onDidDispose(() => this.dispose()); + + if (SerialPlotterPanel.serialPlotter) { + SerialPlotterPanel.serialPlotter.setSendMessageFn((msg) => panel.webview.postMessage(msg)); + SerialPlotterPanel.serialPlotter.reset(); + } + } + + public dispose(): void { + SerialPlotterPanel.currentPanel = undefined; + SerialPlotterPanel.serialPlotter = undefined; + + this._panel.dispose(); + } +} diff --git a/src/serialmonitor/serialportctrl.ts b/src/serialmonitor/serialportctrl.ts index 70e03631..cc8991fa 100644 --- a/src/serialmonitor/serialportctrl.ts +++ b/src/serialmonitor/serialportctrl.ts @@ -4,7 +4,9 @@ import { ChildProcess, execFileSync, spawn } from "child_process"; import * as os from "os"; import * as path from "path"; +import * as vscode from "vscode"; import { OutputChannel } from "vscode"; +import { VscodeSettings } from "../arduino/vscodeSettings"; import { DeviceContext } from "../deviceContext"; interface ISerialPortDetail { @@ -62,10 +64,14 @@ export class SerialPortCtrl { private _currentPort: string; private _currentBaudRate: number; private _currentSerialPort = null; + private _lineEmitter: vscode.EventEmitter; + private _lineBuffer: Buffer; public constructor(port: string, baudRate: number, private _outputChannel: OutputChannel) { this._currentBaudRate = baudRate; this._currentPort = port; + this._lineEmitter = new vscode.EventEmitter(); + this._lineBuffer = Buffer.alloc(0); } /* @@ -79,6 +85,10 @@ export class SerialPortCtrl { return this._currentPort; } + public onLine(listener: (string) => {}): vscode.Disposable { + return this._lineEmitter.event(listener); + } + public open(): Promise { this._outputChannel.appendLine(`[Starting] Opening the serial port - ${this._currentPort}`); this._outputChannel.show(); @@ -98,6 +108,8 @@ export class SerialPortCtrl { this._child.stdout.on("data", (data) => { const jsonObj = JSON.parse(data.toString()) this._outputChannel.append(jsonObj["payload"] + "\n"); + + this.readLines(jsonObj["payload"]).forEach((line) => this._lineEmitter.fire(line)); }); // TODO: add message check to ensure _child spawned without errors resolve(); @@ -183,4 +195,15 @@ export class SerialPortCtrl { } }); } + + private readLines(buf: Buffer): string[] { + this._lineBuffer = Buffer.concat([this._lineBuffer, buf]); + + const lastEndingIdx = this._lineBuffer.lastIndexOf("\r\n"); + const lines = this._lineBuffer.slice(0, lastEndingIdx).toString().split("\r\n"); + + this._lineBuffer = this._lineBuffer.slice(lastEndingIdx + 1); + + return lines; + } } diff --git a/src/views/app/actions/api.ts b/src/views/app/actions/api.ts index 0fa1547c..5990732f 100644 --- a/src/views/app/actions/api.ts +++ b/src/views/app/actions/api.ts @@ -95,3 +95,9 @@ export function openExample(examplePath) { examplePath, }).then((response) => response.json()); } + +export function updatePlotRefreshRate(rate) { + return postHTTP("/api/updateplotrate", { + rate, + }).then((response) => response.json()); +} diff --git a/src/views/app/components/SerialPlotter.tsx b/src/views/app/components/SerialPlotter.tsx new file mode 100644 index 00000000..fdb71efc --- /dev/null +++ b/src/views/app/components/SerialPlotter.tsx @@ -0,0 +1,282 @@ +import * as React from "react"; +import { Button, ControlLabel, FormControl, FormGroup } from "react-bootstrap"; +import * as API from "../actions/api"; +import { chartConfig } from "./chartConfig"; + +import "dygraphs"; + +enum MessageType { + Frame = "Frame", + Action = "Action", +} + +enum Action { + Reset = "Reset", +} + +interface IMessage { + type: MessageType; +} + +interface IMessageFrame extends IMessage { + type: typeof MessageType.Frame; + time?: number; + [field: string]: string | number; +} + +interface IMessageAction extends IMessage { + type: typeof MessageType.Action; + action: Action; +} + +interface ISerialPlotterState extends React.Props { + rate: number; + active: boolean; + timeWindow: number; +} + +const formatTime = (time: number) => { + const date = new Date(time); + + const hh = date.getUTCHours().toString().padStart(2, "0"); + const mm = date.getUTCMinutes().toString().padStart(2, "0"); + const ss = date.getUTCSeconds().toString().padStart(2, "0"); + const mss = date.getUTCMilliseconds().toString().padStart(3, "0"); + + return `${hh}:${mm}:${ss}.${mss}`; +}; + +const getFrameLabels = (msg: IMessageFrame) => + Object.keys(msg).filter((label) => !["time", "type"].includes(label)); + +class SerialPlotter extends React.Component, ISerialPlotterState> { + public static INITIAL_THROTTLING = 100; + public static INITIAL_TIME_WINDOW = 1000 * 20; + + public state = { + rate: SerialPlotter.INITIAL_THROTTLING, + timeWindow: SerialPlotter.INITIAL_TIME_WINDOW, + active: false, + }; + + private _graph: Dygraph = null; + private _data: number[][] = null; + private _lastValues: { [field: string]: number } = null; + private _labels: string[] = null; + private _timeWindow: number = SerialPlotter.INITIAL_TIME_WINDOW; + + private _ref: HTMLElement = null; + + public componentDidMount() { + this.initMessageHandler(); + this.initChart(); + + window.addEventListener("resize", this.handleResize, true); + } + + public render() { + return ( +
+
+
(this._ref = el)} /> +
+
+
+
+
+ + Refresh rate + + + + + Time window + + +
+ +
+
+ +
+
+ + +
+
+
+
+ ); + } + + private initChart() { + if (this._graph) { + this._graph.destroy(); + } + + this._labels = []; + this._graph = new Dygraph(this._ref, [[0, 0]], { + labels: this._labels, + legend: "always", + showRangeSelector: true, + connectSeparatedPoints: true, + drawGapEdgePoints: true, + axes: { + x: { + valueFormatter: formatTime, + axisLabelFormatter: formatTime, + }, + }, + }); + + this._data = []; + this._lastValues = {}; + } + + private getFrameValues(msg: IMessageFrame, labels: string[]) { + return labels.map((label) => { + const value = msg[label] as number; + + if (typeof value !== "undefined") { + this._lastValues[label] = value; + + return value; + } + + return this._lastValues[label] || null; + }); + } + + private getDataTimeWindow(time: number) { + const start = Math.max(0, time - this._timeWindow); + const startIdx = this._data.findIndex((data) => data[0] > start); + const timeWindowData = this._data.slice(startIdx); + + return timeWindowData; + } + + private updateChart() { + this._graph.updateOptions({ + file: this._data, + labels: ["time", ...this._labels], + }); + } + + private addFrame(msg: IMessageFrame) { + if (!this._graph) { + return; + } + + const labels = [...new Set([...this._labels, ...getFrameLabels(msg)])]; + const values = this.getFrameValues(msg, labels); + + const time = msg.time; + const frameData = [time, ...values]; + + this._data = [...this.getDataTimeWindow(time), frameData]; + this._labels = labels; + + this.updateChart(); + } + + private doAction(msg: IMessageAction) { + if (msg.action === Action.Reset) { + this.reset(); + } + } + + private play = () => { + this.setState({ + active: true, + }); + } + + private pause = () => { + this.setState({ + active: false, + }); + } + + private reset = () => { + this.initChart(); + } + + private initMessageHandler() { + window.addEventListener("message", (event) => { + if (!this.state.active) { + return; + } + + const data: IMessage = event.data; + + switch (data.type) { + case MessageType.Frame: + this.addFrame(data as IMessageFrame); + break; + case MessageType.Action: + this.doAction(data as IMessageAction); + break; + default: + // TODO: Add warning back in not in console + // console.warn("Unknown message type", data); + } + }); + + this.setState({ + active: true, + }); + } + + private applyPlotSettings = () => { + API.updatePlotRefreshRate(this.state.rate); + + this._timeWindow = this.state.timeWindow; + + const lastData = this._data[this._data.length - 1]; + const lastTime = lastData[0]; + + this._data = this.getDataTimeWindow(lastTime); + + this.updateChart(); + } + + private onRateChange = (e) => { + this.setState({ + rate: e.target.value, + }); + } + + private onTimeWindowChange = (e) => { + this.setState({ + timeWindow: e.target.value, + }); + } + + private handleResize() { + (this._graph as any).resize(); + } +} + +export default SerialPlotter; diff --git a/src/views/app/components/chartConfig.ts b/src/views/app/components/chartConfig.ts new file mode 100644 index 00000000..49fcd208 --- /dev/null +++ b/src/views/app/components/chartConfig.ts @@ -0,0 +1,72 @@ +export const chartConfig = { + chart: { + zoomType: "x", + }, + title: { + text: "Serial Plotter", + }, + boost: { + enabled: true, + useGPUTranslations: true, + }, + xAxis: { + type: "datetime", + crosshair: true, + title: { + text: "Time", + }, + }, + yAxis: { + title: { + text: "Value", + }, + }, + series: { + marker: { + enabled: false, + }, + }, + tooltip: { + animation: false, + split: true, + xDateFormat: "%H:%M:%S.%L", + }, + legend: { + layout: "vertical", + align: "right", + verticalAlign: "middle", + title: { + text: "Legend", + }, + }, + plotOptions: { + series: { + showInNavigator: true, + }, + }, + rangeSelector: { + buttons: [ + { + count: 10, + type: "second", + text: "10s", + }, + { + count: 30, + type: "second", + text: "30s", + }, + { + count: 1, + type: "minute", + text: "1m", + }, + { + type: "all", + text: "All", + }, + ], + inputEnabled: false, + selected: 0, + }, +}; diff --git a/src/views/app/index.tsx b/src/views/app/index.tsx index 52c6111d..5b2b3f11 100644 --- a/src/views/app/index.tsx +++ b/src/views/app/index.tsx @@ -10,8 +10,8 @@ import BoardConfig from "./components/BoardConfig"; import BoardManager from "./components/BoardManager"; import ExampleTreeView from "./components/ExampleTreeView"; import LibraryManager from "./components/LibraryManager"; +import SerialPlotter from "./components/SerialPlotter"; import reducer from "./reducers"; - import "./styles"; class App extends React.Component<{}, {}> { @@ -35,6 +35,7 @@ ReactDOM.render( + , diff --git a/src/views/app/styles/board.scss b/src/views/app/styles/board.scss index eb42759d..85e59a9c 100644 --- a/src/views/app/styles/board.scss +++ b/src/views/app/styles/board.scss @@ -215,4 +215,42 @@ a { .react-selector { width: 70%; } -} \ No newline at end of file +} + +.serialplotter { + padding: 12px; + + .graph { + width: 100% !important; + } + + .settings { + padding: 12px; + display: flex; + justify-content: space-between; + } + + .section { + display: grid; + grid-auto-columns: auto; + grid-auto-flow: column; + grid-gap: 6px; + align-items: baseline; + border: 1px solid white; + padding: 6px; + } + + .parameters { + display: grid; + grid-auto-columns: auto; + grid-auto-flow: row; + grid-gap: 6px; + + input { + width: 80px; + } + } + + .actions { + } +} diff --git a/src/views/package-lock.json b/src/views/package-lock.json index 6e07c825..ba2d8cda 100644 --- a/src/views/package-lock.json +++ b/src/views/package-lock.json @@ -21,6 +21,21 @@ } } }, + "@types/dygraphs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/dygraphs/-/dygraphs-2.1.0.tgz", + "integrity": "sha512-v61ndhl/F215QQVBJka4W2hpCAsmRY/TRMOeIq8fMBlGmnbvO7GNxD6DxC+vNS1lLaaZEqUsQbHkZsqg5cPh5w==", + "dev": true, + "requires": { + "@types/google.visualization": "*" + } + }, + "@types/google.visualization": { + "version": "0.0.68", + "resolved": "https://registry.npmjs.org/@types/google.visualization/-/google.visualization-0.0.68.tgz", + "integrity": "sha512-LkLniL1TYykhz+ZdRof3Bi8cp1OhqoK11Tj1RM2bPtGVBNexQ0eRnOrOWcWTdi80Sz9DzJ4JIG2rTlSJBVV58w==", + "dev": true + }, "@types/history": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@types/history/-/history-3.2.4.tgz", @@ -1666,6 +1681,11 @@ } } }, + "dygraphs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/dygraphs/-/dygraphs-2.1.0.tgz", + "integrity": "sha1-L7/SyAPq0CMH3z+vjU3T71XLIHU=" + }, "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -3258,6 +3278,14 @@ "read-pkg-up": "^1.0.1", "redent": "^1.0.0", "trim-newlines": "^1.0.0" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + } } }, "micromatch": { diff --git a/src/views/package.json b/src/views/package.json index 27a9cb6b..d21e9ae3 100644 --- a/src/views/package.json +++ b/src/views/package.json @@ -9,6 +9,7 @@ "author": "", "license": "ISC", "devDependencies": { + "@types/dygraphs": "2.1.0", "@types/react": "^15.0.11", "@types/react-bootstrap": "0.0.45", "@types/react-dom": "^0.14.23", @@ -20,9 +21,9 @@ "mini-css-extract-plugin": "^0.8.0", "node-sass": "^4.14.1", "rc-tree": "~1.4.5", - "react": "^15.4.2", + "react": "^15.6.2", "react-bootstrap": "^0.30.7", - "react-dom": "^15.4.2", + "react-dom": "^15.6.2", "react-list": "^0.8.4", "react-redux": "^5.0.2", "react-router": "^3.0.2", @@ -36,5 +37,7 @@ "ts-loader": "^4.5.0", "webpack": "^4.44.1" }, - "dependencies": {} + "dependencies": { + "dygraphs": "^2.1.0" + } } diff --git a/src/views/tsconfig.json b/src/views/tsconfig.json index 9def2d1d..cb71ceaa 100644 --- a/src/views/tsconfig.json +++ b/src/views/tsconfig.json @@ -7,9 +7,13 @@ "outDir": "out", "alwaysStrict": true, "sourceMap": true, - "rootDir": "." + "rootDir": ".", + "lib": [ + "dom", + "es2017" + ] }, "exclude": [ "node_modules" ] -} \ No newline at end of file +} diff --git a/test/extension.test.ts b/test/extension.test.ts index 6c91b0e9..8f4c20e0 100644 --- a/test/extension.test.ts +++ b/test/extension.test.ts @@ -43,10 +43,12 @@ suite("Arduino: Extension Tests", () => { "arduino.showLibraryManager", "arduino.showBoardConfig", "arduino.showExamples", + "arduino.showSerialPlotter", "arduino.changeBoardType", "arduino.initialize", "arduino.selectSerialPort", "arduino.openSerialMonitor", + "arduino.openSerialPlotter", "arduino.changeBaudRate", "arduino.sendMessageToSerialPort", "arduino.closeSerialMonitor",