Files
mattandClaude Opus 5 d2e4c974dd 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>
2026-08-03 19:47:00 -07:00

9.3 KiB
Raw Permalink Blame History

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 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:

cp main/app_config.h.example main/app_config.h

Fill in main/app_config.h (WiFi, broker, sensor friendly_names), then:

. ~/esp/esp-idf/export.sh && idf.py build

Flash and watch the log:

. ~/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:

    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.

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.


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 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.