Skip to main content

Coding Guide 5 — Program your Butter Bot: four worked examples

New phrases, new routines and voice commands, object detection, and responding to the controller — with the code for each.

K
Written by Karla Trstenjak

What you'll build

Guides 2–4 taught you to build, flash, and tweak a joke. This guide goes further: by the end of it, you'll have taught your Butter Bot a whole new trick, end to end. Say "do you see a bottle" — the robot wakes its camera, actually looks, and answers out loud depending on what it saw. Then you'll trigger the same trick from the Butter Bot Controller.

One running project — we call it Bottle Detective — carries you through four skills, each building on the last:

  • Phrases — how the robot's spoken lines really work: variants, probabilities, rare responses, and the pronounced-vs-shown trick.

  • Routines and voice commands — writing a new behavior from scratch and giving it an activation phrase, including generating the phoneme strings with multinet_g2p.py.

  • Object detection — using the on-board camera and neural network inside your routine.

  • Controller input — how button presses travel over Bluetooth, and three ways to change what they do.

Prefer to let an AI do the typing? Guide 3 (Claude Code) and Guide 4 (Codex) show how. This guide is written to double as a spec: every code block names its file, so you can paste a whole section straight into Claude Code or Codex and say "do this".

What you need

Item

Notes

Everything from Guide 2

Working ESP-IDF setup, the cloned ButterBot-Firmware project, and at least one successful build + flash. Do Guide 2 first.

Python 3

For the phoneme generator in Topic 2. You already have it — ESP-IDF installed it.

A bottle

Any bottle. This is the most affordable item on any parts list we've ever published.

Butter Bot Controller (optional)

Only for Topic 4. You'll also clone ButterBotCtrl-Firmware the same way you cloned the robot firmware in Guide 2 — with --recurse-submodules — plus a USB cable to flash it.


The three-repo map

Before touching any code, you need one piece of architecture that the earlier guides could skip: the Butter Bot is really two devices and three codebases.

Codebase

What it is

ButterBot-Firmware

The robot itself: motors, camera, microphone, speech, routines. Topics 1–3 live here.

ButterBotCtrl-Firmware

The handheld controller: screen, joystick, five buttons. It talks to the robot over Bluetooth. Topic 4 lives here.

components/ButterBot-Common

A git submodule that exists inside both of the above. It holds everything the two devices must agree on: the phrases (Phrases.h/.cpp), the voice-command catalogue (Scenarios.h), and the Bluetooth message formats (BBData.h, CtrlData.h).

⚠️ One file, two checkouts

When you edit anything under components/ButterBot-Common, you are editing one copy of a file that exists in two places on your disk — once inside each firmware project. If Topic 4 is on your menu, make the identical edit in both checkouts (or commit in one and check out that commit in the other), and reflash both devices. If you only ever touch the robot, the single copy inside ButterBot-Firmware is all that matters.

⚠️ The append-only rule

Three enums — Phrase, BB::Action::Scenario, and Ctrl::Command — are sent between the devices as raw numbers. Their values come from their position in the list. Always add new entries at the end (right before COUNT where there is one). Inserting in the middle silently renumbers everything after it, and a robot and controller flashed from different versions will start speaking past each other in the strangest ways.


Topic 1 — Adding new phrases, the full story 🗣️

Guide 2 showed you how to slip a new joke into JokePhrases. Now let's look at what that file actually does — and create two brand-new phrase groups our Bottle Detective will speak later.

Files you'll touch

File

Repo

What you do

components/ButterBot-Common/src/Phrases.h

ButterBot-Common (submodule)

Add an entry to the Phrase enum

components/ButterBot-Common/src/Phrases.cpp

ButterBot-Common (submodule)

Add an array of variants + one registration line

The Phrase enum: one entry per thing-to-say

Every distinct thing the robot can say — a joke, a fact, "battery low", "shutting down" — has one entry in enum class Phrase in Phrases.h. There are about 150 of them, and the file marks exactly where yours go:

// FILE: ButterBot-Firmware/components/ButterBot-Common/src/Phrases.h  (excerpt, end of the enum) enum class Phrase : uint16_t {     // ... existing code (about 150 entries) ...     CameraFailure,     MotorBoardFailure,     ShuttingDown,     ListenAbort,     // Add phrases here     COUNT };

New entries go right where the // Add phrases here comment says — after the last real entry, before COUNT. That's the append-only rule from the repo map in action: the enum value is what the robot sends to the controller over Bluetooth, so existing entries must never shift.

Anatomy of a phrase: the PhraseOutput record

One Phrase entry maps to an array of variants in Phrases.cpp, so the robot doesn't repeat itself. Each variant is a PhraseOutput:

// FILE: ButterBot-Firmware/components/ButterBot-Common/src/Phrases.h  (excerpt, private section of class Phrases) struct PhraseOutput {     const char* const pronounced; // The string the robot will pronounce (say out loud)     float probability = 0.0f; // If <= 0.0f, it will be considered as uniform distribution with all others with probability <= 0.0f     bool rare = false; // If true, first response for a phrase will not be this one unless its the only one     const char* const shown = ""; // The string shown on the controller's screen. If empty, it is the same as 'pronounced' };

Those four fields are the whole secret to how the robot's personality is tuned:

  • pronounced is what the text-to-speech engine reads. Write it the way it should sound: plain words, commas and periods for pauses, no emoji.

  • probability lets you weight a variant. Leave it at 0.0f and the variant shares the remaining probability equally with all the other 0.0f ones. Set it to, say, 0.1f for a 10% pick chance.

  • rare variants are never the first response you hear for that phrase — they only enter the pool once the phrase has been used. Perfect for easter eggs.

  • shown is the text on the controller's screen. Leave it empty and the screen shows pronounced. It exists because sometimes you must misspell a word for the speech engine to say it right — and you don't want the misspelling on screen.

The stock firmware uses that last trick in earnest. Look at this real entry from JokePhrasesareaa is deliberate, because the speech engine rushed the correctly spelled word:

// FILE: ButterBot-Firmware/components/ButterBot-Common/src/Phrases.cpp  (excerpt from JokePhrases) { "I execute commands precisely. Comedy is the only areaa where accuracy does not help" , 0.0f, false,     "I execute commands precisely. Comedy is the only area where accuracy does not help"},

Template phrases: a few arrays contain printf-style placeholders — WhatsThisOnePhrases has lines like "It might be a %s", and the routine fills in the object name at runtime. If you add variants to one of those arrays, every variant must keep the same number of %s placeholders as its neighbors, or the robot will read garbage.

Registration: one line in buildMappings()

The arrays live inside a wrapper class called PhraseArrays at the top of Phrases.cpp (a C++ visibility trick — the comment in the file explains it). The link between an enum entry and its array is a single line in PhraseArrays::buildMappings(), further down the same file:

// FILE: ButterBot-Firmware/components/ButterBot-Common/src/Phrases.cpp  (excerpt from buildMappings()) m[static_cast<size_t>(Phrase::Fact)] = FactPhrases; // ... existing code (one line per phrase) ...

Forget this line and the phrase compiles fine but stays silent — Phrases::get() finds an empty array and returns -1.

How the firmware speaks: get, map, play

Every routine that talks uses the same four-step pattern. Here it is, verbatim, from the joke routine — this is the most important snippet in the whole guide, and we'll reuse it shortly:

// FILE: ButterBot-Firmware/main/src/Routines/JokeRoutine.cpp  (excerpt from tick()) const int16_t id = Phrases::get(Phrase::Joke); if(id < 0){     CMF_LOG(JokeRoutine, LogLevel::Warning, "Phrases::get returned no phrase id");     return TickingState::Done; }  JokeData jokeData{}; jokeData.id = id; com->sendData(BB::State::Scenario, BB::Action::Scenario::Joke, jokeData);  auto source = std::make_unique<SpeechAudioSource>(SpeechGen::InputType::Text, Phrases::map(Phrase::Joke, id)); audio->play(ServiceLocator::SpeechAudioGenInstance.get(), std::move(source)); audio->waitEnd(portMAX_DELAY);
  • Phrases::get(phrase) picks a variant — weighted by probability, honoring rare, and never repeating a variant until all of them have had a turn. It returns the variant's index, or -1 if there's nothing to say.

  • com->sendData(...) tells the controller which variant was picked, so its screen can display the matching shown text. (Optional — only useful if the controller has a screen for that action.)

  • Phrases::map(phrase, id) turns the index back into the pronounced string, and the three audio lines speak it and wait until the speech finishes.

Worked step: the Bottle Detective's lines

Time to write. Our routine will need two phrases: one for "I see a bottle", one for "I don't". First the enum — insert both entries at the marker:

// FILE: ButterBot-Firmware/components/ButterBot-Common/src/Phrases.h  (edit, end of the enum)     ListenAbort,     BottleFound,      // new     BottleMissing,    // new     // Add phrases here     COUNT };

Then the variant arrays. Put these anywhere inside class PhraseArrays in Phrases.cpp, next to the existing ones. Note how the last BottleFound variant shows off both tuning knobs at once — a 10% pick chance and the rare flag — and how one line uses shown to fix a spelling written for the speech engine's benefit:

// FILE: ButterBot-Firmware/components/ButterBot-Common/src/Phrases.cpp  (add inside class PhraseArrays) static constexpr std::array BottleFoundPhrases = std::to_array<Phrases::PhraseOutput>(     {         { "Bottle located. I will add this to my list of achievements" , 0.0f, false},         { "Yes. That is a bottle. My purpose expands... marginally" , 0.0f, false},         { "Bee hold. A bottle" , 0.0f, false, "Behold. A bottle"},         { "A bottle. And they said I would never amount to anything" , 0.1f, true},     } );  static constexpr std::array BottleMissingPhrases = std::to_array<Phrases::PhraseOutput>(     {         { "I see no bottle. I see only disappointment" , 0.0f, false},         { "No bottle detected. Please point me at one" , 0.0f, false},         { "Scan complete. Bottle count, zero" , 0.0f, false},     } );

And finally the two registration lines, added inside buildMappings() in the same file:

// FILE: ButterBot-Firmware/components/ButterBot-Common/src/Phrases.cpp  (add inside buildMappings()) m[static_cast<size_t>(Phrase::BottleFound)] = BottleFoundPhrases; m[static_cast<size_t>(Phrase::BottleMissing)] = BottleMissingPhrases;

Run idf.py build (Guide 2, Step 6) to confirm everything compiles. The robot can't say these lines yet — nothing asks for them. That's Topic 2's job.


Topic 2 — New routines and new voice commands 🎤

This is the section Guides 3 and 4 told you was "an advanced project". It is — but it's four small, understandable steps: write a routine class, give it a scenario number, map the number to the class, and register a spoken phrase that triggers it.

Files you'll touch

File

Repo

What you do

main/src/Routines/BottleCheckRoutine.h + .cpp

ButterBot-Firmware

Create — the behavior itself

components/ButterBot-Common/src/BBData.h

ButterBot-Common (submodule)

Append one value to BB::Action::Scenario

main/src/States/ScenarioRoutineMappings.cpp

ButterBot-Firmware

Map the scenario to your routine class

components/ButterBot-Common/src/Scenarios.h

ButterBot-Common (submodule)

Add activation phrases (with phonemes)

How routines work

A routine is a plain C++ class deriving from Routine (main/src/Routines/Routine.h). The state machine creates it, calls tick(deltaTime) repeatedly, and destroys it when it reports it's finished. Your tick() returns one of:

Return value

Meaning

TickingState::Continue

Call me again right away — I'm mid-work.

TickingState::Block

Put me to sleep until an event (a bound callback) wakes the state machine.

TickingState::Done

Finished — destroy me and go back to idling.

TickingState::None

Internal fallback — never return this from your own tick().

Two mechanics are worth knowing before you write one:

  • The 512-byte budget. Only one routine exists at a time, and it's constructed into a fixed 512-byte buffer (RoutineBufSize in main/src/States/StateRoutineFactory.h). If your class grows past that — say, a huge member array — the build fails with "Routine exceeds RoutineBufSize - bump RoutineBufSize". The fix is in the message: bump the constant, or keep big data on the heap.

  • Three ways a routine gets started. The scenario table (ScenarioRoutineMappings.cpp) handles voice commands and controller actions — that's what we'll use. IdleState::RandomRoutines (in main/src/States/IdleState.cpp) is a weighted list of routines the robot starts on its own every so often — add yours there with a weight and the robot will do it spontaneously. And an event table maps world events (battery low, gas alarm, being summoned) to routines.

There are also specialized base classes you'll meet when reading the stock code: EventRoutine (carries an event payload), PhraseEventRoutine (an event routine that just says a phrase), and PhraseListeningRoutine (lets a routine listen for speech mid-flight — DiceRollRoutine is the showcase). Plain Routine is all we need today.

Worked step 1: the routine class

Create two new files. The header is nearly nothing:

// FILE: ButterBot-Firmware/main/src/Routines/BottleCheckRoutine.h  (complete new file) #ifndef BUTTERBOT_FIRMWARE_BOTTLECHECKROUTINE_H #define BUTTERBOT_FIRMWARE_BOTTLECHECKROUTINE_H  #include "Routine.h"  class BottleCheckRoutine : public Routine { public:     using Routine::Routine;      virtual TickingState tick(float deltaTime) override; };  #endif //BUTTERBOT_FIRMWARE_BOTTLECHECKROUTINE_H

The first version of the implementation doesn't look for bottles yet — it just speaks the "no bottle" phrase from Topic 1, using the exact pattern from JokeRoutine. Camera comes in Topic 3.

// FILE: ButterBot-Firmware/main/src/Routines/BottleCheckRoutine.cpp  (complete new file, version 1) #include "BottleCheckRoutine.h" #include <Statics/ApplicationStatics.h> #include <Core/Application.h> #include <Services/Audio/Audio.h> #include <Phrases.h> #include <Util/ServiceLocator.h> #include "Audio/SpeechAudioGen.h" #include "Audio/SpeechAudioSource.h" #include "Audio/SpeechGen.h"  DEFINE_LOG(BottleCheckRoutine)  Routine::TickingState BottleCheckRoutine::tick(float deltaTime){     const Application* app = ApplicationStatics::getApplication();     Audio* audio = app->getService<Audio>();      if(audio == nullptr || !ServiceLocator::SpeechAudioGenInstance || !ServiceLocator::SpeechGenInstance){         CMF_LOG(BottleCheckRoutine, LogLevel::Error, "Missing required service(s)");         return TickingState::Done;     }      const int16_t id = Phrases::get(Phrase::BottleMissing);     if(id < 0){         CMF_LOG(BottleCheckRoutine, LogLevel::Warning, "Phrases::get returned no phrase id");         return TickingState::Done;     }      auto source = std::make_unique<SpeechAudioSource>(SpeechGen::InputType::Text, Phrases::map(Phrase::BottleMissing, id));     audio->play(ServiceLocator::SpeechAudioGenInstance.get(), std::move(source));     audio->waitEnd(portMAX_DELAY);      return TickingState::Done; }

No build-system edits needed — but one command is. The firmware's CMake finds everything under main/src/ by scanning the folder, and that scan is cached. After creating a brand-new .cpp, run idf.py reconfigure once so the build system notices it — otherwise the build ends with undefined reference errors at the link step. (Editing existing files never needs this.)

Worked step 2: a scenario number and its mapping

Everything the robot can be asked to do — by voice or by controller — is identified by a value of BB::Action::Scenario. Append ours at the end of the enum in BBData.h:

// FILE: ButterBot-Firmware/components/ButterBot-Common/src/BBData.h  (edit, inside union Action) enum class Scenario {     ReadNotifs, EightBall, Fact, Joke, PassTheButter, Profanity, YouPassButter, DiceRoll, CurrentTime,     // ... existing code ...     WhatsThis,     BottleCheck,   // new - appended at the end } scenario;

Then map the number to your class in ScenarioRoutineMappings.cpp. Add the include at the top and one row to the Mappings array — the existing rows show the pattern:

// FILE: ButterBot-Firmware/main/src/States/ScenarioRoutineMappings.cpp  (edits) #include "Routines/BottleCheckRoutine.h"   // new, with the other includes at the top  const ScenarioRoutineMapping Mappings[] = {     { { BB::Action::Scenario::Fact, {} }, &makeRoutine<FactRoutine> },     { { BB::Action::Scenario::Joke, {} }, &makeRoutine<JokeRoutine> },     // ... existing code ...     { { BB::Action::Scenario::BottleCheck, {} }, &makeRoutine<BottleCheckRoutine> },   // new };

Skip this row and the robot will hear you, look up the scenario, find nothing, and log No routine mapped for scenario ... before shrugging back to idle. (The second element of the key, {}, is a ScenarioData payload — that's how one scenario like DiceRoll maps to different routines for a D6 versus a D20. Plain routines leave it empty.)

Worked step 3: the activation phrase catalogue

Now the fun part. Every phrase the robot understands lives in one array — activations in Scenarios.h, about 240 rows. Here's its shape, with real rows:

// FILE: ButterBot-Firmware/components/ButterBot-Common/src/Scenarios.h  (excerpt) struct ScenarioActivation {     BB::Action::Scenario scenario;     ScenarioData scenarioData = {};      const char* string; //Phrase string (graphemes)     const char* phonemes; //Phrase phonemes, generated using multinet_g2p.py[esp-sr/tool/multinet_g2p.py]     const char* fuzzyCore = nullptr;     float fuzzyThreshold = DefaultFuzzyThreshold;     const void* action = nullptr; };  constexpr std::array activations = std::to_array<ScenarioActivation>(     {         // ... existing code ...         { BB::Action::Scenario::Joke, {}, "tell me a joke", "TfL Mm c qbK", "qbK" },         { BB::Action::Scenario::Joke, {}, "make a joke", "MdK c qbK", "qbK" },         { BB::Action::Scenario::Joke, {}, "say something funny", "Sd ScMvgl FcNm", "FcNm" },         // ... existing code ...     } );

Field

What it is

scenario + scenarioData

Which mapping row fires when this phrase is heard. Several rows with the same scenario = synonyms.

string

The phrase in normal spelling ("graphemes"). Lowercase, no punctuation.

phonemes

The same phrase in the speech model's compact phonetic alphabet. Generated by a script — never typed by hand.

fuzzyCore

The phonemes of the distinctive word(s) in the phrase — "joke" in "tell me a joke". The fuzzy matcher weights these words double.

fuzzyThreshold

How forgiving the fuzzy matcher is for this phrase. Default 0.2f; see the tuning notes below.

The mechanics behind it: when the robot starts listening, ListenState feeds this whole array into the speech-recognition engine (Espressif's MultiNet). Adding a row is genuinely all it takes — the list is rebuilt from activations every time listening starts.

Generating phonemes with multinet_g2p.py

The phoneme strings look like line noise (TfL Mm c qbK) because they use a compact one-letter-per-sound alphabet internal to MultiNet. The generator script ships inside the firmware project, at managed_components/espressif__esp-sr/tool/multinet_g2p.py — note it's tool, singular, and that managed_components/ only exists after your first build, because ESP-IDF downloads it then.

The script needs two Python packages (one-time setup):

pip install g2p_en pandas

If pip refuses with an externally-managed-environment error (common with recent Python on Linux and macOS), install into a virtual environment instead: python -m venv g2p-venv, then use g2p-venv/bin/pip and g2p-venv/bin/python (on Windows: g2p-venv\Scripts\pip and ...\python) for the commands in this section.

Now generate — run this from the ButterBot-Firmware folder. Commas separate synonyms of the same command; semicolons would separate different commands:

python managed_components/espressif__esp-sr/tool/multinet_g2p.py -t "do you see a bottle,is that a bottle"
in: do you see a bottle,is that a bottle out: Do Yo Sm c BnTcL,gZ jaT c BnTcL;

Copy each phrase's phonemes from the out: line (drop the trailing semicolon). Keep the spaces — the fuzzy matcher works word by word. To sanity-check your setup, run it once on "tell me a joke": the output must be exactly TfL Mm c qbK, the same string you saw in Scenarios.h.

⚠️ First run complains about missing NLTK data?

If the script stops with LookupError: Resource 'averaged_perceptron_tagger_eng' not found, the phonetic library needs a one-time data download. Run this, then retry:

python -c "import nltk; nltk.download('averaged_perceptron_tagger_eng')"

With real phonemes in hand, add the activation rows anywhere in the activations array. The fuzzy core is the phoneme spelling of "bottle" — BnTcL — which you can read straight off the end of either output:

// FILE: ButterBot-Firmware/components/ButterBot-Common/src/Scenarios.h  (add inside the activations array) { BB::Action::Scenario::BottleCheck, {}, "do you see a bottle", "Do Yo Sm c BnTcL", "BnTcL" }, { BB::Action::Scenario::BottleCheck, {}, "is that a bottle", "gZ jaT c BnTcL", "BnTcL" },

How recognition really decides, and how to tune it

Two matchers stand between your voice and your routine. First, MultiNet itself tries for an exact command hit. If it times out without one, the firmware runs its own fuzzy phoneme matcher over what it heard — an edit-distance comparison in phoneme space, where the fuzzyCore word counts double and short filler words count half. A candidate wins if its score clears the phrase's fuzzyThreshold without being ambiguous against a different command (synonyms of the same scenario don't compete with each other).

Tuning, in practice:

  • Phrase triggers on similar-but-wrong sentences → lower the threshold (stricter), e.g. 0.21f. You'll find stock rows doing exactly this.

  • Phrase needs three attempts to land → raise it (looser), e.g. 0.27f0.3f, like the "pass the butter" family does.

  • Pick a fuzzyCore that's genuinely distinctive. "bottle" is great; "do you" would match half the catalogue.

See what the robot hears: the stock firmware silences the recognizer's logs. In main/main.cpp, inside begin(), find esp_log_level_set("AudioFrontend", ESP_LOG_NONE); and comment it out, rebuild, and run idf.py monitor. You'll see the recognized transcript, candidate scores, and why the fuzzy matcher accepted or rejected each attempt — invaluable while tuning.

Build, flash, test

  1. idf.py build, then flash (Guide 2, Steps 6 and 8).

  2. Short-press the robot's power button so it starts listening.

  3. Say "do you see a bottle".

The robot should answer with one of your BottleMissing lines — it always says "no bottle" for now, because version 1 of the routine never looks. Notice it will not repeat a line until it has used all three. That's Phrases::get() doing its no-repeat bookkeeping.

Symptom

Fix

Robot hears you but does nothing; monitor shows No routine mapped for scenario ...

You added the activation rows but forgot the row in ScenarioRoutineMappings.cpp (or its #include).

Phrase is never recognized

Re-generate the phonemes and compare character by character — a hand-typed string is the usual culprit. Then un-silence the AudioFrontend logs and check the scores; raise fuzzyThreshold a notch if it's always just missing.

Routine runs but stays silent

Missing buildMappings() line from Topic 1 — Phrases::get() is returning -1.

Build error mentioning RoutineBufSize

Your routine class outgrew the 512-byte buffer. Bump RoutineBufSize in main/src/States/StateRoutineFactory.h, or move large members to the heap.


Topic 3 — Giving your routine eyes: object detection 👀

Time to make the Bottle Detective earn its name. The robot has a camera and an on-board neural network; in this topic the routine starts using them.

Files you'll touch

File

Repo

What you do

main/src/Routines/BottleCheckRoutine.cpp

ButterBot-Firmware

Upgrade tick() — the only edit in this topic

components/ButterBot-Common/src/ObjDetClass.h, main/src/Services/ObjDet.h

both in ButterBot-Firmware

Read only — the class list and the API

What the detector actually is

It's a whole-frame classifier, not a bounding-box detector: it looks at the entire camera image (a 120×120 crop of a 128×128 frame) and answers "what object is this most likely to be". It knows exactly ten things:

// FILE: ButterBot-Firmware/components/ButterBot-Common/src/ObjDetClass.h  (complete) enum class ObjClass { Backpack, Bottle, Controller, Keyboard, Lamp, Laptop, Mug, Notebook, Phone, Plant, COUNT };

⚠️ Ten classes, fixed

You can build anything you like using these ten classes, but you can't add an eleventh by editing the enum — the list mirrors the output layer of a trained neural-network model (models/objdet.tflite, flashed to its own partition). Teaching it new objects means retraining that model, which is a much bigger adventure than this guide.

The lifecycle: load, look, unload

The detection service is reached through ServiceLocator::ObjDetInstance, and every consumer in the stock firmware uses the same five-call lifecycle. Here it is in WhatsThisRoutine (the routine behind "what is this?"):

// FILE: ButterBot-Firmware/main/src/Routines/WhatsThisRoutine.cpp  (excerpt from tick()) ServiceLocator::ObjDetInstance->loadModel();  ServiceLocator::ObjDetInstance->init(); const std::vector<ObjClass> detections = ServiceLocator::ObjDetInstance->detect(); ServiceLocator::ObjDetInstance->deinit();  ServiceLocator::ObjDetInstance->unloadModel();
  • loadModel() / unloadModel() map the neural network into memory and allocate its 1 MB working arena.

  • init() / deinit() configure the camera for detection and release it.

  • detect() returns 0, 1, or 2 classes: the top guess alone if it scores ≥ 0.90, the top two if the runner-up also scores ≥ 0.40, or an empty vector when nothing is confident enough.

⚠️ Always tear it down

Mirror every loadModel() with unloadModel() and every init() with deinit() before your routine returns Done — on every code path. That megabyte of arena and the reconfigured camera don't free themselves, and the next routine that needs memory will find the robot mysteriously out of it.

Bonus reading: ObserveRoutine (main/src/Routines/ObserveRoutine.cpp) is the ambient cousin — it's in IdleState::RandomRoutines, so the robot occasionally looks around unprompted and comments on what it sees. ObjDet::ClassNames maps each ObjClass to a printable name if you need one.

Worked step: the Bottle Detective opens its eyes

Replace BottleCheckRoutine.cpp with the final version. The diff from version 1: two new includes, the ObjDet guard, the five-line lifecycle, and a one-line verdict that picks which phrase to speak.

// FILE: ButterBot-Firmware/main/src/Routines/BottleCheckRoutine.cpp  (complete new file, final version) #include "BottleCheckRoutine.h" #include <Statics/ApplicationStatics.h> #include <Core/Application.h> #include <Services/Audio/Audio.h> #include <Phrases.h> #include <Util/ServiceLocator.h> #include <algorithm> #include "Audio/SpeechAudioGen.h" #include "Audio/SpeechAudioSource.h" #include "Audio/SpeechGen.h" #include "Services/ObjDet.h"  DEFINE_LOG(BottleCheckRoutine)  Routine::TickingState BottleCheckRoutine::tick(float deltaTime){     const Application* app = ApplicationStatics::getApplication();     Audio* audio = app->getService<Audio>();      if(audio == nullptr || !ServiceLocator::SpeechAudioGenInstance || !ServiceLocator::SpeechGenInstance || !ServiceLocator::ObjDetInstance){         CMF_LOG(BottleCheckRoutine, LogLevel::Error, "Missing required service(s)");         return TickingState::Done;     }      ServiceLocator::ObjDetInstance->loadModel();      ServiceLocator::ObjDetInstance->init();     const std::vector<ObjClass> detections = ServiceLocator::ObjDetInstance->detect();     ServiceLocator::ObjDetInstance->deinit();      ServiceLocator::ObjDetInstance->unloadModel();      const bool sawBottle = std::find(detections.begin(), detections.end(), ObjClass::Bottle) != detections.end();     const Phrase phrase = sawBottle ? Phrase::BottleFound : Phrase::BottleMissing;      const int16_t id = Phrases::get(phrase);     if(id < 0){         CMF_LOG(BottleCheckRoutine, LogLevel::Warning, "Phrases::get returned no phrase id");         return TickingState::Done;     }      auto source = std::make_unique<SpeechAudioSource>(SpeechGen::InputType::Text, Phrases::map(phrase, id));     audio->play(ServiceLocator::SpeechAudioGenInstance.get(), std::move(source));     audio->waitEnd(portMAX_DELAY);      return TickingState::Done; }

Build, flash, and test properly this time:

  1. Stand a bottle 20–50 cm in front of the robot, reasonably centered in its view, in decent light. Remember the classifier sees the whole frame — a bottle filling a good chunk of the image works far better than a bottle in a cluttered corner.

  2. Short-press the power button, say "do you see a bottle" — it should answer with a BottleFound line.

  3. Take the bottle away and ask again — a BottleMissing line.

  4. Somewhere around your fifth successful detection, don't be surprised if it says something it's never said before. That's your rare variant unlocking. 🧈

What about the controller's screen? Our routine speaks but doesn't send anything to the controller, and that's perfectly fine — the controller simply stays on its home screen. If you want a custom screen for your routine one day: define a data struct in BBData.h, send it with com->sendData(...) like JokeRoutine does, and build a window for it in the controller firmware under main/src/Components/HomeWindows/. The next topic gets you into that codebase.


Topic 4 — Responding to the controller 🎮

The Butter Bot Controller is a computer of its own, with its own firmware to hack. In this topic: how a button press becomes a robot action, and three graded examples — remap a button, put Bottle Detective in the controller's action list, and mint a brand-new Bluetooth command.

Files you'll touch

File

Repo

Used in

main/src/Screens/HomeScreen.cpp

ButterBotCtrl-Firmware

Examples A and C — where buttons become messages

main/src/Components/GuideElement.cpp

ButterBotCtrl-Firmware

Example A — the on-screen button cheat sheet

main/src/Util/ScenarioMapping.h

ButterBotCtrl-Firmware

Example B — the action list

components/ButterBot-Common/src/CtrlData.h

ButterBot-Common (submodule, both repos!)

Example C — the command vocabulary

main/src/States/IdleState.cpp

ButterBot-Firmware

Example C — the robot-side handler

How a button becomes an action

The controller has five physical buttons — ManualOverride, Poke, ShutUp, Summon, and the joystick's own press (main/src/Enums.hpp). The framework delivers exactly two events per button, Press and Release — anything fancier, like "hold for two seconds", is hand-rolled with timestamps, and you'll see how in a moment.

The journey of a press: the input service polls the pins and broadcasts a button event → HomeScreen::handleButtonEvent() decides what it means → a Com::send...() call writes a message over Bluetooth (the controller is the BLE client, the robot the server) → the robot's Com::tick() (main/src/Services/Com.cpp) parses it and re-broadcasts it as an event → whichever robot-side object bound that event reacts. The message itself is beautifully simple — a 4-byte command code, optionally followed by a raw payload struct:

// FILE: ButterBot-Firmware/components/ButterBot-Common/src/CtrlData.h  (complete enum) struct Ctrl {     enum Command {         EnterRC, ExitRC, Listen, Summon, ShutUp, Poke, Drive, RCSound, Scenario     } type; };

Who reacts to what, on the robot:

Command

Sent when

Robot-side handler

Listen

Summon button, short press

IdleState::onCommand — starts listening

Summon

Summon button, 2-second hold

EventBag — queues the summon event routine

Poke

Poke button, release

IdleState::onCommand — instant poke routine

ShutUp

Shut-up button, press

the app itself, in main/main.cpp — toggles mute

Scenario (+ payload)

An entry is chosen in the action list

EventBag — runs the mapped scenario routine (Topic 2's table!)

EnterRC/ExitRC/Drive/RCSound

Manual-override hold + RC mode

IdleState/RCState — remote-control driving

Example A (easy): change what a button sends

All the button decisions live in HomeScreen::handleButtonEvent(): press-time actions in its if(action == ButtonInput::Action::Press) block, release-time actions after it, and hold detection in HomeScreen::loop() (press stores a millis() timestamp, loop() watches the clock — steal the Summon pattern if you ever add a hold). To make the Poke button ask for a joke instead of a poke:

// FILE: ButterBotCtrl-Firmware/main/src/Screens/HomeScreen.cpp  (excerpt from handleButtonEvent, release block) // Before: if(btn == Button::Poke){     com->sendCommand(Ctrl::Command::Poke); } // After: if(btn == Button::Poke){     com->sendScenario(BB::Action::Scenario::Joke, {}); }

Whenever you change what a button does, also update the controller's idle-screen cheat sheet so it doesn't lie to you:

// FILE: ButterBotCtrl-Firmware/main/src/Components/GuideElement.cpp  (excerpt) static constexpr const char* Lines[] = {         "Override: Hold for RC",         "Poke: Tell a joke",      // was: "Poke: Existential dread"         "Shut up: (Un)Mute",         "Summon: Press to listen, hold to summon",         "Joystick press: Actions", };

⚠️ Builds cache their file list

Same rule as the robot firmware: CMake's source scan is cached, so editing existing files is fine, but after adding a new .cpp to either project, run idf.py reconfigure once — otherwise you'll get undefined reference errors at link time.

Example B: Bottle Detective in the action list

Pressing the joystick opens the controller's scrollable list of everything the robot can do. That list is generated from one array — ScenarioNameMap — and its declaration order is the display order. One new row puts our Topic-2 scenario on the menu:

// FILE: ButterBotCtrl-Firmware/main/src/Util/ScenarioMapping.h  (add inside ScenarioNameMap) { "Bottle Check", { BB::Action::Scenario::BottleCheck, {} } },

For that line to compile, this checkout's copy of ButterBot-Common must contain your BottleCheck enum entry from Topic 2 — this is the two-checkouts warning from the repo map becoming real. Mirror the BBData.h edit (and the Phrases.h/Phrases.cpp ones while you're at it), build, and flash the controller. Now joystick-press → Bottle Check → the robot goes looking, no voice needed. The selection travels as Ctrl::Scenario with the scenario in its payload, lands in the same mapping table your voice command uses, and runs the same routine.

Example C (advanced): a brand-new command

The Scenario command covers most "make the robot do X" ideas, but sometimes you want a first-class command — something the robot should react to even outside of scenarios. Three edits, three files:

1. Append it to the shared vocabulary (append-only — this enum crosses the wire raw):

// FILE: ButterBot-Firmware/components/ButterBot-Common/src/CtrlData.h  (edit, mirror in both checkouts) enum Command {     EnterRC, ExitRC, Listen, Summon, ShutUp, Poke, Drive, RCSound, Scenario,     BottleCheckNow   // new } type;

2. Send it from the controller — a payload-less command needs no new plumbing, sendCommand() already handles any Ctrl::Command. Wire it to whatever trigger you like inside handleButtonEvent(), for example the Poke button again:

// FILE: ButterBotCtrl-Firmware/main/src/Screens/HomeScreen.cpp  (excerpt from handleButtonEvent, release block) if(btn == Button::Poke){     com->sendCommand(Ctrl::Command::BottleCheckNow); }

3. Handle it on the robot. Payload-less commands need zero changes in the robot's Com service — everything it receives is re-broadcast through its OnCommand event. You just need a listener. IdleState::onCommand (main/src/States/IdleState.cpp) already listens and is the natural home; hand the routine to the scenario system exactly the way ListenState does after a voice match:

// FILE: ButterBot-Firmware/main/src/States/IdleState.cpp  (edit, inside onCommand) void IdleState::onCommand(Ctrl::Command cmd){     if(cmd == Ctrl::Command::Listen){         sm->transitionTo<ListenState>();         return;     }      if(cmd == Ctrl::Command::BottleCheckNow){   // new         if(ServiceLocator::ScenarioRoutineServiceInstance){             ServiceLocator::ScenarioRoutineServiceInstance->setRoutineFactory(&makeRoutine<BottleCheckRoutine>);             sm->transitionTo<ScenarioState>();         }         return;     }      // ... existing code (Poke handling) ... }

(Add #include "Routines/BottleCheckRoutine.h" at the top of IdleState.cpp.) If your command carried a payload instead, you'd follow the Drive pattern in the robot's Com::tick() — a length-checked memcpy and a dedicated event — but start payload-less; it's nine-tenths of the use cases.

⚠️ Flash both, always

Any edit under components/ButterBot-Common — Examples B and C both make one — means rebuilding and reflashing both the robot and the controller before testing. A pair flashed from mismatched copies of CtrlData.h or BBData.h is the classic source of "the button does the wrong thing" mysteries.


Cheat sheet: where everything lives

Every recipe from this guide, in one table. Paths are relative to the repo named in the last column.

I want to…

Edit these files

Repo(s)

Add a variant to an existing phrase

The phrase's array in components/ButterBot-Common/src/Phrases.cpp

Common

Add a whole new phrase

Phrases.h (enum, at the marker) + Phrases.cpp (array + buildMappings() line)

Common

Create a new routine

New main/src/Routines/YourRoutine.h/.cpp (no CMake edit; run idf.py reconfigure once)

Robot

Trigger it by voice or controller

BBData.h (scenario enum, append) + main/src/States/ScenarioRoutineMappings.cpp (row) + Scenarios.h (activation rows)

Common + Robot

Make it happen randomly, on its own

IdleState::RandomRoutines in main/src/States/IdleState.cpp (add with a weight)

Robot

Generate phonemes for a new voice command

managed_components/espressif__esp-sr/tool/multinet_g2p.py (run, don't edit)

Robot

Tune voice recognition

fuzzyCore/fuzzyThreshold on the row in Scenarios.h; un-silence AudioFrontend logs in main/main.cpp

Common + Robot

Use object detection in a routine

The 5-call lifecycle on ServiceLocator::ObjDetInstance (main/src/Services/ObjDet.h); classes in ObjDetClass.h

Robot

Change what a controller button sends

main/src/Screens/HomeScreen.cpp (handleButtonEvent/loop) + main/src/Components/GuideElement.cpp (hint text)

Controller

Add an action-list entry

One row in main/src/Util/ScenarioMapping.h

Controller

Invent a new Bluetooth command

CtrlData.h (enum, append) + a sendCommand() call on the controller + a handler such as IdleState::onCommand on the robot

All three


Troubleshooting

Symptom

Fix

Monitor: No routine mapped for scenario ...

Add the row (and #include) in ScenarioRoutineMappings.cpp. The scenario/data pair must match the activation row exactly.

Voice command never triggers

Re-generate the phoneme string — never retype it. Verify your generator with "tell me a joke"TfL Mm c qbK. Still flaky? Un-silence AudioFrontend logs (Topic 2) and nudge fuzzyThreshold up.

multinet_g2p.py fails with a missing module or NLTK LookupError

pip install g2p_en pandas; for the NLTK error, download averaged_perceptron_tagger_eng (exact command in Topic 2).

New phrase compiles but the robot skips it silently

Missing buildMappings() registration line in Phrases.cpp.

Build error: Routine exceeds RoutineBufSize

Bump RoutineBufSize in main/src/States/StateRoutineFactory.h or move large members to the heap.

Detection always comes back empty

The classifier wants ≥ 0.90 confidence for a solo answer. More light, closer object, plainer background — and make sure it's one of the ten known classes.

undefined reference at link time after adding a new file

Run idf.py reconfigure once (either repo — the CMake source scan is cached), then build again.

Controller shows wrong/old text, or buttons act strangely

Robot and controller were flashed from different versions of ButterBot-Common. Sync the submodule edits in both checkouts and reflash both devices.

The smallest app partition is nearly full warning, or Error: app partition is too small for binary

The firmware is a snug fit — expect only a few dozen KB of headroom, so keep additions modest (this guide's whole project adds under 5 KB). If you hit the hard error, remove some of your earlier experiments, or trim stock phrase variants you don't care about.

I want my stock robot back

In the project folder: git checkout -- . && git submodule foreach --recursive git checkout -- . then rebuild and flash. Works in either repo — remember the shared files live in the submodule, which is why the second half of the command matters.


You now know every moving part: phrases, routines, voice commands, vision, and the controller link. Want an AI pair-programmer to do the typing while you do the ideas? Guide 3 — Claude Code and Guide 4 — OpenAI Codex show exactly that — and every section of this guide is written to work as a spec you can paste straight into them.

Ready for the deep end? Guide 6 — Give Your Butter Bot a New Voice is our advanced guide: tune the robot's pitch and speaking speed, or teach it an entirely new voice.

Did this answer your question?