feat(dispatcher): capability discovery + browser Device Hub - #767
Conversation
Let a connected peer ask a device WHICH modules it runs, and add a hosted hub
app that renders them.
Dispatcher (still a pure router; only sends when you opt in):
- ModuleInfo{name, app, description} + a register_module overload that carries it.
- set_device_info(name, firmware) advertised at the head of the reply.
- describe(): serialize device info + advertised modules into a compact binary
TLV ([version][reserved][device_name][device_fw][count] then per module
[id][name][app][desc]); the reserved discovery module never lists itself.
- serve_discovery(reply_fn): opt-in auto-answer of a ListModules request on the
reserved discovery module id 0xFF, handing the encoded reply frame to the
app-supplied transmit callback. This is the only path by which the Dispatcher
sends -- routing stays send-free otherwise.
Web: components/dispatcher/web/dispatcher_hub.html -- connect over WebUSB /
Web Serial, query 0xFF, and list the device's modules as tabs, each linking to
its own hosted web app (apps/<module.app>). Auto-hosted + indexed by the apps
pipeline (title + meta description).
Verified: dispatcher host test (incl. new discovery TLV + serve_discovery round
trip) passes under -Werror -Wall -Wextra; example builds on IDF v6.0.1; hub JS
passes node --check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
✅Static analysis result - no issues found! ✅ |
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Pull request overview
Adds dispatcher capability discovery (binary TLV) and a browser “Device Hub” page to query and render a device’s advertised modules.
Changes:
- Extend
espp::DispatcherwithModuleInfo, device info,describe()TLV serialization, andserve_discovery()auto-reply on module0xFF. - Add a WebUSB/Web Serial hub page (
dispatcher_hub.html) that queries discovery and links modules to their hosted apps. - Add host test coverage for discovery (TLV encode/decode + serve_discovery round-trip + loop prevention) and update the example/README.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| components/dispatcher/web/dispatcher_hub.html | New hub UI and transport/parser logic to query module discovery and render module tabs/links. |
| components/dispatcher/test/dispatcher_host_test.cpp | Adds a discovery-focused host test validating TLV payload and serve_discovery() behavior. |
| components/dispatcher/include/dispatcher.hpp | Implements module metadata advertising + discovery serialization and auto-reply handler plumbing. |
| components/dispatcher/example/main/dispatcher_example.cpp | Demonstrates registering metadata and serving discovery with an in-process transmit callback. |
| components/dispatcher/README.md | Documents the discovery feature, reserved IDs, and how the hub/app workflow works. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- describe(): use std::count_if for the advertised-module count (fixes the useStlAlgorithm static-analysis finding) and cap the count + emitted records at the 1-byte wire limit (255) -- defensive; module ids are unique and 0xFF is excluded, so it is structurally <=255 already. - hub: buildFrame() no longer derives the reply bit from (type & 0x80) -- it only ever sends requests, so the reply bit stays clear (a reply is a stream_frame flag, not a type value). - hub: validate a module's advertised app as a safe internal .html filename (no scheme/colon/slash/..) before linking, and add rel=noreferrer, so a malicious device cannot make the hub open javascript:/data:/external URLs. - hub: parseDiscovery() bounds-checks every read and rejects trailing bytes, so a truncated/oversized reply is reported as malformed instead of rendering partial state. (<algorithm> is already included; the README module table uses single pipes -- those two Copilot comments are false positives.) Verified: host test passes under -Werror -Wall -Wextra; hub JS node --check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the review + the static-analysis failure in
Two I believe are false positives (no change): Verified: host test passes under |
Wire the USB examples that already route through an espp::Dispatcher to advertise themselves, so the browser Device Hub lists a live device: each registers its module WITH a ModuleInfo (name / web app / description), sets the device info from the USB product string, and opts in to serve_discovery() over its transport. - ota (module 0, vendor): "OTA" -> ota_console.html. - coredump (module 4, vendor + cdc): "Core Dump" -> coredump_console.html; each transport's dispatcher answers discovery over its own writer. - can_bridge (module 5, vendor + cdc): "CAN Bridge" -> can_bridge_console.html. - mcp266 webapp (module 6, vendor + cdc): "MCP266" -> mcp266_console.html. No protocol change: discovery rides the reserved module 0xFF; each module's own protocol is untouched. Follow-up: bldc_haptics still drives a raw StreamParser (module 2) rather than a Dispatcher, so advertising it is a small parser-> Dispatcher migration left for a separate change. Verified: all four examples build clean, manager-off, on IDF v6.0.1 (esp32s3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Follow-up in
Each registers its module with a
Verified: all four examples build clean, manager-off, on IDF v6.0.1 (esp32s3). |
Two WebUSB picker bugs in the Device Hub, found while testing:
- "any device" used { acceptAllDevices: true }, which is a Web Bluetooth option;
WebUSB requireDevice REQUIRES `filters`, so it threw "Required member filters
is undefined". Use { filters: [] } (empty list = show every device).
- the default filter pinned VID+PID 0x1209/0x0d32 (the ota PID), so other espp
examples that share the VID but use a different PID (e.g. coredump 0x0d36) were
not listed. Filter by VID only so the hub shows every espp device.
Hub JS passes node --check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Discovery payload limits, transport cleanup, and concurrent USB writes can cause missing replies, leaked connections, or transmission races.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
Previously missed (3) — in code that hasn't changed since the last review.
components/dispatcher/web/dispatcher_hub.html:175
- The payload version is read but never validated, so a future incompatible discovery layout will be decoded as v1 and may render incorrect module links. Reject unsupported versions before consuming the v1 fields.
components/dispatcher/web/dispatcher_hub.html:322 - Connection setup stores the candidate globally before awaiting
open(), leaves both connect buttons enabled, and the failure path drops the reference without closing it. A double click can overwrite one in-flight transport, while failures afterdevice.open()(for example, no matching interface) leak the opened device. Use a local candidate plus an in-progress guard, close that candidate on failure, and have its read-loop callback verify it is still the active transport.
components/dispatcher/web/dispatcher_hub.html:346 - Unexpected EOF/read errors and WebUSB disconnect events call this function, but it only resets the UI: the query timeout remains active and the serial writer/port or USB interface stays referenced and open. Since reconnect is re-enabled, the next connection overwrites
transport, making the old resource impossible to close; clear the timer, detach the current transport, and close it here.
- Files reviewed: 9/9 changed files
- Comments generated: 3
- Review effort level: Balanced
…y writes Latest #767 review round: - describe(): the module COUNT was capped at 255 but the encoded BYTE size was not, so max-length metadata across enough modules could exceed stream_frame::kMaxPayloadSize -- build_frame() then returns empty and serve_discovery() silently sends nothing. describe() now reserves the count byte, appends records only while they still fit the payload cap, and backpatches module_count to the number actually emitted, so the payload is always self-consistent and encodable (deterministic truncation). Added a host test that fills 40 max-length modules and checks the payload fits + count matches + the frame builds. (Also: give Entry::id a default initializer.) - mcp266 + can_bridge examples: the serve_discovery reply callbacks wrote usb.write_vendor/write_cdc directly, bypassing tx_mutex, so they could race the async senders (the mcp266 status task / the TWAI receive task) on the TinyUSB FIFO. Both examples now route EVERY device->host write -- request replies, streamed frames, and discovery replies -- through one tx_mutex-guarded, destination-aware send_to(transport, bytes) helper (send() is send_to with the active transport; discovery uses the per-dispatcher transport). coredump/ota do not need this (single-task senders, no concurrency). Verified: dispatcher host test (incl. new payload-bound test) passes under -Werror -Wall -Wextra; cppcheck clean on the changed loop (no useStlAlgorithm); mcp266 + can_bridge build clean manager-off on IDF v6.0.1; hub JS node --check. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the latest round in
Verified: host test (incl. the new payload-bound case) passes under (The remaining open Copilot threads from the earlier round — |
Dispatcher capability discovery + browser Device Hub
Lets a connected peer ask an espp device which modules it runs — module ids,
names, and the web app associated with each — and adds a hosted hub page that
renders them. Groundwork for a device-aware launcher: connect once, see only the
modules this device actually serves, and jump to each one's tool.
Firmware —
espp::Dispatcher(still a pure router)ModuleInfo{name, app, description}+ aregister_module(id, handler, info)overload that carries it (a module with an empty
nameisn't advertised).set_device_info(name, firmware)— advertised at the head of the reply.describe()— serializes device info + advertised modules into a compactbinary TLV (
[version][reserved][device_name][device_fw][count], then permodule
[id][name][app][desc]; all lengths one byte). The reserved discoverymodule never lists itself.
serve_discovery(reply_fn)— opt-in auto-answer of aListModulesrequest onthe reserved discovery module id
0xFF, handing the encoded reply frame tothe app-supplied transmit callback. This is the only path by which a
Dispatcher sends — routing stays send-free otherwise, so nothing changes for
existing users who don't opt in.
Web —
components/dispatcher/web/dispatcher_hub.htmlConnect over WebUSB / Web Serial, query
0xFF, and list the device's modules astabs — each linking to its own hosted web app (
apps/<module.app>). Auto-hostedand indexed by the existing apps pipeline (picks up the
<title>+<meta description>), so it shows up on the apps index with no wiring.Design notes
the hub decodes it with a small
readU8/readStringloop. Keeps the Dispatcherdependency-free.
where it connects to the device itself). True in-page embedded tabs would need
the hub to hold the one USB/serial connection and proxy frames to iframed module
apps over
postMessage— a nice follow-up that requires each module app to accepta proxied transport. Left out here to keep this focused on the discovery mechanism.
are unchanged; wiring them to advertise metadata via
serve_discoveryis anopt-in follow-up per component.
Verified
serve_discoveryround-trip + echoed-reply-doesn't-loop) — passes under-Werror -Wall -Wextra.node --check.🤖 Generated with Claude Code