ほとんどのコンパニオンアプリは、ボタンをタップして値を書き込むだけのシンプルなBLEリモートです。しかし、ディスペンサーはそれほど単純にはいきません。投与は厳密に1回記録されなければならない健康イベントであり、デバイスは標準のGATTプロファイルではなくカスタムのバイナリプロトコルを使用します。「服用を忘れない」ことは、スマートフォンが起動していることに依存してはならないからです。ここでは、タップから永続的で整合性のある状態へのパスを、弊社のウェルネス&フィットネスアプリ開発の一環としてどのように構築したかをご紹介します。
概要
ドメイン コネクテッドウェルネス — スマートサプリメントディスペンシング
コアテクノロジー React Native + Expo, react-native-ble-plx, Zustand, NestJS, MongoDB 8 (トランザクション)
主要機能 カスタムBLEコマンド/テレメトリープロトコル、SMP/CBORファームウェアアップデート、トランザクション方式による投与記録、タイムゾーン対応リマインダー
ステータス 開発フェーズ2へ移行中
課題
当社のクライアントは、スマートフォンから制御するサプリメントディスペンサーデバイスという、コネクテッドウェルネス製品を開発していました。コンパニオンアプリを構築する従来の一般的な方法は、特性を書き込み、その結果を信頼するだけの薄いリモートとして構築することです。これは電球には有効ですが、ディスペンサーにはほとんど当てはまりません。
私たちはそのことを最初から認識しており、だからこそそのようには構築しませんでした。薄型リモートモデルの限界は、チューニングの問題ではなく、構造的なものです。
- 投与は厳密に1回記録される必要があります。物理的な在庫を減らし、1日の投与量に計上され、栄養分析に情報を提供します。二重カウントや書き込みの欠落は、これら3つすべてを破損させます。
- デバイスはプロファイルではなくカスタムプロトコルを使用します。コマンドはフレーム化されたバイナリパケットであり、「1錠を投与する」という意味の既製の特性はありません。
- コマンドとテレメトリーは形状を共有しません。アプリはコンパクトなバイナリコマンドを書き込みますが、異なる特性を介してJSON形式のテレメトリー(バッテリー、充電状況、カートリッジID、水量)を読み取ります。
- ファームウェアのアップデートは同じ接続を使用します。デバイスは、別の管理サービスを介して、追加のツールなしで現場でアップグレードできる必要があります。
- リマインダーはデバイス上に存在できません。「今日はまだ服用していません」という問いは、タイムゾーン、履歴、重複排除に関するものであり、スマートフォンのタイマーではなくサーバーが関与する問題です。
根本原因は単純でした。投与はボタンの押下ではなく、通信上でもデータベース内でもトランザクションです。これを「書き込みっぱなし」と見なすモデルでは、どちらの側面も信頼性を確保できません。
当社のソリューション
各レイヤーが最も得意なことを行うように責任を分担しました。スマートフォンがオーケストレーションを行い、デバイスは弊社の IoTアプリケーション開発プラクティスを通じて定義されたカスタムプロトコルを実行し、クラウドがトランザクション処理の記録システムとなります。リマインダーは完全にサーバー側で処理されます。
アーキテクチャ
- React Native + Expo + Zustand — BluetoothStoreがBLE接続、投与フロー、およびテレメトリーを所有し、AsyncStorageに永続化します。
- react-native-ble-plx — スキャン、接続/再接続、および特性の読み書き。
- カスタムコマンドレイヤー — デバイス用のフレーム化されたバイナリコマンドを構築し、ファームウェアアップデートにはSMPサービスを介してCBORを使用します。
- NestJS API — 投与およびスケジュールエンドポイント。これは、クライアント製品全体で使用しているSaaSアプリケーション開発と同じ基盤の上に構築されています。各投与はMongoDBトランザクション内で記録されます。
- MongoDB 8 (Mongoose) — 記録システムであり、下流の分析が読み取る書き込み時ロールアップコレクションも含まれます。
- NestJS Schedule cronジョブ + Expoプッシュ (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
});
この単一のトランザクションは、投与ログ、月次ロールアップ、物理在庫、1日の投与量、および保留中のリマインダーを更新します。これこそが、投与が単なる書き込みではなくトランザクションである必要がある理由です。
リマインダーはサーバーの関心事
投与が永続的になると、スマートフォンのタイマーでは答えられなかった疑問が日常的なものとなり、アプリではなくcronジョブによって解決されます。
- このユーザーは今日、自分のタイムゾーンで投与しましたか?
- カートリッジは空ですか(交換)?それとも在庫は空ですか(再注文)?あるいはその両方ですか(中止)?
- 以前服用していた健康分野から離れてしまいましたか?
これらの処理は、@nestjs/scheduleの3つのサービス(スケジュール/投与リマインダー、カートリッジ状態リマインダー、クロスヘルス非活動リマインダー)によって行われます。それぞれがdate-fns-tzを使用してユーザーの現地時間を解決し、Mongoで今日の投与状況を確認し、ログコレクションに対して重複排除を行い、ActiveMQ経由でExpoプッシュをキューに入れます。デバイスが関与することはありません。
結果
「投与はトランザクションである」という設計を、シンプルなリモート書き込みの代わりに採用することで、システムが保証できることが変わりました。
- 厳密に1回記録。投与は、ログ、ロールアップ、在庫、および1日の投与量をアトミックに更新します。二重カウントや部分的な書き込みは発生しません。
- フィールドアップグレードを含む文書化されたデバイスプロトコル。コマンドとテレメトリーは第一級オブジェクトであり、ファームウェアのアップデートは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)
私たちのチームからのその他の記事
1. Syncing Apple Health & Health Connect
2. Personalized Recipe Search: Retrieval That Knows What You Should Eat Next

