Files
thermostat-hw/main/main.c
T
mattandClaude 3aabcac90a Add thermostat control for heat and fan with cycle protection
Turns the weather dashboard into an actual thermostat. Relays are not
fitted yet -- the GPIOs are driven and can be metered at the UART1-OUT
plug (idle 3.3V, energized 0V, active-low module assumed).

Control loop (main/control/thermostat.c) is pure logic: no ESP-IDF, no
network, no clock of its own. The caller passes a temperature and a
millisecond timestamp, which is what lets the simulator and the test
harness run the identical decision code.

Equipment protections, all verified by tools/thermostat_test.c:

  deadband      1.0F    stops the burner chattering at the setpoint
  minimum on    3 min   a shorter call is all ignition wear, no heat
  minimum off   5 min   back-to-back ignitions are what kill igniters
  cycles/hour   5       standard gas default; catches a flapping sensor
  boot lockout  60 s    a boot loop must not machine-gun the gas valve

That test drives the state machine with synthetic time, so two hours of
furnace behaviour runs in milliseconds. It exits non-zero on failure and
is worth re-running after any edit to thermostat.c -- this switches a gas
valve.

Two deliberate decisions:

  - G is never energized during a heat call. The furnace runs its own
    blower with a fan-on delay that waits for the heat exchanger and a
    fan-off delay that scavenges residual heat; driving G ourselves would
    blow cold air at people and throw that heat away. G is manual
    circulation only.
  - The control loop starts before every early return in app_main. Those
    returns are all "no WiFi" or "no HA token", and none of them is a
    reason to leave a house unheated.

Relay pins are IO20 heat / IO19 fan on the UART1-OUT plug, measured on
this board since Elecrow documents the GPIO numbers but not the connector
pin order. Heat is on IO20 because it is USB D+, the line carrying the
hardware pull-up -- with an active-low module it fails toward "no heat"
rather than "heat stuck on" if the USB PHY is ever re-enabled. Both pins
were confirmed free of the onboard mic before use.
CONFIG_USJ_ENABLE_USB_SERIAL_JTAG and CONFIG_ESP_PHY_ENABLE_USB are now
off to release them; the console is on UART0 via the CH340 regardless.

UI: the indoor card gains a status dot, mode and fan buttons, and a
setpoint column with finger-sized up/down targets. The reading grid drops
from six cells to four to make room -- wind was the one to cut, since the
station anemometer has been dead since 2026-07-27 and that cell has never
shown a number. Wind now rides on the outdoor cell's sub-line and will
reappear on its own if the sensor is fixed. Sunrise and sunset share a
cell, showing whichever comes next.

Known weakness, documented in README and top of TODO: indoor temperature
comes from Home Assistant over the network, so an HA outage ages the
reading until the 15-minute failsafe forces heat off. A local I2C sensor
removes the failure mode entirely and is the next thing to add. The
setpoint also resets to 68F on reboot, because persisting it means an NVS
write and a flash write while the panel scans faults the bounce-buffer
ISR.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 20:09:36 -07:00

509 lines
18 KiB
C

#include <math.h>
#include <stdio.h>
#include <string.h>
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "ambient_weather.h"
#include "app_config.h"
#include "backlight.h"
#include "display_rgb.h"
#include "ha_client.h"
#include "ha_data.h"
#include "i2c_bus.h"
#include "lvgl_port.h"
#include "relay.h"
#include "thermostat.h"
#include "touch_gt911.h"
#include "ui_manager.h"
#include "wifi_manager.h"
static const char *TAG = "main";
/*
* 6144 was measured with the old cloud client, whose TLS handshake was the
* deepest this task ever got. The HA path is plain HTTP and shallower, but
* ha_data_t is ~600 bytes and the JSON bodies are on the stack, so the
* headroom is kept. Re-check the high-water-mark log if more work lands here.
*/
#define HA_TASK_STACK 6144
#define HA_TASK_PRIO 3
#define HA_FIRST_DELAY_MS 3000
/* The forecast changes hourly at most and costs a service call, so it is
* polled far less often than the sensors. */
#define FORECAST_EVERY_N_POLLS 30
/* Consecutive failed polls before the displayed reading is treated as stale. */
#define STALE_AFTER_POLLS 2
/*
* Trend arrow thresholds, per hour. Below these a reading renders as flat:
* a thermostat's indoor temp wobbles a couple of tenths continuously, and an
* arrow that flickers on noise is worse than none.
*/
#define TREND_TEMP_PER_HOUR_F 0.5f
#define TREND_PRESS_PER_HOUR_INHG 0.02f
/* Depth 8: a burst of taps while the network is slow should queue rather than
* be dropped, but a user cannot meaningfully get more than a few ahead. */
#define CMD_QUEUE_DEPTH 8
/* The control loop runs at 1Hz -- fast enough for a live lockout countdown,
* and far faster than a house changes temperature. */
#define THERM_TASK_STACK 4096
#define THERM_TASK_PRIO 4
#define THERM_TICK_MS 1000
typedef struct {
int index;
bool on;
int16_t brightness;
} light_cmd_t;
static QueueHandle_t cmd_queue;
/* The single copy of what is on screen. Only the HA task touches it. */
static ha_data_t ha_data;
/*
* Indoor temperature handed from the HA task to the control task.
*
* Guarded rather than assumed-atomic: a float store is a single word on this
* core, but the value and its timestamp must agree with each other, and the
* staleness failsafe depends on that pairing being consistent.
*/
static SemaphoreHandle_t temp_mutex;
static float indoor_temp_f;
static bool indoor_temp_valid;
static uint32_t indoor_temp_ms;
static uint32_t uptime_ms(void)
{
return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
}
static void publish_indoor_temp(const ha_value_t *v)
{
if (temp_mutex == NULL || xSemaphoreTake(temp_mutex, pdMS_TO_TICKS(100)) != pdTRUE) {
return;
}
if (v->valid) {
indoor_temp_f = v->value;
indoor_temp_valid = true;
indoor_temp_ms = uptime_ms();
}
xSemaphoreGive(temp_mutex);
}
static bool take_indoor_temp(float *out_f)
{
if (temp_mutex == NULL || xSemaphoreTake(temp_mutex, pdMS_TO_TICKS(100)) != pdTRUE) {
return false;
}
const bool valid = indoor_temp_valid;
*out_f = indoor_temp_f;
xSemaphoreGive(temp_mutex);
return valid;
}
/*
* The control loop. Deliberately independent of Home Assistant: it needs only
* a temperature and a clock, so an HA outage cannot stop the house being
* heated -- the reading simply ages, and thermostat_tick() applies its own
* staleness failsafe once it is too old to trust.
*/
static void thermostat_task(void *arg)
{
therm_status_t status;
while (1) {
float temp = 0.0f;
const bool valid = take_indoor_temp(&temp);
thermostat_tick(temp, valid, uptime_ms());
thermostat_get_status(&status);
relay_set(RELAY_HEAT, status.heat_relay);
relay_set(RELAY_FAN, status.fan_relay);
ui_update_thermostat(&status);
vTaskDelay(pdMS_TO_TICKS(THERM_TICK_MS));
}
}
/*
* Runs on the LVGL task with the lock held. No network here, so the change
* applies immediately and the display is refreshed straight back -- the
* button feels instant because it is.
*/
static void therm_cmd_from_ui(ui_therm_cmd_t cmd)
{
switch (cmd) {
case UI_THERM_SETPOINT_UP:
thermostat_adjust_setpoint(1.0f);
break;
case UI_THERM_SETPOINT_DOWN:
thermostat_adjust_setpoint(-1.0f);
break;
case UI_THERM_MODE_TOGGLE:
thermostat_set_mode(thermostat_get_mode() == THERM_MODE_HEAT
? THERM_MODE_OFF : THERM_MODE_HEAT);
break;
case UI_THERM_FAN_TOGGLE:
thermostat_set_fan(thermostat_get_fan() == THERM_FAN_ON
? THERM_FAN_AUTO : THERM_FAN_ON);
break;
}
/*
* Apply the fan immediately rather than waiting up to a second for the
* control task -- a button that lags its own relay feels broken. Heat is
* left to the control task, which is where the protection timers live;
* nothing here may light a burner.
*/
therm_status_t status;
thermostat_get_status(&status);
relay_set(RELAY_FAN, status.fan_relay);
ui_update_thermostat(&status);
}
/*
* Runs on the LVGL task with the LVGL lock held, so it must not block.
* Queueing hands the HTTP request to the HA task; a request from here would
* freeze rendering for the duration and deadlock anything waiting on the lock.
*/
static void light_cmd_from_ui(int index, bool on, int16_t brightness)
{
const light_cmd_t cmd = { .index = index, .on = on, .brightness = brightness };
if (cmd_queue == NULL || xQueueSend(cmd_queue, &cmd, 0) != pdTRUE) {
ESP_LOGW(TAG, "Light command queue full, dropping command for index %d", index);
}
}
/*
* Fall back to the Ambient Weather cloud API when HA is unreachable.
*
* Only fills the outdoor readings -- indoor, sun, lights and forecast have no
* cloud equivalent and stay as they were. That is deliberate: a partial screen
* of live outdoor data beats a blank one, and the status line says the panel
* is running on the fallback.
*/
static bool fill_from_cloud(ha_data_t *data)
{
weather_data_t w;
if (weather_update(&w) != ESP_OK) {
return false;
}
data->outdoor_temp_f = (ha_value_t){ w.temp_f, w.has_temp };
data->outdoor_humidity = (ha_value_t){ w.humidity, w.has_humidity };
data->wind_mph = (ha_value_t){ w.wind_mph, w.has_wind };
return w.has_temp;
}
static void ha_task(void *arg)
{
char line[96];
/*
* Age is tracked in ticks, not wall time: with no SNTP the system clock
* starts at the epoch. (The clock shown on screen comes from HA, which is
* why the panel can display the time without knowing it.) Tick
* subtraction is also wrap-safe.
*/
TickType_t last_success = 0;
int consecutive_failures = 0;
bool have_reading = false;
bool stack_logged = false;
uint32_t poll_count = 0;
vTaskDelay(pdMS_TO_TICKS(HA_FIRST_DELAY_MS));
/*
* Zero on the first pass so the dashboard populates immediately after
* boot. The wait below is what paces the loop, and it sits at the top --
* so a full-interval timeout here would leave the panel showing dashes
* for HA_UPDATE_INTERVAL_MS before the first poll ever ran. Measured on
* hardware as a 63-second blank screen.
*/
TickType_t wait_ticks = 0;
while (1) {
/*
* Wait for a light command, or time out and poll. A command wakes the
* task immediately so a tap feels instant instead of waiting out the
* poll interval.
*/
light_cmd_t cmd;
const bool have_cmd = (cmd_queue != NULL) &&
(xQueueReceive(cmd_queue, &cmd, wait_ticks) == pdTRUE);
wait_ticks = pdMS_TO_TICKS(HA_UPDATE_INTERVAL_MS);
if (!wifi_is_connected()) {
ui_set_status("WiFi: reconnecting...");
continue;
}
/*
* Freeze rendering for the duration of the network work. The redraw
* and the flush into the PSRAM framebuffer compete with the LCD's own
* DMA out of that same PSRAM; letting them overlap is what produces
* visible artefacts. The panel keeps scanning the last completed
* frame, so the screen simply holds still.
*/
lvgl_port_pause();
if (have_cmd) {
const esp_err_t cmd_err = ha_light_command(cmd.index, cmd.on, cmd.brightness);
if (cmd_err != ESP_OK) {
ESP_LOGW(TAG, "Light command failed: %s", esp_err_to_name(cmd_err));
}
}
esp_err_t err = ha_fetch_state(&ha_data);
/* The forecast is a second request, so only fetch it when the first
* one proved HA is reachable. */
if (err == ESP_OK &&
(ha_data.forecast_count == 0 || (poll_count % FORECAST_EVERY_N_POLLS) == 0)) {
if (ha_fetch_forecast(&ha_data) != ESP_OK) {
ESP_LOGW(TAG, "Forecast fetch failed; keeping previous days");
}
}
bool on_fallback = false;
if (err != ESP_OK) {
on_fallback = fill_from_cloud(&ha_data);
}
lvgl_port_resume();
/*
* Repaint everything after every fetch window.
*
* The known left-edge artefacts (see README) happen when WiFi
* interrupt load starves the bounce-refill ISR, and once bad pixels
* are on screen LVGL has no idea they are wrong -- it never
* reinvalidates them, so they sit there indefinitely. A full
* invalidate after each poll bounds their lifetime at one poll
* interval. Mitigation, not cure: it cannot prevent the glitch,
* only clean up after it.
*/
if (lvgl_port_lock(100)) {
lv_obj_invalidate(lv_screen_active());
lvgl_port_unlock();
}
poll_count++;
if (err == ESP_OK || on_fallback) {
last_success = xTaskGetTickCount();
consecutive_failures = 0;
have_reading = true;
/*
* Trend history is local: the device sees every reading anyway,
* so an /api/history query would be hundreds of JSON points to
* answer a question one ring buffer answers for free. History is
* lost on reboot; arrows reappear ~5 minutes later.
*/
const uint32_t now_ms = xTaskGetTickCount() * portTICK_PERIOD_MS;
if (ha_data.indoor_temp_f.valid) {
ha_trend_feed(HA_TREND_CH_INDOOR, ha_data.indoor_temp_f.value, now_ms);
}
if (ha_data.outdoor_temp_f.valid) {
ha_trend_feed(HA_TREND_CH_OUTDOOR, ha_data.outdoor_temp_f.value, now_ms);
}
if (ha_data.pressure_inhg.valid) {
ha_trend_feed(HA_TREND_CH_PRESSURE, ha_data.pressure_inhg.value, now_ms);
}
ha_data.trend_indoor =
ha_trend_compute(HA_TREND_CH_INDOOR, TREND_TEMP_PER_HOUR_F, now_ms);
ha_data.trend_outdoor =
ha_trend_compute(HA_TREND_CH_OUTDOOR, TREND_TEMP_PER_HOUR_F, now_ms);
ha_data.trend_pressure =
ha_trend_compute(HA_TREND_CH_PRESSURE, TREND_PRESS_PER_HOUR_INHG, now_ms);
/* Hand the reading to the control loop, which runs on its own
* task and does not care where it came from. */
publish_indoor_temp(&ha_data.indoor_temp_f);
ui_update(&ha_data);
ui_set_stale(false);
if (on_fallback) {
ui_set_status("Home Assistant unreachable - cloud weather only");
} else {
wifi_get_status(line, sizeof(line));
ui_set_status(line);
}
if (!stack_logged) {
stack_logged = true;
ESP_LOGI(TAG, "HA task stack headroom: %u bytes",
(unsigned)(uxTaskGetStackHighWaterMark(NULL) * sizeof(StackType_t)));
}
} else {
consecutive_failures++;
ESP_LOGW(TAG, "Update failed (%d consecutive)", consecutive_failures);
if (have_reading && consecutive_failures >= STALE_AFTER_POLLS) {
const uint32_t age_min =
((xTaskGetTickCount() - last_success) * portTICK_PERIOD_MS) / 60000U;
ui_set_stale(true);
snprintf(line, sizeof(line), "No update for %lum", (unsigned long)age_min);
ui_set_status(line);
} else {
ui_set_status("Update failed");
}
}
}
}
/*
* The furnace must run whether or not anything else does.
*
* Every early return further down app_main() is about WiFi or Home Assistant
* being unusable -- no SSID, no token, no cloud fallback. None of those are
* reasons to leave a house unheated, so the control loop starts before any of
* them can bail out. It needs only a temperature and a clock; where the
* temperature comes from is somebody else's problem.
*/
static void start_thermostat(void)
{
temp_mutex = xSemaphoreCreateMutex();
if (temp_mutex == NULL) {
ESP_LOGE(TAG, "Could not create the temperature mutex");
return;
}
ui_set_therm_cb(therm_cmd_from_ui);
xTaskCreate(thermostat_task, "therm", THERM_TASK_STACK, NULL,
THERM_TASK_PRIO, NULL);
ESP_LOGI(TAG, "Thermostat running: setpoint %.0f F, %d min on / %d min off, "
"max %d cycles/hr",
thermostat_get_setpoint(),
(int)(THERM_MIN_ON_MS / 60000), (int)(THERM_MIN_OFF_MS / 60000),
THERM_MAX_CYCLES_PER_HOUR);
}
void app_main(void)
{
ESP_LOGI(TAG, "Thermostat HMI -- CrowPanel 7.0\" ESP32-S3 (800x480)");
/*
* Panel BUFFERS first, WiFi second, panel SCAN-OUT third. Both orderings
* are load-bearing:
*
* - display_alloc() before WiFi: the bounce buffers need two contiguous
* 25.6KB internal DMA blocks, and WiFi's RX buffer churn fragments
* the internal heap within seconds of association. Allocating from
* the virgin heap is deterministic; observed otherwise as a
* boot-loop with 84KB free and no block big enough.
*
* - display_start() after WiFi: from the moment scan-out begins, the
* bounce-refill ISR reads PSRAM continuously, and PSRAM shares the
* cache with flash on the S3. NVS and WiFi PHY calibration write
* flash; doing all of that before the first scan means the ISR can
* never hit "Cache disabled but cached memory region accessed".
*/
ESP_ERROR_CHECK(display_alloc());
/*
* Relays before anything that can fail or take time. relay_init() only
* touches two GPIOs, and until it runs those pads are whatever reset left
* them -- so the sooner they are pinned to the de-energized level, the
* shorter the window in which a relay board could see an undefined input.
*/
ESP_ERROR_CHECK(relay_init());
thermostat_init(uptime_ms());
const esp_err_t wifi_err = wifi_init_sta();
if (wifi_err != ESP_OK) {
ESP_LOGE(TAG, "WiFi not up yet -- polling starts when it connects");
}
/* LVGL core must exist before the display and touch drivers register
* themselves with it. */
ESP_ERROR_CHECK(lvgl_port_init());
/* Shared bus: STC8H1K28 board controller and GT911 both live on it. */
ESP_ERROR_CHECK(i2c_bus_init());
/* Must precede touch_init(): the STC8H1K28 holds the GT911 in reset
* until its init sequence runs. */
if (backlight_init() != ESP_OK) {
ESP_LOGE(TAG, "STC8H1K28 init failed -- backlight and touch will not work");
}
i2c_bus_scan(TAG);
/* Buffers were allocated before WiFi; this only starts scan-out. */
ESP_ERROR_CHECK(display_start());
if (touch_init() != ESP_OK) {
/* A dead touch panel should not stop the display from working -- but
* it does mean the light tiles are unusable. */
ESP_LOGW(TAG, "Touch unavailable -- continuing without input");
}
ui_init();
ESP_ERROR_CHECK(lvgl_port_start());
/* First frame is rendered by now, so it is safe to light the panel. */
vTaskDelay(pdMS_TO_TICKS(100));
ESP_ERROR_CHECK(backlight_set(255));
/* Before the WiFi/HA branches below, any of which may return. */
start_thermostat();
if (wifi_err == ESP_OK) {
char status[96];
wifi_get_status(status, sizeof(status));
ui_set_status(status);
} else if (wifi_err == ESP_FAIL || wifi_err == ESP_ERR_TIMEOUT) {
/* The event handler keeps retrying in the background; the HA task
* polls wifi_is_connected(), so start it anyway rather than
* permanently disabling updates because boot raced a slow AP. */
ui_set_status("WiFi: connecting...");
} else {
/* No SSID configured (or setup failed outright) -- nothing will ever
* connect, so there is no point starting the task. */
ui_set_status("WiFi: not configured");
return;
}
if (ha_client_init() != ESP_OK) {
ui_set_status("Home Assistant: not configured");
return;
}
/* Optional: only used when HA cannot be reached. A missing cloud API key
* is not fatal, it just means there is no fallback. */
if (weather_init() != ESP_OK) {
ESP_LOGW(TAG, "Ambient Weather fallback unavailable (no credentials)");
}
cmd_queue = xQueueCreate(CMD_QUEUE_DEPTH, sizeof(light_cmd_t));
if (cmd_queue == NULL) {
ESP_LOGE(TAG, "Could not create the light command queue");
return;
}
ui_set_light_cb(light_cmd_from_ui);
xTaskCreate(ha_task, "ha", HA_TASK_STACK, NULL, HA_TASK_PRIO, NULL);
ESP_LOGI(TAG, "System ready");
}