Compare commits

...
Author SHA1 Message Date
temphummon dev
96089c168f Verify internal temp sensor is live via CPU stress test
- 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)
2026-09-24 18:10:12 +00:00
temphummon dev
094ff08877 Document session workflows: probe/app build switch, tmux serial monitor, board reset, black-control bits 2026-09-24 18:00:09 +00:00
temphummon dev
b6a7e082b1 Document planned battery (low) and Bluetooth (Matter joined) icon behavior 2026-09-24 17:58:09 +00:00
temphummon dev
b5cf59b39a Complete icon map: Bluetooth (f13 bit3) and battery (f13 bit4) with °C composition
- probed f[13] on blank panel: bit3=Bluetooth, bit4=battery icon,
  0x05=°C, 0x06=°F, bits5-7 unused
- combos verified live: 0x0D (°C+BT), 0x15 (°C+bat), 0x1D (°C+BT+bat)
- add ICON_* constants to temphummon.c; restore as active build
- update PROJECT_MEMORY.md with complete f[13] icon table
2026-09-24 17:56:39 +00:00
temphummon dev
d9bac7cf28 Document icon-probe results; keep iconfinder bitprobe as artifact
Probed remaining frame bytes for Bluetooth/power icons on a blank panel:
- found cross-wired glyph dots (byte0 bit7, byte4 bit7) and an I2C timeout
  (byte8 bit4), but no standalone icon bits
- waveshare reference does not drive these icons; likely multi-bit or
  unpopulated on the glass (cosmetic, deprioritized)
- restore temphummon.c as the active firmware build in CMakeLists
2026-09-24 17:44:02 +00:00
temphummon dev
ebbeffa432 Add bit-probe results, decimal point/percentage bit map, and working temperature+humidity display
- bitprobe.c: interactive single-bit probe that discovered the display glyph
  layout and the decimal point / percent sign bit positions
- temphummon.c: render top line '188.8°C' and bottom '88.8%' using the
  verified decimal points (f[4], f[8] bit5) and percent sign (f[10] bit5)
- PROJECT_MEMORY.md: document full frame layout + empirical bit map
- README: add wiring table and project memory pointer
- add Waveshare reference zip and flake.lock
2026-09-24 17:33:12 +00:00
8 changed files with 925 additions and 4 deletions

BIN
E-Paper-Segment-Code.zip Normal file

Binary file not shown.

391
PROJECT_MEMORY.md Normal file
View file

@ -0,0 +1,391 @@
# 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
```bash
export PATH="/root/.nix-profile/bin:$PATH"
cd /src && nix develop --command bash
```
### One-off commands (no interactive shell needed)
```bash
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` (has `experimental-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). Files `dpfinder`/`iconfinder` were folded into `bitprobe.c` across 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`):
```bash
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:
```bash
$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 0xE0` power/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°C` with
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 sign** `f[10]` bit5), updates every 1s
(`render_temp()` / `render_humidity()` helpers)
- Prints status via ESP_LOG (`temphummon: T: 27.6°C` every 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)
```bash
cd /src/firmware && nix develop /src --no-write-lock-file --command idf.py -p <PORT> flash monitor
```
Or via the flake app:
```bash
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)
- [x] **Board bringup** — flashed and tested the display driver on real hardware
- [x] **Port Waveshare digit lookup table** — all 10 digits (0–9) verified working on the display
- [x] **Internal temperature sensor** — reads ESP32-H2 internal temp sensor, shows on display line 1, updates every 1s
- [x] **Find decimal point & % bits** — probing session mapped them: `f[4]/f[8]` bit5 = decimals, `f[10]` bit5 = %
- [x] **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`; drive `f[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`
- `.envrc` contains `use flake` (for direnv, not active in this environment)
- `direnv` state: `.direnv/` directory exists

View file

@ -12,3 +12,17 @@ Based on the ESP32-H2 and Waveshare Segment E-Paper display.
| [1.9inch Segment E-Paper Module](https://www.waveshare.com/product/displays/e-paper/1.9inch-segment-e-paper-module.htm) | 9.47 € | [wiki](https://www.waveshare.com/wiki/1.9inch_Segment_e-Paper_Module_Manual) | | [1.9inch Segment E-Paper Module](https://www.waveshare.com/product/displays/e-paper/1.9inch-segment-e-paper-module.htm) | 9.47 € | [wiki](https://www.waveshare.com/wiki/1.9inch_Segment_e-Paper_Module_Manual) |
Matter over Thread Temperature and humidity monitor Matter over Thread Temperature and humidity monitor
## 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 |
> 📖 **Project Memory**: See [`PROJECT_MEMORY.md`](PROJECT_MEMORY.md) for environment setup,
> Nix dev shell usage, build status, hardware details, and next steps.

View file

@ -1,2 +1,3 @@
idf_component_register(SRCS "temphummon.c" idf_component_register(SRCS "temphummon.c"
INCLUDE_DIRS ".") INCLUDE_DIRS "."
PRIV_REQUIRES esp_driver_tsens esp_driver_i2c esp_driver_gpio)

218
firmware/main/bitprobe.c Normal file
View file

@ -0,0 +1,218 @@
/**
* iconfinder2 — ESP32-H2 Zero + 1.9" Segment E-Paper (IST7134 via I²C)
*
* Hunt the BLUETOOTH and BATTERY icons specifically.
* Per user: they are at the "3rd and 4th position on the 13th bit" —
* i.e. frame byte 13 (the °C/°F byte), bits 3 and 4.
*
* We probe byte 13 bits 0..7 one at a time on a BLANK panel so any icon is
* unmistakable. (We already know: bit0+bit2 = °C letter; bit1 = black
* control, so it's guarded.)
*
* Wiring (GPIO4→SDA, GPIO5→SCL, GPIO10→RST, GPIO11→BUSY, 3V3→VCC, GND→GND)
*/
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/i2c_master.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_timer.h"
#define PIN_SDA GPIO_NUM_4
#define PIN_SCL GPIO_NUM_5
#define PIN_RST GPIO_NUM_10
#define PIN_BUSY GPIO_NUM_11
#define PIN_BOOT GPIO_NUM_9
#define FRAME_LEN 15
#define ADVANCE_MODE 1
#define AUTO_HOLD_MS 3000
#define BOOT_TIMEOUT_MS 0
static i2c_master_bus_handle_t bus;
static i2c_master_dev_handle_t cmd_dev, dat_dev;
static void epd_reset(void)
{
gpio_set_level(PIN_RST, 1); vTaskDelay(pdMS_TO_TICKS(200));
gpio_set_level(PIN_RST, 0); vTaskDelay(pdMS_TO_TICKS(20));
gpio_set_level(PIN_RST, 1); vTaskDelay(pdMS_TO_TICKS(200));
}
static void epd_wait_busy(void)
{
while (gpio_get_level(PIN_BUSY) == 0) vTaskDelay(pdMS_TO_TICKS(1));
vTaskDelay(pdMS_TO_TICKS(10));
}
static void epd_lut_GC(void)
{
uint8_t lut[] = {0x82, 0x20, 0x00, 0xA0, 0x80, 0x40, 0x63};
i2c_master_transmit(cmd_dev, lut, sizeof(lut), pdMS_TO_TICKS(100));
}
static void epd_lut_DU_WB(void)
{
uint8_t lut[] = {0x82, 0x80, 0x00, 0xC0, 0x80, 0x80, 0x62};
i2c_master_transmit(cmd_dev, lut, sizeof(lut), pdMS_TO_TICKS(100));
}
static void epd_temperature(void)
{
uint8_t a[] = {0x7E, 0x81, 0xB4};
i2c_master_transmit(cmd_dev, a, sizeof(a), pdMS_TO_TICKS(100));
vTaskDelay(pdMS_TO_TICKS(10));
uint8_t b[] = {0xE7, 0x0E};
i2c_master_transmit(cmd_dev, b, sizeof(b), pdMS_TO_TICKS(100));
}
static void epd_init(void)
{
epd_reset();
vTaskDelay(pdMS_TO_TICKS(100));
uint8_t p[] = {0x2B, 0xA7, 0xE0};
i2c_master_transmit(cmd_dev, p, sizeof(p), pdMS_TO_TICKS(100));
vTaskDelay(pdMS_TO_TICKS(10));
epd_temperature();
}
static void epd_write(const uint8_t *frame, uint8_t extra_byte)
{
uint8_t w[] = {0xAC, 0x2B, 0x40, 0xA9, 0xA8};
i2c_master_transmit(cmd_dev, w, sizeof(w), pdMS_TO_TICKS(100));
i2c_master_transmit(dat_dev, frame, FRAME_LEN, pdMS_TO_TICKS(100));
i2c_master_transmit(dat_dev, &extra_byte, 1, pdMS_TO_TICKS(100));
uint8_t d[] = {0xAB, 0xAA, 0xAF};
i2c_master_transmit(cmd_dev, d, sizeof(d), pdMS_TO_TICKS(100));
epd_wait_busy();
uint8_t s[] = {0xAE, 0x28, 0xAD};
i2c_master_transmit(cmd_dev, s, sizeof(s), pdMS_TO_TICKS(100));
}
/* Show byte13 with ONLY the given bit set (blank elsewhere). */
static void show_b13(uint8_t value, int tag, const char *note)
{
uint8_t f[FRAME_LEN];
memset(f, 0, FRAME_LEN);
f[13] = value;
ESP_LOGI("icon2", "T%d: f[13]=0x%02x (%s)", tag, value, note);
uint8_t off[FRAME_LEN];
memset(off, 0, FRAME_LEN);
epd_lut_GC();
epd_write(off, 0x00);
epd_lut_DU_WB();
epd_write(f, 0x00);
}
static void boot_init(void)
{
gpio_config_t bc = {
.pin_bit_mask = (1ULL << PIN_BOOT), .mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE, .pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&bc);
}
static void wait_for_advance(void)
{
if (ADVANCE_MODE == 0) { vTaskDelay(pdMS_TO_TICKS(AUTO_HOLD_MS)); return; }
ESP_LOGI("icon2", "press BOOT to advance next...");
int64_t t_start = esp_timer_get_time();
for (;;) {
if (gpio_get_level(PIN_BOOT) == 0) {
vTaskDelay(pdMS_TO_TICKS(30));
while (gpio_get_level(PIN_BOOT) == 0) vTaskDelay(pdMS_TO_TICKS(10));
vTaskDelay(pdMS_TO_TICKS(30));
break;
}
if (BOOT_TIMEOUT_MS > 0 &&
esp_timer_get_time() - t_start > BOOT_TIMEOUT_MS * 1000LL) {
ESP_LOGW("icon2", "no BOOT press in %d ms", BOOT_TIMEOUT_MS);
break;
}
vTaskDelay(pdMS_TO_TICKS(20));
}
}
void app_main(void)
{
i2c_master_bus_config_t bc = {
.i2c_port = -1, .sda_io_num = PIN_SDA, .scl_io_num = PIN_SCL,
.clk_source = I2C_CLK_SRC_DEFAULT, .glitch_ignore_cnt = 7,
.flags.enable_internal_pullup = 1,
};
ESP_ERROR_CHECK(i2c_new_master_bus(&bc, &bus));
i2c_device_config_t cc = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7, .device_address = 0x3C, .scl_speed_hz = 100000,
};
i2c_device_config_t dc = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7, .device_address = 0x3D, .scl_speed_hz = 100000,
};
ESP_ERROR_CHECK(i2c_master_bus_add_device(bus, &cc, &cmd_dev));
ESP_ERROR_CHECK(i2c_master_bus_add_device(bus, &dc, &dat_dev));
gpio_config_t rc = {
.pin_bit_mask = (1ULL << PIN_RST), .mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE, .pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&rc); gpio_set_level(PIN_RST, 1);
gpio_config_t busy_cfg = {
.pin_bit_mask = (1ULL << PIN_BUSY), .mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_DISABLE, .pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&busy_cfg);
boot_init();
epd_init();
ESP_LOGI("icon2", "== Probe f[13] bits for Bluetooth (3) & power (4) icons ==");
/* Single bits of byte 13 (skip bit1 = known black control). */
show_b13(0x01, 1, "bit0 (C stroke)");
wait_for_advance();
show_b13(0x02, 2, "bit1 (SKIPPED-guard, black ctrl)");
wait_for_advance(); // just wait, we already know it blackens
show_b13(0x04, 3, "bit2 (C stroke)");
wait_for_advance();
show_b13(0x08, 4, "bit3 = BLUETOOTH?");
wait_for_advance();
show_b13(0x10, 5, "bit4 = POWER/BATTERY?");
wait_for_advance();
show_b13(0x20, 6, "bit5");
wait_for_advance();
show_b13(0x40, 7, "bit6");
wait_for_advance();
show_b13(0x80, 8, "bit7");
wait_for_advance();
/* Combo reference: full °C (0x05) and, if icons exist, try adding them. */
show_b13(0x05, 9, "0x05 = °C full");
wait_for_advance();
show_b13(0x05 | 0x08, 10, "0x0D = °C + BT?");
wait_for_advance();
show_b13(0x05 | 0x10, 11, "0x15 = °C + bat?");
wait_for_advance();
show_b13(0x05 | 0x08 | 0x10, 12, "0x1D = °C + BT + bat?");
wait_for_advance();
ESP_LOGI("icon2", "== Done. ==");
uint8_t off[FRAME_LEN];
memset(off, 0, FRAME_LEN);
epd_lut_DU_WB();
epd_write(off, 0x00);
while (1) vTaskDelay(pdMS_TO_TICKS(1000));
}

View file

@ -1,6 +1,217 @@
#include <stdio.h> /**
* temphummon — ESP32-H2 + 1.9" Segment E-Paper (IST7134 via I²C)
* Shows internal CPU temperature on top line + fixed humidity on bottom.
*
* Frame layout (empirically verified, see PROJECT_MEMORY.md):
* f[0] = leading "1" glyph (bits 0-4)
* f[1],f[2] = top-line tens digit
* f[3],f[4] = top-line ones digit
* f[5],f[6] = bottom-line tens digit
* f[7],f[8] = bottom-line ones digit
* f[9],f[10] = bottom-line tenths digit
* f[11],f[12] = top-line tenths digit
* f[13] = °C (0x05) / °F (0x06) letter
*
* DECIMAL POINT / % (the hard-won bits!):
* f[4] bit5 = top decimal (after top ones) -> "188.8"
* f[8] bit5 = bottom decimal (after bottom ones) -> "88.8"
* f[10] bit5 = percent sign (after bottom tenths) -> "88.8%"
*
* Wiring: GPIO4→SDA, GPIO5→SCL, GPIO10→RST, GPIO11→BUSY, 3V3→VCC, GND→GND
*/
#include <string.h>
#include <math.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/i2c_master.h"
#include "driver/gpio.h"
#include "driver/temperature_sensor.h"
#include "esp_log.h"
void app_main(void) #define PIN_SDA GPIO_NUM_4
{ #define PIN_SCL GPIO_NUM_5
#define PIN_RST GPIO_NUM_10
#define PIN_BUSY GPIO_NUM_11
static i2c_master_bus_handle_t bus;
static i2c_master_dev_handle_t cmd_dev, dat_dev;
static temperature_sensor_handle_t tsens;
/* Digit glyphs from Waveshare reference (byte pair per digit). */
static const uint8_t DIG[10][2] = {
{0xbf,0x1f},{0x1f,0x00},{0xfd,0x17},{0xf5,0x1f},{0x47,0x1f},
{0xf7,0x1d},{0xff,0x1d},{0x21,0x1f},{0xff,0x1f},{0xf7,0x1f},
};
static const uint8_t DIG_L[10][2] = {
{0xbf,0x1f},{0x1f,0x00},{0xfd,0x37},{0xf5,0x1f},{0x47,0x1f},
{0xf7,0x1d},{0xff,0x3d},{0x21,0x1f},{0xff,0x3f},{0xf7,0x1f},
};
/* Decimal point & % bits (0x20) */
#define BIT_DP_TOP 0x20 /* f[4] */
#define BIT_DP_BOT 0x20 /* f[8] */
#define BIT_PERCENT 0x20 /* f[10] */
/* Status icons in f[13] (empirically verified, see PROJECT_MEMORY.md) */
#define ICON_DEGC 0x05 /* °C (degree + C) */
#define ICON_DEGF 0x06 /* °F */
#define ICON_BT 0x08 /* Bluetooth */
#define ICON_BAT 0x10 /* battery/power */
#define ICON_BT_BAT 0x1D /* °C + Bluetooth + battery */
/* Debug: burn CPU for a while to stress the die and verify the internal
* temp sensor responds. Set to 1 to spin in a busy loop for STRESS_MS
* after each read (should raise/perturb the die temp).
* Verified: die rises 27.6 -> 28.6°C under load (sensor is live). */
#define CPU_STRESS_TEST 0
#define STRESS_MS 3000
static void epd_wait_busy(void) {
/* BUSY_N: LOW = busy, HIGH = ready */
while (gpio_get_level(PIN_BUSY) == 0) vTaskDelay(1);
vTaskDelay(pdMS_TO_TICKS(10));
}
static void epd_lut_GC(void) {
uint8_t lut[] = {0x82,0x20,0x00,0xA0,0x80,0x40,0x63};
i2c_master_transmit(cmd_dev, lut, sizeof(lut), pdMS_TO_TICKS(100));
}
static void epd_lut_DU_WB(void) {
uint8_t lut[] = {0x82,0x80,0x00,0xC0,0x80,0x80,0x62};
i2c_master_transmit(cmd_dev, lut, sizeof(lut), pdMS_TO_TICKS(100));
}
static void epd_temperature(void) {
uint8_t a[] = {0x7E,0x81,0xB4};
i2c_master_transmit(cmd_dev, a, sizeof(a), pdMS_TO_TICKS(100));
vTaskDelay(pdMS_TO_TICKS(10));
uint8_t b[] = {0xE7,0x0E};
i2c_master_transmit(cmd_dev, b, sizeof(b), pdMS_TO_TICKS(100));
}
static void epd_write(const uint8_t *frame, bool clear) {
uint8_t w[] = {0xAC,0x2B,0x40,0xA9,0xA8};
i2c_master_transmit(cmd_dev, w, 5, pdMS_TO_TICKS(100));
i2c_master_transmit(dat_dev, frame, 15, pdMS_TO_TICKS(100));
uint8_t e = clear ? 0x03 : 0x00;
i2c_master_transmit(dat_dev, &e, 1, pdMS_TO_TICKS(100));
uint8_t d[] = {0xAB,0xAA,0xAF};
i2c_master_transmit(cmd_dev, d, 3, pdMS_TO_TICKS(100));
epd_wait_busy();
uint8_t s[] = {0xAE,0x28,0xAD};
i2c_master_transmit(cmd_dev, s, 3, pdMS_TO_TICKS(100));
}
static void epd_init(void) {
// Reset
gpio_set_level(PIN_RST, 1); vTaskDelay(pdMS_TO_TICKS(200));
gpio_set_level(PIN_RST, 0); vTaskDelay(pdMS_TO_TICKS(20));
gpio_set_level(PIN_RST, 1); vTaskDelay(pdMS_TO_TICKS(200));
// Power on
uint8_t p[] = {0x2B,0xA7,0xE0};
i2c_master_transmit(cmd_dev, p, 3, pdMS_TO_TICKS(100));
vTaskDelay(pdMS_TO_TICKS(10));
epd_temperature();
}
/* Render a value (0-199.9) to the top line: [lead1 ][tens][ones].[tenths] */
static void render_temp(uint8_t *f, float t) {
if (t < 0) t = 0;
if (t > 199.9) t = 199.9;
int hi = (int)t / 100;
int te = ((int)t / 10) % 10;
int on = (int)t % 10;
int tn = (int)(t * 10) % 10;
// tens (top)
if (hi || te) { f[1] = DIG[te][0]; f[2] = DIG[te][1]; }
// ones (top) + DECIMAL (f[4] bit5)
f[3] = DIG[on][0];
f[4] = DIG[on][1] | BIT_DP_TOP;
// tenths (top)
f[11] = DIG_L[tn][0];
f[12] = DIG_L[tn][1];
// leading "1"
if (hi) {
f[0] |= 0x1f; /* left column of the leading "1" glyph */
}
// °C
f[13] = ICON_DEGC;
}
/* Render a value (0-99.9) to the bottom line: [tens][ones].[tenths]% */
static void render_humidity(uint8_t *f, float h) {
if (h < 0) h = 0;
if (h > 99.9) h = 99.9;
int te = ((int)h / 10) % 10;
int on = (int)h % 10;
int tn = (int)(h * 10) % 10;
// tens (bottom)
if (te) { f[5] = DIG[te][0]; f[6] = DIG[te][1]; }
// ones (bottom) + DECIMAL (f[8] bit5)
f[7] = DIG[on][0];
f[8] = DIG[on][1] | BIT_DP_BOT;
// tenths (bottom) + PERCENT (f[10] bit5)
f[9] = DIG_L[tn][0];
f[10] = DIG_L[tn][1] | BIT_PERCENT;
}
void app_main(void) {
// Init I²C
i2c_master_bus_config_t bc = {.i2c_port=-1,.sda_io_num=PIN_SDA,.scl_io_num=PIN_SCL,
.clk_source=I2C_CLK_SRC_DEFAULT,.glitch_ignore_cnt=7,.flags.enable_internal_pullup=1};
ESP_ERROR_CHECK(i2c_new_master_bus(&bc, &bus));
i2c_device_config_t cc = {.dev_addr_length=I2C_ADDR_BIT_LEN_7,.device_address=0x3C,.scl_speed_hz=100000};
i2c_device_config_t dc = {.dev_addr_length=I2C_ADDR_BIT_LEN_7,.device_address=0x3D,.scl_speed_hz=100000};
ESP_ERROR_CHECK(i2c_master_bus_add_device(bus, &cc, &cmd_dev));
ESP_ERROR_CHECK(i2c_master_bus_add_device(bus, &dc, &dat_dev));
// Init GPIO
gpio_config_t rc = {.pin_bit_mask=(1ULL<<PIN_RST),.mode=GPIO_MODE_OUTPUT,
.pull_up_en=GPIO_PULLUP_DISABLE,.pull_down_en=GPIO_PULLDOWN_DISABLE,.intr_type=GPIO_INTR_DISABLE};
gpio_config(&rc); gpio_set_level(PIN_RST, 1);
gpio_config_t bc2 = {.pin_bit_mask=(1ULL<<PIN_BUSY),.mode=GPIO_MODE_INPUT,
.pull_up_en=GPIO_PULLUP_DISABLE,.pull_down_en=GPIO_PULLDOWN_DISABLE,.intr_type=GPIO_INTR_DISABLE};
gpio_config(&bc2);
// Init temp sensor
temperature_sensor_config_t tc = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 60);
ESP_ERROR_CHECK(temperature_sensor_install(&tc, &tsens));
ESP_ERROR_CHECK(temperature_sensor_enable(tsens));
// Init display
epd_init();
// Clear
uint8_t off[15]; memset(off, 0, 15);
epd_lut_GC();
epd_write(off, 0); vTaskDelay(pdMS_TO_TICKS(500));
float t;
while (1) {
ESP_ERROR_CHECK(temperature_sensor_get_celsius(tsens, &t));
ESP_LOGI("temphummon", "T: %.1f°C", (double)t);
#if CPU_STRESS_TEST
/* Burn CPU hard for STRESS_MS to heat the die. */
uint32_t ticks = pdMS_TO_TICKS(STRESS_MS);
uint32_t start = xTaskGetTickCount();
volatile uint32_t sink = 1;
while ((xTaskGetTickCount() - start) < ticks) {
for (uint32_t i = 0; i < 100000; i++) sink = sink * 131 + i; /* ~busy */
}
(void)sink;
#endif
uint8_t f[15]; memset(f, 0, 15);
render_temp(f, t);
render_humidity(f, 88.8f); /* fixed sample until a humidity sensor is wired */
epd_lut_DU_WB();
epd_write(f, 0);
vTaskDelay(pdMS_TO_TICKS(1000));
}
} }

86
flake.lock generated Normal file
View file

@ -0,0 +1,86 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1782847189,
"narHash": "sha256-twXPFqFsrrY5r28Zh7Homgcp2gUMBgQ6WDS98Q/3xFI=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "b6018f87da91d19d0ab4cf979885689b469cdd41",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-esp-dev": {
"inputs": {
"flake-utils": [
"flake-utils"
],
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1777553768,
"narHash": "sha256-2J1ZbfbLUGwpXfLpDTCEpiR4wZD1lVFWiup67DSJRuw=",
"owner": "dvdvgt",
"repo": "nixpkgs-esp-dev",
"rev": "f9b1e211262a4cc9c1a265b227def56ef01c2d56",
"type": "github"
},
"original": {
"owner": "dvdvgt",
"ref": "update-v6.0.1",
"repo": "nixpkgs-esp-dev",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"nixpkgs-esp-dev": "nixpkgs-esp-dev"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}