On this page

Poppet — the body

A deep dive into the firmware half of familiar: the ESP32-S3 program that runs the M5Stack StackChan — its ears, voice, face, neck, and the on-device wake word. Built with ESP-IDF v5.5.4; build instructions live in poppet/BUILD.md.

Two layers: the core and the character

Poppet is deliberately two code bases in one tree:

The only seam between them is main/hal/board/hal_bridge.{h,cc} — a small namespace the character layer uses to reach the core (start the assistant, lock the display, play a sound, toggle listening) without the two worlds including each other’s headers.

Boot to conversation

  1. Power on → HAL init, boot logo. If the device is unconfigured (or you asked for settings), a touch UI handles Wi-Fi provisioning (hotspot by default) and servo calibration.
  2. Discovery. The device GETs the URL you flashed into CONFIG_OTA_URL — your server’s /discover endpoint — and receives {ws_url: …}. That is the only address baked into the device; the server can move as long as discovery answers. If the URL is blank, the device refuses to open any socket at all and idles — fail-safe, zero egress.
  3. Handshake. It opens the WebSocket with Protocol-Version: 2, sends its hello (codec, features, its tool catalog inline), and gets back the server’s hello — which dictates audio parameters and carries the wall clock, so an unconfigured-NTP device still knows the time.
  4. Idle. The face appears, idle motion starts, and the wake-word engine listens. From here the loop is: wake → stream mic audio → server replies with captions + Opus speech → back to idle.

The device and the wire are half-duplex in this release: while the familiar speaks, the mic is off. Audio is Opus, 16 kHz mono, 60 ms frames both directions, with credit-based flow control so the ESP32’s fixed decode buffer can never overrun (Protocol v2 has the full contract).

The wake word

The stock firmware wakes on Espressif’s prebuilt WakeNet phrases (“Hi, ESP” et al.). Poppet instead ships a microWakeWord engine (main/stackchan/wake_word/microwakeword.{h,cc}) — the streaming TFLite-Micro architecture ESPHome uses in production — running a custom “Hey Artemis” model:

The shipped model is trained on its author’s voice. Wake word is the full recipe for training your own phrase — including the hard-won lesson that hard-negative counts must stay modest — and menuconfig can always fall back to the stock WakeNet phrases.

The face: avatars and familiars

main/stackchan/avatar/ is a small rig, not a video player:

The switch is a device-side tool: the LLM calls self.avatar.set_familiar, the registry (avatar/skins/familiar/familiar_registry.cpp) builds the new avatar under the LVGL lock and swaps it live, and the choice persists in NVS. The valid-name list in the tool description is generated from the registry, so the LLM’s menu can never drift from the code.

Adding your own familiar is three steps: draw (or script — see main/assets/familiars/gen_familiars.py) five sprites (<name>_face.png 320×240, <name>_eye_open/closed.png, <name>_mouth_open/closed.png), drop them into main/assets/assets_bin/, and add the name to knownFamiliars(). Rebuild, reflash the assets image, and “be a dragon” works.

The neck: servos and idle motion

Two Feetech SCS serial servos (yaw + pitch) give the head its life, wrapped in main/stackchan/motion/ with critically-damped spring physics — moves ease in and settle rather than snap, and torque releases after settling so the servos aren’t buzzing at hold all day.

Idle motion runs on profiles (normal, looking-around, sleepy, surveillance) with randomized cadence, and it’s polite about it: face-tracking mode halves the amplitude so the head doesn’t snap away from a person it’s looking at, an empty room backs the cadence off to save servo lifespan, and sleep parks the head and cuts torque so it droops — asleep, not powered-down.

The rest of the body

Build system notes

For toolchain setup, flashing, and the network-isolation guide (the actual privacy boundary), see poppet/BUILD.md.