Construí un rodillo falso para poder construir el de verdad
Cómo se desarrolla una app que habla con el hardware, cuando el hardware no está.

Hubo un momento preciso, al principio de este proyecto, en que me senté delante de Xcode con las ideas clarísimas y me di cuenta de que no podía escribir una línea.
Grade toma un archivo GPX, la traza de una salida de verdad con sus subidas, y mientras pedaleas en un rodillo de entrenamiento le manda la pendiente que corresponde al punto en el que estás. La subida que estás escalando la tienes bajo los pedales. Todo el producto está ahí.
Y todo ese producto pasa por un protocolo Bluetooth.
El primer problema es que el simulador de iOS en Mac no tiene Bluetooth. No es una limitación parcial, no es "funciona a medias": CoreBluetooth en el simulador sencillamente no existe. Puedes compilar, puedes lanzar, y el framework te dice educadamente que no hay ningún módulo Bluetooth. El segundo problema era el rodillo. ¿Te acuerdas? En la otra entrega del diario te dije que el rodillo era una compra de mi hermano. Lo que significa que no había ningún rodillo en mi estudio, junto a mi Mac y junto a mi sofá.
Las alternativas, por tanto, no eran muchas. O compraba un rodillo y lo tenía junto al escritorio del estudio, conectado al Mac veinticuatro horas al día, levantándome a pedalear cada vez que hubiera que comprobar si un botón estaba alineado. O me construía uno propio: no en hardware, claro, sino una versión simulada.
Me construí el rodillo.
Antes, sin embargo: qué es FTMS, en dos minutos
Si tienes un rodillo "inteligente" comprado en los últimos años, por dentro habla un estándar que se llama FTMS, Fitness Machine Service. Es una especificación pública del Bluetooth SIG, y lo bueno es que es de verdad un estándar: Wahoo, Elite, Tacx, Decathlon, los chinos sin nombre. Todos lo implementan.
Funciona así. El rodillo expone tres "buzones":
0x2AD2: los datos que manda él, es decir, potencia, cadencia y velocidad. Llegan continuamente, sin que pidas nada.0x2AD9: el control point, donde escribes tú. Es desde aquí desde donde le dices «ahora pon el 6 % de pendiente».0x2ACC: qué sabe hacer. Un campo de bits: el bit 13, por ejemplo, dice «sé simular una pendiente».
El primer comando, quizá el más interesante de todos, es 0x11, setSimulation. Le pasas viento, pendiente, coeficiente de rodadura y resistencia aerodinámica. La pendiente es un entero de 16 bits con signo, en centésimas de punto porcentual: el 6,25 % se escribe 625.
Luego el rodillo responde. Si todo ha ido bien: 0x80 0x11 0x01, es decir, «he recibido 0x11, resultado: Success».
Sencillísimo, ¿no?
Sí. Sobre el papel.
Nivel 1: el rodillo que no está
El primer movimiento es el que haría cualquier desarrollador: poner un protocolo donde había una clase de sistema.
En lugar de hablar directamente con CBPeripheral, el código habla con un TrainerTransport, con tres operaciones: escribir unos bytes, escuchar unas notificaciones, leer un valor. Debajo puedes poner el Bluetooth de verdad, o cualquier otra cosa.
No es una idea original. Es una de las primeras que se enseñan, y normalmente se aplica con moderación porque abstraer cuesta. Aquí no había elección: sin ese protocolo, la mitad de la app no se puede ejecutar en una máquina de desarrollo.
La ganancia, sin embargo, llegó desde una dirección que no había previsto.
En los tests, detrás de ese protocolo no hay Bluetooth sino un rodillo falso: un objeto que responde a los comandos como un rodillo de verdad, y al que puedo pedir que se porte mal. Lo hacen dos propiedades con nombres un poco raros:
var silentOpCodes: Set<UInt8> = [] // comandos a los que el rodillo no responde
var deniesOnceOpCodes: Set<UInt8> = [] // comandos que deniega la primera vez, y luego acepta
Detengámonos un momento, porque estas dos líneas son el verdadero motivo por el que la app funciona.
¿Cómo pruebas qué pasa si el rodillo no responde? En el dispositivo de verdad: le desenchufas el cable a mitad de comando y esperas dar con el momento justo. Una vez de cada veinte. Luego tienes que repetirlo para verificar la corrección, y mientras tanto has reiniciado la app seis veces.
Con el falso: añades 0x11 a silentOpCodes, y el rodillo se queda mudo con ese comando concreto, siempre, en diez milisegundos.
Lo mismo con deniesOnceOpCodes, que es el caso más retorcido: el rodillo que rechaza la primera petición y acepta la segunda. Pasa de verdad, cuando el control lo había tomado otra app y se libera. Provocarlo a mano requiere dos teléfonos y una coreografía.
La prueba que lo verifica, con el rodillo falso que rechaza la primera vez, intercambia exactamente estos bytes:
→ 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 es 740 con los bytes invertidos, es decir, una pendiente del 7,40 %: el mismo comando, escrito dos veces.
Las ramas de error son la parte de la app que decide si una sesión se rompe o no, y son también la parte que nadie prueba, porque probarlas es incómodo. Mi truco del protocolo TrainerTransport las ha hecho cómodas. De ahí salieron cuatro defectos reales, de los cuales uno de verdad interesante: la cola de comandos que había creado no serializaba de verdad, así que dos peticiones seguidas podían solaparse. Y el rodillo, que acepta una por vez, rechazaba la segunda petición.
Nivel 2: las máquinas de estados
El transporte falso resuelve los tests unitarios. No resuelve el desarrollo de la interfaz: para dibujar un panel necesito un rodillo que se comporte como un rodillo, que responda a los comandos, que mande datos mientras pedaleo.
Así que el nivel de arriba: un simulador de verdad, con tres máquinas de estados que hablan FTMS como lo habla un dispositivo real.
- La máquina del control point: recibe
0x00(toma el control),0x07(arranca),0x11(pendiente),0x05(potencia objetivo),0x08(para). Responde con0x80y un resultado. Rechaza los comandos recibidos en el orden equivocado, porque un rodillo de verdad hace eso:0x11sin haber tomado el control daControl Not Permitted. - La máquina de la sesión:
idle→started→paused→stopped. - La máquina del modo: ninguno, potencia, resistencia, simulación.
Y luego la parte que resultó ser la más útil de todo el simulador: los perfiles de dispositivos reales.
Once, hoy. KICKR CORE, ZBike 2.0, Van Rysel D100, Elite Direto XR, Elite Justo, Elite Suito. Un FTMS genérico. Una banda cardiaca, un sensor de cadencia, un medidor de potencia.
Y esto:
case ftmsWithoutSimulation
Un rodillo que habla FTMS pero no declara el bit 13. No sabe simular la pendiente.
Existe de verdad. Son los rodillos de resistencia controlada: puedes decirle «haz más fuerza», no puedes decirle «ahora estás al 6 %». Si tu app asume que todo rodillo FTMS sabe simular, en esos dispositivos se rompe de una manera especialmente desagradable: conectada, aparentemente funcionando, y con la pendiente que nunca cambia.
Tenerlo en el simulador significó escribir el recurso a la resistencia, es decir, una correspondencia de pendiente a nivel de resistencia con sus matemáticas, antes de tener en la mano un dispositivo así. Cuando luego llegó uno, funcionó; y fue una de las mayores satisfacciones que he tenido en 2026.
No era suerte. Era que el caso difícil había sido pensado en lugar de descubierto.
Nivel 3: el ciclista que no está

Un rodillo simulado sin nadie que pedalee encima manda cero vatios.
Así que también hay un ciclista. Tiene cuatro formas: constante, en día bueno, en día malo, distinto en cada salida. La primera es la de los tests, y la palabra importante es determinista.
Un test que depende de un número aleatorio es un test que falla en martes. Pasa veinte veces, falla a la vigésima primera, y pierdes una tarde buscando un defecto que no existe. O, mucho peor, te acostumbras a relanzarlo hasta que pasa. El día que falla por un motivo real, lo relanzas.
Con el ciclista constante, dos salidas por el mismo recorrido dan exactamente el mismo tiempo. Lo cual tiene un efecto colateral divertido: la función "fantasma", en la que corres contra ti mismo, muestra siempre cero segundos de diferencia. Para probarla hace falta la forma «distinto en cada salida».
Hay también un acelerador. A 50×, un recorrido de dos horas se agota en poco más de dos minutos.
Esta es la pieza que me permitió probar cosas que de otro modo serían impensables: qué pasa en el resumen tras tres horas de pedaleo, cómo se comporta el detector de subidas en un trazado con catorce ascensiones, qué hace el archivo cuando le entran cincuenta actividades.
Y produjo también un defecto instructivo. Una tolerancia estaba expresada en segundos de reloj, y a 50× esos segundos pasaban cincuenta veces más deprisa: el umbral saltaba donde no debía. Un número correcto, medido con el reloj equivocado. Tardé un poco en verlo, porque el código era correcto: era correcto respecto a un tiempo que a 50× ya no es el mismo.
Y luego conecté el rodillo de verdad
Aquí el artículo se da la vuelta, y esta es la parte que quería contar de verdad.
El simulador funcionaba. El panel estaba listo, los tests en verde, las ramas de error cubiertas. Conecté un Wahoo KICKR CORE y me subí a la bici.
En el registro, el rodillo notificaba dos características a 10 Hz cada una.
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 segundo del registro exportado el 20 de agosto, a las 18:18:33: veinte líneas. El archivo entero contiene veinte mil, de las 18:10:29 a las 18:26:28.
Mi código suponía cuatro por segundo. Y no por descuido: la especificación FTMS no impone una frecuencia, cuatro por segundo me parecía un valor razonable, y mi simulador mandaba justo cuatro notificaciones por segundo. Lo había escrito yo, partiendo de esta suposición.
Fue ahí donde entendí algo que hoy me parece evidente: un simulador reproduce fielmente lo que tú crees. No es un defecto suyo, es su naturaleza. Si te equivocas en una hipótesis, te equivocas dos veces: en el código, y en el simulador que debería ponerlo a prueba.
Esa suposición tuvo dos consecuencias, y las dos las vi sobre el terreno.
La primera afectaba al registro de diagnóstico, el que la app guarda para reconstruir qué pasó cuando algo va mal. Es un anillo de veinte mil líneas: cuando está lleno, cada línea nueva borra la más antigua. A veinte líneas por segundo, veinte mil líneas son dieciséis minutos. Así, si exportabas el registro al final de una hora de pedaleo, un problema aparecido en el minuto cuarenta ya había sido borrado.
La segunda era un umbral que saltaba sin motivo. La app comprueba que la cadencia no se quede bloqueada, porque un sensor que se cuelga sigue repitiendo el mismo valor. La regla decía: si durante diez segundos el número no cambia, hay un problema. Pero el KICKR comunica la cadencia en revoluciones enteras, mientras que la especificación prevé también las medias revoluciones, y quien pedalea de forma regular puede quedarse en el mismo número durante bastantes segundos seguidos sin que haya nada raro. La alarma saltaba justo con quien estaba pedaleando bien.
De ahí una regla que escribí en la documentación del proyecto y que releo a menudo:
El ritmo de un dispositivo se mide, no se supone.
Lo que me llevo
El simulador no es un parche a la espera del hardware. Es una segunda herramienta, con un oficio distinto:
| el simulador | el hardware de verdad | |
|---|---|---|
| sirve para | explorar: casos raros, errores, escenarios largos | desmentir |
| cubre | todo lo que has pensado | lo que no habías pensado |
| cuesta | diez milisegundos | una hora, y tienes que sudar |
| cuándo | cada día, cada commit | antes de decir «funciona» |
El simulador te protege de los defectos. No te protege de tus suposiciones, porque está hecho de tus suposiciones.
Así que la pregunta que me hago ahora, cada vez que el simulador dice que todo va bien, es: esto que estoy verificando, ¿lo he medido o lo he escrito yo en los dos sitios?
Es una pregunta que casi siempre tiene la misma respuesta: la segunda.

El código de Grade tiene hoy más de ciento treinta mil líneas de Swift, y casi cuatro de cada diez son tests. El simulador, con sus tres niveles, once perfiles de dispositivo y cuatro formas de ciclista, nunca llega a manos de quien usa la app: es todo andamiaje.
Pero sin ese andamiaje no habría nada que usar. Y cada vez que alguien me pregunta por qué una aplicación que "manda un número a un rodillo" ha requerido tanto tiempo, la respuesta más honesta es que el número es la parte fácil.
La parte difícil es todo lo que pasa cuando el número no llega.
Grade es un simulador de rutas GPX para smart trainers. Importas la traza de una salida de verdad, y el rodillo te da sus subidas, pendiente a pendiente, metro a metro. iPhone, iPad, Mac.


