The PM5 doesn't speak CSAFE for everything
Third-party docs keep repeating that the sample rate is requested over CSAFE. It's wrong: you write a single byte to the Rowing Service characteristic. Finding that out cost an afternoon of packet captures.
Internal applications
For companies
Case · iRowing · TwoConnect
An app that reads the Concept2 PM5 erg over Bluetooth and shows pace, power and heart rate live. It was built entirely without ever having the machine.
The starting point
ThePM5isthemonitoronConcept2ergs:itbroadcastseverystrokeoverBluetoothLowEnergyinaproprietarybinaryformat.Withoutamachineonthedeskthere'snowaytoreadasinglerealbyte,andweweren'tgoingtostalldevelopmentuntilwegotone.
The decision that defined the project was to reverse the order: build the machine first, then the app. A software machine, faithful to the byte.
How it was built
Challenge 01No hardware
pm5emu is a deterministic PM5 emulator written in Go: a single physics and frame engine, ergcore, that generates the same binary frames the real machine would emit.
Deterministic means a virtual clock and a fixed seed: the same session produces exactly the same bytes, every time. That turns a hardware problem into a reproducible test.
Scenarios
steady_2k · power_drop · pyramid_with_dropout
Live telemetry
Power is sampled by writing a single byte to characteristic 0x0034 (not over CSAFE, as almost all third-party documentation assumes). Counters arrive as little-endian uint24 and wrap and reset have to be handled by hand.
Under the hood
Third-party docs keep repeating that the sample rate is requested over CSAFE. It's wrong: you write a single byte to the Rowing Service characteristic. Finding that out cost an afternoon of packet captures.
The multiplexed characteristic stops multiplexing if you subscribe to the individual ones. The right strategy is to subscribe only to 0x0080 and demultiplex in Dart.
An MIT-licensed BLE library behind ErgConnection, instead of maintaining two native codebases in Swift and Kotlin.
GATT 133, backoff, resubscription and the “PM5 busy” case. The order of operations is mandatory, or the connection dies silently.
A per-session write-ahead log: if the app dies halfway through a 2k, the piece isn't lost.
Radio input
2 out of range · logged, not discarded
Everything that arrives over radio is treated as hostile: physiological ranges are validated on the server, the parser is versioned, and frames that don't fit are logged instead of silently dropped. A counter that jumps backward is a wrap bug, not an athlete who rowed in reverse.
Explicit classification, a legal basis under GDPR Art. 9 and Argentina's Law 25.326, in-app account deletion and row-level RLS in PostgreSQL.
Yours could be next
Like iRowing, your project can get started even when pieces are missing. Tell us what you need and in 30 minutes we'll leave the call with the scope locked and a delivery date.
More case studies
Internal ERP · Management and invoicing
From zero control to everything measured
Chamber of commerce · Full platform
No SaaS license
Outdoor living · 3D configurator + CRM
From 3D to proposal
Roadside emergencies · Mobile app
3 roles, 1 codebase
Car dealerships · Identity verification
12 layers, one Trust Score
The starting point
ThePM5isthemonitoronConcept2ergs:itbroadcastseverystrokeoverBluetoothLowEnergyinaproprietarybinaryformat.Withoutamachineonthedeskthere'snowaytoreadasinglerealbyte,andweweren'tgoingtostalldevelopmentuntilwegotone.
The decision that defined the project was to reverse the order: build the machine first, then the app. A software machine, faithful to the byte.
How it was built
Challenge 01No hardware
pm5emu is a deterministic PM5 emulator written in Go: a single physics and frame engine, ergcore, that generates the same binary frames the real machine would emit.
Deterministic means a virtual clock and a fixed seed: the same session produces exactly the same bytes, every time. That turns a hardware problem into a reproducible test.
Scenarios
steady_2k · power_drop · pyramid_with_dropout
Live telemetry
Power is sampled by writing a single byte to characteristic 0x0034 (not over CSAFE, as almost all third-party documentation assumes). Counters arrive as little-endian uint24 and wrap and reset have to be handled by hand.
Under the hood
Third-party docs keep repeating that the sample rate is requested over CSAFE. It's wrong: you write a single byte to the Rowing Service characteristic. Finding that out cost an afternoon of packet captures.
The multiplexed characteristic stops multiplexing if you subscribe to the individual ones. The right strategy is to subscribe only to 0x0080 and demultiplex in Dart.
An MIT-licensed BLE library behind ErgConnection, instead of maintaining two native codebases in Swift and Kotlin.
GATT 133, backoff, resubscription and the “PM5 busy” case. The order of operations is mandatory, or the connection dies silently.
A per-session write-ahead log: if the app dies halfway through a 2k, the piece isn't lost.
Radio input
2 out of range · logged, not discarded
Everything that arrives over radio is treated as hostile: physiological ranges are validated on the server, the parser is versioned, and frames that don't fit are logged instead of silently dropped. A counter that jumps backward is a wrap bug, not an athlete who rowed in reverse.
Explicit classification, a legal basis under GDPR Art. 9 and Argentina's Law 25.326, in-app account deletion and row-level RLS in PostgreSQL.
Yours could be next
Like iRowing, your project can get started even when pieces are missing. Tell us what you need and in 30 minutes we'll leave the call with the scope locked and a delivery date.
iRowing · TwoConnect · Flutter, Go and Supabase on the Concept2 PM5 over Bluetooth Low Energy.