ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于Arduino的智能药箱:状态机与EEPROM实现可靠服药提醒

基于Arduino的智能药箱:状态机与EEPROM实现可靠服药提醒 简介这是一份基于Arduino的智能药箱完整项目源码与文档面向电子、自动化、通信、计算机等相关专业学生可作为课程设计、毕业设计或入门练手项目。方案围绕服药场景设计正常状态LCD显示时间与箱内温湿度防止药品受潮过热手机通过蓝牙“LMH”连接后可设定定时服药时间舱门到点自动打开若未及时取药则蜂鸣器与警示灯报警取药后停止、放回药瓶后自动关门还支持键盘模式手动控制舱门提升容错性。项目涉及DHT温湿度采集、DS1307实时时钟、I2C液晶显示、蓝牙通信、舵机控制与状态机编程代码结构清晰。资源包共38个文件以.ino工程、.cpp/.h源码和markdown/txt文档为主含JSON与properties配置压缩包仅48KB可在Arduino IDE直接编译运行。已有110人学习下载代码经测试稳定适合需要完整参考方案或快速搭建原型的开发者。1. 智能药箱项目最容易被低估的是「确认」而不是「提醒」把一堆模块拼起来做出一个会响铃的药箱半天就能完成但真正让这个项目从「课程作业」变成「能跑几个月的设备」核心不在响铃和亮灯而在「用户到底吃没吃」。很多智能药箱方案只做定时提醒漏服问题依然存在——因为提醒之后没有确认闭环也没有客观记录。本标题这套基于 Arduino 的智能药箱本质上要解决的是三件事可靠的时间基准、不阻塞主循环的状态机、以及能留存到 EEPROM 的服药记录。适合正在做嵌入式课程设计、参赛作品或家里有慢性病患者的开发者下文从硬件选型讲起一路到状态机、人机交互和可交付的源码结构全程可复现。2. 硬件选型与电路搭建Arduino 主控、DS3231 时钟与多仓位传感2.1 为什么选 Arduino Uno 而不是 Nano 或 ESP32对于药箱这个场景主控的选型第一考虑不是性能而是「长期稳定运行 外设接口够用 调试成本低」。Arduino Uno 的 ATmega328P 虽然只有 2KB SRAM 和 32KB Flash但本项目不需要跑协议栈或图形界面这些资源绰绰有余。Uno 相比 Nano 的优势在于USB 转串口芯片是独立 IC插拔和供电更稳5V 和 3.3V 引脚电流余量大直接驱动蜂鸣器和 LED 不需要额外电平转换。ESP32 不是不能做但对本项目是浪费——你需要额外处理 3.3V 逻辑电平与 5V 外设的兼容、Wi‑Fi 配置、OTA 分区任何一个都会把交付周期拉长一倍。arduino esp32环境搭建本身就够写一篇排错文药箱场景没有远程升级的硬需求就别引入。结论选 Uno理由就三个字省心、够用、好查资料。2.2 时钟模块的选择DS3231 比 DS1302 强在哪智能药箱的一切逻辑依赖「当前时间」所以时钟模块就是整个系统的地基。DS1302 模块便宜但温漂严重而且多数廉价模块没装晶振负载电容一个月能偏几分钟对定时服药来说是灾难。DS3231 内置 TCXO温度补偿晶振年误差通常在 1 到 2 分钟以内且自带电池座断电后靠 CR2032 维持走时。I2C 地址默认是0x68用Wire库直接访问即可。很多教程让你每次上电都通过串口手动校时这一步可以省掉编译烧录时把当前时间写入固件首次启动自动校准。后面给出代码。接线约定DS3231 VCC → 5VGND → GNDSDA → A4SCL → A5 注意模块上的 SQW 引脚在本项目不用接避免中断引脚资源被占用2.3 多仓位检测用簧片开关代替红外对射药箱一般分 3 到 6 个仓位检测「仓门是否打开」比检测「药片是否取出」更实用。红外对射模块需要精确对准灰尘和偏移都会导致误判簧片开关干簧管配合小磁铁贴在仓门上开合即通断抗污能力强而且每个仓位只需一个数字引脚。接线上簧片开关一端接 GND另一端接数字引脚并启用INPUT_PULLUP。这样默认读到HIGH门打开时读到LOW。一个 Uno 有 14 个数字引脚扣除 LCDI2C 占用 A4/A5和按键后做 6 个仓位没问题。模块引脚说明DS3231A4 (SDA), A5 (SCL)I2C 时钟LCD1602 I2CA4, A5与 DS3231 共用 I2C 总线蜂鸣器D3用 tone() 驱动PWM 引脚按键 (确认)D4INPUT_PULLUP按键 (菜单)D5INPUT_PULLUP仓门1 磁簧D6INPUT_PULLUP仓门2 磁簧D7INPUT_PULLUP仓门3 磁簧D8INPUT_PULLUPLED (状态)D9用 PWM 做呼吸效果电源侧注意一点蜂鸣器响铃瞬间电流会拉低 5V 电压如果 LCD 同时刷新可能白屏。接一个 1000µF 电解电容并联在 5V 与 GND 之间正极接 5V能把跌落压住。这是很多人忽略的一步arduino在5v端口接电容的方案就是用在这里。2.4 初始化与校时只在掉电失步时校准DS3231 有lostPower()方法判断后备电池是否耗尽这是校准时间的唯一触发条件。不要在每次setup()里无条件覆盖时间否则换电池或断电重插后之前校准的精度就白费了。#include Wire.h #include RTClib.h RTC_DS3231 rtc; void setup() { Serial.begin(115200); Wire.begin(); if (!rtc.begin()) { Serial.println(F([ERR] DS3231 not found)); while (1); } if (rtc.lostPower()) { // 只在时钟芯片失去后备电源时校时平时不覆盖 rtc.adjust(DateTime(F(__DATE__), F(__TIME__))); Serial.println(F([OK] RTC calibrated from compile time)); } // 关闭 SQW 输出避免干扰 I2C 时序 rtc.writeSqwPinMode(DS3231_OFF); }F(__DATE__)和F(__TIME__)是编译器宏烧录瞬间把当前电脑时间写入固件rtc.adjust()只执行一次。之后每次上电都从 RTC 读时间断电靠电池维持。如果要求秒级精度可在setup()里加一次串口时间同步指令但药箱场景每天偏差 1 秒以内完全可接受。3. 时间触发状态机让药箱在无操作系统环境下不丢提醒3.1 为什么不能用 delay() 写定时逻辑初学者的第一版通常是这样的void loop() { if (remindTimeReached()) { digitalWrite(BUZZER, HIGH); delay(30000); // 响铃 30 秒 digitalWrite(BUZZER, LOW); delay(60000); // 等 1 分钟再检查 } }这个写法有一个致命问题delay(30000)期间整个单片机被阻塞按键扫描、LCD 刷新、仓门检测全部停摆。如果用户在这 30 秒内按确认键程序根本读不到。更隐蔽的是delay(60000)结束时已经到了 1 分钟后下一次时间判断会跳过本该响铃的时间点。药箱不是跑分程序它要求的是「任何时候用户操作都有响应同时定时任务不丢失」。解决手段就是事件循环加时间触发状态机loop()每一圈都执行非阻塞的任务扫描通过millis()判断是否到时间通过状态转移判断应该做什么。3.2 服药状态机空闲、提醒、待确认、超时把药箱的行为拆成四个状态用枚举定义enum MedState { IDLE, // 空闲等待到达服药时间 REMIND, // 蜂鸣器响铃LCD 显示需要服药 WAIT_CONFIRM, // 已提醒等待用户按确认键或仓门打开 OVERDUE // 超过确认窗口记入漏服记录 }; struct MedPlan { uint8_t hour; // 服药小时24 小时制 uint8_t minute; // 服药分钟 uint8_t slot; // 对应仓门编号 1~6 MedState state; }; MedPlan plans[3] { { 8, 30, 1, IDLE }, { 12, 0, 2, IDLE }, { 20, 0, 3, IDLE } };主循环里每秒检查一次时间做状态转移unsigned long prevTick 0; const unsigned long TICK_INTERVAL 1000; void loop() { unsigned long now millis(); if (now - prevTick TICK_INTERVAL) { prevTick now; tickPerSecond(); } // 其他非阻塞任务按键扫描、LCD 刷新、串口命令处理 scanButtons(); } void tickPerSecond() { DateTime dt rtc.now(); for (uint8_t i 0; i 3; i) { switch (plans[i].state) { case IDLE: if (dt.hour() plans[i].hour dt.minute() plans[i].minute) { plans[i].state REMIND; remindStartTime now; } break; case REMIND: // 确认窗口设为 10 分钟窗口内响铃超时进入 OVERDUE if (now - remindStartTime REMIND_TIMEOUT_MS) { plans[i].state OVERDUE; logMissed(plans[i]); } else { playRemindTone(); } break; case WAIT_CONFIRM: // 已确认不重复提醒 break; case OVERDUE: // 记录后转回 IDLE等下一个周期 plans[i].state IDLE; break; } } }几个参数必须仔细设定REMIND_TIMEOUT_MS设成 60000010 分钟比较合理太短用户来不及反应太长会让 OVERDUE 状态的记录延迟太久。TICK_INTERVAL取 1000 毫秒与 RTC 秒字段对齐即可不需要更小——因为药箱的时间粒度就是分钟级。3.3 用 EEPROM 记录漏服时间戳Uno 的 EEPROM 有 1024 字节写入寿命约 10 万次。一天最多漏服 3 次一年 1000 次写入寿命完全够。这里存储一个紧凑结构体而不是用EEPROM.write()逐字节存字符串。#include EEPROM.h struct MissRecord { uint32_t unixTime; // 漏服发生时的 Unix 时间戳 uint8_t slot; // 仓门编号 }; const uint16_t RECORD_ADDR 0; const uint8_t MAX_RECORDS 50; uint8_t recordCount 0; void logMissed(MedPlan plan) { uint16_t addr RECORD_ADDR 2 recordCount * sizeof(MissRecord); MissRecord rec; rec.unixTime rtc.now().unixtime(); rec.slot plan.slot; EEPROM.put(addr, rec); recordCount; EEPROM.write(RECORD_ADDR, recordCount); // 首字节存总数 }EEPROM.put()会按结构体长度连续写入读取时同样用EEPROM.get()。首字节存总数重启后先读出来再按偏移量遍历记录就能在 LCD 上显示「最近 7 天漏服 3 次」这类统计。注意不要在每次漏服时都遍历一遍全片 EEPROMSerial.print输出调试信息即可量产时才考虑磨损均衡。4. 人机交互LCD 菜单、按键防抖与 GSM 短信升级路径4.1 用 LiquidCrystal_I2C 库驱动 LCD1602LCD1602 是药箱最直观的输出设备I2C 版本用 4 根线解决 16 根线的接法。库文件用LiquidCrystal_I2C初始化时指定 I2C 地址和行列数#include LiquidCrystal_I2C.h LiquidCrystal_I2C lcd(0x27, 16, 2); void setup() { lcd.init(); lcd.backlight(); lcd.setCursor(0, 0); lcd.print(F(Smart MedBox)); }I2C 地址不是固定0x27有些模块是0x3F。上电后用I2CScanner扫描一遍确认地址或者直接看模块背面的丝印。用F()宏把字符串放进 Flash省掉 SRAM——2KB 的 SRAM 很紧张LCD 菜单字符串多时容易爆内存。4.2 按键消抖用 Bounce2 库而不是自己写 delay按键最经典的坑是机械抖动按下和释放时引脚电平会在几毫秒内反复跳变。自己写消抖通常是这样if (digitalRead(BTN) LOW) { delay(50); if (digitalRead(BTN) LOW) { // 确认按下 } }这个写法能滤掉抖动但delay(50)会阻塞状态机扫描。更好的做法是用Bounce2库它内部用millis()做时间窗口过滤不阻塞主循环#include Bounce2.h Bounce btnConfirm Bounce(); Bounce btnMenu Bounce(); void setup() { btnConfirm.attach(BTN_CONFIRM_PIN, INPUT_PULLUP); btnConfirm.interval(20); btnMenu.attach(BTN_MENU_PIN, INPUT_PULLUP); btnMenu.interval(20); } void loop() { btnConfirm.update(); btnMenu.update(); if (btnConfirm.rose()) { // 按下确认 confirmServing(); } if (btnMenu.rose()) { // 进入菜单 cycleMenu(); } }rose()是电平从低到高的边沿表示「按下后弹起」的完整动作比fell()更符合按键语义——用户不会在按下瞬间松手弹起时才算是完成一次确认。4.3 从 LCD 菜单到 SIM800C 短信通知菜单状态用uint8_t menuLevel表示0 是待机页1 是查看记录2 是设置时钟。多级菜单不需要真嵌套状态机用 switch 分派即可。药箱场景对菜单的要求很简单能看时间、能确认服药、能查漏服记录不要做超过三层。如果要做远程通知常见做法是接 SIM800C 模块通过串口发 AT 指令。Uno 的硬件串口被 USB 占用SIM800C 可以接软件串口#include SoftwareSerial.h SoftwareSerial sim800(10, 11); // RX, TX void sendSMS(const char* number, const char* message) { sim800.println(F(ATCMGF1)); delay(200); sim800.print(F(ATCMGS\)); sim800.print(number); sim800.println(F(\)); delay(200); sim800.println(message); sim800.write(0x1A); // CtrlZ 发送 }SIM800C 用 5V 供电会发热建议单独用 3.7V 锂电池或稳压模块供电地与 Uno 共地即可。短信内容别写中文——SoftwareSerial 在 9600 波特率下传输中文 PDU 容易丢字节用拼音或英文更稳。5. 工程交付源码目录、串口调试指令与验证清单5.1 源码目录结构如何组织才叫「有文档说明」「项目源码文档说明」这个交付物不只是把.ino文件扔进文件夹而是要让接手的人能在 10 分钟内跑起来。常见做法是把代码按驱动、应用、配置分层MedBox/ ├── MedBox.ino # 入口初始化硬件注册任务 ├── include/ │ └── config.h # 引脚定义、服药时间表、EEPROM 地址 ├── src/ │ ├── drivers/ │ │ ├── ds3231_drv.h │ │ ├── lcd_drv.h │ │ └── buzzer_drv.h │ ├── app/ │ │ ├── scheduler.cpp # 状态机与时间触发逻辑 │ │ ├── ui_menu.cpp # LCD 菜单与按键处理 │ │ └── log_record.cpp # EEPROM 读写 │ └── comm/ │ └── serial_cmd.cpp # 串口指令解析 └── docs/ ├── hardware_wiring.md # 接线图与 BOM 清单 ├── serial_protocol.md # 串口指令集 └── test_checklist.mdconfig.h是最重要的文件把散落在代码里的魔数集中管理。服药时间表定义为MED_PLAN_TABLE二维数组用户按格式修改即可不需要翻代码。5.2 串口调试指令让设备状态可查可改烧录时插着 USB串口是唯一能实时观察内部状态的手段。定义一组简单的文本指令比 JSON 解析更适合 328P——解析ATRESET比解析{cmd:reset}省几十字节 SRAM指令功能示例GET TIME读取 RTC 当前时间2024-01-01 08:30:00SET TIME 2024-01-01 08:30:00校准 RTC返回OKGET STATE查看所有仓位状态SLOT1:IDLE, SLOT2:REMINDGET MISS读取漏服记录3 records, last at ...FORCE REMIND 1手动触发 1 号仓位提醒用于测试蜂鸣器和 LCD实现时用Serial.readStringUntil(\n)读整行再按空格拆字段每收到一条指令后必须触发一次 LCD 刷新这样调试时能看到设备即时反应。5.3 最后补上「人为 error 的防呆设计」交付一个智能药箱最容易被忽略的是「用户按错键」和「断电重启」两个场景。防呆设计的两个关键点1. 确认键只对当前有效的提醒生效。如果用户在没有提醒的时候按确认不应产生任何副作用LCD 显示NO PENDING ALARM。否则误触一次就会清掉提醒状态等于漏服没记录。2. 断电重启后不重复响铃。在 EEPROM 里单独开一个字节存「最后一次响铃的日期」重启后比对 RTC 日期同一天不重复触发。这个字段用EEPROM.write()每次覆盖即可一天最多写入一次寿命无忧。3. 蜂鸣器用非阻塞发声。tone()本身不阻塞但如果循环里加delay()去控时长又会卡状态机。用一个remindToneStep变量记录 0.5 秒嘀、1 秒停的节奏在tickPerSecond()里改tone()和noTone()即可。4. 4 个电源不稳的坑。药箱一般采用 USB 电源供电如果插上电机或舵机蜂鸣器响铃瞬间复位是常见故障。除了并联大电容还要检查蜂鸣器是否用了tone()的 PWM 引脚——D3 与中断 1 共用如果接了编码器会冲突本项目不接中断没问题。交付前按下面四条验证断开 USB 电源装好电池等 RTC 走时 5 分钟后重新上电确认时间不跳变手动改系统日期到服药时刻观察蜂鸣器响、LCD 显示、按键确认三件事是否依次发生连续按 50 次确认键不应出现卡死或无响应用 USB 转串口接出GET MISS确认漏服记录在重启后仍可读出。四步通过这套基于 Arduino 的智能药箱才算真正「可交付」而不仅是「能演示」。本文还有配套的精品资源点击获取
返回列表