# Intégrer des capteurs scientifiques custom dans Cordova et Electron — C++, node-gyp et protocole maison > Comment on a intégré des capteurs scientifiques Bluetooth avec un protocole propriétaire dans une app hybride Cordova/Electron. Code C++ partagé entre iOS et macOS, bridges Objective-C++ et NAN, compilation node-gyp pour Electron, et les défis d'un protocole sans synchronisation d'horloge. Date : 05/08/2020 Auteur : Aurélien N. Tags : C++, Cordova, Electron, Bluetooth, node-gyp, IoT --- L'[article précédent](/blog/eudoxe-app-hybride-educative-cordova-electron-architecture) décrit l'architecture générale de l'app. Ici, on plonge dans le sujet le plus technique : comment faire parler des capteurs scientifiques custom à une app hybride JavaScript, sur deux plateformes différentes, avec un seul code C++ au milieu. Les capteurs du client sont des boîtiers Bluetooth qui mesurent température, pH, débit, CO2, fréquence cardiaque, indice UV, et une dizaine d'autres grandeurs physiques. Ils communiquent via un protocole propriétaire — texte, pas GATT standard — conçu à l'époque pour l'interface Delphi. Ce protocole n'a pas évolué et ne va pas évoluer : il y a des milliers de boîtiers déployés dans les lycées et collèges de France. ## Le protocole : commandes texte et paquets binaires Le protocole est documenté dans une vingtaine de fichiers Markdown que le client nous a fournis. C'est un protocole texte : chaque commande est une chaîne ASCII terminée par `\x0A` (line feed), et chaque réponse suit le même format. Pas de JSON, pas de protobuf, pas de framing complexe — du texte brut avec des séparateurs. Quelques exemples tirés de la doc : ``` J\x0A → Lecture de la référence capteur réponse : "J:20000000@\x0A" W\x0A → Version firmware réponse : "W:CAMPUS-BT-V3.23@\x0A" $b\x0A → Statut batterie réponse : "$b:085;4.12@\x0A" (85%, 4.12V) A04\x0A → Lancer une acquisition continue (canal 04) données : compteur temps + valeurs 12 bits P00001.50000000\x0A → Intervalle d'échantillonnage : 1.5 secondes 51234567805040280\x0A → Réglage potentiomètre (amplification/offset) ``` L'acquisition continue (`A04`) est le mode principal. Le capteur envoie un flux continu de paquets avec un compteur de temps et des échantillons sur 12 bits. Le mode data logger (`Ld`, `Li`, `Ll`) permet de stocker des échantillons en local sur le boîtier et de les récupérer plus tard par blocs de 64. ### Le problème de synchronisation d'horloge Le protocole historique a été conçu pour une communication filaire directe avec un PC sous Windows, où la latence était négligeable et prévisible. Il ne prévoit **aucun mécanisme de synchronisation d'horloge** entre le capteur et l'application. En acquisition continue, le capteur envoie ses échantillons avec un compteur interne qui démarre à zéro. L'app reçoit ces paquets via Bluetooth, mais le temps de réception côté app ne correspond pas au temps d'échantillonnage côté capteur. La latence Bluetooth varie (20-100ms), le buffering du système d'exploitation ajoute du jitter, et quand l'app est en arrière-plan un instant (notification système, par exemple), les paquets s'accumulent et arrivent en burst. Sans protocole NTP ou PTP entre le capteur et l'app, on ne peut pas savoir avec précision à quel instant réel un échantillon a été pris. On reconstruit la timeline à partir de l'intervalle d'échantillonnage configuré (`P00001.50000000`) et du compteur du capteur, mais la dérive s'accumule sur les acquisitions longues. Pour un TP de physique de 10 minutes, c'est acceptable. Pour un data logger qui tourne des heures, c'est un problème. On a implémenté un recalage périodique basé sur les timestamps de réception : quand l'écart entre le temps estimé (compteur × intervalle) et le temps de réception dépasse un seuil, on insère une correction. C'est un compromis — les données en temps réel ont un léger jitter visuel, mais la timeline globale ne dérive pas. ## Un code C++ unique, deux bridges natifs Le coeur partagé entre les plateformes est une bibliothèque C++ pure (pas de dépendance OS) qui encode et décode les commandes du protocole : - `CommandEncoder` transforme les intentions de l'app ("lire la référence", "lancer une acquisition continue sur le canal 4") en chaînes du protocole (`J\x0A`, `A04\x0A`) - `CommandDecoder` parse les réponses du capteur et extrait les valeurs typées (référence capteur, pourcentage batterie, échantillons 12 bits...) Ce code C++ est compilé et lié sur chaque plateforme par un bridge différent. Mais le code métier — l'encodage, le décodage, la validation des réponses — est le même partout. ### Le bridge Cordova iOS : Objective-C++, ExternalAccessory et `plugin.xml` Sur iOS, les capteurs utilisent Bluetooth Classic (pas BLE) et sont certifiés MFi (Made for iPhone). La communication passe par le framework `ExternalAccessory` d'Apple, qui fournit un `NSInputStream`/`NSOutputStream` sur un protocole custom identifié par une chaîne déclarée dans le `Info.plist`. Un plugin Cordova est défini par un fichier `plugin.xml` qui décrit tout ce que Cordova doit savoir pour intégrer le code natif dans le projet Xcode. C'est le manifeste du plugin : ```xml fr.eurosmart.campus fr.eurosmart.campus2 ``` Le `plugin.xml` fait trois choses critiques. D'abord, il injecte les protocoles ExternalAccessory dans le `Info.plist` — sans ça, iOS refuse la connexion Bluetooth aux capteurs. Ensuite, il inclut les fichiers source C++ dans le build Xcode avec les bons flags de compilation. Enfin, il linke le framework `ExternalAccessory`. Le `define` `USE_EXTERNAL_ACCESSORY` est ce qui sélectionne l'implémentation iOS plutôt que macOS. La plateforme macOS (``) utilise le même plugin mais sans ce define, et linke `IOBluetooth.framework` à la place — deux API Bluetooth différentes, même code C++ au milieu. Le bridge JavaScript côté Cordova est un mapping 1:1 entre les méthodes JS et les méthodes natives, via `cordova.exec()` : ```javascript // cordova/SensorsPlugin.js Sensors.readBatteryStatus = function readBatteryStatus(uniqueID, callback) { cordova.exec( function (result) , function (msg) , "ESSensorsPlugin", // Classe Obj-C à appeler "readBatteryStatus", // Méthode à invoquer [uniqueID] // Arguments passés au natif ); }; ``` Côté Objective-C++, chaque méthode reçoit un objet `CDVInvokedUrlCommand` contenant les arguments et un `callbackId` pour renvoyer le résultat : ```c // ESSensorsPlugin.mm #define CHECK_NUM_ARGUMENTS(N) { \ if (command.arguments.count != (N)) \ } #define SEND_SENSOR_COMMAND(sensorUniqueID, sensorCommand) { \ ESSerialCommandsRunner* runner = \ [[ESSensorsManager sharedManager] commandRunnerFor:sensorUniqueID]; \ [runner scheduleCommand:sensorCommand \ callback:^(ESJSONSerializableResponse* response) ]; \ } ``` Les macros `CHECK_NUM_ARGUMENTS` et `SEND_SENSOR_COMMAND` encapsulent le boilerplate Cordova. Chaque méthode du plugin se réduit à : valider les arguments, créer la commande C++ correspondante (via `CommandEncoder`), et l'envoyer au `ESSerialCommandsRunner` qui gère la file half-duplex. ### Le bridge Electron : NAN, V8, libuv et les heures perdues Sur macOS/Electron, le même code C++ est exposé à Node.js via un addon natif compilé par node-gyp. Le fichier `addon.mm` utilise NAN (Native Abstractions for Node.js) pour créer des fonctions appelables depuis JavaScript : ```cpp // addon.mm — le point d'entrée du module natif Electron #include #import #import "ESSensorsManager.h" #import "ESCallbackManager.h" #import "ESSensorCommands.h" #include "encodeHelpers.h" #include "decodeHelpers.h" // File GCD pour les opérations Bluetooth asynchrones static dispatch_queue_t workerQueue = dispatch_queue_create("biz.eurosmart.Eudoxe.Electron.workerQueue", 0); NAN_METHOD(listPairedSensors) { Nan::HandleScope scope; CHECK_NUM_ARGUMENTS(0) NSArray* devices = [[ESSensorsManager sharedManager] pairedSensors]; if (devices.count == 0) // Sérialiser les résultats Objective-C en objets v8 auto result = Nan::New((int)devices.count); for (int i = 0; i < (int)devices.count; ++i) info.GetReturnValue().Set(result); } NAN_MODULE_INIT(Init) NODE_MODULE(sensors, Init) ``` La compilation produit un fichier `.node` (une shared library) que Node.js/Electron charge via `require()`. Simple en théorie. En pratique, c'est le point le plus douloureux du projet. ### Le mélange V8, libuv et Grand Central Dispatch Ce qui rend l'addon Electron difficile n'est pas le C++ en soi — c'est le **mélange de trois modèles de concurrence** dans le même process. **V8** (le moteur JavaScript) est single-threaded. Tout le code JavaScript s'exécute dans un seul thread, sur un seul **Isolate** (l'instance V8 qui possède son propre heap, son propre garbage collector, et ses propres contextes). La documentation sur les Isolates en 2020 est notoirement incomplète — la doc officielle V8 est pensée pour les embedders comme Chrome, pas pour les développeurs d'addons Node.js. NAN masque une partie de la complexité, mais pas toute. **libuv** est la bibliothèque d'I/O asynchrone de Node.js. C'est une bibliothèque **C** (pas C++) qui gère la boucle événementielle, les timers, les file descriptors, et un thread pool pour les opérations bloquantes. Les callbacks libuv s'exécutent sur le thread principal (l'event loop), mais les workers peuvent tourner sur des threads séparés. **IOBluetooth** (l'API Bluetooth macOS) et **Grand Central Dispatch** (GCD) sont le modèle de concurrence Apple. Les callbacks Bluetooth arrivent sur des dispatch queues, potentiellement sur n'importe quel thread. Le problème : quand un capteur envoie des données via Bluetooth, le callback IOBluetooth arrive sur la queue GCD (`workerQueue`). Mais pour renvoyer ces données à JavaScript, il faut toucher des objets V8 — et V8 refuse catégoriquement qu'on accède à ses objets depuis un thread autre que celui de l'Isolate. Appeler `Nan::New()` depuis un thread GCD provoque un crash immédiat sans message d'erreur lisible. La solution : les callbacks asynchrones doivent transiter par libuv pour revenir sur le thread principal de V8 avant de créer des objets JavaScript. On utilise `uv_async_send()` pour signaler la boucle événementielle depuis le thread GCD, puis on fait la sérialisation dans le callback `uv_async_t` qui s'exécute sur le bon thread. L'`ESCallbackManager` dans notre code encapsule ce mécanisme — il stocke le résultat C++, signale libuv, et crée les objets V8 dans le bon contexte. C'est exactement le genre de problème qui n'apparaît dans aucun tutoriel. La documentation Node.js parle de `Nan::AsyncWorker` pour les tâches ponctuelles (lancer un calcul en background, renvoyer le résultat), mais pas pour les flux de données continus (un capteur qui envoie des paquets à 100Hz). Et la documentation V8 sur les Isolates est un headers file de 1 200 lignes avec des commentaires laconiques. ### node-gyp : le temps qu'on ne récupérera jamais node-gyp utilise GYP, un système de build que même Google a abandonné au profit de GN. Il faut Python 2 (officiellement EOL depuis janvier 2020), les Xcode Command Line Tools, et une prière au dieu de la compilation. Le premier problème : la version de Node.js. Electron embarque sa propre version de Node.js, différente de celle installée sur la machine. Le addon doit être compilé contre les headers d'Electron, pas ceux du Node.js système : ```makefile configure: cd node && node-gyp configure \ --target=1.8.4 \ --arch=x64 \ --dist-url=https://atom.io/download/electron build: cd node && node-gyp build ``` Se tromper de `--target` produit un `.node` qui charge sans erreur mais segfault au premier appel. On a perdu une journée entière sur ce bug la première fois — le message d'erreur est `MODULE_NOT_FOUND` ou un crash sans stack trace, jamais "mauvaise version de headers V8". Le deuxième problème : NAN est une abstraction au-dessus de l'API V8, pas au-dessus de l'ABI. Chaque mise à jour majeure de Node.js (ou d'Electron) peut casser la compilation. On est sur NAN plutôt que N-API (la nouvelle interface ABI-stable) parce que le projet a démarré avant que N-API soit mature pour Electron. En 2020, N-API est recommandé pour les nouveaux projets, mais migrer un addon existant avec 30+ méthodes n'est pas anodin. Le troisième problème : le debug. Quand un addon natif segfault, le process Electron meurt. Pas de stack trace JavaScript, pas de core dump par défaut. Il faut attacher lldb au process Electron, reproduire le crash, et naviguer dans les frames NAN/V8/libuv pour trouver le bug C++. Les crashs les plus vicieux sont ceux de thread safety : un `Nan::New` appelé depuis le mauvais thread ne crashe pas immédiatement — il corrompt le heap V8, et le segfault arrive 200ms plus tard dans un endroit complètement différent. ## Le design du pipeline Bluetooth en C++ La couche `src/bluetooth/` est le travail le plus exigeant du projet. C'est la bibliothèque C++ pure (aucune dépendance OS) qui encode et décode les trames du protocole. Elle est compilée sur chaque plateforme par un bridge différent, mais le code métier est le même partout. C'est aussi la couche qu'on a le plus testée — elle est couverte par des tests unitaires Google Test, parce qu'un bug de décodage ici se manifeste comme une valeur aberrante sur un graphe, et qu'on peut passer des heures à chercher la source. ### Les types du domaine Avant d'écrire un seul encodeur, on a modélisé les types de données que le protocole manipule. Chaque type est un header C++ dans `bluetooth/` : ```cpp // Point.h — un point (timestamp, valeur) templated template class Point final { static_assert(std::is_copy_assignable_v); static_assert(std::is_copy_assignable_v); public: Point(Timestamp x, Value y) noexcept : x_(x), y_(y) Timestamp x() const noexcept Value y() const noexcept private: Timestamp x_; Value y_; }; ``` ```cpp // InformationSegment.h — données d'étalonnage usine class InformationSegment final ; ``` ```cpp // DataLoggerSamples — un bloc de données du data logger template class DataLoggerSamples final ; ``` Le `Point` est templaté pour pouvoir servir à la fois comme point de mesure `(int timestamp, int raw_value)` et comme point calibré `(double time_sec, double physical_value)`. Le `DataLoggerSamples` encapsule un bloc de 64 échantillons avec un flag de continuation et un index de buffer — tout ce que retourne la commande `Ll`. Le `InformationSegment` porte les données d'étalonnage usine : 8 valeurs de potentiomètre (uint8_t), et 4 modes avec chacun un gain et un offset flottants. Chaque type a un `operator==` pour les tests unitaires. C'est un détail, mais ça change tout pour la vérification : on peut écrire `EXPECT_EQ(decoded, expected)` au lieu de comparer chaque champ manuellement. ### Le CommandDecoder : 20 types de réponse Le `CommandDecoder` est l'objet central du pipeline. Il prend une chaîne de bytes reçue du capteur et fournit des méthodes typées pour en extraire le contenu : ```cpp class CommandDecoder final { public: explicit CommandDecoder(const std::string& data); enum class ResponseType : int ; optional Type() const; // Chaque type a sa méthode de décodage typée optional DecodeModuleIdentification() const; optional DecodeBatteryInfo() const; std::unique_ptr DecodeSensorMeasure() const; std::unique_ptr DecodeInformationSegment() const; std::unique_ptr DecodeReadSamplesFromBufferIndex() const; // ... 15 autres méthodes }; ``` L'identification du type se fait par inspection du préfixe — les 1-2 premiers octets de la réponse. C'est un `switch` sur le premier caractère, avec des raffinements sur le second : ```cpp static optional DetectType(const std::string& data) { if (data.length() < 2) return nullopt; switch (data[0]) { case 'I': return (data[1] == ':') ? ResponseType::ModuleIdentification : nullopt; case 'W': return (data[1] == ':') ? ResponseType::FirmwareRevision : nullopt; case '$': switch (data[1]) case 'A': return (data[1] == ':') ? ResponseType::SensorMeasure : nullopt; case 'L': switch (data[1]) case '5': // Le cas le plus retors : les réponses "5..." ont des formats // qui varient selon la sous-commande if (data.compare(1, 13, "FFFFFFFF0100:") == 0) return ResponseType::SensorAddress; else if (data[17] == ':') return ResponseType::PotentiometerOK; else if (data[14] == ':') return ResponseType::SwitchOK; // ... } return nullopt; } ``` Le cas `'5'` illustre la complexité du protocole maison. Les commandes qui commencent par `5` couvrent l'adresse capteur, le potentiomètre et le changement de mode — trois types de réponse différents, discriminés par la position du caractère `:` dans la trame. C'est le genre de spécification qu'on ne trouve dans aucune RFC, qu'on découvre en testant avec le matériel physique, et qu'on documente au fur et à mesure dans les commentaires du code. ### Le décodage des mesures : parsing binaire en hexadécimal Les mesures continues arrivent sous forme `A:xxxx,yyyy@` où `xxxx` est un compteur 16 bits en hexadécimal et `yyyy` est la valeur brute 12 bits en hexadécimal. Le décodage : ```cpp std::unique_ptr CommandDecoder::DecodeSensorMeasure() const ``` Pour le segment d'information (étalonnage usine), c'est plus complexe : 128 caractères hexadécimaux qui encodent 8 potentiomètres (uint8_t) et 4×2 floats (gain + offset par mode), le tout en big-endian. Les floats sont désérialisés via un `union` qui réinterprète 4 octets en IEEE 754 : ```cpp static float unpackFloat(uint8_t bytes[4]) { static_assert(sizeof(float) == 4); union temp = { .bytes = }; return temp.floatValue; } ``` Le data logger est le plus délicat : les échantillons arrivent par blocs de 64, avec un octet de statut qui encode à la fois un flag de continuation (bit 7) et le nombre d'échantillons dans le bloc (bits 0-6). Chaque échantillon est un short 16 bits. Le `DecodeReadSamplesFromBufferIndex` décode tout ça et retourne un `PartialDataLoggerResult` avec les échantillons, le flag de continuation, et l'index de buffer pour la requête suivante. ### Le pattern `optional` : avant C++17 Un détail qui date le projet : on utilise notre propre `optional.h` au lieu de `std::optional` parce que le support C++17 sur tous les compilateurs ciblés (Clang iOS, GCC Linux, MSVC) n'est pas encore complet en 2019 quand on commence. Le header teste `__cplusplus` et charge ``, ``, ou refuse de compiler. C'est le même problème de portabilité que sur le [projet baby-foot Tekbak](/blog/fsb-tracking-vision-cinder-opencv-cpp) trois ans plus tôt, mais cette fois C++17 est beaucoup plus proche de la finalisation. ## Le plugin de calcul : Delphi → C++ → JavaScript Le second plugin natif (`calcul-0.0.1`) suit le même pattern mais avec un contenu différent : c'est la bibliothèque de calcul scientifique, portée depuis Delphi par Christophe, l'ingénieur du client. Christophe connaît les algorithmes sur le bout des doigts — il les a implémentés en Delphi il y a des années. Mais le C++ n'est pas son langage principal. Le code qu'il produit est fonctionnellement correct (les résultats numériques sont bons, vérifiés contre la version Delphi) mais pas toujours idiomatique : des pointeurs bruts là où on mettrait des `vector`, des `new` sans `delete`, des conversions de type implicites. On passe derrière pour corriger le C++ — ajouter de la RAII, remplacer les tableaux C par des conteneurs STL, et surtout ajouter de la validation d'entrées pour que les erreurs JavaScript (un `undefined` qui se transforme en `NaN` en C++) ne fassent pas crasher le process. Le binding NAN côté Electron illustre bien la verbosité du pont JavaScript ↔ C++ : ```cpp NAN_METHOD(DonneMinMax) { Nan::HandleScope scope; auto ptXKeyString = Nan::New("ptX").ToLocalChecked(); auto ptYKeyString = Nan::New("ptY").ToLocalChecked(); if (info.Length() != 3) // Désérialiser un tableau JS d'objets en vector v8::Handle rawArg0 = v8::Handle::Cast(info[0]); TListePoint listePointSource; for (uint32_t i = 0; i < rawArg0->Length(); ++i) { auto rawValue = rawArg0->Get(i); // ... 30 lignes de validation par point ... double ptX = ptXValue->NumberValue(); if (isnan(ptX)) listePointSource.push_back(T_PointXY(ptX, ptY)); } // Appel à la fonction C++ portée de Delphi auto result = calculWrapper.DonneMinMax(listePointSource, min, max); // ... sérialisation du résultat en objet JS ... } ``` Pour chaque fonction, il faut désérialiser les arguments JavaScript en types C++ (avec validation exhaustive), appeler la fonction, et sérialiser le résultat. C'est un code d'intendance qui représente autant de lignes que les algorithmes eux-mêmes. Le code de calcul C++ est compilé avec le framework `Accelerate` d'Apple pour bénéficier des routines BLAS/LAPACK optimisées, notamment pour la FFT. Sur un iPad Pro, le calcul d'une FFT sur 4096 points prend moins d'une milliseconde — largement dans le budget pour du temps réel. ## Les commandes du protocole en détail Pour donner une idée de la richesse (et de la spécificité) du protocole, voici les principales catégories de commandes telles que documentées par le client : **Identification et diagnostic** : lecture de la référence capteur (`J`), de l'identifiant Bluetooth (`I`, retourne par exemple `CAMPUS-BT00`), de la version firmware (`W`), du statut batterie (`$b`, retourne pourcentage et tension), et un echo de test (`$e`). **Configuration** : changement de mode capteur (`5...11012` — chaque capteur peut avoir jusqu'à 4 modes de mesure), réglage du potentiomètre d'amplification/offset (`5...05040280`), intervalle d'échantillonnage (`P00001.50000000` pour 1,5 secondes, minimum 1ms), nombre de points d'acquisition (`N0064` pour 100 points). **Acquisition continue** : démarrage (`A04` avec le numéro de canal), le capteur envoie ensuite un flux de paquets avec un compteur 16 bits et des échantillons 12 bits. L'arrêt est implicite (on ferme le canal ou on envoie une autre commande). **Data logger** : élévation en mode admin (`Lp`), préparation du buffer (`Lf`), démarrage avec date (`Ld2017-12-31`), arrêt (`Li`), lecture par blocs de 64 échantillons (`Ll0000` avec l'offset). Le statut (`Ls`) retourne si une acquisition est en cours, le nombre d'échantillons et l'intervalle. **Contrôle physique** : LED du boîtier (`$l1` — 0=off, 1=bleu, 2=rouge, 3=violet), buzzer (`$z1500;4000;0200;0100;0003` — fréquences et durées). Le `CommandEncoder` en C++ transforme ces appels en chaînes du protocole. Le `CommandDecoder` parse les réponses. Le `ESSerialCommandsRunner` gère une file de commandes pour garantir l'ordre d'exécution — le protocole est half-duplex, on ne peut pas envoyer une commande tant que la réponse de la précédente n'est pas arrivée. ## Ce qu'on en retient **Le C++ partagé entre plateformes, ça marche.** Le code d'encodage/décodage est le même sur iOS et macOS. Les bridges sont différents (Cordova plugin vs NAN addon), mais le coeur algorithmique est identique. C'est le principal bénéfice de cette architecture en couches. **node-gyp est un impôt sur l'innovation.** Le temps passé à comprendre les options de compilation, les headers Electron, les incompatibilités Python 2/3, et le debug des segfaults natifs est du temps qu'on ne passe pas sur les features. Si le projet redémarrait aujourd'hui, on utiliserait N-API et cmake-js. **Un protocole propriétaire sans synchro d'horloge est un handicap technique permanent.** Chaque fonctionnalité temps réel demande un contournement. Le fait de ne pas pouvoir modifier le firmware des capteurs déployés nous force à des compensations logicielles qui ajoutent de la complexité et de l'imprécision. **La collaboration ingénieur client / dev externe fonctionne si les interfaces sont claires.** Christophe porte le Delphi en C++, on écrit les bindings, on se retrouve sur les tests numériques. Les couches sont le contrat entre nos deux équipes.