diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4897c90b11..c52b793ecd 100755
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -328,6 +328,9 @@ jobs:
target: esp32s3
- path: 'components/sx126x/example'
target: esp32s3
+ - path: 'components/system/example'
+ target: esp32s3
+ command: 'IDF_COMPONENT_MANAGER=0 idf.py build'
- path: 'components/t-deck/example'
target: esp32s3
- path: 'components/t-dongle-s3/example'
diff --git a/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml
index 9dc15ca287..c106b1f356 100755
--- a/.github/workflows/upload_components.yml
+++ b/.github/workflows/upload_components.yml
@@ -172,6 +172,7 @@ jobs:
components/state_machine
components/switch_pro
components/sx126x
+ components/system
components/t_keyboard
components/t-deck
components/t-dongle-s3
diff --git a/components/dispatcher/README.md b/components/dispatcher/README.md
index 19cc3709be..13231e90e9 100644
--- a/components/dispatcher/README.md
+++ b/components/dispatcher/README.md
@@ -29,6 +29,8 @@ protocols and examples use these ids by default:
| 4 | crash dump | `espp.coredump` v1 |
| 5 | CAN bridge | `espp.can-bridge` v1 |
| 6 | MCP266 | `espp.mcp266` v1 |
+| 7 | System info / reboot (`espp::SystemService`) | `espp.system` v1 |
+| 8 | Heap / task monitor (`espp::MonitorService`) | `espp.monitor` v1 |
| 0xF0–0xFE | reserved (meta) | — |
| 0xFF | capability discovery | — |
diff --git a/components/dispatcher/web/test/resolve_module_id_test.js b/components/dispatcher/web/test/resolve_module_id_test.js
index 71c8b23849..b253a94683 100644
--- a/components/dispatcher/web/test/resolve_module_id_test.js
+++ b/components/dispatcher/web/test/resolve_module_id_test.js
@@ -24,6 +24,7 @@ const consoles = [
"components/mcp266/web/mcp266_console.html",
"components/bldc_haptics/web/haptics_console.html",
"components/telemetry/web/telemetry.html",
+ "components/system/web/system_console.html",
];
const hub = "components/dispatcher/web/dispatcher_hub.html";
diff --git a/components/monitor/CMakeLists.txt b/components/monitor/CMakeLists.txt
index cb1e7ccab3..fb35c98f8a 100644
--- a/components/monitor/CMakeLists.txt
+++ b/components/monitor/CMakeLists.txt
@@ -1,4 +1,6 @@
+# stream_frame + dispatcher: the header-only MonitorService (monitor_service.hpp)
+# that serves the heap / task statistics over a framed byte stream.
idf_component_register(
INCLUDE_DIRS "include"
SRC_DIRS "src"
- REQUIRES base_component task)
+ REQUIRES base_component task stream_frame dispatcher)
diff --git a/components/monitor/README.md b/components/monitor/README.md
index 30ed80fe2e..355482b41f 100644
--- a/components/monitor/README.md
+++ b/components/monitor/README.md
@@ -10,6 +10,7 @@ system.
- [Monitor Component](#monitor-component)
- [Task Monitor](#task-monitor)
+ - [Monitor Service](#monitor-service)
- [Example](#example)
@@ -24,6 +25,26 @@ There is an associated [task-monitor](https://github.com/esp-cpp/task-monitor)
python gui which can parse the output of this component and render it as a chart
or into a table for visualization.
+## Monitor Service
+
+`espp::MonitorService` (`monitor_service.hpp`) serves the heap-region and task
+statistics over any framed byte stream as an `espp::Dispatcher` module
+(`espp.monitor` v1, module 8 by default): `GET_HEAP` (one record per configured
+`MALLOC_CAP_*` region), `GET_TASKS` (the `TaskMonitor` table; needs
+`CONFIG_FREERTOS_USE_TRACE_FACILITY` + `CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS`;
+capped, like `GET_HEAP`, so the whole frame fits `Config::max_frame_bytes`, 4096 by default)
+and `SET_STREAM` (periodic HEAP / TASKS events). Replies echo the request
+frame's correlation id, so a host can pair them and drop stale ones; streamed
+events carry none. The wire codec lives in
+`include/detail/monitor_protocol.hpp` (host-buildable, tested by
+`test/monitor_host_test.cpp`). The hosted
+[system console](https://esp-cpp.github.io/espp/apps/system_console.html) web
+app renders heap gauges and a live task table from it; the
+[system](../system) component's example exposes it over USB together with
+`espp::SystemService`:
+
+
+
## Example
This example shows how to use the `monitor` component to monitor the executing
diff --git a/components/monitor/idf_component.yml b/components/monitor/idf_component.yml
index 1e8bedd91e..ab886b1e0e 100644
--- a/components/monitor/idf_component.yml
+++ b/components/monitor/idf_component.yml
@@ -1,6 +1,6 @@
## IDF Component Manager Manifest File
license: "MIT"
-description: "System Monitor component for ESP-IDF"
+description: "System Monitor component for ESP-IDF: heap and task statistics, plus a framed stream service (MonitorService) that serves them over USB / WebUSB / Web Serial"
url: "https://github.com/esp-cpp/espp/tree/main/components/monitor"
repository: "git://github.com/esp-cpp/espp.git"
maintainers:
@@ -14,8 +14,12 @@ tags:
- Monitor
- Memory
- Tasks
+ - WebUSB
+ - WebSerial
dependencies:
idf:
version: '>=5.0'
espp/base_component: '>=1.0'
espp/task: '>=1.0'
+ espp/stream_frame: '>=1.0'
+ espp/dispatcher: '>=1.0'
diff --git a/components/monitor/include/detail/monitor_protocol.hpp b/components/monitor/include/detail/monitor_protocol.hpp
new file mode 100644
index 0000000000..6efadc7669
--- /dev/null
+++ b/components/monitor/include/detail/monitor_protocol.hpp
@@ -0,0 +1,259 @@
+#pragma once
+
+// Wire protocol of espp::MonitorService: heap-region and task statistics over
+// the espp stream_frame codec, routed by an espp::Dispatcher on module 8 by
+// default (`espp.monitor` v1 through discovery).
+//
+// This header is deliberately host-buildable (stream_frame.hpp + the standard
+// library only) so the codec is unit-tested on the host
+// (components/monitor/test/monitor_host_test.cpp) and so host tools can reuse
+// it. All multi-byte fields are little-endian; a `str` is [len u8][bytes].
+//
+// Requests (host -> device, reply flag clear):
+// 0x01 GET_HEAP (no payload)
+// 0x02 GET_TASKS (no payload)
+// 0x03 SET_STREAM [enable u8][period_ms u16][what u8: bit0 heap, bit1 tasks]
+// enable = 0 stops the periodic HEAP / TASKS events; the
+// device clamps the period to its Config::min_stream_period.
+// Replies / events (device -> host, high bit set = frame reply flag):
+// 0x81 HEAP [count u8]{[flags u32][free u32][min_free u32]
+// [largest_free_block u32][allocated u32][total u32]}
+// 0x82 TASKS [count u8]{[name str][cpu_percent u8][high_water_mark u32]
+// [priority u8][core i8]}
+// 0x83 OK [request_type u8]
+// 0x84 ERROR [request_type u8][code u32][utf8 message]
+// code = the POSIX errno value of the std::errc the service
+// chose (informational; the message is authoritative)
+// HEAP / TASKS answer the matching GET_* request and are also sent
+// unsolicited while streaming is enabled (same encoding, so a host decodes
+// both the same way). Replies echo the request frame's correlation id (if it
+// carried one); streamed events carry none. HEAP and TASKS payloads are capped
+// (MonitorService: to fit Config::max_frame_bytes with the frame overhead; at
+// most the payload limit):
+// regions / tasks that would not fit are dropped from the END of the list.
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "stream_frame.hpp"
+
+namespace espp::detail::monitor_protocol {
+
+/// Default dispatcher module id (a routing key only; see MonitorService::Config::module).
+inline constexpr uint8_t kModule = 8;
+/// Stable protocol identifier + version advertised through discovery.
+inline constexpr const char *kProtocol = "espp.monitor";
+inline constexpr uint16_t kProtocolVersion = 1;
+
+/// Frame `type` values within the monitor module.
+enum class Type : uint8_t {
+ // host -> device
+ GetHeap = 0x01,
+ GetTasks = 0x02,
+ SetStream = 0x03,
+ // device -> host (high bit set)
+ Heap = 0x81,
+ Tasks = 0x82,
+ Ok = 0x83,
+ Error = 0x84,
+};
+
+/// SET_STREAM `what` bits.
+inline constexpr uint8_t kStreamHeap = 0x01;
+inline constexpr uint8_t kStreamTasks = 0x02;
+
+/// One heap region as carried in a HEAP payload.
+struct HeapRegion {
+ uint32_t flags{0}; ///< MALLOC_CAP_* bitmask the region was queried with.
+ uint32_t free_bytes{0};
+ uint32_t min_free_bytes{0};
+ uint32_t largest_free_block{0};
+ uint32_t allocated_bytes{0};
+ uint32_t total_size{0};
+};
+
+/// One task as carried in a TASKS payload.
+struct TaskEntry {
+ std::string name;
+ uint8_t cpu_percent{0};
+ uint32_t high_water_mark{0};
+ uint8_t priority{0};
+ int8_t core_id{-2}; ///< 0 / 1, -1 = unpinned, -2 = unknown (core ids not compiled in).
+};
+
+/// Decoded SET_STREAM request.
+struct StreamRequest {
+ bool enable{false};
+ uint16_t period_ms{0};
+ uint8_t what{0}; ///< kStreamHeap | kStreamTasks
+};
+
+/// Bytes one TaskEntry occupies on the wire (name capped at 255).
+inline size_t task_entry_size(std::string_view name) {
+ return 1 + (name.size() > 255 ? 255 : name.size()) + 1 + 4 + 1 + 1;
+}
+
+/// Append a [len u8][bytes] string (truncated to 255 bytes).
+inline void put_str(std::vector &out, std::string_view s) {
+ const size_t n = s.size() > 255 ? 255 : s.size();
+ out.push_back(static_cast(n));
+ out.insert(out.end(), s.begin(), s.begin() + static_cast(n));
+}
+
+/// Whether a type value is a device->host reply / event.
+inline constexpr bool is_reply(Type type) { return (static_cast(type) & 0x80) != 0; }
+
+/// Build an encoded frame for a monitor message (device->host types map to the
+/// frame reply flag).
+/// @param correlation The stream_frame correlation id to carry: a reply echoes
+/// the request's (so a host can pair them), a streamed event carries none.
+inline std::vector build_frame(Type type, std::span payload = {},
+ uint8_t module = kModule,
+ std::optional correlation = std::nullopt) {
+ return espp::stream_frame::build_frame(is_reply(type), module, static_cast(type),
+ payload, correlation);
+}
+
+// ---- encoders ---------------------------------------------------------------
+
+/// Bytes one HeapRegion occupies on the wire.
+inline constexpr size_t kHeapRegionSize = 24;
+
+/// Encode a HEAP payload, keeping it within @p max_bytes (the frame payload
+/// limit by default): regions that would not fit are dropped from the end.
+/// At most 255 regions are encoded.
+/// @param[out] encoded_count Set to the number of regions encoded, if non-null.
+inline std::vector encode_heap(std::span regions,
+ size_t max_bytes = espp::stream_frame::kMaxPayloadSize,
+ size_t *encoded_count = nullptr) {
+ std::vector p;
+ const size_t fit = max_bytes > 1 ? (max_bytes - 1) / kHeapRegionSize : 0;
+ const size_t n = std::min({regions.size(), size_t{255}, fit});
+ if (encoded_count)
+ *encoded_count = n;
+ p.reserve(1 + kHeapRegionSize * n);
+ p.push_back(static_cast(n));
+ for (const auto &r : regions.first(n)) {
+ espp::stream_frame::put_u32(p, r.flags);
+ espp::stream_frame::put_u32(p, r.free_bytes);
+ espp::stream_frame::put_u32(p, r.min_free_bytes);
+ espp::stream_frame::put_u32(p, r.largest_free_block);
+ espp::stream_frame::put_u32(p, r.allocated_bytes);
+ espp::stream_frame::put_u32(p, r.total_size);
+ }
+ return p;
+}
+
+/// Encode a TASKS payload, keeping it within @p max_bytes (the frame payload
+/// limit by default): tasks that would not fit are dropped from the end.
+/// @param[out] encoded_count Set to the number of tasks encoded, if non-null.
+inline std::vector encode_tasks(std::span tasks,
+ size_t max_bytes = espp::stream_frame::kMaxPayloadSize,
+ size_t *encoded_count = nullptr) {
+ std::vector p;
+ p.push_back(0); // count, patched below
+ size_t n = 0;
+ for (const auto &t : tasks) {
+ if (n == 255 || p.size() + task_entry_size(t.name) > max_bytes)
+ break;
+ put_str(p, t.name);
+ p.push_back(t.cpu_percent);
+ espp::stream_frame::put_u32(p, t.high_water_mark);
+ p.push_back(t.priority);
+ p.push_back(static_cast(t.core_id));
+ ++n;
+ }
+ p[0] = static_cast(n);
+ if (encoded_count)
+ *encoded_count = n;
+ return p;
+}
+
+/// Encode a SET_STREAM request payload.
+inline std::vector encode_set_stream(bool enable, uint16_t period_ms, uint8_t what) {
+ std::vector p;
+ p.push_back(enable ? 1 : 0);
+ espp::stream_frame::put_u16(p, period_ms);
+ p.push_back(what);
+ return p;
+}
+
+/// Encode an OK payload.
+inline std::vector encode_ok(uint8_t request_type) { return {request_type}; }
+
+/// Encode an ERROR payload.
+inline std::vector encode_error(uint8_t request_type, uint32_t code,
+ std::string_view message) {
+ std::vector p;
+ p.push_back(request_type);
+ espp::stream_frame::put_u32(p, code);
+ p.insert(p.end(), message.begin(), message.end());
+ return p;
+}
+
+// ---- decoders (nullopt on a malformed payload) ---------------------------------
+
+inline std::optional> decode_heap(std::span p) {
+ if (p.empty())
+ return std::nullopt;
+ const size_t n = p[0];
+ if (p.size() < 1 + 24 * n)
+ return std::nullopt;
+ std::vector out;
+ out.reserve(n);
+ size_t i = 1;
+ for (size_t k = 0; k < n; ++k, i += 24) {
+ HeapRegion r;
+ r.flags = espp::stream_frame::get_u32(p.subspan(i));
+ r.free_bytes = espp::stream_frame::get_u32(p.subspan(i + 4));
+ r.min_free_bytes = espp::stream_frame::get_u32(p.subspan(i + 8));
+ r.largest_free_block = espp::stream_frame::get_u32(p.subspan(i + 12));
+ r.allocated_bytes = espp::stream_frame::get_u32(p.subspan(i + 16));
+ r.total_size = espp::stream_frame::get_u32(p.subspan(i + 20));
+ out.push_back(r);
+ }
+ return out;
+}
+
+inline std::optional> decode_tasks(std::span p) {
+ if (p.empty())
+ return std::nullopt;
+ const size_t n = p[0];
+ std::vector out;
+ out.reserve(n);
+ size_t i = 1;
+ for (size_t k = 0; k < n; ++k) {
+ if (i >= p.size())
+ return std::nullopt;
+ const size_t len = p[i++];
+ if (i + len + 7 > p.size())
+ return std::nullopt;
+ TaskEntry t;
+ t.name.assign(reinterpret_cast(p.data() + i), len);
+ i += len;
+ t.cpu_percent = p[i++];
+ t.high_water_mark = espp::stream_frame::get_u32(p.subspan(i));
+ i += 4;
+ t.priority = p[i++];
+ t.core_id = static_cast(p[i++]);
+ out.push_back(std::move(t));
+ }
+ return out;
+}
+
+inline std::optional decode_set_stream(std::span p) {
+ if (p.size() < 4)
+ return std::nullopt;
+ StreamRequest r;
+ r.enable = p[0] != 0;
+ r.period_ms = espp::stream_frame::get_u16(p.subspan(1));
+ r.what = p[3];
+ return r;
+}
+
+} // namespace espp::detail::monitor_protocol
diff --git a/components/monitor/include/monitor_service.hpp b/components/monitor/include/monitor_service.hpp
new file mode 100644
index 0000000000..e2aca9651c
--- /dev/null
+++ b/components/monitor/include/monitor_service.hpp
@@ -0,0 +1,350 @@
+#pragma once
+
+// espp::MonitorService -- heap-region and task statistics (espp::HeapMonitor /
+// espp::TaskMonitor) as a transport-agnostic dispatcher module
+// (detail/monitor_protocol.hpp is the wire spec). Same contract as the other
+// espp services: requests are handled under an internal mutex, the `send`
+// callback runs with that mutex released, frames for other modules /
+// reply-flagged frames are ignored so the service shares a stream.
+//
+// Wiring (one line per transport):
+//
+// espp::MonitorService monitor_service({.send = [&](auto f) { usb.write_vendor(f); }});
+// dispatcher.register_module(monitor_service); // module 8 + discovery metadata
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "esp_heap_caps.h"
+
+#include "dispatcher.hpp"
+#include "stream_frame.hpp"
+
+#include "base_component.hpp"
+#include "detail/monitor_protocol.hpp"
+#include "heap_monitor.hpp"
+#include "task.hpp"
+#include "task_monitor.hpp"
+
+namespace espp {
+
+/**
+ * @brief Serves heap-region and task statistics over any framed byte stream
+ * (dispatcher module 8 by default; see Config::module).
+ *
+ * GET_HEAP answers with one record per configured heap region
+ * (Config::heap_regions, MALLOC_CAP_* masks; regions that do not exist on the
+ * chip, i.e. report a total size of 0, are left out). GET_TASKS answers with
+ * espp::TaskMonitor's per-task table (name, CPU %, stack high-water mark,
+ * priority, core); it needs CONFIG_FREERTOS_USE_TRACE_FACILITY and
+ * CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS -- without them the list is empty
+ * (logged once), never an error. A TASKS payload is capped at the frame
+ * payload limit: tasks that do not fit are dropped from the end (logged).
+ * SET_STREAM starts (or stops) a task that sends HEAP and / or TASKS events
+ * periodically (period clamped to Config::min_stream_period), so a host can
+ * plot them live without polling.
+ *
+ * **Threading**: an internal mutex covers the parser and request handling;
+ * every outbound frame (replies and streamed events alike) is serialized on
+ * a send mutex held across the `send` callback, so frames never interleave
+ * and `send` never runs concurrently with itself. `send` must therefore not
+ * re-enter this object.
+ *
+ * \section monitor_service_ex1 MonitorService Example
+ * \snippet system_example.cpp system_example
+ */
+class MonitorService : public BaseComponent {
+public:
+ using Stream = espp::stream_frame::StreamParser;
+ using Type = espp::detail::monitor_protocol::Type;
+
+ /// Default dispatcher module id (8). A routing key only: Config::module serves
+ /// on any id, and hosts find it through discovery (by kProtocol).
+ static constexpr uint8_t kModule = espp::detail::monitor_protocol::kModule;
+ /// Stable protocol identifier + version advertised through discovery.
+ static constexpr const char *kProtocol = espp::detail::monitor_protocol::kProtocol;
+ static constexpr uint16_t kProtocolVersion = espp::detail::monitor_protocol::kProtocolVersion;
+
+ /// Transmits one encoded frame to the host.
+ using send_fn = std::function frame)>;
+
+ /// Configuration for the MonitorService.
+ struct Config {
+ send_fn send{nullptr}; ///< Transmits an encoded frame (required).
+ /// Dispatcher module id this instance answers on (and stamps on every
+ /// frame it sends). A routing key only (0x00..0xEF).
+ uint8_t module{kModule};
+ /// Heap regions reported, as MALLOC_CAP_* masks (HeapMonitor::get_info).
+ std::vector heap_regions{MALLOC_CAP_DEFAULT, MALLOC_CAP_INTERNAL, MALLOC_CAP_SPIRAM};
+ /// Shortest streaming period a host may request (SET_STREAM is clamped to it).
+ std::chrono::milliseconds min_stream_period{100};
+ /// Largest encoded frame (header + payload + CRC) `send` can carry in one
+ /// write: the HEAP and TASKS payloads are capped so the whole frame fits
+ /// (regions / tasks that do not fit are dropped from the end, logged). 4096 matches the default
+ /// TinyUSB vendor / CDC TX FIFO of the espp examples; the stream_frame
+ /// maximum is kMaxFrameSize (4111).
+ size_t max_frame_bytes{4096};
+ /// The streaming task (started on the first SET_STREAM enable).
+ Task::BaseConfig task_config{.name = "monitor_stream", .stack_size_bytes = 6 * 1024};
+ espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Logger verbosity.
+ };
+
+ /// @brief Construct the service.
+ explicit MonitorService(const Config &config)
+ : BaseComponent("MonitorService", config.log_level)
+ , config_(config)
+ , period_(config.min_stream_period) {}
+
+ ~MonitorService() { stop_stream(); }
+
+ /// @brief The dispatcher module id this service answers on (Config::module).
+ uint8_t module_id() const { return config_.module; }
+
+ /// @brief Discovery metadata for registering this service on a Dispatcher.
+ Dispatcher::ModuleInfo module_info() const {
+ return {.name = "Monitor",
+ .app = "system_console.html",
+ .description = "Heap regions and task statistics",
+ .protocol = kProtocol,
+ .protocol_version = kProtocolVersion};
+ }
+
+ /// @brief Whether periodic HEAP / TASKS events are being sent.
+ bool streaming() const { return streaming_.load(); }
+
+ /// @brief Stop streaming (also done by the destructor and by SET_STREAM 0).
+ void stop_stream() {
+ std::unique_ptr task;
+ {
+ std::lock_guard lock(mutex_);
+ streaming_.store(false);
+ task = std::move(task_);
+ }
+ if (task)
+ task->stop();
+ }
+
+ /**
+ * @brief Dispatcher entry point: handle one routed frame. Frames for other
+ * modules and reply-flagged frames are ignored, so this can be
+ * registered directly: `dispatcher.register_module(service)`.
+ */
+ void handle(const espp::stream_frame::Frame &frame) {
+ if (frame.module != module_id() || frame.is_reply())
+ return;
+ handle_frame(frame.type, frame.payload, frame.correlation);
+ }
+
+ /// @brief Feed received transport bytes (standalone use, without a Dispatcher).
+ void feed(std::span data) {
+ std::vector frames;
+ {
+ std::lock_guard lock(mutex_);
+ frames = parser_.feed(data);
+ }
+ for (const auto &frame : frames)
+ handle(frame);
+ }
+
+ /// @brief Discard any partially-buffered frame bytes (standalone feed() use).
+ void reset_parser() {
+ std::lock_guard lock(mutex_);
+ parser_.reset();
+ }
+
+ /**
+ * @brief Handle one already-parsed request frame.
+ * @return true if the type belongs to the monitor protocol (a reply was
+ * sent), false if it was ignored.
+ */
+ /// @param correlation The request frame's correlation id, if it carried one;
+ /// every reply echoes it (streamed events carry none).
+ bool handle_frame(uint8_t type, std::span payload,
+ std::optional correlation = std::nullopt) {
+ namespace proto = espp::detail::monitor_protocol;
+ switch (static_cast(type)) {
+ case Type::GetHeap:
+ send_frame(proto::build_frame(Type::Heap, build_heap(), module_id(), correlation));
+ return true;
+ case Type::GetTasks:
+ send_frame(proto::build_frame(Type::Tasks, build_tasks(), module_id(), correlation));
+ return true;
+ case Type::SetStream: {
+ const auto req = proto::decode_set_stream(payload);
+ if (!req) {
+ send_error(type, std::errc::invalid_argument,
+ "malformed SET_STREAM (expected u8 enable, u16 period_ms, u8 what)",
+ correlation);
+ return true;
+ }
+ if (req->enable && (req->what & (proto::kStreamHeap | proto::kStreamTasks)) == 0) {
+ send_error(type, std::errc::invalid_argument, "SET_STREAM: nothing selected to stream",
+ correlation);
+ return true;
+ }
+ if (req->enable)
+ start_stream(std::chrono::milliseconds(req->period_ms), req->what);
+ else
+ stop_stream();
+ logger_.debug("SET_STREAM enable={} period_ms={} what=0x{:02x}", req->enable,
+ period_.load().count(), req->what);
+ send_frame(proto::build_frame(Type::Ok, proto::encode_ok(type), module_id(), correlation));
+ return true;
+ }
+ default:
+ return false; // not a monitor request: ignore so the service can share a stream
+ }
+ }
+
+protected:
+ /// The HEAP payload for the configured regions (regions with no memory left
+ /// out; capped so the frame fits Config::max_frame_bytes, the overflow logged).
+ std::vector build_heap() {
+ namespace proto = espp::detail::monitor_protocol;
+ std::vector regions;
+ regions.reserve(config_.heap_regions.size());
+ for (const int flags : config_.heap_regions) {
+ const HeapMonitor::HeapInfo hi = HeapMonitor::get_info(flags);
+ if (hi.total_size == 0)
+ continue; // e.g. MALLOC_CAP_SPIRAM on a chip without PSRAM
+ // the wire carries the QUERY mask (what the region was asked for), so a
+ // host can label it the way it configured the service
+ regions.push_back({.flags = static_cast(flags),
+ .free_bytes = static_cast(hi.free_bytes),
+ .min_free_bytes = static_cast(hi.min_free_bytes),
+ .largest_free_block = static_cast(hi.largest_free_block),
+ .allocated_bytes = static_cast(hi.allocated_bytes),
+ .total_size = static_cast(hi.total_size)});
+ }
+ size_t encoded = 0;
+ auto payload = proto::encode_heap(regions, max_payload(), &encoded);
+ if (encoded < regions.size())
+ logger_.warn_rate_limited("HEAP payload full: reporting {} of {} regions", encoded,
+ regions.size());
+ return payload;
+ }
+
+ /// The TASKS payload (capped at the frame payload limit).
+ std::vector build_tasks() {
+ namespace proto = espp::detail::monitor_protocol;
+#if CONFIG_FREERTOS_USE_TRACE_FACILITY && CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS
+ const auto infos = TaskMonitor::get_latest_info_vector();
+ std::vector tasks;
+ tasks.reserve(infos.size());
+ std::transform(
+ infos.begin(), infos.end(), std::back_inserter(tasks), [](const TaskMonitor::TaskInfo &t) {
+ return proto::TaskEntry{
+ .name = t.name,
+ .cpu_percent = static_cast(t.cpu_percent > 100 ? 100 : t.cpu_percent),
+ .high_water_mark = t.high_water_mark,
+ .priority = static_cast(t.priority > 255 ? 255 : t.priority),
+ .core_id = static_cast(t.core_id)};
+ });
+ size_t encoded = 0;
+ auto payload = proto::encode_tasks(tasks, max_payload(), &encoded);
+ if (encoded < tasks.size())
+ logger_.warn_rate_limited("TASKS payload full: reporting {} of {} tasks", encoded,
+ tasks.size());
+ return payload;
+#else
+ // TaskMonitor cannot collect anything without the FreeRTOS stats Kconfig:
+ // report an empty list (and say why, once in a while)
+ logger_.warn_rate_limited("task statistics need CONFIG_FREERTOS_USE_TRACE_FACILITY and "
+ "CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS; reporting no tasks");
+ return proto::encode_tasks({}, max_payload(), nullptr);
+#endif
+ }
+
+ /// The payload cap shared by HEAP and TASKS: Config::max_frame_bytes less the
+ /// frame overhead (the largest header, i.e. with a correlation id echoed,
+ /// plus the CRC), never above the codec's own payload limit.
+ size_t max_payload() const {
+ constexpr size_t overhead = espp::stream_frame::kMaxHeaderSize + espp::stream_frame::kCrcSize;
+ const size_t cap = config_.max_frame_bytes > overhead ? config_.max_frame_bytes - overhead : 0;
+ return std::min(cap, espp::stream_frame::kMaxPayloadSize);
+ }
+
+ void start_stream(std::chrono::milliseconds period, uint8_t what) {
+ std::lock_guard lock(mutex_);
+ period_.store(std::max(period, config_.min_stream_period));
+ what_.store(what);
+ streaming_.store(true);
+ if (task_)
+ return; // already running: the new period / selection apply on its next wake
+ task_ = std::make_unique(
+ Task::Config{.callback = [this](std::mutex &m, std::condition_variable &cv,
+ bool ¬ified) { return stream_step(m, cv, notified); },
+ .task_config = config_.task_config});
+ task_->start();
+ }
+
+ /// One streaming period: send the selected reports (no correlation id: they
+ /// are events, not replies), then wait one period. The wait uses the Task's
+ /// notified flag as its predicate, so a spurious wake-up does not emit early;
+ /// Task::stop() notifies, which ends the wait and the task.
+ bool stream_step(std::mutex &m, std::condition_variable &cv, bool ¬ified) {
+ namespace proto = espp::detail::monitor_protocol;
+ if (streaming_.load()) {
+ const uint8_t what = what_.load();
+ if (what & proto::kStreamHeap)
+ send_frame(proto::build_frame(Type::Heap, build_heap(), module_id()));
+ if (what & proto::kStreamTasks)
+ send_frame(proto::build_frame(Type::Tasks, build_tasks(), module_id()));
+ }
+ std::unique_lock lock(m);
+ cv.wait_for(lock, period_.load(), [¬ified] { return notified; });
+ notified = false; // consumed, under the mutex, per the Task contract
+ return false; // keep running until stopped
+ }
+
+ /// Transmit a frame. Serialized on send_mutex_ (held across the callback) so
+ /// a streamed event and a reply never interleave. Never called with mutex_ held.
+ void send_frame(const std::vector &frame) {
+ if (frame.empty())
+ return;
+ std::lock_guard send_lock(send_mutex_);
+ if (!config_.send) {
+ logger_.warn_rate_limited("no send function configured; dropping a {}-byte frame",
+ frame.size());
+ return;
+ }
+ config_.send(frame);
+ }
+
+ void send_error(uint8_t request_type, std::errc errc, std::string_view message,
+ std::optional correlation = std::nullopt) {
+ namespace proto = espp::detail::monitor_protocol;
+ logger_.warn("{} (type 0x{:02x})", message, request_type);
+ send_frame(proto::build_frame(
+ Type::Error,
+ proto::encode_error(request_type, static_cast(std::make_error_code(errc).value()),
+ message),
+ module_id(), correlation));
+ }
+
+private:
+ Config config_;
+ mutable std::mutex mutex_; ///< guards the parser and task_
+ mutable std::mutex send_mutex_; ///< serializes every outbound frame across `send`
+ Stream parser_;
+ std::unique_ptr task_;
+ std::atomic streaming_{false};
+ std::atomic what_{0};
+ std::atomic period_;
+};
+
+// Compile-time check that the service keeps satisfying the dispatcher's module
+// contract (module_id() / module_info() / handle(frame)).
+static_assert(DispatcherModuleConcept);
+
+} // namespace espp
diff --git a/components/monitor/test/monitor_host_test.cpp b/components/monitor/test/monitor_host_test.cpp
new file mode 100644
index 0000000000..84ff8893c7
--- /dev/null
+++ b/components/monitor/test/monitor_host_test.cpp
@@ -0,0 +1,189 @@
+// Host-buildable unit tests for the MonitorService wire codec
+// (include/detail/monitor_protocol.hpp). Build & run with:
+// c++ -std=c++20 -I../include -I../../stream_frame/include monitor_host_test.cpp -o test &&
+// ./test
+//
+// The codec needs no ESP-IDF headers; these are golden encode/decode tests so
+// the browser console and any host tool can rely on the byte layout.
+
+#include
+#include
+#include
+#include
+#include
+
+#include "detail/monitor_protocol.hpp"
+
+namespace mp = espp::detail::monitor_protocol;
+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_heap_roundtrip() {
+ std::printf("test_heap_roundtrip\n");
+ std::vector regions = {
+ {.flags = 0x1800,
+ .free_bytes = 100000,
+ .min_free_bytes = 90000,
+ .largest_free_block = 65536,
+ .allocated_bytes = 200000,
+ .total_size = 300000},
+ {.flags = 0x400,
+ .free_bytes = 1,
+ .min_free_bytes = 2,
+ .largest_free_block = 3,
+ .allocated_bytes = 4,
+ .total_size = 5},
+ };
+ const auto p = mp::encode_heap(regions);
+ CHECK(p.size() == 1 + 2 * 24);
+ CHECK(p[0] == 2);
+ // golden: first region, flags 0x1800 little-endian, free 100000 = 0x000186A0
+ CHECK(p[1] == 0x00 && p[2] == 0x18 && p[3] == 0x00 && p[4] == 0x00);
+ CHECK(p[5] == 0xA0 && p[6] == 0x86 && p[7] == 0x01 && p[8] == 0x00);
+ const auto d = mp::decode_heap(p);
+ CHECK(d && d->size() == 2);
+ if (d && d->size() == 2) {
+ CHECK((*d)[0].flags == 0x1800 && (*d)[0].free_bytes == 100000 && (*d)[0].total_size == 300000);
+ CHECK((*d)[1].largest_free_block == 3 && (*d)[1].allocated_bytes == 4);
+ }
+ // truncated: declared 2 regions, only one present
+ CHECK(!mp::decode_heap(std::span(p.data(), 1 + 24)));
+ CHECK(!mp::decode_heap({}));
+ // the cap drops whole regions from the end (the service passes its
+ // max_payload(); 1 count byte + 24 per region)
+ size_t nh = 0;
+ const auto capped = mp::encode_heap(regions, 1 + 24 + 5, &nh);
+ CHECK(nh == 1 && capped[0] == 1 && capped.size() == 25);
+ CHECK(mp::decode_heap(capped) && mp::decode_heap(capped)->size() == 1);
+ size_t nz = 9;
+ CHECK(mp::encode_heap(regions, 10, &nz).size() == 1 && nz == 0);
+ // empty list
+ const auto e = mp::encode_heap({});
+ CHECK(e.size() == 1 && e[0] == 0);
+ CHECK(mp::decode_heap(e) && mp::decode_heap(e)->empty());
+}
+
+static void test_tasks_roundtrip_and_cap() {
+ std::printf("test_tasks_roundtrip_and_cap\n");
+ std::vector tasks = {
+ {.name = "main", .cpu_percent = 12, .high_water_mark = 3000, .priority = 1, .core_id = 0},
+ {.name = "IDLE1", .cpu_percent = 88, .high_water_mark = 500, .priority = 0, .core_id = 1},
+ {.name = "tiT", .cpu_percent = 0, .high_water_mark = 1234, .priority = 18, .core_id = -1},
+ };
+ size_t encoded = 0;
+ const auto p = mp::encode_tasks(tasks, sf::kMaxPayloadSize, &encoded);
+ CHECK(encoded == 3 && p[0] == 3);
+ // golden first record: [4]"main"[12][0xB8 0x0B 0 0][1][0]
+ const uint8_t golden[] = {3, 4, 'm', 'a', 'i', 'n', 12, 0xB8, 0x0B, 0, 0, 1, 0};
+ CHECK(p.size() >= sizeof(golden) && std::equal(golden, golden + sizeof(golden), p.begin()));
+ const auto d = mp::decode_tasks(p);
+ CHECK(d && d->size() == 3);
+ if (d && d->size() == 3) {
+ CHECK((*d)[1].name == "IDLE1" && (*d)[1].cpu_percent == 88 && (*d)[1].core_id == 1);
+ CHECK((*d)[2].core_id == -1 && (*d)[2].priority == 18 && (*d)[2].high_water_mark == 1234);
+ }
+ // truncated payload is rejected
+ CHECK(!mp::decode_tasks(std::span(p.data(), p.size() - 1)));
+ // the cap drops whole entries from the end
+ size_t n2 = 0;
+ const auto capped = mp::encode_tasks(tasks, 1 + mp::task_entry_size("main") + 3, &n2);
+ CHECK(n2 == 1 && capped[0] == 1 && capped.size() == 1 + mp::task_entry_size("main"));
+ // the service caps the payload so the whole frame (9-byte header + CRC)
+ // fits its max_frame_bytes: with the default 4096 that is a 4083-byte payload
+ const size_t service_cap = 4096 - (sf::kHeaderSize + sf::kCrcSize);
+ CHECK(service_cap == 4083);
+ size_t n4 = 0;
+ std::vector lots(400, {.name = std::string(16, 'y'),
+ .cpu_percent = 1,
+ .high_water_mark = 1,
+ .priority = 1,
+ .core_id = 0});
+ const auto fitted = mp::encode_tasks(lots, service_cap, &n4);
+ CHECK(fitted.size() <= service_cap && n4 == fitted[0] && n4 < 400);
+ CHECK(mp::build_frame(mp::Type::Tasks, fitted).size() <= 4096);
+ // a 4096-byte payload never overflows: 500 tasks with long names
+ std::vector many(500, {.name = std::string(40, 'x'),
+ .cpu_percent = 1,
+ .high_water_mark = 1,
+ .priority = 1,
+ .core_id = 0});
+ size_t n3 = 0;
+ const auto big = mp::encode_tasks(many, sf::kMaxPayloadSize, &n3);
+ CHECK(big.size() <= sf::kMaxPayloadSize && n3 < 500 && n3 == big[0]);
+ CHECK(mp::decode_tasks(big) && mp::decode_tasks(big)->size() == n3);
+ // a name longer than 255 bytes is truncated on the wire
+ std::vector longname = {{.name = std::string(300, 'n'),
+ .cpu_percent = 0,
+ .high_water_mark = 0,
+ .priority = 0,
+ .core_id = 0}};
+ const auto ln = mp::encode_tasks(longname);
+ CHECK(mp::decode_tasks(ln) && (*mp::decode_tasks(ln))[0].name.size() == 255);
+}
+
+static void test_set_stream_and_replies() {
+ std::printf("test_set_stream_and_replies\n");
+ const auto p = mp::encode_set_stream(true, 250, mp::kStreamHeap | mp::kStreamTasks);
+ const uint8_t golden[] = {1, 0xFA, 0x00, 0x03};
+ CHECK(p.size() == 4 && std::equal(golden, golden + 4, p.begin()));
+ const auto r = mp::decode_set_stream(p);
+ CHECK(r && r->enable && r->period_ms == 250 && r->what == 3);
+ CHECK(!mp::decode_set_stream(std::span(p.data(), 3)));
+ CHECK(mp::encode_ok(0x03) == std::vector{0x03});
+ const auto e = mp::encode_error(0x02, 95, "no");
+ const uint8_t eg[] = {0x02, 95, 0, 0, 0, 'n', 'o'};
+ CHECK(e.size() == 7 && std::equal(eg, eg + 7, e.begin()));
+}
+
+static void test_frames() {
+ std::printf("test_frames\n");
+ // device->host types set the frame reply flag; requests do not
+ const auto req = mp::build_frame(mp::Type::GetHeap, {}, 8);
+ CHECK(req.size() == 9 + 4 && req[2] == 0x10 && req[3] == 8 && req[4] == 0x01);
+ const auto rep = mp::build_frame(mp::Type::Heap, mp::encode_heap({}), 9);
+ CHECK(rep[2] == 0x11 && rep[3] == 9 && rep[4] == 0x81);
+ sf::StreamParser parser;
+ std::vector stream(req);
+ stream.insert(stream.end(), rep.begin(), rep.end());
+ const auto frames = parser.feed(stream);
+ CHECK(frames.size() == 2 && !frames[0].is_reply() && frames[1].is_reply() &&
+ frames[1].module == 9 && frames[1].payload.size() == 1);
+ // correlation: a request may carry a u16 id; a reply built with the
+ // request's id (what MonitorService::handle_frame does for every reply)
+ // echoes it, a streamed event (built without one) carries none
+ const auto creq = mp::build_frame(mp::Type::GetTasks, {}, 8, 0xBEEF);
+ sf::StreamParser p2;
+ const auto cf = p2.feed(creq);
+ CHECK(cf.size() == 1 && cf[0].has_correlation() && *cf[0].correlation == 0xBEEF &&
+ creq.size() == 9 + 2 + 4 && (creq[2] & 0x02) != 0);
+ const auto crep = mp::build_frame(mp::Type::Tasks, mp::encode_tasks({}), 8, cf[0].correlation);
+ const auto cr = sf::StreamParser{}.feed(crep);
+ CHECK(cr.size() == 1 && cr[0].is_reply() && cr[0].correlation == std::optional(0xBEEF));
+ const auto ev = mp::build_frame(mp::Type::Heap, mp::encode_heap({}), 8);
+ const auto er = sf::StreamParser{}.feed(ev);
+ CHECK(er.size() == 1 && !er[0].has_correlation());
+ // the largest correlated frame the service may build under the default
+ // 4096-byte cap: max header (11) + payload + crc (4) <= 4096
+ CHECK(sf::kMaxHeaderSize + (4096 - sf::kMaxHeaderSize - sf::kCrcSize) + sf::kCrcSize == 4096);
+}
+
+int main() {
+ test_heap_roundtrip();
+ test_tasks_roundtrip_and_cap();
+ test_set_stream_and_replies();
+ test_frames();
+ if (g_failures) {
+ std::printf("%d FAILURE(S)\n", g_failures);
+ return 1;
+ }
+ std::printf("ALL TESTS PASSED\n");
+ return 0;
+}
diff --git a/components/system/CMakeLists.txt b/components/system/CMakeLists.txt
new file mode 100644
index 0000000000..d387f08077
--- /dev/null
+++ b/components/system/CMakeLists.txt
@@ -0,0 +1,18 @@
+# Header-only component (system_info.hpp, system_control.hpp, system_service.hpp).
+#
+# REQUIRES are public since the headers include theirs:
+# esp_system — esp_system.h (esp_restart, esp_reset_reason, heap figures)
+# esp_app_format — esp_app_desc.h (the embedded application description)
+# app_update — esp_ota_ops.h (running / boot partition, OTA state)
+# esp_hw_support — esp_chip_info.h, esp_mac.h, esp_clk_tree.h
+# esp_timer — esp_timer.h (uptime)
+# spi_flash — esp_flash.h (flash size)
+# esp_psram — esp_psram.h (PSRAM size; empty component without PSRAM)
+# soc / esp_rom — the always-on register + ROM USB persist calls behind
+# SystemControl::reboot_to_bootloader()
+# format — format.hpp (SystemInfo::to_string)
+# stream_frame, dispatcher — the framed service (header-only, ESP-free codec)
+idf_component_register(
+ INCLUDE_DIRS "include"
+ REQUIRES base_component esp_system esp_app_format app_update esp_hw_support esp_timer spi_flash esp_psram soc esp_rom format stream_frame dispatcher
+)
diff --git a/components/system/README.md b/components/system/README.md
new file mode 100644
index 0000000000..54bfc72d0b
--- /dev/null
+++ b/components/system/README.md
@@ -0,0 +1,92 @@
+# System Component
+
+[](https://components.espressif.com/components/espp/system)
+
+The `system` component answers "what is this device, how is it doing, and can
+I restart it?" for any espp application:
+
+- `espp::SystemInfo` — static getters (and a one-call `collect()` /
+ `to_string()` snapshot) for the chip model / revision / cores / features, the
+ ESP-IDF version, the application description (project name, version, build
+ date and time, ELF SHA-256), the running and boot partitions with the OTA
+ image state, the reset reason, uptime, base MAC, flash and PSRAM sizes, CPU
+ frequency and the free / minimum-free heap.
+- `espp::SystemControl` — `reboot()` and `reboot_to_bootloader()`: the latter
+ sets the chip's *force download boot* flag and restarts, so the device comes
+ back in the ROM bootloader's download mode ready for `esptool` / `idf.py
+ flash` (what holding the BOOT strap does, without a button). Supported on the
+ ESP32-S2 / -S3 (the ROM enumerates its USB CDC / DFU device afresh after the
+ reset), -C2 / -C3 / -C5 / -C6 / -C61 / -H2 / -H21 and -P4 (USB-Serial-JTAG);
+ the classic ESP32 has no software path and reports `operation_not_supported`.
+ On the S2 / S3 the ROM can also keep the USB peripheral's state across the
+ reset (`BootloaderOptions::usb_persist`, `SystemService::Config::usb_persist`)
+ so the host sees no re-plug — **opt-in, off by default**: the ROM only
+ expects that from an application whose USB device is ROM-CDC/DFU-compatible
+ (ESP-IDF's ROM USB console); a TinyUSB vendor + CDC composite like the espp
+ examples has different descriptors, and persisting it can leave the host
+ with a stale enumeration the bootloader cannot serve.
+- `espp::SystemService` — both of the above as a transport-agnostic
+ [dispatcher](../dispatcher) module (`espp.system` v1, module 7 by default):
+ `GET_INFO` answers with a tagged-record snapshot hosts can extend-proof
+ decode, `REBOOT` / `REBOOT_TO_BOOTLOADER` reply OK and restart after a delay.
+ Both restarts are guarded by `Config::allow_reboot` / `allow_bootloader` and
+ an optional `on_reboot_request` veto callback, so an application can refuse a
+ restart while, say, a motor is running.
+
+The hosted [system console](https://esp-cpp.github.io/espp/apps/system_console.html)
+web app (`web/system_console.html`) talks to the service over WebUSB or Web
+Serial, and to the [monitor](../monitor) component's `MonitorService` (heap
+regions + task table, live) when the device advertises it.
+
+The console connected to the example over WebUSB: device info, the two restart
+buttons and the heap gauges
+
+
+
+and its live task table, streamed by `MonitorService`
+
+
+
+
+**Table of Contents**
+
+- [System Component](#system-component)
+ - [Protocol (module 7, `espp.system` v1)](#protocol-module-7-esppsystem-v1)
+ - [Example](#example)
+
+
+
+## Protocol (module 7, `espp.system` v1)
+
+Framed with `stream_frame` and routed by `espp::Dispatcher`; requests carry the
+reply flag clear, replies set it (type high bit). All multi-byte fields are
+little-endian. See `include/detail/system_protocol.hpp` (host-buildable, with
+`test/system_host_test.cpp`).
+
+| Type | Dir | Meaning |
+|------|-----|---------|
+| `0x01` GET_INFO | H→D | request an INFO reply |
+| `0x02` REBOOT | H→D | `[delay_ms u16]` — reply OK, restart after the delay |
+| `0x03` REBOOT_TO_BOOTLOADER | H→D | `[delay_ms u16]` — reply OK, restart into download mode |
+| `0x81` INFO | D→H | tagged records `[tag u8][len u8][value]` (unknown tags are skipped) |
+| `0x83` OK | D→H | `[request_type u8]` |
+| `0x84` ERROR | D→H | `[request_type u8][code u32][utf8 message]` — code is the POSIX errno of the chosen `std::errc` (the message is authoritative) |
+
+Every reply echoes the request frame's optional `stream_frame` correlation id,
+so a host that stamps its requests can pair replies with them and drop a late
+reply to a request it already timed out (the console does).
+
+INFO tags: 1 chip model (str), 2 chip revision (u16), 3 cores (u8), 4 chip
+features (u32), 5 IDF version, 6 project name, 7 app version, 8 build date, 9
+build time, 10 ELF SHA-256 (32 bytes), 11 running partition, 12 boot partition,
+13 OTA state (u8), 14 reset reason (u8), 15 uptime ms (u64), 16 MAC (6 bytes),
+17 flash size, 18 PSRAM size, 19 CPU MHz, 20 free heap, 21 min free heap (all
+u32), 22 capabilities (u32: bit0 reboot allowed, bit1 bootloader reboot allowed
+and supported). The delay is clamped to at least `Config::min_restart_delay`
+so the OK reply leaves the transport before the restart.
+
+## Example
+
+The [example](./example) exposes `SystemService` and `MonitorService` on the
+native USB port (vendor / WebUSB + CDC / Web Serial) of an ESP32-S3 for the
+system console web app.
diff --git a/components/system/example/CMakeLists.txt b/components/system/example/CMakeLists.txt
new file mode 100644
index 0000000000..0f83e5f18e
--- /dev/null
+++ b/components/system/example/CMakeLists.txt
@@ -0,0 +1,46 @@
+# The following lines of boilerplate have to be in your project's CMakeLists
+# in this exact order for cmake to work correctly
+cmake_minimum_required(VERSION 3.20)
+
+set(CMAKE_CXX_STANDARD 20)
+
+# This example needs the managed `espressif/esp_tinyusb` component (required by
+# usb_device). It supports two build modes:
+#
+# * DEFAULT (component manager ON) - how an end user builds it from the
+# component registry: the manager fetches esp_tinyusb (and its tinyusb
+# dependency) and the espp/* dependencies from the registry.
+# EXTRA_COMPONENT_DIRS is narrowed to just the components this example uses.
+#
+# * MANAGER OFF (IDF_COMPONENT_MANAGER=0) - used by CI: every espp dependency
+# resolves locally from EXTRA_COMPONENT_DIRS, and esp_tinyusb / tinyusb come
+# from the vendored git submodules under external/.
+include($ENV{IDF_PATH}/tools/cmake/project.cmake)
+
+set(EXTRA_COMPONENT_DIRS
+ "../../../components/base_component"
+ "../../../components/dispatcher"
+ "../../../components/format"
+ "../../../components/logger"
+ "../../../components/monitor"
+ "../../../components/stream_frame"
+ "../../../components/system"
+ "../../../components/task"
+ "../../../components/usb_device"
+)
+
+if(DEFINED ENV{IDF_COMPONENT_MANAGER} AND "$ENV{IDF_COMPONENT_MANAGER}" STREQUAL "0")
+ list(APPEND EXTRA_COMPONENT_DIRS
+ "../../../external/esp-usb/device/esp_tinyusb"
+ "../../../external/tinyusb"
+ )
+endif()
+
+set(
+ COMPONENTS
+ "main esptool_py base_component dispatcher format logger monitor stream_frame system task usb_device esp_tinyusb esp_psram"
+ CACHE STRING
+ "List of components to include"
+ )
+
+project(system_example)
diff --git a/components/system/example/README.md b/components/system/example/README.md
new file mode 100644
index 0000000000..e06afc0361
--- /dev/null
+++ b/components/system/example/README.md
@@ -0,0 +1,58 @@
+# System Info + Control over USB Example
+
+Exposes the espp `SystemService` (device identity / status, reboot, reboot
+into the ROM bootloader) and `MonitorService` (heap regions and the task
+table, on request or streamed) on the native USB port of an ESP32-S3, over
+both the **vendor (WebUSB)** and **CDC (Web Serial)** interfaces. The hosted
+[system console web app](https://esp-cpp.github.io/espp/apps/system_console.html)
+(`components/system/web/system_console.html`) talks to both services; the
+[Device Hub](https://esp-cpp.github.io/espp/apps/dispatcher_hub.html) lists them
+through discovery (`espp.system` v1 on module 7, `espp.monitor` v1 on module 8
+by default).
+
+## How to use example
+
+### Hardware Required
+
+An ESP32-S3 (or -S2 / -P4) board with the native USB port wired to a host. The
+system console / logs go to UART0 (see `sdkconfig.defaults`).
+
+### Build and Flash
+
+```
+idf.py set-target esp32s3
+idf.py build flash monitor
+```
+
+CI builds it with the component manager off (`IDF_COMPONENT_MANAGER=0 idf.py
+build`), resolving every dependency from the repository (including the
+vendored `esp_tinyusb` / `tinyusb` submodules under `external/`).
+
+Then open the system console web app and Connect (WebUSB or Web Serial):
+
+- **Device info**: chip, ESP-IDF version, application (project, version, build
+ date / time, ELF SHA-256), running / boot partition and OTA state, reset
+ reason, uptime, MAC, flash / PSRAM size, CPU frequency, heap.
+- **Reboot** and **Reboot into bootloader**: the device replies OK and restarts
+ after the delay; in the second case it comes back in the ROM download mode
+ (the S3's ROM USB CDC / DFU interface) ready for `esptool` / `idf.py flash`.
+ The example's `on_reboot_request` callback logs and permits every request.
+- **Heap** gauges per region and a **live task table** (CPU %, stack
+ high-water mark, priority, core) with a stream toggle and period.
+
+Task statistics need `CONFIG_FREERTOS_USE_TRACE_FACILITY` and
+`CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS` (set in `sdkconfig.defaults`).
+
+## Example Output
+
+```
+I (317) System Example: Starting system info + control example
+I (327) System Example: System:
+ESP32-S3 rev 0.2 (2 cores), ESP-IDF v6.1
+app: system_example 1 built Sep 30 2026 12:34:56
+partition: running 'factory', boot 'factory', OTA state n/a
+reset: power-on; uptime 320 ms; MAC 34:85:18:xx:xx:xx
+flash 8192 KiB, PSRAM 0 KiB, CPU 240 MHz, heap free 318412 (min 318412)
+I (357) System Example: Reboot into the bootloader is supported on this chip
+I (1077) System Example: Ready. Connect the native USB port and open the system console ...
+```
diff --git a/components/system/example/main/CMakeLists.txt b/components/system/example/main/CMakeLists.txt
new file mode 100644
index 0000000000..57bd1ae9a2
--- /dev/null
+++ b/components/system/example/main/CMakeLists.txt
@@ -0,0 +1,5 @@
+idf_component_register(
+ SRC_DIRS "."
+ INCLUDE_DIRS "."
+ REQUIRES system monitor dispatcher task usb_device esp_tinyusb
+)
diff --git a/components/system/example/main/system_example.cpp b/components/system/example/main/system_example.cpp
new file mode 100644
index 0000000000..ba502ae794
--- /dev/null
+++ b/components/system/example/main/system_example.cpp
@@ -0,0 +1,126 @@
+#include
+#include
+#include
+#include
+
+#include "sdkconfig.h"
+
+#include "dispatcher_worker.hpp"
+#include "logger.hpp"
+#include "monitor_service.hpp"
+#include "system_control.hpp"
+#include "system_info.hpp"
+#include "system_service.hpp"
+#include "usb_device.hpp"
+
+using namespace std::chrono_literals;
+
+// System info + control over USB example.
+//
+// Exposes two espp services on the USB vendor (WebUSB) and CDC (Web Serial)
+// interfaces, routed by one espp::DispatcherWorker per transport:
+// - espp::SystemService (module 7, `espp.system`): device identity /
+// status, reboot, reboot into the ROM bootloader (download mode)
+// - espp::MonitorService (module 8, `espp.monitor`): heap regions and the
+// task table, on request or streamed
+// The hosted system console web app (components/system/web/system_console.html)
+// talks to both; the Device Hub lists them through discovery.
+
+extern "C" void app_main(void) {
+ espp::Logger logger({.tag = "System Example", .level = espp::Logger::Verbosity::INFO});
+ logger.info("Starting system info + control example");
+
+ //! [system_example]
+ // Boot banner: everything SystemInfo knows, in one string.
+ logger.info("System:\n{}", espp::SystemInfo::to_string());
+ logger.info("Reboot into the bootloader is {} on this chip",
+ espp::SystemControl::bootloader_reboot_supported() ? "supported" : "NOT supported");
+
+ // USB composite device: a vendor/WebUSB function and a CDC function, both
+ // carrying the same framed protocol.
+ espp::UsbDevice::Config usb_cfg;
+ usb_cfg.pid = 0x0d37; // distinct from the espp default so the webapp filter is specific
+ usb_cfg.manufacturer = "espp";
+ usb_cfg.product = "espp System";
+ usb_cfg.log_level = espp::Logger::Verbosity::WARN;
+ espp::UsbDevice::CdcFunction cdc;
+ cdc.interface_name = "espp System (CDC)";
+ usb_cfg.cdc = cdc;
+ espp::UsbDevice::VendorFunction vendor;
+ vendor.interface_name = "espp System (WebUSB)";
+ vendor.webusb = true; // advertise BOS / WebUSB / MS OS 2.0 descriptors
+ vendor.landing_page_url = "esp-cpp.github.io/espp/apps/system_console.html";
+ usb_cfg.vendor = vendor;
+ espp::UsbDevice usb(usb_cfg);
+
+ // Replies go back on the stream the request came in on: one send function
+ // per transport. Both services share each transport and only serialize
+ // their OWN frames (a streamed monitor event and a system reply come from
+ // different tasks), so every device->host write on a transport goes through
+ // one application-level mutex; write_vendor / write_cdc are all-or-nothing
+ // per call, so a frame is never truncated or interleaved.
+ std::mutex vendor_tx_mutex, cdc_tx_mutex;
+ auto vendor_send = [&](std::span frame) {
+ std::lock_guard lock(vendor_tx_mutex);
+ usb.write_vendor(frame);
+ };
+ auto cdc_send = [&](std::span frame) {
+ std::lock_guard lock(cdc_tx_mutex);
+ usb.write_cdc(frame);
+ };
+
+ // The application decides whether a reboot may happen right now: this demo
+ // permits every request and logs it. A real application would refuse (or
+ // defer) while, say, a motor is running or a file is being written.
+ auto reboot_request = [&](espp::SystemService::RebootKind kind) {
+ logger.warn("Host requested a {}; allowing it",
+ kind == espp::SystemService::RebootKind::Bootloader ? "reboot into the bootloader"
+ : "reboot");
+ return true;
+ };
+
+ // One service instance per transport (they are cheap; each replies on its
+ // own stream). Declared BEFORE the workers that call into them.
+ espp::SystemService vendor_system({.send = vendor_send,
+ .on_reboot_request = reboot_request,
+ .log_level = espp::Logger::Verbosity::INFO});
+ espp::SystemService cdc_system({.send = cdc_send,
+ .on_reboot_request = reboot_request,
+ .log_level = espp::Logger::Verbosity::INFO});
+ espp::MonitorService vendor_monitor(
+ {.send = vendor_send, .log_level = espp::Logger::Verbosity::INFO});
+ espp::MonitorService cdc_monitor({.send = cdc_send, .log_level = espp::Logger::Verbosity::INFO});
+
+ // One DispatcherWorker per byte stream: a bounded receive queue + worker
+ // task feeding its Dispatcher, so the services never run on the TinyUSB
+ // task. Registering a service routes its module to it and advertises it for
+ // discovery; serve_discovery() answers the hub's ListModules query.
+ espp::DispatcherWorker vendor_link(
+ {.send = vendor_send, .task_config = {.name = "system_rx_vendor", .stack_size_bytes = 8192}});
+ espp::DispatcherWorker cdc_link(
+ {.send = cdc_send, .task_config = {.name = "system_rx_cdc", .stack_size_bytes = 8192}});
+ vendor_link.register_module(vendor_system);
+ vendor_link.register_module(vendor_monitor);
+ cdc_link.register_module(cdc_system);
+ cdc_link.register_module(cdc_monitor);
+ vendor_link.serve_discovery(usb_cfg.product);
+ cdc_link.serve_discovery(usb_cfg.product);
+ //! [system_example]
+
+ // RX plumbing: the TinyUSB callbacks just queue the bytes for the workers.
+ usb.set_vendor_receive_callback([&](std::span data) { vendor_link.push(data); });
+ usb.set_cdc_receive_callback([&](std::span data) { cdc_link.push(data); });
+
+ std::error_code usb_ec;
+ if (!usb.initialize(usb_ec))
+ logger.error("Failed to initialize USB device: {}", usb_ec.message());
+ else
+ logger.info("Ready. Connect the native USB port and open the system console "
+ "(components/system/web/system_console.html or https://{})",
+ vendor.landing_page_url);
+
+ // Idle; all work happens in the dispatcher workers and the monitor stream task.
+ while (true) {
+ std::this_thread::sleep_for(1s);
+ }
+}
diff --git a/components/system/example/sdkconfig.defaults b/components/system/example/sdkconfig.defaults
new file mode 100644
index 0000000000..bd0e1388d8
--- /dev/null
+++ b/components/system/example/sdkconfig.defaults
@@ -0,0 +1,27 @@
+CONFIG_IDF_TARGET="esp32s3"
+
+CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096
+CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
+
+# Console on UART0 (secondary on USB-Serial-JTAG): on the S3, USB-Serial-JTAG
+# shares the native USB PHY with USB-OTG, which TinyUSB takes over.
+CONFIG_ESP_CONSOLE_UART_DEFAULT=y
+CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y
+
+# TinyUSB vendor (WebUSB) + CDC (Web Serial) class drivers for the framed
+# protocol; HID count only so usb_device's HID code paths compile.
+CONFIG_TINYUSB_VENDOR_COUNT=1
+CONFIG_TINYUSB_CDC_ENABLED=y
+CONFIG_TINYUSB_CDC_COUNT=1
+CONFIG_TINYUSB_HID_COUNT=1
+CONFIG_TINYUSB_VENDOR_RX_BUFSIZE=4096
+CONFIG_TINYUSB_VENDOR_TX_BUFSIZE=4096
+CONFIG_TINYUSB_CDC_TX_BUFSIZE=4096
+
+# Task statistics for MonitorService (GET_TASKS / TASKS stream): the FreeRTOS
+# trace facility + run-time stats, and the core id per task.
+CONFIG_FREERTOS_USE_TRACE_FACILITY=y
+CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS=y
+CONFIG_FREERTOS_VTASKLIST_INCLUDE_COREID=y
+
+CONFIG_COMPILER_CXX_EXCEPTIONS=y
diff --git a/components/system/idf_component.yml b/components/system/idf_component.yml
new file mode 100644
index 0000000000..5929b2bd8f
--- /dev/null
+++ b/components/system/idf_component.yml
@@ -0,0 +1,28 @@
+## IDF Component Manager Manifest File
+license: "MIT"
+description: "System identity / status (chip, app, partitions, reset reason, uptime, heap) and restart control (reboot, reboot into the ROM bootloader), plus a framed stream service and a browser console (WebUSB / Web Serial)"
+url: "https://github.com/esp-cpp/espp/tree/main/components/system"
+repository: "https://github.com/esp-cpp/espp.git"
+maintainers:
+ - William Emfinger
+documentation: "https://esp-cpp.github.io/espp/system/system.html"
+examples:
+ - path: example
+tags:
+ - cpp
+ - Component
+ - System
+ - Reboot
+ - Bootloader
+ - Info
+ - WebUSB
+ - WebSerial
+ - USB
+dependencies:
+ idf:
+ # esp_app_get_description() and esp_clk_tree.h first appear in ESP-IDF v5.1
+ version: '>=5.1'
+ espp/base_component: '>=1.0'
+ espp/format: '>=1.0'
+ espp/stream_frame: '>=1.0'
+ espp/dispatcher: '>=1.0'
diff --git a/components/system/include/detail/system_protocol.hpp b/components/system/include/detail/system_protocol.hpp
new file mode 100644
index 0000000000..717f81ac82
--- /dev/null
+++ b/components/system/include/detail/system_protocol.hpp
@@ -0,0 +1,359 @@
+#pragma once
+
+// Wire protocol of espp::SystemService: device identity / status plus reboot
+// control over the espp stream_frame codec, routed by an espp::Dispatcher on
+// module 7 by default (`espp.system` v1 through discovery).
+//
+// This header is deliberately host-buildable (stream_frame.hpp + the standard
+// library only) so the codec is unit-tested on the host
+// (components/system/test/system_host_test.cpp) and so host tools can reuse
+// it. All multi-byte fields are little-endian.
+//
+// Requests (host -> device, reply flag clear):
+// 0x01 GET_INFO (no payload)
+// 0x02 REBOOT [delay_ms u16]
+// 0x03 REBOOT_TO_BOOTLOADER [delay_ms u16]
+// Replies (device -> host, high bit set = frame reply flag):
+// 0x81 INFO a list of tagged records [tag u8][len u8][value...]; a host
+// skips tags it does not know, so fields can be added without a
+// version bump (see InfoTag for the values).
+// 0x83 OK [request_type u8]
+// 0x84 ERROR [request_type u8][code u32][utf8 message]
+// code = the POSIX errno value of the std::errc the service
+// chose (informational; the message is authoritative)
+// Every reply echoes the request frame's optional correlation id, so a host
+// that stamps its requests can pair replies with them and drop stale ones.
+// A reboot request is acknowledged with OK first; the device restarts after
+// the requested delay (clamped to at least Config::min_restart_delay).
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "stream_frame.hpp"
+
+namespace espp::detail::system_protocol {
+
+/// Default dispatcher module id (a routing key only; see SystemService::Config::module).
+inline constexpr uint8_t kModule = 7;
+/// Stable protocol identifier + version advertised through discovery.
+inline constexpr const char *kProtocol = "espp.system";
+inline constexpr uint16_t kProtocolVersion = 1;
+
+/// Frame `type` values within the system module.
+enum class Type : uint8_t {
+ // host -> device
+ GetInfo = 0x01,
+ Reboot = 0x02,
+ RebootToBootloader = 0x03,
+ // device -> host (high bit set)
+ Info = 0x81,
+ Ok = 0x83,
+ Error = 0x84,
+};
+
+/// Tags of the INFO records. Values are little-endian; `str` is raw UTF-8
+/// (the record's len is the string length).
+enum class InfoTag : uint8_t {
+ ChipModel = 1, ///< str, e.g. "ESP32-S3"
+ ChipRevision = 2, ///< u16 (MXX: major * 100 + minor)
+ Cores = 3, ///< u8
+ ChipFeatures = 4, ///< u32 (CHIP_FEATURE_* bitmask)
+ IdfVersion = 5, ///< str
+ ProjectName = 6, ///< str
+ AppVersion = 7, ///< str
+ BuildDate = 8, ///< str
+ BuildTime = 9, ///< str
+ ElfSha256 = 10, ///< 32 raw bytes
+ RunningPartition = 11, ///< str (partition label)
+ BootPartition = 12, ///< str (partition label)
+ OtaState = 13, ///< u8 esp_ota_img_states_t (0 new .. 4 aborted); 0xFE = ESP_OTA_IMG_UNDEFINED,
+ ///< 0xFF = state unavailable / not an OTA partition (SystemInfo::ota_state())
+ ResetReason = 14, ///< u8 (esp_reset_reason_t)
+ UptimeMs = 15, ///< u64
+ Mac = 16, ///< 6 raw bytes (base MAC)
+ FlashSize = 17, ///< u32 bytes
+ PsramSize = 18, ///< u32 bytes (0 = none)
+ CpuMhz = 19, ///< u32
+ FreeHeap = 20, ///< u32 bytes
+ MinFreeHeap = 21, ///< u32 bytes
+ Capabilities = 22, ///< u32 (kCapReboot | kCapBootloader)
+};
+
+/// Capabilities bits (InfoTag::Capabilities).
+inline constexpr uint32_t kCapReboot = 0x01; ///< REBOOT is allowed by the service.
+inline constexpr uint32_t kCapBootloader = 0x02; ///< REBOOT_TO_BOOTLOADER is allowed AND supported.
+
+/// Whether a type value is a device->host reply.
+inline constexpr bool is_reply(Type type) { return (static_cast(type) & 0x80) != 0; }
+
+/// Build an encoded frame for a system message (device->host types map to the
+/// frame reply flag).
+/// @param correlation The stream_frame correlation id to carry: a reply echoes
+/// the request's, so a host can pair a reply with its request.
+inline std::vector build_frame(Type type, std::span payload = {},
+ uint8_t module = kModule,
+ std::optional correlation = std::nullopt) {
+ return espp::stream_frame::build_frame(is_reply(type), module, static_cast(type),
+ payload, correlation);
+}
+
+// ---- INFO record encoding ------------------------------------------------------
+
+/// Builds an INFO payload one tagged record at a time. Values longer than 255
+/// bytes are truncated (strings) -- every value defined today is far shorter.
+class InfoBuilder {
+public:
+ InfoBuilder &str(InfoTag tag, std::string_view s) {
+ const size_t n = s.size() > 255 ? 255 : s.size();
+ header(tag, n);
+ out_.insert(out_.end(), s.begin(), s.begin() + static_cast(n));
+ return *this;
+ }
+ InfoBuilder &u8(InfoTag tag, uint8_t v) {
+ header(tag, 1);
+ out_.push_back(v);
+ return *this;
+ }
+ InfoBuilder &u16(InfoTag tag, uint16_t v) {
+ header(tag, 2);
+ espp::stream_frame::put_u16(out_, v);
+ return *this;
+ }
+ InfoBuilder &u32(InfoTag tag, uint32_t v) {
+ header(tag, 4);
+ espp::stream_frame::put_u32(out_, v);
+ return *this;
+ }
+ InfoBuilder &u64(InfoTag tag, uint64_t v) {
+ header(tag, 8);
+ espp::stream_frame::put_u32(out_, static_cast(v));
+ espp::stream_frame::put_u32(out_, static_cast(v >> 32));
+ return *this;
+ }
+ InfoBuilder &bytes(InfoTag tag, std::span b) {
+ const size_t n = b.size() > 255 ? 255 : b.size();
+ header(tag, n);
+ out_.insert(out_.end(), b.begin(), b.begin() + static_cast(n));
+ return *this;
+ }
+ const std::vector &payload() const { return out_; }
+ std::vector take() { return std::move(out_); }
+
+private:
+ void header(InfoTag tag, size_t len) {
+ out_.push_back(static_cast(tag));
+ out_.push_back(static_cast(len));
+ }
+ std::vector out_;
+};
+
+/// A decoded INFO payload: every field is optional (absent when the device did
+/// not send the tag). Unknown tags are skipped, so a newer device decodes fine.
+struct Info {
+ std::optional chip_model;
+ std::optional chip_revision;
+ std::optional cores;
+ std::optional chip_features;
+ std::optional idf_version;
+ std::optional project_name;
+ std::optional app_version;
+ std::optional build_date;
+ std::optional build_time;
+ std::optional> elf_sha256;
+ std::optional running_partition;
+ std::optional boot_partition;
+ std::optional ota_state;
+ std::optional reset_reason;
+ std::optional uptime_ms;
+ std::optional> mac;
+ std::optional flash_size;
+ std::optional psram_size;
+ std::optional cpu_mhz;
+ std::optional free_heap;
+ std::optional min_free_heap;
+ std::optional capabilities;
+ size_t unknown_tags{0}; ///< how many records carried a tag this decoder does not know
+};
+
+/// Decode an INFO payload. Returns nullopt only on a truncated record (a record
+/// whose declared length runs past the payload); a record with an unexpected
+/// length for a known fixed-size tag is skipped (counted as unknown).
+inline std::optional decode_info(std::span p) {
+ Info info;
+ size_t i = 0;
+ while (i < p.size()) {
+ if (i + 2 > p.size())
+ return std::nullopt;
+ const uint8_t tag = p[i];
+ const size_t len = p[i + 1];
+ i += 2;
+ if (i + len > p.size())
+ return std::nullopt;
+ const std::span v = p.subspan(i, len);
+ i += len;
+ auto as_str = [&]() { return std::string(reinterpret_cast(v.data()), v.size()); };
+ auto u8 = [&](std::optional &dst) {
+ if (len == 1)
+ dst = v[0];
+ else
+ ++info.unknown_tags;
+ };
+ auto u16 = [&](std::optional &dst) {
+ if (len == 2)
+ dst = espp::stream_frame::get_u16(v);
+ else
+ ++info.unknown_tags;
+ };
+ auto u32 = [&](std::optional &dst) {
+ if (len == 4)
+ dst = espp::stream_frame::get_u32(v);
+ else
+ ++info.unknown_tags;
+ };
+ switch (static_cast(tag)) {
+ case InfoTag::ChipModel:
+ info.chip_model = as_str();
+ break;
+ case InfoTag::ChipRevision:
+ u16(info.chip_revision);
+ break;
+ case InfoTag::Cores:
+ u8(info.cores);
+ break;
+ case InfoTag::ChipFeatures:
+ u32(info.chip_features);
+ break;
+ case InfoTag::IdfVersion:
+ info.idf_version = as_str();
+ break;
+ case InfoTag::ProjectName:
+ info.project_name = as_str();
+ break;
+ case InfoTag::AppVersion:
+ info.app_version = as_str();
+ break;
+ case InfoTag::BuildDate:
+ info.build_date = as_str();
+ break;
+ case InfoTag::BuildTime:
+ info.build_time = as_str();
+ break;
+ case InfoTag::ElfSha256:
+ if (len == 32) {
+ std::array sha{};
+ std::copy(v.begin(), v.end(), sha.begin());
+ info.elf_sha256 = sha;
+ } else {
+ ++info.unknown_tags;
+ }
+ break;
+ case InfoTag::RunningPartition:
+ info.running_partition = as_str();
+ break;
+ case InfoTag::BootPartition:
+ info.boot_partition = as_str();
+ break;
+ case InfoTag::OtaState:
+ u8(info.ota_state);
+ break;
+ case InfoTag::ResetReason:
+ u8(info.reset_reason);
+ break;
+ case InfoTag::UptimeMs:
+ if (len == 8) {
+ info.uptime_ms = static_cast(espp::stream_frame::get_u32(v)) |
+ (static_cast(espp::stream_frame::get_u32(v.subspan(4))) << 32);
+ } else {
+ ++info.unknown_tags;
+ }
+ break;
+ case InfoTag::Mac:
+ if (len == 6) {
+ std::array mac{};
+ std::copy(v.begin(), v.end(), mac.begin());
+ info.mac = mac;
+ } else {
+ ++info.unknown_tags;
+ }
+ break;
+ case InfoTag::FlashSize:
+ u32(info.flash_size);
+ break;
+ case InfoTag::PsramSize:
+ u32(info.psram_size);
+ break;
+ case InfoTag::CpuMhz:
+ u32(info.cpu_mhz);
+ break;
+ case InfoTag::FreeHeap:
+ u32(info.free_heap);
+ break;
+ case InfoTag::MinFreeHeap:
+ u32(info.min_free_heap);
+ break;
+ case InfoTag::Capabilities:
+ u32(info.capabilities);
+ break;
+ default:
+ ++info.unknown_tags;
+ break;
+ }
+ }
+ return info;
+}
+
+// ---- request / reply helpers ---------------------------------------------------
+
+/// Encode a REBOOT / REBOOT_TO_BOOTLOADER payload.
+inline std::vector encode_delay(uint16_t delay_ms) {
+ std::vector p;
+ espp::stream_frame::put_u16(p, delay_ms);
+ return p;
+}
+
+/// Decode a REBOOT / REBOOT_TO_BOOTLOADER payload: empty = 0 ms, else exactly
+/// [delay_ms u16]; any other size is malformed (nullopt).
+inline std::optional decode_delay(std::span p) {
+ if (p.empty())
+ return 0;
+ if (p.size() != 2)
+ return std::nullopt; // exactly [delay_ms u16]; anything else is malformed
+ return espp::stream_frame::get_u16(p);
+}
+
+/// Encode an OK payload.
+inline std::vector encode_ok(uint8_t request_type) { return {request_type}; }
+
+/// Encode an ERROR payload.
+inline std::vector encode_error(uint8_t request_type, uint32_t code,
+ std::string_view message) {
+ std::vector p;
+ p.push_back(request_type);
+ espp::stream_frame::put_u32(p, code);
+ p.insert(p.end(), message.begin(), message.end());
+ return p;
+}
+
+/// Decoded ERROR payload.
+struct Error {
+ uint8_t request_type{0};
+ uint32_t code{0};
+ std::string message;
+};
+
+inline std::optional decode_error(std::span p) {
+ if (p.size() < 5)
+ return std::nullopt;
+ Error e;
+ e.request_type = p[0];
+ e.code = espp::stream_frame::get_u32(p.subspan(1));
+ e.message.assign(reinterpret_cast(p.data() + 5), p.size() - 5);
+ return e;
+}
+
+} // namespace espp::detail::system_protocol
diff --git a/components/system/include/system_control.hpp b/components/system/include/system_control.hpp
new file mode 100644
index 0000000000..20698f2a6d
--- /dev/null
+++ b/components/system/include/system_control.hpp
@@ -0,0 +1,172 @@
+#pragma once
+
+#include
+#include
+#include
+
+#include "sdkconfig.h"
+
+#include "esp_system.h"
+#include "soc/soc.h"
+
+// The "force download boot" bit lives in a different always-on register on
+// each chip family; ESP-IDF's own ROM USB console (esp_usb_cdc_rom_console)
+// sets it the same way before restarting.
+#if CONFIG_IDF_TARGET_ESP32S2 || CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32C3 || \
+ CONFIG_IDF_TARGET_ESP32C2
+#include "soc/rtc_cntl_reg.h"
+#define ESPP_SYSTEM_DOWNLOAD_BOOT_RTC_CNTL 1
+#elif CONFIG_IDF_TARGET_ESP32C6 || CONFIG_IDF_TARGET_ESP32H2 || CONFIG_IDF_TARGET_ESP32C5 || \
+ CONFIG_IDF_TARGET_ESP32C61 || CONFIG_IDF_TARGET_ESP32H21
+#include "soc/lp_aon_reg.h"
+#define ESPP_SYSTEM_DOWNLOAD_BOOT_LP_AON 1
+#elif CONFIG_IDF_TARGET_ESP32P4
+#include "soc/lp_system_reg.h"
+#define ESPP_SYSTEM_DOWNLOAD_BOOT_LP_SYSTEM 1
+#endif
+// The ROM USB persistence calls (usb_dc_prepare_persist() +
+// chip_usb_set_persist_flags()) exist on the S2 / S3 only, whose ROM has a USB
+// CDC / DFU device of its own; they are used only when a caller opts in.
+#if CONFIG_IDF_TARGET_ESP32S2
+#include "esp32s2/rom/usb/chip_usb_dw_wrapper.h"
+#include "esp32s2/rom/usb/usb_dc.h"
+#include "esp32s2/rom/usb/usb_persist.h"
+#define ESPP_SYSTEM_USB_PERSIST 1
+#elif CONFIG_IDF_TARGET_ESP32S3
+#include "esp32s3/rom/usb/chip_usb_dw_wrapper.h"
+#include "esp32s3/rom/usb/usb_dc.h"
+#include "esp32s3/rom/usb/usb_persist.h"
+#define ESPP_SYSTEM_USB_PERSIST 1
+#endif
+
+namespace espp {
+
+/// Options for SystemControl::reboot_to_bootloader() (a namespace-scope type
+/// so it is complete where the functions default it).
+struct SystemBootloaderOptions {
+ /// Keep the USB peripheral's state across the reset (ESP32-S2 / -S3 ROM
+ /// only; ignored elsewhere). ONLY for applications whose USB device is
+ /// ROM-CDC/DFU-compatible (see the SystemControl notes); off by default,
+ /// letting the ROM re-enumerate on its own.
+ bool usb_persist{false};
+};
+
+/**
+ * @brief Restart control: a plain reboot, and a reboot into the ROM
+ * bootloader's download (serial flashing) mode.
+ *
+ * reboot_to_bootloader() sets the chip's "force download boot" flag in its
+ * always-on register and restarts, so the next boot stays in the ROM
+ * download mode instead of running the app -- exactly what holding the BOOT
+ * strap during a reset does, without touching a button. The device then
+ * re-enumerates as the ROM's own flashing interface: the USB CDC / DFU device
+ * on the ESP32-S2 / -S3 native USB port, or USB-Serial-JTAG on the ESP32-C3 /
+ * -C6 / -H2 / -C5 / -C61 / -H21 / -P4 -- so `esptool` / `idf.py flash` can
+ * program it. On the classic ESP32 there is no software path (only the GPIO0
+ * strap): bootloader_reboot_supported() is false and reboot_to_bootloader()
+ * fails with operation_not_supported.
+ *
+ * **USB persistence (S2 / S3, opt-in)**: by default the reset tears the USB
+ * connection down and the ROM enumerates its CDC / DFU device afresh, which
+ * works for any application. The ROM can instead keep the USB peripheral's
+ * state across the reset (BootloaderOptions::usb_persist), so the host sees
+ * no re-plug -- but the ROM only expects that from an application whose USB
+ * device is ROM-CDC/DFU-compatible (the same descriptors the ROM exposes, as
+ * ESP-IDF's ROM USB console has); a TinyUSB composite device (the espp
+ * examples: vendor + CDC) has different descriptors, and persisting it can
+ * leave the host with a stale enumeration the bootloader cannot serve. Leave
+ * it off unless the application runs on the ROM USB console driver.
+ *
+ * Both functions restart immediately; use the delayed variants (or
+ * espp::SystemService, which replies before restarting) when a reply must
+ * leave the transport first.
+ */
+class SystemControl {
+public:
+ /// Options for the reboot into download mode (see SystemBootloaderOptions).
+ using BootloaderOptions = SystemBootloaderOptions;
+
+ /// @brief Whether reboot_to_bootloader() is implemented for this chip.
+ static constexpr bool bootloader_reboot_supported() {
+#if ESPP_SYSTEM_DOWNLOAD_BOOT_RTC_CNTL || ESPP_SYSTEM_DOWNLOAD_BOOT_LP_AON || \
+ ESPP_SYSTEM_DOWNLOAD_BOOT_LP_SYSTEM
+ return true;
+#else
+ return false;
+#endif
+ }
+
+ /// @brief Restart the chip (esp_restart()); does not return.
+ [[noreturn]] static void reboot() { esp_restart(); }
+
+ /// @brief Restart into the ROM bootloader's download mode.
+ /// @param ec Set to operation_not_supported on chips without a software path
+ /// (classic ESP32); then returns false without restarting.
+ /// @param options See BootloaderOptions (USB persistence is opt-in).
+ /// @return Does not return on success; false on failure.
+ static bool reboot_to_bootloader(std::error_code &ec, const BootloaderOptions &options = {}) {
+ ec.clear();
+ if (!bootloader_reboot_supported()) {
+ ec = std::make_error_code(std::errc::operation_not_supported);
+ return false;
+ }
+ arm_download_boot(options);
+ esp_restart();
+ return true; // not reached
+ }
+
+ /// @brief Restart after a delay, from a detached thread, so the caller can
+ /// finish (e.g. send a reply) first. Returns immediately.
+ static void reboot_after(std::chrono::milliseconds delay) {
+ std::thread([delay]() {
+ std::this_thread::sleep_for(delay);
+ esp_restart();
+ }).detach();
+ }
+
+ /// @brief Restart into download mode after a delay, from a detached thread.
+ /// Returns immediately; false (nothing scheduled) if unsupported.
+ static bool reboot_to_bootloader_after(std::chrono::milliseconds delay, std::error_code &ec,
+ const BootloaderOptions &options = {}) {
+ ec.clear();
+ if (!bootloader_reboot_supported()) {
+ ec = std::make_error_code(std::errc::operation_not_supported);
+ return false;
+ }
+ std::thread([delay, options]() {
+ std::this_thread::sleep_for(delay);
+ arm_download_boot(options);
+ esp_restart();
+ }).detach();
+ return true;
+ }
+
+private:
+ /// Set the chip's force-download-boot flag (survives the reset that follows).
+ static void arm_download_boot(const BootloaderOptions &options) {
+#if ESPP_SYSTEM_USB_PERSIST
+ if (options.usb_persist) {
+ // Keep the ROM USB device attached across the reset, the way ESP-IDF's
+ // ROM USB console does before its own reboot-to-bootloader: park the
+ // peripheral (usb_dc_prepare_persist(), "reboot soon after") and set the
+ // persist flag the ROM reads on the next boot. Opt-in only: see the
+ // class notes.
+ usb_dc_prepare_persist();
+ chip_usb_set_persist_flags(USBDC_PERSIST_ENA);
+ }
+#else
+ (void)options;
+#endif
+#if ESPP_SYSTEM_DOWNLOAD_BOOT_RTC_CNTL
+ REG_WRITE(RTC_CNTL_OPTION1_REG, RTC_CNTL_FORCE_DOWNLOAD_BOOT);
+#elif ESPP_SYSTEM_DOWNLOAD_BOOT_LP_AON
+ // a 1-bit flag on most chips; a 2-bit field on the C5 where 1 = "force
+ // download boot 0 (UART / USB)" -- writing the field value 1 covers both
+ REG_SET_FIELD(LP_AON_SYS_CFG_REG, LP_AON_FORCE_DOWNLOAD_BOOT, 1);
+#elif ESPP_SYSTEM_DOWNLOAD_BOOT_LP_SYSTEM
+ REG_SET_FIELD(LP_SYSTEM_REG_SYS_CTRL_REG, LP_SYSTEM_REG_FORCE_DOWNLOAD_BOOT, 1);
+#endif
+ }
+};
+
+} // namespace espp
diff --git a/components/system/include/system_info.hpp b/components/system/include/system_info.hpp
new file mode 100644
index 0000000000..04c2074abd
--- /dev/null
+++ b/components/system/include/system_info.hpp
@@ -0,0 +1,297 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+
+#include "sdkconfig.h"
+
+#include "esp_app_desc.h"
+#include "esp_chip_info.h"
+#include "esp_clk_tree.h"
+#include "esp_flash.h"
+#include "esp_idf_version.h"
+#include "esp_mac.h"
+#include "esp_ota_ops.h"
+#include "esp_system.h"
+#include "esp_timer.h"
+#if CONFIG_SPIRAM
+#include "esp_psram.h"
+#endif
+
+#include "format.hpp"
+
+namespace espp {
+
+/**
+ * @brief Static accessors for the identity and status of the running system:
+ * the chip, the ESP-IDF version, the application description (project
+ * name, version, build date / time, ELF SHA-256), the running / boot
+ * partitions and OTA state, the reset reason, uptime, base MAC, flash
+ * and PSRAM sizes, CPU frequency and heap figures.
+ *
+ * Everything is a thin, allocation-light wrapper over the corresponding
+ * ESP-IDF call so it can be used from any task. collect() gathers it all into
+ * one Snapshot (what espp::SystemService reports to a host) and to_string()
+ * renders a human-readable summary for logs.
+ *
+ * \section system_info_ex1 SystemInfo Example
+ * \snippet system_example.cpp system_example
+ */
+class SystemInfo {
+public:
+ /// Everything collect() gathers.
+ struct Snapshot {
+ std::string chip_model; ///< e.g. "ESP32-S3"
+ uint16_t chip_revision{0}; ///< MXX: major * 100 + minor
+ uint8_t cores{0}; ///< CPU core count
+ uint32_t chip_features{0}; ///< CHIP_FEATURE_* bitmask
+ std::string idf_version; ///< e.g. "v6.1"
+ std::string project_name; ///< CMake project name
+ std::string app_version; ///< PROJECT_VER
+ std::string build_date; ///< compile date
+ std::string build_time; ///< compile time
+ std::array elf_sha256{}; ///< SHA-256 of the application ELF
+ std::string running_partition; ///< label of the partition the app runs from
+ std::string boot_partition; ///< label of the partition the bootloader will boot next
+ uint8_t ota_state{0xFF}; ///< esp_ota_img_states_t of the running partition; 0xFF = n/a
+ uint8_t reset_reason{0}; ///< esp_reset_reason_t
+ uint64_t uptime_ms{0}; ///< time since boot
+ std::array mac{}; ///< base MAC address
+ uint32_t flash_size{0}; ///< bytes
+ uint32_t psram_size{0}; ///< bytes (0 = none / disabled)
+ uint32_t cpu_mhz{0}; ///< current CPU frequency
+ uint32_t free_heap{0}; ///< bytes
+ uint32_t min_free_heap{0}; ///< bytes, lowest since boot
+ };
+
+ /// @brief Chip model name for an esp_chip_model_t ("ESP32-S3", ...).
+ static const char *chip_model_name(esp_chip_model_t model) {
+ // Compared by value rather than by enumerator so this compiles against
+ // ESP-IDF releases that predate the newer chips.
+ switch (static_cast(model)) {
+ case 1:
+ return "ESP32";
+ case 2:
+ return "ESP32-S2";
+ case 9:
+ return "ESP32-S3";
+ case 5:
+ return "ESP32-C3";
+ case 12:
+ return "ESP32-C2";
+ case 13:
+ return "ESP32-C6";
+ case 16:
+ return "ESP32-H2";
+ case 18:
+ return "ESP32-P4";
+ case 20:
+ return "ESP32-C61";
+ case 23:
+ return "ESP32-C5";
+ case 25:
+ return "ESP32-H21";
+ case 28:
+ return "ESP32-H4";
+ case 32:
+ return "ESP32-S31";
+ default:
+ return "ESP32 (unknown model)";
+ }
+ }
+
+ /// @brief Human-readable name for an esp_reset_reason_t.
+ static const char *reset_reason_name(esp_reset_reason_t reason) {
+ switch (static_cast(reason)) {
+ case 0:
+ return "unknown";
+ case 1:
+ return "power-on";
+ case 2:
+ return "external pin";
+ case 3:
+ return "software (esp_restart)";
+ case 4:
+ return "panic";
+ case 5:
+ return "interrupt watchdog";
+ case 6:
+ return "task watchdog";
+ case 7:
+ return "other watchdog";
+ case 8:
+ return "deep-sleep wake";
+ case 9:
+ return "brownout";
+ case 10:
+ return "SDIO";
+ case 11:
+ return "USB";
+ case 12:
+ return "JTAG";
+ case 13:
+ return "efuse error";
+ case 14:
+ return "power glitch";
+ case 15:
+ return "CPU lockup";
+ default:
+ return "unknown";
+ }
+ }
+
+ /// @brief Human-readable name for an OTA image state byte (Snapshot::ota_state).
+ static const char *ota_state_name(uint8_t state) {
+ switch (state) {
+ case 0:
+ return "new";
+ case 1:
+ return "pending verify";
+ case 2:
+ return "valid";
+ case 3:
+ return "invalid";
+ case 4:
+ return "aborted";
+ case 0xFF:
+ return "n/a";
+ default:
+ return "undefined";
+ }
+ }
+
+ /// @brief Chip information (model, revision, cores, features).
+ static esp_chip_info_t chip_info() {
+ esp_chip_info_t info{};
+ esp_chip_info(&info);
+ return info;
+ }
+
+ /// @brief The ESP-IDF version string the app was built with.
+ static std::string idf_version() { return esp_get_idf_version(); }
+
+ /// @brief The application description embedded in the running image.
+ static const esp_app_desc_t &app_description() { return *esp_app_get_description(); }
+
+ /// @brief Label of the partition the running app was loaded from ("" if unknown).
+ static std::string running_partition() {
+ const esp_partition_t *p = esp_ota_get_running_partition();
+ return p ? p->label : "";
+ }
+
+ /// @brief Label of the partition the bootloader will boot next ("" if unknown).
+ static std::string boot_partition() {
+ const esp_partition_t *p = esp_ota_get_boot_partition();
+ return p ? p->label : "";
+ }
+
+ /// @brief OTA image state of the running partition (esp_ota_img_states_t as a
+ /// byte; 0xFF when the app runs from a factory partition or the state
+ /// cannot be read).
+ static uint8_t ota_state() {
+ const esp_partition_t *p = esp_ota_get_running_partition();
+ esp_ota_img_states_t state;
+ if (!p || esp_ota_get_state_partition(p, &state) != ESP_OK)
+ return 0xFF;
+ return state == ESP_OTA_IMG_UNDEFINED ? 0xFE : static_cast(state);
+ }
+
+ /// @brief Why the chip last reset.
+ static esp_reset_reason_t reset_reason() { return esp_reset_reason(); }
+
+ /// @brief Milliseconds since boot.
+ static uint64_t uptime_ms() { return static_cast(esp_timer_get_time() / 1000); }
+
+ /// @brief The chip's base (factory-programmed) MAC address.
+ static std::array base_mac() {
+ std::array mac{};
+ esp_efuse_mac_get_default(mac.data());
+ return mac;
+ }
+
+ /// @brief Size of the main SPI flash in bytes (0 if it cannot be read).
+ static uint32_t flash_size() {
+ uint32_t size = 0;
+ if (esp_flash_get_size(nullptr, &size) != ESP_OK)
+ return 0;
+ return size;
+ }
+
+ /// @brief Size of the PSRAM in bytes (0 without PSRAM / with CONFIG_SPIRAM off).
+ static uint32_t psram_size() {
+#if CONFIG_SPIRAM
+ return static_cast(esp_psram_get_size());
+#else
+ return 0;
+#endif
+ }
+
+ /// @brief Current CPU frequency in MHz.
+ static uint32_t cpu_mhz() {
+ uint32_t hz = 0;
+ if (esp_clk_tree_src_get_freq_hz(SOC_MOD_CLK_CPU, ESP_CLK_TREE_SRC_FREQ_PRECISION_CACHED,
+ &hz) != ESP_OK ||
+ hz == 0)
+ return CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ;
+ return hz / 1000000u;
+ }
+
+ /// @brief Free heap in bytes (default capabilities).
+ static uint32_t free_heap() { return esp_get_free_heap_size(); }
+
+ /// @brief Lowest free heap since boot, in bytes.
+ static uint32_t min_free_heap() { return esp_get_minimum_free_heap_size(); }
+
+ /// @brief Gather everything into one Snapshot.
+ static Snapshot collect() {
+ Snapshot s;
+ const esp_chip_info_t chip = chip_info();
+ s.chip_model = chip_model_name(chip.model);
+ s.chip_revision = chip.revision;
+ s.cores = chip.cores;
+ s.chip_features = chip.features;
+ s.idf_version = idf_version();
+ const esp_app_desc_t &app = app_description();
+ s.project_name =
+ std::string(app.project_name, strnlen(app.project_name, sizeof(app.project_name)));
+ s.app_version = std::string(app.version, strnlen(app.version, sizeof(app.version)));
+ s.build_date = std::string(app.date, strnlen(app.date, sizeof(app.date)));
+ s.build_time = std::string(app.time, strnlen(app.time, sizeof(app.time)));
+ std::memcpy(s.elf_sha256.data(), app.app_elf_sha256, s.elf_sha256.size());
+ s.running_partition = running_partition();
+ s.boot_partition = boot_partition();
+ s.ota_state = ota_state();
+ s.reset_reason = static_cast(reset_reason());
+ s.uptime_ms = uptime_ms();
+ s.mac = base_mac();
+ s.flash_size = flash_size();
+ s.psram_size = psram_size();
+ s.cpu_mhz = cpu_mhz();
+ s.free_heap = free_heap();
+ s.min_free_heap = min_free_heap();
+ return s;
+ }
+
+ /// @brief A multi-line human-readable summary of a Snapshot.
+ static std::string to_string(const Snapshot &s) {
+ return fmt::format(
+ "{} rev {}.{} ({} core{}), ESP-IDF {}\n"
+ "app: {} {} built {} {}\n"
+ "partition: running '{}', boot '{}', OTA state {}\n"
+ "reset: {}; uptime {} ms; MAC {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}\n"
+ "flash {} KiB, PSRAM {} KiB, CPU {} MHz, heap free {} (min {})",
+ s.chip_model, s.chip_revision / 100, s.chip_revision % 100, s.cores,
+ s.cores == 1 ? "" : "s", s.idf_version, s.project_name, s.app_version, s.build_date,
+ s.build_time, s.running_partition, s.boot_partition, ota_state_name(s.ota_state),
+ reset_reason_name(static_cast(s.reset_reason)), s.uptime_ms, s.mac[0],
+ s.mac[1], s.mac[2], s.mac[3], s.mac[4], s.mac[5], s.flash_size / 1024, s.psram_size / 1024,
+ s.cpu_mhz, s.free_heap, s.min_free_heap);
+ }
+
+ /// @brief Collect and format in one call (for a boot banner).
+ static std::string to_string() { return to_string(collect()); }
+};
+
+} // namespace espp
diff --git a/components/system/include/system_service.hpp b/components/system/include/system_service.hpp
new file mode 100644
index 0000000000..afc41d2ded
--- /dev/null
+++ b/components/system/include/system_service.hpp
@@ -0,0 +1,300 @@
+#pragma once
+
+// espp::SystemService -- device identity / status and reboot control as a
+// transport-agnostic dispatcher module (detail/system_protocol.hpp is the wire
+// spec). It follows the same contract as espp::OtaService / CoreDumpService:
+// requests are handled and the reply built under an internal mutex, the
+// `send` callback always runs after that mutex is released, and frames for
+// other modules / reply-flagged frames are ignored so the service coexists
+// with other protocols on one stream.
+//
+// Wiring (one line per transport):
+//
+// espp::SystemService system_service({.send = [&](auto f) { usb.write_vendor(f); }});
+// dispatcher.register_module(system_service); // module 7 + discovery metadata
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "dispatcher.hpp"
+#include "stream_frame.hpp"
+
+#include "base_component.hpp"
+#include "detail/system_protocol.hpp"
+#include "system_control.hpp"
+#include "system_info.hpp"
+
+namespace espp {
+
+/**
+ * @brief Serves espp::SystemInfo and espp::SystemControl over any framed byte
+ * stream (dispatcher module 7 by default; see Config::module).
+ *
+ * GET_INFO answers with a tagged-record snapshot (see
+ * detail/system_protocol.hpp; hosts skip tags they do not know). REBOOT and
+ * REBOOT_TO_BOOTLOADER reply OK first and restart after the requested delay
+ * (at least Config::min_restart_delay, so the reply leaves the transport)
+ * from a detached thread -- the same pattern as OtaService's post-update
+ * restart. Both are guarded: Config::allow_reboot / allow_bootloader switch
+ * them off (ERROR "not permitted"), the optional Config::on_reboot_request
+ * callback can veto a specific request (an application with a motor running
+ * can refuse or defer), and REBOOT_TO_BOOTLOADER is refused with "not
+ * supported" on chips without a software download-mode path (classic ESP32).
+ * The INFO capabilities record tells a host up front which of the two it may
+ * offer.
+ *
+ * **Threading**: an internal mutex covers the parser and request handling;
+ * the `send` callback and the veto callback run after it is released. Drive
+ * one instance from one context per byte stream (a Dispatcher /
+ * DispatcherWorker, or a single task calling feed()).
+ *
+ * \section system_service_ex1 SystemService Example
+ * \snippet system_example.cpp system_example
+ */
+class SystemService : public BaseComponent {
+public:
+ using Stream = espp::stream_frame::StreamParser;
+ using Type = espp::detail::system_protocol::Type;
+
+ /// Default dispatcher module id (7). Only a routing key: Config::module serves
+ /// on any id, and the hosted system console finds it through discovery (by
+ /// kProtocol).
+ static constexpr uint8_t kModule = espp::detail::system_protocol::kModule;
+ /// Stable protocol identifier + version advertised through discovery.
+ static constexpr const char *kProtocol = espp::detail::system_protocol::kProtocol;
+ static constexpr uint16_t kProtocolVersion = espp::detail::system_protocol::kProtocolVersion;
+
+ /// Which restart a host asked for (passed to Config::on_reboot_request).
+ enum class RebootKind : uint8_t {
+ Reboot, ///< plain restart
+ Bootloader, ///< restart into the ROM download mode
+ };
+
+ /// Transmits one encoded reply frame to the host.
+ using send_fn = std::function frame)>;
+ /// Asked (outside the lock) before a permitted reboot is acknowledged;
+ /// return false to veto it (the host gets ERROR "refused by the application").
+ using reboot_request_fn = std::function;
+
+ /// Configuration for the SystemService.
+ struct Config {
+ send_fn send{nullptr}; ///< Transmits an encoded reply frame (required).
+ /// Dispatcher module id this instance answers on (and stamps on its
+ /// replies). A routing key only: hosts find whichever id is chosen through
+ /// discovery (by kProtocol), so any id 0x00..0xEF is fine.
+ uint8_t module{kModule};
+ bool allow_reboot{true}; ///< Serve REBOOT (else ERROR "not permitted").
+ bool allow_bootloader{true}; ///< Serve REBOOT_TO_BOOTLOADER (else ERROR "not permitted").
+ /// Keep the USB peripheral's state across a REBOOT_TO_BOOTLOADER reset
+ /// (ESP32-S2 / -S3 ROM only; see SystemControl::BootloaderOptions). Off by
+ /// default: the ROM re-enumerates its CDC / DFU device on its own, which is
+ /// right for a TinyUSB (vendor / CDC composite) application; enable only
+ /// when the application's USB device is ROM-CDC/DFU-compatible.
+ bool usb_persist{false};
+ /// Optional veto for a specific reboot request; called after the allow_*
+ /// checks, outside the lock. nullptr = every permitted request proceeds.
+ reboot_request_fn on_reboot_request{nullptr};
+ /// Lower bound on the delay between the OK reply and the restart, so the
+ /// reply reaches the host even when it asked for 0 ms.
+ std::chrono::milliseconds min_restart_delay{250};
+ espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Logger verbosity.
+ };
+
+ /// @brief Construct the service.
+ explicit SystemService(const Config &config)
+ : BaseComponent("SystemService", config.log_level)
+ , config_(config) {}
+
+ /// @brief The dispatcher module id this service answers on (Config::module).
+ uint8_t module_id() const { return config_.module; }
+
+ /// @brief Discovery metadata for registering this service on a Dispatcher.
+ Dispatcher::ModuleInfo module_info() const {
+ return {.name = "System",
+ .app = "system_console.html",
+ .description = "Device info, reboot and bootloader entry",
+ .protocol = kProtocol,
+ .protocol_version = kProtocolVersion};
+ }
+
+ /// @brief The capabilities bitmask GET_INFO reports (kCapReboot / kCapBootloader).
+ uint32_t capabilities() const {
+ namespace proto = espp::detail::system_protocol;
+ uint32_t caps = 0;
+ if (config_.allow_reboot)
+ caps |= proto::kCapReboot;
+ if (config_.allow_bootloader && SystemControl::bootloader_reboot_supported())
+ caps |= proto::kCapBootloader;
+ return caps;
+ }
+
+ /// @brief Build the INFO payload (a SystemInfo snapshot as tagged records).
+ std::vector build_info() const {
+ namespace proto = espp::detail::system_protocol;
+ const SystemInfo::Snapshot s = SystemInfo::collect();
+ proto::InfoBuilder b;
+ b.str(proto::InfoTag::ChipModel, s.chip_model)
+ .u16(proto::InfoTag::ChipRevision, s.chip_revision)
+ .u8(proto::InfoTag::Cores, s.cores)
+ .u32(proto::InfoTag::ChipFeatures, s.chip_features)
+ .str(proto::InfoTag::IdfVersion, s.idf_version)
+ .str(proto::InfoTag::ProjectName, s.project_name)
+ .str(proto::InfoTag::AppVersion, s.app_version)
+ .str(proto::InfoTag::BuildDate, s.build_date)
+ .str(proto::InfoTag::BuildTime, s.build_time)
+ .bytes(proto::InfoTag::ElfSha256, s.elf_sha256)
+ .str(proto::InfoTag::RunningPartition, s.running_partition)
+ .str(proto::InfoTag::BootPartition, s.boot_partition)
+ .u8(proto::InfoTag::OtaState, s.ota_state)
+ .u8(proto::InfoTag::ResetReason, s.reset_reason)
+ .u64(proto::InfoTag::UptimeMs, s.uptime_ms)
+ .bytes(proto::InfoTag::Mac, s.mac)
+ .u32(proto::InfoTag::FlashSize, s.flash_size)
+ .u32(proto::InfoTag::PsramSize, s.psram_size)
+ .u32(proto::InfoTag::CpuMhz, s.cpu_mhz)
+ .u32(proto::InfoTag::FreeHeap, s.free_heap)
+ .u32(proto::InfoTag::MinFreeHeap, s.min_free_heap)
+ .u32(proto::InfoTag::Capabilities, capabilities());
+ return b.take();
+ }
+
+ /**
+ * @brief Dispatcher entry point: handle one routed frame. Frames for other
+ * modules and reply-flagged frames are ignored, so this can be
+ * registered directly: `dispatcher.register_module(service)`.
+ */
+ void handle(const espp::stream_frame::Frame &frame) {
+ if (frame.module != module_id() || frame.is_reply())
+ return;
+ handle_frame(frame.type, frame.payload, frame.correlation);
+ }
+
+ /// @brief Feed received transport bytes (standalone use, without a Dispatcher).
+ void feed(std::span data) {
+ std::vector frames;
+ {
+ std::lock_guard lock(mutex_);
+ frames = parser_.feed(data);
+ }
+ for (const auto &frame : frames)
+ handle(frame);
+ }
+
+ /// @brief Discard any partially-buffered frame bytes (standalone feed() use).
+ void reset_parser() {
+ std::lock_guard lock(mutex_);
+ parser_.reset();
+ }
+
+ /**
+ * @brief Handle one already-parsed request frame.
+ * @return true if the type belongs to the system protocol (a reply was
+ * sent), false if it was ignored.
+ * @note The `send` and veto callbacks run after the internal mutex is released.
+ */
+ /// @param correlation The request frame's correlation id, if it carried one;
+ /// every reply echoes it so the host can pair them.
+ bool handle_frame(uint8_t type, std::span payload,
+ std::optional correlation = std::nullopt) {
+ namespace proto = espp::detail::system_protocol;
+ std::error_code ec;
+ switch (static_cast(type)) {
+ case Type::GetInfo: {
+ std::vector info;
+ {
+ std::lock_guard lock(mutex_);
+ info = build_info();
+ }
+ send(proto::build_frame(Type::Info, info, module_id(), correlation));
+ return true;
+ }
+ case Type::Reboot:
+ case Type::RebootToBootloader: {
+ const bool bootloader = static_cast(type) == Type::RebootToBootloader;
+ const auto delay = proto::decode_delay(payload);
+ if (!delay) {
+ send_error(type, std::errc::invalid_argument, "malformed request (expected u16 delay_ms)",
+ correlation);
+ return true;
+ }
+ const bool allowed = bootloader ? config_.allow_bootloader : config_.allow_reboot;
+ if (!allowed) {
+ send_error(type, std::errc::operation_not_permitted,
+ bootloader ? "reboot into bootloader is disabled on this device"
+ : "reboot is disabled on this device",
+ correlation);
+ return true;
+ }
+ if (bootloader && !SystemControl::bootloader_reboot_supported()) {
+ send_error(type, std::errc::operation_not_supported,
+ "this chip has no software path into download mode (use the BOOT strap)",
+ correlation);
+ return true;
+ }
+ // the veto runs outside the lock: the application may take its own locks
+ if (config_.on_reboot_request &&
+ !config_.on_reboot_request(bootloader ? RebootKind::Bootloader : RebootKind::Reboot)) {
+ send_error(type, std::errc::operation_canceled,
+ "refused by the application (try again later)", correlation);
+ return true;
+ }
+ const auto wait = std::max(std::chrono::milliseconds(*delay), config_.min_restart_delay);
+ // reply first, then restart from a detached thread so the reply leaves
+ // the transport and the caller's task (the transport worker) is never blocked
+ send(proto::build_frame(Type::Ok, proto::encode_ok(type), module_id(), correlation));
+ logger_.info("{} in {} ms", bootloader ? "rebooting into the bootloader" : "rebooting",
+ wait.count());
+ if (bootloader)
+ SystemControl::reboot_to_bootloader_after(wait, ec, {.usb_persist = config_.usb_persist});
+ else
+ SystemControl::reboot_after(wait);
+ return true;
+ }
+ default:
+ // not a system request: ignore so the service can share a stream
+ return false;
+ }
+ }
+
+protected:
+ /// Transmit an encoded frame. Must be called WITHOUT the mutex held.
+ void send(const std::vector &frame) {
+ if (frame.empty())
+ return;
+ if (!config_.send) {
+ logger_.warn("no send function configured; dropping a {}-byte reply", frame.size());
+ return;
+ }
+ config_.send(frame);
+ }
+
+ void send_error(uint8_t request_type, std::errc errc, std::string_view message,
+ std::optional correlation = std::nullopt) {
+ namespace proto = espp::detail::system_protocol;
+ logger_.warn("{} (type 0x{:02x})", message, request_type);
+ send(proto::build_frame(
+ Type::Error,
+ proto::encode_error(request_type, static_cast(std::make_error_code(errc).value()),
+ message),
+ module_id(), correlation));
+ }
+
+private:
+ Config config_;
+ mutable std::mutex mutex_;
+ Stream parser_;
+};
+
+// Compile-time check that the service keeps satisfying the dispatcher's module
+// contract (module_id() / module_info() / handle(frame)).
+static_assert(DispatcherModuleConcept);
+
+} // namespace espp
diff --git a/components/system/test/system_host_test.cpp b/components/system/test/system_host_test.cpp
new file mode 100644
index 0000000000..ec273db4f4
--- /dev/null
+++ b/components/system/test/system_host_test.cpp
@@ -0,0 +1,188 @@
+// Host-buildable unit tests for the SystemService wire codec
+// (include/detail/system_protocol.hpp). Build & run with:
+// c++ -std=c++20 -I../include -I../../stream_frame/include system_host_test.cpp -o test && ./test
+//
+// The codec needs no ESP-IDF headers; these are golden encode/decode tests so
+// the browser console and any host tool can rely on the byte layout.
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "detail/system_protocol.hpp"
+
+namespace sp = espp::detail::system_protocol;
+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_info_golden() {
+ std::printf("test_info_golden\n");
+ sp::InfoBuilder b;
+ b.str(sp::InfoTag::ChipModel, "ESP32-S3")
+ .u16(sp::InfoTag::ChipRevision, 2)
+ .u8(sp::InfoTag::Cores, 2);
+ const auto &p = b.payload();
+ // [1][8]"ESP32-S3" [2][2][0x02 0x00] [3][1][2]
+ const uint8_t golden[] = {1, 8, 'E', 'S', 'P', '3', '2', '-', 'S',
+ '3', 2, 2, 0x02, 0x00, 3, 1, 2};
+ CHECK(p.size() == sizeof(golden) && std::equal(golden, golden + sizeof(golden), p.begin()));
+ const auto info = sp::decode_info(p);
+ CHECK(info && info->chip_model == "ESP32-S3" && info->chip_revision == 2 && info->cores == 2);
+ CHECK(info && !info->idf_version && info->unknown_tags == 0);
+}
+
+static void test_info_all_tags() {
+ std::printf("test_info_all_tags\n");
+ std::array sha{};
+ for (size_t i = 0; i < sha.size(); ++i)
+ sha[i] = static_cast(i);
+ const std::array mac = {0x24, 0x6F, 0x28, 0x01, 0x02, 0x03};
+ sp::InfoBuilder b;
+ b.str(sp::InfoTag::ChipModel, "ESP32-P4")
+ .u16(sp::InfoTag::ChipRevision, 100)
+ .u8(sp::InfoTag::Cores, 2)
+ .u32(sp::InfoTag::ChipFeatures, 0x81)
+ .str(sp::InfoTag::IdfVersion, "v6.1")
+ .str(sp::InfoTag::ProjectName, "system_example")
+ .str(sp::InfoTag::AppVersion, "1.2.3")
+ .str(sp::InfoTag::BuildDate, "Sep 30 2026")
+ .str(sp::InfoTag::BuildTime, "12:34:56")
+ .bytes(sp::InfoTag::ElfSha256, sha)
+ .str(sp::InfoTag::RunningPartition, "ota_0")
+ .str(sp::InfoTag::BootPartition, "ota_1")
+ .u8(sp::InfoTag::OtaState, 2)
+ .u8(sp::InfoTag::ResetReason, 3)
+ .u64(sp::InfoTag::UptimeMs, 0x0000000123456789ULL)
+ .bytes(sp::InfoTag::Mac, mac)
+ .u32(sp::InfoTag::FlashSize, 16 * 1024 * 1024)
+ .u32(sp::InfoTag::PsramSize, 8 * 1024 * 1024)
+ .u32(sp::InfoTag::CpuMhz, 360)
+ .u32(sp::InfoTag::FreeHeap, 123456)
+ .u32(sp::InfoTag::MinFreeHeap, 100000)
+ .u32(sp::InfoTag::Capabilities, sp::kCapReboot | sp::kCapBootloader);
+ const auto info = sp::decode_info(b.payload());
+ CHECK(info);
+ if (!info)
+ return;
+ CHECK(info->chip_model == "ESP32-P4" && info->chip_revision == 100 && info->cores == 2 &&
+ info->chip_features == 0x81u);
+ CHECK(info->idf_version == "v6.1" && info->project_name == "system_example" &&
+ info->app_version == "1.2.3" && info->build_date == "Sep 30 2026" &&
+ info->build_time == "12:34:56");
+ CHECK(info->elf_sha256 && (*info->elf_sha256)[31] == 31);
+ CHECK(info->running_partition == "ota_0" && info->boot_partition == "ota_1" &&
+ info->ota_state == 2 && info->reset_reason == 3);
+ CHECK(info->uptime_ms == 0x0000000123456789ULL);
+ CHECK(info->mac && (*info->mac)[0] == 0x24 && (*info->mac)[5] == 0x03);
+ CHECK(info->flash_size == 16u * 1024 * 1024 && info->psram_size == 8u * 1024 * 1024 &&
+ info->cpu_mhz == 360);
+ CHECK(info->free_heap == 123456 && info->min_free_heap == 100000);
+ CHECK(info->capabilities == (sp::kCapReboot | sp::kCapBootloader));
+ CHECK(info->unknown_tags == 0);
+ // the u64 is little-endian on the wire: find the record and check its bytes
+ const auto &p = b.payload();
+ bool found = false;
+ for (size_t i = 0; i + 1 < p.size();) {
+ const uint8_t tag = p[i], len = p[i + 1];
+ if (tag == static_cast(sp::InfoTag::UptimeMs)) {
+ CHECK(len == 8 && p[i + 2] == 0x89 && p[i + 3] == 0x67 && p[i + 5] == 0x23 && p[i + 9] == 0);
+ found = true;
+ }
+ i += 2 + len;
+ }
+ CHECK(found);
+}
+
+static void test_info_unknown_and_truncated() {
+ std::printf("test_info_unknown_and_truncated\n");
+ // an unknown tag (200) with a 3-byte value is skipped; the record after it decodes
+ std::vector p = {200, 3, 0xAA, 0xBB, 0xCC, 3, 1, 1};
+ auto info = sp::decode_info(p);
+ CHECK(info && info->cores == 1 && info->unknown_tags == 1);
+ // a known fixed-size tag with the wrong length is skipped, not misdecoded
+ p = {3, 2, 1, 1, 19, 4, 0x68, 0x01, 0, 0};
+ info = sp::decode_info(p);
+ CHECK(info && !info->cores && info->cpu_mhz == 360 && info->unknown_tags == 1);
+ // a record whose length runs past the payload is rejected
+ p = {1, 8, 'E', 'S', 'P'};
+ CHECK(!sp::decode_info(p));
+ p = {1};
+ CHECK(!sp::decode_info(p));
+ // an empty payload decodes to "nothing known"
+ info = sp::decode_info({});
+ CHECK(info && !info->chip_model && info->unknown_tags == 0);
+ // a string longer than 255 bytes is truncated by the builder, not corrupted
+ sp::InfoBuilder b;
+ b.str(sp::InfoTag::ProjectName, std::string(300, 'p'));
+ info = sp::decode_info(b.payload());
+ CHECK(info && info->project_name && info->project_name->size() == 255);
+}
+
+static void test_requests_and_replies() {
+ std::printf("test_requests_and_replies\n");
+ const auto d = sp::encode_delay(750);
+ CHECK(d.size() == 2 && d[0] == 0xEE && d[1] == 0x02);
+ CHECK(sp::decode_delay(d) == 750);
+ CHECK(sp::decode_delay({}) == 0); // empty payload = no delay
+ const uint8_t one[] = {1};
+ CHECK(!sp::decode_delay(one));
+ const uint8_t three[] = {1, 2, 3}; // too long is malformed too, not "the first two bytes"
+ CHECK(!sp::decode_delay(three));
+ CHECK(sp::encode_ok(0x02) == std::vector{0x02});
+ const auto e = sp::encode_error(0x03, 95, "not supported");
+ CHECK(e.size() == 5 + 13 && e[0] == 3 && e[1] == 95 && e[2] == 0 && e[5] == 'n');
+ const auto de = sp::decode_error(e);
+ CHECK(de && de->request_type == 3 && de->code == 95 && de->message == "not supported");
+ CHECK(!sp::decode_error(std::span(e.data(), 4)));
+ // frames: replies carry the reply flag, requests do not; module is stamped
+ const auto req = sp::build_frame(sp::Type::Reboot, d, 7);
+ CHECK(req[2] == 0x10 && req[3] == 7 && req[4] == 0x02);
+ const auto rep = sp::build_frame(sp::Type::Info, {}, 11);
+ CHECK(rep[2] == 0x11 && rep[3] == 11 && rep[4] == 0x81);
+ sf::StreamParser parser;
+ std::vector stream(req);
+ stream.insert(stream.end(), rep.begin(), rep.end());
+ const auto frames = parser.feed(stream);
+ CHECK(frames.size() == 2 && !frames[0].is_reply() && frames[0].payload.size() == 2 &&
+ frames[1].is_reply() && frames[1].module == 11);
+ // correlation: a request may carry a u16 id; every reply SystemService builds
+ // (INFO, OK, ERROR) passes the request's id through build_frame, so it is
+ // echoed; a request without one gets a reply without one
+ const auto creq = sp::build_frame(sp::Type::GetInfo, {}, 7, 0x1234);
+ const auto cf = sf::StreamParser{}.feed(creq);
+ CHECK(cf.size() == 1 && cf[0].correlation == std::optional(0x1234) &&
+ (creq[2] & 0x02) != 0 && creq.size() == 11 + 4);
+ for (const auto t : {sp::Type::Info, sp::Type::Ok, sp::Type::Error}) {
+ const auto crep = sp::build_frame(t, sp::encode_ok(1), 7, cf[0].correlation);
+ const auto cr = sf::StreamParser{}.feed(crep);
+ CHECK(cr.size() == 1 && cr[0].is_reply() &&
+ cr[0].correlation == std::optional(0x1234));
+ }
+ const auto plain =
+ sf::StreamParser{}.feed(sp::build_frame(sp::Type::Ok, sp::encode_ok(1), 7, std::nullopt));
+ CHECK(plain.size() == 1 && !plain[0].has_correlation());
+}
+
+int main() {
+ test_info_golden();
+ test_info_all_tags();
+ test_info_unknown_and_truncated();
+ test_requests_and_replies();
+ if (g_failures) {
+ std::printf("%d FAILURE(S)\n", g_failures);
+ return 1;
+ }
+ std::printf("ALL TESTS PASSED\n");
+ return 0;
+}
diff --git a/components/system/web/system_console.html b/components/system/web/system_console.html
new file mode 100644
index 0000000000..afda30362b
--- /dev/null
+++ b/components/system/web/system_console.html
@@ -0,0 +1,1455 @@
+
+
+
+
+
+ espp System Console (WebUSB / Web Serial)
+
+
+
+
+
+
+
+
espp System Console (WebUSB / Web Serial)
+ Disconnected
+
+
+
+ This browser supports neither WebUSB nor Web Serial. Use a Chromium-based
+ browser (Chrome / Edge / Opera) on a secure origin (https, localhost or file://).
+
+
+
+
Device
+
+
+
+
+
+
+ Not connected. WebUSB talks to the vendor (0xFF) interface (default filter VID 0x1209);
+ Web Serial talks to the CDC port, where the device console and the protocol share one
+ stream — this page renders the console text and speaks the protocol at the same time.
+
+
+
+
+
System info
+
+
+
+
+
+
+
+
+
Control
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Heap & tasks (monitor module)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
CPU bar
+
+
+
+
+
+
+
+
+
+
+
Log
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/doc/Doxyfile b/doc/Doxyfile
index 24e15bb869..f60edb835a 100755
--- a/doc/Doxyfile
+++ b/doc/Doxyfile
@@ -188,6 +188,7 @@ EXAMPLE_PATH = \
$(PROJECT_PATH)/components/stream_frame/example/main/stream_frame_example.cpp \
$(PROJECT_PATH)/components/switch_pro/example/main/switch_pro_example.cpp \
$(PROJECT_PATH)/components/sx126x/example/main/sx126x_example.cpp \
+ $(PROJECT_PATH)/components/system/example/main/system_example.cpp \
$(PROJECT_PATH)/components/tabulate/example/main/tabulate_example.cpp \
$(PROJECT_PATH)/components/t-deck/example/main/t_deck_example.cpp \
$(PROJECT_PATH)/components/t-dongle-s3/example/main/t_dongle_s3_example.cpp \
@@ -387,6 +388,7 @@ INPUT = \
$(PROJECT_PATH)/components/matouch-rotary-display/include/matouch-rotary-display.hpp \
$(PROJECT_PATH)/components/max1704x/include/max1704x.hpp \
$(PROJECT_PATH)/components/monitor/include/heap_monitor.hpp \
+ $(PROJECT_PATH)/components/monitor/include/monitor_service.hpp \
$(PROJECT_PATH)/components/monitor/include/task_monitor.hpp \
$(PROJECT_PATH)/components/motor_controller/include/basicmicro_commands.hpp \
$(PROJECT_PATH)/components/motor_controller/include/motor_controller.hpp \
@@ -465,6 +467,9 @@ INPUT = \
$(PROJECT_PATH)/components/stream_frame/include/stream_frame.hpp \
$(PROJECT_PATH)/components/switch_pro/include/switch_pro.hpp \
$(PROJECT_PATH)/components/sx126x/include/sx126x.hpp \
+ $(PROJECT_PATH)/components/system/include/system_control.hpp \
+ $(PROJECT_PATH)/components/system/include/system_info.hpp \
+ $(PROJECT_PATH)/components/system/include/system_service.hpp \
$(PROJECT_PATH)/components/t-deck/include/t-deck.hpp \
$(PROJECT_PATH)/components/t-dongle-s3/include/t-dongle-s3.hpp \
$(PROJECT_PATH)/components/t_keyboard/include/t_keyboard.hpp \
diff --git a/doc/en/core/monitor.rst b/doc/en/core/monitor.rst
index df39dac650..ed8786b26a 100644
--- a/doc/en/core/monitor.rst
+++ b/doc/en/core/monitor.rst
@@ -64,3 +64,38 @@ Task Monitor API Reference
--------------------------
.. include-build-file:: inc/task_monitor.inc
+
+Monitor Service
+---------------
+
+The `MonitorService` class serves the heap-region and task statistics above
+over **any byte stream** as a :doc:`dispatcher <../dispatcher/dispatcher>`
+module (``espp.monitor`` v1, module id 8 by default; ``Config::module`` moves
+an instance and hosts find it through discovery by its protocol id).
+``GET_HEAP`` answers with one record per configured heap region
+(``Config::heap_regions``, ``MALLOC_CAP_*`` masks; regions the chip does not
+have are left out), ``GET_TASKS`` with the ``TaskMonitor`` table (name, CPU %,
+stack high-water mark, priority, core — it needs
+``CONFIG_FREERTOS_USE_TRACE_FACILITY`` and
+``CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS``, else the list is empty; both
+replies are capped so the whole frame fits ``Config::max_frame_bytes``, 4096
+by default, regions / tasks beyond it being dropped from the end), and
+``SET_STREAM`` starts a task that sends either or both periodically so a host
+can plot them live. Replies echo the request frame's correlation id so a host
+can pair them and drop stale ones (streamed events carry none). The wire codec
+(``detail/monitor_protocol.hpp``) is
+host-buildable and unit-tested (``test/monitor_host_test.cpp``). The hosted
+`espp System Console `_
+web app renders the heap gauges and a live task table; see the
+:doc:`system <../system/system>` component's example, which exposes both
+services over USB.
+
+.. image:: https://github.com/user-attachments/assets/c2c962bf-debb-44c7-96a7-95527218d953
+ :alt: espp System Console streaming the task table from MonitorService (name, CPU %, stack high-water mark, priority, core)
+ :width: 100%
+ :target: https://esp-cpp.github.io/espp/apps/system_console.html
+
+Monitor Service API Reference
+-----------------------------
+
+.. include-build-file:: inc/monitor_service.inc
diff --git a/doc/en/dispatcher/custom_modules.rst b/doc/en/dispatcher/custom_modules.rst
index 30bc7fb3ec..070cbc8882 100644
--- a/doc/en/dispatcher/custom_modules.rst
+++ b/doc/en/dispatcher/custom_modules.rst
@@ -66,6 +66,8 @@ Module id Protocol Protocol id
4 Crash dump (``espp::CoreDumpService``) ``espp.coredump`` v1
5 CAN bridge (``components/canopen``) ``espp.can-bridge`` v1
6 MCP266 motor-controller console (``espp::Mcp266Service``) ``espp.mcp266`` v1
+7 System info / reboot control (``espp::SystemService``) ``espp.system`` v1
+8 Heap / task monitor (``espp::MonitorService``) ``espp.monitor`` v1
0xF0-0xFE reserved for dispatcher / meta use
0xFF capability discovery
========= ======================================================== ==================================
@@ -738,7 +740,7 @@ module's hosted web app (see `Hosting your webapp`_ below); the hub treats it
as a same-directory relative link. `protocol` is how a host *identifies* your
module regardless of the id it is registered on: give your protocol a stable,
namespaced id (espp's are ``espp.ota``, ``espp.coredump``, ``espp.telemetry``,
-``espp.mcp266``, ``espp.can-bridge``, ``espp.haptics``,
+``espp.mcp266``, ``espp.can-bridge``, ``espp.haptics``, ``espp.system``, ``espp.monitor``,
``espp.coredump-crash-trigger``) and bump `protocol_version` when the wire
format changes; a host reports a version other than the one it implements as
a warning. Both are new in discovery payload version 2 and optional.
diff --git a/doc/en/dispatcher/dispatcher.rst b/doc/en/dispatcher/dispatcher.rst
index 75148e80e6..50d77a03b2 100644
--- a/doc/en/dispatcher/dispatcher.rst
+++ b/doc/en/dispatcher/dispatcher.rst
@@ -28,6 +28,8 @@ Module id Protocol Protocol id (discover
4 crash dump ``espp.coredump`` v1
5 CAN bridge ``espp.can-bridge`` v1
6 MCP266 console ``espp.mcp266`` v1
+7 System info / reboot ``espp.system`` v1
+8 Heap / task monitor ``espp.monitor`` v1
0xF0-0xFE reserved (meta)
0xFF capability discovery
========= ============================================== ===============================
diff --git a/doc/en/index.rst b/doc/en/index.rst
index 0ac3b24fdd..15cc46a6a6 100755
--- a/doc/en/index.rst
+++ b/doc/en/index.rst
@@ -23,6 +23,7 @@ collected under :doc:`web_apps`.
core/index
coredump/index
+ system/index
.. toctree::
:maxdepth: 1
diff --git a/doc/en/system/index.rst b/doc/en/system/index.rst
new file mode 100644
index 0000000000..5b05413856
--- /dev/null
+++ b/doc/en/system/index.rst
@@ -0,0 +1,16 @@
+System APIs
+***********
+
+.. toctree::
+ :maxdepth: 1
+
+ system
+
+The `System` component reports what a device is and how it is doing —
+chip, ESP-IDF version, application description, partitions and OTA state,
+reset reason, uptime, MAC, memory sizes, CPU frequency and heap — and
+controls restarts (a plain reboot, or a reboot into the ROM bootloader's
+download mode), as plain C++ APIs and as a transport-agnostic stream service
+with a browser web app (WebUSB / Web Serial). The :doc:`monitor
+<../core/monitor>` component's ``MonitorService`` complements it with live
+heap and task statistics.
diff --git a/doc/en/system/system.rst b/doc/en/system/system.rst
new file mode 100644
index 0000000000..c8dfcee802
--- /dev/null
+++ b/doc/en/system/system.rst
@@ -0,0 +1,86 @@
+System Info, Control & Service
+******************************
+
+The `SystemInfo` class is a set of static getters over the corresponding
+ESP-IDF calls: the chip model, revision, core count and feature flags, the
+ESP-IDF version, the application description embedded in the image (project
+name, version, build date and time, ELF SHA-256), the running and boot
+partitions with the OTA image state, the reset reason, uptime, base MAC, flash
+and PSRAM sizes, CPU frequency and the free / lowest-free heap.
+``collect()`` gathers everything into one ``Snapshot`` and ``to_string()``
+renders a boot-banner style summary.
+
+The `SystemControl` class restarts the device: ``reboot()`` (``esp_restart``),
+and ``reboot_to_bootloader()`` which sets the chip's *force download boot*
+flag in its always-on register and restarts, so the next boot stays in the
+ROM download mode instead of running the app — what holding the BOOT strap
+during a reset does, without a button. The device then re-enumerates as the
+ROM's own flashing interface (the USB CDC / DFU device on the ESP32-S2 / -S3
+native USB port; USB-Serial-JTAG on the ESP32-C3 / -C6 / -H2 / -C5 / -C61 /
+-H21 / -P4), ready for ``esptool`` / ``idf.py flash``. The classic ESP32 has
+no software path (only the GPIO0 strap): ``bootloader_reboot_supported()`` is
+false there and the call fails with ``operation_not_supported``. The
+``*_after(delay)`` variants restart from a detached thread so a reply can
+leave the transport first.
+
+By default the reset tears the USB connection down and the ROM enumerates its
+device afresh, which works for any application. On the ESP32-S2 / -S3 the ROM
+can instead keep the USB peripheral's state across the reset
+(``SystemControl::BootloaderOptions::usb_persist``, passed through as
+``SystemService::Config::usb_persist``) so the host sees no re-plug. This is
+**opt-in and off by default**: the ROM only expects it from an application
+whose USB device is ROM-CDC/DFU-compatible (ESP-IDF's ROM USB console driver,
+which is what performs the same sequence — ``usb_dc_prepare_persist()`` then
+the persist flag — before its own reboot into the bootloader). A TinyUSB
+composite device such as the espp examples' vendor + CDC has different
+descriptors, and persisting it can leave the host with a stale enumeration
+the bootloader cannot serve; leave the option off there.
+
+The `SystemService` class serves both over **any byte stream** as a
+:doc:`dispatcher <../dispatcher/dispatcher>` module (``espp.system`` v1,
+module id 7 by default; ``Config::module`` moves an instance and hosts find
+it through discovery by its protocol id). ``GET_INFO`` answers with a list of
+tagged records (``[tag u8][len u8][value]``) a host decodes while skipping
+tags it does not know, so fields can be added without a version bump. Every
+reply echoes the request frame's correlation id, so a host that stamps its
+requests can pair replies with them and drop stale ones.
+``REBOOT`` and ``REBOOT_TO_BOOTLOADER`` reply ``OK`` first and restart after
+the requested delay (clamped to ``Config::min_restart_delay``). Both are
+guarded: ``Config::allow_reboot`` / ``allow_bootloader`` switch them off, the
+optional ``on_reboot_request`` callback can veto a specific request (an
+application with a motor running can refuse or defer), and the bootloader
+restart is refused on chips without a software path. The ``INFO``
+capabilities record tells a host up front which of the two it may offer.
+
+The hosted `espp System Console
+`_ web app speaks
+the protocol over **WebUSB** (vendor interface) or **Web Serial** (CDC, where
+it doubles as a serial monitor): a device-info panel, the two restart buttons
+(with an in-page confirmation), and — when the device also advertises the
+:doc:`monitor <../core/monitor>` component's ``MonitorService`` — heap-region
+gauges and a live, sortable task table with a stream toggle.
+
+.. image:: https://github.com/user-attachments/assets/bc5fa520-f3a0-40de-9b53-889a887c0322
+ :alt: espp System Console: device info, reboot / bootloader controls and heap gauges
+ :width: 100%
+ :target: https://esp-cpp.github.io/espp/apps/system_console.html
+
+.. image:: https://github.com/user-attachments/assets/c2c962bf-debb-44c7-96a7-95527218d953
+ :alt: espp System Console streaming the task table (name, CPU %, stack high-water mark, priority, core)
+ :width: 100%
+ :target: https://esp-cpp.github.io/espp/apps/system_console.html
+
+.. ------------------------------- Example -------------------------------------
+
+.. toctree::
+
+ system_example
+
+.. ---------------------------- API Reference ----------------------------------
+
+API Reference
+-------------
+
+.. include-build-file:: inc/system_info.inc
+.. include-build-file:: inc/system_control.inc
+.. include-build-file:: inc/system_service.inc
diff --git a/doc/en/system/system_example.md b/doc/en/system/system_example.md
new file mode 100644
index 0000000000..4ae87dfbf8
--- /dev/null
+++ b/doc/en/system/system_example.md
@@ -0,0 +1,2 @@
+```{include} ../../../components/system/example/README.md
+```
diff --git a/doc/en/web_apps.rst b/doc/en/web_apps.rst
index 6e5cc9c56d..f04dc6856a 100644
--- a/doc/en/web_apps.rst
+++ b/doc/en/web_apps.rst
@@ -42,6 +42,11 @@ device may serve a protocol on any dispatcher module id.
over SDO).
- **MCP266 Console** (``mcp266_console.html``) — status, motor, and configuration
controls for the :doc:`mcp266 ` motor controller.
+- **System Console** (``system_console.html``) — device info (chip, firmware,
+ partitions, reset reason, uptime, memory), reboot and reboot-into-bootloader
+ for the :doc:`system ` component, plus live heap gauges and a
+ task table when the device serves the :doc:`monitor `
+ component's ``MonitorService``.
Motor control
=============