Production-oriented TinyML environment-controller demo for ESP32-S3, designed for future GrowClip Nodeflow integration.
The project trains a small neural-network controller on deterministic, physically inspired growbox simulations, exports it to portable C with emlearn, and runs that exact generated model in a native ESP-IDF application. A separate deterministic safety supervisor remains in control of hard limits. The demonstration firmware only drives its local simulator: it never configures or writes GPIO.
This is an engineering demo, not a calibrated physical model or a validated controller for unattended heaters, pumps, or other real equipment.
The long-term target is a commercial configurable controller (multi-pot irrigation, optional ML, deterministic safety), not a single hobby growbox. Product scope, v2 I/O, and work order: docs/plan.md (section Wizja produktu).
Python runs on the development computer to create and train the model. The ESP32-S3 runs only the exported C model, applies independent safety rules, and feeds the safe result back into a local simulator. TensorFlow is not needed on the board.
flowchart TB
subgraph Host["Development computer"]
Contract["Data contract<br/>40 features and 4 outputs"] --> Simulation["Python simulator<br/>and rollout teacher"]
Simulation --> Training["Keras MLP training<br/>40 to 32 to 32 to 4"]
Training --> Export["emlearn C model"]
end
subgraph Board["ESP32-S3 / ESP-IDF"]
Input["Sensors, validity, targets,<br/>and actuator capabilities"] --> Encoder["FeatureEncoder<br/>clamp and normalize"]
Encoder --> Runtime["ModelRuntime<br/>emlearn inference"]
Runtime --> Raw["Raw ML proposal"]
Raw --> Safety["SafetySupervisor<br/>deterministic hard limits"]
Input --> Safety
Safety --> Safe["Safe control decision"]
Safe --> Demo["Demo simulator<br/>no GPIO"]
Demo --> Input
Safe --> Log["One NDJSON decision line"]
end
Contract --> Encoder
Export --> Runtime
Every control cycle follows the same five steps:
- The demo simulator, or an external replay command, provides sensor readings, validity masks, targets, enclosure parameters, and actuator capabilities.
FeatureEncoderconverts that state into the exact 40-element input vector. Values are clamped to contract ranges and normalized to0..1.ModelRuntimeverifies schema identity and dimensions, then runs the committed generated model.SafetySupervisortreats model outputs as suggestions and independently enforces availability, temperature limits, binary dwell times, and irrigation limits.- Firmware logs both
raw_outputandsafe_output. Only the safe result is applied to the demo simulator.
The reusable lib/environment_control library has no dependency on ESP-IDF, Arduino, serial I/O,
JSON, GPIO, Wi-Fi, FreeRTOS, sensor drivers, actuator drivers, or the simulator.
Docs: Plan prac (v2) · Architecture · I/O map · Contract · Training
- ESP-IDF 5.5.1 baseline in CI.
- ESP32-S3 target, C++17 application and controller components.
- ESP-IDF
jsoncomponent for the bounded NDJSON demo protocol. - ESP-IDF UART, monotonic timer, FreeRTOS, and heap APIs.
- A narrow in-tree emlearn-compatible runtime pinned to the exact upstream revision used by the generated model.
- No Arduino framework and no PlatformIO firmware build.
The default profile is an ESP32-S3-DevKitC-1 with an N8 module: 8 MB quad flash and no PSRAM. An
explicit N32R16V profile is provided for modules marked ESP32-S3-WROOM-2-N32R16V. Do not select the
octal flash/PSRAM profile for N8 or N8R8 hardware.
- ESP-IDF 5.5.1 installed and exported into the active shell.
- CMake and a host C++17 compiler for portable tests.
- Python 3.11 and the pinned packages in
requirements-lock.txtfor training and analysis. - A data-capable USB cable connected to the development board's USB-to-UART port.
For a standard ESP-IDF installation, activate it before running firmware commands:
. "$HOME/esp/esp-idf/export.sh"
idf.py --versionOne-time setup installs pre-commit hooks and dev linters:
make setup-dev| Command | When |
|---|---|
make check-fast |
Lint/format + schema check (matches CI pre-commit step) |
make check |
Full gate: pre-commit + pre-push steps below |
make check-push |
pytest, host C++ tests, idf build (N8 gate), clang-tidy (lib) |
make idf-gate-build |
Firmware compile gate only (build/idf-gate, fast N8 profile) |
make clang-tidy-host |
Static analysis on portable controller (lib/environment_control) |
make fmt |
Auto-fix Python (ruff) and C++ (clang-format) on the tree |
On git commit, fast hooks run on staged files. On git push, make check-push runs
(scripts/quality_gate_push.sh). Requires ESP-IDF in the shell (source export.sh). For
idf.py clang-check in CI, install esp-clang once: python $IDF_PATH/tools/idf_tools.py install esp-clang.
Skip temporarily: SKIP=quality-gate-push git push, SKIP_IDF_BUILD=1, SKIP_CLANG_TIDY=1, or
git commit --no-verify when needed.
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -r requirements-lock.txt
python -m tools.ml.pipeline --quick
cmake -S test/host -B build/host-tests
cmake --build build/host-tests --parallel
ctest --test-dir build/host-tests --output-on-failure
idf.py -B build/idf -D GROWBOX_BOARD_PROFILE=esp32s3-devkitc1-n8 build
idf.py -B build/idf -p /dev/cu.usbserial-10 flash monitorEquivalent shortcuts are available through make setup, make train-quick, make test,
make build, make flash, and make monitor.
Only for a module explicitly marked ESP32-S3-WROOM-2-N32R16V:
idf.py -B build/idf-n32r16v \
-D "SDKCONFIG_DEFAULTS=config/idf/sdkconfig.defaults.n32r16v" \
-D GROWBOX_BOARD_PROFILE=esp32s3-devkitc1-n32r16v \
buildThe profile enables 32 MB octal flash and octal PSRAM. The default build remains the safer N8/no- PSRAM configuration.
config/idf/ board sdkconfig.defaults profiles
schemas/ active ML/wire contract (v4 pots)
docs/ documentation (+ docs/simulator research)
tools/ml/ simulation, training, export
tools/panel/ host control panel
tools/serial/ capture and replay
lib/environment_control/ portable controller library
components/emlearn_runtime/ pinned inference runtime
src/ ESP-IDF application (demo + UART)
test/host/ CMake/CTest (portable C++)
tests/ pytest
scripts/ CI and IDF helpers
Full map: docs/PROJECT_LAYOUT.md.
The root CMakeLists.txt registers src/ and lib/environment_control as ESP-IDF components.
library.json remains in the portable library because LiteGraph is still expected to consume an
immutable release of that library through its PlatformIO build.
The quick profile is for CI and smoke tests only. Full training waits for the high-fidelity growbox simulator — see docs/simulator/.
make train-quick # CI / smoke (not production weights)
make train-full # after simulator fidelity workSee Model pipeline.
idf.py -B build/idf build
idf.py -B build/idf -p /dev/cu.usbserial-10 flash
idf.py -B build/idf -p /dev/cu.usbserial-10 monitorOn boot, firmware emits one startup NDJSON object containing framework, ESP-IDF, schema, model, and board-profile identity. It then emits one decision object per step. One wall-clock second represents a ten-second simulation step.
Commands are one JSON object per line. Supported operations remain:
statusresetseedpauseandresumesteptargetload_scenariomodewithclosed_looporreplay
The ESP-IDF UART adapter uses a bounded 4096-byte line buffer and returns structured errors for oversized, malformed, or unsupported input.
Replay a committed scenario and save the bidirectional session:
python -m tools.serial.replay \
--port /dev/cu.usbserial-10 \
--scenario examples/scenarios/nominal.jsonl \
--output logs/nominal-session.ndjsonCapture autonomous output until interrupted:
python -m tools.serial.capture \
--port /dev/cu.usbserial-10 \
--baud 115200 \
--output logs/closed-loop.ndjsonAnalyse a capture and optionally export decision rows to CSV:
python -m tools.analysis.report logs/closed-loop.ndjson --csv logs/closed-loop.csvschemas/environment-controller.json is the single source of truth for field names, order,
units, ranges, defaults, and model inputs/outputs. Generation embeds its canonical short hash in the
C++ schema metadata, model, manifest, firmware, and startup logs. Firmware rejects a model built for
a different contract identity.
After changing the contract, regenerate its C++ view before retraining:
python tools/schema/generate_environment_schema.py
python -m tools.ml.pipeline --quickEach sensor has an independent validity mask. A missing actuator is represented by
available: false and zero maximum capability. The encoder exposes this to the model, while the
safety supervisor independently forces that actuator's final output to zero. See
Data contract.
python -m pytest
python tools/schema/generate_environment_schema.py --check
python -m tools.ml.pipeline --quick --check-generated
cmake -S test/host -B build/host-tests
cmake --build build/host-tests --parallel
ctest --test-dir build/host-tests --output-on-failure
idf.py -B build/idf buildCI runs Python validation, deterministic model regeneration, compiled-C golden-vector parity tests, and an ESP-IDF 5.5.1 ESP32-S3 firmware build. No physical board is required for CI.
- The training simulator is still placeholder-grade physics; high-fidelity work is tracked under
docs/simulator/. Committed model weights may be
untrained-placeholder. - Synthetic training cannot establish real-world performance or safety.
- The v1 teacher is a short-horizon deterministic search, not model-predictive control or RL.
- The exported float model favors a transparent demonstration over aggressive quantization.
- No physical sensors or actuators are connected, calibrated, or driven.
- Flashing and serial smoke testing require a locally attached board.
A future integration with MichalMatu/esp32s3_LiteGraph will replace the dummy simulator with a
provider adapter and pass safe decisions to a typed actuator bridge. The encoder, runtime,
supervisor, schema identity checks, and fixed-size public types should move unchanged. No integration
with that repository is implemented here. See Porting to LiteGraph.
Released under the MIT License. The in-tree emlearn runtime subset retains its upstream
MIT notice in components/emlearn_runtime/LICENSE.