Tell us your idea

Case · iRowing · TwoConnect

iRowing

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.

Flutter · iOS + AndroidBLE · Concept2 PM5Supabase · RLSGo emulator

The starting point

The client had the machine in another country.
We had to write the code that reads it.

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

One card, five turns.

01
02
03
04
05

Challenge 01No hardware

We built the erg in software

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

PM5in another country
we wrote it
pm5emuGo · ergcore
physics
binary frames
virtual clock
fixed seed
pm5emurunning locally
ws://localhost:8080
1:57/500 m
Bluetooth required0· Linux · macOS · Windows

Live telemetry

Every stroke, drawn as it happens.

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

What took real work.

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.

write 1 byte → 0x0034
0x0080pacepowerstroke rate

Mutual exclusion on 0x0080

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.

No native bridge

An MIT-licensed BLE library behind ErgConnection, instead of maintaining two native codebases in Swift and Kotlin.

1 interface · 0 native code

Real reconnection

GATT 133, backoff, resubscription and the “PM5 busy” case. The order of operations is mandatory, or the connection dies silently.

GATT 133 → backoff → resub

Crash recovery

A per-session write-ahead log: if the app dies halfway through a 2k, the piece isn't lost.

WAL per session

Radio input

2 out of range · logged, not discarded

BLE is untrusted input

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.

Health data

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.

Row-level RLS · in-app deletion
0
transports in a single emulator: WebSocket and BLE peripheral
0
packages in the monorepo, with boundaries enforced by lint
0
hours of hardware needed to develop and run CI
0
session with the real machine, and it was enough

Yours could be next

We build the system for you

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.

The starting point

The client had the machine in another country.
We had to write the code that reads it.

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

One card, five turns.

01
02
03
04
05

Challenge 01No hardware

We built the erg in software

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

PM5in another country
we wrote it
pm5emuGo · ergcore
physics
binary frames
virtual clock
fixed seed
pm5emurunning locally
ws://localhost:8080
1:57/500 m
Bluetooth required0· Linux · macOS · Windows

Live telemetry

Every stroke, drawn as it happens.

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

What took real work.

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.

write 1 byte → 0x0034
0x0080pacepowerstroke rate

Mutual exclusion on 0x0080

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.

No native bridge

An MIT-licensed BLE library behind ErgConnection, instead of maintaining two native codebases in Swift and Kotlin.

1 interface · 0 native code

Real reconnection

GATT 133, backoff, resubscription and the “PM5 busy” case. The order of operations is mandatory, or the connection dies silently.

GATT 133 → backoff → resub

Crash recovery

A per-session write-ahead log: if the app dies halfway through a 2k, the piece isn't lost.

WAL per session

Radio input

2 out of range · logged, not discarded

BLE is untrusted input

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.

Health data

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.

Row-level RLS · in-app deletion
0
transports in a single emulator: WebSocket and BLE peripheral
0
packages in the monorepo, with boundaries enforced by lint
0
hours of hardware needed to develop and run CI
0
session with the real machine, and it was enough

Yours could be next

We build the system for you

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.