A battery-powered Matter-over-Thread room sensor for Home Assistant, built from scratch on the ESP32-C6 with esp-matter and ESP-IDF. 125 µA average, e-ink display, rotary dial, no cloud, no Wi-Fi, and every trap written down.
A room temperature and humidity sensor that reports to Home Assistant over Thread as a Matter sleepy end device, shows its own readings on a 2.9" e-ink panel, and takes input from a rotary dial. It averages 125 µA measured on the bench, about eighteen months on one 2000 mAh cell at 85 % usable. No vendor cloud, no hub beyond a Thread border router, and no Wi-Fi: the radio is compiled out.
Everything is here: firmware, drivers, pin map with schematic-verified board facts, measured power budget, 3D-printable enclosure, bill of materials with real prices, and the reasoning behind each decision, including the ones that turned out wrong.
| MCU | Seeed XIAO ESP32-C6 (RISC-V, 802.15.4 + BLE; Wi-Fi compiled out) |
| Stack | esp-matter v1.6 on ESP-IDF v5.5.5, C++ |
| Network | Matter over Thread, OpenThread minimal device (MTD), commissioned over BLE |
| Power mode | Intermittently Connected Device (ICD), short idle time: 15 s parent polls, 600 s idle interval, automatic light sleep |
| Controller | Home Assistant with matterjs-server, Home Assistant Connect ZBT-2 border router |
| Clusters | Temperature Measurement, Relative Humidity Measurement, Power Source (battery % and voltage), Thread Network Diagnostics with counters |
| Measured current | 125 µA paired and settled (sleep floor 57 µA, parent poll ~0.48 mC); 19.7 µA unpaired in deep sleep |
| Display | 2.9" 296×128 SSD1680 e-paper, partial refresh ~6–7.5 mC, full ~19 mC |
| Status | One unit bench-verified on a Nordic PPK2; enclosure being redesigned |
Most of this repo is reusable even if you never build this sensor. The findings that took longest, with where to read them:
| Topic | Where |
|---|---|
| Getting an ESP32-C6 Matter device from ~376 µA to 125 µA: ADC power domain, bus power-down flags, ICD mode, a pin regression | power-budget.md, field-notes §20–§22, §28 |
| Long Idle Time ICD buys nothing with Home Assistant (no check-in client) and costs ~67 µA; what to use instead | retro M8, sdkconfig.defaults |
A non-wake GPIO with a level interrupt under CONFIG_PM_SLP_DISABLE_GPIO: +16 µA floor, polls 60 % dearer |
field-notes §28 |
| Waking light sleep from a rotary encoder: LP GPIOs only, level not edge | field-notes §21, ec11_encoder |
| An unpaired Matter device draws 28 mA; deep sleep that works (restart, then sleep before the stack starts) | field-notes §26–§27 |
attribute::update() silently does nothing for code-driven clusters in esp-matter v1.6 |
field-notes §9 |
| Commissioning to Home Assistant without HAOS: test certificates, BLE proxy, border router | commissioning.md |
| Thread coverage, border-router outages, a bare XIAO ESP32-C6 as a Thread router | field-notes §6–§7, §24, infrastructure.md, bringup.md Radio / Matter |
| Measuring µA on a sleepy radio device with a PPK2, and analysing captures offline | tools/ppk2-events.py, retro T7–T9 |
| Hardware bring-up traps: FPC ribbons, flux, underside pads, breadboard battery paths | assembly.md, battery-runbook.md, field-notes §15–§19, §25 |
| Eight weeks of bring-up in one place: what went wrong, why, and the checklist for the next unit | retro.md |
Status: bench-verified, not yet assembled. One unit runs on the bench (XIAO, driver board, panel, SHT40, encoder on a breadboard) with a Nordic PPK2 standing in for the cell. Verified there: commissioning to Home Assistant over Thread, display output, battery-voltage reading (within 3 mV of a meter, 3.4–4.0 V), 125 µA settled paired current (2026-10-05), 19.7 µA unpaired deep sleep, and the dial waking the chip from light sleep on battery power. It has run 21 h on a real LiPo with no restart. Not yet done: a long soak on a real cell, and the enclosure, which is being redesigned. Open rows are in docs/bringup.md; code written ahead of hardware is marked
HW-VERIFYand tracked there. The build procedure, with a test gate after every soldering stage, is docs/assembly.md.
| Device | Status | Description |
|---|---|---|
| sensor-01 | in development | Battery-powered room temp/humidity sensor. 2.9" e-ink display, rotary dial, Matter-over-Thread sleepy end device (SIT ICD). 3 units. |
The drivers are self-contained ESP-IDF components with no dependencies beyond
the IDF itself. Copy the directory into your own components/ and it builds.
MIT licensed.
| Component | Why it might be useful |
|---|---|
sht40 |
Sensirion SHT40 on the ESP-IDF 5.x i2c_master API (not the deprecated legacy driver), high-precision single-shot with CRC-8 validation |
ssd1680 |
2.9" e-paper with differential partial refresh, periodic full refresh to clear ghosting, and panel deep sleep between updates — the parts most sample code leaves out |
monogfx |
1-bit framebuffer renderer with a scaled 5×7 font and a seven-segment digit routine. No LVGL, no external graphics library |
ec11_encoder |
EC11 rotary encoder decoded from a Gray-code transition table in an ISR — bounce-immune without debounce delays, and draws no idle current |
Tools, usable on any project:
| Tool | What it does |
|---|---|
tools/ppk2-events.py |
Reads a Nordic PPK2 .ppk2 capture offline and splits it into sleep floor, radio polls, bursts and short wakes with their charge, so one capture gives a per-item current budget |
tools/matter-node.py |
Reads a node through the Home Assistant Matter server WebSocket: the cached attributes (free for the device) or a forced interview |
tools/check-profiles.sh |
Asserts a shipping and a bench sdkconfig really differ on sleep, and that test-only options are off in shipping. Runs in CI |
Also reusable regardless of hardware: the power budget and the firmware policies it forced, the retro, and the documented traps below.
Firmware capabilities, all implemented in this repo unless noted:
- Matter over Thread, Wi-Fi compiled out. OpenThread MTD, commissioned over Bluetooth Low Energy, joins via any OpenThread border router (developed against Home Assistant Connect ZBT-2).
- Short Idle Time ICD (Intermittently Connected Device): automatic light sleep between 15 s parent polls, and a 600 s idle interval that the device offers as its subscription max interval, so a report with nothing new goes out every 10 min. Long Idle Time was dropped on 2026-10-04: Home Assistant registers no check-in client, so it only added cost (retro M8).
- Unpaired deep sleep: a unit nobody pairs deep-sleeps at 19.7 µA once its 15-minute commissioning window closes, and a turn of the dial starts a new window (field-notes §27).
- Thread Network Diagnostics with counters (attach attempts, parent changes, MAC retries), readable from the controller at no reporting cost.
- Matter clusters: TemperatureMeasurement, RelativeHumidityMeasurement, and PowerSource with battery percentage and voltage.
- Delta-gated reporting — a report is sent on ≥0.2 °C / ≥1 %RH change, with a forced heartbeat every N polls, instead of on every sample.
- SHT40 driver (
firmware/components/sht40) on the ESP-IDF 5.xi2c_masterAPI, high-precision single-shot with Sensirion CRC-8 checking. - SSD1680 e-paper driver (
firmware/components/ssd1680) with differential partial refresh, periodic full refresh for ghosting, and panel deep sleep between updates. - 1-bit framebuffer renderer (
firmware/components/monogfx) with a scaled 5×7 font and a seven-segment digit routine — no external graphics library. - EC11 rotary encoder (
firmware/components/ec11_encoder) decoded from a Gray-code transition table in an interrupt handler: bounce-immune and drawing no idle current. - Local-only UI: readings, diagnostics, and settings views; settings persist in NVS. The display never renders Home Assistant state — it shows what this device measured.
- Battery monitoring via a 2×1 MΩ + 100 nF divider on GPIO4 (an underside pad): 64 one-shot ADC samples after a 20 ms settle, eFuse curve-fitting calibration, and a scale factor fitted against a metered source to correct the ADC input loading the 500 kΩ divider. Open-circuit-voltage lookup table for percent.
- Dial wakes the chip from light sleep on a detent (encoder A on an LP GPIO underside pad, level-triggered).
- Factory reset on a 10-second encoder press (turn first: the switch is on a pin that cannot wake the chip); commissioning and low-battery states shown on a single LED and on the display.
- CI builds the flashable images on every push touching
firmware/**and uploads them as an artifact.
Seeed XIAO ESP32-C6 + Seeed ePaper Driver Board for XIAO V2 + 2.9" mono e-ink (296×128, SSD1680) + Grove SHT40 + EC11 rotary encoder + 2000mAh LiPo, in a wall-mount enclosure remixed from veltoc (hardware/case — rev 4, FDM PLA, laser-engraved wordmark).
Assembled view rendered from the enclosure STLs. Colours and finish are indicative; no unit has been built yet.
Full bill of materials: docs/bom.md. Pin map with the schematic-verified board facts behind this diagram: docs/pinmap.md. The official bare-board pinout is on the Seeed XIAO ESP32-C6 wiki (not reproduced here — Seeed's wiki content is GPL-3.0, this repo is MIT).
Espressif esp-matter v1.6 on ESP-IDF v5.5.5, C++. Thread only (Wi-Fi disabled), commissions over Bluetooth Low Energy to Home Assistant via an OpenThread border router (Home Assistant Connect ZBT-2). Build instructions: docs/build.md.
The device advertises Espressif's test vendor ID 0xFFF1 and shared test
commissioning credentials. It is not a Matter-certified product; controllers
will show an uncertified-device warning. Per-device factory partitions
(esp-matter-mfg-tool) are an open work item — see
docs/commissioning.md.
| Doc | Contents |
|---|---|
| docs/bom.md | Bill of materials, part SKUs, divider-value rationale |
| docs/pinmap.md | Pin assignments and schematic-verified XIAO ESP32-C6 board facts |
| docs/assembly.md | Wiring and build order, battery-polarity warning |
| docs/build.md | Pinned toolchain SHAs, Docker and native builds, flashing from WSL2 |
| docs/commissioning.md | Pairing to Home Assistant: the three preconditions, the BLE-proxy workaround, factory reset. Apple Home is not available here and the doc says why |
| docs/retro.md | Retrospective of the whole bring-up: lessons, timeline, every problem with its cause and rule, the checklist for the next unit, open questions |
| docs/field-notes.md | Traps that cost real time during bring-up, one numbered section per incident (cited as §n). Read before bringing up another unit |
| docs/power-budget.md | Modelled and measured current draw per item, battery-life calculator, the policies it forced |
| docs/battery-runbook.md | Why a pack reads 0 V or a unit resets on battery: protection-board causes, look-alikes, and the test order |
| docs/bringup.md | Hardware verification checklist — every HW-VERIFY marker has a row |
| docs/diagrams/ | Bench wiring, battery/divider schematic, enclosure internals and the no-solder bring-up ladder (self-contained HTML) |
| docs/infrastructure.md | Network-side hardware: ZBT-2 border router, Thread coverage, Voice PE, order status |
| docs/source-reliability.md | Documented traps in vendor docs and third-party sources |
| hardware/case/README.md | Enclosure: revision history, measured wall thicknesses, print vendor and material, engraving artwork |
| circuit-board-maker/ | Custom-PCB evaluation and two paper board designs. Concluded: not worth it for three units. Never fabricated — see the conflicts list before reviving it |
Findings that aren't in any datasheet, written down so the next person doesn't pay for them twice. Full list in docs/source-reliability.md and docs/pinmap.md.
- A0 and D0 are the same pin on the XIAO ESP32-C6. If your display uses D0 for reset, the obvious ADC pin is gone. Battery sense moved to an underside test pad (pinmap).
- The C6's RF switch is off at reset. GPIO3 has a 10k pull-up and must be driven low in firmware before the antenna works; GPIO14 selects onboard ceramic versus U.FL. Neither pin is on the header. Schematic-verified.
- espboards.dev pin tables are wrong for the C6 — they're shared boilerplate across ESP32 variants.
- The Seeed wiki's battery-sense snippet doesn't apply. It assumes an onboard divider the C6 schematic shows isn't populated.
- A 100k battery divider costs ~9% of a 300µA budget. 2×1MΩ + 100nF instead, at the price of a high-impedance ADC source (power budget).
- A live ADC unit keeps the C6's radio power domain on through every light sleep (~95 µA). Create and delete the unit per reading, and hold a no-light-sleep lock across it or the chip can fail to wake (field notes §21).
- The chip only powers down its peripherals in light sleep if every I2C and
SPI bus asks for it (
flags.allow_pd,SPICOMMON_BUSFLAG_SLP_ALLOW_PD). Without them the sleep floor was 279 µA; with them, 56 µA (§21). - A multimeter on a high-impedance node is a load, not a reference. A 10 MΩ meter reads a 1 MΩ:1 MΩ divider midpoint ~5 % low. Calibrate the ADC against the supply, end to end (§23).
- Light sleep kills the USB serial port in ~2 seconds, which makes a board
effectively unflashable without
CONFIG_USJ_NO_AUTO_LS_ON_CONNECTION(build has the recovery procedure, including the WSL2/usbipd quirks). - Opening
/dev/ttyACM0with a plain shell read can hard-reset the chip — it pulses the USB-Serial-JTAG control lines. Useidf.py monitor. - Seeed's sleep-current figures are for a bare board. Measured on this build: a 45–57 µA light-sleep floor once the fixes here were in; 376 µA before them (power budget).
- Long Idle Time ICD does nothing for you under Home Assistant. No check-in client registers, so the device runs short-idle mode anyway, while LIT forces a 5 s minimum active period after every exchange and an extra wake a minute. Turning it off and raising the idle interval to 600 s took the average from 307 to 185 µA (retro M8).
- A non-wake GPIO with a level interrupt reads as triggered in light sleep.
With
CONFIG_PM_SLP_DISABLE_GPIO, pins that are not wake sources are isolated and read low. A push switch on an HP pin with a low-level interrupt raised the floor 16 µA and made every Thread poll 60 % dearer;gpio_sleep_sel_dis()fixed it, 185 → 125 µA (§28). - Only LP GPIOs (0–7) wake the C6 from light sleep with peripheral power-down on, and only on a level. On the XIAO the free LP pins are underside pads (§21, pinmap).
- An unpaired Matter device on the C6 never light-sleeps and draws ~28 mA.
esp_deep_sleep_start()from the running stack hung or stuck at 20 mA; what worked is restarting and entering deep sleep at the top of the next boot, before Bluetooth, Thread or power management start (§26–§27). - A USB host keeps the C6 out of light sleep, so a sleep bug can be invisible for weeks on the bench. Test sleep on battery or a PPK2 with USB out (§20).
- A breadboard row in the battery path brown-outs the radio. Thousands of
restarts read as a firmware fault; the Matter
bootReasonattribute said brown-out all along (battery-runbook B3).
| # | Deliverable | Status |
|---|---|---|
| 1 | Repo scaffold, docs, CI compiling an esp-matter skeleton for esp32c6 | done |
| 2 | Matter temp/humidity over Thread, commissions to HA (TinyENV parity) | done 2026-08-23 — commissioned to HA over ZBT-2 OTBR, readings live in HA; see docs/field-notes.md for the preconditions |
| 3 | Display driver, view 1 rendering readings, measured refresh cost | done — display verified 2026-08-22 (full 1.79 s / partial 0.54 s BUSY); refresh charge measured 2026-10-05: partial ~6–7.5 mC, full ~19 mC |
| 4 | Encoder, views, settings, wake behavior | done 2026-09-28 — a detent wakes the chip from light sleep on battery (encoder A on the MTCK pad); the push switch registers within 2 s of a turn |
| 5 | ICD tuning, battery reporting, power budget with measured numbers | done — 238 µA over 12.6 h (2026-09-22); 125 µA settled after the ICD and pin fixes (2026-10-05); unpaired deep sleep 19.7 µA; battery voltage within 3 mV. See docs/power-budget.md |
| 6 | Factory reset, low-battery behavior, assembly guide final, v1.0.0 | factory reset + LED + low-bat display done; rest awaits hardware |
docs/ BOM, pin map, assembly, commissioning, power budget, build,
field notes, retro
hardware/case/ wall-mount enclosure (veltoc remix, MIT) + engraving artwork
circuit-board-maker/ custom-PCB evaluation + two unfabricated KiCad designs
firmware/
components/ drivers shared across future homecadia devices
sensor-01/ esp-matter application
tools/ PPK2 capture analysis, Matter server node reader, profile check
.github/workflows/ CI: firmware build on push, .bin artifacts
- Read docs/bom.md — real parts, real prices in CAD, real vendors, and which ones have known counterfeits or reversed polarity.
- Print the enclosure from hardware/case, or adapt it. The revision history there explains why each dimension is what it is, which matters if you swap the battery or the display.
- Build the firmware with docs/build.md — Docker path matches CI exactly and is the low-friction option.
- Wire it using docs/pinmap.md and docs/assembly.md. Read the LiPo polarity warning first.
- Commission it with docs/commissioning.md, then work through docs/bringup.md.
Forking for different hardware? firmware/sensor-01/main/app_config.h is the
single source of pin truth and the place to start.
This is a personal build against one specific parts list, so the parts list, the pinned toolchain and the locked design decisions are unlikely to change. That said: corrections are very welcome, especially if you've measured something this repo only models, or if one of the traps turns out to be wrong on your hardware. Issues and pull requests both fine.
If you are working on Matter or Thread devices on the ESP32-C6 (or H2, or any ESP32 with an 802.15.4 radio) and hit something documented here, or something that should be, open an issue: comparing measured numbers across boards is the fastest way to tell a board quirk from a firmware one.
MIT, see LICENSE. Third-party attribution — including the veltoc enclosure remix and the TinyENV architecture references — is in NOTICE.md.
