Files
thermostat-hw/main/main.c
T

351 lines
13 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/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
/*
* 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
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();
/*
* 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);
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)");
/*
* 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());
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));
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");
}