The artefacts went from occasional to continuous, and the cause was my own
mitigation for them.
lv_obj_set_local_style_prop() does NOT skip identical values -- it calls
lv_obj_refresh_style() unconditionally, which invalidates the object. A
comment in ui_set_stale() claimed the opposite and that assumption spread.
ui_update_thermostat() is called once a second by the control task and was
writing seven style properties every time, so the panel was redrawing
something forever, and glitched lines were being regenerated as fast as
anything could clear them.
Two full-screen invalidates made it worse still: one after every HA poll,
one 600ms after every touch. The premise was that LVGL never reinvalidates
pixels it does not know are wrong, so repainting would clean them up. But
the README already had the answer -- the corruption scales with how much is
written, because LVGL's PSRAM writes compete with the panel's DMA reads out
of the same PSRAM. A full repaint is 768KB of exactly the contention that
causes the problem. Both are removed.
What is left is the thing that actually helps: write less.
- ui_update_thermostat() returns without taking the lock when nothing
moved, and otherwise touches only the widgets whose values changed.
- ui_set_stale() is guarded on the previous value.
- Condition and trend icons only get a colour write when the glyph itself
changed.
Also raises the bounce buffer from 16 to 20 scanlines. The comment saying
20 "fails to allocate once WiFi is up" was true when display_init() ran
after wifi_init_sta(); it does not any more. display_alloc() takes the
buffers from a virgin heap with ~200KB free, so the extra 12.8KB is
affordable, and each larger chunk buys the refill ISR more time to fetch the
next one before the LCD needs it. That attacks the mechanism rather than the
symptom. ~24KB internal heap remains once WiFi is up; the "Free internal
heap" log line is the thing to watch if it needs to come back down.
Both gotchas are now in the README so the next person does not reach for a
repaint again.
Co-Authored-By: Claude <noreply@anthropic.com>
508 lines
18 KiB
C
508 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();
|
|
|
|
/*
|
|
* 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;
|
|
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");
|
|
}
|