#include #include #include #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"); }