项目文档
本项目使用 ESP8266 模组实现的简易单词卡项目,支持web端单词管理功能,可用于英文单词学习等。项目器件较少,贴片均为0805大封装,成本偏低,结构简单,适用于教学
固件功能
✅ 支持常见中文显示,内置中文字库,无需字库芯片。
✅ 支持FAT16格式的最大2GB存储卡扩展
✅ 多个按键仅需一个IO即可识别,提高IO利用率
✅ 可进入WEB控制后台添加、删除、修改单词及数据
项目参数
✅ ESP8266系列主控,自带WIFI通讯功能
✅ 支持TF卡扩展存储,FAT16格式,最大2GB
✅ 支持SSD1306/SSD1315驱动的OLED 0.96寸屏幕
硬件设计
TF卡这里移除CS引脚的上拉电阻,因为CS引脚为GPIO15引脚,是ESP8266模式切换的重要IO,已经在启动电路部分接下拉电阻。
电压转换部分采用 ME6217系列LDO ,低压差,800mA,SOT23封装的小巧LDO供电,符合ESP8266建议的500mA供电要求。
显示部分采用 SSD1306/SSD1315 OLED 屏幕,分辨率128×64,尺寸0.96。由于本项目采用的是OLED模块,模块通常板载上拉电阻,因此原理图中未添加上拉电阻。
供电接口采用立插设计的TypeC接口,CC脚接5.1k下拉识别电阻,支持双TypeC口的线材供电。
这里采用ADC的方式来识别按键,灵感来自之前做EDA-Piano电子琴的SC12B触摸传感器,通过BCD端口接电阻产生的模拟电压确认按键。这里为每个按键配置不同的上拉电阻,这样当ADC检测到不同电压时来识别是哪个按键按下。ESP8266的ADC电压范围是0-1V,理论上来说只要你买的电阻误差足够低,就可以通过一个ADC去识别很多个按键。不过通过ADC检测时要注意干扰,最好不要裸板操作,而是配合塑料外壳使用。
软件开发
完整源码请在附件查看
- 软件环境:VSCode + PlatformIO
- 开发语言:C / C++
- 框架:Arduino Framework
本项目采用 BSP(Board Support Package)板级适配架构.
- display.cpp/.h: OLED屏幕显示部分实现
- input.cpp/.h: 按键控制部分实现
- learning.cpp/.h: 学习部分实现
- storage.cpp/.h: TF存储部分实现
- webserver.cpp/.h: WEB控制端页面及路由实现
- main.cpp/.h: 主程序
- config.h: 板级配置及用户配置文件
config.h
在这里可以自定义WIFI配置,ADC按键配置及单词数量信息。建议购买高精度电阻,如果电阻精度不高会存在误差,这时就需要在这里修改ADC阈值到实际测量的电压。另外最大单词数量也需要在这里定义,存储的单词越多,单片机处理速度可能会较慢。
#ifndef CONFIG_H
#define CONFIG_H
// WiFi配置 - 使用inline避免重复定义
inline const char* ap_ssid = "EDA-WordCard";
inline const char* ap_password = "12345678";
// OLED 配置
#define OLED_W 128
#define OLED_H 64
#define OLED_ADDR 0x3C
// SD卡SPI引脚配置
#define PIN_CS 15 // D8
// 按键ADC阈值
#define BTN_LEFT_MIN 200 // 不会 (约0.25V)
#define BTN_LEFT_MAX 300
#define BTN_MIDDLE_MIN 500 // 模糊 (约0.57V)
#define BTN_MIDDLE_MAX 650
#define BTN_RIGHT_MIN 750 // 会 (约0.83V)
#define BTN_RIGHT_MAX 1000
// 学习状态
enum StudyResult {
UNKNOWN = 0,
DONT_KNOW = 1,
VAGUE = 2,
KNOW = 3
};
// 单词结构
struct Word {
String name;
String chinese;
int study;
String example;
int frequency; // 出现频率 = 100 - study
};
// 全局变量声明 - 需要在一个.cpp文件中定义
extern Word currentWord;
extern bool showAnswer;
extern int totalWords;
extern int studiedWords;
extern int currentWordIndex;
extern String wordFiles[500]; // 最多支持500个单词文件
#endif
display.h
在display部分定义了显示功能的函数,屏幕初始化,显示居中,展示信息的UI格式都在这里定义,这里的中文显示采用的u8g2_font_wqy12_t_gb2312字体,无需外挂字库芯片。
#ifndef DISPLAY_H
#define DISPLAY_H
#include
#include
#include "config.h"
// OLED显示对象声明
extern U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2;
// 显示功能函数
void initDisplay();
void drawCenteredText(const char* text, int y, const uint8_t* font);
void showMessage(String line1, String line2 = "", String line3 = "", String line4 = "");
void displayCurrentWord();
void showStats();
void processStudyResult(StudyResult result);
#endif
input.h
在这里定义的是功能按键的功能函数,处理按键按下的结果以及ADC电压对应的按键识别部分。。
class Oscillator {
private:
int pos_;
int amplitude_;
int offset_;
double phase_;
double phase0_;
int trim_;
public:
void SetA(unsigned int amplitude);
void SetO(int offset);
void SetPh(double Ph);
void SetTrim(int trim);
void Refresh();
int GetPosition();
};
learning.h
这里主要定义的是单词的学习逻辑功能部分。为了确保单片机的处理效率,这里单词选取还是依照的随机数生成。因为如果单词数量多,要读取熟练度的话就意味着单片机要从所有单词数据提取熟练度数据判断,所有这里性能考虑还是随机数生成。
#ifndef LEARNING_H
#define LEARNING_H
#include
#include "config.h"
// 学习功能函数
int selectWordByFrequency();
void nextWord();
#endif
storage.h
TF存储功能则是对TF卡单词数据的增删改查的实现。
#ifndef STORAGE_H
#define STORAGE_H
#include
#include
#include
#include "config.h"
// 存储功能函数
bool initStorage();
void scanWordFiles();
void calculateStudiedWords();
void loadConfig();
void saveConfig();
void loadWord(String fileName);
void saveWordProgress();
#endif
webserver.h
借助ESP8266的WIFI功能,像往期项目一样,这里添加了一个单词管理后台,你可以通过连接“EDA-WordCard”的WIFI热点,密码是“12345678”。进入后台可以对单词数据进行增删改查,不再需要读卡器插入电脑修改数据。
#ifndef STORAGE_H
#define STORAGE_H
#include
#include
#include
#include "config.h"
// 存储功能函数
bool initStorage();
void scanWordFiles();
void calculateStudiedWords();
void loadConfig();
void saveConfig();
void loadWord(String fileName);
void saveWordProgress();
#endif
实现原理
display.cpp
显示部分采用U8G2显示库,利用该库集成的中文国标字体u8g2_font_wqy12_t_gb2312即可显示中文,无需外挂或自定义字库。
#include "display.h"
#include
// OLED显示对象定义
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE);
// 初始化显示
void initDisplay() {
Wire.begin(D2, D1);
u8g2.begin();
}
// 居中显示文本
void drawCenteredText(const char* text, int y, const uint8_t* font) {
u8g2.setFont(font);
int textWidth = u8g2.getUTF8Width(text);
int x = (128 - textWidth) / 2; // 屏幕宽度128,居中计算
u8g2.drawUTF8(x, y, text);
}
// 显示消息函数 - 优化UI
void showMessage(String line1, String line2, String line3, String line4) {
u8g2.clearBuffer();
if (line1.length() > 0) {
drawCenteredText(line1.c_str(), 16, u8g2_font_wqy12_t_gb2312);
}
if (line2.length() > 0) {
drawCenteredText(line2.c_str(), 32, u8g2_font_wqy12_t_gb2312);
}
if (line3.length() > 0) {
drawCenteredText(line3.c_str(), 48, u8g2_font_wqy12_t_gb2312);
}
if (line4.length() > 0) {
drawCenteredText(line4.c_str(), 64, u8g2_font_wqy12_t_gb2312);
}
u8g2.sendBuffer();
}
// 显示当前单词
void displayCurrentWord() {
u8g2.clearBuffer();
if (!showAnswer) {
// 英文单词使用统一字体
drawCenteredText(currentWord.name.c_str(), 30, u8g2_font_wqy12_t_gb2312);
drawCenteredText("显示答案", 60, u8g2_font_wqy12_t_gb2312);
} else {
// 显示完整信息
// 英文单词
drawCenteredText(currentWord.name.c_str(), 13, u8g2_font_wqy12_t_gb2312);
// 中文翻译
drawCenteredText(currentWord.chinese.c_str(), 30, u8g2_font_wqy12_t_gb2312);
// 学习进度
String studyInfo = "进度: " + String(currentWord.study);
drawCenteredText(studyInfo.c_str(), 45, u8g2_font_wqy12_t_gb2312);
// 操作提示
drawCenteredText("知道 | 模糊 | 忘记", 60, u8g2_font_wqy12_t_gb2312);
}
u8g2.sendBuffer();
}
// 显示统计信息
void showStats() {
u8g2.clearBuffer();
drawCenteredText("学习统计", 16, u8g2_font_wqy12_t_gb2312);
String line1 = "单词总计: " + String(totalWords);
String line2 = "已学习: " + String(studiedWords);
drawCenteredText(line1.c_str(), 35, u8g2_font_wqy12_t_gb2312);
drawCenteredText(line2.c_str(), 50, u8g2_font_wqy12_t_gb2312);
u8g2.sendBuffer();
}
// 处理学习结果
void processStudyResult(StudyResult result) {
u8g2.clearBuffer();
String resultText = "";
String studyText = "进度: " + String(currentWord.study);
switch (result) {
case DONT_KNOW:
// 不会:熟练度不变
resultText = "不会";
drawCenteredText(resultText.c_str(), 30, u8g2_font_wqy12_t_gb2312);
drawCenteredText(studyText.c_str(), 50, u8g2_font_wqy12_t_gb2312);
break;
case VAGUE:
// 模糊:熟练度+25
currentWord.study += 25;
if (currentWord.study > 100) currentWord.study = 100;
resultText = "模糊";
studyText = "进度: " + String(currentWord.study);
drawCenteredText(resultText.c_str(), 30, u8g2_font_wqy12_t_gb2312);
drawCenteredText(studyText.c_str(), 50, u8g2_font_wqy12_t_gb2312);
break;
case KNOW:
// 会:熟练度+50
currentWord.study += 50;
if (currentWord.study > 100) currentWord.study = 100;
resultText = "知道";
studyText = "进度: " + String(currentWord.study);
drawCenteredText(resultText.c_str(), 30, u8g2_font_wqy12_t_gb2312);
drawCenteredText(studyText.c_str(), 50, u8g2_font_wqy12_t_gb2312);
break;
case UNKNOWN:
default:
return; // 无效输入,不处理
}
u8g2.sendBuffer();
delay(1500);
}
input.cpp
基于ESP8266的ADC功能实现,这样只需要一个ADC接口即可识别多个按键功能,此功能的实现对电阻精度要求较高,不同电阻存在误差,可能需要在config文件中去配置实际按下时测得的ADC值。
#include "input.h"
#include "display.h"
#include "storage.h"
#include "learning.h"
// 获取按键结果
StudyResult getButtonResult(int btnValue) {
if (btnValue >= BTN_LEFT_MIN && btnValue <= BTN_LEFT_MAX) {
return DONT_KNOW;
} else if (btnValue >= BTN_MIDDLE_MIN && btnValue <= BTN_MIDDLE_MAX) {
return VAGUE;
} else if (btnValue >= BTN_RIGHT_MIN && btnValue <= BTN_RIGHT_MAX) {
return KNOW;
}
return UNKNOWN;
}
// 处理输入
void handleInput() {
int btnValue = analogRead(A0);
if (btnValue > 100) { // 有按键按下
if (!showAnswer) {
// 第一次按键显示答案
showAnswer = true;
displayCurrentWord();
} else {
// 第二次按键处理学习结果
StudyResult result = getButtonResult(btnValue);
if (result != UNKNOWN) {
processStudyResult(result);
saveWordProgress();
calculateStudiedWords(); // 重新计算已学习单词数量
saveConfig();
nextWord();
}
}
// 等待按键释放
while (analogRead(A0) > 100) {
delay(50);
}
delay(200); // 防抖
}
}
storage.cpp
存储部分采用ArduinoJson库完成对json数据文件的格式识别及增删改查,单词数据以json形式储存,可以查看example_word文件夹下的示例单词
#include "storage.h"
#include
// 初始化存储
bool initStorage() {
pinMode(PIN_CS, OUTPUT);
digitalWrite(PIN_CS, HIGH);
SPI.begin();
if (!SD.begin(PIN_CS)) {
return false;
}
return true;
}
// 计算已学习的单词数量(学习进度>0的单词)
void calculateStudiedWords() {
studiedWords = 0;
for (int i = 0; i < totalWords; i++) {
File file = SD.open("/" + wordFiles[i], FILE_READ);
if (file) {
JsonDocument doc;
deserializeJson(doc, file);
int study = doc["study"] | 0;
if (study > 0) {
studiedWords++;
}
file.close();
}
}
Serial.println("Calculated studied words: " + String(studiedWords));
}
// 扫描单词文件
void scanWordFiles() {
totalWords = 0;
File root = SD.open("/");
while (true) {
File entry = root.openNextFile();
if (!entry) break;
String fileName = entry.name();
if (fileName.endsWith(".json") && !fileName.equals("config.json")) {
wordFiles[totalWords] = fileName;
totalWords++;
if (totalWords >= 100) break;
}
entry.close();
}
root.close();
Serial.println("Found " + String(totalWords) + " word files");
}
// 加载配置
void loadConfig() {
if (SD.exists("/config.json")) {
File file = SD.open("/config.json", FILE_READ);
if (file) {
JsonDocument doc;
deserializeJson(doc, file);
// studiedWords现在是动态计算的,不从配置文件加载
String version = doc["version"] | "1.0";
Serial.println("Config loaded - version: " + version);
file.close();
}
}
}
// 保存配置
void saveConfig() {
// 先删除原配置文件
if (SD.exists("/config.json")) {
SD.remove("/config.json");
}
// 重新创建配置文件
File file = SD.open("/config.json", FILE_WRITE);
if (file) {
JsonDocument doc;
// 不再保存studiedWords,因为它是动态计算的
doc["totalWords"] = totalWords;
doc["version"] = "1.1B";
serializeJson(doc, file);
file.close();
Serial.println("Config saved - total: " + String(totalWords));
} else {
Serial.println("Failed to save config");
}
}
// 加载单词
void loadWord(String fileName) {
File file = SD.open("/" + fileName, FILE_READ);
if (file) {
JsonDocument doc;
deserializeJson(doc, file);
currentWord.name = doc["name"].as();
currentWord.chinese = doc["chinese"].as();
currentWord.study = doc["study"] | 0;
currentWord.example = doc["example"].as();
currentWord.frequency = 100 - currentWord.study;
file.close();
}
}
// 保存单词进度
void saveWordProgress() {
String fileName = wordFiles[currentWordIndex];
// 先删除原文件,确保完全覆盖
if (SD.exists("/" + fileName)) {
SD.remove("/" + fileName);
}
// 重新创建文件并写入
File file = SD.open("/" + fileName, FILE_WRITE);
if (file) {
JsonDocument doc;
doc["name"] = currentWord.name;
doc["chinese"] = currentWord.chinese;
doc["study"] = currentWord.study;
doc["example"] = currentWord.example;
serializeJson(doc, file);
file.close();
Serial.println("Saved word: " + currentWord.name + ", study: " + String(currentWord.study));
} else {
Serial.println("Failed to save word progress");
}
}
单词json数据示例
所有单词数据以json形式保存,文件命名格式为“英文.json”
{
"name": "computer",
"chinese": "计算机",
"study": 90,
"example": "I use my computer for work and study."
}
安装结构
项目采用双壳体,由前壳及后壳组成。
- 前盖设计有限位槽,限位支撑薄弱处添加倒角加强,避免断裂
- OLED屏从外侧放入,卡入屏幕限位槽中,PCB从内侧放入,卡入PCB限位槽中/li>
- 侧面为TF卡开孔,方便TF卡拔插
- 底盖部分设有限位柱,确保PCB卡在顶底壳中间
- 限位柱同样添加倒角避免断裂
- TypeC部分开孔,并在外圈挖槽,确保不受线缆胶壳阻挡
拓展方向
本项目提供了一个简易的单词卡方案,你可以基于此项目进行以下拓展:
- 增加电池充放电ic,添加电池供电
- 添加遗忘曲线计算算法
- 集成到Homeassistant
- 添加更多小功能
注意事项
立创商城采购物料清单
| 数量 | 器件 | 位号 | 商城编号 |
|---|---|---|---|
| 3 | 10uF | C1,C4 | C15850 |
| 4 | 100nF | C2,C3,C5,C7 | C5137467 |
| 1 | TF PUSH | CARD1 | C393941 |
| 1 | PZ254V-12-6P | H1 | C492420 |
| 2 | ZX-QC66-9.5CJ | LEFT,RIGHT | C7470135 |
| 1 | ZX-QC66-7.5TP | MID | C7470157 |
| 10 | 10kΩ | R1,R2,R3,R4,R5,R8,R9,R11,R12,R13 | C17414 |
| 2 | 5.1kΩ | R6,R7 | C27834 |
| 1 | 1.2MΩ | R14 | C17380 |
| 1 | 470kΩ | R15 | C17709 |
| 1 | 270k | R16 | C17589 |
| 1 | 100k | R17 | C149504 |
| 1 | ME6217C33M5G | U1 | C427602 |
| 1 | ESP8266 | U2 | C5248080 |
| 1 | HS96L03W2C03 | U3 | C492409 |
| 1 | MC-306LS-H65 | USB2 | C6332265 |
💡 提示:部分通用元件可选择等效型号,重点关注封装及电气参数一致性。
下表列出了 ESP8266 固件烧录时的分区布局,地址与文件对应关系如下,可直接用于命令行烧录或 Flash 工具导入配置。
| 烧录地址 | 文件名称 | 说明 |
|---|---|---|
| 0x0 | firmware.bin | 统一固件 |
