La plupart des applications compagnons sont de simples télécommandes BLE : appuyer sur un bouton, écrire une valeur, c'est fait. Un distributeur ne peut pas être aussi simple — une distribution est un événement de santé qui doit être enregistré exactement une fois, l'appareil utilise un protocole binaire personnalisé plutôt qu'un profil GATT standard, et « ne manquez pas une dose » ne peut pas dépendre de l'état éveillé du téléphone. Voici comment nous avons construit le chemin d'un simple clic à un état durable et réconciliable, dans le cadre de nos travaux de développement d'applications de bien-être et de fitness.
En bref
Domaine Bien-être connecté — distribution intelligente de compléments
Technologies Clés React Native + Expo, react-native-ble-plx, Zustand, NestJS, MongoDB 8 (transactions)
Fonctionnalités Clés Protocole BLE de commande/télémétrie personnalisé, mises à jour de firmware SMP/CBOR, enregistrement transactionnel des distributions, rappels tenant compte du fuseau horaire
Statut Entrée en phase 2 de développement
Le Défi
Notre client développait un produit de bien-être connecté : un appareil qui distribue des compléments, contrôlé depuis un téléphone. La manière conventionnelle de construire l'application compagnon — et la manière dont la plupart sont construites — est celle d'une simple télécommande qui écrit une caractéristique et fait confiance au résultat. Cela fonctionne pour une ampoule, mais presque pas du tout pour un distributeur.
Nous le savions dès le départ, c'est pourquoi nous ne l'avons pas construit de cette manière. Les limites du modèle de la simple télécommande sont structurelles, et non des problèmes de réglage :
- Une distribution doit être enregistrée exactement une fois. Elle décrémente le stock physique, compte pour une dose quotidienne et alimente l'analyse des nutriments. Un double comptage ou une écriture perdue corrompt les trois.
- L'appareil utilise un protocole personnalisé, pas un profil. Les commandes sont des paquets binaires encadrés ; il n'y a pas de caractéristique standard qui signifie « distribuer un comprimé ».
- Les commandes et la télémétrie n'ont pas la même structure. L'application écrit des commandes binaires compactes mais lit en retour la télémétrie JSON (niveau de batterie, charge, identifiant de cartouche, quantité d'eau) — sur différentes caractéristiques.
- Les mises à jour de firmware utilisent la même connexion. L'appareil doit pouvoir être mis à jour sur le terrain, via un service de gestion séparé, sans second outil.
- Les rappels ne peuvent pas résider sur l'appareil. « Vous n'avez pas pris votre dose aujourd'hui » est une question de fuseaux horaires, d'historique et de déduplication — une préoccupation de serveur, pas un minuteur de téléphone.
La cause fondamentale était simple : une distribution n'est pas un appui sur un bouton, c'est une transaction — à la fois sur le réseau et dans la base de données. Un modèle qui le traite comme une écriture « fire-and-forget » ne peut pas rendre l'une ou l'autre moitié fiable.
Notre Solution
Nous avons réparti les responsabilités afin que chaque couche fasse ce qu'elle fait de mieux : le téléphone orchestre, l'appareil exécute un protocole personnalisé défini via notre pratique de développement d'applications IoT, et le cloud est le système d'enregistrement transactionnel — les rappels étant gérés entièrement côté serveur.
Architecture
- React Native + Expo + Zustand — un BluetoothStore gère la connexion BLE, le flux de distribution et la télémétrie, persisté dans AsyncStorage.
- react-native-ble-plx — balayage, connexion/reconnexion et lecture/écriture des caractéristiques.
- Une couche de commande personnalisée — construit des commandes binaires encadrées pour l'appareil, et utilise CBOR sur un service SMP pour les mises à jour du firmware.
- NestJS API — endpoints de distribution et de planification, construite sur la même fondation de développement d'applications SaaS que nous utilisons pour tous les produits clients ; enregistre chaque distribution au sein d'une transaction MongoDB.
- MongoDB 8 (Mongoose) — le système d'enregistrement, ainsi que des collections de regroupement (roll-up) au moment de l'écriture que les analyses en aval lisent.
- NestJS Schedule crons + Expo push (mis en file d'attente via ActiveMQ/STOMP) — rappels tenant compte du fuseau horaire, dédupliqués via les logs Mongo.
- Sentry (mobile) — suivi des erreurs de production dans l'application.

Un Protocole Filaire Personnalisé
La partie difficile de tout produit BLE est que l'appareil ne parle pas un langage standard. Le nôtre expose un service de commande/télémétrie et un service de firmware (SMP) séparé. L'application se connecte avec react-native-ble-plx, puis s'abonne à la télémétrie — qui arrive sous forme de JSON sur la caractéristique de notification :
// BluetoothStore.ts — react-native-ble-plx + Zustand
const device = await bleManager.connectToDevice(deviceId, { autoConnect: true });
await device.discoverAllServicesAndCharacteristics();
// Télémétrie : JSON via la caractéristique de notification (les UUID sont des constantes côté application).
bleManager.monitorCharacteristicForDevice(device.id, CH_SERVICE_UUID, TX_UUID, (_err, c) => {
if (!c?.value) return;
const t = JSON.parse(base64ToString(c.value));
// { pourcentage_batterie, état_charge, id_cartouche, quantité_eau, séquence, horodatage }
set({ batteryPercentage: Number(t.battery_percentage), chargingStatus: t.charging_status });
});
Une distribution est une commande binaire encadrée compacte — un marqueur de début, un identifiant de commande, une longueur, l'octet de la cartouche, un indicateur de lecture/écriture et un marqueur de fin — encodée en base64 et écrite dans la caractéristique de commande. (Les UUID littéraux et les marqueurs de trame sont masqués ici.)
// Forme de la trame : [SOF] [CMD] [LEN] [DATA] [RW] [EOF] — construite par le service de commande.
const command = SNBCommandService.buildDispenseNutritionCommand(cartridgeId); // CMD = DISTRIBUTION
await bleManager.writeCharacteristicWithResponseForDevice(
device.id, CH_SERVICE_UUID, RX_UUID,
SNBCommandService.uint8ArrayToBase64(command),
);
L'application attend l'accusé de réception de l'appareil (elle fait correspondre l'ID de commande à partir de la trame de réponse) avant de considérer la distribution comme réelle — et seulement alors l'enregistre côté serveur. Les mises à jour de firmware utilisent la même connexion mais un langage différent : des charges utiles CBOR chunkées sur le service SMP.
Du Clic Ă l'Enregistrement
Une distribution confirmée devient un état durable via un seul endpoint — POST /dispense/tablet/:cartridgeId — qui écrit au sein d'une seule transaction MongoDB, de sorte que la demi-douzaine d'effets se produisent tous ou aucun :
// dispense.service.ts — enregistrement exactement une fois, atomiquement
await session.withTransaction(async () => {
await this.dispensed.create([{ cartridgeId, cartridgeModalId, dispensedBy: userId, dispensedAt }], { session });
// regroupement (roll-up) au moment de l'écriture : totalDaysConsumed n'incrémente que pour la première dose de la journée
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 }); // marquer IS_MISSED
});
Cette transaction unique met à jour le journal de distribution, le regroupement mensuel, le stock physique, le nombre de doses quotidiennes et tout rappel en attente — ce qui est précisément la raison pour laquelle une distribution doit être une transaction, et non une simple écriture.
Les Rappels Relèvent du Serveur
Une fois qu'une distribution est durable, les questions auxquelles un minuteur de téléphone ne pouvait pas répondre deviennent routinières — et elles sont traitées par des tâches cron, pas par l'application :
- Cet utilisateur a-t-il pris sa dose aujourd'hui, dans son fuseau horaire ?
- La cartouche est-elle vide (remplacer) ou le stock est-il vide (commander Ă nouveau), ou les deux (arrĂŞt) ?
- Se sont-ils éloignés d'un domaine de santé qu'ils suivaient auparavant ?
Trois @nestjs/schedule services gèrent ces points — un rappel de calendrier/dose, un rappel d'état de cartouche et un rappel d'inactivité inter-santé. Chacun résout l'heure locale de l'utilisateur avec date-fns-tz, vérifie dans Mongo s'ils ont pris leur dose aujourd'hui, déduplique par rapport à une collection de logs, et met en file d'attente une notification Expo via ActiveMQ. L'appareil n'est jamais impliqué.
Résultats
Concevoir pour « une distribution est une transaction » au lieu d'une écriture simple à distance a changé ce que le système peut garantir :
- Enregistrement exactement une fois. Une distribution met à jour le journal, les regroupements, le stock et la dose quotidienne de manière atomique — pas de double comptage, pas d'écritures partielles.
- Un protocole d'appareil documenté, incluant les mises à niveau sur le terrain. Les commandes et la télémétrie sont de première classe, et les mises à jour de firmware sont expédiées sur la même connexion via SMP/CBOR.
- Des rappels corrects, et non approximatifs. Conscients des fuseaux horaires, de l'historique et dédupliqués côté serveur — ils se déclenchent donc que l'application soit ouverte ou non.
- Des analyses fiables. Parce que chaque distribution est agrégée au moment de l'écriture, les données alimentant les graphiques et l'adhérence sont toujours cohérentes avec le journal.
L'API, les tâches cron et la file d'attente de notifications fonctionnent sur une infrastructure gérée via nos services d'infrastructure cloud, de sorte que les mêmes garanties de fiabilité sont maintenues à mesure que le parc d'appareils et la base d'utilisateurs augmentent.
Pile Technologique : 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)
En savoir plus de notre équipe
1. Synchronisation d'Apple Health et Health Connect
3. Optimisation du logo de chaîne pour différentes résolutions vidéo

