J'ai construit un faux home-trainer pour pouvoir construire le vrai
Comment développer une application qui parle au matériel, quand le matériel n'est pas là.

Il y a eu un moment précis, au début de ce projet, où je me suis assis devant Xcode avec les idées très claires et où je me suis aperçu que je ne pouvais pas écrire une ligne.
Grade prend un fichier GPX, la trace d'une vraie sortie avec ses montées, et pendant que vous pédalez sur un home-trainer, il lui commande la pente qui correspond à l'endroit où vous vous trouvez. La montée que vous escaladez, vous l'avez sous les pédales. Tout le produit est là.
Et tout ce produit passe par un protocole Bluetooth.
Le premier problème, c'est que le simulateur iOS sur Mac n'a pas de Bluetooth. Ce n'est pas une limitation partielle, ce n'est pas « ça marche à moitié » : CoreBluetooth sur le simulateur n'existe tout simplement pas. Vous pouvez compiler, vous pouvez lancer, et le framework vous dit poliment qu'il n'y a aucun module Bluetooth. Le second problème, c'était le home-trainer. Vous vous souvenez ? Dans l'autre épisode du journal, je vous avais dit que le home-trainer était un achat de mon frère. Ce qui veut dire qu'il n'y avait aucun home-trainer dans mon bureau, près de mon Mac et près de mon canapé.
Les alternatives n'étaient donc pas nombreuses. Soit j'achetais un home-trainer et je le gardais à côté du bureau, branché au Mac vingt-quatre heures sur vingt-quatre, en me levant pour pédaler chaque fois qu'il fallait vérifier si un bouton était bien aligné. Soit j'en construisais un moi-même : pas en matériel, bien sûr, mais une version simulée.
Je me suis construit le home-trainer.
D'abord, pourtant : qu'est-ce que le FTMS, en deux minutes
Si vous avez un home-trainer « connecté » acheté ces dernières années, à l'intérieur il parle un standard qui s'appelle FTMS, Fitness Machine Service. C'est une spécification publique du Bluetooth SIG, et le plus beau, c'est que c'est vraiment un standard : Wahoo, Elite, Tacx, Decathlon, les Chinois sans nom. Tous l'implémentent.
Voici comment ça marche. Le home-trainer expose trois « boîtes aux lettres » :
0x2AD2: les données qu'il envoie, c'est-à-dire puissance, cadence et vitesse. Elles arrivent en continu, sans que vous ne demandiez rien.0x2AD9: le control point, où c'est vous qui écrivez. C'est par là que vous lui dites « maintenant mets 6 % de pente ».0x2ACC: ce qu'il sait faire. Un champ de bits : le bit 13, par exemple, dit « je sais simuler une pente ».
La première commande, peut-être la plus intéressante de toutes, est 0x11, setSimulation. Vous lui passez vent, pente, coefficient de roulement et résistance aérodynamique. La pente est un entier signé sur 16 bits, en centièmes de point de pourcentage : 6,25 % s'écrit 625.
Puis le home-trainer répond. Si tout s'est bien passé : 0x80 0x11 0x01, c'est-à-dire « j'ai reçu 0x11, résultat : Success ».
Tout simple, non ?
Oui. Sur le papier.
Niveau 1 : le home-trainer qui n'existe pas
Le premier geste est celui que n'importe quel développeur ferait : mettre un protocole là où il y avait une classe système.
Au lieu de parler directement avec CBPeripheral, le code parle avec un TrainerTransport, qui a trois opérations : écrire des octets, écouter des notifications, lire une valeur. Dessous, on peut mettre le vrai Bluetooth, ou n'importe quoi d'autre.
Ce n'est pas une idée originale. C'est l'une des premières qu'on enseigne, et on l'applique en général avec modération parce qu'abstraire a un coût. Ici, il n'y avait pas le choix : sans ce protocole, la moitié de l'application n'est pas exécutable sur une machine de développement.
Le gain, pourtant, est venu d'une direction que je n'avais pas prévue.
Dans les tests, derrière ce protocole il n'y a pas le Bluetooth mais un faux home-trainer : un objet qui répond aux commandes comme un vrai, et à qui je peux demander de mal se comporter. Deux propriétés aux noms un peu cocasses s'en chargent :
var silentOpCodes: Set<UInt8> = [] // commandes auxquelles le home-trainer ne répond pas
var deniesOnceOpCodes: Set<UInt8> = [] // commandes qu'il refuse la première fois, puis accepte
Arrêtons-nous un instant, parce que ces deux lignes sont la vraie raison pour laquelle l'application fonctionne.
Comment testez-vous ce qui se passe si le home-trainer ne répond pas ? Sur l'appareil réel : vous débranchez la prise en plein milieu d'une commande et vous espérez tomber sur le bon moment. Une fois sur vingt. Puis il faut recommencer pour vérifier la correction, et entre-temps vous avez relancé l'application six fois.
Avec le faux : vous ajoutez 0x11 à silentOpCodes, et le home-trainer devient muet sur cette commande précise, à chaque fois, en dix millisecondes.
Pareil pour deniesOnceOpCodes, qui est le cas le plus vicieux : le home-trainer qui refuse la première demande et accepte la seconde. Cela arrive vraiment, quand le contrôle a été pris par une autre application puis relâché. Le provoquer à la main demande deux téléphones et une chorégraphie.
Le test qui le vérifie, avec le faux home-trainer qui refuse la première fois, échange exactement ces octets :
→ 0x2AD9 11 00 00 E4 02 28 33 setIndoorBikeSimulation
← 0x2AD9 80 11 05 Control Not Permitted
→ 0x2AD9 00 requestControl
← 0x2AD9 80 00 01 Success
→ 0x2AD9 11 00 00 E4 02 28 33 setIndoorBikeSimulation
← 0x2AD9 80 11 01 Success
E4 02, c'est 740 avec les octets inversés, soit une pente de 7,40 % : la même commande, écrite deux fois.
Les branches d'erreur sont la partie de l'application qui décide si une séance se casse ou non, et ce sont aussi la partie que personne ne teste, parce que les tester est incommode. Mon stratagème du protocole TrainerTransport les a rendues commodes. Quatre vrais défauts en sont sortis, dont un vraiment intéressant : la file de commandes que j'avais créée ne sérialisait pas vraiment, si bien que deux requêtes rapprochées pouvaient se chevaucher. Et le home-trainer, qui en accepte une à la fois, envoyait balader la seconde.
Niveau 2 : les machines à états
Le transport factice résout les tests unitaires. Il ne résout pas le développement de l'interface : pour dessiner un tableau de bord, il me faut un home-trainer qui se comporte en home-trainer, qui réponde aux commandes, qui envoie des données pendant que je pédale.
Donc le niveau au-dessus : un vrai simulateur, avec trois machines à états qui parlent FTMS comme le parle un appareil réel.
- La machine du control point : elle reçoit
0x00(prends le contrôle),0x07(démarre),0x11(pente),0x05(puissance cible),0x08(arrête). Elle répond avec0x80et un résultat. Elle refuse les commandes reçues dans le mauvais ordre, parce qu'un vrai home-trainer fait ainsi :0x11sans avoir pris le contrôle donneControl Not Permitted. - La machine de la séance :
idle→started→paused→stopped. - La machine du mode : aucun, puissance, résistance, simulation.
Et puis la partie qui s'est révélée la plus utile de tout le simulateur : les profils d'appareils réels.
Onze, aujourd'hui. KICKR CORE, ZBike 2.0, Van Rysel D100, Elite Direto XR, Elite Justo, Elite Suito. Un FTMS générique. Une ceinture cardio, un capteur de cadence, un capteur de puissance.
Et ceci :
case ftmsWithoutSimulation
Un home-trainer qui parle FTMS mais ne déclare pas le bit 13. Il ne sait pas simuler la pente.
Il existe vraiment. Ce sont les home-trainers à résistance contrôlée : vous pouvez leur dire « fais plus d'effort », vous ne pouvez pas leur dire « maintenant tu es à 6 % ». Si votre application suppose que tout home-trainer FTMS sait simuler, sur ces appareils elle se casse d'une manière particulièrement désagréable : connectée, apparemment en état de marche, et avec la pente qui ne change jamais.
L'avoir dans le simulateur a voulu dire écrire le repli sur la résistance, c'est-à-dire une correspondance de la pente vers un niveau de résistance avec ses propres mathématiques, avant d'avoir en main un appareil de ce genre. Quand il en est arrivé un, ça a fonctionné ; et ce fut l'une des plus grandes satisfactions que j'aie eues en 2026.
Ce n'était pas de la chance. C'est que le cas difficile avait été pensé au lieu d'être découvert.
Niveau 3 : le cycliste qui n'existe pas

Un home-trainer simulé sans personne qui pédale dessus envoie zéro watt.
Il y a donc aussi le cycliste. Il a quatre formes : constant, en bonne journée, en mauvaise journée, différent à chaque sortie. La première est celle des tests, et le mot important est déterministe.
Un test qui dépend d'un nombre aléatoire est un test qui échoue le mardi. Il passe vingt fois, échoue à la vingt-et-unième, et vous perdez un après-midi à chercher un défaut qui n'existe pas. Ou, bien pire, vous prenez l'habitude de le relancer jusqu'à ce qu'il passe. Le jour où il échoue pour une vraie raison, vous le relancez.
Avec le cycliste constant, deux sorties sur le même parcours donnent exactement le même temps. Ce qui a un effet secondaire amusant : la fonction « fantôme », où vous courez contre vous-même, affiche toujours zéro seconde d'écart. Pour la tester, il faut la forme « différent à chaque sortie ».
Il y a aussi un accélérateur. À 50×, un parcours de deux heures s'épuise en un peu plus de deux minutes.
C'est la pièce qui m'a permis d'essayer des choses autrement impensables : ce qui arrive au récapitulatif après trois heures de pédalage, comment se comporte le détecteur de montées sur un tracé de quatorze ascensions, ce que fait l'archive quand cinquante activités y entrent.
Et elle a aussi produit un défaut instructif. Une tolérance était exprimée en secondes d'horloge, et à 50× ces secondes passaient cinquante fois plus vite : le seuil se déclenchait là où il ne devait pas. Un nombre juste, mesuré avec la mauvaise horloge. J'ai mis du temps à le voir, parce que le code était correct : il était correct par rapport à un temps qui, à 50×, n'est plus le même.
Et puis j'ai branché le vrai home-trainer
Ici l'article se retourne, et c'est la partie que je voulais vraiment raconter.
Le simulateur fonctionnait. Le tableau de bord était prêt, les tests au vert, les branches d'erreur couvertes. J'ai branché un Wahoo KICKR CORE et je suis monté en selle.
Dans le journal, le home-trainer notifiait deux caractéristiques à 10 Hz chacune.
18:18:33.157 ← 0x2AD2 44 00 60 0B A0 00 8B 00
18:18:33.159 ← 0x2A63 34 00 8B 00 7D A4 45 A3 00 00 B9 E9 3F 48 51 C7
18:18:33.159 ← 0x2AD2 44 00 7C 0B A0 00 98 00
18:18:33.159 ← 0x2A63 34 00 98 00 42 A5 46 A3 00 00 C0 EB 3F 48 51 C7
18:18:33.337 ← 0x2AD2 44 00 7C 0B A0 00 98 00
18:18:33.338 ← 0x2A63 34 00 98 00 42 A5 46 A3 00 00 C0 EB 3F 48 51 C7
18:18:33.338 ← 0x2AD2 44 00 7C 0B A0 00 98 00
18:18:33.340 ← 0x2A63 34 00 98 00 42 A5 46 A3 00 00 C0 EB 3F 48 51 C7
18:18:33.427 ← 0x2AD2 44 00 82 0B A0 00 A6 00
18:18:33.428 ← 0x2A63 34 00 A6 00 18 A6 47 A3 00 00 C6 ED 3F 48 51 C7
18:18:33.517 ← 0x2AD2 44 00 82 0B A0 00 A6 00
18:18:33.519 ← 0x2A63 34 00 A6 00 18 A6 47 A3 00 00 C6 ED 3F 48 51 C7
18:18:33.697 ← 0x2AD2 44 00 6B 0B A0 00 A3 00
18:18:33.699 ← 0x2A63 34 00 A3 00 EC A6 48 A3 00 00 D0 EF 3F 48 51 C7
18:18:33.787 ← 0x2AD2 44 00 6B 0B A0 00 A3 00
18:18:33.788 ← 0x2A63 34 00 A3 00 EC A6 48 A3 00 00 D0 EF 40 48 51 CA
18:18:33.877 ← 0x2AD2 44 00 6B 0B A0 00 A3 00
18:18:33.879 ← 0x2A63 34 00 A3 00 EC A6 48 A3 00 00 D0 EF 40 48 51 CA
18:18:33.966 ← 0x2AD2 44 00 6B 0B A0 00 AB 00
18:18:33.968 ← 0x2A63 34 00 AB 00 CA A7 49 A3 00 00 DA F1 40 48 51 CA
Une seconde du journal exporté le 20 août, à 18:18:33 : vingt lignes. Le fichier entier en contient vingt mille, de 18:10:29 à 18:26:28.
Mon code en supposait quatre par seconde. Et pas par distraction : la spécification FTMS n'impose pas de fréquence, quatre par seconde me semblait une valeur raisonnable, et mon simulateur envoyait justement quatre notifications par seconde. Je l'avais écrit moi-même, à partir de cette supposition.
C'est là que j'ai compris une chose qui me semble évidente aujourd'hui : un simulateur reproduit fidèlement ce que vous croyez. Ce n'est pas un défaut de sa part, c'est sa nature. Si vous vous trompez sur une hypothèse, vous vous trompez deux fois : dans le code, et dans le simulateur censé le mettre à l'épreuve.
Cette supposition a eu deux conséquences, et je les ai vues toutes les deux sur le terrain.
La première concernait le journal de diagnostic, celui que l'application garde pour reconstituer ce qui s'est passé quand quelque chose tourne mal. C'est un anneau de vingt mille lignes : quand il est plein, chaque nouvelle ligne efface la plus ancienne. À vingt lignes par seconde, vingt mille lignes font seize minutes. Ainsi, si vous exportiez le journal à la fin d'une heure de pédalage, un problème apparu à la quarantième minute était déjà effacé.
La seconde était un seuil qui se déclenchait sans raison. L'application vérifie que la cadence ne reste pas bloquée, parce qu'un capteur qui se plante continue de répéter la même valeur. La règle disait : si pendant dix secondes le nombre ne change pas, il y a un problème. Mais le KICKR communique la cadence en tours entiers, alors que la spécification prévoit aussi les demi-tours, et celui qui pédale de façon régulière peut rester sur le même nombre plusieurs secondes de suite sans que rien d'anormal ne se passe. L'alarme se déclenchait justement sur ceux qui pédalaient bien.
De là, une règle que j'ai écrite dans la documentation du projet et que je relis souvent :
Un rythme d'appareil se mesure, il ne se suppose pas.
Ce que j'en retiens
Le simulateur n'est pas un pis-aller en attendant le matériel. C'est un second outil, avec un métier différent :
| le simulateur | le vrai matériel | |
|---|---|---|
| sert à | explorer : cas rares, erreurs, scénarios longs | démentir |
| couvre | tout ce que vous avez pensé | ce que vous n'aviez pas pensé |
| coûte | dix millisecondes | une heure, et il faut transpirer |
| quand | tous les jours, à chaque commit | avant de dire « ça marche » |
Le simulateur vous protège des défauts. Il ne vous protège pas de vos suppositions, parce qu'il est fait de vos suppositions.
Alors la question que je me pose maintenant, chaque fois que le simulateur dit que tout va bien, est : cette chose que je suis en train de vérifier, l'ai-je mesurée, ou l'ai-je écrite moi-même aux deux endroits ?
C'est une question qui a presque toujours la même réponse : la seconde.

Le code de Grade compte aujourd'hui plus de cent trente mille lignes de Swift, dont près de quatre sur dix sont des tests. Le simulateur, avec ses trois niveaux, ses onze profils d'appareils et ses quatre formes de cycliste, n'arrive jamais devant ceux qui utilisent l'application : ce n'est qu'échafaudage.
Mais sans cet échafaudage, il n'y aurait rien à utiliser. Et chaque fois que quelqu'un me demande pourquoi une application qui « envoie un nombre à un home-trainer » a demandé tout ce temps, la réponse la plus honnête est que le nombre est la partie facile.
La partie difficile, c'est tout ce qui se passe quand le nombre n'arrive pas.
Grade est un simulateur de parcours GPX pour home-trainers connectés. Vous importez la trace d'une vraie sortie, et le home-trainer vous donne ses montées, pente par pente, mètre par mètre. iPhone, iPad, Mac.


