需求洞察

你有没有过这种体验:周末回父母家,发现茶几上摆着 4 盒药,老人指着「这个早上吃半片、那个晚上吃一粒、这个饭后两小时吃」——他自己也说不清今天到底吃了没?又或者,给患高血压的爷爷打电话,他说「记着吃呢」,但你翻他家垃圾桶的板子,看到昨天的药还在铝箔板上戳着?

这不是个例。中国 60 岁以上老人有 2.6 亿,慢性病患者超 1.8 亿,其中需要每天定时多次服药的占 65%。糖尿病、高血压、帕金森、阿尔茨海默……一天 3-6 次吃药是常态。但老年人的记忆力和执行力都在下降,「忘吃药」「吃错剂量」「重复吃」是三大常见事故。

市面上的智能药盒分两类:

本项目的目标是做一个机械弹出型智能药盒——到点舵机转动 60° 弹出对应药格,蜂鸣 + OLED 提示,老人「被迫」起来吃药。6 路舵机 = 6 个独立药格,DS3231 断电仍能走时,断电一晚也不怕设置丢失。

核心痛点

方案设计

本项目采用「主控 + 时钟 + 输出 + 输入 + 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 次 │
      └───────────────┘

关键技术点

物料清单

总成本 ¥175,比医院级智能药盒便宜 78%。如果暂时用纸盒代替 3D 打印外壳,可压到 ¥145。

🛒 物料清单(2026 年 6 月淘宝均价)
数量物料单价小计备注
1ESP32 DevKit V1(Type-C 口)¥30¥30主控,双核 240MHz
1DS3231 RTC 时钟模块(带 CR2032)¥6¥6±2ppm,断电走时 5 年
6SG90 舵机(9g 塑料齿)¥10¥60每路独立控制一个药格
14×3 矩阵键盘(12 键)¥5¥50-9 + * + #
1SSD1306 OLED 0.96 寸(I2C 4 针)¥12¥12显示时间 + 状态
1有源蜂鸣器模块(3.3V/5V)¥2¥2到点响铃
1S8050 NPN 三极管 + 1kΩ 电阻¥1¥1蜂鸣器驱动(蜂鸣器峰值 100mA)
1 包杜邦线(公-公 + 公-母)¥5¥520cm 30 根
1面包板 400 点(SYB-170)¥5¥5原型验证用
15V 5A 电源适配器(DC 5.5x2.1)¥20¥206 路舵机需要 5V/5A(峰值 4A)
13D 打印外壳(6 格 + 6 舵机仓)¥30¥30可选,纸盒代替可省
总计¥175省 3D 外壳 = ¥145

采购建议

接线与结构

按 ESP32 DevKit V1 的丝印实际位置说明(不是抽象 GPIO 编号):

ESP32 排针位置参考

DS3231 + OLED 接线(I2C 总线)

🔌 I2C 总线接线(DS3231 + OLED 共用)
模块引脚颜色连接目标丝印位置
DS3231SDA黄线ESP32 GPIO 21右侧排针第 11 脚,标 D21
DS3231SCL白线ESP32 GPIO 22右侧排针第 14 脚,标 D22
DS3231VCC红线ESP32 3.3V右侧排针第 1 脚,标 3V3
DS3231GND黑线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 舵机接线

🔌 6 路舵机接线(务必外接 5V/5A)
舵机信号(橙)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 键盘接线

🔌 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 驱动)

🔌 蜂鸣器接线(需 NPN 三极管)
蜂鸣器颜色连接目标丝印位置
+红线外部 5V 电源正极(与舵机同源)DC 5.5x2.1 母头内芯
-黑线S8050 集电极(C 极)
S8050 发射极(E 极)ESP32 GND(共地)
S8050 基极(B 极)1kΩ 电阻 → ESP32 GPIO 5右侧排针第 8 脚,标 D5

⚠️ 关键提醒

核心代码

使用 Arduino 框架,需要安装 5 个库:RTClibServoKeypadAdafruit_SSD1306EEPROM(内置)。

#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();
}

代码说明

调试步骤

按以下顺序逐步调试,每步验证通过再进下一步(避免一次接 30 根线发现全错)。

第 1 步:DS3231 自检(3 分钟)

  1. 接线:仅接 DS3231 的 4 根线(VCC/GND/SDA/SCL)
  2. 烧录 Arduino IDE 自带的 RTClib > DS3231 示例
  3. 打开串口监视器(波特率 115200),应看到 2026/6/24 10:30:45 格式的当前时间每秒刷新
  4. 时间对 → 接线正确;时间一直 2165/165/165 → DS3231 没认出来,检查 SDA/SCL 是否接反
  5. 断电 10 秒后重上电,时间仍正确 → CR2032 电池安装成功

第 2 步:单路舵机测试(5 分钟)

  1. 接线:只接 1 路舵机(信号 → D13,VCC → 外部 5V,GND → ESP32 GND + 电源 GND 共地)
  2. 烧录 Arduino IDE 自带的 Sweep 示例(把 pin 改为 13)
  3. 看到舵机在 0°-180° 之间平滑扫动 → 单路 OK
  4. 舵机抖但不转 → 检查信号线是否接对、共地是否良好
  5. 舵机转但有杂音 → 电压不够(5V 跌到 4V 以下),换 5V/5A 电源

第 3 步:4×3 键盘测试(3 分钟)

  1. 接线:键盘 7 根线全接(行 4 根 + 列 3 根)
  2. 烧录 Keypad > CustomKeypad 示例
  3. 打开串口监视器,按数字键应显示 Key: 1Key: 5
  4. 按键无反应 → 检查行列线是否接反、Keypad.h 的 R1-R4/C1-C3 顺序与物理接线是否一致
  5. 显示乱码 → 行列顺序反了,重新对照丝印

第 4 步:OLED 自检(3 分钟)

  1. 接线:OLED 的 4 根线全接(与 DS3231 共用 I2C 总线)
  2. 烧录 Adafruit_SSD1306 > ssd1306_128x64_i2c 示例
  3. 看到 OLED 显示一个跳动的方块 + 演示文字 → OK
  4. 黑屏 → 用 I2C Scanner 扫地址(0x3C 或 0x3D)、检查 VCC/GND 是否接反

第 5 步:6 路舵机联动(10 分钟)

  1. 6 路舵机全接(信号线分散到 D13/D12/D14/D27/D26/D25)
  2. 烧录下面的最小测试代码(只测舵机联动)
  3. 看到 6 路舵机依次转动 60° → 联动 OK
  4. 某一路不转 → 检查那一路的信号线和 GND
  5. 全部抖动 → 电源功率不够(5V/3A 拖 6 路 SG90 必抖),换 5V/5A

第 6 步:整合测试(15 分钟)

  1. 烧录上面的核心代码(完整版)
  2. 打开串口监视器(波特率 115200)
  3. OLED 应显示 Time HH:MM:SS + Next: #1 08:00(或当前时刻之后的下一餐)
  4. 临时把 EEPROM 里的 slot[0] 时间改成「1 分钟后」(用串口命令或临时改代码),等 1 分钟
  5. 应该看到:蜂鸣响 + OLED 大字 EAT #1 + 1 号舵机转到 60°
  6. # 键 → 蜂鸣停、1 号舵机回到 0°、OLED 恢复正常
  7. * 键 → 全部舵机回到 0°、蜂鸣停(紧急复位)
  8. 拔掉电源 1 分钟后重上电 → 时间仍正确(CR2032 电池工作)+ slot 配置不变(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 路舵机引脚分配表

🔌 6 路舵机引脚分配
药格用途ESP32 GPIO丝印排针位置
#1早 8:00(降压药)GPIO 13D13左侧排针第 3 脚
#2中 12:00(护胃药)GPIO 12D12左侧排针第 4 脚
#3晚 18:00(降糖药)GPIO 14D14左侧排针第 5 脚
#4睡前 22:00(钙片)GPIO 27D27左侧排针第 6 脚
#5备用 14:00GPIO 26D26左侧排针第 7 脚
#6备用 16:00GPIO 25D25左侧排针第 8 脚

触发逻辑与时序

🔄 提醒触发状态机
当前状态触发条件动作状态切换
待机now.minute == slot.minute && now.hour == slot.hour && !已吃蜂鸣 5 秒 + OLED 大字 + 舵机转 60°→ 提醒中
提醒中按 # 键蜂鸣停 + 舵机回 0° + 写 EEPROM→ 待机
提醒中30 分钟未按 #蜂鸣 5 秒 + 舵机再弹 60°→ 提醒中(重复)
提醒中按 * 键(紧急复位)蜂鸣停 + 全部舵机回 0°→ 待机
待机每天 0:00不清 last_taken_day,靠「日」变化自动失效

适用场景对照表

💊 6 路药格典型用药方案
药格时间适用场景
#1 早08:00降压药 / 降糖药 / 抗凝药
#2 上午10:00维生素 D / 钙片(饭后)
#3 中12:00护胃药 / 饭后降糖药
#4 下午15:00帕金森药 / 老年痴呆药(多奈哌齐)
#5 晚18:00晚饭前降压药 / 抗凝药
#6 睡前22:00褪黑素 / 助眠药 / 他汀类

4×3 键盘按键含义

⌨️ 键盘布局与功能
123
456
789
*0#
紧急复位数字 0确认吃药

键盘扩展:当前实现只用了 * 和 # 两个功能键。0-9 数字键可扩展为「时间设置」模式(长按 * 进入 → 输入 4 位数字 HHMM → 按 # 确认 → 写入 EEPROM 对应 slot)。

扩展玩法

金句

"时间是最贵的药,ESP32 让它按时送达。"

本项目总成本 ¥175(省 3D 外壳可压到 ¥145),比医院级智能药盒便宜 78%。6 路 SG90 舵机 + DS3231 断电守时 + 4×3 键盘本地交互 + OLED 实时显示,所有模块都是淘宝 ¥5-30 常见件,3D 外壳可在嘉立创免费下载打印,二次开发空间大(加 WiFi、加语音、加摄像头都能叠)。动手做——给家里的长辈一份「会弹出药盒的关心」。