ESP32 · 01-foundations
The module map
Note: This document was generated by an AI agent.
Where every piece of the firmware lives, why the boundaries fall where they do, and — more usefully — which of those boundaries the build actually enforces.
The short version: the folders tell you where to find code, not what is allowed to depend on what. That is a deliberate choice with a real cost, and this page is mostly about why it was made and what is enforced instead.
Why a sketch has an unusual shape
First, what a sketch is
A sketch is Arduino's word for a program — and it is a folder, not a
file. The convention is that the folder and the main source file inside it
carry the same name, so this firmware lives in a directory called Boto/
whose entry file is Boto.ino. Hand the Arduino tooling that folder and it
knows it has been given one complete program.
The .ino extension is the other half of the convention. An .ino file is
ordinary C++ with one courtesy applied before the compiler ever sees it: the
tooling pastes in the Arduino headers and generates forward declarations on
your behalf, so a function can be called above the line that defines it. That
is the entire difference — which is why an .ino file is not quite a .cpp
file, despite compiling as one.
A sketch also has no main(). It supplies two functions instead, and the
Arduino core calls them for you: setup() runs once when the board powers up,
and loop() runs again and again for as long as it has power. The core owns
the real main(); a sketch fills in those two blanks. Everything this device
does, it does somewhere inside that loop.
What that forces, and what it leaves free
Only two things are actually forced. The entry file must sit at the top of the
sketch folder, and it must match the folder's name. Then, of everything
beneath it, exactly one subdirectory is special: src/, which Arduino
promises to compile recursively, however deeply the files are nested. A
sibling folder with some other name gets no such promise — put code there and
it simply is not built.
That combination is what gives the tree its shape. It is rigid at the very top
and completely free underneath, so the entry file sits alone at the root,
deliberately thin, while everything else is grouped inside src/ by what it
owns into domains — application, assets, assistant, audio, config, face,
network, storage, ui, and util.
Every internal include is written from the sketch root rather than
relatively — #include "src/face/Face.h", never ../face/Face.h. One include
root means the firmware build, the host tests and the preview tools all
resolve the same string to the same file, so a header cannot mean different
things in different contexts.
The sketch's entry point is kept to about a hundred lines, over half of them
comments explaining why the order of operations is what it is: setup() wires
the modules up, loop() services the network and then calls into the
application's per-frame update. Everything it used to hold now lives in a
domain.
The ten domains
| Domain | Owns | Files |
|---|---|---|
| application | Top-level modes, interaction orchestration, ambient scheduling | 3 |
| assets | Generated and compiled-in web and animation data | 4 |
| assistant | Agent contracts, turn state, action dispatch, the Whisper/Ollama/Piper flow | 24 |
| audio | I2S ownership, buffers, WAV handling, resampling, signal processing | 12 |
| config | The tracked configuration template, plus the ignored local one | 3 |
| face | Classic and 2.5D models, drawing, scenes, easing, playback | 21 |
| network | Wi-Fi and live data, the web server, OTA, cancelable socket writes | 7 |
| storage | Runtime setting policy and its NVS-backed adapters | 9 |
| ui | OLED ownership, panels, touch gestures, ticker, text layout | 10 |
| util | Small Arduino-free encoding, parsing and text helpers | 5 |
The two largest, assistant at 24 files and face at 21, are where most of the complexity lives, and each gets a whole section of this documentation.
What actually depends on what
This is the part worth looking at closely, because it is not what the folder names suggest.
Read the diagonal band of warning-coloured cells and the conclusion is immediate. Eleven pairs of domains include each other, and eight of the ten domains are mutually reachable — application, assets, assistant, audio, face, network, storage and ui form a single strongly connected component. Only config and util include nothing at all.
application <-> assistant assistant <-> face face <-> network
application <-> network assistant <-> network face <-> ui
application <-> ui assistant <-> ui network <-> ui
assets <-> face assistant <-> audio
Is that a problem?
It is a real constraint with a real cost, and it is worth being plain about both rather than calling it a design.
What it costs. You cannot reason about this codebase in layers. There is no "lower level" you can change in confidence that nothing above it breaks, because there is no above. ui includes network, and network includes ui. A change to a widely-included header can touch most of the tree.
Why it is nonetheless reasonable here. A single-binary firmware with one team and no plugin boundary gets little from a strict layering. The cycles are mostly header dependencies for types and constants, not runtime coupling, and the binary is linked whole regardless. Enforcing acyclicity would mean inventing interface headers whose only purpose is to break a cycle — indirection that buys nothing on a device with 320 KB of RAM.
What is enforced instead. Two things, and both are checked mechanically rather than documented and hoped for:
- Host-testability. Anything that is arithmetic must compile without the Arduino API, and the test suite proves it by compiling it. That boundary is the bar on the right of the matrix.
- Core ownership. Anything that can block belongs on core 0 behind a task. This is enforced by the frame budget rather than by the compiler: violate it and the animation visibly stutters.
So when placing new code, the question is not "which layer" — it is which domain owns this state, and can this block.
The host-testable boundary
This is the one boundary that is genuinely enforced, and it is enforced the only way a boundary ever really is: something fails when you cross it.
The host test suite compiles 34 test binaries with a plain C++17 compiler, no Arduino toolchain anywhere. Those tests include 35 headers and 3 source files directly from the firmware source tree. If Arduino-specific code creeps into any of them, the host build stops compiling — immediately, on a laptop, with no board attached.
| Domain | Host-tested | Of |
|---|---|---|
| assistant | 13 | 24 |
| audio | 10 | 12 |
| face | 6 | 21 |
| ui | 4 | 10 |
| util | 3 | 5 |
| application | 1 | 3 |
| storage | 1 | 9 |
| assets, config, network | 0 | 4, 3, 7 |
That audio sits at 10 of 12 is the striking number. The two exceptions are the files that own the I2S peripherals directly, and cannot be anything but hardware. Everything around them — the ring buffer, the compressor, the envelope, the resampler, the VAD, the pre-roll, WAV parsing — is pure arithmetic, tested on the host, and described in the audio section.
The project's own conventions document is out of date on this point. It names five headers as the ones kept free of the Arduino API. The real figure is 35 headers across seven domains. The convention succeeded well beyond what was written down, which is a pleasant way for a document to be wrong, but wrong all the same.
Keeping a unit testable
A header stays host-testable by obeying four rules:
- Include only
<stdint.h>,<stddef.h>,<string.h>,<math.h>and other host-testable project headers. Never<Arduino.h>or a driver. - Take time as a
uint32_tparameter rather than callingmillis(). - Allocate nothing — fixed capacity, chosen by template parameter or constant.
- Say what it does in a comment block at the top, including the measurement or incident that set its constants. Several of these headers are the only record of why a number is what it is.
Execution and state ownership
The Arduino loop() runs the UI on core 1. Explicit tasks on core 0
handle the weather fetch, I2S capture, I2S playback and local speech requests.
The runtime covers their startup, priorities and timing in
detail.
Each frame the loop services OTA and HTTP before the 33 ms gate, then updates data, touch, voice and wake state, agent actions, ambient state, navigation and rendering. Ordinary panels redraw every 100 ms; the Speak panel every 33 ms, because a moving mouth at 10 fps looks stepped. Long-running uploads draw directly, since the normal frame loop is occupied while they run.
Ownership rules, which matter more than the folder structure:
| State | Owner | Crossing rule |
|---|---|---|
| Live data snapshots | Core-0 fetch task | Mutex-protected copy |
| Animation and controller state | The render loop | Never written from core 0 |
| Agent poll events, pending actions | The worker, read by the loop | Carried back as values, not shared pointers |
| Audio and transcript buffers | Fixed buffers, lightweight flags | Single producer, single consumer only |
The code does not offer general multi-producer thread safety, and adding a second writer to any of these without reading the relevant header's comment block is how a rare, load-dependent corruption gets introduced.
Architectural invariants
Each of these has a consequence attached, because an invariant without one is just a preference.
| Invariant | What breaking it does |
|---|---|
| Every character-state field is a floating-point number | The tween walks the state as a float array; a non-float member corrupts every interpolation. The host test suite guards this |
| Every new pose channel in the 3D rig is registered in its channel list | The channel silently never animates |
| The audio ring buffer has one producer and one consumer | A second writer races the index and corrupts audio under load only |
| Slow work goes in a worker; animation changes go in the loop | A blocking call in the main loop drops frames; a draw call from core 0 races the buffer |
| Raw UTF-8 for models and speech; fold a separate copy for the OLED font | Accented text either breaks the model prompt or renders as glyph soup |
| Scene IDs and settings keys are persisted contracts | Reordering them silently remaps a user's saved preferences |
| Missing data uses validity flags, never an invented reading | The device confidently displays a plausible lie |
| Compile-time gates and runtime preferences are separate contracts | A feature appears enabled in the UI while compiled out |
Passing the host suite establishes none of the hardware properties. Concurrency, browser integration, the panel and the network need real checks that this suite does not cover.
Adding to the tree
- Find the domain that owns the state or the contract. Not the one that calls it most — the one responsible for it.
- Add a top-level folder only when a cohesive subsystem genuinely fits no existing owner. Ten is already a lot for a firmware this size.
- Make it host-testable if it is arithmetic, and add the test in the same change. The boundary holds only because it is cheap to stay inside.
- Ask whether it can block. If yes, it is a core-0 task, not a call from the main loop.
- Put its settings in the central configuration file, and mirror them into the example template used for setup.
- Record why its constants are what they are, in the header, where the next person will look.