Fix TWDT starvation, vsync race, and UI responsiveness from review

Three bugs and two UX defects found on review, plus hardening:

- The control loop fed the task watchdog then slept exactly the watchdog
  period (both 10s), tripping the TWDT fractionally late on every single
  iteration. Sleep is now sliced 1s with a reset per slice. Also set
  CONFIG_ESP_TASK_WDT_PANIC=y - the sdkconfig comment claimed a hung task
  panics the chip, but nothing actually made that true.

- The LVGL flush called draw_bitmap (which only queues the framebuffer
  switch) and released LVGL immediately, letting it render the next frame
  into the buffer still being scanned out - the exact PRD 8.2 race the
  double-buffer design exists to prevent. display_present_frame() now
  blocks on a vsync handshake until the swap has latched.

- WiFi reconnect slept up to 30s inside the system event handler, stalling
  all queued WiFi/IP events. Moved to an esp_timer.

- The setpoint label, arc, and mode/room lines rendered the control loop's
  copy of state, which refreshes every 10s - every knob detent looked dead
  for up to 10 seconds. The UI now reads settings live; when the fallback
  ladder clamps the setpoint it shows both values. Room-select focus now
  shows which room is being selected.

- z2m ISO_8601_local timestamps carry a UTC offset the parser ignored,
  which would shift apparent reading age by the local offset (5-6h in the
  US) and permanently trip the fallback ladder. Offset is now honoured.

- Suppressed the cosmetic "control loop stopped" fault in the window
  between relay_init and the control task's first call-in at boot.

- Explicit pullups on the encoder A/B lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mattandClaude Opus 5 committed 2026-08-03 20:05:51 -07:00
1 parent d2e4c974dd
commit 4c7b120473
10 files changed
+206 -27

No files matched your search

+70 -1
View File
@@ -60,6 +60,16 @@ static const char *TAG = "display";
static esp_lcd_panel_handle_t s_panel;
static void *s_fb0, *s_fb1;
/*
* Vsync handshake for tear-free buffer swaps (the pattern from Espressif's
* own RGB-panel LVGL example). present_frame gives s_sem_present_pending after
* queueing the swap; the vsync ISR converts that into s_sem_swap_done only
* when a present is actually waiting, so a stale vsync can never satisfy a
* later wait.
*/
static SemaphoreHandle_t s_sem_present_pending;
static SemaphoreHandle_t s_sem_swap_done;
/* ------------------------------------------------------------ backlight -- */
static esp_err_t backlight_init(void)
@@ -122,6 +132,52 @@ static esp_err_t panel_power_and_reset(void)
return ESP_OK;
}
/*
* Runs from the RGB panel's frame ISR. CONFIG_LCD_RGB_ISR_IRAM_SAFE=y requires
* this to live in IRAM.
*/
IRAM_ATTR static bool on_vsync_cb(esp_lcd_panel_handle_t panel,
const esp_lcd_rgb_panel_event_data_t *edata,
void *user_ctx)
{
(void)panel;
(void)edata;
(void)user_ctx;
BaseType_t hpw = pdFALSE;
if (xSemaphoreTakeFromISR(s_sem_present_pending, &hpw) == pdTRUE) {
xSemaphoreGiveFromISR(s_sem_swap_done, &hpw);
}
return hpw == pdTRUE;
}
esp_err_t display_present_frame(void *fb)
{
if (!s_panel || !fb) {
return ESP_ERR_INVALID_STATE;
}
esp_err_t err = esp_lcd_panel_draw_bitmap(s_panel, 0, 0,
DISPLAY_H_RES, DISPLAY_V_RES, fb);
if (err != ESP_OK) {
return err;
}
/* Wait for the vsync at which the queued switch latches. The timeout is a
* guard so a stalled panel degrades to tearing instead of wedging the
* LVGL task; one frame at these timings is ~15ms. */
xSemaphoreGive(s_sem_present_pending);
if (xSemaphoreTake(s_sem_swap_done, pdMS_TO_TICKS(100)) != pdTRUE) {
static bool warned;
if (!warned) {
warned = true;
ESP_LOGW(TAG, "vsync wait timed out — panel not producing frames?");
}
}
return ESP_OK;
}
/* --------------------------------------------------------------- init ---- */
static esp_err_t send_init_sequence(void)
@@ -243,6 +299,19 @@ static esp_err_t rgb_panel_init(void)
esp_lcd_rgb_panel_get_frame_buffer(s_panel, 2, &s_fb0, &s_fb1),
TAG, "get framebuffers");
s_sem_present_pending = xSemaphoreCreateBinary();
s_sem_swap_done = xSemaphoreCreateBinary();
if (!s_sem_present_pending || !s_sem_swap_done) {
return ESP_ERR_NO_MEM;
}
esp_lcd_rgb_panel_event_callbacks_t cbs = {
.on_vsync = on_vsync_cb,
};
ESP_RETURN_ON_ERROR(
esp_lcd_rgb_panel_register_event_callbacks(s_panel, &cbs, NULL),
TAG, "vsync callback");
ESP_LOGI(TAG, "RGB panel up: %dx%d @ %d MHz, fb0=%p fb1=%p",
DISPLAY_H_RES, DISPLAY_V_RES, LCD_PCLK_HZ / 1000000, s_fb0, s_fb1);
return ESP_OK;
@@ -321,6 +390,6 @@ void display_test_pattern(void)
}
}
esp_lcd_panel_draw_bitmap(s_panel, 0, 0, DISPLAY_H_RES, DISPLAY_V_RES, fb);
display_present_frame(fb);
ESP_LOGI(TAG, "colour bars drawn — this is the PRD phase-2 check");
}
+12
View File
@@ -40,6 +40,18 @@ esp_err_t display_init(void);
/* The esp_lcd panel handle, for the LVGL port. */
esp_lcd_panel_handle_t display_panel(void);
/*
* Hand a completed frame (one of the two framebuffers) to the panel and block
* until the swap has latched at vsync.
*
* esp_lcd_panel_draw_bitmap() only *queues* the framebuffer switch — the RGB
* driver latches it at the next frame boundary. Returning before that point
* lets the caller start rendering into the other buffer while it is still
* being scanned out, which is exactly the persistent-corruption race PRD 8.2
* warns about. All frame presentation goes through here so that cannot happen.
*/
esp_err_t display_present_frame(void *fb);
/* Both framebuffers, for LVGL to render into directly. */
esp_err_t display_get_framebuffers(void **fb0, void **fb1);
+11
View File
@@ -1,5 +1,6 @@
#include "encoder.h"
#include "driver/gpio.h"
#include "driver/pulse_cnt.h"
#include "esp_check.h"
#include "esp_log.h"
@@ -125,6 +126,16 @@ esp_err_t encoder_init(void)
PCNT_CHANNEL_LEVEL_ACTION_INVERSE),
TAG, "chan b level");
/*
* The vendor's Arduino example uses bare INPUT, implying external pullups
* on the board, but their ESPHome config enables internal ones as well.
* Enabling them here costs nothing and guards against a floating line
* reading as phantom rotation. (Done after channel creation so the PCNT
* driver's own GPIO setup cannot undo it.)
*/
gpio_pullup_en(ENCODER_PIN_A);
gpio_pullup_en(ENCODER_PIN_B);
ESP_RETURN_ON_ERROR(pcnt_unit_enable(s_unit), TAG, "enable");
ESP_RETURN_ON_ERROR(pcnt_unit_clear_count(s_unit), TAG, "clear");
ESP_RETURN_ON_ERROR(pcnt_unit_start(s_unit), TAG, "start");