מרבית אפליקציות הלוויה הן שלטים דקים מבוססי BLE: לחיצה על כפתור, כתיבת ערך, וזהו. מתקן אינו יכול להיות כה דק – מתן מנה הוא אירוע בריאותי שחייב להיות מתועד בדיוק פעם אחת, המכשיר מדבר פרוטוקול בינארי מותאם אישית במקום פרופיל GATT סטנדרטי, ו"אל תחמיץ מנה" אינו יכול להיות תלוי בכך שהטלפון יהיה פעיל. כך בנינו את הנתיב מלחיצה למצב עמיד וניתן להתאמה, כחלק מעבודת wellness & fitness app development שלנו.
במבט מהיר
תחום: וולנס מחובר — מתן תוספים חכם
טכנולוגיות ליבה: React Native + Expo, react-native-ble-plx, Zustand, NestJS, MongoDB 8 (transactions)
יכולות מפתח: פרוטוקול פקודה/טלמטריה מותאם אישית של BLE, עדכוני קושחה SMP/CBOR, תיעוד מתן מנה טרנזקציוני, תזכורות מודעות אזורי זמן
סטטוס: נכנס לשלב 2 של הפיתוח
האתגר
הלקוח שלנו יצר מוצר וולנס מחובר: מכשיר המחלק תוספים, הנשלט מהטלפון. הדרך המקובלת לבניית אפליקציית הלוויה – והדרך שבה נבנות רובן – היא כשלט דק הכותב מאפיין וסומך על התוצאה. זה עובד עבור נורת חשמל, וכמעט ולא עובד כלל עבור מתקן.
ידענו זאת מראש, ולכן לא בנינו זאת בדרך זו. המגבלות של מודל השלט הדק הן מבניות, לא בעיות כוונון:
- מתן מנה חייב להיות מתועד בדיוק פעם אחת. הוא מפחית מלאי פיזי, נספר כחלק ממנה יומית, ומזין נתוני אנליטיקה תזונתית. ספירה כפולה או כתיבה שאבדה משבשות את כל השלושה.
- המכשיר מדבר פרוטוקול מותאם אישית, לא פרופיל. פקודות הן חבילות בינאריות ממוסגרות; אין מאפיין מוכן מהמדף שמשמעותו "תן טבליה אחת".
- פקודות וטלמטריה אינן חולקות צורה. האפליקציה כותבת פקודות בינאריות קומפקטיות אך קוראת בחזרה טלמטריה בפורמט JSON (סוללה, טעינה, מזהה מחסנית, כמות מים) – על גבי מאפיינים שונים.
- עדכוני קושחה עוברים באותו חיבור. המכשיר חייב להיות ניתן לשדרוג בשטח, דרך שירות ניהול נפרד, ללא כלי שני.
- תזכורות אינן יכולות לחיות על המכשיר. "לא נטלת מנה היום" היא שאלה הנוגעת לאזורי זמן, היסטוריה, וביטול כפילויות – עניין של שרת, לא של טיימר בטלפון.
שורש הבעיה היה פשוט: מתן מנה אינו לחיצת כפתור, זו טרנזקציה – גם על גבי החיבור וגם במסד הנתונים. מודל שמתייחס לכך כאל כתיבה מסוג 'ירי ושיכחה' אינו יכול להבטיח אמינות באף אחד מהצדדים.
הפתרון שלנו
חילקנו אחריות כך שכל שכבה תעשה את מה שהיא הטובה ביותר בו: הטלפון מתזמן, המכשיר מבצע פרוטוקול מותאם אישית שהוגדר באמצעות IoT application development פרקטיקה, והענן הוא מערכת הרישום הטרנזקציונית – כאשר התזכורות מטופלות כולן בצד השרת.
ארכיטקטורה
- React Native + Expo + Zustand — ה-BluetoothStore אחראי על חיבור ה-BLE, זרימת חלוקת המנה, והטלמטריה, המאוחסנים באופן עמיד ב-AsyncStorage.
- react-native-ble-plx — סריקה, חיבור/חיבור מחדש, וקריאה/כתיבה של מאפיינים.
- שכבת פקודות מותאמת אישית — בונה פקודות בינאריות ממוסגרות עבור המכשיר, ומשתמשת ב-CBOR על גבי שירות SMP לעדכוני קושחה.
- NestJS API — נקודות קצה לחלוקת מנות ותזמון, הבנויות על אותה SaaS application development תשתית שאנו משתמשים בה במוצרי לקוחות שונים; רושמת כל חלוקת מנה בתוך טרנזקציית MongoDB.
- MongoDB 8 (Mongoose) — מערכת הרישום, בתוספת אוספי סיכומים בזמן כתיבה שאותם קוראות מערכות אנליטיקה במורד הזרם.
- NestJS Schedule crons + Expo push (queued via ActiveMQ/STOMP) — תזכורות מודעות אזורי זמן, שעברו דה-דופליקציה באמצעות יומני Mongo.
- Sentry (mobile) — מעקב שגיאות ייצור באפליקציה.

פרוטוקול תקשורת מותאם אישית
החלק הקשה בכל מוצר BLE הוא שהמכשיר אינו מדבר בשפה סטנדרטית. שלנו חושף שירות פקודות/טלמטריה ושירות קושחה (SMP) נפרד. האפליקציה מתחברת עם react-native-ble-plx, ואז נרשמת לטלמטריה – המגיעה בפורמט JSON על גבי מאפיין ההתראה (notify characteristic):
// 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 ונכתבת למאפיין הפקודה. (מזהי ה-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),
);
האפליקציה ממתינה לאישור מהמכשיר (היא מתאימה את מזהה הפקודה בחזרה מתוך מסגרת התגובה) לפני שהיא מתייחסת למתן המנה כאמיתי – ורק אז רושמת אותו בצד השרת. עדכוני קושחה משתמשים באותו חיבור אך בשפה שונה: מטענים חלקיקים של 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, ותור ההודעות המועברות פועלים על תשתית המנוהלת באמצעות cloud infrastructure services, כך שאותן הבטחות אמינות נשמרות ככל שצי המכשירים ובסיס המשתמשים גדלים.
מכלול טכנולוגי: 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. חיפוש מתכונים מותאם אישית: אחזור שיודע מה כדאי לכם לאכול הלאה

