New panel design
This commit is contained in:
1 parent
face2432e4
commit
b5d6b4a897
30 files changed
+4540
-171
No files matched your search
+160
-56
@@ -4,12 +4,15 @@
|
||||
|
||||
#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"
|
||||
@@ -19,99 +22,187 @@
|
||||
static const char *TAG = "main";
|
||||
|
||||
/*
|
||||
* 6144 bytes. Measured headroom after a fetch including the TLS handshake --
|
||||
* the deepest this task gets -- is ~2400 bytes, so roughly 3.7KB is in use.
|
||||
* mbedTLS keeps its heap in PSRAM (CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC), which
|
||||
* is why the handshake fits at all. Re-check the high-water-mark log if more
|
||||
* work lands in this task.
|
||||
* 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 WEATHER_TASK_STACK 6144
|
||||
#define WEATHER_TASK_PRIO 3
|
||||
#define WEATHER_FIRST_DELAY_MS 3000
|
||||
#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 WEATHER_STALE_AFTER_POLLS 2
|
||||
#define STALE_AFTER_POLLS 2
|
||||
|
||||
static void weather_update_task(void *arg)
|
||||
/* 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)
|
||||
{
|
||||
weather_data_t weather;
|
||||
char line[96];
|
||||
|
||||
/*
|
||||
* Age is tracked in ticks, not from weather_data_t.last_update. That field
|
||||
* comes from time(NULL), and with no SNTP client the system clock starts
|
||||
* at the epoch -- an age derived from it would be nonsense. Tick
|
||||
* 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(WEATHER_FIRST_DELAY_MS));
|
||||
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...");
|
||||
vTaskDelay(pdMS_TO_TICKS(WEATHER_UPDATE_INTERVAL_MS));
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
* Freeze rendering for the duration of the fetch. The TLS session,
|
||||
* the LVGL redraw and the flush into the PSRAM framebuffer all
|
||||
* 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.
|
||||
* 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();
|
||||
const esp_err_t err = weather_update(&weather);
|
||||
|
||||
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();
|
||||
|
||||
if (err == ESP_OK) {
|
||||
poll_count++;
|
||||
|
||||
if (err == ESP_OK || on_fallback) {
|
||||
last_success = xTaskGetTickCount();
|
||||
consecutive_failures = 0;
|
||||
have_reading = true;
|
||||
|
||||
weather_get_string(&weather, line, sizeof(line));
|
||||
ESP_LOGI(TAG, "Weather: %s", line);
|
||||
ui_update(&ha_data);
|
||||
ui_set_stale(false);
|
||||
|
||||
char detail[64];
|
||||
snprintf(detail, sizeof(detail), "%.0f%% humidity",
|
||||
weather.has_humidity ? weather.humidity : 0.0f);
|
||||
if (on_fallback) {
|
||||
ui_set_status("Home Assistant unreachable - cloud weather only");
|
||||
} else {
|
||||
wifi_get_status(line, sizeof(line));
|
||||
ui_set_status(line);
|
||||
}
|
||||
|
||||
/* lroundf, not +0.5f-and-truncate: the latter rounds toward zero
|
||||
* below 0°F, turning -10.6 into -10. */
|
||||
ui_update_weather((int16_t)lroundf(weather.temp_f),
|
||||
weather.has_humidity ? detail : "Outdoor");
|
||||
ui_set_weather_stale(false);
|
||||
|
||||
wifi_get_status(line, sizeof(line));
|
||||
ui_set_status(line);
|
||||
|
||||
/* One-shot, after a fetch that included a TLS handshake -- that is
|
||||
* the deepest this task's stack ever gets. */
|
||||
if (!stack_logged) {
|
||||
stack_logged = true;
|
||||
ESP_LOGI(TAG, "weather task stack headroom: %u bytes",
|
||||
ESP_LOGI(TAG, "HA task stack headroom: %u bytes",
|
||||
(unsigned)(uxTaskGetStackHighWaterMark(NULL) * sizeof(StackType_t)));
|
||||
}
|
||||
} else {
|
||||
consecutive_failures++;
|
||||
ESP_LOGW(TAG, "Weather update failed (%d consecutive)", consecutive_failures);
|
||||
ESP_LOGW(TAG, "Update failed (%d consecutive)", consecutive_failures);
|
||||
|
||||
if (have_reading && consecutive_failures >= WEATHER_STALE_AFTER_POLLS) {
|
||||
if (have_reading && consecutive_failures >= STALE_AFTER_POLLS) {
|
||||
const uint32_t age_min =
|
||||
((xTaskGetTickCount() - last_success) * portTICK_PERIOD_MS) / 60000U;
|
||||
ui_set_weather_stale(true);
|
||||
ui_set_stale(true);
|
||||
snprintf(line, sizeof(line), "No update for %lum", (unsigned long)age_min);
|
||||
ui_set_status(line);
|
||||
} else {
|
||||
ui_set_status("Weather update failed");
|
||||
ui_set_status("Update failed");
|
||||
}
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(WEATHER_UPDATE_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +221,7 @@ void app_main(void)
|
||||
*/
|
||||
const esp_err_t wifi_err = wifi_init_sta();
|
||||
if (wifi_err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "WiFi not up yet -- weather starts when it connects");
|
||||
ESP_LOGE(TAG, "WiFi not up yet -- polling starts when it connects");
|
||||
}
|
||||
|
||||
/* LVGL core must exist before the display and touch drivers register
|
||||
@@ -150,7 +241,8 @@ void app_main(void)
|
||||
ESP_ERROR_CHECK(display_init());
|
||||
|
||||
if (touch_init() != ESP_OK) {
|
||||
/* A dead touch panel should not stop the display from working. */
|
||||
/* 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");
|
||||
}
|
||||
|
||||
@@ -166,24 +258,36 @@ void app_main(void)
|
||||
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 weather
|
||||
* task polls wifi_is_connected(), so start it anyway rather than
|
||||
* permanently disabling weather because boot raced a slow AP. */
|
||||
/* 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 weather task. */
|
||||
* connect, so there is no point starting the task. */
|
||||
ui_set_status("WiFi: not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
if (weather_init() != ESP_OK) {
|
||||
ui_set_status("Weather: no API credentials");
|
||||
if (ha_client_init() != ESP_OK) {
|
||||
ui_set_status("Home Assistant: not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
xTaskCreate(weather_update_task, "weather", WEATHER_TASK_STACK, NULL,
|
||||
WEATHER_TASK_PRIO, NULL);
|
||||
/* 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");
|
||||
}
|
||||
Reference in new issue
Block a user