muse-gadget-sdk / esp32
AGENTS.md2.6k starsChanged 24 days agoApache-2.0View on GitHub
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --> How…
Trending
More talk lately.thin dataabout the whole repository
Who's talking about facebookincubator/muse-gadget-sdk
- @j4ck.xyz“I still wouldn't trust Meta with my personal context for agents, but what they're doing with Muse is pretty …”Bluesky · Oct 2, 2026 · 7 likes
- A Hacker News story“Open source SDK to build Muse gadgets”Hacker News · Oct 3, 2026 · 2 points
- ComBba/muse-gadget-sdk“CI preflight: wait for the installer serial close before SDK token setup”GitHub · Oct 17, 2026 · 1 reactions
- @sarubot.bsky.social“muse-gadget-sdk — Metaが放つ、ESP32やラズパイをAIガジェットへ変貌させるSDK。今週スター急増中の注目作! …”Bluesky · Oct 7, 2026 · 1 likes
- aegisfleet/tool-survey-report“feat: add survey report for Muse Gadget SDK”GitHub · Nov 9, 2026
The biggest 5 of 14 mentions linking to the repository that our crawl found. Each line is their own headline.
See all 14
Talked about on GitHub, Bluesky and Hacker News (14 times).
On GitHub · 11
- Oct 17, 2026CI preflight: wait for the installer serial close before SDK token setup — ComBba/muse-gadget-sdk1 reaction · 2 comments
- Nov 9, 2026feat: add survey report for Muse Gadget SDK — aegisfleet/tool-survey-report0 reactions · 4 comments
- Oct 14, 2026Fix S3-2.06 ES7210 microphone and native USB serial console — polygoku/waveshare-amoled-206-debug0 reactions · 5 comments
- Oct 12, 2026Integration blocker: native Muse gadget command invocation — 1chris2many/muse-portal-kit0 reactions
- Oct 12, 2026Integration blocker: authoritative per-device reply correlation — 1chris2many/muse-portal-kit0 reactions · 2 comments
6 more on GitHub
- Oct 11, 2026[fork CI preflight] #181 candidate a6fd89c — ComBba/muse-gadget-sdk0 reactions · 1 comment
- Oct 11, 2026[fork CI] #179 Use the custom avatar in the UI simulator — ComBba/muse-gadget-sdk0 reactions · 1 comment
- Oct 11, 2026[fork CI] #181 Add validated avatar restore and host status — ComBba/muse-gadget-sdk0 reactions · 1 comment
- Oct 11, 2026[fork CI] #180 Validate avatar candidates before replacing the current renderer — ComBba/muse-gadget-sdk0 reactions · 1 comment
- Oct 11, 2026[fork CI] #174 Reject incomplete bench screenshots without padding pixels — ComBba/muse-gadget-sdk0 reactions · 1 comment
- Oct 11, 2026[fork CI] #177 Abort bench screenshots when USB console output stalls — ComBba/muse-gadget-sdk0 reactions · 1 comment
On Bluesky · 2
- Oct 2, 2026I still wouldn't trust Meta with my personal context for agents, but what they're doing with Muse is pretty cool & fun icl… — @j4ck.xyz7 likes and reposts
- Oct 7, 2026muse-gadget-sdk — Metaが放つ、ESP32やラズパイをAIガジェットへ変貌させるSDK。今週スター急増中の注目作! ・C言語製のESP32/Linux用SDK。センサーやオーディオをMuseエコシステムに即統合 ・AIエージェント「Muse… — @sarubot.bsky.social1 likes and repost
On Hacker News · 1
- Oct 3, 2026Open source SDK to build Muse gadgets2 points
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,439 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository48 other files it gives its agents.
- linux/AGENTS.mdAGENTS.md
- gadget-apple-homepod-miniSkill
- gadget-apple-tv-4kSkill
- gadget-brother-printersSkill
- gadget-dyson-pure-hot-cool-hp04Skill
- gadget-ecowitt-gatewaysSkill
- gadget-elgato-key-lightSkill
- gadget-epson-printersSkill
- gadget-esphome-devicesSkill
- gadget-espsomfy-rtsSkill
- gadget-eufy-robovac-tuyaSkill
- gadget-freebox-player-popSkill
- gadget-google-castSkill
- gadget-google-home-maxSkill
- gadget-google-home-speakerSkill
- gadget-google-nest-audioSkill
- gadget-google-nest-hub-maxSkill
- gadget-google-nest-hubSkill
- gadget-google-nest-miniSkill
- gadget-google-nest-thermostatsSkill
- gadget-google-pixel-tabletSkill
- gadget-google-tv-streamerSkill
- gadget-hp-color-laserjet-pro-m254dwSkill
- gadget-irobot-roomba-braavaSkill
- gadget-lg-webos-tvsSkill
- gadget-logitech-squeezeboxSkill
- gadget-lutron-smart-bridgesSkill
- gadget-meross-smart-plugsSkill
- gadget-miele-g7566-dishwasherSkill
- gadget-moonraker-3d-printersSkill
- gadget-philips-hue-bridgesSkill
- gadget-ratgdo-v32-garage-doorSkill
- gadget-roborock-vacuumsSkill
- gadget-roku-tvs-and-playersSkill
- gadget-samsung-tizen-tvsSkill
- gadget-shelly-plugsSkill
- gadget-sonoff-rf-bridgeSkill
- gadget-sonos-speakersSkill
- gadget-teslaSkill
- gadget-tplink-kasa-ep10Skill
- gadget-tplink-kasa-ep25Skill
- gadget-tuya-wifi-devicesSkill
- gadget-unifi-network-consolesSkill
- gadget-velux-klf200Skill
- gadget-vizio-d40f-g9Skill
- gadget-wyze-rtsp-camerasSkill
- gadget-yi-hack-camerasSkill
- gadget-zigbee2mqtt-gatewaysSkill
The file
Show its rules and text
We split this AGENTS.md into the rules it gives an agent
19 rules. About 0 are common, 4 are distinctive, and 15 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
15
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 110
Every application build needs the user's SDK token (mgst_…, from gadgets.muse.ai > Account > SDK tokens).
- Very specific: line 215
All boards share managed_components/ and dependencies.lock in this directory.
- Very specific: line 235
Follow devices/AGENTS.md, which covers boards with a status light, a status screen or the full UI from the overlay to verification.
- Distinctive: line 250
When you're asked to flash, work out which board is attached instead of asking which one it is or assuming the default.
- Distinctive: line 281
Ask the chip: When you can write to the port (this resets the board):
- Very specific: line 292
Fall back to a read-only capture: If the board is mid-run and you can't write to the port, the ## Monitor recipe below reads it without …
- Distinctive: line 309
Ask the user only when these come up empty or contradict each other, and say what you found and what's ambiguous rather than asking from …
- Very specific: line 401
When someone asks to put their own avatar on their board (or to update or change it), run this from esp32/ with the sandbox off (it uses …
- Very specific: line 419
2: no board, or it can't do this (or another avatar.py is still running: let it finish).
- Very specific: line 433
Don't commit anything in components/muse/avatar/.
- Very specific: line 450
Don't restyle them or replace their headers with the Apache one.
- Very specific: line 531
Every way out of a call sends POST /voice/end-call while the connection is up: closing the media alone doesn't end a call.
- Very specific: line 629
write: {"path": "notes/today.md", "text": "...", "overwrite": true, "append": false, "create_parent": true}, or data_base64 in place of …
- Distinctive: line 637
Keep within the session's bounds:
- Very specific: line 773
Don't move offsets in partitions.csv.
- Very specific: line 830
Read slow or I2C sensors in a background task and have sensors.read return the latest value.
- Very specific: line 850
Don't use hatch in a new file name or identifier.
- Very specific: line 879
Run one idf.py build first: test_link_discovery compiles cJSON from managed_components/, and that directory only exists after a build.
- Very specific: line 902
If you flashed, the boot log reaches starting, and the log shows no panic or reboot loop.
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
<!--
Copyright (c) Meta Platforms, Inc. and affiliates.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->AGENTS.md
How to build this firmware and flash it onto your own ESP32 board. See
README.md for a shorter human overview.
What this is
The ESP32 Device SDK: the ESP-IDF firmware (muse-gadget) powering Muse
Home Link. It pairs with a phone over BLE, joins Wi-Fi, and holds an encrypted
Noise session to a VM, with an optional home-network tunnel. main/main.c is the entry
point and main/app.c holds most of the logic. components/muse adds a
avatar/voice/settings UI on boards with a display.
Prerequisites
- ESP-IDF v6.0.3, installed and activated (
. $IDF_PATH/export.sh) soidf.pyis onPATH. Other versions are unsupported. Errors about missing IDF headers such asgcm.horgpio_ll.husually mean the wrong IDF tag. - Python 3 and a C/C++ compiler (
cc,c++) for the host tests. - A USB data cable. Flashing needs access to the serial port. A sandboxed agent usually has to run flash and monitor commands outside the sandbox.
Supported boards
Board overlays live in devices/, which has a README.md listing each board's
hardware, features, and where to buy it, and an AGENTS.md recipe for adding
one. Its Vendor sources table lists each vendor's code for its boards: read it
before adding a feature to one.
| Board | Target | Overlay(s) after sdkconfig.defaults | Helper |
| ESP32-C5 DevKitC-1 (default) | esp32c5 | none | tools/board.sh devkit |
| ESP32-C6 devkit without PSRAM | esp32c6 | devices/sdkconfig.c6-nopsram | tools/board.sh c6-nopsram |
| Espressif ESP32-S3-DevKitC-1 v1.1 (N8R8) | esp32s3 | devices/sdkconfig.espressif-s3-devkitc-1 | tools/board.sh espressif-s3-devkitc-1 |
| ideaspark ESP32 + 1.9" ST7789 | esp32 | devices/sdkconfig.ideaspark | tools/board.sh ideaspark |
| Waveshare ESP32-C6-LCD-1.47 | esp32c6 | devices/sdkconfig.waveshare-c6-lcd-147 | tools/board.sh waveshare-c6-lcd-147 |
| ESP32-2432S028 Cheap Yellow Display (experimental) | esp32 | devices/sdkconfig.esp32-2432s028 | tools/board.sh esp32-2432s028 |
| ESP32-C3 with 0.42" OLED | esp32c3 | devices/sdkconfig.c3-oled-042 | tools/board.sh c3-oled-042 |
| Waveshare ESP32-S3-1.54inch-ePaper V2 | esp32s3 | devices/sdkconfig.waveshare-s3-epaper-154 | tools/board.sh waveshare-s3-epaper-154 |
| Waveshare ESP32-S3-ePaper-1.54G | esp32s3 | devices/sdkconfig.waveshare-s3-epaper-154g | tools/board.sh waveshare-s3-epaper-154g |
| Seeed SenseCAP Indicator | esp32s3 | devices/sdkconfig.sensecap-indicator | tools/board.sh sensecap-indicator |
| Seeed reTerminal E1001 | esp32s3 | devices/sdkconfig.reterminal-e1001 | tools/board.sh reterminal-e1001 |
| Seeed reTerminal E1002 | esp32s3 | devices/sdkconfig.reterminal-e1002 | tools/board.sh reterminal-e1002 |
| Seeed reTerminal Sticky (experimental) | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.reterminal-sticky | tools/board.sh reterminal-sticky |
| Seeed reTerminal Sticky Link status (experimental) | esp32s3 | devices/sdkconfig.reterminal-sticky-link | tools/board.sh reterminal-sticky-link |
| Home Assistant Voice Preview Edition | esp32s3 | devices/sdkconfig.home-assistant-voice | tools/board.sh home-assistant-voice |
| Seeed reSpeaker Lite with XIAO ESP32-S3 (experimental) | esp32s3 | devices/sdkconfig.seeed-respeaker-lite | tools/board.sh seeed-respeaker-lite |
| Waveshare ESP32-S3-Touch-AMOLED-1.75C | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-waveshare-s3-175c | manual (below) |
| Waveshare ESP32-S3-Touch-AMOLED-1.75 | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-waveshare-s3-175 | manual (below) |
| Waveshare ESP32-S3-Touch-LCD-1.9 | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-waveshare-s3-lcd19 | tools/muse/board.sh build lcd19 |
| Waveshare ESP32-S3-Touch-AMOLED-2.16 | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-waveshare-s3-216 | tools/muse/board.sh build s3-216 |
| Waveshare ESP32-S3-Touch-AMOLED-1.43C | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-waveshare-s3-143c | tools/muse/board.sh build 143c |
Espressif ESP-Mosaico (experimental; ESP-IDF master, see devices/README.md) | esp32s31 | devices/sdkconfig.muse;devices/sdkconfig.muse-esp-mosaico;devices/sdkconfig.muse-esp-mosaico-iris | tools/muse/board.sh build mosaico |
| Waveshare ESP32-S3-Touch-AMOLED-2.06 | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-waveshare-s3-206 | tools/muse/board.sh build s3-206 |
| OSTB EchoEar-2ST (experimental) | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-ostb-echoear-2st | tools/muse/board.sh build echoear |
| Espressif ESP32-S3-BOX-3 | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-espressif-box-3 | tools/muse/board.sh build box3 |
| ALIENTEK ATK-DNESP32S3-BOX V1.1 | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-atk-dnesp32s3-box | tools/muse/board.sh build atk-box |
| AIPI Lite | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-aipi | manual |
| Waveshare ESP32-C6-Touch-AMOLED-1.8 | esp32c6 | devices/sdkconfig.muse;devices/sdkconfig.muse-waveshare-c6-18 | manual |
| Waveshare ESP32-S3-Touch-AMOLED-1.8 | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-waveshare-s3-18 | tools/muse/board.sh build s3-18 |
| Waveshare ESP32-C6-Touch-AMOLED-2.06 | esp32c6 | devices/sdkconfig.muse;devices/sdkconfig.muse-waveshare-c6-206 | tools/muse/board.sh build c6-206 |
| Seeed SenseCAP Watcher | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-sensecap-watcher | manual |
| M5Stack Cardputer ADV (experimental) | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-m5stack-cardputer-adv | tools/muse/board.sh build cardputer-adv |
| M5Stack StickS3 | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-m5stack-sticks3 | manual |
| Elecrow CrowPanel Advance 4.3 V1.3 | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-crowpanel-advance-43 | tools/muse/board.sh build crowpanel-43 |
| M5Stack StopWatch | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-m5stack-stopwatch | manual |
| Seeed XIAO ESP32-S3 1.47 Touch Display | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-seeed-xiao-s3-147 | tools/muse/board.sh build xiao147 |
| M5Stack CoreS3 | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-m5stack-cores3 | tools/muse/board.sh build cores3 |
| Freenove FNK0104B | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-fnk0104b | tools/muse/board.sh build fnk0104b |
| VN ESP32-S3 1.83-inch NV3023 | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-vn-s3-183 | tools/muse/board.sh build vn183 |
| Guition JC3248W535 | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-guition-jc3248w535 | tools/muse/board.sh build jc3248w535 |
| Waveshare ESP32-S3-Touch-LCD-7 | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-waveshare-s3-lcd7 | tools/muse/board.sh build lcd7 |
| Waveshare ESP32-S3-Touch-LCD-1.28 | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-waveshare-s3-lcd128 | tools/muse/board.sh build lcd128 |
| Waveshare ESP32-S3-Touch-LCD-1.46 | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-waveshare-s3-146 | tools/muse/board.sh build 146 |
| Waveshare ESP32-S3-Touch-AMOLED-1.43 | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-waveshare-s3-143 | tools/muse/board.sh build 143 |
| Waveshare ESP32-S3-Touch-LCD-3.49 V2 | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-waveshare-s3-lcd349 | tools/muse/board.sh build lcd349 |
| Waveshare ESP32-P4-WIFI6-Touch-LCD-3.5 | esp32p4 | devices/sdkconfig.muse;devices/sdkconfig.muse-waveshare-p4-lcd35 | tools/muse/board.sh build p4lcd35 |
| SpotPear ESP32-AI-1.28 v1.0 (experimental) | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-spotpear-esp32-ai-128 | tools/muse/board.sh build spotpear |
| VIEWE SmartRing-Plus | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-viewe-smartring-plus | tools/muse/board.sh build smartring-plus |
| M5Stack StickC Plus2 | esp32 | devices/sdkconfig.muse;devices/sdkconfig.muse-m5stack-stickc-plus2 | manual |
| M5Stack Core2 (v1.0) | esp32 | devices/sdkconfig.muse;devices/sdkconfig.muse-m5stack-core2 | tools/muse/board.sh build core2 |
| FoloToy AI Passport (experimental) | esp32c3 | devices/sdkconfig.muse;devices/sdkconfig.muse-ai-passport | tools/muse/board.sh build ai-passport |
| Deotaland RoRoLee | esp32s3 | devices/sdkconfig.muse;devices/sdkconfig.muse-deotaland-rorolee | tools/muse/board.sh build rorolee |
The default profile expects the C5 DevKitC-1: an addressable status LED on
GPIO27, the BOOT button on GPIO28 (active low), 8 MB flash and quad PSRAM.
The DevKitC-1's LED takes red first (CONFIG_HOMEHUB_LED_RGB_ORDER, on by
default). If yours shows green as red and amber as green, it takes green first
like most WS2812s: turn the option off in idf.py menuconfig, or in
build-devkit/sdkconfig, and rebuild.
Build
Every application build needs the user's SDK token (mgst_…, from gadgets.muse.ai >
Account > SDK tokens). Ask for it, then set CONFIG_GADGET_SDK_TOKEN="mgst_…"
in that build directory's sdkconfig (or with idf.py menuconfig) before
building. Without it the build warns, and the gadget will stop pairing once
Muse requires tokens. Never commit the token or print it in full.
The opt-in CONFIG_MUSE_POWER_TEST image is an isolated board-load
characterization harness, not a pairing application, and needs no SDK token or
network credentials. Power measurement starts at docs/power/README.md. Read
docs/power/ppk2.md before any PPK2 use (authorization and fixture gates,
continuous source ownership, USB unplug/replug), and
docs/power/sensecap-watcher.md for the Watcher's build and procedure. Never
enable source output with unknown wiring, a connected battery, or
USB-charger backfeed into PPK2. Other boards reuse the shared
muse_power_test_run() core through boards/muse_power_test_board.h hooks
and board-owned matrices, driven by tools/power/power_sweep.py; do not fork
or rename the core for the next board.
DevKitC-1 (default)
idf.py build # -> build/muse-gadget.bin, config in build/sdkconfigOther boards, with the helper
tools/board.sh BOARD [build|flash|monitor|flash-monitor] [PORT] sets the
target and overlays and gives each board its own build-<board>/ directory
and build-<board>/sdkconfig:
tools/board.sh sensecap-indicator build
tools/board.sh ideaspark flash-monitor /dev/cu.usbserial-110Without a port it uses the only matching serial port, and fails if it finds
none or more than one. If idf.py is not on PATH, it sources export.sh
from $IDF_PATH, ~/esp/esp-idf-v6.0.3, ~/esp/esp-idf-v6 or ~/esp/esp-idf.
The Sticky's Muse and Link status profiles build in build-reterminal-sticky/
and build-reterminal-sticky-link/. Set the SDK token in the chosen build
directory through idf.py -B <dir> menuconfig.
Home Assistant Voice Preview Edition
tools/board.sh home-assistant-voice build builds a status-and-voice gadget:
the LED ring shows the status colours, holding the centre button records a
voice note that Muse answers in the app, and the dial sets the speaker volume
(shown on the ring, kept across restarts). It advertises as
MuseGadget-ha-voice-XXXXXX.
- The console and flashing go through the S3's own USB-Serial-JTAG, which
shows up as
/dev/cu.usbmodem*like a DevKitC-1. With both plugged in, pass the port, and check it withpython -m esptool read-macbefore flashing. - Flashing replaces the vendor firmware on the S3 only. The XMOS audio chip keeps its own firmware; never reflash it.
- While voice chat is ready, the centre button is push-to-talk. Turn the mute switch on to get its setup role back (pairing confirmation, the 5 s factory-reset hold).
Boards with the full UI, by hand
tools/muse/board.sh build|flash <s3|s3n|s3-18|s3-216|s3-206|143c|143|146|aipi|atk-box|box3|c6|c6-206|watcher|sticks3|crowpanel-43|plus2|cardputer-adv|stopwatch|xiao147|cores3|core2|jc3248w535|lcd7|lcd128|lcd349|spotpear|vn183|ai-passport|echoear|p4lcd35|rorolee|lcd19|smartring-plus> [SERIAL|PORT]
builds one board in build-muse-<profile>/, logs to
/tmp/muse_build_<board>.log, and clears managed_components/ before and
after so it doesn't clash with other boards. When flashing, it finds the
board's port with tools/muse/ports.py, by the USB device behind it rather
than the port name, which changes when boards are re-cabled. With several
boards of one kind attached, pass the device's USB serial number (on the chip's
own USB serial port, its MAC) or the port; tools/muse/ports.py --list shows
them. It finds ESP-IDF the way tools/board.sh does, trying
~/.espressif/esp-idf-v6.0.3 first. For an install anywhere else, set
IDF_EXPORT to its export.sh, e.g. in your shell profile:
export IDF_EXPORT=/path/to/esp-idf/export.sh. tools/muse/avatar.py builds
through board.sh, so it needs the same.
For bench testing, MUSE_BENCH=1 tools/muse/board.sh build|flash ... adds
devices/sdkconfig.muse-bench and uses build-muse-<profile>-bench/. That
turns on screenshots: tools/muse/snap.py PORT KEYS OUT.png sends bench keys
and saves the screen, and >face=thinking (or idle, listening,
speaking, error, boot, off, happy) in KEYS picks the avatar mode first.
On touch boards, >ui.settings=wifi (or home, muse, ble, sound,
sleep, battery, power) opens that settings page for the shot.
Screenshots are off in normal builds because each one takes a buffer the size
of the screen. >face= works in any build. Or run idf.py directly:
idf.py -B build-muse-aipi -DIDF_TARGET=esp32s3 \
-DSDKCONFIG=build-muse-aipi/sdkconfig \
-DSDKCONFIG_DEFAULTS="sdkconfig.defaults;devices/sdkconfig.muse;devices/sdkconfig.muse-aipi" buildTo flash, add -p PORT flash with the same -B, target and defaults
arguments, except on the SenseCAP Watcher: idf.py flash runs plain esptool,
which fails on its USB bridge (see "Flash it"). Build the Watcher with
idf.py, flash it with tools/muse/board.sh flash watcher, then
idf.py … -p PORT monitor as usual; reading from the bridge works. Muse builds use partitions_muse.csv and need 16 MB of flash or
more, except the StickS3, StickC Plus2, Cardputer ADV, AI Passport and Waveshare 1.43C, which have 8 MB and use
partitions_muse_8mb.csv.
On the Watcher, rotation opens and pages through the latest reply. A short
wheel click sleeps/wakes; a 300 ms hold starts push-to-talk. Pairing still
confirms on the initial press. muse_review.c keeps up to 32 KB of the reply
in PSRAM across the end of the turn. >review.next, >review.prev, and
>review.close exercise navigation over the serial console.
All boards share managed_components/ and dependencies.lock in this
directory. If the component manager fails after you switch between a board
with the full UI and one without (lvgl is the usual offender), delete both and build again.
OSTB EchoEar-2ST (experimental)
tools/muse/board.sh build echoear uses the measured ESP32-S3 rev 0.2 unit
with 16 MB DIO flash and 8 MB octal PSRAM at 80 MHz. Native USB Serial/JTAG
(303a:1001) is shared by several boards, so it does not identify the model
on its own. EchoEar setup records its pin map
and limits. The bottom button controls physical power; GPIO0 is not a talk
button. Talking and pairing confirmation use the touch screen. There's no
software power-off, so Settings has no Power off option.
The final port was tested on hardware: pairing, touch, recording and playback, sleep and wake, and reconnect. See its setup guide for what wasn't checked.
A new board
Follow devices/AGENTS.md, which covers boards with a status light, a status
screen or the full UI from the overlay to verification. In short: copy the
closest overlay to devices/sdkconfig.<yourboard>, load it last, and add the
board to devices/README.md. At minimum set CONFIG_IDF_TARGET,
CONFIG_HOMEHUB_BUTTON_GPIO, the LED backend (CONFIG_HOMEHUB_LED_BACKEND_*,
or ..._NONE), and the flash size and mode. Without PSRAM, also set
CONFIG_SPIRAM=n, CONFIG_HOMEHUB_TUNNEL=n and the mbedtls
internal-allocation options, as devices/sdkconfig.ideaspark does. Signed
apps on a classic ESP32 need chip revision 3.0 or later
(CONFIG_ESP32_REV_MIN_3=y).
Flash
Identify the board first
When you're asked to flash, work out which board is attached instead of asking which one it is or assuming the default. Say which board you found and what told you, then flash that profile. Getting it wrong writes another board's pin map, flash size and status backend.
Ask the running firmware. A board already running this firmware names itself, and this needs no guessing:
python3 tools/muse/chat.py --status # {"board": "M5Stack StickS3", ...}It also logs the name once at startup, as
muse: board: <name>, whichtools/muse/monitor.py PORTcaptures because it resets the board first.components/muse/muse_app.clogs it; eachcomponents/muse/boards/board_*.csets its.name. Boards with the full UI only: the other overlays don't log a name.Read the USB descriptor. This works with no write access to the port and whatever the board is running, including vendor firmware or nothing.
ioreg -p IOUSB -l -w 0on macOS,lsusb -vorudevadm info /dev/ttyACM0on Linux:Descriptor Board Espressif 303a:1001, "USB JTAG/serial debug unit"the chip's own USB: C3, C5, C6, S3 and the S3 boards with the full UI. Its serial number is the MAC CH340 ( 1a86:7523)ideaspark, CYD, SenseCAP Indicator, reTerminal E1001 and E1002 CH340 ( 1a86:7522)Elecrow CrowPanel Advance 4.3 V1.3 CH9102 M5Stack StickC Plus2 CH343 ( 1a86:55d3), "USB Single Serial"Seeed reTerminal Sticky, Waveshare ESP32-S3-Touch-LCD-7, ESP32-S3-Touch-LCD-1.28 or ESP32-P4-WIFI6-Touch-LCD-3.5: chip-idandflash-idtell them apart (the P4 is anesp32p4; 8 MB of PSRAM on the Sticky and the 7-inch, 2 MB on the 1.28-inch; 32 MB of flash on the Sticky, whichflash-idshows)CH342, two usbmodemportsSenseCAP Watcher: the S3 console is the one ending in 3, the other is the Himax cameraAsk the chip. When you can write to the port (this resets the board):
python -m esptool -p PORT chip-id # read-mac for the MACThe target narrows it a long way:
esp32c5is the DevKitC-1,esp32c6the Waveshare C6 or a C6 devkit,esp32c3the AI Passport or the C3 with a 0.42" OLED (flash-id: 8 MB on the AI Passport, 4 MB on the OLED board),esp32the ideaspark, the CYD or the StickC Plus2.Fall back to a read-only capture. If the board is mid-run and you can't write to the port, the
## Monitorrecipe below reads it without resetting, and a talk press logs the board's own button name asmuse_input: waking (<hint>):Hint Board top-right, ortop-leftwith live calls (yellowon older firmware)M5Stack StopWatch USR1Seeed XIAO ESP32-S3 1.47 Touch Display powerM5Stack CoreS3 frontM5Stack StickS3, or StickC Plus2 — tell them apart by the port: the StickS3 is native USB, the Plus2 is a CH9102 usbserialtopWaveshare ESP32-S3-Touch-AMOLED-1.75C keyWaveshare ESP32-S3-Touch-AMOLED-2.16 bottom rightAIPI Lite wheelSeeed SenseCAP Watcher bootWaveshare ESP32-C6-Touch-AMOLED-1.8, the ESP32-S3-Touch-AMOLED-1.75, 1.8, 2.06, 1.43C or 1.43, the Elecrow CrowPanel Advance 4.3, the Guition JC3248W535, the Waveshare ESP32-S3-Touch-LCD-3.49, the VN ESP32-S3 1.83-inch NV3023, the Deotaland RoRoLee, the Waveshare ESP32-S3-Touch-LCD-1.9 or the SpotPear ESP32-AI-1.28 v1.0 — tell the C6 by its target ( esp32c6), and the S3 boards by themuse: board:line a reset logs
Ask the user only when these come up empty or contradict each other, and say what you found and what's ambiguous rather than asking from scratch.
Flash it
idf.py -p PORT flash # default DevKitC-1 build
tools/board.sh devkit flash PORT # same, in build-devkit/
cd build && python -m esptool --chip esp32c5 -b 460800 \
--before default-reset --after hard-reset write-flash "@flash_args"Typical ports: /dev/cu.usbmodem* or /dev/ttyACM* for native USB (C3, C5, S3
and C6 boards), and /dev/cu.usbserial-*, /dev/cu.wchusbserial* or /dev/ttyUSB*
for CH340 bridges (ideaspark, CYD, SenseCAP Indicator, reTerminal E1001 and E1002,
and CrowPanel Advance 4.3). On Linux,
add yourself to the dialout (or uucp) group. If the chip won't enter the
bootloader, hold BOOT, tap RESET, release BOOT, and flash again.
The M5Stack StickC Plus2's CH9102 bridge shows up as /dev/cu.usbserial-* or
/dev/ttyACM*. It drops out above 230400 baud, so tools/muse/board.sh
flashes it at 230400.
The SenseCAP Watcher's bottom USB-C port has a CH342 bridge with two ports,
both /dev/cu.usbmodem* on macOS. The ESP32-S3 console is the second (ending
in 3); the first is the Himax camera chip. Muse overwrites the Watcher's
factory data, so back up its nvsfactory partition first (see
devices/README.md).
The CH342 has no flow control and drops bytes when esptool sends a whole
packet at once, at any baud and on any USB port. Plain esptool then fails with
Failed to write to target RAM (result was 0107: Checksum error) while
uploading its stub, or 0105: The format of the received message is invalid
on the first flash block, by which point it has erased the bootloader. That
leaves the Watcher unable to boot until a flash succeeds; the ROM loader still
answers, so it isn't bricked. tools/muse/board.sh flash watcher goes through
tools/muse/paced_esptool.py, which takes esptool's arguments and sends 64
bytes at a time at the line rate, at 115200 baud. Use it in place of
python -m esptool for anything that writes to the Watcher. A lower baud or
another USB port doesn't help. Pacing works with macOS's built-in driver, so
WCH's driver isn't needed.
board.sh prints only esptool's last three lines, so a Watcher flash shows
nothing for about three minutes. Don't interrupt it, which leaves the board
unbootable again. To watch the progress, run the wrapper from the build
directory:
cd build-muse-sensecap-watcher
python ../tools/muse/paced_esptool.py --chip esp32s3 -p PORT -b 115200 \
--before default-reset --after hard-reset write-flash "@flash_args"flashwrites the bootloader, the partition table (at0x10000, not the IDF default0x8000), otadata, phy_init and the app toota_0. NVS is left alone, so pairing and Wi-Fi credentials survive a reflash.idf.py -p PORT erase-flashwipes everything, including pairing. Do this only when you mean to start from scratch.- The app is signed with the committed
dev_signing_key.pem, and Secure Boot is not enabled, so flashing never burns eFuses. Keep it that way: don't enableCONFIG_SECURE_BOOT, flash encryption, orCONFIG_HOMEHUB_PAIRING_EFUSE_AUTHon a board you want to keep reflashing.
Monitor
idf.py -p PORT monitor (Ctrl-] to quit) resets the board and needs an
interactive terminal. An agent without a TTY can do a read-only capture that
doesn't reset the board:
import os, termios, time
fd = os.open("/dev/cu.usbmodem1101", os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
attrs = termios.tcgetattr(fd)
attrs[2] &= ~termios.HUPCL # don't toggle DTR/RTS (which resets) on close
termios.tcsetattr(fd, termios.TCSANOW, attrs)
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
try:
chunk = os.read(fd, 4096)
if chunk:
os.write(1, chunk)
except BlockingIOError:
time.sleep(0.05)To capture from boot, use tools/muse/monitor.py PORT [secs], which resets the
board first (it needs pyserial). A healthy boot logs
link.main: Muse Gadget starting. For a board with the full UI, $(tools/muse/ports.py BOARD)
gives the port, with BOARD as in tools/muse/board.sh.
Update my avatar
When someone asks to put their own avatar on their board (or to update or
change it), run this from esp32/ with the sandbox off (it uses the serial
port) and let it finish. It takes several minutes, mostly waiting for Muse:
python3 tools/muse/avatar.py # draw their avatar
python3 tools/muse/avatar.py --edit "CHANGE" # change the one they haveIt checks the board, asks their Muse for the renderer through the board,
checks it on the host and builds the firmware with it in a copy of the tree,
and only then saves it to components/muse/avatar/muse_pixel.c (gitignored)
and flashes. It doesn't rebuild their own build-muse-*/: flashing that with
tools/muse/board.sh before a board.sh build puts the previous avatar back.
Progress and errors go to stderr. Relay the line that starts with Muse drew:
and the GIF paths. On failure, pass the message on. The exit status says what
kind of failure it was:
- 2: no board, or it can't do this (or another
avatar.pyis still running: let it finish). If it says the board doesn't answer, rerun with--board s3or--board aipi(from the board's name, or ask) to flash firmware that can. On the C6 boards or Watcher, follow the manual steps intools/muse/AVATAR_RECIPE.md. - 3: the board isn't on Wi-Fi, or it isn't paired in the Muse app and has
no device token, so their Muse isn't connected. Tell them, and point them to
the Muse app or
tools/muse/ble_setup.html. - 1: Muse's file still fails after two fix rounds, or the build or flash
failed. A file that fails a check or the build isn't saved: their avatar is
as it was. If only the flash failed, the new avatar is saved (the one before
is
muse_pixel.c.prev) and its checked build stays incomponents/muse/avatar/firmware-BOARD/; the error says how to flash it.
Don't commit anything in components/muse/avatar/. To see what the board
says, run python3 tools/muse/chat.py --status. To ask their Muse something,
run python3 tools/muse/chat.py "question".
The default avatar is in avatar/: its renderer (muse_pixel.c) and
its animation (jollybot.gif, and happy_anim.c/.h made from it by
tools/gen_happy_anim.py).
Third-party code keeps its upstream license and header: minimp3.h (CC0),
main/pixel_font.c (BSD-2-Clause, Adafruit),
components/muse/boards/ostb_echoear_display_init.h (MIT, 78/xiaozhi-esp32),
components/muse/boards/viewe_smartring_display_init.h (Apache-2.0, VIEWE),
main/epd_ssd1677.c (MIT, Seeed-reTerminal-E10xx-Firmware),
components/tinyh264/src (Apache-2.0, the Android Open Source Project) and
components/libvpx_vp8/src (BSD-3-Clause and PATENTS, the WebM Project).
Each third-party file is listed under License in README.md and the root
README.md; add a new one to both.
Don't restyle them or replace their headers with the Apache one; components/minimp3/README.md says how to
update minimp3.
The Apache License doesn't cover the Jollybot avatar in avatar/. Its files
carry only a Meta copyright line; don't add the Apache header to them.
First boot and pairing
The status LED (or the edge bars or avatar on display boards) shows the state:
| Colour | Meaning |
| orange, breathing | BLE advertising, waiting for setup |
| blue, breathing | pairing needs an explicit device press (screen on EchoEar-2ST) |
| blue | Wi-Fi or VM session coming up |
| green | connected (tunnel up, or control session up when the tunnel is off) |
| yellow, blinking | VM switching or connection lost |
| purple | unpaired |
| red, blinking | error |
Button (BOOT on the dev boards):
- short press: confirm a pending pairing, or reopen the setup window if setup isn't complete
- hold for 5 s: reset setup (unpair and forget Wi-Fi)
EchoEar-2ST confirms pending pairing with an explicit screen press; hold the on-screen microphone to record and release it to send once paired. It has no physical talk/setup button, so the GPIO button reset gesture does not apply. The VIEWE SmartRing-Plus works the same way; its BOOT button is inside the case.
The device advertises as MuseGadget-XXXXXX (MuseGadget-Disp-XXXXXX on the
ideaspark, CYD, SenseCAP Indicator and reTerminal E1001 and E1002 overlays, MuseGadget-ha-voice-XXXXXX on the
Voice PE, MuseGadget-respeaker-XXXXXX on the reSpeaker Lite,
MuseGadget-Oled-XXXXXX on the ESP32-C3 OLED, which shows the status as words). It uses
community pairing v5, so the phone app must support v5
and list community devices. Community pairing needs the explicit device press but has no
manufacturer attestation, and it doesn't stop an active man-in-the-middle.
Until it's paired, a board with the full UI shows that name on its screen, dim under the
state, so you can tell which gadget to pick in the Muse app. A screen too narrow
for the whole name shows the XXXXXX tail on its own, and a square 128 px
screen (AIPI Lite) leaves it out, the same as it leaves out the state. Once it's
paired (or has a token set by hand), the name goes, and the mic icon and the
touch boards' speaker button appear. They're hidden until then, since a press
can't reach Muse and there are no replies to mute. The StopWatch is an exception:
its dedicated microphone, speaker/mute and power rim icons stay visible before
pairing as well, identifying its three physical controls.
To skip BLE Wi-Fi provisioning while you iterate, set
CONFIG_HOMEHUB_WIFI_SSID and CONFIG_HOMEHUB_WIFI_PASSWORD in menuconfig.
The device still needs to be paired once for its token.
Live voice calls (experimental)
CONFIG_MUSE_REALTIME_VOICE (off by default, "Live voice calls with Muse"
under Muse in menuconfig) adds calls beside voice notes on boards with Muse's
own voice session: the full-UI boards with PSRAM and the Voice PE (and
reSpeaker Lite) builds. On StopWatch, hold yellow to talk or interrupt, release
to keep the call open, and tap blue to hang up; voice notes are an explicit
Sound setting. With the option on, the StopWatch's yellow key is its talk
button and blue its speaker button (board_m5stack_stopwatch.c), swapped from
the default build: outside a call blue still mutes, and during one, or once a
press that will start one is queued, its press hangs up (muse_input.c). A
hang-up that can't be queued moves the input epoch, which ends the call. Other boards retain tap-to-call and hold-for-note. Link-only
builds, the DevKitC-1 default included, don't have the option and don't change.
- The call is a
POST /voice/realtimeover the voice session's Noise connection:components/muse/muse_voice_call_core.chas the protocol (a bounded JSON scanner, the 40 ms appends, the audio decode, the call's lifecycle and deadlines) with no IDF in it, andmuse_voice_call.cppruns it on the session task.tests/test_muse_voice_call.pycovers both on the host with synthetic audio. - The session connects with
app_id(CONFIG_MUSE_REALTIME_VOICE_APP_ID,hatch-webby default): the VM takes the app identity from the connection, not from a request header, and refuses calls without one. With the option on, this applies to everything on that connection, voice notes and typed turns included. - The voice session never sends
link.register: Link's own control connection keeps the device's registration. - Muse's greeting is off by default (
CONFIG_MUSE_REALTIME_VOICE_GREETING). - Every way out of a call sends
POST /voice/end-callwhile the connection is up: closing the media alone doesn't end a call. Server policy may also end a call before the client's own limits. If the connection drops instead, the VM keeps the call through a disconnect grace (about 45 s, then up to 35 s more) before ending it, and in that window another connection of the same account can take the call over; end calls explicitly rather than by dropping. So a call whose end wasn't confirmed (the connection dropped first, or Muse didn't confirm the end-call) is kept as one pending cleanup: its end-call, unchanged, goes again when the session reconnects, before a new call starts (which waits for one try, at most 10 s), up to 3 tries and for at most 10 minutes; a refused (4xx) end-call isn't retried. It names the old call's id, so it can't end a new call, and nothing else of the old call is sent again. A new call's admission replaces the old one at Muse, which also ends the cleanup. The pending cleanup is in RAM only: if the gadget loses power or restarts first, the call stays until Muse's disconnect grace runs out. If Muse doesn't confirm the end-call ({"ok":true,...}), the session drops its connection once the media is down, which makes the VM end the call. The gadget hangs up afterCONFIG_MUSE_REALTIME_VOICE_IDLE_Squiet seconds, and always atCONFIG_MUSE_REALTIME_VOICE_MAX_S. Held input, a caller mid-sentence (until that speech item's own stop) and a turn Muse is still working on aren't idle; silent keepalive audio is. Turns are tracked bysubmission_message_idand inputitem_id: aturn_settledretires its submission and the earlier ones for that item, never another item's or a later alias. - On push-to-talk calls only replies to your latest hold play
(
muse_vc_reply_gate_t). The one exception: once that reply has finished, older work from before your last press reporting back (a delegated task's result, or the outcome after you answer a spoken approval) plays after it, with captions. The audio of a reply you cut off with a press never comes back, and older work arriving while a newer reply is still going is dropped. Approvals work by voice: Muse asks in the call and you hold to answer; the gadget has no approval card. - On boards with the full LVGL UI (the e-paper UI keeps THINKING), a call
shows WORKING (the thinking avatar's working variant) when nothing is
playing, the caller isn't holding or talking, and Muse still has a caller
turn open
(
hatch.voice.turn_startedwithout itsturn_settled; Muse keeps a delegating turn open until its result has been spoken). A settlement withasync_handoffs_pendingof 1 or more is a conversational boundary, such as a spoken approval prompt, where Muse waits on the caller: not WORKING (READY on push-to-talk, LISTENING hands-free) until a new caller turn starts, even though such a settlement may name none of the caller's turns. One reporting 0 retires every open turn, since Muse doesn't settle each caller turn by name, except the latest caller turn while its reply hasn't finished (an interrupted reply's settlement can arrive after the next caller turn started), and that only once. A repeat of an earlier settlement changes nothing. Its reply is a response whoseresponse.submission_message_idnames a turn for its item, or one that names none (early media the turn adopts, older VMs); older work's responses don't count. Only plain digits count: a string, sign, fraction or exponent, or no count, changes nothing.muse_vc_call_working()decides it; talking, Muse speaking and a pending reply still take precedence. On a hands-free call it waits for the turn's first reply to finish (before that the call shows LISTENING, as it always has), the mic stays open while it shows, and speech the server hears brings back LISTENING. The avatar getsmuse_pose_t.working: the default one types on a little keyboard with a spinner beside its head in a warm scheme; an avatar that ignores the field shows THINKING. - Nothing plays and the mic doesn't open until
hatch.voice.sessionnames this call's exactcall_idand asession_id; a missing or wrong one fails the call. Before that, anerrorevent fails it too; after, onlysession_deaddoes. - Caller and reply text go to captions only, never the log.
- Hands-free calls shut the mic while Muse is audibly talking, plus 300 ms. The Voice PE keeps it open (full duplex) when its XMOS reports echo-cancelled outputs at start-up.
- The VM must accept calls from the gadget's credentials. A
session_controlstop on this call's realtime stream ends the call: the client drains queued playback and explicitly ends it. The admission body's second line,MUSE_VC_CAPABILITY_OFFER(hatch.voice.capability.offerwithend_call_stop: 1), tells Muse the gadget ends calls that way, so it can hang up when the caller says goodbye; the gadget has no channel forui.set.voice.end_call. A VM that doesn't support the offer ignores it and sends no stop on goodbye, so a press, the idle limit or the length limit is what reliably ends a call there.
Requests to Muse's VM from your code
CONFIG_HOMEHUB_DAEMON_REQUESTS (on by default, on every board, Link-only
builds included) lets firmware open its own HTTP requests to the VM's daemon
on Link's Noise session, with noise_ctrl_req_open(), noise_ctrl_req_send()
and noise_ctrl_req_cancel() in main/noise_control.h. Muse's own voice notes
and chat use the same calls. Each request gets a stream of its own; a refused,
reset or cancelled request never touches Link's control stream.
tests/test_link_daemon_requests.py runs them on the host with the Muse UI off.
Routes a gadget's credentials can reach, besides Link's own and the voice
call's POST /voice/realtime and POST /voice/end-call:
GET /chat/history: the chat's messages. Ask for one at a time (GET /chat/history?limit=1) so the response stays under the frame bound below. A single message larger than that bound can't be fetched this way.POST /fs/readandPOST /fs/write: files on the user's VM, with JSON bodies. Apathis relative to the user's home on the VM, under the VM's existing path rules; nothing confines it to a workspace folder.- read:
{"path": "notes/today.md", "offset": 0, "len": 1024}.offsetandlenpick the part of the file to return. Keeplenat 1024 or less: the content comes back escaped (or base64), which can grow it well past its size in the file, and the response has to fit the frame bound below. - write:
{"path": "notes/today.md", "text": "...", "overwrite": true, "append": false, "create_parent": true}, ordata_base64in place oftext(one of the two).overwrite,appendandcreate_parentare required booleans: send all three every time. Write a large file in several requests, the later ones with"append": true.
- read:
There are no list or stat routes for gadgets.
Keep within the session's bounds:
- Up to 3 requests at once; another is refused (its callback gets -1).
- Send a body in pieces of at most 8 KB (4 KB on Muse boards without PSRAM
and the CYD, 2 KB on the Cardputer ADV and AI Passport): a larger piece ends
that request.
Queued body bytes are capped at 16 KB with PSRAM and 4 KB without, and
noise_ctrl_req_send()waits up towait_msfor room. - Each response frame has to fit the session's inbound buffer (12 KB, 17 KB on
Muse boards without PSRAM and the CYD). A larger one ends the whole session,
which reconnects, and the request with
NOISE_CTRL_REQ_TOO_LARGE. Ask for responses that stay under it: read large files in parts. - The callback runs on the session task: copy what you need and return. Do
slow work, and any request that depends on its result, in a task of your
own, as
on_ws_command()'s asynchronous commands do.
Video from a phone
MUSE_VIDEO=1 tools/muse/board.sh build|flash <board> (overlay
devices/sdkconfig.muse-video) adds video streaming (CONFIG_MUSE_VIDEO,
CONFIG_MUSE_VIDEO_WIFI): Muse Gadgets Test (../android/musegadgetstest for
Android, ../ios/musegadgetstest for iPhone) on a phone on the same Wi-Fi streams
its camera, or a clip, as H.264 or VP8
(CONFIG_MUSE_VIDEO_VP8, on by default), and the board decodes it and
draws it full screen. ../protocols/VIDEO.md has
the design, the Wi-Fi protocol and the numbers.
- Boards: those whose panel driver has
draw_frameand that have PSRAM: the Waveshare S3 1.75C (tested) and 1.75, the StopWatch and the ESP-Mosaico. Another QSPI AMOLED driven byboards/muse_lcd_bands.cgets it with.draw_frame = muse_lcd_bands_frameand its panel gap (muse_lcd_bands_set_gap);muse_lcd_bands_set_teif the TE line is wired. - Pieces:
components/tinyh264andcomponents/libvpx_vp8(the decoders, behindmuse_vdec.c),components/muse_vstream(the receive ring and frame format any transport shares),components/muse/muse_video.c(decode and show tasks, pacing, bands, the view),muse_yuv*.c/.S(colour conversion and scaling),muse_video_wifi.c(TCP on port 7766, mDNS_musevideo._tcp) andmuse_video_wire.c(its messages). A new transport fills amuse_video_transport_tand callsmuse_video_begin(). - Using it: Phone camera in Settings (touch boards) or the menu opens the
view and asks the phone for a stream; any button closes it. The console's
>video/>video=0do the same. The board takes a stream only while the view is open: the port is unauthenticated (../protocols/VIDEO.md, Trust).CONFIG_MUSE_VIDEO_WIFI_START_ANYTIMElifts that for automated benches; never ship it. Stats every 2 s onmuse_video: decode time, received-to-shown, kb/s, shown/dropped/skipped, each core's load. - Radio: while a stream is on, Wi-Fi stays out of modem sleep and BLE
stops advertising (
muse_wifi_keep_awake,muse_link_ble_quiet): with BLE advertising, the radio's scheduler gives BLE half of every beacon period and the stream's packets wait out that half (50 ms round trips instead of 15, and the phone's bitrate falls to its floor). - Memory: the ring (128 KB) and the decoders' memory are in PSRAM, and
the decode tasks' stacks in main SRAM (
MALLOC_CAP_DMA): the S31 also hands out LP RAM as heap once main SRAM runs low, several times slower, so keep anything per-frame out of plain internalmalloc. The decode and show tasks take about 20 KB of internal RAM once the first stream starts.CONFIG_MUSE_VIDEO_DECODER_IRAM(off) puts the decoders' hot code in IRAM: 44 KB (and 33 KB for VP8;CONFIG_MUSE_VP8_IRAMpicks less or more) that a Wi-Fi + BLE + UI build on the S3 can't spare (LVGL's task then fails to start). - Test with
tests/test_muse_video.py(the ring and the Wi-Fi messages, and the wiring under the options),tests/test_video_untrusted.py(hostile sizes, the port's guards),tests/test_tinyh264_pie.pyandtests/test_libvpx_vp8.py; on a board, with Muse Gadgets Test (Android:adb logcat -s MuseCam; iPhone: Xcode's console). - Untrusted input: everything from the network is. Keep the checks in
../protocols/VIDEO.md's Trust section when changing the transport, the decoders' glue (muse_vdec.c) or the drawing (fill()inmuse_video.c).
Configuration gotchas
sdkconfig.defaults.esp32c5is loaded aftersdkconfig.defaultsfor C5 builds only (ESP-IDF's target-specific defaults): it moves lwIP and Wi-Fi heap buffers to PSRAM, raises the TCP windows to 64 KiB and deepens the receive path to match (64 dynamic RX buffers, 12 queued out-of-order segments). Other chips keep 16 KiB windows in internal RAM;cmake/validate_config.cmakeenforces both.- Each build's generated
sdkconfiglives in its build directory (build/sdkconfig,build-<board>/sdkconfig). Once it exists, it overrides later edits tosdkconfig.defaultsor the overlays. After changing those, delete the generatedsdkconfig(or the whole build directory) and rebuild.cmake/validate_config.cmakestops the build if a stale config has the old TCP buffer sizes or cJSON nesting limit, and warns if it still has the full root CA bundle. - HTTPS trusts ESP-IDF's common root CAs only (
sdkconfig.defaults), with cross-signed verification so chains that reach one of them through a cross-sign still verify. A server whose chain ends at another root (somedisplay.draw_urlimage hosts, an OTA URL you host yourself) fails to connect. If you need one, select the full bundle in menuconfig (Component config > mbedTLS > Certificates > Certificate Bundle Configuration). - Edit per-build settings with
idf.py -B <dir> menuconfig(under "ESP32 Device SDK"). Don't commit a generatedsdkconfig: it's gitignored for a reason. - The e-ink UI's task stack (
muse_eink) must be in internal RAM: Settings' handlers write NVS synchronously, and flash writes disable the cache. A PSRAM UI task stack asserts inesp_task_stack_is_sane_cache_disabled()when changing a setting. The framebuffer and font caches can remain in PSRAM. - E-paper boards with the full UI (
CONFIG_MUSE_UI_FREEINK, the Sticky so far) draw with FreeInk, not LVGL, which they don't build at all. To bring up another panel: give it a FreeInk board profile (or reuse one), selectMUSE_UI_FREEINKand name the profile inMUSE_FREEINK_DEVICE(components/muse/Kconfig), and write a board file that suppliestouch_read, buttons, audio and power and pointsdisplay_pauseatmuse_eink_display_pause(eink/muse_eink.h); leavedisplay_startand the other display hooks NULL. The UI's fonts are embedded TTFs (eink/fonts/, regenerated bytools/muse/gen_eink_fonts.sh). FreeInk is pinned to a commit incomponents/muse/idf_component.yml. - OTA is off by default (
CONFIG_HOMEHUB_OTA_ENABLED=n, version999.0.0), except on boards with the full UI. Set a version with-DPROJECT_VER=1.0.0orversion.txt. - BLE is shut down and its memory released once setup completes, except on
boards with the full UI.
CONFIG_HOMEHUB_BLE_PERSISTENTkeeps NimBLE up beside Wi-Fi with a basic GATT service (main/ble_basic.c) advertised slowly; it costs roughly 40 KB of internal RAM for as long as the device runs (tools/coexist/sdkconfig.ble-memwins most of it back on the C5). On the C5,CONFIG_HOMEHUB_BLE_COEX(main/ble_coex.c) keeps BLE advertising and connections from reserving half the radio's airtime once setup is complete, which doubles Wi-Fi throughput with BLE up. It relies on ESP-IDF's private coexistence status bits, so it builds on IDF v6.0.x only until the bits are rechecked on another version.tools/coexist/holds the overlays used to size it, and the data-path bench (CONFIG_HOMEHUB_PIPELINE_BENCH) with its results. - With BLE persistent,
CONFIG_HOMEHUB_BLE_COMMANDS(default on) lets Muse drive the Link's BLE:ble.state,ble.power,ble.advertise,ble.scan,ble.connect,ble.disconnect,ble.discover,ble.read,ble.writeandble.subscribe(main/ble_commands.cover the central inmain/ble_client.c). They run one at a time on their own task.ble.poweroff stops the stack without releasing the controller's memory, so it can come back; the default build, which releases BLE after setup, can't.tests/test_ble_commands.pyruns them against a fake client. - On the C5 with PSRAM,
CONFIG_HOMEHUB_FAST_GCM(default on there) wraps ESP-IDF's one-shot AES-GCM withmain/fast_gcm.cfor TLS and Noise. It takes about 19 KB of internal RAM (8-bit GHASH tables and the PSRAM staging buffer; see its Kconfig help). Turn it off to compare against IDF's;tests/test_ghash32.pychecks the tables and its failure handling. - Don't move offsets in
partitions.csv.prod_dataandprod_bakare fixed manufacturing locations, and the table offset of0x10000leaves room for a larger Secure Boot bootloader. Check thecheck_sizesline in the build output: app slots are 2 MB (4 MB on Muse).
Adding a command
Muse calls a gadget's commands by name: the firmware lists them in
link.register and answers each link.invoke. A command you add lives in two
places, which must use the same name. (device.health and device.ota are
handled in noise_control.cpp itself; everything else goes through
on_ws_command().)
- Advertise it in
build_register_json()inmain/noise_control.cpp:add_command(commands, "relay.set", "<description>", required, optional).requiredandoptionalmap each parameter's name to{type, description}(string_param()makes a string one). Muse reads the descriptions, so say what the command does and what it returns. If it can take longer than the default 30 seconds, set itstimeout_ms, asdevice.discoverdoes. - Handle it in
on_ws_command()inmain/app.c. Return{"ok": true, "payload": {...}}, orcommand_error(code, message)for a failure. Onlyok,payload(or apayload_jsonstring) and the error'smessagereach the Muse. Always return a result:NULLreaches the Muse as a generic "command handler did not return a result" error. Validate the parameters yourself:paramsisNULLwhen the request has none, and the firmware doesn't check them against the advertisedrequiredandoptional, so check each one's presence, type, length and allowed values. - Don't block.
on_ws_command()runs on the Noise session's task, so anything slow (the network, a slow sensor, a camera) belongs in its own task. Copyrequest_id,session_generationand every parameter the task needs, strings included, into memory the task owns (device.discoverusescJSON_Duplicate()): the request is freed as soon ason_ws_command()returns. If an allocation or the task start fails, free what you allocated and returncommand_error(). Otherwise start the task and return{"_async": true}. The task then callsnoise_ctrl_send_command_result()once, failures included, or the Muse waits out the timeout. It takes ownership of the result and frees it, so don't free or reuse it afterwards.camera.captureanddevice.discoverwork this way. A result from an earlier session is dropped. - Gate it on a Kconfig option in
main/Kconfig.projbuildwhen it needs particular hardware, and wrap both places in the same#if, assensors.readdoes withCONFIG_HOMEHUB_SENSECAP_SENSORS. Add new source files tomain/CMakeLists.txt. - Keep
link.registersmall. It's printed into at most 8 KB, and a device whose registration doesn't fit never registers. - Add a host test in
tests/.test_link_sensecap_sensors.pychecks thatsensors.readis advertised and dispatched under the same option, and runs its parser against a harness.
Report environmental sensors (temperature, humidity, CO2, tVOC, light and so
on) through sensors.read, on every board that has them, so Muse has one
command to call wherever it runs. Don't add them to device.health, which is
for the link itself (battery, power, network), or give them a board-specific
command. Return each reading as {value, unit, age_s}, as
sensecap_sensors.c does, and report a sensor with no recent reading as null.
Read slow or I2C sensors in a background task and have sensors.read return
the latest value.
Muse sees the command once the board reconnects with the new firmware. Keep
the management commands that on_ws_command() also handles (device.list_vms,
device.set_vm, device.reset_vm and device.unpair) out of
link.register: tests/test_link_transport_contract.py checks they stay
unadvertised.
Say Muse, never Hatch
Users never see the name Hatch.
- Anything a person reads says Muse, the Muse app, or the Muse's name:
- screen text and error captions (
CAN'T REACH MUSE, notCAN'T REACH HATCH) - settings labels
- Kconfig prompts and help
- log lines
- tool and script output
- docs
- screen text and error captions (
- Don't use
hatchin a new file name or identifier. Usemuseormuse_gadget. The Muse chat code iscomponents/muse/muse_chat*. - The ESP32 account clients use
https://api.muse.ai, or theapi_url_v2base the app sends during pairing, with bare API paths. They ignoreapi_url: only older firmware reads it, and that firmware adds/hatch/itself. Keep saving it so a device flashed back to older firmware still works. hatchstays only where the server or the Muse app depends on it. Don't rename these:- the VM host
hatch.metaaivm.com - the
hatch_refresh:auth prefix, thehatch-webapp id and theHatchLink/user agent - pairing labels and ids such as
hatch-link-pairing-v%d, thehatch_linkmodel and thehatch-link:device id - the bug report fields
- the setup commands (
hatch.tokenand the rest) and the"hatch"key in the status JSON, which the app and older tools use
- the VM host
- Some older identifiers still carry the name (
muse_hatch_*,MUSE_HATCH_*,CONFIG_MUSE_HATCH). Leave them unless you're asked to rename them. Don't copy the name into new code.
Tests
These are host-side Python unittest harnesses that compile the firmware C/C++ against fakes, with no board or IDF environment required:
python3 -m unittest discover -s tests -p 'test_*.py'Run one idf.py build first: test_link_discovery compiles cJSON from
managed_components/, and that directory only exists after a build. Set CC
or CXX to change compilers.
The e-ink navigation tests use the pinned FreeInk source: set
FREEINK_SOURCE_DIR to its checkout, or use managed_components/freeink-sdk
from a Sticky configure. CI checks out that exact pin for the host tests.
Two tests skip quietly when their inputs are missing; check the summary for
skipped=:
test_noise_corelinks against the host's PSA Crypto library, found withpkg-config mbedcrypto. Installlibmbedtls-devandpkg-configon Debian or Ubuntu (as CI does), ormbedtlsandpkgconfwith Homebrew.test_link_pairing_handshakebuilds Mbed TLS from source for its real-crypto case, so it needsIDF_PATH(set byexport.sh) orMBEDTLS_SOURCE_DIR.
Before you hand back work
idf.py build(and the board build you touched) passes, with the size check under the slot limit.- The host tests pass.
- If you flashed, the boot log reaches
starting, and the log shows no panic or reboot loop.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

