From 3945ca5e56cba1ffa2d6edae0facce0f0184aa82 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 4 Sep 2026 09:14:46 -0500 Subject: [PATCH 1/5] feat(dispatcher): capability discovery + browser Device Hub 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/). 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) --- components/dispatcher/README.md | 46 ++- .../example/main/dispatcher_example.cpp | 65 +++- components/dispatcher/include/dispatcher.hpp | 166 ++++++++- .../dispatcher/test/dispatcher_host_test.cpp | 66 ++++ components/dispatcher/web/dispatcher_hub.html | 345 ++++++++++++++++++ 5 files changed, 657 insertions(+), 31 deletions(-) create mode 100644 components/dispatcher/web/dispatcher_hub.html diff --git a/components/dispatcher/README.md b/components/dispatcher/README.md index 02b8915a0a..5e6ab439b9 100644 --- a/components/dispatcher/README.md +++ b/components/dispatcher/README.md @@ -20,11 +20,13 @@ request/reply direction (`flags`) travel with the frame and are handed to the module's handler untouched; the Dispatcher does not interpret them. espp built-in protocols use, for example: -| Module | Protocol | -|--------|------------| -| 0 | OTA | -| 4 | crash dump | -| 5 | CAN bridge | +| Module | Protocol | +|-----------|----------------------| +| 0 | OTA | +| 4 | crash dump | +| 5 | CAN bridge | +| 0xF0–0xFF | reserved (meta) | +| 0xFF | capability discovery | A device-side dispatcher registers the modules it serves; frames for an unregistered module are silently ignored. A protocol's replies use the **same** @@ -56,6 +58,40 @@ dispatcher.register_module(4, [&](const espp::stream_frame::Frame &f) { usb.set_vendor_receive_callback([&](std::span data) { dispatcher.feed(data); }); ``` +## Capability discovery + +A module can be registered with a `ModuleInfo` (name / web app / description) so a +connected peer can ask the device **which** modules it runs — over the reserved +discovery module id `0xFF` — and render or link each one. This powers the browser +**Device Hub** app (`components/dispatcher/web/dispatcher_hub.html`, hosted at +`apps/dispatcher_hub.html`): connect over WebUSB / Web Serial, and it lists the +device's modules as tabs, each linking to that module's own web app. + +- `struct ModuleInfo { std::string name, app, description; };` +- `void register_module(uint8_t id, handler_fn handler, ModuleInfo info)` — the + registration overload that carries metadata (a module with an empty `name` is + not advertised). +- `void set_device_info(std::string name, std::string firmware = "")` — advertised + at the head of the reply. +- `std::vector describe() const` — the serialized capability payload, for + apps that own their transmit path. +- `void serve_discovery(reply_fn reply)` — opt in to auto-answering the discovery + query. This is the **only** path by which a Dispatcher sends: it hands the encoded + reply frame to your transmit callback. The router stays otherwise send-free. + +```cpp +dispatcher.set_device_info("espp MCP266 Console", "1.0.0"); +dispatcher.register_module(6, mcp_handler, + {.name = "MCP266", .app = "mcp266_console.html", .description = "Configure & command motors"}); +// answer discovery over 0xFF using the app's transport +dispatcher.serve_discovery([&](std::span frame) { usb.write_vendor(frame); }); +``` + +The discovery reply payload is a compact binary TLV (all lengths one byte; +strings are `[len][bytes]`): `[version][reserved][device_name][device_fw] +[module_count]` then per module `[id][name][app][description]`. The reserved +discovery module (`0xFF`) never lists itself. + ## Host tests ``` diff --git a/components/dispatcher/example/main/dispatcher_example.cpp b/components/dispatcher/example/main/dispatcher_example.cpp index bb41ab54db..9672c5b19e 100644 --- a/components/dispatcher/example/main/dispatcher_example.cpp +++ b/components/dispatcher/example/main/dispatcher_example.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -26,14 +27,23 @@ extern "C" void app_main(void) { static constexpr uint8_t kModuleTelemetry = 4; espp::Dispatcher dispatcher; - dispatcher.register_module(kModuleControl, [&](const sf::Frame &f) { - logger.info("[control] {} type=0x{:02X} ({} payload bytes)", f.is_reply() ? "reply" : "request", - f.type, f.payload.size()); - }); - dispatcher.register_module(kModuleTelemetry, [&](const sf::Frame &f) { - uint32_t value = f.payload.size() == 4 ? sf::get_u32(f.payload) : 0; - logger.info("[telemetry] type=0x{:02X} value={}", f.type, value); - }); + // Register each module WITH discovery metadata (name / web app / description) + // so a connected peer can enumerate them (see the discovery section below). + dispatcher.register_module( + kModuleControl, + [&](const sf::Frame &f) { + logger.info("[control] {} type=0x{:02X} ({} payload bytes)", + f.is_reply() ? "reply" : "request", f.type, f.payload.size()); + }, + {.name = "Control", .app = "control_console.html", .description = "Device control channel"}); + dispatcher.register_module(kModuleTelemetry, + [&](const sf::Frame &f) { + uint32_t value = f.payload.size() == 4 ? sf::get_u32(f.payload) : 0; + logger.info("[telemetry] type=0x{:02X} value={}", f.type, value); + }, + {.name = "Telemetry", + .app = "telemetry_console.html", + .description = "Live telemetry stream"}); // Build a mixed stream, as a peer would send it. std::vector telemetry_payload; @@ -54,6 +64,45 @@ extern "C" void app_main(void) { dispatcher.feed(std::span(stream.data(), half)); dispatcher.feed(std::span(stream.data() + half, stream.size() - half)); logger.info("done ({} bytes dropped while resyncing)", dispatcher.dropped_bytes()); + + // --- capability discovery ------------------------------------------------- + // A connected peer (e.g. the browser hub app) can ask WHICH modules this + // device runs, over the reserved discovery module id 0xFF. Advertise a device + // name + firmware, then opt in to auto-answering the query. serve_discovery() + // is the ONLY path by which the Dispatcher sends: it hands the encoded reply + // frame to the transmit callback we supply. On real hardware that callback is + // usb.write_vendor / socket send / etc.; here we just capture it in-process + // and decode it to show what a peer receives. + dispatcher.set_device_info("espp Dispatcher Example", "1.0.0"); + std::vector discovery_reply; + dispatcher.serve_discovery( + [&](std::span frame) { discovery_reply.assign(frame.begin(), frame.end()); }); + + dispatcher.feed(sf::build_frame(/*reply=*/false, espp::Dispatcher::kDiscoveryModule, + static_cast(espp::Dispatcher::Discovery::ListModules))); + + const auto reply_frames = sf::StreamParser{}.feed(discovery_reply); + if (!reply_frames.empty()) { + const auto &p = reply_frames[0].payload; + size_t i = 2; // skip [version][reserved] + auto rd_str = [&]() { + const uint8_t n = p[i++]; + std::string s(reinterpret_cast(&p[i]), n); + i += n; + return s; + }; + const std::string dev = rd_str(); + const std::string fw = rd_str(); + const uint8_t count = p[i++]; + logger.info("discovery: '{}' (fw {}) advertises {} module(s):", dev, fw, count); + for (uint8_t m = 0; m < count; ++m) { + const uint8_t id = p[i++]; + const std::string name = rd_str(); + const std::string app = rd_str(); + const std::string desc = rd_str(); + logger.info(" module {}: {} [app={}] — {}", id, name, app, desc); + } + } //! [dispatcher example] while (true) { diff --git a/components/dispatcher/include/dispatcher.hpp b/components/dispatcher/include/dispatcher.hpp index c7bd612e58..43a95dde05 100644 --- a/components/dispatcher/include/dispatcher.hpp +++ b/components/dispatcher/include/dispatcher.hpp @@ -18,6 +18,14 @@ // interpret them. A device-side dispatcher typically registers the modules it // serves and ignores everything else (including its own replies echoed back). // +// Capability discovery: a module can be registered with a ModuleInfo (name, web +// app, description). A connected peer (e.g. a browser hub) can then ask the +// device WHICH modules it runs — over the reserved discovery module id 0xFF — +// and render/link each one. describe() serializes the registered modules for +// callers that own their transmit path; serve_discovery() is a one-liner that +// auto-answers the discovery request. The Dispatcher stays a pure router: it +// only ever sends when you opt in by giving serve_discovery() a reply function. +// // Header-only and dependency-free (only espp::stream_frame + the standard // library), so it builds and unit-tests on a host. @@ -25,6 +33,7 @@ #include #include #include +#include #include #include @@ -39,6 +48,35 @@ class Dispatcher { /// @param frame The decoded frame (module, type, flags/reply, payload). using handler_fn = std::function; + /// @brief Transmit callback for serve_discovery(): sends one already-encoded + /// stream_frame back to the peer over the application's transport. + using reply_fn = std::function frame)>; + + /// @brief Optional human/browser-facing metadata advertised for a module. + /// @details All fields are optional; a module with an empty name is not + /// advertised by describe(). Kept short — each string is serialized + /// with a one-byte length, so anything past 255 bytes is truncated. + struct ModuleInfo { + std::string name; ///< Human-readable module name, e.g. "MCP266 Console". + std::string app; ///< Hosted web-app filename, e.g. "mcp266_console.html" (optional). + std::string description; ///< One-line description (optional). + }; + + /// @brief Reserved module id for capability discovery. A peer sends a + /// Discovery::ListModules request here; the device answers with the + /// serialized module list (see describe() / serve_discovery()). Module + /// ids 0xF0..0xFF are reserved for dispatcher / meta use. + static constexpr uint8_t kDiscoveryModule = 0xFF; + + /// @brief `type` values within the discovery module (kDiscoveryModule). + enum class Discovery : uint8_t { + ListModules = 0x00, ///< request: list the device's modules; reply payload = describe(). + }; + + /// @brief Version byte at the start of a describe() payload, so the wire format + /// can evolve without a framing change. + static constexpr uint8_t kDiscoveryVersion = 1; + /// @brief The module id a frame will route to (its `module` byte). static constexpr uint8_t module_of(const stream_frame::Frame &frame) { return frame.module; } @@ -47,14 +85,26 @@ class Dispatcher { /// @param handler Callback for frames with this module id. A null handler /// unregisters the module. void register_module(uint8_t module_id, handler_fn handler) { + register_module(module_id, std::move(handler), ModuleInfo{}); + } + + /// @brief Register (or replace) a module's handler AND its discovery metadata. + /// @param module_id Module id (0..255) to route to @p handler. + /// @param handler Callback for frames with this module id (a null handler + /// unregisters the module; @p info is then ignored). + /// @param info Metadata advertised to a discovery peer (see describe()). + /// @note Registration fully replaces any previous entry, so re-registering a + /// module with the 2-argument overload clears its metadata. + void register_module(uint8_t module_id, handler_fn handler, ModuleInfo info) { // Mutating handlers_ while a handler runs could reallocate it or destroy the // running handler (use-after-free). If called from inside a dispatch (a // handler registering/unregistering), defer the change until dispatch // unwinds; otherwise apply it immediately. + Entry entry{module_id, std::move(handler), std::move(info)}; if (dispatch_depth_ > 0) - pending_.emplace_back(module_id, std::move(handler)); + pending_.push_back(std::move(entry)); else - apply_register(module_id, std::move(handler)); + apply_register(std::move(entry)); } /// @brief Remove the handler for a module id (frames for it become ignored). @@ -64,7 +114,68 @@ class Dispatcher { /// registrations; changes made during a dispatch apply after it ends). bool has_module(uint8_t module_id) const { return std::any_of(handlers_.begin(), handlers_.end(), - [module_id](const auto &e) { return e.first == module_id; }); + [module_id](const Entry &e) { return e.id == module_id; }); + } + + /// @brief Set the device-level info advertised at the head of describe() + /// (so a peer can show "Connected to "). + void set_device_info(std::string name, std::string firmware = "") { + device_name_ = std::move(name); + device_firmware_ = std::move(firmware); + } + + /// @brief Serialize the device info + every registered module that carries a + /// (non-empty) name into the binary discovery payload. + /// + /// Layout (all lengths are one byte; strings are [len][bytes], truncated at + /// 255): [version u8][reserved u8][device_name str][device_fw str] + /// [module_count u8] then per module [id u8][name str][app str][desc str]. + /// The reserved discovery module (0xFF) is never listed. + /// @return The payload bytes (to be sent as the ListModules reply). + std::vector describe() const { + std::vector out; + out.push_back(kDiscoveryVersion); + out.push_back(0); // reserved flags + append_string(out, device_name_); + append_string(out, device_firmware_); + uint8_t count = 0; + for (const Entry &e : handlers_) + if (e.id != kDiscoveryModule && !e.info.name.empty()) + ++count; + out.push_back(count); + for (const Entry &e : handlers_) { + if (e.id == kDiscoveryModule || e.info.name.empty()) + continue; + out.push_back(e.id); + append_string(out, e.info.name); + append_string(out, e.info.app); + append_string(out, e.info.description); + } + return out; + } + + /// @brief Opt in to auto-answering capability discovery. + /// @details Registers a handler on kDiscoveryModule that, on a + /// Discovery::ListModules request, encodes describe() into a reply + /// frame (echoing the request's correlation id, if any) and hands it + /// to @p reply for transmission. This is the only path by which a + /// Dispatcher ever sends — the app supplies the transport. + /// @param reply Transmit callback (sends the encoded reply frame). + void serve_discovery(reply_fn reply) { + register_module( + kDiscoveryModule, [this, reply = std::move(reply)](const stream_frame::Frame &f) { + // Only answer requests (ignore our own replies echoed back) of the right type. + if (f.is_reply() || f.type != static_cast(Discovery::ListModules)) + return; + const auto payload = describe(); + const auto frame = stream_frame::build_frame(true, kDiscoveryModule, + static_cast(Discovery::ListModules), + payload, f.correlation); + // build_frame yields empty only if the payload exceeds the frame cap + // (far more modules than any real device); drop rather than send garbage. + if (reply && !frame.empty()) + reply(frame); + }); } /// @brief Feed raw received bytes: parse and route each complete frame to its @@ -95,9 +206,9 @@ class Dispatcher { ++dispatch_depth_; DepthGuard guard{dispatch_depth_}; const auto it = std::find_if(handlers_.begin(), handlers_.end(), - [&frame](const auto &e) { return e.first == frame.module; }); + [&frame](const Entry &e) { return e.id == frame.module; }); if (it != handlers_.end()) - it->second(frame); // may throw; guard still restores the depth + it->handler(frame); // may throw; guard still restores the depth // Flush deferred registrations only on the normal path of the OUTERMOST // dispatch (depth is still 1 here; the guard makes it 0 on scope exit). If a // handler threw, pending ops stay queued and are applied on the next @@ -105,10 +216,10 @@ class Dispatcher { if (dispatch_depth_ == 1 && !pending_.empty()) { // swap (not move) so pending_ is left in a defined empty state; applying an // op never re-enters dispatch, so no new pending ops accrue here. - std::vector> ops; + std::vector ops; ops.swap(pending_); for (auto &op : ops) - apply_register(op.first, std::move(op.second)); + apply_register(std::move(op)); } } @@ -123,28 +234,47 @@ class Dispatcher { size_t dropped_bytes() const { return parser_.dropped_bytes(); } private: - /// Add / replace / (null handler) remove a module's handler in handlers_. + /// A registered module: routing id, its handler, and its discovery metadata. + struct Entry { + uint8_t id; + handler_fn handler; + ModuleInfo info; + }; + + /// Append a length-prefixed string ([len u8][bytes]), truncated at 255 bytes. + static void append_string(std::vector &out, const std::string &s) { + const uint8_t len = static_cast(std::min(s.size(), 255)); + out.push_back(len); + out.insert(out.end(), s.begin(), s.begin() + len); + } + + /// Add / replace / (null handler) remove a module's entry in handlers_. /// Must not run while a handler is on the stack (see register_module()). - void apply_register(uint8_t module_id, handler_fn handler) { + void apply_register(Entry entry) { const auto it = std::find_if(handlers_.begin(), handlers_.end(), - [module_id](const auto &e) { return e.first == module_id; }); + [&entry](const Entry &e) { return e.id == entry.id; }); if (it != handlers_.end()) { - if (handler) - it->second = std::move(handler); - else + if (entry.handler) { + it->handler = std::move(entry.handler); + it->info = std::move(entry.info); + } else { handlers_.erase(it); - } else if (handler) { - handlers_.emplace_back(module_id, std::move(handler)); + } + } else if (entry.handler) { + handlers_.push_back(std::move(entry)); } } stream_frame::StreamParser parser_; - // Small set of (module id -> handler); linear scan is fine for the handful of + // Small set of registered modules; linear scan is fine for the handful of // protocols a stream carries, and it costs memory only per registered module // (vs a 256-entry table). - std::vector> handlers_; + std::vector handlers_; // Registrations deferred while dispatching (applied when dispatch unwinds). - std::vector> pending_; + std::vector pending_; + // Device-level info advertised at the head of describe(). + std::string device_name_; + std::string device_firmware_; // >0 while a handler is executing (supports nested dispatch). int dispatch_depth_{0}; }; diff --git a/components/dispatcher/test/dispatcher_host_test.cpp b/components/dispatcher/test/dispatcher_host_test.cpp index e2f29d0c7f..5f145f373e 100644 --- a/components/dispatcher/test/dispatcher_host_test.cpp +++ b/components/dispatcher/test/dispatcher_host_test.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include "dispatcher.hpp" @@ -132,11 +133,76 @@ static void test_handler_exception_recovers() { CHECK(hits == 1 && d.has_module(2)); } +// Minimal reader for the describe() TLV payload ([len u8][bytes] strings). +struct TlvReader { + std::span b; + size_t p = 0; + uint8_t u8() { return b[p++]; } + std::string str() { + const uint8_t n = u8(); + std::string s(reinterpret_cast(&b[p]), n); + p += n; + return s; + } +}; + +static void test_discovery() { + std::printf("test_discovery\n"); + using D = espp::Dispatcher; + espp::Dispatcher d; + d.set_device_info("espp Hub", "1.2.3"); + d.register_module(0, [](const sf::Frame &) {}, + {.name = "OTA", .app = "ota_console.html", .description = "Firmware update"}); + d.register_module(6, [](const sf::Frame &) {}, + {.name = "MCP266", .app = "mcp266_console.html", .description = "Motors"}); + d.register_module(9, [](const sf::Frame &) {}); // no metadata -> not advertised + + const auto payload = d.describe(); + TlvReader r{payload}; + CHECK(r.u8() == D::kDiscoveryVersion); + CHECK(r.u8() == 0); // reserved + CHECK(r.str() == "espp Hub"); + CHECK(r.str() == "1.2.3"); + const uint8_t count = r.u8(); + CHECK(count == 2); // module 9 (no name) excluded; discovery module absent + const uint8_t id0 = r.u8(); + const std::string n0 = r.str(), a0 = r.str(), de0 = r.str(); + CHECK(id0 == 0 && n0 == "OTA" && a0 == "ota_console.html" && de0 == "Firmware update"); + const uint8_t id1 = r.u8(); + const std::string n1 = r.str(), a1 = r.str(), de1 = r.str(); + CHECK(id1 == 6 && n1 == "MCP266" && a1 == "mcp266_console.html" && de1 == "Motors"); + CHECK(r.p == payload.size()); + + // serve_discovery: a ListModules request produces one reply frame carrying the + // describe() payload and echoing the request correlation id. + std::vector sent; + d.serve_discovery( + [&](std::span frame) { sent.assign(frame.begin(), frame.end()); }); + CHECK(d.has_module(D::kDiscoveryModule)); + CHECK(d.describe() == payload); // registering discovery must not list it + d.feed(sf::build_frame(false, D::kDiscoveryModule, + static_cast(D::Discovery::ListModules), {}, 0x1234)); + CHECK(!sent.empty()); + sf::StreamParser sp; + const auto frames = sp.feed(sent); + CHECK(frames.size() == 1); + CHECK(frames[0].module == D::kDiscoveryModule && frames[0].is_reply()); + CHECK(frames[0].correlation.has_value() && *frames[0].correlation == 0x1234); + CHECK(frames[0].payload == payload); + + // An echoed reply frame must NOT trigger another reply (avoid loops). + sent.clear(); + d.feed( + sf::build_frame(true, D::kDiscoveryModule, static_cast(D::Discovery::ListModules))); + CHECK(sent.empty()); +} + int main() { test_routing_and_coexistence(); test_register_replace_unregister(); test_reset(); test_reentrant_unregister(); + test_discovery(); // cppcheck-suppress throwInEntryPoint // the handler's throw is caught inside // the test (cppcheck can't trace it through the std::function / feed() call) test_handler_exception_recovers(); diff --git a/components/dispatcher/web/dispatcher_hub.html b/components/dispatcher/web/dispatcher_hub.html new file mode 100644 index 0000000000..a29cf6c6ed --- /dev/null +++ b/components/dispatcher/web/dispatcher_hub.html @@ -0,0 +1,345 @@ + + + + + + espp Device Hub (WebUSB / Web Serial) + + + + +
+

espp Device Hub (WebUSB / Web Serial)

+
+ + + + + idle +
+ +
+
This browser supports neither WebUSB nor Web Serial. Use a Chromium-based + browser (Chrome / Edge) over HTTPS.
+ + + +
+
+
No modules yet — connect a device.
+
+ +
+
+ + + + From 74bea47b00640546ed33c035d826418f430cd180 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 4 Sep 2026 11:28:32 -0500 Subject: [PATCH 2/5] fix(dispatcher): address #767 review + static analysis - 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. ( 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) --- components/dispatcher/include/dispatcher.hpp | 16 +++++++---- components/dispatcher/web/dispatcher_hub.html | 27 ++++++++++++++----- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/components/dispatcher/include/dispatcher.hpp b/components/dispatcher/include/dispatcher.hpp index 43a95dde05..00c43e9543 100644 --- a/components/dispatcher/include/dispatcher.hpp +++ b/components/dispatcher/include/dispatcher.hpp @@ -138,14 +138,20 @@ class Dispatcher { out.push_back(0); // reserved flags append_string(out, device_name_); append_string(out, device_firmware_); - uint8_t count = 0; - for (const Entry &e : handlers_) - if (e.id != kDiscoveryModule && !e.info.name.empty()) - ++count; + // A module is advertised if it carries a name and is not the discovery module. + const auto advertised = [](const Entry &e) { + return e.id != kDiscoveryModule && !e.info.name.empty(); + }; + // module_count is a single wire byte, so cap the count (and the number of + // records emitted below) at 255 rather than overflowing it. + const auto total = std::count_if(handlers_.begin(), handlers_.end(), advertised); + const uint8_t count = static_cast(std::min(total, 255)); out.push_back(count); + uint8_t emitted = 0; for (const Entry &e : handlers_) { - if (e.id == kDiscoveryModule || e.info.name.empty()) + if (emitted == count || !advertised(e)) continue; + ++emitted; out.push_back(e.id); append_string(out, e.info.name); append_string(out, e.info.app); diff --git a/components/dispatcher/web/dispatcher_hub.html b/components/dispatcher/web/dispatcher_hub.html index a29cf6c6ed..cba2b60d44 100644 --- a/components/dispatcher/web/dispatcher_hub.html +++ b/components/dispatcher/web/dispatcher_hub.html @@ -119,12 +119,14 @@

espp Device Hub (WebUSB / Web Serial)

function crc32(bytes) { let c=0xFFFFFFFF; for (let i=0;i>>8); return (~c)>>>0; } if (crc32(new TextEncoder().encode("123456789")) !== 0xCBF43926) throw new Error("CRC-32 self-test failed"); + // The hub only ever SENDS requests, so the reply bit stays clear. (A reply is + // marked by stream_frame flags bit0, which is unrelated to the type value.) function buildFrame(module, type, payload) { payload = payload || new Uint8Array(0); const frame = new Uint8Array(HEADER_SIZE + payload.length + CRC_SIZE); const view = new DataView(frame.buffer); view.setUint16(0, 0x4F54, true); - frame[2] = FLAGS_REQUEST | ((type & 0x80) ? FLAGS_REPLY_BIT : 0); + frame[2] = FLAGS_REQUEST; frame[3] = module; frame[4] = type; view.setUint32(5, payload.length, true); view.setUint32(HEADER_SIZE + payload.length, crc32(frame.subarray(0, HEADER_SIZE + payload.length)), true); @@ -165,15 +167,24 @@

espp Device Hub (WebUSB / Web Serial)

// ---- decode the describe() TLV payload (see espp::Dispatcher::describe) ---- function parseDiscovery(payload) { let i = 0; - const u8 = () => payload[i++]; - const str = () => { const n = u8(); const s = new TextDecoder().decode(payload.subarray(i, i + n)); i += n; return s; }; + const need = (n) => { if (i + n > payload.length) throw new Error("truncated discovery payload"); }; + const u8 = () => { need(1); return payload[i++]; }; + const str = () => { const n = u8(); need(n); const s = new TextDecoder().decode(payload.subarray(i, i + n)); i += n; return s; }; const version = u8(); u8(); // version, reserved const device = str(), fw = str(); const count = u8(); const modules = []; for (let m = 0; m < count; m++) modules.push({ id: u8(), name: str(), app: str(), desc: str() }); + if (i !== payload.length) throw new Error("trailing bytes in discovery payload"); return { version, device, fw, modules }; } + // Treat a module's `app` as an internal, same-directory page only: a safe + // filename ending in .html, no scheme/colon, no slash, no "..". This stops a + // malicious/compromised device from making the hub open javascript:/data:/ + // external URLs via the advertised app field. + function safeAppName(app) { + return typeof app === "string" && /^[A-Za-z0-9._-]+\.html$/.test(app) && !app.includes(".."); + } // ===================== Transports (WebUSB + Web Serial) ===================== let transport = null; @@ -260,7 +271,7 @@

espp Device Hub (WebUSB / Web Serial)

const b = document.createElement("button"); b.className = "tab" + (idx === selected ? " active" : ""); const name = document.createElement("div"); name.textContent = m.name || ("module " + m.id); - const id = document.createElement("div"); id.className = "id"; id.textContent = "module " + m.id + (m.app ? "" : " · no app"); + const id = document.createElement("div"); id.className = "id"; id.textContent = "module " + m.id + (safeAppName(m.app) ? "" : " · no app"); b.appendChild(name); b.appendChild(id); b.addEventListener("click", () => { selected = idx; renderTabs(); renderDetail(); }); els.tabs.appendChild(b); @@ -276,15 +287,17 @@

espp Device Hub (WebUSB / Web Serial)

const sub = document.createElement("div"); sub.className = "sub"; sub.textContent = "module id " + m.id; const p = document.createElement("p"); p.className = "desc"; p.textContent = m.desc || ""; d.appendChild(h); d.appendChild(sub); if (m.desc) d.appendChild(p); - if (m.app) { - const a = document.createElement("a"); a.className = "open"; a.href = m.app; a.target = "_blank"; a.rel = "noopener"; + if (safeAppName(m.app)) { + const a = document.createElement("a"); a.className = "open"; a.href = m.app; a.target = "_blank"; a.rel = "noopener noreferrer"; a.textContent = "Open " + m.app + " ↗"; d.appendChild(a); const note = document.createElement("div"); note.className = "muted"; note.style.marginTop = "12px"; note.textContent = "Opens the module's own web app in a new tab (it connects to the device itself)."; d.appendChild(note); } else { - const n = document.createElement("div"); n.className = "noapp"; n.textContent = "This module advertises no web app."; + const n = document.createElement("div"); n.className = "noapp"; + n.textContent = m.app ? ("advertises an app (" + m.app + ") that is not a valid internal page — not linked") + : "This module advertises no web app."; d.appendChild(n); } } From 124d5e9c56614fc5f260b66f576376ef0d10f70a Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 4 Sep 2026 11:36:21 -0500 Subject: [PATCH 3/5] feat(examples): advertise modules for dispatcher capability discovery 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) --- .../main/can_bridge_example.cpp | 14 ++++++- .../example/main/coredump_example.cpp | 38 +++++++++++++------ .../main/mcp266_webapp_example.cpp | 13 ++++++- components/ota/example/main/ota_example.cpp | 9 ++++- 4 files changed, 57 insertions(+), 17 deletions(-) diff --git a/components/canopen/can_bridge_example/main/can_bridge_example.cpp b/components/canopen/can_bridge_example/main/can_bridge_example.cpp index d15dc42640..edefdad4e5 100644 --- a/components/canopen/can_bridge_example/main/can_bridge_example.cpp +++ b/components/canopen/can_bridge_example/main/can_bridge_example.cpp @@ -250,8 +250,18 @@ extern "C" void app_main(void) { // gets its OWN Dispatcher (one parser) — a frame split across reads on one // transport must never be stitched onto bytes from the other. espp::Dispatcher vendor_dispatcher, cdc_dispatcher; - vendor_dispatcher.register_module(can_bridge::kModuleId, handle_can_frame); - cdc_dispatcher.register_module(can_bridge::kModuleId, handle_can_frame); + // Advertise the CAN-bridge module for capability discovery so the browser + // Device Hub can list and link it. + const espp::Dispatcher::ModuleInfo can_info{.name = "CAN Bridge", + .app = "can_bridge_console.html", + .description = + "Raw CAN 2.0 bridge (WebUSB / Web Serial)"}; + vendor_dispatcher.register_module(can_bridge::kModuleId, handle_can_frame, can_info); + cdc_dispatcher.register_module(can_bridge::kModuleId, handle_can_frame, can_info); + vendor_dispatcher.set_device_info(usb_cfg.product); + cdc_dispatcher.set_device_info(usb_cfg.product); + vendor_dispatcher.serve_discovery([&](std::span f) { usb.write_vendor(f); }); + cdc_dispatcher.serve_discovery([&](std::span f) { usb.write_cdc(f); }); // --- USB RX plumbing: queue in the TinyUSB task, dispatch from a worker ----- // transmit() can block up to its timeout, so it must not run in the TinyUSB diff --git a/components/coredump/example/main/coredump_example.cpp b/components/coredump/example/main/coredump_example.cpp index 24fc3db883..1783c83734 100644 --- a/components/coredump/example/main/coredump_example.cpp +++ b/components/coredump/example/main/coredump_example.cpp @@ -254,18 +254,34 @@ extern "C" void app_main(void) { }; // Register the protocols on each stream's dispatcher. The service answers // requests, so ignore reply-flagged frames (handle_frame() takes only - // type+payload, so the direction check lives here). - vendor_dispatcher.register_module(espp::CoreDumpService::kModule, - [&](const espp::stream_frame::Frame &f) { - if (!f.is_reply()) - vendor_service.handle_frame(f.type, f.payload); - }); + // type+payload, so the direction check lives here). The core-dump module is + // advertised (name / web app / description) so the browser Device Hub can + // discover and link it. + const espp::Dispatcher::ModuleInfo coredump_info{.name = "Core Dump", + .app = "coredump_console.html", + .description = + "Inspect the last crash core dump"}; + vendor_dispatcher.register_module( + espp::CoreDumpService::kModule, + [&](const espp::stream_frame::Frame &f) { + if (!f.is_reply()) + vendor_service.handle_frame(f.type, f.payload); + }, + coredump_info); vendor_dispatcher.register_module(kCrashModule, handle_cmd_frame); - cdc_dispatcher.register_module(espp::CoreDumpService::kModule, - [&](const espp::stream_frame::Frame &f) { - if (!f.is_reply()) - cdc_service.handle_frame(f.type, f.payload); - }); + cdc_dispatcher.register_module( + espp::CoreDumpService::kModule, + [&](const espp::stream_frame::Frame &f) { + if (!f.is_reply()) + cdc_service.handle_frame(f.type, f.payload); + }, + coredump_info); + // Advertise the device, and answer discovery on whichever transport asked + // (each stream has its own dispatcher, so each replies over its own writer). + vendor_dispatcher.set_device_info(usb_cfg.product); + cdc_dispatcher.set_device_info(usb_cfg.product); + vendor_dispatcher.serve_discovery([&](std::span f) { usb.write_vendor(f); }); + cdc_dispatcher.serve_discovery([&](std::span f) { usb.write_cdc(f); }); espp::Task rx_task({.callback = [&](std::mutex &, std::condition_variable &) -> bool { std::deque>> chunks; diff --git a/components/mcp266/webapp_example/main/mcp266_webapp_example.cpp b/components/mcp266/webapp_example/main/mcp266_webapp_example.cpp index 7ed53b001e..21567f86b0 100644 --- a/components/mcp266/webapp_example/main/mcp266_webapp_example.cpp +++ b/components/mcp266/webapp_example/main/mcp266_webapp_example.cpp @@ -351,8 +351,17 @@ extern "C" void app_main(void) { }; espp::Dispatcher vendor_dispatcher, cdc_dispatcher; - vendor_dispatcher.register_module(proto::kModuleId, dispatch_frame); - cdc_dispatcher.register_module(proto::kModuleId, dispatch_frame); + // Advertise the MCP266 module for capability discovery so the browser Device + // Hub can list and link it. + const espp::Dispatcher::ModuleInfo mcp_info{.name = "MCP266", + .app = "mcp266_console.html", + .description = "Configure & command MCP266 motors"}; + vendor_dispatcher.register_module(proto::kModuleId, dispatch_frame, mcp_info); + cdc_dispatcher.register_module(proto::kModuleId, dispatch_frame, mcp_info); + vendor_dispatcher.set_device_info(usb_cfg.product); + cdc_dispatcher.set_device_info(usb_cfg.product); + vendor_dispatcher.serve_discovery([&](std::span f) { usb.write_vendor(f); }); + cdc_dispatcher.serve_discovery([&](std::span f) { usb.write_cdc(f); }); // --- USB RX plumbing: queue in the TinyUSB callback, dispatch from a worker - std::mutex rx_mutex; diff --git a/components/ota/example/main/ota_example.cpp b/components/ota/example/main/ota_example.cpp index 3e9e8a1f4b..fea3bae584 100644 --- a/components/ota/example/main/ota_example.cpp +++ b/components/ota/example/main/ota_example.cpp @@ -355,8 +355,13 @@ extern "C" void app_main(void) { }; // OTA is module id 0. The Dispatcher routes each frame for that module here. - dispatcher.register_module(proto::kModule, - [&](const proto::Frame &frame) { handle_usb_frame(frame); }); + // Advertise it (name / web app / description) so the browser Device Hub can + // discover and link it, and answer discovery queries over the vendor stream. + dispatcher.register_module( + proto::kModule, [&](const proto::Frame &frame) { handle_usb_frame(frame); }, + {.name = "OTA", .app = "ota_console.html", .description = "Firmware update over USB"}); + dispatcher.set_device_info(usb_cfg.product); + dispatcher.serve_discovery([&](std::span frame) { usb.write_vendor(frame); }); espp::Task usb_task( {.callback = [&](std::mutex &, std::condition_variable &) -> bool { From 7b5979587c476976bbf65d55e3ffb7da80e9c96d Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 4 Sep 2026 12:02:46 -0500 Subject: [PATCH 4/5] fix(dispatcher): hub WebUSB device filter (any-device + all espp PIDs) 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) --- components/dispatcher/web/dispatcher_hub.html | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/components/dispatcher/web/dispatcher_hub.html b/components/dispatcher/web/dispatcher_hub.html index cba2b60d44..e222e4f2e0 100644 --- a/components/dispatcher/web/dispatcher_hub.html +++ b/components/dispatcher/web/dispatcher_hub.html @@ -93,7 +93,9 @@

espp Device Hub (WebUSB / Web Serial)