Ich habe einen falschen Rollentrainer gebaut, um den echten bauen zu können
Wie man eine App entwickelt, die mit Hardware spricht, wenn die Hardware nicht da ist.

Es gab einen ganz bestimmten Moment, zu Beginn dieses Projekts, in dem ich mich mit völlig klaren Ideen vor Xcode gesetzt habe und gemerkt habe, dass ich keine einzige Zeile schreiben konnte.
Grade nimmt eine GPX-Datei, die Spur einer echten Fahrt mit ihren Anstiegen, und während du auf einem Trainingsrollentrainer trittst, schickt es ihm die Steigung, die dem Punkt entspricht, an dem du dich gerade befindest. Den Anstieg, den du gerade erklimmst, hast du unter den Pedalen. Das ganze Produkt steckt darin.
Und dieses ganze Produkt läuft über ein Bluetooth-Protokoll.
Das erste Problem ist, dass der iOS-Simulator auf dem Mac kein Bluetooth hat. Das ist keine teilweise Einschränkung, kein „funktioniert halb“: CoreBluetooth gibt es im Simulator schlicht nicht. Du kannst kompilieren, du kannst starten, und das Framework sagt dir höflich, dass es kein Bluetooth-Modul gibt. Das zweite Problem war der Rollentrainer. Erinnerst du dich? Im anderen Teil des Tagebuchs hatte ich dir gesagt, dass der Rollentrainer eine Anschaffung meines Bruders war. Das heißt, in meinem Arbeitszimmer stand kein Rollentrainer, nicht neben meinem Mac und nicht neben meinem Sofa.
Viele Alternativen gab es also nicht. Entweder kaufte ich einen Rollentrainer und stellte ihn neben den Schreibtisch im Arbeitszimmer, vierundzwanzig Stunden am Tag mit dem Mac verbunden, und stand jedes Mal zum Treten auf, wenn zu prüfen war, ob ein Button ausgerichtet war. Oder ich baute mir einen eigenen: nicht in Hardware, natürlich, aber eine simulierte Version.
Ich habe mir den Rollentrainer gebaut.
Vorher aber: Was ist FTMS, in zwei Minuten
Hast du einen „smarten“ Rollentrainer, der in den letzten Jahren gekauft wurde, spricht in ihm ein Standard namens FTMS, Fitness Machine Service. Es ist eine öffentliche Spezifikation der Bluetooth SIG, und das Schöne ist, dass es wirklich ein Standard ist: Wahoo, Elite, Tacx, Decathlon, die namenlosen Chinesen. Alle setzen ihn um.
So funktioniert es. Der Rollentrainer stellt drei „Briefkästen“ bereit:
0x2AD2: die Daten, die er selbst sendet, also Leistung, Trittfrequenz und Geschwindigkeit. Sie kommen laufend, ohne dass du etwas anfragst.0x2AD9: der Control Point, in den du schreibst. Von hier sagst du ihm „stell jetzt 6 % Steigung ein“.0x2ACC: was er kann. Ein Bitfeld: Bit 13 zum Beispiel sagt „ich kann eine Steigung simulieren“.
Der erste Befehl, vielleicht der interessanteste von allen, ist 0x11, setSimulation. Du übergibst Wind, Steigung, Rollwiderstandskoeffizient und Luftwiderstand. Die Steigung ist eine vorzeichenbehaftete 16-Bit-Ganzzahl in Hundertsteln eines Prozentpunkts: 6,25 % schreibt man 625.
Dann antwortet der Rollentrainer. Wenn alles gut gegangen ist: 0x80 0x11 0x01, also „ich habe 0x11 empfangen, Ergebnis: Success“.
Ganz einfach, oder?
Ja. Auf dem Papier.
Stufe 1: der Rollentrainer, den es nicht gibt
Der erste Zug ist der, den jeder Entwickler machen würde: ein Protokoll dorthin setzen, wo eine Systemklasse stand.
Statt direkt mit CBPeripheral zu sprechen, spricht der Code mit einem TrainerTransport, mit drei Operationen: Bytes schreiben, auf Benachrichtigungen lauschen, einen Wert lesen. Darunter kannst du das echte Bluetooth setzen oder sonst irgendetwas.
Das ist keine originelle Idee. Es ist eine der ersten, die man lernt, und meist wendet man sie mit Maß an, weil Abstrahieren etwas kostet. Hier gab es keine Wahl: Ohne dieses Protokoll ist die halbe App auf einem Entwicklungsrechner nicht ausführbar.
Der Gewinn kam allerdings aus einer Richtung, die ich nicht vorhergesehen hatte.
In den Tests steht hinter diesem Protokoll nicht das Bluetooth, sondern ein falscher Rollentrainer: ein Objekt, das auf Befehle antwortet wie ein echter Rollentrainer und von dem ich verlangen kann, dass er sich schlecht benimmt. Das erledigen zwei Eigenschaften mit etwas komischen Namen:
var silentOpCodes: Set<UInt8> = [] // Befehle, auf die der Rollentrainer nicht antwortet
var deniesOnceOpCodes: Set<UInt8> = [] // Befehle, die er beim ersten Mal ablehnt und dann akzeptiert
Halten wir kurz an, denn diese zwei Zeilen sind der eigentliche Grund, warum die App funktioniert.
Wie probierst du aus, was passiert, wenn der Rollentrainer nicht antwortet? Am echten Gerät: Du ziehst ihm mitten im Befehl den Stecker und hoffst, den richtigen Moment zu erwischen. Einmal von zwanzig. Dann musst du es noch einmal tun, um die Korrektur zu prüfen, und in der Zwischenzeit hast du die App sechsmal neu gestartet.
Mit dem falschen: Du fügst 0x11 zu silentOpCodes hinzu, und der Rollentrainer wird bei genau diesem Befehl stumm, jedes Mal, in zehn Millisekunden.
Dasselbe gilt für deniesOnceOpCodes, den fiesesten Fall: der Rollentrainer, der die erste Anfrage ablehnt und die zweite akzeptiert. Das passiert wirklich, wenn die Kontrolle von einer anderen App übernommen und dann freigegeben wird. Es von Hand auszulösen, erfordert zwei Telefone und eine Choreografie.
Der Test, der das prüft, mit dem falschen Rollentrainer, der beim ersten Mal ablehnt, tauscht genau diese Bytes aus:
→ 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 ist 740 mit vertauschten Bytes, also eine Steigung von 7,40 %: derselbe Befehl, zweimal geschrieben.
Die Fehlerzweige sind der Teil der App, der entscheidet, ob eine Sitzung zerbricht oder nicht, und sie sind auch der Teil, den niemand ausprobiert, weil das unbequem ist. Mein Kniff mit dem Protokoll TrainerTransport hat sie bequem gemacht. Daraus sind vier echte Fehler hervorgegangen, einer davon wirklich interessant: Die Befehlswarteschlange, die ich gebaut hatte, serialisierte nicht wirklich, sodass sich zwei dicht aufeinanderfolgende Anfragen überlappen konnten. Und der Rollentrainer, der immer nur eine annimmt, wies die zweite Anfrage ab.
Stufe 2: die Zustandsautomaten
Der falsche Transport löst die Unit-Tests. Er löst nicht die Entwicklung der Oberfläche: Um ein Dashboard zu zeichnen, brauche ich einen Rollentrainer, der sich wie ein Rollentrainer verhält, der auf Befehle antwortet, der Daten sendet, während ich trete.
Also die Stufe darüber: ein richtiger Simulator, mit drei Zustandsautomaten, die FTMS sprechen, wie es ein echtes Gerät tut.
- Der Automat des Control Point: Er empfängt
0x00(Kontrolle übernehmen),0x07(starten),0x11(Steigung),0x05(Zielleistung),0x08(stoppen). Er antwortet mit0x80und einem Ergebnis. Er weist Befehle in der falschen Reihenfolge zurück, denn ein echter Rollentrainer tut das:0x11ohne vorherige Kontrollübernahme ergibtControl Not Permitted. - Der Automat der Sitzung:
idle→started→paused→stopped. - Der Automat des Modus: keiner, Leistung, Widerstand, Simulation.
Und dann der Teil, der sich als der nützlichste am ganzen Simulator erwiesen hat: die Profile echter Geräte.
Heute elf. KICKR CORE, ZBike 2.0, Van Rysel D100, Elite Direto XR, Elite Justo, Elite Suito. Ein generisches FTMS. Ein Herzfrequenzgurt, ein Trittfrequenzsensor, ein Leistungsmesser.
Und das hier:
case ftmsWithoutSimulation
Ein Rollentrainer, der FTMS spricht, aber Bit 13 nicht deklariert. Er kann keine Steigung simulieren.
Den gibt es wirklich. Das sind die Rollentrainer mit geregeltem Widerstand: Du kannst ihm sagen „streng dich mehr an“, aber nicht „du bist jetzt bei 6 %“. Nimmt deine App an, dass jeder FTMS-Rollentrainer simulieren kann, bricht sie bei diesen Geräten auf besonders unangenehme Weise: verbunden, scheinbar funktionierend, und die Steigung ändert sich nie.
Ihn im Simulator zu haben, bedeutete, den Rückfall auf Widerstand zu schreiben, also eine Abbildung von Steigung auf Widerstandsstufe mit eigener Mathematik, bevor ich ein solches Gerät in der Hand hatte. Als dann eines kam, hat es funktioniert; und es war eine der größten Genugtuungen, die ich im Jahr 2026 hatte.
Es war kein Glück. Der schwierige Fall war durchdacht worden, statt entdeckt.
Stufe 3: der Radfahrer, den es nicht gibt

Ein simulierter Rollentrainer ohne jemanden, der darauf tritt, sendet null Watt.
Also gibt es auch den Radfahrer. Er hat vier Formen: konstant, an einem guten Tag, an einem schlechten Tag, jede Fahrt anders. Die erste ist die der Tests, und das wichtige Wort ist deterministisch.
Ein Test, der von einer Zufallszahl abhängt, ist ein Test, der dienstags fehlschlägt. Er besteht zwanzigmal, scheitert beim einundzwanzigsten Mal, und du verlierst einen Nachmittag mit der Suche nach einem Fehler, den es nicht gibt. Oder, viel schlimmer, du gewöhnst dich daran, ihn neu zu starten, bis er besteht. An dem Tag, an dem er aus einem echten Grund fehlschlägt, startest du ihn neu.
Mit dem konstanten Radfahrer ergeben zwei Fahrten auf derselben Route exakt dieselbe Zeit. Das hat einen lustigen Nebeneffekt: Die „Geister“-Funktion, bei der du gegen dich selbst fährst, zeigt immer null Sekunden Abstand. Um sie auszuprobieren, braucht es die Form „jede Fahrt anders“.
Es gibt auch einen Beschleuniger. Bei 50× ist eine zweistündige Route in gut zwei Minuten durch.
Das ist das Stück, das es mir erlaubt hat, Dinge auszuprobieren, die sonst unzumutbar wären: was mit der Zusammenfassung nach drei Stunden Fahrt passiert, wie sich der Anstiegserkenner auf einer Strecke mit vierzehn Anstiegen verhält, was das Archiv tut, wenn fünfzig Aktivitäten hineinkommen.
Und es hat auch einen lehrreichen Fehler hervorgebracht. Eine Toleranz war in Uhrsekunden ausgedrückt, und bei 50× liefen diese Sekunden fünfzigmal schneller ab: Die Schwelle löste dort aus, wo sie nicht sollte. Eine richtige Zahl, gemessen mit der falschen Uhr. Ich habe eine Weile gebraucht, um das zu sehen, weil der Code korrekt war: Er war korrekt bezogen auf eine Zeit, die bei 50× nicht mehr dieselbe ist.
Und dann habe ich den echten Rollentrainer angeschlossen
Hier kippt der Artikel, und das ist der Teil, den ich wirklich erzählen wollte.
Der Simulator funktionierte. Das Dashboard war fertig, die Tests grün, die Fehlerzweige abgedeckt. Ich habe einen Wahoo KICKR CORE angeschlossen und bin aufs Rad gestiegen.
Im Log benachrichtigte der Rollentrainer zwei Characteristics mit je 10 Hz.
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
Eine Sekunde aus dem am 20. August exportierten Log, um 18:18:33: zwanzig Zeilen. Die ganze Datei enthält zwanzigtausend, von 18:10:29 bis 18:26:28.
Mein Code ging von vier pro Sekunde aus. Und nicht aus Unachtsamkeit: Die FTMS-Spezifikation schreibt keine Frequenz vor, vier pro Sekunde kamen mir vernünftig vor, und mein Simulator sendete genau vier Benachrichtigungen pro Sekunde. Ich hatte ihn selbst geschrieben, ausgehend von dieser Annahme.
Da habe ich etwas verstanden, das mir heute selbstverständlich scheint: Ein Simulator reproduziert getreu, was du glaubst. Das ist kein Fehler von ihm, das ist seine Natur. Liegst du bei einer Annahme falsch, liegst du doppelt falsch: im Code und im Simulator, der ihn auf die Probe stellen sollte.
Diese Annahme hatte zwei Folgen, und beide habe ich im Feld gesehen.
Die erste betraf das Diagnoseprotokoll, das die App führt, um zu rekonstruieren, was passiert ist, wenn etwas schiefgeht. Es ist ein Ring mit zwanzigtausend Zeilen: Ist er voll, löscht jede neue Zeile die älteste. Bei zwanzig Zeilen pro Sekunde sind zwanzigtausend Zeilen sechzehn Minuten. Exportierte man das Protokoll also am Ende einer Stunde Fahrt, war ein Problem, das in der vierzigsten Minute aufgetreten war, schon gelöscht.
Die zweite war eine Schwelle, die grundlos auslöste. Die App prüft, dass die Trittfrequenz nicht hängen bleibt, denn ein Sensor, der sich aufhängt, wiederholt ständig denselben Wert. Die Regel lautete: Ändert sich die Zahl zehn Sekunden lang nicht, gibt es ein Problem. Der KICKR meldet die Trittfrequenz aber in ganzen Umdrehungen, während die Spezifikation auch halbe Umdrehungen vorsieht, und wer gleichmäßig tritt, kann etliche Sekunden in Folge bei derselben Zahl bleiben, ohne dass irgendetwas nicht stimmt. Der Alarm löste genau bei dem aus, der gut trat.
Daraus ist eine Regel entstanden, die ich in die Dokumentation des Projekts geschrieben habe und oft wieder lese:
Der Takt eines Geräts wird gemessen, nicht angenommen.
Was ich mitnehme
Der Simulator ist kein Notbehelf, der auf die Hardware wartet. Er ist ein zweites Werkzeug, mit einem anderen Handwerk:
| der Simulator | die echte Hardware | |
|---|---|---|
| dient dazu | zu erkunden: seltene Fälle, Fehler, lange Szenarien | zu widerlegen |
| deckt ab | alles, was du dir gedacht hast | was du dir nicht gedacht hattest |
| kostet | zehn Millisekunden | eine Stunde, und du musst schwitzen |
| wann | jeden Tag, jeder Commit | bevor du sagst „es funktioniert“ |
Der Simulator schützt dich vor Fehlern. Er schützt dich nicht vor deinen Annahmen, denn er ist aus deinen Annahmen gemacht.
Die Frage, die ich mir jetzt stelle, jedes Mal wenn der Simulator sagt, dass alles in Ordnung ist, lautet also: Habe ich das, was ich gerade prüfe, gemessen, oder habe ich es selbst an beiden Stellen geschrieben?
Es ist eine Frage, die fast immer dieselbe Antwort hat: die zweite.

Der Code von Grade umfasst heute mehr als hundertdreißigtausend Zeilen Swift, und fast vier von zehn sind Tests. Der Simulator mit seinen drei Stufen, elf Geräteprofilen und vier Radfahrerformen kommt dem, der die App benutzt, nie vor Augen: Er ist ganz Gerüst.
Aber ohne dieses Gerüst gäbe es nichts zu benutzen. Und jedes Mal, wenn mich jemand fragt, warum eine Anwendung, die „eine Zahl an einen Rollentrainer schickt“, so viel Zeit gebraucht hat, ist die ehrlichste Antwort, dass die Zahl der leichte Teil ist.
Der schwierige Teil ist alles, was passiert, wenn die Zahl nicht ankommt.
Grade ist ein Simulator für GPX-Routen für Smart Trainer. Du importierst die Spur einer echten Fahrt, und der Rollentrainer gibt dir ihre Anstiege, Steigung für Steigung, Meter für Meter. iPhone, iPad, Mac.


