ivãstival

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

DomainOwnsFiles
applicationTop-level modes, interaction orchestration, ambient scheduling3
assetsGenerated and compiled-in web and animation data4
assistantAgent contracts, turn state, action dispatch, the Whisper/Ollama/Piper flow24
audioI2S ownership, buffers, WAV handling, resampling, signal processing12
configThe tracked configuration template, plus the ignored local one3
faceClassic and 2.5D models, drawing, scenes, easing, playback21
networkWi-Fi and live data, the web server, OTA, cancelable socket writes7
storageRuntime setting policy and its NVS-backed adapters9
uiOLED ownership, panels, touch gestures, ticker, text layout10
utilSmall Arduino-free encoding, parsing and text helpers5

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.

A ten by ten matrix of the firmware's source domains. A filled cell means the row's domain includes a header from the column's domain. Cells marked with a double arrow are mutual — the two domains include each other — and eleven such pairs exist. Only config and util include nothing at all, making them the sole leaves. To the right of each row a bar shows how many of that domain's files the host test suite compiles: assistant thirteen of twenty-four, audio ten of twelve, face six of twenty-one, ui four of ten, util three of five, application one of three, storage one of nine, and none for assets, config or network.

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:

  1. 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.
  2. 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.

DomainHost-testedOf
assistant1324
audio1012
face621
ui410
util35
application13
storage19
assets, config, network04, 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:

  1. Include only <stdint.h>, <stddef.h>, <string.h>, <math.h> and other host-testable project headers. Never <Arduino.h> or a driver.
  2. Take time as a uint32_t parameter rather than calling millis().
  3. Allocate nothing — fixed capacity, chosen by template parameter or constant.
  4. 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:

StateOwnerCrossing rule
Live data snapshotsCore-0 fetch taskMutex-protected copy
Animation and controller stateThe render loopNever written from core 0
Agent poll events, pending actionsThe worker, read by the loopCarried back as values, not shared pointers
Audio and transcript buffersFixed buffers, lightweight flagsSingle 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.

InvariantWhat breaking it does
Every character-state field is a floating-point numberThe 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 listThe channel silently never animates
The audio ring buffer has one producer and one consumerA second writer races the index and corrupts audio under load only
Slow work goes in a worker; animation changes go in the loopA 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 fontAccented text either breaks the model prompt or renders as glyph soup
Scene IDs and settings keys are persisted contractsReordering them silently remaps a user's saved preferences
Missing data uses validity flags, never an invented readingThe device confidently displays a plausible lie
Compile-time gates and runtime preferences are separate contractsA 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

  1. Find the domain that owns the state or the contract. Not the one that calls it most — the one responsible for it.
  2. 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.
  3. 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.
  4. Ask whether it can block. If yes, it is a core-0 task, not a call from the main loop.
  5. Put its settings in the central configuration file, and mirror them into the example template used for setup.
  6. Record why its constants are what they are, in the header, where the next person will look.