406 lines
16 KiB
Markdown
406 lines
16 KiB
Markdown
# 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://<hub>/<endpoint>'
|
||
```
|
||
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.**
|