/** * CYD ESP32-2432S028R — LVGL SGP30 空气质量仪表盘 * SGP30 SDA -> GPIO22, SCL -> GPIO27 */ #include #include #include #include #include #include #include #define I2C_SDA 22 #define I2C_SCL 27 #if defined(CYD_BOARD_3248S035) #define TFT_BL_PIN 27 #else #define TFT_BL_PIN 21 #endif #define XPT2046_IRQ 36 #define XPT2046_MOSI 32 #define XPT2046_MISO 39 #define XPT2046_CLK 25 #define XPT2046_CS 33 #if defined(CYD_BOARD_3248S035) #define SCREEN_WIDTH 480 #define SCREEN_HEIGHT 320 #else #define SCREEN_WIDTH 320 #define SCREEN_HEIGHT 240 #endif #define SGP_READ_MS 1000 #define SGP_FAIL_TOLERANCE 3 #define EMA_ALPHA 0.35f #define HISTORY_LEN 48 #define WARMUP_SEC 15 #define DRAW_BUF_SIZE (SCREEN_WIDTH * SCREEN_HEIGHT / 10 * (LV_COLOR_DEPTH / 8)) /* Gauge display clamps (arc range) */ #define ECO2_ARC_MAX 2000 #define TVOC_ARC_MAX 1000 /* Layout grid — every region has exclusive space, no overlaps */ #if defined(CYD_BOARD_3248S035) #define UI_M 10 #define UI_G 10 #define UI_HDR_H 32 #define UI_GAUGE_H 168 #define UI_GAUGE_W 225 #define UI_ARC 120 #else #define UI_M 6 #define UI_G 6 #define UI_HDR_H 22 #define UI_GAUGE_H 124 #define UI_GAUGE_W 151 #define UI_ARC 90 #endif #define UI_BOT_Y (UI_HDR_H + UI_G + UI_GAUGE_H + UI_G) /* 158 */ #define UI_BOT_H (SCREEN_HEIGHT - UI_BOT_Y - UI_M) /* 76 */ #define UI_BOT_W (SCREEN_WIDTH - 2 * UI_M) /* 308 */ #if defined(CYD_BOARD_3248S035) #define UI_INFO_W 180 #else #define UI_INFO_W 124 #endif #define UI_CHART_W (UI_BOT_W - 12 - UI_INFO_W - UI_G) /* ~160 */ static const lv_color_t COL_BG = lv_color_hex(0x0A1018); static const lv_color_t COL_PANEL = lv_color_hex(0x121C2A); static const lv_color_t COL_EDGE = lv_color_hex(0x1E2C40); static const lv_color_t COL_TEXT = lv_color_hex(0xF2F5FA); static const lv_color_t COL_MUTED = lv_color_hex(0x7E90A8); static const lv_color_t COL_ECO2 = lv_color_hex(0x4DA3FF); static const lv_color_t COL_TVOC = lv_color_hex(0xB07CFF); static const lv_color_t COL_OK = lv_color_hex(0x4CAF7A); static const lv_color_t COL_WARN = lv_color_hex(0xE6B84D); static const lv_color_t COL_BAD = lv_color_hex(0xE05A5A); static const lv_color_t COL_ARC_BG = lv_color_hex(0x243247); static const lv_color_t COL_CHART_BG = lv_color_hex(0x0E1622); SPIClass touchscreenSPI = SPIClass(VSPI); XPT2046_Touchscreen touchscreen(XPT2046_CS, XPT2046_IRQ); Adafruit_SGP30 sgp; static uint32_t draw_buf[DRAW_BUF_SIZE / 4]; static lv_obj_t *arc_eco2; static lv_obj_t *arc_tvoc; static lv_obj_t *lbl_eco2_val; static lv_obj_t *lbl_tvoc_val; static lv_obj_t *lbl_eco2_range; static lv_obj_t *lbl_tvoc_range; static lv_obj_t *lbl_aqi; static lv_obj_t *lbl_eco2_lvl; static lv_obj_t *lbl_tvoc_lvl; static lv_obj_t *lbl_status; static lv_obj_t *lbl_uptime; static lv_obj_t *lbl_hint; static lv_obj_t *led_status; static lv_obj_t *bar_aqi; static lv_obj_t *chart; static lv_chart_series_t *ser_eco2; static lv_chart_series_t *ser_tvoc; static float disp_eco2 = NAN; static float disp_tvoc = NAN; static float min_eco2 = NAN; static float max_eco2 = NAN; static float min_tvoc = NAN; static float max_tvoc = NAN; static uint32_t last_sgp_ms = 0; static uint32_t last_ok_ms = 0; static uint32_t boot_ms = 0; static uint8_t fail_streak = 0; static bool sensor_ok = false; static bool iaq_ready = false; static uint8_t bright_level = 2; static const uint8_t BRIGHT_PWM[4] = {40, 90, 160, 255}; static uint32_t hint_until_ms = 0; static void set_backlight(uint8_t level) { bright_level = level & 0x03; ledcWrite(0, BRIGHT_PWM[bright_level]); } static float ema(float prev, float sample) { if (isnan(prev)) return sample; return prev + EMA_ALPHA * (sample - prev); } /* eCO2 color bands (ppm) */ static lv_color_t eco2_color(float ppm) { if (isnan(ppm)) return COL_MUTED; if (ppm < 800.0f) return COL_OK; if (ppm < 1200.0f) return COL_WARN; return COL_BAD; } /* TVOC color bands (ppb) */ static lv_color_t tvoc_color(float ppb) { if (isnan(ppb)) return COL_MUTED; if (ppb < 220.0f) return COL_OK; if (ppb < 660.0f) return COL_WARN; return COL_BAD; } static const char *eco2_level_text(float ppm) { if (isnan(ppm)) return "eCO2 --"; if (ppm < 800.0f) return "eCO2 Good"; if (ppm < 1200.0f) return "eCO2 Fair"; if (ppm < 2000.0f) return "eCO2 Poor"; return "eCO2 Bad"; } static const char *tvoc_level_text(float ppb) { if (isnan(ppb)) return "TVOC --"; if (ppb < 220.0f) return "TVOC Good"; if (ppb < 660.0f) return "TVOC Fair"; if (ppb < 2200.0f) return "TVOC Poor"; return "TVOC Bad"; } static const char *aqi_text(float eco2, float tvoc) { if (isnan(eco2) || isnan(tvoc)) return "NO DATA"; if (eco2 < 800.0f && tvoc < 220.0f) return "GOOD"; if (eco2 < 1200.0f && tvoc < 660.0f) return "FAIR"; if (eco2 < 2000.0f && tvoc < 2200.0f) return "POOR"; return "BAD"; } static lv_color_t aqi_color(float eco2, float tvoc) { if (isnan(eco2) || isnan(tvoc)) return COL_BAD; if (eco2 < 800.0f && tvoc < 220.0f) return COL_OK; if (eco2 < 1200.0f && tvoc < 660.0f) return COL_WARN; return COL_BAD; } /* 0..100 score: higher = better air */ static int aqi_score(float eco2, float tvoc) { if (isnan(eco2) || isnan(tvoc)) return 0; float e_score = 100.0f - (eco2 - 400.0f) * 0.06f; /* 400→100, ~2067→0 */ float t_score = 100.0f - tvoc * 0.12f; /* 0→100, ~833→0 */ int score = (int)lroundf((constrain(e_score, 0.0f, 100.0f) + constrain(t_score, 0.0f, 100.0f)) * 0.5f); return constrain(score, 0, 100); } static void show_hint(const char *text, uint32_t ms) { lv_label_set_text(lbl_hint, text); lv_obj_remove_flag(lbl_hint, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(lbl_hint); hint_until_ms = millis() + ms; } static void reset_minmax(void) { if (!isnan(disp_eco2)) { min_eco2 = max_eco2 = disp_eco2; } else { min_eco2 = max_eco2 = NAN; } if (!isnan(disp_tvoc)) { min_tvoc = max_tvoc = disp_tvoc; } else { min_tvoc = max_tvoc = NAN; } show_hint("min/max reset", 1200); } static void touchscreen_read(lv_indev_t *indev, lv_indev_data_t *data) { LV_UNUSED(indev); static bool was_pressed = false; static uint32_t press_ms = 0; if (touchscreen.tirqTouched() && touchscreen.touched()) { TS_Point p = touchscreen.getPoint(); data->point.x = map(p.y, 240, 3800, 1, SCREEN_WIDTH); data->point.y = map(p.x, 200, 3700, SCREEN_HEIGHT, 1); data->state = LV_INDEV_STATE_PRESSED; if (!was_pressed) { was_pressed = true; press_ms = millis(); } } else { data->state = LV_INDEV_STATE_RELEASED; if (was_pressed) { was_pressed = false; uint32_t held = millis() - press_ms; if (held >= 700) { reset_minmax(); } else if (held < 400) { set_backlight((bright_level + 1) & 0x03); char buf[20]; lv_snprintf(buf, sizeof(buf), "brightness %u/3", (unsigned)bright_level); show_hint(buf, 900); } } } } static void arc_anim_exec(void *obj, int32_t v) { lv_arc_set_value((lv_obj_t *)obj, v); } static void anim_arc(lv_obj_t *arc, int32_t value) { int32_t cur = lv_arc_get_value(arc); if (cur == value) return; lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, arc); lv_anim_set_values(&a, cur, value); lv_anim_set_duration(&a, 520); lv_anim_set_exec_cb(&a, arc_anim_exec); lv_anim_set_path_cb(&a, lv_anim_path_ease_out); lv_anim_start(&a); } static void bar_anim_exec(void *obj, int32_t v) { lv_bar_set_value((lv_obj_t *)obj, v, LV_ANIM_OFF); } static void anim_bar(lv_obj_t *bar, int32_t value) { int32_t cur = lv_bar_get_value(bar); if (cur == value) return; lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, bar); lv_anim_set_values(&a, cur, value); lv_anim_set_duration(&a, 400); lv_anim_set_exec_cb(&a, bar_anim_exec); lv_anim_set_path_cb(&a, lv_anim_path_ease_out); lv_anim_start(&a); } static lv_obj_t *make_panel(lv_obj_t *parent, int x, int y, int w, int h) { lv_obj_t *p = lv_obj_create(parent); lv_obj_set_pos(p, x, y); lv_obj_set_size(p, w, h); lv_obj_set_style_bg_color(p, COL_PANEL, 0); lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0); lv_obj_set_style_border_color(p, COL_EDGE, 0); lv_obj_set_style_border_width(p, 1, 0); lv_obj_set_style_radius(p, 12, 0); lv_obj_set_style_pad_all(p, 6, 0); lv_obj_set_style_pad_row(p, 0, 0); lv_obj_set_style_pad_column(p, 0, 0); lv_obj_clear_flag(p, LV_OBJ_FLAG_SCROLLABLE); lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE); return p; } static lv_obj_t *make_gauge(lv_obj_t *parent, lv_color_t accent, int max_val) { lv_obj_t *arc = lv_arc_create(parent); lv_obj_set_size(arc, UI_ARC, UI_ARC); lv_obj_align(arc, LV_ALIGN_TOP_MID, 0, 2); lv_arc_set_rotation(arc, 135); lv_arc_set_bg_angles(arc, 0, 270); lv_arc_set_range(arc, 0, max_val); lv_arc_set_value(arc, 0); lv_obj_remove_style(arc, NULL, LV_PART_KNOB); lv_obj_clear_flag(arc, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_style_arc_color(arc, COL_ARC_BG, LV_PART_MAIN); lv_obj_set_style_arc_width(arc, 10, LV_PART_MAIN); lv_obj_set_style_arc_color(arc, accent, LV_PART_INDICATOR); lv_obj_set_style_arc_width(arc, 10, LV_PART_INDICATOR); lv_obj_set_style_arc_rounded(arc, true, LV_PART_MAIN); lv_obj_set_style_arc_rounded(arc, true, LV_PART_INDICATOR); return arc; } static void create_ui(void) { lv_obj_t *scr = lv_screen_active(); lv_obj_set_style_bg_color(scr, COL_BG, 0); lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); lv_obj_clear_flag(scr, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t *title = lv_label_create(scr); lv_label_set_text(title, "AIR"); lv_obj_set_style_text_font(title, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(title, COL_TEXT, 0); lv_obj_set_pos(title, UI_M, 4); lbl_uptime = lv_label_create(scr); lv_label_set_text(lbl_uptime, "up 00:00"); lv_obj_set_style_text_font(lbl_uptime, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(lbl_uptime, COL_MUTED, 0); lv_obj_align_to(lbl_uptime, title, LV_ALIGN_OUT_RIGHT_MID, 8, 1); led_status = lv_led_create(scr); lv_obj_set_size(led_status, 9, 9); lv_obj_align(led_status, LV_ALIGN_TOP_RIGHT, -UI_M, 6); lv_led_set_color(led_status, COL_WARN); lv_led_on(led_status); lbl_status = lv_label_create(scr); lv_label_set_text(lbl_status, "INIT"); lv_obj_set_style_text_font(lbl_status, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(lbl_status, COL_MUTED, 0); lv_obj_align(lbl_status, LV_ALIGN_TOP_RIGHT, -(UI_M + 16), 4); lbl_hint = lv_label_create(scr); lv_label_set_text(lbl_hint, ""); lv_obj_set_style_text_font(lbl_hint, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(lbl_hint, COL_WARN, 0); lv_obj_set_style_bg_color(lbl_hint, COL_BG, 0); lv_obj_set_style_bg_opa(lbl_hint, LV_OPA_COVER, 0); lv_obj_set_style_pad_hor(lbl_hint, 6, 0); lv_obj_align(lbl_hint, LV_ALIGN_TOP_MID, 0, 4); lv_obj_add_flag(lbl_hint, LV_OBJ_FLAG_HIDDEN); const int gy = UI_HDR_H + UI_G; lv_obj_t *p_eco2 = make_panel(scr, UI_M, gy, UI_GAUGE_W, UI_GAUGE_H); lv_obj_t *e_cap = lv_label_create(p_eco2); lv_label_set_text(e_cap, "eCO2"); lv_obj_set_style_text_font(e_cap, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(e_cap, COL_MUTED, 0); lv_obj_align(e_cap, LV_ALIGN_TOP_LEFT, 0, -2); arc_eco2 = make_gauge(p_eco2, COL_ECO2, ECO2_ARC_MAX); lbl_eco2_val = lv_label_create(p_eco2); lv_label_set_text(lbl_eco2_val, "----"); lv_obj_set_style_text_font(lbl_eco2_val, &lv_font_montserrat_24, 0); lv_obj_set_style_text_color(lbl_eco2_val, COL_ECO2, 0); lv_obj_align(lbl_eco2_val, LV_ALIGN_TOP_MID, 0, 32); lv_obj_t *e_unit = lv_label_create(p_eco2); lv_label_set_text(e_unit, "ppm"); lv_obj_set_style_text_font(e_unit, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(e_unit, COL_MUTED, 0); lv_obj_align(e_unit, LV_ALIGN_TOP_MID, 0, 58); lbl_eco2_range = lv_label_create(p_eco2); lv_label_set_text(lbl_eco2_range, "-- / --"); lv_obj_set_style_text_font(lbl_eco2_range, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(lbl_eco2_range, COL_MUTED, 0); lv_obj_align(lbl_eco2_range, LV_ALIGN_BOTTOM_MID, 0, 2); lv_obj_t *p_tvoc = make_panel(scr, UI_M + UI_GAUGE_W + UI_G, gy, UI_GAUGE_W, UI_GAUGE_H); lv_obj_t *t_cap = lv_label_create(p_tvoc); lv_label_set_text(t_cap, "TVOC"); lv_obj_set_style_text_font(t_cap, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(t_cap, COL_MUTED, 0); lv_obj_align(t_cap, LV_ALIGN_TOP_LEFT, 0, -2); arc_tvoc = make_gauge(p_tvoc, COL_TVOC, TVOC_ARC_MAX); lbl_tvoc_val = lv_label_create(p_tvoc); lv_label_set_text(lbl_tvoc_val, "----"); lv_obj_set_style_text_font(lbl_tvoc_val, &lv_font_montserrat_24, 0); lv_obj_set_style_text_color(lbl_tvoc_val, COL_TVOC, 0); lv_obj_align(lbl_tvoc_val, LV_ALIGN_TOP_MID, 0, 32); lv_obj_t *t_unit = lv_label_create(p_tvoc); lv_label_set_text(t_unit, "ppb"); lv_obj_set_style_text_font(t_unit, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(t_unit, COL_MUTED, 0); lv_obj_align(t_unit, LV_ALIGN_TOP_MID, 0, 58); lbl_tvoc_range = lv_label_create(p_tvoc); lv_label_set_text(lbl_tvoc_range, "-- / --"); lv_obj_set_style_text_font(lbl_tvoc_range, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(lbl_tvoc_range, COL_MUTED, 0); lv_obj_align(lbl_tvoc_range, LV_ALIGN_BOTTOM_MID, 0, 2); lv_obj_t *p_bot = make_panel(scr, UI_M, UI_BOT_Y, UI_BOT_W, UI_BOT_H); lv_obj_t *info = lv_obj_create(p_bot); lv_obj_set_size(info, UI_INFO_W, UI_BOT_H - 12); lv_obj_align(info, LV_ALIGN_LEFT_MID, 0, 0); lv_obj_set_style_bg_opa(info, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(info, 0, 0); lv_obj_set_style_pad_all(info, 0, 0); lv_obj_clear_flag(info, LV_OBJ_FLAG_SCROLLABLE); lbl_aqi = lv_label_create(info); lv_label_set_text(lbl_aqi, "WARMUP"); lv_obj_set_style_text_font(lbl_aqi, &lv_font_montserrat_14, 0); lv_obj_set_style_text_color(lbl_aqi, COL_WARN, 0); lv_obj_set_width(lbl_aqi, UI_INFO_W); lv_label_set_long_mode(lbl_aqi, LV_LABEL_LONG_CLIP); lv_obj_align(lbl_aqi, LV_ALIGN_TOP_LEFT, 0, 0); bar_aqi = lv_bar_create(info); lv_obj_set_size(bar_aqi, UI_INFO_W - 4, 8); lv_obj_align(bar_aqi, LV_ALIGN_TOP_LEFT, 0, 20); lv_bar_set_range(bar_aqi, 0, 100); lv_bar_set_value(bar_aqi, 0, LV_ANIM_OFF); lv_obj_set_style_bg_color(bar_aqi, COL_ARC_BG, LV_PART_MAIN); lv_obj_set_style_bg_opa(bar_aqi, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_radius(bar_aqi, 4, LV_PART_MAIN); lv_obj_set_style_bg_color(bar_aqi, COL_WARN, LV_PART_INDICATOR); lv_obj_set_style_radius(bar_aqi, 4, LV_PART_INDICATOR); lbl_eco2_lvl = lv_label_create(info); lv_label_set_text(lbl_eco2_lvl, "eCO2 --"); lv_obj_set_style_text_font(lbl_eco2_lvl, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(lbl_eco2_lvl, COL_MUTED, 0); lv_obj_set_width(lbl_eco2_lvl, UI_INFO_W); lv_label_set_long_mode(lbl_eco2_lvl, LV_LABEL_LONG_CLIP); lv_obj_align(lbl_eco2_lvl, LV_ALIGN_TOP_LEFT, 0, 34); lbl_tvoc_lvl = lv_label_create(info); lv_label_set_text(lbl_tvoc_lvl, "TVOC --"); lv_obj_set_style_text_font(lbl_tvoc_lvl, &lv_font_montserrat_12, 0); lv_obj_set_style_text_color(lbl_tvoc_lvl, COL_MUTED, 0); lv_obj_set_width(lbl_tvoc_lvl, UI_INFO_W); lv_label_set_long_mode(lbl_tvoc_lvl, LV_LABEL_LONG_CLIP); lv_obj_align(lbl_tvoc_lvl, LV_ALIGN_TOP_LEFT, 0, 48); chart = lv_chart_create(p_bot); lv_obj_set_size(chart, UI_CHART_W, UI_BOT_H - 16); lv_obj_align(chart, LV_ALIGN_RIGHT_MID, 0, 0); lv_chart_set_type(chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(chart, HISTORY_LEN); lv_chart_set_range(chart, LV_CHART_AXIS_PRIMARY_Y, 400, ECO2_ARC_MAX); lv_chart_set_range(chart, LV_CHART_AXIS_SECONDARY_Y, 0, TVOC_ARC_MAX); lv_chart_set_update_mode(chart, LV_CHART_UPDATE_MODE_SHIFT); lv_obj_set_style_bg_color(chart, COL_CHART_BG, 0); lv_obj_set_style_bg_opa(chart, LV_OPA_COVER, 0); lv_obj_set_style_border_color(chart, COL_EDGE, 0); lv_obj_set_style_border_width(chart, 1, 0); lv_obj_set_style_radius(chart, 8, 0); lv_obj_set_style_pad_all(chart, 4, 0); lv_obj_set_style_line_width(chart, 2, LV_PART_ITEMS); lv_chart_set_div_line_count(chart, 2, 4); lv_obj_set_style_line_color(chart, COL_EDGE, LV_PART_MAIN); lv_obj_set_style_line_opa(chart, LV_OPA_40, LV_PART_MAIN); ser_eco2 = lv_chart_add_series(chart, COL_ECO2, LV_CHART_AXIS_PRIMARY_Y); ser_tvoc = lv_chart_add_series(chart, COL_TVOC, LV_CHART_AXIS_SECONDARY_Y); lv_obj_move_foreground(title); lv_obj_move_foreground(lbl_uptime); lv_obj_move_foreground(lbl_status); lv_obj_move_foreground(led_status); lv_obj_move_foreground(lbl_hint); } static void refresh_meta(void) { char buf[40]; uint32_t up = millis() / 1000; lv_snprintf(buf, sizeof(buf), "up %02lu:%02lu bl%u", (unsigned long)(up / 60), (unsigned long)(up % 60), (unsigned)bright_level); lv_label_set_text(lbl_uptime, buf); if (hint_until_ms && (int32_t)(millis() - hint_until_ms) >= 0) { hint_until_ms = 0; lv_obj_add_flag(lbl_hint, LV_OBJ_FLAG_HIDDEN); } if (!iaq_ready) { uint32_t left = WARMUP_SEC - (millis() - boot_ms) / 1000; if ((int32_t)left < 0) left = 0; lv_snprintf(buf, sizeof(buf), "WARM %lus", (unsigned long)left); lv_label_set_text(lbl_status, buf); lv_led_set_color(led_status, COL_WARN); } else if (sensor_ok) { uint32_t age = (millis() - last_ok_ms) / 1000; lv_snprintf(buf, sizeof(buf), "LIVE %lus", (unsigned long)age); lv_label_set_text(lbl_status, buf); lv_led_set_color(led_status, COL_OK); } } static void update_ui(bool ok) { char buf[32]; if (ok && !isnan(disp_eco2) && !isnan(disp_tvoc)) { lv_color_t ec = eco2_color(disp_eco2); lv_color_t tc = tvoc_color(disp_tvoc); lv_snprintf(buf, sizeof(buf), "%.0f", disp_eco2); lv_label_set_text(lbl_eco2_val, buf); lv_obj_set_style_text_color(lbl_eco2_val, ec, 0); lv_obj_set_style_arc_color(arc_eco2, ec, LV_PART_INDICATOR); anim_arc(arc_eco2, (int)lroundf(constrain(disp_eco2, 0.0f, (float)ECO2_ARC_MAX))); lv_snprintf(buf, sizeof(buf), "%.0f", disp_tvoc); lv_label_set_text(lbl_tvoc_val, buf); lv_obj_set_style_text_color(lbl_tvoc_val, tc, 0); lv_obj_set_style_arc_color(arc_tvoc, tc, LV_PART_INDICATOR); anim_arc(arc_tvoc, (int)lroundf(constrain(disp_tvoc, 0.0f, (float)TVOC_ARC_MAX))); lv_snprintf(buf, sizeof(buf), "%.0f / %.0f", min_eco2, max_eco2); lv_label_set_text(lbl_eco2_range, buf); lv_snprintf(buf, sizeof(buf), "%.0f / %.0f", min_tvoc, max_tvoc); lv_label_set_text(lbl_tvoc_range, buf); lv_label_set_text(lbl_eco2_lvl, eco2_level_text(disp_eco2)); lv_obj_set_style_text_color(lbl_eco2_lvl, ec, 0); lv_label_set_text(lbl_tvoc_lvl, tvoc_level_text(disp_tvoc)); lv_obj_set_style_text_color(lbl_tvoc_lvl, tc, 0); lv_color_t ac = aqi_color(disp_eco2, disp_tvoc); lv_label_set_text(lbl_aqi, aqi_text(disp_eco2, disp_tvoc)); lv_obj_set_style_text_color(lbl_aqi, ac, 0); anim_bar(bar_aqi, aqi_score(disp_eco2, disp_tvoc)); lv_obj_set_style_bg_color(bar_aqi, ac, LV_PART_INDICATOR); lv_chart_set_next_value(chart, ser_eco2, (int32_t)lroundf(disp_eco2)); lv_chart_set_next_value(chart, ser_tvoc, (int32_t)lroundf(disp_tvoc)); refresh_meta(); } else { lv_label_set_text(lbl_aqi, "ERROR"); lv_obj_set_style_text_color(lbl_aqi, COL_BAD, 0); lv_label_set_text(lbl_status, "ERR"); lv_led_set_color(led_status, COL_BAD); } } static void read_sgp(void) { if (!sgp.IAQmeasure()) { fail_streak++; Serial.printf("SGP30 fail(%u)\n", fail_streak); if (fail_streak >= SGP_FAIL_TOLERANCE) { sensor_ok = false; update_ui(false); } return; } fail_streak = 0; sensor_ok = true; last_ok_ms = millis(); float eco2 = (float)sgp.eCO2; float tvoc = (float)sgp.TVOC; if (!iaq_ready) { if ((millis() - boot_ms) / 1000 >= WARMUP_SEC) { iaq_ready = true; show_hint("IAQ ready", 1200); } else { /* Still show raw warmup readings */ } } disp_eco2 = ema(disp_eco2, eco2); disp_tvoc = ema(disp_tvoc, tvoc); if (isnan(min_eco2) || disp_eco2 < min_eco2) min_eco2 = disp_eco2; if (isnan(max_eco2) || disp_eco2 > max_eco2) max_eco2 = disp_eco2; if (isnan(min_tvoc) || disp_tvoc < min_tvoc) min_tvoc = disp_tvoc; if (isnan(max_tvoc) || disp_tvoc > max_tvoc) max_tvoc = disp_tvoc; Serial.printf("eCO2=%.0f(%.0f) ppm TVOC=%.0f(%.0f) ppb\n", disp_eco2, eco2, disp_tvoc, tvoc); update_ui(true); } void setup() { Serial.begin(115200); delay(200); boot_ms = millis(); ledcSetup(0, 5000, 8); ledcAttachPin(TFT_BL_PIN, 0); set_backlight(2); Serial.println("CYD LVGL SGP30 Air Quality Dashboard"); Serial.printf("LVGL %d.%d.%d SDA=GPIO%d SCL=GPIO%d\n", lv_version_major(), lv_version_minor(), lv_version_patch(), I2C_SDA, I2C_SCL); Wire.begin(I2C_SDA, I2C_SCL); Wire.setClock(100000); if (!sgp.begin()) { Serial.println("SGP30 not found — check wiring (SDA22/SCL27)"); } else { Serial.printf("SGP30 OK serial 0x%04X%04X\n", sgp.serialnumber[0], sgp.serialnumber[1]); /* Absolute humidity optional; skip until DHT available */ } lv_init(); touchscreenSPI.begin(XPT2046_CLK, XPT2046_MISO, XPT2046_MOSI, XPT2046_CS); touchscreen.begin(touchscreenSPI); touchscreen.setRotation(2); #if defined(CYD_BOARD_3248S035) lv_display_t *disp = lv_tft_espi_create(320, 480, draw_buf, sizeof(draw_buf)); #else lv_display_t *disp = lv_tft_espi_create(240, 320, draw_buf, sizeof(draw_buf)); #endif lv_display_set_rotation(disp, LV_DISPLAY_ROTATION_270); lv_indev_t *indev = lv_indev_create(); lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER); lv_indev_set_read_cb(indev, touchscreen_read); create_ui(); delay(200); last_sgp_ms = 0; } void loop() { uint32_t now = millis(); if (now - last_sgp_ms >= SGP_READ_MS) { last_sgp_ms = now; read_sgp(); } static uint32_t last_meta_ms = 0; if (now - last_meta_ms >= 1000) { last_meta_ms = now; refresh_meta(); } lv_timer_handler(); lv_tick_inc(5); delay(5); }