معظم التطبيقات المصاحبة هي أجهزة تحكم رقيقة عبر BLE: اضغط على زر، اكتب قيمة، انتهى. لا يمكن أن يكون الموزع بهذه الرقة - فالتوزيع هو حدث صحي يجب تسجيله مرة واحدة فقط، ويتحدث الجهاز بروتوكول ثنائي مخصص بدلاً من ملف تعريف GATT القياسي، و"لا تفوت جرعة" لا يمكن أن تعتمد على أن يكون الهاتف مستيقظًا. إليك كيف بنينا الطريق من نقرة إلى حالة دائمة قابلة للتسوية، كجزء من عملنا في تطوير تطبيقات الصحة واللياقة.
نظرة عامة
المجال: الصحة المتصلة — توزيع المكملات الذكية
التقنيات الأساسية: React Native + Expo, react-native-ble-plx, Zustand, NestJS, MongoDB 8 (المعاملات)
القدرات الرئيسية: بروتوكول أوامر/تليمترية BLE مخصص، تحديثات البرامج الثابتة SMP/CBOR، تسجيل التوزيع المعاملاتي، تذكيرات مدركة للمنطقة الزمنية
الحالة: دخول المرحلة 2 من التطوير
التحدي
كان عميلنا يبتكر منتجًا صحيًا متصلًا: جهاز يوزع المكملات، يتم التحكم فيه من الهاتف. الطريقة التقليدية لبناء التطبيق المصاحب — والطريقة التي تُبنى بها معظم التطبيقات — هي كجهاز تحكم رقيق يكتب خاصية ويثق في النتيجة. هذا يعمل لمصباح كهربائي، ولا يعمل تقريبًا لموزع.
كنا نعلم ذلك منذ البداية، ولهذا لم نبنيه بهذه الطريقة. حدود نموذج جهاز التحكم الرقيق هي هيكلية، وليست مشاكل ضبط:
- يجب تسجيل التوزيع مرة واحدة فقط. إنه ينقص المخزون الفعلي، ويحسب ضمن الجرعة اليومية، ويغذي تحليلات المغذيات. العد المزدوج أو الكتابة المفقودة تفسد الثلاثة.
- يتحدث الجهاز بروتوكول مخصص، وليس ملف تعريف. الأوامر هي حزم ثنائية مؤطرة؛ لا توجد خاصية جاهزة تعني "وزع قرصًا واحدًا".
- الأوامر والتليمترية لا تشترك في شكل. يكتب التطبيق أوامر ثنائية مضغوطة ولكنه يقرأ تليمترية JSON (البطارية، الشحن، معرف الخرطوشة، كمية الماء) — عبر خصائص مختلفة.
- تحديثات البرامج الثابتة تستخدم نفس الاتصال. يجب أن يكون الجهاز قابلًا للترقية في الميدان، عبر خدمة إدارة منفصلة، دون أداة ثانية.
- لا يمكن أن تعيش التذكيرات على الجهاز. "لم تقم بالجرعة اليوم" هو سؤال عن المناطق الزمنية، التاريخ، وإزالة التكرار — مسألة خادم، وليس مؤقت هاتف.
السبب الجذري كان بسيطًا: التوزيع ليس ضغط زر، إنه معاملة — سواء على السلك أو في قاعدة البيانات. النموذج الذي يعامله ككتابة نسيان لا يمكن أن يجعل أي من النصفين موثوقًا.
حلنا
قمنا بتقسيم المسؤوليات بحيث يقوم كل طبقة بما هو أفضل فيه: يقوم الهاتف بالتنسيق، ينفذ الجهاز بروتوكولًا مخصصًا تم تعريفه من خلال ممارستنا في تطوير تطبيقات 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 });
});
التوزيع هو أمر ثنائي مؤطر مضغوط — علامة بدء، معرف أمر، طول، بايت الخرطوشة، علم القراءة/الكتابة، وعلامة نهاية — مشفر بـ base64 ومكتوب إلى خاصية الأمر. (تم حجب UUIDs الحرفية وعلامات الإطار هنا.)
// 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),
);
ينتظر التطبيق تأكيد الجهاز (يطابق معرف الأمر من إطار الاستجابة) قبل أن يعتبر التوزيع حقيقيًا — وفقط بعد ذلك يسجله على الخادم. تستخدم تحديثات البرامج الثابتة نفس الاتصال ولكن بلغة مختلفة: حزم CBOR مجزأة عبر خدمة SMP.
من النقر إلى التسجيل
يصبح التوزيع المؤكد حالة دائمة من خلال نقطة نهاية واحدة — 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 لمعرفة ما إذا كانوا قد قاموا بالجرعة اليوم، تزيل التكرار ضد مجموعة السجلات، وتضع في قائمة انتظار دفع Expo عبر ActiveMQ. الجهاز لا يشارك أبدًا.
النتائج
التصميم على أساس "التوزيع هو معاملة" بدلاً من كتابة جهاز تحكم رقيق غير ما يمكن للنظام ضمانه:
- تسجيل مرة واحدة بالضبط. التوزيع يقوم بتحديث السجل، التلخيصات، المخزون، والجرعة اليومية بشكل ذري — لا عد مزدوج، لا كتابات جزئية.
- بروتوكول جهاز موثق، بما في ذلك الترقيات الميدانية. الأوامر والتليمترية هي من الدرجة الأولى، وتحديثات البرامج الثابتة تُشحن عبر نفس الاتصال عبر 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. مزامنة Apple Health وHealth Connect

