Implement rotary thermostat firmware from PRD

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>
This commit is contained in:
mattandClaude Opus 5 committed 2026-08-03 19:47:00 -07:00
1 parent 393592e327
commit d2e4c974dd
35 files changed
+4685

No files matched your search

+234
View File
@@ -0,0 +1,234 @@
# 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.