L’article précédent 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 :
CommandEncodertransforme 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)CommandDecoderparse 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 :
<plugin id="biz.eurosmart.eudoxe.plugins.Sensors" version="0.0.1">
<!-- Le wrapper JavaScript (SensorsPlugin.js → cordova.plugins.Sensors) -->
<js-module src="cordova/SensorsPlugin.js" name="Sensors">
<clobbers target="cordova.plugins.Sensors" />
</js-module>
<platform name="ios">
<!-- Patch Info.plist pour déclarer les protocoles ExternalAccessory -->
<config-file target="*-Info.plist"
parent="UISupportedExternalAccessoryProtocols">
<array>
<string>fr.eurosmart.campus</string>
<string>fr.eurosmart.campus2</string>
</array>
</config-file>
<!-- Plugin Objective-C++ avec flags C++17 -->
<source-file src="cordova/ESSensorsPlugin.mm"
compiler-flags="-std=c++17 -fno-exceptions -DUSE_EXTERNAL_ACCESSORY"/>
<!-- Code C++ partagé (le même que pour Electron) -->
<source-file src="src/bluetooth/CommandDecoder.cpp"
compiler-flags="-std=c++17 -fno-exceptions" target-dir="bluetooth"/>
<source-file src="src/bluetooth/CommandEncoder.cpp"
compiler-flags="-std=c++17 -fno-exceptions" target-dir="bluetooth"/>
<framework src="ExternalAccessory.framework" />
</platform>
</plugin>
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 (<platform name="osx">) 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() :
// cordova/SensorsPlugin.js
Sensors.readBatteryStatus = function readBatteryStatus(uniqueID, callback) {
cordova.exec(
function (result) { return callback(null, result); },
function (msg) { return callback(new Error(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 :
// ESSensorsPlugin.mm
#define CHECK_NUM_ARGUMENTS(N) { \
if (command.arguments.count != (N)) { \
CDVPluginResult *result = [CDVPluginResult \
resultWithStatus:CDVCommandStatus_ERROR \
messageAsString:@"Wrong number of arguments"]; \
[self.commandDelegate sendPluginResult:result \
callbackId:command.callbackId]; \
return; \
} \
}
#define SEND_SENSOR_COMMAND(sensorUniqueID, sensorCommand) { \
ESSerialCommandsRunner* runner = \
[[ESSensorsManager sharedManager] commandRunnerFor:sensorUniqueID]; \
[runner scheduleCommand:sensorCommand \
callback:^(ESJSONSerializableResponse* response) { \
CDVPluginResult* result = resultForSerializableResponse(response); \
[self.commandDelegate sendPluginResult:result \
callbackId:command.callbackId]; \
}]; \
}
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 :
// addon.mm — le point d'entrée du module natif Electron
#include <nan.h>
#import <IOBluetooth/IOBluetooth.h>
#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<ESPairedBluetoothModule*>* devices =
[[ESSensorsManager sharedManager] pairedSensors];
if (devices.count == 0) {
RETURN_EMPTY_ARRAY();
return;
}
// Sérialiser les résultats Objective-C en objets v8
auto result = Nan::New<v8::Array>((int)devices.count);
for (int i = 0; i < (int)devices.count; ++i) {
Nan::Set(result, i, encodeBluetoothModule(devices[i]));
}
info.GetReturnValue().Set(result);
}
NAN_MODULE_INIT(Init) {
Nan::SetMethod(target, "listPairedSensorsNative", listPairedSensors);
Nan::SetMethod(target, "connectToSensorNative", connectToSensor);
Nan::SetMethod(target, "readModuleIdentificationNative", readModuleIdentification);
// ... 30+ méthodes
}
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<v8::String>() 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 :
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/ :
// Point.h — un point (timestamp, valeur) templated
template <typename Timestamp, typename Value = Timestamp>
class Point final {
static_assert(std::is_copy_assignable_v<Timestamp>);
static_assert(std::is_copy_assignable_v<Value>);
public:
Point(Timestamp x, Value y) noexcept : x_(x), y_(y) {}
Timestamp x() const noexcept { return x_; }
Value y() const noexcept { return y_; }
private:
Timestamp x_;
Value y_;
};
// InformationSegment.h — données d'étalonnage usine
class InformationSegment final {
public:
typedef std::array<uint8_t, 8> PotentiometerValues;
typedef std::array<SegmentData, 4> Modes; // 4 modes max, chacun avec gain + offset
// ...
};
// DataLoggerSamples — un bloc de données du data logger
template <typename Sample = int>
class DataLoggerSamples final {
public:
const Samples& samples() const noexcept;
bool hasMoreSamplesAvailable() const noexcept; // continuation bit
Index bufferIndex() const noexcept;
// ...
};
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 :
class CommandDecoder final {
public:
explicit CommandDecoder(const std::string& data);
enum class ResponseType : int {
ModuleIdentification, FirmwareRevision, EchoPong,
LEDStateStatus, BuzzerStateStatus, BatteryStatus,
AcquisitionInterval, AcquisitionSize, SensorMeasure,
SensorReference, RequestInfoSegment, SensorAddress,
PotentiometerOK, SwitchOK,
DataLoggerElevationStatus, DataLoggerPrepairStatus,
DataLoggerStartStatus, DataLoggerReading,
DataLoggerStatus, DataLoggerStopStatus
};
optional<ResponseType> Type() const;
// Chaque type a sa méthode de décodage typée
optional<std::string> DecodeModuleIdentification() const;
optional<BatteryStatus> DecodeBatteryInfo() const;
std::unique_ptr<T_PointXY> DecodeSensorMeasure() const;
std::unique_ptr<InformationSegment> DecodeInformationSegment() const;
std::unique_ptr<PartialDataLoggerResult> 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 :
static optional<ResponseType> 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 'e': return ResponseType::EchoPong;
case 'l': return ResponseType::LEDStateStatus;
case 'b': return ResponseType::BatteryStatus;
default: return nullopt;
}
case 'A': return (data[1] == ':') ? ResponseType::SensorMeasure : nullopt;
case 'L':
switch (data[1]) {
case 'p': return ResponseType::DataLoggerElevationStatus;
case 'd': return ResponseType::DataLoggerStartStatus;
case 'l': return ResponseType::DataLoggerReading;
case 's': return ResponseType::DataLoggerStatus;
// ...
}
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 :
std::unique_ptr<T_PointXY> CommandDecoder::DecodeSensorMeasure() const {
if (Type() != ResponseType::SensorMeasure) return nullptr;
if (data_.length() < 11) return nullptr;
if (data_[6] != ',') return nullptr;
int ptX = std::stoi(data_.substr(2, 4), nullptr, 16);
int ptY = std::stoi(data_.substr(7, 4), nullptr, 16);
return std::make_unique<T_PointXY>(ptX, ptY);
}
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 :
static float unpackFloat(uint8_t bytes[4]) {
static_assert(sizeof(float) == 4);
union {
float floatValue;
uint8_t bytes[4];
} temp = { .bytes = { bytes[0], bytes[1], bytes[2], bytes[3] } };
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 <optional>, <experimental/optional>, ou refuse de compiler. C’est le même problème de portabilité que sur le projet baby-foot Tekbak 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++ :
NAN_METHOD(DonneMinMax) {
Nan::HandleScope scope;
auto ptXKeyString = Nan::New<v8::String>("ptX").ToLocalChecked();
auto ptYKeyString = Nan::New<v8::String>("ptY").ToLocalChecked();
if (info.Length() != 3) {
Nan::ThrowTypeError("Wrong number of arguments");
return;
}
// Désérialiser un tableau JS d'objets {ptX, ptY} en vector<T_PointXY>
v8::Handle<v8::Array> rawArg0 = v8::Handle<v8::Array>::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)) {
Nan::ThrowTypeError("value for ptX is NaN");
return;
}
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.