DFTE is a lightweight C++ template engine tailored for Arduino-class hardware (ESP8266, ESP32, RP2040, and friends) that need to render rich HTML dashboards or textual feeds without allocating giant buffers. It streams HTML over chunked HTTP, stitches together deeply nested layouts from PROGMEM, and injects live device data sourced from RAM getters—all while keeping your microcontroller responsive. If you are searching for an “ESP32 streaming HTML template engine” or a way to “render dynamic Arduino web UI without SPIFFS,” DFTE is the solution.
-
Define your root template in PROGMEM (or RAM if you prefer):
static const char ROOT_TEMPLATE_PROGMEM[] PROGMEM = R"DFTE( <html> <head><title>%APP_TITLE%</title></head> <body> <h1>%APP_TITLE%</h1> <p>Uptime: %UPTIME%</p> </body> </html> )DFTE"; -
In your sketch, register placeholders and stream them out:
#include <TemplateEngine.h> PlaceholderRegistry registry; TemplateContext ctx; void setup() { Serial.begin(115200); registry.registerProgmemData(PSTR("%APP_TITLE%"), PSTR("DFTE Quickstart")); registry.registerRamData(PSTR("%UPTIME%"), [](PlaceholderWriter& w) { static char buffer[16]; snprintf(buffer, sizeof(buffer), "%lus", millis() / 1000); w.write(buffer); }); registry.registerProgmemTemplate(PSTR("%ROOT%"), ROOT_TEMPLATE_PROGMEM); ctx.setRegistry(®istry); TemplateRenderer::initializeContext(ctx, PSTR("%ROOT%")); } void loop() { static uint8_t buffer[128]; if (!TemplateRenderer::isComplete(ctx) && !TemplateRenderer::hasError(ctx)) { size_t written = TemplateRenderer::renderNextChunk(ctx, buffer, sizeof(buffer)); Serial.write(buffer, written); } } -
Open the serial monitor (or send chunks to
AsyncWebServer) to watch the template stream without ever allocating the full HTML in RAM.
| Approach | Streaming | Dynamic data | Template reuse | Notes |
|---|---|---|---|---|
| DFTE (this library) | ✅ Streams in 128–512 byte chunks | ✅ Registry handles placeholders, conditionals, iterators | ✅ PROGMEM templates shared across requests | Built for async HTTP; keeps RAM usage predictable |
| Static SPIFFS/LittleFS files | ❌ Full file send only | ✅ Stored once on flash filesystem | Great for static assets, not ideal for live telemetry | |
Arduino String concatenation |
❌ Concatenates into one RAM buffer | ✅ Manual String inserts |
❌ Template duplicated per build | Fast to prototype but fragments heap on longer sketches |
| AsyncWebServer template callback | ✅ Values supplied in callback | ❌ No shared layout or nesting support | Fine for small pages; scales poorly with complex UIs | |
| Server-side proxy (external backend) | ✅ Offloaded to external service | ✅ Managed by backend | ❌ Device ships only proxy stub | Requires constant connectivity and extra infrastructure |
-
PlaceholderRegistryregisterProgmemData(const char*, const char*)– link%TOKEN%to flash-resident data.registerRamData(const char*, PlaceholderDataGetter)– provide dynamic strings from getters.registerProgmemTemplate(const char*, const char*)– nest other templates.registerDynamicTemplate(const char*, const DynamicTemplateDescriptor*)– compute template fragments at render time.registerConditional(const char*, const ConditionalDescriptor*)– choose between delegates (TRUE_BRANCH,FALSE_BRANCH,SKIP).registerIterator(const char*, const IteratorDescriptor*)– stream repeated sections item-by-item.getPlaceholder,getCount,clear– inspection/utilities used throughout the tests.
-
TemplateContext- Holds the render stack, buffers, and statistics.
setRegistry(PlaceholderRegistry*)– inject the registry you populated.reset()– reuse the context without re-allocating buffers.isComplete(),hasError(),getStateString()– status helpers.
-
TemplateRendererinitializeContext(TemplateContext&, const char*, bool templateInProgmem = true)– prime the context with the root template.renderNextChunk(TemplateContext&, uint8_t* buf, size_t len)– stream out the next chunk; returns written bytes.isComplete(const TemplateContext&),hasError(const TemplateContext&)– convenience checks.
-
DeviceFrameworkTemplateEngineDebug- Optional logging interface; create a
DeviceFrameworkTemplateEngineLoggersubclass and calldeviceFrameworkTemplateEngineEnableLogging(&logger).
- Optional logging interface; create a
All public headers are re-exported from TemplateEngine.h, so typical sketches only include that file.
-
Include
TemplateEngine.h.#include <TemplateEngine.h> -
Register placeholders on a
PlaceholderRegistry.PlaceholderRegistry registry; registry.registerProgmemData(PSTR("%APP_TITLE%"), PSTR("DFTE Dashboard")); registry.registerRamData(PSTR("%UPTIME%"), [](PlaceholderWriter& w) { static char buffer[16]; snprintf(buffer, sizeof(buffer), "%lus", millis() / 1000); w.write(buffer); }); registry.registerProgmemTemplate(PSTR("%ROOT%"), ROOT_TEMPLATE_PROGMEM); -
Attach the registry to a
TemplateContext.TemplateContext ctx; ctx.setRegistry(®istry); -
Feed the root template to
TemplateRenderer::initializeContext.TemplateRenderer::initializeContext(ctx, PSTR("%ROOT%")); -
Loop on
renderNextChunkuntilTemplateRenderer::isComplete(ctx)istrue.uint8_t buffer[128]; while (!TemplateRenderer::isComplete(ctx) && !TemplateRenderer::hasError(ctx)) { size_t written = TemplateRenderer::renderNextChunk(ctx, buffer, sizeof(buffer)); Serial.write(buffer, written); } -
Optionally reuse the same context for additional templates by calling
ctx.reset().ctx.reset(); TemplateRenderer::initializeContext(ctx, PSTR("%DETAIL_PANEL%"));
When serving requests with ESPAsyncWebServer, give every request its own TemplateContext so chunked rendering cannot be corrupted by overlapping clients. Build and cache your PlaceholderRegistry once during setup, then share it across handlers. The same pattern powers the DeviceFramework web UI and the DFTE examples:
/** Global registry prepared during setup() */
std::shared_ptr<PlaceholderRegistry> registry;
void setupRegistry() {
registry = std::make_shared<PlaceholderRegistry>();
registry->registerProgmemData(PSTR("%APP_TITLE%"), PSTR("DFTE Async Portal"));
registry->registerRamData(PSTR("%UPTIME%"), [](PlaceholderWriter& w) {
static char buffer[16];
snprintf(buffer, sizeof(buffer), "%lus", millis() / 1000);
w.write(buffer);
});
registry->registerProgmemTemplate(PSTR("%ROOT%"), ROOT_TEMPLATE_PROGMEM);
}
void streamTemplate(AsyncWebServerRequest* request, const char* rootTemplate) {
auto ctx = std::make_shared<TemplateContext>();
ctx->setRegistry(registry.get());
TemplateRenderer::initializeContext(*ctx, rootTemplate);
request->onDisconnect([ctx]() mutable { ctx.reset(); });
AsyncWebServerResponse* response = request->beginChunkedResponse(
"text/html; charset=utf-8",
[ctx](uint8_t* buffer, size_t maxLen, size_t) mutable -> size_t {
if (!ctx) {
return 0;
}
size_t written = TemplateRenderer::renderNextChunk(*ctx, buffer, maxLen);
if (!written || TemplateRenderer::isComplete(*ctx) || TemplateRenderer::hasError(*ctx)) {
ctx.reset(); // prevent cross-request pollution
}
return written;
});
response->addHeader("Cache-Control", "no-cache, no-store, must-revalidate");
response->addHeader("Pragma", "no-cache");
response->addHeader("Expires", "-1");
request->send(response);
}
void setup() {
setupRegistry();
server.on("/", [](AsyncWebServerRequest* request) {
streamTemplate(request, PSTR("%ROOT%"));
});
server.begin();
}- Build and cache the
PlaceholderRegistryduring startup so heavy PROGMEM registration runs once. - Allocate a request-scoped
TemplateContext, initialise it with the shared registry, and render inside the chunked callback. - Tear everything down on completion or disconnect to avoid state bleed between clients.
Every placeholder in a template uses %NAME%. DFTE looks up NAME in the registry and decides how to render it based on the registered type. Templates can be nested arbitrarily (up to DFTE_MAX_STACK_DEPTH_DEFAULT unless you raise it).
- Static data –
registerProgmemData("%CSS%", PROGMEM_BLOCK)streams literal content from flash or RAM. - Nested template –
registerProgmemTemplate("%HEADER%", HEADER_TEMPLATE)injects another template that can contain its own placeholders. - Dynamic value –
registerRamData("%UPTIME%", getter)calls a function that writes the current value into the output buffer. - Dynamic template –
registerDynamicTemplate("%CONTENT%", &DynamicTemplateDescriptor{getter, getLength, userData})asks your getter to return template text at render time. - Conditional –
registerConditional("%IS_ONLINE%", &ConditionalDescriptor{evaluate, "%ONLINE%", "%OFFLINE%", userData})chooses which delegate placeholder to render based on the evaluator result. - Iterator –
registerIterator("%SENSORS%", &IteratorDescriptor{open, next, close, userData})opens a handle, streams each item template throughIteratorItemView, and finalises withclose.
All demos under examples/ are standalone PlatformIO projects that use the library via lib_extra_dirs. Each contains a platformio.ini with ready-to-build environments, so you can compile and upload without touching your primary application.
examples/AsyncDashboardDemo/– Full SoftAP dashboard with iterators, conditionals, and runtime telemetry.examples/StreamingAsync/– Minimal captive portal that streams the template directly to the HTTP response.examples/NestedLayouts/– Demonstrates layout stacking, partials, and conditional fragments.examples/HelloPlaceholder/– Smallest possible sketch that renders a single placeholder.
Typical workflow (replace the path/env as needed):
# Build
pio run -d examples/StreamingAsync -e example_esp32
# Flash
pio run -d examples/StreamingAsync -e example_esp32 -t upload
# Monitor (optional)
pio run -d examples/StreamingAsync -e example_esp32 -t monitor
ESP8266 variants use the example_esp8266 or dashboard_esp8266 environments, while ESP32 boards use example_esp32 or dashboard_esp32. Update Wi-Fi credentials inside each example’s src/main.cpp, then connect to the serial monitor or SoftAP as documented in the per-example README.
Refer to the Unity tests in test/test_template_engine/tests for exhaustive combinations of placeholders, nested templates, conditionals, and iterators.
Tune DFTE by defining these macros before including TemplateEngine.h (or via PlatformIO build_flags = -DNAME=value). Larger values increase RAM or flash use, so bump them only when necessary.
DFTE_BUFFER_SIZE_DEFAULT(512 bytes) – streaming buffer insideTemplateContext.DFTE_MAX_STACK_DEPTH_DEFAULT(16) – maximum nested placeholder/template depth.DFTE_PLACEHOLDER_NAME_SIZE_DEFAULT(24) – length limit for placeholder tokens.DFTE_MAX_PLACEHOLDERS_DEFAULT(16) – default capacity when constructingPlaceholderRegistry.DFTE_PROGMEM_CHUNK_SIZE_DEFAULT(512) – copy window when reading PROGMEM data.DFTE_RAM_CHUNK_SIZE_DEFAULT(128) – chunk size for RAM-based getters.DFTE_MAX_ITERATIONS_DEFAULT(50) – safety cap for iterator placeholders.
// Increase iterator cap to 100 and expand streaming buffer
#define DFTE_MAX_ITERATIONS_DEFAULT 100
#define DFTE_BUFFER_SIZE_DEFAULT 768
#include <TemplateEngine.h>
Using DFTE inside DeviceFramework
DeviceFramework projects generate a DeviceFrameworkTemplateConfig.h that is re-exported by DeviceFrameworkConfig.h. Define your defaults there and make sure DeviceFrameworkConfig.h is included before TemplateEngine.h; DFTE detects the CONFIG_template* symbols and swaps them in automatically.
// DeviceFrameworkTemplateConfig.h
#pragma once
#define CONFIG_templateBufferSize_default 768
#define CONFIG_templateStackDepth_default 24
#define CONFIG_templateMaxTemplatePlaceholders_default 24
#define CONFIG_templateProgmemChunkSize_default 1024
#define CONFIG_templateRamChunkSize_default 256
#define CONFIG_templateMaxIterations_default 80
When the core pulls in DeviceFrameworkConfig.h, all templates compiled in that project will inherit these values without further changes.
Add DFTE to your project’s platformio.ini using the Git repository URL:
lib_deps =
https://github.com/alexhopeoconnor/DFTE
Pin to a specific release tag if you need reproducible builds (for example https://github.com/alexhopeoconnor/DFTE#v1.0.0), or keep the lib_deps entry as-is to track the latest main branch during development.
pio test -e test_template_engine # run full Unity suite
pio test -e test_template_engine -f test_template_renderer # single test file
pio run -e test_template_engine # compile without running tests
The default environment targets d1_mini (ESP8266) with test_build_src = yes so library sources are included during builds.
DFTE’s logger is opt-in and costs nothing until you enable it. Implement DeviceFrameworkTemplateEngineLogger, register it once, and all internal DFTE_LOG_* calls stream through your logger.
#include <TemplateEngine.h>
#include <DeviceFrameworkTemplateEngineDebug.h>
class SerialLogger : public DeviceFrameworkTemplateEngineLogger {
public:
void error(const String& msg) override { Serial.println("[DFTE][E] " + msg); }
void warn(const String& msg) override { Serial.println("[DFTE][W] " + msg); }
void info(const String& msg) override { Serial.println("[DFTE][I] " + msg); }
void debug(const String& msg) override { Serial.println("[DFTE][D] " + msg); }
};
SerialLogger logger;
void setup() {
Serial.begin(115200);
deviceFrameworkTemplateEngineEnableLogging(&logger);
}
Use deviceFrameworkTemplateEngineDisableLogging() to silence output or deviceFrameworkTemplateEngineIsLoggingEnabled() to inspect the current state.
This project is released under the MIT License. See LICENSE for details.