摘要
针对传统用药提醒依赖手机、药仓位置容易变化以及多药仓状态难以统一管理等问题,本文设计并实现了一套基于 MAX32655FTHR 的模块化智能药仓提醒系统。系统由主控开发板、可拆装药仓模块、RTC、BLE、128×64 OLED、RGB 指示灯和 Android App 组成。
设备端通过级联电路识别药仓 ID、连接状态和开盖状态,并依据本地 RTC 完成提醒判断。手机端通过 BLE 完成设备连接、时间同步、提醒配置和状态查看。系统将提醒配置与逻辑 ID 绑定,将当前状态与物理槽位绑定,使药仓在插拔或调整顺序后仍能保持正确的提醒关系。
关键词: MAX32655FTHR;模块化药仓;RTC;BLE;OLED;Android App
1 项目概述
1.1 任务选择
所选任务:模块化智能药仓提醒系统设计与实现。
1.2 项目背景
手机 App 适合进行提醒配置,但手机后台限制、蓝牙断开和 App 退出可能影响提醒可靠性。因此,本项目将提醒判断放在药仓设备端,由单片机和本地 RTC 独立完成提醒;手机主要承担配置和信息查看功能。
项目采用可拆装药仓结构,多个药仓通过级联方式连接到主控。系统能够识别当前已接入的药仓,并根据药仓 ID 管理每日提醒时间。
1.3 项目目标
- 实现最多 8 个药仓的识别、开盖检测和状态显示。
- 实现基于本地 RTC 的每日提醒和开盖确认。
- 实现 BLE Android App,完成提醒时间配置和设备状态查看。
1.4 系统功能
系统主要功能包括:
- 药仓 ID 和级联状态识别;
- 药仓开盖检测;
- 每日提醒时间配置;
- RTC 本地提醒和 RGB 灯提示;
- OLED 显示当前时间、药仓 ID 和状态;
- BLE 连接、时钟同步、配置写入和状态通知;
- 药仓插拔后的重新扫描和状态更新。

2 系统方案与设计思路
2.1 系统方案

2.2 设计思路
(1)提醒本地化
Android App 通过 BLE 写入提醒时间和设备时钟,设备将配置保存到 Flash,并由本地 RTC 完成提醒判断。手机暂时断开时,设备仍可独立工作。
(2)身份与位置分离
提醒时间按照药仓逻辑 ID 保存,物理槽位只表示药仓在级联链中的当前位置。药仓重新插拔或调整顺序后,设备重新扫描拓扑,并按照 ID 恢复提醒关系。
(3)事件与状态分离
开盖和插拔信号只触发唤醒事件,业务层延时后再扫描药仓。通过连续一致的扫描结果确认稳定状态,减少连接器抖动造成的误判。
(4)适配小尺寸显示屏
OLED 分辨率为 128×64,状态页采用横屏八列布局,从左到右对应物理槽位 1 至 8。每列显示十进制 ID 和开盖图标,不存在的槽位直接刷黑,列间采用分隔线。
3 硬件设计






3.1 硬件组成
硬件 | 功能 |
|---|---|
MAX32655FTHR | 系统主控,提供 BLE、RTC、GPIO、SPI 和 I2C |
SSD1312 128×64 OLED | 显示时间、药仓数量、ID 和开盖状态 |
74HC165 | 读取药仓 ID、连接状态和开盖状态 |
74HC164 | 输出 RGB 指示灯控制信号 |
CC6201ST | 检测药仓盖子开合 |
W25 Flash | 保存提醒配置 |
MAX20303 PMIC | 主控板电源管理 |
PMOS 负载开关 | 控制 OLED 和 RGB 等负载电源 |
RGB LED | 表示提醒、确认和异常状态 |
74LVC2G17、74AHC1G86、74LVC1G32 | 热插拔检测、脉冲处理和唤醒级联 |
3.2 硬件工作原理
主控通过 SPI 读取 74HC165 输入链,获得每个药仓的 3-bit ID、下一级连接状态和开盖状态。74HC164 输出串行 RGB 控制帧,并将数据传递给下一级药仓。OLED 通过 I2C 连接在主控板上,用于显示系统状态。
系统采用电源分层设计。VCC_3V3_AON 为药仓检测和唤醒逻辑供电,LED_POWER 为 OLED 和 RGB 等负载供电。系统空闲时可以关闭显示和灯光,同时保留必要的检测和唤醒能力。
3.3 开盖唤醒
每个药仓使用 `CC6201ST` 霍尔开关检测盖子。闭盖输出低电平,开盖输出高电平。该信号同时送入 74HC165,作为开盖状态数据;也送入 `74LVC1G32` 或门,作为本地唤醒信号。
开盖唤醒采用高电平级联:本地开盖信号与下游传来的 `COVER_WAKE` 经过或门合并,再向上游输出。任意一个药仓开盖后,高电平会逐级传到主控,触发主控唤醒;只要仍有药仓开盖,唤醒线就保持高电平。主控唤醒后再读取 74HC165,确认具体是哪一个药仓开盖。

3.4 热插拔检测
药仓插拔通过下一级存在检测线判断。下一级插入时,`LINK_DET_N_LOCAL` 被拉低;下一级拔出后,该信号由上拉恢复高电平。这个稳定电平送入 74HC165,用于扫描药仓链路是否存在。
为了让插入和拔出都能唤醒主控,`LINK_DET_N_LOCAL` 分成两路:一路直接经过施密特整形得到快速边沿,另一路经过 RC 延时和施密特整形得到延迟边沿。两路送入异或门,在电平变化后的短暂时间内,两路电平不同,异或输出为高电平;延时结束后两路恢复一致,输出回到低电平。因此一次插入或拔出都会被转换成一个高电平脉冲。

各药仓的插拔脉冲再通过 74LVC1G32 或门向上游级联,最终输入主控的 `PLUG_WAKE`。主控收到脉冲后重新读取首仓存在电平和 74HC165 数据,确认实际药仓数量、ID 以及链路状态。也就是说,或门负责传递唤醒电平,RC 加异或负责把电平变化变成脉冲,最终拓扑仍以重新扫描结果为准。
4 原理图与 PCB 设计
项目使用 KiCad 完成药仓模块和主控板相关的原理图、PCB 设计。硬件设计内容包括电源部分、74HC165 输入链、74HC164 输出链、开盖检测、热插拔检测以及唤醒级联电路。
药仓模块使用上下游连接器进行级联,电源、串行时钟、输入链、输出链和唤醒信号按照模块结构布置。PCB 布局围绕主控接口、药仓连接器和信号级联展开,便于多个药仓连续安装。

5 软件设计
5.1 软件平台
固件位于 fw/CM4,使用 ADI MSDK、Cordio BLE 和 WSF 事件框架。设备作为 BLE Peripheral/GATT Server,Android App 作为 BLE Central/GATT Client。
Android App 使用 Kotlin、Jetpack Compose 和 Android BluetoothGatt,完成 BLE 扫描、连接、数据读写和界面显示。
5.2 软件流程

5.3 固件关键模块
模块 | 主要功能 |
|---|---|
| 完成系统初始化和 WSF 主循环 |
| 管理药仓拓扑、提醒状态、显示模型和 RGB 输出 |
| 读取 RTC、设置时间和执行分钟调度 |
| 保存和恢复提醒配置 |
| 读取 74HC165 和写入 74HC164 |
| 提供 Status、Config、Clock GATT 特征 |
| 完成 OLED 通信和显示适配 |
到达设定时间后,当前存在的对应药仓进入 DUE 状态并亮红灯;超过一定时间但仍在 5 分钟窗口内时进入 MISSED;用户打开对应药仓后进入 ACKNOWLEDGED。超过 5 分钟后清除未处理提醒并关闭 RGB 灯。
5.4 单片机关键代码说明
5.4.1 启动入口与事件分发
main()负责完成板级初始化、应用驱动初始化和 BLE 平台初始化。初始化完成后,主循环只取出检测事件、更新定时器、分发 WSF 事件和轮询日志,不在中断中执行 SPI、Flash 或 BLE 操作。
int main(void)
{
PinInit();
PeripheralInit();
AppLogInit();
AppDriversInit();
BlePlatformInit();
while (1) {
uint32_t events = BoardDetectTakeEvents();
if (events != 0U) {
MedcabNotifyDetectEvents(events);
}
WsfTimerSleepUpdate();
wsfOsDispatcher();
AppLogPoll();
}
}
这种结构将初始化、硬件事件和业务处理分离。GPIO ISR 只负责置位事件,真正的扫描和状态变更由 MedcabHandler()在 WSF 上下文中完成,避免中断执行时间过长。
5.4.2 药仓输入快照与 ID 解析
medcabReadSnapshot()先读取首仓存在信号,再决定是否读取 74HC165 输入链。首仓不存在时直接生成空快照,避免对已经断开的级联链路进行无效读取;首仓存在时才执行一次 SPI 扫描。
static int medcabReadSnapshot(medcab_snapshot_t *snapshot)
{
int result = BoardDetectGet(BOARD_DETECT_HOTPLUG_FIRST,
&snapshot->hotplug_first);
if (result != E_NO_ERROR) {
return result;
}
if (!snapshot->hotplug_first) {
memset(snapshot->raw, 0, sizeof(snapshot->raw));
return E_NO_ERROR;
}
return Shift74hcRead(snapshot->raw, sizeof(snapshot->raw));
}
每个 74HC165 原始字节的低 3 位是 ID,0x08表示后面仍有药仓,0x10表示当前药仓开盖。软件遇到 ID 7立即停止,当前值及其后的数据不加入有效拓扑;连续两次扫描结果一致后才提交新的拓扑,避免插拔抖动造成错误状态。
static bool medcabNextConnected(uint8_t raw)
{
return (raw & MEDCAB_RAW_PRESENT) == 0U;
}
static bool medcabCoverOpen(uint8_t raw)
{
return (raw & MEDCAB_RAW_COVER_OPEN) != 0U;
}
5.4.3 拓扑变化与开盖确认
medcabApplySnapshot()将新快照与上一份稳定快照比较,只保留仍处于相同物理位置的提醒状态。新插入的药仓初始化为 IDLE,移除的药仓清除待处理状态;因此缺失药仓不会在到期后产生提示,重新插入时也不会补发已经错过的提醒。
对开盖状态的处理以物理槽位为单位。当检测到 DUE或 MISSED药仓由闭盖变为开盖时,只确认该槽位,并将其置为 ACKNOWLEDGED;同一个 ID 的其他物理药仓不会被同时确认。关盖和普通开盖事件则生成短时状态页,触发 OLED 刷新。
5.4.4 RTC 提醒状态机
MedcabProcessRtcTime()将 RTC 秒数转换为中国标准时间,并按 ID 遍历每日配置。只有当前拓扑中存在的 ID 才参与判断。提醒时间到达后,1 分钟内进入 `DUE`,超过 1 分钟但仍在 5 分钟窗口内进入 `MISSED`,超过窗口则直接清除,不再补发。
if (!medcabIdPresent(id)) {
medcabCb.idReminderDay[id] = MEDCAB_DAY_INVALID;
medcabCb.idReminderState[id] = MEDCAB_SLOT_IDLE;
continue;
}
reminderAge = localEpochSeconds - dueSeconds;
if (reminderAge >= MEDCAB_REMINDER_WINDOW_SECONDS) {
medcabSetReminderForId(id, MEDCAB_SLOT_IDLE, day);
continue;
}
medcabSetReminderForId(id,
reminderAge <= 60UL ? MEDCAB_SLOT_DUE : MEDCAB_SLOT_MISSED,
day);
提醒状态以 ID 记录当天是否已经触发,再映射到当前实际存在的物理槽位。这样可以处理设备重启、药仓插拔、重复 ID 和提醒超时等情况;在提醒有效期间发生药仓状态变化时,UI 计时器会重新开始 5 分钟倒计时。
5.4.5 RGB 输出帧
medcabRenderLeds()根据每个物理槽位的提醒状态生成 74HC164 输出帧。因为数据从首仓向下游移位,最远端药仓的字节需要先写入,因此代码使用 length - 1 - slot反向排列。共阳 RGB 为低电平点亮,未使用的输出预先填充熄灭值。
memset(frame, MEDCAB_LED_OFF, sizeof(frame));
if (!medcabCb.displaySleeping) {
for (uint8_t slot = 0U;
slot < medcabCb.committedTopology.active_count;
slot++) {
frame[length - 1U - slot] = medcabLedColor(slot, warning);
}
}
if (!medcabCb.ledFrameValid ||
(memcmp(frame, medcabCb.lastLedFrame, sizeof(frame)) != 0)) {
Shift74hcWriteRaw(frame, length);
}
medcabLedColor()的状态映射为:待提醒和超时提醒显示红色,普通开盖显示绿色,拓扑异常显示黄色,闭盖空闲或睡眠状态关闭灯光。只有输出帧变化时才重新移位,减少无效总线操作。
5.4.6 128x64 OLED 绘制
u8g2PortDrawSlotStatus()将屏幕宽度平均分为 8 列,每列固定宽度 128 / 8 = 16像素。不存在的槽位先用黑色矩形覆盖状态区;存在的槽位居中绘制十进制 ID,再绘制开盖图标,最后绘制列间分隔线。
static const uint8_t columnWidth = APP_SSD1306_WIDTH / MEDCAB_SLOT_COUNT;
for (uint8_t slot = 0U; slot < MEDCAB_SLOT_COUNT; slot++) {
uint8_t x = slot * columnWidth;
if ((slot >= model->slot_count) ||
(model->topology_ids[slot] == UINT8_MAX)) {
u8g2_SetDrawColor(&appU8g2, 0U);
u8g2_DrawBox(&appU8g2, x, 14U, columnWidth,
APP_SSD1306_HEIGHT - 14U);
u8g2_SetDrawColor(&appU8g2, 1U);
continue;
}
snprintf(id, sizeof(id), "%u", model->topology_ids[slot]);
u8g2_DrawStr(&appU8g2, centeredX, 29U, id);
u8g2PortDrawCoverIcon(x, model->topology_cover_open[slot]);
}
状态页只显示实际存在的药仓,ID 使用十进制,开盖和闭盖使用图标区分。顶部标题和状态区使用固定坐标,保证动态数据不会改变列宽,也不会在 128x64 屏幕上发生文字重叠。
5.4.7 BLE 配置写入
blePeripheralMedcabWriteCback()对 App 写入的 Config数据进行长度、时间范围和 Flash 写入检查。数据包由 7 个 little-endian uint16_t组成,0xffff表示禁用。只有完整配置通过校验并成功写入双副本 Flash 后,才调用 MedcabApplyConfig()替换运行配置。
if (len != sizeof(blePeripheralMedcabConfigValue)) {
return ATT_ERR_LENGTH;
}
for (uint8_t id = 0U; id < MEDCAB_ID_COUNT; id++) {
uint8_t byte = id * sizeof(uint16_t);
config.minute_of_day[id] = (uint16_t)pValue[byte] |
((uint16_t)pValue[byte + 1U] << 8U);
}
if (!MedcabConfigIsValid(&config)) {
return ATT_ERR_VALUE_RANGE;
}
if (MedcabConfigStoreSave(&config) != E_NO_ERROR) {
return ATT_ERR_UNLIKELY;
}
MedcabApplyConfig(&config);
return ATT_SUCCESS;
这种整包提交方式避免了逐个设置时间时出现部分配置生效。`Clock` 特征使用 4 字节 Unix 秒写入 RTC,Status特征则在拓扑、提醒或开盖状态变化后通过 WSF 消息排队发送通知。
5.4.8 事件与低功耗处理
GPIO 中断只记录首仓检测、插拔脉冲和开盖事件,主循环调用 BoardDetectTakeEvents()取出事件后交给 `MedcabNotifyDetectEvents()`。药仓扫描、去抖、Flash、OLED 和 BLE 操作均在 WSF 事件处理上下文执行,避免中断时间过长。
提醒超时或普通空闲达到 30 秒时,控制器清空 OLED 并将 RGB 输出帧改为全灭;开盖状态和插拔事件仍保持扫描。设备从假休眠恢复后,先完成药仓扫描和状态确认,再重新绘制显示内容,避免使用休眠前的过期拓扑。
5.5 BLE 与 Android App
BLE 设备作为 Peripheral,手机作为 Central。业务服务包含 Status、Config 和 Clock 三个特征:
- Status:读取和通知 RTC、药仓数量、ID、提醒状态及开盖状态。
- Config:读取或整包写入 7 个 ID 的每日提醒时间。
- Clock:写入手机当前时间,对设备 RTC 进行校时。
Android App 使用 Kotlin、Jetpack Compose、BluetoothGatt 和协程实现。设备页负责扫描和连接,闹钟页使用时间选择器设置每日提醒,状态页展示设备实时状态。App 不自行判断是否到点,也不直接控制 RGB 灯。
5.6 调试软件
固件使用 CodeFusion Studio™ 进行工程管理、代码查看、固件调试。调试过程中重点查看 RTC 恢复、药仓扫描、ID 解码、开盖状态、提醒触发和显示更新日志。
Android App 使用 Android Studio 进行开发和调试,重点验证 BLE 扫描、GATT 服务发现、配置读写、时钟同步和状态通知。

6 设备界面设计
6.1 单片机 UI
OLED 采用横屏固定八列布局。顶部显示 SLOTS N/8,每列显示物理槽位的十进制 ID 和开盖图标;不存在的药仓整列刷黑,列之间使用分隔线。RGB 灯用于表达提醒颜色,OLED 用于表达位置和状态。
首页用于显示当前时间和下一次提醒信息:


状态页固定显示 8 个物理槽位,避免药仓数量变化造成界面跳动:


提醒页面显示待处理药仓,配合 RGB 红灯提示;用户打开对应药仓后完成确认:

6.2 手机 App
Android App 包含设备页、闹钟页和状态页。设备页负责扫描和连接:

闹钟页显示十进制 ID 0..6,使用 24 小时制时间选择器配置提醒:


状态页显示设备时间、药仓数量、ID、提醒状态和开盖状态。状态变化弹窗固定显示 8 列,不存在的药仓显示黑色:


7 遇到的难点及解决方法
7.1 RTC 上电状态不稳定
RTC 在上电或复位后可能需要一定时间才能稳定读取。系统在进入业务逻辑前先等待并重试读取 RTC;只有读到有效时间后才参与提醒判断,无效时显示 SET CLOCK。
7.2 级联药仓扫描容易受到抖动影响
药仓插拔时连接器和检测信号可能短暂变化。系统将中断和业务处理分开,事件发生后延时约 50 ms,再进行输入链扫描,并使用连续一致的结果确认当前拓扑。
7.3 药仓位置变化导致提醒错配
如果直接按物理位置保存提醒时间,药仓换位后可能对应错误的提醒。系统将提醒配置绑定到逻辑 ID,物理槽位只保存当前状态,从而避免位置变化导致的错配。
7.4 128×64 屏幕显示空间有限
屏幕无法同时显示大量文字和完整状态信息。系统固定使用八列布局,采用十进制 ID、开盖图标、缺仓刷黑和列间分隔线,并将提醒颜色交给 RGB 灯表达。
7.5 提醒重复触发和过期补发
设备重启或药仓重新插入时,旧提醒可能被重复触发。系统记录当天提醒状态和 5 分钟有效窗口,只对当前存在的药仓提醒;超过窗口或缺仓期间的提醒不再补发。
8 心得体会、意见与建议
通过本项目,我对嵌入式系统中硬件、固件、BLE 和 Android App 的协同关系有了更直观的认识。定时提醒看似简单,但实际涉及 RTC 初始化、级联扫描、状态机、异常处理、低功耗和 UI 设计等多个方面。
项目实施过程中,提前划分逻辑 ID、物理槽位和瞬时事件非常重要。对于资源有限的显示屏,也应先确定信息优先级,再设计固定布局,而不是简单缩小所有文字。
建议后续活动提供统一的硬件接口说明、基础示例工程和阶段性测试要求,帮助参赛者更早完成电源、通信和显示等基础验证。后续项目将继续通过真实硬件测试确认 RTC、药仓级联、BLE、OLED、RGB 和低功耗功能。