Concept2’s PM5 monitor firmware can be updated via USB, but the official tool — Concept2 Utility — only runs on Windows. No Linux support, no documented protocol. I wanted to update my PM5 from my Debian machine, so I reverse engineered the Windows binary to figure out exactly what it does. I used the script to update my PM5 from v217.037 to v217.067.
The Reverse Engineering Setup
I used Ghidra as the disassembly and decompilation platform, combined with the Pi coding agent and a custom Ghidra skill I’ve been building. The skill lets Pi drive Ghidra directly — running scripts, querying the API, renaming functions, following cross-references — so the RE work becomes a conversation rather than manual click-through.
The target binary was Concept2 Utility.exe v7.18.00: a 64-bit Windows PE, compiled with MSVC, built on Qt 6. No PDB symbols, but MSVC RTTI was fully intact, which meant Ghidra’s auto-analysis recovered all the class names and vtables. Qt stores signal/slot prototypes as plain C strings with a numeric prefix, so grepping the string table for ^[12][a-zA-Z] immediately surfaced most of the interesting class and method names.
With that as a starting point, Pi was able to trace the full firmware update flow: from the initial API request through to the exact bytes written on the USB drive.
What the Utility Actually Does
The firmware update path is straightforward once you have the function names:
1. Firmware manifest API
The Utility fetches a JSON manifest from Concept2’s own API (FirmwareInfoJSON::requestLatest, 0x1401a50b0):
GET https://tech.concept2.com/api/firmware/latest?client=utility-windows
Authorization: Basic <base64(hardcoded credentials)>
The response is a data array where each element describes one firmware release — version, target hardware variant, status (public or internal), and a list of .7z download URLs.
2. USB drive validation
Before writing anything, C2UtilityMainWindow__removableDriveCandidateStatusChanged (0x14009ed00) checks three things via QStorageInfo:
- Filesystem type contains
"FAT"or"msdos"(FAT16 or FAT32) - Partition table type contains
"MBR"— GPT is explicitly rejected - Bus type contains
"USB"
3. Writing the USB drive
updateFlashDrive (0x140173740) iterates all applicable PM5 hardware variants, copies each .7z archive to Concept2/Firmware/ on the USB, then calls PmUnzip::extract to decompress the .bin files into the same flat directory. Multiple hardware variants (pm5_, pm5v2_, pm5v3_, pm5v5_, pm5v6_, etc.) all coexist in the same directory with no conflicts — each uses a distinct filename prefix.
The resulting layout looks like this:
<USB_ROOT>/
└── Concept2/
└── Firmware/
├── pm5v6_allbin_secure_R459B037.7z
├── pm5v6_bundle.bin
├── pm5v6_appint.bin
├── pm5v6_appext.bin
├── pm5v6_ldrint.bin
├── pm5v6_lupint.bin
├── pm5v6_font.bin
├── pm5v6_screen.bin
├── pm5v6_antbleapp.bin
├── pm5v6_antbleldr.bin
└── … (other hardware variants alongside)
The PM5 scans this directory, finds the *_bundle.bin matching its own hardware revision, reads the version from the bundle header, and prompts for confirmation before flashing.
The Script
All of the above is implemented in c2fw-updater: a single Python script with no dependencies beyond the standard library and p7zip.
Install p7zip:
sudo apt install p7zip-full
Format a USB drive and run the script:
# Format (replace /dev/sdX with your device)
sudo parted /dev/sdX mklabel msdos
sudo parted /dev/sdX mkpart primary fat32 1MiB 100%
sudo mkfs.fat -F32 -n C2FIRMWARE /dev/sdX1
sudo mount /dev/sdX1 /mnt/usb
# Download firmware and write to USB
python3 c2fw.py --usb /mnt/usb
sudo umount /mnt/usb
Then on the PM5: MENU → More Options → Utilities → Update PM5 Firmware, insert the USB, and follow the prompts.
By default the script downloads firmware for the rower PM5. To include other machines:
# All machine types (rower, SkiErg, BikeErg, Strength Erg)
python3 c2fw.py --usb /mnt/usb --all-machines
# SkiErg only
python3 c2fw.py --machine skierg --usb /mnt/usb
# List everything available, including beta firmware
python3 c2fw.py --list --all-machines --beta
The script caches downloaded archives locally (in ./concept2_firmware/ by default), so re-running to refresh the USB is fast if nothing has changed upstream.
Limitations & Known Issues
The script doesn’t create the entire USB layout. Specifically, The Concept2/Logbook/ directory is not created. The PM5 will report that there is an issue with the drive and offer to repair it. Performing the repair creates the necssary directories and allows to perform the firmware update. I hope to address that in a future update.