Files
thermostat-hw/main/main.c
T
mattandClaude ea1b8b8d15 Two indoor sensors, wind compass, and stop framebuffer cache thrashing
Downstairs and upstairs are now separate readings. The station names them
backwards -- "temperature_4" is downstairs and "inside_temperature" is
upstairs -- so ha_config.h says which is which. Downstairs is the primary:
big number on the card, and it is what the furnace controls on, because it
is where people are and heat rises. Upstairs sits beside it, obviously
subordinate by size, and costs no vertical space in a layout that has none.

The anemometer came back on 2026-08-07, so wind gets its own column: a dial
whose needle points at the direction the wind is coming FROM (weather-vane
convention, matching the cardinal below it), the speed, and a 5-minute trend
arrow.

  - The needle does not animate. A spin would look better, but every animated
    frame is a redraw, and redrawing is what starves the bounce refill ISR.
  - Below 0.5 mph it shows "Calm" and draws no needle. A vane that is not
    being pushed reports whatever it last settled on, which on this station
    is a steady 0 degrees -- a confident needle pointing due north at zero
    wind. The live snapshot caught exactly that.
  - The trend is local, not HA's 10-minute average, so the window is ours to
    pick: 1-minute buckets over 5 minutes, threshold 1.5 mph.

That last point needed the trend engine to stop assuming one hour. Window,
bucket size, minimum age and threshold are now per-channel in a table, which
also removes the threshold arguments main.c was passing in.

Grid drops to 117px cells to make room, so the sun sub-line is just which
event it is and "RAIN TODAY" is "RAIN" -- both were overrunning.

On the artefacts: bounce mode is NOT a DMA read of the framebuffer. The
refill is a CPU memcpy in an ISR, which is why the driver skips the usual
write-back in draw_bitmap ("the data gets read by the CPU as well"). So the
panel streams 750KB per frame at ~44Hz through the DCache, evicting the
working set of everything else on the chip, continuously -- which is the real
reason this board is so sensitive to WiFi and to any extra rendering.
bb_invalidate_cache frees each chunk after it is copied. The header warns it
"can be dangerous if data is written from other core(s)"; that does not apply
here, because it invalidates the buffer being scanned and by design LVGL only
draws into the other one. Whether it is enough is for the panel to say.

Also gives tools/ha_snapshot.py a --token-file so the token no longer has to
travel through a shell command line to reach it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 19:58:41 -07:00

506 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
/* 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();
/*
* No full-screen invalidate here, deliberately.
*
* There used to be one, on the theory that repainting would clean up
* glitched pixels LVGL does not know are wrong. It made things
* materially worse, and the README already said why: the corruption
* scales with how much is written, because LVGL's writes into PSRAM
* compete with the panel's own DMA reads out of it. A full repaint is
* 768KB of exactly that contention -- the largest possible dose of
* the cause, applied as a cure.
*
* Redraw as little as possible is the actual lever. See ui_update*(),
* which now write nothing when nothing changed.
*/
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;
struct { int ch; const ha_value_t *v; } feeds[] = {
{ HA_TREND_CH_INDOOR_DOWN, &ha_data.indoor_down_temp_f },
{ HA_TREND_CH_INDOOR_UP, &ha_data.indoor_up_temp_f },
{ HA_TREND_CH_OUTDOOR, &ha_data.outdoor_temp_f },
{ HA_TREND_CH_PRESSURE, &ha_data.pressure_inhg },
{ HA_TREND_CH_WIND, &ha_data.wind_mph },
};
for (size_t i = 0; i < sizeof(feeds) / sizeof(feeds[0]); i++) {
if (feeds[i].v->valid) {
ha_trend_feed(feeds[i].ch, feeds[i].v->value, now_ms);
}
}
ha_data.trend_indoor_down = ha_trend_compute(HA_TREND_CH_INDOOR_DOWN, now_ms);
ha_data.trend_indoor_up = ha_trend_compute(HA_TREND_CH_INDOOR_UP, now_ms);
ha_data.trend_outdoor = ha_trend_compute(HA_TREND_CH_OUTDOOR, now_ms);
ha_data.trend_pressure = ha_trend_compute(HA_TREND_CH_PRESSURE, now_ms);
ha_data.trend_wind = ha_trend_compute(HA_TREND_CH_WIND, now_ms);
/*
* Downstairs drives the furnace -- it is where people are, and
* heat rises, so controlling on upstairs would under-heat the
* living space. Change this line and the big card in ui_manager
* together if that ever needs to swap.
*/
publish_indoor_temp(&ha_data.indoor_down_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");
}