先约定一段结束后发生什么
默认工作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单调时钟
保留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共地 |
| 白线SIG | GPIO18 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线
长按先暂停,改参数需要明确确认
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手册
计时模型与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单调时钟
保存任务回执与输入任务计时分开
输入任务优先级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存储与掉电说明
显示保留传输边界,声音保留失败出口
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供电/信号/音量
真实构建与十五组同源快进检查
下载是完整独立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分钟流程另做观察。
先看到SAVE OK,再做一次有记录的断电
无检查点启动应25:00 / FOCUS / READY,不自动跑。设工作与休息各1分钟,真实跑工作→休息→工作,确认每段都手动开始,次数只在工作完成增加。运行中暂停一分钟剩余不应变化,返回设置也不续跑;静音再跑边界,核无提示及MUTED标签。
先等最新SAVE OK,记录阶段、剩余、参数、静音和generation,再正常拔USB、等待、重上电。应恢复最近检查点的参数与剩余并PAUSED,停电时间不扣。随后单独做SAVE PENDING时掉电,结果按实录,不保证最后动作保存;如记录无效则默认READY加SAVE ERROR。没有板时观察表必须留空,host解码不是Flash掉电测试。
- 记录正常运行、暂停等待、参数极值/取消、静音及RESET。
- 每次断电前区分SAVE PENDING / SAVE OK,保存串口与照片。
- 恢复后先不短按,核PAUSED剩余不变,再手动继续。
- 再测1~60/1~30分边界,实际快转漏步、音量和供电如实记。
按错误所在层恢复,不擦掉证据
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供电/信号/音量
把作品交付为可重复检查的完整目录
作者已完成固定源核对、实际IDF构建和同源15组host;没有实板,屏幕方向/光学、旋钮手感、LEDC实际波形、功放提示、供电与真实Flash断电都待测。README、版本锁、MIT/第三方许可和空observations.csv随源码一起下载。
前篇D07给菜单与引脚基础,P03说明非阻塞状态推进;本篇增加计时和有边界的持久化。先补齐一套自己的运行、静音、暂停、参数和掉电记录,再考虑外壳或长期桌面使用。
- 做到这里,你应该看到
- 记录保留空白比把编译或host结果写成硬件成功更准确。
资料来源与版本说明
已核固定上游与实际安装源、真实构建及同源逻辑;实板效果未测,构建和host不替代屏幕观察。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。