前面已经做了从过网络访问摄像头的实验:esp32s3 通过wifi查看正点原子摄像头-优快云博客
现在的目标是添加一张sd卡(其实是手机上用的tf卡),没有现成的,就从玩具中拆了一张出来,一看才128M,这也太小了,玩具厂可真会节省成本^_^!
功能上,就添加了sd卡的读写功能,还在页面上添加了一个查看sd卡中存储的照片的功能。
就直接在main.cpp上添加代码了,实际项目中的话,最好还是拆出来,这样代码比较清晰。
代码如下:
#include <Arduino.h>
#include "esp_camera.h"
#include "camera.h"
#include "xl9555.h"
#include <WiFi.h>
#include <WebServer.h>
#include <SD.h> // 使用SPI模式需要包含SD库
#include <SPI.h> // SPI库
#include "FS.h" // 文件系统支持
// SD卡引脚定义(自定义以避免与摄像头冲突)
#define SD_CS_PIN 2
#define SD_MISO_PIN 13
#define SD_MOSI_PIN 11
#define SD_SCK_PIN 12
// SD卡相关变量
bool sdCardAvailable = false;
uint32_t imageCounter = 0; // 图片计数器
// WiFi配置
const char* ssid = "改为你家的wifi";
const char* password = "改为你家的wifi密码";
// HTTP服务器
WebServer server(80);
camera_fb_t *fb = NULL;
// 提前声明HTTP处理函数
void handleRoot();
void handleStream();
void handleJPG();
void handleListImages(); // 查看SD卡照片
void handleImageFile(); // 显示单张图片
// 函数声明
bool saveImageToSD(camera_fb_t *fb);
bool initSDCard();
void setup() {
Serial.begin(115200);
// 初始化SD卡
sdCardAvailable = initSDCard();
// 初始化XL9555扩展IO
xl9555_init();
// 摄像头初始化
if(camera_init() != 0) {
Serial.println("摄像头初始化失败!");
while(1) delay(100);
}
// 连接WiFi
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWiFi已连接");
Serial.print("IP地址: ");
Serial.println(WiFi.localIP());
// 设置HTTP路由
server.on("/", HTTP_GET, handleRoot);
server.on("/stream", HTTP_GET, handleStream);
server.on("/jpg", HTTP_GET, handleJPG);
server.on("/list", HTTP_GET, handleListImages); // 查看照片列表
server.on("/image", HTTP_GET, handleImageFile); // 显示单张图片
server.begin();
Serial.println("HTTP服务器已启动");
}
void loop() {
server.handleClient();
}
/**
* @brief 摄像头初始化
* @param 无
* @retval 0:成功 / 1:失败
*/
uint8_t camera_init(void) {
camera_config_t camera_config;
// 引脚配置
camera_config.pin_d0 = OV_D0_PIN;
camera_config.pin_d1 = OV_D1_PIN;
camera_config.pin_d2 = OV_D2_PIN;
camera_config.pin_d3 = OV_D3_PIN;
camera_config.pin_d4 = OV_D4_PIN;
camera_config.pin_d5 = OV_D5_PIN;
camera_config.pin_d6 = OV_D6_PIN;
camera_config.pin_d7 = OV_D7_PIN;
camera_config.pin_xclk = OV_XCLK_PIN;
camera_config.pin_pclk = OV_PCLK_PIN;
camera_config.pin_vsync = OV_VSYNC_PIN;
camera_config.pin_href = OV_HREF_PIN;
camera_config.pin_sccb_sda = OV_SDA_PIN;
camera_config.pin_sccb_scl = OV_SCL_PIN;
camera_config.pin_pwdn = OV_PWDN_PIN;
camera_config.pin_reset = OV_RESET_PIN;
// 其他配置
camera_config.ledc_channel = LEDC_CHANNEL_0;
camera_config.ledc_timer = LEDC_TIMER_0;
camera_config.xclk_freq_hz = 20000000;
camera_config.pixel_format = PIXFORMAT_JPEG; // 使用JPEG格式
// 优先使用PSRAM
if(psramFound()){
camera_config.frame_size = FRAMESIZE_SVGA; // 800x600
camera_config.jpeg_quality = 12;
camera_config.fb_count = 2;
camera_config.grab_mode = CAMERA_GRAB_LATEST;
camera_config.fb_location = CAMERA_FB_IN_PSRAM;
} else {
camera_config.frame_size = FRAMESIZE_QVGA; // 320x240
camera_config.jpeg_quality = 15;
camera_config.fb_count = 1;
}
// XL9555引脚控制
if (OV_PWDN_PIN == -1) {
xl9555_io_config(OV_PWDN, IO_SET_OUTPUT);
xl9555_pin_set(OV_PWDN, IO_SET_LOW);
}
if (OV_RESET_PIN == -1) {
xl9555_io_config(OV_RESET, IO_SET_OUTPUT);
xl9555_pin_set(OV_RESET, IO_SET_LOW);
delay(20);
xl9555_pin_set(OV_RESET, IO_SET_HIGH);
delay(20);
}
// 初始化摄像头
esp_err_t err = esp_camera_init(&camera_config);
if (err != ESP_OK) {
Serial.printf("摄像头初始化失败: 0x%x", err);
return 1;
}
// 摄像头传感器配置
sensor_t *s = esp_camera_sensor_get();
// 根据摄像头型号设置方向
if (s->id.PID == OV2640_PID) {
s->set_vflip(s, 0); // OV2640不需要垂直翻转
} else {
s->set_vflip(s, 1); // 其他摄像头垂直翻转
}
// 图像参数调整
s->set_brightness(s, 0); // 亮度 (-2~2)
s->set_contrast(s, 0); // 对比度 (-2~2)
s->set_saturation(s, 0); // 饱和度 (-2~2)
s->set_hmirror(s, 0); // 水平镜像
Serial.println("摄像头初始化成功");
return 0;
}
// 根页面处理
void handleRoot() {
String html = "<html>\n"
"<head>\n"
"<meta charset=\"UTF-8\">\n" // 添加UTF-8编码声明
"<title>ESP32-CAM 监控</title>\n"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
"<style>\n"
"body { font-family: Arial; text-align: center; margin: 0; padding: 20px; background-color: #f5f5f5; }\n"
"h1 { color: #333; }\n"
".container { max-width: 800px; margin: 0 auto; }\n"
"img { max-width: 100%; border: 1px solid #ddd; border-radius: 4px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }\n"
".controls { margin: 20px 0; }\n"
"a { display: inline-block; margin: 10px; padding: 10px 20px; background: #4CAF50; color: white; text-decoration: none; border-radius: 4px; }\n"
".gallery { display: flex; flex-wrap: wrap; justify-content: center; }\n"
".gallery img { width: 150px; height: 150px; object-fit: cover; margin: 5px; }\n"
"</style>\n"
"</head>\n"
"<body>\n"
"<div class=\"container\">\n"
"<h1>ESP32-S3 摄像头监控</h1>\n"
"<img src=\"/stream\" id=\"video\" alt=\"实时视频流\">\n"
"<div class=\"controls\">\n"
"<a href=\"/jpg\">拍照</a>\n"
"<a href=\"/list\">查看保存的照片</a>\n"
"<a href=\"javascript:location.reload()\">刷新</a>\n"
"</div>\n"
"<p>IP地址: " + WiFi.localIP().toString() + "</p>\n"
"</div>\n"
"<script>\n"
"// Auto-refresh image\n"
"setInterval(function() {\n"
" document.getElementById('video').src = '/stream?' + Date.now();\n"
"}, 100);\n"
"</script>\n"
"</body>\n"
"</html>\n";
server.send(200, "text/html", html);
}
// 视频流处理
void handleStream() {
WiFiClient client = server.client();
// 发送HTTP头
String response = "HTTP/1.1 200 OK\r\n";
response += "Content-Type: multipart/x-mixed-replace; boundary=frame\r\n\r\n";
client.print(response);
while (client.connected()) {
fb = esp_camera_fb_get();
if (!fb) {
Serial.println("摄像头捕获失败");
delay(100);
continue;
}
// 发送图像边界
String header = "--frame\r\n";
header += "Content-Type: image/jpeg\r\n";
header += "Content-Length: " + String(fb->len) + "\r\n\r\n";
client.print(header);
// 发送图像数据
client.write(fb->buf, fb->len);
esp_camera_fb_return(fb);
fb = NULL;
// 短暂延迟以控制帧率
delay(50);
// 检查连接状态
if (!client.connected()) {
break;
}
}
}
// 单张图片处理
void handleJPG() {
fb = esp_camera_fb_get();
if (!fb) {
server.send(500, "text/plain", "摄像头捕获失败");
return;
}
// 保存图片到SD卡
if (sdCardAvailable) {
saveImageToSD(fb);
}
// 发送图片到客户端
server.send_P(
200,
"image/jpeg",
reinterpret_cast<const char*>(fb->buf),
fb->len
);
esp_camera_fb_return(fb);
fb = NULL;
}
// 保存图片到SD卡
bool saveImageToSD(camera_fb_t *fb) {
if (!sdCardAvailable) {
Serial.println("SD卡不可用,无法保存图像");
return false;
}
// 创建目录(如果不存在)
if (!SD.exists("/images")) {
if (!SD.mkdir("/images")) {
Serial.println("创建/images目录失败");
return false;
}
Serial.println("已创建/images目录");
}
// 生成带时间戳的文件名
struct timeval tv;
gettimeofday(&tv, NULL);
char filename[64];
snprintf(filename, sizeof(filename), "/images/img_%lu_%lu.jpg", tv.tv_sec, imageCounter++);
// 打开文件
File file = SD.open(filename, FILE_WRITE);
if (!file) {
Serial.printf("无法创建文件: %s\n", filename);
return false;
}
// 写入图像数据
size_t bytesWritten = file.write(fb->buf, fb->len);
file.close();
if (bytesWritten != fb->len) {
Serial.printf("写入不完整: %d/%d 字节\n", bytesWritten, fb->len);
return false;
}
Serial.printf("图片已保存: %s (%d字节)\n", filename, bytesWritten);
return true;
}
// 初始化SD卡 (SPI模式)
bool initSDCard() {
Serial.println("初始化SD卡(SPI模式)...");
// 初始化SPI引脚
SPI.begin(SD_SCK_PIN, SD_MISO_PIN, SD_MOSI_PIN, SD_CS_PIN);
if (!SD.begin(SD_CS_PIN)) {
Serial.println("SD卡初始化失败");
return false;
}
// 检查SD卡类型
uint8_t cardType = SD.cardType();
if (cardType == CARD_NONE) {
Serial.println("未检测到SD卡");
return false;
}
Serial.print("SD卡类型: ");
if (cardType == CARD_MMC) {
Serial.println("MMC");
} else if (cardType == CARD_SD) {
Serial.println("SDSC");
} else if (cardType == CARD_SDHC) {
Serial.println("SDHC");
} else {
Serial.println("未知");
}
// 显示SD卡大小
uint64_t cardSize = SD.cardSize() / (1024 * 1024);
Serial.printf("SD卡大小: %lluMB\n", cardSize);
// 检查可用空间
uint64_t freeBytes = SD.totalBytes() - SD.usedBytes();
Serial.printf("可用空间: %lluMB\n", freeBytes / (1024 * 1024));
// 确保/images目录存在
if (!SD.exists("/images")) {
if (!SD.mkdir("/images")) {
Serial.println("无法创建/images目录");
} else {
Serial.println("已创建/images目录");
}
}
return true;
}
// 查看SD卡照片列表
void handleListImages() {
if (!sdCardAvailable) {
server.send(200, "text/plain", "SD卡不可用");
return;
}
String html = "<html><head><meta charset=\"UTF-8\"><title>已保存的照片</title><style>";
html += "body { text-align: center; font-family: Arial; }";
html += "h1 { color: #333; }";
html += ".gallery { display: flex; flex-wrap: wrap; justify-content: center; }";
html += ".gallery a { margin: 10px; text-decoration: none; color: #333; }";
html += ".gallery img { width: 200px; height: 150px; object-fit: cover; border: 1px solid #ddd; border-radius: 4px; }";
html += ".back-btn { display: block; margin: 20px auto; padding: 10px 20px; background: #4CAF50; color: white; border-radius: 4px; width: fit-content; }";
html += "</style></head><body>";
html += "<h1>已保存的照片</h1>";
html += "<div class='gallery'>";
// 列出/images目录下的文件
File root = SD.open("/images");
if (!root) {
html += "<p>无法打开目录</p>";
} else if (!root.isDirectory()) {
html += "<p>不是一个目录</p>";
} else {
File file = root.openNextFile();
int count = 0;
while (file) {
if (!file.isDirectory()) {
String path = file.path();
if (path.endsWith(".jpg") || path.endsWith(".jpeg")) {
// 移除"/sd"前缀,因为SD库会添加这个前缀
if (path.startsWith("/sd")) {
path = path.substring(3);
}
html += "<a href='/image?path=" + path + "'>";
html += "<img src='/image?path=" + path + "' alt='照片'>";
html += "<br>" + path.substring(8) + "</a>"; // 显示文件名(去掉/images/前缀)
count++;
}
}
file = root.openNextFile();
}
if (count == 0) {
html += "<p>没有找到照片</p>";
}
}
root.close();
html += "</div>";
html += "<a class='back-btn' href='/'>返回主页</a>";
html += "</body></html>";
server.send(200, "text/html", html);
}
// 显示单张图片
void handleImageFile() {
String path = server.arg("path");
if (path.length() == 0) {
server.send(400, "text/plain", "缺少路径参数");
return;
}
// 确保路径以/开头
if (!path.startsWith("/")) {
path = "/" + path;
}
// 检查文件是否存在
if (!SD.exists(path)) {
server.send(404, "text/plain", "文件未找到: " + path);
return;
}
File file = SD.open(path, FILE_READ);
if (!file) {
server.send(500, "text/plain", "无法打开文件: " + path);
return;
}
// 设置正确的Content-Type
server.setContentLength(file.size());
server.send(200, "image/jpeg");
// 流式传输文件内容
uint8_t buffer[1024];
size_t bytesRead;
while ((bytesRead = file.read(buffer, sizeof(buffer))) > 0) {
server.sendContent_P((const char*)buffer, bytesRead);
}
file.close();
}
platformio.ini
[env:dnesp32s3]
platform = espressif32
board = dnesp32s3
framework = arduino
monitor_speed = 115200
test_speed = 115200
upload_speed=115200
debug_speed = 115200
lib_deps =
esp32-camera@^2.0.0
https://github.com/me-no-dev/AsyncTCP.git
https://github.com/me-no-dev/ESPAsyncWebServer.git
; 启用PSRAM支持
board_build.psram = opi
board_build.psram_mode = opi
; 分区方案
board_build.partitions = default_16MB.csv
; 优化设置
build_flags =
-DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue
烧录成功:
从页面上查看实际效果:
尝试拍照:
点击“查看保存的照片”:
不过页面刷了一会才出来,需要耐心等几秒钟。