Implement rotary thermostat firmware from PRD
ESP-IDF v5.3.2 firmware for the CrowPanel 2.1" rotary display. Builds clean;
nothing has been run on hardware yet.
Hardware verified against Elecrow's own source for this board and
cross-checked against their ESPHome config, per PRD 8.1. Three corrections to
the PRD's pinout, all documented in docs/HARDWARE.md:
- touch is CST826 at 0x15, not CST816 (chip ID reads 0x11, not 0xB4)
- the ST7701S needs a 3-wire SPI bus on GPIO 16/2/1 that the PRD omitted;
without it the init sequence cannot be sent and the panel stays black
- the backlight is GPIO6
Two further departures from the PRD, both forced by facts on the ground:
- Transport is MQTT, not HTTP. zigbee2mqtt has no REST endpoint for live
sensor state. Subscribing to the broker also gets retained topics, so
every room's last reading arrives on connect rather than after a poll.
- The furnace is 2-wire R/W with no C, not 3-wire. The board needs its own
5V supply. The thermostat's 5 CPH setting is enforced as a minimum
12-minute period between heat-call starts, which a minimum off-time alone
does not achieve.
All of PRD section 5 is implemented in the relay layer itself: fail-open,
watchdog-gated output, max on-time, min off-time, cycle-rate limit, startup
lockout, and the stale-data fallback ladder down to open-loop freeze
protection. The one gap that cannot be closed in software — the PCF8574 not
resetting when the ESP32 does — is documented rather than papered over.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
393592e327
commit
d2e4c974dd
35 files changed
+4685
No files matched your search
@@ -0,0 +1,187 @@
|
||||
#include "lvgl_port.h"
|
||||
|
||||
#include "cst826.h"
|
||||
#include "display.h"
|
||||
#include "esp_check.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
static const char *TAG = "lvgl";
|
||||
|
||||
#define TICK_PERIOD_MS 2
|
||||
#define TASK_STACK 8192
|
||||
#define TASK_PRIO 3
|
||||
#define TASK_MAX_DELAY_MS 20
|
||||
#define TASK_MIN_DELAY_MS 2
|
||||
|
||||
static SemaphoreHandle_t s_lock;
|
||||
static lv_display_t *s_disp;
|
||||
static lv_indev_t *s_touch_indev;
|
||||
|
||||
/* ---------------------------------------------------------------- tick -- */
|
||||
|
||||
static void tick_cb(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
lv_tick_inc(TICK_PERIOD_MS);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- flush -- */
|
||||
|
||||
static void flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map)
|
||||
{
|
||||
(void)area;
|
||||
|
||||
/*
|
||||
* px_map is one of the panel's own framebuffers, so this does not copy —
|
||||
* the RGB driver just switches which buffer is being scanned out and waits
|
||||
* for the switch to take effect.
|
||||
*/
|
||||
esp_lcd_panel_handle_t panel = display_panel();
|
||||
esp_lcd_panel_draw_bitmap(panel, 0, 0, DISPLAY_H_RES, DISPLAY_V_RES, px_map);
|
||||
|
||||
lv_display_flush_ready(disp);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- input -- */
|
||||
|
||||
static void touch_read_cb(lv_indev_t *indev, lv_indev_data_t *data)
|
||||
{
|
||||
(void)indev;
|
||||
|
||||
cst826_touch_t t;
|
||||
cst826_read(&t);
|
||||
|
||||
if (t.pressed) {
|
||||
data->point.x = t.x;
|
||||
data->point.y = t.y;
|
||||
data->state = LV_INDEV_STATE_PRESSED;
|
||||
} else {
|
||||
data->state = LV_INDEV_STATE_RELEASED;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- task -- */
|
||||
|
||||
static void lvgl_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
|
||||
ESP_LOGI(TAG, "service task running");
|
||||
|
||||
while (1) {
|
||||
uint32_t next = TASK_MAX_DELAY_MS;
|
||||
|
||||
if (lvgl_port_lock(-1)) {
|
||||
next = lv_timer_handler();
|
||||
lvgl_port_unlock();
|
||||
}
|
||||
|
||||
if (next > TASK_MAX_DELAY_MS) {
|
||||
next = TASK_MAX_DELAY_MS;
|
||||
} else if (next < TASK_MIN_DELAY_MS) {
|
||||
next = TASK_MIN_DELAY_MS;
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(next));
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- init -- */
|
||||
|
||||
esp_err_t lvgl_port_init(void)
|
||||
{
|
||||
if (s_disp) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void *fb0 = NULL, *fb1 = NULL;
|
||||
esp_err_t err = display_get_framebuffers(&fb0, &fb1);
|
||||
if (err != ESP_OK || !fb0 || !fb1) {
|
||||
ESP_LOGE(TAG, "no framebuffers — display_init() must run first");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
s_lock = xSemaphoreCreateRecursiveMutex();
|
||||
if (!s_lock) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
/*
|
||||
* PRD 8.2: lv_init() before creating any LVGL object. Calling
|
||||
* lv_display_create() first faults with LoadProhibited inside the TLSF
|
||||
* allocator, which is a confusing way to discover an ordering bug.
|
||||
*/
|
||||
if (!lv_is_initialized()) {
|
||||
lv_init();
|
||||
}
|
||||
|
||||
s_disp = lv_display_create(DISPLAY_H_RES, DISPLAY_V_RES);
|
||||
if (!s_disp) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
lv_display_set_color_format(s_disp, LV_COLOR_FORMAT_RGB565);
|
||||
lv_display_set_flush_cb(s_disp, flush_cb);
|
||||
|
||||
size_t fb_bytes = (size_t)DISPLAY_H_RES * DISPLAY_V_RES * 2;
|
||||
lv_display_set_buffers(s_disp, fb0, fb1, fb_bytes,
|
||||
LV_DISPLAY_RENDER_MODE_FULL);
|
||||
|
||||
/* Touch, if the controller answered. The thermostat is fully usable from
|
||||
* the encoder alone, so a missing touch panel is not fatal. */
|
||||
if (cst826_is_ready()) {
|
||||
s_touch_indev = lv_indev_create();
|
||||
lv_indev_set_type(s_touch_indev, LV_INDEV_TYPE_POINTER);
|
||||
lv_indev_set_read_cb(s_touch_indev, touch_read_cb);
|
||||
lv_indev_set_display(s_touch_indev, s_disp);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "touch unavailable — encoder only");
|
||||
}
|
||||
|
||||
const esp_timer_create_args_t tick_args = {
|
||||
.callback = tick_cb,
|
||||
.name = "lv_tick",
|
||||
};
|
||||
esp_timer_handle_t tick_timer;
|
||||
ESP_RETURN_ON_ERROR(esp_timer_create(&tick_args, &tick_timer),
|
||||
TAG, "tick timer");
|
||||
ESP_RETURN_ON_ERROR(
|
||||
esp_timer_start_periodic(tick_timer, TICK_PERIOD_MS * 1000),
|
||||
TAG, "tick start");
|
||||
|
||||
if (xTaskCreate(lvgl_task, "lvgl", TASK_STACK, NULL, TASK_PRIO, NULL)
|
||||
!= pdPASS) {
|
||||
ESP_LOGE(TAG, "task create failed");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "up: %dx%d, full-refresh double buffered",
|
||||
DISPLAY_H_RES, DISPLAY_V_RES);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool lvgl_port_lock(int timeout_ms)
|
||||
{
|
||||
if (!s_lock) {
|
||||
return false;
|
||||
}
|
||||
TickType_t wait = (timeout_ms < 0) ? portMAX_DELAY
|
||||
: pdMS_TO_TICKS(timeout_ms);
|
||||
return xSemaphoreTakeRecursive(s_lock, wait) == pdTRUE;
|
||||
}
|
||||
|
||||
void lvgl_port_unlock(void)
|
||||
{
|
||||
if (s_lock) {
|
||||
xSemaphoreGiveRecursive(s_lock);
|
||||
}
|
||||
}
|
||||
|
||||
lv_display_t *lvgl_port_display(void)
|
||||
{
|
||||
return s_disp;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* lvgl_port — tick, service task, and locking.
|
||||
*
|
||||
* LVGL is not thread-safe. Everything that touches an lv_* object must hold
|
||||
* the lock, including code called from timers and event handlers that runs on
|
||||
* other tasks. The lock is recursive so a UI callback can re-enter without
|
||||
* deadlocking itself.
|
||||
*
|
||||
* Rendering uses the panel's two framebuffers directly in LVGL's FULL refresh
|
||||
* mode: LVGL draws a whole frame into the back buffer, flush hands it to
|
||||
* esp_lcd_panel_draw_bitmap(), and the RGB driver swaps buffers rather than
|
||||
* copying. Nothing ever writes into the buffer being scanned out, which is
|
||||
* what PRD 8.2's warning about persistent framebuffer corruption is about.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "lvgl.h"
|
||||
|
||||
/* Requires display_init(). Creates the display, input devices and task. */
|
||||
esp_err_t lvgl_port_init(void);
|
||||
|
||||
/*
|
||||
* Take the LVGL lock. timeout_ms < 0 waits forever.
|
||||
* Every lv_* call from outside a UI callback must be wrapped in this.
|
||||
*/
|
||||
bool lvgl_port_lock(int timeout_ms);
|
||||
void lvgl_port_unlock(void);
|
||||
|
||||
/* The display LVGL is driving. */
|
||||
lv_display_t *lvgl_port_display(void);
|
||||
@@ -0,0 +1,406 @@
|
||||
#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[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;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* ui_manager — the round-panel UI.
|
||||
*
|
||||
* Layout is built for a 480x480 circular panel: everything meaningful stays
|
||||
* inside a radius of about 190px from centre, so nothing important is lost to
|
||||
* the bezel or the corner mask.
|
||||
*
|
||||
* One knob drives everything. Rotation acts on whatever is currently focused;
|
||||
* a short press moves focus on; focus falls back to the setpoint after a few
|
||||
* seconds of stillness, so the knob is always "the temperature knob" unless
|
||||
* you have just deliberately made it something else.
|
||||
*
|
||||
* Requires lvgl_port_init().
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
esp_err_t ui_manager_init(void);
|
||||
Reference in new issue
Block a user