1. 下载 ZIP
先下载 02 源码包,完整解压,不要只拖出单个 main.ino。
02 是 DeepWarpHUD 新视频对应代码;01 代码在旧页面,不要混在一起发给客户。
先下载 02 源码包,完整解压,不要只拖出单个 main.ino。
推荐用 VS Code + PlatformIO 打开整个 DeepWarpHUD 文件夹,库和参数按 platformio.ini 固定。
这份代码按 ESP32 2.8 黄屏/CYD 资料整理;不同屏幕引脚或触摸芯片不同,需要改引脚。
| 文件 | 说明 |
|---|---|
src/main.ino | DeepWarpHUD 主程序,包含 UI、触摸、BMI160、番茄钟和自动转屏逻辑。 |
include/lv_conf.h | LVGL 精简配置,客户不要随便删。 |
platformio.ini | 固定 ESP32 Arduino 2.0.17、TFT_eSPI、LVGL 8.3.x 和屏幕引脚参数。 |
docs/ | 接线、库安装、配置说明,适合发给客户先看。 |
代码比较长,所以放进带滚动条的代码框里。客户要完整工程优先下载 ZIP;只改小地方时,可以直接点右上角复制。
主程序:UI、触摸、BMI160、番茄钟、自动转屏都在这里。
// ESP32-2432S028R (CYD) · Deep Warp HUD 时钟主体
//
// 建在已经实机验证过的基础上:
// - TFT_eSPI(硬件SPI) + LVGL 驱动,alpha混合实机验证通过。
// - BMI160在CN1(SCL=IO22蓝线/SDA=IO27黄线/VCC=3V3/GND),实机确认在I2C地址0x69。
//
// 时间来源说明:加了WiFi配网门户+NTP校时,还加了开机用编译时间起底
// (seedClockFromCompileTime),所以从第一帧起就有个大概准的北京时间显示,不是
// 从0开始跳的UPTIME计数。真实NTP同步后会自动覆盖成精确时间。配网方式:开机自带
// 热点CYD-Setup(密码见AP_PASS),手机连上后浏览器打开192.168.4.1选WiFi填密码提交,
// 凭证存在flash里,以后开机自动连。
//
// 转屏:参考了另一个并行项目(ESP32_中文触摸效率时钟)的"手机式自动转屏"思路——
// 倾斜跟番茄钟手势共用同一个ax轴信号,不再靠等待时长区分,倾斜稳定后立刻转,
// 具体见handlePomodoroGesture()。
//
// 2026-07-27:这个项目原来跟另一个并行Claude Code会话共用同一个文件夹
// 反复互相覆盖过好几次(main.ino和platformio.ini都中过),所以搬到这个独立文件夹,
// 以后不会再跟那边冲突。
#include <lvgl.h>
#include <TFT_eSPI.h>
#include <Wire.h>
#include <math.h>
#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <Preferences.h>
#include <time.h>
#include <sys/time.h>
// 加了第二个"简约风格"时钟页面(参考客户发的黑白天气钟截图),跟原来科幻风的
// Deep Warp HUD并列存在,两套场景代码都留着。这个宏只决定开机默认显示哪一个:
// 1=简约风格,0=Deep Warp HUD。运行时可以点屏幕切换(见下面触摸+菜单)。
#define ACTIVE_SCENE_MINIMAL 1
static const uint16_t SCREEN_W = 320;
static const uint16_t SCREEN_H = 240;
// 场景常驻内存(各自一个独立的lv_obj screen),点触摸切换用lv_scr_load瞬间换屏,
// 不用每次重新build。0=Deep Warp HUD,1=简约风格,2=BMI160测试页——这三个参与
// 点击循环切换(SCENE_COUNT)。番茄钟页是第四个,单独用pomodoroScreenShown这个
// flag控制,不算进循环里:手势触发时才会跳过去,跳过去之前activeScene是哪个就
//还是哪个,倒计时结束/用户点一下会自动回到原来那个。
static lv_obj_t *deepWarpScreen;
static lv_obj_t *minimalScreen;
static lv_obj_t *bmiTestScreen;
static lv_obj_t *pomodoroScreen;
static const uint8_t SCENE_COUNT = 3;
static uint8_t activeScene = ACTIVE_SCENE_MINIMAL ? 1 : 0;
static bool pomodoroScreenShown = false;
static lv_obj_t *sceneObj(uint8_t idx); // 定义在setup()前面,这里先声明给前面的函数用
TFT_eSPI tft = TFT_eSPI();
static lv_disp_draw_buf_t draw_buf;
static lv_color_t buf1[SCREEN_W * 40];
static void disp_flush(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) {
uint32_t w = area->x2 - area->x1 + 1;
uint32_t h = area->y2 - area->y1 + 1;
tft.startWrite();
tft.setAddrWindow(area->x1, area->y1, w, h);
tft.pushColors((uint16_t *)&color_p->full, w * h, true);
tft.endWrite();
lv_disp_flush_ready(disp);
}
// ---------------- 触摸(XPT2046位带SPI,独立总线,不走TFT硬件SPI) ----------------
// 2026-07-27:横屏rotation(1)下原始ADC值到屏幕像素的映射方向没标定,这一版先
// 不做精确坐标映射,菜单交互做成"屏幕上任意位置点一下就切换场景",不依赖具体
// 点在哪,规避坐标标定的风险。以后如果要做多个可点区域的菜单,再点几个角标定。
#define TOUCH_CLK 25
#define TOUCH_CS 33
#define TOUCH_DIN 32
#define TOUCH_DO 39
#define TOUCH_IRQ 36
static void touchInit() {
pinMode(TOUCH_CLK, OUTPUT);
pinMode(TOUCH_DIN, OUTPUT);
pinMode(TOUCH_CS, OUTPUT);
pinMode(TOUCH_DO, INPUT);
pinMode(TOUCH_IRQ, INPUT_PULLUP);
digitalWrite(TOUCH_CS, HIGH);
digitalWrite(TOUCH_CLK, LOW);
}
static uint16_t touchTransfer16(uint8_t cmd) {
uint16_t value = 0;
digitalWrite(TOUCH_CS, LOW);
for (uint8_t i = 0; i < 8; i++) {
digitalWrite(TOUCH_CLK, LOW);
digitalWrite(TOUCH_DIN, (cmd & 0x80) ? HIGH : LOW);
cmd <<= 1;
digitalWrite(TOUCH_CLK, HIGH);
}
for (uint8_t i = 0; i < 16; i++) {
digitalWrite(TOUCH_CLK, LOW);
digitalWrite(TOUCH_CLK, HIGH);
value <<= 1;
if (digitalRead(TOUCH_DO)) value |= 1;
}
digitalWrite(TOUCH_CS, HIGH);
return (value >> 3) & 0x0FFF;
}
// 只判断"有没有摁下",不算具体像素坐标(见上面注释,坐标映射还没标定)。
static bool isTouchedNow() {
if (digitalRead(TOUCH_IRQ) != LOW) return false;
uint16_t rawX = touchTransfer16(0xD0);
uint16_t rawY = touchTransfer16(0x90);
return rawX >= 120 && rawX <= 4000 && rawY >= 120 && rawY <= 4000;
}
// ---------------- BMI160 (最小寄存器读写,没依赖第三方库) ----------------
#define BMI_SDA_PIN 27
#define BMI_SCL_PIN 22
static uint8_t bmiAddr = 0; // 0 = 没找到
static bool bmiWriteReg(uint8_t reg, uint8_t val) {
Wire.beginTransmission(bmiAddr);
Wire.write(reg);
Wire.write(val);
return Wire.endTransmission() == 0;
}
static bool bmiReadRegs(uint8_t reg, uint8_t *buf, uint8_t len) {
Wire.beginTransmission(bmiAddr);
Wire.write(reg);
if (Wire.endTransmission(false) != 0) return false;
Wire.requestFrom((int)bmiAddr, (int)len);
for (uint8_t i = 0; i < len; i++) {
if (!Wire.available()) return false;
buf[i] = Wire.read();
}
return true;
}
static bool bmiInit() {
Wire.begin(BMI_SDA_PIN, BMI_SCL_PIN);
Wire.setTimeOut(50); // 没这行的话,I2C一旦卡住Wire.requestFrom会死等,整个loop()跟着卡死
Wire.setClock(400000);
uint8_t candidates[2] = {0x68, 0x69};
for (uint8_t i = 0; i < 2; i++) {
bmiAddr = candidates[i];
uint8_t chipId = 0;
if (bmiReadRegs(0x00, &chipId, 1) && chipId == 0xD1) {
bmiWriteReg(0x7E, 0x11); // CMD: 加速度计切到normal模式
delay(50);
bmiWriteReg(0x40, 0x28); // ACC_CONF: normal filter, ODR 100Hz
bmiWriteReg(0x41, 0x03); // ACC_RANGE: +-2g
return true;
}
}
bmiAddr = 0;
return false;
}
static bool bmiReadAccel(float &ax, float &ay, float &az) {
if (!bmiAddr) return false;
uint8_t raw[6];
if (!bmiReadRegs(0x12, raw, 6)) return false;
int16_t rx = (int16_t)((raw[1] << 8) | raw[0]);
int16_t ry = (int16_t)((raw[3] << 8) | raw[2]);
int16_t rz = (int16_t)((raw[5] << 8) | raw[4]);
const float scale = 2.0f / 32768.0f; // +-2g量程
ax = rx * scale;
ay = ry * scale;
az = rz * scale;
return true;
}
// ---------------- 重力转屏 + 番茄钟手势 ----------------
static uint8_t activeRotation = 1;
// ---------------- 番茄钟手势(左倾10分钟/右倾30分钟,BMI160最初加进来就是为了
// 这个功能,现在才真正接上)----------------
// 轴向/阈值先照抄另一个并行会话已经用过的参考值(ax轴、0.45阈值),那边验证过能
// 摸出手势,但具体哪边倒对应哪个时长在这块板子上还没实机确认,大概率要现场调。
static bool pomodoroActive = false;
static uint32_t pomodoroEndMs = 0;
static uint32_t pomodoroDoneUntilMs = 0; // 倒计时结束后"DONE"提示保留到这个时间点
static uint8_t pomodoroMinutes = 0; // 这次是10还是30分钟,番茄钟页标题用
static const uint32_t POMODORO_10MIN_MS = 10UL * 60UL * 1000UL;
static const uint32_t POMODORO_30MIN_MS = 30UL * 60UL * 1000UL;
static const float GESTURE_THRESHOLD_G = 0.45f;
static const uint32_t GESTURE_STABLE_MS = 800; // 倾斜要维持这么久才算数,避免拿起/挪动板子时误触发
static uint8_t gestureCandidate = 0; // 0=水平, 1=左倾, 2=右倾
static uint32_t gestureCandidateSinceMs = 0;
// 2026-07-27修复:原来是"触发一次后必须先回到接近水平才能再触发",结果从左倒
// 直接倒向右边(没经过水平)会一直卡在锁定状态,右倾没反应。改成记"上一次触发的
// 是哪个方向"——同一个方向不重复触发(避免一直倒着不动反复重开),但换成另一个
// 方向可以立刻触发,不用先回水平。回到水平会解锁两个方向。
static uint8_t lastTriggeredCandidate = 0;
// 触发后直接跳到独立的番茄钟大字页面(pomodoroScreen),不再是塞进原来页面的小字。
// activeScene不动——番茄钟页只是临时盖在上面,倒计时结束或用户点一下会自动切回
// 触发前那个页面。
static void handlePomodoroGesture(float ax, float ay, uint32_t now) {
uint8_t candidate = 0;
// ay变化比ax更大,说明这次动作更像转屏的那种翻转,不当手势处理。
if (fabsf(ax) > fabsf(ay)) {
if (ax > GESTURE_THRESHOLD_G) candidate = 2;
else if (ax < -GESTURE_THRESHOLD_G) candidate = 1;
}
if (candidate == 0) {
lastTriggeredCandidate = 0; // 回到水平,两个方向重新解锁
}
if (candidate != gestureCandidate) {
gestureCandidate = candidate;
gestureCandidateSinceMs = now;
}
bool stable = candidate != 0 && (now - gestureCandidateSinceMs > GESTURE_STABLE_MS);
if (stable && candidate != lastTriggeredCandidate) {
pomodoroActive = true;
pomodoroDoneUntilMs = 0;
pomodoroMinutes = (candidate == 1) ? 10 : 30;
pomodoroEndMs = now + (candidate == 1 ? POMODORO_10MIN_MS : POMODORO_30MIN_MS);
lastTriggeredCandidate = candidate;
pomodoroScreenShown = true;
lv_scr_load(pomodoroScreen);
}
// 2026-07-27改:参考另一个并行项目(ESP32_中文触摸效率时钟)的做法——不再等
// 额外的ROTATION_HOLD_MS,倾斜跟计时共用同一个GESTURE_STABLE_MS稳定判定,
// 稳定了就立刻转,跟手机自动转屏一样"倒下去就转"。不用lastTriggeredCandidate
// 锁:人一直倒着不动,屏幕方向保持对的就行,不是"只触发一次"的事件。
// 放平(candidate==0)时不强制转回默认方向——重力在放平时分不出左右,这里跟
// 手机一样"保持最后一次有效方向",不瞎猜。
// 哪个方向对应rotation 1/3还没现场核对过,如果实机看到倒的方向跟屏幕转向反了
// (差180度),把下面这行的1和3调换一下就行。
if (stable) {
uint8_t wantRotation = (candidate == 1) ? 1 : 3;
if (wantRotation != activeRotation) {
activeRotation = wantRotation;
tft.setRotation(activeRotation);
lv_obj_invalidate(lv_scr_act());
}
}
}
// ---------------- WiFi配网门户 + NTP校时 ----------------
// 开机常驻一个配网热点(AP),同时也尝试用flash里存的凭证去连客户家/办公室
// WiFi(STA),两个模式同时开(WIFI_AP_STA)。手机连上热点后大多数系统会自动弹出
// 配网页(靠DNS把所有域名都指向本机+网页请求兜底重定向实现,即"土法captive
// portal");也可以手动打开192.168.4.1。
//
// AP_SSID/AP_PASS现在是占位名字,卖客户前记得改成正式产品名/合适的默认密码。
static const char *AP_SSID = "CYD-Setup"; // TODO(卖客户前必改): 占位热点名,换成正式产品名
static const char *AP_PASS = "00000000"; // AP密码(>=8位),图方便改成纯数字,量产前再定
static Preferences prefs;
static WebServer webServer(80);
static DNSServer dnsServer;
static const byte DNS_PORT = 53;
static String staSsid, staPass;
static bool staStarted = false;
static bool timeConfigured = false;
static void loadWifiConfig() {
prefs.begin("dwhud", false);
staSsid = prefs.getString("ssid", "");
staPass = prefs.getString("pass", "");
}
static void saveWifiConfig() {
prefs.putString("ssid", staSsid);
prefs.putString("pass", staPass);
}
static void startStaConnect() {
if (staSsid.length() == 0) return;
WiFi.begin(staSsid.c_str(), staPass.c_str());
staStarted = true;
}
static void handleRoot() {
String html = "<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>时钟WiFi设置</title>"
"<style>body{font-family:sans-serif;background:#04050c;color:#eef6ff;"
"padding:20px;max-width:400px;margin:auto}h1{font-size:20px}"
"select,input{width:100%;padding:10px;margin:8px 0;box-sizing:border-box;"
"border-radius:6px;border:1px solid #445;background:#10152a;color:#eef6ff}"
"button{width:100%;padding:12px;background:#e7b23e;color:#04050c;"
"border:none;border-radius:6px;font-weight:bold}</style></head><body>"
"<h1>Deep Warp HUD · WiFi设置</h1>"
"<form method='POST' action='/save'>"
"<label>选择WiFi</label><select name='ssid'>";
int netCount = WiFi.scanNetworks();
for (int i = 0; i < netCount; i++) {
html += "<option value='" + WiFi.SSID(i) + "'>" + WiFi.SSID(i) +
" (" + String(WiFi.RSSI(i)) + "dBm)</option>";
}
html += "</select>"
"<label>WiFi密码</label><input type='password' name='pass'>"
"<button type='submit'>保存并连接</button>"
"</form><p style='opacity:.6;font-size:12px'>保存后设备会尝试连接,"
"连上后时钟会自动校准真实时间。</p></body></html>";
webServer.send(200, "text/html; charset=utf-8", html);
}
static void handleSave() {
if (webServer.hasArg("ssid")) staSsid = webServer.arg("ssid");
if (webServer.hasArg("pass")) staPass = webServer.arg("pass");
saveWifiConfig();
timeConfigured = false;
startStaConnect();
webServer.send(200, "text/html; charset=utf-8",
"<meta charset='utf-8'><body style='font-family:sans-serif;background:#04050c;"
"color:#eef6ff;padding:20px'>已保存,设备正在连接,稍后看屏幕状态。</body>");
}
// 没连WiFi/没校时前,之前是显示UPTIME计数(从开机0开始跳),用户反馈没意义。
// 改成开机先用编译时间起个底——假设编译这台电脑的系统时间是北京时间,直接当
// 系统时钟的初始值,比UPTIME有意义。真实NTP同步后配置的时区偏移会生效,系统
// 时间也会被SNTP自动纠正成准确值,这个"底"只是过渡用,不冲突。
static void seedClockFromCompileTime() {
struct tm ti = {};
char monStr[4] = {0};
int day = 1, year = 1970, hour = 0, minv = 0, sec = 0;
sscanf(__DATE__, "%3s %d %d", monStr, &day, &year);
sscanf(__TIME__, "%d:%d:%d", &hour, &minv, &sec);
static const char *months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
for (int i = 0; i < 12; i++) {
if (strncmp(monStr, months[i], 3) == 0) {
ti.tm_mon = i;
break;
}
}
ti.tm_year = year - 1900;
ti.tm_mday = day;
ti.tm_hour = hour;
ti.tm_min = minv;
ti.tm_sec = sec;
time_t t = mktime(&ti);
struct timeval tv = {t, 0};
settimeofday(&tv, nullptr);
}
static void handleCaptivePortalRedirect() {
// 手机系统探测captive portal时会请求各种奇怪路径,统一302回首页,
// 大部分系统看到跳转就会自动弹出配网页,不用用户手动输网址。
webServer.sendHeader("Location", String("http://") + WiFi.softAPIP().toString() + "/", true);
webServer.send(302, "text/plain", "");
}
static void setupWifi() {
Serial.println("WIFI: mode AP_STA...");
WiFi.mode(WIFI_AP_STA);
Serial.println("WIFI: mode done, setTxPower...");
// 加WiFi后板子会瞬间掉电重连(USB口都消失)、屏幕出现半屏花纹——典型的radio上电
// 瞬间电流冲高、供电扛不住导致欠压。降低发射功率减小冲击电流。
// 屏幕亮度还会跟着WiFi周期性发信标包轻微跳动——供电裕量不够是根子上的问题
// (需要硬件加电容才能根治),这里再把功率往下压一档、开modem sleep减少非发送
// 时段的电流,尽量把软件层面能压的都压一压。
// 最终结论:用户换了个更稳的供电源之后这个抖动就消失了,确认是纯供电问题,
// 不是代码能完全解决的,这里的降功率/sleep保留作为额外的稳妥措施。
WiFi.setTxPower(WIFI_POWER_2dBm);
WiFi.setSleep(true);
Serial.println("WIFI: setTxPower done, softAP...");
WiFi.softAP(AP_SSID, AP_PASS);
Serial.println("WIFI: softAP done, dnsServer.start...");
dnsServer.start(DNS_PORT, "*", WiFi.softAPIP());
Serial.println("WIFI: dns done, loadWifiConfig...");
loadWifiConfig();
Serial.print("WIFI: config loaded, saved ssid len=");
Serial.println(staSsid.length());
startStaConnect();
Serial.println("WIFI: startStaConnect done, registering routes...");
webServer.on("/", HTTP_GET, handleRoot);
webServer.on("/save", HTTP_POST, handleSave);
webServer.onNotFound(handleCaptivePortalRedirect);
Serial.println("WIFI: routes registered, webServer.begin...");
webServer.begin();
Serial.println("WIFI: setupWifi() fully done.");
}
// ================= Deep Warp HUD 场景 =================
#define STAR_COUNT 40
static lv_obj_t *starObj[STAR_COUNT];
static float starX[STAR_COUNT], starY[STAR_COUNT], starZ[STAR_COUNT];
static void starRespawn(uint8_t i) {
starX[i] = ((float)random(-1000, 1000) / 1000.0f) * (SCREEN_W / 2);
starY[i] = ((float)random(-1000, 1000) / 1000.0f) * (SCREEN_H / 2);
starZ[i] = (float)SCREEN_W;
}
static lv_obj_t *secArc;
static lv_obj_t *timeLabel;
static lv_obj_t *uptimeLabel;
static lv_obj_t *radarLine;
static lv_point_t radarPoints[2]; // lv_line_set_points只存指针不拷贝数据,数组必须常驻
static lv_obj_t *dbgLabel;
static const lv_point_t bracketPointsTL[3] = {{0, 14}, {0, 0}, {14, 0}};
static const lv_point_t bracketPointsTR[3] = {{0, 0}, {14, 0}, {14, 14}};
static const lv_point_t bracketPointsBL[3] = {{0, 0}, {0, 14}, {14, 14}};
static const lv_point_t bracketPointsBR[3] = {{14, 0}, {14, 14}, {0, 14}};
static void addBracket(lv_obj_t *scr, const lv_point_t *pts, lv_align_t align, lv_coord_t ox, lv_coord_t oy) {
lv_obj_t *line = lv_line_create(scr);
lv_line_set_points(line, pts, 3);
lv_obj_set_style_line_color(line, lv_color_hex(0x8cd7ff), 0);
lv_obj_set_style_line_width(line, 2, 0);
lv_obj_set_style_line_opa(line, LV_OPA_60, 0);
lv_obj_set_style_line_rounded(line, true, 0);
lv_obj_align(line, align, ox, oy);
}
static void build_deep_warp_hud(lv_obj_t *scr) {
lv_obj_set_style_bg_color(scr, lv_color_hex(0x04050c), 0);
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
// 星云背景色块(静态,不动,便宜)
lv_obj_t *nebA = lv_obj_create(scr);
lv_obj_remove_style_all(nebA);
lv_obj_set_size(nebA, 140, 140);
lv_obj_set_style_radius(nebA, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(nebA, lv_color_hex(0x50b4dc), 0);
lv_obj_set_style_bg_opa(nebA, LV_OPA_10, 0);
lv_obj_align(nebA, LV_ALIGN_TOP_LEFT, -30, -30);
lv_obj_t *nebB = lv_obj_create(scr);
lv_obj_remove_style_all(nebB);
lv_obj_set_size(nebB, 150, 150);
lv_obj_set_style_radius(nebB, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(nebB, lv_color_hex(0x965ade), 0);
lv_obj_set_style_bg_opa(nebB, LV_OPA_10, 0);
lv_obj_align(nebB, LV_ALIGN_TOP_RIGHT, 30, -20);
// 行星(右上角,静态)。光环这个装饰试了三版(正圆/胶囊边框/斜线)都不够好看或
// 不好使,跟用户确认过直接去掉,只留干净的圆行星。
lv_obj_t *planet = lv_obj_create(scr);
lv_obj_remove_style_all(planet);
lv_obj_set_size(planet, 20, 20);
lv_obj_set_style_radius(planet, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(planet, lv_color_hex(0xd7e2ff), 0);
lv_obj_set_style_bg_opa(planet, LV_OPA_COVER, 0);
lv_obj_align(planet, LV_ALIGN_TOP_RIGHT, -46, 34);
// 星场(40个小点,warp透视效果,loop里定时更新位置)
for (uint8_t i = 0; i < STAR_COUNT; i++) {
starRespawn(i);
starZ[i] = (float)random(1, SCREEN_W);
lv_obj_t *s = lv_obj_create(scr);
lv_obj_remove_style_all(s);
lv_obj_set_size(s, 2, 2);
lv_obj_set_style_radius(s, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(s, lv_color_hex(0xd8e6ff), 0);
lv_obj_set_style_bg_opa(s, LV_OPA_70, 0);
starObj[i] = s;
}
// 雷达扫描线:之前用transform_angle转一根lv_obj的做法不生效(这个LVGL构建下
// transform_angle不生效),表现是屏幕中间冻着一根不转的横杠。改成每帧直接用
// sin/cos算出线段两个端点坐标,用lv_line重新setPoints,不依赖transform_angle。
radarLine = lv_line_create(scr);
lv_obj_remove_style_all(radarLine);
lv_obj_set_style_line_color(radarLine, lv_color_hex(0x78cdff), 0);
lv_obj_set_style_line_width(radarLine, 2, 0);
lv_obj_set_style_line_opa(radarLine, LV_OPA_50, 0);
lv_obj_set_style_line_rounded(radarLine, true, 0);
radarPoints[0].x = SCREEN_W / 2;
radarPoints[0].y = SCREEN_H / 2 + 6;
radarPoints[1].x = SCREEN_W / 2;
radarPoints[1].y = SCREEN_H / 2 + 6 - 85;
lv_line_set_points(radarLine, radarPoints, 2);
// 秒进度环(发光效果靠外层阴影凑)
secArc = lv_arc_create(scr);
lv_obj_remove_style_all(secArc);
lv_obj_set_size(secArc, 170, 170);
lv_arc_set_bg_angles(secArc, 0, 360);
lv_arc_set_rotation(secArc, 270);
lv_arc_set_range(secArc, 0, 59);
lv_arc_set_value(secArc, 0);
lv_obj_set_style_arc_color(secArc, lv_color_hex(0x2a2f45), LV_PART_MAIN);
lv_obj_set_style_arc_opa(secArc, LV_OPA_40, LV_PART_MAIN);
lv_obj_set_style_arc_width(secArc, 3, LV_PART_MAIN);
lv_obj_set_style_arc_color(secArc, lv_color_hex(0xe7b23e), LV_PART_INDICATOR);
lv_obj_set_style_arc_width(secArc, 4, LV_PART_INDICATOR);
lv_obj_set_style_arc_rounded(secArc, true, LV_PART_INDICATOR);
lv_obj_set_style_shadow_width(secArc, 14, LV_PART_INDICATOR);
lv_obj_set_style_shadow_color(secArc, lv_color_hex(0xe7b23e), LV_PART_INDICATOR);
lv_obj_set_style_shadow_opa(secArc, LV_OPA_50, LV_PART_INDICATOR);
lv_obj_clear_flag(secArc, LV_OBJ_FLAG_CLICKABLE);
lv_obj_align(secArc, LV_ALIGN_CENTER, 0, 6);
// 四角全息取景框
addBracket(scr, bracketPointsTL, LV_ALIGN_TOP_LEFT, 10, 10);
addBracket(scr, bracketPointsTR, LV_ALIGN_TOP_RIGHT, -10, 10);
addBracket(scr, bracketPointsBL, LV_ALIGN_BOTTOM_LEFT, 10, -10);
addBracket(scr, bracketPointsBR, LV_ALIGN_BOTTOM_RIGHT, -10, -10);
// 时间大字。2026-07-27证实:跟简约页之前一样,transform_zoom放大文字压根不
// 渲染,这里从来没真正显示出来过。换成原生48px字体,不用变换效果。
timeLabel = lv_label_create(scr);
lv_label_set_text(timeLabel, "00:00");
lv_obj_set_style_text_color(timeLabel, lv_color_hex(0xeef6ff), 0);
lv_obj_set_style_text_font(timeLabel, &lv_font_montserrat_48, 0);
lv_obj_set_style_shadow_width(timeLabel, 18, 0);
lv_obj_set_style_shadow_color(timeLabel, lv_color_hex(0x8cd7ff), 0);
lv_obj_set_style_shadow_opa(timeLabel, LV_OPA_50, 0);
lv_obj_align(timeLabel, LV_ALIGN_CENTER, 0, -8);
uptimeLabel = lv_label_create(scr);
lv_label_set_text(uptimeLabel, "WIFI SETUP: JOIN \"CYD-Setup\"");
lv_obj_set_style_text_color(uptimeLabel, lv_color_hex(0x9ab0cf), 0);
lv_obj_align(uptimeLabel, LV_ALIGN_CENTER, 0, 36);
// 底部BMI160调试小字,标定还没做,先留着方便以后接着调
dbgLabel = lv_label_create(scr);
lv_label_set_text(dbgLabel, "BMI160 init...");
lv_obj_set_style_text_color(dbgLabel, lv_color_hex(0x4a5568), 0);
lv_obj_align(dbgLabel, LV_ALIGN_BOTTOM_LEFT, 6, -4);
lv_obj_t *hint = lv_label_create(scr);
lv_label_set_text(hint, "TAP TO SWITCH VIEW");
lv_obj_set_style_text_color(hint, lv_color_hex(0x3a4258), 0);
lv_obj_align(hint, LV_ALIGN_TOP_MID, 0, 4);
}
// ================= 简约风格场景(参考客户发的黑白天气钟截图) =================
// 空气质量/湿度/舒适度这几项现在是占位文字,没接真实数据源。真要接的话调
// api.open-meteo.com就行,现在先把版式做出来看效果。
static lv_obj_t *minAqiLabel;
static lv_obj_t *minTimeLabel;
static lv_obj_t *minSecLabel;
static lv_obj_t *minDateLabel;
static lv_obj_t *minSunIcon;
static lv_obj_t *minConditionLabel;
static lv_obj_t *minHumComfortLabel;
static lv_obj_t *minBmiLabel;
static lv_obj_t *minStatusLabel;
static void build_minimal_clock(lv_obj_t *scr) {
lv_obj_set_style_bg_color(scr, lv_color_hex(0xf3f5f7), 0);
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
const lv_color_t ink = lv_color_hex(0x1c2430);
const lv_color_t gray = lv_color_hex(0x8a94a6);
minAqiLabel = lv_label_create(scr);
lv_label_set_text(minAqiLabel, "AQI GOOD");
lv_obj_set_style_text_color(minAqiLabel, lv_color_hex(0x1f7a3d), 0);
lv_obj_set_style_bg_color(minAqiLabel, lv_color_hex(0xd9f2df), 0);
lv_obj_set_style_bg_opa(minAqiLabel, LV_OPA_COVER, 0);
lv_obj_set_style_radius(minAqiLabel, 6, 0);
lv_obj_set_style_pad_hor(minAqiLabel, 8, 0);
lv_obj_set_style_pad_ver(minAqiLabel, 4, 0);
lv_obj_align(minAqiLabel, LV_ALIGN_TOP_LEFT, 12, 10);
minTimeLabel = lv_label_create(scr);
lv_label_set_text(minTimeLabel, "00:00");
lv_obj_set_style_text_color(minTimeLabel, ink, 0);
// 踩坑:用transform_zoom放大文字,加阴影也救不回来,一直没渲染出来(Deep Warp
// HUD那边的大时间字大概率也一直是同一个问题,只是没细看漏掉了)。换思路:不用
// 变换效果,直接用LVGL自带的48px大字体,原生字体清晰、也不用再猜transform
// 哪里坏了。
lv_obj_set_style_text_font(minTimeLabel, &lv_font_montserrat_48, 0);
lv_obj_align(minTimeLabel, LV_ALIGN_CENTER, -14, -18);
minSecLabel = lv_label_create(scr);
lv_label_set_text(minSecLabel, "00");
lv_obj_set_style_text_color(minSecLabel, gray, 0);
lv_obj_align_to(minSecLabel, minTimeLabel, LV_ALIGN_OUT_RIGHT_TOP, 4, 4);
minDateLabel = lv_label_create(scr);
lv_label_set_text(minDateLabel, "---");
lv_obj_set_style_text_color(minDateLabel, gray, 0);
lv_obj_align(minDateLabel, LV_ALIGN_CENTER, 0, 30);
// 分隔线
static lv_point_t dividerPts[2] = {{20, 0}, {300, 0}};
lv_obj_t *divider = lv_line_create(scr);
lv_line_set_points(divider, dividerPts, 2);
lv_obj_set_style_line_color(divider, lv_color_hex(0xe2e6ea), 0);
lv_obj_set_style_line_width(divider, 2, 0);
lv_obj_align(divider, LV_ALIGN_BOTTOM_LEFT, 0, -58);
minSunIcon = lv_obj_create(scr);
lv_obj_remove_style_all(minSunIcon);
lv_obj_set_size(minSunIcon, 22, 22);
lv_obj_set_style_radius(minSunIcon, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(minSunIcon, lv_color_hex(0xf5b942), 0);
lv_obj_set_style_bg_opa(minSunIcon, LV_OPA_COVER, 0);
lv_obj_align(minSunIcon, LV_ALIGN_BOTTOM_LEFT, 14, -36);
minConditionLabel = lv_label_create(scr);
lv_label_set_text(minConditionLabel, "CLEAR");
lv_obj_set_style_text_color(minConditionLabel, ink, 0);
lv_obj_align_to(minConditionLabel, minSunIcon, LV_ALIGN_OUT_RIGHT_MID, 8, 0);
minHumComfortLabel = lv_label_create(scr);
lv_label_set_text(minHumComfortLabel, "HUM 60% - COMFY");
lv_obj_set_style_text_color(minHumComfortLabel, ink, 0);
lv_obj_align(minHumComfortLabel, LV_ALIGN_BOTTOM_RIGHT, -14, -32);
minBmiLabel = lv_label_create(scr);
lv_label_set_text(minBmiLabel, "BMI --");
lv_obj_set_style_text_color(minBmiLabel, gray, 0);
lv_obj_align(minBmiLabel, LV_ALIGN_BOTTOM_LEFT, 10, -6);
// 底部两个状态字之前用完整长句会在屏幕中间撞在一起,改成短版本。
minStatusLabel = lv_label_create(scr);
lv_label_set_text(minStatusLabel, "WIFI: SETUP");
lv_obj_set_style_text_color(minStatusLabel, gray, 0);
lv_obj_align(minStatusLabel, LV_ALIGN_BOTTOM_RIGHT, -10, -6);
// 之前放TOP_MID跟左边的AQI胶囊在同一行撞在一起了,挪到胶囊正下方错开。
lv_obj_t *hint = lv_label_create(scr);
lv_label_set_text(hint, "TAP TO SWITCH VIEW");
lv_obj_set_style_text_color(hint, gray, 0);
lv_obj_align(hint, LV_ALIGN_TOP_LEFT, 12, 34);
}
// ================= BMI160测试页(跟I2C/其他信息分开,专门看模块是否正常+手势判断) =================
static lv_obj_t *bmiFoundLabel;
static lv_obj_t *bmiAddrLabel;
static lv_obj_t *bmiXyzLabel;
static lv_obj_t *bmiTiltLabel;
static void build_bmi_test_page(lv_obj_t *scr) {
lv_obj_set_style_bg_color(scr, lv_color_hex(0x0c1018), 0);
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
const lv_color_t ink = lv_color_hex(0xeef6ff);
const lv_color_t gray = lv_color_hex(0x8a94a6);
lv_obj_t *title = lv_label_create(scr);
lv_label_set_text(title, "BMI160 TEST");
lv_obj_set_style_text_color(title, ink, 0);
lv_obj_align(title, LV_ALIGN_TOP_LEFT, 12, 10);
bmiFoundLabel = lv_label_create(scr);
lv_label_set_text(bmiFoundLabel, "FOUND: --");
lv_obj_set_style_text_color(bmiFoundLabel, gray, 0);
lv_obj_align(bmiFoundLabel, LV_ALIGN_TOP_LEFT, 12, 40);
bmiAddrLabel = lv_label_create(scr);
lv_label_set_text(bmiAddrLabel, "ADDR: --");
lv_obj_set_style_text_color(bmiAddrLabel, gray, 0);
lv_obj_align(bmiAddrLabel, LV_ALIGN_TOP_LEFT, 12, 64);
bmiXyzLabel = lv_label_create(scr);
lv_label_set_text(bmiXyzLabel, "X 0.00 Y 0.00 Z 0.00");
lv_obj_set_style_text_color(bmiXyzLabel, ink, 0);
lv_obj_align(bmiXyzLabel, LV_ALIGN_CENTER, 0, -10);
bmiTiltLabel = lv_label_create(scr);
lv_label_set_text(bmiTiltLabel, "LEVEL");
lv_obj_set_style_text_font(bmiTiltLabel, &lv_font_montserrat_48, 0);
lv_obj_set_style_text_color(bmiTiltLabel, lv_color_hex(0xe7b23e), 0);
lv_obj_align(bmiTiltLabel, LV_ALIGN_CENTER, 0, 50);
lv_obj_t *hint = lv_label_create(scr);
lv_label_set_text(hint, "TAP TO SWITCH VIEW");
lv_obj_set_style_text_color(hint, gray, 0);
lv_obj_align(hint, LV_ALIGN_TOP_RIGHT, -12, 10);
}
static void updateBmiTestPage(float ax, float ay, float az) {
char addrBuf[16];
snprintf(addrBuf, sizeof(addrBuf), "ADDR: 0x%02X", bmiAddr);
lv_label_set_text(bmiFoundLabel, bmiAddr ? "FOUND: YES" : "FOUND: NO");
lv_label_set_text(bmiAddrLabel, bmiAddr ? addrBuf : "ADDR: --");
char xyzBuf[32];
snprintf(xyzBuf, sizeof(xyzBuf), "X %.2f Y %.2f Z %.2f", ax, ay, az);
lv_label_set_text(bmiXyzLabel, xyzBuf);
// 跟handlePomodoroGesture用的是同一个阈值(GESTURE_THRESHOLD_G/ax轴),这页看到
// 的判断结果就是手势逻辑实际会用的结果,方便直接对着这页调阈值/轴向。
const char *tilt = "LEVEL";
lv_color_t tiltColor = lv_color_hex(0x8a94a6);
if (ax > GESTURE_THRESHOLD_G) {
tilt = "RIGHT";
tiltColor = lv_color_hex(0xe7b23e);
} else if (ax < -GESTURE_THRESHOLD_G) {
tilt = "LEFT";
tiltColor = lv_color_hex(0xe7b23e);
}
lv_label_set_text(bmiTiltLabel, tilt);
lv_obj_set_style_text_color(bmiTiltLabel, tiltColor, 0);
}
// ================= 番茄钟页(手势触发后自动跳过来,大字倒计时) =================
static lv_obj_t *pomodoroTitleLabel;
static lv_obj_t *pomodoroBigLabel;
static lv_obj_t *pomodoroSubLabel;
static void build_pomodoro_page(lv_obj_t *scr) {
lv_obj_set_style_bg_color(scr, lv_color_hex(0x1a1206), 0); // 暖色调,跟另外几个场景区分开
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
const lv_color_t ink = lv_color_hex(0xffe9c2);
pomodoroTitleLabel = lv_label_create(scr);
lv_label_set_text(pomodoroTitleLabel, "POMODORO");
lv_obj_set_style_text_color(pomodoroTitleLabel, ink, 0);
lv_obj_align(pomodoroTitleLabel, LV_ALIGN_TOP_MID, 0, 14);
pomodoroBigLabel = lv_label_create(scr);
lv_label_set_text(pomodoroBigLabel, "00:00");
lv_obj_set_style_text_color(pomodoroBigLabel, lv_color_hex(0xe7b23e), 0);
lv_obj_set_style_text_font(pomodoroBigLabel, &lv_font_montserrat_48, 0);
lv_obj_align(pomodoroBigLabel, LV_ALIGN_CENTER, 0, 0);
pomodoroSubLabel = lv_label_create(scr);
lv_label_set_text(pomodoroSubLabel, "TAP TO DISMISS");
lv_obj_set_style_text_color(pomodoroSubLabel, lv_color_hex(0xb8956a), 0);
lv_obj_align(pomodoroSubLabel, LV_ALIGN_BOTTOM_MID, 0, -14);
}
static void updatePomodoroPage(uint32_t nowMs) {
if (pomodoroActive) {
int32_t remainMs = (int32_t)(pomodoroEndMs - nowMs);
if (remainMs <= 0) {
pomodoroActive = false;
pomodoroDoneUntilMs = nowMs + 10000; // DONE留10秒,之后自动切回原页面
lv_label_set_text(pomodoroBigLabel, "DONE");
} else {
uint32_t remainSec = (uint32_t)remainMs / 1000;
char buf[8];
snprintf(buf, sizeof(buf), "%02u:%02u", remainSec / 60, remainSec % 60);
lv_label_set_text(pomodoroBigLabel, buf);
char titleBuf[16];
snprintf(titleBuf, sizeof(titleBuf), "POMODORO %umin", pomodoroMinutes);
lv_label_set_text(pomodoroTitleLabel, titleBuf);
}
} else if (pomodoroDoneUntilMs > 0 && nowMs >= pomodoroDoneUntilMs) {
pomodoroDoneUntilMs = 0;
pomodoroScreenShown = false;
lv_scr_load(sceneObj(activeScene));
}
}
// ---------------- 每帧/每秒更新 ----------------
static uint32_t lastStarMs = 0;
static uint32_t lastDigitMs = 0;
static uint32_t lastAccelMs = 0;
static float radarAngleDeg = 0;
static void updateStars(uint32_t nowMs) {
static uint32_t lastMs = 0;
float dt = lastMs == 0 ? 16.0f : (float)(nowMs - lastMs);
lastMs = nowMs;
const float speed = 0.09f * dt; // 速度跟帧间隔挂钩,帧率抖动时观感稳定
for (uint8_t i = 0; i < STAR_COUNT; i++) {
starZ[i] -= speed;
if (starZ[i] <= 1.0f) {
starRespawn(i);
continue;
}
float sx = SCREEN_W / 2.0f + starX[i] / starZ[i] * SCREEN_W;
float sy = SCREEN_H / 2.0f + starY[i] / starZ[i] * SCREEN_H;
if (sx < -4 || sx > SCREEN_W + 4 || sy < -4 || sy > SCREEN_H + 4) {
starRespawn(i);
continue;
}
lv_obj_set_pos(starObj[i], (lv_coord_t)sx, (lv_coord_t)sy);
}
radarAngleDeg += dt * 0.06f;
if (radarAngleDeg >= 360.0f) radarAngleDeg -= 360.0f;
float rad = radarAngleDeg * (float)M_PI / 180.0f;
radarPoints[1].x = (lv_coord_t)(SCREEN_W / 2 + sinf(rad) * 85.0f);
radarPoints[1].y = (lv_coord_t)(SCREEN_H / 2 + 6 - cosf(rad) * 85.0f);
lv_line_set_points(radarLine, radarPoints, 2); // 重新setPoints会带上invalidate,触发重绘
}
static const char *WEEKDAY_ABBR[7] = {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"};
static const char *MONTH_ABBR[12] = {"JAN", "FEB", "MAR", "APR", "MAY", "JUN",
"JUL", "AUG", "SEP", "OCT", "NOV", "DEC"};
// 两个场景(Deep Warp HUD/简约风格)共用:拿当前时间。开机时setup()里已经用编译
// 时间起了个底(seedClockFromCompileTime),所以getLocalTime()从第一帧起就基本
// 总能成功——是不是"真的经过NTP校准"跟"有没有可显示的时间"是两件事,分开判断,
// 状态文字看WiFi/timeConfigured,不看这个返回值。默认字体montserrat_14没有中文
// 字形,所以两个场景屏幕上的文字都只能是英文/数字。
static bool getCurrentTime(uint32_t nowMs, uint16_t &hh, uint8_t &mm, uint8_t &ss, struct tm &ti) {
bool haveClock = getLocalTime(&ti, 5);
if (haveClock) {
hh = ti.tm_hour;
mm = ti.tm_min;
ss = ti.tm_sec;
} else {
uint32_t secs = nowMs / 1000;
hh = (secs / 3600) % 24;
mm = (secs / 60) % 60;
ss = secs % 60;
}
return haveClock;
}
// 短版WiFi/校时状态,跟"有没有可显示时间"无关,纯看WiFi连接和NTP配置进度。
static void buildWifiStatusText(char *buf, size_t len) {
if (WiFi.status() == WL_CONNECTED && timeConfigured) {
snprintf(buf, len, "WIFI: OK");
} else if (WiFi.status() == WL_CONNECTED) {
snprintf(buf, len, "WIFI: SYNCING");
} else if (staSsid.length() > 0) {
snprintf(buf, len, "WIFI: CONNECTING");
} else {
snprintf(buf, len, "WIFI: SETUP");
}
}
static void updateDigits(uint32_t nowMs) {
uint16_t hh;
uint8_t mm, ss;
struct tm ti;
getCurrentTime(nowMs, hh, mm, ss, ti);
bool blinkOn = (nowMs / 500) % 2 == 0;
char buf[16];
snprintf(buf, sizeof(buf), "%02u%c%02u", hh, blinkOn ? ':' : ' ', mm);
lv_label_set_text(timeLabel, buf);
// 番茄钟现在是独立页面(见build_pomodoro_page),这行只管日期/WiFi状态。
char statusBuf[16];
buildWifiStatusText(statusBuf, sizeof(statusBuf));
char ubuf[48];
snprintf(ubuf, sizeof(ubuf), "%04d-%02d-%02d %s - %s",
ti.tm_year + 1900, ti.tm_mon + 1, ti.tm_mday, WEEKDAY_ABBR[ti.tm_wday], statusBuf);
lv_label_set_text(uptimeLabel, ubuf);
lv_arc_set_value(secArc, ss);
}
static void updateMinimalClock(uint32_t nowMs) {
uint16_t hh;
uint8_t mm, ss;
struct tm ti;
getCurrentTime(nowMs, hh, mm, ss, ti);
char buf[8];
snprintf(buf, sizeof(buf), "%02u:%02u", hh, mm);
lv_label_set_text(minTimeLabel, buf);
char secBuf[4];
snprintf(secBuf, sizeof(secBuf), "%02u", ss);
lv_label_set_text(minSecLabel, secBuf);
lv_obj_align_to(minSecLabel, minTimeLabel, LV_ALIGN_OUT_RIGHT_TOP, 4, 4);
char dbuf[20];
snprintf(dbuf, sizeof(dbuf), "%s %s %02d", WEEKDAY_ABBR[ti.tm_wday], MONTH_ABBR[ti.tm_mon], ti.tm_mday);
lv_label_set_text(minDateLabel, dbuf);
// 底部空间窄,用短版状态字,避免跟左边BMI状态在屏幕中间撞在一起。
char shortStatus[16];
buildWifiStatusText(shortStatus, sizeof(shortStatus));
lv_label_set_text(minStatusLabel, shortStatus);
}
static lv_obj_t *sceneObj(uint8_t idx) {
if (idx == 1) return minimalScreen;
if (idx == 2) return bmiTestScreen;
return deepWarpScreen;
}
void setup() {
Serial.begin(115200);
delay(200);
Serial.println("BOOT: start");
randomSeed(analogRead(34));
seedClockFromCompileTime();
tft.begin();
tft.setRotation(activeRotation);
Serial.println("BOOT: tft done");
lv_init();
lv_disp_draw_buf_init(&draw_buf, buf1, NULL, SCREEN_W * 40);
static lv_disp_drv_t disp_drv;
lv_disp_drv_init(&disp_drv);
disp_drv.hor_res = SCREEN_W;
disp_drv.ver_res = SCREEN_H;
disp_drv.flush_cb = disp_flush;
disp_drv.draw_buf = &draw_buf;
lv_disp_drv_register(&disp_drv);
Serial.println("BOOT: lvgl driver done");
// 场景都建出来常驻内存,点触摸的时候用lv_scr_load瞬间切换,不用现场重建。
deepWarpScreen = lv_obj_create(NULL);
minimalScreen = lv_obj_create(NULL);
bmiTestScreen = lv_obj_create(NULL);
pomodoroScreen = lv_obj_create(NULL);
build_deep_warp_hud(deepWarpScreen);
build_minimal_clock(minimalScreen);
build_bmi_test_page(bmiTestScreen);
build_pomodoro_page(pomodoroScreen);
lv_scr_load(sceneObj(activeScene));
touchInit();
Serial.println("BOOT: scenes built, touch init, backlight off, entering setupWifi()");
// 排查"一直闪/掉电":二分测试关掉WiFi后确认问题真的在WiFi身上,后来发现是
// USB线太长导致压降,换短线后不闪了——不是代码问题,恢复正常逻辑。
digitalWrite(TFT_BL, LOW);
delay(50);
setupWifi();
delay(100);
digitalWrite(TFT_BL, TFT_BACKLIGHT_ON);
Serial.println("BOOT: setupWifi returned, backlight back on, bmiInit...");
bool ok = bmiInit();
Serial.println(ok ? "BMI160 OK" : "BMI160 NOT FOUND");
// 两个场景都常驻内存,两边的BMI状态字都更新一下,不管现在显示的是哪个。
lv_label_set_text(minBmiLabel, ok ? "BMI OK" : "BMI --");
lv_label_set_text(dbgLabel, ok ? "BMI160 OK" : "BMI160 NOT FOUND");
}
static uint32_t lastTickMs = 0;
void loop() {
uint32_t now = millis();
lv_tick_inc(now - lastTickMs); // LV_TICK_CUSTOM没生效,改手动喂tick,见lv_conf.h里的说明
lastTickMs = now;
dnsServer.processNextRequest();
webServer.handleClient();
if (WiFi.status() == WL_CONNECTED && !timeConfigured) {
configTime(8 * 3600, 0, "ntp.aliyun.com", "ntp.tencent.com", "pool.ntp.org");
timeConfigured = true;
}
// 连上家里/客户WiFi之后配网热点(AP)就没用了,一直开着AP+STA两个都广播是持续
// 多耗一份电流,关掉AP降低平时的功耗/供电压力。开机2分钟内AP还在,方便配网;
// 没配置成功就一直留着AP(不然用户没法再连上去配网)。
static bool apDropped = false;
if (!apDropped && WiFi.status() == WL_CONNECTED && now > 120000) {
WiFi.softAPdisconnect(true);
apDropped = true;
}
// 点触摸切换场景/转屏:坐标标定还没做(见触摸驱动那段注释),先做成"点哪都算",
// 不依赖具体点在屏幕哪个位置。轮询间隔30ms,够跟手又不用每次loop()都做位带SPI。
// 长按屏幕手动转屏:现在转屏主要靠BMI160自动触发(见handlePomodoroGesture),
// 这个长按留作手动兜底(比如传感器读数不稳/贴装角度偏了自动没转对的时候手动转
// 回来)。快点(松手快于LONG_PRESS_MS)算切页面,按住超过这个时长算长按转屏,
// 长按期间不会再触发切页面。
static uint32_t lastTouchMs = 0;
static bool touchWasDown = false;
static uint32_t lastSwitchMs = 0;
static uint32_t touchDownStartMs = 0;
static bool longPressFired = false;
const uint32_t LONG_PRESS_MS = 900;
if (now - lastTouchMs > 30) {
lastTouchMs = now;
bool touchDown = isTouchedNow();
if (touchDown && !touchWasDown) {
touchDownStartMs = now;
longPressFired = false;
}
if (touchDown && !longPressFired && now - touchDownStartMs > LONG_PRESS_MS) {
activeRotation = (activeRotation == 1) ? 3 : 1;
tft.setRotation(activeRotation);
lv_obj_invalidate(lv_scr_act());
longPressFired = true;
}
// 2026-07-27:试过滑动、淡入淡出两种切换动画,用户反馈都会"一闪一闪从上往下
// 扫"——这块板子用的是精简驱动,没有双缓冲,动画期间LVGL反复整屏重绘,每次
// 重绘都从上到下推,密集重绘就是一直在扫。这个驱动上其他"特效类"样式之前也
// 踩过坑(放大文字、旋转变换都不好使),同一类问题。放弃动画,直接瞬间切换,
// 稳定优先。防抖(300ms内不重复触发)还留着,避免手指按住不动时触摸信号抖动
// 被误判成连续多次点击。
if (!touchDown && touchWasDown && !longPressFired && now - lastSwitchMs > 300) {
if (pomodoroScreenShown) {
// 番茄钟页不算进循环里,点一下直接回到触发前那个页面(倒计时继续在
// 后台跑,不会因为切走就取消)。
pomodoroScreenShown = false;
lv_scr_load(sceneObj(activeScene));
} else {
activeScene = (activeScene + 1) % SCENE_COUNT;
lv_scr_load(sceneObj(activeScene));
}
lastSwitchMs = now;
}
touchWasDown = touchDown;
}
if (pomodoroScreenShown) {
if (now - lastDigitMs > 200) {
lastDigitMs = now;
updatePomodoroPage(now);
}
} else if (activeScene == 0) {
if (now - lastStarMs > 40) {
lastStarMs = now;
updateStars(now);
}
if (now - lastDigitMs > 200) {
lastDigitMs = now;
updateDigits(now);
}
} else if (activeScene == 1) {
if (now - lastDigitMs > 200) {
lastDigitMs = now;
updateMinimalClock(now);
}
}
// activeScene==2(BMI测试页)内容由下面的BMI160轮询里updateBmiTestPage()负责,
// 不需要在这里按200ms定时刷新。
// 排查过"画面冻住",真正原因是lv_tick没喂(见上面lv_tick_inc那行),跟这段
// BMI160轮询无关,重新打开。
if (bmiAddr && now - lastAccelMs > 150) {
lastAccelMs = now;
float ax, ay, az;
if (bmiReadAccel(ax, ay, az)) {
// 实机数据证实转屏和番茄钟手势在这个传感器贴法下是同一个物理动作(往左转
// 90度 vs 右倾30分钟手势,读数几乎一样)。参考另一个并行项目的思路,不再
// 硬要区分开——倾斜稳定了就一起触发(计时+转屏),具体在handlePomodoroGesture
// 里实现。
handlePomodoroGesture(ax, ay, now);
updateBmiTestPage(ax, ay, az);
}
}
lv_timer_handler();
delay(5);
}
工程依赖和屏幕参数:建议整包使用,不要只复制 main.ino。
[env:esp32dev]
platform = espressif32@6.13.0
board = esp32dev
framework = arduino
board_build.partitions = huge_app.csv
; Arduino-ESP32 核心3.x(ESP-IDF5)在这台机器上链接报错:
; undefined reference to `esp_bt_controller_mem_release'
; 是工具链BT库版本不匹配的已知问题,跟代码无关。固定用2.0.17这个稳定核心版本绕开。
platform_packages =
framework-arduinoespressif32 @ https://github.com/espressif/arduino-esp32/releases/download/2.0.17/esp32-2.0.17.zip
upload_port = COM28
monitor_port = COM28
monitor_speed = 115200
; ---- 驱动:位带SPI手写 -> TFT_eSPI(硬件SPI) + LVGL ----
; lvgl定死8.3.x:这台机器固定用的Arduino-ESP32 2.0.17(旧核心),LVGL9.x要求更新的
; 工具链,配旧核心容易踩坑,8.3.x是这个核心搭配TFT_eSPI最成熟稳定的组合。
lib_deps =
bodmer/TFT_eSPI @ ^2.5.43
lvgl/lvgl @ ^8.3.11
build_flags =
-D USER_SETUP_LOADED=1
-D ST7789_DRIVER=1
-D TFT_WIDTH=240
-D TFT_HEIGHT=320
-D TFT_MOSI=13
-D TFT_SCLK=14
-D TFT_CS=15
-D TFT_DC=2
-D TFT_RST=-1
-D TFT_MISO=-1
-D TFT_BL=21
-D TFT_BACKLIGHT_ON=HIGH
-D TFT_INVERSION_OFF=1
-D SPI_FREQUENCY=40000000
-D LOAD_GLCD=1
-D LOAD_FONT2=1
-D LOAD_FONT4=1
-D LOAD_FONT6=1
-D LOAD_FONT7=1
-D LOAD_FONT8=1
-D LOAD_GFXFF=1
-D SMOOTH_FONT=1
-D LV_CONF_INCLUDE_SIMPLE=1
-D LV_FONT_MONTSERRAT_48=1
LVGL 配置文件:缺这个文件时很容易编译失败。
// LVGL 8.3 配置 · 精简版,只留 ESP32-2432S028R (CYD) 用得到的部分
// 完整选项模板见 lvgl 库自带的 lv_conf_template.h,这里没有照抄全部,只挑了
// 跑得起来 + 能验证alpha混合效果需要的最小集合。
#ifndef LV_CONF_H
#define LV_CONF_H
#define LV_COLOR_DEPTH 16
// 2026-07-27踩坑:这行原来是1,但main.ino里pushColors()第三个参数已经传true让
// TFT_eSPI自己做字节序交换了,两边都转等于转了两次,颜色全乱(浅蓝显示成黄绿、
// 琥珀显示成粉红)。字节序交换只能选一边做,这里改成0,交给TFT_eSPI那边处理。
#define LV_COLOR_16_SWAP 0
#define LV_MEM_CUSTOM 0
// 2026-07-27排查"画面完全不刷新但loop()没卡死":怀疑是这里48KB不够用,40个星星
// 对象+一堆local style覆盖(阴影/圆角/发光那些)可能在setup()阶段就把堆吃得所剩无几,
// 后续每帧的重绘请求静默分配失败、不报错但也不生效。先放大到128KB试一下。
#define LV_MEM_SIZE (128U * 1024U)
// 2026-07-27踩坑:LV_TICK_CUSTOM=1这条路没生效,lv_tick_get()实测一直卡在0
// (millis()明明在正常走),导致LVGL内部"该不该重绘"的计时器永远以为时间没过,
// 画面只在开机那一次重绘、之后再也不刷新。改成手动喂tick(main.ino的loop()里
// 调lv_tick_inc())。
#define LV_TICK_CUSTOM 0
#define LV_DISP_DEF_REFR_PERIOD 30
#define LV_INDEV_DEF_READ_PERIOD 30
#define LV_USE_LOG 0
#define LV_USE_ASSERT_NULL 1
#define LV_USE_ASSERT_MALLOC 1
#define LV_FONT_MONTSERRAT_14 1
#define LV_FONT_MONTSERRAT_48 1 // 简约时钟页大时间数字用
#define LV_FONT_DEFAULT &lv_font_montserrat_14
#define LV_USE_LABEL 1
#define LV_USE_ARC 1
#define LV_USE_BAR 1
#define LV_USE_IMG 1
#define LV_USE_PERF_MONITOR 0
#define LV_USE_MEM_MONITOR 0
#endif