4 Commits
Author SHA1 Message Date
alex 65d2cb38ae Release v1.1.0 2026-09-01 10:57:59 +10:00
alex 6a5b3aab9e feat: prepare DFTE 1.0.2 2026-08-27 14:25:38 +10:00
alex 361803c2b3 docs: clarify supported targets and releases 2026-08-25 09:01:06 +10:00
alex 5c6d0140c5 Fix iterator cleanup and configuration guidance 2026-08-24 22:36:39 +10:00
40 changed files with 602 additions and 378 deletions
+13 -2
View File
@@ -2,22 +2,33 @@ 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: ./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 }}
+5 -1
View File
@@ -17,7 +17,11 @@ 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 compile --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 }}
+21
View File
@@ -0,0 +1,21 @@
# Changelog
## 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.
+44 -291
View File
@@ -1,317 +1,70 @@
# Device Framework Template Engine (DFTE)
# Device Framework Template Engine
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 for ESP8266 and ESP32 without constructing a giant response in RAM. It combines PROGMEM templates, live getters, nested layouts, conditions, and iterators with request-safe asynchronous HTTP rendering.
## Why use it
## Quickstart
1. Define your root template in PROGMEM (or RAM if you prefer):
- **Predictable memory:** render fixed-size chunks instead of one large `String`.
- **Reusable templates:** keep layouts, partials, CSS, and HTML in flash.
- **Live device data:** resolve values from getters only when a chunk is rendered.
- **Safe async responses:** use one render context per HTTP request while sharing one prepared registry.
```
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:
## Try it
```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>";
Serial.begin(115200);
PlaceholderRegistry registry;
TemplateContext context;
void setup() {
setupRegistry();
server.on("/", [](AsyncWebServerRequest* request) {
streamTemplate(request, PSTR("%ROOT%"));
});
server.begin();
registry.registerProgmemData(PSTR("%TITLE%"), PSTR("Hello DFTE"));
registry.registerProgmemTemplate(PSTR("%PAGE%"), PAGE);
context.setRegistry(&registry);
TemplateRenderer::initializeContext(context, PSTR("%PAGE%"));
}
void loop() {
uint8_t chunk[128];
if (!TemplateRenderer::isComplete(context) && !TemplateRenderer::hasError(context)) {
Serial.write(chunk, TemplateRenderer::renderNextChunk(context, chunk, sizeof(chunk)));
}
}
```
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.
## Featured examples
### Template Syntax
| Goal | Example |
| --- | --- |
| Smallest placeholder render | [HelloPlaceholder](examples/HelloPlaceholder/) |
| Layouts, partials, and conditions | [NestedLayouts](examples/NestedLayouts/) |
| Async HTTP streaming | [StreamingAsync](examples/StreamingAsync/) |
| Dashboard, iterators, and telemetry | [AsyncDashboardDemo](examples/AsyncDashboardDemo/) |
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).
## Install
### Placeholder Types
- **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`.
### 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 <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.1.0
```
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’s supported release targets are ESP8266 and ESP32.
### 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.
## Documentation
### Debug Logging
Read the [documentation index](docs/README.md) for template syntax, async web responses, per-context memory sizing, examples, tests, and releases.
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.
## Development and releases
```
#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));
}
```bash
./scripts/test.sh compile --platform esp8266
./scripts/test.sh compile --platform esp32
./scripts/check-docs.sh
./scripts/prepare-release.sh vMAJOR.MINOR.PATCH --tag
```
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.
Tagging repeats the board-free compile checks, validates the package, and creates a GitHub Release from the matching changelog section. It does not publish to the PlatformIO Registry or deploy firmware.
## License
This project is released under the MIT License. See `LICENSE` for details.
See the [changelog](CHANGELOG.md) and [licence](LICENSE).
+29
View File
@@ -0,0 +1,29 @@
# 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.
```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);
request->onDisconnect([context]() mutable { context.reset(); });
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.
Use [StreamingAsync](../examples/StreamingAsync/) for a complete SoftAP/captive-portal project.
Back to [documentation](README.md) · [project overview](../README.md).
+41
View File
@@ -0,0 +1,41 @@
# 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
TemplateContext standard; // default: 16 stack frames, 512-byte buffer
TemplateContext pageContext(6, 128); // known shallow response, smaller allocation
if (!pageContext.isReady()) {
// Allocation failed; do not start a response with this 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).
+16
View File
@@ -0,0 +1,16 @@
# 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.
Before a release, update `library.json`, `CHANGELOG.md`, and the relevant guides, then run:
```bash
./scripts/check-docs.sh
./scripts/test.sh compile --platform esp8266
./scripts/test.sh compile --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).
+42
View File
@@ -0,0 +1,42 @@
# 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() {
uint8_t chunk[128];
if (!TemplateRenderer::isComplete(context) && !TemplateRenderer::hasError(context)) {
const size_t written = TemplateRenderer::renderNextChunk(context, chunk, sizeof(chunk));
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).
+20
View File
@@ -0,0 +1,20 @@
# DFTE documentation
DFTE documentation is organised by the rendering problem being solved.
| 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 the standalone examples | [Examples](../examples/README.md) |
| Run checks or prepare a release | [Testing](TESTING.md) · [Development](DEVELOPMENT.md) |
## Documentation rules
- The template-language guide owns DFTE semantics and lifecycle contracts.
- The configuration guide owns ABI-sensitive build flags.
- The project README is an entry point and example chooser, not a full API manual.
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).
+14
View File
@@ -0,0 +1,14 @@
# Testing
The two 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
```
The test environments include the library sources with `test_build_src = yes`. CI runs both target checks on the maintained branch and pull requests.
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/51.03.05/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 -1
View File
@@ -15,6 +15,6 @@ 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/51.03.05/platform-espressif32.zip
board = esp32dev
+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 -1
View File
@@ -15,6 +15,6 @@ 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/51.03.05/platform-espressif32.zip
board = esp32dev
+17 -9
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;
}
}
+22
View File
@@ -0,0 +1,22 @@
# DFTE examples
Every example is a standalone PlatformIO project. Build, upload, and monitor from its directory:
```bash
pio run -d examples/HelloPlaceholder -e example_esp8266
pio run -d examples/HelloPlaceholder -e example_esp8266 -t upload
pio run -d examples/HelloPlaceholder -e example_esp8266 -t monitor
```
Choose the ESP32 environment where provided.
| Example | What it demonstrates |
| --- | --- |
| [HelloPlaceholder](HelloPlaceholder/) | Smallest registry/context/chunk flow over serial |
| [NestedLayouts](NestedLayouts/) | Templates, partials, conditionals, and iterators without a web server |
| [StreamingAsync](StreamingAsync/) | Request-scoped streaming HTTP response through a SoftAP portal |
| [AsyncDashboardDemo](AsyncDashboardDemo/) | Dashboard telemetry, iterator rows, and captive-portal flow |
The SoftAP examples print their network names and passwords to serial. They are demonstrations, not production provisioning implementations.
Back to [DFTE documentation](../docs/README.md) · [project overview](../README.md).
+2
View File
@@ -11,6 +11,8 @@ Small ESPAsyncWebServer demo that exposes a captive portal over a SoftAP and ren
```
pio run -d examples/StreamingAsync -e example_esp8266
See the shared [examples guide](../README.md) and [async web guidance](../../docs/ASYNC_WEB.md).
pio run -d examples/StreamingAsync -e example_esp32
```
+3 -1
View File
@@ -24,12 +24,14 @@ build_flags =
-I$PROJECT_LIBDEPS_DIR/$PIOENV/ESPAsyncTCP/src
[env:example_esp32]
platform = espressif32
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/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);
* }
+12 -1
View File
@@ -1,6 +1,6 @@
{
"name": "DeviceFrameworkTemplateEngine",
"version": "1.0.1",
"version": "1.1.0",
"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"
]
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ test_framework = unity
test_build_src = yes
[env:test_template_engine_esp32]
platform = espressif32@6.13.0
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
board = nodemcu-32s
framework = arduino
test_framework = unity
+28
View File
@@ -0,0 +1,28 @@
#!/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
)
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)
echo "Documentation links and required files passed"
+7
View File
@@ -27,6 +27,13 @@ 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
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
+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"
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 compile --platform esp8266|esp32" >&2
exit 2
}
[[ "${1:-}" == "compile" && "${2:-}" == "--platform" && $# -eq 3 ]] || usage
case "${3:-}" in
esp8266) environment="test_template_engine_8266" ;;
esp32) environment="test_template_engine_esp32" ;;
*) usage ;;
esac
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
pio test -d "$root" -e "$environment" --without-uploading --without-testing
echo "DFTE compile check passed for ${3}"
+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