From 7038a485a01f4ef165de0b6ccc7e7d02424b278d Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 17 Sep 2026 12:34:16 -0500 Subject: [PATCH 1/4] feat(dispatcher): configurable module ids for every service and example module The dispatcher module byte is a routing key, not an identity, but most espp services hard-coded theirs (static kModule). Add `Config::module` (default = the published id) to OtaService (0), Telemetry (3) and CoreDumpService (4), matching Mcp266Service (6), and route EVERY use of the id through module_id(): frame filtering in handle()/feed() and the build_frame() calls for replies/notifications (feed()/build() in Telemetry and CoreDumpService used kModule directly, so a moved instance would have replied on the wrong module). The ota_stream make_* builders take an optional trailing module argument for the same reason. Example modules keep their id in one named constant at the top of the example (kHapticsModule, kCanBridgeModule; kCrashModule already was), with the protocol headers' kModule/kModuleId as the default. Defaults are unchanged; the hosted web consoles and the espp_ota CLI assume them, which the docs (custom_modules.rst, dispatcher pages, component READMEs/rst) now state explicitly. Adds a host test that the OTA builders stamp a given module id. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/bldc_haptics/example/README.md | 6 ++ .../example/main/bldc_haptics_example.cpp | 32 ++++-- .../example/main/haptics_usb_protocol.hpp | 16 ++- .../canopen/can_bridge_example/README.md | 7 +- .../main/can_bridge_example.cpp | 14 ++- .../main/can_bridge_protocol.hpp | 4 +- components/coredump/README.md | 16 +-- .../example/main/coredump_example.cpp | 6 +- .../coredump/include/coredump_service.hpp | 42 +++++--- components/dispatcher/README.md | 18 ++-- components/mcp266/README.md | 6 +- components/ota/README.md | 18 ++-- .../include/detail/ota_stream_protocol.hpp | 57 +++++++---- components/ota/include/ota_service.hpp | 34 ++++--- .../ota/test/ota_protocol_host_test.cpp | 32 ++++++ components/telemetry/README.md | 5 +- components/telemetry/include/telemetry.hpp | 40 +++++--- doc/en/coredump/coredump.rst | 4 +- doc/en/dispatcher/custom_modules.rst | 98 ++++++++++++++----- doc/en/dispatcher/dispatcher.rst | 10 +- doc/en/ota/ota.rst | 4 +- doc/en/telemetry/telemetry.rst | 4 +- 22 files changed, 333 insertions(+), 140 deletions(-) diff --git a/components/bldc_haptics/example/README.md b/components/bldc_haptics/example/README.md index 0747502dfd..414bba3065 100644 --- a/components/bldc_haptics/example/README.md +++ b/components/bldc_haptics/example/README.md @@ -105,6 +105,12 @@ work against this device: The **device hub** (`dispatcher_hub.html`) discovers all three modules on this one device and links to each console. +The module ids (haptics 2, OTA 0, core dump 4) are only routing keys and are +each configurable in one place — `kHapticsModule` at the top of +`bldc_haptics_example.cpp`, and `.module` in the `OtaService` / +`CoreDumpService` `Config` — but the hosted consoles expect these defaults, so +change them only together with your own host tooling. + ## Example Behaviors The detent presets can be switched at runtime from the web console (or by diff --git a/components/bldc_haptics/example/main/bldc_haptics_example.cpp b/components/bldc_haptics/example/main/bldc_haptics_example.cpp index 2b446827e9..73c30e53b9 100644 --- a/components/bldc_haptics/example/main/bldc_haptics_example.cpp +++ b/components/bldc_haptics/example/main/bldc_haptics_example.cpp @@ -57,6 +57,14 @@ static constexpr size_t example_motor_index = 1; static constexpr size_t example_motor_index = 0; #endif +// Dispatcher module id the haptics protocol is registered under (and stamped on +// every haptics reply / telemetry frame). It is only a routing key: change this +// one constant to move the protocol, but note the hosted haptics console looks +// for the default (haptics_proto::kModule = 2) until it is told otherwise. The +// OTA (0) and core-dump (4) services keep their own defaults; pass `.module` in +// their Config to move those. +static constexpr uint8_t kHapticsModule = haptics_proto::kModule; + // The USB telemetry / web dial needs the continuous knob value, i.e. the detent // index PLUS the fractional progress towards the neighboring detents. The // detent center and active config are protected in espp::BldcHaptics, so expose @@ -382,19 +390,23 @@ extern "C" void app_main(void) { // Haptics protocol (module 2) frame handling -- runs on the dispatcher // worker task, never on the TinyUSB task. // -------------------------------------------------------------------------- - // Build replies via proto::build so they carry the haptics module (2) + reply - // flag — NOT the OTA reply builders (those are OTA module 0). + // Build replies via proto::build so they carry the haptics module + // (kHapticsModule) + reply flag — NOT the OTA reply builders (those are OTA + // module 0). + auto build = [](proto::Msg type, std::span payload = {}) { + return proto::build(type, payload, kHapticsModule); + }; auto reply_ok = [&](uint32_t value) { std::vector payload; proto::put_u32(payload, value); - usb_send(proto::build(proto::Msg::Ok, payload)); + usb_send(build(proto::Msg::Ok, payload)); }; auto reply_error = [&](const std::error_code &err, const std::string &context) { std::vector payload; proto::put_u32(payload, static_cast(err.value())); const std::string message = context + ": " + err.message(); payload.insert(payload.end(), message.begin(), message.end()); - usb_send(proto::build(proto::Msg::Error, payload)); + usb_send(build(proto::Msg::Error, payload)); }; auto reply_errc = [&](std::errc errc, const std::string &context) { reply_error(std::make_error_code(errc), context); @@ -408,7 +420,7 @@ extern "C" void app_main(void) { proto::put_str(payload, app.version); proto::put_str(payload, app.date + " " + app.time); proto::put_str(payload, app.idf_version); - usb_send(proto::build(proto::Msg::Info, payload)); + usb_send(build(proto::Msg::Info, payload)); }; auto send_status = [&]() { @@ -420,7 +432,7 @@ extern "C" void app_main(void) { proto::put_f32(payload, motor->get_shaft_angle()); proto::put_f32(payload, motor->get_shaft_velocity()); proto::put_u16(payload, stream_period_ms); - usb_send(proto::build(proto::Msg::Status, payload)); + usb_send(build(proto::Msg::Status, payload)); }; auto send_modes = [&]() { @@ -440,7 +452,7 @@ extern "C" void app_main(void) { proto::put_i32(payload, detent); proto::put_str(payload, kPresets[i].name); } - usb_send(proto::build(proto::Msg::Modes, payload)); + usb_send(build(proto::Msg::Modes, payload)); }; auto handle_frame = [&](const proto::stream::Frame &frame) { @@ -558,6 +570,8 @@ extern "C" void app_main(void) { // module 0 -> OTA (espp::OtaService -> ota_console) // module 2 -> BLDC haptics (this example's protocol -> haptics_console) // module 4 -> core dump (espp::CoreDumpService -> coredump_console) + // (the defaults the hosted consoles expect; each id is configurable -- + // kHapticsModule above, and `.module` in the services' Config) // All replies -- and the discovery reply -- go through the same // tx_mutex-guarded usb_send as the telemetry frames. @@ -601,7 +615,7 @@ extern "C" void app_main(void) { usb_link.register_module(ota_service); // module 0 + its discovery metadata usb_link.register_module(coredump_service); // module 4 + its discovery metadata // The handler gates on !is_reply() so a reply-typed echo cannot re-enter it. - usb_link.register_module(proto::kModule, + usb_link.register_module(kHapticsModule, [&](const proto::stream::Frame &frame) { if (!frame.is_reply()) handle_frame(frame); @@ -659,7 +673,7 @@ extern "C" void app_main(void) { proto::put_f32(payload, continuous_value()); proto::put_f32(payload, motor->get_shaft_angle()); proto::put_f32(payload, motor->get_shaft_velocity()); - if (usb_send(proto::build(proto::Msg::Telemetry, payload))) { + if (usb_send(build(proto::Msg::Telemetry, payload))) { telemetry_stall_start = {}; // queued OK -> the host is draining } else if (telemetry_stall_start == std::chrono::steady_clock::time_point{}) { telemetry_stall_start = start; // first drop -> start the stall clock diff --git a/components/bldc_haptics/example/main/haptics_usb_protocol.hpp b/components/bldc_haptics/example/main/haptics_usb_protocol.hpp index fa186ea013..75a4f8cd50 100644 --- a/components/bldc_haptics/example/main/haptics_usb_protocol.hpp +++ b/components/bldc_haptics/example/main/haptics_usb_protocol.hpp @@ -35,7 +35,11 @@ namespace haptics_proto { // protocol's module + reply flag. namespace stream = espp::stream_frame; -/// Dispatcher module id owned by the haptics protocol (the frame `module` byte). +/// Default dispatcher module id of the haptics protocol (the frame `module` +/// byte): the id the hosted haptics console expects. The example registers the +/// protocol under `kHapticsModule` (bldc_haptics_example.cpp), which defaults +/// to this; build() takes the id to stamp so replies follow whatever the app +/// registered. static constexpr uint8_t kModule = 2; /// Protocol version reported in the INFO reply. @@ -103,11 +107,13 @@ inline std::optional get_f32_at(std::span bytes, size_t of return std::bit_cast(get_u32(bytes.subspan(offset))); } -/// Build a frame for any haptics-protocol message type (module 2; the reply flag -/// is set for reply/telemetry types, whose ids have the high bit set). -inline std::vector build(Msg type, std::span payload = {}) { +/// Build a frame for any haptics-protocol message type on `module` (kModule, 2, +/// by default; the reply flag is set for reply/telemetry types, whose ids have +/// the high bit set). +inline std::vector build(Msg type, std::span payload = {}, + uint8_t module = kModule) { const bool reply = (static_cast(type) & 0x80) != 0; - return espp::stream_frame::build_frame(reply, kModule, static_cast(type), payload); + return espp::stream_frame::build_frame(reply, module, static_cast(type), payload); } /// Status flag bits (Status + Telemetry `flags` byte). diff --git a/components/canopen/can_bridge_example/README.md b/components/canopen/can_bridge_example/README.md index 8fe5f8d3c4..13d496c94c 100644 --- a/components/canopen/can_bridge_example/README.md +++ b/components/canopen/can_bridge_example/README.md @@ -9,8 +9,11 @@ connects over the native USB and can node is a passive sniffer that never ACKs or transmits. It bridges the ESP32-S3 TWAI (CAN 2.0) controller to the host over USB using the -espp `stream_frame` framing and an `espp::Dispatcher` (this example owns -**module id 5**). The same framed protocol is exposed on both the USB **vendor** +espp `stream_frame` framing and an `espp::Dispatcher` (this example uses +**module id 5** by default — `kCanBridgeModule` at the top of +`can_bridge_example.cpp` is the one place to change it, though the hosted +console looks for 5 until told otherwise). The same framed protocol is exposed +on both the USB **vendor** interface (WebUSB) and a **CDC** interface (Web Serial), so the web app can use either transport. The system console/logs stay on the separate built-in USB-Serial-JTAG. 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 065ac75a74..c065032cb7 100644 --- a/components/canopen/can_bridge_example/main/can_bridge_example.cpp +++ b/components/canopen/can_bridge_example/main/can_bridge_example.cpp @@ -39,6 +39,12 @@ namespace sf = espp::stream_frame; static constexpr int kCanTxGpio = 17; static constexpr int kCanRxGpio = 16; +// Dispatcher module id the bridge protocol is registered under (and stamped on +// every reply / CAN_RX frame). It is only a routing key: change this one +// constant to move the protocol, but the hosted CAN console looks for the +// default (can_bridge::kModuleId = 5) until it is told otherwise. +static constexpr uint8_t kCanBridgeModule = can_bridge::kModuleId; + extern "C" void app_main(void) { espp::Logger logger({.tag = "CAN Bridge", .level = espp::Logger::Verbosity::INFO}); logger.info("Starting USB<->CAN bridge example"); @@ -94,9 +100,9 @@ extern "C" void app_main(void) { send_fn stream_send; auto build_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. + // to the frame reply flag. All CAN-bridge frames are module kCanBridgeModule. const bool reply = (type & 0x80) != 0; - return sf::build_frame(reply, can_bridge::kModuleId, type, payload); + return sf::build_frame(reply, kCanBridgeModule, type, payload); }; auto send_frame = [&](const send_fn &send, uint8_t type, std::span payload = {}) { send(build_frame(type, payload)); @@ -178,7 +184,7 @@ extern "C" void app_main(void) { } }; - // --- CAN bridge protocol handler (dispatcher module id 5) ------------------ + // --- CAN bridge protocol handler (dispatcher module kCanBridgeModule) ------ // `send` transmits on the transport the frame arrived on (each worker // registers the handler with its own sender), so replies never cross streams. auto handle_can_frame = [&](const espp::stream_frame::Frame &frame, const send_fn &send) { @@ -291,7 +297,7 @@ extern "C" void app_main(void) { "Raw CAN 2.0 bridge (WebUSB / Web Serial)"}; for (auto *link : {&vendor_link, &cdc_link}) { link->register_module( - can_bridge::kModuleId, + kCanBridgeModule, [&, send = link->sender()](const sf::Frame &f) { handle_can_frame(f, send); }, can_info); link->serve_discovery(usb_cfg.product); } diff --git a/components/canopen/can_bridge_example/main/can_bridge_protocol.hpp b/components/canopen/can_bridge_example/main/can_bridge_protocol.hpp index 95b9e59283..8f8821f52a 100644 --- a/components/canopen/can_bridge_example/main/can_bridge_protocol.hpp +++ b/components/canopen/can_bridge_example/main/can_bridge_protocol.hpp @@ -25,7 +25,9 @@ namespace can_bridge { -/// Dispatcher module id owned by the CAN bridge protocol. +/// Default dispatcher module id of the CAN bridge protocol: the id the hosted +/// CAN console expects. The example registers under `kCanBridgeModule` +/// (can_bridge_example.cpp), which defaults to this. static constexpr uint8_t kModuleId = 5; /// Host -> device (requests, high nibble 5). diff --git a/components/coredump/README.md b/components/coredump/README.md index 9beac5c9ea..019f902bc6 100644 --- a/components/coredump/README.md +++ b/components/coredump/README.md @@ -14,10 +14,13 @@ any transport. Failures are reported via `std::error_code` (no exceptions). `stream_frame` codec (magic `"OT"` + flags + module + type + len + payload + CRC-32) so the core dump can be inspected over **any byte stream** — the USB vendor (WebUSB) interface, a USB CDC (Web Serial) port, a socket, a UART. It -owns dispatcher **module 4** (requests `0x40..0x4F`, replies `0xC0..0xCF` with -the frame reply flag set), so the service coexists with other framed protocols -(OTA on module 0, an app protocol, ...) — and with free-form console text — on -the same stream, routed by `espp::Dispatcher`. +uses dispatcher **module 4** by default (requests `0x40..0x4F`, replies +`0xC0..0xCF` with the frame reply flag set), so the service coexists with other +framed protocols (OTA on module 0, an app protocol, ...) — and with free-form +console text — on the same stream, routed by `espp::Dispatcher`. The module id +is only a routing key: `CoreDumpService::Config::module` moves an instance to +another id (used for both the requests it accepts and the replies it sends), +but the hosted console looks for 4 until told otherwise. The matching browser tool is [`web/coredump_console.html`](web/coredump_console.html), hosted at @@ -47,8 +50,9 @@ the same stream), view the crash summary, download the core dump as (partition-backed chunked reads), `erase(ec)` - **Stream service**: `espp::CoreDumpService` — transport-agnostic; construct with a `send` function, then register it on a dispatcher - (`dispatcher.register_module(service)` — it carries its module id 4, - `handle(frame)` and discovery `module_info()`), or `feed(bytes)` (internal + (`dispatcher.register_module(service)` — it carries its module id + (`Config::module`, default 4), `handle(frame)` and discovery + `module_info()`), or `feed(bytes)` (internal resynchronizing frame parser) / `handle_frame(type, payload)` (bring your own parser); GET_SUMMARY / GET_SIZE / READ / ERASE requests, unknown frame types ignored diff --git a/components/coredump/example/main/coredump_example.cpp b/components/coredump/example/main/coredump_example.cpp index d8f00530b4..86cec93dc6 100644 --- a/components/coredump/example/main/coredump_example.cpp +++ b/components/coredump/example/main/coredump_example.cpp @@ -37,8 +37,10 @@ enum class CrashKind : uint8_t { None, NullPointer, Assert, DivideByZero, Hang } // Example-specific "trigger a test crash" command: a stream_frame with // module = kCrashModule, type = kMsgTriggerCrash, payload = [CrashKind]. Its // own dispatcher module keeps it cleanly separate from the core-dump protocol -// (module 4); the CDC text console keeps working for Web Serial / terminal -// users too. +// (module 4 by default -- CoreDumpService::Config::module can move it); the +// CDC text console keeps working for Web Serial / terminal users too. The +// module id is only a routing key: this one constant is the place to change +// it (the hosted coredump console sends the trigger on 1 until told otherwise). static constexpr uint8_t kCrashModule = 1; static constexpr uint8_t kMsgTriggerCrash = 0x00; diff --git a/components/coredump/include/coredump_service.hpp b/components/coredump/include/coredump_service.hpp index 8519af41e2..5d36508472 100644 --- a/components/coredump/include/coredump_service.hpp +++ b/components/coredump/include/coredump_service.hpp @@ -4,11 +4,12 @@ // handler layered on the espp `stream_frame` codec (magic "OT" + flags u8 + // module u8 + type u8 + len u32 + payload + CRC-32, all little-endian; see // components/stream_frame/include/stream_frame.hpp for the authoritative -// framing spec). The core-dump protocol owns dispatcher MODULE 4, so it can -// share one byte stream with other espp protocols (OTA on module 0, an -// application protocol, or free-form console text): frames for other modules -// are simply ignored (route with espp::Dispatcher, or call handle_frame() -// after routing by module). +// framing spec). The core-dump protocol occupies dispatcher MODULE 4 by +// default (CoreDumpService::Config::module can move it), so it can share one +// byte stream with other espp protocols (OTA on module 0, an application +// protocol, or free-form console text): frames for other modules are simply +// ignored (route with espp::Dispatcher, or call handle_frame() after routing +// by module). // // Message types & payloads (host -> device), module 4: // 0x40 GET_SUMMARY — no payload. Reply: SUMMARY. @@ -128,9 +129,10 @@ class CoreDumpService : public BaseComponent { Error = 0xC4, ///< u32 informational code + authoritative UTF-8 message }; - /// Dispatcher module id owned by the core-dump protocol (the frame `module` - /// byte). Reply Msg values keep the high bit set, which build() maps to the - /// frame reply flag. + /// Default dispatcher module id of the core-dump protocol (the frame + /// `module` byte): the id the hosted core-dump console expects. See + /// Config::module to serve on a different id. Reply Msg values keep the + /// high bit set, which build() maps to the frame reply flag. static constexpr uint8_t kModule = 4; /// Maximum image bytes per READ request / DATA reply (the DATA payload is @@ -146,6 +148,11 @@ class CoreDumpService : public BaseComponent { /// Configuration for the CoreDumpService. struct Config { send_fn send{nullptr}; ///< Transmits an encoded reply frame (required). + /// Dispatcher module id this instance answers on (and stamps on its + /// replies). The module id is purely a routing key; the default (kModule, + /// 4) is what the stock core-dump console looks for, so change it only if + /// your host tooling is told the new id. + uint8_t module{kModule}; espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Logger verbosity. }; @@ -158,11 +165,12 @@ class CoreDumpService : public BaseComponent { explicit CoreDumpService(CoreDump &core_dump, const Config &config) : BaseComponent("CoreDumpService", config.log_level) , core_dump_(core_dump) - , send_(config.send) {} + , send_(config.send) + , module_(config.module) {} - /// @brief The dispatcher module id this service answers on (kModule: the - /// core-dump protocol's fixed id, which the web console expects). - uint8_t module_id() const { return kModule; } + /// @brief The dispatcher module id this service answers on (Config::module; + /// kModule by default, which the web console expects). + uint8_t module_id() const { return module_; } /// @brief Discovery metadata for registering this service on a Dispatcher. Dispatcher::ModuleInfo module_info() const { @@ -217,7 +225,7 @@ class CoreDumpService : public BaseComponent { // Only handle this protocol's REQUESTS: ignore frames for other modules // and reply-flagged frames (the service answers requests; a reply-typed // frame — e.g. an echo/loopback — is never a host request). - if (frame.module != kModule || frame.is_reply()) + if (frame.module != module_id() || frame.is_reply()) continue; std::vector reply; { @@ -353,14 +361,15 @@ class CoreDumpService : public BaseComponent { } } - /// Build an encoded frame for a core-dump protocol message type. - static std::vector build(Msg type, std::span payload = {}) { + /// Build an encoded frame for a core-dump protocol message type, on THIS + /// instance's module id (Config::module). + std::vector build(Msg type, std::span payload = {}) const { namespace stream = espp::stream_frame; // Reply message types (Summary/Size/Data/Ok/Error) carry the high bit; map // it to the frame reply flag so requests and replies are distinguishable // independent of the type value. const bool reply = (static_cast(type) & 0x80) != 0; - return stream::build_frame(reply, kModule, static_cast(type), payload); + return stream::build_frame(reply, module_id(), static_cast(type), payload); } /// Transmit an encoded reply frame via the configured send function. Must @@ -419,6 +428,7 @@ class CoreDumpService : public BaseComponent { private: CoreDump &core_dump_; send_fn send_; + uint8_t module_{kModule}; // Config::module: routing id for requests + replies std::mutex mutex_; Stream parser_; }; diff --git a/components/dispatcher/README.md b/components/dispatcher/README.md index d327708ea5..d749210d69 100644 --- a/components/dispatcher/README.md +++ b/components/dispatcher/README.md @@ -18,7 +18,7 @@ The `module` byte (0..255) is the routing key — a full byte, so up to 256 protocols can coexist on one stream. The message/transaction `type` and the request/reply direction (`flags`) travel with the frame and are handed to the module's handler untouched; the Dispatcher does not interpret them. espp's own -protocols and examples use, for example: +protocols and examples use these ids by default: | Module | Protocol | |-----------|-------------------------------------------------| @@ -39,7 +39,13 @@ the module), so both route to the one registered handler — use `frame.is_reply to distinguish them. In practice a device only *receives* requests (it *sends* the replies), so its handler normally sees requests only. Application code may assign any unused module id to its own protocol — nothing is hard-wired to a -specific service. +specific service. The ids above are **defaults**: the module id is only a routing +key, and every espp service takes its id from `Config::module` (used for both +the requests it accepts and the replies it sends — `kModule` is just the +default), while each example module keeps its id in one named constant. The +defaults are what the hosted web consoles and the `espp_ota` CLI look for, so a +device that moves a service off its default must also tell its host tooling the +new id. ## API @@ -54,8 +60,8 @@ specific service. `espp::CoreDumpService`, `espp::Telemetry`, `espp::Mcp266Service` satisfy it (and `static_assert` so); add `static_assert(espp::DispatcherModuleConcept);` to your own module for a precise compile-time check. The id and metadata are - read from the *object*, so a module whose id is configured per instance - registers under that id. + read from the *object* at registration, so a module whose id is configured + per instance (every espp service: `Config::module`) registers under that id. - `void feed(std::span data)` — parse + route. - `void dispatch(const stream_frame::Frame&)` — route an already-parsed frame. - `void reset()` — drop buffered bytes (reconnect / RX overflow). @@ -63,8 +69,8 @@ specific service. ```cpp espp::Dispatcher dispatcher; -dispatcher.register_module(ota_service); // espp::OtaService, module 0 -dispatcher.register_module(coredump_service); // espp::CoreDumpService, module 4 +dispatcher.register_module(ota_service); // espp::OtaService, module 0 (its Config::module) +dispatcher.register_module(coredump_service); // espp::CoreDumpService, module 4 (ditto) dispatcher.register_module(0x10, [&](const espp::stream_frame::Frame &f) { // your own protocol: f.type, f.is_reply(), f.payload }); diff --git a/components/mcp266/README.md b/components/mcp266/README.md index 8d236e3e38..e9f592a7a0 100644 --- a/components/mcp266/README.md +++ b/components/mcp266/README.md @@ -61,8 +61,10 @@ The [MCP266 Console](https://esp-cpp.github.io/espp/apps/mcp266_console.html) or Web Serial) with a small high-level protocol — the CANopen/DS402 work stays on the device: -- `mcp266_protocol.hpp` — the wire protocol (dispatcher **module 6**, - `stream_frame` framing): request/reply types, the payload structs +- `mcp266_protocol.hpp` — the wire protocol (dispatcher **module 6** by + default — `Mcp266Service::Config::module` moves an instance, but the console + looks for 6 until told otherwise; `stream_frame` framing): request/reply + types, the payload structs (`ConfigurePositionLoop`, `MoveToPosition`, `Status`, `DeviceInfo`, ...) with `serialize()` / `parse()`. Standard-library only, so it is usable from any client (host tests in `test/mcp266_protocol_host_test.cpp`). diff --git a/components/ota/README.md b/components/ota/README.md index 81080c3dab..3fba50888a 100644 --- a/components/ota/README.md +++ b/components/ota/README.md @@ -74,8 +74,8 @@ Key class: `espp::Ota` (header-only, `ota.hpp`) `session_active()`, `bytes_written()`, `image_size()` Service class: `espp::OtaService` (`ota_service.hpp`) — the stream protocol -below as a drop-in [dispatcher](../dispatcher) module (module 0), so an -application never implements the OTA state machine itself: +below as a drop-in [dispatcher](../dispatcher) module (module 0 by default), so +an application never implements the OTA state machine itself: ```cpp auto send = [&](std::span frame) { usb.write_vendor(frame); }; @@ -88,9 +88,13 @@ usb.set_vendor_receive_callback([&](std::span data) { link.push(d ``` - construct with the `Ota` engine and a `send` function; `Config` also has - `auto_restart` (default true: reply `OK` to `FINISH`, then restart after - `restart_delay`, 750 ms) and `on_update_finished` (run your own logic / - `Ota::restart()` when `auto_restart` is off) + `module` (the dispatcher module id the instance answers on and stamps on its + replies — default `kModule` = 0, which is what the OTA console and the + `espp_ota` CLI look for; the id is only a routing key, so move it only if + your host tooling is told the new id), `auto_restart` (default true: reply + `OK` to `FINISH`, then restart after `restart_delay`, 750 ms) and + `on_update_finished` (run your own logic / `Ota::restart()` when + `auto_restart` is off) - `handle(frame)` — the dispatcher entry point (ignores other modules and replies); `feed(bytes)` / `handle_frame(type, payload)` for standalone use; `on_rx_overflow()` — abort the transfer and tell the host after the transport @@ -116,7 +120,9 @@ little-endian: This is the shared [`stream_frame`](../stream_frame) v2 codec: `flags` bit0 = reply (0 = request, 1 = device→host reply) and bits 4-7 = version (1); `module` -is the routing id (OTA is **module 0**). OTA layers its message types on it. +is the routing id (OTA is **module 0** by default — every `make_*` builder takes +an optional module argument, and `OtaService::Config::module` moves the +service). OTA layers its message types on it. - `crc32` is the standard zlib CRC-32 (poly `0xEDB88320` reflected, init/final xor `0xFFFFFFFF`) over magic..payload; check value `crc32("123456789") == diff --git a/components/ota/include/detail/ota_stream_protocol.hpp b/components/ota/include/detail/ota_stream_protocol.hpp index 116b0d5353..ae09104dfd 100644 --- a/components/ota/include/detail/ota_stream_protocol.hpp +++ b/components/ota/include/detail/ota_stream_protocol.hpp @@ -82,7 +82,10 @@ using espp::stream_frame::put_u32; using espp::stream_frame::Frame; using espp::stream_frame::StreamParser; -/// OTA occupies dispatcher module id 0. +/// Default dispatcher module id of the OTA protocol (0) -- the id the OTA +/// console / CLI expect. Every frame builder below takes an optional `module` +/// argument (defaulting to this) so a device that serves OTA on another id +/// (espp::OtaService::Config::module) builds its replies on that id. static constexpr uint8_t kModule = 0; /// OTA stream protocol message types (the frame `type` field within module 0). @@ -122,38 +125,50 @@ inline bool is_reply(MessageType type) { /// @brief Build an encoded OTA frame (typed overload of stream_frame::build_frame). /// @param type OTA message type. /// @param payload Payload bytes; must be <= kMaxPayloadSize. +/// @param module Dispatcher module id to stamp on the frame (default kModule). /// @return The encoded frame bytes, or an empty vector if the payload is too large. -inline std::vector build_frame(MessageType type, std::span payload = {}) { - return espp::stream_frame::build_frame(is_reply(type), kModule, static_cast(type), +inline std::vector build_frame(MessageType type, std::span payload = {}, + uint8_t module = kModule) { + return espp::stream_frame::build_frame(is_reply(type), module, static_cast(type), payload); } /// Build a BEGIN frame (image_size in bytes, 0 = unknown / streaming). -inline std::vector make_begin(uint32_t image_size) { +inline std::vector make_begin(uint32_t image_size, uint8_t module = kModule) { std::vector payload; put_u32(payload, image_size); - return build_frame(MessageType::Begin, payload); + return build_frame(MessageType::Begin, payload, module); } /// Build a DATA frame carrying up to kMaxPayloadSize image bytes. -inline std::vector make_data(std::span data) { - return build_frame(MessageType::Data, data); +inline std::vector make_data(std::span data, uint8_t module = kModule) { + return build_frame(MessageType::Data, data, module); } /// Build a FINISH frame (no payload). -inline std::vector make_finish() { return build_frame(MessageType::Finish); } +inline std::vector make_finish(uint8_t module = kModule) { + return build_frame(MessageType::Finish, {}, module); +} /// Build an ABORT frame (no payload). -inline std::vector make_abort() { return build_frame(MessageType::Abort); } +inline std::vector make_abort(uint8_t module = kModule) { + return build_frame(MessageType::Abort, {}, module); +} /// Build a GET_STATUS frame (no payload). The device replies with STATUS. -inline std::vector make_get_status() { return build_frame(MessageType::GetStatus); } +inline std::vector make_get_status(uint8_t module = kModule) { + return build_frame(MessageType::GetStatus, {}, module); +} /// Build a MARK_VALID frame (no payload). Confirms the running image. -inline std::vector make_mark_valid() { return build_frame(MessageType::MarkValid); } +inline std::vector make_mark_valid(uint8_t module = kModule) { + return build_frame(MessageType::MarkValid, {}, module); +} /// Build a MARK_INVALID frame (no payload). Rolls back + reboots the device. -inline std::vector make_mark_invalid() { return build_frame(MessageType::MarkInvalid); } +inline std::vector make_mark_invalid(uint8_t module = kModule) { + return build_frame(MessageType::MarkInvalid, {}, module); +} /// Append a length-prefixed (u8 length) UTF-8 string, truncated to 255 bytes. inline void put_str(std::vector &out, std::string_view s) { @@ -166,38 +181,40 @@ inline void put_str(std::vector &out, std::string_view s) { /// and project name (each a u8-length-prefixed string), so the host can report /// what firmware is now running before confirming it. inline std::vector make_status(uint8_t flags, std::string_view version = {}, - std::string_view project = {}) { + std::string_view project = {}, uint8_t module = kModule) { std::vector p; p.push_back(flags); put_str(p, version); put_str(p, project); - return build_frame(MessageType::Status, p); + return build_frame(MessageType::Status, p, module); } /// Build an OK reply (bytes_received so far). -inline std::vector make_ok(uint32_t bytes_received) { +inline std::vector make_ok(uint32_t bytes_received, uint8_t module = kModule) { std::vector payload; put_u32(payload, bytes_received); - return build_frame(MessageType::Ok, payload); + return build_frame(MessageType::Ok, payload, module); } /// Build an ERROR reply (u32 code + UTF-8 message; the message is truncated if /// it would overflow the maximum payload size). -inline std::vector make_error(uint32_t code, std::string_view message) { +inline std::vector make_error(uint32_t code, std::string_view message, + uint8_t module = kModule) { std::vector payload; put_u32(payload, code); const size_t max_message = kMaxPayloadSize - payload.size(); const size_t count = std::min(message.size(), max_message); payload.insert(payload.end(), message.begin(), message.begin() + count); - return build_frame(MessageType::Error, payload); + return build_frame(MessageType::Error, payload, module); } /// Build a PROGRESS reply (bytes written so far, total expected — 0 if unknown). -inline std::vector make_progress(uint32_t written, uint32_t total) { +inline std::vector make_progress(uint32_t written, uint32_t total, + uint8_t module = kModule) { std::vector payload; put_u32(payload, written); put_u32(payload, total); - return build_frame(MessageType::Progress, payload); + return build_frame(MessageType::Progress, payload, module); } /// Parse the single-u32 payload of a BEGIN (image_size) or OK (bytes_received) diff --git a/components/ota/include/ota_service.hpp b/components/ota/include/ota_service.hpp index 61b310cd90..302a5dc29a 100644 --- a/components/ota/include/ota_service.hpp +++ b/components/ota/include/ota_service.hpp @@ -49,7 +49,8 @@ namespace espp { /** * @brief Transport-agnostic service exposing an espp::Ota engine over any - * framed byte stream (dispatcher module 0). + * framed byte stream (dispatcher module 0 by default; see + * Config::module). * * See detail/ota_stream_protocol.hpp for the wire protocol. Flow control is * one request in flight: the host waits for OK / ERROR before sending the @@ -88,7 +89,9 @@ class OtaService : public BaseComponent { /// The OTA wire protocol (message types, frame builders). using MessageType = espp::detail::ota_stream::MessageType; - /// Dispatcher module id owned by the OTA protocol. + /// Default dispatcher module id of the OTA protocol (0): the id the hosted + /// OTA console and the `espp_ota` CLI expect. See Config::module to serve on + /// a different id. static constexpr uint8_t kModule = espp::detail::ota_stream::kModule; /// Transmits one encoded reply frame to the host. @@ -99,6 +102,11 @@ class OtaService : public BaseComponent { /// Configuration for the OtaService. struct Config { send_fn send{nullptr}; ///< Transmits an encoded reply frame (required). + /// Dispatcher module id this instance answers on (and stamps on its + /// replies). The module id is purely a routing key; the default (kModule, + /// 0) is what the stock OTA console / `espp_ota` CLI look for, so change it + /// only if your host tooling is told the new id. + uint8_t module{kModule}; /// Restart the device after a successful FINISH (after the OK reply). bool auto_restart{true}; /// Delay between the OK reply and the restart, so the reply reaches the host. @@ -121,9 +129,9 @@ class OtaService : public BaseComponent { , ota_(ota) , config_(config) {} - /// @brief The dispatcher module id this service answers on (kModule: the - /// OTA protocol's fixed id, which the OTA console / CLI expect). - uint8_t module_id() const { return kModule; } + /// @brief The dispatcher module id this service answers on (Config::module; + /// kModule by default, which the OTA console / CLI expect). + uint8_t module_id() const { return config_.module; } /// @brief Discovery metadata for registering this service on a Dispatcher. Dispatcher::ModuleInfo module_info() const { @@ -245,7 +253,7 @@ class OtaService : public BaseComponent { if (ota_.begin(image_size, ec)) { owns_session_ = true; logger_.info("BEGIN: update session started ({} bytes expected)", image_size); - reply = proto::make_ok(0); + reply = proto::make_ok(0, module_id()); } else { // busy = another transport's session; ownership stays false reply = build_error(ec, "begin failed"); @@ -259,7 +267,7 @@ class OtaService : public BaseComponent { return true; } if (ota_.write(payload, ec)) { - reply = proto::make_ok(static_cast(ota_.bytes_written())); + reply = proto::make_ok(static_cast(ota_.bytes_written()), module_id()); } else { owns_session_ = false; // write() aborted the session on failure reply = build_error(ec, "write failed"); @@ -275,7 +283,7 @@ class OtaService : public BaseComponent { owns_session_ = false; // finish() ends the session in all outcomes if (ota_.finish(ec)) { logger_.info("FINISH: image validated and activated ({} bytes)", written); - reply = proto::make_ok(written); + reply = proto::make_ok(written, module_id()); finished = true; } else { reply = build_error(ec, "finish (validate/activate) failed"); @@ -292,7 +300,7 @@ class OtaService : public BaseComponent { owns_session_ = false; // session over either way if (ota_.abort(ec)) { logger_.info("ABORT: session discarded after {} bytes", written); - reply = proto::make_ok(written); + reply = proto::make_ok(written, module_id()); } else { reply = build_error(ec, "abort failed"); } @@ -308,14 +316,14 @@ class OtaService : public BaseComponent { flags |= proto::kStatusPendingVerify; #endif const auto desc = ota_.running_app_description(); - reply = proto::make_status(flags, desc.version, desc.project_name); + reply = proto::make_status(flags, desc.version, desc.project_name, module_id()); return true; } case MessageType::MarkValid: // The HOST confirms the running image after its own health checks. if (ota_.mark_app_valid(ec)) { logger_.info("MARK_VALID: running image confirmed"); - reply = proto::make_ok(0); + reply = proto::make_ok(0, module_id()); } else { reply = build_error(ec, "mark valid failed"); } @@ -372,8 +380,8 @@ class OtaService : public BaseComponent { code = (cond.category() == std::generic_category()) ? cond.value() : static_cast(std::errc::io_error); } - return espp::detail::ota_stream::make_error(static_cast(code), - std::string(context) + ": " + ec.message()); + return espp::detail::ota_stream::make_error( + static_cast(code), std::string(context) + ": " + ec.message(), module_id()); } std::vector build_error(std::errc errc, std::string_view context) const { diff --git a/components/ota/test/ota_protocol_host_test.cpp b/components/ota/test/ota_protocol_host_test.cpp index 18a9332954..d13c3673a6 100644 --- a/components/ota/test/ota_protocol_host_test.cpp +++ b/components/ota/test/ota_protocol_host_test.cpp @@ -194,9 +194,41 @@ static void test_malformed_reply_payloads() { } } +// Every builder takes an optional module id (default kModule) so a device that +// serves OTA on another dispatcher id (OtaService::Config::module) stamps its +// replies -- and a host driving it its requests -- on that id. +static void test_builders_take_a_module_id() { + std::printf("test_builders_take_a_module_id\n"); + constexpr uint8_t kOther = 0x20; + const uint8_t img[] = {0xE9}; + const std::vector frames[] = { + ota::make_begin(1u, kOther), + ota::make_data(img, kOther), + ota::make_finish(kOther), + ota::make_abort(kOther), + ota::make_get_status(kOther), + ota::make_mark_valid(kOther), + ota::make_mark_invalid(kOther), + ota::make_ok(1u, kOther), + ota::make_error(1u, "e", kOther), + ota::make_progress(1u, 2u, kOther), + ota::make_status(0, "v", "p", kOther), + }; + for (const auto &encoded : frames) { + ota::Frame f{}; + CHECK(parse_one(encoded, f)); + CHECK(f.module == kOther); + } + // The default is unchanged (the stock console / CLI expect module 0). + ota::Frame d{}; + CHECK(parse_one(ota::make_ok(1u), d)); + CHECK(d.module == ota::kModule && ota::kModule == 0); +} + int main() { test_requests_are_module0_requests(); test_replies_carry_reply_flag(); + test_builders_take_a_module_id(); test_status_reply(); test_malformed_status_payloads(); test_malformed_reply_payloads(); diff --git a/components/telemetry/README.md b/components/telemetry/README.md index cb456981b7..08bcac0a2c 100644 --- a/components/telemetry/README.md +++ b/components/telemetry/README.md @@ -71,8 +71,9 @@ rest, with the per-series filter bar: - **Binary telemetry over WebUSB.** Connect with **USB** to an espp device running `espp::Telemetry`: the app reads the channel schema and plots the device-timestamped sample stream (decoded from the `stream_frame` framing, - dispatcher module 3) into the same plot. Requests the schema on connect and - can pause/resume the device stream. + dispatcher module 3 — the `espp::Telemetry` default; `Config::module` can + move an instance, but the app looks for 3 until told otherwise) into the same + plot. Requests the schema on connect and can pause/resume the device stream. ## Requirements diff --git a/components/telemetry/include/telemetry.hpp b/components/telemetry/include/telemetry.hpp index 695723c86a..5551c56ab8 100644 --- a/components/telemetry/include/telemetry.hpp +++ b/components/telemetry/include/telemetry.hpp @@ -31,9 +31,10 @@ namespace espp { /// Web-Serial transport: instead of parsing printed columns, the device sends /// typed samples directly. /// -/// ## Wire protocol (dispatcher module id 3) +/// ## Wire protocol (dispatcher module id 3 by default) /// -/// Every message is a `stream_frame` frame with `module == kModule`. The frame +/// Every message is a `stream_frame` frame with `module == module_id()` (kModule, +/// 3, unless Config::module says otherwise). The frame /// `type` byte's high bit distinguishes direction: host->device requests are /// `0x0X`, device->host frames are `0x8X` (which sets the frame reply flag). /// @@ -69,9 +70,10 @@ namespace espp { /// (non-recursive) mutex. Keep `send` to writing the bytes to the transport. class Telemetry : public espp::BaseComponent { public: - /// Dispatcher module id owned by the telemetry protocol (the frame `module` - /// byte). Device->host Type values keep the high bit set, which the framing - /// maps to the reply flag. + /// Default dispatcher module id of the telemetry protocol (the frame + /// `module` byte): the id the hosted Serial Plotter expects. See + /// Config::module to serve on a different id. Device->host Type values keep + /// the high bit set, which the framing maps to the reply flag. static constexpr uint8_t kModule = 3; /// Version byte at the head of a SCHEMA payload, so the wire format can evolve. @@ -103,8 +105,13 @@ class Telemetry : public espp::BaseComponent { struct Config { std::vector channels; ///< Channel names, in sample order (>= 1). send_fn send{nullptr}; ///< Transmits an encoded frame (may be set later). - bool stream_on_start{true}; ///< Start with streaming enabled. - uint16_t period_ms{20}; ///< Default requested sample period (informational). + /// Dispatcher module id this instance answers on (and stamps on every + /// frame it sends). The module id is purely a routing key; the default + /// (kModule, 3) is what the stock Serial Plotter looks for, so change it + /// only if your host tooling is told the new id. + uint8_t module{kModule}; + bool stream_on_start{true}; ///< Start with streaming enabled. + uint16_t period_ms{20}; ///< Default requested sample period (informational). espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Logger verbosity. }; @@ -114,6 +121,7 @@ class Telemetry : public espp::BaseComponent { : BaseComponent("Telemetry", config.log_level) , channels_(config.channels) , send_(config.send) + , module_(config.module) , streaming_(config.stream_on_start) , period_ms_(config.period_ms) { clamp_channels(); @@ -226,9 +234,9 @@ class Telemetry : public espp::BaseComponent { s(std::span(frame)); // send while still holding send_mutex_ } - /// @brief The dispatcher module id this service answers on (kModule: the - /// telemetry protocol's fixed id, which the Serial Plotter expects). - uint8_t module_id() const { return kModule; } + /// @brief The dispatcher module id this service answers on (Config::module; + /// kModule by default, which the Serial Plotter expects). + uint8_t module_id() const { return module_; } /// @brief Discovery metadata for registering this service on a Dispatcher. Dispatcher::ModuleInfo module_info() const { @@ -257,7 +265,7 @@ class Telemetry : public espp::BaseComponent { frames = parser_.feed(data); } for (const auto &frame : frames) { - if (frame.module != kModule || frame.is_reply()) + if (frame.module != module_id() || frame.is_reply()) continue; handle_request(frame.type, frame.payload); } @@ -384,11 +392,12 @@ class Telemetry : public espp::BaseComponent { s(std::span(frame)); // send while still holding send_mutex_ } - /// Build an encoded frame for a telemetry message type. Device->host types - /// (high bit set) map to the frame reply flag. - static std::vector build(Type type, std::span payload = {}) { + /// Build an encoded frame for a telemetry message type, on THIS instance's + /// module id (Config::module). Device->host types (high bit set) map to the + /// frame reply flag. + std::vector build(Type type, std::span payload = {}) const { const bool reply = (static_cast(type) & 0x80) != 0; - return espp::stream_frame::build_frame(reply, kModule, static_cast(type), payload); + return espp::stream_frame::build_frame(reply, module_id(), static_cast(type), payload); } /// Append a little-endian IEEE-754 float32 to a byte buffer. @@ -411,6 +420,7 @@ class Telemetry : public espp::BaseComponent { ///< send_mutex_ then mutex_) std::vector channels_; send_fn send_; + const uint8_t module_; ///< Config::module: routing id for requests + every outbound frame std::atomic streaming_; std::atomic period_ms_; espp::stream_frame::StreamParser parser_; diff --git a/doc/en/coredump/coredump.rst b/doc/en/coredump/coredump.rst index 7468c8cacc..61f25a3047 100644 --- a/doc/en/coredump/coredump.rst +++ b/doc/en/coredump/coredump.rst @@ -24,7 +24,9 @@ it on a USB vendor (WebUSB) callback, a CDC (Web Serial) callback, or a socket takes a few lines. Unknown frame types are ignored, so the service coexists with other framed protocols — and, because the parser resynchronizes on the frame magic, with free-form **console text** — on the -same stream. +same stream. It answers on :doc:`dispatcher <../dispatcher/dispatcher>` +module id 4 by default; ``Config::module`` moves an instance (requests and +replies alike), but the hosted console looks for 4 until told otherwise. The hosted `espp Core Dump Console `_ web app speaks diff --git a/doc/en/dispatcher/custom_modules.rst b/doc/en/dispatcher/custom_modules.rst index ced7c2b6fa..606eacb142 100644 --- a/doc/en/dispatcher/custom_modules.rst +++ b/doc/en/dispatcher/custom_modules.rst @@ -53,12 +53,13 @@ Module ids already in use -------------------------- The ``module`` byte is a full byte (0..255), so up to 256 protocols can -coexist on one stream. espp's own protocols and examples currently claim: +coexist on one stream. espp's own protocols and examples use these ids **by +default**: ========= ======================================================== Module id Protocol ========= ======================================================== -0 OTA (firmware update) +0 OTA (firmware update, ``espp::OtaService``) 1 Core-dump example crash trigger (example only) 2 BLDC haptics (``components/bldc_haptics``) 3 Telemetry (``espp::Telemetry``) @@ -74,6 +75,39 @@ your own application) for your protocol. Nothing in `espp::Dispatcher` is hard-wired to a specific id — a device only ever registers the modules it actually serves, and frames for an unregistered id are silently ignored. +The module id is a **routing key, not an identity**: which protocol a module +speaks is what its handler does with the frame (and what its +`ModuleInfo` advertises — see `Discovery + the webapp side`_), and the id only +says which registered handler receives the frame. So the ids above are +*defaults*, not fixed properties of the protocols. Every espp service takes +its id from its `Config` (``Config::module``, defaulting to the service's +``kModule`` / the protocol header's ``kModuleId``), and each example module +routes through a single named constant (``kHapticsModule``, +``kCanBridgeModule``, ``kCrashModule``) at the top of its ``main``. An +application that needs to, for example, run two `espp::Telemetry` instances +on one link, or whose own protocol already occupies ``4``, simply moves the +espp service: + +.. code-block:: cpp + + espp::CoreDumpService coredump_service(core_dump, {.send = send, .module = 0x24}); + espp::Telemetry fast({.channels = {"ax", "ay", "az"}, .send = send}); // module 3 + espp::Telemetry slow({.channels = {"temp"}, .send = send, .module = 0x13}); // moved + link.register_module(coredump_service); // registered under 0x24 + link.register_module(fast); + link.register_module(slow); + +A service uses its configured id for **everything** — the frames it accepts +in `handle()` / `feed()`, and the `module` byte it stamps on every reply and +notification it sends — so the request and reply sides can never disagree. +The one thing the id does *not* change is the host: the hosted web consoles +(``ota_console.html``, ``coredump_console.html``, ``telemetry.html``, +``mcp266_console.html``, ...) and the ``espp_ota`` Python CLI are written +against the **default** ids in the table, so a device that moves a service +off its default will not be found by the stock console until that console +(or your own host tool) is told the new id. Keep the defaults unless you have +a reason not to; discovery still lists the actual registered id either way. + Several modules can, and routinely do, share **one** `Dispatcher` over **one** USB link. The `bldc_haptics` example registers OTA, the crash-dump service, *and* its own haptics protocol on a single `espp::DispatcherWorker` (a @@ -100,10 +134,12 @@ USB link. The `bldc_haptics` example registers OTA, the crash-dump service, link.serve_discovery(usb_cfg.product); The espp *services* (`espp::OtaService`, `espp::CoreDumpService`, -`espp::Telemetry`) each carry their own module id, discovery metadata and -handler, so registering one is a single call. Your application module -registers alongside them the same way — pick an unused id, register a -handler, and (optionally) attach `ModuleInfo` so it is discoverable (see +`espp::Telemetry`, `espp::Mcp266Service`) each carry their own module id +(``Config::module``, defaulting to the protocol's published id), discovery +metadata and handler, so registering one is a single call. Your application +module registers alongside them the same way — pick an unused id (the +haptics example keeps its own in one ``kHapticsModule`` constant), register +a handler, and (optionally) attach `ModuleInfo` so it is discoverable (see `Discovery + the webapp side`_) — or give it the same three members and register it with the one-argument overload too. @@ -116,13 +152,15 @@ is a complete, production module: it owns a protocol (module id, message types), is transport-agnostic, and is registered on a `Dispatcher` exactly like your own module will be. The pattern has six parts: -1. **A module id.** +1. **A module id** — a published *default*, which each instance can override + through its `Config` (part 3): .. code-block:: cpp - /// Dispatcher module id owned by the core-dump protocol (the frame `module` - /// byte). Reply Msg values keep the high bit set, which build() maps to the - /// frame reply flag. + /// Default dispatcher module id of the core-dump protocol (the frame + /// `module` byte): the id the hosted core-dump console expects. See + /// Config::module to serve on a different id. Reply Msg values keep the + /// high bit set, which build() maps to the frame reply flag. static constexpr uint8_t kModule = 4; 2. **Message types**, as a scoped enum of `uint8_t` values. `CoreDumpService` @@ -159,10 +197,12 @@ like your own module will be. The pattern has six parts: works as long as your `build()` (or equivalent) passes the right `reply` bool to `build_frame()`. -3. **A `Config` carrying a `send_fn`.** The module never touches a transport - directly — it is handed a callback that transmits one already-encoded - frame, so the same module class works unmodified over USB vendor, USB CDC, - a socket, or a UART: +3. **A `Config` carrying a `send_fn` and the module id.** The module never + touches a transport directly — it is handed a callback that transmits one + already-encoded frame, so the same module class works unmodified over USB + vendor, USB CDC, a socket, or a UART. The module id lives here too, so an + application can move the module without touching the class (the default + is the protocol's published `kModule`, which the stock console expects): .. code-block:: cpp @@ -170,6 +210,7 @@ like your own module will be. The pattern has six parts: struct Config { send_fn send{nullptr}; ///< Transmits an encoded reply frame (required). + uint8_t module{kModule}; ///< Dispatcher module id (routing key; default 4). espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; }; @@ -183,14 +224,16 @@ like your own module will be. The pattern has six parts: .log_level = espp::Logger::Verbosity::INFO}); 4. **A `build()` helper** that wraps `stream_frame::build_frame()` so every - reply in the module goes through one place: + reply in the module goes through one place — and stamps *this instance's* + id (`module_id()`, part 6), never the `kModule` constant, so a moved + module's replies route back on the same id its requests arrived on: .. code-block:: cpp - static std::vector build(Msg type, std::span payload = {}) { + std::vector build(Msg type, std::span payload = {}) const { namespace stream = espp::stream_frame; const bool reply = (static_cast(type) & 0x80) != 0; - return stream::build_frame(reply, kModule, static_cast(type), payload); + return stream::build_frame(reply, module_id(), static_cast(type), payload); } 5. **`feed()` / `handle_frame()` entry points** that parse (or accept an @@ -254,11 +297,11 @@ like your own module will be. The pattern has six parts: one call, metadata included — those overloads are constrained on the concept, so a non-conforming type fails to compile at the call with a message naming the missing member. The dispatcher reads the id and - metadata from the *object*, so a module constructed with a configurable - id (see the "hello" module below) registers under that id — `CoreDumpService` - simply returns its protocol's fixed `kModule`. Every espp service also - `static_assert`s the concept right after its class definition, and so - should yours: + metadata from the *object* at registration time, so a module registers + under whatever id it was constructed with — `CoreDumpService` returns its + `Config::module` (`kModule` unless the app changed it), exactly like the + "hello" module below. Every espp service also `static_assert`s the concept + right after its class definition, and so should yours: .. code-block:: cpp @@ -268,7 +311,7 @@ like your own module will be. The pattern has six parts: .. code-block:: cpp - uint8_t module_id() const { return kModule; } + uint8_t module_id() const { return module_; } // Config::module, default kModule Dispatcher::ModuleInfo module_info() const { return {.name = "Core Dump", @@ -732,7 +775,10 @@ Checklist: shipping a new module + webapp ============================================ #. **Pick a module id** not already listed in the `Module ids already in use`_ - table (and not used elsewhere in your own application). + table (and not used elsewhere in your own application). Make it a + `Config` field with that id as the default (as every espp service does) + rather than a bare constant used everywhere, so an application can move + it later without editing the module. #. **Define your message `type`s** as a small enum; decide (and document) how request vs. reply is signalled in your `type` values, remembering the *authoritative* signal is always the frame's `flags` reply bit passed to @@ -774,10 +820,10 @@ Checklist: shipping a new module + webapp - :doc:`../coredump/coredump` — the `CoreDumpService` worked example used throughout this page. - :doc:`../ota/ota` — `OtaService`, the OTA protocol as a drop-in service - (module 0). + (module 0 by default). - :doc:`../telemetry/telemetry` — a module streaming typed float samples (the `put_f32` example above). - - :doc:`../buses/canopen` — a bridge module (module 5) plus an + - :doc:`../buses/canopen` — a bridge module (module 5 by default) plus an in-browser CANopen/DS402 client. - :doc:`../buses/usb_cdc` — the `espp::UsbDevice` component reference (vendor / CDC / HID / X-Input functions, endpoint budgeting). diff --git a/doc/en/dispatcher/dispatcher.rst b/doc/en/dispatcher/dispatcher.rst index 965e352c99..9c931473b2 100644 --- a/doc/en/dispatcher/dispatcher.rst +++ b/doc/en/dispatcher/dispatcher.rst @@ -16,7 +16,7 @@ The frame's ``module`` byte (0..255) is the routing key — a full byte, so up t 256 protocols can coexist on one stream. The message/transaction ``type`` and the request/reply direction (``flags``) travel with the frame and are handed to the module's handler untouched; the Dispatcher does not interpret them. espp's -own protocols and examples use, for example: +own protocols and examples use these ids by default: ========= ============================================== Module id Protocol @@ -39,7 +39,13 @@ the module), so both directions route to the one registered handler — use ``frame.is_reply()`` to tell them apart. In practice a device only *receives* requests (it *sends* the replies), so its handler normally sees requests only. Application code may assign any unused module id to its own protocol; nothing is -hard-wired to a specific service. +hard-wired to a specific service. The ids in the table are **defaults**: the +module id is only a routing key, and every espp service takes its id from +``Config::module`` (used for both the requests it accepts and the replies it +sends), while each example module keeps its id in one named constant. The +defaults are what the hosted web consoles and the ``espp_ota`` CLI look for, so +a device that moves a service off its default must also tell its host tooling +the new id — see :doc:`custom_modules`. .. ------------------------------- Example ------------------------------------- diff --git a/doc/en/ota/ota.rst b/doc/en/ota/ota.rst index 1d1c742e32..419a6a9421 100644 --- a/doc/en/ota/ota.rst +++ b/doc/en/ota/ota.rst @@ -32,7 +32,9 @@ component (``detail/ota_stream_protocol.hpp`` re-exports it and layers the OTA message types on top); to run OTA alongside other protocols (crash-dump, CAN, ...) on one stream, register it as a module with the :doc:`../dispatcher/index` — the ``ota`` example does exactly this (OTA is -module id 0). +module id 0 by default; ``OtaService::Config::module`` moves an instance, at +the cost of the stock OTA console / ``espp_ota`` CLI no longer finding it +until they are told the new id). Command line: build → OTA ------------------------- diff --git a/doc/en/telemetry/telemetry.rst b/doc/en/telemetry/telemetry.rst index f35a5aa6df..0d6ec3527d 100644 --- a/doc/en/telemetry/telemetry.rst +++ b/doc/en/telemetry/telemetry.rst @@ -25,7 +25,9 @@ Telemetry service The `Telemetry` class is a small device→host protocol carried on the espp :doc:`stream_frame <../stream_frame/stream_frame>` framing (dispatcher module -id 3), so it can share one USB vendor / CDC stream with other modules via +id 3 by default — ``Config::module`` moves an instance, e.g. to run two +emitters on one link, but the hosted plotter looks for 3 until told +otherwise), so it can share one USB vendor / CDC stream with other modules via :doc:`dispatcher <../dispatcher/dispatcher>`. Firmware declares a fixed set of named ``float`` channels (the **SCHEMA**) and pushes **SAMPLE** frames — a device timestamp plus one float per channel (batchable) — with ``emit(...)``. From 5f51132a37120869e0f14cd4229a6c5d92c5233c Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 17 Sep 2026 20:11:09 -0500 Subject: [PATCH 2/4] docs(dispatcher): describe module ids as defaults wherever a protocol header still called them fixed Review follow-ups on #799: the OTA, coredump, haptics and CAN-bridge protocol headers (plus PROTOCOL.md, the dispatcher README snippet and the CANopen doc) now describe their module id as the default and point at the Config::module / constant that moves it; grammar fix in the OTA host test comment. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/bldc_haptics/example/PROTOCOL.md | 4 ++-- .../example/main/haptics_usb_protocol.hpp | 5 +++-- .../main/can_bridge_protocol.hpp | 15 ++++++++------- components/coredump/include/coredump_service.hpp | 8 ++++---- components/dispatcher/README.md | 4 ++-- .../ota/include/detail/ota_stream_protocol.hpp | 6 ++++-- components/ota/test/ota_protocol_host_test.cpp | 2 +- doc/en/buses/canopen.rst | 2 +- 8 files changed, 25 insertions(+), 21 deletions(-) diff --git a/components/bldc_haptics/example/PROTOCOL.md b/components/bldc_haptics/example/PROTOCOL.md index 4cbe02fc8f..2c8f403d15 100644 --- a/components/bldc_haptics/example/PROTOCOL.md +++ b/components/bldc_haptics/example/PROTOCOL.md @@ -14,7 +14,7 @@ for the reference host implementation. Uses the espp `stream_frame` v2 codec (`components/stream_frame/include/stream_frame.hpp` is the authoritative spec). -The whole haptics protocol is dispatcher **module 2**. All multi-byte fields are +The whole haptics protocol is one dispatcher module, **2 by default** (`kHapticsModule` in `bldc_haptics_example.cpp`; the hosted console expects 2). All multi-byte fields are **little-endian**: ``` @@ -26,7 +26,7 @@ The whole haptics protocol is dispatcher **module 2**. All multi-byte fields are reply/event); `bits 4-7` = protocol version = `1`. So a request byte is `0x10` and a reply/telemetry byte is `0x11`. Request types (`0x0_`/`0x1_`) clear the reply bit; reply/telemetry types (`0x8_`/`0x9_`) set it. -- `module`: `u8` dispatcher module — **2** for the entire haptics protocol. +- `module`: `u8` dispatcher module — the haptics module id (**2** by default) for the entire haptics protocol. - `type`: message type (tables below). - `len`: payload length, capped at **4096** bytes per frame; receivers reject and resynchronize past any frame whose length field exceeds the cap. diff --git a/components/bldc_haptics/example/main/haptics_usb_protocol.hpp b/components/bldc_haptics/example/main/haptics_usb_protocol.hpp index 75a4f8cd50..a65f545134 100644 --- a/components/bldc_haptics/example/main/haptics_usb_protocol.hpp +++ b/components/bldc_haptics/example/main/haptics_usb_protocol.hpp @@ -7,7 +7,8 @@ // spec and ../PROTOCOL.md next to this example for the full haptics wire // protocol). // -// The haptics protocol occupies dispatcher MODULE 2 (haptics commands only). +// The haptics protocol occupies dispatcher MODULE 2 by default (haptics commands +// only; kHapticsModule in bldc_haptics_example.cpp picks the id). // Firmware update and crash-dump inspection are NOT part of it: the example runs // the standard espp OTA protocol on module 0 and the coredump service on module // 4 (routed by the same espp::Dispatcher), handled by the ota / coredump web @@ -45,7 +46,7 @@ static constexpr uint8_t kModule = 2; /// Protocol version reported in the INFO reply. static constexpr uint8_t kProtocolVersion = 1; -/// Message types carried in the frame `type` byte (within module 2). +/// Message types carried in the frame `type` byte (within the haptics module). enum class Msg : uint8_t { // --- Haptics commands ------------------------------------------------------ GetInfo = 0x10, ///< host->dev: no payload -> Info reply diff --git a/components/canopen/can_bridge_example/main/can_bridge_protocol.hpp b/components/canopen/can_bridge_example/main/can_bridge_protocol.hpp index 8f8821f52a..47aec7a0ba 100644 --- a/components/canopen/can_bridge_example/main/can_bridge_protocol.hpp +++ b/components/canopen/can_bridge_example/main/can_bridge_protocol.hpp @@ -3,13 +3,14 @@ // Wire protocol for the USB <-> CAN (TWAI) bridge example. // // Framed with the espp stream_frame v2 codec and routed by espp::Dispatcher on -// MODULE ID 5. The v2 frame has a DEDICATED module byte, so EVERY frame here — -// requests and replies alike — sets module = 5 and routes to dispatcher module -// 5 (this is NOT the retired v1 scheme where the module was derived from the -// type's high nibble). The 0x5X / 0xD_ values below are the `type` byte, not -// the module; the reply/event types (0xD_) additionally set the frame reply -// flag (build derives it from the type's high bit). The hosted CAN console web -// app speaks this exact protocol over WebUSB / Web Serial. +// one module id, 5 by default (kModuleId here; the example registers under +// kCanBridgeModule in can_bridge_example.cpp, and a host must use the same id). +// The v2 frame has a DEDICATED module byte, so EVERY frame here — requests and +// replies alike — carries that module id and routes to it (this is NOT the +// retired v1 scheme where the module was derived from the type's high nibble). The 0x5X / 0xD_ +// values below are the `type` byte, not the module; the reply/event types (0xD_) additionally set +// the frame reply flag (build derives it from the type's high bit). The hosted CAN console web app +// speaks this exact protocol over WebUSB / Web Serial. // // A CAN frame is encoded as a compact payload: // [id u32 LE][flags u8][dlc u8][data: dlc bytes] diff --git a/components/coredump/include/coredump_service.hpp b/components/coredump/include/coredump_service.hpp index 5d36508472..65edfc8f04 100644 --- a/components/coredump/include/coredump_service.hpp +++ b/components/coredump/include/coredump_service.hpp @@ -11,13 +11,13 @@ // ignored (route with espp::Dispatcher, or call handle_frame() after routing // by module). // -// Message types & payloads (host -> device), module 4: +// Message types & payloads (host -> device), module 4 by default: // 0x40 GET_SUMMARY — no payload. Reply: SUMMARY. // 0x41 GET_SIZE — no payload. Reply: SIZE. // 0x42 READ — payload: u32 offset + u16 length. Reply: DATA / ERROR. // 0x43 ERASE — no payload. Reply: OK / ERROR. // -// Message types & payloads (device -> host), module 4, reply flag set: +// Message types & payloads (device -> host), same module, reply flag set: // 0xC0 SUMMARY — payload: UTF-8 crash report text (espp::CoreDump:: // format_report()); EMPTY payload = clean boot history. // 0xC1 SIZE — payload: u32 total core-dump image size in bytes (0 = no @@ -113,8 +113,8 @@ class CoreDumpService : public BaseComponent { /// Frame-stream parser type (from the shared stream_frame codec). using Stream = espp::stream_frame::StreamParser; - /// Core-dump protocol message types (the stream_frame `type` byte within - /// module 4; see the header comment for the payload spec). + /// Core-dump protocol message types (the stream_frame `type` byte within the + /// service's module, 4 by default; see the header comment for the payload spec). enum class Msg : uint8_t { // host -> device GetSummary = 0x40, ///< request the crash report text diff --git a/components/dispatcher/README.md b/components/dispatcher/README.md index d749210d69..5dbf4ac54f 100644 --- a/components/dispatcher/README.md +++ b/components/dispatcher/README.md @@ -69,8 +69,8 @@ new id. ```cpp espp::Dispatcher dispatcher; -dispatcher.register_module(ota_service); // espp::OtaService, module 0 (its Config::module) -dispatcher.register_module(coredump_service); // espp::CoreDumpService, module 4 (ditto) +dispatcher.register_module(ota_service); // espp::OtaService, its Config::module (0 by default) +dispatcher.register_module(coredump_service); // espp::CoreDumpService, its Config::module (4 by default) dispatcher.register_module(0x10, [&](const espp::stream_frame::Frame &f) { // your own protocol: f.type, f.is_reply(), f.payload }); diff --git a/components/ota/include/detail/ota_stream_protocol.hpp b/components/ota/include/detail/ota_stream_protocol.hpp index ae09104dfd..0324d7dd5a 100644 --- a/components/ota/include/detail/ota_stream_protocol.hpp +++ b/components/ota/include/detail/ota_stream_protocol.hpp @@ -20,7 +20,8 @@ // [magic u16 = 0x4F54 ("OT")][flags u8][module u8][type u8][len u32] // [payload: len bytes][crc32 u32] // -// OTA occupies module 0. Requests are host->device (flags reply bit = 0); +// OTA occupies module 0 by default (espp::OtaService::Config::module can move +// it). Requests are host->device (flags reply bit = 0); // replies are device->host (flags reply bit = 1). `type` alone identifies the // message. // @@ -88,7 +89,8 @@ using espp::stream_frame::StreamParser; /// (espp::OtaService::Config::module) builds its replies on that id. static constexpr uint8_t kModule = 0; -/// OTA stream protocol message types (the frame `type` field within module 0). +/// OTA stream protocol message types (the frame `type` field within the OTA +/// module, kModule by default). /// Requests are host->device (frame flag reply=0); replies are device->host /// (reply=1). `type` alone identifies the message; the reply flag is the generic /// direction hint. diff --git a/components/ota/test/ota_protocol_host_test.cpp b/components/ota/test/ota_protocol_host_test.cpp index d13c3673a6..3776980708 100644 --- a/components/ota/test/ota_protocol_host_test.cpp +++ b/components/ota/test/ota_protocol_host_test.cpp @@ -196,7 +196,7 @@ static void test_malformed_reply_payloads() { // Every builder takes an optional module id (default kModule) so a device that // serves OTA on another dispatcher id (OtaService::Config::module) stamps its -// replies -- and a host driving it its requests -- on that id. +// replies -- and a host driving it stamps its requests -- on that id. static void test_builders_take_a_module_id() { std::printf("test_builders_take_a_module_id\n"); constexpr uint8_t kOther = 0x20; diff --git a/doc/en/buses/canopen.rst b/doc/en/buses/canopen.rst index d33a685ba7..545bbbdf73 100644 --- a/doc/en/buses/canopen.rst +++ b/doc/en/buses/canopen.rst @@ -41,7 +41,7 @@ USB to CAN bridge The ``can_bridge_example`` (``components/canopen/can_bridge_example``) turns an ESP32-S3 into a WebUSB / Web Serial CAN interface: it bridges the ``Twai`` (CAN 2.0) controller to the host over USB using the ``stream_frame`` framing and -an :doc:`../dispatcher/index` (module id 5), so the hosted +an :doc:`../dispatcher/index` (module id 5 by default), so the hosted `CAN bridge console `_ web app can send frames (as a bus master) and inspect the bus (streaming every received frame, optionally in passive listen-only mode) directly from a Chromium browser. From e3d5d0e5f94f15b05668ed0666625d292385dab1 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Thu, 17 Sep 2026 22:00:21 -0500 Subject: [PATCH 3/4] refactor(coredump,telemetry): keep build() static with a module parameter; const module_ Review follow-ups on #799: CoreDumpService::build() and Telemetry::build() are static again, taking the module id to stamp (kModule by default) so existing callers compile unchanged; the services pass module_id() at every call site. CoreDumpService::module_ is const like Telemetry's. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- .../coredump/include/coredump_service.hpp | 26 ++++++++++++------- components/telemetry/include/telemetry.hpp | 25 +++++++++++------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/components/coredump/include/coredump_service.hpp b/components/coredump/include/coredump_service.hpp index 65edfc8f04..bd5ca21ace 100644 --- a/components/coredump/include/coredump_service.hpp +++ b/components/coredump/include/coredump_service.hpp @@ -297,7 +297,8 @@ class CoreDumpService : public BaseComponent { } reply = build(Msg::Summary, - std::span(reinterpret_cast(report.data()), count)); + std::span(reinterpret_cast(report.data()), count), + module_id()); return true; } case Msg::GetSize: { @@ -310,7 +311,7 @@ class CoreDumpService : public BaseComponent { logger_.info("GET_SIZE -> {} bytes", size); std::vector reply_payload; stream::put_u32(reply_payload, size); - reply = build(Msg::Size, reply_payload); + reply = build(Msg::Size, reply_payload, module_id()); return true; } case Msg::Read: { @@ -337,7 +338,7 @@ class CoreDumpService : public BaseComponent { return true; } logger_.debug("READ offset {} length {}", offset, length); - reply = build(Msg::Data, reply_payload); + reply = build(Msg::Data, reply_payload, module_id()); return true; } case Msg::Erase: { @@ -351,7 +352,7 @@ class CoreDumpService : public BaseComponent { logger_.info("ERASE ok"); std::vector reply_payload; stream::put_u32(reply_payload, 0); - reply = build(Msg::Ok, reply_payload); + reply = build(Msg::Ok, reply_payload, module_id()); return true; } default: @@ -361,15 +362,20 @@ class CoreDumpService : public BaseComponent { } } - /// Build an encoded frame for a core-dump protocol message type, on THIS - /// instance's module id (Config::module). - std::vector build(Msg type, std::span payload = {}) const { + /// Build an encoded frame for a core-dump protocol message type. + /// \param type The message type. + /// \param payload The payload bytes. + /// \param module The dispatcher module id to stamp (kModule by default; a + /// service instance passes its module_id() so replies follow + /// Config::module). + static std::vector build(Msg type, std::span payload = {}, + uint8_t module = kModule) { namespace stream = espp::stream_frame; // Reply message types (Summary/Size/Data/Ok/Error) carry the high bit; map // it to the frame reply flag so requests and replies are distinguishable // independent of the type value. const bool reply = (static_cast(type) & 0x80) != 0; - return stream::build_frame(reply, module_id(), static_cast(type), payload); + return stream::build_frame(reply, module, static_cast(type), payload); } /// Transmit an encoded reply frame via the configured send function. Must @@ -417,7 +423,7 @@ class CoreDumpService : public BaseComponent { --count; } payload.insert(payload.end(), message.begin(), message.begin() + count); - return build(Msg::Error, payload); + return build(Msg::Error, payload, module_id()); } /// Overload taking a std::errc directly. @@ -428,7 +434,7 @@ class CoreDumpService : public BaseComponent { private: CoreDump &core_dump_; send_fn send_; - uint8_t module_{kModule}; // Config::module: routing id for requests + replies + const uint8_t module_; // Config::module: routing id for requests + replies std::mutex mutex_; Stream parser_; }; diff --git a/components/telemetry/include/telemetry.hpp b/components/telemetry/include/telemetry.hpp index 5551c56ab8..0a59b1e86e 100644 --- a/components/telemetry/include/telemetry.hpp +++ b/components/telemetry/include/telemetry.hpp @@ -173,7 +173,7 @@ class Telemetry : public espp::BaseComponent { espp::stream_frame::put_u32(p, timestamp_us); for (float v : values) put_f32(p, v); - frame = build(Type::Sample, p); + frame = build(Type::Sample, p, module_id()); } s(std::span(frame)); // send while still holding send_mutex_ } @@ -197,7 +197,7 @@ class Telemetry : public espp::BaseComponent { if (!send_) return; // no transport yet; the next GetSchema will carry the new set s = send_; - frame = build(Type::Schema, build_schema_payload_locked()); + frame = build(Type::Schema, build_schema_payload_locked(), module_id()); } s(std::span(frame)); // send while still holding send_mutex_ } @@ -229,7 +229,7 @@ class Telemetry : public espp::BaseComponent { if (!send_) return; s = send_; - frame = build(Type::Schema, build_schema_payload_locked()); + frame = build(Type::Schema, build_schema_payload_locked(), module_id()); } s(std::span(frame)); // send while still holding send_mutex_ } @@ -365,7 +365,7 @@ class Telemetry : public espp::BaseComponent { void send_ok(uint8_t request_type) const { const uint8_t p[] = {request_type}; - send_frame(build(Type::Ok, p)); + send_frame(build(Type::Ok, p, module_id())); } void send_error(uint8_t request_type, std::string_view message) const { @@ -374,7 +374,7 @@ class Telemetry : public espp::BaseComponent { p.push_back(request_type); espp::stream_frame::put_u32(p, 0); // reserved code p.insert(p.end(), message.begin(), message.end()); - send_frame(build(Type::Error, p)); + send_frame(build(Type::Error, p, module_id())); } /// Transmit an already-built frame via the configured send function. Takes @@ -392,12 +392,17 @@ class Telemetry : public espp::BaseComponent { s(std::span(frame)); // send while still holding send_mutex_ } - /// Build an encoded frame for a telemetry message type, on THIS instance's - /// module id (Config::module). Device->host types (high bit set) map to the - /// frame reply flag. - std::vector build(Type type, std::span payload = {}) const { + /// Build an encoded frame for a telemetry message type. Device->host types + /// (high bit set) map to the frame reply flag. + /// \param type The message type. + /// \param payload The payload bytes. + /// \param module The dispatcher module id to stamp (kModule by default; the + /// emitter passes its module_id() so every outbound frame follows + /// Config::module). + static std::vector build(Type type, std::span payload = {}, + uint8_t module = kModule) { const bool reply = (static_cast(type) & 0x80) != 0; - return espp::stream_frame::build_frame(reply, module_id(), static_cast(type), payload); + return espp::stream_frame::build_frame(reply, module, static_cast(type), payload); } /// Append a little-endian IEEE-754 float32 to a byte buffer. From fa3b40c2f9fa0761bcb634c23d570c87b8777dd5 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 18 Sep 2026 13:23:57 -0500 Subject: [PATCH 4/4] docs(dispatcher,telemetry): show the static build(type, payload, module) helper; telemetry example says module 3 is the default Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU --- components/telemetry/example/README.md | 7 ++++--- .../example/main/telemetry_example.cpp | 10 +++++---- doc/en/dispatcher/custom_modules.rst | 21 ++++++++++++------- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/components/telemetry/example/README.md b/components/telemetry/example/README.md index 20727033f1..eb4feaae66 100644 --- a/components/telemetry/example/README.md +++ b/components/telemetry/example/README.md @@ -2,7 +2,7 @@ Streams synthetic float channels from an ESP32-S3 to the browser **Serial Plotter** web app over USB, using `espp::Telemetry` (a binary telemetry emitter -carried on the `stream_frame` framing, dispatcher module 3). +carried on the `stream_frame` framing, dispatcher module 3 by default). The hosted app — — connects on the **vendor (WebUSB)** interface, reads the channel **schema**, and @@ -15,8 +15,9 @@ device-timestamped counterpart to the app's text/CSV Web Serial transport. - A producer task emits one sample (a `float` per channel) every ~10 ms (100 Hz), timestamped with the device clock. - Exposes the stream over the USB **vendor (WebUSB)** interface; a `Dispatcher` - routes module-3 frames to the emitter and serves capability discovery so the - browser **Device Hub** lists this device and links to `telemetry.html`. + routes the emitter's module (`Telemetry::Config::module`, 3 by default — the + id the hosted Serial Plotter looks for) to it and serves capability discovery + so the browser **Device Hub** lists this device and links to `telemetry.html`. - The web app can pause/resume the stream and request a rate (`SET_STREAM`), and requests the schema on connect (`GET_SCHEMA`). diff --git a/components/telemetry/example/main/telemetry_example.cpp b/components/telemetry/example/main/telemetry_example.cpp index 75ac904758..905fee75df 100644 --- a/components/telemetry/example/main/telemetry_example.cpp +++ b/components/telemetry/example/main/telemetry_example.cpp @@ -2,7 +2,8 @@ // // Streams a few synthetic float channels from an ESP32-S3 to the browser over // USB using the espp::Telemetry emitter (a small binary protocol carried on the -// stream_frame framing, dispatcher module 3). The hosted `telemetry.html` +// stream_frame framing, dispatcher module 3 by default -- Config::module moves +// it, but the hosted web app looks for 3). The hosted `telemetry.html` // web app connects on the vendor (WebUSB) interface, reads the SCHEMA (channel // names), and plots the SAMPLE stream live — the binary, higher-rate, // device-timestamped counterpart to the app's text/CSV Web Serial transport. @@ -80,13 +81,14 @@ extern "C" void app_main(void) { }; telemetry.set_send(send); - // --- dispatcher worker: route module-3 frames to the telemetry service ------ + // --- dispatcher worker: route the telemetry module's frames to the service --- // RX bytes arrive in the TinyUSB task context; DispatcherWorker queues them - // and feeds its Dispatcher (module 3 -> telemetry, 0xFF -> discovery) from + // and feeds its Dispatcher (telemetry.module_id() -> telemetry, 0xFF -> + // discovery) from // its own task, so all frame parsing happens on one thread. espp::DispatcherWorker usb_link( {.send = send, .task_config = {.name = "telemetry_rx", .stack_size_bytes = 8192}}); - usb_link.register_module(telemetry); // module 3 + its discovery metadata + usb_link.register_module(telemetry); // its Config::module (3) + discovery metadata usb_link.serve_discovery(usb_cfg.product); usb.set_vendor_receive_callback([&](std::span data) { usb_link.push(data); }); diff --git a/doc/en/dispatcher/custom_modules.rst b/doc/en/dispatcher/custom_modules.rst index 606eacb142..ec53dddeec 100644 --- a/doc/en/dispatcher/custom_modules.rst +++ b/doc/en/dispatcher/custom_modules.rst @@ -224,16 +224,20 @@ like your own module will be. The pattern has six parts: .log_level = espp::Logger::Verbosity::INFO}); 4. **A `build()` helper** that wraps `stream_frame::build_frame()` so every - reply in the module goes through one place — and stamps *this instance's* - id (`module_id()`, part 6), never the `kModule` constant, so a moved - module's replies route back on the same id its requests arrived on: + reply in the module goes through one place. It is `static` and takes the + module id to stamp (defaulting to `kModule`, so a host-side test or a + caller without an instance can build frames on the default id); the + service's own call sites pass *this instance's* id (`module_id()`, part 6), + never the `kModule` constant, so a moved module's replies route back on the + same id its requests arrived on: .. code-block:: cpp - std::vector build(Msg type, std::span payload = {}) const { + static std::vector build(Msg type, std::span payload = {}, + uint8_t module = kModule) { namespace stream = espp::stream_frame; const bool reply = (static_cast(type) & 0x80) != 0; - return stream::build_frame(reply, module_id(), static_cast(type), payload); + return stream::build_frame(reply, module, static_cast(type), payload); } 5. **`feed()` / `handle_frame()` entry points** that parse (or accept an @@ -277,7 +281,7 @@ like your own module will be. The pattern has six parts: static_cast(std::min(raw_size, std::numeric_limits::max())); std::vector reply_payload; stream::put_u32(reply_payload, size); - reply = build(Msg::Size, reply_payload); + reply = build(Msg::Size, reply_payload, module_id()); // this instance's id return true; } @@ -410,7 +414,8 @@ parser, no mutex, because a handler this small can run straight out of the private: // 4. build(): every reply goes through stream_frame::build_frame(); the // Msg high bit (see the enum above) selects the reply flag, exactly - // like CoreDumpService::build(). Replies carry THIS instance's id. + // like CoreDumpService::build() (static there, taking the module id to + // stamp). Replies carry THIS instance's id. std::vector build(hello_module::Msg type, std::span payload) const { const bool reply = (static_cast(type) & 0x80) != 0; return espp::stream_frame::build_frame(reply, module_, static_cast(type), payload); @@ -514,7 +519,7 @@ keep payloads simple to parse on both the device (C++) and the browser espp::stream_frame::put_u32(p, timestamp_us); for (float v : values) put_f32(p, v); - frame = build(Type::Sample, p); + frame = build(Type::Sample, p, module_id()); On the browser side the matching read is one `DataView.getFloat32(offset, true)` call (the `true` selects little-endian) — no bit-reinterpretation