Більшість супутніх програм є тонкими BLE-пультами: натиснув кнопку, записав значення, готово. Дозатор не може бути настільки простим — видача є подією, пов'язаною зі здоров'ям, яку необхідно записати рівно один раз, пристрій використовує власний бінарний протокол, а не стандартний GATT-профіль, а "не пропустити дозу" не може залежати від того, чи працює телефон. Ось як ми побудували шлях від натискання до довговічного, узгоджуваного стану, у рамках нашої роботи з розробки програм для здоров'я та фітнесу.
Короткий огляд
Сфера: Connected wellness — розумне дозування добавок
Основні технології: React Native + Expo, react-native-ble-plx, Zustand, NestJS, MongoDB 8 (транзакції)
Ключові можливості: Власний BLE протокол команд/телеметрії, оновлення прошивки SMP/CBOR, транзакційний запис видачі, нагадування з урахуванням часових поясів
Статус: Вступ до фази 2 розробки
Виклик
Наш клієнт створював connected wellness продукт: пристрій, що дозує добавки, керований з телефону. Традиційний спосіб створення супутньої програми — і той, як більшість із них створюються — це тонкий пульт, який записує characteristic і довіряє результату. Це працює для лампочки, але майже зовсім не підходить для дозатора.
Ми знали це з самого початку, тому й не будували його таким чином. Обмеження моделі тонкого пульта є структурними, а не проблемами налаштування:
- Видача має бути записана рівно один раз. Вона зменшує фізичний запас, зараховується до добової дози та живить аналітику поживних речовин. Подвійний підрахунок або втрачений запис псує всі три показники.
- Пристрій використовує власний протокол, а не профіль. Команди є бінарними пакетами у фреймах; немає готового characteristic, що означає "видати одну таблетку".
- Команди та телеметрія не мають спільної форми. Додаток записує компактні бінарні команди, але зчитує JSON-телеметрію (рівень батареї, зарядка, ідентифікатор картриджа, кількість води) — через різні characteristics.
- Оновлення прошивки відбувається через те саме з'єднання. Пристрій має бути оновлюваним на місці, через окрему службу керування, без другого інструменту.
- Нагадування не можуть зберігатися на пристрої. "Ви сьогодні не приймали дозу" — це питання часових поясів, історії та дедуплікації — проблема сервера, а не таймера телефону.
Першопричина була проста: видача — це не натискання кнопки, це транзакція — як на лінії зв'язку, так і в базі даних. Модель, яка розглядає це як запис "вистрілив і забув", не може зробити жодну з половин надійною.
Наше рішення
Ми розділили обов'язки так, щоб кожен шар виконував те, що йому найкраще вдається: телефон оркеструє, пристрій виконує власний протокол, визначений через нашу практику розробки IoT-додатків, а хмара є транзакційною системою записів — з нагадуваннями, що повністю обробляються на стороні сервера.
Архітектура
- React Native + Expo + Zustand — BluetoothStore керує BLE-з'єднанням, потоком видачі та телеметрією, що зберігається в AsyncStorage.
- react-native-ble-plx — сканування, підключення/перепідключення та читання/запис characteristic.
- Користувацький командний шар — створює бінарні команди у фреймах для пристрою та використовує 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 через 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 та записаною до командного characteristic. (Літерні 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 jobs, а не додаток:
- Чи приймав цей користувач дозу сьогодні, у своєму часовому поясі?
- Чи порожній картридж (замінити) або порожній запас (замовити), або обидва (припинення)?
- Чи відійшли вони від області здоров'я, якою вони раніше займалися?
Ці питання обробляються трьома сервісами @nestjs/schedule — нагадування про розклад/дозу, нагадування про стан картриджа та нагадування про неактивність у сфері здоров'я. Кожен з них визначає місцевий час користувача за допомогою date-fns-tz, перевіряє в Mongo, чи приймав користувач дозу сьогодні, дедуплікує записи в журнальній колекції та ставить у чергу push-сповіщення Expo через ActiveMQ. Пристрій ніколи не задіяний.
Результати
Проектування з принципом "видача — це транзакція" замість простого запису з тонкого пульта змінило те, що система може гарантувати:
- Запис рівно один раз. Видача оновлює журнал, зведені дані, запас та щоденну дозу атомарно — без подвійних підрахунків, без часткових записів.
- Документований протокол пристрою, включаючи оновлення на місці. Команди та телеметрія є першокласними, а оновлення прошивки надсилаються через те саме з'єднання за допомогою SMP/CBOR.
- Точні, а не приблизні нагадування. З урахуванням часових поясів, історії та дедуплікацією на стороні сервера — вони спрацьовують незалежно від того, чи відкрито додаток.
- Аналітика, якій можна довіряти. Оскільки кожна видача зводиться під час запису, дані, що використовуються для діаграм та дотримання режиму, завжди узгоджуються з журналом.
API, crons та черга push-сповіщень працюють на інфраструктурі, що керується нашими хмарними інфраструктурними послугами, тому ті самі гарантії надійності діють по мірі масштабування парку пристроїв та бази користувачів.
Стек технологій: React Native · Expo · TypeScript · react-native-ble-plx · Zustand · CBOR (SMP firmware) · NestJS · MongoDB 8 · Mongoose (транзакції) · @nestjs/schedule · ActiveMQ / STOMP · Expo Server SDK · JWT · AWS S3 · AWS SES · Sentry (мобільний)
Читайте більше від нашої команди
1. Синхронізація Apple Health та Health Connect
2. Персоналізований пошук рецептів: пошук, який знає, що вам слід з'їсти далі
3. Оптимізація логотипу каналу для різних роздільних здатностей відео

