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

+139
View File
@@ -0,0 +1,139 @@
#include "wifi_manager.h"
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "app_config.h"
#include "esp_event.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_netif_sntp.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
static const char *TAG = "wifi";
#define CONNECTED_BIT BIT0
#define RETRY_MIN_MS 1000
#define RETRY_MAX_MS 30000
static EventGroupHandle_t s_events;
static volatile bool s_connected;
static int s_retry_ms = RETRY_MIN_MS;
static bool s_sntp_started;
static void schedule_reconnect(void)
{
ESP_LOGW(TAG, "reconnecting in %d ms", s_retry_ms);
vTaskDelay(pdMS_TO_TICKS(s_retry_ms));
s_retry_ms *= 2;
if (s_retry_ms > RETRY_MAX_MS) {
s_retry_ms = RETRY_MAX_MS;
}
esp_wifi_connect();
}
static void event_handler(void *arg, esp_event_base_t base,
int32_t id, void *data)
{
(void)arg;
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
s_connected = false;
xEventGroupClearBits(s_events, CONNECTED_BIT);
/*
* Runs on the event task, so the delay in schedule_reconnect() blocks
* only WiFi event processing — never the control loop, which lives on
* its own task and does not consult connection state anyway.
*/
schedule_reconnect();
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *e = data;
ESP_LOGI(TAG, "connected, ip " IPSTR, IP2STR(&e->ip_info.ip));
s_connected = true;
s_retry_ms = RETRY_MIN_MS;
xEventGroupSetBits(s_events, CONNECTED_BIT);
/* Start SNTP once we first have an address. Schedule mode needs a
* real clock, and control.c refuses to act on one that looks like
* 1970. */
if (!s_sntp_started) {
esp_sntp_config_t cfg =
ESP_NETIF_SNTP_DEFAULT_CONFIG(APP_SNTP_SERVER);
if (esp_netif_sntp_init(&cfg) == ESP_OK) {
s_sntp_started = true;
ESP_LOGI(TAG, "sntp started (%s)", APP_SNTP_SERVER);
}
}
}
}
esp_err_t wifi_manager_init(void)
{
if (s_events) {
return ESP_OK;
}
s_events = xEventGroupCreate();
if (!s_events) {
return ESP_ERR_NO_MEM;
}
setenv("TZ", APP_TIMEZONE, 1);
tzset();
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_sta();
wifi_init_config_t init_cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&init_cfg));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
WIFI_EVENT, ESP_EVENT_ANY_ID, event_handler, NULL, NULL));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
IP_EVENT, IP_EVENT_STA_GOT_IP, event_handler, NULL, NULL));
wifi_config_t wifi_cfg = {0};
strlcpy((char *)wifi_cfg.sta.ssid, APP_WIFI_SSID, sizeof(wifi_cfg.sta.ssid));
strlcpy((char *)wifi_cfg.sta.password, APP_WIFI_PASS,
sizeof(wifi_cfg.sta.password));
wifi_cfg.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_cfg));
/* CONFIG_ESP_WIFI_NVS_ENABLED=n, so nothing here touches flash. */
ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "started, joining \"%s\"", APP_WIFI_SSID);
return ESP_OK;
}
bool wifi_manager_is_connected(void)
{
return s_connected;
}
bool wifi_manager_wait_connected(int timeout_ms)
{
if (!s_events) {
return false;
}
EventBits_t bits = xEventGroupWaitBits(s_events, CONNECTED_BIT,
pdFALSE, pdFALSE,
pdMS_TO_TICKS(timeout_ms));
return (bits & CONNECTED_BIT) != 0;
}
+27
View File
@@ -0,0 +1,27 @@
/*
* wifi_manager — STA bring-up with automatic reconnect.
*
* Never blocks the control loop. wifi_manager_init() returns as soon as the
* driver is started; association happens in the background and reconnection
* is driven entirely by events. A house with no WiFi still heats — it just
* runs on the fallback ladder in control.c.
*
* Retries back off from 1s to 30s so an outage does not hammer the radio, and
* they never stop: the thermostat must recover unattended.
*/
#pragma once
#include <stdbool.h>
#include "esp_err.h"
esp_err_t wifi_manager_init(void);
bool wifi_manager_is_connected(void);
/*
* Block until associated or until timeout. Optional — nothing in the control
* path calls this. main() uses it only to give the MQTT client a head start
* before the display comes up.
*/
bool wifi_manager_wait_connected(int timeout_ms);
+356
View File
@@ -0,0 +1,356 @@
#include "zigbee_client.h"
#include <stdio.h>
#include <string.h>
#include <time.h>
#include "cJSON.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "mqtt_client.h"
static const char *TAG = "zigbee";
typedef struct {
const char *name;
const char *friendly;
} room_def_t;
#define ROOM_DEF_ENTRY(display, friendly_name) { display, friendly_name },
static const room_def_t s_rooms[] = {
APP_ROOM_TABLE(ROOM_DEF_ENTRY)
};
static room_reading_t s_readings[ROOM_COUNT];
static SemaphoreHandle_t s_lock;
static esp_mqtt_client_handle_t s_client;
static volatile bool s_connected;
/* ------------------------------------------------------------ parsing --- */
/*
* Match an incoming topic to a room.
*
* z2m publishes to <base>/<friendly_name>, and also to sub-topics such as
* <base>/<friendly_name>/availability. Only the exact device topic carries
* the state payload, so this compares the whole string rather than a prefix.
*/
static int room_for_topic(const char *topic, int topic_len)
{
char expected[160];
for (int i = 0; i < ROOM_COUNT; i++) {
int n = snprintf(expected, sizeof(expected), "%s/%s",
APP_MQTT_BASE_TOPIC, s_rooms[i].friendly);
if (n == topic_len && strncmp(topic, expected, (size_t)topic_len) == 0) {
return i;
}
}
return -1;
}
/*
* struct tm -> epoch seconds, interpreting the fields as UTC.
*
* timegm() is not in ESP-IDF's newlib, and mktime() is not a substitute: it
* would apply APP_TIMEZONE to a timestamp that z2m already emitted in UTC,
* shifting every reading's apparent age by the local offset. On a US Central
* install that is six hours, which would make live sensors look stale and trip
* the fallback ladder permanently.
*
* Days-from-civil (Howard Hinnant) avoids both problems and has no state.
*/
static int64_t tm_to_epoch_utc(const struct tm *t)
{
int y = t->tm_year + 1900;
unsigned m = (unsigned)t->tm_mon + 1;
unsigned d = (unsigned)t->tm_mday;
y -= (m <= 2);
const int era = (y >= 0 ? y : y - 399) / 400;
const unsigned yoe = (unsigned)(y - era * 400);
const unsigned doy = (153u * (m + (m > 2 ? -3u : 9u)) + 2u) / 5u + d - 1u;
const unsigned doe = yoe * 365u + yoe / 4u - yoe / 100u + doy;
const int64_t days = (int64_t)era * 146097 + (int64_t)doe - 719468;
return days * 86400 + t->tm_hour * 3600 + t->tm_min * 60 + t->tm_sec;
}
/*
* z2m reports last_seen either as an ISO-8601 string or as epoch milliseconds,
* depending on the `last_seen` advanced setting. Handle both, and treat
* anything unparseable as absent rather than as zero age.
*/
static bool parse_sensor_age(const cJSON *root, uint32_t *age_s)
{
const cJSON *ls = cJSON_GetObjectItemCaseSensitive(root, "last_seen");
if (!ls) {
return false;
}
time_t now = time(NULL);
/* Before SNTP has synced, wall-clock time is meaningless and any age we
* computed would be nonsense. Report absent. */
if (now < 1600000000) {
return false;
}
time_t seen = 0;
if (cJSON_IsNumber(ls)) {
seen = (time_t)(ls->valuedouble / 1000.0);
} else if (cJSON_IsString(ls) && ls->valuestring) {
struct tm tm = {0};
if (!strptime(ls->valuestring, "%Y-%m-%dT%H:%M:%S", &tm)) {
return false;
}
seen = (time_t)tm_to_epoch_utc(&tm);
} else {
return false;
}
if (seen <= 0 || seen > now) {
*age_s = 0;
return true;
}
*age_s = (uint32_t)(now - seen);
return true;
}
static void handle_payload(int room, const char *data, int len)
{
cJSON *root = cJSON_ParseWithLength(data, (size_t)len);
if (!root) {
ESP_LOGW(TAG, "%s: unparseable payload", s_rooms[room].name);
return;
}
const cJSON *temp = cJSON_GetObjectItemCaseSensitive(root, "temperature");
if (!cJSON_IsNumber(temp)) {
/*
* Not an error. z2m publishes on any attribute change, so a humidity
* or link-quality update legitimately arrives with no temperature.
* Leaving the previous reading untouched is the point — a partial
* message must not age or clear good data.
*/
cJSON_Delete(root);
return;
}
room_reading_t r = {
.valid = true,
.temperature_c = (float)temp->valuedouble,
.received_us = esp_timer_get_time(),
};
const cJSON *batt = cJSON_GetObjectItemCaseSensitive(root, "battery");
if (cJSON_IsNumber(batt)) {
r.has_battery = true;
r.battery_percent = (int)batt->valuedouble;
}
uint32_t age = 0;
if (parse_sensor_age(root, &age)) {
r.has_sensor_age = true;
r.sensor_age_at_receipt_s = age;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
/* Carry battery forward if this message did not include it. */
if (!r.has_battery && s_readings[room].has_battery) {
r.has_battery = true;
r.battery_percent = s_readings[room].battery_percent;
}
s_readings[room] = r;
xSemaphoreGive(s_lock);
if (r.has_battery) {
ESP_LOGI(TAG, "%s: %.1fC, battery %d%%",
s_rooms[room].name, r.temperature_c, r.battery_percent);
} else {
ESP_LOGI(TAG, "%s: %.1fC", s_rooms[room].name, r.temperature_c);
}
cJSON_Delete(root);
}
/* -------------------------------------------------------------- events -- */
static void mqtt_event_handler(void *args, esp_event_base_t base,
int32_t event_id, void *event_data)
{
(void)args;
(void)base;
esp_mqtt_event_handle_t event = event_data;
switch ((esp_mqtt_event_id_t)event_id) {
case MQTT_EVENT_CONNECTED: {
s_connected = true;
ESP_LOGI(TAG, "broker connected");
char topic[160];
for (int i = 0; i < ROOM_COUNT; i++) {
snprintf(topic, sizeof(topic), "%s/%s",
APP_MQTT_BASE_TOPIC, s_rooms[i].friendly);
/* QoS 0 is right here: readings are periodic and a missed one
* simply ages, which the control loop already handles. */
esp_mqtt_client_subscribe(s_client, topic, 0);
ESP_LOGI(TAG, "subscribed %s -> %s", topic, s_rooms[i].name);
}
break;
}
case MQTT_EVENT_DISCONNECTED:
s_connected = false;
/* Readings are deliberately left alone. They age; they are not
* cleared. PRD 4.1. */
ESP_LOGW(TAG, "broker disconnected — readings will age");
break;
case MQTT_EVENT_DATA: {
int room = room_for_topic(event->topic, event->topic_len);
if (room < 0) {
break;
}
/*
* Long payloads arrive in fragments. z2m sensor JSON is small enough
* that this should not happen, but a silently truncated parse would be
* a nasty way to find out otherwise.
*/
if (event->data_len != event->total_data_len) {
ESP_LOGW(TAG, "%s: fragmented payload (%d of %d), ignoring",
s_rooms[room].name, event->data_len, event->total_data_len);
break;
}
handle_payload(room, event->data, event->data_len);
break;
}
case MQTT_EVENT_ERROR:
ESP_LOGW(TAG, "mqtt error");
break;
default:
break;
}
}
/* ----------------------------------------------------------------- api -- */
esp_err_t zigbee_client_init(void)
{
if (s_client) {
return ESP_OK;
}
s_lock = xSemaphoreCreateMutex();
if (!s_lock) {
return ESP_ERR_NO_MEM;
}
esp_mqtt_client_config_t cfg = {
.broker.address.uri = APP_MQTT_URI,
.credentials = {
.username = (sizeof(APP_MQTT_USER) > 1) ? APP_MQTT_USER : NULL,
.authentication.password =
(sizeof(APP_MQTT_PASS) > 1) ? APP_MQTT_PASS : NULL,
},
.session.keepalive = 60,
.network.reconnect_timeout_ms = 10000,
.network.disable_auto_reconnect = false,
};
s_client = esp_mqtt_client_init(&cfg);
if (!s_client) {
ESP_LOGE(TAG, "client init failed");
return ESP_FAIL;
}
ESP_ERROR_CHECK(esp_mqtt_client_register_event(
s_client, ESP_EVENT_ANY_ID, mqtt_event_handler, NULL));
/*
* Start unconditionally, even with no WiFi yet. The client retries on its
* own and must never block the control loop — PRD section 6.
*/
esp_err_t err = esp_mqtt_client_start(s_client);
if (err != ESP_OK) {
ESP_LOGE(TAG, "client start failed: %s", esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "started, broker %s, %d room(s)", APP_MQTT_URI, ROOM_COUNT);
return ESP_OK;
}
int zigbee_client_room_count(void)
{
return ROOM_COUNT;
}
const char *zigbee_client_room_name(int idx)
{
if (idx < 0 || idx >= ROOM_COUNT) {
return "?";
}
return s_rooms[idx].name;
}
bool zigbee_client_get_reading(int idx, room_reading_t *out)
{
if (idx < 0 || idx >= ROOM_COUNT || !out) {
return false;
}
/* The UI and control loop both call this. If init failed we report "no
* reading" and let the fallback ladder handle it, rather than taking a
* NULL semaphore and crashing a device that controls heat. */
if (!s_lock) {
*out = (room_reading_t){0};
return false;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
*out = s_readings[idx];
xSemaphoreGive(s_lock);
return true;
}
uint32_t zigbee_client_reading_age_s(int idx)
{
room_reading_t r;
if (!zigbee_client_get_reading(idx, &r) || !r.valid) {
return UINT32_MAX;
}
uint32_t local = (uint32_t)((esp_timer_get_time() - r.received_us) / 1000000);
return r.has_sensor_age ? local + r.sensor_age_at_receipt_s : local;
}
bool zigbee_client_is_connected(void)
{
return s_connected;
}
int zigbee_client_lowest_battery(void)
{
int lowest = -1;
if (!s_lock) {
return -1;
}
xSemaphoreTake(s_lock, portMAX_DELAY);
for (int i = 0; i < ROOM_COUNT; i++) {
if (s_readings[i].valid && s_readings[i].has_battery) {
if (lowest < 0 || s_readings[i].battery_percent < lowest) {
lowest = s_readings[i].battery_percent;
}
}
}
xSemaphoreGive(s_lock);
return lowest;
}
+79
View File
@@ -0,0 +1,79 @@
/*
* zigbee_client — room temperatures from the Zigbee sensors.
*
* Named for its role rather than its transport. The PRD assumed HTTP polling
* of the Zigbee hub, but zigbee2mqtt has no REST endpoint that returns live
* sensor state — its frontend streams state over a WebSocket, and /api/devices
* returns definitions, not readings. So this subscribes to the MQTT broker
* that z2m publishes to instead.
*
* That turns out to be the better shape for this application anyway:
*
* - z2m publishes sensor topics *retained*, so every room's last known
* reading arrives within milliseconds of connecting, including after a
* reboot. An HTTP poller would have had the same property; a WebSocket
* client would not, and could have waited an hour for a battery sensor to
* report in.
* - It bypasses z2m's frontend entirely. Readings keep flowing if the
* frontend is down, and there is no HTTP shim to become a new single point
* of failure.
* - Updates are pushed, so readings are as fresh as the sensors allow rather
* than as fresh as a poll interval.
*
* A failed connection never clears previously good readings — it only lets
* them age, per PRD 4.1. Staleness is the control loop's business, not this
* module's.
*/
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "app_config.h"
#include "esp_err.h"
/* Room count, derived from APP_ROOM_TABLE in app_config.h. */
#define ZB_ROOM_COUNT_HELPER(name, topic) +1
#define ROOM_COUNT (0 APP_ROOM_TABLE(ZB_ROOM_COUNT_HELPER))
typedef struct {
bool valid; /* a reading has been received since boot */
float temperature_c;
int64_t received_us; /* local receipt time, esp_timer clock */
bool has_battery;
int battery_percent;
/*
* z2m's own last_seen, when the sensor reports it, as seconds before
* receipt. Sensor-side age is more honest than our receipt time for a
* retained message replayed on connect: a retained reading can be hours
* old and still arrive "just now".
*/
bool has_sensor_age;
uint32_t sensor_age_at_receipt_s;
} room_reading_t;
esp_err_t zigbee_client_init(void);
int zigbee_client_room_count(void);
const char *zigbee_client_room_name(int idx);
/* Copy out a room's latest reading. False if idx is out of range. */
bool zigbee_client_get_reading(int idx, room_reading_t *out);
/*
* Age of a room's reading in seconds, UINT32_MAX if never received.
*
* Includes the sensor-side age at receipt when z2m provided last_seen, so a
* retained message replayed on connect is correctly reported as old rather
* than as brand new.
*/
uint32_t zigbee_client_reading_age_s(int idx);
/* True while the MQTT session is up. Purely informational — the control loop
* reasons about reading age, not about connection state. */
bool zigbee_client_is_connected(void);
/* Lowest battery percentage across rooms that report one, or -1. */
int zigbee_client_lowest_battery(void);