- temp sensor confirmed responsive to die heating (27.6 -> 28.6 C under load) - document quantization/calibration: readout sticks to ~1C steps ending in .6 (inherent to ESP32-H2 internal sensor, not a bug) - keep CPU_STRESS_TEST as a debug toggle (default off)
15 KiB
Project Memory — temphummon
Temperature and humidity monitor using Matter over Thread. Based on the ESP32-H2 Zero and Waveshare 1.9" Segment E-Paper display.
Quick Reference
| Item | Value |
|---|---|
| MCU | ESP32-H2 (RISC-V, IEEE 802.15.4 / Thread) |
| IDF version | v6.0.1 |
| Chip target | esp32h2 |
| Build tool | idf.py (via Nix dev shell) |
| Main source | firmware/main/temphummon.c |
| sdkconfig | firmware/sdkconfig |
| Datasheets | datasheets/ |
| CAD files | cad/ |
| BOM | See README.md |
| Reference code | E-Paper-Segment-Code.zip (Waveshare, in project root) |
Wiring (ESP32-H2-Zero ↔ E-Paper Module)
Fixed and tested — display does NOT overheat with correct wiring.
| ESP32-H2-Zero | E-Paper Module | Wire color |
|---|---|---|
| GPIO4 | SDA | 🟢 Green |
| GPIO5 | SCL | 🟡 Yellow |
| GPIO10 | RST | 🟠 Orange |
| GPIO11 | BUSY | 🔵 Blue |
| 3V3 | VCC | 🔴 Red |
| GND | GND | ⚫ Black |
Note: The module version has its own level shifters and LDO — safe to connect directly. The bare panel (without PCB) has different pinout.
Important: Display Interface = I²C, not SPI
The IST7134 driver IC on the 1.9" Segment E-Paper uses I²C communication, NOT SPI. The Waveshare reference code confirms this.
I²C Protocol Details
| Parameter | Value |
|---|---|
| Command address | 0x3C (A0=0, 7-bit) |
| Data address | 0x3D (A0=1, 7-bit) |
| Speed | 100 kHz |
| Pull-ups | Internal (enabled in firmware) |
The A0 bit is encoded in the I²C address itself: 0x3C for commands, 0x3D for data. This is handled by using two separate i2c_master_dev_handle_t handles in the firmware.
Display Data Format
- 15 bytes per frame
- 91 segment outputs + 2 background + 1 VCOM (94 bits ≈ 12 bytes, padded to 15)
- Digit patterns (0-9) from Waveshare reference code
- Two SRAM banks used: first for image data, second for control
Frame Layout (EMPIRICALLY VERIFIED with bit-probe)
The digits are dot-matrix style (each bit = one dot / short stroke of a column), NOT classic one-bit-per-7-segment. Each digit glyph maps to a byte pair. The two lines interleave: top-tens, top-ones, bottom-tens, bottom-ones, bottom-tenths, top-tenths.
| Frame byte(s) | Display location | Position label |
|---|---|---|
f[0] bits 0-4 |
Top line, leading "1" (hundreds) | — |
f[1], f[2] |
Top line, tens digit | pos 1 |
f[3], f[4] |
Top line, ones digit | pos 2 |
f[5], f[6] |
Bottom line, tens digit | pos 3 |
f[7], f[8] |
Bottom line, ones digit | pos 4 |
f[9], f[10] |
Bottom line, tenths digit | pos 5 |
f[11], f[12] |
Top line, tenths digit | pos 6 |
f[13] |
°C / °F letter (0x05 = °C) | — |
f[14] |
Padding / unknown | — |
→ Top line renders as [1][tens][ones].[tenths] °C e.g. 188.8 °C
→ Bottom line renders as [tens][ones].[tenths] % e.g. 88.8 %
Decimal point & % (THE missing piece — now found! 🎯)
All three are bit 5 (0x20) of the SECOND byte of the pair, matching the manual: "decimal point and % are the 5th place in the 4th, 8th, and 10th positions."
| Symbol | Frame byte | Bit | Where it appears |
|---|---|---|---|
| Decimal point (top) | f[4] |
bit 5 (0x20) | after top-line ones → 188.8 |
| Decimal point (bottom) | f[8] |
bit 5 (0x20) | after bottom-line ones → 88.8 |
| % sign (humidity) | f[10] |
bit 5 (0x20) | after bottom-line tenths → 88.8% |
Bluetooth / power icons (FOUND! 🎯 — in f[13], not a special byte)
All status icons live in f[13], the same byte as the °C/°F letter. The
manual's "3rd and 4th position on the 13th bit" = bits 3 and 4 of f[13].
Probing on a blank panel confirmed each bit, and combos compose cleanly.
| f[13] value | Bits | Displays |
|---|---|---|
0x01 |
bit0 | part of C letter (standalone = black-control risk) |
0x02 |
bit1 | degree sign ° line |
0x04 |
bit2 | C/F letter strokes |
0x05 |
bits0+2 | °C (degree + C) |
0x06 |
bits1+2 | °F (per manual) |
0x08 |
bit3 | Bluetooth icon |
0x10 |
bit4 | Battery/power icon |
0x0D |
0x05+0x08 | °C + Bluetooth |
0x15 |
0x05+0x10 | °C + battery |
0x1D |
0x05+0x08+0x10 | °C + Bluetooth + battery |
| bits 5-7 | — | unused |
All compositions verified live on the panel. (The earlier "no icon found" conclusion was wrong - the icons were in f[13] bits 3/4, which the first probe had not isolated.)
Planned status-icon behavior (for future battery + Matter work)
- Battery icon (
f[13]bit4, 0x10): to turn ON when the device battery is nearly empty (low-battery warning). Requires adding a battery/gas- gauge circuit (e.g. ADC divider on VBAT, or an I²C fuel gauge) later. - Bluetooth icon (
f[13]bit3, 0x08): to turn ON when the Matter/ Thread network join/connection is successful, OFF when not connected. Requires the Matter cluster + OpenThread bring-up (see TODO). - Both compose with °C via OR:
f[13] = ICON_DEGC | (low_bat ? ICON_BAT : 0) | (matter_ok ? ICON_BT : 0)once those signals exist.
Verified glyph segment map (from probing)
For a digit byte-pair, the first byte = left column + horizontal bars, the second byte = right column (bits 0-3) + decimal/control (bit 5+). Example (pos 1, top tens): byte1 bit5 = top bar, bit6 = middle bar, bit7 = bottom bar; byte2 bits 0-3 = right-side dots/bars.
⚠️ Control bits (probed): second-byte bit 4 of some pairs and byte13 bit1 flip the whole panel BLACK and wedge the driver until a hard re-init (RST pulse). Avoid setting them, or re-init after. (bitprobe had to power-cycle the board.)
Initialization Sequence
1. Hardware reset (RST pin: high → low → high with delays)
2. POWER_ON command (0x2B)
3. Boost + TSON (0xA7, 0xE0)
4. Temperature compensation based on ambient temp
Display Refresh Sequence
1. Wake from sleep (0xAC)
2. Power on (0x2B)
3. Set RAM address (0x40)
4. Open first SRAM (0xA9), close (0xA8)
5. Write 15 bytes of image data
6. Write extra byte (0x00 or 0x03)
7. Open second SRAM (0xAB), close (0xAA)
8. Display on (0xAF)
9. Wait for BUSY pin
10. Display off (0xAE), HV off (0x28), sleep (0xAD)
Waveform LUTs
| Function | Bytes sent | Use case |
|---|---|---|
epd_lut_DU_WB() |
82 80 00 C0 80 80 62 |
Partial update (white extinction + black out) |
epd_lut_GC() |
82 20 00 A0 80 40 63 |
Full global refresh |
epd_lut_5S() |
82 28 20 A8 A0 50 65 |
Boot waveform (better ghosting) |
Nix Development Shell
The project uses Nix flakes to provision the ESP-IDF toolchain.
How to enter the dev shell
export PATH="/root/.nix-profile/bin:$PATH"
cd /src && nix develop --command bash
One-off commands (no interactive shell needed)
cd /src/firmware && nix develop /src --no-write-lock-file --command idf.py build
Nix details
- Flake:
/src/flake.nix - Lock:
/src/flake.lock - Nix binary:
/root/.nix-profile/bin/nix(also at/nix/store/irfrbndi76zhkvqsfhmsn4a99iafck29-nix-2.35.2/bin/nix) - Nix config:
/etc/nix/nix.conf(hasexperimental-features = nix-command flakes) - Dev shell inputs:
esp-idf,git,minicom,usbutils,gawk,coreutils,cmake,ninja
⚠️ Note: Custom nixpkgs-esp-dev fork
The flake uses a custom fork of nixpkgs-esp-dev:
nixpkgs-esp-dev.url = "github:dvdvgt/nixpkgs-esp-dev/update-v6.0.1";
This fork has few/no cached binary substitutes, so nix develop compiles the entire ESP-IDF toolchain from source. This takes a long time on first run but we're keeping it for now since mainline mirrexagon/nixpkgs-esp-dev doesn't support ESP-IDF v6.0.1 yet.
⚙️ Two firmware builds: app vs. bit-probe
firmware/main/CMakeLists.txt selects which source compiles. To switch:
- App (
temphummon.c):SRCS "temphummon.c"— the normal temperature/humidity display - Probe (
bitprobe.c):SRCS "bitprobe.c"— interactive bit-field explorer (BOOT-button advance). Filesdpfinder/iconfinderwere folded intobitprobe.cacross sessions.
The project name is always temphummon (even when bitprobe is compiled), so the ELF/bin are
named temphummon.* regardless. See the serial-monitor workflow below for how to watch output.
🖥️ Serial monitor workflow (headless container)
idf.py monitor needs a real TTY and fails in this container. Use a detached tmux
session instead (tmux was installed:
apt-get install -y -qq tmux):
PYENV=/nix/store/h1az2v5l5jx81jdqfzk5q6wnv40cgq4y-python3-3.13.12-env
IDF_MON=/nix/store/4d83mx8ff4ffpcj0x8r4l2x3qd8z3z8k-source/tools/idf_monitor.py
tmux new-session -d -s mon "cd /src/firmware && $PYENV/bin/python $IDF_MON \
-p /dev/ttyACM0 -b 115200 --toolchain-prefix riscv32-esp-elf- --target esp32h2 \
--revision 0 --decode-panic backtrace build/temphummon.elf build/bootloader/bootloader.elf \
2>&1 | tee /tmp/monitor.log"
# read live log: tr -d '\000' < /tmp/monitor.log | tail -20
# stop: tmux kill-session -t mon
To hard-reset the board (re-init a wedged panel) without reflashing:
$PYENV/bin/python -m esptool --chip esp32h2 -p /dev/ttyACM0 \
--before=default-reset --after=hard-reset chip-id
The flash tool needs the port free — kill the tmux monitor first (tmux kill-session -t mon).
⚠️ Black-control bits (panel wedging) & I²C timeout
Probing found several bits that flip the whole panel BLACK and wedge it until a hard
re-init (RST pulse / board power-cycle): f[2] b4, f[4] b7, f[13] b1 (and f[8] b4
caused an I²C hardware timeout). Avoid touching these when composing frames. A single
epd_write() does not always clear the wedge — use epd_lut_GC() clear or reset the board.
Build State
✅ Successful build
The firmware compiles and links successfully for ESP32-H2 (RISC-V target).
Current temphummon.c build size: 0x2c580 bytes (83% free).
Firmware behavior
Current firmware (main/temphummon.c):
- Initializes GPIOs (RST output, BUSY input) and I²C bus
- Initializes display (RST pulse →
0x2B 0xA7 0xE0power/boost/TSON → temp comp) - Clears display with GC (full global refresh) LUT, then updates with DU_WB (partial) LUT every second
- Reads internal CPU temperature, renders on top line as
188.8°Cwith correct decimal point (f[4]bit5) and leading "1" (f[0]) - Renders fixed humidity sample on bottom line as
88.8%(tens/ones/tenths- decimal
f[8]bit5 + percent signf[10]bit5), updates every 1s (render_temp()/render_humidity()helpers)
- decimal
- Prints status via ESP_LOG (
temphummon: T: 27.6°Cevery 1s) - Bit 1 of
f[13](0x02) is NOT used for °C (leaves the degree circle incomplete) —0x05= °C (bits 0+2) is the correct full-degree rendering
Internal temp sensor — accuracy/resolution notes (verified)
The internal CPU die sensor is live (a CPU-stress test during bring-up
raised its reading 27.6 → 28.6°C and held it, confirming the die heats). It
measures the silicon die, NOT ambient air — blowing on it barely changes it.
The readout is coarsely quantized with a per-chip calibration offset: values
appear stuck in ~1°C steps and always end in .6 (e.g. 27.6 / 28.6). This is
an inherent limitation of the ESP32-H2 internal sensor, not a firmware bug.
For a real ambient thermometer, wire an external I²C sensor (BME280/SHT40)
instead — see TODO.
⚠️ The display uses a dot-matrix glyph style. A lone bit does not always produce a clean segment; the erase flash (white frame) is normal before each partial update.
✅ Hardware bringup (confirmed working)
- ESP32-H2 Zero detected as rev v1.2 at 96 MHz
- Flashing via USB-Serial/JTAG works at 460800 baud
- Display driver (IST7134 via I²C at 0x3C/0x3D) works:
- I²C communication succeeds, BUSY handshake works
- Display clears properly
- Digits 0–9 show correctly in sequence
- All-black font pattern renders
- Deep sleep mode works without errors
- No hardware issues — wiring, level shifting, LDO all correct
Flashing (requires serial connected)
cd /src/firmware && nix develop /src --no-write-lock-file --command idf.py -p <PORT> flash monitor
Or via the flake app:
nix run .#flash
Hardware Info
ESP32-H2 Zero (Waveshare)
- SoC: ESP32-H2 (single-core RISC-V 32-bit, up to 96 MHz)
- Radio: IEEE 802.15.4 (Thread, Zigbee) + Bluetooth 5 (LE)
- Flash: 4 MB
- PSRAM: Not present on this board
- Schematic:
datasheets/ESP32-H2-Zero-Schematic.pdf - Datasheet:
datasheets/Esp32-h2_datasheet_en.pdf - TRM:
datasheets/Esp32-h2_technical_reference_manual_en.pdf - Wiki: https://docs.waveshare.com/ESP32-H2-Zero
1.9" Segment E-Paper Module (Waveshare)
- Driver IC: IST7134 (datasheet:
datasheets/IST7134.pdf) - Interface: I²C
- Module schematic:
datasheets/1.9inch_Segment_e-Paper_Module01.pdf - Display manual:
datasheets/1.9inch Segment e-Paper V1.1.pdf - Module wiki: https://www.waveshare.com/wiki/1.9inch_Segment_e-Paper_Module_Manual
- Reference code:
E-Paper-Segment-Code.zip(in project root)
Module board features
- NDC7002N dual N-channel MOSFETs for level shifting
- RT9193-33 LDO (3.3V regulator)
- AO3401 P-MOSFET power switch (on by default without external EN)
- Two 6-pin male headers: SDA, SCL, RST, BUSY, VCC, GND
- Supports 3.3V or 5V input
Other components
- WS2812B RGB LED:
datasheets/XL-0807RGBC-WS2812B.pdf(on-board, on GPIO8)
Next Steps (TODO)
- Board bringup — flashed and tested the display driver on real hardware
- Port Waveshare digit lookup table — all 10 digits (0–9) verified working on the display
- Internal temperature sensor — reads ESP32-H2 internal temp sensor, shows on display line 1, updates every 1s
- Find decimal point & % bits — probing session mapped them:
f[4]/f[8]bit5 = decimals,f[10]bit5 = % - Find Bluetooth & battery icons —
f[13]bit3 = Bluetooth, bit4 = battery; combos with °C work (0x0D/0x15/0x1D) - Add humidity sensor — wire up an external I²C sensor (SHT40, BME280, etc.) and show on display line 2
- Add battery + low-battery icon — battery circuit/gauge; drive
f[13]bit4 (battery icon) when nearly empty - Configure Matter/Thread — enable OpenThread / Matter components in
sdkconfig; drivef[13]bit3 (Bluetooth icon) on successful join - Implement Matter cluster — temperature and humidity measurement clusters
- Implement low-power operation — deep sleep between measurements
Flake Nix Apps
The flake defines these convenience apps (nix run .#<name>):
| App | Description |
|---|---|
build |
Build firmware (idf.py build) |
flash |
Flash to auto-detected serial port |
monitor |
Open serial monitor |
menuconfig |
Open idf.py menuconfig |
clean |
idf.py clean |
full |
Build → Flash → Monitor pipeline |
run-agent |
Run pi agent container |
build-agent |
Build pi agent container image |
Environment Notes
- Container: Debian-based (from
Containerfile), runs as root - Working directory:
/src(mounted volume) - Nix is installed but NOT in default PATH — must use
/root/.nix-profile/bin/nix .envrccontainsuse flake(for direnv, not active in this environment)direnvstate:.direnv/directory exists