From bac571fd8f406ede8b984433b5342b56fbcb4a94 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 31 Aug 2026 21:55:19 -0500 Subject: [PATCH 01/13] feat(dispatcher): stream_frame codec + Dispatcher multiplexer; OTA rides them Extracts the frame codec that lived in ota/detail/ota_stream_protocol.hpp into a new dependency-free 'stream_frame' component (magic/type/len/crc framing, CRC-32, put/get helpers, incremental resynchronizing StreamParser; Frame::type is now a generic uint8_t). ota_stream_protocol.hpp becomes a thin facade that re-exports those symbols under the historical espp::detail::ota_stream namespace and keeps the OTA MessageType enum + make_*/parse_* helpers, so existing OTA (and the open coredump branch) keep compiling unchanged. Adds a new 'dispatcher' component: espp::Dispatcher parses one stream once and routes each frame to a per-module handler by the type byte's high-nibble module id (module_of = type >> 4). This lets several framed protocols share one USB / socket / UART link instead of running a StreamParser per protocol. The module id math is backward compatible with the deployed wire codes: OTA opcodes 0x0X/0x8X -> module 0, coredump 0x4X/0xCX -> module 4 (the 0x80 reply bit sits in the high nibble, so requests and replies map to disjoint module ids and a device-side dispatcher only ever sees the request modules). The ota example now feeds a Dispatcher with OTA registered on module 0 (instead of a bare StreamParser), demonstrating the pattern and cleanly ignoring other protocols' frames rather than replying 'unknown message type'. Host tests: components/ota/test (codec, updated for uint8_t Frame::type) and a new components/dispatcher/test (routing / coexistence / reset) both pass. ota example builds clean (esp32s3). Docs + Doxygen inputs added for both components. The ota + dispatcher manifests point espp/stream_frame at the local source via override_path until it is published (mirroring lilygo-t5-47's bq27220/pca9535). Co-Authored-By: Claude Fable 5 --- components/dispatcher/CMakeLists.txt | 6 + components/dispatcher/README.md | 48 ++++ components/dispatcher/idf_component.yml | 24 ++ components/dispatcher/include/dispatcher.hpp | 109 +++++++++ .../dispatcher/test/dispatcher_host_test.cpp | 110 +++++++++ components/ota/CMakeLists.txt | 4 +- components/ota/example/CMakeLists.txt | 4 +- components/ota/example/main/CMakeLists.txt | 2 +- components/ota/example/main/ota_example.cpp | 21 +- components/ota/idf_component.yml | 5 + .../include/detail/ota_stream_protocol.hpp | 223 ++++-------------- .../ota/test/ota_protocol_host_test.cpp | 32 +-- components/stream_frame/CMakeLists.txt | 5 + components/stream_frame/README.md | 45 ++++ components/stream_frame/idf_component.yml | 20 ++ .../stream_frame/include/stream_frame.hpp | 216 +++++++++++++++++ doc/Doxyfile | 2 + doc/en/dispatcher/dispatcher.rst | 51 ++++ doc/en/dispatcher/index.rst | 13 + doc/en/index.rst | 2 + doc/en/ota/ota.rst | 7 + doc/en/stream_frame/index.rst | 14 ++ doc/en/stream_frame/stream_frame.rst | 34 +++ 23 files changed, 790 insertions(+), 207 deletions(-) create mode 100644 components/dispatcher/CMakeLists.txt create mode 100644 components/dispatcher/README.md create mode 100644 components/dispatcher/idf_component.yml create mode 100644 components/dispatcher/include/dispatcher.hpp create mode 100644 components/dispatcher/test/dispatcher_host_test.cpp create mode 100644 components/stream_frame/CMakeLists.txt create mode 100644 components/stream_frame/README.md create mode 100644 components/stream_frame/idf_component.yml create mode 100644 components/stream_frame/include/stream_frame.hpp create mode 100644 doc/en/dispatcher/dispatcher.rst create mode 100644 doc/en/dispatcher/index.rst create mode 100644 doc/en/stream_frame/index.rst create mode 100644 doc/en/stream_frame/stream_frame.rst diff --git a/components/dispatcher/CMakeLists.txt b/components/dispatcher/CMakeLists.txt new file mode 100644 index 000000000..1666a6191 --- /dev/null +++ b/components/dispatcher/CMakeLists.txt @@ -0,0 +1,6 @@ +# Header-only frame router. Depends only on the stream_frame codec (both are +# dependency-free, so this also builds on a host). +idf_component_register( + INCLUDE_DIRS "include" + REQUIRES stream_frame +) diff --git a/components/dispatcher/README.md b/components/dispatcher/README.md new file mode 100644 index 000000000..7de916b78 --- /dev/null +++ b/components/dispatcher/README.md @@ -0,0 +1,48 @@ +# Dispatcher + +`espp::Dispatcher` multiplexes several independent framed protocols over a +single byte stream. It parses the `stream_frame` codec **once** and routes each +complete frame to a per-module handler by the message-type byte's high-nibble +module id — so OTA, crash-dump inspection, a CAN bridge and an application's own +control channel can share one USB vendor / CDC / socket / UART link without +interfering. Header-only and dependency-free (only `stream_frame` + the standard +library), so it also builds and unit-tests on a host. + +Rather than run a separate `StreamParser` per protocol over the same bytes (each +re-buffering the whole stream and needing its own reset-on-overflow +bookkeeping), the Dispatcher owns the one parser and dispatches by module id. + +## Module id convention + +The module id is the high nibble of the message-type byte (`type >> 4`). espp +built-in protocols place their request opcodes so each protocol occupies one +high nibble, and use bit 7 to mark device→host replies: + +| Module id | Protocol | Requests | Replies | +|-----------|------------|----------|-----------------| +| 0 | OTA | `0x0X` | `0x8X` | +| 4 | crash dump | `0x4X` | `0xCX` | +| 5 | CAN bridge | `0x5X` | `0xDX` (example)| + +A device-side dispatcher registers the request modules (0, 4, 5, ...); it never +receives reply-typed frames (high nibble 8..15), and if one arrives it lands on +an unregistered module and is ignored, so requests and replies of one protocol +can never be confused. Application code may assign any unused module id to its +own protocol. Frames for an unregistered module are silently ignored. + +## API + +- `void register_module(uint8_t module_id, handler_fn handler)` / + `void unregister_module(uint8_t module_id)` / `bool has_module(uint8_t)` + where `handler_fn = std::function payload)>`. +- `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). +- `static constexpr uint8_t module_of(uint8_t type)`, `buffered()`, `dropped_bytes()`. + +## Host tests + +``` +c++ -std=c++20 -Werror -I components/dispatcher/include -I components/stream_frame/include \ + components/dispatcher/test/dispatcher_host_test.cpp -o test && ./test +``` diff --git a/components/dispatcher/idf_component.yml b/components/dispatcher/idf_component.yml new file mode 100644 index 000000000..aad1f8b33 --- /dev/null +++ b/components/dispatcher/idf_component.yml @@ -0,0 +1,24 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "Multiplex several framed protocols (OTA, crash-dump, CAN bridge, app control, ...) over one byte stream: parses the stream_frame codec once and routes each frame to a per-module handler by the type byte's high-nibble module id" +url: "https://github.com/esp-cpp/espp/tree/main/components/dispatcher" +repository: "https://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/dispatcher/dispatcher.html" +tags: + - cpp + - Component + - Protocol + - Multiplex + - Router + - Framing + - USB +dependencies: + idf: + version: '>=5.0' + # stream_frame is a new espp component not published to the registry yet; + # point at the local source until it is released. + espp/stream_frame: + version: '*' + override_path: '../stream_frame' diff --git a/components/dispatcher/include/dispatcher.hpp b/components/dispatcher/include/dispatcher.hpp new file mode 100644 index 000000000..f11b42366 --- /dev/null +++ b/components/dispatcher/include/dispatcher.hpp @@ -0,0 +1,109 @@ +#pragma once + +// espp::Dispatcher — multiplex several independent framed protocols over a +// single byte stream, routing each frame to a handler by its "module id". +// +// A USB vendor / CDC / socket link often needs to carry more than one protocol +// at once: firmware update (OTA), crash-dump inspection, a CAN bridge, an +// application's own control channel, ... They all ride the same +// espp::stream_frame framing (magic / type / len / crc). Rather than run a +// separate StreamParser per protocol over the same bytes (each re-buffering the +// whole stream and needing its own reset-on-overflow bookkeeping), a Dispatcher +// parses the stream ONCE and routes each complete frame to the handler +// registered for its module id. +// +// Module id convention +// -------------------- +// The module id is the high nibble of the message-type byte: `type >> 4`. +// espp built-in protocols place their request opcodes so each protocol occupies +// one high nibble, and use bit 7 to mark device->host replies: +// +// module 0 OTA requests 0x0X replies 0x8X +// module 4 crash dump requests 0x4X replies 0xCX +// module 5 CAN bridge requests 0x5X replies 0xDX (example) +// +// A DEVICE-side dispatcher registers the request modules (0, 4, 5, ...); the +// reply-typed frames (high nibble 8..15) it never receives, and if one does +// arrive it lands on an unregistered module and is ignored — so requests and +// replies of the same protocol can never be confused. A HOST-side dispatcher +// (if used) would instead register the reply high nibbles. Application code is +// free to assign any unused module id to its own protocol; nothing here is +// hard-wired to a specific service. +// +// Header-only and dependency-free (only espp::stream_frame + the standard +// library), so it builds and unit-tests on a host. + +#include +#include +#include +#include + +#include "stream_frame.hpp" + +namespace espp { + +/// @brief Routes framed messages from one byte stream to per-module handlers. +class Dispatcher { +public: + /// Number of routable modules (one per value of the type byte's high nibble). + static constexpr uint8_t kNumModules = 16; + + /// @brief Handler invoked for every frame whose module id was registered. + /// @param type The full message-type byte (module id in the high nibble). + /// @param payload The frame payload bytes (valid only for the call). + using handler_fn = std::function payload)>; + + /// @brief The module id of a message-type byte (its high nibble). + static constexpr uint8_t module_of(uint8_t type) { return static_cast(type >> 4); } + + /// @brief Register (or replace) the handler for a module id (0..kNumModules-1). + /// @param module_id High-nibble module id to route to @p handler. + /// @param handler Callback for frames with this module id (null unregisters). + void register_module(uint8_t module_id, handler_fn handler) { + if (module_id < kNumModules) + handlers_[module_id] = std::move(handler); + } + + /// @brief Remove the handler for a module id (frames for it become ignored). + void unregister_module(uint8_t module_id) { + if (module_id < kNumModules) + handlers_[module_id] = nullptr; + } + + /// @brief Whether a handler is registered for a module id. + bool has_module(uint8_t module_id) const { + return module_id < kNumModules && static_cast(handlers_[module_id]); + } + + /// @brief Feed raw received bytes: parse and route each complete frame to its + /// module's handler. Frames whose module has no handler are ignored + /// (so unrelated protocols on the same stream are harmless). + /// @param data Any number of received bytes (frames may be split or batched). + void feed(std::span data) { + for (const auto &frame : parser_.feed(data)) + dispatch(frame); + } + + /// @brief Route an already-parsed frame (for callers running their own parser). + void dispatch(const stream_frame::Frame &frame) { + const uint8_t module_id = module_of(frame.type); + if (module_id < kNumModules && handlers_[module_id]) + handlers_[module_id](frame.type, frame.payload); + } + + /// @brief Discard any partially-buffered frame bytes (transport reconnect or + /// RX overflow) so a frame straddling the gap resynchronizes at once. + void reset() { parser_.reset(); } + + /// @brief Bytes buffered awaiting frame completion. + size_t buffered() const { return parser_.buffered(); } + + /// @brief Total bytes discarded while resynchronizing (diagnostics). + size_t dropped_bytes() const { return parser_.dropped_bytes(); } + +private: + stream_frame::StreamParser parser_; + std::array handlers_{}; +}; + +} // namespace espp diff --git a/components/dispatcher/test/dispatcher_host_test.cpp b/components/dispatcher/test/dispatcher_host_test.cpp new file mode 100644 index 000000000..6420212e5 --- /dev/null +++ b/components/dispatcher/test/dispatcher_host_test.cpp @@ -0,0 +1,110 @@ +// Host-buildable unit tests for espp::Dispatcher. Build & run with: +// c++ -std=c++20 -Werror -I components/dispatcher/include \ +// -I components/stream_frame/include \ +// components/dispatcher/test/dispatcher_host_test.cpp -o test && ./test +// +// No ESP-IDF headers required. + +#include +#include +#include +#include + +#include "dispatcher.hpp" +#include "stream_frame.hpp" + +namespace sf = espp::stream_frame; + +static int g_failures = 0; +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf(" FAIL: %s (line %d)\n", #cond, __LINE__); \ + ++g_failures; \ + } \ + } while (0) + +static void test_module_of() { + std::printf("test_module_of\n"); + // High nibble is the module id; the 0x80 reply bit lives in the high nibble + // too, so requests and replies of one protocol map to *different* module ids + // (device sees the request nibble; host would see the reply nibble). + CHECK(espp::Dispatcher::module_of(0x01) == 0); // OTA BEGIN + CHECK(espp::Dispatcher::module_of(0x04) == 0); // OTA ABORT + CHECK(espp::Dispatcher::module_of(0x40) == 4); // coredump GET_SUMMARY + CHECK(espp::Dispatcher::module_of(0x43) == 4); // coredump ERASE + CHECK(espp::Dispatcher::module_of(0x50) == 5); // CAN bridge (example) + CHECK(espp::Dispatcher::module_of(0x81) == 8); // OTA OK reply + CHECK(espp::Dispatcher::module_of(0xC2) == 12); // coredump DATA reply +} + +static void test_routing_and_coexistence() { + std::printf("test_routing_and_coexistence\n"); + espp::Dispatcher d; + std::vector mod0_types, mod4_types, mod5_types; + d.register_module(0, [&](uint8_t t, std::span) { mod0_types.push_back(t); }); + d.register_module(4, [&](uint8_t t, std::span p) { + mod4_types.push_back(t); + // echo payload length check for one case below + if (t == 0x42) + CHECK(p.size() == 3); + }); + d.register_module(5, [&](uint8_t t, std::span) { mod5_types.push_back(t); }); + CHECK(d.has_module(0) && d.has_module(4) && d.has_module(5)); + CHECK(!d.has_module(1) && !d.has_module(12)); + + // Interleave three protocols' frames on one stream, plus one frame for an + // UNREGISTERED module (must be silently ignored). + std::vector stream; + auto add = [&](const std::vector &f) { + stream.insert(stream.end(), f.begin(), f.end()); + }; + const uint8_t p3[] = {1, 2, 3}; + add(sf::build_frame(0x01)); // module 0 + add(sf::build_frame(0x50)); // module 5 + add(sf::build_frame(0x42, p3)); // module 4, 3-byte payload + add(sf::build_frame(0x20)); // module 2 — unregistered, ignored + add(sf::build_frame(0x02)); // module 0 + + d.feed(stream); + CHECK(mod0_types.size() == 2); + CHECK(mod0_types.size() == 2 && mod0_types[0] == 0x01 && mod0_types[1] == 0x02); + CHECK(mod4_types.size() == 1 && mod4_types[0] == 0x42); + CHECK(mod5_types.size() == 1 && mod5_types[0] == 0x50); + CHECK(d.dropped_bytes() == 0); +} + +static void test_unregister_and_reset() { + std::printf("test_unregister_and_reset\n"); + espp::Dispatcher d; + int count = 0; + d.register_module(0, [&](uint8_t, std::span) { ++count; }); + d.feed(sf::build_frame(0x01)); + CHECK(count == 1); + d.unregister_module(0); + d.feed(sf::build_frame(0x02)); + CHECK(count == 1); // no longer routed + + // reset() drops a partially-buffered frame so a stale prefix cannot stitch + // onto later bytes. + d.register_module(0, [&](uint8_t, std::span) { ++count; }); + auto frame = sf::build_frame(0x03); + d.feed(std::span(frame.data(), 3)); // partial (header only) + CHECK(d.buffered() > 0); + d.reset(); + CHECK(d.buffered() == 0); + d.feed(std::span(frame.data() + 3, frame.size() - 3)); // remainder alone + CHECK(count == 1); // the split frame did NOT complete after reset +} + +int main() { + test_module_of(); + test_routing_and_coexistence(); + test_unregister_and_reset(); + if (g_failures == 0) { + std::printf("ALL TESTS PASSED\n"); + return 0; + } + std::printf("%d FAILURE(S)\n", g_failures); + return 1; +} diff --git a/components/ota/CMakeLists.txt b/components/ota/CMakeLists.txt index d7c66e03b..b38f79b2a 100644 --- a/components/ota/CMakeLists.txt +++ b/components/ota/CMakeLists.txt @@ -3,11 +3,13 @@ # alone makes `#include "detail/ota_stream_protocol.hpp"` resolve for consumers. # # REQUIRES are public since ota.hpp (header-only) includes their headers: +# stream_frame — stream_frame.hpp (the frame codec that detail/ +# ota_stream_protocol.hpp re-exports and layers OTA on) # app_update — esp_ota_ops.h # esp_app_format — esp_app_desc.h (esp_app_desc_t) # bootloader_support — esp_app_format.h (esp_image_header_t / magic 0xE9) # esp_partition — esp_partition.h idf_component_register( INCLUDE_DIRS "include" - REQUIRES base_component app_update esp_app_format bootloader_support esp_partition + REQUIRES base_component stream_frame app_update esp_app_format bootloader_support esp_partition ) diff --git a/components/ota/example/CMakeLists.txt b/components/ota/example/CMakeLists.txt index 0935ec140..1492a344c 100644 --- a/components/ota/example/CMakeLists.txt +++ b/components/ota/example/CMakeLists.txt @@ -16,10 +16,12 @@ include($ENV{IDF_PATH}/tools/cmake/project.cmake) set(EXTRA_COMPONENT_DIRS "../../../components/base_component" "../../../components/cli" + "../../../components/dispatcher" "../../../components/format" "../../../components/logger" "../../../components/nvs" "../../../components/ota" + "../../../components/stream_frame" "../../../components/task" "../../../components/usb_device" "../../../components/wifi" @@ -27,7 +29,7 @@ set(EXTRA_COMPONENT_DIRS set( COMPONENTS - "main esptool_py base_component cli format logger nvs ota task usb_device wifi esp_tinyusb" + "main esptool_py base_component cli dispatcher format logger nvs ota stream_frame task usb_device wifi esp_tinyusb" CACHE STRING "List of components to include" ) diff --git a/components/ota/example/main/CMakeLists.txt b/components/ota/example/main/CMakeLists.txt index c8ddda153..dfe9b0a29 100644 --- a/components/ota/example/main/CMakeLists.txt +++ b/components/ota/example/main/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( SRC_DIRS "." INCLUDE_DIRS "." - REQUIRES ota task usb_device wifi esp_http_server nvs_flash esp_tinyusb + REQUIRES ota dispatcher task usb_device wifi esp_http_server nvs_flash esp_tinyusb ) diff --git a/components/ota/example/main/ota_example.cpp b/components/ota/example/main/ota_example.cpp index fad99375a..b6c3eeeab 100644 --- a/components/ota/example/main/ota_example.cpp +++ b/components/ota/example/main/ota_example.cpp @@ -18,6 +18,7 @@ #include "nvs_flash.h" #include "detail/ota_stream_protocol.hpp" +#include "dispatcher.hpp" #include "logger.hpp" #include "ota.hpp" #include "task.hpp" @@ -262,7 +263,12 @@ extern "C" void app_main(void) { if (!usb.initialize(usb_ec)) logger.error("Failed to initialize USB device: {}", usb_ec.message()); - proto::StreamParser parser; + // Route the vendor stream through a Dispatcher: OTA occupies module id 0 (its + // opcodes are 0x0X). Other protocols (e.g. a crash-dump service on module 4) + // could register alongside on the same stream and would be routed + // independently; frames for unregistered modules are ignored rather than + // mis-handled as malformed OTA frames. + espp::Dispatcher dispatcher; bool restart_pending = false; // The OTA engine serializes sessions across ALL transports, but that alone // is not enough here: without ownership tracking a USB DATA/FINISH/ABORT @@ -278,7 +284,7 @@ extern "C" void app_main(void) { usb.write_vendor( proto::make_error(static_cast(err.value()), context + ": " + err.message())); }; - switch (frame.type) { + switch (static_cast(frame.type)) { case proto::MessageType::Begin: { const auto image_size = proto::parse_u32_payload(frame); if (!image_size.has_value()) { @@ -343,6 +349,12 @@ extern "C" void app_main(void) { } }; + // OTA is module id 0. The Dispatcher hands us (type, payload); rebuild a Frame + // for the existing handler. + dispatcher.register_module(0, [&](uint8_t type, std::span payload) { + handle_usb_frame(proto::Frame{type, std::vector(payload.begin(), payload.end())}); + }); + espp::Task usb_task( {.callback = [&](std::mutex &, std::condition_variable &) -> bool { std::vector> chunks; @@ -367,7 +379,7 @@ extern "C" void app_main(void) { ota.abort(abort_ec); usb_owns_session = false; } - parser.reset(); + dispatcher.reset(); usb.write_vendor(proto::make_error( static_cast(std::make_error_code(std::errc::no_buffer_space).value()), "RX overflow: frames dropped; transfer aborted -- wait for OK " @@ -375,8 +387,7 @@ extern "C" void app_main(void) { return false; // dropped chunks are gone; skip parse } for (const auto &chunk : chunks) - for (const auto &frame : parser.feed(chunk)) - handle_usb_frame(frame); + dispatcher.feed(chunk); if (restart_pending) { // give the final OK reply time to reach the host std::this_thread::sleep_for(750ms); diff --git a/components/ota/idf_component.yml b/components/ota/idf_component.yml index b8eeafba1..c510bc339 100644 --- a/components/ota/idf_component.yml +++ b/components/ota/idf_component.yml @@ -24,3 +24,8 @@ dependencies: idf: version: '>=5.0' espp/base_component: '>=1.0' + # stream_frame is a new espp component not published to the registry yet; + # point at the local source until it is released. + espp/stream_frame: + version: '*' + override_path: '../stream_frame' diff --git a/components/ota/include/detail/ota_stream_protocol.hpp b/components/ota/include/detail/ota_stream_protocol.hpp index 8e4118f81..dc98d7e3b 100644 --- a/components/ota/include/detail/ota_stream_protocol.hpp +++ b/components/ota/include/detail/ota_stream_protocol.hpp @@ -2,30 +2,23 @@ // espp OTA stream protocol — wire framing for OTA over a raw byte stream. // -// This header is intentionally free of any ESP-IDF / FreeRTOS dependency so -// that the framing logic (CRC-32, frame building, incremental parsing with -// resynchronization) can be built and unit-tested on a host with nothing more -// than a C++20 standard library. The espp `ota` example composes this framing -// with `espp::Ota` and `espp::UsbDevice` to stream firmware over the USB -// vendor (WebUSB) interface; the `ota_console.html` web app implements the +// The generic frame codec (magic / type / len / crc, CRC-32, incremental +// resynchronizing StreamParser) now lives in the dependency-free +// `stream_frame` component (espp::stream_frame). This header layers the +// OTA-specific message-type enum, frame builders (make_*) and payload parsers +// (parse_*) on top, and re-exports the generic pieces under the historical +// `espp::detail::ota_stream` namespace so existing OTA/host code keeps working. +// +// Like stream_frame it stays free of any ESP-IDF / FreeRTOS dependency, so the +// framing builds and unit-tests on a host. The espp `ota` example composes it +// with `espp::Ota`, `espp::Dispatcher` and `espp::UsbDevice` to stream firmware +// over the USB vendor (WebUSB) interface; `ota_console.html` implements the // exact same framing in JavaScript. // // Wire format (all multi-byte fields LITTLE-ENDIAN): // // [magic u16 = 0x4F54 ("OT")][type u8][len u32][payload: len bytes][crc32 u32] // -// - magic: the u16 value 0x4F54 ("OT"), transmitted little-endian, so the -// raw byte sequence on the wire is 0x54 ('T') then 0x4F ('O'). -// - type: one of the MessageType values below. -// - len: payload length in bytes; MUST be <= kMaxPayloadSize (4096). The -// parser rejects (and resynchronizes past) any frame whose length field -// exceeds this cap, so a remote-supplied length can never cause unbounded -// buffering. -// - crc32: standard zlib CRC-32 (IEEE 802.3: polynomial 0xEDB88320 -// reflected, init 0xFFFFFFFF, final xor 0xFFFFFFFF) computed over -// magic..payload, i.e. the kHeaderSize (7) header bytes plus the payload. -// Golden check value: crc32("123456789") == 0xCBF43926. -// // Message types & payloads (host -> device): // 0x01 BEGIN — payload: u32 image_size (0 = unknown / streaming). // 0x02 DATA — payload: raw image bytes (1..kMaxPayloadSize per frame). @@ -33,45 +26,47 @@ // 0x04 ABORT — no payload. Discards the in-progress session. // // Message types & payloads (device -> host): -// 0x81 OK — payload: u32 bytes_received so far. Sent in reply to each -// successfully-handled BEGIN / DATA / FINISH / ABORT. +// 0x81 OK — payload: u32 bytes_received so far. // 0x82 ERROR — payload: u32 code followed by a UTF-8 message. -// 0x83 PROGRESS — payload: u32 written, u32 total (0 if unknown). Optional, -// informational; hosts must tolerate (and may ignore) it. +// 0x83 PROGRESS — payload: u32 written, u32 total (0 if unknown). Optional. +// +// The OTA opcodes occupy module id 0 (high nibble 0); see espp::Dispatcher. // -// Flow control: the host serializes transactions — it sends one frame and -// waits for the matching OK / ERROR reply before sending the next — so the -// device never needs to buffer more than one frame of image data. +// Flow control: the host serializes transactions — it sends one frame and waits +// for the matching OK / ERROR reply before sending the next — so the device +// never needs to buffer more than one frame of image data. #include #include -#include #include #include #include #include #include +#include "stream_frame.hpp" + namespace espp { namespace detail { namespace ota_stream { -/// Frame magic: the u16 value 0x4F54 ("OT"); little-endian on the wire, so the -/// first frame byte is 0x54 ('T') and the second is 0x4F ('O'). -static constexpr uint16_t kMagic = 0x4F54; -/// First (low) magic byte on the wire. -static constexpr uint8_t kMagicByte0 = static_cast(kMagic & 0xFF); // 0x54 'T' -/// Second (high) magic byte on the wire. -static constexpr uint8_t kMagicByte1 = static_cast(kMagic >> 8); // 0x4F 'O' -/// Frame header size: magic (2) + type (1) + len (4). -static constexpr size_t kHeaderSize = 7; -/// Trailing CRC-32 size. -static constexpr size_t kCrcSize = 4; -/// Maximum payload bytes per frame. Frames whose length field exceeds this are -/// rejected and resynchronized past, bounding parser memory usage. -static constexpr size_t kMaxPayloadSize = 4096; -/// Maximum total encoded frame size (header + payload + crc) = 4107 bytes. -static constexpr size_t kMaxFrameSize = kHeaderSize + kMaxPayloadSize + kCrcSize; +// --- Re-export the generic stream_frame codec under the historical name ------ +using espp::stream_frame::kCrcSize; +using espp::stream_frame::kHeaderSize; +using espp::stream_frame::kMagic; +using espp::stream_frame::kMagicByte0; +using espp::stream_frame::kMagicByte1; +using espp::stream_frame::kMaxFrameSize; +using espp::stream_frame::kMaxPayloadSize; + +using espp::stream_frame::crc32; +using espp::stream_frame::get_u16; +using espp::stream_frame::get_u32; +using espp::stream_frame::put_u16; +using espp::stream_frame::put_u32; + +using espp::stream_frame::Frame; +using espp::stream_frame::StreamParser; /// OTA stream protocol message types. enum class MessageType : uint8_t { @@ -84,64 +79,12 @@ enum class MessageType : uint8_t { Progress = 0x83, ///< device -> host: optional progress (payload: u32 written, u32 total) }; -/// @brief Standard zlib CRC-32 (IEEE 802.3; poly 0xEDB88320 reflected, init -/// 0xFFFFFFFF, final xor 0xFFFFFFFF). -/// @param data Bytes to checksum. -/// @param crc Running CRC from a previous call (0 to start, matching zlib's -/// crc32(0, ...) convention); chainable across chunks. -/// @return The CRC-32 of the concatenated input. -/// @note Golden check value: crc32 over the ASCII bytes "123456789" == 0xCBF43926. -inline uint32_t crc32(std::span data, uint32_t crc = 0) { - crc = ~crc; - for (const uint8_t byte : data) { - crc ^= byte; - for (int bit = 0; bit < 8; bit++) - crc = (crc & 1u) ? ((crc >> 1) ^ 0xEDB88320u) : (crc >> 1); - } - return ~crc; -} - -/// Append a u16 little-endian to a byte vector. -inline void put_u16(std::vector &out, uint16_t value) { - out.push_back(static_cast(value & 0xFF)); - out.push_back(static_cast((value >> 8) & 0xFF)); -} - -/// Append a u32 little-endian to a byte vector. -inline void put_u32(std::vector &out, uint32_t value) { - out.push_back(static_cast(value & 0xFF)); - out.push_back(static_cast((value >> 8) & 0xFF)); - out.push_back(static_cast((value >> 16) & 0xFF)); - out.push_back(static_cast((value >> 24) & 0xFF)); -} - -/// Read a u32 little-endian from a byte span (span must hold >= 4 bytes). -inline uint32_t get_u32(std::span bytes) { - return static_cast(bytes[0]) | (static_cast(bytes[1]) << 8) | - (static_cast(bytes[2]) << 16) | (static_cast(bytes[3]) << 24); -} - -/// @brief A complete, CRC-verified protocol frame. -struct Frame { - MessageType type; ///< Message type byte (unknown values are passed through). - std::vector payload; ///< Payload bytes (may be empty). -}; - -/// @brief Build an encoded frame: header + payload + CRC-32 over magic..payload. -/// @param type Message 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. /// @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 = {}) { - if (payload.size() > kMaxPayloadSize) - return {}; - std::vector out; - out.reserve(kHeaderSize + payload.size() + kCrcSize); - put_u16(out, kMagic); - out.push_back(static_cast(type)); - put_u32(out, static_cast(payload.size())); - out.insert(out.end(), payload.begin(), payload.end()); - put_u32(out, crc32(std::span(out.data(), out.size()))); - return out; + return espp::stream_frame::build_frame(static_cast(type), payload); } /// Build a BEGIN frame (image_size in bytes, 0 = unknown / streaming). @@ -229,92 +172,6 @@ inline std::optional parse_progress(const Frame &frame) { return info; } -/// @brief Incremental frame parser for the OTA stream protocol. -/// -/// Feed arbitrary chunks of received bytes (USB bulk transfers, socket reads, -/// single bytes, ...) and it yields the complete, CRC-verified frames they -/// contain. On a bad magic, an oversized length field (> kMaxPayloadSize) or a -/// CRC mismatch it resynchronizes by discarding bytes until the next plausible -/// frame start, so a corrupted stream recovers at the next intact frame. -/// -/// Buffering is bounded: because the length field is capped, the parser never -/// retains more than kMaxFrameSize bytes between feed() calls (plus at most the -/// chunk currently being processed), so a remote-supplied length cannot cause -/// memory exhaustion. -class StreamParser { -public: - /// @brief Feed received bytes to the parser. - /// @param data Any number of bytes (frames may be split or batched arbitrarily). - /// @return All complete, CRC-verified frames terminated by these bytes, in order. - std::vector feed(std::span data) { - buffer_.insert(buffer_.end(), data.begin(), data.end()); - std::vector frames; - size_t pos = 0; - while (true) { - pos = find_frame_start(pos); - if (buffer_.size() - pos < kHeaderSize) - break; // incomplete header; wait for more bytes - const auto header = std::span(buffer_).subspan(pos); - const uint32_t len = get_u32(header.subspan(3)); - if (len > kMaxPayloadSize) { - // Reject a remote-supplied length that exceeds the cap and resync one - // byte past this (bogus) frame start. - dropped_bytes_++; - pos++; - continue; - } - const size_t total = kHeaderSize + len + kCrcSize; - if (buffer_.size() - pos < total) - break; // incomplete frame; wait for more bytes - const uint32_t expected = get_u32(header.subspan(kHeaderSize + len)); - const uint32_t actual = crc32(header.first(kHeaderSize + len)); - if (actual != expected) { - // Corrupt frame; resync one byte past this frame start. - dropped_bytes_++; - pos++; - continue; - } - Frame frame{}; - frame.type = static_cast(header[2]); - frame.payload.assign(header.begin() + kHeaderSize, header.begin() + kHeaderSize + len); - frames.push_back(std::move(frame)); - pos += total; - } - buffer_.erase(buffer_.begin(), buffer_.begin() + pos); - return frames; - } - - /// Discard all buffered bytes (e.g. on transport reconnect). - void reset() { buffer_.clear(); } - - /// Number of bytes currently buffered awaiting frame completion. - size_t buffered() const { return buffer_.size(); } - - /// Total bytes discarded so far while resynchronizing (diagnostics). - size_t dropped_bytes() const { return dropped_bytes_; } - -protected: - /// Advance @p pos to the next plausible frame start (the magic byte pair), - /// counting the skipped bytes as dropped. A trailing lone kMagicByte0 is kept - /// (it may be the first half of a magic split across chunks). - size_t find_frame_start(size_t pos) { - const size_t start = pos; - while (pos < buffer_.size()) { - if (buffer_[pos] == kMagicByte0) { - if (pos + 1 >= buffer_.size() || buffer_[pos + 1] == kMagicByte1) - break; // found (or possibly-split) magic - } - pos++; - } - dropped_bytes_ += pos - start; - return pos; - } - -private: - std::vector buffer_; - size_t dropped_bytes_{0}; -}; - } // namespace ota_stream } // namespace detail } // namespace espp diff --git a/components/ota/test/ota_protocol_host_test.cpp b/components/ota/test/ota_protocol_host_test.cpp index 2472af919..a9e1ea9dd 100644 --- a/components/ota/test/ota_protocol_host_test.cpp +++ b/components/ota/test/ota_protocol_host_test.cpp @@ -1,6 +1,6 @@ // Host-buildable unit tests for the espp OTA stream protocol framing. Build & // run with: -// c++ -std=c++20 -Werror -I components/ota/include \ +// c++ -std=c++20 -Werror -I components/ota/include -I components/stream_frame/include \ // components/ota/test/ota_protocol_host_test.cpp -o test && ./test // // These tests exercise espp::detail::ota_stream directly so they need no @@ -90,23 +90,23 @@ static void test_round_trip_all_types() { if (frames.size() != 7) return; - CHECK(frames[0].type == MessageType::Begin); + CHECK(frames[0].type == static_cast(MessageType::Begin)); CHECK(ota::parse_u32_payload(frames[0]).value_or(0) == 1234567u); - CHECK(frames[1].type == MessageType::Data); + CHECK(frames[1].type == static_cast(MessageType::Data)); CHECK(frames[1].payload.size() == sizeof(image_bytes)); CHECK(std::memcmp(frames[1].payload.data(), image_bytes, sizeof(image_bytes)) == 0); - CHECK(frames[2].type == MessageType::Finish); + CHECK(frames[2].type == static_cast(MessageType::Finish)); CHECK(frames[2].payload.empty()); - CHECK(frames[3].type == MessageType::Abort); + CHECK(frames[3].type == static_cast(MessageType::Abort)); CHECK(frames[3].payload.empty()); - CHECK(frames[4].type == MessageType::Ok); + CHECK(frames[4].type == static_cast(MessageType::Ok)); CHECK(ota::parse_u32_payload(frames[4]).value_or(0) == 6u); - CHECK(frames[5].type == MessageType::Error); + CHECK(frames[5].type == static_cast(MessageType::Error)); const auto err = ota::parse_error(frames[5]); CHECK(err.has_value()); if (err.has_value()) { @@ -114,7 +114,7 @@ static void test_round_trip_all_types() { CHECK(err->message == "flash write failed"); } - CHECK(frames[6].type == MessageType::Progress); + CHECK(frames[6].type == static_cast(MessageType::Progress)); const auto prog = ota::parse_progress(frames[6]); CHECK(prog.has_value()); if (prog.has_value()) { @@ -140,9 +140,9 @@ static void test_split_across_chunks() { CHECK(frames.size() == 2); CHECK(parser.dropped_bytes() == 0); if (frames.size() == 2) { - CHECK(frames[0].type == MessageType::Data); + CHECK(frames[0].type == static_cast(MessageType::Data)); CHECK(frames[0].payload.size() == sizeof(data_bytes)); - CHECK(frames[1].type == MessageType::Ok); + CHECK(frames[1].type == static_cast(MessageType::Ok)); CHECK(ota::parse_u32_payload(frames[1]).value_or(0) == 10u); } @@ -170,7 +170,7 @@ static void test_resync_on_corruption() { const auto frames = parser.feed(stream); CHECK(frames.size() == 1); if (frames.size() == 1) { - CHECK(frames[0].type == MessageType::Ok); + CHECK(frames[0].type == static_cast(MessageType::Ok)); CHECK(ota::parse_u32_payload(frames[0]).value_or(0) == 4u); } CHECK(parser.dropped_bytes() > 0); @@ -195,7 +195,7 @@ static void test_oversized_len_rejected() { const auto frames = parser.feed(stream); CHECK(frames.size() == 1); if (frames.size() == 1) - CHECK(frames[0].type == MessageType::Finish); + CHECK(frames[0].type == static_cast(MessageType::Finish)); CHECK(parser.dropped_bytes() > 0); // The oversized length must never be buffered/waited for. CHECK(parser.buffered() < ota::kMaxFrameSize); @@ -217,14 +217,14 @@ static void test_oversized_len_rejected() { static void test_malformed_reply_payloads() { std::printf("test_malformed_reply_payloads\n"); // Wrong-size payloads must be rejected by the typed parse helpers. - Frame f{MessageType::Ok, {0x01, 0x02}}; + Frame f{static_cast(MessageType::Ok), {0x01, 0x02}}; CHECK(!ota::parse_u32_payload(f).has_value()); - Frame e{MessageType::Error, {0x01, 0x02, 0x03}}; + Frame e{static_cast(MessageType::Error), {0x01, 0x02, 0x03}}; CHECK(!ota::parse_error(e).has_value()); - Frame p{MessageType::Progress, {0x01, 0x02, 0x03, 0x04}}; + Frame p{static_cast(MessageType::Progress), {0x01, 0x02, 0x03, 0x04}}; CHECK(!ota::parse_progress(p).has_value()); // An ERROR with just a code (no message) is valid. - Frame e2{MessageType::Error, {0x05, 0x00, 0x00, 0x00}}; + Frame e2{static_cast(MessageType::Error), {0x05, 0x00, 0x00, 0x00}}; const auto info = ota::parse_error(e2); CHECK(info.has_value()); if (info.has_value()) { diff --git a/components/stream_frame/CMakeLists.txt b/components/stream_frame/CMakeLists.txt new file mode 100644 index 000000000..e64c21e5f --- /dev/null +++ b/components/stream_frame/CMakeLists.txt @@ -0,0 +1,5 @@ +# Header-only, dependency-free wire-framing codec (CRC-32 framing + incremental +# StreamParser). No ESP-IDF component dependencies so it also builds on a host. +idf_component_register( + INCLUDE_DIRS "include" +) diff --git a/components/stream_frame/README.md b/components/stream_frame/README.md new file mode 100644 index 000000000..2510172b9 --- /dev/null +++ b/components/stream_frame/README.md @@ -0,0 +1,45 @@ +# Stream Frame + +A tiny, dependency-free wire-framing codec: CRC-32-verified, length-delimited, +typed frames plus an incremental, resynchronizing `StreamParser` for carrying +messages over any raw byte stream (USB vendor / CDC, TCP/UDP sockets, UART, ...). + +It has **no** ESP-IDF / FreeRTOS dependency, so the framing builds and +unit-tests on a host with nothing more than a C++20 standard library. It is the +shared substrate under the OTA stream protocol (`espp::detail::ota_stream`), +the crash-dump service, and — via `espp::Dispatcher` — any number of +independent protocols multiplexed over one stream. + +## Wire format + +All multi-byte fields are little-endian: + +``` +[magic u16 = 0x4F54 ("OT")][type u8][len u32][payload: len bytes][crc32 u32] +``` + +- `magic` — `0x4F54` ("OT"); raw bytes `0x54` ('T') then `0x4F` ('O'). +- `type` — an application message-type byte. Each protocol owns a disjoint range + of the byte space; parsers ignore types they do not recognize, so multiple + protocols coexist on one stream (see the `dispatcher` component). +- `len` — payload length, `<= kMaxPayloadSize` (4096). Oversized lengths are + rejected and resynchronized past, bounding parser memory. +- `crc32` — standard zlib CRC-32 over `magic..payload` + (`crc32("123456789") == 0xCBF43926`). + +## API + +- `std::vector build_frame(uint8_t type, std::span payload = {})` +- `class StreamParser` — `feed(bytes) -> std::vector`, `reset()`, + `buffered()`, `dropped_bytes()`. `Frame` is `{ uint8_t type; std::vector payload; }`. +- `crc32`, `put_u16`/`put_u32`, `get_u16`/`get_u32` helpers. + +## Host tests + +There is no ESP-IDF dependency, so `components/ota/test/ota_protocol_host_test.cpp` +exercises this codec directly on a host: + +``` +c++ -std=c++20 -Werror -I components/ota/include -I components/stream_frame/include \ + components/ota/test/ota_protocol_host_test.cpp -o test && ./test +``` diff --git a/components/stream_frame/idf_component.yml b/components/stream_frame/idf_component.yml new file mode 100644 index 000000000..0a0200254 --- /dev/null +++ b/components/stream_frame/idf_component.yml @@ -0,0 +1,20 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "Tiny dependency-free wire-framing codec: CRC-32 length-delimited typed frames with an incremental, resynchronizing StreamParser for carrying messages over any raw byte stream (USB/CDC/socket/UART)" +url: "https://github.com/esp-cpp/espp/tree/main/components/stream_frame" +repository: "https://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/stream_frame/stream_frame.html" +tags: + - cpp + - Component + - Framing + - Protocol + - CRC + - Stream + - USB + - Socket +dependencies: + idf: + version: '>=5.0' diff --git a/components/stream_frame/include/stream_frame.hpp b/components/stream_frame/include/stream_frame.hpp new file mode 100644 index 000000000..d6f56dee5 --- /dev/null +++ b/components/stream_frame/include/stream_frame.hpp @@ -0,0 +1,216 @@ +#pragma once + +// espp stream-frame codec — a tiny, dependency-free wire framing for carrying +// typed, length-delimited, CRC-verified messages over any raw byte stream (USB +// bulk / vendor, CDC, TCP/UDP sockets, UART, ...). +// +// This header deliberately has NO ESP-IDF / FreeRTOS dependency so the framing +// (CRC-32, frame building, incremental parsing with resynchronization) builds +// and unit-tests on a host with nothing more than a C++20 standard library. +// +// It is the shared substrate under several espp protocols: the OTA stream +// protocol (`espp::detail::ota_stream`, which layers its MessageType enum and +// make_*/parse_* helpers on top), the crash-dump service, and — via +// `espp::Dispatcher` — any number of independent protocols multiplexed over one +// stream (routed by the message-type byte's module id). Each protocol owns a +// disjoint range of the u8 `type` space; unknown types are ignored by parsers +// that do not recognize them, so multiple protocols coexist on one stream. +// +// Wire format (all multi-byte fields LITTLE-ENDIAN): +// +// [magic u16 = 0x4F54 ("OT")][type u8][len u32][payload: len bytes][crc32 u32] +// +// - magic: the u16 value 0x4F54 ("OT"), transmitted little-endian, so the raw +// byte sequence on the wire is 0x54 ('T') then 0x4F ('O'). +// - type: an application message-type byte (see espp::Dispatcher for the +// module-id convention that lets protocols share the byte space). +// - len: payload length in bytes; MUST be <= kMaxPayloadSize (4096). The +// parser rejects (and resynchronizes past) any frame whose length field +// exceeds this cap, so a remote-supplied length can never cause unbounded +// buffering. +// - crc32: standard zlib CRC-32 (IEEE 802.3: polynomial 0xEDB88320 reflected, +// init 0xFFFFFFFF, final xor 0xFFFFFFFF) computed over magic..payload, i.e. +// the kHeaderSize (7) header bytes plus the payload. +// Golden check value: crc32("123456789") == 0xCBF43926. + +#include +#include +#include + +namespace espp { +namespace stream_frame { + +/// Frame magic: the u16 value 0x4F54 ("OT"); little-endian on the wire, so the +/// first frame byte is 0x54 ('T') and the second is 0x4F ('O'). +static constexpr uint16_t kMagic = 0x4F54; +/// First (low) magic byte on the wire. +static constexpr uint8_t kMagicByte0 = static_cast(kMagic & 0xFF); // 0x54 'T' +/// Second (high) magic byte on the wire. +static constexpr uint8_t kMagicByte1 = static_cast(kMagic >> 8); // 0x4F 'O' +/// Frame header size: magic (2) + type (1) + len (4). +static constexpr size_t kHeaderSize = 7; +/// Trailing CRC-32 size. +static constexpr size_t kCrcSize = 4; +/// Maximum payload bytes per frame. Frames whose length field exceeds this are +/// rejected and resynchronized past, bounding parser memory usage. +static constexpr size_t kMaxPayloadSize = 4096; +/// Maximum total encoded frame size (header + payload + crc) = 4107 bytes. +static constexpr size_t kMaxFrameSize = kHeaderSize + kMaxPayloadSize + kCrcSize; + +/// @brief Standard zlib CRC-32 (IEEE 802.3; poly 0xEDB88320 reflected, init +/// 0xFFFFFFFF, final xor 0xFFFFFFFF). +/// @param data Bytes to checksum. +/// @param crc Running CRC from a previous call (0 to start, matching zlib's +/// crc32(0, ...) convention); chainable across chunks. +/// @return The CRC-32 of the concatenated input. +/// @note Golden check value: crc32 over the ASCII bytes "123456789" == 0xCBF43926. +inline uint32_t crc32(std::span data, uint32_t crc = 0) { + crc = ~crc; + for (const uint8_t byte : data) { + crc ^= byte; + for (int bit = 0; bit < 8; bit++) + crc = (crc & 1u) ? ((crc >> 1) ^ 0xEDB88320u) : (crc >> 1); + } + return ~crc; +} + +/// Append a u16 little-endian to a byte vector. +inline void put_u16(std::vector &out, uint16_t value) { + out.push_back(static_cast(value & 0xFF)); + out.push_back(static_cast((value >> 8) & 0xFF)); +} + +/// Append a u32 little-endian to a byte vector. +inline void put_u32(std::vector &out, uint32_t value) { + out.push_back(static_cast(value & 0xFF)); + out.push_back(static_cast((value >> 8) & 0xFF)); + out.push_back(static_cast((value >> 16) & 0xFF)); + out.push_back(static_cast((value >> 24) & 0xFF)); +} + +/// Read a u16 little-endian from a byte span (span must hold >= 2 bytes). +inline uint16_t get_u16(std::span bytes) { + return static_cast(static_cast(bytes[0]) | + (static_cast(bytes[1]) << 8)); +} + +/// Read a u32 little-endian from a byte span (span must hold >= 4 bytes). +inline uint32_t get_u32(std::span bytes) { + return static_cast(bytes[0]) | (static_cast(bytes[1]) << 8) | + (static_cast(bytes[2]) << 16) | (static_cast(bytes[3]) << 24); +} + +/// @brief A complete, CRC-verified protocol frame. +/// +/// `type` is the raw message-type byte; each protocol interprets its own range +/// of the byte space (and, with espp::Dispatcher, its module id). +struct Frame { + uint8_t type; ///< Message type byte (unknown values are passed through). + std::vector payload; ///< Payload bytes (may be empty). +}; + +/// @brief Build an encoded frame: header + payload + CRC-32 over magic..payload. +/// @param type Message type byte. +/// @param payload Payload bytes; must be <= kMaxPayloadSize. +/// @return The encoded frame bytes, or an empty vector if the payload is too large. +inline std::vector build_frame(uint8_t type, std::span payload = {}) { + if (payload.size() > kMaxPayloadSize) + return {}; + std::vector out; + out.reserve(kHeaderSize + payload.size() + kCrcSize); + put_u16(out, kMagic); + out.push_back(type); + put_u32(out, static_cast(payload.size())); + out.insert(out.end(), payload.begin(), payload.end()); + put_u32(out, crc32(std::span(out.data(), out.size()))); + return out; +} + +/// @brief Incremental frame parser for the stream-frame protocol. +/// +/// Feed arbitrary chunks of received bytes (USB bulk transfers, socket reads, +/// single bytes, ...) and it yields the complete, CRC-verified frames they +/// contain. On a bad magic, an oversized length field (> kMaxPayloadSize) or a +/// CRC mismatch it resynchronizes by discarding bytes until the next plausible +/// frame start, so a corrupted stream recovers at the next intact frame. +/// +/// Buffering is bounded: because the length field is capped, the parser never +/// retains more than kMaxFrameSize bytes between feed() calls (plus at most the +/// chunk currently being processed), so a remote-supplied length cannot cause +/// memory exhaustion. +class StreamParser { +public: + /// @brief Feed received bytes to the parser. + /// @param data Any number of bytes (frames may be split or batched arbitrarily). + /// @return All complete, CRC-verified frames terminated by these bytes, in order. + std::vector feed(std::span data) { + buffer_.insert(buffer_.end(), data.begin(), data.end()); + std::vector frames; + size_t pos = 0; + while (true) { + pos = find_frame_start(pos); + if (buffer_.size() - pos < kHeaderSize) + break; // incomplete header; wait for more bytes + const auto header = std::span(buffer_).subspan(pos); + const uint32_t len = get_u32(header.subspan(3)); + if (len > kMaxPayloadSize) { + // Reject a remote-supplied length that exceeds the cap and resync one + // byte past this (bogus) frame start. + dropped_bytes_++; + pos++; + continue; + } + const size_t total = kHeaderSize + len + kCrcSize; + if (buffer_.size() - pos < total) + break; // incomplete frame; wait for more bytes + const uint32_t expected = get_u32(header.subspan(kHeaderSize + len)); + const uint32_t actual = crc32(header.first(kHeaderSize + len)); + if (actual != expected) { + // Corrupt frame; resync one byte past this frame start. + dropped_bytes_++; + pos++; + continue; + } + Frame frame{}; + frame.type = header[2]; + frame.payload.assign(header.begin() + kHeaderSize, header.begin() + kHeaderSize + len); + frames.push_back(std::move(frame)); + pos += total; + } + buffer_.erase(buffer_.begin(), buffer_.begin() + pos); + return frames; + } + + /// Discard all buffered bytes (e.g. on transport reconnect or RX overflow). + void reset() { buffer_.clear(); } + + /// Number of bytes currently buffered awaiting frame completion. + size_t buffered() const { return buffer_.size(); } + + /// Total bytes discarded so far while resynchronizing (diagnostics). + size_t dropped_bytes() const { return dropped_bytes_; } + +protected: + /// Advance @p pos to the next plausible frame start (the magic byte pair), + /// counting the skipped bytes as dropped. A trailing lone kMagicByte0 is kept + /// (it may be the first half of a magic split across chunks). + size_t find_frame_start(size_t pos) { + const size_t start = pos; + while (pos < buffer_.size()) { + if (buffer_[pos] == kMagicByte0) { + if (pos + 1 >= buffer_.size() || buffer_[pos + 1] == kMagicByte1) + break; // found (or possibly-split) magic + } + pos++; + } + dropped_bytes_ += pos - start; + return pos; + } + +private: + std::vector buffer_; + size_t dropped_bytes_{0}; +}; + +} // namespace stream_frame +} // namespace espp diff --git a/doc/Doxyfile b/doc/Doxyfile index 07bb86eaf..2d8f2f442 100755 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -385,6 +385,8 @@ INPUT = \ $(PROJECT_PATH)/components/odrive_native/include/detail/odrive_native_core.hpp \ $(PROJECT_PATH)/components/ota/include/ota.hpp \ $(PROJECT_PATH)/components/ota/include/detail/ota_stream_protocol.hpp \ + $(PROJECT_PATH)/components/dispatcher/include/dispatcher.hpp \ + $(PROJECT_PATH)/components/stream_frame/include/stream_frame.hpp \ $(PROJECT_PATH)/components/pca9535/include/pca9535.hpp \ $(PROJECT_PATH)/components/pcf85063/include/pcf85063.hpp \ $(PROJECT_PATH)/components/pid/include/pid.hpp \ diff --git a/doc/en/dispatcher/dispatcher.rst b/doc/en/dispatcher/dispatcher.rst new file mode 100644 index 000000000..7aa5745c0 --- /dev/null +++ b/doc/en/dispatcher/dispatcher.rst @@ -0,0 +1,51 @@ +Dispatcher +********** + +The `Dispatcher` routes framed messages from one byte stream to per-module +handlers, letting several independent protocols share a single USB vendor / CDC +/ socket / UART link. Rather than run a separate ``StreamParser`` per protocol +over the same bytes (each re-buffering the whole stream and needing its own +reset-on-overflow bookkeeping), a Dispatcher parses the +:doc:`../stream_frame/index` stream once and hands each complete frame to the +handler registered for its module id. + +Module id convention +-------------------- + +The module id is the high nibble of the message-type byte (``type >> 4``). espp +built-in protocols place their request opcodes so each protocol occupies one +high nibble, and use bit 7 to mark device→host replies: + +=========== ========= ============== ============= +Module id Protocol Requests Replies +=========== ========= ============== ============= +0 OTA ``0x0X`` ``0x8X`` +4 crash dump ``0x4X`` ``0xCX`` +5 CAN bridge ``0x5X`` ``0xDX`` *(example)* +=========== ========= ============== ============= + +A device-side dispatcher registers the request modules (0, 4, 5, ...). It never +receives the reply-typed frames (high nibble 8..15); if one arrives it lands on +an unregistered module and is ignored, so requests and replies of the same +protocol can never be confused. Application code may assign any unused module id +to its own protocol — nothing is hard-wired to a specific service. Frames for an +unregistered module are silently ignored. + +.. code-block:: cpp + + espp::Dispatcher dispatcher; + dispatcher.register_module(0, [&](uint8_t type, std::span payload) { + // handle OTA frames + }); + dispatcher.register_module(4, [&](uint8_t type, std::span payload) { + // handle crash-dump frames + }); + // feed raw received bytes; each complete frame is routed to its module + usb.set_vendor_receive_callback([&](std::span data) { dispatcher.feed(data); }); + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/dispatcher.inc diff --git a/doc/en/dispatcher/index.rst b/doc/en/dispatcher/index.rst new file mode 100644 index 000000000..ed71e0c21 --- /dev/null +++ b/doc/en/dispatcher/index.rst @@ -0,0 +1,13 @@ +Dispatcher APIs +*************** + +.. toctree:: + :maxdepth: 1 + + dispatcher + +The `Dispatcher` component multiplexes several independent framed protocols over +a single byte stream. It parses the :doc:`../stream_frame/index` codec once and +routes each frame to a per-module handler by the message-type byte's high-nibble +module id, so OTA, crash-dump inspection, a CAN bridge and an application's own +control channel can share one USB / socket / UART link without interfering. diff --git a/doc/en/index.rst b/doc/en/index.rst index 1b61b04c3..466719835 100755 --- a/doc/en/index.rst +++ b/doc/en/index.rst @@ -84,6 +84,8 @@ collected under :doc:`web_apps`. ftp/index nfc/index ota/index + stream_frame/index + dispatcher/index wireless/index protocols/index diff --git a/doc/en/ota/ota.rst b/doc/en/ota/ota.rst index 202e41327..c00662baf 100644 --- a/doc/en/ota/ota.rst +++ b/doc/en/ota/ota.rst @@ -27,6 +27,13 @@ resynchronizing parser and a bounded 4096-byte maximum payload. The hosted `espp OTA Console `_ web app speaks this protocol over WebUSB directly from a Chromium browser. +The frame codec itself lives in the reusable :doc:`../stream_frame/index` +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). + .. ------------------------------- Example ------------------------------------- .. toctree:: diff --git a/doc/en/stream_frame/index.rst b/doc/en/stream_frame/index.rst new file mode 100644 index 000000000..365a602dc --- /dev/null +++ b/doc/en/stream_frame/index.rst @@ -0,0 +1,14 @@ +Stream Frame APIs +***************** + +.. toctree:: + :maxdepth: 1 + + stream_frame + +The `stream_frame` component is a tiny, dependency-free wire-framing codec: +CRC-32-verified, length-delimited, typed frames plus an incremental, +resynchronizing ``StreamParser`` for carrying messages over any raw byte stream +(USB vendor / CDC, TCP/UDP sockets, UART, ...). It is the shared substrate under +the OTA stream protocol, the crash-dump service and the :doc:`../dispatcher/index` +multiplexer. diff --git a/doc/en/stream_frame/stream_frame.rst b/doc/en/stream_frame/stream_frame.rst new file mode 100644 index 000000000..9998a2db6 --- /dev/null +++ b/doc/en/stream_frame/stream_frame.rst @@ -0,0 +1,34 @@ +Stream Frame +************ + +The `stream_frame` component provides a minimal wire framing for carrying typed, +length-delimited, CRC-verified messages over any raw byte stream. It is +intentionally free of any ESP-IDF / FreeRTOS dependency, so the framing (CRC-32, +frame building, incremental parsing with resynchronization) builds and +unit-tests on a host with nothing more than a C++20 standard library. + +Wire format (all multi-byte fields little-endian):: + + [magic u16 = 0x4F54 ("OT")][type u8][len u32][payload: len bytes][crc32 u32] + +- ``magic`` — the u16 ``0x4F54`` ("OT"), so the raw bytes are ``0x54`` ('T') + then ``0x4F`` ('O'). +- ``type`` — an application message-type byte. Each protocol owns a disjoint + range of the byte space; parsers ignore types they do not recognize, so + multiple protocols coexist on one stream (see :doc:`../dispatcher/index`). +- ``len`` — payload length, ``<= kMaxPayloadSize`` (4096). The parser rejects + and resynchronizes past any oversized length, bounding memory usage. +- ``crc32`` — standard zlib CRC-32 over ``magic..payload`` (golden check value + ``crc32("123456789") == 0xCBF43926``). + +``build_frame()`` encodes a frame; ``StreamParser::feed()`` consumes arbitrary +chunks (frames may be split or batched) and yields the complete, CRC-verified +frames, resynchronizing at the next intact frame after any corruption. The +buffering is bounded because the length field is capped. + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/stream_frame.inc From 3c44c821d437386115b1012621a4ae7fd39afc8b Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 31 Aug 2026 22:41:57 -0500 Subject: [PATCH 02/13] =?UTF-8?q?refactor(stream=5Fframe):=20v2=20framing?= =?UTF-8?q?=20=E2=80=94=20full-byte=20module=20+=20type=20+=20flags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback that the v1 nibble module id (module = type>>4) was too restrictive and conflated module id, message type and direction in one byte. New v2 wire format (breaking; all espp protocols + web apps move to it): [magic u16][flags u8][module u8][type u8][len u32][payload][crc32] - flags: bit0 = reply (request vs device->host reply/event); bits4-7 = version (=1); reserved bits leave room to extend payload semantics later. - module: full u8 (256 protocols) — the Dispatcher routing key. - type: full u8 message/transaction type within the module. A Transaction enum {Write, Read, WriteRead, Custom} gives recommended standard values; protocols may define their own type values and carry a finer opcode in the payload. Dispatcher now routes by the module byte (not type>>4); handlers receive the whole Frame (module/type/flags/payload). Registration is a small (module -> handler) set rather than a 256-entry table. OTA migrated to v2: module 0, request types Begin/Data/Finish/Abort = 0x01-0x04, reply types Ok/Error/Progress = 0x05/0x06/0x07 with the frame reply flag set (previously 0x81/0x82/0x83). ota_console.html updated to the v2 header + reply opcodes. Also addresses PR review comments: - stream_frame docs/README/header no longer imply StreamParser filters unknown types — it yields every CRC-verified frame; routing/ignoring is the Dispatcher's job. - Doxyfile INPUT: dispatcher/stream_frame moved to their alphabetical positions. - upload_components.yml: dispatcher + stream_frame added. - Raw-framing host tests moved to components/stream_frame/test; the ota test now covers the OTA make_*/parse_* helpers. Both + the dispatcher test pass on host. ota example builds clean (esp32s3); all three host tests pass; webapp node --check clean and BEGIN-frame bytes verified. Co-Authored-By: Claude Fable 5 --- .github/workflows/upload_components.yml | 2 + components/dispatcher/README.md | 54 ++-- components/dispatcher/include/dispatcher.hpp | 111 +++---- .../dispatcher/test/dispatcher_host_test.cpp | 99 +++---- components/ota/example/main/ota_example.cpp | 8 +- .../include/detail/ota_stream_protocol.hpp | 22 +- .../ota/test/ota_protocol_host_test.cpp | 271 +++++------------- components/ota/web/ota_console.html | 53 +++- components/stream_frame/README.md | 46 +-- .../stream_frame/include/stream_frame.hpp | 137 ++++++--- .../test/stream_frame_host_test.cpp | 170 +++++++++++ doc/Doxyfile | 4 +- doc/en/dispatcher/dispatcher.rst | 53 ++-- doc/en/stream_frame/stream_frame.rst | 26 +- 14 files changed, 615 insertions(+), 441 deletions(-) create mode 100644 components/stream_frame/test/stream_frame_host_test.cpp diff --git a/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml index b984fcb2f..c50ad6974 100755 --- a/.github/workflows/upload_components.yml +++ b/.github/workflows/upload_components.yml @@ -75,6 +75,7 @@ jobs: components/coredump components/cst816 components/csv + components/dispatcher components/display components/display_drivers components/dns_server @@ -156,6 +157,7 @@ jobs: components/st25dv components/st7123touch components/state_machine + components/stream_frame components/sx126x components/t_keyboard components/t-deck diff --git a/components/dispatcher/README.md b/components/dispatcher/README.md index 7de916b78..e175bd83f 100644 --- a/components/dispatcher/README.md +++ b/components/dispatcher/README.md @@ -2,43 +2,55 @@ `espp::Dispatcher` multiplexes several independent framed protocols over a single byte stream. It parses the `stream_frame` codec **once** and routes each -complete frame to a per-module handler by the message-type byte's high-nibble -module id — so OTA, crash-dump inspection, a CAN bridge and an application's own -control channel can share one USB vendor / CDC / socket / UART link without -interfering. Header-only and dependency-free (only `stream_frame` + the standard -library), so it also builds and unit-tests on a host. +complete frame to a per-module handler by the frame's `module` byte — so OTA, +crash-dump inspection, a CAN bridge and an application's own control channel can +share one USB vendor / CDC / socket / UART link without interfering. +Header-only and dependency-free (only `stream_frame` + the standard library), +so it also builds and unit-tests on a host. Rather than run a separate `StreamParser` per protocol over the same bytes (each re-buffering the whole stream and needing its own reset-on-overflow bookkeeping), the Dispatcher owns the one parser and dispatches by module id. -## Module id convention +## Module id -The module id is the high nibble of the message-type byte (`type >> 4`). espp -built-in protocols place their request opcodes so each protocol occupies one -high nibble, and use bit 7 to mark device→host replies: +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 +built-in protocols use, for example: -| Module id | Protocol | Requests | Replies | -|-----------|------------|----------|-----------------| -| 0 | OTA | `0x0X` | `0x8X` | -| 4 | crash dump | `0x4X` | `0xCX` | -| 5 | CAN bridge | `0x5X` | `0xDX` (example)| +| Module | Protocol | +|--------|------------| +| 0 | OTA | +| 4 | crash dump | +| 5 | CAN bridge | -A device-side dispatcher registers the request modules (0, 4, 5, ...); it never -receives reply-typed frames (high nibble 8..15), and if one arrives it lands on -an unregistered module and is ignored, so requests and replies of one protocol -can never be confused. Application code may assign any unused module id to its -own protocol. Frames for an unregistered module are silently ignored. +A device-side dispatcher registers the modules it serves; frames for an +unregistered module (including the device's own replies echoed back, which carry +the reply flag) are silently ignored. Application code may assign any unused +module id to its own protocol — nothing is hard-wired to a specific service. ## API - `void register_module(uint8_t module_id, handler_fn handler)` / `void unregister_module(uint8_t module_id)` / `bool has_module(uint8_t)` - where `handler_fn = std::function payload)>`. + where `handler_fn = std::function`. - `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). -- `static constexpr uint8_t module_of(uint8_t type)`, `buffered()`, `dropped_bytes()`. +- `buffered()`, `dropped_bytes()`. + +```cpp +espp::Dispatcher dispatcher; +dispatcher.register_module(0, [&](const espp::stream_frame::Frame &f) { + // handle OTA frames: f.type, f.is_reply(), f.payload +}); +dispatcher.register_module(4, [&](const espp::stream_frame::Frame &f) { + // handle crash-dump frames +}); +usb.set_vendor_receive_callback([&](std::span data) { dispatcher.feed(data); }); +``` ## Host tests diff --git a/components/dispatcher/include/dispatcher.hpp b/components/dispatcher/include/dispatcher.hpp index f11b42366..f94f59153 100644 --- a/components/dispatcher/include/dispatcher.hpp +++ b/components/dispatcher/include/dispatcher.hpp @@ -1,42 +1,31 @@ #pragma once // espp::Dispatcher — multiplex several independent framed protocols over a -// single byte stream, routing each frame to a handler by its "module id". +// single byte stream, routing each frame to a handler by its `module` id. // -// A USB vendor / CDC / socket link often needs to carry more than one protocol -// at once: firmware update (OTA), crash-dump inspection, a CAN bridge, an -// application's own control channel, ... They all ride the same -// espp::stream_frame framing (magic / type / len / crc). Rather than run a -// separate StreamParser per protocol over the same bytes (each re-buffering the -// whole stream and needing its own reset-on-overflow bookkeeping), a Dispatcher -// parses the stream ONCE and routes each complete frame to the handler -// registered for its module id. +// A USB vendor / CDC / socket / UART link often needs to carry more than one +// protocol at once: firmware update (OTA), crash-dump inspection, a CAN bridge, +// an application's own control channel, ... They all ride the same +// espp::stream_frame framing. Rather than run a separate StreamParser per +// protocol over the same bytes (each re-buffering the whole stream and needing +// its own reset-on-overflow bookkeeping), a Dispatcher parses the stream ONCE +// and routes each complete frame to the handler registered for its module id. // -// Module id convention -// -------------------- -// The module id is the high nibble of the message-type byte: `type >> 4`. -// espp built-in protocols place their request opcodes so each protocol occupies -// one high nibble, and use bit 7 to mark device->host replies: -// -// module 0 OTA requests 0x0X replies 0x8X -// module 4 crash dump requests 0x4X replies 0xCX -// module 5 CAN bridge requests 0x5X replies 0xDX (example) -// -// A DEVICE-side dispatcher registers the request modules (0, 4, 5, ...); the -// reply-typed frames (high nibble 8..15) it never receives, and if one does -// arrive it lands on an unregistered module and is ignored — so requests and -// replies of the same protocol can never be confused. A HOST-side dispatcher -// (if used) would instead register the reply high nibbles. Application code is -// free to assign any unused module id to its own protocol; nothing here is -// hard-wired to a specific service. +// The frame's `module` byte (0..255) is the routing key — a full byte, so up to +// 256 protocols can coexist. The message/transaction type and the +// request/reply direction travel in the frame's `type` and `flags` fields and +// are handed to the module's handler untouched; the Dispatcher does not +// interpret them. A device-side dispatcher typically registers the modules it +// serves and ignores everything else (including its own replies echoed back). // // Header-only and dependency-free (only espp::stream_frame + the standard // library), so it builds and unit-tests on a host. -#include #include #include #include +#include +#include #include "stream_frame.hpp" @@ -45,40 +34,45 @@ namespace espp { /// @brief Routes framed messages from one byte stream to per-module handlers. class Dispatcher { public: - /// Number of routable modules (one per value of the type byte's high nibble). - static constexpr uint8_t kNumModules = 16; - - /// @brief Handler invoked for every frame whose module id was registered. - /// @param type The full message-type byte (module id in the high nibble). - /// @param payload The frame payload bytes (valid only for the call). - using handler_fn = std::function payload)>; + /// @brief Handler invoked for every frame whose module was registered. + /// @param frame The decoded frame (module, type, flags/reply, payload). + using handler_fn = std::function; - /// @brief The module id of a message-type byte (its high nibble). - static constexpr uint8_t module_of(uint8_t type) { return static_cast(type >> 4); } + /// @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; } - /// @brief Register (or replace) the handler for a module id (0..kNumModules-1). - /// @param module_id High-nibble module id to route to @p handler. - /// @param handler Callback for frames with this module id (null unregisters). + /// @brief Register (or replace) the handler for a module id. + /// @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. void register_module(uint8_t module_id, handler_fn handler) { - if (module_id < kNumModules) - handlers_[module_id] = std::move(handler); + for (auto &entry : handlers_) { + if (entry.first == module_id) { + if (handler) + entry.second = std::move(handler); + else + erase(module_id); + return; + } + } + if (handler) + handlers_.emplace_back(module_id, std::move(handler)); } /// @brief Remove the handler for a module id (frames for it become ignored). - void unregister_module(uint8_t module_id) { - if (module_id < kNumModules) - handlers_[module_id] = nullptr; - } + void unregister_module(uint8_t module_id) { erase(module_id); } /// @brief Whether a handler is registered for a module id. bool has_module(uint8_t module_id) const { - return module_id < kNumModules && static_cast(handlers_[module_id]); + for (const auto &entry : handlers_) + if (entry.first == module_id) + return true; + return false; } /// @brief Feed raw received bytes: parse and route each complete frame to its /// module's handler. Frames whose module has no handler are ignored /// (so unrelated protocols on the same stream are harmless). - /// @param data Any number of received bytes (frames may be split or batched). void feed(std::span data) { for (const auto &frame : parser_.feed(data)) dispatch(frame); @@ -86,9 +80,12 @@ class Dispatcher { /// @brief Route an already-parsed frame (for callers running their own parser). void dispatch(const stream_frame::Frame &frame) { - const uint8_t module_id = module_of(frame.type); - if (module_id < kNumModules && handlers_[module_id]) - handlers_[module_id](frame.type, frame.payload); + for (const auto &entry : handlers_) { + if (entry.first == frame.module) { + entry.second(frame); + return; + } + } } /// @brief Discard any partially-buffered frame bytes (transport reconnect or @@ -102,8 +99,20 @@ class Dispatcher { size_t dropped_bytes() const { return parser_.dropped_bytes(); } private: + void erase(uint8_t module_id) { + for (auto it = handlers_.begin(); it != handlers_.end(); ++it) { + if (it->first == module_id) { + handlers_.erase(it); + return; + } + } + } + stream_frame::StreamParser parser_; - std::array handlers_{}; + // Small set of (module id -> handler); 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_; }; } // namespace espp diff --git a/components/dispatcher/test/dispatcher_host_test.cpp b/components/dispatcher/test/dispatcher_host_test.cpp index 6420212e5..afcea4fb4 100644 --- a/components/dispatcher/test/dispatcher_host_test.cpp +++ b/components/dispatcher/test/dispatcher_host_test.cpp @@ -1,4 +1,4 @@ -// Host-buildable unit tests for espp::Dispatcher. Build & run with: +// Host-buildable unit tests for espp::Dispatcher (v2). Build & run with: // c++ -std=c++20 -Werror -I components/dispatcher/include \ // -I components/stream_frame/include \ // components/dispatcher/test/dispatcher_host_test.cpp -o test && ./test @@ -24,83 +24,78 @@ static int g_failures = 0; } \ } while (0) -static void test_module_of() { - std::printf("test_module_of\n"); - // High nibble is the module id; the 0x80 reply bit lives in the high nibble - // too, so requests and replies of one protocol map to *different* module ids - // (device sees the request nibble; host would see the reply nibble). - CHECK(espp::Dispatcher::module_of(0x01) == 0); // OTA BEGIN - CHECK(espp::Dispatcher::module_of(0x04) == 0); // OTA ABORT - CHECK(espp::Dispatcher::module_of(0x40) == 4); // coredump GET_SUMMARY - CHECK(espp::Dispatcher::module_of(0x43) == 4); // coredump ERASE - CHECK(espp::Dispatcher::module_of(0x50) == 5); // CAN bridge (example) - CHECK(espp::Dispatcher::module_of(0x81) == 8); // OTA OK reply - CHECK(espp::Dispatcher::module_of(0xC2) == 12); // coredump DATA reply -} - static void test_routing_and_coexistence() { std::printf("test_routing_and_coexistence\n"); espp::Dispatcher d; - std::vector mod0_types, mod4_types, mod5_types; - d.register_module(0, [&](uint8_t t, std::span) { mod0_types.push_back(t); }); - d.register_module(4, [&](uint8_t t, std::span p) { - mod4_types.push_back(t); - // echo payload length check for one case below - if (t == 0x42) - CHECK(p.size() == 3); + std::vector mod0, mod4, mod200; + bool mod4_saw_reply = false; + d.register_module(0, [&](const sf::Frame &f) { mod0.push_back(f.type); }); + d.register_module(4, [&](const sf::Frame &f) { + mod4.push_back(f.type); + if (f.is_reply()) + mod4_saw_reply = true; + if (f.type == 0x42) + CHECK(f.payload.size() == 3); }); - d.register_module(5, [&](uint8_t t, std::span) { mod5_types.push_back(t); }); - CHECK(d.has_module(0) && d.has_module(4) && d.has_module(5)); - CHECK(!d.has_module(1) && !d.has_module(12)); + // A full-byte module id well beyond the old nibble range (0..15). + d.register_module(200, [&](const sf::Frame &f) { mod200.push_back(f.type); }); + CHECK(d.has_module(0) && d.has_module(4) && d.has_module(200)); + CHECK(!d.has_module(1) && !d.has_module(13)); - // Interleave three protocols' frames on one stream, plus one frame for an - // UNREGISTERED module (must be silently ignored). std::vector stream; auto add = [&](const std::vector &f) { stream.insert(stream.end(), f.begin(), f.end()); }; const uint8_t p3[] = {1, 2, 3}; - add(sf::build_frame(0x01)); // module 0 - add(sf::build_frame(0x50)); // module 5 - add(sf::build_frame(0x42, p3)); // module 4, 3-byte payload - add(sf::build_frame(0x20)); // module 2 — unregistered, ignored - add(sf::build_frame(0x02)); // module 0 + add(sf::build_frame(false, 0, 0x02)); // module 0 request + add(sf::build_frame(true, 4, 0xC0)); // module 4 reply + add(sf::build_frame(false, 4, 0x42, p3)); // module 4 request, 3-byte payload + add(sf::build_frame(false, 7, 0x01)); // module 7 — unregistered, ignored + add(sf::build_frame(false, 200, 0x99)); // module 200 request + add(sf::build_frame(false, 0, 0x03)); // module 0 request d.feed(stream); - CHECK(mod0_types.size() == 2); - CHECK(mod0_types.size() == 2 && mod0_types[0] == 0x01 && mod0_types[1] == 0x02); - CHECK(mod4_types.size() == 1 && mod4_types[0] == 0x42); - CHECK(mod5_types.size() == 1 && mod5_types[0] == 0x50); + CHECK(mod0.size() == 2 && mod0[0] == 0x02 && mod0[1] == 0x03); + CHECK(mod4.size() == 2 && mod4[0] == 0xC0 && mod4[1] == 0x42 && mod4_saw_reply); + CHECK(mod200.size() == 1 && mod200[0] == 0x99); CHECK(d.dropped_bytes() == 0); } -static void test_unregister_and_reset() { - std::printf("test_unregister_and_reset\n"); +static void test_register_replace_unregister() { + std::printf("test_register_replace_unregister\n"); espp::Dispatcher d; - int count = 0; - d.register_module(0, [&](uint8_t, std::span) { ++count; }); - d.feed(sf::build_frame(0x01)); - CHECK(count == 1); - d.unregister_module(0); - d.feed(sf::build_frame(0x02)); - CHECK(count == 1); // no longer routed + int a = 0, b = 0; + d.register_module(3, [&](const sf::Frame &) { ++a; }); + d.feed(sf::build_frame(false, 3, 0x00)); + CHECK(a == 1 && b == 0); + // Replacing the handler for a module routes to the new one. + d.register_module(3, [&](const sf::Frame &) { ++b; }); + d.feed(sf::build_frame(false, 3, 0x00)); + CHECK(a == 1 && b == 1); + // Unregister -> frames for the module are ignored. + d.unregister_module(3); + d.feed(sf::build_frame(false, 3, 0x00)); + CHECK(a == 1 && b == 1 && !d.has_module(3)); +} - // reset() drops a partially-buffered frame so a stale prefix cannot stitch - // onto later bytes. - d.register_module(0, [&](uint8_t, std::span) { ++count; }); - auto frame = sf::build_frame(0x03); +static void test_reset() { + std::printf("test_reset\n"); + espp::Dispatcher d; + int count = 0; + d.register_module(0, [&](const sf::Frame &) { ++count; }); + auto frame = sf::build_frame(false, 0, 0x03); d.feed(std::span(frame.data(), 3)); // partial (header only) CHECK(d.buffered() > 0); d.reset(); CHECK(d.buffered() == 0); d.feed(std::span(frame.data() + 3, frame.size() - 3)); // remainder alone - CHECK(count == 1); // the split frame did NOT complete after reset + CHECK(count == 0); // the split frame did NOT complete after reset } int main() { - test_module_of(); test_routing_and_coexistence(); - test_unregister_and_reset(); + test_register_replace_unregister(); + test_reset(); if (g_failures == 0) { std::printf("ALL TESTS PASSED\n"); return 0; diff --git a/components/ota/example/main/ota_example.cpp b/components/ota/example/main/ota_example.cpp index b6c3eeeab..7658f862a 100644 --- a/components/ota/example/main/ota_example.cpp +++ b/components/ota/example/main/ota_example.cpp @@ -349,11 +349,9 @@ extern "C" void app_main(void) { } }; - // OTA is module id 0. The Dispatcher hands us (type, payload); rebuild a Frame - // for the existing handler. - dispatcher.register_module(0, [&](uint8_t type, std::span payload) { - handle_usb_frame(proto::Frame{type, std::vector(payload.begin(), payload.end())}); - }); + // 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); }); espp::Task usb_task( {.callback = [&](std::mutex &, std::condition_variable &) -> bool { diff --git a/components/ota/include/detail/ota_stream_protocol.hpp b/components/ota/include/detail/ota_stream_protocol.hpp index dc98d7e3b..87c47ce9b 100644 --- a/components/ota/include/detail/ota_stream_protocol.hpp +++ b/components/ota/include/detail/ota_stream_protocol.hpp @@ -68,23 +68,35 @@ using espp::stream_frame::put_u32; using espp::stream_frame::Frame; using espp::stream_frame::StreamParser; -/// OTA stream protocol message types. +/// OTA occupies dispatcher module id 0. +static constexpr uint8_t kModule = 0; + +/// OTA stream protocol message types (the frame `type` field within module 0). +/// 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. enum class MessageType : uint8_t { Begin = 0x01, ///< host -> device: start a session (payload: u32 image_size, 0 = unknown) Data = 0x02, ///< host -> device: image bytes (payload: raw image data) Finish = 0x03, ///< host -> device: validate + activate the received image (no payload) Abort = 0x04, ///< host -> device: discard the in-progress session (no payload) - Ok = 0x81, ///< device -> host: success reply (payload: u32 bytes_received so far) - Error = 0x82, ///< device -> host: failure reply (payload: u32 code + utf8 message) - Progress = 0x83, ///< device -> host: optional progress (payload: u32 written, u32 total) + Ok = 0x05, ///< device -> host: success reply (payload: u32 bytes_received so far) + Error = 0x06, ///< device -> host: failure reply (payload: u32 code + utf8 message) + Progress = 0x07, ///< device -> host: optional progress (payload: u32 written, u32 total) }; +/// Whether a message type is a device->host reply (sets the frame reply flag). +inline bool is_reply(MessageType type) { + return type == MessageType::Ok || type == MessageType::Error || type == MessageType::Progress; +} + /// @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. /// @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(static_cast(type), payload); + return espp::stream_frame::build_frame(is_reply(type), kModule, static_cast(type), + payload); } /// Build a BEGIN frame (image_size in bytes, 0 = unknown / streaming). diff --git a/components/ota/test/ota_protocol_host_test.cpp b/components/ota/test/ota_protocol_host_test.cpp index a9e1ea9dd..385570858 100644 --- a/components/ota/test/ota_protocol_host_test.cpp +++ b/components/ota/test/ota_protocol_host_test.cpp @@ -1,23 +1,22 @@ -// Host-buildable unit tests for the espp OTA stream protocol framing. Build & -// run with: +// Host-buildable unit tests for the espp OTA stream protocol helpers. The raw +// frame codec (magic/flags/module/type/len/crc, StreamParser resync, ...) is +// tested by components/stream_frame/test; this file exercises the OTA-specific +// make_*/parse_* helpers layered on top. Build & run: // c++ -std=c++20 -Werror -I components/ota/include -I components/stream_frame/include \ // components/ota/test/ota_protocol_host_test.cpp -o test && ./test // -// These tests exercise espp::detail::ota_stream directly so they need no -// ESP-IDF headers. +// No ESP-IDF headers required. #include #include #include #include #include -#include #include #include "detail/ota_stream_protocol.hpp" namespace ota = espp::detail::ota_stream; -using ota::Frame; using ota::MessageType; static int g_failures = 0; @@ -29,217 +28,97 @@ static int g_failures = 0; } \ } while (0) -static std::span as_bytes(std::string_view s) { - return {reinterpret_cast(s.data()), s.size()}; -} - -static void test_crc32() { - std::printf("test_crc32\n"); - // The standard CRC-32 check value (zlib / IEEE 802.3). - CHECK(ota::crc32(as_bytes("123456789")) == 0xCBF43926u); - // Empty input yields the zlib initial value 0. - CHECK(ota::crc32({}) == 0u); - // A couple more golden vectors (values from zlib's crc32()). - CHECK(ota::crc32(as_bytes("a")) == 0xE8B7BE43u); - CHECK(ota::crc32(as_bytes("abc")) == 0x352441C2u); - const uint8_t zeros[4] = {0, 0, 0, 0}; - CHECK(ota::crc32(zeros) == 0x2144DF1Cu); - // Chaining across chunks matches the one-shot result. - const uint32_t first = ota::crc32(as_bytes("12345")); - CHECK(ota::crc32(as_bytes("6789"), first) == 0xCBF43926u); -} - -static void test_frame_layout() { - std::printf("test_frame_layout\n"); - // BEGIN with image_size 0x11223344; check the exact encoded bytes. - const auto frame = ota::make_begin(0x11223344u); - CHECK(frame.size() == ota::kHeaderSize + 4 + ota::kCrcSize); - CHECK(frame[0] == 0x54); // 'T' — low byte of the LE magic 0x4F54 - CHECK(frame[1] == 0x4F); // 'O' — high byte - CHECK(frame[2] == 0x01); // type BEGIN - // len u32 LE = 4 - CHECK(frame[3] == 0x04 && frame[4] == 0x00 && frame[5] == 0x00 && frame[6] == 0x00); - // payload u32 LE = image_size - CHECK(frame[7] == 0x44 && frame[8] == 0x33 && frame[9] == 0x22 && frame[10] == 0x11); - // trailing crc32 (LE) over magic..payload - const uint32_t crc = ota::crc32(std::span(frame.data(), 11)); - CHECK(ota::get_u32(std::span(frame).subspan(11)) == crc); -} - -static void test_round_trip_all_types() { - std::printf("test_round_trip_all_types\n"); +// Parse a single OTA frame out of an encoded buffer. +static bool parse_one(const std::vector &encoded, ota::Frame &out) { ota::StreamParser parser; - const uint8_t image_bytes[] = {0xE9, 0x06, 0x02, 0x2F, 0xAA, 0x55}; + auto frames = parser.feed(encoded); + if (frames.size() != 1) + return false; + out = frames[0]; + return true; +} - std::vector stream; - auto append = [&stream](const std::vector &f) { - stream.insert(stream.end(), f.begin(), f.end()); +static void test_requests_are_module0_requests() { + std::printf("test_requests_are_module0_requests\n"); + struct Case { + std::vector frame; + MessageType type; }; - append(ota::make_begin(1234567u)); - append(ota::make_data(image_bytes)); - append(ota::make_finish()); - append(ota::make_abort()); - append(ota::make_ok(6u)); - append(ota::make_error(static_cast(5), "flash write failed")); - append(ota::make_progress(4096u, 8192u)); - - const auto frames = parser.feed(stream); - CHECK(frames.size() == 7); - CHECK(parser.buffered() == 0); - CHECK(parser.dropped_bytes() == 0); - if (frames.size() != 7) - return; - - CHECK(frames[0].type == static_cast(MessageType::Begin)); - CHECK(ota::parse_u32_payload(frames[0]).value_or(0) == 1234567u); - - CHECK(frames[1].type == static_cast(MessageType::Data)); - CHECK(frames[1].payload.size() == sizeof(image_bytes)); - CHECK(std::memcmp(frames[1].payload.data(), image_bytes, sizeof(image_bytes)) == 0); - - CHECK(frames[2].type == static_cast(MessageType::Finish)); - CHECK(frames[2].payload.empty()); - - CHECK(frames[3].type == static_cast(MessageType::Abort)); - CHECK(frames[3].payload.empty()); - - CHECK(frames[4].type == static_cast(MessageType::Ok)); - CHECK(ota::parse_u32_payload(frames[4]).value_or(0) == 6u); - - CHECK(frames[5].type == static_cast(MessageType::Error)); - const auto err = ota::parse_error(frames[5]); - CHECK(err.has_value()); - if (err.has_value()) { - CHECK(err->code == 5u); - CHECK(err->message == "flash write failed"); - } - - CHECK(frames[6].type == static_cast(MessageType::Progress)); - const auto prog = ota::parse_progress(frames[6]); - CHECK(prog.has_value()); - if (prog.has_value()) { - CHECK(prog->written == 4096u); - CHECK(prog->total == 8192u); + const uint8_t img[] = {0xE9, 0x06, 0x02}; + Case cases[] = { + {ota::make_begin(1234567u), MessageType::Begin}, + {ota::make_data(img), MessageType::Data}, + {ota::make_finish(), MessageType::Finish}, + {ota::make_abort(), MessageType::Abort}, + }; + for (auto &c : cases) { + ota::Frame f{}; + CHECK(parse_one(c.frame, f)); + CHECK(f.module == ota::kModule); + CHECK(f.type == static_cast(c.type)); + CHECK(!f.is_reply()); // requests are host -> device } + // BEGIN payload round-trips as a u32 image size. + ota::Frame begin{}; + CHECK(parse_one(ota::make_begin(1234567u), begin)); + CHECK(ota::parse_u32_payload(begin).value_or(0) == 1234567u); + // DATA payload is the raw image bytes. + ota::Frame data{}; + CHECK(parse_one(ota::make_data(img), data)); + CHECK(data.payload.size() == sizeof(img) && + std::memcmp(data.payload.data(), img, sizeof(img)) == 0); } -static void test_split_across_chunks() { - std::printf("test_split_across_chunks\n"); - const uint8_t data_bytes[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; - auto stream = ota::make_data(data_bytes); - const auto ok = ota::make_ok(10u); - stream.insert(stream.end(), ok.begin(), ok.end()); - - // Deliver one byte at a time; both frames must still come out, in order. - ota::StreamParser parser; - std::vector frames; - for (const uint8_t byte : stream) { - auto out = parser.feed(std::span(&byte, 1)); - frames.insert(frames.end(), out.begin(), out.end()); - } - CHECK(frames.size() == 2); - CHECK(parser.dropped_bytes() == 0); - if (frames.size() == 2) { - CHECK(frames[0].type == static_cast(MessageType::Data)); - CHECK(frames[0].payload.size() == sizeof(data_bytes)); - CHECK(frames[1].type == static_cast(MessageType::Ok)); - CHECK(ota::parse_u32_payload(frames[1]).value_or(0) == 10u); +static void test_replies_carry_reply_flag() { + std::printf("test_replies_carry_reply_flag\n"); + ota::Frame ok{}, err{}, prog{}; + CHECK(parse_one(ota::make_ok(6u), ok)); + CHECK(ok.module == ota::kModule && ok.type == static_cast(MessageType::Ok)); + CHECK(ok.is_reply()); // replies are device -> host + CHECK(ota::parse_u32_payload(ok).value_or(0) == 6u); + + CHECK(parse_one(ota::make_error(5u, "flash write failed"), err)); + CHECK(err.is_reply() && err.type == static_cast(MessageType::Error)); + const auto info = ota::parse_error(err); + CHECK(info.has_value()); + if (info.has_value()) { + CHECK(info->code == 5u); + CHECK(info->message == "flash write failed"); } - // Also deliver in two awkward halves that split the magic itself. - ota::StreamParser parser2; - auto first = parser2.feed(std::span(stream.data(), 1)); // just 'T' - CHECK(first.empty()); - auto rest = parser2.feed(std::span(stream.data() + 1, stream.size() - 1)); - CHECK(rest.size() == 2); -} - -static void test_resync_on_corruption() { - std::printf("test_resync_on_corruption\n"); - const uint8_t payload[] = {0xDE, 0xAD, 0xBE, 0xEF}; - - // Leading garbage, then a corrupted frame (bad CRC), then a good frame. - std::vector stream = {0x00, 0xFF, 0x42, 0x54 /* lone 'T' not followed by 'O' */}; - auto corrupted = ota::make_data(payload); - corrupted[ota::kHeaderSize] ^= 0xFF; // flip a payload byte -> CRC mismatch - stream.insert(stream.end(), corrupted.begin(), corrupted.end()); - const auto good = ota::make_ok(4u); - stream.insert(stream.end(), good.begin(), good.end()); - - ota::StreamParser parser; - const auto frames = parser.feed(stream); - CHECK(frames.size() == 1); - if (frames.size() == 1) { - CHECK(frames[0].type == static_cast(MessageType::Ok)); - CHECK(ota::parse_u32_payload(frames[0]).value_or(0) == 4u); + CHECK(parse_one(ota::make_progress(4096u, 8192u), prog)); + CHECK(prog.is_reply() && prog.type == static_cast(MessageType::Progress)); + const auto p = ota::parse_progress(prog); + CHECK(p.has_value()); + if (p.has_value()) { + CHECK(p->written == 4096u && p->total == 8192u); } - CHECK(parser.dropped_bytes() > 0); - CHECK(parser.buffered() == 0); -} - -static void test_oversized_len_rejected() { - std::printf("test_oversized_len_rejected\n"); - // Hand-craft a frame whose length field exceeds kMaxPayloadSize (with a - // valid CRC, so only the length cap can reject it), followed by a good frame. - std::vector bogus; - ota::put_u16(bogus, ota::kMagic); - bogus.push_back(static_cast(MessageType::Data)); - ota::put_u32(bogus, static_cast(ota::kMaxPayloadSize + 1)); - ota::put_u32(bogus, ota::crc32(std::span(bogus.data(), bogus.size()))); - - std::vector stream = bogus; - const auto good = ota::make_finish(); - stream.insert(stream.end(), good.begin(), good.end()); - - ota::StreamParser parser; - const auto frames = parser.feed(stream); - CHECK(frames.size() == 1); - if (frames.size() == 1) - CHECK(frames[0].type == static_cast(MessageType::Finish)); - CHECK(parser.dropped_bytes() > 0); - // The oversized length must never be buffered/waited for. - CHECK(parser.buffered() < ota::kMaxFrameSize); - - // The builder refuses to build an oversized frame outright. - const std::vector too_big(ota::kMaxPayloadSize + 1, 0xAB); - CHECK(ota::make_data(too_big).empty()); - // ...but a maximum-size frame is fine and round-trips. - const std::vector max_size(ota::kMaxPayloadSize, 0xCD); - const auto max_frame = ota::make_data(max_size); - CHECK(max_frame.size() == ota::kMaxFrameSize); - ota::StreamParser parser2; - const auto max_frames = parser2.feed(max_frame); - CHECK(max_frames.size() == 1); - if (max_frames.size() == 1) - CHECK(max_frames[0].payload == max_size); } static void test_malformed_reply_payloads() { std::printf("test_malformed_reply_payloads\n"); - // Wrong-size payloads must be rejected by the typed parse helpers. - Frame f{static_cast(MessageType::Ok), {0x01, 0x02}}; - CHECK(!ota::parse_u32_payload(f).has_value()); - Frame e{static_cast(MessageType::Error), {0x01, 0x02, 0x03}}; - CHECK(!ota::parse_error(e).has_value()); - Frame p{static_cast(MessageType::Progress), {0x01, 0x02, 0x03, 0x04}}; - CHECK(!ota::parse_progress(p).has_value()); + ota::Frame f{}; + f.payload = {0x01, 0x02}; + CHECK(!ota::parse_u32_payload(f).has_value()); // needs exactly 4 bytes + ota::Frame e{}; + e.payload = {0x01, 0x02, 0x03}; + CHECK(!ota::parse_error(e).has_value()); // needs >= 4 bytes + ota::Frame p{}; + p.payload = {0x01, 0x02, 0x03, 0x04}; + CHECK(!ota::parse_progress(p).has_value()); // needs exactly 8 bytes // An ERROR with just a code (no message) is valid. - Frame e2{static_cast(MessageType::Error), {0x05, 0x00, 0x00, 0x00}}; + ota::Frame e2{}; + e2.payload = {0x05, 0x00, 0x00, 0x00}; const auto info = ota::parse_error(e2); CHECK(info.has_value()); if (info.has_value()) { - CHECK(info->code == 5u); - CHECK(info->message.empty()); + CHECK(info->code == 5u && info->message.empty()); } } int main() { - test_crc32(); - test_frame_layout(); - test_round_trip_all_types(); - test_split_across_chunks(); - test_resync_on_corruption(); - test_oversized_len_rejected(); + test_requests_are_module0_requests(); + test_replies_carry_reply_flag(); test_malformed_reply_payloads(); if (g_failures == 0) { std::printf("ALL TESTS PASSED\n"); diff --git a/components/ota/web/ota_console.html b/components/ota/web/ota_console.html index 4752fedd2..d981a9703 100644 --- a/components/ota/web/ota_console.html +++ b/components/ota/web/ota_console.html @@ -13,15 +13,19 @@ protocol (see components/ota/include/detail/ota_stream_protocol.hpp for the authoritative wire spec). - Wire format (all fields LITTLE-ENDIAN): - [magic u16 = 0x4F54 "OT"][type u8][len u32][payload...][crc32 u32] + Wire format v2 (all fields LITTLE-ENDIAN): + [magic u16 = 0x4F54 "OT"][flags u8][module u8][type u8][len u32][payload...][crc32 u32] + - flags u8: bit0 = reply (0 host->device request, 1 device->host reply); + bits 4-7 = protocol version = 1. So a request byte = 0x10, a reply = 0x11. + - module u8: OTA is module 0. - the crc32 is the standard zlib CRC-32 (poly 0xEDB88320 reflected, init / - final xor 0xFFFFFFFF) over magic..payload; check value + final xor 0xFFFFFFFF) over the 9-byte header + payload; check value crc32("123456789") == 0xCBF43926. - payload length is capped at 4096 bytes per frame. - - host -> device: 0x01 BEGIN(u32 image_size), 0x02 DATA(bytes), - 0x03 FINISH, 0x04 ABORT; device -> host: 0x81 OK(u32 bytes_received), - 0x82 ERROR(u32 code + utf8 message), 0x83 PROGRESS(u32 written, u32 total). + - host -> device requests (flags reply bit 0): 0x01 BEGIN(u32 image_size), + 0x02 DATA(bytes), 0x03 FINISH, 0x04 ABORT; device -> host replies + (flags reply bit 1): 0x05 OK(u32 bytes_received), + 0x06 ERROR(u32 code + utf8 message), 0x07 PROGRESS(u32 written, u32 total). - transactions are serialized: exactly one command frame is in flight and the host waits for its OK / ERROR reply (PROGRESS frames are informational and may arrive before the reply). @@ -233,7 +237,7 @@

Log