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>
427 lines
13 KiB
C
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;
|
|
}
|