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..f5fe20c28a 100644 --- a/components/canopen/can_bridge_example/main/can_bridge_example.cpp +++ b/components/canopen/can_bridge_example/main/can_bridge_example.cpp @@ -72,7 +72,11 @@ extern "C" void app_main(void) { enum class Transport { Vendor, Cdc }; std::atomic active_transport{Transport::Vendor}; std::mutex tx_mutex; - auto send = [&](std::span bytes) { + // All device->host writes (request replies, streamed CAN_RX frames, AND + // discovery replies) go through this one tx_mutex-guarded helper so the + // concurrent senders (the RX worker + the TWAI receive task) never write the + // TinyUSB FIFO at once. + auto send_to = [&](Transport dest, std::span bytes) { std::lock_guard lock(tx_mutex); // write_vendor/write_cdc are all-or-nothing (no truncated frame): they // bounded-wait (~250 ms) for the host to drain the TX FIFO, then return @@ -81,12 +85,12 @@ extern "C" void app_main(void) { // not the TinyUSB callback, so the drain-wait path applies. For a // best-effort CAN monitor a drop is acceptable; surface it rate-limited // rather than silently discarding a reply/CAN_RX frame. - const bool ok = (active_transport.load() == Transport::Cdc) ? usb.write_cdc(bytes) - : usb.write_vendor(bytes); + const bool ok = (dest == Transport::Cdc) ? usb.write_cdc(bytes) : usb.write_vendor(bytes); if (!ok) logger.warn_rate_limited("dropped a {}-byte frame (USB TX backpressure or disconnect)", bytes.size()); }; + auto send = [&](std::span bytes) { send_to(active_transport.load(), bytes); }; auto send_frame = [&](uint8_t type, std::span payload = {}) { // Reply/event types (kCanRx/kOk/kError/kStatus) carry the high bit; map it // to the frame reply flag. All CAN-bridge frames are module kModuleId. @@ -250,8 +254,19 @@ 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) { send_to(Transport::Vendor, f); }); + cdc_dispatcher.serve_discovery([&](std::span f) { send_to(Transport::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/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..edac1edcd3 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,82 @@ 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. At most 255 modules + /// are emitted, and trailing modules are dropped if the payload would exceed + /// stream_frame::kMaxPayloadSize -- module_count always reflects the number + /// actually emitted, so the payload is self-consistent and fits one frame. + /// @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_); + // module_count precedes the records; reserve its byte now and backpatch the + // ACTUAL number emitted. Records are appended only while they still fit the + // frame payload limit (and the 1-byte count), so a very large set of modules + // / metadata truncates deterministically here rather than overflowing the + // count byte or producing an over-cap payload that build_frame would drop. + const size_t count_pos = out.size(); + out.push_back(0); + uint8_t count = 0; + for (const Entry &e : handlers_) { + if (count == 255) + break; + if (e.id == kDiscoveryModule || e.info.name.empty()) + continue; + std::vector record; + record.push_back(e.id); + append_string(record, e.info.name); + append_string(record, e.info.app); + append_string(record, e.info.description); + if (out.size() + record.size() > stream_frame::kMaxPayloadSize) + break; // adding this record would exceed the frame payload limit + out.insert(out.end(), record.begin(), record.end()); + ++count; + } + out[count_pos] = count; + 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 +220,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 +230,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 +248,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..364f3d5456 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,110 @@ 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()); +} + +static void test_discovery_payload_bound() { + std::printf("test_discovery_payload_bound\n"); + using D = espp::Dispatcher; + espp::Dispatcher d; + const std::string big(255, 'x'); // max-length metadata (each record ~769 bytes) + for (int i = 0; i < 40; ++i) + d.register_module(static_cast(i), [](const sf::Frame &) {}, + {.name = big, .app = big, .description = big}); + const auto payload = d.describe(); + // 40 * ~769 bytes >> kMaxPayloadSize, so describe() must truncate to fit. + CHECK(payload.size() <= sf::kMaxPayloadSize); + // The count must match the records that actually fit, and the walk must consume + // exactly the payload (self-consistent: no short/trailing bytes). + TlvReader r{payload}; + r.u8(); + r.u8(); // version, reserved + r.str(); + r.str(); // device name, fw + const uint8_t count = r.u8(); + for (uint8_t m = 0; m < count; ++m) { + r.u8(); + r.str(); + r.str(); + r.str(); + } + CHECK(r.p == payload.size()); + CHECK(count > 0 && count < 40); // some fit, some were dropped + // The resulting payload must be encodable as a frame (i.e. within the cap). + CHECK(!sf::build_frame(true, D::kDiscoveryModule, static_cast(D::Discovery::ListModules), + payload) + .empty()); +} + int main() { test_routing_and_coexistence(); test_register_replace_unregister(); test_reset(); test_reentrant_unregister(); + test_discovery(); + test_discovery_payload_bound(); // 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..e222e4f2e0 --- /dev/null +++ b/components/dispatcher/web/dispatcher_hub.html @@ -0,0 +1,363 @@ + + + + + + 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.
+
+ +
+
+ + + + diff --git a/components/mcp266/webapp_example/main/mcp266_webapp_example.cpp b/components/mcp266/webapp_example/main/mcp266_webapp_example.cpp index 7ed53b001e..9725a03332 100644 --- a/components/mcp266/webapp_example/main/mcp266_webapp_example.cpp +++ b/components/mcp266/webapp_example/main/mcp266_webapp_example.cpp @@ -155,14 +155,17 @@ extern "C" void app_main(void) { enum class Transport { Vendor, Cdc }; std::atomic active_transport{Transport::Vendor}; std::mutex tx_mutex; - auto send = [&](std::span bytes) { + // All device->host writes (request replies, the status stream, AND discovery + // replies) go through this one tx_mutex-guarded helper so concurrent senders + // (the RX worker + the status task) never write the TinyUSB FIFO at once. + auto send_to = [&](Transport dest, std::span bytes) { std::lock_guard lock(tx_mutex); - const bool ok = (active_transport.load() == Transport::Cdc) ? usb.write_cdc(bytes) - : usb.write_vendor(bytes); + const bool ok = (dest == Transport::Cdc) ? usb.write_cdc(bytes) : usb.write_vendor(bytes); if (!ok) logger.warn_rate_limited("dropped a {}-byte frame (USB TX backpressure or disconnect)", bytes.size()); }; + auto send = [&](std::span bytes) { send_to(active_transport.load(), bytes); }; auto send_frame = [&](uint8_t type, std::span payload = {}) { const bool reply = (type & 0x80) != 0; // 0xE_ reply types set the frame reply flag send(sf::build_frame(reply, proto::kModuleId, type, payload)); @@ -351,8 +354,18 @@ 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) { send_to(Transport::Vendor, f); }); + cdc_dispatcher.serve_discovery([&](std::span f) { send_to(Transport::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 {