Modern C++20/23 best practices for ESP32-S3 embedded development. Use when writing new code, refactoring, or reviewing for DRY violations, readability, and reusability.
ESP-IDF v5.4 ships GCC 14.2.0 defaulting to -std=gnu++23.
Nearly all C++20 and most C++23 features are available.
No exceptions (-fno-exceptions), no RTTI (-fno-rtti).
Colors are currently duplicated across ui.cpp, ui_timer.cpp, ui_voice.cpp, and ui_pages.cpp with different prefixes (COL_, COL_T_, COL_V_). Centralize:
// ui/theme.h — single source of truth
#pragma once
#include "lvgl.h"
namespace theme {
constexpr lv_color_t bg = lv_color_hex(0x000000);
constexpr lv_color_t text = lv_color_hex(0xFFFFFF);
constexpr lv_color_t text_sec = lv_color_hex(0x8E8E93);
constexpr lv_color_t accent = lv_color_hex(0x0A84FF);
constexpr lv_color_t green = lv_color_hex(0x30D158);
constexpr lv_color_t orange = lv_color_hex(0xFF9F0A);
constexpr lv_color_t red = lv_color_hex(0xFF453A);
constexpr lv_color_t arc_bg = lv_color_hex(0x1C1C1E);
constexpr lv_color_t dim = lv_color_hex(0x48484A);
constexpr lv_color_t indigo = lv_color_hex(0x5E5CE6);
constexpr lv_color_t purple = lv_color_hex(0xBF5AF2);
}
ui.cpp has anim_fade() but ui_voice.cpp (24 lv_anim_init blocks) and ui_timer.cpp don't use it. Extract to a shared header:
// ui/anim_helpers.h
#pragma once
#include "lvgl.h"
// Simple fade: animates a single property from start to end
void anim_fade(lv_obj_t *obj, lv_anim_exec_xcb_t exec_cb,
int32_t start, int32_t end, int duration_ms,
lv_anim_completed_cb_t done_cb = nullptr);
// Pulse: oscillates between lo and hi (for breathing effects)
void anim_pulse(lv_obj_t *obj, lv_anim_exec_xcb_t exec_cb,
int32_t lo, int32_t hi, int period_ms,
int repeat_count = LV_ANIM_REPEAT_INFINITE);
// Cancel all animations on an object for a given callback
inline void anim_cancel(lv_obj_t *obj, lv_anim_exec_xcb_t cb) {
lv_anim_delete(obj, cb);
}
The SOAP envelope wrapping in sonos.cpp is repeated for every command. Extract:
// Before: repeated in every command
char envelope[1024];
snprintf(envelope, sizeof(envelope), SOAP_ENVELOPE_FMT, body);
// After: helper that builds and sends
bool soap_fire(const char *path, const char *action, const char *ns,
const char *body_xml);
bool soap_request(const char *path, const char *action, const char *ns,
const char *body_xml, Response *resp);
This is already partially done — keep pushing toward fewer raw snprintf calls.
| Feature | Use for | Example |
|---|---|---|
constexpr | Compile-time constants, lookup tables | constexpr int LCD_H_RES = 360; |
consteval | Force compile-time evaluation | consteval uint32_t make_color(uint8_t r, uint8_t g, uint8_t b) |
std::string_view | Non-owning string references | Function params instead of const char* when you need .size() |
std::span | Bounds-safe buffer views | void parse(std::span<const char> data) instead of (char* buf, size_t len) |
std::optional | Nullable returns without pointers | std::optional<int> parse_volume(const char* xml) |
std::expected | Error handling without exceptions (C++23) | std::expected<int, SonosError> get_volume() |
std::array | Fixed-size arrays with bounds info | std::array<Station, 12> stations |
std::clamp | Bounded values | std::clamp(vol, VOLUME_MIN, VOLUME_MAX) |
| Structured bindings | Unpacking structs/pairs | auto [ip, port] = parse_endpoint(url); |
| Designated initializers | Readable struct init | Config cfg = { .timeout = 5000, .retries = 3 }; |
| Concepts | Template constraints | template<EventPayload T> void post(const T& data) |
[[nodiscard]] | Force callers to check return values | [[nodiscard]] bool soap_fire(...) |
[[maybe_unused]] | Suppress warnings on debug-only vars | [[maybe_unused]] int64_t t0 = esp_timer_get_time(); |
enum class | Type-safe enums | enum class PlayState : uint8_t { ... }; (already used) |
using aliases | Readable function types | using PageChangedCb = void(*)(int, const char*); |
| Feature | Caveat |
|---|---|
| Designated initializers | Must be in declaration order — GCC enforces strictly. Use field-by-field assignment for third-party structs (e.g., esp_lvgl_port types). |
std::string_view | Does NOT own the data. Never return a string_view to a local buffer. Not null-terminated — copy to char[] before passing to C APIs. |
std::optional | Adds 1 byte overhead + alignment padding. Fine for return values, avoid in hot structs. |
std::format | Available in GCC 14 but may have issues with newlib (ESP-IDF's C library). Test before relying on it. snprintf is the safe fallback. |
| Templates | Each instantiation adds flash. Prefer constexpr functions. Use extern template to limit bloat. |
auto | Use for iterators and complex types. Avoid for simple types where the type aids readability. |
std::ranges | Available but pulls in heavy headers. Verify binary size impact before using broadly. |
| Feature | Why |
|---|---|
std::string | Heap allocation for every instance. Use std::string_view or char[]. |
std::map / std::unordered_map | Heavy allocator use. Use sorted std::array + binary search for small N. |
std::shared_ptr | Atomic refcount overhead. Use std::unique_ptr or raw ownership. |
dynamic_cast | Requires RTTI (disabled). |
std::iostream | +200KB binary size. Use ESP_LOGx macros. |
std::function | May heap-allocate. Use function pointers or templates. |
| Exceptions | Disabled (-fno-exceptions). Use std::expected or return codes. |
| Modules | Not supported by ESP-IDF build system. |
The best pattern for this project — replaces both error codes and exceptions:
#include <expected>
enum class SonosError : uint8_t {
Timeout, HttpError, ParseError, NotFound
};
template<typename T>
using Result = std::expected<T, SonosError>;
using VoidResult = std::expected<void, SonosError>;
// Function returns either a value or an error
Result<int> get_volume(const char *speaker_ip) {
// ... HTTP request ...
if (err != ESP_OK) return std::unexpected(SonosError::HttpError);
if (status != 200) return std::unexpected(SonosError::ParseError);
return parsed_volume; // success
}
// Caller
if (auto vol = get_volume(ip)) {
ESP_LOGI(TAG, "Volume: %d", *vol);
} else {
ESP_LOGE(TAG, "Failed: %d", static_cast<int>(vol.error()));
}
// With value_or for defaults
int vol = get_volume(ip).value_or(50);
Note: std::expected is header-only and should work with newlib, but verify on hardware before using in critical paths.
// Before: manual lock/unlock (easy to forget unlock on early return)
if (display_lock(50)) {
lv_label_set_text(label, "hello");
if (error) return; // BUG: display_unlock() never called!
display_unlock();
}
// After: RAII guard
class DisplayLock {
bool locked_;