feat(telemetry): binary telemetry service + WebUSB transport (Phase 2); rename serial_plotter→telemetry - #783
feat(telemetry): binary telemetry service + WebUSB transport (Phase 2); rename serial_plotter→telemetry#783finger563 wants to merge 24 commits into
Conversation
…example Phase 2 of the Serial Plotter: a firmware-side binary telemetry transport the web app can plot over WebUSB / Web Serial, alongside the existing text/CSV path. - include/telemetry_service.hpp: espp::Telemetry, a small device->host protocol on the stream_frame framing (dispatcher module 3). Firmware declares named float channels (SCHEMA) and pushes SAMPLE frames (device timestamp + one float per channel, batchable); host requests are GET_SCHEMA and SET_STREAM (enable/disable + rate). Follows the CoreDumpService pattern: build frames under a mutex, invoke the user send callback with the lock released; usable with a Dispatcher (handle()) or standalone (feed()). - CMakeLists.txt / idf_component.yml: the component now ships firmware, so it registers include/ and depends on base_component + stream_frame again. - example/: an esp32s3 app that streams four synthetic channels over USB vendor + CDC, wires a Dispatcher per transport (module 3 + discovery advertising app="serial_plotter.html"), and honors GET_SCHEMA / SET_STREAM. The web-app WebUSB transport that decodes SCHEMA/SAMPLE into the same uPlot plot lands in a follow-up commit. Verified: the example builds clean against ESP-IDF v6.1 (esp32s3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
…p + docs Adds a WebUSB transport to serial_plotter.html that plots the espp::Telemetry binary stream, alongside the existing text/CSV Web Serial path. - A new "USB" button connects over WebUSB (claims the vendor 0xFF interface's bulk IN/OUT pair), requests the schema (GET_SCHEMA), and starts the stream (SET_STREAM). It decodes stream_frame frames (vendored codec: magic/flags/ crc32 matching components/stream_frame), routes module 3, maps SCHEMA -> series and SAMPLE -> the same ring buffers / uPlot plot, using the device timestamp (u32 microseconds, unwrapped) as the X axis. Serial and USB are mutually exclusive; pause/clear/filter/save/modes all work over USB too. - Docs: a serial_plotter component doc page (Telemetry API via include-build-file) + index + example include, registered in the main toctree and the Doxygen INPUT list; web_apps.rst and the README/meta note the WebUSB path. Verified: the JS crc32 matches the C++ golden (0xCBF43926); schema/sample frames built + parsed + decoded correctly (headless); the app loads clean with the USB button and the CSV/serial path still plots (browser). Live WebUSB I/O uses the same UsbTransport pattern as the other espp consoles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
|
✅Static analysis result - no issues found! ✅ |
There was a problem hiding this comment.
🟡 Changes recommended
telemetry_service.hpp has likely C++20 build breaks (missing direct includes and std::vector passed to a std::span parameter) that should be fixed before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds Phase 2 of the serial_plotter component by introducing a firmware-side binary telemetry service (espp::Telemetry) and a matching WebUSB transport in the existing browser plotter, plus docs and an ESP32-S3 USB streaming example.
Changes:
- Add
espp::Telemetryheader-only service usingstream_frame(module 3) with SCHEMA/SAMPLE and GET_SCHEMA/SET_STREAM handling. - Extend
serial_plotter.htmlwith a WebUSB transport that decodesstream_frametelemetry and feeds the existing plotting pipeline. - Add example + documentation wiring (Sphinx + Doxygen) and update component metadata to include firmware dependencies.
File summaries
| File | Description |
|---|---|
| doc/en/web_apps.rst | Mentions Serial Plotter’s new WebUSB binary telemetry path in the web apps overview. |
| doc/en/serial_plotter/serial_plotter.rst | New component docs page describing Web Serial vs WebUSB and the Telemetry protocol. |
| doc/en/serial_plotter/serial_plotter_example.md | Adds docs include of the example README. |
| doc/en/serial_plotter/index.rst | Adds Serial Plotter section entry to the docs tree. |
| doc/en/index.rst | Registers Serial Plotter docs section in the main docs toctree. |
| doc/Doxyfile | Adds Telemetry header + example source to Doxygen inputs/example paths. |
| components/serial_plotter/web/serial_plotter.html | Implements WebUSB connect + stream_frame parsing and telemetry schema/sample decoding. |
| components/serial_plotter/README.md | Updates README to describe both transports and the Telemetry firmware path. |
| components/serial_plotter/include/telemetry_service.hpp | Introduces the espp::Telemetry firmware protocol/service implementation. |
| components/serial_plotter/idf_component.yml | Adds firmware deps and example metadata now that the component ships code. |
| components/serial_plotter/example/sdkconfig.defaults | Configures ESP32-S3 USB + TinyUSB vendor/CDC settings for telemetry streaming. |
| components/serial_plotter/example/README.md | Documents how to build/use the USB telemetry example with the web app. |
| components/serial_plotter/example/main/telemetry_example.cpp | Implements ESP32-S3 demo producing synthetic channels over vendor + CDC with Dispatcher routing. |
| components/serial_plotter/example/main/CMakeLists.txt | Registers the example’s main component requirements. |
| components/serial_plotter/example/CMakeLists.txt | Adds a managed-deps-friendly example project configuration and component search path narrowing. |
| components/serial_plotter/CMakeLists.txt | Updates component registration to export include/ and depend on base_component + stream_frame. |
Review details
Suppressed comments (2)
components/serial_plotter/include/telemetry_service.hpp:180
- send_fn expects a std::span, but this call passes a std::vector<uint8_t>, which can fail to compile under C++20. Pass an explicit span over the vector storage.
s(frame);
components/serial_plotter/include/telemetry_service.hpp:283
- send_fn expects a std::span, but send_frame passes a std::vector<uint8_t>. This can fail to compile under C++20; pass an explicit span over the vector bytes.
if (s)
s(frame);
- Files reviewed: 16/16 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Copilot review + cppcheck fixes on the Telemetry service: - Include <algorithm> and <string_view> directly (used by std::min / std::string_view) instead of relying on transitive includes. - Pass an explicit std::span<const uint8_t> over the vector at every send_fn call site, so it compiles under C++20 (no reliance on C++23 span-from-range). - Cap the channel count at 255 (kMaxChannels) in the constructor and set_channels, and derive the SCHEMA channel count and the serialized channels from the same bound, so a >255-channel config can't emit a malformed frame (the count is a u8). Warns when truncating. - Example: return early if usb.initialize() fails instead of running the tasks with no host transport. - static analysis (cppcheck functionConst): make send_schema, send_frame, send_ok, and send_error const (none mutate state; Logger::warn is const, warn_rate_limited is not, so emit stays non-const). Verified: builds clean on ESP-IDF v6.1 (esp32s3); cppcheck reports no findings on the header. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
There was a problem hiding this comment.
🟡 Changes recommended
WebUSB lifecycle and pause behavior, CDC compatibility, schema bounds, concurrency guarantees, and CI coverage need correction.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (6)
Previously missed (6) — in code that hasn't changed since the last review.
components/serial_plotter/example/main/telemetry_example.cpp:52
- This binary CDC path is not consumable by the Serial Plotter web app: its Web Serial loop decodes every chunk as UTF-8 and calls
feedText()(serial_plotter.html:764-799), while onlyUsbTransportfeedsSfParser. Consequently selecting the example's CDC port cannot request a schema or plot telemetry. Either add a binary Web Serial mode or remove the CDC telemetry path and its claims.
components/serial_plotter/include/telemetry_service.hpp:91 - Concurrent
emit()and request handling can invoke this callback simultaneously because each path releasesmutex_before sending. The type does not document thatsendmust be thread-safe, despite promising concurrent-safe use; a transport without the example's separate TX mutex can race or interleave frames. Serialize callback invocations internally, or explicitly require callers to provide a serialized/thread-safe callback.
components/serial_plotter/web/serial_plotter.html:946 open()can fail after the device has been opened or an interface claimed, but this catch dropstwithout closing it. That leaves the WebUSB device/interface open and can make the next connection attempt fail; closetbefore returning, as the other web transports do.
components/serial_plotter/web/serial_plotter.html:958SET_STREAMis sent only during connect/disconnect. The shared Pause handler merely togglespausedand drops incoming samples, so a WebUSB device continues transmitting while paused and is never re-enabled through this request on resume. SendSET_STREAMwith enabled=0/1 when Pause changes in USB mode (while retaining local-only pause for text serial).
components/serial_plotter/example/CMakeLists.txt:7- This raises the example to C++23 even though the repository standard and the directly related examples use C++20 (
components/dispatcher/example/CMakeLists.txt:22,components/stream_frame/example/CMakeLists.txt:21, andcomponents/usb_device/example/CMakeLists.txt:35). This code needs no C++23 feature, so the higher setting unnecessarily narrows compiler/ESP-IDF compatibility.
components/serial_plotter/example/CMakeLists.txt:49 - The new firmware example is absent from
.github/workflows/build.yml; the matrix jumps fromseeed-studio-round-displaytoserialization. Add an alphabetically placedcomponents/serial_plotter/exampleentry targetingesp32s3(using the manager-disabled command if intended) so this firmware path is continuously built.
- Files reviewed: 16/16 changed files
- Comments generated: 2
- Review effort level: Balanced
- Example is now WebUSB-only: the web app's Web Serial path parses text/CSV, so a binary-telemetry CDC interface it can't consume was misleading. Drop the CDC function (device enumerates just the vendor interface) and update the README / docs. The Telemetry service stays transport-agnostic (CDC/UART/socket work); only the demo is WebUSB. (CDC stays enabled in sdkconfig because usb_device includes the TinyUSB CDC header unconditionally, but no CDC interface is made.) - Serialize the send callback internally (a dedicated send_mutex_ + deliver()), so concurrent emit()/request handling can't interleave two frames' bytes even if the transport's send isn't itself thread-safe; document it. - WebUSB: close the transport if open() fails partway (don't leave the device claimed), and send SET_STREAM(0/1) on Pause/Resume so the device actually stops/starts streaming (text serial keeps local-only pause). - Example builds at C++20 (repo standard; no C++23 feature needed) and is added to the CI build matrix (esp32s3, manager-off). - static analysis: pass send_frame's frame by const ref (passedByValue). Verified: example builds clean on ESP-IDF v6.1 (esp32s3, gnu++20); cppcheck reports no findings; the web app parses. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
…SB-only) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
The WebUSB connect was an unstyled "USB" button separated from the primary "Connect" button by the Baud/Reset controls, so it was easy to miss. Rename to "Connect Serial" / "Connect WebUSB", style both as primary (blue), and place them side by side. Connection state is now tracked via the port/usb handles rather than the button text (which the labels changed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
A WebUSB tab close does NOT unmount the device, so the example kept streaming into the vendor TX FIFO with no reader; the backlog was then delivered to the next host ahead of its schema reply, so connecting often took several retries. Firmware (example): - Start paused (stream_on_start = false): only stream after a host sends SET_STREAM, so nothing queues before anyone is reading. - On vendor TX backpressure, clear the FIFO (vendor_write_clear) so an un-drained backlog isn't fed to the next host. - Reset streaming / TX / the frame parser on unmount and mount (physical replug), for a clean slate. Web app: - Request the schema on connect and retry until it arrives (a device left streaming can bury the first reply behind samples the read loop discards before it has a schema); start the device stream (SET_STREAM) only once the schema lands, so streaming begins on a clean handshake. - Ignore a duplicate SCHEMA (from a retried request) so it can't re-reset the rings, and cancel the retry timer on disconnect / link loss. Verified: example builds clean on ESP-IDF v6.1 (esp32s3); the web app parses. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
The live plot re-autoscaled every frame, so you couldn't zoom or pan while data streamed. Add a Follow mode: - Following (default) auto-scrolls to the latest data, optionally to a rolling Window of the last N seconds (new "Window" input) so recent detail stays legible at large retention. - Dragging to zoom drops out of Follow (setSelect hook) and the view is kept across live updates (setData without resetting the scales), so a zoomed/panned view sticks while samples keep flowing through it; all retained samples remain available to pan/zoom through. - Double-click the plot, or click Follow, to snap back to live. A fresh dataset starts Following. Verified: follow/window/zoom logic unit-tested against a mock uPlot (setData reset flag + rolling x-scale + drag hook); drag-zoom drops Follow and double-click resumes it live in the browser, no console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
…-time X - The Follow "Window" is seconds and "Retain" is samples; label them "Window (s)" and "Retain (samples)" so the units are explicit. - WebUSB samples carry a device hardware timestamp (stored as the time axis), but the X-source read "Arrival time" so it wasn't clear it was being used. In WebUSB mode the "time" X source now reads "Device time" and the axis is labelled "device time (s)"; serial/file keep "Arrival time" / "time (s)". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
…ase to 0) buildData rebased the time axis to the first retained sample, so "Device time" started at 0 on every (re)connect — indistinguishable from arrival time and throwing away the device clock. Rebasing now applies only to relative browser arrival time; the WebUSB device timestamp is kept as-is, so the X axis reflects real device time (e.g. uptime) and stays continuous across reconnects. Note: the device timestamp is u32 microseconds, so the absolute value wraps ~every 71 min; within a streaming session the host unwraps it to stay monotonic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
Over WebUSB the "time" X source is the device hardware clock; some users prefer plotting against browser arrival time (e.g. at different experiment times). Add a distinct "Arrival time" X source, backed by a separate arrival-time ring populated at receive; it is offered only in WebUSB mode (over serial, the "time" source already IS arrival time). Arrival time is rebased to the first retained sample; device time stays absolute. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
updateSchemaLabel() runs on the 500 ms stats tick and was mutating the #xMode <select> (option text / hidden / value) every time. Mutating a native select closes it if it's open, so the X-source menu flashed/disappeared and was unusable during live capture. Move the transport-dependent option reconciliation into updateXModeOptions(), called only from setSchema() (a schema/transport change); the periodic label update now only writes the status text. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
…not browser arrival The browser arrival timestamp is bursty over USB: one transferIn often delivers several sample frames, which then share a single receive time, so plotting against real browser arrival bunched points at the same x and distorted the waveform. Point the WebUSB "Arrival time" X source at the device timestamp rebased to the first retained sample (relative time from capture start) instead — clean and monotonic, same underlying data as Device time, just offset — and drop the separate browser-arrival ring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
… time" It's the device clock rebased to the capture start (not host arrival), so "Elapsed time" is clearer than "Arrival time". Renames the option (value "arrival" -> "elapsed", label + axis "elapsed (s)"). WebUSB X sources are now "Device time" (absolute) and "Elapsed time" (relative); serial keeps "Arrival time" (which there genuinely is browser arrival). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
The rolling window used `maxX - windowSec` in the current X-axis units, so it meant seconds on a time axis but samples on Sample-index (and column units on First column) — switching X source jarringly changed the view. Make Window always a time window: find the first sample within the last windowSec seconds by the actual sample time (tRing, binary search), then set the x-range to span those samples in whatever the current X units are. Selecting the same window now shows the same span of data on Device time, Elapsed time, Sample index, or First column. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
uPlot has no built-in pan (drag = box-zoom, double-click = reset), so a static or non-Follow plot couldn't be scrolled along — you could only zoom a box and reset. Wire the missing gestures on the plot overlay: - wheel -> zoom X about the cursor - Shift+wheel -> zoom Y about the cursor - Shift+drag -> pan X/Y (plain drag stays uPlot's box-zoom) Any of these drops out of Follow first (via setFollowing(false)) so the view isn't re-autoscaled on the next live frame. Shift+drag uses a capture-phase mousedown to pre-empt uPlot's own drag handler; the window-level move/up handlers are installed once at module scope (not re-added on every buildPlot() overlay rebuild). The Follow tooltip now documents the full gesture set. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
Two follow-ups to the pan/zoom gestures: - Live data kept flowing into the buffer when not following, but with the x-scale pinned the new points landed off the right edge and stayed invisible until you dragged the window over them (looked frozen once you stopped dragging). Now, when the view is still parked at the live edge, the x-window slides forward with each new sample so live data keeps scrolling through at your chosen zoom width; if you've panned back into history, the view holds where you left it. Skipped for the non-monotonic first-column X source. Tracked via lastLiveX (latest drawn X). - Wheel zoom used a fixed 15%-per-event factor, so every notch (and every small trackpad delta) was a big jump. Zoom is now proportional to the normalized wheel delta (deltaMode-aware) with a single WHEEL_ZOOM_RATE constant (~10% per mouse notch); lower it for finer steps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
…ntrol Wheel-zoom sensitivity was a source constant; expose it in the UI. A gear button on the toolbar opens a small settings popover with a "Wheel zoom sensitivity" slider shown as "% per notch" (2–30%, default 10). The value maps to WHEEL_ZOOM_RATE via rate = -ln(1 - pct/100)/120 and applies live to wheel (X) and Shift+wheel (Y) zoom. Persisted per browser in localStorage (guarded for private-mode throws). The popover closes on outside-click or Escape and is structured to hold future settings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
There was a problem hiding this comment.
🟡 Changes recommended
Concurrency hazards, schema boundary failures, and several WebUSB plotting state bugs can corrupt or stall telemetry sessions.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (7)
components/serial_plotter/include/telemetry_service.hpp:113
Configdocuments at least one channel, but construction accepts an empty vector. That emits a zero-channel schema; the new browser rejects it (labels.lengthis false) and retries until “no schema,” so this valid-looking configuration can never connect. Reject empty schemas or define and support zero-channel behavior explicitly.
explicit Telemetry(const Config &config)
: BaseComponent("Telemetry", config.log_level)
, channels_(config.channels)
, send_(config.send)
, streaming_(config.stream_on_start)
, period_ms_(config.period_ms) {
clamp_channels();
components/serial_plotter/include/telemetry_service.hpp:272
- The per-name 255-byte cap does not keep the whole SCHEMA within
stream_frame::kMaxPayloadSize(4096). For example, sixteen 255-byte names produce 4,115 bytes, sobuild_frame()returns an empty vector andsend_schema()silently delivers no frame. Enforce the aggregate payload limit (and report truncation/rejection) while building the schema.
const uint8_t len = static_cast<uint8_t>(std::min<size_t>(name.size(), 255));
p.push_back(len);
p.insert(p.end(), name.begin(), name.begin() + len);
}
components/serial_plotter/include/telemetry_service.hpp:317
deliver()holds a non-recursive mutex while invoking user code. If the send callback synchronously re-entersTelemetryand reachesemit(),send_schema(), or a request reply, the nesteddeliver()blocks forever, contradicting the class-level guarantee that a re-entrant transport cannot deadlock. Drain a serialized outbound queue without holding its guard during the callback, or explicitly disallow re-entry and update the contract.
void deliver(const send_fn &s, const std::vector<uint8_t> &frame) const {
std::lock_guard<std::mutex> lock(send_mutex_);
s(std::span<const uint8_t>(frame));
components/serial_plotter/example/main/telemetry_example.cpp:170
- The timer snapshots
period_ms()only during construction. A later nonzero SET_STREAM updatesTelemetry::period_ms_, but the producer remains at the original 10 ms cadence, so the example does not honor the requested rate as described. Detect period changes and callgen_timer.set_period(...), or remove the rate-honoring claim.
espp::Timer gen_timer(
{.period = std::chrono::milliseconds(telemetry.period_ms()),
.callback = [&]() -> bool {
components/serial_plotter/web/serial_plotter.html:772
- The rolling window assumes the first and last X values bound the selected samples. In 2D mode or with “First column” selected, X can be non-monotonic (the example’s sine channel is one case), yielding reversed or clipped scales. Compute the minimum and maximum X across
[lo, n)instead.
plot.setScale("x", { min: xs[Math.min(lo, n - 1)], max: xs[n - 1] });
components/serial_plotter/web/serial_plotter.html:786
- This live-edge shift also runs in 2D X–Y mode whenever the hidden
xModeis notfirstcol. There,xsis an arbitrary selected signal and is not chronological, so changes in its last value spuriously pan the user's view. Restrict this scrolling behavior to time-series mode.
if (n && lastLiveX != null && curMin != null && curMax != null &&
$("xMode").value !== "firstcol") {
components/serial_plotter/web/serial_plotter.html:1204
- This unconditionally re-enables the Serial controls after a USB disconnect, even when
navigator.serialis unavailable. That leaves a nonfunctional Connect Serial button in WebUSB-only browsers. Preserve the feature-detection result when resetting the UI.
$("connect").disabled = false; $("baud").disabled = false;
- Files reviewed: 17/17 changed files
- Comments generated: 7
- Review effort level: Balanced
…bustness) Firmware (telemetry_service.hpp): - Close a SCHEMA/SAMPLE ordering race: a concurrent emit() could observe a new channel count and deliver a SAMPLE before (or an old-width SAMPLE after) the matching SCHEMA, making the host decode at the wrong record width. emit(), set_channels(), send_schema() and send_frame() now take send_mutex_ as the outer lock across build+send (uniform order send_mutex_ -> mutex_, no inversion), so a channel-set change and its SCHEMA are atomic w.r.t. samples. Example (telemetry_example.cpp): - The mount/unmount callbacks ran dispatcher.reset() on the TinyUSB task while rx_task ran dispatcher.feed() — a data race on the (non-thread-safe) frame parser. Callbacks now clear queued chunks and flag a reset under rx_mutex; rx_task performs the actual reset, keeping all parser access on one thread. Web app (serial_plotter.html): - Reset lastLiveX on every plot/X-source rebuild so the not-following live-scroll never shifts the scale by a delta computed across mismatched X units. - Gate the Web Serial / WebUSB re-enables in resetConnUI/usbResetUI on feature detection (hasSerial/hasUsb) so a disconnect doesn't undo the initial capability disablement; also clear usbHaveSchema on USB teardown. - decodeSchema now rejects unsupported schema version, nonzero reserved flags, non-f32 channel types, and trailing bytes instead of misdecoding SAMPLE data. - Loading a CSV now stops any live transport first, so binary/serial samples can't corrupt the loaded dataset (stale usbHaveSchema / wrong record width). Builds clean on ESP-IDF v6.1 (esp32s3); cppcheck clean; decodeSchema branch tests and webapp JS parse pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
There was a problem hiding this comment.
🟡 Changes recommended
The 2D draw path currently throws, and telemetry schema validation and callback locking have correctness risks.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
Previously missed (2) — in code that hasn't changed since the last review.
components/serial_plotter/web/serial_plotter.html:777
- Using only the first and last X values does not define the range for XY mode, whose selected X column is explicitly allowed to be non-monotonic. A sine-like X can yield reversed or tiny bounds and hide most samples in the requested time window. Compute the extrema over the time-selected slice instead.
components/serial_plotter/example/README.md:21 - The app never requests a rate—it always sends a zero period—and the example reads
period_ms()only once when constructing its timer, so this rate claim is not implemented. Remove it unless a rate control and dynamic producer-period update are added.
components/serial_plotter/include/telemetry_service.hpp:305
- The channel validation does not enforce the wire constraints. An empty channel list is accepted even though the browser rejects zero-channel schemas, and a legal count with long names can exceed
stream_frame::kMaxPayloadSize(for example, 16 names of 255 bytes produce 4,115 payload bytes).build_frame()then returns an empty vector and the host retries forever. Validate the minimum count and cumulative encoded schema size, then reject or explicitly truncate invalid configurations before sending.
void clamp_channels() {
if (channels_.size() > kMaxChannels) {
logger_.warn("{} channels exceeds the {}-channel SCHEMA limit; truncating", channels_.size(),
kMaxChannels);
channels_.resize(kMaxChannels);
- Files reviewed: 17/17 changed files
- Comments generated: 3
- Review effort level: Balanced
The component provides espp::Telemetry (a binary telemetry emitter, dispatcher module 3) plus its browser plotter web app; espp names components after their C++ library (cf. coredump -> CoreDumpService), so `serial_plotter` was a misnomer once Phase 2 added the firmware library. Rename the whole component to `telemetry` for coherence: - components/serial_plotter/ -> components/telemetry/ - include/telemetry_service.hpp -> include/telemetry.hpp (matches espp::Telemetry) - web/serial_plotter.html -> web/telemetry.html (hosted at apps/telemetry.html) - doc/en/serial_plotter/ -> doc/en/telemetry/ (serial_plotter.rst -> telemetry.rst, serial_plotter_example.md -> telemetry_example.md) Updated all references: component CMake/idf_component.yml, the example's EXTRA_COMPONENT_DIRS / REQUIRES / include / landing_page_url / ModuleInfo.app, the docs toctrees + hosted URLs + include-build-file (inc/telemetry.inc), the Doxyfile INPUT list, and the build.yml / upload_components.yml CI paths. The web app keeps its on-screen product name "espp Serial Plotter" (what the tool does); only the file/identifier/URL move to telemetry. All git-tracked as renames. Builds clean on ESP-IDF v6.1 (esp32s3); web app loads at telemetry.html and plots with no console errors. External uart_serial_plotter references (other components' examples) are unrelated and left untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
There was a problem hiding this comment.
🟡 Changes recommended
The X–Y redraw path crashes, schema sizing can suppress replies, and telemetry locking can deadlock re-entrant callbacks.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (5)
components/telemetry/web/telemetry.html:767
- In X–Y mode
buildData()returns[null, [x, y], ...], sodata[0]isnulland thisxs.lengthaccess throws on the first redraw. The same mode-1 assumption is repeated in the non-following branch and finallastLiveXupdate. Derive the shared X array fromdata[1][0]for mode 2 (and decide whether the time-window logic should be disabled or slice mode-2 data) before using it in all three places.
components/telemetry/include/telemetry.hpp:295 - The schema can exceed
stream_frame::kMaxPayloadSizelong before the documented 255-channel limit (for example, 17 channels with 255-byte names).build_frame()then returns an empty vector, andsend_schema()invokes the transport with an empty frame, so the host can never complete its handshake. Budget channel-name bytes against the framing payload cap while reserving the type/length bytes for every remaining channel.
for (size_t i = 0; i < n; i++) {
const auto &name = channels_[i];
p.push_back(static_cast<uint8_t>(ChannelType::F32));
const uint8_t len = static_cast<uint8_t>(std::min<size_t>(name.size(), 255));
p.push_back(len);
p.insert(p.end(), name.begin(), name.begin() + len);
}
components/telemetry/include/telemetry.hpp:63
- The callback is still invoked while
send_mutex_is held (for example at line 158), so a callback that synchronously re-entersemit(),send_schema(), or request handling deadlocks on that mutex. This contradicts the stated re-entrancy guarantee and the CoreDumpService pattern. Serialize frames with an outbound queue/state flag, but release every internal lock before invoking user code.
/// on a transport RX task; both are safe to call concurrently. Frames are built
/// under one mutex and transmitted under a separate send mutex, so the user
/// `send` callback is (a) never invoked while the build mutex is held — a
/// re-entrant transport cannot deadlock — and (b) never invoked concurrently, so
/// a `send` that is not itself thread-safe still cannot interleave the bytes of
/// two frames.
doc/Doxyfile:434
- The new INPUT entry is not alphabetical because it precedes
serializationand theseeed-*entries. Per the repository Doxyfile convention, movetelemetry.hppto after thetaskheaders and beforethermistor.
$(PROJECT_PATH)/components/telemetry/include/telemetry.hpp \
components/telemetry/include/telemetry.hpp:306
Config::channelspromises at least one channel, but this validation only enforces the upper bound, so both construction andset_channels({})accept an empty schema. The WebUSB client rejects that schema (labels.lengthis false) and retries until “no schema.” Add an explicit non-empty validation/error path and apply it consistently to construction and runtime replacement.
/// Truncate channels_ to the u8 SCHEMA limit (caller holds mutex_, or is the
/// constructor). Warns when channels are dropped.
void clamp_channels() {
if (channels_.size() > kMaxChannels) {
logger_.warn("{} channels exceeds the {}-channel SCHEMA limit; truncating", channels_.size(),
kMaxChannels);
channels_.resize(kMaxChannels);
}
- Files reviewed: 20/21 changed files
- Comments generated: 3
- Review effort level: Balanced
…abels Review fixes: - Web app: 2D X-Y (uPlot mode 2) has a null data[0], so the Follow-window / live-edge scheduleDraw code threw `data[0].length` on every 2D draw. Gate all x-scale manipulation and lastLiveX tracking on time-series mode; X-Y just streams data and lets uPlot autoscale (or keeps the user's zoom). This also fixes live-edge scrolling wrongly firing in X-Y (hidden xMode defaults to "time"). - Header threading doc claimed a re-entrant transport "cannot deadlock", but the refactor now holds send_mutex_ across the user `send` callback. Correct the guarantee: `send` MUST NOT re-enter emit()/send_schema()/set_channels()/ handle()/feed() or it deadlocks on the non-recursive mutex. - clamp_channels() now also warns on an empty channel list and bounds the encoded SCHEMA to stream_frame::kMaxPayloadSize (drops overflowing channels), so build_frame() never rejects an oversized payload and calls `send` with nothing. - Example: a SET_STREAM rate change updated the Telemetry period atomic but never the running producer timer. The main loop now polls period_ms() and calls gen_timer.set_period() off the timer thread, so the requested rate is honored. - CI ordering: move the `telemetry` entries to their alphabetical slot (after `task`) in build.yml's matrix and the Doxyfile INPUT lists. Serial parser (requested): - Strip ANSI/VT100 CSI escapes from every line before parsing, so a colored data row still reads as numbers and a colored header yields clean labels. - Strip the `%` field-marker/delimiter and trim each parsed header token, so axis labels, the legend, and the series filter read cleanly (token count preserved so a same-width data row still confirms the header). Binary WebUSB schema names are untouched. Builds clean on ESP-IDF v6.1 (esp32s3); cppcheck clean; parser + webapp JS unit tests pass; browser: colored/%-marked header parses to clean labels and 2D X-Y renders with no console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
There was a problem hiding this comment.
🟡 Changes recommended
Concurrency and transport-state races can emit after stopping or corrupt/reset active plotting sessions.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
components/telemetry/web/telemetry.html:958
- Switching from WebUSB to Serial leaves the old binary schema and device-time samples intact:
usbResetUI()does not clear them, andconnect()only changes the controls. If the Serial source has the same header (or starts with numeric rows),ingestLine()never callssetSchema(), so browser-relative timestamps are appended to a ring still identified aswebusb, producing a non-monotonic/mislabeled X axis. Reset the binary dataset/source when starting Serial.
components/telemetry/web/telemetry.html:1214 - Clearing
usbbefore this asynchronous teardown finishes allows another click to start a new connection immediately. The old disconnect then unconditionally runsusbResetUI(), clearingusbHaveSchemaand resetting controls/status for the new transport; similarly, the old read-loop catch currently checks only whether anyusbexists and can tear down the replacement. Serialize connect/disconnect with a busy or generation token, and gate callbacks/UI reset onusb === t.
- Files reviewed: 20/21 changed files
- Comments generated: 2
- Review effort level: Balanced
…g doc - emit() checked streaming_ before taking send_mutex_, so a SET_STREAM(false) handler could disable streaming and send its OK (through the same mutex) while an already-started emit() waited, then deliver a SAMPLE after the disable acknowledgment. Re-check streaming_ once send_mutex_ is held: because the handler stores false before sending OK and both serialize on send_mutex_, any SAMPLE we send now provably precedes the OK (or is dropped). - The telemetry.rst threading paragraph still said the send callback "runs with the lock released" — stale after the send_mutex_-outer refactor. Rewrite it to match the header: the callback runs under the send mutex and must not re-enter the emitter. Builds clean on ESP-IDF v6.1 (esp32s3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
There was a problem hiding this comment.
🟡 Changes recommended
Several edge cases can corrupt labels, freeze plots, hide cleared captures, or produce non-atomic USB frames.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (4)
Previously missed (2) — in code that hasn't changed since the last review.
components/telemetry/web/telemetry.html:1289
- Once the ring reaches
cap,tRing.countstops changing, so the 3D cache key also stops changing anddrawScene()reuses stale points forever. WebUSB streaming in 3D therefore freezes at the retention limit. InvalidatesceneCachein the shared sample-ingestion path or include a ring generation/write index in its key.
components/telemetry/web/telemetry.html:1429 - Clearing while Follow is disabled leaves
lastLiveXand the non-following state from the previous capture. For WebUSB's absolute device time, the emptied plot resets to an unrelated scale; the first new sample updateslastLiveX, but later samples never satisfy the live-edge test, so the new capture remains off-screen. Treat Clear as a fresh followed dataset and reset the stale live-edge marker.
components/telemetry/web/telemetry.html:381
- Removing every
%corrupts legitimate unit labels. Existing producers such asbq27220/example/main/bq27220_example.cpp:77emit%time(s), ..., SoC (%), so this turnsSoC (%)intoSoC ()(and%/hrinto/hr). Strip only the leading header marker.
components/telemetry/include/telemetry.hpp:318 - An empty channel set breaks the firmware/browser contract rather than merely producing a useless schema.
set_channels({})sends a zero-column schema, but the web client rejects it vialabels.lengthand retains the previous width, so subsequent zero-channel samples are silently dropped against that stale schema. Reject empty construction/updates, or explicitly clear the browser schema when zero channels are supported.
if (channels_.empty()) {
// The config contract asks for >= 1 channel; a zero-channel SCHEMA is
// ignored by the web client. Warn rather than emit a silently-useless one.
logger_.warn("Telemetry configured with no channels; SCHEMA will be empty");
return;
- Files reviewed: 20/21 changed files
- Comments generated: 1
- Review effort level: Balanced
| # truncated by TinyUSB backpressure. The vendor RX FIFO only carries short | ||
| # requests (GET_SCHEMA / SET_STREAM), so 512 bytes is plenty there. | ||
| CONFIG_TINYUSB_VENDOR_RX_BUFSIZE=512 | ||
| CONFIG_TINYUSB_VENDOR_TX_BUFSIZE=4096 |
clamp_channels() permits a SCHEMA payload up to stream_frame::kMaxPayloadSize (4096), which encodes to ~4109 bytes with the 9-byte header and 4-byte CRC — over the old 4096-byte vendor TX FIFO. UsbDevice::write_vendor() is only all-or-nothing for frames that fit the FIFO; a larger frame takes the non-atomic streaming path and could leave a truncated prefix under backpressure, breaking the example send callback's all-or-nothing assumption. Raise CONFIG_TINYUSB_VENDOR_TX_BUFSIZE to 8192 (above the max frame, with headroom for batched SAMPLE frames). Builds clean on ESP-IDF v6.1 (esp32s3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
Phase 2 of the Serial Plotter (#781): a firmware-side binary telemetry transport the web app plots over WebUSB, alongside the existing text/CSV Web Serial path.
Firmware:
espp::Telemetry(include/telemetry_service.hpp)A small device→host protocol on the
stream_frameframing (dispatcher module 3). It is transport-agnostic — it can share aDispatcherwith other modules over any byte stream (USB vendor/WebUSB, CDC, UART, a socket) — though the web app's binary path reads it over WebUSB. Firmware declares namedfloatchannels (SCHEMA) and pushes SAMPLE frames — a device timestamp (u32 µs) + one float per channel, batchable — withemit(...). Host requests:GET_SCHEMA,SET_STREAM(enable/disable + rate).Follows the
CoreDumpServicepattern: frames built under a mutex, thesendcallback invoked with the lock released; usable with aDispatcher(handle()) or standalone (feed()).emit()and request handling are concurrency-safe.The component now ships firmware, so it registers
include/and depends onbase_component+stream_frameagain (the deps removed when it was webapp-only).Example (
example/)An esp32s3 app streams four synthetic channels (
sine,cosine,noise,ramp) over the USB vendor (WebUSB) interface (the console/logs stay on the separate USB-Serial-JTAG). It wires oneDispatcheron that vendor stream — module 3 + discovery advertisingapp="serial_plotter.html"— and honorsGET_SCHEMA/SET_STREAM. (CDC is enabled insdkconfig.defaultsonly so theusb_devicecomponent compiles; the example creates no CDC interface.)Web app (
web/serial_plotter.html)A new USB button connects over WebUSB (claims the vendor
0xFFinterface's bulk IN/OUT pair), requests the schema, starts the stream, decodesstream_frameframes (vendored codec matchingcomponents/stream_frame), and maps SCHEMA → series and SAMPLE → the same ring buffers / uPlot plot, using the device timestamp as X. Serial and USB are mutually exclusive; pause / clear / filter / save / 2D / 3D modes all work over USB too.Docs
New
doc/en/serial_plotter/component page (Telemetry API viainclude-build-file) + example include, registered in the main toctree and the Doxygen INPUT list;web_apps.rst, the README, and the app<meta>note the WebUSB path.Testing
example/builds clean against ESP-IDF v6.1 (esp32s3) — image generated.crc32matches the C++ golden (0xCBF43926); SCHEMA/SAMPLE frames build + parse + decode correctly (headless).UsbTransportpattern as the other espp consoles (dispatcher_hub.html); an on-device WebUSB smoke test is the one thing not exercisable in CI.🤖 Generated with Claude Code