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

不用触摸屏也能做菜单:旋钮选择、按下确认、返回

保留P09的准确旋钮,解决TFT引脚冲突;用有边界和草稿取消的菜单控制真实温湿度页,采样、输入与绘制各自推进。

读完能做什么
得到有边界、可确认和可取消的实体菜单,菜单操作期间采样任务仍发布有效或无效状态。
开始之前
已完成所列前置,能根据具体模块手册断电布线、复建固定版本工程并保存串口日志。
所需器材
官方ESP32-S3-DevKitC-1 v1.1,模组ESP32-S3-WROOM-1-N8R8(8MB Flash / Octal PSRAM);USB-to-UART桥接串口115200。、Bourns PEC11R-4215F-S0024(24定位/24脉冲),可靠组装转接板;不是未知5V旋钮模块、Adafruit PID3787 2022年11月EYESPI修订,排针已可靠焊接;SD卡槽空、EYESPI不用、Adafruit PID4885 SHT40,排针已可靠焊接;两个10kΩ片选上拉;可选SDA信号SPST
版本组合
参考板:官方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;无托管组件
适用范围

官方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#d07版本: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 / 桌面显示作品

合并旋钮与彩屏,先处理三处占脚冲突

P09已经占A6/B7/SW5,而D03原来的TFT复位6/背光7/片选5恰好冲突。把两段源码粘在一起不会自动让引脚兼容。本例保留P09的准确硬件与正交/按键状态机,只把TFT的TC/RT/BL迁到14/15/16。其他SPI/I2C引脚不乱换。

目标是三个可操作页面:温湿度、C/F草稿、版本说明。短按确认,长按返回或取消;快速旋转不能把数组索引推到边界外。真实采样始终独立运行,不能进入菜单后停住传感器,又把旧数字当成继续采样。

做到这里,你应该看到
纸上先列唯一引脚占用表,确认5/6/7只归旋钮。

参考:[1] DevKitC-1 v1.1排针[2] Bourns PEC11R引脚/定位/脉冲[3] PID3787具体排针、电平和背光

02 / 桌面显示作品

只用这一版合并后的接线表

PEC11R-4215F-S0024为24定位/24脉冲型号,必须按厂家底视图认A/B/C和独立按压开关,用可靠组装转接板;不能从一张旋钮正面照片猜引脚。A6/B7内部上拉,C接GND;开关一端SW5、另一端GND。DIRECTION=1定义11→10→00→01→11为正格,物理顺时针符号待测,相反时只改DIRECTION。

PID3787固定2022年11月EYESPI修订、1.54寸240×240 ST7789、排针已可靠焊接。VIN用3V3,SD槽空;TC14、CC13各10k上拉,RT15、BL16、DC4、SCK12、SI11、SO10,EYESPI/TE/3V输出留空。SHT40为S01的Adafruit PID4885,同样使用已可靠焊接排针;VIN3V3、SDA8、SCL9、0x44/100kHz,模块自带上拉。全部共地,断电改线。

功能GPIO / v1.1位置
旋钮A / B / SW6 J1-6 / 7 J1-7 / 5 J1-5
TFT TC / RT / BL14 J1-20 / 15 J1-8 / 16 J1-9
TFT SCK / SI / SO / DC / CC12 J1-18 / 11 J1-17 / 10 J1-16 / 4 J1-4 / 13 J1-19
SHT40 SDA / SCL8 J1-12 / 9 J1-15
电源 / 公共地3V3 J1-1 / GND J1-22

参考:[1] DevKitC-1 v1.1排针[2] Bourns PEC11R引脚/定位/脉冲[3] PID3787具体排针、电平和背光[6] SHT4x1.1.2 0xFD/10ms/换算[10] Adafruit PID4885 SHT40具体模块

04 / 桌面显示作品

菜单、输入与样本有效期用同一份头文件测试

MenuModel只处理离散格数和Button事件,完全不知道TFT、I2C或FreeRTOS。边界相加先提升int64_t,避免极大输入绕出数组。Reading独立记valid、64位esp_timer采样时刻和序号;CRC/I2C失败马上无效,超过5秒未有更新也不能继续显示旧值;年龄不依赖32位millis整圈,旧样本不能49天后又出现。单位转换只在渲染时做,原样本保留摄氏毫度和湿度千分数。

下列文件原样用于固件和host夹具。InputDecode保留P09的Quadrature/Button实现,没有悄悄换成另一种分辨率或中断算法;Sht40Logic与D03同源,0x31/0xFF检查每个16位测量字。

代码 / 配置
// ---- 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/MenuModel.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
#include "InputDecode.h"
struct MenuModel {
 enum Page { ROOT, LIVE, UNIT, ABOUT } page=ROOT;
 int selected=0,draftUnit=0;
 bool fahrenheit=false;
 static int bounded(int value,int delta,int hi){int64_t n=int64_t(value)+delta;return n<0?0:n>hi?hi:int(n);}
 void rotate(int step){if(page==ROOT)selected=bounded(selected,step,2);else if(page==UNIT)draftUnit=bounded(draftUnit,step,1);}
 void press(Button::Event e){
  if(e==Button::LONG){page=ROOT;return;}
  if(e!=Button::SHORT)return;
  if(page==ROOT){page=selected==0?LIVE:selected==1?UNIT:ABOUT;draftUnit=fahrenheit?1:0;}
  else if(page==UNIT){fahrenheit=draftUnit==1;page=ROOT;}
  else page=ROOT;
 }
};
// ---- main/SampleState.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
struct Reading {bool valid=false;int t=0,rh=0;uint64_t at=0;uint32_t sequence=0;bool fresh(uint64_t now)const{return valid&&now>=at&&now-at<=5000;}};
// ---- main/Sht40Logic.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
inline uint8_t sht_crc(const uint8_t* b){uint8_t c=0xff;for(int k=0;k<2;++k){c^=b[k];for(int j=0;j<8;++j)c=(c&0x80)?uint8_t((c<<1)^0x31):uint8_t(c<<1);}return c;}
inline bool decode_sht40(const uint8_t b[6],int& t,int& rh){
 if(sht_crc(b)!=b[2]||sht_crc(b+3)!=b[5])return false;
 uint16_t rt=(uint16_t(b[0])<<8)|b[1],rr=(uint16_t(b[3])<<8)|b[4];
 t=-45000+int((175000LL*rt)/65535);rh=-6000+int((125000LL*rr)/65535);
 if(rh<0)rh=0;
 if(rh>100000)rh=100000;
 return true;
}

参考:[2] Bourns PEC11R引脚/定位/脉冲[6] SHT4x1.1.2 0xFD/10ms/换算[7] Sensirion Core0.7.3 CRC

05 / 桌面显示作品

输入拥有菜单,采样和绘制各用自己的阻塞边界

输入任务优先级4、目标每1ms轮询A/B/SW,拥有MenuModel与最新Reading;传感器任务优先级3、每2秒测一次,I2C收发各100ms超时,0xFD后等10ms读取CRC数据。两个长度1覆盖队列传最新状态,消费迟了可以合并中间帧,不能承诺每一个点击已经上屏。

app_main独占TFT和DMA缓冲,只按队列画快照。输入先出队再取年龄时刻,避免另一核在两句间跨毫秒入队而暂判假无效。输入任务不等一整帧;采样不由页面切换启停。这个结构给业务留出独立推进的机会,但Flash/调度/串口与SPI中断仍会影响轮询,不是硬实时系统,也不宣称最高转速。

代码 / 配置
// ---- main/main.cpp ----
// SPDX-License-Identifier: MIT
#include <inttypes.h>
#include "driver/gpio.h"
#include "driver/i2c_master.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "MenuModel.h"
#include "SampleState.h"
#include "Sht40Logic.h"
#include "Tft.h"
static constexpr gpio_num_t A=GPIO_NUM_6,B=GPIO_NUM_7,SW=GPIO_NUM_5;
static constexpr int DIRECTION=1;
static QueueHandle_t sensorQueue,frames;
static hx::Tft screen;
static uint64_t monoMs(){return uint64_t(esp_timer_get_time()/1000);}
static uint32_t ms(){return uint32_t(monoMs());}
static uint8_t ab(){return uint8_t((gpio_get_level(A)<<1)|gpio_get_level(B));}
static void sensorTask(void*){
 i2c_master_bus_config_t bus_cfg{};bus_cfg.i2c_port=I2C_NUM_0;bus_cfg.sda_io_num=GPIO_NUM_8;bus_cfg.scl_io_num=GPIO_NUM_9;bus_cfg.clk_source=I2C_CLK_SRC_DEFAULT;bus_cfg.glitch_ignore_cnt=7;bus_cfg.flags.enable_internal_pullup=true;
 i2c_master_bus_handle_t bus;ESP_ERROR_CHECK(i2c_new_master_bus(&bus_cfg,&bus));
 i2c_device_config_t cfg{};cfg.dev_addr_length=I2C_ADDR_BIT_LEN_7;cfg.device_address=0x44;cfg.scl_speed_hz=100000;
 i2c_master_dev_handle_t dev;ESP_ERROR_CHECK(i2c_master_bus_add_device(bus,&cfg,&dev));
 Reading s;TickType_t wake=xTaskGetTickCount();
 for(;;){
  uint8_t cmd=0xfd,b[6];esp_err_t e=i2c_master_transmit(dev,&cmd,1,100);
  if(e==ESP_OK){vTaskDelay(pdMS_TO_TICKS(10));e=i2c_master_receive(dev,b,6,100);}
  s.valid=e==ESP_OK&&decode_sht40(b,s.t,s.rh);s.at=monoMs();++s.sequence;xQueueOverwrite(sensorQueue,&s);
  ESP_LOGI("D07","sample_seq=%" PRIu32 " valid=%u I2C=%s; input task independent",s.sequence,s.valid,esp_err_to_name(e));
  vTaskDelayUntil(&wake,pdMS_TO_TICKS(2000));
 }
}
static void inputTask(void*){
 Quadrature enc(ab());Button button(gpio_get_level(SW)==0,ms());MenuModel menu;Reading reading;hx::View v;
 TickType_t wake=xTaskGetTickCount();uint32_t oldInvalid=0;bool oldFresh=false;
 xQueueOverwrite(frames,&v);
 for(;;){
  // Read the queue before taking the age timestamp: producer may run on another core.
  bool changed=xQueueReceive(sensorQueue,&reading,0)==pdTRUE;
  uint64_t now=monoMs();
  int step=enc.update(ab())*DIRECTION;auto e=button.update(gpio_get_level(SW)==0,uint32_t(now));
  if(step){menu.rotate(step);++v.events;changed=true;}
  if(e!=Button::NONE){menu.press(e);++v.events;changed=true;}
  bool fresh=reading.fresh(now);if(fresh!=oldFresh){changed=true;oldFresh=fresh;}
  if(enc.invalid()!=oldInvalid){oldInvalid=enc.invalid();changed=true;}
  if(changed){v.page=menu.page;v.selected=menu.selected;v.draftUnit=menu.draftUnit;v.fahrenheit=menu.fahrenheit;v.valid=fresh;v.t_mC=reading.t;v.rh_mPermille=reading.rh;v.samples=reading.sequence;v.invalid=oldInvalid;xQueueOverwrite(frames,&v);}
  vTaskDelayUntil(&wake,pdMS_TO_TICKS(1));
 }
}
extern "C" void app_main(){
 gpio_config_t gpio{};gpio.pin_bit_mask=(1ULL<<A)|(1ULL<<B)|(1ULL<<SW);gpio.mode=GPIO_MODE_INPUT;gpio.pull_up_en=GPIO_PULLUP_ENABLE;gpio.intr_type=GPIO_INTR_DISABLE;ESP_ERROR_CHECK(gpio_config(&gpio));
 ESP_ERROR_CHECK(hx::init_bus());ESP_ERROR_CHECK(screen.init());
 sensorQueue=xQueueCreate(1,sizeof(Reading));frames=xQueueCreate(1,sizeof(hx::View));if(!sensorQueue||!frames){screen.backlight(false);ESP_LOGE("D07","STOP queue allocation");return;}
 TaskHandle_t sensor=nullptr;
 if(xTaskCreate(sensorTask,"sht40",3072,nullptr,3,&sensor)!=pdPASS){screen.backlight(false);ESP_LOGE("D07","STOP sensor task allocation");return;}
 if(xTaskCreate(inputTask,"menu_input",4096,nullptr,4,nullptr)!=pdPASS){vTaskDelete(sensor);screen.backlight(false);ESP_LOGE("D07","STOP input task allocation");return;}
 ESP_LOGI("D07","READY A6 B7 SW5 TFT_TC14 RT15 BL16; short confirm, long return/cancel; sample2s; no physical checks recorded");
 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("D07","STOP rendering %s; retain DMA buffer; input/sensor tasks may continue, RESET after inspection",esp_err_to_name(e));return;}ESP_LOGI("D07","frame_SPI_done page=%d selected=%d input=%" PRIu32 " sample=%" PRIu32 " valid=%u invalid=%" PRIu32,v.page,v.selected,v.events,v.samples,v.valid,v.invalid);}}
}

参考:[5] IDF5.5.5 SPI LCD完成回调[8] 固定IDF I2C主机API[9] 固定IDF FreeRTOS

06 / 桌面显示作品

复用明确的TFT驱动,不把DMA返回当实拍

本例保留D03的native esp_lcd驱动:SPI2_HOST mode0/4MHz、RGB565、LITTLE、RGB、inversion=true;rotation0是mirror X/Y、gap(0,80)。16行缓冲为7680B,在回调信号量完成后再写下一条,避免传输期间改缓冲。不是全屏双缓冲,逐条更新仍可能看到扫描感。

原创5×7字体只覆盖界面用到的大写字母/数字/标点。屏幕颜色、偏移、真实光学与四方向应先按D03色卡确认;本篇菜单图只是关系示意。draw失败关背光并停止后续绘制,保留可能仍被DMA引用的缓冲,输入/采样任务可能继续;检查后RESET,不能把黑屏当成采样已经停机。

代码 / 配置
// ---- 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,draftUnit=0;bool fahrenheit=false,valid=false;int t_mC=0,rh_mPermille=0;uint32_t samples=0,events=0,invalid=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,"MENU",12,20))return W;
  const char* items[]={"SENSOR","UNIT C/F","ABOUT"};for(int i=0;i<3;++i){if(ink(x,y,items[i],36,65+i*40))return W;if(v.selected==i&&ink(x,y,"+",12,65+i*40))return G;}
  if(ink(x,y,v.valid?"DATA OK":"DATA BAD",12,185))return W;
 }else if(v.page==1){
  if(ink(x,y,"ROOM SAMPLE",12,20))return W;
  if(!v.valid){if(ink(x,y,"NO SAMPLE",12,75))return W;}
  else{int t=v.fahrenheit?int((int64_t(v.t_mC)*9)/5+32000):v.t_mC;unsigned a=t<0?unsigned(-t):unsigned(t);snprintf(line,sizeof line,"T %c%u.%u %s",t<0?'-':'+',a/1000,(a%1000)/100,v.fahrenheit?"F":"C");if(ink(x,y,line,12,75))return W;snprintf(line,sizeof line,"RH %u.%u %%",unsigned(v.rh_mPermille)/1000,(unsigned(v.rh_mPermille)%1000)/100);if(ink(x,y,line,12,110))return W;}
  snprintf(line,sizeof line,"SAMPLE %lu",(unsigned long)v.samples);if(ink(x,y,line,12,150))return W;
 }else if(v.page==2){if(ink(x,y,"UNIT EDIT",12,20)||ink(x,y,v.draftUnit?"F SELECTED":"C SELECTED",12,75)||ink(x,y,"SHORT SAVE",12,120)||ink(x,y,"LONG CANCEL",12,155))return W;
 }else{if(ink(x,y,"IDF 5.5.5",12,20)||ink(x,y,"POLL 1MS",12,75)||ink(x,y,"SAMPLE 2S",12,115)||ink(x,y,"LONG BACK",12,155))return W;}
 snprintf(line,sizeof line,"INPUT %lu",(unsigned long)v.events);if(ink(x,y,line,12,210))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] IDF5.5.5 ST7789驱动[5] IDF5.5.5 SPI LCD完成回调

07 / 桌面显示作品

固定IDF复建,先跑十一组逻辑检查

工程锁ESP-IDFv5.5.5,驱动均来自SDK,无远程托管组件;sdkconfig.defaults固定S3/QIO8MB/Octal PSRAM80MHz/UART115200/FreeRTOS1000Hz。激活该版本环境,在工程根目录执行idf.py set-target esp32s3、idf.py build,随后用自己的桥接串口idf.py -p PORT flash monitor;不先擦整片。完整CMake、组件、头文件、版本锁和许可都在下载目录。

主机执行clang++ -std=c++17 -Wall -Wextra -Werror tests/menu_test.cpp -o /tmp/d07-menu-test,再运行它。实际11组通过,涵盖INT边界、草稿提交/取消、各页返回、合成正反各20格、非法跳变、长按/上电按住、业务更新交错与64位5秒有效期、跨32位边界及整圈不复活。交错业务项不是RTOS采样仿真。另2项提取实际main.cpp出队/取时片段,编译确定性100→101ms生产者stub核新顺序及旧顺序反例,不模拟双核硬件。实际固件构建也通过,原始日志与输入SHA可审计。

做到这里,你应该看到
编译通过不填写“旋钮实测20格通过”;实转结果单列。

参考:[9] 固定IDF FreeRTOS

08 / 桌面显示作品

让采样序号增长,同时做快转、边界与断信号

第一次上板先在ROOT慢转正反各20格,核物理方向,记录INPUT和invalid;再在第0/2项连续快转确认仍只落在0~2,实际漏步如实写。进UNIT选F后长按取消,确认回C;再次短按提交才变F。长按释放不能多进下一页,上电按住不能自动触发动作。

进入SENSOR并保持旋钮操作,记录sample_seq与帧里的sample继续推进,不能只凭“有三个任务”推断同时运行。断电预装SDA SPST;已有有效样本后运行时只断SDA信号,下一次失败应NO SAMPLE;若更新停止则5秒有效期后隐藏。闭合后下一次CRC成功恢复。另测冷启动缺模块,它不能替代“旧值消失”实验。

  1. 保存带时间的串口日志与实际页面照片。
  2. 同时记录转动方向/格数、按下时刻、当前页、INPUT、sample_seq、invalid。
  3. 断信号/恢复后记录首次无效与首次恢复,不制造反应延迟数字。
如果卡住了
菜单乱跳先核A/B/C、开关脚与上拉;屏幕异常先确认TC14/RT15/BL16已迁移,再回D03色卡排查;采样不变看I2C错误和模块供电。

参考:[1] DevKitC-1 v1.1排针[2] Bourns PEC11R引脚/定位/脉冲[8] 固定IDF I2C主机API

09 / 桌面显示作品

交付边界和下一件完整作品

源码核对、真实IDF构建、同源host逻辑已经完成;实板供电、图像、快速旋转、按压和采样同时运行仍待测。下载README给故障恢复,observations.csv只留列头。不要用三任务结构或11组host通过代替这些观察。

前篇P09单独核旋钮,D03单独核屏幕;本篇把两者接起来并说明冲突与状态所有权。下一篇在这套菜单之上做番茄钟,增加可暂停计时、提示音和有边界的断电保存。

BACK TO THE SOURCE

资料来源与版本说明

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

  1. DevKitC-1 v1.1排针
  2. Bourns PEC11R引脚/定位/脉冲
  3. PID3787具体排针、电平和背光
  4. IDF5.5.5 ST7789驱动
  5. IDF5.5.5 SPI LCD完成回调
  6. SHT4x1.1.2 0xFD/10ms/换算
  7. Sensirion Core0.7.3 CRC
  8. 固定IDF I2C主机API
  9. 固定IDF FreeRTOS
  10. Adafruit PID4885 SHT40具体模块