Skip to content

feat(telemetry): binary telemetry service + WebUSB transport (Phase 2); rename serial_plotter→telemetry - #783

Open
finger563 wants to merge 24 commits into
mainfrom
feat/telemetry-service
Open

feat(telemetry): binary telemetry service + WebUSB transport (Phase 2); rename serial_plotter→telemetry#783
finger563 wants to merge 24 commits into
mainfrom
feat/telemetry-service

Conversation

@finger563

@finger563 finger563 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

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.

Component rename (breaking): now that this PR adds the firmware library, the component is renamed serial_plottertelemetry (espp names components after their C++ library, espp::Telemetry). include/telemetry_service.hppinclude/telemetry.hpp, the web app web/serial_plotter.htmlweb/telemetry.html (hosted at apps/telemetry.html), and doc/en/serial_plotter/doc/en/telemetry/. All git-tracked as renames. The web app keeps its on-screen name "espp Serial Plotter".

Firmware: espp::Telemetry (include/telemetry_service.hpp)

A small device→host protocol on the stream_frame framing (dispatcher module 3). It is transport-agnostic — it can share a Dispatcher with 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 named float channels (SCHEMA) and pushes SAMPLE frames — a device timestamp (u32 µs) + one float per channel, batchable — with emit(...). Host requests: GET_SCHEMA, SET_STREAM (enable/disable + rate).

Follows the CoreDumpService pattern: frames built under a mutex, the send callback invoked with the lock released; usable with a Dispatcher (handle()) or standalone (feed()). emit() and request handling are concurrency-safe.

The component now ships firmware, so it registers include/ and depends on base_component + stream_frame again (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 one Dispatcher on that vendor stream — module 3 + discovery advertising app="serial_plotter.html" — and honors GET_SCHEMA / SET_STREAM. (CDC is enabled in sdkconfig.defaults only so the usb_device component compiles; the example creates no CDC interface.)

Web app (web/serial_plotter.html)

A new USB button connects over WebUSB (claims the vendor 0xFF interface's bulk IN/OUT pair), requests the schema, starts the stream, decodes stream_frame frames (vendored codec matching components/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 via include-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

  • Firmware: the example/ builds clean against ESP-IDF v6.1 (esp32s3) — image generated.
  • Protocol match: the web app's crc32 matches the C++ golden (0xCBF43926); SCHEMA/SAMPLE frames build + parse + decode correctly (headless).
  • Web app: loads clean with the USB button, no console errors; the shared plotting path is confirmed via the CSV/serial path (regression). Live WebUSB enumeration/transfer uses the same UsbTransport pattern 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

finger563 and others added 2 commits September 8, 2026 19:00
…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
Copilot AI lite review requested due to automatic review settings September 9, 2026 00:13
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

✅Static analysis result - no issues found! ✅

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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::Telemetry header-only service using stream_frame (module 3) with SCHEMA/SAMPLE and GET_SCHEMA/SET_STREAM handling.
  • Extend serial_plotter.html with a WebUSB transport that decodes stream_frame telemetry 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.

Comment thread components/serial_plotter/example/main/telemetry_example.cpp
Comment thread components/serial_plotter/include/telemetry_service.hpp
Comment thread components/serial_plotter/include/telemetry_service.hpp Outdated
Comment thread components/serial_plotter/include/telemetry_service.hpp
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 only UsbTransport feeds SfParser. 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 releases mutex_ before sending. The type does not document that send must 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 drops t without closing it. That leaves the WebUSB device/interface open and can make the next connection attempt fail; close t before returning, as the other web transports do.
    components/serial_plotter/web/serial_plotter.html:958
  • SET_STREAM is sent only during connect/disconnect. The shared Pause handler merely toggles paused and drops incoming samples, so a WebUSB device continues transmitting while paused and is never re-enabled through this request on resume. Send SET_STREAM with 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, and components/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 from seeed-studio-round-display to serialization. Add an alphabetically placed components/serial_plotter/example entry targeting esp32s3 (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

Comment thread components/telemetry/include/telemetry.hpp
Comment thread components/telemetry/include/telemetry.hpp
finger563 and others added 16 commits September 8, 2026 23:37
- 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
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

  • Config documents at least one channel, but construction accepts an empty vector. That emits a zero-channel schema; the new browser rejects it (labels.length is 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, so build_frame() returns an empty vector and send_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-enters Telemetry and reaches emit(), send_schema(), or a request reply, the nested deliver() 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 updates Telemetry::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 call gen_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 xMode is not firstcol. There, xs is 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.serial is 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

Comment thread components/telemetry/example/main/telemetry_example.cpp
Comment thread components/serial_plotter/include/telemetry_service.hpp Outdated
Comment thread components/telemetry/web/telemetry.html
Comment thread components/serial_plotter/web/serial_plotter.html Outdated
Comment thread components/serial_plotter/web/serial_plotter.html
Comment thread components/telemetry/web/telemetry.html
Comment thread components/telemetry/example/sdkconfig.defaults
…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread components/telemetry/include/telemetry.hpp Outdated
Comment thread components/telemetry/web/telemetry.html Outdated
Comment thread components/telemetry/web/telemetry.html
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
@finger563 finger563 changed the title feat(serial_plotter): binary telemetry service + WebUSB transport (Phase 2) feat(telemetry): binary telemetry service + WebUSB transport (Phase 2); rename serial_plotter→telemetry Sep 9, 2026
@finger563
finger563 requested a balanced review from Copilot September 9, 2026 21:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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], ...], so data[0] is null and this xs.length access throws on the first redraw. The same mode-1 assumption is repeated in the non-following branch and final lastLiveX update. Derive the shared X array from data[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::kMaxPayloadSize long before the documented 255-channel limit (for example, 17 channels with 255-byte names). build_frame() then returns an empty vector, and send_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-enters emit(), 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 serialization and the seeed-* entries. Per the repository Doxyfile convention, move telemetry.hpp to after the task headers and before thermistor.
  $(PROJECT_PATH)/components/telemetry/include/telemetry.hpp \

components/telemetry/include/telemetry.hpp:306

  • Config::channels promises at least one channel, but this validation only enforces the upper bound, so both construction and set_channels({}) accept an empty schema. The WebUSB client rejects that schema (labels.length is 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

Comment thread components/telemetry/example/main/telemetry_example.cpp
Comment thread .github/workflows/build.yml Outdated
Comment thread doc/Doxyfile Outdated
…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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, and connect() only changes the controls. If the Serial source has the same header (or starts with numeric rows), ingestLine() never calls setSchema(), so browser-relative timestamps are appended to a ring still identified as webusb, producing a non-monotonic/mislabeled X axis. Reset the binary dataset/source when starting Serial.
    components/telemetry/web/telemetry.html:1214
  • Clearing usb before this asynchronous teardown finishes allows another click to start a new connection immediately. The old disconnect then unconditionally runs usbResetUI(), clearing usbHaveSchema and resetting controls/status for the new transport; similarly, the old read-loop catch currently checks only whether any usb exists and can tear down the replacement. Serialize connect/disconnect with a busy or generation token, and gate callbacks/UI reset on usb === t.
  • Files reviewed: 20/21 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread components/telemetry/include/telemetry.hpp
Comment thread doc/en/telemetry/telemetry.rst Outdated
…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.count stops changing, so the 3D cache key also stops changing and drawScene() reuses stale points forever. WebUSB streaming in 3D therefore freezes at the retention limit. Invalidate sceneCache in 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 lastLiveX and 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 updates lastLiveX, 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 as bq27220/example/main/bq27220_example.cpp:77 emit %time(s), ..., SoC (%), so this turns SoC (%) into SoC () (and %/hr into /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 via labels.length and 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants