I2C sensors on the XIAO ESP32S3
Bus pins are fixed by the board: SDA = D4 = GPIO5, SCL = D5 = GPIO6.
Wire.begin(D4, D5) — the no-argument form picks the wrong pins.
Read the xiao-esp32s3 skill for the compile/upload workflow, and
xiao-serial-monitor for watching the output.
Always scan before writing driver code
Module silkscreens lie and address straps vary between clones. One scan settles both, and everything downstream depends on being right about it:
arduino-cli compile --fqbn esp32:esp32:XIAO_ESP32S3 -u -p <PORT> assets\sketches\i2c_scan
i2c_scan prints every responding address, guesses the part, and then reads
the ID register of anything at 0x76 and 0x29 so the guess becomes a fact.
Verified output on the reference board:
0x23 BH1750 (ADDR=L)
0x29 BNO055 (ADDR=H)
0x76 BME280/BMP280 (SDO=L)
0x76 chip id = 0x60 -> BME280
0x29 chip id = 0xA0 -> BNO055
Sketches
| Sketch | What it does |
|---|---|
assets/sketches/i2c_scan |
scan + chip-ID identification |
assets/sketches/test_bh1750 |
BH1750 unit test, 5 automated checks |
assets/sketches/test_bme280 |
BME280 unit test, 8 automated checks |
assets/sketches/test_bno055 |
BNO055 unit test, 10 automated checks |
assets/sketches/sensors_all |
all three streaming together, 1 Hz |
Copy the one you need into the user's workspace (folder name must match the
.ino name) rather than writing a driver from scratch. All five were run on
real hardware; the unit tests all pass at 5/5, 8/8, 10/10.
Libraries:
arduino-cli lib install "BH1750"
arduino-cli lib install "Adafruit BME280 Library"
arduino-cli lib install "Adafruit BNO055"
sensors_all output, one line per second:
lux 48.3 | 25.86C 1006.31hPa 42.7%RH | H 359.9 R 7.9 P -5.2 | cal 1300
cal is the BNO055's four calibration bytes (sys/gyro/accel/mag), each 0–3.
Handing the stream to the user
A sensor stream is something the user watches, not something you screenshot
once. Verify it yourself with a bounded read, then tell them how to open it
themselves — the xiao-serial-monitor skill covers both halves:
.\scripts\mon.ps1 -Seconds 15 # your check; returns
mon # theirs; runs until Ctrl+C
Never launch the interactive monitor from a tool call — it never returns and the session hangs. Close instead with a handoff that names the command, how to run it, and how to stop it:
센서 값이 1초마다 흐르고 있습니다. 직접 보시려면:
! D:\path\to\mon.ps1Claude Code에서
!를 붙이면 이 세션에서 바로 실행됩니다. Ctrl+C로 종료. 프로필 단축키를 넣으셨다면 새 터미널에서mon만 쳐도 됩니다.
This matters more for sensors than for most things: the interesting part is what happens when the user covers the light sensor, breathes on the humidity sensor, or rotates the IMU. None of that shows up in a capture you took while the board sat still on a desk.
How these unit tests are built
Two rules make the difference between a test that proves something and a test that just prints numbers:
Bounds-check, don't eyeball. Every reading is asserted against the
datasheet range, because the failure that actually happens is a dead bus
returning 0, NaN, or a pegged rail — not a part that is 2 % out of
calibration. The strongest single check in the set is the BNO055's gravity
magnitude: however the board is lying, |g| must come out near 9.8 m/s², so
one assertion covers all three accelerometer axes and their scaling at once.
Never assert something that needs a human hand. An early version of the BH1750 test asserted that consecutive readings differ, reasoning that a frozen bus repeats itself. Under steady room light a healthy sensor returns exactly 46.7 lx every time, so the test failed on working hardware whenever nobody happened to be waving at it. Checks that need physical stimulus — covering the light sensor, breathing on the humidity sensor, moving the IMU to calibrate — belong in a printed "manual check" section that is not scored. Keep the automated count honest and it stays worth reading.
Pitfalls
These each cost a debugging session once.
A BNO055 at 0x29 will not talk to the default driver. The Adafruit library constructor defaults to 0x28; a module with ADDR/COM3 strapped high sits at 0x29 and
begin()fails while the scan shows the device plainly. Pass it explicitly:Adafruit_BNO055 bno(55, 0x29, &Wire);setExtCrystalUse(true)silently zeroes the BNO055. Many clone modules have no 32 kHz crystal fitted, and selecting an absent clock source parks the chip in idle with every output stuck at0.00— temperature, gravity, euler angles, all of it. The trap is that it still looks healthy: the power-on self test reports0x0Fandsystem_errorstays0. Leave it on the internal oscillator unless you have confirmed the crystal exists.Guard against it by checking the mode rather than the self test:
uint8_t sysStat, selfTest, sysErr; bno.getSystemStatus(&sysStat, &selfTest, &sysErr); // 5 = sensor fusion algorithm running. Anything else and the // orientation numbers are not real, whatever self_test says.0x76 does not mean BME280. BMP280 shares the address and has no humidity channel, so a humidity read returns garbage rather than an error. Read chip-ID register
0xD0:0x60= BME280,0x58= BMP280,0x61= BME680,0x55= BMP180. Decide the driver from that, not the module's label.BH1750 needs ~120 ms after
begin()before the first conversion. Reading immediately returns a stale or zero value that looks like a wiring fault. The tests wait 200 ms.One missing module should not silence the others. In
sensors_alleach sensor is probed independently and a missing one is reported once at startup, then skipped. A single loose wire otherwise takes the whole stream down and sends you looking for a software bug.Keep serial output ASCII. The Windows console reads the port as the ANSI codepage, so Korean or accented text in
Serial.printcomes back as mojibake in captured logs. Put the explanation in comments, keep the printed strings English.
Adding another I2C sensor
Scan first and confirm the address; identify the part from its ID register where one exists; bounds-check every reading against the datasheet; and keep any check that needs a human hand out of the automated score. That is the whole method — the five sketches here are just instances of it.