Desktop, Desktop Service & Console Capture

The Desktop class is the retained model of a windowed desktop: apps (name, icon, description and a launch callback), windows (title, flags, geometry) each holding a tree of widgets (containers — Column, Row, Group — and leaves — Label, Button, Checkbox, TextBox, TextArea, List, Table, Select, Progress, Slider, Separator, Spacer), plus modal dialogs (message and input boxes) and notifications. The firmware never draws anything: a browser connected through the DesktopService renders the desktop, lets the user operate it, and reports every interaction back as an event.

An app is a few lines of C++: register it, and build its window in the launch callback with the Window / Widget value handles (win.label(...), win.button("+1", [=]{ ... }), label.set_text("Count: {}", n)). Every mutator may be called from any task; it records the change and wakes the desktop task, which coalesces everything changed since the last flush (last value wins, text appends concatenate, a removed widget cancels its pending changes) into the fewest frames every Config::flush_period (50 ms by default). Every application callback — launch, widget / window / dialog handlers, timers, post() — runs on the desktop task with no lock held, so a handler may call anything, including closing its own window. Handles are plain values that become invalid (and no-ops) once their target is gone.

Layout is a box model: nested Column / Row / Group containers become CSS flexbox in the browser; a widget’s weight is its flex-grow along the parent’s axis and its layout bits set the cross-axis behaviour (stretch, scroll, align end / center); width / height give a preferred size. Windows carry flags (movable, resizable, closable, modal, minimizable, maximizable, centered, pinned, wants-geometry) and a geometry the browser may override with what the user last chose (remembered per app and title).

The DesktopService class serves one Desktop over any byte stream as a dispatcher module (espp.desktop v2, module id 9 by default; one instance per transport). It only decodes and validates: a malformed request is answered with ERROR, everything else is handed to the desktop and handled — replied to, and its events broadcast — on the desktop task. GET_DESKTOP returns the app list and settings followed by the full tree of every open window and dialog (so a reconnecting browser resyncs in one request) and marks the transport active; LAUNCH_APP / CLOSE_WINDOW are acknowledged; WINDOW_EVENT / WIDGET_EVENT / DIALOG_RESULT are not. Widget payloads are split across frames (a long text becomes Text + TextAppend pieces, a long list several ranges, a big window tree WINDOW_OPEN + WIDGET_ADD continuations), never truncated; dialogs, notifications and the app registry are single frames whose limits the API enforces (an oversized dialog / toast / title / placeholder / tooltip / column set is refused and logged, the registry is bounded in app count and string lengths). The transport’s send reports whether a frame was queued; when one is refused (the host went away or stopped reading) the desktop pauses streaming to that transport (needs_resync(), it stays attached) and re-sends the full snapshot by itself once frames go through again, retrying every second with back-off to eight seconds, or at the host’s next GET_DESKTOP; the wire format is documented in include/detail/desktop_protocol.hpp and checked by a host test against the fixture test/desktop_vectors.txt, which the web app’s test reads too.

The ConsoleCapture class keeps the last N bytes of everything the firmware prints (ESP_LOG, espp::Logger, printf, stderr) in a byte ring a reader can page through with a cursor, while still writing them to the original console: a tiny write-only VFS device is registered and stdout / stderr are re-opened on it. It is what the example’s Log Viewer app streams. It is mutually exclusive with UsbDevice::route_console_to_cdc() (both re-point stdout).

The hosted espp Desktop web app (web/desktop.html) speaks the protocol over WebUSB or Web Serial: a desktop with app icons and a start menu, draggable / resizable windows with a taskbar, modal dialogs and toasts, and a frame log for debugging.

API Reference

Header File

Classes

class Desktop : public espp::BaseComponent

The retained desktop: apps, windows, widgets, dialogs and notifications, flushed to the browser by its own task. One per device; attach one espp::DesktopService per transport.

Counter app (the API in 30 lines)

inline void register_counter_app(espp::Desktop &desktop) {
  desktop.register_app({
      .name = "Counter",
      .icon = "\xF0\x9F\xA7\xAE", // abacus
      .description = "Counts clicks (kept in NVS)",
      // runs on the desktop task when the user launches the app
      .launch =
          [](espp::Desktop &d, espp::Desktop::AppId app) {
            std::error_code ec;
            auto nvs = std::make_shared<espp::NvsHandle>("desktop", ec);
            auto count = std::make_shared<int32_t>(0);
            nvs->get("count", *count, int32_t{0}, ec);

            auto win = d.create_window({.title = "Counter", .app = app, .w = 240, .h = 150});
            auto label = win.label(fmt::format("Count: {}", *count), 0, espp::Desktop::kLabelBold);
            auto row = win.row(); // a horizontal container for the buttons
            auto bump = [=](int32_t delta) mutable {
              *count += delta;
              // set() only stages: commit() writes it to flash
              nvs->set("count", *count, ec);
              if (!ec)
                nvs->commit(ec);
              label.set_text("Count: {}{}", *count, ec ? " (not saved)" : "");
            };
            win.button(
                "-1", [=]() mutable { bump(-1); }, row.id());
            win.button(
                "+1", [=]() mutable { bump(+1); }, row.id(), espp::Desktop::kButtonPrimary);
            win.button(
                "Reset",
                [=, &d]() mutable {
                  // a message box owned by the window; the answer arrives later
                  d.message_box({.owner = win.id(),
                                 .title = "Reset counter?",
                                 .text = "The count is kept in NVS.",
                                 .buttons = {"Reset", "Cancel"},
                                 .icon = espp::Desktop::DialogIcon::Question,
                                 .on_result = [=](int button) mutable {
                                   if (button == 0)
                                     bump(-*count);
                                 }});
                },
                row.id(), espp::Desktop::kButtonDanger);
          },
  });
}

Wiring

  // The desktop: one per device. Apps register with it; every app callback
  // runs on its task.
  const auto sysinfo = espp::SystemInfo::collect();
  espp::Desktop desktop(
      {.device_name = "espp Desktop",
       .firmware = fmt::format("{} {}", sysinfo.project_name, sysinfo.app_version),
       .task_config = {.name = "desktop", .stack_size_bytes = 10 * 1024},
       .log_level = espp::Logger::Verbosity::INFO});
  register_counter_app(desktop);
  register_about_app(desktop);
  register_system_monitor_app(desktop);
  register_task_manager_app(desktop);
  register_log_viewer_app(desktop);
  register_files_app(desktop);
#if DESKTOP_EXAMPLE_CANOPEN_APP
  register_canopen_app(desktop);
#elif CONFIG_DESKTOP_EXAMPLE_ENABLE_CANOPEN
  logger.warn("CONFIG_DESKTOP_EXAMPLE_ENABLE_CANOPEN is set but the CANopen app was not built: "
              "it needs IDF >= 6.0 (twai) and the CMake option DESKTOP_EXAMPLE_CANOPEN=ON");
#endif
#if CONFIG_DESKTOP_EXAMPLE_ENABLE_I2C
  register_i2c_scanner_app(desktop);
#endif
#if CONFIG_DESKTOP_EXAMPLE_ENABLE_WIFI || CONFIG_DESKTOP_EXAMPLE_ENABLE_ETHERNET
  register_network_app(desktop);
#endif
  register_settings_app(desktop, "espp Desktop");
  desktop_example::apply_saved_settings(desktop);

  // 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 = 0x0d38; // distinct from the espp default so the webapp filter is specific
  usb_cfg.manufacturer = "espp";
  usb_cfg.product = "espp Desktop";
  usb_cfg.log_level = espp::Logger::Verbosity::WARN;
  espp::UsbDevice::CdcFunction cdc;
  cdc.interface_name = "espp Desktop (CDC)";
  usb_cfg.cdc = cdc;
  espp::UsbDevice::VendorFunction vendor;
  vendor.interface_name = "espp Desktop (WebUSB)";
  vendor.webusb = true; // advertise BOS / WebUSB / MS OS 2.0 descriptors
  vendor.landing_page_url = "esp-cpp.github.io/espp/apps/desktop.html";
  usb_cfg.vendor = vendor;
  espp::UsbDevice usb(usb_cfg);

  // One send function per transport, serialized by one mutex each, so the
  // services' frames never interleave. write_vendor / write_cdc are
  // all-or-nothing: they wait (bounded, 250 ms) for FIFO room for the WHOLE
  // frame and never queue a partial one, and return false when the host did
  // not drain in time (unplugged, or the page is not reading) -- the desktop
  // then flags that transport as needing a resync, and the browser resyncs
  // with GET_DESKTOP when it reconnects.
  std::mutex vendor_tx_mutex, cdc_tx_mutex;
  auto vendor_send = [&](std::span<const uint8_t> frame) {
    std::lock_guard<std::mutex> lock(vendor_tx_mutex);
    return usb.write_vendor(frame);
  };
  auto cdc_send = [&](std::span<const uint8_t> frame) {
    std::lock_guard<std::mutex> lock(cdc_tx_mutex);
    return usb.write_cdc(frame);
  };

  // One service instance per transport (they are cheap; each replies on its
  // own stream). Declared BEFORE the workers that call into them.
  espp::DesktopService vendor_desktop(
      desktop, {.send = vendor_send, .log_level = espp::Logger::Verbosity::INFO});
  espp::DesktopService cdc_desktop(desktop,
                                   {.send = cdc_send, .log_level = espp::Logger::Verbosity::INFO});
  espp::SystemService vendor_system({.send = vendor_send});
  espp::SystemService cdc_system({.send = cdc_send});
  espp::MonitorService vendor_monitor(
      {.send = vendor_send, .task_config = {.name = "monitor_v", .stack_size_bytes = 6 * 1024}});
  espp::MonitorService cdc_monitor(
      {.send = cdc_send, .task_config = {.name = "monitor_c", .stack_size_bytes = 6 * 1024}});
  espp::OtaService vendor_ota(ota, {.send = vendor_send});
  espp::OtaService cdc_ota(ota, {.send = cdc_send});
  espp::CoreDumpService vendor_coredump(core_dump, {.send = vendor_send});
  espp::CoreDumpService cdc_coredump(core_dump, {.send = cdc_send});

  // 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,
       .on_overflow = [&]() { vendor_ota.on_rx_overflow(); },
       .task_config = {.name = "desktop_rx_vendor", .stack_size_bytes = 8192}});
  espp::DispatcherWorker cdc_link(
      {.send = cdc_send,
       .on_overflow = [&]() { cdc_ota.on_rx_overflow(); },
       .task_config = {.name = "desktop_rx_cdc", .stack_size_bytes = 8192}});
  for (auto *link : {&vendor_link, &cdc_link}) {
    const bool v = link == &vendor_link;
    link->register_module(v ? vendor_desktop : cdc_desktop);
    link->register_module(v ? vendor_system : cdc_system);
    link->register_module(v ? vendor_monitor : cdc_monitor);
    link->register_module(v ? vendor_ota : cdc_ota);
    link->register_module(v ? vendor_coredump : cdc_coredump);
    link->serve_discovery(usb_cfg.product);
  }

Public Types

using WidgetEvent = detail::dp::WidgetEvent

A widget event from the host: `kind` says which of value / text / key apply (Text events arrive reassembled, `text` holds the whole text).

using WindowEvent = detail::dp::WindowEvent

A window event from the host (Moved / Resized carry the new geometry).

using send_fn = std::function<bool(std::span<const uint8_t> frame)>

Transmits one encoded frame to a host (one per transport; see add_sink). Returns false when the frame could NOT be queued (the transport is gone, or its FIFO did not drain in time). The desktop then stops streaming to that sink (its host’s mirror is incomplete, and every further write would block the desktop task for the transport’s drain timeout), flags it (sink_needs_resync) and re-sends the full snapshot by itself once the transport takes frames again (retried with back-off, kResyncRetryMin..kResyncRetryMax) &#8212; or at the host’s next GET_DESKTOP, whichever comes first. A transport must never queue a partial frame (write a frame all-or-nothing).

Public Functions

inline explicit Desktop(const Config &config)

Construct the desktop and start its task.

inline AppId register_app(App app)

Register an app; returns its id, or 0 (logged) when kMaxApps are registered already or the DESKTOP records + full app list (with empty descriptions) would no longer fit one frame of Config::max_frame_bytes (apps are never trimmed on the wire, only the window list and the descriptions are). Name / icon / description longer than kMaxAppNameBytes / kMaxAppIconBytes / kMaxAppDescriptionBytes are truncated (logged).

inline bool unregister_app(AppId id)

Unregister an app: its open windows are closed (reason Shutdown, their on_close callbacks run on the desktop task) and its id is not handed out again while anything still references it.

inline void launch(AppId id)

Launch an app from the firmware (its launch callback runs on the desktop task; a single-instance app that is open is focused).

inline Window create_window(WindowConfig cfg)

Open a window (sent at the next flush).

Open a window (sent at the next flush); an invalid handle (logged) when the title is longer than kMaxStr8Bytes.

inline bool close_window(WindowId id, WindowCloseReason reason = WindowCloseReason::App)

Close a window; its on_close runs on the desktop task.

inline std::vector<Window> windows(AppId app = 0)

The open windows (of one app, or every app when id == 0).

inline DialogId message_box(MessageBoxConfig cfg)

Open a message box; returns its id, or 0 (logged) when it would not fit one frame (max_payload(): title / text / buttons too long).

inline DialogId input_box(InputBoxConfig cfg)

Open an input box; returns its id, or 0 (logged) when it would not fit one frame (max_payload()).

inline bool close_dialog(DialogId id)

Close a dialog from the firmware (its callback does not run).

inline bool notify(NotifyConfig cfg)

Show a toast; false (logged) when it would not fit one frame.

inline void post(std::function<void()> fn)

Run a function on the desktop task (soon).

inline TimerId add_timer(std::chrono::milliseconds period, std::function<void()> fn, WindowId owner = 0)

A periodic callback on the desktop task; `owner` (a window id) cancels it when that window closes. Returns the timer id.

inline bool on_desktop_task() const

Whether the caller is the desktop task (where app callbacks run).

inline void set_theme(std::string_view theme)

“auto” | “light” | “dark” (anything else is logged and ignored).

inline void set_device_name(std::string_view name)

Rename the device (at most kMaxDeviceNameBytes, else truncated).

inline SinkId add_sink(send_fn send, uint8_t module = detail::dp::kModule)

Register a transport. Events are sent to it once a GET_DESKTOP arrived through it (set_sink_active). Returns its id.

Parameters:

module – The dispatcher module id stamped on the frames sent to it.

inline void set_sink_active(SinkId id, bool active)

Start / stop broadcasting events to a sink (a disconnected transport should be deactivated; the next GET_DESKTOP reactivates it).

inline void submit(Command cmd)

Queue a decoded host request for the desktop task. When the queue is full (Config::max_queued_commands) a request is refused with ERROR(EAGAIN) on its sink and an event is dropped (logged); what is already queued is never evicted.

inline bool sink_needs_resync(SinkId id) const

Whether a frame to this sink was dropped and the host’s mirror is still incomplete: streaming to it is paused until the desktop’s own snapshot gets through or the host sends GET_DESKTOP.

inline size_t max_text_bytes() const

The bound on a TextArea’s retained text (Config::max_text_bytes, normalized to at least 1).

inline size_t max_payload() const

The largest payload sent / accepted (Config::max_frame_bytes less the frame overhead, at most the codec’s limit).

inline bool set_columns(WindowId win, WidgetId widget, const std::vector<std::string> &columns)

Replace the column names; false (logged) when they are not representable.

inline bool set_items(WindowId win, WidgetId widget, uint16_t start, const std::vector<std::string> &items, bool replace_all)

Replace a range of items (`replace_all` first sets the count to the range’s size). Validated before anything changes: false (logged, model untouched) when the target is unknown, an entry is too large for a frame, or the range does not fit the wire’s u16 index space.

inline bool set_prop(WindowId win, WidgetId widget, detail::dp::Prop prop)

False (logged) when the target is unknown or the value cannot be carried on the wire (Title / Placeholder / Tooltip > kMaxShortTextBytes, Columns or an Items entry too large for a frame).

inline const std::string &get_name() const

Get the name of the component

Note

This is the tag of the logger

Returns:

A const reference to the name of the component

inline void set_log_tag(const std::string_view &tag)

Set the tag for the logger

Parameters:

tag – The tag to use for the logger

inline espp::Logger::Verbosity get_log_level() const

Get the log level for the logger

Returns:

The verbosity level of the logger

inline void set_log_level(espp::Logger::Verbosity level)

Set the log level for the logger

Parameters:

level – The verbosity level to use for the logger

inline void set_log_verbosity(espp::Logger::Verbosity level)

Set the log verbosity for the logger

See also

set_log_level

Note

This is a convenience method that calls set_log_level

Parameters:

level – The verbosity level to use for the logger

inline espp::Logger::Verbosity get_log_verbosity() const

Get the log verbosity for the logger

See also

get_log_level

Note

This is a convenience method that calls get_log_level

Returns:

The verbosity level of the logger

inline void set_log_rate_limit(std::chrono::duration<float> rate_limit)

Set the rate limit for the logger

Note

Only calls to the logger that have _rate_limit suffix will be rate limited

Parameters:

rate_limit – The rate limit to use for the logger

Public Static Attributes

static constexpr int32_t kNoSelection = -1

A list / table / select selection meaning “nothing”.

static constexpr size_t kMinFrameBytes = espp::stream_frame::kMaxHeaderSize + espp::stream_frame::kCrcSize + detail::dp::kMinPayloadBytes

Smallest Config::max_frame_bytes: the largest frame header + CRC + the smallest payload cap (detail::desktop_protocol::kMinPayloadBytes: the largest payload the API accepts that cannot be split &#8212; a WINDOW_OPEN head with a 255-byte title, a widget with a 255-byte placeholder / tooltip, the maximal DESKTOP record set &#8212; always fits it).

static constexpr std::chrono::milliseconds kResyncRetryMin = {1000}

Configuration for the Desktop. Retry period of the device-initiated resync after a dropped frame (see send_fn): starts at the minimum and doubles up to the maximum while the transport keeps refusing frames (each attempt costs one failed write).

struct App

An application the desktop lists (register_app) and launches on request.

Public Members

std::string name = {}

Shown under the icon / in the start menu.

std::string icon = {}

An emoji / short text, or “svg:<name>” from the built-in set.

std::string description = {}

Tooltip.

std::function<void(Desktop&, AppId)> launch = {nullptr}

Called on the desktop task when the user launches the app (or launch() is called): create the window(s) here.

bool single_instance = {true}

Launching again focuses the open window.

bool hidden = {false}

Not shown on the desktop (launch() only).

struct Command

A decoded host request, submitted by a DesktopService (or a test) and handled on the desktop task. Replies (if any) go to `sink`.

struct Config

Public Members

std::string device_name = {"espp"}

Shown in the browser’s tray / title (at most kMaxDeviceNameBytes, else truncated).

std::string firmware = {}

e.g. project name + version (DESKTOP record; at most kMaxFirmwareBytes).

std::string theme = {"auto"}

“auto” | “light” | “dark” (the browser’s initial theme; anything else is logged and replaced by “auto”).

uint32_t accent = {0x3b82f6}

Accent color, 0xRRGGBB.

std::chrono::milliseconds flush_period = {50}

How often pending changes are coalesced and sent (the latency of a set_text, and the period that bounds the frame rate of a busy app).

size_t max_frame_bytes = {4096}

Largest encoded frame (header + payload + CRC) a sink can carry in one write; every widget payload is split to fit (4096 = the TinyUSB FIFOs of the espp examples; the stream_frame maximum is kMaxFrameSize). At least kMinFrameBytes (a kMinPayloadBytes payload, the largest unsplittable payload the API accepts); a smaller value is clamped with a warning. Dialogs, notifications and the DESKTOP record set are single frames, so a small cap limits them (see the k* limits in the protocol).

size_t max_queued_commands = {64}

Bound on host commands queued for the desktop task (at least 1). When it is full a request (GET_DESKTOP / LAUNCH_APP / CLOSE_WINDOW) is refused with ERROR(EAGAIN) and an event is dropped (logged, rate-limited); queued commands are never evicted.

size_t max_text_bytes = {16 * 1024}

Bound on a TextArea’s retained text and on a text the host sends for one widget (Text events are reassembled up to this size). Advertised to the host (DESKTOP record MaxTextBytes), which applies the same bound.

Task::BaseConfig task_config   = {.name = "desktop", .stack_size_bytes = 8 * 1024}

The desktop task: every app callback runs on it, so size the stack for the apps (file I/O and fmt formatting comfortably fit 8 KiB).

espp::Logger::Verbosity log_level = {espp::Logger::Verbosity::WARN}

Logger verbosity.

struct GetDesktop

A GET_DESKTOP request (Command).

struct InputBoxConfig

An input box (input_box()). `on_result(text)` gets the text when the default button (0) was pressed, nullopt when cancelled / dismissed.

Public Members

std::string text = {}

The prompt.

std::string default_text = {}

Initial field contents.

struct MessageBoxConfig

A message box (message_box()). `on_result(button)` gets the index of the button pressed, or -1 when dismissed.

Public Members

WindowId owner = {0}

Modal to this window (0 = to the whole desktop).

std::vector<std::string> buttons = {"OK"}

Button 0 is the default.

struct NotifyConfig

A toast notification (notify()).

Public Members

std::chrono::milliseconds timeout = {4000}

0 = sticky until dismissed.

class Widget

A handle to a widget: a value, safe to copy and keep; every method is a no-op once the widget (or its window) is gone.

Public Functions

inline void set_color(uint32_t rgb)

0xRRGGBB; 0xFFFFFFFF = the theme’s default.

inline void set_items(const std::vector<std::string> &items)

Replace every item (table rows: cells ‘\t’-separated).

inline void set_item(uint16_t index, std::string_view item)

Replace one item (the list grows to fit).

inline void set_items(uint16_t start, const std::vector<std::string> &items)

Replace a range of items starting at `start`. Refused (logged, nothing changes) when an entry is too large for a frame.

inline void set_selected(int32_t index)

Select an item (kNoSelection = none).

inline void set_columns(const std::vector<std::string> &columns)

Refused (logged) with more than 255 names, a name over 255 bytes, or a set that does not fit a frame.

inline void focus()

Give the widget keyboard focus.

inline void remove()

Remove the widget (and its children).

inline void on_event(widget_event_fn fn)

Replace the event handler.

class Window

A handle to a window: a value, safe to copy and keep; every method is a no-op once the window is closed.

Public Functions

inline Widget add(WidgetConfig cfg)

Add any widget (the general form; the helpers below cover the common ones). Returns an invalid handle on failure (unknown parent, parent not a container, window gone).

inline Widget textbox(std::string_view text, std::function<void(const std::string&)> on_submit, WidgetId parent = 0, std::string_view placeholder = "", uint16_t flags = 0)

A single-line field; `on_submit` gets the text on Enter.

inline Widget list(const std::vector<std::string> &items, std::function<void(int32_t)> on_select, WidgetId parent = 0, uint8_t weight = 1)

`on_select(index)` on selection change; Activate (double-click / Enter) arrives through Widget::on_event.

inline void request_geometry(int16_t x, int16_t y, uint16_t w, uint16_t h)

Ask the browser to move / resize (-1 / 0 = keep); the host answers with a Moved / Resized event carrying the clamped result.

inline void focus()

Raise and focus the window.

inline void clear()

Remove every widget.

inline TimerId add_timer(std::chrono::milliseconds period, std::function<void()> fn)

A periodic callback on the desktop task, cancelled when the window closes.

class Widget

A handle to a widget: a value, safe to copy and keep; every method is a no-op once the widget (or its window) is gone.

Public Functions

inline void set_color(uint32_t rgb)

0xRRGGBB; 0xFFFFFFFF = the theme’s default.

inline void set_items(const std::vector<std::string> &items)

Replace every item (table rows: cells ‘\t’-separated).

inline void set_item(uint16_t index, std::string_view item)

Replace one item (the list grows to fit).

inline void set_items(uint16_t start, const std::vector<std::string> &items)

Replace a range of items starting at `start`. Refused (logged, nothing changes) when an entry is too large for a frame.

inline void set_selected(int32_t index)

Select an item (kNoSelection = none).

inline void set_columns(const std::vector<std::string> &columns)

Refused (logged) with more than 255 names, a name over 255 bytes, or a set that does not fit a frame.

inline void focus()

Give the widget keyboard focus.

inline void remove()

Remove the widget (and its children).

inline void on_event(widget_event_fn fn)

Replace the event handler.

class Window

A handle to a window: a value, safe to copy and keep; every method is a no-op once the window is closed.

Public Functions

inline Widget add(WidgetConfig cfg)

Add any widget (the general form; the helpers below cover the common ones). Returns an invalid handle on failure (unknown parent, parent not a container, window gone).

inline Widget textbox(std::string_view text, std::function<void(const std::string&)> on_submit, WidgetId parent = 0, std::string_view placeholder = "", uint16_t flags = 0)

A single-line field; `on_submit` gets the text on Enter.

inline Widget list(const std::vector<std::string> &items, std::function<void(int32_t)> on_select, WidgetId parent = 0, uint8_t weight = 1)

`on_select(index)` on selection change; Activate (double-click / Enter) arrives through Widget::on_event.

inline void request_geometry(int16_t x, int16_t y, uint16_t w, uint16_t h)

Ask the browser to move / resize (-1 / 0 = keep); the host answers with a Moved / Resized event carrying the clamped result.

inline void focus()

Raise and focus the window.

inline void clear()

Remove every widget.

inline TimerId add_timer(std::chrono::milliseconds period, std::function<void()> fn)

A periodic callback on the desktop task, cancelled when the window closes.

Header File

Classes

class DesktopService : public espp::BaseComponent

Serves an espp::Desktop over any framed byte stream (dispatcher module 9 by default; see Config::module).

GET_DESKTOP answers with the DESKTOP snapshot (apps, settings, open windows) followed by the full tree of every open window and every open dialog, and marks this transport attached: from then on the Desktop broadcasts its changes (WINDOW_OPEN / WIDGET_SET / … ) to it until detach() (e.g. on USB unmount) or until the next GET_DESKTOP after a reconnect. A frame the transport refuses pauses the broadcasts (the transport stays attached, needs_resync() is true) until the Desktop’s own retried snapshot gets through or the host sends GET_DESKTOP. LAUNCH_APP / CLOSE_WINDOW are acknowledged with OK / ERROR; the events (WINDOW_EVENT / WIDGET_EVENT / DIALOG_RESULT) are not.

**Threading**: an internal mutex covers the parser; the only frame this object sends itself is the ERROR for a malformed request, serialized on a send mutex that is also held while the Desktop’s task sends through this transport, so frames never interleave. `send` must not re-enter this object.

DesktopService Example

  // The desktop: one per device. Apps register with it; every app callback
  // runs on its task.
  const auto sysinfo = espp::SystemInfo::collect();
  espp::Desktop desktop(
      {.device_name = "espp Desktop",
       .firmware = fmt::format("{} {}", sysinfo.project_name, sysinfo.app_version),
       .task_config = {.name = "desktop", .stack_size_bytes = 10 * 1024},
       .log_level = espp::Logger::Verbosity::INFO});
  register_counter_app(desktop);
  register_about_app(desktop);
  register_system_monitor_app(desktop);
  register_task_manager_app(desktop);
  register_log_viewer_app(desktop);
  register_files_app(desktop);
#if DESKTOP_EXAMPLE_CANOPEN_APP
  register_canopen_app(desktop);
#elif CONFIG_DESKTOP_EXAMPLE_ENABLE_CANOPEN
  logger.warn("CONFIG_DESKTOP_EXAMPLE_ENABLE_CANOPEN is set but the CANopen app was not built: "
              "it needs IDF >= 6.0 (twai) and the CMake option DESKTOP_EXAMPLE_CANOPEN=ON");
#endif
#if CONFIG_DESKTOP_EXAMPLE_ENABLE_I2C
  register_i2c_scanner_app(desktop);
#endif
#if CONFIG_DESKTOP_EXAMPLE_ENABLE_WIFI || CONFIG_DESKTOP_EXAMPLE_ENABLE_ETHERNET
  register_network_app(desktop);
#endif
  register_settings_app(desktop, "espp Desktop");
  desktop_example::apply_saved_settings(desktop);

  // 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 = 0x0d38; // distinct from the espp default so the webapp filter is specific
  usb_cfg.manufacturer = "espp";
  usb_cfg.product = "espp Desktop";
  usb_cfg.log_level = espp::Logger::Verbosity::WARN;
  espp::UsbDevice::CdcFunction cdc;
  cdc.interface_name = "espp Desktop (CDC)";
  usb_cfg.cdc = cdc;
  espp::UsbDevice::VendorFunction vendor;
  vendor.interface_name = "espp Desktop (WebUSB)";
  vendor.webusb = true; // advertise BOS / WebUSB / MS OS 2.0 descriptors
  vendor.landing_page_url = "esp-cpp.github.io/espp/apps/desktop.html";
  usb_cfg.vendor = vendor;
  espp::UsbDevice usb(usb_cfg);

  // One send function per transport, serialized by one mutex each, so the
  // services' frames never interleave. write_vendor / write_cdc are
  // all-or-nothing: they wait (bounded, 250 ms) for FIFO room for the WHOLE
  // frame and never queue a partial one, and return false when the host did
  // not drain in time (unplugged, or the page is not reading) -- the desktop
  // then flags that transport as needing a resync, and the browser resyncs
  // with GET_DESKTOP when it reconnects.
  std::mutex vendor_tx_mutex, cdc_tx_mutex;
  auto vendor_send = [&](std::span<const uint8_t> frame) {
    std::lock_guard<std::mutex> lock(vendor_tx_mutex);
    return usb.write_vendor(frame);
  };
  auto cdc_send = [&](std::span<const uint8_t> frame) {
    std::lock_guard<std::mutex> lock(cdc_tx_mutex);
    return usb.write_cdc(frame);
  };

  // One service instance per transport (they are cheap; each replies on its
  // own stream). Declared BEFORE the workers that call into them.
  espp::DesktopService vendor_desktop(
      desktop, {.send = vendor_send, .log_level = espp::Logger::Verbosity::INFO});
  espp::DesktopService cdc_desktop(desktop,
                                   {.send = cdc_send, .log_level = espp::Logger::Verbosity::INFO});
  espp::SystemService vendor_system({.send = vendor_send});
  espp::SystemService cdc_system({.send = cdc_send});
  espp::MonitorService vendor_monitor(
      {.send = vendor_send, .task_config = {.name = "monitor_v", .stack_size_bytes = 6 * 1024}});
  espp::MonitorService cdc_monitor(
      {.send = cdc_send, .task_config = {.name = "monitor_c", .stack_size_bytes = 6 * 1024}});
  espp::OtaService vendor_ota(ota, {.send = vendor_send});
  espp::OtaService cdc_ota(ota, {.send = cdc_send});
  espp::CoreDumpService vendor_coredump(core_dump, {.send = vendor_send});
  espp::CoreDumpService cdc_coredump(core_dump, {.send = cdc_send});

  // 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,
       .on_overflow = [&]() { vendor_ota.on_rx_overflow(); },
       .task_config = {.name = "desktop_rx_vendor", .stack_size_bytes = 8192}});
  espp::DispatcherWorker cdc_link(
      {.send = cdc_send,
       .on_overflow = [&]() { cdc_ota.on_rx_overflow(); },
       .task_config = {.name = "desktop_rx_cdc", .stack_size_bytes = 8192}});
  for (auto *link : {&vendor_link, &cdc_link}) {
    const bool v = link == &vendor_link;
    link->register_module(v ? vendor_desktop : cdc_desktop);
    link->register_module(v ? vendor_system : cdc_system);
    link->register_module(v ? vendor_monitor : cdc_monitor);
    link->register_module(v ? vendor_ota : cdc_ota);
    link->register_module(v ? vendor_coredump : cdc_coredump);
    link->serve_discovery(usb_cfg.product);
  }

Public Types

using send_fn = std::function<bool(std::span<const uint8_t> frame)>

Transmits one encoded frame to the host, all-or-nothing, and returns whether it was queued. Unlike the other espp services’ `send`, which is void, this one must report a refused frame: the desktop streams state, so it then pauses streaming to this transport and re-sends the full snapshot by itself once frames go through again, see needs_resync(). UsbDevice::write_vendor / write_cdc have exactly this contract (bounded wait for FIFO room, never a partial frame).

Public Functions

inline explicit DesktopService(Desktop &desktop, const Config &config)

Construct the service and register its transport with the desktop.

inline uint8_t module_id() const

The dispatcher module id this service answers on (Config::module).

inline Dispatcher::ModuleInfo module_info() const

Discovery metadata for registering this service on a Dispatcher.

inline Desktop::SinkId sink() const

The Desktop sink id of this transport.

inline void detach()

Stop broadcasting to this transport (call when it disconnects, e.g. on USB unmount); the next GET_DESKTOP re-attaches it.

inline bool attached() const

Whether a host asked for the desktop on this transport (and it was not detached since).

inline bool needs_resync() const

Whether a frame to this transport was dropped (send returned false) and the host’s mirror is still incomplete: streaming is paused (the transport stays attached()) until the desktop’s own snapshot gets through (retried with back-off) or the host sends GET_DESKTOP.

inline void handle(const espp::stream_frame::Frame &frame)

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)`.

inline void feed(std::span<const uint8_t> data)

Feed received transport bytes (standalone use, without a Dispatcher).

inline void reset_parser()

Discard any partially-buffered frame bytes (standalone feed() use).

inline bool handle_frame(uint8_t type, std::span<const uint8_t> payload, std::optional<uint16_t> correlation = std::nullopt)

Handle one already-parsed request frame.

Parameters:

correlation – The request frame’s correlation id, if it carried one; the reply (DESKTOP / OK / ERROR) echoes it.

Returns:

true if the type belongs to the desktop protocol (it was queued for the desktop task, or answered with ERROR), false if ignored.

inline const std::string &get_name() const

Get the name of the component

Note

This is the tag of the logger

Returns:

A const reference to the name of the component

inline void set_log_tag(const std::string_view &tag)

Set the tag for the logger

Parameters:

tag – The tag to use for the logger

inline espp::Logger::Verbosity get_log_level() const

Get the log level for the logger

Returns:

The verbosity level of the logger

inline void set_log_level(espp::Logger::Verbosity level)

Set the log level for the logger

Parameters:

level – The verbosity level to use for the logger

inline void set_log_verbosity(espp::Logger::Verbosity level)

Set the log verbosity for the logger

See also

set_log_level

Note

This is a convenience method that calls set_log_level

Parameters:

level – The verbosity level to use for the logger

inline espp::Logger::Verbosity get_log_verbosity() const

Get the log verbosity for the logger

See also

get_log_level

Note

This is a convenience method that calls get_log_level

Returns:

The verbosity level of the logger

inline void set_log_rate_limit(std::chrono::duration<float> rate_limit)

Set the rate limit for the logger

Note

Only calls to the logger that have _rate_limit suffix will be rate limited

Parameters:

rate_limit – The rate limit to use for the logger

Public Static Attributes

static constexpr uint8_t kModule = espp::detail::desktop_protocol::kModule

Default dispatcher module id (9). A routing key only: Config::module serves on any id, and hosts find it through discovery (by kProtocol).

static constexpr const char *kProtocol = espp::detail::desktop_protocol::kProtocol

Stable protocol identifier + version advertised through discovery.

struct Config

Configuration for the DesktopService.

Public Members

send_fn send = {nullptr}

Transmits an encoded frame, all-or-nothing (required).

uint8_t module = {kModule}

Dispatcher module id this instance answers on (and stamps on every frame it sends). A routing key only (0x00..0xEF).

espp::Logger::Verbosity log_level = {espp::Logger::Verbosity::WARN}

Logger verbosity.

Header File

Classes

class ConsoleCapture

Captures stdout / stderr into a bounded byte ring while teeing them to the original console.

A process-wide singleton (there is one stdout): install() once, then any task may call read_since() with its own cursor to page through the log without blocking the writers. The cursor is an absolute byte position (total_bytes()); when the ring overwrote bytes the reader had not consumed, read_since() reports how many were dropped and resumes at the oldest byte still kept.

ConsoleCapture Example

  // First thing: tee stdout / stderr into a ring the Log Viewer app reads, so
  // the boot log is captured too. The UART console keeps working.
#if CONFIG_DESKTOP_EXAMPLE_LOG_CAPTURE
  {
    std::error_code ec;
    espp::ConsoleCapture::install(
        {.capacity_bytes = CONFIG_DESKTOP_EXAMPLE_LOG_CAPTURE_BYTES, .tee_to_console = true}, ec);
  }
#endif

Public Static Functions

static inline bool install(const Config &config, std::error_code &ec)

Register the capture device and re-point stdout / stderr at it.

Parameters:
  • config – Ring size and tee / strip options.

  • ec – Set on failure (io_error: the VFS could not be registered or stdout could not be re-opened &#8212; the original console is restored on a best-effort basis).

Returns:

true on success, or when already installed (the config of the first install stays; the tee / strip flags are updated).

static inline bool installed()

Whether install() succeeded.

static inline size_t capacity()

The ring capacity in bytes.

static inline uint64_t total_bytes()

Bytes captured since boot (monotonic; a cursor value).

static inline size_t available()

Bytes currently kept in the ring (readable).

static inline size_t read_since(uint64_t *cursor, std::string &out, size_t max_bytes, size_t *dropped = nullptr)

Copy the bytes captured after `*cursor` (at most max_bytes) and advance the cursor.

Parameters:
  • cursor – In: where the reader is (0 = from the oldest readable byte); out: the position after the bytes returned.

  • out – Appended with the bytes.

  • max_bytes – Upper bound on the bytes appended in this call.

  • dropped – If not null, set to the number of bytes the ring EVICTED (capacity overwrite) before the reader got to them (0 when none); bytes hidden by clear() are skipped without being counted.

Returns:

The number of bytes appended.

static inline void clear()

Forget everything captured so far (readers resume at total_bytes()).

static inline void set_tee_to_console(bool enable)

Switch the tee to the original console on / off.

Public Static Attributes

static constexpr const char *kVfsPath = "/dev/logcap"

VFS path of the capture device (must be <= ESP_VFS_PATH_MAX).

struct Config

Configuration for install().

Public Members

size_t capacity_bytes = {16 * 1024}

Ring size: the newest bytes kept.

bool tee_to_console = {true}

Keep writing to the original console (the UART / USB-Serial-JTAG monitor). Off = capture only (the console goes quiet).

bool strip_ansi = {false}

Remove ANSI CSI escape sequences (colors) from the captured bytes; the tee still gets them. Leave off when the consumer renders ANSI itself (the desktop Log Viewer does).