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

+156
View File
@@ -0,0 +1,156 @@
/*
* Rotary Thermostat — CrowPanel 2.1" HMI Rotary Display.
*
* Boot ordering here is not arbitrary. Reading top to bottom:
*
* 1. Relay off, first, before anything can fail. A PCF8574 does not reset
* when the ESP32 does, so if we got here from a panic while the contact
* was closed, the furnace is still running right now. Nothing else in
* this function matters as much as the next few milliseconds.
* 2. WiFi before the display (PRD 8.2). The radio's PSRAM allocations and
* the RGB panel's bandwidth demands do not mix well the other way round.
* 3. Display, then LVGL, then UI.
* 4. Control loop last, once everything it reports on exists.
*/
#include "control.h"
#include "cst826.h"
#include "display.h"
#include "encoder.h"
#include "esp_log.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "i2c_bus.h"
#include "lvgl_port.h"
#include "pcf8574.h"
#include "relay.h"
#include "settings.h"
#include "ui_manager.h"
#include "wifi_manager.h"
#include "zigbee_client.h"
static const char *TAG = "main";
/*
* Set to 1 to draw colour bars and stop before LVGL.
*
* This is the PRD phase-2 check. It proves the ST7701S init sequence, the RGB
* bus and the timings on their own, with no LVGL in the picture to blame.
*/
#define BRINGUP_TEST_PATTERN 0
void app_main(void)
{
ESP_LOGI(TAG, "Rotary Thermostat starting");
/* ---- 1. Make the furnace safe ------------------------------------- */
ESP_ERROR_CHECK(i2c_bus_init());
/*
* The scan is the cheap sanity check from PRD 8.1: finding the PCF8574 at
* 0x21 and the CST826 at 0x15 proves the board is alive and the I2C pins
* are right, independently of the display.
*/
i2c_bus_scan();
if (pcf8574_init() != ESP_OK) {
/*
* Without the expander there is no way to guarantee the relay is open,
* and no way to open it if it is closed. Continuing would mean running
* a heating controller that cannot turn the heat off. Reboot and try
* again rather than pretend.
*/
ESP_LOGE(TAG, "expander init failed — cannot control the relay, "
"restarting in 5s");
vTaskDelay(pdMS_TO_TICKS(5000));
esp_restart();
}
/* pcf8574_init() already pushed the all-high shadow, which de-asserts the
* relay. This is belt and braces, and it is cheap. */
relay_emergency_off();
ESP_LOGI(TAG, "relay de-asserted");
/* ---- 2. Settings and network -------------------------------------- */
settings_init();
/* Non-blocking: returns as soon as the driver is started. */
wifi_manager_init();
/*
* Give the association a few seconds before bringing the panel up. Not
* required for correctness — everything downstream tolerates no network —
* but PRD 8.2 wants WiFi up first, and it means the first frame drawn
* usually already has real readings on it.
*/
if (wifi_manager_wait_connected(6000)) {
ESP_LOGI(TAG, "network up before display init");
} else {
ESP_LOGW(TAG, "no network yet — continuing, it will connect in the "
"background");
}
zigbee_client_init();
/* ---- 3. Display ---------------------------------------------------- */
if (display_init() != ESP_OK) {
/*
* A blind thermostat is still a thermostat. It has a setpoint from
* NVS, sensors over MQTT and a relay; losing the screen should not
* lose the heat.
*/
ESP_LOGE(TAG, "display init failed — continuing headless");
} else {
#if BRINGUP_TEST_PATTERN
display_test_pattern();
ESP_LOGW(TAG, "BRINGUP_TEST_PATTERN is set — stopping here");
while (1) {
vTaskDelay(pdMS_TO_TICKS(1000));
}
#endif
cst826_init();
if (lvgl_port_init() == ESP_OK) {
ui_manager_init();
} else {
ESP_LOGE(TAG, "LVGL port failed — continuing headless");
}
}
if (encoder_init() != ESP_OK) {
ESP_LOGE(TAG, "encoder init failed — setpoint fixed at stored value");
}
/* ---- 4. Arm the heat ----------------------------------------------- */
ESP_ERROR_CHECK(relay_init());
ESP_ERROR_CHECK(control_init());
ESP_LOGI(TAG, "running");
/*
* Everything from here is task-driven. This loop only reports, at a
* cadence slow enough to be readable in a terminal left open on the bench.
*/
while (1) {
vTaskDelay(pdMS_TO_TICKS(60000));
control_state_t st;
relay_status_t rs;
control_get_state(&st);
relay_status(&rs);
ESP_LOGI(TAG, "temp %.1fC set %.1fC | %s | heat %s | %s | wifi %s mqtt %s",
st.governing_temp_c, st.effective_setpoint_c,
(st.governing_room >= 0)
? zigbee_client_room_name(st.governing_room) : "none",
rs.heat_on ? "ON" : "off",
control_source_str(st.source),
wifi_manager_is_connected() ? "up" : "down",
zigbee_client_is_connected() ? "up" : "down");
}
}