diff --git a/README.md b/README.md index 2c6820b..2eb8ec1 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,319 @@ # Device Framework Template Engine (DFTE) -Device Framework Template Engine provides a lightweight templating layer for Arduino-based projects within the Device Framework ecosystem. It exposes a registry for dynamic placeholders, a rendering engine, and utilities for composing nested templates. +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. -## Structure -- `include/` and `src/` contain the core template engine components. -- `test/test_template_engine/` hosts PlatformIO-based unit tests covering placeholder registration, context handling, renderer behaviours, and integration scenarios. +## Quickstart +1. Define your root template in PROGMEM (or RAM if you prefer): -## Getting Started + ``` + static const char ROOT_TEMPLATE_PROGMEM[] PROGMEM = R"DFTE( + + %APP_TITLE% + +

%APP_TITLE%

+

Uptime: %UPTIME%

+ + + )DFTE"; + ``` -The library is distributed as a PlatformIO library. To use it in another PlatformIO project, add the repository as a dependency in your `platformio.ini`: +2. In your sketch, register placeholders and stream them out: + + ``` + #include + + 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); + } + } + ``` + +3. Open the serial monitor (or send chunks to `AsyncWebServer`) to watch the template stream without ever allocating the full HTML in RAM. + +## Why DFTE vs Alternatives? + +| Approach | Streaming-friendly | Live data injection | Flash reuse | Notes | +|----------|-------------------|---------------------|-------------|-------| +| DFTE (this library) | ✅ chunked writer with small buffers | ✅ registry-driven placeholders, iterators, conditionals | ✅ PROGMEM templates shared across requests | Designed for async HTTP and captive portals; minimal RAM usage | +| Static SPIFFS/LittleFS files | ❌ requires full file read | ⚠️ must generate files ahead of time | ✅ stored once on flash filesystem | Great for pure static sites, awkward for live telemetry | +| Arduino `String` concatenation | ❌ builds full payload in RAM | ✅ manual inserts | ❌ duplicates templates in firmware | Simple but fragments heap; under heavy usage causes resets | +| AsyncWebServer template callback | ⚠️ per-placeholder string copies | ✅ but single-function spaghetti | ❌ no reuse between pages | Works for tiny pages; tough to maintain with real layouts | +| Server-side proxy (external backend) | ✅ handled off-device | ✅ unlimited, but depends on network | ❌ device serves only proxy stub | Needs constant connectivity; adds infrastructure overhead | + +## Core Types & API +- `PlaceholderRegistry` + - `registerProgmemData(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. + +- `TemplateRenderer` + - `initializeContext(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 `DeviceFrameworkTemplateEngineLogger` subclass and call `deviceFrameworkTemplateEngineEnableLogging(&logger)`. + +All public headers are re-exported from `TemplateEngine.h`, so typical sketches only include that file. + +## Usage Overview +1. Include `TemplateEngine.h`. + + ``` + #include + ``` + +2. 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); + ``` + +3. Attach the registry to a `TemplateContext`. + + ``` + TemplateContext ctx; + ctx.setRegistry(®istry); + ``` + +4. Feed the root template to `TemplateRenderer::initializeContext`. + + ``` + TemplateRenderer::initializeContext(ctx, PSTR("%ROOT%")); + ``` + +5. Loop on `renderNextChunk` until `TemplateRenderer::isComplete(ctx)` is `true`. + + ``` + uint8_t buffer[128]; + while (!TemplateRenderer::isComplete(ctx) && !TemplateRenderer::hasError(ctx)) { + size_t written = TemplateRenderer::renderNextChunk(ctx, buffer, sizeof(buffer)); + Serial.write(buffer, written); + } + ``` + +6. Optionally reuse the same context for additional templates by calling `ctx.reset()`. + + ``` + ctx.reset(); + TemplateRenderer::initializeContext(ctx, PSTR("%DETAIL_PANEL%")); + ``` + +### Async Streaming Pattern + +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: + +```cpp +/** Global registry prepared during setup() */ +std::shared_ptr registry; + +void setupRegistry() { + registry = std::make_shared(); + 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(); + 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(); +} +``` + +1. Build and cache the `PlaceholderRegistry` during startup so heavy PROGMEM registration runs once. +2. Allocate a request-scoped `TemplateContext`, initialise it with the shared registry, and render inside the chunked callback. +3. Tear everything down on completion or disconnect to avoid state bleed between clients. + +### Template Syntax Reference + +- `%PLACEHOLDER%` – Register with `registerProgmemData`, `registerRamData`, or `registerProgmemTemplate`. +- `%DYNAMIC%` – Pair with `registerDynamicTemplate` to supply template text and length at render time. +- `%CONDITIONAL%` – Use `registerConditional` and return `TRUE_BRANCH`, `FALSE_BRANCH`, or `SKIP` from your evaluator. +- `%ITERATOR%` – Register an `IteratorDescriptor` that opens, yields items via `IteratorItemView`, and closes when complete. +- Nest templates freely; DFTE manages stack depth up to `DFTE_MAX_STACK_DEPTH_DEFAULT` by default. + +### Buildable Examples + +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. + +### Memory & Configuration Knobs + +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 inside `TemplateContext`. +- `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 constructing `PlaceholderRegistry`. +- `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 +``` + +**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. + +## PlatformIO Usage + +### Consume as a Dependency +Add DFTE to your project’s `platformio.ini` using the Git repository URL: ``` lib_deps = - alexhopeoconnor/DFTE + https://github.com/alexhopeoconnor/DFTE ``` -To develop locally or contribute, clone the repository and run the tests through PlatformIO: +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. + +### Local Development & Testing +``` +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. + +### Debug Logging + +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. ``` -pio test -e test_template_engine +#include +#include + +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. + ## License This project is released under the MIT License. See `LICENSE` for details. diff --git a/examples/AsyncDashboardDemo/README.md b/examples/AsyncDashboardDemo/README.md new file mode 100644 index 0000000..b262bf0 --- /dev/null +++ b/examples/AsyncDashboardDemo/README.md @@ -0,0 +1,23 @@ +# Async Dashboard Demo + +Full-featured DFTE demo that combines nested templates, iterators, and a captive portal served via ESPAsyncWebServer. The device exposes its own SoftAP so multiple users can connect and view the dashboard without additional infrastructure. + +Highlights: +- SoftAP + DNS captive portal (`DFTE-Dashboard-8266` or `DFTE-Dashboard-ESP32`, password `dfte-dashboard`) +- Aggregated metrics section populated from RAM getters (uptime, device count, client count) +- Iterator-driven device table with per-entry overrides +- Streaming HTTP responses to avoid buffering the entire page in RAM + +## Build + +``` +pio run -d examples/AsyncDashboardDemo -e dashboard_esp8266 +pio run -d examples/AsyncDashboardDemo -e dashboard_esp32 +``` + +## Run + +1. Flash your target board (`pio run -d examples/AsyncDashboardDemo -e -t upload --upload-port `). +2. Open the serial monitor at 115200 baud to confirm the SoftAP credentials. +3. Connect to the advertised network and browse to `http://192.168.4.1/`. The captive portal should automatically redirect; if not, navigate manually. + diff --git a/examples/AsyncDashboardDemo/platformio.ini b/examples/AsyncDashboardDemo/platformio.ini new file mode 100644 index 0000000..22b974f --- /dev/null +++ b/examples/AsyncDashboardDemo/platformio.ini @@ -0,0 +1,32 @@ +[platformio] +default_envs = dashboard_esp8266 + +[env] +framework = arduino +build_flags = + -O2 +lib_extra_dirs = ../../ +lib_deps = + DFTE=symlink://../../ + ESP32Async/ESPAsyncWebServer +monitor_speed = 115200 + +[env:dashboard_esp8266] +platform = espressif8266 +board = d1_mini +lib_deps = + ${env.lib_deps} + ESP32Async/ESPAsyncTCP +build_flags = + ${env.build_flags} + -I$PROJECT_LIBDEPS_DIR/$PIOENV/ESPAsyncTCP/src + +[env:dashboard_esp32] +platform = espressif32 +board = esp32dev +lib_deps = + ${env.lib_deps} + ESP32Async/AsyncTCP +build_flags = + ${env.build_flags} + -I$PROJECT_LIBDEPS_DIR/$PIOENV/AsyncTCP/src diff --git a/examples/AsyncDashboardDemo/src/main.cpp b/examples/AsyncDashboardDemo/src/main.cpp new file mode 100644 index 0000000..2fbf27d --- /dev/null +++ b/examples/AsyncDashboardDemo/src/main.cpp @@ -0,0 +1,298 @@ +#include +#include +#include +#include +#include + +#if defined(ESP32) + #include + #include +#elif defined(ESP8266) + #include + #include +#endif +#include + +#include "../../common/templates/LayoutSnippets.h" + +AsyncWebServer server(80); +DNSServer dnsServer; +std::shared_ptr registry; + +constexpr byte DNS_PORT = 53; +constexpr const char* AP_PASSWORD = "dfte-dashboard"; +#if defined(ESP32) +constexpr const char* AP_SSID = "DFTE-Dashboard-ESP32"; +#else +constexpr const char* AP_SSID = "DFTE-Dashboard-8266"; +#endif + +// --------------------------------------------------------------------------- +// Template definitions (kept in PROGMEM to reduce RAM usage) +// --------------------------------------------------------------------------- +static const char PROGMEM kLayoutTemplate[] = R"HTML( + + + + + %PAGE_TITLE% + + + + %HEADER% +
+
+

Overview

+
+
Connected Clients
%CLIENT_COUNT%
+
Uptime
%UPTIME%
+
Device Entries
%DEVICE_COUNT%
+
+
+
+

Devices

+ + + + + %DEVICE_ROWS% +
NameStatusLast Seen
+
+
+ %FOOTER% + + +)HTML"; + +static const char PROGMEM kDeviceRowTemplate[] = R"HTML( + + %DEVICE_NAME% + %DEVICE_STATUS% + %DEVICE_LAST_SEEN% + +)HTML"; + +// --------------------------------------------------------------------------- +// Mock data sources (replace with your own business logic) +// --------------------------------------------------------------------------- +struct DeviceInfo { + const char* name; + const char* status; + const char* statusClass; + const char* lastSeen; +}; + +static DeviceInfo kDevices[] = { + {"Living Room Light", "Online", "ok", "5s ago"}, + {"Garage Door", "Warning", "warn", "18s ago"}, + {"Garden Pump", "Offline", "error", "2m ago"} +}; +static constexpr size_t kDeviceCount = sizeof(kDevices) / sizeof(DeviceInfo); + +static String uptimeBuffer; +static String clientCountBuffer; +static String deviceCountBuffer; + +const char* getPageTitle() { return "DFTE Dashboard"; } +const char* getTagline() { return "Rendered chunk-by-chunk from flash + dynamic data"; } + +const char* getDeviceCount() { + deviceCountBuffer = String(kDeviceCount); + return deviceCountBuffer.c_str(); +} + +const char* getUptime() { + uptimeBuffer = String(millis() / 1000) + "s"; + return uptimeBuffer.c_str(); +} + +const char* getClientCount() { +#if defined(ESP32) + clientCountBuffer = String(WiFi.softAPgetStationNum()); +#else + clientCountBuffer = String(WiFi.softAPgetStationNum()); +#endif + return clientCountBuffer.c_str(); +} + +// --------------------------------------------------------------------------- +// Iterator wiring for %DEVICE_ROWS% +// --------------------------------------------------------------------------- +struct DeviceIteratorState { + size_t index; +}; + +DeviceIteratorState gIteratorState; +static PlaceholderEntry gDeviceOverrides[kDeviceCount][4]; +static bool gOverridesInitialized = false; + +void initializeDeviceOverrides() { + if (gOverridesInitialized) { + return; + } + + const char* names[4] = { + "%DEVICE_NAME%", "%DEVICE_STATUS%", "%STATUS_CLASS%", "%DEVICE_LAST_SEEN%" + }; + + for (size_t i = 0; i < kDeviceCount; ++i) { + const char* values[4] = { + kDevices[i].name, + kDevices[i].status, + kDevices[i].statusClass, + kDevices[i].lastSeen + }; + + for (size_t field = 0; field < 4; ++field) { + PlaceholderEntry& entry = gDeviceOverrides[i][field]; + memset(entry.name, 0, sizeof(entry.name)); + strncpy(entry.name, names[field], sizeof(entry.name) - 1); + entry.type = PlaceholderType::PROGMEM_DATA; + entry.data = values[field]; + entry.getLength = DeviceFrameworkPlaceholderRegistry::getProgmemLength; + } + } + + gOverridesInitialized = true; +} + +void* deviceIteratorOpen(void* userData) { + auto* state = static_cast(userData); + state->index = 0; + initializeDeviceOverrides(); + return state; +} + +IteratorStepResult deviceIteratorNext(void* handle, IteratorItemView& view) { + auto* state = static_cast(handle); + if (state->index >= kDeviceCount) { + return IteratorStepResult::COMPLETE; + } + + view.templateData = kDeviceRowTemplate; + view.templateLength = strlen_P(kDeviceRowTemplate); + view.templateIsProgmem = true; + view.placeholders = gDeviceOverrides[state->index]; + view.placeholderCount = 4; + + state->index++; + return IteratorStepResult::ITEM_READY; +} + +void deviceIteratorClose(void*) {} + +IteratorDescriptor gDeviceIterator = { + .open = deviceIteratorOpen, + .next = deviceIteratorNext, + .close = deviceIteratorClose, + .userData = &gIteratorState +}; + +// --------------------------------------------------------------------------- +// Registry + rendering helpers +// --------------------------------------------------------------------------- +void initialiseRegistry(PlaceholderRegistry& registryRef) { + registryRef.clear(); + + // Static assets and nested templates + registryRef.registerProgmemData("%GLOBAL_CSS%", DFTEExamples::SHARED_CSS); + registryRef.registerProgmemTemplate("%HEADER%", DFTEExamples::SHARED_HEADER); + registryRef.registerProgmemTemplate("%FOOTER%", DFTEExamples::SHARED_FOOTER); + + // Header content + registryRef.registerRamData("%PAGE_TITLE%", getPageTitle); + registryRef.registerRamData("%TAGLINE%", getTagline); + + // Overview metrics + registryRef.registerRamData("%CLIENT_COUNT%", getClientCount); + registryRef.registerRamData("%UPTIME%", getUptime); + registryRef.registerRamData("%DEVICE_COUNT%", getDeviceCount); + + // Iterator to populate the device table + registryRef.registerIterator("%DEVICE_ROWS%", &gDeviceIterator); +} + +void streamTemplate(AsyncWebServerRequest* request, + std::shared_ptr registryPtr, + const char* rootTemplate) { + auto ctx = std::make_shared(); + ctx->setRegistry(registryPtr.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 /*index*/) 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(); + } + + 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 handleDashboard(AsyncWebServerRequest* request) { + if (!registry) { + registry = std::make_shared(24); + initialiseRegistry(*registry); + } + streamTemplate(request, registry, kLayoutTemplate); +} + +void startCaptivePortal() { + WiFi.mode(WIFI_AP); + IPAddress apIP(192, 168, 4, 1); + IPAddress netmask(255, 255, 255, 0); + WiFi.softAPConfig(apIP, apIP, netmask); + WiFi.softAP(AP_SSID, AP_PASSWORD); + dnsServer.start(DNS_PORT, "*", apIP); + + Serial.println(F("Captive portal active")); + Serial.print(F("SSID: ")); + Serial.println(AP_SSID); + Serial.print(F("Password: ")); + Serial.println(AP_PASSWORD); + Serial.print(F("Portal IP: ")); + Serial.println(apIP); +} + +// --------------------------------------------------------------------------- +// Arduino entry points +// --------------------------------------------------------------------------- +void setup() { + Serial.begin(115200); + while (!Serial) { /* wait */ } + + Serial.println(); + Serial.println(F("=== DFTE Async Dashboard Demo ===")); + + registry = std::make_shared(24); + initialiseRegistry(*registry); + startCaptivePortal(); + + server.on("/", HTTP_GET, handleDashboard); + server.onNotFound(handleDashboard); + server.begin(); + + Serial.println(F("Dashboard available at http://192.168.4.1/")); +} + +void loop() { + dnsServer.processNextRequest(); +} + diff --git a/examples/HelloPlaceholder/README.md b/examples/HelloPlaceholder/README.md new file mode 100644 index 0000000..ef7bb70 --- /dev/null +++ b/examples/HelloPlaceholder/README.md @@ -0,0 +1,17 @@ +# Hello Placeholder + +This introductory sketch demonstrates the minimum DFTE setup: + +- Create a `PlaceholderRegistry` and register simple RAM getters +- Initialise a `TemplateContext` with a RAM template +- Stream the rendered output into a Serial buffer + +## Build + +``` +pio run -d examples/HelloPlaceholder -e example_esp8266 +pio run -d examples/HelloPlaceholder -e example_esp32 +``` + +Flash the firmware and open the serial monitor at 115200 baud to see the rendered text. + diff --git a/examples/HelloPlaceholder/platformio.ini b/examples/HelloPlaceholder/platformio.ini new file mode 100644 index 0000000..60dac4d --- /dev/null +++ b/examples/HelloPlaceholder/platformio.ini @@ -0,0 +1,18 @@ +[platformio] +default_envs = example_esp8266 + +[env] +framework = arduino +lib_extra_dirs = ../../ +lib_deps = + DFTE=symlink://../../ +monitor_speed = 115200 + +[env:example_esp8266] +platform = espressif8266 +board = d1_mini + +[env:example_esp32] +platform = espressif32 +board = esp32dev + diff --git a/examples/HelloPlaceholder/src/main.cpp b/examples/HelloPlaceholder/src/main.cpp new file mode 100644 index 0000000..842a4ee --- /dev/null +++ b/examples/HelloPlaceholder/src/main.cpp @@ -0,0 +1,51 @@ +#include +#include + +namespace { + +static String deviceName = "DFTE Getting Started"; +static String buildId = "v1.0.0"; + +const char SIMPLE_TEMPLATE[] = R"TPL( +Device: %DEVICE_NAME% +Build: %BUILD_ID% + +DFTE lets you reuse this template across Serial, HTTP, or any other transport. +)TPL"; + +const char* getDeviceName() { return deviceName.c_str(); } +const char* getBuildId() { return buildId.c_str(); } + +} // namespace + +void setup() { + Serial.begin(115200); + while (!Serial) { /* wait for USB CDC */ } + + Serial.println(); + Serial.println(F("=== DFTE Hello Placeholder ===")); + + PlaceholderRegistry registry; + registry.registerRamData("%DEVICE_NAME%", getDeviceName); + registry.registerRamData("%BUILD_ID%", getBuildId); + + TemplateContext ctx; + ctx.setRegistry(®istry); + TemplateRenderer::initializeContext(ctx, SIMPLE_TEMPLATE, false); + + uint8_t buffer[64]; + while (!TemplateRenderer::isComplete(ctx) && !TemplateRenderer::hasError(ctx)) { + size_t written = TemplateRenderer::renderNextChunk(ctx, buffer, sizeof(buffer)); + if (!written) { + break; + } + Serial.write(buffer, written); + } + + Serial.println(F("\nRendering complete.")); +} + +void loop() { + // Nothing else to do in the basic example. +} + diff --git a/examples/NestedLayouts/README.md b/examples/NestedLayouts/README.md new file mode 100644 index 0000000..f8e5f82 --- /dev/null +++ b/examples/NestedLayouts/README.md @@ -0,0 +1,18 @@ +# Nested Layouts + +Demonstrates nested templates, conditional placeholders, and iterator-driven sections without a web server dependency. Ideal for learning how DFTE composes complex layouts on memory-constrained boards. + +Highlights: +- Shared header/footer partials referenced via `%HEADER%` and `%FOOTER%` +- Conditional placeholder toggling a "maintenance" banner +- Iterator rendering a list of subsystem status entries with per-item overrides + +## Build + +``` +pio run -d examples/NestedLayouts -e example_esp8266 +pio run -d examples/NestedLayouts -e example_esp32 +``` + +Open the serial monitor at 115200 baud to inspect the rendered output. + diff --git a/examples/NestedLayouts/platformio.ini b/examples/NestedLayouts/platformio.ini new file mode 100644 index 0000000..60dac4d --- /dev/null +++ b/examples/NestedLayouts/platformio.ini @@ -0,0 +1,18 @@ +[platformio] +default_envs = example_esp8266 + +[env] +framework = arduino +lib_extra_dirs = ../../ +lib_deps = + DFTE=symlink://../../ +monitor_speed = 115200 + +[env:example_esp8266] +platform = espressif8266 +board = d1_mini + +[env:example_esp32] +platform = espressif32 +board = esp32dev + diff --git a/examples/NestedLayouts/src/main.cpp b/examples/NestedLayouts/src/main.cpp new file mode 100644 index 0000000..d8b3653 --- /dev/null +++ b/examples/NestedLayouts/src/main.cpp @@ -0,0 +1,213 @@ +#include +#include +#include +#include + +#include "../../common/templates/LayoutSnippets.h" + +namespace { + +struct SubsystemStatus { + const char* name; + const char* detail; + const char* severityClass; +}; + +constexpr SubsystemStatus SUBSYSTEMS[] = { + {"Wi-Fi", "Connected", "ok"}, + {"MQTT", "Disconnected", "warn"}, + {"Storage", "Healthy", "ok"}, + {"OTA", "Idle", "info"}, +}; +constexpr size_t SUBSYSTEM_COUNT = sizeof(SUBSYSTEMS) / sizeof(SubsystemStatus); + +bool maintenanceMode = false; + +// Templates +inline const char PROGMEM BASE_TEMPLATE[] = R"HTML( + + + + + %PAGE_TITLE% + + + + %HEADER% +
+

Environment

+

Firmware: %FIRMWARE_VERSION%

+

Boot Count: %BOOT_COUNT%

+
+
+

Maintenance

+ %MAINTENANCE_BANNER% +
+
+

Subsystems

+
    + %SUBSYSTEM_LIST% +
+
+ %FOOTER% + + +)HTML"; + +inline const char PROGMEM SUBSYSTEM_ITEM_TEMPLATE[] = R"HTML( +
  • + %NAME% + %DETAIL% +
  • +)HTML"; + +inline const char PROGMEM MAINTENANCE_TRUE_TEMPLATE[] = R"HTML( +
    System in maintenance mode. Automations disabled.
    +)HTML"; + +inline const char PROGMEM MAINTENANCE_FALSE_TEMPLATE[] = R"HTML( +
    All services operating normally.
    +)HTML"; + +// RAM data +static String bootCountBuffer; +static uint32_t bootCount = 42; + +const char* getFirmwareVersion() { return "2.3.1"; } + +const char* getBootCount() { + bootCountBuffer = String(bootCount); + return bootCountBuffer.c_str(); +} + +// Conditional descriptor +struct MaintenanceState { + bool enabled; +} maintenanceState{maintenanceMode}; + +ConditionalBranchResult evaluateMaintenance(void* userData) { + auto* state = static_cast(userData); + return state->enabled ? ConditionalBranchResult::TRUE_BRANCH : ConditionalBranchResult::FALSE_BRANCH; +} + +inline const ConditionalDescriptor MAINTENANCE_DESCRIPTOR = { + evaluateMaintenance, + "%MAINTENANCE_TRUE%", + "%MAINTENANCE_FALSE%", + (void*)&maintenanceState}; + +// Iterator plumbing +struct SubsystemIteratorState { + size_t index = 0; +}; + +SubsystemIteratorState iteratorState; +PlaceholderEntry subsystemOverrides[SUBSYSTEM_COUNT][3]; + +void initialiseOverrides() { + for (size_t i = 0; i < SUBSYSTEM_COUNT; ++i) { + const SubsystemStatus& status = SUBSYSTEMS[i]; + + PlaceholderEntry& name = subsystemOverrides[i][0]; + strncpy(name.name, "%NAME%", sizeof(name.name) - 1); + name.type = PlaceholderType::RAM_DATA; + name.data = status.name; + name.getLength = DeviceFrameworkPlaceholderRegistry::getRamLength; + + PlaceholderEntry& detail = subsystemOverrides[i][1]; + strncpy(detail.name, "%DETAIL%", sizeof(detail.name) - 1); + detail.type = PlaceholderType::RAM_DATA; + detail.data = status.detail; + detail.getLength = DeviceFrameworkPlaceholderRegistry::getRamLength; + + PlaceholderEntry& severity = subsystemOverrides[i][2]; + strncpy(severity.name, "%SEVERITY%", sizeof(severity.name) - 1); + severity.type = PlaceholderType::RAM_DATA; + severity.data = status.severityClass; + severity.getLength = DeviceFrameworkPlaceholderRegistry::getRamLength; + } +} + +void* iteratorOpen(void* userData) { + initialiseOverrides(); + auto* state = static_cast(userData); + state->index = 0; + return state; +} + +IteratorStepResult iteratorNext(void* handle, IteratorItemView& view) { + auto* state = static_cast(handle); + if (state->index >= SUBSYSTEM_COUNT) { + return IteratorStepResult::COMPLETE; + } + + view.templateData = SUBSYSTEM_ITEM_TEMPLATE; + view.templateLength = strlen_P(SUBSYSTEM_ITEM_TEMPLATE); + view.templateIsProgmem = true; + view.placeholders = subsystemOverrides[state->index]; + view.placeholderCount = 3; + state->index++; + return IteratorStepResult::ITEM_READY; +} + +void iteratorClose(void*) {} + +IteratorDescriptor iteratorDescriptor = { + iteratorOpen, + iteratorNext, + iteratorClose, + &iteratorState}; + +std::shared_ptr buildRegistry() { + auto registry = std::make_shared(); + registry->registerProgmemData("%CSS%", DFTEExamples::SHARED_CSS); + registry->registerProgmemTemplate("%HEADER%", DFTEExamples::SHARED_HEADER); + registry->registerProgmemTemplate("%FOOTER%", DFTEExamples::SHARED_FOOTER); + + registry->registerRamData("%PAGE_TITLE%", []() -> const char* { return "DFTE Nested Layouts"; }); + registry->registerRamData("%TAGLINE%", []() -> const char* { return "Composing templates with conditionals and iterators"; }); + registry->registerRamData("%FIRMWARE_VERSION%", getFirmwareVersion); + registry->registerRamData("%BOOT_COUNT%", getBootCount); + + registry->registerConditional("%MAINTENANCE_BANNER%", &MAINTENANCE_DESCRIPTOR); + registry->registerProgmemTemplate("%MAINTENANCE_TRUE%", MAINTENANCE_TRUE_TEMPLATE); + registry->registerProgmemTemplate("%MAINTENANCE_FALSE%", MAINTENANCE_FALSE_TEMPLATE); + + registry->registerIterator("%SUBSYSTEM_LIST%", &iteratorDescriptor); + + return registry; +} + +void renderToSerial() { + auto registry = buildRegistry(); + TemplateContext ctx; + ctx.setRegistry(registry.get()); + TemplateRenderer::initializeContext(ctx, BASE_TEMPLATE); + + uint8_t buffer[128]; + while (!TemplateRenderer::isComplete(ctx) && !TemplateRenderer::hasError(ctx)) { + size_t written = TemplateRenderer::renderNextChunk(ctx, buffer, sizeof(buffer)); + if (!written) { + break; + } + Serial.write(buffer, written); + } + Serial.println(); +} + +} // namespace + +void setup() { + Serial.begin(115200); + while (!Serial) { /* wait */ } + + Serial.println(); + Serial.println(F("=== DFTE Nested Layouts Example ===")); + + renderToSerial(); +} + +void loop() { + // Nothing to do in loop for this example. +} + diff --git a/examples/StreamingAsync/README.md b/examples/StreamingAsync/README.md new file mode 100644 index 0000000..cb1d378 --- /dev/null +++ b/examples/StreamingAsync/README.md @@ -0,0 +1,18 @@ +# Streaming Async + +Small ESPAsyncWebServer demo that exposes a captive portal over a SoftAP and renders a DFTE template directly to the HTTP response stream. It highlights: + +- Per-request `TemplateContext` ownership to avoid concurrent request clashes +- PROGMEM template with shared CSS/header/footer snippets +- Runtime data from RAM getters (uptime, connected station count) +- Captive portal DNS redirect so clients automatically receive the dashboard + +## Build + +``` +pio run -d examples/StreamingAsync -e example_esp8266 +pio run -d examples/StreamingAsync -e example_esp32 +``` + +Flash the board, connect to the SoftAP announced by the device (`DFTE-Portal-8266` or `DFTE-Portal-ESP32`, password `dfte-demo`), and your browser should automatically present the streamed dashboard. If it does not, navigate manually to `http://192.168.4.1/`. + diff --git a/examples/StreamingAsync/platformio.ini b/examples/StreamingAsync/platformio.ini new file mode 100644 index 0000000..d43e251 --- /dev/null +++ b/examples/StreamingAsync/platformio.ini @@ -0,0 +1,33 @@ +[platformio] +default_envs = example_esp8266 + +[env] +framework = arduino +lib_extra_dirs = ../../ +lib_deps = + DFTE=symlink://../../ + ESP32Async/ESPAsyncWebServer +build_flags = + -O2 +monitor_speed = 115200 + +[env:example_esp8266] +platform = espressif8266 +board = d1_mini +lib_deps = + ${env.lib_deps} + ESP32Async/ESPAsyncTCP +build_flags = + ${env.build_flags} + -I$PROJECT_LIBDEPS_DIR/$PIOENV/ESPAsyncTCP/src + +[env:example_esp32] +platform = espressif32 +board = esp32dev +lib_deps = + ${env.lib_deps} + ESP32Async/AsyncTCP +build_flags = + ${env.build_flags} + -I$PROJECT_LIBDEPS_DIR/$PIOENV/AsyncTCP/src + diff --git a/examples/StreamingAsync/src/main.cpp b/examples/StreamingAsync/src/main.cpp new file mode 100644 index 0000000..af2448c --- /dev/null +++ b/examples/StreamingAsync/src/main.cpp @@ -0,0 +1,159 @@ +#include +#include +#include +#include +#include + +#if defined(ESP32) + #include + #include +#elif defined(ESP8266) + #include + #include +#endif +#include + +#include "../../common/templates/LayoutSnippets.h" + +namespace { + +constexpr const char* AP_SSID = +#if defined(ESP32) + "DFTE-Portal-ESP32"; +#else + "DFTE-Portal-8266"; +#endif +constexpr const char* AP_PASSWORD = "dfte-demo"; +constexpr byte DNS_PORT = 53; + +DNSServer dnsServer; + +AsyncWebServer server(80); +std::shared_ptr registry; + +inline const char PROGMEM PAGE_TEMPLATE[] = R"HTML( + + + + + %PAGE_TITLE% + + + + %HEADER% +
    +

    Device Snapshot

    +

    Uptime: %UPTIME%

    +

    Connected Clients: %CLIENT_COUNT%

    +
    + %FOOTER% + + +)HTML"; + +String uptimeBuffer; +String clientBuffer; + +const char* getUptime() { + uptimeBuffer = String(millis() / 1000) + "s"; + return uptimeBuffer.c_str(); +} + +const char* getClientCount() { +#if defined(ESP32) + clientBuffer = String(WiFi.softAPgetStationNum()); +#else + clientBuffer = String(WiFi.softAPgetStationNum()); +#endif + return clientBuffer.c_str(); +} + +void initialiseRegistry(PlaceholderRegistry& registryRef) { + registryRef.clear(); + registryRef.registerProgmemData("%CSS%", DFTEExamples::SHARED_CSS); + registryRef.registerProgmemTemplate("%HEADER%", DFTEExamples::SHARED_HEADER); + registryRef.registerProgmemTemplate("%FOOTER%", DFTEExamples::SHARED_FOOTER); + registryRef.registerRamData("%PAGE_TITLE%", []() -> const char* { return "DFTE Streaming Async"; }); + registryRef.registerRamData("%TAGLINE%", []() -> const char* { return "Chunked rendering with ESPAsyncWebServer"; }); + registryRef.registerRamData("%UPTIME%", getUptime); + registryRef.registerRamData("%CLIENT_COUNT%", getClientCount); +} + +void streamTemplate(AsyncWebServerRequest* request, + std::shared_ptr registryPtr, + const char* tpl) { + auto ctx = std::make_shared(); + ctx->setRegistry(registryPtr.get()); + TemplateRenderer::initializeContext(*ctx, tpl); + + 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(); + } + return written; + }); + + response->addHeader("Cache-Control", "no-cache"); + request->send(response); +} + +void handleRoot(AsyncWebServerRequest* request) { + if (!registry) { + registry = std::make_shared(); + initialiseRegistry(*registry); + } + streamTemplate(request, registry, PAGE_TEMPLATE); +} + +void startCaptivePortal() { + WiFi.mode(WIFI_AP); + IPAddress apIP(192, 168, 4, 1); + IPAddress netMask(255, 255, 255, 0); + WiFi.softAPConfig(apIP, apIP, netMask); + WiFi.softAP(AP_SSID, AP_PASSWORD); + + dnsServer.start(DNS_PORT, "*", apIP); + + Serial.println(F("Captive portal active")); + Serial.print(F("SSID: ")); + Serial.println(AP_SSID); + Serial.print(F("Password: ")); + Serial.println(AP_PASSWORD); + Serial.print(F("Portal IP: ")); + Serial.println(apIP); +} + +} // namespace + +void setup() { + Serial.begin(115200); + while (!Serial) { /* wait */ } + + Serial.println(); + Serial.println(F("=== DFTE Streaming Async Example ===")); + + registry = std::make_shared(); + initialiseRegistry(*registry); + startCaptivePortal(); + + server.on("/", HTTP_GET, handleRoot); + server.onNotFound(handleRoot); + server.begin(); + Serial.println(F("HTTP server listening on port 80")); +} + +void loop() { + dnsServer.processNextRequest(); +} + diff --git a/examples/common/templates/LayoutSnippets.h b/examples/common/templates/LayoutSnippets.h new file mode 100644 index 0000000..f0ccc5c --- /dev/null +++ b/examples/common/templates/LayoutSnippets.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +namespace DFTEExamples { + +// Shared CSS snippet stored in flash to minimise RAM usage. +inline const char PROGMEM SHARED_CSS[] = R"CSS( +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 1.5rem; + background: #f4f6f9; + color: #222; +} +h1, h2 { + color: #0a3d62; + margin-bottom: 0.5rem; +} +section { + margin-bottom: 1.5rem; + padding: 1rem; + background: #ffffff; + border-radius: 8px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); +} +)CSS"; + +// Minimal header/footer fragments that examples can reuse. +inline const char PROGMEM SHARED_HEADER[] = R"HTML( +
    +

    %PAGE_TITLE%

    +

    %TAGLINE%

    +
    +)HTML"; + +inline const char PROGMEM SHARED_FOOTER[] = R"HTML( + +)HTML"; + +} // namespace DFTEExamples + diff --git a/platformio.ini b/platformio.ini index cf07284..fd88195 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,6 +1,13 @@ -[env:test_template_engine] +[env:test_template_engine_8266] platform = espressif8266 board = d1_mini framework = arduino test_framework = unity test_build_src = yes + +[env:test_template_engine_esp32] +platform = espressif32@5.3.0 +board = esp32doit-devkit-v1 +framework = arduino +test_framework = unity +test_build_src = yes