16 KiB
Rotary Thermostat — Product Requirements
ESP-IDF firmware turning an Elecrow CrowPanel 2.1" Rotary Display into a multi-room thermostat for a gas furnace, reading Zigbee room sensors over HTTP and driving the furnace's heat call via a relay.
Status: not started. This document is the brief for the implementing agent.
1. Goal
Replace a conventional 24VAC thermostat with a device that:
- reads temperature from Zigbee sensors in multiple rooms, via an existing Zigbee hub's HTTP API
- decides which room's reading governs (manual / scheduled / formula)
- calls for heat by closing a dry contact between
RandW - presents setpoint, current temperature and status on a 480×480 round display
- takes setpoint input from the rotary encoder
Control logic runs on the ESP32, not in Home Assistant. The house must keep heating when the hub or HA is down, degrading to a safe fallback.
2. Hardware
2.1 Board — Elecrow CrowPanel 2.1" HMI Rotary Display
Verified on the actual unit (2026-08-03) with esptool:
| Property | Value |
|---|---|
| SoC | ESP32-S3 (QFN56) rev v0.2 |
| Flash | 16MB (GigaDevice c8:4018) |
| PSRAM | 8MB embedded (AP_3v3) |
| USB | native USB-Serial/JTAG (303a:1001) — no CH340 |
| Port | /dev/ttyACM0 |
| MAC | 1c:db:d4:b3:5c:74 |
| Power | 5V / 1A |
Currently running stock Arduino firmware (arduino-lib-builder, IDF v4.4.6, Oct 2023). Nothing has been written to it.
2.2 Peripherals — FROM VENDOR DOCS, NOT YET VERIFIED ON HARDWARE
Read section 8.1 before trusting any of these numbers. The previous project in this family lost most of a session to a pinout taken from the wrong board variant. Confirm every value against Elecrow's own source before writing driver code.
- Panel: ST7701S, 480×480 round IPS. Requires a ~30-command SPI initialisation sequence before the RGB bus produces anything. A bare RGB configuration displays nothing.
- Touch: CST816 capacitive (not GT911 — different register map and protocol from the 7" project).
- I/O expander: PCF8574 @ I2C
0x21. - Rotary encoder: A = GPIO42, B = GPIO4; button on expander P5 (INPUT_PULLUP).
| Signal | GPIO |
|---|---|
| CS | 16 |
| DE | 40 |
| HSYNC | 15 |
| VSYNC | 7 |
| PCLK | 41 (18 MHz) |
| R0–R4 | 46, 3, 8, 18, 17 |
| G0–G5 | 14, 13, 12, 11, 10, 9 |
| B0–B4 | 5, 45, 48, 47, 21 |
| I2C SDA / SCL | 38 / 39 |
PCF8574 allocation:
| Pin | Use |
|---|---|
| P0 | Touch reset |
| P1 | free |
| P2 | Touch interrupt |
| P3 | LCD power |
| P4 | LCD reset |
| P5 | Encoder button |
| P6 | free |
| P7 | free |
2.3 Available I/O
The RGB panel consumes ~21 GPIOs, so almost nothing native is exposed. Externally available:
- I2C connector (SDA 38 / SCL 39), ZX-MX 1.25-4P
- UART0 and UART1 connectors
- 12P FPC (power + programming)
- PCF8574 P1, P6, P7
I2C is the expansion path, not GPIO. Three spare expander pins is enough for this project; a second expander or I2C relay board can be added on the connector if more are needed.
PCF8574 outputs sink well (~25 mA) but barely source — use active-low triggered relay modules.
2.4 HVAC system
- Gas/oil forced-air furnace, 24VAC control. Heat only, no cooling.
- 3-wire system.
- Presence of a
C(common) wire unconfirmed — see section 7.
Third wire is most likely G (fan). On a heat-only system independent fan
control is rarely needed — the furnace board runs the blower automatically
during a heat call — so G can usually be repurposed as C by moving it to
the C terminal at the furnace control board. This powers the thermostat with
no new cable. Confirm terminal labels at the furnace and photograph the
existing wiring before changing anything.
If C is unavailable, power the board from a separate 5V USB supply. Do not
attempt 24VAC "power stealing" — this board needs ~5W and power stealing
supplies roughly a tenth of that; it will cause furnace chatter or
short-cycling.
2.5 Output wiring
PCF8574 P1 ──► relay module IN (active low)
R (24VAC) ──► relay COM
W (heat) ◄── relay NO closes to call for heat
Use an opto-isolated relay module with a separate JD-VCC jumper so the 24VAC side is genuinely isolated. Relay must be normally open.
Never connect R to C — dead short across the transformer; blows the
fuse on the furnace control board.
3. Architecture
Zigbee sensors ──► Zigbee hub (HTTP/JSON) ──► ESP32 polls over WiFi
│
setpoint + control loop
│
PCF8574 P1 ──► relay ──► R/W
Suggested component layout (mirroring the sibling project, section 9):
main/
├── main.c
├── app_config.h gitignored; credentials + hub URL
├── app_config.h.example tracked template
├── drivers/
│ ├── i2c_bus.{c,h} shared bus: PCF8574, touch, any added sensor
│ ├── expander/ PCF8574 — touch reset, LCD power/reset, relay
│ ├── display/ ST7701S init + RGB panel
│ ├── touch/ CST816
│ └── encoder/ rotary + button
├── hvac/
│ ├── relay.{c,h} heat call, with the safety interlocks of section 5
│ └── control.{c,h} setpoint, hysteresis, room selection
├── net/
│ ├── wifi_manager.{c,h}
│ └── zigbee_client.{c,h} polls hub, exposes readings with timestamps
├── ui/
│ ├── lvgl_port.{c,h} tick, task, locking
│ └── ui_manager.{c,h}
└── storage/
└── settings.{c,h} NVS persistence
4. Functional requirements
4.1 Room temperature acquisition
- Poll the Zigbee hub's HTTP endpoint on a fixed interval (default 60s; configurable).
- Parse JSON with cJSON.
- Every reading carries a value and a timestamp (
last_seenfrom the hub if provided, otherwise local receipt time). - Expose battery level per sensor if the hub reports it, and surface a low battery warning on screen.
- A failed poll must not clear previously good readings — only age them.
4.2 Room selection
Three modes, selectable from the UI:
- Manual — encoder picks the governing room.
- Schedule — time-of-day rules (e.g. bedroom overnight, TV room evenings).
- Formula —
minacross rooms ("coldest room wins") is the recommended default;averagealso acceptable. Min-based control means nobody is cold, at the cost of overheating elsewhere.
Manual override must always be reachable regardless of active mode.
4.3 Control loop
- Setpoint adjustable by rotary encoder, 0.5°C steps, sane bounds (e.g. 5–30°C).
- Hysteresis (deadband) to prevent chatter — default ±0.5°C. Call for heat
below
setpoint − deadband, stop atsetpoint + deadband. - Evaluate at a fixed cadence; do not react to every incoming reading.
4.4 Display / UI
- Circular layout suited to a 480×480 round panel — nothing important near the edges.
- Show: current governing temperature, setpoint, active room, mode, heat-call state, and any fault (stale sensor, hub unreachable, low battery).
- Encoder rotation adjusts setpoint; button press cycles screens or confirms.
- LVGL 9.x.
4.5 Persistence
Survive reboot via NVS: setpoint, mode, selected room, schedule.
5. Safety requirements
These are not optional and must be implemented in the relay layer itself, not left to UI code.
- Fail-open. Normally-open relay. Crash, reset, brownout or power loss must leave heat off.
- Watchdog-gated output. The relay is held closed only while a control task refreshes a timer. If the task stops, the relay drops automatically. A hung firmware must not be able to hold a heat call.
- Maximum on-time cap. Absolute limit per heat call (suggest 60 min). Exceeding it drops the relay and raises a fault.
- Minimum off-time. At least 5 minutes between heat calls (anti short-cycle).
- Stale-data policy. Define explicitly for "selected room hasn't reported
in N minutes" (suggest N = 20):
- stale data must never hold the relay closed
- it must never silently mean no heat either (frozen pipes)
- fall back in order: another room's fresh reading → a local sensor if fitted → a fixed conservative setpoint (suggest 16°C), showing a clear fault on screen throughout
- Bounded startup state. Relay off until the control loop has at least one fresh reading or has entered the documented fallback.
5.1 Risk calibration
For accuracy rather than alarm: the furnace's high-limit and rollout switches are independent of the thermostat and remain in circuit. A stuck heat call results in an overheated house and a large gas bill, with the furnace cycling on its own limit — not a fire. The 24VAC control side is genuinely low voltage. This does not remove the need for the interlocks above, but it does mean the realistic failure mode is discomfort and cost.
6. Non-functional
- Must keep heating with the hub, MQTT broker or Home Assistant down, per the fallback ladder in 5.5.
- WiFi reconnect must be automatic and must not block the control loop.
- All network I/O off the LVGL task.
- Boot to a usable, safe state in under ~10 seconds.
7. Blocking inputs still needed from the user
The implementing agent should request these before writing the relevant module. Do not guess at any of them.
- Zigbee hub HTTP API shape. A sample response is ideal:
Needed: URL pattern, how a sensor is identified (friendly name? IEEE address?), the JSON field holding temperature, whether a timestamp /
curl -s 'http://<hub>/<endpoint>'last_seenis included, whether auth is required, and battery reporting if available. - Confirmation of the 3 thermostat wires — specifically whether the third
is
G(repurposable asC) or alreadyC. - Room names / sensor IDs for the TV room and bedroom.
- Units — °C or °F for display.
8. Implementation notes and known pitfalls
8.1 Verify the pinout against vendor source first — non-negotiable
The sibling 7" project lost most of a session to a pinout taken from the wrong board variant (CrowPanel 7" Basic vs Advance — same brand, same wiki, and not one pin in common). Symptoms were a black screen and an apparently empty I2C bus, and every software hypothesis tested came back negative because the code was correct and the hardware description was wrong.
Before writing the display driver:
- Clone Elecrow's repo for this board and read the actual example source — not the wiki prose, not a forum post, not a search summary.
- Lift the ST7701S init sequence verbatim. It is ~30 hex commands; one wrong byte gives garbage or nothing, with no useful diagnostic.
- Cross-check against a second independent source (Elecrow's ESPHome example is good) before trusting it.
- If the panel is black, re-verify the pinout before debugging the software.
A cheap early sanity check: bring up I2C first and scan the bus. Finding the
PCF8574 at 0x21 and the CST816 confirms the board is alive and the I2C pins
are right, independently of the display.
8.2 ESP-IDF / LVGL pitfalls already paid for on the sibling project
lv_init()must run before creating any LVGL object. Callinglv_display_create()first faults withLoadProhibitedinside the TLSF allocator — a confusing way to discover an ordering bug. Guard it withlv_is_initialized().- USB-Serial-JTAG occupies GPIO19/20 on the ESP32-S3. IDF enables it as a
secondary console by default. On the sibling board that silently blocked the
touch I2C bus. On this board USB-JTAG is the only console link, so do
not set
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y— you will lose all serial output. - Do not enable
CONFIG_SPIRAM_FETCH_INSTRUCTIONS/CONFIG_SPIRAM_RODATAwith an RGB panel. They contend with the LCD for PSRAM bandwidth: black screen and stalled TLS handshakes. - Bounce-buffer ISR vs flash writes. The refill ISR reads PSRAM, which
shares the cache with flash on the S3. Any NVS/flash write while the panel is
running faults it with "Cache disabled but cached memory region accessed".
Bring WiFi up before
display_init(), and setCONFIG_ESP_WIFI_NVS_ENABLED=n. Note this project writes settings to NVS at runtime — either pause the panel around those writes or avoid bounce buffers. - Never write into the framebuffer being scanned. Copying a partial rectangle into the live framebuffer races the LCD's reads; the corruption scales with bytes written and persists, because LVGL does not know those pixels are wrong and never reinvalidates them. Use two framebuffers and swap.
- LVGL does not bubble events to parents. An event handler on the screen
object never sees taps landing on a child widget — which looks exactly like
dead touch zones. Poll the input device from an
lv_timerfor anything screen-wide. lv_label_set_text()invalidates unconditionally, even for identical text, forcing a repaint. Compare before setting.- PSRAM at 80MHz is marginal for an RGB panel. The vendor's Arduino builds
use 120MHz, but on ESP-IDF 5.3 selecting
CONFIG_SPIRAM_SPEED_120Mraises the shared MSPI core clock and forces flash to 120MHz too, which needs flash High Performance Mode and a DC-aware bootloader. 480×480 needs roughly 40% of the bandwidth 800×480 did, so this is likely to be a non-issue here — but that is the lever if artefacts appear.
8.3 Toolchain
ESP-IDF v5.3.2 at ~/esp/esp-idf. cmake and ninja live in the IDF
virtualenv, not system-wide, so they are only on PATH after sourcing
export.sh. The Bash tool does not persist shell state between calls — source
it in the same compound command:
. ~/esp/esp-idf/export.sh && idf.py build
Flash and monitor on /dev/ttyACM0. Rootless podman on this machine is broken
(stale /etc/subuid after a user rename) — do not plan on containers.
9. Prior art
~/workspace/thermostat-hw — the sibling project on the CrowPanel 7" Advance.
Working, verified on hardware. Directly reusable patterns:
main/api/ambient_weather.c— HTTP + cJSON polling with timeouts, fixed response buffer and a chunked-encoding-safe event handler. The Zigbee hub client is the same shape.main/ui/lvgl_port.c— LVGL tick, service task, recursive lock, and pause/resume.main/drivers/i2c_bus.c— shared bus ownership with a diagnostic scan.main/wifi/wifi_manager.c— STA bring-up with bounded retries.
Its README.md documents the display architecture and the pitfalls above in
context.
~/workspace/displayTest/FluidTouch — third-party project supporting several
CrowPanel variants; useful reference for board-specific configuration and a
worked LovyanGFX setup.
10. Suggested phasing
Each phase should end in something verifiable on hardware.
| Phase | Deliverable | Verification |
|---|---|---|
| 1 | Project skeleton, WiFi, I2C bus + scan | PCF8574 at 0x21 and CST816 found in the log |
| 2 | ST7701S init + RGB panel | Colour bars on the round panel |
| 3 | LVGL port + basic UI | Setpoint renders, stable, no artefacts |
| 4 | Encoder + CST816 touch | Knob changes setpoint; taps register |
| 5 | Zigbee hub client | Room temps logged with timestamps and staleness |
| 6 | Control loop + all of section 5 | Relay logic exercised with a bench LED/relay, not the furnace |
| 7 | NVS persistence | Settings survive reboot |
| 8 | Schedule / formula modes | Room selection behaves per 4.2 |
| 9 | Install | Only after phase 6 has been proven on the bench |
Do not connect to furnace wiring until the safety interlocks in section 5 are implemented and demonstrated on the bench.