显示屏与桌面作品 / 桌面显示作品发布 内容更新

做一个桌面番茄钟:旋钮、屏幕和提示音组成完整作品

用旋钮设置工作和休息时长,暂停时冻结计时;NVS保存带回执的检查点,重启只恢复暂停状态,并用同源快进夹具检查完整周期与边界。

读完能做什么
得到可暂停、静音、设时和恢复检查点的桌面番茄钟,清楚区分时间状态与保存完成。
开始之前
已完成所列前置,能根据具体模块手册断电布线、复建固定版本工程并保存串口日志。
所需器材
官方ESP32-S3-DevKitC-1 v1.1,模组ESP32-S3-WROOM-1-N8R8(8MB Flash / Octal PSRAM);USB-to-UART桥接串口115200。、沿用D07的Bourns PEC11R-4215F-S0024可靠转接板和已焊接PID3787 TFT、片选各10kΩ上拉;SD空/EYESPI不用、Adafruit PID3885 STEMMA Speaker(内置功放+扬声器)与PID3893 PH2mm三针转排针线、1kΩ与2kΩ各1只1%分压电阻;短线/面包板;同一USB供电,音量初始最小
版本组合
参考板:官方ESP32-S3-DevKitC-1 v1.1,模组ESP32-S3-WROOM-1-N8R8(8MB Flash / Octal PSRAM);USB-to-UART桥接串口115200。框架:ESP-IDFv5.5.5;native esp_lcd / FreeRTOS;无托管组件提示音:PID3885 + PID3893;5V功放 / GPIO18经1k:2k分压;LEDC低速1kHz8bit128,300ms目标记录格式:NVS d10-v1/checkpoint,DP10v1 / 32B / little-endian / CRC32;恢复强制PAUSED
适用范围

官方ESP32-S3-DevKitC-1 v1.1,模组ESP32-S3-WROOM-1-N8R8(8MB Flash / Octal PSRAM);USB-to-UART桥接串口115200。仅对本文具体器材与版本;未取得实板。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/display-projects.md#d10版本:ESP-IDFv5.5.5验证日期:
构建 / 配置已通过
docs/esp32-evidence/display-project-builds.json对象:esp32s3 / N8R8版本:ESP-IDFv5.5.5验证日期:
仿真已通过
docs/esp32-evidence/display-project-host-checks.json对象:同源纯逻辑host夹具;D05另有真实Python loopback,不模拟屏幕/ESP32验证日期:
本站实板待验证
无实板;光学/电气/旋钮/无线/提示音/Flash掉电观察表保持空白。

构建通过只说明指定环境中的代码或配置通过检查;实物功能、功耗和稳定性以对应的实板记录为准。

01 / 桌面显示作品

先约定一段结束后发生什么

默认工作25分钟、休息5分钟。短按开始;运行时短按暂停,再短按继续。工作结束只把已完成工作次数加一、切到休息READY并请求提示音;休息结束切到工作READY,不增加次数。每段都等你手动开始,不因用户离开桌面就默默跨过几个番茄。

计时用esp_timer_get_time的单调毫秒差,暂停不扣时间,不依赖Wi-Fi/NTP。显示剩余秒向上取整,不是精密秒表。断电后只能恢复最近成功检查点,并暂停;不从当前开机时间猜停电多久。

做到这里,你应该看到
READY、RUNNING、PAUSED三种状态与下一段等待规则应先写在操作说明里。

参考:[6] 固定NVS get/set/commit API[7] 固定NVS存储与掉电说明[10] 固定esp_timer单调时钟

02 / 桌面显示作品

保留D07接线,新增有功放的三针模块

TFT与旋钮严格沿用D07:A6/B7/SW5,TFT TC14/RT15/BL16,SCK12/SI11/SO10/DC4/CC13;TC、CC各10k上拉3V3。PID3787排针已可靠焊接,SD空、EYESPI/TE/3V输出不用,VIN仍用3V3。没有SHT40采样任务,本篇GPIO8/9空闲。

声音固定Adafruit PID3885 STEMMA Speaker,内置class D功放和1W/8Ω扬声器;用PID3893 PH2mm三针线,红+接DevKit J1-21/5V,黑−共地,白SIG接分压节点。GPIO18 J1-11经1kΩ 1%到SIG,SIG经2kΩ 1%到地,空载标称2.2V;模块输入负载可能使幅度更低。厂家Overview允许更宽幅度,但Pinouts写0~3V,本例按更保守范围设计,这是电路推导而非实测。

GPIO只驱动功放信号输入,不能把裸8Ω扬声器直接接GPIO。三针PH不是四针STEMMA QT。先断电核极性、共地、分压,再把音量电位器调到最小;上电后缓慢调到舒适音量。没有声压、输出功率或USB电流裕量测量;出现欠压复位先核电源线/电压/负载,不关闭欠压检测。

新增连接DevKitC-1 v1.1
PID3885红线+J1-21 / 5V,同一USB供电
黑线−GND共地
白线SIGGPIO18 J1-11 →1kΩ→SIG;SIG→2kΩ→GND
TFT电源继续3V3 J1-1,不跟着改成5V

参考:[1] DevKitC-1 v1.1排针[2] PEC11R手册[3] PID3787引脚[8] PID3885 STEMMA Speaker供电/信号/音量[9] PID3893三针PH2mm线

03 / 桌面显示作品

长按先暂停,改参数需要明确确认

CLOCK短按开始/暂停/继续;长按先暂停,再进设置ROOT。ROOT五项WORK MIN、BREAK MIN、MUTE、RESET、BACK,旋转边界0~4钳制。长按或BACK回CLOCK但不自动续跑。按键仍为30ms去抖、1200ms长按单发,上电按住须释放后武装。

工作草稿限制1~60分,休息1~30分。短按提交;只有值改变才重置为FOCUS/READY、清零次数,避免“25改60仍继承旧剩余”歧义;长按取消草稿。MUTE只改静音,不改剩余时间;RESET明确重新开始会话但仍等短按启动。main本轮advance完成阶段后,把boundaryJustCompleted传给共享菜单策略;同轮CLOCK短按不启动下一段,长按仍可进设置,后一次独立短按才开始。

位置短按长按
CLOCK开始 / 暂停 / 继续暂停并进设置
ROOT进入草稿 / 静音 / 重置 / 返回返回CLOCK,仍暂停
时长草稿提交;改变时重置会话取消并返回ROOT

参考:[2] PEC11R手册

04 / 桌面显示作品

计时模型与32字节记录都可独立快进

TimerModel在每次advance(now)只对RUNNING扣时间。一次很大跳时最多完成当前一段,其余时间不跨到下一段;倒退的单调输入会PAUSED并标CLOCK FAULT,显式RESET后再用。64位now避免普通32位毫秒回绕造成误结束。

TimerStore不用裸写C++结构体,固定32字节小端:DP10魔数、v1、长度、工作/休息秒、阶段、静音、剩余毫秒、次数、generation与CRC32。解码先检查长度/版本/保留位/CRC/范围和剩余>0且不大于该阶段长度;坏记录不赋给输出。记录不含旧单调时钟,也不存“开机自动RUNNING”,有效恢复统一PAUSED。CRC不是防篡改认证。

代码 / 配置
// ---- main/InputDecode.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>

class Quadrature {
 public:
  explicit Quadrature(uint8_t initial) : prev_(initial&3) {}
  int update(uint8_t ab) {
    static constexpr int8_t delta[16]={0,1,-1,0,-1,0,0,1,1,0,0,-1,0,-1,1,0};
    ab &= 3;
    if ((prev_^ab)==3) { ++invalid_; quarter_=0; prev_=ab; return 0; }
    quarter_ += delta[(prev_<<2)|ab]; prev_=ab;
    if (quarter_>=4) { quarter_=0; return 1; }
    if (quarter_<=-4) { quarter_=0; return -1; }
    return 0;
  }
  uint32_t invalid() const { return invalid_; }
 private:
  uint8_t prev_;
  int8_t quarter_=0;
  uint32_t invalid_=0;
};
class Button {
 public:
  enum Event { NONE, SHORT, LONG };
  Button(bool held,uint32_t now):raw_(held),stable_(held),armed_(!held),changed_(now),pressed_(now) {}
  Event update(bool held,uint32_t now) {
    if (held!=raw_) { raw_=held; changed_=now; }
    if (raw_!=stable_ && uint32_t(now-changed_)>=30) {
      stable_=raw_;
      if (stable_) { pressed_=now; long_sent_=false; }
      else {
        bool send=armed_&&!long_sent_;
        armed_=true;
        return send?SHORT:NONE;
      }
    }
    if (armed_&&stable_&&!long_sent_&&uint32_t(now-pressed_)>=1200) {
      long_sent_=true; return LONG;
    }
    return NONE;
  }
 private:
  bool raw_,stable_,armed_,long_sent_=false;
  uint32_t changed_,pressed_;
};
// ---- main/TimerModel.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
#include "InputDecode.h"
namespace pomo {
enum Phase:uint8_t { FOCUS=0, BREAK=1 };
enum Run:uint8_t { READY=0, RUNNING=1, PAUSED=2 };
struct Timer {
 uint16_t workS=1500,breakS=300;bool muted=false;
 Phase phase=FOCUS;Run run=READY;uint32_t remainingMs=1500000,cycles=0;
 uint64_t lastMs=0;bool clockFault=false,chime=false;
 uint32_t duration()const{return uint32_t(phase==FOCUS?workS:breakS)*1000;}
 void reset(uint64_t now){phase=FOCUS;run=READY;remainingMs=uint32_t(workS)*1000;cycles=0;lastMs=now;clockFault=false;chime=false;}
 bool advance(uint64_t now){
  if(now<lastMs){run=PAUSED;clockFault=true;lastMs=now;return false;}
  uint64_t delta=now-lastMs;lastMs=now;
  if(run!=RUNNING)return false;
  if(delta>=remainingMs){if(phase==FOCUS&&cycles!=UINT32_MAX)++cycles;phase=phase==FOCUS?BREAK:FOCUS;remainingMs=duration();run=READY;chime=true;return true;}
  remainingMs-=uint32_t(delta);return false;
 }
 void toggle(uint64_t now){if(advance(now))return;if(clockFault)return;run=run==RUNNING?PAUSED:RUNNING;}
 bool changeMinutes(bool work,int minutes,uint64_t now){
  int hi=work?60:30;if(minutes<1||minutes>hi)return false;
  uint16_t value=uint16_t(minutes*60);uint16_t& slot=work?workS:breakS;
  if(slot==value)return false;
  slot=value;reset(now);return true;
 }
};
struct Menu {
 enum Page{CLOCK,ROOT,EDIT_WORK,EDIT_BREAK} page=CLOCK;
 int selected=0,draft=25;
 static int clamp(int v,int d,int lo,int hi){int64_t n=int64_t(v)+d;return n<lo?lo:n>hi?hi:int(n);}
 void rotate(int step){if(page==ROOT)selected=clamp(selected,step,0,4);else if(page==EDIT_WORK)draft=clamp(draft,step,1,60);else if(page==EDIT_BREAK)draft=clamp(draft,step,1,30);}
 // Return true only when an explicit timer/settings action needs a persistent checkpoint.
 bool press(Button::Event e,Timer& t,uint64_t now,bool boundaryJustCompleted=false){
  if(e==Button::LONG){
   if(page==CLOCK){t.advance(now);if(t.run==RUNNING)t.run=PAUSED;page=ROOT;selected=0;return true;}
   if(page==ROOT)page=CLOCK;else page=ROOT;return false;
  }
  if(e!=Button::SHORT)return false;
  // main may have completed a phase before delivering this same-tick button event.
  if(page==CLOCK){if(!boundaryJustCompleted)t.toggle(now);return true;}
  if(page==ROOT){
   if(selected<2){page=selected==0?EDIT_WORK:EDIT_BREAK;draft=(selected==0?t.workS:t.breakS)/60;return false;}
   if(selected==2){t.muted=!t.muted;return true;}
   if(selected==3){t.reset(now);page=CLOCK;return true;}
   page=CLOCK;return false;
  }
  bool changed=t.changeMinutes(page==EDIT_WORK,draft,now);page=ROOT;return changed;
 }
};
}
// ---- main/TimerStore.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include "TimerModel.h"
namespace pomo {
constexpr size_t RECORD_SIZE=32;
inline uint32_t crc32(const uint8_t* b,size_t n){uint32_t c=0xffffffff;for(size_t i=0;i<n;++i){c^=b[i];for(int k=0;k<8;++k)c=(c&1)?(c>>1)^0xedb88320u:c>>1;}return ~c;}
inline void put16(uint8_t* b,uint16_t x){b[0]=x;b[1]=x>>8;}
inline void put32(uint8_t* b,uint32_t x){for(int i=0;i<4;++i)b[i]=uint8_t(x>>(8*i));}
inline uint16_t get16(const uint8_t* b){return uint16_t(b[0])|(uint16_t(b[1])<<8);}
inline uint32_t get32(const uint8_t* b){uint32_t n=0;for(int i=0;i<4;++i)n|=uint32_t(b[i])<<(8*i);return n;}
inline void encode(const Timer& t,uint32_t gen,uint8_t out[RECORD_SIZE]){
 memset(out,0,RECORD_SIZE);memcpy(out,"DP10",4);out[4]=1;put16(out+6,RECORD_SIZE);put16(out+8,t.workS);put16(out+10,t.breakS);out[12]=t.phase;out[13]=t.muted;put32(out+16,t.remainingMs);put32(out+20,t.cycles);put32(out+24,gen);put32(out+28,crc32(out,28));
}
inline bool decode(const uint8_t* b,size_t n,Timer& out,uint32_t& gen,uint64_t now){
 if(!b||n!=RECORD_SIZE||memcmp(b,"DP10",4)||b[4]!=1||b[5]||get16(b+6)!=RECORD_SIZE||b[12]>1||b[13]>1||b[14]||b[15]||get32(b+28)!=crc32(b,28))return false;
 uint16_t w=get16(b+8),r=get16(b+10);uint32_t left=get32(b+16);
 if(w<60||w>3600||w%60||r<60||r>1800||r%60||left==0||left>uint32_t(b[12]==FOCUS?w:r)*1000)return false;
 Timer t;t.workS=w;t.breakS=r;t.phase=Phase(b[12]);t.muted=b[13]!=0;t.remainingMs=left;t.cycles=get32(b+20);t.run=PAUSED;t.lastMs=now;out=t;gen=get32(b+24);return true;
}
}

参考:[6] 固定NVS get/set/commit API[7] 固定NVS存储与掉电说明[10] 固定esp_timer单调时钟

05 / 桌面显示作品

保存任务回执与输入任务计时分开

输入任务优先级4、1ms目标采样,独占Timer/Menu/LEDC。绘制只消费最新View;存储任务优先级2独占NVS句柄,执行nvs_set_blob再nvs_commit,回传generation及错误码。显式动作、阶段完成、运行中每60秒请求检查点;长度1队列允许合并尚未处理的旧请求。

SAVE PENDING不是保存成功,只有最新generation回执两步成功才SAVE OK;错误显示SAVE ERROR。60秒是请求间隔,不保证断电损失≤60秒:排队、调度、提交失败可更久,刚操作的静音/参数也可能尚未落盘。Flash写入仍可能阻塞CPU、影响旋钮边沿,分任务不构成硬实时或掉电事务证明。

代码 / 配置
// ---- main/main.cpp ----
// SPDX-License-Identifier: MIT
#include <inttypes.h>
#include "driver/gpio.h"
#include "driver/ledc.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "TimerStore.h"
#include "Tft.h"
static constexpr gpio_num_t A=GPIO_NUM_6,B=GPIO_NUM_7,SW=GPIO_NUM_5,SIG=GPIO_NUM_18;
static constexpr int DIRECTION=1;
static hx::Tft screen;
static QueueHandle_t frames,saves,acks;
static nvs_handle_t storage=0;
static bool storageReady=false,loadError=false,wasRestored=false,soundReady=false;
static pomo::Timer initial;
static uint32_t generation=0;
struct SaveRequest{uint8_t bytes[pomo::RECORD_SIZE];uint32_t generation;};
struct SaveAck{uint32_t generation;esp_err_t error;};
static uint64_t ms(){return uint64_t(esp_timer_get_time()/1000);}
static uint8_t ab(){return uint8_t((gpio_get_level(A)<<1)|gpio_get_level(B));}
static void loadCheckpoint(){
 esp_err_t e=nvs_flash_init();
 if(e!=ESP_OK){loadError=true;ESP_LOGE("D10","NVS init %s; no automatic erase, RAM timer only",esp_err_to_name(e));return;}
 e=nvs_open("d10-v1",NVS_READWRITE,&storage);if(e!=ESP_OK){loadError=true;ESP_LOGE("D10","NVS open %s",esp_err_to_name(e));return;}
 storageReady=true;uint8_t bytes[pomo::RECORD_SIZE];size_t n=sizeof bytes;e=nvs_get_blob(storage,"checkpoint",bytes,&n);
 if(e==ESP_ERR_NVS_NOT_FOUND){ESP_LOGI("D10","NO CHECKPOINT; default READY");return;}
 if(e!=ESP_OK||!pomo::decode(bytes,n,initial,generation,ms())){loadError=true;ESP_LOGE("D10","CHECKPOINT INVALID %s; default READY, no auto-run",esp_err_to_name(e));return;}
 wasRestored=true;ESP_LOGI("D10","RESTORED PAUSED remaining_ms=%" PRIu32 " generation=%" PRIu32 "; outage not inferred",initial.remainingMs,generation);
}
static bool initSound(){
 gpio_set_level(SIG,0);gpio_set_direction(SIG,GPIO_MODE_OUTPUT);
 ledc_timer_config_t t{};t.speed_mode=LEDC_LOW_SPEED_MODE;t.duty_resolution=LEDC_TIMER_8_BIT;t.timer_num=LEDC_TIMER_0;t.freq_hz=1000;t.clk_cfg=LEDC_AUTO_CLK;
 esp_err_t e=ledc_timer_config(&t);
 if(e==ESP_OK){ledc_channel_config_t c{};c.gpio_num=SIG;c.speed_mode=LEDC_LOW_SPEED_MODE;c.channel=LEDC_CHANNEL_0;c.timer_sel=LEDC_TIMER_0;c.duty=0;c.hpoint=0;e=ledc_channel_config(&c);}
 if(e!=ESP_OK){gpio_reset_pin(SIG);gpio_set_direction(SIG,GPIO_MODE_OUTPUT);gpio_set_level(SIG,0);ESP_LOGE("D10","AUDIO ERROR %s; timer remains usable without tone",esp_err_to_name(e));return false;}return true;
}
static void saveTask(void*){
 SaveRequest req;
 for(;;){if(xQueueReceive(saves,&req,portMAX_DELAY)==pdTRUE){esp_err_t e=storageReady?nvs_set_blob(storage,"checkpoint",req.bytes,sizeof req.bytes):ESP_ERR_INVALID_STATE;if(e==ESP_OK)e=nvs_commit(storage);SaveAck a{req.generation,e};xQueueOverwrite(acks,&a);ESP_LOGI("D10","checkpoint generation=%" PRIu32 " commit=%s",req.generation,esp_err_to_name(e));}}
}
static void inputTask(void*){
 pomo::Timer timer=initial;pomo::Menu menu;Quadrature encoder(ab());Button button(gpio_get_level(SW)==0,uint32_t(ms()));hx::View view;
 uint32_t newest=generation,lastAck=generation,oldSecond=UINT32_MAX,oldInvalid=0;
 bool pending=false,saveError=loadError,restored=wasRestored,hasSaved=wasRestored,tone=false,oldClockFault=false;uint64_t toneUntil=0,lastRequest=ms();
 TickType_t wake=xTaskGetTickCount();
 for(;;){
  uint64_t now=ms();bool boundary=timer.advance(now),changed=boundary,save=boundary;
  if(timer.clockFault!=oldClockFault){oldClockFault=timer.clockFault;changed=true;}
  int step=encoder.update(ab())*DIRECTION;auto event=button.update(gpio_get_level(SW)==0,uint32_t(now));
  if(step){menu.rotate(step);++view.events;changed=true;}
  if(event!=Button::NONE){save=menu.press(event,timer,now,boundary)||save;++view.events;changed=true;}
  if(timer.chime){timer.chime=false;if(!timer.muted&&soundReady){esp_err_t e=ledc_set_duty(LEDC_LOW_SPEED_MODE,LEDC_CHANNEL_0,128);if(e==ESP_OK)e=ledc_update_duty(LEDC_LOW_SPEED_MODE,LEDC_CHANNEL_0);tone=e==ESP_OK;toneUntil=now+300;if(e!=ESP_OK){changed=true;soundReady=false;gpio_reset_pin(SIG);gpio_set_direction(SIG,GPIO_MODE_OUTPUT);gpio_set_level(SIG,0);ESP_LOGE("D10","AUDIO start failed %s; detached to GPIO low",esp_err_to_name(e));}}}
  if(tone&&(timer.muted||now>=toneUntil)){esp_err_t e=ledc_stop(LEDC_LOW_SPEED_MODE,LEDC_CHANNEL_0,0);tone=false;if(e!=ESP_OK){changed=true;soundReady=false;gpio_reset_pin(SIG);gpio_set_direction(SIG,GPIO_MODE_OUTPUT);gpio_set_level(SIG,0);ESP_LOGE("D10","AUDIO stop failed %s; detached to GPIO low",esp_err_to_name(e));}}
  // Only running sessions request a periodic checkpoint; explicit actions also request one.
  if(timer.run==pomo::RUNNING&&now-lastRequest>=60000)save=true;
  if(save){SaveRequest req{};req.generation=++newest;pomo::encode(timer,req.generation,req.bytes);xQueueOverwrite(saves,&req);lastRequest=now;pending=true;changed=true;}
  SaveAck ack;if(xQueueReceive(acks,&ack,0)==pdTRUE){lastAck=ack.generation;if(lastAck==newest){pending=false;saveError=ack.error!=ESP_OK;hasSaved=!saveError;restored=false;}changed=true;}
  uint32_t second=(timer.remainingMs+999)/1000;if(second!=oldSecond){oldSecond=second;changed=true;}
  if(encoder.invalid()!=oldInvalid){oldInvalid=encoder.invalid();ESP_LOGW("D10","input invalid=%" PRIu32,oldInvalid);}
  if(changed){view.page=menu.page;view.selected=menu.selected;view.draft=menu.draft;view.phase=timer.phase;view.run=timer.run;view.muted=timer.muted;view.remainingS=second;view.cycles=timer.cycles;view.restored=restored;view.pending=pending;view.saveError=saveError;view.hasSaved=hasSaved;view.clockFault=timer.clockFault;view.audioError=!soundReady;xQueueOverwrite(frames,&view);}
  vTaskDelayUntil(&wake,pdMS_TO_TICKS(1));
 }
}
extern "C" void app_main(){
 gpio_config_t g{};g.pin_bit_mask=(1ULL<<A)|(1ULL<<B)|(1ULL<<SW);g.mode=GPIO_MODE_INPUT;g.pull_up_en=GPIO_PULLUP_ENABLE;g.intr_type=GPIO_INTR_DISABLE;ESP_ERROR_CHECK(gpio_config(&g));
 loadCheckpoint();soundReady=initSound();ESP_ERROR_CHECK(hx::init_bus());ESP_ERROR_CHECK(screen.init());
 frames=xQueueCreate(1,sizeof(hx::View));saves=xQueueCreate(1,sizeof(SaveRequest));acks=xQueueCreate(1,sizeof(SaveAck));if(!frames||!saves||!acks){screen.backlight(false);ESP_LOGE("D10","STOP queues");return;}
 TaskHandle_t writer=nullptr;
 if(xTaskCreate(saveTask,"checkpoint",4096,nullptr,2,&writer)!=pdPASS){screen.backlight(false);ESP_LOGE("D10","STOP save task");return;}
 if(xTaskCreate(inputTask,"pomo_input",4096,nullptr,4,nullptr)!=pdPASS){vTaskDelete(writer);screen.backlight(false);ESP_LOGE("D10","STOP input task");return;}
 ESP_LOGI("D10","READY short=start/pause/resume; long=pause/settings; manual start every next phase; no wall-clock outage estimate");
 hx::View v;
 for(;;){if(xQueueReceive(frames,&v,portMAX_DELAY)==pdTRUE){esp_err_t e=screen.draw(v);if(e!=ESP_OK){screen.backlight(false);ESP_LOGE("D10","STOP render %s; retain DMA, input/timer may continue, RESET after inspection",esp_err_to_name(e));return;}ESP_LOGI("D10","frame_SPI_done page=%d phase=%d run=%d remaining_s=%" PRIu32 " cycles=%" PRIu32 " muted=%u input=%" PRIu32 " save_pending=%u save_error=%u",v.page,v.phase,v.run,v.remainingS,v.cycles,v.muted,v.events,v.pending,v.saveError);}}
}

参考:[5] 固定LEDC duty/update/stop API[6] 固定NVS get/set/commit API[7] 固定NVS存储与掉电说明

06 / 桌面显示作品

显示保留传输边界,声音保留失败出口

TFT组件沿用D07的具体ST7789配置和16行DMA条带,每条等完成信号量再复写;原创5×7字体画分钟/秒、运行状态、次数、静音及保存状态。音频故障优先于MUTED显示,避免静音状态掩盖错误。初始没有检查点显示NOT SAVED,不能把未保存默认值标SAVE OK;恢复有效记录显示RESTORED CHECKPT。

LEDC使用S3低速timer0/channel0,1kHz、8位、duty128,经分压进入功放;set_duty后调用update_duty,只有输入任务使用这些非线程安全API。300ms是目标提示时长,静音或到期调用stop(idle0),停止失败脱离LEDC拉低GPIO18并显示AUDIO ERROR。start/stop错误同时标记changed,即使已在READY也发布AUDIO ERROR而不等待下一次操作;实际听感与持续时间仍待测。

代码 / 配置
// ---- components/hxdisplay/Graphics.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
#include <stdio.h>
#include <string.h>
namespace hx {
struct View {unsigned rotation=0;int page=0,selected=0,draft=25,phase=0,run=0;bool muted=false,restored=false,saveError=false,pending=false,hasSaved=false,clockFault=false,audioError=false;uint32_t remainingS=1500,cycles=0,events=0;};
// Original 5x7 uppercase/digit bitmap font, one row per byte (5 significant bits).
inline const uint8_t* glyph(char c) {
 struct Entry { char c; uint8_t row[7]; };
 static const Entry f[]={
 {'0',{14,17,19,21,25,17,14}},{'1',{4,12,4,4,4,4,14}},{'2',{14,17,1,2,4,8,31}},
 {'3',{30,1,1,14,1,1,30}},{'4',{2,6,10,18,31,2,2}},{'5',{31,16,16,30,1,1,30}},
 {'6',{14,16,16,30,17,17,14}},{'7',{31,1,2,4,8,8,8}},{'8',{14,17,17,14,17,17,14}},{'9',{14,17,17,15,1,1,14}},
 {'A',{14,17,17,31,17,17,17}},{'B',{30,17,17,30,17,17,30}},{'C',{14,17,16,16,16,17,14}},
 {'D',{30,17,17,17,17,17,30}},{'E',{31,16,16,30,16,16,31}},{'F',{31,16,16,30,16,16,16}},
 {'G',{14,17,16,23,17,17,14}},{'H',{17,17,17,31,17,17,17}},{'I',{14,4,4,4,4,4,14}},
 {'J',{7,2,2,2,2,18,12}},{'K',{17,18,20,24,20,18,17}},{'L',{16,16,16,16,16,16,31}},
 {'M',{17,27,21,21,17,17,17}},{'N',{17,25,21,19,17,17,17}},{'O',{14,17,17,17,17,17,14}},
 {'P',{30,17,17,30,16,16,16}},{'Q',{14,17,17,17,21,18,13}},{'R',{30,17,17,30,20,18,17}},
 {'S',{15,16,16,14,1,1,30}},{'T',{31,4,4,4,4,4,4}},{'U',{17,17,17,17,17,17,14}},
 {'V',{17,17,17,17,17,10,4}},{'W',{17,17,17,21,21,27,17}},{'X',{17,17,10,4,10,17,17}},
 {'Y',{17,17,10,4,4,4,4}},{'Z',{31,1,2,4,8,16,31}},{'.',{0,0,0,0,0,6,6}},
 {'-',{0,0,0,31,0,0,0}},{'+',{0,4,4,31,4,4,0}},{'/',{1,1,2,4,8,16,16}},
 {'%',{25,25,2,4,8,19,19}},{',',{0,0,0,0,6,4,8}},{' ',{0,0,0,0,0,0,0}}
 };
 for(const auto& e:f)if(e.c==c)return e.row;
 static const uint8_t unknown[7]={31,17,1,2,4,0,4};return unknown;
}
inline bool ink(int x,int y,const char* text,int left,int top,int scale=2) {
 x-=left;y-=top;if(x<0||y<0||y>=7*scale)return false;
 unsigned i=unsigned(x/(6*scale));if(i>=strlen(text)||x%(6*scale)>=5*scale)return false;
 return glyph(text[i])[y/scale] & (1<<(4-(x%(6*scale))/scale));
}
inline uint16_t pixel(const View& v,int x,int y){constexpr uint16_t W=0xffff,K=0,G=0x07e0;char line[32];if(x==0||y==0||x==239||y==239)return W;
 if(v.page==0){
  if(ink(x,y,v.phase?"BREAK":"FOCUS",12,20))return W;
  snprintf(line,sizeof line,"%02lu M %02lu S",(unsigned long)(v.remainingS/60),(unsigned long)(v.remainingS%60));if(ink(x,y,line,12,65,2))return W;
  if(ink(x,y,v.clockFault?"CLOCK ERROR":v.run==1?"RUNNING":v.run==2?"PAUSED":"READY",12,105))return W;
  snprintf(line,sizeof line,"CYCLES %lu",(unsigned long)v.cycles);if(ink(x,y,line,12,140))return W;
  if(ink(x,y,v.audioError?"AUDIO ERROR":v.muted?"MUTED":"SOUND ON",12,175))return W;
 }else if(v.page==1){
  const char* items[]={"WORK MIN","BREAK MIN","MUTE","RESET","BACK"};if(ink(x,y,"SETTINGS",12,12))return W;for(int i=0;i<5;++i){if(ink(x,y,items[i],36,45+i*29))return W;if(v.selected==i&&ink(x,y,"+",12,45+i*29))return G;}
 }else{if(ink(x,y,v.page==2?"WORK MIN":"BREAK MIN",12,20))return W;snprintf(line,sizeof line,"VALUE %d",v.draft);if(ink(x,y,line,12,70))return W;if(ink(x,y,"SHORT SAVE",12,110)||ink(x,y,"CHANGE RESETS",12,145)||ink(x,y,"LONG CANCEL",12,175))return W;}
 const char* state=v.saveError?"SAVE ERROR":v.pending?"SAVE PENDING":v.restored?"RESTORED CHECKPT":v.hasSaved?"SAVE OK":"NOT SAVED";if(ink(x,y,state,12,210,1))return W;return K;}
struct Rotation { bool swap,mx,my; int gx,gy; };
inline Rotation rotation(unsigned r){switch(r&3){case 0:return {false,true,true,0,80};case 1:return {true,false,true,80,0};case 2:return {false,false,false,0,0};default:return {true,true,false,0,0};}}
}
// ---- components/hxdisplay/Tft.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include "esp_err.h"
#include "driver/spi_master.h"
#include "esp_lcd_panel_io.h"
#include "esp_lcd_panel_ops.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "Graphics.h"
namespace hx {
constexpr int SCK=12,MOSI=11,MISO=10,TCS=14,CCS=13,DC=4,RST=15,BL=16;
constexpr spi_host_device_t HOST=SPI2_HOST;
esp_err_t init_bus();
class Tft {
 public:
  esp_err_t init();
  esp_err_t set_rotation(unsigned r);
  esp_err_t draw(const View& v);
  void backlight(bool on);
 private:
  esp_lcd_panel_handle_t panel_=nullptr;
  esp_lcd_panel_io_handle_t io_=nullptr;
  SemaphoreHandle_t done_=nullptr;
  uint16_t* strip_=nullptr;
};
}
// ---- components/hxdisplay/Tft.cpp ----
// SPDX-License-Identifier: MIT
#include "Tft.h"
#include <initializer_list>
#include "driver/gpio.h"
#include "esp_heap_caps.h"
#include "esp_lcd_panel_vendor.h"
namespace hx {
esp_err_t init_bus(){
 for(int pin:{TCS,CCS}){ESP_ERROR_CHECK(gpio_set_level(gpio_num_t(pin),1));ESP_ERROR_CHECK(gpio_set_direction(gpio_num_t(pin),GPIO_MODE_OUTPUT));}
 ESP_ERROR_CHECK(gpio_set_level(gpio_num_t(BL),0));ESP_ERROR_CHECK(gpio_set_direction(gpio_num_t(BL),GPIO_MODE_OUTPUT));
 spi_bus_config_t cfg{};cfg.sclk_io_num=SCK;cfg.mosi_io_num=MOSI;cfg.miso_io_num=MISO;
 cfg.quadwp_io_num=-1;cfg.quadhd_io_num=-1;cfg.max_transfer_sz=240*16*2;
 return spi_bus_initialize(HOST,&cfg,SPI_DMA_CH_AUTO);
}
static bool done_cb(esp_lcd_panel_io_handle_t,esp_lcd_panel_io_event_data_t*,void* ctx){
 BaseType_t wake=pdFALSE;xSemaphoreGiveFromISR((SemaphoreHandle_t)ctx,&wake);return wake==pdTRUE;
}
void Tft::backlight(bool on){gpio_set_level(gpio_num_t(BL),on);}
esp_err_t Tft::init(){
 done_=xSemaphoreCreateBinary();strip_=(uint16_t*)heap_caps_malloc(240*16*2,MALLOC_CAP_DMA|MALLOC_CAP_INTERNAL);
 if(!done_||!strip_)return ESP_ERR_NO_MEM;
 esp_lcd_panel_io_spi_config_t io{};io.cs_gpio_num=TCS;io.dc_gpio_num=DC;io.spi_mode=0;io.pclk_hz=4000000;io.trans_queue_depth=1;io.lcd_cmd_bits=8;io.lcd_param_bits=8;io.on_color_trans_done=done_cb;io.user_ctx=done_;
 esp_err_t e=esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)HOST,&io,&io_);if(e!=ESP_OK)return e;
 esp_lcd_panel_dev_config_t dev{};dev.reset_gpio_num=RST;dev.rgb_ele_order=LCD_RGB_ELEMENT_ORDER_RGB;dev.data_endian=LCD_RGB_DATA_ENDIAN_LITTLE;dev.bits_per_pixel=16;
 if((e=esp_lcd_new_panel_st7789(io_,&dev,&panel_))!=ESP_OK)return e;
 if((e=esp_lcd_panel_reset(panel_))!=ESP_OK)return e;
 if((e=esp_lcd_panel_init(panel_))!=ESP_OK)return e;
 if((e=esp_lcd_panel_invert_color(panel_,true))!=ESP_OK)return e;
 if((e=set_rotation(0))!=ESP_OK)return e;
 return esp_lcd_panel_disp_on_off(panel_,true);
}
esp_err_t Tft::set_rotation(unsigned r){
 auto q=rotation(r);esp_err_t e=esp_lcd_panel_swap_xy(panel_,q.swap);if(e!=ESP_OK)return e;
 if((e=esp_lcd_panel_mirror(panel_,q.mx,q.my))!=ESP_OK)return e;
 return esp_lcd_panel_set_gap(panel_,q.gx,q.gy);
}
esp_err_t Tft::draw(const View& v){
 for(int y=0;y<240;y+=16){
  for(int j=0;j<16;++j)for(int x=0;x<240;++x)strip_[j*240+x]=pixel(v,x,y+j);
  esp_err_t e=esp_lcd_panel_draw_bitmap(panel_,0,y,240,y+16,strip_);if(e!=ESP_OK)return e;
  if(xSemaphoreTake(done_,pdMS_TO_TICKS(1000))!=pdTRUE){backlight(false);return ESP_ERR_TIMEOUT;}
 }
 backlight(true);return ESP_OK;
}
}

参考:[3] PID3787引脚[4] 固定ST7789[5] 固定LEDC duty/update/stop API[8] PID3885 STEMMA Speaker供电/信号/音量

07 / 桌面显示作品

真实构建与十五组同源快进检查

下载是完整独立IDF工程,固定v5.5.5、S3/QIO8MB/Octal PSRAM80MHz/UART115200/FreeRTOS1000Hz,无远程托管组件。激活该版本环境执行idf.py set-target esp32s3、idf.py build,上传用自己的桥接串口idf.py -p PORT flash monitor;不先擦NVS。原命令、构建输入SHA、日志和bin/ELF/map在display-project-builds.json。

运行README的clang++命令编译tests/timer_test.cpp再执行,15组实际通过:工作/休息快进、暂停/恢复、边界点击、大跳只完成一段、倒退时钟、参数边界、菜单取消、静音、检查点恢复暂停、256个位逐一翻转、截短/版本/范围拒绝、64位时间跨32位边界。它直接包含固件原头文件;快进仅在host注入时间,没有上板快进后门。另4项从实际main.cpp提取start/stop错误分支编译执行,检查成功/错误对changed与低输出请求的影响。它不验证ESP调度、真实LEDC、NVS掉电或声光效果。

做到这里,你应该看到
host里走完完整周期,用于核状态逻辑;真实1分钟流程另做观察。

参考:[6] 固定NVS get/set/commit API[10] 固定esp_timer单调时钟

08 / 桌面显示作品

先看到SAVE OK,再做一次有记录的断电

无检查点启动应25:00 / FOCUS / READY,不自动跑。设工作与休息各1分钟,真实跑工作→休息→工作,确认每段都手动开始,次数只在工作完成增加。运行中暂停一分钟剩余不应变化,返回设置也不续跑;静音再跑边界,核无提示及MUTED标签。

先等最新SAVE OK,记录阶段、剩余、参数、静音和generation,再正常拔USB、等待、重上电。应恢复最近检查点的参数与剩余并PAUSED,停电时间不扣。随后单独做SAVE PENDING时掉电,结果按实录,不保证最后动作保存;如记录无效则默认READY加SAVE ERROR。没有板时观察表必须留空,host解码不是Flash掉电测试。

  1. 记录正常运行、暂停等待、参数极值/取消、静音及RESET。
  2. 每次断电前区分SAVE PENDING / SAVE OK,保存串口与照片。
  3. 恢复后先不短按,核PAUSED剩余不变,再手动继续。
  4. 再测1~60/1~30分边界,实际快转漏步、音量和供电如实记。

参考:[6] 固定NVS get/set/commit API[7] 固定NVS存储与掉电说明

09 / 桌面显示作品

按错误所在层恢复,不擦掉证据

NVS初始化失败时应用不自动擦分区,保留RAM计时并SAVE ERROR;读不到键走默认READY,损坏记录也不自动运行。恢复默认参数可在菜单改回25/5并RESET,等SAVE OK;不是默认要求erase-flash。切换其他固件或分区可能失去检查点,不能当“断电保存已坏”的唯一证据。

无声音先看MUTED/AUDIO ERROR,断电查三针极性/分压/音量;不把5V接SIG。渲染失败会关背光并停止画图,计时/输入可能继续;不要把黑屏当暂停,断电检查后重启。CLOCK FAULT先保留日志再显式RESET。真实欠压要修供电,不能靠关闭检测继续展示“通过”。

参考:[1] DevKitC-1 v1.1排针[5] 固定LEDC duty/update/stop API[6] 固定NVS get/set/commit API[8] PID3885 STEMMA Speaker供电/信号/音量

BACK TO THE SOURCE

资料来源与版本说明

已核固定上游与实际安装源、真实构建及同源逻辑;实板效果未测,构建和host不替代屏幕观察。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. DevKitC-1 v1.1排针
  2. PEC11R手册
  3. PID3787引脚
  4. 固定ST7789
  5. 固定LEDC duty/update/stop API
  6. 固定NVS get/set/commit API
  7. 固定NVS存储与掉电说明
  8. PID3885 STEMMA Speaker供电/信号/音量
  9. PID3893三针PH2mm线
  10. 固定esp_timer单调时钟