Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ jobs:
working-directory: ui
run: npm run lint

- name: Unit tests
working-directory: ui
run: npm test

- name: Build and check cli/src/mpftp/webui is not stale
working-directory: ui
run: |
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
`python.exe -m pip install bleak`, and the VS Code extension's Python needs
it for Bluetooth too.

- The browser interface has a plotter. The Plotter button over the REPL opens
a live graph beside it of the numbers the board prints: one or more series
per line, separated by commas or spaces, optionally named (`temp:21.5`).
Hover reads the values at a sample; Pause and Clear do what they say.

## v0.1.1 (2026-10-10)

- `firmware ptable` reads a classic ESP32's, an S2's or a P4's `firmware.bin`
Expand Down
2 changes: 1 addition & 1 deletion cli/src/mpftp/webui/app.css

Large diffs are not rendered by default.

73 changes: 38 additions & 35 deletions cli/src/mpftp/webui/app.js

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion cli/src/mpftp/webui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,13 @@
<i class="codicon codicon-terminal"></i>
<span>REPL</span>
</div>
<button type="button" class="mp-btn mp-plot-toggle" id="plot-toggle" aria-pressed="false" title="Plot numbers the board prints">
<i class="codicon codicon-graph-line"></i><span>Plotter</span>
</button>
</div>
<div class="mp-console-body" id="console-body">
<div class="mp-terminal-body" id="repl"></div>
</div>
<div class="mp-terminal-body" id="repl"></div>
</section>
</section>

Expand Down
7 changes: 7 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,13 @@ What you can do there:
local one, and its output appears in the REPL. The `⋯` menu has the rest:
interrupt, soft and hard reset, eval and exec, `mip`/`circup` installs,
the RTC, `df`, Wi-Fi access and running the editor's file.
- **Plot** what the board prints: the Plotter button over the REPL opens a
graph beside it. Every line that is only numbers adds a sample, one series
per number: `print(t)`, `print(t, h, p)` (commas or spaces), or named,
`print(f"temp:{t} hum:{h}")` (also `temp: 21.5` or `temp=21.5`). Up to
eight series, the newest 500 samples, scaled to fit; hover for the values
at a sample. Pause stops adding samples, Clear starts again, and other
REPL output is left alone.
- **Install it as an app**: Chrome and Edge offer Install in the address bar.
The app still needs `python -m mpftp` running to reach your boards.

Expand Down
7 changes: 6 additions & 1 deletion ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,13 @@
<i class="codicon codicon-terminal"></i>
<span>REPL</span>
</div>
<button type="button" class="mp-btn mp-plot-toggle" id="plot-toggle" aria-pressed="false" title="Plot numbers the board prints">
<i class="codicon codicon-graph-line"></i><span>Plotter</span>
</button>
</div>
<div class="mp-console-body" id="console-body">
<div class="mp-terminal-body" id="repl"></div>
</div>
<div class="mp-terminal-body" id="repl"></div>
</section>
</section>

Expand Down
3 changes: 2 additions & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"scripts": {
"build": "node esbuild.mjs",
"watch": "node esbuild.mjs --watch",
"lint": "tsc -p ./ --noEmit"
"lint": "tsc -p ./ --noEmit",
"test": "esbuild test/*.test.ts --bundle --platform=node --format=esm --outdir=out/test --out-extension:.js=.mjs --log-level=warning && node --test out/test/*.test.mjs"
},
"devDependencies": {
"esbuild": "^0.28.2",
Expand Down
13 changes: 13 additions & 0 deletions ui/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "@xterm/xterm/css/xterm.css";
import "./style.css";
import { Rpc } from "./rpc";
import { Plotter } from "./plotter";
import { Repl } from "./repl";
import { Editor } from "./editor";
import { initSplitters } from "./splitters";
Expand Down Expand Up @@ -48,6 +49,18 @@ let board: PanelState = { connected: false, device: "", interpreter: "", localPa

const repl = new Repl(el("repl"), rpc);

// The plotter sits beside the REPL and draws the numbers the board prints.
const plotToggle = el<HTMLButtonElement>("plot-toggle");
const plotter = new Plotter((open) => {
plotToggle.setAttribute("aria-pressed", String(open));
plotToggle.classList.toggle("is-active", open);
requestAnimationFrame(() => repl.fit());
});
el("console-body").append(plotter.pane);
repl.onOutput((bytes) => plotter.feed(bytes));
plotToggle.addEventListener("click", () => plotter.toggle());
plotter.restore();

const editorTitle = el<HTMLElement>("editor-where");
const saveBtn = el<HTMLButtonElement>("save-btn");
const saveAsBtn = el<HTMLButtonElement>("save-as-btn");
Expand Down
85 changes: 85 additions & 0 deletions ui/src/plotparse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Which REPL lines the plotter draws, and what is in them.
*
* A line plots when every item on it is a number, or a name and a number:
*
* 21.5 one series
* 21.5, 40.2, 1013 three series, named 1, 2, 3 by position
* 21.5 40.2 1013 the same, separated by spaces or tabs
* temp:21.5 hum:40.2 named series (also "temp: 21.5" and "temp=21.5")
*
* Anything else (a prompt, a traceback, text with a number in it) is left
* alone, so ordinary REPL output never turns into a graph by accident.
*/

/** The most series one line can carry. */
export const MAX_SERIES = 8;

export interface PlotSample {
/** Series names: the given name, or the 1-based position as a string. */
names: string[];
values: number[];
}

const NUMBER = String.raw`[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?`;
const ITEM = new RegExp(String.raw`^(?:([A-Za-z_][\w .()/%-]*?)\s*[:=]\s*)?(${NUMBER})$`);
// Terminal control sequences (colour, cursor moves) a board may print.
const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[()][0-9A-Za-z]|\x1b[@-_]/g;

export function stripAnsi(text: string): string {
return text.replace(ANSI, "");
}

export function parsePlotLine(raw: string): PlotSample | null {
const line = stripAnsi(raw).trim();
if (!line || line.length > 400) {
return null;
}
// "name: 1" keeps its space after the colon; join it so spaces can separate items.
const joined = line.replace(/\s*([:=])\s*/g, "$1");
const items = /[,;]/.test(joined)
? joined.split(/\s*[,;]\s*/)
: joined.split(/\s+/);
if (items.length > MAX_SERIES) {
return null;
}
const names: string[] = [];
const values: number[] = [];
for (let i = 0; i < items.length; i++) {
const m = ITEM.exec(items[i]);
if (!m) {
return null;
}
const value = Number(m[2]);
if (!Number.isFinite(value)) {
return null;
}
names.push(m[1] ? m[1].trim() : String(i + 1));
values.push(value);
}
return { names, values };
}

/**
* Turns the REPL's byte stream into whole lines. A board's output arrives in
* arbitrary chunks, so a line may be split across several.
*/
export class LineSplitter {
private decoder = new TextDecoder();
private partial = "";

/** Feed one chunk; returns the lines it completed. */
push(bytes: Uint8Array): string[] {
const text = this.partial + this.decoder.decode(bytes, { stream: true });
const parts = text.split("\n");
this.partial = parts.pop() ?? "";
if (this.partial.length > 4096) {
this.partial = ""; // no newline in sight: not a line of numbers
}
return parts.map((p) => p.replace(/\r$/, ""));
}

reset(): void {
this.partial = "";
}
}
Loading
Loading