大多数配套应用程序都是简单的BLE遥控器:点击按钮,写入值,完成。分配器不能如此简单——分配是一个必须精确一次记录的健康事件,设备使用自定义二进制协议而非标准GATT配置文件,并且“不要错过剂量”不能依赖于手机处于唤醒状态。以下是我们如何从一次点击构建到持久、可协调状态的路径,作为我们健康与健身应用开发工作的一部分。
概览
领域 连接健康——智能补充剂分配
核心技术 React Native + Expo, react-native-ble-plx, Zustand, NestJS, MongoDB 8(事务)
关键能力 自定义BLE命令/遥测协议,SMP/CBOR固件更新,事务性分配记录,时区感知提醒
状态 进入开发的第二阶段
挑战
我们的客户正在创建一个连接的健康产品:一个从手机控制的补充剂分配设备。构建配套应用程序的常规方法——以及大多数应用程序的构建方式——是作为一个简单的遥控器,写入特征并信任结果。这适用于灯泡,但几乎不适用于分配器。
我们在开始时就知道这一点,这就是为什么我们没有那样构建它。简单遥控模型的限制是结构性的,而不是调优问题:
- 分配必须精确记录一次。它减少物理库存,计入每日剂量,并提供营养分析。双重计数或丢失写入会破坏这三者。
- 设备使用自定义协议,而不是配置文件。命令是框架二进制数据包;没有现成的特征表示“分配一片药片”。
- 命令和遥测不共享形状。应用程序写入紧凑的二进制命令,但读取回JSON遥测(电池、充电、墨盒ID、水量)——通过不同的特征。
- 固件更新通过相同的连接进行。设备必须能够在现场升级,通过单独的管理服务,而无需第二个工具。
- 提醒不能存在于设备上。“你今天还没有剂量”是一个关于时区、历史和去重的问题——这是服务器的关注点,而不是手机计时器。
根本原因很简单:分配不是按钮按下,而是一个事务——无论是在网络上还是在数据库中。将其视为一次性写入的模型无法使任何一半可靠。
我们的解决方案
我们分配责任,以便每一层都能发挥其最佳作用:手机进行协调,设备执行通过我们的 IoT应用开发实践定义的自定义协议,云是事务记录系统——提醒完全由服务器端处理。
架构
- React Native + Expo + Zustand — 一个BluetoothStore拥有BLE连接、分配流程和遥测,持久化到AsyncStorage。
- react-native-ble-plx — 扫描、连接/重新连接和特征读/写。
- 自定义命令层 — 为设备构建框架二进制命令,并使用CBOR通过SMP服务进行固件更新。
- NestJS API — 分配和计划端点,基于我们在客户产品中使用的相同SaaS应用开发基础构建;在MongoDB事务中记录每次分配。
- MongoDB 8 (Mongoose) — 记录系统,以及下游分析读取的写时汇总集合。
- NestJS Schedule crons + Expo push(通过ActiveMQ/STOMP排队)— 时区感知提醒,通过Mongo日志去重。
- Sentry(移动)— 应用程序中的生产错误跟踪。

自定义线协议
任何BLE产品的难点在于设备不使用标准语言。我们的设备公开了一个命令/遥测服务和一个独立的固件(SMP)服务。应用程序使用react-native-ble-plx连接,然后订阅遥测——它以JSON形式到达通知特征:
// BluetoothStore.ts — react-native-ble-plx + Zustand
const device = await bleManager.connectToDevice(deviceId, { autoConnect: true });
await device.discoverAllServicesAndCharacteristics();
// Telemetry: JSON over the notify characteristic (UUIDs are app-side constants).
bleManager.monitorCharacteristicForDevice(device.id, CH_SERVICE_UUID, TX_UUID, (_err, c) => {
if (!c?.value) return;
const t = JSON.parse(base64ToString(c.value));
// { battery_percentage, charging_status, cartridge_id, water_qty, sequence, timestamp }
set({ batteryPercentage: Number(t.battery_percentage), chargingStatus: t.charging_status });
});
分配是一个紧凑的框架二进制命令——一个开始标记,一个命令ID,一个长度,墨盒字节,一个读/写标志和一个结束标记——base64编码并写入命令特征。(此处省略了字面UUID和框架标记。)
// Frame shape: [SOF] [CMD] [LEN] [DATA] [RW] [EOF] — built by the command service.
const command = SNBCommandService.buildDispenseNutritionCommand(cartridgeId); // CMD = DISPENSE
await bleManager.writeCharacteristicWithResponseForDevice(
device.id, CH_SERVICE_UUID, RX_UUID,
SNBCommandService.uint8ArrayToBase64(command),
);
应用程序等待设备的确认(它从响应框架中匹配命令ID)后才将分配视为真实——然后才在服务器端记录它。固件更新使用相同的连接但不同的语言:通过SMP服务的分段CBOR负载。
从点击到记录
确认的分配通过一个端点成为持久状态——POST /dispense/tablet/:cartridgeId——它在单个MongoDB事务中写入,因此六个效果要么全部发生,要么都不发生:
// dispense.service.ts — record exactly once, atomically
await session.withTransaction(async () => {
await this.dispensed.create([{ cartridgeId, cartridgeModalId, dispensedBy: userId, dispensedAt }], { session });
// write-time roll-up: totalDaysConsumed only increments on the first dose of the day
await this.monthly.findOneAndUpdate(
{ userId, cartridgeModalId, month, year },
{ $inc: { totalTablets: 1, ...(firstDoseToday ? { totalDaysConsumed: 1 } : {}) } },
{ upsert: true, session },
);
await this.cartridge.updateOne({ cartridgeId }, { $inc: { tablets: -1 } }, { session });
await this.dailyDose.updateOne({ userId, dateLocal }, { $inc: { tabletsTaken: 1 } }, { upsert: true, session });
await this.notifications.cancelPending(userId, 'SUPPLEMENT_REMINDER', { session }); // mark IS_MISSED
});
单个事务更新分配日志、每月汇总、物理库存、每日剂量计数和任何待处理的提醒——这正是为什么分配必须是事务而不是写入。
提醒是服务器的关注点
一旦分配变得持久,手机计时器无法回答的问题就变得常规——它们由cron作业回答,而不是应用程序:
- 这个用户今天在他们的时区内剂量了吗?
- 墨盒是否为空(更换)或库存为空(重新订购),或两者(停止)?
- 他们是否偏离了他们曾经服用的健康领域?
三个@nestjs/schedule服务处理这些——一个计划/剂量提醒、一个墨盒状态提醒和一个跨健康不活跃提醒。每个服务使用date-fns-tz解析用户的本地时间,检查Mongo是否在今天剂量,去重对比日志集合,并通过ActiveMQ排队一个Expo推送。设备从未参与。
结果
设计为“分配是事务”而不是简单的遥控写入改变了系统可以保证的内容:
- 精确一次记录。分配原子性地更新日志、汇总、库存和每日剂量——没有双重计数,没有部分写入。
- 一个记录在案的设备协议,包括现场升级。命令和遥测是第一类,固件更新通过SMP/CBOR通过相同的连接传输。
- 准确的提醒,而不是近似的。时区感知、历史感知和服务器端去重——因此无论应用程序是否打开,它们都会触发。
- 可以信任的分析。因为每次分配在写入时汇总,所以提供图表和依从性的数据始终与日志一致。
API、cron和推送队列运行在通过我们的云基础设施服务管理的基础设施上,因此随着设备群和用户基础的扩展,相同的可靠性保证依然有效。
技术栈:React Native · Expo · TypeScript · react-native-ble-plx · Zustand · CBOR (SMP firmware) · NestJS · MongoDB 8 · Mongoose (transactions) · @nestjs/schedule · ActiveMQ / STOMP · Expo Server SDK · JWT · AWS S3 · AWS SES · Sentry (mobile)

