LCD显示效果图、电脑/手机端显示(同内网ip查看)
设备显示拍照(LCD显示有色差),电脑/手机端正常

电脑端(手机端)实时画面(电脑端略有延时,同内网192.168.3.201)

源代码
/*
* all_cam.cpp — 整合版 v1:摄像头 + LCD 实时显示 + SD 存图 + WiFi
* 烧录:platformio run -e all_cam -t upload
*
* 功能:
* · OV2640 QVGA(320x240) RGB565 逐帧推到 ILI9341 屏(免 JPEG 解码,零新依赖)
* · SD(TF) 卡保存 BMP 快照:BOOT 键按下 / 串口 'S' / 串口 'A' 开自动存图(30s)
* · WiFi:NVS 主备凭据依次尝试,成功置主;全空则进入网页配网 ESP32-SETUP
* · 屏幕底部状态条:帧率 / WiFi / 快照数 / SD 状态;顶部时间(对时后)
*
* 资源占用说明:
* · WS2812(IO48) 与摄像头 PCLK 共用 —— 本固件全程不碰彩灯!
* · IO0 = TF MISO = BOOT 键:SD 空闲期读按键没问题,传输期间勿按
* · LCD 用 FSPI,SD 用 HSPI,互不干扰
*
* 串口命令:S=存图 A=自动存图开关 M=重挂SD W=立即重连WiFi
* T=彩条自检(摄像头开着时连推3秒=干扰判据) D=转储解码缓冲像素
* F=LCD SPI时钟循环(10/20/5MHz) X=WiFi关/开(隔离射频)
* R=屏幕重初始化(lcdReinit)
* P=原生几何验证(全屏红+四角白块) O=退出探针恢复正常
* 9IEDITWIFI:SSID,PASSWORD=配网(回车结束)
*
* v14(LCD 攻坚:推屏期间让 PSRAM 总线安静 + SPI 降 20MHz):
* v13 实测:网页看图成功(摄像头链路实锤健康)、SD 自动挂上;LCD 出现
* "隐隐约约图像 + 底部1/4全花" —— 比全花前进,坏点仍是"推屏×并发"时段。
* ① fb_count 2→1:推屏期间驱动没有空闲缓冲可 DMA → 摄像头对 PSRAM 的
* 写流量在推屏窗口内归零(代价:cam_hal 每秒打约10条 EV-VSYNC-OVF,
* 属预期提示不是故障)。排除 PSRAM 总线争用这一变量。
* ② LCD SPI 40→20MHz:抗干扰余量翻倍;推屏 208行 66ms,帧率仍是 ~10fps。
* (若 v14 仍花,发 F 逐级试 40/10MHz 对比 —— 频率越低抗扰越强)
* ③ 网页图泛绿 = OV2640 室内白平衡偏色(非字节序错误,交换错会是红蓝
* 对调而非绿罩),后续可用 set_awb/饱和度调。
*
* v24(LCD 红蓝互换修复:MADCTL 0x48→0x40 清 BGR 位 —— v23 实测同帧数据网页正确
* 而 LCD 红蓝反,钉死面板 BGR 位语义与标准相反;三处 begin 后均重钉 0x40):
* v23(红蓝互换修复:BMP 像素字节序 B,G,R 写反成 R,G,B —— saveBMP+buildBMPFromFB 两处):
* 症状:网页/存卡图 红色似紫、蓝色似红(LCD 正常,因其不走 BMP 转换)。
* 修复:两处 565→888 转换的 R/B 输出字节对调(BMP 规范蓝在第一字节)。
* v22(v21 实测修复:Q 高清花屏根因=fb 缓冲不重分配 + 网页乱码/刷新慢):
* · 根因:fb 缓冲只在 esp_camera_init 时按初始 frame_size 分配,运行时
* set_framesize(VGA) 后 fb->width=640 但缓冲仍是 150KB → 读越界
* (网页三重像+LCD 花屏。修复:Q 命令改为 esp_camera_deinit+init 重开相机);
* · VGA 模式 fb_count=2(600KB×2=1.2MB PSRAM,兼容 2MB PSRAM 板);
* · 网页加 <meta charset=utf-8>(中文乱码);刷新改为 img.onload 后 300ms
* 连续刷新(不再固定 2 秒);esp_log_level_set("WiFiClient") 压浏览器
* 中断传输的 write 错误刷屏。
*
* v21(画质动态调参 + 高清模式 —— v20 实测画面正常,坏帧0%,进入调优阶段):
* 用户问题:颜色不真实、清晰度能否调、摄像头分辨率、如何动态调参。
* 清晰度知识:清晰度由摄像头决定(分辨率+镜头+传感器 ISP),屏只是 320x240
* 像素显示不增信息;OV2640 当前 QVGA(320x240) 直采,最高支持 UXGA(1600x1200)。
* 改动:
* ① 单字符调参命令(即到即执行,按一下换一档,串口打印当前值):
* B=亮度(-2..2) C=对比度(-2..2) U=饱和度(-2..2) G=白平衡模式(自动/晴天/
* 阴天/办公室/家里) N=白平衡增益开关 L=镜头阴影校正 Y=特效 V=上下翻转
* I=左右镜像 K=打印当前全部参数
* ② Q=清晰度模式:QVGA直采(流畅~15fps) ↔ VGA(640x480) 2x2均值到屏
* (高清模式保留全量传感器信息,实际锐度更高,~9fps)
* ③ fb_count 2→4:吸收推屏期间的 VSYNC 溢出,压 EV-VSYNC-OVF 日志刷屏
* ④ saveBMP 行缓冲 320→640(高清模式拍照 VGA BMP 不溢出)
*
* v20(真凶二定案:推屏字节序反了 —— v18/v19 的 memcpy 等于逐像素字节交换):
* v19 实测:fb_count=2 后坏帧率仍 98%(差恒 3300~3600)→ "单缓冲撕裂"论破产,
* 帧数据本身没坏,是 ① 推屏字节序 ② 判定阈值两个软件问题。
* 证据链闭环:
* · v17(逐像素 v=(b0<<8)|b1 组装 + writePixels 默认交换)= 唯一"能看到实物"的版本
* · v18/v19(memcpy 进 stage = 小端读 MSB-first 字节流 = 每像素字节交换)= 花屏+泛绿
* · 用户从 v13 起持续抱怨"泛绿" = 绿色分量跑到高位的字节交换特征
* · v15 的"bigEndian 修复"结论是在几何错乱(竖屏)的屏上得出的,不可靠
* 对策:
* ① lcdPushArea 改回 v17 已验证正确的组装:stage[i]=(b0<<8)|b1(b0=R5G3 高字节),
* writePixels 默认交换 → 高字节先发 ✓。与彩条路径真正等价(彩条的 stage 本来
* 就是真值,memcpy 对彩条恰好无害,对摄像头帧才有害)。
* ② 判定阈值 3000→6000:真实场景相邻像素差可达 3000~4000(此前 98% 好帧被误杀)。
* ③ 开机信息显示完后 fillScreen 清屏 → 消灭"Camera:/SD:/WiFi:"文字残影。
*
* v19(摄像头帧结构损坏根因:S3 + RGB565 + fb_count=1 单缓冲 —— 双缓冲+坏帧不上屏):
* v18 实测(串口日志铁证):
* ① 开机首帧判定 1754=正常画面(摄像头链路本身好)
* ② 循环取帧后每帧判定恒 3927~3983 "可疑" —— 数值恒定=结构性损坏
* (随机噪声会 >15000;EMI 干扰也不会帧帧都停在 3975 附近)
* ③ EV-VSYNC-OVF 每秒 10+ 条刷屏
* 定性:ESP32-S3 的 cam_hal 在 fb_count=1(单缓冲)+ 连续采集时,VSYNC 溢出后
* 取出的帧带固定模式撕裂(v14 为"PSRAM 总线安静"改的单缓冲方向反了)。
* 对策:
* ① fb_count 1→2(PSRAM 300KB 无压力)+ GRAB_LATEST —— S3 官方推荐配置
* ② 坏帧不上屏:每帧抽样判定,差≥3000 直接丢帧不推 —— 屏上永远只有好帧,
* 闪烁花屏物理消失。同时是新判据:
* 坏帧率高 + 屏上画面冻结正常 = 帧数据坏(摄像头侧)
* 坏帧率低 + 屏上仍花 = 推屏路径坏(LCD 侧)
* ③ esp_log_level_set("cam_hal", ESP_LOG_NONE) 压掉 OVF 刷屏;
* 每 300 帧打印坏帧统计代替。
*
* v18(终解:面板 MV 位与标准 ILI9341 相反 —— 保持 0x48 横屏态,库用横屏坐标,
* 推屏不再转置,状态栏不推画面根治闪烁,单字符命令即到即执行):
* v17 实测(视频帧证据,全部闭环):
* ① 文字横排可读:逻辑y→物理y直接映射(无旋转)✓
* ② FPS 栏出现在 ~27% 高度:逻辑 y=304..319 被 PASET 在 240 处回绕 → 64..79 ✓
* ③ 右侧 ~25% 噪点:转置窗口 CASET 只写 0..239,列 240..319 无人写 ✓
* ④ 两行字闪烁:转置推屏把逻辑行 240..319 回绕覆盖到物理 0..79,每帧冲掉
* 两条状态栏再由 overlay 重画 = 帧频闪烁 ✓
* ⑤ P/O 无反应:用户的串口工具发命令不带回车,旧代码必须等回车才执行 ✗
* 结论:本屏(克隆控制器)MADCTL 的 MV 语义与标准 ILI9341 相反:
* 0x48(initSequence 默认,无 MV)→ 横屏 320x240 有效(CASET=横向, PASET=纵向240回绕)
* 0x68(setRotation(1),带 MV)→ 反而竖屏(v13~v16 竖排文字的根源)
* 对策:
* ① lcdInit: setRotation(1)(库坐标=320x240 横屏)后立刻 sendCommand(MADCTL,0x48)
* 把面板钉回横屏态 —— 库窗口几何与面板实际完全一致,一切坐标直来直去。
* ② 推屏不再转置:摄像头 320x240 帧直接推(与彩条同路径),方向与网页看图一致。
* ③ 状态栏(顶部 0..15 / 底部 224..239)区域不推画面 → 永不被覆盖,闪烁根治;
* overlay 只在变化时重画(去掉每帧重画)。
* ④ 单字符命令即到即执行(不等回车);9IEDITWIFI 行协议不受影响('9' 起缓冲)。
*
* v17(终极修复:面板不吃旋转命令 → 放弃 setRotation,全部软件转置):
* v16 实测:ID/MADCTL 回读全 0 —— 这类板载屏 SPI 只写(LCD MISO 未接主控),
* 回读证据作废。但照片几何证据依旧成立:文字竖排、纯红只盖 75%
* (240/320)→ 面板始终以原生竖屏 240x320 显示,MADCTL 旋转写不生效。
* 对策(不再依赖任何寄存器行为):
* ① 全库统一 setRotation(0)(原生竖屏坐标,Adafruit 的窗口/文字/填充
* 几何全部正确);凡"横屏 320x240 图像"推屏前软件转置 (nx,ny)=(y,x)。
* ② lcdPushTransposed:逐原生行组装(每行 240 像素,源帧按列步进读取),
* 字节序与已验证清晰的彩条路径一致(v=(b0<<8)|b1,writePixels 默认交换)。
* ③ 状态栏改画在原生顶部/底部横条(每帧推图后重画,不闪)。
* ④ P 探针改为原生几何验证:全屏红(铺满=几何正确)+ 四角白块 + 中央文字。
* 代价:转置为逐像素读(PSRAM 列步进),每帧约增加几毫秒,可接受。
*
* v16(一锤定音:屏几何没对上 —— 面板在竖屏 240x320,我们按横屏 320x240 写):
* 铁证(照片,非推测):
* ① 开机自检全屏红(此时摄像头/WiFi/SD 全未启动,总线除这一个命令外完全
* 空闲)只盖住 ~75%,下面 1/4 是整齐的未写区噪声。240/320 恰好=75%。
* ② SNAP 状态栏显示在屏面 ~70% 宽度处、时间条在最左边缘竖排 —— 全部符合
* "面板竖屏 240x320 + 我们横屏寻址":每行 320 像素里 80 个落到不存在的
* 列上被丢弃,逻辑行 240~319 从未被写过。文字/彩条窗口小恰好看不出,
* 摄像头大窗口一推就散架(行错位=横向条纹)。
* 手段:
* ① lcdInit 后读回控制器 ID(0xD3) 与 MADCTL(0x36) 回读 —— 验证面板到底
* 吃没吃 setRotation 的 MV 位(吃=回读 0x28;不吃=仍是默认值)。
* ② 新增 P=几何探针:逐个旋转 0→1→2→3,全屏红+白色角标+屏中央大字标注;
* O=退出探针恢复正常。红色铺满整屏无噪点的那个旋转=真实几何。
* ③ saveBMP 加 flush+大小校验(消灭 0 字节文件);开机存证前等 NTP 同步
* (最多 8 秒,消灭 1980 时间戳)。
* ④ 摄像头 set_saturation+1 改善室内泛绿/发灰。
*
* v15(LCD 花屏根因修复:字节序 flag 用反了):
* 根因:lcdPushCamArea 调用 writePixels(stage, n/2, true, true) 显式传了
* bigEndian=true。在 ESP32 上该分支走 writeBytes 直发内存字节序(不交换),
* 而 stage 是从 fb 按字节 memcpy 进来的 uint16_t(小端)→ 低字节(G3B5)先发、
* 高字节(R5G3)后发,与 ILI9341 要的线序相反 = 整屏 RB 互换/偏绿花屏。
* 彩条走默认 bigEndian=false(核心 MSB_PIX_SET 把高字节先发)所以清晰。
* 修复:摄像头也改成 writePixels(stage, n/2, true),与彩条完全一致即可。
* 这是纯用法 bug,与硬件/EMI 无关 —— 修完应直接看到正常彩色实时画面。
*
* v13(免读卡看图 + 自动重挂SD + 降XCLK):
* ① WiFi 浏览器实时看图:连 WiFi 后用手机/电脑浏览器打开串口打印的
* http://IP/ —— 每秒自动刷新抓一帧 BMP 显示,彻底摆脱"拔卡读卡器"。
* SD 挂载失败也不影响网页看图(图直接走 WiFi,不落卡)。
* ② SD 每 15s 自动重试挂载(卡座接触不良时重插卡即可,无需发 M)。
* mountSD 改为复用 SPI 实例,避免反复 new 泄漏。
* ③ 摄像头 XCLK 20MHz → 10MHz:帧率降到 ~10fps(LCD 推屏本就 ~8-12fps),
* PCLK 翻转频率减半(EMI/WS2812 误亮都会减轻),VSYNC-OVF 明显减少。
* ④ 黄灯说明:IO48 = WS2812 = 摄像头 PCLK 共用,摄像头一跑 PCLK 翻转
* 信号就会被 WS2812 当成数据点亮(常亮某色=正常现象,不是故障)。
*
* v12(统一结论版:RGB565 直推 + 40MHz,砍掉 JPEG 解码):
* ① RGB565 直采当年被误判"帧损坏"(判定9400~11600) —— 那其实是 esp32-camera
* RGB565 的字节交换特征(相邻像素差被抬到中间带,非噪声)。摄像头数据线
* 由 JPEG 通路证明完好 → RGB565 从来没坏,坏的只是字节序处理。
* ② 10MHz 推屏 117ms + 解码 34ms = 每帧 88% 时间在传输,摄像头流式期间
* 任何扰动都打在推屏上 → 帧帧全坏。出厂固件直推 RGB565 + 更快 SPI,
* 暴露窗口短几倍,扰动看不出来。
* ③ v12:LCD SPI 默认 40MHz(板载走线 IOMUX 原生脚),开机彩条立刻验证:
* 彩条乱 = 40MHz 过快,发 F 逐级降 40→20→10→5(80 会循环回来)。
* ④ 推摄像头帧与彩条共用 writePixels(bigEndian 默认 false,核心内建字节交换);
* 此前误传 bigEndian=true 是 v15 才修复的字节序 bug 根源,详见 v15。
* ⑤ 快照直接从 RGB565 fb 存 BMP(带字节序处理),不再依赖 JPEGDEC。
*
* v10(v9 照片定性:底栏下方多噪点+顶栏被串写 → 有像素写到窗口外 = DC/CS
* 在长突发传输中被干扰成命令,面板地址窗口被打乱。文字(短突发)清晰、
* 开机彩条(摄像头未开)清晰、D 判定正常 → 推屏函数没问题,坏在
* "摄像头全速采集期间"的总线受扰 = EMI/电源,物理层问题。
* 新增三个现场判据命令,无需反复烧录:
* T = 摄像头采集中连续推 3 秒彩条(静态内容若也花 = 传输受扰实锤)
* F = LCD SPI 时钟循环 10→20→5→10 MHz(试更低频率能否缓解)
* X = WiFi 关/开(隔离射频与电源:关 WiFi 后明显好转 = 射频/电源侧)
* 花屏现场处理顺序:先发 R(恢复=面板状态被打乱),再发 T,再发 X
*
* v7(照片新发现两件事):
* ① 中间图像区是"有结构的横向条纹花屏"而非纯噪点 → 行级干扰,
* 支持 v6 的同频干扰定性(v6 已修:10MHz + 内部RAM中转)。
* ② FPS:0 / SNAP:37 / 文件号 cam1245 → IO0(BOOT)与 TF MISO 共用,
* 卡信号拉低 IO0 造成"幽灵按键"疯狂拍照、饿死显示循环。
* 修复:按键需持续低 200ms 且回高后才允许下一次(一次一拍)。
* ⚠️ v6 的 lcdPushFrame 中转版曾被编辑器旧缓冲覆盖丢失,本次已重新补回,
* 编译前请先在 VSCode 里 Revert/重开本文件确认是最新版!
*
* v6(v5 实测后修复:判定776=解码好、屏仍噪点、开机彩条清晰):
* 定性:坏点在"推屏传输 × 摄像头流式采集"叠加时。LCD SPI 20MHz 与
* 摄像头 20MHz 晶振同频,采集时 PCLK/数据线全速翻转造成同频干扰。
* ① LCD SPI 20MHz → 10MHz(避开同频点)
* ② 推屏分块经内部 RAM 中转(16行/块),排除 PSRAM 直读路径
*
* v5(推屏噪点定位:解码已判好(845)但屏上仍噪点):
* ① jpegDrawCB 加越界裁剪+计数(防 JPEG 尺寸异常写花内存)
* ② 首帧打印 JPEG 实际分辨率(验证是否真 320x240)
* ③ 前 3 帧打印 解码耗时/推屏耗时/判定值
* ④ 新增 D 命令:转储 decBuf 像素——若 D 显示正常值而屏上噪点,
* = 坏在推屏传输(摄像头流式采集期间干扰),降 SPI 频率/查线
* ⑤ 关键实测:屏幕噪点出现时发 T(彩条)——彩条若也噪 = 传输/干扰问题;
* 彩条清晰而画面噪 = 解码缓冲内容问题
*
* v2(修花屏 + VSYNC-OVF 刷屏 + 配网指令):
* ① 推屏改用 Adafruit 官方路径 startWrite/setAddrWindow/writePixels
* (ESP32 上走 DMA 且内建字节交换,窗口由库管理,不再手写 CASET/PASET)
* ② PSRAM 下 fb_count=2,减少 EV-VSYNC-OVF 报警(推屏期间丢帧属正常)
* ③ 新增 9IEDITWIFI: 串口配网(置主+存NVS+立即重连)
* ④ 存图后屏幕显示 SAVED 提示 800ms,串口同步打印文件名
*
* v3(花屏诊断:一次烧录自动区分"屏"还是"摄像头"):
* ① 开机自动自检:红→绿→蓝全屏填充(纯 fillRect,不涉内存拷贝),
* 然后推 8 色彩条 1.2 秒 → 彩条乱 = 推屏/接线问题;彩条好 = 屏没问题
* ② 画面客观判定:前 3 帧计算"相邻像素平均差"(真实画面 <3000,随机噪声 >15000)
* 串口直接打印结论,不再肉眼猜
* ③ 开机自动存一张 BMP 到 SD → 拔卡插读卡器看:BMP 正常 = 摄像头好、
* 推屏坏;BMP 也是雪花 = 摄像头 RGB565 帧本身是垃圾(换 JPEG+解码路线)
*
* v4(实测定性后的修复):
* v3 实测:彩条/文字/填充全部清晰(屏+推屏通路好),但 RGB565 帧判定值
* 9400~11600(中间带=有结构损坏)→ S3 esp32-camera RGB565+PSRAM 直采帧损坏。
* JPEG 通路此前已验证出标准 FF D8 帧 → v4 改走 JPEG 采集 + JPEGDEC 软解码:
* 摄像头出 JPEG → 内存解码成 RGB565 → 推屏/存 BMP,完全绕开坏的数据路径。
* 副产物:以后可直接存 .jpg(体积小 20 倍),本版先保持 BMP 方便对比。
*/
#include <Arduino.h>
#include <esp_camera.h>
#include <SPI.h>
#include <SD.h>
#include <WiFi.h>
#include <WebServer.h> // v13:浏览器看图
#include <time.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include "pins.h"
#include "wifi_creds.h"
#include "prov_ap.h"
// ================= 全局状态 =================
static SPIClass *lcdSPI = nullptr;
static Adafruit_ILI9341 *tft = nullptr;
static SPIClass *sdSPI = nullptr;
static bool camOK = false, sdOK = false, wifiOK = false;
static uint32_t frameCount = 0, saveCount = 0, snapIndex = 0;
static uint32_t goodFrames = 0, badFrames = 0; // v19:坏帧统计
static uint32_t fpsStart = 0, fpsFrames = 0, fpsShow = 0;
static bool autoSave = false;
static uint32_t lastAutoSave = 0;
static uint32_t lastWifiTry = 0;
static bool wifiUserOff = false; // v11:X 主动关 WiFi 后,禁用 30s 自动重连
static char curSsid[33] = "";
static time_t nowTime;
static struct tm tmNow;
static WifiCreds creds;
// 存图成功后的屏幕提示(800ms)
static char snapMsg[32] = "";
static uint32_t snapMsgUntil = 0;
// ================= 摄像头 =================
// v22:fb 缓冲只在 esp_camera_init 时按 frame_size 一次性分配 —— 运行时
// set_framesize 切更大分辨率,缓冲不够 → 读越界(网页三重像+LCD 花屏的根因)。
// 切清晰度必须 deinit+init 重开相机。
static bool initCamera(framesize_t fs)
{
camera_config_t cfg = {}; // 零初始化
cfg.pin_pwdn = -1;
cfg.pin_reset = -1;
cfg.pin_xclk = -1; // 外部 20MHz 有源晶振
cfg.pin_sccb_sda = PIN_CAM_SIOD;
cfg.pin_sccb_scl = PIN_CAM_SIOC;
cfg.pin_d0 = PIN_CAM_D2; // sensor D2~D9 → 驱动 d0~d7(LSB=D2)
cfg.pin_d1 = PIN_CAM_D3;
cfg.pin_d2 = PIN_CAM_D4;
cfg.pin_d3 = PIN_CAM_D5;
cfg.pin_d4 = PIN_CAM_D6;
cfg.pin_d5 = PIN_CAM_D7;
cfg.pin_d6 = PIN_CAM_D8;
cfg.pin_d7 = PIN_CAM_D9;
cfg.pin_vsync = PIN_CAM_VSYNC;
cfg.pin_href = PIN_CAM_HREF;
cfg.pin_pclk = PIN_CAM_PCLK;
cfg.xclk_freq_hz = 20000000;
cfg.ledc_timer = LEDC_TIMER_0;
cfg.ledc_channel = LEDC_CHANNEL_0;
cfg.pixel_format = PIXFORMAT_RGB565; // v12:RGB565 直采(原 JPEG,见文件头 v12 说明)
cfg.frame_size = fs; // v22:由调用方指定(开机 QVGA / Q 命令切换)
if (psramFound())
{
// v22:QVGA 用 4 缓冲压 OVF 刷屏;VGA 每帧 600KB,用 2 缓冲省 PSRAM
cfg.fb_count = (fs == FRAMESIZE_VGA) ? 2 : 4;
cfg.fb_location = CAMERA_FB_IN_PSRAM;
}
else
{
cfg.fb_count = 1;
cfg.fb_location = CAMERA_FB_IN_DRAM; // 320x240x2=150KB,勉强可行
}
cfg.grab_mode = CAMERA_GRAB_LATEST; // 实时画面取最新帧
// v12:RGB565 直采(JPEG 路线已验证摄像头数据完好,直推省 34ms 解码 + 少一半 PSRAM 流量)
cfg.pixel_format = PIXFORMAT_RGB565;
esp_err_t err = esp_camera_init(&cfg);
if (err != ESP_OK)
{
Serial.printf("[相机] 初始化失败: 0x%x\n", err);
return false;
}
// v19:压掉 cam_hal 的 EV-VSYNC-OVF 刷屏(双缓冲后本就大幅减少)
esp_log_level_set("cam_hal", ESP_LOG_NONE);
// 轻微提升画面观感(失败忽略)
sensor_t *s = esp_camera_sensor_get();
if (s)
{
s->set_vflip(s, 0);
s->set_hmirror(s, 0);
s->set_saturation(s, 1); // v16:+1 档饱和度,改善室内泛绿/发灰
s->set_awb_gain(s, 1); // 自动白平衡增益开
s->set_lenc(s, 1); // v21:镜头阴影校正开(改善边缘色偏/暗角,颜色更真实)
}
Serial.println("[相机] 初始化成功 (QVGA RGB565)");
return true;
}
// ================= v12:RGB565 直推(无解码)=================
// esp32-camera 的 RGB565 帧在 fb->buf 里是 MSB-first 字节流(传感器发送顺序),
// 正好就是 ILI9341 要的线上顺序 —— 直接 writeBytes 推,不做任何交换。
// (uint16 小端读出来会得到字节交换的值,这就是当年误判"帧损坏"的来源)
// 判定用:按交换后的真实像素值计算相邻像素平均差(真实画面 <3000,随机噪声 >15000)
static float frameSanityRGB(const uint8_t *buf, int w, int h)
{
uint32_t acc = 0;
size_t n = 0;
for (int y = 0; y < h; y += 4)
{
const uint8_t *row = buf + (size_t)y * w * 2;
for (int x = 0; x + 1 < w; x += 4)
{
uint16_t a = ((uint16_t)row[x * 2] << 8) | row[x * 2 + 1];
uint16_t b = ((uint16_t)row[x * 2 + 2] << 8) | row[x * 2 + 3];
int d = (int)a - (int)b;
acc += (d < 0) ? -d : d;
n++;
}
}
return n ? (float)acc / n : 0.0f;
}
// ================= LCD =================
static uint32_t lcdClockHz = 20000000; // v14:40→20MHz 抗干扰余量翻倍;F 循环 20→10→5→80→40
static void lcdInit()
{
lcdSPI = new SPIClass(FSPI);
lcdSPI->begin(PIN_TFT_SCK, PIN_TFT_MISO, PIN_TFT_MOSI, PIN_TFT_CS);
pinMode(PIN_TFT_CS, OUTPUT);
digitalWrite(PIN_TFT_CS, HIGH);
pinMode(PIN_TFT_DC, OUTPUT);
pinMode(PIN_TP_CS, OUTPUT); // 触摸片选拉高防干扰
digitalWrite(PIN_TP_CS, HIGH);
tft = new Adafruit_ILI9341(lcdSPI, PIN_TFT_DC, PIN_TFT_CS);
tft->begin(lcdClockHz);
// v18:本屏(克隆控制器)MADCTL 的 MV 位语义与标准 ILI9341 相反:
// 0x48(begin 的 initSequence 默认)= 横屏 320x240 有效;0x68(旋转1)= 反而竖屏。
// 做法:setRotation(1) 让库坐标变成 320x240 横屏,随后立即把 MADCTL 钉回 0x48,
// 面板保持横屏有效态 → 库的窗口/文字/填充几何与面板实际完全一致,坐标直来直去。
tft->setRotation(1); // 库:width=320, height=240(此调用会发 0x68,下一行覆盖)
// v24:0x48 = 0x40(MX横屏) | 0x08(BGR)。v23 实测铁证:同一摄像头帧网页颜色正确、
// LCD 红蓝互换(红流苏变蓝/葫芦变蓝紫)→ 本面板 BGR 位语义与标准相反,
// 必须清掉 BGR 位。改 0x40 后彩条应为:红黄绿青蓝紫白黑。
uint8_t mad = 0x40; // v23 的 0x48 → v24 清 BGR 位(0x08)= 面板按 RGB 顺序解析
tft->sendCommand(ILI9341_MADCTL, &mad, 1);
}
// v18:通用区域推屏 —— 面板已钉在横屏有效态(MADCTL=0x48),摄像头 320x240 帧
// 与面板几何完全一致,不再转置。
// v20 关键修复:摄像头 fb 是 MSB-first 字节流(b0=R5G3 高字节在前),memcpy 进
// uint16_t stage 会按小端读成 (b1<<8)|b0 = 每像素字节交换(绿跑高位=泛绿花屏)。
// 必须逐像素组装真值 stage[i]=(b0<<8)|b1 —— 与 v17 唯一显示正常的路径完全一致,
// 也与彩条路径真正等价(彩条 stage 本来就是真值,memcpy 恰好无害)。
static void lcdPushArea(const uint8_t *src, int w, int y0, int rows)
{
const size_t CHUNK = 16 * 320; // 16 行/块 = 10KB 内部 RAM 中转
static uint16_t stage[16 * 320];
tft->startWrite();
tft->setAddrWindow(0, y0, w, rows);
const uint8_t *s = src + (size_t)y0 * w * 2; // 跳过源顶部 y0 行
size_t done = 0, total = (size_t)rows * w;
while (done < total)
{
size_t n = total - done;
if (n > CHUNK)
n = CHUNK;
const uint8_t *p = s + done * 2;
for (size_t i = 0; i < n; i++) // v20:b0=高字节(R5G3)在前 → 组装真值再交核心交换
stage[i] = ((uint16_t)p[i * 2] << 8) | p[i * 2 + 1];
tft->writePixels(stage, n, true);
done += n;
}
tft->endWrite();
}
// 全帧推屏(彩条自检等 320x240 缓冲)—— v18 起直推不转置
static void lcdPushFrame(uint8_t *buf, int w, int h)
{
(void)h;
lcdPushArea((const uint8_t *)buf, w, 0, 240);
}
// v21:高清模式推屏 —— 摄像头输出 VGA(640x480),2x2 均值降到 320x240 再推。
// QVGA 是传感器 4 像素跳 1 采样;VGA 均值保留全量信息,实际锐度更高。
// 只推中间 208 行(对应源 32..447 行),顶/底状态栏预留区不碰。
static void lcdPushHQ(const uint8_t *src, int y0, int rows)
{
static uint16_t stage[16 * 320];
tft->startWrite();
tft->setAddrWindow(0, y0, 320, rows);
for (int r0 = 0; r0 < rows; r0 += 16)
{
int n = (rows - r0 < 16) ? (rows - r0) : 16;
for (int j = 0; j < n; j++)
{
int sy = (y0 + r0 + j) * 2;
const uint8_t *rA = src + (size_t)sy * 1280; // VGA 行宽 640*2 字节
const uint8_t *rB = rA + 1280;
for (int x = 0; x < 320; x++)
{
int o = x * 4; // 每输出像素 = 2x2 源像素(各 2 字节)
uint16_t a = ((uint16_t)rA[o] << 8) | rA[o + 1];
uint16_t b = ((uint16_t)rA[o + 2] << 8) | rA[o + 3];
uint16_t c = ((uint16_t)rB[o] << 8) | rB[o + 1];
uint16_t d = ((uint16_t)rB[o + 2] << 8) | rB[o + 3];
uint16_t rr = ((a >> 11) & 0x1F) + ((b >> 11) & 0x1F) + ((c >> 11) & 0x1F) + ((d >> 11) & 0x1F);
uint16_t gg = ((a >> 5) & 0x3F) + ((b >> 5) & 0x3F) + ((c >> 5) & 0x3F) + ((d >> 5) & 0x3F);
uint16_t bb = (a & 0x1F) + (b & 0x1F) + (c & 0x1F) + (d & 0x1F);
stage[j * 320 + x] = ((rr >> 2) << 11) | ((gg >> 2) << 5) | (bb >> 2);
}
}
tft->writePixels(stage, (size_t)n * 320, true); // 与 v20 已验证路径同序
}
tft->endWrite();
}
// v18:摄像头帧推屏 —— 只推中间 208 行(16..223),顶/底状态栏区域不碰,
// 状态栏永不被画面覆盖 → 文字闪烁根治。
static void lcdPushCamArea(const uint8_t *srcBuf, int w)
{
if (w == 640)
lcdPushHQ(srcBuf, 16, 208); // v21:VGA 帧 2x2 均值(Q 高清模式)
else
lcdPushArea(srcBuf, w, 16, 208);
}
// 状态条:v18 横屏坐标 —— 顶部 0..15 行、底部 224..239 行各一条黑底横条。
// 摄像头推屏只写中间 16..223 行,永不覆盖状态栏 → 不闪。
// 只在内容变化时重画(loop 的 overlayDirty / 每秒 FPS 刷新)。
// 横屏宽 320px / 6px每字符 = 50 字符定宽。
static bool overlayDirty = true; // 状态变化时置 true,loop 里立即重画
static void lcdOverlay()
{
char line[52];
int n;
// 顶部:时间 + WiFi
if (wifiOK && time(nullptr) > 100000)
{
localtime_r(&nowTime, &tmNow);
n = snprintf(line, sizeof(line), "%02d:%02d:%02d %s",
tmNow.tm_hour, tmNow.tm_min, tmNow.tm_sec, curSsid);
}
else if (wifiOK)
{
n = snprintf(line, sizeof(line), "WiFi:%s", curSsid);
}
else
{
n = snprintf(line, sizeof(line), "WiFi:OFF (W=retry)");
}
while (n < 50)
line[n++] = ' '; // 定宽抹除残留
line[n] = 0;
tft->setTextSize(1);
tft->setTextColor(wifiOK ? ILI9341_GREEN : ILI9341_RED, ILI9341_BLACK);
tft->setCursor(4, 4);
tft->print(line);
// 底部:SAVED 提示优先,否则 FPS / SD / 快照
if (millis() < snapMsgUntil)
{
n = snprintf(line, sizeof(line), "%s", snapMsg);
tft->setTextColor(ILI9341_YELLOW, ILI9341_BLACK);
}
else
{
n = snprintf(line, sizeof(line), "FPS:%d SD:%s SNAP:%u%s",
(int)fpsShow, sdOK ? "OK" : "NO", (unsigned)saveCount,
autoSave ? " AUTO" : "");
tft->setTextColor(ILI9341_CYAN, ILI9341_BLACK);
}
while (n < 50)
line[n++] = ' ';
line[n] = 0;
tft->setCursor(4, tft->height() - 12);
tft->print(line);
}
// 画面客观判定:见 frameSanityRGB(v12:按字节交换后的真实像素值计算)
// ================= SD 卡 =================
static bool mountSD()
{
if (!sdSPI)
{ // v13:SPI 实例只建一次(自动重挂会反复调用,不能反复 new)
sdSPI = new SPIClass(HSPI);
sdSPI->begin(PIN_TF_SCK, PIN_TF_MISO, PIN_TF_MOSI, PIN_TF_CS);
}
// SPI 模式挂载(含 1 次重试)
for (int i = 0; i < 2; i++)
{
if (SD.begin(PIN_TF_CS, *sdSPI, 4000000))
{
uint8_t type = SD.cardType();
Serial.printf("[SD] 挂载成功 type=%d %lluMB\n", type,
SD.cardSize() / (1024ULL * 1024ULL));
return true;
}
delay(300);
}
return false; // v13:自动重试会再打印,这里不刷屏
}
// v12:存 BMP —— 源是 fb 的 RGB565 MSB-first 字节流(byte0=R5G3, byte1=G3B5)
static bool saveBMP(const uint8_t *buf, int w, int h)
{
if (!sdOK)
return false;
char name[24];
// 找一个不存在的文件名
do
{
snprintf(name, sizeof(name), "/cam%04u.bmp", (unsigned)(++snapIndex));
} while (SD.exists(name) && snapIndex < 9999);
File f = SD.open(name, FILE_WRITE);
if (!f)
{
Serial.printf("[SD] 无法创建 %s\n", name);
return false;
}
uint32_t rowBytes = (uint32_t)w * 3;
uint32_t imgSize = rowBytes * h;
uint8_t hdr[54] = {0};
hdr[0] = 'B';
hdr[1] = 'M';
auto wr32 = [&](int o, uint32_t v)
{ memcpy(hdr + o, &v, 4); };
auto wr16 = [&](int o, uint16_t v)
{ memcpy(hdr + o, &v, 2); };
wr32(2, 54 + imgSize); // 文件大小
wr32(10, 54); // 像素数据偏移
wr32(14, 40); // 信息头大小
wr32(18, w);
wr32(22, h); // 底行优先
wr16(26, 1); // 平面数
wr16(28, 24); // 24bpp
wr32(34, imgSize);
wr32(38, 2835);
wr32(42, 2835);
f.write(hdr, 54);
static uint8_t row[640 * 3]; // v21:320→640,高清模式(VGA)拍照不溢出
for (int y = h - 1; y >= 0; y--)
{ // BMP 底行在前
const uint8_t *src = buf + (size_t)y * w * 2;
for (int x = 0; x < w; x++)
{
uint8_t hi = src[x * 2], lo = src[x * 2 + 1];
// v23:BMP 24bpp 像素字节顺序 = B,G,R(蓝在前)!之前写成 R,G,B 导致红蓝互换
row[x * 3 + 0] = (lo & 0x1F) << 3; // B(BMP 第 1 字节=蓝)
row[x * 3 + 1] = ((hi & 0x07) << 5) | ((lo & 0xE0) >> 3); // G
row[x * 3 + 2] = hi & 0xF8; // R(BMP 第 3 字节=红)
}
f.write(row, rowBytes);
}
f.flush(); // v16:确保落盘(之前断电/卡接触瞬断会留下 0 字节残骸)
uint32_t actual = f.size();
f.close();
if (actual != 54 + imgSize)
{
Serial.printf("[SD] %s 写入不完整: 实际%u字节 / 应为%u —— 卡座可能接触不良\n",
name, (unsigned)actual, (unsigned)(54 + imgSize));
return false;
}
saveCount++;
snprintf(snapMsg, sizeof(snapMsg), "SAVED %s", name);
snapMsgUntil = millis() + 800;
overlayDirty = true; // v9:立即在底栏显示 SAVED
Serial.printf("[SD] 已保存 %s (%dx%d 24bpp, %u字节)\n", name, w, h,
(unsigned)actual);
return true;
}
// v12:快照 = 直接取 RGB565 帧存 BMP
static uint32_t lastSnapMs = 0; // v8:全局快门速率限制(1s),杜绝任何来源的连拍
static void snapNow()
{
if (!camOK)
return;
if (millis() - lastSnapMs < 1000)
return;
lastSnapMs = millis();
camera_fb_t *fb = esp_camera_fb_get();
if (!fb)
return;
float sn = frameSanityRGB((const uint8_t *)fb->buf, fb->width, fb->height);
Serial.printf("[快照判定] 相邻像素平均差 = %.0f → %s\n", sn,
sn < 6000 ? "正常画面"
: (sn > 15000 ? "随机噪声" : "疑似花屏数据"));
if (!sdOK)
Serial.println("[SD] 未挂载,图片未存卡(浏览器 http://板子IP/ 可直接看图)");
saveBMP((const uint8_t *)fb->buf, fb->width, fb->height);
esp_camera_fb_return(fb);
}
// 彩条自检:不经过摄像头,直接推 8 条竖条 —— 判定屏/推屏通路是否正常
static void colorBarTest()
{
static uint16_t *bar = nullptr;
if (!bar)
{
bar = (uint16_t *)(psramFound() ? ps_malloc(320 * 240 * 2)
: malloc(320 * 240 * 2));
if (!bar)
{
Serial.println("[LCD] 自检缓冲分配失败");
return;
}
const uint16_t cols[8] = {0xF800, 0xFFE0, 0x07E0, 0x07FF,
0x001F, 0xF81F, 0xFFFF, 0x0000}; // 红黄绿青蓝紫白黑
for (int y = 0; y < 240; y++)
for (int x = 0; x < 320; x++)
bar[y * 320 + x] = cols[x * 8 / 320];
}
lcdPushFrame((uint8_t *)bar, 320, 240);
overlayDirty = true; // 彩条覆盖了全屏,让状态栏立即恢复
Serial.println("[LCD] 彩条已推送(红/黄/绿/青/蓝/紫/白/黑 竖条)");
}
// v8:屏幕重初始化 —— 若花屏是面板被杂散命令(DC/CS 受扰)打乱内部状态,
// 重跑初始化序列即可恢复清晰;若 R 无效 = 持续性传输受扰(物理层问题)
static void lcdReinit()
{
tft->fillScreen(ILI9341_BLACK);
tft->begin(lcdClockHz); // 重发初始化序列:面板 MADCTL 被库复位为 0x48,下一行重钉
uint8_t mad2 = 0x40; // v24:清 BGR 位(库 initSequence 恒发 0x48,必须覆盖)
tft->sendCommand(ILI9341_MADCTL, &mad2, 1);
tft->fillScreen(ILI9341_BLACK);
tft->setTextSize(2);
tft->setTextColor(ILI9341_GREEN);
tft->setCursor(4, 4);
tft->print("LCD RE-INIT OK");
Serial.println("[LCD] 已重新初始化。画面恢复清晰=面板状态被打乱(受扰);仍花=持续干扰(物理层)");
}
// ================= WiFi =================
static bool tryWiFi(unsigned long perApMs = 10000)
{
WiFi.mode(WIFI_STA);
for (int i = 0; i < creds.count(); i++)
{
const WifiCred *c = creds.get(i);
Serial.printf("[WiFi] 尝试 %d/%d: %s\n", i + 1, creds.count(), c->ssid);
WiFi.begin(c->ssid, c->pass);
unsigned long t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < perApMs)
delay(100);
if (WiFi.status() == WL_CONNECTED)
{
if (i != 0)
creds.promote(i); // 连成功的置为主
creds.save();
strlcpy(curSsid, c->ssid, sizeof(curSsid));
Serial.printf("[WiFi] 已连接 %s IP:%s\n", c->ssid,
WiFi.localIP().toString().c_str());
return true;
}
WiFi.disconnect(false, true);
}
return false;
}
static void wifiSetup()
{
creds.begin();
if (creds.empty())
{
Serial.println("[WiFi] 无凭据,进入网页配网(热点 ESP32-SETUP / 12345678)");
startApProvisioning(creds, 120000);
}
wifiOK = creds.count() > 0 && tryWiFi();
if (!wifiOK)
Serial.println("[WiFi] 暂未连接(相机/SD 不受影响,30s 后自动重试)");
else
{
configTime(8 * 3600, 0, "pool.ntp.org", "ntp.aliyun.com"); // 北京时间
}
}
// ================= v13:浏览器看图 =================
static WebServer *web = nullptr;
// 把 RGB565 MSB-first 帧转成完整 BMP(PSRAM 缓冲),失败返回 false
static bool buildBMPFromFB(const camera_fb_t *fb, uint8_t **out, size_t *outLen)
{
int w = fb->width, h = fb->height;
size_t len = 54 + (size_t)w * h * 3;
uint8_t *img = (uint8_t *)(psramFound() ? ps_malloc(len) : malloc(len));
if (!img)
return false;
memset(img, 0, 54);
img[0] = 'B';
img[1] = 'M';
auto wr32 = [&](int o, uint32_t v)
{ memcpy(img + o, &v, 4); };
auto wr16 = [&](int o, uint16_t v)
{ memcpy(img + o, &v, 2); };
wr32(2, (uint32_t)len);
wr32(10, 54);
wr32(14, 40);
wr32(18, w);
wr32(22, h);
wr16(26, 1);
wr16(28, 24);
wr32(34, (uint32_t)(w * 3 * h));
wr32(38, 2835);
wr32(42, 2835);
uint8_t *body = img + 54;
const uint8_t *src = (const uint8_t *)fb->buf;
for (int y = 0; y < h; y++)
{ // BMP 底行在前 → 目标行 y 存源行 h-1-y
const uint8_t *s = src + (size_t)(h - 1 - y) * w * 2;
uint8_t *d = body + (size_t)y * w * 3;
for (int x = 0; x < w; x++)
{
uint8_t hi = s[x * 2], lo = s[x * 2 + 1];
// v23:BMP 24bpp 像素字节顺序 = B,G,R(蓝在前)!之前写成 R,G,B 导致红蓝互换
d[x * 3 + 0] = (lo & 0x1F) << 3; // B(BMP 第 1 字节=蓝)
d[x * 3 + 1] = ((hi & 0x07) << 5) | ((lo & 0xE0) >> 3); // G
d[x * 3 + 2] = hi & 0xF8; // R(BMP 第 3 字节=红)
}
}
*out = img;
*outLen = len;
return true;
}
static void handleRoot()
{
char page[640];
snprintf(page, sizeof(page),
"<meta charset='utf-8'>"
"<meta name=viewport content='width=device-width,initial-scale=1'>"
"<title>ESP32-S3 相机</title>"
"<body style='background:#111;color:#ddd;font-family:sans-serif;text-align:center'>"
"<h3>ESP32-S3 实时画面</h3>"
"<img id=c src='/snap' style='max-width:100%%;border:1px solid #444'>"
"<script>var c=document.getElementById('c');"
"c.onload=function(){setTimeout(function(){c.src='/snap?t='+Date.now()},300)};</script>"
"<p>SD:%s SNAP:%u FPS:%u<br>串口: S=存卡 A=自动存图 G=白平衡 U=饱和度 Q=清晰度</p>",
sdOK ? "OK" : "NO", (unsigned)saveCount, (int)fpsShow);
web->send(200, "text/html", page);
}
static void handleSnap()
{
if (!camOK)
{
web->send(503, "text/plain", "camera not ready");
return;
}
camera_fb_t *fb = esp_camera_fb_get();
if (!fb)
{
web->send(503, "text/plain", "no frame");
return;
}
uint8_t *img = nullptr;
size_t len = 0;
if (!buildBMPFromFB(fb, &img, &len))
{
esp_camera_fb_return(fb);
web->send(500, "text/plain", "out of memory");
return;
}
esp_camera_fb_return(fb);
web->setContentLength(len);
web->send(200, "image/bmp", "");
size_t off = 0;
while (web->client().connected() && off < len)
{
size_t n = len - off;
if (n > 4096)
n = 4096;
size_t wr = web->client().write(img + off, n);
if (wr == 0)
break; // 客户端断开
off += wr;
}
free(img);
}
static void handle404()
{
web->send(404, "text/plain", "404");
}
static void webSetup()
{
web = new WebServer(80);
web->on("/", handleRoot);
web->on("/snap", handleSnap); // 默认 GET
web->onNotFound(handle404);
web->begin();
Serial.printf("[网页] 浏览器打开 http://%s/ 实时看图(手机电脑都行)\n",
WiFi.localIP().toString().c_str());
}
// ================= v18:屏幕几何验证探针 =================
// P:全屏红(横屏 320x240 fillScreen,铺满 = 几何正确的直接证据)
// + 四角白块 + 中央黑字。探针期间 loop 停止推画面,可以慢慢拍照。
// O:退出恢复正常画面。
static bool probeMode = false;
static void probeShow()
{
tft->fillScreen(ILI9341_RED);
tft->fillRect(0, 0, 24, 24, ILI9341_WHITE); // 左上
tft->fillRect(296, 0, 24, 24, ILI9341_WHITE); // 右上
tft->fillRect(0, 216, 24, 24, ILI9341_WHITE); // 左下
tft->fillRect(296, 216, 24, 24, ILI9341_WHITE); // 右下
tft->setTextSize(2);
tft->setTextColor(ILI9341_BLACK);
tft->setCursor(60, 110);
tft->print("LANDSCAPE 320x240");
Serial.println("[探针] 横屏几何验证:红色应铺满整屏无噪点残留,四角各一个白块,\n"
" 中央文字 LANDSCAPE 320x240 应正向可读。O=退出");
}
// ================= 串口命令 =================
// 单字符命令与 9IEDITWIFI: 行协议共存:整行缓冲,回车后判定
static char lineBuf[100];
static size_t lineLen = 0;
// v21:档位循环 —— 当前已在最高档则回到最低档
static int cycleParam(int cur, int lo, int hi)
{
return (cur >= hi) ? lo : cur + 1;
}
static void singleCmd(char c)
{
switch (c)
{
case 'S':
snapNow();
break;
case 'A':
autoSave = !autoSave;
lastAutoSave = millis();
Serial.printf("[SD] 自动存图(30s): %s\n", autoSave ? "开" : "关");
break;
case 'M':
SD.end();
sdOK = mountSD();
break;
case 'W':
wifiUserOff = false; // v11:手动重连解除 X 的关闭锁定
wifiOK = tryWiFi();
if (wifiOK)
configTime(8 * 3600, 0, "pool.ntp.org", "ntp.aliyun.com");
break;
case 'T':
if (camOK)
{
// v10 判据:摄像头全速采集期间连续推静态彩条。
// 彩条也花 = 推屏传输被摄像头活动干扰(EMI/电源),与画面内容无关;
// 彩条清晰 = 干扰只坏"解码缓冲→屏"这一条时序路径,另做排查。
Serial.println("[T] 摄像头采集中,连续推彩条3秒...(看屏:彩条也花=传输受扰实锤)");
for (int i = 0; i < 3; i++)
{
colorBarTest();
delay(800);
}
overlayDirty = true;
Serial.println("[T] T测试结束");
}
else
{
colorBarTest();
}
break;
case 'F':
{
// v12:LCD SPI 时钟循环 40→20→10→5→80→40
static const uint32_t freqs[] = {20000000, 10000000, 5000000, 80000000, 40000000};
static const int NF = sizeof(freqs) / sizeof(freqs[0]);
int idx = 0;
for (int i = 0; i < NF; i++)
if (freqs[i] == lcdClockHz)
idx = i;
lcdClockHz = freqs[(idx + 1) % NF];
tft->begin(lcdClockHz); // initSequence 把面板 MADCTL 复位为 0x48,下一行重钉
uint8_t mad3 = 0x40; // v24:清 BGR 位(库 initSequence 恒发 0x48,必须覆盖)
tft->sendCommand(ILI9341_MADCTL, &mad3, 1);
overlayDirty = true;
Serial.printf("[LCD] SPI 时钟已切换为 %u MHz\n", (unsigned)(lcdClockHz / 1000000));
break;
}
case 'X':
// v11:只关不重连(修复双 X 陷阱——连发两次会一关一开);30s 自动重连也禁用。
// W 才会重新打开。发一次 X 后等 5 秒再看画面。
if (wifiUserOff)
{
Serial.println("[WiFi] 已处于关闭锁定状态(再发 X 不会重连;W=重新打开)");
break;
}
WiFi.mode(WIFI_OFF);
wifiOK = false;
wifiUserOff = true;
Serial.println("[WiFi] 已关闭并锁定(自动重连已禁用)。看画面是否恢复清晰;W=重新打开");
break;
case 'R':
lcdReinit();
break;
case 'P':
{
// v17:原生几何验证探针(红色铺满+四角白块)
probeMode = true;
probeShow();
break;
}
case 'O':
probeMode = false;
tft->fillScreen(ILI9341_BLACK);
overlayDirty = true;
Serial.println("[探针] 已退出,恢复正常画面");
break;
// ---- v21:画质动态调参(单字符循环档位,即到即执行,串口打印当前值)----
case 'B': // 亮度 -2..+2
{
if (!camOK)
break;
sensor_t *s = esp_camera_sensor_get();
int v = cycleParam(s->status.brightness, -2, 2);
s->set_brightness(s, v);
Serial.printf("[画质] 亮度=%d(-2..+2,按B循环)\n", v);
break;
}
case 'C': // 对比度 -2..+2
{
if (!camOK)
break;
sensor_t *s = esp_camera_sensor_get();
int v = cycleParam(s->status.contrast, -2, 2);
s->set_contrast(s, v);
Serial.printf("[画质] 对比度=%d(-2..+2,按C循环)\n", v);
break;
}
case 'U': // 饱和度 -2..+2(颜色不真实主要调这个+G白平衡)
{
if (!camOK)
break;
sensor_t *s = esp_camera_sensor_get();
int v = cycleParam(s->status.saturation, -2, 2);
s->set_saturation(s, v);
Serial.printf("[画质] 饱和度=%d(-2..+2,按U循环)\n", v);
break;
}
case 'G': // 白平衡模式 0..4(颜色偏色首选换档)
{
if (!camOK)
break;
sensor_t *s = esp_camera_sensor_get();
int v = cycleParam(s->status.wb_mode, 0, 4);
s->set_wb_mode(s, v);
static const char *wb[] = {"自动", "晴天", "阴天", "办公室", "家里"};
Serial.printf("[画质] 白平衡模式=%s(按G循环,偏色就换档)\n", wb[v]);
break;
}
case 'N': // 白平衡增益 开/关(此版 esp32-camera 无 set_awb,用 awb_gain)
{
if (!camOK)
break;
sensor_t *s = esp_camera_sensor_get();
int v = s->status.awb_gain ? 0 : 1;
s->set_awb_gain(s, v);
Serial.printf("[画质] 白平衡增益=%s\n", v ? "开" : "关");
break;
}
case 'L': // 镜头阴影校正 开/关(改善边缘色偏/暗角)
{
if (!camOK)
break;
sensor_t *s = esp_camera_sensor_get();
int v = s->status.lenc ? 0 : 1;
s->set_lenc(s, v);
Serial.printf("[画质] 镜头阴影校正=%s\n", v ? "开" : "关");
break;
}
case 'Y': // 特效 0..6
{
if (!camOK)
break;
sensor_t *s = esp_camera_sensor_get();
int v = cycleParam(s->status.special_effect, 0, 6);
s->set_special_effect(s, v);
static const char *fx[] = {"无", "负片", "黑白", "偏红", "偏绿", "偏蓝", "复古"};
Serial.printf("[画质] 特效=%s(按Y循环)\n", fx[v]);
break;
}
case 'V': // 上下翻转
{
if (!camOK)
break;
sensor_t *s = esp_camera_sensor_get();
int v = s->status.vflip ? 0 : 1;
s->set_vflip(s, v);
Serial.printf("[画质] 上下翻转=%s\n", v ? "开" : "关");
break;
}
case 'I': // 左右镜像
{
if (!camOK)
break;
sensor_t *s = esp_camera_sensor_get();
int v = s->status.hmirror ? 0 : 1;
s->set_hmirror(s, v);
Serial.printf("[画质] 左右镜像=%s\n", v ? "开" : "关");
break;
}
case 'Q': // 清晰度模式:QVGA直采 ↔ VGA 2x2均值(v22:deinit+init 重开相机重分配 fb)
{
if (!camOK)
break;
sensor_t *s = esp_camera_sensor_get();
framesize_t target = (s->status.framesize == FRAMESIZE_VGA) ? FRAMESIZE_QVGA : FRAMESIZE_VGA;
Serial.println("[画质] 正在切换清晰度(相机重启约2秒)...");
esp_camera_deinit();
delay(100);
camOK = initCamera(target);
if (camOK)
Serial.println(target == FRAMESIZE_VGA
? "[画质] 清晰度=高清(VGA 640x480 2x2均值上屏,更锐利,约9fps)"
: "[画质] 清晰度=流畅(QVGA 320x240 直采,约15fps)");
else
Serial.println("[画质] 相机重启失败!串口发 R 无效,请按复位键重开");
break;
}
case 'K': // 打印当前全部画质参数
{
if (!camOK)
{
Serial.println("[画质] 相机未初始化");
break;
}
sensor_t *s = esp_camera_sensor_get();
static const char *wb[] = {"自动", "晴天", "阴天", "办公室", "家里"};
static const char *fx[] = {"无", "负片", "黑白", "偏红", "偏绿", "偏蓝", "复古"};
Serial.printf("[画质] 分辨率=%s 亮度=%d 对比度=%d 饱和度=%d\n",
s->status.framesize == FRAMESIZE_VGA ? "VGA 640x480(高清)" : "QVGA 320x240(流畅)",
s->status.brightness, s->status.contrast, s->status.saturation);
Serial.printf("[画质] 白平衡模式=%s 自动白平衡=%s 镜头校正=%s 特效=%s 翻转=%d 镜像=%d\n",
wb[s->status.wb_mode], s->status.awb ? "开" : "关",
s->status.lenc ? "开" : "关", fx[s->status.special_effect],
s->status.vflip, s->status.hmirror);
break;
}
case 'D': // v12:抓一帧 RGB565,打印真实像素值与判定
{
if (!camOK)
{
Serial.println("[D] 相机未初始化");
break;
}
camera_fb_t *fb = esp_camera_fb_get();
if (!fb)
{
Serial.println("[D] 取帧失败");
break;
}
Serial.printf("[D] 帧 %ux%u len=%u\n", fb->width, fb->height,
(unsigned)fb->len);
Serial.printf("[D] 判定=%.0f\n",
frameSanityRGB((const uint8_t *)fb->buf, fb->width, fb->height));
Serial.print("[D] 第10行前8像素(交换后): ");
for (int i = 0; i < 8; i++)
{
const uint8_t *p = (const uint8_t *)fb->buf + (size_t)10 * fb->width * 2;
Serial.printf("%04X ", ((uint16_t)p[i * 2] << 8) | p[i * 2 + 1]);
}
Serial.println();
esp_camera_fb_return(fb);
break;
}
}
}
static void handleLine(const char *line)
{
if (strncmp(line, "9IEDITWIFI:", 11) == 0)
{
const char *p = line + 11;
const char *comma = strchr(p, ',');
if (!comma || comma == p || !comma[1])
{
Serial.println("[WiFi] 格式: 9IEDITWIFI:SSID,PASSWORD");
return;
}
String ssid = String(p).substring(0, comma - p);
String pass = String(comma + 1);
creds.set(ssid.c_str(), pass.c_str()); // 置主 + 存 NVS
creds.save();
Serial.printf("[WiFi] 已置主并保存: %s,正在连接...\n", ssid.c_str());
wifiOK = tryWiFi();
if (wifiOK)
configTime(8 * 3600, 0, "pool.ntp.org", "ntp.aliyun.com");
return;
}
Serial.printf("[串口] 未知命令: %s\n", line);
}
static void checkSerial()
{
while (Serial.available())
{
char c = (char)Serial.read();
if (c == '\n' || c == '\r')
{
lineBuf[lineLen] = 0;
if (lineLen == 1)
singleCmd(toupper(lineBuf[0]));
else if (lineLen > 1)
handleLine(lineBuf);
lineLen = 0;
}
else if (lineLen == 0 && strchr("SAMWTFXRPODBCULGNVIQKY", toupper(c)))
{
// v18:单字符命令即到即执行 —— 部分串口工具发命令不带回车,
// 旧的"等回车"逻辑会导致命令永远不执行。9IEDITWIFI 行以 '9' 开头
// 不在命令表内 → 走缓冲,不受影响。
singleCmd(toupper(c));
}
else if (lineLen < sizeof(lineBuf) - 1)
{
lineBuf[lineLen++] = c;
}
}
}
// ================= 主流程 =================
void setup()
{
Serial.begin(115200);
Serial.setTxTimeoutMs(0);
unsigned long t0 = millis();
while (!Serial && millis() - t0 < 5000)
delay(50);
delay(200);
Serial.println("\n=== all_cam 整合 v24:LCD红蓝互换修复(MADCTL清BGR位 0x48→0x40) ===");
Serial.println("[调参] B亮度 C对比度 U饱和度 G白平衡模式 N白平衡增益 L镜头校正 Y特效");
Serial.println("[调参] V上下翻转 I左右镜像 Q清晰度(流畅/高清切换) K打印当前参数");
Serial.println("[调参] 颜色不真实:先按G换白平衡模式,再按U调饱和度");
Serial.printf("[系统] PSRAM: %s\n",
psramFound() ? "有" : "无(降级DRAM)");
lcdInit();
Serial.println("[LCD] 初始化完成");
// ---- v3 开机自检(不依赖摄像头)----
Serial.println("[LCD] 自检1/2:应看到 红→绿→蓝 全屏各0.4秒");
tft->fillScreen(ILI9341_RED);
delay(400);
tft->fillScreen(ILI9341_GREEN);
delay(400);
tft->fillScreen(ILI9341_BLUE);
delay(400);
Serial.println("[LCD] 自检2/2:应看到 8 条竖彩条(红黄绿青蓝紫白黑)1.2 秒");
colorBarTest();
delay(1200);
Serial.println("[LCD] 自检结束。彩条清晰=屏/推屏通路正常(此时尚未开摄像头采集)");
Serial.printf("[LCD] v14:当前 SPI=%u MHz。若彩条乱=过快,串口发 F 逐级降频\n",
(unsigned)(lcdClockHz / 1000000));
// 开机画面
tft->fillScreen(ILI9341_BLACK);
tft->setTextSize(2);
tft->setTextColor(ILI9341_GREEN);
tft->setCursor(4, 4);
tft->print("ESP32-S3 ALL_CAM");
camOK = initCamera(FRAMESIZE_QVGA);
// v22:压掉浏览器刷新页面时中断 /snap 传输产生的 WiFiClient write 错误刷屏
esp_log_level_set("WiFiClient", ESP_LOG_NONE);
tft->setTextSize(1);
tft->setCursor(4, 30);
tft->setTextColor(camOK ? ILI9341_GREEN : ILI9341_RED);
tft->printf("Camera: %s", camOK ? "OK" : "FAIL");
sdOK = mountSD();
tft->setCursor(4, 44);
tft->setTextColor(sdOK ? ILI9341_GREEN : ILI9341_RED);
tft->printf("SD: %s", sdOK ? "OK" : "FAIL (M=retry)");
wifiSetup();
tft->setCursor(4, 58);
tft->setTextColor(ILI9341_YELLOW);
tft->printf("WiFi: %s", wifiOK ? curSsid : "connecting...");
if (wifiOK)
webSetup(); // v13:浏览器看图(WiFi 断开时 handleClient 自动跳过)
// v16:等 NTP 同步(最多 8 秒),否则 BMP 文件时间戳会落在 1980
for (int i = 0; i < 16 && wifiOK; i++)
{
time(&nowTime);
if (nowTime > 1600000000) // 2020-09 之后才算同步成功
break;
delay(500);
}
// ---- v12 开机自动存证:第一帧 RGB565 直接存 BMP,供拔卡上机判读 ----
if (camOK && sdOK)
{
camera_fb_t *fb = esp_camera_fb_get();
if (fb)
{
float sn = frameSanityRGB((const uint8_t *)fb->buf, fb->width, fb->height);
Serial.printf("[帧判定] 相邻像素平均差 = %.0f → %s\n", sn,
sn < 6000 ? "正常画面"
: (sn > 15000 ? "随机噪声" : "疑似花屏数据"));
saveBMP((const uint8_t *)fb->buf, fb->width, fb->height);
Serial.println("[存证] 已把开机第一帧存到 SD 根目录,拔卡插读卡器查看");
esp_camera_fb_return(fb);
}
}
// v20:清掉开机自检文字(Camera:/SD:/WiFi: 残影就是它们),状态栏立即重画
tft->fillScreen(ILI9341_BLACK);
overlayDirty = true;
pinMode(PIN_BTN_BOOT, INPUT_PULLUP); // v8:不再用作快门(与 TF MISO 共用不可靠)
pinMode(PIN_TP_IRQ, INPUT_PULLUP); // v8:触摸屏中断 = 物理快门
fpsStart = millis();
}
void loop()
{
checkSerial();
// v16:几何探针期间冻结一切推屏,保持探针图形不被摄像头画面覆盖
if (probeMode)
return;
// v9:状态变化(存图/命令/SD/WiFi)时立即刷新状态栏
if (overlayDirty)
{
overlayDirty = false;
lcdOverlay();
}
// WiFi 掉线 30s 自动重连(v11:X 主动关闭时锁定,不自动重连)
if (wifiOK && WiFi.status() != WL_CONNECTED)
{
wifiOK = false;
Serial.println("[WiFi] 掉线");
}
if (!wifiOK && !wifiUserOff && millis() - lastWifiTry > 30000)
{
lastWifiTry = millis();
if (tryWiFi())
{
wifiOK = true;
configTime(8 * 3600, 0, "pool.ntp.org", "ntp.aliyun.com");
if (!web)
webSetup(); // v13:首次连上时启动网页服务
}
}
time(&nowTime);
// v13:浏览器看图请求处理(不阻塞,每个请求 30~100ms)
if (wifiOK && web)
web->handleClient();
// v13:SD 每 15s 自动重试挂载(卡座接触不良,重插卡后自动接上)
static uint32_t lastSdRetry = 0;
if (!sdOK && millis() - lastSdRetry > 15000)
{
lastSdRetry = millis();
sdOK = mountSD();
if (sdOK)
overlayDirty = true;
}
// v8:物理快门 = TP_IRQ(IO2,触摸屏中断) —— 触摸屏幕即拍照。
// BOOT 键(IO0) 彻底弃用:IO0 与 TF MISO 共用,卡座接触不良时电平乱跳,
// v7 防抖也被"SPI 流量期间偶尔回高"反复重新武装(实测 17 秒 115 张幽灵快照)。
static uint32_t tpLowSince = 0;
if (digitalRead(PIN_TP_IRQ) == LOW)
{
if (!tpLowSince)
tpLowSince = millis();
else if (millis() - tpLowSince > 50) // 50ms 去抖
{
tpLowSince = millis(); // 持续触摸由 snapNow 的 1s 速率限制兜底
snapNow();
}
}
else
{
tpLowSince = 0;
}
// 自动存图(30s)
if (autoSave && sdOK && millis() - lastAutoSave > 30000)
{
lastAutoSave = millis();
snapNow();
}
// v12:取帧(RGB565) → v19:每帧抽样判定,坏帧(差≥3000)直接丢弃不上屏
if (camOK)
{
camera_fb_t *fb = esp_camera_fb_get();
if (fb)
{
uint32_t tP0 = millis();
float sn = frameSanityRGB((const uint8_t *)fb->buf, fb->width, fb->height);
bool good = sn < 6000; // v20:阈值 3000→6000(真实场景可达 3000~4000,3000 误杀 98% 好帧)
uint32_t pushMs = 0;
if (good)
{
lcdPushCamArea((const uint8_t *)fb->buf, fb->width); // v18:直推中间208行,状态栏不碰
pushMs = millis() - tP0;
goodFrames++;
}
else
{
badFrames++; // 坏帧不推屏 → 屏上永远只有好帧,花屏闪烁物理消失
}
frameCount++;
fpsFrames++;
// 前 3 帧打印明细;之后每 300 帧打印一次坏帧统计
if (frameCount <= 3 || frameCount % 300 == 0)
{
Serial.printf("[帧%u] 差=%.0f(%s) 推屏=%ums 坏帧=%u/%u(%u%%)\n",
(unsigned)frameCount, sn, good ? "正常" : "坏帧丢弃",
(unsigned)pushMs, (unsigned)badFrames, (unsigned)frameCount,
(unsigned)(badFrames * 100 / frameCount));
}
esp_camera_fb_return(fb);
}
else
{
delay(50);
}
}
else
{
delay(500);
}
// 每秒刷新 FPS 与状态条
if (millis() - fpsStart >= 1000)
{
fpsShow = fpsFrames * 1000 / (millis() - fpsStart);
fpsStart = millis();
fpsFrames = 0;
if (camOK)
lcdOverlay();
}
}