12 Commits
43 changed files with 1012 additions and 398 deletions
+15 -2
View File
@@ -2,22 +2,35 @@ name: Build
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
concurrency:
group: build-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
documentation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: bash -n scripts/*.sh
- run: ./scripts/check-docs.sh
compile-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
environment: ["test_template_engine_8266", "test_template_engine_esp32"]
platform: [esp8266, esp32]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19
- run: pio test -e ${{ matrix.environment }} --without-uploading --without-testing
- run: ./scripts/test.sh compile --platform ${{ matrix.platform }}
- run: ./scripts/test.sh examples --platform ${{ matrix.platform }}
+7 -1
View File
@@ -17,7 +17,13 @@ jobs:
with:
python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19
- run: ./scripts/test.sh compile --platform esp8266
- run: ./scripts/test.sh examples --platform esp8266
- run: ./scripts/test.sh compile --platform esp32
- run: ./scripts/test.sh examples --platform esp32
- run: ./scripts/check-docs.sh
- run: ./scripts/prepare-release.sh "$GITHUB_REF_NAME"
- run: gh release create "$GITHUB_REF_NAME" --generate-notes --title "$GITHUB_REF_NAME"
- run: ./scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-notes.md"
- run: gh release create "$GITHUB_REF_NAME" --title "DeviceFrameworkTemplateEngine $GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/release-notes.md"
env:
GH_TOKEN: ${{ github.token }}
+33
View File
@@ -0,0 +1,33 @@
# Changelog
## 1.2.1
- Clarify the safe shared-response and fixed-slot borrowed-response helpers so
embedded web clients can choose the ownership model that matches their
response lifetime.
- Refresh the package documentation and canonical PlatformIO installation tag.
## 1.2.0
- Add a borrowed async-response helper for fixed, caller-owned response slots. It avoids a per-request `shared_ptr` control allocation while releasing the slot safely on completion or disconnect.
- Add callback-enabled safe shared response helpers so dynamic contexts can return a bounded owner permit at the same time as their shared state is released.
## 1.1.0
- Replace layout-affecting compile-time storage overrides with per-context,
caller-selected rendering depth and read-buffer capacity. This keeps the
public ABI stable across translation units while allowing constrained
responses to use smaller storage.
- Pin ESP32 tests to the Arduino 3-compatible pioarduino platform release.
## 1.0.2
- Ensure open iterator handles are closed when rendering resets, stalls, or
terminates with an error.
- Document the supported build-flag configuration model so every translation
unit uses the same DFTE object layout.
## 1.0.1
- Establish the first semantic-versioned release of the maintained DFTE
package.
+41 -296
View File
@@ -1,317 +1,62 @@
# Device Framework Template Engine (DFTE)
# DeviceFramework Template Engine (DFTE)
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.
DFTE streams HTML and text from ESP8266 and ESP32 firmware without constructing a complete response in RAM. Store layouts in PROGMEM, resolve changing values through getters, and render fixed-size chunks directly to serial or an asynchronous HTTP response.
## Quickstart
1. 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";
```
2. 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%"), []() -> const char* {
static char buffer[16];
snprintf(buffer, sizeof(buffer), "%lus", millis() / 1000);
return buffer;
});
registry.registerProgmemTemplate(PSTR("%ROOT%"), ROOT_TEMPLATE_PROGMEM);
ctx.setRegistry(&registry);
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 | 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 | ⚠️ Requires pre-generated HTML | ✅ 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 | ⚠️ Streams callback output but copies per placeholder | ✅ 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 |
## 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)` (or the two-argument overload with an owner tag, e.g. `this`, for `deviceFrameworkTemplateEngineDisableLoggingForOwner`).
All public headers are re-exported from `TemplateEngine.h`, so typical sketches only include that file.
## Usage Overview
1. Include `TemplateEngine.h`.
```
#include <TemplateEngine.h>
```
2. Register placeholders on a `PlaceholderRegistry`.
```
PlaceholderRegistry registry;
registry.registerProgmemData(PSTR("%APP_TITLE%"), PSTR("DFTE Dashboard"));
registry.registerRamData(PSTR("%UPTIME%"), []() -> const char* {
static char buffer[16];
snprintf(buffer, sizeof(buffer), "%lus", millis() / 1000);
return buffer;
});
registry.registerProgmemTemplate(PSTR("%ROOT%"), ROOT_TEMPLATE_PROGMEM);
```
3. Attach the registry to a `TemplateContext`.
```
TemplateContext ctx;
ctx.setRegistry(&registry);
```
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, include `TemplateEngineAsyncWeb.h` and 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:
## Render a first template
```cpp
#include <TemplateEngine.h>
#include <TemplateEngineAsyncWeb.h>
/** 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%"), []() -> const char* {
static char buffer[16];
snprintf(buffer, sizeof(buffer), "%lus", millis() / 1000);
return 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 =
TemplateEngineAsyncWeb::beginSafeTemplateResponse(
request, "text/html; charset=utf-8", ctx, 128);
response->addHeader("Cache-Control", "no-cache, no-store, must-revalidate");
response->addHeader("Pragma", "no-cache");
response->addHeader("Expires", "-1");
request->send(response);
}
static const char PAGE[] PROGMEM = "<h1>%TITLE%</h1>";
PlaceholderRegistry registry;
TemplateContext context;
void setup() {
setupRegistry();
server.on("/", [](AsyncWebServerRequest* request) {
streamTemplate(request, PSTR("%ROOT%"));
});
server.begin();
Serial.begin(115200);
registry.registerProgmemData(PSTR("%TITLE%"), PSTR("Hello DFTE"));
registry.registerProgmemTemplate(PSTR("%PAGE%"), PAGE);
context.setRegistry(&registry);
TemplateRenderer::initializeContext(context, PSTR("%PAGE%"));
}
void loop() {
if (TemplateRenderer::hasError(context)) {
// Stop or report the invalid root/placeholder; completion alone is not success.
return;
}
if (TemplateRenderer::isComplete(context)) return;
uint8_t chunk[128];
const size_t written = TemplateRenderer::renderNextChunk(context, chunk, sizeof(chunk));
if (written > 0) Serial.write(chunk, written);
}
```
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.
Build [Hello Placeholder](examples/HelloPlaceholder/) to see the rendered result over serial.
### Template Syntax
## Choose an example
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).
| Example | What you will build |
| --- | --- |
| [Hello Placeholder](examples/HelloPlaceholder/) | the smallest registry, context, and serial-rendering flow |
| [Nested Layouts](examples/NestedLayouts/) | reusable partials, conditions, and iterator sections |
| [Streaming Async](examples/StreamingAsync/) | a streamed ESPAsyncWebServer response over a SoftAP |
| [Async Dashboard Demo](examples/AsyncDashboardDemo/) | a live dashboard with iterator rows and captive-portal access |
### Placeholder Types
## Why DFTE
- **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 returns the current value as a `const char*`.
- **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 through `IteratorItemView`, and finalises with `close`.
- **Bounded response memory:** render fixed-size chunks instead of allocating one large `String`.
- **Flash-resident layouts:** keep templates, CSS, and shared fragments in PROGMEM.
- **Live values:** resolve dynamic information only as the active response needs it.
- **Async-safe rendering:** each HTTP request owns its rendering context while sharing the prepared registry.
### Buildable Examples
## Install
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 <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.
## PlatformIO Usage
### Consume as a Dependency
Add DFTE to your project’s `platformio.ini` using the Git repository URL:
```
```ini
lib_deps =
https://github.com/alexhopeoconnor/DFTE
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.2.1
```
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.
PlatformIO checks out the Git ref after `#`; GitHub Release assets are unrelated. DFTE supports ESP8266 and ESP32 Arduino projects.
### 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.
```
#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, static_cast<const void*>(&logger));
}
```
Use `deviceFrameworkTemplateEngineDisableLogging()` to silence output, `deviceFrameworkTemplateEngineDisableLoggingForOwner(tag)` to tear down only the registration made with that owner (for example WiFiManager’s server instance), or `deviceFrameworkTemplateEngineIsLoggingEnabled()` to inspect the current state.
## License
This project is released under the MIT License. See `LICENSE` for details.
See [getting started](docs/GETTING_STARTED.md), the [documentation index](docs/README.md), [examples](examples/README.md), [changelog](CHANGELOG.md), and [licence](LICENSE).
+86
View File
@@ -0,0 +1,86 @@
# Async web responses
Build a `PlaceholderRegistry` once during setup, but allocate a fresh `TemplateContext` for each request. A shared context would mix rendering state when clients overlap. The handler below assumes setup has populated the long-lived `registry`; see [StreamingAsync](../examples/StreamingAsync/) for the complete project.
| Need | Use |
| --- | --- |
| A request-owned dynamic render context | `beginSafeTemplateResponse()` |
| A fixed response-slot pool owned by the application | `beginBorrowedChunkedResponse()` |
```cpp
#include <TemplateEngine.h>
#include <TemplateEngineAsyncWeb.h>
std::shared_ptr<PlaceholderRegistry> registry;
void sendTemplate(AsyncWebServerRequest* request, const char* root) {
auto context = std::make_shared<TemplateContext>();
context->setRegistry(registry.get());
TemplateRenderer::initializeContext(*context, root);
// The helper retains the request-owned context until the response completes or disconnects.
AsyncWebServerResponse* response =
TemplateEngineAsyncWeb::beginSafeTemplateResponse(
request, "text/html; charset=utf-8", context, 128
);
request->send(response);
}
```
The response retains the context while it streams. Releasing the request-owned `shared_ptr` on disconnect prevents state from leaking into later requests.
## Bounded fixed slots
Use `beginBorrowedChunkedResponse()` when the application already owns a small, fixed response pool. It does not allocate a `shared_ptr` control block for the request. The release callback must be idempotent because it can run once when rendering finishes and again if the connection later disconnects.
```cpp
struct ResponseSlot {
bool busy = false;
uint32_t lease = 0;
TemplateContext context;
};
ResponseSlot slots[2];
void releaseSlot(ResponseSlot& slot, uint32_t lease) {
// A completed response can disconnect after this slot has been reused.
if (!slot.busy || slot.lease != lease) return;
slot.context.reset();
slot.busy = false;
}
void sendBoundedTemplate(AsyncWebServerRequest* request, const char* root) {
ResponseSlot* slot = nullptr;
for (auto& candidate : slots) {
if (!candidate.busy) {
slot = &candidate;
break;
}
}
if (slot == nullptr) {
request->send(503, "text/plain", "Busy");
return;
}
slot->busy = true;
const uint32_t lease = ++slot->lease;
slot->context.setRegistry(registry.get());
TemplateRenderer::initializeContext(slot->context, root);
request->send(TemplateEngineAsyncWeb::beginBorrowedChunkedResponse(
request, "text/html; charset=utf-8", slot,
[](ResponseSlot& state, uint8_t* out, size_t size, size_t) {
return TemplateEngineAsyncWeb::renderTemplateChunkWithRetries(
state.context, out, size, 128);
},
[](const ResponseSlot& state) {
return TemplateEngineAsyncWeb::isTemplateTerminal(state.context);
},
[lease](ResponseSlot& state) { releaseSlot(state, lease); }));
}
```
Use this form only while the slot itself has static or otherwise guaranteed lifetime. The lease makes a late disconnect from an old response a no-op after the slot has been reused. Use the `shared_ptr` form above for a request-owned dynamic context.
Use [StreamingAsync](../examples/StreamingAsync/) for a complete SoftAP/captive-portal project.
Back to [documentation](README.md) · [project overview](../README.md).
+45
View File
@@ -0,0 +1,45 @@
# Configuration and memory limits
DFTE has a stable public object layout. `TemplateContext` owns its rendering
stack and read buffer at runtime, so choosing capacities cannot make a consumer
and the compiled library disagree about object size.
```cpp
// Keep each context alive for the full response that uses it.
TemplateContext standard; // Default: 16 stack frames, 512-byte buffer.
TemplateContext pageContext(6, 128); // Known shallow response, smaller allocation.
void setup() {
if (!standard.isReady() || !pageContext.isReady()) {
// Allocation failed; do not start a response with either context.
}
}
```
The context allocates its stack and read buffer when it is constructed. Its
approximate heap use is `maxDepth * sizeof(RenderingContext) + bufferSize`, plus
allocator overhead; its fixed 24-byte placeholder-token storage is part of the
stable context object. Allocate one context per concurrently streaming request; use explicit
small capacities for bounded pages rather than changing global definitions.
Nested template expansion normally consumes two stack frames per level.
| Setting | Default | Meaning |
| --- | --- | --- |
| `DFTE_MAX_STACK_DEPTH` | 16 | Default depth passed by the no-argument `TemplateContext` constructor |
| `DFTE_BUFFER_SIZE` | 512 | Default read-buffer size passed by the no-argument constructor |
| `DFTE_MAX_ITERATIONS` | 50 | Safety cap for one renderer call |
| `DFTE_PROGMEM_CHUNK_SIZE` | 512 | Source-copy window for flash data |
| `DFTE_RAM_CHUNK_SIZE` | 128 | Source-copy window for RAM data |
| `DFTE_MAX_PLACEHOLDERS_DEFAULT` | 16 | Default `PlaceholderRegistry` capacity |
The first two are constructor defaults, not layout controls: a consuming
translation unit can choose them without an ABI mismatch. The source-copy and
iteration settings change renderer behaviour, so set them consistently for a
whole PlatformIO build. `DFTE_PLACEHOLDER_NAME_SIZE` is no longer a supported
setting; placeholder tokens have a fixed ABI-stable capacity of 23 characters
plus the terminator.
`DFTE_MAX_PLACEHOLDERS_DEFAULT` is only a constructor default; pass an explicit
registry capacity where a device needs more.
Back to [documentation](README.md) · [project overview](../README.md).
+50
View File
@@ -0,0 +1,50 @@
# Development and releases
Released applications should use a public Git tag. While changing DFTE with a sibling library, select a local `symlink://` or `file://` dependency from an ignored PlatformIO override rather than changing tracked application dependencies.
## Target pins
DFTE uses one maintained ESP32 fixture baseline:
| Test selector | pioarduino platform | Framework stack | Purpose |
| --- | --- | --- | --- |
| `esp32` | `55.03.311` | Arduino-ESP32 3.3.11 / ESP-IDF 5.5.5 | Maintained baseline |
These are test-harness fixture lanes, not DFTE package dependencies: a consuming
application owns its `platform` pin and tests the complete framework/toolchain
stack. Do not copy a compiler or toolchain package between lanes; each pinned
pioarduino platform resolves its matched framework, uploader, and toolchain.
All DFTE ESP8266 test and example environments pin framework commit `521ae60`
for the upstream Postmortem large-jump linker fix. The exact rationale and
update rule are in the shared [ESP8266 linker-workaround
note](https://github.com/alexhopeoconnor/arduino-home-assistant/blob/main/docs/ESP8266-LINKER-WORKAROUND.md).
For the pioarduino release-to-Core mapping and cache-collision diagnosis, see
[DeviceFramework's toolchain guide](https://github.com/alexhopeoconnor/DeviceFramework/blob/main/docs/TOOLCHAINS.md).
`./scripts/test.sh` uses the persistent PlatformIO Core/cache shared by the
maintained framework repositories:
`${XDG_CACHE_HOME:-$HOME/.cache}/arduino-framework-platformio/core-3.3.11`.
All maintained ESP32 lanes pin this exact graph, avoiding repeated downloads
while keeping stale global `tool-esptoolpy` metadata from shadowing the current
pioarduino uploader. Override it with `DFTE_PLATFORMIO_CORE_DIR`,
`DFTE_PLATFORMIO_PACKAGES_DIR`, and `DFTE_PLATFORMIO_CACHE_DIR` for another
disk; the script never clears that cache or pins one compiler separately.
Start a release with `bump-version.sh`. It updates package metadata and canonical installation snippets, then creates the changelog section. Replace its generated TODO with the release summary and update any behavioural documentation before running:
```bash
./scripts/bump-version.sh vMAJOR.MINOR.PATCH
# Replace the generated CHANGELOG TODO with the release summary.
./scripts/check-docs.sh
./scripts/test.sh compile --platform esp8266
./scripts/test.sh compile --platform esp32
./scripts/test.sh examples --platform esp8266
./scripts/test.sh examples --platform esp32
./scripts/prepare-release.sh vMAJOR.MINOR.PATCH --tag
```
Push the branch and annotated tag. GitHub Actions repeats the board-free compile checks, validates the package, and creates a GitHub Release from that version’s changelog section. It does not publish to the PlatformIO Registry.
Back to [documentation](README.md) · [project overview](../README.md).
+46
View File
@@ -0,0 +1,46 @@
# Getting started
DFTE renders a named root template through a `TemplateContext`. Register templates and placeholder values once, then ask the renderer for fixed-size chunks until completion.
```cpp
#include <Arduino.h>
#include <TemplateEngine.h>
static const char ROOT[] PROGMEM = R"DFTE(
<h1>%TITLE%</h1><p>Uptime: %UPTIME%</p>
)DFTE";
PlaceholderRegistry registry;
TemplateContext context;
void setup() {
Serial.begin(115200);
registry.registerProgmemData(PSTR("%TITLE%"), PSTR("DFTE Quickstart"));
registry.registerRamData(PSTR("%UPTIME%"), []() -> const char* {
static char value[16];
snprintf(value, sizeof(value), "%lus", millis() / 1000);
return value;
});
registry.registerProgmemTemplate(PSTR("%ROOT%"), ROOT);
context.setRegistry(&registry);
TemplateRenderer::initializeContext(context, PSTR("%ROOT%"));
}
void loop() {
if (TemplateRenderer::hasError(context)) {
// Report the invalid root or placeholder once in production, then stop this response.
return;
}
if (TemplateRenderer::isComplete(context)) return;
uint8_t chunk[128];
const size_t written = TemplateRenderer::renderNextChunk(context, chunk, sizeof(chunk));
if (written > 0) Serial.write(chunk, written);
}
```
`registerProgmemData()` is for static flash data. `registerRamData()` takes a getter for a value that can change as chunks are rendered. `registerProgmemTemplate()` lets a placeholder expand to another template.
For a buildable project, start with [HelloPlaceholder](../examples/HelloPlaceholder/). Next: [template language](TEMPLATE_LANGUAGE.md).
Back to [documentation](README.md) · [project overview](../README.md).
+12
View File
@@ -0,0 +1,12 @@
# DeviceFramework Template Engine (DFTE) documentation
| I want to… | Read |
| --- | --- |
| Render a first template | [Getting started](GETTING_STARTED.md) |
| Use placeholders, templates, conditions, dynamic fragments, or iterators | [Template language](TEMPLATE_LANGUAGE.md) |
| Stream a response through ESPAsyncWebServer | [Async web responses](ASYNC_WEB.md) |
| Change buffer, stack, or placeholder limits safely | [Configuration](CONFIGURATION.md) |
| Build, flash, and observe a complete example | [Examples](../examples/README.md) |
| Run checks or prepare a release | [Testing](TESTING.md) · [Development](DEVELOPMENT.md) |
Back to the [project overview](../README.md).
+30
View File
@@ -0,0 +1,30 @@
# Template language and lifecycle
Every token has the form `%NAME%`. DFTE resolves it from a `PlaceholderRegistry` according to its registered type.
| Registered type | Purpose |
| --- | --- |
| `registerProgmemData()` | Static flash-resident text |
| `registerRamData()` | Getter returning current `const char*` data |
| `registerProgmemTemplate()` | Nested PROGMEM template |
| `registerDynamicTemplate()` | Template fragment supplied at render time |
| `registerConditional()` | Choose a true, false, or skipped delegate |
| `registerIterator()` | Stream repeated item templates through an iterator handle |
## Context lifecycle
1. Populate a registry during setup.
2. Attach it to a `TemplateContext`.
3. Initialise the context with the root placeholder.
4. Call `renderNextChunk()` until complete or error.
5. Call `reset()` before reusing a completed context for another root.
Do not treat `isComplete()` as a success result by itself; check `hasError()` as well.
## Iterators
An iterator opens a handle, produces `IteratorItemView` values, then closes the handle. Handles are closed when rendering finishes, resets, stalls, or fails. The `open`, `next`, and `close` callbacks must therefore tolerate early termination.
Use [AsyncDashboardDemo](../examples/AsyncDashboardDemo/) for a complete iterator example. Use [NestedLayouts](../examples/NestedLayouts/) for partials and conditions.
Back to [documentation](README.md) · [project overview](../README.md).
+24
View File
@@ -0,0 +1,24 @@
# Testing
The PlatformIO Unity commands compile the complete DFTE test suites without uploading or executing them, so they require no attached board.
```bash
./scripts/test.sh compile --platform esp8266
./scripts/test.sh compile --platform esp32
./scripts/test.sh examples --platform esp8266
./scripts/test.sh examples --platform esp32
```
`esp32` uses pioarduino `55.03.311` (Arduino-ESP32 3.3.11 / ESP-IDF 5.5.5).
The `compile` commands compile the complete suites with `test_build_src = yes`; the example commands
compile every standalone project on each target, protecting the code that the
documentation links users to. CI runs every listed target lane on the
maintained branch and pull requests.
The ESP32 lane uses a persistent dedicated Core/cache directory so the
package-form Arduino-ESP32 uploader cannot inherit stale global Python
metadata. The test script never clears that cache.
The standalone examples are buildable PlatformIO projects; see [examples](../examples/README.md).
Back to [documentation](README.md) · [project overview](../README.md).
+2
View File
@@ -17,6 +17,8 @@ pio run -d examples/AsyncDashboardDemo -e dashboard_esp32
## Run
See the shared [examples guide](../README.md) and [async web guidance](../../docs/ASYNC_WEB.md).
1. Flash your target board (`pio run -d examples/AsyncDashboardDemo -e <env> -t upload --upload-port <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.
+3 -1
View File
@@ -24,11 +24,13 @@ build_flags =
-I$PROJECT_LIBDEPS_DIR/$PIOENV/ESPAsyncTCP/src
[env:dashboard_esp32]
platform = espressif32
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
lib_deps =
${env.lib_deps}
ESP32Async/AsyncTCP
build_flags =
${env.build_flags}
-DSOC_WIFI_SUPPORTED=1
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
-I$PROJECT_LIBDEPS_DIR/$PIOENV/AsyncTCP/src
+9 -3
View File
@@ -125,8 +125,13 @@ struct DeviceIteratorState {
DeviceIteratorState gIteratorState;
static PlaceholderEntry gDeviceOverrides[kDeviceCount][4];
static DynamicDataDescriptor gDeviceOverrideData[kDeviceCount][4];
static bool gOverridesInitialized = false;
const char* getStaticRamValue(void* userData) {
return static_cast<const char*>(userData);
}
void initializeDeviceOverrides() {
if (gOverridesInitialized) {
return;
@@ -148,9 +153,10 @@ void initializeDeviceOverrides() {
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;
gDeviceOverrideData[i][field] = {getStaticRamValue, nullptr, const_cast<char*>(values[field])};
entry.type = PlaceholderType::DYNAMIC_DATA;
entry.data = &gDeviceOverrideData[i][field];
entry.getLength = nullptr;
}
}
+2
View File
@@ -13,5 +13,7 @@ pio run -d examples/HelloPlaceholder -e example_esp8266
pio run -d examples/HelloPlaceholder -e example_esp32
```
See the shared [examples guide](../README.md) and [quickstart](../../docs/GETTING_STARTED.md).
Flash the firmware and open the serial monitor at 115200 baud to see the rendered text.
+1 -2
View File
@@ -15,6 +15,5 @@ platform_packages =
platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3
[env:example_esp32]
platform = espressif32
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
+6 -2
View File
@@ -37,15 +37,19 @@ void setup() {
while (!TemplateRenderer::isComplete(ctx) && !TemplateRenderer::hasError(ctx)) {
size_t written = TemplateRenderer::renderNextChunk(ctx, buffer, sizeof(buffer));
if (!written) {
Serial.println(F("\nRendering stalled before completion."));
break;
}
Serial.write(buffer, written);
}
Serial.println(F("\nRendering complete."));
if (TemplateRenderer::hasError(ctx)) {
Serial.println(F("\nRendering failed."));
} else if (TemplateRenderer::isComplete(ctx)) {
Serial.println(F("\nRendering complete."));
}
}
void loop() {
// Nothing else to do in the basic example.
}
+2
View File
@@ -14,5 +14,7 @@ pio run -d examples/NestedLayouts -e example_esp8266
pio run -d examples/NestedLayouts -e example_esp32
```
See the shared [examples guide](../README.md) and [template-language guide](../../docs/TEMPLATE_LANGUAGE.md).
Open the serial monitor at 115200 baud to inspect the rendered output.
+1 -2
View File
@@ -15,6 +15,5 @@ platform_packages =
platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3
[env:example_esp32]
platform = espressif32
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
+23 -11
View File
@@ -103,6 +103,11 @@ struct SubsystemIteratorState {
SubsystemIteratorState iteratorState;
PlaceholderEntry subsystemOverrides[SUBSYSTEM_COUNT][3];
DynamicDataDescriptor subsystemOverrideData[SUBSYSTEM_COUNT][3];
const char* getStaticRamValue(void* userData) {
return static_cast<const char*>(userData);
}
void initialiseOverrides() {
for (size_t i = 0; i < SUBSYSTEM_COUNT; ++i) {
@@ -110,21 +115,24 @@ void initialiseOverrides() {
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;
subsystemOverrideData[i][0] = {getStaticRamValue, nullptr, const_cast<char*>(status.name)};
name.type = PlaceholderType::DYNAMIC_DATA;
name.data = &subsystemOverrideData[i][0];
name.getLength = nullptr;
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;
subsystemOverrideData[i][1] = {getStaticRamValue, nullptr, const_cast<char*>(status.detail)};
detail.type = PlaceholderType::DYNAMIC_DATA;
detail.data = &subsystemOverrideData[i][1];
detail.getLength = nullptr;
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;
subsystemOverrideData[i][2] = {getStaticRamValue, nullptr, const_cast<char*>(status.severityClass)};
severity.type = PlaceholderType::DYNAMIC_DATA;
severity.data = &subsystemOverrideData[i][2];
severity.getLength = nullptr;
}
}
@@ -188,11 +196,16 @@ void renderToSerial() {
while (!TemplateRenderer::isComplete(ctx) && !TemplateRenderer::hasError(ctx)) {
size_t written = TemplateRenderer::renderNextChunk(ctx, buffer, sizeof(buffer));
if (!written) {
Serial.println(F("\nRendering stalled before completion."));
break;
}
Serial.write(buffer, written);
}
Serial.println();
if (TemplateRenderer::hasError(ctx)) {
Serial.println(F("\nRendering failed."));
} else if (TemplateRenderer::isComplete(ctx)) {
Serial.println();
}
}
} // namespace
@@ -210,4 +223,3 @@ void setup() {
void loop() {
// Nothing to do in loop for this example.
}
+26
View File
@@ -0,0 +1,26 @@
# DFTE examples
Every example is a standalone PlatformIO project. Build, upload, and monitor from the repository root:
```bash
pio run -d examples/HelloPlaceholder -e example_esp8266
pio run -d examples/HelloPlaceholder -e example_esp8266 -t upload
pio device monitor -d examples/HelloPlaceholder -e example_esp8266
```
Choose the corresponding ESP32 environment where provided. The examples use the checked-out DFTE source, so they double as practical integration checks for this repository.
The `example_esp32` / `dashboard_esp32` environments use Arduino-ESP32 3.3.11.
A consuming application should select and pin its own complete PlatformIO
platform stack.
| Example | Start here when you want to… |
| --- | --- |
| [HelloPlaceholder](HelloPlaceholder/) | understand the smallest registry/context/chunk flow over serial |
| [NestedLayouts](NestedLayouts/) | compose a page with partials, conditions, and repeating data |
| [StreamingAsync](StreamingAsync/) | serve one streamed page through an asynchronous web server |
| [AsyncDashboardDemo](AsyncDashboardDemo/) | inspect a richer browser-facing dashboard without buffering the response |
The SoftAP examples print their network names and passwords to serial. They are self-contained browser demonstrations, not Wi-Fi provisioning implementations.
Back to [DFTE documentation](../docs/README.md) · [project overview](../README.md).
+13 -7
View File
@@ -1,18 +1,24 @@
# 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:
This ESPAsyncWebServer example exposes a captive portal over a SoftAP and streams a DFTE template directly to each HTTP response. It demonstrates:
- 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
- one request-scoped `TemplateContext` per response;
- a PROGMEM page with shared CSS/header/footer snippets;
- runtime getters for uptime and connected-station count;
- a captive-portal DNS redirect for browsers that support it.
## Build
```
```bash
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/`.
## Run
1. Flash your target with `pio run -d examples/StreamingAsync -e <env> -t upload --upload-port <port>`.
2. Open the serial monitor at 115200 baud and confirm the SoftAP credentials.
3. Connect to `DFTE-Portal-8266` or `DFTE-Portal-ESP32` using password `dfte-demo`.
4. Browse to `http://192.168.4.1/` if the captive portal does not open automatically.
See the shared [examples guide](../README.md) and [async web guidance](../../docs/ASYNC_WEB.md).
+3 -2
View File
@@ -24,12 +24,13 @@ build_flags =
-I$PROJECT_LIBDEPS_DIR/$PIOENV/ESPAsyncTCP/src
[env:example_esp32]
platform = espressif32
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
lib_deps =
${env.lib_deps}
ESP32Async/AsyncTCP
build_flags =
${env.build_flags}
-DSOC_WIFI_SUPPORTED=1
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
-I$PROJECT_LIBDEPS_DIR/$PIOENV/AsyncTCP/src
+1 -5
View File
@@ -10,10 +10,6 @@
// When DeviceFrameworkConfig is included, extern variables are declared with CONFIG_* names
// We use DFTE_* internal names for compile-time constants (constexpr) and default parameter values
// NEVER define CONFIG_* macros here to avoid conflicts with extern declarations in DeviceFrameworkConfig
#ifndef DFTE_PLACEHOLDER_NAME_SIZE_DEFAULT
#define DFTE_PLACEHOLDER_NAME_SIZE_DEFAULT 24
#endif
#ifndef DFTE_MAX_PLACEHOLDERS_DEFAULT
#define DFTE_MAX_PLACEHOLDERS_DEFAULT 16
#endif
@@ -119,7 +115,7 @@ public:
static size_t getDynamicTemplateLength(const DynamicTemplateDescriptor* descriptor, const char* templateData);
private:
static constexpr uint16_t MAX_PLACEHOLDER_NAME_SIZE = DFTE_PLACEHOLDER_NAME_SIZE;
static constexpr uint16_t MAX_PLACEHOLDER_NAME_SIZE = DFTE_PLACEHOLDER_NAME_CAPACITY;
PlaceholderEntry* placeholders; // Dynamically allocated array
uint16_t maxPlaceholders; // Configurable size
+26 -13
View File
@@ -16,11 +16,9 @@
#define DFTE_BUFFER_SIZE_DEFAULT 512
#endif
#ifndef DFTE_PLACEHOLDER_NAME_SIZE_DEFAULT
#define DFTE_PLACEHOLDER_NAME_SIZE_DEFAULT 24
#endif
// Layout-affecting settings must be supplied through build-wide DFTE_* flags.
// These are default capacities for a general-purpose standalone context. A
// caller can select smaller per-context capacities when its template topology
// is known, without changing the public object layout across translation units.
#ifndef DFTE_MAX_STACK_DEPTH
#define DFTE_MAX_STACK_DEPTH DFTE_MAX_STACK_DEPTH_DEFAULT
#endif
@@ -43,20 +41,26 @@ public:
// Context state
State state;
// Unified rendering stack
// Standalone defaults retained for callers that use the no-argument
// constructor and for tests that exercise the maximum supported depth.
static constexpr int MAX_RENDERING_DEPTH = DFTE_MAX_STACK_DEPTH;
RenderingContext renderingStack[MAX_RENDERING_DEPTH];
static const size_t BUFFER_SIZE = DFTE_BUFFER_SIZE;
// Per-context storage avoids layout-affecting build flags. This also lets
// constrained applications request only the stack and read buffer they
// actually need for a specific response.
RenderingContext* renderingStack;
size_t maxRenderingDepth;
int renderingDepth;
// Current placeholder being built (only valid during BUILDING_PLACEHOLDER state)
// Allocated to configured size - matches CONFIG_templatePlaceholderNameSize when DeviceFramework is present
char placeholderName[DFTE_PLACEHOLDER_NAME_SIZE];
// Fixed ABI-stable placeholder token storage.
char placeholderName[DFTE_PLACEHOLDER_NAME_CAPACITY];
size_t placeholderPos;
// Centralized buffer management
// Allocated to configured size - matches CONFIG_templateBufferSize when DeviceFramework is present
static const size_t BUFFER_SIZE = DFTE_BUFFER_SIZE;
uint8_t readBuffer[BUFFER_SIZE];
uint8_t* readBuffer;
size_t readBufferSize;
size_t bufferPos;
size_t bufferLen;
size_t bufferOffset;
@@ -68,8 +72,17 @@ public:
size_t totalBytesProcessed;
unsigned long startTime;
DeviceFrameworkTemplateContext();
explicit DeviceFrameworkTemplateContext(
size_t maxDepth = MAX_RENDERING_DEPTH,
size_t bufferSize = BUFFER_SIZE);
~DeviceFrameworkTemplateContext();
DeviceFrameworkTemplateContext(const DeviceFrameworkTemplateContext&) = delete;
DeviceFrameworkTemplateContext& operator=(const DeviceFrameworkTemplateContext&) = delete;
void reset();
bool isReady() const { return renderingStack != nullptr && readBuffer != nullptr; }
size_t getMaxRenderingDepth() const { return maxRenderingDepth; }
size_t getBufferSize() const { return readBufferSize; }
// Unified stack management methods
bool pushContext(RenderingContextType type, const char* name);
+4 -13
View File
@@ -3,18 +3,9 @@
#include <Arduino.h>
// Fallback defaults when DeviceFrameworkConfig is not available (standalone usage)
// Always use internal macro names (DFTE_*) to avoid conflicts with DeviceFrameworkConfig extern declarations
#ifndef DFTE_PLACEHOLDER_NAME_SIZE_DEFAULT
#define DFTE_PLACEHOLDER_NAME_SIZE_DEFAULT 24
#endif
// Layout-affecting settings must be passed as build-wide DFTE_* flags so every
// translation unit sees the same object layout. DeviceFramework runtime config is
// intentionally not used for fixed-size storage.
#ifndef DFTE_PLACEHOLDER_NAME_SIZE
#define DFTE_PLACEHOLDER_NAME_SIZE DFTE_PLACEHOLDER_NAME_SIZE_DEFAULT
#endif
// Placeholder-name storage has a fixed, ABI-stable capacity.
// DFTE_PLACEHOLDER_NAME_SIZE overrides are intentionally ignored.
static constexpr size_t DFTE_PLACEHOLDER_NAME_CAPACITY = 24;
/**
* Placeholder types for template substitution
@@ -82,7 +73,7 @@ struct ConditionalDescriptor {
*/
struct PlaceholderEntry {
// Allocated to configured size - matches CONFIG_templatePlaceholderNameSize when DeviceFramework is present
char name[DFTE_PLACEHOLDER_NAME_SIZE];
char name[DFTE_PLACEHOLDER_NAME_CAPACITY];
PlaceholderType type;
const void* data;
PlaceholderLengthGetter getLength;
+2 -1
View File
@@ -24,7 +24,8 @@
* DeviceFrameworkTemplateRenderer::initializeContext(ctx, my_template);
*
* uint8_t buffer[512];
* while (!DeviceFrameworkTemplateRenderer::isComplete(ctx)) {
* while (!DeviceFrameworkTemplateRenderer::isComplete(ctx) &&
* !DeviceFrameworkTemplateRenderer::hasError(ctx)) {
* size_t written = DeviceFrameworkTemplateRenderer::renderNextChunk(ctx, buffer, sizeof(buffer));
* server->sendContent((const char*)buffer, written);
* }
+116
View File
@@ -85,6 +85,103 @@ AsyncWebServerResponse* beginSafeChunkedResponse(AsyncWebServerRequest* request,
});
}
// A bounded embedded server can own response state in a fixed slot instead of
// allocating a shared control block for every request. The caller guarantees
// that state remains valid until release() is called. release() must be safe
// to call more than once because a completed request can later disconnect.
template <typename StateT, typename FillFn, typename IsDoneFn, typename ReleaseFn>
AsyncWebServerResponse* beginBorrowedChunkedResponse(AsyncWebServerRequest* request,
const char* contentType,
StateT* state,
FillFn fill,
IsDoneFn isDone,
ReleaseFn release) {
request->onDisconnect([state, release]() mutable {
if (state != nullptr) {
release(*state);
}
});
return request->beginChunkedResponse(contentType,
[state, fill, isDone, release](uint8_t* buffer, size_t maxLen, size_t index) mutable -> size_t {
if (state == nullptr) {
return 0;
}
if (isDone(*state)) {
release(*state);
return 0;
}
if (maxLen == 0) {
return RESPONSE_TRY_AGAIN;
}
size_t written = fill(*state, buffer, maxLen, index);
if (written == RESPONSE_TRY_AGAIN) {
return RESPONSE_TRY_AGAIN;
}
if (written > 0) {
return written;
}
if (isDone(*state)) {
release(*state);
return 0;
}
yieldForChunkRetry();
return RESPONSE_TRY_AGAIN;
});
}
// Variant for dynamically allocated state when the owner also needs an
// idempotent completion notification (for example, returning a bounded web
// response permit). The original overload intentionally remains available for
// callers that only need shared ownership.
template <typename StateT, typename FillFn, typename IsDoneFn, typename ReleaseFn>
AsyncWebServerResponse* beginSafeChunkedResponse(AsyncWebServerRequest* request,
const char* contentType,
const std::shared_ptr<StateT>& sharedState,
FillFn fill,
IsDoneFn isDone,
ReleaseFn release) {
request->onDisconnect([state = sharedState, release]() mutable {
release();
state.reset();
});
return request->beginChunkedResponse(contentType,
[state = sharedState, fill, isDone, release](uint8_t* buffer, size_t maxLen, size_t index) mutable -> size_t {
if (!state) {
return 0;
}
if (maxLen == 0) {
return RESPONSE_TRY_AGAIN;
}
size_t written = fill(*state, buffer, maxLen, index);
if (written == RESPONSE_TRY_AGAIN) {
return RESPONSE_TRY_AGAIN;
}
if (written > 0) {
return written;
}
if (isDone(*state)) {
release();
state.reset();
return 0;
}
yieldForChunkRetry();
return RESPONSE_TRY_AGAIN;
});
}
template <typename StateT, typename FillFn, typename IsDoneFn>
AsyncWebServerResponse* beginSafeChunkedResponse(AsyncWebServerRequest* request,
const String& contentType,
@@ -128,6 +225,25 @@ AsyncWebServerResponse* beginSafeChunkedResponse(AsyncWebServerRequest* request,
});
}
template <typename ContextT, typename ReleaseFn>
AsyncWebServerResponse* beginSafeTemplateResponse(AsyncWebServerRequest* request,
const char* contentType,
const std::shared_ptr<ContextT>& sharedContext,
unsigned maxNoProgressRetries,
ReleaseFn release) {
return beginSafeChunkedResponse(
request,
contentType,
sharedContext,
[maxNoProgressRetries](ContextT& context, uint8_t* buffer, size_t maxLen, size_t /*index*/) -> size_t {
return renderTemplateChunkWithRetries(context, buffer, maxLen, maxNoProgressRetries);
},
[](const ContextT& context) -> bool {
return isTemplateTerminal(context);
},
release);
}
template <typename ContextT>
AsyncWebServerResponse* beginSafeTemplateResponse(AsyncWebServerRequest* request,
const char* contentType,
+12 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DeviceFrameworkTemplateEngine",
"version": "1.0.1",
"version": "1.2.1",
"description": "Memory-efficient streaming template engine for ESP8266/ESP32 with chunked rendering support. Designed for embedded web interfaces with PROGMEM template support.",
"keywords": [
"template",
@@ -34,5 +34,16 @@
"repository": {
"type": "git",
"url": "https://github.com/alexhopeoconnor/DFTE.git"
},
"export": {
"include": [
"include",
"src",
"examples",
"README.md",
"CHANGELOG.md",
"LICENSE",
"library.json"
]
}
}
+4 -1
View File
@@ -9,7 +9,10 @@ test_framework = unity
test_build_src = yes
[env:test_template_engine_esp32]
platform = espressif32@6.13.0
; Arduino-ESP32 3.3.11 / ESP-IDF 5.5.5 is the maintained ESP32 baseline.
; Pin the complete pioarduino platform so its framework and toolchain stay
; matched; PlatformIO must not select one from an unrelated package cache.
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = nodemcu-32s
framework = arduino
test_framework = unity
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 vMAJOR.MINOR.PATCH" >&2
exit 2
}
tag="${1:-}"
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version="${tag#v}"
repo_url="https://github.com/alexhopeoconnor/DFTE.git"
reference_files=(README.md)
current_version="$(sed -n 's/.*"version": "\([^"]*\)".*/\1/p' "$root/library.json" | head -n 1)"
[[ "$current_version" != "$version" ]] || {
echo "library.json already declares $version; choose a new version." >&2
exit 1
}
grep -q "^## $version$" "$root/CHANGELOG.md" && {
echo "CHANGELOG.md already has a $version section; choose a new version." >&2
exit 1
}
sed -i -E '0,/"version": "[0-9]+\.[0-9]+\.[0-9]+"/s//"version": "'"$version"'"/' "$root/library.json"
for file in "${reference_files[@]}"; do
sed -i -E "s|${repo_url}#v[0-9]+\.[0-9]+\.[0-9]+|${repo_url}#v${version}|g" "$root/$file"
done
temp_file="$(mktemp)"
trap 'rm -f "$temp_file"' EXIT
{
IFS= read -r changelog_heading < "$root/CHANGELOG.md"
[[ "$changelog_heading" == "# Changelog" ]] || {
echo "CHANGELOG.md must begin with # Changelog" >&2
exit 1
}
printf '%s\n\n## %s\n\n- TODO: Describe this release.\n' "$changelog_heading" "$version"
tail -n +2 "$root/CHANGELOG.md"
} > "$temp_file"
mv "$temp_file" "$root/CHANGELOG.md"
echo "Updated DFTE declarations and canonical install references to $tag."
echo "Replace the generated changelog TODO with the release summary, then run scripts/check-docs.sh and scripts/prepare-release.sh $tag."
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
required=(
README.md CHANGELOG.md
docs/README.md docs/GETTING_STARTED.md docs/TEMPLATE_LANGUAGE.md
docs/ASYNC_WEB.md docs/CONFIGURATION.md docs/TESTING.md docs/DEVELOPMENT.md
examples/README.md
)
check_cpp_fence_scope() {
local markdown="$1"
awk '
function brace_delta(line, copy) {
copy = line
return gsub(/\{/, "{", copy) - gsub(/\}/, "}", copy)
}
/^```cpp[[:space:]]*$/ { in_cpp = 1; depth = 0; next }
in_cpp && /^```[[:space:]]*$/ { in_cpp = 0; next }
in_cpp {
line = $0
sub(/^[[:space:]]+/, "", line)
if (depth == 0 &&
(line ~ /^(if|for|while|switch)[[:space:]]*\(/ ||
line ~ /^[A-Za-z_][A-Za-z0-9_:]*::[A-Za-z0-9_]+[[:space:]]*\(/ ||
line ~ /^[A-Za-z_][A-Za-z0-9_]*\./ ||
line ~ /^[A-Za-z_][A-Za-z0-9_]*[[:space:]]*\(/)) {
printf "%s:%d: C++ expression appears at namespace scope; wrap it in a function.\n", FILENAME, FNR > "/dev/stderr"
failed = 1
}
depth += brace_delta($0)
}
END { exit failed }
' "$markdown"
}
for path in "${required[@]}"; do
[[ -f "$root/$path" ]] || { echo "Missing required documentation: $path" >&2; exit 1; }
done
while IFS= read -r -d '' markdown; do
while IFS= read -r target; do
[[ -z "$target" || "$target" == \#* || "$target" == http://* || "$target" == https://* || "$target" == mailto:* ]] && continue
target="${target%%#*}"
case "$target" in
/*) candidate="$root/${target#/}" ;;
*) candidate="$(dirname "$markdown")/$target" ;;
esac
[[ -e "$candidate" ]] || { echo "Broken relative link in ${markdown#$root/}: $target" >&2; exit 1; }
done < <(sed -nE 's/.*\]\(([^ )]+)( "[^"]*")?\).*/\1/p' "$markdown")
done < <(find "$root" -path "$root/.git" -prune -o -path '*/.pio' -prune -o -name '*.md' -type f -print0)
while IFS= read -r markdown; do
check_cpp_fence_scope "$markdown"
done < <(find "$root" -path "$root/.git" -prune -o -path '*/.pio' -prune -o -name '*.md' -type f -print)
while IFS= read -r example; do
for required in README.md platformio.ini; do
[[ -f "$example/$required" ]] || { echo "Incomplete example: ${example#$root/} is missing $required" >&2; exit 1; }
done
find "$example/src" -type f \( -name '*.ino' -o -name '*.cpp' \) -print -quit | grep -q . || {
echo "Incomplete example: ${example#$root/} has no source" >&2
exit 1
}
done < <(find "$root/examples" -mindepth 2 -maxdepth 2 -type f -name platformio.ini -printf '%h\n' | sort)
while IFS= read -r markdown; do
(( $(grep -Ec '^[[:space:]]*```' "$markdown") % 2 == 0 )) || {
echo "Unclosed code fence in ${markdown#$root/}" >&2
exit 1
}
done < <(find "$root" -path "$root/.git" -prune -o -path '*/.pio' -prune -o -name '*.md' -type f -print)
echo "Documentation links and required files passed"
+26 -1
View File
@@ -27,10 +27,35 @@ if [[ -f "$root/library.properties" ]]; then
fi
fi
if ! grep -q "^## ${version}$" "$root/CHANGELOG.md"; then
echo "CHANGELOG.md is missing a ## ${version} section" >&2
exit 1
fi
if awk -v heading="## $version" '
$0 == heading { found = 1; next }
found && /^## / { exit }
found { print }
' "$root/CHANGELOG.md" | grep -Fq 'TODO: Describe this release.'; then
echo "CHANGELOG.md still has the generated TODO for $version" >&2
exit 1
fi
repo_url="https://github.com/alexhopeoconnor/DFTE.git"
validate_reference() {
local file="$1"
local reference_count
reference_count="$(grep -F "$repo_url#v" "$root/$file" | wc -l)"
[[ "$reference_count" -eq 1 ]] || { echo "$file must contain exactly one canonical release reference" >&2; exit 1; }
grep -Fq "$repo_url#$tag" "$root/$file" || { echo "$file does not reference $tag" >&2; exit 1; }
}
validate_reference README.md
git -C "$root" diff --check
package_dir="$(mktemp -d)"
trap 'rm -rf "$package_dir"' EXIT
pio pkg pack "$root" --output "$package_dir/package.tar.gz" >/dev/null
echo "Validated PlatformIO package for $tag"
echo "Validated release metadata and PlatformIO package for $tag"
if [[ "${2:-}" == "--tag" ]]; then
git -C "$root" diff --quiet
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 vMAJOR.MINOR.PATCH" >&2
exit 2
}
tag="${1:-}"
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version="${tag#v}"
changelog="$root/CHANGELOG.md"
output="$(mktemp)"
trap 'rm -f "$output"' EXIT
awk -v version="$version" '
$0 == "## " version { capture = 1; next }
capture && /^## / { exit }
capture { print }
' "$changelog" > "$output"
if [[ ! -s "$output" ]]; then
echo "No release notes found for $tag in CHANGELOG.md" >&2
exit 1
fi
printf '%s\n\n' "# DeviceFrameworkTemplateEngine $tag"
cat "$output"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 compile|examples --platform esp8266|esp32" >&2
exit 2
}
[[ $# -eq 3 && ( "${1:-}" == "compile" || "${1:-}" == "examples" ) && "${2:-}" == "--platform" ]] || usage
case "${3:-}" in
esp8266|esp32) platform="$3" ;;
*) usage ;;
esac
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
pio_for_platform() {
if [[ "$platform" != "esp32" ]]; then
pio "$@"
return
fi
# Keep the maintained Core 3.3.11 package-form uploader in one persistent,
# repository-owned cache. This avoids unrelated global package metadata;
# it is never cleared by this script.
local core_dir packages_dir cache_dir
core_dir="${DFTE_PLATFORMIO_CORE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/arduino-framework-platformio/core-3.3.11}"
packages_dir="${DFTE_PLATFORMIO_PACKAGES_DIR:-$core_dir/packages}"
cache_dir="${DFTE_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
PLATFORMIO_CORE_DIR="$core_dir" PLATFORMIO_PACKAGES_DIR="$packages_dir" \
PLATFORMIO_CACHE_DIR="$cache_dir" pio "$@"
}
case "$1" in
compile)
case "$platform" in
esp8266) test_environment="test_template_engine_8266" ;;
esp32) test_environment="test_template_engine_esp32" ;;
esac
pio_for_platform test -d "$root" -e "$test_environment" --without-uploading --without-testing
echo "DFTE compile check passed for $platform"
exit 0
;;
esac
suffix="$platform"
mapfile -t examples < <(find "$root/examples" -mindepth 2 -maxdepth 2 -type f -name platformio.ini -printf '%h\n' | sort)
if (( ${#examples[@]} == 0 )); then
echo "No example projects found" >&2
exit 1
fi
for example in "${examples[@]}"; do
env_name="example_$suffix"
[[ "$(basename "$example")" == "AsyncDashboardDemo" ]] && env_name="dashboard_$suffix"
pio_for_platform run -d "$example" -e "$env_name" </dev/null
done
echo "DFTE examples compile check passed for $platform"
+46 -10
View File
@@ -1,18 +1,49 @@
#include "DeviceFrameworkTemplateContext.h"
#include "DeviceFrameworkTemplateEngineDebug.h"
#include <new>
DeviceFrameworkTemplateContext::DeviceFrameworkTemplateContext()
: state(TemplateRenderState::TEXT), renderingDepth(0), placeholderPos(0),
bufferPos(0), bufferLen(0), bufferOffset(0),
registry(nullptr),
DeviceFrameworkTemplateContext::DeviceFrameworkTemplateContext(size_t maxDepth, size_t bufferSize)
: state(TemplateRenderState::TEXT), renderingStack(nullptr), maxRenderingDepth(maxDepth),
renderingDepth(0), placeholderPos(0), readBuffer(nullptr), readBufferSize(bufferSize),
bufferPos(0), bufferLen(0), bufferOffset(0), registry(nullptr),
totalBytesProcessed(0), startTime(0) {
memset(placeholderName, 0, sizeof(placeholderName));
for (int i = 0; i < MAX_RENDERING_DEPTH; ++i) {
renderingStack[i] = RenderingContext();
if (maxRenderingDepth == 0 || readBufferSize == 0) {
state = TemplateRenderState::ERROR;
return;
}
renderingStack = new (std::nothrow) RenderingContext[maxRenderingDepth];
readBuffer = new (std::nothrow) uint8_t[readBufferSize];
if (!isReady()) {
delete[] renderingStack;
delete[] readBuffer;
renderingStack = nullptr;
readBuffer = nullptr;
state = TemplateRenderState::ERROR;
}
}
DeviceFrameworkTemplateContext::~DeviceFrameworkTemplateContext() {
while (renderingDepth > 0) {
popContext();
}
delete[] readBuffer;
delete[] renderingStack;
}
void DeviceFrameworkTemplateContext::reset() {
if (!isReady()) {
state = TemplateRenderState::ERROR;
return;
}
// An interrupted render may own an iterator handle. Pop through the stack
// before overwriting it so every descriptor receives its close callback.
while (renderingDepth > 0) {
popContext();
}
state = TemplateRenderState::TEXT;
renderingDepth = 0;
placeholderPos = 0;
@@ -22,14 +53,14 @@ void DeviceFrameworkTemplateContext::reset() {
totalBytesProcessed = 0;
startTime = millis();
memset(placeholderName, 0, sizeof(placeholderName));
for (int i = 0; i < MAX_RENDERING_DEPTH; ++i) {
for (size_t i = 0; i < maxRenderingDepth; ++i) {
renderingStack[i] = RenderingContext();
}
}
// Unified stack management methods
bool DeviceFrameworkTemplateContext::pushContext(RenderingContextType type, const char* name) {
if (renderingDepth >= MAX_RENDERING_DEPTH) {
if (!isReady() || static_cast<size_t>(renderingDepth) >= maxRenderingDepth) {
DFTE_LOG_ERROR("Rendering stack overflow! Depth=" + String(renderingDepth));
state = TemplateRenderState::ERROR;
return false;
@@ -142,7 +173,7 @@ RenderingContextType DeviceFrameworkTemplateContext::getCurrentContextType() con
}
bool DeviceFrameworkTemplateContext::isComplete() const {
return state == TemplateRenderState::COMPLETE || state == TemplateRenderState::ERROR;
return state == TemplateRenderState::COMPLETE;
}
bool DeviceFrameworkTemplateContext::hasError() const {
@@ -183,6 +214,11 @@ String DeviceFrameworkTemplateContext::getStackTrace() const {
}
bool DeviceFrameworkTemplateContext::refillBuffer() {
if (!isReady()) {
state = TemplateRenderState::ERROR;
return false;
}
RenderingContext* currentCtx = getCurrentContext();
if (!currentCtx || currentCtx->type != RenderingContextType::TEMPLATE) {
return false;
@@ -193,7 +229,7 @@ bool DeviceFrameworkTemplateContext::refillBuffer() {
return false;
}
bufferLen = min(BUFFER_SIZE, templateCtx.templateLen - templateCtx.position);
bufferLen = min(readBufferSize, templateCtx.templateLen - templateCtx.position);
if (bufferLen > 0) {
if (templateCtx.isProgmem) {
+13
View File
@@ -354,6 +354,9 @@ DeviceFrameworkTemplateRenderer::RenderOutcome DeviceFrameworkTemplateRenderer::
if (!applyStackCommands(ctx, outcome)) {
ctx.state = TemplateRenderState::ERROR;
while (ctx.renderingDepth > 0) {
ctx.popContext();
}
return makeError();
}
@@ -369,6 +372,13 @@ DeviceFrameworkTemplateRenderer::RenderOutcome DeviceFrameworkTemplateRenderer::
outcome.finished = (ctx.state == TemplateRenderState::COMPLETE);
outcome.errored = (ctx.state == TemplateRenderState::ERROR);
if (outcome.errored) {
// Error paths can leave a live iterator below the active context.
// Pop all contexts so its close handler is called exactly once.
while (ctx.renderingDepth > 0) {
ctx.popContext();
}
}
return outcome;
}
@@ -875,6 +885,9 @@ size_t DeviceFrameworkTemplateRenderer::renderNextChunk(DeviceFrameworkTemplateC
DFTE_LOG_WARN("Maximum iterations reached in renderNextChunk");
if (consecutiveNoProgressIterations >= 3 && !ctx.isComplete() && !ctx.hasError()) {
ctx.state = TemplateRenderState::ERROR;
while (ctx.renderingDepth > 0) {
ctx.popContext();
}
}
}
+1
View File
@@ -74,6 +74,7 @@ TestCase tests[] = {
TEST_ENTRY(test_template_renderer_iterator_dynamic_items),
TEST_ENTRY(test_template_renderer_iterator_error_cleanup),
TEST_ENTRY(test_template_renderer_iterator_stall_guard),
TEST_ENTRY(test_template_renderer_iterator_reset_and_destruction_cleanup),
// Group 4: Integration Tests
TEST_ENTRY(test_integration_full_rendering),
+1
View File
@@ -45,6 +45,7 @@ void test_template_renderer_iterator_empty();
void test_template_renderer_iterator_dynamic_items();
void test_template_renderer_iterator_error_cleanup();
void test_template_renderer_iterator_stall_guard();
void test_template_renderer_iterator_reset_and_destruction_cleanup();
// Group 4: Integration Tests
void test_integration_full_rendering();
@@ -285,28 +285,22 @@ void test_edge_cases_stress() {
// Test stack depth near limit
registry.clear();
// A nested template consumes a placeholder frame plus a template frame.
// Seven nested templates fit within the default 16-frame rendering stack.
registry.registerProgmemTemplate("%L2%", deep_nest_level2);
registry.registerProgmemTemplate("%L3%", deep_nest_level3);
registry.registerProgmemTemplate("%L4%", deep_nest_level4);
registry.registerProgmemTemplate("%L5%", deep_nest_level5);
registry.registerProgmemTemplate("%L6%", deep_nest_level6);
registry.registerProgmemTemplate("%L7%", deep_nest_level7);
registry.registerProgmemTemplate("%L8%", deep_nest_level8);
registry.registerProgmemTemplate("%L9%", deep_nest_level9);
registry.registerProgmemTemplate("%L10%", deep_nest_level10);
registry.registerProgmemTemplate("%L11%", deep_nest_level11);
registry.registerProgmemTemplate("%L12%", deep_nest_level12);
registry.registerProgmemTemplate("%L13%", deep_nest_level13);
registry.registerProgmemTemplate("%L14%", deep_nest_level14);
registry.registerProgmemTemplate("%L15%", deep_nest_level15);
registry.registerProgmemTemplate("%L16%", deep_nest_level16);
registry.registerProgmemTemplate("%L8%", deep_nest_level16);
ctx.reset();
ctx.setRegistry(&registry);
TemplateRenderer::initializeContext(ctx, deep_nest_level1);
String result3 = captureRenderedOutput(ctx);
TEST_ASSERT_TRUE_MESSAGE(result3.length() > 0, "Should render deep nested template");
TEST_ASSERT_FALSE_MESSAGE(ctx.hasError(), "Deep nesting within the frame limit must not error");
TEST_ASSERT_TRUE_MESSAGE(TemplateRenderer::isComplete(ctx),
"Should complete deep nested template");
TEST_ASSERT_LESS_OR_EQUAL_MESSAGE(TemplateContext::MAX_RENDERING_DEPTH, ctx.renderingDepth,
@@ -19,6 +19,27 @@ void test_template_context_initialization() {
TEST_ASSERT_EQUAL_MESSAGE(0, ctx.bufferPos, "Initial bufferPos should be 0");
TEST_ASSERT_EQUAL_MESSAGE(0, ctx.bufferLen, "Initial bufferLen should be 0");
TEST_ASSERT_NULL_MESSAGE(ctx.registry, "Initial registry should be null");
TEST_ASSERT_TRUE_MESSAGE(ctx.isReady(), "Default context storage should allocate");
TEST_ASSERT_EQUAL_UINT32(TemplateContext::MAX_RENDERING_DEPTH, ctx.getMaxRenderingDepth());
TEST_ASSERT_EQUAL_UINT32(TemplateContext::BUFFER_SIZE, ctx.getBufferSize());
TemplateContext constrained(3, 64);
TEST_ASSERT_TRUE_MESSAGE(constrained.isReady(), "Constrained context storage should allocate");
TEST_ASSERT_EQUAL_UINT32(3, constrained.getMaxRenderingDepth());
TEST_ASSERT_EQUAL_UINT32(64, constrained.getBufferSize());
TEST_ASSERT_TRUE_MESSAGE(constrained.pushContext(RenderingContextType::TEMPLATE, "%ONE%"),
"First constrained stack frame should fit");
TEST_ASSERT_TRUE_MESSAGE(constrained.pushContext(RenderingContextType::TEMPLATE, "%TWO%"),
"Second constrained stack frame should fit");
TEST_ASSERT_TRUE_MESSAGE(constrained.pushContext(RenderingContextType::TEMPLATE, "%THREE%"),
"Third constrained stack frame should fit");
TEST_ASSERT_FALSE_MESSAGE(constrained.pushContext(RenderingContextType::TEMPLATE, "%FOUR%"),
"A constrained context must reject stack overflow");
TEST_ASSERT_TRUE_MESSAGE(constrained.hasError(), "Constrained stack overflow should be reported");
TemplateContext invalid(0, 64);
TEST_ASSERT_FALSE_MESSAGE(invalid.isReady(), "A zero-depth context must fail safely");
TEST_ASSERT_TRUE_MESSAGE(invalid.hasError(), "An invalid context must report an error");
// Test reset
ctx.pushContext(RenderingContextType::TEMPLATE, "TEST");
@@ -38,7 +59,7 @@ void test_template_context_initialization() {
ctx.state = TemplateRenderState::COMPLETE;
TEST_ASSERT_TRUE_MESSAGE(ctx.isComplete(), "Should be complete when state is COMPLETE");
ctx.state = TemplateRenderState::ERROR;
TEST_ASSERT_TRUE_MESSAGE(ctx.isComplete(), "Should be complete when state is ERROR");
TEST_ASSERT_FALSE_MESSAGE(ctx.isComplete(), "An error must not report successful completion");
// Test hasError
ctx.state = TemplateRenderState::TEXT;
@@ -310,7 +331,7 @@ void test_template_context_state() {
TEST_ASSERT_TRUE_MESSAGE(ctx.isComplete(), "COMPLETE should be complete");
ctx.state = TemplateRenderState::ERROR;
TEST_ASSERT_TRUE_MESSAGE(ctx.isComplete(), "ERROR should be complete");
TEST_ASSERT_FALSE_MESSAGE(ctx.isComplete(), "ERROR should not be complete");
// Test hasError for all states
ctx.state = TemplateRenderState::TEXT;
@@ -422,6 +422,7 @@ static const char PROGMEM stalledIteratorWrapperTemplate[] = "Start:%STALLING_LI
struct StalledIteratorState {
size_t calls;
bool closeCalled;
};
static StalledIteratorState stalledIteratorState;
@@ -430,6 +431,7 @@ static void* stalledIteratorOpen(void* userData) {
StalledIteratorState* state = static_cast<StalledIteratorState*>(userData);
if (state) {
state->calls = 0;
state->closeCalled = false;
}
return state;
}
@@ -449,7 +451,10 @@ static IteratorStepResult stalledIteratorNext(void* handle, IteratorItemView& vi
}
static void stalledIteratorClose(void* handle) {
(void)handle;
StalledIteratorState* state = static_cast<StalledIteratorState*>(handle);
if (state) {
state->closeCalled = true;
}
}
static IteratorDescriptor stalledIteratorDescriptor = {
@@ -852,27 +857,21 @@ void test_template_renderer_nested() {
"Should contain placeholder value");
// Test deep nesting (up to MAX_RENDERING_DEPTH)
// A nested template consumes a placeholder frame plus a template frame.
// Seven nested templates fit within the default 16-frame rendering stack.
registry.registerProgmemTemplate("%L2%", deep_nest_level2);
registry.registerProgmemTemplate("%L3%", deep_nest_level3);
registry.registerProgmemTemplate("%L4%", deep_nest_level4);
registry.registerProgmemTemplate("%L5%", deep_nest_level5);
registry.registerProgmemTemplate("%L6%", deep_nest_level6);
registry.registerProgmemTemplate("%L7%", deep_nest_level7);
registry.registerProgmemTemplate("%L8%", deep_nest_level8);
registry.registerProgmemTemplate("%L9%", deep_nest_level9);
registry.registerProgmemTemplate("%L10%", deep_nest_level10);
registry.registerProgmemTemplate("%L11%", deep_nest_level11);
registry.registerProgmemTemplate("%L12%", deep_nest_level12);
registry.registerProgmemTemplate("%L13%", deep_nest_level13);
registry.registerProgmemTemplate("%L14%", deep_nest_level14);
registry.registerProgmemTemplate("%L15%", deep_nest_level15);
registry.registerProgmemTemplate("%L16%", deep_nest_level16);
registry.registerProgmemTemplate("%L8%", deep_nest_level16);
ctx.reset();
ctx.setRegistry(&registry);
TemplateRenderer::initializeContext(ctx, deep_nest_level1);
String result5 = captureRenderedOutput(ctx);
TEST_ASSERT_TRUE_MESSAGE(result5.length() > 0, "Should render deep nested template");
TEST_ASSERT_FALSE_MESSAGE(ctx.hasError(), "Deep nesting within the frame limit must not error");
TEST_ASSERT_TRUE_MESSAGE(TemplateRenderer::isComplete(ctx),
"Should complete deep nested template");
@@ -1364,6 +1363,7 @@ void test_template_renderer_iterator_error_cleanup() {
void test_template_renderer_iterator_stall_guard() {
PlaceholderRegistry registry(4);
stalledIteratorState.calls = 0;
stalledIteratorState.closeCalled = false;
TEST_ASSERT_TRUE_MESSAGE(registry.registerIterator("%STALLING_LIST%", &stalledIteratorDescriptor), "Stalling iterator placeholder should register");
TemplateContext ctx;
@@ -1376,5 +1376,38 @@ void test_template_renderer_iterator_stall_guard() {
TEST_ASSERT_EQUAL_MEMORY_MESSAGE("Start:", buffer, 6, "Stalling iterator output should include expected prefix");
TEST_ASSERT_TRUE_MESSAGE(ctx.hasError(), "Renderer should enter error state after exhausting iteration guard");
TEST_ASSERT_TRUE_MESSAGE(stalledIteratorState.calls > 0, "Stalling iterator should be advanced at least once");
TEST_ASSERT_TRUE_MESSAGE(stalledIteratorState.closeCalled, "Stall cleanup should close the live iterator");
}
void test_template_renderer_iterator_reset_and_destruction_cleanup() {
PlaceholderRegistry registry(4);
TEST_ASSERT_TRUE_MESSAGE(registry.registerIterator("%WIFI_LIST%", &wifiIteratorDescriptor), "Iterator should register");
resetWifiIteratorState(2);
{
TemplateContext ctx;
ctx.setRegistry(&registry);
TemplateRenderer::initializeContext(ctx, iteratorWrapperTemplate);
uint8_t buffer[1];
while (wifiIteratorState.index == 0 && !ctx.hasError()) {
TemplateRenderer::renderNextChunk(ctx, buffer, sizeof(buffer));
}
TEST_ASSERT_FALSE_MESSAGE(wifiIteratorState.closeCalled, "Iterator should remain open before reset");
ctx.reset();
TEST_ASSERT_TRUE_MESSAGE(wifiIteratorState.closeCalled, "reset should close an interrupted iterator");
}
resetWifiIteratorState(2);
{
TemplateContext ctx;
ctx.setRegistry(&registry);
TemplateRenderer::initializeContext(ctx, iteratorWrapperTemplate);
uint8_t buffer[1];
while (wifiIteratorState.index == 0 && !ctx.hasError()) {
TemplateRenderer::renderNextChunk(ctx, buffer, sizeof(buffer));
}
TEST_ASSERT_FALSE_MESSAGE(wifiIteratorState.closeCalled, "Iterator should remain open before destruction");
}
TEST_ASSERT_TRUE_MESSAGE(wifiIteratorState.closeCalled, "destruction should close an interrupted iterator");
}
@@ -11,7 +11,7 @@ String captureRenderedOutput(TemplateContext& ctx, size_t bufferSize) {
return output;
}
while (!TemplateRenderer::isComplete(ctx)) {
while (!TemplateRenderer::isComplete(ctx) && !TemplateRenderer::hasError(ctx)) {
size_t written = TemplateRenderer::renderNextChunk(ctx, buffer, bufferSize);
if (written > 0) {
// Arduino String doesn't have (const char*, size_t) constructor