Ho costruito un rullo finto per poter costruire quello vero
Come si sviluppa un'app che parla con l'hardware, quando l'hardware non c'è.

C'è stato un momento preciso, all'inizio di questo progetto, in cui mi sono seduto davanti a Xcode con le idee chiarissime e mi sono accorto di non poter scrivere una riga.
Grade prende un file GPX, la traccia di un giro vero con le sue salite, e mentre pedali su un rullo da allenamento gli comanda la pendenza che corrisponde al punto in cui ti trovi. La salita che stai scalando ce l'hai sotto i pedali. Tutto il prodotto sta lì.
E tutto quel prodotto passa da un protocollo Bluetooth.
Il primo problema è che il simulatore iOS su Mac non ha il Bluetooth. Non è una limitazione parziale, non è "funziona a metà": CoreBluetooth sul simulatore semplicemente non esiste. Puoi compilare, puoi lanciare, e il framework ti dice educatamente che non c'è nessun modulo Bluetooth. Il secondo problema era il rullo. Ricordi? Nell'altra puntata del diario ti avevo detto che il rullo era un acquisto di mio fratello. Il che significa che non c'era nessun rullo nel mio studio, vicino al mio Mac e vicino al mio divano.
Le alternative, quindi, non erano molte. O compravo un rullo e lo tenevo accanto alla scrivania dello studio, collegato al Mac ventiquattro ore su ventiquattro, alzandomi a pedalare ogni volta che c'era da controllare se un pulsante fosse allineato. Oppure me ne costruivo uno mio: non in hardware, certo, ma una versione simulata.
Mi sono costruito il rullo.
Prima però: cos'è FTMS, in due minuti
Se hai un rullo "smart" comprato negli ultimi anni, dentro parla uno standard che si chiama FTMS, Fitness Machine Service. È una specifica pubblica del Bluetooth SIG, e la cosa bella è che è davvero uno standard: Wahoo, Elite, Tacx, Decathlon, i cinesi senza nome. Tutti la implementano.
Funziona così. Il rullo espone tre "caselle postali":
0x2AD2: i dati che manda lui, cioè potenza, cadenza e velocità. Arrivano in continuazione, senza che tu chieda niente.0x2AD9: il control point, dove scrivi tu. È da qui che gli dici «adesso metti il 6% di pendenza».0x2ACC: cosa sa fare. Un campo di bit: il bit 13, per esempio, dice «so simulare una pendenza».
Il primo comando, forse il più interessante di tutti, è 0x11, setSimulation. Gli passi vento, pendenza, coefficiente di rotolamento e resistenza aerodinamica. La pendenza è un intero a 16 bit con segno, in centesimi di punto percentuale: il 6,25% si scrive 625.
Poi il rullo risponde. Se tutto è andato bene: 0x80 0x11 0x01, cioè «ho ricevuto 0x11, esito: Success».
Semplicissimo, no?
Sì. Sulla carta.
Livello 1: il rullo che non c'è
La prima mossa è quella che qualunque sviluppatore farebbe: mettere un protocollo dove c'era una classe di sistema.
Al posto di parlare direttamente con CBPeripheral, il codice parla con un TrainerTransport, con tre operazioni: scrivi dei byte, ascolta delle notifiche, leggi un valore. Sotto ci puoi mettere il Bluetooth vero, oppure qualunque altra cosa.
Non è un'idea originale. È una delle prime che insegnano, e di solito la si applica con moderazione perché astrarre costa. Qui non c'era scelta: senza quel protocollo, metà dell'app non è eseguibile su una macchina da sviluppo.
Il guadagno però è arrivato da una direzione che non avevo previsto.
Nei test, dietro quel protocollo non c'è il Bluetooth ma un rullo finto: un oggetto che risponde ai comandi come un rullo vero, e a cui posso chiedere di comportarsi male. Lo fanno due proprietà dai nomi un po' buffi:
var silentOpCodes: Set<UInt8> = [] // comandi a cui il rullo non risponde
var deniesOnceOpCodes: Set<UInt8> = [] // comandi che nega la prima volta, poi accetta
Fermiamoci un attimo, perché queste due righe sono il vero motivo per cui l'app funziona.
Come fai a provare cosa succede se il rullo non risponde? Sul dispositivo vero: gli stacchi la spina a metà comando e speri di beccare il momento giusto. Una volta su venti. Poi devi rifarlo per verificare la correzione, e nel frattempo hai riavviato l'app sei volte.
Col finto: aggiungi 0x11 a silentOpCodes, e il rullo diventa muto su quel comando specifico, ogni volta, in dieci millisecondi.
Lo stesso per deniesOnceOpCodes, che è il caso più cattivo: il rullo che rifiuta la prima richiesta e accetta la seconda. Succede davvero, quando il controllo è stato preso da un'altra app e viene rilasciato. Provocarlo a mano richiede due telefoni e una coreografia.
Il test che lo verifica, col rullo finto che rifiuta la prima volta, scambia esattamente questi byte:
→ 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 è 740 con i byte invertiti, cioè una pendenza del 7,40%: lo stesso comando, scritto due volte.
I rami d'errore sono la parte dell'app che decide se una sessione si rompe o no, e sono anche la parte che nessuno prova, perché provarli è scomodo. Il mio stratagemma del protocollo TrainerTransport li ha resi comodi. Da lì sono usciti quattro difetti veri, di cui uno davvero interessante: la coda dei comandi che avevo creato non serializzava davvero, quindi due richieste ravvicinate potevano sovrapporsi. E il rullo, che ne accetta una per volta, rispondeva picche alla seconda richiesta.
Livello 2: le macchine a stati
Il trasporto finto risolve i test unitari. Non risolve lo sviluppo dell'interfaccia: per disegnare una dashboard mi serve un rullo che si comporti da rullo, che risponda ai comandi, che mandi dati mentre pedali.
Quindi il livello sopra: un simulatore vero e proprio, con tre macchine a stati che parlano FTMS come lo parla un dispositivo reale.
- La macchina del control point: riceve
0x00(prendi il controllo),0x07(parti),0x11(pendenza),0x05(potenza target),0x08(ferma). Risponde con0x80e un esito. Rifiuta i comandi ricevuti nell'ordine sbagliato, perché un rullo vero fa così:0x11senza aver preso il controllo dàControl Not Permitted. - La macchina della sessione:
idle→started→paused→stopped. - La macchina della modalità: nessuna, potenza, resistenza, simulazione.
E poi la parte che si è rivelata la più utile di tutto il simulatore: i profili di dispositivi reali.
Undici, oggi. KICKR CORE, ZBike 2.0, Van Rysel D100, Elite Direto XR, Elite Justo, Elite Suito. Un FTMS generico. Una fascia cardio, un sensore di cadenza, un misuratore di potenza.
E questo:
case ftmsWithoutSimulation
Un rullo che parla FTMS ma non dichiara il bit 13. Non sa simulare la pendenza.
Esiste davvero. Sono i rulli a resistenza controllata: puoi dirgli «fai più fatica», non puoi dirgli «adesso sei al 6%». Se la tua app assume che ogni rullo FTMS sappia simulare, su quei dispositivi si rompe in un modo particolarmente sgradevole: collegata, apparentemente funzionante, e con la pendenza che non cambia mai.
Averlo nel simulatore ha significato scrivere il ripiego sulla resistenza, cioè una mappatura da pendenza a livello di resistenza con la sua matematica, prima di avere in mano un dispositivo così. Quando poi ne è arrivato uno, ha funzionato; ed è stata una delle più grandi soddisfazioni che ho avuto nel 2026.
Non era fortuna. Era che il caso difficile era stato pensato invece che scoperto.
Livello 3: il ciclista che non c'è

Un rullo simulato senza qualcuno che ci pedali sopra manda zero watt.
Quindi c'è anche il ciclista. Ha quattro forme: costante, in giornata buona, in giornata scarsa, diverso ogni corsa. La prima è quella dei test, e la parola importante è deterministico.
Un test che dipende da un numero casuale è un test che fallisce di martedì. Passa venti volte, fallisce alla ventunesima, e tu perdi un pomeriggio a cercare un difetto che non c'è. Oppure, molto peggio, ti abitui a rilanciarlo finché non passa. Il giorno in cui fallisce per un motivo vero, lo rilanci.
Col ciclista costante, due corse sullo stesso percorso danno lo stesso identico tempo. Il che ha un effetto collaterale divertente: la funzione "fantasma", in cui corri contro te stesso, mostra sempre zero secondi di scarto. Per provarla serve la forma «diverso ogni corsa».
C'è anche un acceleratore. A 50×, un percorso da due ore si esaurisce in poco più di due minuti.
Questo è il pezzo che mi ha permesso di provare cose altrimenti improponibili: cosa succede al riepilogo dopo tre ore di pedalata, come si comporta il rilevatore di salite su un tracciato con quattordici scalate, cosa fa l'archivio quando ci finiscono dentro cinquanta attività.
E ha prodotto anche un difetto istruttivo. Una tolleranza era espressa in secondi di orologio, e a 50× quei secondi passavano cinquanta volte più in fretta: la soglia scattava dove non doveva. Un numero giusto, misurato con l'orologio sbagliato. Ci ho messo un po' a vederlo, perché il codice era corretto: era corretto rispetto a un tempo che a 50× non è più quello.
E poi ho attaccato il rullo vero
Qui l'articolo si ribalta, e questa è la parte che volevo raccontare davvero.
Il simulatore funzionava. La dashboard era pronta, i test verdi, i rami d'errore coperti. Ho collegato un Wahoo KICKR CORE e sono salito in sella.
Nel log, il rullo notificava due caratteristiche a 10 Hz ciascuna.
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
Un secondo del log esportato il 20 agosto, alle 18:18:33: venti righe. Il file intero ne contiene ventimila, dalle 18:10:29 alle 18:26:28.
Il mio codice ne supponeva quattro al secondo. E non per distrazione: la specifica FTMS non impone una frequenza, quattro al secondo mi sembrava un valore ragionevole, e il mio simulatore mandava proprio quattro notifiche al secondo. L'avevo scritto io, partendo da questa supposizione.
È lì che ho capito una cosa che oggi mi sembra evidente: un simulatore riproduce fedelmente quello che credi. Non è un suo difetto, è la sua natura. Se sbagli un'ipotesi, la sbagli due volte: nel codice, e nel simulatore che dovrebbe metterlo alla prova.
Quella supposizione ha avuto due conseguenze, ed entrambe le ho viste sul campo.
La prima riguardava il log di diagnostica, quello che l'app tiene per ricostruire cosa è successo quando qualcosa va storto. È un anello da ventimila righe: quando è pieno, ogni riga nuova cancella la più vecchia. A venti righe al secondo, ventimila righe sono sedici minuti. Così, se esportavi il log alla fine di un'ora di pedalata, un problema comparso al quarantesimo minuto era già stato cancellato.
La seconda era una soglia che scattava senza motivo. L'app controlla che la cadenza non resti bloccata, perché un sensore che si pianta continua a ripetere lo stesso valore. La regola diceva: se per dieci secondi il numero non cambia, c'è un problema. Ma il KICKR comunica la cadenza in giri interi, mentre la specifica prevede anche i mezzi giri, e chi pedala in modo regolare può restare sullo stesso numero per parecchi secondi di fila senza che ci sia niente di strano. L'allarme scattava proprio su chi stava pedalando bene.
Da lì una regola che ho scritto nella documentazione del progetto e che rileggo spesso:
Un ritmo di dispositivo si misura, non si suppone.
Cosa mi porto dietro
Il simulatore non è un ripiego in attesa dell'hardware. È un secondo strumento, con un mestiere diverso:
| il simulatore | l'hardware vero | |
|---|---|---|
| serve a | esplorare: casi rari, errori, scenari lunghi | smentire |
| copre | tutto ciò che hai pensato | ciò che non avevi pensato |
| costa | dieci millisecondi | un'ora, e devi sudare |
| quando | ogni giorno, ogni commit | prima di dire «funziona» |
Il simulatore ti protegge dai difetti. Non ti protegge dalle tue supposizioni, perché è fatto delle tue supposizioni.
Quindi la domanda che mi faccio adesso, ogni volta che il simulatore dice che va tutto bene, è: questa cosa che sto verificando, l'ho misurata o l'ho scritta io in entrambi i posti?
È una domanda che ha quasi sempre la stessa risposta: la seconda.

Il codice di Grade ha oggi più di centotrentamila righe di Swift, e quasi quattro su dieci sono test. Il simulatore, con i suoi tre livelli, undici profili di dispositivo e quattro forme di ciclista, non arriva mai davanti a chi usa l'app: è tutta impalcatura.
Ma senza quell'impalcatura non ci sarebbe niente da usare. E ogni volta che qualcuno mi chiede perché un'applicazione che "manda un numero a un rullo" abbia richiesto tutto questo tempo, la risposta più onesta è che il numero è la parte facile.
La parte difficile è tutto ciò che succede quando il numero non arriva.
Grade è un simulatore di percorsi GPX per smart trainer. Importi la traccia di un giro vero, e il rullo ti dà le sue salite, pendenza per pendenza, metro per metro. iPhone, iPad, Mac.


