From b53ef63fc522ecd8005defa1758cdbb73fffd283 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 18 Aug 2026 16:45:39 -0500 Subject: [PATCH 1/3] feat(web): Basicmicro MCP console (UART/CAN) + generic slcan CAN console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two self-contained, offline, dependency-free Web Serial browser apps, following the structure and quality bar of the odrive_ascii web consoles (theme-aware CSS with prefers-color-scheme + data-theme overrides, responsive grid layout, accessible labels, + meta description for the auto-generated docs apps index, e.port-based unplug handling, and promise-chain transaction serialization with strict typed codecs). components/basicmicro/web/mcp_console.html — "Basicmicro MCP Console" Test/config console for Basicmicro MCP236/MCP266 (RoboClaw-family) motor controllers over either kind of USB converter: * Transport A — USB-UART (packet serial): CRC16-CCITT (poly 0x1021, init 0, non-reflected, big-endian) framing; write commands carry data+CRC and expect the single 0xFF ACK; read commands send [addr][cmd] and verify the reply CRC over addr+cmd+data; >=15 ms quiet-period resync after any timeout/CRC/ACK failure (the device clears its packet buffer after >10 ms of silence). Implements GETVERSION(21), GETMBATT(24), GETTEMP(82), GETCURRENTS(49), GETSTATUS(90, 32-bit with 16-bit retry fallback + status-bit decode), duty cmds 32/33/34 (spring-to-zero sliders with paced updates), speed cmds 35/36/37 (strict i32 qpps entry), encoder readback 16/17 (u32 + status-bit decode, 5 Hz poll toggle), RESETENC(20). * Transport B — USB-CAN adapter (slcan/LAWICEL) carrying CANopen/DS402: slcan bring-up (C, Sn, O; CR ok / BEL error), client-side CANopen — NMT (start/stop/pre-op/reset), expedited SDO upload/download on 0x600/0x580+node with abort-code decoding and a single-waiter transaction chain, heartbeat monitor badge on 0x700+node — and the CiA 402 profile: statusword state machine decode with standard masks, verified enable sequence (0x0006→0x0007→0x000F with per-step statusword confirmation), fault reset edge (0x0080→0x0000), quick stop (0x0002), mode 0x6060, target/actual velocity 0x60FF/0x606C, target/actual position 0x607A/0x6064, identity 0x1018:1-4, device type 0x1000, error register 0x1001, plus an SDO object browser (preloaded standard objects + arbitrary index/sub/type read/write). A big always-visible STOP control sends duty 0/0 (cmd 34) in UART mode or the DS402 quick-stop controlword in CAN mode. Raw traffic log (hex packets / slcan lines) with pause/clear/export. Every transmitted value passes a strict full-string parse + range check; CRC16 golden vector + SDO codec round-trip self-tests run at load. components/twai/web/can_console.html — "CAN Bus Console (slcan)" Generic raw-CAN console for any slcan USB-CAN adapter: bitrate select (S0-S8) with open/close; TX composer for standard/extended data and RTR frames with strict full-string validation (hex ID with 11/29-bit range check, DLC auto/0-8 consistency, hex data bytes); multiple periodic-transmit slots with individual stop and stop-all; live RX as both a chronological log (pause/autoscroll/clear/export, TX echoed inline) and a sorted per-ID latest table (dlc, data with changed-byte delta highlighting, count, smoothed period estimate); include/exclude ID filter with hex ranges; adapter command panel (V/N/F + guarded raw command entry). Both files were verified with node --check on the extracted inline scripts, contain no external resource references, and initialize cleanly (self-tests pass) in headless Chromium. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- components/basicmicro/web/mcp_console.html | 2040 ++++++++++++++++++++ components/twai/web/can_console.html | 1285 ++++++++++++ 2 files changed, 3325 insertions(+) create mode 100644 components/basicmicro/web/mcp_console.html create mode 100644 components/twai/web/can_console.html diff --git a/components/basicmicro/web/mcp_console.html b/components/basicmicro/web/mcp_console.html new file mode 100644 index 000000000..cbe9da7d0 --- /dev/null +++ b/components/basicmicro/web/mcp_console.html @@ -0,0 +1,2040 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Basicmicro MCP Console + + + + + +
+

Basicmicro MCP Console

+
+ + + Disconnected +
+ + +
+ Web Serial is not available in this browser. + Web Serial requires a Chromium-based browser (Chrome, Edge, Opera, Brave) + served over https:// or http://localhost. +
+ +
+ +
+

Transport

+
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ Packet serial: the console talks the Basicmicro binary protocol directly over the USB-UART converter. +
+
+ +
+ + +
+ +
+

Device Info + + +

+
+
+
Firmware
+
Main battery
+
Temperature
+
Motor currents
+
Status
+
+
GETVERSION (21) · GETMBATT (24) · GETTEMP (82) · GETCURRENTS (49) · GETSTATUS (90). Status bit meanings can vary slightly across firmware revisions.
+
+
+
+
Device type (0x1000)
+
Error register (0x1001)
+
Vendor ID (0x1018:1)
+
Product code (0x1018:2)
+
Revision (0x1018:3)
+
Serial number (0x1018:4)
+
+
Read over expedited SDO from the configured node.
+
+
+ + +
+

CANopen Status & NMT

+
+ NMTno heartbeat + CiA 402unknown + +
+
+ + + + + +
+
Heartbeat monitor listens on 0x700+node. If the drive is not producing heartbeats, enable them via object 0x1017.
+
+ + +
+

Motion — Packet Serial

+ +

Signed duty (±32767) · cmds 32 / 33

+
+ M1 duty + + 0 + +
+
+ M2 duty + + 0 + +
+
Sliders spring back to zero when released. Keyboard users: arrow keys nudge, the 0 button stops that motor.
+ +

Signed speed (qpps) · cmds 35 / 36 / 37

+
+
+ + +
+ +
+ + +
+ + +
+ + +

Encoders · cmds 16 / 17 / 20

+
+
Encoder 1
+
Encoder 2
+
+
+ + + + +
+
+ + +
+

Motion — DS402

+
+
+ + +
+ + Mode display (0x6061): +
+
+ + + + +
+ +

Velocity

+
+
+ + +
+ +
+ Actual (0x606C) + +
+ +
+ +

Position

+
+
+ + +
+ + +
+ Actual (0x6064) + +
+
+ +
Values are transmitted only after strict validation (full-string integer parse + type range check).
+
+
+ + +
+ +
+

SDO Object Browser

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ +
+ + + + + + + + + + + + +
ObjectIndexSubTypeValue
+
+
Click a row to load it into the form. reads the object. Expedited transfers only (≤ 4 bytes).
+
+ + +
+

Raw Traffic + + +

+
+ + + + + +
+
+
UART mode: hex packet bytes. CAN mode: raw slcan lines. Pause stops the display; capture (for Export) continues.
+
+
+
+ + + + + + diff --git a/components/twai/web/can_console.html b/components/twai/web/can_console.html new file mode 100644 index 000000000..a2b37c172 --- /dev/null +++ b/components/twai/web/can_console.html @@ -0,0 +1,1285 @@ + + + + + + CAN Bus Console (slcan) + + + + + +
+

CAN Bus Console — slcan / Web Serial

+
+ + + + + + Disconnected +
+ + +
+ Web Serial is not available in this browser. + Web Serial requires a Chromium-based browser (Chrome, Edge, Opera, Brave) + served over https:// or http://localhost. +
+ +
+ +
+ +
+

Traffic Log + + +

+
+ + + + + +
+
+
+ + +
+

Per-ID Latest + + +

+
+ + + + + + + + + + + + +
IDTypeDLCDataCountPeriod
+
No frames received yet.
+
+
Bytes that changed since the previous frame with the same ID are highlighted. Period is a smoothed inter-frame interval.
+
+
+ + +
+ +
+

Transmit Frame

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+ +
+ +
+
+ + +
+ +
Adds a repeating transmit slot below (5–60000 ms).
+
+
+ + +
+

Periodic Transmits + + +

+
No periodic transmits running.
+
+ + +
+

ID Filter

+
+
+ + +
+
+ + +
+ +
+ +
Filters received frames in the log and the per-ID table. Transmitted frames are always echoed.
+
+ + +
+

Adapter

+
+ + + +
+
+
+ + +
+ +
+
Responses appear in the traffic log. BEL (0x07) from the adapter is reported as an error.
+
+
+
+ + + + + + From 988b40c69974e44082d5bd2422a58bf522670543 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 18 Aug 2026 19:03:20 -0500 Subject: [PATCH 2/3] =?UTF-8?q?fix(web):=20address=20PR=20#731=20review=20?= =?UTF-8?q?comments=20=E2=80=94=20slcan=20robustness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Both consoles: 'z'/'Z' transmit acks are unsolicited tokens and no longer consume the pending-command FIFO, so an autopolling adapter can no longer desynchronize command/response pairing. - Both consoles: the open-channel result now distinguishes CR ack (open), BEL (refused, stays closed), and TIMEOUT (silent adapter): the silent case proceeds tentatively (some bridges never ack) but surfaces 'Open (no ack)' status + a warning instead of reporting a confirmed open channel. - can_console: periodic transmit slots use a self-scheduling setTimeout loop armed only after the previous send completes — a slow/stalled send can no longer overlap itself with concurrent writer.write() calls. node --check passes on both extracted scripts. Co-Authored-By: Claude Opus 4.8 --- components/basicmicro/web/mcp_console.html | 32 ++++++++++++----- components/twai/web/can_console.html | 41 +++++++++++++++++----- 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/components/basicmicro/web/mcp_console.html b/components/basicmicro/web/mcp_console.html index cbe9da7d0..2a3c1db7d 100644 --- a/components/basicmicro/web/mcp_console.html +++ b/components/basicmicro/web/mcp_console.html @@ -913,16 +913,28 @@

Raw Traffic await slcanCmd("C", { quiet: true }); await slcanCmd("S" + s); const okO = await slcanCmd("O"); - channelOpen = okO !== false; + // okO: true = CR ack, false = BEL refusal, null = timeout (silent + // adapter). Only an explicit BEL keeps the channel closed; a silent + // adapter is treated as tentatively open (some bridges never ack) but + // the unknown state is surfaced instead of being reported as confirmed. setConnectedUI(true); - if (channelOpen) { - setStatus("connected", kbit + " · node " + canNodeId); - logLine("sys", "CAN channel open at " + kbit + ", talking to node " + canNodeId + "."); - await readDeviceInfo(); - try { await readStatusword(); } catch (_) { /* drive may be silent until NMT start */ } - } else { + if (okO === false) { + channelOpen = false; setStatus("error", "Adapter refused O"); logLine("err", "Adapter rejected the open command; check the bitrate or power-cycle the adapter."); + } else { + channelOpen = true; + if (okO === true) { + setStatus("connected", kbit + " · node " + canNodeId); + logLine("sys", "CAN channel open at " + kbit + ", talking to node " + canNodeId + "."); + } else { + setStatus("connected", kbit + " (no ack) · node " + canNodeId); + logLine("err", "Adapter did not acknowledge the open command — state unknown. Proceeding " + + "since some adapters never ack; if SDO reads time out, verify the bitrate, wiring, and " + + "that the adapter really speaks slcan."); + } + await readDeviceInfo(); + try { await readStatusword(); } catch (_) { /* drive may be silent until NMT start */ } } } } @@ -1386,6 +1398,10 @@

Raw Traffic } function resolveCmd(ok, payload) { + // 'z'/'Z' transmit acks (autopoll) are UNSOLICITED tokens, not command + // responses: never let them consume the pending-command FIFO, or every + // later command/response pairing desynchronizes. + if (ok && (payload === "z" || payload === "Z")) return; const p = pendingCmds.shift(); if (!p) { if (payload !== "" || !ok) { @@ -1395,7 +1411,7 @@

Raw Traffic } clearTimeout(p.timer); if (ok) { - if (payload !== "" && payload !== "z" && payload !== "Z") logLine("rx", payload); + if (payload !== "") logLine("rx", payload); } else if (!p.quiet) { logLine("err", "Adapter rejected command '" + p.cmd + "' (BEL)"); } diff --git a/components/twai/web/can_console.html b/components/twai/web/can_console.html index a2b37c172..6ae47bbcb 100644 --- a/components/twai/web/can_console.html +++ b/components/twai/web/can_console.html @@ -695,15 +695,26 @@

Adapter

await sendCmd("C", { quiet: true }); // a BEL here just means "was not open" const okS = await sendCmd("S" + s); const okO = await sendCmd("O"); - channelOpen = okO !== false; + // okO: true = CR ack, false = BEL refusal, null = timeout (silent + // adapter). Only an explicit BEL keeps the channel closed; a silent + // adapter is treated as tentatively open (some bridges never ack) but + // the unknown state is surfaced instead of being reported as confirmed. setConnectedUI(true); - if (channelOpen) { + if (okO === true) { + channelOpen = true; setStatus("connected", "Open · " + kbit); logLine("sys", "CAN channel open at " + kbit + "."); - } else { + } else if (okO === false) { + channelOpen = false; setStatus("error", "Adapter refused O"); logLine("err", "Adapter rejected the open command" + (okS === false ? " (bitrate was rejected too)" : "") + "; check the bitrate or power-cycle the adapter."); + } else { + channelOpen = true; + setStatus("connected", "Open (no ack) · " + kbit); + logLine("err", "Adapter did not acknowledge the open command — state unknown. Proceeding " + + "since some adapters never ack; if no frames flow, verify the bitrate, wiring, and that " + + "the adapter really speaks slcan."); } } @@ -857,9 +868,14 @@

Adapter

const pendingCmds = []; // { cmd, resolve, timer, quiet } function resolveCmd(ok, payload) { + // 'z'/'Z' transmit acks (autopoll) are UNSOLICITED tokens, not command + // responses: they can arrive while a control command is awaiting its + // CR/BEL, and consuming a FIFO entry for them would desynchronize every + // subsequent command/response pairing. Never let them shift the queue. + if (ok && (payload === "z" || payload === "Z")) return; const p = pendingCmds.shift(); if (!p) { - // Unsolicited non-frame token (e.g. stray tx-ack): just show it. + // Unsolicited non-frame token: just show it. if (payload !== "" || !ok) { logLine(ok ? "rx" : "err", payload === "" ? "(BEL) adapter error" : payload + (ok ? "" : " (BEL)")); } @@ -867,7 +883,7 @@

Adapter

} clearTimeout(p.timer); if (ok) { - if (payload !== "" && payload !== "z" && payload !== "Z") logLine("rx", payload); + if (payload !== "") logLine("rx", payload); } else if (!p.quiet) { logLine("err", "Adapter rejected command '" + p.cmd + "' (BEL)"); } @@ -1029,18 +1045,27 @@

Adapter

slots.push(slot); els.slots.appendChild(el); renderSlotsEmpty(); - slot.timer = setInterval(async () => { + // Self-scheduling loop (not setInterval): the next tick is armed only + // after the previous send fully completes, so a slow/stalled + // sendFrameLine can never overlap itself with concurrent writer.write() + // calls or out-of-order count updates. + slot.stopped = false; + const tick = async () => { + if (slot.stopped) return; if (!writer || !channelOpen) { removeSlot(slot); return; } const ok = await sendFrameLine(slot.line, slot.desc); if (ok) { slot.count++; slot.countEl.textContent = "sent " + slot.count; } - }, ms); + if (!slot.stopped) slot.timer = setTimeout(tick, ms); + }; + slot.timer = setTimeout(tick, ms); logLine("sys", "Periodic transmit started: " + line + " every " + ms + " ms."); } function removeSlot(slot) { const idx = slots.indexOf(slot); if (idx < 0) return; - clearInterval(slot.timer); + slot.stopped = true; + clearTimeout(slot.timer); slots.splice(idx, 1); slot.el.remove(); renderSlotsEmpty(); From d4f5b1c66914f7eb22c6aaff750373501bd5bc56 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Tue, 18 Aug 2026 20:48:18 -0500 Subject: [PATCH 3/3] fix(web): treat reader done as terminal in both consoles (PR #731 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When reader.read() reports done, the stream has ended — breaking only the inner loop re-entered the outer while and re-acquired a reader on the dead stream in a tight loop, potentially never reaching the cleanup path. Set keepReading = false on done so both loops exit and safeClose()/UI cleanup runs. Applied identically to both consoles; node --check passes. Co-Authored-By: Claude Opus 4.8 --- components/basicmicro/web/mcp_console.html | 8 +++++++- components/twai/web/can_console.html | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/components/basicmicro/web/mcp_console.html b/components/basicmicro/web/mcp_console.html index 2a3c1db7d..9a57e3477 100644 --- a/components/basicmicro/web/mcp_console.html +++ b/components/basicmicro/web/mcp_console.html @@ -983,7 +983,13 @@

Raw Traffic try { while (true) { const { value, done } = await reader.read(); - if (done) break; + if (done) { + // Terminal: the stream ended (port closed / unplugged). Stop + // the OUTER loop too -- otherwise we would re-acquire a reader + // on the dead stream in a tight loop and never reach cleanup. + keepReading = false; + break; + } if (value && value.length) { if (mode === "uart") handleUartRx(value); else handleSlcanRx(value); diff --git a/components/twai/web/can_console.html b/components/twai/web/can_console.html index 6ae47bbcb..9d4c920d4 100644 --- a/components/twai/web/can_console.html +++ b/components/twai/web/can_console.html @@ -759,7 +759,13 @@

Adapter

try { while (true) { const { value, done } = await reader.read(); - if (done) break; + if (done) { + // Terminal: the stream ended (port closed / unplugged). Stop + // the OUTER loop too -- otherwise we would re-acquire a reader + // on the dead stream in a tight loop and never reach cleanup. + keepReading = false; + break; + } if (value && value.length) handleRxBytes(value); } } catch (e) {