esp3d_log (components/esp3d_log/) is the firmware’s logging subsystem. It provides four severity-gated macros (esp3d_log, esp3d_log_d, esp3d_log_w, esp3d_log_e), a compile-time pluggable output backend (serial, SD card, secondary UART, Telnet, WebSocket, or USB CDC), and an optional runtime hook mechanism that lets other modules observe log calls without esp3d_log knowing anything about them.
All share the same printf signature, are declared in esp3d_log.h, and route through esp3d_log_output(). Legacy aliases esp3d_log_error() and esp3d_log_warning() exist as thin wrappers around esp3d_log_e and esp3d_log_w respectively.
%llu / %llx are not supported by the nanolib printf ESP-IDF links by default. Use the U64_STR(x) helper macro to format a uint64_t as a string:
Each macro is compiled only when the build’s ESP3D_LOG constant meets or exceeds its required level; otherwise it expands to nothing — the format string is not retained, and there is zero runtime cost.
esp3d_log(...) — always left in the codebase for long-term trace, but dormant unless ESP3D_LOG=4. Do not remove these calls.
esp3d_log_d(...) — debug-only, temporary. When investigating a specific area, rename the relevant esp3d_log( calls to esp3d_log_d( (or add new _d calls) so they appear at level 3. Rename back to esp3d_log( once done.
esp3d_log_w / esp3d_log_e — real warnings/failures, always compiled in both shipped profiles.
All options are C preprocessor defines. They are set via add_compile_options() in cmake/dev_tools.cmake (see Build Configuration) and have #ifndef-guarded defaults in esp3d_log.h.
String prepended to every output line. The default ";" makes lines look like G-code comments — if the same UART is shared with the CNC controller, the target firmware ignores them. Set to "" to disable.
ESP3D_LOG_TIMESTAMP
0
When 1, prepends [+SSSSs.MMM] (seconds.milliseconds since boot) before each line.
ESP3D_LOG_BUFFER_SIZE
512
Max bytes in one formatted log line (static buffer). Increase if messages are truncated; watch DRAM budget — see esp32_memory_constraints.md.
DISABLE_COLOR_LOG
(unset = colors on)
When 1, replaces ANSI color escapes with plain [LOG]/[ERR]/[WNG]/[DBG] tags for terminals that do not handle ANSI color.
Production builds (PROD_BUILD=ON) set ESP3D_LOG=0 — all four macros compile to nothing, zero runtime overhead. To enable verbose esp3d_log() output during development, change set(ESP3D_LOG_LEVEL 3) to set(ESP3D_LOG_LEVEL 4) in the else() branch of cmake/dev_tools.cmake (affects --dev builds only).
If the format string contains \n or \r, esp3d_log_output() splits on those characters. The first sub-line gets the full [file:line] func(): header; subsequent sub-lines are indented to the same width to align visually:
esp3d_log_output() uses a FreeRTOS mutex (s_log_mutex) created by esp3d_log_init(). All static formatting buffers (msg_buf, header_buf, indent_buf, line_buf) are protected under this mutex, so concurrent log calls from multiple tasks (e.g., the LVGL task on Core 1 and esp3d_serial_rx_task on Core 0) are serialized safely. If the mutex cannot be acquired within 200 ms, the line is dropped silently rather than blocking.
Calls issued before esp3d_log_init() (early boot, before the scheduler) run uncontended — the mutex check is skipped when s_log_mutex is still NULL.
Only one backend is active per build, selected by ESP3D_LOG_BACKEND at compile time. Every backend implements the same four-function interface defined in esp3d_log_backend.h:
typedefstruct {
void (*init)(void); // called once from esp3d_log_init()
void (*write)(constchar*data, int len); // called per formatted line
void (*flush)(void); // called after each write()
void (*deinit)(void); // release resources
} esp3d_log_backend_t;
// Provided by exactly one backend source file (linked at compile time):
The esp3d_log component must stay dependency-free so it can be included in any build. Backends 1–5 depend on app-level services (ESP3DSd, network services, etc.) that the component must know nothing about. They therefore live in main/modules/log/ and are conditionally added to the build by main/CMakeLists.txt — never more than one at a time, since each provides its own esp3d_log_get_backend() and two would collide at link time. In components/esp3d_log/CMakeLists.txt a single branch (ESP3D_LOG_BACKEND from 1 to 5) compiles nothing in the component; any other value falls back to serial with a warning. Adding a backend = extend that range and main/CMakeLists.txt.
This flag is mandatory. Without it, backends 1–5 fail to link with an “undefined reference to esp3d_log_get_backend” error even though the backend .cpp compiles cleanly. Root cause: ESP-IDF links libmain.a exactly once and before this component’s library. By the time esp3d_log.c.obj’s reference is seen, the linker has already moved past libmain.a and will not revisit it. The -u flag forces the symbol to be treated as “wanted” from the very start of the link, pulling the object out of libmain.a regardless of scan order — the same idiom used by ESP-IDF’s own components (e.g. components/esp_timer/CMakeLists.txt).
Always use a fresh build directory when changing ESP3D_LOG_BACKEND. An existing build/ directory may silently keep the old backend with no error or warning.
File:components/esp3d_log/backends/esp3d_log_serial.c Status: Implemented. Always available, no app-level dependency.
Writes via esp_log_write(ESP_LOG_ERROR, "[ESP3D-X]", ...) so output survives ESP-IDF’s own log-level filtering.
init() suppresses all other ESP-IDF log tags (wifi, sdmmc, vfs_fat_sdmmc, etc.) via esp_log_level_set("*", ESP_LOG_NONE) to keep the console clean — unless SHOW_ESP_LOG is defined.
File:main/modules/log/sd/esp3d_log_sd_backend.cppStatus: Implemented. Requires SD_CARD_SERVICE=ON (enforced by cmake/sanity_check.cmake).
Dependency:ESP3DSd (main/modules/filesystem/esp3d_sd.h)
Asynchronous since a14 (a log client is an observer, it must never disturb the observed system):
write() never blocks and never touches the SD: it copies the whole line into a static 4 KB stream buffer, or drops and counts it when the buffer is full.
A low-priority writer task (core 0) drains the buffer every 200 ms: one open / append / close per flush, bounded per flush to keep the shared SD hold short; it retries later when accessFS() reports the SD busy.
Lines logged before the SD is mounted are kept (up to the buffer size); each boot starts with a ===== boot (reset reason: …) ===== marker; dropped lines are reported in the file.
Rotation at 1 MB: /esp3dlog.txt is renamed /esp3dlog.old.txt (the previous one is replaced).
File:main/modules/log/uart2/esp3d_log_uart2_backend.cpp Status: Implemented. No board in this repo currently defines the required pins.
This backend requires the target board’s board_config.h to define a UART genuinely separate from the primary CNC serial link (UART_PORT_IDX):
#defineESP3D_LOG_UART_PORT_IDX UART_NUM_1 // not UART_PORT_IDX
#defineESP3D_LOG_UART_TX_PIN GPIO_NUM_xx
#defineESP3D_LOG_UART_RX_PIN GPIO_NUM_xx
// optional — defaults to 115200 if omitted:
#defineESP3D_LOG_UART_BAUD_RATE_BPS115200
If any of the first three defines are absent, a #error fires at compile time — there is no fallback to another port’s pins.
init() installs the UART driver with a 512-byte TX ring buffer (ISR-drained, so write() copies in and returns immediately) and a 256-byte RX buffer (required by the driver, never read from — this is a log-only, one-way channel).
write() calls uart_write_bytes(). Safe to call from any task, including LVGL.
flush() is a no-op — blocking on uart_wait_tx_done() here would stall the calling task.
File:main/modules/log/telnet/esp3d_log_telnet_backend.cpp Status: Implemented. Requires SOCKET_SERVER_SERVICE=ON (enforced by cmake/sanity_check.cmake). Dependency:ESP3DSocketServer (main/modules/socket_server/esp3d_socket_server.h)
Reuses the project’s existing telnet/raw-TCP console server rather than standing up a second one:
write() creates one ESP3DMessage (ESP3DClient::newMsg(..., ESP3DClientType::socket_server, ...)) and hands it to esp3dSocketServer.process() — the same path any other socket-bound text takes. process() only enqueues (mutex-protected) and returns immediately, safe from any task including LVGL.
Does nothing until a telnet client is actually connected (isConnected() check) — no network dependency at early boot.
Reentrancy guard (s_in_write): process() itself calls esp3d_log_e() on a full queue. Without the guard that re-enters write() through esp3d_log_output() and recurses indefinitely on a persistently full queue.
SOCKET_SERVER_SERVICE and SOCKET_CLIENT_SERVICE (CNC-over-WiFi) are mutually exclusive — the Telnet backend cannot be used with CNC-over-WiFi-TCP variants.
File:main/modules/log/websocket/esp3d_log_websocket_backend.cpp Status: Implemented. Requires WIFI_SERVICE=ON, WEB_SERVICES=ON, and WS_SERVER_SERVICE=ON together (enforced by cmake/sanity_check.cmake). Dependency:ESP3DWsDataService (main/modules/websocket_server/esp3d_ws_data_service.h)
Reuses the existing WS data server the same way the Telnet backend reuses esp3dSocketServer:
write() creates one ESP3DMessage (ESP3DClient::newMsg(..., ESP3DClientType::websocket_server, ...)) and hands it to esp3dWsServerDataService.process(), which ends in httpd_ws_send_frame_async() — the ESP-IDF httpd API explicitly designed to be called safely from any task.
Does nothing until a WS client is attached (isConnected() check).
Reentrancy guard (s_in_write): stricter need than Telnet’s — process() calls esp3d_log() unconditionally on every single invocation (not just on error). Without the guard the very first call would immediately re-enter write() and recurse without end.
Broadcast id (a14 fix): the message is sent with request_id.id = 0; the timestamp set by newMsg() made process() push to a nonexistent socket. The Telnet backend got the same fix.
No PiBot variant ships this combination by default — all WiFi variants use SOCKET_CLIENT_SERVICE, which is mutually exclusive with WS_SERVER_SERVICE. Testing this backend requires a temporary custom variant with WS_SERVER_SERVICE=ON (and WEBUI_SERVER=ON to work around an unrelated compile gap in esp3d_http_service.cpp).
Same design as the SD backend: static 4 KB stream buffer, low-priority writer task that writes to CDC #1 only while a terminal holds the port open (DTR). It never installs TinyUSB (the USB CDC command client does). Details: logging_backends.md.
Since 2026-07, esp3d_log_output() dispatches every log call to registered hooks in addition to the normal backend write. esp3d_log remains completely agnostic of what a hook does with the message.
#defineESP3D_LOG_HOOKS_MAX2 // fixed-size slots — no dynamic allocation
boolesp3d_log_register_hook(esp3d_log_hook_t hook); // false if full or NULL
voidesp3d_log_unregister_hook(esp3d_log_hook_t hook); // no-op if not found
level — one of ESP3D_LOG_LEVEL_ERROR / WARNING / DEBUG / ALL.
message — plain formatted message (no file/line/timestamp decoration), valid only for the duration of the call. Copy it if needed.
Level filtering is the hook’s responsibility — esp3d_log passes every call regardless.
When ESP3D_LOG == 0 (production), register_hook and unregister_hook compile as no-op stubs — no #if ESP3D_LOG guard is needed around registration calls.
A third register_hook() call (beyond ESP3D_LOG_HOOKS_MAX = 2) returns false.
Dispatched after the log mutex is released (a14). The message is copied into a local buffer (msg_buf is a shared static buffer another task may overwrite), the mutex is given back, then the hooks run. Dispatching under the mutex (which is not recursive) caused a 200 ms stall and a dropped line on every hooked log when a hook logged in turn.
Reentrancy guard (s_hook_dispatch_active, a14). While a dispatch is running, nested dispatches are skipped, so a hook that logs again cannot recurse; the nested line is still written to the backend.
main/core/esp3d_system_message_history.cpp registers a hook that captures error-level messages into a small volatile history (RAM only, max 20 entries, cleared on reboot) displayed on the firmware status screen’s Local message list:
Registered once from UIManager::initialize() in main/display/esp3d_ui.cpp — only once a screen exists to display errors. Every existing esp3d_log_e(...) call in the firmware then automatically surfaces on that screen with no per-call-site changes needed.