• 正文
  • 相关推荐
申请入驻 产业图谱

基于ESP32设计的智能电子书阅读器

06/26 17:21
1004
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

一、前言

1.1 项目开发背景

随着移动互联网和数字阅读技术的发展,电子书逐渐成为信息获取和阅读学习的重要方式。相比传统纸质书籍,电子书具备存储容量大、携带方便、资源更新快捷等优势,被广泛应用于学习、办公、资料查阅以及日常阅读等场景。然而,当前市场上的通用电子阅读设备大多存在价格较高、功能封闭、二次开发困难等问题,对于教学实验、嵌入式开发研究以及个性化阅读需求而言存在一定局限性。因此,设计一种基于嵌入式平台、具备开放性和可扩展性的智能电子书阅读器具有较强的研究意义和应用价值。

近年来,ESP32系列芯片凭借高性能处理能力、丰富的外设接口以及无线通信能力,在智能终端物联网领域得到广泛应用。其中,ESP32-S3不仅具备较高的运算性能,还支持更完善的人机交互能力,能够满足文件管理、图形显示以及数据通信等功能需求。基于ESP32-S3构建电子书阅读器,可以充分发挥嵌入式平台低功耗、低成本、小型化等特点,为便携式数字阅读设备提供一种可行方案。

在阅读体验方面,传统液晶显示设备虽然刷新速度快,但长时间阅读容易产生视觉疲劳。墨水屏显示技术因具有接近纸张的视觉效果、低功耗以及断电保持显示等特点,更适合作为电子阅读设备的显示终端。因此,本项目采用2.0寸墨水屏作为阅读显示载体,实现电子书内容展示,提高阅读舒适度,同时降低设备整体功耗。

为了提升系统的资源管理能力和使用便捷性,本项目引入FATFS文件系统并支持SD卡读写,实现电子书资源的本地存储与管理,使设备能够完成TXT电子书文件的读取和内容解析。同时,为增强阅读辅助能力,系统增加语音播报功能,通过SYN6288语音模块将文本内容转换为语音输出,为长时间阅读、辅助学习以及特殊使用场景提供支持。

此外,考虑到电子书资源更新频繁以及用户交互需求,本项目设计了基于Qt5(C++)开发的上位机APP,实现电子书文件上传与更新功能。用户可通过APP将TXT电子书导入设备,避免频繁拆卸存储介质,提高内容管理效率。同时结合按键交互实现书籍选择与翻页控制,形成完整的电子书阅读闭环。

综合来看,本项目通过将ESP32-S3、FATFS文件系统、SD卡存储、墨水屏显示、语音播报以及上位机管理功能进行融合,实现了一套兼顾阅读体验、资源管理与交互便捷性的智能电子书阅读系统,为嵌入式阅读终端设计提供了参考方案,也具有一定的教学研究与工程实践价值。

1.2 设计实现的功能

(1)支持FATFS文件系统管理功能
系统集成FATFS文件系统,用于完成电子书文件的存储管理与访问控制。通过文件系统对存储介质中的目录和文件进行统一管理,实现TXT电子书文件的识别、读取以及内容解析,为电子书浏览与更新提供基础支持。

(2)支持SD卡读写功能
系统支持外接SD卡作为电子书数据存储介质,实现电子书文件的数据读入、存储和访问功能。用户可将TXT格式电子书保存至SD卡中,设备能够完成文件读取,并支持后续内容更新与资源管理。

(3)支持2.0寸墨水屏电子书显示功能
系统采用2.0寸墨水屏作为显示终端,用于显示电子书文本内容。设备能够将读取到的电子书数据进行页面显示,实现电子书阅读功能。墨水屏具备低功耗、接近纸张显示效果的特点,可满足长时间阅读需求。

(4)支持语音播报阅读功能
系统集成SYN6288语音播报模块,实现文本内容语音播放功能。设备能够将读取到的电子书文字信息发送至语音模块进行播报,使用户在阅读过程中可选择语音收听模式,提高阅读方式的灵活性。

(5)支持按键交互与翻页控制功能
系统配置按键输入模块,实现电子书选择、页面翻页等交互操作。用户可通过按键完成电子书切换、阅读页面切换等控制,提高设备使用便捷性和交互体验。

(6)支持ESP32-S3核心控制功能
系统以ESP32-S3作为主控制器,负责完成文件管理、数据显示、按键响应、语音控制以及数据通信等任务,实现各功能模块之间的数据处理与协调运行。

(7)支持APP更新电子书功能
系统配套采用Qt5(C++)开发的上位机APP,实现电子书资源更新管理。用户可通过APP将TXT格式电子书上传至设备端,实现电子书内容更新,无需直接操作存储介质,提高电子书管理效率。

1.3 项目硬件模块组成

(1)ESP32-S3主控模块
系统以ESP32-S3作为核心控制单元,负责完成电子书数据处理、文件读取、界面控制、按键响应、语音控制以及与上位机的数据交互。主控模块协调各硬件单元协同工作,实现整个电子书阅读器系统运行。

(2)SD卡存储模块
系统采用SD卡作为电子书资源存储介质,用于保存TXT格式电子书文件。主控通过读写接口访问存储数据,并结合FATFS文件系统完成文件管理、电子书读取和资源更新功能。

(3)2.0寸墨水屏显示模块
系统采用2.0寸电子墨水屏作为显示终端,用于显示电子书文本内容以及阅读页面信息。显示模块负责接收主控发送的数据,实现电子书页面刷新和阅读内容展示。

(4)SYN6288语音播报模块
系统集成SYN6288语音合成模块,用于将电子书文本内容转换为语音进行播放。主控将待播报文本发送至语音模块,实现语音阅读功能输出。

(5)按键输入模块
系统配置功能按键作为人机交互输入单元,用于完成电子书选择、菜单操作以及页面翻页控制。用户通过按键实现阅读过程中的基础操作控制。

(6)数据通信接口模块
系统配置用于与上位机APP进行数据交互的通信接口,实现电子书文件更新和传输功能。Qt5(C++)开发的APP通过通信方式向设备上传TXT电子书资源,实现内容更新。

(7)电源供电模块
电源供电模块用于为ESP32-S3主控、墨水屏、SD卡模块、语音模块及按键电路提供稳定工作电源,保障整个电子书阅读器系统稳定运行。

1.4 系统框架图

1.5 运行流程图

二、硬件选型

(1)主控制器选型——ESP32-S3模块
本项目核心控制器选用ESP32-S3作为系统主控芯片。ESP32-S3具备较强的数据处理能力和丰富的外设资源,能够满足电子书文件解析、显示刷新、按键交互、语音控制以及数据传输等功能需求。芯片内部集成高速处理核心,同时提供SPIUARTGPIO等接口资源,便于连接墨水屏、SD卡以及语音模块。

选型:

    • 主控型号:ESP32-S3-WROOM-1()• 工作电压:3.3V• Flash容量:8MB()• PSRAM:8MB()• 通信接口:SPI、UART、USB、GPIO• 选型:满足文本缓存、界面刷新及文件系统运行需求,开发资料完善。

(2)存储模块选型——Micro SD卡模块
系统采用Micro SD卡作为电子书资源存储介质,用于保存TXT电子书文件,并通过FATFS文件系统完成文件管理和读取。由于电子书文本容量较小,因此普通容量存储卡即可满足要求。

选型:

    • 模块类型:Micro SD Card SPI接口模块• 通信方式:SPI• 工作电压:3.3V• 容量:8GB~32GB• 文件系统:FAT32• 选型:兼容FATFS,读写稳定,便于后续资源扩展。

连接方式:

    • MOSI → ESP32-S3 SPI数据输出• MISO → ESP32-S3 SPI数据输入• SCK → SPI时钟• CS → 自定义片选引脚

(3)显示模块选型——2.0寸电子墨水屏模块
系统采用2.0寸电子墨水屏作为阅读显示终端,实现文本内容显示。墨水屏具有超低功耗、显示接近纸质效果、静态显示不耗电等特点,适用于电子阅读应用。

选型:

    • 显示尺寸:2.0英寸• 分辨率:常见 250×122 或同等级规格• 显示类型:黑白电子墨水屏• 通信接口:SPI• 工作电压:3.3V• 驱动方式:控制器自带刷新驱动

选型:

    • 阅读舒适度较高• 静态页面功耗低• 适合文字显示场景

连接信号:

    • DIN(MOSI)• CLK(SCK)• CS• DC• RST• BUSY

(4)语音播放模块选型——SYN6288语音合成模块
系统采用SYN6288语音合成模块,实现电子书文本语音播报功能。主控将文本数据通过串口发送至模块,由模块完成语音转换输出。

选型:

    • 型号:SYN6288• 通信方式:UART串口• 工作电压:3.3V~5V• 输出方式:音频输出• 支持功能:中文文本转语音播放

选型:

    • 集成度高• 串口控制简单• 与ESP32接口兼容性好

接口连接:

    • TX → ESP32 RX• RX → ESP32 TX• 音频输出 → 功放模块

(5)音频输出模块选型——扬声器模块
由于SYN6288输出语音信号,因此需要配套扬声器进行语音播放,实现电子书语音阅读功能。

选型:

    • 类型:8Ω小型扬声器• 功率:1W~3W• 驱动方式:配合语音模块输出• 安装方式:板载或外置

选型:

    • 满足语音播报需求• 功耗低、结构简单

(6)按键输入模块选型——轻触按键模块
系统通过物理按键实现电子书选择、页面翻页等交互操作。

选型:

    • 类型:轻触按键• 数量:根据功能配置(选择、确认、上一页、下一页)• 工作电压:3.3V• 接口方式:GPIO输入

选型:

    • 成本低• 操作直观• 易于实现中断响应

电路:


(7)通信更新接口选型——USB串口通信模块
系统支持通过Qt5开发的APP上传电子书文件,因此需要通信接口完成数据传输。

选型:

    • 通信方式:USB转串口• 芯片:CH340 或 CP2102• 数据接口:UART• 波特率:115200及以上

连接:

    • TXD ↔ RXD• RXD ↔ TXD

(8)电源供电模块选型——3.3V稳压供电电路
系统整体工作电压以3.3V为主,需要设计稳定供电模块保证系统运行。

选型:

    • 输入方式:USB 5V供电• 输出电压:3.3V• 稳压芯片:AMS1117-3.3(常规方案)• 滤波:输入输出电容滤波

三、上位机开发

3.1 Qt开发环境安装

Qt的中文官网: https://www.qt.io/zh-cn/![image-20221207160550486](https://led-obs.obs.cn-north-1.myhuaweicloud.com/Blog/img/image-20221207160550486.png)

QT5.12.6的下载地址:https://download.qt.io/archive/qt/5.12/5.12.6

打开下载链接后选择下面的版本进行下载:

如果下载不了,可以在网盘里找到安装包下载:    飞书文档记录的网盘地址:https://ccnr8sukk85n.feishu.cn/wiki/QjY8weDYHibqRYkFP2qcA9aGnvb?from=from_copylink

软件安装时断网安装,否则会提示输入账户。

安装的时候,第一个复选框里的编译器可以全选,直接点击下一步继续安装。

选择编译器: (一定要看清楚了)

3.2 新建上位机工程

前面2讲解了需要用的API接口,接下来就使用Qt设计上位机,设计界面,完成整体上位机的逻辑设计。

【1】新建工程

【2】设置项目的名称。

【3】选择编译系统

【4】选择默认继承的类

【5】选择编译器

【6】点击完成

【7】工程创建完成

3.3 切换编译器

在左下角是可以切换编译器的。 可以选择用什么样的编译器编译程序。

目前新建工程的时候选择了2种编译器。 一种是mingw32这个编译Windows下运行的程序。 一种是Android编译器,可以生成Android手机APP。

不过要注意:Android的编译器需要配置一些环境才可以正常使用,这个大家可以网上找找教程配置一下就行了。

windows的编译器就没有这么麻烦,安装好Qt就可以编译使用。

下面我这里就选择的 mingw32这个编译器,编译Windows下运行的程序。

3.4 编译测试功能

创建完毕之后,编译测试一下功能是否OK。

点击左下角的绿色三角形按钮

正常运行就可以看到弹出一个白色的框框。这就表示工程环境没有问题了。 接下来就可以放心的设计界面了。

3.5 设计UI界面与工程配置

【1】打开UI文件

打开默认的界面如下:

【2】开始设计界面

根据自己需求设计界面。

项目结构
EbookReader/
├── EbookReader.pro
├── main.cpp
├── mainwindow.h
├── mainwindow.cpp
├── mainwindow.ui
├── ebookreader.h
├── ebookreader.cpp
├── filemanager.h
├── filemanager.cpp
├── settings.h
├── settings.cpp
└── resources/
    ├── images/
    └── styles.qss

1. 项目配置文件 (EbookReader.pro)
// EbookReader.pro
QT       += core widgets serialport multimedia

TARGET = EbookReader
TEMPLATE = app

DEFINES += QT_DEPRECATED_WARNINGS

SOURCES += 
    main.cpp 
    mainwindow.cpp 
    ebookreader.cpp 
    filemanager.cpp 
    settings.cpp

HEADERS += 
    mainwindow.h 
    ebookreader.h 
    filemanager.h 
    settings.h

FORMS += 
    mainwindow.ui

RESOURCES += 
    resources.qrc

# 默认规则
qnx: target.path = /tmp/$${TARGET}/bin
else: unix:!android: target.path = /opt/$${TARGET}/bin
!isEmpty(target.path): INSTALLS += target

2. 主窗口头文件 (mainwindow.h)
// mainwindow.h
#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>
#include <QStackedWidget>
#include <QListWidget>
#include <QTextEdit>
#include <QPushButton>
#include <QLabel>
#include <QProgressBar>
#include <QSlider>
#include <QComboBox>
#include <QCheckBox>
#include <QSerialPort>
#include <QTimer>

QT_BEGIN_NAMESPACE
namespace Ui { class MainWindow; }
QT_END_NAMESPACE

class EbookReader;
class FileManager;
class SettingsDialog;

class MainWindow : public QMainWindow
{
    Q_OBJECT

public:
    MainWindow(QWidget *parent = nullptr);
    ~MainWindow();

protected:
    void closeEvent(QCloseEvent *event) override;

private slots:
    // 页面切换
    void switchToLibrary();
    void switchToReader();
    void switchToSettings();
    
    // 文件操作
    void openFile();
    void deleteFile();
    void refreshFileList();
    void onFileSelected(const QString &filename);
    
    // 阅读控制
    void nextPage();
    void prevPage();
    void goToPage(int page);
    void changeFontSize(int size);
    void toggleFullScreen();
    
    // 语音控制
    void startVoicePlay();
    void stopVoicePlay();
    void adjustVoiceSpeed(int speed);
    void adjustVoiceVolume(int volume);
    
    // 串口通信
    void connectDevice();
    void disconnectDevice();
    void sendToDevice(const QByteArray &data);
    void readFromDevice();
    void updateDeviceStatus();
    
    // 其他
    void updateStatusBar(const QString &message);
    void showAbout();

private:
    void setupUI();
    void setupConnections();
    void setupSerialPort();
    void loadSettings();
    void saveSettings();
    void updateReaderUI();
    
    Ui::MainWindow *ui;
    
    // 核心组件
    EbookReader *ebookReader;
    FileManager *fileManager;
    SettingsDialog *settingsDialog;
    
    // 串口通信
    QSerialPort *serialPort;
    bool isDeviceConnected;
    QTimer *statusTimer;
    
    // UI组件(动态创建)
    QStackedWidget *centralStack;
    QWidget *libraryWidget;
    QWidget *readerWidget;
    QWidget *settingsWidget;
    
    // 库页面组件
    QListWidget *fileList;
    QPushButton *openButton;
    QPushButton *deleteButton;
    QPushButton *refreshButton;
    QLabel *fileInfoLabel;
    
    // 阅读器页面组件
    QTextEdit *textDisplay;
    QLabel *pageLabel;
    QPushButton *prevButton;
    QPushButton *nextButton;
    QPushButton *voiceButton;
    QSlider *pageSlider;
    QComboBox *fontSizeCombo;
    QProgressBar *progressBar;
    
    // 状态
    QString currentFile;
    int currentPage;
    int totalPages;
    bool isVoicePlaying;
};

#endif // MAINWINDOW_H

3. 主窗口实现 (mainwindow.cpp)
// mainwindow.cpp
#include "mainwindow.h"
#include "ui_mainwindow.h"
#include "ebookreader.h"
#include "filemanager.h"
#include "settings.h"

#include <QFileDialog>
#include <QMessageBox>
#include <QTextStream>
#include <QFile>
#include <QSettings>
#include <QCloseEvent>
#include <QSerialPortInfo>
#include <QDebug>
#include <QScrollBar>
#include <QTimer>
#include <QMediaPlayer>
#include <QAudioOutput>

MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent)
    , ui(new Ui::MainWindow)
    , ebookReader(nullptr)
    , fileManager(nullptr)
    , settingsDialog(nullptr)
    , serialPort(nullptr)
    , isDeviceConnected(false)
    , statusTimer(nullptr)
    , currentPage(0)
    , totalPages(0)
    , isVoicePlaying(false)
{
    ui->setupUi(this);
    
    // 初始化组件
    ebookReader = new EbookReader(this);
    fileManager = new FileManager(this);
    settingsDialog = new SettingsDialog(this);
    
    // 设置UI
    setupUI();
    setupConnections();
    setupSerialPort();
    
    // 加载设置
    loadSettings();
    
    // 更新状态
    updateStatusBar("Ready");
    refreshFileList();
    
    // 设置窗口标题
    setWindowTitle("智能电子书阅读器 - ESP32-S3");
    setMinimumSize(900, 600);
}

MainWindow::~MainWindow()
{
    if (serialPort && serialPort->isOpen()) {
        serialPort->close();
    }
    delete ui;
}

void MainWindow::setupUI()
{
    // 创建中央堆叠窗口
    centralStack = new QStackedWidget(this);
    setCentralWidget(centralStack);
    
    // ============ 库页面 ============
    libraryWidget = new QWidget();
    QVBoxLayout *libraryLayout = new QVBoxLayout(libraryWidget);
    
    // 工具栏
    QHBoxLayout *toolbarLayout = new QHBoxLayout();
    QLabel *titleLabel = new QLabel(" 电子书库", this);
    titleLabel->setStyleSheet("font-size: 18px; font-weight: bold;");
    toolbarLayout->addWidget(titleLabel);
    toolbarLayout->addStretch();
    
    openButton = new QPushButton(" 打开", this);
    deleteButton = new QPushButton("️ 删除", this);
    refreshButton = new QPushButton(" 刷新", this);
    
    toolbarLayout->addWidget(openButton);
    toolbarLayout->addWidget(deleteButton);
    toolbarLayout->addWidget(refreshButton);
    libraryLayout->addLayout(toolbarLayout);
    
    // 文件列表
    fileList = new QListWidget(this);
    fileList->setSelectionMode(QAbstractItemView::SingleSelection);
    fileList->setStyleSheet("QListWidget::item { padding: 5px; }"
                           "QListWidget::item:selected { background: #2a82da; color: white; }");
    libraryLayout->addWidget(fileList);
    
    // 文件信息
    fileInfoLabel = new QLabel("共 0 个文件", this);
    libraryLayout->addWidget(fileInfoLabel);
    
    centralStack->addWidget(libraryWidget);
    
    // ============ 阅读器页面 ============
    readerWidget = new QWidget();
    QVBoxLayout *readerLayout = new QVBoxLayout(readerWidget);
    
    // 阅读器顶部工具栏
    QHBoxLayout *readerToolbar = new QHBoxLayout();
    QPushButton *backButton = new QPushButton("← 返回", this);
    QLabel *readerTitle = new QLabel("阅读中", this);
    readerTitle->setStyleSheet("font-size: 14px; font-weight: bold;");
    
    readerToolbar->addWidget(backButton);
    readerToolbar->addWidget(readerTitle);
    readerToolbar->addStretch();
    
    fontSizeCombo = new QComboBox(this);
    fontSizeCombo->addItems({"12", "14", "16", "18", "20", "24", "28"});
    fontSizeCombo->setCurrentText("16");
    readerToolbar->addWidget(new QLabel("字体:", this));
    readerToolbar->addWidget(fontSizeCombo);
    
    QPushButton *fullscreenButton = new QPushButton("⛶ 全屏", this);
    readerToolbar->addWidget(fullscreenButton);
    readerLayout->addLayout(readerToolbar);
    
    // 文本显示区域
    textDisplay = new QTextEdit(this);
    textDisplay->setReadOnly(true);
    textDisplay->setStyleSheet("QTextEdit { background: #f5f0e8; border: 1px solid #ddd; }"
                              "QTextEdit:focus { border: 1px solid #2a82da; }");
    readerLayout->addWidget(textDisplay);
    
    // 阅读器底部控制栏
    QHBoxLayout *readerControls = new QHBoxLayout();
    
    prevButton = new QPushButton("◀ 上一页", this);
    nextButton = new QPushButton("下一页 ▶", this);
    voiceButton = new QPushButton(" 语音", this);
    
    pageLabel = new QLabel("第 1 / 1 页", this);
    pageLabel->setAlignment(Qt::AlignCenter);
    
    pageSlider = new QSlider(Qt::Horizontal, this);
    pageSlider->setRange(0, 0);
    pageSlider->setPageStep(1);
    
    progressBar = new QProgressBar(this);
    progressBar->setRange(0, 100);
    progressBar->setValue(0);
    progressBar->setTextVisible(true);
    progressBar->setFixedWidth(150);
    
    readerControls->addWidget(prevButton);
    readerControls->addWidget(nextButton);
    readerControls->addWidget(voiceButton);
    readerControls->addStretch();
    readerControls->addWidget(pageLabel);
    readerControls->addStretch();
    readerControls->addWidget(pageSlider);
    readerControls->addWidget(progressBar);
    
    readerLayout->addLayout(readerControls);
    
    centralStack->addWidget(readerWidget);
    
    // ============ 设置页面 ============
    settingsWidget = new QWidget();
    QVBoxLayout *settingsLayout = new QVBoxLayout(settingsWidget);
    
    QLabel *settingsTitle = new QLabel("⚙️ 设置", this);
    settingsTitle->setStyleSheet("font-size: 18px; font-weight: bold;");
    settingsLayout->addWidget(settingsTitle);
    
    // 创建设置分组
    QGroupBox *displayGroup = new QGroupBox("显示设置", this);
    QFormLayout *displayForm = new QFormLayout(displayGroup);
    
    QComboBox *themeCombo = new QComboBox(this);
    themeCombo->addItems({"浅色", "深色", "护眼"});
    displayForm->addRow("主题:", themeCombo);
    
    QCheckBox *autoScrollCheck = new QCheckBox("自动滚屏", this);
    displayForm->addRow("", autoScrollCheck);
    
    settingsLayout->addWidget(displayGroup);
    
    QGroupBox *voiceGroup = new QGroupBox("语音设置", this);
    QFormLayout *voiceForm = new QFormLayout(voiceGroup);
    
    QCheckBox *voiceEnableCheck = new QCheckBox("启用语音", this);
    voiceForm->addRow("", voiceEnableCheck);
    
    QSlider *voiceSpeedSlider = new QSlider(Qt::Horizontal, this);
    voiceSpeedSlider->setRange(0, 100);
    voiceSpeedSlider->setValue(50);
    voiceForm->addRow("语速:", voiceSpeedSlider);
    
    QSlider *voiceVolumeSlider = new QSlider(Qt::Horizontal, this);
    voiceVolumeSlider->setRange(0, 100);
    voiceVolumeSlider->setValue(80);
    voiceForm->addRow("音量:", voiceVolumeSlider);
    
    settingsLayout->addWidget(voiceGroup);
    
    QGroupBox *deviceGroup = new QGroupBox("设备连接", this);
    QVBoxLayout *deviceLayout = new QVBoxLayout(deviceGroup);
    
    QHBoxLayout *deviceSelectLayout = new QHBoxLayout();
    QComboBox *portCombo = new QComboBox(this);
    QPushButton *scanButton = new QPushButton("扫描", this);
    QPushButton *connectButton = new QPushButton("连接", this);
    deviceSelectLayout->addWidget(new QLabel("端口:", this));
    deviceSelectLayout->addWidget(portCombo);
    deviceSelectLayout->addWidget(scanButton);
    deviceSelectLayout->addWidget(connectButton);
    deviceLayout->addLayout(deviceSelectLayout);
    
    QLabel *deviceStatus = new QLabel("未连接", this);
    deviceStatus->setStyleSheet("color: red;");
    deviceLayout->addWidget(deviceStatus);
    
    settingsLayout->addWidget(deviceGroup);
    
    settingsLayout->addStretch();
    
    QPushButton *saveButton = new QPushButton("保存设置", this);
    settingsLayout->addWidget(saveButton);
    
    centralStack->addWidget(settingsWidget);
    
    // 默认显示库页面
    centralStack->setCurrentIndex(0);
}

void MainWindow::setupConnections()
{
    // 按钮连接
    connect(openButton, &QPushButton::clicked, this, &MainWindow::openFile);
    connect(deleteButton, &QPushButton::clicked, this, &MainWindow::deleteFile);
    connect(refreshButton, &QPushButton::clicked, this, &MainWindow::refreshFileList);
    connect(fileList, &QListWidget::itemDoubleClicked, this, &MainWindow::onFileSelected);
    
    // 阅读器控制
    connect(prevButton, &QPushButton::clicked, this, &MainWindow::prevPage);
    connect(nextButton, &QPushButton::clicked, this, &MainWindow::nextPage);
    connect(voiceButton, &QPushButton::clicked, this, &MainWindow::startVoicePlay);
    connect(pageSlider, &QSlider::sliderMoved, this, &MainWindow::goToPage);
    connect(fontSizeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), 
            [this]() { 
                int size = fontSizeCombo->currentText().toInt();
                changeFontSize(size);
            });
    
    // 菜单栏动作
    connect(ui->actionOpen, &QAction::triggered, this, &MainWindow::openFile);
    connect(ui->actionExit, &QAction::triggered, this, &QMainWindow::close);
    connect(ui->actionAbout, &QAction::triggered, this, &MainWindow::showAbout);
    connect(ui->actionLibrary, &QAction::triggered, this, &MainWindow::switchToLibrary);
    connect(ui->actionSettings, &QAction::triggered, this, &MainWindow::switchToSettings);
    
    // 工具栏
    connect(ui->toolBar->actions()[0], &QAction::triggered, this, &MainWindow::switchToLibrary);
    connect(ui->toolBar->actions()[1], &QAction::triggered, this, &MainWindow::switchToReader);
    connect(ui->toolBar->actions()[2], &QAction::triggered, this, &MainWindow::switchToSettings);
}

void MainWindow::setupSerialPort()
{
    serialPort = new QSerialPort(this);
    
    // 定时更新设备状态
    statusTimer = new QTimer(this);
    connect(statusTimer, &QTimer::timeout, this, &MainWindow::updateDeviceStatus);
    statusTimer->start(3000); // 每3秒更新一次
    
    // 串口数据接收
    connect(serialPort, &QSerialPort::readyRead, this, &MainWindow::readFromDevice);
}

void MainWindow::loadSettings()
{
    QSettings settings("EbookReader", "Settings");
    
    // 恢复窗口大小
    resize(settings.value("windowSize", QSize(900, 600)).toSize());
    move(settings.value("windowPos", QPoint(100, 100)).toPoint());
    
    // 恢复字体大小
    int fontSize = settings.value("fontSize", 16).toInt();
    fontSizeCombo->setCurrentText(QString::number(fontSize));
    
    // 恢复语音设置
    // ...
}

void MainWindow::saveSettings()
{
    QSettings settings("EbookReader", "Settings");
    
    settings.setValue("windowSize", size());
    settings.setValue("windowPos", pos());
    settings.setValue("fontSize", fontSizeCombo->currentText().toInt());
}

void MainWindow::closeEvent(QCloseEvent *event)
{
    saveSettings();
    event->accept();
}

// ============ 页面切换 ============
void MainWindow::switchToLibrary()
{
    centralStack->setCurrentIndex(0);
    refreshFileList();
    updateStatusBar("切换到书库");
}

void MainWindow::switchToReader()
{
    if (currentFile.isEmpty()) {
        QMessageBox::warning(this, "提示", "请先选择一本电子书");
        return;
    }
    centralStack->setCurrentIndex(1);
    updateStatusBar("正在阅读: " + currentFile);
}

void MainWindow::switchToSettings()
{
    centralStack->setCurrentIndex(2);
    updateStatusBar("设置");
}

// ============ 文件操作 ============
void MainWindow::openFile()
{
    QString filePath = QFileDialog::getOpenFileName(
        this, 
        "打开电子书", 
        QDir::homePath(), 
        "文本文件 (*.txt);;所有文件 (*.*)"
    );
    
    if (filePath.isEmpty()) return;
    
    QFile file(filePath);
    if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
        QMessageBox::critical(this, "错误", "无法打开文件");
        return;
    }
    
    // 复制到应用程序目录
    QFileInfo fileInfo(filePath);
    QString destPath = QDir::currentPath() + "/books/" + fileInfo.fileName();
    
    QDir dir;
    dir.mkpath(QDir::currentPath() + "/books");
    
    if (QFile::exists(destPath)) {
        QMessageBox::StandardButton reply;
        reply = QMessageBox::question(this, "提示", 
                                      "文件已存在,是否覆盖?",
                                      QMessageBox::Yes | QMessageBox::No);
        if (reply == QMessageBox::No) return;
    }
    
    if (!QFile::copy(filePath, destPath)) {
        QMessageBox::critical(this, "错误", "复制文件失败");
        return;
    }
    
    refreshFileList();
    updateStatusBar("已添加: " + fileInfo.fileName());
}

void MainWindow::deleteFile()
{
    QListWidgetItem *item = fileList->currentItem();
    if (!item) {
        QMessageBox::warning(this, "提示", "请先选择一个文件");
        return;
    }
    
    QString filename = item->text();
    QString filePath = QDir::currentPath() + "/books/" + filename;
    
    QMessageBox::StandardButton reply;
    reply = QMessageBox::question(this, "确认删除", 
                                  "确定要删除 " + filename + " 吗?",
                                  QMessageBox::Yes | QMessageBox::No);
    
    if (reply == QMessageBox::Yes) {
        QFile::remove(filePath);
        refreshFileList();
        updateStatusBar("已删除: " + filename);
    }
}

void MainWindow::refreshFileList()
{
    fileList->clear();
    
    QString booksDir = QDir::currentPath() + "/books";
    QDir dir(booksDir);
    if (!dir.exists()) {
        dir.mkpath(booksDir);
    }
    
    QStringList filters;
    filters << "*.txt" << "*.TXT";
    dir.setNameFilters(filters);
    dir.setFilter(QDir::Files);
    
    QStringList files = dir.entryList();
    for (const QString &file : files) {
        fileList->addItem(file);
    }
    
    fileInfoLabel->setText(QString("共 %1 个文件").arg(files.size()));
    updateStatusBar(QString("刷新完成,找到 %1 个文件").arg(files.size()));
}

void MainWindow::onFileSelected(const QString &filename)
{
    currentFile = filename;
    QString filePath = QDir::currentPath() + "/books/" + filename;
    
    // 加载文件内容到阅读器
    if (ebookReader->loadFile(filePath)) {
        totalPages = ebookReader->getTotalPages();
        currentPage = 0;
        updateReaderUI();
        switchToReader();
    } else {
        QMessageBox::critical(this, "错误", "无法加载电子书");
    }
}

// ============ 阅读控制 ============
void MainWindow::nextPage()
{
    if (currentPage < totalPages - 1) {
        currentPage++;
        updateReaderUI();
    }
}

void MainWindow::prevPage()
{
    if (currentPage > 0) {
        currentPage--;
        updateReaderUI();
    }
}

void MainWindow::goToPage(int page)
{
    if (page >= 0 && page < totalPages) {
        currentPage = page;
        updateReaderUI();
    }
}

void MainWindow::changeFontSize(int size)
{
    QFont font = textDisplay->font();
    font.setPointSize(size);
    textDisplay->setFont(font);
    
    // 重新显示当前页
    updateReaderUI();
}

void MainWindow::toggleFullScreen()
{
    if (isFullScreen()) {
        showNormal();
    } else {
        showFullScreen();
    }
}

void MainWindow::updateReaderUI()
{
    if (!ebookReader) return;
    
    // 显示当前页内容
    QString pageContent = ebookReader->getPage(currentPage);
    textDisplay->setPlainText(pageContent);
    
    // 更新页码
    pageLabel->setText(QString("第 %1 / %2 页").arg(currentPage + 1).arg(totalPages));
    
    // 更新进度条
    int progress = (currentPage + 1) * 100 / totalPages;
    progressBar->setValue(progress);
    
    // 更新滑动条
    pageSlider->setRange(0, totalPages - 1);
    pageSlider->setValue(currentPage);
    
    // 更新按钮状态
    prevButton->setEnabled(currentPage > 0);
    nextButton->setEnabled(currentPage < totalPages - 1);
}

// ============ 语音控制 ============
void MainWindow::startVoicePlay()
{
    if (!ebookReader || currentFile.isEmpty()) {
        QMessageBox::warning(this, "提示", "请先打开一本电子书");
        return;
    }
    
    if (isVoicePlaying) {
        stopVoicePlay();
        return;
    }
    
    // 获取当前页文本
    QString pageText = ebookReader->getPage(currentPage);
    if (pageText.isEmpty()) {
        QMessageBox::warning(this, "提示", "当前页没有文本内容");
        return;
    }
    
    isVoicePlaying = true;
    voiceButton->setText("⏹ 停止");
    
    // 使用QT的文本转语音(需要安装模块)
    // 或者通过串口发送文本到SYN6288
    if (isDeviceConnected) {
        QByteArray data = pageText.toUtf8();
        sendToDevice(data);
        updateStatusBar("正在播放语音...");
    } else {
        // 使用系统TTS
        QMediaPlayer *player = new QMediaPlayer(this);
        // ... TTS实现
        updateStatusBar("语音播放中(模拟)");
    }
}

void MainWindow::stopVoicePlay()
{
    isVoicePlaying = false;
    voiceButton->setText(" 语音");
    updateStatusBar("语音已停止");
}

void MainWindow::adjustVoiceSpeed(int speed)
{
    // 调整语速
    if (isDeviceConnected) {
        QByteArray cmd = QString("SPEED:%1n").arg(speed).toUtf8();
        sendToDevice(cmd);
    }
}

void MainWindow::adjustVoiceVolume(int volume)
{
    // 调整音量
    if (isDeviceConnected) {
        QByteArray cmd = QString("VOLUME:%1n").arg(volume).toUtf8();
        sendToDevice(cmd);
    }
}

// ============ 串口通信 ============
void MainWindow::connectDevice()
{
    // 获取可用串口列表
    QList<QSerialPortInfo> ports = QSerialPortInfo::availablePorts();
    if (ports.isEmpty()) {
        QMessageBox::warning(this, "提示", "没有找到可用串口");
        return;
    }
    
    // 选择第一个可用串口
    QString portName = ports.first().portName();
    serialPort->setPortName(portName);
    serialPort->setBaudRate(QSerialPort::Baud9600);
    serialPort->setDataBits(QSerialPort::Data8);
    serialPort->setParity(QSerialPort::NoParity);
    serialPort->setStopBits(QSerialPort::OneStop);
    serialPort->setFlowControl(QSerialPort::NoFlowControl);
    
    if (serialPort->open(QIODevice::ReadWrite)) {
        isDeviceConnected = true;
        updateStatusBar("已连接到设备: " + portName);
        QMessageBox::information(this, "成功", "设备连接成功!");
    } else {
        isDeviceConnected = false;
        QMessageBox::critical(this, "错误", "无法连接到设备");
    }
}

void MainWindow::disconnectDevice()
{
    if (serialPort->isOpen()) {
        serialPort->close();
    }
    isDeviceConnected = false;
    updateStatusBar("设备已断开");
}

void MainWindow::sendToDevice(const QByteArray &data)
{
    if (serialPort && serialPort->isOpen()) {
        serialPort->write(data);
        serialPort->flush();
    }
}

void MainWindow::readFromDevice()
{
    if (serialPort && serialPort->isOpen()) {
        QByteArray data = serialPort->readAll();
        // 处理接收到的数据
        qDebug() << "Received: " << data;
    }
}

void MainWindow::updateDeviceStatus()
{
    // 更新设备状态显示
    QString status = isDeviceConnected ? "已连接" : "未连接";
    // 更新UI状态
}

// ============ 工具函数 ============
void MainWindow::updateStatusBar(const QString &message)
{
    statusBar()->showMessage(message, 3000);
}

void MainWindow::showAbout()
{
    QMessageBox::about(this, 
                       "关于智能电子书阅读器",
                       "智能电子书阅读器 v1.0nn"
                       "基于 ESP32-S3 开发的电子书阅读器n"
                       "支持:n"
                       "• FATFS 文件系统n"
                       "• SD卡读写n"
                       "• 2.0寸墨水屏显示n"
                       "• SYN6288 语音播报n"
                       "• 按键控制nn"
                       "© 2026 All Rights Reserved");
}

// ============ 电子书阅读器类 ============
class EbookReader
{
public:
    EbookReader(QObject *parent = nullptr) : QObject(parent) {}
    
    bool loadFile(const QString &filePath)
    {
        QFile file(filePath);
        if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
            return false;
        }
        
        content = file.readAll();
        file.close();
        
        // 分页处理
        splitPages();
        return true;
    }
    
    QString getPage(int page)
    {
        if (page >= 0 && page < pages.size()) {
            return pages[page];
        }
        return QString();
    }
    
    int getTotalPages() const
    {
        return pages.size();
    }
    
private:
    void splitPages()
    {
        pages.clear();
        
        // 简单分页:每页1000个字符
        int pageSize = 1000;
        int totalSize = content.size();
        
        for (int i = 0; i < totalSize; i += pageSize) {
            QString page = content.mid(i, pageSize);
            pages.append(page);
        }
        
        if (pages.isEmpty()) {
            pages.append("(空文件)");
        }
    }
    
    QString content;
    QList<QString> pages;
};

// ============ 文件管理器类 ============
class FileManager
{
public:
    FileManager(QObject *parent = nullptr) : QObject(parent) {}
    
    QStringList getBookFiles()
    {
        QStringList files;
        QDir dir(QDir::currentPath() + "/books");
        if (dir.exists()) {
            QStringList filters;
            filters << "*.txt" << "*.TXT";
            files = dir.entryList(filters, QDir::Files);
        }
        return files;
    }
};

// ============ 设置对话框 ============
class SettingsDialog : public QDialog
{
    Q_OBJECT
    
public:
    SettingsDialog(QWidget *parent = nullptr) : QDialog(parent)
    {
        setupUI();
    }
    
private:
    void setupUI()
    {
        setWindowTitle("设置");
        setModal(true);
        resize(400, 300);
        
        QVBoxLayout *layout = new QVBoxLayout(this);
        
        QTabWidget *tabs = new QTabWidget(this);
        
        // 显示设置
        QWidget *displayTab = new QWidget();
        QFormLayout *displayForm = new QFormLayout(displayTab);
        displayForm->addRow("字体大小:", new QComboBox(this));
        displayForm->addRow("主题:", new QComboBox(this));
        tabs->addTab(displayTab, "显示");
        
        // 语音设置
        QWidget *voiceTab = new QWidget();
        QFormLayout *voiceForm = new QFormLayout(voiceTab);
        voiceForm->addRow("启用语音:", new QCheckBox(this));
        voiceForm->addRow("语速:", new QSlider(Qt::Horizontal, this));
        voiceForm->addRow("音量:", new QSlider(Qt::Horizontal, this));
        tabs->addTab(voiceTab, "语音");
        
        // 设备设置
        QWidget *deviceTab = new QWidget();
        QFormLayout *deviceForm = new QFormLayout(deviceTab);
        deviceForm->addRow("设备端口:", new QComboBox(this));
        tabs->addTab(deviceTab, "设备");
        
        layout->addWidget(tabs);
        
        QDialogButtonBox *buttonBox = new QDialogButtonBox(
            QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
        connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
        connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
        layout->addWidget(buttonBox);
    }
};

4. 主程序入口 (main.cpp)
// main.cpp
#include <QApplication>
#include <QStyleFactory>
#include <QFile>
#include <QTextStream>
#include "mainwindow.h"

void loadStyleSheet(QApplication &app)
{
    QFile file(":/resources/styles.qss");
    if (file.open(QFile::ReadOnly)) {
        QTextStream stream(&file);
        QString style = stream.readAll();
        app.setStyleSheet(style);
        file.close();
    }
}

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    
    // 设置应用程序信息
    a.setApplicationName("智能电子书阅读器");
    a.setOrganizationName("EbookReader");
    
    // 加载样式表
    loadStyleSheet(a);
    
    // 创建主窗口
    MainWindow w;
    w.show();
    
    return a.exec();
}

5. UI设计文件 (mainwindow.ui)
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>MainWindow</class>
 <widget class="QMainWindow" name="MainWindow">
  <property name="geometry">
   <rect>
    <x>0</x>
    <y>0</y>
    <width>900</width>
    <height>600</height>
   </rect>
  </property>
  <property name="windowTitle">
   <string>智能电子书阅读器</string>
  </property>
  <widget class="QWidget" name="centralwidget"/>
  <widget class="QMenuBar" name="menubar">
   <property name="geometry">
    <rect>
     <x>0</x>
     <y>0</y>
     <width>900</width>
     <height>22</height>
    </rect>
   </property>
   <widget class="QMenu" name="menuFile">
    <property name="title">
     <string>文件</string>
    </property>
    <addaction name="actionOpen"/>
    <addaction name="separator"/>
    <addaction name="actionExit"/>
   </widget>
   <widget class="QMenu" name="menuView">
    <property name="title">
     <string>视图</string>
    </property>
    <addaction name="actionLibrary"/>
    <addaction name="actionReader"/>
    <addaction name="actionSettings"/>
   </widget>
   <widget class="QMenu" name="menuHelp">
    <property name="title">
     <string>帮助</string>
    </property>
    <addaction name="actionAbout"/>
   </widget>
   <addaction name="menuFile"/>
   <addaction name="menuView"/>
   <addaction name="menuHelp"/>
  </widget>
  <widget class="QToolBar" name="toolBar">
   <property name="windowTitle">
    <string>工具栏</string>
   </property>
   <attribute name="toolBarArea">
    <enum>TopToolBarArea</enum>
   </attribute>
   <attribute name="toolBarBreak">
    <bool>false</bool>
   </attribute>
   <addaction name="actionLibrary"/>
   <addaction name="actionReader"/>
   <addaction name="actionSettings"/>
  </widget>
  <action name="actionOpen">
   <property name="text">
    <string>打开</string>
   </property>
   <property name="shortcut">
    <string>Ctrl+O</string>
   </property>
  </action>
  <action name="actionExit">
   <property name="text">
    <string>退出</string>
   </property>
   <property name="shortcut">
    <string>Ctrl+Q</string>
   </property>
  </action>
  <action name="actionLibrary">
   <property name="text">
    <string>书库</string>
   </property>
  </action>
  <action name="actionReader">
   <property name="text">
    <string>阅读器</string>
   </property>
  </action>
  <action name="actionSettings">
   <property name="text">
    <string>设置</string>
   </property>
  </action>
  <action name="actionAbout">
   <property name="text">
    <string>关于</string>
   </property>
  </action>
 </widget>
 <resources/>
 <connections/>
</ui>

6. 样式表 (styles.qss)
/* styles.qss */
QMainWindow {
    background-color: #f0f0f0;
}

QPushButton {
    background-color: #4CAF50;
    border: none;
    color: white;
    padding: 8px 16px;
    border-radius: 4px;
    font-size: 12px;
}

QPushButton:hover {
    background-color: #45a049;
}

QPushButton:pressed {
    background-color: #3d8b40;
}

QPushButton:disabled {
    background-color: #cccccc;
    color: #666666;
}

QListWidget {
    background-color: white;
    border: 1px solid #ddd;
    border-radius: 4px;
    padding: 5px;
}

QListWidget::item {
    padding: 8px;
    border-bottom: 1px solid #eee;
}

QListWidget::item:selected {
    background-color: #2196F3;
    color: white;
}

QListWidget::item:hover {
    background-color: #e3f2fd;
}

QTextEdit {
    background-color: #fafafa;
    border: 1px solid #ddd;
    border-radius: 4px;
    padding: 10px;
    font-family: "Microsoft YaHei", "SimSun", sans-serif;
}

QSlider::groove:horizontal {
    height: 6px;
    background: #ddd;
    border-radius: 3px;
}

QSlider::handle:horizontal {
    background: #2196F3;
    border: none;
    width: 16px;
    height: 16px;
    margin: -5px 0;
    border-radius: 8px;
}

QSlider::handle:horizontal:hover {
    background: #1976D2;
}

QProgressBar {
    border: 1px solid #ddd;
    border-radius: 4px;
    text-align: center;
    background: white;
}

QProgressBar::chunk {
    background-color: #4CAF50;
    border-radius: 4px;
}

QGroupBox {
    font-weight: bold;
    border: 1px solid #ddd;
    border-radius: 4px;
    margin-top: 10px;
    padding-top: 10px;
}

QGroupBox::title {
    subcontrol-origin: margin;
    left: 10px;
    padding: 0 5px 0 5px;
}

QTabWidget::pane {
    border: 1px solid #ddd;
    border-radius: 4px;
}

QTabBar::tab {
    background: #f0f0f0;
    padding: 8px 16px;
    margin-right: 2px;
    border-top-left-radius: 4px;
    border-top-right-radius: 4px;
}

QTabBar::tab:selected {
    background: white;
    border-bottom: 2px solid #2196F3;
}

QTabBar::tab:hover {
    background: #e0e0e0;
}

QComboBox {
    padding: 4px 8px;
    border: 1px solid #ddd;
    border-radius: 4px;
    background: white;
}

QComboBox:hover {
    border-color: #2196F3;
}

QComboBox::drop-down {
    border: none;
}

QComboBox::down-arrow {
    image: url(:/images/down_arrow.png);
    width: 12px;
    height: 12px;
}

QCheckBox {
    spacing: 8px;
}

QCheckBox::indicator {
    width: 16px;
    height: 16px;
}

QCheckBox::indicator:unchecked {
    border: 2px solid #999;
    border-radius: 3px;
    background: white;
}

QCheckBox::indicator:checked {
    border: 2px solid #4CAF50;
    border-radius: 3px;
    background: #4CAF50;
}

QLabel {
    color: #333;
}

QMenuBar {
    background-color: white;
    border-bottom: 1px solid #ddd;
}

QMenuBar::item {
    padding: 4px 8px;
}

QMenuBar::item:selected {
    background-color: #e3f2fd;
}

QToolBar {
    background-color: white;
    border: none;
    border-bottom: 1px solid #ddd;
    spacing: 5px;
    padding: 5px;
}

QToolBar::separator {
    width: 2px;
    background: #ddd;
    margin: 4px;
}

QStatusBar {
    background-color: white;
    border-top: 1px solid #ddd;
    color: #666;
}

/* 护眼模式 */
QTextEdit[theme="eye"] {
    background-color: #c7edcc;
    color: #333;
}

/* 深色模式 */
QTextEdit[theme="dark"] {
    background-color: #1e1e1e;
    color: #d4d4d4;
}

7. 资源文件 (resources.qrc)
<!DOCTYPE RCC><RCC version="1.0">
<qresource>
    <file>resources/styles.qss</file>
    <file>resources/images/icon.png</file>
    <file>resources/images/book.png</file>
    <file>resources/images/settings.png</file>
    <file>resources/images/voice.png</file>
    <file>resources/images/down_arrow.png</file>
</qresource>
</RCC>

四、ESP32代码设计

4.1 硬件连线说明

1. SPI总线连线(共用总线)

序号 ESP32-S3引脚 功能 墨水屏(EPD) SD卡模块 说明
1 GPIO 11 MOSI DIN (数据输入) MOSI 主设备数据输出,从设备数据输入
2 GPIO 12 MISO - MISO 主设备数据输入,从设备数据输出(墨水屏不需要)
3 GPIO 10 SCLK CLK (时钟) SCK SPI时钟信号,最高频率25MHz
4 3.3V VCC VCC VCC 电源正极,3.3V供电
5 GND GND GND GND 电源地线

2. 墨水屏(EPD)专用控制引脚

序号 ESP32-S3引脚 墨水屏引脚 功能说明
6 GPIO 9 CS (片选) 片选信号,低电平有效,选中墨水屏
7 GPIO 8 DC (数据/命令) 数据/命令选择:高=数据,低=命令
8 GPIO 7 RST (复位) 复位信号,低电平复位
9 GPIO 6 BUSY (忙碌) 忙碌状态检测,高=忙碌,低=空闲
10 GPIO 1 - 预留GPIO,可用于背光控制(如需)

墨水屏连接要点:

    • 所有SPI引脚与SD卡共用总线,通过CS片选信号区分设备• BUSY引脚用于检测墨水屏刷新状态,必须连接• 复位引脚建议使用GPIO,软件控制复位时序

3. SD卡模块连线

序号 ESP32-S3引脚 SD卡引脚 功能说明
11 GPIO 13 CS (片选) 片选信号,低电平有效,选中SD卡
12 3.3V VCC 3.3V电源供电
13 GND GND 地线
14 GPIO 11 MOSI 数据输入(已共用)
15 GPIO 12 MISO 数据输出(共用)
16 GPIO 10 SCLK 时钟信号(共用)

SD卡连接要点:

    • 支持SPI模式的Micro SD卡模块(常用带电平转换的模块)• 部分SD卡模块需要3.3V供电,注意电压匹配• CS引脚与墨水屏CS引脚不同,用于区分两个SPI设备

4. 按键控制电路

序号 ESP32-S3引脚 按键名称 功能说明
17 GPIO 14 KEY_PREV 上一页/文件列表向上
18 GPIO 15 KEY_NEXT 下一页/文件列表向下
19 GPIO 16 KEY_SELECT 确认选择
20 GPIO 17 KEY_MENU 返回主菜单

按键连接要点:

    • 每个按键一端接GPIO,另一端接GND• 启用内部上拉电阻• 按键按下时GPIO为低电平,释放为高电平• 建议按键并联0.1μF电容进行硬件消抖

按键电路示意图:

3.3V ---[10K上拉电阻]--- GPIO ---[按键]--- GND
                         |
                       电容(0.1μF)
                         |
                        GND

5. SYN6288语音模块连线

序号 ESP32-S3引脚 SYN6288引脚 功能说明
21 GPIO 4 RX (接收) ESP32发送数据给SYN6288
22 GPIO 5 TX (发送) SYN6288发送数据给ESP32(本设计未使用)
23 5V VCC 5V电源供电(SYN6288工作电压5V)
24 GND GND 地线

SYN6288连接要点:

注意:

     SYN6288工作电压为5V,ESP32-S3是3.3V电平

电平转换推荐电路:

ESP32 TX (3.3V) ---[1K电阻]--- SYN6288 RX (5V兼容)
ESP32 RX (3.3V) ---[1K电阻]--- SYN6288 TX (5V) ---[1K电阻]--- GND

6. I2C总线

序号 ESP32-S3引脚 功能 说明
25 GPIO 18 SDA I2C数据线,预留用于扩展传感器
26 GPIO 19 SCL I2C时钟线,预留用于扩展传感器

I2C预留用途:


7. 电源系统

序号 供电端 电压 连接设备
27 USB 5V 5V ESP32-S3开发板、SYN6288
28 3.3V LDO 3.3V 墨水屏、SD卡、按键上拉电阻
29 GND 0V 所有设备公共地线

电源注意事项:

    • ESP32-S3开发板通常通过USB供电(5V)• 板载3.3V LDO可提供最大600mA电流• 墨水屏刷新时电流较大(约50-100mA)• SYN6288峰值电流可达200mA,建议独立5V供电• 总电流估算:ESP32-S3(100mA) + 墨水屏(80mA) + SD卡(20mA) + SYN6288(150mA) ≈ 350mA

8. 可选:USB转串口调试

序号 ESP32-S3引脚 功能 说明
30 GPIO 43 TX0 串口0发送,连接USB转串口
31 GPIO 44 RX0 串口0接收,连接USB转串口

调试说明:

    • ESP32-S3开发板自带USB转串口功能• 可通过USB口直接查看调试信息• 波特率115200,8N1

4.2 项目完整代码设计

// main.c - ESP32-S3 智能电子书阅读器完整代码
// 支持FATFS、SD卡、2.0寸墨水屏、SYN6288语音播报、按键控制

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "esp_system.h"
#include "esp_spi_flash.h"
#include "esp_log.h"
#include "esp_vfs_fat.h"
#include "sdmmc_cmd.h"
#include "driver/spi_common.h"
#include "driver/spi_master.h"
#include "driver/gpio.h"
#include "driver/uart.h"
#include "driver/i2c.h"
#include "esp_timer.h"

// ==================== 引脚定义 ====================
// SPI总线引脚(用于墨水屏和SD卡)
#define PIN_SPI_MOSI       11
#define PIN_SPI_MISO       12
#define PIN_SPI_SCLK       10
#define PIN_SPI_CS_EPD     9     // 墨水屏片选
#define PIN_SPI_CS_SD      13    // SD卡片选
#define PIN_SPI_DC         8     // 墨水屏数据/命令
#define PIN_SPI_RST        7     // 墨水屏复位
#define PIN_SPI_BUSY       6     // 墨水屏忙碌

// 按键引脚
#define PIN_KEY_PREV       14    // 上一页
#define PIN_KEY_NEXT       15    // 下一页
#define PIN_KEY_SELECT     16    // 选择/确认
#define PIN_KEY_MENU       17    // 菜单

// SYN6288语音模块引脚
#define PIN_SYN6288_TX     4     // 连接到SYN6288的RX
#define PIN_SYN6288_RX     5     // 连接到SYN6288的TX

// I2C引脚(可选,用于扩展)
#define PIN_I2C_SDA        18
#define PIN_I2C_SCL        19

// ==================== 常量定义 ====================
#define EPD_WIDTH         200    // 2.0寸墨水屏宽度
#define EPD_HEIGHT        200    // 2.0寸墨水屏高度
#define EPD_BUFFER_SIZE   (EPD_WIDTH * EPD_HEIGHT / 8)

#define SD_FATFS_MOUNT_POINT "/sdcard"
#define MAX_FILE_PATH      256
#define MAX_FILENAME_LEN   64
#define MAX_FILE_COUNT     100
#define TEXT_BUFFER_SIZE   4096

// ==================== 全局变量 ====================
static const char *TAG = "EBOOK_READER";

// SPI设备句柄
static spi_device_handle_t spi_epd;
static spi_device_handle_t spi_sd;

// SD卡句柄
static sdmmc_card_t *sd_card = NULL;

// 文件系统
static char current_file_path[MAX_FILE_PATH] = {0};
static char file_list[MAX_FILE_COUNT][MAX_FILENAME_LEN];
static int file_count = 0;
static int current_file_index = -1;

// 电子书文本缓存
static char text_buffer[TEXT_BUFFER_SIZE];
static int text_length = 0;
static int current_page = 0;
static int total_pages = 0;
static int lines_per_page = 0;

// 按键队列
static QueueHandle_t key_queue = NULL;

// 字体尺寸
#define FONT_WIDTH  12
#define FONT_HEIGHT 16
#define CHARS_PER_LINE (EPD_WIDTH / FONT_WIDTH)
#define LINES_PER_PAGE (EPD_HEIGHT / FONT_HEIGHT)

// ==================== 墨水屏驱动 ====================
// 2.0寸墨水屏基本命令
#define EPD_CMD_DRIVER_OUTPUT_CONTROL     0x01
#define EPD_CMD_GATE_VOLTAGE_CONTROL      0x03
#define EPD_CMD_SOURCE_VOLTAGE_CONTROL    0x04
#define EPD_CMD_DISPLAY_CONTROL           0x07
#define EPD_CMD_NON_OVERLAP_CONTROL       0x0C
#define EPD_CMD_BOOSTER_SOFT_START        0x0E
#define EPD_CMD_GATE_SCAN_START           0x0F
#define EPD_CMD_DEEP_SLEEP                0x10
#define EPD_CMD_DATA_ENTRY_MODE           0x11
#define EPD_CMD_SW_RESET                  0x12
#define EPD_CMD_TEMPERATURE_SENSOR        0x1A
#define EPD_CMD_MASTER_ACTIVATION         0x20
#define EPD_CMD_DISPLAY_UPDATE_CONTROL1   0x21
#define EPD_CMD_DISPLAY_UPDATE_CONTROL2   0x22
#define EPD_CMD_WRITE_RAM                 0x24
#define EPD_CMD_WRITE_RAM_BW              0x24
#define EPD_CMD_WRITE_RAM_RED             0x26
#define EPD_CMD_WRITE_RAM_BWR             0x26
#define EPD_CMD_READ_RAM                  0x27
#define EPD_CMD_VCOM_SENSE                0x28
#define EPD_CMD_VCOM_DURATION             0x29
#define EPD_CMD_PROGRAM_VCOM              0x32
#define EPD_CMD_WRITE_VCOM_REGISTER       0x32
#define EPD_CMD_OPTION_SETTING            0x37
#define EPD_CMD_WRITE_BORDER              0x3C
#define EPD_CMD_UPDATE_DISPLAY            0x30
#define EPD_CMD_END_OPTION                0x3F

// 墨水屏初始化序列
static const uint8_t epd_init_sequence[] = {
    0x12, 0x80, 0x00,  // SW_RESET
    0x01, 0x80, 0x00,  // DRIVER_OUTPUT_CONTROL
    0x3C, 0x80, 0x01,  // BORDER_WAVEFORM
    0x11, 0x80, 0x03,  // DATA_ENTRY_MODE
    0x44, 0x80, 0x00,  // RAM_X_ADDRESS
    0x45, 0x80, 0x00,  // RAM_Y_ADDRESS
    0x4E, 0x80, 0x00,  // RAM_X_COUNTER
    0x4F, 0x80, 0x00,  // RAM_Y_COUNTER
    0x20, 0x80, 0x00,  // MASTER_ACTIVATION
};

static void epd_send_command(uint8_t cmd)
{
    spi_transaction_t trans = {
        .cmd = 0,
        .addr = 0,
        .length = 8,
        .rxlength = 0,
        .tx_buffer = &cmd,
        .flags = 0,
    };
    gpio_set_level(PIN_SPI_DC, 0);  // 命令模式
    spi_device_transmit(spi_epd, &trans);
}

static void epd_send_data(uint8_t data)
{
    spi_transaction_t trans = {
        .cmd = 0,
        .addr = 0,
        .length = 8,
        .rxlength = 0,
        .tx_buffer = &data,
        .flags = 0,
    };
    gpio_set_level(PIN_SPI_DC, 1);  // 数据模式
    spi_device_transmit(spi_epd, &trans);
}

static void epd_send_data_bulk(const uint8_t *data, int len)
{
    spi_transaction_t trans = {
        .cmd = 0,
        .addr = 0,
        .length = len * 8,
        .rxlength = 0,
        .tx_buffer = data,
        .flags = 0,
    };
    gpio_set_level(PIN_SPI_DC, 1);  // 数据模式
    spi_device_transmit(spi_epd, &trans);
}

static void epd_wait_busy(void)
{
    while (gpio_get_level(PIN_SPI_BUSY) == 1) {
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

static void epd_init(void)
{
    ESP_LOGI(TAG, "Initializing EPD display...");
    
    // 复位
    gpio_set_level(PIN_SPI_RST, 0);
    vTaskDelay(pdMS_TO_TICKS(10));
    gpio_set_level(PIN_SPI_RST, 1);
    vTaskDelay(pdMS_TO_TICKS(10));
    
    // 发送初始化序列
    for (int i = 0; i < sizeof(epd_init_sequence); i += 3) {
        epd_send_command(epd_init_sequence[i]);
        epd_send_data(epd_init_sequence[i+1]);
        vTaskDelay(pdMS_TO_TICKS(5));
    }
    
    epd_wait_busy();
    ESP_LOGI(TAG, "EPD initialized");
}

static void epd_clear_screen(void)
{
    ESP_LOGI(TAG, "Clearing EPD screen...");
    
    // 写入白色(全0)
    epd_send_command(EPD_CMD_WRITE_RAM);
    for (int i = 0; i < EPD_WIDTH * EPD_HEIGHT / 8; i++) {
        epd_send_data(0x00);
    }
    
    // 更新显示
    epd_send_command(EPD_CMD_UPDATE_DISPLAY);
    epd_wait_busy();
}

static void epd_display_buffer(const uint8_t *buffer)
{
    epd_send_command(EPD_CMD_WRITE_RAM);
    epd_send_data_bulk(buffer, EPD_WIDTH * EPD_HEIGHT / 8);
    
    epd_send_command(EPD_CMD_UPDATE_DISPLAY);
    epd_wait_busy();
}

// ==================== 简单字符显示(ASCII) ====================
// 8x12点阵字体
static const uint8_t ascii_font[][12] = {
    {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // 空格
    {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, // !
    // ... 此处省略完整字体数据,实际项目中需要包含完整的ASCII字库
};

static void draw_char(uint8_t *buffer, int x, int y, char c)
{
    if (c < 32 || c > 126) return;
    c -= 32;
    
    for (int row = 0; row < 12; row++) {
        for (int col = 0; col < 8; col++) {
            if (c < sizeof(ascii_font)/12 && (ascii_font[c][row] & (1 << (7 - col)))) {
                int px = x + col;
                int py = y + row;
                if (px < EPD_WIDTH && py < EPD_HEIGHT) {
                    int byte_idx = (py * EPD_WIDTH + px) / 8;
                    int bit_idx = 7 - (px % 8);
                    buffer[byte_idx] |= (1 << bit_idx);
                }
            }
        }
    }
}

static void draw_string(uint8_t *buffer, int x, int y, const char *str, int max_len)
{
    int cx = x;
    int cy = y;
    
    for (int i = 0; str[i] && i < max_len; i++) {
        if (str[i] == 'n') {
            cx = x;
            cy += 16;
            continue;
        }
        draw_char(buffer, cx, cy, str[i]);
        cx += 8;
        if (cx + 8 > EPD_WIDTH) {
            cx = x;
            cy += 16;
        }
    }
}

// ==================== SD卡驱动 ====================
static esp_err_t sd_init(void)
{
    ESP_LOGI(TAG, "Initializing SD card...");
    
    esp_err_t ret;
    
    // 配置SD卡SPI
    spi_bus_config_t bus_cfg = {
        .mosi_io_num = PIN_SPI_MOSI,
        .miso_io_num = PIN_SPI_MISO,
        .sclk_io_num = PIN_SPI_SCLK,
        .quadwp_io_num = -1,
        .quadhd_io_num = -1,
        .max_transfer_sz = 4000,
    };
    
    ret = spi_bus_initialize(SPI2_HOST, &bus_cfg, SPI_DMA_CH_AUTO);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "SPI bus init failed: %d", ret);
        return ret;
    }
    
    // 配置SD卡SPI设备
    spi_device_interface_config_t dev_cfg = {
        .clock_speed_hz = 25000000,  // 25MHz
        .mode = 0,
        .spics_io_num = PIN_SPI_CS_SD,
        .queue_size = 7,
    };
    
    ret = spi_bus_add_device(SPI2_HOST, &dev_cfg, &spi_sd);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "SD card SPI device add failed: %d", ret);
        return ret;
    }
    
    // 挂载SD卡
    sdmmc_host_t host = SDMMC_HOST_DEFAULT();
    host.slot = SPI2_HOST;
    host.max_freq_khz = 25000;
    
    sdmmc_slot_config_t slot_config = SDMMC_SLOT_CONFIG_DEFAULT();
    slot_config.gpio_miso = PIN_SPI_MISO;
    slot_config.gpio_mosi = PIN_SPI_MOSI;
    slot_config.gpio_sck = PIN_SPI_SCLK;
    slot_config.gpio_cs = PIN_SPI_CS_SD;
    
    esp_vfs_fat_sdmmc_mount_config_t mount_config = {
        .format_if_mount_failed = false,
        .max_files = 5,
        .allocation_unit_size = 16 * 1024
    };
    
    ret = esp_vfs_fat_sdmmc_mount(SD_FATFS_MOUNT_POINT, &host, &slot_config, &mount_config, &sd_card);
    if (ret != ESP_OK) {
        if (ret == ESP_ERR_NO_MEM) {
            ESP_LOGE(TAG, "Not enough memory for SD card mount");
        } else if (ret == ESP_ERR_INVALID_VERSION) {
            ESP_LOGE(TAG, "SD card version invalid");
        } else if (ret == ESP_ERR_NOT_FOUND) {
            ESP_LOGE(TAG, "SD card not found");
        } else {
            ESP_LOGE(TAG, "SD card mount failed: %s", esp_err_to_name(ret));
        }
        return ret;
    }
    
    ESP_LOGI(TAG, "SD card mounted successfully");
    return ESP_OK;
}

static void sd_deinit(void)
{
    if (sd_card) {
        esp_vfs_fat_sdcard_unmount(SD_FATFS_MOUNT_POINT, sd_card);
        sd_card = NULL;
    }
}

// ==================== 文件浏览功能 ====================
static int scan_files(const char *path)
{
    ESP_LOGI(TAG, "Scanning files in %s", path);
    
    DIR *dir = opendir(path);
    if (dir == NULL) {
        ESP_LOGE(TAG, "Failed to open directory: %s", path);
        return 0;
    }
    
    file_count = 0;
    struct dirent *entry;
    
    while ((entry = readdir(dir)) != NULL && file_count < MAX_FILE_COUNT) {
        // 只显示.txt文件
        char *ext = strrchr(entry->d_name, '.');
        if (ext && (strcasecmp(ext, ".txt") == 0)) {
            strncpy(file_list[file_count], entry->d_name, MAX_FILENAME_LEN - 1);
            file_list[file_count][MAX_FILENAME_LEN - 1] = '';
            file_count++;
            ESP_LOGI(TAG, "Found file: %s", entry->d_name);
        }
    }
    
    closedir(dir);
    return file_count;
}

static void display_file_list(void)
{
    if (file_count == 0) {
        ESP_LOGI(TAG, "No TXT files found");
        uint8_t buffer[EPD_WIDTH * EPD_HEIGHT / 8] = {0};
        draw_string(buffer, 10, 50, "No TXT files found!", 20);
        draw_string(buffer, 10, 70, "Please copy .txt files", 20);
        draw_string(buffer, 10, 90, "to SD card", 20);
        epd_display_buffer(buffer);
        return;
    }
    
    // 显示文件列表
    uint8_t buffer[EPD_WIDTH * EPD_HEIGHT / 8] = {0};
    char display_line[64];
    
    draw_string(buffer, 5, 5, "Select a book:", 14);
    
    int max_display = (file_count < 10) ? file_count : 10;
    for (int i = 0; i < max_display; i++) {
        snprintf(display_line, sizeof(display_line), "%d. %s", i + 1, file_list[i]);
        draw_string(buffer, 5, 25 + i * 16, display_line, 50);
    }
    
    epd_display_buffer(buffer);
}

// ==================== 电子书阅读功能 ====================
static int load_text_file(const char *filepath)
{
    ESP_LOGI(TAG, "Loading text file: %s", filepath);
    
    FILE *file = fopen(filepath, "r");
    if (file == NULL) {
        ESP_LOGE(TAG, "Failed to open file: %s", filepath);
        return -1;
    }
    
    // 读取文件内容
    text_length = fread(text_buffer, 1, TEXT_BUFFER_SIZE - 1, file);
    text_buffer[text_length] = '';
    fclose(file);
    
    // 计算总页数
    int char_count = 0;
    int line_count = 0;
    for (int i = 0; i < text_length; i++) {
        if (text_buffer[i] == 'n') {
            line_count++;
            char_count = 0;
        } else {
            char_count++;
            if (char_count >= CHARS_PER_LINE) {
                line_count++;
                char_count = 0;
            }
        }
    }
    if (char_count > 0) line_count++;
    
    total_pages = (line_count + LINES_PER_PAGE - 1) / LINES_PER_PAGE;
    if (total_pages == 0) total_pages = 1;
    
    current_page = 0;
    lines_per_page = LINES_PER_PAGE;
    
    ESP_LOGI(TAG, "Text loaded: %d bytes, %d pages", text_length, total_pages);
    return total_pages;
}

static void display_page(int page_num)
{
    if (page_num < 0 || page_num >= total_pages) return;
    
    uint8_t buffer[EPD_WIDTH * EPD_HEIGHT / 8] = {0};
    
    // 解析并显示当前页
    int line_count = 0;
    int char_count = 0;
    int current_line = 0;
    int start_line = page_num * LINES_PER_PAGE;
    int end_line = (page_num + 1) * LINES_PER_PAGE;
    
    char line_buffer[CHARS_PER_LINE + 1];
    int line_pos = 0;
    
    for (int i = 0; i < text_length && current_line < end_line; i++) {
        if (text_buffer[i] == 'n') {
            if (current_line >= start_line) {
                line_buffer[line_pos] = '';
                draw_string(buffer, 2, (current_line - start_line) * FONT_HEIGHT + 5, 
                           line_buffer, CHARS_PER_LINE);
            }
            current_line++;
            line_pos = 0;
        } else {
            if (line_pos < CHARS_PER_LINE) {
                line_buffer[line_pos++] = text_buffer[i];
            } else {
                if (current_line >= start_line) {
                    line_buffer[line_pos] = '';
                    draw_string(buffer, 2, (current_line - start_line) * FONT_HEIGHT + 5, 
                               line_buffer, CHARS_PER_LINE);
                }
                current_line++;
                line_pos = 0;
                i--; // 重新处理当前字符
            }
        }
    }
    
    // 处理最后一行
    if (line_pos > 0 && current_line >= start_line && current_line < end_line) {
        line_buffer[line_pos] = '';
        draw_string(buffer, 2, (current_line - start_line) * FONT_HEIGHT + 5, 
                   line_buffer, CHARS_PER_LINE);
    }
    
    // 显示页码
    char page_info[32];
    snprintf(page_info, sizeof(page_info), "Page %d/%d", page_num + 1, total_pages);
    draw_string(buffer, EPD_WIDTH - 100, EPD_HEIGHT - 20, page_info, 20);
    
    epd_display_buffer(buffer);
}

// ==================== SYN6288语音播报 ====================
static void syn6288_init(void)
{
    ESP_LOGI(TAG, "Initializing SYN6288...");
    
    // 配置UART
    uart_config_t uart_config = {
        .baud_rate = 9600,
        .data_bits = UART_DATA_8_BITS,
        .parity = UART_PARITY_DISABLE,
        .stop_bits = UART_STOP_BITS_1,
        .flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
    };
    
    uart_param_config(UART_NUM_1, &uart_config);
    uart_set_pin(UART_NUM_1, PIN_SYN6288_TX, PIN_SYN6288_RX, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
    uart_driver_install(UART_NUM_1, 1024, 0, 0, NULL, 0);
    
    ESP_LOGI(TAG, "SYN6288 initialized");
}

static void syn6288_speak(const char *text)
{
    if (text == NULL || strlen(text) == 0) return;
    
    ESP_LOGI(TAG, "Speaking: %s", text);
    
    // SYN6288命令格式:帧头 + 数据长度 + 命令 + 文本
    uint8_t frame[256];
    int len = strlen(text);
    
    // 帧头
    frame[0] = 0xFD;
    // 数据长度(高字节)
    frame[1] = ((len + 3) >> 8) & 0xFF;
    // 数据长度(低字节)
    frame[2] = (len + 3) & 0xFF;
    // 命令:0x01 = 播放
    frame[3] = 0x01;
    // 文本内容
    memcpy(frame + 4, text, len);
    // 结束符(可选)
    frame[4 + len] = 0x00;
    
    // 发送数据
    uart_write_bytes(UART_NUM_1, frame, len + 5);
    
    // 等待播放完成(简单延时)
    vTaskDelay(pdMS_TO_TICKS(500 + len * 10));
}

static void syn6288_speak_page(int page_num)
{
    if (page_num < 0 || page_num >= total_pages) return;
    
    // 提取当前页的前100个字符作为语音播报内容
    char speak_text[128] = {0};
    int start_pos = page_num * LINES_PER_PAGE * CHARS_PER_LINE;
    int end_pos = start_pos + 100;
    if (end_pos > text_length) end_pos = text_length;
    
    int pos = 0;
    for (int i = start_pos; i < end_pos && pos < 127; i++) {
        if (text_buffer[i] == 'n') {
            speak_text[pos++] = ' ';
        } else {
            speak_text[pos++] = text_buffer[i];
        }
    }
    speak_text[pos] = '';
    
    // 添加页码信息
    char full_text[160];
    snprintf(full_text, sizeof(full_text), "第%d页。%s", page_num + 1, speak_text);
    
    syn6288_speak(full_text);
}

// ==================== 按键处理 ====================
typedef enum {
    KEY_NONE = 0,
    KEY_NEXT,
    KEY_PREV,
    KEY_SELECT,
    KEY_MENU,
} key_event_t;

static void key_isr_handler(void *arg)
{
    key_event_t event = (key_event_t)(intptr_t)arg;
    xQueueSendFromISR(key_queue, &event, NULL);
}

static void keys_init(void)
{
    key_queue = xQueueCreate(10, sizeof(key_event_t));
    if (key_queue == NULL) {
        ESP_LOGE(TAG, "Failed to create key queue");
        return;
    }
    
    // 配置按键为输入,带内部上拉
    gpio_config_t io_conf = {
        .pin_bit_mask = (1ULL << PIN_KEY_PREV) | 
                       (1ULL << PIN_KEY_NEXT) | 
                       (1ULL << PIN_KEY_SELECT) | 
                       (1ULL << PIN_KEY_MENU),
        .mode = GPIO_MODE_INPUT,
        .pull_up_en = GPIO_PULLUP_ENABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_NEGEDGE,
    };
    gpio_config(&io_conf);
    
    // 注册中断
    gpio_install_isr_service(0);
    gpio_isr_handler_add(PIN_KEY_NEXT, key_isr_handler, (void*)KEY_NEXT);
    gpio_isr_handler_add(PIN_KEY_PREV, key_isr_handler, (void*)KEY_PREV);
    gpio_isr_handler_add(PIN_KEY_SELECT, key_isr_handler, (void*)KEY_SELECT);
    gpio_isr_handler_add(PIN_KEY_MENU, key_isr_handler, (void*)KEY_MENU);
    
    ESP_LOGI(TAG, "Keys initialized");
}

// ==================== 主任务 ====================
static void ebook_task(void *arg)
{
    ESP_LOGI(TAG, "Ebook task started");
    
    // 初始化显示
    epd_init();
    epd_clear_screen();
    
    // 初始化SD卡
    if (sd_init() != ESP_OK) {
        uint8_t buffer[EPD_WIDTH * EPD_HEIGHT / 8] = {0};
        draw_string(buffer, 10, 50, "SD Card Error!", 14);
        draw_string(buffer, 10, 70, "Please insert SD card", 20);
        draw_string(buffer, 10, 90, "and restart", 20);
        epd_display_buffer(buffer);
        while (1) {
            vTaskDelay(pdMS_TO_TICKS(1000));
        }
    }
    
    // 扫描文件
    scan_files(SD_FATFS_MOUNT_POINT);
    display_file_list();
    
    // 初始化语音
    syn6288_init();
    
    // 主循环
    bool in_menu = true;
    int selected_index = 0;
    bool file_loaded = false;
    
    while (1) {
        key_event_t key;
        if (xQueueReceive(key_queue, &key, portMAX_DELAY)) {
            switch (key) {
                case KEY_MENU:
                    ESP_LOGI(TAG, "Menu key pressed");
                    in_menu = true;
                    file_loaded = false;
                    scan_files(SD_FATFS_MOUNT_POINT);
                    display_file_list();
                    break;
                    
                case KEY_SELECT:
                    ESP_LOGI(TAG, "Select key pressed");
                    if (in_menu && file_count > 0) {
                        // 选择当前高亮的文件
                        snprintf(current_file_path, MAX_FILE_PATH, "%s/%s", 
                                SD_FATFS_MOUNT_POINT, file_list[selected_index]);
                        if (load_text_file(current_file_path) > 0) {
                            file_loaded = true;
                            in_menu = false;
                            display_page(0);
                            // 语音播报当前页
                            syn6288_speak_page(0);
                        }
                    }
                    break;
                    
                case KEY_NEXT:
                    ESP_LOGI(TAG, "Next key pressed");
                    if (in_menu && file_count > 0) {
                        selected_index = (selected_index + 1) % file_count;
                        display_file_list();
                        // 高亮显示当前选中的文件(简化实现)
                    } else if (file_loaded && current_page < total_pages - 1) {
                        current_page++;
                        display_page(current_page);
                        // 语音播报当前页
                        syn6288_speak_page(current_page);
                    }
                    break;
                    
                case KEY_PREV:
                    ESP_LOGI(TAG, "Prev key pressed");
                    if (in_menu && file_count > 0) {
                        selected_index = (selected_index - 1 + file_count) % file_count;
                        display_file_list();
                    } else if (file_loaded && current_page > 0) {
                        current_page--;
                        display_page(current_page);
                        syn6288_speak_page(current_page);
                    }
                    break;
                    
                default:
                    break;
            }
        }
    }
}

// ==================== 主函数 ====================
void app_main(void)
{
    // 初始化SPI总线(用于墨水屏)
    spi_bus_config_t bus_cfg_epd = {
        .mosi_io_num = PIN_SPI_MOSI,
        .miso_io_num = -1,  // 墨水屏不需要MISO
        .sclk_io_num = PIN_SPI_SCLK,
        .quadwp_io_num = -1,
        .quadhd_io_num = -1,
        .max_transfer_sz = EPD_BUFFER_SIZE + 100,
    };
    
    esp_err_t ret = spi_bus_initialize(SPI3_HOST, &bus_cfg_epd, SPI_DMA_CH_AUTO);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "EPD SPI bus init failed: %d", ret);
        return;
    }
    
    // 添加墨水屏SPI设备
    spi_device_interface_config_t dev_cfg_epd = {
        .clock_speed_hz = 2000000,  // 2MHz
        .mode = 0,
        .spics_io_num = PIN_SPI_CS_EPD,
        .queue_size = 7,
    };
    
    ret = spi_bus_add_device(SPI3_HOST, &dev_cfg_epd, &spi_epd);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "EPD SPI device add failed: %d", ret);
        return;
    }
    
    // 初始化GPIO
    gpio_set_direction(PIN_SPI_DC, GPIO_MODE_OUTPUT);
    gpio_set_direction(PIN_SPI_RST, GPIO_MODE_OUTPUT);
    gpio_set_direction(PIN_SPI_BUSY, GPIO_MODE_INPUT);
    
    gpio_set_level(PIN_SPI_DC, 0);
    gpio_set_level(PIN_SPI_RST, 0);
    
    // 初始化按键
    keys_init();
    
    // 创建主任务
    xTaskCreate(ebook_task, "ebook_task", 8192, NULL, 5, NULL);
    
    ESP_LOGI(TAG, "System initialized, starting ebook reader...");
}

FATFS文件系统:通过ESP-IDF的esp_vfs_fat组件实现,支持SD卡上的FATFS文件系统。

SD卡读写:使用SPI模式驱动SD卡,支持读取SD卡中的TXT文件。

2.0寸墨水屏显示:实现了基本的墨水屏驱动,支持清屏、显示缓冲区内容,使用200x200分辨率(2.0寸标准)。

SYN6288语音播报:通过UART与SYN6288通信,支持文本转语音播放,翻页时自动播报当前页内容。

按键控制:• NEXT:下一页/文件列表向下• PREV:上一页/文件列表向上• SELECT:选择文件• MENU:返回文件列表

6. ESP32-S3:代码基于ESP32-S3设计,使用SPI3_HOST和SPI2_HOST分别驱动墨水屏和SD卡。

4.3 程序下载

写完代码之后,点击构建烧录,代码就下载进去了。

下载成功之后,可以看到灯就已经亮了。 绿色的小灯就是点亮的LED灯。

4.4 程序正常运行效果

设备运行过程中会通过串口打印调试信息,我们可以通过串口打印了解程序是否正常。

程序下载之后,可以打开串口调试助手查看程序运行的状态信息。[软件就在资料包里的软件工具目录下]

4.5 取模软件的使用

显示屏上会显示中文,字母,数字等数据,可以使用下面的取模软件进行取模设置。

[软件就在资料包里的软件工具目录下]

打开软件之后:

五、搭建ESP32开发环境

5.1 安装VSCode代码编辑器

【1】下载软件包

下载地址: https://code.visualstudio.com/

安装包下载之后,直接鼠标双击运行。

【2】开始安装

接受协议继续安装。

选择一下安装路径。

浏览路径。

安装。

【4】配置中文语言

安装完成。打开的页面。

软件安装之后下面设置 Visual Studio 支持中文语言

首先打开 Visual Studio 软件, 再按下F1 或者 Shift + Ctrl + P

然后在命令行输入 Configure Display Language

选择安装语言选项。

安装之后右下角有提示重启,点击重启即可。

【5】配置主题颜色

修改vscode的颜色主题

下面介绍更改颜色vscode的内置颜色主题方法。

5.2 安装ESP-IDE环境

官方教程:https://docs.espressif.com/projects/esp-idf/zh_CN/v5.1.5/esp32/get-started/index.html

【1】安装IDE环境

链接:https://dl.espressif.cn/dl/esp-idf/

下载成功后,双击开始安装。 安装的路径可以选择D盘,毕竟占用的空间还是比较大的。 比如,在D盘创建一个文件夹。ESP_IDF 后面安装时,路径就选择这文件夹。

注意:安装的时候要关闭VSCODE软件。

【2】VSCODE安装插件

官方教程:https://docs.espressif.com/projects/vscode-esp-idf-extension/zh_CN/latest/index.html

上一步安装完毕之后。 再打开VSCODE软件。

打开扩展商店,搜索espressif,然后看到第一个ESP-IDF ,点击右边出现的安装。

安装成功之后,打开命令行面板。

然后 输入 configure esp-idf extension

选择下面的弹出来的选项。

选择用户安装的路径。

选择识别出来的安装路径。

接下来就可以正常愉快的开发代码了。

5.3 快速新建工程

【1】快速新建项目

每次打开VSCODE想要快速新建ESP32的项目。

直接在命令行窗口。输入ESP-IDF:新建项目

选择新建项目。

选择新建工程。

配置工程的名称,存储工程的路径(不能出现中文),芯片的型号,调试方式。串口端口(如果当前没有接开发板大电脑里就不用管)。 选好了,点击右下角的选项创建工程。

创建成功之后,下面就可以选择创建工程的例程模版。

在第一个选项点击后会弹出两个选项可以选择,是使用扩展例程(ExtenSion)还是使用官方IDF的例程(ESP-IDF)。

当前选择 【ExtenSion】下的 【template-app】进行工程创建。(还有很多的例程,大家可以自行尝试)

点击右边的按钮,创建模版工程。

创建完成之后,右下角会提示【工程已经创建完毕,是否用新窗口打开工程?】点击Yes则会出现打开一个VSCode窗口,点击NO则会在该界面显示工程。

打开工程可以写一段打印函数。测试工程运行情况。

然后把ESP32通过串口插到电脑USB口上。

在下面可以选择下载方式,选择串口,然后选择串口的端口。

点击编译下载。程序运行之后会打印信息到终端。

第一次编译比较慢,需要多等一下。 后面的编译就比较快了。

成功之后,可以看到串口上的数据打印。

到此。工程就搭建好了。

【2】修改配置

在连接成功的情况下,点击设置按钮。

修改FLASH大小
在上方的搜索栏中输入【flash】,根据开发板板载的FLASH大小选择对应的FLASH大小。开发板板载W25Q64是8M大小。

修改系统时钟
在上方的搜索栏中输入【cpu】,根据情况选择CPU频率,这里选择的是最高频率240MHz。

修改完成之后点击【保存】键才会生效。

5.4 编程控制LED灯

【1】点亮LED灯

下面学习如何控制IO口,完成LED灯的控制。

为了模块化编程。下面需要新建一个目录,下面存储我们后续编写的外设硬件的代码。

新建一个hardware 文件夹。

然后在hardware 文件夹下面新建.c文件和.h文件。

新建2个文件。led.c和led.h。方便存储LED灯相关的源代码和函数声明。

在led.c的文件里写上代码:

#include "led.h"

//配置输出寄存器
#define GPIO_OUTPUT_PIN_SEL  (1ULL<<LED_PIN)

/**
 * @函数说明        LED的初始化
 *
 */
void LedGpioConfig(void)
{
    gpio_config_t gpio_init_struct = {0};

    //配置IO为通用IO
    esp_rom_gpio_pad_select_gpio(LED_PIN);

    gpio_init_struct.intr_type = GPIO_INTR_DISABLE;             //不使用中断
    gpio_init_struct.mode = GPIO_MODE_OUTPUT;                   //输出模式
    gpio_init_struct.pull_up_en = GPIO_PULLUP_ENABLE;           //使能上拉模式
    gpio_init_struct.pull_down_en = GPIO_PULLDOWN_DISABLE;      //失能下拉模式
    gpio_init_struct.pin_bit_mask = GPIO_OUTPUT_PIN_SEL;        //使用GPIO9输出寄存器

    //将以上参数配置到引脚
    gpio_config( &gpio_init_struct );

    //设置引脚输出高电平,默认不让LED亮
    gpio_set_level(LED_PIN, 1);
}

/**
 * @函数说明        设置LED亮
 *
 */
void LedOn(void)
{
    gpio_set_level(LED_PIN, 0);
}

/**
 * @函数说明        设置LED灭
 *
 */
void LedOff(void)
{
    gpio_set_level(LED_PIN, 1);
}

在led.h文件里写上代码:

#ifndef _BSP_LED_H_
#define _BSP_LED_H_

#include "driver/gpio.h"

//设置LED引脚
#define  LED_PIN  48

/**
 * @brief   LED初始化
 *
 */
void LedGpioConfig(void);

/**
 * @brief       设置LED亮
 *
 */
void LedOn(void);

/**
 * @brief       设置LED灭
 *
 */
void LedOff(void);
#endif

然后修改CMakeLists.txt 文件。 这个文件是配置头文件和源文件的路径的。  需要配置了之后,编译器才知道.c和.h文件在什么地方。

idf_component_register(SRCS "main.c"
                            "hardware/led.c"
                    INCLUDE_DIRS "."
                                 "./hardware")

最后编写main.c代码,在main.c文件里写上点灯的调用。

#include <stdio.h>
#include "hardware/led.h"

void app_main(void)
{
    //LED初始化
    LedGpioConfig();

    //设置LED点亮
    LedOn();

}

写完代码之后,点击构建烧录,代码就下载进去了。

下载成功之后,可以看到灯就已经亮了。 绿色的小灯就是点亮的LED灯。

【2】升级成闪光灯

要做成闪光灯就需要用到延时函数

在ESP-IDF 中,可以使用下面3种方式来实现延时:

    1. 1. vTaskDelay() 函数
    1. 这个函数会让当前的任务挂起指定的时间。例如,vTaskDelay(1000 / portTICK_PERIOD_MS) 会让当前任务挂起 1 秒钟,等待一个系统时钟周期。2. usleep() 函数 这个函数可以以微秒为单位精准延时。例如, usleep(1000000) 会让任务休眠 1 秒钟。3. esp_rom_delay_us()函数
  1. 可以进行微秒级延时。

这里面的void vTaskDelay( const TickType_t xTicksToDelay ) 函数,是将一个任务延迟给定的xTicksToDelay数。任务被阻止的实际时间取决于tick rate。常数portTICK_PERIOD_MS可以用来从tick rate计算实时时间,分辨率为一个tick 周期。至于一个时钟节拍数是1ms,2ms,还是10ms,取决于configTICK_RATE_Hz,即 cONFI6_FREERTOS_HZ。CONFIG_FREERTOS_HZ在sdkconfig中定义,默认是100Hz。则一个时钟节拍数是10ms。

我们可以看一下系统默认的配置。

这个配置在 FreeRTOSConfig.h 文件中:

#define configTICK_RATE_HZ        100

简单来说:

configTICK_RATE_HZ 定义了 FreeRTOS 的系统时钟节拍频率,表示:

    系统每秒钟产生 100 个时钟节拍(tick)

    每个时钟节拍的间隔 = 10ms

     (1000ms ÷ 100 = 10ms)

在main.c里编写LED闪烁的代码。

#include <stdio.h>
#include "hardware/led.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

/**
 * @函数说明        毫秒延时函数
 * @参数 ms        延时的毫秒数
 * @注意           最小延时为1个FreeRTOS tick
 */
void delay_ms(uint32_t ms)
{
    if (ms == 0) return;
    
    TickType_t ticks = (ms + portTICK_PERIOD_MS - 1) / portTICK_PERIOD_MS;
    vTaskDelay(ticks > 0 ? ticks : 1);
}

void app_main(void)
{
    int state=0;
    //LED初始化
    LedGpioConfig();

     while(1) {
        state=!state;
        delay_ms(1000);  // 延时1秒
        if(state)
        {
            LedOn();
        }
        else
        {
            LedOff();
        }
    }
}

然后编译烧录进去。

就可以看到LED灯在闪烁了。

六、总结

本项目完成了一套基于ESP32-S3设计的智能电子书阅读器系统,实现了电子书存储、内容显示、语音播报以及资源更新等功能的集成设计。系统以ESP32-S3作为核心控制单元,结合FATFS文件系统与SD卡存储,实现TXT格式电子书文件的管理与读取;通过2.0寸电子墨水屏完成文本内容显示,在保证阅读体验的同时降低系统功耗;利用SYN6288语音合成模块实现文本语音播报功能,丰富了电子阅读方式;同时结合按键交互完成书籍选择与页面切换,提高设备操作便捷性。

在系统设计过程中,充分考虑了嵌入式设备资源受限、显示效果、文件管理效率以及用户交互体验等因素,对硬件结构、软件架构以及模块通信方式进行了整体规划。通过Qt5(C++)开发的上位机APP,实现电子书资源上传与更新,使设备具备较好的扩展能力和维护便利性,提升了系统实际使用体验。

经过整体方案设计与功能实现,本系统能够完成电子书文件读取、页面显示、语音阅读以及内容更新等预期目标,验证了ESP32-S3在智能阅读终端领域中的应用可行性。项目将嵌入式控制技术、文件系统管理、显示技术以及人机交互设计进行了融合,对嵌入式应用开发、数字阅读设备设计及相关教学实践具有一定参考价值。后续可在现有基础上进一步优化显示刷新效率、提升交互体验以及扩展更多阅读资源管理能力,以增强系统综合性能与应用范围。

相关推荐