Reverse Engineering the Concept2 Utility to Update PM5 Firmware on Linux

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.

Web HR Monitor: Heart Rate and HRV in the Browser

I’ve been reading about HRV (Heart Rate Variability) for a while and wanted a way to record it on my computer and actually understand the underlying calculations, rather than just consume a number from a proprietary app. I also wanted an excuse to finally play with the Web Bluetooth API, which lets a browser connect directly to Bluetooth LE devices with no native app or server involved. The result is web-hr-monitor, a small app that runs entirely in the browser — no backend, no install.

The live version is at guyru.github.io/web-hr-monitor. Open it in Chrome or Edge, click Connect to HR Monitor, pair your BLE heart rate monitor, and you get a live readout along with running average, maximum, minimum, and a heart rate distribution histogram for the session.

HRV Analysis

The more interesting part is the HRV analysis. After connecting, you can run a 2-minute test that collects RR intervals — the time between successive heartbeats — and computes two standard metrics:

  • RMSSD (Root Mean Square of Successive Differences): measures short-term variability.
  • SDNN (Standard Deviation of NN intervals): measures overall variability.

I did most of my testing with a Polar H7, which worked well.

screenshot of HRV results panel with RMSSD/SDNN values

Synthesized RR Intervals

While testing I ran into something worth knowing: not all heart rate monitors report real RR intervals. Some devices don’t actually measure the time between beats — instead they synthesize a value as 60000 / HR. The result is mathematically consistent with the reported heart rate but has no actual beat-to-beat variability, making HRV analysis meaningless.

I discovered this when I switched from the Polar H7 to a Decathlon ANT+/Bluetooth heart rate monitor I had lying around. The app detects this automatically: if the reported RR intervals consistently fall within 1 ms of 60000 / HR, it warns you that the data is likely synthesized and HRV results are unreliable.

Browser Support

The Web Bluetooth API works in Chrome and Edge. Firefox and Safari don’t support it.

On Linux (and older Windows versions) you need to enable an experimental flag before it works:

  1. Open chrome://flags#enable-experimental-web-platform-features
  2. Enable the flag and restart the browser

On Windows 10/11 and Android it works out of the box. The app also requires an HTTPS connection — the GitHub Pages deployment covers that, and http://localhost works fine for local development.

The Cost of Currency Hedging in TASE Index Funds

I wanted to quantify the actual cost of currency hedging (USD/ILS) for index-tracking ETFs and mutual funds on the Tel Aviv Stock Exchange (TASE). Many Israeli investors face the choice between hedged (“מנוטרלת מט"ח”) and unhedged versions of the same fund, and the hedging cost is rarely stated explicitly by the fund providers.

Methodology

For each hedged/unhedged pair, I fetched historical closing prices from the TASE API and USD/ILS exchange rates from Yahoo Finance. The hedging cost is derived by comparing the hedged fund’s return to the unhedged fund’s return after stripping out the FX component:

  1. Compute daily returns for the hedged fund, the unhedged fund, and USD/ILS.
  2. The unhedged fund is priced in ILS but tracks a USD-denominated index, so its return already embeds FX moves. Dividing the cumulative unhedged return by the cumulative FX return gives us the “FX-stripped” return: what the unhedged fund earned in pure index terms.
  3. The difference between the hedged fund’s cumulative return and this FX-stripped return is the realized hedging cost.

A negative value means the hedged fund underperforms, i.e., hedging costs money.

I analyzed 11 fund pairs (8 ETFs and 3 mutual funds) covering S&P 500, Nasdaq 100, MSCI ACWI, and MSCI World indices, with data going back up to 5 years (the TASE API limit).

Results

The annualized hedging cost per fund pair, broken down by calendar year:

Instrument 2021 2022 2023 2024 2025 2026 Overall
קסם S&P 500 -1.24% -5.40% -3.77% -3.10% -2.63% -2.56% -3.30%
MTF S&P 500 -0.39% -4.69% -3.50% -2.36% -2.79% -1.22% -2.78%
אי.בי.אי S&P 500 -0.56% -1.13% -3.94% -1.29%
קסם Nasdaq 100 -2.31% -6.41% -3.92% -2.84% -3.88% -1.65% -3.83%
אי.בי.אי Nasdaq 100 -2.15% -3.45% -2.42% -2.83%
קסם MSCI ACWI +0.89% -7.17% -2.64% -2.18% -1.67% -2.78% -2.58%
קסם MSCI World -10.85% -21.83% -8.54% -4.33% -3.98% -3.45% -7.72%
MTF MSCI ACWI -2.60% -3.74% -1.42% -3.38%
MTF מחקה S&P 500 -0.04% -4.90% -3.98% -2.17% -3.23% -1.86% -2.97%
הראל מחקה S&P 500 -0.36% -5.60% -4.47% -2.65% -3.63% -1.83% -3.43%
אי.בי.אי מחקה S&P 500 -0.16% -4.76% -4.10% -2.11% -3.16% -1.93% -2.96%
Average -1.81% -7.59% -4.36% -2.46% -3.03% -2.28% -3.37%
Median -0.37% -5.50% -3.95% -2.36% -3.23% -1.93% -2.97%

The 2026 column covers only the first ~3 months (through March 2026) but is annualized for comparability.

The קסם MSCI World pair is a clear outlier with dramatically higher costs, suggesting issues beyond pure hedging (likely significant tracking error or structural differences between the hedged and unhedged versions of that specific product).

Annualized hedging cost by fund pair

The cumulative hedging cost over time shows a consistent downward drift across all instruments:

Cumulative hedging cost over time for ETFs and mutual funds

Takeaways

  • The median annualized hedging cost across all instruments and years was about -3%.
  • Hedging costs are not constant; they ranged from roughly -1% to -8%, depending on the year and the interest rate environment.
  • When choosing between hedged and unhedged TASE index funds, the hedging cost is a significant drag that should be weighed against your view on future ILS/USD exchange rate movements.

USB Keyboard Not Working in Dracut When Connected via Thunderbolt Dock

My USB keyboard stopped working during the Dracut initramfs phase (e.g., at the LUKS password prompt) when connected through a Thunderbolt dock. It worked fine in GRUB, in GNOME, and when plugged directly into the laptop. It had also worked through the dock before.

Why It Broke

GRUB is probably using UEFI/BIOS USB legacy emulation and doesn’t need the Thunderbolt controller at all. Dracut uses the real kernel driver stack, so the Thunderbolt controller needs to be initialized and authorized before the keyboard becomes visible.

Checking the Thunderbolt security level:

$ cat /sys/bus/thunderbolt/devices/domain0/security
user

The user level requires explicit device authorization. In GNOME, boltd handles this automatically. In Dracut, nothing does. Previously, the security level was none, but a firmware update changed it to user.

IOMMU DMA protection is still active independently:

$ cat /sys/bus/thunderbolt/devices/domain0/iommu_dma_protection
1

Why Not Boot ACL?

The ideal fix would be to enroll the dock in the firmware’s Boot ACL – pre-authorized devices stored in UEFI NVRAM that are authorized before the OS loads. However, boltctl domains showed bootacl: 0/0 – the firmware doesn’t support it.

The Fix: A Dracut-Only Udev Rule

The solution is a udev rule that auto-authorizes Thunderbolt devices during the initramfs phase only. We don’t want this rule in the running system, as it would bypass boltd‘s authorization logic in GNOME. The clean way is a small dracut module that carries the udev rule inside the initramfs.

Create the module directory and files:

$ sudo mkdir -p /usr/lib/dracut/modules.d/99thunderbolt-auth

$ sudo tee /usr/lib/dracut/modules.d/99thunderbolt-auth/99-thunderbolt-auto-auth.rules <<'EOF'
ACTION=="add", SUBSYSTEM=="thunderbolt", ATTR{authorized}=="0", ATTR{authorized}="1"
EOF

$ sudo tee /usr/lib/dracut/modules.d/99thunderbolt-auth/module-setup.sh <<'EOF'
#!/bin/bash
check() { return 0; }
depends() { return 0; }
install() {
    inst_simple "$moddir/99-thunderbolt-auto-auth.rules" 
        /etc/udev/rules.d/99-thunderbolt-auto-auth.rules
}
EOF

$ sudo chmod +x /usr/lib/dracut/modules.d/99thunderbolt-auth/module-setup.sh

Create the dracut config at /etc/dracut.conf.d/thunderbolt.conf:

add_dracutmodules+=" thunderbolt-auth "

Note that dracut module names in config files omit the numeric prefix – the directory is 99thunderbolt-auth but is referenced as thunderbolt-auth.

Rebuild the initramfs:

$ sudo dracut --force

Security Notes

The udev rule auto-authorizes Thunderbolt devices only during the brief Dracut window. In the running system, boltd continues to handle authorization normally. In both cases, IOMMU DMA protection remains active, which is the actual security boundary against malicious Thunderbolt devices.

Auto-switch power-profiles-daemon with udev

On GNOME with power-profiles-daemon, I wanted the power profile to switch between power-saver and performance based on AC power or battery. The solution is to let udev react to POWER_SUPPLY_ONLINE changes and call powerprofilesctl directly.

Configuration

Put the following rules in /etc/udev/rules.d/99-power-profile-switch.rules:

$ sudo tee /etc/udev/rules.d/99-power-profile-switch.rules >/dev/null <<'EOF'
# AC plugged in
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ENV{POWER_SUPPLY_ONLINE}=="1", ACTION=="change", RUN+="/usr/bin/powerprofilesctl set performance"

# On battery
SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ENV{POWER_SUPPLY_ONLINE}=="0", ACTION=="change", RUN+="/usr/bin/powerprofilesctl set power-saver"
EOF

Reload udev and trigger the change:

$ sudo udevadm control --reload-rules
$ sudo udevadm trigger --subsystem-match=power_supply

Verify

Unplug and plug the AC adapter back in, then check the active profile:

$ powerprofilesctl get

Stop Slack from Spamming systemd Journal Logs

Slack has been spamming my systemd journal logs with useless info-level debug logs. I’ve tried starting Slack with --silent and setting the log level to fatal using --logLevel, but the log spamming hasn’t stopped.

The solution is to filter these messages using rsyslog.

Create the following configuration under /etc/rsyslog.d/20-slack.conf:

# Drop info log messages from Slack
if $rawmsg contains "slack.desktop" then stop

Verify the configuration, then restart rsyslog:

$ sudo rsyslogd -N1
$ sudo systemctl restart rsyslog

Update: Slack used a different .desktop file for autostart, which didn’t have my -s -g fatal switches. I believe editing the ~/.config/autostart/slack.desktop file should be enough to fix the issue.

Expanding Encrypted Swap and Adding ZRAM to Fix Memory Pressure Stalls

Despite having 32GB of RAM on my Debian Unstable system, I was experiencing random stalls and freezes triggered by memory pressure alerts from psi-notify. Sometimes the system would recover after a few seconds, but often it required a hard reboot. The solution was to expand my existing 4GB encrypted swapfile to 16GB and add ZRAM for compressed swap in RAM.

This builds on my previous guide for setting up encrypted swap on Btrfs.

Expand Encrypted Swapfile

First, expand the existing encrypted swapfile from 4GB to 16GB:

# Turn off current swap
sudo swapoff /dev/mapper/swap
sudo systemctl stop systemd-cryptsetup@swap.service

# Remove old swap file
sudo rm /swap/swapfile

# Create new 16GB swap file
sudo btrfs filesystem mkswapfile --size 16g /swap/swapfile

# Reload and restart encrypted swap
sudo systemctl daemon-reload
sudo systemctl start systemd-cryptsetup@swap.service
sudo swapon /dev/mapper/swap

Add ZRAM for Compressed RAM Swap

Install ZRAM tools:

sudo apt update
sudo apt install -y zram-tools

Edit /etc/default/zramswap:

PERCENT=50       # 50% of RAM → ~16 GB compressed swap
ALGO=zstd        # fast and efficient
PRIORITY=100     # higher than disk swap

Enable and start the service:

sudo systemctl enable --now zramswap.service

Tune Swappiness

Lower swappiness so the kernel prefers RAM over swap:

sudo sysctl vm.swappiness=15
echo 'vm.swappiness=15' | sudo tee /etc/sysctl.d/99-swappiness.conf

Verify

Check that both swap devices are active:

sudo swapon --show
cat /proc/swaps

You should see both /dev/mapper/swap (encrypted disk swap) and /dev/zram0 (compressed RAM) listed as active swap devices.

captive-firefox: Solving Captive Portal Headaches with DNS over TLS

If you’re running systemd-resolved with DNS over TLS (like I detailed in my split DNS post), you’ve probably run into the same annoying problem I have: captive portals just don’t work properly.

The issue is straightforward but frustrating. Your system is configured to use secure DNS servers like Cloudflare (1.1.1.1) with DNS over TLS, which is great for security and privacy. But when you connect to that hotel Wi-Fi or coffee shop network, the captive portal can’t intercept your DNS queries to redirect you to their login page. Your requests bypass their DNS entirely, so you never see the portal.

The Manual Workaround (That Gets Old Fast)

The typical workaround involves giving the Wi-Fi network DNS priority and disabling DNS over TLS:

sudo resolvectl domain wlp0s20f3 "~." && sudo resolvectl dnsovertls wlp0s20f3 no
# Connect to captive portal
# Log in manually
# Revert changes
sudo resolvectl domain wlp0s20f3 "" && sudo resolvectl dnsovertls wlp0s20f3 yes

This works, but it’s annoying for two reasons: you have to remember to revert the changes, and it affects your entire system’s DNS behavior instead of just the browser you need for the captive portal.

Enter captive-firefox

I got tired of this dance and wrote a simple Bash script that handles captive portals elegantly. The idea is simple: launch Firefox in a sandbox that uses the Wi-Fi network’s DNS server directly, bypassing your system’s DNS configuration entirely.

Here’s what the script does:

  1. Auto-detects your Wi-Fi interface (or you can specify it)
  2. Extracts the DHCP-provided DNS server from NetworkManager
  3. Launches Firefox in a firejail sandbox with that specific DNS
  4. Uses a completely isolated profile – no access to your regular browsing data

The result? Firefox can see the captive portal while your system maintains its secure DNS configuration.

Zero Dependencies, Maximum Convenience

The script requires only standard Linux tools you probably already have:

  • Bash
  • firejail (for sandboxing)
  • nmcli (NetworkManager CLI)
  • iw (wireless tools)
  • Firefox

No Go binaries to compile and no complex dependencies. Just copy the script and run it.

Usage

Most of the time, it’s as simple as:

./captive-firefox.sh

The script will auto-detect everything and open Firefox, pointing to the standard captive portal detection URL. For edge cases, you can specify the interface or target URL manually.

I’ve also included a .desktop file so you can launch it from your application menu when needed.

Security Considerations

The sandboxed Firefox instance:

  • Can’t access your real Firefox profile or data
  • Uses only the captive portal’s DNS (isolated from your secure setup)
  • Runs in a firejail container for additional isolation
  • Automatically cleans up when closed

Your main system’s DNS configuration remains untouched throughout the process.

Get It

The script is available on my GitHub: captive-firefox

Finally, a civilized way to deal with captive portals without compromising your DNS security setup.

Configuring LDAC Quality in PipeWire

You can set the LDAC quality to High, Standard, or Mobile, corresponding to 990/660/330 kbps. You can do it either statically or dynamically.

Static Configuration

Place the following configuration in ~/.config/wireplumber/wireplumber.conf.d/10-bluez.conf:

monitor.bluez.rules = [
  {
    matches = [
      {
        ## This matches all Bluetooth devices.
        device.name = "~bluez_card.*"
      }
    ]
    actions = {
      update-props = {
        bluez5.a2dp.ldac.quality = "sq"
      }
    }
  }
]

The value of quality can be set to either hq, sq, mq, or auto.

You can also change the match fragment to match only a specific device. Use the following command to list all currently available devices:

$ pw-cli ls | grep device.name

After changing the configuration, you’ll have to restart WirePlumber:

$ systemctl --user restart wireplumber

Dynamic Configuration

This method is less user-friendly. The first step is to find the id of the relevant output node. You can do this by examining the output of pw-cli ls or wpctl status. Make sure you pick the id of the corresponding node and not the device. Next, use pw-cli set-param to set the quality, for example:

$ pw-cli set-param 93 Props '{quality=0}'
Object: size 32, type Spa:Pod:Object:Param:Props (262146), id Spa:Enum:ParamId:Props (2)
  Prop: key Spa:Pod:Object:Param:Props:quality (269), flags 00000000
    Int 0

Here, 93 is our node id, and 0 corresponds to hq quality. Other possible values are -1 for auto, 0 for hq, 1 for sq, and 2 for mq.

Empirically Verifying Bitrate

You can deduce the actual bitrate by sniffing the Bluetooth traffic and analyzing the capture in Wireshark.

$ sudo btmon -w btsnoop.log
$ wireshark btsnoop.log

In Wireshark, go to Statistics -> Capture File Properties. There, you can see the average bits/s and compare it to the bitrate of the expected quality setting.

Tiered vs. Fixed Commissions in IBKR

This post compares the tiered and fixed commission models offered by Interactive Brokers for trading U.S.-denominated ETFs on the London Stock Exchange (LSE). Fixed commissions are set at 0.05% of the trade value, with a minimum charge of $4. In contrast, tiered commissions begin with a lower minimum of $1.70, but they incur additional exchange fees of 0.0045% of the trade value (with a minimum of £0.10) and a clearing fee of £0.06. While tiered pricing can be advantageous for very large trades, this aspect is less relevant for my trading needs.

In practice, for small trades up to approximately $7,200, tiered pricing tends to be slightly more beneficial, whereas fixed pricing becomes cheaper for trades exceeding that amount. However, the differences in cost remain relatively minor, as illustrated in the figures below.