Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions components/canopen/can_bridge_example/main/can_bridge_example.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@ extern "C" void app_main(void) {
enum class Transport { Vendor, Cdc };
std::atomic<Transport> active_transport{Transport::Vendor};
std::mutex tx_mutex;
auto send = [&](std::span<const uint8_t> bytes) {
// All device->host writes (request replies, streamed CAN_RX frames, AND
// discovery replies) go through this one tx_mutex-guarded helper so the
// concurrent senders (the RX worker + the TWAI receive task) never write the
// TinyUSB FIFO at once.
auto send_to = [&](Transport dest, std::span<const uint8_t> bytes) {
std::lock_guard<std::mutex> lock(tx_mutex);
// write_vendor/write_cdc are all-or-nothing (no truncated frame): they
// bounded-wait (~250 ms) for the host to drain the TX FIFO, then return
Expand All @@ -81,12 +85,12 @@ extern "C" void app_main(void) {
// not the TinyUSB callback, so the drain-wait path applies. For a
// best-effort CAN monitor a drop is acceptable; surface it rate-limited
// rather than silently discarding a reply/CAN_RX frame.
const bool ok = (active_transport.load() == Transport::Cdc) ? usb.write_cdc(bytes)
: usb.write_vendor(bytes);
const bool ok = (dest == Transport::Cdc) ? usb.write_cdc(bytes) : usb.write_vendor(bytes);
if (!ok)
logger.warn_rate_limited("dropped a {}-byte frame (USB TX backpressure or disconnect)",
bytes.size());
};
auto send = [&](std::span<const uint8_t> bytes) { send_to(active_transport.load(), bytes); };
auto send_frame = [&](uint8_t type, std::span<const uint8_t> payload = {}) {
// Reply/event types (kCanRx/kOk/kError/kStatus) carry the high bit; map it
// to the frame reply flag. All CAN-bridge frames are module kModuleId.
Expand Down Expand Up @@ -250,8 +254,19 @@ extern "C" void app_main(void) {
// gets its OWN Dispatcher (one parser) — a frame split across reads on one
// transport must never be stitched onto bytes from the other.
espp::Dispatcher vendor_dispatcher, cdc_dispatcher;
vendor_dispatcher.register_module(can_bridge::kModuleId, handle_can_frame);
cdc_dispatcher.register_module(can_bridge::kModuleId, handle_can_frame);
// Advertise the CAN-bridge module for capability discovery so the browser
// Device Hub can list and link it.
const espp::Dispatcher::ModuleInfo can_info{.name = "CAN Bridge",
.app = "can_bridge_console.html",
.description =
"Raw CAN 2.0 bridge (WebUSB / Web Serial)"};
vendor_dispatcher.register_module(can_bridge::kModuleId, handle_can_frame, can_info);
cdc_dispatcher.register_module(can_bridge::kModuleId, handle_can_frame, can_info);
vendor_dispatcher.set_device_info(usb_cfg.product);
cdc_dispatcher.set_device_info(usb_cfg.product);
vendor_dispatcher.serve_discovery(
[&](std::span<const uint8_t> f) { send_to(Transport::Vendor, f); });
cdc_dispatcher.serve_discovery([&](std::span<const uint8_t> f) { send_to(Transport::Cdc, f); });

// --- USB RX plumbing: queue in the TinyUSB task, dispatch from a worker -----
// transmit() can block up to its timeout, so it must not run in the TinyUSB
Expand Down
38 changes: 27 additions & 11 deletions components/coredump/example/main/coredump_example.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -254,18 +254,34 @@ extern "C" void app_main(void) {
};
// Register the protocols on each stream's dispatcher. The service answers
// requests, so ignore reply-flagged frames (handle_frame() takes only
// type+payload, so the direction check lives here).
vendor_dispatcher.register_module(espp::CoreDumpService::kModule,
[&](const espp::stream_frame::Frame &f) {
if (!f.is_reply())
vendor_service.handle_frame(f.type, f.payload);
});
// type+payload, so the direction check lives here). The core-dump module is
// advertised (name / web app / description) so the browser Device Hub can
// discover and link it.
const espp::Dispatcher::ModuleInfo coredump_info{.name = "Core Dump",
.app = "coredump_console.html",
.description =
"Inspect the last crash core dump"};
vendor_dispatcher.register_module(
espp::CoreDumpService::kModule,
[&](const espp::stream_frame::Frame &f) {
if (!f.is_reply())
vendor_service.handle_frame(f.type, f.payload);
},
coredump_info);
vendor_dispatcher.register_module(kCrashModule, handle_cmd_frame);
cdc_dispatcher.register_module(espp::CoreDumpService::kModule,
[&](const espp::stream_frame::Frame &f) {
if (!f.is_reply())
cdc_service.handle_frame(f.type, f.payload);
});
cdc_dispatcher.register_module(
espp::CoreDumpService::kModule,
[&](const espp::stream_frame::Frame &f) {
if (!f.is_reply())
cdc_service.handle_frame(f.type, f.payload);
},
coredump_info);
// Advertise the device, and answer discovery on whichever transport asked
// (each stream has its own dispatcher, so each replies over its own writer).
vendor_dispatcher.set_device_info(usb_cfg.product);
cdc_dispatcher.set_device_info(usb_cfg.product);
vendor_dispatcher.serve_discovery([&](std::span<const uint8_t> f) { usb.write_vendor(f); });
cdc_dispatcher.serve_discovery([&](std::span<const uint8_t> f) { usb.write_cdc(f); });

espp::Task rx_task({.callback = [&](std::mutex &, std::condition_variable &) -> bool {
std::deque<std::pair<Source, std::vector<uint8_t>>> chunks;
Expand Down
46 changes: 41 additions & 5 deletions components/dispatcher/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ request/reply direction (`flags`) travel with the frame and are handed to the
module's handler untouched; the Dispatcher does not interpret them. espp
built-in protocols use, for example:

| Module | Protocol |
|--------|------------|
| 0 | OTA |
| 4 | crash dump |
| 5 | CAN bridge |
| Module | Protocol |
|-----------|----------------------|
| 0 | OTA |
| 4 | crash dump |
| 5 | CAN bridge |
| 0xF0–0xFF | reserved (meta) |
| 0xFF | capability discovery |
Comment thread
finger563 marked this conversation as resolved.

A device-side dispatcher registers the modules it serves; frames for an
unregistered module are silently ignored. A protocol's replies use the **same**
Expand Down Expand Up @@ -56,6 +58,40 @@ dispatcher.register_module(4, [&](const espp::stream_frame::Frame &f) {
usb.set_vendor_receive_callback([&](std::span<const uint8_t> data) { dispatcher.feed(data); });
```

## Capability discovery

A module can be registered with a `ModuleInfo` (name / web app / description) so a
connected peer can ask the device **which** modules it runs — over the reserved
discovery module id `0xFF` — and render or link each one. This powers the browser
**Device Hub** app (`components/dispatcher/web/dispatcher_hub.html`, hosted at
`apps/dispatcher_hub.html`): connect over WebUSB / Web Serial, and it lists the
device's modules as tabs, each linking to that module's own web app.

- `struct ModuleInfo { std::string name, app, description; };`
- `void register_module(uint8_t id, handler_fn handler, ModuleInfo info)` — the
registration overload that carries metadata (a module with an empty `name` is
not advertised).
- `void set_device_info(std::string name, std::string firmware = "")` — advertised
at the head of the reply.
- `std::vector<uint8_t> describe() const` — the serialized capability payload, for
apps that own their transmit path.
- `void serve_discovery(reply_fn reply)` — opt in to auto-answering the discovery
query. This is the **only** path by which a Dispatcher sends: it hands the encoded
reply frame to your transmit callback. The router stays otherwise send-free.

```cpp
dispatcher.set_device_info("espp MCP266 Console", "1.0.0");
dispatcher.register_module(6, mcp_handler,
{.name = "MCP266", .app = "mcp266_console.html", .description = "Configure & command motors"});
// answer discovery over 0xFF using the app's transport
dispatcher.serve_discovery([&](std::span<const uint8_t> frame) { usb.write_vendor(frame); });
```

The discovery reply payload is a compact binary TLV (all lengths one byte;
strings are `[len][bytes]`): `[version][reserved][device_name][device_fw]
[module_count]` then per module `[id][name][app][description]`. The reserved
discovery module (`0xFF`) never lists itself.

## Host tests

```
Expand Down
65 changes: 57 additions & 8 deletions components/dispatcher/example/main/dispatcher_example.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include <chrono>
#include <string>
#include <thread>
#include <vector>

Expand Down Expand Up @@ -26,14 +27,23 @@ extern "C" void app_main(void) {
static constexpr uint8_t kModuleTelemetry = 4;

espp::Dispatcher dispatcher;
dispatcher.register_module(kModuleControl, [&](const sf::Frame &f) {
logger.info("[control] {} type=0x{:02X} ({} payload bytes)", f.is_reply() ? "reply" : "request",
f.type, f.payload.size());
});
dispatcher.register_module(kModuleTelemetry, [&](const sf::Frame &f) {
uint32_t value = f.payload.size() == 4 ? sf::get_u32(f.payload) : 0;
logger.info("[telemetry] type=0x{:02X} value={}", f.type, value);
});
// Register each module WITH discovery metadata (name / web app / description)
// so a connected peer can enumerate them (see the discovery section below).
dispatcher.register_module(
kModuleControl,
[&](const sf::Frame &f) {
logger.info("[control] {} type=0x{:02X} ({} payload bytes)",
f.is_reply() ? "reply" : "request", f.type, f.payload.size());
},
{.name = "Control", .app = "control_console.html", .description = "Device control channel"});
dispatcher.register_module(kModuleTelemetry,
[&](const sf::Frame &f) {
uint32_t value = f.payload.size() == 4 ? sf::get_u32(f.payload) : 0;
logger.info("[telemetry] type=0x{:02X} value={}", f.type, value);
},
{.name = "Telemetry",
.app = "telemetry_console.html",
.description = "Live telemetry stream"});

// Build a mixed stream, as a peer would send it.
std::vector<uint8_t> telemetry_payload;
Expand All @@ -54,6 +64,45 @@ extern "C" void app_main(void) {
dispatcher.feed(std::span<const uint8_t>(stream.data(), half));
dispatcher.feed(std::span<const uint8_t>(stream.data() + half, stream.size() - half));
logger.info("done ({} bytes dropped while resyncing)", dispatcher.dropped_bytes());

// --- capability discovery -------------------------------------------------
// A connected peer (e.g. the browser hub app) can ask WHICH modules this
// device runs, over the reserved discovery module id 0xFF. Advertise a device
// name + firmware, then opt in to auto-answering the query. serve_discovery()
// is the ONLY path by which the Dispatcher sends: it hands the encoded reply
// frame to the transmit callback we supply. On real hardware that callback is
// usb.write_vendor / socket send / etc.; here we just capture it in-process
// and decode it to show what a peer receives.
dispatcher.set_device_info("espp Dispatcher Example", "1.0.0");
std::vector<uint8_t> discovery_reply;
dispatcher.serve_discovery(
[&](std::span<const uint8_t> frame) { discovery_reply.assign(frame.begin(), frame.end()); });

dispatcher.feed(sf::build_frame(/*reply=*/false, espp::Dispatcher::kDiscoveryModule,
static_cast<uint8_t>(espp::Dispatcher::Discovery::ListModules)));

const auto reply_frames = sf::StreamParser{}.feed(discovery_reply);
if (!reply_frames.empty()) {
const auto &p = reply_frames[0].payload;
size_t i = 2; // skip [version][reserved]
auto rd_str = [&]() {
const uint8_t n = p[i++];
std::string s(reinterpret_cast<const char *>(&p[i]), n);
i += n;
return s;
};
const std::string dev = rd_str();
const std::string fw = rd_str();
const uint8_t count = p[i++];
logger.info("discovery: '{}' (fw {}) advertises {} module(s):", dev, fw, count);
for (uint8_t m = 0; m < count; ++m) {
const uint8_t id = p[i++];
const std::string name = rd_str();
const std::string app = rd_str();
const std::string desc = rd_str();
logger.info(" module {}: {} [app={}] — {}", id, name, app, desc);
}
}
//! [dispatcher example]

while (true) {
Expand Down
Loading
Loading