Files
rotaryThermo/main/ui/ui_manager.c
T
mattandClaude Opus 5 4c7b120473 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>
2026-08-03 20:05:51 -07:00

427 lines
13 KiB
C

#include "ui_manager.h"
#include <math.h>
#include <stdio.h>
#include <string.h>
#include "app_config.h"
#include "control.h"
#include "encoder.h"
#include "esp_log.h"
#include "lvgl_port.h"
#include "relay.h"
#include "wifi_manager.h"
#include "zigbee_client.h"
static const char *TAG = "ui";
#define REFRESH_PERIOD_MS 250
#define FOCUS_TIMEOUT_MS 8000
#define DEG "\xC2\xB0" /* UTF-8 degree sign */
/* Colours. Amber for an active heat call — it reads as "warm" without the
* alarm connotation of red, which is reserved for faults. */
#define COL_BG 0x101014
#define COL_TEXT 0xF0F0F0
#define COL_DIM 0x808088
#define COL_HEAT 0xFF9500
#define COL_FAULT 0xFF453A
#define COL_ARC_BG 0x2C2C30
#define COL_ARC_FG 0x0A84FF
typedef enum {
FOCUS_SETPOINT = 0,
FOCUS_ROOM,
FOCUS_MODE,
FOCUS_COUNT,
} focus_t;
static lv_obj_t *s_arc;
static lv_obj_t *s_lbl_temp;
static lv_obj_t *s_lbl_setpoint;
static lv_obj_t *s_lbl_room;
static lv_obj_t *s_lbl_mode;
static lv_obj_t *s_lbl_status;
static lv_obj_t *s_lbl_heat;
static focus_t s_focus;
static uint32_t s_focus_idle_ms;
/* ------------------------------------------------------------- helpers -- */
static float c_to_display(float c)
{
#if APP_UNITS_FAHRENHEIT
return c * 9.0f / 5.0f + 32.0f;
#else
return c;
#endif
}
/*
* One detent.
*
* In Fahrenheit this is 5/9 C, which is exactly 1F. The default setpoint of
* 20.0C is exactly 68F, so stepping by 5/9 from there keeps every displayed
* value a whole number of degrees rather than drifting onto 67.9.
*/
static float detent_step_c(void)
{
#if APP_UNITS_FAHRENHEIT
return 5.0f / 9.0f;
#else
return 0.5f;
#endif
}
static const char *unit_suffix(void)
{
#if APP_UNITS_FAHRENHEIT
return DEG "F";
#else
return DEG "C";
#endif
}
/*
* PRD 8.2: lv_label_set_text() invalidates unconditionally, even when the text
* is identical, forcing a full repaint. At full-refresh that is an entire
* 480x480 frame redrawn to change nothing, four times a second.
*/
static void set_text_if_changed(lv_obj_t *label, const char *text)
{
const char *current = lv_label_get_text(label);
if (!current || strcmp(current, text) != 0) {
lv_label_set_text(label, text);
}
}
static void set_color_if_changed(lv_obj_t *obj, uint32_t hex)
{
lv_color_t want = lv_color_hex(hex);
lv_color_t have = lv_obj_get_style_text_color(obj, LV_PART_MAIN);
if (lv_color_to_u32(want) != lv_color_to_u32(have)) {
lv_obj_set_style_text_color(obj, want, 0);
}
}
/* --------------------------------------------------------------- build -- */
static lv_obj_t *make_label(lv_obj_t *parent, const lv_font_t *font,
uint32_t color, lv_align_t align, int x, int y)
{
lv_obj_t *l = lv_label_create(parent);
lv_obj_set_style_text_font(l, font, 0);
lv_obj_set_style_text_color(l, lv_color_hex(color), 0);
lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(l, align, x, y);
lv_label_set_text(l, "");
return l;
}
static void build_screen(void)
{
lv_obj_t *scr = lv_screen_active();
lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), 0);
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
/*
* Setpoint arc, just inside the panel edge. Its gap sits at the bottom so
* the scale reads left-to-right over the top, and the bottom of the circle
* stays free for the status line.
*/
s_arc = lv_arc_create(scr);
lv_obj_set_size(s_arc, 440, 440);
lv_obj_center(s_arc);
lv_arc_set_rotation(s_arc, 135);
lv_arc_set_bg_angles(s_arc, 0, 270);
lv_arc_set_range(s_arc, 0, 100);
lv_obj_remove_style(s_arc, NULL, LV_PART_KNOB);
lv_obj_remove_flag(s_arc, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_arc_width(s_arc, 14, LV_PART_MAIN);
lv_obj_set_style_arc_width(s_arc, 14, LV_PART_INDICATOR);
lv_obj_set_style_arc_color(s_arc, lv_color_hex(COL_ARC_BG), LV_PART_MAIN);
lv_obj_set_style_arc_color(s_arc, lv_color_hex(COL_ARC_FG),
LV_PART_INDICATOR);
/* Room and mode above centre. */
s_lbl_room = make_label(scr, &lv_font_montserrat_28, COL_TEXT,
LV_ALIGN_CENTER, 0, -120);
s_lbl_mode = make_label(scr, &lv_font_montserrat_16, COL_DIM,
LV_ALIGN_CENTER, 0, -88);
/* The number that matters. */
s_lbl_temp = make_label(scr, &lv_font_montserrat_48, COL_TEXT,
LV_ALIGN_CENTER, 0, -20);
s_lbl_setpoint = make_label(scr, &lv_font_montserrat_28, COL_DIM,
LV_ALIGN_CENTER, 0, 40);
/* Heat-call indicator. */
s_lbl_heat = make_label(scr, &lv_font_montserrat_20, COL_DIM,
LV_ALIGN_CENTER, 0, 88);
/*
* Status and faults. Kept at y=+140 rather than nearer the rim: on a
* circular panel the horizontal room at the bottom shrinks fast, and a
* long fault string clipped by the bezel is worse than no fault string.
*/
s_lbl_status = make_label(scr, &lv_font_montserrat_16, COL_DIM,
LV_ALIGN_CENTER, 0, 140);
lv_obj_set_width(s_lbl_status, 300);
lv_label_set_long_mode(s_lbl_status, LV_LABEL_LONG_WRAP);
lv_obj_align(s_lbl_status, LV_ALIGN_CENTER, 0, 140);
}
/* ------------------------------------------------------------- refresh -- */
static void refresh_temperatures(const control_state_t *st)
{
char buf[40];
if (isnan(st->governing_temp_c)) {
set_text_if_changed(s_lbl_temp, "--" DEG);
} else {
snprintf(buf, sizeof(buf), "%.0f" DEG, c_to_display(st->governing_temp_c));
set_text_if_changed(s_lbl_temp, buf);
}
/*
* The setpoint shown must come from settings, live — not from control
* state, which only refreshes every CONTROL_INTERVAL_S. Rendering the
* control loop's copy makes the knob feel dead for up to 10 seconds per
* detent. When the fallback ladder has clamped the working setpoint, show
* both: what the user asked for and what the thermostat is actually
* holding — the fault text below explains why they differ.
*/
float live_sp = control_get_setpoint();
bool capped = st->effective_setpoint_c < live_sp - 0.05f;
if (capped) {
snprintf(buf, sizeof(buf), "Set %.0f%s (cap %.0f)",
c_to_display(live_sp), unit_suffix(),
c_to_display(st->effective_setpoint_c));
} else {
snprintf(buf, sizeof(buf), "Set %.0f%s",
c_to_display(live_sp), unit_suffix());
}
set_text_if_changed(s_lbl_setpoint, buf);
int pct = (int)((live_sp - CONTROL_SETPOINT_MIN_C) * 100.0f /
(CONTROL_SETPOINT_MAX_C - CONTROL_SETPOINT_MIN_C));
if (pct < 0) {
pct = 0;
} else if (pct > 100) {
pct = 100;
}
if (lv_arc_get_value(s_arc) != pct) {
lv_arc_set_value(s_arc, pct);
}
}
static void refresh_room_and_mode(const control_state_t *st)
{
const char *room = (st->governing_room >= 0)
? zigbee_client_room_name(st->governing_room)
: "No sensor";
set_text_if_changed(s_lbl_room, room);
/*
* While a selection is in progress, read the *settings* value, not control
* state: the control loop only re-evaluates every CONTROL_INTERVAL_S, and
* showing its lagging copy would make the knob appear to do nothing.
* control_get_mode()/control_get_selected_room() go straight to settings.
*/
char buf[48];
switch (s_focus) {
case FOCUS_ROOM:
snprintf(buf, sizeof(buf), "> ROOM: %s <",
zigbee_client_room_name(control_get_selected_room()));
break;
case FOCUS_MODE:
snprintf(buf, sizeof(buf), "> MODE: %s <",
control_mode_str(control_get_mode()));
break;
default:
snprintf(buf, sizeof(buf), "%s", control_mode_str(st->mode));
break;
}
set_text_if_changed(s_lbl_mode, buf);
set_color_if_changed(s_lbl_mode,
(s_focus == FOCUS_SETPOINT) ? COL_DIM : COL_ARC_FG);
}
static void refresh_heat(void)
{
relay_status_t rs;
relay_status(&rs);
char buf[48];
if (rs.heat_on) {
snprintf(buf, sizeof(buf), LV_SYMBOL_CHARGE " HEAT %lu:%02lu",
(unsigned long)(rs.on_seconds / 60),
(unsigned long)(rs.on_seconds % 60));
set_color_if_changed(s_lbl_heat, COL_HEAT);
} else if (rs.heat_requested && rs.hold_off_seconds > 0) {
/* Wanted but rate-limited. Saying so beats looking broken. */
snprintf(buf, sizeof(buf), "Waiting %lum",
(unsigned long)((rs.hold_off_seconds + 59) / 60));
set_color_if_changed(s_lbl_heat, COL_DIM);
} else {
snprintf(buf, sizeof(buf), "Idle");
set_color_if_changed(s_lbl_heat, COL_DIM);
}
set_text_if_changed(s_lbl_heat, buf);
}
static void refresh_status(const control_state_t *st)
{
relay_status_t rs;
relay_status(&rs);
char buf[96];
uint32_t color = COL_DIM;
/*
* Most severe first — one line, so it has to be the one that matters.
*
* The clock check has to come before the source check. A schedule with no
* clock yet falls through to the coldest fresh room, which sets the source
* to CONTROL_SRC_OTHER_ROOM; reporting that as "sensor stale" would send
* you looking at a sensor when the actual problem is that SNTP has not
* synced.
*/
if (rs.fault != RELAY_FAULT_NONE) {
snprintf(buf, sizeof(buf), "FAULT: %s", relay_fault_str(rs.fault));
color = COL_FAULT;
} else if (st->mode == ROOM_MODE_SCHEDULE && !st->schedule_clock_valid) {
snprintf(buf, sizeof(buf), "No clock yet - using coldest room");
color = COL_FAULT;
} else if (st->source != CONTROL_SRC_SELECTED) {
snprintf(buf, sizeof(buf), "%s", control_source_str(st->source));
color = COL_FAULT;
} else if (!wifi_manager_is_connected()) {
snprintf(buf, sizeof(buf), LV_SYMBOL_WARNING " No WiFi");
color = COL_FAULT;
} else if (!zigbee_client_is_connected()) {
snprintf(buf, sizeof(buf), LV_SYMBOL_WARNING " No broker");
color = COL_FAULT;
} else {
int batt = zigbee_client_lowest_battery();
if (batt >= 0 && batt < 20) {
snprintf(buf, sizeof(buf), LV_SYMBOL_BATTERY_1 " Sensor battery %d%%",
batt);
color = COL_FAULT;
} else {
snprintf(buf, sizeof(buf), "%u cycles/hr", rs.cycles_last_hour);
}
}
set_text_if_changed(s_lbl_status, buf);
set_color_if_changed(s_lbl_status, color);
}
/* --------------------------------------------------------------- input -- */
/*
* PRD 8.2: LVGL does not bubble events to parents, so a handler on the screen
* object never sees taps landing on a child widget. The encoder is not an
* LVGL input device here at all — it is polled from this timer, which sidesteps
* the whole question.
*/
static void handle_encoder(void)
{
int delta = encoder_take_delta();
encoder_button_event_t btn = encoder_take_button_event();
if (delta != 0) {
s_focus_idle_ms = 0;
switch (s_focus) {
case FOCUS_ROOM: {
int n = zigbee_client_room_count();
int room = control_get_selected_room() + delta;
/* Wrap rather than clamp: with two rooms, clamping makes the knob
* feel dead half the time. */
room = ((room % n) + n) % n;
control_select_room(room);
break;
}
case FOCUS_MODE: {
int mode = (int)control_get_mode() + delta;
mode = ((mode % ROOM_MODE_COUNT) + ROOM_MODE_COUNT) % ROOM_MODE_COUNT;
control_set_mode((room_mode_t)mode);
break;
}
case FOCUS_SETPOINT:
default:
control_adjust_setpoint(detent_step_c() * (float)delta);
break;
}
}
if (btn == ENCODER_BTN_CLICK) {
s_focus = (focus_t)((s_focus + 1) % FOCUS_COUNT);
s_focus_idle_ms = 0;
} else if (btn == ENCODER_BTN_LONG_PRESS) {
s_focus = FOCUS_SETPOINT;
s_focus_idle_ms = 0;
}
/* Fall back to the setpoint so the knob's default meaning is always the
* temperature, however the user left it. */
if (s_focus != FOCUS_SETPOINT) {
s_focus_idle_ms += REFRESH_PERIOD_MS;
if (s_focus_idle_ms >= FOCUS_TIMEOUT_MS) {
s_focus = FOCUS_SETPOINT;
s_focus_idle_ms = 0;
}
}
}
/* ---------------------------------------------------------------- tick -- */
static void refresh_timer_cb(lv_timer_t *timer)
{
(void)timer;
handle_encoder();
control_state_t st;
control_get_state(&st);
refresh_temperatures(&st);
refresh_room_and_mode(&st);
refresh_heat();
refresh_status(&st);
}
/* ---------------------------------------------------------------- init -- */
esp_err_t ui_manager_init(void)
{
if (!lvgl_port_lock(1000)) {
ESP_LOGE(TAG, "could not take LVGL lock");
return ESP_ERR_TIMEOUT;
}
build_screen();
lv_timer_create(refresh_timer_cb, REFRESH_PERIOD_MS, NULL);
lvgl_port_unlock();
ESP_LOGI(TAG, "ready (%s)", unit_suffix());
return ESP_OK;
}