Step-by-step for taking a fresh M5Stack StackChan from the box to a working,
fully-self-hosted voice robot. The backend (all four Docker containers on a
single Docker host) is assumed to already be deployed — if you're
starting fresh, skim README.md first.
This guide assumes you've already substituted the placeholders from
README.md's "Configuring for your environment" section with the real values for your setup.
Run these from any LAN-connected machine — should all succeed within a second or two:
curl -s http://<XIAOZHI_HOST>:8003/xiaozhi/ota/
# Expect: OTA接口运行正常,向设备发送的websocket地址是:ws://<XIAOZHI_HOST>:8000/xiaozhi/v1/
curl -s http://<XIAOZHI_HOST>:8081/health
# Expect: {"status":"ok", ...}
curl -s http://<XIAOZHI_HOST>:8090/health
# Expect: {"status":"ok", ...}If any fails, fix the backend before dealing with the robot:
- OTA down →
ssh <XIAOZHI_USER>@<XIAOZHI_HOST> 'docker logs --tail 40 xiaozhi-esp32-server' - Dashboard/bridge down →
ssh <XIAOZHI_USER>@<XIAOZHI_HOST> 'docker logs --tail 40 bridge' - dotty-behaviour down →
ssh <XIAOZHI_USER>@<XIAOZHI_HOST> 'docker logs --tail 40 dotty-behaviour' - dotty-pi down →
ssh <XIAOZHI_USER>@<XIAOZHI_HOST> 'docker logs --tail 40 dotty-pi'
The StackChan ships from M5Stack with stock firmware that:
- Shows a pairing-code screen on first boot
- Uses BLE-based WiFi provisioning via M5Stack's StackChanWorld phone app
- Registers the device to M5Stack's cloud for device management
There is no SoftAP captive portal in stock firmware (some older third-party xiaozhi builds had one; the shipped firmware does not).
To run the StackChan fully self-hosted (no phone-app account, no vendor cloud,
your own xiaozhi-server as the endpoint), reflash it with Dotty's pinned
BrettKinny/StackChan@dotty firmware fork. The official m5stack/StackChan
tree is its upstream, but it does not contain Dotty's state, motion, LED, and
perception changes.
The StackChan's on-device Settings app has no Advanced Options or OTA URL
editor. The similarly named Advanced tab documented by Xiaozhi belongs to
its browser-based SoftAP captive portal and appears only when that provisioning
mode is active. The server address is compiled into the firmware. Earlier
fw-v* GitHub releases carried binaries locked to the maintainer's LAN
server; those binaries have been removed and the releases are now
source-only. A portable prebuilt build is tracked in
#190. Build from
source with your own CONFIG_OTA_URL as shown below.
The exact firmware source is the firmware/ submodule of this repo. It pins
BrettKinny/StackChan@dotty, whose firmware/fetch_repos.py pulls
78/xiaozhi-esp32 v2.2.4 and applies the StackChan integration patch.
Requires ESP-IDF v5.5.4. Easiest path: the official
espressif/idf:v5.5.4 Docker image.
git clone --recursive https://github.com/BrettKinny/dotty-stackchan.git
cd dotty-stackchan/firmware/firmwarePoint the firmware at your xiaozhi-server for OTA. Edit
sdkconfig.defaults and add (or modify) the line:
CONFIG_OTA_URL="http://<XIAOZHI_HOST>:8003/xiaozhi/ota/"
Trailing slash matters — that's the path the server exposes. The build stops
with an error while the <XIAOZHI_HOST> placeholder is still there.
sdkconfig.defaults seeds a newly generated sdkconfig; it does not overwrite
an existing one. On a repeat build, either make the same change through
idf.py menuconfig (Xiaozhi Assistant → Default OTA URL) or remove the
generated sdkconfig before rebuilding. Verify the effective value after
configuration with:
grep '^CONFIG_OTA_URL=' sdkconfigdocker run --rm -it -v "$PWD":/project -w /project \
espressif/idf:v5.5.4 \
bash -c "python3 fetch_repos.py && idf.py build"fetch_repos.py pulls the xiaozhi-esp32 dependency and applies the
StackChan patches; idf.py build produces build/*.bin.
Plug the device in via USB-C. It typically appears as /dev/ttyACM0 on
Linux (/dev/cu.usbmodem* on macOS — adapt the --device flag).
docker run --rm -it --device=/dev/ttyACM0 \
-v "$PWD":/project -w /project \
espressif/idf:v5.5.4 \
idf.py -p /dev/ttyACM0 flashIf the build reported a non-standard flash command (e.g. a merged-bin flow), use that instead.
Seeing “Welcome! Let's get started.” is expected on a fresh flash. It is
the StackChan app-configuration wizard, controlled by an NVS flag; it does not
mean the compiled CONFIG_OTA_URL was ignored.
For a self-hosted setup:
- Tap Skip on the welcome screen. This bypasses the M5Stack account/BLE wizard for the current boot.
- If the launcher remains visible, open SETUP, then use its home control to exit. Closing SETUP starts the Xiaozhi voice application.
- With no saved SSID, the pinned build enters its default Xiaozhi hotspot
provisioner. Join the temporary
Xiaozhi-*Wi-Fi network and open the URL shown on the robot (normallyhttp://192.168.4.1). - Select the robot's 2.4 GHz Wi-Fi network. The browser portal also has an
Advanced tab where you can confirm or override
ota_url. - After Wi-Fi connects, the device POSTs to the compiled/persisted OTA URL, receives the WebSocket endpoint, and connects.
Tail the server logs while the device boots so you can watch the handshake happen (see step 4 below).
The pinned firmware does not define CONFIG_WIFI_SSID or
CONFIG_WIFI_PASSWORD; adding those names to sdkconfig.defaults has no
effect. Wi-Fi credentials live in NVS and are supplied through the Xiaozhi
hotspot portal described above (or retained from an earlier compatible
provisioning).
The device must land on a 2.4 GHz network. ESP32-S3 does not do 5 GHz.
While the freshly flashed device boots, tail the server logs in one terminal:
ssh <XIAOZHI_USER>@<XIAOZHI_HOST> 'docker logs -f xiaozhi-esp32-server'Within ~30s of reboot you should see (in order):
- A
POST /xiaozhi/ota/line — device asking for config - A WebSocket connect line with the device's MAC
- A
vad/asrinit line when the device first starts listening
If the device isn't on the list after 60s:
- Confirm it actually joined your LAN: check your router's DHCP table for a new ESP32-looking MAC
- If it's on the LAN but not reaching OTA, try the OTA URL from a phone on the same WiFi — it should return plain text, not a connection error
- Power-cycle the device (hold power 3s, unplug, replug)
- Wait for the face to change from "connecting" to a neutral/idle expression. That means the WebSocket is up.
- Say a wake phrase and then a short message (the default wake phrase depends on your xiaozhi-esp32 build — consult its README). Some builds also support a press-to-talk button on the side.
- Watch the logs — you should see:
- An ASR line with transcribed text
- A
PiVoiceLLMcall (routes to the dotty-pi brain container) - A TTS line with the response text
- Face animation changes to match the leading emoji
- The robot speaks. If you hear audio but no face change, check that the response starts with one of: 😊 😆 😢 😮 🤔 😠 😐 😍 😴.
Expected first-audio latency: ~5–8s after you stop speaking (the pi agent cold-start on first turn). Subsequent turns are faster once the model is warm.
All of these are edits to data/.config.yaml on the Docker host followed by
docker compose restart.
| Complaint | Edit | File |
|---|---|---|
| "It cuts me off mid-sentence" | raise min_silence_duration_ms from 700 to e.g. 1000 |
data/.config.yaml → VAD.SileroVAD |
| "It waits forever after I stop talking" | lower min_silence_duration_ms to 400 |
same |
| "I don't like the voice" | change voice: to any Edge Neural voice |
data/.config.yaml → TTS.EdgeTTS / StreamingEdgeTTS |
| "Responses are too long" | add "Keep replies under 20 words." to the persona | data/.config.yaml → prompt: block |
| "Too slow to reply" | switch LLM model | see dotty-pi/README.md for model selection rules |
| "No facial expression change" | check response actually starts with a supported emoji (tail logs) | — |
Hold the power button for 5s to force reboot. Hold for 10s+ to enter download mode on some boards (used for firmware reflashing).
To recover, re-flash from step 2c. If the device won't enter download mode via the button, hold the BOOT button (if present) while pressing RESET, then release RESET, then release BOOT.
This section may match older xiaozhi-esp32 builds that ship a SoftAP captive portal. Stock M5Stack firmware does NOT work this way — if you're on current stock, see sections 1–2 above.
Some older xiaozhi builds expose a SoftAP captive portal on first boot:
- Device creates an open SSID like
Xiaozhi-XXXX,ML307R-XXXX, orDevice-XXXX. - Join it from your phone; a captive portal typically auto-pops. If not,
browse to
http://192.168.4.1. - Pick your 2.4 GHz LAN SSID, enter the password.
- Open the portal's Advanced tab and paste the OTA URL:
http://<XIAOZHI_HOST>:8003/xiaozhi/ota/(trailing slash required). - Save. Device reboots and connects.
If you have a build where this works, it's the fastest provisioning flow. It just isn't what M5Stack ships today.
This Advanced control is a tab in the browser portal at 192.168.4.1, not
an item in the StackChan's on-device Settings app.
- Tail voice pipeline:
ssh <XIAOZHI_USER>@<XIAOZHI_HOST> 'docker logs -f xiaozhi-esp32-server' - Tail brain container:
ssh <XIAOZHI_USER>@<XIAOZHI_HOST> 'docker logs -f dotty-pi' - Tail perception/behaviour:
ssh <XIAOZHI_USER>@<XIAOZHI_HOST> 'docker logs -f dotty-behaviour' - Admin dashboard: open
http://<XIAOZHI_HOST>:8081/uiin a browser. - Dashboard health:
curl http://<XIAOZHI_HOST>:8081/health
The /xiaozhi/admin/* routes (inject-text, say, set-state, play-asset, …) let
anyone who can reach port 8003 make the robot speak, move, and take photos.
make setup generates a shared secret, DOTTY_ADMIN_TOKEN, into the repo-root
.env — once set, xiaozhi-server rejects admin calls that don't carry it as an
X-Admin-Token header.
All callers read the same variable: copy the generated value into the
deploy-dir .env of each sibling service —
# on the Docker host, same value in each:
/mnt/user/appdata/dotty-bridge-src/.env # bridge dashboard
/mnt/user/appdata/dotty-behaviour-src/.env # behaviour consumers
<dotty-pi deploy dir>/.env # voice tools (adminFetch)— then restart the four containers in the same window. Leaving the variable
unset everywhere keeps the legacy permissive mode (admin routes open on the
LAN); setting it on only some services 401s the ones missing it. See the
"Admin API auth" section of .env.example.