#include "ui_manager.h" #include #include #include #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[32]; 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); } /* * Show the effective setpoint, not the requested one. When the fallback * ladder has clamped it, the screen must say what the thermostat is * actually doing — the fault text below explains why. */ snprintf(buf, sizeof(buf), "Set %.0f%s", c_to_display(st->effective_setpoint_c), unit_suffix()); set_text_if_changed(s_lbl_setpoint, buf); int pct = (int)((st->effective_setpoint_c - 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); char buf[48]; switch (s_focus) { case FOCUS_ROOM: snprintf(buf, sizeof(buf), "> SELECT ROOM <"); break; case FOCUS_MODE: snprintf(buf, sizeof(buf), "> MODE: %s <", control_mode_str(st->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; }