Skip to content

feat(dispatcher): capability discovery + browser Device Hub - #767

Merged
finger563 merged 5 commits into
mainfrom
feat/dispatcher-discovery
Sep 4, 2026
Merged

feat(dispatcher): capability discovery + browser Device Hub#767
finger563 merged 5 commits into
mainfrom
feat/dispatcher-discovery

Conversation

@finger563

Copy link
Copy Markdown
Contributor

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} + a register_module(id, handler, info)
    overload that carries it (a module with an empty name isn't advertised).
  • set_device_info(name, firmware) — advertised at the head of the reply.
  • describe() — serializes device info + advertised modules into a compact
    binary TLV ([version][reserved][device_name][device_fw][count], then per
    module [id][name][app][desc]; all lengths one byte). 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 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.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
and 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

  • TLV over JSON (your call): consistent with the other espp framed protocols;
    the hub decodes it with a small readU8/readString loop. Keeps the Dispatcher
    dependency-free.
  • Scope: the hub currently links to each module's app (opens in a new tab,
    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 accept
    a proxied transport. Left out here to keep this focused on the discovery mechanism.
  • Existing module ids (OTA=0, haptics=2, coredump=4, CAN bridge=5, MCP266=6)
    are unchanged; wiring them to advertise metadata via serve_discovery is an
    opt-in follow-up per component.

Verified

  • Dispatcher host test — including a new discovery test (TLV encode/decode +
    serve_discovery round-trip + echoed-reply-doesn't-loop) — passes under
    -Werror -Wall -Wextra.
  • Dispatcher example (extended to demo discovery) builds on IDF v6.0.1.
  • Hub JS passes node --check.

🤖 Generated with Claude Code

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>
Copilot AI lite review requested due to automatic review settings September 4, 2026 14:15
@github-actions

github-actions Bot commented Sep 4, 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.

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::Dispatcher with ModuleInfo, device info, describe() TLV serialization, and serve_discovery() auto-reply on module 0xFF.
  • 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.

Comment thread components/dispatcher/web/dispatcher_hub.html Outdated
Comment thread components/dispatcher/README.md
Comment thread components/dispatcher/include/dispatcher.hpp
Comment thread components/dispatcher/include/dispatcher.hpp Outdated
Comment thread components/dispatcher/web/dispatcher_hub.html
Comment thread components/dispatcher/web/dispatcher_hub.html Outdated
- 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>
@finger563

Copy link
Copy Markdown
Contributor Author

Addressed the review + the static-analysis failure in 74bea47b0:

  • static analysis (dispatcher.hpp:144, useStlAlgorithm) — the advertised-module count now uses std::count_if.
  • module_count overflow — the count and the number of emitted records are capped at 255 (the 1-byte wire limit). It is structurally ≤255 already (module ids are unique and 0xFF is excluded), but the cap makes it explicit.
  • hub buildFrame reply bit — it derived the reply bit from type & 0x80 (a leftover from a 0xE_-reply-type console). The hub only ever sends requests, so the reply bit now stays clear (a reply is a stream_frame flag, not a type value).
  • hub app URL injectionapp is now validated as a safe internal .html filename (no scheme/colon/slash/..) before it is linked, with rel="noopener noreferrer"; anything else renders as “not a valid internal page — not linked”. A malicious device can no longer make the hub open javascript:/data:/external URLs.
  • hub parseDiscovery bounds — every read is bounds-checked and trailing bytes are rejected, so a truncated/oversized reply is reported as malformed instead of rendering partial state.

Two I believe are false positives (no change): <algorithm> is already included (dispatcher.hpp:32), and the README module table uses single leading pipes (| … | … |) — it renders as a normal 2-column table.

Verified: host test passes under -Werror -Wall -Wextra; hub JS passes node --check. A follow-up commit will make the real modules (OTA, core dump, CAN bridge, MCP266) advertise themselves so the hub lists a live device.

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>
@finger563

Copy link
Copy Markdown
Contributor Author

Follow-up in 124d5e9c5 — the real USB modules now advertise themselves, so the hub lists a live device:

  • ota (module 0, vendor): "OTA" → ota_console.html
  • coredump (module 4, vendor + CDC): "Core Dump" → coredump_console.html
  • can_bridge (module 5, vendor + CDC): "CAN Bridge" → can_bridge_console.html
  • mcp266 webapp (module 6, vendor + CDC): "MCP266" → mcp266_console.html

Each registers its module with a ModuleInfo, sets set_device_info() from the USB product string, and opts in via serve_discovery() (dual-transport examples answer on the same transport the query arrived on — each stream has its own dispatcher). No protocol change; discovery rides the reserved 0xFF module.

bldc_haptics (module 2) still drives a raw StreamParser rather than a Dispatcher, so advertising it is a small parser→Dispatcher migration I left as a separate change (it would also need dispatcher added to its manager-off component closure).

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>

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

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 after device.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

Comment thread components/canopen/can_bridge_example/main/can_bridge_example.cpp Outdated
Comment thread components/mcp266/webapp_example/main/mcp266_webapp_example.cpp Outdated
Comment thread components/dispatcher/include/dispatcher.hpp Outdated
…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>
@finger563

Copy link
Copy Markdown
Contributor Author

Addressed the latest round in b66b51077:

  • describe() byte-size bound — right, the count was capped but the payload size was not. describe() now reserves the count byte, appends module records only while they still fit stream_frame::kMaxPayloadSize, and backpatches module_count to the number actually emitted. So an oversized set truncates deterministically and the payload is always self-consistent and encodable (no more silent empty build_frame()). Added a host test that registers 40 max-length (255-byte × 3) modules and asserts the payload fits, the count matches the records that fit, the walk consumes exactly the payload, and the frame builds. (Also gave Entry::id a default initializer.)

  • mcp266 + can_bridge discovery writes bypassing tx_mutex — good catch, they could race the async senders (status task / TWAI RX task). 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() = send_to(active_transport, …); discovery uses the per-dispatcher transport). ota/coredump dont need it — their sends are all on a single task, no concurrency.

Verified: host test (incl. the new payload-bound case) passes under -Werror -Wall -Wextra; cppcheck --enable=all clean on the restructured loop (no useStlAlgorithm); mcp266 + can_bridge build clean manager-off on IDF v6.0.1.

(The remaining open Copilot threads from the earlier round — <algorithm> include and the README table pipes — are the two I flagged as false positives; <algorithm> is included at line 32 and the table uses single leading pipes.)

@finger563
finger563 merged commit 65c13b9 into main Sep 4, 2026
153 of 154 checks passed
@finger563
finger563 deleted the feat/dispatcher-discovery branch September 4, 2026 17:48
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