I2C sensors on the XIAO ESP32S3
Bus pins are fixed by the board:
SDA = D4 = GPIO5,
SCL = D5 = GPIO6.
— the no-argument form picks the wrong pins.
Read the
skill for the compile/upload workflow, and
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:
powershell
arduino-cli compile --fqbn esp32:esp32:XIAO_ESP32S3 -u -p <PORT> assets\sketches\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 |
|---|
| 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
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:
powershell
arduino-cli lib install "BH1750"
arduino-cli lib install "Adafruit BME280 Library"
arduino-cli lib install "Adafruit BNO055"
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
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
skill covers both halves:
powershell
.\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초마다 흐르고 있습니다. 직접 보시려면:
Claude Code에서
를 붙이면 이 세션에서 바로 실행됩니다. Ctrl+C로 종료.
프로필 단축키를 넣으셨다면 새 터미널에서
만 쳐도 됩니다.
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
,
, 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,
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
fails while the scan shows the device plainly.
Pass it explicitly:
Adafruit_BNO055 bno(55, 0x29, &Wire);
-
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 at
— temperature, gravity,
euler angles, all of it. The trap is that it still looks healthy: the
power-on self test reports
and
stays
. 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:
cpp
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
:
= BME280,
= BMP280,
= BME680,
= BMP180. Decide the driver from that, not the
module's label.
-
BH1750 needs ~120 ms after 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
each 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
comes 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.