#include #include #include #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/queue.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 "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 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; /* * 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(); poll_count++; if (err == ESP_OK || on_fallback) { last_success = xTaskGetTickCount(); consecutive_failures = 0; have_reading = true; 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"); } } } } void app_main(void) { ESP_LOGI(TAG, "Thermostat HMI -- CrowPanel 7.0\" ESP32-S3 (800x480)"); /* * WiFi comes up FIRST, before the RGB panel exists. * * The panel's bounce-buffer ISR reads the framebuffer out of PSRAM, and * PSRAM shares the cache with flash on the ESP32-S3. Bringing up NVS and * the WiFi PHY writes calibration data to flash, which disables the cache * and faults that ISR. Doing it here means every flash write is finished * before the panel starts scanning. */ 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); ESP_ERROR_CHECK(display_init()); 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)); 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"); }