# 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 `R` and `W` - 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_seen` from 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: 1. **Manual** — encoder picks the governing room. 2. **Schedule** — time-of-day rules (e.g. bedroom overnight, TV room evenings). 3. **Formula** — `min` across rooms ("coldest room wins") is the recommended default; `average` also 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 at `setpoint + 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.** 1. **Fail-open.** Normally-open relay. Crash, reset, brownout or power loss must leave heat **off**. 2. **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. 3. **Maximum on-time cap.** Absolute limit per heat call (suggest 60 min). Exceeding it drops the relay and raises a fault. 4. **Minimum off-time.** At least 5 minutes between heat calls (anti short-cycle). 5. **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 6. **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.** 1. **Zigbee hub HTTP API shape.** A sample response is ideal: ```bash curl -s 'http:///' ``` Needed: URL pattern, how a sensor is identified (friendly name? IEEE address?), the JSON field holding temperature, whether a timestamp / `last_seen` is included, whether auth is required, and battery reporting if available. 2. **Confirmation of the 3 thermostat wires** — specifically whether the third is `G` (repurposable as `C`) or already `C`. 3. **Room names / sensor IDs** for the TV room and bedroom. 4. **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.** Calling `lv_display_create()` first faults with `LoadProhibited` inside the TLSF allocator — a confusing way to discover an ordering bug. Guard it with `lv_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_RODATA`** with 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 set `CONFIG_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_timer` for 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_120M` raises 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: ```bash . ~/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.**