需求洞察
你有没有过这种体验:周末回父母家,发现茶几上摆着 4 盒药,老人指着「这个早上吃半片、那个晚上吃一粒、这个饭后两小时吃」——他自己也说不清今天到底吃了没?又或者,给患高血压的爷爷打电话,他说「记着吃呢」,但你翻他家垃圾桶的板子,看到昨天的药还在铝箔板上戳着?
这不是个例。中国 60 岁以上老人有 2.6 亿,慢性病患者超 1.8 亿,其中需要每天定时多次服药的占 65%。糖尿病、高血压、帕金森、阿尔茨海默……一天 3-6 次吃药是常态。但老年人的记忆力和执行力都在下降,「忘吃药」「吃错剂量」「重复吃」是三大常见事故。
市面上的智能药盒分两类:
- 简易型(¥50-200):电子提醒 + 7 格旋转盘,无机械弹出,老人家「听到了提醒但懒得起来开盖」
- 医院级(¥800-3000):分药机 + 远程报警 + 上传云端,但操作复杂,老人不会用,且价格不亲民
本项目的目标是做一个机械弹出型智能药盒——到点舵机转动 60° 弹出对应药格,蜂鸣 + OLED 提示,老人「被迫」起来吃药。6 路舵机 = 6 个独立药格,DS3231 断电仍能走时,断电一晚也不怕设置丢失。
核心痛点
- 忘吃药:3 餐 + 睡前 4 次,单次记忆负担大,老人常忘
- 吃错剂量:多种药外形相似,老人拿错或漏拿
- 无机械反馈:现有智能药盒只响铃不弹出,老人装聋就跳过
- 断电设置丢失:WiFi 智能药盒断网就停摆,且老人不会重置
方案设计
本项目采用「主控 + 时钟 + 输出 + 输入 + UI」五件套架构,ESP32 为中心,DS3231 守时,6 路 SG90 舵机输出机械动作,4×3 键盘 + OLED 提供本地交互:
| 模块 | 型号 | 功能 |
|---|---|---|
| 主控 | ESP32 DevKit V1 | 双核 240MHz,34 个 GPIO,带 EEPROM 模拟 |
| 时钟 | DS3231 RTC(CR2032 电池) | ±2ppm 精度,断电走时 5 年 |
| 输出 | SG90 舵机 × 6 | 每路对应 1 个药格,0-180° 转动 |
| 输入 | 4×3 矩阵键盘 | 0-9 数字 + 确认/取消 |
| 显示 | SSD1306 OLED 0.96" | 128×64,I2C 接口 |
| 反馈 | 有源蜂鸣器 | GPIO 5 + NPN 三极管驱动 |
数据流:时间 → 匹配 → 触发
┌──────────┐ 每秒 ┌──────────┐
│ DS3231 │ ──────▶ │ ESP32 │
│ RTC 实时 │ I2C │ loop() │
└──────────┘ └────┬─────┘
│
┌─────────────┴─────────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ 匹配 6 个│ │ 4×3 键盘 │
│ PillSlot │ │ 设置时间│
│ (EEPROM) │ └──────────┘
└────┬─────┘
│ 时间到 + 未吃
▼
┌───────────────┐
│ 蜂鸣 + OLED │
│ + SG90 弹 60°│
└───────┬───────┘
│
▼ 30 分钟内未确认
┌───────────────┐
│ 重复提醒 3 次 │
└───────────────┘
关键技术点
- DS3231 走时:自带温度补偿,±2ppm 精度(年误差 ±1 分钟),CR2032 电池保 5 年
- 6 路舵机独立控制:每个 SG90 独立 GPIO,避免 PWM 冲突
- EEPROM 持久化:6 个药格的时间表存 EEPROM,断电不丢失
- 4×3 键盘交互:数字键设置时间,# 键确认吃药,* 键取消
- 漏吃多次提醒:到点后 30 分钟内每 10 分钟重弹一次
物料清单
总成本 ¥175,比医院级智能药盒便宜 78%。如果暂时用纸盒代替 3D 打印外壳,可压到 ¥145。
| 数量 | 物料 | 单价 | 小计 | 备注 |
|---|---|---|---|---|
| 1 | ESP32 DevKit V1(Type-C 口) | ¥30 | ¥30 | 主控,双核 240MHz |
| 1 | DS3231 RTC 时钟模块(带 CR2032) | ¥6 | ¥6 | ±2ppm,断电走时 5 年 |
| 6 | SG90 舵机(9g 塑料齿) | ¥10 | ¥60 | 每路独立控制一个药格 |
| 1 | 4×3 矩阵键盘(12 键) | ¥5 | ¥5 | 0-9 + * + # |
| 1 | SSD1306 OLED 0.96 寸(I2C 4 针) | ¥12 | ¥12 | 显示时间 + 状态 |
| 1 | 有源蜂鸣器模块(3.3V/5V) | ¥2 | ¥2 | 到点响铃 |
| 1 | S8050 NPN 三极管 + 1kΩ 电阻 | ¥1 | ¥1 | 蜂鸣器驱动(蜂鸣器峰值 100mA) |
| 1 包 | 杜邦线(公-公 + 公-母) | ¥5 | ¥5 | 20cm 30 根 |
| 1 | 面包板 400 点(SYB-170) | ¥5 | ¥5 | 原型验证用 |
| 1 | 5V 5A 电源适配器(DC 5.5x2.1) | ¥20 | ¥20 | 6 路舵机需要 5V/5A(峰值 4A) |
| 1 | 3D 打印外壳(6 格 + 6 舵机仓) | ¥30 | ¥30 | 可选,纸盒代替可省 |
| 总计 | ¥175 | 省 3D 外壳 = ¥145 | ||
采购建议:
- ESP32 建议买带 Type-C 口的 V1 改进版,丝印清楚
- SG90 选 9g 塑料齿的就够用(扭矩 1.8kg·cm,足够弹出 5g 药盒),金属齿贵 3 倍没必要
- DS3231 必须买带 CR2032 电池的版本(淘宝 ¥6 那种),裸模块断电不走时
- 5V 5A 电源 必选 5A,6 路舵机峰值电流 4A;3A 电源会触发过流保护导致舵机抖动
- 3D 打印外壳 嘉立创 / 拓竹 MakerWorld 找「6 格药盒舵机」模型,¥30 包邮
接线与结构
按 ESP32 DevKit V1 的丝印实际位置说明(不是抽象 GPIO 编号):
ESP32 排针位置参考
- 左侧排针(从下到上):VIN / GND / D13 / D12 / D14 / D27 / D26 / D25 / D33 / D32 / D35 / D34 / VN / IO / VP / EN
- 右侧排针(从下到上):3V3 / GND / D15 / D2 / D4 / RX2 / TX2 / D5 / D18 / D19 / D21 / RX0 / TX0 / D22 / D23
DS3231 + OLED 接线(I2C 总线)
| 模块 | 引脚 | 颜色 | 连接目标 | 丝印位置 |
|---|---|---|---|---|
| DS3231 | SDA | 黄线 | ESP32 GPIO 21 | 右侧排针第 11 脚,标 D21 |
| DS3231 | SCL | 白线 | ESP32 GPIO 22 | 右侧排针第 14 脚,标 D22 |
| DS3231 | VCC | 红线 | ESP32 3.3V | 右侧排针第 1 脚,标 3V3 |
| DS3231 | GND | 黑线 | ESP32 GND | 右侧排针第 2 脚,标 GND |
| OLED 0.96" | SDA | 黄线 | ESP32 GPIO 21 | 与 DS3231 共用 I2C 总线 |
| OLED 0.96" | SCL | 白线 | ESP32 GPIO 22 | 与 DS3231 共用 I2C 总线 |
| OLED 0.96" | VCC | 红线 | ESP32 3.3V | 右侧排针第 1 脚,标 3V3 |
| OLED 0.96" | GND | 黑线 | ESP32 GND | 右侧排针第 2 脚,标 GND |
6 路 SG90 舵机接线
| 舵机 | 信号(橙) | VCC(红) | GND(棕) |
|---|---|---|---|
| #1 早 | 绿线 → GPIO 13(左侧排针第 3 脚,标 D13) | 外部 5V 电源正 | ESP32 GND + 电源 GND 共地 |
| #2 中 | 绿线 → GPIO 12(左侧排针第 4 脚,标 D12) | 外部 5V 电源正 | ESP32 GND + 电源 GND 共地 |
| #3 晚 | 绿线 → GPIO 14(左侧排针第 5 脚,标 D14) | 外部 5V 电源正 | ESP32 GND + 电源 GND 共地 |
| #4 睡前 | 绿线 → GPIO 27(左侧排针第 6 脚,标 D27) | 外部 5V 电源正 | ESP32 GND + 电源 GND 共地 |
| #5 备用 | 绿线 → GPIO 26(左侧排针第 7 脚,标 D26) | 外部 5V 电源正 | ESP32 GND + 电源 GND 共地 |
| #6 备用 | 绿线 → GPIO 25(左侧排针第 8 脚,标 D25) | 外部 5V 电源正 | ESP32 GND + 电源 GND 共地 |
关键提示:6 路舵机的 VCC(红线)必须并联到外部 5V/5A 电源正极,6 路 GND(棕线)必须并联到 ESP32 的 GND(即左侧排针第 2 脚),最后把外部 5V 电源的 GND 也接到 ESP32 GND,三个 GND 共地。这是项目成功与否的关键!
4×3 键盘接线
| 键盘线 | 颜色 | 连接目标 | 丝印位置 |
|---|---|---|---|
| 行 R1 | 绿线 | ESP32 GPIO 32 | 左侧排针第 10 脚,标 D32 |
| 行 R2 | 绿线 | ESP32 GPIO 33 | 左侧排针第 9 脚,标 D33 |
| 行 R3 | 绿线 | ESP32 GPIO 15 | 右侧排针第 3 脚,标 D15 |
| 行 R4 | 绿线 | ESP32 GPIO 2 | 右侧排针第 4 脚,标 D2 |
| 列 C1 | 蓝线 | ESP32 GPIO 4 | 右侧排针第 5 脚,标 D4 |
| 列 C2 | 蓝线 | ESP32 GPIO 16 | 右侧排针第 6 脚,标 RX2 |
| 列 C3 | 蓝线 | ESP32 GPIO 17 | 右侧排针第 7 脚,标 TX2 |
蜂鸣器接线(NPN 驱动)
| 蜂鸣器 | 颜色 | 连接目标 | 丝印位置 |
|---|---|---|---|
| + | 红线 | 外部 5V 电源正极(与舵机同源) | DC 5.5x2.1 母头内芯 |
| - | 黑线 | S8050 集电极(C 极) | — |
| S8050 发射极(E 极) | — | ESP32 GND(共地) | — |
| S8050 基极(B 极) | — | 1kΩ 电阻 → ESP32 GPIO 5 | 右侧排针第 8 脚,标 D5 |
⚠️ 关键提醒:
- 6 路舵机必须用外部 5V/5A 电源,不能接 ESP32 的 VIN(3.3V 稳压器会烧)。6 路 SG90 满载峰值电流 4A(每路约 650mA 堵转电流)
- 务必共地:6 路舵机 GND + 蜂鸣器 GND + ESP32 GND + 外部 5V 电源 GND 四者必须连在一起,否则舵机会乱抖、蜂鸣器不响
- 蜂鸣器必须用 NPN 驱动:GPIO 5 输出电流只有 12mA,不够直接驱动蜂鸣器(峰值 100mA)。S8050 + 1kΩ 电阻是经典三极管开关电路
- DS3231 装上 CR2032 电池:开机的瞬间就装上,否则断电后时间会重置
- 5V 电源加 1000μF 电容(可选):在 5V 电源正负极之间并联电容,避免 6 路舵机同时启动瞬间电流冲击
核心代码
使用 Arduino 框架,需要安装 5 个库:RTClib、Servo、Keypad、Adafruit_SSD1306、EEPROM(内置)。
#include <Wire.h>
#include <RTClib.h>
#include <Servo.h>
#include <Keypad.h>
#include <Adafruit_SSD1306.h>
#include <EEPROM.h>
// === 6 路舵机引脚(按 ESP32 DevKit V1 丝印 D13/D12/D14/D27/D26/D25)===
const uint8_t SERVO_PINS[6] = {13, 12, 14, 27, 26, 25};
// === 4×3 矩阵键盘 ===
const byte ROWS = 4, COLS = 3;
char keys[ROWS][COLS] = {
{'1','2','3'},
{'4','5','6'},
{'7','8','9'},
{'*','0','#'}
};
const uint8_t ROW_PINS[ROWS] = {32, 33, 15, 2}; // R1-R4
const uint8_t COL_PINS[COLS] = {4, 16, 17}; // C1-C3
// === OLED ===
#define OLED_ADDR 0x3C
Adafruit_SSD1306 display(128, 64, &Wire, -1);
// === 蜂鸣器(经 S8050 三极管接到 GPIO 5)===
#define BUZZER_PIN 5
// === RTC ===
RTC_DS3231 rtc;
// === 药格数据结构(6 格:早/中/晚/睡前/备用/备用)===
struct PillSlot {
uint8_t hour;
uint8_t minute;
uint8_t weekday_mask; // bit0=周日, bit1=周一, ..., bit6=周六, 0x7F=每天
uint8_t servo_index; // 对应 SERVO_PINS 数组下标
bool enabled;
uint8_t last_taken_day; // 上次服药的「日」(1-31),用于当天防重复
};
#define NUM_SLOTS 6
PillSlot slots[NUM_SLOTS];
// === 全局对象 ===
Servo servos[NUM_SLOTS];
Keypad keypad = Keypad(makeKeymap(keys), ROW_PINS, COL_PINS, ROWS, COLS);
#define EEPROM_ADDR 0
#define MAGIC 0xAB
uint8_t current_alert_slot = 0xFF; // 当前正在提醒的药格(0xFF = 无)
unsigned long buzz_start_ms = 0; // 蜂鸣开始时间
unsigned long last_repeat_ms = 0; // 上次重复提醒时间
bool is_buzzing = false;
void setup() {
Serial.begin(115200);
// 1) 初始化 6 路舵机
for (uint8_t i = 0; i < NUM_SLOTS; i++) {
servos[i].attach(SERVO_PINS[i]);
servos[i].write(0); // 初始关闭(0°)
delay(50);
}
// 2) 初始化 DS3231
if (!rtc.begin()) {
Serial.println(F("DS3231 not found, check wiring"));
while (1) delay(1000);
}
if (rtc.lostPower()) {
// 首次上电 / 换电池后,用编译时间初始化
rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
}
// 3) 初始化 OLED
display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR);
display.display();
delay(1500);
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
// 4) 蜂鸣器
pinMode(BUZZER_PIN, OUTPUT);
digitalWrite(BUZZER_PIN, LOW);
// 5) 从 EEPROM 加载药格设置
loadSlots();
// 6) 开机欢迎
welcomeScreen();
delay(2000);
}
void loop() {
DateTime now = rtc.now();
// 每分钟第 0 秒检查一次时间匹配
if (now.second() == 0) {
checkSchedule(now);
}
// 4×3 键盘扫描
char key = keypad.getKey();
if (key) {
handleKey(key, now);
}
// 蜂鸣超时关掉(响 5 秒后停)
if (is_buzzing && millis() - buzz_start_ms > 5000) {
digitalWrite(BUZZER_PIN, LOW);
is_buzzing = false;
}
// 漏吃 30 分钟内每 10 分钟重弹
checkMissedPills(now);
// OLED 实时显示
displayStatus(now);
delay(200);
}
// === 匹配时间表 ===
void checkSchedule(DateTime now) {
for (uint8_t i = 0; i < NUM_SLOTS; i++) {
if (slots[i].enabled
&& slots[i].hour == now.hour()
&& slots[i].minute == now.minute()
&& slots[i].last_taken_day != now.day()
&& isWeekdayMatch(now.dayOfTheWeek(), slots[i].weekday_mask)) {
triggerReminder(i);
}
}
}
// === 触发提醒:蜂鸣 + OLED + 舵机 ===
void triggerReminder(uint8_t slot_index) {
current_alert_slot = slot_index;
buzz_start_ms = millis();
is_buzzing = true;
digitalWrite(BUZZER_PIN, HIGH);
servos[slot_index].write(60); // 弹出 60°
Serial.print(F("[提醒] 该吃第 "));
Serial.print(slot_index + 1);
Serial.println(F(" 格药"));
}
// === 漏吃 30 分钟内每 10 分钟重复提醒 ===
void checkMissedPills(DateTime now) {
if (current_alert_slot == 0xFF) return;
if (is_buzzing) return; // 正在响不再重复
if (millis() - last_repeat_ms < 600000) return; // 10 分钟一次
// 计算从触发到现在过了几分钟
if ((millis() - buzz_start_ms) > 1800000UL) { // 30 分钟仍未确认
triggerReminder(current_alert_slot);
last_repeat_ms = millis();
}
}
// === 键盘处理:# = 确认吃药,* = 取消/复位 ===
void handleKey(char key, DateTime now) {
if (key == '#') {
if (current_alert_slot != 0xFF) {
slots[current_alert_slot].last_taken_day = now.day();
servos[current_alert_slot].write(0); // 关闭药格
digitalWrite(BUZZER_PIN, LOW);
is_buzzing = false;
current_alert_slot = 0xFF;
saveSlots();
Serial.println(F("[确认] 已服药"));
}
} else if (key == '*') {
// 复位(紧急停止蜂鸣 + 关闭所有药格)
digitalWrite(BUZZER_PIN, LOW);
is_buzzing = false;
for (uint8_t i = 0; i < NUM_SLOTS; i++) servos[i].write(0);
current_alert_slot = 0xFF;
Serial.println(F("[复位] 全部关闭"));
}
// 0-9 数字键可扩展为「设置时间」模式(长按 * 进入)
}
bool isWeekdayMatch(uint8_t weekday, uint8_t mask) {
return (mask >> weekday) & 0x01;
}
// === OLED 显示 ===
void displayStatus(DateTime now) {
display.clearDisplay();
display.setCursor(0, 0);
display.setTextSize(1);
display.print(F("Time "));
display.print(now.hour() < 10 ? "0" : "");
display.print(now.hour());
display.print(F(":"));
display.print(now.minute() < 10 ? "0" : "");
display.print(now.minute());
display.print(F(":"));
display.print(now.second() < 10 ? "0" : "");
display.print(now.second());
// 下一餐倒计时
display.setCursor(0, 14);
display.print(F("Next: "));
bool found = false;
for (uint8_t i = 0; i < NUM_SLOTS; i++) {
if (slots[i].enabled && slots[i].last_taken_day != now.day()) {
bool future = (slots[i].hour > now.hour()) ||
(slots[i].hour == now.hour() && slots[i].minute > now.minute());
if (future) {
display.print(F("#"));
display.print(i + 1);
display.print(F(" "));
display.print(slots[i].hour < 10 ? "0" : "");
display.print(slots[i].hour);
display.print(F(":"));
display.print(slots[i].minute < 10 ? "0" : "");
display.print(slots[i].minute);
found = true;
break;
}
}
}
if (!found) display.print(F("all done"));
// 当前提醒(大字)
if (is_buzzing && current_alert_slot != 0xFF) {
display.setCursor(0, 30);
display.setTextSize(2);
display.print(F("EAT #"));
display.println(current_alert_slot + 1);
display.setTextSize(1);
display.setCursor(0, 56);
display.println(F("Press # ok, * stop"));
} else {
display.setCursor(0, 32);
display.print(F("Slot1: "));
display.print(slots[0].hour);
display.print(F(":"));
display.print(slots[0].minute < 10 ? "0" : "");
display.println(slots[0].minute);
display.setCursor(0, 44);
display.print(F("Slot2: "));
display.print(slots[1].hour);
display.print(F(":"));
display.print(slots[1].minute < 10 ? "0" : "");
display.println(slots[1].minute);
display.setCursor(0, 56);
display.print(F("Slot3: "));
display.print(slots[2].hour);
display.print(F(":"));
display.print(slots[2].minute < 10 ? "0" : "");
display.print(slots[2].minute);
}
display.display();
}
// === 加载默认时间表(首次上电)===
void loadSlots() {
if (EEPROM.read(EEPROM_ADDR) == MAGIC) {
for (uint8_t i = 0; i < NUM_SLOTS; i++) {
uint8_t base = EEPROM_ADDR + 1 + i * 6;
slots[i].hour = EEPROM.read(base);
slots[i].minute = EEPROM.read(base + 1);
slots[i].weekday_mask = EEPROM.read(base + 2);
slots[i].servo_index = EEPROM.read(base + 3);
slots[i].enabled = EEPROM.read(base + 4);
slots[i].last_taken_day = EEPROM.read(base + 5);
}
} else {
// 默认:早 8:00 / 中 12:00 / 晚 18:00 / 睡前 22:00 / 备用 / 备用
slots[0] = {8, 0, 0x7F, 0, true, 0};
slots[1] = {12, 0, 0x7F, 1, true, 0};
slots[2] = {18, 0, 0x7F, 2, true, 0};
slots[3] = {22, 0, 0x7F, 3, true, 0};
slots[4] = {14, 0, 0x7F, 4, false, 0};
slots[5] = {16, 0, 0x7F, 5, false, 0};
saveSlots();
}
}
void saveSlots() {
EEPROM.write(EEPROM_ADDR, MAGIC);
for (uint8_t i = 0; i < NUM_SLOTS; i++) {
uint8_t base = EEPROM_ADDR + 1 + i * 6;
EEPROM.write(base, slots[i].hour);
EEPROM.write(base + 1, slots[i].minute);
EEPROM.write(base + 2, slots[i].weekday_mask);
EEPROM.write(base + 3, slots[i].servo_index);
EEPROM.write(base + 4, slots[i].enabled);
EEPROM.write(base + 5, slots[i].last_taken_day);
}
EEPROM.commit();
}
void welcomeScreen() {
display.clearDisplay();
display.setTextSize(2);
display.setCursor(10, 8);
display.println(F("PillBox"));
display.setTextSize(1);
display.setCursor(20, 36);
display.println(F("Smart Reminder v1.0"));
display.setCursor(10, 50);
display.println(F("6 slots * 24h cycle"));
display.display();
}
代码说明:
- 第 9-13 行:舵机信号线接 D13/D12/D14/D27/D26/D25,全部都是 ESP32 安全的 GPIO(避免 6/7/8/9/10/11 这些启动 strapping pin)
- 第 17-24 行:4×3 键盘的 7 根线全部分布在左右两侧排针,D2/D15/D32/D33 都不与舵机/舵机冲突
- 第 28 行:DS3231 默认 I2C 地址 0x68(固定),OLED 0x3C(也可能是 0x3D)
- 第 49 行:
struct PillSlot用 6 字节存一个药格,6 个药格 = 36 字节 + 1 字节 MAGIC = 37 字节,符合 ESP32 EEPROM 容量 - 第 89 行:
if (now.second() == 0)每分钟第 0 秒检查一次时间匹配,避免每秒都跑 6 次比较 - 第 124 行:
last_taken_day = now.day()用「日(1-31)」做幂等键,跨月自动重置 - 第 168 行:
triggerReminder()是核心动作——蜂鸣 + 舵机转 60°,机械感来源 - 第 213 行:
isWeekdayMatch()用 7 位掩码表示星期几,0x7F = 每天
调试步骤
按以下顺序逐步调试,每步验证通过再进下一步(避免一次接 30 根线发现全错)。
第 1 步:DS3231 自检(3 分钟)
- 接线:仅接 DS3231 的 4 根线(VCC/GND/SDA/SCL)
- 烧录 Arduino IDE 自带的
RTClib > DS3231示例 - 打开串口监视器(波特率 115200),应看到
2026/6/24 10:30:45格式的当前时间每秒刷新 - 时间对 → 接线正确;时间一直
2165/165/165→ DS3231 没认出来,检查 SDA/SCL 是否接反 - 断电 10 秒后重上电,时间仍正确 → CR2032 电池安装成功
第 2 步:单路舵机测试(5 分钟)
- 接线:只接 1 路舵机(信号 → D13,VCC → 外部 5V,GND → ESP32 GND + 电源 GND 共地)
- 烧录 Arduino IDE 自带的
Sweep示例(把 pin 改为 13) - 看到舵机在 0°-180° 之间平滑扫动 → 单路 OK
- 舵机抖但不转 → 检查信号线是否接对、共地是否良好
- 舵机转但有杂音 → 电压不够(5V 跌到 4V 以下),换 5V/5A 电源
第 3 步:4×3 键盘测试(3 分钟)
- 接线:键盘 7 根线全接(行 4 根 + 列 3 根)
- 烧录
Keypad > CustomKeypad示例 - 打开串口监视器,按数字键应显示
Key: 1、Key: 5等 - 按键无反应 → 检查行列线是否接反、Keypad.h 的 R1-R4/C1-C3 顺序与物理接线是否一致
- 显示乱码 → 行列顺序反了,重新对照丝印
第 4 步:OLED 自检(3 分钟)
- 接线:OLED 的 4 根线全接(与 DS3231 共用 I2C 总线)
- 烧录
Adafruit_SSD1306 > ssd1306_128x64_i2c示例 - 看到 OLED 显示一个跳动的方块 + 演示文字 → OK
- 黑屏 → 用 I2C Scanner 扫地址(0x3C 或 0x3D)、检查 VCC/GND 是否接反
第 5 步:6 路舵机联动(10 分钟)
- 6 路舵机全接(信号线分散到 D13/D12/D14/D27/D26/D25)
- 烧录下面的最小测试代码(只测舵机联动)
- 看到 6 路舵机依次转动 60° → 联动 OK
- 某一路不转 → 检查那一路的信号线和 GND
- 全部抖动 → 电源功率不够(5V/3A 拖 6 路 SG90 必抖),换 5V/5A
第 6 步:整合测试(15 分钟)
- 烧录上面的核心代码(完整版)
- 打开串口监视器(波特率 115200)
- OLED 应显示
Time HH:MM:SS+Next: #1 08:00(或当前时刻之后的下一餐) - 临时把 EEPROM 里的 slot[0] 时间改成「1 分钟后」(用串口命令或临时改代码),等 1 分钟
- 应该看到:蜂鸣响 + OLED 大字
EAT #1+ 1 号舵机转到 60° - 按
#键 → 蜂鸣停、1 号舵机回到 0°、OLED 恢复正常 - 按
*键 → 全部舵机回到 0°、蜂鸣停(紧急复位) - 拔掉电源 1 分钟后重上电 → 时间仍正确(CR2032 电池工作)+ slot 配置不变(EEPROM)
常见故障
- 舵机完全不动:检查 5V 电源是否通电(万用表测电源正负极应有 5V)、GND 是否共地、信号线是否接对
- 舵机抖但不转:大概率是电源功率不够(5V/3A 拖 6 路 SG90 满载峰值 4A 必抖),换 5V/5A
- DS3231 一直返回 2165/165/165:I2C 没通信,检查 SDA/SCL 是否接反、I2C 总线是否被 OLED 占用(两设备地址不能冲突)
- 断电时间重置:CR2032 电池没装上 / 装反了 / 电池没电(用万用表测应有 3V)
- OLED 一直黑屏:I2C 地址不对(用 Scanner 扫,0x3C 或 0x3D)、VCC/GND 接反
- 蜂鸣器不响:NPN 三极管 S8050 的 B/C/E 极接反、1kΩ 电阻虚焊、GPIO 5 没设 OUTPUT
- 4×3 键盘按一个键显示多个字符:行列线接错,Keypad.h 的 R1-R4/C1-C3 顺序与物理接线不一致
- 舵机弹出后不复位:检查
last_taken_day是否被正确写入 EEPROM(重新烧录时会被重置)
设计图
系统架构图(ESP32 中心 + 5 个外围)
┌──────────────────────────────┐
│ ESP32 DevKit V1 │
│ (主控 + 24KB EEPROM) │
└────────┬─────────────────────┘
│
┌──────────┬───┴────┬──────────┬──────────┐
│ │ │ │ │
I2C(21/22) GPIO矩阵 6路GPIO GPIO 5 共地
│ (32/33/15/ (13/12/14/ (BUZZER)
│ 2/4/16/17) 27/26/25)
│ │ │ │
▼ ▼ ▼ ▼
┌────────┐ ┌──────┐ ┌──────┐ ┌──────┐
│DS3231 │ │4×3 │ │6路 │ │蜂鸣器│
│+OLED │ │键盘 │ │SG90 │ │NPN │
│(感知+UI)│ │(输入) │ │(输出) │ │(反馈) │
└────────┘ └──────┘ └──────┘ └──────┘
│
▼
┌──────────┐
│ 外部 5V │
│ 5A 电源 │
│ (舵机专用)│
└──────────┘
6 路舵机引脚分配表
| 药格 | 用途 | ESP32 GPIO | 丝印 | 排针位置 |
|---|---|---|---|---|
| #1 | 早 8:00(降压药) | GPIO 13 | D13 | 左侧排针第 3 脚 |
| #2 | 中 12:00(护胃药) | GPIO 12 | D12 | 左侧排针第 4 脚 |
| #3 | 晚 18:00(降糖药) | GPIO 14 | D14 | 左侧排针第 5 脚 |
| #4 | 睡前 22:00(钙片) | GPIO 27 | D27 | 左侧排针第 6 脚 |
| #5 | 备用 14:00 | GPIO 26 | D26 | 左侧排针第 7 脚 |
| #6 | 备用 16:00 | GPIO 25 | D25 | 左侧排针第 8 脚 |
触发逻辑与时序
| 当前状态 | 触发条件 | 动作 | 状态切换 |
|---|---|---|---|
| 待机 | now.minute == slot.minute && now.hour == slot.hour && !已吃 | 蜂鸣 5 秒 + OLED 大字 + 舵机转 60° | → 提醒中 |
| 提醒中 | 按 # 键 | 蜂鸣停 + 舵机回 0° + 写 EEPROM | → 待机 |
| 提醒中 | 30 分钟未按 # | 蜂鸣 5 秒 + 舵机再弹 60° | → 提醒中(重复) |
| 提醒中 | 按 * 键(紧急复位) | 蜂鸣停 + 全部舵机回 0° | → 待机 |
| 待机 | 每天 0:00 | 不清 last_taken_day,靠「日」变化自动失效 | — |
适用场景对照表
| 药格 | 时间 | 适用场景 |
|---|---|---|
| #1 早 | 08:00 | 降压药 / 降糖药 / 抗凝药 |
| #2 上午 | 10:00 | 维生素 D / 钙片(饭后) |
| #3 中 | 12:00 | 护胃药 / 饭后降糖药 |
| #4 下午 | 15:00 | 帕金森药 / 老年痴呆药(多奈哌齐) |
| #5 晚 | 18:00 | 晚饭前降压药 / 抗凝药 |
| #6 睡前 | 22:00 | 褪黑素 / 助眠药 / 他汀类 |
4×3 键盘按键含义
| 1 | 2 | 3 |
|---|---|---|
| 4 | 5 | 6 |
| 7 | 8 | 9 |
| * | 0 | # |
| 紧急复位 | 数字 0 | 确认吃药 |
键盘扩展:当前实现只用了 * 和 # 两个功能键。0-9 数字键可扩展为「时间设置」模式(长按 * 进入 → 输入 4 位数字 HHMM → 按 # 确认 → 写入 EEPROM 对应 slot)。
扩展玩法
- 加 WiFi 远程推送:ESP32 连 WiFi 后,到点吃药时通过 Blynk / 微信推送通知子女(适合异地照顾父母)
- 加摄像头拍照(OV2640 ¥15):服药时拍一张照片发到子女手机,确认真吃了
- 加称重传感器(HX711 + 5kg 压力 ¥10):监测药格内药量余量,提前 3 天提醒「该补药了」
- 加语音播报(DFPlayer Mini + 小喇叭 ¥8):到点用普通话喊「爷爷,该吃降压药了」
- 改 12 格药盒:加 6 路舵机到 12 路,2 天用药量(周末上班用)
金句
"时间是最贵的药,ESP32 让它按时送达。"
本项目总成本 ¥175(省 3D 外壳可压到 ¥145),比医院级智能药盒便宜 78%。6 路 SG90 舵机 + DS3231 断电守时 + 4×3 键盘本地交互 + OLED 实时显示,所有模块都是淘宝 ¥5-30 常见件,3D 外壳可在嘉立创免费下载打印,二次开发空间大(加 WiFi、加语音、加摄像头都能叠)。动手做——给家里的长辈一份「会弹出药盒的关心」。