ESP32-网络摄像头方案
# ESP32网络摄像头方案
基于ESP32、MJPEG 640×480@10fps和ECS(暂由本地模拟)的 完整可控视频传输方案,包含启动/停止控制、状态反馈和优化策略。该方案实现了从设备控制到视频传输的完整闭环,预期在3Mbps带宽下可稳定运行。
# 硬件选择
ESP32-CAM (ESP32 + OV2640)
# 系统架构
# 环境搭建
以Windows为例:
# MQTT Broker搭建
下载安装Mosquitto ,官网 https://mosquitto.org/download/
选择目录进行安装,安装目录之下会有一个mosquitto.conf,添加下面内容:
# 监听所有网络接口(允许局域网访问) listener 10086 0.0.0.0 # 允许匿名连接(测试用,生产环境需关闭) allow_anonymous true
1
2
3
4
5mosquitto.conf 参考 mosquitto.conf man page | Eclipse Mosquitto (opens new window)
安装目录之下,使用终端启动:
.\mosquitto -c .\mosquitto.conf
1新开终端,启动订阅(可选)
# -t TOPIC .\mosquitto_sub -t 'cam/device1/ctrl' -v -p 10086
1
2
# NodeJs搭建
官网: Node.js — 在任何地方运行 JavaScript (opens new window)
下载nodejs
创建目录,安装依赖
npm install express multer mqtt ws
1
# 关键代码
# esp32-s3的代码
#include "esp_camera.h"
#include <WiFi.h>
#include <HTTPClient.h>
#include <PubSubClient.h>
// 在全局或静态变量中标记初始化状态
static bool gpio_isr_installed = false;
// 摄像头配置
#define PWDN_GPIO_NUM 32
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 0
#define SIOD_GPIO_NUM 26
#define SIOC_GPIO_NUM 27
#define Y9_GPIO_NUM 35
#define Y8_GPIO_NUM 34
#define Y7_GPIO_NUM 39
#define Y6_GPIO_NUM 36
#define Y5_GPIO_NUM 21
#define Y4_GPIO_NUM 19
#define Y3_GPIO_NUM 18
#define Y2_GPIO_NUM 5
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM 23
#define PCLK_GPIO_NUM 22
// 4 for flash led or 33 for normal led
#define LED_GPIO_NUM 4
// Wi-Fi配置
const char* ssid = "wifi名";
const char* password = "wifi密码";
const char* serverURL = "http://127.0.0.1:10188/video";
// MQTT配置
const char* mqttServer = "127.0.0.1";
const int mqttPort = 10086;
const char* mqttTopic = "cam/device1/ctrl";
WiFiClient espClient;
PubSubClient mqttClient(espClient);
bool isStreaming = false;
// MQTT回调函数
void callback(char* topic, byte* payload, unsigned int length) {
String message = String((char*)payload, length);
if(message == "ON") {
startStreaming();
} else if(message == "OFF") {
stopStreaming();
}
}
void startStreaming() {
// 初始化 GPIO ISR 服务(仅一次)(没有作用)
if (!gpio_isr_installed) {
esp_err_t ret = gpio_install_isr_service(0);
if (ret != ESP_OK) {
Serial.println("初始化GPIO ISR 服务失败");
return;
}
gpio_isr_installed = true;
}
if(!isStreaming) {
camera_config_t config;
config.ledc_channel = LEDC_CHANNEL_0;
config.ledc_timer = LEDC_TIMER_0;
config.pin_d0 = Y2_GPIO_NUM;
config.pin_d1 = Y3_GPIO_NUM;
config.pin_d2 = Y4_GPIO_NUM;
config.pin_d3 = Y5_GPIO_NUM;
config.pin_d4 = Y6_GPIO_NUM;
config.pin_d5 = Y7_GPIO_NUM;
config.pin_d6 = Y8_GPIO_NUM;
config.pin_d7 = Y9_GPIO_NUM;
config.pin_xclk = XCLK_GPIO_NUM;
config.pin_pclk = PCLK_GPIO_NUM;
config.pin_vsync = VSYNC_GPIO_NUM;
config.pin_href = HREF_GPIO_NUM;
config.pin_sccb_sda = SIOD_GPIO_NUM;
config.pin_sccb_scl = SIOC_GPIO_NUM;
config.pin_pwdn = PWDN_GPIO_NUM;
config.pin_reset = RESET_GPIO_NUM;
config.xclk_freq_hz = 20000000;
config.frame_size = FRAMESIZE_VGA; // 640x480
config.pixel_format = PIXFORMAT_JPEG; // for streaming
//config.pixel_format = PIXFORMAT_RGB565; // for face detection/recognition
config.grab_mode = CAMERA_GRAB_WHEN_EMPTY;
config.fb_location = CAMERA_FB_IN_PSRAM;
config.jpeg_quality = 12;
config.fb_count = 2;
esp_camera_init(&config); // 初始化摄像头
isStreaming = true;
Serial.println("视频流已启动");
}
}
void stopStreaming() {
if(isStreaming) {
esp_camera_deinit(); // 释放摄像头资源
isStreaming = false;
Serial.println("视频流已停止");
}
}
void setup() {
Serial.begin(115200);
Serial.setDebugOutput(true);
Serial.println();
// 初始化Wi-Fi(同前)
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) delay(500);
Serial.println("Wi-Fi连接成功");
// 连接MQTT
mqttClient.setServer(mqttServer, mqttPort);
mqttClient.setCallback(callback);
while(!mqttClient.connect("ESP32CAM")) delay(500);
mqttClient.subscribe(mqttTopic);
mqttClient.publish(mqttTopic, "666");
Serial.println("mqtt连接成功");
}
void loop() {
mqttClient.loop();
if(isStreaming) {
camera_fb_t *fb = esp_camera_fb_get();
if(fb) {
// 发送视频帧(同前)
HTTPClient http;
http.begin(serverURL);
String boundary = "1234567890";
String body = "--" + boundary + "\r\n";
body += "Content-Disposition: form-data; name=\"frame\"; filename=\"image.jpg\"\r\n";
body += "Content-Type: image/jpeg\r\n\r\n";
body += String((const char*)fb->buf, fb->len); // 注意:仅适用于文本安全的数据
body += "\r\n--" + boundary + "--\r\n";
http.addHeader("Content-Type", "multipart/form-data; boundary=" + boundary);
http.POST(body); // 直接发送完整请求体
http.end();
esp_camera_fb_return(fb);
}
delay(100); // 10fps控制
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# 服务端代码
const express = require('express');
const mqtt = require('mqtt');
const app = express();
const cors = require('cors'); // 新增引入cors
app.use(express.json());
// 跨域设置
app.use(cors());
// 设备状态存储
let deviceStatus = {
device1: {
running: false,
lastFrame: null
}
};
// MQTT连接
const mqttClient = mqtt.connect('mqtt://127.0.0.1:10086');
// 控制指令接口
app.post('/control', (req, res) => {
const { deviceId, command } = req.body;
if(command === 'start') {
deviceStatus[deviceId].running = true;
mqttClient.publish(`cam/${deviceId}/ctrl`, 'ON');
} else if(command === 'stop') {
deviceStatus[deviceId].running = false;
mqttClient.publish(`cam/${deviceId}/ctrl`, 'OFF');
}
res.json({ status: deviceStatus[deviceId] });
});
// 视频接收接口
const multer = require('multer');
const upload = multer();
app.post('/video', upload.single('frame'), (req, res) => {
if(deviceStatus.device1.running) {
deviceStatus.device1.lastFrame = req.file.buffer;
broadcastToWeb(); // 转发到前端
}
res.sendStatus(200);
});
// WebSocket广播
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 10081 });
function broadcastToWeb() {
wss.clients.forEach(client => {
if(client.readyState === WebSocket.OPEN) {
client.send(deviceStatus.device1.lastFrame);
}
});
}
app.listen(10188);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# 前端代码
<!DOCTYPE html>
<html>
<head>
<style>
/* 整体容器样式 */
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px; /* 元素间距 */
padding: 20px;
background-color: #f0f0f0;
min-height: 100vh;
}
/* 画面区域样式 */
#videoFeed {
max-width: 100%;
border: 3px solid #333;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
/* 按钮容器 */
.button-group {
display: flex;
gap: 15px; /* 按钮间距 */
}
/* 按钮样式 */
button {
padding: 12px 24px;
font-size: 16px;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
/* 启动按钮样式 */
#startBtn {
background-color: #4CAF50; /* 绿色 */
color: white;
}
/* 停止按钮样式 */
#stopBtn {
background-color: #f44336; /* 红色 */
color: white;
}
/* 悬停效果 */
button:hover {
opacity: 0.9;
transform: translateY(-2px);
}
</style>
</head>
<body>
<div class="container">
<img id="videoFeed" width="640" height="480">
<div class="button-group">
<button id="startBtn" onclick="sendCommand('start')">启动设备</button>
<button id="stopBtn" onclick="sendCommand('stop')">停止设备</button>
</div>
</div>
<script>
const ws = new WebSocket('ws://127.0.0.1:10081');
ws.binaryType = 'arraybuffer';
const img = document.getElementById('videoFeed');
// 接收视频流
ws.onmessage = (event) => {
const blob = new Blob([event.data], { type: 'image/jpeg' });
img.src = URL.createObjectURL(blob);
};
// 发送控制指令(添加了错误处理)
function sendCommand(cmd) {
fetch('http://127.0.0.1/control', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deviceId: 'device1', command: cmd })
})
.then(response => {
if (!response.ok) throw new Error('控制失败');
console.log(`${cmd} 指令发送成功`);
})
.catch(error => console.error('控制错误:', error));
}
// 关闭连接时清理资源
window.addEventListener('beforeunload', () => {
ws.close();
});
</script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
编辑 (opens new window)