ESP-IDF v5.3.2 firmware for the CrowPanel 2.1" rotary display. Builds clean;
nothing has been run on hardware yet.
Hardware verified against Elecrow's own source for this board and
cross-checked against their ESPHome config, per PRD 8.1. Three corrections to
the PRD's pinout, all documented in docs/HARDWARE.md:
- touch is CST826 at 0x15, not CST816 (chip ID reads 0x11, not 0xB4)
- the ST7701S needs a 3-wire SPI bus on GPIO 16/2/1 that the PRD omitted;
without it the init sequence cannot be sent and the panel stays black
- the backlight is GPIO6
Two further departures from the PRD, both forced by facts on the ground:
- Transport is MQTT, not HTTP. zigbee2mqtt has no REST endpoint for live
sensor state. Subscribing to the broker also gets retained topics, so
every room's last reading arrives on connect rather than after a poll.
- The furnace is 2-wire R/W with no C, not 3-wire. The board needs its own
5V supply. The thermostat's 5 CPH setting is enforced as a minimum
12-minute period between heat-call starts, which a minimum off-time alone
does not achieve.
All of PRD section 5 is implemented in the relay layer itself: fail-open,
watchdog-gated output, max on-time, min off-time, cycle-rate limit, startup
lockout, and the stale-data fallback ladder down to open-loop freeze
protection. The one gap that cannot be closed in software — the PCF8574 not
resetting when the ESP32 does — is documented rather than papered over.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
235 lines
9.3 KiB
Markdown
235 lines
9.3 KiB
Markdown
# Rotary Thermostat
|
||
|
||
ESP-IDF firmware turning an Elecrow CrowPanel 2.1" HMI Rotary Display into a
|
||
multi-room thermostat for a gas furnace. Room temperatures come from Zigbee
|
||
sensors via zigbee2mqtt; the heat call is a dry contact between `R` and `W`.
|
||
|
||
Control logic runs on the ESP32. The house keeps heating when the broker,
|
||
zigbee2mqtt or Home Assistant is down — it degrades through a documented
|
||
fallback ladder rather than stopping.
|
||
|
||
**Status: builds clean against ESP-IDF v5.3.2. Nothing has been run on
|
||
hardware.** The board was not connected when this was written. See
|
||
[Bench bring-up](#bench-bring-up) for the order to verify things in.
|
||
|
||
---
|
||
|
||
## Build
|
||
|
||
Requires ESP-IDF v5.3.2 at `~/esp/esp-idf`. `cmake` and `ninja` live in the IDF
|
||
virtualenv, so they are only on `PATH` after sourcing `export.sh` — and the
|
||
shell does not persist between commands, so source it in the same line:
|
||
|
||
```bash
|
||
cp main/app_config.h.example main/app_config.h
|
||
```
|
||
|
||
Fill in `main/app_config.h` (WiFi, broker, sensor friendly_names), then:
|
||
|
||
```bash
|
||
. ~/esp/esp-idf/export.sh && idf.py build
|
||
```
|
||
|
||
Flash and watch the log:
|
||
|
||
```bash
|
||
. ~/esp/esp-idf/export.sh && idf.py -p /dev/ttyACM0 flash monitor
|
||
```
|
||
|
||
`main/app_config.h` is gitignored. `main/app_config.h.example` is the tracked
|
||
template.
|
||
|
||
---
|
||
|
||
## What still needs your input
|
||
|
||
Everything in the PRD's section 7 is resolved except the sensor identities:
|
||
|
||
- **`APP_ROOM_TABLE` in `main/app_config.h`** is a placeholder. It needs the
|
||
exact zigbee2mqtt `friendly_name` for each room — the last path element of
|
||
the MQTT topic. Check with:
|
||
|
||
```bash
|
||
mosquitto_sub -h <broker> -t 'zigbee2mqtt/#' -v
|
||
```
|
||
|
||
- **`APP_MQTT_URI`, `APP_MQTT_USER`, `APP_MQTT_PASS`** — broker address and
|
||
credentials.
|
||
- **`APP_WIFI_SSID` / `APP_WIFI_PASS`**.
|
||
- **`APP_TIMEZONE`** defaults to US Central. Only matters for schedule mode.
|
||
|
||
---
|
||
|
||
## Where this departs from the PRD
|
||
|
||
Three places, all documented in full where they occur:
|
||
|
||
**Transport is MQTT, not HTTP.** zigbee2mqtt has no REST endpoint that returns
|
||
live sensor state — its frontend streams state over a WebSocket, and
|
||
`/api/devices` returns definitions rather than readings. The ESP32 subscribes
|
||
to the broker z2m publishes to instead. Sensor topics are retained, so every
|
||
room's last known reading arrives within milliseconds of connecting, including
|
||
after a reboot; and it removes z2m's frontend from the failure chain entirely.
|
||
See the header comment in [`main/net/zigbee_client.c`](main/net/zigbee_client.c).
|
||
|
||
**The furnace is 2-wire, not 3-wire.** Confirmed as `R` and `W` only, thermostat
|
||
set to Gas/Oil Warm Air at 5 CPH. There is no third wire to repurpose as `C`,
|
||
so the board runs from its own 5V USB supply — do not attempt power stealing.
|
||
5 CPH is enforced in firmware as a minimum 12-minute period between heat-call
|
||
*starts*, which a minimum off-time alone does not achieve.
|
||
|
||
**Three pinout corrections.** Touch is CST826 (not CST816), the panel needs a
|
||
3-wire SPI bus on GPIO 16/2/1 that the PRD did not list, and the backlight is
|
||
on GPIO6. All verified against Elecrow's own source for this board and
|
||
cross-checked against a second independent source, per PRD 8.1. Details and
|
||
sources in [`docs/HARDWARE.md`](docs/HARDWARE.md).
|
||
|
||
---
|
||
|
||
## Layout
|
||
|
||
```
|
||
main/
|
||
├── main.c boot ordering — read the comment at the top
|
||
├── app_config.h.example credentials, broker, room table
|
||
├── drivers/
|
||
│ ├── i2c_bus.{c,h} shared bus + diagnostic scan
|
||
│ ├── expander/pcf8574 shadow-register I/O expander
|
||
│ ├── display/ ST7701S init sequence + RGB panel
|
||
│ ├── touch/cst826 capacitive touch
|
||
│ └── encoder/ PCNT quadrature + expander button
|
||
├── hvac/
|
||
│ ├── relay.{c,h} heat call + every interlock in PRD section 5
|
||
│ └── control.{c,h} setpoint, hysteresis, room selection, fallbacks
|
||
├── net/
|
||
│ ├── wifi_manager.{c,h} STA with backoff, non-blocking
|
||
│ └── zigbee_client.{c,h} MQTT subscriber, timestamped readings
|
||
├── ui/
|
||
│ ├── lvgl_port.{c,h} tick, task, recursive lock
|
||
│ └── ui_manager.{c,h} round-panel UI
|
||
└── storage/settings.{c,h} NVS, debounced writes
|
||
```
|
||
|
||
---
|
||
|
||
## Safety design
|
||
|
||
The interlocks live in `hvac/relay.c`, not in the control loop and not in UI
|
||
code. Callers express intent; the relay layer decides whether the contact
|
||
actually closes, and nothing above it is given the means to override a limit.
|
||
|
||
| Interlock | Value | Constant |
|
||
|---|---|---|
|
||
| Watchdog — contact drops if the control loop stops calling | 30 s | `RELAY_WATCHDOG_TIMEOUT_S` |
|
||
| Maximum on-time per call | 60 min | `RELAY_MAX_ON_TIME_S` |
|
||
| Minimum off-time | 5 min | `RELAY_MIN_OFF_TIME_S` |
|
||
| Minimum period between call starts (5 CPH) | 12 min | `RELAY_MIN_CYCLE_PERIOD_S` |
|
||
| Startup lockout | 60 s | `RELAY_STARTUP_LOCKOUT_S` |
|
||
|
||
Stale-data ladder, in `hvac/control.c`, when the governing room has not
|
||
reported in 20 minutes:
|
||
|
||
1. the selected room, if fresh
|
||
2. another room's fresh reading — coldest wins
|
||
3. *(a local sensor, if one were fitted — none is)*
|
||
4. the least-stale reading available, with the setpoint clamped to 16 °C
|
||
5. no reading at all since boot: open-loop freeze protection, 6 min on /
|
||
24 min off
|
||
|
||
Rungs 2–5 all raise a visible fault on screen. None of them can hold the relay
|
||
closed on its own; the interlocks above still apply.
|
||
|
||
### The honest limitation
|
||
|
||
A PCF8574 does not reset when the ESP32 does. If the firmware panics while the
|
||
contact is closed, the expander holds that latch until firmware comes back and
|
||
clears it. `app_main()` de-asserts the relay as its first real action, which
|
||
bounds the window to roughly **one second — not zero**. Closing that gap
|
||
properly needs external hardware (a retriggerable monostable in series with the
|
||
relay coil) which this board does not have.
|
||
|
||
Per PRD 5.1, for calibration rather than alarm: the furnace's high-limit and
|
||
rollout switches are independent of the thermostat and stay in circuit. A stuck
|
||
heat call means an overheated house and a large gas bill, with the furnace
|
||
cycling on its own limit — not a fire.
|
||
|
||
---
|
||
|
||
## Bench bring-up
|
||
|
||
Do these in order. Each step's failure mode is only unambiguous if the previous
|
||
one passed.
|
||
|
||
**1 — I2C.** Flash and watch the log. Expect:
|
||
|
||
```
|
||
I (xxx) i2c: 0x21 PCF8574 expander
|
||
I (xxx) i2c: 0x15 CST826 touch
|
||
```
|
||
|
||
If the bus looks empty, stop. Re-check SDA 38 / SCL 39 before touching
|
||
anything else — a wrong pinout produces exactly the same symptoms as broken
|
||
software, and PRD 8.1 exists because a sibling project lost most of a session
|
||
to that.
|
||
|
||
**2 — Panel.** Set `BRINGUP_TEST_PATTERN` to `1` in
|
||
[`main/main.c`](main/main.c) and reflash. Expect eight vertical colour bars.
|
||
|
||
If black, in this order:
|
||
1. `ST7701_SPI_MODE` in `drivers/display/display.c` — try 3
|
||
2. the init sequence in `drivers/display/st7701_init.h`
|
||
3. **re-verify the pinout before writing any new software hypothesis**
|
||
|
||
If red and blue are swapped, swap the R and B pin blocks in
|
||
`display.c`'s `data_gpio_nums`. If the image rolls or shears, try the ESPHome
|
||
timings noted in `docs/HARDWARE.md`.
|
||
|
||
**3 — UI.** Set `BRINGUP_TEST_PATTERN` back to `0`. Expect the setpoint arc,
|
||
a large temperature, room and mode. Watch for artefacts that persist rather
|
||
than flicker — those mean a framebuffer race, not a drawing bug.
|
||
|
||
**4 — Encoder and touch.** Turning the knob changes the setpoint. A short
|
||
press cycles focus setpoint → room → mode; focus reverts to setpoint after 8 s.
|
||
A long press returns to setpoint immediately.
|
||
|
||
**5 — Sensors.** Expect one line per room as retained messages arrive:
|
||
|
||
```
|
||
I (xxx) zigbee: TV Room: 20.5C, battery 87%
|
||
```
|
||
|
||
**6 — Relay, on the bench. Not on the furnace.**
|
||
|
||
Put an LED (with a resistor) or a spare relay module on PCF8574 P1 and exercise
|
||
every interlock before this goes anywhere near 24 VAC:
|
||
|
||
| Test | How | Expect |
|
||
|---|---|---|
|
||
| Startup lockout | Watch the first 60 s after boot | No call, even if cold |
|
||
| Normal call | Set the setpoint well above room temp | Call after the lockout |
|
||
| Hysteresis | Setpoint just above, then just below current temp | No chatter within ±0.5 °C |
|
||
| Max on-time | Setpoint at maximum, wait 60 min | Drops, `FAULT: max on-time exceeded` |
|
||
| Min off-time | After a call ends, demand heat again | `Waiting 5m` on screen |
|
||
| Cycle rate | Force several short calls | Never more than 5 starts/hour |
|
||
| **Watchdog** | Suspend the control task from a debugger, or add a temporary `vTaskDelay(60000)` in `control_task` | Contact drops within 30 s, `FAULT: control loop stopped` |
|
||
| Stale data | Stop the broker, wait 20 min | Falls to rung 4, setpoint clamps to 16 °C, fault on screen |
|
||
| No data | Boot with the broker down | Freeze-protection duty cycle, fault on screen |
|
||
| Power loss | Pull USB mid-call | Contact opens |
|
||
| Panic | Trigger a crash mid-call | Contact reopens within ~1 s of reboot |
|
||
|
||
**7 — Install.** Only after step 6 has been proven. The board needs its own 5V
|
||
USB supply; there is no `C` wire on this system.
|
||
|
||
```
|
||
PCF8574 P1 ──► relay module IN (active low)
|
||
|
||
R (24VAC) ──► relay COM
|
||
W (heat) ◄── relay NO closes to call for heat
|
||
```
|
||
|
||
Opto-isolated module, separate JD-VCC jumper, normally open. Photograph the
|
||
existing thermostat wiring before disconnecting anything.
|
||
|
||
**Never connect `R` to `C`** — dead short across the transformer, blows the
|
||
fuse on the furnace control board.
|